@@ -64,9 +64,17 @@ const { title = '', description = '', class: className } = Astro.props as HeroPr
6464 JS drives the slow drift + flicker after activation.
6565 */
6666 }
67+ {
68+ /*
69+ Stars div is intentionally oversized beyond inset-0.
70+ It extends 220px left and 90px down past the hero bounds so the
71+ arc drift (max 200px right, 70px up) never reveals a gap.
72+ overflow-hidden on hero-container clips it to the visible hero area.
73+ */
74+ }
6775 <div
6876 id =" hero-stars"
69- class =" absolute inset-0 z-2"
77+ class =" absolute top-0 right-0 -bottom-[90px] -left-[220px] z-2"
7078 style =" opacity: 0; will-change: transform, opacity;"
7179 >
7280 <Image
@@ -144,6 +152,35 @@ const { title = '', description = '', class: className } = Astro.props as HeroPr
144152
145153 if (!imgOff || !imgOn || !stars) throw new Error('Hero: missing elements');
146154
155+ // ── Arc-length LUT — ensures constant-speed traversal of the elliptical arc ─
156+ // Without this, parametric speed varies from 200 px/s (start) to 70 px/s (end)
157+ // because the ellipse radii differ. The LUT maps arc-fraction → angle so each
158+ // frame advances the same physical distance regardless of ellipse shape.
159+ const ARC_RX = 200,
160+ ARC_RY = 70,
161+ ARC_SPAN = Math.PI / 2;
162+ const ARC_N = 500;
163+ const _arcLen: number[] = [0];
164+ for (let i = 1; i <= ARC_N; i++) {
165+ const a = (i / ARC_N) * ARC_SPAN;
166+ const spd = Math.sqrt((ARC_RX * Math.cos(a)) ** 2 + (ARC_RY * Math.sin(a)) ** 2);
167+ _arcLen.push(_arcLen[i - 1] + spd * (ARC_SPAN / ARC_N));
168+ }
169+ const ARC_TOTAL = _arcLen[ARC_N];
170+
171+ function arcFracToAngle(frac: number): number {
172+ const s = Math.min(frac * ARC_TOTAL, ARC_TOTAL);
173+ let lo = 0,
174+ hi = ARC_N;
175+ while (hi - lo > 1) {
176+ const m = (lo + hi) >> 1;
177+ if (_arcLen[m] < s) lo = m;
178+ else hi = m;
179+ }
180+ const t = _arcLen[hi] > _arcLen[lo] ? (s - _arcLen[lo]) / (_arcLen[hi] - _arcLen[lo]) : 0;
181+ return ((lo + t) / ARC_N) * ARC_SPAN;
182+ }
183+
147184 // ── Reset — fade everything out, then restart the full cycle ───────────────
148185 function resetAndRestart() {
149186 const FADE_OUT = 1200; // ms to fade stars + device out
@@ -154,13 +191,25 @@ const { title = '', description = '', class: className } = Astro.props as HeroPr
154191 header?.classList.remove('hero--alive');
155192 stars!.style.opacity = '0';
156193 imgOn!.style.opacity = '0';
157- setGlow(0);
194+
195+ // Glow fades with device — transition to zero-alpha (not 'none') so CSS interpolates
196+ if (glowSpan) {
197+ glowSpan.style.transition = `text-shadow ${FADE_OUT}ms ease-in-out`;
198+ glowSpan.style.textShadow =
199+ '0 0 15px rgba(230,245,159,0),' +
200+ '0 0 40px rgba(230,245,159,0),' +
201+ '0 0 80px rgba(230,245,159,0)';
202+ }
158203
159204 setTimeout(() => {
160205 stars!.style.transition = '';
161206 imgOn!.style.transition = '';
207+ if (glowSpan) {
208+ glowSpan.style.transition = '';
209+ glowSpan.style.textShadow = 'none';
210+ }
162211 // Reset position only after stars are fully invisible — no visible jolt
163- stars!.style.transform = 'translateX(0px)';
212+ stars!.style.transform = 'translateX(0px) translateY(0px) ';
164213 // Restart full cycle from Phase 1
165214 setTimeout(startFlicker, LASER_DELAY);
166215 }, FADE_OUT + 100);
@@ -174,6 +223,7 @@ const { title = '', description = '', class: className } = Astro.props as HeroPr
174223
175224 // Stars drift state
176225 let starsOpacity = 0;
226+ let sCurrentOpacity = 0; // tracked in JS to avoid DOM reads (prevents forced reflow)
177227 const STARS_FADE_IN = 1800; // ms for stars to reach full opacity
178228
179229 // Stars flicker — subtle random breathing
@@ -186,6 +236,10 @@ const { title = '', description = '', class: className } = Astro.props as HeroPr
186236 let dEpisodeStart = 0;
187237 let dEpisodeDur = 0;
188238
239+ // Glow breath — slow sine pulse when device is stable
240+ const BREATH_PERIOD = 3200; // ms per breath cycle
241+ let glowCurrent = 1.0; // tracked in JS to smooth transitions
242+
189243 // Schedule restart after RESTART_DELAY
190244 setTimeout(() => {
191245 stopped = true;
@@ -204,8 +258,10 @@ const { title = '', description = '', class: className } = Astro.props as HeroPr
204258 starsOpacity = Math.min(1, elapsed / STARS_FADE_IN);
205259 }
206260
207- // ── Stars: continuous rightward drift (60px over the 30s cycle) ────────
208- const driftX = elapsed * (60 / RESTART_DELAY);
261+ // ── Stars: constant-speed arc (LUT-corrected, upper-right quarter) ──
262+ const arcAngle = arcFracToAngle(elapsed / RESTART_DELAY);
263+ const driftX = ARC_RX * Math.sin(arcAngle);
264+ const driftY = -ARC_RY * (1 - Math.cos(arcAngle));
209265
210266 // ── Device: random episode flicker ───────────────────────────────────
211267 dEpisodeTimer -= dt;
@@ -219,6 +275,7 @@ const { title = '', description = '', class: className } = Astro.props as HeroPr
219275
220276 // ── Stars opacity — synced to device episode when active ─────────────
221277 let sTargetOpacity: number;
278+ let glowTarget: number;
222279 if (dEpisodeActive) {
223280 const t = ts - dEpisodeStart;
224281 const sec = t / 1000;
@@ -230,19 +287,19 @@ const { title = '', description = '', class: className } = Astro.props as HeroPr
230287 Math.sin(sec * 38.7) * 0.3 + Math.sin(sec * 17.1) * 0.25 + Math.sin(sec * 5.3) * 0.2;
231288 const dAlpha = Math.max(0.15, 0.75 + noise);
232289 imgOn!.style.opacity = String(dAlpha);
233- setGlow(dAlpha);
234290 // Stars mirror the same flicker value
235291 sTargetOpacity = starsOpacity * dAlpha;
292+ glowTarget = dAlpha;
236293 } else {
237294 // Recover — snap device back to stable
238295 dEpisodeActive = false;
239296 imgOn!.style.transition = 'opacity 0.18s ease-out';
240297 imgOn!.style.opacity = '1';
241- setGlow(1);
242298 setTimeout(() => {
243299 imgOn!.style.transition = '';
244300 }, 200);
245301 sTargetOpacity = starsOpacity;
302+ glowTarget = 1;
246303 }
247304 } else {
248305 // Idle breathing flicker for stars
@@ -252,13 +309,19 @@ const { title = '', description = '', class: className } = Astro.props as HeroPr
252309 sFlickerTimer = 500 + Math.random() * 1800;
253310 }
254311 sTargetOpacity = starsOpacity * sFlickerTarget;
312+ // Glow breathes slowly via sine wave: 0.55–1.0
313+ glowTarget = 0.55 + 0.45 * (0.5 + 0.5 * Math.sin((elapsed / BREATH_PERIOD) * Math.PI * 2));
255314 }
256315
257- const sCurrent = parseFloat(stars!.style.opacity || '0');
258- const sSmoothed = sCurrent + (sTargetOpacity - sCurrent) * 0.04;
316+ // Smooth glow transitions (episode snaps fast, breath eases slowly)
317+ const glowLerp = dEpisodeActive ? 0.25 : 0.03;
318+ glowCurrent = glowCurrent + (glowTarget - glowCurrent) * glowLerp;
319+ setGlow(glowCurrent);
320+
321+ sCurrentOpacity = sCurrentOpacity + (sTargetOpacity - sCurrentOpacity) * 0.04;
259322
260- stars!.style.opacity = String(sSmoothed );
261- stars!.style.transform = `translateX(${driftX.toFixed(2)}px)`;
323+ stars!.style.opacity = String(sCurrentOpacity );
324+ stars!.style.transform = `translateX(${driftX.toFixed(2)}px) translateY(${driftY.toFixed(2)}px) `;
262325
263326 requestAnimationFrame(ambient);
264327 }
0 commit comments