feat: card highlight system for cross-entity navigation

When clicking a CrossLink, the target entity ID is passed as
?highlight=<id> in the URL. The destination page:
1. Shows a semi-transparent dim overlay (z-index: 10)
2. Finds the card with data-entity-id matching the ID
3. Scrolls to it smoothly (block: center)
4. Applies a pulsing primary-color box-shadow animation (z-index: 11)
5. Cleans up overlay + animation after 2 seconds

If the card isn't in DOM yet (data still loading), a MutationObserver
waits up to 5 seconds for it to appear.

Changes:
- New highlight.ts utility with highlightFromUrl(), MutationObserver,
  dim overlay management
- Card component accepts entityId prop → data-entity-id attribute
- CrossLink accepts entityId prop → appends ?highlight=<id> to href
- All 9 entity pages: Card elements have entityId, highlightFromUrl()
  called after data loads
- CSS: cardHighlight keyframe animation + nav-dim-overlay styles
This commit is contained in:
2026-03-21 23:59:25 +03:00
parent 227b9c2e92
commit f0f49db21e
13 changed files with 202 additions and 27 deletions
+93
View File
@@ -0,0 +1,93 @@
/**
* 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:
* 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.
*/
const HIGHLIGHT_DURATION = 2000;
const WAIT_TIMEOUT = 5000;
/** Show dim overlay, find card, scroll & highlight. Call from onMount or $effect. */
export function highlightFromUrl(): void {
if (typeof window === 'undefined') return;
const params = new URLSearchParams(window.location.search);
const id = params.get('highlight');
if (!id) return;
// Clean the URL immediately (remove highlight param, keep others)
params.delete('highlight');
const qs = params.toString();
const cleanUrl = window.location.pathname + (qs ? '?' + qs : '');
window.history.replaceState(null, '', cleanUrl);
// Try to find the card now, or wait for it
const card = document.querySelector(`[data-entity-id="${id}"]`);
if (card) {
_highlightCard(card as HTMLElement);
} else {
_waitForCard(id);
}
}
function _highlightCard(card: HTMLElement): void {
// Show dim overlay
const overlay = _showDimOverlay();
// Scroll to card
card.scrollIntoView({ behavior: 'smooth', block: 'center' });
// Apply highlight
card.classList.add('card-highlight');
// Cleanup after duration
setTimeout(() => {
card.classList.remove('card-highlight');
overlay.classList.remove('active');
setTimeout(() => overlay.remove(), 300); // wait for fade-out
}, 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');
overlay.className = 'nav-dim-overlay';
document.body.appendChild(overlay);
}
// Force reflow then activate
void overlay.offsetHeight;
overlay.classList.add('active');
return overlay;
}
function _waitForCard(id: string): void {
const start = Date.now();
const observer = new MutationObserver(() => {
const card = document.querySelector(`[data-entity-id="${id}"]`);
if (card) {
observer.disconnect();
// Small delay for layout to settle
requestAnimationFrame(() => _highlightCard(card as HTMLElement));
return;
}
if (Date.now() - start > WAIT_TIMEOUT) {
observer.disconnect();
}
});
observer.observe(document.body, { childList: true, subtree: true });
// Safety timeout
setTimeout(() => observer.disconnect(), WAIT_TIMEOUT);
}