@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 +17 -1
- package/package.json +1 -1
- package/src/brand.css +1 -1
- package/src/components/AuthLayout.tsx +123 -0
- package/src/components/Dialog.tsx +1 -1
- package/src/components/PageState.tsx +1 -1
- package/src/components/ThemeSwitch.tsx +97 -0
- package/src/components/WorkspaceRow.tsx +64 -0
- package/src/index.ts +5 -0
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
|
-
|
|
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
package/src/brand.css
CHANGED
|
@@ -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
|
|
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 "
|
|
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';
|