Files
school_compare/nextjs-app/components/Navigation.tsx
T
Tudor accc562b90
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m3s
PR Checks / Backend Smoke (pull_request) Successful in 7s
PR Checks / Build Backend (no push) (pull_request) Successful in 13s
PR Checks / Build Frontend (no push) (pull_request) Successful in 44s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 10s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 4m29s
feat(brand): adopt the schoolcompare identity across the site
Implements the supplied brand guideline: the pin-and-leaf mark, the lowercase
schoolcompare wordmark, Manrope for headings and Inter for everything a person
reads a number from, the deep-teal/coral/mustard/sage palette, and the rounder
geometry the guideline asks for.

The landing hero is rebuilt to the concept: a Sand panel carrying the
proposition, the search and a trust line, with the brand landscape bleeding to
the panel's right edge, followed by the four value props.

Concept vs reality — where the guideline describes something this product does
not have, the real feature takes its place and the decision is recorded in a
code comment next to it:

- The Thriving Score is not implemented. Four of its five sub-scores (pastoral,
  enrichment, community, facilities) have no source in DfE or Ofsted data, and
  its band names are Ofsted's own, which would sit beside the real Ofsted grade
  and be indistinguishable from it.
- Guides, About, Favourites and Sign in are not added to the nav — there are no
  such pages and no accounts system. Rankings and Admissions, which the
  guideline omits, keep their places because they exist.
- The fourth value prop is "Never miss a deadline" rather than "Save & revisit",
  which would need shortlisting.

Two deviations from the supplied swatches, both forced by contrast and both
confined to text and fills. Coral #F97360 under a white label measures 2.75:1
against a 4.5 floor, so --action is a darkened #BE3C27 and the original coral
survives as --coral for tints, borders and illustration. Mustard #F2C94C is
1.5:1 as text, so --mustard-ink carries labels while --mustard stays exact for
fills and rings. Every token clears AA on all three light grounds; Sand is the
binding one, so several values sit a step darker than white alone would need.

The guideline is light-only, but the site already ships a dark theme, so it is
rebuilt from the same tokens rather than dropped.

Verified: tsc clean, 159/159 unit tests, production build green, and the
generated share card and app icon render from the new mark. The hero and the
site chrome were rendered from the compiled CSS and the prerendered markup to
confirm Manrope and Inter actually resolve — a font token that silently falls
back has shipped here before.
2026-08-07 18:05:49 +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>
</>
);
}