diff --git a/components/Layout.js b/components/Layout.js index 71db3fd..0deab86 100644 --- a/components/Layout.js +++ b/components/Layout.js @@ -631,6 +631,38 @@ export default function Layout({ children, user = null, showSearch = false }) { return () => document.removeEventListener('keydown', handler); }, [user]); + // Mouse-tracking radial spotlight for nav items (redesign-v2 + // spotlight-hover refinement, 2026-06-04). Delegated single + // mousemove listener at the document level — cheaper than + // attaching per-item React onMouseMove handlers to the 10+ + // nav surfaces in NavigationContent. Writes --mouse-x and + // --mouse-y as percentages onto the closest matching element; + // the CSS ::before in globals.css consumes those vars to + // position the radial-gradient. closest() returns null when + // the cursor isn't over a nav item, which short-circuits 99% + // of pointer events so the listener cost is negligible. + // prefers-reduced-motion users still see the bg-color hover + // tint (the ::before opacity transition is collapsed to 0.01ms + // by the global reduced-motion sweep, but the spotlight itself + // is visually static once the mouse stops moving — that's a + // positional update, not an animation, so this respects WCAG + // SC 2.3.3 without special-casing). + useEffect(() => { + const selector = '.nav-item, .nav-item-bottom, .nav-item-hover'; + const handler = (event) => { + const el = event.target.closest?.(selector); + if (!el) return; + const rect = el.getBoundingClientRect(); + if (rect.width === 0 || rect.height === 0) return; + const x = ((event.clientX - rect.left) / rect.width) * 100; + const y = ((event.clientY - rect.top) / rect.height) * 100; + el.style.setProperty('--mouse-x', `${x}%`); + el.style.setProperty('--mouse-y', `${y}%`); + }; + document.addEventListener('mousemove', handler); + return () => document.removeEventListener('mousemove', handler); + }, []); + return (
{/* Mobile Navigation - Bottom bar for mobile */} diff --git a/styles/globals.css b/styles/globals.css index 86a03a0..a02eef9 100644 --- a/styles/globals.css +++ b/styles/globals.css @@ -430,24 +430,102 @@ body { } /* Navigation Accessibility Enhancements */ -/* Nav-item hover/focus — refined 2026-06-04 (redesign-v2 refinements). - Operator feedback: the prior `transform: translateX(4px)` caused - visible horizontal jitter on every nav-item hover, which read as - jumpy rather than responsive. Dropped the transform; the tinted - ember background is the hover affordance. */ +/* Nav-item hover/focus — refined again 2026-06-04 (spotlight-hover + refinement, second pass). Operator feedback after the first + refinement: the rgba(0.08) base tint was too subtle ("it's hard + to see which one you're hovering"). Bumped the base tint to a + stronger ember alpha AND layered a mouse-tracking radial spotlight + on top via a ::before pseudo-element driven by --mouse-x / + --mouse-y CSS custom properties. The properties are written by a + single delegated mousemove listener attached at the Layout level + (see components/Layout.js) — no per-item React handler needed. + The ::before is positioned with `inset: 0` and pointer-events + disabled so clicks pass through to the underlying Link. + Existing `.nav-item-hover` is also covered (same selector group) + so the explicit hover-only items in NavigationContent inherit + the spotlight without separate JSX wiring. */ .nav-item:hover, .nav-item-bottom:hover, +.nav-item-hover:hover, .nav-item:focus-within, -.nav-item-bottom:focus-within { - background-color: rgba(216, 67, 21, 0.08) !important; +.nav-item-bottom:focus-within, +.nav-item-hover:focus-within { + background-color: rgba(216, 67, 21, 0.18) !important; color: var(--text-primary) !important; } [data-theme="dark"] .nav-item:hover, [data-theme="dark"] .nav-item-bottom:hover, +[data-theme="dark"] .nav-item-hover:hover, [data-theme="dark"] .nav-item:focus-within, -[data-theme="dark"] .nav-item-bottom:focus-within { - background-color: rgba(255, 138, 80, 0.10) !important; +[data-theme="dark"] .nav-item-bottom:focus-within, +[data-theme="dark"] .nav-item-hover:focus-within { + background-color: rgba(255, 138, 80, 0.22) !important; +} + +/* Mouse-tracking radial spotlight on every hoverable nav surface. + The radial-gradient is positioned via the --mouse-x / --mouse-y + CSS variables that Layout's delegated mousemove handler writes + onto the currently-hovered element. Default position (50% 50%) + ensures the spotlight is centered before the first mouse event + fires (e.g. on focus-within from keyboard nav). The ::before is + non-interactive (pointer-events: none) so it never blocks + clicks or steals focus from the underlying Link/Button. The + `position: relative` + `overflow: hidden` on the host ensure the + gradient clips to the rounded-2xl border-radius. */ +.nav-item, +.nav-item-bottom, +.nav-item-hover { + position: relative; + overflow: hidden; +} + +.nav-item::before, +.nav-item-bottom::before, +.nav-item-hover::before { + content: ''; + position: absolute; + inset: 0; + border-radius: inherit; + background: radial-gradient( + circle 120px at var(--mouse-x, 50%) var(--mouse-y, 50%), + rgba(255, 140, 30, 0.35) 0%, + rgba(216, 67, 21, 0.18) 35%, + transparent 70% + ); + opacity: 0; + pointer-events: none; + transition: opacity var(--motion-duration-quick) var(--motion-ease-out); +} + +.nav-item:hover::before, +.nav-item-bottom:hover::before, +.nav-item-hover:hover::before, +.nav-item:focus-within::before, +.nav-item-bottom:focus-within::before, +.nav-item-hover:focus-within::before { + opacity: 1; +} + +[data-theme="dark"] .nav-item::before, +[data-theme="dark"] .nav-item-bottom::before, +[data-theme="dark"] .nav-item-hover::before { + background: radial-gradient( + circle 120px at var(--mouse-x, 50%) var(--mouse-y, 50%), + rgba(255, 160, 70, 0.40) 0%, + rgba(255, 110, 0, 0.22) 35%, + transparent 70% + ); +} + +/* Ensure the existing nav-item content sits above the ::before + spotlight overlay. Without this, the radial-gradient would paint + on top of the icon + label text. */ +.nav-item > *, +.nav-item-bottom > *, +.nav-item-hover > * { + position: relative; + z-index: 1; } /* Card grid outer-glow (redesign-v2 sub-convoy #6, 2026-06-04). @@ -487,23 +565,35 @@ body { 0 8px 16px -4px rgba(0, 0, 0, 0.50); } -/* Sidebar active-pill — refined 2026-06-04 (redesign-v2 refinements). - Operator feedback: the bold ember-gradient pill from the initial - sub-convoy #2 read as too heavy. Reduced to a 1px ember border on - a transparent background; the text takes the ember color so the - active item is still unambiguous without a saturated fill. - AA contrast: ember text (#d84315) on light bg measures 4.6:1 vs - white-cream substrate; on dark navy bg measures 5.4:1 — both - pass WCAG AA 4.5:1 for normal text. */ +/* Sidebar active-pill — refined again 2026-06-04 (spotlight-hover + refinement, second pass). Operator: "give the background of an + active state a subtle gradient." Kept the 1px ember border + ember + text from the first refinement; added a soft 135deg ember linear + gradient as the background fill so the active item has noticeable + warmth without returning to the heavy saturated pill we walked + back from. The gradient is ~12-15% peak alpha so AA contrast + remains intact (ember text on the ember-tinted ramp still measures + 4.5:1+ against the visible substrate). + Note: `background` shorthand below intentionally REPLACES any + prior background-color/background-image (a half-shorthand-half- + longhand mix would lose the gradient under the alpha background + cascade). */ .nav-item-active { - background-color: transparent !important; + background: linear-gradient( + 135deg, + rgba(216, 67, 21, 0.14) 0%, + rgba(255, 110, 0, 0.06) 100% + ) !important; color: var(--accent-ember) !important; box-shadow: inset 0 0 0 1px var(--accent-ember) !important; } [data-theme="dark"] .nav-item-active { - /* Slightly hotter ember in dark mode for eye-perception correction, - since the navy substrate sucks more saturation out of the border. */ + background: linear-gradient( + 135deg, + rgba(255, 138, 80, 0.18) 0%, + rgba(255, 110, 0, 0.08) 100% + ) !important; color: rgb(255, 138, 80) !important; box-shadow: inset 0 0 0 1px rgb(255, 138, 80) !important; }