@vvedantb/ui 0.38.0 → 0.39.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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"
@@ -292,7 +292,7 @@ function QueueWidgetItem({
292
292
  <Textarea
293
293
  value={editText}
294
294
  onChange={(e) => setEditText(e.currentTarget.value)}
295
- className="min-h-[60px] text-sm"
295
+ className="min-h-[60px]"
296
296
  autoFocus
297
297
  />
298
298
  <div className="flex justify-end gap-2">
@@ -47,8 +47,9 @@ export function AgentTraceLine({
47
47
  data-status={status}
48
48
  className={merge(
49
49
  "flex w-fit max-w-full min-w-0 items-center gap-2 text-muted-foreground text-sm transition-colors",
50
- "in-[button:hover]:text-foreground",
51
- "data-[status=error]:text-destructive data-[status=error]:in-[button:hover]:text-destructive",
50
+ // Gated like `hover:` plus a fine pointer, so a tap on a phone does not leave it lit.
51
+ "[@media(hover:hover)_and_(pointer:fine)]:in-[button:hover]:text-foreground",
52
+ "data-[status=error]:text-destructive data-[status=error]:[@media(hover:hover)_and_(pointer:fine)]:in-[button:hover]:text-destructive",
52
53
  "data-[status=pending]:opacity-60",
53
54
  className,
54
55
  )}
@@ -1,7 +1,7 @@
1
1
  import { useId, type CSSProperties, type ReactNode, type SVGProps } from "react";
2
2
  import { cn } from "../utils/cn";
3
3
 
4
- /** `"hover"`: animate while an ancestor with the `group` class is hovered. `true`: always. `false`: never. */
4
+ /** `"hover"`: animate while an ancestor with the `group` class is hovered by a mouse (fine pointer). `true`: always. `false`: never. */
5
5
  export type IconAnimate = boolean | "hover";
6
6
 
7
7
  export type IconProps = Omit<SVGProps<SVGSVGElement>, "children" | "color" | "height" | "width" | "strokeWidth"> & {
@@ -12,8 +12,8 @@ exports[`ClearInputAlt markup > every prop 1`] = `"<div class="relative w-40"><s
12
12
 
13
13
  exports[`ClearInputAlt markup > filled 1`] = `"<div class="relative"><input class="flex h-10 w-full rounded-field border border-border bg-field-background px-3.5 py-2 text-sm transition-[border-color,background-color] duration-200 ease-smooth 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 pr-9" type="text" value="abc"><button type="button" tabindex="0" class="absolute right-2 top-1/2 inline-flex h-6 w-6 -translate-y-1/2 items-center justify-center rounded-md text-muted-foreground transition-[opacity,background-color,color,transform] duration-150 ease-out active:duration-100 active:ease-out active:scale-[0.97] hover:bg-surface-tertiary hover:text-foreground focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-focus-ring before:absolute before:inset-[-8px] before:content-[''] opacity-100" aria-hidden="false" aria-label="Clear"><svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" 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></button></div>"`;
14
14
 
15
- exports[`InputGroupAlt markup > input className and props 1`] = `"<div data-input-group="" class="flex h-10 flex-wrap rounded-field border border-border bg-field-background text-sm transition-[border-color,box-shadow] duration-200 ease-smooth has-[textarea]:h-auto has-data-[slot=input-group-control]:h-auto has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-focus-visible:outline-hidden has-focus-visible:border-focus-border has-focus-visible:ring-1 has-focus-visible:ring-focus-ring"><input class="flex w-full max-sm:min-w-0 border-border text-base sm:text-sm transition-[border-color,box-shadow,background-color] duration-200 ease-smooth file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-field-placeholder disabled:cursor-not-allowed disabled:opacity-50 focus-visible:outline-hidden focus-visible:border-focus-border focus-visible:ring-focus-ring h-full flex-1 rounded-none border-0 bg-transparent px-3 py-2 shadow-none focus-visible:border-0 focus-visible:ring-0 in-x" id="i" disabled=""></div>"`;
15
+ exports[`InputGroupAlt markup > input className and props 1`] = `"<div data-input-group="" class="flex h-10 flex-wrap rounded-field border border-border bg-field-background text-sm transition-[border-color,box-shadow] duration-200 ease-smooth has-[textarea]:h-auto has-data-[slot=input-group-control]:h-auto has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-focus-visible:outline-hidden has-focus-visible:border-focus-border has-focus-visible:ring-1 has-focus-visible:ring-focus-ring"><input class="flex w-full max-sm:min-w-0 border-border text-base sm:pointer-fine:text-sm transition-[border-color,box-shadow,background-color] duration-200 ease-smooth file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-field-placeholder disabled:cursor-not-allowed disabled:opacity-50 focus-visible:outline-hidden focus-visible:border-focus-border focus-visible:ring-focus-ring h-full flex-1 rounded-none border-0 bg-transparent px-3 py-2 shadow-none focus-visible:border-0 focus-visible:ring-0 in-x" id="i" disabled=""></div>"`;
16
16
 
17
- exports[`InputGroupAlt markup > input with every addon align and button size 1`] = `"<div data-input-group="" class="flex h-10 flex-wrap rounded-field border border-border bg-field-background text-sm transition-[border-color,box-shadow] duration-200 ease-smooth has-[textarea]:h-auto has-data-[slot=input-group-control]:h-auto has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-focus-visible:outline-hidden has-focus-visible:border-focus-border has-focus-visible:ring-1 has-focus-visible:ring-focus-ring"><div class="flex items-center flex-row ps-3"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="tabler-icon tabler-icon-search"><path d="M3 10a7 7 0 1 0 14 0a7 7 0 1 0 -14 0"></path><path d="M21 21l-6 -6"></path></svg></div><div data-align="inline-start" class="flex items-center flex-row ps-3"><span class="flex items-center text-sm text-muted-foreground/95">https://</span></div><input class="flex w-full max-sm:min-w-0 border-border text-base sm:text-sm transition-[border-color,box-shadow,background-color] duration-200 ease-smooth file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-field-placeholder disabled:cursor-not-allowed disabled:opacity-50 focus-visible:outline-hidden focus-visible:border-focus-border focus-visible:ring-focus-ring h-full flex-1 rounded-none border-0 bg-transparent px-3 py-2 shadow-none focus-visible:border-0 focus-visible:ring-0" placeholder="Search"><div data-align="inline-end" class="flex items-center flex-row pe-3"><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 py-2 shrink-0 shadow-none h-7 rounded-lg px-2 text-xs">Go</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 text-muted-foreground hover:bg-surface-tertiary hover:text-foreground py-2 shrink-0 shadow-none h-8 rounded-lg px-2.5 text-xs">Small</button><button class="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] [&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 px-4 py-2 shrink-0 shadow-none h-7 w-7 rounded-lg" aria-label="A"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="tabler-icon tabler-icon-search"><path d="M3 10a7 7 0 1 0 14 0a7 7 0 1 0 -14 0"></path><path d="M21 21l-6 -6"></path></svg></button><button class="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] [&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 px-4 py-2 shrink-0 shadow-none h-8 w-8 rounded-lg" type="submit" aria-label="B"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="tabler-icon tabler-icon-search"><path d="M3 10a7 7 0 1 0 14 0a7 7 0 1 0 -14 0"></path><path d="M21 21l-6 -6"></path></svg></button></div></div>"`;
17
+ exports[`InputGroupAlt markup > input with every addon align and button size 1`] = `"<div data-input-group="" class="flex h-10 flex-wrap rounded-field border border-border bg-field-background text-sm transition-[border-color,box-shadow] duration-200 ease-smooth has-[textarea]:h-auto has-data-[slot=input-group-control]:h-auto has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-focus-visible:outline-hidden has-focus-visible:border-focus-border has-focus-visible:ring-1 has-focus-visible:ring-focus-ring"><div class="flex items-center flex-row ps-3"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="tabler-icon tabler-icon-search"><path d="M3 10a7 7 0 1 0 14 0a7 7 0 1 0 -14 0"></path><path d="M21 21l-6 -6"></path></svg></div><div data-align="inline-start" class="flex items-center flex-row ps-3"><span class="flex items-center text-sm text-muted-foreground/95">https://</span></div><input class="flex w-full max-sm:min-w-0 border-border text-base sm:pointer-fine:text-sm transition-[border-color,box-shadow,background-color] duration-200 ease-smooth file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-field-placeholder disabled:cursor-not-allowed disabled:opacity-50 focus-visible:outline-hidden focus-visible:border-focus-border focus-visible:ring-focus-ring h-full flex-1 rounded-none border-0 bg-transparent px-3 py-2 shadow-none focus-visible:border-0 focus-visible:ring-0" placeholder="Search"><div data-align="inline-end" class="flex items-center flex-row pe-3"><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] active:duration-[var(--motion-fast,150ms)] [&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 py-2 shrink-0 shadow-none h-7 rounded-lg px-2 text-xs">Go</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] active:duration-[var(--motion-fast,150ms)] [&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 py-2 shrink-0 shadow-none h-8 rounded-lg px-2.5 text-xs">Small</button><button class="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] active:duration-[var(--motion-fast,150ms)] [&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 px-4 py-2 shrink-0 shadow-none h-7 w-7 rounded-lg" aria-label="A"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="tabler-icon tabler-icon-search"><path d="M3 10a7 7 0 1 0 14 0a7 7 0 1 0 -14 0"></path><path d="M21 21l-6 -6"></path></svg></button><button class="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] active:duration-[var(--motion-fast,150ms)] [&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 px-4 py-2 shrink-0 shadow-none h-8 w-8 rounded-lg" type="submit" aria-label="B"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="tabler-icon tabler-icon-search"><path d="M3 10a7 7 0 1 0 14 0a7 7 0 1 0 -14 0"></path><path d="M21 21l-6 -6"></path></svg></button></div></div>"`;
18
18
 
19
- exports[`InputGroupAlt markup > textarea with block addons, error and classNames 1`] = `"<div data-input-group="" class="flex h-10 flex-wrap rounded-field border bg-field-background text-sm transition-[border-color,box-shadow] duration-200 ease-smooth has-[textarea]:h-auto has-data-[slot=input-group-control]:h-auto has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-focus-visible:outline-hidden has-focus-visible:ring-1 border-danger-border has-focus-visible:border-danger has-focus-visible:ring-danger-ring root-x" id="g"><div data-align="block-start" class="flex items-center w-full flex-row border-b border-separator px-3 py-2 addon-x"><span class="flex items-center text-sm text-x">Title</span></div><textarea class="flex min-h-20 w-full max-sm:min-w-0 border-border text-base 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-focus-ring flex-1 resize-none rounded-none border-0 bg-transparent px-3 py-2.5 shadow-none focus-visible:border-0 focus-visible:ring-0 field-sizing-content ta-x" rows="2">v</textarea><div data-align="block-end" class="flex items-center w-full flex-row px-3 py-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 py-2 shrink-0 shadow-none h-7 rounded-lg px-2 text-xs btn-x" disabled="" data-loading="true"><svg class="h-4 w-4 animate-spin" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c3.042 1.135 5.824 3 7.938l3-2.647z"></path></svg>Send</button></div></div>"`;
19
+ exports[`InputGroupAlt markup > textarea with block addons, error and classNames 1`] = `"<div data-input-group="" class="flex h-10 flex-wrap rounded-field border bg-field-background text-sm transition-[border-color,box-shadow] duration-200 ease-smooth has-[textarea]:h-auto has-data-[slot=input-group-control]:h-auto has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-focus-visible:outline-hidden has-focus-visible:ring-1 border-danger-border has-focus-visible:border-danger has-focus-visible:ring-danger-ring root-x" id="g"><div data-align="block-start" class="flex items-center w-full flex-row border-b border-separator px-3 py-2 addon-x"><span class="flex items-center text-sm text-x">Title</span></div><textarea class="flex min-h-20 w-full max-sm:min-w-0 border-border text-base sm:pointer-fine: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-focus-ring flex-1 resize-none rounded-none border-0 bg-transparent px-3 py-2.5 shadow-none focus-visible:border-0 focus-visible:ring-0 field-sizing-content ta-x" rows="2">v</textarea><div data-align="block-end" class="flex items-center w-full flex-row px-3 py-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] active:duration-[var(--motion-fast,150ms)] [&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 py-2 shrink-0 shadow-none h-7 rounded-lg px-2 text-xs btn-x" disabled="" data-loading="true"><svg class="h-4 w-4 animate-spin" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c3.042 1.135 5.824 3 7.938l3-2.647z"></path></svg>Send</button></div></div>"`;
@@ -2,4 +2,4 @@
2
2
 
3
3
  exports[`SonnerToaster DOM > renders the toaster and a toast 1`] = `"<section aria-label="Notifications alt+T" tabindex="-1" aria-live="polite" aria-relevant="additions text" aria-atomic="false" data-react-aria-top-layer="true"></section>"`;
4
4
 
5
- exports[`SonnerToaster DOM > renders the toaster and a toast 2`] = `"<section aria-label="Notifications alt+T" tabindex="-1" aria-live="polite" aria-relevant="additions text" aria-atomic="false" data-react-aria-top-layer="true"><ol dir="ltr" tabindex="-1" class="toaster group !font-sans" data-sonner-toaster="true" data-sonner-theme="light" data-y-position="top" data-x-position="right" style="--front-toast-height: 0px; --width: 356px; --gap: 12px; --offset-top: 24px; --offset-right: 24px; --offset-bottom: 24px; --offset-left: 24px; --mobile-offset-top: 16px; --mobile-offset-right: 16px; --mobile-offset-bottom: 16px; --mobile-offset-left: 16px;"><li tabindex="0" class="group toast glass-panel-strong flex w-full items-center gap-3 rounded-lg px-4 py-3 text-sm text-foreground smooth-shadow-ring-md" data-sonner-toast="" data-styled="false" data-mounted="true" data-promise="false" data-swiped="false" data-removed="false" data-visible="true" data-y-position="top" data-x-position="right" data-index="0" data-front="true" data-swiping="false" data-dismissible="true" data-swipe-out="false" data-expanded="false" style="--index: 0; --toasts-before: 0; --z-index: 1; --offset: 0px; --initial-height: 0px;"><div data-content="" class="flex min-w-0 grow flex-col"><div data-title="" class="font-medium leading-snug">Saved</div><div data-description="" class="text-[13px] leading-snug text-muted-foreground">All changes stored</div></div></li></ol></section>"`;
5
+ exports[`SonnerToaster DOM > renders the toaster and a toast 2`] = `"<section aria-label="Notifications alt+T" tabindex="-1" aria-live="polite" aria-relevant="additions text" aria-atomic="false" data-react-aria-top-layer="true"><ol dir="ltr" tabindex="-1" class="toaster group !font-sans" data-sonner-toaster="true" data-sonner-theme="light" data-y-position="top" data-x-position="right" style="--front-toast-height: 0px; --width: 356px; --gap: 12px; --offset-top: 24px; --offset-right: 24px; --offset-bottom: 24px; --offset-left: 24px; --mobile-offset-top: calc(16px + env(safe-area-inset-top, 0px)); --mobile-offset-right: 16px; --mobile-offset-bottom: calc(16px + env(safe-area-inset-bottom, 0px)); --mobile-offset-left: 16px;"><li tabindex="0" class="group toast glass-panel-strong flex w-full items-center gap-3 rounded-lg px-4 py-3 text-sm text-foreground smooth-shadow-ring-md" data-sonner-toast="" data-styled="false" data-mounted="true" data-promise="false" data-swiped="false" data-removed="false" data-visible="true" data-y-position="top" data-x-position="right" data-index="0" data-front="true" data-swiping="false" data-dismissible="true" data-swipe-out="false" data-expanded="false" style="--index: 0; --toasts-before: 0; --z-index: 1; --offset: 0px; --initial-height: 0px;"><div data-content="" class="flex min-w-0 grow flex-col"><div data-title="" class="font-medium leading-snug">Saved</div><div data-description="" class="text-[13px] leading-snug text-muted-foreground">All changes stored</div></div></li></ol></section>"`;
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 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] active:duration-[var(--motion-fast,150ms)] [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:shrink-0";
12
12
 
13
13
  const buttonVariantClasses = {
14
14
  default:
@@ -49,7 +49,7 @@ export interface ClearInputProps extends Omit<
49
49
  }
50
50
 
51
51
  const inputClasses =
52
- "flex h-10 w-full max-sm:min-w-0 rounded-field border border-border bg-field-background px-3.5 py-2 text-base sm:text-sm transition-[border-color,box-shadow,background-color] duration-200 ease-smooth placeholder:text-field-placeholder disabled:cursor-not-allowed disabled:opacity-50";
52
+ "flex h-10 w-full max-sm:min-w-0 rounded-field border border-border bg-field-background px-3.5 py-2 text-base sm:pointer-fine:text-sm transition-[border-color,box-shadow,background-color] duration-200 ease-smooth placeholder:text-field-placeholder disabled:cursor-not-allowed disabled:opacity-50";
53
53
 
54
54
  const buttonClasses =
55
55
  "absolute right-2 top-1/2 z-4 inline-flex h-6 w-6 -translate-y-1/2 items-center justify-center rounded-md text-muted-foreground transition-[background-color,color,transform] duration-100 ease-out active:scale-[0.92] hover:bg-surface-tertiary/50 hover:text-foreground focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-focus-ring max-sm:hit-target";
@@ -131,7 +131,7 @@ const CommandInput = React.forwardRef<
131
131
  ref={ref}
132
132
  className={merge(
133
133
  config?.classes?.input ??
134
- "flex h-11 w-full max-sm:min-w-0 bg-transparent text-base sm:text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50",
134
+ "flex h-11 w-full max-sm:min-w-0 bg-transparent text-base sm:pointer-fine:text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50",
135
135
  className,
136
136
  )}
137
137
  {...props}
package/src/ui/input.tsx CHANGED
@@ -16,7 +16,7 @@ const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
16
16
  type={type}
17
17
  className={merge(
18
18
  base ?? [
19
- "flex h-10 w-full max-sm:min-w-0 rounded-field border border-border bg-field-background px-3.5 py-2 text-base sm:text-sm transition-[border-color,box-shadow,background-color] duration-200 ease-smooth file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-field-placeholder disabled:cursor-not-allowed disabled:opacity-50",
19
+ "flex h-10 w-full max-sm:min-w-0 rounded-field border border-border bg-field-background px-3.5 py-2 text-base sm:pointer-fine:text-sm transition-[border-color,box-shadow,background-color] duration-200 ease-smooth file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-field-placeholder disabled:cursor-not-allowed disabled:opacity-50",
20
20
  fieldFocusClass,
21
21
  ],
22
22
  className,
@@ -7,7 +7,7 @@ import { Kbd } from "./kbd";
7
7
  import { useCn, useUiConfig } from "./ui-config";
8
8
 
9
9
  const sizeClasses = {
10
- sm: { input: "h-8 pl-8 text-base sm:text-sm", icon: "left-2" },
10
+ sm: { input: "h-8 pl-8 text-base sm:pointer-fine:text-sm", icon: "left-2" },
11
11
  md: { input: "h-10 pl-9", icon: "left-3" },
12
12
  };
13
13
 
package/src/ui/select.tsx CHANGED
@@ -55,7 +55,7 @@ const SelectTrigger = React.forwardRef<
55
55
  data-slot={(config?.triggerSlot ?? true) ? "select-trigger" : undefined}
56
56
  className={merge(
57
57
  config?.classes?.trigger ?? [
58
- "flex h-10 w-full max-sm:min-w-0 items-center justify-between whitespace-nowrap rounded-field border border-border bg-field-background px-3.5 py-2 text-base sm:text-sm ring-offset-background placeholder:text-field-placeholder transition-[border-color,box-shadow,background-color] duration-200 ease-smooth disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
58
+ "flex h-10 w-full max-sm:min-w-0 items-center justify-between whitespace-nowrap rounded-field border border-border bg-field-background px-3.5 py-2 text-base sm:pointer-fine:text-sm ring-offset-background placeholder:text-field-placeholder transition-[border-color,box-shadow,background-color] duration-200 ease-smooth disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
59
59
  fieldFocusClass,
60
60
  ],
61
61
  className,
package/src/ui/sonner.tsx CHANGED
@@ -16,6 +16,7 @@ import "./sonner.css";
16
16
  //
17
17
  // Position: `top-right` by default (sonner's own default is `bottom-right`), as
18
18
  // apps mount it. A `position` prop or `UiConfig.SonnerToaster.position` wins.
19
+ // `mobileOffset` adds the safe-area insets to sonner's 16px; a prop or config replaces it.
19
20
  //
20
21
  // Stacking: `expand={false}` keeps toasts collapsed (offset + scale). Hovering
21
22
  // the stack expands them. `sonner.css` hides back-toast content while collapsed
@@ -100,6 +101,14 @@ const defaultCancelButtonClassName =
100
101
 
101
102
  const defaultToasterPosition = "top-right" satisfies ToasterProps["position"];
102
103
 
104
+ // Sonner's 16px phone offset (viewport up to 600px) plus the safe-area inset, so
105
+ // toasts clear the notch and home indicator under a translucent status bar
106
+ // (`viewport-fit=cover`). `env()` is 0 without it. Left and right keep 16px.
107
+ const defaultMobileOffset = {
108
+ top: "calc(16px + env(safe-area-inset-top, 0px))",
109
+ bottom: "calc(16px + env(safe-area-inset-bottom, 0px))",
110
+ } satisfies ToasterProps["mobileOffset"];
111
+
103
112
  const toastRadiusClasses = {
104
113
  lg: "rounded-lg",
105
114
  surface: "rounded-surface",
@@ -220,6 +229,7 @@ function SonnerToaster(props: ToasterProps & SonnerToasterOptions) {
220
229
  // Defaulted here, not by spreading after a literal, so `position={undefined}`
221
230
  // still gets top-right rather than sonner's bottom-right.
222
231
  position = defaultToasterPosition,
232
+ mobileOffset = defaultMobileOffset,
223
233
  toastRadius,
224
234
  density,
225
235
  toastIcons = "default",
@@ -252,6 +262,7 @@ function SonnerToaster(props: ToasterProps & SonnerToasterOptions) {
252
262
  gap={12}
253
263
  {...rest}
254
264
  position={position}
265
+ mobileOffset={mobileOffset}
255
266
  closeButton={closeButton ?? (closeButtonStyle === "hoverChip" ? true : undefined)}
256
267
  className={
257
268
  toasterClassName && className
@@ -17,7 +17,7 @@ const Textarea = React.forwardRef<
17
17
  <textarea
18
18
  className={merge(
19
19
  base ?? [
20
- "flex min-h-20 w-full max-sm:min-w-0 rounded-field border border-border bg-field-background px-3.5 py-2.5 text-base sm:text-sm placeholder:text-field-placeholder disabled:cursor-not-allowed disabled:opacity-50",
20
+ "flex min-h-20 w-full max-sm:min-w-0 rounded-field border border-border bg-field-background px-3.5 py-2.5 text-base sm:pointer-fine:text-sm placeholder:text-field-placeholder disabled:cursor-not-allowed disabled:opacity-50",
21
21
  fieldFocusClass,
22
22
  ],
23
23
  className,
@@ -14,6 +14,29 @@
14
14
  accent-color: var(--accent);
15
15
  }
16
16
 
17
+ /* No double-tap-zoom wait on controls, so a tap fires `click` at once.
18
+ touch-action is not inherited and the `html` value stops at the first
19
+ scroll container, so controls inside scroll areas need their own. In
20
+ `@layer base`, so a `touch-*` utility (e.g. `touch-pan-y`) still wins. */
21
+ button,
22
+ a[href],
23
+ summary,
24
+ label,
25
+ [role="button"],
26
+ [role="link"],
27
+ [role="tab"],
28
+ [role="option"],
29
+ [role="menuitem"],
30
+ [role="menuitemcheckbox"],
31
+ [role="menuitemradio"],
32
+ [role="checkbox"],
33
+ [role="radio"],
34
+ [role="switch"],
35
+ input[type="checkbox"],
36
+ input[type="radio"] {
37
+ touch-action: manipulation;
38
+ }
39
+
17
40
  ::selection {
18
41
  background-color: color-mix(in oklch, var(--accent) 18%, transparent);
19
42
  color: var(--foreground);
@@ -181,8 +181,10 @@
181
181
  text-underline-offset: 0.2em;
182
182
  transition: text-decoration-color var(--motion-fast, 150ms) ease-out;
183
183
  }
184
- .markdown-link:hover {
185
- text-decoration-color: currentColor;
184
+ @media (hover: hover) and (pointer: fine) {
185
+ .markdown-link:hover {
186
+ text-decoration-color: currentColor;
187
+ }
186
188
  }
187
189
  .markdown-link-icon {
188
190
  display: inline-block;
@@ -35,9 +35,12 @@
35
35
  will-change: transform;
36
36
  }
37
37
 
38
- .mq-host[data-mq-overflow="true"]:hover .mq-inner,
39
- .group:hover .mq-host[data-mq-overflow="true"] .mq-inner {
40
- max-width: none; /* let the text spill to its full width; the host clips it */
41
- transform: translateX(calc(-1 * var(--mq-shift, 0px)));
42
- transition: transform var(--mq-dur, 0s) linear var(--mq-delay, 400ms);
38
+ /* Fine pointers only: on touch a tap leaves :hover stuck and the text scrolled. */
39
+ @media (hover: hover) and (pointer: fine) {
40
+ .mq-host[data-mq-overflow="true"]:hover .mq-inner,
41
+ .group:hover .mq-host[data-mq-overflow="true"] .mq-inner {
42
+ max-width: none; /* let the text spill to its full width; the host clips it */
43
+ transform: translateX(calc(-1 * var(--mq-shift, 0px)));
44
+ transition: transform var(--mq-dur, 0s) linear var(--mq-delay, 400ms);
45
+ }
43
46
  }