@vvedantb/ui 0.37.1 → 0.39.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
@@ -324,7 +324,7 @@ import {
324
324
  rail={
325
325
  <SidebarRail
326
326
  showDivider={hasPanel && !(isDesktop && collapsed)}
327
- top={<RailTile asChild label="Home" active={isHome}><Link to="/"><IconHome size={22} /></Link></RailTile>}
327
+ top={<RailTile asChild app="vmem" label="Home" active={isHome}><Link to="/" /></RailTile>}
328
328
  bodyClassName="scrollbar-thin"
329
329
  footerClassName="border-t border-separator"
330
330
  footer={<SidebarAccountMenu name={name} email={email} imageUrl={avatarUrl}><DropdownMenuItem>Settings</DropdownMenuItem></SidebarAccountMenu>}
@@ -344,7 +344,8 @@ import {
344
344
  - `AppShell`: `sidebar`, `children`, `panelHidden` (main offsets by the rail width instead of the sidebar width), `skipLinkLabel` (`"Skip to main content"`), `mainId` (`"main-content"`), `className`, `mainClassName`, `contentClassName`.
345
345
  - `AppSidebar`: legacy (still supported) `rail`, `panel` and `mobile*` props; compose `Sidebar.*` instead. `rail`, `panel` (not rendered while `panelHidden`), `collapsed`, `panelHidden`, `isDesktop`, `mobileOpen`, `onMobileOpenChange`, `id` (default from `useId`), `asideProps` (`data-*` attributes for the `<aside>`), `className`, `mobileTitle`, `mobileHeaderActions` (0.5.2; trailing actions such as icon buttons, in place of the end spacer), `mobileMenuIcon` (`(open) => node`, default a menu icon), `mobileMenuLabel` (`"Open navigation menu"`), `closeLabel` (`"Close navigation"`). Swipe left or Escape closes the drawer. The drawer is `inert` while closed on mobile, and closes when `isDesktop` becomes `true`. Close it on route change in the app. The mobile header is a three-column grid, so the title and trailing actions stay on one row and a long title truncates (0.5.4).
346
346
  - `SidebarRail`: legacy (still supported) `top`, `footer`, `bodyAsChild`, `tabs` and `*ClassName` props; compose `Sidebar.Rail` parts instead. `top`, `children` (scroll body), `footer`, `showDivider`, `bodyAsChild` (the single child, for example a tabs list, becomes the body), `className`, `topClassName`, `bodyClassName`, `footerClassName`. Other HTML attributes (`id`, `data-*`, `aria-*`) go to the root `<div>`. The top and footer wrappers render only when their slot is set.
347
- - `RailTile`: `label` (`aria-label` and tooltip), `active`, `tooltip`, `showTooltip` (default `true`), `badge` (inside the tile, after the children; style it with `railBadgeClass`), `asChild`, `className`. Without `asChild` it is a native `<button type="button">` (0.5.3), so icons keep their authored size; other props go to it. With `asChild` the child (for example a router link) gets the tile classes and `aria-current="page"` when active.
347
+ - `RailTile`: `label` (`aria-label` and tooltip), `active`, `tooltip`, `showTooltip` (default `true`), `badge` (inside the tile, after the children; style it with `railBadgeClass`), `asChild`, `app`, `className`. Without `asChild` it is a native `<button type="button">` (0.5.3), so icons keep their authored size; other props go to it. With `asChild` the child (for example a router link) gets the tile classes and `aria-current="page"` when active.
348
+ - Rail app icon: the home `RailTile` icon is 26px across the apps, enforced by the tile. Pass `app="vmem"` (any `ProductAppName`) and the tile renders `APP_ICONS[app]` at `SIDEBAR_RAIL_APP_ICON_SIZE` (`26`). With `asChild` it goes inside the child (`<Link to="/" />`); without it, `children` are ignored. Tiles without `app` are unchanged: the tile renders the icon you pass, at its size. `Sidebar.MobileHeader` is the standard mobile bar: 56px plus safe-area, solid `bg-surface`, centred `text-base font-semibold tracking-tight` title, 20px menu icon.
348
349
  - `RailDivider`, `SidebarIconTooltip` (`label`, `enabled`, `side` `"right"`, `sideOffset` 8).
349
350
  - `SidebarPanel`: legacy (still supported) `headerTrailing`, `onClose`, `closeLabel` and `closeButtonClassName`; compose `Sidebar.Panel` parts instead. `title`, `headerTrailing`, `onClose` (adds the close button; pass it on the mobile drawer only), `closeLabel`, `closeButtonClassName`, `collapsed` (`md:hidden`), `className`, `children`.
350
351
  - `SidebarAccountMenu`: legacy (still supported) `badge`, `triggerProps`, `contentProps` and `stackedLabel`; compose `Sidebar.Account*` parts instead. `name`, `email` (two-line label when set), `stackedLabel` (two-line layout even without `email`, default `false`), `imageUrl` (initials without it), `align` (`"center"`), `side` (`"right"`), `sideOffset` (8), `collisionPadding`, `contentClassName`, `children` (menu items). 0.5.2 adds `badge` (unread dot beside the trigger, in a `relative` wrapper around the menu, so the trigger's dimming and press scale skip it: `true` shows it, `false` fades it out, omitted renders no wrapper and no dot), `triggerProps` (spread onto the trigger `Button` after the defaults; `className` merges last) and `contentProps` (spread onto `DropdownMenuContent`, for example `fitViewport` or `aria-label`; no `className`, use `contentClassName`). `SidebarAccountMenuPlaceholder` is the tile-sized loading box.
@@ -477,7 +478,7 @@ Convention: compound parts are static members of a namespace object, `<Namespace
477
478
  | `ResizeHandle` | `SidebarResizeHandle` in a resizable `AppShell`, shown on desktop while the panel shows. Place it inside `Aside`. |
478
479
  | `Rail` | Rail column. `showDivider`. |
479
480
  | `RailHeader` / `RailBody` / `RailFooter` | Top block, scroll body (`asChild`, `tabs` for a Radix tabs list) and bottom block. `Footer` is an alias of `RailFooter`. |
480
- | `RailItem` | Rail tile with tooltip (`RailTile`): `label`, `active`, `tooltip`, `showTooltip`, `badge`, `asChild`, `tabValue`. |
481
+ | `RailItem` | Rail tile with tooltip (`RailTile`): `label`, `active`, `tooltip`, `showTooltip`, `badge`, `asChild`, `app`, `tabValue`. |
481
482
  | `RailSeparator` | Short rule between rail items (`RailDivider`). |
482
483
  | `Tooltip` | Right-side icon tooltip (`SidebarIconTooltip`). |
483
484
  | `Panel` | Panel column. `collapsed` (`md:hidden`) and `panelHidden` (renders nothing) default to the Root values. |
@@ -896,6 +897,34 @@ Constants: `landingShellClass`, `landingPanelClass`, `landingItemVariants`, and
896
897
  for the footer's `actions` slot `landingFooterLinkClass` (a text link) and
897
898
  `landingFooterButtonClass` (a `Button variant="link"`).
898
899
 
900
+ ### `SiteHeader`
901
+
902
+ Marketing header (0.38.0): `AppWordmark` at `SITE_HEADER_WORDMARK_SIZE` (20px, icon 28px; no size prop), up to four
903
+ links from `md`, one CTA and an optional GitHub button. Below `md` the links, the CTA and GitHub sit in one full-width
904
+ dropdown menu, in that order. It is plain markup with no motion and needs no `createLandingKit`.
905
+
906
+ ```tsx
907
+ <SiteHeader
908
+ app="vmem"
909
+ homeLabel="vmem home"
910
+ links={[{ label: "Features", href: "#features" }, { label: "Pricing", href: "#pricing" }]}
911
+ cta={<Button size="sm" className="rounded-full">Sign in</Button>}
912
+ githubUrl="https://github.com/vvedantb"
913
+ />
914
+ ```
915
+
916
+ | Prop | |
917
+ | --- | --- |
918
+ | `app` | `vibot`, `vmem`, `verve`, `velth` or `vhub`. |
919
+ | `homeLabel` | `aria-label` of the home wordmark link. |
920
+ | `homeHref` | Default `#home`. |
921
+ | `links` | `{ label, href }[]`, at most 4. More logs a development warning and only the first 4 render. |
922
+ | `cta` | One node. Use `Button` (default variant) with `size="sm"` and `className="rounded-full"`. Shows in the bar from `md` and full width in the menu below. No second slot. |
923
+ | `githubUrl` | Optional. Ghost icon button from `sm`, labelled "{app} on GitHub", and the last menu item. |
924
+
925
+ The bar is `sticky top-0 z-50`, `h-14 sm:h-16`, `bg-background/80 backdrop-blur-md` with a `border-separator` bottom border.
926
+ `createLandingKit().LandingNav` and `LandingNavMenu` are deprecated in favour of `SiteHeader`.
927
+
899
928
  ### `SiteFooter`
900
929
 
901
930
  Brand, tagline and social icon buttons; link columns; then copyright, product-of line and legal
@@ -1604,6 +1633,33 @@ const config = {
1604
1633
 
1605
1634
  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.
1606
1635
  - `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.
1636
+ - Quoted replies (0.39.0): `Message` takes `actions` (right-click menu plus hover toolbar) and `highlighted`; `MessageQuote` goes in `MessageContent` above the text; `ReplyQuoteChip` goes in `PromptInputHeader`.
1637
+
1638
+ ```tsx
1639
+ <Message
1640
+ from="user"
1641
+ id={`msg-${m.id}`}
1642
+ highlighted={highlightId === m.id}
1643
+ actions={[
1644
+ { key: "reply", label: "Reply", icon: <IconArrowBackUp size={14} />, onSelect: () => setReply(m) },
1645
+ { key: "copy", label: "Copy", icon: <IconCopy size={14} />, onSelect: () => copy(m.text) },
1646
+ ]}
1647
+ >
1648
+ <MessageContent>
1649
+ {m.quoted && <MessageQuote author={m.quoted.author} text={m.quoted.text} onPress={() => jumpTo(m.quoted.id)} />}
1650
+ {m.text}
1651
+ </MessageContent>
1652
+ </Message>
1653
+
1654
+ <PromptInput onSubmit={send}>
1655
+ <PromptInputHeader>
1656
+ {reply && <ReplyQuoteChip author={reply.author} text={reply.text} onDismiss={() => setReply(null)} />}
1657
+ </PromptInputHeader>
1658
+ {/* textarea, footer */}
1659
+ </PromptInput>
1660
+ ```
1661
+
1662
+ The app owns the reply state and the scroll. `MessageHoverActions` needs a `relative` `Message` (the `actions` prop sets it).
1607
1663
  - `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` (default `true`: `PromptInputActionMenuTrigger` is the Radix trigger; `false` nests 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.
1608
1664
  - `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`.
1609
1665
  - `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`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vvedantb/ui",
3
- "version": "0.37.1",
3
+ "version": "0.39.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), third-party brand logos (also @vvedantb/ui/logos), motion-driven animated SVG icons (@vvedantb/ui/icons/animated), 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",
@@ -3,7 +3,9 @@ import { Slot, Slottable } from "@radix-ui/react-slot";
3
3
  import * as TabsPrimitive from "@radix-ui/react-tabs";
4
4
  import { IconLayoutSidebarLeftCollapse, IconLayoutSidebarLeftExpandFilled } from "@tabler/icons-react";
5
5
  import { cn } from "../../src/utils/cn";
6
- import { railTileClass, railTileStateClass } from "./app-shell-classes";
6
+ import { APP_ICONS } from "../../src/brand/AppIcons";
7
+ import type { ProductAppName } from "../../src/brand/AppWordmark";
8
+ import { SIDEBAR_RAIL_APP_ICON_SIZE, railTileClass, railTileStateClass } from "./app-shell-classes";
7
9
  import { SidebarIconTooltip } from "./SidebarIconTooltip";
8
10
 
9
11
  export type SidebarRailProps = Omit<ComponentPropsWithoutRef<"div">, "children" | "className"> & {
@@ -111,7 +113,7 @@ export function RailFooter({ className, ...props }: SidebarRailFooterProps) {
111
113
  return <div {...props} className={cn("flex w-full flex-col items-center gap-1.5 py-3", className)} />;
112
114
  }
113
115
 
114
- export type SidebarRailItemProps = Omit<ComponentPropsWithoutRef<"button">, "children"> & {
116
+ type SidebarRailItemBaseProps = Omit<ComponentPropsWithoutRef<"button">, "children"> & {
115
117
  /** `aria-label`, and the tooltip unless `tooltip` is set. */
116
118
  label: string;
117
119
  /** Active state. With `asChild`, also sets `aria-current="page"`. */
@@ -129,9 +131,27 @@ export type SidebarRailItemProps = Omit<ComponentPropsWithoutRef<"button">, "chi
129
131
  * The label is sr-only text instead of `aria-label`; `asChild` is ignored.
130
132
  */
131
133
  tabValue?: string;
132
- children: ReactNode;
133
134
  };
134
135
 
136
+ export type SidebarRailItemProps = SidebarRailItemBaseProps &
137
+ (
138
+ | {
139
+ /**
140
+ * Product app id for the home tile. The tile renders `APP_ICONS[app]` at
141
+ * `SIDEBAR_RAIL_APP_ICON_SIZE` (26px), so apps cannot drift. With `asChild` the
142
+ * icon goes inside the child element (for example `<Link to="/" />`). Without
143
+ * `asChild`, `children` are ignored.
144
+ */
145
+ app: ProductAppName;
146
+ children?: ReactNode;
147
+ }
148
+ | {
149
+ app?: undefined;
150
+ /** The tile icon. The caller controls its size. */
151
+ children: ReactNode;
152
+ }
153
+ );
154
+
135
155
  /** Rail tile with a right-side tooltip. Renders a native `<button>`, its child with `asChild`, or a tabs trigger with `tabValue`. */
136
156
  export function RailItem({
137
157
  label,
@@ -142,9 +162,13 @@ export function RailItem({
142
162
  asChild = false,
143
163
  tabValue,
144
164
  className,
165
+ app,
145
166
  children,
146
167
  ...props
147
168
  }: SidebarRailItemProps) {
169
+ const AppIcon = app ? APP_ICONS[app] : null;
170
+ const appIcon = AppIcon ? <AppIcon size={SIDEBAR_RAIL_APP_ICON_SIZE} /> : null;
171
+ const content = appIcon ?? children;
148
172
  return (
149
173
  <SidebarIconTooltip label={tooltip ?? label} enabled={showTooltip}>
150
174
  {tabValue !== undefined ? (
@@ -153,7 +177,7 @@ export function RailItem({
153
177
  value={tabValue}
154
178
  className={cn(railTileClass, "group relative", railTileStateClass(active), className)}
155
179
  >
156
- {children}
180
+ {content}
157
181
  <span className="sr-only">{label}</span>
158
182
  {badge}
159
183
  </TabsPrimitive.Trigger>
@@ -164,6 +188,7 @@ export function RailItem({
164
188
  aria-current={active ? "page" : undefined}
165
189
  className={cn(railTileClass, "group", railTileStateClass(active), className)}
166
190
  >
191
+ {appIcon}
167
192
  <Slottable>{children}</Slottable>
168
193
  {badge}
169
194
  </Slot>
@@ -174,7 +199,7 @@ export function RailItem({
174
199
  aria-label={label}
175
200
  className={cn(railTileClass, "group", railTileStateClass(active), className)}
176
201
  >
177
- {children}
202
+ {content}
178
203
  {badge}
179
204
  </button>
180
205
  )}
@@ -5,6 +5,9 @@ import { cn } from "../../src/utils/cn";
5
5
  // accent colour names match that markup and are allow-listed for check:tokens
6
6
  // on the marked lines only.
7
7
 
8
+ /** App icon size in px for the home `RailTile`. Fixed across the apps. `RailTile` applies it when you pass `app`. */
9
+ export const SIDEBAR_RAIL_APP_ICON_SIZE = 26;
10
+
8
11
  /** Rail tile: size-11, rounded-lg, press scale. */
9
12
  export const railTileClass =
10
13
  "relative flex size-11 items-center justify-center rounded-lg border active:scale-[0.96] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring";
@@ -46,6 +46,7 @@ export type { SignOutDialogProps } from "./SignOutDialog.js";
46
46
  export {
47
47
  railBadgeClass,
48
48
  railTileClass,
49
+ SIDEBAR_RAIL_APP_ICON_SIZE,
49
50
  railTileStateClass,
50
51
  sidebarNavIconClass,
51
52
  sidebarNavItemActionsClass,
@@ -1,8 +1,14 @@
1
1
  "use client";
2
2
  import type { UIMessage } from "ai";
3
- import type { ComponentProps, HTMLAttributes, ReactElement } from "react";
3
+ import type { ComponentProps, HTMLAttributes, ReactElement, ReactNode } from "react";
4
4
  import { Button } from "../ui/button";
5
5
  import { ButtonGroup, ButtonGroupText } from "../ui/button-group";
6
+ import {
7
+ ContextMenu,
8
+ ContextMenuContent,
9
+ ContextMenuItem,
10
+ ContextMenuTrigger,
11
+ } from "../ui/context-menu";
6
12
  import {
7
13
  Tooltip,
8
14
  TooltipContent,
@@ -30,23 +36,233 @@ export interface MessageConfig {
30
36
  branchIcon?: string;
31
37
  };
32
38
  }
39
+ /**
40
+ * One action of a message: a row in the right-click menu and, with an `icon`,
41
+ * a button in the hover toolbar. Start with Reply.
42
+ */
43
+ export type MessageMenuAction = {
44
+ key: string;
45
+ label: string;
46
+ /** Shows the action in the hover toolbar (an icon button). Without it: menu only. */
47
+ icon?: ReactNode;
48
+ destructive?: boolean;
49
+ disabled?: boolean;
50
+ onSelect: () => void;
51
+ /** Force the hover toolbar on (`true`) or off (`false`). Default: on when there is an `icon`. */
52
+ hover?: boolean;
53
+ };
33
54
  export type MessageProps = HTMLAttributes<HTMLDivElement> & {
34
55
  from: UIMessage["role"];
56
+ /**
57
+ * Right-click menu and hover toolbar actions, such as Reply. Without it (or
58
+ * empty) `Message` renders the same `div` as before. With it a right click
59
+ * opens the menu and a hover toolbar shows beside the bubble. The toolbar is
60
+ * absolutely positioned and takes no layout space.
61
+ */
62
+ actions?: MessageMenuAction[];
63
+ /**
64
+ * The target of a quote click: a soft primary wash and ring. Set it back to
65
+ * `false` about 1.5 seconds later and it fades out (`duration-1000`). Sets
66
+ * `data-highlighted`.
67
+ */
68
+ highlighted?: boolean;
35
69
  };
36
- export const Message = ({ className, from, ...props }: MessageProps) => {
70
+ export const Message = ({
71
+ className,
72
+ from,
73
+ actions,
74
+ highlighted,
75
+ children,
76
+ ...props
77
+ }: MessageProps) => {
37
78
  const classes = useUiConfig("Message")?.classes;
38
79
  const merge = useCn();
39
- return (
80
+ const hasActions = actions !== undefined && actions.length > 0;
81
+ const root = (
40
82
  <div
41
83
  className={merge(
42
84
  classes?.root ?? "group flex w-full max-w-[95%] flex-col gap-2",
43
85
  from === "user"
44
86
  ? (classes?.user ?? "is-user ml-auto justify-end")
45
87
  : (classes?.assistant ?? "is-assistant"),
88
+ highlighted !== undefined &&
89
+ "rounded-xl transition-[background-color,box-shadow] duration-1000",
90
+ highlighted && "bg-primary/10 ring-2 ring-primary/30",
46
91
  className,
47
92
  )}
93
+ data-highlighted={highlighted ? "" : undefined}
48
94
  {...props}
49
- />
95
+ >
96
+ {hasActions ? (
97
+ <div
98
+ className={
99
+ from === "user"
100
+ ? "relative ml-auto w-fit max-w-full"
101
+ : "relative w-fit max-w-full"
102
+ }
103
+ >
104
+ {children}
105
+ <MessageHoverActions actions={actions} from={from} />
106
+ </div>
107
+ ) : (
108
+ children
109
+ )}
110
+ </div>
111
+ );
112
+ return hasActions ? (
113
+ <MessageContextMenu actions={actions}>{root}</MessageContextMenu>
114
+ ) : (
115
+ root
116
+ );
117
+ };
118
+ export type MessageContextMenuProps = {
119
+ actions: MessageMenuAction[];
120
+ /** One element, which becomes the right-click target (Radix `asChild`). */
121
+ children: ReactElement;
122
+ };
123
+ /**
124
+ * The right-click menu of a message: `actions` as `ContextMenuItem`s,
125
+ * destructive ones in the danger colour. With no actions it returns `children`.
126
+ */
127
+ export const MessageContextMenu = ({
128
+ actions,
129
+ children,
130
+ }: MessageContextMenuProps) => {
131
+ if (actions.length === 0) return children;
132
+ return (
133
+ <ContextMenu>
134
+ <ContextMenuTrigger asChild>{children}</ContextMenuTrigger>
135
+ <ContextMenuContent aria-label="Message actions">
136
+ {actions.map((action) => (
137
+ <ContextMenuItem
138
+ key={action.key}
139
+ disabled={action.disabled}
140
+ startContent={action.icon}
141
+ className={action.destructive ? "text-danger" : undefined}
142
+ onSelect={action.onSelect}
143
+ >
144
+ {action.label}
145
+ </ContextMenuItem>
146
+ ))}
147
+ </ContextMenuContent>
148
+ </ContextMenu>
149
+ );
150
+ };
151
+ export type MessageHoverActionsProps = Omit<
152
+ ComponentProps<"div">,
153
+ "children"
154
+ > & {
155
+ actions: MessageMenuAction[];
156
+ /** Role of the message: user toolbars sit left of the bubble, others right. Default `assistant`. */
157
+ from?: UIMessage["role"];
158
+ };
159
+ /**
160
+ * A small toolbar of icon buttons that shows on hover or focus of the
161
+ * enclosing `Message` (the `group` class, `reveal-on-hover`; always visible on
162
+ * touch). It is absolutely positioned, so it takes no layout space: beside the
163
+ * bubble, top-aligned (left of a user bubble, right of an assistant message).
164
+ * On narrow screens it moves to the bubble's top edge. Draws the actions that
165
+ * have an `icon`. Place it inside a `relative w-fit` wrapper around the
166
+ * bubble; the `actions` prop on `Message` does this.
167
+ */
168
+ export const MessageHoverActions = ({
169
+ actions,
170
+ from = "assistant",
171
+ className,
172
+ ...props
173
+ }: MessageHoverActionsProps) => {
174
+ const merge = useCn();
175
+ const shown = actions.filter((action) =>
176
+ action.hover === undefined ? action.icon !== undefined : action.hover,
177
+ );
178
+ if (shown.length === 0) return null;
179
+ return (
180
+ <div
181
+ role="toolbar"
182
+ aria-label="Message actions"
183
+ className={merge(
184
+ "reveal-on-hover absolute top-0 z-10 flex w-fit items-center gap-0.5 rounded-lg border border-border bg-surface p-0.5 shadow-sm transition-opacity",
185
+ from === "user"
186
+ ? "right-full mr-1 max-sm:right-2 max-sm:mr-0 max-sm:-translate-y-[62%] max-sm:[&>button]:size-6"
187
+ : "left-full ml-1 max-sm:left-auto max-sm:right-0 max-sm:ml-0 max-sm:-translate-y-full max-sm:[&>button]:size-6",
188
+ className,
189
+ )}
190
+ {...props}
191
+ >
192
+ {shown.map((action) => (
193
+ <MessageAction
194
+ key={action.key}
195
+ label={action.label}
196
+ tooltip={action.label}
197
+ tooltipSide="bottom"
198
+ // Narrow: the toolbar sits on the bubble corner and a tooltip below it would cover the text.
199
+ tooltipClassName="max-sm:hidden"
200
+ disabled={action.disabled}
201
+ className={action.destructive ? "text-danger" : undefined}
202
+ onClick={action.onSelect}
203
+ >
204
+ {action.icon}
205
+ </MessageAction>
206
+ ))}
207
+ </div>
208
+ );
209
+ };
210
+ export type MessageQuoteProps = Omit<ComponentProps<"div">, "onClick"> & {
211
+ /** Who wrote the quoted message. */
212
+ author: string;
213
+ /** The quoted text, cut to two lines. */
214
+ text: string;
215
+ /** Makes the quote a button, so the app can scroll to and highlight the original. */
216
+ onPress?: () => void;
217
+ };
218
+ /**
219
+ * A compact quote inside a message bubble, above its text: a left border, the
220
+ * author and two lines of the text. It reads on the primary user bubble, the
221
+ * border and text take the bubble colour. With `onPress` it is a button.
222
+ */
223
+ export const MessageQuote = ({
224
+ author,
225
+ text,
226
+ onPress,
227
+ className,
228
+ ...props
229
+ }: MessageQuoteProps) => {
230
+ const merge = useCn();
231
+ const body = (
232
+ <>
233
+ <span className="block truncate text-xs font-semibold opacity-70">
234
+ {author}
235
+ </span>
236
+ <span className="line-clamp-2 block text-sm opacity-90">{text}</span>
237
+ </>
238
+ );
239
+ const base = "block min-w-0 border-l-2 border-current/40 pl-2.5 text-left";
240
+ if (onPress === undefined) {
241
+ return (
242
+ <div
243
+ role="group"
244
+ aria-label={`Quoted message from ${author}`}
245
+ className={merge(base, className)}
246
+ {...props}
247
+ >
248
+ {body}
249
+ </div>
250
+ );
251
+ }
252
+ return (
253
+ <div
254
+ className={merge(base, className)}
255
+ {...props}
256
+ >
257
+ <button
258
+ type="button"
259
+ aria-label={`Go to quoted message from ${author}`}
260
+ onClick={onPress}
261
+ className="block w-full min-w-0 cursor-pointer text-left focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring"
262
+ >
263
+ {body}
264
+ </button>
265
+ </div>
50
266
  );
51
267
  };
52
268
  export type MessageContentProps = HTMLAttributes<HTMLDivElement>;
@@ -88,6 +304,10 @@ export const MessageActionsBar = ({
88
304
  };
89
305
  export type MessageActionProps = ComponentProps<typeof Button> & {
90
306
  tooltip?: string;
307
+ /** Side of the tooltip. Default: the Radix default (`top`). */
308
+ tooltipSide?: "top" | "right" | "bottom" | "left";
309
+ /** Extra classes for the tooltip content. */
310
+ tooltipClassName?: string;
91
311
  label?: string;
92
312
  };
93
313
  export const MessageAction = (props: MessageActionProps) => {
@@ -96,6 +316,8 @@ export const MessageAction = (props: MessageActionProps) => {
96
316
  };
97
317
  function EagerMessageAction({
98
318
  tooltip,
319
+ tooltipSide,
320
+ tooltipClassName,
99
321
  children,
100
322
  label,
101
323
  variant = "ghost",
@@ -113,7 +335,7 @@ function EagerMessageAction({
113
335
  <TooltipProvider>
114
336
  <Tooltip>
115
337
  <TooltipTrigger asChild>{button}</TooltipTrigger>
116
- <TooltipContent>
338
+ <TooltipContent side={tooltipSide} className={tooltipClassName}>
117
339
  <p>{tooltip}</p>
118
340
  </TooltipContent>
119
341
  </Tooltip>
@@ -129,6 +351,8 @@ function EagerMessageAction({
129
351
  */
130
352
  function LazyMessageAction({
131
353
  tooltip,
354
+ tooltipSide,
355
+ tooltipClassName,
132
356
  children,
133
357
  label,
134
358
  variant = "ghost",
@@ -166,7 +390,7 @@ function LazyMessageAction({
166
390
  {/* The focus that armed it landed before the tooltip existed. */}
167
391
  <Tooltip defaultOpen={armedBy === "focus"}>
168
392
  <TooltipTrigger asChild>{button}</TooltipTrigger>
169
- <TooltipContent>
393
+ <TooltipContent side={tooltipSide} className={tooltipClassName}>
170
394
  <p>{tooltip}</p>
171
395
  </TooltipContent>
172
396
  </Tooltip>
@@ -47,6 +47,7 @@ import {
47
47
  SelectTrigger,
48
48
  SelectValue,
49
49
  } from "../ui/radix-select";
50
+ import { Button } from "../ui/button";
50
51
  import { CrossfadeIconSlot } from "../ui/crossfade-icon";
51
52
  import { SendQueueIcon } from "../ui/send-queue-icon";
52
53
  import { Spinner } from "../ui/spinner";
@@ -811,6 +812,67 @@ export const PromptInputHeader = ({
811
812
  />
812
813
  );
813
814
  };
815
+ export type ReplyQuoteChipProps = Omit<ComponentProps<"div">, "children"> & {
816
+ /** Who wrote the message being replied to. */
817
+ author: string;
818
+ /** Its text, cut to two lines. */
819
+ text: string;
820
+ /** Called by the X button. Clear the reply in the app. */
821
+ onDismiss?: () => void;
822
+ /** Default "Dismiss reply quote". */
823
+ dismissLabel?: string;
824
+ };
825
+ /**
826
+ * The chip above the composer while a reply is pending: an accent bar, the
827
+ * author, two lines of the message and an X. Put it in `PromptInputHeader`:
828
+ *
829
+ * ```tsx
830
+ * <PromptInputHeader>
831
+ * {reply && <ReplyQuoteChip author={reply.author} text={reply.text} onDismiss={() => setReply(null)} />}
832
+ * </PromptInputHeader>
833
+ * ```
834
+ */
835
+ export const ReplyQuoteChip = ({
836
+ author,
837
+ text,
838
+ onDismiss,
839
+ dismissLabel = "Dismiss reply quote",
840
+ className,
841
+ ...props
842
+ }: ReplyQuoteChipProps) => {
843
+ const merge = useCn();
844
+ return (
845
+ <div
846
+ role="group"
847
+ aria-label={`Replying to ${author}`}
848
+ className={merge(
849
+ "flex w-full min-w-0 items-start gap-2 rounded-field border border-border bg-surface-secondary/60 py-1.5 pr-1 pl-3 text-sm",
850
+ className,
851
+ )}
852
+ {...props}
853
+ >
854
+ <span
855
+ aria-hidden="true"
856
+ className="w-0.5 shrink-0 self-stretch rounded-full bg-primary"
857
+ />
858
+ <div className="min-w-0 flex-1">
859
+ <span className="block truncate text-xs font-semibold text-muted-foreground">
860
+ {author}
861
+ </span>
862
+ <span className="line-clamp-2 block text-foreground">{text}</span>
863
+ </div>
864
+ <Button
865
+ type="button"
866
+ variant="ghost"
867
+ size="icon-sm"
868
+ aria-label={dismissLabel}
869
+ onClick={onDismiss}
870
+ >
871
+ <IconX size={14} />
872
+ </Button>
873
+ </div>
874
+ );
875
+ };
814
876
  export type PromptInputFooterProps = Omit<
815
877
  ComponentProps<typeof InputGroupAddon>,
816
878
  "align"
package/src/client.ts CHANGED
@@ -257,6 +257,7 @@ export type { SignOutDialogProps } from "../shell/src/SignOutDialog";
257
257
  export {
258
258
  railBadgeClass,
259
259
  railTileClass,
260
+ SIDEBAR_RAIL_APP_ICON_SIZE,
260
261
  railTileStateClass,
261
262
  sidebarNavIconClass,
262
263
  sidebarNavItemActionsClass,
@@ -313,6 +314,8 @@ export {
313
314
  landingPanelClass,
314
315
  landingShellClass,
315
316
  } from "./marketing/marketing-classes";
317
+ export { SITE_HEADER_WORDMARK_SIZE, SiteHeader } from "./marketing/SiteHeader";
318
+ export type { SiteHeaderLink, SiteHeaderProps } from "./marketing/SiteHeader";
316
319
  export type { PricingModalTone, SiteFooterVariant } from "./marketing/marketing-classes";
317
320
  export { PROJECT_V_LEGAL_LINKS, PROJECT_V_SOCIALS, SITE_FOOTER_WORDMARK_SIZE, SiteFooter } from "./marketing/SiteFooter";
318
321
  export type {
package/src/index.ts CHANGED
@@ -257,6 +257,7 @@ export type { SignOutDialogProps } from "../shell/src/SignOutDialog";
257
257
  export {
258
258
  railBadgeClass,
259
259
  railTileClass,
260
+ SIDEBAR_RAIL_APP_ICON_SIZE,
260
261
  railTileStateClass,
261
262
  sidebarNavIconClass,
262
263
  sidebarNavItemActionsClass,
@@ -313,6 +314,8 @@ export {
313
314
  landingPanelClass,
314
315
  landingShellClass,
315
316
  } from "./marketing/marketing-classes";
317
+ export { SITE_HEADER_WORDMARK_SIZE, SiteHeader } from "./marketing/SiteHeader";
318
+ export type { SiteHeaderLink, SiteHeaderProps } from "./marketing/SiteHeader";
316
319
  export type { PricingModalTone, SiteFooterVariant } from "./marketing/marketing-classes";
317
320
  export { PROJECT_V_LEGAL_LINKS, PROJECT_V_SOCIALS, SITE_FOOTER_WORDMARK_SIZE, SiteFooter } from "./marketing/SiteFooter";
318
321
  export type {
@@ -0,0 +1,128 @@
1
+ import { useEffect, useState, type ReactNode } from "react";
2
+ import { IconMenu2, IconX } from "@tabler/icons-react";
3
+ import { AppWordmark, type ProductAppName } from "../brand/AppWordmark";
4
+ import { GitHubIcon } from "../logos";
5
+ import { Button } from "../ui/button";
6
+ import {
7
+ DropdownMenu,
8
+ DropdownMenuContent,
9
+ DropdownMenuItem,
10
+ DropdownMenuSeparator,
11
+ DropdownMenuTrigger,
12
+ } from "../ui/dropdown-menu";
13
+ import { cn } from "../utils/cn";
14
+ import { landingClasses, landingShellClass } from "./marketing-classes";
15
+
16
+ /** Wordmark text size in px. The icon is `Math.round(20 * 1.4)` = 28px. There is no size prop. */
17
+ export const SITE_HEADER_WORDMARK_SIZE = 20;
18
+
19
+ const MAX_LINKS = 4;
20
+
21
+ export type SiteHeaderLink = { label: string; href: string };
22
+
23
+ export type SiteHeaderProps = {
24
+ /** Product app id for `AppWordmark`. */
25
+ app: ProductAppName;
26
+ /** Home link `href`. Default `#home`. */
27
+ homeHref?: string;
28
+ /** `aria-label` of the home wordmark link. */
29
+ homeLabel: string;
30
+ /** Up to 4 section links, shown from `md`. More than 4 logs a development warning. */
31
+ links: readonly SiteHeaderLink[];
32
+ /**
33
+ * Exactly one call to action, for example the app's Clerk "Sign in" or "Open <App>" button, or "Get the app".
34
+ * Pass a `Button` with the default variant, `size="sm"` and `className="rounded-full"`. It shows in the bar from `md`
35
+ * and below `md` it sits full width in the menu.
36
+ */
37
+ cta: ReactNode;
38
+ /** Optional GitHub URL. An icon button from `sm`, and the last item in the mobile menu. */
39
+ githubUrl?: string;
40
+ className?: string;
41
+ };
42
+
43
+ const focusRing =
44
+ "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background";
45
+
46
+ function SiteHeaderMenu({ links, cta, githubUrl }: Pick<SiteHeaderProps, "links" | "cta" | "githubUrl">) {
47
+ const [open, setOpen] = useState(false);
48
+ const Icon = open ? IconX : IconMenu2;
49
+ return (
50
+ <DropdownMenu open={open} onOpenChange={setOpen}>
51
+ <DropdownMenuTrigger asChild>
52
+ <Button variant="ghost" size="icon-sm" aria-label={open ? "Close menu" : "Open menu"} className="md:hidden">
53
+ <Icon size={20} />
54
+ </Button>
55
+ </DropdownMenuTrigger>
56
+ <DropdownMenuContent
57
+ align="end"
58
+ className="max-h-[70dvh] w-[calc(100vw-2.5rem)] max-w-none overflow-y-auto sm:w-[calc(100vw-4rem)]"
59
+ >
60
+ {links.slice(0, MAX_LINKS).map((link) => (
61
+ <DropdownMenuItem key={link.href} asChild className="max-sm:py-2.5">
62
+ <a href={link.href}>{link.label}</a>
63
+ </DropdownMenuItem>
64
+ ))}
65
+ <div data-site-header-cta="" className="p-1 [&>*]:w-full">
66
+ {cta}
67
+ </div>
68
+ {githubUrl ? (
69
+ <>
70
+ <DropdownMenuSeparator />
71
+ <DropdownMenuItem asChild className="max-sm:py-2.5">
72
+ <a href={githubUrl} target="_blank" rel="noreferrer">
73
+ <GitHubIcon size={16} />
74
+ GitHub
75
+ </a>
76
+ </DropdownMenuItem>
77
+ </>
78
+ ) : null}
79
+ </DropdownMenuContent>
80
+ </DropdownMenu>
81
+ );
82
+ }
83
+
84
+ /**
85
+ * Marketing header for the Project V apps: sticky, blurred, `AppWordmark` at `SITE_HEADER_WORDMARK_SIZE`, up to four
86
+ * links, one `cta` and an optional GitHub button. Below `md` the links, the CTA and GitHub move into one dropdown menu.
87
+ * Plain markup with no motion, so any app can use it.
88
+ */
89
+ export function SiteHeader({ app, homeHref = "#home", homeLabel, links, cta, githubUrl, className }: SiteHeaderProps) {
90
+ const tooMany = links.length > MAX_LINKS;
91
+ useEffect(() => {
92
+ if (!tooMany) return;
93
+ const nodeEnv = (globalThis as { process?: { env?: { NODE_ENV?: string } } }).process?.env?.NODE_ENV;
94
+ if (nodeEnv === "production") return;
95
+ console.warn(`[SiteHeader] SiteHeader supports at most ${MAX_LINKS} links. Received ${links.length}.`);
96
+ }, [tooMany, links.length]);
97
+
98
+ const shown = links.slice(0, MAX_LINKS);
99
+ return (
100
+ <header className={cn("sticky top-0 z-50 border-b border-separator bg-background/80 backdrop-blur-md", className)}>
101
+ <div className={cn(landingShellClass, "flex h-14 items-center justify-between gap-3 sm:h-16")}>
102
+ <a href={homeHref} className={cn("group min-w-0 shrink-0 rounded-lg", focusRing)} aria-label={homeLabel}>
103
+ <AppWordmark app={app} size={SITE_HEADER_WORDMARK_SIZE} />
104
+ </a>
105
+
106
+ <nav className="hidden items-center gap-1 md:flex" aria-label="Page">
107
+ {shown.map((link) => (
108
+ <a key={link.href} href={link.href} className={landingClasses.navLink}>
109
+ {link.label}
110
+ </a>
111
+ ))}
112
+ </nav>
113
+
114
+ <div className="flex items-center gap-1.5">
115
+ {githubUrl ? (
116
+ <Button asChild variant="ghost" size="icon-sm" className="hidden sm:inline-flex">
117
+ <a href={githubUrl} target="_blank" rel="noreferrer" aria-label={`${app} on GitHub`}>
118
+ <GitHubIcon size={18} />
119
+ </a>
120
+ </Button>
121
+ ) : null}
122
+ <div className="hidden md:flex">{cta}</div>
123
+ <SiteHeaderMenu links={shown} cta={cta} githubUrl={githubUrl} />
124
+ </div>
125
+ </div>
126
+ </header>
127
+ );
128
+ }
@@ -153,7 +153,7 @@ export function createLandingKit({ motion }: LandingKitOptions): LandingKit {
153
153
  );
154
154
  }
155
155
 
156
- /** Sticky blurred header that rises in on mount. */
156
+ /** @deprecated Use `SiteHeader`. Sticky blurred header that rises in on mount. */
157
157
  function LandingNav({ brand, homeLabel, links, github, actions, menu }: LandingNavProps) {
158
158
  return (
159
159
  <motion.header