@pyreon/mcp 0.12.15 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -17,152 +17,135 @@ export const API_REFERENCE: Record<string, ApiEntry> = {
17
17
  // @pyreon/reactivity
18
18
  // ═══════════════════════════════════════════════════════════════════════════
19
19
 
20
+ // <gen-docs:api-reference:start @pyreon/reactivity>
21
+
20
22
  'reactivity/signal': {
21
- signature: 'signal<T>(initialValue: T, options?: { name?: string }): Signal<T>',
23
+ signature: '<T>(initialValue: T, options?: { name?: string }) => Signal<T>',
22
24
  example: `const count = signal(0)
23
-
24
- // Read (subscribes to updates):
25
- count() // 0
26
-
27
- // Write:
28
- count.set(5) // sets to 5
29
-
30
- // Update:
25
+ count() // 0 (subscribes to updates)
26
+ count.set(5) // sets to 5
31
27
  count.update(n => n + 1) // 6
32
-
33
- // Read without subscribing:
34
- count.peek() // 6`,
35
- notes:
36
- 'Signals are callable functions, NOT .value getters. Components run once signal reads in JSX auto-subscribe. Optional { name } for debugging auto-injected by @pyreon/vite-plugin in dev mode.',
37
- mistakes: `- \`count.value\` Use \`count()\` to read
38
- - \`{count}\` in JSX Use \`{count()}\` to read (or let the compiler wrap it)
39
- - \`const [val, setVal] = signal(0)\` Not destructurable. Use \`const val = signal(0)\``,
28
+ count.peek() // 6 (does NOT subscribe)`,
29
+ notes: 'Create a reactive signal. The returned value is a CALLABLE FUNCTION — `count()` reads (and subscribes), `count.set(v)` writes, `count.update(fn)` derives, `count.peek()` reads without subscribing. This is NOT a `.value` getter/setter pattern (React/Vue) — Pyreon signals are functions. Optional `{ name }` for debugging; auto-injected by `@pyreon/vite-plugin` in dev mode. See also: computed, effect, batch.',
30
+ mistakes: `- \`count.value\` — does not exist. Use \`count()\` to read
31
+ - \`count = 5\` — reassigning the variable replaces the signal, does not write to it. Use \`count.set(5)\`
32
+ - \`signal(5)\` called with an argument after creation — reads and ignores the argument (dev mode warns). Use \`.set(5)\` to write
33
+ - \`const [val, setVal] = signal(0)\` signals are not destructurable tuples. The whole return value IS the signal
34
+ - \`{count}\` in JSX renders the signal function itself, not its value. Use \`{count()}\` or \`{() => count()}\`
35
+ - \`.peek()\` inside \`effect()\` / \`computed()\` bypasses tracking, creates stale reads. Only use \`.peek()\` for loop-prevention guards`,
40
36
  },
41
37
 
42
38
  'reactivity/computed': {
43
- signature:
44
- 'computed<T>(fn: () => T, options?: { equals?: (a: T, b: T) => boolean }): Computed<T>',
39
+ signature: '<T>(fn: () => T, options?: { equals?: (a: T, b: T) => boolean }) => Computed<T>',
45
40
  example: `const count = signal(0)
46
41
  const doubled = computed(() => count() * 2)
47
-
48
42
  doubled() // 0
49
43
  count.set(5)
50
44
  doubled() // 10`,
51
- notes:
52
- 'Dependencies auto-tracked. No dependency array needed. Memoized only recomputes when dependencies change.',
53
- mistakes: `- \`computed(() => count)\` Must call signal: \`computed(() => count())\`
54
- - Don't use for side effectsuse effect() instead`,
45
+ notes: 'Create a memoized derived value. Dependencies auto-tracked on each evaluation — no dependency array needed (unlike React `useMemo`). Only recomputes when a tracked signal actually changes. Custom `equals` function prevents downstream effects from firing on structurally-equal updates (default: `Object.is`). See also: signal, effect.',
46
+ mistakes: `- \`computed(() => count)\` must CALL the signal: \`computed(() => count())\`
47
+ - Using \`computed()\` for side effects use \`effect()\` instead; computed is for pure derivation
48
+ - Expecting \`computed()\` to re-run when a \`.peek()\`-read signal changes \`.peek()\` bypasses tracking`,
55
49
  },
56
50
 
57
51
  'reactivity/effect': {
58
- signature: 'effect(fn: () => (() => void) | void): () => void',
52
+ signature: '(fn: () => (() => void) | void) => () => void',
59
53
  example: `const count = signal(0)
60
-
61
- // Auto-tracks count() dependency:
62
54
  const dispose = effect(() => {
63
- console.log("Count is:", count())
64
- })
65
-
66
- // With onCleanup:
67
- effect(() => {
68
- const handler = () => console.log(count())
69
- window.addEventListener("resize", handler)
70
- onCleanup(() => window.removeEventListener("resize", handler))
55
+ console.log("Count:", count())
56
+ onCleanup(() => console.log("cleaning up"))
71
57
  })
72
-
73
- // Or return cleanup (also works):
58
+ // Or return cleanup directly:
74
59
  effect(() => {
75
60
  const handler = () => console.log(count())
76
61
  window.addEventListener("resize", handler)
77
62
  return () => window.removeEventListener("resize", handler)
78
63
  })`,
79
- notes:
80
- 'Returns a dispose function. Dependencies auto-tracked on each run. Use onCleanup() inside to register cleanup that runs before re-execution. For DOM-specific effects, use renderEffect().',
81
- mistakes: `- Don't pass a dependency array Pyreon auto-tracks
82
- - \`effect(() => { count })\` Must call: \`effect(() => { count() })\``,
64
+ notes: 'Run a side effect that auto-tracks signal dependencies and re-runs when they change. Returns a dispose function that unsubscribes. The effect function can return a cleanup callback (equivalent to calling `onCleanup()` inside the body) — the cleanup runs before each re-execution and on final dispose. For DOM-specific effects with lighter overhead, use `renderEffect()` instead. See also: onCleanup, computed, renderEffect.',
65
+ mistakes: `- Passing a dependency array Pyreon auto-tracks; no array needed
66
+ - \`effect(() => { count })\`must call the signal: \`effect(() => { count() })\`
67
+ - Nesting \`effect()\` inside \`effect()\` use \`computed()\` for derived values instead
68
+ - Creating signals inside an effect — they re-create on every run; create once outside`,
69
+ },
70
+
71
+ 'reactivity/batch': {
72
+ signature: '(fn: () => void) => void',
73
+ example: `const a = signal(1)
74
+ const b = signal(2)
75
+ batch(() => {
76
+ a.set(10)
77
+ b.set(20)
78
+ })
79
+ // Effects that read both a() and b() fire once, not twice`,
80
+ notes: 'Group multiple signal writes so subscribers fire only once — after the batch completes. Uses pointer swap (zero allocation). Essential when updating 3+ signals that downstream effects read together; without batch, each `.set()` triggers an independent notification pass. See also: signal, effect.',
81
+ mistakes: `- Reading a signal inside \`batch()\` and expecting the NEW value before the batch completes — reads inside the batch see the new value (writes are synchronous), but effects fire only after the batch callback returns
82
+ - Forgetting \`batch()\` when updating 3+ related signals — causes N intermediate re-renders`,
83
83
  },
84
84
 
85
85
  'reactivity/onCleanup': {
86
- signature: 'onCleanup(fn: () => void): void',
86
+ signature: '(fn: () => void) => void',
87
87
  example: `effect(() => {
88
88
  const handler = () => console.log(count())
89
89
  window.addEventListener("resize", handler)
90
90
  onCleanup(() => window.removeEventListener("resize", handler))
91
91
  })`,
92
- notes:
93
- 'Registers a cleanup function inside an effect. Runs between re-executions (before the effect re-runs) and when the effect is disposed.',
94
- mistakes: `- Using onCleanup outside an effect it only works inside effect() or renderEffect()
95
- - Confusing with onUnmount — onCleanup is for effects, onUnmount is for components`,
92
+ notes: 'Register a cleanup function inside an `effect()` or `renderEffect()`. Runs before each re-execution of the effect (when dependencies change) and once on final dispose. Equivalent to returning a cleanup function from the effect body — both forms work, `onCleanup` is useful when you need to register cleanup at a different point than the end of the body. See also: effect.',
93
+ mistakes: `- Using \`onCleanup\` outside an effect it only works inside \`effect()\` or \`renderEffect()\` body
94
+ - Confusing with \`onUnmount\` \`onCleanup\` is for effects, \`onUnmount\` is for component lifecycle`,
96
95
  },
97
96
 
98
- 'reactivity/batch': {
99
- signature: 'batch(fn: () => void): void',
100
- example: `const a = signal(1)
101
- const b = signal(2)
102
-
103
- // Updates subscribers only once:
104
- batch(() => {
105
- a.set(10)
106
- b.set(20)
97
+ 'reactivity/watch': {
98
+ signature: '<T>(source: () => T, callback: (next: T, prev: T) => void, options?: WatchOptions) => () => void',
99
+ example: `watch(() => count(), (next, prev) => {
100
+ console.log(\`changed from \${prev} to \${next}\`)
107
101
  })`,
108
- notes: 'Defers all signal notifications until the batch completes. Nested batches are merged.',
102
+ notes: 'Explicit reactive watcher tracks `source` and fires `callback` when it changes. Unlike `effect()`, the callback receives both `next` and `prev` values and does NOT auto-track signals read inside the callback body. `source` is evaluated at setup time to establish tracking; reading browser globals there still fires SSR lint rules. Returns a dispose function. See also: effect, computed.',
103
+ mistakes: `- Reading browser globals in the \`source\` function — it runs at setup time (not just in mounted context), so \`no-window-in-ssr\` fires on \`window.X\` there
104
+ - Expecting signals read inside the \`callback\` to be tracked — only the \`source\` function establishes tracking; the callback is untracked`,
109
105
  },
110
106
 
111
107
  'reactivity/createStore': {
112
- signature: 'createStore<T extends object>(initialValue: T): T',
108
+ signature: '<T extends object>(initial: T) => T',
113
109
  example: `const store = createStore({
114
- user: { name: "Alice", age: 30 },
115
- items: [1, 2, 3]
110
+ todos: [{ text: 'Learn Pyreon', done: false }],
111
+ filter: 'all',
116
112
  })
117
-
118
- // Granular reactivity only rerenders what changed:
119
- store.user.name = "Bob" // only name subscribers fire
120
- store.items.push(4) // only items subscribers fire`,
121
- notes:
122
- 'Deep proxy nested objects are automatically reactive. Use reconcile() for bulk updates.',
123
- },
124
-
125
- 'reactivity/createResource': {
126
- signature:
127
- 'createResource<T>(fetcher: () => Promise<T>, options?: ResourceOptions): Resource<T>',
128
- example: `const users = createResource(() => fetch("/api/users").then(r => r.json()))
129
-
130
- // In JSX:
131
- <Show when={!users.loading()}>
132
- <For each={users()} by={u => u.id}>
133
- {user => <li>{user.name}</li>}
134
- </For>
135
- </Show>`,
136
- notes:
137
- 'Integrates with Suspense. Access .loading(), .error(), and call resource() for the value.',
113
+ store.todos[0].done = true // fine-grained — only 'done' subscribers fire
114
+ store.todos.push({ text: 'Build app', done: false }) // array methods work`,
115
+ notes: 'Create a deeply reactive proxy-based object. Mutations at any depth trigger fine-grained updates — `store.todos[0].done = true` only re-runs effects that read `store.todos[0].done`, not effects that read `store.todos.length` or other items. No immer, no spread-copy, no `produce()` — just mutate. Works with nested objects, arrays, Maps, and Sets. See also: signal.',
116
+ mistakes: `- Replacing the entire store object — \`store = { ... }\` replaces the variable, not the proxy. Mutate properties instead: \`store.filter = "active"\`
117
+ - Destructuring store properties at setup — \`const { filter } = store\` captures the value once, losing reactivity. Read \`store.filter\` inside reactive scopes
118
+ - Using \`createStore\` for simple scalar state use \`signal()\` for primitives; \`createStore\` adds proxy overhead that only pays off for nested objects`,
138
119
  },
139
120
 
140
121
  'reactivity/untrack': {
141
- signature: 'untrack<T>(fn: () => T): T',
142
- example: `import { untrack } from "@pyreon/reactivity"
143
-
144
- // Read signals without subscribing:
145
- effect(() => {
146
- const name = untrack(() => userName())
147
- console.log("Count changed:", count(), "user is", name)
122
+ signature: '(fn: () => T) => T',
123
+ example: `effect(() => {
124
+ const current = count() // tracked — effect re-runs on count change
125
+ const other = untrack(() => otherSignal()) // NOT tracked just reads the current value
148
126
  })`,
149
- notes:
150
- 'Alias for runUntracked. Reads signals inside fn without adding them as dependencies of the current effect/computed.',
127
+ notes: `Execute a function reading signals WITHOUT subscribing to them. Alias for \`runUntracked\`. Use inside effects when you need to read a signal's current value as a one-shot snapshot without the effect re-running when that signal changes. See also: signal, effect.`,
128
+ mistakes: '- Using `untrack` as the default — signals should be tracked by default; `untrack` is the escape hatch for specific optimization or loop-prevention cases',
151
129
  },
130
+ // <gen-docs:api-reference:end @pyreon/reactivity>
152
131
 
153
132
  // ═══════════════════════════════════════════════════════════════════════════
154
133
  // @pyreon/core
155
134
  // ═══════════════════════════════════════════════════════════════════════════
156
135
 
136
+ // <gen-docs:api-reference:start @pyreon/core>
137
+
157
138
  'core/h': {
158
- signature:
159
- 'h<P>(type: ComponentFn<P> | string | symbol, props: P | null, ...children: VNodeChild[]): VNode',
160
- example: `// Usually use JSX instead:
161
- const vnode = h("div", { class: "container" },
139
+ signature: 'h<P extends Props>(type: ComponentFn<P> | string | symbol, props: P | null, ...children: VNodeChild[]): VNode',
140
+ example: `const vnode = h("div", { class: "container" },
162
141
  h("h1", null, "Hello"),
163
142
  h(Counter, { initial: 0 })
164
143
  )`,
165
- notes: 'Low-level API. Prefer JSX which compiles to h() calls (or _tpl() for templates).',
144
+ notes: 'Create a VNode from a component function, HTML tag string, or symbol (Fragment, Portal). Low-level API prefer JSX which compiles to `h()` calls (or `_tpl()` + `_bind()` for template-optimized paths). Children are stored in `vnode.children`; components must merge them via `props.children = vnode.children.length === 1 ? vnode.children[0] : vnode.children`. See also: Fragment, Dynamic, lazy.',
145
+ mistakes: `- \`h("div", "text")\` — second arg is always props (or null). Text children go in the third+ positions: \`h("div", null, "text")\`
146
+ - \`h(MyComponent, { children: <span /> })\` — children go as rest args, not a prop: \`h(MyComponent, null, <span />)\`
147
+ - \`h("input", { className: "x" })\` — use \`class\` not \`className\` (Pyreon uses standard HTML attributes)
148
+ - \`h("input", { onChange: handler })\` — use \`onInput\` for keypress-by-keypress updates (native DOM events)`,
166
149
  },
167
150
 
168
151
  'core/Fragment': {
@@ -175,6 +158,7 @@ const vnode = h("div", { class: "container" },
175
158
 
176
159
  // h() API:
177
160
  h(Fragment, null, h("h1", null, "Title"), h("p", null, "Content"))`,
161
+ notes: 'Symbol used as the type for fragment VNodes that group children without producing a wrapper DOM element. In JSX, `<>...</>` compiles to `h(Fragment, null, ...)`. Useful when a component needs to return multiple sibling elements. See also: h.',
178
162
  },
179
163
 
180
164
  'core/onMount': {
@@ -184,13 +168,16 @@ h(Fragment, null, h("h1", null, "Title"), h("p", null, "Content"))`,
184
168
 
185
169
  onMount(() => {
186
170
  const id = setInterval(() => count.update(n => n + 1), 1000)
187
- return () => clearInterval(id) // cleanup
171
+ return () => clearInterval(id) // cleanup on unmount
188
172
  })
189
173
 
190
- return <div>{count()}</div>
174
+ return <div>{() => count()}</div>
191
175
  }`,
192
- notes: 'Optionally return a cleanup function that runs on unmount.',
193
- mistakes: `- Forgetting cleanup: \`onMount(() => { const id = setInterval(...) })\` Return cleanup: \`onMount(() => { const id = setInterval(...); return () => clearInterval(id) })\``,
176
+ notes: 'Register a callback that runs after the component mounts into the DOM. The callback can optionally return a cleanup function that runs on unmount — this is the idiomatic pattern for event listeners, timers, and subscriptions. Must be called during component setup (the synchronous function body), not inside effects or async callbacks. See also: onUnmount, onUpdate.',
177
+ mistakes: `- Forgetting cleanup: \`onMount(() => { const id = setInterval(...) })\` leaks the interval. Return cleanup: \`return () => clearInterval(id)\`
178
+ - Using \`onMount\` + separate \`onUnmount\` for paired setup/teardown — prefer returning cleanup from \`onMount\` instead
179
+ - Calling \`onMount\` inside an \`effect()\` or async callback — it only works during synchronous component setup
180
+ - Accessing DOM refs before mount — the callback runs AFTER mount, which is the right place for DOM measurements`,
194
181
  },
195
182
 
196
183
  'core/onUnmount': {
@@ -198,62 +185,121 @@ h(Fragment, null, h("h1", null, "Title"), h("p", null, "Content"))`,
198
185
  example: `onUnmount(() => {
199
186
  console.log("Component removed from DOM")
200
187
  })`,
188
+ notes: 'Register a callback that runs when the component is removed from the DOM. For paired setup/teardown, prefer returning a cleanup function from `onMount` instead — it co-locates the cleanup with the setup. `onUnmount` is useful when cleanup needs to reference state computed separately from the mount callback. See also: onMount.',
189
+ },
190
+
191
+ 'core/onUpdate': {
192
+ signature: 'onUpdate(fn: () => void): void',
193
+ example: `onUpdate(() => {
194
+ console.log("Component updated, DOM is current")
195
+ })`,
196
+ notes: 'Register a callback that runs after the component updates (reactive dependencies change and DOM patches complete). Rarely needed — most update logic belongs in `effect()` or `computed()`. Useful for imperative DOM measurements that need to run after all reactive updates have flushed. See also: onMount, onUnmount.',
197
+ },
198
+
199
+ 'core/onErrorCaptured': {
200
+ signature: 'onErrorCaptured(fn: (error: unknown) => boolean | void): void',
201
+ example: `onErrorCaptured((error) => {
202
+ console.error("Caught:", error)
203
+ return false // stop propagation
204
+ })`,
205
+ notes: 'Register an error handler that captures errors thrown by descendant components. Return `false` to prevent the error from propagating further up the tree. Works alongside `ErrorBoundary` for programmatic error handling. See also: ErrorBoundary.',
201
206
  },
202
207
 
203
208
  'core/createContext': {
204
209
  signature: 'createContext<T>(defaultValue: T): Context<T>',
205
- example: `const ThemeContext = createContext<"light" | "dark">("light")
210
+ example: `const ThemeCtx = createContext<"light" | "dark">("light")
206
211
 
207
212
  // Provide:
208
213
  const App = () => {
209
- provide(ThemeContext, "dark")
214
+ provide(ThemeCtx, "dark")
210
215
  return <Child />
211
216
  }
212
217
 
213
218
  // Consume:
214
219
  const Child = () => {
215
- const theme = useContext(ThemeContext)
220
+ const theme = useContext(ThemeCtx) // "dark" — safe to destructure
216
221
  return <div class={theme}>...</div>
217
222
  }`,
223
+ notes: 'Create a static context. `useContext()` returns the value directly (`T`), so it is safe to destructure. Use this for values that do not change after being provided (theme name, locale string, config object). For values that change reactively (mode signal, locale signal), use `createReactiveContext` instead — otherwise consumers capture a stale snapshot at setup time. See also: createReactiveContext, provide, useContext.',
224
+ mistakes: `- \`provide(ThemeCtx, () => modeSignal())\` with a static context — the consumer receives the function itself, not the signal value. Use \`createReactiveContext\` for dynamic values
225
+ - Destructuring a reactive context value: \`const { mode } = useContext(reactiveCtx)\` captures once. Keep the object reference and access lazily
226
+ - Calling \`useContext\` outside a component body — it reads from the component context stack, which only exists during setup`,
218
227
  },
219
228
 
220
- 'core/useContext': {
221
- signature: 'useContext<T>(ctx: Context<T>): T',
222
- example: `const theme = useContext(ThemeContext) // returns provided value or default`,
229
+ 'core/createReactiveContext': {
230
+ signature: 'createReactiveContext<T>(defaultValue: T): ReactiveContext<T>',
231
+ example: `const ModeCtx = createReactiveContext<"light" | "dark">("light")
232
+
233
+ // Provide:
234
+ const App = () => {
235
+ const mode = signal<"light" | "dark">("dark")
236
+ provide(ModeCtx, () => mode())
237
+ return <Child />
238
+ }
239
+
240
+ // Consume:
241
+ const Child = () => {
242
+ const getMode = useContext(ModeCtx) // () => "dark"
243
+ return <div class={getMode()}>...</div>
244
+ }`,
245
+ notes: 'Create a reactive context. `useContext()` returns `() => T` — an accessor that must be called to read the current value. Use this for values that change over time (mode, locale, user). The accessor subscribes to updates when read inside reactive scopes (`effect()`, JSX thunks, `computed()`). See also: createContext, provide, useContext.',
223
246
  },
224
247
 
225
248
  'core/provide': {
226
- signature: 'provide<T>(ctx: Context<T>, value: T): void',
249
+ signature: 'provide<T>(ctx: Context<T> | ReactiveContext<T>, value: T): void',
227
250
  example: `const ThemeCtx = createContext<"light" | "dark">("light")
228
251
 
229
252
  function App() {
230
253
  provide(ThemeCtx, "dark")
231
254
  return <Child />
232
255
  }`,
233
- notes:
234
- 'Pushes a context value and auto-cleans up on unmount. Preferred over manual pushContext/popContext. Must be called during component setup.',
256
+ notes: 'Push a context value for all descendant components. Auto-cleans up on unmount. Must be called during component setup (synchronous function body). Preferred over manual `pushContext`/`popContext`. For reactive values, provide a getter function to a `ReactiveContext`: `provide(ModeCtx, () => modeSignal())`. See also: createContext, createReactiveContext, useContext.',
257
+ mistakes: `- \`provide(ctx, "static")\` for a value that changes use \`createReactiveContext\` + \`provide(ctx, () => signal())\`
258
+ - Calling \`provide\` inside \`onMount\` or \`effect\` — it must run during synchronous component setup
259
+ - Providing the same context twice in one component — the second \`provide\` shadows the first for that subtree`,
235
260
  },
236
261
 
237
- 'core/ExtractProps': {
238
- signature: 'type ExtractProps<T> = T extends ComponentFn<infer P> ? P : T',
239
- example: `const Greet: ComponentFn<{ name: string }> = ({ name }) => <h1>{name}</h1>
240
-
241
- type Props = ExtractProps<typeof Greet>
242
- // { name: string }`,
243
- notes:
244
- 'Extracts the props type from a ComponentFn. Passes through unchanged if T is not a ComponentFn.',
262
+ 'core/useContext': {
263
+ signature: 'useContext<T>(ctx: Context<T>): T',
264
+ example: `const theme = useContext(ThemeContext) // static: returns T
265
+ const getMode = useContext(ModeCtx) // reactive: returns () => T`,
266
+ notes: 'Read the nearest provided value for a context. For static `Context<T>`, returns `T` directly. For `ReactiveContext<T>`, returns `() => T` — must call the accessor to read. Falls back to the default value if no ancestor provides the context. See also: provide, createContext, createReactiveContext.',
245
267
  },
246
268
 
247
- 'core/HigherOrderComponent': {
248
- signature: 'type HigherOrderComponent<HOP, P> = ComponentFn<HOP & P>',
249
- example: `function withLogger<P>(Wrapped: ComponentFn<P>): HigherOrderComponent<{ logLevel?: string }, P> {
250
- return (props) => {
251
- console.log(\`[\${props.logLevel ?? "info"}] Rendering\`)
252
- return <Wrapped {...props} />
253
- }
254
- }`,
255
- notes:
256
- "Typed HOC pattern — HOP is the props the HOC adds, P is the wrapped component's own props.",
269
+ 'core/Show': {
270
+ signature: '<Show when={condition} fallback={alternative}>{children}</Show>',
271
+ example: `<Show when={isLoggedIn()} fallback={<LoginForm />}>
272
+ <Dashboard />
273
+ </Show>`,
274
+ notes: 'Reactive conditional rendering. Mounts children when `when` is truthy, unmounts and shows `fallback` when falsy. More efficient than ternary for signal-driven conditions because it avoids re-evaluating the entire branch expression on every signal change — `Show` only transitions between mounted/unmounted when the boolean flips. See also: Switch, Match, For.',
275
+ mistakes: `- \`{cond() ? <A /> : <B />}\` — works but less efficient than \`<Show>\` for signal-driven conditions
276
+ - \`<Show when={items().length}>\` — works (truthy check), but be explicit: \`<Show when={items().length > 0}>\`
277
+ - \`<Show when={user}>\` without calling the signal — must call: \`<Show when={user()}>\``,
278
+ },
279
+
280
+ 'core/Switch': {
281
+ signature: '<Switch fallback={default}>{Match children}</Switch>',
282
+ example: `<Switch fallback={<p>Unknown status</p>}>
283
+ <Match when={status() === "loading"}>
284
+ <Spinner />
285
+ </Match>
286
+ <Match when={status() === "error"}>
287
+ <ErrorDisplay />
288
+ </Match>
289
+ <Match when={status() === "success"}>
290
+ <Results />
291
+ </Match>
292
+ </Switch>`,
293
+ notes: 'Multi-branch conditional rendering. Renders the first `<Match>` child whose `when` prop is truthy. If no match, renders the `fallback`. More readable than nested `<Show>` for multi-way conditions. See also: Match, Show.',
294
+ },
295
+
296
+ 'core/Match': {
297
+ signature: '<Match when={condition}>{children}</Match>',
298
+ example: `<Switch>
299
+ <Match when={tab() === "home"}><Home /></Match>
300
+ <Match when={tab() === "settings"}><Settings /></Match>
301
+ </Switch>`,
302
+ notes: 'A branch inside a `<Switch>`. Renders its children when `when` is truthy and it is the first truthy `<Match>` in the parent `<Switch>`. Must be a direct child of `<Switch>`. See also: Switch, Show.',
257
303
  },
258
304
 
259
305
  'core/For': {
@@ -264,21 +310,13 @@ type Props = ExtractProps<typeof Greet>
264
310
  ])
265
311
 
266
312
  <For each={items()} by={item => item.id}>
267
- {item => <li>{item.name}</li>}
313
+ {(item, index) => <li>{item.name}</li>}
268
314
  </For>`,
269
- notes: "Uses 'by' prop (not 'key') because JSX extracts 'key' as a special VNode prop.",
270
- mistakes: `- \`<For each={items}>\` Must call signal: \`<For each={items()}>\`
271
- - \`<For each={items()} key={...}>\` Use \`by\` not \`key\`
272
- - \`{items().map(...)}\` Use <For> for reactive list rendering`,
273
- },
274
-
275
- 'core/Show': {
276
- signature: '<Show when={condition} fallback={alternative}>{children}</Show>',
277
- example: `<Show when={isLoggedIn()} fallback={<LoginForm />}>
278
- <Dashboard />
279
- </Show>`,
280
- notes:
281
- 'More efficient than ternary for signal-driven conditions. Only mounts/unmounts when condition changes.',
315
+ notes: 'Keyed reactive list rendering. Uses the `by` prop (not `key`) for the key function because JSX extracts `key` as a special VNode reconciliation prop. The render function receives each item and its index. Internally uses an LIS-based reconciler for minimal DOM mutations when the list changes. See also: Show, mapArray.',
316
+ mistakes: `- \`<For each={items}>\` must call the signal: \`<For each={items()}>\`
317
+ - \`<For each={items()} key={...}>\` use \`by\` not \`key\` (JSX reserves \`key\` for VNode reconciliation)
318
+ - \`{items().map(...)}\` use \`<For>\` for reactive list rendering; \`.map()\` re-creates all DOM nodes on every change
319
+ - \`<For each={items()} by={index}>\` — using array index as key defeats the reconciler; use a stable identity like \`item.id\``,
282
320
  },
283
321
 
284
322
  'core/Suspense': {
@@ -288,17 +326,29 @@ type Props = ExtractProps<typeof Greet>
288
326
  <Suspense fallback={<div>Loading...</div>}>
289
327
  <LazyPage />
290
328
  </Suspense>`,
329
+ notes: 'Async boundary that shows `fallback` while any `lazy()` component or async child inside is loading. SSR mode streams the fallback immediately and swaps in the resolved content when ready (30s timeout). Nested Suspense boundaries are independent — an inner boundary resolving does not affect the outer. See also: lazy, ErrorBoundary.',
330
+ },
331
+
332
+ 'core/ErrorBoundary': {
333
+ signature: '<ErrorBoundary onCatch={handler} fallback={errorUI}>{children}</ErrorBoundary>',
334
+ example: `<ErrorBoundary
335
+ onCatch={(err) => console.error(err)}
336
+ fallback={(err) => <div>Error: {err.message}</div>}
337
+ >
338
+ <App />
339
+ </ErrorBoundary>`,
340
+ notes: 'Catches render errors thrown by descendant components. The `fallback` receives the error object for display. `onCatch` fires with the error for logging/telemetry. Without an ErrorBoundary, uncaught errors propagate to the nearest `registerErrorHandler` or crash the app. See also: Suspense, onErrorCaptured.',
291
341
  },
292
342
 
293
343
  'core/lazy': {
294
- signature:
295
- 'lazy(loader: () => Promise<{ default: ComponentFn }>, options?: LazyOptions): LazyComponent',
344
+ signature: 'lazy(loader: () => Promise<{ default: ComponentFn }>, options?: LazyOptions): LazyComponent',
296
345
  example: `const Settings = lazy(() => import("./pages/Settings"))
297
346
 
298
347
  // Use in JSX (wrap with Suspense):
299
348
  <Suspense fallback={<Spinner />}>
300
349
  <Settings />
301
350
  </Suspense>`,
351
+ notes: 'Wrap a dynamic import for code splitting. Returns a component that integrates with `Suspense` — the parent Suspense boundary shows its fallback until the import resolves. The loaded component is cached after first resolution. See also: Suspense, Dynamic.',
302
352
  },
303
353
 
304
354
  'core/Dynamic': {
@@ -307,23 +357,12 @@ type Props = ExtractProps<typeof Greet>
307
357
  const current = signal("home")
308
358
 
309
359
  <Dynamic component={components[current()]} />`,
310
- },
311
-
312
- 'core/ErrorBoundary': {
313
- signature: '<ErrorBoundary onCatch={handler} fallback={errorUI}>{children}</ErrorBoundary>',
314
- example: `<ErrorBoundary
315
- onCatch={(err) => console.error(err)}
316
- fallback={(err) => <div>Error: {err.message}</div>}
317
- >
318
- <App />
319
- </ErrorBoundary>`,
360
+ notes: 'Renders a component by reference or string tag name. Useful when the component to render is determined at runtime (tab panels, plugin systems, polymorphic containers). When `component` changes, the previous component unmounts and the new one mounts. See also: lazy, h.',
320
361
  },
321
362
 
322
363
  'core/cx': {
323
364
  signature: 'cx(...values: ClassValue[]): string',
324
- example: `import { cx } from "@pyreon/core"
325
-
326
- cx("foo", "bar") // "foo bar"
365
+ example: `cx("foo", "bar") // "foo bar"
327
366
  cx("base", isActive && "active") // conditional
328
367
  cx({ base: true, active: isActive() }) // object syntax
329
368
  cx(["a", ["b", { c: true }]]) // nested arrays
@@ -331,43 +370,35 @@ cx(["a", ["b", { c: true }]]) // nested arrays
331
370
  // class prop accepts ClassValue directly:
332
371
  <div class={["base", cond && "active"]} />
333
372
  <div class={{ base: true, active: isActive() }} />`,
334
- notes:
335
- 'Combines class values into a single string. Accepts strings, booleans, objects, arrays (nested). Falsy values are ignored. ClassValue type is also exported from @pyreon/core.',
336
- mistakes: `- \`class={cx(...)}\` works but is redundant — class prop already accepts ClassValue
337
- - \`class={condition ? "a" : undefined}\` → Use \`class={[condition && "a"]}\` or \`class={{ a: condition }}\``,
373
+ notes: 'Combine class values into a single string. Accepts strings, booleans (falsy values ignored), objects (`{ active: true }`), and arrays (nested). The `class` prop on JSX elements already accepts `ClassValue` directly, so explicit `cx()` is only needed when building class strings outside JSX or when composing values from multiple sources. See also: splitProps, mergeProps.',
338
374
  },
339
375
 
340
376
  'core/splitProps': {
341
377
  signature: 'splitProps<T, K extends keyof T>(props: T, keys: K[]): [Pick<T, K>, Omit<T, K>]',
342
- example: `import { splitProps } from "@pyreon/core"
343
-
344
- const Button = (props: { class?: string; onClick: () => void; children: VNodeChild }) => {
378
+ example: `const Button = (props: { class?: string; onClick: () => void; children: VNodeChild }) => {
345
379
  const [local, rest] = splitProps(props, ["class"])
346
380
  return <button {...rest} class={cx("btn", local.class)} />
347
381
  }`,
348
- notes:
349
- 'Splits a props object into two: picked keys and the rest. Preserves signal reactivity on both halves.',
350
- mistakes: `- Destructuring props directly breaks reactivity use splitProps instead
351
- - \`const { class: cls, ...rest } = props\` \`const [local, rest] = splitProps(props, ["class"])\``,
382
+ notes: 'Split a props object into two parts: the picked keys and the rest. Both halves preserve signal reactivity — reads through either half still track the original reactive prop getters. This is the Pyreon replacement for `const { x, ...rest } = props` destructuring, which captures values once and loses reactivity. See also: mergeProps, cx.',
383
+ mistakes: `- \`const { class: cls, ...rest } = props\` destructuring captures once, loses reactivity. Use \`splitProps(props, ["class"])\`
384
+ - Passing a non-props object \`splitProps\` relies on reactive getter descriptors that the compiler creates on props objects
385
+ - Forgetting that symbol-keyed props are preserved \`splitProps\` uses \`Reflect.ownKeys\` so symbols (like \`REACTIVE_PROP\`) survive`,
352
386
  },
353
387
 
354
388
  'core/mergeProps': {
355
389
  signature: 'mergeProps<T extends object[]>(...sources: T): MergedProps<T>',
356
- example: `import { mergeProps } from "@pyreon/core"
357
-
358
- const Button = (props: { size?: string; variant?: string }) => {
390
+ example: `const Button = (props: { size?: string; variant?: string }) => {
359
391
  const merged = mergeProps({ size: "md", variant: "primary" }, props)
360
392
  return <button class={\`btn-\${merged.size} btn-\${merged.variant}\`} />
361
393
  }`,
362
- notes:
363
- 'Merges multiple props objects. Last source wins for each key. Preserves reactivity reads are lazy.',
394
+ notes: 'Merge multiple props objects with last-source-wins semantics. Reads are lazy — the merged object delegates to the source objects via getters, so signal reactivity is preserved. Commonly used to inject default props: `mergeProps({ size: "md" }, props)`. Forces `configurable: true` on copied descriptors to prevent "Cannot redefine property" errors. See also: splitProps, cx.',
395
+ mistakes: `- \`Object.assign({}, defaults, props)\` loses reactivity. Use \`mergeProps(defaults, props)\` instead
396
+ - \`mergeProps(props, defaults)\` — wrong order. Defaults go FIRST, actual props last (last source wins)`,
364
397
  },
365
398
 
366
399
  'core/createUniqueId': {
367
400
  signature: 'createUniqueId(): string',
368
- example: `import { createUniqueId } from "@pyreon/core"
369
-
370
- const LabeledInput = (props: { label: string }) => {
401
+ example: `const LabeledInput = (props: { label: string }) => {
371
402
  const id = createUniqueId()
372
403
  return (
373
404
  <>
@@ -376,14 +407,69 @@ const LabeledInput = (props: { label: string }) => {
376
407
  </>
377
408
  )
378
409
  }`,
379
- notes:
380
- 'Returns a unique string ID ("pyreon-1", "pyreon-2", etc.). SSR-safe — IDs are consistent between server and client when called in the same order.',
410
+ notes: 'Generate a unique string ID ("pyreon-1", "pyreon-2", ...) that is consistent between server and client when called in the same order. SSR-safe — the counter resets per request context. Use for `id`/`for`/`aria-*` attribute pairing in components. See also: splitProps.',
411
+ },
412
+
413
+ 'core/Portal': {
414
+ signature: '<Portal target={element}>{children}</Portal>',
415
+ example: `<Portal target={document.body}>
416
+ <div class="modal-overlay">
417
+ <div class="modal">Content</div>
418
+ </div>
419
+ </Portal>`,
420
+ notes: 'Render children into a DOM element outside the component tree (typically `document.body`). Useful for modals, tooltips, and overlays that need to escape parent overflow/z-index stacking contexts. Context values from the Portal source tree are preserved. See also: Dynamic.',
421
+ },
422
+
423
+ 'core/mapArray': {
424
+ signature: 'mapArray<T, U>(list: () => T[], mapFn: (item: T, index: () => number) => U): () => U[]',
425
+ example: `const items = signal([1, 2, 3])
426
+ const doubled = mapArray(() => items(), (item) => item * 2)
427
+ // doubled() → [2, 4, 6] — updates reactively`,
428
+ notes: 'Low-level reactive array mapping used internally by `<For>`. Maps a reactive array signal through a transform function, caching results per item identity. Prefer `<For>` in JSX — use `mapArray` only when you need a reactive derived array outside of rendering. See also: For.',
429
+ },
430
+
431
+ 'core/createRef': {
432
+ signature: 'createRef<T>(): Ref<T>',
433
+ example: `const inputRef = createRef<HTMLInputElement>()
434
+ onMount(() => inputRef.current?.focus())
435
+ return <input ref={inputRef} />`,
436
+ notes: 'Create a mutable ref object (`{ current: T | null }`) for holding DOM element references. Pass as the `ref` prop on JSX elements — the runtime sets `.current` after mount and clears it on unmount. Callback refs (`(el: T | null) => void`) are also supported via `RefProp<T>`. See also: onMount.',
437
+ },
438
+
439
+ 'core/untrack': {
440
+ signature: '(fn: () => T) => T',
441
+ example: `effect(() => {
442
+ const current = count() // tracked
443
+ const other = untrack(() => otherSignal()) // NOT tracked
444
+ })`,
445
+ notes: 'Execute a function reading signals WITHOUT subscribing to them. Alias for `runUntracked` from `@pyreon/reactivity`. Use inside effects when you need a one-shot snapshot of a signal value without the effect re-running when that signal changes. See also: @pyreon/reactivity.',
446
+ },
447
+
448
+ 'core/ExtractProps': {
449
+ signature: 'type ExtractProps<T> = T extends ComponentFn<infer P> ? P : T',
450
+ example: `const Greet: ComponentFn<{ name: string }> = ({ name }) => <h1>{name}</h1>
451
+ type Props = ExtractProps<typeof Greet> // { name: string }`,
452
+ notes: `Extracts the props type from a \`ComponentFn\`. Passes through unchanged if \`T\` is not a \`ComponentFn\`. Useful for HOC patterns and typed wrappers that need to infer the wrapped component's prop interface. See also: HigherOrderComponent.`,
453
+ },
454
+
455
+ 'core/HigherOrderComponent': {
456
+ signature: 'type HigherOrderComponent<HOP, P> = ComponentFn<HOP & P>',
457
+ example: `function withLogger<P>(Wrapped: ComponentFn<P>): HigherOrderComponent<{ logLevel?: string }, P> {
458
+ return (props) => {
459
+ console.log(\`[\${props.logLevel ?? "info"}] Rendering\`)
460
+ return <Wrapped {...props} />
461
+ }
462
+ }`,
463
+ notes: `Typed HOC pattern where \`HOP\` is the props the HOC adds and \`P\` is the wrapped component's own props. The resulting component accepts both sets of props. See also: ExtractProps.`,
381
464
  },
465
+ // <gen-docs:api-reference:end @pyreon/core>
382
466
 
383
467
  // ═══════════════════════════════════════════════════════════════════════════
384
468
  // @pyreon/router
385
469
  // ═══════════════════════════════════════════════════════════════════════════
386
470
 
471
+ // <gen-docs:api-reference:start @pyreon/router>
472
+
387
473
  'router/createRouter': {
388
474
  signature: 'createRouter(options: RouterOptions | RouteRecord[]): Router',
389
475
  example: `const router = createRouter([
@@ -393,6 +479,11 @@ const LabeledInput = (props: { label: string }) => {
393
479
  { path: "settings", component: Settings },
394
480
  ]},
395
481
  ])`,
482
+ notes: 'Create a router instance with route records, guards, middleware, and mode configuration. Accepts either an array of route records (shorthand) or a full `RouterOptions` object with `routes`, `mode` (`"history"` | `"hash"`), `scrollBehavior`, `beforeEach`, `afterEach`, and `middleware`. The returned `Router` is generic over route names for typed programmatic navigation. See also: RouterProvider, useRouter, useRoute.',
483
+ mistakes: `- \`createRouter({ routes: [...], mode: "hash" })\` and using \`window.location.hash\` elsewhere — hash mode uses \`history.pushState\`, not \`location.hash\`. Reading \`location.hash\` directly will not reflect router state
484
+ - Defining route paths without leading \`/\` in root routes — all root-level paths must start with \`/\`
485
+ - Using \`redirect: "/target"\` with a guard on the same route — redirects bypass guards. Use \`beforeEnter\` to conditionally redirect instead
486
+ - Forgetting the catch-all route — \`{ path: "(.*)", component: NotFound }\` should be the last route to handle 404s`,
396
487
  },
397
488
 
398
489
  'router/RouterProvider': {
@@ -403,6 +494,7 @@ const LabeledInput = (props: { label: string }) => {
403
494
  <RouterView />
404
495
  </RouterProvider>
405
496
  )`,
497
+ notes: 'Provide the router instance to the component tree via `RouterContext`. Must wrap the entire app (or the routed section). Sets up the context stack so `useRouter()`, `useRoute()`, and other hooks can access the router. See also: createRouter, RouterView, RouterLink.',
406
498
  },
407
499
 
408
500
  'router/RouterView': {
@@ -417,12 +509,17 @@ const Admin = () => (
417
509
  <RouterView /> {/* renders Settings, Users, etc. */}
418
510
  </div>
419
511
  )`,
512
+ notes: `Render the matched route's component. For nested routes, the parent route component includes a \`<RouterView />\` that renders the matched child. Each \`<RouterView>\` renders one level of the route tree. See also: RouterProvider, createRouter.`,
420
513
  },
421
514
 
422
515
  'router/RouterLink': {
423
- signature: '<RouterLink to={path} activeClass={cls} exactActiveClass={cls} />',
516
+ signature: '<RouterLink to={path} activeClass={cls} exactActiveClass={cls}>{children}</RouterLink>',
424
517
  example: `<RouterLink to="/" activeClass="nav-active">Home</RouterLink>
425
518
  <RouterLink to={{ name: "user", params: { id: "42" } }}>Profile</RouterLink>`,
519
+ notes: 'Declarative navigation link that renders an `<a>` element. Supports string paths or named route objects (`{ name, params }`). Applies `activeClass` when the current route matches the link path (prefix), and `exactActiveClass` for exact matches. Click handler calls `router.push()` and prevents default. See also: useRouter, useIsActive.',
520
+ mistakes: `- \`<a href="/about" onClick={() => router.push("/about")}>\` — use \`<RouterLink to="/about">\` instead; it handles the anchor element, active class, and click interception
521
+ - \`<RouterLink to="/about" target="_blank">\` — external navigation bypasses the router; use a plain \`<a>\` for external links
522
+ - \`<RouterLink to={dynamicPath}>\` without calling the signal — must call: \`<RouterLink to={dynamicPath()}>\` (or let the compiler handle it via \`_rp()\`)`,
426
523
  },
427
524
 
428
525
  'router/useRouter': {
@@ -435,6 +532,10 @@ router.replace("/login")
435
532
  router.back()
436
533
  router.forward()
437
534
  router.go(-2)`,
535
+ notes: 'Access the router instance for programmatic navigation. Returns the `Router` object with `push()`, `replace()`, `back()`, `forward()`, `go()`. `await router.push()` resolves after the View Transition `updateCallbackDone` (DOM commit is complete, new route state is live), NOT after the animation finishes. See also: useRoute, RouterLink, createRouter.',
536
+ mistakes: `- \`router.push("/path")\` at the top level of a component body — this is synchronous imperative navigation during render, causing an infinite loop. Wrap in \`onMount\`, event handler, or \`effect\`
537
+ - \`await router.push("/path")\` expecting animation completion — \`push\` resolves after DOM commit (\`updateCallbackDone\`), not after View Transition animation finishes. Use the returned transition object's \`.finished\` if you need to wait for animation
538
+ - Calling \`useRouter()\` outside a \`<RouterProvider>\` — throws because no router context exists`,
438
539
  },
439
540
 
440
541
  'router/useRoute': {
@@ -446,18 +547,53 @@ const userId = route().params.id // string
446
547
  // Access query, meta, etc:
447
548
  route().query
448
549
  route().meta`,
550
+ notes: 'Access the current resolved route as a reactive accessor. Generic over the path string for typed params — `useRoute<"/user/:id">()` yields `route().params.id: string`. Returns a function (accessor) that must be called to read the current route — reads inside reactive scopes track route changes. See also: useRouter, useSearchParams, useLoaderData.',
449
551
  },
450
552
 
451
- 'router/useSearchParams': {
452
- signature:
453
- 'useSearchParams<T>(defaults?: T): [get: () => T, set: (updates: Partial<T>) => Promise<void>]',
454
- example: `const [search, setSearch] = useSearchParams({ page: "1", sort: "name" })
553
+ 'router/useIsActive': {
554
+ signature: 'useIsActive(path: string, exact?: boolean): () => boolean',
555
+ example: `const isHome = useIsActive("/")
556
+ const isAdmin = useIsActive("/admin") // prefix match
557
+ const isExactAdmin = useIsActive("/admin", true) // exact only
455
558
 
456
- // Read:
457
- search().page // "1"
559
+ // Reactive — updates when route changes:
560
+ <a class={{ active: isAdmin() }} href="/admin">Admin</a>`,
561
+ notes: 'Returns a reactive boolean for whether a path matches the current route. Segment-aware prefix matching: `/admin` matches `/admin/users` but NOT `/admin-panel`. Pass `exact=true` for exact-only matching. Updates reactively when the route changes. See also: useRoute, RouterLink.',
562
+ mistakes: `- \`useIsActive("/admin")\` matching \`/admin-panel\` — this does NOT happen. Matching is segment-aware: \`/admin\` only matches paths starting with \`/admin/\` or exactly \`/admin\`
563
+ - \`if (useIsActive("/settings")())\` at component top level — the outer call returns an accessor; make sure to read it inside a reactive scope for updates
564
+ - Using \`useIsActive\` for complex route matching — it only does path prefix/exact matching. For query-param-aware or meta-aware checks, use \`useRoute()\` directly`,
565
+ },
458
566
 
459
- // Write:
460
- setSearch({ page: "2" })`,
567
+ 'router/useTypedSearchParams': {
568
+ signature: 'useTypedSearchParams<T>(schema: T): TypedSearchParams<T>',
569
+ example: `const params = useTypedSearchParams({ page: "number", q: "string", active: "boolean" })
570
+ params.page() // number (auto-coerced)
571
+ params.q() // string
572
+ params.set({ page: 2 }) // updates URL`,
573
+ notes: 'Type-safe search params with auto-coercion from URL strings. Schema keys define parameter names, values define types (`"string"`, `"number"`, `"boolean"`). Returns an object where each key is a reactive accessor and `.set()` updates the URL. See also: useSearchParams, useRoute.',
574
+ },
575
+
576
+ 'router/useTransition': {
577
+ signature: 'useTransition(): { isTransitioning: () => boolean }',
578
+ example: `const { isTransitioning } = useTransition()
579
+
580
+ <Show when={isTransitioning()}>
581
+ <ProgressBar />
582
+ </Show>`,
583
+ notes: 'Reactive signal for route transition state. `isTransitioning()` is true during navigation (while guards run + loaders resolve), false when the new route is mounted. Useful for progress bars and global loading indicators. See also: useRouter, useRoute.',
584
+ },
585
+
586
+ 'router/useMiddlewareData': {
587
+ signature: 'useMiddlewareData<T>(): T',
588
+ example: `// Middleware:
589
+ const authMiddleware: RouteMiddleware = async (ctx) => {
590
+ ctx.data.user = await getUser(ctx.to)
591
+ }
592
+
593
+ // Component:
594
+ const data = useMiddlewareData<{ user: User }>()
595
+ // data.user is available`,
596
+ notes: 'Read data set by `RouteMiddleware` in the middleware chain. Middleware functions receive `ctx` with a mutable `ctx.data` object — properties set there are available to all downstream components via this hook. See also: createRouter, useLoaderData.',
461
597
  },
462
598
 
463
599
  'router/useLoaderData': {
@@ -468,7 +604,52 @@ const User = () => {
468
604
  const data = useLoaderData<UserData>()
469
605
  return <div>{data.name}</div>
470
606
  }`,
607
+ notes: `Access the data returned by the current route's \`loader\` function. The loader runs before the route component mounts; its return value is cached and available synchronously via this hook. Generic over the loader return type. See also: useMiddlewareData, useRoute.`,
608
+ },
609
+
610
+ 'router/useSearchParams': {
611
+ signature: 'useSearchParams<T>(defaults?: T): [get: () => T, set: (updates: Partial<T>) => Promise<void>]',
612
+ example: `const [search, setSearch] = useSearchParams({ page: "1", sort: "name" })
613
+
614
+ // Read:
615
+ search().page // "1"
616
+
617
+ // Write:
618
+ setSearch({ page: "2" })`,
619
+ notes: 'Access and update URL search params as a reactive tuple. Returns `[get, set]` where `get()` reads the current params and `set()` updates them via `replaceState`. For typed params with auto-coercion, prefer `useTypedSearchParams`. See also: useTypedSearchParams, useRoute.',
620
+ },
621
+
622
+ 'router/useBlocker': {
623
+ signature: 'useBlocker(shouldBlock: () => boolean): Blocker',
624
+ example: `const blocker = useBlocker(() => form.isDirty())
625
+
626
+ <Show when={blocker.isBlocked()}>
627
+ <Dialog>
628
+ <p>Unsaved changes. Leave anyway?</p>
629
+ <button onClick={blocker.proceed}>Leave</button>
630
+ <button onClick={blocker.reset}>Stay</button>
631
+ </Dialog>
632
+ </Show>`,
633
+ notes: `Block navigation when a condition is true (e.g., unsaved form changes). Returns a \`Blocker\` object with \`proceed()\` and \`reset()\` methods. Also hooks into the browser's \`beforeunload\` event to warn on tab close. Uses a shared ref-counted listener for \`beforeunload\` — N blockers share one event handler. See also: useRouter.`,
634
+ },
635
+
636
+ 'router/onBeforeRouteLeave': {
637
+ signature: 'onBeforeRouteLeave(guard: NavigationGuard): void',
638
+ example: `onBeforeRouteLeave((to, from) => {
639
+ if (hasUnsavedChanges()) return false // cancel navigation
640
+ })`,
641
+ notes: 'Register a per-component navigation guard that fires when leaving the current route. Return `false` to cancel, a string path to redirect, or `undefined` to allow. Must be called during component setup. See also: onBeforeRouteUpdate, useBlocker.',
642
+ },
643
+
644
+ 'router/onBeforeRouteUpdate': {
645
+ signature: 'onBeforeRouteUpdate(guard: NavigationGuard): void',
646
+ example: `onBeforeRouteUpdate((to, from) => {
647
+ if (to.params.id === from.params.id) return // no change
648
+ // reload data for new ID...
649
+ })`,
650
+ notes: 'Register a per-component navigation guard that fires when the route updates but the same component stays mounted (e.g., param change `/user/1` to `/user/2`). Same return semantics as `onBeforeRouteLeave`. See also: onBeforeRouteLeave, useRoute.',
471
651
  },
652
+ // <gen-docs:api-reference:end @pyreon/router>
472
653
 
473
654
  // ═══════════════════════════════════════════════════════════════════════════
474
655
  // @pyreon/head
@@ -537,6 +718,8 @@ export default createHandler({
537
718
  // @pyreon/runtime-dom
538
719
  // ═══════════════════════════════════════════════════════════════════════════
539
720
 
721
+ // <gen-docs:api-reference:start @pyreon/runtime-dom>
722
+
540
723
  'runtime-dom/mount': {
541
724
  signature: 'mount(root: VNodeChild, container: Element): () => void',
542
725
  example: `import { mount } from "@pyreon/runtime-dom"
@@ -545,20 +728,31 @@ const dispose = mount(<App />, document.getElementById("app")!)
545
728
 
546
729
  // To unmount:
547
730
  dispose()`,
548
- mistakes: `- \`createRoot(container).render(<App />)\` Use \`mount(<App />, container)\`
549
- - Container must not be null/undefined`,
731
+ notes: 'Mount a VNode tree into a container element. Clears the container first, sets up event delegation, then mounts the given child. Returns an `unmount` function that removes everything and disposes all effects. In dev mode, throws if `container` is null/undefined with an actionable error message. See also: hydrateRoot, render.',
732
+ mistakes: `- \`createRoot(container).render(<App />)\` Pyreon uses a single function call: \`mount(<App />, container)\`
733
+ - \`mount(<App />, document.getElementById("app"))\` without \`!\` — getElementById returns \`Element | null\`. The runtime throws in dev if null, but TypeScript needs the assertion
734
+ - \`mount(<App />, document.body)\` — mounting directly to body is discouraged; use a dedicated container element
735
+ - Forgetting to call the returned unmount function — leaks event listeners and effects. Store and call it on cleanup`,
736
+ },
737
+
738
+ 'runtime-dom/render': {
739
+ signature: 'render(root: VNodeChild, container: Element): () => void',
740
+ example: `import { render } from "@pyreon/runtime-dom"
741
+ render(<App />, document.getElementById("app")!)`,
742
+ notes: 'Alias for `mount`. Provided for API familiarity — both names point to the same function. See also: mount.',
550
743
  },
551
744
 
552
745
  'runtime-dom/hydrateRoot': {
553
746
  signature: 'hydrateRoot(root: VNodeChild, container: Element): () => void',
554
747
  example: `import { hydrateRoot } from "@pyreon/runtime-dom"
555
748
 
556
- // Hydrate server-rendered HTML:
749
+ // Hydrate SSR-rendered HTML:
557
750
  hydrateRoot(<App />, document.getElementById("app")!)`,
751
+ notes: 'Hydrate server-rendered HTML. Walks the existing DOM and attaches reactive bindings without recreating elements. Expects the DOM to match the VNode tree structure — mismatches emit dev-mode warnings. Returns an unmount function. See also: mount, @pyreon/runtime-server.',
558
752
  },
559
753
 
560
754
  'runtime-dom/Transition': {
561
- signature: '<Transition name={name} mode={mode}>{children}</Transition>',
755
+ signature: '<Transition name={name} mode={mode} onEnter={fn} onLeave={fn}>{children}</Transition>',
562
756
  example: `<Transition name="fade" mode="out-in">
563
757
  <Show when={visible()}>
564
758
  <div>Content</div>
@@ -569,75 +763,710 @@ hydrateRoot(<App />, document.getElementById("app")!)`,
569
763
  .fade-enter-active, .fade-leave-active { transition: opacity 0.3s }
570
764
  .fade-enter-from, .fade-leave-to { opacity: 0 }
571
765
  */`,
766
+ notes: 'CSS-based enter/leave animation wrapper. Applies `{name}-enter-from`, `{name}-enter-active`, `{name}-enter-to` classes on enter and the corresponding `-leave-*` classes on leave. `mode` controls sequencing: `"out-in"` waits for leave to complete before entering, `"in-out"` enters first. Has a 5-second safety timeout — if `transitionend`/`animationend` never fires, the transition completes automatically. See also: TransitionGroup, @pyreon/kinetic.',
767
+ mistakes: `- Missing CSS classes — \`<Transition name="fade">\` does nothing without \`.fade-enter-active\` / \`.fade-leave-active\` CSS
768
+ - Wrapping multiple root elements — Transition expects a single child (or null). Multiple children cause undefined behavior
769
+ - Using \`mode="in-out"\` when you want sequential — \`"out-in"\` is almost always what you want (old leaves, then new enters)`,
770
+ },
771
+
772
+ 'runtime-dom/TransitionGroup': {
773
+ signature: '<TransitionGroup name={name} tag={tag}>{children}</TransitionGroup>',
774
+ example: `<TransitionGroup name="list" tag="ul">
775
+ <For each={items()} by={i => i.id}>
776
+ {item => <li>{item.name}</li>}
777
+ </For>
778
+ </TransitionGroup>
779
+
780
+ /* CSS:
781
+ .list-enter-active, .list-leave-active { transition: all 0.3s }
782
+ .list-enter-from, .list-leave-to { opacity: 0; transform: translateY(10px) }
783
+ .list-move { transition: transform 0.3s }
784
+ */`,
785
+ notes: 'Animate list item additions and removals with CSS transitions. Each item gets enter/leave classes on mount/unmount. The `tag` prop controls the wrapper element (defaults to a fragment). Works with `<For>` for reactive lists. Also applies `-move` classes for FLIP-animated reordering. See also: Transition, For.',
786
+ },
787
+
788
+ 'runtime-dom/KeepAlive': {
789
+ signature: '<KeepAlive include={pattern} exclude={pattern} max={number}>{children}</KeepAlive>',
790
+ example: `const tab = signal<"a" | "b">("a")
791
+
792
+ <KeepAlive>
793
+ <Show when={tab() === "a"}><ExpensiveFormA /></Show>
794
+ <Show when={tab() === "b"}><ExpensiveFormB /></Show>
795
+ </KeepAlive>`,
796
+ notes: 'Cache component instances across mount/unmount cycles so their state (signals, scroll position, form inputs) is preserved when they are toggled out and back in. `include`/`exclude` filter by component name. `max` limits cache size (LRU eviction). Useful for tab panels and multi-step forms. See also: Transition, Show.',
797
+ },
798
+
799
+ 'runtime-dom/_tpl': {
800
+ signature: '_tpl(html: string): () => DocumentFragment',
801
+ example: `// Compiler output (not hand-written):
802
+ const _$t0 = _tpl("<div class=\"container\"><span></span></div>")`,
803
+ notes: 'Compiler-internal: create a template factory from an HTML string. First call parses the HTML into a `<template>` element; subsequent calls use `cloneNode(true)` for zero-parse instantiation. Not intended for direct use — the JSX compiler emits `_tpl()` calls automatically. See also: _bindText, _bindDirect.',
804
+ },
805
+
806
+ 'runtime-dom/_bindText': {
807
+ signature: '_bindText(fn: () => string, node: Text): void',
808
+ example: `// Compiler output for <div>{count()}</div>:
809
+ const _$t = _tpl("<div> </div>")
810
+ const _$n = _$t()
811
+ _bindText(() => count(), _$n.firstChild)`,
812
+ notes: 'Compiler-internal: bind a reactive expression to a text node via `TextNode.data` assignment. Creates a `renderEffect` that re-runs when tracked signals change. Each text node gets its own independent binding for fine-grained reactivity. See also: _tpl, _bindDirect.',
572
813
  },
573
814
 
815
+ 'runtime-dom/sanitizeHtml': {
816
+ signature: 'sanitizeHtml(html: string): string',
817
+ example: `import { setSanitizer, sanitizeHtml } from "@pyreon/runtime-dom"
818
+ setSanitizer(DOMPurify.sanitize)
819
+ const clean = sanitizeHtml(userInput)`,
820
+ notes: 'Sanitize an HTML string using the registered sanitizer (set via `setSanitizer()`). Falls back to the identity function if no sanitizer is registered. Used by the runtime when setting `innerHTML` on elements. See also: setSanitizer.',
821
+ },
822
+ // <gen-docs:api-reference:end @pyreon/runtime-dom>
823
+
574
824
  // ═══════════════════════════════════════════════════════════════════════════
575
825
  // @pyreon/store
576
826
  // ═══════════════════════════════════════════════════════════════════════════
577
827
 
828
+ // <gen-docs:api-reference:start @pyreon/store>
829
+
578
830
  'store/defineStore': {
579
- signature: 'defineStore<T>(id: string, setup: () => T): () => StoreApi<T>',
831
+ signature: '<T extends Record<string, unknown>>(id: string, setup: () => T) => () => StoreApi<T>',
580
832
  example: `const useCounter = defineStore('counter', () => {
581
833
  const count = signal(0)
834
+ const double = computed(() => count() * 2)
582
835
  const increment = () => count.update(n => n + 1)
583
- return { count, increment }
836
+ return { count, double, increment }
584
837
  })
585
838
 
586
- const { store } = useCounter()
587
- store.count() // 0
588
- store.increment() // reactive update`,
589
- notes:
590
- 'Composition-style stores. Singleton by ID. Returns StoreApi with .store, .patch(), .subscribe(), .onAction(), .reset(), .dispose().',
839
+ const { store, patch, subscribe, reset } = useCounter()
840
+ store.count() // 0
841
+ store.increment() // reactive update
842
+ patch({ count: 42 })`,
843
+ notes: 'Define a composition-style store. The setup function runs once per store ID, returning an object whose signals become tracked state and whose functions become interceptable actions. Returns a hook function that produces a StoreApi with `.store` (user state/actions), `.patch()`, `.subscribe()`, `.onAction()`, `.reset()`, and `.dispose()`. Stores are singletons — calling the hook twice with the same ID returns the same instance. See also: StoreApi, addStorePlugin, resetStore.',
844
+ mistakes: `- Calling \`useCounter()\` expecting a new instance — stores are singletons by ID, the setup function only runs once
845
+ - Reading \`store.count\` without calling it — signals are functions, use \`store.count()\` to read the value
846
+ - Calling \`store.count.set()\` instead of using \`patch()\` when updating multiple signals — \`patch()\` batches updates into a single notification
847
+ - Forgetting \`dispose()\` in tests — store persists in the registry across test cases, leaking state. Use \`resetStore(id)\` or \`resetAllStores()\` in test cleanup`,
848
+ },
849
+
850
+ 'store/addStorePlugin': {
851
+ signature: '(plugin: StorePlugin) => void',
852
+ example: `addStorePlugin((api) => {
853
+ api.subscribe((mutation, state) => {
854
+ console.log(\`[\${api.id}] \${mutation.type}:\`, mutation.events)
855
+ })
856
+ })`,
857
+ notes: 'Register a global store plugin that runs when any store is first created. Plugin receives the full StoreApi, enabling cross-cutting concerns like logging, persistence, or devtools integration. Plugin errors are caught and logged in dev mode without breaking store creation. See also: defineStore, StoreApi.',
858
+ },
859
+
860
+ 'store/setStoreRegistryProvider': {
861
+ signature: '(provider: () => Map<string, StoreApi<any>>) => void',
862
+ example: `import { setStoreRegistryProvider } from '@pyreon/store'
863
+ import { AsyncLocalStorage } from 'node:async_hooks'
864
+
865
+ const als = new AsyncLocalStorage<Map<string, any>>()
866
+ setStoreRegistryProvider(() => als.getStore() ?? new Map())`,
867
+ notes: 'Replace the default global store registry with a provider function. Essential for concurrent SSR — pass an AsyncLocalStorage-backed provider so each request gets isolated store state instead of sharing a single global map across concurrent requests. See also: defineStore.',
868
+ mistakes: '- Forgetting to call this on the SSR server — all concurrent requests share the same store instances, causing cross-request state leaks',
869
+ },
870
+
871
+ 'store/resetStore': {
872
+ signature: '(id: string) => void',
873
+ example: `resetStore('counter') // next useCounter() call creates a fresh store`,
874
+ notes: 'Remove a store from the registry by ID. The next call to the store hook re-runs the setup function from scratch. Useful for testing isolation and HMR. See also: resetAllStores, defineStore.',
875
+ },
876
+
877
+ 'store/resetAllStores': {
878
+ signature: '() => void',
879
+ example: 'afterEach(() => resetAllStores())',
880
+ notes: 'Clear the entire store registry. All subsequent store hook calls create fresh instances. Primary use case is test cleanup and SSR request isolation. See also: resetStore.',
881
+ },
882
+ // <gen-docs:api-reference:end @pyreon/store>
883
+
884
+ // ═══════════════════════════════════════════════════════════════════════════
885
+ // @pyreon/state-tree
886
+ // ═══════════════════════════════════════════════════════════════════════════
887
+
888
+ // <gen-docs:api-reference:start @pyreon/state-tree>
889
+
890
+ 'state-tree/model': {
891
+ signature: '(definition: { state: StateShape, views?: (self: ModelSelf) => Record<string, () => any>, actions?: (self: ModelSelf) => Record<string, (...args: any[]) => any> }) => ModelDefinition',
892
+ example: `const Counter = model({
893
+ state: { count: 0 },
894
+ views: (self) => ({
895
+ doubled: () => self.count() * 2,
896
+ }),
897
+ actions: (self) => ({
898
+ increment: () => self.count.update(n => n + 1),
899
+ }),
900
+ })
901
+
902
+ const counter = Counter.create({ count: 10 })
903
+ counter.count() // 10
904
+ counter.increment()
905
+ counter.doubled() // 22`,
906
+ notes: 'Define a structured reactive model. `state` declares signal-backed fields with their initial values. `views` are computed derivations. `actions` are the only way to mutate state — enabling middleware interception and patch recording. Returns a `ModelDefinition` with `.create(initial?)` for instances and `.asHook(id)` for singleton access. See also: getSnapshot, applySnapshot, onPatch, addMiddleware.',
907
+ mistakes: `- Mutating state outside of actions — bypasses middleware and patch recording, breaks the structured contract
908
+ - Forgetting that \`self.count\` is a signal — read with \`self.count()\`, write with \`self.count.set(v)\` or \`.update(fn)\` inside actions
909
+ - Nesting plain objects in state instead of child models — plain objects are not signal-backed, changes to their properties are not reactive`,
910
+ },
911
+
912
+ 'state-tree/getSnapshot': {
913
+ signature: '(instance: ModelInstance) => Snapshot',
914
+ example: 'const snap = getSnapshot(counter) // { count: 10 }',
915
+ notes: 'Recursively serialize a model instance into a plain JSON-safe snapshot. Reads all signal values via `.peek()` to avoid tracking subscriptions. Nested models are recursively serialized. See also: applySnapshot, model.',
916
+ },
917
+
918
+ 'state-tree/applySnapshot': {
919
+ signature: '(instance: ModelInstance, snapshot: Snapshot) => void',
920
+ example: 'applySnapshot(counter, { count: 0 }) // reset to zero',
921
+ notes: `Replace a model instance's state wholesale from a snapshot. Recursively applies to nested models. Triggers patch listeners with replace operations. See also: getSnapshot, model.`,
922
+ },
923
+
924
+ 'state-tree/onPatch': {
925
+ signature: '(instance: ModelInstance, listener: PatchListener) => () => void',
926
+ example: `const dispose = onPatch(counter, (patch) => {
927
+ console.log(patch) // { op: 'replace', path: '/count', value: 11 }
928
+ })`,
929
+ notes: 'Subscribe to JSON patches emitted by actions on a model instance. Each patch records the path, operation (add/replace/remove), and value. Returns an unsubscribe function. Pairs with `applyPatch` for undo/redo and state synchronization. See also: applyPatch, model.',
930
+ },
931
+
932
+ 'state-tree/applyPatch': {
933
+ signature: '(instance: ModelInstance, patch: Patch | Patch[]) => void',
934
+ example: `applyPatch(counter, { op: 'replace', path: '/count', value: 0 })`,
935
+ notes: 'Apply one or more JSON patches to a model instance. Accepts a single patch or an array for batch replay. Used with `onPatch` for undo/redo and state synchronization. See also: onPatch, model.',
936
+ },
937
+
938
+ 'state-tree/addMiddleware': {
939
+ signature: '(instance: ModelInstance, middleware: MiddlewareFn) => () => void',
940
+ example: `addMiddleware(counter, (call, next) => {
941
+ console.log(\`\${call.name}(\${call.args.join(', ')})\`)
942
+ return next(call)
943
+ })`,
944
+ notes: 'Add an action interception middleware to a model instance. The middleware receives the action call context and a `next` function — call `next(call)` to proceed or return early to block the action. Returns an unsubscribe function. See also: model.',
591
945
  },
946
+ // <gen-docs:api-reference:end @pyreon/state-tree>
592
947
 
593
948
  // ═══════════════════════════════════════════════════════════════════════════
594
949
  // @pyreon/form
950
+
951
+ // ═══════════════════════════════════════════════════════════════════════════
952
+ // @pyreon/validation
953
+ // ═══════════════════════════════════════════════════════════════════════════
954
+
955
+ // <gen-docs:api-reference:start @pyreon/validation>
956
+
957
+ 'validation/zodSchema': {
958
+ signature: '<T>(schema: ZodType<T>) => SchemaAdapter<T>',
959
+ example: `const schema = z.object({ email: z.string().email(), age: z.number().min(18) })
960
+ const form = useForm({
961
+ initialValues: { email: '', age: 0 },
962
+ schema: zodSchema(schema),
963
+ onSubmit: (values) => save(values),
964
+ })`,
965
+ notes: 'Create a whole-form schema adapter from a Zod schema. Duck-typed against the `.safeParse()` method so it works with both Zod v3 and v4 without version checks. Pass the result to `useForm({ schema })` for automatic full-form validation on submit or blur. See also: zodField, valibotSchema, arktypeSchema.',
966
+ mistakes: `- Passing zodSchema AND per-field validators for the same field — both run and errors may conflict
967
+ - Using zodSchema with a non-object schema (z.string()) — form schemas must validate an object shape matching initialValues`,
968
+ },
969
+
970
+ 'validation/zodField': {
971
+ signature: '<T>(schema: ZodType<T>) => ValidateFn<T>',
972
+ example: `const form = useForm({
973
+ initialValues: { username: '' },
974
+ validators: { username: zodField(z.string().min(3).max(20)) },
975
+ onSubmit: (values) => save(values),
976
+ })`,
977
+ notes: `Create a per-field validator from a Zod schema. Returns a function compatible with \`useForm({ validators: { fieldName: zodField(z.string().email()) } })\`. Use when individual fields have independent validation rules that don't need cross-field context. See also: zodSchema, valibotField.`,
978
+ },
979
+
980
+ 'validation/valibotSchema': {
981
+ signature: '<T>(schema: ValibotSchema<T>, safeParse: SafeParseFn) => SchemaAdapter<T>',
982
+ example: `import * as v from 'valibot'
983
+ const schema = v.object({ email: v.pipe(v.string(), v.email()) })
984
+ const form = useForm({
985
+ initialValues: { email: '' },
986
+ schema: valibotSchema(schema, v.safeParse),
987
+ onSubmit: (values) => save(values),
988
+ })`,
989
+ notes: `Create a whole-form schema adapter from a Valibot schema. Requires passing the \`safeParse\` function explicitly (Valibot uses standalone functions, not methods). This keeps the adapter independent of Valibot's internal module structure across versions. See also: valibotField, zodSchema.`,
990
+ mistakes: '- Forgetting to pass v.safeParse as the second argument — the adapter cannot call safeParse without it since Valibot uses standalone functions',
991
+ },
992
+
993
+ 'validation/valibotField': {
994
+ signature: '<T>(schema: ValibotSchema<T>, safeParse: SafeParseFn) => ValidateFn<T>',
995
+ example: 'validators: { email: valibotField(v.pipe(v.string(), v.email()), v.safeParse) }',
996
+ notes: 'Create a per-field validator from a Valibot schema. Same standalone-function-style as valibotSchema — pass `v.safeParse` explicitly. See also: valibotSchema, zodField.',
997
+ },
998
+
999
+ 'validation/arktypeSchema': {
1000
+ signature: '<T>(schema: ArkTypeSchema<T>) => SchemaAdapter<T>',
1001
+ example: `import { type } from 'arktype'
1002
+ const schema = type({ email: 'email', age: 'number > 18' })
1003
+ const form = useForm({
1004
+ initialValues: { email: '', age: 0 },
1005
+ schema: arktypeSchema(schema),
1006
+ onSubmit: (values) => save(values),
1007
+ })`,
1008
+ notes: 'Create a whole-form schema adapter from an ArkType type. ArkType validation is synchronous only — async validators are not supported through this adapter. Returns errors via the ArkType `problems` array. See also: arktypeField, zodSchema.',
1009
+ },
1010
+
1011
+ 'validation/arktypeField': {
1012
+ signature: '<T>(schema: ArkTypeSchema<T>) => ValidateFn<T>',
1013
+ example: `validators: { age: arktypeField(type('number > 18')) }`,
1014
+ notes: 'Create a per-field validator from an ArkType type. Synchronous only, same as arktypeSchema. See also: arktypeSchema, zodField.',
1015
+ },
1016
+ // <gen-docs:api-reference:end @pyreon/validation>
595
1017
  // ═══════════════════════════════════════════════════════════════════════════
596
1018
 
1019
+ // <gen-docs:api-reference:start @pyreon/form>
1020
+
597
1021
  'form/useForm': {
598
- signature:
599
- 'useForm<T>(options: { initialValues: T, onSubmit: (values: T) => void | Promise<void>, schema?, validateOn?, debounceMs? }): FormInstance<T>',
1022
+ signature: '<TValues extends Record<string, unknown>>(options: UseFormOptions<TValues>) => FormState<TValues>',
600
1023
  example: `const form = useForm({
601
- initialValues: { name: '', email: '' },
602
- onSubmit: async (values) => await api.save(values),
603
- validateOn: 'blur',
1024
+ initialValues: { email: '', password: '' },
1025
+ validators: {
1026
+ email: (v) => (!v ? 'Required' : undefined),
1027
+ password: (v, all) => (v.length < 8 ? 'Too short' : undefined),
1028
+ },
1029
+ onSubmit: async (values) => { await login(values) },
604
1030
  })
605
1031
 
606
- form.handleSubmit() // triggers validation + onSubmit
607
- form.reset() // reset to initial values`,
608
- notes:
609
- 'Signal-based form state. Use useField() for individual field binding, useFieldArray() for dynamic arrays.',
1032
+ // Bind inputs with register():
1033
+ // h('input', form.register('email'))
1034
+ // h('input', { type: 'checkbox', ...form.register('remember', { type: 'checkbox' }) })`,
1035
+ notes: `Create a signal-based form. \`initialValues\` drives field keys and types end-to-end — TValues is inferred from it, so all downstream typings (\`useField\` field name, \`useWatch\` keys, validator signatures) are fully typed without annotation. Returns \`FormState<TValues>\` with per-field signals, form-level signals (\`isSubmitting\`, \`isValidating\`, \`isValid\`, \`isDirty\`, \`submitCount\`, \`submitError\`), and handlers (\`handleSubmit\`, \`reset\`, \`validate\`). \`validateOn\` defaults to \`"blur"\` (not \`"change"\`) so users aren't scolded mid-keystroke; optional \`schema\` integrates with \`@pyreon/validation\` adapters (\`zodSchema\`, \`valibotSchema\`, \`arktypeSchema\`) for whole-form validation after per-field validators run. See also: useField, FormProvider, useFormState.`,
1036
+ mistakes: `- Mutating \`initialValues\` after creation — it is read once at setup; use \`setFieldValue\` for programmatic updates
1037
+ - Reading \`form.fields[name].value\` as a plain value — it is \`Signal<T>\`, call it: \`form.fields.email.value()\`
1038
+ - Passing \`validateOn: "change"\` without \`debounceMs\` on async validators — fires a network request on every keystroke
1039
+ - Calling \`form.handleSubmit()\` without attaching it as a form \`onSubmit\` handler — it calls \`preventDefault()\` so it must receive the form event, or be called with no argument for programmatic submit
1040
+ - Forgetting that \`schema\` runs AFTER per-field \`validators\` — errors from both sources merge; if a field validator already set an error, the schema can override it`,
610
1041
  },
611
1042
 
612
1043
  'form/useField': {
613
- signature: 'useField<T>(form: FormInstance<T>, name: keyof T): FieldInstance',
614
- example: `const name = useField(form, 'name')
1044
+ signature: '<TValues, K extends keyof TValues & string>(form: FormState<TValues>, name: K) => UseFieldResult<TValues[K]>',
1045
+ example: `function EmailField({ form }: { form: FormState<{ email: string }> }) {
1046
+ const field = useField(form, 'email')
1047
+ return (
1048
+ <>
1049
+ <input {...field.register()} />
1050
+ {() => field.showError() && <span>{field.error()}</span>}
1051
+ </>
1052
+ )
1053
+ }`,
1054
+ notes: `Extract a single field's state and helpers from a form instance — avoids passing the entire \`FormState\` to leaf components. Returns all \`FieldState\` signals (\`value\`, \`error\`, \`touched\`, \`dirty\`) plus two convenience computeds: \`hasError\` (true when an error string exists) and \`showError\` (true when touched AND errored — the typical UI condition for gating error display). Also exposes \`register(opts?)\` to bind an \`<input>\` element with a single spread. See also: useForm, useWatch.`,
1055
+ mistakes: `- Destructuring \`const { value } = useField(form, "email")\` and calling \`value()\` — works, but the getter evaluates to the Signal itself; storing \`value()\` at setup captures the initial value and defeats reactivity
1056
+ - Forgetting \`showError\` and reimplementing \`touched() && hasError()\` in every template — \`showError\` is a \`Computed<boolean>\`, use it directly`,
1057
+ },
1058
+
1059
+ 'form/useFieldArray': {
1060
+ signature: '<T>(initial?: T[]) => UseFieldArrayResult<T>',
1061
+ example: `const tags = useFieldArray<string>([])
1062
+ tags.append('typescript')
1063
+ tags.prepend('signals')
1064
+ tags.insert(1, 'reactive')
1065
+ tags.move(0, 2)
1066
+ tags.remove(0)
1067
+
1068
+ // Keyed rendering — never drop the \`by={i => i.key}\`
1069
+ <For each={tags.items()} by={(i) => i.key}>
1070
+ {(item) => <input value={item.value()} onInput={(e) => item.value.set(e.currentTarget.value)} />}
1071
+ </For>`,
1072
+ notes: 'Manage a dynamic array of form fields with stable keys. Each item is `{ key: number, value: Signal<T> }` — use `item.key` inside `<For by={i => i.key}>` so reordering / inserts do not remount child components. Full mutation surface: `append`, `prepend`, `insert`, `remove`, `update`, `move`, `swap`, `replace`. See also: useForm.',
1073
+ mistakes: `- Rendering with <For by={(_, i) => i}> — index-based keys lose identity on reorder, defeating the stable-key design
1074
+ - Calling tags.items() inside setup and storing the array — it is a Signal, read inside reactive scopes`,
1075
+ },
1076
+
1077
+ 'form/useWatch': {
1078
+ signature: '(form, name) => Signal<TValues[K]> | (form, names[]) => Signal<T>[] | (form) => Computed<TValues>',
1079
+ example: `const email = useWatch(form, 'email') // Signal<string>
1080
+ const [first, last] = useWatch(form, ['firstName', 'lastName'])
1081
+ const everything = useWatch(form) // Computed<TValues>
1082
+
1083
+ // Derive and sync: preview displays the email as the user types.
1084
+ effect(() => { preview.set(\`Hello \${email()}\`) })`,
1085
+ notes: 'Typed overloads for reactively watching form field values. Single-field form returns `Signal<T>` (fast path — same signal, no wrapper), multi-field returns a tuple of signals, no-args returns a `Computed<TValues>` over the whole values object. Prefer the narrowest form — watching everything re-runs your effect when ANY field changes. See also: useFormState, useField.',
1086
+ mistakes: '- Using the all-fields overload (`useWatch(form)`) to derive a single computed — re-runs when any field changes, not just the one you care about. Use `useWatch(form, "email")` for single-field precision',
1087
+ },
1088
+
1089
+ 'form/useFormState': {
1090
+ signature: '<TValues, T>(form: FormState<TValues>, selector?: (s: FormStateSummary) => T) => Computed<T>',
1091
+ example: `const canSubmit = useFormState(form, (s) => s.isValid && !s.isSubmitting && s.isDirty)
1092
+ <button disabled={() => !canSubmit()}>Save</button>`,
1093
+ notes: 'Computed summary of form-level state (`isValid`, `isDirty`, `isSubmitting`, `isValidating`, `submitCount`, `errors`). Passing a selector restricts the tracked subset — a button driven by `canSubmit` should not re-render just because `submitCount` changed. Without a selector, the computed re-derives on ANY form-level state change. See also: useForm, useWatch.',
1094
+ mistakes: '- Omitting the selector and reading `useFormState(form)` as a whole — triggers on every field change, every validation, every submit count bump. Always pass a selector for UI-bound computeds',
1095
+ },
1096
+
1097
+ 'form/FormProvider': {
1098
+ signature: '<TValues>(props: { form: FormState<TValues>; children: VNodeChild }) => VNode',
1099
+ example: `<FormProvider form={form}>
1100
+ <PersonalInfoSection />
1101
+ <AddressSection />
1102
+ <SubmitButton />
1103
+ </FormProvider>
1104
+
1105
+ // Inside any descendant:
1106
+ const form = useFormContext<typeof values>()`,
1107
+ notes: 'Provide a form via context so nested components can read it with `useFormContext<TValues>()` without prop-drilling. Every call to `useFormContext` inside the provider tree returns the same `FormState` instance. Nest inside `PyreonUI` or any other provider — the form context is independent. See also: useFormContext, useForm.',
1108
+ mistakes: '- Nesting `FormProvider` within itself expecting scoped forms — the inner provider shadows the outer; for multi-form pages, use separate providers at sibling level, not nested',
1109
+ },
615
1110
 
616
- <input {...name.register()} />
617
- // name.value(), name.error(), name.hasError(), name.showError()`,
1111
+ 'form/useFormContext': {
1112
+ signature: '<TValues>() => FormState<TValues>',
1113
+ example: `const form = useFormContext<{ email: string; password: string }>()
1114
+ const field = useField(form, 'email')`,
1115
+ notes: 'Read the nearest `FormProvider` form from context. Throws at dev time if no provider is mounted above the call site. Pass the expected `TValues` generic so downstream typings (`useField` field names, `useWatch` keys) stay end-to-end typed. Returns the same `FormState<TValues>` instance that was passed to `FormProvider`. See also: FormProvider, useForm.',
1116
+ mistakes: `- Calling at module scope — hooks require an active component setup context; call inside a component body
1117
+ - Omitting the \`<TValues>\` generic — TypeScript infers \`FormState<Record<string, unknown>>\` and \`useField\` field names lose type narrowing`,
618
1118
  },
1119
+ // <gen-docs:api-reference:end @pyreon/form>
619
1120
 
620
1121
  // ═══════════════════════════════════════════════════════════════════════════
621
1122
  // @pyreon/query
622
1123
  // ═══════════════════════════════════════════════════════════════════════════
623
1124
 
1125
+ // <gen-docs:api-reference:start @pyreon/query>
1126
+
1127
+ 'query/QueryClientProvider': {
1128
+ signature: '(props: { client: QueryClient; children: VNodeChild }) => VNode',
1129
+ example: `const client = new QueryClient()
1130
+ <QueryClientProvider client={client}>
1131
+ <App />
1132
+ </QueryClientProvider>`,
1133
+ notes: 'Mounts a `QueryClient` at the root of the component tree via context so every descendant hook (`useQuery`, `useMutation`, `useSubscription`, `useSSE`, etc.) can reach it via `useQueryClient()`. Must wrap the app — omitting it causes a runtime throw on the first hook call. One provider per app; nested providers are not supported (the deepest one wins, silently shadowing the outer). See also: useQueryClient, QueryClient.',
1134
+ mistakes: `- Forgetting to wrap the app — every query/mutation hook throws "No QueryClient set" at runtime
1135
+ - Creating the \`QueryClient\` inside a component body — it re-creates on every render. Hoist to module scope or use \`useMemo\`-equivalent (\`const client = useMemo(() => new QueryClient())\`)
1136
+ - Nesting providers expecting scoped caches — only one provider is supported; the deepest one wins silently`,
1137
+ },
1138
+
624
1139
  'query/useQuery': {
625
- signature:
626
- 'useQuery<T>(options: { queryKey: unknown[], queryFn: () => Promise<T>, ... }): { data: Signal<T>, error: Signal<Error>, isFetching: Signal<boolean>, ... }',
627
- example: `const { data, error, isFetching } = useQuery({
628
- queryKey: ['users'],
629
- queryFn: () => fetch('/api/users').then(r => r.json()),
1140
+ signature: '<TData, TError, TKey>(options: () => QueryObserverOptions<...>) => UseQueryResult<TData, TError>',
1141
+ example: `const userId = signal(1)
1142
+ const user = useQuery(() => ({
1143
+ queryKey: ['user', userId()],
1144
+ queryFn: () => fetch(\`/api/users/\${userId()}\`).then((r) => r.json()),
1145
+ }))
1146
+ // user.data(), user.error(), user.isFetching() — each its own signal`,
1147
+ notes: `Subscribe to a query with fine-grained reactive signals. \`options\` is a FUNCTION (not an object) so it can read Pyreon signals — when a tracked signal inside changes (e.g. a reactive queryKey), the observer re-evaluates options and refetches automatically. Returns one independent \`Signal<T>\` per observer field (\`data\`, \`error\`, \`status\`, \`isPending\`, \`isLoading\`, \`isFetching\`, \`isError\`, \`isSuccess\`) so templates only re-run for the exact fields they read. Internally wraps TanStack's \`QueryObserver\` and subscribes via \`onUnmount\`-guarded effect — the observer unsubscribes when the component unmounts. See also: useQueryClient, useMutation, useSuspenseQuery.`,
1148
+ mistakes: `- Passing the options object directly instead of a function — loses reactive queryKey support; the observer never re-evaluates when signals change
1149
+ - Reading \`.data\` / \`.error\` / \`.isFetching\` as plain values — they are \`Signal<T>\`, call them: \`user.data()\`, \`user.isFetching()\`
1150
+ - Destructuring \`const { data } = useQuery(...)\` at setup and reading \`data\` later — captures the Signal reference once, which is fine, but storing \`data()\` at setup captures the initial VALUE and defeats reactivity
1151
+ - Returning \`user.data()\` at the top of a component body instead of inside a reactive accessor — components run once; read signals inside \`() => user.data()?.name\` or effects
1152
+ - Expecting refetch on \`queryFn\` closure changes alone — only signals read inside the options function trigger re-evaluation; a closure capture of a \`let\` variable does not`,
1153
+ },
1154
+
1155
+ 'query/useMutation': {
1156
+ signature: '<TData, TError, TVars, TCtx>(options: MutationObserverOptions<...>) => UseMutationResult<TData, TError, TVars, TCtx>',
1157
+ example: `const create = useMutation({
1158
+ mutationFn: (input) => fetch('/api/posts', { method: 'POST', body: JSON.stringify(input) }).then(r => r.json()),
1159
+ onSuccess: () => client.invalidateQueries({ queryKey: ['posts'] }),
1160
+ })
1161
+ // <button onClick={() => create.mutate({ title: 'New' })}>Create</button>`,
1162
+ notes: 'Run a mutation (create / update / delete). Returns reactive `pending` / `success` / `error` signals plus two firing modes: `mutate(vars)` (fire-and-forget — errors go to the `error` signal) and `mutateAsync(vars)` (returns a promise for try/catch). `reset()` returns state to idle. Unlike `useQuery`, options is a plain OBJECT (not a function) because mutations are imperative — there are no reactive queryKeys to re-evaluate, so the function-wrapper overhead would add no value. `onSuccess` / `onError` / `onSettled` callbacks fire synchronously after the mutation resolves, useful for cache invalidation (`client.invalidateQueries`). See also: useQuery, useIsMutating.',
1163
+ mistakes: `- \`mutate()\` swallows errors into the \`error\` signal — use \`mutateAsync()\` with try/catch if you need programmatic error handling
1164
+ - Calling \`mutate()\` inside a \`useQuery\` \`queryFn\` — mutations are imperative user actions, not data-fetching side effects; this causes infinite loops if the mutation invalidates the query that spawned it
1165
+ - Reading \`mutation.data()\` outside a reactive scope — same rule as \`useQuery\`: read inside \`() => mutation.data()\` or effects`,
1166
+ },
1167
+
1168
+ 'query/useInfiniteQuery': {
1169
+ signature: '<TQueryFnData, TError>(options: () => InfiniteQueryObserverOptions<...>) => UseInfiniteQueryResult<TQueryFnData, TError>',
1170
+ example: `const feed = useInfiniteQuery(() => ({
1171
+ queryKey: ['feed'],
1172
+ queryFn: ({ pageParam }) => fetchPage(pageParam),
1173
+ initialPageParam: 0,
1174
+ getNextPageParam: (last) => last.nextCursor,
1175
+ }))`,
1176
+ notes: 'Paginated / cursor-based query. Returns reactive `data` (wrapping `InfiniteData<T>` with `.pages` + `.pageParams`), `hasNextPage` / `hasPreviousPage` booleans, and `fetchNextPage` / `fetchPreviousPage` trigger functions. Options is a function (same reactive-tracking contract as `useQuery`). `getNextPageParam` / `getPreviousPageParam` drive cursor progression — return `undefined` to signal the end. See also: useQuery, useSuspenseInfiniteQuery.',
1177
+ mistakes: `- Forgetting \`initialPageParam\` — required by TanStack v5; omitting it throws at the first \`queryFn\` call
1178
+ - Using \`data().pages\` without flattening — \`pages\` is an array of page results; most UIs want \`data().pages.flat()\` or \`data().pages.flatMap(p => p.items)\``,
1179
+ },
1180
+
1181
+ 'query/useQueries': {
1182
+ signature: '(queries: () => UseQueriesOptions[]) => Signal<QueryObserverResult[]>',
1183
+ example: `const results = useQueries(() =>
1184
+ userIds().map((id) => ({ queryKey: ['user', id], queryFn: () => fetchUser(id) })),
1185
+ )
1186
+ // results() is QueryObserverResult[] — one entry per input query`,
1187
+ notes: 'Subscribe to multiple queries in parallel. Returns a `Signal<QueryObserverResult[]>` — one entry per input query. Options is a function so the query list can depend on signals (e.g. derive one query per item in a reactive array). Each inner query independently tracks its own `data` / `error` / `isFetching` — the outer signal fires when ANY inner query updates. See also: useQuery.',
1188
+ mistakes: `- Expecting per-query fine-grained signals — \`useQueries\` returns a single combined signal, not individual \`UseQueryResult\` objects. For independent per-query tracking, call \`useQuery\` N times
1189
+ - Passing a static array instead of a function — loses reactive query-list tracking; if the list of IDs changes (e.g. \`userIds()\` is a signal), the queries won't re-evaluate. Always wrap: \`useQueries(() => ids().map(...))\``,
1190
+ },
1191
+
1192
+ 'query/useSubscription': {
1193
+ signature: '(options: UseSubscriptionOptions) => UseSubscriptionResult',
1194
+ example: `const sub = useSubscription({
1195
+ url: 'wss://api.example.com/feed',
1196
+ onMessage: (event, client) => {
1197
+ if (JSON.parse(event.data).type === 'post-created') {
1198
+ client.invalidateQueries({ queryKey: ['posts'] })
1199
+ }
1200
+ },
1201
+ })
1202
+ // sub.status() — 'connecting' | 'connected' | 'disconnected' | 'error'
1203
+ // sub.send(data), sub.close(), sub.reconnect()`,
1204
+ notes: 'Reactive WebSocket with auto-reconnect and QueryClient cache integration. `onMessage` receives the active `QueryClient` so push updates can invalidate or directly patch cached queries in a single line. Exponential backoff on reconnect (default 1s doubling, max 10 attempts — configurable via `reconnectDelay` / `maxReconnectAttempts`). `url` and `enabled` may be signals for reactive connection management — changing the URL closes the old socket and opens a new one. Returns `status` (signal), `send(data)`, `close()`, `reconnect()`. See also: useSSE, useQuery.',
1205
+ mistakes: `- \`onMessage\` runs on every frame the socket receives — debounce cache invalidations for high-frequency streams or you'll trigger N refetches per second
1206
+ - Storing data in a parallel signal instead of using \`queryClient.setQueryData\` inside \`onMessage\` — defeats the QueryClient cache; use \`setQueryData\` to push updates into the same cache that \`useQuery\` reads
1207
+ - Forgetting \`enabled: false\` on unmount-sensitive connections — the WebSocket stays open unless \`enabled\` is a signal that tracks component lifecycle or a reactive condition`,
1208
+ },
1209
+
1210
+ 'query/useSSE': {
1211
+ signature: '<T>(options: UseSSEOptions<T>) => UseSSEResult<T>',
1212
+ example: `const sse = useSSE({
1213
+ url: '/api/events',
1214
+ parse: JSON.parse,
1215
+ onMessage: (data, queryClient) => {
1216
+ if (data.type === 'order-updated') {
1217
+ queryClient.invalidateQueries({ queryKey: ['orders'] })
1218
+ }
1219
+ },
1220
+ })
1221
+ // sse.data() — last parsed message
1222
+ // sse.status() — 'connecting' | 'connected' | 'disconnected' | 'error'
1223
+ // sse.lastEventId(), sse.readyState(), sse.close(), sse.reconnect()`,
1224
+ notes: 'Reactive Server-Sent Events hook with QueryClient cache integration. Same pattern as `useSubscription` but read-only (no `send`). `parse` deserializes raw event data per message (e.g. `JSON.parse`); `events` filters named SSE event types (defaults to generic `message` events). Honours the SSE spec `id` field via `lastEventId()` so the browser includes `Last-Event-ID` on reconnect and the server can resume from the right offset. `onMessage` receives the `QueryClient` for cache invalidation. See also: useSubscription.',
1225
+ mistakes: `- Passing \`queryKey\` (TanStack v4 pattern) instead of using \`onMessage\` for cache integration — Pyreon's \`useSSE\` does NOT auto-update query cache; use \`queryClient.setQueryData\` or \`invalidateQueries\` inside \`onMessage\`
1226
+ - Omitting \`parse\` and expecting typed data — without \`parse\`, \`data()\` is \`string\` (raw event payload); pass \`parse: JSON.parse\` for auto-deserialization`,
1227
+ },
1228
+
1229
+ 'query/useSuspenseQuery': {
1230
+ signature: '<TData, TError>(options: () => QueryObserverOptions<...>) => UseSuspenseQueryResult<TData, TError>',
1231
+ example: `const user = useSuspenseQuery(() => ({ queryKey: ['user', id()], queryFn: fetchUser }))
1232
+
1233
+ <QuerySuspense query={user} fallback={<Spinner />}>
1234
+ {() => <UserCard name={user.data().name} />}
1235
+ </QuerySuspense>`,
1236
+ notes: 'Like `useQuery` but `data` is narrowed to `Signal<TData>` (never undefined). Designed for use inside a `QuerySuspense` boundary that guarantees children only render after the query succeeds — read `user.data().name` unconditionally, no `undefined` guard needed. The Suspense-mode observer fires a background refetch but never transitions `data` back to `undefined` (the previous data is retained as placeholder). `useSuspenseInfiniteQuery` is the equivalent for paginated queries. See also: QuerySuspense, useSuspenseInfiniteQuery, useQuery.',
1237
+ mistakes: `- Using \`useSuspenseQuery\` without a \`QuerySuspense\` wrapper — the narrowed type assumes a boundary guarantees data; without it, \`data()\` CAN be the initial value during the first render cycle
1238
+ - Mixing \`useSuspenseQuery\` and \`useQuery\` for the same \`queryKey\` — the Suspense observer and the regular observer can race; use one or the other per key`,
1239
+ },
1240
+
1241
+ 'query/useSuspenseInfiniteQuery': {
1242
+ signature: '<TQueryFnData, TError>(options: () => InfiniteQueryObserverOptions<...>) => UseSuspenseInfiniteQueryResult<TQueryFnData, TError>',
1243
+ example: `const feed = useSuspenseInfiniteQuery(() => ({
1244
+ queryKey: ['feed'],
1245
+ queryFn: ({ pageParam }) => fetchPage(pageParam),
1246
+ initialPageParam: 0,
1247
+ getNextPageParam: (last) => last.nextCursor,
1248
+ }))
1249
+
1250
+ <QuerySuspense query={feed} fallback={<Spinner />}>
1251
+ {() => <Feed pages={feed.data().pages} onMore={feed.fetchNextPage} />}
1252
+ </QuerySuspense>`,
1253
+ notes: 'Like `useInfiniteQuery` but `data` is narrowed to `Signal<InfiniteData<TQueryFnData>>` (never undefined) — for use inside a `QuerySuspense` boundary. Returns the same `fetchNextPage` / `fetchPreviousPage` / `hasNextPage` / `hasPreviousPage` surface as `useInfiniteQuery`. Same caveats as `useSuspenseQuery` regarding Suspense boundary requirement. See also: useSuspenseQuery, useInfiniteQuery, QuerySuspense.',
1254
+ mistakes: `- Using without a \`QuerySuspense\` wrapper — same boundary-requirement as \`useSuspenseQuery\`; the narrowed type assumes success, but \`data()\` CAN be the initial value during the first render cycle without a boundary
1255
+ - Mixing \`useSuspenseInfiniteQuery\` and \`useInfiniteQuery\` for the same \`queryKey\` — the Suspense observer and the regular observer can race; use one or the other per key`,
1256
+ },
1257
+
1258
+ 'query/QuerySuspense': {
1259
+ signature: '(props: QuerySuspenseProps) => VNodeChild',
1260
+ example: `<QuerySuspense
1261
+ query={[userQuery, postsQuery]}
1262
+ fallback={<Spinner />}
1263
+ error={(err) => <ErrorCard message={String(err)} />}
1264
+ >
1265
+ {() => <Dashboard user={userQuery.data()} posts={postsQuery.data()} />}
1266
+ </QuerySuspense>`,
1267
+ notes: 'Pyreon-native Suspense boundary for queries — replaces `<Suspense>` for the query use case with explicit error handling. Shows `fallback` while any query is `isPending`. On error, renders the `error` callback or re-throws to the nearest `ErrorBoundary`. Accepts a single query or an array — pass an array to gate on multiple queries in parallel. Children are a function (`{() => <UI />}`) so they only execute after all queries succeed. See also: useSuspenseQuery, useSuspenseInfiniteQuery.',
1268
+ mistakes: `- Passing children as plain JSX (\`<QuerySuspense query={q}><Data /></QuerySuspense>\`) instead of a function — plain children evaluate eagerly, defeating the Suspense gate. Always wrap: \`{() => <Data />}\`
1269
+ - Omitting the \`error\` callback — errors re-throw to the nearest \`ErrorBoundary\`, which may not exist or may be too far up the tree. Provide an explicit \`error\` fallback for precise error handling`,
1270
+ },
1271
+
1272
+ 'query/useIsFetching': {
1273
+ signature: '(filters?: QueryFilters) => Signal<number>',
1274
+ example: `const fetching = useIsFetching()
1275
+ // <TopSpinner visible={() => fetching() > 0} />`,
1276
+ notes: 'Global reactive count of currently-fetching queries. Pass `QueryFilters` to narrow by `queryKey` prefix, `stale` status, or `fetchStatus`. Pair with `useIsMutating` to drive a top-of-page progress bar that aggregates ALL in-flight data fetching without tracking individual queries. Returns `Signal<number>` — zero when idle. See also: useIsMutating.',
1277
+ },
1278
+
1279
+ 'query/useIsMutating': {
1280
+ signature: '(filters?: MutationFilters) => Signal<number>',
1281
+ example: `const mutating = useIsMutating()
1282
+ // <Banner visible={() => mutating() > 0}>Saving…</Banner>`,
1283
+ notes: 'Global reactive count of currently-running mutations (optionally filtered by `MutationFilters`). Same pattern as `useIsFetching` but for the mutation pipeline. Returns `Signal<number>` — zero when no mutations are in flight. See also: useIsFetching.',
1284
+ },
1285
+
1286
+ 'query/QueryErrorResetBoundary': {
1287
+ signature: '(props: QueryErrorResetBoundaryProps) => VNodeChild',
1288
+ example: `<QueryErrorResetBoundary>
1289
+ {(reset) => (
1290
+ <ErrorBoundary fallback={(err, retry) => <button onClick={() => { reset(); retry() }}>Retry</button>}>
1291
+ <QuerySuspense query={q}>{() => <Data />}</QuerySuspense>
1292
+ </ErrorBoundary>
1293
+ )}
1294
+ </QueryErrorResetBoundary>`,
1295
+ notes: 'Resets errored queries inside its subtree when a sibling `ErrorBoundary` recovers. Wrap around a `QuerySuspense` + `ErrorBoundary` pair to get clean retry semantics — without this, a recovered `ErrorBoundary` re-renders children but the queries still hold their error state, so the boundary immediately catches the same error again (infinite error loop). Accepts a render function child `{(reset) => ...}` so the reset action can be wired to a retry button. See also: QuerySuspense.',
1296
+ },
1297
+
1298
+ 'query/useQueryErrorResetBoundary': {
1299
+ signature: '() => { reset: () => void }',
1300
+ example: `const { reset } = useQueryErrorResetBoundary()
1301
+ // Inside an ErrorBoundary fallback:
1302
+ <button onClick={() => { reset(); retry() }}>Try again</button>`,
1303
+ notes: `Imperative access to the nearest \`QueryErrorResetBoundary\`. Returns \`{ reset }\` — call \`reset()\` to clear errored queries in the subtree. Useful when an error fallback has its own retry button outside the render-prop form of \`QueryErrorResetBoundary\`, e.g. inside a standalone \`ErrorBoundary\` fallback component that isn't a direct child of the boundary. See also: QueryErrorResetBoundary.`,
1304
+ },
1305
+
1306
+ 'query/useQueryClient': {
1307
+ signature: '() => QueryClient',
1308
+ example: `const client = useQueryClient()
1309
+ client.invalidateQueries({ queryKey: ['posts'] })
1310
+ await client.prefetchQuery({ queryKey: ['user', 1], queryFn: fetchUser })`,
1311
+ notes: 'Access the nearest `QueryClient` from context. Used to invalidate queries (`client.invalidateQueries`), prefetch data (`client.prefetchQuery`), read/write cache (`getQueryData` / `setQueryData`), or cancel queries. Throws "[Pyreon] No QueryClient set" if no `QueryClientProvider` is mounted above the call site. Returns the same `QueryClient` instance that TanStack core exposes — all TanStack methods work. See also: QueryClientProvider.',
1312
+ mistakes: '- Calling `useQueryClient()` at module scope — hooks require an active component setup context; hoist into the component body or pass the client as a function parameter',
1313
+ },
1314
+
1315
+ 'query/TanStack core re-exports': {
1316
+ signature: `import { QueryClient, QueryCache, MutationCache, dehydrate, hydrate, keepPreviousData, hashKey, isCancelledError, CancelledError, defaultShouldDehydrateQuery, defaultShouldDehydrateMutation } from '@pyreon/query'`,
1317
+ example: `// SSR dehydration round-trip:
1318
+ import { QueryClient, dehydrate, hydrate } from '@pyreon/query'
1319
+
1320
+ const server = new QueryClient()
1321
+ await server.prefetchQuery({ queryKey: ['users'], queryFn: fetchUsers })
1322
+ const snapshot = dehydrate(server)
1323
+
1324
+ const client = new QueryClient()
1325
+ hydrate(client, snapshot)`,
1326
+ notes: '`@pyreon/query` re-exports the framework-agnostic TanStack surface so consumers import every primitive from one entry: `QueryClient` / `QueryCache` / `MutationCache` (instance classes), `dehydrate` / `hydrate` (SSR serialization), `keepPreviousData` (placeholder helper), `hashKey` / `isCancelledError` / `CancelledError`, and the `defaultShouldDehydrate*` predicates. Types (`QueryKey`, `QueryFilters`, `MutationFilters`, `DehydratedState`, `FetchQueryOptions`, `InvalidateQueryFilters`, `InvalidateOptions`, `RefetchQueryFilters`, `RefetchOptions`, `QueryClientConfig`) re-export alongside the runtime values. See also: QueryClientProvider, useQueryClient.',
1327
+ },
1328
+ // <gen-docs:api-reference:end @pyreon/query>
1329
+
1330
+ // ═══════════════════════════════════════════════════════════════════════════
1331
+ // @pyreon/hooks
1332
+ // ═══════════════════════════════════════════════════════════════════════════
1333
+
1334
+ // <gen-docs:api-reference:start @pyreon/hooks>
1335
+
1336
+ 'hooks/useControllableState': {
1337
+ signature: '<T>(opts: { value?: () => T | undefined; defaultValue: () => T; onChange?: (v: T) => void }) => [Signal<T>, (v: T) => void]',
1338
+ example: `function MyToggle(props: { checked?: boolean; defaultChecked?: boolean; onChange?: (v: boolean) => void }) {
1339
+ const [checked, setChecked] = useControllableState({
1340
+ value: () => props.checked,
1341
+ defaultValue: () => props.defaultChecked ?? false,
1342
+ onChange: props.onChange,
1343
+ })
1344
+ return <button onClick={() => setChecked(!checked())}>{() => checked() ? 'on' : 'off'}</button>
1345
+ }`,
1346
+ notes: 'Canonical controlled/uncontrolled state pattern. Returns a `[value, setValue]` tuple where the setter respects controlled mode (calls `onChange` only if controlled, mutates internal signal if uncontrolled). Used by every primitive in `@pyreon/ui-primitives`. Never reimplement the `isControlled + signal + getter` shape by hand. `value` and `defaultValue` are FUNCTIONS so signal reads track reactively — passing a plain value loses controlled/uncontrolled detection on prop changes. See also: useToggle, usePrevious.',
1347
+ mistakes: `- Passing \`value: props.checked\` (not a function) — loses reactivity on prop changes
1348
+ - Mutating the returned signal directly with \`.set()\` instead of using the returned setter — bypasses the controlled-mode check`,
1349
+ },
1350
+
1351
+ 'hooks/useEventListener': {
1352
+ signature: '(target: EventTarget | (() => EventTarget | null), event: string, handler: EventListener, options?: AddEventListenerOptions) => void',
1353
+ example: `useEventListener(window, 'resize', () => layoutSig.set(measure()))
1354
+ useEventListener(() => panelRef(), 'keydown', (e) => {
1355
+ if (e.key === 'Escape') setOpen(false)
630
1356
  })`,
631
- notes:
632
- 'TanStack Query adapter. Fine-grained signals per field. Reactive options via function getter. Also: useMutation, useInfiniteQuery, useSuspenseQuery, useSubscription (WebSocket).',
1357
+ notes: `Register a DOM event listener with automatic cleanup on unmount. Use this instead of raw \`addEventListener\` in primitives — never \`addEventListener\` / \`removeEventListener\` directly in component code (the cleanup is the hook's whole job). \`target\` may be a getter so reactive refs (\`() => buttonRef()\`) re-bind when the underlying element changes. See also: useClickOutside, useKeyboard.`,
1358
+ mistakes: `- Using raw \`addEventListener\` instead of \`useEventListener\` you lose automatic \`onUnmount\` cleanup
1359
+ - Passing a static \`window\` / \`document\` when the target might not exist on SSR — \`useEventListener\` handles SSR-safe registration internally, but the target must be resolvable at \`onMount\` time`,
1360
+ },
1361
+
1362
+ 'hooks/useClickOutside': {
1363
+ signature: '(ref: () => HTMLElement | null, handler: (e: MouseEvent) => void) => void',
1364
+ example: 'useClickOutside(() => panelRef(), () => setOpen(false))',
1365
+ notes: 'Fire a callback when the user clicks outside the referenced element. Foundation for click-to-dismiss popovers, dropdowns, modals. Pair with `useFocusTrap` + `useScrollLock` for the full modal package. See also: useFocusTrap, useScrollLock, useDialog.',
1366
+ mistakes: '- Attaching to a ref that encompasses the entire viewport — every click anywhere except the ref itself triggers the handler; use a more specific ref (the popover panel, not the whole page)',
1367
+ },
1368
+
1369
+ 'hooks/useElementSize': {
1370
+ signature: '(ref: () => HTMLElement | null) => Signal<{ width: number; height: number }>',
1371
+ example: `const size = useElementSize(() => boxRef())
1372
+ effect(() => console.log('Box is', size().width, 'x', size().height))`,
1373
+ notes: 'Reactive element size via `ResizeObserver`. Returns `Signal<{ width, height }>` that updates whenever the observed element resizes. SSR-safe (returns `{ width: 0, height: 0 }` until mount). See also: useWindowResize, useRootSize.',
1374
+ },
1375
+
1376
+ 'hooks/useFocusTrap': {
1377
+ signature: '(ref: () => HTMLElement | null, active: () => boolean) => void',
1378
+ example: `const isOpen = signal(false)
1379
+ useFocusTrap(() => modalRef(), () => isOpen())
1380
+ useScrollLock(() => isOpen())`,
1381
+ notes: 'Trap Tab/Shift+Tab focus inside the referenced element while `active()` is true. Required for modals / drawers / fullscreen overlays to be keyboard-accessible. Returns focus to the previously-focused element on deactivation. See also: useScrollLock, useDialog, useClickOutside.',
1382
+ mistakes: `- Forgetting the second argument \`active\` — always pass a reactive boolean (\`() => isOpen()\`) so the trap deactivates when the modal closes; a static \`true\` traps focus forever
1383
+ - Using on an element that isn't rendered yet — the ref getter must return the element at the time \`active\` becomes true; pair with a \`<Show>\` or reactive accessor that mounts the element first`,
1384
+ },
1385
+
1386
+ 'hooks/useBreakpoint': {
1387
+ signature: '() => Signal<{ xs: boolean; sm: boolean; md: boolean; lg: boolean; xl: boolean }>',
1388
+ example: `const bp = useBreakpoint()
1389
+ {() => bp().md ? <DesktopNav /> : <MobileNav />}`,
1390
+ notes: 'Reactive breakpoint flags driven by the **theme**, not raw media queries — reads `theme.breakpoints` so swapping themes (or unit systems) Just Works. Use `useMediaQuery` for one-off arbitrary queries. See also: useMediaQuery, useThemeValue.',
1391
+ mistakes: '- Using `useBreakpoint` for a one-off media query like `(prefers-contrast: more)` — `useBreakpoint` reads theme breakpoints only; use `useMediaQuery` for arbitrary media queries',
1392
+ },
1393
+
1394
+ 'hooks/useDebouncedValue': {
1395
+ signature: '<T>(source: Signal<T> | (() => T), delayMs: number) => Signal<T>',
1396
+ example: `const search = signal('')
1397
+ const debouncedSearch = useDebouncedValue(search, 300)
1398
+ effect(() => fetchResults(debouncedSearch()))`,
1399
+ notes: `Returns a debounced signal that only updates after \`delayMs\` of source-signal idle. Use for search-as-you-type, filter inputs, anywhere downstream effects shouldn't fire on every keystroke. The PAIR — \`useDebouncedCallback\` — debounces a function call instead of a value. See also: useDebouncedCallback, useThrottledCallback.`,
1400
+ mistakes: '- Reading the debounced signal immediately after setting the source — it still holds the OLD value during the debounce window; effects downstream of the debounced signal are correct, but imperative reads in the same tick are stale',
1401
+ },
1402
+
1403
+ 'hooks/useClipboard': {
1404
+ signature: '(timeoutMs?: number) => { copy: (text: string) => Promise<void>; copied: Signal<boolean> }',
1405
+ example: `const { copy, copied } = useClipboard()
1406
+ <button onClick={() => copy(token)}>{() => copied() ? 'Copied!' : 'Copy'}</button>`,
1407
+ notes: '`navigator.clipboard.writeText` wrapped with a reactive `copied` flag that auto-resets after `timeoutMs` (default 2000). Use the `copied` signal to flash a "Copied!" UI cue without manual timer management. See also: useDialog, useOnline.',
1408
+ },
1409
+
1410
+ 'hooks/useDialog': {
1411
+ signature: '() => { ref: (el: HTMLDialogElement | null) => void; open: () => void; close: (returnValue?: string) => void; isOpen: Signal<boolean>; returnValue: Signal<string> }',
1412
+ example: `const dialog = useDialog()
1413
+ <dialog ref={dialog.ref}>...</dialog>
1414
+ <button onClick={dialog.open}>Open</button>`,
1415
+ notes: `Native \`<dialog>\` element wrapper with reactive \`isOpen\` / \`returnValue\` signals. Handles \`showModal()\` / \`close()\` plumbing and the \`cancel\`/\`close\` event wiring so consumers don't reimplement the boilerplate. See also: useFocusTrap, useScrollLock.`,
1416
+ mistakes: '- Calling `dialog.open()` before the ref callback has fired — Pyreon components run once, so the `<dialog>` must be in the initial render (not behind a conditional `<Show>`); the ref callback fires synchronously during mount, and `dialog.open()` before that point has no element to call `showModal()` on',
1417
+ },
1418
+
1419
+ 'hooks/useTimeAgo': {
1420
+ signature: '(date: Date | (() => Date), opts?: UseTimeAgoOptions) => Signal<string>',
1421
+ example: `const sent = useTimeAgo(message.sentAt)
1422
+ <span>{sent}</span>`,
1423
+ notes: 'Reactive "5 minutes ago" / "in 2 hours" relative-time string. Auto-updates on a sensible interval (every minute under an hour, every hour under a day, etc.) so the UI stays accurate without manual scheduling. Cleans up the interval on unmount. See also: useInterval, useDebouncedValue.',
1424
+ },
1425
+
1426
+ 'hooks/useInfiniteScroll': {
1427
+ signature: '(onLoadMore: () => void | Promise<void>, opts?: { rootMargin?: string; threshold?: number; enabled?: () => boolean }) => { sentinelRef: (el: HTMLElement | null) => void; isLoading: Signal<boolean> }',
1428
+ example: `const { sentinelRef, isLoading } = useInfiniteScroll(loadNextPage, { rootMargin: '200px', enabled: () => hasMore() })
1429
+ <For each={items()} by={(i) => i.id}>{(item) => <Row data={item} />}</For>
1430
+ <div ref={sentinelRef}>{() => isLoading() && 'Loading…'}</div>`,
1431
+ notes: `\`IntersectionObserver\`-based infinite loading. Attach the returned \`sentinelRef\` to a node at the bottom of the list — when it scrolls into view, \`onLoadMore\` fires. \`isLoading\` blocks re-fires until the promise resolves. \`enabled\` accessor lets you stop observing once you've loaded the last page. See also: useIntersection.`,
1432
+ mistakes: `- Placing the sentinel inside a container with \`overflow: hidden\` and no scroll — IntersectionObserver never fires because the sentinel is always clipped; the sentinel must be inside the scrollable container
1433
+ - Forgetting to pass \`enabled: () => hasMore()\` — the hook keeps calling \`onLoadMore\` even after the last page`,
1434
+ },
1435
+
1436
+ 'hooks/useMergedRef': {
1437
+ signature: '<T>(...refs: (Ref<T> | RefCallback<T> | null | undefined)[]) => RefCallback<T>',
1438
+ example: `const localRef = ref<HTMLDivElement>()
1439
+ const merged = useMergedRef(localRef, props.ref)
1440
+ <div ref={merged}>...</div>`,
1441
+ notes: 'Combine multiple refs into a single callback ref — used when forwarding `props.ref` while also keeping a local ref to the same element. Each provided ref (callback or object) receives the element on mount and `null` on unmount. See also: useEventListener.',
1442
+ },
1443
+
1444
+ 'hooks/useUpdateEffect': {
1445
+ signature: '(fn: () => void | (() => void), deps: Signal<unknown>[]) => void',
1446
+ example: `useUpdateEffect(() => api.save(value()), [value])
1447
+ // Doesn't fire on initial mount — only on subsequent value changes`,
1448
+ notes: 'Like `effect` but skips the initial run — only fires when one of the tracked signals updates *after* mount. Use for "save on change but not on first render" patterns where the initial value is already persisted. See also: useIsomorphicLayoutEffect.',
1449
+ },
1450
+
1451
+ 'hooks/useIsomorphicLayoutEffect': {
1452
+ signature: '(fn: () => void | (() => void)) => void',
1453
+ example: `const ref = signal<HTMLDivElement | null>(null)
1454
+ useIsomorphicLayoutEffect(() => {
1455
+ const el = ref()
1456
+ if (el) widthSig.set(el.getBoundingClientRect().width)
1457
+ })`,
1458
+ notes: 'Runs a layout-phase effect on the client (synchronous, before paint) and a no-op on the server. Use when you need to read DOM measurements before the next paint without triggering an SSR mismatch warning. See also: useUpdateEffect, useElementSize.',
633
1459
  },
1460
+ // <gen-docs:api-reference:end @pyreon/hooks>
634
1461
 
635
1462
  // ═══════════════════════════════════════════════════════════════════════════
636
1463
  // @pyreon/permissions
637
1464
  // ═══════════════════════════════════════════════════════════════════════════
638
1465
 
1466
+ // <gen-docs:api-reference:start @pyreon/permissions>
1467
+
639
1468
  'permissions/createPermissions': {
640
- signature: 'createPermissions<T extends PermissionMap>(initial?: T): PermissionsInstance',
1469
+ signature: '<T extends PermissionMap>(initial?: T) => Permissions',
641
1470
  example: `const can = createPermissions({
642
1471
  'posts.read': true,
643
1472
  'posts.delete': (post) => post.authorId === userId,
@@ -648,17 +1477,39 @@ can('posts.read') // true (reactive)
648
1477
  can('posts.delete', post) // evaluates predicate
649
1478
  can.not('admin.dashboard')
650
1479
  can.all('posts.read', 'posts.create')
651
- can.any('admin.users', 'posts.read')`,
652
- notes:
653
- "Reactive permissions. Supports RBAC, ABAC, feature flags, subscription tiers. Wildcard matching with '*'. PermissionsProvider/usePermissions for context.",
1480
+ can.any('admin.users', 'posts.read')
1481
+ can.set({ 'admin.*': true }) // replace all
1482
+ can.patch({ 'posts.delete': true }) // merge`,
1483
+ notes: 'Create a reactive permissions instance. Returns a callable object — `can(key, context?)` checks a permission reactively (reads as a signal in effects and JSX). Permissions can be booleans or predicate functions `(context?) => boolean`. Supports wildcard keys (`admin.*`). The instance exposes `.not()`, `.all()`, `.any()` for multi-checks, and `.set()` / `.patch()` for runtime updates. See also: PermissionsProvider, usePermissions.',
1484
+ mistakes: `- Reading \`can("key")\` outside a reactive scope and expecting updates — the check is a signal read, it only re-evaluates inside \`effect()\`, \`computed()\`, or JSX expression thunks
1485
+ - Using a static object instead of a predicate for context-dependent checks — \`'posts.update': true\` always passes, use \`(post) => post.authorId === userId()\` for ABAC
1486
+ - Forgetting that wildcard \`admin.*\` only matches one level — \`admin.users.list\` is NOT matched by \`admin.*\`, only \`admin.users\` is`,
1487
+ },
1488
+
1489
+ 'permissions/PermissionsProvider': {
1490
+ signature: '(props: { value: Permissions; children: VNodeChild }) => VNodeChild',
1491
+ example: `<PermissionsProvider value={can}>
1492
+ <App />
1493
+ </PermissionsProvider>`,
1494
+ notes: 'Context provider that makes a permissions instance available to descendant components via `usePermissions()`. Enables SSR isolation (per-request permissions) and testing (override permissions per test). See also: usePermissions, createPermissions.',
654
1495
  },
655
1496
 
1497
+ 'permissions/usePermissions': {
1498
+ signature: '() => Permissions',
1499
+ example: `const can = usePermissions()
1500
+ return (() => can('admin.dashboard') ? <Dashboard /> : <AccessDenied />)`,
1501
+ notes: 'Consume the nearest `PermissionsProvider` value. Returns the same callable `Permissions` instance. Throws if no provider is mounted. See also: PermissionsProvider, createPermissions.',
1502
+ },
1503
+ // <gen-docs:api-reference:end @pyreon/permissions>
1504
+
656
1505
  // ═══════════════════════════════════════════════════════════════════════════
657
1506
  // @pyreon/machine
658
1507
  // ═══════════════════════════════════════════════════════════════════════════
659
1508
 
1509
+ // <gen-docs:api-reference:start @pyreon/machine>
1510
+
660
1511
  'machine/createMachine': {
661
- signature: 'createMachine<S, E>(config: MachineConfig<S, E>): Machine<S, E>',
1512
+ signature: '<S extends string, E extends string>(config: MachineConfig<S, E>) => Machine<S, E>',
662
1513
  example: `const traffic = createMachine({
663
1514
  initial: 'red',
664
1515
  states: {
@@ -672,77 +1523,168 @@ traffic() // 'red' (reactive)
672
1523
  traffic.send('NEXT') // 'green'
673
1524
  traffic.matches('green') // true
674
1525
  traffic.can('NEXT') // true`,
675
- notes:
676
- 'Constrained signal with type-safe transitions. Guards: { target, guard: (payload?) => boolean }. No context use signals alongside.',
1526
+ notes: 'Create a reactive state machine. The returned machine reads like a signal (`machine()` returns the current state string) and transitions via `machine.send(event, payload?)`. States and events are type-safe — TypeScript infers the union from the config object. Guards enable conditional transitions with typed payloads. No built-in context or effects — use Pyreon signals and `effect()` alongside the machine for data and side effects. See also: Machine, MachineConfig.',
1527
+ mistakes: `- Expecting \`machine.send()\` to return the new state it returns void; read the state with \`machine()\` after sending
1528
+ - Calling \`machine.set()\` — machines are constrained signals, they do not expose \`.set()\`. State changes only happen through \`machine.send(event)\`
1529
+ - Using a machine for data storage — machines only hold the current state string. Use regular signals alongside the machine for associated data
1530
+ - Forgetting guard payloads — \`machine.send("LOGIN")\` without the required payload silently fails the guard`,
677
1531
  },
1532
+ // <gen-docs:api-reference:end @pyreon/machine>
678
1533
 
679
1534
  // ═══════════════════════════════════════════════════════════════════════════
680
1535
  // @pyreon/storage
681
1536
  // ═══════════════════════════════════════════════════════════════════════════
682
1537
 
1538
+ // <gen-docs:api-reference:start @pyreon/storage>
1539
+
683
1540
  'storage/useStorage': {
684
- signature:
685
- 'useStorage<T>(key: string, defaultValue: T, options?: StorageOptions<T>): StorageSignal<T>',
1541
+ signature: '<T>(key: string, defaultValue: T, options?: StorageOptions<T>) => StorageSignal<T>',
686
1542
  example: `const theme = useStorage('theme', 'light')
687
1543
  theme() // 'light'
688
1544
  theme.set('dark') // persists + cross-tab sync
689
- theme.remove() // delete from storage`,
690
- notes:
691
- 'localStorage by default. Also: useSessionStorage, useCookie, useIndexedDB, useMemoryStorage, createStorage(backend). All return StorageSignal<T> extending Signal<T> with .remove().',
1545
+ theme.remove() // delete from storage, reset to default`,
1546
+ notes: 'Create a reactive signal backed by localStorage. Reads the stored value on creation (falling back to `defaultValue` if absent or on SSR), writes on every `.set()`, and syncs across browser tabs via `storage` events. Returns `StorageSignal<T>` which extends `Signal<T>` with `.remove()` to delete the key and reset to default. Serialization defaults to JSON; provide custom `serialize`/`deserialize` in options for non-JSON types. See also: useSessionStorage, useCookie, useIndexedDB, createStorage.',
1547
+ mistakes: `- Expecting cross-tab sync with \`useSessionStorage\` only \`useStorage\` (localStorage) fires storage events across tabs
1548
+ - Storing non-serializable values (functions, class instances) without custom \`serialize\`/\`deserialize\` — JSON.stringify drops them silently
1549
+ - Reading \`.remove()\` return value — it returns void, not the removed value`,
1550
+ },
1551
+
1552
+ 'storage/useCookie': {
1553
+ signature: '<T>(key: string, defaultValue: T, options?: CookieOptions) => StorageSignal<T>',
1554
+ example: `const locale = useCookie('locale', 'en', { maxAge: 365 * 86400, path: '/' })
1555
+ locale.set('fr')`,
1556
+ notes: 'Reactive signal backed by browser cookies. SSR-readable — on the server, reads from the request cookie header via `setCookieSource()`. Options include `maxAge`, `path`, `domain`, `sameSite`, `secure`. Same `StorageSignal<T>` return type as other hooks. See also: useStorage, setCookieSource.',
1557
+ },
1558
+
1559
+ 'storage/useIndexedDB': {
1560
+ signature: '<T>(key: string, defaultValue: T, options?: IndexedDBOptions) => StorageSignal<T>',
1561
+ example: `const draft = useIndexedDB('article-draft', { title: '', body: '' })
1562
+ draft.set({ title: 'New Article', body: 'Content...' })`,
1563
+ notes: 'Reactive signal backed by IndexedDB for large data. Writes are debounced to avoid excessive I/O. The signal initializes with `defaultValue` synchronously and hydrates from IndexedDB asynchronously — the value updates reactively once the read completes. Silent init error logging in dev mode. See also: useStorage, useMemoryStorage.',
1564
+ },
1565
+
1566
+ 'storage/createStorage': {
1567
+ signature: '(backend: StorageBackend | AsyncStorageBackend) => <T>(key: string, defaultValue: T, options?: StorageOptions<T>) => StorageSignal<T>',
1568
+ example: `const useEncrypted = createStorage({
1569
+ getItem: (key) => decrypt(localStorage.getItem(key)),
1570
+ setItem: (key, value) => localStorage.setItem(key, encrypt(value)),
1571
+ removeItem: (key) => localStorage.removeItem(key),
1572
+ })
1573
+ const secret = useEncrypted('api-key', '')`,
1574
+ notes: 'Factory for custom storage backends. Pass an object with `getItem`, `setItem`, `removeItem` methods (sync or async) and receive a hook function with the same signature as `useStorage`. Use for encrypted storage, remote backends, or any custom persistence layer. See also: useStorage.',
692
1575
  },
1576
+ // <gen-docs:api-reference:end @pyreon/storage>
693
1577
 
694
1578
  // ═══════════════════════════════════════════════════════════════════════════
695
1579
  // @pyreon/i18n
696
1580
  // ═══════════════════════════════════════════════════════════════════════════
697
1581
 
698
- 'i18n/createI18n': {
699
- signature:
700
- 'createI18n(options: { locale: string, messages: Record<string, Record<string, string>>, loader?, fallbackLocale?, pluralRules? }): I18nInstance',
701
- example: `// Full entry — includes JSX components (Trans, I18nProvider, useI18n)
702
- import { createI18n, useI18n } from '@pyreon/i18n'
1582
+ // <gen-docs:api-reference:start @pyreon/i18n>
703
1583
 
704
- const i18n = createI18n({
1584
+ 'i18n/createI18n': {
1585
+ signature: '(options: I18nOptions) => I18nInstance',
1586
+ example: `const i18n = createI18n({
705
1587
  locale: 'en',
706
1588
  messages: { en: { greeting: 'Hello, {{name}}!' } },
707
1589
  loader: (locale, ns) => import(\`./locales/\${locale}/\${ns}.json\`),
1590
+ fallbackLocale: 'en',
708
1591
  })
709
1592
 
710
- const { t, locale } = useI18n()
711
- t('greeting', { name: 'World' }) // "Hello, World!"
712
- locale.set('fr') // switch reactively
1593
+ i18n.t('greeting', { name: 'World' }) // "Hello, World!"
1594
+ i18n.locale.set('fr') // switch reactively`,
1595
+ notes: 'Create a reactive i18n instance. Returns `{ t, locale, addMessages, loadNamespace }`. The `t(key, values?)` function resolves translations reactively — changing `locale` via `.set()` re-evaluates all `t()` reads in reactive scopes. Supports `{{name}}` interpolation, `_one`/`_other` plural suffixes, namespace lazy loading with deduplication, fallback locale, and custom plural rules. Available from both `@pyreon/i18n` and `@pyreon/i18n/core`. See also: I18nProvider, useI18n, Trans, interpolate.',
1596
+ mistakes: `- Reading \`t(key)\` outside a reactive scope and expecting updates on locale change — \`t()\` is a reactive signal read, wrap in JSX thunk or \`effect()\`
1597
+ - Using \`@pyreon/i18n\` on the backend — use \`@pyreon/i18n/core\` instead, it has zero JSX/core dependencies
1598
+ - Forgetting \`fallbackLocale\` — missing keys in the current locale return the key string instead of falling back to another language`,
1599
+ },
713
1600
 
714
- // Backend / non-JSX entry — @pyreon/i18n/core
715
- // Zero JSX dependencies, transitively only @pyreon/reactivity.
716
- // Use this on backends, edge workers, non-Pyreon frontends.
717
- import { createI18n } from '@pyreon/i18n/core'
718
- const backendI18n = createI18n({ locale: 'en', messages: { en: { hello: 'Hi' } } })
719
- backendI18n.t('hello')`,
720
- notes:
721
- 'Interpolation with {{name}}, pluralization with _one/_other suffixes. Namespace lazy loading. <Trans> component for rich JSX interpolation. TWO ENTRY POINTS: `@pyreon/i18n` (full, with JSX components) vs `@pyreon/i18n/core` (framework-agnostic, zero JSX deps — use for backends and non-Pyreon consumers). Both return identical I18nInstance objects.',
722
- mistakes: `- Using \`@pyreon/i18n\` (the main entry) on a backend without a JSX-aware tsconfig — the bun condition resolves to source which transitively includes the Trans JSX component. Use \`@pyreon/i18n/core\` instead.
723
- - Reading the README example and importing from \`@pyreon/i18n\` in a non-Pyreon project — that path works for Pyreon UIs but the README now documents \`/core\` as the backend recommendation.
724
- - Trying to use \`<Trans>\` from \`@pyreon/i18n/core\` — it's intentionally not exported there. Import it from the main \`@pyreon/i18n\` entry instead.`,
1601
+ 'i18n/I18nProvider': {
1602
+ signature: '(props: I18nProviderProps) => VNodeChild',
1603
+ example: `<I18nProvider value={i18n}>
1604
+ <App />
1605
+ </I18nProvider>`,
1606
+ notes: 'Context provider that makes an i18n instance available to descendant components via `useI18n()`. Only available from the full `@pyreon/i18n` entry, not from `/core`. See also: useI18n, createI18n.',
725
1607
  },
726
1608
 
1609
+ 'i18n/useI18n': {
1610
+ signature: '() => I18nInstance',
1611
+ example: `const { t, locale } = useI18n()
1612
+ return <div>{() => t('greeting', { name: 'User' })}</div>`,
1613
+ notes: 'Consume the nearest `I18nProvider` value. Returns the same `I18nInstance` with `t`, `locale`, `addMessages`, etc. Only available from the full `@pyreon/i18n` entry. See also: I18nProvider, createI18n.',
1614
+ },
1615
+
1616
+ 'i18n/Trans': {
1617
+ signature: '(props: TransProps) => VNodeChild',
1618
+ example: `// Message: "Please <link>click here</link> to continue"
1619
+ <Trans key="action" components={{ link: <a href="/next" /> }}>
1620
+ Please <link>click here</link> to continue
1621
+ </Trans>`,
1622
+ notes: 'Rich text interpolation component. Translates a key and replaces named placeholders with JSX components. Use for translations that contain markup (bold, links, etc.) that cannot be expressed as plain string interpolation. See also: createI18n, useI18n.',
1623
+ },
1624
+
1625
+ 'i18n/interpolate': {
1626
+ signature: '(template: string, values?: InterpolationValues) => string',
1627
+ example: `interpolate('Hello, {{name}}!', { name: 'World' }) // 'Hello, World!'`,
1628
+ notes: 'Pure string interpolation — replaces `{{name}}` placeholders with values from the map. Available from both entries. Use directly when you need interpolation without the full i18n instance (e.g. server-side email templates). See also: createI18n.',
1629
+ },
1630
+ // <gen-docs:api-reference:end @pyreon/i18n>
1631
+
727
1632
  // ═══════════════════════════════════════════════════════════════════════════
728
1633
  // @pyreon/document
729
1634
  // ═══════════════════════════════════════════════════════════════════════════
730
1635
 
1636
+ // <gen-docs:api-reference:start @pyreon/document>
1637
+
1638
+ 'document/render': {
1639
+ signature: '(node: DocNode, format: OutputFormat, options?: RenderOptions) => Promise<RenderResult>',
1640
+ example: `const pdf = await render(doc, 'pdf') // Uint8Array
1641
+ const html = await render(doc, 'html') // string
1642
+ const email = await render(doc, 'email') // Outlook-safe HTML
1643
+ const md = await render(doc, 'md') // Markdown string
1644
+ const slack = await render(doc, 'slack') // Slack Block Kit JSON`,
1645
+ notes: 'Render a document node tree to any supported format. Returns a string (HTML, Markdown, text, CSV, email, Slack, Teams, etc.) or Uint8Array (PDF, DOCX, XLSX, PPTX) depending on the format. Heavy format renderers are lazy-loaded on first use. Supports 14+ built-in formats plus custom renderers registered via `registerRenderer()`. See also: createDocument, Document, download, registerRenderer.',
1646
+ mistakes: `- Not awaiting the render call — render() is always async due to lazy-loaded format renderers
1647
+ - Expecting render("pdf") to return a string — PDF, DOCX, XLSX, PPTX return Uint8Array
1648
+ - Passing a VNode instead of a DocNode — render() expects the output of JSX primitives (Document, Page, etc.) or createDocument(), not arbitrary Pyreon VNodes`,
1649
+ },
1650
+
731
1651
  'document/createDocument': {
732
- signature: 'createDocument(props?: DocumentProps): DocumentBuilder',
1652
+ signature: '(props?: DocumentProps) => DocumentBuilder',
733
1653
  example: `const doc = createDocument({ title: 'Report' })
734
1654
  .heading('Sales Report')
1655
+ .text('Q4 2026 summary.')
735
1656
  .table({ columns: ['Region', 'Revenue'], rows: [['US', '$1M']] })
736
1657
 
737
- await doc.toPdf() // PDF
1658
+ await doc.toPdf() // PDF Uint8Array
738
1659
  await doc.toEmail() // Outlook-safe HTML
739
- await doc.toDocx() // Word document
740
- await doc.toSlack() // Slack Block Kit JSON
741
- await doc.toNotion() // Notion blocks`,
742
- notes:
743
- '14+ output formats. JSX primitives: Document, Page, Heading, Text, Table, Image, List, Code, etc. Heavy renderers lazy-loaded.',
1660
+ await doc.toDocx() // Word document`,
1661
+ notes: 'Fluent builder API for constructing documents without JSX. Chain `.heading()`, `.text()`, `.table()`, `.image()`, `.list()`, `.code()`, `.divider()`, `.page()` calls. Terminal methods: `.toPdf()`, `.toDocx()`, `.toEmail()`, `.toSlack()`, `.toNotion()`, `.toHtml()`, `.toMarkdown()`, etc. Each terminal method calls `render()` internally. See also: render, Document.',
1662
+ mistakes: `- Forgetting to await terminal methods — toPdf(), toDocx(), etc. are async
1663
+ - Calling builder methods after a terminal method — the builder is consumed; create a new one`,
1664
+ },
1665
+
1666
+ 'document/Document': {
1667
+ signature: '(props: DocumentProps) => DocNode',
1668
+ example: `const doc = (
1669
+ <Document title="Report" author="Team">
1670
+ <Page>
1671
+ <Heading>Title</Heading>
1672
+ <Text>Content</Text>
1673
+ </Page>
1674
+ </Document>
1675
+ )
1676
+ await render(doc, 'pdf')`,
1677
+ notes: 'Root JSX primitive for document trees. Accepts `title`, `author`, `subject` as metadata props. Children should be `Page` elements (or other block-level primitives for single-page documents). The returned DocNode is passed to `render()` for output. See also: render, Page, createDocument.',
744
1678
  },
745
1679
 
1680
+ 'document/download': {
1681
+ signature: '(data: Uint8Array | string, filename: string) => void',
1682
+ example: `const pdf = await render(doc, 'pdf')
1683
+ download(pdf, 'report.pdf')`,
1684
+ notes: 'Browser helper that triggers a file download from rendered document data. Creates a temporary Blob URL and clicks a hidden anchor element. Works with both Uint8Array (PDF, DOCX) and string (HTML, Markdown) outputs from `render()`. See also: render.',
1685
+ },
1686
+ // <gen-docs:api-reference:end @pyreon/document>
1687
+
746
1688
  // ═══════════════════════════════════════════════════════════════════════════
747
1689
  // @pyreon/flow
748
1690
  // ═══════════════════════════════════════════════════════════════════════════
@@ -888,147 +1830,295 @@ flow.addEdge({ source: '1', sourceHandle: 'out-primary', target: '2' })`,
888
1830
 
889
1831
  // ═══════════════════════════════════════════════════════════════════════════
890
1832
  // @pyreon/code
1833
+
1834
+ // ═══════════════════════════════════════════════════════════════════════════
1835
+ // @pyreon/charts
891
1836
  // ═══════════════════════════════════════════════════════════════════════════
892
1837
 
1838
+ // <gen-docs:api-reference:start @pyreon/charts>
1839
+
1840
+ 'charts/useChart': {
1841
+ signature: '<TOption extends EChartsOption = EChartsOption>(optionsFn: () => TOption, config?: UseChartConfig) => UseChartResult',
1842
+ example: `const chart = useChart(() => ({
1843
+ xAxis: { type: 'category', data: months() },
1844
+ yAxis: { type: 'value' },
1845
+ series: [{ type: 'bar', data: revenue() }],
1846
+ }))
1847
+
1848
+ <div ref={chart.ref} style="height: 400px" />
1849
+ // chart.loading() — true until ECharts modules loaded + chart initialized
1850
+ // chart.instance() — raw ECharts instance for imperative API`,
1851
+ notes: 'Create a reactive ECharts instance. Options are passed as a function — signal reads inside are tracked and the chart updates automatically when any tracked signal changes. Lazy-loads the required ECharts modules on first render (zero bytes until mount). Returns `ref` (bind to a container div), `instance` (Signal<ECharts | null>), `loading` (Signal<boolean>), `error` (Signal<Error | null>), and `resize()`. Auto-resizes via ResizeObserver and disposes on unmount. See also: Chart.',
1852
+ mistakes: `- Forgetting to set a height on the container div — ECharts requires explicit dimensions, it does not auto-size to content
1853
+ - Passing options as a plain object instead of a function — signal reads are not tracked and the chart never updates
1854
+ - Reading chart.instance() immediately after useChart — the instance is null until the async module load completes; check chart.loading() first
1855
+ - Calling chart.resize() during SSR — useChart is browser-only; the hook no-ops safely on the server but resize is meaningless`,
1856
+ },
1857
+
1858
+ 'charts/Chart': {
1859
+ signature: '(props: ChartProps) => VNodeChild',
1860
+ example: `<Chart
1861
+ options={() => ({
1862
+ series: [{ type: 'pie', data: [{ value: 60, name: 'A' }, { value: 40, name: 'B' }] }],
1863
+ })}
1864
+ style="height: 300px"
1865
+ onClick={(params) => alert(params.name)}
1866
+ />`,
1867
+ notes: 'Declarative chart component that wraps `useChart` internally. Accepts `options` (reactive function), `style`/`class` for the container, and event handlers (`onClick`, `onMouseover`, etc.) that bind to the ECharts instance. Renders a div with the chart — auto-resizes and cleans up on unmount. Simpler than useChart for most use cases. See also: useChart.',
1868
+ mistakes: `- Missing style height on the Chart component — same as useChart, ECharts requires explicit container dimensions
1869
+ - Passing a static options object — wrap in \`() => ({...})\` so signal reads inside are tracked reactively`,
1870
+ },
1871
+ // <gen-docs:api-reference:end @pyreon/charts>
1872
+ // ═══════════════════════════════════════════════════════════════════════════
1873
+
1874
+ // <gen-docs:api-reference:start @pyreon/code>
1875
+
893
1876
  'code/createEditor': {
894
- signature:
895
- 'createEditor(config: { value?: string, language?: EditorLanguage, theme?: EditorTheme, onChange?: (val: string) => void, minimap?: boolean, lineNumbers?: boolean, ... }): EditorInstance',
1877
+ signature: '(config: EditorConfig) => EditorInstance',
896
1878
  example: `const editor = createEditor({
897
1879
  value: '// hello',
898
1880
  language: 'typescript',
899
1881
  theme: 'dark',
900
1882
  minimap: true,
901
- onChange: (next) => console.log('user edit:', next),
1883
+ onChange: (next) => console.log('edit:', next),
902
1884
  })
903
1885
 
904
- editor.value() // reactive Signal<string>, read inside JSX/effects
905
- editor.value.set('new') // write back into CodeMirror
906
- editor.cursor() // computed { line, col }
1886
+ editor.value() // reactive read
1887
+ editor.value.set('new') // write into CodeMirror
1888
+ editor.cursor() // { line, col }
907
1889
  editor.lineCount() // computed
908
1890
  editor.goToLine(42)
909
- editor.insert('new code')
910
- editor.setDiagnostics([{ from: 0, to: 5, severity: 'error', message: '...' }])
1891
+ editor.insert('code')
911
1892
 
912
- <CodeEditor instance={editor} style="height: 400px" />
913
- <DiffEditor original="old" modified="new" language="typescript" />`,
914
- notes:
915
- "Built on CodeMirror 6 (~250KB vs Monaco's ~2.5MB). 19 languages via lazy-loaded grammars (declared as optionalDependencies). Two-way binding: editor.value is a writable Signal pass onChange for editor → external, set editor.value for external → editor. For external↔editor binding with built-in loop prevention, use the higher-level `bindEditorToSignal({ editor, signal, serialize, parse })` helper instead of hand-rolling the flag pattern. <CodeEditor> auto-mounts and cleans up on unmount.",
916
- mistakes: `- Forgetting to declare @pyreon/runtime-dom in consumer app deps — <CodeEditor> JSX emits _tpl() which needs runtime-dom imports
917
- - Hand-rolling the applyingFromExternal/applyingFromEditor flag pattern for two-way binding — use the bindEditorToSignal helper instead, it handles the loop prevention correctly and is tested
1893
+ <CodeEditor instance={editor} style="height: 400px" />`,
1894
+ notes: 'Create a reactive editor instance. `editor.value` is a writable Signal<string> `editor.value()` reads reactively, `editor.value.set(next)` writes back into CodeMirror. `editor.cursor` and `editor.lineCount` are computed signals. Config accepts value, language, theme, minimap, lineNumbers, foldGutter, onChange, and more. The instance is framework-independent — mount it via `<CodeEditor instance={editor} />`. See also: CodeEditor, bindEditorToSignal, loadLanguage.',
1895
+ mistakes: `- Forgetting to declare @pyreon/runtime-dom in consumer app deps — <CodeEditor> JSX emits _tpl() which needs runtime-dom
1896
+ - Hand-rolling the applyingFromExternal/applyingFromEditor flag pattern — use bindEditorToSignal instead
918
1897
  - Calling editor methods before mount — they no-op safely but changes don't persist
919
1898
  - Setting both vim: true and emacs: true — emacs wins`,
920
1899
  },
921
1900
 
922
1901
  'code/bindEditorToSignal': {
923
- signature:
924
- 'bindEditorToSignal<T>(options: { editor: EditorInstance, signal: SignalLike<T>, serialize: (val: T) => string, parse: (text: string) => T | null, onParseError?: (err: Error) => void }): { dispose: () => void }',
925
- example: `import { bindEditorToSignal, createEditor } from '@pyreon/code'
926
- import { signal } from '@pyreon/reactivity'
927
-
928
- interface Doc { name: string; count: number }
929
- const data = signal<Doc>({ name: 'Alice', count: 1 })
930
-
931
- const editor = createEditor({
932
- value: JSON.stringify(data(), null, 2),
933
- language: 'json',
934
- })
1902
+ signature: '<T>(options: BindEditorToSignalOptions<T>) => EditorBinding',
1903
+ example: `const data = signal<Doc>({ name: 'Alice', count: 1 })
1904
+ const editor = createEditor({ value: JSON.stringify(data(), null, 2), language: 'json' })
935
1905
 
936
1906
  const binding = bindEditorToSignal({
937
1907
  editor,
938
- signal: data, // accepts Signal<T> or any SignalLike<T>
1908
+ signal: data,
939
1909
  serialize: (val) => JSON.stringify(val, null, 2),
940
- parse: (text) => {
941
- try { return JSON.parse(text) } catch { return null }
942
- },
1910
+ parse: (text) => { try { return JSON.parse(text) } catch { return null } },
943
1911
  onParseError: (err) => console.warn(err.message),
944
1912
  })
1913
+ // binding.dispose() on unmount`,
1914
+ notes: 'Two-way binding between an editor instance and an external Signal<T> (or SignalLike<T>). Replaces the recurring loop-prevention flag-pair boilerplate. Round-trips through user-supplied `serialize`/`parse` functions. Internal flags break the format-on-input race; parse failures call `onParseError` and leave the external state at its last valid value. Returns `{ dispose }` for cleanup. See also: createEditor.',
1915
+ mistakes: `- Forgetting to call binding.dispose() on unmount — leaks both effects
1916
+ - Non-deterministic serialize() — if serialize(parse(text)) varies on each call, the helper dispatches redundant writes that fight the user's typing
1917
+ - Returning a non-null value from parse() for malformed input — return null on failure, or throw
1918
+ - Using bindEditorToSignal AND a manual editor.value.set() loop — defeats loop prevention`,
1919
+ },
945
1920
 
946
- // Later, on unmount:
947
- binding.dispose()`,
948
- notes:
949
- "Replaces the recurring loop-prevention flag-pair boilerplate (applyingFromExternal / applyingFromEditor) that consumers had to hand-roll for two-way external↔editor binding. The helper manages both directions, breaks the format-on-input race via internal flags, catches parse errors, and returns a disposable. Accepts any SignalLike<T> (Pyreon Signal, custom store wrapper, etc.). The editor itself ALSO has internal CM↔signal loop guards — this helper adds the SECOND layer for the external↔editor boundary.",
950
- mistakes: `- Forgetting to call binding.dispose() on unmount — leaks both effects until the editor instance is GC'd
951
- - Non-deterministic serialize() — if serialize(parse(text)) returns a string structurally different from the input text, the helper dispatches redundant editor writes that fight the user's typing. JSON.stringify with consistent indentation is fine; pretty-printing that varies on every call is not
952
- - Throwing in parse() without an onParseError handler — the helper catches and silently no-ops if no handler is provided. Pass onParseError to surface parse errors in your UI
953
- - Returning a non-null value from parse() for malformed input — the helper writes whatever you return, including partial / corrupted state. Return null on parse failure, or throw with an error message
954
- - Using bindEditorToSignal AND a manual editor.value.set() loop in the same component — defeats the loop prevention. Pick one binding strategy per editor instance`,
1921
+ 'code/CodeEditor': {
1922
+ signature: '(props: CodeEditorProps) => VNodeChild',
1923
+ example: '<CodeEditor instance={editor} style="height: 400px" class="my-editor" />',
1924
+ notes: 'Mount component for a `createEditor` instance. Accepts `instance`, `style`, `class`, and passes through to a container div. Auto-mounts the CodeMirror view on render and cleans up on unmount. See also: createEditor, DiffEditor, TabbedEditor.',
1925
+ },
1926
+
1927
+ 'code/DiffEditor': {
1928
+ signature: '(props: DiffEditorProps) => VNodeChild',
1929
+ example: '<DiffEditor original="old code" modified="new code" language="typescript" />',
1930
+ notes: 'Side-by-side diff editor. Accepts `original` and `modified` strings plus optional `language` and `theme`. Renders two CodeMirror instances with unified diff highlighting via @codemirror/merge. See also: CodeEditor, TabbedEditor.',
1931
+ },
1932
+
1933
+ 'code/loadLanguage': {
1934
+ signature: '(lang: EditorLanguage) => Promise<void>',
1935
+ example: `await loadLanguage('python')
1936
+ // Now 'python' is available in createEditor({ language: 'python' })`,
1937
+ notes: 'Lazy-load a language grammar. Supports 19 languages: json, typescript, javascript, python, css, html, markdown, rust, go, java, cpp, sql, xml, yaml, php, and more. Grammars are declared as optional dependencies and loaded on demand. See also: createEditor, getAvailableLanguages.',
955
1938
  },
956
1939
 
1940
+ 'code/minimapExtension': {
1941
+ signature: '() => Extension',
1942
+ example: `const editor = createEditor({ value: longCode, minimap: true })
1943
+ // or: import { minimapExtension } from '@pyreon/code'`,
1944
+ notes: 'CodeMirror extension that renders a canvas-based code overview minimap. Enable via `createEditor({ minimap: true })` or add the extension manually to a CodeMirror state. See also: createEditor.',
1945
+ },
1946
+ // <gen-docs:api-reference:end @pyreon/code>
1947
+
957
1948
  // ═══════════════════════════════════════════════════════════════════════════
958
1949
  // @pyreon/hotkeys
959
1950
  // ═══════════════════════════════════════════════════════════════════════════
960
1951
 
1952
+ // <gen-docs:api-reference:start @pyreon/hotkeys>
1953
+
961
1954
  'hotkeys/useHotkey': {
962
- signature:
963
- 'useHotkey(shortcut: string, handler: (e: KeyboardEvent) => void, options?: HotkeyOptions): void',
1955
+ signature: '(shortcut: string, handler: (e: KeyboardEvent) => void, options?: HotkeyOptions) => void',
964
1956
  example: `useHotkey('mod+s', (e) => {
965
1957
  e.preventDefault()
966
1958
  save()
967
- })
1959
+ }, { description: 'Save' })
968
1960
 
969
- useHotkey('mod+k', () => openSearch(), { scope: 'global' })
970
- useHotkeyScope('editor') // activate scope for component lifetime`,
971
- notes:
972
- "Component-scoped, auto-unregisters on unmount. 'mod' = on Mac, Ctrl elsewhere. Scope-based activation for context-aware shortcuts.",
1961
+ useHotkey('ctrl+z', () => undo(), { scope: 'editor' })
1962
+ useHotkey('escape', () => close(), { enableOnFormElements: true })`,
1963
+ notes: `Register a keyboard shortcut that auto-unregisters when the component unmounts. Shortcut format: \`mod+s\`, \`ctrl+shift+p\`, \`escape\`, etc. \`mod\` is Command on Mac, Ctrl elsewhere. By default, shortcuts don't fire when focused on form elements (input, textarea, select) — override with \`enableOnFormElements: true\`. Supports \`scope\` option for context-aware activation and \`description\` for introspection. See also: useHotkeyScope, registerHotkey.`,
1964
+ mistakes: `- Forgetting e.preventDefault() for browser-reserved shortcuts (mod+s, mod+p) the browser dialog fires alongside your handler
1965
+ - Registering the same shortcut in overlapping scopes without priority — both handlers fire; use scope isolation to prevent conflicts
1966
+ - Using useHotkey outside a component body — the onUnmount cleanup requires an active component setup context
1967
+ - Not activating the scope — useHotkey with a scope option does nothing unless useHotkeyScope(scope) is called or enableScope(scope) is invoked`,
1968
+ },
1969
+
1970
+ 'hotkeys/useHotkeyScope': {
1971
+ signature: '(scope: string) => void',
1972
+ example: `// In an editor component:
1973
+ useHotkeyScope('editor')
1974
+ useHotkey('ctrl+z', () => undo(), { scope: 'editor' })
1975
+
1976
+ // In a modal component:
1977
+ useHotkeyScope('modal')
1978
+ useHotkey('escape', () => close(), { scope: 'modal' })`,
1979
+ notes: 'Activate a hotkey scope for the lifetime of the current component. When the component mounts, the scope is enabled; when it unmounts, the scope is disabled. Shortcuts registered with a matching `scope` option only fire when the scope is active. Multiple components can activate the same scope — it stays active until the last one unmounts. See also: useHotkey, enableScope, disableScope.',
1980
+ mistakes: `- Using useHotkeyScope outside a component body — the lifecycle hooks require an active setup context
1981
+ - Assuming scope deactivation is immediate on unmount — if another component also activated the scope, it stays active`,
1982
+ },
1983
+
1984
+ 'hotkeys/registerHotkey': {
1985
+ signature: '(shortcut: string, handler: (e: KeyboardEvent) => void, options?: HotkeyOptions) => () => void',
1986
+ example: `const unregister = registerHotkey('ctrl+q', () => quit(), { scope: 'global' })
1987
+ // Later:
1988
+ unregister()`,
1989
+ notes: 'Imperative hotkey registration for non-component contexts (stores, global setup). Returns an unregister function. Unlike useHotkey, this does NOT auto-cleanup on unmount — caller is responsible for calling the returned unregister function. See also: useHotkey.',
973
1990
  },
1991
+ // <gen-docs:api-reference:end @pyreon/hotkeys>
974
1992
 
975
1993
  // ═══════════════════════════════════════════════════════════════════════════
976
1994
  // @pyreon/table
977
1995
  // ═══════════════════════════════════════════════════════════════════════════
978
1996
 
1997
+ // <gen-docs:api-reference:start @pyreon/table>
1998
+
979
1999
  'table/useTable': {
980
- signature: 'useTable<T>(options: TableOptions<T>): Table<T>',
981
- example: `const table = useTable({
982
- data: () => users(),
2000
+ signature: '<TData extends RowData>(options: () => TableOptions<TData>) => Computed<Table<TData>>',
2001
+ example: `const table = useTable(() => ({
2002
+ data: users(),
983
2003
  columns: [
984
2004
  { accessorKey: 'name', header: 'Name' },
985
2005
  { accessorKey: 'email', header: 'Email' },
986
2006
  ],
987
- })
2007
+ getCoreRowModel: getCoreRowModel(),
2008
+ }))
988
2009
 
989
- // flexRender for column templates:
2010
+ // Read inside reactive scope:
2011
+ <For each={() => table().getRowModel().rows} by={(r) => r.id}>
2012
+ {(row) => <tr>...</tr>}
2013
+ </For>`,
2014
+ notes: 'Create a reactive TanStack Table instance. Options are passed as a function so reactive signals (data, columns, sorting state) can be read inside and the table updates automatically when they change. Returns a Computed<Table<T>> — read it inside JSX expression thunks or effects to track state changes. Internal state management uses a version counter to force re-notification even when the table reference is the same object. See also: flexRender.',
2015
+ mistakes: `- Passing options as a plain object instead of a function — signal reads are not tracked and the table never updates when data changes
2016
+ - Reading \`table\` without calling it — \`table\` is a Computed, you must call \`table()\` to get the Table instance
2017
+ - Forgetting getCoreRowModel() — TanStack Table requires at least getCoreRowModel in options or it throws
2018
+ - Using \`.map()\` on rows instead of \`<For>\` — loses Pyreon's keyed reconciliation and fine-grained DOM updates`,
2019
+ },
2020
+
2021
+ 'table/flexRender': {
2022
+ signature: '<TData extends RowData, TValue>(component: Renderable<TValue>, props: TValue) => unknown',
2023
+ example: `// Header:
2024
+ flexRender(header.column.columnDef.header, header.getContext())
2025
+ // Cell:
990
2026
  flexRender(cell.column.columnDef.cell, cell.getContext())`,
991
- notes: 'TanStack Table adapter with reactive options and auto state sync.',
2027
+ notes: 'Render a TanStack Table column definition template (header, cell, or footer). Handles strings, numbers, functions (component functions or render functions), and VNodes. Returns the rendered output or null for undefined/null inputs. Use in JSX to render column definitions provided by TanStack Table. See also: useTable.',
2028
+ mistakes: `- Wrapping flexRender output in an extra function accessor — the result is already renderable JSX content
2029
+ - Passing the column def directly instead of calling getContext() — TanStack Table requires the context object`,
992
2030
  },
2031
+ // <gen-docs:api-reference:end @pyreon/table>
993
2032
 
994
2033
  // ═══════════════════════════════════════════════════════════════════════════
995
2034
  // @pyreon/virtual
996
2035
  // ═══════════════════════════════════════════════════════════════════════════
997
2036
 
2037
+ // <gen-docs:api-reference:start @pyreon/virtual>
2038
+
998
2039
  'virtual/useVirtualizer': {
999
- signature:
1000
- 'useVirtualizer(options: VirtualizerOptions): { virtualItems: Signal, totalSize: Signal, scrollToIndex: (i) => void, ... }',
1001
- example: `const { virtualItems, totalSize } = useVirtualizer({
1002
- count: 10000,
1003
- getScrollElement: () => scrollRef.current,
2040
+ signature: '(options: UseVirtualizerOptions) => UseVirtualizerResult',
2041
+ example: `const virtualizer = useVirtualizer({
2042
+ count: () => items().length,
2043
+ getScrollElement: () => scrollRef,
1004
2044
  estimateSize: () => 35,
1005
- })`,
1006
- notes: 'TanStack Virtual adapter. Also: useWindowVirtualizer for window-scoped virtualization.',
2045
+ overscan: 5,
2046
+ })
2047
+
2048
+ // virtualItems() is reactive — re-evaluates as user scrolls
2049
+ <For each={() => virtualizer.virtualItems()} by={(item) => item.index}>
2050
+ {(item) => <div style={() => \`top: \${item.start}px\`}>{item.index}</div>}
2051
+ </For>`,
2052
+ notes: 'Create an element-scoped virtualizer. Attach to a scrollable container via `getScrollElement`. Returns reactive `virtualItems()`, `totalSize()`, and `isScrolling()` signals plus `scrollToIndex()` and `scrollToOffset()` for programmatic control. Options that accept functions (`count`, `estimateSize`) track signal reads reactively. See also: useWindowVirtualizer.',
2053
+ mistakes: `- Forgetting to set a fixed height on the scroll container — without overflow:auto + a height, the virtualizer has no viewport to measure
2054
+ - Passing count as a plain number instead of a function when the list length is dynamic — the virtualizer won't update when items change
2055
+ - Reading virtualItems() outside a reactive scope — captures the initial window only, never updates on scroll
2056
+ - Using .map() instead of <For> on virtualItems — loses keyed reconciliation`,
2057
+ },
2058
+
2059
+ 'virtual/useWindowVirtualizer': {
2060
+ signature: '(options: UseWindowVirtualizerOptions) => UseWindowVirtualizerResult',
2061
+ example: `const virtualizer = useWindowVirtualizer({
2062
+ count: () => items().length,
2063
+ estimateSize: () => 50,
2064
+ })
2065
+
2066
+ <div style={() => \`height: \${virtualizer.totalSize()}px; position: relative\`}>
2067
+ <For each={() => virtualizer.virtualItems()} by={(item) => item.index}>
2068
+ {(item) => <div style={() => \`position: absolute; top: \${item.start}px\`}>Row {item.index}</div>}
2069
+ </For>
2070
+ </div>`,
2071
+ notes: 'Create a window-scoped virtualizer that uses the browser window as the scroll container. SSR-safe — checks for browser environment before attaching scroll listeners. Same return shape as `useVirtualizer` (virtualItems, totalSize, isScrolling, scrollToIndex). Use for long page-level lists where the entire page scrolls. See also: useVirtualizer.',
2072
+ mistakes: `- Using useWindowVirtualizer inside a scrollable container that is not the window — use useVirtualizer with getScrollElement instead
2073
+ - Forgetting to position items absolutely inside a relative container with the total height — items overlap or collapse`,
1007
2074
  },
2075
+ // <gen-docs:api-reference:end @pyreon/virtual>
1008
2076
 
1009
2077
  // ═══════════════════════════════════════════════════════════════════════════
1010
2078
  // @pyreon/feature
1011
2079
  // ═══════════════════════════════════════════════════════════════════════════
1012
2080
 
2081
+ // <gen-docs:api-reference:start @pyreon/feature>
2082
+
1013
2083
  'feature/defineFeature': {
1014
- signature:
1015
- 'defineFeature<T>(config: { name: string, schema: FeatureSchema<T>, api: FeatureApi<T> }): Feature<T>',
2084
+ signature: '<T>(config: FeatureConfig<T>) => Feature<T>',
1016
2085
  example: `const Posts = defineFeature({
1017
2086
  name: 'posts',
1018
- schema: { title: 'string', body: 'string', author: reference('users') },
2087
+ schema: {
2088
+ title: 'string',
2089
+ body: 'string',
2090
+ author: reference('users'),
2091
+ },
1019
2092
  api: { baseUrl: '/api/posts' },
1020
2093
  })
1021
2094
 
1022
- // Auto-generated hooks:
1023
- Posts.useList() // paginated query
1024
- Posts.useById(id) // single item query
1025
- Posts.useCreate() // mutation
1026
- Posts.useForm(id) // edit form with validation
1027
- Posts.useTable() // TanStack Table config`,
1028
- notes:
1029
- 'Schema-driven CRUD. Composes @pyreon/query, @pyreon/form, @pyreon/validation, @pyreon/store, @pyreon/table.',
2095
+ Posts.useList({ page: 1 })
2096
+ Posts.useById('123')
2097
+ Posts.useCreate()
2098
+ Posts.useForm('123')
2099
+ Posts.useTable({ columns: ['title', 'author'] })`,
2100
+ notes: 'Define a schema-driven CRUD feature. Accepts a name, field schema, and API config. Returns a Feature object with auto-generated hooks: `useList`, `useById`, `useSearch`, `useCreate`, `useUpdate`, `useDelete`, `useForm`, `useTable`, `useStore`. Composes @pyreon/query (data fetching), @pyreon/form (form state), @pyreon/validation (schema validation), @pyreon/store (global state), and @pyreon/table (table configuration). Schema field types are inferred for TypeScript autocompletion across all generated hooks. See also: reference, extractFields, defaultInitialValues.',
2101
+ mistakes: `- Forgetting to install peer dependencies — defineFeature composes @pyreon/query, @pyreon/form, @pyreon/validation, @pyreon/store, @pyreon/table internally
2102
+ - Using defineFeature without a QueryClient provider — useList/useById/useSearch/useCreate/useUpdate/useDelete all depend on @pyreon/query which requires a QueryClient in context
2103
+ - Passing schema field types as TypeScript types instead of string literals — schema values must be runtime strings like \`"string"\`, \`"number"\`, \`"boolean"\`, or \`reference("otherFeature")\`
2104
+ - Calling useForm without an id for edit mode — pass an id to load existing data, omit it for create mode`,
1030
2105
  },
1031
2106
 
2107
+ 'feature/reference': {
2108
+ signature: '(featureName: string) => ReferenceSchema',
2109
+ example: `const Posts = defineFeature({
2110
+ name: 'posts',
2111
+ schema: {
2112
+ title: 'string',
2113
+ author: reference('users'), // FK to users feature
2114
+ category: reference('categories'),
2115
+ },
2116
+ api: { baseUrl: '/api/posts' },
2117
+ })`,
2118
+ notes: 'Mark a schema field as a foreign key reference to another feature. Used inside defineFeature schema definitions to establish relationships between features. The generated form and table hooks understand reference fields and can render appropriate UI (select dropdowns, linked displays). See also: defineFeature.',
2119
+ },
2120
+ // <gen-docs:api-reference:end @pyreon/feature>
2121
+
1032
2122
  // ═══════════════════════════════════════════════════════════════════════════
1033
2123
  // @pyreon/storybook
1034
2124
  // ═══════════════════════════════════════════════════════════════════════════
@@ -1196,195 +2286,121 @@ const theme = enrichTheme({
1196
2286
  // @pyreon/rx
1197
2287
  // ═══════════════════════════════════════════════════════════════════════════
1198
2288
 
1199
- 'rx/filter': {
1200
- signature:
1201
- 'filter<T>(source: Signal<T[]> | T[], predicate: (item: T) => boolean): Computed<T[]> | T[]',
1202
- example: `import { filter } from '@pyreon/rx'
1203
-
1204
- // Signal input → Computed output (auto-tracks):
1205
- const items = signal([1, 2, 3, 4, 5])
1206
- const evens = filter(items, n => n % 2 === 0) // Computed<number[]>
1207
- evens() // [2, 4]
2289
+ // <gen-docs:api-reference:start @pyreon/rx>
1208
2290
 
1209
- // Plain input → plain output:
1210
- const result = filter([1, 2, 3, 4, 5], n => n > 3) // [4, 5]`,
1211
- notes:
1212
- 'Every @pyreon/rx function is overloaded: Signal<T[]> input produces Computed<T[]>, plain T[] input produces plain T[]. 24 functions total: filter, map, sortBy, groupBy, keyBy, uniqBy, take, skip, last, chunk, flatten, find, mapValues, count, sum, min, max, average, distinct, scan, combine, debounce, throttle, search.',
2291
+ 'rx/rx': {
2292
+ signature: 'Readonly<{ filter, map, sortBy, groupBy, keyBy, uniqBy, take, skip, last, chunk, flatten, find, mapValues, count, sum, min, max, average, distinct, scan, combine, debounce, throttle, search, pipe }>',
2293
+ example: `const active = rx.filter(users, u => u.active) // Computed<User[]>
2294
+ const sorted = rx.sortBy(active, 'name') // Computed<User[]>
2295
+ const total = rx.sum(users, u => u.age) // Computed<number>
2296
+ const grouped = rx.groupBy(users, u => u.department) // Computed<Map<string, User[]>>`,
2297
+ notes: 'Namespaced object exposing all 24 reactive transform functions plus `pipe`. Use `rx.filter(...)` for dot-notation style, or destructure individual functions for tree-shaking. Every function is overloaded: `Signal<T[]>` input produces `Computed<T[]>` that auto-tracks, plain `T[]` input produces a static result. See also: pipe, filter.',
2298
+ mistakes: `- Expecting \`rx.filter(signal, pred)\` to return a plain array — signal inputs always produce \`Computed\` outputs. Call the result to read: \`active()\`
2299
+ - Passing a signal accessor (\`() => items()\`) instead of the signal itself — pass \`items\` not \`() => items()\`; the function checks for \`.subscribe\` to detect signals`,
1213
2300
  },
1214
2301
 
1215
2302
  'rx/pipe': {
1216
- signature: 'pipe<T>(source: Signal<T[]> | T[], ...operators: Operator[]): Computed<T[]> | T[]',
1217
- example: `import { pipe, filter, sortBy, map } from '@pyreon/rx'
1218
-
1219
- const users = signal([
1220
- { name: 'Charlie', age: 35 },
1221
- { name: 'Alice', age: 25 },
1222
- { name: 'Bob', age: 30 },
1223
- ])
1224
-
1225
- // Compose transforms left-to-right:
1226
- const result = pipe(
2303
+ signature: '<T>(source: Signal<T[]> | T[], ...operators: Operator[]) => Computed<T[]> | T[]',
2304
+ example: `const result = pipe(
1227
2305
  users,
1228
- filter(u => u.age >= 30),
2306
+ filter(u => u.active),
1229
2307
  sortBy('name'),
1230
2308
  map(u => u.name),
2309
+ take(10),
1231
2310
  )
1232
- // Computed<string[]> ["Bob", "Charlie"]`,
1233
- notes:
1234
- 'Pipe composes operators left-to-right. Signal source produces reactive Computed that re-derives when source changes.',
2311
+ // Computed<string[]> when users is a signal`,
2312
+ notes: 'Compose transforms left-to-right. Each operator receives the output of the previous one. Signal source produces a reactive `Computed` that re-derives when the source changes. Use curried forms of individual functions as operators: `filter(pred)`, `sortBy(key)`, `map(fn)`, etc. See also: rx.',
2313
+ mistakes: '- Calling the non-curried form inside pipe `pipe(users, filter(users, pred))` is wrong; use the curried form: `pipe(users, filter(pred))`',
2314
+ },
2315
+
2316
+ 'rx/filter': {
2317
+ signature: '<T>(source: Signal<T[]> | T[], predicate: (item: T) => boolean) => Computed<T[]> | T[]',
2318
+ example: `const evens = filter(items, n => n % 2 === 0) // Computed<number[]>
2319
+ const result = filter([1, 2, 3, 4, 5], n => n > 3) // [4, 5]`,
2320
+ notes: 'Filter items by predicate. Signal input produces a reactive `Computed<T[]>` that re-evaluates when the source signal changes. Also available in curried form `filter(pred)` for use with `pipe()`. See also: rx, pipe.',
1235
2321
  },
2322
+ // <gen-docs:api-reference:end @pyreon/rx>
1236
2323
 
1237
2324
  // ═══════════════════════════════════════════════════════════════════════════
1238
2325
  // @pyreon/toast
1239
2326
  // ═══════════════════════════════════════════════════════════════════════════
1240
2327
 
1241
- 'toast/toast': {
1242
- signature:
1243
- 'toast(message: string, options?: ToastOptions): string\ntoast.success/error/warning/info/loading(message): string\ntoast.update(id, options): void\ntoast.dismiss(id?): void\ntoast.promise(promise, { loading, success, error }): string',
1244
- example: `import { toast, Toaster } from '@pyreon/toast'
2328
+ // <gen-docs:api-reference:start @pyreon/toast>
1245
2329
 
1246
- // Basic:
2330
+ 'toast/toast': {
2331
+ signature: '(message: string, options?: ToastOptions) => string',
2332
+ example: `// Basic:
1247
2333
  toast('Hello!')
1248
- toast.success('Saved!')
1249
- toast.error('Failed!')
2334
+ const id = toast.success('Saved!')
1250
2335
 
1251
- // Loading → success pattern:
1252
- const id = toast.loading('Saving...')
2336
+ // Loading → success:
2337
+ const loadId = toast.loading('Saving...')
1253
2338
  await save()
1254
- toast.update(id, { type: 'success', message: 'Done!' })
2339
+ toast.update(loadId, { type: 'success', message: 'Done!' })
1255
2340
 
1256
2341
  // Promise helper:
1257
2342
  toast.promise(fetchData(), {
1258
2343
  loading: 'Loading...',
1259
2344
  success: 'Loaded!',
1260
- error: 'Failed to load',
2345
+ error: 'Failed',
1261
2346
  })
1262
2347
 
1263
2348
  // Dismiss:
1264
2349
  toast.dismiss(id) // one
1265
- toast.dismiss() // all
2350
+ toast.dismiss() // all`,
2351
+ notes: 'Create a toast notification imperatively. Returns the toast ID for later `update()` or `dismiss()`. Works from anywhere in the app — no context or provider needed. The function also exposes `.success()`, `.error()`, `.warning()`, `.info()`, `.loading()` preset methods, `.update(id, options)` for modifying existing toasts, `.dismiss(id?)` for removal, and `.promise(promise, messages)` for async operation tracking. See also: Toaster.',
2352
+ mistakes: `- Forgetting to render \`<Toaster />\` — toasts are created but have no visual container to render into
2353
+ - Calling \`toast.update()\` after the toast has been auto-dismissed — the ID is no longer valid, the update is silently ignored
2354
+ - Using \`toast.promise()\` with a function instead of a promise — pass the promise directly, not \`() => fetch(...)\``,
2355
+ },
1266
2356
 
1267
- // Mount Toaster once in your app:
1268
- <Toaster />`,
1269
- notes:
1270
- "Imperative API call from anywhere, no context needed. <Toaster /> renders via Portal with CSS transitions, auto-dismiss, pause on hover. Accessible: role='alert', aria-live='polite'.",
2357
+ 'toast/Toaster': {
2358
+ signature: '(props?: ToasterProps) => VNodeChild',
2359
+ example: '<Toaster position="top-right" duration={5000} />',
2360
+ notes: 'Render container for toast notifications. Mount once at the app root. Renders via Portal with CSS transitions, auto-dismiss timer, and pause-on-hover behavior. Position configurable via `position` prop (`top-right`, `top-left`, `bottom-right`, `bottom-left`, `top-center`, `bottom-center`). Duration configurable via `duration` prop (default 4000ms). See also: toast.',
2361
+ mistakes: `- Mounting multiple \`<Toaster />\` instances — toasts render in all of them, causing duplicates
2362
+ - Conditional rendering of \`<Toaster />\` — if unmounted, toasts created via \`toast()\` are queued but invisible until the Toaster mounts`,
1271
2363
  },
2364
+ // <gen-docs:api-reference:end @pyreon/toast>
1272
2365
 
1273
2366
  // ═══════════════════════════════════════════════════════════════════════════
1274
2367
  // @pyreon/url-state
1275
2368
  // ═══════════════════════════════════════════════════════════════════════════
1276
2369
 
1277
- 'url-state/useUrlState': {
1278
- signature:
1279
- 'useUrlState<T>(key: string, defaultValue: T): UrlStateSignal<T>\nuseUrlState<T extends Record<string, unknown>>(schema: T): UrlStateSchema<T>',
1280
- example: `import { useUrlState } from '@pyreon/url-state'
2370
+ // <gen-docs:api-reference:start @pyreon/url-state>
1281
2371
 
1282
- // Single param — synced to ?page=:
2372
+ 'url-state/useUrlState': {
2373
+ signature: '<T>(key: string, defaultValue: T, options?: UrlStateOptions) => UrlStateSignal<T>',
2374
+ example: `// Single param:
1283
2375
  const page = useUrlState('page', 1)
1284
- page() // 1 (auto-coerced number)
1285
- page.set(2) // URL → ?page=2
1286
-
1287
- // Schema mode — multiple params:
1288
- const filters = useUrlState({ page: 1, sort: 'name', desc: false })
1289
- filters.page() // 1
1290
- filters.sort() // "name"
1291
- filters.set({ page: 2, sort: 'date' })`,
1292
- notes:
1293
- 'Auto type coercion (numbers, booleans, arrays). Uses replaceState (no history spam). Configurable debounce. SSR-safe — reads request URL on server.',
1294
- },
1295
-
1296
- // ═══════════════════════════════════════════════════════════════════════════
1297
- // @pyreon/queryuseSSE
1298
- // ═══════════════════════════════════════════════════════════════════════════
1299
-
1300
- 'query/useSSE': {
1301
- signature:
1302
- 'useSSE<T>(options: { queryKey: unknown[], url: string, transform?: (event: MessageEvent) => T, ... }): { data: Signal<T>, error: Signal<Error>, status: Signal<string> }',
1303
- example: `import { useSSE } from '@pyreon/query'
1304
-
1305
- const { data, error, status } = useSSE({
1306
- queryKey: ['events'],
1307
- url: '/api/events',
1308
- transform: (event) => JSON.parse(event.data),
1309
- })
1310
-
1311
- // data() reactively updates on each SSE message
1312
- // Auto-reconnects on disconnect
1313
- // Integrates with QueryClient for cache invalidation`,
1314
- notes:
1315
- 'Server-Sent Events hook. Same pattern as useSubscription but read-only (no send). Integrates with QueryClient cache.',
1316
- },
1317
-
1318
- // ═══════════════════════════════════════════════════════════════════════════
1319
- // @pyreon/router — useIsActive
1320
- // ═══════════════════════════════════════════════════════════════════════════
1321
-
1322
- 'router/useIsActive': {
1323
- signature: 'useIsActive(path: string, exact?: boolean): () => boolean',
1324
- example: `import { useIsActive } from '@pyreon/router'
1325
-
1326
- const isHome = useIsActive('/')
1327
- const isAdmin = useIsActive('/admin') // prefix match
1328
- const isExactAdmin = useIsActive('/admin', true) // exact only
1329
-
1330
- // Reactive — updates when route changes:
1331
- <a class={{ active: isAdmin() }} href="/admin">Admin</a>`,
1332
- notes:
1333
- 'Returns a reactive boolean. Segment-aware prefix matching: /admin matches /admin/users but not /admin-panel. Pass exact=true for exact-only matching.',
1334
- },
1335
-
1336
- 'router/useTypedSearchParams': {
1337
- signature:
1338
- "useTypedSearchParams<T>(schema: T): TypedSearchParams<T>",
1339
- example: `import { useTypedSearchParams } from '@pyreon/router'
1340
-
1341
- const params = useTypedSearchParams({ page: 'number', q: 'string', active: 'boolean' })
1342
- params.page() // number (auto-coerced)
1343
- params.q() // string
1344
- params.set({ page: 2 }) // updates URL`,
1345
- notes:
1346
- 'Type-safe search params with auto-coercion from URL strings. Supports "string", "number", and "boolean" types.',
1347
- },
1348
-
1349
- 'router/useTransition': {
1350
- signature: 'useTransition(): { isTransitioning: () => boolean }',
1351
- example: `import { useTransition } from '@pyreon/router'
1352
-
1353
- const { isTransitioning } = useTransition()
1354
- // true during navigation (guards + loaders), false when mounted`,
1355
- notes:
1356
- 'Reactive signal for route transition state. Useful for progress bars and loading indicators.',
1357
- },
1358
-
1359
- 'router/useMiddlewareData': {
1360
- signature: 'useMiddlewareData<T>(): T',
1361
- example: `import { useMiddlewareData } from '@pyreon/router'
1362
-
1363
- // After middleware sets ctx.data.user:
1364
- const data = useMiddlewareData<{ user: User }>()
1365
- // data.user is available in the component`,
1366
- notes:
1367
- 'Reads data set by RouteMiddleware in the middleware chain. Middleware sets ctx.data properties, components read them.',
1368
- },
1369
-
1370
- 'storybook/renderToCanvas': {
1371
- signature: 'renderToCanvas(context: StoryContext, canvasElement: HTMLElement): void',
1372
- example: `// .storybook/main.ts:
1373
- export default { framework: '@pyreon/storybook' }
1374
-
1375
- // Story file:
1376
- import type { Meta, StoryObj } from '@pyreon/storybook'
1377
- import { Button } from './Button'
1378
-
1379
- const meta: Meta<typeof Button> = { component: Button }
1380
- export default meta
1381
-
1382
- export const Primary: StoryObj<typeof meta> = {
1383
- args: { variant: 'primary', label: 'Click me' },
1384
- }`,
1385
- notes:
1386
- 'Storybook renderer for Pyreon components. Re-exports h, Fragment, signal, computed, effect, mount for story convenience.',
1387
- },
2376
+ page() // 1
2377
+ page.set(2) // URL → ?page=2
2378
+
2379
+ // Schema mode:
2380
+ const { q, sort } = useUrlState({ q: '', sort: 'name' })
2381
+ q.set('hello') // ?q=hello&sort=name
2382
+
2383
+ // Array with repeated keys:
2384
+ const tags = useUrlState('tags', [] as string[], { arrayFormat: 'repeat' })
2385
+ tags.set(['a', 'b']) // ?tags=a&tags=b`,
2386
+ notes: 'Create a reactive signal synced to a URL search parameter. Type is inferred from the default value — numbers, booleans, strings, and arrays are auto-coerced. Uses `replaceState` by default (no history entries). Returns a `UrlStateSignal<T>` with `.set()`, `.reset()`, and `.remove()`. Schema mode overload: `useUrlState({ page: 1, sort: "name" })` creates multiple synced signals from a single call. SSR-safe — reads from the request URL on server. See also: setUrlRouter.',
2387
+ mistakes: `- Using pushState behavior (adds history entries per keystroke) — useUrlState defaults to replaceState; if you pass \`{ replaceState: false }\` on a high-frequency input, the browser back button breaks
2388
+ - Forgetting the default value — the type is inferred from it and determines the auto-coercion strategy (number default = coerce to number, boolean default = coerce to boolean)
2389
+ - Reading useUrlState in a non-reactive scope at component setup the signal reads the URL once; wrap in a reactive scope to track URL changes
2390
+ - Calling setUrlRouter before the router is available — SSR renders may not have a router instance yet`,
2391
+ },
2392
+
2393
+ 'url-state/setUrlRouter': {
2394
+ signature: '(router: UrlRouter) => void',
2395
+ example: `import { useRouter } from '@pyreon/router'
2396
+ import { setUrlRouter } from '@pyreon/url-state'
2397
+
2398
+ const router = useRouter()
2399
+ setUrlRouter(router)
2400
+ // Now useUrlState uses router.replace() internally`,
2401
+ notes: `Configure useUrlState to use a @pyreon/router instance for URL updates instead of raw \`history.replaceState\`. When set, URL changes go through the router's navigation system, ensuring route guards, middleware, and scroll management integrate correctly. See also: useUrlState.`,
2402
+ },
2403
+ // <gen-docs:api-reference:end @pyreon/url-state>
1388
2404
 
1389
2405
  // ═══════════════════════════════════════════════════════════════════════════
1390
2406
  // @pyreon/document-primitives