@linhey/rhine-design 0.2.0 → 0.4.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.
Files changed (70) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/CONTRIBUTING.md +6 -0
  3. package/README.md +70 -26
  4. package/components/Actions.tsx +6 -4
  5. package/components/COMMON_COMPONENTS.md +5 -1
  6. package/components/Data.tsx +8 -8
  7. package/components/FormWorkflow.tsx +25 -0
  8. package/components/Forms.tsx +11 -11
  9. package/components/Layout.tsx +4 -4
  10. package/components/Logo.tsx +7 -7
  11. package/components/MOTION.md +7 -3
  12. package/components/Navigation.tsx +42 -0
  13. package/components/Overlays.tsx +17 -11
  14. package/components/README.md +19 -11
  15. package/components/SPACING.md +82 -0
  16. package/components/Select.tsx +5 -5
  17. package/components/StatusTimeline.tsx +29 -0
  18. package/components/TYPOGRAPHY.md +35 -3
  19. package/components/Toast.tsx +2 -2
  20. package/components/Toolbar.tsx +3 -3
  21. package/components/Typography.tsx +1 -1
  22. package/components/common.css +78 -69
  23. package/components/components.tsx +28 -28
  24. package/components/index.ts +7 -1
  25. package/components/patterns.css +39 -0
  26. package/components/styles.css +105 -101
  27. package/components/theme-arknights.css +301 -0
  28. package/components/theme-neo-brutalism.css +84 -0
  29. package/components/theme.tsx +58 -0
  30. package/components/timeline.css +23 -0
  31. package/components/tokens.css +61 -56
  32. package/components/typography.css +31 -31
  33. package/docs/DESIGN_SYSTEM.md +24 -13
  34. package/docs/DESIGN_SYSTEM_AUDIT.md +4 -4
  35. package/docs/INTERACTION_PATTERNS.md +129 -0
  36. package/docs/REFERENCE_RESEARCH.md +36 -0
  37. package/docs/THEME_NEO_BRUTALISM.md +37 -0
  38. package/docs/THEMING.md +104 -0
  39. package/package.json +5 -1
  40. package/preview/CommonGallery.tsx +8 -8
  41. package/preview/GalleryShell.tsx +106 -0
  42. package/preview/LineHeightGallery.tsx +27 -0
  43. package/preview/MotionGallery.tsx +9 -5
  44. package/preview/PatternGallery.tsx +52 -0
  45. package/preview/SpacingGallery.tsx +32 -0
  46. package/preview/StartupGallery.tsx +1 -1
  47. package/preview/SubscriptionGallery.tsx +84 -0
  48. package/preview/brand-assembly.css +8 -8
  49. package/preview/line-height.css +2 -0
  50. package/preview/main.tsx +39 -25
  51. package/preview/motion.css +14 -14
  52. package/preview/patterns.css +7 -0
  53. package/preview/preview.css +15 -21
  54. package/preview/shell.css +35 -0
  55. package/preview/spacing.css +2 -0
  56. package/preview/startup.css +15 -15
  57. package/preview/subscriptions.css +27 -0
  58. package/preview/theme-arknights.css +234 -0
  59. package/preview/theme-neo-brutalism.css +11 -0
  60. package/scripts/check-build.mjs +1 -1
  61. package/skills/rhine-design/SKILL.md +6 -4
  62. package/skills/rhine-design/references/API_MAP.md +7 -3
  63. package/skills/rhine-design/references/TASK_MAP.md +4 -1
  64. package/skills/rhine-design/references/integration.md +6 -4
  65. package/tests/CommonComponents.test.tsx +1 -1
  66. package/tests/GalleryShell.test.tsx +98 -0
  67. package/tests/Patterns.test.tsx +75 -0
  68. package/tests/StartupGallery.test.tsx +3 -3
  69. package/tests/StatusTimeline.test.tsx +15 -0
  70. package/tests/Theme.test.tsx +112 -0
package/CHANGELOG.md CHANGED
@@ -1,5 +1,44 @@
1
1
  # 版本记录
2
2
 
3
+ ## 0.4.0
4
+
5
+ - 新增 `@linhey/rhine-design/theme`:`ThemeProvider`、`useTheme`、`themes`、`ThemeName` 与 `ThemeProviderProps`,自动加载三套内置主题,支持受控/非受控切换及并列 Provider;嵌套覆盖暂不支持。切换保留组件、表单与浮层状态,偏好存储由宿主管理。展台复用公共 API;主题 CSS 中的展台布局和 HUD/扫描/开机装饰移至 `preview/theme-*.css`。补齐接入文档及 API/展台回归测试。
6
+
7
+ - 新增「橙趣 · 粗描边」主题 `theme-neo-brutalism.css`:浅灰蓝点阵、米白大圆角、黑色粗描边、无模糊硬阴影、橙色主操作与低饱和类型标签。展台主题切换器支持选择与记忆,`?theme=neo-brutalism#subscriptions` 可直达单页订阅示例;复用 Dialog/Menu/DataTable 展示卡片详情、管理菜单和可更新的演示数量与金额。可单独导入。
8
+
9
+ - 新增明日方舟·战术终端主题 `theme-arknights.css`:暗色舱底、平行四边形按钮、信号黄高亮、坐标网格与警示斜纹,附 HUD 取景框、终端读数与扫描/开机装饰动效;仅覆盖变量与装饰层,不改组件 API 与交互契约,减少动态下装饰动效全部关闭。展台右上角可切换主题并记忆选择。
10
+
11
+ - 将密集任务浮层经验整理为通用规范:自然高度/单层滚动、导航行、渐进展示、视觉与命中区分离,以及选择即保存的适用边界;补充临界溢出和末条详情验收要求。本项仅整理文档,不代表发布新API。
12
+
13
+ - compact健康条将20px视觉高度与44px命中区分开,避免列表重复叠加空白;间距规范补充标题8px、分隔线两侧16px及操作区避让规则。
14
+
15
+ - Dialog的sidePanel支持渲染函数,将传入的唯一关闭按钮与侧栏标题、说明组合成顶部导航;取消额外关闭行,左右标题对齐,展台同步示例。
16
+
17
+ - 健康时间轴使用独立明亮health色;新增compact模式,重复列表隐藏常驻日期/详情,悬停与键盘聚焦时查看,保留44px操作区和aria-valuetext。规范补充“选择即保存”的忙碌/确认/失败契约,默认与紧凑展台同步更新。
18
+
19
+ - 新增 StatusTimeline / TimelineLegend,源码组件增至48个。五类历史证据、44px键盘/指针操作区、时间文字与图例;不计算健康评分,不对未知补绿色,不播放装饰动画。监控场景展台可交互查看。
20
+ - Dialog新增可选sidePanel:左侧主任务、右侧关联预览,最大960px、24px分栏间距,720px及以下上下排列;保留同一个模态焦点范围、关闭与减少动态规则。展台提供左右分栏预览。
21
+
22
+ - SideNav支持可选20px线性图标(Icon新增connection/chart),移除当前项的外链箭头;采用44px最小操作行、14/22文字、4px行间距与3px圆角,选中状态保留苔绿底/左侧细边界和aria-current。导航展台同步示例;已有不带图标用法继续可用。
23
+
24
+ - SelectionCard统一24px内边距、16/24标题、600字重与8px标题/内容间隔;DataTable标题显式16/24。SideNav分组、Breadcrumbs、Progress标签及KeyValue标签明确12/18,侧栏分组标题后8px。既有真实展台同步展示,不再继承正文行高。
25
+
26
+ - 补充文字行高、段落与控件规则及可切换行框辅助线的多行展台。沿用现有字号/行高token,按钮、Tab和单行输入同步消费body行高,默认尺寸不变。
27
+
28
+ - 新增间距规范与`#spacing`真实组合展台:24/8/16卡片配方,默认/加载/错误/长文案;复用Stack/Inline/Grid,公共API和组件数量不变。Surface内边距改为消费既有space-6 token,默认外观不变。
29
+
30
+ - 新增 SideNav、Breadcrumbs、Steps、Drawer、Fieldset、ErrorSummary,main 公共组件增至46个;展台自身复用导航组件。
31
+ - 新增导航与步骤、表单流程、14类场景与交互选型章节,动效实验室增加抽屉与步骤播放/重播,总计12组。
32
+ - 参考 GitHub 高星项目与 skills,沉淀组件选择、错误恢复、监控告警、动效与无障碍契约;不增加运行时依赖。
33
+
34
+ - 在线展台改为侧栏目录与单章节工作区,支持章节深链接、历史导航、当前项高亮、独立滚动与窄屏折叠目录;全局动效偏好跨章节保留,离开的演示卸载。
35
+
36
+ ## 0.3.0
37
+
38
+ - **破坏性变更:**统一使用莱茵设计的 `rhine-` 命名空间,无旧前缀别名。
39
+ - 升级时将主题根 `.tod-theme` 改为 `.rhine-theme`,CSS 变量 `--tod-*` 改为 `--rhine-*`,类名及关键帧 `tod-*` 改为 `rhine-*`,减少动态属性 `data-tod-motion` 改为 `data-rhine-motion`。请同步更新自定义样式、DOM 查询和复制到项目中的 skill。
40
+ - 组件、浮层主题挂载、动效展台、文档与测试一起迁移;React 导出与 props、视觉参数保持一致。
41
+
3
42
  ## 0.2.0
4
43
 
5
44
  - 定位为个人通用设计系统 linhay/rhine-design,独立迭代。
package/CONTRIBUTING.md CHANGED
@@ -42,3 +42,9 @@ README 面向首次接触项目的开发者,优先解释用途、安装、示
42
42
  问题反馈请提供包版本、组件名、预期与实际行为、最小复现。删除复现中的私人数据和凭据。
43
43
 
44
44
  维护者发布步骤见 [RELEASING](docs/RELEASING.md)。项目采用 [Apache-2.0](LICENSE) 许可证。
45
+
46
+ ## 在线展台
47
+
48
+ [GitHub Pages](https://linhay.github.io/rhine-design/) 展示 `main` 的组件与演示。现有 CI 先执行 `npm run check`,成功后只上传 `dist/` 并部署;Pull Request 运行检查,不发布站点。工作流也支持在 `main` 手动重跑。
49
+
50
+ Vite 使用相对资源路径,兼容 `/rhine-design/` 子路径。调整构建或静态资源时,检查在线页面、CSS、JS 和 Logo SVG 均能正常加载,以及 `#motion`、`#startup` 直接访问和重播。
package/README.md CHANGED
@@ -7,6 +7,7 @@
7
7
  <p align="center">暖白与苔绿,三角与细线。让界面拥有清晰的秩序与克制的节奏。</p>
8
8
 
9
9
  <p align="center">
10
+ <a href="https://linhay.github.io/rhine-design/">在线预览</a> ·
10
11
  <a href="#quick-start">快速接入</a> ·
11
12
  <a href="#components">组件</a> ·
12
13
  <a href="#documentation">设计规范</a> ·
@@ -15,13 +16,15 @@
15
16
  <a href="CHANGELOG.md">版本记录</a>
16
17
  </p>
17
18
 
18
- Rhine Design 是面向 React 应用的开源设计系统,适合桌面工具、管理界面和数据密集型应用。它将设计规范、40 个组件和动效示例放在一起,帮助团队建立一致、可复用的界面语言。
19
+ Rhine Design 是面向 React 应用的开源设计系统,适合桌面工具、管理界面和数据密集型应用。它将设计规范、48 个组件和动效示例放在一起,帮助团队建立一致、可复用的界面语言。
19
20
 
20
21
  - **一致的视觉语言**:色彩、字体、文字层级、间距、几何与焦点规则。
21
- - **40 个可组合组件**:覆盖布局、表单、数据、导航、浮层和反馈。
22
- - **可验证的动效**:10 组播放 / 重播 / 复位演示,以及三角构架启动编排。
22
+ - **48 个可组合组件**:覆盖布局、表单、数据、导航、浮层和反馈。
23
+ - **可验证的动效**:12 组播放 / 重播 / 复位演示,以及三角构架启动编排。
23
24
  - **面向开发协作**:组件契约、交互展台和 AI skill 帮助设计与实现保持一致。
24
25
 
26
+ **0.4.0 新增能力:**主题 API、三套内置主题,以及导航、表单流程与健康时间轴组件;在线展台跟随 main,安装前请核对[版本记录](CHANGELOG.md)。新增组件及选型见[交互规范](docs/INTERACTION_PATTERNS.md),参考项目见[GitHub 调研](docs/REFERENCE_RESEARCH.md)。
27
+
25
28
  基于 React 与 Radix UI,采用 [Apache-2.0](LICENSE) 许可证。
26
29
 
27
30
  <a id="quick-start"></a>
@@ -30,25 +33,29 @@ Rhine Design 是面向 React 应用的开源设计系统,适合桌面工具、
30
33
 
31
34
  需要 **React 19**。当前已验证的构建环境为 **Vite**,开发建议使用 **Node 24**。
32
35
 
33
- 首次 npm 发布尚未完成,目前可在你的 React 项目中通过公开仓库安装:
36
+ 在你的 React 项目中安装:
34
37
 
35
38
  ```sh
36
- npm install git+https://github.com/linhay/rhine-design.git
39
+ npm install @linhey/rhine-design@0.4.0
37
40
  ```
38
41
 
39
- 包的导入名称为 `@linhey/rhine-design`。正式项目建议在 Git URL 后附加 `#<commit>` 固定源码版本,并提交 lockfile。
42
+ 包与版本信息见 [npm](https://www.npmjs.com/package/@linhey/rhine-design)。如需使用未发版的源码,可安装 `git+https://github.com/linhay/rhine-design.git#<commit>`;建议固定版本并提交 lockfile。
40
43
 
41
44
  ```tsx
42
- import { Button, Stack, Surface, Text } from '@linhey/rhine-design';
45
+ import { Button, Inline, Stack, Surface, Text } from '@linhey/rhine-design';
43
46
 
44
47
  export function Welcome() {
45
48
  return (
46
- <div className="tod-theme">
49
+ <div className="rhine-theme">
47
50
  <Surface>
48
51
  <Stack gap={4}>
49
- <Text as="h1" variant="title">开始新的工作</Text>
50
- <Text tone="muted">用统一的组件,构建属于你的工作空间。</Text>
51
- <Button onClick={() => { /* 在这里处理点击 */ }}>继续</Button>
52
+ <Stack gap={2}>
53
+ <Text as="h1" variant="title">开始新的工作</Text>
54
+ <Text tone="muted">用统一的组件,构建属于你的工作空间。</Text>
55
+ </Stack>
56
+ <Inline gap={2}>
57
+ <Button onClick={() => { /* 在这里处理点击 */ }}>继续</Button>
58
+ </Inline>
52
59
  </Stack>
53
60
  </Surface>
54
61
  </div>
@@ -56,14 +63,42 @@ export function Welcome() {
56
63
  }
57
64
  ```
58
65
 
59
- 入口自动加载样式,用 `.tod-theme` 包裹页面即可使用默认主题。布局和颜色变量使用 `--tod-*` 前缀;完整定义见 [tokens.css](components/tokens.css)。
66
+ 入口自动加载样式,用 `.rhine-theme` 包裹页面即可使用默认主题。布局和颜色变量使用 `--rhine-*` 前缀;完整定义见 [tokens.css](components/tokens.css)。
67
+
68
+ ### 一键切换主题(0.4.0)
69
+
70
+ 展台右上角可切换并记忆主题。新增 **橙趣 · 粗描边**:浅灰蓝点阵背景、米白圆角卡片、黑色粗边、向右下偏移的硬阴影与亮橙主按钮。打开 [在线订阅管理示例](https://linhay.github.io/rhine-design/?theme=neo-brutalism#subscriptions),体验卡片详情、管理菜单与演示表单;本地开发访问 `http://127.0.0.1:18309/?theme=neo-brutalism#subscriptions`。
71
+
72
+ 从 0.4.0 接入,入口自动加载内置主题,页面只需包裹一次:
73
+
74
+ ```tsx
75
+ import { Button, Surface } from '@linhey/rhine-design';
76
+ import { ThemeProvider, useTheme } from '@linhey/rhine-design/theme';
77
+
78
+ function Appearance() {
79
+ const { setTheme } = useTheme();
80
+ return <Button onClick={() => setTheme('arknights')}>切换战术主题</Button>;
81
+ }
82
+
83
+ export function App() {
84
+ return <ThemeProvider defaultTheme="neo-brutalism">
85
+ <Surface><Appearance /></Surface>
86
+ </ThemeProvider>;
87
+ }
88
+ ```
89
+
90
+ 受控模式、主题列表、存储职责、浮层继承和迁移方式见 [主题 API 接入](docs/THEMING.md)。第一版支持并列 Provider,不支持嵌套覆盖;偏好由宿主管理,库不读写本地存储。此 API 自 0.4.0 提供。
91
+
92
+ 完整变量、类型标签、交互与减少动态规则见 [橙趣主题规范](docs/THEME_NEO_BRUTALISM.md)。现有 CSS-only 用法仍支持;展台装饰独立放在 `preview/theme-*.css`。
93
+
94
+ 类名、CSS 变量、动画名称与动态设置统一使用 `rhine-` 命名空间。升级到 0.3.0 时请同步迁移主题根、自定义样式与动效属性,具体见 [迁移说明](CHANGELOG.md#030)。
60
95
 
61
96
  ### 兼容范围
62
97
 
63
98
  - 包导出 TS/TSX、CSS 和 SVG 源码,构建器需支持 CSS 导入与 Vite 的 SVG `?no-inline` 引用;目前未验证其他构建器和 SSR。
64
- - 浮层挂载到最近的 `.tod-theme`。请将主题根放在页面根部,避免对它使用 `transform`、裁剪或 `contain`。
99
+ - 浮层挂载到最近的 `.rhine-theme`。请将主题根放在页面根部,避免对它使用 `transform`、裁剪或 `contain`。
65
100
  - 组件提供呈现与交互,应用负责数据请求、搜索、分页和操作结果。
66
- - 当前提供默认暖白主题;Logo 支持独立的深浅配色,完整组件深色主题尚未提供。
101
+ - 默认使用暖白主题;0.4.0 还提供可选的战术终端与橙趣主题。Logo 的深浅配色独立于界面主题。
67
102
 
68
103
  <a id="components"></a>
69
104
 
@@ -74,28 +109,37 @@ export function Welcome() {
74
109
  | 品牌与文字 | Logo · Text |
75
110
  | 操作 | Button · Icon · IconButton · Link · CopyButton |
76
111
  | 布局 | Surface · Toolbar · Divider · Stack · Inline · Grid |
77
- | 表单与选择 | TextField · Textarea · Select · Checkbox · RadioGroup · Switch · SearchField · Combobox · SelectionCard |
78
- | 导航与展开 | Tabs · SegmentedControl · Disclosure |
79
- | 数据 | Metric · KeyValue · DataTable · Pagination |
80
- | 浮层 | Dialog · ConfirmDialog · Popover · Menu · Tooltip |
112
+ | 表单与选择 | TextField · Fieldset¹ · ErrorSummary¹ · Textarea · Select · Checkbox · RadioGroup · Switch · SearchField · Combobox · SelectionCard |
113
+ | 导航与展开 | SideNav¹ · Breadcrumbs¹ · Steps¹ · Tabs · SegmentedControl · Disclosure |
114
+ | 数据 | Metric · KeyValue · DataTable · Pagination · StatusTimeline¹ · TimelineLegend¹ |
115
+ | 浮层 | Drawer¹ · Dialog · ConfirmDialog · Popover · Menu · Tooltip |
81
116
  | 状态与反馈 | Badge · Notice · Toast · Progress · Skeleton · EmptyState |
82
117
 
118
+ ¹ 自 0.4.0 提供。
119
+
83
120
  参数、状态和组合示例见[组件 API](components/README.md)与[常用组件契约](components/COMMON_COMPONENTS.md)。
84
121
 
85
122
  <a id="documentation"></a>
86
123
 
87
124
  ## 设计规范与展台
88
125
 
89
- | 主题 | 文档 | 本地展台锚点 |
126
+ 无需安装即可打开 **[在线组件展台](https://linhay.github.io/rhine-design/)**,体验组件与动效。
127
+
128
+ | 主题 | 文档 | 在线预览 |
90
129
  | --- | --- | --- |
91
- | 视觉基础与组合原则 | [设计规范](docs/DESIGN_SYSTEM.md) | `/#foundation` |
92
- | 字体、文字层级与数值 | [Typography](components/TYPOGRAPHY.md) | `/#typography` |
93
- | 品牌几何与 Logo | [Logo 契约](components/README.md#品牌标志) | `/#brand` |
94
- | 动效、交互与减少动态 | [Motion](components/MOTION.md) | `/#motion` |
95
- | 启动动画与就绪衔接 | [Startup](components/STARTUP.md) | `/#startup` |
130
+ | 视觉基础与组合原则 | [设计规范](docs/DESIGN_SYSTEM.md) | [视觉基础](https://linhay.github.io/rhine-design/#foundation) |
131
+ | 间距、分组与卡片组合 | [Spacing](components/SPACING.md) | [间距与组合](https://linhay.github.io/rhine-design/#spacing) |
132
+ | 字体、文字层级与数值 | [Typography](components/TYPOGRAPHY.md) | [字体与文字](https://linhay.github.io/rhine-design/#typography) |
133
+ | 品牌几何与 Logo | [Logo 契约](components/README.md#品牌标志) | [品牌标志](https://linhay.github.io/rhine-design/#brand) |
134
+ | 动效、交互与减少动态 | [Motion](components/MOTION.md) | [动效实验室](https://linhay.github.io/rhine-design/#motion) |
135
+ | 启动动画与就绪衔接 | [Startup](components/STARTUP.md) | [启动动画](https://linhay.github.io/rhine-design/#startup) |
136
+ | 场景与交互选型 | [使用规范](docs/INTERACTION_PATTERNS.md) | [场景指南](https://linhay.github.io/rhine-design/#patterns) |
137
+ | 导航与表单流程 | [组件契约](docs/INTERACTION_PATTERNS.md#新组件契约) | [导航](https://linhay.github.io/rhine-design/#navigation) · [表单](https://linhay.github.io/rhine-design/#form-workflow) |
96
138
  | 系统覆盖与演进方向 | [组件系统对照](docs/DESIGN_SYSTEM_AUDIT.md) | — |
97
139
 
98
- 本地展台包含组件状态、键盘操作、长文本、加载与错误示例。动效可播放、重播、复位和慢放,并支持减少动态模式。
140
+ 展台采用左侧分组目录、右侧单章节工作区;目录与内容独立滚动,当前章节高亮,原有锚点可直接分享并支持浏览器前进/后退。窄屏使用可展开目录,选择章节后自动收起。动效偏好跨章节保留。
141
+
142
+ 展台包含组件状态、键盘操作、长文本、加载与错误示例。动效可播放、重播、复位和慢放,并支持减少动态模式。离开章节会卸载其演示,返回后重新开始。在线版本跟随 `main` 更新;如需本地运行:
99
143
 
100
144
  ```sh
101
145
  git clone https://github.com/linhay/rhine-design.git
@@ -104,7 +148,7 @@ npm ci
104
148
  npm run dev
105
149
  ```
106
150
 
107
- 打开 `http://127.0.0.1:18309/`,使用上表中的锚点访问对应章节。
151
+ 打开 `http://127.0.0.1:18309/`,使用相同的 `#foundation`、`#motion` 等锚点访问对应章节。
108
152
 
109
153
  <a id="ai-skills"></a>
110
154
 
@@ -13,16 +13,18 @@ const paths = {
13
13
  external: 'M11 3h6v6M17 3 9 11M7 3H3v14h14v-4',
14
14
  check: 'm4 10 4 4 8-8',
15
15
  more: 'M4 10h.1M10 10h.1M16 10h.1',
16
+ connection: 'M7 2v4M13 2v4M5 6h10v3a5 5 0 0 1-10 0V6M10 14v4',
17
+ chart: 'M3 3v14h14M7 13V9M11 13V5M15 13V7',
16
18
  } as const;
17
19
  export type IconName = keyof typeof paths;
18
20
  export function Icon({ name, size = 20 }: { name: IconName; size?: 16 | 20 }) {
19
- return <svg className="tod-icon" width={size} height={size} viewBox="0 0 20 20" aria-hidden="true" focusable="false"><path d={paths[name]} /></svg>;
21
+ return <svg className="rhine-icon" width={size} height={size} viewBox="0 0 20 20" aria-hidden="true" focusable="false"><path d={paths[name]} /></svg>;
20
22
  }
21
23
  export const IconButton = forwardRef<HTMLButtonElement, ButtonHTMLAttributes<HTMLButtonElement> & { label: string; icon: IconName }>(function IconButton({ label, icon, className = '', type = 'button', ...props }, ref) {
22
- return <button {...props} ref={ref} type={type} aria-label={label} className={`tod-button tod-button--quiet tod-icon-button ${className}`}><Icon name={icon} /></button>;
24
+ return <button {...props} ref={ref} type={type} aria-label={label} className={`rhine-button rhine-button--quiet rhine-icon-button ${className}`}><Icon name={icon} /></button>;
23
25
  });
24
26
  export function Link({ children, className = '', target, rel, ...props }: AnchorHTMLAttributes<HTMLAnchorElement> & { href: string }) {
25
- return <a {...props} target={target} rel={target === '_blank' ? `${rel || ''} noopener noreferrer`.trim() : rel} className={`tod-link ${className}`}>{children}{target === '_blank' && <><Icon name="external" size={16} /><span className="tod-sr-only">(新窗口打开)</span></>}</a>;
27
+ return <a {...props} target={target} rel={target === '_blank' ? `${rel || ''} noopener noreferrer`.trim() : rel} className={`rhine-link ${className}`}>{children}{target === '_blank' && <><Icon name="external" size={16} /><span className="rhine-sr-only">(新窗口打开)</span></>}</a>;
26
28
  }
27
29
  export function CopyButton({ value, label = '复制', copy = text => navigator.clipboard.writeText(text) }: { value: string; label?: string; copy?: (value: string) => Promise<void> }) {
28
30
  const [status, setStatus] = useState<'idle' | 'busy' | 'success' | 'error'>('idle');
@@ -36,5 +38,5 @@ export function CopyButton({ value, label = '复制', copy = text => navigator.c
36
38
  catch { if (request === epoch.current) setStatus('error'); }
37
39
  finally { if (request === epoch.current) pending.current = false; }
38
40
  };
39
- return <span className="tod-copy"><Button variant="secondary" busy={status === 'busy'} busyLabel="正在复制" onClick={run}><Icon name="copy" size={16} /> {label}</Button><span role={status === 'error' ? 'alert' : 'status'} className={status === 'error' ? 'tod-text--error' : 'tod-text--muted'}>{status === 'success' ? '已复制' : status === 'error' ? '复制失败,请重试' : ''}</span></span>;
41
+ return <span className="rhine-copy"><Button variant="secondary" busy={status === 'busy'} busyLabel="正在复制" onClick={run}><Icon name="copy" size={16} /> {label}</Button><span role={status === 'error' ? 'alert' : 'status'} className={status === 'error' ? 'rhine-text--error' : 'rhine-text--muted'}>{status === 'success' ? '已复制' : status === 'error' ? '复制失败,请重试' : ''}</span></span>;
40
42
  }
@@ -2,6 +2,10 @@
2
2
 
3
3
  本文件定义[莱茵设计](../README.md)的常用组件契约。先固定契约和组件展台,再验证;默认优先复用本目录,页面先改设计稿,再按授权接入App。具体业务由宿主定义,不进入通用契约。
4
4
 
5
+ ## 新增导航与流程能力(main,未发布)
6
+
7
+ SideNav、Breadcrumbs、Steps、Drawer、Fieldset、ErrorSummary、StatusTimeline、TimelineLegend 的 API、键盘与场景契约见[交互选型](../docs/INTERACTION_PATTERNS.md)。main 共48个导出组件;下表保留原24个扩展组件的契约。
8
+
5
9
  ## 范围与契约
6
10
 
7
11
  | 组件 | 最小职责与边界 |
@@ -40,7 +44,7 @@
40
44
 
41
45
  ## 接入契约
42
46
 
43
- 统一从 套件的`components/index.ts` 导入,并在页面根部使用 `.tod-theme`。主题根不要放在带transform、overflow裁剪或contain的局部面板;浮层挂到最近主题根,保持token和减少动态偏好。Dialog、Popover、Menu的trigger与Tooltip的children必须是单个可聚焦、可接收ref和事件的元素;使用Button/IconButton即可。Dialog受控时必须同时传open与onOpenChange。
47
+ 统一从 套件的`components/index.ts` 导入,并在页面根部使用 `.rhine-theme`。主题根不要放在带transform、overflow裁剪或contain的局部面板;浮层挂到最近主题根,保持token和减少动态偏好。Dialog、Popover、Menu的trigger与Tooltip的children必须是单个可聚焦、可接收ref和事件的元素;使用Button/IconButton即可。Dialog受控时必须同时传open与onOpenChange。分栏的sidePanel可传ReactNode,或传`closeButton => ReactNode`将关闭按钮放进侧栏导航;函数模式必须展示传入按钮,不能复制第二个关闭入口。
44
48
 
45
49
  ```tsx
46
50
  <Stack gap={4}>
@@ -3,10 +3,10 @@ import { Button, EmptyState, Notice, Skeleton } from './components';
3
3
  import { Text } from './Typography';
4
4
 
5
5
  export function Metric({ label, value, unit, description, loading = false }: { label: string; value: ReactNode; unit?: string; description?: string; loading?: boolean }) {
6
- return <div className="tod-metric" aria-busy={loading}><Text variant="caption" tone="muted">{label}</Text>{loading ? <Skeleton lines={1} label={`正在读取${label}`} /> : <Text variant="metric">{value ?? '—'}{unit && <span className="tod-metric__unit"> {unit}</span>}</Text>}{description && <Text variant="caption" tone="muted">{description}</Text>}</div>;
6
+ return <div className="rhine-metric" aria-busy={loading}><Text variant="caption" tone="muted">{label}</Text>{loading ? <Skeleton lines={1} label={`正在读取${label}`} /> : <Text variant="metric">{value ?? '—'}{unit && <span className="rhine-metric__unit"> {unit}</span>}</Text>}{description && <Text variant="caption" tone="muted">{description}</Text>}</div>;
7
7
  }
8
8
  export function KeyValue({ items }: { items: { key: string; label: string; value: ReactNode }[] }) {
9
- return <dl className="tod-key-value">{items.map(item => <div key={item.key}><dt>{item.label}</dt><dd>{item.value ?? '—'}</dd></div>)}</dl>;
9
+ return <dl className="rhine-key-value">{items.map(item => <div key={item.key}><dt>{item.label}</dt><dd>{item.value ?? '—'}</dd></div>)}</dl>;
10
10
  }
11
11
  export interface DataColumn<T> { key: string; header: string; render: (row: T) => ReactNode; align?: 'left' | 'right'; sortable?: boolean }
12
12
  export interface TableSort { key: string; direction: 'asc' | 'desc' }
@@ -16,11 +16,11 @@ export interface DataTableProps<T> {
16
16
  onSortChange?: (sort: TableSort) => void; onRetry?: () => void;
17
17
  }
18
18
  export function DataTable<T>({ caption, columns, rows, rowKey, loading = false, error, emptyTitle = '暂无记录', sort, onSortChange, onRetry }: DataTableProps<T>) {
19
- return <div className="tod-data" aria-busy={loading}>
19
+ return <div className="rhine-data" aria-busy={loading}>
20
20
  {error && <Notice tone="error" title="读取失败" action={onRetry && <Button variant="secondary" onClick={onRetry} disabled={loading}>重试</Button>}>{error}{rows.length > 0 && ' · 以下保留上次读取的记录。'}</Notice>}
21
- {loading && <p role="status" className="tod-text tod-text--caption tod-text--muted">{rows.length ? '正在刷新,保留当前记录…' : '正在读取…'}</p>}
22
- <div className="tod-table-scroll" role="region" aria-label={`${caption},可横向滚动`} tabIndex={0}>
23
- <table className="tod-table"><caption>{caption}</caption><thead><tr>{columns.map(column => <th scope="col" key={column.key} data-align={column.align} aria-sort={column.sortable ? sort?.key === column.key ? sort.direction === 'asc' ? 'ascending' : 'descending' : 'none' : undefined}>{column.sortable && onSortChange ? <button type="button" disabled={loading} onClick={() => onSortChange({ key: column.key, direction: sort?.key === column.key && sort.direction === 'asc' ? 'desc' : 'asc' })}>{column.header}<span aria-hidden="true">{sort?.key === column.key ? sort.direction === 'asc' ? ' ↑' : ' ↓' : ' ↕'}</span></button> : column.header}</th>)}</tr></thead>
21
+ {loading && <p role="status" className="rhine-text rhine-text--caption rhine-text--muted">{rows.length ? '正在刷新,保留当前记录…' : '正在读取…'}</p>}
22
+ <div className="rhine-table-scroll" role="region" aria-label={`${caption},可横向滚动`} tabIndex={0}>
23
+ <table className="rhine-table"><caption>{caption}</caption><thead><tr>{columns.map(column => <th scope="col" key={column.key} data-align={column.align} aria-sort={column.sortable ? sort?.key === column.key ? sort.direction === 'asc' ? 'ascending' : 'descending' : 'none' : undefined}>{column.sortable && onSortChange ? <button type="button" disabled={loading} onClick={() => onSortChange({ key: column.key, direction: sort?.key === column.key && sort.direction === 'asc' ? 'desc' : 'asc' })}>{column.header}<span aria-hidden="true">{sort?.key === column.key ? sort.direction === 'asc' ? ' ↑' : ' ↓' : ' ↕'}</span></button> : column.header}</th>)}</tr></thead>
24
24
  <tbody>{rows.map(row => <tr key={rowKey(row)}>{columns.map(column => <td key={column.key} data-align={column.align}>{column.render(row) ?? '—'}</td>)}</tr>)}</tbody></table>
25
25
  </div>
26
26
  {!rows.length && !loading && !error && <EmptyState title={emptyTitle} description="当前条件下没有可展示的数据。" />}
@@ -34,8 +34,8 @@ export function Pagination({ page, pageSize, total, hasNext = false, loading = f
34
34
  const pages = count === undefined ? undefined : Math.max(1, Math.ceil(count / size));
35
35
  const current = Math.max(1, Math.floor(page));
36
36
  const next = pages === undefined ? hasNext : current < pages;
37
- return <nav className="tod-pagination" aria-label="分页">
38
- <span className="tod-text tod-text--caption tod-text--numeric">{count === undefined ? `第 ${current} 页` : `第 ${current} / ${pages} 页 · 共 ${count} 条`}</span>
37
+ return <nav className="rhine-pagination" aria-label="分页">
38
+ <span className="rhine-text rhine-text--caption rhine-text--numeric">{count === undefined ? `第 ${current} 页` : `第 ${current} / ${pages} 页 · 共 ${count} 条`}</span>
39
39
  <Button variant="secondary" disabled={loading || current <= 1} onClick={() => onPageChange(current - 1)}>上一页</Button>
40
40
  <Button variant="secondary" disabled={loading || !next} onClick={() => onPageChange(current + 1)}>下一页</Button>
41
41
  </nav>;
@@ -0,0 +1,25 @@
1
+ import { useEffect, useId, useRef, type FieldsetHTMLAttributes, type ReactNode } from 'react';
2
+
3
+ export interface FieldsetProps extends FieldsetHTMLAttributes<HTMLFieldSetElement> { legend: string; hint?: string; children: ReactNode }
4
+ export function Fieldset({ legend, hint, children, className = '', 'aria-describedby': describedBy, ...props }: FieldsetProps) {
5
+ const id = useId();
6
+ return <fieldset {...props} className={`rhine-fieldset ${className}`} aria-describedby={[describedBy, hint ? id : undefined].filter(Boolean).join(' ') || undefined}>
7
+ <legend>{legend}</legend>{hint && <p id={id}>{hint}</p>}<div className="rhine-fieldset__body">{children}</div>
8
+ </fieldset>;
9
+ }
10
+
11
+ export interface FieldError { fieldId: string; message: string }
12
+ export interface ErrorSummaryProps { errors: readonly FieldError[]; title?: string; focusKey?: number }
13
+ export function ErrorSummary({ errors, title = '请修正以下问题', focusKey }: ErrorSummaryProps) {
14
+ const ref = useRef<HTMLDivElement>(null), id = useId();
15
+ const hasErrors = errors.length > 0;
16
+ // Focus only on a failed submission, never on each keystroke. Increment focusKey for retries.
17
+ useEffect(() => { if (hasErrors && focusKey !== undefined) ref.current?.focus(); }, [focusKey, hasErrors]);
18
+ if (!hasErrors) return null;
19
+ return <div className="rhine-error-summary" ref={ref} tabIndex={-1} role="region" aria-labelledby={id}>
20
+ <h3 id={id}>{title}</h3><ul>{errors.map(error => <li key={error.fieldId}><a href={`#${error.fieldId}`} onClick={event => {
21
+ const field = document.getElementById(error.fieldId);
22
+ if (field) { event.preventDefault(); field.focus(); field.scrollIntoView?.({ block: 'center' }); }
23
+ }}>{error.message}</a></li>)}</ul>
24
+ </div>;
25
+ }
@@ -7,23 +7,23 @@ export function Checkbox({ label, hint, indeterminate = false, id: suppliedId, c
7
7
  const generatedId = useId(), id = suppliedId || generatedId;
8
8
  const ref = useRef<HTMLInputElement>(null);
9
9
  useEffect(() => { if (ref.current) ref.current.indeterminate = indeterminate; }, [indeterminate]);
10
- return <div className={`tod-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="tod-text tod-text--caption tod-text--muted">{hint}</p>}</div>;
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
11
  }
12
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
13
  const id = useId();
14
- return <fieldset className="tod-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>;
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
15
  }
16
16
  export function Switch({ label, checked, onCheckedChange, disabled = false, hint }: { label: string; checked: boolean; onCheckedChange: (checked: boolean) => void; disabled?: boolean; hint?: string }) {
17
17
  const id = useId();
18
- return <div className="tod-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="tod-switch__track" aria-hidden="true"><span /></span><span>{label}</span></label>{hint && <p id={id} className="tod-text tod-text--caption tod-text--muted">{hint}</p>}</div>;
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
19
  }
20
20
  export function Textarea({ label, hint, error, id: suppliedId, className = '', rows = 3, ...props }: TextareaHTMLAttributes<HTMLTextAreaElement> & { label: string; hint?: string; error?: string }) {
21
21
  const generatedId = useId(), id = suppliedId || generatedId;
22
- return <div className={`tod-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="tod-field__hint">{hint}</span>}{error && <span id={`${id}-error`} className="tod-field__error" role="alert">{error}</span>}</div>;
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
23
  }
24
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
25
  const id = useId(); const composing = useRef(false);
26
- return <div className="tod-search tod-field" role="search" aria-label={label}><label htmlFor={id}>{label}</label><div className="tod-inline">
26
+ return <div className="rhine-search rhine-field" role="search" aria-label={label}><label htmlFor={id}>{label}</label><div className="rhine-inline">
27
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
28
  {value && <IconButton icon="close" label="清空搜索" disabled={disabled || loading} onClick={() => { onValueChange(''); onSearch(''); document.getElementById(id)?.focus(); }} />}
29
29
  <Button variant="secondary" disabled={disabled} busy={loading} busyLabel="正在搜索" onClick={() => { if (!composing.current) onSearch(value); }}>搜索</Button>
@@ -38,15 +38,15 @@ export function Combobox({ label, query, onQueryChange, value, onValueChange, op
38
38
  const id = useId(); const composing = useRef(false);
39
39
  const root = useRef<HTMLDivElement>(null);
40
40
  const [container, setContainer] = useState<HTMLElement>();
41
- useLayoutEffect(() => { setContainer((root.current?.closest('.tod-theme') as HTMLElement) || document.body); }, []);
41
+ useLayoutEffect(() => { setContainer((root.current?.closest('.rhine-theme') as HTMLElement) || document.body); }, []);
42
42
  const [open, setOpen] = useState(false), [active, setActive] = useState<string | null>(null), [draft, setDraft] = useState<string | null>(null);
43
43
  const available = loading || error ? [] : options.filter(option => !option.disabled);
44
44
  const activeIndex = options.findIndex(option => option.value === active && !option.disabled);
45
45
  const activeId = activeIndex >= 0 && !loading && !error ? `${id}-option-${activeIndex}` : undefined;
46
46
  useEffect(() => { if (!open || !activeId) return; document.getElementById(activeId)?.scrollIntoView?.({ block: 'nearest' }); }, [activeId, open]);
47
47
  const choose = (option: Choice) => { onValueChange(option); setOpen(false); setActive(null); };
48
- return <div ref={root} className="tod-combobox tod-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="tod-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}
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
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
51
  onCompositionStart={() => { composing.current = true; setDraft(query); }} onCompositionEnd={event => { composing.current = false; setDraft(null); onQueryChange(event.currentTarget.value); }}
52
52
  onKeyDown={event => {
@@ -55,10 +55,10 @@ export function Combobox({ label, query, onQueryChange, value, onValueChange, op
55
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
56
  if (open && ['Home', 'End'].includes(event.key)) { event.preventDefault(); setActive((event.key === 'Home' ? available[0] : available.at(-1))?.value ?? null); }
57
57
  if (open && event.key === 'Enter') { event.preventDefault(); const option = available.find(item => item.value === active); if (option) choose(option); }
58
- }} /><span className="tod-combobox__arrow"><Icon name="chevronDown" size={16} /></span></div></PopoverBase.Anchor>
58
+ }} /><span className="rhine-combobox__arrow"><Icon name="chevronDown" size={16} /></span></div></PopoverBase.Anchor>
59
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="tod-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="tod-combobox__empty" role="presentation">{loading ? '正在读取…' : error ? '暂不可用' : '暂无匹配结果'}</div>}</div>
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
61
  </PopoverBase.Content></PopoverBase.Portal></PopoverBase.Root>
62
- <span id={`${id}-status`} role="status" className={`tod-text tod-text--caption ${error ? 'tod-text--error' : 'tod-text--muted'}`}>{loading ? '正在读取候选项…' : error || (options.length ? `${options.length} 个候选项` : '暂无匹配结果')}</span>
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
63
  </div>;
64
64
  }
@@ -2,12 +2,12 @@ import type { CSSProperties, HTMLAttributes } from 'react';
2
2
  export type Spacing = 1 | 2 | 3 | 4 | 6 | 8 | 12;
3
3
  type LayoutProps = HTMLAttributes<HTMLDivElement> & { gap?: Spacing };
4
4
  export function Stack({ gap = 4, className = '', style, ...props }: LayoutProps) {
5
- return <div {...props} className={`tod-stack ${className}`} style={{ gap: `var(--tod-space-${gap})`, ...style }} />;
5
+ return <div {...props} className={`rhine-stack ${className}`} style={{ gap: `var(--rhine-space-${gap})`, ...style }} />;
6
6
  }
7
7
  export function Inline({ gap = 3, className = '', style, ...props }: LayoutProps) {
8
- return <div {...props} className={`tod-inline ${className}`} style={{ gap: `var(--tod-space-${gap})`, ...style }} />;
8
+ return <div {...props} className={`rhine-inline ${className}`} style={{ gap: `var(--rhine-space-${gap})`, ...style }} />;
9
9
  }
10
10
  export function Grid({ gap = 4, minWidth = 240, className = '', style, ...props }: LayoutProps & { minWidth?: number }) {
11
- return <div {...props} className={`tod-grid ${className}`} style={{ '--tod-grid-min': `${Math.max(80, minWidth)}px`, gap: `var(--tod-space-${gap})`, ...style } as CSSProperties} />;
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
12
  }
13
- export function Divider() { return <hr className="tod-divider" />; }
13
+ export function Divider() { return <hr className="rhine-divider" />; }
@@ -16,19 +16,19 @@ export interface LogoProps {
16
16
  export function Logo({ size = 48, variant = 'mark', theme = 'light', animate = false, decorative = false, wordmark = 'Rhine Design', label = wordmark, className = '' }: LogoProps) {
17
17
  const dimension = Number.isFinite(size) ? Math.max(16, size) : 48;
18
18
  return <span
19
- className={`tod-logo tod-logo--${variant} ${className}`}
19
+ className={`rhine-logo rhine-logo--${variant} ${className}`}
20
20
  data-theme={theme}
21
21
  data-animate={animate}
22
22
  role={decorative ? undefined : 'img'}
23
23
  aria-label={decorative ? undefined : label}
24
24
  aria-hidden={decorative || undefined}
25
- style={{ '--tod-logo-size': `${dimension}px`, '--tod-logo-travel': `${2400 / dimension}px` } as CSSProperties}
25
+ style={{ '--rhine-logo-size': `${dimension}px`, '--rhine-logo-travel': `${2400 / dimension}px` } as CSSProperties}
26
26
  >
27
- <svg className="tod-logo__symbol" viewBox="0 0 800 800" aria-hidden="true" focusable="false">
28
- <use className="tod-logo__plate tod-logo__plate--left" href={`${markUrl}#left`} />
29
- <use className="tod-logo__plate tod-logo__plate--right" href={`${markUrl}#right`} />
30
- <use className="tod-logo__plate tod-logo__plate--base" href={`${markUrl}#base`} />
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
31
  </svg>
32
- {variant === 'lockup' && <span className="tod-logo__wordmark" aria-hidden="true">{wordmark}</span>}
32
+ {variant === 'lockup' && <span className="rhine-logo__wordmark" aria-hidden="true">{wordmark}</span>}
33
33
  </span>;
34
34
  }
@@ -4,7 +4,7 @@
4
4
 
5
5
  2026-10-22,用户指定 [Transitions.dev](https://transitions.dev/) 作为UX与动画参考。本文件补充[组件规范](./README.md#动效规范),保留Rhine Design的苔绿、暖白、三角几何与清晰文字。以下参数是本项目设计决定,不是对参考站时长或曲线的测量结果。
6
6
 
7
- 用户已进一步授权实现展台:`/#motion`提供10组可播放、重播、复位的真实示例,支持原速/4倍慢放、逐例减少动态及全局偏好。组件总数保持40;新配方仍限定于展台,后续按“展台评审 → 通用组件采用 → App接入”推进。
7
+ 用户已进一步授权实现展台:`/#motion`目前提供12组可播放、重播、复位的真实示例,支持原速/4倍慢放、逐例减少动态及全局偏好。main组件共46个(npm0.3.0为40个);实验性配方仍限定于展台,后续按“展台评审 → 通用组件采用 → App接入”推进。
8
8
 
9
9
  ## 参考依据
10
10
 
@@ -20,7 +20,7 @@
20
20
  4. **动效可被主动比较。** 展台后续为每种动效提供静态起点、播放/切换、重播、参数说明及减少动态对照。重播只改变本地演示状态,不重新执行复制、请求、确认等真实动作。
21
21
  5. **解释适用条件。** 示例卡写清“触发 → 变化 → 结束状态”,并显示已实现/设计候选;筛选只改变展台展示范围,不改变组件业务值。宽屏并排对照,窄屏单列,播放按钮位置稳定。
22
22
 
23
- 动效实验室已为10组配方提供上述播放控制、状态说明、参数和逐例减少动态开关;并排卡片可分别设置偏好作比较。既有组件目录保留普通交互示例,不给每个无动画组件添加播放按钮。
23
+ 动效实验室已为12组配方提供上述播放控制、状态说明、参数和逐例减少动态开关;并排卡片可分别设置偏好作比较。既有组件目录保留普通交互示例,不给每个无动画组件添加播放按钮。
24
24
 
25
25
  ## Rhine Design 动效映射
26
26
 
@@ -51,7 +51,7 @@
51
51
  ## 参数与中断约束
52
52
 
53
53
  - 继续使用[tokens.css](./tokens.css):按下80ms、交互120ms、浮层160ms、面板180ms、通知/折叠200ms、进度/品牌240ms;Toast用面板180ms,Notice用通知200ms。正文与数值不做缩放或模糊。
54
- - `--tod-ease-standard`为`cubic-bezier(.2,0,0,1)`;Disclosure收起用现有exit曲线。`--tod-motion-distance`为3px;新增配方先复用这些变量,不新增逐组件硬编码时长。
54
+ - `--rhine-ease-standard`为`cubic-bezier(.2,0,0,1)`;Disclosure收起用现有exit曲线。`--rhine-motion-distance`为3px;新增配方先复用这些变量,不新增逐组件硬编码时长。
55
55
  - 动画仅声明需要变化的属性,不用`transition: all`。一般用opacity/transform;高度测量仅用于必要的Disclosure布局变化,不用整页高度动画掩盖布局跳动。
56
56
  - 快速反向从当前画面续接,旧动画取消;卸载清理动画和计时器。初始渲染、窗口恢复或普通重渲染不重复播放操作成功效果。
57
57
  - 语义先于装饰:checked、aria-expanded、真实结果和焦点即时更新;内容淡入不延迟可读状态。不要为了动画同时保留两份可聚焦控件或重复live播报。
@@ -70,3 +70,7 @@
70
70
  首次内容切换不闪空白、不让操作目标跳位;刷新不丢旧数据;退出后无透明遮挡层和遗留焦点;无隐藏控件可Tab到。文档参数、DOM存在与单张截图不能代替完整动效或宿主原生平台原生验收。
71
71
 
72
72
  2026-10-22展台验证:Chrome通过400/520/880/1200宽度、200%缩放、10组播放/复位、快速重播取消旧结果、四向位置/位移采样、菜单键盘、弹窗内重播保焦点与关闭归还、折叠中途反向、通知关闭、Tooltip Escape、逐例/全局/系统减少动态。采样确认4倍慢放的内容淡入720ms、反馈480ms、浮层640ms、折叠800ms及品牌960ms;未将合成浏览器结果扩展为原生或业务验收。
73
+
74
+ ## 导航与流程动效(main,未发布)
75
+
76
+ 新增抽屉与步骤两组示例,实验室现为12组。Drawer继承160ms浮层token,从右侧3px淡入;弹层内重播只重启CSS动画,焦点与内容不重建。Steps当前段使用180ms/3px进入,由业务校验推进。表单错误直接呈现并定位,无抖动效果。完整适用场景、减少动态与告警规则见[交互规范](../docs/INTERACTION_PATTERNS.md)。
@@ -0,0 +1,42 @@
1
+ import type { HTMLAttributes, MouseEvent } from 'react';
2
+ import { Icon, type IconName } from './Actions';
3
+
4
+ export interface NavItem { id: string; label: string; href: string; icon?: IconName }
5
+ export interface NavGroup { label: string; items: readonly NavItem[] }
6
+ export interface SideNavProps extends HTMLAttributes<HTMLElement> {
7
+ groups: readonly NavGroup[]; currentId: string;
8
+ onNavigate?: (item: NavItem, event: MouseEvent<HTMLAnchorElement>) => void;
9
+ }
10
+ export function SideNav({ groups, currentId, onNavigate, className = '', 'aria-label': label = '主导航', ...props }: SideNavProps) {
11
+ return <nav {...props} aria-label={label} className={`rhine-side-nav ${className}`}>
12
+ {groups.map(group => <div className="rhine-side-nav__group" key={group.label}><p>{group.label}</p><ul>
13
+ {group.items.map(item => <li key={item.id}><a href={item.href} aria-current={currentId === item.id ? 'page' : undefined} onClick={event => onNavigate?.(item, event)}>{item.icon && <Icon name={item.icon} size={20}/>}<span className="rhine-side-nav__label">{item.label}</span></a></li>)}
14
+ </ul></div>)}
15
+ </nav>;
16
+ }
17
+
18
+ export interface BreadcrumbItem { label: string; href?: string }
19
+ export interface BreadcrumbsProps { items: readonly BreadcrumbItem[]; label?: string }
20
+ export function Breadcrumbs({ items, label = '当前位置' }: BreadcrumbsProps) {
21
+ return <nav className="rhine-breadcrumbs" aria-label={label}><ol>{items.map((item, index) => <li key={index}>
22
+ {index > 0 && <span aria-hidden="true" className="rhine-breadcrumbs__separator">/</span>}
23
+ {item.href ? <a href={item.href} aria-current={index === items.length - 1 ? 'page' : undefined}>{item.label}</a> : <span aria-current={index === items.length - 1 ? 'page' : undefined}>{item.label}</span>}
24
+ </li>)}</ol></nav>;
25
+ }
26
+
27
+ export interface StepItem { id: string; label: string; description?: string }
28
+ export interface StepsProps {
29
+ items: readonly StepItem[]; current: number; label?: string; error?: string;
30
+ onStepChange?: (index: number) => void;
31
+ }
32
+ export function Steps({ items, current, label = '任务步骤', error, onStepChange }: StepsProps) {
33
+ const position = Math.max(0, Math.min(current, items.length));
34
+ return <ol className="rhine-steps" aria-label={label}>{items.map((item, index) => {
35
+ const state = index < position ? 'complete' : index === position ? (error ? 'error' : 'current') : 'pending';
36
+ const status = { complete: '已完成', current: '进行中', error: '需要修正', pending: '未开始' }[state];
37
+ const content = <><span className="rhine-steps__number" aria-hidden="true">{state === 'complete' ? '✓' : String(index + 1).padStart(2, '0')}</span><span><strong>{item.label}</strong><small>{status}{item.description ? ` · ${item.description}` : ''}{state === 'error' ? ` · ${error}` : ''}</small></span></>;
38
+ return <li key={item.id} data-state={state} aria-current={index === position ? 'step' : undefined}>
39
+ {onStepChange && index < position ? <button type="button" onClick={() => onStepChange(index)} aria-label={`返回${item.label}`}>{content}</button> : <div>{content}</div>}
40
+ </li>;
41
+ })}</ol>;
42
+ }