@linhey/rhine-design 0.9.1 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/CONTRIBUTING.md +3 -1
  3. package/README.md +18 -15
  4. package/components/AdvancedForms.tsx +22 -8
  5. package/components/COMMON_COMPONENTS.md +6 -3
  6. package/components/Forms.tsx +14 -1
  7. package/components/ListFilterBar.tsx +5 -3
  8. package/components/Overlays.tsx +3 -2
  9. package/components/README.md +13 -5
  10. package/components/SPACING.md +9 -0
  11. package/components/TYPOGRAPHY.md +2 -0
  12. package/components/Toast.tsx +10 -4
  13. package/components/checkbox-row.css +12 -0
  14. package/components/common.css +3 -1
  15. package/components/compact-forms.css +20 -0
  16. package/components/components.tsx +13 -7
  17. package/components/index.ts +1 -0
  18. package/components/media-parity.css +4 -4
  19. package/components/patterns.css +3 -3
  20. package/components/styles.css +17 -2
  21. package/components/theme-picker.css +2 -2
  22. package/docs/APPLICATION_COMPONENTS.md +2 -2
  23. package/docs/CHECKBOX_ROWS.md +36 -0
  24. package/docs/COMPACT_FILTER_CONTROLS.md +38 -0
  25. package/docs/COMPONENT_TYPOGRAPHY_AUDIT.md +44 -0
  26. package/docs/DESIGN_SYSTEM.md +10 -1
  27. package/docs/DESIGN_SYSTEM_AUDIT.md +4 -4
  28. package/docs/DISCLOSURE_VERIFICATION.md +49 -0
  29. package/docs/INTERACTION_PATTERNS.md +9 -1
  30. package/docs/LIST_FILTER_BAR.md +5 -1
  31. package/docs/REVIEW_TOOL.md +128 -0
  32. package/docs/REVIEW_VERIFICATION.md +56 -0
  33. package/docs/SUMMARY_TABS.md +77 -0
  34. package/docs/THEMING.md +1 -1
  35. package/docs/assets/disclosure/arknights-400.png +0 -0
  36. package/docs/assets/disclosure/neo-brutalism-400.png +0 -0
  37. package/docs/assets/disclosure/rhine-400.png +0 -0
  38. package/docs/assets/disclosure/rhine-native200.png +0 -0
  39. package/examples/review/http/App.tsx +46 -0
  40. package/examples/review/http/catalog.tsx +44 -0
  41. package/examples/review/http/details.ts +1 -0
  42. package/examples/review/http/vite.config.ts +8 -0
  43. package/examples/review/platform/Workspace.tsx +34 -0
  44. package/examples/review/platform/catalog.tsx +46 -0
  45. package/examples/review/platform/vite.config.ts +8 -0
  46. package/examples/review/product.css +3 -0
  47. package/package.json +17 -5
  48. package/preview/CheckboxRowGallery.tsx +26 -0
  49. package/preview/CommonGallery.tsx +1 -1
  50. package/preview/CompactFilterBarGallery.tsx +19 -0
  51. package/preview/ComponentTypographyGallery.tsx +23 -0
  52. package/preview/DenseFilterControlsGallery.tsx +21 -0
  53. package/preview/GalleryShell.tsx +4 -3
  54. package/preview/OrbitGallery.tsx +1 -1
  55. package/preview/SpacingGallery.tsx +9 -1
  56. package/preview/SummaryTabsGallery.tsx +37 -0
  57. package/preview/main.tsx +14 -3
  58. package/preview/packageInfo.ts +5 -0
  59. package/review/canvas.tsx +107 -0
  60. package/review/dev-client.mjs +17 -0
  61. package/review/feedback.ts +17 -0
  62. package/review/index.ts +2 -0
  63. package/review/isolation.ts +64 -0
  64. package/review/lifecycle.ts +59 -0
  65. package/review/protocol.ts +7 -0
  66. package/review/review.css +41 -0
  67. package/review/shell.tsx +165 -0
  68. package/review/state.ts +41 -0
  69. package/review/types.ts +59 -0
  70. package/review/vite.d.mts +10 -0
  71. package/review/vite.mjs +107 -0
  72. package/scripts/check-checkbox-rows.js +65 -0
  73. package/scripts/check-component-typography.js +76 -0
  74. package/scripts/check-dense-filters.js +60 -0
  75. package/scripts/check-review-package.mjs +11 -0
  76. package/scripts/check-review-recovery.js +47 -0
  77. package/scripts/check-review.js +153 -0
  78. package/skills/rhine-design/SKILL.md +2 -2
  79. package/skills/rhine-design/references/API_MAP.md +6 -0
  80. package/skills/rhine-design/references/TASK_MAP.md +2 -0
  81. package/skills/rhine-design/references/integration.md +1 -1
  82. package/tests/CheckboxRow.test.tsx +53 -0
  83. package/tests/CommonComponents.test.tsx +8 -0
  84. package/tests/CompactFilterBar.test.tsx +50 -0
  85. package/tests/DenseFilterControls.test.tsx +79 -0
  86. package/tests/DesignSystem.test.tsx +32 -0
  87. package/tests/Review.test.tsx +117 -0
  88. package/tests/ReviewBuild.test.tsx +59 -0
  89. package/tests/ReviewShell.test.tsx +63 -0
  90. package/tsconfig.json +5 -2
@@ -0,0 +1,41 @@
1
+ import { themes } from '../components/themeMetadata';
2
+ import type { ReviewCatalog, ReviewCatalogInfo, ReviewSelection } from './types';
3
+
4
+ export const defaultWidths = [400, 520, 880, 1280] as const;
5
+ const validId = /^[a-zA-Z0-9][a-zA-Z0-9_-]*$/;
6
+ export function defineReview(catalog: ReviewCatalog): ReviewCatalog {
7
+ if (!catalog.scenes.length) throw new Error('Review needs at least one scene.');
8
+ const unique = (ids: string[]) => ids.every(id => validId.test(id)) && new Set(ids).size === ids.length;
9
+ if (!unique(catalog.scenes.map(scene => scene.id))) throw new Error('Scene IDs must be unique stable identifiers.');
10
+ for (const scene of catalog.scenes) {
11
+ if (!unique((scene.dimensions ?? []).map(d => d.id))) throw new Error(`Invalid dimensions: ${scene.id}`);
12
+ for (const dimension of scene.dimensions ?? []) {
13
+ if (!dimension.options.length || !unique(dimension.options.map(o => o.id))) throw new Error(`Invalid options: ${dimension.id}`);
14
+ }
15
+ }
16
+ if (catalog.widths && (!catalog.widths.length || catalog.widths.some(w => !Number.isInteger(w) || w < 240 || w > 3840))) throw new Error('Widths must be integers between 240 and 3840.');
17
+ return catalog;
18
+ }
19
+ export function catalogInfo(catalog: ReviewCatalog): ReviewCatalogInfo {
20
+ return { title: catalog.title, widths: catalog.widths, scenes: catalog.scenes.map(({ setup: _setup, ...info }) => info) };
21
+ }
22
+ export function parseReviewState(hash: string, catalog: ReviewCatalogInfo): ReviewSelection {
23
+ const params = new URLSearchParams(hash.replace(/^#/, ''));
24
+ const scene = catalog.scenes.find(s => s.id === params.get('scene')) ?? catalog.scenes[0];
25
+ if (!scene) throw new Error('Empty review catalog.');
26
+ const width = Number(params.get('width'));
27
+ const motion = params.get('motion');
28
+ return {
29
+ scene: scene.id,
30
+ dimensions: Object.fromEntries((scene.dimensions ?? []).map(d => [d.id, d.options.find(o => o.id === params.get(`d.${d.id}`))?.id ?? d.options[0].id])),
31
+ theme: themes.find(t => t.name === params.get('theme'))?.name ?? 'rhine',
32
+ width: (catalog.widths ?? defaultWidths).includes(width) ? width : 'fit',
33
+ zoom: params.get('zoom') === '2' ? 2 : 1,
34
+ motion: motion === 'reduced' ? motion : (motion === 'full' || motion === 'paused') && scene.motionControls?.includes(motion) ? motion : 'system',
35
+ };
36
+ }
37
+ export function serializeReviewState(state: ReviewSelection): string {
38
+ const params = new URLSearchParams({ scene: state.scene, theme: state.theme, width: String(state.width), zoom: String(state.zoom), motion: state.motion });
39
+ for (const key of Object.keys(state.dimensions).sort()) params.set(`d.${key}`, state.dimensions[key]);
40
+ return `#${params}`;
41
+ }
@@ -0,0 +1,59 @@
1
+ import type { ReactNode } from 'react';
2
+ import type { ThemeName } from '../components/themeMetadata';
3
+
4
+ export type ReviewMotion = 'system' | 'full' | 'reduced' | 'paused';
5
+ export interface ReviewEnvironment {
6
+ theme: ThemeName;
7
+ motion: ReviewMotion;
8
+ reducedMotion: boolean;
9
+ paused: boolean;
10
+ }
11
+ export interface ReviewSelection {
12
+ scene: string;
13
+ dimensions: Record<string, string>;
14
+ theme: ThemeName;
15
+ width: number | 'fit';
16
+ zoom: 1 | 2;
17
+ motion: ReviewMotion;
18
+ }
19
+ export interface ReviewOption { id: string; label: string }
20
+ export interface ReviewDimension { id: string; label: string; options: readonly ReviewOption[] }
21
+ export interface ReviewSceneInfo {
22
+ id: string;
23
+ title: string;
24
+ group?: string;
25
+ description?: string;
26
+ source?: string;
27
+ issue?: string;
28
+ validation?: string;
29
+ /** Host apps with an existing ThemeProvider own their single theme root. */
30
+ themeRoot?: 'runtime' | 'host';
31
+ /** Opt in only if every scene animation handles the corresponding environment flag. */
32
+ motionControls?: readonly ('full' | 'paused')[];
33
+ dimensions?: readonly ReviewDimension[];
34
+ }
35
+ export interface ReviewContext {
36
+ dimensions: Readonly<Record<string, string>>;
37
+ signal: AbortSignal;
38
+ reason: 'select' | 'reset';
39
+ /** Register immediately, including resources acquired before async setup finishes. */
40
+ onDispose(cleanup: () => void | Promise<void>): void;
41
+ /** Only synthetic requests are dispatched here. Returning undefined explicitly fails. */
42
+ mockHttp(handler: (request: Request) => Response | undefined | Promise<Response | undefined>): void;
43
+ report(message: string, kind?: 'info' | 'error'): void;
44
+ }
45
+ export interface ReviewSession {
46
+ render(environment: ReviewEnvironment): ReactNode;
47
+ /** Explicit reset notification; a fresh setup always follows disposal. */
48
+ reset?(): void | Promise<void>;
49
+ dispose(): void | Promise<void>;
50
+ }
51
+ export interface ReviewScene extends ReviewSceneInfo {
52
+ setup(context: ReviewContext): ReviewSession | Promise<ReviewSession>;
53
+ }
54
+ export interface ReviewCatalog {
55
+ title: string;
56
+ widths?: readonly number[];
57
+ scenes: readonly ReviewScene[];
58
+ }
59
+ export type ReviewCatalogInfo = Omit<ReviewCatalog, 'scenes'> & { scenes: readonly ReviewSceneInfo[] };
@@ -0,0 +1,10 @@
1
+ import type { Plugin } from 'vite';
2
+ export interface ReviewViteOptions {
3
+ /** Catalog module relative to the dedicated Vite root. */
4
+ catalog: string;
5
+ /** Product router base, separate from static asset base. */
6
+ routerBase?: string;
7
+ }
8
+ export function reviewPlugin(options: ReviewViteOptions): Plugin;
9
+ /** Forbidden paths are exact files/directories relative to the production Vite root. */
10
+ export function reviewProductionGuard(options?: { forbidden?: readonly string[] }): Plugin;
@@ -0,0 +1,107 @@
1
+ // @ts-check
2
+ import { resolve, dirname, relative } from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+ import { readFileSync } from 'node:fs';
5
+ const directory = dirname(fileURLToPath(import.meta.url));
6
+ const ids = { shell: '\0rhine-review:shell', canvas: '\0rhine-review:canvas' };
7
+ const csp = "default-src 'none'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; media-src 'self' blob:; connect-src 'none'; frame-src 'self'; worker-src 'none'; object-src 'none'; form-action 'none'; base-uri 'self'";
8
+ /** @param {string} script @param {string[]} styles */
9
+ function html(script, styles = []) {
10
+ return `<!doctype html><html lang="zh-CN"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta http-equiv="Content-Security-Policy" content="${csp}"><link rel="icon" href="data:,"><title>Rhine Review</title>${styles.map(style => `<link rel="stylesheet" href="${style}">`).join('')}</head><body><div id="root"></div><script type="module" src="${script}"></script></body></html>`;
11
+ }
12
+ /** Use in a dedicated config, never merge a production config containing secrets/proxies. */
13
+ /** @param {import('./vite.mjs').ReviewViteOptions} options @returns {import('vite').Plugin} */
14
+ export function reviewPlugin(options) {
15
+ /** @type {import('vite').ResolvedConfig} */
16
+ let config;
17
+ return {
18
+ name: 'rhine-review',
19
+ enforce: 'pre',
20
+ config(userConfig) {
21
+ const root = userConfig.root ?? process.cwd();
22
+ return {
23
+ appType: 'mpa', envDir: false, publicDir: false, envPrefix: 'RHINE_REVIEW_PUBLIC_',
24
+ server: { hmr: false },
25
+ cacheDir: resolve(root, 'node_modules/.vite-review'),
26
+ optimizeDeps: { entries: [resolve(directory, 'shell.tsx'), resolve(directory, 'canvas.tsx'), resolve(root, options.catalog)] },
27
+ resolve: { dedupe: ['react', 'react-dom'] },
28
+ define: options.routerBase ? { 'import.meta.env.BASE_URL': JSON.stringify(options.routerBase) } : {},
29
+ build: { cssCodeSplit: false, rollupOptions: { input: { review: ids.shell, canvas: ids.canvas } } },
30
+ };
31
+ },
32
+ configResolved(resolved) {
33
+ config = resolved;
34
+ if (resolved.envDir !== false || resolved.publicDir !== '' || Object.keys(resolved.server.proxy ?? {}).length || Object.keys(resolved.preview.proxy ?? {}).length) {
35
+ throw new Error('Review requires envDir:false, publicDir:false and no server/preview proxies.');
36
+ }
37
+ if (resolved.build.outDir === '.' || resolve(resolved.root, resolved.build.outDir) === resolved.root)
38
+ throw new Error('Review output must use a separate directory.');
39
+ },
40
+ resolveId(id) { if (Object.values(ids).includes(id))
41
+ return id; },
42
+ load(id) {
43
+ if (id === ids.shell)
44
+ return `import {mountReviewShell} from ${JSON.stringify(resolve(directory, 'shell.tsx'))}; mountReviewShell();`;
45
+ if (id === ids.canvas)
46
+ return `import {mountReviewCanvas} from ${JSON.stringify(resolve(directory, 'canvas.tsx'))}; mountReviewCanvas(() => import(${JSON.stringify(resolve(config.root, options.catalog))}));`;
47
+ },
48
+ configureServer(server) {
49
+ server.middlewares.use(async (request, response, next) => {
50
+ const requested = (request.url ?? '/').split('?')[0];
51
+ const pathname = config.base !== './' && requested.startsWith(config.base) ? `/${requested.slice(config.base.length)}` : requested;
52
+ if (pathname === '/@vite/client') {
53
+ response.setHeader('Content-Type', 'text/javascript');
54
+ response.end(readFileSync(resolve(directory, 'dev-client.mjs'), 'utf8'));
55
+ return;
56
+ }
57
+ if (!['/', '/index.html', '/canvas.html'].includes(pathname)) {
58
+ next();
59
+ return;
60
+ }
61
+ const entry = pathname === '/canvas.html' ? ids.canvas : ids.shell;
62
+ try {
63
+ let document = await server.transformIndexHtml(pathname, html(`/@id/${entry.replace('\0', '__x00__')}`));
64
+ document = document.replace(/<script type="module" src="[^\"]*\/@vite\/client"><\/script>/g, '');
65
+ response.statusCode = 200;
66
+ response.setHeader('Content-Type', 'text/html');
67
+ response.end(document);
68
+ }
69
+ catch (error) {
70
+ next(error);
71
+ }
72
+ });
73
+ },
74
+ generateBundle: { order: 'post', handler(_options, bundle) {
75
+ const assetBase = config.base === './' || config.base === '' ? './' : config.base;
76
+ const styles = Object.values(bundle).filter(asset => asset.type === 'asset' && asset.fileName.endsWith('.css')).map(asset => `${assetBase}${asset.fileName}`);
77
+ for (const [name, entryId] of Object.entries(ids)) {
78
+ const entry = Object.values(bundle).find(asset => asset.type === 'chunk' && asset.isEntry && asset.facadeModuleId === entryId);
79
+ if (!entry)
80
+ this.error(`Missing review entry: ${name}`);
81
+ this.emitFile({ type: 'asset', fileName: name === 'shell' ? 'index.html' : 'canvas.html', source: html(`${assetBase}${entry.fileName}`, styles) });
82
+ }
83
+ } },
84
+ };
85
+ }
86
+ /** Add to the actual production build. A build fails if fixtures or review code are imported. */
87
+ /** @param {{forbidden?: readonly string[]}} options @returns {import('vite').Plugin} */
88
+ export function reviewProductionGuard(options = {}) {
89
+ /** @type {string} */
90
+ let root;
91
+ return {
92
+ name: 'rhine-review-production-guard', apply: 'build',
93
+ configResolved(config) { root = config.root; },
94
+ generateBundle(_options, bundle) {
95
+ const forbidden = [directory, ...(options.forbidden ?? []).map(path => resolve(root, path))].map(path => path.replaceAll('\\', '/'));
96
+ for (const chunk of Object.values(bundle)) {
97
+ if (chunk.type !== 'chunk')
98
+ continue;
99
+ for (const module of Object.keys(chunk.modules)) {
100
+ const normalized = module.replaceAll('\\', '/').split('?')[0];
101
+ if (normalized.includes('rhine-review:') || forbidden.some(path => normalized === path || normalized.startsWith(`${path}/`)))
102
+ this.error(`Production bundle imports review runtime or forbidden fixtures: ${relative(root, normalized)}`);
103
+ }
104
+ }
105
+ },
106
+ };
107
+ }
@@ -0,0 +1,65 @@
1
+ // Use a production preview and run with playwright-cli run-code --filename.
2
+ async page => {
3
+ page.setDefaultTimeout(10000);
4
+ const origin = new URL(page.url()).origin;
5
+ const failures = [], errors = [];
6
+ let scenarios = 0;
7
+ page.on('pageerror', error => errors.push(String(error)));
8
+ const check = (pass, context) => { if (!pass) failures.push(context); };
9
+ for (const theme of ['rhine', 'arknights', 'neo-brutalism']) {
10
+ for (const width of [400, 520, 880]) for (const zoom of [1, 2]) {
11
+ await page.setViewportSize({ width, height: 1000 });
12
+ await page.goto(`${origin}/?theme=${theme}&proof=${Date.now()}#checkbox-rows`);
13
+ await page.getByRole('heading', { name: '侧栏双行多选', exact: true }).waitFor();
14
+ await page.evaluate(value => { document.documentElement.style.zoom = String(value); }, zoom);
15
+ const container = page.locator('[data-checkbox-rows]');
16
+ for (const requestedWidth of [160, 240, 320]) {
17
+ const context = { theme, width, zoom, requestedWidth };
18
+ await page.getByRole('combobox', { name: '侧栏宽度', exact: true }).selectOption(String(requestedWidth));
19
+ const geometry = await container.evaluate((root, zoom) => {
20
+ const rows = [...root.querySelectorAll('.rhine-check--row > label')];
21
+ return rows.map(row => {
22
+ const name = row.querySelector('.rhine-check__name'), hint = row.querySelector('.rhine-check__hint');
23
+ const text = row.querySelector('.rhine-check__text');
24
+ const r = row.getBoundingClientRect(), n = name.getBoundingClientRect(), h = hint?.getBoundingClientRect(), t = text.getBoundingClientRect();
25
+ const style = getComputedStyle(row), ns = getComputedStyle(name), hs = hint && getComputedStyle(hint);
26
+ const border = parseFloat(style.borderTopWidth);
27
+ return { height:r.height / zoom, fits:row.scrollWidth <= row.clientWidth + 1, nameSize:ns.fontSize, nameLine:ns.lineHeight, nameWeight:ns.fontWeight, hintSize:hs?.fontSize, hintLine:hs?.lineHeight,
28
+ aligned:!h || Math.abs(n.left-h.left)<1, gap:h ? (h.top-n.bottom)/zoom : null, top:(t.top-r.top)/zoom-border, bottom:(r.bottom-t.bottom)/zoom-border };
29
+ });
30
+ }, zoom);
31
+ check(geometry.every(row => row.fits && row.height >= 44 && row.nameSize === '14px' && row.nameLine === '22px' && row.nameWeight === (theme === 'neo-brutalism' ? '600' : '500') && row.aligned && (row.gap === null || Math.abs(row.gap-4)<.1) && (!row.hintSize || row.hintSize === '12px' && row.hintLine === '18px') && Math.abs(row.top-row.bottom)<.1), { ...context, kind:'geometry', geometry });
32
+ check(await container.evaluate(e => e.scrollWidth <= e.clientWidth + 1), { ...context, kind:'container-fit' });
33
+ scenarios++;
34
+ }
35
+ const all = container.getByRole('checkbox', { name:'全部项目', exact:true });
36
+ const input = container.getByRole('checkbox', { name:'示例项目 A', exact:true });
37
+ check(await all.evaluate(e => e.indeterminate), { theme,width,zoom,kind:'mixed' });
38
+ const row = input.locator('..');
39
+ const selectedStyle = await row.evaluate(e => ({ background:getComputedStyle(e).backgroundColor,border:getComputedStyle(e).borderColor }));
40
+ await row.getByText('内存卡 · 14 项', { exact:true }).click();
41
+ check(!await input.isChecked(), { theme,width,zoom,kind:'hint-click' });
42
+ await page.mouse.move(0, 0);
43
+ const defaultStyle = await row.evaluate(e => ({ background:getComputedStyle(e).backgroundColor,border:getComputedStyle(e).borderColor }));
44
+ check(defaultStyle.background !== selectedStyle.background && defaultStyle.border !== selectedStyle.border, { theme,width,zoom,kind:'selection-style' });
45
+ const rowBox = await row.boundingBox();
46
+ await row.click({ position:{x:rowBox.width / 2,y:4} });
47
+ check(await input.isChecked(), { theme,width,zoom,kind:'padding-click' });
48
+ await all.focus(); await page.keyboard.press('Tab');
49
+ check(await input.evaluate(e => e === document.activeElement) && await row.evaluate(e => getComputedStyle(e).boxShadow.includes('inset')), { theme,width,zoom,kind:'keyboard-focus' });
50
+ await page.keyboard.press('Space');
51
+ check(!await input.isChecked(), { theme,width,zoom,kind:'space-toggle' });
52
+ const disabled = container.getByRole('checkbox', { name:'不可用项目',exact:true });
53
+ await disabled.locator('..').scrollIntoViewIfNeeded();
54
+ const disabledBox = await disabled.locator('..').boundingBox();
55
+ await page.mouse.click(disabledBox.x + disabledBox.width / 2, disabledBox.y + disabledBox.height / 2);
56
+ check(!await disabled.isChecked(), { theme,width,zoom,kind:'disabled' });
57
+ if (width === 400 && zoom === 2) {
58
+ await page.getByRole('combobox', { name:'侧栏宽度',exact:true }).selectOption('160');
59
+ await all.focus(); await page.keyboard.press('Tab');
60
+ await container.screenshot({ path:`output/playwright/issue-29/${theme}.png` });
61
+ }
62
+ }
63
+ }
64
+ return { pass:!failures.length && !errors.length, scenarios, failures, errors, scope:'Chromium, three themes, 400/520/880px, 160/240/320px sidebar capped by available space, CSS zoom 100/200%; native browser menu zoom not covered.' };
65
+ }
@@ -0,0 +1,76 @@
1
+ // Run against the production preview with playwright-cli run-code --filename.
2
+ async page => {
3
+ page.setDefaultTimeout(10000);
4
+ const origin = new URL(page.url()).origin;
5
+ const failures = [], errors = [];
6
+ let scenarios = 0;
7
+ page.on('pageerror', error => errors.push(String(error)));
8
+ const check = (pass, context) => { if (!pass) failures.push(context); };
9
+ const typography = async (selector, size, leading, weight, context) => {
10
+ const actual = await page.locator(selector).first().evaluate(e => {
11
+ const s = getComputedStyle(e);
12
+ return { size:s.fontSize, leading:s.lineHeight, weight:s.fontWeight };
13
+ });
14
+ check(actual.size === `${size}px` && actual.leading === `${leading}px` && actual.weight === String(weight), { ...context, selector, actual, expected:{size,leading,weight} });
15
+ };
16
+ const wrappedText = async (selector, context) => {
17
+ const actual = await page.locator(selector).evaluate(e => {
18
+ const range = document.createRange();
19
+ range.selectNodeContents(e);
20
+ const lines = Array.from(range.getClientRects());
21
+ const box = e.getBoundingClientRect();
22
+ return {
23
+ lineCount:new Set(lines.map(r => Math.round(r.top))).size,
24
+ unclipped:e.scrollWidth <= e.clientWidth+1 && e.scrollHeight <= e.clientHeight+1 &&
25
+ lines.every(r => r.left >= box.left-1 && r.right <= box.right+1 && r.top >= box.top-1 && r.bottom <= box.bottom+1)
26
+ };
27
+ });
28
+ check(actual.lineCount > 1 && actual.unclipped, {...context,selector,kind:'text-wrap',actual});
29
+ };
30
+ for (const theme of ['rhine', 'arknights', 'neo-brutalism']) {
31
+ for (const width of [400,520,880]) for (const zoom of [1,2]) {
32
+ const context = {theme,width,zoom}, heading = theme === 'neo-brutalism' ? 750 : 600;
33
+ await page.setViewportSize({width,height:1000});
34
+ await page.goto(`${origin}/?theme=${theme}&proof=${Date.now()}#component-typography`);
35
+ await page.getByRole('heading',{name:'公共组件文字配对',exact:true}).waitFor();
36
+ await page.evaluate(value => { document.documentElement.style.zoom=String(value); },zoom);
37
+ await page.getByRole('button',{name:'模拟复制',exact:true}).click();
38
+ await page.locator('.rhine-copy>[role]').filter({hasText:'已复制'}).waitFor();
39
+ await page.locator('.rhine-theme-preview[data-state=error]').waitFor();
40
+ for (const inherited of [false,true]) {
41
+ if (inherited) await page.locator('.rhine-theme').evaluate(e => { e.style.lineHeight='38px'; e.style.fontWeight='700'; });
42
+ const state = {...context,inherited};
43
+ await typography('.rhine-empty strong',16,24,heading,state);
44
+ await typography('.rhine-error-summary h3',16,24,heading,state);
45
+ for (const selector of ['.rhine-copy>[role]','.rhine-steps small','.rhine-fieldset>p','.rhine-theme-option__status','.rhine-theme-preview']) await typography(selector,12,18,400,state);
46
+ await typography('.rhine-media-card__status',14,22,400,state);
47
+ }
48
+ await page.locator('.rhine-theme').evaluate(e => { e.style.lineHeight=''; e.style.fontWeight=''; });
49
+ check(await page.locator('.rhine-empty').evaluate(e => e.scrollWidth <= e.clientWidth+1), {...context,kind:'empty-wrap'});
50
+ if (width === 400 && zoom === 2) await wrappedText('.rhine-empty strong',context);
51
+ if (width === 400 && zoom === 2) await page.locator('.rhine-empty').screenshot({path:`output/playwright/issue-31/empty-${theme}.png`});
52
+ const opener=page.getByRole('button',{name:'打开媒体示例',exact:true});
53
+ await opener.click();
54
+ const dialog=page.getByRole('dialog');
55
+ await dialog.waitFor();
56
+ await typography('.rhine-media-viewer__header p',12,18,400,context);
57
+ await typography('.rhine-media-viewer__header h2',18,26,heading,context);
58
+ await typography('.rhine-media-viewer__actions label',12,18,400,context);
59
+ if (width === 400 && zoom === 2) await wrappedText('.rhine-media-viewer__header p',context);
60
+ check(await dialog.evaluate(e => e.scrollWidth <= e.clientWidth+1), {...context,kind:'viewer-wrap'});
61
+ check(await dialog.locator('button,select').evaluateAll((elements, zoom) => elements.every(e => e.getBoundingClientRect().height/zoom >= 44),zoom), {...context,kind:'hit-targets'});
62
+ await dialog.getByRole('button',{name:'下一项',exact:true}).click();
63
+ check(await dialog.getByRole('heading',{name:'下一项合成媒体',exact:true}).isVisible(), {...context,kind:'next'});
64
+ if (width === 400 && zoom === 2) await page.locator('.rhine-media-viewer__header').screenshot({path:`output/playwright/issue-31/media-${theme}.png`});
65
+ const close=dialog.getByRole('button',{name:'关闭媒体查看器',exact:true});
66
+ await close.scrollIntoViewIfNeeded();
67
+ check(await close.evaluate(e => { const r=e.getBoundingClientRect(); return r.top >= 0 && r.bottom <= innerHeight && r.left >= 0 && r.right <= innerWidth; }), {...context,kind:'close-visible'});
68
+ await close.click(); await dialog.waitFor({state:'detached'});
69
+ await page.waitForFunction(() => document.activeElement?.textContent === '打开媒体示例');
70
+ await opener.click(); await page.keyboard.press('Escape'); await dialog.waitFor({state:'detached'});
71
+ await page.waitForFunction(() => document.activeElement?.textContent === '打开媒体示例');
72
+ scenarios++;
73
+ }
74
+ }
75
+ return {pass:!failures.length&&!errors.length,scenarios,failures,errors,scope:'Chromium, three themes, 400/520/880px, CSS zoom 100/200%; synthetic content and inherited-style stress. Native menu zoom is not covered.'};
76
+ }
@@ -0,0 +1,60 @@
1
+ // Run against a production preview: playwright-cli run-code --filename=scripts/check-dense-filters.js
2
+ async page => {
3
+ page.setDefaultTimeout(10000);
4
+ const origin = new URL(page.url()).origin;
5
+ const failures = [], errors = [];
6
+ let scenarios = 0;
7
+ page.on('pageerror', error => errors.push(String(error)));
8
+ const check = (pass, context) => { if (!pass) failures.push(context); };
9
+ for (const theme of ['rhine', 'arknights', 'neo-brutalism']) {
10
+ for (const width of [400, 520, 880]) for (const zoom of [1, 2]) {
11
+ const context = { theme, width, zoom };
12
+ await page.setViewportSize({ width, height: 1000 });
13
+ await page.goto(`${origin}/?theme=${theme}&proof=${Date.now()}-${scenarios}#compact-filters`);
14
+ await page.getByRole('heading', { name: '密集筛选控件', exact: true }).waitFor();
15
+ await page.evaluate(value => { document.documentElement.style.zoom = String(value); }, zoom);
16
+ const tags = page.getByRole('list', { name: '部门已选项' });
17
+ check(await tags.getByRole('listitem').count() === 2, { ...context, kind: 'bounded-tags' });
18
+ check(await tags.evaluate(e => e.scrollWidth <= e.clientWidth + 1 && [...e.children].every(li => li.scrollWidth <= li.clientWidth + 1)), { ...context, kind: 'tags-fit' });
19
+ await page.getByRole('button', { name: '展开已选项' }).click();
20
+ check(await tags.getByRole('listitem').count() === 90 && await tags.evaluate(e => e.clientHeight <= 224 && e.scrollHeight > e.clientHeight), { ...context, kind: 'bounded-expansion' });
21
+ await page.getByRole('button', { name: '移除合成部门 90', exact: true }).click();
22
+ check(await page.getByRole('combobox', { name: '部门', exact: true }).evaluate(e => document.activeElement === e), { ...context, kind: 'remove-focus' });
23
+ await page.getByRole('button', { name: '清空部门', exact: true }).click();
24
+ await page.getByRole('button', { name: '全选部门', exact: true }).click();
25
+ check(await tags.getByRole('listitem').count() === 2, { ...context, kind: 'reset-expansion' });
26
+ const range = page.getByRole('group', { name: '日期区间', exact: true });
27
+ const start = range.getByLabel('开始日期', { exact: true });
28
+ await start.fill('2026-10-01'); await start.focus();
29
+ await page.keyboard.press('ArrowRight'); await page.keyboard.press('ArrowRight'); await page.keyboard.press('ArrowUp');
30
+ check(await start.inputValue() === '2026-10-02', { ...context, kind: 'native-last-segment' });
31
+ check(await range.evaluate(e => e.scrollWidth <= e.clientWidth + 1), { ...context, kind: 'date-fit' });
32
+ if (width === 400 && zoom === 2) await range.screenshot({ path: `output/playwright/issue-27/date-${theme}.png` });
33
+ const trigger = page.getByRole('button', { name: '更多筛选', exact: true });
34
+ await trigger.click();
35
+ const dialog = page.getByRole('dialog', { name: '紧凑筛选示例', exact: true });
36
+ await dialog.evaluate(e => Promise.all(e.getAnimations().map(a => a.finished)));
37
+ check(!await dialog.getAttribute('aria-describedby') && await dialog.locator('.rhine-list-filter-bar__feedback').count() === 0, { ...context, kind: 'omitted-feedback' });
38
+ const segments = dialog.locator('fieldset.rhine-segmented');
39
+ check(await segments.evaluate(e => {
40
+ const items = [...e.querySelectorAll('label')].map(label => ({ label: label.getBoundingClientRect(), span: label.querySelector('span').getBoundingClientRect() }));
41
+ return items.every(item => Math.abs(item.label.width - items[0].label.width) < 1 && Math.abs(item.label.width - item.span.width) < 1 && Math.abs(item.label.height - item.span.height) < 1);
42
+ }), { ...context, kind: 'equal-segment-backgrounds' });
43
+ await segments.getByRole('radio', { name: '今天', exact: true }).focus();
44
+ await page.keyboard.press('ArrowRight');
45
+ check(await segments.getByRole('radio', { name: '昨天', exact: true }).isChecked(), { ...context, kind: 'native-radio-keyboard' });
46
+ check(await dialog.evaluate(e => e.scrollWidth <= e.clientWidth + 1), { ...context, kind: 'drawer-fit' });
47
+ await page.keyboard.press('Escape'); await dialog.waitFor({ state: 'detached' });
48
+ await trigger.waitFor();
49
+ await page.waitForFunction(() => document.activeElement?.textContent?.includes('更多筛选'));
50
+ for (const [state, text] of [['加载', '正在更新结果…'], ['失败', '暂时无法更新合成结果,请重试。']]) {
51
+ await page.getByRole('button', { name: state, exact: true }).click();
52
+ await trigger.click();
53
+ check(await dialog.getByText(text, { exact: true }).isVisible(), { ...context, kind: `feedback-${state}` });
54
+ await page.keyboard.press('Escape'); await dialog.waitFor({ state: 'detached' });
55
+ }
56
+ scenarios++;
57
+ }
58
+ }
59
+ return { pass: failures.length === 0 && errors.length === 0, scenarios, failures, errors, scope: 'Chromium, three themes, 400/520/880px, 100/200% CSS zoom; synthetic data only. Native menu zoom is not covered.' };
60
+ }
@@ -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
+ }