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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,37 @@
1
+ ## 1.0.0-alpha.12 (2026-10-04)
2
+
3
+ From the Metaroom app (Motion & Native Feel §04, Account):
4
+
5
+ - BottomSheet: rises in 280ms and now leaves in 220ms instead of vanishing
6
+ (it stays mounted for its exit; the scrim fades with it and carries
7
+ `data-leaving` while it goes). `detents="two"`: half (60%) and full —
8
+ typing in it, scrolling its list or dragging up lifts it to full,
9
+ dragging down from full goes back to half; dragging above its top gives
10
+ way at 0.3×. `action` (the title row's button) and `footer` (pinned below
11
+ the list). The rise starts when Radix puts the content on the page, so it
12
+ always plays. Buttons and fields in the title row don't start a drag.
13
+ - Avatar: size 64 (Account's profile photo), initials at 20px.
14
+
15
+ ## 1.0.0-alpha.11 (2026-10-01)
16
+
17
+ App shell for the Agent module (Metaroom Agent, desktop and phone):
18
+
19
+ - Popover, PopoverTrigger, PopoverContent, PopoverAnchor, PopoverClose: a
20
+ small non-modal panel on Radix Popover — portal, flips to fit, Esc and
21
+ outside click close, focus returns. `inline` keeps it out of the portal.
22
+ - Avatar: a person's photo or initials, sizes 20–52, tone chrome / brand,
23
+ optional ring; decorative unless `label` is given.
24
+ - BottomSheet: the phone's sheet on Radix Dialog — drag handle, title, drag
25
+ or flick down to close, scrim, 20px top corners, safe-area padding, a
26
+ screen-reader close button.
27
+ - BottomTabs: the phone tab bar — 48×28 pill in the brand field for the
28
+ current tab, counts, avatar tab, disabled tabs at 40% (aria-disabled,
29
+ `onDisabledSelect`).
30
+ - AppRail + AppRailSlot: the 60px desktop rail — workspace slot, 40×38
31
+ module buttons (brand field when current), counts and dots, disabled
32
+ modules with the caller's tooltip, account slot. A nav of buttons with
33
+ aria-current.
34
+
1
35
  ## 1.0.0-alpha.10 (2026-09-30)
2
36
 
3
37
  - BrandLoader: the COSX loop as a loading indicator (Claude Design, COSX
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosxai/ui",
3
- "version": "1.0.0-alpha.10",
3
+ "version": "1.0.0-alpha.12",
4
4
  "description": "COSX Design System 3.0 \u2014 tokens, Tailwind v4 theme and React components",
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",
@@ -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,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 | 64;
7
+
8
+ // Initials size per avatar size (Metaroom Agent / Account: 22→9, 28→10,
9
+ // 32→11, 40→13, 52→16, Account's profile photo 64→20).
10
+ const TYPE: Record<AvatarSize, number> = { 20: 9, 24: 9, 28: 10, 32: 11, 36: 12, 40: 13, 52: 16, 64: 20 };
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–64 as the Metaroom app uses them (rail account 32, phone tab 24, lists
33
+ * 28–40, Me card 52, Account's profile photo 64). 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
+ });
@@ -0,0 +1,253 @@
1
+ import * as DialogPrimitive from '@radix-ui/react-dialog';
2
+ import { useCallback, useEffect, useLayoutEffect, 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
+ /** The main button, at the end of the title row (seen at half height too). */
15
+ action?: ReactNode;
16
+ /** Pinned below the scrolling content, above the home indicator (a Done or Add button). */
17
+ footer?: ReactNode;
18
+ /**
19
+ * "content": as tall as what it holds, up to half the screen — menus and
20
+ * confirmations. "two": half (60%) and full — browsing and picking;
21
+ * typing in it, scrolling its list or dragging it up lifts it to full,
22
+ * dragging down from full goes back to half. @default "content"
23
+ */
24
+ detents?: 'content' | 'two' | undefined;
25
+ children?: ReactNode;
26
+ open?: boolean | undefined;
27
+ defaultOpen?: boolean | undefined;
28
+ onOpenChange?: ((open: boolean) => void) | undefined;
29
+ /** The element that opens it (rendered asChild). Or control `open`. */
30
+ trigger?: ReactElement | undefined;
31
+ /** Accessible name of the screen-reader close button (touch readers have no Esc). @default "Close" */
32
+ closeLabel?: string | undefined;
33
+ };
34
+
35
+ // Motion & Native Feel §04: rises in 280ms, leaves in 220ms (a quarter faster).
36
+ const ENTER_MS = 280;
37
+ const EXIT_MS = 220;
38
+ const ENTER = 'cubic-bezier(.16,1,.3,1)';
39
+ const EXIT = 'cubic-bezier(.4,0,1,1)';
40
+ const RELEASE = 'cubic-bezier(.2,.8,.2,1)';
41
+ // A drag past this share of the sheet's height (at most CLOSE_MAX), or a flick, closes it.
42
+ const CLOSE_SHARE = 0.25;
43
+ const CLOSE_MAX = 120;
44
+ const FLICK = 0.6; // px per ms
45
+ // Dragged above its top, the sheet gives way at this rate.
46
+ const OVERSHOOT = 0.3;
47
+ const HALF = 0.6;
48
+
49
+ const currentY = (el: HTMLElement) => {
50
+ try {
51
+ return new DOMMatrixReadOnly(getComputedStyle(el).transform).m42 || 0;
52
+ } catch {
53
+ return 0;
54
+ }
55
+ };
56
+
57
+ /**
58
+ * BottomSheet — the phone's sheet: a page-coloured sheet rising from the
59
+ * bottom with a drag handle and a title (Metaroom Agent phone: switch
60
+ * workspace, language, an object's menu; link files, with two heights).
61
+ * 20px top corners over the scrim; the bottom padding clears the home
62
+ * indicator (safe area). Rises in 280ms and leaves in 220ms. Drag the
63
+ * handle or the title row down, or flick, to close (from full height: back
64
+ * to half); Esc, a tap on the scrim and the screen-reader close button
65
+ * close it too. Built on Radix Dialog: focus moves in, is trapped and
66
+ * returns to the opener.
67
+ *
68
+ * Compose with: BottomTabs (the Me tab opens it), rows of 48–56px buttons
69
+ * inside. On desktop use Popover or DialogContent instead.
70
+ */
71
+ export function BottomSheet({
72
+ title,
73
+ hideTitle = false,
74
+ description,
75
+ action,
76
+ footer,
77
+ detents = 'content',
78
+ children,
79
+ open: openProp,
80
+ defaultOpen = false,
81
+ onOpenChange,
82
+ trigger,
83
+ closeLabel = 'Close',
84
+ className,
85
+ style,
86
+ onOpenAutoFocus,
87
+ onFocusCapture,
88
+ ...rest
89
+ }: BottomSheetProps) {
90
+ const [openState, setOpenState] = useState(defaultOpen);
91
+ const open = openProp ?? openState;
92
+ const setOpen = useCallback(
93
+ (next: boolean) => {
94
+ if (openProp === undefined) setOpenState(next);
95
+ onOpenChange?.(next);
96
+ },
97
+ [openProp, onOpenChange],
98
+ );
99
+
100
+ const reduce = useReducedMotion();
101
+ // Mounted while open and while leaving (the exit plays before it goes)
102
+ const [mounted, setMounted] = useState(open);
103
+ const [full, setFull] = useState(false);
104
+ const sheet = useRef<HTMLDivElement | null>(null);
105
+ const scrim = useRef<HTMLDivElement | null>(null);
106
+ const drag = useRef<{ y0: number; t0: number } | null>(null);
107
+ const entering = useRef(open);
108
+
109
+ useLayoutEffect(() => {
110
+ if (open) {
111
+ entering.current = true;
112
+ setMounted(true);
113
+ setFull(false);
114
+ return;
115
+ }
116
+ if (!mounted) return;
117
+ const el = sheet.current;
118
+ if (!el || reduce || typeof el.animate !== 'function') {
119
+ setMounted(false);
120
+ return;
121
+ }
122
+ // The scrim's leaving is a CSS animation: listeners (a status bar
123
+ // following the page) hear it start, as with Dialog's
124
+ scrim.current?.setAttribute('data-leaving', '');
125
+ const from = currentY(el) || parseFloat(el.style.transform.replace(/[^\d.-]/g, '')) || 0;
126
+ el.style.transform = '';
127
+ el.animate([{ transform: `translateY(${from}px)` }, { transform: 'translateY(100%)' }], { duration: EXIT_MS, easing: EXIT, fill: 'forwards' }).onfinish = () => setMounted(false);
128
+ // eslint-disable-next-line react-hooks/exhaustive-deps
129
+ }, [open]);
130
+
131
+ // Radix puts the content on the page a render after ours: the rise starts when it arrives
132
+ const setSheet = (el: HTMLDivElement | null) => {
133
+ sheet.current = el;
134
+ if (!el || !entering.current) return;
135
+ entering.current = false;
136
+ if (reduce || typeof el.animate !== 'function') return;
137
+ el.animate([{ transform: 'translateY(100%)' }, { transform: 'translateY(0)' }], { duration: ENTER_MS, easing: ENTER });
138
+ };
139
+
140
+ useEffect(() => () => void (drag.current = null), []);
141
+
142
+ const height = () => sheet.current?.offsetHeight ?? 0;
143
+ const settle = () => {
144
+ const el = sheet.current;
145
+ if (!el) return;
146
+ const from = el.style.transform;
147
+ el.style.transform = '';
148
+ if (from && !reduce && typeof el.animate === 'function') {
149
+ el.animate([{ transform: from }, { transform: 'translateY(0)' }], { duration: 200, easing: RELEASE });
150
+ }
151
+ };
152
+ const onDown = (e: ReactPointerEvent<HTMLDivElement>) => {
153
+ if (e.button !== 0) return;
154
+ if ((e.target as HTMLElement).closest('button, a, input, textarea, select')) return;
155
+ drag.current = { y0: e.clientY, t0: performance.now() };
156
+ e.currentTarget.setPointerCapture?.(e.pointerId);
157
+ };
158
+ const onMove = (e: ReactPointerEvent<HTMLDivElement>) => {
159
+ const d = drag.current;
160
+ if (!d || !sheet.current) return;
161
+ const raw = e.clientY - d.y0;
162
+ const dy = raw < 0 ? raw * OVERSHOOT : raw;
163
+ sheet.current.style.transform = `translateY(${dy}px)`;
164
+ };
165
+ const onUp = (e: ReactPointerEvent<HTMLDivElement>) => {
166
+ const d = drag.current;
167
+ drag.current = null;
168
+ if (!d) return;
169
+ const dist = e.clientY - d.y0;
170
+ const speed = dist / Math.max(1, performance.now() - d.t0);
171
+ const h = height();
172
+ const limit = h > 0 ? Math.min(CLOSE_MAX, h * CLOSE_SHARE) : CLOSE_MAX;
173
+ const down = dist > limit || (dist > 8 && speed > FLICK);
174
+ if (down && detents === 'two' && full) {
175
+ setFull(false);
176
+ settle();
177
+ } else if (down) {
178
+ setOpen(false);
179
+ } else {
180
+ if (detents === 'two' && !full && (dist < -24 || speed < -FLICK)) setFull(true);
181
+ settle();
182
+ }
183
+ };
184
+
185
+ const two = detents === 'two';
186
+ return (
187
+ // Open while shown, leaving included; the trigger stays the same element throughout
188
+ <DialogPrimitive.Root open={mounted} onOpenChange={(o) => (o ? setOpen(true) : open && setOpen(false))}>
189
+ {trigger && <DialogPrimitive.Trigger asChild>{trigger}</DialogPrimitive.Trigger>}
190
+ {mounted && (
191
+ <DialogPrimitive.Portal>
192
+ <DialogPrimitive.Overlay ref={scrim} className="cosx-sheet-scrim fixed inset-0 z-50 bg-scrim" />
193
+ <DialogPrimitive.Content
194
+ ref={setSheet}
195
+ {...(description ? {} : { 'aria-describedby': undefined })}
196
+ onOpenAutoFocus={(e) => {
197
+ onOpenAutoFocus?.(e);
198
+ if (e.defaultPrevented) return;
199
+ // The sheet itself takes focus (read out by its title), not the first row.
200
+ e.preventDefault();
201
+ (e.currentTarget as HTMLElement | null)?.focus();
202
+ }}
203
+ // Typing lifts a two-height sheet to full (the keyboard needs the room)
204
+ onFocusCapture={(e) => {
205
+ onFocusCapture?.(e);
206
+ if (two && (e.target as HTMLElement).matches('input, textarea')) setFull(true);
207
+ }}
208
+ className={cn(
209
+ 'fixed inset-x-0 bottom-0 z-50 flex flex-col rounded-t-[20px] bg-page px-4 pt-2 font-sans text-fg outline-none',
210
+ two ? 'transition-[height] duration-300 ease-[cubic-bezier(.2,0,0,1)]' : 'max-h-[90dvh]',
211
+ className,
212
+ )}
213
+ style={{
214
+ paddingBottom: footer ? 'max(16px, calc(env(safe-area-inset-bottom, 0px) + 8px))' : 'max(30px, calc(env(safe-area-inset-bottom, 0px) + 12px))',
215
+ ...(two ? { height: full ? 'calc(100dvh - env(safe-area-inset-top, 0px) - 8px)' : `${HALF * 100}dvh` } : {}),
216
+ ...style,
217
+ }}
218
+ {...rest}
219
+ >
220
+ <div
221
+ data-sheet-drag=""
222
+ className="flex shrink-0 cursor-grab touch-none flex-col select-none active:cursor-grabbing"
223
+ onPointerDown={onDown}
224
+ onPointerMove={onMove}
225
+ onPointerUp={onUp}
226
+ onPointerCancel={() => {
227
+ drag.current = null;
228
+ settle();
229
+ }}
230
+ >
231
+ <span aria-hidden className="mb-2.5 h-1 w-9 self-center rounded-pill bg-rule" />
232
+ <div className="flex items-center gap-3">
233
+ <DialogPrimitive.Title className={cn('m-0 min-w-0 flex-1 truncate px-1 pb-2 text-[17px] leading-[1.3] font-medium', hideTitle && 'sr-only')}>{title}</DialogPrimitive.Title>
234
+ {action && <div className="shrink-0 pb-2">{action}</div>}
235
+ </div>
236
+ {description && <DialogPrimitive.Description className="m-0 px-1 pb-2 text-small text-fg-secondary">{description}</DialogPrimitive.Description>}
237
+ </div>
238
+ <div
239
+ className={cn('flex min-h-0 flex-1 flex-col gap-0.5', two && !full ? 'overflow-hidden' : 'overflow-y-auto overscroll-contain')}
240
+ // At half height, a pull up on the list lifts the sheet first
241
+ onWheel={(e) => two && !full && e.deltaY > 0 && setFull(true)}
242
+ onTouchMove={() => two && !full && setFull(true)}
243
+ >
244
+ {children}
245
+ </div>
246
+ {footer && <div className="shrink-0 border-t border-rule-soft pt-2.5">{footer}</div>}
247
+ <DialogPrimitive.Close className="sr-only">{closeLabel}</DialogPrimitive.Close>
248
+ </DialogPrimitive.Content>
249
+ </DialogPrimitive.Portal>
250
+ )}
251
+ </DialogPrimitive.Root>
252
+ );
253
+ }
@@ -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,46 @@
1
+ import * as PopoverPrimitive from '@radix-ui/react-popover';
2
+ import type { ComponentProps } from 'react';
3
+
4
+ import { cn } from '../lib/cn';
5
+
6
+ /** Popover root: `open` / `onOpenChange`, or uncontrolled with PopoverTrigger. */
7
+ export const Popover = PopoverPrimitive.Root;
8
+ /** The element that opens it (asChild onto a Button or IconButton). Focus returns here on close. */
9
+ export const PopoverTrigger = PopoverPrimitive.Trigger;
10
+ /** Positions the content against something other than the trigger (a row, a caret). */
11
+ export const PopoverAnchor = PopoverPrimitive.Anchor;
12
+ /** Closes the popover (asChild onto a Done button). */
13
+ export const PopoverClose = PopoverPrimitive.Close;
14
+
15
+ export type PopoverContentProps = ComponentProps<typeof PopoverPrimitive.Content> & {
16
+ /** Keep it in the tree without a portal (inside a dialog that must contain it). @default false */
17
+ inline?: boolean | undefined;
18
+ };
19
+
20
+ /**
21
+ * PopoverContent — a small non-modal panel next to its trigger: the files of
22
+ * a conversation, a workspace switcher, the account card (Metaroom Agent).
23
+ * Page colour, 14px corners, a hairline edge; no shadow (3.0 has none).
24
+ * Renders in a portal and flips to stay in view (placement auto, Radix
25
+ * Popover); Esc and an outside click close it and focus returns to the
26
+ * trigger.
27
+ *
28
+ * Compose with: PopoverTrigger asChild onto an IconButton / AppRail slot;
29
+ * Menu for a list of actions instead (it brings arrow-key navigation).
30
+ */
31
+ export function PopoverContent({ className, sideOffset = 6, collisionPadding = 8, align = 'start', inline = false, ...rest }: PopoverContentProps) {
32
+ const content = (
33
+ <PopoverPrimitive.Content
34
+ sideOffset={sideOffset}
35
+ collisionPadding={collisionPadding}
36
+ align={align}
37
+ className={cn(
38
+ 'z-[60] w-[320px] max-w-[calc(100vw-16px)] overflow-y-auto rounded-[14px] border border-rule bg-page p-2 font-sans text-ui text-fg outline-none',
39
+ 'max-h-(--radix-popover-content-available-height)',
40
+ className,
41
+ )}
42
+ {...rest}
43
+ />
44
+ );
45
+ return inline ? content : <PopoverPrimitive.Portal>{content}</PopoverPrimitive.Portal>;
46
+ }
@@ -0,0 +1,10 @@
1
+ /* Component motion that has to be CSS (listeners hear animationstart):
2
+ BottomSheet's scrim fades in with the sheet (280ms) and out as it
3
+ leaves (220ms, data-leaving) — Motion & Native Feel §04. */
4
+ @keyframes cosx-sheet-scrim-in { from { opacity: 0; } }
5
+ @keyframes cosx-sheet-scrim-out { to { opacity: 0; } }
6
+ .cosx-sheet-scrim { animation: cosx-sheet-scrim-in 280ms cubic-bezier(.16,1,.3,1); }
7
+ .cosx-sheet-scrim[data-leaving] { animation: cosx-sheet-scrim-out 220ms cubic-bezier(.4,0,1,1) forwards; }
8
+ @media (prefers-reduced-motion: reduce) {
9
+ .cosx-sheet-scrim, .cosx-sheet-scrim[data-leaving] { animation: none; }
10
+ }
package/src/index.ts CHANGED
@@ -77,3 +77,10 @@ export { WindowDrop, useWindowDrop, type WindowDropProps } from './components/Wi
77
77
  export { AuthIconTile, AuthLayout, AuthPanel, AuthStepHead, type AuthIconTileProps, type AuthLayoutProps, type AuthPanelProps, type AuthStepHeadProps } from './components/AuthLayout';
78
78
  export { ThemeSwitch, type ThemeSwitchLabels, type ThemeSwitchProps } from './components/ThemeSwitch';
79
79
  export { WorkspaceMark, WorkspaceRow, type WorkspaceMarkProps, type WorkspaceRowProps } from './components/WorkspaceRow';
80
+
81
+ // Stage 4 — app shell (Metaroom Agent)
82
+ export { AppRail, AppRailSlot, type AppRailItem, type AppRailProps, type AppRailSlotProps } from './components/AppRail';
83
+ export { Avatar, type AvatarProps, type AvatarSize } from './components/Avatar';
84
+ export { BottomSheet, type BottomSheetProps } from './components/BottomSheet';
85
+ export { BottomTabs, type BottomTabItem, type BottomTabsProps } from './components/BottomTabs';
86
+ export { Popover, PopoverAnchor, PopoverClose, PopoverContent, PopoverTrigger, type PopoverContentProps } from './components/Popover';
package/src/styles.css CHANGED
@@ -17,3 +17,4 @@
17
17
  @import "./brand.css";
18
18
  @import "./modes.css";
19
19
  @import "./fonts.css";
20
+ @import "./components.css";