@linhey/rhine-design 0.10.0 → 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.
- package/CHANGELOG.md +13 -0
- package/CONTRIBUTING.md +3 -1
- package/README.md +17 -14
- package/components/COMMON_COMPONENTS.md +5 -3
- package/components/README.md +13 -5
- package/components/SPACING.md +9 -0
- package/components/Toast.tsx +10 -4
- package/components/common.css +2 -0
- package/components/components.tsx +13 -7
- package/components/styles.css +12 -0
- package/docs/DESIGN_SYSTEM.md +10 -1
- package/docs/DESIGN_SYSTEM_AUDIT.md +4 -4
- package/docs/DISCLOSURE_VERIFICATION.md +49 -0
- package/docs/INTERACTION_PATTERNS.md +9 -1
- package/docs/REVIEW_TOOL.md +128 -0
- package/docs/REVIEW_VERIFICATION.md +56 -0
- package/docs/SUMMARY_TABS.md +77 -0
- package/docs/THEMING.md +1 -1
- package/docs/assets/disclosure/arknights-400.png +0 -0
- package/docs/assets/disclosure/neo-brutalism-400.png +0 -0
- package/docs/assets/disclosure/rhine-400.png +0 -0
- package/docs/assets/disclosure/rhine-native200.png +0 -0
- package/examples/review/http/App.tsx +46 -0
- package/examples/review/http/catalog.tsx +44 -0
- package/examples/review/http/details.ts +1 -0
- package/examples/review/http/vite.config.ts +8 -0
- package/examples/review/platform/Workspace.tsx +34 -0
- package/examples/review/platform/catalog.tsx +46 -0
- package/examples/review/platform/vite.config.ts +8 -0
- package/examples/review/product.css +3 -0
- package/package.json +17 -5
- package/preview/CommonGallery.tsx +1 -1
- package/preview/GalleryShell.tsx +3 -2
- package/preview/OrbitGallery.tsx +1 -1
- package/preview/SpacingGallery.tsx +9 -1
- package/preview/SummaryTabsGallery.tsx +37 -0
- package/preview/main.tsx +7 -3
- package/preview/packageInfo.ts +5 -0
- package/review/canvas.tsx +107 -0
- package/review/dev-client.mjs +17 -0
- package/review/feedback.ts +17 -0
- package/review/index.ts +2 -0
- package/review/isolation.ts +64 -0
- package/review/lifecycle.ts +59 -0
- package/review/protocol.ts +7 -0
- package/review/review.css +41 -0
- package/review/shell.tsx +165 -0
- package/review/state.ts +41 -0
- package/review/types.ts +59 -0
- package/review/vite.d.mts +10 -0
- package/review/vite.mjs +107 -0
- package/scripts/check-review-package.mjs +11 -0
- package/scripts/check-review-recovery.js +47 -0
- package/scripts/check-review.js +153 -0
- package/skills/rhine-design/references/API_MAP.md +4 -0
- package/skills/rhine-design/references/TASK_MAP.md +2 -0
- package/tests/CommonComponents.test.tsx +8 -0
- package/tests/DesignSystem.test.tsx +32 -0
- package/tests/Review.test.tsx +117 -0
- package/tests/ReviewBuild.test.tsx +59 -0
- package/tests/ReviewShell.test.tsx +63 -0
- package/tsconfig.json +5 -2
package/review/shell.tsx
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
2
|
+
import { createRoot } from 'react-dom/client';
|
|
3
|
+
import { Button, Logo, Select, TextField } from '../components';
|
|
4
|
+
import { ThemeProvider, themes } from '../components/theme';
|
|
5
|
+
import { defaultWidths, parseReviewState, serializeReviewState } from './state';
|
|
6
|
+
import { isReviewMessage, sendReview } from './protocol';
|
|
7
|
+
import { appendFeedback, emptyFeedback, type ReviewFeedback } from './feedback';
|
|
8
|
+
import type { ReviewCatalogInfo, ReviewSelection } from './types';
|
|
9
|
+
import './review.css';
|
|
10
|
+
|
|
11
|
+
type Phase = 'connecting' | 'loading' | 'ready' | 'failed' | 'timed-out';
|
|
12
|
+
export const reviewWaitMs = 10_000;
|
|
13
|
+
|
|
14
|
+
export function ReviewShell() {
|
|
15
|
+
const frame = useRef<HTMLIFrameElement>(null);
|
|
16
|
+
const [frameEpoch, setFrameEpoch] = useState(0);
|
|
17
|
+
const [catalog, setCatalog] = useState<ReviewCatalogInfo>();
|
|
18
|
+
const [state, setState] = useState<ReviewSelection>();
|
|
19
|
+
const [query, setQuery] = useState('');
|
|
20
|
+
const [log, setLog] = useState(emptyFeedback);
|
|
21
|
+
const [phase, setPhase] = useState<Phase>('connecting');
|
|
22
|
+
const [menu, setMenu] = useState(false);
|
|
23
|
+
const [size, setSize] = useState({ width: 0, height: 0 });
|
|
24
|
+
const stateRef = useRef(state);
|
|
25
|
+
const connected = useRef(false);
|
|
26
|
+
const sequence = useRef(0);
|
|
27
|
+
const pendingRequest = useRef(0);
|
|
28
|
+
const feedbackSequence = useRef(0);
|
|
29
|
+
const timeout = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
|
30
|
+
stateRef.current = state;
|
|
31
|
+
const record = useCallback((message: string, kind: ReviewFeedback['kind'] = 'info', scene = '画布') => {
|
|
32
|
+
const entry = { id: ++feedbackSequence.current, message, kind, scene };
|
|
33
|
+
setLog(previous => appendFeedback(previous, entry));
|
|
34
|
+
}, []);
|
|
35
|
+
const stopWaiting = useCallback(() => { clearTimeout(timeout.current); timeout.current = undefined; }, []);
|
|
36
|
+
const waitFor = useCallback((next: 'connecting' | 'loading') => {
|
|
37
|
+
stopWaiting(); setPhase(next);
|
|
38
|
+
timeout.current = setTimeout(() => {
|
|
39
|
+
setPhase('timed-out');
|
|
40
|
+
record(next === 'connecting' ? '连接画布超时,请检查资源加载后重新载入画布。' : '场景初始化或清理超过 10 秒,尚未确认完成。可继续等待,或重新载入画布。', 'error', next === 'connecting' ? '画布' : stateRef.current?.scene);
|
|
41
|
+
}, reviewWaitMs);
|
|
42
|
+
}, [record, stopWaiting]);
|
|
43
|
+
const select = useCallback((selection: ReviewSelection, reset = false) => {
|
|
44
|
+
if (!connected.current || !frame.current?.contentWindow) return;
|
|
45
|
+
pendingRequest.current = ++sequence.current;
|
|
46
|
+
waitFor('loading');
|
|
47
|
+
sendReview(frame.current.contentWindow, { type: 'select', hash: serializeReviewState(selection), requestId: pendingRequest.current, reset });
|
|
48
|
+
}, [waitFor]);
|
|
49
|
+
useEffect(() => {
|
|
50
|
+
connected.current = false; pendingRequest.current = 0;
|
|
51
|
+
waitFor('connecting');
|
|
52
|
+
const receive = (event: MessageEvent) => {
|
|
53
|
+
if (!isReviewMessage(event, frame.current?.contentWindow ?? null)) return;
|
|
54
|
+
if (event.data.type === 'ready') {
|
|
55
|
+
// Repeated hello acknowledgements must not restart a pending initialization.
|
|
56
|
+
if (connected.current) return;
|
|
57
|
+
connected.current = true;
|
|
58
|
+
const next = event.data.catalog as ReviewCatalogInfo;
|
|
59
|
+
setCatalog(next);
|
|
60
|
+
const selection = parseReviewState(location.hash, next);
|
|
61
|
+
history.replaceState(null, '', serializeReviewState(selection));
|
|
62
|
+
setState(selection);
|
|
63
|
+
document.title = `${next.title} · Rhine Review`;
|
|
64
|
+
} else if (event.data.type === 'feedback' && typeof event.data.message === 'string') {
|
|
65
|
+
record(event.data.message, event.data.kind === 'error' ? 'error' : 'info', typeof event.data.scene === 'string' ? event.data.scene : '画布');
|
|
66
|
+
} else if ((event.data.type === 'rendered' || event.data.type === 'failed') && event.data.requestId === pendingRequest.current) {
|
|
67
|
+
stopWaiting(); setPhase(event.data.type === 'rendered' ? 'ready' : 'failed');
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
window.addEventListener('message', receive);
|
|
71
|
+
const handshake = setInterval(() => {
|
|
72
|
+
if (!connected.current && frame.current?.contentWindow) sendReview(frame.current.contentWindow, { type: 'hello' });
|
|
73
|
+
}, 500);
|
|
74
|
+
return () => { window.removeEventListener('message', receive); clearInterval(handshake); stopWaiting(); };
|
|
75
|
+
}, [frameEpoch, record, stopWaiting, waitFor]);
|
|
76
|
+
useEffect(() => {
|
|
77
|
+
if (!catalog) return;
|
|
78
|
+
const navigate = () => {
|
|
79
|
+
const next = parseReviewState(location.hash, catalog);
|
|
80
|
+
history.replaceState(null, '', serializeReviewState(next)); setState(next);
|
|
81
|
+
};
|
|
82
|
+
window.addEventListener('popstate', navigate); window.addEventListener('hashchange', navigate);
|
|
83
|
+
return () => { window.removeEventListener('popstate', navigate); window.removeEventListener('hashchange', navigate); };
|
|
84
|
+
}, [catalog]);
|
|
85
|
+
useEffect(() => { if (state) select(state); }, [state, select]);
|
|
86
|
+
useEffect(() => {
|
|
87
|
+
const element = frame.current;
|
|
88
|
+
if (!element) return;
|
|
89
|
+
const measure = () => setSize({ width: element.clientWidth, height: element.clientHeight });
|
|
90
|
+
measure();
|
|
91
|
+
if (typeof ResizeObserver === 'undefined') return;
|
|
92
|
+
const observer = new ResizeObserver(measure); observer.observe(element);
|
|
93
|
+
return () => observer.disconnect();
|
|
94
|
+
}, [frameEpoch]);
|
|
95
|
+
const update = (patch: Partial<ReviewSelection>) => {
|
|
96
|
+
if (!state || !catalog) return;
|
|
97
|
+
const next = parseReviewState(serializeReviewState({ ...state, ...patch }), catalog);
|
|
98
|
+
history.pushState(null, '', serializeReviewState(next)); setState(next);
|
|
99
|
+
};
|
|
100
|
+
const reload = () => {
|
|
101
|
+
// A new browsing context terminates noncooperative code; never start new setup in a stuck context.
|
|
102
|
+
stopWaiting(); connected.current = false;
|
|
103
|
+
record('已重新载入画布;旧会话与未保存草稿已清空,评审条件保留。', 'info', state?.scene);
|
|
104
|
+
setFrameEpoch(value => value + 1);
|
|
105
|
+
};
|
|
106
|
+
const scene = catalog?.scenes.find(item => item.id === state?.scene);
|
|
107
|
+
const matching = catalog?.scenes.filter(item => `${item.title} ${item.group ?? ''} ${item.id}`.toLocaleLowerCase().includes(query.toLocaleLowerCase())) ?? [];
|
|
108
|
+
const trouble = phase === 'failed' || phase === 'timed-out';
|
|
109
|
+
const lastError = [...log.entries].reverse().find(entry => entry.kind === 'error');
|
|
110
|
+
const phaseLabel = { connecting: '正在连接场景画布…', loading: '正在初始化或清理合成场景…', ready: '合成场景已就绪 · 无业务后端', failed: '场景未能完成,请查看反馈或重新载入画布。', 'timed-out': '画布响应较慢,尚未确认完成。' }[phase];
|
|
111
|
+
return <ThemeProvider theme={state?.theme ?? 'rhine'} className="rhine-review-shell">
|
|
112
|
+
<header className="rhine-review-header">
|
|
113
|
+
<div className="rhine-review-brand"><Logo size={28} /><div><strong>{catalog?.title ?? 'Rhine Review'}</strong><small>产品设计评审 · 合成数据</small></div></div>
|
|
114
|
+
<Button id="review-menu" className="rhine-review-menu" variant="secondary" aria-expanded={menu} aria-controls="review-directory" onClick={() => setMenu(!menu)}>场景目录</Button>
|
|
115
|
+
<Button variant="secondary" disabled={!state} onClick={async () => {
|
|
116
|
+
try { await navigator.clipboard.writeText(location.href); record('评审链接已复制'); }
|
|
117
|
+
catch { record('复制未完成,请复制浏览器地址', 'error'); }
|
|
118
|
+
}}>复制链接</Button>
|
|
119
|
+
</header>
|
|
120
|
+
<div className="rhine-review-body">
|
|
121
|
+
<aside id="review-directory" className="rhine-review-directory" data-open={menu} onKeyDown={event => {
|
|
122
|
+
if (event.key === 'Escape' && menu) { setMenu(false); document.getElementById('review-menu')?.focus(); }
|
|
123
|
+
}}>
|
|
124
|
+
<TextField label="搜索场景" value={query} onChange={event => setQuery(event.target.value)} />
|
|
125
|
+
<nav aria-label="评审场景">{[...new Set(matching.map(item => item.group ?? '场景'))].map(group => <section key={group}><h2>{group}</h2>{matching.filter(item => (item.group ?? '场景') === group).map(item => <a key={item.id} aria-current={item.id === scene?.id ? 'page' : undefined} href={state ? serializeReviewState({ ...state, scene: item.id, dimensions: {} }) : '#'} onClick={event => { event.preventDefault(); update({ scene: item.id, dimensions: {} }); setMenu(false); if (menu) document.getElementById('review-menu')?.focus(); }}>{item.title}</a>)}</section>)}</nav>
|
|
126
|
+
{catalog && !matching.length && <p>没有匹配场景</p>}
|
|
127
|
+
</aside>
|
|
128
|
+
<main className="rhine-review-main">
|
|
129
|
+
{state && <>
|
|
130
|
+
<div className="rhine-review-controls" aria-label="评审设置">
|
|
131
|
+
<Select label="界面主题" value={state.theme} onChange={event => update({ theme: event.target.value as ReviewSelection['theme'] })}>{themes.map(theme => <option key={theme.name} value={theme.name}>{theme.label}</option>)}</Select>
|
|
132
|
+
<Select label="画布宽度" value={state.width} onChange={event => update({ width: event.target.value === 'fit' ? 'fit' : Number(event.target.value) })}><option value="fit">适应容器</option>{(catalog?.widths ?? defaultWidths).map(width => <option key={width} value={width}>{width} px</option>)}</Select>
|
|
133
|
+
{scene?.dimensions?.map(dimension => <Select key={dimension.id} label={dimension.label} value={state.dimensions[dimension.id]} onChange={event => update({ dimensions: { ...state.dimensions, [dimension.id]: event.target.value } })}>{dimension.options.map(option => <option key={option.id} value={option.id}>{option.label}</option>)}</Select>)}
|
|
134
|
+
<Button variant="secondary" onClick={() => select(state, true)}>重置场景</Button>
|
|
135
|
+
</div>
|
|
136
|
+
<details className="rhine-review-options"><summary>更多设置 · {state.zoom * 100}% CSS zoom · {{ system: '跟随系统', full: '完整动态', reduced: '减少动态', paused: '暂停' }[state.motion]}</summary>
|
|
137
|
+
<div className="rhine-review-controls">
|
|
138
|
+
<Select label="CSS 缩放" value={state.zoom} onChange={event => update({ zoom: Number(event.target.value) as 1 | 2 })}><option value="1">100%</option><option value="2">200%(CSS zoom)</option></Select>
|
|
139
|
+
<Select label="动态偏好" value={state.motion} onChange={event => update({ motion: event.target.value as ReviewSelection['motion'] })}><option value="system">跟随系统</option>{scene?.motionControls?.includes('full') && <option value="full">完整动态</option>}<option value="reduced">减少动态</option>{scene?.motionControls?.includes('paused') && <option value="paused">暂停</option>}</Select>
|
|
140
|
+
{!trouble && <Button variant="secondary" onClick={reload}>重新载入画布</Button>}
|
|
141
|
+
</div><p>重新载入会清空未保存草稿,并保留当前评审条件。</p>
|
|
142
|
+
</details>
|
|
143
|
+
</>}
|
|
144
|
+
<div className="rhine-review-feedback">
|
|
145
|
+
<p role="status">{phaseLabel}</p>
|
|
146
|
+
{trouble && <div className="rhine-review-recovery"><Button variant="secondary" onClick={reload}>重新载入画布</Button><span>清空未保存草稿,保留当前评审条件。</span></div>}
|
|
147
|
+
{log.errors > 0 && <p role="alert">{log.errors} 条错误待确认{lastError ? ` · ${lastError.message}` : ''}</p>}
|
|
148
|
+
{log.entries.length > 0 && <details className="rhine-review-log"><summary>评审反馈 · {log.entries.length} 条记录{log.errors ? ` · ${log.errors} 条错误待确认` : ''}</summary>
|
|
149
|
+
<ol>{log.entries.map(entry => <li key={entry.id} data-kind={entry.kind}><strong>{catalog?.scenes.find(item => item.id === entry.scene)?.title ?? entry.scene}</strong><span>{entry.message}</span></li>)}</ol>
|
|
150
|
+
{log.omittedErrors > 0 && <p>另有 {log.omittedErrors} 条较早错误未展示,仍计入待确认数量。</p>}
|
|
151
|
+
<Button variant="secondary" onClick={() => setLog(emptyFeedback())}>确认并清除反馈</Button>
|
|
152
|
+
</details>}
|
|
153
|
+
</div>
|
|
154
|
+
<p className="rhine-review-size" aria-label="画布尺寸">画布 {size.width} × {size.height} px · {(state?.zoom ?? 1) * 100}% CSS zoom · 等效内容宽度约 {Math.round(size.width / (state?.zoom ?? 1))} CSS px</p>
|
|
155
|
+
<div className="rhine-review-viewport">
|
|
156
|
+
<iframe key={frameEpoch} ref={frame} title="产品场景画布" src={new URL('canvas.html', location.href).href} sandbox="allow-scripts allow-same-origin allow-forms" referrerPolicy="no-referrer" onLoad={() => {
|
|
157
|
+
if (frame.current?.contentWindow) sendReview(frame.current.contentWindow, { type: 'hello' });
|
|
158
|
+
}} style={{ width: state?.width === 'fit' || !state ? '100%' : state.width }} />
|
|
159
|
+
</div>
|
|
160
|
+
{scene && <details className="rhine-review-notes"><summary>场景说明与验证状态</summary><p>{scene.description ?? '此场景使用产品提供的合成数据。'}</p>{scene.source && <p>源码:<code>{scene.source}</code></p>}{scene.issue && /^https:\/\//.test(scene.issue) && <a href={scene.issue} target="_blank" rel="noreferrer">关联 issue</a>}<p>{scene.validation ?? '验证状态由产品维护;可交互预览不等于生产或原生验收。'}</p></details>}
|
|
161
|
+
</main>
|
|
162
|
+
</div>
|
|
163
|
+
</ThemeProvider>;
|
|
164
|
+
}
|
|
165
|
+
export function mountReviewShell() { createRoot(document.getElementById('root')!).render(<ReviewShell />); }
|
package/review/state.ts
ADDED
|
@@ -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
|
+
}
|
package/review/types.ts
ADDED
|
@@ -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;
|
package/review/vite.mjs
ADDED
|
@@ -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,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.
|