@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,215 @@
1
+ import {
2
+ ACESFilmicToneMapping, AdditiveBlending, BufferGeometry, Color, DirectionalLight,
3
+ DoubleSide, EdgesGeometry, Float32BufferAttribute, Group, HemisphereLight, InstancedMesh,
4
+ LineBasicMaterial, LineSegments, Material, Mesh, MeshBasicMaterial, MeshPhysicalMaterial,
5
+ MeshStandardMaterial, NormalBlending, Object3D, OctahedronGeometry, PerspectiveCamera, PlaneGeometry,
6
+ Points, Scene, ShaderMaterial, SRGBColorSpace, Texture, TorusGeometry, WebGLRenderer,
7
+ } from 'three';
8
+ import { paperTexture, mineralMaterial, studioEnvironment } from './orbitObjectMaterials';
9
+ import { morphPaperPoint, paperBirdFolds, type PaperFold } from './orbitBirdGeometry';
10
+ import { paperBirdFlight, paperMorphAmount } from './orbitBirdFlight';
11
+
12
+ export type OrbitObjectKind = 'paper' | 'technical';
13
+ export interface OrbitObjectArtwork {
14
+ canvas: HTMLCanvasElement;
15
+ draw(width: number, height: number, time: number): boolean;
16
+ dispose(): void;
17
+ }
18
+
19
+ /** The host supplies elapsed visible time; a theme object never schedules its own frames. */
20
+ export function createOrbitObject(kind: OrbitObjectKind, invalidate: () => void): OrbitObjectArtwork {
21
+ const geometries = new Set<BufferGeometry>(), materials = new Set<Material>(), instances = new Set<InstancedMesh>(), textures = new Set<Texture>();
22
+ const geometry = <T extends BufferGeometry>(value: T) => { geometries.add(value); return value; };
23
+ const material = <T extends Material>(value: T) => { materials.add(value); return value; };
24
+ const texture = <T extends Texture>(value: T) => { textures.add(value); return value; };
25
+ const canvas = document.createElement('canvas');
26
+ canvas.className = `rhine-orbit-canvas rhine-orbit-object-webgl rhine-orbit-${kind}-webgl`;
27
+ canvas.setAttribute('aria-hidden', 'true'); canvas.dataset.orbitArtwork = kind;
28
+ let renderer: WebGLRenderer | undefined, disposed = false, lost = false;
29
+ function contextLost(event: Event) { event.preventDefault(); lost = true; invalidate(); }
30
+ function contextRestored() { lost = false; invalidate(); }
31
+ function dispose() {
32
+ if (disposed) return;
33
+ disposed = true;
34
+ canvas.removeEventListener('webglcontextlost', contextLost); canvas.removeEventListener('webglcontextrestored', contextRestored);
35
+ instances.forEach(value => value.dispose()); geometries.forEach(value => value.dispose()); materials.forEach(value => value.dispose()); textures.forEach(value => value.dispose());
36
+ renderer?.dispose(); renderer?.forceContextLoss(); canvas.remove();
37
+ }
38
+ try {
39
+ renderer = new WebGLRenderer({ canvas, alpha: true, antialias: true, powerPreference: 'low-power' });
40
+ renderer.setClearColor(0x000000, 0); renderer.outputColorSpace = SRGBColorSpace;
41
+ renderer.toneMapping = ACESFilmicToneMapping; renderer.toneMappingExposure = 1.12;
42
+ const bird = kind === 'paper', scene = new Scene(), camera = new PerspectiveCamera(35, 1, .1, 40);
43
+ scene.environment = texture(studioEnvironment());
44
+ scene.name = bird ? 'origami-paper-bird' : 'originium-cluster';
45
+ scene.add(new HemisphereLight(bird ? '#f5fff1' : '#d8e4dc', bird ? '#42654d' : '#151c24', bird ? .9 : .65));
46
+ const key = new DirectionalLight('#fff5da', 2.4); key.position.set(-3, 5, 4); scene.add(key);
47
+ const rim = new DirectionalLight(bird ? '#b6e0c8' : '#ffa634', 2.8); rim.position.set(3, 1, -2); scene.add(rim);
48
+ const fill = new DirectionalLight('#d4eeee', .7); fill.position.set(2, -2, 3); scene.add(fill);
49
+ const frame = new Group(); frame.rotation.set(bird ? .53 : .13, bird ? -.65 : -.32, bird ? -.12 : .08); scene.add(frame);
50
+ const object = new Group(); object.name = 'theme-object'; frame.add(object);
51
+ const dummy = new Object3D();
52
+ const updates: Array<(time: number) => void> = [];
53
+ let seed = bird ? 70231 : 39017;
54
+ const random = () => { seed = (Math.imul(seed, 1664525) + 1013904223) >>> 0; return seed / 4294967296; };
55
+ const mesh = (g: BufferGeometry, m: Material, parent = object) => { const value = new Mesh(g, m); parent.add(value); return value; };
56
+ const instanced = (g: BufferGeometry, m: Material, count: number, name: string, parent = object) => {
57
+ const value = new InstancedMesh(g, m, count); value.name = name; instances.add(value); parent.add(value); return value;
58
+ };
59
+
60
+ if (bird) {
61
+ const grain = texture(paperTexture());
62
+ const paper = material(new MeshPhysicalMaterial({ color: '#fffdf0', map: grain, bumpMap: grain, bumpScale: .008, roughness: .87, metalness: 0, side: DoubleSide }));
63
+ const shadedPaper = material(new MeshStandardMaterial({ color: '#dce4d5', map: grain, bumpMap: grain, bumpScale: .006, roughness: .92, side: DoubleSide }));
64
+ const innerPaper = material(new MeshStandardMaterial({ color: '#a7b9a7', map: grain, roughness: .93, side: DoubleSide }));
65
+ const crease = material(new LineBasicMaterial({ color: '#6f8572', transparent: true, opacity: .13 }));
66
+ function foldedSheet(fold: PaperFold, target: PaperFold, parent: Group) {
67
+ const { points, faces, name } = fold;
68
+ const positions: number[] = [], uvs: number[] = [];
69
+ for (const face of faces) for (const i of face) { positions.push(...points[i]); uvs.push(points[i][0] * .5 + .5, points[i][2] * .5 + .5); }
70
+ const g = geometry(new BufferGeometry()); g.setAttribute('position', new Float32BufferAttribute(positions, 3)); g.setAttribute('uv', new Float32BufferAttribute(uvs, 2)); g.computeVertexNormals();
71
+ faces.forEach((_, i) => g.addGroup(i * 3, 3, i % 10 === 4 ? 2 : i % 4 === 2 ? 1 : 0));
72
+ const sheet = new Mesh(g, [paper, shadedPaper, innerPaper]); sheet.name = name; parent.add(sheet);
73
+ const edges = geometry(new EdgesGeometry(g, 8)), edgePositions = edges.getAttribute('position');
74
+ const edgeIndices = Array.from({ length: edgePositions.count }, (_, i) => points.findIndex(p =>
75
+ Math.abs(p[0] - edgePositions.getX(i)) < 1e-6 && Math.abs(p[1] - edgePositions.getY(i)) < 1e-6 && Math.abs(p[2] - edgePositions.getZ(i)) < 1e-6));
76
+ const lines = new LineSegments(edges, crease); parent.add(lines);
77
+ // Reuse the same buffers, materials and crease endpoints for the entire transformation.
78
+ let previousAmount = -1;
79
+ updates.push(time => {
80
+ const amount = paperMorphAmount(time);
81
+ if (amount === previousAmount) return;
82
+ const vertices = points.map((p, i) => morphPaperPoint(p, target.points[i], amount, fold.wing, fold.name));
83
+ const positions = g.getAttribute('position');
84
+ faces.forEach((face, f) => face.forEach((index, v) => positions.setXYZ(f * 3 + v, ...vertices[index])));
85
+ edgeIndices.forEach((index, i) => edgePositions.setXYZ(i, ...vertices[index]));
86
+ positions.needsUpdate = true; edgePositions.needsUpdate = true;
87
+ g.computeVertexNormals(); g.computeBoundingSphere(); edges.computeBoundingSphere();
88
+ previousAmount = amount;
89
+ });
90
+ return sheet;
91
+ }
92
+ const wings = new Map<number, Group>();
93
+ for (const direction of [-1, 1]) {
94
+ const wing = new Group(); wing.name = direction < 0 ? 'paper-wing-left' : 'paper-wing-right'; object.add(wing); wings.set(direction, wing);
95
+ updates.push(time => { wing.rotation.z = direction * paperBirdFlight(time).wing; });
96
+ }
97
+ const targets = paperBirdFolds('butterfly');
98
+ paperBirdFolds().forEach((fold, i) => foldedSheet(fold, targets[i], fold.wing ? wings.get(fold.wing)! : object));
99
+ const scraps = instanced(geometry(new PlaneGeometry(.035,.018)), shadedPaper, 42, 'floating-paper-flecks', frame);
100
+ scraps.frustumCulled = false;
101
+ const seeds = Array.from({length:scraps.count},()=>({a:random()*Math.PI*2,r:1.15+random()*.7,y:random()*2.1-1.05,s:.4+random()*.9}));
102
+ updates.push(time=>{
103
+ const flight = paperBirdFlight(time);
104
+ object.position.set(flight.x, flight.y, flight.z);
105
+ object.rotation.set(flight.pitch, flight.yaw, flight.bank);
106
+ seeds.forEach((p,i)=>{const a=p.a+time*.06, wake=paperBirdFlight(time-i*.06);dummy.position.set(wake.x+Math.cos(a)*p.r*.65,wake.y+p.y*.7, wake.z- .25-i*.017+Math.sin(a)*p.r*.25);dummy.rotation.set(p.a+time*.16,a,time*.1+p.a);dummy.scale.setScalar(p.s);dummy.updateMatrix();scraps.setMatrixAt(i,dummy.matrix);});
107
+ scraps.instanceMatrix.needsUpdate=true;
108
+ });
109
+ } else {
110
+ function crystalGeometry() {
111
+ const positions: number[] = [];
112
+ const rings = [{ y: -1.03, r: .24 }, { y: -.78, r: .72 }, { y: .57, r: 1 }, { y: .72, r: .92 }, { y: 1.46, r: .06 }];
113
+ const vertex = (i: number, level: number) => {
114
+ const a = Math.floor(i / 2) * Math.PI / 3 + (i % 2 ? .055 : -.055);
115
+ return [Math.cos(a) * rings[level].r, rings[level].y, Math.sin(a) * rings[level].r];
116
+ };
117
+ const triangle = (a: number[], b: number[], c: number[]) => positions.push(...a, ...b, ...c);
118
+ for (let i = 0; i < 12; i++) {
119
+ const j = (i + 1) % 12;
120
+ for (let level = 0; level < rings.length - 1; level++) {
121
+ const p = vertex(i, level), q = vertex(j, level), r = vertex(i, level + 1), t = vertex(j, level + 1);
122
+ triangle(p, r, q); triangle(q, r, t);
123
+ }
124
+ triangle(vertex(j, 0), [0, -1.1, 0], vertex(i, 0));
125
+ triangle(vertex(i, 4), [0, 1.51, 0], vertex(j, 4));
126
+ }
127
+ const g = geometry(new BufferGeometry()); g.setAttribute('position', new Float32BufferAttribute(positions, 3)); g.computeVertexNormals(); return g;
128
+ }
129
+ const crystal = crystalGeometry();
130
+ const facetMaterial = material(mineralMaterial());
131
+ const edgeMaterial = material(new LineBasicMaterial({ color: '#9e885b', transparent: true, opacity: .16 }));
132
+ const edges = geometry(new EdgesGeometry(crystal, 25));
133
+ const shards = [
134
+ { x: 0, y: .04, z: 0, s: .88, rx: .04, rz: -.08 },
135
+ { x: -.48, y: -.38, z: .12, s: .56, rx: .12, rz: .45 },
136
+ { x: .47, y: -.35, z: .13, s: .64, rx: -.14, rz: -.4 },
137
+ { x: -.18, y: -.58, z: -.42, s: .48, rx: -.4, rz: .15 },
138
+ { x: .18, y: -.65, z: .48, s: .42, rx: .38, rz: -.1 },
139
+ ];
140
+ shards.forEach((p, i) => {
141
+ const shard = new Group(); shard.name = `originium-facet-${i}`; shard.position.set(p.x, p.y, p.z); shard.rotation.set(p.rx, i * .8, p.rz); shard.scale.set(.37 * p.s, p.s, .37 * p.s); object.add(shard);
142
+ mesh(crystal, facetMaterial, shard); shard.add(new LineSegments(edges, edgeMaterial));
143
+ });
144
+ const fragments = instanced(geometry(new OctahedronGeometry(.038, 0)), facetMaterial, 48, 'floating-fragments');
145
+ // The shader and instanced particles move beyond their initial CPU coordinates.
146
+ fragments.frustumCulled = false;
147
+ const fragmentSeeds = Array.from({ length: fragments.count }, () => ({ phase: random() * Math.PI * 2, r: 1.02 + random() * .58, y: random() * 2.35 - 1.1, size: .28 + random() * 1.15, speed: .08 + random() * .08 }));
148
+ updates.push(time => {
149
+ fragmentSeeds.forEach((p, i) => {
150
+ const a = p.phase + time * p.speed;
151
+ dummy.position.set(Math.cos(a) * p.r, p.y + Math.sin(time * .4 + p.phase) * .12, Math.sin(a) * p.r * .5);
152
+ dummy.rotation.set(a * .5, a, p.phase + time * .12); dummy.scale.set(.65 * p.size, 1.5 * p.size, p.size); dummy.updateMatrix(); fragments.setMatrixAt(i, dummy.matrix);
153
+ }); fragments.instanceMatrix.needsUpdate = true;
154
+ });
155
+ const orbital = new Group(); orbital.name = 'containment-ring'; orbital.rotation.set(1.12, .35, -.2); scene.add(orbital);
156
+ const line = material(new MeshBasicMaterial({ color: '#d9a151', transparent: true, opacity: .26 }));
157
+ for (let i = 0; i < 8; i++) {
158
+ const ring = mesh(geometry(new TorusGeometry(1.46, .003, 4, 32, .43)), line, orbital); ring.rotation.z = i / 8 * Math.PI * 2;
159
+ }
160
+ updates.push(time => { orbital.rotation.z = -.2 + time * .055; });
161
+ }
162
+
163
+ const particleCount = bird ? 120 : 420, positions: number[] = [], phases: number[] = [], sizes: number[] = [];
164
+ for (let i = 0; i < particleCount; i++) {
165
+ const a = random() * Math.PI * 2, r = .85 + random() * 1.25;
166
+ positions.push(Math.cos(a) * r, random() * 2.9 - 1.45, Math.sin(a) * r * .55); phases.push(random() * Math.PI * 2); sizes.push(.8 + random() * 2.2);
167
+ }
168
+ const particleGeometry = geometry(new BufferGeometry()); particleGeometry.setAttribute('position', new Float32BufferAttribute(positions, 3));
169
+ particleGeometry.setAttribute('phase', new Float32BufferAttribute(phases, 1)); particleGeometry.setAttribute('size', new Float32BufferAttribute(sizes, 1));
170
+ particleGeometry.computeBoundingSphere(); particleGeometry.boundingSphere!.radius += .3;
171
+ const particleMaterial = material(new ShaderMaterial({
172
+ transparent: true, depthWrite: false, blending: bird ? NormalBlending : AdditiveBlending,
173
+ uniforms: { time: { value: 0 }, pixelRatio: { value: 1 }, tint: { value: new Color(bird ? '#ad935d' : '#ffab39') } },
174
+ vertexShader: `attribute float phase; attribute float size; uniform float time; uniform float pixelRatio; varying float alpha;
175
+ void main(){vec3 p=position; p.x+=sin(time*.2+phase)*.08; p.y+=sin(time*.3+phase)*.14;
176
+ vec4 mv=modelViewMatrix*vec4(p,1.); gl_Position=projectionMatrix*mv; gl_PointSize=size*pixelRatio*6./-mv.z;
177
+ alpha=.35+.3*sin(time*.8+phase);}`,
178
+ fragmentShader: `uniform vec3 tint; varying float alpha; void main(){float r=length(gl_PointCoord-.5)*2.;
179
+ gl_FragColor=vec4(tint,(1.-smoothstep(.05,1.,r))*alpha);}`,
180
+ }));
181
+ const dust = new Points(particleGeometry, particleMaterial); dust.name = bird ? 'paper-motes' : 'crystal-embers'; scene.add(dust);
182
+ const shadow = new Mesh(geometry(new PlaneGeometry(3.4, .65)), material(new ShaderMaterial({
183
+ transparent: true, depthWrite: false,
184
+ vertexShader: 'varying vec2 vUv;void main(){vUv=uv;gl_Position=projectionMatrix*modelViewMatrix*vec4(position,1.);}',
185
+ fragmentShader: 'varying vec2 vUv;void main(){float a=(1.-smoothstep(.05,1.,length((vUv-.5)*2.)))*.17;gl_FragColor=vec4(.15,.23,.19,a);}',
186
+ }))); scene.add(shadow); shadow.position.set(.08, -1.52, -1);
187
+ let lastWidth = 0, lastHeight = 0, lastRatio = 0;
188
+ canvas.addEventListener('webglcontextlost', contextLost); canvas.addEventListener('webglcontextrestored', contextRestored);
189
+ return {
190
+ canvas,
191
+ draw(width, height, time) {
192
+ if (disposed || lost || width <= 0 || height <= 0) return false;
193
+ const ratio = Math.min(window.devicePixelRatio || 1, 1.5);
194
+ if (width !== lastWidth || height !== lastHeight || ratio !== lastRatio) {
195
+ renderer!.setPixelRatio(ratio); renderer!.setSize(width, height, false);
196
+ const scale = Math.min(width * (bird ? .175 : .28), Math.max(1, height - 76) * (bird ? .285 : .3));
197
+ camera.aspect = width / height; camera.position.set(0, 0, 6); camera.lookAt(0, 0, 0);
198
+ camera.fov = 2 * Math.atan(height / (2 * scale * 6)) * 180 / Math.PI; camera.updateProjectionMatrix();
199
+ camera.projectionMatrix.elements[8] = -.08; camera.projectionMatrix.elements[9] = -36 / height;
200
+ camera.projectionMatrixInverse.copy(camera.projectionMatrix).invert();
201
+ particleMaterial.uniforms.pixelRatio.value = ratio; lastWidth = width; lastHeight = height; lastRatio = ratio;
202
+ }
203
+ if (!bird) object.rotation.y = time * .11;
204
+ else {
205
+ const flight = paperBirdFlight(time);
206
+ shadow.position.x = .08 + flight.x * .7;
207
+ shadow.scale.setScalar(1 - (flight.y + .3) * .18);
208
+ }
209
+ updates.forEach(update => update(time)); particleMaterial.uniforms.time.value = time;
210
+ renderer!.render(scene, camera); return true;
211
+ },
212
+ dispose,
213
+ };
214
+ } catch (error) { dispose(); throw error; }
215
+ }
@@ -0,0 +1,21 @@
1
+ export type OrbitVector = { x: number; y: number; z: number };
2
+ export const orbitCameraDistance = 3.6;
3
+ const yaw = -.72, tilt = .22, roll = .34;
4
+ const ct = Math.cos(tilt), st = Math.sin(tilt), cy = Math.cos(yaw), sy = Math.sin(yaw), cr = Math.cos(roll), sr = Math.sin(roll);
5
+
6
+ /** Keep every renderer in the original artwork's frame, including on narrow screens. */
7
+ export function orbitFraming(width: number, height: number) {
8
+ return { scale: Math.min(width * .66, height * .39), centerX: width * .62, centerY: height * .57 };
9
+ }
10
+
11
+ /** Shared camera orientation for the original network and the strawberry ring. */
12
+ export function rotateOrbitPoint({ x, y, z }: OrbitVector): OrbitVector {
13
+ const yy = y * ct - z * st, zz = y * st + z * ct;
14
+ const xx = x * cy + zz * sy, depth = -x * sy + zz * cy;
15
+ return { x: xx * cr - yy * sr, y: xx * sr + yy * cr, z: depth };
16
+ }
17
+
18
+ export function projectOrbitPoint(p: OrbitVector, scale: number, centerX: number, centerY: number) {
19
+ const perspective = orbitCameraDistance / (orbitCameraDistance - p.z);
20
+ return { x: centerX + p.x * scale * perspective, y: centerY + p.y * scale * perspective * 1.08, depth: p.z, perspective };
21
+ }
@@ -1,11 +1,33 @@
1
+ import { createOrbitDonut } from './orbitDonut';
2
+ import type { DonutArtwork } from './orbitDonutThree';
3
+ import { drawOrbitObject } from './orbitObjects';
4
+ import { orbitFraming, projectOrbitPoint, rotateOrbitPoint } from './orbitProjection';
5
+
6
+ type OrbitLinework = 'original' | 'paper' | 'technical' | 'donut';
1
7
  type Point = { x: number; y: number; depth: number; perspective: number };
2
8
  type Edge = { a: number; sweep: number; phase: number; waist: number; lift: number; color: number; speed: number };
3
9
  type Layer =
4
10
  | { kind: 'void'; points: Point[] }
5
- | { kind: 'line'; p: Point; q: Point; color: number }
11
+ | { kind: 'line'; p: Point; q: Point; color: number; distance: number }
6
12
  | { kind: 'particle'; p: Point; color: number }
7
13
  | { kind: 'spike'; p: Point; q: Point; i: number; pulse: number };
8
14
  type CanvasScene = { el: HTMLCanvasElement; ctx: CanvasRenderingContext2D; w: number; h: number };
15
+ const originalPalette = {
16
+ 'line-primary': '#68b2f4', 'line-secondary': '#dfbb5c', 'line-tertiary': '#48b6b3',
17
+ 'particle-primary': '#51b7f6', 'particle-secondary': '#e5b94d', 'particle-tertiary': '#2ec7a1',
18
+ 'spike-near': '#d5e0f1', 'spike-far': '#b57635', 'trail-near': '#addaff', 'trail-far': '#e1a147',
19
+ spark: '#f2f7ff', void: '#010205', highlight: '#ff658c', 'highlight-glow': '#f25380',
20
+ };
21
+ function transparentColor(color: string) {
22
+ // Canvas serializes sRGB as hex/rgb and other color spaces as functions.
23
+ // Keep the RGB components at alpha zero: Canvas gradients do not premultiply interpolation.
24
+ if (color.startsWith('#')) return `${color.slice(0, 7)}00`;
25
+ if (color.startsWith('rgb')) {
26
+ const channels = color.slice(color.indexOf('(') + 1, -1).trim().split(/[\s,/]+/).slice(0, 3);
27
+ return `rgba(${channels.join(',')},0)`;
28
+ }
29
+ return color.replace(/(?:\s*\/[^()]*)?\)$/, ' / 0)');
30
+ }
9
31
  export type OrbitState = 'playing' | 'paused' | 'reduced' | 'compact' | 'hidden' | 'offscreen' | 'empty' | 'unavailable';
10
32
  export interface OrbitRenderer { setPaused(value: boolean): void; setReducedMotion(value: boolean): void; dispose(): void }
11
33
 
@@ -17,7 +39,28 @@ export function mountOrbit(canvas: HTMLCanvasElement, onState: (state: OrbitStat
17
39
  onState('unavailable');
18
40
  return { setPaused() {}, setReducedMotion() {}, dispose() {} };
19
41
  }
20
- const TAU = Math.PI * 2, colors = ["#51b7f6", "#e5b94d", "#2ec7a1", "#bdcbe1"];
42
+ const TAU = Math.PI * 2;
43
+ let linework: OrbitLinework = 'original';
44
+ let drawDonut: ReturnType<typeof createOrbitDonut> | undefined;
45
+ type ObjectStyle = Exclude<OrbitLinework, 'original'>;
46
+ const artworks = new Map<ObjectStyle, DonutArtwork>();
47
+ const loading = new Set<ObjectStyle>(), failed = new Set<ObjectStyle>();
48
+ const palette = { ...originalPalette }, fadeColors = { near: '', far: '' };
49
+ function readPalette() {
50
+ const style = getComputedStyle(canvas), previousFill = ctx!.fillStyle;
51
+ const recipe = style.getPropertyValue('--rhine-orbit-linework').trim();
52
+ linework = recipe === 'paper' || recipe === 'technical' || recipe === 'donut' ? recipe : 'original';
53
+ for (const key of Object.keys(originalPalette) as (keyof typeof originalPalette)[]) {
54
+ // Let Canvas resolve CSS colors, including color-mix() and non-RGB host tokens.
55
+ // An invalid value leaves fillStyle unchanged, so each entry starts at its fallback.
56
+ ctx!.fillStyle = originalPalette[key];
57
+ ctx!.fillStyle = style.getPropertyValue(`--rhine-orbit-${key}`).trim() || originalPalette[key];
58
+ palette[key] = ctx!.fillStyle as string;
59
+ }
60
+ fadeColors.near = transparentColor(palette['trail-near']);
61
+ fadeColors.far = transparentColor(palette['trail-far']);
62
+ ctx!.fillStyle = previousFill;
63
+ }
21
64
  let seed = 1823;
22
65
  function rand() {
23
66
  seed = seed * 1664525 + 1013904223 >>> 0;
@@ -32,8 +75,8 @@ export function mountOrbit(canvas: HTMLCanvasElement, onState: (state: OrbitStat
32
75
  }
33
76
  const paths = edges.map((e) => Array.from({ length: 81 }, (_, i) => edgePoint(e, i / 80)));
34
77
  const canvases = { network: { el: canvas, ctx, w: 0, h: 0 } };
78
+ let framing = orbitFraming(0, 0);
35
79
  let time = 0, orbit = 0, projected: Point[] = [], last: number | null = null, frame = 0;
36
- const yaw = -0.72, tilt = 0.22;
37
80
  let paused = false, manualReduced = false, disposed = false, visible = true;
38
81
  let previousState: OrbitState | undefined;
39
82
  function resize() {
@@ -42,38 +85,61 @@ export function mountOrbit(canvas: HTMLCanvasElement, onState: (state: OrbitStat
42
85
  const c = canvases.network;
43
86
  c.w = c.el.clientWidth;
44
87
  c.h = c.el.clientHeight;
88
+ framing = orbitFraming(c.w, c.h);
45
89
  c.el.width = Math.round(c.w * dpr);
46
90
  c.el.height = Math.round(c.h * dpr);
47
91
  c.ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
48
92
  draw();
49
93
  }
50
- function line(ctx2: CanvasRenderingContext2D, x1: number, y1: number, x2: number, y2: number, color: string | CanvasGradient, width = 1) {
94
+ function line(ctx2: CanvasRenderingContext2D, x1: number, y1: number, x2: number, y2: number, color: string | CanvasGradient, width = 1, alpha = 1) {
95
+ ctx2.globalAlpha = alpha;
51
96
  ctx2.strokeStyle = color;
52
97
  ctx2.lineWidth = width;
53
98
  ctx2.beginPath();
54
99
  ctx2.moveTo(x1, y1);
55
100
  ctx2.lineTo(x2, y2);
56
101
  ctx2.stroke();
102
+ ctx2.globalAlpha = 1;
57
103
  }
58
- function dot(ctx2: CanvasRenderingContext2D, x: number, y: number, r: number, color: string | CanvasGradient) {
104
+ function dot(ctx2: CanvasRenderingContext2D, x: number, y: number, r: number, color: string | CanvasGradient, alpha = 1) {
105
+ ctx2.globalAlpha = alpha;
59
106
  ctx2.fillStyle = color;
60
107
  ctx2.beginPath();
61
108
  ctx2.arc(x, y, r, 0, TAU);
62
109
  ctx2.fill();
110
+ ctx2.globalAlpha = 1;
111
+ }
112
+ function marker(x: number, y: number, radius: number, color: string, alpha = 1) {
113
+ if (linework === 'original') { dot(ctx!, x, y, radius, color, alpha); return; }
114
+ ctx!.globalAlpha = alpha;
115
+ if (linework === 'technical') {
116
+ ctx!.fillStyle = color;
117
+ ctx!.fillRect(x - radius, y - radius, radius * 2, radius * 2);
118
+ } else {
119
+ ctx!.beginPath(); ctx!.arc(x, y, radius, 0, TAU);
120
+ ctx!.fillStyle = palette.void;
121
+ ctx!.fill();
122
+ ctx!.strokeStyle = color;
123
+ ctx!.lineWidth = .8;
124
+ ctx!.stroke();
125
+ }
126
+ ctx!.globalAlpha = 1;
63
127
  }
64
128
  function clear(c: CanvasScene) {
65
129
  c.ctx.clearRect(0, 0, c.w, c.h);
66
130
  }
67
131
  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 };
132
+ const { scale, centerX, centerY } = framing;
133
+ return projectOrbitPoint(rotateOrbitPoint({ x: Math.cos(a) * r, y: Math.sin(a) * r, z }), scale, centerX, centerY);
73
134
  }
74
135
  function network() {
75
136
  const c = canvases.network, { ctx: ctx2 } = c;
76
137
  clear(c);
138
+ const original = linework === 'original', paper = linework === 'paper', technical = linework === 'technical';
139
+ ctx2.lineCap = original || technical ? 'butt' : 'round';
140
+ ctx2.lineJoin = original || technical ? 'miter' : 'round';
141
+ ctx2.setLineDash([]);
142
+ ctx2.lineDashOffset = 0;
77
143
  const buckets: Layer[][] = Array.from({ length: 80 }, () => []), offset = orbit;
78
144
  function add(item: Layer, depth: number) {
79
145
  buckets[Math.max(0, Math.min(79, Math.floor((depth + 1.5) / 3 * 80)))].push(item);
@@ -82,7 +148,11 @@ export function mountOrbit(canvas: HTMLCanvasElement, onState: (state: OrbitStat
82
148
  add({ kind: "void", points: horizon }, -0.04);
83
149
  edges.forEach((e, index) => {
84
150
  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);
151
+ let distance = 0;
152
+ for (let i = 1; i < pts.length; i++) {
153
+ add({ kind: "line", p: pts[i - 1], q: pts[i], color: e.color, distance }, (pts[i - 1].depth + pts[i].depth) / 2 + 7e-3);
154
+ distance += Math.hypot(pts[i].x - pts[i - 1].x, pts[i].y - pts[i - 1].y);
155
+ }
86
156
  if (index % 3 === 0) {
87
157
  const t = (time * e.speed + e.phase / TAU) % 1, p3 = edgePoint(e, t), p = project(p3.a + offset, p3.r, p3.z);
88
158
  add({ kind: "particle", p, color: e.color }, p.depth + 0.012);
@@ -93,73 +163,116 @@ export function mountOrbit(canvas: HTMLCanvasElement, onState: (state: OrbitStat
93
163
  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
164
  add({ kind: "spike", p, q, i, pulse }, (p.depth + q.depth) / 2);
95
165
  });
96
- const palette = ["104,178,244", "223,187,92", "72,182,179"];
166
+ const lineColors = [palette['line-primary'], palette['line-secondary'], palette['line-tertiary']];
167
+ const particleColors = [palette['particle-primary'], palette['particle-secondary'], palette['particle-tertiary']];
97
168
  for (const bucket of buckets) for (const item of bucket) {
98
169
  if (item.kind === "void") {
99
170
  ctx2.beginPath();
100
171
  item.points.forEach((p2, i) => i ? ctx2.lineTo(p2.x, p2.y) : ctx2.moveTo(p2.x, p2.y));
101
172
  ctx2.closePath();
102
- ctx2.fillStyle = "#010205";
173
+ ctx2.fillStyle = palette.void;
103
174
  ctx2.fill();
104
175
  continue;
105
176
  }
106
177
  const p = item.p, near = Math.max(0, Math.min(1, (p.depth + 1) / 2));
107
178
  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();
179
+ // Keep the same projected segments and depth order; dash phase follows the whole curve.
180
+ if (technical && item.color !== 1) {
181
+ ctx2.setLineDash([4, 5]); ctx2.lineDashOffset = -item.distance;
182
+ }
183
+ const width = original ? .35 + near * .65 : paper ? .45 + near * .65 : .55 + near * .95;
184
+ const alpha = original ? .07 + near * near * .57 : paper ? .05 + near * near * .48 : .08 + near * near * .55;
185
+ line(ctx2, p.x, p.y, item.q.x, item.q.y, lineColors[item.color], width, alpha);
186
+ if (technical) { ctx2.setLineDash([]); ctx2.lineDashOffset = 0; }
114
187
  } 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);
188
+ const q = item.q, bright = near > 0.48, color = palette[bright ? 'trail-near' : 'trail-far'], dx = q.x - p.x, dy = q.y - p.y, len = Math.hypot(dx, dy) || 1, ux = dx / len, uy = dy / len;
189
+ if (technical) ctx2.setLineDash([2, 3]);
190
+ line(ctx2, p.x, p.y, q.x, q.y, palette[bright ? 'spike-near' : 'spike-far'], original ? .4 + near * .4 : paper ? .5 : .7, bright ? .16 + near * .35 : .12 + near * .3);
191
+ if (technical) ctx2.setLineDash([]);
117
192
  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);
193
+ if (original) {
194
+ const trail = ctx2.createLinearGradient(tx, ty, x + 1e-3, y + 1e-3);
195
+ trail.addColorStop(0, fadeColors[bright ? 'near' : 'far']);
196
+ trail.addColorStop(1, color);
197
+ line(ctx2, tx, ty, x, y, trail, .7 + near * .9, alpha);
198
+ } else {
199
+ if (technical) ctx2.setLineDash([2, 2]);
200
+ line(ctx2, tx, ty, x, y, color, paper ? .65 + near * .65 : 1 + near * .8, alpha);
201
+ if (technical) ctx2.setLineDash([]);
202
+ }
122
203
  const size = (1.6 + near * 1.5) * q.perspective;
123
- ctx2.strokeStyle = "rgba(" + rgb + "," + alpha + ")";
124
- ctx2.lineWidth = 0.65 + near * 0.5;
204
+ ctx2.globalAlpha = alpha;
205
+ ctx2.strokeStyle = color;
206
+ ctx2.lineWidth = original ? .65 + near * .5 : paper ? .8 : 1.1;
125
207
  ctx2.beginPath();
126
208
  ctx2.moveTo(x - ux * size - uy * size * 0.55, y - uy * size + ux * size * 0.55);
127
209
  ctx2.lineTo(x, y);
128
210
  ctx2.lineTo(x - ux * size + uy * size * 0.55, y - uy * size - ux * size * 0.55);
129
211
  ctx2.stroke();
212
+ ctx2.globalAlpha = 1;
130
213
  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 + ")");
214
+ if (original) {
215
+ const radius = (4 + near * 4) * q.perspective, glow = ctx2.createRadialGradient(x, y, 0, x, y, radius);
216
+ glow.addColorStop(0, color);
217
+ glow.addColorStop(1, fadeColors[bright ? 'near' : 'far']);
218
+ dot(ctx2, x, y, radius, glow, alpha * .45);
219
+ }
220
+ dot(ctx2, x, y, 0.65 + near * 0.45, palette.spark, alpha);
136
221
  }
137
222
  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) + ")");
223
+ dot(ctx2, q.x, q.y, (0.55 + flash * 1.1) * q.perspective, color, 0.18 + near * 0.25 + flash * 0.35);
139
224
  } 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]);
225
+ ctx2.shadowBlur = original ? 5 : 0;
226
+ ctx2.shadowColor = particleColors[item.color];
227
+ marker(p.x, p.y, 1.1 * p.perspective, particleColors[item.color]);
143
228
  ctx2.shadowBlur = 0;
144
229
  }
145
230
  }
146
231
  for (let i = 0; i < 8; i++) {
147
232
  const e = edges[i * 19], v = edgePoint(e, 0.4), p = project(v.a + offset, v.r, v.z);
148
233
  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");
234
+ ctx2.shadowColor = palette['highlight-glow'];
235
+ ctx2.shadowBlur = original ? 10 : 0;
236
+ marker(p.x, p.y, 2.2 * p.perspective, palette.highlight);
152
237
  ctx2.shadowBlur = 0;
153
238
  }
154
239
  }
155
240
  const reduced = window.matchMedia?.('(prefers-reduced-motion: reduce)');
156
241
  const compact = window.matchMedia?.('(max-width: 760px)');
157
242
  function draw() {
158
- if (canvases.network.w && canvases.network.h) network();
243
+ const { w, h } = canvases.network;
244
+ if (!w || !h) return;
245
+ let rendered = false;
246
+ if (linework !== 'original') {
247
+ const kind = linework;
248
+ if (!artworks.has(kind) && !loading.has(kind) && !failed.has(kind) && window.WebGL2RenderingContext) {
249
+ loading.add(kind);
250
+ const load = kind === 'donut'
251
+ ? import('./orbitDonutThree').then(module => () => module.createDonutArtwork(sync))
252
+ : import('./orbitObjectsThree').then(module => () => module.createOrbitObject(kind, sync));
253
+ load.then(create => {
254
+ loading.delete(kind);
255
+ if (disposed || linework !== kind) return;
256
+ const artwork = create();
257
+ artworks.set(kind, artwork);
258
+ canvas.after(artwork.canvas);
259
+ sync();
260
+ }).catch(() => { loading.delete(kind); failed.add(kind); });
261
+ }
262
+ try { rendered = artworks.get(kind)?.draw(w, h, time) ?? false; }
263
+ catch { artworks.get(kind)?.dispose(); artworks.delete(kind); failed.add(kind); }
264
+ if (!rendered) {
265
+ if (kind === 'donut') (drawDonut ??= createOrbitDonut())(ctx!, w, h, time, palette['line-primary']);
266
+ else drawOrbitObject(ctx!, w, h, time, kind);
267
+ }
268
+ } else network();
269
+ // Keep the measured 2D canvas in layout while the optional WebGL painter covers it.
270
+ canvas.toggleAttribute('data-orbit-covered', rendered);
271
+ for (const [kind, artwork] of artworks) artwork.canvas.hidden = kind !== linework || !rendered;
159
272
  }
160
273
  function state(): OrbitState {
161
274
  if (manualReduced || reduced?.matches || canvas.closest('[data-rhine-motion="reduced"]')) return 'reduced';
162
- if (compact?.matches) return 'compact';
275
+ if (compact?.matches && linework === 'original') return 'compact';
163
276
  if (document.hidden) return 'hidden';
164
277
  if (!visible) return 'offscreen';
165
278
  if (canvases.network.w <= 0 || canvases.network.h <= 0) return 'empty';
@@ -182,6 +295,7 @@ export function mountOrbit(canvas: HTMLCanvasElement, onState: (state: OrbitStat
182
295
  cancelAnimationFrame(frame);
183
296
  frame = 0;
184
297
  last = null;
298
+ readPalette();
185
299
  resize();
186
300
  const next = state();
187
301
  if (next !== previousState) { previousState = next; onState(next); }
@@ -194,10 +308,11 @@ export function mountOrbit(canvas: HTMLCanvasElement, onState: (state: OrbitStat
194
308
  sync();
195
309
  });
196
310
  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'] });
311
+ const appearance = new MutationObserver(sync);
312
+ // CSS-only roots can switch classes or custom properties without a ThemeProvider.
313
+ // Excluding canvas sizing and React state attributes prevents redraw loops.
314
+ for (let ancestor: HTMLElement | null = canvas; ancestor; ancestor = ancestor.parentElement) {
315
+ appearance.observe(ancestor, { attributes: true, attributeFilter: ['data-rhine-motion', 'data-rhine-theme', 'data-rhine-orbit-appearance', 'class', 'style'] });
201
316
  }
202
317
  reduced?.addEventListener?.('change', sync);
203
318
  compact?.addEventListener?.('change', sync);
@@ -217,10 +332,12 @@ export function mountOrbit(canvas: HTMLCanvasElement, onState: (state: OrbitStat
217
332
  },
218
333
  dispose() {
219
334
  disposed = true;
335
+ for (const artwork of artworks.values()) artwork.dispose();
336
+ artworks.clear(); canvas.removeAttribute('data-orbit-covered');
220
337
  cancelAnimationFrame(frame);
221
338
  observer?.disconnect();
222
339
  intersection?.disconnect();
223
- motion.disconnect();
340
+ appearance.disconnect();
224
341
  reduced?.removeEventListener?.('change', sync);
225
342
  compact?.removeEventListener?.('change', sync);
226
343
  window.removeEventListener('resize', sync);
@@ -6,6 +6,7 @@
6
6
  @import './extensions.css';
7
7
  @import './background.css';
8
8
  @import './orbit.css';
9
+ @import './neutron.css';
9
10
  @import './charts.css';
10
11
 
11
12
  .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; }
@@ -22,7 +23,8 @@
22
23
  .rhine-button--secondary:hover:not(:disabled):not([aria-disabled=true]),.rhine-button--quiet:hover:not(:disabled):not([aria-disabled=true]) { background:var(--rhine-selected); }
23
24
  .rhine-button--danger { background:var(--rhine-error); border-color:var(--rhine-error); }
24
25
  .rhine-button--danger:hover:not(:disabled):not([aria-disabled=true]) { background:#81342e; border-color:#81342e; }
25
- .rhine-button:disabled { background:var(--rhine-selected); border-color:var(--rhine-line); color:var(--rhine-muted); cursor:default; }
26
+ .rhine-button:is(:disabled,[data-disabled=true],[aria-disabled=true]:not([aria-busy=true])) { background:var(--rhine-selected); border-color:var(--rhine-line); color:var(--rhine-muted); cursor:default; }
27
+ .rhine-button-link { text-decoration:none; text-align:center; }
26
28
  .rhine-button[aria-busy=true] { cursor:wait; }
27
29
  .rhine-button__ghost { visibility:hidden; }
28
30
  .rhine-button__busy { position:absolute; inset:0; display:grid; place-items:center; }
@@ -53,12 +53,13 @@
53
53
  .rhine-theme[data-rhine-theme="neo-brutalism"] :is(.rhine-button--secondary, .rhine-button--quiet):hover:not(:disabled):not([aria-disabled=true]) { background: var(--rhine-selected); }
54
54
  .rhine-theme[data-rhine-theme="neo-brutalism"] .rhine-button--danger:hover:not(:disabled):not([aria-disabled=true]) { background: #81262d; }
55
55
  .rhine-theme[data-rhine-theme="neo-brutalism"] .rhine-button:active:not(:disabled):not([aria-disabled=true]) { transform: translate(3px, 3px); box-shadow: none; }
56
- .rhine-theme[data-rhine-theme="neo-brutalism"] .rhine-button:disabled { background: #e1e5e8; color: var(--rhine-muted); border-color: #89929a; box-shadow: none; }
56
+ .rhine-theme[data-rhine-theme="neo-brutalism"] .rhine-button:is(:disabled,[data-disabled=true],[aria-disabled=true]:not([aria-busy=true])) { background: #e1e5e8; color: var(--rhine-muted); border-color: #89929a; box-shadow: none; }
57
57
  .rhine-theme[data-rhine-theme="neo-brutalism"] .rhine-button[aria-busy=true] { box-shadow: none; }
58
58
  .rhine-theme[data-rhine-theme="neo-brutalism"] :is(button, input, select, textarea, summary, a, [tabindex]):focus-visible { outline: 3px solid var(--rhine-ink); outline-offset: 4px; }
59
59
  .rhine-theme[data-rhine-theme="neo-brutalism"] .rhine-tone-success { --rhine-tone-bg: var(--rhine-tag-green); }
60
60
  .rhine-theme[data-rhine-theme="neo-brutalism"] .rhine-tone-neutral { --rhine-tone-bg: #e7ebee; }
61
- .rhine-theme[data-rhine-theme="neo-brutalism"] .rhine-badge { border: 1px solid currentColor; border-radius: 8px; padding: 3px 9px; font-weight: 600; }
61
+ .rhine-theme[data-rhine-theme="neo-brutalism"] .rhine-badge { border: 1.5px solid var(--rhine-ink); border-radius: 8px; padding: 3px 9px; font-weight: 700; }
62
+ .rhine-theme[data-rhine-theme="neo-brutalism"] .rhine-badge.rhine-tone-neutral { color: var(--rhine-ink); }
62
63
  .rhine-theme[data-rhine-theme="neo-brutalism"] .rhine-type-tag { display: inline-flex; padding: 4px 10px; border: 1.5px solid var(--rhine-ink); border-radius: 8px; color: var(--rhine-ink); font-size: 12px; line-height: 20px; font-weight: 700; }
63
64
  .rhine-theme[data-rhine-theme="neo-brutalism"] .rhine-type-tag[data-color=green] { background: var(--rhine-tag-green); }
64
65
  .rhine-theme[data-rhine-theme="neo-brutalism"] .rhine-type-tag[data-color=orange] { background: var(--rhine-tag-orange); }