@linhey/rhine-design 0.11.0 → 0.11.2

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 (87) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/CONTRIBUTING.md +2 -0
  3. package/README.md +9 -4
  4. package/components/Actions.tsx +1 -0
  5. package/components/AdvancedForms.tsx +13 -5
  6. package/components/AppShell.tsx +3 -2
  7. package/components/COMMON_COMPONENTS.md +5 -1
  8. package/components/Chat.tsx +160 -0
  9. package/components/Forms.tsx +66 -6
  10. package/components/Navigation.tsx +15 -4
  11. package/components/Overlays.tsx +37 -7
  12. package/components/README.md +11 -1
  13. package/components/chat.css +32 -0
  14. package/components/common.css +2 -0
  15. package/components/components.tsx +19 -9
  16. package/components/dialog-settings.css +54 -1
  17. package/components/extensions.css +4 -0
  18. package/components/index.ts +8 -3
  19. package/components/patterns.css +4 -0
  20. package/components/styles.css +11 -2
  21. package/components/theme-arknights.css +4 -2
  22. package/components/useFloatingScale.ts +7 -3
  23. package/docs/AGENT_CHAT.md +76 -0
  24. package/docs/AGENT_CHAT_VERIFICATION.md +42 -0
  25. package/docs/APPLICATION_COMPONENTS.md +4 -2
  26. package/docs/BUTTON_ACTIONS.md +32 -0
  27. package/docs/BUTTON_ACTIONS_VERIFICATION.md +14 -0
  28. package/docs/COMPACT_ACCOUNT_PATTERNS.md +16 -0
  29. package/docs/COMPACT_SELECTION_VERIFICATION.md +15 -0
  30. package/docs/FLOATING_OVERLAYS.md +20 -0
  31. package/docs/INTERACTION_PATTERNS.md +4 -0
  32. package/docs/SETTINGS_COMPOSITION_VERIFICATION.md +12 -0
  33. package/docs/SETTINGS_DIALOG.md +42 -4
  34. package/docs/SIDE_NAV_ACTIONS.md +9 -0
  35. package/docs/SINGLE_SEARCH.md +19 -0
  36. package/docs/SINGLE_SEARCH_VERIFICATION.md +15 -0
  37. package/docs/SUMMARY_TABS.md +7 -1
  38. package/docs/TRAY_USAGE.md +34 -0
  39. package/docs/TRAY_USAGE_VERIFICATION.md +13 -0
  40. package/docs/assets/chat/after.png +0 -0
  41. package/docs/assets/chat/arknights.png +0 -0
  42. package/docs/assets/chat/before.png +0 -0
  43. package/docs/assets/chat/neo-brutalism.png +0 -0
  44. package/package.json +1 -1
  45. package/preview/ButtonActionsGallery.tsx +25 -0
  46. package/preview/ChatGallery.tsx +98 -0
  47. package/preview/CompactSelectionGallery.tsx +76 -0
  48. package/preview/DenseFilterControlsGallery.tsx +5 -2
  49. package/preview/GalleryShell.tsx +2 -2
  50. package/preview/NavActionsGallery.tsx +24 -0
  51. package/preview/SettingsCompositionGallery.tsx +38 -0
  52. package/preview/SettingsDialogGallery.tsx +54 -14
  53. package/preview/SingleSearchGallery.tsx +50 -0
  54. package/preview/SummaryTabsGallery.tsx +7 -3
  55. package/preview/TrayUsageGallery.tsx +82 -0
  56. package/preview/chat-gallery.css +1 -0
  57. package/preview/compact-selection.css +9 -0
  58. package/preview/main.tsx +15 -1
  59. package/preview/packageInfo.ts +1 -1
  60. package/preview/settings-dialog-gallery.css +5 -0
  61. package/preview/tray-usage.css +4 -0
  62. package/scripts/check-button-actions.js +34 -0
  63. package/scripts/check-combobox-modal-focus.js +22 -0
  64. package/scripts/check-combobox-zoom.js +109 -0
  65. package/scripts/check-compact-selection.js +40 -0
  66. package/scripts/check-dialog-scroll-boundaries.js +41 -0
  67. package/scripts/check-multiselect-completion.js +46 -0
  68. package/scripts/check-nav-actions.js +41 -0
  69. package/scripts/check-settings-bounded.js +49 -0
  70. package/scripts/check-settings-composition.js +50 -0
  71. package/scripts/check-settings-interactions.js +93 -0
  72. package/scripts/check-settings-theme.js +1 -5
  73. package/scripts/check-single-search.js +43 -0
  74. package/scripts/check-tray-usage.js +68 -0
  75. package/scripts/prepare-settings-consumer.mjs +21 -0
  76. package/skills/rhine-design/SKILL.md +2 -0
  77. package/skills/rhine-design/references/API_MAP.md +11 -0
  78. package/skills/rhine-design/references/TASK_MAP.md +1 -0
  79. package/skills/rhine-design/references/integration.md +1 -1
  80. package/tests/Chat.test.tsx +84 -0
  81. package/tests/CompactSelection.test.tsx +47 -0
  82. package/tests/FormParity.test.tsx +104 -2
  83. package/tests/NavActions.test.tsx +29 -0
  84. package/tests/SettingsDialog.test.tsx +102 -9
  85. package/tests/SingleSearch.test.tsx +42 -0
  86. package/tests/SkillInstallation.test.tsx +77 -0
  87. package/tests/TrayUsage.test.tsx +70 -0
@@ -0,0 +1,34 @@
1
+ async page=>{
2
+ page.setDefaultTimeout(8000);const failures=[],errors=[],samples=[];const origin=new URL(page.url()).origin;
3
+ const contrast=({color,bg})=>{const luminance=c=>c.match(/[\d.]+/g).slice(0,3).map(Number).map(v=>{v/=255;return v<=.04045?v/12.92:((v+.055)/1.055)**2.4}).reduce((sum,v,i)=>sum+v*[.2126,.7152,.0722][i],0);const values=[luminance(color),luminance(bg)].sort((a,b)=>a-b);return (values[1]+.05)/(values[0]+.05);};
4
+ page.on('pageerror',e=>errors.push(String(e)));const check=(ok,ctx)=>{if(!ok)failures.push(ctx)};
5
+ for(const theme of ['rhine','arknights','neo-brutalism'])for(const width of [400,520,880])for(const zoom of [1,2]){
6
+ const context={theme,width,zoom};await page.setViewportSize({width,height:1000});await page.goto(`${origin}/?theme=${theme}&proof=${Date.now()}#button-actions`);
7
+ await page.getByLabel('按钮容器宽度').selectOption(String(width));await page.evaluate(z=>document.documentElement.style.zoom=String(z),zoom);
8
+ const demo=page.locator('[data-button-actions]');const buttons=demo.locator('button');
9
+ const dims=()=>buttons.evaluateAll(es=>es.map(e=>{const s=getComputedStyle(e);return {w:e.offsetWidth,h:e.offsetHeight,size:s.fontSize,line:s.lineHeight,pad:s.paddingInlineStart,large:e.classList.contains('rhine-button--large'),fit:e.scrollWidth<=e.clientWidth+1}}));
10
+ const normal=await dims();check(normal.every(x=>x.h>=(x.large?52:44)&&x.size===(x.large?'16px':'14px')&&x.line===(x.large?'24px':'22px')&&x.pad===(x.large?'24px':'16px')&&x.fit),{...context,kind:'size-contract',normal});
11
+ for(const state of ['disabled','busy']){await page.getByLabel('按钮状态').selectOption(state);const ds=await dims();check(ds.every((x,i)=>x.w===normal[i].w&&x.h===normal[i].h),{...context,kind:`stable-${state}`});if(state==='disabled')check(await buttons.first().isDisabled(),{...context,kind:'disabled'});else check(await buttons.first().getAttribute('aria-busy')==='true',{...context,kind:'busy'});}
12
+ await page.getByLabel('按钮状态').selectOption('normal');
13
+ const logout=demo.getByRole('button',{name:'退出登录',exact:true}).nth(1);await logout.focus();await page.keyboard.press('Enter');await page.keyboard.press('Space');
14
+ await page.getByRole('status').filter({hasText:'large退出登录'}).waitFor();check(await logout.evaluate(e=>document.activeElement===e),{...context,kind:'keyboard-focus'});
15
+ const disconnect=demo.getByRole('button',{name:'断开连接',exact:true}).nth(1);await disconnect.focus();await page.keyboard.press('Space');await page.getByRole('status').filter({hasText:'large断开连接'}).waitFor();
16
+ for(const label of ['退出登录','断开连接','删除记录']){
17
+ const button=demo.getByRole('button',{name:label,exact:true}).nth(1);await page.mouse.move(0,0);await page.waitForTimeout(350);const normalColor=await button.evaluate(e=>({color:getComputedStyle(e).color,bg:getComputedStyle(e).backgroundColor}));check(contrast(normalColor)>=4.5,{...context,kind:`contrast-default-${label}`,normalColor,ratio:contrast(normalColor)});await button.hover();await page.waitForTimeout(350);const color=await button.evaluate(e=>({color:getComputedStyle(e).color,bg:getComputedStyle(e).backgroundColor}));
18
+ check(contrast(color)>=4.5,{...context,kind:`contrast-hover-${label}`,color,ratio:contrast(color)});
19
+ await page.mouse.down();check(await button.evaluate(e=>e.matches(':active')),{...context,kind:`active-${label}`});await page.mouse.move(0,0);await page.mouse.up();
20
+ await button.focus();await page.keyboard.press('Tab');await page.keyboard.press('Shift+Tab');
21
+ check(await button.evaluate(e=>e.matches(':focus-visible')&&(getComputedStyle(e).outlineStyle!=='none'||getComputedStyle(e).boxShadow!=='none')),{...context,kind:`focus-${label}`});
22
+ samples.push({...context,label,color});
23
+ }
24
+ await page.getByLabel('按钮状态').selectOption('long');check(await demo.evaluate(e=>e.scrollWidth<=e.clientWidth+1),{...context,kind:'long-container'});check((await dims()).every(x=>x.fit),{...context,kind:'long-text'});
25
+ await page.getByLabel('按钮状态').selectOption('normal');await demo.getByRole('button',{name:'删除记录',exact:true}).nth(1).click();
26
+ const dialog=page.getByRole('alertdialog',{name:'删除合成记录'});await dialog.waitFor();await page.keyboard.press('Escape');await dialog.waitFor({state:'detached'});
27
+ if(width===880&&zoom===1)await demo.screenshot({path:`output/playwright/buttons-${theme}.png`});
28
+ }
29
+ for(const theme of ['rhine','arknights','neo-brutalism'])for(const mode of ['system','manual']){
30
+ await page.emulateMedia({reducedMotion:mode==='system'?'reduce':'no-preference'});await page.goto(`${origin}/?theme=${theme}&proof=${Date.now()}#button-actions`);
31
+ if(mode==='manual')await page.getByRole('radio',{name:'减少动态',exact:true}).check();await page.locator('[data-button-actions]').getByRole('button',{name:'退出登录',exact:true}).first().press('Enter');await page.getByRole('status').filter({hasText:'合成操作完成'}).waitFor();
32
+ }
33
+ return {pass:!failures.length&&!errors.length,failures,errors,samples};
34
+ }
@@ -0,0 +1,22 @@
1
+ // Regression for the interval between a nested popup closing and its modal scope resuming.
2
+ async page => {
3
+ const origin = new URL(page.url()).origin, cases = [], errors = [];
4
+ page.on('pageerror', error => errors.push(String(error)));
5
+ for (const theme of ['rhine', 'arknights', 'neo-brutalism']) for (const width of [400, 880]) for (const zoom of [1, 2]) {
6
+ await page.setViewportSize({ width, height: 960 });
7
+ await page.goto(`${origin}/?theme=${theme}#compact-filters`); await page.reload();
8
+ await page.evaluate(value => { document.documentElement.style.zoom = String(value); }, zoom);
9
+ await page.getByRole('button', { name: '在弹窗内选择', exact: true }).click();
10
+ const input = page.getByRole('combobox', { name: '弹窗部门', exact: true });
11
+ await input.click(); await input.press('Shift+Tab');
12
+ await page.keyboard.press('Shift+Tab');
13
+ const immediateInside = await page.getByRole('dialog').evaluate(element => element.contains(document.activeElement));
14
+ // Assert the fast path before waiting; then separately verify normal navigation resumes.
15
+ await page.waitForTimeout(50);
16
+ const close = page.getByRole('button', { name: '关闭对话框', exact: true });
17
+ await close.focus(); await page.keyboard.press('Shift+Tab');
18
+ const resumed = await page.getByRole('dialog').evaluate(element => element.contains(document.activeElement) && document.activeElement?.getAttribute('aria-label') !== '关闭对话框');
19
+ cases.push({ theme, width, zoom, immediateInside, resumed });
20
+ }
21
+ return { pass: cases.every(item => item.immediateInside && item.resumed) && !errors.length, cases, errors };
22
+ }
@@ -0,0 +1,109 @@
1
+ // Open the built gallery, then run with Playwright CLI run-code --filename.
2
+ async page => {
3
+ const origin = new URL(page.url()).origin;
4
+ page.setDefaultTimeout(10000);
5
+ const failures = [], errors = [], cases = [];
6
+ page.on('pageerror', error => errors.push(String(error)));
7
+ const bounds = locator => locator.evaluate(element => {
8
+ const r = element.getBoundingClientRect();
9
+ return { left: r.left, right: r.right, top: r.top, bottom: r.bottom, width: innerWidth, height: innerHeight };
10
+ });
11
+ const contained = r => r.left >= -1 && r.top >= -1 && r.right <= r.width + 1 && r.bottom <= r.height + 1;
12
+ for (const theme of ['rhine', 'arknights', 'neo-brutalism']) {
13
+ for (const width of [400, 520, 880]) for (const zoom of [1, 2]) for (const modal of [false, true]) {
14
+ const spec = { theme, width, zoom, modal };
15
+ await page.setViewportSize({ width, height: 960 });
16
+ await page.goto(`${origin}/?theme=${theme}#compact-filters`);
17
+ await page.reload();
18
+ await page.getByRole('heading', { name: '密集筛选控件', exact: true }).waitFor();
19
+ await page.evaluate(zoom => { document.documentElement.style.zoom = String(zoom); }, zoom);
20
+ if (modal) await page.getByRole('button', { name: '在弹窗内选择', exact: true }).click();
21
+ const label = modal ? '弹窗部门' : '部门';
22
+ const input = page.getByRole('combobox', { name: label, exact: true });
23
+ await input.fill('合成部门 99');
24
+ const list = page.getByRole('listbox', { name: label, exact: true });
25
+ await list.getByRole('option', { name: '合成部门 99', exact: true }).waitFor();
26
+ await page.waitForTimeout(200);
27
+ // Native Tab targets differ with browser/platform keyboard preferences.
28
+ // Compare the exit with the same input's ordinary closed-popup Tab path.
29
+ await input.press('Escape'); await input.press('Tab');
30
+ const naturalNext = await page.evaluateHandle(() => document.activeElement);
31
+ await input.click(); await list.waitFor();
32
+ const popup = list.locator('..');
33
+ const completion = popup.getByRole('button', { name: '完成选择', exact: true });
34
+ await input.press('Tab');
35
+ const tabIn = await completion.evaluate(element => element === document.activeElement);
36
+ await completion.press('Shift+Tab');
37
+ const tabBack = await input.evaluate(element => element === document.activeElement);
38
+ await input.press('Tab'); await completion.press('Tab');
39
+ await list.waitFor({ state: 'detached' });
40
+ const tabOut = await page.waitForFunction(element => element === document.activeElement, naturalNext, { timeout: 2000 }).then(() => true, () => false);
41
+ await naturalNext.dispose();
42
+ await input.click(); await list.waitFor();
43
+
44
+ const rect = await bounds(popup);
45
+ await list.getByRole('option', { name: '合成部门 99', exact: true }).click();
46
+ const selected = await list.getByRole('option', { name: '合成部门 99', exact: true }).getAttribute('aria-selected');
47
+ await input.press('Escape');
48
+ await list.waitFor({ state: 'detached' });
49
+ const focusRetained = await input.evaluate(element => element === document.activeElement);
50
+ await input.fill('没有匹配的合成部门');
51
+ await list.getByText('暂无匹配结果', { exact: true }).waitFor();
52
+ await input.fill('合成部门 98');
53
+ await input.press('ArrowDown');
54
+ await input.press('Enter');
55
+ const keyboardSelected = await list.getByRole('option', { name: '合成部门 98', exact: true }).getAttribute('aria-selected');
56
+ const node = await input.elementHandle();
57
+ await page.evaluate(zoom => { document.documentElement.style.zoom = String(zoom === 1 ? 2 : 1); }, zoom);
58
+ await page.waitForTimeout(200);
59
+ const stable = await node.evaluate(element => element.isConnected && element.value === '合成部门 98');
60
+ const dynamic = await bounds(popup);
61
+ const option = list.getByRole('option', { name: '合成部门 98', exact: true });
62
+ await option.click();
63
+ const toggledOff = await option.getAttribute('aria-selected') === 'false';
64
+ await input.press('ArrowDown');
65
+ await input.press('Enter');
66
+ const toggledOn = await option.getAttribute('aria-selected') === 'true';
67
+ await input.fill('');
68
+ await list.getByRole('option').last().waitFor();
69
+ const done = popup.getByRole('button', { name: '完成选择', exact: true });
70
+ await done.scrollIntoViewIfNeeded();
71
+ const doneBounds = await bounds(done);
72
+ const longListBounds = await bounds(popup);
73
+ const boundedList = longListBounds.bottom - longListBounds.top <= Math.min(240 * (zoom === 1 ? 2 : 1), 936) + 1;
74
+ await done.click();
75
+ await list.waitFor({ state: 'detached' });
76
+ const completed = await input.evaluate(element => element === document.activeElement && element.value === '' && element.getAttribute('aria-expanded') === 'false');
77
+ const field = page.locator('.rhine-multi-select').filter({ has: input });
78
+ let cleared = true;
79
+ for (const initiallyOpen of [false, true]) {
80
+ await input.fill('合成部门 99');
81
+ await input.press('ArrowDown'); await input.press('Enter');
82
+ if (!initiallyOpen) await popup.getByRole('button', { name: '完成选择', exact: true }).click();
83
+ await field.getByRole('button', { name: '清空选择', exact: true }).click();
84
+ await list.waitFor({ state: 'detached' });
85
+ cleared = cleared && await input.evaluate(element => element === document.activeElement && element.value === '合成部门 99' && element.getAttribute('aria-expanded') === 'false') && await field.getByRole('button', { name: '清空选择', exact: true }).count() === 0;
86
+ }
87
+ for (const name of ['合成部门 99', '合成部门 98']) {
88
+ await input.fill(name); await input.press('ArrowDown');
89
+ await list.getByRole('option', { name, exact: true }).waitFor();
90
+ await input.press('Enter');
91
+ await page.waitForFunction(({ id, name }) => [...document.getElementById(id).querySelectorAll('[role=option]')].some(element => element.textContent.trim().replace(/^✓/, '').trim() === name && element.getAttribute('aria-selected') === 'true'), { id: await list.getAttribute('id'), name });
92
+ }
93
+ await popup.getByRole('button', { name: '完成选择', exact: true }).click();
94
+ await field.getByRole('button', { name: '移除合成部门 99', exact: true }).click();
95
+ const tagRemoved = await input.evaluate(element => element === document.activeElement && element.value === '合成部门 98' && element.getAttribute('aria-expanded') === 'false');
96
+ await input.fill(''); await input.press('Backspace');
97
+ const backspaceRetained = await input.getAttribute('aria-expanded') === 'true' && await field.getByRole('button', { name: '清空选择', exact: true }).count() === 0;
98
+ await input.press('Escape');
99
+ const pass = boundedList && tabIn && tabBack && tabOut && cleared && tagRemoved && backspaceRetained && contained(rect) && contained(dynamic) && selected === 'true' && keyboardSelected === 'true' && focusRetained && stable && toggledOff && toggledOn && contained(doneBounds) && completed;
100
+ cases.push({ ...spec, pass });
101
+ if (!pass) failures.push({ ...spec, rect, dynamic, selected, keyboardSelected, focusRetained, stable, toggledOff, toggledOn, doneBounds, completed, cleared, tagRemoved, backspaceRetained, tabIn, tabBack, tabOut, boundedList, longListBounds });
102
+ if (modal) {
103
+ await input.press('Escape');
104
+ await page.getByRole('dialog', { name: '搜索多选', exact: true }).waitFor({ state: 'detached' });
105
+ }
106
+ }
107
+ }
108
+ return { pass: !failures.length && !errors.length, cases: cases.length, failures, errors };
109
+ }
@@ -0,0 +1,40 @@
1
+ async page => {
2
+ page.setDefaultTimeout(10000);
3
+ const origin=new URL(page.url()).origin, failures=[],errors=[],samples=[];
4
+ page.on('pageerror',e=>errors.push(String(e)));
5
+ const check=(ok,ctx)=>{if(!ok)failures.push(ctx)};
6
+ for(const theme of ['rhine','arknights','neo-brutalism'])for(const width of [400,520,880])for(const zoom of [1,2]){
7
+ const context={theme,width,zoom};
8
+ await page.setViewportSize({width,height:1000});await page.goto(`${origin}/?theme=${theme}&proof=${Date.now()}#compact-selection`);
9
+ await page.getByLabel('组合宽度').selectOption(String(width));await page.evaluate(z=>document.documentElement.style.zoom=String(z),zoom);
10
+ const cards=page.locator('[data-card]');
11
+ const measure=()=>cards.evaluateAll(es=>es.map(e=>({height:e.getBoundingClientRect().height,fit:e.scrollWidth<=e.clientWidth+1,widths:[...e.querySelectorAll('.ds-choice-card__actions button')].map(b=>b.offsetWidth)})));
12
+ const before=await measure();check(Math.abs(before[0].height-before[1].height)<1&&before.every(x=>x.fit&&x.widths[0]===64&&x.widths[1]===112),{...context,kind:'equal-cards-actions',before});
13
+ await page.getByRole('tab',{name:'备用区',exact:true}).focus();await page.keyboard.press('Home');await page.keyboard.press('End');
14
+ await page.getByRole('button',{name:'切换操作',exact:true}).click();await page.getByRole('menuitem',{name:'设为当前项',exact:true}).click();
15
+ const frames=await page.evaluate(()=>new Promise(resolve=>{const data=[],start=performance.now();function frame(){data.push([...document.querySelectorAll('[data-card]')].map(e=>e.getBoundingClientRect().height));if(performance.now()-start<900)requestAnimationFrame(frame);else resolve(data)}requestAnimationFrame(frame)}));
16
+ check(frames.every(f=>f.every((height,i)=>Math.abs(height-before[i].height)<1)),{...context,kind:'card-frame-stability',range:frames.slice(0,2).concat(frames.slice(-2))});
17
+ check(await page.getByRole('tab',{name:'备用区',exact:true}).getAttribute('aria-selected')==='true',{...context,kind:'tab-identity'});
18
+ const trigger=page.getByRole('button',{name:'选择 合成对象 B 的授权分组'});await trigger.click();const dialog=page.getByRole('dialog',{name:'分组选择'});await dialog.waitFor();await dialog.evaluate(e=>Promise.all(e.getAnimations().map(a=>a.finished)));
19
+ const box=await dialog.boundingBox();
20
+ await page.evaluate(()=>{window.__frames=[];window.__sampling=true;function frame(){const e=document.querySelector('[role=dialog]');if(e){const r=e.getBoundingClientRect();window.__frames.push({height:r.height,y:r.y});}if(window.__sampling)requestAnimationFrame(frame)}requestAnimationFrame(frame)});
21
+ for(const value of ['four','last','empty','loading','error','two'])await dialog.getByLabel('预览内容').selectOption(value);
22
+ await dialog.getByLabel('候选分组').selectOption('beta');check(await dialog.getByText('已确认:示例组').count()===1,{...context,kind:'preview-only'});
23
+ const save=dialog.getByRole('button',{name:'保存',exact:true});await save.click();await page.keyboard.press('Escape');check(await dialog.count()===1,{...context,kind:'busy-blocks-close'});
24
+ await dialog.getByText('已确认:联合研究组').waitFor();check(await save.isDisabled(),{...context,kind:'confirmed-save'});
25
+ const heights=await page.evaluate(()=>{window.__sampling=false;return window.__frames});
26
+ check(heights.every(r=>Math.abs(r.height-box.height)<1&&Math.abs(r.y-box.y)<1),{...context,kind:'dialog-frame-stability',box,range:heights.slice(0,1).concat(heights.slice(-1))});
27
+ const footer=await save.boundingBox();const viewport=await page.evaluate(()=>({width:innerWidth,height:innerHeight}));
28
+ check(footer.y>=0&&footer.y+footer.height<=viewport.height+1&&footer.x>=0&&footer.x+footer.width<=viewport.width+1,{...context,kind:'footer-reachable',footer,viewport});
29
+ await dialog.getByLabel('候选分组').selectOption('none');await page.keyboard.press('Escape');await dialog.waitFor({state:'detached'});
30
+ await trigger.click();check(await dialog.getByLabel('候选分组').inputValue()==='beta',{...context,kind:'discard-draft'});
31
+ await dialog.getByRole('button',{name:'关闭对话框'}).click();await dialog.waitFor({state:'detached'});await page.waitForFunction(()=>document.activeElement?.getAttribute('aria-label')?.startsWith('选择 '));
32
+ await page.getByLabel('组合场景').selectOption('error');await trigger.click();await dialog.getByLabel('候选分组').selectOption('none');await dialog.getByRole('button',{name:'保存',exact:true}).click();await dialog.getByText('保存失败,请重试;确认值未改变。').waitFor();check(await dialog.getByText('已确认:联合研究组').count()===1,{...context,kind:'failure-preserves-confirmed'});
33
+ await page.keyboard.press('Escape');await dialog.waitFor({state:'detached'});
34
+ await page.getByLabel('组合场景').selectOption('long');check((await measure()).every(x=>x.fit),{...context,kind:'long-fit'});
35
+ for(const layout of ['fill','content']){await page.getByLabel('页签布局').selectOption(layout);check(await page.getByRole('tablist',{name:'选择摘要'}).evaluate(el=>el.scrollWidth<=el.clientWidth+1),{...context,kind:`tabs-${layout}`});}
36
+ if(width===400&&zoom===2){await cards.first().scrollIntoViewIfNeeded();await page.screenshot({path:`output/playwright/compact-${theme}.png`});}
37
+ samples.push(context);
38
+ }
39
+ return {pass:!failures.length&&!errors.length,failures,errors,samples};
40
+ }
@@ -0,0 +1,41 @@
1
+ // Additional public API boundaries: footer without height, and text-only bounded body.
2
+ async page => {
3
+ const origin=page.url().split('/').slice(0,3).join('/'),results=[];
4
+ for(const theme of ['rhine','arknights','neo-brutalism'])for(const width of [400,880])for(const zoom of [1,2]) {
5
+ const c={theme,width,zoom};
6
+ try {
7
+ await page.setViewportSize({width,height:648});
8
+ await page.goto(`${origin}/?fixture=settings-dialog&theme=${theme}&zoom=${zoom}`);
9
+ await page.getByRole('button',{name:'自动高度操作区示例'}).click();
10
+ const auto=page.getByRole('dialog',{name:'自动高度操作区'});
11
+ const initial=await auto.evaluate(e=>{
12
+ const footer=e.querySelector('.rhine-dialog__footer'),body=e.querySelector('.rhine-dialog__body');
13
+ return {root:e.getBoundingClientRect().toJSON(),head:e.querySelector('.rhine-dialog__head').getBoundingClientRect().toJSON(),footer:footer.getBoundingClientRect().toJSON(),body:body.getBoundingClientRect().toJSON(),footerScrollHeight:footer.scrollHeight,footerClientHeight:footer.clientHeight};
14
+ });
15
+ await auto.getByRole('button',{name:'长操作 20',exact:true}).focus();
16
+ const after=await auto.evaluate(e=>{
17
+ const footer=e.querySelector('.rhine-dialog__footer'),r=footer.getBoundingClientRect(),last=footer.querySelector('button:last-child').getBoundingClientRect();
18
+ return {head:e.querySelector('.rhine-dialog__head').getBoundingClientRect().toJSON(),lastInside:last.top>=r.top-1&&last.bottom<=r.bottom+1,scroll:footer.scrollTop,rootScroll:e.scrollTop};
19
+ });
20
+ const autoPass=initial.body.height>0&&initial.footer.height<=(648-32*zoom)*.4+1&&initial.footerScrollHeight>initial.footerClientHeight&&after.lastInside&&after.scroll>0&&after.rootScroll===0&&Math.abs(after.head.y-initial.head.y)<1;
21
+ // Ancestor zoom changes while open must update the local-pixel cap.
22
+ for(const changedZoom of [zoom===1?2:1,zoom]) {
23
+ await page.evaluate(z=>document.documentElement.style.zoom=String(z),changedZoom);
24
+ await page.waitForFunction(z=>{const e=document.querySelector('[role=dialog]');return e&&Math.abs(parseFloat(e.style.getPropertyValue('--rhine-dialog-footer-limit'))-(innerHeight/z-32)*.4)<1;},changedZoom);
25
+ }
26
+ await page.keyboard.press('Escape');await auto.waitFor({state:'detached'});
27
+ await page.getByRole('button',{name:'纯文本长正文示例'}).click();
28
+ const text=page.getByRole('dialog',{name:'纯文本长正文'}),region=text.getByRole('region',{name:'纯文本长正文内容'});
29
+ const named=await region.count()===1;
30
+ let keyboard=false;
31
+ if(named) {
32
+ await text.getByRole('button',{name:'关闭对话框'}).focus();await page.keyboard.press('Tab');
33
+ keyboard=await region.evaluate(e=>document.activeElement===e);
34
+ await page.keyboard.press('End');
35
+ await page.waitForFunction(()=>{const e=document.querySelector('[aria-label="纯文本长正文内容"]');return e&&e.scrollTop>0&&e.scrollHeight-e.clientHeight-e.scrollTop<2;});
36
+ }
37
+ results.push({...c,pass:autoPass&&named&&keyboard,autoPass,named,keyboard,initial,after});
38
+ }catch(error){results.push({...c,pass:false,error:String(error)});}
39
+ }
40
+ return {pass:results.every(r=>r.pass),results};
41
+ }
@@ -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,41 @@
1
+ async page => {
2
+ page.setDefaultTimeout(8000);
3
+ const failures=[], errors=[], samples=[];
4
+ page.on('pageerror',e=>errors.push(String(e)));
5
+ const origin=new URL(page.url()).origin;
6
+ const check=(ok,context)=>{if(!ok)failures.push(context)};
7
+ for(const theme of ['rhine','arknights','neo-brutalism'])for(const width of [400,520,880])for(const zoom of [1,2]){
8
+ await page.setViewportSize({width:1100,height:1000});
9
+ await page.goto(`${origin}/?theme=${theme}&proof=${Date.now()}#nav-actions`);
10
+ await page.getByLabel('导航容器宽度').selectOption(String(width));
11
+ await page.getByLabel('导航标题').selectOption('long');
12
+ await page.evaluate(z=>document.documentElement.style.zoom=String(z),zoom);
13
+ const nav=page.getByRole('navigation',{name:'行操作示例'}),trigger=nav.getByRole('button');
14
+ const context={theme,width,zoom};
15
+ check(await trigger.count()===1,{...context,kind:'empty-actions'});
16
+ const bounds=await trigger.evaluate(el=>({height:el.offsetHeight,width:el.offsetWidth,nested:!!el.closest('a'),opacity:getComputedStyle(el).opacity,fit:el.closest('nav').scrollWidth<=el.closest('nav').clientWidth+1}));
17
+ check(bounds.height>=44&&bounds.width>=44&&!bounds.nested&&bounds.opacity==='1'&&bounds.fit,{...context,kind:'bounds',bounds});
18
+ await nav.getByRole('link',{name:'没有动作的工作区',exact:true}).click();
19
+ await trigger.click();
20
+ await page.getByRole('menuitem',{name:'查看详情',exact:true}).click();
21
+ const dialog=page.getByRole('dialog',{name:'工作区详情'});await dialog.waitFor();
22
+ await page.keyboard.press('Escape');await dialog.waitFor({state:'detached'});
23
+ await page.waitForFunction(()=>document.activeElement?.getAttribute('aria-label')?.endsWith(':更多操作'));
24
+ check(await nav.getByRole('link',{name:'没有动作的工作区',exact:true}).getAttribute('aria-current')==='page',{...context,kind:'no-navigation'});
25
+ await trigger.press('Space');await page.getByRole('menu').waitFor();
26
+ await page.keyboard.press('Escape');await page.getByRole('menu').waitFor({state:'detached'});
27
+ await page.waitForFunction(()=>document.activeElement?.getAttribute('aria-label')?.endsWith(':更多操作'));
28
+ if(width===400&&zoom===2){await nav.scrollIntoViewIfNeeded();await page.screenshot({path:`output/playwright/nav-${theme}.png`});}
29
+ samples.push(context);
30
+ }
31
+ for(const reduced of [false,true]){
32
+ await page.emulateMedia({reducedMotion:reduced?'reduce':'no-preference'});
33
+ await page.goto(`${origin}/?proof=${Date.now()}#nav-actions`);await page.getByRole('navigation',{name:'行操作示例'}).getByRole('button').click();
34
+ await page.getByRole('menuitem',{name:'查看详情',exact:true}).click();await page.getByRole('dialog').waitFor();
35
+ await page.getByRole('button',{name:'关闭对话框',exact:true}).click();await page.getByRole('dialog').waitFor({state:'detached'});
36
+ }
37
+ await page.getByRole('radio',{name:'减少动态',exact:true}).check();
38
+ const trigger=page.getByRole('navigation',{name:'行操作示例'}).getByRole('button');await trigger.press('Enter');await page.keyboard.press('Escape');
39
+ await page.waitForFunction(()=>document.activeElement?.getAttribute('aria-label')?.endsWith(':更多操作'));
40
+ return {pass:!failures.length&&!errors.length,failures,errors,samples};
41
+ }
@@ -0,0 +1,49 @@
1
+ // Run against a production preview or an installed-tarball consumer:
2
+ // playwright-cli goto http://127.0.0.1:18481/?fixture=settings-dialog
3
+ // playwright-cli run-code --filename scripts/check-settings-bounded.js
4
+ async page => {
5
+ const base = page.url().split('/').slice(0,3).join('/'), results = [], errors = [];
6
+ page.on('pageerror', error => errors.push(String(error)));
7
+ const cases = [];
8
+ for (const theme of ['rhine','arknights','neo-brutalism']) {
9
+ for (const width of [400,520,880,1280]) for (const zoom of [1,2]) for (const height of [900,648]) cases.push({theme,width,zoom,height});
10
+ // A 400px bordered iframe has 398px inner width; include that real consumer boundary.
11
+ cases.push({theme,width:398,zoom:2,height:648});
12
+ for (const [width,height] of [[390,680],[889,480],[1440,900]]) cases.push({theme,width,zoom:1,height});
13
+ }
14
+ const rect = e => {const r=e.getBoundingClientRect();return {x:r.x,y:r.y,width:r.width,height:r.height,right:r.right,bottom:r.bottom};};
15
+ for (const c of cases) {
16
+ try {
17
+ await page.setViewportSize({width:c.width,height:c.height});
18
+ await page.goto(`${base}/?fixture=settings-dialog&theme=${c.theme}&zoom=${c.zoom}`);
19
+ await page.getByRole('checkbox',{name:'长导航',exact:true}).check();
20
+ await page.getByRole('checkbox',{name:'长操作文案',exact:true}).check();
21
+ await page.getByRole('button',{name:'打开设置',exact:true}).click();
22
+ const dialog=page.getByRole('dialog',{name:'设置',exact:true});
23
+ await dialog.getByRole('link',{name:'用户管理与权限',exact:true}).click();
24
+ const measure=()=>dialog.evaluate(d=>{
25
+ const rect=e=>{const r=e.getBoundingClientRect();return {x:r.x,y:r.y,width:r.width,height:r.height,right:r.right,bottom:r.bottom};};
26
+ const body=d.querySelector('.rhine-dialog__settings-content'), nav=d.querySelector('.rhine-dialog__navigation'), footer=d.querySelector('.rhine-dialog__footer');
27
+ return {dialog:rect(d),head:rect(d.querySelector('.rhine-dialog__head')),close:rect(d.querySelector('.rhine-dialog__head button')),footer:rect(footer),buttons:[...footer.querySelectorAll('button')].map(rect),labelsMatch:[...footer.querySelectorAll('button')].every(b=>(b.getAttribute('aria-label')||b.innerText).replace(/\s/g,'').includes(b.innerText.replace(/\s/g,''))),body:rect(body),nav:rect(nav),bodyScroll:body.scrollTop,navScroll:nav.scrollTop,overflow:body.scrollWidth>body.clientWidth+1,footerOverflow:footer.scrollHeight>footer.clientHeight+1,columns:getComputedStyle(d.querySelector('.rhine-dialog__settings-grid')).gridTemplateColumns,rootScroll:d.scrollTop};
28
+ });
29
+ const before=await measure();
30
+ const body=dialog.getByRole('region',{name:'设置内容'});
31
+ await body.focus(); await page.keyboard.press('End');
32
+ await body.evaluate(e=>{e.scrollTop=e.scrollHeight;});
33
+ const last=dialog.getByRole('button',{name:'编辑成员 30',exact:true});
34
+ const lastRect=await last.evaluate(rect);
35
+ const after=await measure();
36
+ const navLast=dialog.getByRole('link',{name:'扩展设置 20 · 长栏目名称',exact:true});
37
+ await navLast.scrollIntoViewIfNeeded();
38
+ const navLastRect=await navLast.evaluate(rect), navAfter=await measure();
39
+ const within=(r,b)=>r.x>=b.x-1&&r.right<=b.right+1&&r.y>=b.y-1&&r.bottom<=b.bottom+1;
40
+ const same=(a,b)=>['x','y','width','height'].every(k=>Math.abs(a[k]-b[k])<1);
41
+ const stable=['dialog','head','close','footer'].every(k=>same(before[k],after[k])&&same(before[k],navAfter[k]));
42
+ const expectedColumns=c.width>=880&&c.zoom===1?2:1;
43
+ const checks={labelsMatch:after.labelsMatch,stable,buttonsStable:before.buttons.every((r,i)=>same(r,after.buttons[i])),withinViewport:within(after.dialog,{x:0,y:0,right:c.width,bottom:c.height}),bodyNonzero:after.body.height>0,bodyScrolled:after.bodyScroll>0,lastReachable:within(lastRect,after.body),navReachable:within(navLastRect,navAfter.nav),footerVisible:after.buttons.every(r=>within(r,after.footer)),targets:after.buttons.every(r=>r.width/c.zoom>=44-.1&&r.height/c.zoom>=44-.1),noOverflow:!after.overflow&&!after.footerOverflow,columns:after.columns.split(' ').length===expectedColumns,noRootScroll:after.rootScroll===0};
44
+ results.push({...c,pass:Object.values(checks).every(Boolean),checks,before,after});
45
+ if(c.width===400&&c.zoom===2&&c.height===648) await page.screenshot({path:`output/playwright/settings-${c.theme}-400-200-short.png`});
46
+ } catch(error) { results.push({...c,pass:false,error:String(error)}); }
47
+ }
48
+ return {pass:results.every(r=>r.pass)&&errors.length===0,cases:results.length,errors,failures:results.filter(r=>!r.pass),results};
49
+ }
@@ -0,0 +1,50 @@
1
+ // Run against this revision's production preview; origin comes from the current page.
2
+ async page => {
3
+ const native = page.url().includes('native=1');
4
+ const origin = page.url().split('/').slice(0,3).join('/'), results = [], errors = [];
5
+ page.on('pageerror', e => errors.push(String(e)));
6
+ for (const theme of ['rhine','arknights','neo-brutalism']) for (const width of [400,520,880]) for (const zoom of (native ? [1] : [1,2])) {
7
+ await page.setViewportSize({ width, height: 1000 });
8
+ await page.goto(`${origin}/?fixture=settings-composition&theme=${theme}&zoom=${zoom}`);
9
+ await page.getByRole('checkbox', {name:'长产品名与状态'}).check();
10
+ const shell = page.getByRole('region', {name:'设置组合工作区'}), header = shell.locator('.rhine-shell__content-header');
11
+ const viewport = await page.evaluate(()=>({width:innerWidth,dpr:devicePixelRatio,zoom:getComputedStyle(document.documentElement).zoom}));
12
+ const desktop = viewport.width / zoom - 32 >= 760;
13
+ if (await header.isVisible() === desktop) throw Error('Empty header visibility');
14
+ await page.getByRole('checkbox', {name:'显示顶部动作'}).check();
15
+ if (!await header.isVisible()) throw Error('Visible action lost');
16
+ await page.getByRole('button',{name:'完成顶部操作'}).click();
17
+ if (desktop) {
18
+ await page.getByRole('button',{name:'收起设置导航'}).focus(); await page.keyboard.press('Enter');
19
+ await page.waitForFunction(()=>document.activeElement?.getAttribute('aria-label')==='展开设置导航');
20
+ if (!await header.isVisible()) throw Error('Restore header missing');
21
+ await page.keyboard.press('Enter');
22
+ await page.waitForFunction(()=>document.activeElement?.getAttribute('aria-label')==='收起设置导航');
23
+ if (await header.isVisible()) throw Error('Empty header remains');
24
+ } else {
25
+ await shell.getByRole('button',{name:'设置导航',exact:true}).click();
26
+ await page.getByRole('dialog',{name:'设置导航',exact:true}).waitFor();
27
+ await page.keyboard.press('Escape');
28
+ await page.waitForFunction(()=>document.activeElement?.getAttribute('aria-label')==='设置导航');
29
+ }
30
+ for (const state of ['current','checking','available','error']) {
31
+ await page.getByRole('combobox',{name:'更新状态'}).selectOption(state);
32
+ const area = page.getByTestId('update-area');
33
+ const geometry = await area.evaluate(e=>{const s=e.querySelector('[role=status]').getBoundingClientRect(),b=e.querySelector('button').getBoundingClientRect(),r=e.getBoundingClientRect();return { aligned:Math.abs(s.right-b.right)<1, overflow:e.scrollWidth>e.clientWidth+1, right:r.right,x:r.x };});
34
+ if (!geometry.aligned || geometry.overflow || geometry.x<0 || geometry.right>viewport.width+1) throw Error(JSON.stringify({theme,width,zoom,state,geometry}));
35
+ if (state==='checking' && await area.locator('button').getAttribute('aria-busy')!=='true') throw Error('Busy feedback absent');
36
+ }
37
+ await page.getByRole('button',{name:'重置设置示例'}).click();
38
+ if (await page.getByRole('checkbox',{name:'长产品名与状态'}).isChecked()) throw Error('Reset failed');
39
+ results.push({theme,width,zoom,native,viewport,pass:true});
40
+ }
41
+ await page.emulateMedia({ reducedMotion:'reduce' });
42
+ await page.goto(`${origin}/?fixture=settings-composition&motion=system`);
43
+ await page.getByRole('combobox',{name:'更新状态'}).selectOption('checking');
44
+ const system = await page.locator('.rhine-spinner').evaluate(e=>getComputedStyle(e).animationDuration);
45
+ await page.emulateMedia({reducedMotion:'no-preference'});
46
+ await page.goto(`${origin}/?fixture=settings-composition&motion=reduced`);
47
+ await page.getByRole('combobox',{name:'更新状态'}).selectOption('checking');
48
+ const manual = await page.locator('.rhine-spinner').evaluate(e=>getComputedStyle(e).animationDuration);
49
+ return {pass:!errors.length,results,errors,reducedMotion:{system,manual}};
50
+ }
@@ -0,0 +1,93 @@
1
+ // Run after navigating to a production settings-dialog fixture (source or tarball consumer).
2
+ async page => {
3
+ page.setDefaultTimeout(6000);
4
+ const base=page.url().split('/').slice(0,3).join('/'), results=[],errors=[];
5
+ page.on('pageerror',error=>errors.push(String(error)));
6
+ const assert=(ok,message)=>{if(!ok)throw new Error(message)};
7
+ const trapped=async dialog=>assert(await dialog.evaluate(e=>e.contains(document.activeElement)),'focus escaped settings');
8
+ for(const theme of ['rhine','arknights','neo-brutalism']) for(const width of [400,520,880,1280]) for(const zoom of [1,2]) {
9
+ const c={theme,width,zoom};
10
+ try {
11
+ await page.setViewportSize({width,height:900});
12
+ await page.goto(`${base}/?fixture=settings-dialog&theme=${theme}&zoom=${zoom}`);
13
+ const url=page.url(),host=page.getByRole('textbox',{name:'宿主查询'});
14
+ await host.fill('保留宿主查询');
15
+ const trigger=page.getByRole('button',{name:'打开设置',exact:true});await trigger.click();
16
+ const d=page.getByRole('dialog',{name:'设置',exact:true});
17
+ assert(await d.getAttribute('aria-describedby')===null,'dangling description');
18
+ const frame=await d.boundingBox();
19
+ const stable=async()=>{const r=await d.boundingBox();assert(Math.abs(r.y-frame.y)<1&&Math.abs(r.height-frame.height)<1,'section changed frame')};
20
+ const draft=d.getByRole('textbox',{name:'工作区名称'});await draft.fill('跨主题草稿');
21
+ const handle=await draft.elementHandle();
22
+ for(const selectedTheme of ['rhine','arknights','neo-brutalism']) {
23
+ await d.getByRole('combobox',{name:'界面主题'}).selectOption(selectedTheme);
24
+ assert(await handle.evaluate(e=>e.isConnected&&e.value==='跨主题草稿'),'theme remounted draft');
25
+ await stable();
26
+ }
27
+ await d.getByRole('combobox',{name:'界面主题'}).selectOption(theme);
28
+ const search=d.getByRole('combobox',{name:'授权成员'});
29
+ await search.fill('成员 2');
30
+ const list=page.getByRole('listbox');await list.waitFor();
31
+ const opt=list.getByRole('option',{name:'合成成员 2',exact:true});await opt.click();
32
+ assert(await opt.getAttribute('aria-selected')==='true','multiselect did not select');
33
+ await opt.click();assert(await opt.getAttribute('aria-selected')==='false','multiselect did not deselect');
34
+ await page.getByRole('button',{name:'完成选择',exact:true}).click();
35
+ // Immediate consecutive reverse/forward traversal after a nested FocusScope unmount.
36
+ for(let i=0;i<8;i++){await page.keyboard.press('Shift+Tab');await trapped(d);}
37
+ for(let i=0;i<8;i++){await page.keyboard.press('Tab');await trapped(d);}
38
+ await search.fill('成员 2');await list.waitFor();await page.keyboard.press('Escape');await list.waitFor({state:'detached'});await trapped(d);
39
+ assert(await d.isVisible(),'select Escape closed parent');
40
+ await d.getByRole('link',{name:'用户管理与权限',exact:true}).click();await stable();
41
+ const edit=d.getByRole('button',{name:'编辑成员 30',exact:true});await edit.click();
42
+ const inner=page.getByRole('dialog',{name:'编辑成员 30',exact:true});await inner.getByRole('textbox',{name:'成员备注'}).fill('内层草稿');
43
+ await page.keyboard.press('Escape');await inner.waitFor({state:'detached'});
44
+ await page.waitForFunction(()=>document.activeElement?.textContent==='编辑成员 30');
45
+ for(let i=0;i<8;i++){await page.keyboard.press('Shift+Tab');await trapped(d);}
46
+ await d.getByRole('link',{name:'公开项目',exact:true}).click();
47
+ for(const state of ['loading','error','empty','normal']) {
48
+ await d.getByRole('combobox',{name:'项目状态'}).selectOption(state);await stable();
49
+ const text={loading:'正在读取项目',error:'项目读取失败',empty:'暂无公开项目',normal:'项目标题'}[state];
50
+ assert((await d.textContent()).includes(text)||await d.getByLabel(text).count()>0,`missing ${state}`);
51
+ }
52
+ await d.getByRole('link',{name:'异步栏目',exact:true}).click();await d.getByText('正在读取栏目', {exact:true}).waitFor({state:'attached'});await stable();await d.getByText('异步栏目已就绪').waitFor();await stable();
53
+ await d.getByRole('link',{name:'账号',exact:true}).click();assert(await draft.inputValue()==='跨主题草稿','section lost draft');
54
+ await d.getByRole('button',{name:'保存当前工作区设置',exact:true}).click();assert(await d.getByRole('button',{name:'保存当前工作区设置'}).isDisabled(),'save is not busy');await stable();
55
+ await d.getByText('已保存演示草稿').waitFor();await stable();
56
+ await draft.fill('将被撤销');await d.getByRole('button',{name:'撤销全部未保存的修改'}).click();assert(await draft.inputValue()==='跨主题草稿','undo failed');
57
+ await d.getByRole('button',{name:'关闭设置',exact:true}).click();await d.waitFor({state:'detached'});await page.waitForFunction(()=>document.activeElement?.textContent==='打开设置');
58
+ assert(page.url()===url&&await host.inputValue()==='保留宿主查询','host state changed');
59
+ await trigger.click();assert(await draft.inputValue()==='跨主题草稿','reopen lost saved data');await page.keyboard.press('Escape');await d.waitFor({state:'detached'});
60
+ for(const outcome of ['error','conflict']) {
61
+ await page.getByRole('combobox',{name:'保存结果'}).selectOption(outcome);await trigger.click();await draft.fill('保留失败草稿');await d.getByRole('button',{name:'保存当前工作区设置'}).click();
62
+ await d.getByText(outcome==='error'?'保存失败,草稿已保留':'内容已更新,请核对后重新保存').waitFor();assert(await draft.inputValue()==='保留失败草稿','failure discarded draft');await page.keyboard.press('Escape');await d.waitFor({state:'detached'});
63
+ }
64
+ await page.getByRole('button',{name:'普通短弹窗'}).click();const short=page.getByRole('dialog',{name:'短对话框'});assert(!(await short.getAttribute('class')).includes('--bounded'),'short dialog became bounded');await page.keyboard.press('Escape');await short.waitFor({state:'detached'});
65
+ await page.getByRole('button',{name:'有界普通表单示例'}).click();
66
+ const form=page.getByRole('dialog',{name:'有界普通表单'}), head=form.locator('.rhine-dialog__head'), footer=form.locator('.rhine-dialog__footer');
67
+ const headBefore=await head.boundingBox(), footerBefore=await footer.boundingBox();
68
+ await form.getByRole('textbox',{name:'表单字段 30',exact:true}).focus();
69
+ const field=await form.getByRole('textbox',{name:'表单字段 30',exact:true}).boundingBox(), bodyRect=await form.locator('.rhine-dialog__body').boundingBox();
70
+ assert(Math.abs((await head.boundingBox()).y-headBefore.y)<1&&Math.abs((await footer.boundingBox()).y-footerBefore.y)<1,'plain bounded head/footer moved');
71
+ assert(field.y>=bodyRect.y-1&&field.y+field.height<=bodyRect.y+bodyRect.height+1,'plain bounded last field unreachable');
72
+ await page.keyboard.press('Escape');await form.waitFor({state:'detached'});
73
+ if(zoom===1) for(const [triggerName,title] of [['普通抽屉示例','普通抽屉'],['辅助分栏示例','辅助分栏']]) {
74
+ await page.getByRole('button',{name:triggerName}).click();const legacy=page.getByRole('dialog',{name:title,exact:true});
75
+ assert(!(await legacy.getAttribute('class')).includes('--bounded'),'legacy mode became bounded');
76
+ await legacy.getByRole('textbox').fill('保留既有模式');await page.keyboard.press('Tab');await trapped(legacy);
77
+ await page.keyboard.press('Escape');await legacy.waitFor({state:'detached'});
78
+ }
79
+ results.push({...c,pass:true});
80
+ }catch(error){results.push({...c,pass:false,error:String(error)});}
81
+ }
82
+ // System and manual reduced motion are independent paths.
83
+ const motion=[];
84
+ for(const mode of ['system','manual','full']) {
85
+ await page.emulateMedia({reducedMotion:mode==='system'?'reduce':'no-preference'});
86
+ await page.goto(`${base}/?fixture=settings-dialog&motion=${mode==='manual'?'reduced':'full'}`);
87
+ await page.getByRole('button',{name:'打开设置',exact:true}).click();
88
+ const duration=await page.getByRole('dialog',{name:'设置',exact:true}).evaluate(e=>getComputedStyle(e).animationDuration);
89
+ motion.push({mode,duration,pass:mode==='full'?parseFloat(duration)>0:parseFloat(duration)===0});
90
+ }
91
+ await page.emulateMedia({reducedMotion:'no-preference'});
92
+ return {pass:results.every(r=>r.pass)&&motion.every(r=>r.pass)&&!errors.length,results,motion,errors};
93
+ }
@@ -16,12 +16,8 @@ async page => {
16
16
  await page.getByRole('button',{name:'打开设置',exact:true}).click();
17
17
  const outer = page.getByRole('dialog',{name:'设置',exact:true});
18
18
  const outerBounds = await box(outer);
19
- const help = outer.getByRole('button',{name:'设置帮助',exact:true});
20
- await help.focus(); await page.getByRole('tooltip').waitFor();
21
- await page.keyboard.press('Escape'); await page.getByRole('tooltip').waitFor({state:'detached'});
22
- if (!await outer.isVisible()) throw new Error('Tooltip Escape closed the settings dialog');
23
19
  await outer.getByRole('textbox',{name:'工作区名称'}).fill('已保存草稿');
24
- const save = outer.getByRole('button',{name:'保存设置',exact:true});
20
+ const save = outer.getByRole('button',{name:'保存当前工作区设置',exact:true});
25
21
  await save.scrollIntoViewIfNeeded(); const saveBounds=await box(save); await save.click();
26
22
  await outer.getByRole('status').getByText('已保存演示草稿').waitFor();
27
23
  await outer.getByRole('link',{name:'用户管理与权限',exact:true}).click();