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
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.
170 lines
6.4 KiB
TypeScript
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>
|
|
</>
|
|
);
|
|
}
|