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.
This commit is contained in:
2026-03-22 00:06:36 +03:00
parent 4b59f40fd5
commit f47df934ed
2 changed files with 48 additions and 33 deletions
+1 -6
View File
@@ -184,12 +184,7 @@ a:focus-visible {
font-family: var(--font-mono); font-family: var(--font-mono);
} }
/* Card highlight for cross-entity navigation */ /* Card highlight dim overlay 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); }
}
.nav-dim-overlay { .nav-dim-overlay {
position: fixed; position: fixed;
inset: 0; inset: 0;
+47 -27
View File
@@ -2,21 +2,19 @@
* Card highlight system for cross-entity navigation. * Card highlight system for cross-entity navigation.
* *
* When navigating via CrossLink, the target entity ID is passed as ?highlight=<id>. * When navigating via CrossLink, the target entity ID is passed as ?highlight=<id>.
* The destination page calls highlightFromUrl() on mount, which: * The destination page calls highlightFromUrl() after data loads, which:
* 1. Shows a dim overlay behind everything * 1. Shows a dim overlay behind everything
* 2. Finds the card with [data-entity-id="<id>"] * 2. Finds the card with [data-entity-id="<id>"]
* 3. Scrolls to it smoothly * 3. Scrolls to it smoothly
* 4. Applies a pulsing box-shadow animation * 4. Applies a pulsing glow via direct style manipulation
* 5. Cleans up after 2 seconds * 5. Cleans up after 2.5 seconds
*
* If the card isn't in the DOM yet (data still loading), a MutationObserver
* waits up to 5 seconds for it to appear.
*/ */
const HIGHLIGHT_DURATION = 2000; const HIGHLIGHT_DURATION = 2500;
const PULSE_INTERVAL = 400;
const WAIT_TIMEOUT = 5000; 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 { export function highlightFromUrl(): void {
if (typeof window === 'undefined') return; if (typeof window === 'undefined') return;
const params = new URLSearchParams(window.location.search); 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 // Try to find the card now, or wait for it
const card = document.querySelector(`[data-entity-id="${id}"]`); const card = document.querySelector(`[data-entity-id="${id}"]`);
if (card) { if (card) {
_highlightCard(card as HTMLElement); // Small delay for layout to settle after loaded=true
setTimeout(() => _highlightCard(card as HTMLElement), 100);
} else { } else {
_waitForCard(id); _waitForCard(id);
} }
@@ -45,28 +44,52 @@ function _highlightCard(card: HTMLElement): void {
// Scroll to card // Scroll to card
card.scrollIntoView({ behavior: 'smooth', block: 'center' }); card.scrollIntoView({ behavior: 'smooth', block: 'center' });
// Kill the stagger animation first so it won't replay on cleanup // Save original styles
card.style.animation = 'none'; const origBoxShadow = card.style.boxShadow;
// Force reflow to commit the "none" const origZIndex = card.style.zIndex;
void card.offsetHeight; const origPosition = card.style.position;
// Now set the highlight animation + z-index above overlay const origBorderColor = card.style.borderColor;
card.style.animation = 'cardHighlight 2s ease-in-out';
// Elevate above overlay
card.style.position = 'relative'; card.style.position = 'relative';
card.style.zIndex = '11'; 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(() => { setTimeout(() => {
// Set animation to "none" to prevent stagger replay, then remove inline overrides clearInterval(pulseTimer);
card.style.animation = 'none'; // Fade out: transition the box-shadow away
card.style.removeProperty('position'); card.style.transition = 'box-shadow 0.3s ease, border-color 0.3s ease';
card.style.removeProperty('z-index'); card.style.boxShadow = origBoxShadow;
overlay.classList.remove('active'); card.style.borderColor = origBorderColor;
setTimeout(() => overlay.remove(), 300);
setTimeout(() => {
card.style.position = origPosition;
card.style.zIndex = origZIndex;
card.style.removeProperty('transition');
overlay.classList.remove('active');
setTimeout(() => overlay.remove(), 300);
}, 300);
}, HIGHLIGHT_DURATION); }, HIGHLIGHT_DURATION);
} }
function _showDimOverlay(): HTMLElement { function _showDimOverlay(): HTMLElement {
// Reuse existing overlay if present
let overlay = document.querySelector('.nav-dim-overlay') as HTMLElement | null; let overlay = document.querySelector('.nav-dim-overlay') as HTMLElement | null;
if (!overlay) { if (!overlay) {
overlay = document.createElement('div'); overlay = document.createElement('div');
@@ -86,8 +109,7 @@ function _waitForCard(id: string): void {
const card = document.querySelector(`[data-entity-id="${id}"]`); const card = document.querySelector(`[data-entity-id="${id}"]`);
if (card) { if (card) {
observer.disconnect(); observer.disconnect();
// Small delay for layout to settle setTimeout(() => _highlightCard(card as HTMLElement), 100);
requestAnimationFrame(() => _highlightCard(card as HTMLElement));
return; return;
} }
if (Date.now() - start > WAIT_TIMEOUT) { if (Date.now() - start > WAIT_TIMEOUT) {
@@ -96,7 +118,5 @@ function _waitForCard(id: string): void {
}); });
observer.observe(document.body, { childList: true, subtree: true }); observer.observe(document.body, { childList: true, subtree: true });
// Safety timeout
setTimeout(() => observer.disconnect(), WAIT_TIMEOUT); setTimeout(() => observer.disconnect(), WAIT_TIMEOUT);
} }