@linhey/rhine-design 0.10.0 → 0.11.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +24 -0
- package/CONTRIBUTING.md +3 -1
- package/README.md +17 -14
- package/components/AdvancedForms.tsx +13 -5
- package/components/COMMON_COMPONENTS.md +5 -3
- package/components/Forms.tsx +66 -6
- package/components/README.md +13 -5
- package/components/SPACING.md +9 -0
- package/components/Toast.tsx +10 -4
- package/components/common.css +4 -0
- package/components/components.tsx +13 -7
- package/components/styles.css +12 -0
- package/components/useFloatingScale.ts +7 -3
- package/docs/APPLICATION_COMPONENTS.md +2 -2
- package/docs/DESIGN_SYSTEM.md +10 -1
- package/docs/DESIGN_SYSTEM_AUDIT.md +4 -4
- package/docs/DISCLOSURE_VERIFICATION.md +49 -0
- package/docs/FLOATING_OVERLAYS.md +16 -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/DenseFilterControlsGallery.tsx +5 -2
- 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-combobox-modal-focus.js +22 -0
- package/scripts/check-combobox-zoom.js +109 -0
- package/scripts/check-multiselect-completion.js +46 -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/FormParity.test.tsx +104 -2
- 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
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from 'react';
|
|
2
|
+
import { Button, Notice, Stack, Text, TextField } from '@linhey/rhine-design';
|
|
3
|
+
import type { ReviewEnvironment } from '@linhey/rhine-design/review';
|
|
4
|
+
import '../product.css';
|
|
5
|
+
interface Client { read(): string; save(value: string): Promise<void> }
|
|
6
|
+
export function Workspace({ client, environment, dimensions }: { client: Client; environment: ReviewEnvironment; dimensions: Readonly<Record<string, string>> }) {
|
|
7
|
+
const [draft, setDraft] = useState(client.read);
|
|
8
|
+
const [saved, setSaved] = useState(false);
|
|
9
|
+
const canvas = useRef<HTMLCanvasElement>(null);
|
|
10
|
+
const count = useRef(0);
|
|
11
|
+
useEffect(() => {
|
|
12
|
+
const target = canvas.current!;
|
|
13
|
+
const context = target.getContext('2d')!;
|
|
14
|
+
let frame = 0;
|
|
15
|
+
const draw = () => {
|
|
16
|
+
count.current++;
|
|
17
|
+
target.dataset.frames = String(count.current);
|
|
18
|
+
context.clearRect(0, 0, 320, 100);
|
|
19
|
+
context.fillStyle = environment.theme === 'arknights' ? '#d0ef82' : environment.theme === 'neo-brutalism' ? '#e95725' : '#507058';
|
|
20
|
+
context.beginPath(); context.arc(160 + Math.sin(count.current / 25) * 110, 50, 14, 0, Math.PI * 2); context.fill();
|
|
21
|
+
if (!environment.paused && !environment.reducedMotion) frame = requestAnimationFrame(draw);
|
|
22
|
+
};
|
|
23
|
+
draw();
|
|
24
|
+
return () => cancelAnimationFrame(frame);
|
|
25
|
+
}, [environment.paused, environment.reducedMotion, environment.theme]);
|
|
26
|
+
return <div className="review-product"><Stack gap={4}><Text as="h1" variant="title">桌面工作区</Text>
|
|
27
|
+
{dimensions.connection === 'error' ? <Notice title="合成连接失败" /> : dimensions.connection === 'waiting' ? <p role="status">等待模拟连接…</p> : <p>平台模拟已连接 · {dimensions.quota === 'empty' ? '暂无用量' : '合成用量 42'}</p>}
|
|
28
|
+
<TextField label="工作区名称" value={draft} onChange={event => { setDraft(event.target.value); setSaved(false); }} />
|
|
29
|
+
<Button disabled={dimensions.connection !== 'ready'} onClick={async () => { await client.save(draft); setSaved(true); }}>保存工作区</Button>
|
|
30
|
+
{saved && <p role="status">内存工作区已保存</p>}
|
|
31
|
+
<canvas ref={canvas} width={320} height={100} aria-label="合成动态画布" />
|
|
32
|
+
<p>{environment.reducedMotion ? '减少动态' : environment.paused ? '已暂停' : '动态播放'}</p>
|
|
33
|
+
</Stack></div>;
|
|
34
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { defineReview, type ReviewScene } from '@linhey/rhine-design/review';
|
|
2
|
+
const workspace: ReviewScene = {
|
|
3
|
+
id: 'workspace', title: '桌面工作区', group: '平台接口',
|
|
4
|
+
motionControls: ['full', 'paused'],
|
|
5
|
+
source: 'examples/review/platform/Workspace.tsx',
|
|
6
|
+
description: '仅注入内存平台 client,不调用原生配置、下载或业务剪贴板。Canvas 通过显式环境属性暂停、减少动态和恢复。',
|
|
7
|
+
dimensions: [
|
|
8
|
+
{ id: 'connection', label: '连接阶段', options: [{ id: 'ready', label: '已连接' }, { id: 'waiting', label: '等待连接' }, { id: 'error', label: '连接失败' }] },
|
|
9
|
+
{ id: 'quota', label: '用量状态', options: [{ id: 'normal', label: '正常用量' }, { id: 'empty', label: '暂无用量' }] },
|
|
10
|
+
],
|
|
11
|
+
async setup(context) {
|
|
12
|
+
const { Workspace } = await import('./Workspace');
|
|
13
|
+
context.signal.throwIfAborted();
|
|
14
|
+
let current = '合成工作区';
|
|
15
|
+
const client = {
|
|
16
|
+
read: () => current,
|
|
17
|
+
save: async (value: string) => { context.signal.throwIfAborted(); current = value; context.report('已更新内存工作区名称'); },
|
|
18
|
+
};
|
|
19
|
+
return {
|
|
20
|
+
render: environment => <Workspace client={client} environment={environment} dimensions={context.dimensions} />,
|
|
21
|
+
dispose() { current = ''; },
|
|
22
|
+
};
|
|
23
|
+
},
|
|
24
|
+
};
|
|
25
|
+
export default defineReview({
|
|
26
|
+
title: '桌面工作区 · 平台示例', scenes: [workspace, {
|
|
27
|
+
id: 'slow', title: '慢速初始化', group: '生命周期', description: '等待期间可以切回工作区;定时器与迟到结果必须清理。',
|
|
28
|
+
async setup(context) {
|
|
29
|
+
await new Promise<void>((resolve, reject) => {
|
|
30
|
+
const timer = setTimeout(resolve, 4000);
|
|
31
|
+
context.onDispose(() => { clearTimeout(timer); reject(new DOMException('Disposed', 'AbortError')); });
|
|
32
|
+
});
|
|
33
|
+
context.signal.throwIfAborted();
|
|
34
|
+
return { render: () => <p>慢速初始化完成</p>, dispose() {} };
|
|
35
|
+
},
|
|
36
|
+
}, {
|
|
37
|
+
id: 'stuck-cleanup', title: '清理挂起恢复', group: '故障恢复', description: '离开此场景会遇到永不结束的合成清理;10 秒后可在外层重新载入画布恢复。',
|
|
38
|
+
setup: () => ({ render: () => <p>此场景模拟清理挂起,请切换到桌面工作区验证恢复。</p>, dispose: () => new Promise<void>(() => {}) }),
|
|
39
|
+
}, {
|
|
40
|
+
id: 'cleanup-error', title: '清理错误留存', group: '故障恢复',
|
|
41
|
+
setup: () => ({ render: () => <p>离开此场景后,合成清理错误应保留到显式确认。</p>, dispose() { throw new Error('合成清理失败'); } }),
|
|
42
|
+
}, {
|
|
43
|
+
id: 'failure', title: '渲染错误边界', group: '生命周期',
|
|
44
|
+
setup: () => ({ render() { throw new Error('合成渲染失败'); }, dispose() {} }),
|
|
45
|
+
}],
|
|
46
|
+
});
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { defineConfig } from 'vite';
|
|
2
|
+
import react from '@vitejs/plugin-react';
|
|
3
|
+
import { reviewPlugin } from '@linhey/rhine-design/review/vite';
|
|
4
|
+
export default defineConfig(({ command }) => ({
|
|
5
|
+
root: import.meta.dirname, base: command === 'serve' ? '/review-platform/' : './',
|
|
6
|
+
plugins: [react(), reviewPlugin({ catalog: './catalog.tsx' })],
|
|
7
|
+
build: { outDir: '../../../dist/review-platform', emptyOutDir: true },
|
|
8
|
+
}));
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@linhey/rhine-design",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.1",
|
|
4
4
|
"license": "Apache-2.0",
|
|
5
5
|
"publishConfig": {
|
|
6
6
|
"access": "public",
|
|
@@ -23,7 +23,12 @@
|
|
|
23
23
|
"./styles.css": "./components/styles.css",
|
|
24
24
|
"./theme-arknights.css": "./components/theme-arknights.css",
|
|
25
25
|
"./theme-neo-brutalism.css": "./components/theme-neo-brutalism.css",
|
|
26
|
-
"./logo.svg": "./components/assets/logo.svg"
|
|
26
|
+
"./logo.svg": "./components/assets/logo.svg",
|
|
27
|
+
"./review": "./review/index.ts",
|
|
28
|
+
"./review/vite": {
|
|
29
|
+
"types": "./review/vite.d.mts",
|
|
30
|
+
"import": "./review/vite.mjs"
|
|
31
|
+
}
|
|
27
32
|
},
|
|
28
33
|
"sideEffects": [
|
|
29
34
|
"./components/index.ts",
|
|
@@ -47,15 +52,22 @@
|
|
|
47
52
|
"vitest.config.ts",
|
|
48
53
|
"package-lock.json",
|
|
49
54
|
"scripts",
|
|
50
|
-
"skills"
|
|
55
|
+
"skills",
|
|
56
|
+
"review",
|
|
57
|
+
"examples/review/*/*.ts",
|
|
58
|
+
"examples/review/*/*.tsx",
|
|
59
|
+
"examples/review/*.css"
|
|
51
60
|
],
|
|
52
61
|
"scripts": {
|
|
53
62
|
"dev": "vite --host 127.0.0.1 --port 18309 --strictPort",
|
|
54
63
|
"typecheck": "tsc --noEmit",
|
|
55
64
|
"test": "vitest run",
|
|
56
65
|
"build": "npm run typecheck && vite build && node scripts/check-build.mjs",
|
|
57
|
-
"check": "npm run test && npm run build",
|
|
58
|
-
"prepublishOnly": "npm run check"
|
|
66
|
+
"check": "npm run test && npm run build && npm run review:build && node scripts/check-review-package.mjs",
|
|
67
|
+
"prepublishOnly": "npm run check",
|
|
68
|
+
"review:http": "vite --config examples/review/http/vite.config.ts --host 127.0.0.1 --port 18340 --strictPort",
|
|
69
|
+
"review:platform": "vite --config examples/review/platform/vite.config.ts --host 127.0.0.1 --port 18341 --strictPort",
|
|
70
|
+
"review:build": "vite build --config examples/review/http/vite.config.ts && vite build --config examples/review/platform/vite.config.ts"
|
|
59
71
|
},
|
|
60
72
|
"peerDependencies": {
|
|
61
73
|
"react": "^19.0.0",
|
|
@@ -30,6 +30,6 @@ export function CommonGallery({ section }: { section?: string }) {
|
|
|
30
30
|
<Menu label="更多操作" trigger={<Button variant="secondary">更多操作</Button>} items={[{id:'refresh',label:'刷新',onSelect:()=>setAction('菜单:刷新')},{id:'disabled',label:'导出(暂不可用)',disabled:true,onSelect:()=>{}},{id:'archive',label:'归档示例',danger:true,onSelect:()=>setAction('菜单:归档示例')}]}/>
|
|
31
31
|
<Dialog trigger={<Button variant="secondary">打开对话框</Button>} title="编辑工作区" description="此对话框仅演示输入、焦点与关闭行为。"><Stack><TextField label="工作区名称" defaultValue="研究工作区"/><Textarea label="工作区说明"/><Popover trigger={<Button variant="secondary">对话框内详情</Button>} title="嵌套详情"><Text>Escape先关闭当前气泡,再返回对话框。</Text></Popover></Stack></Dialog>
|
|
32
32
|
<ConfirmDialog trigger={<Button variant="danger">打开确认框</Button>} title="停止演示任务?" description="这是合成操作,不会修改实际同步。默认焦点在取消按钮。" confirmLabel="停止演示任务" danger onConfirm={async()=>{if(confirmFail)throw new Error('demo');setAction('确认已完成(演示)');}}/>
|
|
33
|
-
</Inline><Checkbox label="模拟确认操作失败" checked={confirmFail} onChange={event=>setConfirmFail(event.target.checked)}/><Text variant="caption" tone="muted">{action}。Tab/方向键/Escape可操作;减少动态保留相同功能。</Text><Divider/><Inline><Button variant="secondary" onClick={()=>{setToastTone('success');setToast(true);}}>显示成功通知</Button><Button variant="secondary" onClick={()=>{setToastTone('error');setToast(true);}}>显示错误通知</Button></Inline><Toast open={toast} onOpenChange={setToast} tone={toastTone} title={toastTone==='success'?'演示操作已完成':'演示操作失败'} description={toastTone==='success'?'5秒后关闭;悬停、聚焦或页面隐藏时暂停。':'错误通知保持显示,请重试或手动关闭。'}/></Stack></Surface></section>}
|
|
33
|
+
</Inline><Checkbox label="模拟确认操作失败" checked={confirmFail} onChange={event=>setConfirmFail(event.target.checked)}/><Text variant="caption" tone="muted">{action}。Tab/方向键/Escape可操作;减少动态保留相同功能。</Text><Divider/><Inline><Button variant="secondary" onClick={()=>{setToastTone('success');setToast(true);}}>显示成功通知</Button><Button variant="secondary" onClick={()=>{setToastTone('error');setToast(true);}}>显示错误通知</Button></Inline><Toast placement="floating" open={toast} onOpenChange={setToast} tone={toastTone} title={toastTone==='success'?'演示操作已完成':'演示操作失败'} description={toastTone==='success'?'5秒后关闭;悬停、聚焦或页面隐藏时暂停。':'错误通知保持显示,请重试或手动关闭。'}/></Stack></Surface></section>}
|
|
34
34
|
</>;
|
|
35
35
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { useState } from 'react';
|
|
2
|
-
import { Button, DateRangePicker, Inline, RemoteMultiSelect, Stack, Surface, Text, type Choice, type DateRange } from '../components';
|
|
2
|
+
import { Button, DateRangePicker, Dialog, Inline, RemoteMultiSelect, Stack, Surface, Text, type Choice, type DateRange } from '../components';
|
|
3
3
|
|
|
4
4
|
const departments: Choice[] = Array.from({ length: 100 }, (_, i) => ({ value: `synthetic-${i}`, label: `合成部门 ${i + 1}${i % 8 === 0 ? ' · 跨团队协作与长期计划' : ''}` }));
|
|
5
5
|
|
|
@@ -12,9 +12,12 @@ export function DenseFilterControlsGallery() {
|
|
|
12
12
|
const options = departments.filter(item => item.label.includes(query));
|
|
13
13
|
return <Surface><Stack gap={4}>
|
|
14
14
|
<Text as="h2" variant="heading">密集筛选控件</Text>
|
|
15
|
-
<Text tone="muted">100 个合成部门、90
|
|
15
|
+
<Text tone="muted">100 个合成部门、90 个已选项;可搜索、再次点选取消、完成选择收起、展开移除或清空。日期保留原生键盘和日历。</Text>
|
|
16
16
|
<Inline gap={2}><Button variant="secondary" onClick={() => setSelected(departments)}>全选部门</Button><Button variant="quiet" onClick={() => setSelected([])}>清空部门</Button></Inline>
|
|
17
17
|
<RemoteMultiSelect label="部门" query={query} onQueryChange={setQuery} value={selected} onValueChange={setSelected} options={options} maxVisibleTags={2} placeholder="搜索部门" />
|
|
18
|
+
<Dialog trigger={<Button variant="secondary">在弹窗内选择</Button>} title="搜索多选" description="再次点击已选项可取消;完成选择只收起列表,不提交表单。">
|
|
19
|
+
<RemoteMultiSelect label="弹窗部门" query={query} onQueryChange={setQuery} value={selected} onValueChange={setSelected} options={options} maxVisibleTags={2}/>
|
|
20
|
+
</Dialog>
|
|
18
21
|
<DateRangePicker compact label="日期区间" value={date} onValueChange={setDate} min="2026-01-01" max="2026-12-31" clearable={false} required onValidationChange={setError} />
|
|
19
22
|
<Text variant="caption" tone="muted">{error || `${date.start} 至 ${date.end}`}</Text>
|
|
20
23
|
</Stack></Surface>;
|
package/preview/GalleryShell.tsx
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { useEffect, useRef, useState, type ReactNode } from 'react';
|
|
2
2
|
import { Breadcrumbs, Button, Logo, SegmentedControl, Select, SideNav } from '../components';
|
|
3
3
|
import { ThemeProvider, themes, useTheme, type ThemeName } from '../components/theme';
|
|
4
|
+
import { componentCount, packageVersion } from './packageInfo';
|
|
4
5
|
import './shell.css';
|
|
5
6
|
import './theme-arknights.css';
|
|
6
7
|
import './theme-neo-brutalism.css';
|
|
@@ -9,7 +10,7 @@ export const galleryGroups = [
|
|
|
9
10
|
{ label: '开始', items: [['top', '设计总览'], ['subscriptions', '订阅管理示例'], ['compact-filters', '紧凑与大量筛选'], ['checkbox-rows', '侧栏双行多选'], ['component-typography', '组件文字配对']] },
|
|
10
11
|
{ label: '应用能力', items: [['app-shell', '背景与多侧栏'], ['advanced-forms', '复杂表单'], ['advanced-data', '受控数据表'], ['media', '媒体卡片与查看器'], ['monitoring', '健康详情与图表']] },
|
|
11
12
|
{ label: '设计语言', items: [['foundation', '视觉基础'], ['backgrounds', '场景背景'], ['spacing', '间距与组合'], ['typography', '字体与文字'], ['brand', '品牌标志']] },
|
|
12
|
-
{ label: '通用组件', items: [['actions', '按钮与输入'], ['selection', '选择与切换'], ['feedback', '状态与加载'], ['disclosure', '展开与组合'], ['data', '数据与分页'], ['forms', '表单与选择'], ['utilities', '操作与布局'], ['overlays', '浮层与反馈'], ['overlay-bounds', '浮层缩放与边界'], ['settings-dialog', '设置导航弹窗'], ['theme-picker', '主题预览与选择'], ['navigation', '导航与步骤'], ['form-workflow', '表单流程']] },
|
|
13
|
+
{ label: '通用组件', items: [['actions', '按钮与输入'], ['selection', '选择与切换'], ['summary-tabs', '带摘要的页签'], ['feedback', '状态与加载'], ['disclosure', '展开与组合'], ['data', '数据与分页'], ['forms', '表单与选择'], ['utilities', '操作与布局'], ['overlays', '浮层与反馈'], ['overlay-bounds', '浮层缩放与边界'], ['settings-dialog', '设置导航弹窗'], ['theme-picker', '主题预览与选择'], ['navigation', '导航与步骤'], ['form-workflow', '表单流程']] },
|
|
13
14
|
{ label: '交互与动效', items: [['patterns', '场景与交互指南'], ['compact-account', '紧凑账号与凭证'], ['list-filter-bar', '列表筛选栏'], ['date-range-filter', '日期筛选组合'], ['motion', '动效实验室'], ['startup', '启动动画'], ['orbit', '轨道星空'], ['neutron-star', '中子星']] },
|
|
14
15
|
] as const;
|
|
15
16
|
export type GallerySection = typeof galleryGroups[number]['items'][number][0];
|
|
@@ -94,7 +95,7 @@ function GalleryContent({ motion, onMotionChange, children }: GalleryShellProps)
|
|
|
94
95
|
<SideNav id="gallery-navigation" className="ds-sidebar-nav" aria-label="组件目录" currentId={section} groups={galleryGroups.map(group=>({label:group.label,items:group.items.map(([id,label])=>({id,label,href:`#${id}`}))}))} onNavigate={item=>{
|
|
95
96
|
setMenuOpen(false); if(section===item.id) main.current?.focus({preventScroll:true});
|
|
96
97
|
}}/>
|
|
97
|
-
<div className="ds-sidebar-foot"><span>COMPONENT LIBRARY</span><span>
|
|
98
|
+
<div className="ds-sidebar-foot"><span>COMPONENT LIBRARY</span><span>{componentCount} 个组件 · 源码包版本 {packageVersion}</span><span>发布范围见版本记录</span><span className="ak-readout">{readout}</span></div>
|
|
98
99
|
</aside>
|
|
99
100
|
<main className="ds-workspace" id="gallery-content" ref={main} tabIndex={-1} aria-label={label}>
|
|
100
101
|
<header className="ds-workspace-head"><Breadcrumbs label="展台位置" items={[{label:"组件展台",href:"#top"},{label}]} /><div className="ds-head-controls"><SegmentedControl label="动效偏好" items={[{value:'system',label:'跟随系统'},{value:'reduced',label:'减少动态'}]} value={motion} onValueChange={onMotionChange} /><Select label="界面主题" value={theme} onChange={event => {
|
package/preview/OrbitGallery.tsx
CHANGED
|
@@ -12,7 +12,7 @@ export function OrbitGallery() {
|
|
|
12
12
|
<div className="ds-section-title"><span>ORBIT</span><h2>轨道星空 · 品牌艺术背景</h2></div>
|
|
13
13
|
<p className="ds-caption">莱茵的折纸鸟、战术的悬浮源石晶簇、橙趣的草莓甜甜圈。每个主题都有自己的物件,也可以切换到原始深色星空。</p>
|
|
14
14
|
<Inline gap={3}>
|
|
15
|
-
<Badge
|
|
15
|
+
<Badge>主题艺术自 0.8.0 提供</Badge>
|
|
16
16
|
<SegmentedControl label="画面风格" value={appearance} onValueChange={value => setAppearance(value as 'theme' | 'original')} items={[{ value: 'theme', label: '跟随主题' }, { value: 'original', label: '原始深色' }]} />
|
|
17
17
|
<Button variant="secondary" onClick={() => setReplay(value => value + 1)}>重播轨道</Button>
|
|
18
18
|
<Switch label="减少轨道动态" checked={reduced} onCheckedChange={setReduced} />
|
|
@@ -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
|
+
}
|