@linhey/rhine-design 0.6.0 → 0.9.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 (110) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/README.md +19 -8
  3. package/components/Actions.tsx +18 -1
  4. package/components/AdvancedForms.tsx +38 -8
  5. package/components/AppShell.tsx +46 -17
  6. package/components/ListFilterBar.tsx +104 -0
  7. package/components/Media.tsx +7 -7
  8. package/components/NeutronStarBackdrop.tsx +43 -0
  9. package/components/OrbitBackdrop.tsx +11 -3
  10. package/components/Overlays.tsx +37 -8
  11. package/components/README.md +28 -3
  12. package/components/StatusTimeline.tsx +72 -6
  13. package/components/StatusTimelineList.tsx +38 -0
  14. package/components/ThemePicker.tsx +49 -0
  15. package/components/assets/orbit-bird.svg +578 -0
  16. package/components/assets/orbit-crystal.svg +11 -0
  17. package/components/assets/orbit-donut.svg +16 -0
  18. package/components/assets/theme-previews/arknights.png +0 -0
  19. package/components/assets/theme-previews/manifest.json +24 -0
  20. package/components/assets/theme-previews/neo-brutalism.png +0 -0
  21. package/components/assets/theme-previews/rhine.png +0 -0
  22. package/components/common.css +3 -3
  23. package/components/components.tsx +3 -3
  24. package/components/dialog-settings.css +9 -0
  25. package/components/extensions.css +8 -32
  26. package/components/index.ts +12 -5
  27. package/components/list-filter-bar.css +33 -0
  28. package/components/media-parity.css +55 -2
  29. package/components/neutron.css +53 -0
  30. package/components/neutronRenderer.ts +327 -0
  31. package/components/orbit.css +100 -1
  32. package/components/orbitBirdFlight.ts +34 -0
  33. package/components/orbitBirdGeometry.ts +158 -0
  34. package/components/orbitDonut.ts +134 -0
  35. package/components/orbitDonutThree.ts +208 -0
  36. package/components/orbitObjectMaterials.ts +76 -0
  37. package/components/orbitObjects.ts +71 -0
  38. package/components/orbitObjectsThree.ts +215 -0
  39. package/components/orbitProjection.ts +21 -0
  40. package/components/orbitRenderer.ts +163 -46
  41. package/components/styles.css +3 -1
  42. package/components/theme-neo-brutalism.css +3 -2
  43. package/components/theme-picker.css +17 -0
  44. package/components/theme.tsx +2 -8
  45. package/components/themeMetadata.ts +7 -0
  46. package/components/timeline.css +17 -6
  47. package/components/useFloatingScale.ts +96 -0
  48. package/docs/APPLICATION_COMPONENTS.md +52 -3
  49. package/docs/BACKGROUND_FILTER_VERIFICATION.md +12 -0
  50. package/docs/COMPACT_ACCOUNT_PATTERNS.md +70 -0
  51. package/docs/DATE_RANGE_FILTER.md +60 -0
  52. package/docs/DESIGN_SYSTEM.md +11 -0
  53. package/docs/FLOATING_OVERLAYS.md +15 -0
  54. package/docs/INTERACTION_PATTERNS.md +10 -2
  55. package/docs/LIST_FILTER_BAR.md +73 -0
  56. package/docs/NEUTRON_STAR_BACKDROP.md +122 -0
  57. package/docs/ORBIT_BACKDROP.md +61 -44
  58. package/docs/SETTINGS_DIALOG.md +21 -0
  59. package/docs/THEME_PICKER.md +26 -0
  60. package/docs/THEMING.md +2 -0
  61. package/docs/assets/compact-account-neo.png +0 -0
  62. package/docs/assets/neutron-star-original.png +0 -0
  63. package/docs/assets/neutron-star-theme.png +0 -0
  64. package/docs/assets/orbit-bird.png +0 -0
  65. package/docs/assets/orbit-crystal.png +0 -0
  66. package/docs/assets/orbit-strawberry.png +0 -0
  67. package/package.json +8 -3
  68. package/preview/BackgroundFiltersFixture.tsx +28 -0
  69. package/preview/CompactAccountGallery.tsx +178 -0
  70. package/preview/DateRangeFilterGallery.tsx +109 -0
  71. package/preview/ExtensionsGallery.tsx +27 -18
  72. package/preview/GalleryShell.tsx +3 -3
  73. package/preview/ListFilterBarGallery.tsx +170 -0
  74. package/preview/MonitoringGallery.tsx +2 -0
  75. package/preview/NeutronStarGallery.tsx +56 -0
  76. package/preview/OrbitGallery.tsx +49 -32
  77. package/preview/OverlayBoundsGallery.tsx +49 -0
  78. package/preview/SettingsDialogGallery.tsx +33 -0
  79. package/preview/ThemePickerGallery.tsx +24 -0
  80. package/preview/ThemeSample.tsx +14 -0
  81. package/preview/TimelineListGallery.tsx +36 -0
  82. package/preview/extensions.css +9 -0
  83. package/preview/main.tsx +19 -3
  84. package/preview/preview.css +23 -0
  85. package/preview/theme-sample.html +1 -0
  86. package/scripts/check-background-filters.js +58 -0
  87. package/scripts/check-background-motion.js +169 -0
  88. package/scripts/check-filter-native-date.js +47 -0
  89. package/scripts/check-overlay-bounds.js +76 -0
  90. package/scripts/check-settings-theme.js +88 -0
  91. package/skills/rhine-design/SKILL.md +3 -2
  92. package/skills/rhine-design/references/API_MAP.md +13 -2
  93. package/skills/rhine-design/references/TASK_MAP.md +8 -2
  94. package/skills/rhine-design/references/integration.md +1 -1
  95. package/tests/AppShellControls.test.tsx +82 -0
  96. package/tests/ButtonLink.test.tsx +94 -0
  97. package/tests/CompactAccountGallery.test.tsx +116 -0
  98. package/tests/DateNativeInput.test.tsx +147 -0
  99. package/tests/DateRangeFilterGallery.test.tsx +85 -0
  100. package/tests/ListFilterBar.test.tsx +198 -0
  101. package/tests/NeutronStarBackdrop.test.tsx +288 -0
  102. package/tests/NeutronStarGallery.test.tsx +62 -0
  103. package/tests/NeutronStarImport.test.tsx +15 -0
  104. package/tests/OrbitBackdrop.test.tsx +273 -9
  105. package/tests/OrbitDonutThree.test.tsx +91 -0
  106. package/tests/OrbitObjectsThree.test.tsx +164 -0
  107. package/tests/SettingsDialog.test.tsx +66 -0
  108. package/tests/StatusTimelineList.test.tsx +63 -0
  109. package/tests/ThemePicker.test.tsx +30 -0
  110. package/tests/TimelineLegend.test.tsx +60 -0
@@ -0,0 +1,49 @@
1
+ import { useEffect, useState } from 'react';
2
+ import { Button, DateRangePicker, Inline, Menu, Popover, Select, Stack, Text, TextField, Tooltip } from '../components';
3
+ import { ThemeProvider, themes, type ThemeName } from '../components/theme';
4
+
5
+ export function OverlayBoundsFixture() {
6
+ const query = new URLSearchParams(location.search);
7
+ const theme = query.get('theme') as ThemeName || 'rhine';
8
+ const zoom = Number(query.get('zoom')) || 1;
9
+ const scope = query.get('scope') || 'document';
10
+ const edge = query.get('edge') || 'bottom-right';
11
+ const side = (query.get('side') || 'top') as 'top' | 'bottom' | 'left' | 'right';
12
+ const [draft, setDraft] = useState('研究工作区');
13
+ const [range, setRange] = useState({ start: '', end: '' });
14
+ const [action, setAction] = useState('尚未操作');
15
+ useEffect(() => {
16
+ const previous = document.documentElement.style.zoom;
17
+ document.documentElement.style.zoom = scope === 'document' ? String(zoom) : '1';
18
+ return () => { document.documentElement.style.zoom = previous; };
19
+ }, [scope, zoom]);
20
+ const scale = scope === 'content' ? 1 : zoom;
21
+ return <ThemeProvider theme={theme} data-rhine-motion="reduced" style={{ zoom: scope === 'theme' ? zoom : 1 }}>
22
+ <div data-testid="overlay-bounds" style={{ display: 'grid', alignItems: edge.startsWith('top') ? 'start' : 'end', justifyItems: edge.endsWith('left') ? 'start' : 'end', padding: 12, width: `calc(100vw / ${scale})`, height: `calc(100dvh / ${scale})`, boxSizing: 'border-box' }}>
23
+ <Stack gap={2} style={{ maxWidth: '100%', zoom: scope === 'content' ? zoom : 1 }}>
24
+ <Popover title="工作区与日期详情" side={side} trigger={<Button variant="secondary">账号与日期</Button>}>
25
+ <Stack gap={3}><Text>研究工作区的长名称与说明,测试换行和完整阅读。</Text><Text>long-unbroken-account-address@example.invalid</Text>
26
+ <TextField label="草稿名称" value={draft} onChange={event => setDraft(event.target.value)} />
27
+ <DateRangePicker label="日期范围" value={range} onValueChange={setRange} />
28
+ <Text>内容超出可用高度时在浮层内滚动;缩放不清空草稿。</Text>
29
+ <Inline><Button variant="secondary" onClick={() => setAction('已取消')}>取消日期</Button><Button onClick={() => setAction('已应用')}>应用日期</Button></Inline>
30
+ </Stack>
31
+ </Popover>
32
+ <Menu label="对象操作" side={side} trigger={<Button variant="secondary">对象操作</Button>} items={['查看完整信息', '复制演示地址', '导出演示内容', '暂不可用', '最后一项操作'].map((label, i) => ({ id: String(i), label, disabled: i === 3, onSelect: () => setAction(label) }))} />
33
+ <Tooltip side={side} content="很长的辅助说明也应在可用范围内换行,保留用户需要的字体大小。"><Button variant="quiet">辅助说明</Button></Tooltip>
34
+ <Text role="status" variant="caption">{action}</Text>
35
+ </Stack>
36
+ </div>
37
+ </ThemeProvider>;
38
+ }
39
+
40
+ export function OverlayBoundsGallery() {
41
+ const [theme, setTheme] = useState('neo-brutalism'), [width, setWidth] = useState('400'), [zoom, setZoom] = useState('2');
42
+ const [scope, setScope] = useState('document'), [edge, setEdge] = useState('bottom-right'), [side, setSide] = useState('top');
43
+ const choices = (label: string, value: string, set: (value: string) => void, values: string[]) => <Select label={label} value={value} onChange={event => set(event.target.value)}>{values.map(item => <option key={item}>{item}</option>)}</Select>;
44
+ const params = new URLSearchParams({ fixture: 'overlay-bounds', theme, zoom, scope, edge, side });
45
+ return <Stack gap={4}><Text as="h2" variant="section">浮层缩放与边界</Text><Text tone="muted">独立文档验证 CSS zoom;它不等同于浏览器原生缩放。示例不访问业务数据。</Text>
46
+ <Inline>{choices('样机主题', theme, setTheme, themes.map(item => item.name))}{choices('样机宽度', width, setWidth, ['400', '520', '880'])}{choices('CSS zoom', zoom, setZoom, ['1', '2'])}{choices('缩放范围', scope, setScope, ['document', 'theme', 'content'])}{choices('锚点位置', edge, setEdge, ['top-left', 'top-right', 'bottom-left', 'bottom-right'])}{choices('优先方向', side, setSide, ['top', 'right', 'bottom', 'left'])}</Inline>
47
+ <iframe title="浮层边界样机" src={`?${params}`} style={{ width: Number(width), maxWidth: '100%', height: 758, border: '1px solid var(--rhine-line)', background: 'var(--rhine-bg)' }} />
48
+ </Stack>;
49
+ }
@@ -0,0 +1,33 @@
1
+ import { useEffect, useRef, useState } from 'react';
2
+ import { Button, Dialog, EmptyState, Notice, Select, SideNav, Skeleton, Stack, Text, TextField, Tooltip } from '../components';
3
+ import { ThemeProvider, type ThemeName } from '../components/theme';
4
+
5
+ export function SettingsDialogGallery() {
6
+ const [section, setSection] = useState('account'), [state, setState] = useState('normal');
7
+ const [draft, setDraft] = useState('研究工作区'), [saved, setSaved] = useState('研究工作区');
8
+ const [busy, setBusy] = useState(false), [message, setMessage] = useState('');
9
+ const [open, setOpen] = useState(false);
10
+ const draftEdited = useRef(false);
11
+ const save = async () => { const submitted = draft; draftEdited.current = false; setBusy(true); await new Promise(resolve => setTimeout(resolve, 300)); setSaved(submitted); if (!draftEdited.current) setDraft(submitted); setBusy(false); setMessage('已保存演示草稿'); };
12
+ return <Stack gap={4}><Text as="h2" variant="section">设置导航对话框</Text><Text>窄导航与宽任务内容。这里只操作合成数据;切换栏目不会改变宿主地址。</Text>
13
+ <TextField label="宿主查询" defaultValue="保留这条查询" />
14
+ <Dialog title="设置" description="管理演示账号与公开项目" trigger={<Button>打开设置</Button>} open={open} onOpenChange={next => { setOpen(next); if (next) { draftEdited.current = false; setDraft(saved); setMessage(''); } }}
15
+ navigation={<SideNav aria-label="设置导航" currentId={section} groups={[{ label: '偏好与管理', items: [{ id: 'account', label: '账号', href: '#account' }, { id: 'users', label: '用户管理与权限', href: '#users' }, { id: 'public', label: '公开项目', href: '#public' }] }]} onNavigate={(item, event) => { event.preventDefault(); setSection(item.id); }} />}>
16
+ <Stack gap={4}>
17
+ <Tooltip content="选择栏目以管理设置;保存后生效,取消仅丢弃未保存的修改。"><Button variant="quiet">设置帮助</Button></Tooltip>
18
+ {section === 'account' && <><Text as="h3" variant="heading">账号</Text><TextField label="工作区名称" value={draft} onChange={event => { draftEdited.current = true; setDraft(event.target.value); }} /><Button busy={busy} onClick={save}>保存设置</Button><Button variant="secondary" onClick={() => { draftEdited.current = false; setDraft(saved); setMessage('已撤销未保存修改'); }}>取消草稿</Button></>}
19
+ {section === 'users' && <><Text as="h3" variant="heading">用户管理</Text>{Array.from({length: 12}, (_, i) => <Stack key={i} gap={2}><Text>演示成员 {i + 1} · 长名称研究工作组</Text><Dialog trigger={<Button variant="secondary">编辑成员 {i + 1}</Button>} title={`编辑成员 ${i + 1}`} description="内层 Escape 只关闭当前编辑"><TextField label="成员备注" defaultValue="合成备注" /></Dialog></Stack>)}</>}
20
+ {section === 'public' && <><Text as="h3" variant="heading">公开项目</Text><Select label="项目状态" value={state} onChange={event => setState(event.target.value)}><option value="normal">正常</option><option value="loading">加载</option><option value="error">失败</option><option value="empty">空态</option></Select>{state === 'loading' ? <Skeleton label="正在读取项目" /> : state === 'error' ? <Notice tone="error" title="项目读取失败" action={<Button onClick={() => setState('normal')}>重试</Button>} /> : state === 'empty' ? <EmptyState title="暂无公开项目" description="添加项目后会显示在这里。" /> : <TextField label="项目标题" defaultValue="公开研究计划" />}</>}
21
+ {message && <Text role="status">{message}</Text>}
22
+ </Stack>
23
+ </Dialog>
24
+ <Text>已保存名称:{saved}</Text>
25
+ </Stack>;
26
+ }
27
+
28
+ export function SettingsDialogFixture() {
29
+ const params = new URLSearchParams(location.search);
30
+ const zoom = Number(params.get('zoom')) || 1;
31
+ useEffect(() => { document.documentElement.style.zoom = String(zoom); return () => { document.documentElement.style.zoom = ''; }; }, [zoom]);
32
+ return <ThemeProvider theme={(params.get('theme') || 'rhine') as ThemeName} data-rhine-motion="reduced"><div style={{padding:16}}><SettingsDialogGallery /></div></ThemeProvider>;
33
+ }
@@ -0,0 +1,24 @@
1
+ import { useEffect, useState } from 'react';
2
+ import { Button, Checkbox, Dialog, Stack, Text, TextField } from '../components';
3
+ import { ThemePicker } from '../components/ThemePicker';
4
+ import { ThemeProvider, themes, useTheme, type ThemeName } from '../components/theme';
5
+
6
+ export function ThemePickerGallery() {
7
+ const { theme, setTheme } = useTheme();
8
+ const [disabled, setDisabled] = useState(false), [longNames, setLongNames] = useState(false), [failed, setFailed] = useState(false);
9
+ const [draft, setDraft] = useState('切换主题时保留');
10
+ const options = themes.map(item => ({ value: item.name, label: longNames ? `${item.label} · 适合长时间阅读与资源管理的工作区外观` : item.label, previewSrc: failed ? '/missing-theme-preview.png' : undefined }));
11
+ return <Stack gap={4}><Text as="h2" variant="section">先比较,再应用</Text><Text tone="muted">预览来自同一组官方组件,保持各自的配色。选择即应用;偏好是否保存由宿主管理。</Text>
12
+ <ThemePicker value={theme} onValueChange={setTheme} options={options} disabled={disabled} />
13
+ <Stack gap={2}><Checkbox label="长主题名称" checked={longNames} onChange={event => setLongNames(event.target.checked)} /><Checkbox label="禁用主题选择" checked={disabled} onChange={event => setDisabled(event.target.checked)} /><Checkbox label="模拟预览失败" checked={failed} onChange={event => setFailed(event.target.checked)} /></Stack>
14
+ <TextField label="未保存草稿" value={draft} onChange={event => setDraft(event.target.value)} />
15
+ <Dialog trigger={<Button variant="secondary">弹窗内切换主题</Button>} title="外观设置" description="切换后当前弹窗与草稿保持打开"><Stack gap={4}><TextField label="弹窗草稿" defaultValue="继续编辑" /><ThemePicker value={theme} onValueChange={setTheme} /></Stack></Dialog>
16
+ </Stack>;
17
+ }
18
+
19
+ export function ThemePickerFixture() {
20
+ const params = new URLSearchParams(location.search);
21
+ const zoom = Number(params.get('zoom')) || 1;
22
+ useEffect(() => { document.documentElement.style.zoom = String(zoom); return () => { document.documentElement.style.zoom = ''; }; }, [zoom]);
23
+ return <ThemeProvider defaultTheme={(params.get('theme') || 'rhine') as ThemeName} data-rhine-motion="reduced"><div style={{padding:16}}><ThemePickerGallery /></div></ThemeProvider>;
24
+ }
@@ -0,0 +1,14 @@
1
+ import { Badge, Button, Inline, SideNav, Stack, Surface, Text } from '../components';
2
+ import { ThemeProvider, type ThemeName } from '../components/theme';
3
+
4
+ /** Synthetic, deterministic source for the shipped 960 × 600 theme previews. */
5
+ export function ThemeSample() {
6
+ const theme = (new URLSearchParams(location.search).get('theme') || 'rhine') as ThemeName;
7
+ return <ThemeProvider theme={theme} data-rhine-motion="reduced" style={{ boxSizing: 'border-box', width: 960, height: 600, padding: 32, display: 'grid', gridTemplateColumns: '200px minmax(0,1fr)', gap: 28 }}>
8
+ <SideNav aria-label="样本导航" currentId="library" groups={[{ label: '研究工作区', items: [{ id: 'library', label: '资源库', href: '#library' }, { id: 'collections', label: '收藏集', href: '#collections' }, { id: 'activity', label: '最近活动', href: '#activity' }] }]} />
9
+ <Stack gap={4}><Inline><Text as="h1" variant="title">让内容保持清晰</Text><Badge tone="success">已同步</Badge></Inline><Text tone="muted">相同的页面,比较每种主题的表面、文字与操作。</Text>
10
+ <Surface><Stack gap={4}><Text as="h2" variant="heading">本周研究计划</Text><Text>整理资料与发现,在需要时继续工作。</Text><Inline><Button>新建项目</Button><Button variant="secondary">查看详情</Button></Inline></Stack></Surface>
11
+ <div style={{display:'grid', gridTemplateColumns:'1fr 1fr', gap:16}}>{['设计与观察', '记录与归档'].map((title, i) => <Surface key={title}><Stack gap={3}><div aria-hidden="true" style={{height:80,background:'var(--rhine-selected)',border:'1px solid var(--rhine-line)',display:'grid',placeItems:'center'}}><span style={{fontSize:40,color:'var(--rhine-green)'}}>{i ? '◷' : '△'}</span></div><Text variant="heading">{title}</Text><Text variant="caption" tone="muted">12 项合成资料 · 随时继续</Text></Stack></Surface>)}</div>
12
+ </Stack>
13
+ </ThemeProvider>;
14
+ }
@@ -0,0 +1,36 @@
1
+ import { useState } from 'react';
2
+ import { Button, Dialog, Inline, Notice, Select, Skeleton, Stack, StatusTimelineList, Surface, Text, TimelineLegend, type StatusTimelineItem, type TimelineSegment } from '../components';
3
+
4
+ export function TimelineListGallery() {
5
+ const [state, setState] = useState('ready');
6
+ const segments: TimelineSegment[] = Array.from({ length: 96 }, (_, i) => ({
7
+ id: String(i), label: `10/23 ${String(Math.floor(i / 4)).padStart(2, '0')}:${String(i % 4 * 15).padStart(2, '0')} · UTC+8`,
8
+ status: i % 29 === 0 ? 'failed' : i % 17 === 0 ? 'degraded' : i % 13 === 0 ? 'recovered' : i % 7 === 0 ? 'unknown' : 'healthy',
9
+ }));
10
+ const items: StatusTimelineItem[] = state === 'empty' ? [] : ['资源 A', '资源 B'].map((title, i) => ({
11
+ id: String(i), title: state === 'long' ? `${title} · 跨区域协作工作空间 / very-long-resource-name-without-a-break-2026` : title,
12
+ summary: <>7 日剩余 <strong>{i ? '—' : '78%'}</strong></>,
13
+ note: state === 'partial' ? '记录不完整,仅展示已确认的时段。' : undefined,
14
+ timeline: state === 'unavailable' ? null : { startLabel: '10/23 00:00 · UTC+8', endLabel: '10/24 00:00 · UTC+8', segments, disabled: state === 'disabled' },
15
+ }));
16
+ const content = <Stack gap={3}>
17
+ {state === 'loading' ? <Skeleton label="正在读取历史记录" lines={3} /> : <>
18
+ {state === 'error' && <Notice tone="error" title="读取失败,保留上次记录">请重试或检查数据来源;旧记录不代表当前状态。</Notice>}
19
+ <StatusTimelineList label="资源历史记录" items={items} />
20
+ </>}
21
+ </Stack>;
22
+ return <Surface className="ds-gap-top"><Stack gap={4}>
23
+ <Text as="h3" variant="section">多条历史记录</Text>
24
+ <Text tone="muted">标题、摘要、分隔、紧凑时间条与详情统一组合;同一列表只展开一条详情。示例数据不连接任何服务。</Text>
25
+ <Select label="历史列表状态" value={state} onChange={event => setState(event.target.value)}>
26
+ <option value="ready">默认</option><option value="loading">首次加载</option><option value="error">读取失败 / 保留旧记录</option>
27
+ <option value="empty">空列表</option><option value="unavailable">历史未知</option><option value="partial">不完整</option>
28
+ <option value="disabled">禁用</option><option value="long">长名称</option>
29
+ </Select>
30
+ {content}<TimelineLegend />
31
+ <Dialog title="资源范围" description="左侧操作,右侧查看当前范围的记录;纯演示,不保存设置。" trigger={<Button variant="secondary">在分栏弹窗中查看</Button>}
32
+ sidePanel={closeButton => <Stack gap={4}><Inline style={{ justifyContent: 'space-between' }}><Text as="h3">资源 · {items.length}</Text>{closeButton}</Inline>{content}</Stack>}>
33
+ <Text>同一组件也可嵌入侧栏;在两条时间轴间悬停或按 Tab,检查详情互斥与焦点。</Text>
34
+ </Dialog>
35
+ </Stack></Surface>;
36
+ }
@@ -4,3 +4,12 @@
4
4
  .ds-extension-controls .rhine-select { width:auto; max-width:100%; }
5
5
  .ds-shell-stage { height:680px; max-height:85dvh; min-height:380px; min-width:0; }
6
6
  .ds-background-swatch { display:grid; gap:24px; padding:24px; border:1px solid var(--rhine-line); }
7
+
8
+ .ds-media-intro { max-width:58ch; color:var(--rhine-muted); margin:0 0 24px; line-height:var(--rhine-leading-body); }
9
+ .ds-media-toolbar { display:flex; align-items:center; justify-content:space-between; flex-wrap:wrap; gap:16px; padding:16px 0; margin-bottom:24px; border-block:1px solid var(--rhine-control); }
10
+ .ds-media-toolbar>span { font:var(--rhine-text-caption)/var(--rhine-leading-caption) var(--rhine-mono); }
11
+ .ds-media-grid { display:grid; grid-template-columns:repeat(auto-fit,minmax(min(100%,240px),1fr)); gap:24px; align-items:start; }
12
+ .ds-media-section-heading { margin-top:48px; margin-bottom:24px; }
13
+ .ds-media-section-heading h3 { font-size:var(--rhine-text-section); margin:0 0 8px; }
14
+ .ds-media-section-heading p { color:var(--rhine-muted); margin:0; }
15
+ .ds-media-note { margin-top:24px; }
package/preview/main.tsx CHANGED
@@ -1,7 +1,11 @@
1
+ import { BackgroundFiltersFixture } from './BackgroundFiltersFixture';
1
2
  import { StrictMode, useEffect, useRef, useState } from 'react';
2
3
  import { createRoot } from 'react-dom/client';
3
- import { Badge, Button, 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, Logo, Notice, Progress, SegmentedControl, Select, SelectionCard, Skeleton, Surface, Tabs, Text, TextField, Toolbar, type TextVariant } from '../components/index';
4
5
  import './preview.css';
6
+ import { OverlayBoundsFixture, OverlayBoundsGallery } from './OverlayBoundsGallery';
7
+ import { SettingsDialogGallery, SettingsDialogFixture } from './SettingsDialogGallery';
8
+ import { ThemePickerGallery, ThemePickerFixture } from './ThemePickerGallery';
5
9
  import { CommonGallery } from './CommonGallery';
6
10
  import { MotionGallery } from './MotionGallery';
7
11
  import { StartupGallery } from './StartupGallery';
@@ -12,8 +16,12 @@ import { LineHeightGallery } from './LineHeightGallery';
12
16
  import { SubscriptionGallery } from './SubscriptionGallery';
13
17
  import { BackgroundGallery } from './BackgroundGallery';
14
18
  import { OrbitGallery } from './OrbitGallery';
19
+ import { NeutronStarGallery } from './NeutronStarGallery';
15
20
  import { AdvancedDataGallery, AdvancedFormsGallery, MediaGallery, ShellGallery } from './ExtensionsGallery';
16
21
  import { MonitoringGallery } from './MonitoringGallery';
22
+ import { DateRangeFilterGallery } from './DateRangeFilterGallery';
23
+ import { ListFilterBarGallery } from './ListFilterBarGallery';
24
+ import { CompactAccountGallery } from './CompactAccountGallery';
17
25
 
18
26
  function Gallery() {
19
27
  const [motion, setMotion] = useState('system');
@@ -21,6 +29,7 @@ function Gallery() {
21
29
  const [previewPage, setPreviewPage] = useState('oauth');
22
30
  const [busy, setBusy] = useState(false);
23
31
  const [selected, setSelected] = useState(false);
32
+ const [showSelectionIndicator, setShowSelectionIndicator] = useState(false);
24
33
  const [tab, setTab] = useState('tokens');
25
34
  const [metric, setMetric] = useState('tokens');
26
35
  const [progress, setProgress] = useState<number | null>(38);
@@ -32,10 +41,17 @@ function Gallery() {
32
41
  useEffect(() => () => { if (timer.current) clearTimeout(timer.current); }, []);
33
42
  const simulate = () => { setBusy(true); timer.current = setTimeout(() => { setBusy(false); setNotice(true); }, 900); };
34
43
  return <GalleryShell motion={motion} onMotionChange={setMotion}>{section => <>
44
+ {section === 'theme-picker' && <ThemePickerGallery />}
45
+ {section === 'settings-dialog' && <SettingsDialogGallery />}
46
+ {section === 'overlay-bounds' && <OverlayBoundsGallery />}
35
47
  {section === 'monitoring' && <MonitoringGallery />}
48
+ {section === 'date-range-filter' && <DateRangeFilterGallery />}
49
+ {section === 'list-filter-bar' && <ListFilterBarGallery />}
50
+ {section === 'compact-account' && <CompactAccountGallery />}
36
51
  {section === 'backgrounds' && <BackgroundGallery />}
37
52
  {section === 'app-shell' && <ShellGallery />}
38
53
  {section === 'orbit' && <OrbitGallery />}
54
+ {section === 'neutron-star' && <NeutronStarGallery />}
39
55
  {section === 'advanced-forms' && <AdvancedFormsGallery />}
40
56
  {section === 'advanced-data' && <AdvancedDataGallery />}
41
57
  {section === 'media' && <MediaGallery />}
@@ -99,7 +115,7 @@ function Gallery() {
99
115
  </section>}
100
116
 
101
117
  {section === 'selection' && <section id="selection"><div className="ds-section-title"><span>03</span><h2>选择清楚,位置稳定</h2></div>
102
- <div className="ds-grid"><div><SelectionCard 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>
118
+ <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>
103
119
  <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>
104
120
  <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>
105
121
  </section>}
@@ -122,4 +138,4 @@ function Gallery() {
122
138
 
123
139
  }
124
140
 
125
- createRoot(document.getElementById('root')!).render(<StrictMode><Gallery /></StrictMode>);
141
+ createRoot(document.getElementById('root')!).render(<StrictMode>{['orbit-entry', 'list-filters'].includes(new URLSearchParams(location.search).get('fixture') || '') ? <BackgroundFiltersFixture /> : new URLSearchParams(location.search).get('fixture') === 'overlay-bounds' ? <OverlayBoundsFixture /> : new URLSearchParams(location.search).get('fixture') === 'settings-dialog' ? <SettingsDialogFixture /> : new URLSearchParams(location.search).get('fixture') === 'theme-picker' ? <ThemePickerFixture /> : <Gallery />}</StrictMode>);
@@ -1,4 +1,10 @@
1
1
  body { margin:0; }
2
+ .compact-gallery { padding-block:var(--rhine-space-6); }
3
+ .compact-gallery-stage { max-width:100%; min-width:0; }
4
+ .compact-gallery-examples { display:grid; grid-template-columns:repeat(auto-fit,minmax(min(100%,320px),1fr)); gap:var(--rhine-space-6); align-items:start; }
5
+ .compact-gallery-examples>* { min-width:0; }
6
+ .compact-gallery-fact-pair { align-items:baseline; }
7
+ .compact-gallery-fact-pair>* { min-width:0; overflow-wrap:anywhere; }
2
8
  .ds-type-row { display:grid; grid-template-columns:180px minmax(0,1fr); align-items:baseline; gap:12px 24px; padding:16px 0; border-bottom:1px solid var(--rhine-line); }
3
9
  .ds-type-row:last-child { border:0; }
4
10
  .ds-type-copy { display:grid; gap:12px; margin-top:16px; max-width:60ch; }
@@ -18,4 +24,21 @@ body { margin:0; }
18
24
  .ds-grid { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:24px; align-items:start; }.ds-grid>* { min-width:0; }.ds-component-title { display:flex; align-items:baseline; justify-content:space-between; gap:12px; flex-wrap:wrap; margin-bottom:20px; }.ds-component-title h3 { font:600 16px var(--rhine-mono); margin:0; }.ds-component-title>span { color:var(--rhine-muted); font-size:12px; }
19
25
  .ds-row { display:flex; flex-wrap:wrap; gap:12px; align-items:center; }.ds-caption { font-size:12px; line-height:1.8; color:var(--rhine-muted); }.ds-gap-top { margin-top:20px; }.ds-badges { margin-bottom:24px; }.ds-loaded { min-height:72px; margin:0; }
20
26
  .ds-footer { border-top:1px solid var(--rhine-ink); display:flex; justify-content:space-between; gap:16px; flex-wrap:wrap; padding:24px 0; color:var(--rhine-muted); font-size:12px; }
27
+ .ds-orbit-artwork { min-height:clamp(360px, 52vw, 560px); }
28
+ .ds-orbit-example-content { width:min(100%, 560px); }
29
+ .ds-orbit-layout-container { container-type:inline-size; }
30
+ .ds-orbit-layout-examples { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:var(--rhine-space-6); }
31
+ .ds-orbit-corner-example { min-height:620px; }
32
+ @container (max-width:760px) { .ds-orbit-layout-examples { grid-template-columns:1fr; } }
21
33
  @media(max-width:760px) { .ds-hero,.ds-grid { grid-template-columns:1fr; gap:24px; }.ds-hero { padding-top:32px; }.ds-hero h1 { font-size:32px; letter-spacing:-1px; }.ds-palette { grid-template-columns:repeat(auto-fit,minmax(min(100%,90px),1fr)); }.ds-palette>div>div { height:64px; }.ds-section-title h2 { font-size:19px; } }
34
+
35
+ .ds-orbit-entry-container { container-type:inline-size; min-width:0; }
36
+ .ds-orbit-entry { position:relative; isolation:isolate; display:grid; grid-template-columns:1fr 1fr; gap:var(--rhine-space-6); min-height:620px; padding:var(--rhine-space-6); }
37
+ .ds-orbit-entry-background { position:absolute; inset:0 auto 0 0; width:50%; z-index:-1; pointer-events:none; }
38
+ .ds-orbit-entry-intro, .ds-orbit-entry-form { min-width:0; align-self:start; }
39
+ .ds-orbit-entry-form { background:var(--rhine-surface); }
40
+ .ds-orbit-entry:has([data-rhine-orbit-appearance="original"]) .ds-orbit-entry-intro { color:#f2f7ff; }
41
+ @container (max-width:760px) {
42
+ .ds-orbit-entry { grid-template-columns:1fr; padding-bottom:300px; }
43
+ .ds-orbit-entry-background { width:100%; }
44
+ }
@@ -0,0 +1 @@
1
+ <!doctype html><html lang="zh-CN"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Theme preview source</title><style>body{margin:0}</style></head><body><div id="root"></div><script type="module">import React from 'react'; import {createRoot} from 'react-dom/client'; import {ThemeSample} from './ThemeSample'; createRoot(document.getElementById('root')).render(React.createElement(ThemeSample));</script></body></html>
@@ -0,0 +1,58 @@
1
+ // Run against a fixed production preview on 18484 with playwright-cli run-code --filename.
2
+ async page => {
3
+ const errors=[],failures=[]; let filterCases=0,orbitCases=0;
4
+ page.on('pageerror',e=>errors.push(String(e)));
5
+ const check=(ok,detail)=>{if(!ok) failures.push(detail);};
6
+ const horizontal=async root=>root.evaluate(e=>{const r=e.getBoundingClientRect();return r.left>=-1&&r.right<=innerWidth+1&&e.scrollWidth<=e.clientWidth+1;});
7
+ for(const theme of ['rhine','arknights','neo-brutalism']) for(const width of [400,520,880]) for(const zoom of [1,2]) {
8
+ await page.setViewportSize({width,height:1000});
9
+ await page.goto(`http://127.0.0.1:18484/?fixture=list-filters&theme=${theme}&zoom=${zoom}`);
10
+ const bars=page.locator('.rhine-list-filter-bar');
11
+ for(let i=0;i<3;i++) {
12
+ const bar=bars.nth(i), trigger=bar.getByRole('button',{name:/^更多筛选/});
13
+ if(i<2) check(await bar.locator('.rhine-list-filter-bar__search label').evaluate(e=>{const r=e.getBoundingClientRect();return r.width>20&&r.height>10;}),{theme,width,zoom,i,kind:'visible-search-label'});
14
+ check(await bar.getByRole('combobox').count()===2,{theme,width,zoom,i,kind:'primary-limit'});
15
+ await trigger.click(); const dialog=page.getByRole('dialog');
16
+ await dialog.evaluate(e=>Promise.all(e.getAnimations().map(a=>a.finished)));
17
+ check(await horizontal(dialog),{theme,width,zoom,i,kind:'drawer-overflow'});
18
+ if(i<2) await dialog.getByRole('checkbox',{name:'只看已审查'}).check();
19
+ else await dialog.getByRole('combobox',{name:'统计范围'}).selectOption({label:'示例团队 / 跨部门体验与可访问性协作小组'});
20
+ await page.keyboard.press('Escape'); await dialog.waitFor({state:'detached'});
21
+ await page.waitForFunction(e=>e===document.activeElement,await trigger.elementHandle());
22
+ check(await trigger.evaluate(e=>e===document.activeElement),{theme,width,zoom,i,kind:'return-focus'});
23
+ check(await horizontal(bar),{theme,width,zoom,i,kind:'summary-overflow'});
24
+ const chip=bar.getByRole('button',{name:/^清除/}).first(); await chip.click();
25
+ check(await bar.evaluate(e=>e.contains(document.activeElement)),{theme,width,zoom,i,kind:'clear-focus'});
26
+ await page.getByRole('combobox',{name:'示例状态'}).selectOption('loading');
27
+ await trigger.click();
28
+ check(await dialog.getByRole('status').textContent().then(t=>t.includes('正在更新结果')),{theme,width,zoom,i,kind:'pending'});
29
+ await page.keyboard.press('Escape'); await dialog.waitFor({state:'detached'});
30
+ await page.getByRole('combobox',{name:'示例状态'}).selectOption('error');
31
+ await trigger.click(); check(await dialog.getByText('未能更新结果',{exact:true}).isVisible(),{theme,width,zoom,i,kind:'error'});
32
+ await page.keyboard.press('Escape'); await dialog.waitFor({state:'detached'});
33
+ await page.getByRole('combobox',{name:'示例状态'}).selectOption('empty');
34
+ check(await bar.getByRole('status').textContent().then(t=>/0 条|无用量/.test(t)),{theme,width,zoom,i,kind:'empty'});
35
+ await page.getByRole('combobox',{name:'示例状态'}).selectOption('disabled');
36
+ check(await bar.getByRole('button',{name:'刷新',exact:true}).isDisabled(),{theme,width,zoom,i,kind:'disabled'});
37
+ await page.getByRole('combobox',{name:'示例状态'}).selectOption('normal');
38
+ filterCases++;
39
+ }
40
+ }
41
+ for(const theme of ['rhine','arknights','neo-brutalism']) for(const width of [400,520,880,1280]) for(const zoom of [1,2]) {
42
+ await page.setViewportSize({width,height:1000});
43
+ await page.goto(`http://127.0.0.1:18484/?fixture=orbit-entry&theme=${theme}&zoom=${zoom}`);
44
+ const input=page.getByRole('textbox',{name:'示例昵称'}); await input.fill('保留草稿');
45
+ const handle=await input.elementHandle();
46
+ const canvas=await page.locator('.rhine-orbit canvas').first().elementHandle();
47
+ const layout=await page.locator('.ds-orbit-entry').evaluate(e=>{
48
+ const r=e.getBoundingClientRect(), bg=e.querySelector('.rhine-orbit'), c=bg.querySelector('canvas').getBoundingClientRect(), intro=e.querySelector('.ds-orbit-entry-intro').getBoundingClientRect(), form=e.querySelector('.ds-orbit-entry-form');
49
+ return {inside:r.left>=0&&r.right<=innerWidth+1,clear:intro.bottom<=c.top,formClear:getComputedStyle(form).backgroundColor!=='rgba(0, 0, 0, 0)',noControl:!bg.querySelector('button'),noFrame:getComputedStyle(bg).borderTopWidth==='0px'&&getComputedStyle(bg).boxShadow==='none',full:Math.abs(bg.getBoundingClientRect().width-r.width)<2, narrow:e.parentElement.clientWidth<=760};
50
+ });
51
+ check(layout.inside&&layout.clear&&layout.formClear&&layout.noControl&&layout.noFrame&&layout.full===layout.narrow,{theme,width,zoom,kind:'orbit-layout',layout});
52
+ await page.getByRole('combobox',{name:'验收主题'}).selectOption(theme==='rhine'?'arknights':'rhine');
53
+ await page.setViewportSize({width:width+80,height:1000});
54
+ check(await handle.evaluate(e=>e.isConnected&&e.value==='保留草稿')&&await canvas.evaluate(e=>e.isConnected),{theme,width,zoom,kind:'stable-nodes'});
55
+ orbitCases++;
56
+ }
57
+ return {pass:!failures.length&&!errors.length,filterCases,orbitCases,failures,errors};
58
+ }
@@ -0,0 +1,169 @@
1
+ async page => {
2
+ await page.addInitScript(() => {
3
+ if (window.__donutProof) return;
4
+ const proof = window.__donutProof = { contexts: [], pending: new Set(), rafScheduled: 0, rafFired: 0, rafCancelled: 0, listeners: new Map(), observerCreates: {}, observerDisconnects: {}, orbitObservers: {}, orbitDisconnects: {} };
5
+ let targetID = 0, listenerID = 0;
6
+ const targets = new WeakMap(), listeners = new WeakMap();
7
+ const targetName = target => {
8
+ if (!targets.has(target)) targets.set(target, ++targetID);
9
+ const kind = target === window ? 'window' : target === document ? 'document' : target instanceof HTMLCanvasElement ? 'canvas' : target instanceof MediaQueryList ? 'media' : '';
10
+ return kind ? `${kind}:${targets.get(target)}` : '';
11
+ };
12
+ const nativeAdd = EventTarget.prototype.addEventListener;
13
+ const nativeRemove = EventTarget.prototype.removeEventListener;
14
+ EventTarget.prototype.addEventListener = function (type, listener, options) {
15
+ const target = targetName(this);
16
+ if (target && listener && (typeof listener === 'object' || typeof listener === 'function')) {
17
+ if (!listeners.has(listener)) listeners.set(listener, ++listenerID);
18
+ const key = `${target}:${type}:${listeners.get(listener)}:${Boolean(typeof options === 'boolean' ? options : options?.capture)}`;
19
+ proof.listeners.set(key, { target, type });
20
+ }
21
+ return nativeAdd.call(this, type, listener, options);
22
+ };
23
+ EventTarget.prototype.removeEventListener = function (type, listener, options) {
24
+ const target = targetName(this);
25
+ if (target && listener) proof.listeners.delete(`${target}:${type}:${listeners.get(listener)}:${Boolean(typeof options === 'boolean' ? options : options?.capture)}`);
26
+ return nativeRemove.call(this, type, listener, options);
27
+ };
28
+ const nativeRequest = requestAnimationFrame, nativeCancel = cancelAnimationFrame;
29
+ window.requestAnimationFrame = callback => {
30
+ proof.rafScheduled++;
31
+ const id = nativeRequest(time => { proof.pending.delete(id); proof.rafFired++; callback(time); });
32
+ proof.pending.add(id);
33
+ return id;
34
+ };
35
+ window.cancelAnimationFrame = id => { proof.pending.delete(id); proof.rafCancelled++; nativeCancel(id); };
36
+ for (const name of ['ResizeObserver', 'IntersectionObserver', 'MutationObserver']) {
37
+ const Native = window[name];
38
+ window[name] = class extends Native {
39
+ constructor(callback) { super(callback); proof.observerCreates[name] = (proof.observerCreates[name] || 0) + 1; }
40
+ observe(target, ...args) { if (target instanceof HTMLCanvasElement && target.classList.contains('rhine-orbit-canvas') && !this.orbit) { this.orbit = true; proof.orbitObservers[name] = (proof.orbitObservers[name] || 0) + 1; } return super.observe(target, ...args); }
41
+ disconnect() { if(this.orbit) proof.orbitDisconnects[name] = (proof.orbitDisconnects[name] || 0) + 1; proof.observerDisconnects[name] = (proof.observerDisconnects[name] || 0) + 1; return super.disconnect(); }
42
+ };
43
+ }
44
+ const locations = new WeakMap(), contexts = new WeakMap();
45
+ const record = gl => {
46
+ if (!contexts.has(gl)) {
47
+ const entry = { canvas: gl.canvas, draws: 0, frames: 0, samples: [], time: null, matrices: [], resources: {}, lost: 0, restored: 0, queued: false };
48
+ contexts.set(gl, entry); proof.contexts.push(entry);
49
+ nativeAdd.call(gl.canvas, 'webglcontextlost', () => entry.lost++);
50
+ nativeAdd.call(gl.canvas, 'webglcontextrestored', () => entry.restored++);
51
+ }
52
+ return contexts.get(gl);
53
+ };
54
+ const scheduleSample = gl => {
55
+ const entry = record(gl);
56
+ if (entry.queued) return;
57
+ entry.queued = true;
58
+ queueMicrotask(() => {
59
+ entry.queued = false; entry.frames++;
60
+ const now = performance.now();
61
+ const retainSample = !entry.samples.length || now - entry.samples.at(-1).at >= 180;
62
+ try {
63
+ const canvas = document.createElement('canvas'); canvas.width = 100; canvas.height = 72;
64
+ const context = canvas.getContext('2d'); context.drawImage(gl.canvas, 0, 0, 100, 72);
65
+ const pixels = context.getImageData(0, 0, 100, 72).data;
66
+ let hash = 2166136261, painted = 0, bright = 0, min = 255, max = 0;
67
+ for (let i = 0; i < pixels.length; i += 4) {
68
+ for (let j = 0; j < 4; j++) hash = Math.imul(hash ^ pixels[i + j], 16777619);
69
+ if (pixels[i + 3]) painted++;
70
+ const luminance = (pixels[i] + pixels[i + 1] + pixels[i + 2]) / 3;
71
+ if (luminance > 100) bright++;
72
+ min = Math.min(min, luminance); max = Math.max(max, luminance);
73
+ }
74
+ entry.latest = { at: now, time: entry.time, hash: hash >>> 0, painted, bright, min, max, matrices: entry.matrices.slice(0, 40) };
75
+ if (retainSample) entry.samples.push(entry.latest);
76
+ if (entry.samples.length > 700) entry.samples.shift();
77
+ } catch (error) { entry.sampleError = String(error); }
78
+ entry.matrices = [];
79
+ });
80
+ };
81
+ for (const Proto of [window.WebGLRenderingContext?.prototype, window.WebGL2RenderingContext?.prototype].filter(Boolean)) {
82
+ for (const name of ['drawArrays', 'drawElements', 'drawArraysInstanced', 'drawElementsInstanced']) {
83
+ const native = Proto[name]; if (!native || native.__donutProof) continue;
84
+ const wrapped = function (...args) { const result = native.apply(this, args); record(this).draws++; scheduleSample(this); return result; };
85
+ wrapped.__donutProof = true; Proto[name] = wrapped;
86
+ }
87
+ const nativeLocation = Proto.getUniformLocation;
88
+ if (!nativeLocation.__donutProof) {
89
+ const wrapped = function (program, name) { const value = nativeLocation.call(this, program, name); if (value) locations.set(value, name); return value; };
90
+ wrapped.__donutProof = true; Proto.getUniformLocation = wrapped;
91
+ }
92
+ for (const name of ['uniform1f', 'uniformMatrix4fv']) {
93
+ const native = Proto[name]; if (native.__donutProof) continue;
94
+ const wrapped = function (...args) {
95
+ const result = native.apply(this, args), uniform = locations.get(args[0]);
96
+ if (name === 'uniform1f' && /^(time|uTime)$/.test(uniform)) record(this).time = args[1];
97
+ if (name === 'uniformMatrix4fv' && uniform === 'modelViewMatrix') record(this).matrices.push(Array.from(args[2], value => Number(value.toFixed(5))));
98
+ return result;
99
+ };
100
+ wrapped.__donutProof = true; Proto[name] = wrapped;
101
+ }
102
+ for (const kind of ['Buffer', 'Texture', 'Program', 'Shader', 'Framebuffer', 'Renderbuffer', 'VertexArray']) {
103
+ for (const action of ['create', 'delete']) {
104
+ const name = action + kind, native = Proto[name]; if (!native || native.__donutProof) continue;
105
+ const wrapped = function (...args) { const result = native.apply(this, args); const entry = record(this); if (action === 'create' ? result : args[0]) entry.resources[name] = (entry.resources[name] || 0) + 1; return result; };
106
+ wrapped.__donutProof = true; Proto[name] = wrapped;
107
+ }
108
+ }
109
+ }
110
+ window.__donutSnapshot = () => ({
111
+ pendingRafs: proof.pending.size, rafScheduled: proof.rafScheduled, rafFired: proof.rafFired, rafCancelled: proof.rafCancelled,
112
+ listeners: [...proof.listeners.values()], observerCreates: proof.observerCreates, observerDisconnects: proof.observerDisconnects, orbitObservers: proof.orbitObservers, orbitDisconnects: proof.orbitDisconnects,
113
+ contexts: proof.contexts.filter(entry => entry.canvas.classList.contains('rhine-orbit-canvas')).map(entry => ({
114
+ example: entry.canvas.closest('.rhine-orbit')?.getAttribute('aria-label') || null,
115
+ kind: entry.canvas.dataset.orbitArtwork || 'donut', active: !entry.canvas.hidden, canvasConnected: entry.canvas.isConnected, draws: entry.draws, frames: entry.frames, time: entry.time, samples: entry.samples, latest: entry.latest,
116
+ resources: entry.resources, lost: entry.lost, restored: entry.restored, sampleError: entry.sampleError, width: entry.canvas.width, height: entry.canvas.height,
117
+ })),
118
+ });
119
+ }
120
+ );
121
+
122
+ const results=[],errors=[]; page.on('pageerror',e=>errors.push(String(e)));
123
+ const snap=()=>page.evaluate(()=>window.__donutSnapshot());
124
+ for(const theme of ['rhine','arknights','neo-brutalism']) {
125
+ await page.setViewportSize({width:1280,height:1000});
126
+ await page.goto(`http://127.0.0.1:18484/?fixture=orbit-entry&theme=${theme}`);
127
+ const root=page.locator('.rhine-orbit'), canvas=root.locator('canvas').first();
128
+ await root.scrollIntoViewIfNeeded();
129
+ await page.waitForFunction(()=>window.__donutSnapshot().contexts.some(c=>c.canvasConnected&&c.active&&c.latest?.painted>50));
130
+ const active=s=>s.contexts.find(c=>c.canvasConnected&&c.active);
131
+ const a=active(await snap()); await page.waitForTimeout(450); const b=active(await snap());
132
+ const playing=a.latest.hash!==b.latest.hash&&b.draws>a.draws;
133
+ await page.getByRole('checkbox',{name:'减少入口动态'}).check();
134
+ await page.waitForTimeout(150);const c=active(await snap());await page.waitForTimeout(350); const d=active(await snap());
135
+ const manual=c.draws===d.draws&&c.latest.hash===d.latest.hash;
136
+ await page.getByRole('checkbox',{name:'减少入口动态'}).uncheck();
137
+ await page.emulateMedia({reducedMotion:'reduce'});await page.waitForTimeout(150);const e=active(await snap());await page.waitForTimeout(350);const f=active(await snap());
138
+ const system=e.draws===f.draws;
139
+ await page.emulateMedia({reducedMotion:'no-preference'});await page.waitForTimeout(350);const resumed=active(await snap()).draws>f.draws;
140
+ await root.evaluate(e=>e.style.display='none');await page.waitForTimeout(150);const g=active(await snap());await page.waitForTimeout(350);const h=active(await snap());
141
+ const empty=g.draws===h.draws;
142
+ await root.evaluate(e=>e.style.display='');await page.waitForTimeout(200);
143
+ await page.locator('.ds-orbit-entry-container').evaluate(e=>e.style.marginTop='2200px');await page.evaluate(()=>scrollTo(0,0));await page.waitForTimeout(150);const j=active(await snap());await page.waitForTimeout(350);const k=active(await snap());
144
+ const offscreen=j.draws===k.draws;
145
+ await page.locator('.ds-orbit-entry-container').evaluate(e=>e.style.marginTop=''); await root.scrollIntoViewIfNeeded();await page.waitForTimeout(250);
146
+ const other=await page.context().newPage();await other.goto('about:blank');await other.bringToFront();await page.waitForTimeout(250);
147
+ const actualHidden=await page.evaluate(()=>document.hidden);
148
+ await page.evaluate(()=>{Object.defineProperty(document,'hidden',{configurable:true,value:true});document.dispatchEvent(new Event('visibilitychange'));});
149
+ await page.waitForTimeout(150);
150
+ const hidden=await page.evaluate(()=>document.hidden);const l=active(await snap());await page.waitForTimeout(350);const m=active(await snap());
151
+ const hiddenPause=hidden&&l.draws===m.draws;
152
+ await other.close();await page.bringToFront();
153
+ await page.evaluate(()=>{delete document.hidden;document.dispatchEvent(new Event('visibilitychange'));});
154
+ await page.getByRole('checkbox',{name:'挂载入口'}).uncheck();await page.waitForTimeout(150);const disposed=await snap();
155
+ await page.waitForTimeout(350);const end=await snap();
156
+ const cleanup=end.contexts.every(c=>!c.canvasConnected)&&end.pendingRafs===0&&JSON.stringify(end.contexts.map(c=>c.draws))===JSON.stringify(disposed.contexts.map(c=>c.draws))&&['ResizeObserver','IntersectionObserver','MutationObserver'].every(n=>end.orbitObservers[n]>0&&end.orbitObservers[n]===end.orbitDisconnects[n]);
157
+ results.push({theme,playing,manual,system,resumed,empty,offscreen,actualHidden,hidden,hiddenPause,cleanup,pending:end.pendingRafs,observers:{created:end.observerCreates,disconnected:end.observerDisconnects,orbitCreated:end.orbitObservers,orbitDisconnected:end.orbitDisconnects},resources:end.contexts.map(c=>c.resources)});
158
+ }
159
+ const fallback=await page.context().newPage();
160
+ await fallback.addInitScript(()=>{HTMLCanvasElement.prototype.getContext=function(){return null;};});
161
+ const fallbacks=[];
162
+ for(const theme of ['rhine','arknights','neo-brutalism']) {
163
+ await fallback.goto(`http://127.0.0.1:18484/?fixture=orbit-entry&theme=${theme}`);
164
+ await fallback.waitForFunction(()=>document.querySelector('.rhine-orbit')?.dataset.rhineOrbitState==='unavailable');
165
+ fallbacks.push(await fallback.locator('.rhine-orbit').evaluate(e=>{const c=getComputedStyle(e.querySelector('canvas')),p=getComputedStyle(e,'::after');return {theme:e.closest('[data-rhine-theme]').dataset.rhineTheme,pass:c.width===p.width&&c.height===p.height&&c.right===p.right&&c.bottom===p.bottom&&p.backgroundImage.includes('svg')};}));
166
+ }
167
+ await fallback.close();
168
+ return {pass:results.every(r=>r.playing&&r.manual&&r.system&&r.resumed&&r.empty&&r.offscreen&&r.hiddenPause&&r.cleanup)&&fallbacks.every(r=>r.pass)&&!errors.length,results,fallbacks,errors};
169
+ }
@@ -0,0 +1,47 @@
1
+ async page => {
2
+ await page.goto(`http://127.0.0.1:18484/?fixture=list-filters&theme=neo-brutalism&proof=${Date.now()}#list-filter-bar`);
3
+ const checks = [];
4
+ const check = (name, pass, evidence = {}) => checks.push({ name, pass: Boolean(pass), ...evidence });
5
+ await page.getByRole('group', { name: '录制会话筛选', exact: true }).getByRole('button', { name: /^更多筛选/ }).click();
6
+ const dialog = page.getByRole('dialog', { name: '录制会话筛选', exact: true });
7
+ const start = dialog.getByLabel('开始日期', { exact: true });
8
+ const end = dialog.getByLabel('结束日期', { exact: true });
9
+ const inspect = () => start.evaluate(element => ({ value: element.value, badInput: element.validity.badInput, invalid: element.getAttribute('aria-invalid') }));
10
+ await start.fill('2026-10-20'); await end.fill('2026-10-24');
11
+ await start.focus(); await page.keyboard.press('ArrowLeft'); await page.keyboard.press('ArrowLeft'); await page.keyboard.press('Backspace');
12
+ await page.waitForTimeout(100);
13
+ let value = await inspect();
14
+ check('Deleting a native date segment keeps accepted range and reports invalid', value.badInput && value.invalid === 'true' && (await page.locator('#list-filter-bar .rhine-list-filter-bar__active').first().innerText()).includes('2026-10-20 — 2026-10-24'), value);
15
+ await page.keyboard.type('2026'); await page.waitForTimeout(100); value = await inspect();
16
+ check('Typing the complete year repairs native draft', value.value === '2026-10-20' && !value.badInput && value.invalid !== 'true', value);
17
+ await dialog.getByRole('button', { name: '清空日期', exact: true }).click();
18
+ await start.evaluate(element => { window.nativeDateEvents = []; for (const name of ['input', 'change', 'keyup', 'blur']) element.addEventListener(name, event => window.nativeDateEvents.push({ event: event.type, key: event.key, value: element.value, badInput: element.validity.badInput })); });
19
+ await start.focus(); await page.keyboard.type('2'); await page.waitForTimeout(100); value = await inspect();
20
+ check('Empty date native fragment is invalid even when no input/change fires', value.badInput && value.invalid === 'true' && await dialog.getByRole('button', { name: '清空日期', exact: true }).isEnabled(), { ...value, events: await page.evaluate(() => window.nativeDateEvents) });
21
+ if (await dialog.getByRole('button', { name: '清空日期', exact: true }).isEnabled()) {
22
+ await dialog.getByRole('button', { name: '清空日期', exact: true }).click(); await page.waitForTimeout(100); value = await inspect();
23
+ check('Clear removes an empty native fragment and validation error', value.value === '' && !value.badInput && value.invalid !== 'true', value);
24
+ }
25
+ await dialog.getByRole('button', { name: '今天', exact: true }).click();
26
+ await start.focus(); await page.keyboard.press('ArrowLeft'); await page.keyboard.press('ArrowLeft'); await page.keyboard.press('Backspace');
27
+ await dialog.getByRole('button', { name: '今天', exact: true }).click(); await page.waitForTimeout(100); value = await inspect();
28
+ check('Applying the same preset clears a rejected native draft', value.value === '2026-10-24' && !value.badInput && value.invalid !== 'true', value);
29
+ await page.keyboard.press('Escape'); await dialog.waitFor({state:'detached'});
30
+ for (let i=0;i<3;i++) {
31
+ const bar=page.locator('.rhine-list-filter-bar').nth(i);
32
+ if(await bar.getByRole('button',{name:'重置筛选',exact:true}).count()) await bar.getByRole('button',{name:'重置筛选',exact:true}).click();
33
+ await bar.getByRole('button',{name:/^更多筛选/}).click(); const panel=page.getByRole('dialog');
34
+ if(i<2) await panel.getByRole('checkbox',{name:'只看已审查'}).check();
35
+ else await panel.getByRole('combobox',{name:'统计范围'}).selectOption({label:'示例团队 / 跨部门体验与可访问性协作小组'});
36
+ const date=panel.getByLabel('开始日期',{exact:true});
37
+ await date.focus();
38
+ if(i===2){await page.keyboard.press('ArrowLeft');await page.keyboard.press('ArrowLeft');await page.keyboard.press('Backspace');}
39
+ else await page.keyboard.type('2');
40
+ await page.waitForFunction(e=>e.validity.badInput&&e.getAttribute('aria-invalid')==='true',await date.elementHandle());
41
+ await panel.getByRole('button',{name:'重置筛选',exact:true}).click();
42
+ const clean=await date.evaluate(e=>!e.validity.badInput&&e.getAttribute('aria-invalid')!=='true');
43
+ check(`Overall reset clears default-valued native draft in example ${i}`,clean&&await panel.evaluate(e=>e.contains(document.activeElement)));
44
+ await page.keyboard.press('Escape');await panel.waitFor({state:'detached'});
45
+ }
46
+ return { complete: checks.every(item => item.pass), checks, scope: 'Real native date segmentation in isolated Chromium; keyboard presses use page.keyboard so locator focus does not reset year digit buffering.' };
47
+ }