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:
@@ -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;
|
||||
|
||||
@@ -2,21 +2,19 @@
|
||||
* Card highlight system for cross-entity navigation.
|
||||
*
|
||||
* 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
|
||||
* 2. Finds the card with [data-entity-id="<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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user