From 98f03db50c674cd9a8606de3536fc83d79bbb8ea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADctor=20Falc=C3=B3n?= Date: Wed, 12 Aug 2026 15:47:03 +0200 Subject: [PATCH] fix(landing): keep the header from overflowing at mid widths (#791) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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 --- .../js/components/partials/header.test.tsx | 28 ++- resources/js/components/partials/header.tsx | 161 ++++++++---------- 2 files changed, 98 insertions(+), 91 deletions(-) diff --git a/resources/js/components/partials/header.test.tsx b/resources/js/components/partials/header.test.tsx index 8a10e456..99029d62 100644 --- a/resources/js/components/partials/header.test.tsx +++ b/resources/js/components/partials/header.test.tsx @@ -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 }; }) => {children}, - 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(
); + + 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(
); + + expect(screen.getAllByText('Whisper Money')).toHaveLength(2); + expect( + screen.queryByRole('link', { name: 'Whisper Money' }), + ).toBeNull(); + }); }); diff --git a/resources/js/components/partials/header.tsx b/resources/js/components/partials/header.tsx index 3357229a..9a5f94fd 100644 --- a/resources/js/components/partials/header.tsx +++ b/resources/js/components/partials/header.tsx @@ -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 ( + + ); +} + +/** + * 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 = ( + <> + + Whisper Money + + ); + + if (isLandingPage) { + return
{content}
; + } + + return ( + + {content} + + ); +} + type Props = { canRegister?: boolean; hideExternalButtons?: boolean; @@ -43,59 +94,8 @@ export default function Header({ <> {/* Mobile pill header */}
-
- - - Whisper Money - -
+