@kinetixui/ui 0.23.2 → 0.24.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 (80) hide show
  1. package/README.md +134 -0
  2. package/dist/index.d.ts +212 -31
  3. package/dist/index.js +1466 -1118
  4. package/dist/kx-src-hash.json +3 -0
  5. package/package.json +7 -7
  6. package/src/components/accordion.tsx +1 -1
  7. package/src/components/alert-dialog.tsx +7 -5
  8. package/src/components/avatar.tsx +4 -1
  9. package/src/components/banner.tsx +9 -3
  10. package/src/components/breadcrumb.tsx +1 -1
  11. package/src/components/button.tsx +1 -1
  12. package/src/components/card.tsx +77 -6
  13. package/src/components/checkbox.tsx +42 -8
  14. package/src/components/collapsible.tsx +41 -1
  15. package/src/components/command.tsx +2 -2
  16. package/src/components/context-menu.tsx +21 -13
  17. package/src/components/dialog.tsx +5 -4
  18. package/src/components/direction-provider.tsx +85 -1
  19. package/src/components/drawer.tsx +62 -18
  20. package/src/components/dropdown-menu.tsx +12 -4
  21. package/src/components/file-upload.tsx +1 -1
  22. package/src/components/hover-card.tsx +1 -1
  23. package/src/components/inform.tsx +9 -3
  24. package/src/components/input-group.tsx +27 -5
  25. package/src/components/input-otp.tsx +48 -5
  26. package/src/components/input.tsx +20 -3
  27. package/src/components/json-viewer.tsx +1 -1
  28. package/src/components/menubar.tsx +21 -15
  29. package/src/components/modal.tsx +3 -2
  30. package/src/components/multi-select.tsx +14 -5
  31. package/src/components/native-select.tsx +10 -2
  32. package/src/components/navigation-bar.tsx +1 -1
  33. package/src/components/navigation-menu.tsx +6 -6
  34. package/src/components/number-input.tsx +28 -10
  35. package/src/components/pagination.tsx +2 -2
  36. package/src/components/popover.tsx +29 -5
  37. package/src/components/radio-group.tsx +23 -8
  38. package/src/components/segmented-control.tsx +20 -4
  39. package/src/components/select.tsx +25 -8
  40. package/src/components/sheet.tsx +4 -3
  41. package/src/components/spinner.tsx +1 -1
  42. package/src/components/switch.tsx +31 -8
  43. package/src/components/table.tsx +121 -8
  44. package/src/components/tabs.tsx +29 -4
  45. package/src/components/tag.tsx +1 -1
  46. package/src/components/textarea.tsx +20 -3
  47. package/src/components/tooltip.tsx +14 -4
  48. package/src/components/tour.tsx +22 -7
  49. package/src/components/tree-view.tsx +1 -1
  50. package/src/components-behavior.test.tsx +74 -0
  51. package/src/components-direction-portal.test.tsx +482 -0
  52. package/src/components-form.test.tsx +435 -0
  53. package/src/components-icons.test.tsx +197 -0
  54. package/src/components-keyboard.test.tsx +542 -2
  55. package/src/components-overlay.test.tsx +381 -0
  56. package/src/components-rtl.test.tsx +258 -0
  57. package/src/components-selection.test.tsx +207 -0
  58. package/src/components-table-scroll.test.tsx +363 -0
  59. package/src/index.ts +2 -0
  60. package/src/navigation-menu-focus.test.tsx +35 -0
  61. package/src/stories/Card.stories.tsx +81 -1
  62. package/src/stories/Checkbox.stories.tsx +23 -1
  63. package/src/stories/ContextMenu.stories.tsx +1 -1
  64. package/src/stories/Input.stories.tsx +34 -0
  65. package/src/stories/InputGroup.stories.tsx +96 -2
  66. package/src/stories/InputOTP.stories.tsx +24 -1
  67. package/src/stories/MultiSelect.stories.tsx +25 -1
  68. package/src/stories/NativeSelect.stories.tsx +24 -1
  69. package/src/stories/NumberInput.stories.tsx +20 -1
  70. package/src/stories/Progress.stories.tsx +1 -1
  71. package/src/stories/RadioGroup.stories.tsx +33 -1
  72. package/src/stories/SegmentedControl.stories.tsx +21 -1
  73. package/src/stories/Select.stories.tsx +28 -1
  74. package/src/stories/Slider.stories.tsx +1 -1
  75. package/src/stories/Switch.stories.tsx +37 -1
  76. package/src/stories/Table.stories.tsx +35 -1
  77. package/src/stories/Tabs.stories.tsx +27 -2
  78. package/src/stories/Textarea.stories.tsx +34 -0
  79. package/src/usage/icons.tsx +60 -0
  80. package/tailwind.config.ts +118 -22
@@ -2,7 +2,7 @@
2
2
  Source of truth: apps/web/src/registry/demos.tsx */
3
3
  import * as React from "react";
4
4
  import type { Meta, StoryObj } from "@storybook/react-vite";
5
- import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@kinetixui/ui";
5
+ import { Table, TableBody, TableCaption, TableCell, TableHead, TableHeader, TableRow } from "@kinetixui/ui";
6
6
 
7
7
  const Demo = () => (
8
8
  <Table>
@@ -40,3 +40,37 @@ const meta = {
40
40
  export default meta;
41
41
 
42
42
  export const Default: StoryObj<typeof meta> = { render: () => <Demo /> };
43
+
44
+ export const Overflowing: StoryObj<typeof meta> = { render: () => (
45
+ <div className="w-[22rem] max-w-full">
46
+ <Table>
47
+ <TableCaption>Settlement ledger, March</TableCaption>
48
+ <TableHeader>
49
+ <TableRow>
50
+ {["Reference", "Counterparty", "Instrument", "Booked", "Value date", "Currency", "Notional", "Status", "Desk"].map(
51
+ (h) => (
52
+ <TableHead key={h} className="whitespace-nowrap">
53
+ {h}
54
+ </TableHead>
55
+ ),
56
+ )}
57
+ </TableRow>
58
+ </TableHeader>
59
+ <TableBody>
60
+ {[
61
+ ["STL-4471", "Northwind Trading", "FX forward", "02 Mar", "04 Mar", "EUR", "1,250,000", "Settled", "Rates"],
62
+ ["STL-4472", "Halvorsen Capital", "Interest swap", "03 Mar", "05 Mar", "USD", "4,000,000", "Pending", "Rates"],
63
+ ["STL-4473", "Keystone Mutual", "Equity basket", "03 Mar", "06 Mar", "GBP", "860,500", "Failed", "Cash"],
64
+ ].map((row) => (
65
+ <TableRow key={row[0]}>
66
+ {row.map((cell) => (
67
+ <TableCell key={cell} className="whitespace-nowrap">
68
+ {cell}
69
+ </TableCell>
70
+ ))}
71
+ </TableRow>
72
+ ))}
73
+ </TableBody>
74
+ </Table>
75
+ </div>
76
+ ) };
@@ -2,7 +2,7 @@
2
2
  Source of truth: apps/web/src/registry/demos.tsx · controls: scripts/gen-stories.mjs CONTROLS */
3
3
  import * as React from "react";
4
4
  import type { Meta, StoryObj } from "@storybook/react-vite";
5
- import { Tabs, TabsContent, TabsList, TabsTrigger } from "@kinetixui/ui";
5
+ import { Card, CardContent, Tabs, TabsContent, TabsList, TabsTrigger } from "@kinetixui/ui";
6
6
 
7
7
  const Demo = () => (
8
8
  <Tabs defaultValue="account" className="w-[360px]">
@@ -25,7 +25,7 @@ const meta = {
25
25
  args: { defaultValue: "account" },
26
26
  argTypes: { defaultValue: { control: "inline-radio", options: ["account", "password"] } },
27
27
  parameters: {
28
- layout: "centered",
28
+ layout: "padded",
29
29
  docs: { source: { code: "<Tabs defaultValue=\"account\">\\n <TabsList>\\n <TabsTrigger value=\"account\">Account</TabsTrigger>\\n <TabsTrigger value=\"password\">Password</TabsTrigger>\\n </TabsList>\\n <TabsContent value=\"account\">…</TabsContent>\\n</Tabs>", language: "tsx" } },
30
30
  },
31
31
  } satisfies Meta;
@@ -48,3 +48,28 @@ export const Playground: StoryObj<typeof meta> = { render: (args) => (
48
48
  ) };
49
49
 
50
50
  export const Default: StoryObj<typeof meta> = { render: () => <Demo /> };
51
+
52
+ export const States: StoryObj<typeof meta> = { render: () => (
53
+ <div className="grid w-[26rem] max-w-full gap-6">
54
+ {(["page", "card"] as const).map((where) => {
55
+ const tabs = (
56
+ <Tabs defaultValue="overview">
57
+ <TabsList data-kx-case={where} aria-label={where === "page" ? "Project" : "Workspace"}>
58
+ <TabsTrigger value="overview" data-kx-case="selected">Overview</TabsTrigger>
59
+ <TabsTrigger value="activity" data-kx-case="unselected">Activity</TabsTrigger>
60
+ <TabsTrigger value="billing" data-kx-case="disabled" disabled>Billing</TabsTrigger>
61
+ </TabsList>
62
+ <TabsContent value="overview" className="text-sm text-muted-foreground">Three deployments this week.</TabsContent>
63
+ <TabsContent value="activity" className="text-sm text-muted-foreground">No new activity.</TabsContent>
64
+ </Tabs>
65
+ );
66
+ return where === "page" ? (
67
+ <div key={where}>{tabs}</div>
68
+ ) : (
69
+ <Card key={where}>
70
+ <CardContent className="p-4">{tabs}</CardContent>
71
+ </Card>
72
+ );
73
+ })}
74
+ </div>
75
+ ) };
@@ -1,5 +1,7 @@
1
1
  import type { Meta, StoryObj } from "@storybook/react-vite";
2
2
  import { Textarea } from "../components/textarea";
3
+ import { Card, CardContent } from "../components/card";
4
+ import { Label } from "../components/label";
3
5
 
4
6
  const STATES = ["Default", "Focus", "Error", "Disabled"] as const;
5
7
 
@@ -47,6 +49,38 @@ export const States: Story = {
47
49
  ),
48
50
  };
49
51
 
52
+ /**
53
+ * The text-entry state contract (TOKENS.md, "Text entry and navigation") as a real form produces it: empty
54
+ * with a placeholder, filled, invalid with its error text, read-only and disabled, side by side in a raised
55
+ * Card. Real states, not the `state` prop's pinned ones — `check:entry-visual` drives hover and focus on
56
+ * these with a real pointer and keyboard, and both axe passes read them.
57
+ */
58
+ export const EntryStates: Story = {
59
+ render: () => (
60
+ <Card className="w-[22rem] max-w-full">
61
+ <CardContent className="grid gap-5 p-6">
62
+ {([
63
+ ["rest", "Bio", { placeholder: "A sentence or two about you" }],
64
+ ["filled", "Shipping notes", { defaultValue: "Leave at the side door." }],
65
+ ["invalid", "Reason for refund", { defaultValue: "No", "aria-invalid": true, "aria-describedby": "ta-invalid-error" }],
66
+ ["readonly", "Signed agreement", { readOnly: true, defaultValue: "Accepted on 2 October." }],
67
+ ["disabled", "Admin note", { disabled: true, defaultValue: "Only admins can edit this." }],
68
+ ] as const).map(([key, label, props]) => (
69
+ <div key={key} className="grid gap-2">
70
+ <Label htmlFor={`ta-${key}`}>{label}</Label>
71
+ <Textarea id={`ta-${key}`} data-kx-case={key} rows={3} {...props} />
72
+ {key === "invalid" ? (
73
+ <p id="ta-invalid-error" className="text-body-sm text-destructive">
74
+ Tell us a little more, at least 20 characters.
75
+ </p>
76
+ ) : null}
77
+ </div>
78
+ ))}
79
+ </CardContent>
80
+ </Card>
81
+ ),
82
+ };
83
+
50
84
  export const WithFieldChrome: Story = {
51
85
  render: (args) => (
52
86
  <div className="flex flex-col gap-6">
@@ -0,0 +1,60 @@
1
+ /**
2
+ * The React icon examples /docs/icons shows. Each `kx-usage:<key>` region is extracted by `pnpm gen:usage`,
3
+ * so the snippet on the page is code `tsc` type-checks against the real props, and components-icons.test.tsx
4
+ * renders it. Not exported from the package: an example, not API.
5
+ */
6
+ import * as React from "react";
7
+ import { CircleX, Trash2 } from "lucide-react";
8
+ import { Banner } from "../components/banner";
9
+ import { Button } from "../components/button";
10
+ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "../components/tooltip";
11
+
12
+ /** Stands in for an application's own icon component: any SVG, no lucide, no KinetixUI. */
13
+ function CompanyCloseIcon(props: React.SVGProps<SVGSVGElement>) {
14
+ return (
15
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} {...props}>
16
+ <path d="M6 6l12 12M18 6L6 18" />
17
+ </svg>
18
+ );
19
+ }
20
+
21
+ export function IconsDismissIconExample({ onDismiss = () => {} }: { onDismiss?: () => void }) {
22
+ return (
23
+ <div className="flex flex-col gap-3">
24
+ {/* kx-usage:icons-dismiss-icon */}
25
+ {/* Default: lucide's X */}
26
+ <Banner onDismiss={onDismiss}>Changes saved.</Banner>
27
+
28
+ {/* Another lucide icon */}
29
+ <Banner onDismiss={onDismiss} dismissIcon={<CircleX />}>
30
+ Changes saved.
31
+ </Banner>
32
+
33
+ {/* Your own icon: sized to 14px, coloured by the banner, named "Dismiss" */}
34
+ <Banner onDismiss={onDismiss} dismissIcon={<CompanyCloseIcon />}>
35
+ Changes saved.
36
+ </Banner>
37
+ {/* kx-usage:end */}
38
+ </div>
39
+ );
40
+ }
41
+
42
+ export function IconsIconOnlyButtonExample({ onDelete = () => {} }: { onDelete?: () => void }) {
43
+ return (
44
+ <TooltipProvider>
45
+ {/* kx-usage:icons-icon-only-button */}
46
+ <Tooltip>
47
+ <TooltipTrigger asChild>
48
+ {/* size="icon" keeps the svg visible and turns the text into the button's
49
+ screen-reader-only name: "Delete draft, button" */}
50
+ <Button size="icon" variant="Ghost" onClick={onDelete}>
51
+ <Trash2 />
52
+ <span>Delete draft</span>
53
+ </Button>
54
+ </TooltipTrigger>
55
+ <TooltipContent>Delete draft</TooltipContent>
56
+ </Tooltip>
57
+ {/* kx-usage:end */}
58
+ </TooltipProvider>
59
+ );
60
+ }
@@ -17,23 +17,35 @@ export default {
17
17
  fontFamily: { sans: "var(--font-sans)" },
18
18
  // Material-3 type scale — `text-body-md`, `text-headline-lg`, … each carries
19
19
  // its line-height, tracking and weight. Mirrors tokens/semantic/typography.json.
20
+ //
21
+ // Sizes and line-heights are rem so that text follows the reader's browser font-size setting;
22
+ // the numbers are the canonical scale divided by the 16px root, so every step renders at exactly
23
+ // its old pixel size at the default setting (14px -> 0.875rem) and grows from there. They are not
24
+ // hand-maintained against the tokens any more: `check:type-scale` derives this table from
25
+ // tokens/semantic/typography.json and fails if the two disagree, which is how the px values here
26
+ // went on silently outliving the ones in globals.css.
27
+ //
28
+ // Letter-spacing stays in px deliberately. It is an optical constant of the typeface at a
29
+ // reference size rather than a size a reader asked to change, the primitive tokens are shared
30
+ // across steps so there is no one font size to make it relative to, and at 0.1-0.5px it is far
31
+ // below the threshold where scaling it would be legible.
20
32
  fontSize: {
21
- "display-lg": ["57px", { lineHeight: "64px", letterSpacing: "-0.25px", fontWeight: "500" }],
22
- "display-md": ["45px", { lineHeight: "52px", letterSpacing: "0", fontWeight: "500" }],
23
- "display-sm": ["36px", { lineHeight: "44px", letterSpacing: "0", fontWeight: "400" }],
24
- "headline-lg": ["32px", { lineHeight: "40px", letterSpacing: "0", fontWeight: "600" }],
25
- "headline-md": ["28px", { lineHeight: "36px", letterSpacing: "0", fontWeight: "400" }],
26
- "headline-sm": ["24px", { lineHeight: "32px", letterSpacing: "0", fontWeight: "400" }],
27
- "title-lg": ["22px", { lineHeight: "28px", letterSpacing: "0", fontWeight: "400" }],
28
- "title-md": ["16px", { lineHeight: "24px", letterSpacing: "0.15px", fontWeight: "500" }],
29
- "title-sm": ["14px", { lineHeight: "20px", letterSpacing: "0.1px", fontWeight: "500" }],
30
- "title-dialog": ["18px", { lineHeight: "24px", letterSpacing: "0", fontWeight: "600" }],
31
- "label-lg": ["14px", { lineHeight: "20px", letterSpacing: "0.1px", fontWeight: "500" }],
32
- "label-md": ["12px", { lineHeight: "16px", letterSpacing: "0.5px", fontWeight: "500" }],
33
- "label-sm": ["11px", { lineHeight: "16px", letterSpacing: "0.5px", fontWeight: "500" }],
34
- "body-lg": ["16px", { lineHeight: "24px", letterSpacing: "0.5px", fontWeight: "400" }],
35
- "body-md": ["14px", { lineHeight: "20px", letterSpacing: "0.25px", fontWeight: "400" }],
36
- "body-sm": ["12px", { lineHeight: "16px", letterSpacing: "0", fontWeight: "400" }],
33
+ "display-lg": ["3.5625rem", { lineHeight: "4rem", letterSpacing: "-0.25px", fontWeight: "500" }],
34
+ "display-md": ["2.8125rem", { lineHeight: "3.25rem", letterSpacing: "0", fontWeight: "500" }],
35
+ "display-sm": ["2.25rem", { lineHeight: "2.75rem", letterSpacing: "0", fontWeight: "400" }],
36
+ "headline-lg": ["2rem", { lineHeight: "2.5rem", letterSpacing: "0", fontWeight: "600" }],
37
+ "headline-md": ["1.75rem", { lineHeight: "2.25rem", letterSpacing: "0", fontWeight: "400" }],
38
+ "headline-sm": ["1.5rem", { lineHeight: "2rem", letterSpacing: "0", fontWeight: "400" }],
39
+ "title-lg": ["1.375rem", { lineHeight: "1.75rem", letterSpacing: "0", fontWeight: "400" }],
40
+ "title-md": ["1rem", { lineHeight: "1.5rem", letterSpacing: "0.15px", fontWeight: "500" }],
41
+ "title-sm": ["0.875rem", { lineHeight: "1.25rem", letterSpacing: "0.1px", fontWeight: "500" }],
42
+ "title-dialog": ["1.125rem", { lineHeight: "1.5rem", letterSpacing: "0", fontWeight: "600" }],
43
+ "label-lg": ["0.875rem", { lineHeight: "1.25rem", letterSpacing: "0.1px", fontWeight: "500" }],
44
+ "label-md": ["0.75rem", { lineHeight: "1rem", letterSpacing: "0.5px", fontWeight: "500" }],
45
+ "label-sm": ["0.6875rem", { lineHeight: "1rem", letterSpacing: "0.5px", fontWeight: "500" }],
46
+ "body-lg": ["1rem", { lineHeight: "1.5rem", letterSpacing: "0.5px", fontWeight: "400" }],
47
+ "body-md": ["0.875rem", { lineHeight: "1.25rem", letterSpacing: "0.25px", fontWeight: "400" }],
48
+ "body-sm": ["0.75rem", { lineHeight: "1rem", letterSpacing: "0", fontWeight: "400" }],
37
49
  },
38
50
  boxShadow: {
39
51
  sm: "var(--shadow-sm)",
@@ -54,7 +66,16 @@ export default {
54
66
  popover: { DEFAULT: c("--popover"), foreground: c("--popover-foreground") },
55
67
  primary: { DEFAULT: c("--primary"), foreground: c("--primary-foreground") },
56
68
  secondary: { DEFAULT: c("--secondary"), foreground: c("--secondary-foreground") },
57
- muted: { DEFAULT: c("--muted"), foreground: c("--muted-foreground") },
69
+ muted: {
70
+ DEFAULT: c("--muted"),
71
+ foreground: c("--muted-foreground"),
72
+ // Secondary text on a tinted container (bg-primary/10, bg-destructive/10). `--muted-foreground`
73
+ // is tuned against `--background` and `--muted`; a 10% tint drops it to 4.33:1.
74
+ "on-container": c("--semantic-muted-on-container"),
75
+ },
76
+ // The grouped section raised content sits on (TOKENS.md, surface model). Not `muted`: dark `muted` is
77
+ // lighter than `card`, so a card on it read as recessed.
78
+ surface: { grouped: c("--surface-grouped") },
58
79
  accent: { DEFAULT: c("--accent"), foreground: c("--accent-foreground") },
59
80
  destructive: { DEFAULT: c("--destructive"), foreground: c("--destructive-foreground") },
60
81
  success: { DEFAULT: c("--success"), foreground: c("--success-foreground") },
@@ -62,10 +83,14 @@ export default {
62
83
  info: {
63
84
  DEFAULT: c("--info"),
64
85
  foreground: c("--info-foreground"),
65
- // text on a tinted info surface (bg-info/10) — --info alone is 4.15:1 there
66
- "on-container": c("--semantic-on-info-container"),
86
+ // text on a tinted info surface (bg-info/10) — --info alone is 4.15:1 there. The top-level role
87
+ // every platform shares; `--semantic-on-info-container` is its legacy alias.
88
+ "on-container": c("--on-info-container"),
67
89
  },
68
90
  tertiary: { DEFAULT: c("--tertiary"), foreground: c("--tertiary-foreground") },
91
+ // The modal backdrop. The role carries its own alpha (black at 40% light, 60% dark), so it is mapped
92
+ // without `<alpha-value>`: `bg-scrim`, not `bg-scrim/40`.
93
+ scrim: "hsl(var(--scrim))",
69
94
  // Role tokens layered over `primary` (which stays the shadcn-compatible source). `action`, `link`
70
95
  // and `focus` default to it, so a theme that only sets `--primary` still works; override them
71
96
  // individually to split the interactive colour, link colour and focus colour. `brand` is identity
@@ -123,6 +148,10 @@ export default {
123
148
  // Added alongside Tailwind's own numeric scales (z-10, opacity-50, …),
124
149
  // not replacing them — these are the token-backed semantic names.
125
150
  transitionDuration: {
151
+ // `instant` is the press/toggle tier from tokens/primitives/motion.json. It was generated
152
+ // into globals.css but never wired here, so the one duration meant for immediate feedback
153
+ // was the one nobody could reach — components fell back to Tailwind's untokenised 150ms.
154
+ instant: "var(--duration-instant)",
126
155
  fast: "var(--duration-fast)",
127
156
  base: "var(--duration-base)",
128
157
  slow: "var(--duration-slow)",
@@ -133,6 +162,13 @@ export default {
133
162
  // cubic-bezier(0,0,1,1) value, now token-backed instead of hardcoded
134
163
  linear: "var(--easing-linear)",
135
164
  standard: "var(--easing-standard)",
165
+ // Same gap as `instant`: enter/exit/emphasized are defined in the DTCG source and emitted
166
+ // as CSS variables, but were unreachable from a class. Directional easing is the whole
167
+ // point of the enter/exit pair — a surface that opens should decelerate and one that
168
+ // closes should accelerate, and without these you cannot say that in a utility.
169
+ enter: "var(--easing-enter)",
170
+ exit: "var(--easing-exit)",
171
+ emphasized: "var(--easing-emphasized)",
136
172
  },
137
173
  zIndex: {
138
174
  base: "var(--z-index-base)",
@@ -151,18 +187,78 @@ export default {
151
187
  keyframes: {
152
188
  "accordion-down": { from: { height: "0" }, to: { height: "var(--radix-accordion-content-height)" } },
153
189
  "accordion-up": { from: { height: "var(--radix-accordion-content-height)" }, to: { height: "0" } },
190
+ // Collapsible is the same disclosure gesture as Accordion on a different Radix primitive, so
191
+ // it reads its own measured height variable and is otherwise identical.
192
+ "collapsible-down": { from: { height: "0" }, to: { height: "var(--radix-collapsible-content-height)" } },
193
+ "collapsible-up": { from: { height: "var(--radix-collapsible-content-height)" }, to: { height: "0" } },
154
194
  "caret-blink": { "0%,70%,100%": { opacity: "1" }, "20%,50%": { opacity: "0" } },
155
195
  marquee: { from: { transform: "translateX(0)" }, to: { transform: "translateX(-50%)" } },
156
196
  "typing-dot": { "0%,60%,100%": { transform: "translateY(0)", opacity: "0.4" }, "30%": { transform: "translateY(-3px)", opacity: "1" } },
157
197
  },
198
+ /**
199
+ * Durations and easings come from the tokens, not from numbers typed here.
200
+ *
201
+ * `0.2s ease-out` happened to equal `--duration-fast` and to be the same curve as
202
+ * `--easing-enter`, so the values were right and the provenance was not — a change to the token
203
+ * would have moved every other transition in the system and left disclosure behind.
204
+ *
205
+ * Disclosure uses the directional pair the easing tokens exist for: opening decelerates
206
+ * (`enter`), closing accelerates (`exit`). The fallbacks match the token values and exist
207
+ * because `animation` is a shorthand — an undefined `var()` makes the whole declaration invalid,
208
+ * which would mean no animation at all rather than an un-themed one. `marquee` already
209
+ * establishes that pattern below.
210
+ *
211
+ * `caret-blink` and `typing-dot` keep their literal timings. They are looping affordances
212
+ * rather than state transitions, their rhythms were chosen for their own reasons, and
213
+ * `scripts/a11y-animations.mjs` documents caret-blink at 1.25s by name. Retiming them to the
214
+ * nearest token would change how they look to buy consistency nobody asked for, which is a
215
+ * separate decision from giving disclosure its provenance back.
216
+ */
158
217
  animation: {
159
- "accordion-down": "accordion-down 0.2s ease-out",
160
- "accordion-up": "accordion-up 0.2s ease-out",
218
+ "accordion-down": "accordion-down var(--duration-fast, 200ms) var(--easing-enter, cubic-bezier(0, 0, 0.2, 1))",
219
+ "accordion-up": "accordion-up var(--duration-fast, 200ms) var(--easing-exit, cubic-bezier(0.4, 0, 1, 1))",
220
+ "collapsible-down": "collapsible-down var(--duration-fast, 200ms) var(--easing-enter, cubic-bezier(0, 0, 0.2, 1))",
221
+ "collapsible-up": "collapsible-up var(--duration-fast, 200ms) var(--easing-exit, cubic-bezier(0.4, 0, 1, 1))",
161
222
  "caret-blink": "caret-blink 1.25s ease-out infinite",
162
223
  marquee: "marquee var(--marquee-duration, 32s) linear infinite",
163
224
  "typing-dot": "typing-dot 1.2s ease-in-out infinite",
164
225
  },
165
226
  },
166
227
  },
167
- plugins: [require("tailwindcss-animate")],
228
+ plugins: [
229
+ require("tailwindcss-animate"),
230
+ /**
231
+ * Reduced motion, for everything this package ships.
232
+ *
233
+ * `packages/ui` ships no CSS of its own — components are class strings and consumers extend
234
+ * this config — so this base layer is the only place a library-wide guarantee can live.
235
+ * It is needed: `tailwindcss-animate` emits no `prefers-reduced-motion` rule at all, so every
236
+ * `animate-in` / `animate-out` on Dialog, Sheet, Popover, Dropdown, Tooltip and the rest ran at
237
+ * full motion in a consumer's app regardless of the operating-system setting.
238
+ *
239
+ * It shortens durations rather than removing motion, and that distinction is the safety
240
+ * property. `transition-duration: 0.01ms` still arrives at the same end state, so a Switch
241
+ * thumb is still translated, a checked box is still checked, and an IoT control still shows
242
+ * confirmed rather than requested — the interpolation goes, the information does not. That is
243
+ * what makes a blanket rule safe here, where `transition: none` would be a blunter instrument
244
+ * and `animation: none` an actively dangerous one: Radix unmounts an overlay on `animationend`,
245
+ * and an animation that never runs never fires it, so the overlay would stay in the tree.
246
+ *
247
+ * Components that need more than this still say so at their own call site with
248
+ * `motion-reduce:`, and `packages/iot` already does exactly that where a pulse or a bar carries
249
+ * meaning. This is the floor, not a replacement for that.
250
+ */
251
+ ({ addBase }: { addBase: (styles: Record<string, unknown>) => void }) => {
252
+ addBase({
253
+ "@media (prefers-reduced-motion: reduce)": {
254
+ "*, ::before, ::after": {
255
+ "animation-duration": "0.01ms !important",
256
+ "animation-iteration-count": "1 !important",
257
+ "transition-duration": "0.01ms !important",
258
+ "scroll-behavior": "auto !important",
259
+ },
260
+ },
261
+ });
262
+ },
263
+ ],
168
264
  } satisfies Config;