@cosxai/ui 1.0.0-alpha.1 → 1.0.0-alpha.11

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 (62) hide show
  1. package/CHANGELOG.md +109 -0
  2. package/package.json +10 -4
  3. package/src/brand.css +1 -1
  4. package/src/components/ActionBar.tsx +607 -0
  5. package/src/components/ActivityTimeline.tsx +77 -0
  6. package/src/components/AppRail.tsx +141 -0
  7. package/src/components/AsyncSelect.tsx +205 -0
  8. package/src/components/AuthLayout.tsx +127 -0
  9. package/src/components/Avatar.tsx +75 -0
  10. package/src/components/BottomSheet.tsx +151 -0
  11. package/src/components/BottomTabs.tsx +102 -0
  12. package/src/components/BrandLoader.tsx +122 -0
  13. package/src/components/Breadcrumb.tsx +129 -0
  14. package/src/components/Button.tsx +48 -40
  15. package/src/components/ChoiceCards.tsx +68 -0
  16. package/src/components/CodeInput.tsx +150 -0
  17. package/src/components/CommandPalette.tsx +228 -0
  18. package/src/components/ConfirmDialog.tsx +294 -0
  19. package/src/components/CopyField.tsx +104 -0
  20. package/src/components/DateInput.tsx +366 -0
  21. package/src/components/Dialog.tsx +27 -3
  22. package/src/components/Dropzone.tsx +187 -0
  23. package/src/components/FileCard.tsx +233 -0
  24. package/src/components/FileRow.tsx +159 -0
  25. package/src/components/FolderTree.tsx +377 -0
  26. package/src/components/ListToolbar.tsx +177 -0
  27. package/src/components/MentionInput.tsx +233 -0
  28. package/src/components/Menu.tsx +109 -0
  29. package/src/components/PageHeader.tsx +62 -0
  30. package/src/components/PageState.tsx +84 -0
  31. package/src/components/Popover.tsx +46 -0
  32. package/src/components/Progress.tsx +97 -0
  33. package/src/components/PulseDot.tsx +31 -0
  34. package/src/components/RecipientsInput.tsx +252 -0
  35. package/src/components/SearchField.tsx +137 -0
  36. package/src/components/SegmentedControl.tsx +95 -0
  37. package/src/components/SidePanel.tsx +130 -0
  38. package/src/components/Skeleton.tsx +73 -0
  39. package/src/components/StageProgress.tsx +51 -0
  40. package/src/components/Steps.tsx +58 -0
  41. package/src/components/SyncStatus.tsx +56 -0
  42. package/src/components/ThemeSwitch.tsx +99 -0
  43. package/src/components/Toast.tsx +45 -8
  44. package/src/components/UploadCheck.tsx +118 -0
  45. package/src/components/UploadList.tsx +176 -0
  46. package/src/components/VirtualList.tsx +90 -0
  47. package/src/components/WindowDrop.tsx +131 -0
  48. package/src/components/WorkspaceRow.tsx +80 -0
  49. package/src/components/inputs-aria.ts +5 -0
  50. package/src/components/inputs-date.ts +92 -0
  51. package/src/components/inputs-listbox.tsx +154 -0
  52. package/src/components/inputs-search.ts +60 -0
  53. package/src/components/status-files.ts +124 -0
  54. package/src/components/status-motion.ts +30 -0
  55. package/src/components/useSelection.ts +142 -0
  56. package/src/index.ts +52 -1
  57. package/src/theme-base.css +123 -0
  58. package/src/theme-reset.css +14 -0
  59. package/src/theme.css +2 -125
  60. package/src/components/core.test.tsx +0 -242
  61. package/src/components/forms.test.tsx +0 -280
  62. package/src/components/overlays.test.tsx +0 -245
@@ -0,0 +1,151 @@
1
+ import * as DialogPrimitive from '@radix-ui/react-dialog';
2
+ import { useCallback, useEffect, useRef, useState, type ComponentProps, type PointerEvent as ReactPointerEvent, type ReactElement, type ReactNode } from 'react';
3
+
4
+ import { cn } from '../lib/cn';
5
+ import { useReducedMotion } from './status-motion';
6
+
7
+ export type BottomSheetProps = Omit<ComponentProps<typeof DialogPrimitive.Content>, 'title' | 'children' | 'ref'> & {
8
+ /** Says what the sheet is for ("Switch workspace", "Language"). Labels the dialog. */
9
+ title: ReactNode;
10
+ /** Keep the title for screen readers only. @default false */
11
+ hideTitle?: boolean | undefined;
12
+ /** A line under the title, read out with it. */
13
+ description?: ReactNode;
14
+ children?: ReactNode;
15
+ open?: boolean | undefined;
16
+ defaultOpen?: boolean | undefined;
17
+ onOpenChange?: ((open: boolean) => void) | undefined;
18
+ /** The element that opens it (rendered asChild). Or control `open`. */
19
+ trigger?: ReactElement | undefined;
20
+ /** Accessible name of the screen-reader close button (touch readers have no Esc). @default "Close" */
21
+ closeLabel?: string | undefined;
22
+ };
23
+
24
+ // A drag past this share of the sheet's height, or a flick, closes it.
25
+ const CLOSE_SHARE = 0.25;
26
+ const CLOSE_MAX = 120;
27
+ const FLICK = 0.6; // px per ms
28
+
29
+ /**
30
+ * BottomSheet — the phone's light choice: a page-coloured sheet rising from
31
+ * the bottom with a drag handle and a title (Metaroom Agent phone: switch
32
+ * workspace, language). 20px top corners over the scrim; the bottom padding
33
+ * clears the home indicator (safe area). Drag the handle or the title down,
34
+ * or flick, to close; Esc, a tap on the scrim and the screen-reader close
35
+ * button close it too. Built on Radix Dialog: focus moves in, is trapped and
36
+ * returns to the opener.
37
+ *
38
+ * Compose with: BottomTabs (the Me tab opens it), rows of 48–56px buttons
39
+ * inside. On desktop use Popover or DialogContent instead.
40
+ */
41
+ export function BottomSheet({
42
+ title,
43
+ hideTitle = false,
44
+ description,
45
+ children,
46
+ open: openProp,
47
+ defaultOpen = false,
48
+ onOpenChange,
49
+ trigger,
50
+ closeLabel = 'Close',
51
+ className,
52
+ style,
53
+ onOpenAutoFocus,
54
+ ...rest
55
+ }: BottomSheetProps) {
56
+ const [openState, setOpenState] = useState(defaultOpen);
57
+ const open = openProp ?? openState;
58
+ const setOpen = useCallback(
59
+ (next: boolean) => {
60
+ if (openProp === undefined) setOpenState(next);
61
+ onOpenChange?.(next);
62
+ },
63
+ [openProp, onOpenChange],
64
+ );
65
+
66
+ const reduce = useReducedMotion();
67
+ const sheet = useRef<HTMLDivElement | null>(null);
68
+ const drag = useRef<{ y0: number; t0: number } | null>(null);
69
+ const [dy, setDy] = useState(0);
70
+ const [settling, setSettling] = useState(false);
71
+
72
+ useEffect(() => {
73
+ if (!open) setDy(0);
74
+ }, [open]);
75
+
76
+ const onDown = (e: ReactPointerEvent<HTMLDivElement>) => {
77
+ if (e.button !== 0) return;
78
+ drag.current = { y0: e.clientY, t0: performance.now() };
79
+ setSettling(false);
80
+ e.currentTarget.setPointerCapture?.(e.pointerId);
81
+ };
82
+ const onMove = (e: ReactPointerEvent<HTMLDivElement>) => {
83
+ if (drag.current) setDy(Math.max(0, e.clientY - drag.current.y0));
84
+ };
85
+ const onUp = (e: ReactPointerEvent<HTMLDivElement>) => {
86
+ const d = drag.current;
87
+ drag.current = null;
88
+ if (!d) return;
89
+ const dist = Math.max(0, e.clientY - d.y0);
90
+ const speed = dist / Math.max(1, performance.now() - d.t0);
91
+ const height = sheet.current?.offsetHeight ?? 0;
92
+ const limit = height > 0 ? Math.min(CLOSE_MAX, height * CLOSE_SHARE) : CLOSE_MAX;
93
+ setSettling(true);
94
+ if (dist > limit || (dist > 8 && speed > FLICK)) setOpen(false);
95
+ else setDy(0);
96
+ };
97
+
98
+ return (
99
+ <DialogPrimitive.Root open={open} onOpenChange={setOpen}>
100
+ {trigger && <DialogPrimitive.Trigger asChild>{trigger}</DialogPrimitive.Trigger>}
101
+ <DialogPrimitive.Portal>
102
+ <DialogPrimitive.Overlay className="fixed inset-0 z-50 bg-scrim" />
103
+ <DialogPrimitive.Content
104
+ ref={sheet}
105
+ {...(description ? {} : { 'aria-describedby': undefined })}
106
+ onOpenAutoFocus={(e) => {
107
+ onOpenAutoFocus?.(e);
108
+ if (e.defaultPrevented) return;
109
+ // The sheet itself takes focus (read out by its title), not the first row.
110
+ e.preventDefault();
111
+ const el = e.currentTarget as HTMLElement | null;
112
+ el?.focus();
113
+ if (!reduce && el && typeof el.animate === 'function') {
114
+ el.animate([{ transform: 'translateY(100%)' }, { transform: 'translateY(0)' }], { duration: 240, easing: 'cubic-bezier(.16,1,.3,1)' });
115
+ }
116
+ }}
117
+ className={cn(
118
+ 'fixed inset-x-0 bottom-0 z-50 flex max-h-[90dvh] flex-col rounded-t-[20px] bg-page px-4 pt-2 font-sans text-fg outline-none',
119
+ settling && !reduce && 'transition-transform duration-200 ease-out',
120
+ className,
121
+ )}
122
+ style={{
123
+ paddingBottom: 'max(30px, calc(env(safe-area-inset-bottom, 0px) + 12px))',
124
+ ...(dy ? { transform: `translateY(${dy}px)` } : {}),
125
+ ...style,
126
+ }}
127
+ {...rest}
128
+ >
129
+ <div
130
+ data-sheet-drag=""
131
+ className="flex shrink-0 cursor-grab touch-none flex-col select-none active:cursor-grabbing"
132
+ onPointerDown={onDown}
133
+ onPointerMove={onMove}
134
+ onPointerUp={onUp}
135
+ onPointerCancel={() => {
136
+ drag.current = null;
137
+ setSettling(true);
138
+ setDy(0);
139
+ }}
140
+ >
141
+ <span aria-hidden className="mb-2.5 h-1 w-9 self-center rounded-pill bg-rule" />
142
+ <DialogPrimitive.Title className={cn('m-0 px-1 pb-2 text-[17px] leading-[1.3] font-medium', hideTitle && 'sr-only')}>{title}</DialogPrimitive.Title>
143
+ {description && <DialogPrimitive.Description className="m-0 px-1 pb-2 text-small text-fg-secondary">{description}</DialogPrimitive.Description>}
144
+ </div>
145
+ <div className="flex min-h-0 flex-1 flex-col gap-0.5 overflow-y-auto">{children}</div>
146
+ <DialogPrimitive.Close className="sr-only">{closeLabel}</DialogPrimitive.Close>
147
+ </DialogPrimitive.Content>
148
+ </DialogPrimitive.Portal>
149
+ </DialogPrimitive.Root>
150
+ );
151
+ }
@@ -0,0 +1,102 @@
1
+ import type { LucideIcon } from 'lucide-react';
2
+ import { forwardRef, type ComponentProps, type ReactNode } from 'react';
3
+
4
+ import { cn } from '../lib/cn';
5
+ import { formatCount } from './Button';
6
+ import { Icon } from './Icon';
7
+
8
+ export type BottomTabItem = {
9
+ key: string;
10
+ /** A Lucide icon. Ignored when `avatar` is set. */
11
+ icon?: LucideIcon | undefined;
12
+ label: string;
13
+ /** A count on the corner; hidden at zero (and on a disabled tab), 99+ above 99. */
14
+ count?: number | undefined;
15
+ /** Not available yet: 40% opacity, aria-disabled; a press calls `onDisabledSelect`. */
16
+ disabled?: boolean | undefined;
17
+ /** Shown in place of the icon — the person's Avatar (size 24) on the Me tab. */
18
+ avatar?: ReactNode;
19
+ };
20
+
21
+ export type BottomTabsProps = Omit<ComponentProps<'nav'>, 'onChange' | 'children'> & {
22
+ items: BottomTabItem[];
23
+ /** The current tab's key. */
24
+ value: string;
25
+ onChange: (key: string) => void;
26
+ /** A disabled tab was pressed — say why (a toast: "Coming soon"). */
27
+ onDisabledSelect?: ((key: string) => void) | undefined;
28
+ /** The landmark's accessible name ("Modules"). */
29
+ label: string;
30
+ };
31
+
32
+ /**
33
+ * BottomTabs — the phone's tab bar (Metaroom Agent phone): an icon in a
34
+ * 48×28 pill over an 11px label; the current tab's pill takes the
35
+ * workspace's field colour (--brand-field), a person's avatar gets an ink
36
+ * ring. Tabs not built yet stay visible at 40% opacity (aria-disabled, still
37
+ * focusable, so a press can explain). The bottom padding clears the home
38
+ * indicator.
39
+ *
40
+ * Compose with: Avatar (size 24) for the Me tab, BottomSheet opened from
41
+ * it; AppRail is the same navigation on desktop.
42
+ *
43
+ * A nav landmark of buttons; the current one has aria-current="page".
44
+ * Forwards ref to the <nav>; spreads `...rest` onto it.
45
+ */
46
+ export const BottomTabs = forwardRef<HTMLElement, BottomTabsProps>(function BottomTabs(
47
+ { items, value, onChange, onDisabledSelect, label, className, style, ...rest },
48
+ ref,
49
+ ) {
50
+ return (
51
+ <nav
52
+ ref={ref}
53
+ aria-label={label}
54
+ className={cn('flex shrink-0 border-t border-rule bg-page px-2 pt-1.5 font-sans text-fg', className)}
55
+ style={{ paddingBottom: 'max(22px, env(safe-area-inset-bottom, 0px))', ...style }}
56
+ {...rest}
57
+ >
58
+ {items.map((it) => {
59
+ const on = it.key === value;
60
+ const count = it.disabled || it.count === undefined ? null : formatCount(it.count);
61
+ return (
62
+ <button
63
+ key={it.key}
64
+ type="button"
65
+ aria-current={on ? 'page' : undefined}
66
+ aria-disabled={it.disabled || undefined}
67
+ aria-label={count ? `${it.label} (${count})` : undefined}
68
+ onClick={() => (it.disabled ? onDisabledSelect?.(it.key) : onChange(it.key))}
69
+ className={cn(
70
+ 'relative flex min-h-12 flex-1 cursor-pointer flex-col items-center justify-center gap-[3px] rounded-md border-0 bg-transparent p-0 text-fg outline-none focus-visible:shadow-(--focus-ring)',
71
+ it.disabled && 'cursor-not-allowed opacity-40',
72
+ )}
73
+ >
74
+ <span
75
+ className={cn(
76
+ 'grid h-7 w-12 place-items-center rounded-pill transition-colors duration-[120ms] ease-standard',
77
+ on && 'bg-brand-field text-ink',
78
+ )}
79
+ >
80
+ {it.avatar ? (
81
+ <span aria-hidden className={cn('inline-flex rounded-pill', on && 'shadow-[0_0_0_1.5px_var(--text-primary)]')}>
82
+ {it.avatar}
83
+ </span>
84
+ ) : (
85
+ it.icon && <Icon icon={it.icon} size={18} />
86
+ )}
87
+ </span>
88
+ <span className="text-[11px] leading-none font-medium">{it.label}</span>
89
+ {count && (
90
+ <span
91
+ aria-hidden
92
+ className="absolute top-0.5 left-1/2 ml-2 h-3.5 min-w-3.5 rounded-pill bg-brand-mark px-[3px] text-center text-[9px] leading-[14px] font-bold text-ink tabular-nums"
93
+ >
94
+ {count}
95
+ </span>
96
+ )}
97
+ </button>
98
+ );
99
+ })}
100
+ </nav>
101
+ );
102
+ });
@@ -0,0 +1,122 @@
1
+ import { useEffect, useId, useRef } from 'react';
2
+
3
+ import { LOOP } from './logo-art';
4
+
5
+ /**
6
+ * BrandLoader — the COSX loop as a loading indicator (Claude Design, COSX
7
+ * Loader). A short stroke runs along the loop, blooms into the full mark,
8
+ * holds 0.6 s, gathers back and runs again: 3.4 s a round, the head's
9
+ * speed continuous. At rest it is the logo itself — the fill is the logo
10
+ * path, the stroke only a mask over it.
11
+ *
12
+ * For whole-page and start-up waits, where the brand belongs. Buttons,
13
+ * rows, inputs and anything under 24 px wide keep Spinner or a skeleton.
14
+ * Reduced motion: the full mark, still.
15
+ */
16
+ export type BrandLoaderProps = {
17
+ /** Width in px (height follows the mark: 106 / 202). Default 48; 24 at least. */
18
+ size?: number | undefined;
19
+ /**
20
+ * ink on paper and yellow; linen or accent on ink; current follows the
21
+ * text colour (light and dark themes alike). The mark is never yellow on paper.
22
+ */
23
+ tone?: 'ink' | 'linen' | 'accent' | 'current' | undefined;
24
+ /** The faint full mark under the stroke (default on), so it always reads as COSX. */
25
+ track?: boolean | undefined;
26
+ /** 1 = a 3.4 s round. */
27
+ speed?: number | undefined;
28
+ /** What is loading, for assistive tech. Default "Loading". */
29
+ label?: string | undefined;
30
+ className?: string | undefined;
31
+ };
32
+
33
+ const TONES = {
34
+ ink: ['#111111', 'rgba(17,17,17,.12)'],
35
+ linen: ['#F5F2EC', 'rgba(245,242,236,.16)'],
36
+ accent: ['#FFD166', 'rgba(255,209,102,.18)'],
37
+ current: ['currentColor', 'currentColor'],
38
+ } as const;
39
+
40
+ // The stroke's centre line: the C counter-clockwise, the diagonal, the O
41
+ // clockwise, then back across the open gap.
42
+ const R = 38.3;
43
+ const LC = [96.12, 144.62] as const;
44
+ const RC = [193.32, 144.62] as const;
45
+ const pt = (c: readonly [number, number], deg: number) => {
46
+ const a = (deg * Math.PI) / 180;
47
+ return `${(c[0] + R * Math.cos(a)).toFixed(2)},${(c[1] + R * Math.sin(a)).toFixed(2)}`;
48
+ };
49
+ const CENTRE = `M${pt(LC, -42)} A${R},${R} 0 1,0 ${pt(LC, 48)} L${pt(RC, 214)} A${R},${R} 0 0,1 ${pt(RC, 34)} A${R},${R} 0 0,1 ${pt(RC, 214)} L${pt(LC, -42)} Z`;
50
+
51
+ // Seconds: run, bloom, hold, gather; SEG = the running stroke's share of the loop.
52
+ const RUN = 1.5;
53
+ const GROW = 0.7;
54
+ const HOLD = 0.6;
55
+ const SHRINK = 0.6;
56
+ const T = RUN + GROW + HOLD + SHRINK;
57
+ const SEG = 0.14;
58
+ const easeOut = (x: number) => 1 - (1 - x) ** 3;
59
+ const easeInOut = (x: number) => (x < 0.5 ? 4 * x ** 3 : 1 - (-2 * x + 2) ** 3 / 2);
60
+
61
+ /** Head position h (in loop lengths) and visible share s (0..1) at t seconds. */
62
+ export function loaderState(t: number): { h: number; s: number } {
63
+ const c = Math.floor(t / T);
64
+ let u = t - c * T;
65
+ const base = c * (RUN + GROW / 2 + SHRINK / 2);
66
+ if (u < RUN) return { h: base + u, s: SEG };
67
+ u -= RUN;
68
+ if (u < GROW) {
69
+ const g = u / GROW;
70
+ return { h: base + RUN + GROW * (g - (g * g) / 2), s: SEG + (1 - SEG) * easeOut(g) };
71
+ }
72
+ u -= GROW;
73
+ if (u < HOLD) return { h: base + RUN + GROW / 2, s: 1 };
74
+ u -= HOLD;
75
+ const k = u / SHRINK;
76
+ return { h: base + RUN + GROW / 2 + (SHRINK * k * k) / 2, s: 1 - (1 - SEG) * easeInOut(k) };
77
+ }
78
+
79
+ export function BrandLoader({ size = 48, tone = 'ink', track = true, speed = 1, label = 'Loading', className }: BrandLoaderProps) {
80
+ const maskId = `cosx-loader-${useId().replace(/[^a-zA-Z0-9-]/g, '')}`;
81
+ const stroke = useRef<SVGPathElement>(null);
82
+ const [fill, faint] = TONES[tone];
83
+
84
+ useEffect(() => {
85
+ const c = stroke.current;
86
+ if (!c || typeof c.getTotalLength !== 'function') return undefined;
87
+ const L = c.getTotalLength();
88
+ const still = window.matchMedia?.('(prefers-reduced-motion: reduce)');
89
+ const t0 = performance.now();
90
+ let raf = 0;
91
+ const frame = (now: number) => {
92
+ const st = still?.matches ? { h: 0, s: 1 } : loaderState(((now - t0) / 1000) * speed);
93
+ const s = Math.min(1, st.s);
94
+ const tail = (((st.h - s) % 1) + 1) % 1;
95
+ c.setAttribute('stroke-dasharray', s >= 0.999 ? `${L} 0` : `${(s * L).toFixed(2)} ${((1 - s) * L).toFixed(2)}`);
96
+ c.setAttribute('stroke-dashoffset', (-tail * L).toFixed(2));
97
+ raf = requestAnimationFrame(frame);
98
+ };
99
+ raf = requestAnimationFrame(frame);
100
+ return () => cancelAnimationFrame(raf);
101
+ }, [speed]);
102
+
103
+ return (
104
+ <svg
105
+ role="img"
106
+ aria-label={label}
107
+ viewBox="44 92 202 106"
108
+ width={size}
109
+ height={Number(((size * 106) / 202).toFixed(1))}
110
+ className={className}
111
+ style={{ display: 'block', overflow: 'visible' }}
112
+ >
113
+ <defs>
114
+ <mask id={maskId} maskUnits="userSpaceOnUse" x="30" y="80" width="230" height="130">
115
+ <path ref={stroke} d={CENTRE} fill="none" stroke="#fff" strokeWidth="21" strokeLinecap="round" strokeLinejoin="round" />
116
+ </mask>
117
+ </defs>
118
+ {track && <path d={LOOP.paths[0]} fill={faint} fillOpacity={tone === 'current' ? 0.12 : undefined} />}
119
+ <path d={LOOP.paths[0]} fill={fill} mask={`url(#${maskId})`} />
120
+ </svg>
121
+ );
122
+ }
@@ -0,0 +1,129 @@
1
+ import * as DropdownMenu from '@radix-ui/react-dropdown-menu';
2
+ import type { ComponentProps, ReactNode } from 'react';
3
+
4
+ import { cn } from '../lib/cn';
5
+
6
+ export type BreadcrumbItem = {
7
+ label: ReactNode;
8
+ /** A link; or use onSelect for in-app navigation. */
9
+ href?: string | undefined;
10
+ onSelect?: (() => void) | undefined;
11
+ /** A trailing tag on the current page: its format (PDF). */
12
+ tag?: ReactNode;
13
+ };
14
+
15
+ export type BreadcrumbProps = Omit<ComponentProps<'nav'>, 'children'> & {
16
+ items: BreadcrumbItem[];
17
+ /** What starts the trail — the workspace brand (on a customer domain it replaces the platform name). */
18
+ root?: ReactNode;
19
+ /** Beyond this many segments the middle folds into a menu. @default 4 */
20
+ max?: number | undefined;
21
+ /** @default "Breadcrumb" */
22
+ label?: string | undefined;
23
+ /** Accessible name of the fold button. @default "Show hidden folders" */
24
+ foldLabel?: string | undefined;
25
+ /** A line under the trail: the current item's status. */
26
+ status?: ReactNode;
27
+ };
28
+
29
+ const linkClass = 'cursor-pointer text-fg-secondary outline-none hover:text-fg hover:underline underline-offset-2 focus-visible:shadow-(--focus-ring) rounded-xs';
30
+
31
+ function Crumb({ item }: { item: BreadcrumbItem }) {
32
+ if (item.href)
33
+ return (
34
+ <a href={item.href} onClick={item.onSelect} className={linkClass}>
35
+ {item.label}
36
+ </a>
37
+ );
38
+ if (item.onSelect)
39
+ return (
40
+ <button type="button" onClick={item.onSelect} className={linkClass}>
41
+ {item.label}
42
+ </button>
43
+ );
44
+ return <span className="text-fg-secondary">{item.label}</span>;
45
+ }
46
+
47
+ const Sep = () => (
48
+ <span aria-hidden className="text-fg-secondary">
49
+ /
50
+ </span>
51
+ );
52
+
53
+ /**
54
+ * Breadcrumb — where this page sits. The current page is the last segment
55
+ * and not a link; beyond four segments the middle folds into a menu. On a
56
+ * customer domain the trail starts at the workspace brand (root).
57
+ */
58
+ export function Breadcrumb({ items, root, max = 4, label = 'Breadcrumb', foldLabel = 'Show hidden folders', status, className, ...rest }: BreadcrumbProps) {
59
+ const last = items[items.length - 1];
60
+ const before = items.slice(0, -1);
61
+ const total = items.length + (root ? 1 : 0);
62
+ const fold = total > max && before.length > 1;
63
+ // Folded: [root or the first ancestor] / … / parent / current.
64
+ const head = fold ? (root ? [] : before.slice(0, 1)) : before;
65
+ const folded = fold ? before.slice(root ? 0 : 1, -1) : [];
66
+ const tail = fold ? before.slice(-1) : [];
67
+ const segment = 'flex items-center gap-2';
68
+
69
+ return (
70
+ <nav aria-label={label} className={cn('flex flex-col gap-1.5', className)} {...rest}>
71
+ <ol className="m-0 flex list-none flex-wrap items-center gap-x-2 gap-y-1 p-0 text-ui">
72
+ {root && (
73
+ <li className={segment}>
74
+ {root}
75
+ {items.length > 0 && <Sep />}
76
+ </li>
77
+ )}
78
+ {head.map((it, i) => (
79
+ <li key={`h${i}`} className={segment}>
80
+ <Crumb item={it} />
81
+ <Sep />
82
+ </li>
83
+ ))}
84
+ {folded.length > 0 && (
85
+ <li className={segment}>
86
+ <DropdownMenu.Root>
87
+ <DropdownMenu.Trigger
88
+ aria-label={foldLabel}
89
+ className="inline-grid h-6 min-w-6 cursor-pointer place-items-center rounded-sm bg-sunk px-1.5 text-meta font-semibold text-fg outline-none hover:bg-well focus-visible:shadow-(--focus-ring)"
90
+ >
91
+ …
92
+ </DropdownMenu.Trigger>
93
+ <DropdownMenu.Portal>
94
+ <DropdownMenu.Content align="start" sideOffset={6} className="z-50 min-w-[180px] rounded-lg border border-rule bg-page p-1.5 text-ui text-fg">
95
+ {folded.map((it, i) => (
96
+ <DropdownMenu.Item
97
+ key={i}
98
+ onSelect={() => {
99
+ if (it.href) window.location.assign(it.href);
100
+ else it.onSelect?.();
101
+ }}
102
+ className="flex h-8 cursor-pointer items-center rounded-md px-2.5 outline-none data-[highlighted]:bg-hover"
103
+ >
104
+ {it.label}
105
+ </DropdownMenu.Item>
106
+ ))}
107
+ </DropdownMenu.Content>
108
+ </DropdownMenu.Portal>
109
+ </DropdownMenu.Root>
110
+ <Sep />
111
+ </li>
112
+ )}
113
+ {tail.map((it, i) => (
114
+ <li key={`t${i}`} className={segment}>
115
+ <Crumb item={it} />
116
+ <Sep />
117
+ </li>
118
+ ))}
119
+ {last && (
120
+ <li aria-current="page" className="flex items-center gap-2 font-medium text-fg">
121
+ {last.label}
122
+ {last.tag && <span className="rounded-sm bg-sunk px-1.5 py-0.5 text-[11px] font-semibold text-fg">{last.tag}</span>}
123
+ </li>
124
+ )}
125
+ </ol>
126
+ {status && <div className="text-meta text-fg-secondary">{status}</div>}
127
+ </nav>
128
+ );
129
+ }
@@ -1,7 +1,7 @@
1
1
  import { Slot } from '@radix-ui/react-slot';
2
2
  import { cva } from 'class-variance-authority';
3
3
  import { Check } from 'lucide-react';
4
- import { useLayoutEffect, useRef, useState, type ComponentProps, type ReactNode } from 'react';
4
+ import type { ComponentProps, ReactNode } from 'react';
5
5
 
6
6
  import { cn } from '../lib/cn';
7
7
  import { Spinner } from './Spinner';
@@ -17,7 +17,9 @@ const button = cva(
17
17
  'rounded-md border border-transparent font-sans font-semibold leading-none',
18
18
  'transition-[background-color,border-color,color] duration-[120ms] ease-standard',
19
19
  'cursor-pointer outline-none focus-visible:shadow-(--focus-ring)',
20
- 'disabled:cursor-not-allowed disabled:opacity-40 aria-disabled:cursor-not-allowed aria-disabled:opacity-40',
20
+ // Disabled sinks: the well with grey text (3.0 readme and design.cosx.co;
21
+ // the 3.0 Button source used opacity). "!" so hover never lifts it back.
22
+ 'disabled:cursor-not-allowed aria-disabled:cursor-not-allowed',
21
23
  ],
22
24
  {
23
25
  variants: {
@@ -45,6 +47,17 @@ const button = cva(
45
47
  // Focus ring follows the ground.
46
48
  { ground: 'yellow', class: 'focus-visible:shadow-[0_0_0_3px_var(--yellow),0_0_0_5px_var(--ink)]' },
47
49
  { ground: 'ink', class: 'focus-visible:shadow-[0_0_0_3px_var(--ink),0_0_0_5px_var(--yellow)]' },
50
+ // Disabled: filled types sink into the well; outlined and text-only turn grey.
51
+ {
52
+ variant: ['primary', 'yellow', 'ink', 'danger'],
53
+ class:
54
+ 'disabled:border-transparent! disabled:bg-well! disabled:text-fg-secondary! aria-disabled:border-transparent! aria-disabled:bg-well! aria-disabled:text-fg-secondary!',
55
+ },
56
+ {
57
+ variant: ['secondary', 'ghost'],
58
+ class:
59
+ 'disabled:bg-transparent! disabled:text-fg-secondary! aria-disabled:bg-transparent! aria-disabled:text-fg-secondary! disabled:hover:border-[rgba(17,17,17,.18)] aria-disabled:hover:border-[rgba(17,17,17,.18)]',
60
+ },
48
61
  ],
49
62
  defaultVariants: { size: 'md', variant: 'primary', ground: 'auto' },
50
63
  },
@@ -63,7 +76,7 @@ export type ButtonProps = Omit<ComponentProps<'button'>, 'disabled'> & {
63
76
  shortcut?: string | undefined;
64
77
  /** A count at the end (Comments 12). Hidden at zero, 99+ above 99. */
65
78
  count?: number | undefined;
66
- /** Async state: busy locks the width and shows a spinner; done confirms in place. See useButtonAction. */
79
+ /** Async state: busy shows a spinner, done confirms in place; the width never changes. See useButtonAction. */
67
80
  state?: ButtonState | undefined;
68
81
  /** Label shown while state="done". @default the children */
69
82
  doneLabel?: ReactNode;
@@ -106,15 +119,6 @@ export function Button({
106
119
  ref,
107
120
  ...rest
108
121
  }: ButtonProps) {
109
- const inner = useRef<HTMLButtonElement | null>(null);
110
- const [lockedWidth, setLockedWidth] = useState<number | null>(null);
111
-
112
- // Lock the idle width so busy / done never resize the button.
113
- useLayoutEffect(() => {
114
- if (state === 'idle') setLockedWidth(null);
115
- else if (lockedWidth === null && inner.current) setLockedWidth(inner.current.getBoundingClientRect().width);
116
- }, [state, lockedWidth]);
117
-
118
122
  const classes = cn(button({ variant, size, ground: ground ?? 'auto' }), className);
119
123
  if (asChild) {
120
124
  return (
@@ -125,23 +129,22 @@ export function Button({
125
129
  }
126
130
 
127
131
  const busy = state === 'busy';
132
+ // Busy and done keep the idle content in place (hidden) and draw on top of
133
+ // it, so the button never resizes — also when it first renders busy.
134
+ const overlaid = state !== 'idle';
128
135
  const blocked = Boolean(disabledReason) && disabled;
129
136
  const shownCount = count === undefined ? null : formatCount(count);
130
- const setRefs = (el: HTMLButtonElement | null) => {
131
- inner.current = el;
132
- if (typeof ref === 'function') ref(el);
133
- else if (ref) ref.current = el;
134
- };
135
-
136
137
  return (
137
138
  <button
138
139
  type="button"
139
140
  {...rest}
140
- ref={setRefs}
141
+ ref={ref}
141
142
  className={classes}
142
- style={lockedWidth ? { ...style, width: lockedWidth } : style}
143
+ style={style}
143
144
  disabled={disabled && !disabledReason ? true : undefined}
144
- aria-disabled={blocked || busy ? true : undefined}
145
+ // Busy is not disabled (spec: "busy and disabled are different states"):
146
+ // it keeps its look; aria-busy says so and repeat clicks are ignored below.
147
+ aria-disabled={blocked ? true : undefined}
145
148
  aria-busy={busy || undefined}
146
149
  title={blocked ? disabledReason : rest.title}
147
150
  onClick={(e) => {
@@ -152,27 +155,32 @@ export function Button({
152
155
  onClick?.(e);
153
156
  }}
154
157
  >
155
- {busy ? (
156
- <Spinner />
157
- ) : state === 'done' ? (
158
- <>
159
- <Check size={14} strokeWidth={2} aria-hidden />
160
- {doneLabel ?? children}
161
- </>
162
- ) : (
163
- <>
164
- {iconLeft}
165
- {children}
166
- {iconRight}
167
- {shownCount && <span className="font-medium tabular-nums opacity-70">{shownCount}</span>}
168
- {shortcut && (
169
- <kbd className="ml-0.5 font-sans text-[12px] font-medium opacity-60" aria-hidden>
170
- {shortcut}
171
- </kbd>
158
+ <span className={cn('contents', overlaid && 'invisible')} aria-hidden={overlaid || undefined} data-button-content="">
159
+ {iconLeft}
160
+ {children}
161
+ {iconRight}
162
+ {shownCount && <span className="font-medium tabular-nums opacity-70">{shownCount}</span>}
163
+ {shortcut && (
164
+ <kbd className="ml-0.5 font-sans text-[12px] font-medium opacity-60" aria-hidden>
165
+ {shortcut}
166
+ </kbd>
167
+ )}
168
+ </span>
169
+ {overlaid && (
170
+ <span className="absolute inset-0 flex items-center justify-center gap-2" data-button-overlay="">
171
+ {busy ? (
172
+ <>
173
+ <Spinner />
174
+ <span className="sr-only">{children}</span>
175
+ </>
176
+ ) : (
177
+ <>
178
+ <Check size={14} strokeWidth={2} aria-hidden />
179
+ {doneLabel ?? children}
180
+ </>
172
181
  )}
173
- </>
182
+ </span>
174
183
  )}
175
- {busy && <span className="sr-only">{children}</span>}
176
184
  </button>
177
185
  );
178
186
  }