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);
|
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;
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user