@vvedantb/ui 0.30.1 → 0.31.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/README.md CHANGED
@@ -260,6 +260,17 @@ import {
260
260
  <FadePresence show={visible} variants={fadeUpSpring}>…</FadePresence>
261
261
  ```
262
262
 
263
+ - `Button` `shape` (0.31.0, a default change): `"pill"` (default) is `rounded-full` for every variant and size, so the icon sizes are circles. `"rounded"` keeps the radius from before 0.31.0: `rounded-lg`, and `rounded-md` on `xs` and `icon-xs`. Use `shape="rounded"` for buttons that join other edges (inside a `ButtonGroup`) or sit in square chrome. A `className` radius still wins over both. `InputGroupButton`, the message branch arrows and the sidebar `AccountTrigger`, `MobileMenuButton` and `PanelClose` already use `"rounded"`. With the default `--radius` (1rem), buttons 32px tall or less were already fully round, so the visible change is on `default`, `lg` and `icon`.
264
+
265
+ ```tsx
266
+ <Button>Save</Button> {/* pill */}
267
+ <Button size="icon" aria-label="Add"><IconPlus /></Button> {/* circle */}
268
+ <ButtonGroup>
269
+ <Button variant="outline" shape="rounded">Day</Button>
270
+ <Button variant="outline" shape="rounded">Week</Button>
271
+ </ButtonGroup>
272
+ ```
273
+
263
274
  - `Button`: variants `flat`, `success` and `warning` (each is `ghost` plus an override). `loading` disables the button, sets `data-loading="true"` and shows a spinner in place of `startContent`; without `loading` there is no `data-loading` attribute. `endContent` renders after the children. Both are ignored with `asChild` (`loading` still disables).
264
275
  - `DropdownMenuContent`: `fitViewport` caps the height on small screens and scrolls. It reads the optional `--vv-height` CSS variable, which the app may set to the visual viewport height (falls back to `70dvh`). `suppressAltKey` stops a bare Alt press from moving focus. `restoreScrollLockOnClose` calls `restoreModalScrollLock` on close (now and on the next tick). `ContextMenuContent` takes `suppressAltKey` too.
265
276
  - `DropdownMenuItem` `endContent` and `ContextMenuItem` `startContent`, `description` and `endContent`: the label moves into a growing column (`min-w-0 flex-1`), the description sits under it and `endContent` sits at the end. `DropdownMenuSubTrigger` and `ContextMenuSubTrigger` take `startContent`.
@@ -438,7 +449,7 @@ Convention: compound parts are static members of a namespace object, `<Namespace
438
449
  | --- | --- |
439
450
  | `Root` | No DOM. Holds `isDesktop`, `mobileOpen`, `onMobileOpenChange`, `collapsed`, `panelHidden` and the drawer `id`. Closes the drawer at the desktop breakpoint. The chrome parts need it. |
440
451
  | `MobileHeader` | Fixed header below `md`, a three-column grid (menu button, title, actions). Escape closes the drawer. Keep all three children so the columns line up. |
441
- | `MobileMenuButton` | Opens the drawer. `label`, `icon` (`(open) => node`). |
452
+ | `MobileMenuButton` | Opens the drawer. `label`, `icon` (`(open) => node`). Keeps `rounded-lg` (`Button` `shape="rounded"`); change it with `className`. |
442
453
  | `MobileTitle` | Centred `<h1>` that truncates. Without children, an empty `<span>` keeps the middle column. |
443
454
  | `MobileActions` | Trailing header actions. Without children, a spacer that keeps the title centred. |
444
455
  | `Backdrop` | Drawer backdrop; a click closes the drawer. `label` (`"Close navigation"`). |
@@ -452,11 +463,11 @@ Convention: compound parts are static members of a namespace object, `<Namespace
452
463
  | `Panel` | Panel column. `collapsed` (`md:hidden`) and `panelHidden` (renders nothing) default to the Root values. |
453
464
  | `PanelHeader` | Padded header row for `PanelTitle` and `PanelActions`. |
454
465
  | `PanelTitle` / `PanelActions` | The `<h2>` and the trailing actions. |
455
- | `PanelClose` | Ghost close button. `label` (`"Close navigation"`). Without `onClick` it closes the drawer. |
466
+ | `PanelClose` | Ghost close button. `label` (`"Close navigation"`). Without `onClick` it closes the drawer. Keeps `rounded-lg` (`Button` `shape="rounded"`); change it with `className`. |
456
467
  | `NavItem` | Panel nav row (`sidebarNavItemClass`). `active` (fill, `font-medium`, `aria-current="page"`), `iconOnly`, `icon` (element; gets `sidebarNavIconClass`), `asChild` for a router link. A `<button type="button">` without `asChild`. 0.7.2: `selected`, `indent`, `dragging`, `dropTarget`; see [Sidebar nav row parts (0.7.2)](#sidebar-nav-row-parts-072). |
457
468
  | `NavItemLeading` / `NavItemLabel` / `NavItemTrailing` / `NavItemActions` | Row parts (0.7.2): leading slot, truncated label, always-visible trailing content and hover-revealed actions. `<span>`, or the child with `asChild`. |
458
469
  | `AccountMenu` | Dropdown root. `badge` adds the unread dot (`AccountBadge`) in a `relative` wrapper. |
459
- | `AccountTrigger` | Rail-tile-sized avatar button. `userName` (default `title` and `aria-label`, and the initials), `imageUrl`, plus `Button` props; native `name` goes to the button. |
470
+ | `AccountTrigger` | Rail-tile-sized avatar button. `userName` (default `title` and `aria-label`, and the initials), `imageUrl`, plus `Button` props except `asChild` and `shape` (it keeps the rail tile `rounded-lg`); native `name` goes to the button. |
460
471
  | `AccountAvatar` | Avatar image or initials. `userName`, `imageUrl`. |
461
472
  | `AccountContent` | Menu content. Defaults `align="center"`, `side="right"`, `sideOffset={8}`, `w-56`. |
462
473
  | `AccountLabel` | Identity label: avatar, `userName`, optional `email`, `stacked`. |
@@ -1196,9 +1207,10 @@ Generic pieces lifted from an app. Components that use `m` need the app's `LazyM
1196
1207
 
1197
1208
  ```tsx
1198
1209
  import {
1199
- ErrorBoundary, ListEnter, useFirstPaintGate, CountPop, countLabel, UnreadDot,
1210
+ ErrorBoundary, ListEnter, useFirstPaintGate, CountPop, countLabel, UnreadDot, RollingNumber,
1200
1211
  MarqueeOnHover, RelativeDateTime, ImageLightbox, ImageGalleryPreview, ScreenshotPreview, VideoPreview,
1201
- useMediaQuery, useDocumentVisible, useIdleCallback, useSingleFlight, usePersistedScrollParent,
1212
+ useMediaQuery, useDocumentVisible, useDocumentAppearance, useIdleCallback, useSingleFlight, usePersistedScrollParent,
1213
+ useCountUpDisplay, parseCountable,
1202
1214
  convexErrorMessage, userFacingErrorMessage, useHeldResult,
1203
1215
  } from "@vvedantb/ui";
1204
1216
  ```
@@ -1206,12 +1218,16 @@ import {
1206
1218
  - `ErrorBoundary` (`fallback` node or `(retry) => node`, `onError`): contains a failure to its subtree; `retry` clears the error.
1207
1219
  - `ListEnter` (`index`, `fast`, `firstPaint`, `staggerStep`, `staggerMax`, `slide`, `as` `div | tr | li`) and `useFirstPaintGate()`: first-paint fade and 8px slide, staggered with `staggerDelay`. Pass `firstPaint={gate.current}` so recycled rows do not restagger.
1208
1220
  - `CountPop` (`label`, `className`, `children`): pop-in and shrink-out for a live count, re-popping on each change. `countLabel(n)` gives `null`, the number or `"99+"`. `UnreadDot` (`show`): a `bg-primary` dot on `CountPop`.
1221
+ - `RollingNumber` (`value`, `className`): shows an already formatted string ("1m 05s", "42%", "$1.20") and rolls each digit to its new value; other characters swap at once. Columns are keyed from the right, so "9s" → "10s" mounts only the new column. The full value stays in an `sr-only` span. For numbers that tick while the user watches; `CountPop` is for a badge that changes while the user looks elsewhere.
1209
1222
  - `MarqueeOnHover`: a single-line label that scrolls its hidden tail into view on hover (or on hover of a `group` ancestor). Import `@vvedantb/ui/tokens/css/marquee.css`; `--mq-delay` sets the dwell.
1210
1223
  - `RelativeDateTime` (`at`, `emptyLabel` default "—", `exactFormat` default "DD/MM/YYYY HH:mm"): "15m" / "2d" with the exact time in a tooltip.
1211
1224
  - `ImageLightbox` (`images`, `index` or `null`, `onIndexChange`, `onClose`): fullscreen viewer with arrow keys, buttons and swipe. `ImageGalleryPreview` (`images`): up to a 2×2 grid with a "+N" tile. `ScreenshotPreview` (`url`, `alt`) and `VideoPreview` (`url`).
1212
- - Hooks: `useMediaQuery(query)`, `useDocumentVisible()`, `useIdleCallback(ms, fn)` (trailing debounce; `createIdleCallback` is the React-free core), `useSingleFlight(fn)` (one call at a time, latest args run next), `usePersistedScrollParent(storageKey)` (restores a container's `scrollTop` from `sessionStorage`).
1225
+ - Hooks: `useMediaQuery(query)`, `useDocumentVisible()`, `useDocumentAppearance()` (`"light" | "dark"` from `<html>`'s `dark` class or `data-theme`; `readDocumentAppearance` / `subscribeDocumentAppearance` are the React-free core), `useIdleCallback(ms, fn)` (trailing debounce; `createIdleCallback` is the React-free core), `useSingleFlight(fn)` (one call at a time, latest args run next), `usePersistedScrollParent(storageKey)` (restores a container's `scrollTop` from `sessionStorage`).
1226
+ - `useCountUpDisplay(value)`: counts a number, or a label with one number ("45%", "12h", "$1.20"), from the last value to the new one over `tokenTransition.base`, keeping the prefix, suffix and decimal places. Where `RollingNumber` rolls each digit, this counts through the numbers between. The first value counts up from 0, and the same amount again does not replay. Labels it cannot count ("1h 12m", "—") show as they are, and so does every value while `PageMotionProvider` is `disabled`. `parseCountable(value)` is the parser: `{ amount, prefix, suffix, decimals }` or `null`.
1213
1227
  - Convex: `convexErrorMessage(error, fallback)` reads `ConvexError` data first; `userFacingErrorMessage(error, fallback)` strips the client envelope and stack. `useHeldResult(result, key)` keeps the last result while a query is skipped (`key` `null`). None of these import `convex`.
1214
1228
 
1229
+ Fixture for `RollingNumber`, `SendQueueIcon`, `PromptInputSubmit` `queued`, the `ActivityTasks` clock and `useCountUpDisplay`: `examples/motion-primitives/`.
1230
+
1215
1231
  ## Chrome extensions (0.28.0)
1216
1232
 
1217
1233
  Extension pages (popup, side panel, options, welcome) run under the MV3 extension-page CSP, which blocks remote font stylesheets, and should not load `@vvedantb/server`. Three pieces cover them. Web apps change nothing unless they opt in.
@@ -1349,7 +1365,7 @@ const config = {
1349
1365
  } satisfies UiConfig;
1350
1366
  ```
1351
1367
 
1352
- - `Button`: `classes.base`, `classes.variant.<variant>`, `classes.size.<size>`. New size `xs` (`h-7 rounded-md px-2 text-xs hit-target`, 14px icons).
1368
+ - `Button`: `classes.base`, `classes.variant.<variant>`, `classes.size.<size>`, `classes.shape.<pill|rounded>` (0.31.0). New size `xs` (`h-7 px-2 text-xs hit-target`, 14px icons). Since 0.31.0 the radius is not in `base` or the size strings: `classes.shape` sets it, one string per shape for every size (built in: `pill` `rounded-full`; `rounded` `rounded-lg`, or `rounded-md` on `xs` and `icon-xs`).
1353
1369
  - `Badge`: `classes.base`, `classes.variant.<variant>`.
1354
1370
  - `Input`, `Textarea`: `classes.base`.
1355
1371
  - `Checkbox`: `classes.root`, `classes.indicator`, `icon`. New prop `icon`: the indicator content (default a 14px check). The `CheckboxProps` type is exported.
@@ -1497,6 +1513,7 @@ const config = {
1497
1513
  Outside a `StickToBottom` (for example a page outline) use `JumpRail` with `items` (`{ id, title, preview?, level? }`), a `scrollRef` or `getScrollElement`, and `targetAttribute="data-toc-id"` or `resolveTarget`. `onReveal` runs when a target is not in the DOM; `onBeforeScroll` runs just before it. `onSelect(id)` runs after a click or Enter/Space pick, once the scroll has started or `onReveal` has run, so an app can move focus to the target for keyboard users; `ChatJumpRail` accepts it too. `observeKey` re-observes when the rendered window changes. The geometry helpers (`resolveJumpRailTopPercent`, `resolveJumpRailIndexFromPointer`, `resolveJumpRailHeightStyle`, `resolveJumpRailTickWidthClass`, `resolveJumpRailTickLevelStyle`) are exported.
1498
1514
  - `Message`: `classes.root`, `classes.user`, `classes.assistant` (the role classes of `Message`), `classes.content` (all `MessageContent` classes), `classes.branchSelector`, `classes.branchIcon` (a class in place of the arrows' `size={14}`), and `lazyActionTooltips`: a `MessageAction` mounts its tooltip on first hover or focus (focus opens it), not with the button.
1499
1515
  - `PromptInput`: `classes.group` (the input group, default `overflow-hidden`), `classes.groupIdle` / `classes.groupDragOver` (added while no drag / an accepted drag is over the form), `classes.groupDefault` (added when no `containerClassName` is given), `classes.textarea`, `classes.footer`, `classes.submit`, `classes.tabItem`; `submitIcons.<send|submitted|streaming|error>`, `submitCrossfade` (cross-fade the submit icon on status change) and `menuTriggerAsChild` (`PromptInputActionMenuTrigger` becomes the Radix trigger instead of a button inside it). New `PromptInput` props: `dataDrop` (`{ type, onDrop }`: a non-file drag of that MIME type counts as a drag over the form and `onDrop` gets its string payload) and `inputGroupClassName` (same as `containerClassName`). The attachments context adds `replace(id, file)`; `accept` now matches extension patterns such as `.html` by file name; `useOptionalPromptInputController` is exported.
1516
+ - `PromptInputSubmit`: new prop `queued` (0.30.0). Set it when a send would join a queue (the agent is still working). The send icon becomes `SendQueueIcon`, an up arrow that morphs into a stack of bars while `queued` is `true`. Leave it unset to keep `submitIcons.send`. `SendQueueIcon` (`queued`, `className` default `size-4`) is exported for other send buttons. It uses `m`, so `queued` needs the app's `LazyMotion`.
1500
1517
  - `PromptInputSettings` (new): an overflow menu with a "Model" submenu (one radio list, or a submenu per provider). Entry: `providerLabel(provider)` (default the id), `providerIcon(provider)` (default none), `classes.trigger`.
1501
1518
  - `TraitsPanel` (new, with `isUltrathinkPrompt`, `applyUltrathinkPrefix`): reasoning, speed, context and thinking segments for a model picker; inside a `PromptInputProvider` it offers "Ultrathink" by editing the prompt. Entry: `classes.label`, `classes.empty`, `classes.option`, `classes.optionSelected`, `classes.optionIdle`, `classes.note`.
1502
1519
  - `PromptInputSpeech`, `useSpeechRecognition`, `getSpeechRecognition` (new): Web Speech API dictation; the button renders nothing without the API.
@@ -1518,7 +1535,7 @@ New dependency (installed with the package): `katex`. `streamdown` and the `@str
1518
1535
 
1519
1536
  AI surfaces that came from an app, exported from the root. Fills use `surface-secondary` (`bg-surface-secondary`, `from-surface-secondary/40`), which is the app's `--muted` under `compat-shadcn-rgb.css` and the secondary surface under `tokens.css`. They also use the shadcn colour names `bg-secondary`, `bg-popover` and `bg-card` and a few app utilities (`motion-press`, `smooth-shadow-*`, `scrollbar`, `scrollbar-none`, `text-2xs`, `text-3xs`), so they look as intended with `compat-shadcn-rgb.css` and an app that defines those utilities. They merge a caller `className` with `UiConfig.cn` when set.
1520
1537
 
1521
- - `ActivityTasks` (`steps`, `isStreaming`, `name`, `startedAt`, `duration`, `layout` `folded | inline`, `onOpenFile`, `onRequestFullDetail`): an agent turn as a timeline. Tool runs fold behind one summary line, reasoning and mid-turn text render as Markdown, subtasks nest their steps, long timelines cap behind "Show N more". `OpenSubagentContext` gives subtask rows a "View" link.
1538
+ - `ActivityTasks` (`steps`, `isStreaming`, `name`, `startedAt`, `duration`, `layout` `folded | inline`, `onOpenFile`, `onRequestFullDetail`): an agent turn as a timeline. Tool runs fold behind one summary line, reasoning and mid-turn text render as Markdown, subtasks nest their steps, long timelines cap behind "Show N more". While `isStreaming` and before the first row, a header shows the time since `startedAt` as a `RollingNumber` clock. `OpenSubagentContext` gives subtask rows a "View" link.
1522
1539
  - The step model: `ActivityStep`, `ActivityTodoItem`, `ActivityQuestion`, `ActivityQuestionOption`, `ActivityStepOutput`, `ActivityStepEdit`, `activityStepHasRichDetail`, `activityStepConfig` (glyph and fallback label per step type), `ActivityThinkingIcon`, `useActivitySpinnerVerb`, `getRandomActivityVerb`, `useActivityElapsedSeconds`, `formatActivityElapsed`, `useStreamingDisclosure`.
1523
1540
  - Tool-call details show files with `FileChip` and output in a `CodeBlock` (`plain`) with its copy button, so the `CodeBlock` entry (see "App config: AI chat") styles those frames too.
1524
1541
  - `ActivityQuestionCard` (`questions`, `answers`): the read-only record of a multiple-choice question and its answer.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vvedantb/ui",
3
- "version": "0.30.1",
3
+ "version": "0.31.0",
4
4
  "license": "MIT",
5
5
  "description": "Shared React 19 UI for web apps: one main JavaScript entry (primitives, shell pieces, app helpers including the Convex and Clerk provider, VITE env parsing and ensure-user, artifact viewers over the @vvedantb/server artifacts core, native tokens), a client entry without @vvedantb/server, convex or Clerk for Chrome extensions, design-token CSS, shared Tailwind v4 CSS, a Tailwind preset and packaged Instrument Sans fonts.",
6
6
  "type": "module",
@@ -238,19 +238,23 @@ export function AccountAvatar({ imageUrl, userName, className }: SidebarAccountA
238
238
  );
239
239
  }
240
240
 
241
- export type SidebarAccountTriggerProps = Omit<ButtonProps, "asChild"> & {
241
+ export type SidebarAccountTriggerProps = Omit<ButtonProps, "asChild" | "shape"> & {
242
242
  /** Default `title` and `aria-label`, and the avatar initials. Native `name` goes to the button. */
243
243
  userName: string;
244
244
  imageUrl?: string;
245
245
  };
246
246
 
247
- /** Rail-tile-sized avatar button that opens the menu. Children replace the avatar. */
247
+ /**
248
+ * Rail-tile-sized avatar button that opens the menu. Children replace the avatar.
249
+ * A rail tile (`rounded-lg`), so `shape="rounded"`; change the radius with `className`.
250
+ */
248
251
  export function AccountTrigger({ userName, imageUrl, className, children, ...props }: SidebarAccountTriggerProps) {
249
252
  return (
250
253
  <DropdownMenuTrigger asChild>
251
254
  <Button
252
255
  type="button"
253
256
  variant="ghost"
257
+ shape="rounded"
254
258
  title={userName}
255
259
  aria-label={`Account menu for ${userName}`}
256
260
  {...props}
@@ -110,14 +110,14 @@ function MobileHeader({ className, onKeyDown, ...props }: SidebarMobileHeaderPro
110
110
 
111
111
  const defaultMobileMenuIcon = () => <IconMenu2 size={20} />;
112
112
 
113
- export type SidebarMobileMenuButtonProps = Omit<ButtonProps, "children"> & {
113
+ export type SidebarMobileMenuButtonProps = Omit<ButtonProps, "children" | "shape"> & {
114
114
  /** `aria-label`. Default `"Open navigation menu"`. */
115
115
  label?: string;
116
116
  /** Icon for the drawer state. Default a 20px menu icon. */
117
117
  icon?: (open: boolean) => ReactNode;
118
118
  };
119
119
 
120
- /** Opens the drawer. */
120
+ /** Opens the drawer. `shape="rounded"` (`rounded-lg`); change the radius with `className`. */
121
121
  function MobileMenuButton({
122
122
  label = "Open navigation menu",
123
123
  icon = defaultMobileMenuIcon,
@@ -131,6 +131,7 @@ function MobileMenuButton({
131
131
  type="button"
132
132
  variant="ghost"
133
133
  size="icon"
134
+ shape="rounded"
134
135
  {...props}
135
136
  onClick={(event) => {
136
137
  onClick?.(event);
@@ -120,12 +120,15 @@ export function PanelActions({ className, ...props }: SidebarPanelActionsProps)
120
120
  return <div {...props} className={cn("flex items-center justify-end gap-0.5", className)} />;
121
121
  }
122
122
 
123
- export type SidebarPanelCloseProps = ButtonProps & {
123
+ export type SidebarPanelCloseProps = Omit<ButtonProps, "shape"> & {
124
124
  /** `aria-label`. Default `"Close navigation"`. */
125
125
  label?: string;
126
126
  };
127
127
 
128
- /** Ghost close button. Without `onClick` it closes the `Sidebar.Root` drawer. */
128
+ /**
129
+ * Ghost close button. Without `onClick` it closes the `Sidebar.Root` drawer.
130
+ * `shape="rounded"` (`rounded-lg`); change the radius with `className`.
131
+ */
129
132
  export function PanelClose({ label = "Close navigation", onClick, className, children, ...props }: SidebarPanelCloseProps) {
130
133
  const sidebar = useOptionalSidebar();
131
134
  return (
@@ -133,6 +136,7 @@ export function PanelClose({ label = "Close navigation", onClick, className, chi
133
136
  type="button"
134
137
  variant="ghost"
135
138
  size="icon-sm"
139
+ shape="rounded"
136
140
  {...props}
137
141
  onClick={onClick ?? (() => sidebar?.onMobileOpenChange(false))}
138
142
  aria-label={label}
@@ -2,10 +2,10 @@
2
2
 
3
3
  exports[`vibot Queue widget DOM > renders the closed widget 1`] = `"<div class="w-full overflow-hidden" style="height: auto; opacity: 1;"><button type="button" class="flex items-center justify-between w-full px-4 py-2.5 bg-surface-secondary text-foreground border-0 shadow-none backdrop-blur-none transition-[background-color,transform] duration-150 ease-out active:scale-[0.99] hover:bg-surface-tertiary rounded-t-2xl group"><div class="flex items-center gap-2"><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="tabler-icon tabler-icon-stack-2 text-muted-foreground"><path d="M12 4l-8 4l8 4l8 -4l-8 -4"></path><path d="M4 12l8 4l8 -4"></path><path d="M4 16l8 4l8 -4"></path></svg><span class="text-sm font-medium text-foreground">Queued Messages</span><div class="inline-flex items-center gap-1 rounded-full border px-2.5 py-1 font-medium tracking-normal transition-colors focus:outline-none focus:ring-2 focus:ring-focus-ring focus:ring-offset-2 focus:ring-offset-background border-separator bg-default text-default-foreground text-xs">3</div></div><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="tabler-icon tabler-icon-chevron-down text-muted-foreground transition-transform duration-200 ease-out"><path d="M6 9l6 6l6 -6"></path></svg></button></div>"`;
4
4
 
5
- exports[`vibot Queue widget DOM > renders the editing state, then saves the trimmed text 1`] = `"<div class="group relative p-2.5 rounded-lg transition-[background-color,transform] duration-150 ease-out active:scale-[0.99] hover:bg-surface-secondary"><div class="flex flex-col gap-2"><textarea class="flex w-full max-sm:min-w-0 rounded-field border border-border bg-field-background px-3.5 py-2.5 sm:text-sm placeholder:text-field-placeholder disabled:cursor-not-allowed disabled:opacity-50 focus-visible:outline-hidden focus-visible:border-focus-border focus-visible:ring-1 focus-visible:ring-focus-ring min-h-[60px] text-sm">Draft</textarea><div class="flex justify-end gap-2"><button class="inline-flex items-center justify-center gap-2 whitespace-nowrap font-medium tracking-normal transition-[transform,background-color,box-shadow,opacity,border-color] duration-200 ease-smooth focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:pointer-events-none disabled:opacity-50 active:scale-[0.96] [&amp;_svg]:pointer-events-none [&amp;_svg:not([class*='size-'])]:size-4 [&amp;_svg]:shrink-0 text-muted-foreground hover:bg-surface-tertiary hover:text-foreground rounded-lg text-xs max-sm:hit-target h-7 px-2"><svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="tabler-icon tabler-icon-x"><path d="M18 6l-12 12"></path><path d="M6 6l12 12"></path></svg><span class="ml-1 text-xs">Cancel</span></button><button class="inline-flex items-center justify-center gap-2 whitespace-nowrap font-medium tracking-normal transition-[transform,background-color,box-shadow,opacity,border-color] duration-200 ease-smooth focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:pointer-events-none disabled:opacity-50 active:scale-[0.96] [&amp;_svg]:pointer-events-none [&amp;_svg:not([class*='size-'])]:size-4 [&amp;_svg]:shrink-0 bg-primary text-primary-foreground hover:-translate-y-0.5 hover:opacity-90 rounded-lg text-xs max-sm:hit-target h-7 px-2"><svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="tabler-icon tabler-icon-check"><path d="M5 12l5 5l10 -10"></path></svg><span class="ml-1 text-xs">Save</span></button></div></div></div>"`;
5
+ exports[`vibot Queue widget DOM > renders the editing state, then saves the trimmed text 1`] = `"<div class="group relative p-2.5 rounded-lg transition-[background-color,transform] duration-150 ease-out active:scale-[0.99] hover:bg-surface-secondary"><div class="flex flex-col gap-2"><textarea class="flex w-full max-sm:min-w-0 rounded-field border border-border bg-field-background px-3.5 py-2.5 sm:text-sm placeholder:text-field-placeholder disabled:cursor-not-allowed disabled:opacity-50 focus-visible:outline-hidden focus-visible:border-focus-border focus-visible:ring-1 focus-visible:ring-focus-ring min-h-[60px] text-sm">Draft</textarea><div class="flex justify-end gap-2"><button class="inline-flex items-center justify-center gap-2 whitespace-nowrap font-medium tracking-normal transition-[transform,background-color,box-shadow,opacity,border-color] duration-200 ease-smooth focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:pointer-events-none disabled:opacity-50 active:scale-[0.96] [&amp;_svg]:pointer-events-none [&amp;_svg:not([class*='size-'])]:size-4 [&amp;_svg]:shrink-0 text-muted-foreground hover:bg-surface-tertiary hover:text-foreground text-xs max-sm:hit-target rounded-full h-7 px-2"><svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="tabler-icon tabler-icon-x"><path d="M18 6l-12 12"></path><path d="M6 6l12 12"></path></svg><span class="ml-1 text-xs">Cancel</span></button><button class="inline-flex items-center justify-center gap-2 whitespace-nowrap font-medium tracking-normal transition-[transform,background-color,box-shadow,opacity,border-color] duration-200 ease-smooth focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:pointer-events-none disabled:opacity-50 active:scale-[0.96] [&amp;_svg]:pointer-events-none [&amp;_svg:not([class*='size-'])]:size-4 [&amp;_svg]:shrink-0 bg-primary text-primary-foreground hover:-translate-y-0.5 hover:opacity-90 text-xs max-sm:hit-target rounded-full h-7 px-2"><svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="tabler-icon tabler-icon-check"><path d="M5 12l5 5l10 -10"></path></svg><span class="ml-1 text-xs">Save</span></button></div></div></div>"`;
6
6
 
7
- exports[`vibot Queue widget DOM > renders the editing state, then saves the trimmed text 2`] = `"<div class="group relative p-2.5 rounded-lg transition-[background-color,transform] duration-150 ease-out active:scale-[0.99] hover:bg-surface-secondary"><div class="flex items-center gap-2"><p class="text-sm text-foreground/90 flex-1 truncate">Draft</p><div class="flex items-center gap-1.5 shrink-0"></div><div class="flex items-center gap-1 shrink-0 opacity-0 group-hover:opacity-100 transition-opacity"><button class="inline-flex items-center justify-center gap-2 whitespace-nowrap font-medium tracking-normal transition-[transform,background-color,box-shadow,opacity,border-color] duration-200 ease-smooth focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:pointer-events-none disabled:opacity-50 active:scale-[0.96] [&amp;_svg]:pointer-events-none [&amp;_svg:not([class*='size-'])]:size-4 [&amp;_svg]:shrink-0 text-muted-foreground hover:bg-surface-tertiary hover:text-foreground rounded-lg text-xs max-sm:hit-target h-6 w-6 p-0"><svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="tabler-icon tabler-icon-edit"><path d="M7 7h-1a2 2 0 0 0 -2 2v9a2 2 0 0 0 2 2h9a2 2 0 0 0 2 -2v-1"></path><path d="M20.385 6.585a2.1 2.1 0 0 0 -2.97 -2.97l-8.415 8.385v3h3l8.385 -8.415"></path><path d="M16 5l3 3"></path></svg></button><button class="inline-flex items-center justify-center gap-2 whitespace-nowrap font-medium tracking-normal transition-[transform,background-color,box-shadow,opacity,border-color] duration-200 ease-smooth focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:pointer-events-none disabled:opacity-50 active:scale-[0.96] [&amp;_svg]:pointer-events-none [&amp;_svg:not([class*='size-'])]:size-4 [&amp;_svg]:shrink-0 hover:bg-surface-tertiary rounded-lg text-xs max-sm:hit-target h-6 w-6 p-0 text-danger hover:text-danger"><svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="tabler-icon tabler-icon-trash"><path d="M4 7l16 0"></path><path d="M10 11l0 6"></path><path d="M14 11l0 6"></path><path d="M5 7l1 12a2 2 0 0 0 2 2h8a2 2 0 0 0 2 -2l1 -12"></path><path d="M9 7v-3a1 1 0 0 1 1 -1h4a1 1 0 0 1 1 1v3"></path></svg></button></div></div></div>"`;
7
+ exports[`vibot Queue widget DOM > renders the editing state, then saves the trimmed text 2`] = `"<div class="group relative p-2.5 rounded-lg transition-[background-color,transform] duration-150 ease-out active:scale-[0.99] hover:bg-surface-secondary"><div class="flex items-center gap-2"><p class="text-sm text-foreground/90 flex-1 truncate">Draft</p><div class="flex items-center gap-1.5 shrink-0"></div><div class="flex items-center gap-1 shrink-0 opacity-0 group-hover:opacity-100 transition-opacity"><button class="inline-flex items-center justify-center gap-2 whitespace-nowrap font-medium tracking-normal transition-[transform,background-color,box-shadow,opacity,border-color] duration-200 ease-smooth focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:pointer-events-none disabled:opacity-50 active:scale-[0.96] [&amp;_svg]:pointer-events-none [&amp;_svg:not([class*='size-'])]:size-4 [&amp;_svg]:shrink-0 text-muted-foreground hover:bg-surface-tertiary hover:text-foreground text-xs max-sm:hit-target rounded-full h-6 w-6 p-0"><svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="tabler-icon tabler-icon-edit"><path d="M7 7h-1a2 2 0 0 0 -2 2v9a2 2 0 0 0 2 2h9a2 2 0 0 0 2 -2v-1"></path><path d="M20.385 6.585a2.1 2.1 0 0 0 -2.97 -2.97l-8.415 8.385v3h3l8.385 -8.415"></path><path d="M16 5l3 3"></path></svg></button><button class="inline-flex items-center justify-center gap-2 whitespace-nowrap font-medium tracking-normal transition-[transform,background-color,box-shadow,opacity,border-color] duration-200 ease-smooth focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:pointer-events-none disabled:opacity-50 active:scale-[0.96] [&amp;_svg]:pointer-events-none [&amp;_svg:not([class*='size-'])]:size-4 [&amp;_svg]:shrink-0 hover:bg-surface-tertiary text-xs max-sm:hit-target rounded-full h-6 w-6 p-0 text-danger hover:text-danger"><svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="tabler-icon tabler-icon-trash"><path d="M4 7l16 0"></path><path d="M10 11l0 6"></path><path d="M14 11l0 6"></path><path d="M5 7l1 12a2 2 0 0 0 2 2h8a2 2 0 0 0 2 -2l1 -12"></path><path d="M9 7v-3a1 1 0 0 1 1 -1h4a1 1 0 0 1 1 1v3"></path></svg></button></div></div></div>"`;
8
8
 
9
9
  exports[`vibot Queue widget DOM > renders the header with a custom class 1`] = `"<button type="button" class="flex items-center justify-between w-full px-4 py-2.5 bg-surface-secondary text-foreground border-0 shadow-none backdrop-blur-none transition-[background-color,transform] duration-150 ease-out active:scale-[0.99] hover:bg-surface-tertiary rounded-t-2xl group x"><div class="flex items-center gap-2"><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="tabler-icon tabler-icon-stack-2 text-muted-foreground"><path d="M12 4l-8 4l8 4l8 -4l-8 -4"></path><path d="M4 12l8 4l8 -4"></path><path d="M4 16l8 4l8 -4"></path></svg><span class="text-sm font-medium text-foreground">Queued Messages</span><div class="inline-flex items-center gap-1 rounded-full border px-2.5 py-1 font-medium tracking-normal transition-colors focus:outline-none focus:ring-2 focus:ring-focus-ring focus:ring-offset-2 focus:ring-offset-background border-separator bg-default text-default-foreground text-xs">1</div></div><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="tabler-icon tabler-icon-chevron-down text-muted-foreground transition-transform duration-200 ease-out"><path d="M6 9l6 6l6 -6"></path></svg></button>"`;
10
10
 
11
- exports[`vibot Queue widget DOM > renders the open widget 1`] = `"<div class="w-full overflow-hidden mb-2" style="height: auto; opacity: 1;"><button type="button" class="flex items-center justify-between w-full px-4 py-2.5 bg-surface-secondary text-foreground border-0 shadow-none backdrop-blur-none transition-[background-color,transform] duration-150 ease-out active:scale-[0.99] hover:bg-surface-tertiary rounded-t-2xl group"><div class="flex items-center gap-2"><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="tabler-icon tabler-icon-stack-2 text-muted-foreground"><path d="M12 4l-8 4l8 4l8 -4l-8 -4"></path><path d="M4 12l8 4l8 -4"></path><path d="M4 16l8 4l8 -4"></path></svg><span class="text-sm font-medium text-foreground">Queued Messages</span><div class="inline-flex items-center gap-1 rounded-full border px-2.5 py-1 font-medium tracking-normal transition-colors focus:outline-none focus:ring-2 focus:ring-focus-ring focus:ring-offset-2 focus:ring-offset-background border-separator bg-default text-default-foreground text-xs">3</div></div><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="tabler-icon tabler-icon-chevron-down text-muted-foreground transition-transform duration-200 ease-out rotate-180"><path d="M6 9l6 6l6 -6"></path></svg></button><div class="overflow-hidden" style="height: auto; opacity: 1;"><div class="bg-surface-secondary text-foreground border-0 shadow-none backdrop-blur-none rounded-b-2xl"><div dir="ltr" data-slot="scroll-area" class="relative max-h-[240px]" style="position: relative; --radix-scroll-area-corner-width: 0px; --radix-scroll-area-corner-height: 0px;"><style>[data-radix-scroll-area-viewport]{scrollbar-width:none;-ms-overflow-style:none;-webkit-overflow-scrolling:touch;}[data-radix-scroll-area-viewport]::-webkit-scrollbar{display:none}</style><div data-radix-scroll-area-viewport="" data-slot="scroll-area-viewport" class="size-full rounded-[inherit] outline-hidden" style="overflow-x: hidden; overflow-y: scroll;"><div style="min-width: 100%; display: table;"><div class="p-2 space-y-1.5"><div class="group relative p-2.5 rounded-lg transition-[background-color,transform] duration-150 ease-out active:scale-[0.99] hover:bg-surface-secondary"><div class="flex items-center gap-2"><img alt="Ada" class="size-5 rounded-full shrink-0" src="https://example.com/ada.png"><p class="text-sm text-foreground/90 flex-1 truncate">Fix the build</p><div class="flex items-center gap-1.5 shrink-0"><div class="inline-flex items-center gap-1 rounded-full border py-1 font-medium tracking-normal transition-colors focus:outline-none focus:ring-2 focus:ring-focus-ring focus:ring-offset-2 focus:ring-offset-background border-separator bg-transparent text-muted-foreground text-xs h-5 px-1.5">claude-sonnet</div><div class="inline-flex items-center gap-1 rounded-full border py-1 font-medium tracking-normal transition-colors focus:outline-none focus:ring-2 focus:ring-focus-ring focus:ring-offset-2 focus:ring-offset-background border-separator bg-transparent text-muted-foreground text-xs h-5 px-1.5">Terse</div></div><div class="flex items-center gap-1 shrink-0 opacity-0 group-hover:opacity-100 transition-opacity"><button class="inline-flex items-center justify-center gap-2 whitespace-nowrap font-medium tracking-normal transition-[transform,background-color,box-shadow,opacity,border-color] duration-200 ease-smooth focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:pointer-events-none disabled:opacity-50 active:scale-[0.96] [&amp;_svg]:pointer-events-none [&amp;_svg:not([class*='size-'])]:size-4 [&amp;_svg]:shrink-0 text-muted-foreground hover:bg-surface-tertiary hover:text-foreground rounded-lg text-xs max-sm:hit-target h-6 w-6 p-0"><svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="tabler-icon tabler-icon-edit"><path d="M7 7h-1a2 2 0 0 0 -2 2v9a2 2 0 0 0 2 2h9a2 2 0 0 0 2 -2v-1"></path><path d="M20.385 6.585a2.1 2.1 0 0 0 -2.97 -2.97l-8.415 8.385v3h3l8.385 -8.415"></path><path d="M16 5l3 3"></path></svg></button><button class="inline-flex items-center justify-center gap-2 whitespace-nowrap font-medium tracking-normal transition-[transform,background-color,box-shadow,opacity,border-color] duration-200 ease-smooth focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:pointer-events-none disabled:opacity-50 active:scale-[0.96] [&amp;_svg]:pointer-events-none [&amp;_svg:not([class*='size-'])]:size-4 [&amp;_svg]:shrink-0 hover:bg-surface-tertiary rounded-lg text-xs max-sm:hit-target h-6 w-6 p-0 text-danger hover:text-danger"><svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="tabler-icon tabler-icon-trash"><path d="M4 7l16 0"></path><path d="M10 11l0 6"></path><path d="M14 11l0 6"></path><path d="M5 7l1 12a2 2 0 0 0 2 2h8a2 2 0 0 0 2 -2l1 -12"></path><path d="M9 7v-3a1 1 0 0 1 1 -1h4a1 1 0 0 1 1 1v3"></path></svg></button></div></div></div><div class="group relative p-2.5 rounded-lg transition-[background-color,transform] duration-150 ease-out active:scale-[0.99] hover:bg-surface-secondary extra"><div class="flex items-center gap-2"><p class="text-sm text-foreground/90 flex-1 truncate">Plain</p><div class="flex items-center gap-1.5 shrink-0"><div class="inline-flex items-center gap-1 rounded-full border py-1 font-medium tracking-normal transition-colors focus:outline-none focus:ring-2 focus:ring-focus-ring focus:ring-offset-2 focus:ring-offset-background border-separator bg-transparent text-muted-foreground text-xs h-5 px-1.5">Bob</div></div><div class="flex items-center gap-1 shrink-0 opacity-0 group-hover:opacity-100 transition-opacity"><button class="inline-flex items-center justify-center gap-2 whitespace-nowrap font-medium tracking-normal transition-[transform,background-color,box-shadow,opacity,border-color] duration-200 ease-smooth focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:pointer-events-none disabled:opacity-50 active:scale-[0.96] [&amp;_svg]:pointer-events-none [&amp;_svg:not([class*='size-'])]:size-4 [&amp;_svg]:shrink-0 text-muted-foreground hover:bg-surface-tertiary hover:text-foreground rounded-lg text-xs max-sm:hit-target h-6 w-6 p-0"><svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="tabler-icon tabler-icon-edit"><path d="M7 7h-1a2 2 0 0 0 -2 2v9a2 2 0 0 0 2 2h9a2 2 0 0 0 2 -2v-1"></path><path d="M20.385 6.585a2.1 2.1 0 0 0 -2.97 -2.97l-8.415 8.385v3h3l8.385 -8.415"></path><path d="M16 5l3 3"></path></svg></button><button class="inline-flex items-center justify-center gap-2 whitespace-nowrap font-medium tracking-normal transition-[transform,background-color,box-shadow,opacity,border-color] duration-200 ease-smooth focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:pointer-events-none disabled:opacity-50 active:scale-[0.96] [&amp;_svg]:pointer-events-none [&amp;_svg:not([class*='size-'])]:size-4 [&amp;_svg]:shrink-0 hover:bg-surface-tertiary rounded-lg text-xs max-sm:hit-target h-6 w-6 p-0 text-danger hover:text-danger"><svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="tabler-icon tabler-icon-trash"><path d="M4 7l16 0"></path><path d="M10 11l0 6"></path><path d="M14 11l0 6"></path><path d="M5 7l1 12a2 2 0 0 0 2 2h8a2 2 0 0 0 2 -2l1 -12"></path><path d="M9 7v-3a1 1 0 0 1 1 -1h4a1 1 0 0 1 1 1v3"></path></svg></button></div></div></div><div class="group relative p-2.5 rounded-lg transition-[background-color,transform] duration-150 ease-out active:scale-[0.99] hover:bg-surface-secondary"><div class="flex items-center gap-2"><p class="text-sm text-foreground/90 flex-1 truncate">Bare</p><div class="flex items-center gap-1.5 shrink-0"></div><div class="flex items-center gap-1 shrink-0 opacity-0 group-hover:opacity-100 transition-opacity"><button class="inline-flex items-center justify-center gap-2 whitespace-nowrap font-medium tracking-normal transition-[transform,background-color,box-shadow,opacity,border-color] duration-200 ease-smooth focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:pointer-events-none disabled:opacity-50 active:scale-[0.96] [&amp;_svg]:pointer-events-none [&amp;_svg:not([class*='size-'])]:size-4 [&amp;_svg]:shrink-0 text-muted-foreground hover:bg-surface-tertiary hover:text-foreground rounded-lg text-xs max-sm:hit-target h-6 w-6 p-0"><svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="tabler-icon tabler-icon-edit"><path d="M7 7h-1a2 2 0 0 0 -2 2v9a2 2 0 0 0 2 2h9a2 2 0 0 0 2 -2v-1"></path><path d="M20.385 6.585a2.1 2.1 0 0 0 -2.97 -2.97l-8.415 8.385v3h3l8.385 -8.415"></path><path d="M16 5l3 3"></path></svg></button><button class="inline-flex items-center justify-center gap-2 whitespace-nowrap font-medium tracking-normal transition-[transform,background-color,box-shadow,opacity,border-color] duration-200 ease-smooth focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:pointer-events-none disabled:opacity-50 active:scale-[0.96] [&amp;_svg]:pointer-events-none [&amp;_svg:not([class*='size-'])]:size-4 [&amp;_svg]:shrink-0 hover:bg-surface-tertiary rounded-lg text-xs max-sm:hit-target h-6 w-6 p-0 text-danger hover:text-danger"><svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="tabler-icon tabler-icon-trash"><path d="M4 7l16 0"></path><path d="M10 11l0 6"></path><path d="M14 11l0 6"></path><path d="M5 7l1 12a2 2 0 0 0 2 2h8a2 2 0 0 0 2 -2l1 -12"></path><path d="M9 7v-3a1 1 0 0 1 1 -1h4a1 1 0 0 1 1 1v3"></path></svg></button></div></div></div></div></div></div></div></div></div></div>"`;
11
+ exports[`vibot Queue widget DOM > renders the open widget 1`] = `"<div class="w-full overflow-hidden mb-2" style="height: auto; opacity: 1;"><button type="button" class="flex items-center justify-between w-full px-4 py-2.5 bg-surface-secondary text-foreground border-0 shadow-none backdrop-blur-none transition-[background-color,transform] duration-150 ease-out active:scale-[0.99] hover:bg-surface-tertiary rounded-t-2xl group"><div class="flex items-center gap-2"><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="tabler-icon tabler-icon-stack-2 text-muted-foreground"><path d="M12 4l-8 4l8 4l8 -4l-8 -4"></path><path d="M4 12l8 4l8 -4"></path><path d="M4 16l8 4l8 -4"></path></svg><span class="text-sm font-medium text-foreground">Queued Messages</span><div class="inline-flex items-center gap-1 rounded-full border px-2.5 py-1 font-medium tracking-normal transition-colors focus:outline-none focus:ring-2 focus:ring-focus-ring focus:ring-offset-2 focus:ring-offset-background border-separator bg-default text-default-foreground text-xs">3</div></div><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="tabler-icon tabler-icon-chevron-down text-muted-foreground transition-transform duration-200 ease-out rotate-180"><path d="M6 9l6 6l6 -6"></path></svg></button><div class="overflow-hidden" style="height: auto; opacity: 1;"><div class="bg-surface-secondary text-foreground border-0 shadow-none backdrop-blur-none rounded-b-2xl"><div dir="ltr" data-slot="scroll-area" class="relative max-h-[240px]" style="position: relative; --radix-scroll-area-corner-width: 0px; --radix-scroll-area-corner-height: 0px;"><style>[data-radix-scroll-area-viewport]{scrollbar-width:none;-ms-overflow-style:none;-webkit-overflow-scrolling:touch;}[data-radix-scroll-area-viewport]::-webkit-scrollbar{display:none}</style><div data-radix-scroll-area-viewport="" data-slot="scroll-area-viewport" class="size-full rounded-[inherit] outline-hidden" style="overflow-x: hidden; overflow-y: scroll;"><div style="min-width: 100%; display: table;"><div class="p-2 space-y-1.5"><div class="group relative p-2.5 rounded-lg transition-[background-color,transform] duration-150 ease-out active:scale-[0.99] hover:bg-surface-secondary"><div class="flex items-center gap-2"><img alt="Ada" class="size-5 rounded-full shrink-0" src="https://example.com/ada.png"><p class="text-sm text-foreground/90 flex-1 truncate">Fix the build</p><div class="flex items-center gap-1.5 shrink-0"><div class="inline-flex items-center gap-1 rounded-full border py-1 font-medium tracking-normal transition-colors focus:outline-none focus:ring-2 focus:ring-focus-ring focus:ring-offset-2 focus:ring-offset-background border-separator bg-transparent text-muted-foreground text-xs h-5 px-1.5">claude-sonnet</div><div class="inline-flex items-center gap-1 rounded-full border py-1 font-medium tracking-normal transition-colors focus:outline-none focus:ring-2 focus:ring-focus-ring focus:ring-offset-2 focus:ring-offset-background border-separator bg-transparent text-muted-foreground text-xs h-5 px-1.5">Terse</div></div><div class="flex items-center gap-1 shrink-0 opacity-0 group-hover:opacity-100 transition-opacity"><button class="inline-flex items-center justify-center gap-2 whitespace-nowrap font-medium tracking-normal transition-[transform,background-color,box-shadow,opacity,border-color] duration-200 ease-smooth focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:pointer-events-none disabled:opacity-50 active:scale-[0.96] [&amp;_svg]:pointer-events-none [&amp;_svg:not([class*='size-'])]:size-4 [&amp;_svg]:shrink-0 text-muted-foreground hover:bg-surface-tertiary hover:text-foreground text-xs max-sm:hit-target rounded-full h-6 w-6 p-0"><svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="tabler-icon tabler-icon-edit"><path d="M7 7h-1a2 2 0 0 0 -2 2v9a2 2 0 0 0 2 2h9a2 2 0 0 0 2 -2v-1"></path><path d="M20.385 6.585a2.1 2.1 0 0 0 -2.97 -2.97l-8.415 8.385v3h3l8.385 -8.415"></path><path d="M16 5l3 3"></path></svg></button><button class="inline-flex items-center justify-center gap-2 whitespace-nowrap font-medium tracking-normal transition-[transform,background-color,box-shadow,opacity,border-color] duration-200 ease-smooth focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:pointer-events-none disabled:opacity-50 active:scale-[0.96] [&amp;_svg]:pointer-events-none [&amp;_svg:not([class*='size-'])]:size-4 [&amp;_svg]:shrink-0 hover:bg-surface-tertiary text-xs max-sm:hit-target rounded-full h-6 w-6 p-0 text-danger hover:text-danger"><svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="tabler-icon tabler-icon-trash"><path d="M4 7l16 0"></path><path d="M10 11l0 6"></path><path d="M14 11l0 6"></path><path d="M5 7l1 12a2 2 0 0 0 2 2h8a2 2 0 0 0 2 -2l1 -12"></path><path d="M9 7v-3a1 1 0 0 1 1 -1h4a1 1 0 0 1 1 1v3"></path></svg></button></div></div></div><div class="group relative p-2.5 rounded-lg transition-[background-color,transform] duration-150 ease-out active:scale-[0.99] hover:bg-surface-secondary extra"><div class="flex items-center gap-2"><p class="text-sm text-foreground/90 flex-1 truncate">Plain</p><div class="flex items-center gap-1.5 shrink-0"><div class="inline-flex items-center gap-1 rounded-full border py-1 font-medium tracking-normal transition-colors focus:outline-none focus:ring-2 focus:ring-focus-ring focus:ring-offset-2 focus:ring-offset-background border-separator bg-transparent text-muted-foreground text-xs h-5 px-1.5">Bob</div></div><div class="flex items-center gap-1 shrink-0 opacity-0 group-hover:opacity-100 transition-opacity"><button class="inline-flex items-center justify-center gap-2 whitespace-nowrap font-medium tracking-normal transition-[transform,background-color,box-shadow,opacity,border-color] duration-200 ease-smooth focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:pointer-events-none disabled:opacity-50 active:scale-[0.96] [&amp;_svg]:pointer-events-none [&amp;_svg:not([class*='size-'])]:size-4 [&amp;_svg]:shrink-0 text-muted-foreground hover:bg-surface-tertiary hover:text-foreground text-xs max-sm:hit-target rounded-full h-6 w-6 p-0"><svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="tabler-icon tabler-icon-edit"><path d="M7 7h-1a2 2 0 0 0 -2 2v9a2 2 0 0 0 2 2h9a2 2 0 0 0 2 -2v-1"></path><path d="M20.385 6.585a2.1 2.1 0 0 0 -2.97 -2.97l-8.415 8.385v3h3l8.385 -8.415"></path><path d="M16 5l3 3"></path></svg></button><button class="inline-flex items-center justify-center gap-2 whitespace-nowrap font-medium tracking-normal transition-[transform,background-color,box-shadow,opacity,border-color] duration-200 ease-smooth focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:pointer-events-none disabled:opacity-50 active:scale-[0.96] [&amp;_svg]:pointer-events-none [&amp;_svg:not([class*='size-'])]:size-4 [&amp;_svg]:shrink-0 hover:bg-surface-tertiary text-xs max-sm:hit-target rounded-full h-6 w-6 p-0 text-danger hover:text-danger"><svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="tabler-icon tabler-icon-trash"><path d="M4 7l16 0"></path><path d="M10 11l0 6"></path><path d="M14 11l0 6"></path><path d="M5 7l1 12a2 2 0 0 0 2 2h8a2 2 0 0 0 2 -2l1 -12"></path><path d="M9 7v-3a1 1 0 0 1 1 -1h4a1 1 0 0 1 1 1v3"></path></svg></button></div></div></div><div class="group relative p-2.5 rounded-lg transition-[background-color,transform] duration-150 ease-out active:scale-[0.99] hover:bg-surface-secondary"><div class="flex items-center gap-2"><p class="text-sm text-foreground/90 flex-1 truncate">Bare</p><div class="flex items-center gap-1.5 shrink-0"></div><div class="flex items-center gap-1 shrink-0 opacity-0 group-hover:opacity-100 transition-opacity"><button class="inline-flex items-center justify-center gap-2 whitespace-nowrap font-medium tracking-normal transition-[transform,background-color,box-shadow,opacity,border-color] duration-200 ease-smooth focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:pointer-events-none disabled:opacity-50 active:scale-[0.96] [&amp;_svg]:pointer-events-none [&amp;_svg:not([class*='size-'])]:size-4 [&amp;_svg]:shrink-0 text-muted-foreground hover:bg-surface-tertiary hover:text-foreground text-xs max-sm:hit-target rounded-full h-6 w-6 p-0"><svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="tabler-icon tabler-icon-edit"><path d="M7 7h-1a2 2 0 0 0 -2 2v9a2 2 0 0 0 2 2h9a2 2 0 0 0 2 -2v-1"></path><path d="M20.385 6.585a2.1 2.1 0 0 0 -2.97 -2.97l-8.415 8.385v3h3l8.385 -8.415"></path><path d="M16 5l3 3"></path></svg></button><button class="inline-flex items-center justify-center gap-2 whitespace-nowrap font-medium tracking-normal transition-[transform,background-color,box-shadow,opacity,border-color] duration-200 ease-smooth focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:pointer-events-none disabled:opacity-50 active:scale-[0.96] [&amp;_svg]:pointer-events-none [&amp;_svg:not([class*='size-'])]:size-4 [&amp;_svg]:shrink-0 hover:bg-surface-tertiary text-xs max-sm:hit-target rounded-full h-6 w-6 p-0 text-danger hover:text-danger"><svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="tabler-icon tabler-icon-trash"><path d="M4 7l16 0"></path><path d="M10 11l0 6"></path><path d="M14 11l0 6"></path><path d="M5 7l1 12a2 2 0 0 0 2 2h8a2 2 0 0 0 2 -2l1 -12"></path><path d="M9 7v-3a1 1 0 0 1 1 -1h4a1 1 0 0 1 1 1v3"></path></svg></button></div></div></div></div></div></div></div></div></div></div>"`;
@@ -289,6 +289,8 @@ export const MessageBranchSelector = ({
289
289
  );
290
290
  };
291
291
  export type MessageBranchPreviousProps = ComponentProps<typeof Button>;
292
+ // Prev and next sit in a `ButtonGroup` with `rounded-md` inner corners, so they
293
+ // default to `shape="rounded"`; a pill would pair a round outer end with them.
292
294
  export const MessageBranchPrevious = ({
293
295
  children,
294
296
  ...props
@@ -301,6 +303,7 @@ export const MessageBranchPrevious = ({
301
303
  disabled={totalBranches <= 1}
302
304
  onClick={goToPrevious}
303
305
  size="icon-sm"
306
+ shape="rounded"
304
307
  type="button"
305
308
  variant="ghost"
306
309
  {...props}
@@ -327,6 +330,7 @@ export const MessageBranchNext = ({
327
330
  disabled={totalBranches <= 1}
328
331
  onClick={goToNext}
329
332
  size="icon-sm"
333
+ shape="rounded"
330
334
  type="button"
331
335
  variant="ghost"
332
336
  {...props}
@@ -1,5 +1,12 @@
1
1
  export { useMediaQuery } from "./use-media-query";
2
2
  export { useDocumentVisible } from "./use-document-visible";
3
+ export {
4
+ readDocumentAppearance,
5
+ subscribeDocumentAppearance,
6
+ useDocumentAppearance,
7
+ type DocumentAppearance,
8
+ } from "./use-document-appearance";
3
9
  export { createIdleCallback, useIdleCallback } from "./use-idle-callback";
4
10
  export { useSingleFlight } from "./use-single-flight";
5
11
  export { usePersistedScrollParent } from "./use-persisted-scroll-parent";
12
+ export { parseCountable, useCountUpDisplay } from "./use-count-up-display";
@@ -0,0 +1,80 @@
1
+ import { animate } from "motion/react";
2
+ import { useEffect, useRef, useState } from "react";
3
+ import { useDisablePageMotion } from "../../app/src/page-motion/PageMotionProvider";
4
+ import { motionEase, tokenTransition } from "../motion/presets";
5
+
6
+ /** Suffixes we will count; compound durations like "1h 12m" stay static. */
7
+ const COUNTABLE_SUFFIX = /^(?:%|h|k|m|s|ms)?$/i;
8
+
9
+ /**
10
+ * Splits a label into the number to count and the text around it. A finite
11
+ * number passes through; a string needs one number with an optional prefix
12
+ * ("$", "~") and a suffix of `%`, `h`, `k`, `m`, `s` or `ms`. Anything else
13
+ * (`—`, "1h 12m", "3 of 5") is `null`.
14
+ */
15
+ export function parseCountable(value: string | number): {
16
+ amount: number;
17
+ prefix: string;
18
+ suffix: string;
19
+ decimals: number;
20
+ } | null {
21
+ if (typeof value === "number") {
22
+ if (!Number.isFinite(value)) return null;
23
+ return { amount: value, prefix: "", suffix: "", decimals: 0 };
24
+ }
25
+ const match = value.trim().match(/^(\D*?)(-?\d+(?:\.\d+)?)(\D*)$/);
26
+ if (!match) return null;
27
+ const prefix = match[1] ?? "";
28
+ const raw = match[2] ?? "";
29
+ const suffix = (match[3] ?? "").trim();
30
+ if (!COUNTABLE_SUFFIX.test(suffix)) return null;
31
+ const amount = Number(raw);
32
+ if (!Number.isFinite(amount)) return null;
33
+ const decimals = raw.includes(".") ? (raw.split(".")[1]?.length ?? 0) : 0;
34
+ return { amount, prefix, suffix, decimals };
35
+ }
36
+
37
+ /**
38
+ * Counts from the last seen number to `value` on `--motion-base`
39
+ * (`tokenTransition.base`), keeping the prefix, suffix and decimal places. The
40
+ * first value counts up from 0. The same amount again (a refetch) is a no-op,
41
+ * so the number does not replay on every write. Labels `parseCountable` rejects
42
+ * (`—`, "1h 12m") render as they are, and so does every value while
43
+ * `PageMotionProvider` is `disabled`.
44
+ */
45
+ export function useCountUpDisplay(value: string | number): string | number {
46
+ const parsed = parseCountable(value);
47
+ const [display, setDisplay] = useState(value);
48
+ const lastAmount = useRef<number | null>(null);
49
+ const skipMotion = useDisablePageMotion();
50
+
51
+ useEffect(() => {
52
+ if (skipMotion) {
53
+ lastAmount.current = parsed?.amount ?? null;
54
+ setDisplay(value);
55
+ return;
56
+ }
57
+ if (!parsed) {
58
+ lastAmount.current = null;
59
+ setDisplay(value);
60
+ return;
61
+ }
62
+ if (lastAmount.current === parsed.amount) {
63
+ setDisplay(value);
64
+ return;
65
+ }
66
+ const from = lastAmount.current ?? 0;
67
+ lastAmount.current = parsed.amount;
68
+ const controls = animate(from, parsed.amount, {
69
+ duration: tokenTransition.base.duration,
70
+ ease: motionEase,
71
+ onUpdate: (latest) => {
72
+ const rounded = parsed.decimals > 0 ? latest.toFixed(parsed.decimals) : String(Math.round(latest));
73
+ setDisplay(`${parsed.prefix}${rounded}${parsed.suffix}`);
74
+ },
75
+ });
76
+ return () => controls.stop();
77
+ }, [parsed?.amount, parsed?.prefix, parsed?.suffix, parsed?.decimals, skipMotion, value]);
78
+
79
+ return display;
80
+ }
@@ -0,0 +1,46 @@
1
+ import { useSyncExternalStore } from "react";
2
+
3
+ /** Light or dark, as the page currently draws itself. Neutral is dark-family. */
4
+ export type DocumentAppearance = "light" | "dark";
5
+
6
+ /**
7
+ * Reads the appearance from `<html>`: the `dark` class (Tailwind's convention,
8
+ * set before paint by the app's boot script) or `data-theme="dark"`.
9
+ *
10
+ * Use this rather than a theme context when code can run outside the app's
11
+ * theme provider (a signed-out marketing page, a canvas effect): the class is
12
+ * the one source every page shares.
13
+ */
14
+ export function readDocumentAppearance(): DocumentAppearance {
15
+ const root = document.documentElement;
16
+ return root.classList.contains("dark") ||
17
+ root.getAttribute("data-theme") === "dark"
18
+ ? "dark"
19
+ : "light";
20
+ }
21
+
22
+ /** Calls `onChange` whenever `<html>`'s class or `data-theme` changes. */
23
+ export function subscribeDocumentAppearance(onChange: () => void): () => void {
24
+ const observer = new MutationObserver(onChange);
25
+ observer.observe(document.documentElement, {
26
+ attributes: true,
27
+ attributeFilter: ["class", "data-theme"],
28
+ });
29
+ return () => observer.disconnect();
30
+ }
31
+
32
+ function readServerAppearance(): DocumentAppearance {
33
+ return "light";
34
+ }
35
+
36
+ /**
37
+ * The page's appearance, re-rendering when it changes. Server snapshot is
38
+ * `"light"`, matching a page with no `dark` class.
39
+ */
40
+ export function useDocumentAppearance(): DocumentAppearance {
41
+ return useSyncExternalStore(
42
+ subscribeDocumentAppearance,
43
+ readDocumentAppearance,
44
+ readServerAppearance,
45
+ );
46
+ }
@@ -1,6 +1,10 @@
1
1
  import type { MermaidConfig } from "@streamdown/mermaid";
2
2
  import { useSyncExternalStore } from "react";
3
3
  import type { DiagramPlugin } from "streamdown";
4
+ import {
5
+ readDocumentAppearance,
6
+ subscribeDocumentAppearance,
7
+ } from "../hooks/use-document-appearance";
4
8
 
5
9
  /**
6
10
  * Mermaid for markdown, loaded on first use. `@streamdown/mermaid` imports
@@ -102,18 +106,9 @@ function subscribe(key: string, notify: () => void): () => void {
102
106
  };
103
107
  }
104
108
 
105
- /** The app theme lives as a `dark` class on `<html>` (neutral is dark-family). */
109
+ /** Mermaid's theme for the page's current appearance (neutral is dark-family). */
106
110
  function readTheme(): MermaidTheme {
107
- return document.documentElement.classList.contains("dark") ? "dark" : "default";
108
- }
109
-
110
- function subscribeTheme(notify: () => void): () => void {
111
- const observer = new MutationObserver(notify);
112
- observer.observe(document.documentElement, {
113
- attributes: true,
114
- attributeFilter: ["class"],
115
- });
116
- return () => observer.disconnect();
111
+ return readDocumentAppearance() === "dark" ? "dark" : "default";
117
112
  }
118
113
 
119
114
  /**
@@ -145,7 +140,7 @@ export function useMermaidRender(
145
140
  code: string,
146
141
  enabled: boolean,
147
142
  ): { theme: MermaidTheme; status: Entry["status"] } {
148
- const theme = useSyncExternalStore(subscribeTheme, readTheme);
143
+ const theme = useSyncExternalStore(subscribeDocumentAppearance, readTheme);
149
144
  const key = enabled ? cacheKey(theme, code) : "";
150
145
  const entry = useSyncExternalStore(
151
146
  (notify) => (key === "" ? () => {} : subscribe(key, notify)),
package/src/ui/button.tsx CHANGED
@@ -8,7 +8,7 @@ const ghostClass =
8
8
  "text-muted-foreground hover:bg-surface-tertiary hover:text-foreground";
9
9
 
10
10
  const buttonBaseClasses =
11
- "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-lg text-sm font-medium tracking-normal transition-[transform,background-color,box-shadow,opacity,border-color] duration-200 ease-smooth focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:pointer-events-none disabled:opacity-50 active:scale-[0.96] [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:shrink-0";
11
+ "inline-flex items-center justify-center gap-2 whitespace-nowrap text-sm font-medium tracking-normal transition-[transform,background-color,box-shadow,opacity,border-color] duration-200 ease-smooth focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:pointer-events-none disabled:opacity-50 active:scale-[0.96] [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:shrink-0";
12
12
 
13
13
  const buttonVariantClasses = {
14
14
  default:
@@ -26,29 +26,52 @@ const buttonVariantClasses = {
26
26
  warning: `${ghostClass} bg-warning text-warning-foreground hover:bg-warning/90`,
27
27
  };
28
28
 
29
+ // No radius here: `shape` sets it.
29
30
  const buttonSizeClasses = {
30
31
  default: "h-10 px-4 py-2",
31
- xs: "h-7 rounded-md px-2 text-xs hit-target [&_svg:not([class*='size-'])]:size-3.5",
32
- sm: "h-8 rounded-lg px-3 text-xs max-sm:hit-target",
32
+ xs: "h-7 px-2 text-xs hit-target [&_svg:not([class*='size-'])]:size-3.5",
33
+ sm: "h-8 px-3 text-xs max-sm:hit-target",
33
34
  lg: "h-11 px-6",
34
35
  icon: "h-10 w-10",
35
- "icon-sm": "h-8 w-8 rounded-lg max-sm:hit-target",
36
- "icon-xs": "h-7 w-7 rounded-md hit-target",
36
+ "icon-sm": "h-8 w-8 max-sm:hit-target",
37
+ "icon-xs": "h-7 w-7 hit-target",
37
38
  };
38
39
 
40
+ // `pill` (default) is fully round, so icon sizes are circles. `rounded` is
41
+ // the radius from before 0.31.0, for joined edges and field addons.
42
+ const buttonShapeClasses = {
43
+ pill: "rounded-full",
44
+ rounded: "rounded-lg",
45
+ };
46
+
47
+ type ButtonVariant = keyof typeof buttonVariantClasses;
48
+ type ButtonSize = keyof typeof buttonSizeClasses;
49
+ type ButtonShape = keyof typeof buttonShapeClasses;
50
+
51
+ // `rounded` keeps the smaller radius the two 28px sizes had.
52
+ const roundedSmallSizes: ButtonSize[] = ["xs", "icon-xs"];
53
+ const roundedSmallClass = "rounded-md";
54
+
39
55
  const buttonVariants = cva(buttonBaseClasses, {
40
56
  variants: {
41
57
  variant: buttonVariantClasses,
42
58
  size: buttonSizeClasses,
59
+ shape: buttonShapeClasses,
43
60
  },
61
+ compoundVariants: [{ shape: "rounded", size: roundedSmallSizes, class: roundedSmallClass }],
44
62
  defaultVariants: {
45
63
  variant: "default",
46
64
  size: "default",
65
+ shape: "pill",
47
66
  },
48
67
  });
49
68
 
50
- type ButtonVariant = keyof typeof buttonVariantClasses;
51
- type ButtonSize = keyof typeof buttonSizeClasses;
69
+ /** Built-in radius for the config path. Matches the cva variant and compound variant. */
70
+ function buttonShapeClass(shape: ButtonShape, size: ButtonSize): string {
71
+ return shape === "rounded" && roundedSmallSizes.includes(size)
72
+ ? roundedSmallClass
73
+ : buttonShapeClasses[shape];
74
+ }
52
75
 
53
76
  /** `UiConfig.Button`: each given class string replaces the built-in one. */
54
77
  export interface ButtonConfig {
@@ -56,9 +79,12 @@ export interface ButtonConfig {
56
79
  base?: string;
57
80
  variant?: Partial<Record<ButtonVariant, string>>;
58
81
  size?: Partial<Record<ButtonSize, string>>;
82
+ /** Radius per shape. Replaces it for every size, `xs` and `icon-xs` included. */
83
+ shape?: Partial<Record<ButtonShape, string>>;
59
84
  };
60
85
  }
61
86
 
87
+ /** `shape`: `"pill"` (default, `rounded-full`) or `"rounded"` (the radius from before 0.31.0). */
62
88
  export interface ButtonProps
63
89
  extends
64
90
  React.ButtonHTMLAttributes<HTMLButtonElement>,
@@ -74,7 +100,7 @@ export interface ButtonProps
74
100
 
75
101
  const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
76
102
  (
77
- { className, variant, size, asChild = false, startContent, endContent, loading, children, ...props },
103
+ { className, variant, size, shape, asChild = false, startContent, endContent, loading, children, ...props },
78
104
  ref,
79
105
  ) => {
80
106
  const Comp = asChild ? Slot : "button";
@@ -82,6 +108,7 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
82
108
  const merge = useCn();
83
109
  const v = variant ?? "default";
84
110
  const s = size ?? "default";
111
+ const sh = shape ?? "pill";
85
112
  return (
86
113
  <Comp
87
114
  className={
@@ -90,9 +117,10 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
90
117
  classes.base ?? buttonBaseClasses,
91
118
  classes.variant?.[v] ?? buttonVariantClasses[v],
92
119
  classes.size?.[s] ?? buttonSizeClasses[s],
120
+ classes.shape?.[sh] ?? buttonShapeClass(sh, s),
93
121
  className,
94
122
  )
95
- : merge(buttonVariants({ variant, size, className }))
123
+ : merge(buttonVariants({ variant, size, shape, className }))
96
124
  }
97
125
  ref={ref}
98
126
  {...props}
@@ -1,5 +1,7 @@
1
1
  /** transitions.dev input clear with dissolve — see 13-input-clear-dissolve.md */
2
2
 
3
+ import { readDocumentAppearance } from "../hooks/use-document-appearance";
4
+
3
5
  // Times come back in ms. CSS minifiers rewrite `1000ms` as `1s`, so a value
4
6
  // in seconds is converted rather than read as 1.
5
7
  export function readCssNumber(
@@ -53,13 +55,6 @@ export function readCssEase(root: HTMLElement, name: string): (t: number) => num
53
55
  };
54
56
  }
55
57
 
56
- function isDarkTheme(root: HTMLElement): boolean {
57
- return (
58
- root.classList.contains("dark") ||
59
- root.getAttribute("data-theme") === "dark"
60
- );
61
- }
62
-
63
58
  function measureCanvas(): CanvasRenderingContext2D | null {
64
59
  const canvas = document.createElement("canvas");
65
60
  return canvas.getContext("2d");
@@ -93,7 +88,7 @@ function buildGlowBackground(
93
88
  return "";
94
89
  }
95
90
  canvas.font = getComputedStyle(input).font;
96
- const dark = isDarkTheme(root);
91
+ const dark = readDocumentAppearance() === "dark";
97
92
  const rgb = dark ? "255,255,255" : "0,0,0";
98
93
  const width = wrap.clientWidth || 280;
99
94
  const padLeft = parseFloat(getComputedStyle(input).paddingLeft) || 12;
@@ -103,11 +103,12 @@ const inputGroupButtonVariants = cva("shrink-0 shadow-none", {
103
103
  /** @deprecated Use `InputGroupButton`. Kept for existing consumers; removed in a later minor after they move. */
104
104
  const InputGroupButtonAlt = React.forwardRef<
105
105
  HTMLButtonElement,
106
- ButtonProps & VariantProps<typeof inputGroupButtonVariants>
106
+ Omit<ButtonProps, "shape"> & VariantProps<typeof inputGroupButtonVariants>
107
107
  >(({ className, size, variant = "ghost", ...props }, ref) => (
108
108
  <Button
109
109
  ref={ref}
110
110
  variant={variant}
111
+ shape="rounded"
111
112
  className={cn(inputGroupButtonVariants({ size }), className)}
112
113
  {...props}
113
114
  />
@@ -131,13 +131,16 @@ const inputGroupButtonVariants = cva(inputGroupButtonBaseClasses, {
131
131
  },
132
132
  });
133
133
 
134
+ // `shape="rounded"` keeps the field-addon radius under Button's pill default.
135
+ // `shape` is not a prop: the size classes set `rounded-lg`, so radius changes
136
+ // go through `className` (or `UiConfig.InputGroup.classes.button`).
134
137
  function InputGroupButton({
135
138
  className,
136
139
  type = "button",
137
140
  variant = "ghost",
138
141
  size = "xs",
139
142
  ...props
140
- }: Omit<React.ComponentProps<typeof Button>, "size"> &
143
+ }: Omit<React.ComponentProps<typeof Button>, "size" | "shape"> &
141
144
  VariantProps<typeof inputGroupButtonVariants>) {
142
145
  const classes = useUiConfig("InputGroup")?.classes?.button;
143
146
  const merge = useCn();
@@ -147,6 +150,7 @@ function InputGroupButton({
147
150
  type={type}
148
151
  data-size={size}
149
152
  variant={variant}
153
+ shape="rounded"
150
154
  className={
151
155
  classes
152
156
  ? merge(