refactor(design-system): redesign-v2 refinements — tone down active/hover states + dedupe profile + seamless header
Operator feedback after the redesign-v2 epic shipped (PRs #102-#108): the bold ember-gradient pill, the left-shifting hover, the duplicate profile dropdown, and the divider below the header all read too heavy. Four targeted refinements in one PR. 1. Move profile from sidebar bottom → TopSearchBar user-menu chip (top-right). The chip already existed (sub-convoy #3, PR #105); the sidebar's UserProfileDropdown was redundant. Removed from BOTH desktop sidebar and mobile drawer. Kept for logged-out visitors only (the top bar renders null when user is null, so the sidebar still surfaces the auth path via the existing Sign-in CTA branch). 2. Active state: bold ember-gradient pill → 1px ember border on transparent background. - styles/globals.css .nav-item-active: dropped the linear-gradient + 3-stop box-shadow glow. Now: transparent bg, accent-ember text color, inset 0 0 0 1px var(--accent-ember). - Dark theme variant uses a slightly hotter ember (rgb(255,138,80)) for eye-perception correction against the deep-navy substrate. AA contrast measured: 5.4:1 on dark navy bg, 4.6:1 on light cream bg — both pass 4.5:1 normal- text threshold. 3. Hover state: left-shifting border + transform → static transparent ember-tinted background. - Removed `border-left: 3px solid var(--accent-flame)` + `padding-left: calc(1rem - 3px)` on .nav-item-hover:hover (and focus-within). These were causing the 3px-width shift the operator called "movement with the left align." - Removed `transform: translateX(4px)` on .nav-item:hover and .nav-item-bottom:hover — the horizontal-jitter the operator also flagged. - Both classes now apply a flat `background-color: rgba(216, 67, 21, 0.08)` (light) / `rgba(255, 138, 80, 0.10)` (dark) on hover/focus-within with zero geometry shift. 4. TopSearchBar bottom divider removed. - styles change in components/ui/TopSearchBar.js: dropped the `0 1px 0 var(--border)` segment from the box-shadow composition. The rim-light-inner top highlight stays so the bar still reads as elevated chrome against the gradient body, but there's no longer a hairline below — page content flows visually seamlessly out of the header. Test fix: - test/components/Layout.test.js test #4 ("renders the supplied user email") asserted the FULL email `foo@bar.com`. The sidebar UserProfileDropdown used to render that; the TopSearchBar chip renders the username (or email's local-part as fallback) — `'foo'` for `foo@bar.com`. The assertion now checks for `'foo'` + retains the maintainer-email negative check. Renamed the test to "flows the supplied user through to the rendered surface (TopSearchBar chip)" with an inline comment explaining the shift; the three other P0 #7 regression-lock cases are unchanged and still pass. 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:
parent
f701c4ac89
commit
b41097b265
4 changed files with 83 additions and 68 deletions
|
|
@ -717,13 +717,19 @@ export default function Layout({ children, user = null, showSearch = false }) {
|
||||||
/>
|
/>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{/* Mobile Bottom Section */}
|
{/* Mobile Bottom Section.
|
||||||
|
redesign-v2 refinements 2026-06-04: UserProfileDropdown
|
||||||
|
removed for authenticated users (the TopSearchBar's
|
||||||
|
user-menu chip is the canonical entry point now). For
|
||||||
|
logged-out visitors, the Sign-in CTA still renders here
|
||||||
|
so the drawer surfaces the auth path. */}
|
||||||
<div className="mt-auto space-y-2">
|
<div className="mt-auto space-y-2">
|
||||||
{/* User Profile Dropdown */}
|
{!user && (
|
||||||
<UserProfileDropdown
|
<UserProfileDropdown
|
||||||
user={user}
|
user={user}
|
||||||
onMobileMenuClose={() => setIsMobileMenuOpen(false)}
|
onMobileMenuClose={() => setIsMobileMenuOpen(false)}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Icon Buttons Row - Support and Dark Mode */}
|
{/* Icon Buttons Row - Support and Dark Mode */}
|
||||||
<div className="flex justify-center space-x-4 pt-2">
|
<div className="flex justify-center space-x-4 pt-2">
|
||||||
|
|
@ -829,20 +835,22 @@ export default function Layout({ children, user = null, showSearch = false }) {
|
||||||
/>
|
/>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{/* Desktop Bottom Section */}
|
{/* Desktop Bottom Section.
|
||||||
|
redesign-v2 refinements 2026-06-04: the UserProfileDropdown
|
||||||
|
that used to live here has moved to the TopSearchBar's
|
||||||
|
user-menu chip (top-right). Keeping both was redundant.
|
||||||
|
For logged-out visitors, render the Sign-in CTA here so
|
||||||
|
the sidebar still surfaces the auth path (the top bar
|
||||||
|
renders null when user is null). */}
|
||||||
<div className="mt-auto space-y-3">
|
<div className="mt-auto space-y-3">
|
||||||
{/* Daily Ember Widget — gamification card from the
|
|
||||||
redesign-v2 mockup. Only rendered for authenticated
|
|
||||||
users (the hook returns demo data until the real
|
|
||||||
backend ships; an unauthenticated user has no
|
|
||||||
meaningful "today" to track). */}
|
|
||||||
{user && <DailyEmberWidget />}
|
{user && <DailyEmberWidget />}
|
||||||
|
|
||||||
{/* User Profile Dropdown */}
|
{!user && (
|
||||||
<UserProfileDropdown
|
<UserProfileDropdown
|
||||||
user={user}
|
user={user}
|
||||||
onMobileMenuClose={() => {}}
|
onMobileMenuClose={() => {}}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Icon Buttons Row - Support and Dark Mode */}
|
{/* Icon Buttons Row - Support and Dark Mode */}
|
||||||
<div className="flex justify-center space-x-4 pt-2">
|
<div className="flex justify-center space-x-4 pt-2">
|
||||||
|
|
|
||||||
|
|
@ -67,8 +67,12 @@ export default function TopSearchBar({
|
||||||
background: 'var(--glass-surface-mid)',
|
background: 'var(--glass-surface-mid)',
|
||||||
backdropFilter: 'blur(12px) saturate(180%)',
|
backdropFilter: 'blur(12px) saturate(180%)',
|
||||||
WebkitBackdropFilter: 'blur(12px) saturate(180%)',
|
WebkitBackdropFilter: 'blur(12px) saturate(180%)',
|
||||||
boxShadow:
|
// The hairline border-bottom (`0 1px 0 var(--border)`) was
|
||||||
'var(--rim-light-inner), 0 1px 0 var(--border)',
|
// removed 2026-06-04 per operator feedback ("almost looks
|
||||||
|
// seamless between that header and the rest of that page
|
||||||
|
// content"). The rim-light-inner highlight stays so the bar
|
||||||
|
// still reads as elevated chrome against the gradient body.
|
||||||
|
boxShadow: 'var(--rim-light-inner)',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Search affordance — read-only-ish input that opens the
|
{/* Search affordance — read-only-ish input that opens the
|
||||||
|
|
|
||||||
|
|
@ -430,18 +430,24 @@ body {
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Navigation Accessibility Enhancements */
|
/* 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,
|
.nav-item:hover,
|
||||||
.nav-item-bottom:hover {
|
.nav-item-bottom:hover,
|
||||||
background-color: var(--bg-tertiary) !important;
|
|
||||||
color: var(--text-primary) !important;
|
|
||||||
transform: translateX(4px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-item:focus-within,
|
.nav-item:focus-within,
|
||||||
.nav-item-bottom:focus-within {
|
.nav-item-bottom:focus-within {
|
||||||
background-color: var(--bg-tertiary) !important;
|
background-color: rgba(216, 67, 21, 0.08) !important;
|
||||||
color: var(--text-primary) !important;
|
color: var(--text-primary) !important;
|
||||||
transform: translateX(4px);
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] .nav-item:hover,
|
||||||
|
[data-theme="dark"] .nav-item-bottom: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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Card grid outer-glow (redesign-v2 sub-convoy #6, 2026-06-04).
|
/* Card grid outer-glow (redesign-v2 sub-convoy #6, 2026-06-04).
|
||||||
|
|
@ -481,52 +487,40 @@ body {
|
||||||
0 8px 16px -4px rgba(0, 0, 0, 0.50);
|
0 8px 16px -4px rgba(0, 0, 0, 0.50);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Sidebar active-pill (redesign-v2 sub-convoy #2, 2026-06-04).
|
/* Sidebar active-pill — refined 2026-06-04 (redesign-v2 refinements).
|
||||||
The operator's mockup shows the active nav item as a bold rounded-2xl
|
Operator feedback: the bold ember-gradient pill from the initial
|
||||||
pill with an ember-orange gradient fill and a soft outer ember glow.
|
sub-convoy #2 read as too heavy. Reduced to a 1px ember border on
|
||||||
This replaces the prior 3px border-left + bg-tertiary fill treatment,
|
a transparent background; the text takes the ember color so the
|
||||||
which read as "subtle indicator" rather than "active page." The class
|
active item is still unambiguous without a saturated fill.
|
||||||
is applied to the same nav <div> the prior pattern targeted, so the
|
AA contrast: ember text (#d84315) on light bg measures 4.6:1 vs
|
||||||
call-site JSX changes are minimal: drop the `style={{ backgroundColor:
|
white-cream substrate; on dark navy bg measures 5.4:1 — both
|
||||||
... }}` overrides and let the class do the work.
|
pass WCAG AA 4.5:1 for normal text. */
|
||||||
AA contrast: white text on the ember gradient measures 4.8:1 (light)
|
|
||||||
and 6.2:1 (dark) — both >= the 4.5 WCAG AA threshold for normal text. */
|
|
||||||
.nav-item-active {
|
.nav-item-active {
|
||||||
background: linear-gradient(
|
background-color: transparent !important;
|
||||||
135deg,
|
color: var(--accent-ember) !important;
|
||||||
rgb(255, 110, 0) 0%,
|
box-shadow: inset 0 0 0 1px var(--accent-ember) !important;
|
||||||
rgb(216, 67, 21) 100%
|
|
||||||
) !important;
|
|
||||||
color: rgb(255, 255, 255) !important;
|
|
||||||
box-shadow:
|
|
||||||
0 4px 16px -2px rgba(255, 110, 0, 0.45),
|
|
||||||
0 0 0 1px rgba(255, 110, 0, 0.25),
|
|
||||||
inset 0 1px 0 rgba(255, 255, 255, 0.20);
|
|
||||||
/* The prior `border-left: 3px solid var(--accent-ember)` + matching
|
|
||||||
padding adjustment is intentionally removed; the gradient fill is
|
|
||||||
the active indicator now. */
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-theme="dark"] .nav-item-active {
|
[data-theme="dark"] .nav-item-active {
|
||||||
background: linear-gradient(
|
/* Slightly hotter ember in dark mode for eye-perception correction,
|
||||||
135deg,
|
since the navy substrate sucks more saturation out of the border. */
|
||||||
rgb(255, 95, 0) 0%,
|
color: rgb(255, 138, 80) !important;
|
||||||
rgb(216, 67, 21) 100%
|
box-shadow: inset 0 0 0 1px rgb(255, 138, 80) !important;
|
||||||
) !important;
|
|
||||||
box-shadow:
|
|
||||||
0 4px 18px -2px rgba(255, 110, 0, 0.55),
|
|
||||||
0 0 0 1px rgba(255, 110, 0, 0.35),
|
|
||||||
inset 0 1px 0 rgba(255, 255, 255, 0.16);
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-item-hover:hover {
|
|
||||||
border-left: 3px solid var(--accent-flame);
|
|
||||||
padding-left: calc(1rem - 3px);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Hover state — refined 2026-06-04. Operator feedback: the prior
|
||||||
|
border-left + padding-shift caused visible layout movement on
|
||||||
|
hover which felt jittery. Replaced with a subtle transparent
|
||||||
|
bg-tint that keeps the element in place. Same treatment for
|
||||||
|
focus-within so keyboard nav matches mouse. */
|
||||||
|
.nav-item-hover:hover,
|
||||||
.nav-item-hover:focus-within {
|
.nav-item-hover:focus-within {
|
||||||
border-left: 3px solid var(--accent-ember);
|
background-color: rgba(216, 67, 21, 0.08);
|
||||||
padding-left: calc(1rem - 3px);
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] .nav-item-hover:hover,
|
||||||
|
[data-theme="dark"] .nav-item-hover:focus-within {
|
||||||
|
background-color: rgba(255, 138, 80, 0.10);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* High contrast mode support */
|
/* High contrast mode support */
|
||||||
|
|
|
||||||
|
|
@ -63,11 +63,20 @@ describe('Layout — logged-out rendering (regression: P0 #7)', () => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders the supplied user email when user is an object', () => {
|
it('flows the supplied user through to the rendered surface (TopSearchBar chip)', () => {
|
||||||
|
// Pre-refinements (2026-06-04), the UserProfileDropdown in the
|
||||||
|
// sidebar displayed the full email. The redesign-v2 refinements
|
||||||
|
// moved the user-menu to the TopSearchBar's compact chip in the
|
||||||
|
// top-right, which displays the username (or the email's local
|
||||||
|
// part as a fallback) rather than the full address. The
|
||||||
|
// maintainer-email regression-lock from P0 #7 still passes via
|
||||||
|
// the three "does NOT render me@randallstillwell.com" cases
|
||||||
|
// above; this case continues to assert that the user prop FLOWS
|
||||||
|
// through, just against the new render surface.
|
||||||
const { container } = render(
|
const { container } = render(
|
||||||
<Layout user={{ email: 'foo@bar.com', role: 'user' }}>page body</Layout>
|
<Layout user={{ email: 'foo@bar.com', role: 'user' }}>page body</Layout>
|
||||||
);
|
);
|
||||||
expect(container.textContent).toContain('foo@bar.com');
|
expect(container.textContent).toContain('foo');
|
||||||
expect(container.textContent).not.toContain('me@randallstillwell.com');
|
expect(container.textContent).not.toContain('me@randallstillwell.com');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue