@vvedantb/ui 0.17.0 → 0.18.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.
Files changed (130) hide show
  1. package/README.md +253 -3
  2. package/package.json +13 -5
  3. package/shell/src/SidebarResize.tsx +1 -4
  4. package/src/ai-elements/__snapshots__/queue.test.tsx.snap +11 -0
  5. package/src/ai-elements/activity-question-utils.ts +26 -0
  6. package/src/ai-elements/activity-question.tsx +159 -0
  7. package/src/ai-elements/activity-shared.tsx +459 -0
  8. package/src/ai-elements/activity-step-detail.tsx +115 -0
  9. package/src/ai-elements/activity-step-label.ts +981 -0
  10. package/src/ai-elements/activity-tasks-utils.ts +199 -0
  11. package/src/ai-elements/activity-tasks.tsx +770 -0
  12. package/src/ai-elements/artifact.tsx +162 -52
  13. package/src/ai-elements/attachments.tsx +62 -32
  14. package/src/ai-elements/code-block.tsx +76 -22
  15. package/src/ai-elements/context.tsx +370 -0
  16. package/src/ai-elements/conversation.tsx +186 -62
  17. package/src/ai-elements/file-chip.tsx +53 -0
  18. package/src/ai-elements/message.tsx +161 -50
  19. package/src/ai-elements/model-picker-content.tsx +505 -0
  20. package/src/ai-elements/model-picker-types.ts +56 -0
  21. package/src/ai-elements/model-picker.tsx +301 -0
  22. package/src/ai-elements/model-selector.tsx +102 -21
  23. package/src/ai-elements/plan.tsx +130 -0
  24. package/src/ai-elements/prompt-input-settings.tsx +144 -0
  25. package/src/ai-elements/prompt-input-speech.tsx +189 -0
  26. package/src/ai-elements/prompt-input-traits.tsx +281 -0
  27. package/src/ai-elements/prompt-input.tsx +320 -134
  28. package/src/ai-elements/provider-icon.tsx +133 -0
  29. package/src/ai-elements/queue.tsx +293 -9
  30. package/src/ai-elements/shimmer.tsx +72 -1
  31. package/src/ai-elements/simple-model-ladder.tsx +108 -0
  32. package/src/ai-elements/test-results.tsx +510 -0
  33. package/src/ai-elements/trace.tsx +243 -0
  34. package/src/ai-elements/use-streaming-disclosure.ts +50 -0
  35. package/src/ai-elements/web-preview.tsx +349 -0
  36. package/src/composer/input-area/ComposerSlashMenu.tsx +1 -1
  37. package/src/index.ts +66 -12
  38. package/src/kibo/avatar-stack.tsx +40 -0
  39. package/src/kibo/contribution-graph.tsx +539 -0
  40. package/src/kibo/cursor.tsx +72 -0
  41. package/src/kibo/gantt-features.tsx +439 -0
  42. package/src/kibo/gantt-header.tsx +181 -0
  43. package/src/kibo/gantt-provider.tsx +657 -0
  44. package/src/kibo/gantt-sidebar.tsx +157 -0
  45. package/src/kibo/gantt-timeline.tsx +154 -0
  46. package/src/kibo/index.ts +151 -0
  47. package/src/kibo/kanban.tsx +338 -0
  48. package/src/kibo/kanbanDropTarget.ts +25 -0
  49. package/src/kibo/list.tsx +162 -0
  50. package/src/kibo/theme-switcher.tsx +109 -0
  51. package/src/kibo/video-player.tsx +237 -0
  52. package/src/markdown/Markdown.tsx +112 -0
  53. package/src/markdown/MarkdownBlockquote.tsx +45 -0
  54. package/src/markdown/MarkdownCodeBlock.tsx +197 -0
  55. package/src/markdown/MarkdownInlineCode.tsx +40 -0
  56. package/src/markdown/MarkdownLink.tsx +92 -0
  57. package/src/markdown/codeFence.ts +57 -0
  58. package/src/markdown/codeHighlight.ts +138 -0
  59. package/src/markdown/incrementalBlocks.ts +182 -0
  60. package/src/markdown/index.ts +3 -0
  61. package/src/markdown/markdownClipboard.ts +178 -0
  62. package/src/markdown/mermaid.ts +155 -0
  63. package/src/markdown/rehypeGithubAlerts.ts +80 -0
  64. package/src/motion/presets.ts +24 -0
  65. package/src/ui/__snapshots__/alt-wrappers.test.tsx.snap +19 -0
  66. package/src/ui/__snapshots__/sonner-dom.test.tsx.snap +5 -0
  67. package/src/ui/_menu-parts.tsx +29 -0
  68. package/src/ui/accordion.alt.tsx +46 -53
  69. package/src/ui/accordion.tsx +59 -10
  70. package/src/ui/avatar.tsx +56 -0
  71. package/src/ui/badge.tsx +45 -21
  72. package/src/ui/border-beam.tsx +97 -0
  73. package/src/ui/button-group.tsx +50 -21
  74. package/src/ui/button.tsx +67 -45
  75. package/src/ui/calendar.tsx +48 -5
  76. package/src/ui/card.tsx +77 -8
  77. package/src/ui/carousel.tsx +60 -0
  78. package/src/ui/centered-spinner.tsx +30 -0
  79. package/src/ui/checkbox.tsx +34 -16
  80. package/src/ui/clear-input.alt.tsx +33 -75
  81. package/src/ui/clear-input.tsx +140 -15
  82. package/src/ui/collapsible.tsx +40 -2
  83. package/src/ui/command.tsx +143 -70
  84. package/src/ui/context-menu.tsx +159 -93
  85. package/src/ui/crossfade-icon.tsx +123 -0
  86. package/src/ui/dialog.tsx +154 -70
  87. package/src/ui/dropdown-menu.tsx +177 -117
  88. package/src/ui/hover-card.tsx +59 -17
  89. package/src/ui/input-group.alt.tsx +83 -83
  90. package/src/ui/input-group.tsx +129 -58
  91. package/src/ui/input.tsx +13 -4
  92. package/src/ui/label.tsx +11 -8
  93. package/src/ui/list-row.tsx +232 -0
  94. package/src/ui/loading-state.tsx +215 -0
  95. package/src/ui/popover.tsx +56 -23
  96. package/src/ui/progress.tsx +28 -16
  97. package/src/ui/refresh-spin-icon.tsx +41 -0
  98. package/src/ui/search-input.tsx +66 -15
  99. package/src/ui/select.tsx +171 -100
  100. package/src/ui/separator.tsx +23 -14
  101. package/src/ui/slider.tsx +21 -7
  102. package/src/ui/sonner.tsx +68 -30
  103. package/src/ui/spinner.tsx +229 -11
  104. package/src/ui/status-dot.tsx +84 -0
  105. package/src/ui/surface.tsx +49 -0
  106. package/src/ui/switch.tsx +29 -15
  107. package/src/ui/table.tsx +38 -16
  108. package/src/ui/tabs.tsx +123 -35
  109. package/src/ui/tabsSliding.ts +33 -1
  110. package/src/ui/textarea.tsx +13 -4
  111. package/src/ui/tooltip.tsx +50 -14
  112. package/src/ui/ui-config.tsx +149 -0
  113. package/src/utils/cat-color.ts +123 -0
  114. package/src/utils/dayjs.ts +6 -0
  115. package/src/utils/gesture.ts +59 -0
  116. package/src/utils/runtimeVisibility.ts +167 -0
  117. package/src/utils/sharedClock.ts +96 -0
  118. package/src/utils/surface-radius.ts +16 -0
  119. package/src/utils/useDragSensors.ts +58 -0
  120. package/tokens/examples/tokens-moved/Fixture.tsx +283 -0
  121. package/tokens/examples/tokens-moved/README.md +20 -0
  122. package/tokens/examples/tokens-moved/index.html +12 -0
  123. package/tokens/examples/tokens-moved/main.tsx +6 -0
  124. package/tokens/examples/tokens-moved/package.json +14 -0
  125. package/tokens/examples/tokens-moved/styles.css +12 -0
  126. package/tokens/examples/tokens-moved/tsconfig.json +14 -0
  127. package/tokens/examples/tokens-moved/vite.config.ts +11 -0
  128. package/tokens/src/css/border-beam.css +116 -0
  129. package/tokens/src/css/compat-shadcn-rgb.css +6 -0
  130. package/tokens/src/css/markdown.css +318 -0
@@ -0,0 +1,123 @@
1
+ /**
2
+ * Muted categorical ramp for hash-assigned identity colour — session tab
3
+ * groups, repo tiles, avatars. Not for status; status uses `status-*`.
4
+ *
5
+ * The ramp is eight CSS vars (`--cat-1` … `--cat-8`, see globals.css),
6
+ * exposed as the Tailwind `cat` colour family. This module owns the
7
+ * id → slot hashing once, so every consumer picks the same slot for the
8
+ * same id and the class names stay literal (Tailwind's extractor cannot
9
+ * see `` `bg-cat-${n}` ``, only the strings written out below).
10
+ */
11
+
12
+ /** Number of slots in the muted categorical ramp (--cat-1 … --cat-8). */
13
+ export const CAT_SLOT_COUNT = 8;
14
+
15
+ export type CatSlot = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8;
16
+
17
+ export interface CatColorClasses {
18
+ /** Solid foreground colour — text, icons, and `currentColor` accents. */
19
+ text: string;
20
+ /** Solid fill — underline strips, dots, anything with no text on top. */
21
+ bg: string;
22
+ /** Tinted fill, sized to sit behind `text` at readable contrast in both themes. */
23
+ bgTint: string;
24
+ /** Solid border stroke. */
25
+ border: string;
26
+ }
27
+
28
+ // One literal record per slot so Tailwind's class extractor sees every
29
+ // `cat-N` utility it needs to generate — a template string would hide them.
30
+ const CAT_COLOR_CLASSES: Record<CatSlot, CatColorClasses> = {
31
+ 1: {
32
+ text: "text-cat-1",
33
+ bg: "bg-cat-1",
34
+ bgTint: "bg-cat-1/12",
35
+ border: "border-cat-1",
36
+ },
37
+ 2: {
38
+ text: "text-cat-2",
39
+ bg: "bg-cat-2",
40
+ bgTint: "bg-cat-2/12",
41
+ border: "border-cat-2",
42
+ },
43
+ 3: {
44
+ text: "text-cat-3",
45
+ bg: "bg-cat-3",
46
+ bgTint: "bg-cat-3/12",
47
+ border: "border-cat-3",
48
+ },
49
+ 4: {
50
+ text: "text-cat-4",
51
+ bg: "bg-cat-4",
52
+ bgTint: "bg-cat-4/12",
53
+ border: "border-cat-4",
54
+ },
55
+ 5: {
56
+ text: "text-cat-5",
57
+ bg: "bg-cat-5",
58
+ bgTint: "bg-cat-5/12",
59
+ border: "border-cat-5",
60
+ },
61
+ 6: {
62
+ text: "text-cat-6",
63
+ bg: "bg-cat-6",
64
+ bgTint: "bg-cat-6/12",
65
+ border: "border-cat-6",
66
+ },
67
+ 7: {
68
+ text: "text-cat-7",
69
+ bg: "bg-cat-7",
70
+ bgTint: "bg-cat-7/12",
71
+ border: "border-cat-7",
72
+ },
73
+ 8: {
74
+ text: "text-cat-8",
75
+ bg: "bg-cat-8",
76
+ bgTint: "bg-cat-8/12",
77
+ border: "border-cat-8",
78
+ },
79
+ };
80
+
81
+ // Maps the hash's 0–7 bucket onto the 1–8 slot type without a cast — indexing
82
+ // a `Record<number, CatSlot>` by the modulo result stays inside the type
83
+ // system, unlike `(index + 1) as CatSlot`.
84
+ const SLOT_BY_INDEX: Record<number, CatSlot> = {
85
+ 0: 1,
86
+ 1: 2,
87
+ 2: 3,
88
+ 3: 4,
89
+ 4: 5,
90
+ 5: 6,
91
+ 6: 7,
92
+ 7: 8,
93
+ };
94
+
95
+ function slotFromIndex(index: number): CatSlot {
96
+ return SLOT_BY_INDEX[index] ?? 1;
97
+ }
98
+
99
+ /**
100
+ * Stable, order-independent slot for an arbitrary id.
101
+ *
102
+ * Same rolling hash the old tab-group palette used (`hash * 31 + charCode`),
103
+ * reused here rather than invented fresh so ids that landed in the same
104
+ * bucket before still land in the same bucket now.
105
+ */
106
+ export function catSlotForId(id: string): CatSlot {
107
+ let hash = 0;
108
+ for (let i = 0; i < id.length; i++) {
109
+ hash = (hash * 31 + id.charCodeAt(i)) | 0;
110
+ }
111
+ const index = Math.abs(hash) % CAT_SLOT_COUNT;
112
+ return slotFromIndex(index);
113
+ }
114
+
115
+ /** Class set for a known slot — for consumers that need to remap a legacy bucket onto a slot. */
116
+ export function catColorForSlot(slot: CatSlot): CatColorClasses {
117
+ return CAT_COLOR_CLASSES[slot];
118
+ }
119
+
120
+ /** Pre-composed class set for an arbitrary id. */
121
+ export function catColorForId(id: string): CatColorClasses {
122
+ return catColorForSlot(catSlotForId(id));
123
+ }
@@ -0,0 +1,6 @@
1
+ import dayjs from "dayjs";
2
+ import relativeTime from "dayjs/plugin/relativeTime";
3
+
4
+ dayjs.extend(relativeTime);
5
+
6
+ export default dayjs;
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Gesture physics from Apple's *Designing Fluid Interfaces* (WWDC 2018).
3
+ *
4
+ * These two functions are the whole of it, and both were previously copied
5
+ * verbatim into each surface that needed them. They are pure maths with no
6
+ * React or DOM dependency — import them, do not re-derive them.
7
+ */
8
+
9
+ /**
10
+ * Progressive resistance past a boundary. A hard stop reads as frozen; damping
11
+ * that grows with the overshoot reads as "responsive, but there is nothing more
12
+ * here". Returns the *damped* overshoot, so callers add it back to the bound:
13
+ *
14
+ * ```ts
15
+ * width < MIN ? MIN - rubberband(MIN - width, MIN) : width
16
+ * ```
17
+ *
18
+ * @param overshoot How far past the bound the pointer is, always positive.
19
+ * @param dimension The span the resistance is measured against — normally the
20
+ * bound itself, or the container size for a percentage-based drag.
21
+ */
22
+ export function rubberband(
23
+ overshoot: number,
24
+ dimension: number,
25
+ constant = 0.55,
26
+ ): number {
27
+ return (
28
+ (overshoot * dimension * constant) /
29
+ (dimension + constant * Math.abs(overshoot))
30
+ );
31
+ }
32
+
33
+ /**
34
+ * Where a flick would come to rest, so a gesture can be animated to where it
35
+ * was *going* rather than to the nearest snap point from where it was released.
36
+ * Add the result to the current position, then pick the snap target nearest
37
+ * that projection.
38
+ *
39
+ * This is the exponential-decay form Apple ships, not the textbook
40
+ * `v² / (2·decel)` — they are not the same curve.
41
+ *
42
+ * @param initialVelocity Release velocity in px/s.
43
+ * @param decelerationRate 0.998 matches normal scroll feel; 0.99 is snappier.
44
+ */
45
+ export function projectVelocity(
46
+ initialVelocity: number,
47
+ decelerationRate = 0.998,
48
+ ): number {
49
+ return (
50
+ ((initialVelocity / 1000) * decelerationRate) / (1 - decelerationRate)
51
+ );
52
+ }
53
+
54
+ /**
55
+ * Movement in px before a drag commits to a direction. Arming on the first
56
+ * pixel turns sloppy clicks into drags; a hold-to-arm delay makes a fast,
57
+ * decisive drag fail outright. Distance-based activation avoids both.
58
+ */
59
+ export const DRAG_ACTIVATION_DISTANCE_PX = 8;
@@ -0,0 +1,167 @@
1
+ "use client";
2
+
3
+ import { useCallback, useEffect, useRef } from "react";
4
+
5
+ /**
6
+ * Page-visibility and on-screen budget for infinite animations.
7
+ *
8
+ * CSS animations pause via `html[data-page-hidden]` / `[data-anim-offscreen]`
9
+ * / parked `[aria-hidden="true"]` hosts in the host stylesheet. WAAPI
10
+ * (Spinner, LoadingState) has no CSS play-state hook, so
11
+ * {@link bindRuntimeAnimation} pauses those directly.
12
+ *
13
+ * Decorative animation chrome (`data-anim-chrome` on BorderBeam's overlay,
14
+ * Shimmer's copy, LoadingState's grid) is itself `aria-hidden` so AT skips
15
+ * it. That must not count as "the user cannot see this" — #764 paused every
16
+ * composer beam and session-row Drive grid by matching those hosts.
17
+ *
18
+ * Pausing is visually identical while the user is looking: playback resumes
19
+ * from the same offset when the tab or element is shown again.
20
+ */
21
+
22
+ /** Parked subtree (cached session shell, minimized Ave). Skips decorative chrome. */
23
+ const ARIA_HIDDEN_SUBTREE = '[aria-hidden="true"]:not([data-anim-chrome])';
24
+
25
+ type IntersectionListener = (intersecting: boolean) => void;
26
+
27
+ const intersectionListeners = new WeakMap<Element, Set<IntersectionListener>>();
28
+ let sharedObserver: IntersectionObserver | null = null;
29
+ let pageVisibilityStarted = false;
30
+ let ariaHiddenWatchStarted = false;
31
+ const animationSyncs = new Set<() => void>();
32
+
33
+ function pageIsHidden(): boolean {
34
+ return (
35
+ typeof document !== "undefined" && document.visibilityState === "hidden"
36
+ );
37
+ }
38
+
39
+ function elementOrAncestorHidden(el: Element): boolean {
40
+ return el.closest(ARIA_HIDDEN_SUBTREE) !== null;
41
+ }
42
+
43
+ function flushAnimationSyncs(): void {
44
+ for (const sync of animationSyncs) sync();
45
+ }
46
+
47
+ function ensureAriaHiddenWatch(): void {
48
+ if (ariaHiddenWatchStarted || typeof MutationObserver === "undefined") return;
49
+ if (typeof document === "undefined") return;
50
+ ariaHiddenWatchStarted = true;
51
+ const observer = new MutationObserver(flushAnimationSyncs);
52
+ observer.observe(document.documentElement, {
53
+ subtree: true,
54
+ attributes: true,
55
+ attributeFilter: ["aria-hidden"],
56
+ });
57
+ }
58
+
59
+ function getSharedObserver(): IntersectionObserver | null {
60
+ if (typeof IntersectionObserver === "undefined") return null;
61
+ if (sharedObserver) return sharedObserver;
62
+ sharedObserver = new IntersectionObserver(
63
+ (entries) => {
64
+ for (const entry of entries) {
65
+ if (entry.isIntersecting) {
66
+ entry.target.removeAttribute("data-anim-offscreen");
67
+ } else {
68
+ entry.target.setAttribute("data-anim-offscreen", "");
69
+ }
70
+ const listeners = intersectionListeners.get(entry.target);
71
+ if (!listeners) continue;
72
+ for (const listener of listeners) listener(entry.isIntersecting);
73
+ }
74
+ },
75
+ // Keep a small lead so a scroll into view does not show a frozen frame.
76
+ { rootMargin: "64px", threshold: 0 },
77
+ );
78
+ return sharedObserver;
79
+ }
80
+
81
+ /** Sets `html[data-page-hidden]` so CSS can pause compositor animations. */
82
+ export function ensureRuntimeVisibility(): void {
83
+ if (pageVisibilityStarted || typeof document === "undefined") return;
84
+ pageVisibilityStarted = true;
85
+ const sync = () => {
86
+ document.documentElement.toggleAttribute("data-page-hidden", pageIsHidden());
87
+ };
88
+ sync();
89
+ document.addEventListener("visibilitychange", () => {
90
+ sync();
91
+ flushAnimationSyncs();
92
+ });
93
+ }
94
+
95
+ /**
96
+ * Marks `el` with `data-anim-offscreen` when it leaves the viewport and
97
+ * optionally notifies. Shared observer — many cells/spinners cost one IO.
98
+ */
99
+ export function observeAnimVisibility(
100
+ el: Element,
101
+ onChange?: IntersectionListener,
102
+ ): () => void {
103
+ ensureRuntimeVisibility();
104
+ const observer = getSharedObserver();
105
+ if (!observer) return () => {};
106
+ let listeners = intersectionListeners.get(el);
107
+ if (!listeners) {
108
+ listeners = new Set();
109
+ intersectionListeners.set(el, listeners);
110
+ observer.observe(el);
111
+ }
112
+ if (onChange) listeners.add(onChange);
113
+ return () => {
114
+ if (onChange) listeners?.delete(onChange);
115
+ if (listeners && listeners.size === 0) {
116
+ observer.unobserve(el);
117
+ intersectionListeners.delete(el);
118
+ el.removeAttribute("data-anim-offscreen");
119
+ }
120
+ };
121
+ }
122
+
123
+ /** Callback ref that applies {@link observeAnimVisibility} to the node. */
124
+ export function useAnimOffscreenRef<T extends Element>(): (
125
+ node: T | null,
126
+ ) => void {
127
+ const cleanupRef = useRef<(() => void) | null>(null);
128
+ const setRef = useCallback((node: T | null) => {
129
+ cleanupRef.current?.();
130
+ cleanupRef.current = node ? observeAnimVisibility(node) : null;
131
+ }, []);
132
+ useEffect(() => () => cleanupRef.current?.(), []);
133
+ return setRef;
134
+ }
135
+
136
+ /**
137
+ * Play a WAAPI loop only while the page is visible and `el` is on screen.
138
+ * Returns a detach cleanup that also cancels the animation.
139
+ */
140
+ export function bindRuntimeAnimation(
141
+ el: Element,
142
+ animation: Animation,
143
+ ): () => void {
144
+ ensureRuntimeVisibility();
145
+ let intersecting = true;
146
+ const sync = () => {
147
+ const hidden =
148
+ pageIsHidden() || !intersecting || elementOrAncestorHidden(el);
149
+ if (hidden) {
150
+ if (animation.playState === "running") animation.pause();
151
+ return;
152
+ }
153
+ if (animation.playState === "paused") animation.play();
154
+ };
155
+ const unobserve = observeAnimVisibility(el, (next) => {
156
+ intersecting = next;
157
+ sync();
158
+ });
159
+ ensureAriaHiddenWatch();
160
+ animationSyncs.add(sync);
161
+ sync();
162
+ return () => {
163
+ animationSyncs.delete(sync);
164
+ unobserve();
165
+ animation.cancel();
166
+ };
167
+ }
@@ -0,0 +1,96 @@
1
+ /**
2
+ * One timeout chain per interval, shared by every subscriber.
3
+ *
4
+ * Sidebar rows used to each own a `useQuantizedNow` timer; elapsed labels
5
+ * each owned a 1s interval. The snapshot is still `floor(now / interval)`,
6
+ * so the displayed value is unchanged — only the wake-ups collapse.
7
+ *
8
+ * The chain pauses while `document.hidden`: nothing on screen needs a tick,
9
+ * and becoming visible notifies immediately so labels catch up in one paint.
10
+ */
11
+
12
+ export type QuantizedStore = {
13
+ listeners: Set<() => void>;
14
+ timer: ReturnType<typeof setTimeout> | undefined;
15
+ visHandler: (() => void) | undefined;
16
+ };
17
+
18
+ const stores = new Map<number, QuantizedStore>();
19
+
20
+ export function quantizedSnapshot(intervalMs: number, now = Date.now()): number {
21
+ return Math.floor(now / intervalMs) * intervalMs;
22
+ }
23
+
24
+ export function msUntilNextBoundary(
25
+ intervalMs: number,
26
+ now = Date.now(),
27
+ ): number {
28
+ const remainder = now % intervalMs;
29
+ return remainder === 0 ? intervalMs : intervalMs - remainder;
30
+ }
31
+
32
+ function pageHidden(): boolean {
33
+ return (
34
+ typeof document !== "undefined" && document.visibilityState === "hidden"
35
+ );
36
+ }
37
+
38
+ function disarmTimer(store: QuantizedStore): void {
39
+ if (store.timer === undefined) return;
40
+ clearTimeout(store.timer);
41
+ store.timer = undefined;
42
+ }
43
+
44
+ function arm(store: QuantizedStore, intervalMs: number): void {
45
+ disarmTimer(store);
46
+ if (pageHidden() || store.listeners.size === 0) return;
47
+ store.timer = setTimeout(() => {
48
+ for (const listener of store.listeners) listener();
49
+ arm(store, intervalMs);
50
+ }, msUntilNextBoundary(intervalMs));
51
+ }
52
+
53
+ export function subscribeQuantized(
54
+ intervalMs: number,
55
+ onChange: () => void,
56
+ ): () => void {
57
+ let store = stores.get(intervalMs);
58
+ if (!store) {
59
+ store = { listeners: new Set(), timer: undefined, visHandler: undefined };
60
+ stores.set(intervalMs, store);
61
+ }
62
+ store.listeners.add(onChange);
63
+ if (store.listeners.size === 1) {
64
+ arm(store, intervalMs);
65
+ if (typeof document !== "undefined") {
66
+ store.visHandler = () => {
67
+ if (pageHidden()) {
68
+ disarmTimer(store);
69
+ return;
70
+ }
71
+ for (const listener of store.listeners) listener();
72
+ arm(store, intervalMs);
73
+ };
74
+ document.addEventListener("visibilitychange", store.visHandler);
75
+ }
76
+ }
77
+ return () => {
78
+ store.listeners.delete(onChange);
79
+ if (store.listeners.size > 0) return;
80
+ disarmTimer(store);
81
+ if (store.visHandler && typeof document !== "undefined") {
82
+ document.removeEventListener("visibilitychange", store.visHandler);
83
+ }
84
+ stores.delete(intervalMs);
85
+ };
86
+ }
87
+
88
+ /** Test seam — how many listeners share `intervalMs`. */
89
+ export function quantizedListenerCount(intervalMs: number): number {
90
+ return stores.get(intervalMs)?.listeners.size ?? 0;
91
+ }
92
+
93
+ /** Test seam — whether a timer is armed for `intervalMs`. */
94
+ export function quantizedTimerArmed(intervalMs: number): boolean {
95
+ return stores.get(intervalMs)?.timer !== undefined;
96
+ }
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Radius classes for theme-aware corners (`tokens.css` or the compat file).
3
+ *
4
+ * - Compact single-line rows that should stay pills on a "full" radius theme:
5
+ * `rounded-lg` (maps to `var(--radius)`).
6
+ * - Menu and nav rows: `rounded-menu-item` (capped).
7
+ * - Controls: `rounded-control` (capped).
8
+ * - Wide or multi-line surfaces (modals, dropdown panels, cards):
9
+ * `rounded-surface` (clamped) so they do not become ovals.
10
+ */
11
+
12
+ /** Cards, dialogs, dropdown panels, kanban columns, alerts, calendars. */
13
+ export const SURFACE_RADIUS_CLASS = "rounded-surface";
14
+
15
+ /** Inputs, textareas, selects, input groups. */
16
+ export const CONTROL_RADIUS_CLASS = "rounded-control";
@@ -0,0 +1,58 @@
1
+ import {
2
+ KeyboardSensor,
3
+ MouseSensor,
4
+ TouchSensor,
5
+ useSensor,
6
+ useSensors,
7
+ type SensorDescriptor,
8
+ type SensorOptions,
9
+ } from "@dnd-kit/core";
10
+ import { sortableKeyboardCoordinates } from "@dnd-kit/sortable";
11
+ import { DRAG_ACTIVATION_DISTANCE_PX } from "./gesture";
12
+
13
+ /** How long a finger must rest before a drag arms, in ms. */
14
+ const TOUCH_HOLD_MS = 200;
15
+
16
+ /** How far a finger may stray during the hold without cancelling it, in px. */
17
+ const TOUCH_HOLD_TOLERANCE_PX = 10;
18
+
19
+ /**
20
+ * The house dnd-kit activation split, for every draggable surface.
21
+ *
22
+ * A single `PointerSensor` cannot serve both input types. Mouse arms on
23
+ * **distance**, because dnd-kit's `delay` constraint cancels activation outright
24
+ * when the pointer travels past `tolerance` before the timer fires — so a fast,
25
+ * decisive drag never picks the item up at all. Touch keeps a **hold**, because
26
+ * distance-based activation there competes with the scroll the finger is also
27
+ * describing, and the browser wins: the pointer is cancelled and the drag dies.
28
+ *
29
+ * Keyboard is included so a drag is reachable without a pointer.
30
+ *
31
+ * Pass `{ sortable: true }` inside a `SortableContext`. `sortableKeyboardCoordinates`
32
+ * translates arrow keys into the *next item's* position rather than a raw pixel
33
+ * offset, which is the difference between a keyboard reorder that lands on a row
34
+ * and one that nudges the item a few pixels into nothing. The flag lives here
35
+ * rather than being a `coordinateGetter` each call site imports, because that is
36
+ * exactly what two call sites silently dropped when they adopted this hook. It is
37
+ * opt-in because the getter reads `active.data.current.sortable` and returns
38
+ * nothing without it, so defaulting it on would take keyboard dragging away from
39
+ * the non-sortable surfaces (the gantt bars).
40
+ */
41
+ export function useDragSensors({
42
+ sortable = false,
43
+ }: { sortable?: boolean } = {}): SensorDescriptor<SensorOptions>[] {
44
+ return useSensors(
45
+ useSensor(MouseSensor, {
46
+ activationConstraint: { distance: DRAG_ACTIVATION_DISTANCE_PX },
47
+ }),
48
+ useSensor(TouchSensor, {
49
+ activationConstraint: {
50
+ delay: TOUCH_HOLD_MS,
51
+ tolerance: TOUCH_HOLD_TOLERANCE_PX,
52
+ },
53
+ }),
54
+ useSensor(KeyboardSensor, {
55
+ coordinateGetter: sortable ? sortableKeyboardCoordinates : undefined,
56
+ }),
57
+ );
58
+ }