@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
@@ -65,7 +65,7 @@ describe('common design components', () => {
65
65
  });
66
66
  it('keeps a failed confirmation open and closes only after successful retry', async () => {
67
67
  const confirm=vi.fn().mockRejectedValueOnce(new Error('demo')).mockResolvedValue(undefined);
68
- render(<div className="tod-theme"><ConfirmDialog trigger={<button>打开</button>} title="确认任务" description="演示" onConfirm={confirm}/></div>);
68
+ render(<div className="rhine-theme"><ConfirmDialog trigger={<button>打开</button>} title="确认任务" description="演示" onConfirm={confirm}/></div>);
69
69
  fireEvent.click(screen.getByRole('button',{name:'打开'}));expect(await screen.findByRole('alertdialog')).toBeVisible();expect(screen.getByRole('button',{name:'取消'})).toHaveFocus();fireEvent.click(screen.getByRole('button',{name:'确认操作'}));expect(await screen.findByRole('alert')).toHaveTextContent('操作未完成');fireEvent.click(screen.getByRole('button',{name:'确认操作'}));await waitFor(()=>expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument());
70
70
  });
71
71
  });
@@ -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
+ });
@@ -44,11 +44,11 @@ it('keeps the same brand node when readiness arrives and makes the login preview
44
44
  vi.useFakeTimers();
45
45
  const { container } = render(<StartupGallery reduced={false} />);
46
46
  fireEvent.click(screen.getByRole('button', { name: '播放启动动画' }));
47
- const logo = container.querySelector('.ds-startup-logo-anchor .tod-logo');
47
+ const logo = container.querySelector('.ds-startup-logo-anchor .rhine-logo');
48
48
  act(() => vi.advanceTimersByTime(300));
49
49
  fireEvent.click(screen.getByRole('button', { name: '模拟就绪 · 立即进入' }));
50
- expect(container.querySelector('.ds-startup-logo-anchor .tod-logo')).toBe(logo);
51
- expect(container.querySelectorAll('.ds-startup-stage .tod-logo')).toHaveLength(1);
50
+ expect(container.querySelector('.ds-startup-logo-anchor .rhine-logo')).toBe(logo);
51
+ expect(container.querySelectorAll('.ds-startup-stage .rhine-logo')).toHaveLength(1);
52
52
  fireEvent.click(screen.getByRole('button', { name: '在浏览器中登录' }));
53
53
  expect(screen.getByText('仅演示登录入口,不会打开授权页面。')).toBeInTheDocument();
54
54
  expect(vi.getTimerCount()).toBe(0);
@@ -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
+ });
@@ -0,0 +1,112 @@
1
+ import { useState } from 'react';
2
+ import { fireEvent, render, screen, within } from '@testing-library/react';
3
+ import { describe, expect, it, vi } from 'vitest';
4
+ import { ThemeProvider, themes, useTheme, type ThemeName } from '@linhey/rhine-design/theme';
5
+ import { Button, Dialog } from '../components';
6
+
7
+ function Controls() {
8
+ const { theme, setTheme } = useTheme();
9
+ const [count, setCount] = useState(0);
10
+ return <>
11
+ <output aria-label="当前主题">{theme}</output>
12
+ {themes.map(item => <button key={item.name} onClick={() => setTheme(item.name)}>{item.label}</button>)}
13
+ <button onClick={() => setCount(count + 1)}>计数 {count}</button>
14
+ <input aria-label="草稿" defaultValue="" />
15
+ </>;
16
+ }
17
+
18
+ describe('public theme API', () => {
19
+ it('defaults to rhine and preserves the DOM, state and input across all themes', () => {
20
+ const onThemeChange = vi.fn();
21
+ render(<ThemeProvider onThemeChange={onThemeChange} className="host" id="app" data-rhine-motion="reduced"><Controls /></ThemeProvider>);
22
+ const root = document.getElementById('app')!;
23
+ expect(root).toHaveClass('rhine-theme', 'host');
24
+ expect(root).toHaveAttribute('data-rhine-motion', 'reduced');
25
+ expect(root).toHaveAttribute('data-rhine-theme', 'rhine');
26
+ fireEvent.click(screen.getByRole('button', { name: '计数 0' }));
27
+ const input = screen.getByRole('textbox', { name: '草稿' });
28
+ fireEvent.change(input, { target: { value: '未保存的内容' } });
29
+ for (const name of ['arknights', 'neo-brutalism', 'rhine'] as const) {
30
+ fireEvent.click(screen.getByRole('button', { name: themes.find(item => item.name === name)!.label }));
31
+ expect(root).toHaveAttribute('data-rhine-theme', name);
32
+ expect(screen.getByLabelText('当前主题')).toHaveTextContent(name);
33
+ expect(screen.getByRole('textbox')).toBe(input);
34
+ expect(input).toHaveValue('未保存的内容');
35
+ expect(screen.getByRole('button', { name: '计数 1' })).toBeInTheDocument();
36
+ }
37
+ expect(onThemeChange.mock.calls).toEqual([['arknights'], ['neo-brutalism'], ['rhine']]);
38
+ fireEvent.click(screen.getByRole('button', { name: '莱茵 · 纸面' }));
39
+ expect(onThemeChange).toHaveBeenCalledTimes(3);
40
+ });
41
+
42
+ it('uses defaultTheme only on mount', () => {
43
+ const { rerender } = render(<ThemeProvider defaultTheme="arknights"><Controls /></ThemeProvider>);
44
+ expect(screen.getByLabelText('当前主题')).toHaveTextContent('arknights');
45
+ rerender(<ThemeProvider defaultTheme="neo-brutalism"><Controls /></ThemeProvider>);
46
+ expect(screen.getByLabelText('当前主题')).toHaveTextContent('arknights');
47
+ });
48
+
49
+ it('leaves controlled updates to the host and follows subsequent props', () => {
50
+ const onThemeChange = vi.fn();
51
+ const { rerender } = render(<ThemeProvider theme="rhine" onThemeChange={onThemeChange}><Controls /></ThemeProvider>);
52
+ fireEvent.click(screen.getByRole('button', { name: '明日方舟 · 战术终端' }));
53
+ expect(onThemeChange).toHaveBeenCalledWith('arknights');
54
+ expect(screen.getByLabelText('当前主题')).toHaveTextContent('rhine');
55
+ rerender(<ThemeProvider theme="arknights" onThemeChange={onThemeChange}><Controls /></ThemeProvider>);
56
+ expect(screen.getByLabelText('当前主题')).toHaveTextContent('arknights');
57
+ expect(onThemeChange).toHaveBeenCalledTimes(1);
58
+ });
59
+
60
+ it('keeps sibling providers independent', () => {
61
+ render(<><ThemeProvider aria-label="A"><Controls /></ThemeProvider><ThemeProvider aria-label="B" defaultTheme="neo-brutalism"><Controls /></ThemeProvider></>);
62
+ const a = screen.getByLabelText('A'), b = screen.getByLabelText('B');
63
+ fireEvent.click(within(a).getByRole('button', { name: '明日方舟 · 战术终端' }));
64
+ expect(a).toHaveAttribute('data-rhine-theme', 'arknights');
65
+ expect(b).toHaveAttribute('data-rhine-theme', 'neo-brutalism');
66
+ expect(within(b).getByLabelText('当前主题')).toHaveTextContent('neo-brutalism');
67
+ });
68
+
69
+ it('does not read or write host storage', () => {
70
+ const read = vi.spyOn(Storage.prototype, 'getItem');
71
+ const write = vi.spyOn(Storage.prototype, 'setItem');
72
+ try {
73
+ render(<ThemeProvider><Controls /></ThemeProvider>);
74
+ fireEvent.click(screen.getByRole('button', { name: '明日方舟 · 战术终端' }));
75
+ expect(read).not.toHaveBeenCalled();
76
+ expect(write).not.toHaveBeenCalled();
77
+ } finally { read.mockRestore(); write.mockRestore(); }
78
+ });
79
+
80
+ it('keeps an open dialog in the same theme root, preserving its draft and focus', () => {
81
+ render(<ThemeProvider><Dialog trigger={<Button>打开</Button>} title="主题测试" description="保持浮层状态"><Controls /></Dialog></ThemeProvider>);
82
+ const trigger = screen.getByRole('button', { name: '打开' });
83
+ fireEvent.click(trigger);
84
+ const dialog = screen.getByRole('dialog');
85
+ const root = trigger.closest('.rhine-theme');
86
+ expect(dialog.closest('.rhine-theme')).toBe(root);
87
+ fireEvent.change(within(dialog).getByRole('textbox'), { target: { value: '浮层草稿' } });
88
+ const switcher = within(dialog).getByRole('button', { name: '明日方舟 · 战术终端' });
89
+ switcher.focus();
90
+ fireEvent.click(switcher);
91
+ expect(root).toHaveAttribute('data-rhine-theme', 'arknights');
92
+ expect(screen.getByRole('dialog')).toBe(dialog);
93
+ expect(within(dialog).getByRole('textbox')).toHaveValue('浮层草稿');
94
+ expect(switcher).toHaveFocus();
95
+ });
96
+
97
+ it('explains missing providers, nested providers and invalid theme names', () => {
98
+ expect(() => render(<Controls />)).toThrow('useTheme must be used within');
99
+ expect(() => render(<ThemeProvider><ThemeProvider /></ThemeProvider>)).toThrow('Nested Rhine ThemeProvider is not supported');
100
+ expect(() => render(<ThemeProvider theme={'unknown' as ThemeName} />)).toThrow('Unknown Rhine theme');
101
+ });
102
+
103
+ it('rejects invalid imperative requests without updating state or notifying the host', () => {
104
+ let setTheme: (theme: ThemeName) => void = () => {};
105
+ function Capture() { setTheme = useTheme().setTheme; return null; }
106
+ const onThemeChange = vi.fn();
107
+ render(<ThemeProvider onThemeChange={onThemeChange}><Capture /><Controls /></ThemeProvider>);
108
+ expect(() => setTheme('unknown' as ThemeName)).toThrow('Unknown Rhine theme');
109
+ expect(screen.getByLabelText('当前主题')).toHaveTextContent('rhine');
110
+ expect(onThemeChange).not.toHaveBeenCalled();
111
+ });
112
+ });