@cntyclub/ui-react 0.14.0 → 0.16.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cntyclub/ui-react",
3
- "version": "0.14.0",
3
+ "version": "0.16.0",
4
4
  "description": "React component library for the Country Club UI Kit — Base UI primitives styled with the Country Club design system (Tailwind CSS v4)",
5
5
  "type": "module",
6
6
  "sideEffects": [
@@ -47,6 +47,7 @@
47
47
  "input-otp": "^1.4.2",
48
48
  "lucide-react": "^0.561.0",
49
49
  "qr-code-styling": "^1.9.2",
50
+ "react-aria-components": "^1.20.0",
50
51
  "react-day-picker": "^9.14.0",
51
52
  "react-dropzone": "^14.4.0",
52
53
  "react-markdown": "^9.1.0",
@@ -0,0 +1,67 @@
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+
5
+ import { cn } from "../../lib/utils/css";
6
+
7
+ type ChatDividerVariant = "default" | "summary" | "unread";
8
+
9
+ interface ChatDividerProps extends React.HTMLAttributes<HTMLDivElement> {
10
+ /** Centered label, e.g. "New messages", "You're caught up", "Earlier messages summarized". */
11
+ label?: React.ReactNode;
12
+ /** Optional leading icon (e.g. a summary or sparkle glyph). */
13
+ icon?: React.ReactNode;
14
+ /**
15
+ * - `default` — neutral hairline with a muted label.
16
+ * - `summary` — marks where a rolling summary folds older history.
17
+ * - `unread` — the "new messages" baseline; accented so it stands out.
18
+ */
19
+ variant?: ChatDividerVariant;
20
+ }
21
+
22
+ const LABEL_TONE: Record<ChatDividerVariant, string> = {
23
+ default: "text-muted-foreground",
24
+ summary: "text-muted-foreground",
25
+ unread: "text-primary",
26
+ };
27
+
28
+ const LINE_TONE: Record<ChatDividerVariant, string> = {
29
+ default: "bg-border",
30
+ summary: "bg-border",
31
+ unread: "bg-primary/40",
32
+ };
33
+
34
+ /**
35
+ * A centered, labeled divider for the message stream: the point a rolling summary
36
+ * folds history (`summary`), the unread baseline (`unread`), or a plain date/section
37
+ * break (`default`). Pairs with the backend `AgentSummaryCheckpoint` and each
38
+ * participant's `joined_seq`/`last_read_seq`.
39
+ */
40
+ function ChatDivider({ label, icon, variant = "default", className, ...props }: ChatDividerProps) {
41
+ return (
42
+ <div
43
+ className={cn("flex w-full items-center gap-3 py-1 select-none", className)}
44
+ data-slot="chat-divider"
45
+ data-variant={variant}
46
+ role="separator"
47
+ {...props}
48
+ >
49
+ <span className={cn("h-px flex-1", LINE_TONE[variant])} />
50
+ {label != null ? (
51
+ <span
52
+ className={cn(
53
+ "inline-flex items-center gap-1.5 font-medium text-xs whitespace-nowrap",
54
+ LABEL_TONE[variant],
55
+ )}
56
+ >
57
+ {icon ? <span className="inline-flex shrink-0 items-center">{icon}</span> : null}
58
+ {label}
59
+ </span>
60
+ ) : null}
61
+ <span className={cn("h-px flex-1", LINE_TONE[variant])} />
62
+ </div>
63
+ );
64
+ }
65
+
66
+ export { ChatDivider };
67
+ export type { ChatDividerProps, ChatDividerVariant };
@@ -0,0 +1,378 @@
1
+ "use client";
2
+
3
+ import { PipetteIcon, PlusIcon } from "lucide-react";
4
+ import * as React from "react";
5
+ import {
6
+ ColorArea as AriaColorArea,
7
+ ColorField as AriaColorField,
8
+ ColorPicker as AriaColorPicker,
9
+ ColorSlider as AriaColorSlider,
10
+ ColorSwatch as AriaColorSwatch,
11
+ ColorSwatchPicker as AriaColorSwatchPicker,
12
+ ColorSwatchPickerItem as AriaColorSwatchPickerItem,
13
+ ColorThumb as AriaColorThumb,
14
+ ColorPickerStateContext,
15
+ Input as AriaInput,
16
+ SliderTrack as AriaSliderTrack,
17
+ getColorChannels,
18
+ parseColor,
19
+ } from "react-aria-components";
20
+
21
+ import { cn } from "../../lib/utils/css";
22
+
23
+ /**
24
+ * A colour picker, built on React Aria's colour primitives.
25
+ *
26
+ * The one part of this kit that is not Base UI, and deliberately: choosing a colour is
27
+ * a pointer-and-keyboard problem with a saturation plane, two gradient sliders, a
28
+ * parsed text field and a channel model behind them, and React Aria is the only
29
+ * accessible implementation of that we would not otherwise be writing ourselves.
30
+ * Everything visible is still this kit's tokens and `data-slot` conventions.
31
+ *
32
+ * **Compound, so a screen takes only the parts it needs.** The four-hex-field panel in
33
+ * the office needs a swatch and a hex box; a full editor wants the plane and both
34
+ * sliders. Both are the same state, so both read `ColorPicker.Provider` and neither
35
+ * has to lift anything.
36
+ *
37
+ * ```tsx
38
+ * <ColorPicker.Provider defaultValue="#8c1515" onChange={(c) => save(c.toString("hex"))}>
39
+ * <ColorPicker.Area />
40
+ * <ColorPicker.HueSlider />
41
+ * <ColorPicker.ValueInput />
42
+ * </ColorPicker.Provider>
43
+ * ```
44
+ *
45
+ * Or the whole thing, already composed:
46
+ *
47
+ * ```tsx
48
+ * <ColorPickerPanel defaultValue="#8c1515" onChange={…} />
49
+ * ```
50
+ */
51
+
52
+ // The chequerboard behind anything that can be semi-transparent. Inline rather than a
53
+ // token because it is a texture, not a colour, and it must look identical in both
54
+ // themes — a transparency indicator that changes with the theme reads as part of the
55
+ // colour being shown.
56
+ const CHECKERBOARD =
57
+ "repeating-conic-gradient(rgba(0,0,0,.12) 0% 25%, transparent 0% 50%) 50% / 12px 12px";
58
+
59
+ function useColorPicker() {
60
+ const state = React.useContext(ColorPickerStateContext);
61
+ if (!state) {
62
+ throw new Error("useColorPicker must be used inside <ColorPicker.Provider>.");
63
+ }
64
+ return state;
65
+ }
66
+
67
+ function ColorPickerProvider(props: React.ComponentProps<typeof AriaColorPicker>) {
68
+ return <AriaColorPicker {...props} />;
69
+ }
70
+
71
+ function ColorArea({ className, ...props }: React.ComponentProps<typeof AriaColorArea>) {
72
+ return (
73
+ <AriaColorArea
74
+ className={cn(
75
+ "h-40 w-full shrink-0 rounded-lg border border-input outline-none",
76
+ "data-[disabled]:opacity-64",
77
+ className,
78
+ )}
79
+ data-slot="color-picker-area"
80
+ {...props}
81
+ >
82
+ <ColorThumb />
83
+ </AriaColorArea>
84
+ );
85
+ }
86
+
87
+ function ColorThumb({ className, ...props }: React.ComponentProps<typeof AriaColorThumb>) {
88
+ return (
89
+ <AriaColorThumb
90
+ className={cn(
91
+ // `box-shadow` rather than a border, so the ring sits *outside* the swatch and
92
+ // the pixel under the thumb is the colour actually selected.
93
+ "size-4 rounded-full border-2 border-white shadow-[0_0_0_1px_rgba(0,0,0,.35)] outline-none",
94
+ "transition-[scale,box-shadow] duration-150 ease-out",
95
+ "data-[focus-visible]:ring-[3px] data-[focus-visible]:ring-ring/32 data-[dragging]:scale-115",
96
+ className,
97
+ )}
98
+ data-slot="color-picker-thumb"
99
+ {...props}
100
+ />
101
+ );
102
+ }
103
+
104
+ function ColorSliderBase({
105
+ className,
106
+ channel,
107
+ chequered,
108
+ ...props
109
+ }: Omit<React.ComponentProps<typeof AriaColorSlider>, "channel"> & {
110
+ channel: "hue" | "alpha";
111
+ chequered?: boolean;
112
+ }) {
113
+ return (
114
+ <AriaColorSlider
115
+ channel={channel}
116
+ className={cn("w-full data-[disabled]:opacity-64", className)}
117
+ data-slot={`color-picker-${channel}-slider`}
118
+ orientation="horizontal"
119
+ {...props}
120
+ >
121
+ {/* The gradient is React Aria's, handed over as `defaultStyle` — it knows the
122
+ channel and the current colour, and recomputing it here would be a second
123
+ source of truth for the same thing. An alpha track additionally stacks the
124
+ chequerboard *under* it, which is the whole point of that track: the gradient
125
+ ends at transparent, so what shows through is what "transparent" looks like. */}
126
+ <AriaSliderTrack
127
+ className="relative h-3 w-full rounded-full border border-input"
128
+ style={({ defaultStyle }) => ({
129
+ background: chequered
130
+ ? `${defaultStyle.background}, ${CHECKERBOARD}`
131
+ : defaultStyle.background,
132
+ })}
133
+ >
134
+ <ColorThumb className="top-1/2" />
135
+ </AriaSliderTrack>
136
+ </AriaColorSlider>
137
+ );
138
+ }
139
+
140
+ function ColorHueSlider(props: Omit<React.ComponentProps<typeof AriaColorSlider>, "channel">) {
141
+ return <ColorSliderBase channel="hue" {...props} />;
142
+ }
143
+
144
+ function ColorAlphaSlider(props: Omit<React.ComponentProps<typeof AriaColorSlider>, "channel">) {
145
+ return <ColorSliderBase channel="alpha" chequered {...props} />;
146
+ }
147
+
148
+ function ColorSwatch({ className, ...props }: React.ComponentProps<typeof AriaColorSwatch>) {
149
+ return (
150
+ <AriaColorSwatch
151
+ className={cn("size-8 shrink-0 rounded-md border border-input", className)}
152
+ data-slot="color-picker-swatch"
153
+ style={{ background: CHECKERBOARD }}
154
+ {...props}
155
+ />
156
+ );
157
+ }
158
+
159
+ function ColorValueInput({
160
+ className,
161
+ ...props
162
+ }: Omit<React.ComponentProps<typeof AriaColorField>, "children">) {
163
+ return (
164
+ <AriaColorField
165
+ className={cn("min-w-0 flex-1", className)}
166
+ data-slot="color-picker-value"
167
+ {...props}
168
+ >
169
+ <AriaInput
170
+ className={cn(
171
+ "h-9 w-full min-w-0 rounded-md border border-input bg-transparent px-2.5 font-mono text-sm tabular-nums outline-none",
172
+ "placeholder:text-muted-foreground focus-visible:ring-[3px] focus-visible:ring-ring/24",
173
+ )}
174
+ />
175
+ </AriaColorField>
176
+ );
177
+ }
178
+
179
+ /**
180
+ * A grid of colours somebody can pick from, and optionally add to.
181
+ *
182
+ * `onAdd` is what makes it "saved colours" rather than a fixed palette: the plus tile
183
+ * only appears when there is somewhere for the current colour to go.
184
+ */
185
+ function ColorSavedColors({
186
+ className,
187
+ colors,
188
+ onSelect,
189
+ onAdd,
190
+ size = "md",
191
+ ...props
192
+ }: Omit<React.ComponentProps<typeof AriaColorSwatchPicker>, "onChange" | "children"> & {
193
+ colors: string[];
194
+ onSelect?: (color: string) => void;
195
+ onAdd?: () => void;
196
+ size?: "sm" | "md";
197
+ }) {
198
+ const tile = size === "sm" ? "size-6" : "size-8";
199
+
200
+ return (
201
+ <div className={cn("flex flex-wrap items-center gap-1.5", className)} data-slot="color-picker-saved">
202
+ <AriaColorSwatchPicker
203
+ className="flex flex-wrap items-center gap-1.5"
204
+ onChange={(color) => onSelect?.(color.toString("hex"))}
205
+ {...props}
206
+ >
207
+ {colors.map((color) => (
208
+ <AriaColorSwatchPickerItem
209
+ className={cn(
210
+ tile,
211
+ "rounded-md border border-input outline-none",
212
+ "data-[focus-visible]:ring-[3px] data-[focus-visible]:ring-ring/24",
213
+ "data-[selected]:ring-2 data-[selected]:ring-ring data-[selected]:ring-offset-2 data-[selected]:ring-offset-background",
214
+ )}
215
+ color={color}
216
+ key={color}
217
+ >
218
+ <AriaColorSwatch className="size-full rounded-[5px]" />
219
+ </AriaColorSwatchPickerItem>
220
+ ))}
221
+ </AriaColorSwatchPicker>
222
+ {onAdd ? (
223
+ <button
224
+ aria-label="Save this colour"
225
+ className={cn(
226
+ tile,
227
+ "flex items-center justify-center rounded-md border border-input border-dashed text-muted-foreground outline-none",
228
+ "transition-colors hover:bg-accent hover:text-foreground focus-visible:ring-[3px] focus-visible:ring-ring/24",
229
+ )}
230
+ onClick={onAdd}
231
+ type="button"
232
+ >
233
+ <PlusIcon className={size === "sm" ? "size-3" : "size-3.5"} strokeWidth={2} />
234
+ </button>
235
+ ) : null}
236
+ </div>
237
+ );
238
+ }
239
+
240
+ /**
241
+ * Pick a colour from anywhere on the screen.
242
+ *
243
+ * Renders nothing where the browser has no `EyeDropper` — Firefox and Safari do not,
244
+ * and a button that silently does nothing is worse than one that is not offered. The
245
+ * check runs in an effect rather than during render, because `window` is not there on
246
+ * the server and a hydration mismatch over a button is still a hydration mismatch.
247
+ */
248
+ function ColorEyeDropper({ className, ...props }: React.ComponentProps<"button">) {
249
+ const state = useColorPicker();
250
+ const [supported, setSupported] = React.useState(false);
251
+
252
+ React.useEffect(() => {
253
+ setSupported(typeof window !== "undefined" && "EyeDropper" in window);
254
+ }, []);
255
+
256
+ if (!supported) return null;
257
+
258
+ return (
259
+ <button
260
+ aria-label="Pick a colour from the screen"
261
+ className={cn(
262
+ "flex size-9 shrink-0 items-center justify-center rounded-md border border-input text-muted-foreground outline-none",
263
+ "transition-colors hover:bg-accent hover:text-foreground focus-visible:ring-[3px] focus-visible:ring-ring/24",
264
+ className,
265
+ )}
266
+ data-slot="color-picker-eyedropper"
267
+ onClick={async () => {
268
+ try {
269
+ // @ts-expect-error — EyeDropper is not in lib.dom yet.
270
+ const result = await new window.EyeDropper().open();
271
+ state.setColor(parseColor(result.sRGBHex));
272
+ } catch {
273
+ // The user dismissed it. Not an error, and nothing to say about it.
274
+ }
275
+ }}
276
+ type="button"
277
+ {...props}
278
+ >
279
+ <PipetteIcon className="size-4" strokeWidth={1.8} />
280
+ </button>
281
+ );
282
+ }
283
+
284
+ /** The current colour as a swatch — reads state, so it needs no props. */
285
+ function ColorPreview({ className }: { className?: string }) {
286
+ const state = useColorPicker();
287
+ return (
288
+ <AriaColorSwatch
289
+ className={cn("size-9 shrink-0 rounded-md border border-input", className)}
290
+ color={state.color}
291
+ data-slot="color-picker-preview"
292
+ style={{ background: CHECKERBOARD }}
293
+ />
294
+ );
295
+ }
296
+
297
+ /**
298
+ * The whole picker, already composed — plane, hue, alpha, eyedropper and a hex box.
299
+ *
300
+ * What a screen reaches for when it just wants "let them choose a colour". Anything
301
+ * more specific composes the parts above instead.
302
+ */
303
+ function ColorPickerPanel({
304
+ className,
305
+ withAlpha = false,
306
+ savedColors,
307
+ onSaveColor,
308
+ ...props
309
+ }: Omit<React.ComponentProps<typeof AriaColorPicker>, "children"> & {
310
+ /** Wraps the panel, not the provider — the provider renders no element. */
311
+ className?: string;
312
+ withAlpha?: boolean;
313
+ savedColors?: string[];
314
+ onSaveColor?: () => void;
315
+ }) {
316
+ return (
317
+ <ColorPickerProvider {...props}>
318
+ <div
319
+ className={cn("flex w-64 flex-col gap-3 rounded-xl border border-input bg-popover p-3", className)}
320
+ data-slot="color-picker"
321
+ >
322
+ <ColorArea colorSpace="hsb" xChannel="saturation" yChannel="brightness" />
323
+ <div className="flex items-center gap-2">
324
+ <ColorPreview />
325
+ <div className="flex min-w-0 flex-1 flex-col gap-2">
326
+ <ColorHueSlider />
327
+ {withAlpha ? <ColorAlphaSlider /> : null}
328
+ </div>
329
+ </div>
330
+ <div className="flex items-center gap-2">
331
+ <ColorEyeDropper />
332
+ <ColorValueInput />
333
+ </div>
334
+ {savedColors?.length || onSaveColor ? (
335
+ <ColorSavedColors colors={savedColors ?? []} onAdd={onSaveColor} size="sm" />
336
+ ) : null}
337
+ </div>
338
+ </ColorPickerProvider>
339
+ );
340
+ }
341
+
342
+ /**
343
+ * The compound namespace.
344
+ *
345
+ * Exported as one object rather than a dozen loose names because the parts are only
346
+ * meaningful together, and `ColorPicker.Area` says which state it reads in a way that
347
+ * `ColorArea` does not.
348
+ */
349
+ const ColorPicker = Object.assign(ColorPickerPanel, {
350
+ Provider: ColorPickerProvider,
351
+ Area: ColorArea,
352
+ Thumb: ColorThumb,
353
+ HueSlider: ColorHueSlider,
354
+ AlphaSlider: ColorAlphaSlider,
355
+ Swatch: ColorSwatch,
356
+ Preview: ColorPreview,
357
+ ValueInput: ColorValueInput,
358
+ SavedColors: ColorSavedColors,
359
+ EyeDropper: ColorEyeDropper,
360
+ });
361
+
362
+ export {
363
+ ColorPicker,
364
+ ColorPickerPanel,
365
+ ColorPickerProvider,
366
+ ColorArea,
367
+ ColorThumb,
368
+ ColorHueSlider,
369
+ ColorAlphaSlider,
370
+ ColorSwatch,
371
+ ColorPreview,
372
+ ColorValueInput,
373
+ ColorSavedColors,
374
+ ColorEyeDropper,
375
+ useColorPicker,
376
+ parseColor,
377
+ getColorChannels,
378
+ };
@@ -0,0 +1,134 @@
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+
5
+ import { cn } from "../../lib/utils/css";
6
+
7
+ type ContextMeterState = "ok" | "warn" | "summarizing" | "full";
8
+
9
+ interface ContextMeterProps extends Omit<React.SVGProps<SVGSVGElement>, "children"> {
10
+ /** Tokens currently in context. Used with `budget` when `pct` is not given. */
11
+ used?: number;
12
+ /** The model's context budget in tokens. */
13
+ budget?: number;
14
+ /** Fill fraction 0..1. Takes precedence over `used`/`budget` when provided. */
15
+ pct?: number;
16
+ /** Visual state; derived from the thresholds below when omitted. */
17
+ state?: ContextMeterState;
18
+ /** Threshold fractions — mirror the backend `config.context` values. */
19
+ warnAt?: number;
20
+ summarizeAt?: number;
21
+ lockAt?: number;
22
+ /** Diameter in px. */
23
+ size?: number;
24
+ /** Ring thickness in px. */
25
+ strokeWidth?: number;
26
+ /** Render the rounded percentage in the ring's center. */
27
+ showLabel?: boolean;
28
+ className?: string;
29
+ }
30
+
31
+ const STATE_COLOR: Record<ContextMeterState, string> = {
32
+ ok: "text-muted-foreground",
33
+ warn: "text-amber-500",
34
+ summarizing: "text-sky-500",
35
+ full: "text-destructive",
36
+ };
37
+
38
+ function deriveState(
39
+ pct: number,
40
+ warnAt: number,
41
+ summarizeAt: number,
42
+ lockAt: number,
43
+ ): ContextMeterState {
44
+ if (pct >= lockAt) return "full";
45
+ if (pct >= summarizeAt) return "summarizing";
46
+ if (pct >= warnAt) return "warn";
47
+ return "ok";
48
+ }
49
+
50
+ /**
51
+ * A compact radial gauge for how full an agent conversation's context window is.
52
+ *
53
+ * Feed it the backend `context_meter` payload (`pct`/`state` + thresholds) or a
54
+ * raw `used`/`budget` pair. The ring colors shift as the conversation approaches
55
+ * the summarize (folds older messages) and lock (must start a new chat) points,
56
+ * mirroring Claude Code's context indicator. Theme-aware and accessible
57
+ * (`role="meter"`).
58
+ */
59
+ function ContextMeter({
60
+ used,
61
+ budget,
62
+ pct,
63
+ state,
64
+ warnAt = 0.75,
65
+ summarizeAt = 0.85,
66
+ lockAt = 0.98,
67
+ size = 24,
68
+ strokeWidth = 2.5,
69
+ showLabel = false,
70
+ className,
71
+ ...props
72
+ }: ContextMeterProps) {
73
+ const fraction = Math.max(
74
+ 0,
75
+ Math.min(1, pct ?? (budget ? (used ?? 0) / budget : 0)),
76
+ );
77
+ const resolvedState = state ?? deriveState(fraction, warnAt, summarizeAt, lockAt);
78
+ const percent = Math.round(fraction * 100);
79
+
80
+ const radius = (size - strokeWidth) / 2;
81
+ const circumference = 2 * Math.PI * radius;
82
+ const dash = circumference * fraction;
83
+ const center = size / 2;
84
+
85
+ return (
86
+ <span
87
+ className={cn("relative inline-flex items-center justify-center", STATE_COLOR[resolvedState])}
88
+ data-slot="context-meter"
89
+ data-state={resolvedState}
90
+ style={{ width: size, height: size }}
91
+ >
92
+ <svg
93
+ width={size}
94
+ height={size}
95
+ viewBox={`0 0 ${size} ${size}`}
96
+ role="meter"
97
+ aria-valuemin={0}
98
+ aria-valuemax={100}
99
+ aria-valuenow={percent}
100
+ aria-label={`Context ${percent}% used`}
101
+ className={cn("-rotate-90", className)}
102
+ {...props}
103
+ >
104
+ <circle
105
+ cx={center}
106
+ cy={center}
107
+ r={radius}
108
+ fill="none"
109
+ strokeWidth={strokeWidth}
110
+ className="stroke-muted"
111
+ />
112
+ <circle
113
+ cx={center}
114
+ cy={center}
115
+ r={radius}
116
+ fill="none"
117
+ strokeWidth={strokeWidth}
118
+ strokeLinecap="round"
119
+ stroke="currentColor"
120
+ strokeDasharray={`${dash} ${circumference}`}
121
+ className="transition-[stroke-dasharray] duration-500 ease-out"
122
+ />
123
+ </svg>
124
+ {showLabel ? (
125
+ <span className="absolute font-medium text-[0.5rem] text-foreground tabular-nums">
126
+ {percent}
127
+ </span>
128
+ ) : null}
129
+ </span>
130
+ );
131
+ }
132
+
133
+ export { ContextMeter };
134
+ export type { ContextMeterProps, ContextMeterState };
package/src/index.ts CHANGED
@@ -24,12 +24,15 @@ export * from "./components/ui/card";
24
24
  export * from "./components/ui/carousel";
25
25
  export * from "./components/ui/chart";
26
26
  export * from "./components/ui/chat";
27
+ export * from "./components/ui/chat-divider";
27
28
  export * from "./components/ui/checkbox";
28
29
  export * from "./components/ui/checkbox-group";
29
30
  export * from "./components/ui/collapsible";
31
+ export * from "./components/ui/color-picker";
30
32
  export * from "./components/ui/combobox";
31
33
  export * from "./components/ui/command";
32
34
  export * from "./components/ui/confetti";
35
+ export * from "./components/ui/context-meter";
33
36
  export * from "./components/ui/credit-card";
34
37
  export * from "./components/ui/data-table-paged";
35
38
  export * from "./components/ui/drawer";