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

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,23 @@
1
+ ## 1.0.0-alpha.13 (2026-10-04)
2
+
3
+ - BottomSheet: the scrolling list reaches the sheet's sides (its padding
4
+ is the sheet's) and never pans sideways — a row bleeding to the edges
5
+ with `-mx-4` made it scroll 32px sideways and showed a scrollbar.
6
+
7
+ ## 1.0.0-alpha.12 (2026-10-04)
8
+
9
+ From the Metaroom app (Motion & Native Feel §04, Account):
10
+
11
+ - BottomSheet: rises in 280ms and now leaves in 220ms instead of vanishing
12
+ (it stays mounted for its exit; the scrim fades with it and carries
13
+ `data-leaving` while it goes). `detents="two"`: half (60%) and full —
14
+ typing in it, scrolling its list or dragging up lifts it to full,
15
+ dragging down from full goes back to half; dragging above its top gives
16
+ way at 0.3×. `action` (the title row's button) and `footer` (pinned below
17
+ the list). The rise starts when Radix puts the content on the page, so it
18
+ always plays. Buttons and fields in the title row don't start a drag.
19
+ - Avatar: size 64 (Account's profile photo), initials at 20px.
20
+
1
21
  ## 1.0.0-alpha.11 (2026-10-01)
2
22
 
3
23
  App shell for the Agent module (Metaroom Agent, desktop and phone):
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosxai/ui",
3
- "version": "1.0.0-alpha.11",
3
+ "version": "1.0.0-alpha.13",
4
4
  "description": "COSX Design System 3.0 \u2014 tokens, Tailwind v4 theme and React components",
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",
@@ -3,11 +3,11 @@ import { forwardRef, useEffect, useRef, useState, type ComponentProps } from 're
3
3
  import { cn } from '../lib/cn';
4
4
  import { initialsOf } from './ActivityTimeline';
5
5
 
6
- export type AvatarSize = 20 | 24 | 28 | 32 | 36 | 40 | 52;
6
+ export type AvatarSize = 20 | 24 | 28 | 32 | 36 | 40 | 52 | 64;
7
7
 
8
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 };
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
11
 
12
12
  export type AvatarProps = Omit<ComponentProps<'span'>, 'children'> & {
13
13
  /** The person's name: gives the initials (two Latin initials, or the first hanzi). */
@@ -29,8 +29,8 @@ export type AvatarProps = Omit<ComponentProps<'span'>, 'children'> & {
29
29
 
30
30
  /**
31
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
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
34
  * workspace, WorkspaceMark.
35
35
  *
36
36
  * Compose with: AppRail `account` slot (inside a button), BottomTabs item
@@ -1,5 +1,5 @@
1
1
  import * as DialogPrimitive from '@radix-ui/react-dialog';
2
- import { useCallback, useEffect, useRef, useState, type ComponentProps, type PointerEvent as ReactPointerEvent, type ReactElement, type ReactNode } from 'react';
2
+ import { useCallback, useEffect, useLayoutEffect, useRef, useState, type ComponentProps, type PointerEvent as ReactPointerEvent, type ReactElement, type ReactNode } from 'react';
3
3
 
4
4
  import { cn } from '../lib/cn';
5
5
  import { useReducedMotion } from './status-motion';
@@ -11,6 +11,17 @@ export type BottomSheetProps = Omit<ComponentProps<typeof DialogPrimitive.Conten
11
11
  hideTitle?: boolean | undefined;
12
12
  /** A line under the title, read out with it. */
13
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;
14
25
  children?: ReactNode;
15
26
  open?: boolean | undefined;
16
27
  defaultOpen?: boolean | undefined;
@@ -21,18 +32,37 @@ export type BottomSheetProps = Omit<ComponentProps<typeof DialogPrimitive.Conten
21
32
  closeLabel?: string | undefined;
22
33
  };
23
34
 
24
- // A drag past this share of the sheet's height, or a flick, closes it.
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.
25
42
  const CLOSE_SHARE = 0.25;
26
43
  const CLOSE_MAX = 120;
27
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
+ };
28
56
 
29
57
  /**
30
- * BottomSheet — the phone's light choice: a page-coloured sheet rising from
31
- * the bottom with a drag handle and a title (Metaroom Agent phone: switch
32
- * workspace, language). 20px top corners over the scrim; the bottom padding
33
- * clears the home indicator (safe area). Drag the handle or the title down,
34
- * or flick, to close; Esc, a tap on the scrim and the screen-reader close
35
- * button close it too. Built on Radix Dialog: focus moves in, is trapped and
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
36
66
  * returns to the opener.
37
67
  *
38
68
  * Compose with: BottomTabs (the Me tab opens it), rows of 48–56px buttons
@@ -42,6 +72,9 @@ export function BottomSheet({
42
72
  title,
43
73
  hideTitle = false,
44
74
  description,
75
+ action,
76
+ footer,
77
+ detents = 'content',
45
78
  children,
46
79
  open: openProp,
47
80
  defaultOpen = false,
@@ -51,6 +84,7 @@ export function BottomSheet({
51
84
  className,
52
85
  style,
53
86
  onOpenAutoFocus,
87
+ onFocusCapture,
54
88
  ...rest
55
89
  }: BottomSheetProps) {
56
90
  const [openState, setOpenState] = useState(defaultOpen);
@@ -64,64 +98,121 @@ export function BottomSheet({
64
98
  );
65
99
 
66
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);
67
104
  const sheet = useRef<HTMLDivElement | null>(null);
105
+ const scrim = useRef<HTMLDivElement | null>(null);
68
106
  const drag = useRef<{ y0: number; t0: number } | null>(null);
69
- const [dy, setDy] = useState(0);
70
- const [settling, setSettling] = useState(false);
107
+ const entering = useRef(open);
71
108
 
72
- useEffect(() => {
73
- if (!open) setDy(0);
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
74
129
  }, [open]);
75
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
+ };
76
152
  const onDown = (e: ReactPointerEvent<HTMLDivElement>) => {
77
153
  if (e.button !== 0) return;
154
+ if ((e.target as HTMLElement).closest('button, a, input, textarea, select')) return;
78
155
  drag.current = { y0: e.clientY, t0: performance.now() };
79
- setSettling(false);
80
156
  e.currentTarget.setPointerCapture?.(e.pointerId);
81
157
  };
82
158
  const onMove = (e: ReactPointerEvent<HTMLDivElement>) => {
83
- if (drag.current) setDy(Math.max(0, e.clientY - drag.current.y0));
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)`;
84
164
  };
85
165
  const onUp = (e: ReactPointerEvent<HTMLDivElement>) => {
86
166
  const d = drag.current;
87
167
  drag.current = null;
88
168
  if (!d) return;
89
- const dist = Math.max(0, e.clientY - d.y0);
169
+ const dist = e.clientY - d.y0;
90
170
  const speed = dist / Math.max(1, performance.now() - d.t0);
91
- const height = sheet.current?.offsetHeight ?? 0;
92
- const limit = height > 0 ? Math.min(CLOSE_MAX, height * CLOSE_SHARE) : CLOSE_MAX;
93
- setSettling(true);
94
- if (dist > limit || (dist > 8 && speed > FLICK)) setOpen(false);
95
- else setDy(0);
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
+ }
96
183
  };
97
184
 
185
+ const two = detents === 'two';
98
186
  return (
99
- <DialogPrimitive.Root open={open} onOpenChange={setOpen}>
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))}>
100
189
  {trigger && <DialogPrimitive.Trigger asChild>{trigger}</DialogPrimitive.Trigger>}
190
+ {mounted && (
101
191
  <DialogPrimitive.Portal>
102
- <DialogPrimitive.Overlay className="fixed inset-0 z-50 bg-scrim" />
192
+ <DialogPrimitive.Overlay ref={scrim} className="cosx-sheet-scrim fixed inset-0 z-50 bg-scrim" />
103
193
  <DialogPrimitive.Content
104
- ref={sheet}
194
+ ref={setSheet}
105
195
  {...(description ? {} : { 'aria-describedby': undefined })}
106
196
  onOpenAutoFocus={(e) => {
107
197
  onOpenAutoFocus?.(e);
108
198
  if (e.defaultPrevented) return;
109
199
  // The sheet itself takes focus (read out by its title), not the first row.
110
200
  e.preventDefault();
111
- const el = e.currentTarget as HTMLElement | null;
112
- el?.focus();
113
- if (!reduce && el && typeof el.animate === 'function') {
114
- el.animate([{ transform: 'translateY(100%)' }, { transform: 'translateY(0)' }], { duration: 240, easing: 'cubic-bezier(.16,1,.3,1)' });
115
- }
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);
116
207
  }}
117
208
  className={cn(
118
- 'fixed inset-x-0 bottom-0 z-50 flex max-h-[90dvh] flex-col rounded-t-[20px] bg-page px-4 pt-2 font-sans text-fg outline-none',
119
- settling && !reduce && 'transition-transform duration-200 ease-out',
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]',
120
211
  className,
121
212
  )}
122
213
  style={{
123
- paddingBottom: 'max(30px, calc(env(safe-area-inset-bottom, 0px) + 12px))',
124
- ...(dy ? { transform: `translateY(${dy}px)` } : {}),
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` } : {}),
125
216
  ...style,
126
217
  }}
127
218
  {...rest}
@@ -134,18 +225,31 @@ export function BottomSheet({
134
225
  onPointerUp={onUp}
135
226
  onPointerCancel={() => {
136
227
  drag.current = null;
137
- setSettling(true);
138
- setDy(0);
228
+ settle();
139
229
  }}
140
230
  >
141
231
  <span aria-hidden className="mb-2.5 h-1 w-9 self-center rounded-pill bg-rule" />
142
- <DialogPrimitive.Title className={cn('m-0 px-1 pb-2 text-[17px] leading-[1.3] font-medium', hideTitle && 'sr-only')}>{title}</DialogPrimitive.Title>
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>
143
236
  {description && <DialogPrimitive.Description className="m-0 px-1 pb-2 text-small text-fg-secondary">{description}</DialogPrimitive.Description>}
144
237
  </div>
145
- <div className="flex min-h-0 flex-1 flex-col gap-0.5 overflow-y-auto">{children}</div>
238
+ <div
239
+ // Edge to edge (its padding is the sheet's): a row that bleeds
240
+ // to the sheet's sides with -mx-4 fits, and it never pans sideways
241
+ className={cn('-mx-4 flex min-h-0 flex-1 flex-col gap-0.5 px-4', two && !full ? 'overflow-hidden' : 'overflow-x-hidden overflow-y-auto overscroll-contain')}
242
+ // At half height, a pull up on the list lifts the sheet first
243
+ onWheel={(e) => two && !full && e.deltaY > 0 && setFull(true)}
244
+ onTouchMove={() => two && !full && setFull(true)}
245
+ >
246
+ {children}
247
+ </div>
248
+ {footer && <div className="shrink-0 border-t border-rule-soft pt-2.5">{footer}</div>}
146
249
  <DialogPrimitive.Close className="sr-only">{closeLabel}</DialogPrimitive.Close>
147
250
  </DialogPrimitive.Content>
148
251
  </DialogPrimitive.Portal>
252
+ )}
149
253
  </DialogPrimitive.Root>
150
254
  );
151
255
  }
@@ -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/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";