@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
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { useState } from 'react';
|
|
2
|
-
import { Button, DataTable, Grid, Inline, Notice, Select, Stack, Surface, Text, type Spacing } from '../components';
|
|
2
|
+
import { Button, DataTable, Grid, Inline, Notice, SegmentedControl, Select, Stack, Surface, Text, type Spacing } from '../components';
|
|
3
3
|
import './spacing.css';
|
|
4
4
|
|
|
5
5
|
const scale: Spacing[] = [1, 2, 3, 4, 6, 8, 12];
|
|
6
6
|
export function SpacingGallery() {
|
|
7
7
|
const [state, setState] = useState('normal');
|
|
8
|
+
const [metric, setMetric] = useState('volume');
|
|
8
9
|
const [selected, setSelected] = useState<string[]>(['research']);
|
|
9
10
|
return <section id="spacing">
|
|
10
11
|
<div className="ds-section-title"><span>SPACE</span><h2>间距与组合 · 让关系可见</h2></div>
|
|
@@ -12,6 +13,13 @@ export function SpacingGallery() {
|
|
|
12
13
|
<Text tone="muted">同组紧、组间松。间距使用已有 token;gap 的数字是后缀,例如 gap=4 对应16px。</Text>
|
|
13
14
|
<Surface><Stack gap={4}>{scale.map(space => <div className="ds-spacing-scale" key={space}><Text variant="code">space-{space}</Text><span className="ds-spacing-bar" style={{width:`var(--rhine-space-${space})`}} aria-hidden="true"/><Text numeric variant="caption">{space * 4}px</Text></div>)}</Stack></Surface>
|
|
14
15
|
<Select label="卡片内容状态" value={state} onChange={event=>setState(event.target.value)}><option value="normal">默认</option><option value="loading">检查中</option><option value="error">读取失败</option><option value="long">长文案</option></Select>
|
|
16
|
+
<Surface><Stack gap={4}>
|
|
17
|
+
<Inline gap={4} style={{justifyContent:'space-between'}}>
|
|
18
|
+
<Stack gap={2}><Text as="h3" variant="heading">{state==='long'?'长标题示例:按业务维度查看统计分布':'统计分布'}</Text></Stack>
|
|
19
|
+
<SegmentedControl size="compact" label="统计维度" items={[{value:'volume',label:'用量'},{value:'count',label:'次数',disabled:state==='loading'}]} value={metric} onValueChange={setMetric}/>
|
|
20
|
+
</Inline>
|
|
21
|
+
{state==='error'?<Notice tone="error" title="统计暂不可用">保留所选维度,可重新读取。</Notice>:<Text tone="muted">{state==='loading'?'正在读取统计…':`当前维度:${metric==='volume'?'用量':'次数'}。紧凑视觉 32px,点击区域至少 44px;标题与操作居中对齐,窄窗自然换行。`}</Text>}
|
|
22
|
+
</Stack></Surface>
|
|
15
23
|
<Grid gap={6} minWidth={280}>
|
|
16
24
|
<Stack gap={3}>
|
|
17
25
|
<Text variant="caption" tone="muted">24px 内边距 · 8px 标题/说明 · 16px 内容/操作</Text>
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { useState } from 'react';
|
|
2
|
+
import { Badge, KeyValue, Select, Stack, Surface, Tabs, Text, TextField, Toolbar } from '../components';
|
|
3
|
+
|
|
4
|
+
export function SummaryTabsGallery() {
|
|
5
|
+
const [width, setWidth] = useState('520');
|
|
6
|
+
const [scenario, setScenario] = useState('normal');
|
|
7
|
+
const [selected, setSelected] = useState('primary');
|
|
8
|
+
const pending = scenario === 'loading', failed = scenario === 'error';
|
|
9
|
+
return <section id="summary-tabs"><Stack gap={6}>
|
|
10
|
+
<Toolbar title="带摘要的页签" description="名称与状态在同一组,身份摘要紧随;每个对象的状态始终可见。">
|
|
11
|
+
<Select label="局部容器宽度" value={width} onChange={event => setWidth(event.target.value)}>
|
|
12
|
+
<option value="320">320px · 窄容器</option><option value="520">520px · 标准</option><option value="100%">自适应</option>
|
|
13
|
+
</Select>
|
|
14
|
+
<Select label="摘要页签场景" value={scenario} onChange={event => {setScenario(event.target.value); if(event.target.value === 'disabled') setSelected('primary');}}>
|
|
15
|
+
<option value="normal">正常</option><option value="loading">读取中</option><option value="error">读取失败</option><option value="disabled">禁用</option><option value="long">长身份与名称</option>
|
|
16
|
+
</Select>
|
|
17
|
+
</Toolbar>
|
|
18
|
+
<Surface><Stack gap={6}>
|
|
19
|
+
<div style={{width: width === '100%' ? width : `${width}px`, maxWidth:'100%'}}>
|
|
20
|
+
<Tabs label="摘要页签规范示例" value={selected} onValueChange={setSelected} items={[
|
|
21
|
+
{value:'primary',label:scenario === 'long' ? '跨部门联合研究工作区' : '主工作区',description:pending ? '尚未读取当前身份' : scenario === 'long' ? '研究项目 · 跨区域联合研发与长期设计验证团队' : '研发 · 研究项目',
|
|
22
|
+
status:<Badge tone={failed ? 'error' : pending ? 'neutral' : 'success'}>{failed ? '读取失败' : pending ? '读取中' : '同步中'}</Badge>,
|
|
23
|
+
content:<Stack gap={4}><Text>主工作区内容</Text><TextField label="主工作区草稿" placeholder="切换后保留已输入内容"/></Stack>},
|
|
24
|
+
{value:'team',label:'协作工作区',description:'协作 · 设计项目',disabled:scenario === 'disabled',status:<Badge>{scenario === 'disabled' ? '暂不可用' : '已停止'}</Badge>,
|
|
25
|
+
content:<Stack gap={4}><Text>协作工作区内容</Text><TextField label="协作工作区草稿" placeholder="与另一页签独立"/></Stack>},
|
|
26
|
+
]}/>
|
|
27
|
+
</div>
|
|
28
|
+
<Text variant="caption" tone="muted">先输入草稿,再使用左右方向键或 Home / End 切换。场景与主题变化不清空面板。</Text>
|
|
29
|
+
</Stack></Surface>
|
|
30
|
+
<Surface><Stack gap={4}><Text as="h2" variant="heading">尺寸与层级</Text><KeyValue items={[
|
|
31
|
+
{key:'title',label:'名称',value:'14 / 22 · 600'}, {key:'description',label:'身份摘要',value:'12 / 18 · 400'},
|
|
32
|
+
{key:'padding',label:'上下内距',value:'12px / 12px'}, {key:'status',label:'名称到状态',value:'8px'},
|
|
33
|
+
{key:'identity',label:'名称组到摘要',value:'4px'}, {key:'tabs',label:'页签之间',value:'16px'},
|
|
34
|
+
{key:'panel',label:'分隔线到面板',value:'24px'}, {key:'selected',label:'选中指示线',value:'2px,覆盖当前页签宽度'},
|
|
35
|
+
]}/><Text variant="caption" tone="muted">状态复用 Badge,各主题保留自己的文字与边框;不固定总高度。</Text></Stack></Surface>
|
|
36
|
+
</Stack></section>;
|
|
37
|
+
}
|
package/preview/main.tsx
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { BackgroundFiltersFixture } from './BackgroundFiltersFixture';
|
|
2
2
|
import { StrictMode, useEffect, useRef, useState } from 'react';
|
|
3
3
|
import { createRoot } from 'react-dom/client';
|
|
4
|
-
import { Badge, Button, Checkbox, Disclosure, EmptyState, Logo, Notice, Progress, SegmentedControl, Select, SelectionCard, Skeleton, Surface, Tabs, Text, TextField, Toolbar, type TextVariant } from '../components/index';
|
|
4
|
+
import { Badge, Button, Checkbox, Disclosure, EmptyState, Link, Logo, Notice, Progress, SegmentedControl, Select, SelectionCard, Skeleton, Surface, Tabs, Text, TextField, Toolbar, type TextVariant } from '../components/index';
|
|
5
5
|
import './preview.css';
|
|
6
6
|
import { OverlayBoundsFixture, OverlayBoundsGallery } from './OverlayBoundsGallery';
|
|
7
7
|
import { SettingsDialogGallery, SettingsDialogFixture } from './SettingsDialogGallery';
|
|
@@ -11,6 +11,7 @@ import { MotionGallery } from './MotionGallery';
|
|
|
11
11
|
import { StartupGallery } from './StartupGallery';
|
|
12
12
|
import { PatternGallery } from './PatternGallery';
|
|
13
13
|
import { GalleryShell } from './GalleryShell';
|
|
14
|
+
import { componentCount } from './packageInfo';
|
|
14
15
|
import { SpacingGallery } from './SpacingGallery';
|
|
15
16
|
import { LineHeightGallery } from './LineHeightGallery';
|
|
16
17
|
import { SubscriptionGallery } from './SubscriptionGallery';
|
|
@@ -26,6 +27,7 @@ import { CompactFilterBarGallery } from './CompactFilterBarGallery';
|
|
|
26
27
|
import { DenseFilterControlsGallery } from './DenseFilterControlsGallery';
|
|
27
28
|
import { CheckboxRowGallery } from './CheckboxRowGallery';
|
|
28
29
|
import { ComponentTypographyGallery } from './ComponentTypographyGallery';
|
|
30
|
+
import { SummaryTabsGallery } from './SummaryTabsGallery';
|
|
29
31
|
|
|
30
32
|
function Gallery() {
|
|
31
33
|
const [motion, setMotion] = useState('system');
|
|
@@ -55,6 +57,7 @@ function Gallery() {
|
|
|
55
57
|
{section === 'compact-filters' && <><CompactFilterBarGallery /><DenseFilterControlsGallery /></>}
|
|
56
58
|
{section === 'checkbox-rows' && <CheckboxRowGallery />}
|
|
57
59
|
{section === 'component-typography' && <ComponentTypographyGallery />}
|
|
60
|
+
{section === 'summary-tabs' && <SummaryTabsGallery />}
|
|
58
61
|
{section === 'backgrounds' && <BackgroundGallery />}
|
|
59
62
|
{section === 'app-shell' && <ShellGallery />}
|
|
60
63
|
{section === 'orbit' && <OrbitGallery />}
|
|
@@ -64,7 +67,7 @@ function Gallery() {
|
|
|
64
67
|
{section === 'media' && <MediaGallery />}
|
|
65
68
|
{section === 'subscriptions' && <SubscriptionGallery />}
|
|
66
69
|
{section === 'top' && <>
|
|
67
|
-
<div className="ds-hero"><div><p className="ds-kicker">FOUNDATIONS / COMPONENTS / MOTION</p><h1>精密,有序。<br /><span>每一次交互都有回应。</span></h1><p>
|
|
70
|
+
<div className="ds-hero"><div><p className="ds-kicker">FOUNDATIONS / COMPONENTS / MOTION</p><h1>精密,有序。<br /><span>每一次交互都有回应。</span></h1><p>{componentCount} 个通用组件,共用一套视觉变量和动效规则。<br />展台运行当前源码,可能包含未发布能力;接入前请核对安装版本与<Link href="https://github.com/linhay/rhine-design/blob/main/CHANGELOG.md">版本记录</Link>。</p></div></div>
|
|
68
71
|
<div className="ds-overview-links">{[
|
|
69
72
|
['foundation','视觉基础','从色彩、间距与材质开始。'],
|
|
70
73
|
['typography','字体与文字','清晰的层级,稳定的阅读节奏。'],
|
|
@@ -125,6 +128,7 @@ function Gallery() {
|
|
|
125
128
|
<div className="ds-grid"><div><Checkbox label="显示卡片勾选框" checked={showSelectionIndicator} onChange={event => setShowSelectionIndicator(event.target.checked)} /><SelectionCard showIndicator={showSelectionIndicator} label="可选择容器" selected={selected} onSelectedChange={setSelected}><span className="ds-caption">适用于账号、模板或可取消选择项。<br />同一位置切换底色与边框,不抬升卡片。</span><div className="ds-gap-top"><Badge tone={selected?'success':'neutral'}>{selected?'已选择':'未选择'}</Badge></div></SelectionCard><p className="ds-caption">SelectionCard · 背景与边界 120ms</p></div>
|
|
126
129
|
<Surface><div className="ds-component-title"><h3>SegmentedControl</h3><span>原生单选 / 120ms</span></div><SegmentedControl label="展示指标" items={[{value:'tokens',label:'Token'},{value:'requests',label:'请求数'},{value:'cost',label:'费用',disabled:true}]} value={metric} onValueChange={setMetric} /><p className="ds-caption">当前指标:{metric==='tokens'?'Token':'请求数'}。方向键切换,自动跳过禁用项。</p></Surface></div>
|
|
127
130
|
<Surface className="ds-gap-top"><div className="ds-component-title"><h3>Tabs</h3><span>下划线与内容进入 180ms</span></div><Tabs label="组件说明" value={tab} onValueChange={setTab} items={[{value:'tokens',label:'视觉变量',content:<p>颜色、间距、字体与动效集中定义。业务页面只消费语义变量。</p>},{value:'disabled',label:'预留示例',disabled:true,content:null},{value:'motion',label:'动效规则',content:<p>内容进入最多移动 3px。保留隐藏面板状态,切换不触发重复业务操作。</p>}]} /></Surface>
|
|
131
|
+
<Text className="ds-gap-top">需要同时展示对象身份和状态时,查看<a href="#summary-tabs">带摘要的页签规范与交互展台</a>。</Text>
|
|
128
132
|
</section>}
|
|
129
133
|
|
|
130
134
|
{section === 'feedback' && <section id="feedback"><div className="ds-section-title"><span>04</span><h2>告诉用户发生了什么</h2></div>
|
|
@@ -137,7 +141,7 @@ function Gallery() {
|
|
|
137
141
|
</section>}
|
|
138
142
|
|
|
139
143
|
{section === 'disclosure' && <section id="disclosure"><div className="ds-section-title"><span>05</span><h2>逐步展开,保持可读</h2></div>
|
|
140
|
-
<div className="ds-grid"><Surface><div className="ds-component-title"><h3>Disclosure</h3><span>实测高度 / 200ms</span></div><Disclosure title="查看组件动画规则"><p>展开和收起都支持中途反向。关闭后内容不参与 Tab 导航。</p><Button variant="secondary" onClick={()=>setNotice(true)}>内容内操作</Button><p>减少动态模式直接到达最终状态,信息不会丢失。</p></Disclosure></Surface>
|
|
144
|
+
<div className="ds-grid"><Surface><div className="ds-component-title"><h3>Disclosure</h3><span>实测高度 / 200ms</span></div><Disclosure title="查看组件动画规则"><p>展开和收起都支持中途反向。关闭后内容不参与 Tab 导航。</p><Button variant="secondary" onClick={()=>setNotice(true)}>内容内操作</Button><p>减少动态模式直接到达最终状态,信息不会丢失。</p></Disclosure><Disclosure title="相邻分组共用一条分隔线"><p>分组连续排列,不加组间 gap;相邻边框合并,展开和收起各自独立。</p></Disclosure></Surface>
|
|
141
145
|
<EmptyState title="暂无记录" description="用一句话说明当前状态,按业务需要提供一个下一步操作。" action={<Button variant="secondary" onClick={()=>setNotice(true)}>演示下一步</Button>} /></div>
|
|
142
146
|
</section>}
|
|
143
147
|
{['data','forms','utilities','overlays'].includes(section) && <CommonGallery key={section} section={section} />}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { Component, useEffect, type ReactNode } from 'react';
|
|
2
|
+
import { createRoot } from 'react-dom/client';
|
|
3
|
+
import { flushSync } from 'react-dom';
|
|
4
|
+
import { ThemeProvider } from '../components/theme';
|
|
5
|
+
import { Notice } from '../components';
|
|
6
|
+
import { defineReview, catalogInfo, parseReviewState, serializeReviewState } from './state';
|
|
7
|
+
import { createMockFetch, installIsolation, type ReviewHttpHandler } from './isolation';
|
|
8
|
+
import { ReviewLifecycle } from './lifecycle';
|
|
9
|
+
import { isReviewMessage, sendReview } from './protocol';
|
|
10
|
+
import type { ReviewCatalog, ReviewEnvironment, ReviewScene, ReviewSelection, ReviewSession } from './types';
|
|
11
|
+
import './review.css';
|
|
12
|
+
|
|
13
|
+
class RenderBoundary extends Component<{ children: ReactNode; report(message: string): void }, { error: boolean }> {
|
|
14
|
+
state = { error: false };
|
|
15
|
+
static getDerivedStateFromError() { return { error: true }; }
|
|
16
|
+
componentDidCatch(error: Error) { this.props.report(`场景渲染失败:${error.message}`); }
|
|
17
|
+
render() { return this.state.error ? <p role="alert">场景渲染失败,请查看评审反馈并重置。</p> : this.props.children; }
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export async function mountReviewCanvas(loadCatalog: () => Promise<{ default: ReviewCatalog }>) {
|
|
21
|
+
let state: ReviewSelection | undefined;
|
|
22
|
+
let requestId = 0;
|
|
23
|
+
const report = (message: string, kind = 'info', scene = state?.scene) => sendReview(parent, { type: 'feedback', message, kind, scene });
|
|
24
|
+
const failed = () => sendReview(parent, { type: 'failed', requestId });
|
|
25
|
+
const isolation = installIsolation(message => report(message, 'error'));
|
|
26
|
+
// The handler slot is changed only by the currently active setup context.
|
|
27
|
+
let http: ReviewHttpHandler | undefined;
|
|
28
|
+
let requestSignal = AbortSignal.abort();
|
|
29
|
+
window.fetch = createMockFetch(() => http, () => requestSignal, message => report(message, 'error'));
|
|
30
|
+
const root = createRoot(document.getElementById('root')!);
|
|
31
|
+
const media = matchMedia('(prefers-reduced-motion: reduce)');
|
|
32
|
+
let session: ReviewSession | undefined;
|
|
33
|
+
let scene: ReviewScene | undefined;
|
|
34
|
+
let renderGeneration = 0;
|
|
35
|
+
const environment = (): ReviewEnvironment => ({
|
|
36
|
+
theme: state!.theme, motion: state!.motion,
|
|
37
|
+
reducedMotion: state!.motion === 'reduced' || (state!.motion === 'system' && media.matches),
|
|
38
|
+
paused: state!.motion === 'paused',
|
|
39
|
+
});
|
|
40
|
+
const render = () => {
|
|
41
|
+
if (!session || !scene || !state) return;
|
|
42
|
+
const env = environment();
|
|
43
|
+
document.documentElement.style.zoom = String(state.zoom);
|
|
44
|
+
document.documentElement.dataset.rhineMotion = env.reducedMotion ? 'reduced' : env.paused ? 'paused' : 'full';
|
|
45
|
+
const committedRequest = requestId;
|
|
46
|
+
const renderedScene = scene.id;
|
|
47
|
+
const content = <RenderBoundary key={renderGeneration} report={message => { report(message, 'error', renderedScene); failed(); }}>
|
|
48
|
+
<SceneContent session={session} environment={env} committed={() => sendReview(parent, { type: 'rendered', requestId: committedRequest })} />
|
|
49
|
+
</RenderBoundary>;
|
|
50
|
+
root.render(scene.themeRoot === 'host' ? content : <ThemeProvider theme={env.theme} data-rhine-motion={env.reducedMotion ? 'reduced' : env.paused ? 'paused' : 'full'}>{content}</ThemeProvider>);
|
|
51
|
+
};
|
|
52
|
+
let catalog: ReviewCatalog;
|
|
53
|
+
try { catalog = defineReview((await loadCatalog()).default); }
|
|
54
|
+
catch (error) { report(`场景目录加载失败:${String(error)}`, 'error'); failed(); root.render(<Notice title="场景目录加载失败" />); return; }
|
|
55
|
+
const info = catalogInfo(catalog);
|
|
56
|
+
const lifecycle = new ReviewLifecycle({
|
|
57
|
+
clear() {
|
|
58
|
+
session = undefined; scene = undefined; renderGeneration++;
|
|
59
|
+
flushSync(() => root.render(null));
|
|
60
|
+
},
|
|
61
|
+
context(signal, selected) {
|
|
62
|
+
isolation.reset();
|
|
63
|
+
requestSignal = signal;
|
|
64
|
+
http = undefined;
|
|
65
|
+
return {
|
|
66
|
+
mockHttp(next) { signal.throwIfAborted(); http = next; },
|
|
67
|
+
report(message, kind) { if (!signal.aborted) report(message, kind, selected.id); },
|
|
68
|
+
};
|
|
69
|
+
},
|
|
70
|
+
ready(next, selected) { session = next; scene = selected; render(); },
|
|
71
|
+
error(error, selected) { report(`场景生命周期失败:${String(error)}`, 'error', selected); },
|
|
72
|
+
failed,
|
|
73
|
+
});
|
|
74
|
+
const ready = () => sendReview(parent, { type: 'ready', catalog: info });
|
|
75
|
+
let selectionKey = '';
|
|
76
|
+
const receive = (event: MessageEvent) => {
|
|
77
|
+
if (!isReviewMessage(event, parent)) return;
|
|
78
|
+
if (event.data.type === 'hello') { ready(); return; }
|
|
79
|
+
if (event.data.type !== 'select' || typeof event.data.hash !== 'string' || !Number.isSafeInteger(event.data.requestId)) return;
|
|
80
|
+
requestId = event.data.requestId;
|
|
81
|
+
state = parseReviewState(event.data.hash, info);
|
|
82
|
+
const selection = state;
|
|
83
|
+
// Canonicalizing in both documents prevents unsupported values from crossing the boundary.
|
|
84
|
+
const key = JSON.stringify([state.scene, state.dimensions]);
|
|
85
|
+
if (key !== selectionKey || event.data.reset === true) {
|
|
86
|
+
selectionKey = key;
|
|
87
|
+
report('正在初始化合成场景…');
|
|
88
|
+
void lifecycle.select(catalog.scenes.find(s => s.id === selection.scene)!, selection.dimensions, event.data.reset ? 'reset' : 'select');
|
|
89
|
+
} else render();
|
|
90
|
+
sendReview(parent, { type: 'state', hash: serializeReviewState(state) });
|
|
91
|
+
};
|
|
92
|
+
window.addEventListener('message', receive);
|
|
93
|
+
media.addEventListener('change', render);
|
|
94
|
+
const rejection = (event: PromiseRejectionEvent) => { if (event.reason?.name !== 'AbortError') report(`异步操作失败:${String(event.reason)}`, 'error'); };
|
|
95
|
+
window.addEventListener('unhandledrejection', rejection);
|
|
96
|
+
window.addEventListener('pagehide', () => {
|
|
97
|
+
window.removeEventListener('message', receive);
|
|
98
|
+
media.removeEventListener('change', render);
|
|
99
|
+
window.removeEventListener('unhandledrejection', rejection);
|
|
100
|
+
void lifecycle.dispose();
|
|
101
|
+
}, { once: true });
|
|
102
|
+
ready();
|
|
103
|
+
}
|
|
104
|
+
function SceneContent({ session, environment, committed }: { session: ReviewSession; environment: ReviewEnvironment; committed(): void }) {
|
|
105
|
+
useEffect(committed, [committed]);
|
|
106
|
+
return session.render(environment);
|
|
107
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// Style-only development client. The review server intentionally has no HMR transport.
|
|
2
|
+
// CSS transformed by Vite 6/8 imports these helpers even with server.hmr:false.
|
|
3
|
+
const styles = new Map();
|
|
4
|
+
export function updateStyle(id, content) {
|
|
5
|
+
let style = styles.get(id);
|
|
6
|
+
if (!style) { style = document.createElement('style'); style.dataset.reviewStyle = id; document.head.append(style); styles.set(id, style); }
|
|
7
|
+
style.textContent = content;
|
|
8
|
+
}
|
|
9
|
+
export function removeStyle(id) { styles.get(id)?.remove(); styles.delete(id); }
|
|
10
|
+
export function createHotContext() {
|
|
11
|
+
const noop = () => {};
|
|
12
|
+
return { data: {}, accept: noop, acceptExports: noop, dispose: noop, prune: noop, decline: noop, invalidate: noop, on: noop, off: noop, send: noop };
|
|
13
|
+
}
|
|
14
|
+
export function injectQuery(url, query) {
|
|
15
|
+
const [path, hash] = url.split('#');
|
|
16
|
+
return `${path}${path.includes('?') ? '&' : '?'}${query}${hash === undefined ? '' : `#${hash}`}`;
|
|
17
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export interface ReviewFeedback {
|
|
2
|
+
id: number;
|
|
3
|
+
scene: string;
|
|
4
|
+
message: string;
|
|
5
|
+
kind: 'info' | 'error';
|
|
6
|
+
}
|
|
7
|
+
export interface ReviewFeedbackLog {
|
|
8
|
+
entries: ReviewFeedback[];
|
|
9
|
+
errors: number;
|
|
10
|
+
omittedErrors: number;
|
|
11
|
+
}
|
|
12
|
+
export const emptyFeedback = (): ReviewFeedbackLog => ({ entries: [], errors: 0, omittedErrors: 0 });
|
|
13
|
+
export function appendFeedback(log: ReviewFeedbackLog, entry: ReviewFeedback): ReviewFeedbackLog {
|
|
14
|
+
const entries = [...log.entries, { ...entry, message: entry.message.slice(0, 2000) }];
|
|
15
|
+
const omitted = entries.splice(0, Math.max(0, entries.length - 20));
|
|
16
|
+
return { entries, errors: log.errors + Number(entry.kind === 'error'), omittedErrors: log.omittedErrors + omitted.filter(item => item.kind === 'error').length };
|
|
17
|
+
}
|
package/review/index.ts
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
export { defineReview, parseReviewState, serializeReviewState } from './state';
|
|
2
|
+
export type { ReviewCatalog, ReviewCatalogInfo, ReviewContext, ReviewDimension, ReviewEnvironment, ReviewMotion, ReviewOption, ReviewScene, ReviewSceneInfo, ReviewSelection, ReviewSession } from './types';
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
export type ReviewHttpHandler = (request: Request) => Response | undefined | Promise<Response | undefined>;
|
|
2
|
+
export class MemoryStorage implements Storage {
|
|
3
|
+
private values = new Map<string, string>();
|
|
4
|
+
get length() { return this.values.size; }
|
|
5
|
+
clear() { this.values.clear(); }
|
|
6
|
+
getItem(key: string) { return this.values.get(String(key)) ?? null; }
|
|
7
|
+
key(index: number) { return [...this.values.keys()][index] ?? null; }
|
|
8
|
+
removeItem(key: string) { this.values.delete(String(key)); }
|
|
9
|
+
setItem(key: string, value: string) { this.values.set(String(key), String(value)); }
|
|
10
|
+
}
|
|
11
|
+
export function createMockFetch(getHandler: () => ReviewHttpHandler | undefined, getSignal: () => AbortSignal, report: (message: string) => void): typeof fetch {
|
|
12
|
+
return async (input, init) => {
|
|
13
|
+
const url = typeof input === 'string' ? new URL(input, location.href).href : input;
|
|
14
|
+
const request = new Request(url, init);
|
|
15
|
+
const signal = AbortSignal.any([request.signal, getSignal()]);
|
|
16
|
+
signal.throwIfAborted();
|
|
17
|
+
const scoped = new Request(request, { signal, credentials: 'omit' });
|
|
18
|
+
const handler = getHandler();
|
|
19
|
+
// Race cancellation even if a product handler ignores its signal or never resolves.
|
|
20
|
+
return new Promise<Response>((resolve, reject) => {
|
|
21
|
+
const abort = () => reject(signal.reason ?? new DOMException('Aborted', 'AbortError'));
|
|
22
|
+
signal.addEventListener('abort', abort, { once: true });
|
|
23
|
+
Promise.resolve().then(() => { signal.throwIfAborted(); return handler?.(scoped); }).then(response => {
|
|
24
|
+
signal.throwIfAborted();
|
|
25
|
+
if (!response) {
|
|
26
|
+
const message = `未实现模拟请求:${scoped.method} ${new URL(scoped.url).pathname}`;
|
|
27
|
+
report(message);
|
|
28
|
+
throw new Error(message);
|
|
29
|
+
}
|
|
30
|
+
resolve(response);
|
|
31
|
+
}).catch(reject).finally(() => signal.removeEventListener('abort', abort));
|
|
32
|
+
});
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
/** Defense in depth for trusted synthetic product code; not an untrusted-code sandbox. */
|
|
36
|
+
export function installIsolation(report: (message: string) => void) {
|
|
37
|
+
const storage = [new MemoryStorage(), new MemoryStorage()];
|
|
38
|
+
Object.defineProperty(window, 'localStorage', { configurable: true, value: storage[0] });
|
|
39
|
+
Object.defineProperty(window, 'sessionStorage', { configurable: true, value: storage[1] });
|
|
40
|
+
Object.defineProperty(document, 'cookie', { configurable: true, get: () => '', set: () => { report('预览不写入会话 Cookie'); } });
|
|
41
|
+
const blocked = (name: string) => { const message = `预览未实现操作:${name}`; report(message); throw new Error(message); };
|
|
42
|
+
for (const name of ['XMLHttpRequest', 'WebSocket', 'EventSource', 'Worker', 'SharedWorker'] as const) {
|
|
43
|
+
Object.defineProperty(window, name, { configurable: true, value: function () { return blocked(name); } });
|
|
44
|
+
}
|
|
45
|
+
Object.defineProperty(window, 'indexedDB', { configurable: true, get: () => blocked('IndexedDB') });
|
|
46
|
+
Object.defineProperty(window, 'caches', { configurable: true, get: () => blocked('CacheStorage') });
|
|
47
|
+
Object.defineProperty(navigator, 'serviceWorker', { configurable: true, get: () => blocked('ServiceWorker') });
|
|
48
|
+
Object.defineProperty(navigator, 'sendBeacon', { configurable: true, value: () => { report('预览不发送 Beacon'); return false; } });
|
|
49
|
+
Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText: async () => blocked('Clipboard'), readText: async () => blocked('Clipboard') } });
|
|
50
|
+
window.open = () => { report('预览不打开外部窗口'); return null; };
|
|
51
|
+
document.addEventListener('click', event => {
|
|
52
|
+
const link = (event.target as Element)?.closest?.('a');
|
|
53
|
+
if (!link || event.defaultPrevented) return;
|
|
54
|
+
const url = new URL(link.href);
|
|
55
|
+
const localHash = url.origin === location.origin && url.pathname === location.pathname && url.search === location.search && Boolean(url.hash);
|
|
56
|
+
if (link.hasAttribute('download') || link.target || !localHash) {
|
|
57
|
+
event.preventDefault(); report('预览不执行下载或整页跳转;产品路由需使用 SPA 导航适配');
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
document.addEventListener('submit', event => {
|
|
61
|
+
if (!event.defaultPrevented) { event.preventDefault(); report('预览表单需要产品 onSubmit 模拟适配'); }
|
|
62
|
+
});
|
|
63
|
+
return { reset: () => storage.forEach(item => item.clear()) };
|
|
64
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import type { ReviewContext, ReviewScene, ReviewSession } from './types';
|
|
2
|
+
|
|
3
|
+
export class ReviewLifecycle {
|
|
4
|
+
private generation = 0;
|
|
5
|
+
private active?: { scene: string; abort: AbortController; cleanups: (() => void | Promise<void>)[]; session?: ReviewSession; disposed: boolean; disposal?: Promise<void> };
|
|
6
|
+
constructor(private readonly hooks: {
|
|
7
|
+
clear(): void;
|
|
8
|
+
ready(session: ReviewSession, scene: ReviewScene): void;
|
|
9
|
+
error(error: unknown, scene?: string): void;
|
|
10
|
+
failed?(scene: string): void;
|
|
11
|
+
context(signal: AbortSignal, scene: ReviewScene): Pick<ReviewContext, 'mockHttp' | 'report'>;
|
|
12
|
+
}) {}
|
|
13
|
+
private cleanup(run: NonNullable<ReviewLifecycle['active']>, reset: boolean): Promise<void> {
|
|
14
|
+
if (run.disposal) return run.disposal;
|
|
15
|
+
run.disposed = true;
|
|
16
|
+
run.abort.abort();
|
|
17
|
+
const operations = [...run.cleanups.reverse()];
|
|
18
|
+
if (run.session) {
|
|
19
|
+
if (reset && run.session.reset) operations.unshift(() => run.session!.reset!());
|
|
20
|
+
operations.push(() => run.session!.dispose());
|
|
21
|
+
}
|
|
22
|
+
run.disposal = (async () => {
|
|
23
|
+
for (const operation of operations) {
|
|
24
|
+
try { await operation(); } catch (error) { this.hooks.error(error, run.scene); }
|
|
25
|
+
}
|
|
26
|
+
})();
|
|
27
|
+
return run.disposal;
|
|
28
|
+
}
|
|
29
|
+
async select(scene: ReviewScene, dimensions: Record<string, string>, reason: 'select' | 'reset' = 'select') {
|
|
30
|
+
const generation = ++this.generation;
|
|
31
|
+
const previous = this.active;
|
|
32
|
+
try { this.hooks.clear(); } catch (error) { this.hooks.error(error, previous?.scene); }
|
|
33
|
+
if (previous) await this.cleanup(previous, reason === 'reset');
|
|
34
|
+
if (generation !== this.generation) return;
|
|
35
|
+
const run = { scene: scene.id, abort: new AbortController(), cleanups: [] as (() => void | Promise<void>)[], session: undefined as ReviewSession | undefined, disposed: false };
|
|
36
|
+
this.active = run;
|
|
37
|
+
const context: ReviewContext = {
|
|
38
|
+
...this.hooks.context(run.abort.signal, scene), dimensions: Object.freeze({ ...dimensions }), signal: run.abort.signal, reason,
|
|
39
|
+
onDispose: cleanup => {
|
|
40
|
+
if (run.disposed) void Promise.resolve().then(cleanup).catch(error => this.hooks.error(error, scene.id));
|
|
41
|
+
else run.cleanups.push(cleanup);
|
|
42
|
+
},
|
|
43
|
+
};
|
|
44
|
+
try {
|
|
45
|
+
const session = await scene.setup(context);
|
|
46
|
+
if (generation !== this.generation || run.disposed) { await session.dispose(); return; }
|
|
47
|
+
run.session = session;
|
|
48
|
+
this.hooks.ready(session, scene);
|
|
49
|
+
} catch (error) {
|
|
50
|
+
if (!run.abort.signal.aborted) { this.hooks.error(error, scene.id); this.hooks.failed?.(scene.id); }
|
|
51
|
+
await this.cleanup(run, false);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
async dispose() {
|
|
55
|
+
++this.generation;
|
|
56
|
+
try { this.hooks.clear(); } catch (error) { this.hooks.error(error, this.active?.scene); }
|
|
57
|
+
if (this.active) await this.cleanup(this.active, false);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export const protocol = 'rhine-review/2';
|
|
2
|
+
export function isReviewMessage(event: MessageEvent, source: Window | null): boolean {
|
|
3
|
+
return event.origin === location.origin && event.source === source && event.data?.protocol === protocol;
|
|
4
|
+
}
|
|
5
|
+
export function sendReview(target: Window, data: Record<string, unknown>) {
|
|
6
|
+
target.postMessage({ ...data, protocol }, location.origin);
|
|
7
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
html, body, #root { margin: 0; min-height: 100%; }
|
|
2
|
+
.rhine-review-shell { min-height: 100vh; background: var(--rhine-bg); color: var(--rhine-ink); }
|
|
3
|
+
.rhine-review-header { display: flex; gap: 12px; align-items: center; padding: 16px 24px; border-bottom: 1px solid var(--rhine-line); flex-wrap: wrap; }
|
|
4
|
+
.rhine-review-brand { display: flex; align-items: center; gap: 12px; margin-right: auto; }
|
|
5
|
+
.rhine-review-brand strong { display: block; font-size: 16px; line-height: 24px; font-weight: 600; }
|
|
6
|
+
.rhine-review-brand small { display: block; font-size: 12px; line-height: 18px; font-weight: 400; opacity: .7; }
|
|
7
|
+
.rhine-review-menu { display: none; }
|
|
8
|
+
.rhine-review-body { display: grid; grid-template-columns: 232px minmax(0, 1fr); }
|
|
9
|
+
.rhine-review-directory { padding: 20px; border-right: 1px solid var(--rhine-line); }
|
|
10
|
+
.rhine-review-directory h2 { margin-top: 24px; font-size: 12px; line-height: 18px; font-weight: 600; opacity: .7; }
|
|
11
|
+
.rhine-review-directory a { display: block; padding: 10px 12px; color: inherit; text-decoration: none; font-size: 14px; line-height: 22px; font-weight: 400; overflow-wrap: anywhere; }
|
|
12
|
+
.rhine-review-directory a[aria-current] { background: var(--rhine-selected); font-weight: 600; box-shadow: inset 3px 0 var(--rhine-green); }
|
|
13
|
+
.rhine-review-main { min-width: 0; }
|
|
14
|
+
.rhine-review-controls { display: flex; flex-wrap: wrap; align-items: end; gap: 12px; padding: 20px; }
|
|
15
|
+
.rhine-review-controls > .rhine-field { flex: 1 1 140px; min-width: 0; }
|
|
16
|
+
.rhine-review-feedback, .rhine-review-notes { padding: 12px 20px; font-size: 13px; line-height: 20px; font-weight: 400; overflow-wrap: anywhere; }
|
|
17
|
+
.rhine-review-feedback[role=alert] { border-left: 3px solid var(--rhine-error); }
|
|
18
|
+
.rhine-review-feedback p { margin: 0 0 8px; }
|
|
19
|
+
.rhine-review-feedback [role=alert] { border-left: 3px solid var(--rhine-error); padding-left: 10px; }
|
|
20
|
+
.rhine-review-options { margin: 0 20px; padding: 0 0 8px; font-size: 13px; line-height: 20px; font-weight: 400; }
|
|
21
|
+
.rhine-review-options summary, .rhine-review-log summary { cursor: pointer; padding: 10px 0; }
|
|
22
|
+
.rhine-review-options .rhine-review-controls { padding: 12px 0; }
|
|
23
|
+
.rhine-review-options p { color: var(--rhine-muted); margin: 0; }
|
|
24
|
+
.rhine-review-recovery { display: flex; align-items: center; flex-wrap: wrap; gap: 12px; margin: 12px 0; }
|
|
25
|
+
.rhine-review-log ol { max-height: 240px; overflow: auto; padding-left: 24px; }
|
|
26
|
+
.rhine-review-log li { padding: 8px 0; }
|
|
27
|
+
.rhine-review-log li strong, .rhine-review-log li span { display: block; }
|
|
28
|
+
.rhine-review-log li[data-kind=error] { color: var(--rhine-error); }
|
|
29
|
+
.rhine-review-size { margin: 0; padding: 8px 20px; color: var(--rhine-muted); font-size: 12px; line-height: 18px; font-weight: 400; }
|
|
30
|
+
.rhine-review-viewport { overflow: auto; padding: 16px; background: var(--rhine-surface); }
|
|
31
|
+
.rhine-review-viewport iframe { display: block; border: 1px solid var(--rhine-line); min-height: 650px; height: 72vh; margin: 0 auto; background: var(--rhine-bg); box-sizing: border-box; }
|
|
32
|
+
.rhine-review-notes summary { cursor: pointer; min-height: 32px; }
|
|
33
|
+
[data-rhine-motion=paused] *, [data-rhine-motion=paused] *::before, [data-rhine-motion=paused] *::after { animation-play-state: paused !important; }
|
|
34
|
+
@media (max-width: 760px) {
|
|
35
|
+
.rhine-review-menu { display: inline-flex; }
|
|
36
|
+
.rhine-review-body { grid-template-columns: minmax(0, 1fr); }
|
|
37
|
+
.rhine-review-directory { display: none; border-bottom: 1px solid var(--rhine-line); }
|
|
38
|
+
.rhine-review-directory[data-open=true] { display: block; }
|
|
39
|
+
.rhine-review-header { padding: 12px; }
|
|
40
|
+
.rhine-review-controls { padding: 12px; }
|
|
41
|
+
}
|