@cosxai/ui 1.0.0-alpha.0 → 1.0.0-alpha.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/CHANGELOG.md +109 -0
  2. package/package.json +36 -6
  3. package/src/brand.css +13 -0
  4. package/src/components/ActionBar.tsx +607 -0
  5. package/src/components/ActivityTimeline.tsx +77 -0
  6. package/src/components/AsyncSelect.tsx +205 -0
  7. package/src/components/AuthLayout.tsx +127 -0
  8. package/src/components/Badge.tsx +68 -0
  9. package/src/components/BrandLoader.tsx +122 -0
  10. package/src/components/Breadcrumb.tsx +129 -0
  11. package/src/components/Button.tsx +186 -0
  12. package/src/components/Card.tsx +42 -0
  13. package/src/components/Checkbox.tsx +67 -0
  14. package/src/components/ChoiceCards.tsx +68 -0
  15. package/src/components/CodeInput.tsx +150 -0
  16. package/src/components/CommandPalette.tsx +228 -0
  17. package/src/components/ConfirmDialog.tsx +294 -0
  18. package/src/components/CopyField.tsx +104 -0
  19. package/src/components/DateInput.tsx +366 -0
  20. package/src/components/Dialog.tsx +135 -0
  21. package/src/components/Dropzone.tsx +187 -0
  22. package/src/components/Field.tsx +66 -0
  23. package/src/components/Figure.tsx +30 -0
  24. package/src/components/FileCard.tsx +233 -0
  25. package/src/components/FileRow.tsx +159 -0
  26. package/src/components/FolderTree.tsx +377 -0
  27. package/src/components/Icon.tsx +32 -0
  28. package/src/components/IconButton.tsx +55 -0
  29. package/src/components/Input.tsx +74 -0
  30. package/src/components/ListToolbar.tsx +177 -0
  31. package/src/components/Logo.tsx +62 -0
  32. package/src/components/Marker.tsx +26 -0
  33. package/src/components/MentionInput.tsx +233 -0
  34. package/src/components/Menu.tsx +109 -0
  35. package/src/components/MetaLabel.tsx +38 -0
  36. package/src/components/PageHeader.tsx +62 -0
  37. package/src/components/PageState.tsx +84 -0
  38. package/src/components/Progress.tsx +97 -0
  39. package/src/components/PulseDot.tsx +31 -0
  40. package/src/components/Radio.tsx +72 -0
  41. package/src/components/RecipientsInput.tsx +252 -0
  42. package/src/components/SearchField.tsx +137 -0
  43. package/src/components/SegmentedControl.tsx +95 -0
  44. package/src/components/Select.tsx +351 -0
  45. package/src/components/SidePanel.tsx +130 -0
  46. package/src/components/Skeleton.tsx +73 -0
  47. package/src/components/Spinner.tsx +20 -0
  48. package/src/components/StageProgress.tsx +51 -0
  49. package/src/components/Steps.tsx +58 -0
  50. package/src/components/Switch.tsx +67 -0
  51. package/src/components/SyncStatus.tsx +56 -0
  52. package/src/components/Table.tsx +196 -0
  53. package/src/components/Tabs.tsx +124 -0
  54. package/src/components/Tag.tsx +35 -0
  55. package/src/components/Textarea.tsx +46 -0
  56. package/src/components/ThemeProvider.tsx +80 -0
  57. package/src/components/ThemeSwitch.tsx +99 -0
  58. package/src/components/Toast.tsx +196 -0
  59. package/src/components/Tooltip.tsx +57 -0
  60. package/src/components/UploadCheck.tsx +118 -0
  61. package/src/components/UploadList.tsx +176 -0
  62. package/src/components/VirtualList.tsx +90 -0
  63. package/src/components/WindowDrop.tsx +131 -0
  64. package/src/components/WorkspaceRow.tsx +80 -0
  65. package/src/components/forms-control.ts +36 -0
  66. package/src/components/inputs-aria.ts +5 -0
  67. package/src/components/inputs-date.ts +92 -0
  68. package/src/components/inputs-listbox.tsx +154 -0
  69. package/src/components/inputs-search.ts +60 -0
  70. package/src/components/logo-art.ts +4 -0
  71. package/src/components/status-files.ts +124 -0
  72. package/src/components/status-motion.ts +30 -0
  73. package/src/components/useButtonAction.ts +57 -0
  74. package/src/components/useSelection.ts +142 -0
  75. package/src/index.ts +73 -2
  76. package/src/modes.css +10 -0
  77. package/src/styles.css +2 -0
  78. package/src/theme-base.css +123 -0
  79. package/src/theme-reset.css +14 -0
  80. package/src/theme.css +2 -114
@@ -0,0 +1,205 @@
1
+ import * as Popover from '@radix-ui/react-popover';
2
+ import { Search, X } from 'lucide-react';
3
+ import { useId, useRef, useState, type KeyboardEvent } from 'react';
4
+
5
+ import { cn } from '../lib/cn';
6
+ import { useField } from './Field';
7
+ import { controlFrame, controlInner } from './forms-control';
8
+ import { Avatar, DEFAULT_LABELS, ListboxBody, type ListboxLabels, type ListItem } from './inputs-listbox';
9
+ import { useDebouncedSearch } from './inputs-search';
10
+ import { Spinner } from './Spinner';
11
+
12
+ export type AsyncSelectProps = {
13
+ /** Look up matches; the signal aborts when newer input arrives. */
14
+ search: (query: string, signal: AbortSignal) => Promise<ListItem[]>;
15
+ value?: ListItem | null | undefined;
16
+ onChange?: ((item: ListItem | null) => void) | undefined;
17
+ /** Offer “Create …” for text that matches nothing exactly. */
18
+ onCreate?: ((query: string) => void | ListItem | Promise<ListItem | void>) | undefined;
19
+ /** @default "Search" */
20
+ placeholder?: string | undefined;
21
+ /** ms. @default 250 */
22
+ debounce?: number | undefined;
23
+ labels?: Partial<ListboxLabels> & { clear?: string | undefined };
24
+ invalid?: boolean | undefined;
25
+ disabled?: boolean | undefined;
26
+ /** Start with this search typed and its results open. */
27
+ defaultQuery?: string | undefined;
28
+ size?: 'sm' | 'md' | 'lg' | undefined;
29
+ id?: string | undefined;
30
+ 'aria-label'?: string | undefined;
31
+ className?: string | undefined;
32
+ };
33
+
34
+ /**
35
+ * AsyncSelect — choose one from a large or remote set (a customer, a
36
+ * project). Typing searches after a short pause and drops stale answers;
37
+ * a spinner shows until the answer comes, then results with their meta, a
38
+ * Create row, “nothing matches”, or an error with Retry. The choice
39
+ * becomes a card that clears with ×.
40
+ */
41
+ export function AsyncSelect({
42
+ search,
43
+ value,
44
+ onChange,
45
+ onCreate,
46
+ placeholder = 'Search',
47
+ debounce = 250,
48
+ labels: labelsIn,
49
+ invalid,
50
+ disabled,
51
+ defaultQuery = '',
52
+ size,
53
+ id,
54
+ className,
55
+ ...aria
56
+ }: AsyncSelectProps) {
57
+ const field = useField();
58
+ const auto = useId();
59
+ const listId = `as${auto}-list`;
60
+ const optionId = (i: number) => `as${auto}-o${i}`;
61
+ const labels = { ...DEFAULT_LABELS, ...labelsIn };
62
+ const [own, setOwn] = useState<ListItem | null>(null);
63
+ const chosen = value === undefined ? own : value;
64
+ const [query, setQuery] = useState(defaultQuery);
65
+ const [open, setOpen] = useState(defaultQuery !== '');
66
+ const [active, setActive] = useState(0);
67
+ const input = useRef<HTMLInputElement | null>(null);
68
+ const { status, result, retry } = useDebouncedSearch(query, search, { delay: debounce });
69
+ const items = status === 'done' ? (result ?? []) : [];
70
+ const q = query.trim();
71
+ const canCreate = Boolean(onCreate) && q.length > 0 && !items.some((i) => i.label.toLowerCase() === q.toLowerCase());
72
+ const count = items.length + (canCreate ? 1 : 0);
73
+ const bad = Boolean(invalid || field?.invalid);
74
+ const off = Boolean(disabled || field?.disabled);
75
+
76
+ const pick = (item: ListItem | null) => {
77
+ if (value === undefined) setOwn(item);
78
+ onChange?.(item);
79
+ setQuery('');
80
+ setOpen(false);
81
+ };
82
+ const choose = async (i: number) => {
83
+ if (i < items.length) return pick(items[i]!);
84
+ if (i === items.length && canCreate) {
85
+ const made = await onCreate!(q);
86
+ if (made && typeof made === 'object') pick(made);
87
+ else {
88
+ setQuery('');
89
+ setOpen(false);
90
+ }
91
+ }
92
+ };
93
+ const onKey = (e: KeyboardEvent<HTMLInputElement>) => {
94
+ if (e.key === 'ArrowDown') {
95
+ e.preventDefault();
96
+ setOpen(true);
97
+ setActive((a) => Math.min(count - 1, a + 1));
98
+ } else if (e.key === 'ArrowUp') {
99
+ e.preventDefault();
100
+ setActive((a) => Math.max(0, a - 1));
101
+ } else if (e.key === 'Enter' && open && count > 0) {
102
+ e.preventDefault();
103
+ void choose(active);
104
+ } else if (e.key === 'Escape') {
105
+ setOpen(false);
106
+ }
107
+ };
108
+
109
+ if (chosen) {
110
+ return (
111
+ <div
112
+ className={cn(
113
+ 'flex min-h-[38px] items-center gap-2.5 rounded-md border bg-sunk px-2 py-1.5 font-sans text-ui text-fg',
114
+ bad ? 'border-error' : 'border-rule-soft',
115
+ off && 'bg-well text-fg-secondary',
116
+ className,
117
+ )}
118
+ >
119
+ <Avatar item={chosen} className="size-8 rounded-sm bg-ink text-linen ink:bg-yellow-accent ink:text-ink" />
120
+ <span className="flex min-w-0 flex-1 flex-col">
121
+ <span id={id ?? field?.id} className="truncate font-medium">
122
+ {chosen.label}
123
+ </span>
124
+ {chosen.meta && <span className="truncate text-meta text-fg-secondary">{chosen.meta}</span>}
125
+ </span>
126
+ {!off && (
127
+ <button
128
+ type="button"
129
+ aria-label={`${labelsIn?.clear ?? 'Clear'} ${chosen.label}`}
130
+ onClick={() => {
131
+ pick(null);
132
+ requestAnimationFrame(() => input.current?.focus());
133
+ }}
134
+ className="inline-grid shrink-0 cursor-pointer place-items-center rounded-xs p-0.5 text-fg-secondary outline-none hover:text-fg focus-visible:shadow-(--focus-ring)"
135
+ >
136
+ <X size={13} aria-hidden />
137
+ </button>
138
+ )}
139
+ </div>
140
+ );
141
+ }
142
+
143
+ const showList = open && q.length > 0;
144
+ return (
145
+ <Popover.Root open={showList} onOpenChange={setOpen}>
146
+ <Popover.Anchor asChild>
147
+ <div className={cn(controlFrame({ size, invalid: bad, disabled: off }), className)}>
148
+ <Search size={14} aria-hidden className="shrink-0 text-fg-secondary" />
149
+ <input
150
+ ref={input}
151
+ id={id ?? field?.id}
152
+ role="combobox"
153
+ aria-expanded={showList}
154
+ aria-controls={showList ? listId : undefined}
155
+ aria-autocomplete="list"
156
+ aria-activedescendant={showList && status === 'done' && count > 0 ? optionId(active) : undefined}
157
+ aria-invalid={bad || undefined}
158
+ aria-describedby={field?.describedBy}
159
+ aria-label={aria['aria-label']}
160
+ disabled={off}
161
+ value={query}
162
+ placeholder={placeholder}
163
+ autoComplete="off"
164
+ onChange={(e) => {
165
+ setQuery(e.target.value);
166
+ setActive(0);
167
+ setOpen(true);
168
+ }}
169
+ onFocus={() => q && setOpen(true)}
170
+ onKeyDown={onKey}
171
+ className={controlInner}
172
+ />
173
+ {status === 'searching' && <Spinner size={12} className="text-fg-secondary" />}
174
+ </div>
175
+ </Popover.Anchor>
176
+ <Popover.Portal>
177
+ <Popover.Content
178
+ align="start"
179
+ sideOffset={6}
180
+ collisionPadding={12}
181
+ onOpenAutoFocus={(e) => e.preventDefault()}
182
+ onInteractOutside={(e) => {
183
+ if (e.target instanceof Node && input.current?.parentElement?.contains(e.target)) e.preventDefault();
184
+ }}
185
+ className="rise z-60 rounded-lg border border-rule bg-page p-1 font-sans [--motion-reveal:200ms]"
186
+ style={{ width: 'var(--radix-popover-trigger-width)' }}
187
+ >
188
+ <ListboxBody
189
+ id={listId}
190
+ optionId={optionId}
191
+ items={items}
192
+ active={active}
193
+ status={status}
194
+ query={q}
195
+ canCreate={canCreate}
196
+ labels={labels}
197
+ onActive={setActive}
198
+ onChoose={(i) => void choose(i)}
199
+ onRetry={retry}
200
+ />
201
+ </Popover.Content>
202
+ </Popover.Portal>
203
+ </Popover.Root>
204
+ );
205
+ }
@@ -0,0 +1,127 @@
1
+ import type { ComponentProps, ReactNode } from 'react';
2
+
3
+ import { cn } from '../lib/cn';
4
+ import { Logo } from './Logo';
5
+
6
+ export type AuthLayoutProps = Omit<ComponentProps<'div'>, 'children'> & {
7
+ /** Top left: the product lockup (a Logo tile and the product name). */
8
+ brand: ReactNode;
9
+ /** Top right: usually a ThemeSwitch. */
10
+ headerEnd?: ReactNode;
11
+ /** The step, centred in a 400px column. */
12
+ children: ReactNode;
13
+ /** One line under the step: who to ask when stuck. */
14
+ help?: ReactNode;
15
+ /** The foot: links on the left, the language on the right. */
16
+ footer?: ReactNode;
17
+ /** The yellow panel's text (an AuthPanel). Dropped below 900px. */
18
+ panel?: ReactNode;
19
+ };
20
+
21
+ /**
22
+ * AuthLayout — the sign-in page (Metaroom sign-in, design.cosx.co): the step
23
+ * on the left under the product lockup, the yellow panel on the right saying
24
+ * what this step is for. The panel drops when the layout is narrower than
25
+ * 900px (a container query, so a narrow window or pane works too).
26
+ */
27
+ export function AuthLayout({ brand, headerEnd, children, help, footer, panel, className, ...rest }: AuthLayoutProps) {
28
+ return (
29
+ <div className={cn('@container flex h-full w-full font-sans text-fg', className)} {...rest}>
30
+ <div className="flex min-w-0 flex-1 flex-col px-12 pt-12 pb-8">
31
+ <div className="flex items-center justify-between gap-4">
32
+ <div className="flex items-center gap-3 text-[15px] font-medium">{brand}</div>
33
+ {headerEnd}
34
+ </div>
35
+ <main className="flex flex-1 items-center py-8">
36
+ <div className="mx-auto flex w-full max-w-[400px] flex-col gap-8">
37
+ {children}
38
+ {help && <p className="m-0 text-[12px] leading-[1.6] text-fg-secondary">{help}</p>}
39
+ </div>
40
+ </main>
41
+ {footer && <div className="flex flex-wrap items-center justify-between gap-x-4 gap-y-2 text-[12px] text-fg-secondary">{footer}</div>}
42
+ </div>
43
+ {panel && (
44
+ <aside className="hidden w-[min(560px,45%)] shrink-0 p-6 @min-[900px]:flex">
45
+ <div className="flex flex-1 flex-col justify-between gap-8 overflow-hidden rounded-[24px] bg-brand-field p-14 text-ink">{panel}</div>
46
+ </aside>
47
+ )}
48
+ </div>
49
+ );
50
+ }
51
+
52
+ export type AuthPanelProps = {
53
+ /** Small line above: "Your workspaces", "Security". */
54
+ eyebrow: ReactNode;
55
+ /** The statement's plain start: "Every workspace you belong to, ". */
56
+ lead: ReactNode;
57
+ /** Its highlighted end: "under one sign-in." */
58
+ mark: ReactNode;
59
+ /** One sentence under it. */
60
+ sub?: ReactNode;
61
+ /** Top of the panel. @default the COSX wordmark */
62
+ logo?: ReactNode;
63
+ };
64
+
65
+ /** AuthPanel — the yellow panel's text: wordmark on top, the statement at the foot. */
66
+ export function AuthPanel({ eyebrow, lead, mark, sub, logo }: AuthPanelProps) {
67
+ return (
68
+ <>
69
+ {logo ?? <Logo height={22} tone="ink" />}
70
+ <div className="flex flex-col gap-4">
71
+ <span className="text-[13px] font-medium text-[rgba(17,17,17,.7)]">{eyebrow}</span>
72
+ {/* Chinese sets at 1.35, untracked, its band starting lower (design: Patterns · Sign-in).
73
+ Important: typography-cjk.css sets every :lang(zh) line-height outside the layers. */}
74
+ <p className="m-0 text-[34px] leading-[1.22] font-medium tracking-[-.02em] text-pretty [&:lang(zh)]:leading-[1.35]! [&:lang(zh)]:tracking-normal!">
75
+ {lead}
76
+ <span className="mx-[-.08em] box-decoration-clone px-[.08em] bg-[linear-gradient(transparent_40%,var(--brand-mark)_40%,var(--brand-mark)_94%,transparent_94%)] [&:lang(zh)]:bg-[linear-gradient(transparent_50%,var(--brand-mark)_50%,var(--brand-mark)_94%,transparent_94%)]">
77
+ {mark}
78
+ </span>
79
+ </p>
80
+ {sub && <p className="m-0 text-[15px] leading-[1.6] text-[rgba(17,17,17,.7)]">{sub}</p>}
81
+ </div>
82
+ </>
83
+ );
84
+ }
85
+
86
+ export type AuthStepHeadProps = {
87
+ /** An AuthIconTile, a Spinner, a workspace mark. */
88
+ icon?: ReactNode;
89
+ title: ReactNode;
90
+ children?: ReactNode;
91
+ };
92
+
93
+ /** AuthStepHead — a step's icon, title and one explanation. */
94
+ export function AuthStepHead({ icon, title, children }: AuthStepHeadProps) {
95
+ return (
96
+ <div className="flex flex-col gap-5">
97
+ {icon}
98
+ <div className="flex flex-col gap-2">
99
+ <h1 className="m-0 text-[30px] leading-[1.2] font-medium tracking-[-.015em]">{title}</h1>
100
+ {children && <p className="m-0 text-[15px] leading-[1.6] text-fg-secondary">{children}</p>}
101
+ </div>
102
+ </div>
103
+ );
104
+ }
105
+
106
+ export type AuthIconTileProps = {
107
+ children: ReactNode;
108
+ /** plain: a step; yellow: done; error: it didn't work. @default "plain" */
109
+ tone?: 'plain' | 'yellow' | 'error' | undefined;
110
+ };
111
+
112
+ /** AuthIconTile — the 56px tile holding a step's icon. */
113
+ export function AuthIconTile({ children, tone = 'plain' }: AuthIconTileProps) {
114
+ return (
115
+ <span
116
+ aria-hidden
117
+ className={cn(
118
+ 'grid size-14 place-items-center rounded-[14px]',
119
+ tone === 'plain' && 'bg-sunk text-fg',
120
+ tone === 'yellow' && 'bg-brand-field text-ink',
121
+ tone === 'error' && 'bg-error-wash text-error-text',
122
+ )}
123
+ >
124
+ {children}
125
+ </span>
126
+ );
127
+ }
@@ -0,0 +1,68 @@
1
+ import type { ComponentProps, ReactNode } from 'react';
2
+
3
+ import { cn } from '../lib/cn';
4
+
5
+ export type BadgeStatus = 'neutral' | 'attention' | 'error' | 'progress' | 'complete';
6
+ export type BadgeAppearance = 'fill' | 'outline' | 'dot';
7
+
8
+ const DEFAULT_APPEARANCE: Record<BadgeStatus, BadgeAppearance> = {
9
+ attention: 'fill',
10
+ error: 'fill',
11
+ progress: 'outline',
12
+ complete: 'dot',
13
+ neutral: 'dot',
14
+ };
15
+ const STATUSES = new Set<string>(Object.keys(DEFAULT_APPEARANCE));
16
+
17
+ export type BadgeProps = Omit<ComponentProps<'span'>, 'children'> & {
18
+ /** The state. Unknown values fall back to neutral. @default "neutral" */
19
+ status?: BadgeStatus | (string & {}) | undefined;
20
+ /** fill · outline · dot. Defaults by status; change it only to match density in a column. */
21
+ appearance?: BadgeAppearance | undefined;
22
+ /** Dot density for rows: only the dot shows; the wording stays for screen readers and appears on hover. */
23
+ compact?: boolean | undefined;
24
+ /** The wording — always present: name the state (Awaiting you), not the action. */
25
+ children: ReactNode;
26
+ };
27
+
28
+ /**
29
+ * Badge — product status in yellow, ink and one red. Shape carries urgency:
30
+ * a fill needs a person (attention: brand fill, ink text; error: the red),
31
+ * an outline is under way (progress), a dot is a known state (complete:
32
+ * ink dot, neutral: grey dot). Colour is never the only signal.
33
+ * Product UI only — never on brand, marketing or decks.
34
+ */
35
+ export function Badge({ status = 'neutral', appearance, compact = false, className, children, title, ...rest }: BadgeProps) {
36
+ const s = (STATUSES.has(status) ? status : 'neutral') as BadgeStatus;
37
+ const mode = compact ? 'dot' : (appearance ?? DEFAULT_APPEARANCE[s]);
38
+ const base = 'inline-flex items-center gap-[7px] whitespace-nowrap rounded-sm text-meta leading-none font-semibold';
39
+
40
+ if (mode === 'fill') {
41
+ const fill = s === 'error' ? 'bg-error text-white' : s === 'attention' ? 'bg-brand-mark text-ink' : 'bg-fg text-page';
42
+ return (
43
+ <span data-status={s} className={cn(base, 'px-2 py-[5px]', fill, className)} title={title} {...rest}>
44
+ {children}
45
+ </span>
46
+ );
47
+ }
48
+ if (mode === 'outline') {
49
+ return (
50
+ <span data-status={s} className={cn(base, 'border border-fg px-[7px] py-1 text-fg', className)} title={title} {...rest}>
51
+ {children}
52
+ </span>
53
+ );
54
+ }
55
+ const dot =
56
+ s === 'error' ? 'bg-error' : s === 'attention' ? 'bg-brand-mark' : s === 'complete' || s === 'progress' ? 'bg-fg' : 'bg-fg-secondary';
57
+ return (
58
+ <span
59
+ data-status={s}
60
+ className={cn(base, 'text-fg-secondary', className)}
61
+ title={compact ? (title ?? (typeof children === 'string' ? children : undefined)) : title}
62
+ {...rest}
63
+ >
64
+ <span aria-hidden className={cn('size-[9px] shrink-0 rounded-pill', dot)} />
65
+ <span className={compact ? 'sr-only' : undefined}>{children}</span>
66
+ </span>
67
+ );
68
+ }
@@ -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
+ }