From f47df934ed6d6ba2c5273cc423e9c1bf9d96c1eb Mon Sep 17 00:00:00 2001 From: "alexei.dolgolyov" Date: Sun, 22 Mar 2026 00:06:36 +0300 Subject: [PATCH] fix: replace CSS keyframe highlight with direct style pulse for reliability CSS animation was interfering with stagger animation on cards. Now uses setInterval-based box-shadow pulse with computed primary color from CSS variables. Pulses glow on/off every 400ms for 2.5s, then fades out via transition. --- frontend/src/app.css | 7 +--- frontend/src/lib/highlight.ts | 74 ++++++++++++++++++++++------------- 2 files changed, 48 insertions(+), 33 deletions(-) diff --git a/frontend/src/app.css b/frontend/src/app.css index ed66cf3..9eca936 100644 --- a/frontend/src/app.css +++ b/frontend/src/app.css @@ -184,12 +184,7 @@ a:focus-visible { font-family: var(--font-mono); } -/* Card highlight for cross-entity navigation */ -@keyframes cardHighlight { - 0%, 100% { box-shadow: none; } - 25%, 75% { box-shadow: 0 0 0 3px var(--color-primary), 0 0 20px color-mix(in srgb, var(--color-primary) 30%, transparent); } -} - +/* Card highlight dim overlay for cross-entity navigation */ .nav-dim-overlay { position: fixed; inset: 0; diff --git a/frontend/src/lib/highlight.ts b/frontend/src/lib/highlight.ts index 4848a4b..03f975d 100644 --- a/frontend/src/lib/highlight.ts +++ b/frontend/src/lib/highlight.ts @@ -2,21 +2,19 @@ * Card highlight system for cross-entity navigation. * * When navigating via CrossLink, the target entity ID is passed as ?highlight=. - * The destination page calls highlightFromUrl() on mount, which: + * The destination page calls highlightFromUrl() after data loads, which: * 1. Shows a dim overlay behind everything * 2. Finds the card with [data-entity-id=""] * 3. Scrolls to it smoothly - * 4. Applies a pulsing box-shadow animation - * 5. Cleans up after 2 seconds - * - * If the card isn't in the DOM yet (data still loading), a MutationObserver - * waits up to 5 seconds for it to appear. + * 4. Applies a pulsing glow via direct style manipulation + * 5. Cleans up after 2.5 seconds */ -const HIGHLIGHT_DURATION = 2000; +const HIGHLIGHT_DURATION = 2500; +const PULSE_INTERVAL = 400; const WAIT_TIMEOUT = 5000; -/** Show dim overlay, find card, scroll & highlight. Call from onMount or $effect. */ +/** Show dim overlay, find card, scroll & highlight. Call after data loads. */ export function highlightFromUrl(): void { if (typeof window === 'undefined') return; const params = new URLSearchParams(window.location.search); @@ -32,7 +30,8 @@ export function highlightFromUrl(): void { // Try to find the card now, or wait for it const card = document.querySelector(`[data-entity-id="${id}"]`); if (card) { - _highlightCard(card as HTMLElement); + // Small delay for layout to settle after loaded=true + setTimeout(() => _highlightCard(card as HTMLElement), 100); } else { _waitForCard(id); } @@ -45,28 +44,52 @@ function _highlightCard(card: HTMLElement): void { // Scroll to card card.scrollIntoView({ behavior: 'smooth', block: 'center' }); - // Kill the stagger animation first so it won't replay on cleanup - card.style.animation = 'none'; - // Force reflow to commit the "none" - void card.offsetHeight; - // Now set the highlight animation + z-index above overlay - card.style.animation = 'cardHighlight 2s ease-in-out'; + // Save original styles + const origBoxShadow = card.style.boxShadow; + const origZIndex = card.style.zIndex; + const origPosition = card.style.position; + const origBorderColor = card.style.borderColor; + + // Elevate above overlay card.style.position = 'relative'; card.style.zIndex = '11'; - // Cleanup after duration + // Get primary color from CSS variable + const primary = getComputedStyle(document.documentElement) + .getPropertyValue('--color-primary').trim(); + + // Pulse effect via interval + let on = true; + const glowOn = `0 0 0 3px ${primary}, 0 0 24px ${primary}60`; + const glowOff = `0 0 0 2px ${primary}80`; + + card.style.boxShadow = glowOn; + card.style.borderColor = primary; + + const pulseTimer = setInterval(() => { + on = !on; + card.style.boxShadow = on ? glowOn : glowOff; + }, PULSE_INTERVAL); + + // Cleanup setTimeout(() => { - // Set animation to "none" to prevent stagger replay, then remove inline overrides - card.style.animation = 'none'; - card.style.removeProperty('position'); - card.style.removeProperty('z-index'); - overlay.classList.remove('active'); - setTimeout(() => overlay.remove(), 300); + clearInterval(pulseTimer); + // Fade out: transition the box-shadow away + card.style.transition = 'box-shadow 0.3s ease, border-color 0.3s ease'; + card.style.boxShadow = origBoxShadow; + card.style.borderColor = origBorderColor; + + setTimeout(() => { + card.style.position = origPosition; + card.style.zIndex = origZIndex; + card.style.removeProperty('transition'); + overlay.classList.remove('active'); + setTimeout(() => overlay.remove(), 300); + }, 300); }, HIGHLIGHT_DURATION); } function _showDimOverlay(): HTMLElement { - // Reuse existing overlay if present let overlay = document.querySelector('.nav-dim-overlay') as HTMLElement | null; if (!overlay) { overlay = document.createElement('div'); @@ -86,8 +109,7 @@ function _waitForCard(id: string): void { const card = document.querySelector(`[data-entity-id="${id}"]`); if (card) { observer.disconnect(); - // Small delay for layout to settle - requestAnimationFrame(() => _highlightCard(card as HTMLElement)); + setTimeout(() => _highlightCard(card as HTMLElement), 100); return; } if (Date.now() - start > WAIT_TIMEOUT) { @@ -96,7 +118,5 @@ function _waitForCard(id: string): void { }); observer.observe(document.body, { childList: true, subtree: true }); - - // Safety timeout setTimeout(() => observer.disconnect(), WAIT_TIMEOUT); }