@vertz/ui 0.2.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.
package/README.md ADDED
@@ -0,0 +1,1138 @@
1
+ # @vertz/ui
2
+
3
+ A compiler-driven UI framework with fine-grained reactivity. Write plain variables and JSX -- the compiler transforms them into efficient reactive DOM operations. No virtual DOM.
4
+
5
+ ## Table of Contents
6
+
7
+ - [Quick Start](#quick-start)
8
+ - [Reactivity](#reactivity)
9
+ - [Components](#components)
10
+ - [Conditional Rendering](#conditional-rendering)
11
+ - [List Rendering](#list-rendering)
12
+ - [Styling](#styling)
13
+ - [Data Fetching](#data-fetching)
14
+ - [Routing](#routing)
15
+ - [Forms](#forms)
16
+ - [Lifecycle](#lifecycle)
17
+ - [Primitives](#primitives)
18
+ - [When to Use effect()](#when-to-use-effect)
19
+
20
+ ---
21
+
22
+ ## Quick Start
23
+
24
+ ### Install
25
+
26
+ ```bash
27
+ npm install @vertz/ui @vertz/ui-compiler
28
+ ```
29
+
30
+ ### Vite Config
31
+
32
+ ```ts
33
+ // vite.config.ts
34
+ import vertz from '@vertz/ui-compiler/vite';
35
+ import { defineConfig } from 'vite';
36
+
37
+ export default defineConfig({
38
+ plugins: [vertz()],
39
+ });
40
+ ```
41
+
42
+ The plugin transforms all `.tsx` and `.jsx` files by default. You can customize with `include` and `exclude` globs:
43
+
44
+ ```ts
45
+ vertz({
46
+ include: ['**/*.tsx'],
47
+ exclude: ['**/vendor/**'],
48
+ cssExtraction: true, // default: true in production
49
+ })
50
+ ```
51
+
52
+ ### Hello World
53
+
54
+ ```tsx
55
+ function App() {
56
+ let name = 'World';
57
+
58
+ return (
59
+ <div>
60
+ <h1>Hello, {name}!</h1>
61
+ <input
62
+ value={name}
63
+ onInput={(e) => (name = (e.target as HTMLInputElement).value)}
64
+ />
65
+ </div>
66
+ );
67
+ }
68
+
69
+ document.body.appendChild(App());
70
+ ```
71
+
72
+ That's it. `name` is reactive. Typing in the input updates the heading. No hooks, no store setup, no subscriptions.
73
+
74
+ ---
75
+
76
+ ## Reactivity
77
+
78
+ This is the core mental model of the framework. The compiler does the heavy lifting -- you write normal-looking code and get fine-grained reactive DOM updates.
79
+
80
+ ### `let` = Reactive State
81
+
82
+ Any `let` declaration inside a component that is read in JSX becomes a signal:
83
+
84
+ ```tsx
85
+ // What you write:
86
+ function Counter() {
87
+ let count = 0;
88
+ return <span>{count}</span>;
89
+ }
90
+
91
+ // What the compiler produces:
92
+ function Counter() {
93
+ const count = signal(0);
94
+ const __el = __element("span");
95
+ __el.appendChild(__text(() => count.value));
96
+ return __el;
97
+ }
98
+ ```
99
+
100
+ Assignments work naturally:
101
+
102
+ ```tsx
103
+ function Counter() {
104
+ let count = 0;
105
+
106
+ return (
107
+ <div>
108
+ <span>{count}</span>
109
+ <button onClick={() => count++}>+</button>
110
+ </div>
111
+ );
112
+ }
113
+ ```
114
+
115
+ The compiler transforms `count++` to `count.value++`, `count = 5` to `count.value = 5`, and `count += 1` to `count.value += 1`.
116
+
117
+ ### `const` = Derived State
118
+
119
+ A `const` that references a signal becomes a computed:
120
+
121
+ ```tsx
122
+ // What you write:
123
+ function Pricing() {
124
+ let quantity = 1;
125
+ const total = 10 * quantity;
126
+ const formatted = `$${total}`;
127
+
128
+ return <span>{formatted}</span>;
129
+ }
130
+
131
+ // What the compiler produces:
132
+ function Pricing() {
133
+ const quantity = signal(1);
134
+ const total = computed(() => 10 * quantity.value);
135
+ const formatted = computed(() => `$${total.value}`);
136
+ // ...
137
+ }
138
+ ```
139
+
140
+ Computeds are lazy and cached -- they only recompute when their dependencies change.
141
+
142
+ Destructuring also works:
143
+
144
+ ```tsx
145
+ function Profile() {
146
+ let user = { name: 'Alice', age: 30 };
147
+ const { name, age } = user;
148
+
149
+ return <span>{name} - {age}</span>;
150
+ }
151
+ // Compiler produces:
152
+ // const name = computed(() => user.name)
153
+ // const age = computed(() => user.age)
154
+ ```
155
+
156
+ ### JSX Text Interpolation
157
+
158
+ `{expr}` in JSX creates a reactive text node when `expr` depends on signals:
159
+
160
+ ```tsx
161
+ <span>{count}</span>
162
+ // becomes: __text(() => count.value)
163
+ ```
164
+
165
+ Static expressions produce plain text nodes:
166
+
167
+ ```tsx
168
+ const title = "Hello";
169
+ <span>{title}</span>
170
+ // becomes: document.createTextNode(title)
171
+ ```
172
+
173
+ ### JSX Reactive Attributes
174
+
175
+ Attributes that depend on signals auto-update:
176
+
177
+ ```tsx
178
+ function App() {
179
+ let isActive = false;
180
+
181
+ return (
182
+ <div className={isActive ? 'active' : 'inactive'}>
183
+ <button onClick={() => isActive = !isActive}>Toggle</button>
184
+ </div>
185
+ );
186
+ }
187
+ // The compiler wraps the reactive className in __attr(el, "className", () => ...)
188
+ ```
189
+
190
+ ### Mutations
191
+
192
+ The compiler intercepts mutations on signal-backed variables and generates peek/notify calls so the reactivity system is notified:
193
+
194
+ ```tsx
195
+ function App() {
196
+ let items = ['a', 'b'];
197
+
198
+ // .push(), .splice(), etc. all work:
199
+ items.push('c');
200
+ // compiles to: (items.peek().push('c'), items.notify())
201
+
202
+ // Property assignment:
203
+ let user = { name: 'Alice' };
204
+ user.name = 'Bob';
205
+ // compiles to: (user.peek().name = 'Bob', user.notify())
206
+
207
+ // Index assignment:
208
+ items[0] = 'z';
209
+ // compiles to: (items.peek()[0] = 'z', items.notify())
210
+
211
+ // Object.assign:
212
+ Object.assign(user, { age: 30 });
213
+ // compiles to: (Object.assign(user.peek(), { age: 30 }), user.notify())
214
+
215
+ // delete:
216
+ let config = { debug: true };
217
+ delete config.debug;
218
+ // compiles to: (delete config.peek().debug, config.notify())
219
+ }
220
+ ```
221
+
222
+ ### Event Handlers
223
+
224
+ `onClick`, `onInput`, etc. are transformed to `__on(el, "click", handler)`:
225
+
226
+ ```tsx
227
+ <button onClick={() => count++}>+</button>
228
+ ```
229
+
230
+ ### DO vs DON'T
231
+
232
+ ```tsx
233
+ // DON'T -- imperative DOM manipulation
234
+ // This ignores the compiler entirely. You're doing its job by hand, badly.
235
+ import { signal, effect } from '@vertz/ui';
236
+
237
+ function Counter() {
238
+ const count = signal(0);
239
+ const label = document.createElement('span');
240
+ effect(() => { label.textContent = String(count.value); });
241
+ const btn = document.createElement('button');
242
+ btn.onclick = () => { count.value++; };
243
+ btn.textContent = '+';
244
+ const div = document.createElement('div');
245
+ div.append(label, btn);
246
+ return div;
247
+ }
248
+
249
+ // DO -- declarative JSX (let the compiler handle reactivity)
250
+ function Counter() {
251
+ let count = 0;
252
+ return (
253
+ <div>
254
+ <span>{count}</span>
255
+ <button onClick={() => count++}>+</button>
256
+ </div>
257
+ );
258
+ }
259
+ ```
260
+
261
+ The declarative version is shorter, clearer, and produces the same (or better) runtime code. The compiler generates exactly the reactive bindings needed -- no more, no less.
262
+
263
+ ---
264
+
265
+ ## Components
266
+
267
+ Components are functions that return `HTMLElement` (or `Node`). There is no component class, no `render()` method.
268
+
269
+ ### Basic Component
270
+
271
+ ```tsx
272
+ function Greeting() {
273
+ return <h1>Hello</h1>;
274
+ }
275
+
276
+ // Use it:
277
+ document.body.appendChild(Greeting());
278
+ ```
279
+
280
+ ### Props
281
+
282
+ Props are plain objects. For reactive props (values that can change), use getter functions:
283
+
284
+ ```tsx
285
+ interface CardProps {
286
+ title: string; // static -- value captured once
287
+ count: () => number; // reactive -- getter re-evaluated on access
288
+ }
289
+
290
+ function Card(props: CardProps) {
291
+ return (
292
+ <div>
293
+ <h2>{props.title}</h2>
294
+ <span>{props.count()}</span>
295
+ </div>
296
+ );
297
+ }
298
+
299
+ // Usage:
300
+ function App() {
301
+ let n = 0;
302
+ return (
303
+ <div>
304
+ <Card title="Score" count={() => n} />
305
+ <button onClick={() => n++}>+</button>
306
+ </div>
307
+ );
308
+ }
309
+ ```
310
+
311
+ The `() => n` getter ensures `Card` re-reads the value reactively. A bare `n` would capture the value once and never update.
312
+
313
+ ### Children
314
+
315
+ Components can accept children via the `children` helper:
316
+
317
+ ```tsx
318
+ import { children, type ChildrenAccessor } from '@vertz/ui';
319
+
320
+ interface PanelProps {
321
+ children: ChildrenAccessor;
322
+ }
323
+
324
+ function Panel(props: PanelProps) {
325
+ const getChildren = children(props.children);
326
+ const el = <div className="panel" />;
327
+ for (const child of getChildren()) {
328
+ (el as HTMLElement).appendChild(child);
329
+ }
330
+ return el;
331
+ }
332
+ ```
333
+
334
+ ### Composition
335
+
336
+ Components compose by returning DOM nodes. No special syntax needed:
337
+
338
+ ```tsx
339
+ function App() {
340
+ return (
341
+ <div>
342
+ {Header()}
343
+ {MainContent()}
344
+ {Footer()}
345
+ </div>
346
+ );
347
+ }
348
+ ```
349
+
350
+ ---
351
+
352
+ ## Conditional Rendering
353
+
354
+ Use standard JSX conditional patterns. The compiler transforms them into efficient reactive DOM operations with automatic disposal:
355
+
356
+ ```tsx
357
+ function Toggle() {
358
+ let show = false;
359
+
360
+ return (
361
+ <div>
362
+ {show && <p>Now you see me</p>}
363
+ <button onClick={() => show = !show}>Toggle</button>
364
+ </div>
365
+ );
366
+ }
367
+ ```
368
+
369
+ Ternaries work too:
370
+
371
+ ```tsx
372
+ function StatusBadge() {
373
+ let online = true;
374
+
375
+ return (
376
+ <div>
377
+ {online ? <span className="green">Online</span> : <span className="gray">Offline</span>}
378
+ </div>
379
+ );
380
+ }
381
+ ```
382
+
383
+ Under the hood, the compiler transforms these into `__conditional()` calls that manage DOM insertion, replacement, and cleanup automatically. When the condition changes, the old branch is disposed and the new branch is rendered in place.
384
+
385
+ ### Manual Control
386
+
387
+ For advanced use cases where you need direct access to the dispose function or more control over the condition lifecycle, you can use `__conditional()` from `@vertz/ui/internals` directly:
388
+
389
+ ```tsx
390
+ import { __conditional } from '@vertz/ui/internals';
391
+
392
+ function Toggle() {
393
+ let show = false;
394
+
395
+ return (
396
+ <div>
397
+ {__conditional(
398
+ () => show,
399
+ () => <p>Now you see me</p>,
400
+ () => <p>Now you don't</p>
401
+ )}
402
+ <button onClick={() => show = !show}>Toggle</button>
403
+ </div>
404
+ );
405
+ }
406
+ ```
407
+
408
+ `__conditional` takes three arguments:
409
+
410
+ 1. `condFn: () => boolean` -- reactive condition
411
+ 2. `trueFn: () => Node` -- rendered when true
412
+ 3. `falseFn: () => Node` -- rendered when false
413
+
414
+ ---
415
+
416
+ ## List Rendering
417
+
418
+ Use `.map()` in JSX with a `key` prop for efficient keyed reconciliation. The compiler transforms it into optimized list operations:
419
+
420
+ ```tsx
421
+ function TodoList() {
422
+ let todos = [
423
+ { id: '1', text: 'Learn vertz' },
424
+ { id: '2', text: 'Build something' },
425
+ ];
426
+
427
+ return (
428
+ <div>
429
+ <ul>
430
+ {todos.map(todo => <li key={todo.id}>{todo.text}</li>)}
431
+ </ul>
432
+ <button onClick={() => {
433
+ todos = [...todos, { id: String(Date.now()), text: 'New todo' }];
434
+ }}>
435
+ Add
436
+ </button>
437
+ </div>
438
+ );
439
+ }
440
+ ```
441
+
442
+ The `key` prop is extracted by the compiler for efficient keyed reconciliation -- existing DOM nodes are reused and reordered, not recreated. Always provide a stable, unique key for each item.
443
+
444
+ ### Manual Control
445
+
446
+ For advanced use cases where you need direct access to the list lifecycle or want to work with the signal directly, you can use `__list()` from `@vertz/ui/internals`:
447
+
448
+ ```tsx
449
+ import { signal } from '@vertz/ui';
450
+ import { __list } from '@vertz/ui/internals';
451
+
452
+ function TodoList() {
453
+ const todosSignal = signal([
454
+ { id: '1', text: 'Learn vertz' },
455
+ { id: '2', text: 'Build something' },
456
+ ]);
457
+
458
+ const container = <ul /> as HTMLElement;
459
+
460
+ __list(
461
+ container,
462
+ todosSignal,
463
+ (todo) => todo.id, // key function
464
+ (todo) => <li>{todo.text}</li> // render function (called once per key)
465
+ );
466
+
467
+ return (
468
+ <div>
469
+ {container}
470
+ <button onClick={() => {
471
+ todosSignal.value = [...todosSignal.value, { id: String(Date.now()), text: 'New todo' }];
472
+ }}>
473
+ Add
474
+ </button>
475
+ </div>
476
+ );
477
+ }
478
+ ```
479
+
480
+ `__list` arguments:
481
+
482
+ 1. `container: HTMLElement` -- parent element
483
+ 2. `items: Signal<T[]>` -- reactive array
484
+ 3. `keyFn: (item: T) => string | number` -- unique key extractor
485
+ 4. `renderFn: (item: T) => Node` -- creates DOM for each item (called once per key)
486
+
487
+ ---
488
+
489
+ ## Styling
490
+
491
+ Import from `@vertz/ui/css` or from the main `@vertz/ui` export.
492
+
493
+ ### `css()` -- Scoped Style Blocks
494
+
495
+ ```tsx
496
+ import { css } from '@vertz/ui/css';
497
+
498
+ const styles = css({
499
+ card: ['p:4', 'bg:background', 'rounded:lg'],
500
+ title: ['font:xl', 'weight:bold', 'text:foreground'],
501
+ });
502
+
503
+ function Card() {
504
+ return (
505
+ <div className={styles.classNames.card}>
506
+ <h2 className={styles.classNames.title}>Hello</h2>
507
+ </div>
508
+ );
509
+ }
510
+ ```
511
+
512
+ Shorthand syntax: `property:value` maps to CSS custom properties and design tokens. Pseudo-states are supported:
513
+
514
+ ```tsx
515
+ const button = css({
516
+ root: ['p:4', 'bg:primary', 'hover:bg:primary.700', 'rounded:md'],
517
+ });
518
+ ```
519
+
520
+ Object form for complex selectors:
521
+
522
+ ```tsx
523
+ const fancy = css({
524
+ card: [
525
+ 'p:4', 'bg:background',
526
+ { '&::after': ['content:empty', 'block'] },
527
+ ],
528
+ });
529
+ ```
530
+
531
+ ### `variants()` -- Typed Component Variants
532
+
533
+ ```tsx
534
+ import { variants } from '@vertz/ui/css';
535
+
536
+ const button = variants({
537
+ base: ['flex', 'font:medium', 'rounded:md'],
538
+ variants: {
539
+ intent: {
540
+ primary: ['bg:primary.600', 'text:foreground'],
541
+ secondary: ['bg:background', 'text:muted'],
542
+ },
543
+ size: {
544
+ sm: ['text:xs', 'h:8'],
545
+ md: ['text:sm', 'h:10'],
546
+ lg: ['text:base', 'h:12'],
547
+ },
548
+ },
549
+ defaultVariants: { intent: 'primary', size: 'md' },
550
+ compoundVariants: [
551
+ { intent: 'primary', size: 'sm', styles: ['px:2'] },
552
+ ],
553
+ });
554
+
555
+ // Returns a className string:
556
+ button({ intent: 'secondary', size: 'sm' }); // => "base_abc secondary_def sm_ghi"
557
+ button(); // => uses defaults
558
+ ```
559
+
560
+ The variant function is fully typed -- TypeScript infers the allowed values for `intent` and `size`.
561
+
562
+ ### `defineTheme()` and `ThemeProvider`
563
+
564
+ ```tsx
565
+ import { defineTheme, compileTheme, ThemeProvider } from '@vertz/ui/css';
566
+
567
+ const theme = defineTheme({
568
+ colors: {
569
+ primary: { 500: '#3b82f6', 600: '#2563eb', 700: '#1d4ed8' },
570
+ background: { DEFAULT: '#ffffff', _dark: '#111827' },
571
+ foreground: { DEFAULT: '#111827', _dark: '#f9fafb' },
572
+ },
573
+ spacing: {
574
+ 1: '0.25rem',
575
+ 2: '0.5rem',
576
+ 4: '1rem',
577
+ 8: '2rem',
578
+ },
579
+ });
580
+
581
+ // Generate CSS custom properties:
582
+ const compiled = compileTheme(theme);
583
+ // compiled.css contains:
584
+ // :root { --color-primary-500: #3b82f6; --color-background: #ffffff; ... }
585
+ // [data-theme="dark"] { --color-background: #111827; ... }
586
+
587
+ // Apply theme to a subtree:
588
+ const app = ThemeProvider({
589
+ theme: 'dark',
590
+ children: [MyApp()],
591
+ });
592
+ document.body.appendChild(app);
593
+ ```
594
+
595
+ Contextual tokens use `DEFAULT` for the base value and `_dark` for the dark variant. The `ThemeProvider` sets `data-theme` on a wrapper element.
596
+
597
+ ### `globalCss()` -- Global Styles
598
+
599
+ ```tsx
600
+ import { globalCss } from '@vertz/ui/css';
601
+
602
+ globalCss({
603
+ '*, *::before, *::after': {
604
+ boxSizing: 'border-box',
605
+ margin: '0',
606
+ },
607
+ body: {
608
+ fontFamily: 'system-ui, sans-serif',
609
+ lineHeight: '1.5',
610
+ },
611
+ });
612
+ ```
613
+
614
+ Properties use camelCase and are converted to kebab-case. CSS custom properties (`--*`) are passed through as-is.
615
+
616
+ ### `s()` -- Inline Styles
617
+
618
+ For truly dynamic styles that can't be static:
619
+
620
+ ```tsx
621
+ import { s } from '@vertz/ui/css';
622
+
623
+ function Bar(props: { width: number }) {
624
+ return <div style={s([`w:${props.width}px`, 'h:4', 'bg:primary.500'])} />;
625
+ }
626
+ ```
627
+
628
+ Pseudo-states are not supported in `s()` -- use `css()` for those.
629
+
630
+ ---
631
+
632
+ ## Data Fetching
633
+
634
+ Import from `@vertz/ui/query` or the main `@vertz/ui` export.
635
+
636
+ ```tsx
637
+ import { query } from '@vertz/ui/query';
638
+
639
+ function UserProfile() {
640
+ let userId = 1;
641
+
642
+ const { data, loading, error, refetch } = query(
643
+ () => fetch(`/api/users/${userId}`).then(r => r.json()),
644
+ );
645
+
646
+ return (
647
+ <div>
648
+ {/* data, loading, error are signals -- read .value in reactive contexts */}
649
+ <span>{loading.value ? 'Loading...' : ''}</span>
650
+ <span>{data.value?.name}</span>
651
+ <button onClick={() => userId++}>Next User</button>
652
+ <button onClick={refetch}>Refresh</button>
653
+ </div>
654
+ );
655
+ }
656
+ ```
657
+
658
+ The thunk runs inside an effect, so reactive dependencies read before the `await` are automatically tracked. When `userId` changes, the query re-fetches.
659
+
660
+ ### Options
661
+
662
+ ```tsx
663
+ const result = query(() => fetchData(), {
664
+ initialData: cachedValue, // skip initial fetch
665
+ debounce: 300, // debounce re-fetches (ms)
666
+ enabled: true, // set false to disable
667
+ key: 'custom-cache-key', // explicit cache key
668
+ cache: myCustomCache, // custom CacheStore implementation
669
+ });
670
+ ```
671
+
672
+ ### Cleanup
673
+
674
+ ```tsx
675
+ const { dispose } = query(() => fetchData());
676
+
677
+ // Stop the reactive effect and clean up in-flight requests:
678
+ dispose();
679
+ ```
680
+
681
+ `revalidate()` is an alias for `refetch()`.
682
+
683
+ ---
684
+
685
+ ## Routing
686
+
687
+ Import from `@vertz/ui/router` or the main `@vertz/ui` export.
688
+
689
+ ### Define Routes
690
+
691
+ ```tsx
692
+ import { defineRoutes, createRouter, createLink, createOutlet } from '@vertz/ui/router';
693
+
694
+ const routes = defineRoutes({
695
+ '/': {
696
+ component: () => HomePage(),
697
+ },
698
+ '/users/:id': {
699
+ component: () => UserPage(),
700
+ loader: async ({ params, signal }) => {
701
+ const res = await fetch(`/api/users/${params.id}`, { signal });
702
+ return res.json();
703
+ },
704
+ errorComponent: (error) => <div>Failed: {error.message}</div>,
705
+ },
706
+ '/about': {
707
+ component: () => AboutPage(),
708
+ },
709
+ });
710
+ ```
711
+
712
+ ### Create Router
713
+
714
+ ```tsx
715
+ const router = createRouter(routes, window.location.pathname + window.location.search);
716
+
717
+ // Reactive state:
718
+ router.current; // Signal<RouteMatch | null>
719
+ router.loaderData; // Signal<unknown[]>
720
+ router.loaderError; // Signal<Error | null>
721
+ router.searchParams; // Signal<Record<string, unknown>>
722
+
723
+ // Navigation:
724
+ await router.navigate('/users/42');
725
+ await router.navigate('/home', { replace: true });
726
+
727
+ // Re-run loaders:
728
+ await router.revalidate();
729
+
730
+ // Cleanup:
731
+ router.dispose();
732
+ ```
733
+
734
+ ### Link Component
735
+
736
+ ```tsx
737
+ const Link = createLink(router.current, (url) => router.navigate(url));
738
+
739
+ function Nav() {
740
+ return (
741
+ <nav>
742
+ {Link({ href: '/', children: 'Home', activeClass: 'active' })}
743
+ {Link({ href: '/about', children: 'About', activeClass: 'active' })}
744
+ </nav>
745
+ );
746
+ }
747
+ ```
748
+
749
+ Links intercept clicks for SPA navigation (modifier-key clicks still open new tabs). The `activeClass` is applied reactively when the link's `href` matches the current path.
750
+
751
+ ### Nested Routes and Outlets
752
+
753
+ ```tsx
754
+ import { createContext } from '@vertz/ui';
755
+ import { createOutlet, type OutletContext } from '@vertz/ui/router';
756
+
757
+ const outletCtx = createContext<OutletContext>();
758
+ const Outlet = createOutlet(outletCtx);
759
+
760
+ const routes = defineRoutes({
761
+ '/dashboard': {
762
+ component: () => DashboardLayout(),
763
+ children: {
764
+ '/': {
765
+ component: () => DashboardHome(),
766
+ },
767
+ '/settings': {
768
+ component: () => DashboardSettings(),
769
+ },
770
+ },
771
+ },
772
+ });
773
+ ```
774
+
775
+ ### Search Params
776
+
777
+ ```tsx
778
+ import { parseSearchParams, useSearchParams } from '@vertz/ui/router';
779
+
780
+ // Parse raw URLSearchParams, optionally through a schema:
781
+ const params = parseSearchParams(new URLSearchParams('?page=1&sort=name'), mySchema);
782
+
783
+ // Read reactively inside an effect or computed:
784
+ const search = useSearchParams(router.searchParams);
785
+ ```
786
+
787
+ ### Type-Safe Params
788
+
789
+ Route params are extracted from the path pattern at the type level:
790
+
791
+ ```tsx
792
+ import type { ExtractParams } from '@vertz/ui/router';
793
+
794
+ type Params = ExtractParams<'/users/:id/posts/:postId'>;
795
+ // => { id: string; postId: string }
796
+ ```
797
+
798
+ ---
799
+
800
+ ## Forms
801
+
802
+ Import from `@vertz/ui/form` or the main `@vertz/ui` export.
803
+
804
+ ```tsx
805
+ import { form } from '@vertz/ui/form';
806
+ import type { SdkMethod } from '@vertz/ui/form';
807
+
808
+ // An SDK method with endpoint metadata (typically from @vertz/codegen):
809
+ declare const createUser: SdkMethod<{ name: string; email: string }, { id: string }>;
810
+
811
+ const userForm = form(createUser, {
812
+ schema: userSchema, // any object with parse(data): T
813
+ });
814
+
815
+ function CreateUserForm() {
816
+ return (
817
+ <form
818
+ {...userForm.attrs()}
819
+ onSubmit={userForm.handleSubmit({
820
+ onSuccess: (result) => console.log('Created:', result.id),
821
+ onError: (errors) => console.log('Errors:', errors),
822
+ })}
823
+ >
824
+ <input name="name" />
825
+ {userForm.error('name') && <span className="error">{userForm.error('name')}</span>}
826
+
827
+ <input name="email" type="email" />
828
+ {userForm.error('email') && <span className="error">{userForm.error('email')}</span>}
829
+
830
+ <button type="submit" disabled={userForm.submitting.value}>
831
+ {userForm.submitting.value ? 'Saving...' : 'Create'}
832
+ </button>
833
+ </form>
834
+ );
835
+ }
836
+ ```
837
+
838
+ ### API
839
+
840
+ - `form(sdkMethod, { schema })` -- creates a form instance
841
+ - `.attrs()` -- returns `{ action, method }` for progressive enhancement
842
+ - `.handleSubmit({ onSuccess?, onError? })` -- returns an event handler or accepts `FormData` directly
843
+ - `.error(field)` -- returns the error message for a field (reactive)
844
+ - `.submitting` -- `Signal<boolean>` for loading state
845
+
846
+ The schema can be any object with a `parse(data: unknown): T` method (compatible with `@vertz/schema`). On failure, if the error has a `fieldErrors` property, those are surfaced per-field. Otherwise, a generic `_form` error is set.
847
+
848
+ ---
849
+
850
+ ## Lifecycle
851
+
852
+ ### `onMount(callback)`
853
+
854
+ Runs once when the component is created. Does not re-run on signal changes. Supports `onCleanup` inside for teardown:
855
+
856
+ ```tsx
857
+ import { onMount, onCleanup } from '@vertz/ui';
858
+
859
+ function Timer() {
860
+ let elapsed = 0;
861
+
862
+ onMount(() => {
863
+ const id = setInterval(() => elapsed++, 1000);
864
+ onCleanup(() => clearInterval(id));
865
+ });
866
+
867
+ return <span>{elapsed}s</span>;
868
+ }
869
+ ```
870
+
871
+ ### `onCleanup(fn)`
872
+
873
+ Registers a teardown function with the current disposal scope. Called in LIFO order when the scope is disposed:
874
+
875
+ ```tsx
876
+ import { onCleanup } from '@vertz/ui';
877
+
878
+ function WebSocketView() {
879
+ const ws = new WebSocket('wss://example.com');
880
+ onCleanup(() => ws.close());
881
+ // ...
882
+ }
883
+ ```
884
+
885
+ Must be called inside a disposal scope (`effect()`, `watch()`, `onMount()`, or a `pushScope()/popScope()` block). Throws `DisposalScopeError` if called outside a scope.
886
+
887
+ ### `watch(dep, callback)`
888
+
889
+ Watches a reactive dependency and runs the callback whenever it changes. Runs immediately with the current value:
890
+
891
+ ```tsx
892
+ import { watch, onCleanup } from '@vertz/ui';
893
+
894
+ function Logger() {
895
+ let count = 0;
896
+
897
+ watch(
898
+ () => count,
899
+ (value) => {
900
+ console.log('count changed to', value);
901
+ const id = setTimeout(() => console.log('delayed log', value), 1000);
902
+ onCleanup(() => clearTimeout(id));
903
+ }
904
+ );
905
+
906
+ return <button onClick={() => count++}>+</button>;
907
+ }
908
+ ```
909
+
910
+ The `dep` function is the only tracked dependency. The callback runs untracked, so signal reads inside it don't create additional subscriptions. Before each re-run, any `onCleanup` registered in the previous callback execution runs first.
911
+
912
+ ### `ref()`
913
+
914
+ Access a DOM element after creation:
915
+
916
+ ```tsx
917
+ import { ref, onMount } from '@vertz/ui';
918
+
919
+ function FocusInput() {
920
+ const inputRef = ref<HTMLInputElement>();
921
+
922
+ onMount(() => {
923
+ inputRef.current?.focus();
924
+ });
925
+
926
+ // Assign ref.current after element creation:
927
+ const el = <input /> as HTMLInputElement;
928
+ inputRef.current = el;
929
+
930
+ return el;
931
+ }
932
+ ```
933
+
934
+ ### Context
935
+
936
+ Share values down the component tree without prop-drilling:
937
+
938
+ ```tsx
939
+ import { createContext, useContext } from '@vertz/ui';
940
+
941
+ const ThemeCtx = createContext<'light' | 'dark'>('light');
942
+
943
+ function App() {
944
+ const el = document.createDocumentFragment();
945
+
946
+ ThemeCtx.Provider('dark', () => {
947
+ el.appendChild(ThemedCard());
948
+ });
949
+
950
+ return el;
951
+ }
952
+
953
+ function ThemedCard() {
954
+ const theme = useContext(ThemeCtx); // => 'dark'
955
+ return <div className={theme === 'dark' ? 'card-dark' : 'card-light'}>Themed</div>;
956
+ }
957
+ ```
958
+
959
+ `useContext` works in both synchronous component code and inside `effect`/`watch` callbacks (the context scope is captured when the effect is created).
960
+
961
+ ### ErrorBoundary
962
+
963
+ Catch errors thrown by child components:
964
+
965
+ ```tsx
966
+ import { ErrorBoundary } from '@vertz/ui';
967
+
968
+ function App() {
969
+ return ErrorBoundary({
970
+ children: () => RiskyComponent(),
971
+ fallback: (error, retry) => (
972
+ <div>
973
+ <p>Something broke: {error.message}</p>
974
+ <button onClick={retry}>Retry</button>
975
+ </div>
976
+ ),
977
+ });
978
+ }
979
+ ```
980
+
981
+ The `retry` function re-invokes `children()` and swaps the result into the DOM if it succeeds.
982
+
983
+ ### Suspense
984
+
985
+ Handle async boundaries (components that throw promises):
986
+
987
+ ```tsx
988
+ import { Suspense } from '@vertz/ui';
989
+
990
+ function App() {
991
+ return Suspense({
992
+ children: () => AsyncComponent(),
993
+ fallback: () => <div>Loading...</div>,
994
+ });
995
+ }
996
+ ```
997
+
998
+ If `children()` throws a `Promise`, the fallback is rendered. When the promise resolves, `children()` is called again and the result replaces the fallback in the DOM. Non-promise errors are re-thrown for `ErrorBoundary` to catch.
999
+
1000
+ ---
1001
+
1002
+ ## Primitives
1003
+
1004
+ `@vertz/primitives` provides headless, WAI-ARIA compliant UI components. These are intentionally imperative -- they create pre-wired DOM elements with proper ARIA attributes, keyboard handling, and state management.
1005
+
1006
+ ```bash
1007
+ npm install @vertz/primitives
1008
+ ```
1009
+
1010
+ Available components: `Accordion`, `Button`, `Checkbox`, `Combobox`, `Dialog`, `Menu`, `Popover`, `Progress`, `Radio`, `Select`, `Slider`, `Switch`, `Tabs`, `Toast`, `Tooltip`.
1011
+
1012
+ ### Usage Pattern
1013
+
1014
+ Primitives return DOM elements and reactive state. Compose them with your JSX:
1015
+
1016
+ ```tsx
1017
+ import { Button } from '@vertz/primitives';
1018
+ import { css } from '@vertz/ui/css';
1019
+
1020
+ const styles = css({
1021
+ btn: ['px:4', 'py:2', 'bg:primary.600', 'text:foreground', 'rounded:md'],
1022
+ });
1023
+
1024
+ function MyButton() {
1025
+ const { root, state } = Button.Root({
1026
+ disabled: false,
1027
+ onPress: () => console.log('pressed!'),
1028
+ });
1029
+
1030
+ root.textContent = 'Click me';
1031
+ root.classList.add(styles.classNames.btn);
1032
+
1033
+ return root;
1034
+ }
1035
+ ```
1036
+
1037
+ Primitives handle:
1038
+ - ARIA roles and attributes (`role="button"`, `aria-pressed`, `aria-expanded`, etc.)
1039
+ - Keyboard interaction (Enter/Space for buttons, arrow keys for menus, Escape for dialogs)
1040
+ - Focus management
1041
+ - State via signals (`state.disabled`, `state.pressed`, `state.open`, etc.)
1042
+
1043
+ You provide the styling. They provide the behavior and accessibility.
1044
+
1045
+ ---
1046
+
1047
+ ## When to Use `effect()`
1048
+
1049
+ `effect()` is a low-level reactive primitive. In most cases, the compiler handles reactivity for you through JSX. Reach for `effect()` only when you need side effects that the compiler cannot express:
1050
+
1051
+ ### Appropriate Uses
1052
+
1053
+ ```tsx
1054
+ import { effect, onCleanup } from '@vertz/ui';
1055
+
1056
+ function Analytics() {
1057
+ let page = '/home';
1058
+
1059
+ // Side effect: send analytics
1060
+ effect(() => {
1061
+ sendPageView(page);
1062
+ });
1063
+
1064
+ // Third-party library integration
1065
+ effect(() => {
1066
+ chart.updateData(chartData);
1067
+ });
1068
+
1069
+ // DOM operations the compiler can't handle
1070
+ effect(() => {
1071
+ element.scrollTo({ top: scrollPosition, behavior: 'smooth' });
1072
+ });
1073
+
1074
+ // localStorage sync
1075
+ effect(() => {
1076
+ localStorage.setItem('preference', preference);
1077
+ });
1078
+ }
1079
+ ```
1080
+
1081
+ ### NOT Appropriate
1082
+
1083
+ ```tsx
1084
+ // DON'T: manual DOM text updates -- use JSX interpolation instead
1085
+ effect(() => { span.textContent = String(count); });
1086
+ // DO:
1087
+ <span>{count}</span>
1088
+
1089
+ // DON'T: manual attribute updates -- use JSX attributes instead
1090
+ effect(() => { div.className = isActive ? 'on' : 'off'; });
1091
+ // DO:
1092
+ <div className={isActive ? 'on' : 'off'} />
1093
+
1094
+ // DON'T: manual child rendering -- use JSX conditionals and .map() instead
1095
+ effect(() => {
1096
+ container.innerHTML = '';
1097
+ if (show) container.appendChild(createChild());
1098
+ });
1099
+ ```
1100
+
1101
+ `effect()` returns a dispose function. It auto-registers with the current disposal scope, so cleanup happens automatically when the parent scope is disposed:
1102
+
1103
+ ```tsx
1104
+ const dispose = effect(() => {
1105
+ console.log('count is', count);
1106
+ });
1107
+
1108
+ // Manual cleanup if needed:
1109
+ dispose();
1110
+ ```
1111
+
1112
+ ### `batch()`
1113
+
1114
+ Group multiple signal writes to avoid redundant effect runs:
1115
+
1116
+ ```tsx
1117
+ import { batch } from '@vertz/ui';
1118
+
1119
+ batch(() => {
1120
+ firstName = 'Jane';
1121
+ lastName = 'Doe';
1122
+ age = 30;
1123
+ });
1124
+ // Effects that depend on any of these signals run once, not three times.
1125
+ ```
1126
+
1127
+ ### `untrack()`
1128
+
1129
+ Read a signal without subscribing to it:
1130
+
1131
+ ```tsx
1132
+ import { untrack } from '@vertz/ui';
1133
+
1134
+ effect(() => {
1135
+ const tracked = count; // subscribes to count
1136
+ const notTracked = untrack(() => other); // reads other without subscribing
1137
+ });
1138
+ ```