@vvedantb/ui 0.39.0 → 0.40.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/README.md +52 -1
  2. package/package.json +4 -3
  3. package/src/ai-elements/__snapshots__/queue.test.tsx.snap +3 -3
  4. package/src/ai-elements/activity-shared.tsx +1 -103
  5. package/src/ai-elements/activity-step-label.ts +1 -1
  6. package/src/ai-elements/activity-step-types.ts +107 -0
  7. package/src/ai-elements/activity-tasks-utils.ts +1 -1
  8. package/src/ai-elements/activity-tasks.tsx +14 -10
  9. package/src/ai-elements/queue.tsx +1 -1
  10. package/src/ai-elements/trace.tsx +3 -2
  11. package/src/client.ts +3 -0
  12. package/src/icons/nav-icon-base.tsx +1 -1
  13. package/src/index.ts +3 -0
  14. package/src/marketing/ProductHero.tsx +129 -0
  15. package/src/marketing/SiteFooter.tsx +2 -1
  16. package/src/marketing/marketing-classes.ts +3 -0
  17. package/src/marketing/projectV.ts +2 -0
  18. package/src/ui/__snapshots__/alt-wrappers.test.tsx.snap +3 -3
  19. package/src/ui/__snapshots__/sonner-dom.test.tsx.snap +1 -1
  20. package/src/ui/button.tsx +1 -1
  21. package/src/ui/clear-input.tsx +1 -1
  22. package/src/ui/command.tsx +1 -1
  23. package/src/ui/input.tsx +1 -1
  24. package/src/ui/search-input.tsx +1 -1
  25. package/src/ui/select.tsx +1 -1
  26. package/src/ui/sonner.tsx +11 -0
  27. package/src/ui/textarea.tsx +1 -1
  28. package/tokens/src/css/base.css +23 -0
  29. package/tokens/src/css/markdown.css +4 -2
  30. package/tokens/src/css/marquee.css +8 -5
  31. package/tokens/src/css/nav-icons.css +121 -152
  32. package/tokens/src/css/scrollbar-thin.css +17 -13
  33. package/tokens/src/css/tabs-pills.css +9 -7
  34. package/tokens/src/css/tabs.css +5 -1
  35. package/tokens/src/css/utilities.css +2 -2
  36. package/tokens/src/tokens.css +8 -6
@@ -0,0 +1,129 @@
1
+ import type { ComponentProps, MouseEventHandler, ReactNode } from "react";
2
+ import { Button } from "../ui/button";
3
+ import { pageTitleClass } from "../ui/page-title";
4
+ import { useMediaQuery } from "../hooks/use-media-query";
5
+ import { cn } from "../utils/cn";
6
+ import { landingShellClass, productHeroSocialProofClass } from "./marketing-classes";
7
+ import { PROJECT_V_HOME_URL } from "./projectV";
8
+
9
+ export type ProductHeroAction = {
10
+ label: string;
11
+ /** Renders an `a`. Without it the action renders a `button`. */
12
+ href?: string;
13
+ /** Passed to the `a`. Only used with `href`. */
14
+ rel?: string;
15
+ /** Passed to the `a`. Only used with `href`. */
16
+ target?: string;
17
+ onClick?: MouseEventHandler;
18
+ /** Default: `"default"` for the primary action and `"outline"` for the secondary action. */
19
+ variant?: ComponentProps<typeof Button>["variant"];
20
+ };
21
+
22
+ export type ProductHeroProps = {
23
+ /** The only `h1` of the hero. Instrument Sans, weight 600, `leading-tight`, `tracking-tight` (`pageTitleClass`), at a display scale. */
24
+ title: ReactNode;
25
+ /** Short product description under the title. */
26
+ tagline: ReactNode;
27
+ primaryCta: ProductHeroAction;
28
+ secondaryCta?: ProductHeroAction;
29
+ /**
30
+ * Renders exactly one "Sign in" control. Omit it for none. The hero never renders two,
31
+ * so leave the page nav without its own Sign in. Set `href` or `onClick`.
32
+ */
33
+ signIn?: { href?: string; onClick?: MouseEventHandler };
34
+ /**
35
+ * An animated concept visual in the style of https://projectv.uk: a glowing ring, orbit
36
+ * or particle field that conveys the idea of the product.
37
+ *
38
+ * It must NOT be an app replica, a screenshot or a mock of the product UI. Those age as
39
+ * the app changes and are the drift this component removes.
40
+ *
41
+ * If the visual animates, it must respect `prefers-reduced-motion` itself.
42
+ */
43
+ visual: ReactNode;
44
+ /** Gives the visual region `role="img"` and this accessible name. Without it the region is decorative (`aria-hidden`). */
45
+ visualLabel?: string;
46
+ /**
47
+ * Live user count for social proof under the actions: "Free to use · 1,284+ people already here",
48
+ * with a pulsing green dot (static under `prefers-reduced-motion: reduce`).
49
+ *
50
+ * The package does no fetching. The app calls a Convex action that wraps the Clerk backend SDK
51
+ * `clerk.users.getCount()` (needs `CLERK_SECRET_KEY`), once on mount or cached server-side.
52
+ * Pass `undefined` while loading and the positive number once it resolves. The row stays hidden
53
+ * for `undefined`, `null` and any value of 0 or less, so a loading `0` never shows "0+ people".
54
+ */
55
+ userCount?: number | null;
56
+ /** Default `true`. Renders "a product of Project V", always linked to `PROJECT_V_HOME_URL`. The link target cannot be changed; `false` hides the whole line. */
57
+ showProductOf?: boolean;
58
+ className?: string;
59
+ };
60
+
61
+ // Same family, weight, leading and tracking as `PageTitle`; only the size changes with the viewport.
62
+ // Joined as plain strings: `cn` would drop `leading-tight` because a `text-*` size class conflicts with it.
63
+ const titleClass = `text-balance text-4xl sm:text-5xl lg:text-6xl ${pageTitleClass.replace(/\btext-xl\s*/, "")}`;
64
+
65
+ function UserCountProof({ count }: { count: number }) {
66
+ const reducedMotion = useMediaQuery("(prefers-reduced-motion: reduce)");
67
+ return (
68
+ <p className={productHeroSocialProofClass} data-testid="user-count">
69
+ <span data-testid="user-count-dot" aria-hidden="true" className={cn("size-2 rounded-full bg-success", !reducedMotion && "animate-pulse")} />
70
+ <span>Free to use · {new Intl.NumberFormat("en-GB").format(count)}+ people already here</span>
71
+ </p>
72
+ );
73
+ }
74
+
75
+ function ActionControl({ action, variant, size = "lg" }: { action: ProductHeroAction; variant: NonNullable<ProductHeroAction["variant"]>; size?: ComponentProps<typeof Button>["size"] }) {
76
+ const v = action.variant ?? variant;
77
+ if (action.href !== undefined) {
78
+ return (
79
+ <Button asChild variant={v} size={size}>
80
+ <a href={action.href} rel={action.rel} target={action.target} onClick={action.onClick}>
81
+ {action.label}
82
+ </a>
83
+ </Button>
84
+ );
85
+ }
86
+ return (
87
+ <Button type="button" variant={v} size={size} onClick={action.onClick}>
88
+ {action.label}
89
+ </Button>
90
+ );
91
+ }
92
+
93
+ /**
94
+ * Shared hero for a product landing page. Copy first, then the visual on mobile; side by
95
+ * side from `lg`. The title, calls to action, single Sign in and product-of line are fixed
96
+ * by the component so every product renders the same hero.
97
+ */
98
+ export function ProductHero({ title, tagline, primaryCta, secondaryCta, signIn, visual, visualLabel, userCount, showProductOf = true, className }: ProductHeroProps) {
99
+ return (
100
+ <section className={cn(landingShellClass, "py-12 sm:py-16 lg:py-24", className)}>
101
+ <div className="grid items-center gap-10 lg:grid-cols-2 lg:gap-16">
102
+ <div className="flex flex-col items-start text-left">
103
+ <h1 className={titleClass}>{title}</h1>
104
+ <p className="mt-4 max-w-xl text-pretty text-base leading-relaxed text-muted-foreground sm:mt-5 sm:text-lg">{tagline}</p>
105
+ <div className="mt-8 flex flex-wrap items-center gap-3">
106
+ <ActionControl action={primaryCta} variant="default" />
107
+ {secondaryCta ? <ActionControl action={secondaryCta} variant="outline" /> : null}
108
+ {signIn ? <ActionControl action={{ label: "Sign in", ...signIn }} variant="secondary" /> : null}
109
+ </div>
110
+ {userCount != null && userCount > 0 ? <UserCountProof count={userCount} /> : null}
111
+ {showProductOf ? (
112
+ <p className="mt-8 text-sm text-muted-foreground">
113
+ a product of{" "}
114
+ <a href={PROJECT_V_HOME_URL} target="_blank" rel="noopener noreferrer" className="underline underline-offset-2">
115
+ Project V
116
+ </a>
117
+ </p>
118
+ ) : null}
119
+ </div>
120
+ <div
121
+ className="relative flex min-h-64 items-center justify-center overflow-hidden lg:min-h-96"
122
+ {...(visualLabel ? { role: "img", "aria-label": visualLabel } : { "aria-hidden": true })}
123
+ >
124
+ {visual}
125
+ </div>
126
+ </div>
127
+ </section>
128
+ );
129
+ }
@@ -3,6 +3,7 @@ import { IconBrandGithub, IconBrandInstagram, IconBrandX } from "@tabler/icons-r
3
3
  import { APP_ICONS, AppWordmark, projectVAppsColumn, type AppName, type ProductAppName } from "../brand";
4
4
  import { Button } from "../ui/button";
5
5
  import { Separator } from "../ui/separator";
6
+ import { PROJECT_V_HOME_URL } from "./projectV";
6
7
  import { siteFooterClasses, type SiteFooterVariant } from "./marketing-classes";
7
8
 
8
9
  export type SiteFooterLink = {
@@ -178,7 +179,7 @@ export function SiteFooter(props: SiteFooterProps) {
178
179
  {showProductOf ? (
179
180
  <p className={c.rights}>
180
181
  {props.app} is a product of{" "}
181
- <a href="https://projectv.uk" target="_blank" rel="noopener noreferrer" className="underline underline-offset-2">
182
+ <a href={PROJECT_V_HOME_URL} target="_blank" rel="noopener noreferrer" className="underline underline-offset-2">
182
183
  Project V
183
184
  </a>
184
185
  .
@@ -125,3 +125,6 @@ export const pricingMultiClasses = {
125
125
  switchIdle: "text-muted-foreground",
126
126
  },
127
127
  } as const;
128
+
129
+ /** Social proof row under the `ProductHero` actions. */
130
+ export const productHeroSocialProofClass = "mt-6 flex items-center gap-2 text-sm text-muted"; // check-tokens-allow: text-muted
@@ -0,0 +1,2 @@
1
+ /** Home page of Project V. Target of every "a product of Project V" link. */
2
+ export const PROJECT_V_HOME_URL = "https://projectv.uk";
@@ -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
  }