@tangle-network/ui 11.34.0 → 11.36.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 (78) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/README.md +1 -1
  3. package/dist/index.d.ts +4 -3
  4. package/dist/index.js +2 -1
  5. package/dist/lib/control-presentation.d.ts +34 -1
  6. package/dist/lib/control-presentation.js +1 -1
  7. package/dist/lib/focus.d.ts +2 -2
  8. package/dist/lib/focus.js +3 -2
  9. package/dist/lib/motion.d.ts +4 -0
  10. package/dist/lib/motion.js +7 -0
  11. package/dist/lib/surface-motion.js +84 -0
  12. package/dist/presentation.d.ts +88 -0
  13. package/dist/presentation.js +183 -0
  14. package/dist/primitives/alert-dialog.js +1 -1
  15. package/dist/primitives/alert.d.ts +1 -2
  16. package/dist/primitives/button.d.ts +2 -5
  17. package/dist/primitives/button.js +3 -30
  18. package/dist/primitives/card.js +3 -7
  19. package/dist/primitives/date-picker.d.ts +1 -1
  20. package/dist/primitives/dialog.js +29 -16
  21. package/dist/primitives/empty-state.js +6 -5
  22. package/dist/primitives/heading.d.ts +1 -0
  23. package/dist/primitives/heading.js +3 -2
  24. package/dist/primitives/help-text.js +2 -1
  25. package/dist/primitives/input.d.ts +1 -4
  26. package/dist/primitives/input.js +3 -24
  27. package/dist/primitives/label.js +1 -2
  28. package/dist/primitives/metric-strip.js +7 -12
  29. package/dist/primitives/page-header.d.ts +1 -0
  30. package/dist/primitives/page-header.js +7 -6
  31. package/dist/primitives/page-shell.d.ts +8 -2
  32. package/dist/primitives/page-shell.js +21 -2
  33. package/dist/primitives/pagination.js +1 -1
  34. package/dist/primitives/segmented-control.d.ts +6 -3
  35. package/dist/primitives/segmented-control.js +105 -56
  36. package/dist/primitives/sheet.js +29 -19
  37. package/dist/primitives/skeleton.js +1 -1
  38. package/dist/primitives/status-pill.d.ts +3 -1
  39. package/dist/primitives/status-pill.js +12 -3
  40. package/dist/primitives/table.js +10 -9
  41. package/dist/primitives/tabs.js +3 -12
  42. package/dist/primitives/tooltip.js +11 -3
  43. package/dist/primitives.d.ts +3 -2
  44. package/dist/primitives.js +2 -1
  45. package/dist/utils.d.ts +1 -1
  46. package/package.json +15 -5
  47. package/src/lib/control-presentation.ts +2 -4
  48. package/src/lib/focus.ts +3 -2
  49. package/src/lib/motion.ts +7 -0
  50. package/src/lib/surface-motion.tsx +78 -0
  51. package/src/presentation.ts +205 -0
  52. package/src/primitives/alert.tsx +1 -2
  53. package/src/primitives/button.tsx +4 -40
  54. package/src/primitives/card.tsx +5 -10
  55. package/src/primitives/checkbox-separator-sheet-tooltip.test.tsx +2 -1
  56. package/src/primitives/control-presentation.test.tsx +2 -7
  57. package/src/primitives/dialog.tsx +19 -18
  58. package/src/primitives/empty-state.tsx +6 -5
  59. package/src/primitives/heading.tsx +4 -2
  60. package/src/primitives/help-text.tsx +3 -2
  61. package/src/primitives/input.tsx +3 -31
  62. package/src/primitives/label.tsx +2 -4
  63. package/src/primitives/metric-strip.tsx +8 -17
  64. package/src/primitives/page-header.tsx +8 -5
  65. package/src/primitives/page-shell.tsx +13 -2
  66. package/src/primitives/segmented-control.stories.tsx +89 -0
  67. package/src/primitives/segmented-control.test.tsx +20 -7
  68. package/src/primitives/segmented-control.tsx +90 -29
  69. package/src/primitives/sheet.tsx +16 -11
  70. package/src/primitives/skeleton.tsx +1 -1
  71. package/src/primitives/status-pill.test.tsx +1 -0
  72. package/src/primitives/status-pill.tsx +10 -4
  73. package/src/primitives/table.tsx +10 -9
  74. package/src/primitives/tabs.tsx +5 -17
  75. package/src/primitives/tooltip.tsx +6 -5
  76. package/src/styles/compat.css +12 -0
  77. package/src/styles/presentation.css +30 -0
  78. package/src/primitives/page-shell.test.tsx +0 -24
package/src/lib/focus.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { controlMotion } from "./motion";
1
2
  import { ixField } from "./interaction";
2
3
 
3
4
  /**
@@ -30,7 +31,7 @@ import { ixField } from "./interaction";
30
31
  * Tailwind emits `focus:` after `hover:`.
31
32
  */
32
33
  export const focusField =
33
- "border-border transition-[border-color,box-shadow] duration-150 ease-out hover:border-[var(--border-strong)] focus:outline-hidden focus:border-[var(--focus-border)] focus:ring-3 focus:ring-[var(--focus-halo)]";
34
+ `border-border ${controlMotion} hover:border-[var(--border-strong)] focus:outline-hidden focus:border-[var(--focus-border)] focus:ring-3 focus:ring-[var(--focus-halo)]`;
34
35
 
35
36
  /**
36
37
  * The field treatment for a container that wraps a borderless field, such as a
@@ -50,7 +51,7 @@ export const focusField =
50
51
  * borderless field inside stays transparent.
51
52
  */
52
53
  export const focusFieldWithin =
53
- `focus-field-within transition-[border-color,box-shadow] duration-150 ease-out ${ixField} has-[:is(textarea,select,[contenteditable]:not([contenteditable=false]),input:not([type=button],[type=submit],[type=reset],[type=image],[type=checkbox],[type=radio],[type=range],[type=color],[type=file])):focus]:border-[var(--focus-border)] has-[:is(textarea,select,[contenteditable]:not([contenteditable=false]),input:not([type=button],[type=submit],[type=reset],[type=image],[type=checkbox],[type=radio],[type=range],[type=color],[type=file])):focus]:ring-3 has-[:is(textarea,select,[contenteditable]:not([contenteditable=false]),input:not([type=button],[type=submit],[type=reset],[type=image],[type=checkbox],[type=radio],[type=range],[type=color],[type=file])):focus]:ring-[var(--focus-halo)]`;
54
+ `focus-field-within ${controlMotion} ${ixField} has-[:is(textarea,select,[contenteditable]:not([contenteditable=false]),input:not([type=button],[type=submit],[type=reset],[type=image],[type=checkbox],[type=radio],[type=range],[type=color],[type=file])):focus]:border-[var(--focus-border)] has-[:is(textarea,select,[contenteditable]:not([contenteditable=false]),input:not([type=button],[type=submit],[type=reset],[type=image],[type=checkbox],[type=radio],[type=range],[type=color],[type=file])):focus]:ring-3 has-[:is(textarea,select,[contenteditable]:not([contenteditable=false]),input:not([type=button],[type=submit],[type=reset],[type=image],[type=checkbox],[type=radio],[type=range],[type=color],[type=file])):focus]:ring-[var(--focus-halo)]`;
54
55
 
55
56
  /** Error state for a field: a danger hairline and the same focus shape in danger. Compose after `focusField`. */
56
57
  export const focusFieldInvalid =
@@ -0,0 +1,7 @@
1
+ /** Brand motion roles contain duration AND easing; consume complete shorthands. */
2
+ export const controlMotion =
3
+ "[transition:color_var(--motion-control),background-color_var(--motion-control),border-color_var(--motion-control),box-shadow_var(--motion-control),transform_var(--motion-control),scale_var(--motion-control),opacity_var(--motion-control)] motion-reduce:transition-none";
4
+
5
+ /** Radix presence markers carry the scoped role; SurfaceMotion owns interruptible geometry. */
6
+ export const surfaceMotion =
7
+ "data-[state=open]:[animation:tangle-surface-present_var(--motion-surface)_both] data-[state=delayed-open]:[animation:tangle-surface-present_var(--motion-surface)_both] data-[state=instant-open]:[animation:tangle-surface-present_var(--motion-surface)_both] data-[state=closed]:[animation:tangle-surface-absent_var(--motion-dismiss)_both] motion-reduce:animate-none!";
@@ -0,0 +1,78 @@
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+ import { Slot } from "@radix-ui/react-slot";
5
+ import { animate } from "motion";
6
+
7
+ type Side = "top" | "right" | "bottom" | "left";
8
+ type SurfaceProps = React.HTMLAttributes<HTMLDivElement> & {
9
+ kind: "dialog" | "sheet" | "overlay" | "tooltip";
10
+ side?: Side;
11
+ asChild?: boolean;
12
+ "data-state"?: string;
13
+ };
14
+
15
+ /** Radix owns state, focus and presence; Motion only retargets the visible object. */
16
+ export const SurfaceMotion = React.forwardRef<HTMLDivElement, SurfaceProps>(
17
+ ({ kind, side = "right", asChild, "data-state": state, ...props }, forwardedRef) => {
18
+ const element = React.useRef<HTMLDivElement | null>(null);
19
+ const initialized = React.useRef(false);
20
+ const controls = React.useRef<ReturnType<typeof animate> | undefined>(undefined);
21
+ const ref = React.useCallback<React.RefCallback<HTMLDivElement>>((node) => {
22
+ element.current = node;
23
+ if (typeof forwardedRef === "function") {
24
+ const cleanup: unknown = forwardedRef(node);
25
+ if (typeof cleanup === "function") return () => { element.current = null; cleanup(); };
26
+ } else if (forwardedRef) forwardedRef.current = node;
27
+ }, [forwardedRef]);
28
+
29
+ React.useLayoutEffect(() => {
30
+ const node = element.current;
31
+ if (!node) return;
32
+ const preference = typeof matchMedia === "function" ? matchMedia("(prefers-reduced-motion: reduce)") : undefined;
33
+ const run = () => {
34
+ const closed = state === "closed";
35
+ const bounds = node.getBoundingClientRect();
36
+ const hidden = {
37
+ opacity: kind === "sheet" ? 1 : 0,
38
+ scale: kind === "dialog" || kind === "tooltip" ? 0.98 : 1,
39
+ x: kind === "sheet" ? side === "left" ? -bounds.width : side === "right" ? bounds.width : 0 : 0,
40
+ y: kind === "sheet" ? side === "top" ? -bounds.height : side === "bottom" ? bounds.height : 0 : 0,
41
+ };
42
+ const target = closed ? hidden : { opacity: 1, scale: 1, x: 0, y: 0 };
43
+ const style = getComputedStyle(node);
44
+ // The nonvisual marker resolves the full role, including scoped calc().
45
+ const time = /^(\d+(?:\.\d+)?)(ms|s)$/.exec(style.animationDuration.split(",")[0]!.trim());
46
+ const seconds = time ? Number(time[1]) / (time[2] === "ms" ? 1000 : 1) : 0;
47
+ const place = (value: typeof target) => {
48
+ node.style.opacity = String(value.opacity);
49
+ node.style.transform = `translateX(${value.x}px) translateY(${value.y}px) scale(${value.scale})`;
50
+ };
51
+ // Missing styles and reduced motion settle synchronously. No semantic
52
+ // transition waits for a JavaScript completion callback.
53
+ if (preference?.matches || seconds <= 0 || style.animationName === "none") {
54
+ controls.current?.stop();
55
+ place(target);
56
+ if (initialized.current) controls.current = animate(node, target, { duration: 0 });
57
+ return;
58
+ }
59
+ if (!initialized.current) { place(hidden); initialized.current = true; }
60
+ // Critically damped response: <0.01% residual at the role's deadline.
61
+ // Reusing this element's Motion values preserves position and velocity
62
+ // on reversal; a duration-based spring would discard velocity.
63
+ const frequency = 12 / seconds;
64
+ controls.current = animate(node, target, {
65
+ type: "spring", stiffness: frequency * frequency, damping: 2 * frequency,
66
+ restDelta: 0.001, restSpeed: 0.01,
67
+ });
68
+ };
69
+ run();
70
+ preference?.addEventListener("change", run);
71
+ return () => preference?.removeEventListener("change", run);
72
+ }, [state, kind, side]);
73
+ React.useEffect(() => () => controls.current?.stop(), []);
74
+ const Component = asChild ? Slot : "div";
75
+ return <Component {...props} data-state={state} ref={ref} />;
76
+ },
77
+ );
78
+ SurfaceMotion.displayName = "SurfaceMotion";
@@ -0,0 +1,205 @@
1
+ /** React-free presentation. Consumers scan this published source with Tailwind. */
2
+ import { surfaceMotion } from "./lib/motion";
3
+ import { cva } from "class-variance-authority";
4
+ import { controlHeight, controlMotion, controlRadius, controlSizes, controlSquare, controlText, fieldPresentation, fieldSizes, fieldTouchText } from "./lib/control-presentation";
5
+ import { focusRing, focusFieldInvalid } from "./lib/focus";
6
+ import { ixControl, ixControlSoft, ixSelected, ixTabUnderline } from "./lib/interaction";
7
+ import { cn } from "./lib/utils";
8
+ export { controlHeight, controlMotion, controlRadius, controlSizes, controlText, fieldPresentation, fieldSizes, fieldTouchText } from "./lib/control-presentation";
9
+
10
+ export const buttonVariants = cva(
11
+ `inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-lg font-medium ${controlMotion} ${focusRing} disabled:pointer-events-none disabled:opacity-50 aria-disabled:cursor-not-allowed aria-disabled:opacity-50 motion-reduce:active:scale-100 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0`,
12
+ {
13
+ variants: {
14
+ variant: {
15
+ default:
16
+ `bg-primary text-primary-foreground hover:bg-primary/90 active:scale-[0.97] ${ixControl}`,
17
+ destructive:
18
+ `bg-destructive text-destructive-foreground hover:bg-destructive/90 active:scale-[0.97] ${ixControl}`,
19
+ outline:
20
+ `border border-border bg-card hover:bg-muted active:scale-[0.97] text-foreground ${ixControl}`,
21
+ secondary:
22
+ `bg-muted border border-border text-foreground hover:bg-muted/80 active:scale-[0.97] ${ixControl}`,
23
+ ghost:
24
+ `hover:text-foreground text-muted-foreground border border-transparent ${ixControlSoft}`,
25
+ link: `text-[var(--accent-text)] underline-offset-4 hover:underline ${ixControlSoft}`,
26
+ sandbox:
27
+ `bg-[var(--btn-primary-bg)] text-[var(--btn-primary-text)] border border-[var(--border-accent)] hover:bg-[var(--btn-primary-hover)] active:scale-[0.97] ${ixControl}`,
28
+ },
29
+ size: {
30
+ default: `${controlHeight.md} ${controlText.md} ${controlRadius.md} px-4 py-2`,
31
+ md: `${controlHeight.md} ${controlText.md} ${controlRadius.md} px-4 py-2`,
32
+ sm: `${controlHeight.sm} ${controlText.sm} ${controlRadius.sm} px-3`,
33
+ lg: `${controlHeight.lg} ${controlText.lg} ${controlRadius.lg} px-7`,
34
+ display: "tangle-control-display",
35
+ xl: "h-13 rounded-xl px-9 text-base",
36
+ icon: `${controlSquare.md} ${controlText.md} ${controlRadius.md}`,
37
+ "icon-sm": `${controlSquare.sm} ${controlText.sm} ${controlRadius.sm}`,
38
+ "icon-lg": `${controlSquare.lg} ${controlText.lg} ${controlRadius.lg}`,
39
+ ...controlSizes,
40
+ },
41
+ },
42
+ defaultVariants: {
43
+ variant: "default",
44
+ size: "default",
45
+ },
46
+ },
47
+ );
48
+
49
+
50
+ export const inputVariants = cva(
51
+ cn(
52
+ "flex w-full border py-1 placeholder:text-[var(--text-dim)] disabled:cursor-not-allowed disabled:opacity-50 file:border-0 file:bg-transparent file:font-medium",
53
+ fieldPresentation,
54
+ fieldTouchText,
55
+ ),
56
+ {
57
+ variants: {
58
+ // `default` and `sandbox` render the same field; both names stay valid.
59
+ variant: {
60
+ default: "",
61
+ sandbox: "",
62
+ error: focusFieldInvalid,
63
+ },
64
+ size: {
65
+ default: fieldSizes.md,
66
+ md: fieldSizes.md,
67
+ sm: fieldSizes.sm,
68
+ lg: fieldSizes.lg,
69
+ compact: cn(fieldSizes.md, controlSizes.compact),
70
+ touch: cn(fieldSizes.md, controlSizes.touch),
71
+ }
72
+ },
73
+ defaultVariants: {
74
+ variant: "default",
75
+ size: "default",
76
+ },
77
+ }
78
+ );
79
+
80
+
81
+ export const tablePresentation = {
82
+ root: "w-full caption-bottom text-sm",
83
+ wrapper: "relative w-full overflow-auto",
84
+ head: "[&_tr]:border-b",
85
+ body: "[&_tr:last-child]:border-0",
86
+ footer: "border-t bg-card font-medium [&>tr]:last:border-b-0",
87
+ row: `border-border border-b ${controlMotion} hover:bg-muted/50 data-[state=selected]:bg-muted/50`,
88
+ columnHeader: "h-12 px-4 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0",
89
+ cell: "p-4 align-middle [&:has([role=checkbox])]:pr-0",
90
+ caption: "mt-4 text-muted-foreground text-sm",
91
+ } as const;
92
+
93
+ export const emptyStatePresentation = {
94
+ root: "flex flex-col items-center justify-center px-4 py-16 text-center",
95
+ icon: "mb-4 rounded-full bg-muted p-4 text-muted-foreground",
96
+ title: "font-semibold text-lg",
97
+ description: "mt-2 max-w-sm text-muted-foreground text-sm",
98
+ action: "mt-6",
99
+ } as const;
100
+
101
+ export const metricPresentation = {
102
+ strip: "grid min-w-0 rounded-[var(--radius-lg)] border border-border bg-card shadow-[var(--shadow-card)]",
103
+ item: "min-w-0 border-border p-4 sm:p-5",
104
+ value: "mt-1 whitespace-normal [overflow-wrap:anywhere] font-semibold text-xl tabular-nums tracking-tight lg:text-2xl",
105
+ label: "flex min-w-0 flex-wrap items-center gap-2 text-muted-foreground text-sm",
106
+ labelText: "min-w-0 whitespace-normal [overflow-wrap:anywhere]",
107
+ hint: "mt-0.5 whitespace-normal [overflow-wrap:anywhere] text-[var(--text-dim)] text-xs",
108
+ } as const;
109
+
110
+ export const metricLayouts = {
111
+ 3: "grid-cols-2 sm:grid-cols-3 max-sm:[&>div:not(:nth-child(2n+1))]:border-l sm:[&>div:not(:nth-child(3n+1))]:border-l",
112
+ 4: "grid-cols-2 sm:grid-cols-4 max-sm:[&>div:not(:nth-child(2n+1))]:border-l sm:[&>div:not(:nth-child(4n+1))]:border-l",
113
+ 5: "grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 max-sm:[&>div:not(:nth-child(2n+1))]:border-l sm:max-lg:[&>div:not(:nth-child(3n+1))]:border-l lg:[&>div:not(:nth-child(5n+1))]:border-l",
114
+ 6: "grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 max-sm:[&>div:not(:nth-child(2n+1))]:border-l sm:max-lg:[&>div:not(:nth-child(3n+1))]:border-l lg:[&>div:not(:nth-child(6n+1))]:border-l",
115
+ } as const;
116
+
117
+ export const dialogPresentation = {
118
+ overlay: "fixed inset-0 z-50 bg-black/40",
119
+ transition: surfaceMotion,
120
+ position: "fixed top-[50%] left-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%]",
121
+ surface: "gap-4 rounded-lg border bg-card p-6 shadow-lg",
122
+ close: `absolute top-4 right-4 rounded-sm opacity-70 ${controlMotion} hover:opacity-100 focus-visible:opacity-100 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground`,
123
+ header: "flex flex-col space-y-1.5 text-center sm:text-left",
124
+ footer: "flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
125
+ title: "font-semibold text-lg leading-none tracking-tight",
126
+ description: "text-muted-foreground text-sm",
127
+ } as const;
128
+
129
+ /** Native-dialog adapter for Hono/static markup. Radix remains the React Dialog/Sheet owner. */
130
+ export function bindDialog(dialog: HTMLDialogElement, trigger: HTMLElement): () => void {
131
+ const sync = () => trigger.setAttribute("aria-expanded", String(dialog.open));
132
+ const show = () => { if (!dialog.open) dialog.showModal(); sync(); };
133
+ const close = () => { sync(); trigger.focus(); };
134
+ const click = (event: MouseEvent) => {
135
+ const target = event.target;
136
+ if (target instanceof Element && target.closest("[data-dialog-close]")) dialog.close();
137
+ };
138
+ // Native modal inertness blocks page controls, but Tab can still visit browser
139
+ // chrome at a boundary. Keep this reading surface's keyboard cycle local.
140
+ const key = (event: KeyboardEvent) => {
141
+ if (event.key !== "Tab" || !dialog.open) return;
142
+ const candidates = [...dialog.querySelectorAll<HTMLElement>("*")]
143
+ .filter(element => (element.tabIndex >= 0 || (element.isContentEditable && !element.hasAttribute("tabindex")))
144
+ && !element.matches(":disabled") && !element.closest("[inert]")
145
+ && element.getClientRects().length > 0 && getComputedStyle(element).visibility === "visible");
146
+ const radio = (element: Element): element is HTMLInputElement => element instanceof HTMLInputElement && element.type === "radio" && !!element.name;
147
+ const sameRadioGroup = (a: Element, b: Element) => radio(a) && radio(b) && a.name === b.name && a.form === b.form && a.getRootNode() === b.getRootNode();
148
+ const stops = candidates.filter(element => {
149
+ if (!radio(element)) return true;
150
+ const group = candidates.filter(candidate => sameRadioGroup(element, candidate));
151
+ const checked = group.find(candidate => (candidate as HTMLInputElement).checked);
152
+ return element === (checked ?? (event.shiftKey ? group.at(-1) : group[0]));
153
+ }).sort((a, b) => (a.tabIndex > 0 ? a.tabIndex : Infinity) - (b.tabIndex > 0 ? b.tabIndex : Infinity));
154
+ const first = stops[0], last = stops.at(-1), active = document.activeElement;
155
+ if (!first) { event.preventDefault(); dialog.focus(); return; }
156
+ const atBoundary = (boundary: HTMLElement) => active === boundary || (active instanceof Element && sameRadioGroup(active, boundary));
157
+ if (active === dialog || (event.shiftKey ? atBoundary(first) : atBoundary(last!))) {
158
+ event.preventDefault(); (event.shiftKey ? last! : first).focus();
159
+ }
160
+ };
161
+ trigger.setAttribute("aria-haspopup", "dialog");
162
+ if (dialog.id) trigger.setAttribute("aria-controls", dialog.id);
163
+ sync();trigger.addEventListener("click",show);dialog.addEventListener("close",close);dialog.addEventListener("click",click);dialog.addEventListener("keydown",key);
164
+ return () => {trigger.removeEventListener("click",show);dialog.removeEventListener("close",close);dialog.removeEventListener("click",click);dialog.removeEventListener("keydown",key);};
165
+ }
166
+
167
+ export const labelVariants = cva(
168
+ "font-medium text-[length:var(--font-size-label,0.875rem)] leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
169
+ );
170
+
171
+ export const helpPresentation = {
172
+ root: "text-[length:var(--font-size-help,0.75rem)] leading-4",
173
+ error: "font-medium text-[var(--surface-danger-text)]",
174
+ hint: "text-[var(--text-dim)]",
175
+ };
176
+
177
+ export const cardPresentation = {
178
+ root: `rounded-[var(--radius-lg)] border text-card-foreground ${controlMotion}`,
179
+ padding: "p-4",
180
+ variants: {
181
+ default: "bg-card border-border shadow-[var(--edge-highlight),var(--shadow-card)]",
182
+ elevated: "bg-muted/50 border-border shadow-[var(--shadow-card)]",
183
+ glass: "bg-card/80 backdrop-blur-xl border-border shadow-[var(--shadow-card)]",
184
+ sandbox: "bg-muted/50 border-primary/20 shadow-[var(--shadow-accent)]",
185
+ },
186
+ } as const;
187
+
188
+ /** Canonical segmented, pill and underline navigation presentation. */
189
+ export const tabsPresentation = {
190
+ list: {
191
+ default:
192
+ "inline-flex h-10 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground",
193
+ pills: "inline-flex items-center gap-2",
194
+ underline: "inline-flex items-center gap-4 border-b border-border",
195
+ },
196
+ trigger: {
197
+ default:
198
+ `inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1.5 text-sm font-medium ${controlMotion} ${focusRing} disabled:pointer-events-none disabled:opacity-50 ${ixControlSoft} ${ixSelected}`,
199
+ pills:
200
+ `inline-flex items-center justify-center whitespace-nowrap rounded-full px-4 py-2 text-sm font-medium ${controlMotion} ${focusRing} disabled:pointer-events-none disabled:opacity-50 data-[state=inactive]:text-muted-foreground ${ixControlSoft} ${ixSelected}`,
201
+ underline:
202
+ `inline-flex items-center justify-center whitespace-nowrap rounded-t-md pb-3 text-sm font-medium ${controlMotion} ${focusRing} disabled:pointer-events-none disabled:opacity-50 border-b-2 border-transparent data-[state=inactive]:text-muted-foreground ${ixTabUnderline}`,
203
+ }
204
+ } as const;
205
+ export { ixControlSoft, ixSelected, ixTabUnderline } from "./lib/interaction";
@@ -1,6 +1,5 @@
1
1
  import * as React from "react";
2
2
  import { cn } from "../lib/utils";
3
- import type { StatusTone } from "./status-pill";
4
3
 
5
4
  /**
6
5
  * An inline message that stays on the page: a policy note above a form, a
@@ -14,7 +13,7 @@ import type { StatusTone } from "./status-pill";
14
13
  * `danger` and `warning` announce themselves (`role="alert"`); the quieter
15
14
  * tones are `role="status"` so a screen reader is not interrupted by a note.
16
15
  */
17
- export type AlertTone = Exclude<StatusTone, "running">;
16
+ export type AlertTone = "neutral" | "info" | "success" | "warning" | "danger";
18
17
 
19
18
  export interface AlertProps extends React.HTMLAttributes<HTMLDivElement> {
20
19
  tone?: AlertTone;
@@ -1,7 +1,6 @@
1
1
  import { Slot } from "@radix-ui/react-slot";
2
- import { cva } from "class-variance-authority";
2
+ import { buttonVariants } from "../presentation";
3
3
  import * as React from "react";
4
- import { controlHeight, controlMotion, controlRadius, controlSizes, controlSquare, controlText } from "../lib/control-presentation";
5
4
  import { focusRing } from "../lib/focus";
6
5
  import { ixControl, ixControlSoft, ixSelected } from "../lib/interaction";
7
6
  import { cn } from "../lib/utils";
@@ -14,43 +13,6 @@ import { cn } from "../lib/utils";
14
13
  // `ixControl` (green edge and glow on hover, press and after a click); ghost,
15
14
  // link and bare take `ixControlSoft`, a wash that never glows. A toggle button
16
15
  // (any `aria-pressed`) adds `ixControl` + `ixSelected` whatever its variant.
17
- const buttonVariants = cva(
18
- `inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-lg font-medium transition-[color,background-color,border-color,box-shadow,transform,scale] ${controlMotion} ${focusRing} disabled:pointer-events-none disabled:opacity-50 aria-disabled:cursor-not-allowed aria-disabled:opacity-50 motion-reduce:active:scale-100 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0`,
19
- {
20
- variants: {
21
- variant: {
22
- default:
23
- `bg-primary text-primary-foreground hover:bg-primary/90 active:scale-[0.97] ${ixControl}`,
24
- destructive:
25
- `bg-destructive text-destructive-foreground hover:bg-destructive/90 active:scale-[0.97] ${ixControl}`,
26
- outline:
27
- `border border-border bg-card hover:bg-muted active:scale-[0.97] text-foreground ${ixControl}`,
28
- secondary:
29
- `bg-muted border border-border text-foreground hover:bg-muted/80 active:scale-[0.97] ${ixControl}`,
30
- ghost:
31
- `hover:text-foreground text-muted-foreground border border-transparent ${ixControlSoft}`,
32
- link: `text-[var(--accent-text)] underline-offset-4 hover:underline ${ixControlSoft}`,
33
- sandbox:
34
- `bg-[var(--btn-primary-bg)] text-[var(--btn-primary-text)] border border-[var(--border-accent)] hover:bg-[var(--btn-primary-hover)] active:scale-[0.97] ${ixControl}`,
35
- },
36
- size: {
37
- default: `${controlHeight.md} ${controlText.md} ${controlRadius.md} px-4 py-2`,
38
- md: `${controlHeight.md} ${controlText.md} ${controlRadius.md} px-4 py-2`,
39
- sm: `${controlHeight.sm} ${controlText.sm} ${controlRadius.sm} px-3`,
40
- lg: `${controlHeight.lg} ${controlText.lg} ${controlRadius.lg} px-7`,
41
- xl: "h-13 rounded-xl px-9 text-base",
42
- icon: `${controlSquare.md} ${controlText.md} ${controlRadius.md}`,
43
- "icon-sm": `${controlSquare.sm} ${controlText.sm} ${controlRadius.sm}`,
44
- "icon-lg": `${controlSquare.lg} ${controlText.lg} ${controlRadius.lg}`,
45
- ...controlSizes,
46
- },
47
- },
48
- defaultVariants: {
49
- variant: "default",
50
- size: "default",
51
- },
52
- },
53
- );
54
16
 
55
17
  // `bare` keeps the shared focus ring, disabled and loading behavior and drops
56
18
  // every surface, size and type class. It is for pressable rows, tiles and pills
@@ -75,7 +37,7 @@ export interface ButtonProps
75
37
  extends React.ButtonHTMLAttributes<HTMLButtonElement> {
76
38
  variant?: "default" | "destructive" | "outline" | "secondary" | "ghost" | "link" | "sandbox" | "bare" | null;
77
39
  /** sm 32px, default/md 36px (`--control-height`), lg 44px; icon sizes are squares on the same heights. */
78
- size?: "default" | "md" | "sm" | "lg" | "xl" | "icon" | "icon-sm" | "icon-lg" | "compact" | "touch" | null;
40
+ size?: "default" | "md" | "sm" | "lg" | "xl" | "display" | "icon" | "icon-sm" | "icon-lg" | "compact" | "touch" | null;
79
41
  asChild?: boolean;
80
42
  loading?: boolean;
81
43
  children?: React.ReactNode;
@@ -136,6 +98,7 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
136
98
  <Slot
137
99
  className={buttonClassName(variant, size, props["aria-pressed"], className)}
138
100
  ref={ref}
101
+ data-variant={size === "display" ? variant ?? "default" : undefined}
139
102
  {...props}
140
103
  {...(isDisabled ? disabledHandlers(props) : {})}
141
104
  >
@@ -161,6 +124,7 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
161
124
  <button
162
125
  className={buttonClassName(variant, size, props["aria-pressed"], className)}
163
126
  ref={ref}
127
+ data-variant={size === "display" ? variant ?? "default" : undefined}
164
128
  {...props}
165
129
  disabled={disabled || loading}
166
130
  aria-busy={loading || props["aria-busy"]}
@@ -1,5 +1,6 @@
1
1
  import * as React from "react";
2
2
  import { ixControl } from "../lib/interaction";
3
+ import { cardPresentation } from "../presentation";
3
4
  import { cn } from "../lib/utils";
4
5
  import { Heading } from "./heading";
5
6
 
@@ -24,20 +25,14 @@ const Card = React.forwardRef<
24
25
  interactive?: boolean;
25
26
  }
26
27
  >(({ className, variant = "default", hover = false, interactive = false, ...props }, ref) => {
27
- const variants = {
28
- default: "bg-card border-border shadow-[var(--edge-highlight),var(--shadow-card)]",
29
- elevated: "bg-muted/50 border-border shadow-[var(--shadow-card)]",
30
- glass: "bg-card/80 backdrop-blur-xl border-border shadow-[var(--shadow-card)]",
31
- sandbox: "bg-muted/50 border-primary/20 shadow-[var(--shadow-accent)]",
32
- };
28
+
33
29
 
34
30
  return (
35
31
  <div
36
32
  ref={ref}
37
33
  className={cn(
38
- "rounded-[var(--radius-lg)] border text-card-foreground transition-[border-color,box-shadow,background-color]",
39
- "duration-[var(--transition-default)]",
40
- variants[variant],
34
+ cardPresentation.root,
35
+ cardPresentation.variants[variant],
41
36
  (hover || interactive) && ixControl,
42
37
  className,
43
38
  )}
@@ -98,7 +93,7 @@ const CardContent = React.forwardRef<
98
93
  >(({ className, ...props }, ref) => (
99
94
  <div
100
95
  ref={ref}
101
- className={cn("p-4 [&:not(:first-child)]:pt-0", className)}
96
+ className={cn(cardPresentation.padding, "[&:not(:first-child)]:pt-0", className)}
102
97
  {...props}
103
98
  />
104
99
  ));
@@ -71,7 +71,8 @@ describe("Sheet", () => {
71
71
  );
72
72
  fireEvent.click(screen.getByText("Open chat"));
73
73
  const dialog = await screen.findByRole("dialog", { name: "Assistant" });
74
- expect(dialog.className).toContain("slide-in-from-left");
74
+ expect(dialog).toHaveAttribute("data-state", "open");
75
+ expect(dialog.style.opacity).toBe("1");
75
76
  fireEvent.click(screen.getByRole("button", { name: "Close" }));
76
77
  await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument());
77
78
  });
@@ -1,7 +1,7 @@
1
1
  import * as React from "react";
2
2
  import { cleanup, render, screen } from "@testing-library/react";
3
3
  import { afterEach, describe, expect, it } from "vitest";
4
- import { controlHeight, controlText, controlMotion, fieldPresentation } from "../lib/control-presentation";
4
+ import { controlHeight, controlText, fieldPresentation } from "../lib/control-presentation";
5
5
  import { HelpText } from "./help-text";
6
6
  import { Label } from "./label";
7
7
  import { Button, buttonVariants } from "./button";
@@ -11,7 +11,7 @@ import { Select, SelectTrigger, SelectValue } from "./select";
11
11
  afterEach(cleanup);
12
12
 
13
13
  describe("shared control presentation", () => {
14
- it("uses the field well, not the border/track alias, with scoped foreground and time-only motion", () => {
14
+ it("uses the field well, not the border/track alias, with scoped foreground", () => {
15
15
  // The well is the brand's --well context variable; Toolbar can still pick the fill.
16
16
  expect(fieldPresentation).toContain("bg-[var(--field-surface,var(--well))]");
17
17
  expect(fieldPresentation.split(" ")).not.toContain("bg-input");
@@ -20,10 +20,6 @@ describe("shared control presentation", () => {
20
20
  expect(fieldPresentation.split(" ")).toContain("border-[var(--field-border)]");
21
21
  expect(fieldPresentation.split(" ")).not.toContain("border-border");
22
22
  expect(fieldPresentation).toContain("text-foreground");
23
- expect(fieldPresentation).not.toContain("duration-150");
24
- expect(controlMotion).toContain("duration-[var(--duration-fast)]");
25
- expect(controlMotion).not.toContain("--transition-fast");
26
- expect(controlMotion).toContain("motion-reduce:duration-0");
27
23
  });
28
24
 
29
25
  it("puts Button, Input, Textarea and SelectTrigger on one height and text scale", () => {
@@ -116,7 +112,6 @@ describe("shared control presentation", () => {
116
112
  expect(buttonVariants({ variant: "destructive" })).toContain("text-destructive-foreground");
117
113
  expect(buttonVariants({ variant: "sandbox" })).toContain("text-[var(--btn-primary-text)]");
118
114
  expect(buttonVariants()).not.toContain("transition-all");
119
- expect(buttonVariants()).toContain("box-shadow,transform,scale");
120
115
  expect(buttonVariants()).toContain("motion-reduce:active:scale-100");
121
116
  });
122
117
  });
@@ -1,3 +1,5 @@
1
+ import { SurfaceMotion } from "../lib/surface-motion";
2
+ import { dialogPresentation } from "../presentation";
1
3
  import * as DialogPrimitive from "@radix-ui/react-dialog";
2
4
  import { X } from "lucide-react";
3
5
  import * as React from "react";
@@ -15,17 +17,17 @@ const DialogClose = DialogPrimitive.Close;
15
17
  const DialogOverlay = React.forwardRef<
16
18
  React.ElementRef<typeof DialogPrimitive.Overlay>,
17
19
  React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
18
- >(({ className, ...props }, ref) => (
20
+ >(({ className, asChild, children, ...props }, ref) => (
19
21
  <DialogPrimitive.Overlay
20
22
  ref={ref}
21
23
  className={cn(
22
- "fixed inset-0 z-50 bg-black/40",
23
- "data-[state=closed]:animate-out data-[state=open]:animate-in",
24
- "data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
24
+ dialogPresentation.overlay,
25
+ dialogPresentation.transition,
25
26
  className,
26
27
  )}
27
28
  {...props}
28
- />
29
+ asChild
30
+ ><SurfaceMotion kind="overlay" asChild={asChild}>{children}</SurfaceMotion></DialogPrimitive.Overlay>
29
31
  ));
30
32
  DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
31
33
 
@@ -40,7 +42,7 @@ const DialogContent = React.forwardRef<
40
42
  */
41
43
  hideCloseButton?: boolean;
42
44
  }
43
- >(({ className, children, variant = "default", hideCloseButton = false, ...props }, ref) => {
45
+ >(({ className, children, variant = "default", hideCloseButton = false, asChild, ...props }, ref) => {
44
46
  const variants = {
45
47
  default: "border-border",
46
48
  sandbox: "border-[var(--border-accent)] shadow-[var(--shadow-accent)]",
@@ -52,23 +54,21 @@ const DialogContent = React.forwardRef<
52
54
  <DialogPrimitive.Content
53
55
  ref={ref}
54
56
  className={cn(
55
- "fixed top-[50%] left-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%]",
56
- "gap-4 rounded-lg border bg-card p-6 shadow-lg duration-200",
57
- "data-[state=closed]:animate-out data-[state=open]:animate-in",
58
- "data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
59
- "data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95",
60
- "data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%]",
61
- "data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%]",
57
+ dialogPresentation.position,
58
+ dialogPresentation.surface,
59
+ dialogPresentation.transition,
62
60
  variants[variant],
63
61
  className,
64
62
  )}
65
63
  {...props}
64
+ asChild
66
65
  >
66
+ <SurfaceMotion kind="dialog" asChild={asChild}>
67
67
  {children}
68
68
  {!hideCloseButton && (
69
69
  <DialogPrimitive.Close
70
70
  className={cn(
71
- "absolute top-4 right-4 rounded-sm opacity-70 transition-[opacity,box-shadow] hover:opacity-100 focus-visible:opacity-100 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground",
71
+ dialogPresentation.close,
72
72
  focusRing,
73
73
  )}
74
74
  >
@@ -76,6 +76,7 @@ const DialogContent = React.forwardRef<
76
76
  <span className="sr-only">Close</span>
77
77
  </DialogPrimitive.Close>
78
78
  )}
79
+ </SurfaceMotion>
79
80
  </DialogPrimitive.Content>
80
81
  </DialogPortal>
81
82
  );
@@ -88,7 +89,7 @@ const DialogHeader = ({
88
89
  }: React.HTMLAttributes<HTMLDivElement>) => (
89
90
  <div
90
91
  className={cn(
91
- "flex flex-col space-y-1.5 text-center sm:text-left",
92
+ dialogPresentation.header,
92
93
  className,
93
94
  )}
94
95
  {...props}
@@ -102,7 +103,7 @@ const DialogFooter = ({
102
103
  }: React.HTMLAttributes<HTMLDivElement>) => (
103
104
  <div
104
105
  className={cn(
105
- "flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
106
+ dialogPresentation.footer,
106
107
  className,
107
108
  )}
108
109
  {...props}
@@ -117,7 +118,7 @@ const DialogTitle = React.forwardRef<
117
118
  <DialogPrimitive.Title
118
119
  ref={ref}
119
120
  className={cn(
120
- "font-semibold text-lg leading-none tracking-tight",
121
+ dialogPresentation.title,
121
122
  className,
122
123
  )}
123
124
  {...props}
@@ -131,7 +132,7 @@ const DialogDescription = React.forwardRef<
131
132
  >(({ className, ...props }, ref) => (
132
133
  <DialogPrimitive.Description
133
134
  ref={ref}
134
- className={cn("text-muted-foreground text-sm", className)}
135
+ className={cn(dialogPresentation.description, className)}
135
136
  {...props}
136
137
  />
137
138
  ));
@@ -1,3 +1,4 @@
1
+ import { emptyStatePresentation } from "../presentation";
1
2
  import * as React from "react";
2
3
  import { cn } from "../lib/utils";
3
4
 
@@ -17,23 +18,23 @@ const EmptyState = React.forwardRef<HTMLDivElement, EmptyStateProps>(
17
18
  <div
18
19
  ref={ref}
19
20
  className={cn(
20
- "flex flex-col items-center justify-center px-4 py-16 text-center",
21
+ emptyStatePresentation.root,
21
22
  className,
22
23
  )}
23
24
  {...props}
24
25
  >
25
26
  {icon && (
26
- <div className="mb-4 rounded-full bg-muted p-4 text-muted-foreground">
27
+ <div className={emptyStatePresentation.icon}>
27
28
  {icon}
28
29
  </div>
29
30
  )}
30
- <Title className="font-semibold text-lg">{title}</Title>
31
+ <Title className={emptyStatePresentation.title}>{title}</Title>
31
32
  {description && (
32
- <p className="mt-2 max-w-sm text-muted-foreground text-sm">
33
+ <p className={emptyStatePresentation.description}>
33
34
  {description}
34
35
  </p>
35
36
  )}
36
- {action && <div className="mt-6">{action}</div>}
37
+ {action && <div className={emptyStatePresentation.action}>{action}</div>}
37
38
  </div>
38
39
  );
39
40
  },