@linhey/rhine-design 0.5.0 → 0.8.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 (100) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/README.md +26 -8
  3. package/components/Actions.tsx +18 -1
  4. package/components/AppShell.tsx +46 -17
  5. package/components/MOTION.md +9 -1
  6. package/components/Media.tsx +7 -7
  7. package/components/NeutronStarBackdrop.tsx +43 -0
  8. package/components/OrbitBackdrop.tsx +35 -0
  9. package/components/Overlays.tsx +37 -8
  10. package/components/README.md +30 -2
  11. package/components/StatusTimeline.tsx +72 -6
  12. package/components/StatusTimelineList.tsx +38 -0
  13. package/components/ThemePicker.tsx +49 -0
  14. package/components/assets/orbit-bird.svg +578 -0
  15. package/components/assets/orbit-crystal.svg +11 -0
  16. package/components/assets/orbit-donut.svg +16 -0
  17. package/components/assets/theme-previews/arknights.png +0 -0
  18. package/components/assets/theme-previews/manifest.json +24 -0
  19. package/components/assets/theme-previews/neo-brutalism.png +0 -0
  20. package/components/assets/theme-previews/rhine.png +0 -0
  21. package/components/common.css +3 -3
  22. package/components/components.tsx +3 -3
  23. package/components/dialog-settings.css +9 -0
  24. package/components/extensions.css +8 -32
  25. package/components/index.ts +13 -5
  26. package/components/media-parity.css +55 -2
  27. package/components/neutron.css +53 -0
  28. package/components/neutronRenderer.ts +327 -0
  29. package/components/orbit.css +120 -0
  30. package/components/orbitBirdFlight.ts +34 -0
  31. package/components/orbitBirdGeometry.ts +158 -0
  32. package/components/orbitDonut.ts +134 -0
  33. package/components/orbitDonutThree.ts +208 -0
  34. package/components/orbitObjectMaterials.ts +76 -0
  35. package/components/orbitObjects.ts +71 -0
  36. package/components/orbitObjectsThree.ts +215 -0
  37. package/components/orbitProjection.ts +21 -0
  38. package/components/orbitRenderer.ts +347 -0
  39. package/components/styles.css +4 -1
  40. package/components/theme-neo-brutalism.css +3 -2
  41. package/components/theme-picker.css +17 -0
  42. package/components/theme.tsx +2 -8
  43. package/components/themeMetadata.ts +7 -0
  44. package/components/timeline.css +17 -6
  45. package/components/useFloatingScale.ts +96 -0
  46. package/docs/APPLICATION_COMPONENTS.md +52 -3
  47. package/docs/COMPACT_ACCOUNT_PATTERNS.md +70 -0
  48. package/docs/DATE_RANGE_FILTER.md +60 -0
  49. package/docs/DESIGN_SYSTEM.md +7 -0
  50. package/docs/FLOATING_OVERLAYS.md +15 -0
  51. package/docs/INTERACTION_PATTERNS.md +5 -1
  52. package/docs/NEUTRON_STAR_BACKDROP.md +122 -0
  53. package/docs/ORBIT_BACKDROP.md +73 -0
  54. package/docs/SETTINGS_DIALOG.md +21 -0
  55. package/docs/THEME_PICKER.md +26 -0
  56. package/docs/THEMING.md +2 -0
  57. package/docs/assets/compact-account-neo.png +0 -0
  58. package/docs/assets/neutron-star-original.png +0 -0
  59. package/docs/assets/neutron-star-theme.png +0 -0
  60. package/docs/assets/orbit-backdrop.png +0 -0
  61. package/docs/assets/orbit-bird.png +0 -0
  62. package/docs/assets/orbit-crystal.png +0 -0
  63. package/docs/assets/orbit-strawberry.png +0 -0
  64. package/package.json +8 -3
  65. package/preview/CompactAccountGallery.tsx +178 -0
  66. package/preview/DateRangeFilterGallery.tsx +109 -0
  67. package/preview/ExtensionsGallery.tsx +27 -18
  68. package/preview/GalleryShell.tsx +3 -3
  69. package/preview/MonitoringGallery.tsx +2 -0
  70. package/preview/NeutronStarGallery.tsx +56 -0
  71. package/preview/OrbitGallery.tsx +50 -0
  72. package/preview/OverlayBoundsGallery.tsx +49 -0
  73. package/preview/SettingsDialogGallery.tsx +33 -0
  74. package/preview/ThemePickerGallery.tsx +24 -0
  75. package/preview/ThemeSample.tsx +14 -0
  76. package/preview/TimelineListGallery.tsx +36 -0
  77. package/preview/extensions.css +9 -0
  78. package/preview/main.tsx +19 -4
  79. package/preview/preview.css +8 -0
  80. package/preview/theme-sample.html +1 -0
  81. package/scripts/check-overlay-bounds.js +76 -0
  82. package/scripts/check-settings-theme.js +88 -0
  83. package/skills/rhine-design/SKILL.md +3 -2
  84. package/skills/rhine-design/references/API_MAP.md +14 -1
  85. package/skills/rhine-design/references/TASK_MAP.md +8 -1
  86. package/skills/rhine-design/references/integration.md +1 -1
  87. package/tests/AppShellControls.test.tsx +82 -0
  88. package/tests/ButtonLink.test.tsx +94 -0
  89. package/tests/CompactAccountGallery.test.tsx +116 -0
  90. package/tests/DateRangeFilterGallery.test.tsx +85 -0
  91. package/tests/NeutronStarBackdrop.test.tsx +288 -0
  92. package/tests/NeutronStarGallery.test.tsx +62 -0
  93. package/tests/NeutronStarImport.test.tsx +15 -0
  94. package/tests/OrbitBackdrop.test.tsx +420 -0
  95. package/tests/OrbitDonutThree.test.tsx +91 -0
  96. package/tests/OrbitObjectsThree.test.tsx +164 -0
  97. package/tests/SettingsDialog.test.tsx +66 -0
  98. package/tests/StatusTimelineList.test.tsx +63 -0
  99. package/tests/ThemePicker.test.tsx +30 -0
  100. package/tests/TimelineLegend.test.tsx +60 -0
@@ -1,4 +1,5 @@
1
1
  import { useState } from 'react';
2
+ import { TimelineListGallery } from './TimelineListGallery';
2
3
  import { Select, Stack, StackedBar, StatusTimeline, Surface, TimelineLegend, TrendChart, type ChartPoint, type TimelineSegment } from '../components';
3
4
 
4
5
  export function MonitoringGallery() {
@@ -15,6 +16,7 @@ export function MonitoringGallery() {
15
16
  }));
16
17
  const feedback = { stale: state === 'stale' ? '最后采集于 20 分钟前,请核对来源。' : undefined, error: state === 'error' ? '本次读取失败,保留上次采样。' : undefined };
17
18
  return <section><div className="ds-section-title"><span>MONITORING</span><h2>看见证据,也看见缺口</h2></div>
19
+ <TimelineListGallery />
18
20
  <p className="ds-caption">计数与口径由宿主提供。未知保持未知,折线在缺失处断开;图例与数据表提供文字替代,不生成健康评分。时段详情支持鼠标、键盘、触屏和 Escape,并自动避让视口。</p>
19
21
  <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
22
  <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>
@@ -0,0 +1,56 @@
1
+ import { useState } from 'react';
2
+ import { Badge, Button, Disclosure, Inline, NeutronStarBackdrop, Select, Stack, Surface, Switch, Text, TextField } from '../components';
3
+
4
+ export function NeutronStarGallery() {
5
+ const [replay, setReplay] = useState(0);
6
+ const [reduced, setReduced] = useState(false);
7
+ const [appearance, setAppearance] = useState<'theme' | 'original'>('theme');
8
+
9
+ return <section id="neutron-star">
10
+ <div className="ds-section-title"><span>NEUTRON STAR</span><h2>中子星 · 磁场与星尘</h2></div>
11
+ <p className="ds-caption">磁场线、流动粒子与极向喷流围绕星核运行。先看完整的艺术画面,再展开控制与内容组合;装饰不表示加载或业务进度。</p>
12
+ <Stack gap={4}>
13
+ <NeutronStarBackdrop key={replay} data-neutron-example="artwork" aria-label="中子星艺术" role="group" appearance={appearance} reducedMotion={reduced} style={{ minHeight: 560 }} />
14
+ <Disclosure title="演示控制">
15
+ <Stack gap={4}>
16
+ <Inline gap={3}>
17
+ <Badge>源码候选</Badge>
18
+ <Button variant="secondary" onClick={() => setReplay(value => value + 1)}>重播中子星</Button>
19
+ <Switch label="减少中子星动态" checked={reduced} onCheckedChange={setReduced} />
20
+ </Inline>
21
+ <Select label="艺术配色" value={appearance} onChange={event => setAppearance(event.target.value as 'theme' | 'original')}>
22
+ <option value="theme">跟随界面主题</option>
23
+ <option value="original">原始深空</option>
24
+ </Select>
25
+ <Text variant="caption" tone="muted">顶部切换界面主题;艺术配色改变时继续当前时间。暂停后原地继续,重播只重新创建上方艺术,不清空下方输入。</Text>
26
+ </Stack>
27
+ </Disclosure>
28
+ <Disclosure title="查看内容组合">
29
+ <NeutronContentExample appearance={appearance} reducedMotion={reduced} />
30
+ </Disclosure>
31
+ </Stack>
32
+ <p className="ds-caption">28 条磁场线、560 个流动粒子、700 颗星尘、极向喷流与星核;星系以 28 秒有效播放时间完成一轮深度公转。系统、顶部或本节减少动态均保留静态画面;页面隐藏、离屏或尺寸为零时停止逐帧绘制。</p>
33
+ </section>;
34
+ }
35
+
36
+ function NeutronContentExample({ appearance, reducedMotion }: { appearance: 'theme' | 'original'; reducedMotion: boolean }) {
37
+ const [name, setName] = useState('Rhine Observatory');
38
+ return <NeutronStarBackdrop data-neutron-example="content" aria-label="中子星内容组合" role="group" appearance={appearance} reducedMotion={reducedMotion} style={{ minHeight: 460 }}>
39
+ <Stack style={{ padding: 'var(--rhine-space-6)' }}>
40
+ <Surface style={{ maxWidth: 400 }}>
41
+ <Stack gap={4}>
42
+ <Stack gap={2}>
43
+ <Text as="h3" variant="heading">保持内容清晰</Text>
44
+ <Text tone="muted">卡片、文字与操作使用当前界面主题,艺术层独立绘制。</Text>
45
+ </Stack>
46
+ <TextField label="观测名称" value={name} onChange={event => setName(event.target.value)} hint="本地演示草稿;切换配色或重播不会清空。" />
47
+ <Inline gap={2}>
48
+ <Button>开始观测</Button>
49
+ <Button variant="secondary">查看介绍</Button>
50
+ <Button disabled>暂不可用</Button>
51
+ </Inline>
52
+ </Stack>
53
+ </Surface>
54
+ </Stack>
55
+ </NeutronStarBackdrop>;
56
+ }
@@ -0,0 +1,50 @@
1
+ import { useState } from 'react';
2
+ import { Badge, Button, Disclosure, Inline, OrbitBackdrop, SegmentedControl, Stack, Surface, Switch, Text, TextField } from '../components';
3
+
4
+ export function OrbitGallery() {
5
+ const [replay, setReplay] = useState(0);
6
+ const [reduced, setReduced] = useState(false);
7
+ const [appearance, setAppearance] = useState<'theme' | 'original'>('theme');
8
+ const [artworkName, setArtworkName] = useState('Orbit workspace');
9
+ const [workspaceName, setWorkspaceName] = useState('Rhine Studio');
10
+ return <section id="orbit">
11
+ <div className="ds-section-title"><span>ORBIT</span><h2>轨道星空 · 品牌艺术背景</h2></div>
12
+ <p className="ds-caption">莱茵的折纸鸟、战术的悬浮源石晶簇、橙趣的草莓甜甜圈。每个主题都有自己的物件,也可以切换到原始深色星空。</p>
13
+ <Inline gap={3}>
14
+ <Badge>0.7.0</Badge>
15
+ <SegmentedControl label="画面风格" value={appearance} onValueChange={value => setAppearance(value as 'theme' | 'original')} items={[{ value: 'theme', label: '跟随主题' }, { value: 'original', label: '原始深色' }]} />
16
+ <Button variant="secondary" onClick={() => setReplay(value => value + 1)}>重播轨道</Button>
17
+ <Switch label="减少轨道动态" checked={reduced} onCheckedChange={setReduced} />
18
+ </Inline>
19
+ <OrbitBackdrop key={`art-${replay}`} className="ds-gap-top ds-orbit-artwork" appearance={appearance} reducedMotion={reduced} role="region" aria-label="轨道背景预览" />
20
+ <p className="ds-caption">纸鸟在飞行中连续折叠成纸蝴蝶,再舒展还原;晶簇与甜甜圈缓缓旋转。切换主题保留播放位置,窄屏也能播放;暂停或开启减少动态后保持当前形态。</p>
21
+ <Disclosure title="查看组合控件示例">
22
+ <Text tone="muted">输入框与按钮的叠加效果单独展示,不遮挡上方轨道。</Text>
23
+ <OrbitBackdrop key={`controls-${replay}`} className="ds-gap-top" appearance={appearance} reducedMotion={reduced} role="region" aria-label="轨道上的控件组合">
24
+ <div className="ds-orbit-example-content"><Stack gap={6}>
25
+ <Stack gap={3}>
26
+ <Text as="h3" variant="heading">直接叠加</Text>
27
+ <TextField label="直接叠加的输入框" value={artworkName} onChange={event => setArtworkName(event.target.value)} hint="提示文字随当前内容前景保持可读。" />
28
+ <Inline gap={2}>
29
+ <Button>开始探索</Button>
30
+ <Button variant="secondary">查看轨道</Button>
31
+ <Button variant="quiet">了解更多</Button>
32
+ <Button variant="quiet" busy busyLabel="示例处理中">示例处理中</Button>
33
+ <Button variant="quiet" disabled>暂不可用</Button>
34
+ </Inline>
35
+ </Stack>
36
+ <Surface><Stack gap={3}>
37
+ <Text as="h3" variant="heading">带底色的内容</Text>
38
+ <Text tone="muted">Surface 与控件保留当前主题的完整配色。</Text>
39
+ <TextField label="工作空间名称" value={workspaceName} onChange={event => setWorkspaceName(event.target.value)} hint="这是可编辑的本地展台示例。" />
40
+ <Inline gap={2}>
41
+ <Button>继续探索</Button>
42
+ <Button variant="secondary">查看介绍</Button>
43
+ <Button variant="quiet">更多介绍</Button>
44
+ </Inline>
45
+ </Stack></Surface>
46
+ </Stack></div>
47
+ </OrbitBackdrop>
48
+ </Disclosure>
49
+ </section>;
50
+ }
@@ -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,10 @@
1
1
  import { StrictMode, useEffect, useRef, useState } from 'react';
2
2
  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';
3
+ import { Badge, Button, Checkbox, Disclosure, EmptyState, Logo, Notice, Progress, SegmentedControl, Select, SelectionCard, Skeleton, Surface, Tabs, Text, TextField, Toolbar, type TextVariant } from '../components/index';
4
4
  import './preview.css';
5
+ import { OverlayBoundsFixture, OverlayBoundsGallery } from './OverlayBoundsGallery';
6
+ import { SettingsDialogGallery, SettingsDialogFixture } from './SettingsDialogGallery';
7
+ import { ThemePickerGallery, ThemePickerFixture } from './ThemePickerGallery';
5
8
  import { CommonGallery } from './CommonGallery';
6
9
  import { MotionGallery } from './MotionGallery';
7
10
  import { StartupGallery } from './StartupGallery';
@@ -11,8 +14,12 @@ import { SpacingGallery } from './SpacingGallery';
11
14
  import { LineHeightGallery } from './LineHeightGallery';
12
15
  import { SubscriptionGallery } from './SubscriptionGallery';
13
16
  import { BackgroundGallery } from './BackgroundGallery';
17
+ import { OrbitGallery } from './OrbitGallery';
18
+ import { NeutronStarGallery } from './NeutronStarGallery';
14
19
  import { AdvancedDataGallery, AdvancedFormsGallery, MediaGallery, ShellGallery } from './ExtensionsGallery';
15
20
  import { MonitoringGallery } from './MonitoringGallery';
21
+ import { DateRangeFilterGallery } from './DateRangeFilterGallery';
22
+ import { CompactAccountGallery } from './CompactAccountGallery';
16
23
 
17
24
  function Gallery() {
18
25
  const [motion, setMotion] = useState('system');
@@ -20,6 +27,7 @@ function Gallery() {
20
27
  const [previewPage, setPreviewPage] = useState('oauth');
21
28
  const [busy, setBusy] = useState(false);
22
29
  const [selected, setSelected] = useState(false);
30
+ const [showSelectionIndicator, setShowSelectionIndicator] = useState(false);
23
31
  const [tab, setTab] = useState('tokens');
24
32
  const [metric, setMetric] = useState('tokens');
25
33
  const [progress, setProgress] = useState<number | null>(38);
@@ -31,15 +39,22 @@ function Gallery() {
31
39
  useEffect(() => () => { if (timer.current) clearTimeout(timer.current); }, []);
32
40
  const simulate = () => { setBusy(true); timer.current = setTimeout(() => { setBusy(false); setNotice(true); }, 900); };
33
41
  return <GalleryShell motion={motion} onMotionChange={setMotion}>{section => <>
42
+ {section === 'theme-picker' && <ThemePickerGallery />}
43
+ {section === 'settings-dialog' && <SettingsDialogGallery />}
44
+ {section === 'overlay-bounds' && <OverlayBoundsGallery />}
34
45
  {section === 'monitoring' && <MonitoringGallery />}
46
+ {section === 'date-range-filter' && <DateRangeFilterGallery />}
47
+ {section === 'compact-account' && <CompactAccountGallery />}
35
48
  {section === 'backgrounds' && <BackgroundGallery />}
36
49
  {section === 'app-shell' && <ShellGallery />}
50
+ {section === 'orbit' && <OrbitGallery />}
51
+ {section === 'neutron-star' && <NeutronStarGallery />}
37
52
  {section === 'advanced-forms' && <AdvancedFormsGallery />}
38
53
  {section === 'advanced-data' && <AdvancedDataGallery />}
39
54
  {section === 'media' && <MediaGallery />}
40
55
  {section === 'subscriptions' && <SubscriptionGallery />}
41
56
  {section === 'top' && <>
42
- <div className="ds-hero"><div><p className="ds-kicker">FOUNDATIONS / COMPONENTS / MOTION</p><h1>精密,有序。<br /><span>每一次交互都有回应。</span></h1><p>59 个通用组件,共用一套视觉变量和动效规则。<br />这里运行的就是后续业务页面可直接导入的 React 组件。</p></div></div>
57
+ <div className="ds-hero"><div><p className="ds-kicker">FOUNDATIONS / COMPONENTS / MOTION</p><h1>精密,有序。<br /><span>每一次交互都有回应。</span></h1><p>60 个通用组件,共用一套视觉变量和动效规则。<br />这里运行的就是后续业务页面可直接导入的 React 组件。</p></div></div>
43
58
  <div className="ds-overview-links">{[
44
59
  ['foundation','视觉基础','从色彩、间距与材质开始。'],
45
60
  ['typography','字体与文字','清晰的层级,稳定的阅读节奏。'],
@@ -97,7 +112,7 @@ function Gallery() {
97
112
  </section>}
98
113
 
99
114
  {section === 'selection' && <section id="selection"><div className="ds-section-title"><span>03</span><h2>选择清楚,位置稳定</h2></div>
100
- <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>
115
+ <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>
101
116
  <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>
102
117
  <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>
103
118
  </section>}
@@ -120,4 +135,4 @@ function Gallery() {
120
135
 
121
136
  }
122
137
 
123
- createRoot(document.getElementById('root')!).render(<StrictMode><Gallery /></StrictMode>);
138
+ createRoot(document.getElementById('root')!).render(<StrictMode>{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,6 @@ 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); }
21
29
  @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; } }
@@ -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,76 @@
1
+ // First run: npm run build && npx vite preview --host 127.0.0.1 --port 18480 --strictPort
2
+ // Fixed regression port 18480 differs from npm run dev (18309).
3
+ // Run with playwright-cli run-code --filename scripts/check-overlay-bounds.js against port 18480.
4
+ async page => {
5
+ const errors = [], failures = [], cases = [];
6
+ let tooltipCases = 0;
7
+ page.on('pageerror', error => errors.push(String(error)));
8
+ const bounds = async locator => locator.evaluate(element => {
9
+ const rect = element.getBoundingClientRect();
10
+ return { x: rect.x, y: rect.y, right: rect.right, bottom: rect.bottom, width: rect.width, height: rect.height, viewportWidth: innerWidth, viewportHeight: innerHeight };
11
+ });
12
+ const contained = rect => rect.x >= -1 && rect.y >= -1 && rect.right <= rect.viewportWidth + 1 && rect.bottom <= rect.viewportHeight + 1 && rect.width > 0 && rect.height > 0;
13
+ for (const theme of ['rhine', 'arknights', 'neo-brutalism']) for (const width of [400, 520, 880]) for (const zoom of [1, 2]) for (const scope of ['document', 'theme', 'content']) for (const [edge, side] of [['top-left', 'left'], ['top-right', 'top'], ['bottom-left', 'bottom'], ['bottom-right', 'right']]) {
14
+ const spec = { theme, width, zoom, scope, edge, side };
15
+ await page.setViewportSize({ width, height: 758 });
16
+ await page.goto(`http://127.0.0.1:18480/?${new URLSearchParams({ fixture: 'overlay-bounds', theme, zoom: String(zoom), scope, edge, side })}`);
17
+ const trigger = page.getByRole('button', { name: '账号与日期', exact: true });
18
+ await trigger.click();
19
+ const popup = page.getByRole('dialog', { name: '工作区与日期详情' });
20
+ await popup.waitFor();
21
+ const popupRect = await bounds(popup);
22
+ const draft = popup.getByRole('textbox', { name: '草稿名称' });
23
+ await draft.fill('保留中的草稿');
24
+ const draftRect = await bounds(draft);
25
+ const apply = popup.getByRole('button', { name: '应用日期', exact: true });
26
+ await apply.scrollIntoViewIfNeeded();
27
+ const applyRect = await bounds(apply);
28
+ await apply.click();
29
+ const applied = await page.getByRole('status').innerText();
30
+ const close = popup.getByRole('button', { name: '关闭详情', exact: true });
31
+ await close.scrollIntoViewIfNeeded();
32
+ const closeRect = await bounds(close);
33
+ await close.click();
34
+ await page.waitForFunction(() => document.activeElement?.textContent === '账号与日期');
35
+ const focusReturned = await trigger.evaluate(element => element === document.activeElement);
36
+ const menuTrigger = page.getByRole('button', { name: '对象操作', exact: true });
37
+ await menuTrigger.click();
38
+ const menu = page.getByRole('menu', { name: '对象操作' });
39
+ await menu.waitFor();
40
+ const menuRect = await bounds(menu);
41
+ await menu.press('End');
42
+ const last = menu.getByRole('menuitem', { name: '最后一项操作' });
43
+ await last.scrollIntoViewIfNeeded();
44
+ const lastRect = await bounds(last);
45
+ await last.click();
46
+ const selected = await page.getByRole('status').innerText();
47
+ const pass = [popupRect, draftRect, applyRect, closeRect, menuRect, lastRect].every(contained) && applied === '已应用' && selected === '最后一项操作' && focusReturned;
48
+ cases.push({ ...spec, pass });
49
+ if (!pass) failures.push({ ...spec, popupRect, draftRect, applyRect, closeRect, menuRect, lastRect, focusReturned, applied, selected });
50
+ }
51
+ for (const theme of ['rhine','arknights','neo-brutalism']) for (const width of [400,520,880]) for (const zoom of [1,2]) for (const scope of ['document','theme','content']) {
52
+ await page.setViewportSize({width,height:758});
53
+ await page.goto(`http://127.0.0.1:18480/?fixture=overlay-bounds&theme=${theme}&zoom=${zoom}&scope=${scope}&edge=bottom-right&side=right`);
54
+ await page.getByRole('button',{name:'辅助说明',exact:true}).focus();
55
+ const tooltip=page.getByRole('tooltip'); await tooltip.waitFor();
56
+ const tooltipRect=await bounds(tooltip);
57
+ if(!contained(tooltipRect)) failures.push({kind:'tooltip',theme,width,zoom,scope,tooltipRect});
58
+ tooltipCases++;
59
+ await page.keyboard.press('Escape');
60
+ await tooltip.waitFor({state:'detached'});
61
+ }
62
+ // Re-scaling an already open portal must preserve the input node and value.
63
+ await page.goto('http://127.0.0.1:18480/?fixture=overlay-bounds&theme=neo-brutalism&zoom=1');
64
+ await page.setViewportSize({ width: 400, height: 758 });
65
+ await page.getByRole('button', { name: '账号与日期', exact: true }).click();
66
+ const draft = page.getByRole('textbox', { name: '草稿名称' });
67
+ await draft.fill('动态缩放保留');
68
+ const input = await draft.elementHandle();
69
+ await page.evaluate(() => { document.documentElement.style.zoom = '2'; });
70
+ await page.waitForTimeout(150);
71
+ const stable = await input.evaluate(element => element.isConnected && element.value === '动态缩放保留');
72
+ const dynamicBounds = await bounds(page.getByRole('dialog', { name: '工作区与日期详情' }));
73
+ await page.keyboard.press('Escape');
74
+ const escaped = await page.getByRole('dialog').waitFor({state:'detached'}).then(() => true, () => false);
75
+ return { pass: failures.length === 0 && errors.length === 0 && stable && contained(dynamicBounds) && escaped, cases: cases.length, tooltipCases, failures, errors, dynamic: { stable, bounds: dynamicBounds, escaped } };
76
+ }
@@ -0,0 +1,88 @@
1
+ // First run: npm run build && npx vite preview --host 127.0.0.1 --port 18480 --strictPort
2
+ // Fixed regression port 18480 differs from npm run dev (18309).
3
+ // playwright-cli run-code --filename scripts/check-settings-theme.js (preview on port 18480).
4
+ async page => {
5
+ const errors = [], failures = [];
6
+ page.on('pageerror', error => errors.push(String(error)));
7
+ const box = async locator => locator.evaluate(e => { const r = e.getBoundingClientRect(); return { x:r.x, y:r.y, right:r.right, bottom:r.bottom, w:innerWidth, h:innerHeight }; });
8
+ const inside = r => r.x >= -1 && r.y >= -1 && r.right <= r.w + 1 && r.bottom <= r.h + 1;
9
+ let settingsCases = 0, themeCases = 0;
10
+ for (const theme of ['rhine','arknights','neo-brutalism']) for (const width of [400,520,880,1440]) for (const zoom of [1,2]) {
11
+ await page.setViewportSize({width,height:900});
12
+ await page.goto(`http://127.0.0.1:18480/?fixture=settings-dialog&theme=${theme}&zoom=${zoom}`);
13
+ const url = page.url();
14
+ const host = page.getByRole('textbox',{name:'宿主查询'});
15
+ await host.fill('查询保留');
16
+ await page.getByRole('button',{name:'打开设置',exact:true}).click();
17
+ const outer = page.getByRole('dialog',{name:'设置',exact:true});
18
+ const outerBounds = await box(outer);
19
+ const help = outer.getByRole('button',{name:'设置帮助',exact:true});
20
+ await help.focus(); await page.getByRole('tooltip').waitFor();
21
+ await page.keyboard.press('Escape'); await page.getByRole('tooltip').waitFor({state:'detached'});
22
+ if (!await outer.isVisible()) throw new Error('Tooltip Escape closed the settings dialog');
23
+ await outer.getByRole('textbox',{name:'工作区名称'}).fill('已保存草稿');
24
+ const save = outer.getByRole('button',{name:'保存设置',exact:true});
25
+ await save.scrollIntoViewIfNeeded(); const saveBounds=await box(save); await save.click();
26
+ await outer.getByRole('status').getByText('已保存演示草稿').waitFor();
27
+ await outer.getByRole('link',{name:'用户管理与权限',exact:true}).click();
28
+ const edit = outer.getByRole('button',{name:'编辑成员 12',exact:true});
29
+ await edit.click();
30
+ const inner=page.getByRole('dialog',{name:'编辑成员 12',exact:true});
31
+ await inner.waitFor(); const innerBounds=await box(inner);
32
+ await inner.getByRole('textbox',{name:'成员备注'}).fill('临时备注');
33
+ await page.keyboard.press('Escape');
34
+ await inner.waitFor({state:'detached'});
35
+ await page.waitForFunction(()=>document.activeElement?.textContent==='编辑成员 12');
36
+ const outerOpen=await outer.isVisible();
37
+ await page.keyboard.press('Tab');
38
+ const trapped=await outer.evaluate(e=>e.contains(document.activeElement));
39
+ await outer.getByRole('link',{name:'公开项目',exact:true}).click();
40
+ for (const state of ['loading','error','empty','normal']) await outer.getByRole('combobox',{name:'项目状态'}).selectOption(state);
41
+ const close=outer.getByRole('button',{name:'关闭对话框',exact:true});
42
+ await close.scrollIntoViewIfNeeded(); const closeBounds=await box(close); await close.click();
43
+ await page.waitForFunction(()=>document.activeElement?.textContent==='打开设置');
44
+ const pass=[outerBounds,saveBounds,innerBounds,closeBounds].every(inside)&&outerOpen&&trapped&&page.url()===url&&await host.inputValue()==='查询保留';
45
+ if(!pass) failures.push({kind:'settings',theme,width,zoom,outerBounds,saveBounds,innerBounds,closeBounds,outerOpen,trapped});
46
+ settingsCases++;
47
+ }
48
+ for (const theme of ['rhine','arknights','neo-brutalism']) for (const width of [390,400,520,880,889,1440]) for (const zoom of [1,2]) {
49
+ await page.setViewportSize({width,height:900});
50
+ await page.goto(`http://127.0.0.1:18480/?fixture=theme-picker&theme=${theme}&zoom=${zoom}`);
51
+ const draft=page.getByRole('textbox',{name:'未保存草稿',exact:true}); await draft.fill('保留原始输入节点');
52
+ const handle=await draft.elementHandle();
53
+ await page.waitForFunction(()=>[...document.querySelectorAll('.rhine-theme-preview img')].every(e=>e.complete&&e.naturalWidth===960));
54
+ const srcs=await page.locator('.rhine-theme-preview img').evaluateAll(es=>es.map(e=>e.currentSrc));
55
+ for (const name of ['莱茵 · 纸面','明日方舟 · 战术终端','橙趣 · 粗描边']) {
56
+ const radio=page.getByRole('radio',{name,exact:true});
57
+ await page.locator('label.rhine-theme-option').filter({has:radio}).click();
58
+ if(!await radio.isChecked()) failures.push({kind:'theme-selection',theme,width,zoom,name});
59
+ }
60
+ await page.getByRole('checkbox',{name:'长主题名称',exact:true}).check();
61
+ const overflow=await page.evaluate(()=>document.documentElement.scrollWidth>innerWidth+1);
62
+ const cards=await page.locator('.rhine-theme-option').evaluateAll(es=>es.map(e=>{const r=e.getBoundingClientRect();return {x:r.x,right:r.right,width:innerWidth,height:r.height};}));
63
+ const stable=await handle.evaluate(e=>e.isConnected&&e.value==='保留原始输入节点');
64
+ const srcsAfter=await page.locator('.rhine-theme-preview img').evaluateAll(es=>es.map(e=>e.currentSrc));
65
+ await page.getByRole('button',{name:'弹窗内切换主题',exact:true}).click();
66
+ const dialog=page.getByRole('dialog',{name:'外观设置',exact:true});
67
+ const innerDraft=dialog.getByRole('textbox',{name:'弹窗草稿',exact:true}); await innerDraft.fill('继续编辑,不关闭');
68
+ const innerHandle=await innerDraft.elementHandle();
69
+ const radio=dialog.getByRole('radio',{name:'莱茵 · 纸面',exact:true});
70
+ await dialog.locator('label.rhine-theme-option').filter({has:page.getByRole('radio',{name:'莱茵 · 纸面',exact:true})}).click();
71
+ await radio.focus(); await page.keyboard.press('ArrowRight');
72
+ const keyboard=await dialog.getByRole('radio',{name:'明日方舟 · 战术终端',exact:true}).isChecked();
73
+ const dialogStable=await innerHandle.evaluate(e=>e.isConnected&&e.value==='继续编辑,不关闭');
74
+ await page.keyboard.press('Escape');
75
+ await dialog.waitFor({state:'detached'});
76
+ await page.getByRole('checkbox',{name:'模拟预览失败',exact:true}).check();
77
+ await page.waitForFunction(()=>document.querySelectorAll('.rhine-theme-preview[data-state=error]').length===3);
78
+ const fallback=page.getByRole('radio',{name:/橙趣/});
79
+ await page.locator('label.rhine-theme-option').filter({has:fallback}).click();
80
+ const fallbackSelectable=await fallback.isChecked();
81
+ await page.getByRole('checkbox',{name:'禁用主题选择',exact:true}).check();
82
+ const disabled=await page.getByRole('radio').evaluateAll(es=>es.every(e=>e.matches(':disabled')));
83
+ const pass=!overflow&&cards.every(r=>r.x>=-1&&r.right<=r.width+1&&r.height>=44)&&stable&&JSON.stringify(srcs)===JSON.stringify(srcsAfter)&&keyboard&&dialogStable&&fallbackSelectable&&disabled;
84
+ if(!pass) failures.push({kind:'theme',theme,width,zoom,overflow,cards,stable,keyboard,dialogStable,fallbackSelectable,disabled});
85
+ themeCases++;
86
+ }
87
+ return {pass:failures.length===0&&errors.length===0,settingsCases,themeCases,failures,errors};
88
+ }