@preset.nz/ux-kit 0.0.0-stage → 0.1.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 (57) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +117 -2
  3. package/THIRD_PARTY_NOTICES.md +253 -0
  4. package/package.json +50 -3
  5. package/src/chat/index.ts +9 -0
  6. package/src/chat/message.tsx +80 -0
  7. package/src/chat/prompt-input.tsx +121 -0
  8. package/src/chat/reasoning.tsx +182 -0
  9. package/src/chat/scroller.tsx +147 -0
  10. package/src/chat/suggestion.tsx +65 -0
  11. package/src/chat/tool.tsx +143 -0
  12. package/src/chat/types.ts +8 -0
  13. package/src/components/avatar.tsx +35 -0
  14. package/src/components/badge.tsx +40 -0
  15. package/src/components/breadcrumb.tsx +100 -0
  16. package/src/components/button.tsx +56 -0
  17. package/src/components/card.tsx +75 -0
  18. package/src/components/checkbox.tsx +25 -0
  19. package/src/components/color-field.tsx +261 -0
  20. package/src/components/color-swatch.tsx +52 -0
  21. package/src/components/context-menu.tsx +249 -0
  22. package/src/components/dialog.tsx +133 -0
  23. package/src/components/dropdown-menu.tsx +249 -0
  24. package/src/components/empty-state.tsx +35 -0
  25. package/src/components/input.tsx +20 -0
  26. package/src/components/label.tsx +19 -0
  27. package/src/components/navigation-menu.tsx +150 -0
  28. package/src/components/number-field.tsx +432 -0
  29. package/src/components/popover.tsx +75 -0
  30. package/src/components/select.tsx +189 -0
  31. package/src/components/separator.tsx +21 -0
  32. package/src/components/sheet.tsx +132 -0
  33. package/src/components/side-panel.tsx +189 -0
  34. package/src/components/sidebar.tsx +698 -0
  35. package/src/components/skeleton.tsx +14 -0
  36. package/src/components/slider.tsx +71 -0
  37. package/src/components/snackbar.tsx +143 -0
  38. package/src/components/status-bar.tsx +45 -0
  39. package/src/components/tabs.tsx +75 -0
  40. package/src/components/textarea.tsx +18 -0
  41. package/src/components/toggle-group.tsx +86 -0
  42. package/src/components/toggle.tsx +43 -0
  43. package/src/components/toolbar.tsx +304 -0
  44. package/src/components/tooltip.tsx +54 -0
  45. package/src/graphics/adsr.ts +78 -0
  46. package/src/graphics/curve-editor.tsx +567 -0
  47. package/src/graphics/curve-edits.ts +146 -0
  48. package/src/graphics/index.ts +16 -0
  49. package/src/graphics/scale.ts +25 -0
  50. package/src/graphics/use-canvas.ts +56 -0
  51. package/src/graphics/use-tokens.ts +35 -0
  52. package/src/icons.ts +3 -0
  53. package/src/index.css +199 -0
  54. package/src/index.ts +35 -0
  55. package/src/lib/use-mobile.ts +18 -0
  56. package/src/lib/utils.ts +6 -0
  57. package/src/shadcn-tailwind.css +665 -0
@@ -0,0 +1,304 @@
1
+ import { Toolbar as ToolbarPrimitive } from "@base-ui/react/toolbar"
2
+ import * as React from "react"
3
+
4
+ import { cn } from "../lib/utils"
5
+ import { buttonVariants } from "./button"
6
+ import {
7
+ DropdownMenu,
8
+ DropdownMenuContent,
9
+ DropdownMenuItem,
10
+ DropdownMenuShortcut,
11
+ DropdownMenuTrigger,
12
+ } from "./dropdown-menu"
13
+ import { Tooltip, TooltipContent, TooltipTrigger } from "./tooltip"
14
+
15
+ // Toolbar: props and callbacks only, no app state. The rules (native-apps.md,
16
+ // ux-patterns.md): icon only, tooltip with name and shortcut, groups split by
17
+ // separators, toggles show a pressed state, and every item is a command that
18
+ // also lives in the menu. The toolbar is a subset of the menu, not a second
19
+ // home for commands.
20
+ //
21
+ // Two ways in. Data-driven: `<Toolbar groups={[[item, item], [item]]} onCommand={run} />`
22
+ // where `id` is the menu's command id. Composed: `ToolbarGroup`, `ToolbarSeparator`,
23
+ // `ToolbarSpacer` and `ToolbarButton` as children, for anything the table cannot say.
24
+ //
25
+ // Roving focus and arrow-key navigation come from Base UI's Toolbar. Disabled
26
+ // buttons stay focusable and keep their tooltip, which is how a gated item
27
+ // says why it is gated (Oblique's `disabledHint`, M&T's `disabledReason`).
28
+
29
+ /** An entry in a dropdown item's menu. */
30
+ interface ToolbarMenuEntry {
31
+ id: string
32
+ label: string
33
+ shortcut?: string
34
+ /** Default true. */
35
+ enabled?: boolean
36
+ }
37
+
38
+ /** One toolbar item. `id` is the command id shared with the menu. */
39
+ interface ToolbarItemSpec {
40
+ id: string
41
+ label: string
42
+ icon: React.ReactNode
43
+ /** Shown in the tooltip, in the platform's own notation ("⌘S"). */
44
+ shortcut?: string
45
+ /** Default true. */
46
+ enabled?: boolean
47
+ /** Replaces the shortcut in the tooltip while the item is disabled. */
48
+ disabledReason?: string
49
+ /** Present (true or false) makes the item a toggle. Absent means a plain button. */
50
+ pressed?: boolean
51
+ /** Present makes the item a dropdown; picking an entry calls `onCommand(entry.id)`. */
52
+ menu?: ToolbarMenuEntry[]
53
+ }
54
+
55
+ function Toolbar({ className, ...props }: React.ComponentProps<typeof ToolbarPrimitive.Root>) {
56
+ return (
57
+ <ToolbarPrimitive.Root
58
+ data-slot="toolbar"
59
+ className={cn(
60
+ "flex items-center gap-1 border-b border-border bg-background px-2 py-1 data-[orientation=vertical]:w-fit data-[orientation=vertical]:flex-col data-[orientation=vertical]:border-r data-[orientation=vertical]:border-b-0",
61
+ className,
62
+ )}
63
+ {...props}
64
+ />
65
+ )
66
+ }
67
+
68
+ function ToolbarGroup({
69
+ className,
70
+ ...props
71
+ }: React.ComponentProps<typeof ToolbarPrimitive.Group>) {
72
+ return (
73
+ <ToolbarPrimitive.Group
74
+ data-slot="toolbar-group"
75
+ className={cn("flex items-center gap-0.5 data-[orientation=vertical]:flex-col", className)}
76
+ {...props}
77
+ />
78
+ )
79
+ }
80
+
81
+ function ToolbarSeparator({
82
+ className,
83
+ ...props
84
+ }: React.ComponentProps<typeof ToolbarPrimitive.Separator>) {
85
+ return (
86
+ <ToolbarPrimitive.Separator
87
+ data-slot="toolbar-separator"
88
+ className={cn(
89
+ "mx-1 h-5 w-px shrink-0 bg-border data-[orientation=horizontal]:mx-0 data-[orientation=horizontal]:my-1 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-5",
90
+ className,
91
+ )}
92
+ {...props}
93
+ />
94
+ )
95
+ }
96
+
97
+ /** Pushes what follows to the far end (the right-panel toggle sits there). */
98
+ function ToolbarSpacer({ className, ...props }: React.ComponentProps<"div">) {
99
+ return <div data-slot="toolbar-spacer" className={cn("flex-1", className)} {...props} />
100
+ }
101
+
102
+ function TooltipBody({
103
+ label,
104
+ shortcut,
105
+ disabledReason,
106
+ }: Pick<ToolbarItemSpec, "label" | "shortcut" | "disabledReason">) {
107
+ return (
108
+ <>
109
+ <span>{label}</span>
110
+ {disabledReason ? (
111
+ <span className="opacity-70">{disabledReason}</span>
112
+ ) : shortcut ? (
113
+ <kbd data-slot="kbd" className="rounded-sm bg-background/20 px-1 font-sans text-[11px]">
114
+ {shortcut}
115
+ </kbd>
116
+ ) : null}
117
+ </>
118
+ )
119
+ }
120
+
121
+ const buttonClass = (pressed?: boolean) =>
122
+ cn(
123
+ buttonVariants({ variant: "ghost", size: "icon" }),
124
+ "data-disabled:cursor-default data-disabled:opacity-40 data-disabled:hover:bg-transparent",
125
+ pressed && "bg-muted text-foreground",
126
+ )
127
+
128
+ interface ToolbarButtonProps
129
+ extends Omit<ToolbarItemSpec, "id" | "menu">,
130
+ Omit<React.ComponentProps<typeof ToolbarPrimitive.Button>, "children" | "aria-label"> {}
131
+
132
+ /** One icon button: plain, or a toggle when `pressed` is given. Tooltip shows label and shortcut. */
133
+ function ToolbarButton({
134
+ label,
135
+ icon,
136
+ shortcut,
137
+ enabled = true,
138
+ disabledReason,
139
+ pressed,
140
+ className,
141
+ ...props
142
+ }: ToolbarButtonProps) {
143
+ return (
144
+ <Tooltip>
145
+ <TooltipTrigger
146
+ render={
147
+ <ToolbarPrimitive.Button
148
+ data-slot="toolbar-button"
149
+ aria-label={label}
150
+ aria-pressed={pressed}
151
+ disabled={!enabled}
152
+ className={cn(buttonClass(pressed), className)}
153
+ {...props}
154
+ />
155
+ }
156
+ >
157
+ {icon}
158
+ </TooltipTrigger>
159
+ <TooltipContent side="bottom">
160
+ <TooltipBody
161
+ label={label}
162
+ shortcut={shortcut}
163
+ disabledReason={enabled ? undefined : disabledReason}
164
+ />
165
+ </TooltipContent>
166
+ </Tooltip>
167
+ )
168
+ }
169
+
170
+ interface ToolbarDropdownProps extends Omit<ToolbarItemSpec, "id" | "menu" | "pressed"> {
171
+ entries: ToolbarMenuEntry[]
172
+ onCommand?: (id: string) => void
173
+ }
174
+
175
+ /** An icon button that opens a menu of commands. Entries are menu items, so they show shortcuts too. */
176
+ function ToolbarDropdown({
177
+ label,
178
+ icon,
179
+ shortcut,
180
+ enabled = true,
181
+ disabledReason,
182
+ entries,
183
+ onCommand,
184
+ }: ToolbarDropdownProps) {
185
+ return (
186
+ <DropdownMenu>
187
+ <Tooltip>
188
+ <TooltipTrigger
189
+ render={
190
+ <DropdownMenuTrigger
191
+ render={
192
+ <ToolbarPrimitive.Button
193
+ data-slot="toolbar-dropdown"
194
+ aria-label={label}
195
+ disabled={!enabled}
196
+ className={buttonClass()}
197
+ />
198
+ }
199
+ />
200
+ }
201
+ >
202
+ {icon}
203
+ </TooltipTrigger>
204
+ <TooltipContent side="bottom">
205
+ <TooltipBody
206
+ label={label}
207
+ shortcut={shortcut}
208
+ disabledReason={enabled ? undefined : disabledReason}
209
+ />
210
+ </TooltipContent>
211
+ </Tooltip>
212
+ <DropdownMenuContent align="start">
213
+ {entries.map((e) => (
214
+ <DropdownMenuItem
215
+ key={e.id}
216
+ disabled={e.enabled === false}
217
+ onClick={() => onCommand?.(e.id)}
218
+ >
219
+ {e.label}
220
+ {e.shortcut ? <DropdownMenuShortcut>{e.shortcut}</DropdownMenuShortcut> : null}
221
+ </DropdownMenuItem>
222
+ ))}
223
+ </DropdownMenuContent>
224
+ </DropdownMenu>
225
+ )
226
+ }
227
+
228
+ /** Renders one spec as a button, toggle or dropdown. */
229
+ function ToolbarItem({
230
+ item,
231
+ onCommand,
232
+ }: {
233
+ item: ToolbarItemSpec
234
+ onCommand?: (id: string) => void
235
+ }) {
236
+ if (item.menu) {
237
+ return <ToolbarDropdown {...item} entries={item.menu} onCommand={onCommand} />
238
+ }
239
+ return <ToolbarButton {...item} onClick={() => onCommand?.(item.id)} />
240
+ }
241
+
242
+ interface ToolbarItemsProps extends Omit<React.ComponentProps<typeof Toolbar>, "children"> {
243
+ /** Groups of items; a separator is drawn between groups. */
244
+ groups: ToolbarItemSpec[][]
245
+ /** Called with the item's (or menu entry's) id. The app runs the same command the menu does. */
246
+ onCommand?: (id: string) => void
247
+ /** Pinned to the far left, before the groups (the left-panel toggle). */
248
+ leading?: ToolbarItemSpec[]
249
+ /** Pinned to the far right (the right-panel toggle). */
250
+ trailing?: ToolbarItemSpec[]
251
+ /** Extra content between the groups and the trailing items: a slot for anything custom. */
252
+ children?: React.ReactNode
253
+ }
254
+
255
+ /** The data-driven form: a toolbar from an item table. */
256
+ function ToolbarItems({
257
+ groups,
258
+ onCommand,
259
+ leading,
260
+ trailing,
261
+ children,
262
+ ...props
263
+ }: ToolbarItemsProps) {
264
+ const lead = leading?.length ? [leading] : []
265
+ const all = [...lead, ...groups.filter((g) => g.length > 0)]
266
+ return (
267
+ <Toolbar {...props}>
268
+ {all.map((group, i) => (
269
+ // biome-ignore lint/suspicious/noArrayIndexKey: groups are anonymous lists rebuilt each render and never reordered
270
+ <React.Fragment key={i}>
271
+ {i > 0 && <ToolbarSeparator />}
272
+ <ToolbarGroup>
273
+ {group.map((item) => (
274
+ <ToolbarItem key={item.id} item={item} onCommand={onCommand} />
275
+ ))}
276
+ </ToolbarGroup>
277
+ </React.Fragment>
278
+ ))}
279
+ {children}
280
+ {trailing?.length ? (
281
+ <>
282
+ <ToolbarSpacer />
283
+ <ToolbarGroup>
284
+ {trailing.map((item) => (
285
+ <ToolbarItem key={item.id} item={item} onCommand={onCommand} />
286
+ ))}
287
+ </ToolbarGroup>
288
+ </>
289
+ ) : null}
290
+ </Toolbar>
291
+ )
292
+ }
293
+
294
+ export type { ToolbarItemSpec, ToolbarMenuEntry }
295
+ export {
296
+ Toolbar,
297
+ ToolbarButton,
298
+ ToolbarDropdown,
299
+ ToolbarGroup,
300
+ ToolbarItem,
301
+ ToolbarItems,
302
+ ToolbarSeparator,
303
+ ToolbarSpacer,
304
+ }
@@ -0,0 +1,54 @@
1
+ import { Tooltip as TooltipPrimitive } from "@base-ui/react/tooltip"
2
+
3
+ import { cn } from "../lib/utils"
4
+
5
+ // Base UI: `delay` (ms) replaces Radix's `delayDuration`. Triggers take
6
+ // `render={<Button />}` where Radix used `asChild`.
7
+ function TooltipProvider({ delay = 0, ...props }: TooltipPrimitive.Provider.Props) {
8
+ return <TooltipPrimitive.Provider data-slot="tooltip-provider" delay={delay} {...props} />
9
+ }
10
+
11
+ function Tooltip({ ...props }: TooltipPrimitive.Root.Props) {
12
+ return <TooltipPrimitive.Root data-slot="tooltip" {...props} />
13
+ }
14
+
15
+ function TooltipTrigger({ ...props }: TooltipPrimitive.Trigger.Props) {
16
+ return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
17
+ }
18
+
19
+ function TooltipContent({
20
+ className,
21
+ side = "top",
22
+ sideOffset = 4,
23
+ align = "center",
24
+ alignOffset = 0,
25
+ children,
26
+ ...props
27
+ }: TooltipPrimitive.Popup.Props &
28
+ Pick<TooltipPrimitive.Positioner.Props, "align" | "alignOffset" | "side" | "sideOffset">) {
29
+ return (
30
+ <TooltipPrimitive.Portal>
31
+ <TooltipPrimitive.Positioner
32
+ align={align}
33
+ alignOffset={alignOffset}
34
+ side={side}
35
+ sideOffset={sideOffset}
36
+ className="isolate z-50"
37
+ >
38
+ <TooltipPrimitive.Popup
39
+ data-slot="tooltip-content"
40
+ className={cn(
41
+ "z-50 inline-flex w-fit max-w-xs origin-(--transform-origin) items-center gap-1.5 rounded-md bg-foreground px-3 py-1.5 text-xs text-background has-data-[slot=kbd]:pr-1.5 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 **:data-[slot=kbd]:relative **:data-[slot=kbd]:isolate **:data-[slot=kbd]:z-50 **:data-[slot=kbd]:rounded-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
42
+ className,
43
+ )}
44
+ {...props}
45
+ >
46
+ {children}
47
+ <TooltipPrimitive.Arrow className="z-50 size-2.5 rotate-45 rounded-[2px] bg-foreground fill-foreground data-[side=bottom]:top-1 data-[side=left]:top-1/2! data-[side=left]:-right-1 data-[side=left]:-translate-y-1/2 data-[side=right]:top-1/2! data-[side=right]:-left-1 data-[side=right]:-translate-y-1/2 data-[side=top]:-bottom-2.5" />
48
+ </TooltipPrimitive.Popup>
49
+ </TooltipPrimitive.Positioner>
50
+ </TooltipPrimitive.Portal>
51
+ )
52
+ }
53
+
54
+ export { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger }
@@ -0,0 +1,78 @@
1
+ import { type Curve, clamp } from "@preset.nz/math"
2
+
3
+ import type { CurveConstraint } from "./curve-edits"
4
+
5
+ /** An ADSR envelope's four controls. Times are in the curve's x units; sustain is a level, 0..1. */
6
+ export interface Adsr {
7
+ attack: number
8
+ decay: number
9
+ sustain: number
10
+ release: number
11
+ }
12
+
13
+ /**
14
+ * The curve an ADSR draws: start at rest, peak, sustain level, end at rest, with the sustain
15
+ * mark on the third point. Shard's sliders and the editor both write these four points.
16
+ */
17
+ export function adsrCurve({ attack, decay, sustain, release }: Adsr): Curve {
18
+ const a = Math.max(0, attack)
19
+ const d = Math.max(0, decay)
20
+ const r = Math.max(0, release)
21
+ const s = clamp(sustain, 0, 1)
22
+ return {
23
+ points: [
24
+ { x: 0, y: 0, basis: "linear" },
25
+ { x: a, y: 1, basis: "linear" },
26
+ { x: a + d, y: s, basis: "linear" },
27
+ { x: a + d + r, y: 0, basis: "linear" },
28
+ ],
29
+ sustain: 2,
30
+ }
31
+ }
32
+
33
+ /** The ADSR a curve draws, or `null` when it no longer has that shape. */
34
+ export function adsrOf(curve: Curve): Adsr | null {
35
+ const [p0, p1, p2, p3] = curve.points
36
+ if (curve.points.length !== 4 || !p0 || !p1 || !p2 || !p3 || curve.sustain !== 2) return null
37
+ if (p0.x !== 0 || p0.y !== 0 || p1.y !== 1 || p3.y !== 0) return null
38
+ return { attack: p1.x - p0.x, decay: p2.x - p1.x, sustain: p2.y, release: p3.x - p2.x }
39
+ }
40
+
41
+ /**
42
+ * The limits that keep a curve an ADSR. No points are added or removed and sustain stays on the
43
+ * third point. The start is pinned at rest, the peak's level at 1 and the end's at rest. Moving
44
+ * a point changes only the stage that ends there, and the later points ride along, so a longer
45
+ * attack doesn't eat the decay. The whole shape stays within `maxX`.
46
+ */
47
+ export function constrainAdsr(maxX = Infinity): CurveConstraint {
48
+ return (next, prev) => {
49
+ if (next.points.length !== 4 || prev.points.length !== 4 || next.sustain !== 2) return null
50
+ const [n0, n1, n2, n3] = next.points
51
+ const [p0, p1, p2, p3] = prev.points
52
+ if (!(n0 && n1 && n2 && n3 && p0 && p1 && p2 && p3)) return null
53
+ const [wa, wd, wr] = durations(p0, p1, p2, p3)
54
+ const [na, nd, nr] = durations(n0, n1, n2, n3)
55
+ // The stage whose end moved takes the new length; the others keep theirs.
56
+ const moved = [n1.x !== p1.x, n2.x !== p2.x, n3.x !== p3.x].indexOf(true) + 1
57
+ const len = [
58
+ moved === 1 ? Math.max(0, na) : wa,
59
+ moved === 2 ? Math.max(0, nd) : wd,
60
+ moved === 3 ? Math.max(0, nr) : wr,
61
+ ] as [number, number, number]
62
+ const over = len[0] + len[1] + len[2] - maxX
63
+ if (over > 0 && moved > 0) len[moved - 1] = Math.max(0, (len[moved - 1] ?? 0) - over)
64
+ const ys = [0, 1, clamp(n2.y, 0, 1), 0]
65
+ let x = 0
66
+ const points = next.points.map((p, i) => {
67
+ if (i > 0) x += len[i - 1] ?? 0
68
+ return { ...p, x, y: ys[i] ?? p.y }
69
+ })
70
+ return { points, sustain: 2 }
71
+ }
72
+ }
73
+
74
+ type Point = Curve["points"][number]
75
+
76
+ function durations(p0: Point, p1: Point, p2: Point, p3: Point): [number, number, number] {
77
+ return [p1.x - p0.x, p2.x - p1.x, p3.x - p2.x]
78
+ }