Files
school_compare/nextjs-app/components/Navigation.tsx
T
Tudor 39e5c11274
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m2s
PR Checks / Backend Smoke (pull_request) Successful in 6s
PR Checks / Build Backend (no push) (pull_request) Successful in 10s
PR Checks / Build Frontend (no push) (pull_request) Successful in 43s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 10s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 3m19s
fix(brand): match the below-average tint to its own hue, and finish the casing
Three review findings.

--status-below-bg carried the raw --coral tuple (249,115,96) instead of the
hue it belongs to (#A9481F → 169,72,31), so "below average" chips rendered a
coral-pink wash behind terracotta text — the one solid/tint pair in the file
whose hues disagreed. Alpha drops 0.15 → 0.12 to match --status-above-bg, so
the two chips read as equal weight; left at 0.15 the darker terracotta tint
takes the label to 4.45:1 on Warm White, under the 4.5 floor. At 0.12 it is
4.86:1 on cards and 4.67:1 on Warm White.

Auditing the whole file for the same class of error turned up one more:
--action-bg was also a --coral tint while --action is the darkened #BE3C27.
It is unused today and the dark-theme value was already correct, but it is
aligned so a future coral ghost button gets a matching label and ground. The
five phase pairs also "mismatch" by the same test and are deliberately left
alone: those pair a pastel ground (sage, sky, mustard, lavender) with a deeper
text hue, which is exactly what the guideline's Chips/Tags show.

The lowercase wordmark had only reached the visual chrome. Every user-visible
occurrence now matches: page title and template, appleWebApp title, authors,
OpenGraph and Twitter titles and siteName, the school page's siteName, the PWA
manifest name and short_name, the footer copyright line, the nav's aria-label,
the Web Share API strings, and the editorial paragraph. Comments in lib/*.ts
and AdmissionsView still say "SchoolCompare"; they describe the codebase rather
than the mark, so they are left to keep this diff to the reported problem.

The comment explaining why the font classes belong on <html> still described
--font-schibsted, which no longer exists. It now names --font-manrope and
--font-inter, and records that the e2e typeface assertion is the only guard —
this failure mode throws nothing and fails no unit test.

Verified: tsc clean, 159/159 tests, build green, and the rendered title is now
"School Admissions Guide | schoolcompare". Status and phase labels re-checked
on their own composited tints over both light grounds — all clear AA.
2026-08-07 18:33:30 +01:00

170 lines
6.4 KiB
TypeScript

/**
* Navigation Component
* Top header nav for desktop; bottom tab bar for mobile (≤640px).
*/
'use client';
import { useEffect } from 'react';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { useComparison } from '@/hooks/useComparison';
import { LogoMark } from './Logo';
import styles from './Navigation.module.css';
type IconProps = { className?: string };
const SearchIcon = ({ className }: IconProps) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<circle cx="11" cy="11" r="7" />
<path d="m20 20-3.5-3.5" />
</svg>
);
const CompareIcon = ({ className }: IconProps) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M4 7h13l-3-3" />
<path d="M20 17H7l3 3" />
</svg>
);
const RankingsIcon = ({ className }: IconProps) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M7 21V11" />
<path d="M12 21V4" />
<path d="M17 21v-7" />
</svg>
);
const AdmissionsIcon = ({ className }: IconProps) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<rect x="3" y="5" width="18" height="16" rx="2" />
<path d="M3 10h18M8 3v4M16 3v4" />
</svg>
);
export function Navigation() {
const pathname = usePathname();
const { selectedSchools } = useComparison();
const isActive = (path: string) => {
if (path === '/') return pathname === '/';
return pathname.startsWith(path);
};
/**
* iOS Chrome (and some Android browsers) auto-hide their URL bar on scroll,
* which grows the visual viewport without changing the layout viewport.
* `position: fixed; bottom: 0` sticks to the layout viewport, so our tab
* bar appears to float mid-screen with a gap beneath it. Track the delta
* via VisualViewport and apply it as a translate so the bar always sits
* flush against the visible bottom edge.
*/
useEffect(() => {
const vv = window.visualViewport;
if (!vv) return;
const root = document.documentElement;
const update = () => {
const offset = window.innerHeight - (vv.height + vv.offsetTop);
// Only positive offsets are meaningful (bar hidden → push down).
root.style.setProperty('--mobile-bar-offset', `${Math.max(0, offset)}px`);
};
update();
vv.addEventListener('resize', update);
vv.addEventListener('scroll', update);
return () => {
vv.removeEventListener('resize', update);
vv.removeEventListener('scroll', update);
root.style.removeProperty('--mobile-bar-offset');
};
}, []);
/*
* The brand guideline's nav also carries Guides, About, Favourites and a
* "Sign in" button. None of the four is added here, deliberately: there are
* no Guides or About pages, and Favourites and Sign in both need an accounts
* system that does not exist. Drawing chrome for features we do not have
* would be a promise the product cannot keep. Rankings and Admissions —
* which the guideline omits — take their place, because they are real.
*
* If accounts ever ship, this is the list to extend.
*/
const items = [
{ href: '/', label: 'Search', Icon: SearchIcon },
{ href: '/compare', label: 'Compare', Icon: CompareIcon },
{ href: '/rankings', label: 'Rankings', Icon: RankingsIcon },
{ href: '/admissions', label: 'Admissions', Icon: AdmissionsIcon },
] as const;
return (
<>
<header className={styles.header}>
<div className={styles.container}>
<Link href="/" className={styles.logo} aria-label="schoolcompare home">
{/*
LogoMark's defaults are already correct for this ground: the pin
takes var(--brand) and the leaf is knocked out in var(--bg-card),
which is exactly the header's own background in both themes. No
explicit pin/leaf needed here — the footer, which sits on the
sunken teal band, does have to pass them.
*/}
<span className={styles.logoIcon}>
<LogoMark />
</span>
<span className={styles.logoText}>
school<span className={styles.logoTextAccent}>compare</span>
</span>
</Link>
<nav className={styles.nav} aria-label="Main navigation">
{items.map(({ href, label }) => {
const active = isActive(href);
const showBadge = href === '/compare' && selectedSchools.length > 0;
return (
<Link
key={href}
href={href}
className={active ? `${styles.navLink} ${styles.active}` : styles.navLink}
aria-current={active ? 'page' : undefined}
>
{label}
{showBadge && (
<span key={selectedSchools.length} className={styles.badge}>
{selectedSchools.length}
</span>
)}
</Link>
);
})}
</nav>
</div>
</header>
<nav className={styles.bottomBar} aria-label="Main navigation">
{items.map(({ href, label, Icon }) => {
const active = isActive(href);
const showBadge = href === '/compare' && selectedSchools.length > 0;
return (
<Link
key={href}
href={href}
className={active ? `${styles.tab} ${styles.tabActive}` : styles.tab}
aria-current={active ? 'page' : undefined}
>
<span className={styles.tabIconWrap}>
<Icon className={styles.tabIcon} />
{showBadge && (
<span key={selectedSchools.length} className={styles.tabBadge} aria-label={`${selectedSchools.length} selected`}>
{selectedSchools.length}
</span>
)}
</span>
<span className={styles.tabLabel}>{label}</span>
</Link>
);
})}
</nav>
</>
);
}