@linhey/rhine-design 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (89) hide show
  1. package/CHANGELOG.md +46 -0
  2. package/README.md +63 -14
  3. package/components/Actions.tsx +2 -0
  4. package/components/AdvancedForms.tsx +184 -0
  5. package/components/AppShell.tsx +71 -0
  6. package/components/Background.tsx +29 -0
  7. package/components/COMMON_COMPONENTS.md +5 -1
  8. package/components/Charts.tsx +94 -0
  9. package/components/Data.tsx +98 -18
  10. package/components/FormWorkflow.tsx +25 -0
  11. package/components/Forms.tsx +14 -8
  12. package/components/MOTION.md +6 -2
  13. package/components/Media.tsx +82 -0
  14. package/components/Navigation.tsx +42 -0
  15. package/components/Overlays.tsx +10 -3
  16. package/components/README.md +20 -5
  17. package/components/SPACING.md +82 -0
  18. package/components/StatusTimeline.tsx +67 -0
  19. package/components/TYPOGRAPHY.md +34 -2
  20. package/components/background.css +43 -0
  21. package/components/charts.css +27 -0
  22. package/components/common.css +17 -8
  23. package/components/data.css +6 -0
  24. package/components/drawer-placement.css +4 -0
  25. package/components/extensions.css +70 -0
  26. package/components/index.ts +18 -2
  27. package/components/media-parity.css +3 -0
  28. package/components/patterns.css +39 -0
  29. package/components/styles.css +15 -8
  30. package/components/theme-arknights.css +306 -0
  31. package/components/theme-neo-brutalism.css +90 -0
  32. package/components/theme.tsx +58 -0
  33. package/components/timeline.css +23 -0
  34. package/components/tokens.css +49 -1
  35. package/docs/ANTD_MUI_AUDIT.md +38 -0
  36. package/docs/APPLICATION_COMPONENTS.md +175 -0
  37. package/docs/DESIGN_SYSTEM.md +12 -2
  38. package/docs/DESIGN_SYSTEM_AUDIT.md +6 -4
  39. package/docs/INTERACTION_PATTERNS.md +129 -0
  40. package/docs/ISSUE_4_VALIDATION.md +21 -0
  41. package/docs/ISSUE_VALIDATION.md +52 -0
  42. package/docs/REFERENCE_RESEARCH.md +38 -0
  43. package/docs/THEME_NEO_BRUTALISM.md +37 -0
  44. package/docs/THEMING.md +145 -0
  45. package/docs/assets/application-shell.png +0 -0
  46. package/docs/assets/issue4-shell.png +0 -0
  47. package/docs/assets/media-viewer-mobile.png +0 -0
  48. package/docs/assets/monitoring-desktop.png +0 -0
  49. package/docs/audits/DATA_ANTD_MUI.md +75 -0
  50. package/docs/audits/FORMS_ANTD_MUI.md +88 -0
  51. package/docs/audits/MONITORING_ANTD_MUI.md +44 -0
  52. package/docs/audits/SHELL_MEDIA_ANTD_MUI.md +91 -0
  53. package/package.json +5 -1
  54. package/preview/BackgroundGallery.tsx +46 -0
  55. package/preview/CommonGallery.tsx +8 -8
  56. package/preview/ExtensionsGallery.tsx +95 -0
  57. package/preview/GalleryShell.tsx +107 -0
  58. package/preview/LineHeightGallery.tsx +27 -0
  59. package/preview/MonitoringGallery.tsx +27 -0
  60. package/preview/MotionGallery.tsx +6 -2
  61. package/preview/PatternGallery.tsx +52 -0
  62. package/preview/SpacingGallery.tsx +32 -0
  63. package/preview/SubscriptionGallery.tsx +84 -0
  64. package/preview/backgrounds.css +10 -0
  65. package/preview/extensions.css +6 -0
  66. package/preview/line-height.css +2 -0
  67. package/preview/main.tsx +48 -25
  68. package/preview/patterns.css +7 -0
  69. package/preview/preview.css +2 -8
  70. package/preview/shell.css +35 -0
  71. package/preview/spacing.css +2 -0
  72. package/preview/subscriptions.css +27 -0
  73. package/preview/theme-arknights.css +234 -0
  74. package/preview/theme-neo-brutalism.css +11 -0
  75. package/skills/rhine-design/SKILL.md +26 -3
  76. package/skills/rhine-design/references/API_MAP.md +11 -3
  77. package/skills/rhine-design/references/TASK_MAP.md +5 -1
  78. package/skills/rhine-design/references/integration.md +7 -3
  79. package/tests/Background.test.tsx +22 -0
  80. package/tests/DataParity.test.tsx +148 -0
  81. package/tests/Extensions.test.tsx +145 -0
  82. package/tests/FormParity.test.tsx +185 -0
  83. package/tests/GalleryShell.test.tsx +98 -0
  84. package/tests/Monitoring.test.tsx +38 -0
  85. package/tests/MonitoringParity.test.tsx +125 -0
  86. package/tests/Patterns.test.tsx +75 -0
  87. package/tests/ShellMediaParity.test.tsx +180 -0
  88. package/tests/StatusTimeline.test.tsx +15 -0
  89. package/tests/Theme.test.tsx +112 -0
@@ -3,33 +3,33 @@ import { Badge, Button, Checkbox, Combobox, ConfirmDialog, CopyButton, DataTable
3
3
 
4
4
  const demoRows = [{ id:'request-a', time:'14:59:18', model:'gpt-6.1-sol', tokens:9600 }, { id:'request-b', time:'14:58:18', model:'gpt-6-sol', tokens:0 }];
5
5
  const demoOptions = [{value:'a',label:'研究工作区'}, {value:'disabled',label:'归档工作区',disabled:true}, {value:'b',label:'共享工作区'}];
6
- export function CommonGallery() {
6
+ export function CommonGallery({ section }: { section?: string }) {
7
7
  const [tableState,setTableState]=useState('normal'), [page,setPage]=useState(1), [sort,setSort]=useState<TableSort>();
8
8
  const [checked,setChecked]=useState(false), [mixed,setMixed]=useState(true), [radio,setRadio]=useState('daily'), [switchOn,setSwitchOn]=useState(false);
9
9
  const [query,setQuery]=useState(''), [queryEvent,setQueryEvent]=useState('尚未提交'), [candidateQuery,setCandidateQuery]=useState(''), [candidate,setCandidate]=useState<string|null>(null), [candidateState,setCandidateState]=useState('normal');
10
10
  const [toast,setToast]=useState(false), [toastTone,setToastTone]=useState<'success'|'error'>('success'), [confirmFail,setConfirmFail]=useState(false), [action,setAction]=useState('尚无操作');
11
11
  return <>
12
- <section id="data"><div className="ds-section-title"><span>06</span><h2>数据与分页 · 完整表达状态</h2></div>
12
+ {(!section || section === 'data') && <section id="data"><div className="ds-section-title"><span>06</span><h2>数据与分页 · 完整表达状态</h2></div>
13
13
  <Surface><Stack><Grid minWidth={150}><Metric label="请求次数" value={128} description="失败 3 次"/><Metric label="预估费用" value="$5.44" description="3 次未计价"/><Metric label="平均耗时" value={null} unit="s" description="暂不可用"/><Metric label="总 Token" value={null} loading/></Grid><Divider/><KeyValue items={[{key:'mode',label:'本地配置',value:'OAuth'},{key:'model',label:'推荐模型',value:'gpt-6.1-sol'},{key:'state',label:'同步状态',value:<Badge tone="success">运行中</Badge>}]}/></Stack></Surface>
14
14
  <Surface className="ds-gap-top"><Stack><Select label="表格状态" value={tableState} onChange={event=>setTableState(event.target.value)}><option value="normal">正常</option><option value="loading">刷新中(保留数据)</option><option value="empty">无记录</option><option value="error">读取失败(保留数据)</option></Select>
15
15
  <DataTable caption="请求明细 · 合成数据" rows={tableState==='empty'?[]:demoRows} rowKey={row=>row.id} loading={tableState==='loading'} error={tableState==='error'?'连接暂时不可用,请重试。':undefined} onRetry={()=>setTableState('normal')} sort={sort} onSortChange={value=>{setSort(value);setAction(`排序事件:${value.key} / ${value.direction}`);}} columns={[{key:'time',header:'时间 · 上海',render:row=>row.time,sortable:true},{key:'model',header:'模型',render:row=>row.model},{key:'tokens',header:'Token',render:row=>row.tokens.toLocaleString('en-US'),align:'right',sortable:true}]}/>
16
16
  <Pagination page={page} pageSize={10} total={23} loading={tableState==='loading'} onPageChange={setPage}/><Text variant="caption" tone="muted">分页和排序只演示事件;此处保留同一组合成记录,组件不本地切片或排序。{action}</Text>
17
17
  </Stack></Surface>
18
- </section>
19
- <section id="forms"><div className="ds-section-title"><span>07</span><h2>表单与选择 · 原生操作习惯</h2></div><Grid>
18
+ </section>}
19
+ {(!section || section === 'forms') && <section id="forms"><div className="ds-section-title"><span>07</span><h2>表单与选择 · 原生操作习惯</h2></div><Grid>
20
20
  <Surface><Stack><Checkbox label="包含失败请求" checked={checked} onChange={event=>setChecked(event.target.checked)} hint="复选项可以独立启停。"/><Checkbox label="混合状态示例" indeterminate={mixed} onChange={()=>setMixed(false)}/><Checkbox label="暂不可选择" disabled/>
21
21
  <Divider/><RadioGroup label="报表周期" items={[{value:'daily',label:'每日'},{value:'weekly',label:'每周'},{value:'monthly',label:'每月',disabled:true}]} value={radio} onValueChange={setRadio}/><Switch label="显示辅助说明" checked={switchOn} onCheckedChange={setSwitchOn} hint="偏好示例,不控制实际同步。"/></Stack></Surface>
22
22
  <Surface><Stack><Textarea label="备注" placeholder="输入工作区说明" hint="允许换行与垂直调整;此处不保存。"/><Textarea label="只读说明" readOnly value="由组织统一设置,可选择和复制。" rows={2}/><Textarea label="校验失败示例" error="请填写说明后继续。" rows={2}/></Stack></Surface>
23
23
  </Grid>
24
24
  <Grid className="ds-gap-top"><Surface><Stack><SearchField label="搜索请求" value={query} onValueChange={setQuery} onSearch={value=>setQueryEvent(value?`已提交:${value}`:'已清空查询')}/><Text variant="caption" tone="muted">{queryEvent}。只回传查询,不访问接口。</Text></Stack></Surface>
25
- <Surface><Stack><Select label="候选状态" value={candidateState} onChange={event=>setCandidateState(event.target.value)}><option value="normal">正常</option><option value="loading">读取中</option><option value="empty">无匹配结果</option><option value="error">读取失败</option></Select><Combobox label="选择工作区" query={candidateQuery} onQueryChange={value=>{setCandidateQuery(value);setCandidate(null);}} value={candidate} onValueChange={option=>{setCandidate(option.value);setCandidateQuery(option.label);}} options={candidateState==='empty'?[]:demoOptions} loading={candidateState==='loading'} error={candidateState==='error'?'候选读取失败,请重新查询。':undefined}/><Text variant="caption" tone="muted">候选列表为固定合成返回结果,不随输入执行本地筛选。</Text></Stack></Surface></Grid></section>
26
- <section id="utilities"><div className="ds-section-title"><span>08</span><h2>操作与布局 · 按同一规则组合</h2></div><Surface><Stack><Inline><Link href="#typography">查看文字规范</Link><IconButton icon="refresh" label="刷新示例" onClick={()=>setAction('已点击刷新')}/><IconButton icon="close" label="不可关闭" disabled/><Icon name="info"/><Text variant="caption">图标提供辅助识别;按钮始终有名称。</Text></Inline><Divider/><Inline><CopyButton value="Rhine Design · 组件复制示例" label="复制演示文字"/><CopyButton value="demo" label="演示复制失败" copy={()=>Promise.reject(new Error('demo'))}/></Inline><Text variant="caption" tone="muted">Stack / Inline / Grid / Divider 在本页直接参与布局,无额外业务状态。</Text></Stack></Surface></section>
27
- <section id="overlays"><div className="ds-section-title"><span>09</span><h2>浮层与反馈 · 焦点有来有回</h2></div><Surface><Stack><Inline>
25
+ <Surface><Stack><Select label="候选状态" value={candidateState} onChange={event=>setCandidateState(event.target.value)}><option value="normal">正常</option><option value="loading">读取中</option><option value="empty">无匹配结果</option><option value="error">读取失败</option></Select><Combobox label="选择工作区" query={candidateQuery} onQueryChange={value=>{setCandidateQuery(value);setCandidate(null);}} value={candidate} onValueChange={option=>{setCandidate(option.value);setCandidateQuery(option.label);}} options={candidateState==='empty'?[]:demoOptions} loading={candidateState==='loading'} error={candidateState==='error'?'候选读取失败,请重新查询。':undefined}/><Text variant="caption" tone="muted">候选列表为固定合成返回结果,不随输入执行本地筛选。</Text></Stack></Surface></Grid></section>}
26
+ {(!section || section === 'utilities') && <section id="utilities"><div className="ds-section-title"><span>08</span><h2>操作与布局 · 按同一规则组合</h2></div><Surface><Stack><Inline><Link href="#typography">查看文字规范</Link><IconButton icon="refresh" label="刷新示例" onClick={()=>setAction('已点击刷新')}/><IconButton icon="close" label="不可关闭" disabled/><Icon name="info"/><Text variant="caption">图标提供辅助识别;按钮始终有名称。</Text></Inline><Divider/><Inline><CopyButton value="Rhine Design · 组件复制示例" label="复制演示文字"/><CopyButton value="demo" label="演示复制失败" copy={()=>Promise.reject(new Error('demo'))}/></Inline><Text variant="caption" tone="muted">Stack / Inline / Grid / Divider 在本页直接参与布局,无额外业务状态。</Text></Stack></Surface></section>}
27
+ {(!section || section === 'overlays') && <section id="overlays"><div className="ds-section-title"><span>09</span><h2>浮层与反馈 · 焦点有来有回</h2></div><Surface><Stack><Inline>
28
28
  <Tooltip content="可用额度可能延迟更新,以最新采集结果为准。"><IconButton label="额度说明" icon="info"/></Tooltip>
29
29
  <Popover trigger={<Button variant="secondary">查看详情</Button>} title="工作区详情"><Stack><Text>气泡用于补充内容,不阻断页面。</Text><Link href="#data">查看数据组件</Link></Stack></Popover>
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 open={toast} onOpenChange={setToast} tone={toastTone} title={toastTone==='success'?'演示操作已完成':'演示操作失败'} description={toastTone==='success'?'5秒后关闭;悬停、聚焦或页面隐藏时暂停。':'错误通知保持显示,请重试或手动关闭。'}/></Stack></Surface></section>}
34
34
  </>;
35
35
  }
@@ -0,0 +1,95 @@
1
+ import { useEffect, useState } from 'react';
2
+ import { AppShell, Background, Button, Checkbox, DataTable, DateField, DateRangePicker, Dialog, EmptyState, MediaCard, MediaViewer, Notice, NumberField, Pagination, RemoteMultiSelect, Select, SideNav, Skeleton, Stack, Surface, TextField, TreeSelect, type AppShellProps, type DateRange, type TreeNode, type TableSort } from '../components';
3
+ import type { Choice } from '../components/components';
4
+ import './extensions.css';
5
+
6
+ const longName = '非常长的工作区名称与跨区域协作资料—Workspace-2026-archive';
7
+ const sampleImage = `data:image/svg+xml,${encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" width="960" height="640" viewBox="0 0 960 640"><rect width="960" height="640" fill="#d2dacf"/><circle cx="720" cy="150" r="65" fill="#f8f2d8"/><path d="M0 520 310 140 620 550 800 280 960 500V640H0Z" fill="#536b5d"/><path d="M0 560 320 400 610 590 960 480V640H0Z" fill="#293f37"/></svg>')}`;
8
+
9
+ function StateControl({ value, onChange }: { value: string; onChange: (value: string) => void }) {
10
+ return <Select label="演示状态" value={value} onChange={event => onChange(event.target.value)}><option value="ready">默认</option><option value="loading">加载 / 保留旧数据</option><option value="error">失败</option><option value="empty">空内容</option><option value="disabled">禁用</option></Select>;
11
+ }
12
+
13
+ export function ShellGallery() {
14
+ const [layout, setLayout] = useState<AppShellProps['layout']>('double-left');
15
+ const [navOpen, setNavOpen] = useState(true), [secondaryOpen, setSecondaryOpen] = useState(true);
16
+ const [navDrawerOpen, setNavDrawerOpen] = useState(false);
17
+ const [current, setCurrent] = useState('inbox'), [draft, setDraft] = useState('主题切换和侧栏折叠会保留这段内容。');
18
+ const [plain, setPlain] = useState(true), [state, setState] = useState('ready');
19
+ return <section><div className="ds-section-title"><span>LAYOUT</span><h2>画布与应用外壳</h2></div>
20
+ <p className="ds-caption">单侧栏适合简单工具;双左栏适合目录与列表;左右栏适合编辑器与检查器。可用宽度不足 1120px 时次栏转抽屉,不足 760px 时全局导航也转抽屉。正文始终保留。</p>
21
+ <div className="ds-extension-controls"><Select label="布局方案" value={layout} onChange={event => setLayout(event.target.value as AppShellProps['layout'])}><option value="single">单侧栏</option><option value="double-left">双左侧栏</option><option value="left-right">左右侧栏</option></Select><Checkbox label="纯色背景" checked={plain} onChange={event => setPlain(event.target.checked)} /><StateControl value={state} onChange={setState} /></div>
22
+ <div className="ds-shell-stage"><AppShell layout={layout} decoration={plain ? 'none' : 'theme'}
23
+ navigation={{ label: '全局导航', open: navOpen, onOpenChange: setNavOpen, drawerOpen: navDrawerOpen, onDrawerOpenChange: setNavDrawerOpen, content: <SideNav aria-label="应用导航" currentId={current} groups={[{ label: '工作空间', items: [{ id: 'inbox', label: '收件箱', href: '#app-shell' }, { id: 'archive', label: longName, href: '#app-shell' }] }]} onNavigate={(item, event) => { event.preventDefault(); setCurrent(item.id); setNavDrawerOpen(false); }} /> }}
24
+ secondary={{ label: layout === 'left-right' ? '辅助详情' : '上下文目录', open: secondaryOpen, onOpenChange: setSecondaryOpen, content: <Stack gap={4}><strong>{current === 'inbox' ? '收件箱' : longName}</strong>{state === 'loading' ? <Skeleton label="目录加载中" /> : state === 'error' ? <Notice title="目录读取失败" tone="error">可以继续编辑正文。</Notice> : state === 'empty' ? <EmptyState title="目录暂无内容" description="选择其他项目或继续编辑正文。" /> : <><p>当前条目、所属目录和属性。</p><Button variant="secondary" disabled={state === 'disabled'}>上下文操作</Button></>}</Stack> }}
25
+ header={<Dialog title="背景与浮层" description="浮层仍然挂载在唯一主题根;不受局部背景裁剪。" trigger={<Button>打开对话框</Button>}><TextField label="对话框输入" placeholder="试试 Tab 和 Escape" /></Dialog>}>
26
+ <Stack gap={6}><div><p className="ds-kicker">CURRENT TASK / {current}</p><h3>同一主题,独立正文背景</h3><p>正文默认使用纯色背景。关闭“纯色背景”可显式启用主题纹理;纸面主题保持纯净。</p></div>
27
+ <Surface><Stack gap={4}><TextField label="正文草稿" value={draft} onChange={event => setDraft(event.target.value)} /><Button disabled={state === 'disabled'}>继续当前任务</Button></Stack></Surface>
28
+ {Array.from({ length: 5 }, (_, i) => <Surface key={i}><h3>内容段落 {i + 1}</h3><p>滚动正文时顶部操作栏保持可达;每栏仅有一个滚动容器。Tab 可依次访问导航、正文与辅助详情。</p></Surface>)}
29
+ </Stack>
30
+ </AppShell></div>
31
+ <div className="ds-extension-grid ds-gap-top">{(['canvas','content','sidebar'] as const).map(role => <Background key={role} roleTone={role} decoration={plain ? 'none' : 'theme'} className="ds-background-swatch"><strong>{role}</strong><Surface>Surface 始终提供不透明阅读表面。</Surface></Background>)}</div>
32
+ </section>;
33
+ }
34
+
35
+ export function AdvancedFormsGallery() {
36
+ const [range, setRange] = useState<DateRange>({ start: '2026-10-01', end: '2026-10-23' }), [date, setDate] = useState('2026-10-23');
37
+ const [number, setNumber] = useState<number | null>(1.5), [query, setQuery] = useState(''), [options, setOptions] = useState<Choice[]>([]), [selected, setSelected] = useState<Choice[]>([]);
38
+ const [pending, setPending] = useState(false), [state, setState] = useState('ready');
39
+ const [readOnly, setReadOnly] = useState(false);
40
+ const [keys, setKeys] = useState<string[]>(['leaf']), [expanded, setExpanded] = useState<string[]>(['root']), [multiple, setMultiple] = useState(true);
41
+ const nodes: TreeNode[] = [{ id: 'root', label: '协作项目', children: [{ id: 'leaf', label: longName }, { id: 'locked', label: '归档项目', disabled: true }] }, { id: 'other', label: '独立项目' }];
42
+ useEffect(() => {
43
+ setPending(true);
44
+ // This host mock cancels superseded requests; the component never fetches or filters.
45
+ const timer = setTimeout(() => { setOptions([{ value: `${query || 'all'}-1`, label: `${query || '远程结果'} · ${longName}` }, { value: 'disabled', label: '无访问权限', disabled: true }, { value: `${query || 'all'}-2`, label: `${query || '远程结果'} · 第二项` }]); setPending(false); }, 400);
46
+ return () => clearTimeout(timer);
47
+ }, [query]);
48
+ const disabled = state === 'disabled', error = state === 'error' ? '无法读取,请重试。' : undefined;
49
+ return <section><div className="ds-section-title"><span>FORMS</span><h2>复杂输入,明确的数据所有权</h2></div><div className="ds-extension-controls"><StateControl value={state} onChange={setState} /><Checkbox label="只读输入" checked={readOnly} onChange={event => setReadOnly(event.target.checked)} /></div>
50
+ <p className="ds-caption">日期为 YYYY-MM-DD,时区由宿主解释。数字在失焦或 Enter 时提交,Escape 撤销草稿。树支持方向键、字符定位、空格选择与多选 Ctrl/⌘ A;远程结果可连续选择,空查询按 Backspace 移除最后一个可移除标签。只读时可浏览和复制,不能更改值。</p>
51
+ <div className="ds-extension-grid"><Surface><Stack gap={4}><DateRangePicker label="统计范围" value={range} onValueChange={setRange} min="2026-01-01" max="2026-12-31" disabled={disabled} readOnly={readOnly} error={error} presets={[{ label: '本月演示', value: { start: '2026-10-01', end: '2026-10-23' } }, { label: '最近 7 天', value: { start: '2026-10-17', end: '2026-10-23' } }, { label: '无效预设(禁用)', value: { start: '2026-11-01', end: '2026-10-01' } }]} /><DateField label="单个日期" value={date} onValueChange={setDate} disabled={disabled} readOnly={readOnly} /><p>范围:{range.start || '未选'} — {range.end || '未选'}</p></Stack></Surface>
52
+ <Surface><NumberField label="配额" value={number} onValueChange={setNumber} min={-10} max={100} step={0.25} precision={2} disabled={disabled} readOnly={readOnly} error={error} /><p>已提交数值:{number ?? '空值'}。试输入 - 或 1.,编辑时不会丢失。</p></Surface>
53
+ <Surface><RemoteMultiSelect label="远程项目" query={query} onQueryChange={setQuery} value={selected} onValueChange={setSelected} options={state === 'empty' ? [] : options} loading={pending || state === 'loading'} error={error} disabled={disabled} readOnly={readOnly} hint="已选标签跨查询保留;选择后可继续添加。" /></Surface>
54
+ <Surface><Stack gap={4}><Checkbox label="树多选模式" checked={multiple} onChange={event => { setMultiple(event.target.checked); setKeys([]); }} /><TreeSelect label="项目层级" nodes={state === 'empty' ? [] : nodes} value={keys} onValueChange={setKeys} expandedKeys={expanded} onExpandedChange={setExpanded} multiple={multiple} disabled={disabled} readOnly={readOnly} loading={state === 'loading'} error={error} /><p>选中键:{keys.join(', ') || '无'};父子勾选互不联动。</p></Stack></Surface>
55
+ </div>
56
+ </section>;
57
+ }
58
+
59
+ interface DemoRow { id: string; name: string; disabled?: boolean; children?: DemoRow[] }
60
+ const demoRows: DemoRow[] = [{ id: 'one', name: longName, children: [{ id: 'child', name: '子记录 / 保留层级' }] }, { id: 'locked', name: '不可选择的记录', disabled: true }, ...Array.from({ length: 8 }, (_, i) => ({ id: `row-${i}`, name: `记录 ${i + 3}` }))];
61
+ export function AdvancedDataGallery() {
62
+ const [state, setState] = useState('ready'), [keys, setKeys] = useState<string[]>(['off-page']), [expanded, setExpanded] = useState<string[]>(['one']);
63
+ const [page, setPage] = useState(1), [pageSize, setPageSize] = useState(10), [unknown, setUnknown] = useState(false), [compact, setCompact] = useState(false);
64
+ const [single, setSingle] = useState(false), [hideSelectAll, setHideSelectAll] = useState(false), [sort, setSort] = useState<TableSort>();
65
+ return <section><div className="ds-section-title"><span>DATA</span><h2>受控选择、层级与服务端分页</h2></div>
66
+ <div className="ds-extension-controls"><StateControl value={state} onChange={setState} /><Checkbox label="未知总数" checked={unknown} onChange={event => setUnknown(event.target.checked)} /><Checkbox label="紧凑密度" checked={compact} onChange={event => setCompact(event.target.checked)} /><Checkbox label="表格单选" checked={single} onChange={event => { setSingle(event.target.checked); setKeys([]); }} /><Checkbox label="隐藏全选" checked={hideSelectAll} onChange={event => setHideSelectAll(event.target.checked)} /></div>
67
+ <p className="ds-caption">多选表头只作用于当前可见且可选的行,保留跨页键;单选选取新记录会替换旧键。子行与详情独立呈现。右侧两列自动累计固定偏移,表格内部滚动。排序演示仅回传意图,不请求后端。</p>
68
+ <Surface><Stack gap={4}><DataTable caption="项目记录" rows={state === 'empty' ? [] : page === 1 ? demoRows : [{ id: 'next-page', name: '宿主返回的第二页' }]} rowKey={row => row.id} rowLabel={row => row.name} loading={state === 'loading'} error={state === 'error' ? '读取失败,保留上次数据。' : undefined} onRetry={() => setState('ready')}
69
+ selection={{ keys, onChange: setKeys, mode: single ? 'single' : 'multiple', hideSelectAll, isDisabled: row => !!row.disabled || state === 'disabled' }} expansion={{ keys: expanded, onChange: setExpanded, getChildren: row => row.children ?? [], renderDetail: row => <p>独立详情:{row.name}。导出与查询由宿主实现。</p> }} sort={sort} onSortChange={setSort} onSortReset={() => setSort(undefined)} density={compact ? 'compact' : 'comfortable'} stickyHeader maxHeight={420}
70
+ columns={[{ key: 'name', header: '项目名称', width: 300, rowHeader: true, sortable: true, render: row => row.name }, { key: 'id', header: '标识', width: 80, sticky: 'right', render: row => row.id }, { key: 'action', header: '操作(固定)', width: 112, sticky: 'right', render: row => <Button variant="secondary" disabled={state === 'disabled'} onClick={() => setExpanded([...new Set([...expanded, row.id])])}>查看详情</Button> }]} />
71
+ <Pagination label="项目记录分页" page={page} pageSize={pageSize} total={unknown ? undefined : 28} hasNext={page < 3} disabled={state === 'disabled'} loading={state === 'loading'} showFirstButton showLastButton onPageChange={setPage} onPageSizeChange={size => { setPageSize(size); setPage(1); }} />
72
+ <p role="status">已选 {keys.length} 项:{keys.join(', ') || '无'}。排序意图:{sort ? `${sort.key} ${sort.direction}` : '无'};分页只回传意图。</p>
73
+ </Stack></Surface>
74
+ </section>;
75
+ }
76
+
77
+ export function MediaGallery() {
78
+ const [selected, setSelected] = useState(false), [open, setOpen] = useState(false), [index, setIndex] = useState(0), [metadata, setMetadata] = useState(true), [version, setVersion] = useState('original');
79
+ const titles = ['山野 · 午后的光', longName, '视频预览示例'];
80
+ const [retried, setRetried] = useState(false);
81
+ return <section><div className="ds-section-title"><span>MEDIA</span><h2>媒体保持比例,画布保持中性</h2></div><p className="ds-caption">照片、RAW、Live 和视频共享卡片契约。中性暗色查看器独立于三套界面主题;媒体请求、视频播放、下载与版本资源均由宿主提供。示例只使用本地内嵌矢量预览。</p>
82
+ <MediaViewer open={open} onOpenChange={setOpen} trigger={<Button>打开全屏查看器</Button>} title={titles[index]} description={`${index + 1} / 3 · 左右方向键切换;输入和播放器保留自身按键`} onPrevious={() => setIndex(current => Math.max(0, current - 1))} onNext={() => setIndex(current => Math.min(2, current + 1))} previousDisabled={index === 0} nextDisabled={index === 2} metadataOpen={metadata} onMetadataOpenChange={setMetadata}
83
+ actions={<><label>资源版本 <select aria-label="资源版本" value={version} onChange={event => setVersion(event.target.value)}><option value="original">原始资源</option><option value="preview">预览资源</option></select></label><a href={sampleImage} download="rhine-demo.svg">下载演示资源</a></>}
84
+ metadata={<><h3>媒体信息</h3><p>{titles[index]}</p><p>类型:{index === 2 ? '视频' : '照片'}</p><p>版本:{version}</p><p>日期:未知</p><Dialog title="资源说明" description="Escape 先关闭此层,再关闭媒体查看器。" trigger={<button type="button">打开资源说明</button>}><p>业务元数据由宿主管理。</p></Dialog></>}
85
+ >{index === 2 ? <video controls poster={sampleImage} aria-label="视频播放器示例" /> : <img src={sampleImage} alt="山峰与太阳构成的演示插画" />}</MediaViewer>
86
+ <div className="ds-extension-grid ds-gap-top">
87
+ <MediaCard title={titles[0]} kind="photo" date="2026-10-23" preview={<img src={sampleImage} alt="山野插画" />} onOpen={() => { setIndex(0); setOpen(true); }} selected={selected} onSelectedChange={setSelected} />
88
+ <MediaCard title={titles[1]} kind="RAW" preview={<img src={sampleImage} alt="原始预览" />} aspectRatio="3 / 2" onOpen={() => { setIndex(1); setOpen(true); }} />
89
+ <MediaCard title="视频,保持原始比例" kind="video" preview={<img src={sampleImage} alt="视频封面" />} onOpen={() => { setIndex(2); setOpen(true); }} />
90
+ <MediaCard title="加载中的 Live 资源" kind="Live" state="loading" onOpen={() => setOpen(true)} />
91
+ <MediaCard title="不可解码的文件" kind="RAW" state={retried ? 'ready' : 'error'} preview={retried ? <img src={sampleImage} alt="重试后恢复的示例预览" /> : undefined} onOpen={() => { setIndex(1); setOpen(true); }} actions={<Button variant="secondary" onClick={() => setRetried(true)} disabled={retried}>{retried ? '预览已恢复' : '重试预览'}</Button>} />
92
+ <MediaCard title="尚无预览的资源" kind="photo" state="empty" onOpen={() => setOpen(true)} />
93
+ </div>
94
+ </section>;
95
+ }
@@ -0,0 +1,107 @@
1
+ import { useEffect, useRef, useState, type ReactNode } from 'react';
2
+ import { Breadcrumbs, Button, Logo, SegmentedControl, Select, SideNav } from '../components';
3
+ import { ThemeProvider, themes, useTheme, type ThemeName } from '../components/theme';
4
+ import './shell.css';
5
+ import './theme-arknights.css';
6
+ import './theme-neo-brutalism.css';
7
+
8
+ export const galleryGroups = [
9
+ { label: '开始', items: [['top', '设计总览'], ['subscriptions', '订阅管理示例']] },
10
+ { label: '应用能力', items: [['app-shell', '背景与多侧栏'], ['advanced-forms', '复杂表单'], ['advanced-data', '受控数据表'], ['media', '媒体卡片与查看器'], ['monitoring', '健康详情与图表']] },
11
+ { label: '设计语言', items: [['foundation', '视觉基础'], ['backgrounds', '场景背景'], ['spacing', '间距与组合'], ['typography', '字体与文字'], ['brand', '品牌标志']] },
12
+ { label: '通用组件', items: [['actions', '按钮与输入'], ['selection', '选择与切换'], ['feedback', '状态与加载'], ['disclosure', '展开与组合'], ['data', '数据与分页'], ['forms', '表单与选择'], ['utilities', '操作与布局'], ['overlays', '浮层与反馈'], ['navigation', '导航与步骤'], ['form-workflow', '表单流程']] },
13
+ { label: '交互与动效', items: [['patterns', '场景与交互指南'], ['motion', '动效实验室'], ['startup', '启动动画']] },
14
+ ] as const;
15
+ export type GallerySection = typeof galleryGroups[number]['items'][number][0];
16
+ const entries = galleryGroups.flatMap(group => [...group.items]);
17
+ function currentSection(): GallerySection {
18
+ const hash = window.location.hash.slice(1);
19
+ return entries.find(([id]) => id === hash)?.[0] ?? 'top';
20
+ }
21
+
22
+ interface GalleryShellProps {
23
+ motion: string; onMotionChange: (value: string) => void;
24
+ children: (section: GallerySection) => ReactNode;
25
+ }
26
+
27
+ function initialTheme(): ThemeName {
28
+ let stored: string | null = null;
29
+ try { stored = window.localStorage.getItem('rhine-preview-theme'); } catch { /* Storage is optional in the host. */ }
30
+ const requested = new URLSearchParams(window.location.search).get('theme');
31
+ return themes.find(item => item.name === requested)?.name
32
+ ?? themes.find(item => item.name === stored)?.name ?? 'rhine';
33
+ }
34
+
35
+ export function GalleryShell(props: GalleryShellProps) {
36
+ const [theme, setTheme] = useState(initialTheme);
37
+ useEffect(() => {
38
+ try { window.localStorage.setItem('rhine-preview-theme', theme); } catch { /* Keep switching available without storage. */ }
39
+ }, [theme]);
40
+ return <ThemeProvider theme={theme} onThemeChange={next => {
41
+ setTheme(next);
42
+ const url = new URL(window.location.href);
43
+ url.searchParams.delete('theme');
44
+ window.history.replaceState(null, '', url);
45
+ }} className="ds-shell" data-rhine-motion={props.motion}>
46
+ <GalleryContent {...props} />
47
+ </ThemeProvider>;
48
+ }
49
+
50
+ function GalleryContent({ motion, onMotionChange, children }: GalleryShellProps) {
51
+ const { theme, setTheme } = useTheme();
52
+ const [section, setSection] = useState(currentSection);
53
+ const [menuOpen, setMenuOpen] = useState(false);
54
+ const main = useRef<HTMLElement>(null);
55
+ const first = useRef(true);
56
+ const label = entries.find(([id]) => id === section)![1];
57
+ useEffect(() => {
58
+ const navigate = () => { setSection(currentSection()); setMenuOpen(false); };
59
+ window.addEventListener('hashchange', navigate);
60
+ return () => window.removeEventListener('hashchange', navigate);
61
+ }, []);
62
+ useEffect(() => {
63
+ document.title = `${label} · 莱茵设计`;
64
+ if (main.current) main.current.scrollTop = 0;
65
+ if (!first.current) main.current?.focus({ preventScroll: true });
66
+ first.current = false;
67
+ }, [section, label]);
68
+ useEffect(() => {
69
+ const nav = document.getElementById('gallery-navigation');
70
+ const current = nav?.querySelector<HTMLElement>('[aria-current=page]');
71
+ if (!nav || !current || !nav.clientHeight) return;
72
+ const bounds = nav.getBoundingClientRect(), item = current.getBoundingClientRect();
73
+ // Reveal deep-linked chapters inside the directory without scrolling the workspace.
74
+ if (item.top < bounds.top) nav.scrollTop += item.top - bounds.top;
75
+ else if (item.bottom > bounds.bottom) nav.scrollTop += item.bottom - bounds.bottom;
76
+ }, [section, menuOpen]);
77
+ const [now, setNow] = useState(() => new Date());
78
+ useEffect(() => {
79
+ if (theme !== 'arknights') return;
80
+ const timer = setInterval(() => setNow(new Date()), 1000);
81
+ return () => clearInterval(timer);
82
+ }, [theme]);
83
+ const utcOffset = -now.getTimezoneOffset() / 60;
84
+ const readout = `LOCAL ${now.toLocaleTimeString('zh-CN', { hour12: false })} // UTC${utcOffset >= 0 ? '+' : ''}${utcOffset}`;
85
+ return <>
86
+ <a className="ds-skip" href="#gallery-content" onClick={event => { event.preventDefault(); main.current?.focus(); }}>跳到内容</a>
87
+ <div className="ak-hud" aria-hidden="true"><i /><i /><i /><i /></div>
88
+ <aside className="ds-sidebar" data-open={menuOpen} onKeyDown={event => {
89
+ if (event.key === 'Escape' && menuOpen) { setMenuOpen(false); event.currentTarget.querySelector<HTMLButtonElement>('.ds-menu-toggle')?.focus(); }
90
+ }}>
91
+ <div className="ds-sidebar-brand"><a href="#top" aria-label="Rhine Design · 设计总览"><Logo size={32} /><span>莱茵设计<small>RHINE DESIGN</small></span></a>
92
+ <Button className="ds-menu-toggle" variant="secondary" aria-expanded={menuOpen} aria-controls="gallery-navigation" onClick={() => setMenuOpen(value => !value)}>目录</Button>
93
+ </div>
94
+ <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
+ setMenuOpen(false); if(section===item.id) main.current?.focus({preventScroll:true});
96
+ }}/>
97
+ <div className="ds-sidebar-foot"><span>COMPONENT LIBRARY</span><span>59 个组件 · 持续演进</span><span className="ak-readout">{readout}</span></div>
98
+ </aside>
99
+ <main className="ds-workspace" id="gallery-content" ref={main} tabIndex={-1} aria-label={label}>
100
+ <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 => {
101
+ const next = themes.find(item => item.name === event.target.value);
102
+ if (next) setTheme(next.name);
103
+ }}>{themes.map(item => <option key={item.name} value={item.name}>{item.label}</option>)}</Select></div></header>
104
+ <div className="ds-gallery ds-page" key={section}>{children(section)}<footer className="ds-footer"><span>Rhine Design · 通用设计组件</span><span>演示数据,不执行业务操作。</span></footer></div>
105
+ </main>
106
+ </>;
107
+ }
@@ -0,0 +1,27 @@
1
+ import { useState } from 'react';
2
+ import { Checkbox, Grid, Stack, Surface, Text, type TextVariant } from '../components';
3
+ import type { CSSProperties } from 'react';
4
+ import './line-height.css';
5
+
6
+ const samples: {variant: TextVariant; label: string; size: string; leading: string; copy: string}[] = [
7
+ {variant:'heading',label:'卡片标题',size:'16 / 24px',leading:'heading',copy:'让每一行标题保持清晰,让多行内容自然延续。'},
8
+ {variant:'body',label:'正文',size:'14 / 22px',leading:'body',copy:'使用统一的文字节奏组织内容。说明自然换行,保留中英文 API 和数字 128 的阅读空间,不通过压缩行高限制卡片高度。'},
9
+ {variant:'caption',label:'辅助说明',size:'12 / 18px',leading:'caption',copy:'最近检查 14:32 · 示例数据。长说明允许换行,时间与来源仍保持可读,重要信息不使用省略号隐藏。'},
10
+ {variant:'code',label:'代码与路径',size:'12 / 20px',leading:'code',copy:'workspace/example-project/configuration/very-long-resource-identifier-for-line-wrapping.json'},
11
+ ];
12
+
13
+ export function LineHeightGallery() {
14
+ const [guides,setGuides]=useState(true);
15
+ return <div className="ds-gap-top"><Stack gap={6}>
16
+ <Stack gap={2}><Text as="h3" variant="section">多行行高与段落</Text><Text tone="muted">行高控制每一行的高度,gap控制内容组之间的距离。辅助线标出行框,不是字体基线。</Text></Stack>
17
+ <Checkbox label="显示行框辅助线" checked={guides} onChange={event=>setGuides(event.target.checked)}/>
18
+ <Grid gap={6} minWidth={240}>{samples.map(sample=><Surface key={sample.variant}><Stack gap={4}>
19
+ <Stack gap={2}><Text as="h4" variant="heading">{sample.label}</Text><Text variant="code" tone="muted">{sample.size} · --rhine-leading-{sample.leading}</Text></Stack>
20
+ <Text variant={sample.variant} className="ds-leading-sample" data-guides={guides} style={{'--ds-leading':`var(--rhine-leading-${sample.leading})`} as CSSProperties}>{sample.copy}</Text>
21
+ </Stack></Surface>)}</Grid>
22
+ <Surface><Stack gap={4}><Text as="h4" variant="heading">段内22px,段间12px</Text><Stack gap={3} className="ds-leading-paragraphs">
23
+ <Text className="ds-leading-sample" data-guides={guides} style={{'--ds-leading':'var(--rhine-leading-body)'} as CSSProperties}>第一段:文本自然换行,每行高度为22px。文字越长,段落越高,不添加额外的行间gap。</Text>
24
+ <Text className="ds-leading-sample" data-guides={guides} style={{'--ds-leading':'var(--rhine-leading-body)'} as CSSProperties}>第二段:这是独立的信息段,由Stack gap=3提供12px段落间距,不使用空行或br撑开。</Text>
25
+ </Stack></Stack></Surface>
26
+ </Stack></div>;
27
+ }
@@ -0,0 +1,27 @@
1
+ import { useState } from 'react';
2
+ import { Select, Stack, StackedBar, StatusTimeline, Surface, TimelineLegend, TrendChart, type ChartPoint, type TimelineSegment } from '../components';
3
+
4
+ export function MonitoringGallery() {
5
+ const [state, setState] = useState('ready');
6
+ const [activePoint, setActivePoint] = useState<string | null>(null);
7
+ const points: ChartPoint[] = state === 'empty' ? [] : [12, 18, null, 0, 24, 16].map((value, index) => ({ id: String(index), label: `10:${String(index * 10).padStart(2, '0')}`, value }));
8
+ const segments: TimelineSegment[] = state === 'empty' ? [] : ['healthy', 'failed', 'unknown', 'recovered'].map((status, index) => ({
9
+ id: String(index), label: `时段 ${index + 1}`, status: status as TimelineSegment['status'], detail: {
10
+ start: `2026-10-23 10:${String(index * 10).padStart(2, '0')} UTC+8`, end: `2026-10-23 10:${String((index + 1) * 10).padStart(2, '0')} UTC+8`,
11
+ reason: status === 'unknown' ? '此时段没有完整采集证据,不能视为健康。' : status === 'failed' ? '存在宿主确认的最终失败。' : '宿主提供的状态原因,组件不计算健康评分。',
12
+ counts: [{ id: 'success', label: '确认输出', value: status === 'unknown' ? null : 12, unit: '次' }, { id: 'failure', label: '最终失败', value: status === 'failed' ? 2 : 0, unit: '次' }, { id: 'pending', label: '待确认', value: null }],
13
+ methodology: '按请求最终结果归类;重试已恢复与最终失败分开计数,缺失采样保持未知。',
14
+ },
15
+ }));
16
+ const feedback = { stale: state === 'stale' ? '最后采集于 20 分钟前,请核对来源。' : undefined, error: state === 'error' ? '本次读取失败,保留上次采样。' : undefined };
17
+ return <section><div className="ds-section-title"><span>MONITORING</span><h2>看见证据,也看见缺口</h2></div>
18
+ <p className="ds-caption">计数与口径由宿主提供。未知保持未知,折线在缺失处断开;图例与数据表提供文字替代,不生成健康评分。时段详情支持鼠标、键盘、触屏和 Escape,并自动避让视口。</p>
19
+ <Select label="监控演示状态" value={state} onChange={event => setState(event.target.value)}><option value="ready">默认 / 含缺失采样</option><option value="empty">空数据</option><option value="stale">陈旧数据</option><option value="error">错误 / 保留旧数据</option><option value="unknown">占比总量未知</option></Select>
20
+ <Surface className="ds-gap-top"><Stack gap={4}><h3>结构化时段详情</h3><StatusTimeline label="结构化健康历史" startLabel="10:00 UTC+8" endLabel="10:40 UTC+8" segments={segments} /><TimelineLegend /><p>方向键与 Home/End 切换时段;轻触健康条查看同一份完整详情。</p><StatusTimeline compact label="紧凑结构化历史" startLabel="10:00" endLabel="10:40" segments={segments} /></Stack></Surface>
21
+ <div className="ds-extension-grid">
22
+ <Surface><TrendChart label="请求量趋势" unit="次" rangeLabel="2026-10-23 10:00–11:00 UTC+8" description="每 10 分钟一个桶;10:20 未采集,10:30 已确认是零。左右键或轻触可查看,并同步右侧图表时段。" points={points} activePointId={activePoint} onActivePointChange={setActivePoint} valueFormatter={value => value === null ? '未知' : value.toLocaleString('zh-CN')} {...feedback} /></Surface>
23
+ <Surface><TrendChart kind="line" label="资源使用历史" unit="%" rangeLabel="2026-10-23 10:00–11:00 UTC+8" description="宿主采集的资源占用;不插值,不跨越缺失点连线。两个图表按时段 ID 同步查看。" points={points} activePointId={activePoint} onActivePointChange={setActivePoint} {...feedback} /></Surface>
24
+ <Surface><StackedBar label="请求结果分布" unit="次" rangeLabel="2026-10-23 10:00–11:00 UTC+8" description="按宿主互斥分类的计数绘图;包含未知计数时不归一化。" parts={state === 'empty' ? [] : [{ id: 'complete', label: '已完成', value: 32, tone: 1 }, { id: 'retry', label: '重试恢复', value: 8, tone: 3 }, { id: 'failed', label: '最终失败', value: state === 'unknown' ? null : 4, tone: 2 }]} {...feedback} /></Surface>
25
+ </div>
26
+ </section>;
27
+ }
@@ -1,5 +1,5 @@
1
1
  import { useEffect, useRef, useState } from 'react';
2
- import { Badge, Button, Checkbox, Dialog, Disclosure, Icon, Logo, Menu, Popover, SegmentedControl, Select, Skeleton, Stack, Switch, Tabs, Text, Toast, Tooltip } from '../components/index';
2
+ import { Badge, Button, Checkbox, Dialog, Drawer, Disclosure, Icon, Logo, Menu, Popover, SegmentedControl, Select, Skeleton, Stack, Steps, Switch, Tabs, Text, TextField, Toast, Tooltip } from '../components/index';
3
3
  import './motion.css';
4
4
  import './brand-assembly.css';
5
5
 
@@ -13,6 +13,8 @@ const recipes = [
13
13
  ['selection', '切换 · 稳定的位置', '120 / 180ms', '默认选项 → 指示与滑块切换 → 保留内容', '现有组件'],
14
14
  ['toast', '通知 · 轻量回应', '180ms', '静态占位 → 通知进入 → 手动关闭', '现有组件'],
15
15
  ['tooltip', '提示 · 即看即走', '160ms', '播放或悬停 → 提示出现 → Escape关闭', '现有组件'],
16
+ ['drawer', '抽屉 · 保留上下文', '160ms / 3px', '入口 → 右侧详情 → Escape 返回入口', '现有组件'],
17
+ ['steps', '步骤 · 验证后推进', '180ms / 3px', '模拟本步完成 → 下一步进入 → 保持静止', '现有组件'],
16
18
  ['brand', '品牌 · 旋转变形归位', '880ms / 三块错峰', '三块旋转压缩 → 展开闭合 → 定稿标志静止', '品牌候选'],
17
19
  ] as const;
18
20
  type Kind = typeof recipes[number][0];
@@ -58,6 +60,8 @@ function Scene({ kind, active, side }: { kind: Kind; active: boolean; side: Side
58
60
  if (kind === 'result') return <div className="ds-result-demo"><Button disabled={!started || done} busy={started && !done} busyLabel="演示处理中">保存示例</Button><div className="ds-result-slot" role="status">{done ? <span className="ds-result-reveal"><Icon name="check" /> 已完成 · 演示</span> : <Text variant="caption" tone="muted">{started ? '等待合成结果…' : '等待播放'}</Text>}</div></div>;
59
61
  if (kind === 'popover') return <Popover open={open} onOpenChange={setOpen} side={preferredSide} title="方向感知气泡" trigger={<Button variant="secondary">查看气泡</Button>}><Text>方向跟随实际位置。窄窗左右方向改为上下展开。</Text></Popover>;
60
62
  if (kind === 'menu') return <Menu open={open} onOpenChange={setOpen} side={preferredSide} label="动效菜单" trigger={<Button variant="secondary">打开动效菜单</Button>} items={[{id:'view',label:'查看示例',onSelect:()=>{}},{id:'disabled',label:'暂不可用',disabled:true,onSelect:()=>{}},{id:'close',label:'结束演示',onSelect:()=>{}}]} />;
63
+ if (kind === 'steps') return <Steps current={started ? 1 : 0} items={[{id:'input',label:'输入信息'},{id:'verify',label:'核对配置'},{id:'done',label:'完成任务'}]}/>;
64
+ if (kind === 'drawer') return <Drawer open={open} onOpenChange={setOpen} title="抽屉动效示例" description="详情由右侧轻入;重播只重启动画,输入和焦点不变。" trigger={<Button variant="secondary">打开动效抽屉</Button>}><Stack><Text>按 Escape 关闭后回到入口。</Text><TextField label="重播保留输入" placeholder="输入演示文字后重播"/><Button variant="secondary" onClick={event=>replayModal(event.currentTarget)}>重播抽屉入场</Button></Stack></Drawer>;
61
65
  if (kind === 'dialog') return <Dialog open={open} onOpenChange={setOpen} title="弹窗动效示例" description="文字保持原尺寸;重播仅重新播放淡入,不重建弹窗或移动焦点。" trigger={<Button variant="secondary">打开动效弹窗</Button>}><Stack><Text>按 Escape 关闭,焦点回到入口。</Text><Button variant="secondary" onClick={event=>replayModal(event.currentTarget)}>重播弹窗入场</Button></Stack></Dialog>;
62
66
  if (kind === 'disclosure') return <div ref={disclosure} className="ds-motion-fill"><Disclosure title="展开动效说明"><Text>内容沿高度展开,文字保持原尺寸。</Text><Text tone="muted" variant="caption">反复点击标题可观察中途反向。</Text></Disclosure></div>;
63
67
  if (kind === 'selection') return <Stack className="ds-motion-fill"><Switch label="显示辅助信息" checked={enabled} onCheckedChange={setEnabled}/><Tabs label="动效切换示例" value={selected} onValueChange={setSelected} items={[{value:'before',label:'概览',content:<Text>默认阅读内容。</Text>},{value:'after',label:'详情',content:<Text>选中状态即时更新,内容轻轻进入。</Text>}]} /></Stack>;
@@ -83,7 +87,7 @@ function MotionCard({ recipe, slow, reduced }: { recipe: typeof recipes[number];
83
87
  export function MotionGallery({ reduced }: { reduced: boolean }) {
84
88
  const [speed, setSpeed] = useState('normal');
85
89
  return <section id="motion" className="ds-motion-lab"><div className="ds-section-title"><span>MOTION LAB</span><h2>按下播放,感受变化。</h2></div>
86
- <div className="ds-motion-toolbar"><div><Text>10 组可重播示例 · 现有组件与新动效候选</Text><Text variant="caption" tone="muted">播放只改变本地合成状态。浮层按 Escape 关闭;慢放只延长视觉过渡,减少动态始终优先。</Text></div><SegmentedControl label="动效播放速度" value={speed} onValueChange={setSpeed} items={[{value:'normal',label:'原速'},{value:'slow',label:'4 倍慢放'}]}/></div>
90
+ <div className="ds-motion-toolbar"><div><Text>12 组可重播示例 · 现有组件与新动效候选</Text><Text variant="caption" tone="muted">播放只改变本地合成状态。浮层按 Escape 关闭;慢放只延长视觉过渡,减少动态始终优先。</Text></div><SegmentedControl label="动效播放速度" value={speed} onValueChange={setSpeed} items={[{value:'normal',label:'原速'},{value:'slow',label:'4 倍慢放'}]}/></div>
87
91
  <div className="ds-motion-grid">{recipes.map(recipe=><MotionCard key={recipe[0]} recipe={recipe} slow={speed==='slow'} reduced={reduced}/>)}</div>
88
92
  </section>;
89
93
  }
@@ -0,0 +1,52 @@
1
+ import { useId, useState } from 'react';
2
+ import { Dialog, Inline, StatusTimeline, TimelineLegend, Badge, Breadcrumbs, Button, Drawer, ErrorSummary, Fieldset, KeyValue, Metric, Notice, Select, SideNav, Stack, Steps, Surface, Text, TextField, type FieldError } from '../components';
3
+ import './patterns.css';
4
+
5
+ export const scenarios = [
6
+ ['navigation', '跨模块工作', 'SideNav + Breadcrumbs', '侧边栏切换工作区;面包屑表达父子层级。当前位置用 aria-current,切换后聚焦主内容。', '导航状态即时变化,内容 180ms / 3px 进入。', '同页少量内容用 Tabs;面包屑不表示操作历史。'],
7
+ ['tabs', '同一对象的不同视图', 'Tabs / SegmentedControl', 'Tabs 切换独立内容区域;SegmentedControl 改变一个视图的指标或模式。方向键操作,焦点留在选项。', '边界 120ms,面板进入 180ms。', '不要把筛选指标做成全局导航。'],
8
+ ['form', '复杂表单与纠错', 'Fieldset + TextField + ErrorSummary', '按任务分组,提交后同时显示字段错误与顶部摘要。摘要获得焦点,点问题回到字段;保留已填值。', '立即显示错误,不抖动、不滚动循环。', '单字段就地错误即可;不能只给 Toast。'],
9
+ ['steps', '分阶段配置', 'Steps + Fieldset', '当前步骤由业务校验推进;允许返回已完成步骤。步骤完成与真实结果一致。', '当前段 180ms 淡入 / 3px;不自动走步骤。', '未知耗时用 Progress 不确定态;步骤条不是百分比。'],
10
+ ['detail', '列表中查看记录', 'Drawer + KeyValue', '保持列表上下文,临时查看详情。焦点进入抽屉,Escape 关闭后返回入口。', '160ms / 3px 由右侧进入,关闭即时归还焦点。', '需要长期链接或大量编辑时使用独立详情页。'],
11
+ ['confirm', '不可逆操作', 'ConfirmDialog', '说明对象、影响和明确动词;默认焦点在取消。等待真实结果,失败保留上下文。', '160ms 淡入;等待期间保持尺寸,不延迟结果。', '普通保存不弹二次确认;可撤销操作优先撤销。'],
12
+ ['feedback', '任务结果与系统异常', 'Toast / Notice', '短暂成功用 Toast;字段或区域失败用就地 Notice;需要行动的错误持续显示。', '通知 180ms / 3px;悬停或聚焦暂停倒计时。', '不能让关键错误自动消失,也不抢占焦点。'],
13
+ ['load', '首次读取与重新请求', 'Skeleton / Progress / Button busy', '首次已知结构用骨架;提交用按钮 busy;刷新保留旧数据并标记更新状态。', '内容就绪 180ms 原位淡入;减少动态时静态占位。', '不伪造百分比,不清空用户正在看的内容。'],
14
+ ['search', '大列表搜索与选择', 'SearchField / Combobox + Pagination', '输入法完成后查询,防抖、取消过期请求;服务端返回结果和排序。加载时不可选旧结果。', '状态即时更新;结果区域 180ms,不逐条错峰。', '不能拉取全量数据后假装服务端搜索。'],
15
+ ['choice', '选项数量与选择规则', 'RadioGroup / Select / Checkbox / Switch', '少量互斥项用 Radio;较多互斥项用 Select;多选用 Checkbox;即时生效的二态偏好用 Switch。', '选择边界 120ms,开关保持固定位置。', '需要保存才生效的选项应说明保存规则;不伪装即时生效。'],
16
+ ['overlay', '局部说明与次级操作', 'Tooltip / Popover / Menu', 'Tooltip 是非必要补充;Popover 放可交互详情;Menu 提供命令。都应有键盘入口和关闭路径。', '160ms / 3px,按实际放置方向进入。', '必要说明常驻;不要把表单放进 Tooltip。'],
17
+ ['empty', '空白、无结果、无权限', 'EmptyState / Notice', '区分尚未创建、筛选无结果、读取失败与无权限;提供对应的创建、清除筛选或重试动作。', '静态为主,状态替换 180ms。', '失败不是空列表;无权限不能提供无效创建按钮。'],
18
+ ['monitor', '监控数据与告警', 'Metric + Badge + DataTable + Notice', '值、单位、时间范围和最后更新时间一起展示。告警须有文字、级别和处理入口,过期数据标明陈旧。', '更新值原位替换,不滚动数字、不闪烁告警。', '未知值显示 —;不要用 0 掩盖采集失败或只靠红绿色。'],
19
+ ['motion', '品牌与日常交互', 'Logo / 启动编排 / 基础过渡', '品牌动画只在启动或明确重播时出现;业务界面用几何边界和稳定位置建立识别。', '日常 120–240ms;品牌独立编排,减少动态直接到最终态。', '不把 Logo 当加载器,不因动画阻塞可用页面。'],
20
+ ] as const;
21
+
22
+ export function PatternGallery({ section }: { section: string }) {
23
+ const [step, setStep] = useState(0), [chapter, setChapter] = useState('overview');
24
+ const [choice, setChoice] = useState('navigation');
25
+ const [monitor, setMonitor] = useState('normal');
26
+ const [previewScope, setPreviewScope] = useState('primary');
27
+ const [name, setName] = useState(''), [endpoint, setEndpoint] = useState('');
28
+ const [attempt, setAttempt] = useState(0), [errors, setErrors] = useState<FieldError[]>([]), [saved, setSaved] = useState(false);
29
+ const id = useId(), nameId = `${id}-name`, endpointId = `${id}-endpoint`;
30
+ const scenario = scenarios.find(item => item[0] === choice)!;
31
+ if (section === 'navigation') return <section id="navigation"><div className="ds-section-title"><span>NAV</span><h2>去哪里,现在到哪一步。</h2></div><p className="ds-caption">导航表达空间层级,步骤表达任务顺序。当前位置始终有文字与边界提示。</p>
32
+ <Surface><Breadcrumbs items={[{label:'设计总览',href:'#top'},{label:'导航与步骤'}]} /><div className="ds-navigation-demo"><SideNav aria-label="工作区演示导航" currentId={chapter} groups={[{label:'工作区',items:[{id:'overview',label:'概览',href:'#navigation',icon:'chart'},{id:'records',label:'记录',href:'#navigation',icon:'connection'},{id:'settings',label:'设置',href:'#navigation',icon:'info'}]}]} onNavigate={(item,event) => {event.preventDefault(); setChapter(item.id);}}/><div className="ds-navigation-content"><Badge>SideNav</Badge><h3>{({overview:'概览',records:'记录',settings:'设置'})[chapter]}</h3><Text>20px线性图标辅助识别,文字始终可见。44px操作行、4px行间距,选中项以苔绿底和细边界标识,不使用外链箭头。图标可选;长名称自然换行。页面路由、数据和权限由业务提供。</Text></div></div></Surface>
33
+ <Surface className="ds-gap-top"><h3>Steps · 验证后推进</h3><Steps current={step} onStepChange={setStep} items={[{id:'scope',label:'选择范围',description:'确认任务边界'},{id:'setup',label:'完成配置',description:'校验必填信息'},{id:'review',label:'核对结果',description:'完成后才显示成功'}]}/><div className="ds-row ds-gap-top"><Button onClick={()=>setStep(value=>Math.min(3,value+1))} disabled={step===3}>模拟本步完成</Button><Button variant="secondary" onClick={()=>setStep(0)}>重置步骤</Button></div><p role="status" className="ds-caption">{step===3?'全部完成 · 合成演示':'未完成的步骤不能点击跳过;已完成步骤可返回。'}</p></Surface>
34
+ </section>;
35
+ if (section === 'form-workflow') return <section id="form-workflow"><div className="ds-section-title"><span>FORM</span><h2>让问题可定位,让输入被保留。</h2></div><p className="ds-caption">试着直接提交,再点击错误摘要回到字段。这里只做本地验证,不发送或保存数据。</p>
36
+ <Surface><form noValidate onSubmit={event => {
37
+ event.preventDefault(); const next: FieldError[]=[];
38
+ if (!name.trim()) next.push({fieldId:nameId,message:'请输入工作区名称'});
39
+ let validAddress=false;
40
+ try { const url=new URL(endpoint); validAddress=url.protocol==='https:' && !!url.hostname && !url.username && !url.password; } catch { /* Invalid address stays in the form for correction. */ }
41
+ if (!validAddress) next.push({fieldId:endpointId,message:'请输入有效的 HTTPS 服务地址'});
42
+ setErrors(next);setAttempt(value=>value+1);setSaved(next.length===0);
43
+ }}><Stack><ErrorSummary errors={errors} focusKey={attempt}/><Fieldset legend="工作区信息" hint="两项均为必填;失败后保留已输入内容。"><TextField id={nameId} label="工作区名称" required value={name} onChange={event=>{setName(event.target.value);setSaved(false);}} error={errors.find(error=>error.fieldId===nameId)?.message}/><TextField id={endpointId} label="服务地址" required type="url" value={endpoint} placeholder="https://example.com" onChange={event=>{setEndpoint(event.target.value);setSaved(false);}} error={errors.find(error=>error.fieldId===endpointId)?.message}/></Fieldset><div className="ds-row"><Button type="submit">验证配置</Button><Drawer trigger={<Button variant="secondary">查看填写说明</Button>} title="填写说明" description="临时帮助保留当前表单。关闭后回到入口,内容不会丢失。"><Stack><Text>名称用于识别工作区。服务地址必须以 HTTPS 开头。</Text><Notice title="仅为演示">不要填入密码、Token 或真实凭证。这里不发起网络请求。</Notice></Stack></Drawer></div>{saved && <Notice tone="success" title="配置格式通过">演示已完成,没有保存到服务端。</Notice>}</Stack></form></Surface>
44
+ <p className="ds-caption">Fieldset 保留原生分组语义;ErrorSummary 在失败提交后获得焦点;Drawer 继承弹窗的焦点约束与 Escape 关闭。</p>
45
+ </section>;
46
+ if (section !== 'patterns') return null;
47
+ return <section id="patterns"><div className="ds-section-title"><span>PLAYBOOK</span><h2>先选交互,再选组件。</h2></div><p className="ds-caption">14 个常见场景。按任务、信息持续时间和是否需要打断来选择,动效只解释状态变化。</p><Surface><Select label="我正在设计什么场景" value={choice} onChange={event=>setChoice(event.target.value)}>{scenarios.map(item=><option key={item[0]} value={item[0]}>{item[1]}</option>)}</Select><div className="ds-pattern-detail" key={choice}><Text variant="overline">推荐组合</Text><h3>{scenario[2]}</h3><dl><dt>交互规则</dt><dd>{scenario[3]}</dd><dt>动效规则</dt><dd>{scenario[4]}</dd><dt>避免误用</dt><dd>{scenario[5]}</dd></dl></div></Surface>
48
+ {choice==='monitor' && <Surface className="ds-gap-top"><Stack><Text variant="section">健康记录时间轴</Text><Text variant="caption">合成数据;键盘方向键、Home / End 或指针查看时段。更新不播放装饰动画。</Text><StatusTimeline label="示例历史时段" startLabel="昨天 15:30" endLabel="今天 15:30" segments={Array.from({length:96},(_,i)=>({id:String(i),label:`时段 ${i+1}`,status:monitor==='unknown'?'unknown':i%23===0?'failed':i%17===0?'degraded':i%11===0?'recovered':i%7===0?'unknown':'healthy'}))}/><TimelineLegend/><Dialog trigger={<Button variant="secondary">左右分栏预览</Button>} title="选择范围" description="左侧操作,右侧查看选择对应的证据。此演示不保存更改。" sidePanel={closeButton => <Stack><Inline role="group" aria-label="预览导航" style={{justifyContent:'space-between'}}><Text as="h3" variant="body">{previewScope==='primary'?'主要资源':'备用资源'} · 健康记录</Text>{closeButton}</Inline><StatusTimeline compact label="范围历史时段" startLabel="昨天" endLabel="今天" segments={Array.from({length:96},(_,i)=>({id:String(i),label:`时段 ${i+1}`,status:previewScope==='primary'?'healthy':'unknown'}))}/><TimelineLegend/></Stack>}><Select label="范围" value={previewScope} onChange={event=>setPreviewScope(event.target.value)}><option value="primary">主要资源</option><option value="backup">备用资源</option></Select></Dialog></Stack></Surface>}
49
+ {choice==='monitor' && <Surface className="ds-gap-top"><Stack><Select label="监控状态演示" value={monitor} onChange={event=>setMonitor(event.target.value)}><option value="normal">正常</option><option value="stale">数据陈旧</option><option value="unknown">首次采集失败</option></Select><div className="ds-row"><Metric label="请求成功率" value={monitor==='unknown'?null:'99.2'} unit="%" description="最近 5 分钟 · 合成数据"/><Badge tone={monitor==='normal'?'success':monitor==='stale'?'warning':'error'}>{monitor==='normal'?'采集正常':monitor==='stale'?'数据已过期':'暂无有效数据'}</Badge></div><KeyValue items={[{key:'time',label:'最后成功采集',value:monitor==='unknown'?null:'14:32:00 · 上海时区(示例)'},{key:'rule',label:'新鲜度规则',value:'超过 60 秒未成功采集即标为陈旧'}]}/>{monitor!=='normal' && <Notice tone={monitor==='stale'?'warning':'error'} title={monitor==='stale'?'显示上次结果,不代表当前状态':'未能获取指标'} action={<Drawer trigger={<Button variant="secondary">查看处理建议</Button>} title="采集恢复建议" description="这是一段监控交互示例,不连接真实系统。"><Text>检查采集器状态与数据源连接,恢复后重新读取。不要把未知值重置为零。</Text></Drawer>}>{monitor==='stale'?'本例模拟超过 60 秒未更新,请检查采集状态。':'首次读取失败,因此数值显示 —。'}</Notice>}</Stack></Surface>}
50
+ <div className="ds-overview-links ds-gap-top"><a href="#navigation"><strong>导航与步骤</strong><span>↗</span><p>侧边栏、层级位置、阶段进度。</p></a><a href="#form-workflow"><strong>表单纠错与抽屉</strong><span>↗</span><p>试一次失败提交,观察焦点与输入保留。</p></a><a href="#motion"><strong>动效实验室</strong><span>↗</span><p>播放、重播和减少动态对照。</p></a><a href="https://github.com/linhay/rhine-design/blob/main/docs/INTERACTION_PATTERNS.md"><strong>完整使用规范</strong><span>↗</span><p>组件选择、代码契约与参考来源。</p></a></div>
51
+ </section>;
52
+ }
@@ -0,0 +1,32 @@
1
+ import { useState } from 'react';
2
+ import { Button, Grid, Inline, Notice, Select, Stack, Surface, Text, type Spacing } from '../components';
3
+ import './spacing.css';
4
+
5
+ const scale: Spacing[] = [1, 2, 3, 4, 6, 8, 12];
6
+ export function SpacingGallery() {
7
+ const [state, setState] = useState('normal');
8
+ return <section id="spacing">
9
+ <div className="ds-section-title"><span>SPACE</span><h2>间距与组合 · 让关系可见</h2></div>
10
+ <Stack gap={6}>
11
+ <Text tone="muted">同组紧、组间松。间距使用已有 token;gap 的数字是后缀,例如 gap=4 对应16px。</Text>
12
+ <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>
13
+ <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>
14
+ <Grid gap={6} minWidth={280}>
15
+ <Stack gap={3}>
16
+ <Text variant="caption" tone="muted">24px 内边距 · 8px 标题/说明 · 16px 内容/操作</Text>
17
+ <Surface><Stack gap={4}>
18
+ <Stack gap={2}><Text as="h3" variant="heading">应用更新</Text><Text tone="muted">{state==='long'?'当前版本已是最新。应用会在后台检查新版本,准备好后通知你;未完成的工作会保留,请在方便时安装。':state==='loading'?'正在检查新版本…':state==='error'?'暂时无法确认版本状态。':'当前版本已是最新。'}</Text></Stack>
19
+ {state==='error'&&<Notice tone="error" title="检查更新失败">请确认网络连接后重试;已安装的版本仍可继续使用。</Notice>}
20
+ <Inline gap={2}><Button variant="secondary" busy={state==='loading'} busyLabel="正在检查" onClick={()=>setState('loading')}>{state==='error'?'重试检查':'检查更新'}</Button>{state==='loading'&&<Button variant="quiet" onClick={()=>setState('normal')}>结束演示</Button>}</Inline>
21
+ </Stack></Surface>
22
+ <Text variant="caption" tone="muted">按钮仅改变合成状态;选择上方状态或结束演示复位,不联网检查更新。</Text>
23
+ </Stack>
24
+ <Surface><Stack gap={4}>
25
+ <Stack gap={2}><Text as="h3" variant="heading">同一套组合,适用于不同内容</Text><Text tone="muted">换行只增加内容高度,不压缩间距。</Text></Stack>
26
+ <Stack gap={3}><Text>标题 + 说明:Stack gap=2</Text><Text>内容 + 操作:Stack gap=4</Text><Text>按钮组:Inline gap=2</Text><Text>卡片之间:Grid / Stack gap=6</Text></Stack>
27
+ <Text variant="caption" tone="muted">Surface只负责边界与内边距。避免父gap与子margin重复累加;不固定卡高,不为空白增加动效。</Text>
28
+ </Stack></Surface>
29
+ </Grid>
30
+ </Stack>
31
+ </section>;
32
+ }