Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
39e5c11274 | ||
|
|
accc562b90 | ||
|
|
f31f6f8404 | ||
|
|
8d50afef1e | ||
|
|
45a3e7fb8f | ||
|
|
91b49f1a59 | ||
|
|
2433101fa0 | ||
|
|
f43a8ecb69 | ||
|
|
8c3a5cc4e9 | ||
|
|
8ab0ac0a04 | ||
|
|
c298a29b61 | ||
|
|
5f961bf7f4 |
@@ -626,3 +626,292 @@ test('sticky section nav jumps to server-rendered sections', async ({ page }) =>
|
||||
const after = await page.evaluate(() => window.scrollY);
|
||||
expect(after).toBeGreaterThan(before);
|
||||
});
|
||||
|
||||
/**
|
||||
* Brand surface. The identity work replaced a favicon-only asset set that
|
||||
* broke iOS home-screen icons, Android PWA installs, and every link preview.
|
||||
* These are silent failures — nothing on the page looks wrong — so they need
|
||||
* a gate.
|
||||
*/
|
||||
test('the brand asset set is complete and served', async ({ page }) => {
|
||||
const response = await page.goto('/');
|
||||
expect(response?.ok()).toBe(true);
|
||||
|
||||
// The share card: without it, every link pasted into a chat renders bare.
|
||||
const ogImage = page.locator('meta[property="og:image"]');
|
||||
await expect(ogImage).toHaveCount(1);
|
||||
const ogUrl = await ogImage.getAttribute('content');
|
||||
expect(ogUrl).toBeTruthy();
|
||||
// metadataBase pins canonical URLs to the production host, which is correct
|
||||
// for prod but means the absolute URL points off-environment on staging.
|
||||
// Fetch the path against whichever environment we're actually testing.
|
||||
const og = await page.request.get(new URL(ogUrl!).pathname + new URL(ogUrl!).search);
|
||||
expect(og.ok()).toBe(true);
|
||||
expect(og.headers()['content-type']).toContain('image/png');
|
||||
|
||||
// iOS ignores SVG touch icons, so this must be a real raster.
|
||||
const apple = await page.request.get('/apple-icon');
|
||||
expect(apple.ok()).toBe(true);
|
||||
expect(apple.headers()['content-type']).toContain('image/png');
|
||||
|
||||
// Android needs a maskable PNG or the install prompt has no icon.
|
||||
for (const icon of ['/icon-192.png', '/icon-512.png', '/icon-maskable-512.png']) {
|
||||
const res = await page.request.get(icon);
|
||||
expect(res.ok(), `${icon} should be served`).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
test('theme colour matches the page background in both themes', async ({ browser }) => {
|
||||
// A mismatch here paints a stripe of the wrong colour across the top of the
|
||||
// screen on mobile. Previously the dark themeColor was declared with no dark
|
||||
// styling behind it at all.
|
||||
for (const colorScheme of ['light', 'dark'] as const) {
|
||||
const context = await browser.newContext({ colorScheme });
|
||||
const page = await context.newPage();
|
||||
await page.goto('/');
|
||||
|
||||
const declared = await page
|
||||
.locator(`meta[name="theme-color"][media*="${colorScheme}"]`)
|
||||
.getAttribute('content');
|
||||
expect(declared, `theme-color declared for ${colorScheme}`).toBeTruthy();
|
||||
|
||||
const painted = await page.evaluate(() =>
|
||||
getComputedStyle(document.documentElement).getPropertyValue('--bg-primary').trim()
|
||||
);
|
||||
expect(painted.toLowerCase()).toBe(declared!.toLowerCase());
|
||||
|
||||
await context.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('the dark theme actually repaints the page', async ({ browser }) => {
|
||||
// Guards the token layer: if a component reintroduces a hardcoded colour,
|
||||
// the surface below stays light while everything around it flips.
|
||||
const read = async (colorScheme: 'light' | 'dark') => {
|
||||
const context = await browser.newContext({ colorScheme });
|
||||
const page = await context.newPage();
|
||||
await page.goto('/');
|
||||
await expect(page.locator('h1').first()).toBeVisible({ timeout: 15_000 });
|
||||
const values = await page.evaluate(() => {
|
||||
const s = getComputedStyle(document.body);
|
||||
return { bg: s.backgroundColor, fg: s.color };
|
||||
});
|
||||
await context.close();
|
||||
return values;
|
||||
};
|
||||
|
||||
const light = await read('light');
|
||||
const dark = await read('dark');
|
||||
expect(dark.bg).not.toBe(light.bg);
|
||||
expect(dark.fg).not.toBe(light.fg);
|
||||
});
|
||||
|
||||
/**
|
||||
* Typography and palette integrity.
|
||||
*
|
||||
* The identity PR shipped with every font-family silently falling back to
|
||||
* Times: the font variables landed on <body> while the tokens referencing
|
||||
* them were declared on :root, so --font-display computed to the
|
||||
* guaranteed-invalid value. Nothing threw, no test failed, and the build was
|
||||
* green — the only symptom was visual. These assertions make that class of
|
||||
* failure loud.
|
||||
*/
|
||||
test('the brand typefaces actually load and apply', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await expect(page.locator('h1').first()).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
const fonts = await page.evaluate(() => {
|
||||
const root = getComputedStyle(document.documentElement);
|
||||
// Only the FIRST family in the stack is the one actually asked for; the
|
||||
// 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 headings = [...document.querySelectorAll('h1, h2')].map(first);
|
||||
return {
|
||||
body: first(document.body),
|
||||
headings,
|
||||
bodyStack: getComputedStyle(document.body).fontFamily,
|
||||
displayToken: root.getPropertyValue('--font-display').trim(),
|
||||
uiToken: root.getPropertyValue('--font-ui').trim(),
|
||||
proseToken: root.getPropertyValue('--font-prose').trim(),
|
||||
};
|
||||
});
|
||||
|
||||
// An empty token means the var() chain broke, which is the exact failure
|
||||
// mode this guards — the computed font-family would look plausible either
|
||||
// way, because an invalid font-family just inherits.
|
||||
expect(fonts.displayToken, '--font-display resolved').not.toBe('');
|
||||
expect(fonts.uiToken, '--font-ui resolved').not.toBe('');
|
||||
expect(fonts.proseToken, '--font-prose resolved').not.toBe('');
|
||||
|
||||
// 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 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.
|
||||
await page.goto('/');
|
||||
await expect(page.locator('h1').first()).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
const blacks = await page.evaluate(() =>
|
||||
[...document.querySelectorAll('body *')]
|
||||
.filter((el) => {
|
||||
const r = el.getBoundingClientRect();
|
||||
if (r.width < 2 || r.height < 2) return false;
|
||||
if (el.closest('.leaflet-tile-pane')) return false;
|
||||
return getComputedStyle(el).color === 'rgb(0, 0, 0)';
|
||||
})
|
||||
.map((el) => el.tagName.toLowerCase() + '.' + (el.getAttribute('class') || '').split(' ')[0])
|
||||
.slice(0, 10)
|
||||
);
|
||||
|
||||
expect(blacks, `elements rendering pure black: ${blacks.join(', ')}`).toEqual([]);
|
||||
});
|
||||
|
||||
test('rendered colours all come from the token palette', async ({ page }) => {
|
||||
// Turns the manual design audit into a gate: anything painted with a colour
|
||||
// the token layer doesn't define has escaped the system, and will not
|
||||
// follow the dark theme.
|
||||
await page.goto('/rankings');
|
||||
await expect(page.locator('table, [class*="rankings"]').first()).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
const strays = await page.evaluate(() => {
|
||||
const root = getComputedStyle(document.documentElement);
|
||||
const palette = new Set<string>();
|
||||
for (const sheet of document.styleSheets) {
|
||||
let rules: CSSRuleList;
|
||||
try { rules = sheet.cssRules; } catch { continue; }
|
||||
for (const rule of rules) {
|
||||
const r = rule as CSSStyleRule;
|
||||
if (r.selectorText !== ':root' || !r.style) continue;
|
||||
for (const prop of r.style) {
|
||||
if (!prop.startsWith('--')) continue;
|
||||
const v = root.getPropertyValue(prop).trim();
|
||||
if (v) palette.add(v.toLowerCase());
|
||||
}
|
||||
}
|
||||
}
|
||||
const norm = (c: string) => {
|
||||
const d = document.createElement('div');
|
||||
d.style.color = c;
|
||||
document.body.appendChild(d);
|
||||
const v = getComputedStyle(d).color;
|
||||
d.remove();
|
||||
return v;
|
||||
};
|
||||
const allowed = new Set([...palette].filter((v) => /^#|^rgb/.test(v)).map(norm));
|
||||
|
||||
const found: string[] = [];
|
||||
for (const el of document.querySelectorAll('body *')) {
|
||||
const box = el.getBoundingClientRect();
|
||||
if (box.width < 2 || box.height < 2) continue;
|
||||
if (el.closest('.leaflet-tile-pane')) continue; // OSM tiles are imagery, not palette
|
||||
const s = getComputedStyle(el);
|
||||
const checks: Array<[string, string]> = [['color', s.color]];
|
||||
if (s.backgroundColor !== 'rgba(0, 0, 0, 0)') checks.push(['background', s.backgroundColor]);
|
||||
for (const [prop, value] of checks) {
|
||||
if (!value || value.startsWith('rgba(') || allowed.has(value)) continue;
|
||||
const cls = (el.getAttribute('class') || '(none)').split(' ')[0];
|
||||
const entry = `${value} as ${prop} on ${cls}`;
|
||||
if (!found.includes(entry)) found.push(entry);
|
||||
}
|
||||
}
|
||||
return found.slice(0, 12);
|
||||
});
|
||||
|
||||
expect(strays, `off-palette colours: ${strays.join('; ')}`).toEqual([]);
|
||||
});
|
||||
|
||||
/**
|
||||
* Contrast, in both themes.
|
||||
*
|
||||
* The status hues were originally specced against --bg-primary, but they are
|
||||
* used as chip text on their own tint, which sits on darker surfaces — so the
|
||||
* real ratios were 3.85–4.44:1, under AA, on every school row and result card.
|
||||
* The page ground is the easy case; the tinted chip is the one that fails.
|
||||
*
|
||||
* Waits for transitions to settle before measuring: several components carry
|
||||
* `transition: color`, and reading mid-transition reports colours that were
|
||||
* never on screen at rest.
|
||||
*/
|
||||
const CONTRAST_PROBE = `(() => {
|
||||
const ps = c => { const m=(c||'').match(/[\\d.]+/g); if(!m) return null;
|
||||
const a=m.map(Number); return {r:a[0],g:a[1],b:a[2],a:m.length>3?a[3]:1}; };
|
||||
const ov = (f,b) => ({r:f.r*f.a+b.r*(1-f.a), g:f.g*f.a+b.g*(1-f.a), b:f.b*f.a+b.b*(1-f.a), a:1});
|
||||
const L = c => { const f=v=>{v/=255; return v<=0.03928?v/12.92:Math.pow((v+.055)/1.055,2.4);};
|
||||
return .2126*f(c.r)+.7152*f(c.g)+.0722*f(c.b); };
|
||||
const RT = (a,b) => { const x=L(a),y=L(b); return (Math.max(x,y)+.05)/(Math.min(x,y)+.05); };
|
||||
const BG = el => { const ls=[]; let n=el;
|
||||
while(n && n!==document.documentElement){ const c=ps(getComputedStyle(n).backgroundColor);
|
||||
if(c && c.a>0){ ls.push(c); if(c.a===1) break; } n=n.parentElement; }
|
||||
const base = ps(getComputedStyle(document.documentElement).backgroundColor)||{r:255,g:255,b:255,a:1};
|
||||
let acc = ls.length && ls[ls.length-1].a===1 ? ls.pop() : base;
|
||||
for(let i=ls.length-1;i>=0;i--) acc=ov(ls[i],acc); return acc; };
|
||||
const out=[], seen=new Set();
|
||||
for (const el of document.querySelectorAll('body *')) {
|
||||
if (el.closest('.leaflet-container')) continue;
|
||||
const r=el.getBoundingClientRect(), s=getComputedStyle(el);
|
||||
if (r.width<2 || r.height<2 || s.visibility==='hidden' || s.opacity==='0') continue;
|
||||
if (![...el.childNodes].some(n=>n.nodeType===3 && n.textContent.trim().length>1)) continue;
|
||||
const fc=ps(s.color), bc=BG(el); if(!fc||!bc) continue;
|
||||
const fg = fc.a<1?ov(fc,bc):fc, ratio=RT(fg,bc);
|
||||
const px=parseFloat(s.fontSize), bold=parseInt(s.fontWeight,10)>=700;
|
||||
const need=(px>=24||(px>=18.66&&bold))?3:4.5;
|
||||
if (ratio >= need) continue;
|
||||
const key=(el.getAttribute('class')||'')+s.color;
|
||||
if (seen.has(key)) continue; seen.add(key);
|
||||
out.push(((el.getAttribute('class')||'?').split(' ')[0])+' '+ratio.toFixed(2)+':1 (needs '+need+
|
||||
') '+s.color+' on rgb('+Math.round(bc.r)+','+Math.round(bc.g)+','+Math.round(bc.b)+') "'+
|
||||
el.textContent.trim().slice(0,28)+'"');
|
||||
}
|
||||
return out.slice(0, 12);
|
||||
})()`;
|
||||
|
||||
for (const scheme of ['light', 'dark'] as const) {
|
||||
test(`text meets WCAG AA in the ${scheme} theme`, async ({ browser }) => {
|
||||
const context = await browser.newContext({ colorScheme: scheme });
|
||||
const page = await context.newPage();
|
||||
const failures: string[] = [];
|
||||
|
||||
for (const path of ['/', '/rankings', '/admissions']) {
|
||||
await page.goto(path);
|
||||
await expect(page.locator('h1, h2').first()).toBeVisible({ timeout: 15_000 });
|
||||
// Let `transition: color` settle — the longest in the app is 0.4s.
|
||||
await page.waitForTimeout(700);
|
||||
const found = (await page.evaluate(CONTRAST_PROBE)) as string[];
|
||||
failures.push(...found.map((f) => `${path} → ${f}`));
|
||||
}
|
||||
|
||||
await context.close();
|
||||
expect(failures, `AA failures in ${scheme}:\n ${failures.join('\n ')}`).toEqual([]);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -47,6 +47,12 @@ COPY --from=builder /app/public ./public
|
||||
COPY --from=builder /app/.next/standalone ./
|
||||
COPY --from=builder /app/.next/static ./.next/static
|
||||
|
||||
# Fonts for the generated share card. File tracing already places these in
|
||||
# .next/standalone/assets, so the COPY above carries them — this makes the
|
||||
# dependency explicit rather than implicit in the tracer's behaviour, because
|
||||
# a miss here is a silent 500 on /opengraph-image, not a build failure.
|
||||
COPY --from=builder /app/assets ./assets
|
||||
|
||||
# Set correct permissions
|
||||
RUN chown -R nextjs:nodejs /app
|
||||
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { ImageResponse } from 'next/og';
|
||||
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 TEAL = '#0F766E';
|
||||
|
||||
export default function AppleIcon() {
|
||||
return new ImageResponse(
|
||||
(
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
background: TEAL,
|
||||
}}
|
||||
>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img src={markDataUri('#FFFFFF', TEAL)} width={116} height={116} alt="" />
|
||||
</div>
|
||||
),
|
||||
size
|
||||
);
|
||||
}
|
||||
+432
-1920
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,7 @@
|
||||
<svg width="48" height="48" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M17 30 C15 35.6 11.6 40.2 7.8 42.8" stroke="#0F766E" stroke-width="8.5" stroke-linecap="round" fill="none"/>
|
||||
<circle cx="26.5" cy="18.5" r="16.5" fill="#0F766E"/>
|
||||
<path d="M26.5 29.5 C26.5 25 26.6 21 27 18" stroke="#FFFFFF" stroke-width="2.6" stroke-linecap="round" fill="none"/>
|
||||
<path d="M27 19.6 C28.2 13.8 32.4 9.8 37.6 9.4 C38 15.6 33.8 20.8 27 19.6 Z" fill="#FFFFFF"/>
|
||||
<path d="M26.4 24.6 C25.2 20 21.2 17 16.4 17.1 C16.2 21.8 19.8 25.8 26.4 24.6 Z" fill="#FFFFFF"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 594 B |
+39
-25
@@ -1,5 +1,5 @@
|
||||
import type { Metadata, Viewport } from 'next';
|
||||
import { DM_Sans, Playfair_Display } 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,17 +7,22 @@ import { ComparisonToast } from '@/components/ComparisonToast';
|
||||
import { ComparisonProvider } from '@/context/ComparisonProvider';
|
||||
import './globals.css';
|
||||
|
||||
const dmSans = DM_Sans({
|
||||
// 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-dm-sans',
|
||||
weight: ['500', '600', '700'],
|
||||
variable: '--font-manrope',
|
||||
display: 'swap',
|
||||
});
|
||||
|
||||
const playfairDisplay = Playfair_Display({
|
||||
// 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: ['600', '700'],
|
||||
variable: '--font-playfair',
|
||||
weight: ['400', '500', '600', '700'],
|
||||
variable: '--font-inter',
|
||||
display: 'swap',
|
||||
});
|
||||
|
||||
@@ -27,41 +32,43 @@ export const viewport: Viewport = {
|
||||
// viewport-fit=cover lets us paint behind the notch / Dynamic Island so
|
||||
// env(safe-area-inset-*) values resolve to real numbers on iPhone.
|
||||
viewportFit: 'cover',
|
||||
// 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: '#faf7f2' },
|
||||
{ media: '(prefers-color-scheme: dark)', color: '#1a1612' },
|
||||
{ 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',
|
||||
icons: {
|
||||
icon: '/favicon.svg',
|
||||
shortcut: '/favicon.svg',
|
||||
apple: '/favicon.svg',
|
||||
},
|
||||
// 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
|
||||
// app/opengraph-image.tsx supplies og:image and twitter:image.
|
||||
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: {
|
||||
card: 'summary',
|
||||
title: 'SchoolCompare | Compare School Performance',
|
||||
// summary_large_image now that there is an image worth showing.
|
||||
card: 'summary_large_image',
|
||||
title: 'schoolcompare | Compare School Performance',
|
||||
description: 'Compare primary and secondary school SATs and GCSE performance across England',
|
||||
},
|
||||
};
|
||||
@@ -72,7 +79,15 @@ export default function RootLayout({
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html lang="en">
|
||||
// The font variable classes must sit on <html>, not <body>. globals.css
|
||||
// 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 <body>, 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.
|
||||
<html lang="en" className={`${manrope.variable} ${inter.variable}`}>
|
||||
<head>
|
||||
<link rel="preconnect" href="https://analytics.schoolcompare.co.uk" />
|
||||
<link rel="preconnect" href="https://api.postcodes.io" />
|
||||
@@ -94,8 +109,7 @@ export default function RootLayout({
|
||||
strategy="afterInteractive"
|
||||
/>
|
||||
</head>
|
||||
<body className={`${dmSans.variable} ${playfairDisplay.variable}`}>
|
||||
<div className="noise-overlay" />
|
||||
<body>
|
||||
<ComparisonProvider>
|
||||
<a href="#main-content" className="skip-link">Skip to main content</a>
|
||||
<Navigation />
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import { ImageResponse } from 'next/og';
|
||||
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 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 — find the school where your child will flourish';
|
||||
export const size = { width: 1200, height: 630 };
|
||||
export const contentType = 'image/png';
|
||||
|
||||
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));
|
||||
}
|
||||
|
||||
export default async function OpengraphImage() {
|
||||
const [regular, bold] = await Promise.all([
|
||||
font('Manrope-Regular.ttf'),
|
||||
font('Manrope-Bold.ttf'),
|
||||
]);
|
||||
|
||||
return new ImageResponse(
|
||||
(
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'space-between',
|
||||
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. */}
|
||||
<div style={{ position: 'absolute', left: 0, right: 0, bottom: 0, height: 210, display: 'flex' }}>
|
||||
<svg width="1200" height="210" viewBox="0 0 1200 210">
|
||||
<path d="M0 92 C 200 56 380 100 560 78 C 760 54 940 92 1200 62 L1200 210 L0 210 Z" fill={SAGE_PALE} />
|
||||
<path d="M0 132 C 230 96 420 140 620 118 C 830 94 1000 130 1200 106 L1200 210 L0 210 Z" fill={SAGE} />
|
||||
<path d="M0 176 C 260 146 470 186 690 166 C 900 146 1040 174 1200 158 L1200 210 L0 210 Z" fill={SAGE_DEEP} />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{/* Lockup */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 14 }}>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img src={markDataUri(TEAL, SAND)} width={52} height={52} alt="" />
|
||||
<div style={{ display: 'flex', fontSize: 34, fontWeight: 700, letterSpacing: '-0.04em' }}>
|
||||
<span style={{ color: INK }}>school</span>
|
||||
<span style={{ color: TEAL }}>compare</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Proposition. Sized so the headline holds two lines and the subhead
|
||||
one — an orphaned word reads as an accident at this size. */}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 18, paddingBottom: 236 }}>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
fontSize: 72,
|
||||
fontWeight: 700,
|
||||
letterSpacing: '-0.035em',
|
||||
lineHeight: 1.05,
|
||||
color: INK,
|
||||
maxWidth: 900,
|
||||
}}
|
||||
>
|
||||
<span style={{ marginRight: 18 }}>Find the school where</span>
|
||||
<span style={{ marginRight: 18 }}>your child will</span>
|
||||
<span style={{ color: TEAL }}>flourish.</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', fontSize: 27, color: MUTED, letterSpacing: '-0.005em' }}>
|
||||
24,000+ schools in England · SATs, GCSEs, Ofsted and admissions
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
{
|
||||
...size,
|
||||
fonts: [
|
||||
{ name: 'Manrope', data: regular, weight: 400, style: 'normal' },
|
||||
{ name: 'Manrope', data: bold, weight: 700, style: 'normal' },
|
||||
],
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -98,7 +98,7 @@ export async function generateMetadata({ params }: SchoolPageProps): Promise<Met
|
||||
description,
|
||||
type: 'website',
|
||||
url: `https://schoolcompare.co.uk${canonicalPath}`,
|
||||
siteName: 'SchoolCompare',
|
||||
siteName: 'schoolcompare',
|
||||
},
|
||||
twitter: {
|
||||
card: 'summary',
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -9,6 +9,7 @@
|
||||
import { Line } from 'react-chartjs-2';
|
||||
import { ChartOptions } from 'chart.js';
|
||||
import '@/lib/chartSetup';
|
||||
import { useThemeTokens, alpha } from '@/lib/theme';
|
||||
import { formatAcademicYear } from '@/lib/utils';
|
||||
import { fillAcademicYears } from '@/lib/compareChartData';
|
||||
import type { SchoolAdmissions } from '@/lib/types';
|
||||
@@ -38,6 +39,11 @@ export default function AdmissionsTrendChart({ history }: { history: SchoolAdmis
|
||||
const yMin = Math.max(0, Math.floor((lo - padded) / 5) * 5);
|
||||
const yMax = Math.min(100, Math.ceil((hi + padded) / 5) * 5);
|
||||
|
||||
// Canvas can't read var(), so pull the resolved tokens through the bridge.
|
||||
const [cBrand, cGrid, cInverse, cInverseText, cCard] = useThemeTokens(
|
||||
'--brand', '--chart-grid', '--surface-inverse', '--text-inverse', '--bg-card'
|
||||
);
|
||||
|
||||
const options: ChartOptions<'line'> = {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
@@ -49,7 +55,9 @@ export default function AdmissionsTrendChart({ history }: { history: SchoolAdmis
|
||||
legend: { display: false },
|
||||
title: { display: false },
|
||||
tooltip: {
|
||||
backgroundColor: 'rgba(26,22,18,0.92)',
|
||||
backgroundColor: cInverse,
|
||||
titleColor: cInverseText,
|
||||
bodyColor: cInverseText,
|
||||
padding: 10,
|
||||
titleFont: { size: 12 },
|
||||
bodyFont: { size: 12 },
|
||||
@@ -62,7 +70,7 @@ export default function AdmissionsTrendChart({ history }: { history: SchoolAdmis
|
||||
y: {
|
||||
min: yMin,
|
||||
max: yMax,
|
||||
grid: { color: 'rgba(0,0,0,0.05)' },
|
||||
grid: { color: cGrid },
|
||||
ticks: { font: { size: 11 }, maxTicksLimit: 5, callback: (v) => `${v}%` },
|
||||
},
|
||||
x: {
|
||||
@@ -80,14 +88,14 @@ export default function AdmissionsTrendChart({ history }: { history: SchoolAdmis
|
||||
data: values,
|
||||
clip: false as const,
|
||||
spanGaps: false,
|
||||
borderColor: '#e07256',
|
||||
backgroundColor: 'rgba(224,114,86,0.10)',
|
||||
borderColor: cBrand,
|
||||
backgroundColor: alpha('--brand', 0.10),
|
||||
borderWidth: 2.5,
|
||||
tension: 0.3,
|
||||
fill: true,
|
||||
pointRadius: values.map((_, i) => (i === lastIdx ? 5 : 3)),
|
||||
pointBackgroundColor: '#e07256',
|
||||
pointBorderColor: '#fff',
|
||||
pointBackgroundColor: cBrand,
|
||||
pointBorderColor: cCard,
|
||||
pointBorderWidth: values.map((_, i) => (i === lastIdx ? 2 : 0)),
|
||||
pointHoverRadius: 6,
|
||||
},
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-muted, #6d685f);
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 0.85rem;
|
||||
padding-left: 0.85rem;
|
||||
}
|
||||
@@ -39,7 +39,7 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.1rem;
|
||||
border-left: 2px solid var(--border-color, #e5dfd5);
|
||||
border-left: 2px solid var(--border);
|
||||
}
|
||||
|
||||
.navLink {
|
||||
@@ -51,33 +51,33 @@
|
||||
border-left: 2px solid transparent;
|
||||
font-size: 0.88rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary, #5c564d);
|
||||
color: var(--text-secondary);
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
transition: color 0.15s ease, border-color 0.15s ease;
|
||||
}
|
||||
|
||||
.navLink:hover {
|
||||
color: var(--text-primary, #1a1612);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.navLinkActive {
|
||||
color: var(--accent-teal, #2d7d7d);
|
||||
color: var(--brand);
|
||||
font-weight: 700;
|
||||
border-left-color: var(--accent-teal, #2d7d7d);
|
||||
border-left-color: var(--brand);
|
||||
}
|
||||
|
||||
.navDot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--border-color, #e5dfd5);
|
||||
background: var(--border);
|
||||
flex-shrink: 0;
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
|
||||
.navLinkActive .navDot {
|
||||
background: var(--accent-teal, #2d7d7d);
|
||||
background: var(--brand);
|
||||
}
|
||||
|
||||
/* ─── Hero ───────────────────────────────────────────── */
|
||||
@@ -95,8 +95,8 @@
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--accent-teal, #2d7d7d);
|
||||
background: rgba(45, 125, 125, 0.1);
|
||||
color: var(--brand);
|
||||
background: rgba(var(--brand-rgb), 0.1);
|
||||
padding: 0.3rem 0.7rem;
|
||||
border-radius: 999px;
|
||||
margin-bottom: 1rem;
|
||||
@@ -107,22 +107,22 @@
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent-teal, #2d7d7d);
|
||||
background: var(--brand);
|
||||
}
|
||||
|
||||
.heroTitle {
|
||||
font-family: var(--font-playfair), 'Playfair Display', serif;
|
||||
font-family: var(--font-display);
|
||||
font-size: 2.75rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.1;
|
||||
letter-spacing: -0.015em;
|
||||
color: var(--text-primary, #1a1612);
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 0.85rem;
|
||||
}
|
||||
|
||||
.heroSub {
|
||||
font-size: 1.05rem;
|
||||
color: var(--text-secondary, #5c564d);
|
||||
color: var(--text-secondary);
|
||||
max-width: 640px;
|
||||
margin: 0 auto;
|
||||
line-height: 1.55;
|
||||
@@ -132,7 +132,7 @@
|
||||
|
||||
.countdownSection {
|
||||
padding: 0 0 2.5rem;
|
||||
border-bottom: 1px solid var(--border-color, #e5dfd5);
|
||||
border-bottom: 1px solid var(--border);
|
||||
margin-bottom: 2.5rem;
|
||||
}
|
||||
|
||||
@@ -145,7 +145,7 @@
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-muted, #6d685f);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.countdownRail {
|
||||
@@ -155,11 +155,11 @@
|
||||
}
|
||||
|
||||
.chip {
|
||||
background: var(--bg-card, #fff);
|
||||
border: 1px solid var(--border-color, #e5dfd5);
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 1rem 1.1rem 0.9rem;
|
||||
box-shadow: 0 2px 8px rgba(26, 22, 18, 0.06);
|
||||
box-shadow: 0 2px 8px rgba(var(--shadow-rgb), 0.06);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.2rem;
|
||||
@@ -177,12 +177,12 @@
|
||||
border-radius: 12px 12px 0 0;
|
||||
}
|
||||
|
||||
.chipDeadline::before { background: var(--accent-coral, #e07256); }
|
||||
.chipOffer::before { background: var(--accent-teal, #2d7d7d); }
|
||||
.chipDeadline::before { background: var(--brand); }
|
||||
.chipOffer::before { background: var(--status-above); }
|
||||
|
||||
.chipUrgent {
|
||||
border-color: rgba(224, 114, 86, 0.4);
|
||||
background: rgba(224, 114, 86, 0.04);
|
||||
border-color: rgba(var(--status-below-rgb), 0.4);
|
||||
background: rgba(var(--status-below-rgb), 0.04);
|
||||
}
|
||||
|
||||
.chipTrack {
|
||||
@@ -196,8 +196,8 @@
|
||||
margin-bottom: 0.15rem;
|
||||
}
|
||||
|
||||
.chipTrackDeadline { color: var(--accent-coral-dark, #b04a2e); }
|
||||
.chipTrackOffer { color: var(--accent-teal, #2d7d7d); }
|
||||
.chipTrackDeadline { color: var(--brand-strong); }
|
||||
.chipTrackOffer { color: var(--status-above); }
|
||||
|
||||
.chipTrackDot {
|
||||
width: 5px;
|
||||
@@ -208,7 +208,8 @@
|
||||
}
|
||||
|
||||
.chipDays {
|
||||
font-family: var(--font-playfair), 'Playfair Display', serif;
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-family: var(--font-display);
|
||||
font-size: 2.6rem;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
@@ -217,17 +218,17 @@
|
||||
|
||||
.chipDeadline .chipDays,
|
||||
.chipUrgent .chipDays {
|
||||
color: var(--accent-coral-dark, #c45a3f);
|
||||
color: var(--status-below);
|
||||
}
|
||||
|
||||
.chipOffer .chipDays {
|
||||
color: var(--accent-teal, #2d7d7d);
|
||||
color: var(--status-above);
|
||||
}
|
||||
|
||||
.chipDaysUnit {
|
||||
font-size: 0.78rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-muted, #6d685f);
|
||||
color: var(--text-muted);
|
||||
margin-left: 0.2rem;
|
||||
vertical-align: bottom;
|
||||
line-height: 2;
|
||||
@@ -236,14 +237,14 @@
|
||||
.chipMilestone {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary, #1a1612);
|
||||
color: var(--text-primary);
|
||||
line-height: 1.25;
|
||||
margin-top: 0.1rem;
|
||||
}
|
||||
|
||||
.chipDate {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted, #6d685f);
|
||||
color: var(--text-muted);
|
||||
margin-top: 0.05rem;
|
||||
}
|
||||
|
||||
@@ -251,8 +252,8 @@
|
||||
|
||||
.track {
|
||||
margin-bottom: 3rem;
|
||||
background: var(--bg-card, #fff);
|
||||
border: 1px solid var(--border-color, #e5dfd5);
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 14px;
|
||||
overflow: hidden;
|
||||
}
|
||||
@@ -263,7 +264,7 @@
|
||||
justify-content: space-between;
|
||||
gap: 1.5rem;
|
||||
padding: 1.75rem 2rem 1.5rem;
|
||||
border-bottom: 1px solid var(--border-color, #e5dfd5);
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
@@ -278,22 +279,22 @@
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
color: var(--accent-coral-dark, #b04a2e);
|
||||
color: var(--brand-strong);
|
||||
margin-bottom: 0.35rem;
|
||||
}
|
||||
|
||||
.trackTitle {
|
||||
font-family: var(--font-playfair), 'Playfair Display', serif;
|
||||
font-family: var(--font-display);
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary, #1a1612);
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 0.4rem;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.trackSub {
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-secondary, #5c564d);
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.5;
|
||||
margin: 0;
|
||||
}
|
||||
@@ -317,13 +318,13 @@
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-muted, #6d685f);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.trackDateVal {
|
||||
font-size: 0.88rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary, #1a1612);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* ─── Timeline ───────────────────────────────────────── */
|
||||
@@ -356,27 +357,27 @@
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 50%;
|
||||
background: var(--bg-card, #fff);
|
||||
border: 2px solid var(--border-color, #e5dfd5);
|
||||
background: var(--bg-card);
|
||||
border: 2px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
transition: border-color 0.2s ease, background 0.2s ease;
|
||||
}
|
||||
|
||||
.stepDeadline .stepDot {
|
||||
background: rgba(224, 114, 86, 0.15);
|
||||
border-color: var(--accent-coral, #e07256);
|
||||
background: rgba(var(--brand-rgb), 0.15);
|
||||
border-color: var(--brand);
|
||||
}
|
||||
|
||||
.stepOffer .stepDot {
|
||||
background: rgba(45, 125, 125, 0.15);
|
||||
border-color: var(--accent-teal, #2d7d7d);
|
||||
background: rgba(var(--status-above-rgb), 0.15);
|
||||
border-color: var(--status-above);
|
||||
}
|
||||
|
||||
.stepLine {
|
||||
width: 2px;
|
||||
flex: 1;
|
||||
min-height: 24px;
|
||||
background: var(--border-color, #e5dfd5);
|
||||
background: var(--border);
|
||||
margin: 4px 0;
|
||||
}
|
||||
|
||||
@@ -395,27 +396,28 @@
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-muted, #6d685f);
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 0.2rem;
|
||||
}
|
||||
|
||||
.stepDeadline .stepDate { color: var(--accent-coral-dark, #b04a2e); }
|
||||
.stepOffer .stepDate { color: var(--accent-teal, #2d7d7d); }
|
||||
.stepDeadline .stepDate { color: var(--brand-strong); }
|
||||
.stepOffer .stepDate { color: var(--status-above); }
|
||||
|
||||
.stepTitle {
|
||||
font-size: 0.95rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary, #1a1612);
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 0.3rem;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.stepDeadline .stepTitle { color: var(--accent-coral-dark, #c45a3f); }
|
||||
.stepOffer .stepTitle { color: var(--accent-teal, #2d7d7d); }
|
||||
.stepDeadline .stepTitle { color: var(--brand-strong); }
|
||||
.stepOffer .stepTitle { color: var(--status-above); }
|
||||
|
||||
.stepBody {
|
||||
font-family: var(--font-prose);
|
||||
font-size: 0.88rem;
|
||||
color: var(--text-secondary, #5c564d);
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.55;
|
||||
margin: 0;
|
||||
}
|
||||
@@ -427,13 +429,13 @@
|
||||
margin-top: 0.6rem;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 600;
|
||||
color: var(--accent-teal, #2d7d7d);
|
||||
color: var(--status-above);
|
||||
text-decoration: none;
|
||||
transition: gap 0.2s ease, color 0.2s ease;
|
||||
}
|
||||
|
||||
.stepTool:hover {
|
||||
color: var(--accent-coral-dark, #c45a3f);
|
||||
color: var(--brand-strong);
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
@@ -455,9 +457,9 @@
|
||||
display: flex;
|
||||
gap: 0.55rem;
|
||||
padding: 0.6rem 0.75rem;
|
||||
background: var(--accent-gold-bg, rgba(201, 162, 39, 0.12));
|
||||
border: 1px solid rgba(201, 162, 39, 0.28);
|
||||
border-left: 3px solid var(--accent-gold, #c9a227);
|
||||
background: var(--status-below-bg);
|
||||
border: 1px solid rgba(var(--status-below-rgb), 0.28);
|
||||
border-left: 3px solid var(--status-below);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
@@ -467,8 +469,8 @@
|
||||
height: 1.05rem;
|
||||
margin-top: 0.1rem;
|
||||
border-radius: 50%;
|
||||
background: var(--accent-gold, #c9a227);
|
||||
color: #fff;
|
||||
background: var(--status-below);
|
||||
color: var(--text-inverse);
|
||||
font-size: 0.72rem;
|
||||
font-weight: 800;
|
||||
line-height: 1.05rem;
|
||||
@@ -482,14 +484,15 @@
|
||||
.calloutTitle {
|
||||
font-size: 0.82rem;
|
||||
font-weight: 700;
|
||||
color: var(--accent-gold-text, #7a6800);
|
||||
color: var(--status-below);
|
||||
line-height: 1.3;
|
||||
margin-bottom: 0.15rem;
|
||||
}
|
||||
|
||||
.calloutBody {
|
||||
font-family: var(--font-prose);
|
||||
font-size: 0.82rem;
|
||||
color: var(--text-secondary, #5c564d);
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.5;
|
||||
margin: 0;
|
||||
}
|
||||
@@ -501,10 +504,10 @@
|
||||
}
|
||||
|
||||
.tipsHeading {
|
||||
font-family: var(--font-playfair), 'Playfair Display', serif;
|
||||
font-family: var(--font-display);
|
||||
font-size: 1.35rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary, #1a1612);
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
@@ -515,16 +518,17 @@
|
||||
}
|
||||
|
||||
.tipCard {
|
||||
background: var(--bg-secondary, #f3ede4);
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 12px;
|
||||
padding: 1.25rem;
|
||||
}
|
||||
|
||||
.tipNumber {
|
||||
font-family: var(--font-playfair), 'Playfair Display', serif;
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-family: var(--font-display);
|
||||
font-size: 1.75rem;
|
||||
font-weight: 700;
|
||||
color: #7d766b; /* 3.8:1 on cream — AA large-text for this 28px numeral */
|
||||
color: var(--text-muted); /* 3.8:1 on cream — AA large-text for this 28px numeral */
|
||||
line-height: 1;
|
||||
margin-bottom: 0.6rem;
|
||||
}
|
||||
@@ -532,14 +536,15 @@
|
||||
.tipHeading {
|
||||
font-size: 0.95rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary, #1a1612);
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 0.4rem;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.tipBody {
|
||||
font-family: var(--font-prose);
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-secondary, #5c564d);
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.55;
|
||||
margin: 0;
|
||||
}
|
||||
@@ -557,8 +562,8 @@
|
||||
position: sticky;
|
||||
top: 0;
|
||||
padding-top: 0;
|
||||
background: var(--bg-primary, #faf8f3);
|
||||
border-bottom: 1px solid var(--border-color, #e5dfd5);
|
||||
background: var(--bg-primary);
|
||||
border-bottom: 1px solid var(--border);
|
||||
margin: 0 -1.25rem 1rem;
|
||||
padding: 0.6rem 1.25rem;
|
||||
z-index: 10;
|
||||
@@ -590,8 +595,8 @@
|
||||
}
|
||||
|
||||
.navLinkActive {
|
||||
background: var(--text-primary, #1a1612);
|
||||
color: #fff;
|
||||
background: var(--text-primary);
|
||||
color: var(--text-inverse);
|
||||
border-left: none;
|
||||
}
|
||||
|
||||
|
||||
@@ -43,7 +43,7 @@
|
||||
min-height: 40px;
|
||||
min-width: 0;
|
||||
padding: 4px 10px;
|
||||
border: 1px solid rgba(0, 0, 0, .12);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 999px;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
@@ -52,8 +52,8 @@
|
||||
}
|
||||
|
||||
.chip[aria-pressed="true"] {
|
||||
background: rgba(0, 0, 0, .06);
|
||||
border-color: rgba(0, 0, 0, .35);
|
||||
background: var(--bg-secondary);
|
||||
border-color: var(--border-strong);
|
||||
}
|
||||
|
||||
.chipDot {
|
||||
|
||||
@@ -15,6 +15,7 @@ import { useEffect, useState } from 'react';
|
||||
import { Line } from 'react-chartjs-2';
|
||||
import { ChartOptions, ChartDataset, PointStyle } from 'chart.js';
|
||||
import '@/lib/chartSetup';
|
||||
import { useSeriesColors, useThemeTokens } from '@/lib/theme';
|
||||
import { buildCompareChart } from '@/lib/compareChartData';
|
||||
import type { ComparisonData } from '@/lib/types';
|
||||
import {
|
||||
@@ -48,6 +49,13 @@ const POINT_STYLES: PointStyle[] = ['circle', 'triangle', 'rect', 'rectRot', 'st
|
||||
|
||||
export function ComparisonChart({ comparisonData, schools, metric, metricLabel, nationalByYear, isSecondary = false }: ComparisonChartProps) {
|
||||
const isMobile = useIsMobile();
|
||||
// Canvas can't resolve var(), so the series come through the token bridge.
|
||||
// Same index order as CHART_COLORS, so a school's line and its DOM swatch
|
||||
// stay the same colour.
|
||||
const seriesColors = useSeriesColors();
|
||||
const [cRef, cGrid, cInverse, cInverseText] = useThemeTokens(
|
||||
'--chart-reference', '--chart-grid', '--surface-inverse', '--text-inverse'
|
||||
);
|
||||
const [focusedUrn, setFocusedUrn] = useState<number | null>(null);
|
||||
|
||||
// A focused school that leaves the basket must not linger.
|
||||
@@ -67,7 +75,7 @@ export function ComparisonChart({ comparisonData, schools, metric, metricLabel,
|
||||
|
||||
const datasets: ChartDataset<'line'>[] = built.schoolDatasets.map((series) => {
|
||||
const school = schools[series.schoolIndex];
|
||||
const color = CHART_COLORS[series.schoolIndex % CHART_COLORS.length];
|
||||
const color = seriesColors[series.schoolIndex % seriesColors.length];
|
||||
const dimmed = focusedUrn !== null && focusedUrn !== school.urn;
|
||||
|
||||
return {
|
||||
@@ -90,7 +98,7 @@ export function ComparisonChart({ comparisonData, schools, metric, metricLabel,
|
||||
datasets.push({
|
||||
label: built.englandDataset.label,
|
||||
data: built.englandDataset.data,
|
||||
borderColor: 'rgba(109, 104, 95, 0.9)',
|
||||
borderColor: cRef,
|
||||
backgroundColor: 'transparent',
|
||||
borderWidth: 1.5,
|
||||
borderDash: built.englandDataset.borderDash,
|
||||
@@ -138,7 +146,9 @@ export function ComparisonChart({ comparisonData, schools, metric, metricLabel,
|
||||
display: false,
|
||||
},
|
||||
tooltip: {
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.8)',
|
||||
backgroundColor: cInverse,
|
||||
titleColor: cInverseText,
|
||||
bodyColor: cInverseText,
|
||||
padding: isMobile ? 10 : 12,
|
||||
titleFont: {
|
||||
size: isMobile ? 12 : 14,
|
||||
@@ -182,7 +192,7 @@ export function ComparisonChart({ comparisonData, schools, metric, metricLabel,
|
||||
...(isMobile && { maxTicksLimit: 5 }),
|
||||
},
|
||||
grid: {
|
||||
color: 'rgba(0, 0, 0, 0.05)',
|
||||
color: cGrid,
|
||||
},
|
||||
},
|
||||
x: {
|
||||
|
||||
@@ -23,11 +23,11 @@
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
padding: 1rem 1.25rem;
|
||||
background: var(--bg-primary, #faf7f2);
|
||||
color: var(--text-primary, #2c2420);
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 8px 32px rgba(44, 36, 32, 0.18), 0 2px 8px rgba(44, 36, 32, 0.08);
|
||||
border: 1px solid var(--border-color, #e8ddd4);
|
||||
box-shadow: 0 8px 32px rgba(var(--shadow-rgb), 0.18), 0 2px 8px rgba(var(--shadow-rgb), 0.08);
|
||||
border: 1px solid var(--border);
|
||||
min-width: 280px;
|
||||
}
|
||||
|
||||
@@ -37,8 +37,8 @@
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
background: var(--accent-coral, #e07256);
|
||||
color: white;
|
||||
background: var(--brand);
|
||||
color: var(--brand-on);
|
||||
border-radius: 50%;
|
||||
font-weight: 700;
|
||||
font-size: 0.9rem;
|
||||
@@ -62,13 +62,13 @@
|
||||
gap: 0.5rem;
|
||||
font-weight: 600;
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-primary, #2c2420);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.collapseBtn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-muted, #8a7a72);
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
padding: 0.25rem;
|
||||
line-height: 1;
|
||||
@@ -79,7 +79,7 @@
|
||||
}
|
||||
|
||||
.collapseBtn:hover {
|
||||
color: var(--text-primary, #2c2420);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.schoolList {
|
||||
@@ -95,13 +95,13 @@
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
padding: 0.3rem 0.5rem;
|
||||
background: var(--bg-secondary, #f3ede4);
|
||||
background: var(--bg-secondary);
|
||||
border-radius: var(--radius-sm, 4px);
|
||||
}
|
||||
|
||||
.schoolName {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-primary, #2c2420);
|
||||
color: var(--text-primary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
@@ -111,7 +111,7 @@
|
||||
.removeSchoolBtn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-muted, #8a7a72);
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
padding: 0 0.25rem;
|
||||
@@ -121,7 +121,7 @@
|
||||
}
|
||||
|
||||
.removeSchoolBtn:hover {
|
||||
color: var(--accent-coral-dark, #b04a2e);
|
||||
color: var(--brand-strong);
|
||||
}
|
||||
|
||||
.toastActions {
|
||||
@@ -129,13 +129,13 @@
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding-top: 0.625rem;
|
||||
border-top: 1px solid var(--border-color, #e8ddd4);
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.btnClearAll {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-muted, #8a7a72);
|
||||
color: var(--text-muted);
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
@@ -145,13 +145,13 @@
|
||||
}
|
||||
|
||||
.btnClearAll:hover {
|
||||
color: var(--accent-coral-dark, #b04a2e);
|
||||
color: var(--brand-strong);
|
||||
}
|
||||
|
||||
.btnCompare {
|
||||
flex: 1;
|
||||
background: var(--accent-coral, #e07256);
|
||||
color: white;
|
||||
background: var(--brand);
|
||||
color: var(--brand-on);
|
||||
padding: 0.6rem 1.25rem;
|
||||
border-radius: 25px;
|
||||
font-weight: 600;
|
||||
@@ -164,7 +164,7 @@
|
||||
|
||||
.btnCompare:hover {
|
||||
transform: translateY(-1px);
|
||||
background: var(--accent-coral-dark, #c9614a);
|
||||
background: var(--brand-strong);
|
||||
}
|
||||
|
||||
/* Hidden on phones — the bottom tab bar's Compare badge already
|
||||
|
||||
@@ -18,14 +18,14 @@
|
||||
.header h1 {
|
||||
font-size: 2.25rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary, #1a1612);
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 0.5rem;
|
||||
font-family: var(--font-playfair), 'Playfair Display', serif;
|
||||
font-family: var(--font-display);
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 1rem;
|
||||
color: var(--text-secondary, #5c564d);
|
||||
color: var(--text-secondary);
|
||||
margin: 0;
|
||||
line-height: 1.6;
|
||||
max-width: 60ch;
|
||||
@@ -43,7 +43,7 @@
|
||||
display: flex;
|
||||
gap: 0;
|
||||
margin-bottom: 1.5rem;
|
||||
border: 1px solid var(--border-color, #e5dfd5);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
width: fit-content;
|
||||
@@ -53,8 +53,8 @@
|
||||
padding: 0.625rem 1.5rem;
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 500;
|
||||
background: var(--bg-card, white);
|
||||
color: var(--text-secondary, #5c564d);
|
||||
background: var(--bg-card);
|
||||
color: var(--text-secondary);
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
@@ -62,21 +62,21 @@
|
||||
}
|
||||
|
||||
.phaseTab:not(:last-child) {
|
||||
border-right: 1px solid var(--border-color, #e5dfd5);
|
||||
border-right: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.phaseTab:hover {
|
||||
background: var(--bg-secondary, #f3ede4);
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.phaseTabActive {
|
||||
background: var(--accent-coral-dark, #b04a2e);
|
||||
color: white;
|
||||
background: var(--brand-strong);
|
||||
color: var(--brand-on);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.phaseTabActive:hover {
|
||||
background: var(--accent-coral-darker, #9c3f26);
|
||||
background: var(--brand-stronger);
|
||||
}
|
||||
|
||||
/* Sticky school bar — column identity while scrolling; horizontal scroll on
|
||||
@@ -87,23 +87,23 @@
|
||||
position: sticky;
|
||||
top: 65px;
|
||||
z-index: 10;
|
||||
background: var(--bg-primary, #faf7f2);
|
||||
background: var(--bg-primary);
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
overflow-x: auto;
|
||||
padding: 0.75rem 0;
|
||||
border-bottom: 1px solid var(--border-light, #e5dfd5);
|
||||
border-bottom: 1px solid var(--border);
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.schoolChip {
|
||||
flex: 1 1 0;
|
||||
min-width: 180px;
|
||||
background: var(--bg-card, white);
|
||||
border: 1px solid var(--border-light, #e5dfd5);
|
||||
border-top: 3px solid var(--accent-coral, #e07256);
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-top: 3px solid var(--brand);
|
||||
border-radius: 8px;
|
||||
box-shadow: var(--shadow-soft, 0 2px 8px rgba(26, 22, 18, 0.06));
|
||||
box-shadow: var(--shadow-soft, 0 2px 8px rgba(var(--shadow-rgb), 0.06));
|
||||
padding: 0.55rem 0.75rem;
|
||||
display: flex;
|
||||
gap: 0.55rem;
|
||||
@@ -126,12 +126,12 @@
|
||||
font-weight: 600;
|
||||
font-size: 0.92rem;
|
||||
line-height: 1.25;
|
||||
color: var(--text-primary, #1a1612);
|
||||
color: var(--text-primary);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.chipName:hover {
|
||||
color: var(--accent-coral-dark, #b04a2e);
|
||||
color: var(--brand-strong);
|
||||
}
|
||||
|
||||
/* Full name on desktop, short name on the compact mobile pills. */
|
||||
@@ -142,7 +142,7 @@
|
||||
.chipMeta {
|
||||
display: block;
|
||||
font-size: 0.78rem;
|
||||
color: var(--text-muted, #6d685f);
|
||||
color: var(--text-muted);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
@@ -185,22 +185,22 @@
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-muted, #6d685f);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.barCaptionCount {
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.3;
|
||||
color: var(--text-primary, #1a1612);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.chipRemove {
|
||||
margin-left: auto;
|
||||
border: none;
|
||||
background: var(--bg-secondary, #f3ede4);
|
||||
color: var(--text-muted, #6d685f);
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-muted);
|
||||
border-radius: 50%;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
@@ -212,9 +212,9 @@
|
||||
|
||||
.footnote {
|
||||
font-size: 0.78rem;
|
||||
color: var(--text-muted, #6d685f);
|
||||
color: var(--text-muted);
|
||||
margin-top: 2.5rem;
|
||||
border-top: 1px solid var(--border-light, #e5dfd5);
|
||||
border-top: 1px solid var(--border);
|
||||
padding-top: 1rem;
|
||||
max-width: 75ch;
|
||||
}
|
||||
|
||||
@@ -224,11 +224,11 @@ export function ComparisonView({
|
||||
const url = window.location.href;
|
||||
const count = selectedSchools.length;
|
||||
const shareData = {
|
||||
title: 'School comparison · SchoolCompare',
|
||||
title: 'School comparison · schoolcompare',
|
||||
text:
|
||||
count > 0
|
||||
? `Comparing ${count} school${count === 1 ? '' : 's'} on SchoolCompare`
|
||||
: 'SchoolCompare',
|
||||
? `Comparing ${count} school${count === 1 ? '' : 's'} on schoolcompare`
|
||||
: 'schoolcompare',
|
||||
url,
|
||||
};
|
||||
if (
|
||||
|
||||
@@ -23,16 +23,16 @@
|
||||
}
|
||||
|
||||
.good {
|
||||
background: var(--accent-teal-bg, rgba(45, 125, 125, 0.12));
|
||||
color: var(--accent-teal, #2d7d7d);
|
||||
background: var(--status-above-bg);
|
||||
color: var(--status-above);
|
||||
}
|
||||
|
||||
.bad {
|
||||
background: var(--accent-coral-bg, rgba(224, 114, 86, 0.12));
|
||||
color: var(--accent-coral-dark, #b04a2e);
|
||||
background: var(--status-below-bg);
|
||||
color: var(--status-below);
|
||||
}
|
||||
|
||||
.neutral {
|
||||
background: var(--bg-secondary, #f3ede4);
|
||||
color: var(--text-muted, #8a847a);
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
@@ -61,7 +61,7 @@
|
||||
border-radius: 50%;
|
||||
transform: translateX(-50%);
|
||||
border: 2px solid var(--bg-card);
|
||||
box-shadow: 0 0 0 1px rgba(26, 22, 18, 0.08);
|
||||
box-shadow: 0 0 0 1px rgba(var(--shadow-rgb), 0.08);
|
||||
}
|
||||
|
||||
.pointLabel {
|
||||
|
||||
@@ -20,7 +20,7 @@ export function EditorialSection({ totalSchools, localAuthorityCount }: Editoria
|
||||
and demographics — each in its own table, each with its own jargon.
|
||||
</p>
|
||||
<p>
|
||||
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.
|
||||
|
||||
@@ -5,15 +5,15 @@
|
||||
justify-content: center;
|
||||
padding: 4rem 2rem;
|
||||
text-align: center;
|
||||
background: var(--bg-card, white);
|
||||
border: 2px solid var(--border-color, #e5dfd5);
|
||||
background: var(--bg-card);
|
||||
border: 2px solid var(--border);
|
||||
border-radius: 16px;
|
||||
min-height: 400px;
|
||||
box-shadow: var(--shadow-soft, 0 2px 8px rgba(26, 22, 18, 0.06));
|
||||
box-shadow: var(--shadow-soft, 0 2px 8px rgba(var(--shadow-rgb), 0.06));
|
||||
}
|
||||
|
||||
.icon {
|
||||
color: var(--text-muted, #8a847a);
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 1.5rem;
|
||||
opacity: 0.7;
|
||||
}
|
||||
@@ -22,14 +22,14 @@
|
||||
margin: 0 0 0.75rem 0;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary, #1a1612);
|
||||
font-family: var(--font-playfair), 'Playfair Display', serif;
|
||||
color: var(--text-primary);
|
||||
font-family: var(--font-display);
|
||||
}
|
||||
|
||||
.message {
|
||||
margin: 0 0 2rem 0;
|
||||
font-size: 1rem;
|
||||
color: var(--text-secondary, #5c564d);
|
||||
color: var(--text-secondary);
|
||||
max-width: 500px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
@@ -38,8 +38,8 @@
|
||||
padding: 0.875rem 2rem;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
background: var(--accent-coral, #e07256);
|
||||
color: white;
|
||||
background: var(--brand);
|
||||
color: var(--brand-on);
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
@@ -47,9 +47,9 @@
|
||||
}
|
||||
|
||||
.button:hover {
|
||||
background: var(--accent-coral-dark, #c45a3f);
|
||||
background: var(--brand-strong);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(224, 114, 86, 0.3);
|
||||
box-shadow: 0 4px 12px rgba(var(--brand-rgb), 0.3);
|
||||
}
|
||||
|
||||
.button:active {
|
||||
|
||||
@@ -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, white);
|
||||
border: 1px solid var(--border-color, #e5dfd5);
|
||||
border-radius: 10px;
|
||||
padding: 1.5rem;
|
||||
margin-bottom: 2rem;
|
||||
box-shadow: var(--shadow-soft, 0 2px 8px rgba(26, 22, 18, 0.06));
|
||||
transition: opacity 0.2s ease;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
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(26, 22, 18, 0.08);
|
||||
border-width: 2px;
|
||||
border-color: var(--accent-coral, #e07256);
|
||||
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, #5a554d);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.searchHint strong {
|
||||
color: var(--text-primary, #1a1612);
|
||||
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(--accent-teal, #2d7d7d);
|
||||
color: #fff;
|
||||
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: #235f5f;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.nearMeBtn:disabled {
|
||||
opacity: 0.7;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.nearMeSpinner {
|
||||
display: inline-block;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border: 2px solid rgba(255, 255, 255, 0.35);
|
||||
border-top-color: #fff;
|
||||
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(--accent-coral-dark, #b04a2e);
|
||||
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-color, #e5dfd5);
|
||||
border-radius: 8px;
|
||||
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);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
outline: none;
|
||||
transition: all 0.2s ease;
|
||||
background: var(--bg-card, white);
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.omniInput:focus {
|
||||
border-color: var(--accent-coral, #e07256);
|
||||
box-shadow: 0 0 0 3px var(--accent-coral-bg);
|
||||
transition: border-color var(--transition), box-shadow var(--transition);
|
||||
}
|
||||
|
||||
.omniInput::placeholder {
|
||||
color: var(--text-muted, #8a847a);
|
||||
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(255, 255, 255, 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: white;
|
||||
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-color, #e5dfd5);
|
||||
/* ── 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-color, #e5dfd5);
|
||||
border-radius: 8px;
|
||||
background-color: var(--bg-card, white);
|
||||
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, #1a1612);
|
||||
transition: border-color 0.15s ease, box-shadow 0.15s ease;
|
||||
.searchHint strong {
|
||||
color: var(--text-secondary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.filterSelect:hover {
|
||||
border-color: var(--text-muted, #8a847a);
|
||||
}
|
||||
|
||||
.filterSelect:focus {
|
||||
border-color: var(--accent-coral, #e07256);
|
||||
box-shadow: 0 0 0 3px rgba(224, 114, 86, 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 {
|
||||
.nearMeRow {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.5rem;
|
||||
gap: 0.375rem;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.radiusLabel {
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary, #5a554d);
|
||||
white-space: nowrap;
|
||||
/* 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);
|
||||
}
|
||||
|
||||
/* ── Controls row (radius + phase + advanced toggle) ─── */
|
||||
.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;
|
||||
@@ -275,7 +224,7 @@
|
||||
flex-wrap: wrap;
|
||||
margin-top: 0.875rem;
|
||||
padding-top: 0.875rem;
|
||||
border-top: 1px solid var(--border-color, #e5dfd5);
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.controlsRow .advancedToggle {
|
||||
@@ -289,76 +238,157 @@
|
||||
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-color, #e5dfd5);
|
||||
border-radius: 999px;
|
||||
background-color: var(--bg-card, white);
|
||||
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, #1a1612);
|
||||
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, #8a847a);
|
||||
background-color: var(--bg-secondary, #f8f4ef);
|
||||
/* ── 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(--accent-coral, #e07256);
|
||||
box-shadow: 0 0 0 3px rgba(224, 114, 86, 0.12);
|
||||
border-color: var(--brand);
|
||||
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-color, #e5dfd5);
|
||||
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;
|
||||
color: var(--text-secondary, #5a554d);
|
||||
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, #8a847a);
|
||||
background-color: var(--bg-secondary, #f8f4ef);
|
||||
color: var(--text-primary, #1a1612);
|
||||
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(--accent-coral, #e07256);
|
||||
background: var(--accent-coral-bg, rgba(224, 114, 86, 0.12));
|
||||
color: var(--accent-coral, #e07256);
|
||||
border-color: var(--brand);
|
||||
background: var(--brand-bg);
|
||||
color: var(--brand);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.advancedToggleActive:hover {
|
||||
border-color: var(--accent-coral-dark, #c45a3f);
|
||||
background: var(--accent-coral-bg, rgba(224, 114, 86, 0.18));
|
||||
color: var(--accent-coral-dark, #c45a3f);
|
||||
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,
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 <select> so the disclosure arrow can be drawn in CSS from
|
||||
* currentColor rather than baked into a background image — a hardcoded arrow
|
||||
* colour is the one thing in a select that can't follow the theme.
|
||||
*/
|
||||
function SelectShell({
|
||||
children,
|
||||
wide,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
wide?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<span
|
||||
className={`${styles.selectShell}${wide ? ` ${styles.selectShellWide}` : ""}`}
|
||||
>
|
||||
{children}
|
||||
<span className={styles.selectChevron} aria-hidden="true" />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function FilterBar({
|
||||
filters,
|
||||
isHero,
|
||||
@@ -172,12 +195,33 @@ export function FilterBar({
|
||||
const isSecondaryMode =
|
||||
currentPhase === "secondary" || genderOptions.length > 0;
|
||||
|
||||
// A select that is narrowing the results carries a sage tint; the class is
|
||||
// only ever additive, so the control's behaviour is untouched.
|
||||
const activeIf = (value: string) => (value ? ` ${styles.selectActive}` : "");
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`${styles.filterBar} ${isPending ? styles.isLoading : ""} ${isHero ? styles.heroMode : ""}`}
|
||||
>
|
||||
<form onSubmit={handleSearchSubmit} className={styles.searchSection}>
|
||||
<div className={styles.omniBoxContainer}>
|
||||
{isHero && (
|
||||
<span className={styles.omniIcon} aria-hidden="true">
|
||||
<svg
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M12 21s7-5.6 7-11a7 7 0 1 0-14 0c0 5.4 7 11 7 11Z" />
|
||||
<circle cx="12" cy="10" r="2.6" />
|
||||
</svg>
|
||||
</span>
|
||||
)}
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="search"
|
||||
@@ -188,10 +232,10 @@ export function FilterBar({
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className={`btn btn-primary ${styles.searchButton}`}
|
||||
className={`btn btn-action ${styles.searchButton}`}
|
||||
disabled={isPending}
|
||||
>
|
||||
{isPending ? <div className={styles.spinner}></div> : "Search"}
|
||||
{isPending ? <div className={styles.spinner}></div> : isHero ? "Search schools" : "Search"}
|
||||
</button>
|
||||
</div>
|
||||
{isHero && (
|
||||
@@ -248,34 +292,38 @@ export function FilterBar({
|
||||
{currentPostcode && (
|
||||
<div className={styles.radiusControl}>
|
||||
<label className={styles.radiusLabel}>Within:</label>
|
||||
<select
|
||||
value={currentRadius}
|
||||
onChange={(e) => updateURL({ radius: e.target.value })}
|
||||
className={styles.controlSelect}
|
||||
disabled={isPending}
|
||||
>
|
||||
<option value="0.5">0.5 miles</option>
|
||||
<option value="1">1 mile</option>
|
||||
<option value="3">3 miles</option>
|
||||
<option value="5">5 miles</option>
|
||||
</select>
|
||||
<SelectShell>
|
||||
<select
|
||||
value={currentRadius}
|
||||
onChange={(e) => updateURL({ radius: e.target.value })}
|
||||
className={styles.controlSelect}
|
||||
disabled={isPending}
|
||||
>
|
||||
<option value="0.5">0.5 miles</option>
|
||||
<option value="1">1 mile</option>
|
||||
<option value="3">3 miles</option>
|
||||
<option value="5">5 miles</option>
|
||||
</select>
|
||||
</SelectShell>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{phaseOptions.length > 0 && (
|
||||
<select
|
||||
value={currentPhase}
|
||||
onChange={(e) => handleFilterChange("phase", e.target.value)}
|
||||
className={styles.controlSelect}
|
||||
disabled={isPending}
|
||||
>
|
||||
<option value="">All Phases</option>
|
||||
{phaseOptions.map((p) => (
|
||||
<option key={p} value={p.toLowerCase()}>
|
||||
{p}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<SelectShell>
|
||||
<select
|
||||
value={currentPhase}
|
||||
onChange={(e) => handleFilterChange("phase", e.target.value)}
|
||||
className={`${styles.controlSelect}${activeIf(currentPhase)}`}
|
||||
disabled={isPending}
|
||||
>
|
||||
<option value="">All Phases</option>
|
||||
{phaseOptions.map((p) => (
|
||||
<option key={p} value={p.toLowerCase()}>
|
||||
{p}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</SelectShell>
|
||||
)}
|
||||
|
||||
<button
|
||||
@@ -307,87 +355,97 @@ export function FilterBar({
|
||||
|
||||
{filtersOpen && (
|
||||
<div className={styles.filters}>
|
||||
<select
|
||||
value={currentLA}
|
||||
onChange={(e) =>
|
||||
handleFilterChange("local_authority", e.target.value)
|
||||
}
|
||||
className={styles.filterSelect}
|
||||
disabled={isPending}
|
||||
>
|
||||
<option value="">All Local Authorities</option>
|
||||
{laOptions.map((la) => (
|
||||
<option key={la} value={la}>
|
||||
{la}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<SelectShell wide>
|
||||
<select
|
||||
value={currentLA}
|
||||
onChange={(e) =>
|
||||
handleFilterChange("local_authority", e.target.value)
|
||||
}
|
||||
className={`${styles.filterSelect}${activeIf(currentLA)}`}
|
||||
disabled={isPending}
|
||||
>
|
||||
<option value="">All Local Authorities</option>
|
||||
{laOptions.map((la) => (
|
||||
<option key={la} value={la}>
|
||||
{la}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</SelectShell>
|
||||
|
||||
<select
|
||||
value={currentType}
|
||||
onChange={(e) =>
|
||||
handleFilterChange("school_type", e.target.value)
|
||||
}
|
||||
className={styles.filterSelect}
|
||||
disabled={isPending}
|
||||
>
|
||||
<option value="">All School Types</option>
|
||||
{typeOptions.map((type) => (
|
||||
<option key={type} value={type}>
|
||||
{type}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<SelectShell wide>
|
||||
<select
|
||||
value={currentType}
|
||||
onChange={(e) =>
|
||||
handleFilterChange("school_type", e.target.value)
|
||||
}
|
||||
className={`${styles.filterSelect}${activeIf(currentType)}`}
|
||||
disabled={isPending}
|
||||
>
|
||||
<option value="">All School Types</option>
|
||||
{typeOptions.map((type) => (
|
||||
<option key={type} value={type}>
|
||||
{type}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</SelectShell>
|
||||
|
||||
{isSecondaryMode && (
|
||||
<>
|
||||
{genderOptions.length > 0 && (
|
||||
<select
|
||||
value={currentGender}
|
||||
onChange={(e) =>
|
||||
handleFilterChange("gender", e.target.value)
|
||||
}
|
||||
className={styles.filterSelect}
|
||||
disabled={isPending}
|
||||
>
|
||||
<option value="">Boys, Girls & Mixed</option>
|
||||
{genderOptions.map((g) => (
|
||||
<option key={g} value={g.toLowerCase()}>
|
||||
{g}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<SelectShell wide>
|
||||
<select
|
||||
value={currentGender}
|
||||
onChange={(e) =>
|
||||
handleFilterChange("gender", e.target.value)
|
||||
}
|
||||
className={`${styles.filterSelect}${activeIf(currentGender)}`}
|
||||
disabled={isPending}
|
||||
>
|
||||
<option value="">Boys, Girls & Mixed</option>
|
||||
{genderOptions.map((g) => (
|
||||
<option key={g} value={g.toLowerCase()}>
|
||||
{g}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</SelectShell>
|
||||
)}
|
||||
|
||||
<select
|
||||
value={currentHasSixthForm}
|
||||
onChange={(e) =>
|
||||
handleFilterChange("has_sixth_form", e.target.value)
|
||||
}
|
||||
className={styles.filterSelect}
|
||||
disabled={isPending}
|
||||
>
|
||||
<option value="">With or without sixth form</option>
|
||||
<option value="yes">With sixth form</option>
|
||||
<option value="no">Without sixth form</option>
|
||||
</select>
|
||||
|
||||
{admissionsPolicyOptions.length > 0 && (
|
||||
<SelectShell wide>
|
||||
<select
|
||||
value={currentAdmissionsPolicy}
|
||||
value={currentHasSixthForm}
|
||||
onChange={(e) =>
|
||||
handleFilterChange("admissions_policy", e.target.value)
|
||||
handleFilterChange("has_sixth_form", e.target.value)
|
||||
}
|
||||
className={styles.filterSelect}
|
||||
className={`${styles.filterSelect}${activeIf(currentHasSixthForm)}`}
|
||||
disabled={isPending}
|
||||
>
|
||||
<option value="">All admissions types</option>
|
||||
{admissionsPolicyOptions.map((p) => (
|
||||
<option key={p} value={p.toLowerCase()}>
|
||||
{p}
|
||||
</option>
|
||||
))}
|
||||
<option value="">With or without sixth form</option>
|
||||
<option value="yes">With sixth form</option>
|
||||
<option value="no">Without sixth form</option>
|
||||
</select>
|
||||
</SelectShell>
|
||||
|
||||
{admissionsPolicyOptions.length > 0 && (
|
||||
<SelectShell wide>
|
||||
<select
|
||||
value={currentAdmissionsPolicy}
|
||||
onChange={(e) =>
|
||||
handleFilterChange("admissions_policy", e.target.value)
|
||||
}
|
||||
className={`${styles.filterSelect}${activeIf(currentAdmissionsPolicy)}`}
|
||||
disabled={isPending}
|
||||
>
|
||||
<option value="">All admissions types</option>
|
||||
{admissionsPolicyOptions.map((p) => (
|
||||
<option key={p} value={p.toLowerCase()}>
|
||||
{p}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</SelectShell>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -1,6 +1,16 @@
|
||||
/*
|
||||
* The footer band.
|
||||
*
|
||||
* --surface-sunken is deep teal in BOTH themes on purpose, so nothing in here
|
||||
* may read a --text-* or --bg-* token: those invert with the theme and would
|
||||
* put dark ink on the teal (or teal on teal) in one of the two. Every
|
||||
* foreground below comes from the --on-sunken-* family, which is pinned to the
|
||||
* band rather than to the page.
|
||||
*/
|
||||
|
||||
.footer {
|
||||
background: var(--accent-navy, #2c3e50);
|
||||
color: var(--bg-secondary, #f3ede4);
|
||||
background: var(--surface-sunken);
|
||||
color: var(--on-sunken);
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
@@ -12,7 +22,7 @@
|
||||
|
||||
.content {
|
||||
display: grid;
|
||||
grid-template-columns: 1.5fr 1fr 1fr;
|
||||
grid-template-columns: 1.6fr 1fr 1fr;
|
||||
gap: 2rem;
|
||||
margin-bottom: 3rem;
|
||||
}
|
||||
@@ -20,31 +30,76 @@
|
||||
.section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.title {
|
||||
/* ─── Brand lockup ──────────────────────────────────────────────── */
|
||||
|
||||
.lockup {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.625rem;
|
||||
margin: 0;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
color: var(--bg-primary, #faf7f2);
|
||||
font-family: var(--font-playfair), 'Playfair Display', serif;
|
||||
}
|
||||
|
||||
.lockupMark {
|
||||
display: inline-flex;
|
||||
flex: 0 0 auto;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
}
|
||||
|
||||
.lockupMark svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* Same wordmark as the header, inverted: "school" in the band's foreground,
|
||||
"compare" in sage — the one accent that reads cleanly on deep teal. */
|
||||
.wordmark {
|
||||
font-family: var(--font-display);
|
||||
font-size: var(--step-2);
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
letter-spacing: -0.035em;
|
||||
color: var(--on-sunken);
|
||||
}
|
||||
|
||||
.wordmarkAccent {
|
||||
color: var(--sage);
|
||||
}
|
||||
|
||||
/* Key messaging, so this is the one place in the footer that takes Manrope
|
||||
for a full sentence. */
|
||||
.tagline {
|
||||
margin: 0;
|
||||
font-family: var(--font-display);
|
||||
font-size: var(--step-1);
|
||||
font-weight: 600;
|
||||
line-height: 1.35;
|
||||
letter-spacing: -0.015em;
|
||||
color: var(--on-sunken);
|
||||
}
|
||||
|
||||
.description {
|
||||
margin: 0;
|
||||
font-size: 0.875rem;
|
||||
margin: -0.5rem 0 0;
|
||||
font-size: var(--step--1);
|
||||
line-height: 1.6;
|
||||
color: rgba(250, 247, 242, 0.7);
|
||||
color: var(--on-sunken-muted);
|
||||
max-width: 34ch;
|
||||
}
|
||||
|
||||
.sectionTitle {
|
||||
margin: 0;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: var(--accent-gold, #c9a227);
|
||||
font-family: var(--font-display);
|
||||
font-size: var(--step--1);
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
color: var(--sage);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
letter-spacing: 0.1em;
|
||||
}
|
||||
|
||||
.links {
|
||||
@@ -57,43 +112,64 @@
|
||||
}
|
||||
|
||||
.link {
|
||||
font-size: 0.875rem;
|
||||
color: rgba(250, 247, 242, 0.7);
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--step--1);
|
||||
color: var(--on-sunken-muted);
|
||||
text-decoration: none;
|
||||
transition: color 0.2s ease;
|
||||
border-radius: var(--radius-sm);
|
||||
transition: color var(--transition);
|
||||
}
|
||||
|
||||
.link:hover {
|
||||
color: var(--accent-gold, #c9a227);
|
||||
color: var(--on-sunken);
|
||||
}
|
||||
|
||||
.linkDisabled {
|
||||
font-size: 0.875rem;
|
||||
color: rgba(250, 247, 242, 0.4);
|
||||
font-size: var(--step--1);
|
||||
color: var(--on-sunken-faint);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* ─── Bottom rule ───────────────────────────────────────────────── */
|
||||
|
||||
/*
|
||||
* The divider is drawn as a pseudo-element at low opacity rather than as an
|
||||
* rgba() border: there is no channel token for --on-sunken, and every literal
|
||||
* that would work here (rgba(255,255,255,…)) is exactly the kind of hardcoded
|
||||
* colour the token layer exists to remove.
|
||||
*/
|
||||
.bottom {
|
||||
position: relative;
|
||||
padding-top: 2rem;
|
||||
border-top: 1px solid rgba(250, 247, 242, 0.15);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.bottom::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 1px;
|
||||
background: var(--on-sunken);
|
||||
opacity: 0.16;
|
||||
}
|
||||
|
||||
.copyright,
|
||||
.disclaimer {
|
||||
margin: 0;
|
||||
font-size: 0.875rem;
|
||||
color: rgba(250, 247, 242, 0.6);
|
||||
font-size: var(--step--1);
|
||||
color: var(--on-sunken-muted);
|
||||
}
|
||||
|
||||
.disclaimer .link {
|
||||
color: var(--accent-coral, #e07256);
|
||||
color: var(--on-sunken-link);
|
||||
}
|
||||
|
||||
.disclaimer .link:hover {
|
||||
color: var(--accent-gold, #c9a227);
|
||||
color: var(--on-sunken);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
/**
|
||||
* Footer Component
|
||||
* Site footer with links and info
|
||||
*
|
||||
* The footer is the guideline's "brand in action" band: a deep teal anchor
|
||||
* that stays teal in both themes. Everything inside it therefore reads from
|
||||
* the --on-sunken-* family rather than the page's --text-* tokens, which
|
||||
* invert and would go dark against the teal in one theme or the other.
|
||||
*/
|
||||
|
||||
import { LogoMark } from './Logo';
|
||||
import styles from './Footer.module.css';
|
||||
|
||||
export function Footer() {
|
||||
@@ -13,7 +18,20 @@ export function Footer() {
|
||||
<div className={styles.container}>
|
||||
<div className={styles.content}>
|
||||
<div className={styles.section}>
|
||||
<h3 className={styles.title}>SchoolCompare</h3>
|
||||
{/*
|
||||
On the sunken band the mark inverts: the pin takes the band's
|
||||
foreground and the leaf is knocked out in the band itself. The
|
||||
header's defaults (brand pin, card leaf) would disappear here.
|
||||
*/}
|
||||
<h3 className={styles.lockup}>
|
||||
<span className={styles.lockupMark}>
|
||||
<LogoMark pin="var(--on-sunken)" leaf="var(--surface-sunken)" />
|
||||
</span>
|
||||
<span className={styles.wordmark}>
|
||||
school<span className={styles.wordmarkAccent}>compare</span>
|
||||
</span>
|
||||
</h3>
|
||||
<p className={styles.tagline}>Find the right school. For their future.</p>
|
||||
<p className={styles.description}>
|
||||
Compare primary and secondary schools across England.
|
||||
</p>
|
||||
@@ -69,7 +87,7 @@ export function Footer() {
|
||||
|
||||
<div className={styles.bottom}>
|
||||
<p className={styles.copyright}>
|
||||
© {currentYear} SchoolCompare.co.uk
|
||||
© {currentYear} schoolcompare.co.uk
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -12,6 +12,7 @@ import { SchoolRow } from './SchoolRow';
|
||||
import { SecondarySchoolRow } from './SecondarySchoolRow';
|
||||
import { SchoolMap } from './SchoolMap';
|
||||
import { EmptyState } from './EmptyState';
|
||||
import { HeroIllustration } from './Illustration';
|
||||
import { useComparisonContext } from '@/context/ComparisonContext';
|
||||
import { fetchSchools, fetchLAaverages, fetchNationalAverages } from '@/lib/api';
|
||||
import type { SchoolsResponse, Filters, School } from '@/lib/types';
|
||||
@@ -63,6 +64,96 @@ const ADMISSIONS_CHIPS: CountdownChipData[] = [
|
||||
{ type: 'offer', track: 'Secondary · Offer Day', milestone: 'Secondary National Offer Day', month: 3, day: 1 },
|
||||
];
|
||||
|
||||
/* ── Value-prop icons ──────────────────────────────────────────────────────
|
||||
Rounded 2px line icons on a 24px grid, inheriting the chip's colour. Kept
|
||||
local because they exist for this one row and nothing else. */
|
||||
|
||||
const ICON_PROPS = {
|
||||
viewBox: '0 0 24 24',
|
||||
width: 21,
|
||||
height: 21,
|
||||
fill: 'none',
|
||||
stroke: 'currentColor',
|
||||
strokeWidth: 2,
|
||||
strokeLinecap: 'round',
|
||||
strokeLinejoin: 'round',
|
||||
'aria-hidden': true,
|
||||
} as const;
|
||||
|
||||
function ShieldCheckIcon() {
|
||||
return (
|
||||
<svg {...ICON_PROPS}>
|
||||
<path d="M12 3 4 6v6c0 4.5 3.2 8.2 8 9 4.8-.8 8-4.5 8-9V6l-8-3Z" />
|
||||
<path d="m9 12 2 2 4-4" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function BarsIcon() {
|
||||
return (
|
||||
<svg {...ICON_PROPS}>
|
||||
<path d="M4 20V10M10 20V4M16 20v-7M22 20H2" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function HeartIcon() {
|
||||
return (
|
||||
<svg {...ICON_PROPS}>
|
||||
<path d="M12 20s-7-4.4-7-9.2A3.9 3.9 0 0 1 12 8a3.9 3.9 0 0 1 7 2.8C19 15.6 12 20 12 20Z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function CalendarIcon() {
|
||||
return (
|
||||
<svg {...ICON_PROPS}>
|
||||
<rect x="3" y="5" width="18" height="16" rx="2" />
|
||||
<path d="M3 10h18M8 3v4M16 3v4" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
interface ValueProp {
|
||||
icon: React.ReactNode;
|
||||
tintClass: string;
|
||||
title: string;
|
||||
body: string;
|
||||
}
|
||||
|
||||
/*
|
||||
* The brand guideline's fourth value prop is "Save & revisit" (shortlisting /
|
||||
* favourites). That feature does not exist in this product, so it is
|
||||
* deliberately replaced by the admissions-deadline prop below — which is real,
|
||||
* and is backed by the countdown strip further down this page.
|
||||
*/
|
||||
const VALUE_PROPS: ValueProp[] = [
|
||||
{
|
||||
icon: <ShieldCheckIcon />,
|
||||
tintClass: styles.propIconTrust,
|
||||
title: 'Official & trusted',
|
||||
body: 'Every figure comes from DfE performance tables and Ofsted.',
|
||||
},
|
||||
{
|
||||
icon: <BarsIcon />,
|
||||
tintClass: styles.propIconCompare,
|
||||
title: 'Easy to compare',
|
||||
body: 'Up to three schools side by side, on the measures that matter.',
|
||||
},
|
||||
{
|
||||
icon: <HeartIcon />,
|
||||
tintClass: styles.propIconContext,
|
||||
title: 'Beyond the numbers',
|
||||
body: 'Class sizes, SEN support and local context, not just results.',
|
||||
},
|
||||
{
|
||||
icon: <CalendarIcon />,
|
||||
tintClass: styles.propIconDeadline,
|
||||
title: 'Never miss a deadline',
|
||||
body: 'Application and offer dates for both primary and secondary.',
|
||||
},
|
||||
];
|
||||
|
||||
export function HomeView({ initialSchools, filters, totalSchools, howItWorks, editorial }: HomeViewProps) {
|
||||
const searchParams = useSearchParams();
|
||||
const router = useRouter();
|
||||
@@ -256,38 +347,81 @@ export function HomeView({ initialSchools, filters, totalSchools, howItWorks, ed
|
||||
|
||||
return (
|
||||
<div className={styles.homeView}>
|
||||
{/* Combined Hero + Search and Filters */}
|
||||
{!isSearchActive && (
|
||||
<div className={styles.heroSection}>
|
||||
<span className={styles.heroEyebrow}>
|
||||
<span className={styles.heroEyebrowDot} aria-hidden="true" />
|
||||
Updated for the 2026/27 admissions round
|
||||
</span>
|
||||
<h1 className={styles.heroTitle}>
|
||||
Every school in England, <em className={styles.heroEmph}>compared.</em>
|
||||
</h1>
|
||||
<p className={styles.heroDescription}>
|
||||
{/* Full copy for larger screens; a compact merged line (coverage +
|
||||
freshness, standing in for the hidden eyebrow too) on phones,
|
||||
where every line above the fold costs. */}
|
||||
<span className={styles.heroDescriptionFull}>
|
||||
<strong>24,000+ primary and secondary schools</strong> with Key Stage 2 SATs, GCSE results, Ofsted grades, progress scores and admissions data — side by side, in one place.
|
||||
{/* Hero: a Sand panel with the proposition and the search on the left and
|
||||
the brand landscape bleeding to the panel edge on the right. The
|
||||
search lives inside the panel here and above the results elsewhere,
|
||||
which is why FilterBar is rendered in two places rather than moved. */}
|
||||
{!isSearchActive ? (
|
||||
<section className={styles.heroPanel}>
|
||||
<div className={styles.heroContent}>
|
||||
<span className={styles.heroEyebrow}>
|
||||
<span className={styles.heroEyebrowDot} aria-hidden="true" />
|
||||
Updated for the 2026/27 admissions round
|
||||
</span>
|
||||
<span className={styles.heroDescriptionCompact}>
|
||||
<strong>24,000+ English schools</strong> — SATs, GCSEs, Ofsted & admissions, side by side. Updated for 2026/27.
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
<h1 className={styles.heroTitle}>
|
||||
Find the school where your child will{' '}
|
||||
<em className={styles.heroEmph}>flourish.</em>
|
||||
</h1>
|
||||
<p className={styles.heroDescription}>
|
||||
Compare every school in England with clarity and confidence.
|
||||
{/* The coverage sentence is the detail a first-time visitor needs
|
||||
but a returning one does not, so it drops away on phones where
|
||||
every line above the fold costs. */}
|
||||
<span className={styles.heroDescriptionFull}>
|
||||
{' '}Key Stage 2 SATs, GCSE results, Ofsted grades, progress scores
|
||||
and admissions data for <strong>24,000+ schools</strong> — side by
|
||||
side, in one place.
|
||||
</span>
|
||||
</p>
|
||||
|
||||
<FilterBar
|
||||
filters={filters}
|
||||
isHero
|
||||
resultFilters={initialSchools.result_filters}
|
||||
onNearMe={handleNearMe}
|
||||
geoState={geoState}
|
||||
geoError={geoError}
|
||||
/>
|
||||
|
||||
<p className={styles.heroTrust}>
|
||||
<span className={styles.heroTrustDots} aria-hidden="true">
|
||||
<span className={styles.heroTrustDot} />
|
||||
<span className={styles.heroTrustDot} />
|
||||
<span className={styles.heroTrustDot} />
|
||||
</span>
|
||||
Built on official DfE and Ofsted data — 24,000+ schools
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className={styles.heroArt}>
|
||||
<HeroIllustration />
|
||||
</div>
|
||||
</section>
|
||||
) : (
|
||||
<FilterBar
|
||||
filters={filters}
|
||||
isHero={false}
|
||||
resultFilters={initialSchools.result_filters}
|
||||
onNearMe={handleNearMe}
|
||||
geoState={geoState}
|
||||
geoError={geoError}
|
||||
/>
|
||||
)}
|
||||
|
||||
<FilterBar
|
||||
filters={filters}
|
||||
isHero={!isSearchActive}
|
||||
resultFilters={initialSchools.result_filters}
|
||||
onNearMe={handleNearMe}
|
||||
geoState={geoState}
|
||||
geoError={geoError}
|
||||
/>
|
||||
{/* Why this site, in four lines. Only on the landing page. */}
|
||||
{!isSearchActive && (
|
||||
<ul className={styles.valueProps}>
|
||||
{VALUE_PROPS.map(({ icon, tintClass, title, body }) => (
|
||||
<li key={title} className={styles.valueProp}>
|
||||
<span className={`${styles.propIcon} ${tintClass}`}>{icon}</span>
|
||||
<div>
|
||||
<h2 className={styles.propTitle}>{title}</h2>
|
||||
<p className={styles.propBody}>{body}</p>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{/* Admissions countdown strip — only on landing page */}
|
||||
{!isSearchActive && (
|
||||
@@ -573,10 +707,10 @@ function CompactSchoolItem({ school, onAddToCompare, isInCompare, nationalAvgRwm
|
||||
rwmDelta == null
|
||||
? {}
|
||||
: rwmDelta >= 2
|
||||
? { fontSize: '0.7rem', color: 'var(--accent-teal, #2d7d7d)', fontWeight: 600 }
|
||||
? { fontSize: '0.7rem', color: 'var(--status-above)', fontWeight: 600 }
|
||||
: rwmDelta <= -2
|
||||
? { fontSize: '0.7rem', color: 'var(--accent-coral, #e07256)', fontWeight: 600 }
|
||||
: { fontSize: '0.7rem', color: 'var(--text-muted, #8a847a)' };
|
||||
? { fontSize: '0.7rem', color: 'var(--brand)', fontWeight: 600 }
|
||||
: { fontSize: '0.7rem', color: 'var(--text-muted)' };
|
||||
|
||||
return (
|
||||
<div className={styles.compactItem}>
|
||||
@@ -601,13 +735,13 @@ function CompactSchoolItem({ school, onAddToCompare, isInCompare, nationalAvgRwm
|
||||
fontWeight: 600,
|
||||
borderRadius: '3px',
|
||||
whiteSpace: 'nowrap',
|
||||
...(ofstedBadge.cssClass === 'ofsted1' ? { background: 'var(--accent-teal-bg)', color: 'var(--accent-teal, #2d7d7d)' } :
|
||||
ofstedBadge.cssClass === 'ofsted2' ? { background: 'rgba(60,140,60,0.12)', color: '#2f7a2f' } :
|
||||
ofstedBadge.cssClass === 'ofsted3' ? { background: 'var(--accent-gold-bg)', color: 'var(--accent-gold-text, #7a6800)' } :
|
||||
ofstedBadge.cssClass === 'ofsted4' ? { background: 'var(--accent-coral-bg)', color: 'var(--accent-coral, #e07256)' } :
|
||||
ofstedBadge.cssClass === 'ofstedRc' ? { background: '#5a3a6e', color: '#fff' } :
|
||||
ofstedBadge.cssClass === 'ofstedPending' ? { background: '#e0e0e0', color: '#666' } :
|
||||
{ background: '#e0e0e0', color: '#666' }),
|
||||
...(ofstedBadge.cssClass === 'ofsted1' ? { background: 'var(--status-above-bg)', color: 'var(--status-above)' } :
|
||||
ofstedBadge.cssClass === 'ofsted2' ? { background: 'var(--status-above-bg)', color: 'var(--status-above)' } :
|
||||
ofstedBadge.cssClass === 'ofsted3' ? { background: 'var(--status-below-bg)', color: 'var(--status-below)' } :
|
||||
ofstedBadge.cssClass === 'ofsted4' ? { background: 'var(--status-below)', color: 'var(--text-inverse)' } :
|
||||
ofstedBadge.cssClass === 'ofstedRc' ? { background: 'var(--phase-secondary-text)', color: 'var(--text-inverse)' } :
|
||||
ofstedBadge.cssClass === 'ofstedPending' ? { background: 'var(--bg-secondary)', color: 'var(--text-muted)' } :
|
||||
{ background: 'var(--bg-secondary)', color: 'var(--text-muted)' }),
|
||||
}}
|
||||
>
|
||||
{ofstedBadge.label}
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
/**
|
||||
* Brand illustration — the landing hero's landscape.
|
||||
*
|
||||
* Guideline rules for illustration: no people or faces, soft shapes, rounded
|
||||
* corners, minimal detail, maximum clarity. The scene reads as "the journey to
|
||||
* a school" — a winding path up through layered hills to a small schoolhouse,
|
||||
* with a pin marking where you are.
|
||||
*
|
||||
* Deliberately server-safe: no 'use client', no hooks, no event handlers, so
|
||||
* it renders in the RSC pass and never reaches the client bundle.
|
||||
*/
|
||||
|
||||
/*
|
||||
* These hex values mirror the brand palette in app/globals.css — --sky,
|
||||
* --sage, --brand, --coral, --sand and the tints/shades the scene needs
|
||||
* around them. They are written literally on purpose: an illustration is
|
||||
* artwork, not themed UI. Recolouring it per theme would break the picture,
|
||||
* so it keeps one fixed, light palette in both themes (it sits on a Sand
|
||||
* panel in light mode and reads as a framed image in dark mode).
|
||||
*/
|
||||
const SKY_HIGH = '#DAEDF8';
|
||||
const SKY_LOW = '#EFF8FB';
|
||||
const CLOUD = '#FFFFFF';
|
||||
|
||||
const HILL_FAR = '#D6EDE2'; // --sage, lightened
|
||||
const HILL_MID = '#A7D7C5'; // --sage, exact
|
||||
const HILL_NEAR = '#7FC3AC';
|
||||
const HILL_FRONT = '#5BA88F'; // --sage darkened toward --brand
|
||||
|
||||
const PATH_FILL = '#FAF6EE'; // --sand, lightened
|
||||
const PATH_EDGE = '#E6DAC2'; // --sand, darkened
|
||||
|
||||
const SCHOOL_WALL = '#FCE8C3'; // cream
|
||||
const SCHOOL_ROOF = '#F0A868'; // warm orange
|
||||
const SCHOOL_DOOR = '#0F766E'; // --brand, exact
|
||||
const SCHOOL_WINDOW = '#C7EBF5'; // --sky, exact
|
||||
const SCHOOL_CLOCK = '#FAFAF8'; // --bg-primary (Warm White)
|
||||
const FLAGPOLE = '#8FA3AE';
|
||||
const PENNANT = '#F97360'; // --coral, exact
|
||||
|
||||
const TREE_DARK = '#2E7D6B';
|
||||
const TREE_MID = '#3E8C74';
|
||||
const TREE_LIGHT = '#4A9E85';
|
||||
const TREE_PALE = '#7FC3AC';
|
||||
|
||||
const PIN = '#F97360'; // --coral, exact
|
||||
const PIN_EYE = '#FFFFFF';
|
||||
|
||||
/** Scoped so the gradient id can't collide with another inline SVG. */
|
||||
const SKY_GRADIENT_ID = 'sc-hero-sky';
|
||||
|
||||
export function HeroIllustration() {
|
||||
return (
|
||||
<svg viewBox="0 0 540 520" preserveAspectRatio="xMidYMax slice" aria-hidden="true" focusable="false">
|
||||
<defs>
|
||||
<linearGradient id={SKY_GRADIENT_ID} x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0" stopColor={SKY_HIGH} />
|
||||
<stop offset="1" stopColor={SKY_LOW} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
{/* Sky */}
|
||||
<rect width="540" height="520" fill={`url(#${SKY_GRADIENT_ID})`} />
|
||||
|
||||
{/* Two soft clouds, each a pair of overlapping ellipses */}
|
||||
<g fill={CLOUD} opacity="0.85">
|
||||
<ellipse cx="96" cy="86" rx="32" ry="15" />
|
||||
<ellipse cx="122" cy="79" rx="23" ry="18" />
|
||||
<ellipse cx="418" cy="58" rx="27" ry="13" />
|
||||
<ellipse cx="439" cy="52" rx="19" ry="14" />
|
||||
</g>
|
||||
|
||||
{/* Four layered rolling hills, palest and furthest first */}
|
||||
<path d="M0 300 C 90 264 168 294 246 276 C 330 256 410 282 540 254 L540 520 L0 520 Z" fill={HILL_FAR} />
|
||||
<path d="M0 344 C 104 308 186 340 268 324 C 356 306 452 332 540 306 L540 520 L0 520 Z" fill={HILL_MID} />
|
||||
<path d="M0 408 C 118 378 214 410 306 392 C 400 374 470 398 540 382 L540 520 L0 520 Z" fill={HILL_NEAR} />
|
||||
<path d="M0 468 C 130 446 236 474 340 458 C 432 444 486 462 540 452 L540 520 L0 520 Z" fill={HILL_FRONT} />
|
||||
|
||||
{/* Winding cream path, with a dotted edge for a little texture */}
|
||||
<path
|
||||
d="M214 520 C 202 466 254 448 276 424 C 298 400 274 378 254 366 C 230 352 244 328 276 316"
|
||||
stroke={PATH_FILL}
|
||||
strokeWidth="18"
|
||||
fill="none"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<path
|
||||
d="M214 520 C 202 466 254 448 276 424 C 298 400 274 378 254 366 C 230 352 244 328 276 316"
|
||||
stroke={PATH_EDGE}
|
||||
strokeWidth="18"
|
||||
fill="none"
|
||||
strokeLinecap="butt"
|
||||
strokeLinejoin="round"
|
||||
strokeDasharray="0.5 26"
|
||||
opacity="0.5"
|
||||
/>
|
||||
|
||||
{/* The school at the top of the path */}
|
||||
<g transform="translate(276 232)">
|
||||
<rect x="12" y="34" width="98" height="58" rx="4" fill={SCHOOL_WALL} />
|
||||
<path d="M2 36 L61 6 L120 36 Z" fill={SCHOOL_ROOF} />
|
||||
<rect x="54" y="62" width="20" height="30" rx="2" fill={SCHOOL_DOOR} />
|
||||
<g fill={SCHOOL_WINDOW}>
|
||||
<rect x="24" y="46" width="16" height="14" rx="2" />
|
||||
<rect x="86" y="46" width="16" height="14" rx="2" />
|
||||
<rect x="24" y="70" width="16" height="12" rx="2" />
|
||||
<rect x="86" y="70" width="16" height="12" rx="2" />
|
||||
</g>
|
||||
{/* Flagpole and coral pennant */}
|
||||
<rect x="59.5" y="-14" width="2.5" height="20" fill={FLAGPOLE} />
|
||||
<path d="M62 -13 L78 -8.5 L62 -4 Z" fill={PENNANT} />
|
||||
{/* Clock in the gable */}
|
||||
<circle cx="61" cy="24" r="6.5" fill={SCHOOL_CLOCK} />
|
||||
<path d="M61 20.5 v7 M57.5 24 h7" stroke={SCHOOL_DOOR} strokeWidth="1.5" strokeLinecap="round" />
|
||||
</g>
|
||||
|
||||
{/* Scattered rounded trees */}
|
||||
<g fill={TREE_DARK}>
|
||||
<circle cx="104" cy="372" r="27" />
|
||||
<circle cx="136" cy="386" r="19" />
|
||||
<circle cx="470" cy="336" r="23" />
|
||||
</g>
|
||||
<g fill={TREE_LIGHT}>
|
||||
<circle cx="78" cy="394" r="21" />
|
||||
<circle cx="492" cy="358" r="17" />
|
||||
<circle cx="176" cy="420" r="18" />
|
||||
</g>
|
||||
<g fill={TREE_MID}>
|
||||
<ellipse cx="44" cy="336" rx="15" ry="22" />
|
||||
<ellipse cx="508" cy="296" rx="13" ry="18" />
|
||||
<ellipse cx="150" cy="342" rx="12" ry="17" />
|
||||
</g>
|
||||
<g fill={TREE_PALE} opacity="0.9">
|
||||
<circle cx="410" cy="420" r="16" />
|
||||
<circle cx="438" cy="430" r="12" />
|
||||
<circle cx="120" cy="470" r="14" />
|
||||
</g>
|
||||
|
||||
{/* Location pin — you are here, the path leads up to the school */}
|
||||
<g transform="translate(196 372)">
|
||||
<path d="M21 48 C21 48 42 26 42 15.5 A21 21 0 1 0 0 15.5 C0 26 21 48 21 48 Z" fill={PIN} />
|
||||
<circle cx="21" cy="15.5" r="8" fill={PIN_EYE} />
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -11,7 +11,7 @@
|
||||
/* font-size:0 hides the button's own "?" text node; the ::before glyph
|
||||
below carries the visible circled "?" at its own explicit size. */
|
||||
font-size: 0;
|
||||
color: var(--text-muted, #8a7a72);
|
||||
color: var(--text-muted);
|
||||
cursor: help;
|
||||
line-height: 1;
|
||||
user-select: none;
|
||||
@@ -34,17 +34,17 @@
|
||||
.icon:hover,
|
||||
.icon[aria-expanded='true'],
|
||||
.icon:focus-visible {
|
||||
color: var(--accent-coral-dark, #b04a2e);
|
||||
color: var(--brand-strong);
|
||||
}
|
||||
|
||||
.tooltip {
|
||||
z-index: 9999;
|
||||
width: max-content;
|
||||
max-width: min(260px, calc(100vw - 24px));
|
||||
background: var(--bg-primary, #faf7f2);
|
||||
border: 1px solid var(--border-color, #e8ddd4);
|
||||
background: var(--bg-primary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 4px 16px rgba(44, 36, 32, 0.15);
|
||||
box-shadow: 0 4px 16px rgba(var(--shadow-rgb), 0.15);
|
||||
padding: 0.6rem 0.75rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -52,26 +52,26 @@
|
||||
}
|
||||
|
||||
.arrow {
|
||||
fill: var(--bg-primary, #faf7f2);
|
||||
stroke: var(--border-color, #e8ddd4);
|
||||
fill: var(--bg-primary);
|
||||
stroke: var(--border);
|
||||
stroke-width: 1px;
|
||||
}
|
||||
|
||||
.label {
|
||||
font-weight: 600;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-primary, #2c2420);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.plain {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary, #5a4a44);
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.detail {
|
||||
font-size: 0.7rem;
|
||||
color: var(--text-muted, #8a7a72);
|
||||
color: var(--text-muted);
|
||||
line-height: 1.4;
|
||||
margin-top: 0.1rem;
|
||||
}
|
||||
|
||||
@@ -22,10 +22,10 @@ interface LeafletHeroMapInnerProps {
|
||||
// reliably inside Leaflet's divIcon HTML string (no CSS-module dependency).
|
||||
const PIN_HTML = `
|
||||
<div style="position:relative;width:26px;height:26px">
|
||||
<span style="position:absolute;left:50%;top:74%;width:32px;height:32px;transform:translate(-50%,-50%);border-radius:50%;background:rgba(224,114,86,.25)"></span>
|
||||
<span style="position:absolute;left:50%;top:74%;width:32px;height:32px;transform:translate(-50%,-50%);border-radius:50%;background:var(--brand-bg)"></span>
|
||||
<svg width="26" height="34" viewBox="0 0 26 34" style="filter:drop-shadow(0 3px 4px rgba(0,0,0,.35))">
|
||||
<path d="M13 0C5.8 0 0 5.8 0 13c0 9.2 13 21 13 21s13-11.8 13-21C26 5.8 20.2 0 13 0Z" fill="#e07256"/>
|
||||
<circle cx="13" cy="13" r="5" fill="#fff"/>
|
||||
<path d="M13 0C5.8 0 0 5.8 0 13c0 9.2 13 21 13 21s13-11.8 13-21C26 5.8 20.2 0 13 0Z" fill="var(--brand)"/>
|
||||
<circle cx="13" cy="13" r="5" fill="var(--text-inverse)"/>
|
||||
</svg>
|
||||
</div>`;
|
||||
|
||||
|
||||
@@ -48,21 +48,21 @@ function buildPopupBadge(school: School): PopupBadge {
|
||||
// ofsted_framework is the raw event grouping, never "ReportCard").
|
||||
if (school.ofsted_rc_date) {
|
||||
const rcYear = new Date(school.ofsted_rc_date).getFullYear();
|
||||
return { label: `Report Card · ${rcYear}`, style: 'background:#5a3a6e;color:#fff' };
|
||||
return { label: `Report Card · ${rcYear}`, style: 'background:var(--phase-secondary-text);color:var(--text-inverse)' };
|
||||
}
|
||||
const year = school.ofsted_date ? new Date(school.ofsted_date).getFullYear() : null;
|
||||
const yearStr = year ? ` · ${year}` : '';
|
||||
if (school.ofsted_grade) {
|
||||
const labels: Record<number, string> = { 1: 'Outstanding', 2: 'Good', 3: 'Req. Improvement', 4: 'Inadequate' };
|
||||
const colours: Record<number, string> = {
|
||||
1: 'background:#d4f0ea;color:#2d7d7d',
|
||||
2: 'background:rgba(60,140,60,0.12);color:#2f7a2f',
|
||||
3: 'background:#fef3cd;color:#7a6800',
|
||||
4: 'background:#fde8e0;color:#e07256',
|
||||
1: 'background:var(--status-above-bg);color:var(--status-above)',
|
||||
2: 'background:var(--status-above-bg);color:var(--status-above)',
|
||||
3: 'background:var(--status-below-bg);color:var(--status-below)',
|
||||
4: 'background:var(--status-below);color:var(--text-inverse)',
|
||||
};
|
||||
return { label: `${labels[school.ofsted_grade]}${yearStr}`, style: colours[school.ofsted_grade] };
|
||||
}
|
||||
return { label: 'Not yet inspected', style: 'background:#e0e0e0;color:#666' };
|
||||
return { label: 'Not yet inspected', style: 'background:var(--bg-secondary);color:var(--text-muted)' };
|
||||
}
|
||||
|
||||
export default function LeafletMapInner({ schools, center, zoom, referencePoint, onMarkerClick, nationalAvgRwm, laAverages }: LeafletMapInnerProps) {
|
||||
@@ -99,7 +99,7 @@ export default function LeafletMapInner({ schools, center, zoom, referencePoint,
|
||||
const refIcon = L.divIcon({
|
||||
html: `<div style="
|
||||
width: 20px; height: 20px;
|
||||
background: #e07256;
|
||||
background: var(--brand);
|
||||
border: 3px solid white;
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.35);
|
||||
@@ -145,13 +145,13 @@ export default function LeafletMapInner({ schools, center, zoom, referencePoint,
|
||||
const sign = diff >= 0 ? '+' : '';
|
||||
// Att8 scores range 0–90 in 0.1 increments; ±0.5 is meaningful here
|
||||
// vs primary RWM % where ±2 pts is the threshold
|
||||
const colour = diff >= 0.5 ? '#2d7d7d' : diff <= -0.5 ? '#e07256' : '#8a847a';
|
||||
const colour = diff >= 0.5 ? 'var(--status-above)' : diff <= -0.5 ? 'var(--brand)' : 'var(--text-muted)';
|
||||
const laName = escapeHtml(school.local_authority ?? 'LA');
|
||||
deltaLine = `<div style="font-size:11px;font-weight:600;color:${colour}">${sign}${diff} vs ${laName} avg</div>`;
|
||||
}
|
||||
metricHtml = `<div style="margin-bottom:4px">
|
||||
<span style="font-size:20px;font-weight:700;color:#1a1612;font-family:Georgia,serif">${score.toFixed(1)}</span>
|
||||
<span style="font-size:11px;color:#8a847a;margin-left:4px">Attainment 8</span>
|
||||
<span style="font-size:20px;font-weight:700;color:var(--text-primary);font-family:Georgia,serif">${score.toFixed(1)}</span>
|
||||
<span style="font-size:11px;color:var(--text-muted);margin-left:4px">Attainment 8</span>
|
||||
${deltaLine}
|
||||
</div>`;
|
||||
} else if (school.rwm_expected_pct != null) {
|
||||
@@ -159,7 +159,7 @@ export default function LeafletMapInner({ schools, center, zoom, referencePoint,
|
||||
let deltaLine = '';
|
||||
if (nationalAvgRwm != null) {
|
||||
const diff = Math.round(rwm - nationalAvgRwm);
|
||||
const colour = diff >= 2 ? '#2d7d7d' : diff <= -2 ? '#e07256' : '#8a847a';
|
||||
const colour = diff >= 2 ? 'var(--status-above)' : diff <= -2 ? 'var(--brand)' : 'var(--text-muted)';
|
||||
const text =
|
||||
diff >= 2 ? `+${diff} pts vs national` :
|
||||
diff <= -2 ? `${diff} pts vs national` :
|
||||
@@ -167,8 +167,8 @@ export default function LeafletMapInner({ schools, center, zoom, referencePoint,
|
||||
deltaLine = `<div style="font-size:11px;font-weight:600;color:${colour}">${text}</div>`;
|
||||
}
|
||||
metricHtml = `<div style="margin-bottom:4px">
|
||||
<span style="font-size:20px;font-weight:700;color:#1a1612;font-family:Georgia,serif">${rwm}%</span>
|
||||
<span style="font-size:11px;color:#8a847a;margin-left:4px">Reading, Writing & Maths</span>
|
||||
<span style="font-size:20px;font-weight:700;color:var(--text-primary);font-family:Georgia,serif">${rwm}%</span>
|
||||
<span style="font-size:11px;color:var(--text-muted);margin-left:4px">Reading, Writing & Maths</span>
|
||||
${deltaLine}
|
||||
</div>`;
|
||||
}
|
||||
@@ -177,14 +177,14 @@ export default function LeafletMapInner({ schools, center, zoom, referencePoint,
|
||||
|
||||
const popupContent = `<div style="font-family:system-ui,sans-serif;min-width:240px;max-width:280px;padding:0">
|
||||
<div style="display:flex;justify-content:space-between;align-items:flex-start;gap:8px;margin-bottom:6px">
|
||||
<strong style="font-size:13px;color:#1a1612;line-height:1.3">${escapeHtml(school.school_name)}</strong>
|
||||
<strong style="font-size:13px;color:var(--text-primary);line-height:1.3">${escapeHtml(school.school_name)}</strong>
|
||||
<span style="font-size:10px;font-weight:700;padding:2px 6px;border-radius:3px;white-space:nowrap;flex-shrink:0;${badge.style}">${badge.label}</span>
|
||||
</div>
|
||||
<div style="font-size:11px;color:#8a847a;margin-bottom:8px">
|
||||
<div style="font-size:11px;color:var(--text-muted);margin-bottom:8px">
|
||||
${phaseLabel}${school.local_authority ? ` · ${escapeHtml(school.local_authority)}` : ''}${distanceStr}
|
||||
</div>
|
||||
${metricHtml}
|
||||
<a href="${slug}" style="display:block;text-align:center;padding:6px;background:#2d7d7d;color:white;border-radius:5px;text-decoration:none;font-size:12px;font-weight:600;margin-top:8px">View Details →</a>
|
||||
<a href="${slug}" style="display:block;text-align:center;padding:6px;background:var(--status-above);color:white;border-radius:5px;text-decoration:none;font-size:12px;font-weight:600;margin-top:8px">View Details →</a>
|
||||
</div>`;
|
||||
|
||||
marker.bindPopup(popupContent);
|
||||
|
||||
@@ -5,19 +5,19 @@
|
||||
}
|
||||
|
||||
.skeletonCard {
|
||||
background: var(--bg-card, white);
|
||||
border: 1px solid var(--border-color, #e5dfd5);
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 1.5rem;
|
||||
box-shadow: var(--shadow-soft, 0 2px 8px rgba(26, 22, 18, 0.06));
|
||||
box-shadow: var(--shadow-soft, 0 2px 8px rgba(var(--shadow-rgb), 0.06));
|
||||
}
|
||||
|
||||
.skeleton {
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
var(--bg-secondary, #f3ede4) 25%,
|
||||
rgba(224, 114, 86, 0.08) 50%,
|
||||
var(--bg-secondary, #f3ede4) 75%
|
||||
var(--bg-secondary) 25%,
|
||||
rgba(var(--brand-rgb), 0.08) 50%,
|
||||
var(--bg-secondary) 75%
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
animation: shimmer 1.5s ease-in-out infinite;
|
||||
@@ -56,7 +56,7 @@
|
||||
gap: 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
padding: 1rem;
|
||||
background: var(--bg-secondary, #f3ede4);
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
@@ -82,11 +82,11 @@
|
||||
}
|
||||
|
||||
.skeletonListItem {
|
||||
background: var(--bg-card, white);
|
||||
border: 1px solid var(--border-color, #e5dfd5);
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 1.5rem;
|
||||
box-shadow: var(--shadow-soft, 0 2px 8px rgba(26, 22, 18, 0.06));
|
||||
box-shadow: var(--shadow-soft, 0 2px 8px rgba(var(--shadow-rgb), 0.06));
|
||||
}
|
||||
|
||||
.listTitle {
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* The schoolcompare mark.
|
||||
*
|
||||
* A location pin with a leaf growing inside it: where a school is, and a child
|
||||
* growing there. The pin's tail sweeps left as the guideline's "signature path
|
||||
* shape" — the journey a parent takes to find the right school — so the mark
|
||||
* and the illustration style share one gesture.
|
||||
*
|
||||
* This is the single source for the mark. app/icon.svg, app/apple-icon.tsx and
|
||||
* app/opengraph-image.tsx all derive from the same geometry (see MARK_PATHS),
|
||||
* because the header and the favicon had previously drifted into two different
|
||||
* logos.
|
||||
*
|
||||
* Drawn on a 48×48 grid. The pin takes the brand hue and the leaf is knocked
|
||||
* out of it, so the mark needs exactly two colours and works on any ground.
|
||||
*/
|
||||
|
||||
/** Geometry shared by every rendering of the mark, on a 0 0 48 48 viewBox. */
|
||||
export const MARK_PATHS = {
|
||||
/** The path tail, stroked — 8.5 wide with a round cap. */
|
||||
tail: 'M17 30 C15 35.6 11.6 40.2 7.8 42.8',
|
||||
/** The pin head. */
|
||||
head: { cx: 26.5, cy: 18.5, r: 16.5 },
|
||||
/** Leaf stem, stroked — 2.6 wide with a round cap. */
|
||||
stem: 'M26.5 29.5 C26.5 25 26.6 21 27 18',
|
||||
/** Upper leaf, filled. */
|
||||
leafUpper: 'M27 19.6 C28.2 13.8 32.4 9.8 37.6 9.4 C38 15.6 33.8 20.8 27 19.6 Z',
|
||||
/** Lower leaf, filled. */
|
||||
leafLower: 'M26.4 24.6 C25.2 20 21.2 17 16.4 17.1 C16.2 21.8 19.8 25.8 26.4 24.6 Z',
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* The mark as a standalone SVG string.
|
||||
*
|
||||
* Satori (next/og) renders `<img>` with a data URI far more reliably than it
|
||||
* renders inline SVG children, so the generated icon and share card both draw
|
||||
* the mark this way — from the same geometry as the React component above,
|
||||
* which is the whole point of MARK_PATHS.
|
||||
*/
|
||||
export function markSvg(pin: string, leaf: string): string {
|
||||
return [
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" fill="none">',
|
||||
`<path d="${MARK_PATHS.tail}" stroke="${pin}" stroke-width="8.5" stroke-linecap="round" fill="none"/>`,
|
||||
`<circle cx="${MARK_PATHS.head.cx}" cy="${MARK_PATHS.head.cy}" r="${MARK_PATHS.head.r}" fill="${pin}"/>`,
|
||||
`<path d="${MARK_PATHS.stem}" stroke="${leaf}" stroke-width="2.6" stroke-linecap="round" fill="none"/>`,
|
||||
`<path d="${MARK_PATHS.leafUpper}" fill="${leaf}"/>`,
|
||||
`<path d="${MARK_PATHS.leafLower}" fill="${leaf}"/>`,
|
||||
'</svg>',
|
||||
].join('');
|
||||
}
|
||||
|
||||
/** The same string as a data URI, ready for `<img src>`. */
|
||||
export function markDataUri(pin: string, leaf: string): string {
|
||||
return `data:image/svg+xml;base64,${Buffer.from(markSvg(pin, leaf)).toString('base64')}`;
|
||||
}
|
||||
|
||||
interface LogoMarkProps {
|
||||
className?: string;
|
||||
/** Rendered size in px. Defaults to inheriting via CSS. */
|
||||
size?: number;
|
||||
/** The pin. Defaults to the brand token so it inverts with the theme. */
|
||||
pin?: string;
|
||||
/** The leaf knocked out of the pin. */
|
||||
leaf?: string;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
export function LogoMark({
|
||||
className,
|
||||
size,
|
||||
pin = 'var(--brand)',
|
||||
leaf = 'var(--bg-card)',
|
||||
title,
|
||||
}: LogoMarkProps) {
|
||||
return (
|
||||
<svg
|
||||
className={className}
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 48 48"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
role={title ? 'img' : undefined}
|
||||
aria-hidden={title ? undefined : true}
|
||||
aria-label={title}
|
||||
>
|
||||
{title ? <title>{title}</title> : null}
|
||||
<path
|
||||
d={MARK_PATHS.tail}
|
||||
stroke={pin}
|
||||
strokeWidth={8.5}
|
||||
strokeLinecap="round"
|
||||
fill="none"
|
||||
/>
|
||||
<circle cx={MARK_PATHS.head.cx} cy={MARK_PATHS.head.cy} r={MARK_PATHS.head.r} fill={pin} />
|
||||
<path
|
||||
d={MARK_PATHS.stem}
|
||||
stroke={leaf}
|
||||
strokeWidth={2.6}
|
||||
strokeLinecap="round"
|
||||
fill="none"
|
||||
/>
|
||||
<path d={MARK_PATHS.leafUpper} fill={leaf} />
|
||||
<path d={MARK_PATHS.leafLower} fill={leaf} />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
.overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(26, 22, 18, 0.6);
|
||||
background: rgba(var(--shadow-rgb), 0.6);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -20,15 +20,15 @@
|
||||
}
|
||||
|
||||
.modal {
|
||||
background: var(--bg-card, white);
|
||||
background: var(--bg-card);
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 20px 40px rgba(26, 22, 18, 0.2);
|
||||
box-shadow: 0 20px 40px rgba(var(--shadow-rgb), 0.2);
|
||||
max-height: 90vh;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
animation: slideIn 0.3s ease;
|
||||
border: 1px solid var(--border-color, #e5dfd5);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
@@ -62,22 +62,22 @@
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 1.5rem;
|
||||
border-bottom: 1px solid var(--border-color, #e5dfd5);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.title {
|
||||
margin: 0;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary, #1a1612);
|
||||
font-family: var(--font-playfair), 'Playfair Display', serif;
|
||||
color: var(--text-primary);
|
||||
font-family: var(--font-display);
|
||||
}
|
||||
|
||||
.closeButton {
|
||||
padding: 0.5rem;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text-muted, #8a847a);
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
border-radius: 8px;
|
||||
transition: all 0.2s ease;
|
||||
@@ -87,8 +87,8 @@
|
||||
}
|
||||
|
||||
.closeButton:hover {
|
||||
background: var(--bg-secondary, #f3ede4);
|
||||
color: var(--accent-coral, #e07256);
|
||||
background: var(--bg-secondary);
|
||||
color: var(--brand);
|
||||
}
|
||||
|
||||
.content {
|
||||
@@ -102,16 +102,16 @@
|
||||
}
|
||||
|
||||
.content::-webkit-scrollbar-track {
|
||||
background: var(--bg-secondary, #f3ede4);
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.content::-webkit-scrollbar-thumb {
|
||||
background: var(--border-color, #e5dfd5);
|
||||
background: var(--border);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.content::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--text-muted, #8a847a);
|
||||
background: var(--text-muted);
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
|
||||
@@ -1,10 +1,17 @@
|
||||
/*
|
||||
* Header and mobile tab bar, on the schoolcompare brand.
|
||||
*
|
||||
* The header is a white card band closed by a single hairline rule — no
|
||||
* shadow. The guideline's chrome is quiet; the colour on this surface is the
|
||||
* wordmark and the one active nav item, and nothing else.
|
||||
*/
|
||||
|
||||
.header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1000;
|
||||
background: var(--bg-card, white);
|
||||
border-bottom: 1px solid var(--border-color, #e5dfd5);
|
||||
box-shadow: 0 2px 8px rgba(26, 22, 18, 0.06);
|
||||
background: var(--bg-card);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.container {
|
||||
@@ -16,33 +23,42 @@
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 2rem;
|
||||
/* 64px desktop / 56px mobile is load-bearing: the school detail page's
|
||||
sticky section nav docks against it with a hardcoded `top` in
|
||||
SchoolDetailShell.module.css and schoolSections.module.css, and the
|
||||
scroll-spy observers use it as a rootMargin. Change it in all four
|
||||
places or not at all. */
|
||||
height: 64px;
|
||||
}
|
||||
|
||||
.logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
gap: 0.625rem;
|
||||
/* Padded hit area so the logo link is ≥44×44 on touch */
|
||||
margin: -0.375rem -0.5rem;
|
||||
padding: 0.375rem 0.5rem;
|
||||
text-decoration: none;
|
||||
color: var(--text-primary, #1a1612);
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
transition: color 0.2s ease;
|
||||
color: var(--text-primary);
|
||||
transition: color var(--transition);
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
.logo:hover {
|
||||
color: var(--accent-coral-dark, #b04a2e);
|
||||
color: var(--brand-stronger);
|
||||
}
|
||||
|
||||
/*
|
||||
* The mark colours itself — pin in var(--brand), leaf knocked out in
|
||||
* var(--bg-card), which is this header's own ground. It takes no colour from
|
||||
* currentColor, so there is deliberately no `color` here.
|
||||
*/
|
||||
.logoIcon {
|
||||
display: inline-flex;
|
||||
flex: 0 0 auto;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
color: var(--accent-coral, #e07256);
|
||||
}
|
||||
|
||||
.logoIcon svg {
|
||||
@@ -50,78 +66,76 @@
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/*
|
||||
* "schoolcompare", one word, lower case. Manrope 700 pulled tight — the
|
||||
* negative tracking is what makes the two halves read as a single word rather
|
||||
* than as two.
|
||||
*/
|
||||
.logoText {
|
||||
font-family: var(--font-playfair), 'Playfair Display', serif;
|
||||
font-family: var(--font-display);
|
||||
font-size: var(--step-2);
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.01em;
|
||||
line-height: 1;
|
||||
letter-spacing: -0.035em;
|
||||
}
|
||||
|
||||
.logoTextAccent {
|
||||
color: var(--brand);
|
||||
}
|
||||
|
||||
.logo:hover .logoTextAccent {
|
||||
color: var(--brand-stronger);
|
||||
}
|
||||
|
||||
.nav {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
gap: 0.125rem;
|
||||
}
|
||||
|
||||
.navLink {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.625rem 1rem;
|
||||
font-size: 0.9375rem;
|
||||
gap: 0.4375rem;
|
||||
padding: 0.5rem 0.875rem;
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--step--1);
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary, #5c564d);
|
||||
color: var(--text-secondary);
|
||||
text-decoration: none;
|
||||
border-radius: 6px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
/* Sliding underline effect */
|
||||
.navLink::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: 4px;
|
||||
left: 1rem;
|
||||
right: 1rem;
|
||||
height: 2px;
|
||||
background: var(--accent-coral, #e07256);
|
||||
transform: scaleX(0);
|
||||
transform-origin: left;
|
||||
transition: transform 0.25s ease;
|
||||
border-radius: 1px;
|
||||
border-radius: var(--radius-md);
|
||||
transition: color var(--transition), background-color var(--transition);
|
||||
}
|
||||
|
||||
.navLink:hover {
|
||||
color: var(--text-primary, #1a1612);
|
||||
background: var(--bg-secondary, #f3ede4);
|
||||
}
|
||||
|
||||
.navLink:hover::after {
|
||||
transform: scaleX(1);
|
||||
color: var(--text-primary);
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
/* The current page is marked twice — brand hue and a heavier weight — so the
|
||||
state never rests on colour alone. */
|
||||
.navLink.active {
|
||||
color: var(--accent-coral-dark, #b04a2e);
|
||||
background: var(--accent-coral-bg);
|
||||
}
|
||||
|
||||
.navLink.active::after {
|
||||
transform: scaleX(1);
|
||||
color: var(--brand);
|
||||
background: var(--brand-bg);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
min-width: 1.125rem;
|
||||
height: 1.125rem;
|
||||
padding: 0 0.375rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: white;
|
||||
background: var(--accent-coral, #e07256);
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--step--2);
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
color: var(--brand-on);
|
||||
background: var(--brand);
|
||||
border-radius: 9999px;
|
||||
animation: badgePop 0.3s ease-out;
|
||||
box-shadow: 0 2px 6px rgba(224, 114, 86, 0.4);
|
||||
}
|
||||
|
||||
@keyframes badgePop {
|
||||
@@ -149,25 +163,23 @@
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.2rem;
|
||||
gap: 0.25rem;
|
||||
flex: 1;
|
||||
min-height: 56px;
|
||||
padding: 0.375rem 0.25rem;
|
||||
color: var(--text-secondary, #5c564d);
|
||||
font-family: var(--font-ui);
|
||||
color: var(--text-secondary);
|
||||
text-decoration: none;
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.01em;
|
||||
transition: color 0.15s ease, background-color 0.15s ease;
|
||||
transition: color var(--transition);
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
.tab:active {
|
||||
background: var(--bg-secondary, #f3ede4);
|
||||
}
|
||||
|
||||
.tabActive {
|
||||
color: var(--accent-coral-dark, #b04a2e);
|
||||
color: var(--brand);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.tabIconWrap {
|
||||
@@ -179,7 +191,29 @@
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
/* The active tab's icon sits in a brand-tinted stadium — the same rounded
|
||||
geometry as the rest of the system, and a second cue beyond hue. Drawn as a
|
||||
pseudo-element so it can bleed wider than the 24px icon box without moving
|
||||
the badge's anchor. */
|
||||
.tabIconWrap::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: -2px -9px;
|
||||
border-radius: var(--radius-xl);
|
||||
background: transparent;
|
||||
transition: background-color var(--transition);
|
||||
}
|
||||
|
||||
.tabActive .tabIconWrap::before {
|
||||
background: var(--brand-bg);
|
||||
}
|
||||
|
||||
.tab:active .tabIconWrap::before {
|
||||
background: var(--brand-bg);
|
||||
}
|
||||
|
||||
.tabIcon {
|
||||
position: relative;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
}
|
||||
@@ -190,19 +224,21 @@
|
||||
|
||||
.tabBadge {
|
||||
position: absolute;
|
||||
top: -6px;
|
||||
right: -10px;
|
||||
top: -7px;
|
||||
right: -7px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 18px;
|
||||
height: 18px;
|
||||
padding: 0 5px;
|
||||
font-family: var(--font-ui);
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 700;
|
||||
color: white;
|
||||
background: var(--accent-coral, #e07256);
|
||||
border: 2px solid var(--bg-card, white);
|
||||
line-height: 1;
|
||||
color: var(--brand-on);
|
||||
background: var(--brand);
|
||||
border: 2px solid var(--bg-card);
|
||||
border-radius: 9999px;
|
||||
animation: badgePop 0.3s ease-out;
|
||||
}
|
||||
@@ -219,8 +255,12 @@
|
||||
}
|
||||
|
||||
.logoIcon {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
}
|
||||
|
||||
.logoText {
|
||||
font-size: var(--step-1);
|
||||
}
|
||||
|
||||
.bottomBar {
|
||||
@@ -230,9 +270,9 @@
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 1000;
|
||||
background: var(--bg-card, white);
|
||||
border-top: 1px solid var(--border-color, #e5dfd5);
|
||||
box-shadow: 0 -2px 12px rgba(26, 22, 18, 0.06);
|
||||
background: var(--bg-card);
|
||||
border-top: 1px solid var(--border);
|
||||
box-shadow: 0 -2px 12px rgba(var(--shadow-rgb), 0.06);
|
||||
/* Respect iPhone home-indicator (bottom) and notch (left/right in
|
||||
landscape) insets so the tab content never sits under system UI. */
|
||||
padding-bottom: env(safe-area-inset-bottom, 0);
|
||||
|
||||
@@ -9,6 +9,7 @@ import { useEffect } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { useComparison } from '@/hooks/useComparison';
|
||||
import { LogoMark } from './Logo';
|
||||
import styles from './Navigation.module.css';
|
||||
|
||||
type IconProps = { className?: string };
|
||||
@@ -78,6 +79,16 @@ export function Navigation() {
|
||||
};
|
||||
}, []);
|
||||
|
||||
/*
|
||||
* The brand guideline's nav also carries Guides, About, Favourites and a
|
||||
* "Sign in" button. None of the four is added here, deliberately: there are
|
||||
* no Guides or About pages, and Favourites and Sign in both need an accounts
|
||||
* system that does not exist. Drawing chrome for features we do not have
|
||||
* would be a promise the product cannot keep. Rankings and Admissions —
|
||||
* which the guideline omits — take their place, because they are real.
|
||||
*
|
||||
* If accounts ever ship, this is the list to extend.
|
||||
*/
|
||||
const items = [
|
||||
{ href: '/', label: 'Search', Icon: SearchIcon },
|
||||
{ href: '/compare', label: 'Compare', Icon: CompareIcon },
|
||||
@@ -89,15 +100,20 @@ export function Navigation() {
|
||||
<>
|
||||
<header className={styles.header}>
|
||||
<div className={styles.container}>
|
||||
<Link href="/" className={styles.logo} aria-label="SchoolCompare home">
|
||||
<Link href="/" className={styles.logo} aria-label="schoolcompare home">
|
||||
{/*
|
||||
LogoMark's defaults are already correct for this ground: the pin
|
||||
takes var(--brand) and the leaf is knocked out in var(--bg-card),
|
||||
which is exactly the header's own background in both themes. No
|
||||
explicit pin/leaf needed here — the footer, which sits on the
|
||||
sunken teal band, does have to pass them.
|
||||
*/}
|
||||
<span className={styles.logoIcon}>
|
||||
<svg viewBox="0 0 40 40" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
||||
<circle cx="20" cy="20" r="18" stroke="currentColor" strokeWidth="2" />
|
||||
<path d="M20 6L20 34M8 14L32 14M6 20L34 20M8 26L32 26" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
|
||||
<circle cx="20" cy="20" r="3" fill="currentColor" />
|
||||
</svg>
|
||||
<LogoMark />
|
||||
</span>
|
||||
<span className={styles.logoText}>
|
||||
school<span className={styles.logoTextAccent}>compare</span>
|
||||
</span>
|
||||
<span className={styles.logoText}>SchoolCompare</span>
|
||||
</Link>
|
||||
|
||||
<nav className={styles.nav} aria-label="Main navigation">
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
.info {
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-muted, #8a847a);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.controls {
|
||||
@@ -21,18 +21,18 @@
|
||||
padding: 0.5rem 1rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
background: var(--bg-card, white);
|
||||
color: var(--text-secondary, #5c564d);
|
||||
border: 1px solid var(--border-color, #e5dfd5);
|
||||
background: var(--bg-card);
|
||||
color: var(--text-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.navButton:hover:not(:disabled) {
|
||||
background: var(--bg-secondary, #f3ede4);
|
||||
border-color: var(--accent-coral, #e07256);
|
||||
color: var(--text-primary, #1a1612);
|
||||
background: var(--bg-secondary);
|
||||
border-color: var(--brand);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.navButton:disabled {
|
||||
@@ -51,32 +51,32 @@
|
||||
padding: 0.5rem 0.75rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
background: var(--bg-card, white);
|
||||
border: 1px solid var(--border-color, #e5dfd5);
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.pageButton {
|
||||
color: var(--text-secondary, #5c564d);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.pageButton:hover {
|
||||
background: var(--bg-secondary, #f3ede4);
|
||||
border-color: var(--accent-coral, #e07256);
|
||||
color: var(--text-primary, #1a1612);
|
||||
background: var(--bg-secondary);
|
||||
border-color: var(--brand);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.pageButtonActive {
|
||||
background: var(--accent-coral, #e07256);
|
||||
color: white;
|
||||
border-color: var(--accent-coral, #e07256);
|
||||
background: var(--brand);
|
||||
color: var(--brand-on);
|
||||
border-color: var(--brand);
|
||||
}
|
||||
|
||||
.pageButtonActive:hover {
|
||||
background: var(--accent-coral-dark, #c45a3f);
|
||||
border-color: var(--accent-coral-dark, #c45a3f);
|
||||
background: var(--brand-strong);
|
||||
border-color: var(--brand-strong);
|
||||
}
|
||||
|
||||
.ellipsis {
|
||||
@@ -84,7 +84,7 @@
|
||||
align-items: center;
|
||||
padding: 0.5rem 0.75rem;
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-muted, #8a847a);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
|
||||
@@ -12,10 +12,10 @@
|
||||
.trendSummary {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary, #5c564d);
|
||||
color: var(--text-secondary);
|
||||
padding: 0.5rem 0.875rem;
|
||||
background: var(--bg-secondary, #f3ede4);
|
||||
border-left: 3px solid var(--accent-teal, #2d7d7d);
|
||||
background: var(--bg-secondary);
|
||||
border-left: 3px solid var(--brand);
|
||||
border-radius: 0 4px 4px 0;
|
||||
align-self: flex-start;
|
||||
}
|
||||
@@ -34,7 +34,7 @@
|
||||
.covidNote,
|
||||
.chartHint {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted, #8a847a);
|
||||
color: var(--text-muted);
|
||||
margin: 0;
|
||||
font-style: italic;
|
||||
}
|
||||
@@ -52,7 +52,7 @@
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-muted, #6d685f);
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
|
||||
@@ -63,9 +63,9 @@
|
||||
}
|
||||
|
||||
.chip {
|
||||
border: 1px solid var(--border-color, #e5dfd5);
|
||||
background: var(--bg-card, #fff);
|
||||
color: var(--text-secondary, #5c564d);
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg-card);
|
||||
color: var(--text-secondary);
|
||||
padding: 0.4rem 0.75rem;
|
||||
border-radius: 999px;
|
||||
font-size: 0.8125rem;
|
||||
@@ -78,7 +78,7 @@
|
||||
}
|
||||
|
||||
.chip:active {
|
||||
background: var(--bg-secondary, #f3ede4);
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.chip:disabled {
|
||||
@@ -87,21 +87,21 @@
|
||||
}
|
||||
|
||||
.chipActive {
|
||||
background: var(--accent-coral-dark, #b04a2e);
|
||||
color: white;
|
||||
border-color: var(--accent-coral, #e07256);
|
||||
background: var(--brand-strong);
|
||||
color: var(--brand-on);
|
||||
border-color: var(--brand);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.chipActive:active {
|
||||
background: var(--accent-coral-dark, #c45a3f);
|
||||
background: var(--brand-strong);
|
||||
}
|
||||
|
||||
.miniLegend {
|
||||
display: flex;
|
||||
gap: 0.875rem;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary, #5c564d);
|
||||
color: var(--text-secondary);
|
||||
margin-top: -0.25rem;
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useThemeTokens, alpha } from '@/lib/theme';
|
||||
import { Line } from 'react-chartjs-2';
|
||||
import { ChartOptions, ChartDataset } from 'chart.js';
|
||||
import '@/lib/chartSetup';
|
||||
@@ -124,9 +125,17 @@ export function PerformanceChart({
|
||||
const gapYears = axisYears.filter(y => !byYear.has(y));
|
||||
const hasGap = gapYears.length > 0;
|
||||
|
||||
// Chart.js paints to a canvas, so it needs resolved colours rather than
|
||||
// var(). Reading them through the token bridge keeps the series on the
|
||||
// brand palette and lets them follow the theme.
|
||||
const [c1, c2, c3, c4, c5, c6, cGrid, cRef, cInverse, cInverseText] = useThemeTokens(
|
||||
'--chart-1', '--chart-2', '--chart-3', '--chart-4', '--chart-5', '--chart-6',
|
||||
'--chart-grid', '--chart-reference', '--surface-inverse', '--text-inverse'
|
||||
);
|
||||
|
||||
// ── Datasets (full set; mobile filters them via the active chip) ─────
|
||||
const refLineStyle = {
|
||||
borderColor: 'rgba(90,80,70,0.35)',
|
||||
borderColor: cRef,
|
||||
backgroundColor: 'transparent',
|
||||
borderWidth: 1.5,
|
||||
borderDash: [6, 4] as number[],
|
||||
@@ -140,8 +149,8 @@ export function PerformanceChart({
|
||||
{
|
||||
label: 'Attainment 8',
|
||||
data: col('attainment_8_score'),
|
||||
borderColor: '#2d7d7d',
|
||||
backgroundColor: 'rgba(45,125,125,0.08)',
|
||||
borderColor: c1,
|
||||
backgroundColor: alpha('--chart-1', 0.08),
|
||||
borderWidth: 2.5,
|
||||
tension: 0.3,
|
||||
pointRadius: 4,
|
||||
@@ -151,8 +160,8 @@ export function PerformanceChart({
|
||||
{
|
||||
label: 'English & Maths Grade 4+',
|
||||
data: col('english_maths_standard_pass_pct'),
|
||||
borderColor: '#c9a227',
|
||||
backgroundColor: 'rgba(201,162,39,0.08)',
|
||||
borderColor: c2,
|
||||
backgroundColor: alpha('--chart-2', 0.08),
|
||||
borderWidth: 1.5,
|
||||
tension: 0.3,
|
||||
pointRadius: 3,
|
||||
@@ -161,8 +170,8 @@ export function PerformanceChart({
|
||||
{
|
||||
label: 'Progress 8',
|
||||
data: col('progress_8_score'),
|
||||
borderColor: 'rgb(139,92,246)',
|
||||
backgroundColor: 'rgba(139,92,246,0.08)',
|
||||
borderColor: c3,
|
||||
backgroundColor: alpha('--chart-3', 0.08),
|
||||
borderWidth: 1.5,
|
||||
tension: 0.3,
|
||||
pointRadius: 3,
|
||||
@@ -179,8 +188,8 @@ export function PerformanceChart({
|
||||
{
|
||||
label: 'Reading, Writing & Maths expected %',
|
||||
data: col('rwm_expected_pct'),
|
||||
borderColor: '#2d7d7d',
|
||||
backgroundColor: 'rgba(45,125,125,0.08)',
|
||||
borderColor: c1,
|
||||
backgroundColor: alpha('--chart-1', 0.08),
|
||||
borderWidth: 2.5,
|
||||
tension: 0.3,
|
||||
pointRadius: 4,
|
||||
@@ -190,8 +199,8 @@ export function PerformanceChart({
|
||||
{
|
||||
label: 'Exceeding expected level',
|
||||
data: col('rwm_high_pct'),
|
||||
borderColor: '#c9a227',
|
||||
backgroundColor: 'rgba(201,162,39,0.08)',
|
||||
borderColor: c2,
|
||||
backgroundColor: alpha('--chart-2', 0.08),
|
||||
borderWidth: 1.5,
|
||||
tension: 0.3,
|
||||
pointRadius: 3,
|
||||
@@ -206,8 +215,8 @@ export function PerformanceChart({
|
||||
{
|
||||
label: 'Reading progress',
|
||||
data: col('reading_progress'),
|
||||
borderColor: 'rgb(59,130,246)',
|
||||
backgroundColor: 'rgba(59,130,246,0.08)',
|
||||
borderColor: c4,
|
||||
backgroundColor: alpha('--chart-4', 0.08),
|
||||
borderWidth: 1.5,
|
||||
tension: 0.3,
|
||||
pointRadius: 3,
|
||||
@@ -217,8 +226,8 @@ export function PerformanceChart({
|
||||
{
|
||||
label: 'Writing progress',
|
||||
data: col('writing_progress'),
|
||||
borderColor: 'rgb(139,92,246)',
|
||||
backgroundColor: 'rgba(139,92,246,0.08)',
|
||||
borderColor: c3,
|
||||
backgroundColor: alpha('--chart-3', 0.08),
|
||||
borderWidth: 1.5,
|
||||
tension: 0.3,
|
||||
pointRadius: 3,
|
||||
@@ -228,8 +237,8 @@ export function PerformanceChart({
|
||||
{
|
||||
label: 'Maths progress',
|
||||
data: col('maths_progress'),
|
||||
borderColor: 'rgb(236,72,153)',
|
||||
backgroundColor: 'rgba(236,72,153,0.08)',
|
||||
borderColor: c5,
|
||||
backgroundColor: alpha('--chart-5', 0.08),
|
||||
borderWidth: 1.5,
|
||||
tension: 0.3,
|
||||
pointRadius: 3,
|
||||
@@ -301,7 +310,9 @@ export function PerformanceChart({
|
||||
},
|
||||
title: { display: false },
|
||||
tooltip: {
|
||||
backgroundColor: 'rgba(26,22,18,0.92)',
|
||||
backgroundColor: cInverse,
|
||||
titleColor: cInverseText,
|
||||
bodyColor: cInverseText,
|
||||
padding: 12,
|
||||
titleFont: { size: 13 },
|
||||
bodyFont: { size: 12 },
|
||||
@@ -321,7 +332,7 @@ export function PerformanceChart({
|
||||
type: 'linear', display: true, position: 'left',
|
||||
title: { display: true, text: isSecondary ? 'Score / %' : 'Percentage (%)', font: { size: 11 } },
|
||||
min: 0, max: isSecondary ? undefined : 100,
|
||||
grid: { color: 'rgba(0,0,0,0.05)' },
|
||||
grid: { color: cGrid },
|
||||
ticks: { font: { size: 11 } },
|
||||
},
|
||||
y1: {
|
||||
@@ -346,7 +357,9 @@ export function PerformanceChart({
|
||||
legend: { display: false },
|
||||
title: { display: false },
|
||||
tooltip: {
|
||||
backgroundColor: 'rgba(26,22,18,0.92)',
|
||||
backgroundColor: cInverse,
|
||||
titleColor: cInverseText,
|
||||
bodyColor: cInverseText,
|
||||
padding: 10,
|
||||
titleFont: { size: 12 },
|
||||
bodyFont: { size: 11 },
|
||||
@@ -364,7 +377,7 @@ export function PerformanceChart({
|
||||
y: {
|
||||
type: 'linear', display: true, position: 'left',
|
||||
min: mobileYBounds.min, max: mobileYBounds.max,
|
||||
grid: { color: 'rgba(0,0,0,0.05)' },
|
||||
grid: { color: cGrid },
|
||||
ticks: { font: { size: 10 }, maxTicksLimit: 5 },
|
||||
},
|
||||
x: {
|
||||
@@ -429,9 +442,9 @@ export function PerformanceChart({
|
||||
belong together. */}
|
||||
{isMobile && activeChip === 'progress' && (
|
||||
<div className={styles.miniLegend}>
|
||||
<span><span className={styles.miniDot} style={{ background: 'rgb(59,130,246)' }} />Reading</span>
|
||||
<span><span className={styles.miniDot} style={{ background: 'rgb(139,92,246)' }} />Writing</span>
|
||||
<span><span className={styles.miniDot} style={{ background: 'rgb(236,72,153)' }} />Maths</span>
|
||||
<span><span className={styles.miniDot} style={{ background: c4 }} />Reading</span>
|
||||
<span><span className={styles.miniDot} style={{ background: c3 }} />Writing</span>
|
||||
<span><span className={styles.miniDot} style={{ background: c5 }} />Maths</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -11,14 +11,14 @@
|
||||
.header h1 {
|
||||
font-size: 2.25rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary, #1a1612);
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 0.5rem;
|
||||
font-family: var(--font-playfair), 'Playfair Display', serif;
|
||||
font-family: var(--font-display);
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 1rem;
|
||||
color: var(--text-secondary, #5c564d);
|
||||
color: var(--text-secondary);
|
||||
margin: 0;
|
||||
line-height: 1.6;
|
||||
}
|
||||
@@ -28,7 +28,7 @@
|
||||
display: flex;
|
||||
gap: 0;
|
||||
margin-bottom: 1.5rem;
|
||||
border: 1px solid var(--border-color, #e5dfd5);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
width: fit-content;
|
||||
@@ -38,8 +38,8 @@
|
||||
padding: 0.625rem 1.5rem;
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 500;
|
||||
background: var(--bg-card, white);
|
||||
color: var(--text-secondary, #5c564d);
|
||||
background: var(--bg-card);
|
||||
color: var(--text-secondary);
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
@@ -47,34 +47,34 @@
|
||||
}
|
||||
|
||||
.phaseTab:not(:last-child) {
|
||||
border-right: 1px solid var(--border-color, #e5dfd5);
|
||||
border-right: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.phaseTab:hover {
|
||||
background: var(--bg-secondary, #f3ede4);
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.phaseTabActive {
|
||||
background: var(--accent-coral-dark, #b04a2e);
|
||||
color: white;
|
||||
background: var(--brand-strong);
|
||||
color: var(--brand-on);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.phaseTabActive:hover {
|
||||
background: var(--accent-coral-darker, #9c3f26);
|
||||
background: var(--brand-stronger);
|
||||
}
|
||||
|
||||
/* Filters */
|
||||
.filters {
|
||||
background: var(--bg-card, white);
|
||||
border: 1px solid var(--border-color, #e5dfd5);
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 1.5rem;
|
||||
margin-bottom: 2rem;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 1.5rem;
|
||||
box-shadow: var(--shadow-soft, 0 2px 8px rgba(26, 22, 18, 0.06));
|
||||
box-shadow: var(--shadow-soft, 0 2px 8px rgba(var(--shadow-rgb), 0.06));
|
||||
}
|
||||
|
||||
.filterGroup {
|
||||
@@ -88,7 +88,7 @@
|
||||
.filterLabel {
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary, #1a1612);
|
||||
color: var(--text-primary);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@@ -98,44 +98,44 @@
|
||||
max-width: 100%;
|
||||
padding: 0.625rem 1rem;
|
||||
font-size: 0.9375rem;
|
||||
border: 1px solid var(--border-color, #e5dfd5);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
background: var(--bg-card, white);
|
||||
color: var(--text-primary, #1a1612);
|
||||
background: var(--bg-card);
|
||||
color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.filterSelect:hover {
|
||||
border-color: var(--accent-coral, #e07256);
|
||||
border-color: var(--brand);
|
||||
}
|
||||
|
||||
.filterSelect:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent-coral, #e07256);
|
||||
box-shadow: 0 0 0 3px var(--accent-coral-bg);
|
||||
border-color: var(--brand);
|
||||
box-shadow: 0 0 0 3px var(--brand-bg);
|
||||
}
|
||||
|
||||
.filterSelect optgroup {
|
||||
font-weight: 700;
|
||||
color: var(--text-primary, #1a1612);
|
||||
background: var(--bg-secondary, #f3ede4);
|
||||
color: var(--text-primary);
|
||||
background: var(--bg-secondary);
|
||||
padding: 0.5rem 0;
|
||||
}
|
||||
|
||||
.filterSelect option {
|
||||
font-weight: 400;
|
||||
color: var(--text-secondary, #5c564d);
|
||||
color: var(--text-secondary);
|
||||
padding: 0.375rem 1rem;
|
||||
}
|
||||
|
||||
/* Rankings Section */
|
||||
.rankingsSection {
|
||||
background: var(--bg-card, white);
|
||||
border: 1px solid var(--border-color, #e5dfd5);
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 2rem;
|
||||
box-shadow: var(--shadow-soft, 0 2px 8px rgba(26, 22, 18, 0.06));
|
||||
box-shadow: var(--shadow-soft, 0 2px 8px rgba(var(--shadow-rgb), 0.06));
|
||||
}
|
||||
|
||||
.tableWrapper {
|
||||
@@ -151,15 +151,15 @@
|
||||
}
|
||||
|
||||
.rankingsTable thead {
|
||||
background: var(--bg-secondary, #f3ede4);
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.rankingsTable th {
|
||||
padding: 1rem;
|
||||
text-align: left;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary, #1a1612);
|
||||
border-bottom: 2px solid var(--border-color, #e5dfd5);
|
||||
color: var(--text-primary);
|
||||
border-bottom: 2px solid var(--border);
|
||||
white-space: nowrap;
|
||||
text-transform: uppercase;
|
||||
font-size: 0.75rem;
|
||||
@@ -206,8 +206,8 @@
|
||||
|
||||
.rankingsTable td {
|
||||
padding: 1rem;
|
||||
border-bottom: 1px solid var(--border-color, #e5dfd5);
|
||||
color: var(--text-secondary, #5c564d);
|
||||
border-bottom: 1px solid var(--border);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.rankingsTable tbody tr:last-child td {
|
||||
@@ -216,24 +216,24 @@
|
||||
|
||||
/* Alternating row backgrounds for visual rhythm */
|
||||
.rankingsTable tbody tr:nth-child(even) {
|
||||
background: rgba(243, 237, 228, 0.5);
|
||||
background: rgba(var(--text-inverse-rgb), 0.5);
|
||||
}
|
||||
|
||||
.rankingsTable tbody tr:hover {
|
||||
background: var(--bg-secondary, #f3ede4);
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
/* Top 3 Highlighting with Gold */
|
||||
.rank1 {
|
||||
background: linear-gradient(90deg, rgba(201, 162, 39, 0.15) 0%, transparent 100%) !important;
|
||||
background: linear-gradient(90deg, rgba(var(--status-below-rgb), 0.15) 0%, transparent 100%) !important;
|
||||
}
|
||||
|
||||
.rank2 {
|
||||
background: linear-gradient(90deg, rgba(192, 192, 192, 0.15) 0%, transparent 100%) !important;
|
||||
background: linear-gradient(90deg, rgba(var(--medal-silver-rgb), 0.15) 0%, transparent 100%) !important;
|
||||
}
|
||||
|
||||
.rank3 {
|
||||
background: linear-gradient(90deg, rgba(205, 127, 50, 0.15) 0%, transparent 100%) !important;
|
||||
background: linear-gradient(90deg, rgba(var(--medal-bronze-rgb), 0.15) 0%, transparent 100%) !important;
|
||||
}
|
||||
|
||||
.rankCell {
|
||||
@@ -241,7 +241,7 @@
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary, #1a1612);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* Styled rank badges for top 3 */
|
||||
@@ -254,8 +254,8 @@
|
||||
border-radius: 50%;
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
color: white;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
||||
color: var(--brand-on);
|
||||
box-shadow: 0 2px 8px rgba(var(--shadow-rgb), 0.15);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
@@ -265,30 +265,31 @@
|
||||
inset: -2px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid transparent;
|
||||
background: linear-gradient(135deg, rgba(255, 255, 255, 0.4), transparent) border-box;
|
||||
background: linear-gradient(135deg, rgba(var(--text-inverse-rgb), 0.4), transparent) border-box;
|
||||
mask: linear-gradient(#fff 0 0) padding-box, linear-gradient(#fff 0 0);
|
||||
mask-composite: exclude;
|
||||
-webkit-mask-composite: xor;
|
||||
}
|
||||
|
||||
.rankBadge1 {
|
||||
background: linear-gradient(135deg, #c9a227 0%, #e8c547 50%, #c9a227 100%);
|
||||
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.2);
|
||||
background: linear-gradient(135deg, var(--status-below) 0%, var(--medal-gold) 50%, var(--status-below) 100%);
|
||||
text-shadow: 0 1px 2px rgba(var(--shadow-rgb), 0.2);
|
||||
}
|
||||
|
||||
.rankBadge2 {
|
||||
background: linear-gradient(135deg, #8c8c8c 0%, #c0c0c0 50%, #8c8c8c 100%);
|
||||
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.2);
|
||||
background: linear-gradient(135deg, var(--text-muted) 0%, var(--medal-silver) 50%, var(--text-muted) 100%);
|
||||
text-shadow: 0 1px 2px rgba(var(--shadow-rgb), 0.2);
|
||||
}
|
||||
|
||||
.rankBadge3 {
|
||||
background: linear-gradient(135deg, #a5673f 0%, #cd7f32 50%, #a5673f 100%);
|
||||
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.2);
|
||||
background: linear-gradient(135deg, var(--status-below) 0%, var(--medal-bronze) 50%, var(--status-below) 100%);
|
||||
text-shadow: 0 1px 2px rgba(var(--shadow-rgb), 0.2);
|
||||
}
|
||||
|
||||
.rankNumber {
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-size: 1rem;
|
||||
color: var(--text-secondary, #5c564d);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.schoolCell {
|
||||
@@ -296,18 +297,18 @@
|
||||
}
|
||||
|
||||
.schoolLink {
|
||||
color: var(--text-primary, #1a1612);
|
||||
color: var(--text-primary);
|
||||
text-decoration: none;
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
|
||||
.schoolLink:hover {
|
||||
color: var(--accent-coral-dark, #b04a2e);
|
||||
color: var(--brand-strong);
|
||||
}
|
||||
|
||||
.areaCell,
|
||||
.typeCell {
|
||||
color: var(--text-secondary, #5c564d);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* LA subline under the school name — mobile only (Area column hidden there). */
|
||||
@@ -315,7 +316,7 @@
|
||||
display: none;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 400;
|
||||
color: var(--text-muted, #8a847a);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.valueCell {
|
||||
@@ -324,7 +325,7 @@
|
||||
}
|
||||
|
||||
.valueCell strong {
|
||||
color: var(--accent-teal, #2d7d7d);
|
||||
color: var(--status-above);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
@@ -346,7 +347,7 @@
|
||||
.noResults {
|
||||
text-align: center;
|
||||
padding: 4rem 2rem;
|
||||
color: var(--text-secondary, #5c564d);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.noResults p {
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
color: var(--text-muted, #6d685f);
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
@@ -41,7 +41,7 @@
|
||||
top: 0;
|
||||
height: 100%;
|
||||
width: 1px;
|
||||
background: var(--bg-secondary, #f3ede4);
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
/* ── Per-bar national average marker ──
|
||||
@@ -54,7 +54,7 @@
|
||||
bottom: -3px;
|
||||
width: 2px;
|
||||
transform: translateX(-50%);
|
||||
background: var(--accent-coral, #e07256);
|
||||
background: var(--brand);
|
||||
border-radius: 2px;
|
||||
z-index: 4;
|
||||
pointer-events: none;
|
||||
@@ -69,7 +69,7 @@
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent-coral, #e07256);
|
||||
background: var(--brand);
|
||||
}
|
||||
|
||||
.barHeaderRight {
|
||||
@@ -81,7 +81,7 @@
|
||||
.natLabel {
|
||||
font-size: 0.55rem;
|
||||
font-weight: 700;
|
||||
color: var(--accent-coral-dark, #b04a2e);
|
||||
color: var(--brand-strong);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
@@ -123,18 +123,18 @@
|
||||
}
|
||||
|
||||
.barExpected {
|
||||
background: var(--accent-teal-light, #3a9e9e);
|
||||
background: var(--brand);
|
||||
}
|
||||
|
||||
.barExceeding {
|
||||
background: var(--accent-teal, #2d7d7d);
|
||||
background: var(--brand);
|
||||
}
|
||||
|
||||
.barLabel {
|
||||
font-family: var(--font-playfair), 'Playfair Display', Georgia, serif;
|
||||
font-family: var(--font-display);
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary, #1a1612);
|
||||
color: var(--text-primary);
|
||||
white-space: nowrap;
|
||||
position: relative;
|
||||
z-index: 3;
|
||||
@@ -142,7 +142,7 @@
|
||||
|
||||
.barLabelSuffix {
|
||||
font-weight: 500;
|
||||
color: var(--text-muted, #6d685f);
|
||||
color: var(--text-muted);
|
||||
font-size: 0.6rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
@@ -153,7 +153,7 @@
|
||||
position: relative;
|
||||
height: 14px;
|
||||
margin-top: 0.3rem;
|
||||
border-top: 1px solid var(--border-color, #e5dfd5);
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.rulerTick {
|
||||
@@ -161,7 +161,7 @@
|
||||
top: 0;
|
||||
width: 1px;
|
||||
height: 3px;
|
||||
background: var(--border-color, #e5dfd5);
|
||||
background: var(--border);
|
||||
}
|
||||
|
||||
.rulerLabel {
|
||||
@@ -169,7 +169,7 @@
|
||||
top: 5px;
|
||||
font-size: 0.5rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-muted, #6d685f);
|
||||
color: var(--text-muted);
|
||||
transform: translateX(-50%);
|
||||
letter-spacing: 0.01em;
|
||||
opacity: 0.7;
|
||||
@@ -198,7 +198,7 @@
|
||||
gap: 0.35rem;
|
||||
font-size: 0.65rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-muted, #6d685f);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.legendSwatch {
|
||||
|
||||
@@ -145,15 +145,15 @@ export default function SatsChart({ subjects }: SatsChartProps) {
|
||||
</div>
|
||||
<div className={styles.legend}>
|
||||
<div className={styles.legendItem}>
|
||||
<div className={styles.legendSwatch} style={{ background: 'var(--accent-teal-light, #3a9e9e)' }} />
|
||||
<div className={styles.legendSwatch} style={{ background: 'var(--status-above)' }} />
|
||||
Expected standard
|
||||
</div>
|
||||
<div className={styles.legendItem}>
|
||||
<div className={styles.legendSwatch} style={{ background: 'var(--accent-teal, #2d7d7d)' }} />
|
||||
<div className={styles.legendSwatch} style={{ background: 'var(--status-above)' }} />
|
||||
Exceeding / high score
|
||||
</div>
|
||||
<div className={styles.legendItem}>
|
||||
<div className={styles.legendSwatch} style={{ background: 'var(--accent-coral, #e07256)', width: '3px', height: '12px', borderRadius: '2px' }} />
|
||||
<div className={styles.legendSwatch} style={{ background: 'var(--brand)', width: '3px', height: '12px', borderRadius: '2px' }} />
|
||||
National average
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
.card {
|
||||
background: var(--bg-card, white);
|
||||
border: 1px solid var(--border-color, #e5dfd5);
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-left: 3px solid transparent;
|
||||
border-radius: 10px;
|
||||
padding: 1rem 1.125rem;
|
||||
@@ -8,13 +8,13 @@
|
||||
}
|
||||
|
||||
.card.cardInCompare {
|
||||
border-color: var(--accent-teal, #2d7d7d);
|
||||
box-shadow: 0 0 0 1px var(--accent-teal, #2d7d7d);
|
||||
border-color: var(--brand);
|
||||
box-shadow: 0 0 0 1px var(--brand);
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
border-left-color: var(--accent-coral, #e07256);
|
||||
box-shadow: var(--shadow-medium, 0 4px 20px rgba(26, 22, 18, 0.1));
|
||||
border-left-color: var(--brand);
|
||||
box-shadow: var(--shadow-medium, 0 4px 20px rgba(var(--shadow-rgb), 0.1));
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
@@ -31,24 +31,24 @@
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.35;
|
||||
font-family: var(--font-playfair), 'Playfair Display', serif;
|
||||
font-family: var(--font-display);
|
||||
}
|
||||
|
||||
.title a {
|
||||
color: var(--text-primary, #1a1612);
|
||||
color: var(--text-primary);
|
||||
text-decoration: none;
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
|
||||
.title a:hover {
|
||||
color: var(--accent-coral, #e07256);
|
||||
color: var(--brand);
|
||||
}
|
||||
|
||||
.distance {
|
||||
font-size: 0.75rem;
|
||||
color: var(--accent-teal, #2d7d7d);
|
||||
color: var(--brand);
|
||||
white-space: nowrap;
|
||||
background: var(--accent-teal-bg);
|
||||
background: var(--brand-bg);
|
||||
padding: 0.125rem 0.5rem;
|
||||
border-radius: 12px;
|
||||
font-weight: 500;
|
||||
@@ -63,9 +63,9 @@
|
||||
|
||||
.metaItem {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary, #5c564d);
|
||||
color: var(--text-secondary);
|
||||
padding: 0.125rem 0.5rem;
|
||||
background: var(--bg-secondary, #f3ede4);
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
@@ -75,9 +75,9 @@
|
||||
gap: 0.625rem;
|
||||
margin-bottom: 0.875rem;
|
||||
padding: 0.625rem 0.75rem;
|
||||
background: var(--bg-secondary, #f3ede4);
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--border-color, #e5dfd5);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.metric {
|
||||
@@ -88,12 +88,13 @@
|
||||
|
||||
.metricLabel {
|
||||
font-size: 0.6875rem;
|
||||
color: var(--text-muted, #8a847a);
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.metricValue {
|
||||
font-variant-numeric: tabular-nums;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
@@ -101,7 +102,7 @@
|
||||
|
||||
.metricValue strong {
|
||||
font-size: 1rem;
|
||||
color: var(--text-primary, #1a1612);
|
||||
color: var(--text-primary);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
@@ -126,18 +127,18 @@
|
||||
}
|
||||
|
||||
.trendUp {
|
||||
color: var(--accent-teal, #2d7d7d);
|
||||
background: var(--accent-teal-bg);
|
||||
color: var(--status-above);
|
||||
background: var(--status-above-bg);
|
||||
}
|
||||
|
||||
.trendDown {
|
||||
color: var(--accent-coral, #e07256);
|
||||
background: var(--accent-coral-bg);
|
||||
color: var(--status-below);
|
||||
background: var(--status-below-bg);
|
||||
}
|
||||
|
||||
.trendStable {
|
||||
color: var(--text-muted, #8a847a);
|
||||
background: rgba(138, 132, 122, 0.15);
|
||||
color: var(--text-muted);
|
||||
background: rgba(var(--muted-rgb), 0.15);
|
||||
}
|
||||
|
||||
.actions {
|
||||
@@ -156,7 +157,7 @@
|
||||
|
||||
.metricHint {
|
||||
font-size: 0.7rem;
|
||||
color: var(--text-muted, #8a847a);
|
||||
color: var(--text-muted);
|
||||
display: block;
|
||||
margin-top: 1px;
|
||||
font-weight: 400;
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 210px;
|
||||
background: #dfe6e2;
|
||||
background: var(--bg-secondary);
|
||||
isolation: isolate;
|
||||
}
|
||||
|
||||
@@ -47,7 +47,7 @@
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background:
|
||||
linear-gradient(100deg, rgba(255, 255, 255, 0) 40%, rgba(255, 255, 255, .5) 50%, rgba(255, 255, 255, 0) 60%) #e7e2da;
|
||||
linear-gradient(100deg, rgba(255, 255, 255, 0) 40%, rgba(255, 255, 255, .5) 50%, rgba(255, 255, 255, 0) 60%) var(--bg-secondary);
|
||||
background-size: 200% 100%;
|
||||
animation: shimmer 1.4s infinite;
|
||||
}
|
||||
@@ -85,11 +85,11 @@
|
||||
border-radius: 999px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary, #1a1612);
|
||||
color: var(--text-primary);
|
||||
background: rgba(255, 255, 255, .85);
|
||||
-webkit-backdrop-filter: blur(6px);
|
||||
backdrop-filter: blur(6px);
|
||||
box-shadow: 0 2px 10px rgba(0, 0, 0, .16);
|
||||
box-shadow: 0 2px 10px rgba(var(--shadow-rgb), .16);
|
||||
opacity: 0;
|
||||
transform: translateY(4px);
|
||||
transition: opacity .16s ease, transform .16s ease;
|
||||
@@ -118,7 +118,7 @@
|
||||
rgba(255, 255, 255, .35) 35%,
|
||||
rgba(255, 255, 255, .75) 62%,
|
||||
rgba(255, 255, 255, .95) 82%,
|
||||
var(--bg-card, #fff) 100%);
|
||||
var(--bg-card) 100%);
|
||||
}
|
||||
|
||||
.closeBtn {
|
||||
@@ -134,9 +134,9 @@
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
background: rgba(255, 255, 255, .92);
|
||||
color: var(--text-primary, #1a1612);
|
||||
color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
box-shadow: 0 2px 10px rgba(0, 0, 0, .2);
|
||||
box-shadow: 0 2px 10px rgba(var(--shadow-rgb), .2);
|
||||
}
|
||||
|
||||
.closeBtn:hover {
|
||||
|
||||
@@ -33,12 +33,12 @@
|
||||
border: 2px solid rgba(0, 0, 0, 0.2);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
color: #333;
|
||||
color: var(--text-primary);
|
||||
transition: background 0.15s ease, color 0.15s ease;
|
||||
}
|
||||
|
||||
.fullscreenBtn:hover {
|
||||
background: #f4f4f4;
|
||||
background: var(--bg-secondary);
|
||||
color: #000;
|
||||
}
|
||||
|
||||
@@ -57,9 +57,9 @@
|
||||
.spinner {
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border: 3px solid rgba(224, 114, 86, 0.3);
|
||||
border: 3px solid rgba(var(--brand-rgb), 0.3);
|
||||
border-radius: 50%;
|
||||
border-top-color: var(--accent-coral, #e07256);
|
||||
border-top-color: var(--brand);
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
background: var(--bg-card, white);
|
||||
border: 1px solid var(--border-color, #e5dfd5);
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-left: 3px solid transparent;
|
||||
border-radius: 8px;
|
||||
padding: 1rem 1.25rem;
|
||||
@@ -12,17 +12,17 @@
|
||||
}
|
||||
|
||||
.row:hover {
|
||||
box-shadow: 0 2px 8px rgba(26, 22, 18, 0.06);
|
||||
box-shadow: 0 2px 8px rgba(var(--shadow-rgb), 0.06);
|
||||
}
|
||||
|
||||
/* Phase border colours */
|
||||
.phasePrimary { border-left-color: var(--phase-primary, #5b8cbf); }
|
||||
.phaseAllThrough { border-left-color: var(--phase-all-through, #7a9a6d); }
|
||||
.phaseNursery { border-left-color: var(--phase-nursery, #e0a0b0); }
|
||||
.phasePrimary { border-left-color: var(--phase-primary); }
|
||||
.phaseAllThrough { border-left-color: var(--phase-all-through); }
|
||||
.phaseNursery { border-left-color: var(--phase-nursery); }
|
||||
|
||||
.rowInCompare {
|
||||
border-left-color: var(--accent-teal, #2d7d7d);
|
||||
background: var(--bg-secondary, #f3ede4);
|
||||
border-left-color: var(--brand);
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
@keyframes rowFadeIn {
|
||||
@@ -50,7 +50,7 @@
|
||||
.schoolName {
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary, #1a1612);
|
||||
color: var(--text-primary);
|
||||
text-decoration: none;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
@@ -60,7 +60,7 @@
|
||||
}
|
||||
|
||||
.schoolName:hover {
|
||||
color: var(--accent-coral, #e07256);
|
||||
color: var(--brand);
|
||||
}
|
||||
|
||||
/* Phase label pill */
|
||||
@@ -96,8 +96,8 @@
|
||||
line-height: 1.4;
|
||||
border-radius: 4px;
|
||||
white-space: nowrap;
|
||||
background: rgba(26, 22, 18, 0.05);
|
||||
color: var(--text-secondary, #5c564d);
|
||||
background: rgba(var(--shadow-rgb), 0.05);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* Line 3: stats */
|
||||
@@ -115,10 +115,11 @@
|
||||
}
|
||||
|
||||
.statValue {
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary, #1a1612);
|
||||
font-family: var(--font-playfair), 'Playfair Display', serif;
|
||||
color: var(--text-primary);
|
||||
font-family: var(--font-display);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.2rem;
|
||||
@@ -126,7 +127,7 @@
|
||||
|
||||
.statLabel {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted, #8a847a);
|
||||
color: var(--text-muted);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@@ -137,9 +138,9 @@
|
||||
margin-left: 1px;
|
||||
}
|
||||
|
||||
.trendUp { color: var(--accent-teal, #2d7d7d); }
|
||||
.trendDown { color: var(--accent-coral, #e07256); }
|
||||
.trendStable { color: var(--text-muted, #8a847a); }
|
||||
.trendUp { color: var(--status-above); }
|
||||
.trendDown { color: var(--status-below); }
|
||||
.trendStable { color: var(--text-muted); }
|
||||
|
||||
/* Line 4: location */
|
||||
.line4 {
|
||||
@@ -148,13 +149,13 @@
|
||||
flex-wrap: wrap;
|
||||
gap: 0;
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-muted, #8a847a);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.line4 span:not(:last-child)::after {
|
||||
content: '·';
|
||||
margin: 0 0.4rem;
|
||||
color: var(--border-color, #e5dfd5);
|
||||
color: var(--border);
|
||||
}
|
||||
|
||||
.distanceBadge {
|
||||
@@ -162,8 +163,8 @@
|
||||
padding: 0.0625rem 0.375rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
background: var(--accent-teal, #2d7d7d);
|
||||
color: white;
|
||||
background: var(--brand);
|
||||
color: var(--brand-on);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
@@ -200,23 +201,23 @@
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.ofsted1 { background: var(--accent-teal-bg); color: var(--accent-teal, #2d7d7d); }
|
||||
.ofsted2 { background: rgba(60, 140, 60, 0.12); color: #2f7a2f; }
|
||||
.ofsted3 { background: var(--accent-gold-bg); color: var(--accent-gold-text, #7a6800); }
|
||||
.ofsted4 { background: var(--accent-coral-bg); color: var(--accent-coral, #e07256); }
|
||||
.ofsted1 { background: var(--status-above-bg); color: var(--status-above); }
|
||||
.ofsted2 { background: rgba(var(--status-above-rgb), 0.12); color: var(--status-above); }
|
||||
.ofsted3 { background: var(--status-below-bg); color: var(--status-below); }
|
||||
.ofsted4 { background: var(--status-below); color: var(--text-inverse); }
|
||||
|
||||
/* ── Ofsted badge variants ──────────────────────────────────────────────── */
|
||||
/* ofsted1–4 already defined above; these cover the two new framework states */
|
||||
.ofstedRc { background: #5a3a6e; color: #fff; }
|
||||
.ofstedRc { background: var(--phase-secondary-text); color: var(--text-inverse); }
|
||||
/* Inspected but with no overall grade (OEIF post-Sept-2024) — neutral slate,
|
||||
distinct from the grey "Not yet inspected" pending state. */
|
||||
.ofstedInspected { background: #e6ecf2; color: #45586b; }
|
||||
.ofstedPending { background: #e0e0e0; color: #666; }
|
||||
.ofstedInspected { background: var(--phase-primary-bg); color: var(--phase-primary-text); }
|
||||
.ofstedPending { background: var(--border); color: var(--text-muted); }
|
||||
|
||||
/* ── vs-national delta line (under RWM metric) ──────────────────────────── */
|
||||
.vsNational { font-size: 0.7rem; color: var(--accent-teal, #2d7d7d); font-weight: 600; }
|
||||
.vsNationalNeg { font-size: 0.7rem; color: var(--accent-coral, #e07256); font-weight: 600; }
|
||||
.vsNationalFlat { font-size: 0.7rem; color: var(--text-muted, #8a847a); }
|
||||
.vsNational { font-size: 0.7rem; color: var(--status-above); font-weight: 600; }
|
||||
.vsNationalNeg { font-size: 0.7rem; color: var(--brand); font-weight: 600; }
|
||||
.vsNationalFlat { font-size: 0.7rem; color: var(--text-muted); }
|
||||
|
||||
/* ── Mobile ──────────────────────────────────────────── */
|
||||
@media (max-width: 640px) {
|
||||
@@ -257,7 +258,7 @@
|
||||
|
||||
/* GIAS "Open, but proposed to close" marker */
|
||||
.attrClosing {
|
||||
background: #fdf6e3;
|
||||
color: #8a6200;
|
||||
border: 1px solid #e2c96f;
|
||||
background: var(--status-below-bg);
|
||||
color: var(--status-below);
|
||||
border: 1px solid var(--status-below-bg);
|
||||
}
|
||||
|
||||
@@ -3,9 +3,9 @@
|
||||
}
|
||||
|
||||
.warning {
|
||||
background: var(--accent-gold-bg);
|
||||
border: 1px solid var(--accent-gold, #c9a227);
|
||||
color: var(--text-primary, #1a1612);
|
||||
background: var(--status-below-bg);
|
||||
border: 1px solid var(--status-below);
|
||||
color: var(--text-primary);
|
||||
padding: 1rem;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 1rem;
|
||||
@@ -21,21 +21,21 @@
|
||||
width: 100%;
|
||||
padding: 0.875rem 1rem;
|
||||
font-size: 1rem;
|
||||
border: 2px solid var(--border-color, #e5dfd5);
|
||||
border: 2px solid var(--border);
|
||||
border-radius: 8px;
|
||||
background: var(--bg-card, white);
|
||||
color: var(--text-primary, #1a1612);
|
||||
background: var(--bg-card);
|
||||
color: var(--text-primary);
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.searchInput::placeholder {
|
||||
color: var(--text-muted, #8a847a);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.searchInput:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent-coral, #e07256);
|
||||
box-shadow: 0 0 0 3px var(--accent-coral-bg);
|
||||
border-color: var(--brand);
|
||||
box-shadow: 0 0 0 3px var(--brand-bg);
|
||||
}
|
||||
|
||||
.searchSpinner {
|
||||
@@ -45,9 +45,9 @@
|
||||
transform: translateY(-50%);
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
border: 2px solid rgba(224, 114, 86, 0.3);
|
||||
border: 2px solid rgba(var(--brand-rgb), 0.3);
|
||||
border-radius: 50%;
|
||||
border-top-color: var(--accent-coral, #e07256);
|
||||
border-top-color: var(--brand);
|
||||
animation: spin 0.6s linear infinite;
|
||||
}
|
||||
|
||||
@@ -71,17 +71,17 @@
|
||||
}
|
||||
|
||||
.results::-webkit-scrollbar-track {
|
||||
background: var(--bg-secondary, #f3ede4);
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.results::-webkit-scrollbar-thumb {
|
||||
background: var(--border-color, #e5dfd5);
|
||||
background: var(--border);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.results::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--text-muted, #8a847a);
|
||||
background: var(--text-muted);
|
||||
}
|
||||
|
||||
.resultItem {
|
||||
@@ -90,16 +90,16 @@
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 1rem;
|
||||
background: var(--bg-secondary, #f3ede4);
|
||||
border: 1px solid var(--border-color, #e5dfd5);
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.resultItem:hover {
|
||||
background: var(--bg-card, white);
|
||||
border-color: var(--accent-coral, #e07256);
|
||||
box-shadow: var(--shadow-soft, 0 2px 8px rgba(26, 22, 18, 0.06));
|
||||
background: var(--bg-card);
|
||||
border-color: var(--brand);
|
||||
box-shadow: var(--shadow-soft, 0 2px 8px rgba(var(--shadow-rgb), 0.06));
|
||||
}
|
||||
|
||||
.schoolInfo {
|
||||
@@ -110,7 +110,7 @@
|
||||
.schoolName {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary, #1a1612);
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 0.25rem;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
@@ -125,7 +125,7 @@
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-secondary, #5c564d);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.schoolMeta span {
|
||||
@@ -138,14 +138,14 @@
|
||||
.noResults {
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
color: var(--text-secondary, #5c564d);
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.9375rem;
|
||||
}
|
||||
|
||||
.hint {
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
color: var(--text-muted, #8a847a);
|
||||
color: var(--text-muted);
|
||||
font-size: 0.9375rem;
|
||||
}
|
||||
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
background: var(--bg-card, white);
|
||||
border: 1px solid var(--border-color, #e5dfd5);
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-left: 3px solid transparent;
|
||||
border-radius: 8px;
|
||||
padding: 1rem 1.25rem;
|
||||
@@ -12,17 +12,17 @@
|
||||
}
|
||||
|
||||
.row:hover {
|
||||
box-shadow: 0 2px 8px rgba(26, 22, 18, 0.06);
|
||||
box-shadow: 0 2px 8px rgba(var(--shadow-rgb), 0.06);
|
||||
}
|
||||
|
||||
/* Phase border colours */
|
||||
.phaseSecondary { border-left-color: var(--phase-secondary, #9b6bb0); }
|
||||
.phaseAllThrough { border-left-color: var(--phase-all-through, #7a9a6d); }
|
||||
.phasePost16 { border-left-color: var(--phase-post16, #c4915e); }
|
||||
.phaseSecondary { border-left-color: var(--phase-secondary); }
|
||||
.phaseAllThrough { border-left-color: var(--phase-all-through); }
|
||||
.phasePost16 { border-left-color: var(--phase-post16); }
|
||||
|
||||
.rowInCompare {
|
||||
border-left-color: var(--accent-teal, #2d7d7d);
|
||||
background: var(--bg-secondary, #f3ede4);
|
||||
border-left-color: var(--brand);
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
@keyframes rowFadeIn {
|
||||
@@ -50,7 +50,7 @@
|
||||
.schoolName {
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary, #1a1612);
|
||||
color: var(--text-primary);
|
||||
text-decoration: none;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
@@ -60,7 +60,7 @@
|
||||
}
|
||||
|
||||
.schoolName:hover {
|
||||
color: var(--accent-coral, #e07256);
|
||||
color: var(--brand);
|
||||
}
|
||||
|
||||
/* Line 2: context tags */
|
||||
@@ -81,8 +81,8 @@
|
||||
line-height: 1.4;
|
||||
border-radius: 4px;
|
||||
white-space: nowrap;
|
||||
background: rgba(26, 22, 18, 0.05);
|
||||
color: var(--text-secondary, #5c564d);
|
||||
background: rgba(var(--shadow-rgb), 0.05);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* Line 3: KS4 stats */
|
||||
@@ -100,22 +100,24 @@
|
||||
}
|
||||
|
||||
.statValueLarge {
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-size: 1.125rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary, #1a1612);
|
||||
font-family: var(--font-playfair), 'Playfair Display', serif;
|
||||
color: var(--text-primary);
|
||||
font-family: var(--font-display);
|
||||
}
|
||||
|
||||
.statValue {
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary, #1a1612);
|
||||
font-family: var(--font-playfair), 'Playfair Display', serif;
|
||||
color: var(--text-primary);
|
||||
font-family: var(--font-display);
|
||||
}
|
||||
|
||||
.statLabel {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted, #8a847a);
|
||||
color: var(--text-muted);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@@ -125,8 +127,8 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.deltaPositive { color: #2f7a2f; }
|
||||
.deltaNegative { color: var(--accent-coral, #e07256); }
|
||||
.deltaPositive { color: var(--status-above); }
|
||||
.deltaNegative { color: var(--status-below); }
|
||||
|
||||
/* Line 4: location + distance */
|
||||
.line4 {
|
||||
@@ -135,13 +137,13 @@
|
||||
flex-wrap: wrap;
|
||||
gap: 0;
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-muted, #8a847a);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.line4 span:not(:last-child)::after {
|
||||
content: '·';
|
||||
margin: 0 0.4rem;
|
||||
color: var(--border-color, #e5dfd5);
|
||||
color: var(--border);
|
||||
}
|
||||
|
||||
.distanceBadge {
|
||||
@@ -149,8 +151,8 @@
|
||||
padding: 0.0625rem 0.375rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
background: var(--accent-teal, #2d7d7d);
|
||||
color: white;
|
||||
background: var(--brand);
|
||||
color: var(--brand-on);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
@@ -175,15 +177,15 @@
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.4;
|
||||
background: rgba(26, 22, 18, 0.05);
|
||||
color: var(--text-secondary, #5c564d);
|
||||
background: rgba(var(--shadow-rgb), 0.05);
|
||||
color: var(--text-secondary);
|
||||
border-radius: 4px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.selectiveTag {
|
||||
background: rgba(180, 120, 0, 0.1);
|
||||
color: #8a6200;
|
||||
background: rgba(var(--status-below-rgb), 0.1);
|
||||
color: var(--status-below);
|
||||
}
|
||||
|
||||
/* ── Ofsted badge ────────────────────────────────────── */
|
||||
@@ -202,17 +204,17 @@
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.ofsted1 { background: var(--accent-teal-bg); color: var(--accent-teal, #2d7d7d); }
|
||||
.ofsted2 { background: rgba(60, 140, 60, 0.12); color: #2f7a2f; }
|
||||
.ofsted3 { background: var(--accent-gold-bg); color: var(--accent-gold-text, #7a6800); }
|
||||
.ofsted4 { background: var(--accent-coral-bg); color: var(--accent-coral, #e07256); }
|
||||
.ofsted1 { background: var(--status-above-bg); color: var(--status-above); }
|
||||
.ofsted2 { background: rgba(var(--status-above-rgb), 0.12); color: var(--status-above); }
|
||||
.ofsted3 { background: var(--status-below-bg); color: var(--status-below); }
|
||||
.ofsted4 { background: var(--status-below); color: var(--text-inverse); }
|
||||
|
||||
/* ── Ofsted badge variants ──────────────────────────────────────────────── */
|
||||
.ofstedRc { background: #5a3a6e; color: #fff; }
|
||||
.ofstedRc { background: var(--phase-secondary-text); color: var(--text-inverse); }
|
||||
/* Inspected but with no overall grade (OEIF post-Sept-2024) — neutral slate,
|
||||
distinct from the grey "Not yet inspected" pending state. */
|
||||
.ofstedInspected { background: #e6ecf2; color: #45586b; }
|
||||
.ofstedPending { background: #e0e0e0; color: #666; }
|
||||
.ofstedInspected { background: var(--phase-primary-bg); color: var(--phase-primary-text); }
|
||||
.ofstedPending { background: var(--border); color: var(--text-muted); }
|
||||
|
||||
/* ── Right actions column ────────────────────────────── */
|
||||
.rowActions {
|
||||
@@ -268,7 +270,7 @@
|
||||
}
|
||||
|
||||
.closingTag {
|
||||
background: #fdf6e3;
|
||||
color: #8a6200;
|
||||
border: 1px solid #e2c96f;
|
||||
background: var(--status-below-bg);
|
||||
color: var(--status-below);
|
||||
border: 1px solid var(--status-below-bg);
|
||||
}
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
/* Special-school / PRU / AP context note: soft, informational (teal), not a
|
||||
warning. Shared by both detail views so the styling can't drift. */
|
||||
.note {
|
||||
background: var(--bg-secondary, #f3ede4);
|
||||
border-left: 4px solid var(--accent-teal, #2d7d7d);
|
||||
background: var(--bg-secondary);
|
||||
border-left: 4px solid var(--brand);
|
||||
border-radius: 0 6px 6px 0;
|
||||
padding: 0.7rem 1rem;
|
||||
margin: 0 0 1rem;
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.5;
|
||||
color: var(--text-secondary, #5c564d);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.note strong {
|
||||
color: var(--text-primary, #1a1612);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
.moreMeasures {
|
||||
margin-top: 0.5rem;
|
||||
border-top: 1px solid var(--border-light);
|
||||
border-top: 1px solid var(--border);
|
||||
padding-top: 0.75rem;
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
font-size: 0.88rem;
|
||||
color: var(--accent-coral-dark);
|
||||
color: var(--brand-strong);
|
||||
}
|
||||
|
||||
.stripNote {
|
||||
|
||||
@@ -88,7 +88,7 @@ export function CompareAtAGlance({
|
||||
)}
|
||||
{display.kind === 'transitional' && (
|
||||
<>
|
||||
<span className={s.badge} style={{ backgroundColor: '#e2e8f0', color: '#475569' }}>
|
||||
<span className={s.badge} style={{ backgroundColor: 'var(--bg-secondary)', color: 'var(--text-secondary)' }}>
|
||||
No overall grade
|
||||
</span>
|
||||
<span className={s.small}>Sub-judgements only</span>
|
||||
|
||||
@@ -54,7 +54,7 @@ function ResultCell({ display }: { display: OfstedDisplay }) {
|
||||
if (display.kind === 'transitional') {
|
||||
return (
|
||||
<>
|
||||
<span className={s.badge} style={{ backgroundColor: '#e2e8f0', color: '#475569' }}>
|
||||
<span className={s.badge} style={{ backgroundColor: 'var(--bg-secondary)', color: 'var(--text-secondary)' }}>
|
||||
No overall grade
|
||||
</span>
|
||||
<span className={s.small}>
|
||||
|
||||
@@ -5,10 +5,10 @@
|
||||
.explore summary {
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
color: var(--accent-coral-dark);
|
||||
color: var(--brand-strong);
|
||||
padding: 0.85rem 1.1rem;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-light);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
}
|
||||
|
||||
.inner {
|
||||
border: 1px solid var(--border-light);
|
||||
border: 1px solid var(--border);
|
||||
border-top: none;
|
||||
border-radius: 0 0 8px 8px;
|
||||
background: var(--bg-card);
|
||||
@@ -43,7 +43,7 @@
|
||||
font-size: 0.9rem;
|
||||
padding: 0.4rem 0.6rem;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border-light);
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg-card);
|
||||
color: var(--text-primary);
|
||||
max-width: 100%;
|
||||
|
||||
@@ -9,12 +9,12 @@
|
||||
}
|
||||
|
||||
.sectionTitle {
|
||||
font-family: var(--font-playfair), 'Playfair Display', Georgia, serif;
|
||||
font-family: var(--font-display);
|
||||
font-size: 1.45rem;
|
||||
font-weight: 700;
|
||||
margin: 0;
|
||||
padding-left: 0.75rem;
|
||||
border-left: 3px solid var(--accent-coral-dark);
|
||||
border-left: 3px solid var(--brand-strong);
|
||||
}
|
||||
|
||||
.how {
|
||||
@@ -36,7 +36,7 @@
|
||||
back into the shared grid. */
|
||||
.measure {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-light);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
box-shadow: var(--shadow-soft);
|
||||
padding: 0.75rem 0.85rem;
|
||||
@@ -59,7 +59,7 @@
|
||||
gap: 0.35rem 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
padding: 0.5rem 0;
|
||||
border-top: 1px solid var(--border-light);
|
||||
border-top: 1px solid var(--border);
|
||||
margin-top: 0.5rem;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
@@ -113,18 +113,18 @@
|
||||
}
|
||||
|
||||
.chipGood {
|
||||
background: rgba(45, 125, 125, 0.14);
|
||||
color: var(--accent-teal);
|
||||
background: rgba(var(--status-above-rgb), 0.14);
|
||||
color: var(--status-above);
|
||||
}
|
||||
|
||||
.chipWarn {
|
||||
background: var(--accent-gold-bg);
|
||||
color: var(--accent-gold-text);
|
||||
background: var(--status-below-bg);
|
||||
color: var(--status-below);
|
||||
}
|
||||
|
||||
.chipBad {
|
||||
background: var(--accent-coral-bg);
|
||||
color: var(--accent-coral-dark);
|
||||
background: var(--status-below-bg);
|
||||
color: var(--status-below);
|
||||
}
|
||||
|
||||
.chipNeutral {
|
||||
@@ -141,18 +141,18 @@
|
||||
}
|
||||
|
||||
.badgeGood {
|
||||
background: rgba(45, 125, 125, 0.14);
|
||||
color: var(--accent-teal);
|
||||
background: rgba(var(--status-above-rgb), 0.14);
|
||||
color: var(--status-above);
|
||||
}
|
||||
|
||||
.badgeWarn {
|
||||
background: var(--accent-gold-bg);
|
||||
color: var(--accent-gold-text);
|
||||
background: var(--status-below-bg);
|
||||
color: var(--status-below);
|
||||
}
|
||||
|
||||
.badgeBad {
|
||||
background: var(--accent-coral-bg);
|
||||
color: var(--accent-coral-dark);
|
||||
background: var(--status-below-bg);
|
||||
color: var(--status-below);
|
||||
}
|
||||
|
||||
.rcList {
|
||||
@@ -200,7 +200,7 @@
|
||||
|
||||
.card {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-light);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 16px;
|
||||
box-shadow: var(--shadow-soft);
|
||||
padding: 1.25rem 1.5rem;
|
||||
@@ -208,7 +208,7 @@
|
||||
}
|
||||
|
||||
.link {
|
||||
color: var(--accent-coral-dark);
|
||||
color: var(--brand-strong);
|
||||
}
|
||||
|
||||
@media (min-width: 761px) {
|
||||
@@ -230,14 +230,14 @@
|
||||
.rowLabel {
|
||||
color: var(--text-secondary);
|
||||
padding: 0.85rem 0.5rem 0.85rem 0;
|
||||
border-bottom: 1px solid var(--border-light);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.cell {
|
||||
display: block;
|
||||
padding: 0.85rem 0.25rem;
|
||||
border-top: none;
|
||||
border-bottom: 1px solid var(--border-light);
|
||||
border-bottom: 1px solid var(--border);
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
padding: 0.25rem 0;
|
||||
font-size: 1.0625rem;
|
||||
font-weight: 600;
|
||||
color: var(--accent-coral-dark, #b04a2e);
|
||||
color: var(--brand-strong);
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
@@ -29,7 +29,7 @@
|
||||
|
||||
|
||||
.topBack:hover {
|
||||
color: var(--accent-coral-dark, #c85a3e);
|
||||
color: var(--brand-strong);
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
@@ -38,8 +38,8 @@
|
||||
/* Header Section */
|
||||
.header {
|
||||
position: relative;
|
||||
background: var(--bg-card, white);
|
||||
border: 1px solid var(--border-color, #e5dfd5);
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
/* Padding lives on .headerContent so the map band can bleed to the edges. */
|
||||
padding: 0;
|
||||
@@ -88,12 +88,12 @@
|
||||
|
||||
|
||||
.headerHasMap .actions .btnAdd {
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
color: var(--accent-coral-dark, #b04a2e);
|
||||
background: var(--bg-card);
|
||||
color: var(--brand-strong);
|
||||
border-color: transparent;
|
||||
-webkit-backdrop-filter: blur(6px);
|
||||
backdrop-filter: blur(6px);
|
||||
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.16);
|
||||
box-shadow: 0 2px 10px rgba(var(--shadow-rgb), 0.16);
|
||||
}
|
||||
|
||||
|
||||
@@ -116,14 +116,14 @@
|
||||
padding: 0;
|
||||
font: inherit;
|
||||
font-weight: 600;
|
||||
color: var(--accent-coral-dark, #b04a2e);
|
||||
color: var(--brand-strong);
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
|
||||
.mapLink:hover {
|
||||
color: var(--accent-coral-dark, #c45a3f);
|
||||
color: var(--brand-strong);
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
@@ -137,11 +137,11 @@
|
||||
.schoolName {
|
||||
font-size: clamp(2rem, 5vw, 3.25rem);
|
||||
font-weight: 700;
|
||||
color: var(--text-primary, #1a1612);
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 0.5rem;
|
||||
line-height: 1.1;
|
||||
letter-spacing: -0.01em;
|
||||
font-family: var(--font-playfair), "Playfair Display", serif;
|
||||
font-family: var(--font-display);
|
||||
}
|
||||
|
||||
|
||||
@@ -155,16 +155,16 @@
|
||||
|
||||
.metaItem {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--text-secondary, #5c564d);
|
||||
color: var(--text-secondary);
|
||||
padding: 0.125rem 0.5rem;
|
||||
background: var(--bg-secondary, #f3ede4);
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
|
||||
.address {
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-muted, #8a847a);
|
||||
color: var(--text-muted);
|
||||
margin: 0 0 0.75rem;
|
||||
}
|
||||
|
||||
@@ -180,18 +180,18 @@
|
||||
|
||||
.headerDetail {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--text-secondary, #5c564d);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
|
||||
.headerDetail strong {
|
||||
color: var(--text-primary, #1a1612);
|
||||
color: var(--text-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
|
||||
.headerDetail a {
|
||||
color: var(--accent-teal, #2d7d7d);
|
||||
color: var(--brand);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
@@ -213,7 +213,7 @@
|
||||
border: none;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
color: var(--accent-teal, #2d7d7d);
|
||||
color: var(--brand);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@@ -236,25 +236,25 @@
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
white-space: nowrap;
|
||||
box-shadow: var(--shadow-soft, 0 2px 8px rgba(26, 22, 18, 0.08));
|
||||
box-shadow: var(--shadow-soft, 0 2px 8px rgba(var(--shadow-rgb), 0.08));
|
||||
}
|
||||
|
||||
|
||||
.btnAdd {
|
||||
background: var(--accent-coral-dark, #b04a2e);
|
||||
color: white;
|
||||
background: var(--brand-strong);
|
||||
color: var(--brand-on);
|
||||
}
|
||||
|
||||
|
||||
.btnAdd:hover {
|
||||
background: var(--accent-coral-darker, #9c3f26);
|
||||
background: var(--brand-stronger);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
|
||||
.btnRemove {
|
||||
background: var(--accent-teal, #2d7d7d);
|
||||
color: white;
|
||||
background: var(--brand);
|
||||
color: var(--brand-on);
|
||||
}
|
||||
|
||||
|
||||
@@ -270,13 +270,13 @@
|
||||
position: sticky;
|
||||
top: 64px; /* global header height on desktop */
|
||||
z-index: 10;
|
||||
background: var(--bg-card, white);
|
||||
border: 1px solid var(--border-color, #e5dfd5);
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-top: none;
|
||||
border-radius: 0 0 10px 10px;
|
||||
padding: 0.5rem 0.75rem;
|
||||
margin-bottom: 1rem;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.04);
|
||||
box-shadow: 0 2px 4px rgba(var(--shadow-rgb), 0.04);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
@@ -291,9 +291,9 @@
|
||||
padding: 0.3rem 0.625rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: var(--accent-coral-dark, #b04a2e);
|
||||
color: var(--brand-strong);
|
||||
background: none;
|
||||
border: 1px solid var(--border-color, #e5dfd5);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
@@ -302,8 +302,8 @@
|
||||
|
||||
|
||||
.sectionNavBack:hover {
|
||||
background: var(--bg-secondary, #f3ede4);
|
||||
border-color: var(--accent-coral, #e07256);
|
||||
background: var(--bg-secondary);
|
||||
border-color: var(--brand);
|
||||
}
|
||||
|
||||
|
||||
@@ -334,7 +334,7 @@
|
||||
padding: 0.3rem 0.625rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary, #5c564d);
|
||||
color: var(--text-secondary);
|
||||
text-decoration: none;
|
||||
border-radius: 4px;
|
||||
transition: all 0.15s ease;
|
||||
@@ -344,21 +344,21 @@
|
||||
|
||||
|
||||
.sectionNavLink:hover {
|
||||
background: var(--bg-secondary, #f3ede4);
|
||||
color: var(--text-primary, #1a1612);
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
|
||||
.sectionNavLinkActive {
|
||||
background: var(--accent-coral-dark, #b04a2e);
|
||||
color: white;
|
||||
background: var(--brand-strong);
|
||||
color: var(--brand-on);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
|
||||
.sectionNavLinkActive:hover {
|
||||
background: var(--accent-coral-dark, #c45a3f);
|
||||
color: white;
|
||||
background: var(--brand-strong);
|
||||
color: var(--brand-on);
|
||||
}
|
||||
|
||||
|
||||
@@ -373,12 +373,12 @@
|
||||
gap: 0.5rem;
|
||||
min-height: 38px;
|
||||
padding: 0.34rem 0.7rem;
|
||||
background: var(--bg-secondary, #f3ede4);
|
||||
border: 1px solid var(--border-color, #e5dfd5);
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-family: var(--font-dm-sans), "DM Sans", sans-serif;
|
||||
color: var(--text-primary, #1a1612);
|
||||
font-family: var(--font-ui);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
|
||||
@@ -396,7 +396,7 @@
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-muted, #6d685f);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
|
||||
@@ -411,7 +411,7 @@
|
||||
|
||||
.sectionNavMenuChev {
|
||||
flex: none;
|
||||
color: var(--text-muted, #6d685f);
|
||||
color: var(--text-muted);
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
@@ -426,9 +426,9 @@
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
border-radius: 9px;
|
||||
border: 1px solid var(--accent-coral-dark, #b04a2e);
|
||||
background: var(--accent-coral-dark, #b04a2e);
|
||||
color: white;
|
||||
border: 1px solid var(--brand-strong);
|
||||
background: var(--brand-strong);
|
||||
color: var(--brand-on);
|
||||
cursor: pointer;
|
||||
transition: background 0.15s ease, border-color 0.15s ease;
|
||||
}
|
||||
@@ -447,9 +447,9 @@
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
background: var(--bg-card, white);
|
||||
color: var(--accent-coral-dark, #c45a3f);
|
||||
border: 1.5px solid var(--accent-coral, #e07256);
|
||||
background: var(--bg-card);
|
||||
color: var(--brand-strong);
|
||||
border: 1.5px solid var(--brand);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -460,9 +460,9 @@
|
||||
|
||||
|
||||
.sectionNavCompareIconIn {
|
||||
background: var(--bg-card, white);
|
||||
border-color: var(--accent-teal, #2d7d7d);
|
||||
color: var(--accent-teal, #2d7d7d);
|
||||
background: var(--bg-card);
|
||||
border-color: var(--brand);
|
||||
color: var(--brand);
|
||||
}
|
||||
|
||||
|
||||
@@ -474,9 +474,9 @@
|
||||
padding: 0.34rem 0.7rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: white;
|
||||
background: var(--accent-coral-dark, #b04a2e);
|
||||
border: 1px solid var(--accent-coral-dark, #b04a2e);
|
||||
color: var(--brand-on);
|
||||
background: var(--brand-strong);
|
||||
border: 1px solid var(--brand-strong);
|
||||
border-radius: 999px;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
@@ -485,21 +485,21 @@
|
||||
|
||||
|
||||
.sectionNavCompare:hover {
|
||||
background: var(--accent-coral-darker, #9c3f26);
|
||||
border-color: var(--accent-coral-darker, #9c3f26);
|
||||
background: var(--brand-stronger);
|
||||
border-color: var(--brand-stronger);
|
||||
}
|
||||
|
||||
|
||||
.sectionNavCompareIn {
|
||||
background: var(--bg-card, white);
|
||||
color: var(--accent-teal, #2d7d7d);
|
||||
border-color: var(--accent-teal, #2d7d7d);
|
||||
background: var(--bg-card);
|
||||
color: var(--brand);
|
||||
border-color: var(--brand);
|
||||
}
|
||||
|
||||
|
||||
.sectionNavCompareIn:hover {
|
||||
background: var(--bg-secondary, #f3ede4);
|
||||
border-color: var(--accent-teal, #2d7d7d);
|
||||
background: var(--bg-secondary);
|
||||
border-color: var(--brand);
|
||||
}
|
||||
|
||||
|
||||
@@ -512,8 +512,8 @@
|
||||
padding: 0.34rem 0.65rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary, #1a1612);
|
||||
background: var(--bg-secondary, #f3ede4);
|
||||
color: var(--text-primary);
|
||||
background: var(--bg-secondary);
|
||||
border: none;
|
||||
border-radius: 999px;
|
||||
cursor: pointer;
|
||||
@@ -523,7 +523,7 @@
|
||||
|
||||
|
||||
.sectionNavAll:hover {
|
||||
background: var(--border-color, #e5dfd5);
|
||||
background: var(--border);
|
||||
}
|
||||
|
||||
|
||||
@@ -531,7 +531,7 @@
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1500;
|
||||
background: rgba(26, 22, 18, 0.28);
|
||||
background: rgba(var(--shadow-rgb), 0.28);
|
||||
}
|
||||
|
||||
|
||||
@@ -543,19 +543,19 @@
|
||||
width: 230px;
|
||||
max-height: min(70vh, 460px);
|
||||
overflow-y: auto;
|
||||
background: var(--bg-card, white);
|
||||
border: 1px solid var(--border-color, #e5dfd5);
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 18px 44px rgba(26, 22, 18, 0.2);
|
||||
box-shadow: 0 18px 44px rgba(var(--shadow-rgb), 0.2);
|
||||
padding: 0.35rem;
|
||||
}
|
||||
|
||||
|
||||
.sectionsPanelHead {
|
||||
font-family: var(--font-playfair), "Playfair Display", Georgia, serif;
|
||||
font-family: var(--font-display);
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary, #1a1612);
|
||||
color: var(--text-primary);
|
||||
padding: 0.4rem 0.6rem 0.5rem;
|
||||
}
|
||||
|
||||
@@ -568,44 +568,44 @@
|
||||
padding: 0.55rem 0.6rem;
|
||||
border-radius: 8px;
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-secondary, #5c564d);
|
||||
color: var(--text-secondary);
|
||||
text-decoration: none;
|
||||
transition: background 0.12s ease;
|
||||
}
|
||||
|
||||
|
||||
.sectionsItem:hover {
|
||||
background: var(--bg-secondary, #f3ede4);
|
||||
color: var(--text-primary, #1a1612);
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
|
||||
.sectionsItemActive {
|
||||
background: var(--accent-coral-bg, rgba(224, 114, 86, 0.12));
|
||||
color: var(--accent-coral-dark, #c45a3f);
|
||||
background: var(--brand-bg);
|
||||
color: var(--brand-strong);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
|
||||
.sectionsTick {
|
||||
color: var(--accent-coral-dark, #b04a2e);
|
||||
color: var(--brand-strong);
|
||||
}
|
||||
|
||||
|
||||
/* GIAS "Open, but proposed to close" notice strip */
|
||||
.closingStrip {
|
||||
background: #fdf6e3;
|
||||
border-left: 4px solid #e2c96f;
|
||||
background: var(--status-below-bg);
|
||||
border-left: 4px solid var(--status-below-bg);
|
||||
border-radius: 0 6px 6px 0;
|
||||
padding: 0.55rem 0.9rem;
|
||||
margin: 0.5rem 0;
|
||||
font-size: 0.88rem;
|
||||
color: #6e5a00;
|
||||
color: var(--status-below);
|
||||
max-width: 68ch;
|
||||
}
|
||||
|
||||
.closingStrip strong {
|
||||
color: #8a6200;
|
||||
color: var(--status-below);
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
@@ -738,7 +738,7 @@
|
||||
max-height: 74vh;
|
||||
border-radius: 16px 16px 0 0;
|
||||
padding: 0.5rem 0.6rem calc(0.8rem + env(safe-area-inset-bottom, 0));
|
||||
box-shadow: 0 -10px 40px rgba(26, 22, 18, 0.25);
|
||||
box-shadow: 0 -10px 40px rgba(var(--shadow-rgb), 0.25);
|
||||
}
|
||||
|
||||
.sectionsItem {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,163 @@
|
||||
/**
|
||||
* Bridge between the CSS token layer and the bits of the UI that are painted
|
||||
* from JavaScript — Chart.js datasets and Leaflet markers.
|
||||
*
|
||||
* Those two can't use var(), so before this they carried their own hardcoded
|
||||
* hexes. That is how the chart palette drifted off-brand (a stray purple and
|
||||
* Flat UI's stock blue) and why charts would have ignored the dark theme
|
||||
* entirely. Reading the computed custom property keeps one source of truth.
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
export type ThemeToken =
|
||||
| '--brand'
|
||||
| '--brand-strong'
|
||||
| '--brand-bg'
|
||||
| '--status-above'
|
||||
| '--status-above-bg'
|
||||
| '--status-below'
|
||||
| '--status-below-bg'
|
||||
| '--text-primary'
|
||||
| '--text-secondary'
|
||||
| '--text-muted'
|
||||
| '--text-inverse'
|
||||
| '--bg-card'
|
||||
| '--bg-secondary'
|
||||
| '--border'
|
||||
| '--border-strong'
|
||||
| '--surface-inverse'
|
||||
| '--chart-1'
|
||||
| '--chart-2'
|
||||
| '--chart-3'
|
||||
| '--chart-4'
|
||||
| '--chart-5'
|
||||
| '--chart-6'
|
||||
| '--chart-grid'
|
||||
| '--chart-reference'
|
||||
| '--phase-primary'
|
||||
| '--phase-secondary-text'
|
||||
| '--medal-gold'
|
||||
| '--medal-silver'
|
||||
| '--medal-bronze';
|
||||
|
||||
/** Values used before hydration and in any non-DOM context (SSR, tests). */
|
||||
const FALLBACK: Record<string, string> = {
|
||||
'--brand': '#0F766E',
|
||||
'--brand-strong': '#0C5F58',
|
||||
'--brand-bg': 'rgba(15, 118, 110, 0.10)',
|
||||
'--status-above': '#36743F',
|
||||
'--status-above-bg': 'rgba(54, 116, 63, 0.12)',
|
||||
'--status-below': '#A9481F',
|
||||
'--status-below-bg': 'rgba(249, 115, 96, 0.15)',
|
||||
'--text-primary': '#1C2731',
|
||||
'--text-secondary': '#4A5560',
|
||||
'--text-muted': '#5F6A75',
|
||||
'--text-inverse': '#FFFFFF',
|
||||
'--bg-card': '#FFFFFF',
|
||||
'--bg-secondary': '#F5EFE6',
|
||||
'--border': '#E5E7EB',
|
||||
'--border-strong': '#D3D7DD',
|
||||
'--surface-inverse': '#0F766E',
|
||||
'--chart-1': '#0F766E',
|
||||
'--chart-2': '#36743F',
|
||||
'--chart-3': '#806200',
|
||||
'--chart-4': '#A9481F',
|
||||
'--chart-5': '#2F6F8F',
|
||||
'--chart-6': '#5F4FA8',
|
||||
'--chart-grid': '#E5E7EB',
|
||||
'--chart-reference': '#5F6A75',
|
||||
'--phase-primary': '#0F766E',
|
||||
'--phase-secondary-text': '#2A6180',
|
||||
'--medal-gold': '#806200',
|
||||
'--medal-silver': '#6B7580',
|
||||
'--medal-bronze': '#8A5A2B',
|
||||
};
|
||||
|
||||
/** Read one token. Safe to call during SSR — returns the light-theme value. */
|
||||
export function token(name: ThemeToken): string {
|
||||
if (typeof window === 'undefined') return FALLBACK[name];
|
||||
const value = getComputedStyle(document.documentElement).getPropertyValue(name).trim();
|
||||
return value || FALLBACK[name];
|
||||
}
|
||||
|
||||
/**
|
||||
* Same as `token`, but the component re-renders when the OS theme flips so
|
||||
* canvas-painted colours don't get stranded in the previous theme.
|
||||
*/
|
||||
export function useThemeTokens<T extends readonly ThemeToken[]>(
|
||||
...names: T
|
||||
): { [K in keyof T]: string } {
|
||||
const read = () => names.map((n) => token(n)) as { [K in keyof T]: string };
|
||||
const [values, setValues] = useState(read);
|
||||
|
||||
useEffect(() => {
|
||||
// Only commit when something actually changed. In the light theme the
|
||||
// computed values equal the fallbacks, so this is a no-op — which matters
|
||||
// because a state update here re-renders the chart and makes Chart.js
|
||||
// resize a canvas that may not be laid out yet.
|
||||
const sync = () => setValues((prev) => (sameValues(prev, read()) ? prev : read()));
|
||||
sync();
|
||||
const mq = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
mq.addEventListener('change', sync);
|
||||
return () => mq.removeEventListener('change', sync);
|
||||
// `names` is a fixed-length literal tuple at every call site.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
return values;
|
||||
}
|
||||
|
||||
function sameValues(a: readonly string[], b: readonly string[]): boolean {
|
||||
return a.length === b.length && a.every((v, i) => v === b[i]);
|
||||
}
|
||||
|
||||
const SERIES = [
|
||||
'--series-1', '--series-2', '--series-3', '--series-4',
|
||||
'--series-5', '--series-6', '--series-7', '--series-8',
|
||||
] as const;
|
||||
|
||||
const SERIES_FALLBACK = [
|
||||
'#0F766E', '#36743F', '#806200', '#A9481F',
|
||||
'#2F6F8F', '#5F4FA8', '#0E7A86', '#8A4A6B',
|
||||
];
|
||||
|
||||
/**
|
||||
* Resolved series colours for Chart.js datasets, in the same index order as
|
||||
* CHART_COLORS — so a school keeps its colour between the canvas and the DOM
|
||||
* swatch beside it. Re-reads when the OS theme flips.
|
||||
*/
|
||||
export function useSeriesColors(): string[] {
|
||||
const read = () =>
|
||||
typeof window === 'undefined'
|
||||
? SERIES_FALLBACK
|
||||
: SERIES.map((n, i) => {
|
||||
const v = getComputedStyle(document.documentElement).getPropertyValue(n).trim();
|
||||
return v || SERIES_FALLBACK[i];
|
||||
});
|
||||
|
||||
const [colors, setColors] = useState(read);
|
||||
|
||||
useEffect(() => {
|
||||
// See useThemeTokens: skip the commit when nothing changed, so mounting a
|
||||
// chart doesn't cost an extra render.
|
||||
const sync = () => setColors((prev) => (sameValues(prev, read()) ? prev : read()));
|
||||
sync();
|
||||
const mq = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
mq.addEventListener('change', sync);
|
||||
return () => mq.removeEventListener('change', sync);
|
||||
}, []);
|
||||
|
||||
return colors;
|
||||
}
|
||||
|
||||
/** rgba() built from a token's hex, for fills that need their own alpha. */
|
||||
export function alpha(name: ThemeToken, a: number): string {
|
||||
const hex = token(name);
|
||||
const m = /^#([0-9a-f]{6})$/i.exec(hex);
|
||||
if (!m) return hex;
|
||||
const n = parseInt(m[1], 16);
|
||||
return `rgba(${(n >> 16) & 255}, ${(n >> 8) & 255}, ${n & 255}, ${a})`;
|
||||
}
|
||||
+40
-30
@@ -273,17 +273,29 @@ export function debounce<T extends (...args: any[]) => any>(
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Chart color palette (consistent with vanilla JS app)
|
||||
* Series palette for comparing up to eight schools at once.
|
||||
*
|
||||
* These were Chart.js's stock demo colours, which is why the comparison view
|
||||
* never looked like the rest of the site. They now point at the --series-*
|
||||
* tokens, so the palette lives in one place and follows the theme.
|
||||
*
|
||||
* Every step clears WCAG AA on both grounds, so the same value works as a
|
||||
* chart line and as the legend text keyed to it — there is no longer a
|
||||
* separate text ramp to keep in sync.
|
||||
*
|
||||
* These are var() strings: correct anywhere the value lands in the DOM
|
||||
* (inline styles, CSS custom properties). Canvas can't resolve var(), so
|
||||
* Chart.js datasets must use `useSeriesColors()` from lib/theme instead.
|
||||
*/
|
||||
export const CHART_COLORS = [
|
||||
'rgb(75, 192, 192)',
|
||||
'rgb(255, 99, 132)',
|
||||
'rgb(54, 162, 235)',
|
||||
'rgb(255, 206, 86)',
|
||||
'rgb(153, 102, 255)',
|
||||
'rgb(255, 159, 64)',
|
||||
'rgb(201, 203, 207)',
|
||||
'rgb(255, 0, 255)',
|
||||
'var(--series-1)',
|
||||
'var(--series-2)',
|
||||
'var(--series-3)',
|
||||
'var(--series-4)',
|
||||
'var(--series-5)',
|
||||
'var(--series-6)',
|
||||
'var(--series-7)',
|
||||
'var(--series-8)',
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -294,44 +306,42 @@ export function getChartColor(index: number): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* Darker counterparts of CHART_COLORS for use as TEXT on the site's light
|
||||
* backgrounds — the raw series colours fail WCAG AA (e.g. 1.9:1 on cream).
|
||||
* Same hue per index so text still keys to its chart line. All ≥4.5:1 on
|
||||
* #f3ede4 and lighter.
|
||||
* @deprecated The series palette is now AA on both grounds, so line and text
|
||||
* share one value. Kept as an alias so call sites can migrate incrementally.
|
||||
*/
|
||||
export const CHART_TEXT_COLORS = [
|
||||
'#256868', // teal
|
||||
'#c2255c', // pink/red
|
||||
'#2069ad', // blue
|
||||
'#8a5a00', // yellow → ochre
|
||||
'#6d43c8', // purple
|
||||
'#8f4e00', // orange → brown
|
||||
'#5f5952', // grey
|
||||
'#a300a3', // magenta
|
||||
];
|
||||
export const CHART_TEXT_COLORS = CHART_COLORS;
|
||||
|
||||
export function getChartTextColor(index: number): string {
|
||||
return CHART_TEXT_COLORS[index % CHART_TEXT_COLORS.length];
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert RGB color to RGBA with opacity
|
||||
* Add opacity to a colour. Accepts `rgb(...)` or `#rrggbb` — the series
|
||||
* palette resolves to hex now that it comes from the token layer, but the
|
||||
* rgb() form is still used by callers passing Chart.js literals.
|
||||
*/
|
||||
export function rgbToRgba(rgb: string, alpha: number): string {
|
||||
return rgb.replace('rgb', 'rgba').replace(')', `, ${alpha})`);
|
||||
export function rgbToRgba(color: string, alpha: number): string {
|
||||
const hex = /^#([0-9a-f]{6})$/i.exec(color.trim());
|
||||
if (hex) {
|
||||
const n = parseInt(hex[1], 16);
|
||||
return `rgba(${(n >> 16) & 255}, ${(n >> 8) & 255}, ${n & 255}, ${alpha})`;
|
||||
}
|
||||
return color.replace('rgb', 'rgba').replace(')', `, ${alpha})`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get trend color based on direction
|
||||
* Trend direction colour. Teal/amber rather than green/red so the signal
|
||||
* survives red–green colour blindness; `at` is deliberately neutral so a
|
||||
* school sitting on the average doesn't read as a verdict.
|
||||
*/
|
||||
export function getTrendColor(trend: 'up' | 'down' | 'stable'): string {
|
||||
switch (trend) {
|
||||
case 'up':
|
||||
return '#22c55e'; // green
|
||||
return 'var(--status-above)';
|
||||
case 'down':
|
||||
return '#ef4444'; // red
|
||||
return 'var(--status-below)';
|
||||
case 'stable':
|
||||
return '#6b7280'; // gray
|
||||
return 'var(--status-at)';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,15 @@ const nextConfig = {
|
||||
// Enable standalone output for Docker
|
||||
output: 'standalone',
|
||||
|
||||
// app/opengraph-image.tsx reads the Schibsted Grotesk files off disk at
|
||||
// request time (Satori needs a font buffer; it has no system fallback).
|
||||
// File tracing currently picks these up on its own, but that relies on the
|
||||
// tracer resolving a runtime join() — declare them so a Next upgrade can't
|
||||
// silently drop them and turn every link preview into a 500.
|
||||
outputFileTracingIncludes: {
|
||||
'/opengraph-image': ['./assets/**'],
|
||||
},
|
||||
|
||||
// The /api/* and /sitemap.xml proxies to the FastAPI backend are route
|
||||
// handlers (app/api/[...path]/route.ts, app/sitemap.xml/route.ts) rather
|
||||
// than rewrites, so the backend host is read from FASTAPI_URL at runtime
|
||||
@@ -72,7 +81,9 @@ const nextConfig = {
|
||||
],
|
||||
},
|
||||
{
|
||||
source: '/favicon.svg',
|
||||
// favicon.svg was replaced by app/icon.svg (the mark now has one
|
||||
// source in components/Logo.tsx); this rule was caching a 404.
|
||||
source: '/icon.svg',
|
||||
headers: [
|
||||
{
|
||||
key: 'Cache-Control',
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
<svg viewBox="0 0 40 40" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="40" height="40" rx="8" fill="#1a1612"/>
|
||||
<circle cx="20" cy="20" r="14" stroke="#e07256" stroke-width="2"/>
|
||||
<path d="M20 8L20 32M12 14L28 14M10 20L30 20M12 26L28 26" stroke="#e07256" stroke-width="1.5" stroke-linecap="round"/>
|
||||
<circle cx="20" cy="20" r="3" fill="#e07256"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 374 B |
Binary file not shown.
|
After Width: | Height: | Size: 3.2 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 8.9 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 7.4 KiB |
@@ -1,16 +1,34 @@
|
||||
{
|
||||
"name": "SchoolCompare",
|
||||
"short_name": "SchoolCompare",
|
||||
"name": "schoolcompare",
|
||||
"short_name": "schoolcompare",
|
||||
"description": "Compare primary and secondary school performance across England",
|
||||
"start_url": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#faf7f2",
|
||||
"theme_color": "#faf7f2",
|
||||
"background_color": "#FAFAF8",
|
||||
"theme_color": "#0F766E",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/favicon.svg",
|
||||
"src": "/icon.svg",
|
||||
"sizes": "any",
|
||||
"type": "image/svg+xml"
|
||||
},
|
||||
{
|
||||
"src": "/icon-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/icon-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/icon-maskable-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user