@linhey/rhine-design 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,12 @@
1
1
  # 版本记录
2
2
 
3
+ ## 0.6.0(2026-10-24)
4
+
5
+ - 对应 [#8](https://github.com/linhay/rhine-design/issues/8):新增 `OrbitBackdrop`,将授权移交的轨道艺术维护为公共组件,保留 420 个环节点、185 条空间曲线、箭头、光晕与透视;公共组件增至 60 个,无新增运行时依赖。
6
+ - 内置暂停 / 继续,遵循系统、显式及祖先减少动态偏好;窄窗、页面隐藏、离屏与零尺寸停帧,恢复沿冻结时间继续,卸载清理资源;Canvas 不可用时保留静态艺术与内容。逻辑尺寸使用 client size,保持 CSS 缩放后的描线和静态重绘一致。
7
+ - 固定深色艺术仅调整继承文字和 Logo,不覆写宿主控件的主题 tokens;表单、按钮及 Surface 保持各主题前景与背景成对。新增 `#orbit` 真实展台、[轨道背景契约](docs/ORBIT_BACKDROP.md)、生命周期测试与三主题组合示例。
8
+ - 保留 0.5.0 的全部应用组件、受控状态与 Ant Design / MUI 契约改进。`OrbitBackdrop` 自 0.6.0 提供,0.5.0 及以前不包含此组件;建议固定精确版本并提交 lockfile。
9
+
3
10
  ## 0.5.0(2026-10-24)
4
11
 
5
12
  - 完成 [Ant Design / MUI 逐项对标](docs/ANTD_MUI_AUDIT.md):补齐表单只读/校验/键盘、连续多选、树字符导航、数据表单选/排序清除/自动固定列、分页首末页/边界焦点、受控左右抽屉、媒体方向键/首尾焦点、图表受控逐点查看与格式化、健康历史稳定 ID 与受控详情。RemoteMultiSelect 默认连续选择,`closeOnSelect=true` 可恢复选完关闭;原单选 Combobox 默认不变。无新增运行时依赖。
package/README.md CHANGED
@@ -16,10 +16,10 @@
16
16
  <a href="CHANGELOG.md">版本记录</a>
17
17
  </p>
18
18
 
19
- Rhine Design 是面向 React 应用的开源设计系统,适合桌面工具、管理界面和数据密集型应用。它将设计规范、48 个组件和动效示例放在一起,帮助团队建立一致、可复用的界面语言。
19
+ Rhine Design 是面向 React 应用的开源设计系统,适合桌面工具、管理界面和数据密集型应用。它将设计规范、60 个组件和动效示例放在一起,帮助团队建立一致、可复用的界面语言。
20
20
 
21
21
  - **一致的视觉语言**:色彩、字体、文字层级、间距、几何与焦点规则。
22
- - **48 个可组合组件**:覆盖布局、表单、数据、导航、浮层和反馈。
22
+ - **60 个可组合组件**:覆盖布局、表单、数据、导航、浮层和反馈。
23
23
  - **可验证的动效**:12 组播放 / 重播 / 复位演示,以及三角构架启动编排。
24
24
  - **面向开发协作**:组件契约、交互展台和 AI skill 帮助设计与实现保持一致。
25
25
 
@@ -29,6 +29,8 @@ Rhine Design 是面向 React 应用的开源设计系统,适合桌面工具、
29
29
 
30
30
  **0.5.0 新增能力**:新增背景与多侧栏、复杂表单、受控数据表、媒体与监控图表,共 59 个组件。API、最小接入与版本边界见[应用组件契约](docs/APPLICATION_COMPONENTS.md)。npm 0.4.0 不含这些能力。
31
31
 
32
+ **0.6.0 新增能力**:新增 `OrbitBackdrop` 轨道艺术组件,提供暂停、减少动态与静态降级,总数增至 60。安装前核对[版本记录](CHANGELOG.md)与 registry;在线展台跟随 main。
33
+
32
34
  本轮新增 API 与交互已逐项核对 Ant Design / MUI,修复及明确保留的差异见[对标清单](docs/ANTD_MUI_AUDIT.md)。
33
35
 
34
36
  <a id="quick-start"></a>
@@ -40,7 +42,7 @@ Rhine Design 是面向 React 应用的开源设计系统,适合桌面工具、
40
42
  在你的 React 项目中安装:
41
43
 
42
44
  ```sh
43
- npm install --save-exact @linhey/rhine-design@0.5.0
45
+ npm install --save-exact @linhey/rhine-design@0.6.0
44
46
  ```
45
47
 
46
48
  包与版本信息见 [npm](https://www.npmjs.com/package/@linhey/rhine-design)。如需使用未发版的源码,可安装 `git+https://github.com/linhay/rhine-design.git#<commit>`;建议固定版本并提交 lockfile。
@@ -73,6 +75,10 @@ export function Welcome() {
73
75
 
74
76
  0.5.0 提供 Background 与 AppShell,npm 0.4.0 不含这两项能力。背景层级、最小接入及兼容范围见[背景与应用外壳契约](docs/APPLICATION_COMPONENTS.md),真实展台见 [场景背景](https://linhay.github.io/rhine-design/#backgrounds) 和 [背景与侧栏](https://linhay.github.io/rhine-design/#app-shell)。
75
77
 
78
+ ### 轨道背景(0.6.0)
79
+
80
+ OrbitBackdrop 承接授权移交的轨道动画,保留环节点、曲线、箭头、光晕与透视,并提供暂停、减少动态和离屏停帧。它是纯装饰,不表示加载或业务进度。契约与接入见[轨道背景](docs/ORBIT_BACKDROP.md),真实展台见[轨道星空](https://linhay.github.io/rhine-design/#orbit)。
81
+
76
82
  ### 一键切换主题(0.4.0)
77
83
 
78
84
  展台右上角可切换并记忆主题。新增 **橙趣 · 粗描边**:浅灰蓝点阵背景、米白圆角卡片、黑色粗边、向右下偏移的硬阴影与亮橙主按钮。打开 [在线订阅管理示例](https://linhay.github.io/rhine-design/?theme=neo-brutalism#subscriptions),体验卡片详情、管理菜单与演示表单;本地开发访问 `http://127.0.0.1:18309/?theme=neo-brutalism#subscriptions`。
@@ -118,14 +124,14 @@ export function App() {
118
124
  | --- | --- |
119
125
  | 品牌与文字 | Logo · Text |
120
126
  | 操作 | Button · Icon · IconButton · Link · CopyButton |
121
- | 布局 | Surface · Toolbar · Divider · Stack · Inline · Grid |
122
- | 表单与选择 | TextField · Fieldset¹ · ErrorSummary¹ · Textarea · Select · Checkbox · RadioGroup · Switch · SearchField · Combobox · SelectionCard |
127
+ | 布局与背景 | Surface · Toolbar · Divider · Stack · Inline · Grid · Background² · AppShell² · OrbitBackdrop³ |
128
+ | 表单与选择 | TextField · Fieldset¹ · ErrorSummary¹ · Textarea · Select · Checkbox · RadioGroup · Switch · SearchField · Combobox · SelectionCard · DateField² · DateRangePicker² · NumberField² · TreeSelect² · RemoteMultiSelect² |
123
129
  | 导航与展开 | SideNav¹ · Breadcrumbs¹ · Steps¹ · Tabs · SegmentedControl · Disclosure |
124
- | 数据 | Metric · KeyValue · DataTable · Pagination · StatusTimeline¹ · TimelineLegend¹ |
130
+ | 数据与媒体 | Metric · KeyValue · DataTable · Pagination · StatusTimeline¹ · TimelineLegend¹ · TrendChart² · StackedBar² · MediaCard² · MediaViewer² |
125
131
  | 浮层 | Drawer¹ · Dialog · ConfirmDialog · Popover · Menu · Tooltip |
126
132
  | 状态与反馈 | Badge · Notice · Toast · Progress · Skeleton · EmptyState |
127
133
 
128
- ¹ 自 0.4.0 提供。
134
+ ¹ 自 0.4.0 提供。² 自 0.5.0 提供。³ 自 0.6.0 提供。
129
135
 
130
136
  参数、状态和组合示例见[组件 API](components/README.md)与[常用组件契约](components/COMMON_COMPONENTS.md)。
131
137
 
@@ -143,6 +149,7 @@ export function App() {
143
149
  | 品牌几何与 Logo | [Logo 契约](components/README.md#品牌标志) | [品牌标志](https://linhay.github.io/rhine-design/#brand) |
144
150
  | 动效、交互与减少动态 | [Motion](components/MOTION.md) | [动效实验室](https://linhay.github.io/rhine-design/#motion) |
145
151
  | 启动动画与就绪衔接 | [Startup](components/STARTUP.md) | [启动动画](https://linhay.github.io/rhine-design/#startup) |
152
+ | 轨道装饰、暂停与静态降级 | [OrbitBackdrop](docs/ORBIT_BACKDROP.md) | [轨道星空](https://linhay.github.io/rhine-design/#orbit) |
146
153
  | 场景与交互选型 | [使用规范](docs/INTERACTION_PATTERNS.md) | [场景指南](https://linhay.github.io/rhine-design/#patterns) |
147
154
  | 导航与表单流程 | [组件契约](docs/INTERACTION_PATTERNS.md#新组件契约) | [导航](https://linhay.github.io/rhine-design/#navigation) · [表单](https://linhay.github.io/rhine-design/#form-workflow) |
148
155
  | 系统覆盖与演进方向 | [组件系统对照](docs/DESIGN_SYSTEM_AUDIT.md) | — |
@@ -63,6 +63,14 @@
63
63
 
64
64
  参考站是设计来源,组件复用入口仍是本仓库。当前CSS/Web Animations及Radix已能承载上述方案,本次不引入额外动画库、CLI、远程Builder或评分流程。未来需要复用外部源码时单独核对许可与依赖,参考授权不等于已取得代码授权。
65
65
 
66
+ ### 轨道装饰的复用边界(0.6.0)
67
+
68
+ 授权移交的 `OrbitBackdrop` 是独立艺术背景,保留原有环节点、曲线、发射箭头、光晕与透视;不修改通用 Logo、加载或交互配方。组件拥有 Canvas 绘制和生命周期,默认提供暂停 / 继续按钮;业务只组合布局,不另写一套动画。它不表示加载、进度或操作成功,普通业务刷新不触发重播。
69
+
70
+ 系统、显式 prop 和祖先的减少动态偏好均可停帧,内层跟随系统不覆盖外层减少动态,`reducedMotion={false}` 不能覆盖系统偏好。窗口不超过 760px、页面隐藏、组件离屏或零尺寸时也保持静态;恢复沿冻结时间继续,不追赶。resize 静态重绘,DPR 上限为 2,卸载清理 rAF、监听器与 observers;Canvas 不可用时保留静态艺术和内容。
71
+
72
+ 展台 `/#orbit` 使用真实公开组件,提供暂停 / 继续、局部重播、手动减少动态及系统 / 全局偏好继承。重播只重建艺术组件,外层业务内容不重建。此艺术背景不计入 `/#motion` 的 12 组配方;npm 0.5.0 及以前不包含它。API、三主题可读性与验收清单见[轨道背景契约](../docs/ORBIT_BACKDROP.md)。
73
+
66
74
  ## 后续验收
67
75
 
68
76
  每项候选先在真实组件展台展示,再更新实现状态。检查鼠标/键盘、快速反向、重复点击、加载成功与失败、长文本、400/520/880宽度、200%缩放、系统/手动减少动态;浮层额外检查四向避让与嵌套Escape。对比前后截图核对布局,对动画全过程录屏或采样核对时序,中间帧与最终帧分别记录。
@@ -71,6 +79,6 @@
71
79
 
72
80
  2026-10-22展台验证:Chrome通过400/520/880/1200宽度、200%缩放、10组播放/复位、快速重播取消旧结果、四向位置/位移采样、菜单键盘、弹窗内重播保焦点与关闭归还、折叠中途反向、通知关闭、Tooltip Escape、逐例/全局/系统减少动态。采样确认4倍慢放的内容淡入720ms、反馈480ms、浮层640ms、折叠800ms及品牌960ms;未将合成浏览器结果扩展为原生或业务验收。
73
81
 
74
- ## 导航与流程动效(main,未发布)
82
+ ## 导航与流程动效(0.4.0)
75
83
 
76
84
  新增抽屉与步骤两组示例,实验室现为12组。Drawer继承160ms浮层token,从右侧3px淡入;弹层内重播只重启CSS动画,焦点与内容不重建。Steps当前段使用180ms/3px进入,由业务校验推进。表单错误直接呈现并定位,无抖动效果。完整适用场景、减少动态与告警规则见[交互规范](../docs/INTERACTION_PATTERNS.md)。
@@ -0,0 +1,33 @@
1
+ import { useEffect, useRef, useState, type HTMLAttributes } from 'react';
2
+ import { Button } from './components';
3
+ import { mountOrbit, type OrbitRenderer, type OrbitState } from './orbitRenderer';
4
+
5
+ export interface OrbitBackdropProps extends HTMLAttributes<HTMLDivElement> {
6
+ /** Adds a static preference; false never overrides the system or an ancestor. */
7
+ reducedMotion?: boolean;
8
+ }
9
+
10
+ export function OrbitBackdrop({ reducedMotion = false, children, className = '', ...props }: OrbitBackdropProps) {
11
+ const canvas = useRef<HTMLCanvasElement>(null);
12
+ const renderer = useRef<OrbitRenderer | null>(null);
13
+ const [paused, setPaused] = useState(false);
14
+ const [state, setState] = useState<OrbitState>('empty');
15
+
16
+ useEffect(() => {
17
+ const scene = mountOrbit(canvas.current!, setState);
18
+ renderer.current = scene;
19
+ return () => { scene.dispose(); renderer.current = null; };
20
+ }, []);
21
+ useEffect(() => { renderer.current?.setPaused(paused); }, [paused]);
22
+ useEffect(() => { renderer.current?.setReducedMotion(reducedMotion); }, [reducedMotion]);
23
+
24
+ const canControl = state === 'playing' || state === 'paused';
25
+ const label = state === 'reduced' ? '已减少动态' : state === 'compact' ? '窄屏静态画面'
26
+ : canControl ? paused ? '继续动效' : '暂停动效' : '静态画面';
27
+
28
+ return <div {...props} className={`rhine-orbit ${className}`} data-rhine-orbit-state={state}>
29
+ <canvas ref={canvas} className="rhine-orbit-canvas" aria-hidden="true" />
30
+ <div className="rhine-orbit-content">{children}</div>
31
+ <div className="rhine-orbit-controls"><Button variant="secondary" disabled={!canControl} onClick={() => setPaused(value => !value)}>{label}</Button></div>
32
+ </div>;
33
+ }
@@ -2,7 +2,7 @@
2
2
 
3
3
  本目录提供[莱茵设计 · Rhine Design](../README.md)的 React 组件、设计变量和 API 契约。组件接收应用传入的数据与动作,不绑定特定后端或原生平台。包名为`@linhey/rhine-design`;样式变量和类名使用`rhine-`命名空间。
4
4
 
5
- 0.5.0 共59个组件;0.4.0为48个,0.3.0为40个。新增导航、表单流程及健康时间轴组件见[场景与交互规范](../docs/INTERACTION_PATTERNS.md),原24个扩展组件见[常用组件契约](./COMMON_COMPONENTS.md)。[字体与文字规范](./TYPOGRAPHY.md)与[主流系统对照与缺口](../docs/DESIGN_SYSTEM_AUDIT.md)共同约束复用。静态页面样稿也使用同源Toolbar/Select CSS,不运行React。
5
+ 0.6.0 共60个组件;0.5.0为59个,0.4.0为48个,0.3.0为40个。新增导航、表单流程及健康时间轴组件见[场景与交互规范](../docs/INTERACTION_PATTERNS.md),原24个扩展组件见[常用组件契约](./COMMON_COMPONENTS.md)。[字体与文字规范](./TYPOGRAPHY.md)与[主流系统对照与缺口](../docs/DESIGN_SYSTEM_AUDIT.md)共同约束复用。静态页面样稿也使用同源Toolbar/Select CSS,不运行React。
6
6
 
7
7
  页面改动遵循:优先复用组件 → 先在设计稿/展台修改并验证 → 再按任务授权实现到App。缺组件先补组件与展台,不在业务页另写同用途样式;同一任务已授权设计和实现时连续推进,无需重复确认。
8
8
 
@@ -150,3 +150,7 @@ export function Example() {
150
150
  ## 背景与外壳(0.5.0)
151
151
 
152
152
  新增 `Background` 与 `AppShell`,0.5.0 共 59 个组件;npm 0.4.0 仍为 48 个。Background 支持六类 roleTone、auto/theme/none 装饰策略及 appearance 局部覆盖;AppShell 提供组合展台使用的固定顶部操作区与响应式侧栏。完整 API、最小接入、背景滚动及 Portal 边界见[背景与应用外壳契约](../docs/APPLICATION_COMPONENTS.md)。
153
+
154
+ ## 轨道背景(0.6.0)
155
+
156
+ `OrbitBackdrop` 接收 `HTMLAttributes<HTMLDivElement>` 和 `reducedMotion?: boolean`,包含可选 `children`。它提供固定深色轨道艺术与内置暂停 / 继续按钮,Canvas 为 `aria-hidden` 装饰。系统、显式减少动态、祖先偏好、窄窗、页面隐藏、离屏及零尺寸时停帧;恢复沿冻结时间继续,不追赶。Canvas 不可用时保留静态艺术与内容。宿主只组合布局,不复制动画或覆盖组件状态。完整生命周期、主题及接入约束见[轨道背景契约](../docs/ORBIT_BACKDROP.md),真实组件展台为 `/#orbit`。自 0.6.0 起共 60 个组件。
@@ -1,4 +1,6 @@
1
1
  import './styles.css';
2
+ export { OrbitBackdrop } from './OrbitBackdrop';
3
+ export type { OrbitBackdropProps } from './OrbitBackdrop';
2
4
  export { Background } from './Background';
3
5
  export type { BackgroundProps, BackgroundRole, BackgroundDecoration, BackgroundAppearance } from './Background';
4
6
  export { AppShell } from './AppShell';
@@ -0,0 +1,42 @@
1
+ .rhine-orbit {
2
+ position:relative; isolation:isolate; display:flex; flex-direction:column;
3
+ min-width:0; min-height:280px; overflow:hidden;
4
+ border:1px solid var(--rhine-line); border-radius:var(--rhine-radius-card);
5
+ background:
6
+ radial-gradient(ellipse at 90% 78%, #15508066 0, transparent 43%),
7
+ radial-gradient(ellipse at 8% 54%, #84603626 0, transparent 35%),
8
+ radial-gradient(ellipse at 62% 18%, #213b653b 0, transparent 48%),
9
+ linear-gradient(155deg, #03070f 12%, #081426 58%, #030a13);
10
+ }
11
+ .rhine-orbit::before, .rhine-orbit::after { content:''; position:absolute; inset:0; pointer-events:none; }
12
+ .rhine-orbit::before {
13
+ background-image:
14
+ radial-gradient(circle at 17px 37px, #d7eaffb3 .7px, transparent 1.4px),
15
+ radial-gradient(circle at 73px 19px, #8bb8e780 .6px, transparent 1.2px),
16
+ radial-gradient(circle at 41px 89px, #d7ba8f80 .7px, transparent 1.3px),
17
+ radial-gradient(circle at 113px 157px, #c4dcff66 .5px, transparent 1.2px);
18
+ background-size:173px 211px, 127px 181px, 251px 239px, 97px 139px;
19
+ opacity:.55;
20
+ }
21
+ .rhine-orbit::after {
22
+ background:
23
+ radial-gradient(ellipse at 77% 66%, #52a4f21a 0, transparent 28%),
24
+ radial-gradient(ellipse at 24% 84%, #b8874220 0, transparent 30%),
25
+ linear-gradient(125deg, transparent 40%, #6295d109 49%, transparent 58%);
26
+ }
27
+ .rhine-orbit-canvas { position:absolute; inset:0; display:block; width:100%; height:100%; pointer-events:none; }
28
+ /* Keep host control tokens intact; only unpainted content inherits the artwork foreground. */
29
+ .rhine-orbit-content {
30
+ position:relative; z-index:1; flex:1; min-width:0;
31
+ display:flex; flex-direction:column; justify-content:space-between; gap:var(--rhine-space-6);
32
+ padding:var(--rhine-space-6); padding-bottom:calc(44px + var(--rhine-space-6) * 2);
33
+ color:#f2f7ff;
34
+ }
35
+ .rhine-orbit-content :is(.rhine-text--muted, .rhine-field__hint) { color:inherit; opacity:.8; }
36
+ .rhine-orbit-content .rhine-logo:not(.rhine-logo--icon) { color:inherit; }
37
+ /* Transparent actions follow their backdrop; painted hover/disabled states retain the host palette. */
38
+ .rhine-orbit-content .rhine-button--quiet:not(:disabled):not(:hover),
39
+ .rhine-orbit-content .rhine-button--quiet[aria-disabled=true]:not(:disabled) { color:inherit; }
40
+ .rhine-orbit .rhine-orbit-content .rhine-button--quiet:focus-visible { outline:none; box-shadow:inset 0 0 0 2px currentColor; }
41
+ .rhine-orbit-controls { position:absolute; right:var(--rhine-space-4); bottom:var(--rhine-space-4); z-index:2; max-width:calc(100% - var(--rhine-space-4) * 2); }
42
+ .rhine-orbit-controls .rhine-button { max-width:100%; }
@@ -0,0 +1,230 @@
1
+ type Point = { x: number; y: number; depth: number; perspective: number };
2
+ type Edge = { a: number; sweep: number; phase: number; waist: number; lift: number; color: number; speed: number };
3
+ type Layer =
4
+ | { kind: 'void'; points: Point[] }
5
+ | { kind: 'line'; p: Point; q: Point; color: number }
6
+ | { kind: 'particle'; p: Point; color: number }
7
+ | { kind: 'spike'; p: Point; q: Point; i: number; pulse: number };
8
+ type CanvasScene = { el: HTMLCanvasElement; ctx: CanvasRenderingContext2D; w: number; h: number };
9
+ export type OrbitState = 'playing' | 'paused' | 'reduced' | 'compact' | 'hidden' | 'offscreen' | 'empty' | 'unavailable';
10
+ export interface OrbitRenderer { setPaused(value: boolean): void; setReducedMotion(value: boolean): void; dispose(): void }
11
+
12
+ /** The seeded artwork is stable across mounts; only elapsed visible time advances it. */
13
+ export function mountOrbit(canvas: HTMLCanvasElement, onState: (state: OrbitState) => void): OrbitRenderer {
14
+ let ctx: CanvasRenderingContext2D | null = null;
15
+ try { ctx = canvas.getContext('2d'); } catch { /* Canvas may be disabled by the host. */ }
16
+ if (!ctx) {
17
+ onState('unavailable');
18
+ return { setPaused() {}, setReducedMotion() {}, dispose() {} };
19
+ }
20
+ const TAU = Math.PI * 2, colors = ["#51b7f6", "#e5b94d", "#2ec7a1", "#bdcbe1"];
21
+ let seed = 1823;
22
+ function rand() {
23
+ seed = seed * 1664525 + 1013904223 >>> 0;
24
+ return seed / 4294967296;
25
+ }
26
+ const nodes = Array.from({ length: 420 }, (_, i) => ({ a: i / 420 * TAU, r: 1.025 + rand() * 0.07, z: (rand() - 0.5) * 0.34, length: 0.035 + rand() * 0.19, color: i % 7 === 0 ? 1 : i % 11 === 0 ? 2 : 3 }));
27
+ const emissions = nodes.map(() => ({ phase: rand(), rate: 0.28 + rand() * 0.3 }));
28
+ const edges = Array.from({ length: 185 }, () => ({ a: rand() * TAU, sweep: 2.1 + rand() * 3.8, phase: rand() * TAU, waist: 0.5 + rand() * 0.32, lift: (rand() - 0.3) * 0.6, color: rand() < 0.32 ? 1 : rand() < 0.1 ? 2 : 0, speed: 0.026 + rand() * 0.024 }));
29
+ function edgePoint(e: Edge, t: number) {
30
+ const a = e.a + t * e.sweep, envelope = Math.sin(Math.PI * t);
31
+ return { a, r: 1.035 - (1.035 - e.waist) * Math.pow(envelope, 0.72), z: e.lift * envelope + 0.06 * Math.sin(e.phase + t * TAU) * envelope };
32
+ }
33
+ const paths = edges.map((e) => Array.from({ length: 81 }, (_, i) => edgePoint(e, i / 80)));
34
+ const canvases = { network: { el: canvas, ctx, w: 0, h: 0 } };
35
+ let time = 0, orbit = 0, projected: Point[] = [], last: number | null = null, frame = 0;
36
+ const yaw = -0.72, tilt = 0.22;
37
+ let paused = false, manualReduced = false, disposed = false, visible = true;
38
+ let previousState: OrbitState | undefined;
39
+ function resize() {
40
+ const dpr = Math.min(window.devicePixelRatio || 1, 2);
41
+ // Layout CSS pixels exclude CSS zoom; visual scaling must not change stroke proportions.
42
+ const c = canvases.network;
43
+ c.w = c.el.clientWidth;
44
+ c.h = c.el.clientHeight;
45
+ c.el.width = Math.round(c.w * dpr);
46
+ c.el.height = Math.round(c.h * dpr);
47
+ c.ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
48
+ draw();
49
+ }
50
+ function line(ctx2: CanvasRenderingContext2D, x1: number, y1: number, x2: number, y2: number, color: string | CanvasGradient, width = 1) {
51
+ ctx2.strokeStyle = color;
52
+ ctx2.lineWidth = width;
53
+ ctx2.beginPath();
54
+ ctx2.moveTo(x1, y1);
55
+ ctx2.lineTo(x2, y2);
56
+ ctx2.stroke();
57
+ }
58
+ function dot(ctx2: CanvasRenderingContext2D, x: number, y: number, r: number, color: string | CanvasGradient) {
59
+ ctx2.fillStyle = color;
60
+ ctx2.beginPath();
61
+ ctx2.arc(x, y, r, 0, TAU);
62
+ ctx2.fill();
63
+ }
64
+ function clear(c: CanvasScene) {
65
+ c.ctx.clearRect(0, 0, c.w, c.h);
66
+ }
67
+ function project(a: number, r = 1, z = 0) {
68
+ const c = canvases.network, scale = Math.min(c.w * 0.66, c.h * 0.39), x = Math.cos(a) * r, y = Math.sin(a) * r;
69
+ const yy = y * Math.cos(tilt) - z * Math.sin(tilt), zz = y * Math.sin(tilt) + z * Math.cos(tilt);
70
+ const xx = x * Math.cos(yaw) + zz * Math.sin(yaw), depth = -x * Math.sin(yaw) + zz * Math.cos(yaw);
71
+ const spin = 0.34, px = xx * Math.cos(spin) - yy * Math.sin(spin), py = xx * Math.sin(spin) + yy * Math.cos(spin), perspective = 3.6 / (3.6 - depth);
72
+ return { x: c.w * 0.62 + px * scale * perspective, y: c.h * 0.57 + py * scale * perspective * 1.08, depth, perspective };
73
+ }
74
+ function network() {
75
+ const c = canvases.network, { ctx: ctx2 } = c;
76
+ clear(c);
77
+ const buckets: Layer[][] = Array.from({ length: 80 }, () => []), offset = orbit;
78
+ function add(item: Layer, depth: number) {
79
+ buckets[Math.max(0, Math.min(79, Math.floor((depth + 1.5) / 3 * 80)))].push(item);
80
+ }
81
+ const horizon = Array.from({ length: 101 }, (_, i) => project(i / 100 * TAU, 0.46, -0.06));
82
+ add({ kind: "void", points: horizon }, -0.04);
83
+ edges.forEach((e, index) => {
84
+ const pts = paths[index].map((p) => project(p.a + offset, p.r, p.z));
85
+ for (let i = 1; i < pts.length; i++) add({ kind: "line", p: pts[i - 1], q: pts[i], color: e.color }, (pts[i - 1].depth + pts[i].depth) / 2 + 7e-3);
86
+ if (index % 3 === 0) {
87
+ const t = (time * e.speed + e.phase / TAU) % 1, p3 = edgePoint(e, t), p = project(p3.a + offset, p3.r, p3.z);
88
+ add({ kind: "particle", p, color: e.color }, p.depth + 0.012);
89
+ }
90
+ });
91
+ projected = nodes.map((n) => project(n.a + offset, n.r, n.z));
92
+ nodes.forEach((n, i) => {
93
+ const emission = emissions[i], pulse = (time * emission.rate + emission.phase) % 1, breath = 1 + 0.18 * Math.sin(time * 1.7 + n.a * 5), p = projected[i], q = project(n.a + offset, n.r + n.length * breath, n.z * 1.1);
94
+ add({ kind: "spike", p, q, i, pulse }, (p.depth + q.depth) / 2);
95
+ });
96
+ const palette = ["104,178,244", "223,187,92", "72,182,179"];
97
+ for (const bucket of buckets) for (const item of bucket) {
98
+ if (item.kind === "void") {
99
+ ctx2.beginPath();
100
+ item.points.forEach((p2, i) => i ? ctx2.lineTo(p2.x, p2.y) : ctx2.moveTo(p2.x, p2.y));
101
+ ctx2.closePath();
102
+ ctx2.fillStyle = "#010205";
103
+ ctx2.fill();
104
+ continue;
105
+ }
106
+ const p = item.p, near = Math.max(0, Math.min(1, (p.depth + 1) / 2));
107
+ if (item.kind === "line") {
108
+ ctx2.strokeStyle = "rgba(" + palette[item.color] + "," + (0.07 + near * near * 0.57) + ")";
109
+ ctx2.lineWidth = 0.35 + near * 0.65;
110
+ ctx2.beginPath();
111
+ ctx2.moveTo(p.x, p.y);
112
+ ctx2.lineTo(item.q.x, item.q.y);
113
+ ctx2.stroke();
114
+ } else if (item.kind === "spike") {
115
+ const q = item.q, bright = near > 0.48, rgb = bright ? "173,218,255" : "225,161,71", dx = q.x - p.x, dy = q.y - p.y, len = Math.hypot(dx, dy) || 1, ux = dx / len, uy = dy / len;
116
+ line(ctx2, p.x, p.y, q.x, q.y, bright ? "rgba(213,224,241," + (0.16 + near * 0.35) + ")" : "rgba(181,118,53," + (0.12 + near * 0.3) + ")", 0.4 + near * 0.4);
117
+ const t = item.pulse, fade = Math.sin(Math.PI * t), alpha = fade * (0.25 + near * 0.75), x = p.x + dx * t, y = p.y + dy * t, tail = Math.max(0, t - 0.38), tx = p.x + dx * tail, ty = p.y + dy * tail;
118
+ const trail = ctx2.createLinearGradient(tx, ty, x + 1e-3, y + 1e-3);
119
+ trail.addColorStop(0, "rgba(" + rgb + ",0)");
120
+ trail.addColorStop(1, "rgba(" + rgb + "," + alpha + ")");
121
+ line(ctx2, tx, ty, x, y, trail, 0.7 + near * 0.9);
122
+ const size = (1.6 + near * 1.5) * q.perspective;
123
+ ctx2.strokeStyle = "rgba(" + rgb + "," + alpha + ")";
124
+ ctx2.lineWidth = 0.65 + near * 0.5;
125
+ ctx2.beginPath();
126
+ ctx2.moveTo(x - ux * size - uy * size * 0.55, y - uy * size + ux * size * 0.55);
127
+ ctx2.lineTo(x, y);
128
+ ctx2.lineTo(x - ux * size + uy * size * 0.55, y - uy * size - ux * size * 0.55);
129
+ ctx2.stroke();
130
+ if (item.i % 3 === 0) {
131
+ const radius = (4 + near * 4) * q.perspective, glow = ctx2.createRadialGradient(x, y, 0, x, y, radius);
132
+ glow.addColorStop(0, "rgba(" + rgb + "," + alpha * 0.45 + ")");
133
+ glow.addColorStop(1, "rgba(" + rgb + ",0)");
134
+ dot(ctx2, x, y, radius, glow);
135
+ dot(ctx2, x, y, 0.65 + near * 0.45, "rgba(242,247,255," + alpha + ")");
136
+ }
137
+ const flash = Math.pow(Math.max(0, 1 - Math.abs(t - 0.88) / 0.12), 2);
138
+ dot(ctx2, q.x, q.y, (0.55 + flash * 1.1) * q.perspective, "rgba(" + rgb + "," + (0.18 + near * 0.25 + flash * 0.35) + ")");
139
+ } else {
140
+ ctx2.shadowBlur = 5;
141
+ ctx2.shadowColor = colors[item.color];
142
+ dot(ctx2, p.x, p.y, 1.1 * p.perspective, colors[item.color]);
143
+ ctx2.shadowBlur = 0;
144
+ }
145
+ }
146
+ for (let i = 0; i < 8; i++) {
147
+ const e = edges[i * 19], v = edgePoint(e, 0.4), p = project(v.a + offset, v.r, v.z);
148
+ if (p.depth < -0.1) continue;
149
+ ctx2.shadowColor = "#f25380";
150
+ ctx2.shadowBlur = 10;
151
+ dot(ctx2, p.x, p.y, 2.2 * p.perspective, "#ff658c");
152
+ ctx2.shadowBlur = 0;
153
+ }
154
+ }
155
+ const reduced = window.matchMedia?.('(prefers-reduced-motion: reduce)');
156
+ const compact = window.matchMedia?.('(max-width: 760px)');
157
+ function draw() {
158
+ if (canvases.network.w && canvases.network.h) network();
159
+ }
160
+ function state(): OrbitState {
161
+ if (manualReduced || reduced?.matches || canvas.closest('[data-rhine-motion="reduced"]')) return 'reduced';
162
+ if (compact?.matches) return 'compact';
163
+ if (document.hidden) return 'hidden';
164
+ if (!visible) return 'offscreen';
165
+ if (canvases.network.w <= 0 || canvases.network.h <= 0) return 'empty';
166
+ return paused ? 'paused' : 'playing';
167
+ }
168
+ function animate(now: number) {
169
+ frame = 0;
170
+ if (disposed) return;
171
+ // A frame can observe a media change before its change event is delivered.
172
+ if (state() !== 'playing') { sync(); return; }
173
+ const dt = last === null ? 0 : Math.min((now - last) / 1e3, 0.1);
174
+ last = now;
175
+ time += dt;
176
+ orbit += dt * 0.028;
177
+ draw();
178
+ frame = requestAnimationFrame(animate);
179
+ }
180
+ function sync() {
181
+ if (disposed) return;
182
+ cancelAnimationFrame(frame);
183
+ frame = 0;
184
+ last = null;
185
+ resize();
186
+ const next = state();
187
+ if (next !== previousState) { previousState = next; onState(next); }
188
+ if (next === 'playing') frame = requestAnimationFrame(animate);
189
+ }
190
+ const observer = typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(sync);
191
+ observer?.observe(canvas);
192
+ const intersection = typeof IntersectionObserver === 'undefined' ? null : new IntersectionObserver(entries => {
193
+ visible = entries.some(entry => entry.target === canvas && entry.isIntersecting);
194
+ sync();
195
+ });
196
+ intersection?.observe(canvas);
197
+ const motion = new MutationObserver(sync);
198
+ // Observe ancestors only: canvas sizing and React state writes must not retrigger drawing.
199
+ for (let ancestor = canvas.parentElement; ancestor; ancestor = ancestor.parentElement) {
200
+ motion.observe(ancestor, { attributes: true, attributeFilter: ['data-rhine-motion'] });
201
+ }
202
+ reduced?.addEventListener?.('change', sync);
203
+ compact?.addEventListener?.('change', sync);
204
+ window.addEventListener('resize', sync);
205
+ document.addEventListener("visibilitychange", sync);
206
+ sync();
207
+ return {
208
+ setPaused(value) {
209
+ if (paused === value) return;
210
+ paused = value;
211
+ sync();
212
+ },
213
+ setReducedMotion(value) {
214
+ if (manualReduced === value) return;
215
+ manualReduced = value;
216
+ sync();
217
+ },
218
+ dispose() {
219
+ disposed = true;
220
+ cancelAnimationFrame(frame);
221
+ observer?.disconnect();
222
+ intersection?.disconnect();
223
+ motion.disconnect();
224
+ reduced?.removeEventListener?.('change', sync);
225
+ compact?.removeEventListener?.('change', sync);
226
+ window.removeEventListener('resize', sync);
227
+ document.removeEventListener("visibilitychange", sync);
228
+ }
229
+ };
230
+ }
@@ -5,6 +5,7 @@
5
5
  @import './timeline.css';
6
6
  @import './extensions.css';
7
7
  @import './background.css';
8
+ @import './orbit.css';
8
9
  @import './charts.css';
9
10
 
10
11
  .rhine-theme { color:var(--rhine-ink); background:var(--rhine-bg); font:var(--rhine-text-body)/var(--rhine-leading-body) var(--rhine-font); font-synthesis:none; }
@@ -0,0 +1,81 @@
1
+ # OrbitBackdrop · 轨道背景(0.6.0)
2
+
3
+ `OrbitBackdrop` 将授权移交的轨道动画作为通用装饰组件维护:固定深色星空衬底、420 个环节点、185 条曲线、80 个深度桶,以及发射箭头、光晕和透视关系。它适合登录、欢迎等入口区域;不包含产品文案、账号逻辑或网络请求。需求与移交记录见 [Issue #8](https://github.com/linhay/rhine-design/issues/8)。
4
+
5
+ **版本边界:**此组件自 `@linhey/rhine-design@0.6.0` 提供,0.5.0 及以前不包含它。采用前核对安装版本与 `components/index.ts`,固定精确版本并提交 lockfile。组件源于 [OrbitBackdrop.tsx](../components/OrbitBackdrop.tsx),绘制实现在 [orbitRenderer.ts](../components/orbitRenderer.ts);后者是内部实现,不作为公共 API 导入。
6
+
7
+ ## 使用与契约
8
+
9
+ ```tsx
10
+ import { Logo, OrbitBackdrop, Text } from '@linhey/rhine-design';
11
+
12
+ // 此示例要求 0.6.0 或以上,并放在现有主题根内。
13
+ export function WelcomeArtwork() {
14
+ return (
15
+ <OrbitBackdrop className="welcome-artwork" style={{ minHeight: 360 }}>
16
+ <Logo decorative />
17
+ <Text as="h1" variant="title">开始新的工作</Text>
18
+ <Text>从这里进入你的工作空间。</Text>
19
+ </OrbitBackdrop>
20
+ );
21
+ }
22
+ ```
23
+
24
+ | 输入 | 契约 |
25
+ | --- | --- |
26
+ | `HTMLAttributes<HTMLDivElement>` | 接收容器原生属性,如 `className`、`style`、`id`;宿主负责尺寸、位置和相邻内容布局 |
27
+ | `reducedMotion?: boolean` | `true` 加强减少动态偏好;`false` 或省略均不能覆盖系统、祖先或环境的停帧条件 |
28
+ | `children` | 放入 `.rhine-orbit-content` 内容层,可组合 Logo、标题、正文、表单、动作和页脚,也可用 Surface 分组;宿主 class 控制内容布局,避免遮挡主要艺术或暂停控件 |
29
+
30
+ 组件持有绘制配方、内部类名、样式与暂停状态。宿主不复制 Canvas 实现,不另写同用途动画,不通过覆盖内部样式重建其视觉。没有速度、节点数量、业务进度或主题配色参数;需要新能力时先在设计系统中补契约与展台。
31
+
32
+ Canvas 绝对定位在内容层下方。内容层为直接置于星空上的文字提供浅色前景,普通 Text 与非 icon Logo 继承当前前景,muted Text 沿当前前景降低透明度;不修改宿主颜色 tokens,也不新建或嵌套 `ThemeProvider`。Surface、Notice 等有底色的组件继续使用自己的语义前景,表单与按钮保留宿主主题的前景 / 背景组合。默认内置 Rhine `Button`,在独立控件区域提供“暂停 / 继续”的明确操作。Canvas 为 `aria-hidden` 纯装饰,不接收键盘焦点,不提供进度或业务状态。有效信息仍由普通 HTML 内容表达;动画暂停、减少动态或 Canvas 不可用均不影响内容与业务操作。
33
+
34
+ ## 动效生命周期
35
+
36
+ 下列任一条件成立时停止逐帧调度,保留静态画面:
37
+
38
+ - 用户主动暂停;
39
+ - 系统 `prefers-reduced-motion: reduce`,或 `reducedMotion` 为 `true`;
40
+ - 祖先的 `data-rhine-motion="reduced"` 偏好生效(内层跟随系统不覆盖外层减少动态);
41
+ - 当前窗口宽度不超过 760px;
42
+ - `document.hidden`、组件离屏或可用绘制尺寸为零。
43
+
44
+ “暂停 / 继续”使用随当前动作变化的按钮名称,不同时声明 `aria-pressed` 切换语义。“继续”只解除用户主动暂停,不越过其他停帧条件。恢复时沿冻结的动画时间继续,不累计隐藏期间的时间,也不追赶错过的帧。偏好或可见性改变时即时重新判断;窗口调整会以当前冻结时间重绘静态画面。设备像素比上限为 2,卸载清理 rAF、监听器和 observers,离开页面不会继续后台绘制。
45
+
46
+ Canvas 不可用时保留静态艺术 fallback 与 `children`。此降级不表示浏览器或页面业务失败,也不以加载提示掩盖缺失的装饰能力。
47
+
48
+ ## 主题与可读性
49
+
50
+ 轨道采用同一固定深色美术配方;切换 Rhine、Arknights 或 Neo Brutalism 时保留动画构图。艺术文字采用浅色前景,控件、语义容器、独立操作区与外框继续使用完整宿主主题 tokens;不能只改控件的前景而保留白色背景。直接叠加的提示和透明 quiet 操作继承当前内容前景;quiet 的有底色 hover / disabled 状态保持宿主配色,键盘焦点使用当前文字颜色的内描边。可按产品分组需要使用 Surface,不以它作为控件可读性的前提。宿主应提供足够的布局空间,避免正文与主要曲线重叠,也不能遮住暂停入口。
51
+
52
+ 轨道动画不替代 `Progress`、`Skeleton` 或 `Logo`。登录提交、校验失败、数据刷新不重播动画;动画时间不决定业务何时就绪。仅为重播装饰而更换 key 时,将 key 限于 `OrbitBackdrop`,输入状态由重播边界外的宿主持有,不能因重播重置表单或其他业务状态。
53
+
54
+ ## 真实组件展台与验收
55
+
56
+ 本地展台 `http://127.0.0.1:18309/#orbit` 复用公开组件,提供暂停 / 继续、局部重播、手动减少动态,以及系统和全局偏好继承。重播只重新挂载装饰组件,不重执行业务动作、不清空外层内容。此条目与 `/#motion` 的 12 组交互配方、`/#startup` 的启动编排分别维护。
57
+
58
+ 验收应覆盖以下行为;本节是验收要求,不代表浏览器检查已执行:
59
+
60
+ 1. 原动画的环节点、曲线、箭头、光晕与透视仍可辨识;采样不同时间的中间帧,不能只检查单张截图。
61
+ 2. 鼠标与键盘可暂停 / 继续;暂停时画面冻结,恢复不跳过冻结期间的时间。
62
+ 3. 系统、显式 prop、祖先偏好均可中断动画;将 `reducedMotion` 设为 `false` 不能覆盖系统减少动态。
63
+ 4. 检查 400 / 520 / 880 宽度、200% 缩放和三套主题,确认窄窗静态呈现、控件可见、焦点可读且无横向溢出。组合 TextField、主 / 次按钮及 Surface,检查输入文字、placeholder、默认 / hover / disabled 状态和容器内 Text 的前景与背景。
64
+ 5. 页面隐藏、离屏、零尺寸时停帧,恢复及 resize 行为一致;卸载无残留调度或监听。
65
+ 6. Canvas 不可用时仍显示静态艺术和内容;业务表单、请求与路由不依赖动画。
66
+
67
+ 宿主接入后仍需验证自己的页面布局与业务交互;单元测试、展台检查不等于宿主原生平台或完整辅助技术验收。
68
+
69
+ ### 2026-10-24 初始源码候选验证
70
+
71
+ `npm ci`、`npm run check` 通过:65 项测试(轨道新增 10 项)、TypeScript 类型检查、展台生产构建及 CSS / SVG 产物检查。轨道测试覆盖完整节点绘制、DPR 上限、暂停时间冻结、系统 / prop / 祖先偏好、隐藏 / 离屏 / 窄窗 / 零尺寸、resize、Strict Mode 清理、Canvas 失败降级和重播保焦点。逻辑绘制尺寸使用 CSS client size,避免 CSS zoom 后暂停或 resize 改变线宽;真实浏览器验证固定画幅在 zoom 1→2→1 及 resize 后静态像素保持一致。
72
+
73
+ 本地 Chromium 真实展台通过 19 项检查:跨时间像素采样可见轨道运动;Space 键暂停 / 继续;系统、显式及祖先减少动态与恢复;三主题 × 400 / 520 / 880 宽度的静态 / 播放和无横向溢出;200% CSS 缩放与局部重播。另验证真实滚动离屏后像素冻结,返回可见区域后继续。页面错误为 0。页面隐藏与卸载由单元测试覆盖,不宣称已验证真实业务入口或原生平台。
74
+
75
+ 0.6.0 发布审阅发现初始候选的局部前景 tokens 会污染嵌入控件;现已移除这组覆盖。非 icon Logo 使用默认写法并继承所在内容的前景,无需按艺术底色另设 `theme="dark"`。上述初始截图与 19 项浏览器证据不代替修正后的组合控件验证。
76
+
77
+ 合并 0.5.0 应用能力并准备 0.6.0 后,重新执行 `npm ci` 与 `npm run check`:16 个测试文件、136 项测试、TypeScript、展台构建及 CSS / SVG 产物检查全部通过。真实展台已加入 Surface 内的正文、提示、TextField 与主 / 次按钮,供发布时单独验证三主题可读性。
78
+
79
+ 仓库外生产构建验证进一步发现:动画帧可先于媒体 `change` 事件观察到减少动态,此时只停帧会留下过期的播放状态。帧检查现复用同一同步路径,更新按钮与状态并清空上一帧时间;不增加停帧后的轮询。新增该事件顺序与恢复不追赶的回归后,16 个测试文件、137 项测试(`--maxWorkers=2`)、类型检查及构建通过。
80
+
81
+ ![真实组件展台](assets/orbit-backdrop.png)
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@linhey/rhine-design",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "license": "Apache-2.0",
5
5
  "publishConfig": {
6
6
  "access": "public",
@@ -10,7 +10,7 @@ export const galleryGroups = [
10
10
  { label: '应用能力', items: [['app-shell', '背景与多侧栏'], ['advanced-forms', '复杂表单'], ['advanced-data', '受控数据表'], ['media', '媒体卡片与查看器'], ['monitoring', '健康详情与图表']] },
11
11
  { label: '设计语言', items: [['foundation', '视觉基础'], ['backgrounds', '场景背景'], ['spacing', '间距与组合'], ['typography', '字体与文字'], ['brand', '品牌标志']] },
12
12
  { label: '通用组件', items: [['actions', '按钮与输入'], ['selection', '选择与切换'], ['feedback', '状态与加载'], ['disclosure', '展开与组合'], ['data', '数据与分页'], ['forms', '表单与选择'], ['utilities', '操作与布局'], ['overlays', '浮层与反馈'], ['navigation', '导航与步骤'], ['form-workflow', '表单流程']] },
13
- { label: '交互与动效', items: [['patterns', '场景与交互指南'], ['motion', '动效实验室'], ['startup', '启动动画']] },
13
+ { label: '交互与动效', items: [['patterns', '场景与交互指南'], ['motion', '动效实验室'], ['startup', '启动动画'], ['orbit', '轨道星空']] },
14
14
  ] as const;
15
15
  export type GallerySection = typeof galleryGroups[number]['items'][number][0];
16
16
  const entries = galleryGroups.flatMap(group => [...group.items]);
@@ -94,7 +94,7 @@ function GalleryContent({ motion, onMotionChange, children }: GalleryShellProps)
94
94
  <SideNav id="gallery-navigation" className="ds-sidebar-nav" aria-label="组件目录" currentId={section} groups={galleryGroups.map(group=>({label:group.label,items:group.items.map(([id,label])=>({id,label,href:`#${id}`}))}))} onNavigate={item=>{
95
95
  setMenuOpen(false); if(section===item.id) main.current?.focus({preventScroll:true});
96
96
  }}/>
97
- <div className="ds-sidebar-foot"><span>COMPONENT LIBRARY</span><span>59 个组件 · 持续演进</span><span className="ak-readout">{readout}</span></div>
97
+ <div className="ds-sidebar-foot"><span>COMPONENT LIBRARY</span><span>60 个组件 · 持续演进</span><span className="ak-readout">{readout}</span></div>
98
98
  </aside>
99
99
  <main className="ds-workspace" id="gallery-content" ref={main} tabIndex={-1} aria-label={label}>
100
100
  <header className="ds-workspace-head"><Breadcrumbs label="展台位置" items={[{label:"组件展台",href:"#top"},{label}]} /><div className="ds-head-controls"><SegmentedControl label="动效偏好" items={[{value:'system',label:'跟随系统'},{value:'reduced',label:'减少动态'}]} value={motion} onValueChange={onMotionChange} /><Select label="界面主题" value={theme} onChange={event => {
@@ -0,0 +1,47 @@
1
+ import { useState } from 'react';
2
+ import { Badge, Button, Inline, Logo, OrbitBackdrop, 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 [artworkName, setArtworkName] = useState('Orbit workspace');
8
+ const [workspaceName, setWorkspaceName] = useState('Rhine Studio');
9
+ return <section id="orbit">
10
+ <div className="ds-section-title"><span>ORBIT</span><h2>轨道星空 · 品牌艺术背景</h2></div>
11
+ <p className="ds-caption">420 个环节点、185 条空间曲线与发射箭头组成同一幅画面。用于入口和品牌叙事,内容可立即阅读,不表示加载或业务进度。</p>
12
+ <Inline gap={3}>
13
+ <Badge>0.6.0</Badge>
14
+ <Button variant="secondary" onClick={() => setReplay(value => value + 1)}>重播轨道</Button>
15
+ <Switch label="减少轨道动态" checked={reduced} onCheckedChange={setReduced} />
16
+ </Inline>
17
+ <OrbitBackdrop key={replay} className="ds-gap-top" reducedMotion={reduced} style={{ minHeight: 560 }}>
18
+ <Logo variant="lockup" size={40} />
19
+ <Stack gap={3}>
20
+ <Text as="h3" variant="title">连接彼此,探索更多。</Text>
21
+ <Text tone="muted">在有序的轨迹中,留下新的可能。<br />艺术背景不阻挡内容与操作。</Text>
22
+ <TextField label="直接叠加的输入框" value={artworkName} onChange={event => setArtworkName(event.target.value)} hint="提示文字随当前内容前景保持可读。" />
23
+ <Inline gap={2}>
24
+ <Button>开始探索</Button>
25
+ <Button variant="secondary">查看轨道</Button>
26
+ <Button variant="quiet">了解更多</Button>
27
+ <Button variant="quiet" busy busyLabel="示例处理中">示例处理中</Button>
28
+ <Button variant="quiet" disabled>暂不可用</Button>
29
+ </Inline>
30
+ </Stack>
31
+ <Surface>
32
+ <Stack gap={3}>
33
+ <Text as="h3" variant="heading">带底色的内容保持宿主主题</Text>
34
+ <Text tone="muted">也可组合到 Surface,保留完整的前景、背景与状态配色。</Text>
35
+ <TextField label="工作空间名称" value={workspaceName} onChange={event => setWorkspaceName(event.target.value)} hint="这是可编辑的本地展台示例。" />
36
+ <Inline gap={2}>
37
+ <Button>继续探索</Button>
38
+ <Button variant="secondary">查看介绍</Button>
39
+ <Button variant="quiet">更多介绍</Button>
40
+ </Inline>
41
+ </Stack>
42
+ </Surface>
43
+ <Text variant="caption" tone="muted">空间、连接与持续探索。</Text>
44
+ </OrbitBackdrop>
45
+ <p className="ds-caption">暂停后在原画面继续;系统、顶部或本节减少动态均保留静态画面。视口不超过 760px、页面隐藏或画面离屏时停帧。重播只重建这幅艺术,不执行任何业务操作。</p>
46
+ </section>;
47
+ }
package/preview/main.tsx CHANGED
@@ -11,6 +11,7 @@ import { SpacingGallery } from './SpacingGallery';
11
11
  import { LineHeightGallery } from './LineHeightGallery';
12
12
  import { SubscriptionGallery } from './SubscriptionGallery';
13
13
  import { BackgroundGallery } from './BackgroundGallery';
14
+ import { OrbitGallery } from './OrbitGallery';
14
15
  import { AdvancedDataGallery, AdvancedFormsGallery, MediaGallery, ShellGallery } from './ExtensionsGallery';
15
16
  import { MonitoringGallery } from './MonitoringGallery';
16
17
 
@@ -34,12 +35,13 @@ function Gallery() {
34
35
  {section === 'monitoring' && <MonitoringGallery />}
35
36
  {section === 'backgrounds' && <BackgroundGallery />}
36
37
  {section === 'app-shell' && <ShellGallery />}
38
+ {section === 'orbit' && <OrbitGallery />}
37
39
  {section === 'advanced-forms' && <AdvancedFormsGallery />}
38
40
  {section === 'advanced-data' && <AdvancedDataGallery />}
39
41
  {section === 'media' && <MediaGallery />}
40
42
  {section === 'subscriptions' && <SubscriptionGallery />}
41
43
  {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>
44
+ <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
45
  <div className="ds-overview-links">{[
44
46
  ['foundation','视觉基础','从色彩、间距与材质开始。'],
45
47
  ['typography','字体与文字','清晰的层级,稳定的阅读节奏。'],
@@ -2,7 +2,7 @@
2
2
  name: rhine-design
3
3
  description: Use Rhine Design components and specifications to design, implement, or review React UI, typography, themes, and motion. Apply when a project uses Rhine Design or the user requests its visual language or component system.
4
4
  metadata:
5
- version: "0.5.0"
5
+ version: "0.6.0"
6
6
  ---
7
7
 
8
8
  # Rhine Design · 莱茵设计
@@ -13,7 +13,7 @@ Use the installed version's components and contracts to build consistent interfa
13
13
 
14
14
  ## Locate the matching source
15
15
 
16
- Package: `@linhey/rhine-design`. GitHub: `linhay/rhine-design` (different account spelling). This skill targets 0.5.0, including application shells, advanced forms, media, controlled tables and monitoring charts. Theme and navigation APIs require at least 0.4.0; the new application APIs require 0.5.0. Check installed exports before adoption.
16
+ Package: `@linhey/rhine-design`. GitHub: `linhay/rhine-design` (different account spelling). This skill targets 0.6.0, including OrbitBackdrop, application shells, advanced forms, media, controlled tables and monitoring charts. Theme and navigation APIs require at least 0.4.0; application APIs require 0.5.0 and OrbitBackdrop requires 0.6.0. Check installed exports before adoption.
17
17
 
18
18
  Find the package in the host's dependencies, normally `node_modules/@linhey/rhine-design/`, or its registered source checkout. Verify `package.json` name/version before using the paths below. Paths in references are relative to that package root, not the copied skill directory. Read installed types and source when versions differ; do not infer a new API from this skill. If the package is unavailable, use the task's supplied design sources and identify the missing implementation evidence.
19
19
 
@@ -9,6 +9,7 @@ All paths are relative to the matching package root. `package.json` exports, `co
9
9
  | MediaCard, MediaViewer(0.5.0) | `components/Media.tsx`;中性画布,不嵌套主题 |
10
10
  | DateField, DateRangePicker, NumberField, TreeSelect, RemoteMultiSelect(0.5.0) | `components/AdvancedForms.tsx`;请求、时区和查询由宿主负责;只读/校验/多选键盘及AntD/MUI保留差异见 `docs/ANTD_MUI_AUDIT.md` |
11
11
  | ThemeProvider, useTheme, themes, ThemeName, ThemeProviderProps (0.4.0;`@linhey/rhine-design/theme`) | `components/theme.tsx`;契约 `docs/THEMING.md` |
12
+ | OrbitBackdrop, OrbitBackdropProps (0.6.0) | `components/OrbitBackdrop.tsx`;契约 `docs/ORBIT_BACKDROP.md`;`components/orbitRenderer.ts` 为内部绘制实现 |
12
13
  | Logo | `components/Logo.tsx` |
13
14
  | Text | `components/Typography.tsx` |
14
15
  | Select | `components/Select.tsx` |
@@ -38,5 +39,6 @@ API decisions that commonly matter:
38
39
  - `SelectionCard` uses checkbox semantics; the host determines single-selection policy. Do not nest other interactive controls in it.
39
40
  - `Text`'s `as` chooses semantics; `variant` chooses visual hierarchy.
40
41
  - `Logo` accepts `wordmark` and `label`; its `theme` affects the logo, not the full component library.
42
+ - `OrbitBackdrop` is decorative artwork with built-in pause / resume. Its `reducedMotion` prop can strengthen but never override system reduction; see `docs/ORBIT_BACKDROP.md` for visibility, narrow-window and fallback behavior. It does not express loading or business progress.
41
43
  - `Toast` presents one notification, not a global queue. `DataTable` is not a query or table-state engine.
42
44
  - Components in `preview/` are demonstrations. Public API must resolve through a `package.json` export; theme APIs use the separate `/theme` entry.
@@ -16,9 +16,10 @@ Resolve the matching package root as described in SKILL.md. Open the first relev
16
16
  | Modal, menu, popover, tooltip, feedback | `components/COMMON_COMPONENTS.md` | `components/Overlays.tsx`, `components/Toast.tsx` |
17
17
  | Transition, replay, cancellation, reduced motion | `components/MOTION.md` | `preview/`, `components/styles.css` |
18
18
  | Startup sequence, logo assembly, ready handoff | `components/STARTUP.md` | Search `startup` in `preview/`; not a public Startup component |
19
+ | Orbit artwork, decorative Canvas, pause / resume, reduced motion | `docs/ORBIT_BACKDROP.md` (0.6.0) | `components/OrbitBackdrop.tsx`, internal `components/orbitRenderer.ts`; `/#orbit` |
19
20
  | Missing capability or system coverage | `docs/DESIGN_SYSTEM_AUDIT.md` | Verify status against `components/index.ts` |
20
21
  | Version upgrade | `CHANGELOG.md` | Installed `package.json`, current types |
21
22
 
22
- Run the library showcase with `npm ci && npm run dev` in a source checkout. Default port is 18309; sections include `/#foundation`, `/#typography`, `/#brand`, `/#motion` and `/#startup`. Do not assume another project's server or local filesystem layout.
23
+ Run the library showcase with `npm ci && npm run dev` in a source checkout. Default port is 18309; sections include `/#foundation`, `/#typography`, `/#brand`, `/#motion` and `/#startup`, plus `/#orbit` in the unreleased source candidate. Do not assume another project's server or local filesystem layout.
23
24
 
24
25
  For host pages, keep product-specific drafts in the host project. The library's `preview/` demonstrates reusable behavior, not business APIs. Labels such as “candidate” or “preview” in motion docs identify experiments; check whether the behavior is actually exported before integrating it.
@@ -2,7 +2,7 @@
2
2
 
3
3
  ## Supported baseline
4
4
 
5
- Rhine Design 0.5.0 ships TypeScript/TSX, CSS and SVG source for React 19 + Vite. Install `@linhey/rhine-design`; GitHub remains `linhay/rhine-design`. Check the installed package's engine and peer dependencies. Node 24 is the development baseline. Do not assume precompiled JavaScript, arbitrary SSR support or a complete dark theme.
5
+ Rhine Design 0.6.0 ships TypeScript/TSX, CSS and SVG source for React 19 + Vite. Install `@linhey/rhine-design`; GitHub remains `linhay/rhine-design`. Check the installed package's engine and peer dependencies. Node 24 is the development baseline. Do not assume precompiled JavaScript, arbitrary SSR support or a complete dark theme.
6
6
 
7
7
  ```tsx
8
8
  import { Button, Surface, Text } from '@linhey/rhine-design';
@@ -0,0 +1,183 @@
1
+ import { StrictMode } from 'react';
2
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
3
+ import { act, cleanup, fireEvent, render, screen } from '@testing-library/react';
4
+ import { OrbitBackdrop } from '../components';
5
+ import { mountOrbit } from '../components/orbitRenderer';
6
+ import { OrbitGallery } from '../preview/OrbitGallery';
7
+
8
+ class Media extends EventTarget {
9
+ matches = false;
10
+ set(value: boolean) { this.matches = value; this.dispatchEvent(new Event('change')); }
11
+ }
12
+
13
+ describe('OrbitBackdrop artwork and lifecycle', () => {
14
+ let reduced: Media, compact: Media, hidden: boolean, bounds: { width: number; height: number };
15
+ let frameId: number, frames: Map<number, FrameRequestCallback>;
16
+ let resize: () => void, intersect: (value: boolean) => void;
17
+ let resizeDisconnect: () => void, intersectionDisconnect: () => void;
18
+ let draws: number, lines: number, trails: number, glows: number, firstPoint: number[], firstRadius: number, zoom: number;
19
+ let hasPoint: boolean;
20
+ let context: CanvasRenderingContext2D;
21
+
22
+ beforeEach(() => {
23
+ reduced = new Media(); compact = new Media(); hidden = false;
24
+ bounds = { width: 600, height: 680 }; frameId = 0; frames = new Map();
25
+ draws = lines = trails = glows = firstRadius = 0; firstPoint = []; hasPoint = false; zoom = 1;
26
+ resizeDisconnect = vi.fn(); intersectionDisconnect = vi.fn();
27
+ vi.spyOn(document, 'hidden', 'get').mockImplementation(() => hidden);
28
+ vi.spyOn(HTMLCanvasElement.prototype, 'clientWidth', 'get').mockImplementation(() => bounds.width);
29
+ vi.spyOn(HTMLCanvasElement.prototype, 'clientHeight', 'get').mockImplementation(() => bounds.height);
30
+ vi.spyOn(HTMLCanvasElement.prototype, 'getBoundingClientRect').mockImplementation(() => ({ width: bounds.width * zoom, height: bounds.height * zoom, x: 0, y: 0, top: 0, left: 0, right: bounds.width * zoom, bottom: bounds.height * zoom, toJSON() {} }));
31
+ vi.stubGlobal('matchMedia', (query: string) => query.includes('prefers-reduced') ? reduced : compact);
32
+ vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { const id = ++frameId; frames.set(id, callback); return id; });
33
+ vi.stubGlobal('cancelAnimationFrame', (id: number) => frames.delete(id));
34
+ vi.stubGlobal('ResizeObserver', class { constructor(callback: () => void) { resize = callback; } observe() {} disconnect() { resizeDisconnect(); } });
35
+ vi.stubGlobal('IntersectionObserver', class {
36
+ constructor(private callback: (entries: Partial<IntersectionObserverEntry>[]) => void) {}
37
+ observe(target: Element) { intersect = value => this.callback([{ target, isIntersecting: value }]); }
38
+ disconnect() { intersectionDisconnect(); }
39
+ });
40
+ const gradient = { addColorStop() {} };
41
+ context = {
42
+ setTransform() {}, clearRect() { draws++; lines = trails = glows = firstRadius = 0; hasPoint = false; },
43
+ beginPath() {}, closePath() {}, stroke() {}, fill() {}, arc(_x: number, _y: number, radius: number) { if (!firstRadius) firstRadius = radius; },
44
+ moveTo(x: number, y: number) { if (!hasPoint) { hasPoint = true; firstPoint = [x, y]; } },
45
+ lineTo() { lines++; },
46
+ createLinearGradient() { trails++; return gradient; },
47
+ createRadialGradient() { glows++; return gradient; },
48
+ } as unknown as CanvasRenderingContext2D;
49
+ vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(context);
50
+ });
51
+ afterEach(() => { cleanup(); document.documentElement.removeAttribute('data-rhine-motion'); vi.restoreAllMocks(); vi.unstubAllGlobals(); });
52
+
53
+ function advance(now: number) {
54
+ act(() => { const pending = [...frames.values()]; frames.clear(); pending.forEach(callback => callback(now)); });
55
+ }
56
+
57
+ it('retains the complete seeded artwork, caps pixel density and pauses without catching up', () => {
58
+ vi.stubGlobal('devicePixelRatio', 3);
59
+ const canvas = document.createElement('canvas'); document.body.append(canvas);
60
+ const scene = mountOrbit(canvas, () => {});
61
+ expect(canvas.width).toBe(1200); expect(canvas.height).toBe(1360);
62
+ expect(trails).toBe(420); expect(glows).toBe(140); expect(lines).toBeGreaterThan(185 * 80);
63
+ advance(100); const initial = [...firstPoint]; advance(200); const moving = [...firstPoint];
64
+ expect(moving).not.toEqual(initial);
65
+ scene.setPaused(true); const stoppedDraws = draws;
66
+ expect(frames.size).toBe(0); advance(9000); expect(draws).toBe(stoppedDraws);
67
+ scene.setPaused(false); advance(10000); expect(firstPoint).toEqual(moving);
68
+ advance(10100); expect(firstPoint).not.toEqual(moving);
69
+ scene.dispose(); canvas.remove();
70
+ });
71
+
72
+ it('keeps children and keyboard-capable pause control while preserving the current canvas', () => {
73
+ const { container } = render(<OrbitBackdrop><input aria-label="草稿" defaultValue="保留内容" /></OrbitBackdrop>);
74
+ const canvas = container.querySelector('canvas'); expect(canvas).toHaveAttribute('aria-hidden', 'true');
75
+ const input = screen.getByLabelText('草稿'); fireEvent.change(input, { target: { value: '已经输入' } });
76
+ fireEvent.click(screen.getByRole('button', { name: '暂停动效' }));
77
+ expect(frames.size).toBe(0); expect(screen.getByRole('button', { name: '继续动效' })).not.toHaveAttribute('aria-pressed');
78
+ expect(container.querySelector('.rhine-orbit')).toHaveAttribute('data-rhine-orbit-state', 'paused');
79
+ fireEvent.click(screen.getByRole('button', { name: '继续动效' }));
80
+ expect(frames.size).toBe(1); expect(container.querySelector('canvas')).toBe(canvas); expect(input).toHaveValue('已经输入');
81
+ expect(screen.getByRole('button', { name: '暂停动效' })).not.toHaveAttribute('aria-pressed');
82
+ expect(container.querySelector('.rhine-orbit')).toHaveAttribute('data-rhine-orbit-state', 'playing');
83
+ });
84
+
85
+ it('keeps geometry and stroke proportions stable when CSS zoom changes before a pause or resize', () => {
86
+ const { container } = render(<OrbitBackdrop />);
87
+ const canvas = container.querySelector('canvas')!;
88
+ const snapshot = () => ({ width: canvas.width, height: canvas.height, point: [...firstPoint], lineWidth: context.lineWidth, radius: firstRadius });
89
+ const before = snapshot();
90
+ // CSS zoom changes the visual rectangle without changing layout dimensions or issuing RO.
91
+ zoom = 2;
92
+ expect(canvas.getBoundingClientRect().width).toBe(canvas.clientWidth * 2);
93
+ fireEvent.click(screen.getByRole('button', { name: '暂停动效' }));
94
+ expect(snapshot()).toEqual(before); expect(frames.size).toBe(0);
95
+ act(() => resize()); expect(snapshot()).toEqual(before);
96
+ fireEvent.click(screen.getByRole('button', { name: '继续动效' }));
97
+ advance(100); expect(snapshot()).toEqual(before); expect(frames.size).toBe(1);
98
+ });
99
+
100
+ it('honors system and explicit reduction, and never lets false bypass the system', () => {
101
+ reduced.matches = true;
102
+ const { rerender } = render(<OrbitBackdrop reducedMotion={false} />);
103
+ expect(frames.size).toBe(0); expect(screen.getByRole('button', { name: '已减少动态' })).toBeDisabled();
104
+ act(() => reduced.set(false)); expect(frames.size).toBe(1);
105
+ rerender(<OrbitBackdrop reducedMotion />); expect(frames.size).toBe(0);
106
+ rerender(<OrbitBackdrop reducedMotion={false} />); expect(frames.size).toBe(1);
107
+ act(() => reduced.set(true)); expect(frames.size).toBe(0);
108
+ });
109
+
110
+ it('synchronizes a media change observed by a frame before the change event arrives', () => {
111
+ const { container } = render(<OrbitBackdrop />);
112
+ advance(100); advance(200); const frozen = [...firstPoint];
113
+ reduced.matches = true;
114
+ advance(300);
115
+ expect(container.querySelector('.rhine-orbit')).toHaveAttribute('data-rhine-orbit-state', 'reduced');
116
+ expect(screen.getByRole('button', { name: '已减少动态' })).toBeDisabled();
117
+ expect(frames.size).toBe(0); expect(firstPoint).toEqual(frozen);
118
+ const stoppedDraws = draws; advance(9000); expect(draws).toBe(stoppedDraws);
119
+ act(() => reduced.set(false));
120
+ expect(container.querySelector('.rhine-orbit')).toHaveAttribute('data-rhine-orbit-state', 'playing');
121
+ expect(screen.getByRole('button', { name: '暂停动效' })).toBeEnabled();
122
+ advance(10000); expect(firstPoint).toEqual(frozen);
123
+ advance(10100); expect(firstPoint).not.toEqual(frozen);
124
+ });
125
+
126
+ it('observes ancestor motion changes even through a local system preference', async () => {
127
+ render(<div data-rhine-motion="system"><OrbitBackdrop /></div>);
128
+ expect(frames.size).toBe(1);
129
+ await act(async () => { document.documentElement.setAttribute('data-rhine-motion', 'reduced'); });
130
+ expect(frames.size).toBe(0);
131
+ await act(async () => { document.documentElement.removeAttribute('data-rhine-motion'); });
132
+ expect(frames.size).toBe(1);
133
+ });
134
+
135
+ it('stops while hidden, offscreen, compact or empty and redraws a paused resize', () => {
136
+ const { container } = render(<OrbitBackdrop />);
137
+ act(() => { hidden = true; document.dispatchEvent(new Event('visibilitychange')); }); expect(frames.size).toBe(0);
138
+ act(() => { hidden = false; document.dispatchEvent(new Event('visibilitychange')); }); expect(frames.size).toBe(1);
139
+ act(() => intersect(false)); expect(frames.size).toBe(0);
140
+ act(() => intersect(true)); expect(frames.size).toBe(1);
141
+ act(() => compact.set(true)); expect(frames.size).toBe(0); expect(screen.getByRole('button', { name: '窄屏静态画面' })).toBeDisabled();
142
+ act(() => compact.set(false)); expect(frames.size).toBe(1);
143
+ act(() => { bounds.width = 0; resize(); }); expect(frames.size).toBe(0);
144
+ act(() => { bounds.width = 400; resize(); }); expect(frames.size).toBe(1);
145
+ fireEvent.click(screen.getByRole('button', { name: '暂停动效' })); const beforeResize = draws;
146
+ act(() => { bounds.height = 500; resize(); }); expect(draws).toBeGreaterThan(beforeResize); expect(frames.size).toBe(0);
147
+ expect(container.querySelector('canvas')).toHaveAttribute('height', '500');
148
+ });
149
+
150
+ it('cleans up all scheduling and observers, including Strict Mode mount replay', async () => {
151
+ const visibilitySpy = vi.spyOn(document, 'removeEventListener');
152
+ const resizeSpy = vi.spyOn(window, 'removeEventListener');
153
+ const reducedSpy = vi.spyOn(reduced, 'removeEventListener');
154
+ const compactSpy = vi.spyOn(compact, 'removeEventListener');
155
+ const { unmount } = render(<StrictMode><OrbitBackdrop /></StrictMode>);
156
+ expect(frames.size).toBe(1); unmount(); const finalDraws = draws;
157
+ expect(frames.size).toBe(0); expect(resizeDisconnect).toHaveBeenCalledTimes(2); expect(intersectionDisconnect).toHaveBeenCalledTimes(2);
158
+ expect(visibilitySpy).toHaveBeenCalledWith('visibilitychange', expect.any(Function));
159
+ expect(resizeSpy).toHaveBeenCalledWith('resize', expect.any(Function));
160
+ expect(reducedSpy).toHaveBeenCalledWith('change', expect.any(Function)); expect(compactSpy).toHaveBeenCalledWith('change', expect.any(Function));
161
+ await act(async () => { document.documentElement.setAttribute('data-rhine-motion', 'reduced'); reduced.set(true); resize(); intersect(true); });
162
+ expect(draws).toBe(finalDraws); expect(frames.size).toBe(0);
163
+ });
164
+
165
+ it.each(['null', 'throw'])('keeps content usable when the canvas context is %s', mode => {
166
+ vi.mocked(HTMLCanvasElement.prototype.getContext).mockImplementation(() => { if (mode === 'throw') throw new Error('Canvas unavailable'); return null; });
167
+ const { container } = render(<OrbitBackdrop><button>继续工作</button></OrbitBackdrop>);
168
+ expect(container.querySelector('.rhine-orbit')).toHaveAttribute('data-rhine-orbit-state', 'unavailable');
169
+ expect(screen.getByRole('button', { name: '继续工作' })).toBeEnabled();
170
+ expect(screen.getByRole('button', { name: '静态画面' })).toBeDisabled(); expect(frames.size).toBe(0);
171
+ });
172
+
173
+ it('replays the gallery artwork while preserving entered content and external replay focus', () => {
174
+ const { container } = render(<OrbitGallery />); const canvas = container.querySelector('canvas');
175
+ fireEvent.change(screen.getByRole('textbox', { name: '直接叠加的输入框' }), { target: { value: '新的轨道名称' } });
176
+ fireEvent.change(screen.getByRole('textbox', { name: '工作空间名称' }), { target: { value: '新的工作空间' } });
177
+ const replay = screen.getByRole('button', { name: '重播轨道' }); replay.focus(); fireEvent.click(replay);
178
+ expect(container.querySelector('canvas')).not.toBe(canvas); expect(replay).toHaveFocus(); expect(frames.size).toBe(1);
179
+ expect(screen.getByRole('textbox', { name: '直接叠加的输入框' })).toHaveValue('新的轨道名称');
180
+ expect(screen.getByRole('textbox', { name: '工作空间名称' })).toHaveValue('新的工作空间');
181
+ fireEvent.click(screen.getByRole('switch', { name: '减少轨道动态' })); expect(frames.size).toBe(0);
182
+ });
183
+ });