@naeil/ui 0.1.0 → 0.3.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 (170) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/DESIGN.md +186 -0
  3. package/LICENSE +21 -0
  4. package/README.md +85 -22
  5. package/THIRD_PARTY_NOTICES.md +49 -0
  6. package/dist/chunk-JBTDRVZ7.js +46 -0
  7. package/dist/chunk-QKVN6Q6C.js +438 -0
  8. package/dist/components/index.d.ts +87 -0
  9. package/dist/components/index.js +832 -0
  10. package/dist/components/ui/index.d.ts +105 -0
  11. package/dist/components/ui/index.js +736 -0
  12. package/dist/lib/utils.d.ts +5 -0
  13. package/dist/lib/utils.js +7 -0
  14. package/dist/source/components/footer.d.ts +15 -0
  15. package/dist/source/components/footer.js +172 -0
  16. package/dist/source/components/index.d.ts +11 -0
  17. package/dist/source/components/index.js +26 -0
  18. package/dist/source/components/locale-switcher.d.ts +17 -0
  19. package/dist/source/components/locale-switcher.js +66 -0
  20. package/dist/source/components/logo.d.ts +9 -0
  21. package/dist/source/components/logo.js +52 -0
  22. package/dist/source/components/nav.d.ts +39 -0
  23. package/dist/source/components/nav.js +412 -0
  24. package/dist/source/components/theme-provider.d.ts +6 -0
  25. package/dist/source/components/theme-provider.js +45 -0
  26. package/dist/source/components/theme-toggle-icon.d.ts +5 -0
  27. package/dist/source/components/theme-toggle-icon.js +62 -0
  28. package/dist/source/components/theme-toggle.d.ts +5 -0
  29. package/dist/source/components/theme-toggle.js +34 -0
  30. package/dist/source/components/typography.d.ts +12 -0
  31. package/dist/source/components/typography.js +49 -0
  32. package/dist/source/components/ui/avatar.d.ts +14 -0
  33. package/dist/source/components/ui/avatar.js +137 -0
  34. package/dist/source/components/ui/badge.d.ts +13 -0
  35. package/dist/source/components/ui/badge.js +76 -0
  36. package/dist/source/components/ui/button.d.ts +14 -0
  37. package/dist/source/components/ui/button.js +88 -0
  38. package/dist/source/components/ui/card.d.ts +13 -0
  39. package/dist/source/components/ui/card.js +135 -0
  40. package/dist/source/components/ui/checkbox.d.ts +7 -0
  41. package/dist/source/components/ui/checkbox.js +58 -0
  42. package/dist/source/components/ui/dialog.d.ts +20 -0
  43. package/dist/source/components/ui/dialog.js +182 -0
  44. package/dist/source/components/ui/dropdown-menu.d.ts +28 -0
  45. package/dist/source/components/ui/dropdown-menu.js +282 -0
  46. package/dist/source/components/ui/index.d.ts +19 -0
  47. package/dist/source/components/ui/index.js +131 -0
  48. package/dist/source/components/ui/input.d.ts +6 -0
  49. package/dist/source/components/ui/input.js +49 -0
  50. package/dist/source/components/ui/select.d.ts +16 -0
  51. package/dist/source/components/ui/select.js +197 -0
  52. package/dist/source/components/ui/sonner.d.ts +6 -0
  53. package/dist/source/components/ui/sonner.js +80 -0
  54. package/dist/source/components/ui/switch.d.ts +7 -0
  55. package/dist/source/components/ui/switch.js +54 -0
  56. package/dist/source/components/ui/tabs.d.ts +10 -0
  57. package/dist/source/components/ui/tabs.js +57 -0
  58. package/dist/source/components/ui/textarea.d.ts +6 -0
  59. package/dist/source/components/ui/textarea.js +46 -0
  60. package/dist/source/i18n/config.d.ts +6 -0
  61. package/dist/source/i18n/config.js +12 -0
  62. package/dist/source/i18n/routing.d.ts +371 -0
  63. package/dist/source/i18n/routing.js +17 -0
  64. package/dist/source/lib/design/modal-inert.d.ts +6 -0
  65. package/dist/source/lib/design/modal-inert.js +73 -0
  66. package/dist/source/lib/utils.d.ts +5 -0
  67. package/dist/source/lib/utils.js +8 -0
  68. package/dist/tokens.ts +217 -0
  69. package/docs/components/README.md +21 -0
  70. package/docs/components/avatar.md +47 -0
  71. package/docs/components/badge.md +29 -0
  72. package/docs/components/button.md +39 -0
  73. package/docs/components/card.md +32 -0
  74. package/docs/components/checkbox.md +35 -0
  75. package/docs/components/common.md +31 -0
  76. package/docs/components/composition.md +68 -0
  77. package/docs/components/dialog.md +41 -0
  78. package/docs/components/dropdown-menu.md +38 -0
  79. package/docs/components/input.md +32 -0
  80. package/docs/components/select.md +39 -0
  81. package/docs/components/sonner.md +37 -0
  82. package/docs/components/switch.md +33 -0
  83. package/docs/components/tabs.md +40 -0
  84. package/docs/components/textarea.md +35 -0
  85. package/docs/design/component-demand.md +30 -0
  86. package/docs/design/public-ui-final-verification.md +96 -0
  87. package/docs/design/public-ui-release-validation.md +86 -0
  88. package/docs/design/public-ui-stage3-verification.md +98 -0
  89. package/docs/design/public-ui-stage4-verification.md +129 -0
  90. package/docs/design/public-ui-support.md +40 -0
  91. package/docs/design/v2-migration.md +199 -0
  92. package/docs/package-release.md +89 -0
  93. package/licenses/Noto-Sans-JP-OFL.txt +93 -0
  94. package/licenses/Nunito-Sans-OFL.txt +93 -0
  95. package/licenses/Pretendard-OFL.txt +94 -0
  96. package/licenses/React-MIT.txt +21 -0
  97. package/licenses/Storybook-MIT.txt +21 -0
  98. package/licenses/popper-MIT.txt +25 -0
  99. package/licenses/react-remove-scroll-bar-MIT.txt +21 -0
  100. package/licenses/store2-MIT.txt +22 -0
  101. package/licenses/toggle-selection-MIT.txt +21 -0
  102. package/package.json +215 -26
  103. package/src/styles/components.css +276 -0
  104. package/src/styles/globals.css +18 -0
  105. package/src/styles/theme.css +293 -0
  106. package/messages/en.json +0 -221
  107. package/messages/ja.json +0 -221
  108. package/messages/ko.json +0 -221
  109. package/postcss.config.mjs +0 -7
  110. package/src/app/[locale]/(content)/baby-agent/page.tsx +0 -30
  111. package/src/app/[locale]/(content)/blog/page.tsx +0 -68
  112. package/src/app/[locale]/(content)/cc/page.tsx +0 -216
  113. package/src/app/[locale]/(content)/design/page.tsx +0 -703
  114. package/src/app/[locale]/(content)/layout.tsx +0 -7
  115. package/src/app/[locale]/(content)/pkm/page.tsx +0 -31
  116. package/src/app/[locale]/(content)/projects/page.tsx +0 -100
  117. package/src/app/[locale]/(content)/sa/page.tsx +0 -179
  118. package/src/app/[locale]/(landing)/page.tsx +0 -157
  119. package/src/app/[locale]/layout.tsx +0 -72
  120. package/src/app/[locale]/login/actions.ts +0 -10
  121. package/src/app/[locale]/login/oauth-buttons.tsx +0 -78
  122. package/src/app/[locale]/login/page.tsx +0 -85
  123. package/src/app/auth/callback/route.ts +0 -78
  124. package/src/app/favicon.ico +0 -0
  125. package/src/app/globals.css +0 -32
  126. package/src/app/layout.tsx +0 -22
  127. package/src/app/sitemap.ts +0 -26
  128. package/src/components/accent-picker.tsx +0 -62
  129. package/src/components/auth-slot.tsx +0 -96
  130. package/src/components/footer-wrapper.tsx +0 -8
  131. package/src/components/footer.tsx +0 -193
  132. package/src/components/hero-scene.tsx +0 -948
  133. package/src/components/hero-section.tsx +0 -57
  134. package/src/components/locale-switcher.tsx +0 -74
  135. package/src/components/logo.tsx +0 -51
  136. package/src/components/nav-server-wrapper.tsx +0 -22
  137. package/src/components/nav-wrapper.tsx +0 -45
  138. package/src/components/nav.tsx +0 -550
  139. package/src/components/paraglider-cursor.tsx +0 -112
  140. package/src/components/project-layout.tsx +0 -194
  141. package/src/components/theme-provider.tsx +0 -10
  142. package/src/components/theme-toggle-icon.tsx +0 -62
  143. package/src/components/theme-toggle.tsx +0 -39
  144. package/src/components/typography.tsx +0 -27
  145. package/src/components/ui/avatar.tsx +0 -109
  146. package/src/components/ui/badge.tsx +0 -47
  147. package/src/components/ui/button.tsx +0 -65
  148. package/src/components/ui/card.tsx +0 -106
  149. package/src/components/ui/dialog.tsx +0 -158
  150. package/src/components/ui/dropdown-menu.tsx +0 -257
  151. package/src/components/ui/input.tsx +0 -21
  152. package/src/components/ui/sonner.tsx +0 -54
  153. package/src/components/workflow-diagram.tsx +0 -414
  154. package/src/i18n/config.ts +0 -8
  155. package/src/i18n/request.ts +0 -16
  156. package/src/i18n/routing.ts +0 -12
  157. package/src/lib/supabase/client.ts +0 -18
  158. package/src/lib/supabase/middleware.ts +0 -42
  159. package/src/lib/supabase/server.ts +0 -37
  160. package/src/lib/utils.ts +0 -6
  161. package/src/proxy.ts +0 -62
  162. package/src/tokens/.gitkeep +0 -0
  163. package/src/tokens/color.json +0 -56
  164. package/src/tokens/radius.json +0 -9
  165. package/src/tokens/semantic.json +0 -81
  166. package/src/tokens/shadow.json +0 -7
  167. package/src/tokens/spacing.json +0 -16
  168. package/src/tokens/typography.json +0 -34
  169. package/src/types/i18n.d.ts +0 -6
  170. package/src/utils/.gitkeep +0 -0
@@ -1,948 +0,0 @@
1
- "use client";
2
-
3
- import { useState, useRef, useMemo, useCallback, useEffect, useSyncExternalStore } from "react";
4
- import { useTheme } from "next-themes";
5
- import { Canvas, useFrame, useLoader, useThree } from "@react-three/fiber";
6
- import type { Mesh } from "three";
7
- import * as THREE from "three";
8
-
9
- // ─── Gradient Glow Sun (夕焼け) ───
10
-
11
- const GRADIENT_VERTEX = /* glsl */ `
12
- varying vec2 vUv;
13
- varying float vWorldY;
14
- void main() {
15
- vUv = uv;
16
- vec4 worldPos = modelMatrix * vec4(position, 1.0);
17
- vWorldY = worldPos.y;
18
- gl_Position = projectionMatrix * viewMatrix * worldPos;
19
- }
20
- `;
21
-
22
- const GRADIENT_FRAGMENT = /* glsl */ `
23
- uniform vec3 c1, c2, c3, c4, c5;
24
- uniform float uTime;
25
- uniform float uPeak;
26
- uniform float uClipY;
27
- varying vec2 vUv;
28
- varying float vWorldY;
29
- void main() {
30
- float dist = length(vUv - 0.5) * 2.0;
31
- float pulse = 1.0 + sin(uTime * 0.4) * 0.06;
32
- float d = dist * pulse;
33
- vec3 color = mix(c1, c2, smoothstep(0.0, 0.25, d));
34
- color = mix(color, c3, smoothstep(0.15, 0.45, d));
35
- color = mix(color, c4, smoothstep(0.35, 0.70, d));
36
- color = mix(color, c5, smoothstep(0.55, 0.95, d));
37
- float alpha = pow(max(1.0 - d * d, 0.0), 2.0) * uPeak;
38
- alpha *= smoothstep(uClipY - 0.1, uClipY + 0.05, vWorldY);
39
- gl_FragColor = vec4(color, alpha);
40
- }
41
- `;
42
-
43
- // 夕焼け palette: red → orange → yellow → teal → cyan
44
- const SUNSET_STOPS: [number, number, number, number, number] = [0xdc2626, 0xea580c, 0xeab308, 0x0d9488, 0x0e7490];
45
-
46
- // 朝焼け (sunrise): rose → coral → gold → sky → cyan
47
- const SUNRISE_STOPS: [number, number, number, number, number] = [0xfda4af, 0xfb923c, 0xfde047, 0x93c5fd, 0x67e8f9];
48
-
49
- const SUNRISE_LAYERS = [
50
- { r: 2.5, peak: 0.50, y: -0.25, z: -0.2 },
51
- { r: 0.5, peak: 0.55, y: -0.38, z: -0.05 },
52
- ];
53
-
54
- const emptySubscribe = () => () => {};
55
- function useIsMounted() {
56
- return useSyncExternalStore(emptySubscribe, () => true, () => false);
57
- }
58
-
59
- type Tier = "low" | "medium" | "high";
60
-
61
- type SceneProfile = {
62
- animate: boolean;
63
- targetFps: number;
64
- tier: Tier;
65
- dpr: [number, number];
66
- antialias: boolean;
67
- enableClouds: boolean;
68
- enableSeaLife: boolean;
69
- seaLifeLite: boolean;
70
- sunSegments: number;
71
- };
72
-
73
- function useSceneProfile(): SceneProfile {
74
- const [profile, setProfile] = useState<SceneProfile>({
75
- animate: true,
76
- targetFps: 45,
77
- tier: "high",
78
- dpr: [1, 2],
79
- antialias: true,
80
- enableClouds: true,
81
- enableSeaLife: true,
82
- seaLifeLite: false,
83
- sunSegments: 64,
84
- });
85
-
86
- useEffect(() => {
87
- const reduceMotionMedia = window.matchMedia("(prefers-reduced-motion: reduce)");
88
- const mobileMedia = window.matchMedia("(max-width: 767px)");
89
-
90
- const update = () => {
91
- const reduceMotion = reduceMotionMedia.matches;
92
- const isMobile = mobileMedia.matches;
93
- const cores = navigator.hardwareConcurrency ?? 8;
94
- const memory = (navigator as Navigator & { deviceMemory?: number }).deviceMemory ?? 8;
95
- const lowDevice = isMobile || cores <= 4 || memory <= 4;
96
- const midDevice = !lowDevice && (cores <= 6 || memory <= 6);
97
-
98
- const tier: Tier = lowDevice ? "low" : midDevice ? "medium" : "high";
99
-
100
- setProfile({
101
- animate: !reduceMotion && document.visibilityState === "visible",
102
- targetFps: tier === "low" ? 24 : tier === "medium" ? 35 : 45,
103
- tier,
104
- dpr: tier === "low" ? [1, 1.25] : tier === "medium" ? [1, 1.5] : [1, 2],
105
- antialias: tier !== "low",
106
- enableClouds: tier !== "low",
107
- enableSeaLife: true,
108
- seaLifeLite: tier === "low",
109
- sunSegments: tier === "low" ? 24 : tier === "medium" ? 40 : 64,
110
- });
111
- };
112
-
113
- update();
114
- window.addEventListener("resize", update, { passive: true });
115
- document.addEventListener("visibilitychange", update);
116
- reduceMotionMedia.addEventListener?.("change", update);
117
- mobileMedia.addEventListener?.("change", update);
118
-
119
- return () => {
120
- window.removeEventListener("resize", update);
121
- document.removeEventListener("visibilitychange", update);
122
- reduceMotionMedia.removeEventListener?.("change", update);
123
- mobileMedia.removeEventListener?.("change", update);
124
- };
125
- }, []);
126
-
127
- return profile;
128
- }
129
-
130
- const GRADIENT_LAYERS = [
131
- { r: 2.5, peak: 0.60, y: -0.25, z: -0.2 },
132
- { r: 0.5, peak: 0.50, y: -0.38, z: -0.05 },
133
- ];
134
-
135
- function GradientGlowSun({ isDark, segments = 64 }: { isDark: boolean; segments?: number }) {
136
- const refs = useRef<(THREE.ShaderMaterial | null)[]>([]);
137
- const stops = isDark ? SUNSET_STOPS : SUNRISE_STOPS;
138
- const layers = isDark ? GRADIENT_LAYERS : SUNRISE_LAYERS;
139
- const [s0, s1, s2, s3, s4] = stops;
140
-
141
- const uniforms = useMemo(
142
- () =>
143
- layers.map((cfg) => ({
144
- c1: { value: new THREE.Color(s0) },
145
- c2: { value: new THREE.Color(s1) },
146
- c3: { value: new THREE.Color(s2) },
147
- c4: { value: new THREE.Color(s3) },
148
- c5: { value: new THREE.Color(s4) },
149
- uTime: { value: 0 },
150
- uPeak: { value: cfg.peak },
151
- uClipY: { value: -0.5 },
152
- })),
153
- [s0, s1, s2, s3, s4, isDark],
154
- );
155
-
156
- useFrame(({ clock }) => {
157
- const t = clock.getElapsedTime();
158
- refs.current.forEach((mat) => {
159
- if (mat) mat.uniforms.uTime.value = t;
160
- });
161
- });
162
-
163
- return (
164
- <group position={[0, 0, -1.2]}>
165
- {layers.map((cfg, i) => (
166
- <mesh key={i} position={[0, cfg.y, cfg.z]}>
167
- <circleGeometry args={[cfg.r, segments]} />
168
- <shaderMaterial
169
- ref={(el) => {
170
- refs.current[i] = el;
171
- }}
172
- vertexShader={GRADIENT_VERTEX}
173
- fragmentShader={GRADIENT_FRAGMENT}
174
- uniforms={uniforms[i]}
175
- transparent
176
- side={THREE.DoubleSide}
177
- depthWrite={false}
178
- blending={isDark ? THREE.AdditiveBlending : THREE.NormalBlending}
179
- />
180
- </mesh>
181
- ))}
182
- </group>
183
- );
184
- }
185
-
186
- // ─── Clouds (Scatter) ───
187
-
188
- const CLOUD_VERTEX = /* glsl */ `
189
- varying vec2 vUv;
190
- void main() {
191
- vUv = uv;
192
- gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
193
- }
194
- `;
195
-
196
- const CLOUD_FRAGMENT = /* glsl */ `
197
- uniform float uTime;
198
- uniform vec3 uCloudColor;
199
- uniform float uMaxAlpha;
200
- varying vec2 vUv;
201
-
202
- float hash(vec2 p) {
203
- return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453);
204
- }
205
- float noise(vec2 p) {
206
- vec2 i = floor(p);
207
- vec2 f = fract(p);
208
- f = f * f * (3.0 - 2.0 * f);
209
- float a = hash(i);
210
- float b = hash(i + vec2(1.0, 0.0));
211
- float c = hash(i + vec2(0.0, 1.0));
212
- float d = hash(i + vec2(1.0, 1.0));
213
- return mix(mix(a, b, f.x), mix(c, d, f.x), f.y);
214
- }
215
- float fbm(vec2 p) {
216
- float v = 0.0;
217
- float a = 0.5;
218
- for (int i = 0; i < 4; i++) {
219
- v += a * noise(p);
220
- p *= 2.0;
221
- a *= 0.5;
222
- }
223
- return v;
224
- }
225
- void main() {
226
- vec2 uv = vUv;
227
- uv.x += uTime * 0.015;
228
- float n = fbm(uv * 12.0);
229
- float cloud = smoothstep(0.35, 0.7, n);
230
- float vFade = smoothstep(0.0, 0.3, vUv.y) * smoothstep(1.0, 0.7, vUv.y);
231
- float hFade = smoothstep(0.0, 0.15, vUv.x) * smoothstep(1.0, 0.85, vUv.x);
232
- float alpha = cloud * vFade * hFade * uMaxAlpha;
233
- vec3 color = uCloudColor;
234
- gl_FragColor = vec4(color, alpha);
235
- }
236
- `;
237
-
238
- function Clouds({ isDark }: { isDark: boolean }) {
239
- const ref = useRef<THREE.ShaderMaterial>(null);
240
- const uniforms = useMemo(() => ({
241
- uTime: { value: 0 },
242
- uCloudColor: { value: isDark ? new THREE.Vector3(0.75, 0.55, 0.35) : new THREE.Vector3(0.75, 0.60, 0.65) },
243
- uMaxAlpha: { value: isDark ? 0.08 : 0.16 },
244
- }), [isDark]);
245
-
246
- useFrame(({ clock }) => {
247
- if (ref.current) ref.current.uniforms.uTime.value = clock.getElapsedTime();
248
- });
249
-
250
- return (
251
- <mesh position={[0, 0.5, -1.0]}>
252
- <planeGeometry args={[8, 2]} />
253
- <shaderMaterial
254
- ref={ref}
255
- vertexShader={CLOUD_VERTEX}
256
- fragmentShader={CLOUD_FRAGMENT}
257
- uniforms={uniforms}
258
- transparent
259
- depthWrite={false}
260
- side={THREE.DoubleSide}
261
- />
262
- </mesh>
263
- );
264
- }
265
-
266
- // ─── Water Surface ───
267
-
268
- const WATER_VERTEX = /* glsl */ `
269
- varying vec2 vUv;
270
- void main() {
271
- vUv = uv;
272
- gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
273
- }
274
- `;
275
-
276
- const WATER_FRAGMENT = /* glsl */ `
277
- uniform float uTime;
278
- uniform vec3 uWarm;
279
- uniform vec3 uHot;
280
- uniform vec3 uCold;
281
- varying vec2 vUv;
282
- void main() {
283
- float y = vUv.y;
284
- float x = vUv.x;
285
- float ripple1 = sin((y * 60.0) + uTime * 0.8 + x * 3.0) * 0.5 + 0.5;
286
- float ripple2 = sin((y * 30.0) - uTime * 0.5 + 1.5) * 0.5 + 0.5;
287
- float ripple3 = sin((y * 100.0) + uTime * 1.2 - x * 5.0) * 0.5 + 0.5;
288
- float ripple = ripple1 * 0.4 + ripple2 * 0.35 + ripple3 * 0.25;
289
- float shimmer = sin(x * 8.0 + uTime * 0.3) * sin(y * 12.0 - uTime * 0.4);
290
- shimmer = max(shimmer, 0.0) * 0.4;
291
- float fadeTop = pow(1.0 - y, 1.5);
292
- float fadeBottom = smoothstep(0.0, 0.35, y);
293
- float fade = fadeTop * fadeBottom;
294
- vec3 warm = uWarm;
295
- vec3 hot = uHot;
296
- vec3 cold = uCold;
297
- vec3 base = mix(warm, cold, y);
298
- vec3 color = mix(base, hot, shimmer * (1.0 - y));
299
- float alpha = (ripple + shimmer * 0.3) * fade * 1.0;
300
- gl_FragColor = vec4(color, alpha);
301
- }
302
- `;
303
-
304
- function WaterSurface({ isDark }: { isDark: boolean }) {
305
- const ref = useRef<THREE.ShaderMaterial>(null);
306
- const uniforms = useMemo(() => ({
307
- uTime: { value: 0 },
308
- uWarm: { value: isDark ? new THREE.Vector3(0.40, 0.14, 0.04) : new THREE.Vector3(0.85, 0.55, 0.35) },
309
- uHot: { value: isDark ? new THREE.Vector3(0.55, 0.20, 0.06) : new THREE.Vector3(0.95, 0.60, 0.30) },
310
- uCold: { value: isDark ? new THREE.Vector3(0.03, 0.04, 0.06) : new THREE.Vector3(0.45, 0.60, 0.80) },
311
- }), [isDark]);
312
-
313
- useFrame(({ clock }) => {
314
- if (ref.current) ref.current.uniforms.uTime.value = clock.getElapsedTime();
315
- });
316
-
317
- return (
318
- <mesh position={[0, -0.55, -0.5]} rotation={[-Math.PI / 2, 0, 0]}>
319
- <planeGeometry args={[14, 4]} />
320
- <shaderMaterial
321
- ref={ref}
322
- vertexShader={WATER_VERTEX}
323
- fragmentShader={WATER_FRAGMENT}
324
- uniforms={uniforms}
325
- transparent
326
- depthWrite={false}
327
- side={THREE.DoubleSide}
328
- />
329
- </mesh>
330
- );
331
- }
332
-
333
- // ─── Sea Creatures ───
334
-
335
- interface Swimmer {
336
- label: string;
337
- color: string;
338
- speed: number;
339
- y: number;
340
- z: number;
341
- scale: number;
342
- shape: THREE.Shape;
343
- }
344
-
345
- function makeFish(): THREE.Shape {
346
- const s = new THREE.Shape();
347
- // Tail fork
348
- s.moveTo(-0.10, 0.07);
349
- s.lineTo(-0.07, 0.05);
350
- s.lineTo(-0.10, 0.03);
351
- s.lineTo(-0.07, 0.035);
352
- // Body bottom
353
- s.lineTo(-0.04, 0.02);
354
- s.lineTo(0, 0.01);
355
- s.lineTo(0.04, 0.01);
356
- s.lineTo(0.08, 0.015);
357
- // Mouth
358
- s.lineTo(0.12, 0.03);
359
- s.lineTo(0.13, 0.04);
360
- s.lineTo(0.14, 0.05);
361
- // Head top
362
- s.lineTo(0.12, 0.065);
363
- s.lineTo(0.10, 0.075);
364
- // Eye indent
365
- s.lineTo(0.09, 0.07);
366
- s.lineTo(0.08, 0.075);
367
- // Dorsal line
368
- s.lineTo(0.05, 0.085);
369
- s.lineTo(0.02, 0.09);
370
- // Dorsal fin
371
- s.lineTo(-0.01, 0.11);
372
- s.lineTo(-0.02, 0.10);
373
- s.lineTo(-0.03, 0.085);
374
- // Back to tail
375
- s.lineTo(-0.05, 0.075);
376
- s.lineTo(-0.07, 0.065);
377
- s.closePath();
378
- // Pectoral fin
379
- s.moveTo(0.04, 0.03);
380
- s.lineTo(0.02, -0.01);
381
- s.lineTo(0.06, 0.02);
382
- return s;
383
- }
384
-
385
- function makeJellyfish(): THREE.Shape {
386
- const s = new THREE.Shape();
387
- // Bell dome (angular)
388
- s.moveTo(-0.07, 0.01);
389
- s.lineTo(-0.08, 0.03);
390
- s.lineTo(-0.075, 0.06);
391
- s.lineTo(-0.06, 0.08);
392
- s.lineTo(-0.04, 0.10);
393
- s.lineTo(-0.02, 0.11);
394
- s.lineTo(0, 0.115);
395
- s.lineTo(0.02, 0.11);
396
- s.lineTo(0.04, 0.10);
397
- s.lineTo(0.06, 0.08);
398
- s.lineTo(0.075, 0.06);
399
- s.lineTo(0.08, 0.03);
400
- s.lineTo(0.07, 0.01);
401
- // Bell rim
402
- s.lineTo(0.06, 0.005);
403
- s.lineTo(0.04, 0.01);
404
- s.lineTo(0.02, 0.005);
405
- s.lineTo(0, 0.01);
406
- s.lineTo(-0.02, 0.005);
407
- s.lineTo(-0.04, 0.01);
408
- s.lineTo(-0.06, 0.005);
409
- s.closePath();
410
- // Tentacles (separate paths)
411
- s.moveTo(-0.05, 0.005);
412
- s.lineTo(-0.055, -0.04);
413
- s.lineTo(-0.045, -0.02);
414
- s.lineTo(-0.04, -0.06);
415
- s.lineTo(-0.035, -0.03);
416
- s.moveTo(-0.01, 0.005);
417
- s.lineTo(-0.015, -0.05);
418
- s.lineTo(-0.005, -0.03);
419
- s.lineTo(0, -0.07);
420
- s.lineTo(0.005, -0.03);
421
- s.lineTo(0.015, -0.05);
422
- s.moveTo(0.04, 0.005);
423
- s.lineTo(0.035, -0.03);
424
- s.lineTo(0.04, -0.06);
425
- s.lineTo(0.045, -0.02);
426
- s.lineTo(0.055, -0.04);
427
- return s;
428
- }
429
-
430
- function makeTurtle(): THREE.Shape {
431
- const s = new THREE.Shape();
432
- // Head
433
- s.moveTo(0.14, 0.04);
434
- s.lineTo(0.16, 0.045);
435
- s.lineTo(0.17, 0.04);
436
- s.lineTo(0.16, 0.035);
437
- s.lineTo(0.14, 0.035);
438
- // Neck
439
- s.lineTo(0.11, 0.03);
440
- // Front flipper top
441
- s.lineTo(0.09, 0.015);
442
- s.lineTo(0.07, -0.01);
443
- s.lineTo(0.10, 0.005);
444
- // Shell bottom front
445
- s.lineTo(0.08, 0.02);
446
- s.lineTo(0.04, 0.015);
447
- s.lineTo(0, 0.015);
448
- // Rear flipper
449
- s.lineTo(-0.04, 0.01);
450
- s.lineTo(-0.07, -0.005);
451
- s.lineTo(-0.05, 0.015);
452
- // Tail
453
- s.lineTo(-0.08, 0.025);
454
- s.lineTo(-0.10, 0.03);
455
- s.lineTo(-0.08, 0.035);
456
- // Shell top
457
- s.lineTo(-0.06, 0.04);
458
- s.lineTo(-0.04, 0.055);
459
- s.lineTo(-0.02, 0.065);
460
- s.lineTo(0, 0.07);
461
- s.lineTo(0.02, 0.07);
462
- s.lineTo(0.04, 0.065);
463
- s.lineTo(0.06, 0.06);
464
- s.lineTo(0.08, 0.055);
465
- // Shell pattern lines
466
- s.lineTo(0.10, 0.048);
467
- // Neck top
468
- s.lineTo(0.12, 0.045);
469
- s.closePath();
470
- return s;
471
- }
472
-
473
- function makeWhale(): THREE.Shape {
474
- const s = new THREE.Shape();
475
- // Tail flukes
476
- s.moveTo(-0.22, 0.06);
477
- s.lineTo(-0.18, 0.04);
478
- s.lineTo(-0.22, 0.02);
479
- s.lineTo(-0.19, 0.035);
480
- // Tail narrow
481
- s.lineTo(-0.16, 0.03);
482
- s.lineTo(-0.13, 0.025);
483
- // Belly
484
- s.lineTo(-0.08, 0.015);
485
- s.lineTo(-0.03, 0.01);
486
- s.lineTo(0.02, 0.01);
487
- s.lineTo(0.07, 0.015);
488
- // Jaw line
489
- s.lineTo(0.12, 0.025);
490
- s.lineTo(0.16, 0.035);
491
- // Mouth
492
- s.lineTo(0.18, 0.04);
493
- s.lineTo(0.17, 0.045);
494
- // Upper jaw
495
- s.lineTo(0.15, 0.05);
496
- s.lineTo(0.12, 0.055);
497
- // Eye area
498
- s.lineTo(0.10, 0.06);
499
- s.lineTo(0.09, 0.058);
500
- s.lineTo(0.08, 0.06);
501
- // Forehead
502
- s.lineTo(0.06, 0.07);
503
- s.lineTo(0.03, 0.08);
504
- // Back
505
- s.lineTo(0, 0.085);
506
- s.lineTo(-0.04, 0.08);
507
- s.lineTo(-0.08, 0.075);
508
- // Dorsal fin
509
- s.lineTo(-0.10, 0.075);
510
- s.lineTo(-0.11, 0.095);
511
- s.lineTo(-0.13, 0.075);
512
- // Tail section
513
- s.lineTo(-0.15, 0.065);
514
- s.lineTo(-0.17, 0.055);
515
- s.lineTo(-0.19, 0.045);
516
- s.closePath();
517
- // Pectoral fin
518
- s.moveTo(0.04, 0.02);
519
- s.lineTo(0.02, -0.01);
520
- s.lineTo(0.06, 0.01);
521
- return s;
522
- }
523
-
524
- // Diver uses PNG sprite instead of Shape path
525
-
526
- const SWIMMERS: Swimmer[] = [];
527
-
528
- function SwimmingCreature({ swimmer }: { swimmer: Swimmer }) {
529
- const ref = useRef<Mesh>(null);
530
- const geo = useMemo(() => new THREE.ShapeGeometry(swimmer.shape), [swimmer.shape]);
531
-
532
- useFrame(({ clock }) => {
533
- if (!ref.current) return;
534
- const t = clock.getElapsedTime();
535
- // Swim left to right, loop across screen
536
- const x = ((t * swimmer.speed + swimmer.z * 2) % 5) - 2.5;
537
- // Gentle bob up and down
538
- const bob = Math.sin(t * 1.5 + swimmer.z * 10) * 0.008;
539
- ref.current.position.x = x;
540
- ref.current.position.y = swimmer.y + bob;
541
- });
542
-
543
- return (
544
- <mesh ref={ref} position={[0, swimmer.y, swimmer.z]} scale={swimmer.scale}>
545
- <primitive object={geo} attach="geometry" />
546
- <meshBasicMaterial color={swimmer.color} transparent opacity={0.35} side={THREE.DoubleSide} />
547
- </mesh>
548
- );
549
- }
550
-
551
- // Free-swimming sprite: Lissajous curves for organic 2D movement
552
- function useSwim(cfg: { cx: number; cy: number; rx: number; ry: number; freqX: number; freqY: number; phaseX: number; phaseY: number }) {
553
- const ref = useRef<Mesh>(null);
554
- useFrame(({ clock }) => {
555
- if (!ref.current) return;
556
- const t = clock.getElapsedTime();
557
- const x = cfg.cx + Math.sin(t * cfg.freqX + cfg.phaseX) * cfg.rx;
558
- const y = cfg.cy + Math.sin(t * cfg.freqY + cfg.phaseY) * cfg.ry;
559
- // Face movement direction
560
- const dx = Math.cos(t * cfg.freqX + cfg.phaseX) * cfg.freqX * cfg.rx;
561
- const dy = Math.cos(t * cfg.freqY + cfg.phaseY) * cfg.freqY * cfg.ry;
562
- ref.current.position.x = x;
563
- ref.current.position.y = y;
564
- ref.current.rotation.z = Math.atan2(dy, dx) * 0.3;
565
- });
566
- return ref;
567
- }
568
-
569
- function JellyfishSprite({ isDark }: { isDark: boolean }) {
570
- const texture = useLoader(THREE.TextureLoader, "/images/jellyfish.png");
571
- const ref = useSwim({ cx: -0.8, cy: -0.62, rx: 0.6, ry: 0.12, freqX: 0.12, freqY: 0.25, phaseX: 0, phaseY: 1.5 });
572
- return (
573
- <mesh ref={ref} position={[0, -0.63, -0.4]} scale={0.16}>
574
- <planeGeometry args={[0.85, 1]} />
575
- <meshBasicMaterial map={texture} transparent opacity={isDark ? 0.35 : 0.65} side={THREE.DoubleSide} />
576
- </mesh>
577
- );
578
- }
579
-
580
- function FishSprite({ isDark }: { isDark: boolean }) {
581
- const texture = useLoader(THREE.TextureLoader, "/images/fish.png");
582
- const ref = useSwim({ cx: 0.3, cy: -0.60, rx: 1.2, ry: 0.10, freqX: 0.3, freqY: 0.7, phaseX: 2.0, phaseY: 0 });
583
- return (
584
- <mesh ref={ref} position={[0, -0.58, -0.3]} scale={0.22}>
585
- <planeGeometry args={[1, 0.65]} />
586
- <meshBasicMaterial map={texture} transparent opacity={isDark ? 0.4 : 0.7} side={THREE.DoubleSide} />
587
- </mesh>
588
- );
589
- }
590
-
591
- function WhaleSprite({ isDark }: { isDark: boolean }) {
592
- const texture = useLoader(THREE.TextureLoader, "/images/whale.png");
593
- const ref = useSwim({ cx: 0, cy: -0.65, rx: 1.0, ry: 0.15, freqX: 0.06, freqY: 0.1, phaseX: 3.0, phaseY: 0.5 });
594
- return (
595
- <mesh ref={ref} position={[0, -0.65, -0.5]} scale={0.5}>
596
- <planeGeometry args={[1, 0.7]} />
597
- <meshBasicMaterial map={texture} transparent opacity={isDark ? 0.35 : 0.65} side={THREE.DoubleSide} />
598
- </mesh>
599
- );
600
- }
601
-
602
- function TurtleSprite({ isDark }: { isDark: boolean }) {
603
- const texture = useLoader(THREE.TextureLoader, "/images/turtle.png");
604
- const ref = useSwim({ cx: 0.5, cy: -0.60, rx: 0.8, ry: 0.12, freqX: 0.15, freqY: 0.35, phaseX: 1.0, phaseY: 3.0 });
605
- return (
606
- <mesh ref={ref} position={[0, -0.56, -0.2]} scale={0.20}>
607
- <planeGeometry args={[1, 1]} />
608
- <meshBasicMaterial map={texture} transparent opacity={isDark ? 0.4 : 0.7} side={THREE.DoubleSide} />
609
- </mesh>
610
- );
611
- }
612
-
613
- function DiverSprite({ isDark }: { isDark: boolean }) {
614
- const texture = useLoader(THREE.TextureLoader, "/images/diver.png");
615
- const ref = useSwim({ cx: -0.3, cy: -0.60, rx: 1.0, ry: 0.13, freqX: 0.18, freqY: 0.22, phaseX: 4.0, phaseY: 1.0 });
616
- return (
617
- <mesh ref={ref} position={[0, -0.60, -0.15]} scale={0.22}>
618
- <planeGeometry args={[1, 1]} />
619
- <meshBasicMaterial map={texture} transparent opacity={isDark ? 0.35 : 0.65} side={THREE.DoubleSide} />
620
- </mesh>
621
- );
622
- }
623
-
624
- function CoralSprite({ isDark }: { isDark: boolean }) {
625
- const texture = useLoader(THREE.TextureLoader, "/images/coral.png");
626
- const ref = useRef<Mesh>(null);
627
- // Gentle sway — stationary coral, subtle rotation
628
- useFrame(({ clock }) => {
629
- if (!ref.current) return;
630
- const t = clock.getElapsedTime();
631
- ref.current.rotation.z = Math.sin(t * 0.3) * 0.02;
632
- });
633
- return (
634
- <mesh ref={ref} position={[0.9, -0.78, -0.6]} scale={0.22}>
635
- <planeGeometry args={[1, 1]} />
636
- <meshBasicMaterial map={texture} transparent opacity={isDark ? 0.3 : 0.55} side={THREE.DoubleSide} />
637
- </mesh>
638
- );
639
- }
640
-
641
- function SeaLife({ isDark }: { isDark: boolean }) {
642
- return (
643
- <group>
644
- {SWIMMERS.map((sw) => (
645
- <SwimmingCreature key={sw.label} swimmer={sw} />
646
- ))}
647
- <FishSprite isDark={isDark} />
648
- <DiverSprite isDark={isDark} />
649
- <JellyfishSprite isDark={isDark} />
650
- <WhaleSprite isDark={isDark} />
651
- <TurtleSprite isDark={isDark} />
652
- <CoralSprite isDark={isDark} />
653
- </group>
654
- );
655
- }
656
-
657
- // ─── BaseLine ───
658
-
659
- function BaseLine() {
660
- return (
661
- <mesh position={[0, -0.51, -0.98]}>
662
- <boxGeometry args={[16, 0.012, 0.01]} />
663
- <meshBasicMaterial color="#050505" />
664
- </mesh>
665
- );
666
- }
667
-
668
- // ─── Mountain Silhouettes ───
669
-
670
- function MountainSilhouette() {
671
- const islandsGeo = useMemo(() => {
672
- const shape = new THREE.Shape();
673
- shape.moveTo(-2.5, 0);
674
- // Small rock (far left)
675
- shape.lineTo(-1.8, 0);
676
- shape.lineTo(-1.7, 0.08);
677
- shape.lineTo(-1.6, 0.15);
678
- shape.lineTo(-1.5, 0.08);
679
- shape.lineTo(-1.4, 0);
680
- // Gap
681
- shape.lineTo(-1.2, 0);
682
- // Island with 3 peaks — tallest left, descending right
683
- shape.lineTo(-1.1, 0.05);
684
- shape.lineTo(-1.05, 0.20);
685
- shape.lineTo(-1.0, 0.40);
686
- shape.lineTo(-0.95, 0.45);
687
- shape.lineTo(-0.88, 0.40);
688
- shape.lineTo(-0.80, 0.28);
689
- shape.lineTo(-0.70, 0.30);
690
- shape.lineTo(-0.62, 0.22);
691
- shape.lineTo(-0.52, 0.24);
692
- shape.lineTo(-0.42, 0.15);
693
- shape.lineTo(-0.30, 0.05);
694
- shape.lineTo(-0.2, 0);
695
- // Center gap — sun space
696
- shape.lineTo(0.2, 0);
697
- // Large rounded mountain (right)
698
- shape.lineTo(0.3, 0.05);
699
- shape.lineTo(0.5, 0.18);
700
- shape.lineTo(0.7, 0.35);
701
- shape.lineTo(0.9, 0.48);
702
- shape.lineTo(1.1, 0.52);
703
- shape.lineTo(1.3, 0.48);
704
- shape.lineTo(1.5, 0.38);
705
- shape.lineTo(1.7, 0.25);
706
- shape.lineTo(1.9, 0.12);
707
- shape.lineTo(2.1, 0.05);
708
- shape.lineTo(2.5, 0);
709
- // Close bottom
710
- shape.lineTo(2.5, -0.02);
711
- shape.lineTo(-2.5, -0.02);
712
- shape.closePath();
713
- return new THREE.ShapeGeometry(shape);
714
- }, []);
715
-
716
- return (
717
- <group position={[0, -0.5, -1]}>
718
- <mesh geometry={islandsGeo}>
719
- <meshBasicMaterial color="#050505" side={THREE.DoubleSide} />
720
- </mesh>
721
- <mesh geometry={islandsGeo} position={[0, 0.003, 0.01]}>
722
- <meshBasicMaterial color="#0a0a0a" side={THREE.DoubleSide} />
723
- </mesh>
724
- {/* Pavilion on right mountain */}
725
- <group position={[0.95, 0.48, 0.02]} scale={0.8}>
726
- <mesh position={[-0.05, 0.025, 0]}>
727
- <boxGeometry args={[0.006, 0.05, 0.01]} />
728
- <meshBasicMaterial color="#050505" />
729
- </mesh>
730
- <mesh position={[0.05, 0.025, 0]}>
731
- <boxGeometry args={[0.006, 0.05, 0.01]} />
732
- <meshBasicMaterial color="#050505" />
733
- </mesh>
734
- <mesh position={[0, 0.053, 0]}>
735
- <boxGeometry args={[0.14, 0.006, 0.01]} />
736
- <meshBasicMaterial color="#050505" />
737
- </mesh>
738
- <mesh position={[0, 0.058, 0]}>
739
- <boxGeometry args={[0.16, 0.004, 0.01]} />
740
- <meshBasicMaterial color="#050505" />
741
- </mesh>
742
- </group>
743
- </group>
744
- );
745
- }
746
-
747
- // ─── Main Scene ───
748
-
749
- function FrameDriver({ active, targetFps }: { active: boolean; targetFps: number }) {
750
- const { invalidate } = useThree();
751
-
752
- useEffect(() => {
753
- invalidate();
754
- if (!active) return;
755
-
756
- let raf = 0;
757
- let last = 0;
758
- const interval = 1000 / Math.max(1, targetFps);
759
-
760
- const tick = (now: number) => {
761
- if (now - last >= interval) {
762
- last = now;
763
- invalidate();
764
- }
765
- raf = requestAnimationFrame(tick);
766
- };
767
-
768
- raf = requestAnimationFrame(tick);
769
- return () => cancelAnimationFrame(raf);
770
- }, [active, targetFps, invalidate]);
771
-
772
- return null;
773
- }
774
-
775
- /** Handles WebGL context lost/restored — forces re-render on recovery */
776
- function ContextGuard() {
777
- const { gl, invalidate } = useThree();
778
-
779
- useEffect(() => {
780
- const canvas = gl.domElement;
781
- const onLost = (e: Event) => {
782
- e.preventDefault();
783
- console.warn("[HeroScene] WebGL context lost — pausing");
784
- };
785
- const onRestored = () => {
786
- console.info("[HeroScene] WebGL context restored — resuming");
787
- invalidate();
788
- };
789
- canvas.addEventListener("webglcontextlost", onLost);
790
- canvas.addEventListener("webglcontextrestored", onRestored);
791
- return () => {
792
- canvas.removeEventListener("webglcontextlost", onLost);
793
- canvas.removeEventListener("webglcontextrestored", onRestored);
794
- };
795
- }, [gl, invalidate]);
796
-
797
- return null;
798
- }
799
-
800
- /** Disposes scene geometry/materials on unmount (renderer lifecycle managed by R3F) */
801
- function ResourceCleanup() {
802
- const { scene } = useThree();
803
-
804
- useEffect(() => {
805
- return () => {
806
- scene.traverse((obj) => {
807
- if (obj instanceof THREE.Mesh) {
808
- obj.geometry?.dispose();
809
- const mat = obj.material;
810
- if (Array.isArray(mat)) {
811
- mat.forEach((m) => m.dispose());
812
- } else if (mat) {
813
- mat.dispose();
814
- }
815
- }
816
- });
817
- };
818
- }, [scene]);
819
-
820
- return null;
821
- }
822
-
823
- // Screenshot helper — captures the WebGL canvas
824
- function ScreenshotHelper({ onCapture }: { onCapture: ((fn: () => void) => void) }) {
825
- const { gl, scene, camera } = useThree();
826
- const capture = useCallback(() => {
827
- gl.render(scene, camera);
828
- const dataUrl = gl.domElement.toDataURL("image/png");
829
- const link = document.createElement("a");
830
- link.download = "hero-scene.png";
831
- link.href = dataUrl;
832
- link.click();
833
- }, [gl, scene, camera]);
834
-
835
- // Register the capture function
836
- useEffect(() => onCapture(capture), [onCapture, capture]);
837
- return null;
838
- }
839
-
840
- const LAYERS = ["Glow", "Mountain", "BaseLine", "Water", "Clouds", "SeaLife"] as const;
841
-
842
- /** Static CSS gradient fallback for reduced-motion / no-WebGL */
843
- function StaticFallback({ isDark }: { isDark: boolean }) {
844
- return (
845
- <div
846
- className="h-full w-full"
847
- style={{
848
- background: isDark
849
- ? "radial-gradient(ellipse 80% 60% at 50% 65%, rgba(220,38,38,0.25) 0%, rgba(234,88,12,0.15) 25%, rgba(14,116,144,0.08) 50%, transparent 80%)"
850
- : "radial-gradient(ellipse 80% 60% at 50% 65%, rgba(253,164,175,0.3) 0%, rgba(251,146,60,0.18) 25%, rgba(147,197,253,0.1) 50%, transparent 80%)",
851
- }}
852
- />
853
- );
854
- }
855
-
856
- export function HeroScene({ active = true }: { active?: boolean }) {
857
- const { resolvedTheme } = useTheme();
858
- const mounted = useIsMounted();
859
- const profile = useSceneProfile();
860
- const isDark = !mounted || resolvedTheme !== "light";
861
- const isDev = process.env.NODE_ENV === "development";
862
- const shouldAnimate = active && profile.animate;
863
-
864
- // reduced-motion: show static CSS fallback instead of WebGL
865
- const [reducedMotion, setReducedMotion] = useState(false);
866
- useEffect(() => {
867
- const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
868
- setReducedMotion(mq.matches);
869
- const onChange = () => setReducedMotion(mq.matches);
870
- mq.addEventListener?.("change", onChange);
871
- return () => mq.removeEventListener?.("change", onChange);
872
- }, []);
873
-
874
- const [layers, setLayers] = useState({
875
- Glow: true,
876
- Mountain: true,
877
- BaseLine: false,
878
- Water: true,
879
- Clouds: true,
880
- SeaLife: true,
881
- });
882
- const captureRef = useRef<(() => void) | null>(null);
883
- const canvasStyle = useMemo(() => ({ width: "100%", height: "100%" }) as const, []);
884
- const toggle = (key: string) => setLayers((prev) => ({ ...prev, [key]: !prev[key as keyof typeof prev] }));
885
- const on = (key: string) => layers[key as keyof typeof layers];
886
-
887
- if (reducedMotion) {
888
- return (
889
- <div className="relative h-[400px] w-full overflow-hidden md:h-[550px]">
890
- <StaticFallback isDark={isDark} />
891
- </div>
892
- );
893
- }
894
-
895
- return (
896
- <div className="relative h-[400px] w-full overflow-hidden md:h-[550px]">
897
- <div className="pointer-events-none h-full w-full">
898
- <Canvas
899
- style={canvasStyle}
900
- camera={{ position: [0, 0.25, 3.0], fov: 50 }}
901
- frameloop="demand"
902
- dpr={profile.dpr}
903
- gl={{
904
- antialias: profile.antialias,
905
- alpha: true,
906
- preserveDrawingBuffer: isDev,
907
- powerPreference: profile.tier === "low" ? "low-power" : "default",
908
- }}
909
- >
910
- <ContextGuard />
911
- <ResourceCleanup />
912
- <FrameDriver active={shouldAnimate} targetFps={profile.targetFps} />
913
- {isDev && <ScreenshotHelper onCapture={(fn) => { captureRef.current = fn; }} />}
914
- {on("Water") && <WaterSurface isDark={isDark} />}
915
- {on("Glow") && <GradientGlowSun isDark={isDark} segments={profile.sunSegments} />}
916
- {profile.enableClouds && on("Clouds") && <Clouds isDark={isDark} />}
917
- {on("Mountain") && <MountainSilhouette />}
918
- {on("BaseLine") && <BaseLine />}
919
- {profile.enableSeaLife && on("SeaLife") && <SeaLife isDark={isDark} />}
920
- </Canvas>
921
- </div>
922
-
923
- {/* Layer toggles — dev only */}
924
- {isDev && <div className="absolute bottom-4 left-1/2 z-20 flex -translate-x-1/2 gap-1">
925
- <button
926
- onClick={() => captureRef.current?.()}
927
- className="rounded-md bg-muted/50 px-2 py-0.5 font-mono text-[9px] text-muted-foreground transition-colors hover:bg-muted"
928
- >
929
- 📷
930
- </button>
931
- <span className="w-px self-stretch bg-muted/30" />
932
- {LAYERS.map((key) => (
933
- <button
934
- key={key}
935
- onClick={() => toggle(key)}
936
- className={`rounded-md px-2 py-0.5 font-mono text-[9px] transition-colors ${
937
- on(key)
938
- ? "bg-foreground text-background"
939
- : "bg-muted/50 text-muted-foreground hover:bg-muted"
940
- }`}
941
- >
942
- {key}
943
- </button>
944
- ))}
945
- </div>}
946
- </div>
947
- );
948
- }