@ozsarman/clarityjs 0.6.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/src/runtime.js ADDED
@@ -0,0 +1,1465 @@
1
+ /**
2
+ * Clarity.js Runtime — Signal-based Reactivity Core
3
+ *
4
+ * Designed for AI and humans alike.
5
+ * Three concepts only: signal, effect, computed.
6
+ * No magic. No implicit behaviour. Everything is explicit.
7
+ *
8
+ * Author: Claude (Anthropic)
9
+ * Version: 0.1.0
10
+ */
11
+
12
+ // ─── Reactive Context ────────────────────────────────────────────────────────
13
+ // The currently executing effect, if any.
14
+ // This is how signals know who is "reading" them.
15
+ let _currentEffect = null;
16
+ let _batchDepth = 0;
17
+ const _pendingEffects = new Set();
18
+
19
+ // ─── Signal ──────────────────────────────────────────────────────────────────
20
+ /**
21
+ * Creates a reactive value.
22
+ *
23
+ * Usage:
24
+ * const count = signal(0)
25
+ * count.get() // read
26
+ * count.set(n + 1) // write
27
+ * count.update(n => n + 1) // update with function
28
+ *
29
+ * When a signal is read inside an effect, the effect subscribes
30
+ * and will re-run whenever the signal changes.
31
+ */
32
+ export function signal(initialValue) {
33
+ let _value = initialValue;
34
+ const _subscribers = new Set();
35
+
36
+ const sig = {
37
+ // Read the value — tracks if inside an effect
38
+ get() {
39
+ if (_currentEffect) {
40
+ _subscribers.add(_currentEffect);
41
+ _currentEffect._deps.add(sig);
42
+ }
43
+ return _value;
44
+ },
45
+
46
+ // Write a new value — notifies all subscribers
47
+ set(newValue) {
48
+ if (Object.is(_value, newValue)) return; // no-op if same value
49
+ _value = newValue;
50
+ _notify(_subscribers);
51
+ },
52
+
53
+ // Update with a function — sugar for set(fn(get()))
54
+ update(fn) {
55
+ sig.set(fn(_value));
56
+ },
57
+
58
+ // Peek without tracking — useful in effects that shouldn't re-run
59
+ peek() {
60
+ return _value;
61
+ },
62
+
63
+ // Internal: for debugging and tooling
64
+ _type: 'signal',
65
+ _subscribers,
66
+ };
67
+
68
+ return sig;
69
+ }
70
+
71
+ // ─── Effect ──────────────────────────────────────────────────────────────────
72
+ /**
73
+ * Runs a function reactively — re-runs whenever its signal dependencies change.
74
+ *
75
+ * Usage:
76
+ * effect(() => {
77
+ * document.title = count.get() + ' items'
78
+ * })
79
+ *
80
+ * The effect runs immediately, then re-runs when any read signal changes.
81
+ * Returns a cleanup/dispose function.
82
+ */
83
+ export function effect(fn) {
84
+ const _effect = {
85
+ _fn: fn,
86
+ _deps: new Set(),
87
+ _cleanup: null,
88
+ _type: 'effect',
89
+ _disposed: false,
90
+ };
91
+
92
+ function run() {
93
+ if (_effect._disposed) return;
94
+
95
+ // Cleanup previous subscriptions
96
+ _cleanup(_effect);
97
+
98
+ // Run with this effect as the current tracker
99
+ const prev = _currentEffect;
100
+ _currentEffect = _effect;
101
+ try {
102
+ const cleanup = fn();
103
+ if (typeof cleanup === 'function') {
104
+ _effect._cleanup = cleanup;
105
+ }
106
+ } finally {
107
+ _currentEffect = prev;
108
+ }
109
+ }
110
+
111
+ _effect._run = run;
112
+ run(); // Run immediately
113
+
114
+ // Return dispose function
115
+ return function dispose() {
116
+ _effect._disposed = true;
117
+ _cleanup(_effect);
118
+ };
119
+ }
120
+
121
+ // ─── Computed ────────────────────────────────────────────────────────────────
122
+ /**
123
+ * A derived signal — computes a value from other signals.
124
+ * Lazy: only recomputes when read and dependencies have changed.
125
+ *
126
+ * Usage:
127
+ * const doubled = computed(() => count.get() * 2)
128
+ * doubled.get() // always up to date
129
+ */
130
+ export function computed(fn) {
131
+ let _dirty = true;
132
+ let _value;
133
+ const _subscribers = new Set();
134
+
135
+ const _effect = {
136
+ _fn: fn,
137
+ _deps: new Set(),
138
+ _cleanup: null,
139
+ _type: 'computed',
140
+ _disposed: false,
141
+ _run() {
142
+ _dirty = true;
143
+ _notify(_subscribers);
144
+ },
145
+ };
146
+
147
+ const comp = {
148
+ get() {
149
+ // Track who is reading this computed
150
+ if (_currentEffect) {
151
+ _subscribers.add(_currentEffect);
152
+ _currentEffect._deps.add(comp);
153
+ }
154
+
155
+ if (_dirty) {
156
+ // Recompute
157
+ _cleanup(_effect);
158
+ const prev = _currentEffect;
159
+ _currentEffect = _effect;
160
+ try {
161
+ _value = fn();
162
+ _dirty = false;
163
+ } finally {
164
+ _currentEffect = prev;
165
+ }
166
+ }
167
+
168
+ return _value;
169
+ },
170
+
171
+ peek() {
172
+ return _value;
173
+ },
174
+
175
+ _type: 'computed',
176
+ _subscribers,
177
+ };
178
+
179
+ return comp;
180
+ }
181
+
182
+ // ─── Batch ───────────────────────────────────────────────────────────────────
183
+ /**
184
+ * Batch multiple signal writes — effects run once after all updates.
185
+ *
186
+ * Usage:
187
+ * batch(() => {
188
+ * firstName.set('Ada')
189
+ * lastName.set('Lovelace')
190
+ * })
191
+ * // name effect runs once, not twice
192
+ */
193
+ export function batch(fn) {
194
+ _batchDepth++;
195
+ try {
196
+ fn();
197
+ } finally {
198
+ _batchDepth--;
199
+ if (_batchDepth === 0) {
200
+ // Flush all pending effects
201
+ const toRun = new Set(_pendingEffects);
202
+ _pendingEffects.clear();
203
+ toRun.forEach(e => e._run());
204
+ }
205
+ }
206
+ }
207
+
208
+ // ─── DOM Helpers ─────────────────────────────────────────────────────────────
209
+ // ─── AI Registry ─────────────────────────────────────────────────────────────
210
+ /**
211
+ * Global registry of all mounted Clarity components.
212
+ * AI agents can call aiDiscover() to enumerate all live components
213
+ * and their declared permissions.
214
+ */
215
+ const _aiRegistry = new Set();
216
+
217
+ /**
218
+ * Query the AI contract for a DOM element (or its nearest Clarity ancestor).
219
+ *
220
+ * Usage (by an AI agent):
221
+ * const contract = aiQuery(document.getElementById('card'));
222
+ * contract.snapshot() // { name: 'Ada', email: '...' }
223
+ * contract.act('follow') // triggers declared action
224
+ * contract.forbidden // ['balance', 'token']
225
+ */
226
+ export function aiQuery(element) {
227
+ let el = element;
228
+ while (el) {
229
+ if (el.__clarity_ai__) return el.__clarity_ai__;
230
+ el = el.parentElement;
231
+ }
232
+ return null;
233
+ }
234
+
235
+ /**
236
+ * Discover all mounted Clarity components with AI contracts.
237
+ *
238
+ * Usage:
239
+ * aiDiscover().forEach(c => console.log(c.component, c.snapshot()))
240
+ */
241
+ export function aiDiscover() {
242
+ return [..._aiRegistry].filter(el => el.isConnected && el.__clarity_ai__)
243
+ .map(el => el.__clarity_ai__);
244
+ }
245
+
246
+ /**
247
+ * Mount a Clarity component into a DOM element.
248
+ *
249
+ * - Calls component.__clarity_mount__() after DOM insertion (onMount hook)
250
+ * - Returns an unmount() function that calls __clarity_cleanup__() and removes the node
251
+ *
252
+ * Usage:
253
+ * const unmount = mount(Counter, document.getElementById('app'))
254
+ * unmount() // later: removes component, disposes all effects
255
+ */
256
+ export function mount(ComponentFn, container, props = {}) {
257
+ if (!container) throw new Error(`[Clarity] mount: container element not found`);
258
+ container.innerHTML = '';
259
+ const el = ComponentFn(props);
260
+
261
+ if (!(el instanceof Node)) {
262
+ console.error('[Clarity] mount: component must return a DOM Node');
263
+ return () => {};
264
+ }
265
+
266
+ container.appendChild(el);
267
+
268
+ // Register in AI discovery registry
269
+ if (el.__clarity_ai__) _aiRegistry.add(el);
270
+
271
+ // Run onMount hooks across the whole inserted subtree (nested components too)
272
+ _runMountHooks(el);
273
+
274
+ // Return unmount function for cleanup
275
+ return function unmount() {
276
+ _aiRegistry.delete(el);
277
+ _runCleanupHooks(el);
278
+ if (el.parentNode) {
279
+ el.parentNode.removeChild(el);
280
+ }
281
+ };
282
+ }
283
+
284
+ /**
285
+ * @internal — invoke __clarity_mount__ on a node and every descendant, once each.
286
+ * mount() and the pages router call this so nested components' onMount runs.
287
+ */
288
+ export function _runMountHooks(root) {
289
+ if (!root) return;
290
+ const nodes = (typeof root.querySelectorAll === 'function')
291
+ ? [root, ...root.querySelectorAll('*')]
292
+ : [root];
293
+ for (const n of nodes) {
294
+ if (typeof n.__clarity_mount__ === 'function' && !n.__clarity_mounted__) {
295
+ n.__clarity_mounted__ = true;
296
+ if (n.__clarity_ai__) _aiRegistry.add(n);
297
+ try { n.__clarity_mount__(); } catch (e) { console.error('[Clarity onMount]', e); }
298
+ }
299
+ }
300
+ }
301
+
302
+ /**
303
+ * @internal — invoke __clarity_cleanup__ on a node and every descendant, once each.
304
+ */
305
+ export function _runCleanupHooks(root) {
306
+ if (!root) return;
307
+ const nodes = (typeof root.querySelectorAll === 'function')
308
+ ? [root, ...root.querySelectorAll('*')]
309
+ : [root];
310
+ for (const n of nodes) {
311
+ if (typeof n.__clarity_cleanup__ === 'function' && !n.__clarity_cleaned__) {
312
+ n.__clarity_cleaned__ = true;
313
+ _aiRegistry.delete(n);
314
+ try { n.__clarity_cleanup__(); } catch (e) { console.error('[Clarity onCleanup]', e); }
315
+ }
316
+ }
317
+ }
318
+
319
+ /**
320
+ * Create a DOM element with reactive bindings.
321
+ * This is what the code generator produces — never write it manually.
322
+ *
323
+ * Usage (generated code):
324
+ * const el = h('div', { class: 'card' }, [child1, child2])
325
+ */
326
+ export function h(tag, props = {}, children = []) {
327
+ const el = document.createElement(tag);
328
+
329
+ for (const [key, value] of Object.entries(props)) {
330
+ if (key.startsWith('on:')) {
331
+ // Event binding: on:click, on:input, on:change, etc.
332
+ const eventName = key.slice(3);
333
+ el.addEventListener(eventName, typeof value === 'function' ? value : () => value);
334
+ } else if (key === 'class') {
335
+ // Reactive class
336
+ if (typeof value === 'function') {
337
+ effect(() => { el.className = value(); });
338
+ } else {
339
+ el.className = value;
340
+ }
341
+ } else if (key === 'style' && typeof value === 'object') {
342
+ // Style object
343
+ Object.assign(el.style, value);
344
+ } else if (key.startsWith('bind:')) {
345
+ // Two-way binding: bind:value, bind:checked
346
+ const attr = key.slice(5);
347
+ const sig = value;
348
+ // Read: update element when signal changes
349
+ effect(() => { el[attr] = sig.get(); });
350
+ // Write: update signal when element changes
351
+ const eventName = attr === 'checked' ? 'change' : 'input';
352
+ el.addEventListener(eventName, () => {
353
+ sig.set(attr === 'checked' ? el.checked : el.value);
354
+ });
355
+ } else if (key === 'ref') {
356
+ // Ref: expose the DOM node
357
+ if (typeof value === 'function') value(el);
358
+ else if (value && '_type' in value) value.set(el);
359
+ } else {
360
+ // Static or reactive attribute
361
+ if (typeof value === 'function') {
362
+ effect(() => {
363
+ const v = value();
364
+ if (v === false || v === null || v === undefined) {
365
+ el.removeAttribute(key);
366
+ } else if (v === true) {
367
+ el.setAttribute(key, '');
368
+ } else {
369
+ el.setAttribute(key, v);
370
+ }
371
+ });
372
+ } else {
373
+ el.setAttribute(key, value);
374
+ }
375
+ }
376
+ }
377
+
378
+ for (const child of children) {
379
+ appendChild(el, child);
380
+ }
381
+
382
+ return el;
383
+ }
384
+
385
+ /**
386
+ * Append a child to an element — handles text, signals, nodes, and arrays.
387
+ */
388
+ export function appendChild(parent, child) {
389
+ if (child === null || child === undefined || child === false) return;
390
+
391
+ if (Array.isArray(child)) {
392
+ child.forEach(c => appendChild(parent, c));
393
+ return;
394
+ }
395
+
396
+ if (child instanceof Node) {
397
+ parent.appendChild(child);
398
+ return;
399
+ }
400
+
401
+ if (typeof child === 'object' && child._type === 'signal') {
402
+ // Reactive text node
403
+ const textNode = document.createTextNode(String(child.get()));
404
+ effect(() => { textNode.textContent = String(child.get()); });
405
+ parent.appendChild(textNode);
406
+ return;
407
+ }
408
+
409
+ if (typeof child === 'function') {
410
+ // Reactive content — could return different nodes
411
+ const anchor = document.createComment('clarity:reactive');
412
+ parent.appendChild(anchor);
413
+ let currentNodes = [];
414
+ effect(() => {
415
+ const result = child();
416
+ // Remove old nodes
417
+ currentNodes.forEach(n => n.parentNode?.removeChild(n));
418
+ currentNodes = [];
419
+ // Add new
420
+ if (result instanceof Node) {
421
+ anchor.parentNode.insertBefore(result, anchor.nextSibling);
422
+ currentNodes = [result];
423
+ } else if (result !== null && result !== undefined && result !== false) {
424
+ const textNode = document.createTextNode(String(result));
425
+ anchor.parentNode.insertBefore(textNode, anchor.nextSibling);
426
+ currentNodes = [textNode];
427
+ }
428
+ });
429
+ return;
430
+ }
431
+
432
+ // Static text
433
+ parent.appendChild(document.createTextNode(String(child)));
434
+ }
435
+
436
+ /**
437
+ * Conditional rendering — like a ternary but for DOM nodes.
438
+ *
439
+ * Usage:
440
+ * when(() => isLoggedIn.get(), () => h('div', {}, ['Welcome!']), () => h('div', {}, ['Please log in']))
441
+ */
442
+ export function when(conditionFn, thenFn, elseFn = null) {
443
+ const anchor = document.createComment('clarity:when');
444
+
445
+ // We need a container; return a function that resolves post-mount
446
+ // In practice, the code generator wraps this in appendChild
447
+ return () => {
448
+ const parent = anchor.parentNode;
449
+ if (!parent) return anchor;
450
+
451
+ let currentNode = null;
452
+ effect(() => {
453
+ if (currentNode) {
454
+ parent.removeChild(currentNode);
455
+ currentNode = null;
456
+ }
457
+ if (conditionFn()) {
458
+ currentNode = thenFn();
459
+ parent.insertBefore(currentNode, anchor.nextSibling);
460
+ } else if (elseFn) {
461
+ currentNode = elseFn();
462
+ parent.insertBefore(currentNode, anchor.nextSibling);
463
+ }
464
+ });
465
+
466
+ return anchor;
467
+ };
468
+ }
469
+
470
+ /**
471
+ * Reactive list rendering.
472
+ *
473
+ * Usage:
474
+ * list(items, item => h('li', {}, [item.name.get()]))
475
+ */
476
+ export function list(itemsSignal, renderFn, keyFn = (item, i) => i) {
477
+ const anchor = document.createComment('clarity:list');
478
+ const nodeMap = new Map();
479
+
480
+ return () => {
481
+ const parent = anchor.parentNode;
482
+ if (!parent) return anchor;
483
+
484
+ effect(() => {
485
+ const items = itemsSignal.get();
486
+ const newKeys = items.map(keyFn);
487
+
488
+ // Remove nodes no longer in list
489
+ for (const [key, node] of nodeMap) {
490
+ if (!newKeys.includes(key)) {
491
+ parent.removeChild(node);
492
+ nodeMap.delete(key);
493
+ }
494
+ }
495
+
496
+ // Insert/reorder nodes
497
+ let refNode = anchor.nextSibling;
498
+ for (let i = 0; i < items.length; i++) {
499
+ const key = keyFn(items[i], i);
500
+ let node = nodeMap.get(key);
501
+
502
+ if (!node) {
503
+ node = renderFn(items[i], i);
504
+ nodeMap.set(key, node);
505
+ }
506
+
507
+ if (node !== refNode) {
508
+ parent.insertBefore(node, refNode);
509
+ } else {
510
+ refNode = refNode.nextSibling;
511
+ }
512
+ }
513
+ });
514
+
515
+ return anchor;
516
+ };
517
+ }
518
+
519
+ // ─── Internal Helpers ────────────────────────────────────────────────────────
520
+ function _notify(subscribers) {
521
+ // Snapshot before iterating — subscribers may be removed+re-added during _run()
522
+ // (e.g. _cleanup removes, then re-read re-adds). Without snapshot, the Set
523
+ // iterator revisits re-added items causing an infinite loop.
524
+ const snapshot = [...subscribers];
525
+ for (const sub of snapshot) {
526
+ if (_batchDepth > 0) {
527
+ _pendingEffects.add(sub);
528
+ } else {
529
+ sub._run();
530
+ }
531
+ }
532
+ }
533
+
534
+ function _cleanup(eff) {
535
+ if (eff._cleanup) {
536
+ eff._cleanup();
537
+ eff._cleanup = null;
538
+ }
539
+ for (const dep of eff._deps) {
540
+ if (dep._subscribers) dep._subscribers.delete(eff);
541
+ }
542
+ eff._deps.clear();
543
+ }
544
+
545
+ // ─── Lifecycle Cleanup Helper ────────────────────────────────────────────────
546
+ /**
547
+ * Recursively calls __clarity_cleanup__ on a node and its children.
548
+ *
549
+ * Used by <when> and <list> to dispose effect subscriptions when nodes
550
+ * are removed from the DOM. Without this, effects keep running even after
551
+ * the component is gone — a silent memory / CPU leak.
552
+ *
553
+ * Handles DocumentFragment by iterating its childNodes before removal,
554
+ * since fragment.childNodes is live and empties once the fragment is inserted.
555
+ */
556
+ export function _callCleanup(node) {
557
+ if (!node) return;
558
+ if (typeof node.__clarity_cleanup__ === 'function') {
559
+ node.__clarity_cleanup__();
560
+ }
561
+ // DocumentFragment: iterate children (they're moved to DOM on appendChild/insertBefore,
562
+ // so we check childNodes while they still belong to the fragment)
563
+ if (node.nodeType === 11 /* DOCUMENT_FRAGMENT_NODE */) {
564
+ const kids = [...node.childNodes]; // snapshot — live collection mutates on insert
565
+ kids.forEach(child => _callCleanup(child));
566
+ }
567
+ }
568
+
569
+ // ─── Lazy / Suspense ──────────────────────────────────────────────────────────
570
+ //
571
+ // Suspense context stack — the innermost <Suspense> component pushes a context
572
+ // object here. lazy() components check the top of the stack and register their
573
+ // load-promises with it so <Suspense> knows when all children are loaded.
574
+ const _suspenseStack = [];
575
+
576
+ /** @internal — used by generated <Suspense> code */
577
+ export function _pushSuspense(ctx) { _suspenseStack.push(ctx); }
578
+ /** @internal */
579
+ export function _popSuspense() { _suspenseStack.pop(); }
580
+ /** @internal — called by lazy() when inside a <Suspense> boundary */
581
+ export function _registerLazy(promise) {
582
+ if (_suspenseStack.length > 0) {
583
+ _suspenseStack[_suspenseStack.length - 1]._pending.push(promise);
584
+ }
585
+ }
586
+
587
+ /**
588
+ * Lazily loads a component from a dynamic import.
589
+ *
590
+ * Usage:
591
+ * import { lazy } from './clarity-runtime.js'
592
+ * const HeavyChart = lazy(() => import('./HeavyChart.clarity'))
593
+ *
594
+ * // In a component render:
595
+ * <Suspense fallback="Loading chart…">
596
+ * <HeavyChart data={myData} />
597
+ * </Suspense>
598
+ *
599
+ * // Or without Suspense (self-managed):
600
+ * <HeavyChart data={myData} /> // shows a comment while loading, then swaps in
601
+ *
602
+ * The return value is a component function — it can be passed to
603
+ * <Component is={...} /> for dynamic dispatch too.
604
+ */
605
+ export function lazy(importFn) {
606
+ const _loaded = signal(null); // null while loading; component fn when ready
607
+ let _errored = false;
608
+
609
+ // Start loading immediately
610
+ const _promise = importFn().then(mod => {
611
+ const comp = mod.default ?? Object.values(mod)[0];
612
+ // Delay one tick so DOM is settled before any reactive swaps fire
613
+ setTimeout(() => _loaded.set(comp), 0);
614
+ }).catch(err => {
615
+ console.error('[Clarity lazy] Failed to load component:', err);
616
+ _errored = true;
617
+ setTimeout(() => _loaded.set(() => {
618
+ const el = document.createElement('span');
619
+ el.style.cssText = 'color:#f87171;font-size:0.8em;padding:4px 8px;';
620
+ el.textContent = `[lazy load failed: ${err.message}]`;
621
+ return el;
622
+ }), 0);
623
+ });
624
+
625
+ const LazyWrapper = function LazyComponent(props = {}) {
626
+ // Already loaded — render immediately, no overhead
627
+ const comp = _loaded.peek();
628
+ if (comp) return comp(props);
629
+
630
+ // Register with nearest Suspense boundary (if any)
631
+ const insideSuspense = _suspenseStack.length > 0;
632
+ if (insideSuspense) _registerLazy(_promise);
633
+
634
+ // Return a comment placeholder; swap it with the real component when loaded
635
+ const placeholder = document.createComment('clarity:lazy');
636
+
637
+ // Self-managed swap: fires after setTimeout inside _promise .then
638
+ _promise.then(() => {
639
+ // Extra setTimeout so we're always one tick after the _loaded.set() call above
640
+ setTimeout(() => {
641
+ if (placeholder.parentNode) {
642
+ const c = _loaded.peek();
643
+ if (c) {
644
+ const el = c(props);
645
+ placeholder.parentNode.insertBefore(el, placeholder);
646
+ placeholder.parentNode.removeChild(placeholder);
647
+ }
648
+ }
649
+ }, 0);
650
+ });
651
+
652
+ return placeholder;
653
+ };
654
+
655
+ LazyWrapper._clarityLazy = true;
656
+ LazyWrapper._loaded = _loaded;
657
+ LazyWrapper._promise = _promise;
658
+ return LazyWrapper;
659
+ }
660
+
661
+ // ─── Context API ─────────────────────────────────────────────────────────────
662
+ /**
663
+ * Context API — provides a way to pass data through the component tree
664
+ * without passing props manually at every level (equivalent to React Context
665
+ * or Vue provide/inject).
666
+ *
667
+ * Usage:
668
+ * // 1. Create a context (usually in its own file)
669
+ * export const ThemeCtx = createContext('light')
670
+ *
671
+ * // 2. Provide a value with <Provider ctx={ThemeCtx} value={theme}> in .clarity
672
+ * // The codegen emits _pushContext / _popContext around the children.
673
+ *
674
+ * // 3. Consume in any descendant component
675
+ * const theme = useContext(ThemeCtx) // 'light' | 'dark' | or a Signal
676
+ *
677
+ * If `value` passed to Provider is a Signal, useContext returns that signal's
678
+ * current value AND subscribes the calling effect to future changes.
679
+ *
680
+ * Dynamic scoping: context is pushed/popped during the synchronous initial
681
+ * render. Subsequent reactive reads go through the signal returned by the
682
+ * Provider and stay reactive automatically.
683
+ */
684
+ const _ctxRegistry = new Map(); // Symbol → any[]
685
+
686
+ /**
687
+ * Creates a context token with an optional default value.
688
+ * @param {*} defaultValue — returned by useContext() when no Provider wraps the caller
689
+ * @returns {{ id: symbol, _type: 'context', _default: * }}
690
+ */
691
+ export function createContext(defaultValue) {
692
+ const id = Symbol('clarity:context');
693
+ _ctxRegistry.set(id, []);
694
+ return { id, _type: 'context', _default: defaultValue };
695
+ }
696
+
697
+ /** @internal — emitted by <Provider ctx={} value={}> codegen */
698
+ export function _pushContext(ctx, value) {
699
+ const stack = _ctxRegistry.get(ctx.id);
700
+ if (stack) stack.push(value);
701
+ }
702
+
703
+ /** @internal — emitted by <Provider> codegen after children render */
704
+ export function _popContext(ctx) {
705
+ const stack = _ctxRegistry.get(ctx.id);
706
+ if (stack && stack.length > 0) stack.pop();
707
+ }
708
+
709
+ /**
710
+ * Reads the nearest provided context value.
711
+ *
712
+ * If the value on the stack is a Signal, its .get() is called so the
713
+ * current effect subscribes to future changes.
714
+ *
715
+ * @param {{ id: symbol, _default: * }} ctx — context token from createContext()
716
+ * @returns {*} current context value
717
+ */
718
+ export function useContext(ctx) {
719
+ const stack = _ctxRegistry.get(ctx.id);
720
+ const raw = (stack && stack.length > 0) ? stack[stack.length - 1] : ctx._default;
721
+ // Unwrap signal — subscriber will re-run when the signal changes
722
+ if (raw !== null && raw !== undefined && typeof raw === 'object' && raw._type === 'signal') {
723
+ return raw.get();
724
+ }
725
+ return raw;
726
+ }
727
+
728
+ // ─── reactive() ──────────────────────────────────────────────────────────────
729
+ /**
730
+ * Creates a reactive proxy around a plain object.
731
+ * Every own enumerable key is backed by a signal so reading a property
732
+ * inside an effect creates a subscription and writing triggers re-renders.
733
+ *
734
+ * Equivalent to Vue 3's reactive().
735
+ *
736
+ * Usage:
737
+ * const state = reactive({ count: 0, name: 'Clarity' })
738
+ * effect(() => console.log(state.count)) // logs 0
739
+ * state.count++ // logs 1
740
+ *
741
+ * Notes:
742
+ * • Deep nesting is NOT tracked — nested objects require their own reactive()
743
+ * • Adding new keys after creation creates a new signal for that key
744
+ * • Symbol keys are passed through to the target without signal wrapping
745
+ *
746
+ * @param {object} obj — plain object to make reactive
747
+ * @returns {Proxy}
748
+ */
749
+ export function reactive(obj) {
750
+ if (obj === null || typeof obj !== 'object') {
751
+ throw new Error('[Clarity reactive] argument must be a plain object');
752
+ }
753
+
754
+ const _signals = {};
755
+ // Seed signals from initial keys
756
+ for (const key of Object.keys(obj)) {
757
+ _signals[key] = signal(obj[key]);
758
+ }
759
+
760
+ // Pending signals for keys read before they were set — keeps ownKeys clean
761
+ const _pending = {};
762
+
763
+ return new Proxy(obj, {
764
+ get(target, key, receiver) {
765
+ // Expose _signals for tooling (DevTools, spySignal)
766
+ if (key === '__clarity_signals__') return _signals;
767
+ if (typeof key === 'string') {
768
+ if (key in _signals) return _signals[key].get(); // tracked read
769
+ // Track reads of unknown keys via a pending signal so effects re-run
770
+ // when the key is later written via set().
771
+ if (!(key in _pending)) _pending[key] = signal(undefined);
772
+ return _pending[key].get();
773
+ }
774
+ return Reflect.get(target, key, receiver);
775
+ },
776
+ set(target, key, value) {
777
+ if (typeof key === 'string') {
778
+ if (key in _signals) {
779
+ _signals[key].set(value);
780
+ } else if (key in _pending) {
781
+ // Promote pending → real signal and notify subscribed effects
782
+ _pending[key].set(value);
783
+ _signals[key] = _pending[key];
784
+ delete _pending[key];
785
+ } else {
786
+ // Brand-new key with no prior reads
787
+ _signals[key] = signal(value);
788
+ }
789
+ return true;
790
+ }
791
+ return Reflect.set(target, key, value);
792
+ },
793
+ has(target, key) {
794
+ return (typeof key === 'string' && key in _signals) || key in target;
795
+ },
796
+ ownKeys() {
797
+ return Object.keys(_signals);
798
+ },
799
+ getOwnPropertyDescriptor(target, key) {
800
+ if (typeof key === 'string' && key in _signals) {
801
+ return { enumerable: true, configurable: true, writable: true, value: _signals[key].get() };
802
+ }
803
+ return Reflect.getOwnPropertyDescriptor(target, key);
804
+ },
805
+ });
806
+ }
807
+
808
+ // ─── Component lifecycle tracking ────────────────────────────────────────────
809
+ /**
810
+ * Internal: tracks the component currently being constructed so onMount /
811
+ * onCleanup registrations are attached to the right node.
812
+ *
813
+ * The codegen wraps each component body in _withComponent(() => { ... }) so
814
+ * that onMount() / onCleanup() calls inside the function body are captured.
815
+ */
816
+ let _currentComponent = null;
817
+
818
+ /** @internal */
819
+ export function _withComponent(renderFn) {
820
+ const comp = { _befores: [], _mounts: [], _cleanups: [], _updates: [] };
821
+ const prev = _currentComponent;
822
+ _currentComponent = comp;
823
+ let node;
824
+ try {
825
+ node = renderFn();
826
+ } finally {
827
+ _currentComponent = prev;
828
+ }
829
+
830
+ // beforeMount: run synchronously now — the node has been rendered but is not
831
+ // yet inserted into the DOM (Vue's onBeforeMount semantics).
832
+ if (comp._befores.length) {
833
+ comp._befores.forEach(f => {
834
+ try { f(); } catch (e) { console.error('[Clarity beforeMount]', e); }
835
+ });
836
+ }
837
+
838
+ // Use duck-typing so this works in both browser and Node.js test environments.
839
+ // Any object with a numeric nodeType (real DOM node or test mock) is accepted.
840
+ const isNodeLike = node !== null && typeof node === 'object' && typeof node.nodeType === 'number';
841
+ if (isNodeLike) {
842
+ if (comp._mounts.length) {
843
+ const prevMount = node.__clarity_mount__;
844
+ node.__clarity_mount__ = () => {
845
+ if (typeof prevMount === 'function') prevMount();
846
+ comp._mounts.forEach(f => f());
847
+ };
848
+ }
849
+ if (comp._cleanups.length) {
850
+ const prevClean = node.__clarity_cleanup__;
851
+ node.__clarity_cleanup__ = () => {
852
+ if (typeof prevClean === 'function') prevClean();
853
+ comp._cleanups.forEach(f => f());
854
+ };
855
+ }
856
+ // onUpdate: schedule callbacks after every reactive update (not on first mount)
857
+ if (comp._updates.length) {
858
+ let _mounted = false;
859
+ const prevMount = node.__clarity_mount__;
860
+ node.__clarity_mount__ = () => {
861
+ if (typeof prevMount === 'function') prevMount();
862
+ // Mark as mounted — effects running after this point trigger onUpdate
863
+ Promise.resolve().then(() => { _mounted = true; });
864
+ };
865
+ // Attach update runner to node for the effect scheduler to call
866
+ node.__clarity_update__ = () => {
867
+ if (_mounted) comp._updates.forEach(f => { try { f(); } catch (e) { console.error('[Clarity onUpdate]', e); } });
868
+ };
869
+ }
870
+ }
871
+ return node;
872
+ }
873
+
874
+ // ─── createRef ────────────────────────────────────────────────────────────────
875
+
876
+ /**
877
+ * Create an imperative ref — a mutable container for a DOM node or any value.
878
+ *
879
+ * Unlike signals, refs do NOT trigger reactivity. They are used for:
880
+ * • Storing a reference to a DOM element (for focus, animations, measurements)
881
+ * • Holding mutable instance values that don't affect rendering
882
+ *
883
+ * Usage in a component:
884
+ * const inputRef = createRef();
885
+ *
886
+ * // In JSX:
887
+ * <input ref={inputRef} />
888
+ *
889
+ * // In onMount or an effect:
890
+ * onMount(() => inputRef.current?.focus());
891
+ *
892
+ * Usage as a callback ref:
893
+ * const inputRef = createRef();
894
+ * <input ref={(el) => inputRef.current = el} />
895
+ *
896
+ * @template T
897
+ * @param {T} [initialValue=null] — initial value (default: null)
898
+ * @returns {{ current: T | null, readonly __isRef: true }}
899
+ */
900
+ export function createRef(initialValue = null) {
901
+ return {
902
+ current: initialValue,
903
+ __isRef: true,
904
+ };
905
+ }
906
+
907
+ /** Alias — same as createRef() */
908
+ export const useRef = createRef;
909
+
910
+ // ─── onUpdate ─────────────────────────────────────────────────────────────────
911
+
912
+ /**
913
+ * Register a callback to run after EACH reactive update of the component
914
+ * (but NOT on the initial mount — use onMount for that).
915
+ *
916
+ * Equivalent to React's useEffect with deps, or Vue's onUpdated hook.
917
+ *
918
+ * Must be called at the top level of a component function.
919
+ *
920
+ * @param {() => void} fn
921
+ */
922
+ export function onUpdate(fn) {
923
+ if (_currentComponent) {
924
+ _currentComponent._updates = _currentComponent._updates ?? [];
925
+ _currentComponent._updates.push(fn);
926
+ } else if (typeof console !== 'undefined') {
927
+ console.warn('[Clarity onUpdate] called outside of a component render. Did you forget _withComponent()?');
928
+ }
929
+ }
930
+
931
+ /**
932
+ * Register a callback to run AFTER the component has rendered but BEFORE its
933
+ * root node is inserted into the DOM (equivalent to Vue's onBeforeMount).
934
+ *
935
+ * Runs synchronously: state is initialised and the DOM subtree exists, but it
936
+ * is not yet attached to the document — so measurements that require layout
937
+ * are not available (use onMount for those). Good for last-moment state setup,
938
+ * reading initial props, or kicking off data loads before paint.
939
+ *
940
+ * Must be called at the top level of a component function.
941
+ *
942
+ * @param {() => void} fn
943
+ */
944
+ export function beforeMount(fn) {
945
+ if (_currentComponent) {
946
+ _currentComponent._befores = _currentComponent._befores ?? [];
947
+ _currentComponent._befores.push(fn);
948
+ } else if (typeof console !== 'undefined') {
949
+ console.warn('[Clarity beforeMount] called outside of a component render. Did you forget _withComponent()?');
950
+ }
951
+ }
952
+
953
+ /**
954
+ * Register a callback to run after the component's root node is inserted
955
+ * into the DOM (equivalent to React useEffect(fn, []) or Vue onMounted).
956
+ *
957
+ * Must be called at the top level of a component function, NOT inside an
958
+ * effect or async callback.
959
+ *
960
+ * @param {() => void} fn
961
+ */
962
+ export function onMount(fn) {
963
+ if (_currentComponent) {
964
+ _currentComponent._mounts.push(fn);
965
+ } else if (typeof console !== 'undefined') {
966
+ console.warn('[Clarity onMount] called outside of a component render. Did you forget _withComponent()?');
967
+ }
968
+ }
969
+
970
+ /**
971
+ * Register a callback to run when the component is removed from the DOM
972
+ * (equivalent to the cleanup return from useEffect, or Vue onUnmounted).
973
+ *
974
+ * Must be called at the top level of a component function.
975
+ *
976
+ * @param {() => void} fn
977
+ */
978
+ export function onCleanup(fn) {
979
+ if (_currentComponent) {
980
+ _currentComponent._cleanups.push(fn);
981
+ } else if (typeof console !== 'undefined') {
982
+ console.warn('[Clarity onCleanup] called outside of a component render. Did you forget _withComponent()?');
983
+ }
984
+ }
985
+
986
+ // ─── AI Audit Log ────────────────────────────────────────────────────────────
987
+ /**
988
+ * Append-only audit trail for every AI ↔ component interaction.
989
+ * Each entry is an immutable record; listeners are notified synchronously.
990
+ *
991
+ * Entry shape:
992
+ * { type: 'read'|'act'|'forbidden_write', component, field|action, args?, value?, timestamp }
993
+ */
994
+
995
+ const _aiAuditLog = []; // all-time log (grows until clearAIAuditLog())
996
+ const _aiListeners = new Set(); // onAIAction subscribers
997
+ let _inAIAction = false; // true while act() is executing → guards forbidden writes
998
+
999
+ /** @internal — called by generated AI contract code on every read / act */
1000
+ export function _aiAuditRecord(entry) {
1001
+ const rec = Object.freeze({ ...entry, timestamp: Date.now() });
1002
+ _aiAuditLog.push(rec);
1003
+ _aiListeners.forEach(fn => { try { fn(rec); } catch { /* never throw from listener */ } });
1004
+ return rec;
1005
+ }
1006
+
1007
+ /** @internal — set/clear the AI-action execution context */
1008
+ export function _setAIActionContext(val) { _inAIAction = Boolean(val); }
1009
+ /** @internal — read the AI-action context flag */
1010
+ export function _isInAIAction() { return _inAIAction; }
1011
+
1012
+ /**
1013
+ * Returns a copy of the full audit log.
1014
+ * @returns {ReadonlyArray<object>}
1015
+ */
1016
+ export function getAIAuditLog() { return [..._aiAuditLog]; }
1017
+
1018
+ /**
1019
+ * Clears the in-memory audit log (does not affect future entries).
1020
+ */
1021
+ export function clearAIAuditLog() { _aiAuditLog.length = 0; }
1022
+
1023
+ /**
1024
+ * Subscribe to real-time AI action events.
1025
+ * The listener is called synchronously for every read, act, or forbidden attempt.
1026
+ * @param {(entry: object) => void} fn
1027
+ * @returns {() => void} unsubscribe function
1028
+ */
1029
+ export function onAIAction(fn) {
1030
+ _aiListeners.add(fn);
1031
+ return () => _aiListeners.delete(fn);
1032
+ }
1033
+
1034
+ /**
1035
+ * Distinct error thrown when an AI agent (any code running inside an `act()`
1036
+ * call, or a contract read of a non-readable field) touches `ai:forbidden`
1037
+ * state. Catchable by type so hosts can handle agent violations specifically.
1038
+ */
1039
+ export class ClarityAIForbiddenError extends Error {
1040
+ constructor(message, info = {}) {
1041
+ super(message);
1042
+ this.name = 'ClarityAIForbiddenError';
1043
+ this.component = info.component;
1044
+ this.field = info.field;
1045
+ this.kind = info.kind; // 'read' | 'write' | 'access'
1046
+ }
1047
+ }
1048
+
1049
+ /**
1050
+ * @internal — record a forbidden-access violation in the audit log and throw.
1051
+ * Used by `_protectedSignal` (runtime guard) and by the generated contract
1052
+ * `read()` method. Never returns.
1053
+ */
1054
+ export function _aiThrowForbidden(componentName, fieldName, kind = 'access') {
1055
+ _aiAuditRecord({ type: 'forbidden_' + kind, component: componentName, field: fieldName });
1056
+ throw new ClarityAIForbiddenError(
1057
+ `[Clarity AI] Forbidden: an agent attempted to ${kind} '${fieldName}' on ${componentName}. ` +
1058
+ `This field is declared ai:forbidden. Declare it ai:readable / ai:actionable, or do not access it.`,
1059
+ { component: componentName, field: fieldName, kind }
1060
+ );
1061
+ }
1062
+
1063
+ /**
1064
+ * Wraps a signal declared `ai:forbidden` so that BOTH reads and writes are
1065
+ * blocked while inside an AI `act()` call (i.e. while _inAIAction === true).
1066
+ *
1067
+ * Normal component, user, and effect code is completely unaffected — the guard
1068
+ * only fires in AI-action context. Together with the contract (which never
1069
+ * exposes forbidden fields through `readable`/`snapshot`/`read`), this makes
1070
+ * `ai:forbidden` a real, enforced boundary rather than a declaration.
1071
+ *
1072
+ * @param {object} sig — original signal created by signal()
1073
+ * @param {string} componentName — component name for error / audit messages
1074
+ * @param {string} fieldName — signal name for error / audit messages
1075
+ */
1076
+ export function _protectedSignal(sig, componentName, fieldName) {
1077
+ return {
1078
+ get: () => { if (_inAIAction) _aiThrowForbidden(componentName, fieldName, 'read'); return sig.get(); },
1079
+ peek: () => { if (_inAIAction) _aiThrowForbidden(componentName, fieldName, 'read'); return sig.peek(); },
1080
+ set: (v) => { if (_inAIAction) _aiThrowForbidden(componentName, fieldName, 'write'); return sig.set(v); },
1081
+ update: (fn) => { if (_inAIAction) _aiThrowForbidden(componentName, fieldName, 'write'); return sig.update(fn); },
1082
+ _type: 'signal',
1083
+ _forbidden: true,
1084
+ _component: componentName,
1085
+ _field: fieldName,
1086
+ };
1087
+ }
1088
+
1089
+ // ─── Shared duck-typing helper ────────────────────────────────────────────────
1090
+ // Avoids `instanceof Node` which is only available in browser environments.
1091
+ // Used by ErrorBoundary and (via hydrate.js) by hydrateRoot.
1092
+ function _isNodeLike(val) {
1093
+ return val !== null && typeof val === 'object' && typeof val.nodeType === 'number';
1094
+ }
1095
+
1096
+ // ─── AI Registry helper (used by hydrate.js) ─────────────────────────────────
1097
+ /** @internal — allows hydrate.js to register adopted elements without circular import */
1098
+ export function _aiRegistryAdd(el) { _aiRegistry.add(el); }
1099
+
1100
+ // ─── Error Boundary ───────────────────────────────────────────────────────────
1101
+ /**
1102
+ * ErrorBoundary — catches errors thrown during child component rendering and
1103
+ * displays a fallback UI instead of crashing the whole application.
1104
+ *
1105
+ * Equivalent to React's class-based componentDidCatch / getDerivedStateFromError,
1106
+ * but implemented as a plain Clarity component function.
1107
+ *
1108
+ * Usage:
1109
+ *
1110
+ * mount(
1111
+ * () => ErrorBoundary({
1112
+ * children: () => MyComponent({ data }),
1113
+ * fallback: (err) => h('div', { class: 'error-box' }, [err.message]),
1114
+ * onError: (err) => console.error('[App] render error:', err),
1115
+ * }),
1116
+ * document.getElementById('app')
1117
+ * );
1118
+ *
1119
+ * In .clarity files (once the compiler supports it):
1120
+ *
1121
+ * <ErrorBoundary fallback={<p>Something went wrong.</p>}>
1122
+ * <MyComponent />
1123
+ * </ErrorBoundary>
1124
+ *
1125
+ * @param {object} options
1126
+ * @param {Function|Node} options.children - Component factory or pre-rendered node
1127
+ * @param {Function|Node} [options.fallback] - Fallback: function(Error)→Node, or static Node
1128
+ * @param {Function} [options.onError] - Called with the Error when one is caught
1129
+ * @returns {Element}
1130
+ */
1131
+ export function ErrorBoundary({ children, fallback, onError } = {}) {
1132
+ const _error = signal(null);
1133
+ const _wrapper = document.createElement('div');
1134
+ _wrapper.setAttribute('data-clarity-boundary', '');
1135
+
1136
+ // ── Helper: clear wrapper children safely (works in browser + test env) ──────
1137
+ function _clearWrapper() {
1138
+ if (typeof _wrapper.replaceChildren === 'function') {
1139
+ _wrapper.replaceChildren(); // Modern browsers
1140
+ } else {
1141
+ _wrapper.innerHTML = '';
1142
+ // For test stubs that don't implement innerHTML setter:
1143
+ if (Array.isArray(_wrapper.children)) _wrapper.children.length = 0;
1144
+ }
1145
+ }
1146
+
1147
+ // ── Helper: render the fallback UI ──────────────────────────────────────────
1148
+ function _showFallback(err) {
1149
+ _clearWrapper();
1150
+ let fallbackNode;
1151
+
1152
+ if (typeof fallback === 'function') {
1153
+ try {
1154
+ fallbackNode = fallback(err);
1155
+ } catch (fallbackErr) {
1156
+ // Fallback itself threw — show a minimal default
1157
+ console.error('[Clarity ErrorBoundary] fallback threw:', fallbackErr);
1158
+ fallbackNode = _defaultErrorUI(err);
1159
+ }
1160
+ } else if (_isNodeLike(fallback)) {
1161
+ fallbackNode = fallback;
1162
+ } else {
1163
+ fallbackNode = _defaultErrorUI(err);
1164
+ }
1165
+
1166
+ if (_isNodeLike(fallbackNode)) {
1167
+ _wrapper.appendChild(fallbackNode);
1168
+ }
1169
+ }
1170
+
1171
+ // ── Helper: render the child content ────────────────────────────────────────
1172
+ function _showContent() {
1173
+ _clearWrapper();
1174
+ try {
1175
+ let content;
1176
+ if (typeof children === 'function') {
1177
+ content = children();
1178
+ } else {
1179
+ content = children;
1180
+ }
1181
+ if (_isNodeLike(content)) {
1182
+ _wrapper.appendChild(content);
1183
+ } else if (content != null) {
1184
+ _wrapper.appendChild(document.createTextNode(String(content)));
1185
+ }
1186
+ } catch (err) {
1187
+ // Report the error
1188
+ if (typeof onError === 'function') {
1189
+ try { onError(err); } catch { /* never let onError crash the boundary */ }
1190
+ }
1191
+ _error.set(err);
1192
+ _showFallback(err);
1193
+ }
1194
+ }
1195
+
1196
+ // ── Initial render ───────────────────────────────────────────────────────────
1197
+ _showContent();
1198
+
1199
+ // ── reset() — allows parent code to retry rendering after fixing the error ──
1200
+ _wrapper.reset = function () {
1201
+ _error.set(null);
1202
+ _showContent();
1203
+ };
1204
+
1205
+ // ── Expose current error (null when healthy) ─────────────────────────────────
1206
+ _wrapper.getError = function () { return _error.peek(); };
1207
+
1208
+ return _wrapper;
1209
+ }
1210
+
1211
+ /**
1212
+ * @internal — default error UI shown when no fallback is provided
1213
+ */
1214
+ function _defaultErrorUI(err) {
1215
+ const box = document.createElement('div');
1216
+ box.setAttribute('role', 'alert');
1217
+ box.setAttribute('data-clarity-default-error', '');
1218
+ box.style.cssText = [
1219
+ 'padding:12px 16px',
1220
+ 'background:#fef2f2',
1221
+ 'border:1.5px solid #fecaca',
1222
+ 'border-radius:6px',
1223
+ 'color:#991b1b',
1224
+ 'font-family:ui-monospace,monospace',
1225
+ 'font-size:13px',
1226
+ 'line-height:1.5',
1227
+ ].join(';');
1228
+
1229
+ const title = document.createElement('strong');
1230
+ title.textContent = '⚠ Component Error';
1231
+ title.style.cssText = 'display:block;margin-bottom:4px';
1232
+
1233
+ const msg = document.createElement('span');
1234
+ msg.textContent = err instanceof Error ? err.message : String(err);
1235
+
1236
+ // Stack trace (only in development)
1237
+ const isDev = typeof process !== 'undefined'
1238
+ ? (process.env?.NODE_ENV !== 'production')
1239
+ : (typeof location !== 'undefined' && location.hostname === 'localhost');
1240
+
1241
+ if (isDev && err instanceof Error && err.stack) {
1242
+ const pre = document.createElement('pre');
1243
+ pre.style.cssText = 'margin-top:8px;font-size:11px;overflow:auto;white-space:pre-wrap;opacity:0.7';
1244
+ pre.textContent = err.stack;
1245
+ box.appendChild(title);
1246
+ box.appendChild(msg);
1247
+ box.appendChild(pre);
1248
+ } else {
1249
+ box.appendChild(title);
1250
+ box.appendChild(msg);
1251
+ }
1252
+
1253
+ return box;
1254
+ }
1255
+
1256
+ // ─── Portal ───────────────────────────────────────────────────────────────────
1257
+ /**
1258
+ * Renders `children` into a DOM node that exists outside the component's
1259
+ * own DOM hierarchy — equivalent to React's `createPortal`.
1260
+ *
1261
+ * Primary use-cases: modals, drawers, tooltips, toasts, dropdowns.
1262
+ *
1263
+ * Usage in .clarity files:
1264
+ *
1265
+ * import { createPortal } from '@ozsarman/clarityjs/runtime';
1266
+ *
1267
+ * component Modal(open: Boolean) {
1268
+ * render {
1269
+ * <when cond={open}>
1270
+ * { createPortal(
1271
+ * <div class="modal-backdrop">...</div>,
1272
+ * document.body
1273
+ * )
1274
+ * }
1275
+ * </when>
1276
+ * }
1277
+ * }
1278
+ *
1279
+ * Or use the <Portal to="body"> JSX directive (compiler sugar):
1280
+ *
1281
+ * <Portal to="body">
1282
+ * <div class="modal">Hello</div>
1283
+ * </Portal>
1284
+ *
1285
+ * Returns an anchor comment node that acts as a placeholder in the original
1286
+ * tree. Cleanup removes the portal children from the target element.
1287
+ *
1288
+ * @param {Node | Node[] | Function} children - Node(s) or factory function
1289
+ * @param {Element | string} [target] - Target element or CSS selector (default: document.body)
1290
+ * @returns {Comment} - Anchor comment in the original tree (for unmount tracking)
1291
+ */
1292
+ export function createPortal(children, target) {
1293
+ // Resolve target
1294
+ const resolveTarget = () => {
1295
+ if (!target) return document.body;
1296
+ if (typeof target === 'string') {
1297
+ return document.querySelector(target) ?? document.body;
1298
+ }
1299
+ return target;
1300
+ };
1301
+
1302
+ const anchor = document.createComment('clarity:portal');
1303
+
1304
+ // Collect portal nodes
1305
+ let portalNodes = [];
1306
+
1307
+ function _mount() {
1308
+ const dest = resolveTarget();
1309
+
1310
+ // Resolve children
1311
+ let nodes = [];
1312
+ if (Array.isArray(children)) {
1313
+ nodes = children.flat();
1314
+ } else if (typeof children === 'function') {
1315
+ const result = children();
1316
+ nodes = Array.isArray(result) ? result.flat() : [result];
1317
+ } else if (children instanceof Node) {
1318
+ nodes = [children];
1319
+ } else if (children != null) {
1320
+ nodes = [document.createTextNode(String(children))];
1321
+ }
1322
+
1323
+ for (const node of nodes) {
1324
+ if (node instanceof Node) {
1325
+ dest.appendChild(node);
1326
+ portalNodes.push(node);
1327
+ }
1328
+ }
1329
+ }
1330
+
1331
+ function _unmount() {
1332
+ for (const node of portalNodes) {
1333
+ node.parentNode?.removeChild(node);
1334
+ }
1335
+ portalNodes = [];
1336
+ }
1337
+
1338
+ // Attach cleanup to the anchor so the parent component's onCleanup fires it
1339
+ anchor.__clarity_cleanup__ = _unmount;
1340
+
1341
+ // Schedule mount after the current render cycle (so the anchor is in the DOM)
1342
+ queueMicrotask(_mount);
1343
+
1344
+ return anchor;
1345
+ }
1346
+
1347
+ // ─── Suspense ─────────────────────────────────────────────────────────────────
1348
+ /**
1349
+ * Suspense boundary — wraps lazy-loaded children and shows a fallback while
1350
+ * they are loading.
1351
+ *
1352
+ * Usage (component function):
1353
+ *
1354
+ * import { Suspense, lazy } from '@ozsarman/clarityjs/runtime';
1355
+ * const HeavyChart = lazy(() => import('./HeavyChart.clarity'));
1356
+ *
1357
+ * render {
1358
+ * <Suspense fallback={ <Spinner /> }>
1359
+ * <HeavyChart data={data} />
1360
+ * </Suspense>
1361
+ * }
1362
+ *
1363
+ * @param {object} options
1364
+ * @param {Function | Node} options.children - Component factory or node
1365
+ * @param {Function | Node} [options.fallback] - Shown while loading
1366
+ * @returns {Element}
1367
+ */
1368
+ export function Suspense({ children, fallback } = {}) {
1369
+ const wrapper = document.createElement('div');
1370
+ wrapper.setAttribute('data-clarity-suspense', '');
1371
+ const _pending = [];
1372
+
1373
+ const ctx = { _pending };
1374
+ _pushSuspense(ctx);
1375
+
1376
+ try {
1377
+ // Render children (lazy() calls inside register their promises)
1378
+ let content;
1379
+ if (typeof children === 'function') {
1380
+ content = children();
1381
+ } else {
1382
+ content = children;
1383
+ }
1384
+
1385
+ if (_isNodeLike(content)) {
1386
+ wrapper.appendChild(content);
1387
+ }
1388
+ } catch (err) {
1389
+ console.error('[Clarity Suspense] child threw during render', err);
1390
+ } finally {
1391
+ _popSuspense();
1392
+ }
1393
+
1394
+ // If any lazy children registered pending promises, show fallback first
1395
+ if (_pending.length > 0) {
1396
+ // Show fallback
1397
+ wrapper.innerHTML = '';
1398
+ let fallbackNode;
1399
+ if (typeof fallback === 'function') {
1400
+ try { fallbackNode = fallback(); } catch {}
1401
+ } else if (_isNodeLike(fallback)) {
1402
+ fallbackNode = fallback;
1403
+ } else if (fallback != null) {
1404
+ fallbackNode = document.createTextNode(String(fallback));
1405
+ }
1406
+
1407
+ if (fallbackNode) wrapper.appendChild(fallbackNode);
1408
+
1409
+ // When all promises resolve, swap in the real content
1410
+ Promise.all(_pending).then(() => {
1411
+ wrapper.innerHTML = '';
1412
+ let content;
1413
+ if (typeof children === 'function') {
1414
+ try { content = children(); } catch {}
1415
+ } else {
1416
+ content = children;
1417
+ }
1418
+ if (_isNodeLike(content)) wrapper.appendChild(content);
1419
+ }).catch(err => {
1420
+ console.error('[Clarity Suspense] lazy load failed', err);
1421
+ });
1422
+ }
1423
+
1424
+ return wrapper;
1425
+ }
1426
+
1427
+ // ─── TypeScript Types ─────────────────────────────────────────────────────────
1428
+ // These are exported so the TypeGenerator can reference them in .d.ts files.
1429
+ // They mirror the shape of signal() and computed() return values.
1430
+
1431
+ /**
1432
+ * @template T
1433
+ * @typedef {{ get(): T, set(v: T): void, update(fn: (v:T)=>T): void, peek(): T }} Signal
1434
+ */
1435
+
1436
+ /**
1437
+ * @template T
1438
+ * @typedef {{ get(): T, peek(): T }} Computed
1439
+ */
1440
+
1441
+ // ─── Dev Tools ───────────────────────────────────────────────────────────────
1442
+ /**
1443
+ * Expose internals to developer tools and AI assistants.
1444
+ * LLM-friendly: structured, inspectable, auditable.
1445
+ */
1446
+ export const __dev__ = {
1447
+ version: '0.0.1',
1448
+ inspect(sig) {
1449
+ if (sig._type === 'signal') {
1450
+ return {
1451
+ type: 'signal',
1452
+ value: sig.peek(),
1453
+ subscriberCount: sig._subscribers.size,
1454
+ };
1455
+ }
1456
+ if (sig._type === 'computed') {
1457
+ return {
1458
+ type: 'computed',
1459
+ value: sig.peek(),
1460
+ subscriberCount: sig._subscribers.size,
1461
+ };
1462
+ }
1463
+ return { type: 'unknown' };
1464
+ },
1465
+ };