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>
90 lines
3.7 KiB
JavaScript
90 lines
3.7 KiB
JavaScript
// @vitest-environment jsdom
|
|
import { describe, it, expect, vi, afterEach } from 'vitest';
|
|
import { render, cleanup, screen } from '@testing-library/react';
|
|
|
|
// Mock next/router so useRouter() does not crash without a RouterContext.
|
|
// Layout reads `router.pathname` only; the rest of the surface (`prefetch`,
|
|
// `events`, `push`) is for next/link's internals — provide stubs so prefetch
|
|
// does not throw when <Link> mounts.
|
|
vi.mock('next/router', () => ({
|
|
useRouter: () => ({
|
|
pathname: '/',
|
|
asPath: '/',
|
|
query: {},
|
|
push: vi.fn(),
|
|
replace: vi.fn(),
|
|
prefetch: vi.fn().mockResolvedValue(undefined),
|
|
events: { on: vi.fn(), off: vi.fn(), emit: vi.fn() },
|
|
}),
|
|
}));
|
|
|
|
// Mock next/link to a plain <a>. The real next/link triggers prefetch on
|
|
// mount via the router; bypassing it removes a class of jsdom flake without
|
|
// changing the rendered DOM that the assertions inspect.
|
|
vi.mock('next/link', () => ({
|
|
__esModule: true,
|
|
default: ({ href, children, ...rest }) => {
|
|
return (
|
|
<a href={typeof href === 'string' ? href : ''} {...rest}>
|
|
{children}
|
|
</a>
|
|
);
|
|
},
|
|
}));
|
|
|
|
// Mock the theme context so useTheme() does not require a ThemeProvider.
|
|
vi.mock('../../lib/theme-context', () => ({
|
|
useTheme: () => ({ theme: 'light', toggleTheme: vi.fn() }),
|
|
}));
|
|
|
|
import Layout from '../../components/Layout';
|
|
|
|
describe('Layout — logged-out rendering (regression: P0 #7)', () => {
|
|
afterEach(() => cleanup());
|
|
|
|
it('does NOT render the maintainer email when no user prop is passed', () => {
|
|
const { container } = render(<Layout>page body</Layout>);
|
|
expect(container.textContent).not.toContain('me@randallstillwell.com');
|
|
});
|
|
|
|
it('does NOT render the maintainer email when user is null', () => {
|
|
const { container } = render(<Layout user={null}>page body</Layout>);
|
|
expect(container.textContent).not.toContain('me@randallstillwell.com');
|
|
});
|
|
|
|
it('renders a Sign-in link to /login when user is null', () => {
|
|
render(<Layout user={null}>page body</Layout>);
|
|
const links = screen.getAllByRole('link', { name: /sign in/i });
|
|
expect(links.length).toBeGreaterThanOrEqual(1);
|
|
// Both desktop sidebar + mobile drawer render UserProfileDropdown,
|
|
// so we expect TWO Sign-in links (one per copy).
|
|
for (const link of links) {
|
|
expect(link.getAttribute('href')).toBe('/login');
|
|
}
|
|
});
|
|
|
|
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(
|
|
<Layout user={{ email: 'foo@bar.com', role: 'user' }}>page body</Layout>
|
|
);
|
|
expect(container.textContent).toContain('foo');
|
|
expect(container.textContent).not.toContain('me@randallstillwell.com');
|
|
});
|
|
|
|
it('does NOT render a "Guest" placeholder when logged out', () => {
|
|
// Decision A says the logged-out copy is "Sign in", not "Guest".
|
|
// This test prevents a future revert that ships "Guest" as the default
|
|
// (which would still hide the maintainer email but skip the CTA).
|
|
const { container } = render(<Layout user={null}>page body</Layout>);
|
|
expect(container.textContent).not.toContain('Guest');
|
|
});
|
|
});
|