nimbus-docs 0.1.6 → 0.1.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/react.js ADDED
@@ -0,0 +1,782 @@
1
+ import { Component, createContext, useCallback, useContext, useEffect, useId, useLayoutEffect, useMemo, useReducer, useRef, useState, useSyncExternalStore } from "react";
2
+ import { clsx } from "clsx";
3
+ import { twMerge } from "tailwind-merge";
4
+ import { jsx, jsxs } from "react/jsx-runtime";
5
+
6
+ //#region src/react/cn.ts
7
+ /**
8
+ * Compose class names with Tailwind conflict resolution. Vendored into
9
+ * lab/diagram so the framework-surface files don't depend on apps/www's
10
+ * `@/lib/cn` alias — keeps the lift to `nimbus-docs/react` verbatim.
11
+ */
12
+ function cn(...inputs) {
13
+ return twMerge(clsx(inputs));
14
+ }
15
+
16
+ //#endregion
17
+ //#region src/react/registry.ts
18
+ var DiagramRegistry = class {
19
+ entries = /* @__PURE__ */ new Map();
20
+ listeners = /* @__PURE__ */ new Set();
21
+ /** Bumped on every membership change — useSyncExternalStore snapshot key. */
22
+ version = 0;
23
+ register(entry) {
24
+ this.entries.set(entry.id, entry);
25
+ this.version++;
26
+ this.notify();
27
+ return () => {
28
+ this.entries.delete(entry.id);
29
+ this.version++;
30
+ this.notify();
31
+ };
32
+ }
33
+ /**
34
+ * Pause everything when at least one diagram is unpaused; resume
35
+ * everything otherwise. A naive per-entry toggle would *invert* mixed
36
+ * states — resuming the diagrams a reader had deliberately paused.
37
+ */
38
+ toggleAll() {
39
+ const anyUnpaused = [...this.entries.values()].some((e) => !e.userPaused());
40
+ for (const entry of this.entries.values()) entry.setPaused(anyUnpaused);
41
+ }
42
+ count = () => this.entries.size;
43
+ getVersion = () => this.version;
44
+ subscribe = (listener) => {
45
+ this.listeners.add(listener);
46
+ return () => {
47
+ this.listeners.delete(listener);
48
+ };
49
+ };
50
+ notify() {
51
+ for (const listener of this.listeners) listener();
52
+ }
53
+ };
54
+ const diagramRegistry = new DiagramRegistry();
55
+
56
+ //#endregion
57
+ //#region src/react/diagram.tsx
58
+ function reducer(state, event) {
59
+ switch (event.type) {
60
+ case "READY": return state.ready ? state : {
61
+ ...state,
62
+ ready: true
63
+ };
64
+ case "VISIBILITY": return state.visible === event.visible ? state : {
65
+ ...state,
66
+ visible: event.visible
67
+ };
68
+ case "TAB_VISIBILITY": return state.tabVisible === event.tabVisible ? state : {
69
+ ...state,
70
+ tabVisible: event.tabVisible
71
+ };
72
+ case "REDUCED_MOTION": return state.reducedMotion === event.reduced ? state : {
73
+ ...state,
74
+ reducedMotion: event.reduced
75
+ };
76
+ case "TOGGLE": return {
77
+ ...state,
78
+ userPaused: !state.userPaused
79
+ };
80
+ case "SET_PAUSED": return state.userPaused === event.paused ? state : {
81
+ ...state,
82
+ userPaused: event.paused
83
+ };
84
+ }
85
+ }
86
+ function derivePhase(state, reducedMotionMode, pauseWhenOffscreen) {
87
+ if (!state.ready) return "idle";
88
+ if (reducedMotionMode === "respect" && state.reducedMotion) return "paused";
89
+ if (pauseWhenOffscreen && !state.visible) return "paused";
90
+ if (!state.tabVisible) return "paused";
91
+ if (state.userPaused) return "paused";
92
+ return "playing";
93
+ }
94
+ const DiagramContext = createContext(null);
95
+ function useDiagram() {
96
+ return useContext(DiagramContext);
97
+ }
98
+ const DEFAULT_CONTEXT = {
99
+ id: "__no_wrapper__",
100
+ phase: "playing",
101
+ playing: true,
102
+ visible: true,
103
+ tabVisible: true,
104
+ reducedMotion: false,
105
+ depth: "working",
106
+ theme: { id: "default" },
107
+ reset: () => {},
108
+ toggle: () => {}
109
+ };
110
+ const WARNED = /* @__PURE__ */ new Set();
111
+ /**
112
+ * Returns the wrapper context, or a default + one-time dev warning when
113
+ * called outside a `<Diagram>`. Used by every hook in nimbus-docs/react
114
+ * so authors can prototype without forgetting the wrapper.
115
+ */
116
+ function useDiagramOrDefault(hookName) {
117
+ const ctx = useContext(DiagramContext);
118
+ if (ctx) return ctx;
119
+ if (typeof process !== "undefined" && process.env.NODE_ENV !== "production" && !WARNED.has(hookName)) {
120
+ WARNED.add(hookName);
121
+ console.warn(`[nimbus-docs/react] ${hookName} called outside a <Diagram> wrapper. Defaults: playing=true, visible=true, reducedMotion=false. Wrap with <Diagram> for off-screen / reduced-motion / tab-hidden gating.`);
122
+ }
123
+ return DEFAULT_CONTEXT;
124
+ }
125
+ const MARGIN_ROOTMARGIN_VH = 2;
126
+ /**
127
+ * Debounce window for READY dispatch — only applied when scroll activity
128
+ * was observed in the last `SCROLL_VELOCITY_WINDOW_MS`. On a settled page
129
+ * (initial load, no scrolling), READY fires immediately.
130
+ *
131
+ * Lab finding: the spec's flat 500ms gate caused a visible delay on
132
+ * initial render. Scroll-aware gating preserves the mass-init protection
133
+ * for fast scrolls while keeping page-load latency at zero.
134
+ */
135
+ const SCROLL_IDLE_MS = 200;
136
+ const SCROLL_VELOCITY_WINDOW_MS = 200;
137
+ /**
138
+ * Catches render errors thrown by the diagram subtree. React 19 still
139
+ * requires class components for error boundaries — no hook equivalent
140
+ * yet. Falls back to a static "Diagram failed" message + a Reset button
141
+ * that both clears local error state AND bumps the outer resetKey so
142
+ * children remount fresh.
143
+ */
144
+ var DiagramErrorBoundary = class extends Component {
145
+ state = { error: null };
146
+ static getDerivedStateFromError(error) {
147
+ return { error };
148
+ }
149
+ componentDidCatch(error, info) {
150
+ if (typeof process !== "undefined" && process.env.NODE_ENV !== "production") console.error("[nimbus-docs/react] Diagram render error:", error, info);
151
+ }
152
+ handleReset = () => {
153
+ this.setState({ error: null });
154
+ this.props.onReset();
155
+ };
156
+ render() {
157
+ if (this.state.error) return /* @__PURE__ */ jsxs("div", {
158
+ role: "alert",
159
+ className: "flex flex-col items-start gap-2 rounded-md border border-red-300 bg-red-50 p-3 text-sm text-red-900 dark:border-red-800 dark:bg-red-950 dark:text-red-200",
160
+ children: [/* @__PURE__ */ jsx("span", {
161
+ className: "font-medium",
162
+ children: "Diagram failed to render."
163
+ }), /* @__PURE__ */ jsx("button", {
164
+ type: "button",
165
+ onClick: this.handleReset,
166
+ className: "rounded border border-red-400 px-2 py-0.5 text-xs font-medium hover:bg-red-100 dark:border-red-700 dark:hover:bg-red-900",
167
+ children: "Reset"
168
+ })]
169
+ });
170
+ return this.props.children;
171
+ }
172
+ };
173
+ /** Inline visually-hidden style — avoids depending on `.sr-only` Tailwind class. */
174
+ const SR_ONLY = {
175
+ position: "absolute",
176
+ width: 1,
177
+ height: 1,
178
+ padding: 0,
179
+ margin: -1,
180
+ overflow: "hidden",
181
+ clip: "rect(0,0,0,0)",
182
+ whiteSpace: "nowrap",
183
+ border: 0
184
+ };
185
+ /** True when the keypress originated from a native interactive element — skip our shortcut to let the element's own behaviour fire. */
186
+ function isInteractiveTarget(t) {
187
+ if (!t || !(t instanceof HTMLElement)) return false;
188
+ const tag = t.tagName;
189
+ if (tag === "BUTTON" || tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return true;
190
+ if (tag === "A" && t.hasAttribute("href")) return true;
191
+ if (t.isContentEditable) return true;
192
+ return false;
193
+ }
194
+ function DiagramRoot(props) {
195
+ const { children, pauseWhenOffscreen = true, reducedMotion: reducedMotionMode = "respect", errorBoundary = true, keyboard = true, label, className } = props;
196
+ const id = useId();
197
+ const rootRef = useRef(null);
198
+ const [resetKey, setResetKey] = useState(0);
199
+ const [state, dispatch] = useReducer(reducer, {
200
+ ready: false,
201
+ visible: false,
202
+ tabVisible: true,
203
+ reducedMotion: false,
204
+ userPaused: false
205
+ });
206
+ useEffect(() => {
207
+ const doc = (rootRef.current?.ownerDocument.defaultView ?? window).document;
208
+ const onChange = () => dispatch({
209
+ type: "TAB_VISIBILITY",
210
+ tabVisible: doc.visibilityState === "visible"
211
+ });
212
+ onChange();
213
+ doc.addEventListener("visibilitychange", onChange);
214
+ return () => doc.removeEventListener("visibilitychange", onChange);
215
+ }, []);
216
+ useEffect(() => {
217
+ const mq = (rootRef.current?.ownerDocument.defaultView ?? window).matchMedia("(prefers-reduced-motion: reduce)");
218
+ const onChange = (e) => dispatch({
219
+ type: "REDUCED_MOTION",
220
+ reduced: e.matches
221
+ });
222
+ onChange(mq);
223
+ mq.addEventListener("change", onChange);
224
+ return () => mq.removeEventListener("change", onChange);
225
+ }, []);
226
+ useEffect(() => {
227
+ const el = rootRef.current;
228
+ if (!el || typeof IntersectionObserver === "undefined") return;
229
+ const win = el.ownerDocument.defaultView ?? window;
230
+ const vh = win.innerHeight || 800;
231
+ let lastScrollAt = 0;
232
+ const onScroll = () => {
233
+ lastScrollAt = Date.now();
234
+ };
235
+ win.addEventListener("scroll", onScroll, { passive: true });
236
+ let scrollIdleTimer = null;
237
+ let pendingReady = false;
238
+ const marginObserver = new IntersectionObserver((entries) => {
239
+ for (const entry of entries) if (entry.isIntersecting) {
240
+ if (Date.now() - lastScrollAt > SCROLL_VELOCITY_WINDOW_MS) {
241
+ dispatch({ type: "READY" });
242
+ continue;
243
+ }
244
+ pendingReady = true;
245
+ if (scrollIdleTimer) clearTimeout(scrollIdleTimer);
246
+ scrollIdleTimer = setTimeout(() => {
247
+ if (pendingReady) {
248
+ dispatch({ type: "READY" });
249
+ pendingReady = false;
250
+ }
251
+ }, SCROLL_IDLE_MS);
252
+ }
253
+ }, { rootMargin: `${MARGIN_ROOTMARGIN_VH * vh}px 0px ${MARGIN_ROOTMARGIN_VH * vh}px 0px` });
254
+ marginObserver.observe(el);
255
+ const directObserver = new IntersectionObserver((entries) => {
256
+ for (const entry of entries) dispatch({
257
+ type: "VISIBILITY",
258
+ visible: entry.isIntersecting
259
+ });
260
+ }, {
261
+ rootMargin: "0px",
262
+ threshold: [0, 1]
263
+ });
264
+ directObserver.observe(el);
265
+ return () => {
266
+ marginObserver.disconnect();
267
+ directObserver.disconnect();
268
+ win.removeEventListener("scroll", onScroll);
269
+ if (scrollIdleTimer) clearTimeout(scrollIdleTimer);
270
+ };
271
+ }, []);
272
+ const toggle = useCallback(() => dispatch({ type: "TOGGLE" }), []);
273
+ const reset = useCallback(() => {
274
+ setResetKey((k) => k + 1);
275
+ dispatch({
276
+ type: "SET_PAUSED",
277
+ paused: false
278
+ });
279
+ }, []);
280
+ const handleKeyDown = useCallback((event) => {
281
+ if (!keyboard) return;
282
+ if (isInteractiveTarget(event.target)) return;
283
+ if (event.key === " " || event.code === "Space") {
284
+ event.preventDefault();
285
+ toggle();
286
+ } else if (event.key === "r" || event.key === "R") {
287
+ event.preventDefault();
288
+ reset();
289
+ }
290
+ }, [
291
+ keyboard,
292
+ toggle,
293
+ reset
294
+ ]);
295
+ const userPausedRef = useRef(state.userPaused);
296
+ userPausedRef.current = state.userPaused;
297
+ useEffect(() => {
298
+ return diagramRegistry.register({
299
+ id,
300
+ setPaused: (paused) => dispatch({
301
+ type: "SET_PAUSED",
302
+ paused
303
+ }),
304
+ userPaused: () => userPausedRef.current
305
+ });
306
+ }, [id]);
307
+ const phase = derivePhase(state, reducedMotionMode, pauseWhenOffscreen);
308
+ const playing = phase === "playing";
309
+ const reducedMotionEffective = reducedMotionMode === "respect" ? state.reducedMotion : false;
310
+ const visibleEffective = pauseWhenOffscreen ? state.visible : true;
311
+ const ctx = useMemo(() => ({
312
+ id,
313
+ phase,
314
+ playing,
315
+ visible: visibleEffective,
316
+ tabVisible: state.tabVisible,
317
+ reducedMotion: reducedMotionEffective,
318
+ depth: "working",
319
+ theme: { id: "default" },
320
+ reset,
321
+ toggle
322
+ }), [
323
+ id,
324
+ phase,
325
+ playing,
326
+ visibleEffective,
327
+ state.tabVisible,
328
+ reducedMotionEffective,
329
+ reset,
330
+ toggle
331
+ ]);
332
+ const liveText = phase === "playing" ? "Diagram playing" : phase === "paused" ? "Diagram paused" : "";
333
+ const renderBody = /* @__PURE__ */ jsx("div", {
334
+ className: "diagram-render",
335
+ children
336
+ }, resetKey);
337
+ return /* @__PURE__ */ jsx(DiagramContext.Provider, {
338
+ value: ctx,
339
+ children: /* @__PURE__ */ jsxs("div", {
340
+ ref: rootRef,
341
+ "data-nb-diagram": true,
342
+ "data-diagram-id": id,
343
+ "data-phase": phase,
344
+ "data-playing": String(playing),
345
+ "data-visible": String(visibleEffective),
346
+ "data-tab-visible": String(state.tabVisible),
347
+ "data-reduced-motion": String(reducedMotionEffective),
348
+ "aria-label": label,
349
+ role: label ? "region" : void 0,
350
+ tabIndex: keyboard ? 0 : void 0,
351
+ onKeyDown: handleKeyDown,
352
+ className: cn("flex flex-col", className),
353
+ children: [/* @__PURE__ */ jsx("span", {
354
+ "aria-live": "polite",
355
+ role: "status",
356
+ style: SR_ONLY,
357
+ children: liveText
358
+ }), errorBoundary ? /* @__PURE__ */ jsx(DiagramErrorBoundary, {
359
+ onReset: reset,
360
+ children: renderBody
361
+ }) : renderBody]
362
+ })
363
+ });
364
+ }
365
+ const Diagram = DiagramRoot;
366
+
367
+ //#endregion
368
+ //#region src/react/hooks/use-phase.ts
369
+ /**
370
+ * Predicate-gated phase walker. Reads `playing` / `visible` / `tabVisible`
371
+ * / `reducedMotion` from the surrounding `<Diagram>` and pauses
372
+ * automatically when any gate fails.
373
+ *
374
+ * Steps may carry a `when(ctx)` predicate that filters them out for a
375
+ * given cycle, and a `data` payload surfaced while the step holds. Mode
376
+ * toggles, branches, alternating loops — anything cycle-dependent —
377
+ * composes via the predicate plus user-supplied `context`.
378
+ *
379
+ * Two run modes:
380
+ * - `autoplay: true` (default) — ambient looping animation. Freezes under
381
+ * reduced motion (`data` reads `null`).
382
+ * - `autoplay: false` — idle until `start()`. Started runs are
383
+ * user-initiated *function*, not decoration: they keep advancing under
384
+ * reduced motion (gate your CSS transition durations card-side), and a
385
+ * `start()` while the page is paused arms as soon as play resumes.
386
+ *
387
+ * The scheduler depends on the current step's *values* (id, hold), not
388
+ * on `steps` / `context` identity — inline literals are safe and won't
389
+ * re-arm the hold timer under frequent re-renders.
390
+ */
391
+ function usePhase({ steps, context, loop = true, autoplay = true }) {
392
+ const ctx = useDiagramOrDefault("usePhase");
393
+ const [cycle, setCycle] = useState(0);
394
+ const [index, setIndex] = useState(0);
395
+ const [running, setRunning] = useState(autoplay);
396
+ const sequence = useMemo(() => {
397
+ const seq = [];
398
+ let prev = "";
399
+ const userContext = context ?? {};
400
+ for (const step of steps) {
401
+ const when = step.when;
402
+ if (!when || when({
403
+ cycle,
404
+ current: prev,
405
+ ...userContext
406
+ })) {
407
+ seq.push(step);
408
+ prev = step.id;
409
+ }
410
+ }
411
+ return seq;
412
+ }, [
413
+ cycle,
414
+ context,
415
+ steps
416
+ ]);
417
+ const currentStep = sequence[index] ?? sequence[sequence.length - 1] ?? null;
418
+ const currentId = running ? currentStep?.id ?? "" : "";
419
+ const currentHold = currentStep?.hold ?? null;
420
+ const advanceRef = useRef(() => {});
421
+ const sequenceRef = useRef(sequence);
422
+ const runningRef = useRef(running);
423
+ useEffect(() => {
424
+ advanceRef.current = () => {
425
+ if (index + 1 >= sequence.length) {
426
+ if (loop) {
427
+ setCycle((c) => c + 1);
428
+ setIndex(0);
429
+ } else if (!autoplay) {
430
+ setCycle((c) => c + 1);
431
+ setIndex(0);
432
+ setRunning(false);
433
+ }
434
+ } else setIndex((i) => i + 1);
435
+ };
436
+ sequenceRef.current = sequence;
437
+ runningRef.current = running;
438
+ });
439
+ const hasSteps = steps.length > 0;
440
+ useEffect(() => {
441
+ if (!running) return;
442
+ if (!ctx.playing) return;
443
+ if (ctx.reducedMotion && autoplay) return;
444
+ if (currentHold === null) {
445
+ if (!hasSteps) return;
446
+ const t = setTimeout(() => advanceRef.current(), 0);
447
+ return () => clearTimeout(t);
448
+ }
449
+ const t = setTimeout(() => advanceRef.current(), currentHold);
450
+ return () => clearTimeout(t);
451
+ }, [
452
+ index,
453
+ cycle,
454
+ running,
455
+ autoplay,
456
+ ctx.playing,
457
+ ctx.reducedMotion,
458
+ currentId,
459
+ currentHold,
460
+ hasSteps
461
+ ]);
462
+ const start = useCallback(() => {
463
+ if (runningRef.current) return;
464
+ setIndex(0);
465
+ setRunning(true);
466
+ }, []);
467
+ const advance = useCallback(() => advanceRef.current(), []);
468
+ const goto = useCallback((id) => {
469
+ const i = sequenceRef.current.findIndex((s) => s.id === id);
470
+ if (i < 0) return;
471
+ setIndex(i);
472
+ setRunning(true);
473
+ }, []);
474
+ const reset = useCallback(() => {
475
+ setCycle(0);
476
+ setIndex(0);
477
+ setRunning(autoplay);
478
+ }, [autoplay]);
479
+ return {
480
+ current: currentId,
481
+ index,
482
+ cycle,
483
+ data: running && currentStep && !(ctx.reducedMotion && autoplay) ? currentStep.data ?? null : null,
484
+ running,
485
+ start,
486
+ advance,
487
+ goto,
488
+ reset
489
+ };
490
+ }
491
+
492
+ //#endregion
493
+ //#region src/react/hooks/use-measure.ts
494
+ /**
495
+ * DOM rect + optional selector callback. The selector receives the
496
+ * observed element and its rect, and returns whatever derived geometry
497
+ * the author needs (NodeRect, EdgeData, etc.).
498
+ *
499
+ * For multi-element measurements (e.g. measuring a container plus 5
500
+ * child nodes), pass the container ref and read the other refs from
501
+ * closure inside the selector. The selector recomputes when the
502
+ * container resizes — which it typically does when children grow
503
+ * because the container's intrinsic size is content-derived. For
504
+ * state-driven re-layouts that the ResizeObserver might miss, pass
505
+ * `options.deps`.
506
+ */
507
+ function useMeasure(ref, selector, options = {}) {
508
+ const { deps = [] } = options;
509
+ const [rect, setRect] = useState(null);
510
+ const [selected, setSelected] = useState(null);
511
+ const selectorRef = useRef(selector);
512
+ selectorRef.current = selector;
513
+ const sampleRef = useRef(() => {});
514
+ const boundElRef = useRef(null);
515
+ const unbindRef = useRef(null);
516
+ const bind = (el) => {
517
+ const sample = (r) => {
518
+ setRect(r);
519
+ const s = selectorRef.current;
520
+ if (s) setSelected(s(el, r));
521
+ };
522
+ sampleRef.current = () => {
523
+ sample(el.getBoundingClientRect());
524
+ };
525
+ let cancelled = false;
526
+ el.ownerDocument.fonts?.ready.then(() => {
527
+ if (!cancelled) sampleRef.current();
528
+ });
529
+ const win = el.ownerDocument.defaultView ?? window;
530
+ if (typeof win.ResizeObserver === "undefined") {
531
+ sampleRef.current();
532
+ return () => {
533
+ cancelled = true;
534
+ };
535
+ }
536
+ const ro = new win.ResizeObserver(() => {
537
+ sample(el.getBoundingClientRect());
538
+ });
539
+ ro.observe(el);
540
+ sampleRef.current();
541
+ return () => {
542
+ cancelled = true;
543
+ ro.disconnect();
544
+ };
545
+ };
546
+ useLayoutEffect(() => {
547
+ const el = ref.current;
548
+ if (el === boundElRef.current) return;
549
+ unbindRef.current?.();
550
+ boundElRef.current = el;
551
+ unbindRef.current = el ? bind(el) : null;
552
+ });
553
+ useLayoutEffect(() => () => {
554
+ unbindRef.current?.();
555
+ unbindRef.current = null;
556
+ boundElRef.current = null;
557
+ }, []);
558
+ useLayoutEffect(() => {
559
+ sampleRef.current();
560
+ }, deps);
561
+ return {
562
+ rect,
563
+ selected
564
+ };
565
+ }
566
+
567
+ //#endregion
568
+ //#region src/react/hooks/use-tab-indicator.ts
569
+ /**
570
+ * Headless sliding-indicator measurement for a tab group.
571
+ *
572
+ * Pure state + lifecycle — no DOM produced, no styling assumed. The
573
+ * consumer renders the indicator however it wants, applying `style`
574
+ * positionally.
575
+ *
576
+ * Re-measures on `activeId` change and on container resize. Returns
577
+ * `null` style when `activeId` is null or its tab ref isn't mounted —
578
+ * consumer renders nothing in that case, which also prevents a
579
+ * "mount-from-zero" transition the first time the indicator appears.
580
+ */
581
+ function useTabIndicator(containerRef, getTab, activeId) {
582
+ const [style, setStyle] = useState(null);
583
+ const measure = useCallback(() => {
584
+ const container = containerRef.current;
585
+ if (!container || activeId == null) {
586
+ setStyle(null);
587
+ return;
588
+ }
589
+ const btn = getTab(activeId);
590
+ if (!btn) {
591
+ setStyle(null);
592
+ return;
593
+ }
594
+ const cRect = container.getBoundingClientRect();
595
+ const bRect = btn.getBoundingClientRect();
596
+ setStyle({
597
+ top: bRect.top - cRect.top,
598
+ left: bRect.left - cRect.left,
599
+ width: bRect.width,
600
+ height: bRect.height
601
+ });
602
+ }, [
603
+ containerRef,
604
+ getTab,
605
+ activeId
606
+ ]);
607
+ useLayoutEffect(() => {
608
+ measure();
609
+ }, [measure]);
610
+ useEffect(() => {
611
+ const el = containerRef.current;
612
+ if (!el || typeof ResizeObserver === "undefined") return;
613
+ const ro = new ResizeObserver(() => measure());
614
+ ro.observe(el);
615
+ return () => ro.disconnect();
616
+ }, [containerRef, measure]);
617
+ return { style };
618
+ }
619
+
620
+ //#endregion
621
+ //#region src/react/hooks/use-ref-setters.ts
622
+ /**
623
+ * Stable per-id ref callbacks for a refs map — the ergonomic half of the
624
+ * "refs map + setter factory" pattern. Instead of N individual `useRef`s
625
+ * and N inline `ref={(el) => …}` arrows (which churn refs every render),
626
+ * keep one map and hand each element a cached callback:
627
+ *
628
+ * ```tsx
629
+ * const nodeRefs = useRef<Partial<Record<NodeId, HTMLDivElement | null>>>({});
630
+ * const setNode = useRefSetters(nodeRefs);
631
+ *
632
+ * <div ref={setNode("prompt")}>Prompt</div>
633
+ * <div ref={setNode("model")}>Model</div>
634
+ * ```
635
+ *
636
+ * Measurement stays caller-side (read `refs.current` inside a `useMeasure`
637
+ * selector) — this hook owns only the ref plumbing.
638
+ */
639
+ function useRefSetters(refs) {
640
+ const cache = useRef(/* @__PURE__ */ new Map());
641
+ return (id) => {
642
+ let cb = cache.current.get(id);
643
+ if (!cb) {
644
+ cb = (el) => {
645
+ refs.current[id] = el;
646
+ };
647
+ cache.current.set(id, cb);
648
+ }
649
+ return cb;
650
+ };
651
+ }
652
+
653
+ //#endregion
654
+ //#region src/react/hooks/use-media-query.ts
655
+ /**
656
+ * Reactive `window.matchMedia` subscription, SSR-safe.
657
+ *
658
+ * The server snapshot returns `defaultValue` (default `false`), so static
659
+ * builds render the no-match branch and hydrate without mismatch warnings;
660
+ * the first client render corrects it if the query matches.
661
+ *
662
+ * ```ts
663
+ * const isMobile = useMediaQuery("(max-width: 640px)");
664
+ * ```
665
+ */
666
+ function useMediaQuery(query, defaultValue = false) {
667
+ return useSyncExternalStore(useCallback((onChange) => {
668
+ const mq = window.matchMedia(query);
669
+ mq.addEventListener("change", onChange);
670
+ return () => mq.removeEventListener("change", onChange);
671
+ }, [query]), () => window.matchMedia(query).matches, () => defaultValue);
672
+ }
673
+
674
+ //#endregion
675
+ //#region src/react/geometry.ts
676
+ /** Anchor point on a rect edge. `frac` runs 0→1 from top/left. */
677
+ function edgePoint(rect, side, frac = .5) {
678
+ if (side === "top") return {
679
+ x: rect.l + rect.w * frac,
680
+ y: rect.t
681
+ };
682
+ if (side === "bottom") return {
683
+ x: rect.l + rect.w * frac,
684
+ y: rect.t + rect.h
685
+ };
686
+ if (side === "left") return {
687
+ x: rect.l,
688
+ y: rect.t + rect.h * frac
689
+ };
690
+ return {
691
+ x: rect.l + rect.w,
692
+ y: rect.t + rect.h * frac
693
+ };
694
+ }
695
+ function straightPath(from, to, arrowOffset) {
696
+ const dx = Math.abs(to.x - from.x);
697
+ if (Math.abs(to.y - from.y) < 2) {
698
+ const dir = to.x > from.x ? 1 : -1;
699
+ return `M ${from.x},${from.y} L ${to.x - dir * arrowOffset},${to.y}`;
700
+ }
701
+ if (dx < 2) {
702
+ const dir = to.y > from.y ? 1 : -1;
703
+ return `M ${from.x},${from.y} L ${to.x},${to.y - dir * arrowOffset}`;
704
+ }
705
+ return `M ${from.x},${from.y} L ${to.x},${to.y}`;
706
+ }
707
+ function elbowPath(from, to, r, arrowOffset) {
708
+ const dx = to.x - from.x;
709
+ const dy = to.y - from.y;
710
+ const corner = Math.abs(dx) > Math.abs(dy) ? {
711
+ x: to.x,
712
+ y: from.y
713
+ } : {
714
+ x: from.x,
715
+ y: to.y
716
+ };
717
+ const dx1 = Math.sign(corner.x - from.x);
718
+ const dy1 = Math.sign(corner.y - from.y);
719
+ const dx2 = Math.sign(to.x - corner.x);
720
+ const dy2 = Math.sign(to.y - corner.y);
721
+ const seg1Len = Math.abs(corner.x - from.x) + Math.abs(corner.y - from.y);
722
+ const seg2Len = Math.abs(to.x - corner.x) + Math.abs(to.y - corner.y);
723
+ const rr = Math.max(2, Math.min(r, seg1Len / 2, seg2Len / 2));
724
+ const preX = corner.x - dx1 * rr;
725
+ const preY = corner.y - dy1 * rr;
726
+ const postX = corner.x + dx2 * rr;
727
+ const postY = corner.y + dy2 * rr;
728
+ const endX = to.x - dx2 * arrowOffset;
729
+ const endY = to.y - dy2 * arrowOffset;
730
+ return `M ${from.x},${from.y} L ${preX},${preY} Q ${corner.x},${corner.y} ${postX},${postY} L ${endX},${endY}`;
731
+ }
732
+ function vSplitPath(from, to, r) {
733
+ if (Math.abs(from.x - to.x) < 1) return `M ${from.x},${from.y} L ${to.x},${to.y}`;
734
+ const midY = (from.y + to.y) / 2;
735
+ const sx = Math.sign(to.x - from.x);
736
+ const sy1 = Math.sign(midY - from.y);
737
+ const sy2 = Math.sign(to.y - midY);
738
+ const rr = Math.min(r, Math.abs(midY - from.y), Math.abs(to.x - from.x) / 2, Math.abs(to.y - midY));
739
+ return [
740
+ `M ${from.x},${from.y}`,
741
+ `L ${from.x},${midY - rr * sy1}`,
742
+ `Q ${from.x},${midY} ${from.x + rr * sx},${midY}`,
743
+ `L ${to.x - rr * sx},${midY}`,
744
+ `Q ${to.x},${midY} ${to.x},${midY + rr * sy2}`,
745
+ `L ${to.x},${to.y}`
746
+ ].join(" ");
747
+ }
748
+ /** Build an SVG path string between two points using the given route. */
749
+ function routeEdge(from, to, route = "auto", options = {}) {
750
+ const arrowOffset = options.arrowOffset ?? 6;
751
+ if (route === "vSplit") return vSplitPath(from, to, options.radius ?? 6);
752
+ if (route === "straight") return straightPath(from, to, arrowOffset);
753
+ if (route === "elbow") return elbowPath(from, to, options.radius ?? 10, arrowOffset);
754
+ return Math.abs(from.x - to.x) < 2 || Math.abs(from.y - to.y) < 2 ? straightPath(from, to, arrowOffset) : elbowPath(from, to, options.radius ?? 10, arrowOffset);
755
+ }
756
+ /**
757
+ * Resolve declarative edge specs against a map of measured rects.
758
+ * Specs whose endpoints aren't in `rects` yet (unmounted nodes,
759
+ * first-paint gaps) are skipped rather than rendered degenerate.
760
+ */
761
+ function resolveEdges(specs, rects, options = {}) {
762
+ const out = [];
763
+ for (const spec of specs) {
764
+ const fromRect = rects[spec.from[0]];
765
+ const toRect = rects[spec.to[0]];
766
+ if (!fromRect || !toRect) continue;
767
+ const from = edgePoint(fromRect, spec.from[1], spec.from[2] ?? .5);
768
+ const to = edgePoint(toRect, spec.to[1], spec.to[2] ?? .5);
769
+ out.push({
770
+ id: spec.id,
771
+ from,
772
+ to,
773
+ d: routeEdge(from, to, spec.route ?? "auto", options),
774
+ ghost: spec.ghost
775
+ });
776
+ }
777
+ return out;
778
+ }
779
+
780
+ //#endregion
781
+ export { Diagram, cn, diagramRegistry, edgePoint, resolveEdges, routeEdge, useDiagram, useDiagramOrDefault, useMeasure, useMediaQuery, usePhase, useRefSetters, useTabIndicator };
782
+ //# sourceMappingURL=react.js.map