@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,141 @@
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
+ import { Tooltip } from './Tooltip';
8
+
9
+ export type AppRailItem = {
10
+ key: string;
11
+ icon: LucideIcon;
12
+ /** The module's name: accessible name and tooltip. */
13
+ label: string;
14
+ /** A count on the corner; hidden at zero (and on a disabled item), 99+ above 99. */
15
+ count?: number | undefined;
16
+ /** A dot on the corner — something new, no number. A count wins. */
17
+ dot?: boolean | undefined;
18
+ /** Not available yet: 40% opacity, aria-disabled (still focusable); a press calls `onDisabledSelect`. */
19
+ disabled?: boolean | undefined;
20
+ /** The tooltip while disabled ("My tasks · Coming soon"); the caller words it. @default label */
21
+ disabledHint?: string | undefined;
22
+ };
23
+
24
+ export type AppRailProps = Omit<ComponentProps<'nav'>, 'onChange' | 'children'> & {
25
+ items: AppRailItem[];
26
+ /** The current module's key. */
27
+ value: string;
28
+ onChange: (key: string) => void;
29
+ /** A disabled item was pressed — say why (a toast: "Coming soon"). */
30
+ onDisabledSelect?: ((key: string) => void) | undefined;
31
+ /** The landmark's accessible name ("Modules"). */
32
+ label: string;
33
+ /** Top slot: the workspace, usually an AppRailSlot holding a 36px WorkspaceMark. */
34
+ workspace?: ReactNode;
35
+ /** Bottom slot: the account, usually an AppRailSlot shape="round" holding a 32px Avatar. */
36
+ account?: ReactNode;
37
+ };
38
+
39
+ /**
40
+ * AppRail — the 60px desktop module rail (Metaroom Agent): the workspace at
41
+ * the top, one 40×38 icon button per module, a flexible gap, the account at
42
+ * the bottom. The current module takes the workspace's field colour
43
+ * (--brand-field); modules not built yet stay visible at 40% with a tooltip
44
+ * that says so. Counts and dots use --brand-mark.
45
+ *
46
+ * Compose with: AppRailSlot + WorkspaceMark / Avatar for the slots, each
47
+ * usually a PopoverTrigger (workspace switcher, account card); BottomTabs is
48
+ * the same navigation on phones.
49
+ *
50
+ * A nav landmark of buttons; the current one has aria-current="page".
51
+ * Forwards ref to the <nav>; spreads `...rest` onto it.
52
+ */
53
+ export const AppRail = forwardRef<HTMLElement, AppRailProps>(function AppRail(
54
+ { items, value, onChange, onDisabledSelect, label, workspace, account, className, ...rest },
55
+ ref,
56
+ ) {
57
+ return (
58
+ <nav
59
+ ref={ref}
60
+ aria-label={label}
61
+ className={cn('flex w-[60px] shrink-0 flex-col items-center gap-1 border-r border-rule bg-sunk py-3.5 font-sans text-fg', className)}
62
+ {...rest}
63
+ >
64
+ {workspace && <div className="mb-3 flex">{workspace}</div>}
65
+ {items.map((it) => {
66
+ const on = it.key === value;
67
+ const count = it.disabled || it.count === undefined ? null : formatCount(it.count);
68
+ return (
69
+ <Tooltip key={it.key} side="right" content={it.disabled ? (it.disabledHint ?? it.label) : it.label}>
70
+ <button
71
+ type="button"
72
+ aria-label={count ? `${it.label} (${count})` : it.label}
73
+ aria-current={on ? 'page' : undefined}
74
+ aria-disabled={it.disabled || undefined}
75
+ onClick={() => (it.disabled ? onDisabledSelect?.(it.key) : onChange(it.key))}
76
+ className={cn(
77
+ 'relative grid h-[38px] w-10 shrink-0 cursor-pointer place-items-center rounded-md border-0 p-0 outline-none',
78
+ 'transition-colors duration-[120ms] ease-standard focus-visible:shadow-(--focus-ring)',
79
+ on ? 'bg-brand-field text-ink' : 'bg-transparent text-fg',
80
+ !on && !it.disabled && 'hover:bg-hover',
81
+ it.disabled && 'cursor-not-allowed opacity-40',
82
+ )}
83
+ >
84
+ <Icon icon={it.icon} size={17} />
85
+ {count ? (
86
+ <span
87
+ aria-hidden
88
+ className="absolute top-1 right-[5px] 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"
89
+ >
90
+ {count}
91
+ </span>
92
+ ) : (
93
+ it.dot && !it.disabled && <span aria-hidden data-dot="" className="absolute top-[7px] right-[9px] size-[7px] rounded-pill bg-brand-mark" />
94
+ )}
95
+ </button>
96
+ </Tooltip>
97
+ );
98
+ })}
99
+ <div className="flex-1" />
100
+ {account && <div className="flex">{account}</div>}
101
+ </nav>
102
+ );
103
+ });
104
+
105
+ export type AppRailSlotProps = Omit<ComponentProps<'button'>, 'aria-label'> & {
106
+ /** Accessible name ("Switch workspace", "Account"). */
107
+ label: string;
108
+ /** tile (the workspace, 9px corners) · round (the account). @default "tile" */
109
+ shape?: 'tile' | 'round' | undefined;
110
+ /** Its popover is open: an ink ring with a gap. Set from the popover's open state. */
111
+ active?: boolean | undefined;
112
+ };
113
+
114
+ /**
115
+ * AppRailSlot — the bare button around the rail's workspace mark or account
116
+ * avatar: no fill of its own, the focus ring, and the ink ring while its
117
+ * popover is open. Works as `PopoverTrigger asChild` (forwards ref and
118
+ * props).
119
+ */
120
+ export const AppRailSlot = forwardRef<HTMLButtonElement, AppRailSlotProps>(function AppRailSlot(
121
+ { label, shape = 'tile', active = false, className, children, ...rest },
122
+ ref,
123
+ ) {
124
+ return (
125
+ <button
126
+ ref={ref}
127
+ type="button"
128
+ aria-label={label}
129
+ title={label}
130
+ className={cn(
131
+ 'inline-flex shrink-0 cursor-pointer border-0 bg-transparent p-0 outline-none focus-visible:shadow-(--focus-ring) disabled:cursor-default',
132
+ shape === 'round' ? 'rounded-pill' : 'rounded-[9px]',
133
+ active && 'shadow-[0_0_0_2px_var(--bg-sunk),0_0_0_4px_var(--text-primary)]',
134
+ className,
135
+ )}
136
+ {...rest}
137
+ >
138
+ {children}
139
+ </button>
140
+ );
141
+ });
@@ -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,75 @@
1
+ import { forwardRef, useEffect, useRef, useState, type ComponentProps } from 'react';
2
+
3
+ import { cn } from '../lib/cn';
4
+ import { initialsOf } from './ActivityTimeline';
5
+
6
+ export type AvatarSize = 20 | 24 | 28 | 32 | 36 | 40 | 52;
7
+
8
+ // Initials size per avatar size (Metaroom Agent / Account: 22→9, 28→10,
9
+ // 32→11, 40→13, 52→16).
10
+ const TYPE: Record<AvatarSize, number> = { 20: 9, 24: 9, 28: 10, 32: 11, 36: 12, 40: 13, 52: 16 };
11
+
12
+ export type AvatarProps = Omit<ComponentProps<'span'>, 'children'> & {
13
+ /** The person's name: gives the initials (two Latin initials, or the first hanzi). */
14
+ name: string;
15
+ /** Their photo; the initials show while it is unset or when it fails to load. */
16
+ src?: string | undefined;
17
+ /** Initials to use instead of ones taken from `name`. */
18
+ initials?: string | undefined;
19
+ /** px. @default 32 */
20
+ size?: AvatarSize | undefined;
21
+ /** chrome (a grey disc) · brand (the workspace's field colour, for "you"). @default "chrome" */
22
+ tone?: 'chrome' | 'brand' | undefined;
23
+ /** An ink ring with a gap — the avatar's menu is open, or it is the current tab.
24
+ * The gap takes `--avatar-ring-gap` (default the page colour); set it to the ground behind. */
25
+ ring?: boolean | undefined;
26
+ /** Accessible name. Without it the avatar is decorative: the name sits beside it. */
27
+ label?: string | undefined;
28
+ };
29
+
30
+ /**
31
+ * Avatar — a person: their photo, or their initials on a round disc. Sizes
32
+ * 20–52 as the Metaroom app uses them (rail account 32, phone tab 24, lists
33
+ * 28–40, profile 52). For the Agent use MetaAvatar (@cosxai/chat); for a
34
+ * workspace, WorkspaceMark.
35
+ *
36
+ * Compose with: AppRail `account` slot (inside a button), BottomTabs item
37
+ * `avatar`, list rows beside the person's name (decorative there).
38
+ *
39
+ * Forwards ref to the root <span>; spreads `...rest` onto it.
40
+ */
41
+ export const Avatar = forwardRef<HTMLSpanElement, AvatarProps>(function Avatar(
42
+ { name, src, initials, size = 32, tone = 'chrome', ring = false, label, className, style, ...rest },
43
+ ref,
44
+ ) {
45
+ const [failed, setFailed] = useState(false);
46
+ const img = useRef<HTMLImageElement>(null);
47
+ useEffect(() => setFailed(false), [src]);
48
+ // A server-rendered page can fail the image before React is listening.
49
+ useEffect(() => {
50
+ const el = img.current;
51
+ if (el?.complete && el.naturalWidth === 0) setFailed(true);
52
+ }, [src]);
53
+ const photo = Boolean(src) && !failed;
54
+ return (
55
+ <span
56
+ ref={ref}
57
+ {...(label ? { role: 'img', 'aria-label': label } : { 'aria-hidden': true })}
58
+ data-tone={tone}
59
+ className={cn(
60
+ 'inline-grid shrink-0 place-items-center overflow-hidden rounded-pill font-sans leading-none font-semibold select-none',
61
+ tone === 'brand' ? 'bg-brand-field text-ink' : 'bg-chrome text-fg',
62
+ ring && 'shadow-[0_0_0_2px_var(--avatar-ring-gap,var(--bg-page)),0_0_0_4px_var(--text-primary)]',
63
+ className,
64
+ )}
65
+ style={{ width: size, height: size, fontSize: TYPE[size] ?? Math.round(size * 0.32), ...style }}
66
+ {...rest}
67
+ >
68
+ {photo ? (
69
+ <img ref={img} src={src} alt="" className="size-full object-cover" onError={() => setFailed(true)} />
70
+ ) : (
71
+ (initials ?? initialsOf(name))
72
+ )}
73
+ </span>
74
+ );
75
+ });