@workerdeck/ui 0.6.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 (61) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +74 -0
  3. package/build/index.d.mts +927 -0
  4. package/build/index.mjs +5236 -0
  5. package/build/index.mjs.map +1 -0
  6. package/package.json +73 -0
  7. package/src/components/agent/Composer.tsx +139 -0
  8. package/src/components/agent/Conversation.tsx +59 -0
  9. package/src/components/agent/FileCard.tsx +50 -0
  10. package/src/components/agent/Loader.tsx +21 -0
  11. package/src/components/agent/Message.tsx +44 -0
  12. package/src/components/agent/ModelSelect.tsx +87 -0
  13. package/src/components/agent/PermissionModeSelect.tsx +94 -0
  14. package/src/components/agent/PermissionPrompt.tsx +52 -0
  15. package/src/components/agent/QuestionPrompt.tsx +193 -0
  16. package/src/components/agent/Reasoning.tsx +58 -0
  17. package/src/components/agent/Response.tsx +31 -0
  18. package/src/components/agent/SessionList.tsx +93 -0
  19. package/src/components/agent/SessionPanel.tsx +149 -0
  20. package/src/components/agent/StatusBar.tsx +140 -0
  21. package/src/components/agent/ToolCallCard.tsx +94 -0
  22. package/src/components/agent/Transcript.tsx +114 -0
  23. package/src/components/agent/status.ts +16 -0
  24. package/src/components/prompt-area/animated-placeholder.tsx +42 -0
  25. package/src/components/prompt-area/clipboard-helpers.ts +206 -0
  26. package/src/components/prompt-area/cursor-helpers.ts +244 -0
  27. package/src/components/prompt-area/dom-helpers.ts +721 -0
  28. package/src/components/prompt-area/file-strip.tsx +250 -0
  29. package/src/components/prompt-area/html-to-markdown.ts +278 -0
  30. package/src/components/prompt-area/image-strip.tsx +49 -0
  31. package/src/components/prompt-area/index.ts +23 -0
  32. package/src/components/prompt-area/prompt-area-engine.ts +705 -0
  33. package/src/components/prompt-area/prompt-area-list-ops.ts +499 -0
  34. package/src/components/prompt-area/prompt-area.tsx +375 -0
  35. package/src/components/prompt-area/remove-button.tsx +37 -0
  36. package/src/components/prompt-area/segment-helpers.ts +62 -0
  37. package/src/components/prompt-area/trigger-popover.tsx +139 -0
  38. package/src/components/prompt-area/trigger-presets.ts +143 -0
  39. package/src/components/prompt-area/types.ts +360 -0
  40. package/src/components/prompt-area/use-markdown-mode.ts +113 -0
  41. package/src/components/prompt-area/use-prompt-area-events.ts +470 -0
  42. package/src/components/prompt-area/use-prompt-area-state.ts +131 -0
  43. package/src/components/prompt-area/use-prompt-area.ts +1507 -0
  44. package/src/components/prompt-area/use-trigger-search.ts +115 -0
  45. package/src/components/ui/AlertDialog.tsx +56 -0
  46. package/src/components/ui/Badge.tsx +42 -0
  47. package/src/components/ui/Button.tsx +47 -0
  48. package/src/components/ui/Card.tsx +29 -0
  49. package/src/components/ui/CodeBlock.tsx +31 -0
  50. package/src/components/ui/CopyButton.tsx +28 -0
  51. package/src/components/ui/Input.tsx +20 -0
  52. package/src/components/ui/ProgressRing.tsx +49 -0
  53. package/src/components/ui/Select.tsx +80 -0
  54. package/src/components/ui/Sonner.tsx +22 -0
  55. package/src/components/ui/Spinner.tsx +6 -0
  56. package/src/components/ui/Textarea.tsx +21 -0
  57. package/src/components/ui/Tooltip.tsx +34 -0
  58. package/src/index.ts +99 -0
  59. package/src/lib/format.ts +67 -0
  60. package/src/lib/utils.ts +33 -0
  61. package/src/styles/theme.css +413 -0
@@ -0,0 +1,115 @@
1
+ 'use client'
2
+
3
+ import { useCallback, useEffect, useRef, useState } from 'react'
4
+ import type { TriggerConfig, TriggerSuggestion } from './types.ts'
5
+
6
+ // ---------------------------------------------------------------------------
7
+ // Types
8
+ // ---------------------------------------------------------------------------
9
+
10
+ type UseTriggerSearchReturn = {
11
+ suggestions: TriggerSuggestion[]
12
+ suggestionsLoading: boolean
13
+ suggestionsError: string | null
14
+ /** Run a search for the given query using the trigger's onSearch config. */
15
+ search: (query: string, config: TriggerConfig) => void
16
+ /** Cancel any in-flight search and reset state. */
17
+ reset: () => void
18
+ }
19
+
20
+ // ---------------------------------------------------------------------------
21
+ // Hook
22
+ // ---------------------------------------------------------------------------
23
+
24
+ /**
25
+ * Manages async trigger search lifecycle: debouncing, AbortController
26
+ * cancellation, race-condition prevention, loading/error state.
27
+ *
28
+ * Extracted from `usePromptArea` so the main hook stays focused on
29
+ * editing concerns while this hook owns the data-fetching side.
30
+ */
31
+ export function useTriggerSearch(): UseTriggerSearchReturn {
32
+ const [suggestions, setSuggestions] = useState<TriggerSuggestion[]>([])
33
+ const [suggestionsLoading, setSuggestionsLoading] = useState(false)
34
+ const [suggestionsError, setSuggestionsError] = useState<string | null>(null)
35
+
36
+ // Version counter – belt-and-suspenders alongside AbortController
37
+ const searchVersion = useRef(0)
38
+ // AbortController for cancelling in-flight async searches
39
+ const abortController = useRef<AbortController | null>(null)
40
+ // Debounce timer for search queries
41
+ const debounceTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
42
+
43
+ const reset = useCallback(() => {
44
+ abortController.current?.abort()
45
+ if (debounceTimer.current) clearTimeout(debounceTimer.current)
46
+ setSuggestions([])
47
+ setSuggestionsLoading(false)
48
+ setSuggestionsError(null)
49
+ }, [])
50
+
51
+ const search = useCallback((query: string, config: TriggerConfig) => {
52
+ if (!config.onSearch) return
53
+
54
+ // Cancel any previous in-flight request and pending debounce
55
+ abortController.current?.abort()
56
+ if (debounceTimer.current) clearTimeout(debounceTimer.current)
57
+
58
+ setSuggestionsLoading(true)
59
+ setSuggestionsError(null)
60
+ searchVersion.current++
61
+ const version = searchVersion.current
62
+
63
+ const controller = new AbortController()
64
+ abortController.current = controller
65
+ const { onSearch, onSearchError, searchDebounceMs } = config
66
+
67
+ const executeSearch = () => {
68
+ const result = onSearch(query, { signal: controller.signal })
69
+
70
+ if (result instanceof Promise) {
71
+ void result.then(
72
+ (items) => {
73
+ if (controller.signal.aborted || searchVersion.current !== version) return
74
+ setSuggestions(items)
75
+ setSuggestionsLoading(false)
76
+ },
77
+ (error: unknown) => {
78
+ if (controller.signal.aborted || searchVersion.current !== version) return
79
+ // Silently ignore AbortError (expected when superseded)
80
+ if (error instanceof DOMException && error.name === 'AbortError') return
81
+ setSuggestionsError(error instanceof Error ? error.message : 'Search failed')
82
+ setSuggestionsLoading(false)
83
+ onSearchError?.(error)
84
+ },
85
+ )
86
+ } else {
87
+ setSuggestions(result)
88
+ setSuggestionsLoading(false)
89
+ }
90
+ }
91
+
92
+ // Debounce subsequent searches but fire immediately for the initial empty query
93
+ if (searchDebounceMs && searchDebounceMs > 0 && query.length > 0) {
94
+ debounceTimer.current = setTimeout(executeSearch, searchDebounceMs)
95
+ } else {
96
+ executeSearch()
97
+ }
98
+ }, [])
99
+
100
+ // Clean up on unmount
101
+ useEffect(() => {
102
+ return () => {
103
+ abortController.current?.abort()
104
+ if (debounceTimer.current) clearTimeout(debounceTimer.current)
105
+ }
106
+ }, [])
107
+
108
+ return {
109
+ suggestions,
110
+ suggestionsLoading,
111
+ suggestionsError,
112
+ search,
113
+ reset,
114
+ }
115
+ }
@@ -0,0 +1,56 @@
1
+ import { type FunctionComponent } from 'react'
2
+ import { AlertDialog as AlertDialogPrimitive } from '@base-ui/react/alert-dialog'
3
+ import { cn } from '../../lib/utils.ts'
4
+
5
+ export const AlertDialog = AlertDialogPrimitive.Root
6
+ export const AlertDialogTrigger = AlertDialogPrimitive.Trigger
7
+ export const AlertDialogClose = AlertDialogPrimitive.Close
8
+
9
+ export const AlertDialogContent: FunctionComponent<AlertDialogPrimitive.Popup.Props> = ({
10
+ className,
11
+ children,
12
+ ...props
13
+ }) => (
14
+ <AlertDialogPrimitive.Portal>
15
+ <AlertDialogPrimitive.Backdrop
16
+ className={cn(
17
+ 'fixed inset-0 z-70 bg-black/40 backdrop-blur-[1px]',
18
+ 'transition-opacity duration-(--motion-base)',
19
+ 'data-starting-style:opacity-0 data-ending-style:opacity-0',
20
+ )}
21
+ />
22
+ <AlertDialogPrimitive.Popup
23
+ data-slot='alert-dialog-content'
24
+ className={cn(
25
+ 'fixed top-1/2 left-1/2 z-70 w-[min(28rem,calc(100vw-2rem))] -translate-x-1/2 -translate-y-1/2',
26
+ 'rounded-lg border border-border bg-surface p-5 shadow-(--shadow-lg) outline-none',
27
+ 'transition-[opacity,transform] duration-(--motion-base)',
28
+ 'data-starting-style:scale-95 data-starting-style:opacity-0',
29
+ 'data-ending-style:scale-95 data-ending-style:opacity-0',
30
+ className,
31
+ )}
32
+ {...props}>
33
+ {children}
34
+ </AlertDialogPrimitive.Popup>
35
+ </AlertDialogPrimitive.Portal>
36
+ )
37
+
38
+ export const AlertDialogTitle: FunctionComponent<AlertDialogPrimitive.Title.Props> = ({
39
+ className,
40
+ ...props
41
+ }) => (
42
+ <AlertDialogPrimitive.Title
43
+ className={cn('text-heading-3 font-semibold text-text', className)}
44
+ {...props}
45
+ />
46
+ )
47
+
48
+ export const AlertDialogDescription: FunctionComponent<AlertDialogPrimitive.Description.Props> = ({
49
+ className,
50
+ ...props
51
+ }) => (
52
+ <AlertDialogPrimitive.Description
53
+ className={cn('mt-1.5 text-body-sm text-muted-foreground', className)}
54
+ {...props}
55
+ />
56
+ )
@@ -0,0 +1,42 @@
1
+ import { type HTMLAttributes } from 'react'
2
+ import { type VariantProps, cva } from 'class-variance-authority'
3
+ import { cn } from '../../lib/utils.ts'
4
+
5
+ const badgeVariants = cva(
6
+ 'inline-flex items-center gap-1.5 rounded-full border px-2 py-0.5 text-label font-medium whitespace-nowrap',
7
+ {
8
+ variants: {
9
+ variant: {
10
+ neutral: 'border-border bg-surface text-fg-2',
11
+ accent: 'border-transparent bg-accent-bg text-fg-1',
12
+ success: 'border-transparent bg-success-bg text-success',
13
+ warning: 'border-transparent bg-warning-bg text-warning',
14
+ danger: 'border-transparent bg-danger-bg text-danger',
15
+ info: 'border-transparent bg-info-bg text-info',
16
+ },
17
+ mono: {
18
+ true: 'font-mono text-code',
19
+ false: '',
20
+ },
21
+ },
22
+ defaultVariants: { variant: 'neutral', mono: false },
23
+ },
24
+ )
25
+
26
+ export interface BadgeProps
27
+ extends HTMLAttributes<HTMLSpanElement>,
28
+ VariantProps<typeof badgeVariants> {
29
+ /** Render a leading status dot in the variant's color. */
30
+ dot?: boolean
31
+ }
32
+
33
+ export function Badge({ className, variant = 'neutral', mono, dot, children, ...props }: BadgeProps) {
34
+ return (
35
+ <span data-slot='badge' className={cn(badgeVariants({ variant, mono, className }))} {...props}>
36
+ {dot ? <span aria-hidden className='size-1.5 rounded-full bg-current' /> : null}
37
+ {children}
38
+ </span>
39
+ )
40
+ }
41
+
42
+ export { badgeVariants }
@@ -0,0 +1,47 @@
1
+ import { type ButtonHTMLAttributes, forwardRef } from 'react'
2
+ import { type VariantProps, cva } from 'class-variance-authority'
3
+ import { cn } from '../../lib/utils.ts'
4
+
5
+ const buttonVariants = cva(
6
+ "group/button inline-flex shrink-0 items-center justify-center rounded-md border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-colors outline-none select-none focus-visible:ring-2 focus-visible:ring-ring/60 focus-visible:ring-offset-1 focus-visible:ring-offset-bg disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
7
+ {
8
+ variants: {
9
+ variant: {
10
+ default: 'bg-primary text-primary-foreground hover:bg-accent-hover',
11
+ outline: 'border-border bg-surface hover:border-border-strong hover:bg-surface-hover',
12
+ secondary: 'bg-secondary text-secondary-foreground hover:bg-surface-hover',
13
+ ghost: 'text-fg-2 hover:bg-surface-hover hover:text-foreground',
14
+ destructive: 'text-danger hover:bg-danger-bg',
15
+ link: 'text-primary underline-offset-4 hover:underline',
16
+ },
17
+ size: {
18
+ 'default': 'h-8 gap-1.5 px-3',
19
+ 'xs': "h-6 gap-1 px-2 text-xs [&_svg:not([class*='size-'])]:size-3",
20
+ 'sm': 'h-7 gap-1 px-2.5 text-xs',
21
+ 'lg': 'h-9 gap-1.5 px-3.5',
22
+ 'icon': 'size-8',
23
+ 'icon-sm': 'size-7',
24
+ },
25
+ },
26
+ defaultVariants: { variant: 'default', size: 'default' },
27
+ },
28
+ )
29
+
30
+ export interface ButtonProps
31
+ extends ButtonHTMLAttributes<HTMLButtonElement>,
32
+ VariantProps<typeof buttonVariants> {}
33
+
34
+ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
35
+ ({ className, variant = 'default', size = 'default', type = 'button', ...props }, ref) => (
36
+ <button
37
+ ref={ref}
38
+ type={type}
39
+ data-slot='button'
40
+ className={cn(buttonVariants({ variant, size, className }))}
41
+ {...props}
42
+ />
43
+ ),
44
+ )
45
+ Button.displayName = 'Button'
46
+
47
+ export { buttonVariants }
@@ -0,0 +1,29 @@
1
+ import { type HTMLAttributes } from 'react'
2
+ import { cn } from '../../lib/utils.ts'
3
+
4
+ export function Card({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
5
+ return (
6
+ <div
7
+ data-slot='card'
8
+ className={cn('rounded-lg border border-border bg-surface', className)}
9
+ {...props}
10
+ />
11
+ )
12
+ }
13
+
14
+ export function CardHeader({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
15
+ return (
16
+ <div
17
+ className={cn('flex items-start justify-between gap-3 px-4 pt-3.5 pb-3', className)}
18
+ {...props}
19
+ />
20
+ )
21
+ }
22
+
23
+ export function CardTitle({ className, ...props }: HTMLAttributes<HTMLHeadingElement>) {
24
+ return <h3 className={cn('text-heading-3 font-semibold text-text', className)} {...props} />
25
+ }
26
+
27
+ export function CardContent({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
28
+ return <div className={cn('px-4 pb-4', className)} {...props} />
29
+ }
@@ -0,0 +1,31 @@
1
+ import type { ReactNode } from 'react'
2
+ import { CopyButton } from './CopyButton.tsx'
3
+ import { cn } from '../../lib/utils.ts'
4
+
5
+ export interface CodeBlockProps {
6
+ code: string
7
+ /** Header label, e.g. a language or "Parameters". */
8
+ label?: ReactNode
9
+ copyable?: boolean
10
+ className?: string
11
+ }
12
+
13
+ /** Plain (unhighlighted) code panel for structured data like tool inputs. Markdown code
14
+ * inside assistant responses is highlighted by <Response> instead. */
15
+ export function CodeBlock({ code, label, copyable = true, className }: CodeBlockProps) {
16
+ return (
17
+ <div
18
+ data-slot='code-block'
19
+ className={cn('overflow-hidden rounded-md border border-border bg-code-bg', className)}>
20
+ {label !== undefined || copyable ? (
21
+ <div className='flex h-8 items-center justify-between border-b border-border px-2.5'>
22
+ <span className='font-mono text-label text-fg-3'>{label}</span>
23
+ {copyable ? <CopyButton value={code} /> : null}
24
+ </div>
25
+ ) : null}
26
+ <pre className='max-h-64 overflow-auto px-3 py-2 font-mono text-label whitespace-pre-wrap text-fg-2'>
27
+ {code}
28
+ </pre>
29
+ </div>
30
+ )
31
+ }
@@ -0,0 +1,28 @@
1
+ import { useState } from 'react'
2
+ import { Check, Copy } from 'lucide-react'
3
+ import { Button, type ButtonProps } from './Button.tsx'
4
+ import { cn } from '../../lib/utils.ts'
5
+
6
+ export interface CopyButtonProps extends Omit<ButtonProps, 'onClick' | 'children'> {
7
+ value: string
8
+ }
9
+
10
+ export function CopyButton({ value, className, variant = 'ghost', size = 'icon-sm', ...props }: CopyButtonProps) {
11
+ const [copied, setCopied] = useState(false)
12
+ return (
13
+ <Button
14
+ variant={variant}
15
+ size={size}
16
+ aria-label='Copy'
17
+ className={cn('text-fg-3', className)}
18
+ onClick={() => {
19
+ void navigator.clipboard.writeText(value).then(() => {
20
+ setCopied(true)
21
+ setTimeout(() => setCopied(false), 1500)
22
+ })
23
+ }}
24
+ {...props}>
25
+ {copied ? <Check className='size-3.5 text-success' /> : <Copy className='size-3.5' />}
26
+ </Button>
27
+ )
28
+ }
@@ -0,0 +1,20 @@
1
+ import { type InputHTMLAttributes, forwardRef } from 'react'
2
+ import { cn } from '../../lib/utils.ts'
3
+
4
+ export const Input = forwardRef<HTMLInputElement, InputHTMLAttributes<HTMLInputElement>>(
5
+ ({ className, ...props }, ref) => (
6
+ <input
7
+ ref={ref}
8
+ data-slot='input'
9
+ className={cn(
10
+ 'h-8 w-full rounded-md border border-border bg-bg px-2.5 text-body-sm text-text',
11
+ 'placeholder:text-fg-4 transition-colors outline-none',
12
+ 'hover:border-border-strong focus-visible:border-ring focus-visible:ring-2 focus-visible:ring-ring/40',
13
+ 'disabled:pointer-events-none disabled:opacity-50',
14
+ className,
15
+ )}
16
+ {...props}
17
+ />
18
+ ),
19
+ )
20
+ Input.displayName = 'Input'
@@ -0,0 +1,49 @@
1
+ import { cn } from '../../lib/utils.ts'
2
+
3
+ export interface ProgressRingProps {
4
+ /** Filled share, 0–100 (clamped). */
5
+ value: number
6
+ /** Outer diameter in px. */
7
+ size?: number
8
+ strokeWidth?: number
9
+ className?: string
10
+ }
11
+
12
+ /** Tiny SVG progress circle; stroke color comes from `currentColor` so callers set it
13
+ * via text color classes (e.g. warning/danger past thresholds). */
14
+ export function ProgressRing({ value, size = 13, strokeWidth = 2, className }: ProgressRingProps) {
15
+ const clamped = Math.min(100, Math.max(0, value))
16
+ const radius = (size - strokeWidth) / 2
17
+ const circumference = 2 * Math.PI * radius
18
+ return (
19
+ <svg
20
+ data-slot='progress-ring'
21
+ width={size}
22
+ height={size}
23
+ viewBox={`0 0 ${size} ${size}`}
24
+ role='img'
25
+ aria-label={`${Math.round(clamped)}%`}
26
+ className={cn('shrink-0 -rotate-90', className)}>
27
+ <circle
28
+ cx={size / 2}
29
+ cy={size / 2}
30
+ r={radius}
31
+ fill='none'
32
+ stroke='currentColor'
33
+ strokeOpacity={0.2}
34
+ strokeWidth={strokeWidth}
35
+ />
36
+ <circle
37
+ cx={size / 2}
38
+ cy={size / 2}
39
+ r={radius}
40
+ fill='none'
41
+ stroke='currentColor'
42
+ strokeWidth={strokeWidth}
43
+ strokeLinecap='round'
44
+ strokeDasharray={circumference}
45
+ strokeDashoffset={circumference * (1 - clamped / 100)}
46
+ />
47
+ </svg>
48
+ )
49
+ }
@@ -0,0 +1,80 @@
1
+ import { type FunctionComponent } from 'react'
2
+ import { Select as SelectPrimitive } from '@base-ui/react/select'
3
+ import { Check, ChevronsUpDown } from 'lucide-react'
4
+ import { cn } from '../../lib/utils.ts'
5
+
6
+ export const Select = SelectPrimitive.Root
7
+ export const SelectValue = SelectPrimitive.Value
8
+ export const SelectItemText = SelectPrimitive.ItemText
9
+
10
+ export const SelectTrigger: FunctionComponent<SelectPrimitive.Trigger.Props> = ({
11
+ className,
12
+ children,
13
+ ...props
14
+ }) => (
15
+ <SelectPrimitive.Trigger
16
+ data-slot='select-trigger'
17
+ className={cn(
18
+ 'inline-flex h-7 items-center justify-between gap-1.5 rounded-md border border-border bg-bg px-2 text-body-sm text-text',
19
+ 'transition-colors outline-none hover:border-border-strong focus-visible:border-ring focus-visible:ring-2 focus-visible:ring-ring/40',
20
+ 'data-popup-open:border-border-strong disabled:pointer-events-none disabled:opacity-50',
21
+ className,
22
+ )}
23
+ {...props}>
24
+ {children}
25
+ <SelectPrimitive.Icon className='text-fg-4'>
26
+ <ChevronsUpDown className='size-3.5' />
27
+ </SelectPrimitive.Icon>
28
+ </SelectPrimitive.Trigger>
29
+ )
30
+
31
+ export const SelectContent: FunctionComponent<
32
+ SelectPrimitive.Popup.Props &
33
+ Pick<SelectPrimitive.Positioner.Props, 'align' | 'alignItemWithTrigger' | 'side' | 'sideOffset'>
34
+ > = ({
35
+ className,
36
+ align = 'start',
37
+ alignItemWithTrigger = false,
38
+ side = 'bottom',
39
+ sideOffset = 6,
40
+ ...props
41
+ }) => (
42
+ <SelectPrimitive.Portal>
43
+ <SelectPrimitive.Positioner
44
+ align={align}
45
+ alignItemWithTrigger={alignItemWithTrigger}
46
+ side={side}
47
+ sideOffset={sideOffset}
48
+ className='isolate z-60 outline-none'>
49
+ <SelectPrimitive.Popup
50
+ data-slot='select-content'
51
+ className={cn(
52
+ 'max-h-[min(24rem,var(--available-height))] min-w-[var(--anchor-width)] overflow-y-auto',
53
+ 'rounded-md border border-border bg-surface p-1 text-fg-1 shadow-(--shadow-lg) outline-none',
54
+ className,
55
+ )}
56
+ {...props}
57
+ />
58
+ </SelectPrimitive.Positioner>
59
+ </SelectPrimitive.Portal>
60
+ )
61
+
62
+ export const SelectItem: FunctionComponent<SelectPrimitive.Item.Props> = ({
63
+ className,
64
+ children,
65
+ ...props
66
+ }) => (
67
+ <SelectPrimitive.Item
68
+ data-slot='select-item'
69
+ className={cn(
70
+ 'flex cursor-pointer items-start gap-2 rounded-sm px-2 py-1.5 text-body-sm text-text outline-none select-none',
71
+ 'data-highlighted:bg-surface-hover',
72
+ className,
73
+ )}
74
+ {...props}>
75
+ <span className='flex min-w-0 flex-1 flex-col gap-0.5'>{children}</span>
76
+ <SelectPrimitive.ItemIndicator className='mt-0.5 text-fg-1'>
77
+ <Check className='size-3.5' />
78
+ </SelectPrimitive.ItemIndicator>
79
+ </SelectPrimitive.Item>
80
+ )
@@ -0,0 +1,22 @@
1
+ import { Toaster as SonnerToaster, toast } from 'sonner'
2
+
3
+ export { toast }
4
+
5
+ /** Token-themed toaster; relies on the [data-theme] swap, so no `theme` prop needed. */
6
+ export function Toaster() {
7
+ return (
8
+ <SonnerToaster
9
+ position='bottom-right'
10
+ toastOptions={{
11
+ style: {
12
+ background: 'var(--surface)',
13
+ color: 'var(--text)',
14
+ border: '1px solid var(--border)',
15
+ boxShadow: 'var(--shadow-md)',
16
+ fontFamily: 'var(--cw-font-sans)',
17
+ fontSize: 'var(--text-body-sm)',
18
+ },
19
+ }}
20
+ />
21
+ )
22
+ }
@@ -0,0 +1,6 @@
1
+ import { LoaderCircle } from 'lucide-react'
2
+ import { cn } from '../../lib/utils.ts'
3
+
4
+ export function Spinner({ className }: { className?: string }) {
5
+ return <LoaderCircle aria-label='Loading' className={cn('size-4 animate-spin text-fg-3', className)} />
6
+ }
@@ -0,0 +1,21 @@
1
+ import { type TextareaHTMLAttributes, forwardRef } from 'react'
2
+ import { cn } from '../../lib/utils.ts'
3
+
4
+ export const Textarea = forwardRef<
5
+ HTMLTextAreaElement,
6
+ TextareaHTMLAttributes<HTMLTextAreaElement>
7
+ >(({ className, ...props }, ref) => (
8
+ <textarea
9
+ ref={ref}
10
+ data-slot='textarea'
11
+ className={cn(
12
+ 'w-full resize-none rounded-md border border-border bg-bg px-2.5 py-2 text-body-sm text-text',
13
+ 'placeholder:text-fg-4 transition-colors outline-none',
14
+ 'hover:border-border-strong focus-visible:border-ring focus-visible:ring-2 focus-visible:ring-ring/40',
15
+ 'disabled:pointer-events-none disabled:opacity-50',
16
+ className,
17
+ )}
18
+ {...props}
19
+ />
20
+ ))
21
+ Textarea.displayName = 'Textarea'
@@ -0,0 +1,34 @@
1
+ import { type FunctionComponent, type ReactNode } from 'react'
2
+ import { Tooltip as TooltipPrimitive } from '@base-ui/react/tooltip'
3
+ import { cn } from '../../lib/utils.ts'
4
+
5
+ export const TooltipProvider = TooltipPrimitive.Provider
6
+
7
+ export const TooltipContent: FunctionComponent<
8
+ TooltipPrimitive.Popup.Props & Pick<TooltipPrimitive.Positioner.Props, 'side' | 'sideOffset'>
9
+ > = ({ className, side = 'top', sideOffset = 6, ...props }) => (
10
+ <TooltipPrimitive.Portal>
11
+ <TooltipPrimitive.Positioner side={side} sideOffset={sideOffset} className='isolate z-60'>
12
+ <TooltipPrimitive.Popup
13
+ data-slot='tooltip-content'
14
+ className={cn(
15
+ 'rounded-md border border-border bg-surface px-2 py-1 text-label text-fg-2 shadow-(--shadow-md) outline-none',
16
+ className,
17
+ )}
18
+ {...props}
19
+ />
20
+ </TooltipPrimitive.Positioner>
21
+ </TooltipPrimitive.Portal>
22
+ )
23
+
24
+ /** Convenience wrapper: <Tip content="..."><Button/></Tip> */
25
+ export function Tip({ content, children }: { content: ReactNode; children: ReactNode }) {
26
+ return (
27
+ <TooltipPrimitive.Root>
28
+ <TooltipPrimitive.Trigger render={<span className='inline-flex' />}>
29
+ {children}
30
+ </TooltipPrimitive.Trigger>
31
+ <TooltipContent>{content}</TooltipContent>
32
+ </TooltipPrimitive.Root>
33
+ )
34
+ }