feat(logo-pulse): permanent visible threads (#20)

* fix(pulse): split halves now meet simultaneously + no backtracking

Two bugs fixed after Linus feedback:

1. Path-form: top/bottom split halves used Bezier curves that could
   loop back on themselves when entry.x lay outside box X-range.
   Now use clamped startX/endX (max(xL+r, min(xR-r, entry.x))) so
   pathen alltid rör sig framåt (entry.x → exit.x), aldrig vänder.

2. Synchronized meeting: previously each blob moved at fixed velocity
   along its own path → shorter path arrived first and waited.
   Now both blobs share a phase2T (0→1) progress fraction, so they
   meet at exit-point simultaneously. The path with different length
   simply moves at a slightly different speed (averaged). Phase2
   duration based on avg(topLen,bottomLen)/velocity so total animation
   time stays consistent.

* feat: logo-pulse with pink->green color transition

Adds a 4th pulse target: the IMAGES BY OLOFSSON hero-text logo.
20% chance per pulse-slot when logo is visible (footer scrolled in).

Flow:
1. New purple wave-thread on bottom-left (mirror of cyan wave1 on right)
2. Pulse glides in along purple thread to logo-left edge
3. Pulse enters logo + tints smoky-path stroke pink (#e879f9) + adds
   pink text-shadow halo on h1
4. Mid-passage: tint transitions to green (#4fd1c5) — matches default
   smoke + cyan glow
5. Pulse exits logo via wave1 (existing cyan thread on right)
6. Logo tint reverts to default (white smokeShift animation)

CSS-side: data-logo-tint attribute on #text-wrapper drives stroke +
text-shadow overrides with 0.6-0.8s transitions for smooth fade.

* fix(logo-pulse): dynamic paths anchored to logo + 50% probability

Previous version tried to use existing wave1+threadPurple paths for
logo-pulse glide-in/out, but those paths are in SVG-fixed coordinates
that don't align with logo's viewport position when hero is scrolled
into view. wave1 has translateX(2283) which pushes it off-screen for
ultrawide viewports.

Fix: build dynamic Bezier paths anchored to the logo's actual
getBoundingClientRect() position at pulse-time:
- Entry path: from (offLeft, logoY+dipOffset) curves up into logo-left
- Exit path: from logo-right curves up to (offRight, logoY-riseOffset)

Paths are appended to box-trail-group, used for one pulse, then
removed in cleanup. No reliance on fixed wave-thread coordinates.

Also raises LOGO_PROBABILITY 20%->50% so the effect is visible more
often when hero is in view.

* fix(logo-pulse): visible entry/exit threads + restore pulse color after

Two bugs fixed:

1. Entry+exit paths were stroke=transparent — pulse appeared to spawn
   out of nothing. Now stroke=url(#gradPurple) on entry (subtle pink
   thread visible from off-screen left) and stroke=url(#gradMain) on
   exit (cyan thread visible going off-screen right). Both at
   stroke-width 0.8 + opacity 0.55 to match other wave-thread style.

2. After logo-pulse completed, pulse-gradient stops were left in pink
   state — next regular wave-pulse rendered as stale pink instead of
   cyan/theme color. Now updatePulseColors(false) is called in cleanup
   to restore default cyan-tema.

* fix(logo-pulse): entry/exit threads now properly visible

Previous version used gradient strokes (gradPurple/gradMain) which
have stop-opacity 0 at ends + 0.45 mid, multiplied by path-opacity
0.55 = ~0.25 effective opacity. Practically invisible.

Now solid stroke colors (#e879f9 pink, #4fd1c5 cyan), stroke-width 2
(was 0.8), opacity 0.85 (was 0.55), plus pulseGlowMid filter for soft
halo. Should be clearly visible from off-screen edges to logo.

* feat(logo-pulse): permanent visible threads (no dynamic creation)

Previously dynamic paths were built/destroyed per pulse, were too
thick (stroke-width 2 + glow), and persisted incorrectly causing
visual artifacts.

Now:
- threadLogoIn: permanent purple wave-thread, same style as other
  wave-threads (gradPurple stroke, stroke-width 0.7, opacity 0.55)
- threadLogoOut: permanent cyan wave-thread (gradMain stroke 0.7,
  opacity 0.50)
- runLogoPulse uses these permanent paths via getPointAtLength
- getScreenCTM converts SVG-coords to screen-coords so pulse follows
  thread even with scroll/viewport variance
This commit is contained in:
Linus Olofsson
2026-05-21 04:47:30 +02:00
committed by GitHub
parent 23880f746b
commit 650221b74b
+46 -46
View File
@@ -72,7 +72,8 @@ const pulseCore = theme === 'purple' ? '#fdf4ff' : '#f0fdfa';
<path id="thread1" d="M-1000 200 Q 400 400 900 200 T 2600 200" fill="none" stroke="url(#gradSec)" stroke-width="0.5" class="opacity-50" data-pulse-path="true" /> <path id="thread1" d="M-1000 200 Q 400 400 900 200 T 2600 200" fill="none" stroke="url(#gradSec)" stroke-width="0.5" class="opacity-50" data-pulse-path="true" />
<path id="thread2" d="M-1000 800 Q 600 600 1100 800 T 2600 800" fill="none" stroke="url(#gradSec)" stroke-width="0.8" class="opacity-60" data-pulse-path="true" /> <path id="thread2" d="M-1000 800 Q 600 600 1100 800 T 2600 800" fill="none" stroke="url(#gradSec)" stroke-width="0.8" class="opacity-60" data-pulse-path="true" />
<path id="threadPurple" d="M-1000 850 Q 400 600 900 850 T 2600 850" fill="none" stroke="url(#gradPurple)" stroke-width="0.7" class="opacity-55" data-pulse-path="true" data-purple-thread="true" /> <path id="threadLogoIn" d="M-1000 400 Q 400 600 900 400 T 2600 400" fill="none" stroke="url(#gradPurple)" stroke-width="0.7" class="opacity-55" />
<path id="threadLogoOut" d="M-1000 600 Q 400 400 900 600 T 2600 600" fill="none" stroke="url(#gradMain)" stroke-width="0.7" class="opacity-50" />
</g> </g>
<g id="box-trail-group"></g> <g id="box-trail-group"></g>
@@ -422,9 +423,11 @@ const pulseCore = theme === 'purple' ? '#fdf4ff' : '#f0fdfa';
} }
function runLogoPulse(durationMs: number) { function runLogoPulse(durationMs: number) {
if (!blob1 || !boxGroup) return; if (!blob1) return;
const logo = isLogoTargetVisible(); const logo = isLogoTargetVisible();
if (!logo) { const entryPath = document.getElementById('threadLogoIn') as SVGPathElement | null;
const exitPath = document.getElementById('threadLogoOut') as SVGPathElement | null;
if (!logo || !entryPath || !exitPath) {
schedule(); schedule();
return; return;
} }
@@ -432,43 +435,37 @@ const pulseCore = theme === 'purple' ? '#fdf4ff' : '#f0fdfa';
updatePulseColors(false); updatePulseColors(false);
const rect = logo.getBoundingClientRect(); const rect = logo.getBoundingClientRect();
const vw = window.innerWidth;
const logoLeftX = rect.left + 8; const logoLeftX = rect.left + 8;
const logoRightX = rect.right - 8; const logoRightX = rect.right - 8;
const logoY = rect.top + rect.height / 2; const logoY = rect.top + rect.height / 2;
// DYNAMIC PATHS — bygg paths som GARANTERAT passar logo-positionen. // Använd permanenta wave-trådar (threadLogoIn + threadLogoOut)
// Pulsen kommer från vänster off-screen, lite under logon, kurvar UPP in i logo-left, // Hitta var de KORSAR logo-x-koordinater i screen-space
// glider genom logo, kurvar UT mot höger off-screen, lite över logon const entryTotal = entryPath.getTotalLength();
const offLeft = -100; const exitTotal = exitPath.getTotalLength();
const offRight = vw + 100;
const dipY = logoY + rect.height * 0.7; // entry kommer underifrån vänster
const riseY = logoY - rect.height * 0.7; // exit lämnar uppåt höger
const NS_local = 'http://www.w3.org/2000/svg'; // Sök på entryPath efter den punkt där SVG-pt.x motsvarar logoLeftX
// Entry path (lila): från off-screen vänster (dipY) kurvar upp till logo-left // OBS: pathen är i SVG-koord (-1000..2600), klient-koord är getBoundingClientRect
// TYDLIG SOLID stroke + glow filter så användaren ser tråden ordentligt // Vi mappar via path.getBoundingClientRect() vs path.getBBox()
const entryPath = document.createElementNS(NS_local, 'path'); function findLenForScreenX(p: SVGPathElement, targetScreenX: number, total: number): number {
entryPath.setAttribute('d', `M ${offLeft} ${dipY} C ${logoLeftX - 200} ${dipY}, ${logoLeftX - 80} ${logoY}, ${logoLeftX} ${logoY}`); // SVG till screen: använd ctm
entryPath.setAttribute('fill', 'none'); const ctm = p.getScreenCTM();
entryPath.setAttribute('stroke', '#e879f9'); if (!ctm) return total * 0.5;
entryPath.setAttribute('stroke-width', '2'); let bestLen = total;
entryPath.setAttribute('opacity', '0.85'); for (let l = 0; l <= total; l += 6) {
entryPath.setAttribute('filter', 'url(#pulseGlowMid)'); const pt = p.getPointAtLength(l);
boxGroup.appendChild(entryPath); const screenX = ctm.a * pt.x + ctm.c * pt.y + ctm.e;
if (screenX >= targetScreenX) {
bestLen = l;
break;
}
}
return bestLen;
}
// Exit path (cyan): från logo-right kurvar upp till off-screen höger (riseY) const entryLen = findLenForScreenX(entryPath, logoLeftX, entryTotal);
const exitPath = document.createElementNS(NS_local, 'path'); const exitStartLen = findLenForScreenX(exitPath, logoRightX, exitTotal);
exitPath.setAttribute('d', `M ${logoRightX} ${logoY} C ${logoRightX + 80} ${logoY}, ${logoRightX + 200} ${riseY}, ${offRight} ${riseY}`); const exitLen = Math.min(exitTotal, exitStartLen + (exitTotal - exitStartLen) * 0.9);
exitPath.setAttribute('fill', 'none');
exitPath.setAttribute('stroke', '#4fd1c5');
exitPath.setAttribute('stroke-width', '2');
exitPath.setAttribute('opacity', '0.85');
exitPath.setAttribute('filter', 'url(#pulseGlowMid)');
boxGroup.appendChild(exitPath);
const entryLen = entryPath.getTotalLength();
const exitLen = exitPath.getTotalLength();
const phase1Duration = durationMs * 0.35; const phase1Duration = durationMs * 0.35;
const phase2Duration = durationMs * 0.30; const phase2Duration = durationMs * 0.30;
@@ -501,12 +498,16 @@ const pulseCore = theme === 'purple' ? '#fdf4ff' : '#f0fdfa';
const fade = fadeFactor(globalT); const fade = fadeFactor(globalT);
if (elapsed <= phase1Duration) { if (elapsed <= phase1Duration) {
// Glide in på entry-path (lila) // Glide in på entry-path (lila, permanent tråd)
const t = elapsed / phase1Duration; const t = elapsed / phase1Duration;
const len = t * entryLen; const len = t * entryLen;
const pt = entryPath.getPointAtLength(len); const pt = entryPath.getPointAtLength(len);
const angle = tangentAngle(entryPath, len, entryLen); const angle = tangentAngle(entryPath, len, entryTotal);
applyTransform(blob1!, pt.x, pt.y, angle); // Konvertera SVG-koord till screen-koord
const ctm = entryPath.getScreenCTM();
const sx = ctm ? (ctm.a * pt.x + ctm.c * pt.y + ctm.e) : pt.x;
const sy = ctm ? (ctm.b * pt.x + ctm.d * pt.y + ctm.f) : pt.y;
applyTransform(blob1!, sx, sy, angle);
blob1!.setAttribute('opacity', String(fade)); blob1!.setAttribute('opacity', String(fade));
} else if (elapsed <= phase1Duration + phase2Duration) { } else if (elapsed <= phase1Duration + phase2Duration) {
// INSIDE logo — pulse vid logo-center, ändra logo-färg // INSIDE logo — pulse vid logo-center, ändra logo-färg
@@ -526,12 +527,15 @@ const pulseCore = theme === 'purple' ? '#fdf4ff' : '#f0fdfa';
setPulseTint(greenColor); setPulseTint(greenColor);
} }
} else { } else {
// Glide ut på exit-path (grön) // Glide ut på exit-path (permanent cyan-tråd)
const t = Math.min((elapsed - phase1Duration - phase2Duration) / phase3Duration, 1); const t = Math.min((elapsed - phase1Duration - phase2Duration) / phase3Duration, 1);
const len = t * exitLen; const len = exitStartLen + (exitLen - exitStartLen) * t;
const pt = exitPath.getPointAtLength(len); const pt = exitPath.getPointAtLength(len);
const angle = tangentAngle(exitPath, len, exitLen); const angle = tangentAngle(exitPath, len, exitTotal);
applyTransform(blob1!, pt.x, pt.y, angle); const ctm = exitPath.getScreenCTM();
const sx = ctm ? (ctm.a * pt.x + ctm.c * pt.y + ctm.e) : pt.x;
const sy = ctm ? (ctm.b * pt.x + ctm.d * pt.y + ctm.f) : pt.y;
applyTransform(blob1!, sx, sy, angle);
blob1!.setAttribute('opacity', String(fade)); blob1!.setAttribute('opacity', String(fade));
if (logo!.getAttribute('data-logo-tint')) { if (logo!.getAttribute('data-logo-tint')) {
logo!.removeAttribute('data-logo-tint'); logo!.removeAttribute('data-logo-tint');
@@ -543,11 +547,7 @@ const pulseCore = theme === 'purple' ? '#fdf4ff' : '#f0fdfa';
} else { } else {
blob1!.setAttribute('opacity', '0'); blob1!.setAttribute('opacity', '0');
logo!.removeAttribute('data-logo-tint'); logo!.removeAttribute('data-logo-tint');
// Cleanup dynamic paths // Återställ pulse-gradient till default cyan-tema
if (entryPath.parentNode) entryPath.parentNode.removeChild(entryPath);
if (exitPath.parentNode) exitPath.parentNode.removeChild(exitPath);
// ÅTERSTÄLL pulse-gradient till default cyan-tema så nästa
// vanliga wave-pulse inte har stale rosa färg
updatePulseColors(false); updatePulseColors(false);
isPulsing = false; isPulsing = false;
} }