@tangle-network/ui 11.40.0 → 11.40.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +14 -0
- package/dist/lib/control-presentation.d.ts +1 -1
- package/dist/lib/control-presentation.js +9 -7
- package/dist/presentation.js +8 -8
- package/dist/primitives/page-shell.js +26 -5
- package/dist/primitives/segmented-control.js +1 -1
- package/dist/primitives/upload-progress.js +11 -6
- package/package.json +2 -2
- package/src/lib/control-presentation.ts +9 -7
- package/src/presentation.ts +8 -8
- package/src/primitives/control-presentation.stories.tsx +8 -1
- package/src/primitives/page-shell.tsx +26 -7
- package/src/primitives/upload-progress.stories.tsx +36 -1
- package/src/primitives/upload-progress.tsx +13 -8
- package/src/styles/compat.css +5 -5
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,19 @@
|
|
|
1
1
|
# @tangle-network/ui
|
|
2
2
|
|
|
3
|
+
## 11.40.2
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- c22e1b3: PageShell `measure="settings"` re-centres the phone section strip only when the current link changes. Scrolling or tapping an arrow to the first or last section now sticks instead of snapping back to the current link.
|
|
8
|
+
|
|
9
|
+
## 11.40.1
|
|
10
|
+
|
|
11
|
+
### Patch Changes
|
|
12
|
+
|
|
13
|
+
- af0c788: Give Button, Input and SelectTrigger a shared 44px minimum in both dimensions on coarse primary pointers, including compact density. Icon buttons keep a square 44px touch target. Fine-pointer sizes and the existing field text floor are unchanged.
|
|
14
|
+
|
|
15
|
+
Name UploadProgress retry and remove actions after their file, and render them with the shared ghost icon Button for consistent focus and hit areas. Actions remain non-submitting buttons; retry is available only for failed files.
|
|
16
|
+
|
|
3
17
|
## 11.40.0
|
|
4
18
|
|
|
5
19
|
### Minor Changes
|
|
@@ -35,7 +35,7 @@ export declare const fieldSizes: {
|
|
|
35
35
|
readonly lg: string;
|
|
36
36
|
};
|
|
37
37
|
export declare const controlSizes: {
|
|
38
|
-
readonly compact: "h-[var(--control-height)] px-3 py-1 text-sm";
|
|
38
|
+
readonly compact: "h-[var(--control-height)] px-3 py-1 text-sm pointer-coarse:min-h-11 pointer-coarse:min-w-11";
|
|
39
39
|
readonly touch: "h-11 min-h-11 min-w-11 px-4 py-2 text-base";
|
|
40
40
|
};
|
|
41
41
|
export declare const fieldPresentation: string;
|
|
@@ -30,18 +30,20 @@ const controlSquare = {
|
|
|
30
30
|
*/
|
|
31
31
|
const fieldTouchText = "pointer-coarse:text-base";
|
|
32
32
|
/**
|
|
33
|
-
* A control
|
|
34
|
-
*
|
|
33
|
+
* A control smaller than 44px on a fine pointer still gets a 44px target
|
|
34
|
+
* in both dimensions on a coarse one (WCAG 2.5.5, Apple HIG). Same floor as `touch`.
|
|
35
|
+
* Follow the primary pointer, matching SegmentedControl; a secondary touch
|
|
36
|
+
* input alone does not inflate a fine-pointer desktop layout.
|
|
35
37
|
*/
|
|
36
|
-
const touchTarget = "pointer-coarse:min-h-11";
|
|
38
|
+
const touchTarget = "pointer-coarse:min-h-11 pointer-coarse:min-w-11";
|
|
37
39
|
/** Field sizes: the control scale with field padding. */
|
|
38
40
|
const fieldSizes = {
|
|
39
|
-
sm: cn(controlHeight.sm, controlText.sm, controlRadius.sm, "px-2.5"),
|
|
40
|
-
md: cn(controlHeight.md, controlText.md, controlRadius.md, "px-3"),
|
|
41
|
-
lg: cn(controlHeight.lg, controlText.lg, controlRadius.lg, "px-4")
|
|
41
|
+
sm: cn(controlHeight.sm, controlText.sm, controlRadius.sm, touchTarget, "px-2.5"),
|
|
42
|
+
md: cn(controlHeight.md, controlText.md, controlRadius.md, touchTarget, "px-3"),
|
|
43
|
+
lg: cn(controlHeight.lg, controlText.lg, controlRadius.lg, touchTarget, "px-4")
|
|
42
44
|
};
|
|
43
45
|
const controlSizes = {
|
|
44
|
-
compact:
|
|
46
|
+
compact: `h-[var(--control-height)] px-3 py-1 text-sm ${touchTarget}`,
|
|
45
47
|
touch: "h-11 min-h-11 min-w-11 px-4 py-2 text-base"
|
|
46
48
|
};
|
|
47
49
|
const fieldPresentation = cn(focusField, ixField, controlMotion, "min-w-0 text-foreground", "aria-invalid:border-[var(--surface-danger-border)] aria-invalid:hover:border-[var(--surface-danger-border)] aria-invalid:focus:border-[var(--focus-border-danger)] aria-invalid:focus:ring-[var(--focus-halo-danger)]", "autofill:shadow-[inset_0_0_0_1000px_var(--field-surface,var(--well))] autofill:[-webkit-text-fill-color:var(--text-primary)] autofill:caret-[var(--text-primary)]");
|
package/dist/presentation.js
CHANGED
|
@@ -2,7 +2,7 @@ import { controlMotion, surfaceMotion } from "./lib/motion.js";
|
|
|
2
2
|
import { ixControl, ixControlSoft, ixSelected, ixTabUnderline } from "./lib/interaction.js";
|
|
3
3
|
import { focusFieldInvalid, focusRing } from "./lib/focus.js";
|
|
4
4
|
import { cn } from "./lib/utils.js";
|
|
5
|
-
import { controlHeight, controlRadius, controlSizes, controlSquare, controlText, fieldPresentation, fieldSizes, fieldTouchText } from "./lib/control-presentation.js";
|
|
5
|
+
import { controlHeight, controlRadius, controlSizes, controlSquare, controlText, fieldPresentation, fieldSizes, fieldTouchText, touchTarget } from "./lib/control-presentation.js";
|
|
6
6
|
import { cva } from "class-variance-authority";
|
|
7
7
|
//#region src/presentation.ts
|
|
8
8
|
/** React-free presentation. Consumers scan this published source with Tailwind. */
|
|
@@ -19,15 +19,15 @@ const buttonVariants = cva(`inline-flex items-center justify-center gap-2 whites
|
|
|
19
19
|
sandbox: `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}`
|
|
20
20
|
},
|
|
21
21
|
size: {
|
|
22
|
-
default: `${controlHeight.md} ${controlText.md} ${controlRadius.md} px-4 py-2`,
|
|
23
|
-
md: `${controlHeight.md} ${controlText.md} ${controlRadius.md} px-4 py-2`,
|
|
24
|
-
sm: `${controlHeight.sm} ${controlText.sm} ${controlRadius.sm} px-3`,
|
|
25
|
-
lg: `${controlHeight.lg} ${controlText.lg} ${controlRadius.lg} px-7`,
|
|
22
|
+
default: `${controlHeight.md} ${controlText.md} ${controlRadius.md} ${touchTarget} px-4 py-2`,
|
|
23
|
+
md: `${controlHeight.md} ${controlText.md} ${controlRadius.md} ${touchTarget} px-4 py-2`,
|
|
24
|
+
sm: `${controlHeight.sm} ${controlText.sm} ${controlRadius.sm} ${touchTarget} px-3`,
|
|
25
|
+
lg: `${controlHeight.lg} ${controlText.lg} ${controlRadius.lg} ${touchTarget} px-7`,
|
|
26
26
|
display: "tangle-control-display",
|
|
27
27
|
xl: "h-13 rounded-xl px-9 text-base",
|
|
28
|
-
icon: `${controlSquare.md} ${controlText.md} ${controlRadius.md}`,
|
|
29
|
-
"icon-sm": `${controlSquare.sm} ${controlText.sm} ${controlRadius.sm}`,
|
|
30
|
-
"icon-lg": `${controlSquare.lg} ${controlText.lg} ${controlRadius.lg}`,
|
|
28
|
+
icon: `${controlSquare.md} ${controlText.md} ${controlRadius.md} ${touchTarget}`,
|
|
29
|
+
"icon-sm": `${controlSquare.sm} ${controlText.sm} ${controlRadius.sm} ${touchTarget}`,
|
|
30
|
+
"icon-lg": `${controlSquare.lg} ${controlText.lg} ${controlRadius.lg} ${touchTarget}`,
|
|
31
31
|
...controlSizes
|
|
32
32
|
}
|
|
33
33
|
},
|
|
@@ -6,27 +6,44 @@ import { jsx, jsxs } from "react/jsx-runtime";
|
|
|
6
6
|
/**
|
|
7
7
|
* Centre the current link of a horizontally scrolling nav, so the links on
|
|
8
8
|
* both sides show that the strip scrolls. It moves the strip, never the page.
|
|
9
|
+
* It centres when the current link changes (or first becomes reachable), never
|
|
10
|
+
* on scroll, arrow taps or re-renders that keep the same current link, so a
|
|
11
|
+
* user who scrolls to the first or last section stays there. Resize and font
|
|
12
|
+
* swaps re-centre only until the user first scrolls the strip.
|
|
9
13
|
*/
|
|
10
14
|
function useCurrentLinkInView(ref, enabled) {
|
|
11
15
|
React.useEffect(() => {
|
|
12
16
|
const nav = ref.current;
|
|
13
17
|
if (!enabled || !nav) return;
|
|
14
|
-
|
|
18
|
+
let centredKey = null;
|
|
19
|
+
let userScrolled = false;
|
|
20
|
+
const reveal = (layoutOnly) => {
|
|
15
21
|
if (nav.scrollWidth <= nav.clientWidth) return;
|
|
16
22
|
const current = nav.querySelector("[aria-current=\"page\"]");
|
|
17
23
|
if (!current) return;
|
|
24
|
+
const key = current.getAttribute("href") ?? current.textContent ?? "";
|
|
25
|
+
if (key !== centredKey) userScrolled = false;
|
|
26
|
+
else if (!layoutOnly || userScrolled) return;
|
|
27
|
+
centredKey = key;
|
|
18
28
|
const strip = nav.getBoundingClientRect();
|
|
19
29
|
const link = current.getBoundingClientRect();
|
|
20
30
|
nav.scrollLeft += link.left + link.width / 2 - (strip.left + strip.width / 2);
|
|
21
31
|
};
|
|
32
|
+
const onUser = () => {
|
|
33
|
+
userScrolled = true;
|
|
34
|
+
};
|
|
35
|
+
nav.addEventListener("pointerdown", onUser, { passive: true });
|
|
36
|
+
nav.addEventListener("touchstart", onUser, { passive: true });
|
|
37
|
+
nav.addEventListener("wheel", onUser, { passive: true });
|
|
38
|
+
nav.addEventListener("keydown", onUser);
|
|
22
39
|
let live = true;
|
|
23
|
-
reveal();
|
|
40
|
+
reveal(false);
|
|
24
41
|
document.fonts?.ready.then(() => {
|
|
25
|
-
if (live) reveal();
|
|
42
|
+
if (live) reveal(true);
|
|
26
43
|
});
|
|
27
|
-
const resize = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(reveal);
|
|
44
|
+
const resize = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(() => reveal(true));
|
|
28
45
|
resize?.observe(nav);
|
|
29
|
-
const links = new MutationObserver(reveal);
|
|
46
|
+
const links = new MutationObserver(() => reveal(false));
|
|
30
47
|
links.observe(nav, {
|
|
31
48
|
subtree: true,
|
|
32
49
|
childList: true,
|
|
@@ -38,6 +55,10 @@ function useCurrentLinkInView(ref, enabled) {
|
|
|
38
55
|
live = false;
|
|
39
56
|
resize?.disconnect();
|
|
40
57
|
links.disconnect();
|
|
58
|
+
nav.removeEventListener("pointerdown", onUser);
|
|
59
|
+
nav.removeEventListener("touchstart", onUser);
|
|
60
|
+
nav.removeEventListener("wheel", onUser);
|
|
61
|
+
nav.removeEventListener("keydown", onUser);
|
|
41
62
|
};
|
|
42
63
|
}, [ref, enabled]);
|
|
43
64
|
}
|
|
@@ -152,7 +152,7 @@ function SegmentedControl({ value, onValueChange, options, variant = "row", clas
|
|
|
152
152
|
"aria-pressed:bg-[var(--ix-sel-bg)] aria-pressed:text-[var(--accent-text)] aria-pressed:shadow-[inset_0_0_0_1px_var(--ix-sel-ring)] aria-checked:bg-[var(--ix-sel-bg)] aria-checked:text-[var(--accent-text)] aria-checked:shadow-[inset_0_0_0_1px_var(--ix-sel-ring)] aria-[current=page]:bg-[var(--ix-sel-bg)] aria-[current=page]:text-[var(--accent-text)] aria-[current=page]:shadow-[inset_0_0_0_1px_var(--ix-sel-ring)] data-[state=on]:bg-[var(--ix-sel-bg)] data-[state=on]:shadow-[inset_0_0_0_1px_var(--ix-sel-ring)] data-[state=active]:bg-[var(--ix-sel-bg)] data-[state=active]:text-[var(--accent-text)] data-[state=active]:shadow-[inset_0_0_0_1px_var(--ix-sel-ring)]"
|
|
153
153
|
], variant === "tabs" && [
|
|
154
154
|
"shrink-0 rounded-none border-b-2 border-transparent -mb-px px-4 py-2",
|
|
155
|
-
"pointer-coarse:min-h-11",
|
|
155
|
+
"pointer-coarse:min-h-11 pointer-coarse:min-w-11",
|
|
156
156
|
focusRingInset,
|
|
157
157
|
"hover:shadow-[inset_0_-2px_0_hsl(var(--hsl-ring)/0.4)] data-[state=active]:bg-[hsl(var(--hsl-ring)/0.07)] data-[state=active]:text-[var(--accent-text)] data-[state=active]:shadow-[inset_0_-2px_0_var(--ix-sel-ring)] aria-checked:bg-[hsl(var(--hsl-ring)/0.07)] aria-checked:text-[var(--accent-text)] aria-checked:shadow-[inset_0_-2px_0_var(--ix-sel-ring)]"
|
|
158
158
|
], active ? "text-[var(--accent-text)]" : "text-muted-foreground hover:text-foreground"),
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { cn } from "../lib/utils.js";
|
|
2
|
+
import { Button } from "./button.js";
|
|
2
3
|
import { jsx, jsxs } from "react/jsx-runtime";
|
|
3
4
|
import { AlertCircle, CheckCircle2, FileText, Loader2, RefreshCw, X } from "lucide-react";
|
|
4
5
|
//#region src/primitives/upload-progress.tsx
|
|
@@ -52,16 +53,20 @@ function UploadProgress({ files, onRemove, onRetry, className }) {
|
|
|
52
53
|
}),
|
|
53
54
|
/* @__PURE__ */ jsxs("div", {
|
|
54
55
|
className: "flex shrink-0 items-center gap-1",
|
|
55
|
-
children: [file.status === "error" && onRetry && /* @__PURE__ */ jsx(
|
|
56
|
+
children: [file.status === "error" && onRetry && /* @__PURE__ */ jsx(Button, {
|
|
56
57
|
type: "button",
|
|
58
|
+
variant: "ghost",
|
|
59
|
+
size: "icon-sm",
|
|
57
60
|
onClick: () => onRetry(file.id),
|
|
58
|
-
|
|
59
|
-
children: /* @__PURE__ */ jsx(RefreshCw, {
|
|
60
|
-
}), onRemove && /* @__PURE__ */ jsx(
|
|
61
|
+
"aria-label": `Retry ${file.name}`,
|
|
62
|
+
children: /* @__PURE__ */ jsx(RefreshCw, { "aria-hidden": "true" })
|
|
63
|
+
}), onRemove && /* @__PURE__ */ jsx(Button, {
|
|
61
64
|
type: "button",
|
|
65
|
+
variant: "ghost",
|
|
66
|
+
size: "icon-sm",
|
|
62
67
|
onClick: () => onRemove(file.id),
|
|
63
|
-
|
|
64
|
-
children: /* @__PURE__ */ jsx(X, {
|
|
68
|
+
"aria-label": `Remove ${file.name}`,
|
|
69
|
+
children: /* @__PURE__ */ jsx(X, { "aria-hidden": "true" })
|
|
65
70
|
})]
|
|
66
71
|
})
|
|
67
72
|
]
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tangle-network/ui",
|
|
3
|
-
"version": "11.40.
|
|
3
|
+
"version": "11.40.2",
|
|
4
4
|
"description": "Generic React UI components for Tangle products — primitives, chat, run, files, editor, markdown.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -165,7 +165,7 @@
|
|
|
165
165
|
"peerDependencies": {
|
|
166
166
|
"@hocuspocus/provider": ">=2.15.0 <5.0.0",
|
|
167
167
|
"@nanostores/react": ">=0.8.4 <3.0.0",
|
|
168
|
-
"@tangle-network/brand": "^1.26.
|
|
168
|
+
"@tangle-network/brand": "^1.26.1",
|
|
169
169
|
"@tiptap/core": ">=3.20.4 <4.0.0",
|
|
170
170
|
"@tiptap/extension-collaboration": ">=3.20.4 <4.0.0",
|
|
171
171
|
"@tiptap/extension-collaboration-caret": ">=3.7.0 <4.0.0",
|
|
@@ -46,22 +46,24 @@ export const controlSquare = {
|
|
|
46
46
|
export const fieldTouchText = "pointer-coarse:text-base";
|
|
47
47
|
|
|
48
48
|
/**
|
|
49
|
-
* A control
|
|
50
|
-
*
|
|
49
|
+
* A control smaller than 44px on a fine pointer still gets a 44px target
|
|
50
|
+
* in both dimensions on a coarse one (WCAG 2.5.5, Apple HIG). Same floor as `touch`.
|
|
51
|
+
* Follow the primary pointer, matching SegmentedControl; a secondary touch
|
|
52
|
+
* input alone does not inflate a fine-pointer desktop layout.
|
|
51
53
|
*/
|
|
52
|
-
export const touchTarget = "pointer-coarse:min-h-11";
|
|
54
|
+
export const touchTarget = "pointer-coarse:min-h-11 pointer-coarse:min-w-11";
|
|
53
55
|
|
|
54
56
|
/** Field sizes: the control scale with field padding. */
|
|
55
57
|
export const fieldSizes = {
|
|
56
|
-
sm: cn(controlHeight.sm, controlText.sm, controlRadius.sm, "px-2.5"),
|
|
57
|
-
md: cn(controlHeight.md, controlText.md, controlRadius.md, "px-3"),
|
|
58
|
-
lg: cn(controlHeight.lg, controlText.lg, controlRadius.lg, "px-4"),
|
|
58
|
+
sm: cn(controlHeight.sm, controlText.sm, controlRadius.sm, touchTarget, "px-2.5"),
|
|
59
|
+
md: cn(controlHeight.md, controlText.md, controlRadius.md, touchTarget, "px-3"),
|
|
60
|
+
lg: cn(controlHeight.lg, controlText.lg, controlRadius.lg, touchTarget, "px-4"),
|
|
59
61
|
} as const satisfies Record<ControlSize, string>;
|
|
60
62
|
|
|
61
63
|
// Explicit opt-in sizes kept for existing callers. Compact follows the
|
|
62
64
|
// consumer's density token (md); touch is lg with a guaranteed 44px minimum.
|
|
63
65
|
export const controlSizes = {
|
|
64
|
-
compact:
|
|
66
|
+
compact: `h-[var(--control-height)] px-3 py-1 text-sm ${touchTarget}`,
|
|
65
67
|
touch: "h-11 min-h-11 min-w-11 px-4 py-2 text-base",
|
|
66
68
|
} as const;
|
|
67
69
|
|
package/src/presentation.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/** React-free presentation. Consumers scan this published source with Tailwind. */
|
|
2
2
|
import { surfaceMotion } from "./lib/motion";
|
|
3
3
|
import { cva } from "class-variance-authority";
|
|
4
|
-
import { controlHeight, controlMotion, controlRadius, controlSizes, controlSquare, controlText, fieldPresentation, fieldSizes, fieldTouchText } from "./lib/control-presentation";
|
|
4
|
+
import { controlHeight, controlMotion, controlRadius, controlSizes, controlSquare, controlText, fieldPresentation, fieldSizes, fieldTouchText, touchTarget } from "./lib/control-presentation";
|
|
5
5
|
import { focusRing, focusFieldInvalid } from "./lib/focus";
|
|
6
6
|
import { ixControl, ixControlSoft, ixSelected, ixTabUnderline } from "./lib/interaction";
|
|
7
7
|
import { cn } from "./lib/utils";
|
|
@@ -29,15 +29,15 @@ export const buttonVariants = cva(
|
|
|
29
29
|
`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}`,
|
|
30
30
|
},
|
|
31
31
|
size: {
|
|
32
|
-
default: `${controlHeight.md} ${controlText.md} ${controlRadius.md} px-4 py-2`,
|
|
33
|
-
md: `${controlHeight.md} ${controlText.md} ${controlRadius.md} px-4 py-2`,
|
|
34
|
-
sm: `${controlHeight.sm} ${controlText.sm} ${controlRadius.sm} px-3`,
|
|
35
|
-
lg: `${controlHeight.lg} ${controlText.lg} ${controlRadius.lg} px-7`,
|
|
32
|
+
default: `${controlHeight.md} ${controlText.md} ${controlRadius.md} ${touchTarget} px-4 py-2`,
|
|
33
|
+
md: `${controlHeight.md} ${controlText.md} ${controlRadius.md} ${touchTarget} px-4 py-2`,
|
|
34
|
+
sm: `${controlHeight.sm} ${controlText.sm} ${controlRadius.sm} ${touchTarget} px-3`,
|
|
35
|
+
lg: `${controlHeight.lg} ${controlText.lg} ${controlRadius.lg} ${touchTarget} px-7`,
|
|
36
36
|
display: "tangle-control-display",
|
|
37
37
|
xl: "h-13 rounded-xl px-9 text-base",
|
|
38
|
-
icon: `${controlSquare.md} ${controlText.md} ${controlRadius.md}`,
|
|
39
|
-
"icon-sm": `${controlSquare.sm} ${controlText.sm} ${controlRadius.sm}`,
|
|
40
|
-
"icon-lg": `${controlSquare.lg} ${controlText.lg} ${controlRadius.lg}`,
|
|
38
|
+
icon: `${controlSquare.md} ${controlText.md} ${controlRadius.md} ${touchTarget}`,
|
|
39
|
+
"icon-sm": `${controlSquare.sm} ${controlText.sm} ${controlRadius.sm} ${touchTarget}`,
|
|
40
|
+
"icon-lg": `${controlSquare.lg} ${controlText.lg} ${controlRadius.lg} ${touchTarget}`,
|
|
41
41
|
...controlSizes,
|
|
42
42
|
},
|
|
43
43
|
},
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import * as React from "react";
|
|
2
2
|
import type { Meta, StoryObj } from "@storybook/react";
|
|
3
3
|
import { Button } from "./button";
|
|
4
|
+
import { X } from "lucide-react";
|
|
4
5
|
import { HelpText } from "./help-text";
|
|
5
6
|
import { Input, Textarea } from "./input";
|
|
6
7
|
import { Label } from "./label";
|
|
@@ -13,7 +14,7 @@ function Contract() {
|
|
|
13
14
|
return (
|
|
14
15
|
<main className="mx-auto max-w-5xl space-y-6 bg-background p-4 text-foreground" data-presentation-contract>
|
|
15
16
|
<h1 className="text-xl font-semibold">Field and metric presentation</h1>
|
|
16
|
-
{(["sm", "md", "lg", "compact", "touch"] as const).map((size) => (
|
|
17
|
+
{(["default", "sm", "md", "lg", "compact", "touch"] as const).map((size) => (
|
|
17
18
|
<section key={size} data-size-row={size} className="space-y-2">
|
|
18
19
|
<h2 className="font-semibold">{size}</h2>
|
|
19
20
|
<div className="flex flex-wrap items-end gap-3">
|
|
@@ -27,10 +28,16 @@ function Contract() {
|
|
|
27
28
|
</Select>
|
|
28
29
|
<Button size={size} onClick={() => setActions((value) => value + 1)}>Save</Button>
|
|
29
30
|
<Button size={size} variant="outline">Cancel</Button>
|
|
31
|
+
<Button size={size} aria-label={`${size} short action`}>+</Button>
|
|
30
32
|
</div>
|
|
31
33
|
<Textarea size={size} aria-label={`${size} notes`} placeholder="Details" />
|
|
32
34
|
</section>
|
|
33
35
|
))}
|
|
36
|
+
<section className="flex flex-wrap gap-3" aria-label="Icon controls">
|
|
37
|
+
{(["icon-sm", "icon", "icon-lg"] as const).map((size) => (
|
|
38
|
+
<Button key={size} size={size} aria-label={`${size} action`} onClick={() => setActions((value) => value + 1)}><X aria-hidden="true" /></Button>
|
|
39
|
+
))}
|
|
40
|
+
</section>
|
|
34
41
|
<section className="max-w-sm space-y-1.5" data-form-text>
|
|
35
42
|
<Label htmlFor="workspace-name">Workspace name</Label>
|
|
36
43
|
<Input id="workspace-name" aria-describedby="workspace-name-hint" placeholder="Acme" />
|
|
@@ -30,34 +30,53 @@ export interface PageShellProps {
|
|
|
30
30
|
/**
|
|
31
31
|
* Centre the current link of a horizontally scrolling nav, so the links on
|
|
32
32
|
* both sides show that the strip scrolls. It moves the strip, never the page.
|
|
33
|
+
* It centres when the current link changes (or first becomes reachable), never
|
|
34
|
+
* on scroll, arrow taps or re-renders that keep the same current link, so a
|
|
35
|
+
* user who scrolls to the first or last section stays there. Resize and font
|
|
36
|
+
* swaps re-centre only until the user first scrolls the strip.
|
|
33
37
|
*/
|
|
34
38
|
function useCurrentLinkInView(ref: React.RefObject<HTMLElement | null>, enabled: boolean) {
|
|
35
39
|
React.useEffect(() => {
|
|
36
40
|
const nav = ref.current;
|
|
37
41
|
if (!enabled || !nav) return;
|
|
38
|
-
|
|
42
|
+
let centredKey: string | null = null;
|
|
43
|
+
let userScrolled = false;
|
|
44
|
+
const reveal = (layoutOnly: boolean) => {
|
|
39
45
|
if (nav.scrollWidth <= nav.clientWidth) return;
|
|
40
46
|
const current = nav.querySelector<HTMLElement>('[aria-current="page"]');
|
|
41
47
|
if (!current) return;
|
|
48
|
+
const key = current.getAttribute("href") ?? current.textContent ?? "";
|
|
49
|
+
if (key !== centredKey) userScrolled = false;
|
|
50
|
+
else if (!layoutOnly || userScrolled) return;
|
|
51
|
+
centredKey = key;
|
|
42
52
|
const strip = nav.getBoundingClientRect();
|
|
43
53
|
const link = current.getBoundingClientRect();
|
|
44
54
|
nav.scrollLeft += link.left + link.width / 2 - (strip.left + strip.width / 2);
|
|
45
55
|
};
|
|
56
|
+
const onUser = () => { userScrolled = true; };
|
|
57
|
+
nav.addEventListener("pointerdown", onUser, { passive: true });
|
|
58
|
+
nav.addEventListener("touchstart", onUser, { passive: true });
|
|
59
|
+
nav.addEventListener("wheel", onUser, { passive: true });
|
|
60
|
+
nav.addEventListener("keydown", onUser);
|
|
46
61
|
let live = true;
|
|
47
|
-
reveal();
|
|
62
|
+
reveal(false);
|
|
48
63
|
// A web font swap widens the tabs without resizing the strip, so the
|
|
49
64
|
// resize observer alone would leave the current tab where it was.
|
|
50
|
-
void document.fonts?.ready.then(() => { if (live) reveal(); });
|
|
51
|
-
const resize = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(reveal);
|
|
65
|
+
void document.fonts?.ready.then(() => { if (live) reveal(true); });
|
|
66
|
+
const resize = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(() => reveal(true));
|
|
52
67
|
resize?.observe(nav);
|
|
53
|
-
// Links that a caller replaces (sections loaded later, a
|
|
54
|
-
//
|
|
55
|
-
const links = new MutationObserver(reveal);
|
|
68
|
+
// Links that a caller replaces (sections loaded later, a new aria-current)
|
|
69
|
+
// can change the current link; the same current link is left where it is.
|
|
70
|
+
const links = new MutationObserver(() => reveal(false));
|
|
56
71
|
links.observe(nav, { subtree: true, childList: true, characterData: true, attributes: true, attributeFilter: ["aria-current"] });
|
|
57
72
|
return () => {
|
|
58
73
|
live = false;
|
|
59
74
|
resize?.disconnect();
|
|
60
75
|
links.disconnect();
|
|
76
|
+
nav.removeEventListener("pointerdown", onUser);
|
|
77
|
+
nav.removeEventListener("touchstart", onUser);
|
|
78
|
+
nav.removeEventListener("wheel", onUser);
|
|
79
|
+
nav.removeEventListener("keydown", onUser);
|
|
61
80
|
};
|
|
62
81
|
}, [ref, enabled]);
|
|
63
82
|
}
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import type { Meta, StoryObj } from '@storybook/react'
|
|
2
|
+
import { useState } from 'react'
|
|
3
|
+
import { Button } from './button'
|
|
2
4
|
import { UploadProgress, type UploadFile } from './upload-progress'
|
|
3
5
|
|
|
4
6
|
const meta: Meta<typeof UploadProgress> = {
|
|
@@ -7,7 +9,7 @@ const meta: Meta<typeof UploadProgress> = {
|
|
|
7
9
|
parameters: { layout: 'centered', backgrounds: { default: 'dark' } },
|
|
8
10
|
decorators: [
|
|
9
11
|
(Story) => (
|
|
10
|
-
<div className="w-[420px]">
|
|
12
|
+
<div className="w-[420px] max-w-[calc(100vw-2rem)]">
|
|
11
13
|
<Story />
|
|
12
14
|
</div>
|
|
13
15
|
),
|
|
@@ -118,3 +120,36 @@ export const NoActions: Story = {
|
|
|
118
120
|
name: 'No remove / retry handlers',
|
|
119
121
|
args: { files: [complete, uploading45, error] },
|
|
120
122
|
}
|
|
123
|
+
|
|
124
|
+
// A failed upload inside a form, as used by the Agent Builder cover field.
|
|
125
|
+
// Each retry becomes pending; a new observed failure makes retry available again.
|
|
126
|
+
const cover: UploadFile = {
|
|
127
|
+
id: 'cover',
|
|
128
|
+
name: 'cover-image-with-a-long-filename-for-project-review.png',
|
|
129
|
+
size: 2048,
|
|
130
|
+
status: 'error',
|
|
131
|
+
error: 'The upload failed. Try again or remove this file.',
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function RecoveryExample() {
|
|
135
|
+
const [files, setFiles] = useState<UploadFile[]>([cover, complete])
|
|
136
|
+
const [retries, setRetries] = useState(0)
|
|
137
|
+
const [submits, setSubmits] = useState(0)
|
|
138
|
+
return (
|
|
139
|
+
<form className="space-y-4" onSubmit={(event) => { event.preventDefault(); setSubmits((count) => count + 1) }}>
|
|
140
|
+
<UploadProgress
|
|
141
|
+
files={files}
|
|
142
|
+
onRetry={(id) => {
|
|
143
|
+
setRetries((count) => count + 1)
|
|
144
|
+
setFiles((current) => current.map((file) => file.id === id ? { ...file, status: 'pending', error: undefined } : file))
|
|
145
|
+
}}
|
|
146
|
+
onRemove={(id) => setFiles((current) => current.filter((file) => file.id !== id))}
|
|
147
|
+
/>
|
|
148
|
+
<Button type="button" variant="outline" onClick={() => setFiles((current) => current.map((file) => file.id === cover.id ? cover : file))}>Simulate another failure</Button>
|
|
149
|
+
<output className="block text-foreground" aria-label="Retry count">{retries}</output>
|
|
150
|
+
<output className="block text-foreground" aria-label="Form submissions">{submits}</output>
|
|
151
|
+
</form>
|
|
152
|
+
)
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export const Recovery: Story = { render: () => <RecoveryExample /> }
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
|
|
8
8
|
import { AlertCircle, CheckCircle2, FileText, Loader2, RefreshCw, X } from "lucide-react";
|
|
9
9
|
import { cn } from "../lib/utils";
|
|
10
|
+
import { Button } from "./button";
|
|
10
11
|
|
|
11
12
|
export interface UploadFile {
|
|
12
13
|
id: string;
|
|
@@ -85,22 +86,26 @@ export function UploadProgress({ files, onRemove, onRetry, className }: UploadPr
|
|
|
85
86
|
{/* Actions */}
|
|
86
87
|
<div className="flex shrink-0 items-center gap-1">
|
|
87
88
|
{file.status === "error" && onRetry && (
|
|
88
|
-
<
|
|
89
|
+
<Button
|
|
89
90
|
type="button"
|
|
91
|
+
variant="ghost"
|
|
92
|
+
size="icon-sm"
|
|
90
93
|
onClick={() => onRetry(file.id)}
|
|
91
|
-
|
|
94
|
+
aria-label={`Retry ${file.name}`}
|
|
92
95
|
>
|
|
93
|
-
<RefreshCw
|
|
94
|
-
</
|
|
96
|
+
<RefreshCw aria-hidden="true" />
|
|
97
|
+
</Button>
|
|
95
98
|
)}
|
|
96
99
|
{onRemove && (
|
|
97
|
-
<
|
|
100
|
+
<Button
|
|
98
101
|
type="button"
|
|
102
|
+
variant="ghost"
|
|
103
|
+
size="icon-sm"
|
|
99
104
|
onClick={() => onRemove(file.id)}
|
|
100
|
-
|
|
105
|
+
aria-label={`Remove ${file.name}`}
|
|
101
106
|
>
|
|
102
|
-
<X
|
|
103
|
-
</
|
|
107
|
+
<X aria-hidden="true" />
|
|
108
|
+
</Button>
|
|
104
109
|
)}
|
|
105
110
|
</div>
|
|
106
111
|
</div>
|
package/src/styles/compat.css
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
/* Generated by scripts/generate-presentation-css.mjs from shared primitive recipes. Requires Tailwind + Brand theme. */
|
|
2
2
|
@layer components {
|
|
3
|
-
.btn { @apply inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-lg font-medium [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 focus-visible:outline-1 focus-visible:outline-[var(--focus-border)] focus-visible:ring-4 focus-visible:ring-[var(--focus-halo)] focus-visible:outline-offset-0 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 h-[var(--control-height,2.25rem)] text-[length:var(--control-text-md,0.875rem)] rounded-lg px-4 py-2; }
|
|
4
|
-
.btn-primary { @apply inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-lg font-medium [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 focus-visible:outline-1 focus-visible:outline-[var(--focus-border)] focus-visible:ring-4 focus-visible:ring-[var(--focus-halo)] focus-visible:outline-offset-0 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 bg-primary text-primary-foreground hover:bg-primary/90 active:scale-[0.97] hover:border-[var(--ix-hover-border)] hover:shadow-[var(--ix-hover)] focus:not-focus-visible:border-[var(--ix-hover-border)] focus:not-focus-visible:shadow-[var(--ix-after)] active:border-[hsl(var(--hsl-ring))] active:shadow-[var(--ix-press)] focus-visible:shadow-none h-[var(--control-height,2.25rem)] text-[length:var(--control-text-md,0.875rem)] rounded-lg px-4 py-2; }
|
|
5
|
-
.btn-secondary { @apply inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-lg font-medium [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 focus-visible:outline-1 focus-visible:outline-[var(--focus-border)] focus-visible:ring-4 focus-visible:ring-[var(--focus-halo)] focus-visible:outline-offset-0 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 bg-muted border border-border text-foreground hover:bg-muted/80 active:scale-[0.97] hover:border-[var(--ix-hover-border)] hover:shadow-[var(--ix-hover)] focus:not-focus-visible:border-[var(--ix-hover-border)] focus:not-focus-visible:shadow-[var(--ix-after)] active:border-[hsl(var(--hsl-ring))] active:shadow-[var(--ix-press)] focus-visible:shadow-none h-[var(--control-height,2.25rem)] text-[length:var(--control-text-md,0.875rem)] rounded-lg px-4 py-2; }
|
|
6
|
-
.btn-danger { @apply inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-lg font-medium [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 focus-visible:outline-1 focus-visible:outline-[var(--focus-border)] focus-visible:ring-4 focus-visible:ring-[var(--focus-halo)] focus-visible:outline-offset-0 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 bg-destructive text-destructive-foreground hover:bg-destructive/90 active:scale-[0.97] hover:border-[var(--ix-hover-border)] hover:shadow-[var(--ix-hover)] focus:not-focus-visible:border-[var(--ix-hover-border)] focus:not-focus-visible:shadow-[var(--ix-after)] active:border-[hsl(var(--hsl-ring))] active:shadow-[var(--ix-press)] focus-visible:shadow-none h-[var(--control-height,2.25rem)] text-[length:var(--control-text-md,0.875rem)] rounded-lg px-4 py-2; }
|
|
7
|
-
.input { @apply 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 focus:outline-hidden focus:border-[var(--focus-border)] focus:ring-3 focus:ring-[var(--focus-halo)] bg-[var(--field-surface,var(--well))] border-[var(--field-border)] shadow-[var(--shadow-well)] hover:border-[var(--ix-hover-border)] hover:shadow-[var(--shadow-well),var(--ix-hover)] [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 min-w-0 text-foreground aria-invalid:border-[var(--surface-danger-border)] aria-invalid:hover:border-[var(--surface-danger-border)] aria-invalid:focus:border-[var(--focus-border-danger)] aria-invalid:focus:ring-[var(--focus-halo-danger)] autofill:shadow-[inset_0_0_0_1000px_var(--field-surface,var(--well))] autofill:[-webkit-text-fill-color:var(--text-primary)] autofill:caret-[var(--text-primary)] pointer-coarse:text-base h-[var(--control-height,2.25rem)] text-[length:var(--control-text-md,0.875rem)] rounded-lg px-3; }
|
|
3
|
+
.btn { @apply inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-lg font-medium [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 focus-visible:outline-1 focus-visible:outline-[var(--focus-border)] focus-visible:ring-4 focus-visible:ring-[var(--focus-halo)] focus-visible:outline-offset-0 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 h-[var(--control-height,2.25rem)] text-[length:var(--control-text-md,0.875rem)] rounded-lg pointer-coarse:min-h-11 pointer-coarse:min-w-11 px-4 py-2; }
|
|
4
|
+
.btn-primary { @apply inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-lg font-medium [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 focus-visible:outline-1 focus-visible:outline-[var(--focus-border)] focus-visible:ring-4 focus-visible:ring-[var(--focus-halo)] focus-visible:outline-offset-0 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 bg-primary text-primary-foreground hover:bg-primary/90 active:scale-[0.97] hover:border-[var(--ix-hover-border)] hover:shadow-[var(--ix-hover)] focus:not-focus-visible:border-[var(--ix-hover-border)] focus:not-focus-visible:shadow-[var(--ix-after)] active:border-[hsl(var(--hsl-ring))] active:shadow-[var(--ix-press)] focus-visible:shadow-none h-[var(--control-height,2.25rem)] text-[length:var(--control-text-md,0.875rem)] rounded-lg pointer-coarse:min-h-11 pointer-coarse:min-w-11 px-4 py-2; }
|
|
5
|
+
.btn-secondary { @apply inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-lg font-medium [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 focus-visible:outline-1 focus-visible:outline-[var(--focus-border)] focus-visible:ring-4 focus-visible:ring-[var(--focus-halo)] focus-visible:outline-offset-0 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 bg-muted border border-border text-foreground hover:bg-muted/80 active:scale-[0.97] hover:border-[var(--ix-hover-border)] hover:shadow-[var(--ix-hover)] focus:not-focus-visible:border-[var(--ix-hover-border)] focus:not-focus-visible:shadow-[var(--ix-after)] active:border-[hsl(var(--hsl-ring))] active:shadow-[var(--ix-press)] focus-visible:shadow-none h-[var(--control-height,2.25rem)] text-[length:var(--control-text-md,0.875rem)] rounded-lg pointer-coarse:min-h-11 pointer-coarse:min-w-11 px-4 py-2; }
|
|
6
|
+
.btn-danger { @apply inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-lg font-medium [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 focus-visible:outline-1 focus-visible:outline-[var(--focus-border)] focus-visible:ring-4 focus-visible:ring-[var(--focus-halo)] focus-visible:outline-offset-0 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 bg-destructive text-destructive-foreground hover:bg-destructive/90 active:scale-[0.97] hover:border-[var(--ix-hover-border)] hover:shadow-[var(--ix-hover)] focus:not-focus-visible:border-[var(--ix-hover-border)] focus:not-focus-visible:shadow-[var(--ix-after)] active:border-[hsl(var(--hsl-ring))] active:shadow-[var(--ix-press)] focus-visible:shadow-none h-[var(--control-height,2.25rem)] text-[length:var(--control-text-md,0.875rem)] rounded-lg pointer-coarse:min-h-11 pointer-coarse:min-w-11 px-4 py-2; }
|
|
7
|
+
.input { @apply 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 focus:outline-hidden focus:border-[var(--focus-border)] focus:ring-3 focus:ring-[var(--focus-halo)] bg-[var(--field-surface,var(--well))] border-[var(--field-border)] shadow-[var(--shadow-well)] hover:border-[var(--ix-hover-border)] hover:shadow-[var(--shadow-well),var(--ix-hover)] [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 min-w-0 text-foreground aria-invalid:border-[var(--surface-danger-border)] aria-invalid:hover:border-[var(--surface-danger-border)] aria-invalid:focus:border-[var(--focus-border-danger)] aria-invalid:focus:ring-[var(--focus-halo-danger)] autofill:shadow-[inset_0_0_0_1000px_var(--field-surface,var(--well))] autofill:[-webkit-text-fill-color:var(--text-primary)] autofill:caret-[var(--text-primary)] pointer-coarse:text-base h-[var(--control-height,2.25rem)] text-[length:var(--control-text-md,0.875rem)] rounded-lg pointer-coarse:min-h-11 pointer-coarse:min-w-11 px-3; }
|
|
8
8
|
.card { @apply rounded-[var(--radius-lg)] border text-card-foreground [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 bg-card border-border shadow-[var(--edge-highlight),var(--shadow-card)] p-4; }
|
|
9
9
|
.stat-card { @apply rounded-[var(--radius-lg)] border text-card-foreground [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 bg-card border-border shadow-[var(--edge-highlight),var(--shadow-card)] min-w-0 border-border p-4 sm:p-5; }
|
|
10
10
|
.table-header { @apply h-12 px-4 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0; }
|