@vvedantb/ui 0.17.1 → 0.18.1

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 +251 -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/speech-input.tsx +5 -3
  33. package/src/ai-elements/test-results.tsx +510 -0
  34. package/src/ai-elements/trace.tsx +243 -0
  35. package/src/ai-elements/use-streaming-disclosure.ts +50 -0
  36. package/src/ai-elements/web-preview.tsx +349 -0
  37. package/src/composer/input-area/ComposerSlashMenu.tsx +1 -1
  38. package/src/index.ts +66 -12
  39. package/src/kibo/avatar-stack.tsx +40 -0
  40. package/src/kibo/contribution-graph.tsx +539 -0
  41. package/src/kibo/cursor.tsx +72 -0
  42. package/src/kibo/gantt-features.tsx +439 -0
  43. package/src/kibo/gantt-header.tsx +181 -0
  44. package/src/kibo/gantt-provider.tsx +657 -0
  45. package/src/kibo/gantt-sidebar.tsx +157 -0
  46. package/src/kibo/gantt-timeline.tsx +154 -0
  47. package/src/kibo/index.ts +151 -0
  48. package/src/kibo/kanban.tsx +338 -0
  49. package/src/kibo/kanbanDropTarget.ts +25 -0
  50. package/src/kibo/list.tsx +162 -0
  51. package/src/kibo/theme-switcher.tsx +109 -0
  52. package/src/kibo/video-player.tsx +237 -0
  53. package/src/markdown/Markdown.tsx +112 -0
  54. package/src/markdown/MarkdownBlockquote.tsx +45 -0
  55. package/src/markdown/MarkdownCodeBlock.tsx +197 -0
  56. package/src/markdown/MarkdownInlineCode.tsx +40 -0
  57. package/src/markdown/MarkdownLink.tsx +92 -0
  58. package/src/markdown/codeFence.ts +57 -0
  59. package/src/markdown/codeHighlight.ts +138 -0
  60. package/src/markdown/incrementalBlocks.ts +182 -0
  61. package/src/markdown/index.ts +3 -0
  62. package/src/markdown/markdownClipboard.ts +178 -0
  63. package/src/markdown/mermaid.ts +155 -0
  64. package/src/markdown/rehypeGithubAlerts.ts +80 -0
  65. package/src/motion/presets.ts +24 -0
  66. package/src/ui/__snapshots__/alt-wrappers.test.tsx.snap +19 -0
  67. package/src/ui/__snapshots__/sonner-dom.test.tsx.snap +5 -0
  68. package/src/ui/_menu-parts.tsx +29 -0
  69. package/src/ui/accordion.alt.tsx +46 -53
  70. package/src/ui/accordion.tsx +59 -10
  71. package/src/ui/avatar.tsx +56 -0
  72. package/src/ui/badge.tsx +45 -21
  73. package/src/ui/border-beam.tsx +97 -0
  74. package/src/ui/button-group.tsx +50 -21
  75. package/src/ui/button.tsx +67 -45
  76. package/src/ui/calendar.tsx +48 -5
  77. package/src/ui/card.tsx +77 -8
  78. package/src/ui/carousel.tsx +60 -0
  79. package/src/ui/centered-spinner.tsx +30 -0
  80. package/src/ui/checkbox.tsx +34 -16
  81. package/src/ui/clear-input.alt.tsx +33 -75
  82. package/src/ui/clear-input.tsx +140 -15
  83. package/src/ui/collapsible.tsx +40 -2
  84. package/src/ui/command.tsx +143 -70
  85. package/src/ui/context-menu.tsx +159 -93
  86. package/src/ui/crossfade-icon.tsx +123 -0
  87. package/src/ui/dialog.tsx +154 -70
  88. package/src/ui/dropdown-menu.tsx +177 -117
  89. package/src/ui/hover-card.tsx +59 -17
  90. package/src/ui/input-group.alt.tsx +83 -83
  91. package/src/ui/input-group.tsx +129 -58
  92. package/src/ui/input.tsx +13 -4
  93. package/src/ui/label.tsx +11 -8
  94. package/src/ui/list-row.tsx +232 -0
  95. package/src/ui/loading-state.tsx +215 -0
  96. package/src/ui/popover.tsx +56 -23
  97. package/src/ui/progress.tsx +28 -16
  98. package/src/ui/refresh-spin-icon.tsx +41 -0
  99. package/src/ui/search-input.tsx +66 -15
  100. package/src/ui/select.tsx +171 -100
  101. package/src/ui/separator.tsx +23 -14
  102. package/src/ui/slider.tsx +21 -7
  103. package/src/ui/sonner.tsx +68 -30
  104. package/src/ui/spinner.tsx +229 -11
  105. package/src/ui/status-dot.tsx +84 -0
  106. package/src/ui/surface.tsx +49 -0
  107. package/src/ui/switch.tsx +29 -15
  108. package/src/ui/table.tsx +38 -16
  109. package/src/ui/tabs.tsx +123 -35
  110. package/src/ui/tabsSliding.ts +33 -1
  111. package/src/ui/textarea.tsx +13 -4
  112. package/src/ui/tooltip.tsx +50 -14
  113. package/src/ui/ui-config.tsx +149 -0
  114. package/src/utils/cat-color.ts +123 -0
  115. package/src/utils/dayjs.ts +6 -0
  116. package/src/utils/gesture.ts +59 -0
  117. package/src/utils/runtimeVisibility.ts +167 -0
  118. package/src/utils/sharedClock.ts +96 -0
  119. package/src/utils/surface-radius.ts +16 -0
  120. package/src/utils/useDragSensors.ts +58 -0
  121. package/tokens/examples/tokens-moved/Fixture.tsx +283 -0
  122. package/tokens/examples/tokens-moved/README.md +20 -0
  123. package/tokens/examples/tokens-moved/index.html +12 -0
  124. package/tokens/examples/tokens-moved/main.tsx +6 -0
  125. package/tokens/examples/tokens-moved/package.json +14 -0
  126. package/tokens/examples/tokens-moved/styles.css +12 -0
  127. package/tokens/examples/tokens-moved/tsconfig.json +14 -0
  128. package/tokens/examples/tokens-moved/vite.config.ts +11 -0
  129. package/tokens/src/css/border-beam.css +116 -0
  130. package/tokens/src/css/markdown.css +318 -0
package/src/ui/sonner.tsx CHANGED
@@ -1,6 +1,7 @@
1
1
  import type { CSSProperties, ReactNode } from "react";
2
- import { Toaster, type ToasterProps } from "sonner";
2
+ import { Toaster as SonnerPrimitive, type ToasterProps } from "sonner";
3
3
  import { filledToastIcons } from "./sonner-icons";
4
+ import { useUiConfig } from "./ui-config";
4
5
  import "./sonner.css";
5
6
 
6
7
  // Sonner toaster aligned with flat tonal surfaces. `unstyled: true` strips
@@ -55,8 +56,24 @@ type SonnerToasterOptions = {
55
56
  * while the stack is collapsed. Default false. `sonner.css` has the same rule.
56
57
  */
57
58
  hideStackedContent?: boolean;
59
+ /**
60
+ * Replaces built-in classes. Toast parts (`toast`, `title`, `loader`, …)
61
+ * replace the package `classNames` per key (`""` removes one); caller
62
+ * `toastOptions.classNames` still merge after. `toaster` replaces
63
+ * `toaster group !font-sans` on the toaster list (`""` removes it).
64
+ */
65
+ classes?: ToastClassNames & { toaster?: string };
58
66
  };
59
67
 
68
+ /**
69
+ * App defaults for `SonnerToaster` (`UiConfig.SonnerToaster`): every key is a
70
+ * default for the prop of the same name. An explicit prop wins; a prop passed
71
+ * as `undefined` keeps the config value.
72
+ */
73
+ type SonnerToasterConfig = Omit<ToasterProps, "className" | "toastOptions"> & SonnerToasterOptions;
74
+
75
+ const defaultToasterClassName = "toaster group !font-sans";
76
+
60
77
  const hideStackedContentClassName =
61
78
  "[&[data-expanded=false][data-front=false]>*]:opacity-0";
62
79
 
@@ -161,23 +178,37 @@ function mergeToastOptions(
161
178
  };
162
179
  }
163
180
 
164
- function SonnerToaster({
165
- toastRadius,
166
- density,
167
- toastIcons = "default",
168
- closeButtonStyle = "default",
169
- sonnerVars = false,
170
- descriptionMuted,
171
- titleLeading,
172
- loadingIcon,
173
- hideStackedContent,
174
- toastOptions,
175
- icons,
176
- style,
177
- className,
178
- closeButton,
179
- ...props
180
- }: ToasterProps & SonnerToasterOptions) {
181
+ /** `{ ...defaults, ...props }`, except that a prop set to `undefined` keeps its default. */
182
+ function withConfigDefaults<T extends object>(defaults: Partial<T> | undefined, props: T): T {
183
+ const merged = { ...defaults, ...props };
184
+ for (const key in defaults) {
185
+ const value = defaults[key];
186
+ if (props[key] === undefined && value !== undefined) merged[key] = value;
187
+ }
188
+ return merged;
189
+ }
190
+
191
+ /** Defaults come from `UiConfig.SonnerToaster` when the app sets it; explicit props win. */
192
+ function SonnerToaster(props: ToasterProps & SonnerToasterOptions) {
193
+ const {
194
+ classes,
195
+ toastRadius,
196
+ density,
197
+ toastIcons = "default",
198
+ closeButtonStyle = "default",
199
+ sonnerVars = false,
200
+ descriptionMuted,
201
+ titleLeading,
202
+ loadingIcon,
203
+ hideStackedContent,
204
+ toastOptions,
205
+ icons,
206
+ style,
207
+ className,
208
+ closeButton,
209
+ ...rest
210
+ } = withConfigDefaults(useUiConfig("SonnerToaster"), props);
211
+ const { toaster: toasterClassName = defaultToasterClassName, ...toastClasses } = classes ?? {};
181
212
  const loadingOverride =
182
213
  loadingIcon != null ? { loading: loadingIcon } : undefined;
183
214
  const mergedIcons =
@@ -187,26 +218,33 @@ function SonnerToaster({
187
218
  ? { ...loadingOverride, ...icons }
188
219
  : icons;
189
220
  return (
190
- <Toaster
221
+ <SonnerPrimitive
191
222
  expand={false}
192
223
  visibleToasts={4}
193
224
  gap={12}
194
- {...props}
225
+ {...rest}
195
226
  closeButton={closeButton ?? (closeButtonStyle === "hoverChip" ? true : undefined)}
196
- className={className ? `toaster group !font-sans ${className}` : "toaster group !font-sans"}
227
+ className={
228
+ toasterClassName && className
229
+ ? `${toasterClassName} ${className}`
230
+ : toasterClassName || className || undefined
231
+ }
197
232
  icons={mergedIcons}
198
233
  style={mergeToasterStyle(sonnerVars ? sonnerVarsStyle : undefined, style)}
199
234
  toastOptions={mergeToastOptions(
200
235
  {
201
236
  unstyled: true,
202
- classNames: createToasterClassNames({
203
- toastRadius,
204
- density,
205
- closeButtonStyle,
206
- descriptionMuted,
207
- titleLeading,
208
- hideStackedContent,
209
- }),
237
+ classNames: {
238
+ ...createToasterClassNames({
239
+ toastRadius,
240
+ density,
241
+ closeButtonStyle,
242
+ descriptionMuted,
243
+ titleLeading,
244
+ hideStackedContent,
245
+ }),
246
+ ...toastClasses,
247
+ },
210
248
  },
211
249
  toastOptions,
212
250
  )}
@@ -224,4 +262,4 @@ export {
224
262
  sonnerVarsStyle,
225
263
  };
226
264
  export { filledToastIcons } from "./sonner-icons";
227
- export type { SonnerToasterOptions };
265
+ export type { SonnerToasterConfig, SonnerToasterOptions };
@@ -1,5 +1,6 @@
1
1
  import { IconLoader2 } from "@tabler/icons-react";
2
- import { cn } from "../utils/cn";
2
+ import { bindRuntimeAnimation } from "../utils/runtimeVisibility";
3
+ import { useCn, useUiConfig } from "./ui-config";
3
4
 
4
5
  const sizeClasses = {
5
6
  sm: "size-4",
@@ -7,35 +8,252 @@ const sizeClasses = {
7
8
  lg: "size-8",
8
9
  };
9
10
 
11
+ /** One outline of a {@link SpinnerMark}: a `<polygon>` (`points`) or a `<path>` (`d`). */
12
+ export type SpinnerMarkShape =
13
+ | { points: string; stroke: string }
14
+ | { d: string; stroke: string };
15
+
16
+ /**
17
+ * A logo (or any outline) drawn as strokes, with a dash that runs around each
18
+ * shape forever. Each shape gets `pathLength=1`, so `dashArray` is a fraction
19
+ * of its perimeter (`"0.28 0.72"`: a dash over 28% of the outline).
20
+ */
21
+ export interface SpinnerMark {
22
+ viewBox: string;
23
+ shapes: readonly SpinnerMarkShape[];
24
+ strokeWidth: number;
25
+ dashArray: string;
26
+ }
27
+
28
+ /** Stroke colours of the `circle` variant. Each defaults to `currentColor`. */
29
+ export interface SpinnerCircleColors {
30
+ /** The faint full ring (drawn at 20% opacity). */
31
+ track?: string;
32
+ /** The longer arc. */
33
+ arc?: string;
34
+ /** The shorter arc drawn over the leading half of `arc`. */
35
+ lead?: string;
36
+ }
37
+
38
+ export interface SpinnerConfig {
39
+ /** Default for the `mark` prop: every `<Spinner />` without a `variant` draws this mark. */
40
+ mark?: SpinnerMark;
41
+ /** Default for the `colors` prop of the `circle` variant. */
42
+ circle?: SpinnerCircleColors;
43
+ }
44
+
10
45
  export type SpinnerProps = Omit<React.ComponentPropsWithoutRef<"svg">, "size" | "stroke"> & {
11
- /** Named size (a `size-*` class) or a pixel size passed to the icon (no size class). Default `"md"`. */
46
+ /**
47
+ * What to draw. Without it: the `mark` (prop, else `UiConfig.Spinner.mark`),
48
+ * else the loader icon. An explicit `variant` wins over the configured mark.
49
+ * - `"icon"`: the rotating loader icon.
50
+ * - `"circle"`: a faint ring with a two-tone arc sweeping around it, for
51
+ * small slots (inline buttons, menu rows) where a mark is too small to read.
52
+ * - `"ring"`: the `Button` loading ring, a fixed 16px (`h-4 w-4`) ring and arc
53
+ * in `currentColor`. Unlabelled (the button's `disabled` and `data-loading`
54
+ * carry the state); `size`, `tone`, `stroke` and `decorative` do not apply.
55
+ */
56
+ variant?: "icon" | "circle" | "ring";
57
+ /** Named size (a `size-*` class) or a pixel size (`width`/`height`, no size class). Default `"md"`. Not used with `"ring"`. */
12
58
  size?: "sm" | "md" | "lg" | number;
13
- /** Icon stroke width. Default the icon's own (2). */
59
+ /** Icon stroke width. Default the icon's own (2). Only for `"icon"`. */
14
60
  stroke?: number | string;
15
- /** `"muted"` adds `text-muted-foreground`; `"inherit"` adds no colour class, so the icon uses `currentColor`. Default `"muted"`. */
61
+ /** `"muted"` adds `text-muted-foreground`; `"inherit"` adds no colour class, so the icon uses `currentColor`. Default `"muted"`. Only for `"icon"`. */
16
62
  tone?: "muted" | "inherit";
17
- /** Hide from assistive tech (`aria-hidden`, no `role="status"` or label), e.g. inside a labelled button. Default `false`. */
63
+ /** Hide from assistive tech (`aria-hidden`, no `role="status"` or label), e.g. inside a labelled button. Default `false`. Not used with `"ring"`. */
18
64
  decorative?: boolean;
65
+ /** Draw this outline with a running dash instead of the loader icon. Default `UiConfig.Spinner.mark`, else none. Ignored with a `variant`. */
66
+ mark?: SpinnerMark;
67
+ /** Stroke colours of the `"circle"` variant. Default `UiConfig.Spinner.circle`, else `currentColor`. */
68
+ colors?: SpinnerCircleColors;
19
69
  };
20
70
 
21
- function Spinner({
71
+ /** Props every variant renderer takes: the svg props plus the shared ones. */
72
+ type VariantProps = Omit<SpinnerProps, "variant" | "mark" | "stroke" | "tone" | "colors">;
73
+
74
+ function a11yProps(decorative: boolean) {
75
+ return decorative ? { "aria-hidden": true } : { role: "status", "aria-label": "Loading" };
76
+ }
77
+
78
+ /**
79
+ * Runs the dash around one shape: `stroke-dashoffset` 0 → -1 over 2s, forever,
80
+ * against `pathLength=1`. Web Animations rather than SMIL `<animate>`: SMIL
81
+ * ticks each shape's animated attribute on its own (a style recalc per shape
82
+ * per frame); WAAPI folds into the frame's single recalc and needs no app
83
+ * keyframes. {@link bindRuntimeAnimation} pauses it while the tab is hidden or
84
+ * the shape is off screen. Module-level so the ref identity is stable across
85
+ * renders; React 19 runs the returned cleanup on detach.
86
+ */
87
+ function traceDash(shape: SVGGeometryElement | null): (() => void) | undefined {
88
+ if (!shape) return undefined;
89
+ const trace = shape.animate(
90
+ [{ strokeDashoffset: 0 }, { strokeDashoffset: -1 }],
91
+ { duration: 2000, iterations: Infinity },
92
+ );
93
+ return bindRuntimeAnimation(shape, trace);
94
+ }
95
+
96
+ function MarkSpinner({
97
+ mark,
98
+ size = "md",
99
+ decorative = false,
100
+ className,
101
+ ...props
102
+ }: VariantProps & { mark: SpinnerMark }) {
103
+ const pixels = typeof size === "number" ? size : undefined;
104
+ const merge = useCn();
105
+ return (
106
+ <svg
107
+ viewBox={mark.viewBox}
108
+ {...a11yProps(decorative)}
109
+ width={pixels}
110
+ height={pixels}
111
+ className={merge(typeof size === "number" ? undefined : sizeClasses[size], className)}
112
+ {...props}
113
+ >
114
+ {mark.shapes.map((shape, i) =>
115
+ "points" in shape ? (
116
+ <polygon
117
+ key={i}
118
+ ref={traceDash}
119
+ points={shape.points}
120
+ fill="none"
121
+ stroke={shape.stroke}
122
+ strokeWidth={mark.strokeWidth}
123
+ strokeLinejoin="round"
124
+ strokeLinecap="round"
125
+ pathLength={1}
126
+ strokeDasharray={mark.dashArray}
127
+ />
128
+ ) : (
129
+ <path
130
+ key={i}
131
+ ref={traceDash}
132
+ d={shape.d}
133
+ fill="none"
134
+ stroke={shape.stroke}
135
+ strokeWidth={mark.strokeWidth}
136
+ strokeLinejoin="round"
137
+ strokeLinecap="round"
138
+ pathLength={1}
139
+ strokeDasharray={mark.dashArray}
140
+ />
141
+ ),
142
+ )}
143
+ </svg>
144
+ );
145
+ }
146
+
147
+ /**
148
+ * Rotates the arc around the viewBox centre, forever. Web Animations rather
149
+ * than a CSS class: WAAPI gives {@link bindRuntimeAnimation} a handle to pause
150
+ * the loop while the tab is hidden or the spinner is off screen, which a CSS
151
+ * `animate-spin` cannot offer. Module-level so the ref identity is stable.
152
+ */
153
+ function spinArc(arc: SVGGElement): () => void {
154
+ const spin = arc.animate(
155
+ [{ transform: "rotate(0deg)" }, { transform: "rotate(360deg)" }],
156
+ { duration: 900, iterations: Infinity, easing: "linear" },
157
+ );
158
+ return bindRuntimeAnimation(arc, spin);
159
+ }
160
+
161
+ /**
162
+ * The `circle` variant: a faint full ring with a two-tone arc sweeping around it.
163
+ *
164
+ * The arc is two circles rather than one gradient stroke: a gradient only
165
+ * samples the slice of its box the arc crosses, so at 12–16px one end washes
166
+ * out. Drawing `lead` over the leading half of a longer `arc` dash keeps both
167
+ * colours legible at every size, and needs no per-instance gradient id.
168
+ */
169
+ function CircleSpinner({
170
+ colors,
22
171
  size = "md",
23
- tone = "muted",
24
172
  decorative = false,
25
173
  className,
26
174
  ...props
27
- }: SpinnerProps) {
175
+ }: VariantProps & { colors: SpinnerCircleColors }) {
176
+ const merge = useCn();
177
+ const pixels = typeof size === "number" ? size : undefined;
178
+ const { track = "currentColor", arc = "currentColor", lead = "currentColor" } = colors;
179
+ return (
180
+ <svg
181
+ viewBox="0 0 24 24"
182
+ {...a11yProps(decorative)}
183
+ width={pixels}
184
+ height={pixels}
185
+ className={merge(typeof size === "number" ? undefined : sizeClasses[size], className)}
186
+ {...props}
187
+ >
188
+ <circle cx="12" cy="12" r="9" fill="none" stroke={track} strokeWidth={3} opacity={0.2} />
189
+ <g ref={spinArc} style={{ transformBox: "view-box", transformOrigin: "12px 12px" }}>
190
+ <circle
191
+ cx="12"
192
+ cy="12"
193
+ r="9"
194
+ fill="none"
195
+ stroke={arc}
196
+ strokeWidth={3}
197
+ strokeLinecap="round"
198
+ pathLength={1}
199
+ strokeDasharray="0.36 0.64"
200
+ />
201
+ <circle
202
+ cx="12"
203
+ cy="12"
204
+ r="9"
205
+ fill="none"
206
+ stroke={lead}
207
+ strokeWidth={3}
208
+ strokeLinecap="round"
209
+ pathLength={1}
210
+ strokeDasharray="0.18 0.82"
211
+ />
212
+ </g>
213
+ </svg>
214
+ );
215
+ }
216
+
217
+ /** The `ring` variant: the `Button` loading ring, a fixed markup with a CSS `animate-spin`. */
218
+ function RingSpinner({
219
+ className,
220
+ size: _size,
221
+ decorative: _decorative,
222
+ ...props
223
+ }: VariantProps) {
224
+ const merge = useCn();
225
+ return (
226
+ <svg className={merge("h-4 w-4 animate-spin", className)} xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" {...props}>
227
+ <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
228
+ <path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c3.042 1.135 5.824 3 7.938l3-2.647z" />
229
+ </svg>
230
+ );
231
+ }
232
+
233
+ /**
234
+ * The one loading spinner: the loader icon, an app mark with a running dash,
235
+ * the two-tone `circle`, or the `Button` `ring`. See {@link SpinnerProps.variant}.
236
+ */
237
+ function Spinner({ variant, mark, colors, stroke, tone = "muted", ...props }: SpinnerProps) {
238
+ const config = useUiConfig("Spinner");
239
+ const merge = useCn();
240
+ if (variant === "ring") return <RingSpinner {...props} />;
241
+ if (variant === "circle") return <CircleSpinner colors={colors ?? config?.circle ?? {}} {...props} />;
242
+ const resolvedMark = variant ? undefined : (mark ?? config?.mark);
243
+ if (resolvedMark) return <MarkSpinner mark={resolvedMark} {...props} />;
244
+ const { size = "md", decorative = false, className, ...iconProps } = props;
28
245
  return (
29
246
  <IconLoader2
30
- {...(decorative ? { "aria-hidden": true } : { role: "status", "aria-label": "Loading" })}
247
+ {...a11yProps(decorative)}
31
248
  size={typeof size === "number" ? size : undefined}
32
- className={cn(
249
+ stroke={stroke}
250
+ className={merge(
33
251
  "animate-spin",
34
252
  tone === "muted" && "text-muted-foreground",
35
253
  typeof size === "number" ? undefined : sizeClasses[size],
36
254
  className,
37
255
  )}
38
- {...props}
256
+ {...iconProps}
39
257
  />
40
258
  );
41
259
  }
@@ -0,0 +1,84 @@
1
+ import type { HTMLAttributes, Ref } from "react";
2
+
3
+ import { useCn, useUiConfig } from "./ui-config";
4
+
5
+ export type StatusTone =
6
+ | "progress"
7
+ | "business-review"
8
+ | "code-review"
9
+ | "done"
10
+ | "cancelled"
11
+ /**
12
+ * Live/online/enabled. Distinct from `done`: this is a *state* something is
13
+ * currently in, not a workflow stage it has reached.
14
+ */
15
+ | "active"
16
+ /** Something is wrong and needs attention: a critical finding, a hard error. */
17
+ | "critical"
18
+ | "neutral";
19
+
20
+ export interface StatusDotConfig {
21
+ /** Replaces the built-in classes of a part. */
22
+ classes?: {
23
+ /** Fill class per tone. A tone left out keeps its built-in class. */
24
+ tone?: Partial<Record<StatusTone, string>>;
25
+ };
26
+ }
27
+
28
+ // Plain Record so Tailwind's static-class scanner can see every literal
29
+ // class here — a computed `bg-${tone}` string would not survive the purge.
30
+ const TONE_CLASS: Record<StatusTone, string> = {
31
+ progress: "bg-warning",
32
+ "business-review": "bg-primary",
33
+ "code-review": "bg-primary/70",
34
+ done: "bg-success",
35
+ cancelled: "bg-muted-foreground/40",
36
+ active: "bg-success",
37
+ critical: "bg-destructive",
38
+ neutral: "bg-muted-foreground/60",
39
+ };
40
+
41
+ const SIZE_CLASS: Record<"sm" | "md", string> = {
42
+ sm: "size-1.5",
43
+ md: "size-2",
44
+ };
45
+
46
+ export interface StatusDotProps extends HTMLAttributes<HTMLSpanElement> {
47
+ tone: StatusTone;
48
+ /** Slightly larger dot for detail views. Default "sm". */
49
+ size?: "sm" | "md";
50
+ ref?: Ref<HTMLSpanElement>;
51
+ }
52
+
53
+ /**
54
+ * Linear-style status glyph: a small coloured dot that carries the status,
55
+ * so the label next to it can stay neutral text instead of a coloured pill.
56
+ *
57
+ * `ref` is load-bearing: call sites make the dot the tooltip anchor via
58
+ * `<TooltipTrigger asChild>`, and Radix composes its ref onto that child to
59
+ * position the popper.
60
+ */
61
+ export function StatusDot({
62
+ tone,
63
+ size = "sm",
64
+ className,
65
+ ref,
66
+ "aria-hidden": ariaHidden = true,
67
+ ...props
68
+ }: StatusDotProps) {
69
+ const config = useUiConfig("StatusDot");
70
+ const merge = useCn();
71
+ return (
72
+ <span
73
+ ref={ref}
74
+ aria-hidden={ariaHidden}
75
+ className={merge(
76
+ "rounded-full shrink-0",
77
+ SIZE_CLASS[size],
78
+ config?.classes?.tone?.[tone] ?? TONE_CLASS[tone],
79
+ className,
80
+ )}
81
+ {...props}
82
+ />
83
+ );
84
+ }
@@ -0,0 +1,49 @@
1
+ import * as React from "react";
2
+ import { cva, type VariantProps } from "class-variance-authority";
3
+
4
+ import { useCn } from "./ui-config";
5
+ import { SURFACE_RADIUS_CLASS } from "../utils/surface-radius";
6
+
7
+ /**
8
+ * A panel with the card fill and one of four paddings.
9
+ *
10
+ * `Card` splits padding across Header / Content / Footer, which is right for a
11
+ * titled card and wrong for a box that just needs one padding. Density is a
12
+ * prop rather than a `className` so the paddings stay a closed set;
13
+ * `className` still overrides when a layout needs it.
14
+ */
15
+ const surfaceVariants = cva(
16
+ `${SURFACE_RADIUS_CLASS} bg-card text-card-foreground`,
17
+ {
18
+ variants: {
19
+ density: {
20
+ // Dense list items and inline panels.
21
+ tight: "p-3",
22
+ // The default box: settings sections, stat cards, banners.
23
+ default: "p-4",
24
+ // Standalone cards that carry a heading of their own.
25
+ comfortable: "p-6",
26
+ // The caller owns padding entirely — for surfaces with their own
27
+ // internal regions, such as a table that must reach the edge.
28
+ none: "",
29
+ },
30
+ },
31
+ defaultVariants: {
32
+ density: "default",
33
+ },
34
+ },
35
+ );
36
+
37
+ export interface SurfaceProps
38
+ extends React.HTMLAttributes<HTMLDivElement>,
39
+ VariantProps<typeof surfaceVariants> {}
40
+
41
+ const Surface = React.forwardRef<HTMLDivElement, SurfaceProps>(
42
+ ({ className, density, ...props }, ref) => {
43
+ const merge = useCn();
44
+ return <div ref={ref} className={merge(surfaceVariants({ density }), className)} {...props} />;
45
+ },
46
+ );
47
+ Surface.displayName = "Surface";
48
+
49
+ export { Surface, surfaceVariants };
package/src/ui/switch.tsx CHANGED
@@ -1,26 +1,40 @@
1
1
  import * as React from "react";
2
2
  import * as SwitchPrimitive from "@radix-ui/react-switch";
3
3
  import { cn } from "../utils/cn";
4
+ import { useCn, useUiConfig } from "./ui-config";
5
+
6
+ /** `UiConfig.Switch`: `classes` replace the built-in part classes. */
7
+ export interface SwitchConfig {
8
+ classes?: { root?: string; thumb?: string };
9
+ }
4
10
 
5
11
  const Switch = React.forwardRef<
6
12
  React.ComponentRef<typeof SwitchPrimitive.Root>,
7
13
  React.ComponentPropsWithoutRef<typeof SwitchPrimitive.Root>
8
- >(({ className, ...props }, ref) => (
9
- <SwitchPrimitive.Root
10
- className={cn(
11
- "peer relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border border-border bg-default transition-[background-color,border-color] duration-200 ease-smooth focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:border-primary data-[state=checked]:bg-primary data-[state=unchecked]:bg-default dark:data-[state=unchecked]:border-muted/60 dark:data-[state=unchecked]:bg-muted/40 before:absolute before:inset-[-8px] before:content-['']",
12
- className,
13
- )}
14
- {...props}
15
- ref={ref}
16
- >
17
- <SwitchPrimitive.Thumb
18
- className={cn(
19
- "pointer-events-none block size-4 rounded-full bg-background ring-0 transition-[transform,background-color] duration-200 ease-smooth data-[state=checked]:translate-x-4 data-[state=checked]:bg-primary-foreground data-[state=unchecked]:translate-x-0 dark:data-[state=unchecked]:bg-foreground",
14
+ >(({ className, ...props }, ref) => {
15
+ const classes = useUiConfig("Switch")?.classes;
16
+ const merge = useCn();
17
+ return (
18
+ <SwitchPrimitive.Root
19
+ className={merge(
20
+ classes?.root ??
21
+ "peer relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border border-border bg-default transition-[background-color,border-color] duration-200 ease-smooth focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:border-primary data-[state=checked]:bg-primary data-[state=unchecked]:bg-default dark:data-[state=unchecked]:border-muted/60 dark:data-[state=unchecked]:bg-muted/40 before:absolute before:inset-[-8px] before:content-['']", // check-tokens-allow: muted
22
+ className,
20
23
  )}
21
- />
22
- </SwitchPrimitive.Root>
23
- ));
24
+ {...props}
25
+ ref={ref}
26
+ >
27
+ <SwitchPrimitive.Thumb
28
+ className={
29
+ classes?.thumb ??
30
+ cn(
31
+ "pointer-events-none block size-4 rounded-full bg-background ring-0 transition-[transform,background-color] duration-200 ease-smooth data-[state=checked]:translate-x-4 data-[state=checked]:bg-primary-foreground data-[state=unchecked]:translate-x-0 dark:data-[state=unchecked]:bg-foreground",
32
+ )
33
+ }
34
+ />
35
+ </SwitchPrimitive.Root>
36
+ );
37
+ });
24
38
  Switch.displayName = SwitchPrimitive.Root.displayName;
25
39
 
26
40
  export { Switch };