diff --git a/app/[locale]/settings/page.tsx b/app/[locale]/settings/page.tsx index 9c47bcbc..8cf9fc09 100644 --- a/app/[locale]/settings/page.tsx +++ b/app/[locale]/settings/page.tsx @@ -442,37 +442,54 @@ export default function SettingsPage() { }, [isDesktop, mobileShowContent]); // After clicking a search sub-result, scroll the matching setting into view - // and add a temporary highlight class. Two RAFs to wait for the tab content - // to mount and lay out before querying the DOM. + // and add a temporary highlight class. Some tabs fetch data and render + // their SettingItems only after a loading state, so retry until the element + // shows up (or we give up after ~2s). useEffect(() => { if (!pendingHighlight) return; if (pendingHighlight.tab !== activeTab) return; if (typeof window === 'undefined') return; let cancelled = false; + let retryTimer: ReturnType | undefined; let cleanupTimer: ReturnType | undefined; let highlightedEl: HTMLElement | null = null; - const r1 = window.requestAnimationFrame(() => { - window.requestAnimationFrame(() => { - if (cancelled) return; - const escaped = pendingHighlight.label.replace(/"/g, '\\"'); - const el = document.querySelector(`[data-search-label="${escaped}"]`); - if (el) { - el.scrollIntoView({ behavior: 'smooth', block: 'center' }); - el.classList.add('settings-search-highlight'); - highlightedEl = el; - cleanupTimer = setTimeout(() => { - el.classList.remove('settings-search-highlight'); - }, 2000); - } - setPendingHighlight(null); - }); - }); + const escaped = pendingHighlight.label.replace(/"/g, '\\"'); + const selector = `[data-search-label="${escaped}"]`; + const deadline = Date.now() + 2000; + const tryHighlight = () => { + if (cancelled) return; + const el = document.querySelector(selector); + if (!el) { + if (Date.now() < deadline) { + retryTimer = setTimeout(tryHighlight, 80); + } + return; + } + el.scrollIntoView({ behavior: 'smooth', block: 'center' }); + // Remove + reflow + add restarts the CSS animation if the class was + // already present (re-clicking the same sub-result). + el.classList.remove('settings-search-highlight'); + void el.offsetWidth; + el.classList.add('settings-search-highlight'); + highlightedEl = el; + cleanupTimer = setTimeout(() => { + el.classList.remove('settings-search-highlight'); + highlightedEl = null; + }, 1800); + }; + + // First attempt next frame so the freshly-mounted tab content is in DOM. + const raf = window.requestAnimationFrame(tryHighlight); + + // Do NOT reset pendingHighlight here — that would retrigger this effect + // and the cleanup below would strip the class right after we added it. return () => { cancelled = true; - window.cancelAnimationFrame(r1); + window.cancelAnimationFrame(raf); + if (retryTimer) clearTimeout(retryTimer); if (cleanupTimer) clearTimeout(cleanupTimer); if (highlightedEl) highlightedEl.classList.remove('settings-search-highlight'); }; diff --git a/app/globals.css b/app/globals.css index 1f43d7da..da7adf0a 100644 --- a/app/globals.css +++ b/app/globals.css @@ -766,9 +766,31 @@ body { cursor: col-resize; } -/* Brief flash applied to a setting row when the user clicks a sub-result in - the settings search. The page removes the class after ~2s. */ +/* Smooth halo applied to a setting row when the user clicks a sub-result in + the settings search. The element bg + a 10px box-shadow fill the row plus + a 10px ring of the same tint, then a 2px outline sits exactly at the outer + edge. Uses the primary theme color so it adapts to light/dark. */ +@keyframes settings-search-pulse { + 0% { + background-color: color-mix(in srgb, var(--color-primary) 0%, transparent); + box-shadow: 0 0 0 0 color-mix(in srgb, var(--color-primary) 0%, transparent); + outline-color: color-mix(in srgb, var(--color-primary) 0%, transparent); + } + 30% { + background-color: color-mix(in srgb, var(--color-primary) 12%, transparent); + box-shadow: 0 0 0 10px color-mix(in srgb, var(--color-primary) 12%, transparent); + outline-color: color-mix(in srgb, var(--color-primary) 60%, transparent); + } + 100% { + background-color: color-mix(in srgb, var(--color-primary) 0%, transparent); + box-shadow: 0 0 0 10px color-mix(in srgb, var(--color-primary) 0%, transparent); + outline-color: color-mix(in srgb, var(--color-primary) 0%, transparent); + } +} + .settings-search-highlight { - background-color: color-mix(in srgb, var(--color-primary) 18%, transparent); - box-shadow: 0 0 0 2px var(--color-primary); + outline: 2px solid transparent; + outline-offset: 10px; + border-radius: 8px; + animation: settings-search-pulse 1.6s ease-in-out forwards; } diff --git a/components/settings/settings-section.tsx b/components/settings/settings-section.tsx index 23b9018a..ffba0c5d 100644 --- a/components/settings/settings-section.tsx +++ b/components/settings/settings-section.tsx @@ -12,7 +12,7 @@ interface SettingsSectionProps { export function SettingsSection({ title, description, children, experimental, experimentalDescription }: SettingsSectionProps) { return ( -
+
{experimental && (
@@ -46,7 +46,7 @@ export function SettingItem({ label, description, children, locked }: SettingIte return (