@linhey/rhine-design 0.6.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (110) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/README.md +19 -8
  3. package/components/Actions.tsx +18 -1
  4. package/components/AdvancedForms.tsx +38 -8
  5. package/components/AppShell.tsx +46 -17
  6. package/components/ListFilterBar.tsx +104 -0
  7. package/components/Media.tsx +7 -7
  8. package/components/NeutronStarBackdrop.tsx +43 -0
  9. package/components/OrbitBackdrop.tsx +11 -3
  10. package/components/Overlays.tsx +37 -8
  11. package/components/README.md +28 -3
  12. package/components/StatusTimeline.tsx +72 -6
  13. package/components/StatusTimelineList.tsx +38 -0
  14. package/components/ThemePicker.tsx +49 -0
  15. package/components/assets/orbit-bird.svg +578 -0
  16. package/components/assets/orbit-crystal.svg +11 -0
  17. package/components/assets/orbit-donut.svg +16 -0
  18. package/components/assets/theme-previews/arknights.png +0 -0
  19. package/components/assets/theme-previews/manifest.json +24 -0
  20. package/components/assets/theme-previews/neo-brutalism.png +0 -0
  21. package/components/assets/theme-previews/rhine.png +0 -0
  22. package/components/common.css +3 -3
  23. package/components/components.tsx +3 -3
  24. package/components/dialog-settings.css +9 -0
  25. package/components/extensions.css +8 -32
  26. package/components/index.ts +12 -5
  27. package/components/list-filter-bar.css +33 -0
  28. package/components/media-parity.css +55 -2
  29. package/components/neutron.css +53 -0
  30. package/components/neutronRenderer.ts +327 -0
  31. package/components/orbit.css +100 -1
  32. package/components/orbitBirdFlight.ts +34 -0
  33. package/components/orbitBirdGeometry.ts +158 -0
  34. package/components/orbitDonut.ts +134 -0
  35. package/components/orbitDonutThree.ts +208 -0
  36. package/components/orbitObjectMaterials.ts +76 -0
  37. package/components/orbitObjects.ts +71 -0
  38. package/components/orbitObjectsThree.ts +215 -0
  39. package/components/orbitProjection.ts +21 -0
  40. package/components/orbitRenderer.ts +163 -46
  41. package/components/styles.css +3 -1
  42. package/components/theme-neo-brutalism.css +3 -2
  43. package/components/theme-picker.css +17 -0
  44. package/components/theme.tsx +2 -8
  45. package/components/themeMetadata.ts +7 -0
  46. package/components/timeline.css +17 -6
  47. package/components/useFloatingScale.ts +96 -0
  48. package/docs/APPLICATION_COMPONENTS.md +52 -3
  49. package/docs/BACKGROUND_FILTER_VERIFICATION.md +12 -0
  50. package/docs/COMPACT_ACCOUNT_PATTERNS.md +70 -0
  51. package/docs/DATE_RANGE_FILTER.md +60 -0
  52. package/docs/DESIGN_SYSTEM.md +11 -0
  53. package/docs/FLOATING_OVERLAYS.md +15 -0
  54. package/docs/INTERACTION_PATTERNS.md +10 -2
  55. package/docs/LIST_FILTER_BAR.md +73 -0
  56. package/docs/NEUTRON_STAR_BACKDROP.md +122 -0
  57. package/docs/ORBIT_BACKDROP.md +61 -44
  58. package/docs/SETTINGS_DIALOG.md +21 -0
  59. package/docs/THEME_PICKER.md +26 -0
  60. package/docs/THEMING.md +2 -0
  61. package/docs/assets/compact-account-neo.png +0 -0
  62. package/docs/assets/neutron-star-original.png +0 -0
  63. package/docs/assets/neutron-star-theme.png +0 -0
  64. package/docs/assets/orbit-bird.png +0 -0
  65. package/docs/assets/orbit-crystal.png +0 -0
  66. package/docs/assets/orbit-strawberry.png +0 -0
  67. package/package.json +8 -3
  68. package/preview/BackgroundFiltersFixture.tsx +28 -0
  69. package/preview/CompactAccountGallery.tsx +178 -0
  70. package/preview/DateRangeFilterGallery.tsx +109 -0
  71. package/preview/ExtensionsGallery.tsx +27 -18
  72. package/preview/GalleryShell.tsx +3 -3
  73. package/preview/ListFilterBarGallery.tsx +170 -0
  74. package/preview/MonitoringGallery.tsx +2 -0
  75. package/preview/NeutronStarGallery.tsx +56 -0
  76. package/preview/OrbitGallery.tsx +49 -32
  77. package/preview/OverlayBoundsGallery.tsx +49 -0
  78. package/preview/SettingsDialogGallery.tsx +33 -0
  79. package/preview/ThemePickerGallery.tsx +24 -0
  80. package/preview/ThemeSample.tsx +14 -0
  81. package/preview/TimelineListGallery.tsx +36 -0
  82. package/preview/extensions.css +9 -0
  83. package/preview/main.tsx +19 -3
  84. package/preview/preview.css +23 -0
  85. package/preview/theme-sample.html +1 -0
  86. package/scripts/check-background-filters.js +58 -0
  87. package/scripts/check-background-motion.js +169 -0
  88. package/scripts/check-filter-native-date.js +47 -0
  89. package/scripts/check-overlay-bounds.js +76 -0
  90. package/scripts/check-settings-theme.js +88 -0
  91. package/skills/rhine-design/SKILL.md +3 -2
  92. package/skills/rhine-design/references/API_MAP.md +13 -2
  93. package/skills/rhine-design/references/TASK_MAP.md +8 -2
  94. package/skills/rhine-design/references/integration.md +1 -1
  95. package/tests/AppShellControls.test.tsx +82 -0
  96. package/tests/ButtonLink.test.tsx +94 -0
  97. package/tests/CompactAccountGallery.test.tsx +116 -0
  98. package/tests/DateNativeInput.test.tsx +147 -0
  99. package/tests/DateRangeFilterGallery.test.tsx +85 -0
  100. package/tests/ListFilterBar.test.tsx +198 -0
  101. package/tests/NeutronStarBackdrop.test.tsx +288 -0
  102. package/tests/NeutronStarGallery.test.tsx +62 -0
  103. package/tests/NeutronStarImport.test.tsx +15 -0
  104. package/tests/OrbitBackdrop.test.tsx +273 -9
  105. package/tests/OrbitDonutThree.test.tsx +91 -0
  106. package/tests/OrbitObjectsThree.test.tsx +164 -0
  107. package/tests/SettingsDialog.test.tsx +66 -0
  108. package/tests/StatusTimelineList.test.tsx +63 -0
  109. package/tests/ThemePicker.test.tsx +30 -0
  110. package/tests/TimelineLegend.test.tsx +60 -0
@@ -0,0 +1,327 @@
1
+ import {
2
+ AdditiveBlending, NormalBlending, BufferGeometry, Color, DoubleSide, Group, Line,
3
+ LineBasicMaterial, Mesh, PerspectiveCamera, PlaneGeometry,
4
+ Scene, ShaderMaterial, SphereGeometry, Vector3, WebGLRenderer, Points, Float32BufferAttribute, Material,
5
+ } from 'three';
6
+
7
+ export type NeutronState = 'loading' | 'playing' | 'paused' | 'reduced' | 'hidden' | 'offscreen' | 'empty' | 'unavailable';
8
+ type Appearance = 'theme' | 'original';
9
+ interface NeutronOptions { paused?: boolean; reducedMotion?: boolean; appearance?: Appearance }
10
+ export interface NeutronRenderer {
11
+ setPaused(value: boolean): void;
12
+ setReducedMotion(value: boolean): void;
13
+ setAppearance(value: Appearance): void;
14
+ dispose(): void;
15
+ }
16
+
17
+ /** The scene advances only by elapsed visible frames; no timer encodes business state. */
18
+ export function mountNeutron(host: HTMLDivElement, onState: (state: NeutronState) => void, options: NeutronOptions = {}): NeutronRenderer {
19
+ const cleanups: Array<() => void> = [];
20
+ const geometries = new Set<BufferGeometry>(), materials = new Set<Material>();
21
+ const geometry = <T extends BufferGeometry>(value: T): T => { geometries.add(value); return value; };
22
+ const material = <T extends Material>(value: T): T => { materials.add(value); return value; };
23
+ let renderer: WebGLRenderer | undefined;
24
+ let released = false;
25
+ function release() {
26
+ if (released) return;
27
+ released = true;
28
+ geometries.forEach(value => value.dispose());
29
+ materials.forEach(value => value.dispose());
30
+ renderer?.dispose();
31
+ renderer?.forceContextLoss();
32
+ renderer?.domElement.remove();
33
+ host.removeAttribute('data-rhine-neutron-rendered');
34
+ }
35
+ try {
36
+ renderer = new WebGLRenderer({ alpha: true, antialias: true, powerPreference: 'low-power' });
37
+ const gl = renderer;
38
+ const scene = new Scene();
39
+ const camera = new PerspectiveCamera(34, 1, .1, 40);
40
+ camera.position.set(0, .5, 7.8);
41
+ camera.lookAt(0, 0, 0);
42
+ renderer.setClearColor(0x000000, 0);
43
+ renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 1.5));
44
+ host.appendChild(renderer.domElement);
45
+ renderer.domElement.className = 'rhine-neutron-canvas';
46
+ renderer.domElement.setAttribute('aria-hidden', 'true');
47
+ const star = new Group();
48
+ star.position.set(.85, .65, 0);
49
+ star.scale.setScalar(1.5);
50
+ star.rotation.z = -.48;
51
+ scene.add(star);
52
+ const time = { value: 0 };
53
+ const core = new Mesh(geometry(new SphereGeometry(.34, 64, 40)), material(new ShaderMaterial({
54
+ uniforms: { time, cool: { value: new Color().setRGB(.12,.38,.8) }, hot: { value: new Color().setRGB(.86,.97,1) }, edge: { value: new Color().setRGB(.16,.47,.94) } },
55
+ vertexShader: `varying vec3 surface; varying vec3 normalView; varying vec3 viewDirection;
56
+ void main(){ surface=position; vec4 mv=modelViewMatrix*vec4(position,1.);
57
+ normalView=normalize(normalMatrix*normal); viewDirection=normalize(-mv.xyz);
58
+ gl_Position=projectionMatrix*mv; }`,
59
+ fragmentShader: `uniform float time; uniform vec3 cool; uniform vec3 hot; uniform vec3 edge; varying vec3 surface; varying vec3 normalView; varying vec3 viewDirection;
60
+ void main(){vec3 p=normalize(surface); float flow=sin(p.x*28.+sin(p.y*19.+time*.5)*2.+p.z*14.-time*.7);
61
+ float grain=sin(p.y*47.+sin(p.z*31.-time*.3))*sin(p.x*39.+time*.2);
62
+ float facing=max(dot(normalize(normalView),normalize(viewDirection)),0.);
63
+ float heat=smoothstep(-.35,.9,flow+grain*.35);
64
+ vec3 color=mix(cool,hot,heat);
65
+ color=mix(edge,color,pow(facing,.45));
66
+ gl_FragColor=vec4(color,1.); }`,
67
+ })));
68
+ star.add(core);
69
+
70
+ // A camera-facing halo avoids an expensive full-screen bloom pass.
71
+ const halo = new Mesh(geometry(new PlaneGeometry(2.3, 2.3)), material(new ShaderMaterial({
72
+ transparent: true, depthWrite: false,
73
+ uniforms: { tint: { value: new Color('#4c9aff') } },
74
+ vertexShader: 'varying vec2 vUv; void main(){vUv=uv;gl_Position=projectionMatrix*modelViewMatrix*vec4(position,1.);}',
75
+ fragmentShader: 'varying vec2 vUv; uniform vec3 tint; void main(){float d=length(vUv-.5)*2.;float a=exp(-d*d*7.)*.65*(1.-smoothstep(.65,1.,d));gl_FragColor=vec4(tint,a);}',
76
+ })));
77
+ halo.quaternion.copy(camera.quaternion);
78
+ halo.position.copy(star.position);
79
+ halo.scale.setScalar(1.5);
80
+ scene.add(halo);
81
+ const corona = new Mesh(geometry(new SphereGeometry(.39, 48, 32)), material(new ShaderMaterial({
82
+ transparent: true, depthWrite: false, blending: AdditiveBlending,
83
+ uniforms: { time, tint: { value: new Color().setRGB(.25,.6,1) } },
84
+ vertexShader: `varying vec3 n; varying vec3 eye; void main(){vec4 mv=modelViewMatrix*vec4(position,1.);
85
+ n=normalize(normalMatrix*normal); eye=normalize(-mv.xyz); gl_Position=projectionMatrix*mv;}`,
86
+ fragmentShader: `uniform float time; uniform vec3 tint; varying vec3 n; varying vec3 eye; void main(){
87
+ float rim=pow(1.-max(dot(normalize(n),normalize(eye)),0.),2.5);
88
+ gl_FragColor=vec4(tint,rim*(.45+.05*sin(time*.8)));}`,
89
+ })));
90
+ star.add(corona);
91
+ for (let i = 0; i < 28; i++) {
92
+ const azimuth = (i % 14) * Math.PI * 2 / 14 + Math.floor(i / 14) * .15;
93
+ const extent = i < 14 ? 1.1 : 1.65;
94
+ const points = [];
95
+ for (let j = 0; j <= 100; j++) {
96
+ const theta = .24 + (Math.PI - .48) * j / 100;
97
+ const radius = extent * Math.sin(theta) ** 2;
98
+ points.push(new Vector3(radius * Math.sin(theta) * Math.cos(azimuth), radius * Math.cos(theta), radius * Math.sin(theta) * Math.sin(azimuth)));
99
+ }
100
+ star.add(new Line(geometry(new BufferGeometry().setFromPoints(points)), material(new LineBasicMaterial({ color: i < 14 ? 0x6bdafa : 0x8976ed, transparent: true, opacity: i < 14 ? .34 : .16 }))));
101
+ }
102
+ const beamMaterial = material(new ShaderMaterial({
103
+ transparent: true, depthWrite: false, side: DoubleSide, blending: AdditiveBlending,
104
+ uniforms: { time, cool: { value: new Color().setRGB(.24,.48,1) }, hot: { value: new Color().setRGB(.65,.93,1) } },
105
+ vertexShader: 'varying vec2 vUv; void main(){vUv=uv;gl_Position=projectionMatrix*modelViewMatrix*vec4(position,1.);}',
106
+ fragmentShader: `uniform float time; uniform vec3 cool; uniform vec3 hot; varying vec2 vUv; void main(){float width=mix(.07,.45,vUv.y);
107
+ float spine=exp(-pow((vUv.x-.5)/.035,2.)*3.);
108
+ float cloud=exp(-pow((vUv.x-.5)/width,2.)*4.);
109
+ float wave=.8+.2*sin(vUv.y*42.-time*2.);
110
+ float a=(cloud*wave+spine*.45)*pow(1.-vUv.y,1.7);
111
+ gl_FragColor=vec4(mix(cool,hot,spine),a*.65);}`,
112
+ }));
113
+ for (const sign of [-1, 1]) {
114
+ for (const angle of [0, Math.PI / 2]) {
115
+ const beam = new Mesh(geometry(new PlaneGeometry(.75, 1.7)), beamMaterial);
116
+ beam.position.y = sign * 1.03;
117
+ beam.rotation.y = angle;
118
+ if (sign < 0) beam.rotation.z = Math.PI;
119
+ star.add(beam);
120
+ }
121
+ }
122
+ // Stream points follow the same dipole curves as the magnetic field lines.
123
+ const streamsGeometry = geometry(new BufferGeometry());
124
+ const seeds = [];
125
+ for (let i = 0; i < 560; i++) seeds.push((i % 14) / 14 + (i < 280 ? 0 : .15 / (Math.PI * 2)), (i % 40) / 40, i < 280 ? 1.1 : 1.65);
126
+ streamsGeometry.setAttribute('position', new Float32BufferAttribute(seeds, 3));
127
+ const streams = new Points(streamsGeometry, material(new ShaderMaterial({
128
+ transparent: true, depthWrite: false, blending: AdditiveBlending,
129
+ uniforms: { time, pixelRatio: { value: renderer.getPixelRatio() }, tint: { value: new Color().setRGB(.42,.8,1) } },
130
+ vertexShader: `uniform float time; uniform float pixelRatio; varying float alpha;
131
+ void main(){float progress=fract(position.y+time*.065); float theta=.24+progress*2.66159;
132
+ float azimuth=position.x*6.28318; float r=position.z*sin(theta)*sin(theta);
133
+ vec3 p=vec3(r*sin(theta)*cos(azimuth),r*cos(theta),r*sin(theta)*sin(azimuth));
134
+ vec4 mv=modelViewMatrix*vec4(p,1.); gl_Position=projectionMatrix*mv;
135
+ gl_PointSize=pixelRatio*2.8*(6./-mv.z); alpha=sin(progress*3.14159)*.7; }`,
136
+ fragmentShader: `uniform vec3 tint; varying float alpha; void main(){float d=length(gl_PointCoord-.5)*2.;
137
+ gl_FragColor=vec4(tint,(1.-smoothstep(.1,1.,d))*alpha);}`,
138
+ })));
139
+ // Position attributes hold seeds, so their CPU bounds do not enclose the shader-generated curves.
140
+ streams.frustumCulled = false;
141
+ star.add(streams);
142
+ const particleGeometry = geometry(new BufferGeometry());
143
+ const positions = [];
144
+ const sizes = [];
145
+ const phases = [];
146
+ for (let i = 0; i < 700; i++) {
147
+ const angle = i * 2.399963;
148
+ const radius = .65 + Math.sqrt((i % 421) / 421) * 2.6;
149
+ positions.push(Math.cos(angle) * radius, Math.sin(angle) * radius * .48, Math.sin(i * 1.71) * 1.2);
150
+ sizes.push(1.2 + (i % 7) * .3);
151
+ phases.push(i * .73);
152
+ }
153
+ particleGeometry.setAttribute('position', new Float32BufferAttribute(positions, 3));
154
+ particleGeometry.setAttribute('size', new Float32BufferAttribute(sizes, 1));
155
+ particleGeometry.setAttribute('phase', new Float32BufferAttribute(phases, 1));
156
+ const particleMaterial = material(new ShaderMaterial({
157
+ transparent: true, depthWrite: false,
158
+ uniforms: { time, pixelRatio: { value: renderer.getPixelRatio() }, tint: { value: new Color().setRGB(.18,.46,.78) } },
159
+ vertexShader: `attribute float size; attribute float phase; uniform float time; uniform float pixelRatio; varying float opacity;
160
+ void main(){ vec3 p=position; float a=time*.065; p.xz=mat2(cos(a),-sin(a),sin(a),cos(a))*p.xz;
161
+ p.y+=sin(time*.4+phase)*.08; vec4 mv=modelViewMatrix*vec4(p,1.);
162
+ gl_Position=projectionMatrix*mv; gl_PointSize=size*pixelRatio*(6./-mv.z); opacity=.35+.25*sin(phase+time*.6); }`,
163
+ fragmentShader: `uniform vec3 tint; varying float opacity; void main(){float d=length(gl_PointCoord-.5)*2.;
164
+ gl_FragColor=vec4(tint,(1.-smoothstep(.2,1.,d))*opacity);}`,
165
+ }));
166
+ const particles = new Points(particleGeometry, particleMaterial);
167
+ particles.position.copy(star.position);
168
+ particles.rotation.z = -.28;
169
+ scene.add(particles);
170
+ const orbit = new Group();
171
+ scene.add(orbit);
172
+ orbit.add(star, halo, particles);
173
+ const updateOrbit = () => {
174
+ const phase = time.value * Math.PI * 2 / 28;
175
+ // Translate the whole system in depth; perspective supplies the size change.
176
+ orbit.position.set(.75 * Math.sin(phase), .2 * Math.sin(phase + .6), 1.35 * Math.cos(phase));
177
+ };
178
+ updateOrbit();
179
+ let frame = 0, last: number | null = null, width = 0, height = 0;
180
+ let bitmapWidth = 0, bitmapHeight = 0, pixelRatio = 0;
181
+ let paused = options.paused ?? false, manualReduced = options.reducedMotion ?? false;
182
+ let appearance = options.appearance ?? 'theme';
183
+ let disposed = false, failed = false, lost = false, visible = true;
184
+ let previousState: NeutronState | undefined;
185
+ const motion = window.matchMedia?.('(prefers-reduced-motion: reduce)');
186
+ function announce(next: NeutronState) {
187
+ if (next !== previousState) { previousState = next; onState(next); }
188
+ }
189
+ function state(): NeutronState {
190
+ if (failed || lost) return 'unavailable';
191
+ if (manualReduced || motion?.matches || host.closest('[data-rhine-motion="reduced"]')) return 'reduced';
192
+ if (document.hidden) return 'hidden';
193
+ if (!visible) return 'offscreen';
194
+ if (width <= 0 || height <= 0) return 'empty';
195
+ return paused ? 'paused' : 'playing';
196
+ }
197
+ function stop() { cancelAnimationFrame(frame); frame = 0; last = null; }
198
+ function render() {
199
+ if (failed || lost || !width || !height) return;
200
+ try {
201
+ gl.render(scene, camera);
202
+ host.setAttribute('data-rhine-neutron-rendered', 'true');
203
+ } catch {
204
+ failed = true; stop();
205
+ cleanups.splice(0).forEach(cleanup => cleanup());
206
+ release(); announce('unavailable');
207
+ }
208
+ }
209
+ function palette() {
210
+ const css = getComputedStyle(host);
211
+ const read = (token: string, fallback: string) => new Color(css.getPropertyValue(token).trim() || fallback);
212
+ const original = appearance === 'original';
213
+ const primary = read('--rhine-green', '#425c4b'), secondary = read('--rhine-signal', '#c65c34');
214
+ const bright = primary.clone().lerp(new Color('#ffffff'), .7);
215
+ core.material.uniforms.cool.value.copy(original ? new Color().setRGB(.12,.38,.8) : primary);
216
+ core.material.uniforms.hot.value.copy(original ? new Color().setRGB(.86,.97,1) : bright);
217
+ core.material.uniforms.edge.value.copy(original ? new Color().setRGB(.16,.47,.94) : secondary);
218
+ halo.material.uniforms.tint.value.copy(original ? new Color('#4c9aff') : primary);
219
+ corona.material.uniforms.tint.value.copy(original ? new Color().setRGB(.25,.6,1) : primary);
220
+ beamMaterial.uniforms.cool.value.copy(original ? new Color().setRGB(.24,.48,1) : secondary);
221
+ beamMaterial.uniforms.hot.value.copy(original ? new Color().setRGB(.65,.93,1) : bright);
222
+ streams.material.uniforms.tint.value.copy(original ? new Color().setRGB(.42,.8,1) : primary);
223
+ particleMaterial.uniforms.tint.value.copy(original ? new Color().setRGB(.18,.46,.78) : read('--rhine-ink', '#242b28'));
224
+ let line = 0;
225
+ for (const child of star.children) if (child instanceof Line) {
226
+ const inner = line++ < 14;
227
+ child.material.color.copy(original ? new Color(inner ? 0x6bdafa : 0x8976ed) : inner ? primary : secondary);
228
+ child.material.opacity = original ? inner ? .34 : .16 : inner ? .5 : .3;
229
+ }
230
+ for (const value of [corona.material, beamMaterial, streams.material]) {
231
+ value.blending = original ? AdditiveBlending : NormalBlending;
232
+ }
233
+ }
234
+ function resize() {
235
+ // Layout pixels exclude CSS zoom; the bitmap and camera must not grow twice.
236
+ width = host.clientWidth; height = host.clientHeight;
237
+ if (!width || !height || failed || lost) return;
238
+ const ratio = Math.min(window.devicePixelRatio || 1, 1.5);
239
+ if (width !== bitmapWidth || height !== bitmapHeight || ratio !== pixelRatio) {
240
+ if (ratio !== pixelRatio) gl.setPixelRatio(ratio);
241
+ streams.material.uniforms.pixelRatio.value = particleMaterial.uniforms.pixelRatio.value = ratio;
242
+ camera.aspect = width / height;
243
+ // A tall page must not crop the core's orbit: fit the camera, not the artwork geometry.
244
+ camera.position.x = .85 * Math.min(1, Math.max(0, (1.2 - camera.aspect) / .3));
245
+ camera.lookAt(camera.position.x, 0, 0);
246
+ camera.zoom = Math.min(1, camera.aspect / .95);
247
+ camera.updateProjectionMatrix();
248
+ halo.quaternion.copy(camera.quaternion);
249
+ gl.setSize(width, height, false);
250
+ bitmapWidth = width; bitmapHeight = height; pixelRatio = ratio;
251
+ }
252
+ }
253
+ function tick(now: number) {
254
+ frame = 0;
255
+ if (disposed || failed) return;
256
+ // Media preferences can change before their corresponding event is dispatched.
257
+ if (state() !== 'playing') { sync(); return; }
258
+ if (last === null) last = now;
259
+ if (now - last >= 1000 / 30) {
260
+ const delta = Math.min((now - last) / 1000, .05);
261
+ last = now;
262
+ time.value += delta;
263
+ star.rotation.y += delta * .45;
264
+ updateOrbit();
265
+ render();
266
+ }
267
+ if (!failed) frame = requestAnimationFrame(tick);
268
+ }
269
+ function sync() {
270
+ if (disposed || failed) return;
271
+ stop(); resize(); render();
272
+ const next = state(); announce(next);
273
+ if (next === 'playing') frame = requestAnimationFrame(tick);
274
+ }
275
+ function refreshTheme() { if (!disposed && !failed) { palette(); sync(); } }
276
+ function contextLost(event: Event) {
277
+ event.preventDefault(); lost = true;
278
+ host.removeAttribute('data-rhine-neutron-rendered'); sync();
279
+ }
280
+ function contextRestored() {
281
+ if (!disposed && !failed) { lost = false; bitmapWidth = bitmapHeight = pixelRatio = 0; refreshTheme(); }
282
+ }
283
+ gl.domElement.addEventListener('webglcontextlost', contextLost);
284
+ gl.domElement.addEventListener('webglcontextrestored', contextRestored);
285
+ cleanups.push(() => {
286
+ gl.domElement.removeEventListener('webglcontextlost', contextLost);
287
+ gl.domElement.removeEventListener('webglcontextrestored', contextRestored);
288
+ });
289
+ const observer = typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(sync);
290
+ if (observer) { cleanups.push(() => observer.disconnect()); observer.observe(host); }
291
+ const intersection = typeof IntersectionObserver === 'undefined' ? null : new IntersectionObserver(entries => {
292
+ const entry = entries.find(value => value.target === host);
293
+ if (entry) { visible = entry.isIntersecting; sync(); }
294
+ });
295
+ if (intersection) { cleanups.push(() => intersection.disconnect()); intersection.observe(host); }
296
+ const preferences = new MutationObserver(refreshTheme);
297
+ cleanups.push(() => preferences.disconnect());
298
+ // Watch ancestors, not canvas attributes or our own state notifications.
299
+ for (let ancestor = host.parentElement; ancestor; ancestor = ancestor.parentElement) {
300
+ preferences.observe(ancestor, { attributes: true, attributeFilter: ['data-rhine-theme', 'data-rhine-motion', 'class', 'style'] });
301
+ }
302
+ document.addEventListener('visibilitychange', sync);
303
+ window.addEventListener('resize', sync);
304
+ motion?.addEventListener?.('change', sync);
305
+ cleanups.push(() => {
306
+ document.removeEventListener('visibilitychange', sync);
307
+ window.removeEventListener('resize', sync);
308
+ motion?.removeEventListener?.('change', sync);
309
+ });
310
+ palette(); sync();
311
+ return {
312
+ setPaused(value) { if (paused !== value) { paused = value; sync(); } },
313
+ setReducedMotion(value) { if (manualReduced !== value) { manualReduced = value; sync(); } },
314
+ setAppearance(value) { if (appearance !== value) { appearance = value; refreshTheme(); } },
315
+ dispose() {
316
+ if (disposed) return;
317
+ disposed = true; stop();
318
+ cleanups.splice(0).forEach(cleanup => cleanup());
319
+ release();
320
+ },
321
+ };
322
+ } catch {
323
+ cleanups.splice(0).forEach(cleanup => cleanup());
324
+ release(); onState('unavailable');
325
+ return { setPaused() {}, setReducedMotion() {}, setAppearance() {}, dispose() {} };
326
+ }
327
+ }
@@ -1,7 +1,43 @@
1
1
  .rhine-orbit {
2
+ --rhine-orbit-linework: paper;
3
+ --rhine-orbit-line-primary: var(--rhine-green);
4
+ --rhine-orbit-line-secondary: var(--rhine-signal);
5
+ --rhine-orbit-line-tertiary: var(--rhine-muted);
6
+ --rhine-orbit-particle-primary: var(--rhine-green);
7
+ --rhine-orbit-particle-secondary: var(--rhine-signal);
8
+ --rhine-orbit-particle-tertiary: var(--rhine-ink);
9
+ --rhine-orbit-spike-near: var(--rhine-ink);
10
+ --rhine-orbit-spike-far: var(--rhine-warning);
11
+ --rhine-orbit-trail-near: var(--rhine-green);
12
+ --rhine-orbit-trail-far: var(--rhine-signal);
13
+ --rhine-orbit-spark: var(--rhine-ink);
14
+ --rhine-orbit-void: var(--rhine-surface);
15
+ --rhine-orbit-highlight: var(--rhine-signal);
16
+ --rhine-orbit-highlight-glow: var(--rhine-signal);
2
17
  position:relative; isolation:isolate; display:flex; flex-direction:column;
3
18
  min-width:0; min-height:280px; overflow:hidden;
4
19
  border:1px solid var(--rhine-line); border-radius:var(--rhine-radius-card);
20
+ background:
21
+ radial-gradient(ellipse at 90% 78%, color-mix(in srgb, var(--rhine-green) 12%, transparent) 0, transparent 43%),
22
+ radial-gradient(ellipse at 8% 54%, color-mix(in srgb, var(--rhine-signal) 8%, transparent) 0, transparent 35%),
23
+ linear-gradient(155deg, var(--rhine-surface) 12%, var(--rhine-bg) 58%, var(--rhine-surface));
24
+ }
25
+ .rhine-orbit[data-rhine-orbit-appearance="original"] {
26
+ --rhine-orbit-linework: original;
27
+ --rhine-orbit-line-primary: #68b2f4;
28
+ --rhine-orbit-line-secondary: #dfbb5c;
29
+ --rhine-orbit-line-tertiary: #48b6b3;
30
+ --rhine-orbit-particle-primary: #51b7f6;
31
+ --rhine-orbit-particle-secondary: #e5b94d;
32
+ --rhine-orbit-particle-tertiary: #2ec7a1;
33
+ --rhine-orbit-spike-near: #d5e0f1;
34
+ --rhine-orbit-spike-far: #b57635;
35
+ --rhine-orbit-trail-near: #addaff;
36
+ --rhine-orbit-trail-far: #e1a147;
37
+ --rhine-orbit-spark: #f2f7ff;
38
+ --rhine-orbit-void: #010205;
39
+ --rhine-orbit-highlight: #ff658c;
40
+ --rhine-orbit-highlight-glow: #f25380;
5
41
  background:
6
42
  radial-gradient(ellipse at 90% 78%, #15508066 0, transparent 43%),
7
43
  radial-gradient(ellipse at 8% 54%, #84603626 0, transparent 35%),
@@ -10,6 +46,15 @@
10
46
  }
11
47
  .rhine-orbit::before, .rhine-orbit::after { content:''; position:absolute; inset:0; pointer-events:none; }
12
48
  .rhine-orbit::before {
49
+ background-image:
50
+ radial-gradient(circle at 17px 37px, var(--rhine-muted) .7px, transparent 1.4px),
51
+ radial-gradient(circle at 73px 19px, var(--rhine-green) .6px, transparent 1.2px),
52
+ radial-gradient(circle at 41px 89px, var(--rhine-signal) .7px, transparent 1.3px),
53
+ radial-gradient(circle at 113px 157px, var(--rhine-muted) .5px, transparent 1.2px);
54
+ background-size:173px 211px, 127px 181px, 251px 239px, 97px 139px;
55
+ opacity:.24;
56
+ }
57
+ .rhine-orbit[data-rhine-orbit-appearance="original"]::before {
13
58
  background-image:
14
59
  radial-gradient(circle at 17px 37px, #d7eaffb3 .7px, transparent 1.4px),
15
60
  radial-gradient(circle at 73px 19px, #8bb8e780 .6px, transparent 1.2px),
@@ -19,18 +64,51 @@
19
64
  opacity:.55;
20
65
  }
21
66
  .rhine-orbit::after {
67
+ background:
68
+ radial-gradient(ellipse at 77% 66%, color-mix(in srgb, var(--rhine-green) 8%, transparent) 0, transparent 28%),
69
+ radial-gradient(ellipse at 24% 84%, color-mix(in srgb, var(--rhine-signal) 6%, transparent) 0, transparent 30%);
70
+ }
71
+ .rhine-orbit[data-rhine-orbit-appearance="original"]::after {
22
72
  background:
23
73
  radial-gradient(ellipse at 77% 66%, #52a4f21a 0, transparent 28%),
24
74
  radial-gradient(ellipse at 24% 84%, #b8874220 0, transparent 30%),
25
75
  linear-gradient(125deg, transparent 40%, #6295d109 49%, transparent 58%);
26
76
  }
27
77
  .rhine-orbit-canvas { position:absolute; inset:0; display:block; width:100%; height:100%; pointer-events:none; }
78
+ .rhine-orbit-canvas[data-orbit-covered] { visibility:hidden; }
79
+ .rhine-orbit-canvas[hidden] { display:none; }
80
+ .rhine-orbit[data-rhine-orbit-appearance="theme"][data-rhine-orbit-state="unavailable"]::after {
81
+ inset:16px 16px 68px;
82
+ background:url('./assets/orbit-bird.svg') center / contain no-repeat;
83
+ }
28
84
  /* Keep host control tokens intact; only unpainted content inherits the artwork foreground. */
29
85
  .rhine-orbit-content {
30
86
  position:relative; z-index:1; flex:1; min-width:0;
31
87
  display:flex; flex-direction:column; justify-content:space-between; gap:var(--rhine-space-6);
32
88
  padding:var(--rhine-space-6); padding-bottom:calc(44px + var(--rhine-space-6) * 2);
33
- color:#f2f7ff;
89
+ color:var(--rhine-ink);
90
+ }
91
+ .rhine-orbit[data-rhine-orbit-appearance="original"] > .rhine-orbit-content { color:#f2f7ff; }
92
+ .rhine-theme[data-rhine-theme="neo-brutalism"] .rhine-orbit[data-rhine-orbit-appearance="theme"] {
93
+ --rhine-orbit-linework: donut;
94
+ --rhine-orbit-line-primary: var(--rhine-ink);
95
+ --rhine-orbit-line-tertiary: var(--rhine-green);
96
+ border:2px solid var(--rhine-ink);
97
+ box-shadow:var(--rhine-hard-shadow);
98
+ background:#fff3e5;
99
+ }
100
+ .rhine-theme[data-rhine-theme="neo-brutalism"] .rhine-orbit[data-rhine-orbit-appearance="theme"]::before,
101
+ .rhine-theme[data-rhine-theme="neo-brutalism"] .rhine-orbit[data-rhine-orbit-appearance="theme"]::after { display:none; }
102
+ .rhine-theme[data-rhine-theme="neo-brutalism"] .rhine-orbit[data-rhine-orbit-appearance="theme"][data-rhine-orbit-state="unavailable"]::after {
103
+ display:block;
104
+ inset:16px 16px 68px;
105
+ background:url('./assets/orbit-donut.svg') center / contain no-repeat;
106
+ }
107
+ .rhine-theme[data-rhine-theme="arknights"] .rhine-orbit[data-rhine-orbit-appearance="theme"] {
108
+ --rhine-orbit-linework: technical;
109
+ }
110
+ .rhine-theme[data-rhine-theme="arknights"] .rhine-orbit[data-rhine-orbit-appearance="theme"][data-rhine-orbit-state="unavailable"]::after {
111
+ background-image:url('./assets/orbit-crystal.svg');
34
112
  }
35
113
  .rhine-orbit-content :is(.rhine-text--muted, .rhine-field__hint) { color:inherit; opacity:.8; }
36
114
  .rhine-orbit-content .rhine-logo:not(.rhine-logo--icon) { color:inherit; }
@@ -40,3 +118,24 @@
40
118
  .rhine-orbit .rhine-orbit-content .rhine-button--quiet:focus-visible { outline:none; box-shadow:inset 0 0 0 2px currentColor; }
41
119
  .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
120
  .rhine-orbit-controls .rhine-button { max-width:100%; }
121
+
122
+ /* Public composition options keep both renderers and static artwork in the same region. */
123
+ .rhine-orbit[data-rhine-orbit-controls="false"] > .rhine-orbit-content { padding-bottom:var(--rhine-space-6); }
124
+ .rhine-orbit[data-rhine-orbit-composition="corner"] > .rhine-orbit-canvas,
125
+ .rhine-orbit[data-rhine-orbit-composition="corner"][data-rhine-orbit-state="unavailable"]::after,
126
+ .rhine-theme[data-rhine-theme] .rhine-orbit[data-rhine-orbit-composition="corner"][data-rhine-orbit-state="unavailable"]::after {
127
+ inset:auto 0 0 auto;
128
+ width:min(100%, 480px); height:44%; max-height:360px;
129
+ }
130
+ .rhine-orbit[data-rhine-orbit-framed="false"],
131
+ .rhine-theme[data-rhine-theme] .rhine-orbit[data-rhine-orbit-framed="false"] {
132
+ border:0; border-radius:0; box-shadow:none;
133
+ }
134
+ /* Older packaged artwork painted this fallback on the full root background. */
135
+ .rhine-theme[data-rhine-theme="neo-brutalism"] .rhine-orbit[data-rhine-orbit-appearance="theme"][data-rhine-orbit-composition="corner"][data-rhine-orbit-state="unavailable"] {
136
+ background-image:none;
137
+ }
138
+ .rhine-theme[data-rhine-theme="neo-brutalism"] .rhine-orbit[data-rhine-orbit-appearance="theme"][data-rhine-orbit-composition="corner"][data-rhine-orbit-state="unavailable"]::after {
139
+ display:block;
140
+ background:url('./assets/orbit-donut.svg') center / contain no-repeat;
141
+ }
@@ -0,0 +1,34 @@
1
+ export function paperMorphAmount(time: number) {
2
+ const phase = ((time % 42) + 42) % 42;
3
+ return phase < 4 ? 0 : phase < 18 ? (phase - 4) / 14 : phase < 24 ? 1 : phase < 38 ? (38 - phase) / 14 : 0;
4
+ }
5
+
6
+ export function paperEase(start: number, end: number, value: number) {
7
+ const t = Math.max(0, Math.min(1, (value - start) / (end - start)));
8
+ return t * t * t * (t * (t * 6 - 15) + 10);
9
+ }
10
+
11
+ /** Every flight pose is derived from the host's paused, visible-time clock. */
12
+ export function paperBirdFlight(time: number) {
13
+ const morph = paperMorphAmount(time);
14
+ const folding = paperEase(0, .12, morph) * (1 - paperEase(.88, 1, morph));
15
+ const turn = time * Math.PI * 2 / 14;
16
+ const stroke = time * Math.PI * 2 / 2.4;
17
+ const effort = .55 + .45 * Math.sin(turn - .4) ** 2;
18
+ return {
19
+ x: Math.sin(turn) * .37,
20
+ y: -.14 + Math.sin(turn * 2) * .16 + Math.sin(stroke - .5) * .035 * effort,
21
+ z: Math.cos(turn) * .2,
22
+ pitch: Math.cos(turn * 2) * .07 + Math.cos(stroke) * .025 * effort - folding * .09,
23
+ yaw: Math.cos(turn) * .24,
24
+ bank: -Math.sin(turn) * .12,
25
+ wing: ((-.03 + Math.sin(stroke) * .29 * effort) * (1 - morph) + (.2 + Math.sin(time * Math.PI * 2 / .8) * .43) * morph) * (1 - folding),
26
+ };
27
+ }
28
+
29
+ export function rotatePaperPoint(x: number, y: number, z: number, pitch: number, yaw: number, bank: number) {
30
+ // Match Three.js XYZ Euler order: local Z, then Y, then X.
31
+ const bx = x * Math.cos(bank) - y * Math.sin(bank), by = x * Math.sin(bank) + y * Math.cos(bank);
32
+ const yx = bx * Math.cos(yaw) + z * Math.sin(yaw), yz = -bx * Math.sin(yaw) + z * Math.cos(yaw);
33
+ return [yx, by * Math.cos(pitch) - yz * Math.sin(pitch), by * Math.sin(pitch) + yz * Math.cos(pitch)] as const;
34
+ }
@@ -0,0 +1,158 @@
1
+ import { paperEase } from './orbitBirdFlight';
2
+
3
+ type Vertex = [number, number, number];
4
+ export type PaperFold = { name: string; points: Vertex[]; faces: number[][]; wing: -1 | 0 | 1 };
5
+
6
+ /** Authored folds are shared by the WebGL sculpture and the Canvas fallback. */
7
+ export function paperBirdFolds(form: 'bird' | 'butterfly' = 'bird'): PaperFold[] {
8
+ const butterfly = form === 'butterfly';
9
+ const parts: PaperFold[] = [];
10
+ const part = (name: string, wing: -1 | 0 | 1 = 0): PaperFold => {
11
+ const value = { name, points: [] as Vertex[], faces: [] as number[][], wing };
12
+ parts.push(value); return value;
13
+ };
14
+ const mix = (a: Vertex, b: Vertex, t: number): Vertex => a.map((v, i) => v + (b[i] - v) * t) as Vertex;
15
+ function foldedVolume(name: string, rings: Array<{ center: Vertex; width: number; height: number }>) {
16
+ const p = part(name), sides = 8;
17
+ for (const ring of rings) for (let j = 0; j < sides; j++) {
18
+ const a = j / sides * Math.PI * 2;
19
+ p.points.push([ring.center[0] + Math.cos(a) * ring.width, ring.center[1] + Math.sin(a) * ring.height, ring.center[2]]);
20
+ }
21
+ for (let r = 0; r < rings.length - 1; r++) for (let j = 0; j < sides; j++) {
22
+ const a = r * sides + j, b = r * sides + (j + 1) % sides, c = b + sides, d = a + sides;
23
+ const center = mix(mix(p.points[a], p.points[c], .5), mix(p.points[b], p.points[d], .5), .5);
24
+ // A shallow raised diamond gives each panel a real mountain fold.
25
+ const angle = (j + .5) / sides * Math.PI * 2;
26
+ center[0] += Math.cos(angle) * .009; center[1] += Math.sin(angle) * .009;
27
+ const m = p.points.push(center) - 1;
28
+ p.faces.push([a,b,m],[b,c,m],[c,d,m],[d,a,m]);
29
+ }
30
+ for (const end of [0, rings.length - 1]) {
31
+ const m = p.points.push(rings[end].center) - 1, start = end * sides;
32
+ for (let j = 0; j < sides; j++) p.faces.push([m,start+j,start+(j+1)%sides]);
33
+ }
34
+ }
35
+ foldedVolume('folded-body', butterfly ? [
36
+ { center:[0,.035,.44], width:.065, height:.065 },
37
+ { center:[0,.015,.21], width:.09, height:.09 },
38
+ { center:[0,.025,-.13], width:.07, height:.07 },
39
+ { center:[0,.065,-.63], width:.018, height:.025 },
40
+ ] : [
41
+ { center:[0,.035,.44], width:.065, height:.075 },
42
+ { center:[0,.015,.21], width:.245, height:.19 },
43
+ { center:[0,.025,-.13], width:.215, height:.18 },
44
+ { center:[0,.065,-.46], width:.07, height:.085 },
45
+ ]);
46
+ foldedVolume('folded-neck-and-beak', butterfly ? [
47
+ { center:[0,.06,.32], width:.065, height:.065 },
48
+ { center:[0,.07,.4], width:.075, height:.07 },
49
+ { center:[0,.08,.47], width:.085, height:.075 },
50
+ { center:[0,.075,.53], width:.065, height:.06 },
51
+ { center:[0,.06,.57], width:.04, height:.04 },
52
+ { center:[0,.055,.59], width:.015, height:.015 },
53
+ ] : [
54
+ { center:[0,.06,.32], width:.085, height:.075 },
55
+ { center:[0,.29,.43], width:.062, height:.052 },
56
+ { center:[0,.62,.56], width:.042, height:.047 },
57
+ { center:[0,.88,.69], width:.045, height:.055 },
58
+ { center:[0,.93,.79], width:.052, height:.044 },
59
+ { center:[0,.77,1.07], width:.002, height:.002 },
60
+ ]);
61
+
62
+ for (const direction of [-1,1] as const) {
63
+ const wing = part('pleated-wing', direction), root: Vertex = [.13,.025,-.02];
64
+ const contour: Vertex[] = butterfly
65
+ ? [[.28,.08,.48],[.83,.18,1.03],[1.48,.26,.86],[1.4,.19,.22],[.52,.07,-.07],[1.21,.18,-.47],[1.03,.14,-1.02],[.55,.08,-1.08],[.15,.035,-.48]]
66
+ : [[.32,.08,.4],[.8,.23,.31],[1.38,.5,.07],[1.77,.76,-.19],[1.59,.67,-.41],[1.34,.5,-.58],[1.02,.34,-.68],[.61,.17,-.68],[.18,.035,-.52]];
67
+ for (let i = 0; i < contour.length - 1; i++) {
68
+ const start = wing.points.length; wing.points.push(root);
69
+ for (const t of [.28,.62,1]) {
70
+ const left = mix(root, contour[i], t), right = mix(root, contour[i+1], t), ridge = mix(left, right, .5);
71
+ ridge[1] += Math.sin(t * Math.PI * .8) * (.034 + i * .003);
72
+ wing.points.push(left,ridge,right);
73
+ }
74
+ wing.faces.push([start,start+1,start+2],[start,start+2,start+3]);
75
+ for (let row = 0; row < 2; row++) {
76
+ const a=start+1+row*3,b=a+3;
77
+ wing.faces.push([a,b,b+1],[a,b+1,a+1],[a+1,b+1,b+2],[a+1,b+2,a+2]);
78
+ }
79
+ }
80
+ // Separate folded vanes overlap the fan along its trailing edge.
81
+ for (let i = 3; i < contour.length - 1; i++) {
82
+ const vane = part('layered-wing-fold', direction);
83
+ const a: Vertex = [.23+i*.014,.005,-.16-i*.022];
84
+ const tip = contour[i].map((v,j) => v + (j===1 ? -.035 : j===2 ? -.025 : .025)) as Vertex;
85
+ const b = mix(a,tip,.4), c = mix(a,tip,.72);
86
+ const width=.045+(7-i)*.005;
87
+ vane.points=[a,[b[0],b[1]-.013,b[2]+width],[b[0],b[1]+.019,b[2]],[b[0],b[1]-.013,b[2]-width],
88
+ [c[0],c[1]-.009,c[2]+width*.65],[c[0],c[1]+.024,c[2]],[c[0],c[1]-.009,c[2]-width*.65],tip];
89
+ vane.faces=[[0,1,2],[0,2,3],[1,4,5],[1,5,2],[2,5,6],[2,6,3],[4,7,5],[5,7,6]];
90
+ }
91
+ const edge = part('folded-wing-edge',direction);
92
+ contour.forEach(v=>edge.points.push(v,[v[0],v[1]-.014,v[2]+.008]));
93
+ for(let i=0;i<contour.length-1;i++)edge.faces.push([i*2,i*2+2,i*2+3],[i*2,i*2+3,i*2+1]);
94
+ }
95
+ for (const offset of [-1,0,1]) {
96
+ const tail = part('pointed-paper-tail');
97
+ const tip:Vertex=[offset*.14,offset===0?.72:.54,offset===0?-1.15:-.98];
98
+ tail.points=[[-.09,.055,-.3],[.09,.055,-.3],[offset*.04,.2,-.58],[offset*.05,.11,-.6],tip];
99
+ if (butterfly) {
100
+ const x = offset * .045, z = offset ? .53 : -.35;
101
+ tail.points=[[x-.009,.06,z],[x+.009,.06,z],
102
+ [offset*.15,.12,offset ? .82 : -.58],[offset*.15,.1,offset ? .82 : -.58],
103
+ [offset*.28,offset ? .16 : .065,offset ? 1.04 : -.78]];
104
+ }
105
+ tail.faces=[[0,2,4],[2,1,4],[0,3,2],[2,3,1],[0,4,3],[3,4,1]];
106
+ }
107
+ for(const p of parts) if(p.wing) p.points=p.points.map(([x,y,z])=>[x*p.wing,y,z]);
108
+ return parts;
109
+ }
110
+
111
+ /** Keep every vertex and face attached while the paper folds into its second form. */
112
+ export function morphPaperPoint(from: Vertex, to: Vertex, amount: number, wing: number, name: string): Vertex {
113
+ if (amount <= 0) return [...from];
114
+ if (amount >= 1) return [...to];
115
+ // Shared coordinates determine the delay, so duplicated seam vertices never separate.
116
+ const reach = Math.min(1, Math.abs(from[0]) / 1.8);
117
+ const opening = wing ? paperEase(.18 + reach * .14, .32 + reach * .14, amount) : paperEase(.16, .43, amount);
118
+ const closing = wing ? paperEase(.58 + reach * .12, .73 + reach * .12, amount) : paperEase(.58, .87, amount);
119
+ const flat = opening * (1 - closing);
120
+ const delay = wing ? reach * .1 : name === 'folded-neck-and-beak' ? -.07 : .035;
121
+ const shape = .5 * paperEase(.13 + delay, .4 + delay, amount) + .5 * paperEase(.60 + delay, .86 + delay, amount);
122
+ const p = from.map((v, i) => v + (to[i] - v) * shape) as Vertex;
123
+
124
+ if (wing) {
125
+ p[1] = p[1] * (1 - flat * .95) + .025 * flat * .95;
126
+ // Release each hinge in turn; retain the outer fold until the inner paper has opened.
127
+ const tuck = paperEase(0, .16, amount);
128
+ const rootTuck = tuck * (1 - paperEase(.18, .31, amount));
129
+ const innerTuck = tuck * (1 - paperEase(.24, .39, amount));
130
+ const tipTuck = tuck * (1 - paperEase(.31, .46, amount));
131
+ const rootFold = paperEase(.58, .70, amount) * (1 - paperEase(.82, .94, amount));
132
+ const innerFold = paperEase(.64, .77, amount) * (1 - paperEase(.86, .98, amount));
133
+ const tipFold = paperEase(.71, .84, amount) * (1 - paperEase(.90, 1, amount));
134
+ let x = p[0] * wing, y = p[1];
135
+ const hinge = (pivot: number, angle: number) => {
136
+ if (x <= pivot) return;
137
+ angle *= paperEase(pivot, pivot + .12, x);
138
+ const dx = x - pivot, dy = y - .025;
139
+ x = pivot + dx * Math.cos(angle) - dy * Math.sin(angle);
140
+ y = .025 + dx * Math.sin(angle) + dy * Math.cos(angle);
141
+ };
142
+ hinge(1.02, -.95 * tipTuck - .75 * tipFold);
143
+ hinge(.46, .4 * innerTuck + .55 * innerFold);
144
+ const rootAngle = .35 * rootTuck + .38 * rootFold;
145
+ const dx = x - .13, dy = y - .025;
146
+ return [wing * (.13 + dx * Math.cos(rootAngle) - dy * Math.sin(rootAngle)),
147
+ .025 + dx * Math.sin(rootAngle) + dy * Math.cos(rootAngle), p[2]];
148
+ }
149
+
150
+ // Let the neck and tail open around their roots instead of shrinking in place.
151
+ const rootZ = name === 'folded-neck-and-beak' ? .32 : -.3;
152
+ const foldAngle = (name === 'folded-neck-and-beak' ? -.85 : name === 'pointed-paper-tail' ? .65 : 0) * flat;
153
+ const dy = p[1] - .035, dz = p[2] - rootZ;
154
+ p[1] = .035 + (dy * Math.cos(foldAngle) - dz * Math.sin(foldAngle)) * (1 - flat * .8);
155
+ p[2] = rootZ + dy * Math.sin(foldAngle) + dz * Math.cos(foldAngle);
156
+ if (name === 'folded-body') p[0] *= 1 + flat * .7;
157
+ return p;
158
+ }