fix(landing): keep the header from overflowing at mid widths (#791)

## What

The Roadmap item pushed the landing header past the viewport on narrow
laptops: the logo wrapped onto two lines and, in Spanish, the register
button was clipped off screen (at 640px the nav needed 670px).

Between `sm` and `lg` the nav is now icon-only and tighter:

- Roadmap / Github / Discord keep their labels only from `lg` up. The
label stays `sr-only` instead of `display:none`, so the icons keep their
accessible name — before this they announced as "link", "link 1096",
"link". The GitHub star count stays visible at every width.
- Button padding, nav gap and the wordmark size all step down below
`lg`, which is what buys back the room Spanish needs. At 640px the nav
now ends on the 24px page padding instead of 10px past it, with 22px of
air between logo and nav.
- The logo gets `shrink-0` + `whitespace-nowrap`, so it can no longer
wrap.

## Also

- The logo links back to the landing page on every page but the landing
itself (the page is detected by URL, so `?ref=` links behave). Before
this, a phone visitor on `/roadmap` had no way back other than the
browser's back button.
- The mobile pill header drops its Github/Discord buttons and separator:
they were `hidden … sm:flex` inside a `sm:hidden` header, so they could
never render.
- The three nav buttons collapse into a local `NavButton`, so the
breakpoint lives in one place instead of three.

## QA

Browser QA against the dev server on `/` and `/roadmap`, in Spanish (the
longest of our strings) and English, at 390 / 640 / 672 / 1024 / 1280:

- 640px es: no horizontal overflow, right padding intact, nothing
clipped.
- 1024px+: labels are back, layout unchanged from today.
- 390px: the mobile pill is untouched.
- Logo click on `/roadmap` navigates to `/`; on `/` it renders as a
plain `div` in both headers.

Unit tests cover the logo routing branch. The responsive band itself is
visual-QA only — jsdom cannot evaluate Tailwind breakpoints, and a
className assertion would be brittle.

## Demo

<!-- PLACEHOLDER: drag the screenshots here (390 / 640 / 672 / 1280,
Spanish) -->
This commit is contained in:
Víctor Falcón 2026-08-12 15:47:03 +02:00 committed by GitHub
parent df79f1b77d
commit 98f03db50c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 98 additions and 91 deletions

View File

@ -1,10 +1,11 @@
import { dashboard } from '@/routes';
import { dashboard, home } from '@/routes';
import { render, screen } from '@testing-library/react';
import type React from 'react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import Header from './header';
const mocks = vi.hoisted(() => ({
url: '/roadmap',
pageProps: {
auth: {
user: {
@ -24,11 +25,12 @@ vi.mock('@inertiajs/react', () => ({
children: React.ReactNode;
href: string | { url: string };
}) => <a href={typeof href === 'string' ? href : href.url}>{children}</a>,
usePage: () => ({ props: mocks.pageProps }),
usePage: () => ({ url: mocks.url, props: mocks.pageProps }),
}));
describe('Header', () => {
beforeEach(() => {
mocks.url = '/roadmap';
vi.stubGlobal(
'fetch',
vi.fn(() => new Promise(() => {})),
@ -45,4 +47,26 @@ describe('Header', () => {
expect(dashboardLinks).toHaveLength(2);
expect(dashboardLinks[0]?.getAttribute('href')).toBe(dashboard().url);
});
it('links the logo home on every page but the landing one', () => {
render(<Header />);
const logoLinks = screen.getAllByRole('link', {
name: 'Whisper Money',
});
expect(logoLinks).toHaveLength(2);
expect(logoLinks[0]?.getAttribute('href')).toBe(home().url);
});
it('does not link the logo on the landing page', () => {
mocks.url = '/?ref=newsletter';
render(<Header />);
expect(screen.getAllByText('Whisper Money')).toHaveLength(2);
expect(
screen.queryByRole('link', { name: 'Whisper Money' }),
).toBeNull();
});
});

View File

@ -1,9 +1,10 @@
import { dashboard, login, roadmap } from '@/routes';
import { cn, resolveUrl } from '@/lib/utils';
import { dashboard, home, login, roadmap } from '@/routes';
import { type SharedData } from '@/types';
import { __ } from '@/utils/i18n';
import { Link, usePage } from '@inertiajs/react';
import { BirdIcon, Github, LogIn, MapIcon, StarIcon } from 'lucide-react';
import { useEffect, useState } from 'react';
import { useEffect, useState, type ReactNode } from 'react';
import DiscordIcon from '../icons/DiscordIcon';
import { Button } from '../ui/button';
import { Separator } from '../ui/separator';
@ -27,6 +28,56 @@ function useGitHubStars(): number | null {
return stars;
}
/**
* Below `lg` the nav only has room for the icons, so the label stays for
* screen readers instead of disappearing with the text.
*/
function NavButton({
label,
badge,
children,
}: {
label: string;
badge?: ReactNode;
children: ReactNode;
}) {
return (
<Button
variant={'ghost'}
className="cursor-pointer px-2 opacity-70 transition-all duration-200 hover:opacity-100 lg:px-4"
>
{children}
<span className="sr-only lg:not-sr-only">{label}</span>
{badge}
</Button>
);
}
/**
* The logo links back to the landing page, except on the landing page itself.
*/
function Logo({ className }: { className?: string }) {
const { url } = usePage();
const isLandingPage = url.split('?')[0] === resolveUrl(home());
const classes = cn('flex shrink-0 items-center font-mono', className);
const content = (
<>
<BirdIcon className="size-5 text-[#1b1b18] dark:text-[#EDEDEC]" />
<span className="font-medium whitespace-nowrap">Whisper Money</span>
</>
);
if (isLandingPage) {
return <div className={classes}>{content}</div>;
}
return (
<Link href={home()} className={classes}>
{content}
</Link>
);
}
type Props = {
canRegister?: boolean;
hideExternalButtons?: boolean;
@ -43,59 +94,8 @@ export default function Header({
<>
{/* Mobile pill header */}
<header className="fixed top-4 right-4 left-4 z-50 flex items-center justify-between rounded-full border border-border/50 bg-background/70 px-4 py-3.5 shadow-lg shadow-black/10 backdrop-blur-xl sm:hidden dark:border-border/30 dark:shadow-black/30">
<div className="flex shrink-0 items-center gap-2.5 font-mono">
<BirdIcon className="size-5 text-[#1b1b18] dark:text-[#EDEDEC]" />
<span className="text-sm font-medium whitespace-nowrap">
Whisper Money
</span>
</div>
<Logo className="gap-2.5 text-sm" />
<nav className="flex items-center gap-2">
{!hideExternalButtons && (
<>
<a
href="https://github.com/whisper-money/whisper-money"
target="_blank"
rel="noopener noreferrer"
>
<Button
variant={'ghost'}
className="hidden cursor-pointer opacity-70 transition-all duration-200 hover:opacity-100 sm:flex"
>
<Github className="size-5" />
<span className="hidden sm:inline">
{__('Github')}
</span>
{stars !== null && (
<span className="flex items-center gap-1 rounded-full bg-muted px-1.5 py-0.5 text-xs font-medium">
<StarIcon className="size-3 fill-amber-400 text-amber-400" />
{stars}
</span>
)}
</Button>
</a>
<a
href="https://discord.gg/m8hUhx6D9D"
target="_blank"
rel="noopener noreferrer"
>
<Button
variant={'ghost'}
className="hidden cursor-pointer opacity-70 transition-all duration-200 hover:opacity-100 sm:flex"
>
<DiscordIcon className="size-5" />
<span className="hidden sm:inline">
Discord
</span>
</Button>
</a>
</>
)}
{!hideExternalButtons && (
<Separator
orientation="vertical"
className="hidden data-[orientation=vertical]:h-6 data-[orientation=vertical]:w-[1px] data-[orientation=vertical]:bg-border sm:block"
/>
)}
{auth.user ? (
<Link href={dashboard()}>
<Button
@ -135,59 +135,42 @@ export default function Header({
{/* Desktop header */}
<header className="fixed top-0 z-50 hidden w-full bg-background/5 backdrop-blur-lg sm:block">
<div className="mx-auto flex max-w-6xl items-center justify-between px-6 py-4 lg:py-6">
<div className="flex items-center gap-4 font-mono">
<BirdIcon className="size-5 text-[#1b1b18] dark:text-[#EDEDEC]" />
<span className="font-medium">Whisper Money</span>
</div>
<nav className="flex items-center gap-4">
<Logo className="gap-2 text-sm lg:gap-4 lg:text-base" />
<nav className="flex items-center gap-1 lg:gap-4">
{!hideExternalButtons && (
<>
<Link href={roadmap()}>
<Button
variant={'ghost'}
className="hidden cursor-pointer opacity-70 transition-all duration-200 hover:opacity-100 sm:flex"
>
<NavButton label={__('Roadmap')}>
<MapIcon className="size-5" />
<span className="hidden sm:inline">
{__('Roadmap')}
</span>
</Button>
</NavButton>
</Link>
<a
href="https://github.com/whisper-money/whisper-money"
target="_blank"
rel="noopener noreferrer"
>
<Button
variant={'ghost'}
className="hidden cursor-pointer opacity-70 transition-all duration-200 hover:opacity-100 sm:flex"
<NavButton
label={__('Github')}
badge={
stars !== null && (
<span className="flex items-center gap-1 rounded-full bg-muted px-1.5 py-0.5 text-xs font-medium">
<StarIcon className="size-3 fill-amber-400 text-amber-400" />
{stars}
</span>
)
}
>
<Github className="size-5" />
<span className="hidden sm:inline">
{__('Github')}
</span>
{stars !== null && (
<span className="flex items-center gap-1 rounded-full bg-muted px-1.5 py-0.5 text-xs font-medium">
<StarIcon className="size-3 fill-amber-400 text-amber-400" />
{stars}
</span>
)}
</Button>
</NavButton>
</a>
<a
href="https://discord.gg/m8hUhx6D9D"
target="_blank"
rel="noopener noreferrer"
>
<Button
variant={'ghost'}
className="hidden cursor-pointer opacity-70 transition-all duration-200 hover:opacity-100 sm:flex"
>
<NavButton label={__('Discord')}>
<DiscordIcon className="size-5" />
<span className="hidden sm:inline">
{__('Discord')}
</span>
</Button>
</NavButton>
</a>
</>
)}
@ -208,7 +191,7 @@ export default function Header({
<Link href={login()}>
<Button
variant={'ghost'}
className="cursor-pointer"
className="cursor-pointer px-3 lg:px-4"
>
{__('Log in')}
</Button>