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..cdbbb61 100644 --- a/nextjs-app/app/globals.css +++ b/nextjs-app/app/globals.css @@ -1,173 +1,201 @@ /* - * 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; + /* Tint of --action, not of --coral, so a coral ghost button's label and + ground share a hue. Unused today; the dark value was already correct. */ + --action-bg: rgba(190, 60, 39, 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; + /* The tint is the solid hue at low alpha, exactly as every other pair in + this file is. It briefly carried the raw --coral tuple instead, which + put a coral-pink wash behind terracotta text — the one mismatched pair + in the palette. Alpha matches --status-above-bg so the two chips read + as equal weight; at 0.15 the darker terracotta tint drops the label to + 4.45:1 on Warm White. */ + --status-below-bg: rgba(169, 72, 31, 0.12); + --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 +204,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 +448,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..ebf9b67 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,24 +35,24 @@ 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' }, ], }; export const metadata: Metadata = { appleWebApp: { capable: true, - title: 'SchoolCompare', + title: 'schoolcompare', statusBarStyle: 'default', }, title: { - default: 'SchoolCompare | Compare School Performance', - template: '%s | SchoolCompare', + default: 'schoolcompare | Compare School Performance', + template: '%s | schoolcompare', }, description: 'Compare primary and secondary school SATs and GCSE performance across England', keywords: 'school comparison, KS2 results, KS4 results, primary school, secondary school, England schools, SATs results, GCSE results', - authors: [{ name: 'SchoolCompare' }], + authors: [{ name: 'schoolcompare' }], manifest: '/manifest.json', // No `icons` key on purpose: setting it here would override the file // conventions. app/icon.svg and app/apple-icon.tsx are the source, and @@ -61,15 +60,15 @@ export const metadata: Metadata = { metadataBase: new URL('https://schoolcompare.co.uk'), openGraph: { type: 'website', - title: 'SchoolCompare | Compare School Performance', + title: 'schoolcompare | Compare School Performance', description: 'Compare primary and secondary school SATs and GCSE performance across England', url: 'https://schoolcompare.co.uk', - siteName: 'SchoolCompare', + siteName: 'schoolcompare', }, twitter: { // summary_large_image now that there is an image worth showing. card: 'summary_large_image', - title: 'SchoolCompare | Compare School Performance', + title: 'schoolcompare | Compare School Performance', description: 'Compare primary and secondary school SATs and GCSE performance across England', }, }; @@ -81,13 +80,14 @@ export default function RootLayout({ }>) { return ( // The font variable classes must sit on , not . globals.css - // declares --font-display/--font-ui on :root as var(--font-schibsted), - // and a custom property's var() references resolve on the element that - // declares it. With the classes on , --font-schibsted was undefined - // 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. - + // declares --font-display on :root as var(--font-manrope) and --font-ui as + // var(--font-inter), and a custom property's var() references resolve on + // the element that declares it. With the classes on , those two are + // undefined at :root, so --font-display computes to the guaranteed-invalid + // value and every font-family referencing it silently falls back — which + // once shipped the whole site in Times. Nothing throws and no unit test + // fails, so the e2e typeface assertion is the only guard. + 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/app/school/[slug]/page.tsx b/nextjs-app/app/school/[slug]/page.tsx index 56a3daf..538ee3d 100644 --- a/nextjs-app/app/school/[slug]/page.tsx +++ b/nextjs-app/app/school/[slug]/page.tsx @@ -98,7 +98,7 @@ export async function generateMetadata({ params }: SchoolPageProps): Promise 0 - ? `Comparing ${count} school${count === 1 ? '' : 's'} on SchoolCompare` - : 'SchoolCompare', + ? `Comparing ${count} school${count === 1 ? '' : 's'} on schoolcompare` + : 'schoolcompare', url, }; if ( diff --git a/nextjs-app/components/EditorialSection.tsx b/nextjs-app/components/EditorialSection.tsx index 0cbc4af..f875e99 100644 --- a/nextjs-app/components/EditorialSection.tsx +++ b/nextjs-app/components/EditorialSection.tsx @@ -20,7 +20,7 @@ export function EditorialSection({ totalSchools, localAuthorityCount }: Editoria and demographics — each in its own table, each with its own jargon.

- SchoolCompare brings it all into one place. Every school page shows performance against the national + schoolcompare brings it all into one place. Every school page shows performance against the national average, explains what the numbers mean, and lets you shortlist schools side by side. Built for parents, governors, journalists, and anyone who wants to understand a school without reading a full inspection report. 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 && ( - + + + )}