@linhey/rhine-design 0.10.0 → 0.11.1

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 (72) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/CONTRIBUTING.md +3 -1
  3. package/README.md +17 -14
  4. package/components/AdvancedForms.tsx +13 -5
  5. package/components/COMMON_COMPONENTS.md +5 -3
  6. package/components/Forms.tsx +66 -6
  7. package/components/README.md +13 -5
  8. package/components/SPACING.md +9 -0
  9. package/components/Toast.tsx +10 -4
  10. package/components/common.css +4 -0
  11. package/components/components.tsx +13 -7
  12. package/components/styles.css +12 -0
  13. package/components/useFloatingScale.ts +7 -3
  14. package/docs/APPLICATION_COMPONENTS.md +2 -2
  15. package/docs/DESIGN_SYSTEM.md +10 -1
  16. package/docs/DESIGN_SYSTEM_AUDIT.md +4 -4
  17. package/docs/DISCLOSURE_VERIFICATION.md +49 -0
  18. package/docs/FLOATING_OVERLAYS.md +16 -0
  19. package/docs/INTERACTION_PATTERNS.md +9 -1
  20. package/docs/REVIEW_TOOL.md +128 -0
  21. package/docs/REVIEW_VERIFICATION.md +56 -0
  22. package/docs/SUMMARY_TABS.md +77 -0
  23. package/docs/THEMING.md +1 -1
  24. package/docs/assets/disclosure/arknights-400.png +0 -0
  25. package/docs/assets/disclosure/neo-brutalism-400.png +0 -0
  26. package/docs/assets/disclosure/rhine-400.png +0 -0
  27. package/docs/assets/disclosure/rhine-native200.png +0 -0
  28. package/examples/review/http/App.tsx +46 -0
  29. package/examples/review/http/catalog.tsx +44 -0
  30. package/examples/review/http/details.ts +1 -0
  31. package/examples/review/http/vite.config.ts +8 -0
  32. package/examples/review/platform/Workspace.tsx +34 -0
  33. package/examples/review/platform/catalog.tsx +46 -0
  34. package/examples/review/platform/vite.config.ts +8 -0
  35. package/examples/review/product.css +3 -0
  36. package/package.json +17 -5
  37. package/preview/CommonGallery.tsx +1 -1
  38. package/preview/DenseFilterControlsGallery.tsx +5 -2
  39. package/preview/GalleryShell.tsx +3 -2
  40. package/preview/OrbitGallery.tsx +1 -1
  41. package/preview/SpacingGallery.tsx +9 -1
  42. package/preview/SummaryTabsGallery.tsx +37 -0
  43. package/preview/main.tsx +7 -3
  44. package/preview/packageInfo.ts +5 -0
  45. package/review/canvas.tsx +107 -0
  46. package/review/dev-client.mjs +17 -0
  47. package/review/feedback.ts +17 -0
  48. package/review/index.ts +2 -0
  49. package/review/isolation.ts +64 -0
  50. package/review/lifecycle.ts +59 -0
  51. package/review/protocol.ts +7 -0
  52. package/review/review.css +41 -0
  53. package/review/shell.tsx +165 -0
  54. package/review/state.ts +41 -0
  55. package/review/types.ts +59 -0
  56. package/review/vite.d.mts +10 -0
  57. package/review/vite.mjs +107 -0
  58. package/scripts/check-combobox-modal-focus.js +22 -0
  59. package/scripts/check-combobox-zoom.js +109 -0
  60. package/scripts/check-multiselect-completion.js +46 -0
  61. package/scripts/check-review-package.mjs +11 -0
  62. package/scripts/check-review-recovery.js +47 -0
  63. package/scripts/check-review.js +153 -0
  64. package/skills/rhine-design/references/API_MAP.md +4 -0
  65. package/skills/rhine-design/references/TASK_MAP.md +2 -0
  66. package/tests/CommonComponents.test.tsx +8 -0
  67. package/tests/DesignSystem.test.tsx +32 -0
  68. package/tests/FormParity.test.tsx +104 -2
  69. package/tests/Review.test.tsx +117 -0
  70. package/tests/ReviewBuild.test.tsx +59 -0
  71. package/tests/ReviewShell.test.tsx +63 -0
  72. package/tsconfig.json +5 -2
@@ -0,0 +1,46 @@
1
+ // Open the built gallery first; run with playwright-cli run-code --filename.
2
+ async page => {
3
+ const origin = await page.evaluate(() => location.origin);
4
+ const cases = [], errors = [];
5
+ page.on('pageerror', error => errors.push(String(error)));
6
+ for (const theme of ['rhine', 'arknights', 'neo-brutalism']) for (const width of [400, 520, 880]) for (const zoom of [1, 2]) {
7
+ await page.setViewportSize({ width, height: 960 });
8
+ await page.goto(`${origin}/?theme=${theme}#compact-filters`);
9
+ await page.reload();
10
+ await page.getByRole('heading', { name: '密集筛选控件', exact: true }).waitFor();
11
+ await page.evaluate(zoom => { document.documentElement.style.zoom = String(zoom); }, zoom);
12
+ await page.getByRole('button', { name: '在弹窗内选择', exact: true }).click();
13
+ const dialog = page.getByRole('dialog', { name: '搜索多选', exact: true });
14
+ const input = dialog.getByRole('combobox', { name: '弹窗部门', exact: true });
15
+ await input.fill('合成部门 99');
16
+ const option = page.getByRole('option', { name: '合成部门 99', exact: true });
17
+ await option.click();
18
+ if (await option.getAttribute('aria-selected') !== 'true') throw Error('selection failed');
19
+ await option.click();
20
+ if (await option.getAttribute('aria-selected') !== 'false') throw Error('pointer deselection failed');
21
+ await input.press('ArrowDown'); await input.press('Enter');
22
+ if (await option.getAttribute('aria-selected') !== 'true') throw Error('keyboard selection failed');
23
+ await input.press('ArrowDown'); await input.press('Enter');
24
+ if (await option.getAttribute('aria-selected') !== 'false') throw Error('keyboard deselection failed');
25
+ await option.click();
26
+ const done = page.getByRole('button', { name: '完成选择', exact: true });
27
+ const popup = await done.evaluate(el => { const r = el.closest('.rhine-combobox__list').getBoundingClientRect(); return { left: r.left, right: r.right, top: r.top, bottom: r.bottom, width: innerWidth, height: innerHeight }; });
28
+ if (popup.left < -1 || popup.right > popup.width + 1 || popup.top < -1 || popup.bottom > popup.height + 1) throw Error(`popup outside viewport: ${JSON.stringify({ theme, width, zoom, popup })}`);
29
+ await done.click();
30
+ await page.getByRole('listbox').waitFor({ state: 'detached' });
31
+ if (!await input.evaluate(el => el === document.activeElement)) throw Error('completion lost focus');
32
+ if (!await dialog.isVisible() || await input.inputValue() !== '合成部门 99') throw Error('completion changed draft/dialog');
33
+ await input.fill('');
34
+ await page.getByRole('option', { name: '合成部门 99', exact: true }).waitFor();
35
+ await done.scrollIntoViewIfNeeded();
36
+ const rect = await done.evaluate(el => { const r = el.getBoundingClientRect(); return { left: r.left, right: r.right, top: r.top, bottom: r.bottom, width: innerWidth, height: innerHeight }; });
37
+ if (rect.left < -1 || rect.right > rect.width + 1 || rect.top < -1 || rect.bottom > rect.height + 1) throw Error(`completion outside viewport: ${JSON.stringify({ theme, width, zoom, rect })}`);
38
+ await done.focus(); await done.press('Enter');
39
+ await page.getByRole('listbox').waitFor({ state: 'detached' });
40
+ await input.click(); await done.focus(); await done.press('Escape');
41
+ await page.getByRole('listbox').waitFor({ state: 'detached' });
42
+ if (!await dialog.isVisible()) throw Error('Escape closed parent dialog');
43
+ cases.push({ theme, width, zoom, pass: true });
44
+ }
45
+ return { pass: !errors.length, cases: cases.length, errors };
46
+ }
@@ -0,0 +1,11 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ import assert from 'node:assert/strict';
3
+ const [pack] = JSON.parse(execFileSync('npm', ['pack', '--dry-run', '--json', '--ignore-scripts'], { encoding: 'utf8' }));
4
+ const files = pack.files.map(file => file.path);
5
+ for (const path of files) {
6
+ assert(!path.split('/').some(part => part === 'node_modules' || part.startsWith('.vite') || part.startsWith('.playwright')), `Package includes development cache: ${path}`);
7
+ }
8
+ for (const path of ['review/index.ts', 'review/vite.mjs', 'review/vite.d.mts', 'review/dev-client.mjs', 'examples/review/http/catalog.tsx', 'examples/review/platform/catalog.tsx', 'examples/review/product.css']) {
9
+ assert(files.includes(path), `Package omits review entry/example: ${path}`);
10
+ }
11
+ console.log('Review package verified: public entries/examples included, development caches excluded.');
@@ -0,0 +1,47 @@
1
+ // Run against the final static examples served on 18342. Chromium/Firefox/WebKit use the same checks.
2
+ async page => {
3
+ const failures = [], errors = [];
4
+ const check = (value, name) => { if (!value) failures.push(name); };
5
+ page.on('pageerror', error => errors.push(String(error)));
6
+ await page.setViewportSize({ width: 1280, height: 900 });
7
+ await page.goto('http://127.0.0.1:18342/review-platform/');
8
+ const frame = page.frameLocator('iframe');
9
+ await frame.getByRole('heading', { name: '桌面工作区' }).waitFor();
10
+ check(await page.getByRole('combobox', { name: '动态偏好' }).count() === 0, 'advanced settings collapsed');
11
+ check((await page.getByLabel('画布尺寸').textContent()).includes('等效内容宽度'), 'canvas dimensions visible');
12
+ await page.getByRole('link', { name: '清理错误留存', exact: true }).click();
13
+ await frame.getByText('离开此场景后,合成清理错误应保留到显式确认。').waitFor();
14
+ await page.getByRole('link', { name: '桌面工作区', exact: true }).click();
15
+ await frame.getByRole('heading', { name: '桌面工作区' }).waitFor();
16
+ await page.getByRole('alert').filter({ hasText: '合成清理失败' }).waitFor();
17
+ await page.getByRole('combobox', { name: '界面主题' }).selectOption('arknights');
18
+ await frame.locator('.rhine-theme[data-rhine-theme=arknights]').waitFor();
19
+ check(await page.getByRole('alert').filter({ hasText: '合成清理失败' }).count() === 1, 'ready does not overwrite error');
20
+ await page.locator('.rhine-review-log summary').click();
21
+ check(await page.locator('.rhine-review-log li[data-kind=error] strong').textContent() === '清理错误留存', 'error retains origin scene');
22
+ await page.getByRole('button', { name: '确认并清除反馈' }).click();
23
+ check(await page.getByRole('alert').count() === 0, 'explicit acknowledgement clears errors');
24
+ await page.getByRole('link', { name: '清理挂起恢复', exact: true }).click();
25
+ await frame.getByText('此场景模拟清理挂起,请切换到桌面工作区验证恢复。').waitFor();
26
+ await page.getByRole('link', { name: '桌面工作区', exact: true }).click();
27
+ await page.getByRole('alert').filter({ hasText: '初始化或清理超过 10 秒' }).waitFor({ timeout: 15000 });
28
+ check(await frame.getByRole('heading', { name: '桌面工作区' }).count() === 0, 'never initializes in uncleared context');
29
+ const oldFrame = await page.locator('iframe').elementHandle();
30
+ await page.getByRole('button', { name: '重新载入画布' }).click();
31
+ await frame.getByRole('heading', { name: '桌面工作区' }).waitFor();
32
+ check(await oldFrame.evaluate(element => !element.isConnected), 'recovery replaces browsing context');
33
+ check(await page.getByRole('combobox', { name: '界面主题' }).inputValue() === 'arknights', 'recovery keeps URL preferences');
34
+ check(await page.getByRole('alert').count() === 1, 'recovery retains prior timeout record');
35
+ await page.locator('.rhine-review-options summary').click();
36
+ await page.getByRole('combobox', { name: 'CSS 缩放' }).selectOption('2');
37
+ await page.getByRole('combobox', { name: '画布宽度' }).selectOption('520');
38
+ await page.waitForFunction(() => document.querySelector('[aria-label="画布尺寸"]')?.textContent.includes('等效内容宽度约 259 CSS px'));
39
+ await page.route('**/review-platform/canvas.html', route => route.abort());
40
+ await page.goto('http://127.0.0.1:18342/review-platform/');
41
+ await page.getByRole('alert').filter({ hasText: '连接画布超时' }).waitFor({ timeout: 15000 });
42
+ await page.unroute('**/review-platform/canvas.html');
43
+ await page.getByRole('button', { name: '重新载入画布' }).click();
44
+ await frame.getByRole('heading', { name: '桌面工作区' }).waitFor();
45
+ check(await page.getByRole('status').filter({ hasText: '已就绪' }).count() === 1, 'initial load failure recovers');
46
+ return { failures, errors, browser: await page.evaluate(() => navigator.userAgent) };
47
+ }
@@ -0,0 +1,153 @@
1
+ // Build both examples and serve dist at loopback port 18342; HTTP dev uses 18340.
2
+ // playwright-cli run-code --filename scripts/check-review.js
3
+ async page => {
4
+ const failures = [], errors = [], requests = [], consoleErrors = [], consoleChecks = [];
5
+ page.on('console', message => {
6
+ if (message.type() !== 'error') return;
7
+ // Firefox renders an Error argument as only "Error"; inspect the actual exception too.
8
+ consoleChecks.push(Promise.all(message.args().map(argument => argument.evaluate(value => value instanceof Error ? value.message : '').catch(() => ''))).then(messages => {
9
+ if (!message.text().includes('合成渲染失败') && !messages.includes('合成渲染失败')) consoleErrors.push(message.text());
10
+ }));
11
+ });
12
+ const check = (condition, message) => { if (!condition) failures.push(message); };
13
+ page.on('pageerror', error => errors.push(String(error)));
14
+ page.on('request', request => requests.push(request.url()));
15
+ const http = 'http://127.0.0.1:18342/review-http/';
16
+ const advanced = async () => { const details = page.locator('.rhine-review-options'); if (!await details.evaluate(element => element.open)) await details.locator('summary').click(); };
17
+ const motion = async value => { await advanced(); await page.getByRole('combobox', { name: '动态偏好' }).selectOption(value); };
18
+ let cases = 0;
19
+ for (const base of [http, 'http://127.0.0.1:18340/review-http/']) {
20
+ await page.goto(base);
21
+ const frame = page.frameLocator('iframe');
22
+ await frame.getByRole('heading', { name: '项目工作台' }).waitFor();
23
+ check(await page.locator('.rhine-review-header').evaluate(element => getComputedStyle(element).display === 'flex'), `${base} stylesheet loaded`);
24
+ await frame.getByRole('button', { name: '加载延迟模块' }).click();
25
+ await frame.getByText('延迟模块已加载 · 资源 base 与产品路由互相独立').waitFor();
26
+ await frame.getByRole('textbox', { name: '未保存草稿' }).fill('浏览器保留草稿');
27
+ await frame.getByRole('button', { name: '新建项目', exact: true }).click();
28
+ const input = frame.getByRole('textbox', { name: '项目名称' });
29
+ const handle = await input.elementHandle();
30
+ await input.focus();
31
+ for (const theme of ['rhine', 'arknights', 'neo-brutalism']) {
32
+ await page.getByRole('combobox', { name: '界面主题' }).selectOption(theme);
33
+ await page.getByRole('combobox', { name: '画布宽度' }).selectOption('520');
34
+ check(await input.inputValue() === '浏览器保留草稿', `${base} ${theme} draft`);
35
+ check(await handle.evaluate(element => element.isConnected && element === element.ownerDocument.activeElement), `${base} ${theme} node/focus`);
36
+ check(await frame.locator('.rhine-theme').count() === 1, `${base} single theme root`);
37
+ await frame.locator(`.rhine-theme[data-rhine-theme="${theme}"]`).waitFor();
38
+ }
39
+ await frame.getByRole('button', { name: '保存项目', exact: true }).click();
40
+ await frame.getByRole('dialog').waitFor({ state: 'detached' });
41
+ check(await frame.getByText('浏览器保留草稿', { exact: true }).count() === 1, `${base} form submit`);
42
+ await frame.getByRole('button', { name: '触发未实现操作' }).click();
43
+ await page.getByRole('alert').filter({ hasText: '未实现模拟请求' }).waitFor();
44
+ // Verify real navigation defaults never escape the runtime, while same-document anchors work.
45
+ const child = page.frames().find(frame => frame !== page.mainFrame());
46
+ const before = child.url();
47
+ for (const href of ['/product/detail', '/api/export', 'https://synthetic.invalid/export']) {
48
+ await child.evaluate(href => { const link = document.createElement('a'); link.href = href; link.textContent = '测试导航'; document.body.append(link); link.click(); link.remove(); }, href);
49
+ check(child.url() === before, `${base} blocked ${href}`);
50
+ }
51
+ await child.evaluate(() => { const link = document.createElement('a'); link.href = '#local-section'; document.body.append(link); link.click(); link.remove(); });
52
+ await child.waitForFunction(() => location.hash === '#local-section');
53
+ check(child.url().endsWith('#local-section'), `${base} local anchor`);
54
+ const beforeReset = await frame.getByRole('textbox', { name: '未保存草稿' }).elementHandle();
55
+ await page.getByRole('button', { name: '重置场景' }).click();
56
+ await beforeReset.evaluate(element => new Promise((resolve, reject) => { const start = performance.now(); const poll = () => { if (!element.isConnected) resolve(true); else if (performance.now() - start > 1500) reject(new Error('reset did not unmount previous session')); else requestAnimationFrame(poll); }; poll(); }));
57
+ await frame.getByRole('textbox', { name: '未保存草稿' }).waitFor();
58
+ check(await frame.getByRole('textbox', { name: '未保存草稿' }).inputValue() === '', `${base} reset`);
59
+ for (const state of ['loading', 'empty', 'error', 'dense', 'normal']) {
60
+ await page.getByRole('combobox', { name: '列表状态' }).selectOption(state);
61
+ const expected = { loading: '正在加载合成项目…', empty: '暂无项目', error: '合成服务暂不可用', dense: '合成项目 1 · 跨团队设计评审与持续验证的长标题示例', normal: '合成项目 1' }[state];
62
+ await frame.getByText(expected, { exact: true }).waitFor();
63
+ }
64
+ await page.getByRole('combobox', { name: '角色', exact: true }).selectOption('reader');
65
+ await frame.getByText('合成数据 · 只读 · 独立产品路由', { exact: true }).waitFor();
66
+ check(await frame.getByRole('button', { name: '新建项目', exact: true }).isDisabled(), `${base} read-only`);
67
+ const deepLink = page.url(); await page.reload();
68
+ await frame.getByRole('heading', { name: '项目工作台' }).waitFor();
69
+ check(page.url() === deepLink, `${base} refresh URL`);
70
+ await page.getByRole('combobox', { name: '角色', exact: true }).selectOption('editor');
71
+ await page.goBack();
72
+ check(await page.getByRole('combobox', { name: '角色', exact: true }).inputValue() === 'reader', `${base} history back`);
73
+ await page.goForward();
74
+ check(await page.getByRole('combobox', { name: '角色', exact: true }).inputValue() === 'editor', `${base} history forward`);
75
+ cases++;
76
+ }
77
+ // Final artifact visual / keyboard matrix (CSS zoom, not browser zoom).
78
+ for (const theme of ['rhine', 'arknights', 'neo-brutalism']) for (const width of [400, 520, 880, 1280]) for (const zoom of [1, 2]) {
79
+ await page.setViewportSize({ width, height: 1000 });
80
+ await page.goto(`${http}#scene=projects&theme=${theme}&width=fit&zoom=${zoom}&d.status=dense`);
81
+ const frame = page.frameLocator('iframe');
82
+ await frame.getByRole('button', { name: '新建项目', exact: true }).click();
83
+ const dialog = frame.getByRole('dialog');
84
+ await dialog.waitFor();
85
+ await frame.getByRole('textbox', { name: '项目名称' }).fill('窄屏键盘');
86
+ await page.keyboard.press('Tab');
87
+ check(await dialog.evaluate(element => element.contains(element.ownerDocument.activeElement)), `${theme}/${width}/${zoom} focus trap`);
88
+ await page.keyboard.press('Escape');
89
+ await dialog.waitFor({ state: 'detached' });
90
+ try { await frame.getByRole('button', { name: '新建项目', exact: true }).evaluate(element => new Promise((resolve, reject) => { const start = performance.now(); const poll = () => { if (element === document.activeElement) resolve(true); else if (performance.now() - start > 1500) reject(new Error('focus timeout')); else requestAnimationFrame(poll); }; poll(); })); } catch { check(false, `${theme}/${width}/${zoom} focus return`); }
91
+ const overflow = await page.evaluate(() => document.documentElement.scrollWidth > innerWidth + 1);
92
+ check(!overflow, `${theme}/${width}/${zoom} shell overflow`);
93
+ if (width === 400 && zoom === 2) await page.screenshot({ path: `output/playwright/review-${theme}-400-css200.png`, fullPage: true });
94
+ cases++;
95
+ }
96
+ await page.setViewportSize({ width: 400, height: 1000 });
97
+ await page.goto('http://127.0.0.1:18342/review-platform/');
98
+ await page.getByRole('button', { name: '场景目录' }).click();
99
+ await page.getByRole('textbox', { name: '搜索场景' }).fill('没有此场景');
100
+ await page.getByText('没有匹配场景').waitFor();
101
+ await page.getByRole('textbox', { name: '搜索场景' }).fill('');
102
+ await page.getByRole('textbox', { name: '搜索场景' }).focus();
103
+ await page.keyboard.press('Escape');
104
+ check(await page.getByRole('button', { name: '场景目录' }).evaluate(element => element === document.activeElement), 'mobile directory escape focus');
105
+ await page.getByRole('button', { name: '场景目录' }).click();
106
+ await page.getByRole('link', { name: '慢速初始化', exact: true }).click();
107
+ check(await page.getByRole('button', { name: '场景目录' }).getAttribute('aria-expanded') === 'false', 'mobile directory closes after navigation');
108
+ await page.setViewportSize({ width: 1280, height: 1000 });
109
+ await page.goto('http://127.0.0.1:18342/review-platform/');
110
+ const frame = page.frameLocator('iframe');
111
+ const actualCanvas = frame.locator('canvas');
112
+ await actualCanvas.waitFor();
113
+ const frames = async () => Number(await actualCanvas.getAttribute('data-frames'));
114
+ let count = await frames(); await page.waitForTimeout(120); check(await frames() > count, 'canvas plays');
115
+ await motion('paused');
116
+ await frame.getByText('已暂停', { exact: true }).waitFor();
117
+ count = await frames(); await page.waitForTimeout(120); check(await frames() === count, 'canvas pauses');
118
+ await motion('full');
119
+ count = await frames(); await page.waitForTimeout(120); check(await frames() > count, 'canvas resumes');
120
+ await motion('reduced');
121
+ await frame.getByText('减少动态', { exact: true }).waitFor();
122
+ count = await frames(); await page.waitForTimeout(120); check(await frames() === count, 'manual reduced');
123
+ await page.emulateMedia({ reducedMotion: 'reduce' });
124
+ await motion('system');
125
+ count = await frames(); await page.waitForTimeout(120); check(await frames() === count, 'system reduced');
126
+ await page.emulateMedia({ reducedMotion: 'no-preference' });
127
+ count = await frames(); await page.waitForTimeout(120); check(await frames() > count, 'system resumes');
128
+ const oldCanvas = await actualCanvas.elementHandle();
129
+ await frame.getByRole('textbox', { name: '工作区名称' }).fill('内存工作区');
130
+ await frame.getByRole('button', { name: '保存工作区' }).click();
131
+ await frame.getByText('内存工作区已保存').waitFor();
132
+ await page.getByRole('link', { name: '慢速初始化', exact: true }).click();
133
+ await frame.getByRole('heading', { name: '桌面工作区' }).waitFor({ state: 'detached' });
134
+ const oldCount = await oldCanvas.evaluate(element => element.dataset.frames);
135
+ await page.getByRole('link', { name: '桌面工作区', exact: true }).click();
136
+ await frame.getByRole('heading', { name: '桌面工作区' }).waitFor();
137
+ await page.waitForTimeout(4200);
138
+ check(await oldCanvas.evaluate(element => element.dataset.frames) === oldCount, 'unmounted canvas stops');
139
+ check(await frame.getByText('慢速初始化完成').count() === 0, 'late setup discarded');
140
+ await page.getByRole('link', { name: '渲染错误边界', exact: true }).click();
141
+ await frame.getByRole('alert').waitFor();
142
+ await page.getByRole('alert').filter({ hasText: '场景渲染失败' }).waitFor();
143
+ await page.getByRole('link', { name: '桌面工作区', exact: true }).click();
144
+ await frame.getByRole('heading', { name: '桌面工作区' }).waitFor();
145
+ await page.screenshot({ path: 'output/playwright/review-platform.png', fullPage: true });
146
+ const outbounds = requests.filter(url => !url.startsWith('http://127.0.0.1:1834') && !url.startsWith('data:'));
147
+ const businessRequests = requests.filter(url => /\/api\//.test(url));
148
+ check(!outbounds.length && !businessRequests.length, 'no real business/network egress');
149
+ check(!errors.length, `page errors: ${errors}`);
150
+ await Promise.all(consoleChecks);
151
+ check(!consoleErrors.length, `console errors: ${consoleErrors}`);
152
+ return { cases, failures, errors, consoleErrors, outbounds, businessRequests, requests: requests.length };
153
+ }
@@ -34,6 +34,8 @@ All paths are relative to the matching package root. `package.json` exports, `co
34
34
  | Toast | `components/Toast.tsx` |
35
35
  | Button, Badge, Surface, SelectionCard, TextField, Tabs, SegmentedControl, Notice, Progress, Skeleton, Disclosure, EmptyState | `components/components.tsx`;Surface 的 `padding="none"` 仅取消表格容器外围内距,默认不变;此选项自 0.9.1 提供,见 `CHANGELOG.md` |
36
36
 
37
+ Additions in 0.11.0: `Tabs` / `TabItem` `description` and `status`, `Toast placement="floating"`, and `SegmentedControl size="compact"`. They are absent from npm 0.10.0. Pin 0.11.0 and read `CHANGELOG.md` before upgrading.
38
+
37
39
  Primary contracts: `components/README.md` and `components/COMMON_COMPONENTS.md`. Find behavioral evidence in `tests/` and composition examples in `preview/`.
38
40
 
39
41
  ```sh
@@ -55,3 +57,5 @@ API decisions that commonly matter:
55
57
  - Components in `preview/` are demonstrations. Public API must resolve through a `package.json` export; theme APIs use the separate `/theme` entry.
56
58
 
57
59
  Available since 0.8.0 (check installed exports): `ThemePicker` / `ThemePreview` in `components/ThemePicker.tsx`, contract `docs/THEME_PICKER.md`; `Dialog.navigation` in `components/Overlays.tsx`, contract `docs/SETTINGS_DIALOG.md`; shared floating CSS zoom behavior in `docs/FLOATING_OVERLAYS.md`.
60
+
61
+ Product review tooling (since 0.11.0): `/review` exports `defineReview`, URL helpers and scene/session types from `review/index.ts`; `/review/vite` exports Node-only `reviewPlugin` and `reviewProductionGuard` from `review/vite.mjs`, types in `review/vite.d.mts`. Never import the Vite entry into production UI. Read `docs/REVIEW_TOOL.md`; npm 0.10.0 does not contain these entries.
@@ -6,6 +6,7 @@ Resolve the matching package root as described in SKILL.md. Open the first relev
6
6
  | --- | --- | --- |
7
7
  | Application shell, backgrounds, media, advanced inputs, controlled tables | `docs/APPLICATION_COMPONENTS.md` (0.5.0) | `preview/ExtensionsGallery.tsx`; `/#app-shell`, `/#advanced-forms`, `/#advanced-data`, `/#media` |
8
8
  | Workspace header, duplicate page titles, optional breadcrumbs | `docs/APPLICATION_COMPONENTS.md` → 平级工作区顶部操作区 (source guideline) | `preview/ExtensionsGallery.tsx`; `/#app-shell`; keep status/actions, navigation restore and accessible page identity |
9
+ | Product design review shell, synthetic HTTP/platform scenes, static deployment | `docs/REVIEW_TOOL.md` (since 0.11.0; not npm 0.10.0) | `review/`, `examples/review/http`, `examples/review/platform`; separate `/review` and `/review/vite` exports |
9
10
  | Theme integration, switching, controlled preferences | `docs/THEMING.md` (0.4.0) | `components/theme.tsx`, `preview/GalleryShell.tsx` |
10
11
  | 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` |
11
12
  | Date presets, visible date ranges, custom-range drafts and confirmation | `docs/DATE_RANGE_FILTER.md` (source pattern using existing 0.6.0 APIs) | `preview/DateRangeFilterGallery.tsx`; `/#date-range-filter`; no public DateRangeFilter component |
@@ -14,6 +15,7 @@ Resolve the matching package root as described in SKILL.md. Open the first relev
14
15
  | Text hierarchy, line height, paragraphs, Chinese/Latin text, metrics | `components/TYPOGRAPHY.md` | `components/Typography.tsx`, `preview/LineHeightGallery.tsx`; `/#typography` |
15
16
  | Logo, light/dark variants, wordmark | `components/README.md` → 品牌标志 | `components/Logo.tsx`, `components/assets/logo.svg` |
16
17
  | Buttons, navigation, basic states | `components/README.md` → 组件契约 | `components/components.tsx` |
18
+ | Tabs with identity summaries and per-object status | `docs/SUMMARY_TABS.md` (local candidate; verify installed API) | `Tabs.description/status`, `preview/SummaryTabsGallery.tsx`; `/#summary-tabs`; 12/8/4/16/24 spacing, stable labels and accessible descriptions |
17
19
  | Task-centered content reduction, borderless account summaries, credential actions, compact legends | `docs/COMPACT_ACCOUNT_PATTERNS.md` (source candidates; not npm 0.6.0) | `preview/CompactAccountGallery.tsx`; `/#compact-account`; ButtonLink + StatusTimelineList + TimelineLegend |
18
20
  | Forms, selection, combobox, data tables | `components/COMMON_COMPONENTS.md` | Use API_MAP for the source file |
19
21
  | Modal, menu, popover, tooltip, feedback | `components/COMMON_COMPONENTS.md` | `components/Overlays.tsx`, `components/Toast.tsx` |
@@ -59,6 +59,14 @@ describe('common design components', () => {
59
59
  act(()=>vi.advanceTimersByTime(2000));fireEvent.mouseEnter(screen.getByRole('status'));act(()=>vi.advanceTimersByTime(10000));expect(close).not.toHaveBeenCalled();fireEvent.mouseLeave(screen.getByRole('status'));act(()=>vi.advanceTimersByTime(2999));expect(close).not.toHaveBeenCalled();act(()=>vi.advanceTimersByTime(1));expect(close).toHaveBeenCalledWith(false);
60
60
  close.mockClear();rerender(<Toast open onOpenChange={close} title="失败" tone="error" duration={1000}/>);act(()=>vi.advanceTimersByTime(10000));expect(close).not.toHaveBeenCalled();
61
61
  });
62
+ it('portals floating feedback outside the content panel while preserving theme and focus', () => {
63
+ const {container,unmount}=render(<div className="rhine-theme"><section><button>继续</button><Toast placement="floating" open onOpenChange={()=>{}} title="完成"/></section></div>);
64
+ const trigger=screen.getByRole('button',{name:'继续'});trigger.focus();
65
+ const region=screen.getByRole('status').parentElement!;
66
+ expect(region.parentElement).toBe(container.querySelector('.rhine-theme'));
67
+ expect(container.querySelector('section')).not.toContainElement(region);
68
+ expect(trigger).toHaveFocus();unmount();expect(screen.queryByText('完成')).toBeNull();
69
+ });
62
70
  it('resets toast countdown when reopening', () => {
63
71
  vi.useFakeTimers();function Demo(){const [open,setOpen]=useState(true);return <><button onClick={()=>setOpen(true)}>显示</button><Toast open={open} onOpenChange={setOpen} title="通知" duration={1000}/></>;}
64
72
  render(<Demo/>);act(()=>vi.advanceTimersByTime(1000));expect(screen.queryByText('通知')).not.toBeInTheDocument();fireEvent.click(screen.getByRole('button',{name:'显示'}));act(()=>vi.advanceTimersByTime(999));expect(screen.getByText('通知')).toBeVisible();act(()=>vi.advanceTimersByTime(1));expect(screen.queryByText('通知')).not.toBeInTheDocument();
@@ -82,6 +82,21 @@ describe('reusable design components', () => {
82
82
  const tabs=screen.getAllByRole('tab'); expect(tabs[0].id).not.toBe(tabs[1].id);
83
83
  for(const tab of tabs) expect(document.getElementById(tab.getAttribute('aria-controls')!)).toHaveAttribute('aria-labelledby',tab.id);
84
84
  });
85
+ it('keeps tab names stable while associating independent descriptions and status', () => {
86
+ const { rerender } = render(<Tabs label="连接" value="a" onValueChange={()=>{}} items={[
87
+ {value:'a',label:'甲',description:'账号一',status:<span>同步中</span>,content:'甲内容'},
88
+ {value:'b',label:'乙',description:'账号二',status:<span>已停止</span>,content:'乙内容'},
89
+ ]}/>);
90
+ const first=screen.getByRole('tab',{name:'甲'}); first.focus();
91
+ expect(first).toHaveAccessibleDescription('账号一 同步中');
92
+ expect(screen.getByRole('tab',{name:'乙'})).toHaveAccessibleDescription('账号二 已停止');
93
+ rerender(<Tabs label="连接" value="a" onValueChange={()=>{}} items={[
94
+ {value:'a',label:'甲',description:'账号三',status:<span>已停止</span>,content:'甲内容'},
95
+ {value:'b',label:'乙',content:'乙内容'},
96
+ ]}/>);
97
+ expect(first).toHaveFocus(); expect(first).toHaveAccessibleDescription('账号三 已停止');
98
+ expect(screen.getByRole('tab',{name:'乙'})).not.toHaveAttribute('aria-describedby');
99
+ });
85
100
  it('keeps separate segmented controls in independent native radio groups', () => {
86
101
  render(<>{['a','b'].map(label=><SegmentedControl key={label} label={label} value="one" onValueChange={()=>{}} items={[{value:'one',label:'一'},{value:'two',label:'二'}]}/>)}</>);
87
102
  const radios=screen.getAllByRole('radio'); expect(radios[0].getAttribute('name')).not.toBe(radios[2].getAttribute('name'));
@@ -108,4 +123,21 @@ describe('reusable design components', () => {
108
123
  expect(details.open).toBe(false);fireEvent.click(summary);expect(details.open).toBe(true);
109
124
  fireEvent.click(summary);expect(details.open).toBe(false);expect(screen.getByText('内部按钮')).not.toBeVisible();
110
125
  });
126
+ it.each([['200ms', 200], ['.2s', 200], ['0ms', 0], ['0s', 0]])('disclosure respects CSS duration %s after production normalization', (cssDuration, milliseconds) => {
127
+ render(<Disclosure title="动画时长">内容</Disclosure>);
128
+ const summary = screen.getByText('动画时长');
129
+ const details = summary.closest('details')!;
130
+ details.style.setProperty('--rhine-duration-notice', cssDuration);
131
+ const animate = vi.fn(() => ({ cancel: vi.fn(), onfinish: null } as unknown as Animation));
132
+ details.animate = animate;
133
+ fireEvent.click(summary);
134
+ if (milliseconds === 0) {
135
+ expect(animate).not.toHaveBeenCalled();
136
+ expect(details.open).toBe(true);
137
+ fireEvent.click(summary);
138
+ expect(details.open).toBe(false);
139
+ } else {
140
+ expect(animate).toHaveBeenCalledWith(expect.any(Array), expect.objectContaining({ duration: milliseconds }));
141
+ }
142
+ });
111
143
  });
@@ -1,7 +1,7 @@
1
1
  import { useState } from 'react';
2
2
  import { act, fireEvent, render, screen } from '@testing-library/react';
3
3
  import { describe, expect, it, vi } from 'vitest';
4
- import { Combobox, DateField, DateRangePicker, NumberField, RemoteMultiSelect, TreeSelect } from '../components';
4
+ import { Combobox, Dialog, DateField, DateRangePicker, NumberField, RemoteMultiSelect, TreeSelect } from '../components';
5
5
 
6
6
  describe('date field parity', () => {
7
7
  it('reports controlled invalid dates and changed bounds without rewriting the value', () => {
@@ -106,6 +106,64 @@ describe('number field parity', () => {
106
106
  });
107
107
 
108
108
  describe('remote multi-select parity', () => {
109
+ it.each([false, true])('removes a tag without reopening candidates (initially open: %s)', initiallyOpen => {
110
+ const query = vi.fn(), submit = vi.fn(event => event.preventDefault());
111
+ function Demo() {
112
+ const [value, setValue] = useState([{ value: 'a', label: 'Alpha' }, { value: 'b', label: 'Beta' }]);
113
+ return <form onSubmit={submit}><RemoteMultiSelect label="成员" query="保留查询" onQueryChange={query} value={value} onValueChange={setValue} options={[{ value: 'a', label: 'Alpha' }, { value: 'b', label: 'Beta' }]} /></form>;
114
+ }
115
+ render(<Demo />); const input = screen.getByRole('combobox');
116
+ if (initiallyOpen) act(() => input.focus());
117
+ fireEvent.click(screen.getByRole('button', { name: '移除Beta' }));
118
+ expect(screen.queryByRole('button', { name: '移除Beta' })).not.toBeInTheDocument();
119
+ expect(screen.getByRole('button', { name: '移除Alpha' })).toBeVisible();
120
+ expect(input).toHaveFocus(); expect(input).toHaveAttribute('aria-expanded', 'false');
121
+ expect(input).toHaveValue('保留查询'); expect(query).not.toHaveBeenCalled(); expect(submit).not.toHaveBeenCalled();
122
+ fireEvent.click(screen.getByRole('button', { name: '移除Alpha' }));
123
+ expect(input).toHaveAttribute('aria-expanded', 'false');
124
+ expect(screen.queryByRole('button', { name: '清空选择' })).not.toBeInTheDocument();
125
+ });
126
+
127
+ it.each([false, true])('Backspace retains the current candidate state (open: %s)', initiallyOpen => {
128
+ function Demo() {
129
+ const [value, setValue] = useState([{ value: 'a', label: 'Alpha' }]);
130
+ return <RemoteMultiSelect label="成员" query="" onQueryChange={() => {}} value={value} onValueChange={setValue} options={[{ value: 'a', label: 'Alpha' }]} />;
131
+ }
132
+ render(<Demo />); const input = screen.getByRole('combobox'); act(() => input.focus());
133
+ if (!initiallyOpen) fireEvent.keyDown(input, { key: 'Escape' });
134
+ fireEvent.keyDown(input, { key: 'Backspace' });
135
+ expect(screen.queryByRole('button', { name: '移除Alpha' })).not.toBeInTheDocument();
136
+ expect(input).toHaveAttribute('aria-expanded', String(initiallyOpen)); expect(input).toHaveFocus();
137
+ });
138
+
139
+ it.each([false, true])('clears without opening candidates (initially open: %s) and hides empty actions', initiallyOpen => {
140
+ const query = vi.fn(), submit = vi.fn(event => event.preventDefault());
141
+ function Demo() {
142
+ const [value, setValue] = useState([{ value: 'a', label: 'Alpha' }]);
143
+ return <form onSubmit={submit}><RemoteMultiSelect label="成员" query="保留查询" onQueryChange={query} value={value} onValueChange={setValue} options={[{ value: 'a', label: 'Alpha' }]} maxVisibleTags={2} /></form>;
144
+ }
145
+ render(<Demo />); const input = screen.getByRole('combobox');
146
+ if (initiallyOpen) act(() => input.focus());
147
+ const clear = screen.getByRole('button', { name: '清空选择' });
148
+ fireEvent.pointerDown(clear); act(() => clear.focus());
149
+ if (initiallyOpen) expect(screen.getByRole('listbox', { name: '成员' })).toBeVisible();
150
+ else expect(screen.queryByRole('listbox')).not.toBeInTheDocument();
151
+ fireEvent.pointerUp(clear); fireEvent.click(clear);
152
+ expect(screen.getByText('已选 0 项')).toBeVisible();
153
+ expect(screen.queryByRole('button', { name: '清空选择' })).not.toBeInTheDocument();
154
+ expect(input).toHaveFocus(); expect(input).toHaveAttribute('aria-expanded', 'false');
155
+ expect(screen.queryByRole('listbox')).not.toBeInTheDocument();
156
+ expect(input).toHaveValue('保留查询'); expect(query).not.toHaveBeenCalled(); expect(submit).not.toHaveBeenCalled();
157
+ fireEvent.keyDown(input, { key: 'ArrowDown' }); fireEvent.keyDown(input, { key: 'Enter' });
158
+ expect(screen.getByRole('button', { name: '清空选择' })).toBeVisible();
159
+ });
160
+
161
+ it('hides clear when only locked choices remain', () => {
162
+ render(<RemoteMultiSelect label="成员" query="" onQueryChange={() => {}} value={[{ value: 'locked', label: '固定', disabled: true }]} onValueChange={vi.fn()} options={[]} />);
163
+ expect(screen.queryByRole('button', { name: '清空选择' })).not.toBeInTheDocument();
164
+ expect(screen.getByRole('button', { name: '移除固定' })).toBeDisabled();
165
+ });
166
+
109
167
  it('keeps the list open for consecutive selection and exposes the selected list semantics', () => {
110
168
  const query = vi.fn();
111
169
  function Demo() {
@@ -117,12 +175,41 @@ describe('remote multi-select parity', () => {
117
175
  expect(input).toHaveAttribute('aria-expanded', 'true');
118
176
  expect(screen.getByRole('listbox')).toHaveAttribute('aria-multiselectable', 'true');
119
177
  expect(screen.getByRole('option', { name: 'Alpha' })).toHaveAttribute('aria-selected', 'true');
120
- fireEvent.keyDown(input, { key: 'ArrowDown' }); fireEvent.keyDown(input, { key: 'Enter' });
178
+ fireEvent.keyDown(input, { key: 'End' }); fireEvent.keyDown(input, { key: 'Enter' });
121
179
  expect(screen.getByRole('button', { name: '移除Beta' })).toBeVisible();
122
180
  expect(container.querySelectorAll('input[type="hidden"][name="member"]')).toHaveLength(3);
123
181
  expect(query).not.toHaveBeenCalled();
124
182
  });
125
183
 
184
+ it('toggles selected candidates with pointer and keyboard, then completes without submitting', () => {
185
+ const submit = vi.fn(event => event.preventDefault());
186
+ function Demo() {
187
+ const [value, setValue] = useState([{ value: 'a', label: 'Alpha' }, { value: 'locked', label: 'Locked', disabled: true }]);
188
+ return <form onSubmit={submit}><RemoteMultiSelect label="成员" query="查询" onQueryChange={() => {}} value={value} onValueChange={setValue} options={[{ value: 'a', label: 'Alpha' }, { value: 'locked', label: 'Locked' }, { value: 'off', label: 'Disabled', disabled: true }]} /></form>;
189
+ }
190
+ render(<Demo />);
191
+ const input = screen.getByRole('combobox'); act(() => input.focus());
192
+ fireEvent.click(screen.getByRole('option', { name: 'Alpha' }));
193
+ expect(screen.getByRole('option', { name: 'Alpha' })).toHaveAttribute('aria-selected', 'false');
194
+ expect(screen.queryByRole('button', { name: '移除Alpha' })).not.toBeInTheDocument();
195
+ fireEvent.click(screen.getByRole('option', { name: 'Locked' }));
196
+ fireEvent.click(screen.getByRole('option', { name: 'Disabled' }));
197
+ expect(screen.getByRole('button', { name: '移除Locked' })).toBeDisabled();
198
+ expect(screen.queryByRole('button', { name: '移除Disabled' })).not.toBeInTheDocument();
199
+ fireEvent.keyDown(input, { key: 'ArrowDown' }); fireEvent.keyDown(input, { key: 'Enter' });
200
+ expect(screen.getByRole('option', { name: 'Alpha' })).toHaveAttribute('aria-selected', 'true');
201
+ fireEvent.keyDown(input, { key: 'ArrowDown' }); fireEvent.keyDown(input, { key: 'Enter' });
202
+ expect(screen.getByRole('option', { name: 'Alpha' })).toHaveAttribute('aria-selected', 'false');
203
+ const done = screen.getByRole('button', { name: '完成选择' }); act(() => done.focus());
204
+ expect(input).toHaveAttribute('aria-expanded', 'true');
205
+ fireEvent.click(done);
206
+ expect(input).toHaveAttribute('aria-expanded', 'false'); expect(input).toHaveFocus();
207
+ expect(input).toHaveValue('查询'); expect(submit).not.toHaveBeenCalled();
208
+ fireEvent.click(input); act(() => screen.getByRole('button', { name: '完成选择' }).focus());
209
+ fireEvent.keyDown(screen.getByRole('button', { name: '完成选择' }), { key: 'Escape' });
210
+ expect(input).toHaveAttribute('aria-expanded', 'false'); expect(input).toHaveFocus();
211
+ });
212
+
126
213
  it('removes the last removable tag with Backspace while respecting IME and locked values', () => {
127
214
  const change = vi.fn(); render(<RemoteMultiSelect label="成员" query="" onQueryChange={() => {}} value={[{ value: 'a', label: 'Alpha' }, { value: 'locked', label: '固定', disabled: true }]} onValueChange={change} options={[]} />);
128
215
  const input = screen.getByRole('combobox'); fireEvent.focus(input); fireEvent.compositionStart(input);
@@ -183,3 +270,18 @@ describe('tree keyboard parity', () => {
183
270
  fireEvent.keyDown(group, { key: 'ArrowRight' }); expect(expand).toHaveBeenLastCalledWith(['root']);
184
271
  });
185
272
  });
273
+
274
+
275
+ describe('combobox modal keyboard exit', () => {
276
+ it('keeps focus inside while the nested scope is resuming its parent', () => {
277
+ render(<><button data-testid="background-action">背景动作</button><Dialog trigger={<button>打开选择</button>} title="成员选择" description="合成测试" open><RemoteMultiSelect label="弹窗成员" query="" onQueryChange={() => {}} value={[]} onValueChange={() => {}} options={[{ value: 'a', label: 'Alpha' }]} /></Dialog></>);
278
+ const input = screen.getByRole('combobox', { name: '弹窗成员' });
279
+ act(() => input.focus());
280
+ fireEvent.keyDown(input, { key: 'Tab', shiftKey: true });
281
+ const close = screen.getByRole('button', { name: '关闭对话框' });
282
+ act(() => close.focus());
283
+ act(() => screen.getByTestId('background-action').focus());
284
+ expect(close).toHaveFocus();
285
+ expect(input).toHaveAttribute('aria-expanded', 'false');
286
+ });
287
+ });