@linhey/rhine-design 0.3.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 (52) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/README.md +53 -14
  3. package/components/Actions.tsx +2 -0
  4. package/components/COMMON_COMPONENTS.md +5 -1
  5. package/components/FormWorkflow.tsx +25 -0
  6. package/components/MOTION.md +6 -2
  7. package/components/Navigation.tsx +42 -0
  8. package/components/Overlays.tsx +9 -3
  9. package/components/README.md +13 -5
  10. package/components/SPACING.md +82 -0
  11. package/components/StatusTimeline.tsx +29 -0
  12. package/components/TYPOGRAPHY.md +34 -2
  13. package/components/common.css +12 -3
  14. package/components/index.ts +7 -1
  15. package/components/patterns.css +39 -0
  16. package/components/styles.css +12 -8
  17. package/components/theme-arknights.css +301 -0
  18. package/components/theme-neo-brutalism.css +84 -0
  19. package/components/theme.tsx +58 -0
  20. package/components/timeline.css +23 -0
  21. package/components/tokens.css +6 -1
  22. package/docs/DESIGN_SYSTEM.md +12 -2
  23. package/docs/DESIGN_SYSTEM_AUDIT.md +4 -4
  24. package/docs/INTERACTION_PATTERNS.md +129 -0
  25. package/docs/REFERENCE_RESEARCH.md +36 -0
  26. package/docs/THEME_NEO_BRUTALISM.md +37 -0
  27. package/docs/THEMING.md +104 -0
  28. package/package.json +5 -1
  29. package/preview/CommonGallery.tsx +8 -8
  30. package/preview/GalleryShell.tsx +106 -0
  31. package/preview/LineHeightGallery.tsx +27 -0
  32. package/preview/MotionGallery.tsx +6 -2
  33. package/preview/PatternGallery.tsx +52 -0
  34. package/preview/SpacingGallery.tsx +32 -0
  35. package/preview/SubscriptionGallery.tsx +84 -0
  36. package/preview/line-height.css +2 -0
  37. package/preview/main.tsx +39 -25
  38. package/preview/patterns.css +7 -0
  39. package/preview/preview.css +2 -8
  40. package/preview/shell.css +35 -0
  41. package/preview/spacing.css +2 -0
  42. package/preview/subscriptions.css +27 -0
  43. package/preview/theme-arknights.css +234 -0
  44. package/preview/theme-neo-brutalism.css +11 -0
  45. package/skills/rhine-design/SKILL.md +5 -3
  46. package/skills/rhine-design/references/API_MAP.md +7 -3
  47. package/skills/rhine-design/references/TASK_MAP.md +4 -1
  48. package/skills/rhine-design/references/integration.md +4 -2
  49. package/tests/GalleryShell.test.tsx +98 -0
  50. package/tests/Patterns.test.tsx +75 -0
  51. package/tests/StatusTimeline.test.tsx +15 -0
  52. package/tests/Theme.test.tsx +112 -0
@@ -0,0 +1,234 @@
1
+ /* Gallery-only chrome and decoration. Not imported by the public theme entry. */
2
+ /* ================= 全局质感 ================= */
3
+
4
+ /* 坐标网格底纹 + 顶部扫描线氛围 */
5
+ .rhine-theme[data-rhine-theme="arknights"] .ds-workspace {
6
+ background-image:
7
+ linear-gradient(var(--ak-gridline) 1px, transparent 1px),
8
+ linear-gradient(90deg, var(--ak-gridline) 1px, transparent 1px);
9
+ background-size: 56px 56px;
10
+ }
11
+
12
+ /* ================= 字体性格 ================= */
13
+ /* 标签、编号、说明全部等宽化,正文保持无衬线可读性 */
14
+ .rhine-theme[data-rhine-theme="arknights"] .ds-caption,
15
+ .rhine-theme[data-rhine-theme="arknights"] .ds-component-title>span,
16
+ .rhine-theme[data-rhine-theme="arknights"] .ds-notes {
17
+ font-family: var(--rhine-mono);
18
+ letter-spacing: .04em;
19
+ }
20
+
21
+ /* ================= 顶栏 ================= */
22
+ .rhine-theme[data-rhine-theme="arknights"] .ds-workspace-head {
23
+ position: relative;
24
+ background: rgba(11, 13, 16, .85);
25
+ border-bottom-color: var(--rhine-line);
26
+ }
27
+ .rhine-theme[data-rhine-theme="arknights"] .ds-workspace-head::after {
28
+ content: '';
29
+ position: absolute;
30
+ left: 32px;
31
+ bottom: -1px;
32
+ width: 160px;
33
+ height: 4px;
34
+ background: var(--ak-hazard);
35
+ }
36
+
37
+ /* ================= 侧栏:深色舱室 ================= */
38
+ .rhine-theme[data-rhine-theme="arknights"] .ds-sidebar {
39
+ background: #08090c;
40
+ border-right-color: var(--rhine-line);
41
+ }
42
+ .rhine-theme[data-rhine-theme="arknights"] .ds-sidebar-brand {
43
+ border-bottom: 1px solid var(--rhine-line);
44
+ background: repeating-linear-gradient(-45deg, rgba(255, 212, 0, .07) 0 10px, transparent 10px 20px);
45
+ }
46
+ .rhine-theme[data-rhine-theme="arknights"] .ds-sidebar-brand>a { color: var(--rhine-ink); }
47
+ .rhine-theme[data-rhine-theme="arknights"] .ds-sidebar-brand small { color: var(--ak-accent); letter-spacing: .18em; }
48
+ .rhine-theme[data-rhine-theme="arknights"] .ds-sidebar-foot>span:first-child { color: var(--ak-accent); }
49
+
50
+ /* ================= 主视觉 Hero ================= */
51
+ .rhine-theme[data-rhine-theme="arknights"] .ds-kicker {
52
+ color: var(--ak-accent);
53
+ letter-spacing: 3px;
54
+ }
55
+ .rhine-theme[data-rhine-theme="arknights"] .ds-kicker::before { content: '// '; }
56
+ .rhine-theme[data-rhine-theme="arknights"] .ds-hero h1 {
57
+ font-size: 56px;
58
+ font-weight: 700;
59
+ letter-spacing: 0;
60
+ line-height: 1.2;
61
+ }
62
+ .rhine-theme[data-rhine-theme="arknights"] .ds-hero h1>span {
63
+ color: var(--ak-accent);
64
+ /* 黄色高光下划线块 */
65
+ background: linear-gradient(transparent 62%, rgba(255, 212, 0, .22) 62%);
66
+ }
67
+ @media(max-width:760px) {
68
+ .rhine-theme[data-rhine-theme="arknights"] .ds-hero h1 { font-size: 34px; }
69
+ }
70
+
71
+ /* 总览卡片:大号幽灵编号 + 斜切 */
72
+ .rhine-theme[data-rhine-theme="arknights"] .ds-overview-links { counter-reset: ak-link; }
73
+ .rhine-theme[data-rhine-theme="arknights"] .ds-overview-links>a {
74
+ counter-increment: ak-link;
75
+ position: relative;
76
+ background: var(--ak-panel);
77
+ clip-path: polygon(0 0, calc(100% - var(--ak-cut)) 0, 100% var(--ak-cut), 100% 100%, 0 100%);
78
+ overflow: hidden;
79
+ }
80
+ .rhine-theme[data-rhine-theme="arknights"] .ds-overview-links>a::after {
81
+ content: counter(ak-link, decimal-leading-zero);
82
+ position: absolute;
83
+ right: 12px;
84
+ bottom: -14px;
85
+ font: 700 72px/1 var(--rhine-mono);
86
+ color: rgba(238, 240, 236, .05);
87
+ pointer-events: none;
88
+ }
89
+ .rhine-theme[data-rhine-theme="arknights"] .ds-overview-links>a:hover {
90
+ border-color: var(--ak-accent);
91
+ background: var(--ak-panel-hi);
92
+ }
93
+ .rhine-theme[data-rhine-theme="arknights"] .ds-overview-links>a:hover>span { color: var(--ak-accent); }
94
+
95
+ /* ================= 章节标题 ================= */
96
+ .rhine-theme[data-rhine-theme="arknights"] .ds-section-title { align-items:center; }
97
+ .rhine-theme[data-rhine-theme="arknights"] .ds-section-title>span {
98
+ background: var(--ak-accent);
99
+ color: var(--ak-accent-ink);
100
+ font: 700 12px/1 var(--rhine-mono);
101
+ padding: 5px 8px;
102
+ clip-path: polygon(0 0, 100% 0, calc(100% - 6px) 100%, 0 100%);
103
+ }
104
+ .rhine-theme[data-rhine-theme="arknights"] .ds-section-title h2 { letter-spacing: .02em; }
105
+
106
+ /* 组件标题等宽化 */
107
+ .rhine-theme[data-rhine-theme="arknights"] .ds-component-title h3 { letter-spacing: .06em; }
108
+
109
+ /* ================= 面板与容器:斜切 + 角标框架 ================= */
110
+ .rhine-theme[data-rhine-theme="arknights"] .ds-logo-stage {
111
+ background: var(--ak-panel);
112
+ clip-path: polygon(0 0, calc(100% - var(--ak-cut)) 0, 100% var(--ak-cut), 100% 100%, 0 100%);
113
+ }
114
+
115
+ /* ================= Logo 展台 ================= */
116
+ .rhine-theme[data-rhine-theme="arknights"] .ds-logo-stage--dark { background: #08090c; border-color: var(--rhine-line); }
117
+ .rhine-theme[data-rhine-theme="arknights"] .ds-logo-stage>span:last-child { letter-spacing: 3px; color: var(--rhine-muted); }
118
+
119
+ /* ================= 页脚 ================= */
120
+ .rhine-theme[data-rhine-theme="arknights"] .ds-footer {
121
+ border-top: 0;
122
+ position: relative;
123
+ font-family: var(--rhine-mono);
124
+ letter-spacing: .06em;
125
+ }
126
+ .rhine-theme[data-rhine-theme="arknights"] .ds-footer::before {
127
+ content: '';
128
+ position: absolute;
129
+ top: 0;
130
+ left: 0;
131
+ width: 200px;
132
+ height: 4px;
133
+ background: var(--ak-hazard);
134
+ }
135
+
136
+ /* ================= 调色板展示 ================= */
137
+ .rhine-theme[data-rhine-theme="arknights"] .ds-palette>div>div { border-color: var(--rhine-control); }
138
+
139
+ /* ================= 进阶:HUD 视框 / 扫描 / 开机 ================= */
140
+
141
+ /* 终端读数(默认隐藏,仅本主题显示) */
142
+ .ak-readout { display: none; }
143
+ .rhine-theme[data-rhine-theme="arknights"] .ak-readout {
144
+ display: block;
145
+ margin-top: 8px;
146
+ padding-top: 8px;
147
+ border-top: 1px dashed var(--rhine-line);
148
+ color: var(--ak-accent);
149
+ font: 11px/18px var(--rhine-mono);
150
+ letter-spacing: .08em;
151
+ font-variant-numeric: tabular-nums;
152
+ }
153
+
154
+ /* 四角 HUD 取景框 */
155
+ .ak-hud { display: none; }
156
+ .rhine-theme[data-rhine-theme="arknights"] .ak-hud {
157
+ display: block;
158
+ position: fixed;
159
+ inset: 10px;
160
+ z-index: 90;
161
+ pointer-events: none;
162
+ }
163
+ .rhine-theme[data-rhine-theme="arknights"] .ak-hud i {
164
+ position: absolute;
165
+ width: 26px;
166
+ height: 26px;
167
+ border: 2px solid rgba(255, 212, 0, .5);
168
+ }
169
+ .rhine-theme[data-rhine-theme="arknights"] .ak-hud i:nth-child(1) { top: 0; left: 0; border-right: 0; border-bottom: 0; }
170
+ .rhine-theme[data-rhine-theme="arknights"] .ak-hud i:nth-child(2) { top: 0; right: 0; border-left: 0; border-bottom: 0; }
171
+ .rhine-theme[data-rhine-theme="arknights"] .ak-hud i:nth-child(3) { bottom: 0; left: 0; border-right: 0; border-top: 0; }
172
+ .rhine-theme[data-rhine-theme="arknights"] .ak-hud i:nth-child(4) { bottom: 0; right: 0; border-left: 0; border-top: 0; }
173
+
174
+ /* 主题切换开机闪屏 */
175
+ .rhine-theme[data-rhine-theme="arknights"].ds-shell::after {
176
+ content: '';
177
+ position: fixed;
178
+ inset: 0;
179
+ z-index: 200;
180
+ pointer-events: none;
181
+ background:
182
+ repeating-linear-gradient(0deg, rgba(255, 212, 0, .06) 0 2px, transparent 2px 4px),
183
+ #050608;
184
+ animation: ak-boot 520ms steps(3, end) both;
185
+ }
186
+ @keyframes ak-boot {
187
+ 0% { opacity: 1; }
188
+ 55% { opacity: .55; }
189
+ 100% { opacity: 0; visibility: hidden; }
190
+ }
191
+
192
+ /* 屏幕右缘竖排标识 */
193
+ .rhine-theme[data-rhine-theme="arknights"].ds-shell::before {
194
+ content: 'RHINE // TACTICAL TERMINAL';
195
+ position: fixed;
196
+ right: 6px;
197
+ top: 50%;
198
+ transform: translateY(-50%);
199
+ writing-mode: vertical-rl;
200
+ font: 10px/1 var(--rhine-mono);
201
+ letter-spacing: 5px;
202
+ color: rgba(238, 240, 236, .16);
203
+ z-index: 50;
204
+ pointer-events: none;
205
+ }
206
+ @media(max-width:760px) {
207
+ .rhine-theme[data-rhine-theme="arknights"].ds-shell::before { content: none; }
208
+ }
209
+
210
+ /* Hero 扫描线 */
211
+ .rhine-theme[data-rhine-theme="arknights"] .ds-hero { position: relative; overflow: hidden; }
212
+ .rhine-theme[data-rhine-theme="arknights"] .ds-hero::after {
213
+ content: '';
214
+ position: absolute;
215
+ left: 0;
216
+ right: 0;
217
+ top: -120px;
218
+ height: 120px;
219
+ background: linear-gradient(180deg, transparent, rgba(255, 212, 0, .07), transparent);
220
+ pointer-events: none;
221
+ animation: ak-scan 4.5s linear infinite;
222
+ }
223
+ @keyframes ak-scan {
224
+ from { top: -120px; }
225
+ to { top: 100%; }
226
+ }
227
+
228
+ /* 减少动态:关闭扫描与开机闪屏 */
229
+ @media(prefers-reduced-motion:reduce) {
230
+ .rhine-theme[data-rhine-theme="arknights"] .ds-hero::after { animation: none; content: none; }
231
+ .rhine-theme[data-rhine-theme="arknights"].ds-shell::after { animation: none; content: none; }
232
+ }
233
+ .rhine-theme[data-rhine-theme="arknights"][data-rhine-motion=reduced] .ds-hero::after,
234
+ .rhine-theme[data-rhine-theme="arknights"][data-rhine-motion=reduced].ds-shell::after { animation: none; content: none; }
@@ -0,0 +1,11 @@
1
+ /* Gallery-only chrome and decoration. Not imported by the public theme entry. */
2
+ /* Gallery chrome uses the same theme; application layout stays with its host. */
3
+ .rhine-theme[data-rhine-theme="neo-brutalism"] .ds-sidebar { border-right-width: 2px; border-color: var(--rhine-ink); }
4
+ .rhine-theme[data-rhine-theme="neo-brutalism"] .ds-workspace-head { background: var(--rhine-surface); border-bottom: 2px solid var(--rhine-ink); flex-wrap: wrap; }
5
+ .rhine-theme[data-rhine-theme="neo-brutalism"] .ds-overview-links>a { border: 2px solid var(--rhine-ink); border-radius: 22px; box-shadow: var(--rhine-hard-shadow); }
6
+ .rhine-theme[data-rhine-theme="neo-brutalism"] .ds-hero h1 { font-weight: 800; }
7
+ .rhine-theme[data-rhine-theme="neo-brutalism"] .ds-hero h1>span { color: var(--rhine-ink); text-decoration: underline; text-decoration-color: var(--rhine-signal); text-decoration-thickness: 6px; text-underline-offset: 8px; }
8
+ .rhine-theme[data-rhine-theme="neo-brutalism"] .ds-logo-stage { border: 2px solid var(--rhine-ink); border-radius: 22px; }
9
+ @media(max-width:760px) {
10
+ .rhine-theme[data-rhine-theme="neo-brutalism"] .ds-sidebar { border-right: 0; border-bottom: 2px solid var(--rhine-ink); }
11
+ }
@@ -2,7 +2,7 @@
2
2
  name: rhine-design
3
3
  description: Use Rhine Design components and specifications to design, implement, or review React UI, typography, themes, and motion. Apply when a project uses Rhine Design or the user requests its visual language or component system.
4
4
  metadata:
5
- version: "0.3.0"
5
+ version: "0.4.0"
6
6
  ---
7
7
 
8
8
  # Rhine Design · 莱茵设计
@@ -11,7 +11,7 @@ Use the installed version's components and contracts to build consistent interfa
11
11
 
12
12
  ## Locate the matching source
13
13
 
14
- Package: `@linhey/rhine-design`. GitHub: `linhay/rhine-design` (different account spelling). This skill targets 0.3.0.
14
+ Package: `@linhey/rhine-design`. GitHub: `linhay/rhine-design` (different account spelling). This skill targets 0.4.0, including theme, navigation, form and timeline APIs. Never assume those APIs exist in an installed 0.3.0 package.
15
15
 
16
16
  Find the package in the host's dependencies, normally `node_modules/@linhey/rhine-design/`, or its registered source checkout. Verify `package.json` name/version before using the paths below. Paths in references are relative to that package root, not the copied skill directory. Read installed types and source when versions differ; do not infer a new API from this skill. If the package is unavailable, use the task's supplied design sources and identify the missing implementation evidence.
17
17
 
@@ -23,7 +23,7 @@ Find the package in the host's dependencies, normally `node_modules/@linhey/rhin
23
23
 
24
24
  ## Design before implementation
25
25
 
26
- 1. Inventory existing components and tokens for the requested behavior. Compose them before creating a new component or duplicating styles.
26
+ 1. Identify the task, interruption cost, information lifetime and recovery path using `docs/INTERACTION_PATTERNS.md` when present. Choose navigation, form, notification or overlay patterns before styling. Inventory existing components and tokens for the requested behavior. Compose them before creating a new component or duplicating styles.
27
27
  2. Update the host's page mockup or the library's real `preview/` showcase first. A new shared capability includes its contract and a usable example.
28
28
  3. Verify the changed states and interaction in the preview; then integrate into the app when implementation is in scope. Continue both stages when already authorized; a design-only request does not authorize an app migration.
29
29
  4. For library changes run `npm run check`; for visual changes inspect relevant widths (400/520/880), 200% zoom, keyboard/focus and reduced motion. For consumer changes run that project's checks. Documentation-only edits need link, example and version consistency checks, not an unrelated UI test campaign.
@@ -33,5 +33,7 @@ Find the package in the host's dependencies, normally `node_modules/@linhey/rhin
33
33
  - Use public exports, the `.rhine-theme` root and existing tokens. Use the `rhine-` namespace for CSS variables, classes, animation names and motion data attributes; do not introduce host-product prefixes.
34
34
  - Read the actual component signature before using it. Keep semantic headings separate from visual text variants.
35
35
  - Host state determines data, selection and operation outcomes. Search and pagination remain host/server responsibilities; busy indicators never imply success.
36
+ - For multi-field failures, preserve input and focus a summary only after failed submission; links return to fields without changing routing. Use SideNav/Breadcrumbs/Steps only when exported by the installed source.
37
+ - Monitoring shows units, freshness and stale/unknown states; errors must not rely on color, flashing or transient toasts.
36
38
  - Motion explains a change. Respect reduced motion, cancellation and replay; startup decoration never blocks real readiness. Preview-only experiments are not exported components.
37
39
  - Keep independent release boundaries: do not change unrelated products or publish packages merely because this skill was invoked.
@@ -1,9 +1,10 @@
1
1
  # API map
2
2
 
3
- All paths are relative to the matching package root. `components/index.ts` is the public export authority. This index locates APIs; exact props come from the installed source, not from remembered examples.
3
+ All paths are relative to the matching package root. `package.json` exports, `components/index.ts` and the separate `components/theme.tsx` entry are the public export authority. This index locates APIs; exact props come from the installed source, not from remembered examples.
4
4
 
5
5
  | Exports | Source |
6
6
  | --- | --- |
7
+ | ThemeProvider, useTheme, themes, ThemeName, ThemeProviderProps (0.4.0;`@linhey/rhine-design/theme`) | `components/theme.tsx`;契约 `docs/THEMING.md` |
7
8
  | Logo | `components/Logo.tsx` |
8
9
  | Text | `components/Typography.tsx` |
9
10
  | Select | `components/Select.tsx` |
@@ -12,7 +13,10 @@ All paths are relative to the matching package root. `components/index.ts` is th
12
13
  | Divider, Stack, Inline, Grid | `components/Layout.tsx` |
13
14
  | Metric, KeyValue, DataTable, Pagination | `components/Data.tsx` |
14
15
  | Checkbox, RadioGroup, Switch, Textarea, SearchField, Combobox | `components/Forms.tsx` |
15
- | Dialog, ConfirmDialog, Popover, Menu, Tooltip | `components/Overlays.tsx` |
16
+ | Dialog, Drawer (0.4.0), ConfirmDialog, Popover, Menu, Tooltip | `components/Overlays.tsx` |
17
+ | StatusTimeline, TimelineLegend (0.4.0) | `components/StatusTimeline.tsx`;历史证据,不计算健康评分 |
18
+ | SideNav, Breadcrumbs, Steps (0.4.0) | `components/Navigation.tsx` |
19
+ | Fieldset, ErrorSummary (0.4.0) | `components/FormWorkflow.tsx` |
16
20
  | Toast | `components/Toast.tsx` |
17
21
  | Button, Badge, Surface, SelectionCard, TextField, Tabs, SegmentedControl, Notice, Progress, Skeleton, Disclosure, EmptyState | `components/components.tsx` |
18
22
 
@@ -31,4 +35,4 @@ API decisions that commonly matter:
31
35
  - `Text`'s `as` chooses semantics; `variant` chooses visual hierarchy.
32
36
  - `Logo` accepts `wordmark` and `label`; its `theme` affects the logo, not the full component library.
33
37
  - `Toast` presents one notification, not a global queue. `DataTable` is not a query or table-state engine.
34
- - Components in `preview/` are demonstrations unless re-exported through `components/index.ts`.
38
+ - Components in `preview/` are demonstrations. Public API must resolve through a `package.json` export; theme APIs use the separate `/theme` entry.
@@ -4,8 +4,11 @@ Resolve the matching package root as described in SKILL.md. Open the first relev
4
4
 
5
5
  | Task | First reference (relative to package root) | Implementation / example |
6
6
  | --- | --- | --- |
7
+ | Theme integration, switching, controlled preferences | `docs/THEMING.md` (0.4.0) | `components/theme.tsx`, `preview/GalleryShell.tsx` |
8
+ | Scenario selection, navigation, form recovery, monitoring | `docs/INTERACTION_PATTERNS.md` (0.4.0; check installed version) | `preview/PatternGallery.tsx`; research in `docs/REFERENCE_RESEARCH.md` |
7
9
  | Visual direction, colors, spacing, layout | `docs/DESIGN_SYSTEM.md` | `components/tokens.css`, `components/Layout.tsx` |
8
- | Text hierarchy, Chinese/Latin text, metrics | `components/TYPOGRAPHY.md` | `components/Typography.tsx` |
10
+ | Spacing, grouping, card composition | `components/SPACING.md` (0.4.0 docs; existing gap API works in 0.3.0) | `components/Layout.tsx`, `preview/SpacingGallery.tsx`; `/#spacing` |
11
+ | Text hierarchy, line height, paragraphs, Chinese/Latin text, metrics | `components/TYPOGRAPHY.md` | `components/Typography.tsx`, `preview/LineHeightGallery.tsx`; `/#typography` |
9
12
  | Logo, light/dark variants, wordmark | `components/README.md` → 品牌标志 | `components/Logo.tsx`, `components/assets/logo.svg` |
10
13
  | Buttons, navigation, basic states | `components/README.md` → 组件契约 | `components/components.tsx` |
11
14
  | Forms, selection, combobox, data tables | `components/COMMON_COMPONENTS.md` | Use API_MAP for the source file |
@@ -2,7 +2,7 @@
2
2
 
3
3
  ## Supported baseline
4
4
 
5
- Rhine Design 0.3.0 ships TypeScript/TSX, CSS and SVG source for React 19 + Vite. Install `@linhey/rhine-design`; GitHub remains `linhay/rhine-design`. Check the installed package's engine and peer dependencies. Node 24 is the development baseline. Do not assume precompiled JavaScript, arbitrary SSR support or a complete dark theme.
5
+ Rhine Design 0.4.0 ships TypeScript/TSX, CSS and SVG source for React 19 + Vite. Install `@linhey/rhine-design`; GitHub remains `linhay/rhine-design`. Check the installed package's engine and peer dependencies. Node 24 is the development baseline. Do not assume precompiled JavaScript, arbitrary SSR support or a complete dark theme.
6
6
 
7
7
  ```tsx
8
8
  import { Button, Surface, Text } from '@linhey/rhine-design';
@@ -21,11 +21,13 @@ export function Example() {
21
21
 
22
22
  ## Integration invariants
23
23
 
24
+ Version 0.4.0 also exports `ThemeProvider`, `useTheme`, `themes`, `ThemeName` and `ThemeProviderProps` from `@linhey/rhine-design/theme` (not in npm 0.3.0). See [the theme contract](../../../docs/THEMING.md). This entry loads base and built-in theme CSS and renders the `.rhine-theme` root. Use `defaultTheme` for local state, or `theme` with `onThemeChange` for host state; storage and URL preferences remain with the host. Sibling providers are supported; nested providers throw. Do not wrap it in another themed root or key the subtree by theme. Gallery decoration is excluded from the public theme CSS.
25
+
24
26
  - The entry imports CSS automatically. Keep the package's entry and CSS `sideEffects` declarations so production tree shaking retains styles.
25
27
  - Put `.rhine-theme` at the page root. Do not transform, clip or apply containment to it: overlays portal to the nearest theme root and need its variables and reduced-motion settings.
26
28
  - For a locally linked checkout, use Vite `resolve.dedupe: ['react', 'react-dom']`. Duplicate React can break hooks; do not patch component state to mask this.
27
29
  - Preserve SVG `?no-inline` handling. Logo uses a shared geometric master; missing symbols in production warrant checking the emitted SVG asset before replacing the logo.
28
- - Reuse tokens for layout and state styling. Full dark theme is not exported in 0.3.0; the logo's dark variant alone does not establish dark-mode support.
30
+ - Reuse tokens for layout and state styling. The arknights preset uses a dark palette; a general light/dark/system color-mode API is not provided. Logo color remains independent of the component theme.
29
31
 
30
32
  ## Host responsibilities
31
33
 
@@ -0,0 +1,98 @@
1
+ import { useState } from 'react';
2
+ import { act, fireEvent, render, screen } from '@testing-library/react';
3
+ import { afterEach, describe, expect, it, vi } from 'vitest';
4
+ import { GalleryShell } from '../preview/GalleryShell';
5
+ import { themes } from '@linhey/rhine-design/theme';
6
+
7
+ function Demo() {
8
+ const [motion, setMotion] = useState('system');
9
+ return <GalleryShell motion={motion} onMotionChange={setMotion}>{section => <h1>{section}</h1>}</GalleryShell>;
10
+ }
11
+ function navigate(hash: string) {
12
+ act(() => {
13
+ window.history.replaceState(null, '', hash);
14
+ window.dispatchEvent(new HashChangeEvent('hashchange'));
15
+ });
16
+ }
17
+ afterEach(() => {
18
+ vi.restoreAllMocks();
19
+ window.history.replaceState(null, '', '/');
20
+ window.localStorage.clear();
21
+ });
22
+
23
+ describe('gallery theme integration', () => {
24
+ it('uses public metadata, prioritizes URL theme and persists choices without losing the chapter', () => {
25
+ window.localStorage.setItem('rhine-preview-theme', 'rhine');
26
+ window.history.replaceState(null, '', '/?theme=arknights&other=keep#forms');
27
+ render(<Demo />);
28
+ const picker = screen.getByRole('combobox', { name: '界面主题' });
29
+ expect(picker).toHaveValue('arknights');
30
+ expect(screen.getAllByRole('option').map(option => option.textContent)).toEqual(themes.map(item => item.label));
31
+ const main = screen.getByRole('main');
32
+ main.scrollTop = 120;
33
+ fireEvent.change(picker, { target: { value: 'neo-brutalism' } });
34
+ expect(main.closest('.rhine-theme')).toHaveAttribute('data-rhine-theme', 'neo-brutalism');
35
+ expect(main.scrollTop).toBe(120);
36
+ expect(screen.getByRole('heading')).toHaveTextContent('forms');
37
+ expect(window.localStorage.getItem('rhine-preview-theme')).toBe('neo-brutalism');
38
+ expect(window.location.search).toBe('?other=keep');
39
+ expect(window.location.hash).toBe('#forms');
40
+ });
41
+
42
+ it('restores a stored theme when the URL is invalid and falls back for unknown stored values', () => {
43
+ window.localStorage.setItem('rhine-preview-theme', 'neo-brutalism');
44
+ window.history.replaceState(null, '', '/?theme=unknown');
45
+ const { unmount } = render(<Demo />);
46
+ expect(screen.getByRole('combobox', { name: '界面主题' })).toHaveValue('neo-brutalism');
47
+ unmount();
48
+ window.localStorage.setItem('rhine-preview-theme', 'unknown');
49
+ render(<Demo />);
50
+ expect(screen.getByRole('combobox', { name: '界面主题' })).toHaveValue('rhine');
51
+ });
52
+
53
+ it('still switches when browser storage is unavailable', () => {
54
+ vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => { throw new Error('Storage disabled'); });
55
+ vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => { throw new Error('Storage disabled'); });
56
+ render(<Demo />);
57
+ const picker = screen.getByRole('combobox', { name: '界面主题' });
58
+ fireEvent.change(picker, { target: { value: 'arknights' } });
59
+ expect(picker).toHaveValue('arknights');
60
+ expect(screen.getByRole('main').closest('.rhine-theme')).toHaveAttribute('data-rhine-theme', 'arknights');
61
+ });
62
+ });
63
+
64
+ describe('gallery chapter navigation', () => {
65
+ it('opens deep links and follows history changes with one chapter and a current link', () => {
66
+ window.history.replaceState(null, '', '#typography');
67
+ render(<Demo />);
68
+ expect(screen.getByRole('heading')).toHaveTextContent('typography');
69
+ expect(screen.getByRole('link', {name:'字体与文字'})).toHaveAttribute('aria-current', 'page');
70
+ navigate('#motion');
71
+ expect(screen.getAllByRole('heading')).toHaveLength(1);
72
+ expect(screen.getByRole('heading')).toHaveTextContent('motion');
73
+ expect(screen.getByRole('main')).toHaveFocus();
74
+ navigate('#typography');
75
+ expect(screen.getByRole('heading')).toHaveTextContent('typography');
76
+ navigate('#unknown');
77
+ expect(screen.getByRole('heading')).toHaveTextContent('top');
78
+ });
79
+ it('keeps motion preferences while closing navigation on chapter changes', () => {
80
+ render(<Demo />);
81
+ fireEvent.click(screen.getByRole('radio', {name:'减少动态'}));
82
+ fireEvent.click(screen.getByRole('button', {name:'目录'}));
83
+ expect(screen.getByRole('button', {name:'目录'})).toHaveAttribute('aria-expanded','true');
84
+ navigate('#startup');
85
+ expect(screen.getByRole('button', {name:'目录'})).toHaveAttribute('aria-expanded','false');
86
+ expect(screen.getByRole('main').closest('.rhine-theme')).toHaveAttribute('data-rhine-motion','reduced');
87
+ });
88
+ it('closes the compact directory with Escape and returns focus to its trigger', () => {
89
+ render(<Demo />);
90
+ const trigger = screen.getByRole('button', {name:'目录'});
91
+ fireEvent.click(trigger);
92
+ const link = screen.getByRole('link', {name:'字体与文字'});
93
+ link.focus();
94
+ fireEvent.keyDown(link, {key:'Escape'});
95
+ expect(trigger).toHaveAttribute('aria-expanded','false');
96
+ expect(trigger).toHaveFocus();
97
+ });
98
+ });
@@ -0,0 +1,75 @@
1
+ import { fireEvent, render, screen, waitFor, within } from '@testing-library/react';
2
+ import { describe, expect, it, vi } from 'vitest';
3
+ import { Breadcrumbs, Drawer, ErrorSummary, Fieldset, SideNav, Steps, TextField } from '../components';
4
+ import { PatternGallery } from '../preview/PatternGallery';
5
+
6
+ describe('navigation and form workflow contracts', () => {
7
+ it('keeps split dialog selection and preview in one accessible modal', async () => {
8
+ render(<PatternGallery section="patterns"/>);
9
+ fireEvent.change(screen.getByRole('combobox',{name:'我正在设计什么场景'}),{target:{value:'monitor'}});
10
+ fireEvent.click(screen.getByRole('button',{name:'左右分栏预览'}));
11
+ const dialog=await screen.findByRole('dialog',{name:'选择范围'});
12
+ expect(dialog).toHaveClass('rhine-dialog--split');
13
+ fireEvent.change(within(dialog).getByRole('combobox',{name:'范围'}),{target:{value:'backup'}});
14
+ expect(within(dialog).getByRole('heading',{name:'备用资源 · 健康记录'})).toBeVisible();
15
+ expect(within(dialog).getByRole('slider')).toHaveAttribute('aria-valuetext','时段 96 · 未知 / 无数据');
16
+ fireEvent.keyDown(document.activeElement!,{key:'Escape'});
17
+ await waitFor(()=>expect(screen.queryByRole('dialog')).not.toBeInTheDocument());
18
+ });
19
+ it('preserves real navigation links and identifies the current location', () => {
20
+ const navigate = vi.fn();
21
+ render(<><SideNav groups={[{label:'工作区',items:[{id:'a',label:'概览',href:'/overview',icon:'chart'},{id:'b',label:'记录',href:'/records'}]}]} currentId="b" onNavigate={(item,event)=>{event.preventDefault();navigate(item.id);}}/><Breadcrumbs items={[{label:'首页',href:'/'},{label:'记录'}]}/></>);
22
+ expect(screen.getByRole('link',{name:'记录'})).toHaveAttribute('href','/records');
23
+ expect(screen.getByRole('link',{name:'记录'})).toHaveAttribute('aria-current','page');
24
+ fireEvent.click(screen.getByRole('link',{name:'概览'})); expect(navigate).toHaveBeenCalledWith('a');
25
+ expect(within(screen.getByRole('navigation',{name:'当前位置'})).getByText('记录')).toHaveAttribute('aria-current','page');
26
+ });
27
+ it('only permits returning to completed steps and exposes current/error/completed states', () => {
28
+ const change=vi.fn(), items=[{id:'a',label:'输入'},{id:'b',label:'核对'},{id:'c',label:'完成'}];
29
+ const {rerender}=render(<Steps items={items} current={1} onStepChange={change} error="信息不完整"/>);
30
+ expect(screen.getAllByRole('button')).toHaveLength(1);
31
+ fireEvent.click(screen.getByRole('button',{name:'返回输入'}));expect(change).toHaveBeenCalledWith(0);
32
+ expect(screen.getByText('核对').closest('li')).toHaveAttribute('aria-current','step');
33
+ expect(screen.getByText(/需要修正/)).toHaveTextContent('信息不完整');
34
+ rerender(<Steps items={items} current={3}/>);
35
+ expect(screen.getAllByText('已完成')).toHaveLength(3);
36
+ expect(document.querySelector('[aria-current=step]')).toBeNull();
37
+ });
38
+ it('groups fields semantically and supports native disabled fieldsets', () => {
39
+ render(<Fieldset legend="连接" hint="只读任务暂不可编辑" disabled><TextField label="地址"/></Fieldset>);
40
+ expect(screen.getByRole('group',{name:'连接'})).toHaveAccessibleDescription('只读任务暂不可编辑');
41
+ expect(screen.getByRole('textbox',{name:'地址'})).toBeDisabled();
42
+ });
43
+ it('focuses an error summary only for submissions and links back without changing routes', () => {
44
+ const errors=[{fieldId:'workspace',message:'请输入名称'}];
45
+ const {rerender}=render(<><ErrorSummary errors={errors} focusKey={1}/><TextField id="workspace" label="名称"/></>);
46
+ expect(screen.getByRole('region',{name:'请修正以下问题'})).toHaveFocus();
47
+ const before=window.location.hash;fireEvent.click(screen.getByRole('link',{name:'请输入名称'}));
48
+ expect(screen.getByRole('textbox')).toHaveFocus();expect(window.location.hash).toBe(before);
49
+ rerender(<><ErrorSummary errors={[...errors]} focusKey={1}/><TextField id="workspace" label="名称"/></>);
50
+ expect(screen.getByRole('textbox')).toHaveFocus();
51
+ rerender(<><ErrorSummary errors={errors} focusKey={2}/><TextField id="workspace" label="名称"/></>);
52
+ expect(screen.getByRole('region')).toHaveFocus();
53
+ });
54
+ it('opens a themed drawer and restores the trigger focus on Escape', async () => {
55
+ render(<div className="rhine-theme" data-rhine-motion="reduced"><Drawer trigger={<button>查看详情</button>} title="详情" description="记录信息"><TextField label="备注"/></Drawer></div>);
56
+ const trigger=screen.getByRole('button',{name:'查看详情'}); trigger.focus();fireEvent.click(trigger);
57
+ const dialog=await screen.findByRole('dialog'); expect(dialog).toHaveClass('rhine-drawer');
58
+ expect(dialog.closest('.rhine-theme')).toHaveAttribute('data-rhine-motion','reduced');
59
+ expect(dialog).toContainElement(document.activeElement as HTMLElement);
60
+ fireEvent.keyDown(document.activeElement!,{key:'Escape'});
61
+ await waitFor(()=>expect(screen.queryByRole('dialog')).not.toBeInTheDocument());
62
+ await waitFor(()=>expect(trigger).toHaveFocus());
63
+ });
64
+ it('retains form input on failure and clears the summary only after valid resubmission', () => {
65
+ render(<PatternGallery section="form-workflow"/>);
66
+ fireEvent.change(screen.getByRole('textbox',{name:'工作区名称'}),{target:{value:'研究'}});
67
+ fireEvent.click(screen.getByRole('button',{name:'验证配置'}));
68
+ expect(screen.getByRole('textbox',{name:'工作区名称'})).toHaveValue('研究');
69
+ expect(screen.getByRole('region')).toHaveFocus();
70
+ fireEvent.change(screen.getByRole('textbox',{name:'服务地址'}),{target:{value:'https://example.com'}});
71
+ fireEvent.click(screen.getByRole('button',{name:'验证配置'}));
72
+ expect(screen.queryByRole('region')).not.toBeInTheDocument();
73
+ expect(screen.getByRole('status')).toHaveTextContent('没有保存到服务端');
74
+ });
75
+ });
@@ -0,0 +1,15 @@
1
+ import { fireEvent, render, screen } from '@testing-library/react';
2
+ import { expect, it } from 'vitest';
3
+ import { StatusTimeline, TimelineLegend } from '../components';
4
+
5
+ it('announces time and evidence with a single native range control, including unknown and empty states',()=>{
6
+ const {rerender}=render(<><StatusTimeline label="账号历史" startLabel="昨天" endLabel="今天" segments={[{id:'a',label:'10:00',status:'failed'},{id:'b',label:'10:15',status:'unknown'}]}/><TimelineLegend/></>);
7
+ const slider=screen.getByRole('slider',{name:'账号历史'});
8
+ expect(slider).toHaveAttribute('aria-valuetext','10:15 · 未知 / 无数据');
9
+ fireEvent.change(slider,{target:{value:'0'}});
10
+ expect(slider).toHaveAttribute('aria-valuetext','10:00 · 存在最终失败');
11
+ expect(screen.getByRole('list',{name:'健康记录图例'})).toHaveTextContent('重试已恢复');
12
+ rerender(<StatusTimeline label="账号历史" startLabel="昨天" endLabel="今天" segments={[]}/>);
13
+ expect(screen.getByRole('slider')).toBeDisabled();
14
+ expect(screen.getByText('暂无记录')).toBeVisible();
15
+ });