diff --git a/e2e/tests/journeys.spec.ts b/e2e/tests/journeys.spec.ts index 5389bf4..bc0c25c 100644 --- a/e2e/tests/journeys.spec.ts +++ b/e2e/tests/journeys.spec.ts @@ -726,11 +726,10 @@ test('the brand typefaces actually load and apply', async ({ page }) => { // rest are fallbacks and always end in a generic like sans-serif. const first = (el: Element) => getComputedStyle(el).fontFamily.split(',')[0].replace(/["']/g, '').trim(); - const prose = document.querySelector('[class*="editorialText"] p'); + const headings = [...document.querySelectorAll('h1, h2')].map(first); return { body: first(document.body), - heading: first(document.querySelector('h1')!), - prose: prose ? first(prose) : null, + headings, bodyStack: getComputedStyle(document.body).fontFamily, displayToken: root.getPropertyValue('--font-display').trim(), uiToken: root.getPropertyValue('--font-ui').trim(), @@ -745,18 +744,38 @@ test('the brand typefaces actually load and apply', async ({ page }) => { expect(fonts.uiToken, '--font-ui resolved').not.toBe(''); expect(fonts.proseToken, '--font-prose resolved').not.toBe(''); - expect(fonts.body, 'body uses the UI face').toBe('Schibsted Grotesk'); - expect(fonts.heading, 'headings use the display face').toBe('Schibsted Grotesk'); - if (fonts.prose) { - expect(fonts.prose, 'running prose uses the serif').toBe('Literata'); - } + // Inter carries body copy, every control and every figure; Manrope carries + // headings and key messaging. Both must actually resolve, not merely be + // named in a stack that never loads. + expect(fonts.body, 'body uses Inter').toBe('Inter'); + expect(fonts.headings.length, 'the page has headings to check').toBeGreaterThan(0); + expect(fonts.headings, 'at least one heading uses Manrope').toContain('Manrope'); - // The Times fallback is the specific failure that shipped. Match the family - // name only — a stack legitimately ends in sans-serif, so anchoring on - // /serif$/ would flag a perfectly healthy page. + // The Times fallback is the specific failure that shipped once. Match the + // family name only — a stack legitimately ends in sans-serif, so anchoring + // on /serif$/ would flag a perfectly healthy page. expect(fonts.bodyStack).not.toMatch(/\bTimes\b/); }); +/** + * The wordmark is the one piece of brand chrome a user reads on every page, + * and it is set as two spans so "compare" can take the brand colour. A + * refactor that drops the second span, or reverts the lowercase styling, + * changes the brand without failing anything else. + */ +test('the header carries the schoolcompare lockup', async ({ page }) => { + await page.goto('/'); + const home = page.getByRole('banner').getByRole('link', { name: /schoolcompare home/i }); + await expect(home).toBeVisible({ timeout: 15_000 }); + + // Assert on rendered text rather than CSS-module class names, which are + // hashed at build time and change on any unrelated edit. + await expect(home).toHaveText(/^\s*schoolcompare\s*$/); + + const mark = home.locator('svg').first(); + await expect(mark).toBeVisible(); +}); + test('no visible text falls back to the browser default black', async ({ page }) => { // Form controls don't inherit colour from their parent, so a missing // declaration renders pure black — subtle in light mode, invisible in dark. diff --git a/nextjs-app/app/apple-icon.tsx b/nextjs-app/app/apple-icon.tsx index ce161d4..8e03a8f 100644 --- a/nextjs-app/app/apple-icon.tsx +++ b/nextjs-app/app/apple-icon.tsx @@ -1,14 +1,16 @@ import { ImageResponse } from 'next/og'; -import { BARS } from '@/components/Logo'; +import { markDataUri } from '@/components/Logo'; // iOS ignores SVG touch icons — it needs a real raster, on an opaque ground, // with no transparency (it composites its own rounded mask). Previously this // pointed at favicon.svg, so add-to-home-screen produced a blank tile. +// +// Teal ground with the mark knocked out in white, matching the app icon in the +// guideline's "brand in action" row. export const size = { width: 180, height: 180 }; export const contentType = 'image/png'; -const COHORT = ['rgba(22,32,42,0.22)', 'rgba(22,32,42,0.38)', 'rgba(22,32,42,0.55)', '', 'rgba(22,32,42,0.22)']; -const SCALE = 3.6; // 40 → 144, leaving a 18px margin inside 180 +const TEAL = '#0F766E'; export default function AppleIcon() { return new ImageResponse( @@ -20,25 +22,11 @@ export default function AppleIcon() { display: 'flex', alignItems: 'center', justifyContent: 'center', - background: '#f2f1ed', + background: TEAL, }} > -
- {BARS.map(([x, y, w, h, isSchool], i) => ( -
- ))} -
+ {/* eslint-disable-next-line @next/next/no-img-element */} +
), size diff --git a/nextjs-app/app/globals.css b/nextjs-app/app/globals.css index f2be1d1..cda0c37 100644 --- a/nextjs-app/app/globals.css +++ b/nextjs-app/app/globals.css @@ -1,173 +1,193 @@ /* - * SchoolCompare.co.uk — "Cohort" + * SchoolCompare — "Find the right school. For their future." * - * A school's number means nothing on its own; its position in the national - * distribution does. That idea drives the identity: the logo is the same - * five-bar spread that appears inside every school row. + * Implements the schoolcompare brand guideline. Colour has four jobs and they + * never borrow each other's hues: + * brand (deep teal) identity, navigation, headings, links, primary buttons + * action (coral) the one decisive action on a page — search, submit + * status (green / valence against the England average. Never a CTA. + * terracotta) + * phase (desaturated) category chips, deliberately subordinate. * - * Colour has exactly three jobs and they never borrow each other's hues: - * brand (iris) interactive + identity. Never carries valence. - * status (teal / amber) valence — teal is above/good, amber is - * below/needs-attention. Never a CTA. - * phase (desaturated) category chips, deliberately subordinate. + * Two deviations from the supplied swatches, both forced by contrast, both + * confined to text and fills. Coral #F97360 carrying a white label measures + * 2.75:1 where 4.5 is the floor, so --action is a darkened #BE3C27; the + * original coral survives as --coral for tints, borders and illustration. + * Mustard #F2C94C is 1.5:1 as text, so --mustard-ink carries any label while + * --mustard stays exact for fills, rings and badges. * - * Escalate within status by weight, not by reaching for a new hue: a tinted - * amber chip for "requires improvement", a solid amber one for "inadequate". - * - * Teal/amber rather than green/red so the above/below signal survives every - * form of colour blindness — the audience includes parents reading closely. + * Every value below clears WCAG AA on all three light grounds — Warm White, + * white cards and Sand. Sand is the binding one; several values are a step + * darker than they would need to be on white alone. */ :root { color-scheme: light; /* ── Ground ─────────────────────────────────────────────────────── */ - --bg-primary: #f2f1ed; /* paper, not cream — warm enough to read as a document */ - --bg-secondary: #e9e8e2; - --bg-card: #fcfcfa; - --surface-inverse: #16202a; /* dark fills: skip link, active nav, tooltips */ + --bg-primary: #FAFAF8; /* Warm White */ + --bg-secondary: #F5EFE6; /* Sand — hero panels, sunken rows */ + --bg-card: #FFFFFF; + --surface-inverse: #0F766E; /* ── Ink ────────────────────────────────────────────────────────── */ - --text-primary: #16202a; - --text-secondary: #4a545f; - --text-muted: #5c6570; /* 5.3:1 on --bg-primary */ - --text-inverse: #f7f7f4; + --text-primary: #1C2731; /* Ink */ + --text-secondary: #4A5560; + --text-muted: #5F6A75; /* 4.8:1 on Sand, the worst ground */ + --text-inverse: #FFFFFF; /* ── Line ───────────────────────────────────────────────────────── */ - --border: #dedcd5; - --border-strong: #cdcac1; + --border: #E5E7EB; /* Light Grey */ + --border-strong: #D3D7DD; - /* ── Brand: iris ────────────────────────────────────────────────── */ - --brand: #584a9b; /* 6.9:1 on --bg-primary */ - --brand-strong: #473c80; /* hover on brand fills */ - --brand-stronger: #3a3069; /* active/pressed */ - --brand-bg: rgba(88, 74, 155, 0.10); - --brand-on: #fcfcfa; /* text/icon sitting on a brand fill */ + /* ── Brand: deep teal ───────────────────────────────────────────── */ + --brand: #0F766E; /* exact brand value; 4.8:1 on Sand */ + --brand-strong: #0C5F58; + --brand-stronger: #0A4D47; + --brand-bg: rgba(15, 118, 110, 0.10); + --brand-on: #FFFFFF; - /* ── Status: above / at / below the comparison point ────────────── */ - --status-above: #0b625a; /* 6.6:1 on --bg-primary, 5.1:1 on its own tint */ - --status-above-bg: rgba(11, 98, 90, 0.10); - --status-below: #7f4a00; /* 6.3:1 on --bg-primary, 5.1:1 on its own tint */ - --status-below-bg: rgba(127, 74, 0, 0.11); - --status-at: var(--text-muted); - --status-at-bg: rgba(92, 101, 112, 0.10); + /* ── Action: coral. One per screen, and only for the decisive one. ── */ + --action: #BE3C27; /* darkened coral — 5.4:1 under a white label */ + --action-strong: #A33320; + --action-stronger: #8C2B1B; + --action-bg: rgba(249, 115, 96, 0.14); + --action-on: #FFFFFF; - /* ── Charts: the same three hues, extended by lightness only ────── */ - --chart-1: #584a9b; - --chart-2: #0b625a; - --chart-3: #7f4a00; - --chart-4: #8a7cc9; - --chart-5: #3e9c92; - --chart-6: #c9903d; - --chart-grid: #dedcd5; - --chart-reference: #5c6570; /* national/LA reference lines */ + /* ── Palette accents: exact brand values, for fills and tints only ── */ + --coral: #F97360; + --mustard: #F2C94C; + --mustard-ink: #806200; /* any label that has to sit on or beside mustard */ + --sage: #A7D7C5; + --sky: #C7EBF5; + --lavender: #DDD8F5; + --sand: #F5EFE6; + + /* ── Status: above / at / below the England average ─────────────── */ + --status-above: #36743F; + --status-above-bg: rgba(54, 116, 63, 0.12); + --status-below: #A9481F; + --status-below-bg: rgba(249, 115, 96, 0.15); + --status-at: #5F6A75; + --status-at-bg: rgba(95, 106, 117, 0.10); + + /* ── Charts ─────────────────────────────────────────────────────── */ + --chart-1: #0F766E; + --chart-2: #36743F; + --chart-3: #806200; + --chart-4: #A9481F; + --chart-5: #2F6F8F; + --chart-6: #5F4FA8; + --chart-grid: #E5E7EB; + --chart-reference: #5F6A75; /* ── Series: up to 8 schools compared at once ─────────────────────── - Categorical needs distinguishable hues, so this ramp is wider than the - three-hue rule — but every step is held to the same mid-dark tone and - moderate saturation, and every one clears AA on --bg-primary, so they - read as one family and work as legend text as well as chart lines. */ - --series-1: #584a9b; - --series-2: #0b625a; - --series-3: #7f4a00; - --series-4: #a03a5e; - --series-5: #2f5f8f; - --series-6: #4a6b2f; - --series-7: #7a3f7a; - --series-8: #97442a; + Categorical, so wider than the four-hue rule — but every step is held + to the same mid-dark tone and clears AA on the card, so they read as + one family and work as legend text as well as chart lines. */ + --series-1: #0F766E; + --series-2: #36743F; + --series-3: #806200; + --series-4: #A9481F; + --series-5: #2F6F8F; + --series-6: #5F4FA8; + --series-7: #0E7A86; + --series-8: #8A4A6B; /* ── Phase: category, desaturated so it stays under the status hues ── */ - --phase-primary: #4a6072; - --phase-primary-bg: rgba(74, 96, 114, 0.10); - --phase-primary-text: #3b4e5e; - --phase-secondary: #5f5480; - --phase-secondary-bg: rgba(95, 84, 128, 0.10); - --phase-secondary-text: #4e4570; - --phase-all-through: #4b6b57; - --phase-all-through-bg: rgba(75, 107, 87, 0.10); - --phase-all-through-text: #3d5847; - --phase-post16: #7a6242; - --phase-post16-bg: rgba(122, 98, 66, 0.10); - --phase-post16-text: #645036; - --phase-nursery: #7a5560; - --phase-nursery-bg: rgba(122, 85, 96, 0.10); - --phase-nursery-text: #64454f; + --phase-primary: #0F766E; + --phase-primary-bg: rgba(167, 215, 197, 0.40); + --phase-primary-text: #0C5F58; + --phase-secondary: #2F6F8F; + --phase-secondary-bg: rgba(199, 235, 245, 0.55); + --phase-secondary-text: #2A6180; + --phase-all-through: #36743F; + --phase-all-through-bg: rgba(54, 116, 63, 0.12); + --phase-all-through-text: #2F6738; + --phase-post16: #806200; + --phase-post16-bg: rgba(242, 201, 76, 0.26); + --phase-post16-text: #6E5500; + --phase-nursery: #5F4FA8; + --phase-nursery-bg: rgba(221, 216, 245, 0.55); + --phase-nursery-text: #544396; - /* ── Sunken surface: the footer, and anything meant to read as a dark - anchor band. Deliberately NOT --surface-inverse: "inverse" flips - with the theme, which turned the footer into a light slab at the - bottom of a dark page. This stays dark in both. ──────────────── */ - --surface-sunken: #16202a; - --on-sunken: #e4e2dc; - --on-sunken-muted: #b3b8bd; - --on-sunken-faint: #7f878f; - --on-sunken-link: #b9aeef; + /* ── Sunken surface: the footer, and anything meant to read as a deep + teal anchor band. Stays teal in both themes on purpose — it is the + "brand in action" card from the guideline. ─────────────────── */ + --surface-sunken: #0C5F58; + --on-sunken: #FFFFFF; + --on-sunken-muted: #C9E4DB; + --on-sunken-faint: #A9D6CA; + --on-sunken-link: #C7EBF5; - /* ── Medals: rankings podium. Metal, not palette — kept legible on - both grounds rather than literally gold/silver/bronze. ───────── */ - --medal-gold: #a67c00; - --medal-silver: #6f7580; - --medal-bronze: #8a5a2b; + /* ── Medals: rankings podium ────────────────────────────────────── */ + --medal-gold: #806200; + --medal-silver: #6B7580; + --medal-bronze: #8A5A2B; /* ── Channels ─────────────────────────────────────────────────────── CSS can't interpolate a hex token into rgba(), so any tint that needs an arbitrary alpha reads the raw channels from here. Keep these in sync with the hex tokens above — they are the same colours. */ - --brand-rgb: 88, 74, 155; - --status-above-rgb: 11, 98, 90; - --status-below-rgb: 127, 74, 0; - --ink-rgb: 22, 32, 42; - --text-inverse-rgb: 247, 247, 244; - --shadow-rgb: 22, 32, 42; - --muted-rgb: 92, 101, 112; - --phase-secondary-rgb: 95, 84, 128; - --medal-silver-rgb: 111, 117, 128; + --brand-rgb: 15, 118, 110; + --action-rgb: 190, 60, 39; + --coral-rgb: 249, 115, 96; + --sage-rgb: 167, 215, 197; + --status-above-rgb: 54, 116, 63; + --status-below-rgb: 169, 72, 31; + --ink-rgb: 28, 39, 49; + --text-inverse-rgb: 255, 255, 255; + --shadow-rgb: 28, 39, 49; + --muted-rgb: 95, 106, 117; + --phase-secondary-rgb: 47, 111, 143; + --medal-silver-rgb: 107, 117, 128; --medal-bronze-rgb: 138, 90, 43; /* ── Elevation ──────────────────────────────────────────────────── */ - --shadow-soft: 0 1px 2px rgba(22, 32, 42, 0.04), 0 2px 8px rgba(22, 32, 42, 0.05); - --shadow-medium: 0 4px 20px rgba(22, 32, 42, 0.09); - --shadow-strong: 0 8px 40px rgba(22, 32, 42, 0.14); - --scrim: rgba(22, 32, 42, 0.55); + --shadow-soft: 0 1px 2px rgba(28, 39, 49, 0.04), 0 2px 8px rgba(28, 39, 49, 0.05); + --shadow-medium: 0 4px 18px rgba(28, 39, 49, 0.09); + --shadow-strong: 0 10px 34px rgba(28, 39, 49, 0.13); + --scrim: rgba(28, 39, 49, 0.5); - /* ── Type ───────────────────────────────────────────────────────── */ - /* next/font already expands --font-schibsted to the family plus its - metric-matched fallback, so naming the family again here only made the - stack say it twice. It read like a safety net but wasn't one: a var() - with no fallback that resolves to nothing invalidates the whole - declaration, so the literal after it never gets a turn. The real - safeguard is that these classes sit on , where :root can see - them — see app/layout.tsx. */ - --font-display: var(--font-schibsted), -apple-system, BlinkMacSystemFont, sans-serif; - --font-ui: var(--font-schibsted), -apple-system, BlinkMacSystemFont, sans-serif; - --font-data: var(--font-schibsted), -apple-system, BlinkMacSystemFont, sans-serif; - --font-prose: var(--font-literata), Georgia, serif; + /* ── Type ─────────────────────────────────────────────────────────── + Manrope for headings and key messaging; Inter for body copy, every + control and every figure. next/font expands each variable to the + family plus its metric-matched fallback, and the classes sit on + so :root can see them — see app/layout.tsx. */ + --font-display: var(--font-manrope), -apple-system, BlinkMacSystemFont, sans-serif; + --font-ui: var(--font-inter), -apple-system, BlinkMacSystemFont, sans-serif; + --font-data: var(--font-inter), -apple-system, BlinkMacSystemFont, sans-serif; + --font-prose: var(--font-inter), -apple-system, BlinkMacSystemFont, sans-serif; - /* Type scale, 1.2 ratio off a 1rem base. New work should use these - rather than inventing another font-size. */ - --step--2: 0.694rem; - --step--1: 0.833rem; + /* Type scale, straight from the guideline: H1 40/48, H2 32/40, H3 24/32, + body large 16/24, body 14/20, small 12/16. */ + --step--2: 0.75rem; + --step--1: 0.875rem; --step-0: 1rem; - --step-1: 1.2rem; - --step-2: 1.44rem; - --step-3: 1.728rem; - --step-4: 2.074rem; - --step-5: 2.488rem; + --step-1: 1.125rem; + --step-2: 1.5rem; + --step-3: 2rem; + --step-4: 2.5rem; + --step-5: 3rem; - /* ── Geometry & motion ──────────────────────────────────────────── */ - --radius-sm: 3px; - --radius-md: 6px; - --radius-lg: 10px; - --radius-xl: 16px; + /* ── Geometry & motion ────────────────────────────────────────────── + "Soft shapes, rounded corners" — the guideline's geometry is markedly + rounder than the old system's 3/6/10/16. */ + --radius-sm: 8px; + --radius-md: 12px; + --radius-lg: 16px; + --radius-xl: 22px; --transition: 0.2s ease; --transition-slow: 0.4s ease; } /* - * Dark theme. Not an inversion — the accent and both status hues are lifted - * independently so each still clears AA on the dark ground, and --brand-on - * flips to dark because the brand fill is now the lighter of the pair. + * Dark theme. Not in the guideline, which is light-only — but the site already + * ships one, so removing it would be a visible regression. Built from the same + * tokens: teal and coral are lifted independently so each still clears AA on + * the dark ground, and the ground itself keeps the Ink hue rather than going + * to black. * * Tokens only. Never style a component from inside this block, or the two * themes drift apart the first time someone edits one of them. @@ -176,90 +196,108 @@ :root { color-scheme: dark; - --bg-primary: #10151b; - --bg-secondary: #161c24; - --bg-card: #171d25; - --surface-inverse: #e4e9ee; + --bg-primary: #111A20; + --bg-secondary: #16222A; + --bg-card: #18242C; + --surface-inverse: #E9EEF0; - --text-primary: #e4e9ee; - --text-secondary: #b4bec8; - --text-muted: #8d99a6; /* 6.4:1 on --bg-primary */ - --text-inverse: #10151b; + --text-primary: #E9EEF0; + --text-secondary: #B4C2C7; + --text-muted: #8B9AA1; /* 5.5:1 on the card */ + --text-inverse: #111A20; - --border: #28313b; - --border-strong: #38434f; + --border: #26343D; + --border-strong: #35454F; - --brand: #9c8ce8; /* 6.5:1 on --bg-primary */ - --brand-strong: #b0a3ee; - --brand-stronger: #c2b8f3; - --brand-bg: rgba(156, 140, 232, 0.14); - --brand-on: #10151b; + --brand: #5FC7BB; + --brand-strong: #7BD6CC; + --brand-stronger: #9BE3DB; + --brand-bg: rgba(95, 199, 187, 0.14); + --brand-on: #0A1418; - --status-above: #4fc0b0; /* 8.4:1 on --bg-primary, 4.8:1 on its own tint */ - --status-above-bg: rgba(79, 192, 176, 0.14); - --status-below: #d99b2e; /* 7.7:1 */ - --status-below-bg: rgba(217, 155, 46, 0.14); - --status-at-bg: rgba(141, 153, 166, 0.14); + --action: #F08A72; + --action-strong: #F5A492; + --action-stronger: #F8BFB1; + --action-bg: rgba(240, 138, 114, 0.16); + --action-on: #241009; - --chart-1: #9c8ce8; - --chart-2: #4fc0b0; - --chart-3: #d99b2e; - --chart-4: #6f5fc4; - --chart-5: #2b8a7e; - --chart-6: #a97420; - --chart-grid: #28313b; - --chart-reference: #8d99a6; + --coral: #F08A72; + --mustard: #F2C94C; + --mustard-ink: #EFC658; + --sage: #8FCBB7; + --sky: #9CD4E6; + --lavender: #C2BBEA; + --sand: #1B2730; - --series-1: #9c8ce8; - --series-2: #4fc0b0; - --series-3: #d99b2e; - --series-4: #e07a9d; - --series-5: #6fa8dc; - --series-6: #94c46b; - --series-7: #c48bc4; - --series-8: #e08a6b; + --status-above: #7FCB8A; + --status-above-bg: rgba(127, 203, 138, 0.14); + --status-below: #EFA184; + --status-below-bg: rgba(239, 161, 132, 0.14); + --status-at: #8B9AA1; + --status-at-bg: rgba(139, 154, 161, 0.14); - --phase-primary: #8aa4b8; - --phase-primary-bg: rgba(138, 164, 184, 0.14); - --phase-primary-text: #a3bacd; - --phase-secondary: #a294c4; - --phase-secondary-bg: rgba(162, 148, 196, 0.14); - --phase-secondary-text: #b7abd6; - --phase-all-through: #8bb098; - --phase-all-through-bg: rgba(139, 176, 152, 0.14); - --phase-all-through-text: #a2c2ad; - --phase-post16: #bfa27c; - --phase-post16-bg: rgba(191, 162, 124, 0.14); - --phase-post16-text: #d0b795; - --phase-nursery: #bf98a3; - --phase-nursery-bg: rgba(191, 152, 163, 0.14); - --phase-nursery-text: #d1aeb8; + --chart-1: #5FC7BB; + --chart-2: #7FCB8A; + --chart-3: #EFC658; + --chart-4: #EFA184; + --chart-5: #8FB4D9; + --chart-6: #B6A9DD; + --chart-grid: #26343D; + --chart-reference: #8B9AA1; - --surface-sunken: #0a0e13; - --on-sunken: #d8dde2; - --on-sunken-muted: #9aa4ae; - --on-sunken-faint: #6d777f; - --on-sunken-link: #b0a3ee; + --series-1: #5FC7BB; + --series-2: #7FCB8A; + --series-3: #EFC658; + --series-4: #EFA184; + --series-5: #8FB4D9; + --series-6: #B6A9DD; + --series-7: #6FD0DC; + --series-8: #D99BB8; - --medal-gold: #d4a72c; - --medal-silver: #a8b0bb; - --medal-bronze: #c08552; + --phase-primary: #5FC7BB; + --phase-primary-bg: rgba(95, 199, 187, 0.16); + --phase-primary-text: #8ADACF; + --phase-secondary: #8FB4D9; + --phase-secondary-bg: rgba(143, 180, 217, 0.16); + --phase-secondary-text: #AAC8E4; + --phase-all-through: #7FCB8A; + --phase-all-through-bg: rgba(127, 203, 138, 0.16); + --phase-all-through-text: #9AD8A3; + --phase-post16: #EFC658; + --phase-post16-bg: rgba(242, 201, 76, 0.16); + --phase-post16-text: #F2D179; + --phase-nursery: #B6A9DD; + --phase-nursery-bg: rgba(182, 169, 221, 0.16); + --phase-nursery-text: #C8BEE6; - --brand-rgb: 156, 140, 232; - --status-above-rgb: 79, 192, 176; - --status-below-rgb: 217, 155, 46; - --ink-rgb: 228, 233, 238; - --text-inverse-rgb: 16, 21, 27; + --surface-sunken: #0C5F58; + --on-sunken: #FFFFFF; + --on-sunken-muted: #C9E4DB; + --on-sunken-faint: #A9D6CA; + --on-sunken-link: #C7EBF5; + + --medal-gold: #EFC658; + --medal-silver: #A9B6BC; + --medal-bronze: #C99070; + + --brand-rgb: 95, 199, 187; + --action-rgb: 240, 138, 114; + --coral-rgb: 240, 138, 114; + --sage-rgb: 143, 203, 183; + --status-above-rgb: 127, 203, 138; + --status-below-rgb: 239, 161, 132; + --ink-rgb: 233, 238, 240; + --text-inverse-rgb: 17, 26, 32; --shadow-rgb: 0, 0, 0; - --muted-rgb: 141, 153, 166; - --phase-secondary-rgb: 162, 148, 196; - --medal-silver-rgb: 168, 176, 187; - --medal-bronze-rgb: 192, 133, 82; + --muted-rgb: 139, 154, 161; + --phase-secondary-rgb: 143, 180, 217; + --medal-silver-rgb: 169, 182, 188; + --medal-bronze-rgb: 201, 144, 112; --shadow-soft: 0 1px 2px rgba(0, 0, 0, 0.4), 0 2px 8px rgba(0, 0, 0, 0.3); - --shadow-medium: 0 4px 20px rgba(0, 0, 0, 0.45); - --shadow-strong: 0 8px 40px rgba(0, 0, 0, 0.6); - --scrim: rgba(4, 7, 10, 0.7); + --shadow-medium: 0 4px 18px rgba(0, 0, 0, 0.45); + --shadow-strong: 0 10px 34px rgba(0, 0, 0, 0.55); + --scrim: rgba(4, 8, 10, 0.65); } } @@ -402,6 +440,30 @@ table, border-color: var(--brand-stronger); } +/* + * Action: coral fill — the one decisive action on a page, and only that. + * + * The guideline gives coral to "calls to action, highlights, important + * buttons" while its own UI-components sheet draws the primary button in + * teal. Both are honoured by making this a scarce, separate class: teal is + * the default primary, coral marks the single action a page exists for — + * the search submit. Use it more than once per screen and it stops meaning + * anything. + */ +.btn-action { + background: var(--action); + color: var(--action-on); + border-color: var(--action); +} +.btn-action:hover:not(:disabled) { + background: var(--action-strong); + border-color: var(--action-strong); +} +.btn-action:active:not(:disabled) { + background: var(--action-stronger); + border-color: var(--action-stronger); +} + /* Secondary: brand outline — supporting actions (Add to shortlist) */ .btn-secondary { background: transparent; diff --git a/nextjs-app/app/icon.svg b/nextjs-app/app/icon.svg index d9ebdf2..2a77b41 100644 --- a/nextjs-app/app/icon.svg +++ b/nextjs-app/app/icon.svg @@ -1,7 +1,7 @@ - - - - - - + + + + + + diff --git a/nextjs-app/app/layout.tsx b/nextjs-app/app/layout.tsx index 8cc4cdd..1b44f55 100644 --- a/nextjs-app/app/layout.tsx +++ b/nextjs-app/app/layout.tsx @@ -1,5 +1,5 @@ import type { Metadata, Viewport } from 'next'; -import { Schibsted_Grotesk, Literata } from 'next/font/google'; +import { Manrope, Inter } from 'next/font/google'; import Script from 'next/script'; import { Navigation } from '@/components/Navigation'; import { Footer } from '@/components/Footer'; @@ -7,23 +7,22 @@ import { ComparisonToast } from '@/components/ComparisonToast'; import { ComparisonProvider } from '@/context/ComparisonProvider'; import './globals.css'; -// Schibsted Grotesk carries the whole interface — headings, labels, and every -// figure. It was drawn for news brands, so its tabular numerals hold a column -// properly, which matters on a page that is mostly percentages. -const schibsted = Schibsted_Grotesk({ +// Manrope carries headings and key messaging — the guideline's "friendly, +// modern personality". It never sets body copy or a control. +const manrope = Manrope({ subsets: ['latin'], - weight: ['400', '500', '600', '700'], - variable: '--font-schibsted', + weight: ['500', '600', '700'], + variable: '--font-manrope', display: 'swap', }); -// Literata is for prose only — the admissions guide, methodology notes, the -// editorial sections. It never touches a number or a control. -const literata = Literata({ +// Inter carries body copy, every control and every figure — chosen in the +// guideline for clarity and legibility. Its tabular numerals hold a column +// properly, which matters on a page that is mostly percentages. +const inter = Inter({ subsets: ['latin'], - weight: ['400', '600'], - style: ['normal', 'italic'], - variable: '--font-literata', + weight: ['400', '500', '600', '700'], + variable: '--font-inter', display: 'swap', }); @@ -36,8 +35,8 @@ export const viewport: Viewport = { // These must match --bg-primary in globals.css for each theme, or the // browser chrome and the page disagree at the top edge of the screen. themeColor: [ - { media: '(prefers-color-scheme: light)', color: '#f2f1ed' }, - { media: '(prefers-color-scheme: dark)', color: '#10151b' }, + { media: '(prefers-color-scheme: light)', color: '#FAFAF8' }, + { media: '(prefers-color-scheme: dark)', color: '#111A20' }, ], }; @@ -87,7 +86,7 @@ export default function RootLayout({ // at :root, so --font-display computed to the guaranteed-invalid value and // every font-family that referenced it silently fell back — the whole site // rendered in Times. - + diff --git a/nextjs-app/app/opengraph-image.tsx b/nextjs-app/app/opengraph-image.tsx index f79e1ec..1883f82 100644 --- a/nextjs-app/app/opengraph-image.tsx +++ b/nextjs-app/app/opengraph-image.tsx @@ -1,32 +1,28 @@ import { readFile } from 'node:fs/promises'; import { join } from 'node:path'; import { ImageResponse } from 'next/og'; -import { BARS } from '@/components/Logo'; +import { markDataUri } from '@/components/Logo'; /** * The site had no og:image at all, so every link pasted into a class WhatsApp * group or a Slack channel rendered as a bare grey text card — on the highest * intent channel this product has. * - * The card is the mark at poster scale: the cohort as a wide distribution with - * one school lit up. It states the proposition rather than decorating it. + * The card is the brand's own proposition on the Sand ground, with the mark + * and a suggestion of the illustration's rolling landscape along the bottom. + * It states the promise rather than decorating it. */ -export const alt = 'SchoolCompare — compare every school in England against the national picture'; +export const alt = 'schoolcompare — find the school where your child will flourish'; export const size = { width: 1200, height: 630 }; export const contentType = 'image/png'; -const PAPER = '#f2f1ed'; -const INK = '#16202a'; -const MUTED = '#5c6570'; -const BRAND = '#584a9b'; - -// A plausible national distribution, drawn once at poster scale. The lit bar -// is the same position the mark uses. -const SPREAD = [8, 12, 17, 24, 33, 44, 57, 70, 82, 92, 98, 100, 96, 88, 77, 65, 53, 41, 31, 22, 15, 10, 7, 5]; -// Right of the peak: score runs left to right, so the lit school reads as -// above the national average. At index 9 it sat on the low side, which quietly -// said the opposite. -const LIT = 14; +const SAND = '#F5EFE6'; +const INK = '#1C2731'; +const MUTED = '#5F6A75'; +const TEAL = '#0F766E'; +const SAGE = '#A7D7C5'; +const SAGE_DEEP = '#7FC3AC'; +const SAGE_PALE = '#D6EDE2'; async function font(file: string) { return readFile(join(process.cwd(), 'assets', file)); @@ -34,8 +30,8 @@ async function font(file: string) { export default async function OpengraphImage() { const [regular, bold] = await Promise.all([ - font('SchibstedGrotesk-Regular.ttf'), - font('SchibstedGrotesk-Bold.ttf'), + font('Manrope-Regular.ttf'), + font('Manrope-Bold.ttf'), ]); return new ImageResponse( @@ -47,85 +43,62 @@ export default async function OpengraphImage() { display: 'flex', flexDirection: 'column', justifyContent: 'space-between', - background: PAPER, - padding: '58px 72px 52px', - fontFamily: 'Schibsted', + background: SAND, + padding: '58px 72px 0', + fontFamily: 'Manrope', + position: 'relative', }} > + {/* Rolling hills along the bottom edge — the illustration style, + reduced to the one gesture that survives at thumbnail size. */} +
+ + + + + +
+ {/* Lockup */}
-
- {BARS.map(([x, y, w, h, isSchool], i) => ( -
- ))} -
-
- School - Compare + {/* eslint-disable-next-line @next/next/no-img-element */} + +
+ school + compare
{/* Proposition. Sized so the headline holds two lines and the subhead one — an orphaned word reads as an accident at this size. */} -
+
- A score means nothing until you see the spread. + Find the school where + your child will + flourish.
-
+
24,000+ schools in England · SATs, GCSEs, Ofsted and admissions
- - {/* The device, at poster scale. Same object as the mark above. */} -
-
- {SPREAD.map((h, i) => ( -
- ))} -
-
- NATIONAL SPREAD - schoolcompare.co.uk -
-
), { ...size, fonts: [ - { name: 'Schibsted', data: regular, weight: 400, style: 'normal' }, - { name: 'Schibsted', data: bold, weight: 700, style: 'normal' }, + { name: 'Manrope', data: regular, weight: 400, style: 'normal' }, + { name: 'Manrope', data: bold, weight: 700, style: 'normal' }, ], } ); diff --git a/nextjs-app/assets/Manrope-Bold.ttf b/nextjs-app/assets/Manrope-Bold.ttf new file mode 100644 index 0000000..746764d Binary files /dev/null and b/nextjs-app/assets/Manrope-Bold.ttf differ diff --git a/nextjs-app/assets/Manrope-Regular.ttf b/nextjs-app/assets/Manrope-Regular.ttf new file mode 100644 index 0000000..8594569 Binary files /dev/null and b/nextjs-app/assets/Manrope-Regular.ttf differ diff --git a/nextjs-app/assets/SchibstedGrotesk-Bold.ttf b/nextjs-app/assets/SchibstedGrotesk-Bold.ttf deleted file mode 100644 index 5425be0..0000000 Binary files a/nextjs-app/assets/SchibstedGrotesk-Bold.ttf and /dev/null differ diff --git a/nextjs-app/assets/SchibstedGrotesk-Regular.ttf b/nextjs-app/assets/SchibstedGrotesk-Regular.ttf deleted file mode 100644 index 97ef234..0000000 Binary files a/nextjs-app/assets/SchibstedGrotesk-Regular.ttf and /dev/null differ diff --git a/nextjs-app/components/FilterBar.module.css b/nextjs-app/components/FilterBar.module.css index 50772b4..8a085b2 100644 --- a/nextjs-app/components/FilterBar.module.css +++ b/nextjs-app/components/FilterBar.module.css @@ -1,11 +1,26 @@ +/* + * FilterBar — one component, two shapes. + * + * hero a single white pill on the Sand hero panel, carrying the one + * coral action the page exists for. No card of its own: the panel + * behind it belongs to the page, so the pill reads as the object. + * compact the filter rail above the results — a white card of quiet + * controls, brand for what is active, sage for what is selected. + * + * Colour comes from the token layer only; the disclosure arrows are drawn + * from currentColor rather than a background image so they follow the theme. + */ + +/* ── Shell ────────────────────────────────────────────────────────── */ + .filterBar { background: var(--bg-card); border: 1px solid var(--border); - border-radius: 10px; - padding: 1.5rem; - margin-bottom: 2rem; - box-shadow: var(--shadow-soft, 0 2px 8px rgba(var(--shadow-rgb), 0.06)); - transition: opacity 0.2s ease; + border-radius: var(--radius-lg); + padding: 1rem 1.125rem; + margin-bottom: 1.5rem; + box-shadow: var(--shadow-soft); + transition: opacity var(--transition); } .filterBar.isLoading { @@ -14,112 +29,16 @@ } .heroMode { - padding: 2.5rem; - max-width: 800px; - margin: 0 auto 3rem auto; - box-shadow: 0 8px 24px rgba(var(--shadow-rgb), 0.08); - border-width: 2px; - border-color: var(--brand); + background: none; + border: 0; + border-radius: 0; + box-shadow: none; + padding: 0; + max-width: 640px; + margin: 0 auto 1.5rem; } -.heroMode .omniInput { - font-size: 1.25rem; - padding: 1.25rem 1.5rem; -} - -.heroMode .searchButton { - font-size: 1.25rem; - padding: 1.25rem 2.5rem; -} - -.heroMode .searchSection { - margin-bottom: 0; -} - -.searchHint { - margin: 0.875rem 0 0; - font-size: 0.95rem; - color: var(--text-secondary); - text-align: center; -} - -.searchHint strong { - color: var(--text-primary); - font-weight: 600; -} - -@media (max-width: 600px) { - .searchHint { - font-size: 0.85rem; - text-align: left; - } -} - -.nearMeRow { - display: flex; - flex-direction: column; - align-items: center; - gap: 0.5rem; - margin-top: 0.75rem; -} - -.nearMeBtn { - display: inline-flex; - align-items: center; - gap: 0.5rem; - padding: 0.625rem 1.375rem; - background: var(--brand); - color: var(--brand-on); - border: none; - border-radius: 999px; - font-size: 0.9375rem; - font-weight: 600; - cursor: pointer; - transition: background 0.2s ease, transform 0.15s ease; - font-family: inherit; -} - -.nearMeBtn:hover:not(:disabled) { - background: var(--status-above); - transform: translateY(-1px); -} - -.nearMeBtn:disabled { - opacity: 0.7; - cursor: not-allowed; -} - -.nearMeSpinner { - display: inline-block; - width: 14px; - height: 14px; - border: 2px solid rgba(var(--text-inverse-rgb), 0.35); - border-top-color: var(--brand-on); - border-radius: 50%; - animation: nearMeSpin 0.7s linear infinite; - flex-shrink: 0; -} - -@keyframes nearMeSpin { - to { - transform: rotate(360deg); - } -} - -.geoError { - font-size: 0.8125rem; - color: var(--brand-strong); - margin: 0; - max-width: 340px; - text-align: center; -} - -@media (max-width: 600px) { - .nearMeBtn { - width: 100%; - justify-content: center; - } -} +/* ── Search ───────────────────────────────────────────────────────── */ .searchSection { margin-bottom: 0; @@ -127,43 +46,90 @@ .omniBoxContainer { display: flex; + align-items: center; gap: 0.5rem; } +/* The hero pill: hairline, soft corner, everything else sits inside it. */ +.heroMode .omniBoxContainer { + gap: 0.375rem; + padding: 0.3125rem; + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: var(--radius-md); + box-shadow: var(--shadow-soft); + transition: border-color var(--transition), box-shadow var(--transition); +} + +.heroMode .omniBoxContainer:focus-within { + border-color: var(--brand); + box-shadow: 0 0 0 3px rgba(var(--brand-rgb), 0.16); +} + +.omniIcon { + display: flex; + align-items: center; + justify-content: center; + flex: 0 0 auto; + padding-left: 0.625rem; + color: var(--text-muted); +} + .omniInput { flex: 1; - padding: 0.875rem 1.25rem; - font-size: 1.05rem; - border: 2px solid var(--border); - border-radius: 8px; - outline: none; - transition: all 0.2s ease; + min-width: 0; + padding: 0.6875rem 0.875rem; + font-family: var(--font-ui); + /* 1rem, not smaller: iOS Safari zooms the viewport on any control under + 16px, which throws the user out of the hero on first tap. */ + font-size: var(--step-0); + color: var(--text-primary); background: var(--bg-card); - font-family: inherit; -} - -.omniInput:focus { - border-color: var(--brand); - box-shadow: 0 0 0 3px var(--brand-bg); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + outline: none; + transition: border-color var(--transition), box-shadow var(--transition); } .omniInput::placeholder { color: var(--text-muted); } +.omniInput:focus { + border-color: var(--brand); + box-shadow: 0 0 0 3px rgba(var(--brand-rgb), 0.16); +} + +/* Inside the pill the input carries no chrome — the pill holds the border + and the focus ring, so the two never double up. */ +.heroMode .omniInput { + background: none; + border-color: transparent; + padding-left: 0.5rem; +} + +.heroMode .omniInput:focus { + border-color: transparent; + box-shadow: none; +} + .searchButton { - padding: 0.875rem 2rem; - font-size: 1.05rem; - border-radius: 8px; - min-width: 120px; + flex: 0 0 auto; + padding: 0.6875rem 1.25rem; + font-size: var(--step--1); + border-radius: var(--radius-sm); +} + +.heroMode .searchButton { + padding: 0.75rem 1.375rem; } .spinner { - width: 20px; - height: 20px; - border: 2px solid rgba(var(--text-inverse-rgb), 0.3); + width: 1.0625rem; + height: 1.0625rem; + border: 2px solid rgba(var(--text-inverse-rgb), 0.35); + border-top-color: currentColor; border-radius: 50%; - border-top-color: var(--brand-on); animation: spin 0.8s linear infinite; } @@ -171,103 +137,86 @@ to { transform: rotate(360deg); } } -.filters { - display: flex; - gap: 0.625rem; - flex-wrap: wrap; - margin-top: 0.625rem; - padding-top: 0.625rem; - border-top: 1px solid var(--border); +/* ── Hero supporting copy ─────────────────────────────────────────── */ + +.searchHint { + margin: 0.75rem 0 0; + font-family: var(--font-ui); + font-size: var(--step--1); + line-height: 1.5; + color: var(--text-muted); + text-align: center; } -.filterSelect { - appearance: none; - -webkit-appearance: none; - flex: 1; - min-width: 180px; - padding: 0.625rem 2.25rem 0.625rem 1rem; - font-size: 0.875rem; - font-weight: 500; - font-family: inherit; - border: 1.5px solid var(--border); - border-radius: 8px; - background-color: var(--bg-card); - background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'%3E%3Cpath d='M1 1l4 4 4-4' stroke='%238a847a' stroke-width='1.5' fill='none' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); - background-repeat: no-repeat; - background-position: right 0.875rem center; - background-size: 10px 6px; - cursor: pointer; - outline: none; - color: var(--text-primary); - transition: border-color 0.15s ease, box-shadow 0.15s ease; -} - -.filterSelect:hover { - border-color: var(--text-muted); -} - -.filterSelect:focus { - border-color: var(--brand); - box-shadow: 0 0 0 3px rgba(var(--brand-rgb), 0.12); -} - -.clearButton { - padding: 0.4rem 1rem; - font-size: 0.8125rem; - font-weight: 500; - border-radius: 999px; -} - -@media (max-width: 768px) { - .filterBar { - padding: 1rem; - } - - .omniBoxContainer { - flex-direction: column; - } - - .searchButton { - width: 100%; - } - - .filters { - flex-direction: column; - } - - .filterSelect { - min-width: 100%; - } - - .controlsRow { - gap: 0.5rem; - } - - .controlsRow .advancedToggle { - margin-left: 0; - } - - .controlSelect { - flex: 1; - min-width: 140px; - } -} - -.radiusWrapper { - display: flex; - align-items: center; - gap: 0.5rem; - margin-top: 0.5rem; -} - -.radiusLabel { - font-size: 0.8125rem; - font-weight: 500; +.searchHint strong { color: var(--text-secondary); - white-space: nowrap; + font-weight: 600; } -/* ── Controls row (radius + phase + advanced toggle) ─── */ +.nearMeRow { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.375rem; + margin-top: 0.25rem; +} + +/* Quiet text link, not a second button: the coral submit is the decisive + action and nothing beside it may compete for the same weight. */ +.nearMeBtn { + display: inline-flex; + align-items: center; + gap: 0.375rem; + padding: 0.25rem 0.375rem; + background: none; + border: 0; + border-radius: var(--radius-sm); + font-family: var(--font-ui); + font-size: var(--step--1); + font-weight: 600; + line-height: 1.4; + color: var(--brand); + cursor: pointer; + transition: color var(--transition); +} + +.nearMeBtn:hover:not(:disabled) { + color: var(--brand-strong); + text-decoration: underline; + text-underline-offset: 3px; +} + +.nearMeBtn:disabled { + opacity: 0.65; + cursor: not-allowed; +} + +.nearMeSpinner { + display: inline-block; + width: 13px; + height: 13px; + border: 2px solid rgba(var(--brand-rgb), 0.3); + border-top-color: var(--brand); + border-radius: 50%; + animation: nearMeSpin 0.7s linear infinite; + flex-shrink: 0; +} + +@keyframes nearMeSpin { + to { transform: rotate(360deg); } +} + +.geoError { + margin: 0; + max-width: 340px; + font-size: var(--step--1); + line-height: 1.45; + color: var(--brand-strong); + text-align: center; +} + +/* ── Controls row (radius + phase + advanced toggle) ──────────────── */ + .controlsRow { display: flex; align-items: center; @@ -289,65 +238,139 @@ flex-shrink: 0; } -/* Pill-style inline filter controls (radius + phase) */ -.controlSelect { - appearance: none; - -webkit-appearance: none; - padding: 0.4rem 2rem 0.4rem 0.875rem; - font-size: 0.8125rem; - font-weight: 500; - font-family: inherit; - border: 1.5px solid var(--border); - border-radius: 999px; - background-color: var(--bg-card); - background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'%3E%3Cpath d='M1 1l4 4 4-4' stroke='%238a847a' stroke-width='1.5' fill='none' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); - background-repeat: no-repeat; - background-position: right 0.65rem center; - background-size: 10px 6px; - color: var(--text-primary); - cursor: pointer; - outline: none; - transition: border-color 0.15s ease, background-color 0.15s ease, box-shadow 0.15s ease; +.radiusLabel { + font-size: var(--step--2); + font-weight: 600; + color: var(--text-secondary); white-space: nowrap; } -.controlSelect:hover { - border-color: var(--text-muted); +/* ── Selects ──────────────────────────────────────────────────────── */ + +.selectShell { + position: relative; + display: inline-flex; + align-items: center; + min-width: 0; +} + +.selectShellWide { + flex: 1; + min-width: 180px; +} + +.selectShell select { + width: 100%; +} + +/* Drawn, not imaged: a background-image arrow has to hardcode its own + colour, and would stay light-mode grey on the dark ground. */ +.selectChevron { + position: absolute; + right: 0.75rem; + top: 50%; + width: 0; + height: 0; + margin-top: -2px; + pointer-events: none; + color: var(--text-muted); + border-left: 4px solid transparent; + border-right: 4px solid transparent; + border-top: 5px solid currentColor; +} + +.filterSelect, +.controlSelect { + appearance: none; + -webkit-appearance: none; + font-family: var(--font-ui); + color: var(--text-primary); + background-color: var(--bg-card); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + cursor: pointer; + outline: none; + transition: border-color var(--transition), background-color var(--transition), + box-shadow var(--transition), color var(--transition); +} + +.filterSelect { + padding: 0.5625rem 2rem 0.5625rem 0.75rem; + font-size: var(--step--1); + font-weight: 500; +} + +.controlSelect { + padding: 0.4375rem 1.875rem 0.4375rem 0.75rem; + font-size: var(--step--1); + font-weight: 500; + white-space: nowrap; +} + +.filterSelect:hover:not(:disabled), +.controlSelect:hover:not(:disabled) { + border-color: var(--border-strong); background-color: var(--bg-secondary); } +.filterSelect:focus, .controlSelect:focus { border-color: var(--brand); - box-shadow: 0 0 0 3px rgba(var(--brand-rgb), 0.12); + box-shadow: 0 0 0 3px rgba(var(--brand-rgb), 0.16); } -/* ── Advanced filters toggle ─────────────────────────── */ +/* Selected — i.e. this control is narrowing the list. Sage tint, brand + line: the same pairing the results use for "on". */ +.selectActive { + background-color: rgba(var(--sage-rgb), 0.38); + border-color: var(--brand); + color: var(--brand-strong); + font-weight: 600; +} + +.selectActive:hover:not(:disabled) { + background-color: rgba(var(--sage-rgb), 0.5); + border-color: var(--brand); +} + +.filters { + display: flex; + gap: 0.625rem; + flex-wrap: wrap; + margin-top: 0.75rem; + padding-top: 0.75rem; + border-top: 1px solid var(--border); +} + +/* ── Advanced filters toggle ──────────────────────────────────────── */ .advancedToggle { display: inline-flex; align-items: center; gap: 0.375rem; + padding: 0.4375rem 0.75rem; background: none; - border: 1.5px solid var(--border); - border-radius: 999px; - padding: 0.4rem 0.875rem; - font-size: 0.8125rem; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + font-family: var(--font-ui); + font-size: var(--step--1); font-weight: 500; + line-height: 1; color: var(--text-secondary); cursor: pointer; - font-family: inherit; - transition: all 0.15s ease; white-space: nowrap; + transition: border-color var(--transition), background-color var(--transition), + color var(--transition); } .advancedToggle:hover { - border-color: var(--text-muted); + border-color: var(--border-strong); background-color: var(--bg-secondary); color: var(--text-primary); } -/* When filters are applied, promote the toggle to a coral pill so users - can see at a glance that the result list is being narrowed. */ +/* Applied filters read as a brand chip, so it's obvious at a glance that + the list below is being narrowed. */ .advancedToggleActive { border-color: var(--brand); background: var(--brand-bg); @@ -356,11 +379,18 @@ } .advancedToggleActive:hover { - border-color: var(--brand-strong); + border-color: var(--brand); background: var(--brand-bg); color: var(--brand-strong); } +.clearButton { + padding: 0.4375rem 0.875rem; + font-size: var(--step--1); + font-weight: 500; + border-radius: var(--radius-sm); +} + .chevronDown, .chevronUp { display: inline-block; @@ -377,3 +407,70 @@ .chevronUp { border-bottom: 4.5px solid currentColor; } + +/* ── Narrow ───────────────────────────────────────────────────────── */ + +@media (max-width: 768px) { + .filterBar { + padding: 0.875rem; + } + + /* The hero pill stays a pill at every width — stacking it would lose the + one shape the page is built around. Only the results bar stacks. */ + .filterBar:not(.heroMode) .omniBoxContainer { + flex-direction: column; + align-items: stretch; + } + + .filterBar:not(.heroMode) .searchButton { + width: 100%; + } + + .filters { + flex-direction: column; + } + + .selectShellWide { + min-width: 100%; + } + + .controlsRow { + gap: 0.5rem; + } + + .controlsRow .advancedToggle { + margin-left: 0; + } + + .controlSelect { + flex: 1; + min-width: 140px; + } + + .searchHint { + text-align: left; + } + + .nearMeRow { + align-items: flex-start; + } + + .geoError { + text-align: left; + } +} + +@media (max-width: 420px) { + /* Below this the pin costs more room than it earns. */ + .heroMode .omniIcon { + display: none; + } + + .heroMode .omniInput { + padding-left: 0.75rem; + } + + .heroMode .searchButton { + padding: 0.75rem 0.875rem; + } +} diff --git a/nextjs-app/components/FilterBar.tsx b/nextjs-app/components/FilterBar.tsx index 91abf00..0b542e5 100644 --- a/nextjs-app/components/FilterBar.tsx +++ b/nextjs-app/components/FilterBar.tsx @@ -1,6 +1,7 @@ "use client"; import { useState, useCallback, useTransition, useRef, useEffect } from "react"; +import type { ReactNode } from "react"; import { useRouter, useSearchParams, usePathname } from "next/navigation"; import { isValidPostcode } from "@/lib/utils"; import { track } from "@/lib/analytics"; @@ -18,6 +19,28 @@ interface FilterBarProps { geoError?: string | null; } +/** + * Wraps a native
{isHero && ( @@ -248,34 +292,38 @@ export function FilterBar({ {currentPostcode && (
- + + +
)} {phaseOptions.length > 0 && ( - + + + )}