refactor(design-system): redesign-v2 — bolder hover + mouse-tracking spotlight + active gradient #110
2 changed files with 142 additions and 20 deletions
|
|
@ -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 */}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue