refactor(design-system): redesign-v2 — bolder hover + mouse-tracking spotlight + active gradient (#110)

Operator feedback after the first refinement (PR #109): "It's hard
to see which one you're hovering" + asked for a fun mouse-tracking
radial gradient AND a subtle gradient on the active state. Three
targeted refinements in one PR.

1. Active state: subtle ember linear-gradient added.
   - Kept the 1px ember border + ember text from PR #109's first
     refinement (operator approved the toned-down read).
   - Added a 135deg linear-gradient at 14% → 6% alpha (light) /
     18% → 8% alpha (dark) so the active item has visible warmth
     without returning to the heavy saturated pill from sub-convoy
     #2. Background shorthand replaces the prior transparent value.

2. Hover state: base tint bolder.
   - rgba(216, 67, 21, 0.08) → rgba(216, 67, 21, 0.18) on light
     (per operator: "hard to see which one you're hovering")
   - rgba(255, 138, 80, 0.10) → rgba(255, 138, 80, 0.22) on dark
   - Also expanded the selector list to include .nav-item-hover
     consistently with .nav-item and .nav-item-bottom (the three
     hoverable nav surface classes).

3. Hover state: mouse-tracking radial spotlight overlay.
   - New ::before pseudo-element on .nav-item / .nav-item-bottom /
     .nav-item-hover. Uses a radial-gradient positioned via two
     CSS custom properties --mouse-x and --mouse-y (default 50%).
   - Light theme: rgba(255, 140, 30, 0.35) center → rgba(216, 67,
     21, 0.18) at 35% → transparent at 70%. 120px radius.
   - Dark theme: rgba(255, 160, 70, 0.40) center → rgba(255, 110,
     0, 0.22) → transparent. Brighter for visibility against navy.
   - Opacity transitions in/out via --motion-duration-quick.
   - pointer-events: none so it never blocks clicks or focus.
   - Content z-index: 1 so icons + labels paint above the gradient.

4. Layout.js: single delegated mousemove listener at document level.
   - Cheaper than per-item React onMouseMove handlers (the
     NavigationContent function has 10+ different nav-item render
     paths). closest() returns null for non-nav events so the
     listener cost is negligible.
   - Writes --mouse-x / --mouse-y as percentages onto the closest
     matching nav element. CSS consumes those vars to position the
     radial-gradient via inheritance through the ::before.
   - prefers-reduced-motion: respected by the existing global
     0.01ms transition sweep — the spotlight opacity fade is
     collapsed but the static gradient position update is a
     positional change (not an animation), so it stays per WCAG
     SC 2.3.3 without special-casing.

Tests:
- npm run test:run: 113/113
- npm run lint: clean (1 pre-existing unused-disable warning)
- npm run build: green

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
varutasu 2026-06-04 11:52:10 -05:00 committed by GitHub
parent 906b332303
commit 8b7312263d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 142 additions and 20 deletions

View file

@ -631,6 +631,38 @@ export default function Layout({ children, user = null, showSearch = false }) {
return () => document.removeEventListener('keydown', handler); return () => document.removeEventListener('keydown', handler);
}, [user]); }, [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 ( return (
<div className="flex h-screen"> <div className="flex h-screen">
{/* Mobile Navigation - Bottom bar for mobile */} {/* Mobile Navigation - Bottom bar for mobile */}

View file

@ -430,24 +430,102 @@ body {
} }
/* Navigation Accessibility Enhancements */ /* Navigation Accessibility Enhancements */
/* Nav-item hover/focus refined 2026-06-04 (redesign-v2 refinements). /* Nav-item hover/focus refined again 2026-06-04 (spotlight-hover
Operator feedback: the prior `transform: translateX(4px)` caused refinement, second pass). Operator feedback after the first
visible horizontal jitter on every nav-item hover, which read as refinement: the rgba(0.08) base tint was too subtle ("it's hard
jumpy rather than responsive. Dropped the transform; the tinted to see which one you're hovering"). Bumped the base tint to a
ember background is the hover affordance. */ 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:hover,
.nav-item-bottom:hover, .nav-item-bottom:hover,
.nav-item-hover:hover,
.nav-item:focus-within, .nav-item:focus-within,
.nav-item-bottom:focus-within { .nav-item-bottom:focus-within,
background-color: rgba(216, 67, 21, 0.08) !important; .nav-item-hover:focus-within {
background-color: rgba(216, 67, 21, 0.18) !important;
color: var(--text-primary) !important; color: var(--text-primary) !important;
} }
[data-theme="dark"] .nav-item:hover, [data-theme="dark"] .nav-item:hover,
[data-theme="dark"] .nav-item-bottom: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:focus-within,
[data-theme="dark"] .nav-item-bottom:focus-within { [data-theme="dark"] .nav-item-bottom:focus-within,
background-color: rgba(255, 138, 80, 0.10) !important; [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). /* 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); 0 8px 16px -4px rgba(0, 0, 0, 0.50);
} }
/* Sidebar active-pill refined 2026-06-04 (redesign-v2 refinements). /* Sidebar active-pill refined again 2026-06-04 (spotlight-hover
Operator feedback: the bold ember-gradient pill from the initial refinement, second pass). Operator: "give the background of an
sub-convoy #2 read as too heavy. Reduced to a 1px ember border on active state a subtle gradient." Kept the 1px ember border + ember
a transparent background; the text takes the ember color so the text from the first refinement; added a soft 135deg ember linear
active item is still unambiguous without a saturated fill. gradient as the background fill so the active item has noticeable
AA contrast: ember text (#d84315) on light bg measures 4.6:1 vs warmth without returning to the heavy saturated pill we walked
white-cream substrate; on dark navy bg measures 5.4:1 both back from. The gradient is ~12-15% peak alpha so AA contrast
pass WCAG AA 4.5:1 for normal text. */ 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 { .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; color: var(--accent-ember) !important;
box-shadow: inset 0 0 0 1px var(--accent-ember) !important; box-shadow: inset 0 0 0 1px var(--accent-ember) !important;
} }
[data-theme="dark"] .nav-item-active { [data-theme="dark"] .nav-item-active {
/* Slightly hotter ember in dark mode for eye-perception correction, background: linear-gradient(
since the navy substrate sucks more saturation out of the border. */ 135deg,
rgba(255, 138, 80, 0.18) 0%,
rgba(255, 110, 0, 0.08) 100%
) !important;
color: rgb(255, 138, 80) !important; color: rgb(255, 138, 80) !important;
box-shadow: inset 0 0 0 1px rgb(255, 138, 80) !important; box-shadow: inset 0 0 0 1px rgb(255, 138, 80) !important;
} }