@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 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 that is shorter than 44px on a fine pointer still gets a 44px hit
34
- * height on a coarse one (WCAG 2.5.5, Apple HIG). Same floor as `touch`.
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: "h-[var(--control-height)] px-3 py-1 text-sm",
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)]");
@@ -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
- const reveal = () => {
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("button", {
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
- className: "rounded p-1 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground",
59
- children: /* @__PURE__ */ jsx(RefreshCw, { className: "h-3.5 w-3.5" })
60
- }), onRemove && /* @__PURE__ */ jsx("button", {
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
- className: "rounded p-1 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground",
64
- children: /* @__PURE__ */ jsx(X, { className: "h-3.5 w-3.5" })
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.0",
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.0",
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 that is shorter than 44px on a fine pointer still gets a 44px hit
50
- * height on a coarse one (WCAG 2.5.5, Apple HIG). Same floor as `touch`.
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: "h-[var(--control-height)] px-3 py-1 text-sm",
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
 
@@ -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
- const reveal = () => {
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 count that
54
- // changes) move the current tab as surely as a new aria-current does.
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
- <button
89
+ <Button
89
90
  type="button"
91
+ variant="ghost"
92
+ size="icon-sm"
90
93
  onClick={() => onRetry(file.id)}
91
- className="rounded p-1 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
94
+ aria-label={`Retry ${file.name}`}
92
95
  >
93
- <RefreshCw className="h-3.5 w-3.5" />
94
- </button>
96
+ <RefreshCw aria-hidden="true" />
97
+ </Button>
95
98
  )}
96
99
  {onRemove && (
97
- <button
100
+ <Button
98
101
  type="button"
102
+ variant="ghost"
103
+ size="icon-sm"
99
104
  onClick={() => onRemove(file.id)}
100
- className="rounded p-1 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
105
+ aria-label={`Remove ${file.name}`}
101
106
  >
102
- <X className="h-3.5 w-3.5" />
103
- </button>
107
+ <X aria-hidden="true" />
108
+ </Button>
104
109
  )}
105
110
  </div>
106
111
  </div>
@@ -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; }