@cosxai/ui 1.0.0-alpha.5 → 1.0.0-alpha.6

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,19 @@
1
+ ## 1.0.0-alpha.6 (2026-09-29)
2
+
3
+ Sign-in patterns (Metaroom sign-in, design.cosx.co), moved here from the
4
+ COSX app so every product signs in with the same pages:
5
+
6
+ - AuthLayout: the step under the product lockup, header end (theme switch),
7
+ help line, footer, and the yellow panel — dropped under 900px (container
8
+ query, so a narrow window or pane works too). AuthPanel for the panel's
9
+ text, AuthStepHead and AuthIconTile for a step.
10
+ - ThemeSwitch: match system · light · dark, folded to the current choice;
11
+ hover or focus opens it, a quick click steps, a settled click picks.
12
+ Follows the ThemeProvider unless given value / onChange.
13
+ - WorkspaceRow and WorkspaceMark: a workspace in a chooser (logo or
14
+ initial, name, detail, busy).
15
+ - Every string is a prop with an English default.
16
+
1
17
  ## 1.0.0-alpha.5 (2026-09-29)
2
18
 
3
19
  - Builds under the consuming app's TypeScript settings without
@@ -19,7 +35,7 @@
19
35
 
20
36
  ## 1.0.0-alpha.2 (2026-09-29)
21
37
 
22
- MetaRoom product patterns (spec P0), checked light / ink × en / zh.
38
+ Metaroom product patterns (spec P0), checked light / ink × en / zh.
23
39
 
24
40
  - Surfaces: ActionBar (pages register actions; idle / selection / mode,
25
41
  Esc steps back; folds by group and past six; responsive, fold handle
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosxai/ui",
3
- "version": "1.0.0-alpha.5",
3
+ "version": "1.0.0-alpha.6",
4
4
  "description": "COSX Design System 3.0 \u2014 tokens, Tailwind v4 theme and React components",
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",
package/src/brand.css CHANGED
@@ -1,4 +1,4 @@
1
- /* @cosxai/ui — workspace brand injection (MetaRoom spec §01).
1
+ /* @cosxai/ui — workspace brand injection (Metaroom spec §01).
2
2
  *
3
3
  * A customer's colour replaces the COSX yellow through two variables;
4
4
  * everything else stays ink, so any colour keeps text readable:
@@ -0,0 +1,123 @@
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
+ <p className="m-0 text-[34px] leading-[1.22] font-medium tracking-[-.02em] text-pretty">
73
+ {lead}
74
+ <span className="box-decoration-clone bg-[linear-gradient(transparent_45%,var(--brand-mark)_45%,var(--brand-mark)_95%,transparent_95%)]">{mark}</span>
75
+ </p>
76
+ {sub && <p className="m-0 text-[15px] leading-[1.6] text-[rgba(17,17,17,.7)]">{sub}</p>}
77
+ </div>
78
+ </>
79
+ );
80
+ }
81
+
82
+ export type AuthStepHeadProps = {
83
+ /** An AuthIconTile, a Spinner, a workspace mark. */
84
+ icon?: ReactNode;
85
+ title: ReactNode;
86
+ children?: ReactNode;
87
+ };
88
+
89
+ /** AuthStepHead — a step's icon, title and one explanation. */
90
+ export function AuthStepHead({ icon, title, children }: AuthStepHeadProps) {
91
+ return (
92
+ <div className="flex flex-col gap-5">
93
+ {icon}
94
+ <div className="flex flex-col gap-2">
95
+ <h1 className="m-0 text-[30px] leading-[1.2] font-medium tracking-[-.015em]">{title}</h1>
96
+ {children && <p className="m-0 text-[15px] leading-[1.6] text-fg-secondary">{children}</p>}
97
+ </div>
98
+ </div>
99
+ );
100
+ }
101
+
102
+ export type AuthIconTileProps = {
103
+ children: ReactNode;
104
+ /** plain: a step; yellow: done; error: it didn't work. @default "plain" */
105
+ tone?: 'plain' | 'yellow' | 'error' | undefined;
106
+ };
107
+
108
+ /** AuthIconTile — the 56px tile holding a step's icon. */
109
+ export function AuthIconTile({ children, tone = 'plain' }: AuthIconTileProps) {
110
+ return (
111
+ <span
112
+ aria-hidden
113
+ className={cn(
114
+ 'grid size-14 place-items-center rounded-[14px]',
115
+ tone === 'plain' && 'bg-sunk text-fg',
116
+ tone === 'yellow' && 'bg-brand-field text-ink',
117
+ tone === 'error' && 'bg-error-wash text-error-text',
118
+ )}
119
+ >
120
+ {children}
121
+ </span>
122
+ );
123
+ }
@@ -13,7 +13,7 @@ export const DialogTrigger = DialogPrimitive.Trigger;
13
13
  export const DialogClose = DialogPrimitive.Close;
14
14
 
15
15
  // sm/md/lg are the site's 400 / 520 / 640; xl (a wizard) and split (a
16
- // document beside its detail) are the MetaRoom spec's 800 and 1120.
16
+ // document beside its detail) are the Metaroom spec's 800 and 1120.
17
17
  const WIDTH = {
18
18
  sm: 'sm:max-w-[400px]',
19
19
  md: 'sm:max-w-[520px]',
@@ -62,7 +62,7 @@ export type PageNoticeProps = Omit<ComponentProps<'div'>, 'children'> & {
62
62
 
63
63
  /**
64
64
  * PageNotice — a full-width line above the content: "This document has
65
- * 1,240 pages. Only the first 500 were processed…", or "MetaRoom has been
65
+ * 1,240 pages. Only the first 500 were processed…", or "Metaroom has been
66
66
  * updated. Refresh to load this page."
67
67
  */
68
68
  export function PageNotice({ tone = 'info', children, action, className, ...rest }: PageNoticeProps) {
@@ -0,0 +1,97 @@
1
+ import { Monitor, Moon, Sun, type LucideIcon } from 'lucide-react';
2
+ import { useRef, useState, type ComponentProps } from 'react';
3
+
4
+ import { cn } from '../lib/cn';
5
+ import { useTheme, type ThemeMode } from './ThemeProvider';
6
+
7
+ export type ThemeSwitchLabels = {
8
+ /** @default "Theme" */
9
+ group?: string | undefined;
10
+ /** @default "Theme: match system" */
11
+ system?: string | undefined;
12
+ /** @default "Theme: light" */
13
+ light?: string | undefined;
14
+ /** @default "Theme: dark" */
15
+ ink?: string | undefined;
16
+ };
17
+
18
+ export type ThemeSwitchProps = Omit<ComponentProps<'div'>, 'onChange' | 'children'> & {
19
+ /** The choice. Unset: the ThemeProvider's. */
20
+ value?: ThemeMode | undefined;
21
+ /** Unset: the ThemeProvider's setter. */
22
+ onChange?: ((mode: ThemeMode) => void) | undefined;
23
+ labels?: ThemeSwitchLabels | undefined;
24
+ };
25
+
26
+ /** A click on an option only picks it once the switch has been open this long. */
27
+ const PICK_AFTER_MS = 800;
28
+
29
+ /**
30
+ * ThemeSwitch — match system · light · dark, folded to the current choice.
31
+ * Hovering or focusing opens all three. A click anywhere steps to the next;
32
+ * a click on an option picks it only once the switch has been open a moment,
33
+ * so the first tap on touch (which opens it) steps instead of landing on
34
+ * whatever slid under the finger.
35
+ */
36
+ export function ThemeSwitch({ value, onChange, labels, className, ...rest }: ThemeSwitchProps) {
37
+ const theme = useTheme();
38
+ const mode = value ?? theme.mode;
39
+ const setMode = onChange ?? theme.setMode;
40
+ const [open, setOpen] = useState(false);
41
+ const openedAt = useRef(0);
42
+ const show = () => {
43
+ if (open) return;
44
+ openedAt.current = Date.now();
45
+ setOpen(true);
46
+ };
47
+ const options: Array<[ThemeMode, LucideIcon, string]> = [
48
+ ['system', Monitor, labels?.system ?? 'Theme: match system'],
49
+ ['light', Sun, labels?.light ?? 'Theme: light'],
50
+ ['ink', Moon, labels?.ink ?? 'Theme: dark'],
51
+ ];
52
+ const cycle = () => {
53
+ const i = options.findIndex(([v]) => v === mode);
54
+ setMode(options[(i + 1) % options.length]![0]);
55
+ };
56
+ return (
57
+ <div
58
+ role="radiogroup"
59
+ aria-label={labels?.group ?? 'Theme'}
60
+ className={cn('inline-flex cursor-pointer rounded-[10px] bg-sunk p-[3px] transition-[gap] duration-[180ms] ease-standard', open ? 'gap-0.5' : 'gap-0', className)}
61
+ onClick={cycle}
62
+ onMouseEnter={show}
63
+ onMouseLeave={() => setOpen(false)}
64
+ onFocus={show}
65
+ onBlur={(e) => !e.currentTarget.contains(e.relatedTarget as Node | null) && setOpen(false)}
66
+ {...rest}
67
+ >
68
+ {options.map(([v, Glyph, label]) => {
69
+ const on = mode === v;
70
+ const shown = open || on;
71
+ return (
72
+ <button
73
+ key={v}
74
+ type="button"
75
+ role="radio"
76
+ aria-checked={on}
77
+ aria-label={label}
78
+ title={label}
79
+ tabIndex={shown ? 0 : -1}
80
+ onClick={(e) => {
81
+ e.stopPropagation();
82
+ if (open && Date.now() - openedAt.current > PICK_AFTER_MS) setMode(v);
83
+ else cycle();
84
+ }}
85
+ className={cn(
86
+ 'grid h-7 cursor-pointer place-items-center overflow-hidden rounded-[8px] border-0 p-0 outline-none transition-[width,opacity] duration-[180ms] ease-standard focus-visible:shadow-(--focus-ring)',
87
+ shown ? 'w-[30px] opacity-100' : 'w-0 opacity-0',
88
+ on ? 'bg-page text-fg' : 'bg-transparent text-fg-secondary',
89
+ )}
90
+ >
91
+ <Glyph size={15} strokeWidth={1.75} aria-hidden />
92
+ </button>
93
+ );
94
+ })}
95
+ </div>
96
+ );
97
+ }
@@ -0,0 +1,64 @@
1
+ import { ChevronRight } from 'lucide-react';
2
+ import { forwardRef, useState, type ComponentProps, type ReactNode } from 'react';
3
+
4
+ import { cn } from '../lib/cn';
5
+ import { Spinner } from './Spinner';
6
+
7
+ export type WorkspaceMarkProps = {
8
+ name: string;
9
+ /** The workspace's logo; its initial on the yellow when unset or it fails to load. */
10
+ logoUrl?: string | undefined;
11
+ /** px. @default 36 */
12
+ size?: number | undefined;
13
+ };
14
+
15
+ /** WorkspaceMark — a workspace's logo, or its initial on the brand yellow. */
16
+ export function WorkspaceMark({ name, logoUrl, size = 36 }: WorkspaceMarkProps) {
17
+ const [failed, setFailed] = useState(false);
18
+ if (logoUrl && !failed) {
19
+ return <img src={logoUrl} alt="" aria-hidden style={{ width: size, height: size }} className="shrink-0 rounded-[8px] object-cover" onError={() => setFailed(true)} />;
20
+ }
21
+ return (
22
+ <span aria-hidden style={{ width: size, height: size }} className="grid shrink-0 place-items-center rounded-[8px] bg-brand-field text-[13px] font-semibold text-ink">
23
+ {name.trim().charAt(0).toUpperCase()}
24
+ </span>
25
+ );
26
+ }
27
+
28
+ export type WorkspaceRowProps = Omit<ComponentProps<'button'>, 'children'> & {
29
+ name: string;
30
+ /** The second line: the workspace's short name or address. */
31
+ detail?: ReactNode;
32
+ logoUrl?: string | undefined;
33
+ /** Being opened: a spinner in place of the chevron. */
34
+ busy?: boolean | undefined;
35
+ };
36
+
37
+ /**
38
+ * WorkspaceRow — one workspace in a chooser: mark, name, detail, chevron.
39
+ * The whole row is the button; `busy` shows it opening (disable the others).
40
+ */
41
+ export const WorkspaceRow = forwardRef<HTMLButtonElement, WorkspaceRowProps>(function WorkspaceRow(
42
+ { name, detail, logoUrl, busy = false, className, ...rest },
43
+ ref,
44
+ ) {
45
+ return (
46
+ <button
47
+ ref={ref}
48
+ type="button"
49
+ aria-busy={busy || undefined}
50
+ className={cn(
51
+ 'flex w-full cursor-pointer items-center gap-3 rounded-[12px] border border-rule bg-transparent px-3.5 py-3 text-left text-fg outline-none transition-colors duration-[120ms] hover:bg-hover focus-visible:shadow-(--focus-ring) disabled:cursor-default',
52
+ className,
53
+ )}
54
+ {...rest}
55
+ >
56
+ <WorkspaceMark name={name} logoUrl={logoUrl} />
57
+ <span className="flex min-w-0 flex-1 flex-col">
58
+ <span className="truncate text-[15px] font-medium">{name}</span>
59
+ {detail && <span className="truncate text-[12px] text-fg-secondary">{detail}</span>}
60
+ </span>
61
+ {busy ? <Spinner size={16} /> : <ChevronRight size={16} aria-hidden className="text-fg-secondary" />}
62
+ </button>
63
+ );
64
+ });
package/src/index.ts CHANGED
@@ -71,3 +71,8 @@ export { UploadFolderRow, UploadList, UploadRow, useBeforeUnloadWhile, type Uplo
71
71
  export { useSelection, type Selection, type SelectionOptions } from './components/useSelection';
72
72
  export { InfiniteLoader, VirtualList, type InfiniteLoaderProps, type VirtualListProps } from './components/VirtualList';
73
73
  export { WindowDrop, useWindowDrop, type WindowDropProps } from './components/WindowDrop';
74
+
75
+ // Stage 3 — sign-in (Metaroom sign-in, design.cosx.co)
76
+ export { AuthIconTile, AuthLayout, AuthPanel, AuthStepHead, type AuthIconTileProps, type AuthLayoutProps, type AuthPanelProps, type AuthStepHeadProps } from './components/AuthLayout';
77
+ export { ThemeSwitch, type ThemeSwitchLabels, type ThemeSwitchProps } from './components/ThemeSwitch';
78
+ export { WorkspaceMark, WorkspaceRow, type WorkspaceMarkProps, type WorkspaceRowProps } from './components/WorkspaceRow';