@linhey/rhine-design 0.0.0-stage → 0.3.0
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 +22 -0
- package/CONTRIBUTING.md +50 -0
- package/LICENSE +202 -0
- package/NOTICE +4 -0
- package/README.md +149 -2
- package/components/Actions.tsx +40 -0
- package/components/COMMON_COMPONENTS.md +70 -0
- package/components/Data.tsx +42 -0
- package/components/Forms.tsx +64 -0
- package/components/Layout.tsx +13 -0
- package/components/Logo.tsx +34 -0
- package/components/MOTION.md +72 -0
- package/components/Overlays.tsx +53 -0
- package/components/README.md +137 -0
- package/components/STARTUP.md +34 -0
- package/components/Select.tsx +22 -0
- package/components/TYPOGRAPHY.md +60 -0
- package/components/Toast.tsx +25 -0
- package/components/Toolbar.tsx +14 -0
- package/components/Typography.tsx +13 -0
- package/components/assets/logo.svg +5 -0
- package/components/common.css +69 -0
- package/components/components.tsx +151 -0
- package/components/index.ts +24 -0
- package/components/styles.css +109 -0
- package/components/tokens.css +58 -0
- package/components/typography.css +31 -0
- package/design/README.md +14 -0
- package/docs/DESIGN_SYSTEM.md +84 -0
- package/docs/DESIGN_SYSTEM_AUDIT.md +63 -0
- package/docs/RELEASING.md +27 -0
- package/index.html +3 -0
- package/package.json +76 -4
- package/preview/CommonGallery.tsx +35 -0
- package/preview/MotionGallery.tsx +89 -0
- package/preview/StartupGallery.tsx +73 -0
- package/preview/brand-assembly.css +18 -0
- package/preview/main.tsx +100 -0
- package/preview/motion.css +25 -0
- package/preview/preview.css +27 -0
- package/preview/startup.css +33 -0
- package/scripts/check-build.mjs +15 -0
- package/skills/rhine-design/SKILL.md +37 -0
- package/skills/rhine-design/agents/openai.yaml +4 -0
- package/skills/rhine-design/references/API_MAP.md +34 -0
- package/skills/rhine-design/references/TASK_MAP.md +20 -0
- package/skills/rhine-design/references/integration.md +40 -0
- package/tests/CommonComponents.test.tsx +71 -0
- package/tests/DesignSystem.test.tsx +100 -0
- package/tests/MotionGallery.test.tsx +32 -0
- package/tests/StartupGallery.test.tsx +55 -0
- package/tests/setup.ts +4 -0
- package/tsconfig.json +32 -0
- package/vite.config.ts +3 -0
- package/vitest.config.ts +3 -0
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { useEffect, useId, useLayoutEffect, useRef, useState, type InputHTMLAttributes, type TextareaHTMLAttributes } from 'react';
|
|
2
|
+
import * as PopoverBase from '@radix-ui/react-popover';
|
|
3
|
+
import { Button, type Choice } from './components';
|
|
4
|
+
import { Icon, IconButton } from './Actions';
|
|
5
|
+
|
|
6
|
+
export function Checkbox({ label, hint, indeterminate = false, id: suppliedId, className = '', ...props }: Omit<InputHTMLAttributes<HTMLInputElement>, 'type'> & { label: string; hint?: string; indeterminate?: boolean }) {
|
|
7
|
+
const generatedId = useId(), id = suppliedId || generatedId;
|
|
8
|
+
const ref = useRef<HTMLInputElement>(null);
|
|
9
|
+
useEffect(() => { if (ref.current) ref.current.indeterminate = indeterminate; }, [indeterminate]);
|
|
10
|
+
return <div className={`rhine-check ${className}`}><label htmlFor={id}><input {...props} ref={ref} id={id} type="checkbox" aria-checked={indeterminate ? 'mixed' : undefined} aria-describedby={[props['aria-describedby'], hint && `${id}-hint`].filter(Boolean).join(' ') || undefined} /><span>{label}</span></label>{hint && <p id={`${id}-hint`} className="rhine-text rhine-text--caption rhine-text--muted">{hint}</p>}</div>;
|
|
11
|
+
}
|
|
12
|
+
export function RadioGroup({ label, items, value, onValueChange, disabled = false, name }: { label: string; items: Choice[]; value: string; onValueChange: (value: string) => void; disabled?: boolean; name?: string }) {
|
|
13
|
+
const id = useId();
|
|
14
|
+
return <fieldset className="rhine-radio-group" disabled={disabled}><legend>{label}</legend>{items.map(item => <label key={item.value}><input type="radio" name={name || id} value={item.value} checked={value === item.value} disabled={item.disabled} onChange={() => onValueChange(item.value)} /><span>{item.label}</span></label>)}</fieldset>;
|
|
15
|
+
}
|
|
16
|
+
export function Switch({ label, checked, onCheckedChange, disabled = false, hint }: { label: string; checked: boolean; onCheckedChange: (checked: boolean) => void; disabled?: boolean; hint?: string }) {
|
|
17
|
+
const id = useId();
|
|
18
|
+
return <div className="rhine-switch"><label><input type="checkbox" role="switch" checked={checked} aria-checked={checked} disabled={disabled} aria-describedby={hint ? id : undefined} onChange={event => onCheckedChange(event.target.checked)} /><span className="rhine-switch__track" aria-hidden="true"><span /></span><span>{label}</span></label>{hint && <p id={id} className="rhine-text rhine-text--caption rhine-text--muted">{hint}</p>}</div>;
|
|
19
|
+
}
|
|
20
|
+
export function Textarea({ label, hint, error, id: suppliedId, className = '', rows = 3, ...props }: TextareaHTMLAttributes<HTMLTextAreaElement> & { label: string; hint?: string; error?: string }) {
|
|
21
|
+
const generatedId = useId(), id = suppliedId || generatedId;
|
|
22
|
+
return <div className={`rhine-field ${className}`}><label htmlFor={id}>{label}</label><textarea {...props} id={id} rows={rows} aria-invalid={error ? true : props['aria-invalid']} aria-describedby={[props['aria-describedby'], hint && `${id}-hint`, error && `${id}-error`].filter(Boolean).join(' ') || undefined} />{hint && <span id={`${id}-hint`} className="rhine-field__hint">{hint}</span>}{error && <span id={`${id}-error`} className="rhine-field__error" role="alert">{error}</span>}</div>;
|
|
23
|
+
}
|
|
24
|
+
export function SearchField({ label, value, onValueChange, onSearch, disabled = false, loading = false }: { label: string; value: string; onValueChange: (value: string) => void; onSearch: (query: string) => void; disabled?: boolean; loading?: boolean }) {
|
|
25
|
+
const id = useId(); const composing = useRef(false);
|
|
26
|
+
return <div className="rhine-search rhine-field" role="search" aria-label={label}><label htmlFor={id}>{label}</label><div className="rhine-inline">
|
|
27
|
+
<input id={id} type="search" value={value} disabled={disabled} onChange={event => onValueChange(event.target.value)} onCompositionStart={() => { composing.current = true; }} onCompositionEnd={() => { composing.current = false; }} onKeyDown={event => { if (event.key === 'Enter') { if (composing.current || event.nativeEvent.isComposing || event.keyCode === 229) return; event.preventDefault(); if (!loading && !disabled) onSearch(value); } }} />
|
|
28
|
+
{value && <IconButton icon="close" label="清空搜索" disabled={disabled || loading} onClick={() => { onValueChange(''); onSearch(''); document.getElementById(id)?.focus(); }} />}
|
|
29
|
+
<Button variant="secondary" disabled={disabled} busy={loading} busyLabel="正在搜索" onClick={() => { if (!composing.current) onSearch(value); }}>搜索</Button>
|
|
30
|
+
</div></div>;
|
|
31
|
+
}
|
|
32
|
+
export interface ComboboxProps {
|
|
33
|
+
label: string; query: string; onQueryChange: (query: string) => void;
|
|
34
|
+
value: string | null; onValueChange: (option: Choice) => void; options: Choice[];
|
|
35
|
+
loading?: boolean; error?: string; disabled?: boolean;
|
|
36
|
+
}
|
|
37
|
+
export function Combobox({ label, query, onQueryChange, value, onValueChange, options, loading = false, error, disabled = false }: ComboboxProps) {
|
|
38
|
+
const id = useId(); const composing = useRef(false);
|
|
39
|
+
const root = useRef<HTMLDivElement>(null);
|
|
40
|
+
const [container, setContainer] = useState<HTMLElement>();
|
|
41
|
+
useLayoutEffect(() => { setContainer((root.current?.closest('.rhine-theme') as HTMLElement) || document.body); }, []);
|
|
42
|
+
const [open, setOpen] = useState(false), [active, setActive] = useState<string | null>(null), [draft, setDraft] = useState<string | null>(null);
|
|
43
|
+
const available = loading || error ? [] : options.filter(option => !option.disabled);
|
|
44
|
+
const activeIndex = options.findIndex(option => option.value === active && !option.disabled);
|
|
45
|
+
const activeId = activeIndex >= 0 && !loading && !error ? `${id}-option-${activeIndex}` : undefined;
|
|
46
|
+
useEffect(() => { if (!open || !activeId) return; document.getElementById(activeId)?.scrollIntoView?.({ block: 'nearest' }); }, [activeId, open]);
|
|
47
|
+
const choose = (option: Choice) => { onValueChange(option); setOpen(false); setActive(null); };
|
|
48
|
+
return <div ref={root} className="rhine-combobox rhine-field" onBlur={event => { if (!event.currentTarget.contains(event.relatedTarget)) setOpen(false); }}><label htmlFor={id}>{label}</label>
|
|
49
|
+
<PopoverBase.Root open={open && !disabled} onOpenChange={setOpen}><PopoverBase.Anchor asChild><div className="rhine-combobox__input"><input id={id} role="combobox" aria-autocomplete="list" aria-expanded={open && !disabled} aria-controls={open && !disabled ? `${id}-list` : undefined} aria-activedescendant={open ? activeId : undefined} aria-invalid={!!error || undefined} aria-describedby={`${id}-status`} value={draft ?? query} disabled={disabled}
|
|
50
|
+
onClick={() => setOpen(true)} onFocus={() => setOpen(true)} onChange={event => { if (composing.current) setDraft(event.target.value); else onQueryChange(event.target.value); setActive(null); setOpen(true); }}
|
|
51
|
+
onCompositionStart={() => { composing.current = true; setDraft(query); }} onCompositionEnd={event => { composing.current = false; setDraft(null); onQueryChange(event.currentTarget.value); }}
|
|
52
|
+
onKeyDown={event => {
|
|
53
|
+
if (composing.current || event.nativeEvent.isComposing || event.keyCode === 229) return;
|
|
54
|
+
if (event.key === 'Escape' && open) { event.preventDefault(); event.stopPropagation(); setOpen(false); setActive(null); }
|
|
55
|
+
if (['ArrowDown', 'ArrowUp'].includes(event.key)) { event.preventDefault(); setOpen(true); const index = available.findIndex(option => option.value === active); const next = event.key === 'ArrowDown' ? (index + 1) % available.length : (index <= 0 ? available.length : index) - 1; setActive(available[next]?.value ?? null); }
|
|
56
|
+
if (open && ['Home', 'End'].includes(event.key)) { event.preventDefault(); setActive((event.key === 'Home' ? available[0] : available.at(-1))?.value ?? null); }
|
|
57
|
+
if (open && event.key === 'Enter') { event.preventDefault(); const option = available.find(item => item.value === active); if (option) choose(option); }
|
|
58
|
+
}} /><span className="rhine-combobox__arrow"><Icon name="chevronDown" size={16} /></span></div></PopoverBase.Anchor>
|
|
59
|
+
<PopoverBase.Portal container={container}><PopoverBase.Content asChild sideOffset={4} collisionPadding={12} onOpenAutoFocus={event => event.preventDefault()} onCloseAutoFocus={event => event.preventDefault()} onInteractOutside={event => { if (root.current?.contains(event.target as Node)) event.preventDefault(); }}>
|
|
60
|
+
<div className="rhine-combobox__list" role="listbox" id={`${id}-list`} aria-label={label} aria-busy={loading}>{!loading && !error && options.map((option, index) => <div role="option" id={`${id}-option-${index}`} key={option.value} aria-selected={option.value === value} aria-disabled={option.disabled || undefined} data-active={option.value === active} onMouseDown={event => event.preventDefault()} onMouseMove={() => { if (!option.disabled) setActive(option.value); }} onClick={() => { if (!option.disabled) choose(option); }}>{option.label}</div>)}{(loading || error || !options.length) && <div className="rhine-combobox__empty" role="presentation">{loading ? '正在读取…' : error ? '暂不可用' : '暂无匹配结果'}</div>}</div>
|
|
61
|
+
</PopoverBase.Content></PopoverBase.Portal></PopoverBase.Root>
|
|
62
|
+
<span id={`${id}-status`} role="status" className={`rhine-text rhine-text--caption ${error ? 'rhine-text--error' : 'rhine-text--muted'}`}>{loading ? '正在读取候选项…' : error || (options.length ? `${options.length} 个候选项` : '暂无匹配结果')}</span>
|
|
63
|
+
</div>;
|
|
64
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { CSSProperties, HTMLAttributes } from 'react';
|
|
2
|
+
export type Spacing = 1 | 2 | 3 | 4 | 6 | 8 | 12;
|
|
3
|
+
type LayoutProps = HTMLAttributes<HTMLDivElement> & { gap?: Spacing };
|
|
4
|
+
export function Stack({ gap = 4, className = '', style, ...props }: LayoutProps) {
|
|
5
|
+
return <div {...props} className={`rhine-stack ${className}`} style={{ gap: `var(--rhine-space-${gap})`, ...style }} />;
|
|
6
|
+
}
|
|
7
|
+
export function Inline({ gap = 3, className = '', style, ...props }: LayoutProps) {
|
|
8
|
+
return <div {...props} className={`rhine-inline ${className}`} style={{ gap: `var(--rhine-space-${gap})`, ...style }} />;
|
|
9
|
+
}
|
|
10
|
+
export function Grid({ gap = 4, minWidth = 240, className = '', style, ...props }: LayoutProps & { minWidth?: number }) {
|
|
11
|
+
return <div {...props} className={`rhine-grid ${className}`} style={{ '--rhine-grid-min': `${Math.max(80, minWidth)}px`, gap: `var(--rhine-space-${gap})`, ...style } as CSSProperties} />;
|
|
12
|
+
}
|
|
13
|
+
export function Divider() { return <hr className="rhine-divider" />; }
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { CSSProperties } from 'react';
|
|
2
|
+
import markUrl from './assets/logo.svg?no-inline';
|
|
3
|
+
|
|
4
|
+
export interface LogoProps {
|
|
5
|
+
size?: number;
|
|
6
|
+
variant?: 'mark' | 'icon' | 'lockup';
|
|
7
|
+
theme?: 'light' | 'dark' | 'auto';
|
|
8
|
+
animate?: boolean;
|
|
9
|
+
decorative?: boolean;
|
|
10
|
+
label?: string;
|
|
11
|
+
wordmark?: string;
|
|
12
|
+
className?: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** All variants share the same SVG geometry and centered square viewport. */
|
|
16
|
+
export function Logo({ size = 48, variant = 'mark', theme = 'light', animate = false, decorative = false, wordmark = 'Rhine Design', label = wordmark, className = '' }: LogoProps) {
|
|
17
|
+
const dimension = Number.isFinite(size) ? Math.max(16, size) : 48;
|
|
18
|
+
return <span
|
|
19
|
+
className={`rhine-logo rhine-logo--${variant} ${className}`}
|
|
20
|
+
data-theme={theme}
|
|
21
|
+
data-animate={animate}
|
|
22
|
+
role={decorative ? undefined : 'img'}
|
|
23
|
+
aria-label={decorative ? undefined : label}
|
|
24
|
+
aria-hidden={decorative || undefined}
|
|
25
|
+
style={{ '--rhine-logo-size': `${dimension}px`, '--rhine-logo-travel': `${2400 / dimension}px` } as CSSProperties}
|
|
26
|
+
>
|
|
27
|
+
<svg className="rhine-logo__symbol" viewBox="0 0 800 800" aria-hidden="true" focusable="false">
|
|
28
|
+
<use className="rhine-logo__plate rhine-logo__plate--left" href={`${markUrl}#left`} />
|
|
29
|
+
<use className="rhine-logo__plate rhine-logo__plate--right" href={`${markUrl}#right`} />
|
|
30
|
+
<use className="rhine-logo__plate rhine-logo__plate--base" href={`${markUrl}#base`} />
|
|
31
|
+
</svg>
|
|
32
|
+
{variant === 'lockup' && <span className="rhine-logo__wordmark" aria-hidden="true">{wordmark}</span>}
|
|
33
|
+
</span>;
|
|
34
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# 莱茵设计 · UX 与动效规范 V1.4
|
|
2
|
+
|
|
3
|
+
独立套件入口:`http://127.0.0.1:18309/#motion`。以下规则属于[莱茵设计](../README.md);宿主按实际交互选择配方。
|
|
4
|
+
|
|
5
|
+
2026-10-22,用户指定 [Transitions.dev](https://transitions.dev/) 作为UX与动画参考。本文件补充[组件规范](./README.md#动效规范),保留Rhine Design的苔绿、暖白、三角几何与清晰文字。以下参数是本项目设计决定,不是对参考站时长或曲线的测量结果。
|
|
6
|
+
|
|
7
|
+
用户已进一步授权实现展台:`/#motion`提供10组可播放、重播、复位的真实示例,支持原速/4倍慢放、逐例减少动态及全局偏好。组件总数保持40;新配方仍限定于展台,后续按“展台评审 → 通用组件采用 → App接入”推进。
|
|
8
|
+
|
|
9
|
+
## 参考依据
|
|
10
|
+
|
|
11
|
+
- [首页](https://transitions.dev/):分类导航、独立演示卡、主动播放按钮,以及示例名称和简短用途说明。实际浏览并点击卡片尺寸示例;未对全过程录屏或逐帧计时。
|
|
12
|
+
- [动效目录](https://transitions.dev/library.html):核对了菜单、弹窗、面板、状态文字、图标、骨架到内容、提示气泡与折叠等参考类型。目录提供的说明是选型依据,不代表逐项完成交互验收。
|
|
13
|
+
- [示例详情与文档](https://transitions.dev/detail.html?t=card-resize):预览、CSS与React视图并列,动效参数集中管理。Rhine Design继续使用自己的token与组件契约,不复制另一套参数体系。
|
|
14
|
+
|
|
15
|
+
## UX:把变化做得可理解、可比较
|
|
16
|
+
|
|
17
|
+
1. **动作有来源。** 菜单、气泡从触发位置展开;碰撞避让改变位置时,动画方向随最终位置变化。按钮点击立即响应,视觉过渡不排队阻塞下一次操作。
|
|
18
|
+
2. **前后是同一个对象。** 展开说明保留原入口;加载后保留内容位置;刷新保留旧数据,失败留出重试。账号卡的关键详情继续常显,选择时不改变卡片大小。
|
|
19
|
+
3. **一次只有一个视觉重点。** 打开弹窗时处理遮罩与面板即可,不再让标题、字段和按钮逐个飞入;数据表不按行排队进入。
|
|
20
|
+
4. **动效可被主动比较。** 展台后续为每种动效提供静态起点、播放/切换、重播、参数说明及减少动态对照。重播只改变本地演示状态,不重新执行复制、请求、确认等真实动作。
|
|
21
|
+
5. **解释适用条件。** 示例卡写清“触发 → 变化 → 结束状态”,并显示已实现/设计候选;筛选只改变展台展示范围,不改变组件业务值。宽屏并排对照,窄屏单列,播放按钮位置稳定。
|
|
22
|
+
|
|
23
|
+
动效实验室已为10组配方提供上述播放控制、状态说明、参数和逐例减少动态开关;并排卡片可分别设置偏好作比较。既有组件目录保留普通交互示例,不给每个无动画组件添加播放按钮。
|
|
24
|
+
|
|
25
|
+
## Rhine Design 动效映射
|
|
26
|
+
|
|
27
|
+
| 参考方向 | Rhine Design使用方式与参数 | 当前状态 / 下一步 |
|
|
28
|
+
| --- | --- | --- |
|
|
29
|
+
| 尺寸变化、折叠揭示 | Disclosure用200ms高度过渡,内容不缩放;从当前高度反向,展开结束恢复自然高度;账号选择不触发尺寸变化 | 已有可中断Disclosure;不新增通用卡片变形 |
|
|
30
|
+
| 菜单与气泡 | Menu/Popover/Combobox进入160ms、opacity与≤3px位移;位移朝向最终锚点方向,关闭即时归还焦点 | 实验室Menu/Popover已有四向候选;通用默认仍为原有动画,Combobox暂未单列方向演示 |
|
|
31
|
+
| 模态打开与关闭 | Dialog/ConfirmDialog进入160ms淡入;表单文字保持原尺寸;关闭即时,焦点归还不等待视觉退出 | 已实现;继续保留现有语义和时长 |
|
|
32
|
+
| 加载到内容 | 初次加载沿用1400ms骨架轻脉冲,内容就绪即替换;180ms内容淡入,骨架占位匹配最终结构 | 实验室已实现合成加载衔接,未接业务;刷新不重播骨架 |
|
|
33
|
+
| 状态文字与图标切换 | 复制/保存完成后可试验120ms结果淡入;按钮保持尺寸、焦点与可访问名称;只在真实成功后显示勾选反馈 | 实验室已实现合成结果淡入;不会调用保存/复制,通用CopyButton行为不变 |
|
|
34
|
+
| 提示气泡 | 鼠标悬停延迟300ms,键盘focus无等待;进入160ms,离开/Escape即时关闭,可移入气泡阅读 | 已实现;不新增指针跟随或跨按钮滑动 |
|
|
35
|
+
| 通知 | Toast进入180ms、≤3px;成功默认5秒,hover/focus/页面隐藏暂停;错误和警告常驻 | 已实现;不为了参考视觉新增通知堆栈 |
|
|
36
|
+
| 选项切换 | Tabs指示与面板180ms,Switch滑块120ms;选中值立即更新,快速反向响应最新意图 | 已实现;保留现有下划线/滑块,不新增测量式滑动胶囊 |
|
|
37
|
+
| 数字变化 | 请求量、费用、配额直接显示最终值,tabular-nums稳定对齐;未知保持“—” | 保留静态Metric,不采用滚轮、逐位翻转或从零计数 |
|
|
38
|
+
|
|
39
|
+
菜单方向候选:最终位于触发点下方时从`translateY(-3px)`归零,上方从`translateY(3px)`归零;左/右侧对称处理。只修改视觉transform,不干预Radix的定位计算或焦点管理。需要先在四向和视口边缘展台验收,再推广到组件。
|
|
40
|
+
|
|
41
|
+
### 播放控制的实现边界
|
|
42
|
+
|
|
43
|
+
入口为[MotionGallery.tsx](../preview/MotionGallery.tsx)和[展台动效CSS](../preview/motion.css)。每张卡初始静态,播放后可反复重播,复位清理双帧调度和合成结果计时;只重建该卡的演示内容,外层控制按钮保持焦点。加载/结果使用明确的700ms合成等待,不连接接口或系统剪贴板;慢放只改变视觉过渡,不延长这段等待。
|
|
44
|
+
|
|
45
|
+
弹窗使用现有Dialog和焦点限制。弹窗内“重播弹窗入场”重启CSS动画,不卸载内容或复制焦点树;减少动态时没有可重播的视觉动画。Toast演示使用duration=0便于观察,通用Toast默认5秒规则不变。Popover/Menu/Tooltip增加可选open/onOpenChange/side用于受控展示;原有调用方式和默认行为保持不变,受控时须成对提供open与onOpenChange。
|
|
46
|
+
|
|
47
|
+
四向效果只匹配`.ds-motion-card`内的实际Radix浮层,通过最终data-side选方向。Portal仍挂最近主题根,逐例减少动态和慢放均继承到浮层。新增配方不写进业务页或全局组件CSS;不把候选标签改为生产能力。
|
|
48
|
+
|
|
49
|
+
实验室小于760px视口时,左右方向请求优先转换为bottom,再由Radix决定上下避让。实测两侧都容不下300px气泡时,仅依赖左右flip可能仍越界;不以组件库已有碰撞功能代替窄窗验收。
|
|
50
|
+
|
|
51
|
+
## 参数与中断约束
|
|
52
|
+
|
|
53
|
+
- 继续使用[tokens.css](./tokens.css):按下80ms、交互120ms、浮层160ms、面板180ms、通知/折叠200ms、进度/品牌240ms;Toast用面板180ms,Notice用通知200ms。正文与数值不做缩放或模糊。
|
|
54
|
+
- `--rhine-ease-standard`为`cubic-bezier(.2,0,0,1)`;Disclosure收起用现有exit曲线。`--rhine-motion-distance`为3px;新增配方先复用这些变量,不新增逐组件硬编码时长。
|
|
55
|
+
- 动画仅声明需要变化的属性,不用`transition: all`。一般用opacity/transform;高度测量仅用于必要的Disclosure布局变化,不用整页高度动画掩盖布局跳动。
|
|
56
|
+
- 快速反向从当前画面续接,旧动画取消;卸载清理动画和计时器。初始渲染、窗口恢复或普通重渲染不重复播放操作成功效果。
|
|
57
|
+
- 语义先于装饰:checked、aria-expanded、真实结果和焦点即时更新;内容淡入不延迟可读状态。不要为了动画同时保留两份可聚焦控件或重复live播报。
|
|
58
|
+
- 系统减少动态始终优先,手动减少动态也作用于Portal。关闭位移、脉冲和过渡后仍能识别加载、成功、失败;通知停留计时与视觉动画分别处理。
|
|
59
|
+
|
|
60
|
+
## 风格取舍
|
|
61
|
+
|
|
62
|
+
采用状态连续性、方向来源、短促反馈与可重播的展示方式。数字翻转、文字模糊、错误抖动、弹簧回弹、彩屑、液态菜单、3D倾斜及循环扫光不进入当前工作区规范;它们不帮助凭证选择或用量阅读。通用Logo保持240ms三角归位,不用装饰循环表示业务运行。用户另行要求的[启动编排](./STARTUP.md)允许品牌专属720ms/18px旋转展开归位;仅在展台演示,实际App就绪不得等待完整分镜。
|
|
63
|
+
|
|
64
|
+
参考站是设计来源,组件复用入口仍是本仓库。当前CSS/Web Animations及Radix已能承载上述方案,本次不引入额外动画库、CLI、远程Builder或评分流程。未来需要复用外部源码时单独核对许可与依赖,参考授权不等于已取得代码授权。
|
|
65
|
+
|
|
66
|
+
## 后续验收
|
|
67
|
+
|
|
68
|
+
每项候选先在真实组件展台展示,再更新实现状态。检查鼠标/键盘、快速反向、重复点击、加载成功与失败、长文本、400/520/880宽度、200%缩放、系统/手动减少动态;浮层额外检查四向避让与嵌套Escape。对比前后截图核对布局,对动画全过程录屏或采样核对时序,中间帧与最终帧分别记录。
|
|
69
|
+
|
|
70
|
+
首次内容切换不闪空白、不让操作目标跳位;刷新不丢旧数据;退出后无透明遮挡层和遗留焦点;无隐藏控件可Tab到。文档参数、DOM存在与单张截图不能代替完整动效或宿主原生平台原生验收。
|
|
71
|
+
|
|
72
|
+
2026-10-22展台验证:Chrome通过400/520/880/1200宽度、200%缩放、10组播放/复位、快速重播取消旧结果、四向位置/位移采样、菜单键盘、弹窗内重播保焦点与关闭归还、折叠中途反向、通知关闭、Tooltip Escape、逐例/全局/系统减少动态。采样确认4倍慢放的内容淡入720ms、反馈480ms、浮层640ms、折叠800ms及品牌960ms;未将合成浏览器结果扩展为原生或业务验收。
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { useLayoutEffect, useRef, useState, type ReactElement, type ReactNode } from 'react';
|
|
2
|
+
import * as DialogBase from '@radix-ui/react-dialog';
|
|
3
|
+
import * as AlertBase from '@radix-ui/react-alert-dialog';
|
|
4
|
+
import * as PopoverBase from '@radix-ui/react-popover';
|
|
5
|
+
import * as MenuBase from '@radix-ui/react-dropdown-menu';
|
|
6
|
+
import * as TooltipBase from '@radix-ui/react-tooltip';
|
|
7
|
+
import { Button, Notice } from './components';
|
|
8
|
+
import { IconButton } from './Actions';
|
|
9
|
+
|
|
10
|
+
// Keep portal content inside the root theme so tokens and reduced-motion overrides inherit.
|
|
11
|
+
function useScope() {
|
|
12
|
+
const ref = useRef<HTMLSpanElement>(null);
|
|
13
|
+
const [container, setContainer] = useState<HTMLElement>();
|
|
14
|
+
useLayoutEffect(() => { setContainer((ref.current?.closest('.rhine-theme') as HTMLElement) || document.body); }, []);
|
|
15
|
+
return { ref, container };
|
|
16
|
+
}
|
|
17
|
+
export interface DialogProps { trigger: ReactElement; title: string; description: string; children: ReactNode; open?: boolean; onOpenChange?: (open: boolean) => void }
|
|
18
|
+
export function Dialog({ trigger, title, description, children, open, onOpenChange }: DialogProps) {
|
|
19
|
+
const { ref, container } = useScope();
|
|
20
|
+
return <span className="rhine-overlay-anchor" ref={ref}><DialogBase.Root open={open} onOpenChange={onOpenChange}><DialogBase.Trigger asChild>{trigger}</DialogBase.Trigger><DialogBase.Portal container={container}>
|
|
21
|
+
<DialogBase.Overlay className="rhine-overlay" /><DialogBase.Content className="rhine-dialog"><div className="rhine-dialog__head"><DialogBase.Title className="rhine-text rhine-text--section">{title}</DialogBase.Title><DialogBase.Close asChild><IconButton label="关闭对话框" icon="close" /></DialogBase.Close></div><DialogBase.Description className="rhine-text rhine-text--body rhine-text--muted">{description}</DialogBase.Description><div className="rhine-dialog__body">{children}</div></DialogBase.Content>
|
|
22
|
+
</DialogBase.Portal></DialogBase.Root></span>;
|
|
23
|
+
}
|
|
24
|
+
export function ConfirmDialog({ trigger, title, description, confirmLabel = '确认操作', danger = false, onConfirm }: { trigger: ReactElement; title: string; description: string; confirmLabel?: string; danger?: boolean; onConfirm: () => Promise<void> | void }) {
|
|
25
|
+
const { ref, container } = useScope(); const [open, setOpen] = useState(false), [busy, setBusy] = useState(false), [error, setError] = useState(false);
|
|
26
|
+
const pending = useRef(false), mounted = useRef(true);
|
|
27
|
+
useLayoutEffect(() => { mounted.current = true; return () => { mounted.current = false; }; }, []);
|
|
28
|
+
const run = async () => {
|
|
29
|
+
if (pending.current) return;
|
|
30
|
+
pending.current = true; setBusy(true); setError(false);
|
|
31
|
+
try { await onConfirm(); if (mounted.current) setOpen(false); }
|
|
32
|
+
catch { if (mounted.current) setError(true); }
|
|
33
|
+
finally { pending.current = false; if (mounted.current) setBusy(false); }
|
|
34
|
+
};
|
|
35
|
+
return <span className="rhine-overlay-anchor" ref={ref}><AlertBase.Root open={open} onOpenChange={next => { if (!pending.current) { setOpen(next); setError(false); } }}><AlertBase.Trigger asChild>{trigger}</AlertBase.Trigger><AlertBase.Portal container={container}><AlertBase.Overlay className="rhine-overlay" /><AlertBase.Content className="rhine-dialog" aria-busy={busy} onEscapeKeyDown={event => { if (busy) event.preventDefault(); }}>
|
|
36
|
+
<AlertBase.Title className="rhine-text rhine-text--section">{title}</AlertBase.Title><AlertBase.Description className="rhine-text rhine-text--body rhine-text--muted">{description}</AlertBase.Description>{error && <Notice tone="error" title="操作未完成">请重试,或取消后检查。</Notice>}
|
|
37
|
+
<div className="rhine-dialog__actions"><AlertBase.Cancel asChild><Button variant="secondary" disabled={busy}>取消</Button></AlertBase.Cancel><Button variant={danger ? 'danger' : 'primary'} busy={busy} onClick={run}>{confirmLabel}</Button></div>
|
|
38
|
+
</AlertBase.Content></AlertBase.Portal></AlertBase.Root></span>;
|
|
39
|
+
}
|
|
40
|
+
interface OverlayControl { open?: boolean; onOpenChange?: (open: boolean) => void; side?: 'top' | 'right' | 'bottom' | 'left' }
|
|
41
|
+
export function Popover({ trigger, title, children, open, onOpenChange, side }: OverlayControl & { trigger: ReactElement; title: string; children: ReactNode }) {
|
|
42
|
+
const { ref, container } = useScope();
|
|
43
|
+
return <span className="rhine-overlay-anchor" ref={ref}><PopoverBase.Root open={open} onOpenChange={onOpenChange}><PopoverBase.Trigger asChild>{trigger}</PopoverBase.Trigger><PopoverBase.Portal container={container}><PopoverBase.Content className="rhine-popover" aria-label={title} side={side} sideOffset={8} collisionPadding={12}><div className="rhine-dialog__head"><strong>{title}</strong><PopoverBase.Close asChild><IconButton label="关闭详情" icon="close" /></PopoverBase.Close></div>{children}</PopoverBase.Content></PopoverBase.Portal></PopoverBase.Root></span>;
|
|
44
|
+
}
|
|
45
|
+
export interface MenuItem { id: string; label: string; disabled?: boolean; danger?: boolean; onSelect: () => void }
|
|
46
|
+
export function Menu({ trigger, label, items, open, onOpenChange, side }: OverlayControl & { trigger: ReactElement; label: string; items: MenuItem[] }) {
|
|
47
|
+
const { ref, container } = useScope();
|
|
48
|
+
return <span className="rhine-overlay-anchor" ref={ref}><MenuBase.Root open={open} onOpenChange={onOpenChange}><MenuBase.Trigger asChild>{trigger}</MenuBase.Trigger><MenuBase.Portal container={container}><MenuBase.Content className="rhine-menu" aria-label={label} side={side} sideOffset={8} collisionPadding={12}>{items.map(item => <MenuBase.Item key={item.id} disabled={item.disabled} onSelect={item.onSelect} className="rhine-menu__item" data-danger={item.danger}>{item.label}</MenuBase.Item>)}</MenuBase.Content></MenuBase.Portal></MenuBase.Root></span>;
|
|
49
|
+
}
|
|
50
|
+
export function Tooltip({ children, content, open, onOpenChange, side }: OverlayControl & { children: ReactElement; content: string }) {
|
|
51
|
+
const { ref, container } = useScope();
|
|
52
|
+
return <span className="rhine-overlay-anchor" ref={ref}><TooltipBase.Provider delayDuration={300}><TooltipBase.Root open={open} onOpenChange={onOpenChange}><TooltipBase.Trigger asChild>{children}</TooltipBase.Trigger><TooltipBase.Portal container={container}><TooltipBase.Content className="rhine-tooltip" side={side} sideOffset={8} collisionPadding={12}>{content}</TooltipBase.Content></TooltipBase.Portal></TooltipBase.Root></TooltipBase.Provider></span>;
|
|
53
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
# 莱茵设计 · 通用组件 V1.4
|
|
2
|
+
|
|
3
|
+
本目录提供[莱茵设计 · Rhine Design](../README.md)的 React 组件、设计变量和 API 契约。组件接收应用传入的数据与动作,不绑定特定后端或原生平台。包名为`@linhey/rhine-design`;样式变量和类名使用`rhine-`命名空间。
|
|
4
|
+
|
|
5
|
+
共40个组件。已有16个基础组件,新增24个数据、表单、操作、浮层和布局组件,见[常用组件契约](./COMMON_COMPONENTS.md)。[字体与文字规范](./TYPOGRAPHY.md)与[主流系统对照与缺口](../docs/DESIGN_SYSTEM_AUDIT.md)共同约束复用。静态页面样稿也使用同源Toolbar/Select CSS,不运行React。
|
|
6
|
+
|
|
7
|
+
页面改动遵循:优先复用组件 → 先在设计稿/展台修改并验证 → 再按任务授权实现到App。缺组件先补组件与展台,不在业务页另写同用途样式;同一任务已授权设计和实现时连续推进,无需重复确认。
|
|
8
|
+
|
|
9
|
+
## 使用
|
|
10
|
+
|
|
11
|
+
先按[套件接入说明](../README.md#quick-start)安装组件包,再导入:
|
|
12
|
+
|
|
13
|
+
```tsx
|
|
14
|
+
import { Button, Notice, Progress, Surface } from '@linhey/rhine-design';
|
|
15
|
+
|
|
16
|
+
export function Example() {
|
|
17
|
+
return (
|
|
18
|
+
<div className="rhine-theme">
|
|
19
|
+
<Surface>
|
|
20
|
+
<Notice tone="warning" title="需要处理">请检查后重试。</Notice>
|
|
21
|
+
<Progress label="任务进度" value={null} />
|
|
22
|
+
<Button variant="secondary" onClick={() => { /* 业务动作 */ }}>重试</Button>
|
|
23
|
+
</Surface>
|
|
24
|
+
</div>
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
入口 `index.ts` 自动导入组件 CSS。使用一个 `.rhine-theme` 包裹组件树,获得字体、盒模型、焦点和动效降级规则。主题根放在页面根部,不加transform、裁剪或contain;浮层Portal挂在最近的主题根以继承变量和减少动态偏好。独立运行与跨项目导入方式见[套件入口](../README.md);跨仓复用核对React19与套件package.json中的5个Radix依赖,不导入展台。
|
|
30
|
+
|
|
31
|
+
- `tokens.css`:唯一色彩、尺寸与动画变量来源。
|
|
32
|
+
- `styles.css`:`rhine-` 前缀组件样式,业务页面可组合布局但不覆盖组件状态。
|
|
33
|
+
- `components.tsx`:原有基础组件;`Actions/Data/Forms/Layout/Overlays/Toast.tsx`承载新增组件。表单与业务数据由调用方控制,浮层开关、通知计时及复制结果只管理局部交互。
|
|
34
|
+
- `../preview/`:使用同一组件实现的开发预览,独立 HTML 入口,无业务客户端依赖。
|
|
35
|
+
- [index.html](../index.html):Vite 开发入口,在套件根运行 `npm run dev` 后访问 `http://127.0.0.1:18309/`。
|
|
36
|
+
|
|
37
|
+
旧 `design/refactor/tokens.css` 只转引此处变量,避免页面样稿与组件库各维护一份颜色。
|
|
38
|
+
|
|
39
|
+
## 组件契约
|
|
40
|
+
|
|
41
|
+
| 组件 | 关键输入 | 状态与边界 |
|
|
42
|
+
| --- | --- | --- |
|
|
43
|
+
| Button | variant=primary/secondary/quiet/danger;busy;busyLabel;原生按钮属性 | 默认 type=button;busy 保留焦点并拦截再次 click,不自动把业务标记成功;disabled 使用原生禁用 |
|
|
44
|
+
| Logo | variant=mark/icon/lockup;theme=light/dark/auto;size;animate;decorative;label;wordmark | 第三版三角构架;默认静态、48px、浅色单标志;最小16px;无点击/业务状态 |
|
|
45
|
+
| Text | variant;as;tone;numeric;HTML属性 | 默认p/body;视觉层级不决定HTML语义;长文本可换行;不转换数值或截断文本 |
|
|
46
|
+
| Select | label;hint;error;原生单选select属性;option子节点 | 标签与描述自动关联;原生菜单和键盘;受控或defaultValue;不支持multiple/size列表模式;箭头装饰性且不接收点击 |
|
|
47
|
+
| Toolbar | title;description;children;HTML div属性 | 自适应控件组合,role=group与普通Tab顺序;不伪装为需要方向键导航的ARIA toolbar;不含业务状态 |
|
|
48
|
+
| Badge | tone=neutral/success/warning/error;children | 只读短状态,颜色必须配文字,不做可点击按钮 |
|
|
49
|
+
| Surface | HTML div 属性 | 静态分区,无点击语义;不是弹窗或导航 |
|
|
50
|
+
| SelectionCard | label;selected;onSelectedChange;disabled;children | 原生 checkbox 可取消;是否单选由上层控制;内部不嵌套按钮/链接/其他输入 |
|
|
51
|
+
| TextField | label;hint;error;原生 input 属性 | label 与 id 自动关联;错误与提示组成 aria-describedby;输入值由调用方管理 |
|
|
52
|
+
| Tabs | label;items[{value,label,content,disabled?}];value;onValueChange | value 必须指向有效可用项;唯一 value;左右/Home/End,跳过禁用项;隐藏面板保留挂载但退出可访问树 |
|
|
53
|
+
| SegmentedControl | label;items[{value,label,disabled?}];value;onValueChange | 原生 radio,独立 name 防多组干扰;方向键和禁用行为由浏览器提供 |
|
|
54
|
+
| Notice | tone;title;children;action | error 使用 alert,其余 status;调用方决定显示/移除,不自动倒计时关闭错误 |
|
|
55
|
+
| Progress | label;value:number/null;valueText? | null/NaN 为未知进度;数值钳制 0–100;组件不执行或估算真实进度 |
|
|
56
|
+
| Skeleton | label;lines | 1–8 行,默认 3 行;单一可访问加载描述,装饰行隐藏 |
|
|
57
|
+
| Disclosure | title;children;defaultOpen | 非受控原生 details;挂载后 defaultOpen 不作远程控制;支持开合中断与反向,关闭内容退出键盘顺序 |
|
|
58
|
+
| EmptyState | title;description;action | 说明当前状态并可给一个下一步动作;不将错误伪装成空数据 |
|
|
59
|
+
|
|
60
|
+
新增24项API与组合示例见[常用组件契约](./COMMON_COMPONENTS.md)。TextField封装单行输入,Select使用原生单选;需要查询候选时用Combobox。复杂浮层采用Radix Dialog、AlertDialog、Popover、DropdownMenu、Tooltip的无样式基础,莱茵设计提供视觉与动效。Toast只处理单条通知;不加入全局队列、表格引擎、图表包或业务数据层。
|
|
61
|
+
|
|
62
|
+
浏览状态不等于执行结果;页面使用宿主的真实状态决定selected/busy/notice,视觉反馈不能提前宣称业务完成。
|
|
63
|
+
|
|
64
|
+
## 动效规范
|
|
65
|
+
|
|
66
|
+
本节`/#…`相对独立展台服务根(默认18309)。
|
|
67
|
+
|
|
68
|
+
启动动画候选见[三角构架归位](./STARTUP.md)与`/#startup`。专属分镜不改变通用Logo入场参数,也不作为真实初始化等待门槛。
|
|
69
|
+
|
|
70
|
+
用户指定的Transitions.dev参考与莱茵设计取舍见[UX与动效补充](./MOTION.md)。`/#motion`已提供10组播放、重播、复位与4倍慢放示例,可逐例切换减少动态。方向展开、内容淡入与结果反馈仅在实验室作为候选实现;通用默认实现仍以下表和实际组件为准。
|
|
71
|
+
|
|
72
|
+
### 品牌标志
|
|
73
|
+
|
|
74
|
+
用户已选定第三版「三角构架」。[Logo.tsx](./Logo.tsx) 是复用入口,[assets/logo.svg](./assets/logo.svg) 是唯一几何母版;各主题与尺寸均引用同一组三块路径。`?no-inline` 保证生产构建保留可引用的 SVG 文件,避免外部 `<use>` 引用 data URL 的兼容问题。三块图形包围盒为 `(83.5,120.5)–(716.5,679.5)`,在 `800×800` 画布内左右各83.5、上下各120.5,深浅模式保持相同尺寸与中心。不要为深色版另裁图或分别平移路径。
|
|
75
|
+
|
|
76
|
+
```tsx
|
|
77
|
+
<Logo variant="icon" theme="dark" size={96} />
|
|
78
|
+
<Logo variant="lockup" size={40} />
|
|
79
|
+
<Logo size={80} animate />
|
|
80
|
+
// 旁边已有相同品牌文字时隐藏重复的辅助技术描述:
|
|
81
|
+
<Logo size={24} decorative />
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
`mark` 为透明单标志,`icon` 为圆角底板,`lockup` 为标志与可配置字标(默认 Rhine Design);`wordmark`同时提供默认可访问名称,`label`可单独覆盖。浅色为苔绿标志/暖白底;深色为暖白标志/苔绿底。`auto` 跟随系统配色,只有 Logo 改色,不代表组件库已有完整深色主题;透明形态的背景由调用者负责提供。标志画布外建议保留至少宽度的 1/8 安全间距,不拉伸、不旋转。小于24px只用单标志,不加字标。
|
|
85
|
+
|
|
86
|
+
默认没有动画。`animate` 在挂载时播放一次240ms三块归位,位移最大3 CSS px并淡入,使用 standard 缓动。普通重渲染不会重播;需要显式重播时更换 key。减少动态时直接呈现;动画不承载加载或成功状态。原生安装/托盘保持静态,当前尚未替换打包资产。
|
|
87
|
+
|
|
88
|
+
### 通用组件
|
|
89
|
+
|
|
90
|
+
所有时长集中于 tokens;没有弹簧或第三方动画依赖。注意 Web Animations 使用的 token 单位约定为 `ms`,不要替换为 `s`。
|
|
91
|
+
|
|
92
|
+
| 场景 | 时长 / 缓动 | 动画属性 | 规则 |
|
|
93
|
+
| --- | --- | --- | --- |
|
|
94
|
+
| Button 悬停 | 120ms / standard | 背景、边框 | 只在可用且非 busy 时生效 |
|
|
95
|
+
| Logo 入场 | 240ms / standard | 三块位移 ≤3px + opacity | 显式 animate 才播放一次;减少动态立即静态;不循环、不表示业务状态 |
|
|
96
|
+
| Button 按下 | 80ms / standard | translateY(1px) | 不缩放文字,不导致排版移动 |
|
|
97
|
+
| 键盘焦点 | 即时 | outline | 不做缓入,不等待动画完成才响应 |
|
|
98
|
+
| Button 加载 | 1000ms / linear | 小型 spinner 旋转 | 保留原文本占位保证宽度;busyLabel 给辅助技术 |
|
|
99
|
+
| SelectionCard | 120ms / standard | 背景、边框、内描边 | 不移动或抬起整张卡,不改变高度 |
|
|
100
|
+
| TextField | 120ms / standard | 背景、边框 | 错误文字即时出现,不抖动输入框 |
|
|
101
|
+
| Select | 120ms / standard | 背景、边框 | 原生菜单;焦点即时;箭头不因focus旋转;减少动态关闭过渡 |
|
|
102
|
+
| Text / Toolbar | 无 | 无 | 文字、数值不循环、缩放或逐字动画 |
|
|
103
|
+
| Tabs 下划线 | 180ms / standard | scaleX | 不用 JS 测量标签宽度;根据激活项展收 |
|
|
104
|
+
| Tabs 内容进入 | 180ms / standard | opacity + translateY(3px→0) | 切换立即生效;不等待旧内容退出 |
|
|
105
|
+
| SegmentedControl | 120ms / standard | 背景、文字颜色 | 不拖动指示层,避免多行/缩放错位 |
|
|
106
|
+
| Notice 进入 | 200ms / standard | opacity + translateY(3px→0) | 退出直接移除;重要错误不自动消失 |
|
|
107
|
+
| 已知 Progress | 240ms / standard | scaleX | 不为等待动画延迟真实数值;可从当前画面反向过渡 |
|
|
108
|
+
| 未知 Progress | 1000ms / standard | translateX | 永不显示虚假百分比,持续状态文字可见 |
|
|
109
|
+
| Skeleton | 1400ms / ease-in-out | opacity 1↔0.55 | 小幅明暗,不做扫光/闪烁;内容就绪后直接替换 |
|
|
110
|
+
| Disclosure 开合 | 200ms / standard;收起用 exit | 实测 height + 图标 rotation | 高度动画仅用于该必要布局变化;快速反向取消旧动画并从当前高度续接 |
|
|
111
|
+
| Badge / Surface / EmptyState | 无 | 无 | 静态基础内容不额外入场或循环动画 |
|
|
112
|
+
| Switch | 120ms / standard | 滑块位移、背景 | checked即时生效;减少动态立即定位 |
|
|
113
|
+
| Dialog / ConfirmDialog / Popover / Menu / Tooltip / Combobox | 160ms / standard | opacity,非模态最多3px位移 | 关闭立即归还焦点;Tooltip悬停延迟300ms,键盘无延迟 |
|
|
114
|
+
| Toast | 180ms / standard | opacity + translateY(3px→0) | 成功默认5秒;hover/focus/页面隐藏暂停;错误持久;计时不依赖动画 |
|
|
115
|
+
|
|
116
|
+
缓动变量:`--rhine-ease-standard: cubic-bezier(.2,0,0,1)`;`--rhine-ease-exit: cubic-bezier(.4,0,1,1)`。
|
|
117
|
+
|
|
118
|
+
### 降级与中断
|
|
119
|
+
|
|
120
|
+
- 系统 `prefers-reduced-motion: reduce` 始终优先;组件容器也可用 `data-rhine-motion="reduced"` 显式减少动态。
|
|
121
|
+
- 减少动态时过渡与循环动画全部关闭,直接到达最终状态。未知进度保留静态轨迹与「处理中」文字,骨架保留静态结构。
|
|
122
|
+
- Disclosure 的在途 Web Animation 在系统偏好变化或预览切换减少动态时取消并完成当前意图。没有 Web Animations 支持时立即展开/收起。
|
|
123
|
+
- 原生 details 保证完全关闭后隐藏内容不再接收 Tab。收起时把内部焦点送回 summary;卸载取消未完成动画。
|
|
124
|
+
- 动画不承诺异步任务一定成功;不使用定时器模拟业务完成。预览中的 900ms 保存仅为明确标记的本地演示。
|
|
125
|
+
- 组件不提供全局开启/关闭业务状态的开关;动画是视觉层的可选增强。
|
|
126
|
+
|
|
127
|
+
## 页面采用顺序
|
|
128
|
+
|
|
129
|
+
先 Button、TextField、Notice、Badge;再 Tabs、SegmentedControl 与 Surface;随后 SelectionCard、Progress、Skeleton、Disclosure、EmptyState。逐页替换时保持业务 DTO/动作调用不变。
|
|
130
|
+
|
|
131
|
+
组件不放账号字段、API-Key 格式化、服务端分页或系统路径逻辑;这些属于业务组件。新业务组合达到实际复用需求后再提取,不预先建通用数据层。
|
|
132
|
+
|
|
133
|
+
## 验证入口与边界
|
|
134
|
+
|
|
135
|
+
在仓库根执行npm run check。浏览器核对400/520/880/1200宽度、200%缩放、键盘/焦点、展开反向、减少动态中断与加载宽度。JSDOM不代替视觉或原生辅助技术验收。
|
|
136
|
+
|
|
137
|
+
本仓构建产物是独立展台;消费项目只导入组件入口,避免把preview中的模拟流程打入业务产物。版本变化和迁移见[CHANGELOG](../CHANGELOG.md)。
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# Rhine Design 启动编排 · 三角构架归位
|
|
2
|
+
|
|
3
|
+
这是[莱茵设计](../README.md)的可复用启动编排模式,独立演示入口为`http://127.0.0.1:18309/#startup`。宿主决定是否采用、字标和后续页面内容。
|
|
4
|
+
|
|
5
|
+
2026-10-22,设计候选,已在 `/#startup` 实现。用户要求启动动画建立莱茵风格记忆:暖白、苔绿、三角几何、细线定位与克制的工程秩序。品牌为原创 Rhine Design,沿用已定稿 V3 母版;不引入其他作品标志或新动画库。
|
|
6
|
+
|
|
7
|
+
## 分镜与参数
|
|
8
|
+
|
|
9
|
+
| 时间(原速) | 画面 | 目的 |
|
|
10
|
+
| --- | --- | --- |
|
|
11
|
+
| 0–320ms | 三角定位线绘出,三角中心保持固定 | 建立构架,保留大面积留白 |
|
|
12
|
+
| 180–1060ms | 左、右、底三块先旋转压缩,再展开归位,错开80ms | 用同一方向语法形成辨识度;单块720ms、最远18px |
|
|
13
|
+
| 1060–1340ms | 橙色定位点出现,辅助线退去 | 用短暂信号强调几何闭合,不表示业务成功 |
|
|
14
|
+
| 1080–1320ms | Rhine Design字标整体淡入 | 不拆字、不缩放、不模糊 |
|
|
15
|
+
| 1320–1600ms | 静态品牌留白 | 展台完整分镜观察区间,不是App最低等待时间 |
|
|
16
|
+
| 模拟就绪后 | 同一Logo用560ms收拢至88px并上移16px,字标上移84px;登录内容420ms淡入 | 保持中轴与品牌连续,操作立即可用,不保留透明遮挡层 |
|
|
17
|
+
|
|
18
|
+
启动是品牌专属配方:允许标志18px位移与720ms旋转展开,明确区别于通用Logo的240ms/3px入场。启动分镜与品牌演示卡共用 `preview/brand-assembly.css` 配方,覆盖仅匹配展台 `.ds-brand-assembly`;普通Logo、菜单、文字和业务组件保持原规则。使用现有standard曲线,无回弹、整体转圈、扫光、伪进度、音效或装饰循环。
|
|
19
|
+
|
|
20
|
+
旋转变形是用户2026-10-22追加的明确方向:左块−120°、右块+120°、底块+60°;每块围绕自身中心从横向压缩0.22倍展开至1倍,最后transform为单位矩阵。变形只用于中间帧,不修改SVG路径;品牌卡总时长880ms,启动版延后180ms开始。最终轮廓、朝向和中心均回到V3母版。
|
|
21
|
+
|
|
22
|
+
用户指出直接切换到左对齐大标题登录页过于生硬。修订后保留同一Logo DOM与同一字标,登录页沿原中轴构图,标题降至section层级;标志外层做560ms、`cubic-bezier(.22,1,.36,1)`收拢,内部三块可继续完成归位。舞台最小高度540px,普通就绪不产生高度跳变。文字不缩放;内容整体420ms、最多3px淡入,键盘聚焦内容时直接清晰呈现。1.6秒为品牌分镜,560ms为并行呈现可用登录内容时的视觉衔接,不额外阻塞操作。
|
|
23
|
+
|
|
24
|
+
## 播放与真实状态
|
|
25
|
+
|
|
26
|
+
- 初始为静态封面,用户主动播放;重播仅重建演示画面,控制按钮焦点保持。复位/切换场景/卸载取消旧计时。4倍慢放把整个合成分镜(含模拟就绪)放慢,便于审阅,不对应真实网络耗时。
|
|
27
|
+
- 正常场景在1600ms模拟就绪;慢启动不自动就绪,可点击「模拟就绪」;失败在1600ms显示可读错误,无自动重试。可随时模拟就绪中断编排,以检查早到结果。
|
|
28
|
+
- 减少动态(系统、全局或此例)直接呈现静态品牌;正常合成场景立即进入登录示例,慢启动仍等待明确就绪,失败仍显示错误。中途开启也立即取消视觉编排和对应演示计时。
|
|
29
|
+
- 宿主以真实初始化状态驱动:就绪即进入目标页面,错误即显示原因和恢复操作,不等待动画结束。慢启动不循环品牌,不用橙点表示连接成功。窗口恢复、Tab切换、普通重渲染不重播;最多冷启动一次。
|
|
30
|
+
- 展台只演示登录落点;登录按钮可鼠标/键盘激活,只显示本地演示说明,不执行登录、恢复会话、请求或写配置。正式App接入、原生冷启动白屏/窗口合成效果尚未验证。
|
|
31
|
+
|
|
32
|
+
## 验收
|
|
33
|
+
|
|
34
|
+
检查三块SVG引用同一母版、中心不漂移;原速/慢放真实中间帧;快速重播、复位后无旧结果;失败与等待可区分;系统/手动减少动态;400/520/880px、200%缩放;键盘控制焦点与屏幕阅读状态。待用户评审后再按授权迁移App。
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { useId, type SelectHTMLAttributes } from 'react';
|
|
2
|
+
|
|
3
|
+
export interface SelectProps extends Omit<SelectHTMLAttributes<HTMLSelectElement>, 'multiple' | 'size'> {
|
|
4
|
+
label: string;
|
|
5
|
+
hint?: string;
|
|
6
|
+
error?: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function Select({ label, hint, error, id: providedId, className = '', children, ...props }: SelectProps) {
|
|
10
|
+
const generatedId = useId();
|
|
11
|
+
const id = providedId || generatedId;
|
|
12
|
+
const description = [props['aria-describedby'], hint && `${id}-hint`, error && `${id}-error`].filter(Boolean).join(' ');
|
|
13
|
+
return <div className={`rhine-field ${className}`}>
|
|
14
|
+
<label htmlFor={id}>{label}</label>
|
|
15
|
+
<div className="rhine-select">
|
|
16
|
+
<select {...props} id={id} aria-invalid={error ? true : props['aria-invalid']} aria-describedby={description || undefined}>{children}</select>
|
|
17
|
+
<svg className="rhine-select__arrow" viewBox="0 0 16 16" aria-hidden="true" focusable="false"><path d="m4 6 4 4 4-4" /></svg>
|
|
18
|
+
</div>
|
|
19
|
+
{hint && <span id={`${id}-hint`} className="rhine-field__hint">{hint}</span>}
|
|
20
|
+
{error && <span id={`${id}-error`} className="rhine-field__error" role="alert">{error}</span>}
|
|
21
|
+
</div>;
|
|
22
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# 莱茵设计 · 字体与文字规范 V1.3
|
|
2
|
+
|
|
3
|
+
2026-10-22。适用于使用莱茵设计的中文界面与中英文混排。变量定义在 [tokens.css](./tokens.css),样式与 React 入口分别为 [typography.css](./typography.css)、[Typography.tsx](./Typography.tsx)。归属与接入见[套件入口](../README.md)。
|
|
4
|
+
|
|
5
|
+
## 字体与字重
|
|
6
|
+
|
|
7
|
+
- 界面:`-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', 'Noto Sans CJK SC', sans-serif`。使用平台已有字体;未安装的字体按顺序回退,不下载字体文件。
|
|
8
|
+
- 代码与路径:`ui-monospace, SFMono-Regular, Menlo, Consolas, 'Liberation Mono', monospace`。模型 ID、路径、协议值用等宽;普通英文说明仍用界面字体。
|
|
9
|
+
- 常规400、标签500、标题600。避免轻字重、小字号粗细堆叠、650等中间字重和浏览器伪斜体;不全局使用700。Logo字标遵循品牌组件,不作为业务文字模板。
|
|
10
|
+
- 系统字体选择与语义字号层级参考 [Fluent Typography](https://fluent2.microsoft.design/typography);具体尺寸和中文规则是本项目的设计决策,不复制其全部字号。
|
|
11
|
+
- `.rhine-theme` 禁止字体伪造;不同系统的字面宽度可能不同,不能用固定字符串宽度或固定容器高度实现布局。
|
|
12
|
+
|
|
13
|
+
## 文字层级
|
|
14
|
+
|
|
15
|
+
| Text variant | 字号 / 行高 | 字重 | 使用场景 |
|
|
16
|
+
| --- | --- | --- | --- |
|
|
17
|
+
| title | 30 / 38px | 600 | 登录或独立引导标题;紧凑工作区不重复放大标题 |
|
|
18
|
+
| section | 18 / 26px | 600 | 主要内容分区 |
|
|
19
|
+
| heading | 16 / 24px | 600 | 卡片身份、次级分组 |
|
|
20
|
+
| body | 14 / 22px | 400 | 正文、解释 |
|
|
21
|
+
| label | 14 / 22px | 500 | 表单标签与主要操作 |
|
|
22
|
+
| caption | 12 / 18px | 400 | 时间、来源、辅助说明 |
|
|
23
|
+
| overline | 12 / 18px | 500 | 少量分类标签;中文正常字距;`lang="en"` 时0.08em字距 |
|
|
24
|
+
| metric | 26 / 32px | 600 | 关键数值,等宽数字;窄窗组合可切换20 / 26px变量 |
|
|
25
|
+
| code | 12 / 20px | 400 | 路径、模型标识、技术值 |
|
|
26
|
+
|
|
27
|
+
不低于12px承载业务信息。展台的宣传型大标题不属于 App 正文尺度。字号不是HTML语义:Text默认渲染p,按结构显式选择 `as="h1" / "h2" / "h3"`;不要为了字号跳过标题级别。段落间距由布局负责,不用空行字符撑开。
|
|
28
|
+
|
|
29
|
+
```tsx
|
|
30
|
+
<Text as="h2" variant="section">今日用量</Text>
|
|
31
|
+
<Text variant="body">选择 OAuth 账号后开启自动同步。</Text>
|
|
32
|
+
<Text variant="caption" tone="muted">最近检查 14:32 · 上海</Text>
|
|
33
|
+
<Text as="span" variant="metric" numeric>1,280</Text>
|
|
34
|
+
<Text as="code" variant="code">gpt-6.1-sol</Text>
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## 混排、数字与单位
|
|
38
|
+
|
|
39
|
+
- 中文正文不增加字距,不做两端强制分散;段落左对齐,长说明建议不超过60ch。居中仅用于简短空态与品牌展示。
|
|
40
|
+
- 中文与独立英文/数字之间保留一个半角空格,例如「已完成 128 次请求」「使用 API-Key」。中文叙述用中文标点;模型 ID、路径、代码保持原样,不插入空格或改变大小写。
|
|
41
|
+
- 数量、时间、费用使用 `tabular-nums lining-nums`,数字表列右对齐,文本列左对齐。等宽数字不等于必须改用等宽字体。
|
|
42
|
+
- 产品统一显示:整数 `1,280`;概览缩写 `1.4M`;额度 `76%`;时长 `2.4 s`;日期 `2026-10-22`;时间 `14:32`。跨日或跨时区信息补全日期与「上海 / UTC+8」。业务确认的货币用明确币种或货币符号,不凭空换算。
|
|
43
|
+
- 同一指标的小数位固定:费用概览示例 `$5.44`、明细示例 `$0.0384`;展示格式由业务层按精度约定决定,Text不舍入数据。概览缩写应能从明细获取精确值;非零小额不可格式化成误导的零。
|
|
44
|
+
- 未知显示「— / 暂不可用」,不能与0混淆;「预估」「部分采集」「更新时间」是必要信息,不因视觉简洁删除。
|
|
45
|
+
|
|
46
|
+
## 换行与截断
|
|
47
|
+
|
|
48
|
+
- 标题、标签、错误、帮助文字允许换行;不以缩小字号、固定高度裁切或跑马灯解决溢出。长ID或路径可用 `overflow-wrap:anywhere` 视觉换行,复制内容保持完整。
|
|
49
|
+
- 重要状态、错误原因、按钮动作、当前选项不做省略号。非关键信息仅在已有可访问的展开/详情入口时允许单行截断;只加 `title` 不能代替完整内容入口。
|
|
50
|
+
- 原生Select当前值保持足够空间:至少为箭头预留40px右内边距;过长选项优先缩短文案或增加控件宽度。选项列表交给系统,不能通过CSS保证所有平台菜单一致。
|
|
51
|
+
- 200%浏览器缩放必须不丢内容;页面允许纵向增加并重排。桌面400px最小窗与缩放分别验收。纯文本放大、VoiceOver/Narrator及Windows原生显示是额外验收项,不能由Chrome截图推定通过。
|
|
52
|
+
|
|
53
|
+
## 文案与语义
|
|
54
|
+
|
|
55
|
+
- 产品名、实体名称与专业术语由宿主统一;配置、浏览状态与实际运行状态分别表达。
|
|
56
|
+
- 按钮用明确动词:「在浏览器中登录」「重新应用」「停止同步」「重试」。避免含义不明的「确定」「点击这里」。
|
|
57
|
+
- 状态写事实;失败写「发生了什么 + 可执行下一步」,例如「暂时无法读取列表。请检查连接后重试。」不先宣称成功,不在用户文案里展示内部堆栈或秘密。
|
|
58
|
+
- 表单标签始终可见,placeholder只作例子。提示和错误通过 `aria-describedby` 关联;错误同时具备文字和视觉边界。
|
|
59
|
+
- 文字颜色只能增强语义:主文、次文、成功、警告、错误均需可读文本。链接应有可辨识样式与键盘焦点;图标按钮需要可访问名称。
|
|
60
|
+
- 文本与工具栏不做入场动画;错误立即显示;数字更新不滚动计数。Select仅背景/边框120ms过渡,焦点即时,箭头保持向下,不因聚焦伪装菜单已打开。
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from 'react';
|
|
2
|
+
import { IconButton } from './Actions';
|
|
3
|
+
import type { Tone } from './components';
|
|
4
|
+
|
|
5
|
+
export function Toast({ open, onOpenChange, title, description, tone = 'success', duration = 5000 }: {
|
|
6
|
+
open: boolean; onOpenChange: (open: boolean) => void; title: string; description?: string; tone?: Tone; duration?: number;
|
|
7
|
+
}) {
|
|
8
|
+
const [hover, setHover] = useState(false), [focus, setFocus] = useState(false), [hidden, setHidden] = useState(false);
|
|
9
|
+
const remaining = useRef(duration), close = useRef(onOpenChange);
|
|
10
|
+
close.current = onOpenChange;
|
|
11
|
+
useEffect(() => { const update = () => setHidden(document.hidden); update(); document.addEventListener('visibilitychange', update); return () => document.removeEventListener('visibilitychange', update); }, []);
|
|
12
|
+
useEffect(() => { remaining.current = duration; }, [open, duration, title, description, tone]);
|
|
13
|
+
useEffect(() => { if (!open) { setHover(false); setFocus(false); } }, [open]);
|
|
14
|
+
useEffect(() => {
|
|
15
|
+
if (!open || hover || focus || hidden || tone === 'error' || tone === 'warning' || duration <= 0) return;
|
|
16
|
+
const started = Date.now(); const timer = setTimeout(() => close.current(false), Math.max(0, remaining.current));
|
|
17
|
+
return () => { clearTimeout(timer); remaining.current = Math.max(0, remaining.current - (Date.now() - started)); };
|
|
18
|
+
}, [open, hover, focus, hidden, tone, duration, title, description]);
|
|
19
|
+
return <div className="rhine-toast-region" aria-label="通知">
|
|
20
|
+
<div role={tone === 'error' ? 'alert' : 'status'} aria-atomic="true" className={open ? `rhine-toast rhine-tone-${tone}` : 'rhine-sr-only'}
|
|
21
|
+
onMouseEnter={() => setHover(true)} onMouseLeave={() => setHover(false)} onFocus={() => setFocus(true)} onBlur={event => { if (!event.currentTarget.contains(event.relatedTarget)) setFocus(false); }}>
|
|
22
|
+
{open && <><div><strong>{title}</strong>{description && <p>{description}</p>}</div><IconButton label="关闭通知" icon="close" onClick={() => onOpenChange(false)} /></>}
|
|
23
|
+
</div>
|
|
24
|
+
</div>;
|
|
25
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { HTMLAttributes, ReactNode } from 'react';
|
|
2
|
+
|
|
3
|
+
export interface ToolbarProps extends Omit<HTMLAttributes<HTMLDivElement>, 'title'> {
|
|
4
|
+
title: string;
|
|
5
|
+
description?: ReactNode;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
// A native-control group keeps normal Tab navigation, not toolbar arrow-key semantics.
|
|
9
|
+
export function Toolbar({ title, description, children, className = '', ...props }: ToolbarProps) {
|
|
10
|
+
return <div {...props} role="group" aria-label={props['aria-label'] || title} className={`rhine-toolbar ${className}`}>
|
|
11
|
+
<div className="rhine-toolbar__intro"><strong className="rhine-text rhine-text--label">{title}</strong>{description && <p className="rhine-text rhine-text--caption rhine-text--muted">{description}</p>}</div>
|
|
12
|
+
<div className="rhine-toolbar__controls">{children}</div>
|
|
13
|
+
</div>;
|
|
14
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { HTMLAttributes } from 'react';
|
|
2
|
+
|
|
3
|
+
export type TextVariant = 'title' | 'section' | 'heading' | 'body' | 'label' | 'caption' | 'overline' | 'metric' | 'code';
|
|
4
|
+
export interface TextProps extends HTMLAttributes<HTMLElement> {
|
|
5
|
+
as?: 'p' | 'span' | 'h1' | 'h2' | 'h3' | 'h4' | 'strong' | 'code';
|
|
6
|
+
variant?: TextVariant;
|
|
7
|
+
tone?: 'default' | 'muted' | 'success' | 'warning' | 'error';
|
|
8
|
+
numeric?: boolean;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function Text({ as: Element = 'p', variant = 'body', tone = 'default', numeric = false, className = '', ...props }: TextProps) {
|
|
12
|
+
return <Element {...props} className={`rhine-text rhine-text--${variant} rhine-text--${tone}${numeric ? ' rhine-text--numeric' : ''} ${className}`} />;
|
|
13
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 800" fill="currentColor">
|
|
2
|
+
<path id="left" d="M406.5 120.5 476.5 242.5 307.5 535.5H158.5Z"/>
|
|
3
|
+
<path id="right" d="M486.5 275.5 716.5 660.5H567.5L415.5 397.5Z"/>
|
|
4
|
+
<path id="base" d="M159.5 559.5H475.5L544.5 679.5H83.5Z"/>
|
|
5
|
+
</svg>
|