@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.
- package/CHANGELOG.md +109 -0
- package/package.json +10 -4
- package/src/brand.css +1 -1
- package/src/components/ActionBar.tsx +607 -0
- package/src/components/ActivityTimeline.tsx +77 -0
- package/src/components/AppRail.tsx +141 -0
- package/src/components/AsyncSelect.tsx +205 -0
- package/src/components/AuthLayout.tsx +127 -0
- package/src/components/Avatar.tsx +75 -0
- package/src/components/BottomSheet.tsx +151 -0
- package/src/components/BottomTabs.tsx +102 -0
- package/src/components/BrandLoader.tsx +122 -0
- package/src/components/Breadcrumb.tsx +129 -0
- package/src/components/Button.tsx +48 -40
- package/src/components/ChoiceCards.tsx +68 -0
- package/src/components/CodeInput.tsx +150 -0
- package/src/components/CommandPalette.tsx +228 -0
- package/src/components/ConfirmDialog.tsx +294 -0
- package/src/components/CopyField.tsx +104 -0
- package/src/components/DateInput.tsx +366 -0
- package/src/components/Dialog.tsx +27 -3
- package/src/components/Dropzone.tsx +187 -0
- package/src/components/FileCard.tsx +233 -0
- package/src/components/FileRow.tsx +159 -0
- package/src/components/FolderTree.tsx +377 -0
- package/src/components/ListToolbar.tsx +177 -0
- package/src/components/MentionInput.tsx +233 -0
- package/src/components/Menu.tsx +109 -0
- package/src/components/PageHeader.tsx +62 -0
- package/src/components/PageState.tsx +84 -0
- package/src/components/Popover.tsx +46 -0
- package/src/components/Progress.tsx +97 -0
- package/src/components/PulseDot.tsx +31 -0
- package/src/components/RecipientsInput.tsx +252 -0
- package/src/components/SearchField.tsx +137 -0
- package/src/components/SegmentedControl.tsx +95 -0
- package/src/components/SidePanel.tsx +130 -0
- package/src/components/Skeleton.tsx +73 -0
- package/src/components/StageProgress.tsx +51 -0
- package/src/components/Steps.tsx +58 -0
- package/src/components/SyncStatus.tsx +56 -0
- package/src/components/ThemeSwitch.tsx +99 -0
- package/src/components/Toast.tsx +45 -8
- package/src/components/UploadCheck.tsx +118 -0
- package/src/components/UploadList.tsx +176 -0
- package/src/components/VirtualList.tsx +90 -0
- package/src/components/WindowDrop.tsx +131 -0
- package/src/components/WorkspaceRow.tsx +80 -0
- package/src/components/inputs-aria.ts +5 -0
- package/src/components/inputs-date.ts +92 -0
- package/src/components/inputs-listbox.tsx +154 -0
- package/src/components/inputs-search.ts +60 -0
- package/src/components/status-files.ts +124 -0
- package/src/components/status-motion.ts +30 -0
- package/src/components/useSelection.ts +142 -0
- package/src/index.ts +52 -1
- package/src/theme-base.css +123 -0
- package/src/theme-reset.css +14 -0
- package/src/theme.css +2 -125
- package/src/components/core.test.tsx +0 -242
- package/src/components/forms.test.tsx +0 -280
- 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
|
+
});
|