@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/dist/index.d.ts +124 -1
- package/dist/index.js +452 -79
- package/dist/index.js.map +1 -1
- package/package.json +2 -1
- package/src/components/ui/chat-divider.tsx +67 -0
- package/src/components/ui/color-picker.tsx +378 -0
- package/src/components/ui/context-meter.tsx +134 -0
- package/src/index.ts +3 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cntyclub/ui-react",
|
|
3
|
-
"version": "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";
|