A school page links outward to its places and never to another school. This section adds that edge: three nearby schools of the same phase and a comparable intake, each a crawlable link and each addable to the basket. The design separates hard filters from soft preferences and never confuses them. Selectivity, provision and opposite-sex intake are claims the section cannot make, so they never relax, even where that means no section renders. Gender and religious character describe closeness of fit, so they relax in tiers — and the card states what actually survived rather than padding with a match it did not earn. Includes the mockup the design is drawn against, with all three tier states live in both themes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
301 lines
16 KiB
HTML
301 lines
16 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||
<title>Similar Schools Nearby</title>
|
||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&family=Manrope:wght@500;600;700&display=swap" rel="stylesheet">
|
||
<style>
|
||
/* Tokens copied verbatim from nextjs-app/app/(frontend)/globals.css so this
|
||
mockup cannot drift from the shipped palette. Light values first, dark
|
||
under prefers-color-scheme, both overridable by the theme switch. */
|
||
:root {
|
||
color-scheme: light;
|
||
--bg-primary:#FAFAF8; --bg-secondary:#F5EFE6; --bg-card:#FFFFFF;
|
||
--text-primary:#1C2731; --text-secondary:#4A5560; --text-muted:#5F6A75;
|
||
--border:#E5E7EB; --border-strong:#D3D7DD;
|
||
--brand:#0F766E; --brand-strong:#0C5F58; --brand-bg:rgba(15,118,110,.10); --brand-on:#FFFFFF;
|
||
--action:#BE3C27; --action-strong:#A33320; --action-on:#FFFFFF;
|
||
--sand:#F5EFE6; --mustard:#F2C94C; --mustard-ink:#806200;
|
||
--font-display:Manrope,-apple-system,BlinkMacSystemFont,sans-serif;
|
||
--font-ui:Inter,-apple-system,BlinkMacSystemFont,sans-serif;
|
||
--radius-md:8px; --radius-lg:16px;
|
||
--shadow:0 1px 2px rgba(28,39,49,.06),0 1px 3px rgba(28,39,49,.05);
|
||
}
|
||
@media (prefers-color-scheme: dark) {
|
||
:root:not([data-theme="light"]) {
|
||
color-scheme: dark;
|
||
--bg-primary:#111A20; --bg-secondary:#16222A; --bg-card:#18242C;
|
||
--text-primary:#E9EEF0; --text-secondary:#B4C2C7; --text-muted:#8B9AA1;
|
||
--border:#26343D; --border-strong:#35454F;
|
||
--brand:#5FC7BB; --brand-strong:#7BD6CC; --brand-bg:rgba(95,199,187,.14); --brand-on:#0A1418;
|
||
--action:#F08A72; --action-strong:#F5A492; --action-on:#241009;
|
||
--sand:#1B2730; --mustard:#F2C94C; --mustard-ink:#EFC658;
|
||
--shadow:0 1px 2px rgba(0,0,0,.3),0 1px 3px rgba(0,0,0,.25);
|
||
}
|
||
}
|
||
:root[data-theme="dark"] {
|
||
color-scheme: dark;
|
||
--bg-primary:#111A20; --bg-secondary:#16222A; --bg-card:#18242C;
|
||
--text-primary:#E9EEF0; --text-secondary:#B4C2C7; --text-muted:#8B9AA1;
|
||
--border:#26343D; --border-strong:#35454F;
|
||
--brand:#5FC7BB; --brand-strong:#7BD6CC; --brand-bg:rgba(95,199,187,.14); --brand-on:#0A1418;
|
||
--action:#F08A72; --action-strong:#F5A492; --action-on:#241009;
|
||
--sand:#1B2730; --mustard:#F2C94C; --mustard-ink:#EFC658;
|
||
--shadow:0 1px 2px rgba(0,0,0,.3),0 1px 3px rgba(0,0,0,.25);
|
||
}
|
||
* { box-sizing:border-box; }
|
||
body {
|
||
margin:0; padding:32px 16px 80px; background:var(--bg-primary);
|
||
color:var(--text-primary); font:15px/1.55 var(--font-ui);
|
||
-webkit-font-smoothing:antialiased;
|
||
}
|
||
.page { max-width:960px; margin:0 auto; }
|
||
.page > header { margin-bottom:28px; display:flex; flex-wrap:wrap; gap:16px; align-items:flex-start; justify-content:space-between; }
|
||
.page > header h1 { font:700 25px/1.25 var(--font-display); letter-spacing:-.6px; margin:0 0 6px; }
|
||
.page > header p { margin:0; color:var(--text-muted); font-size:14px; max-width:60ch; }
|
||
.theme-switch { border:1px solid var(--border-strong); background:var(--bg-card); color:var(--text-secondary); border-radius:999px; padding:8px 14px; font:500 13px var(--font-ui); cursor:pointer; min-height:40px; }
|
||
.theme-switch:hover { border-color:var(--brand); color:var(--brand); }
|
||
|
||
/* ── The page context each variant is shown inside ─────────────────── */
|
||
.variant { margin-bottom:40px; }
|
||
.variant > .context { padding:0 4px 12px; }
|
||
.variant .eyebrow { margin:0 0 4px; font-size:12px; letter-spacing:.04em; text-transform:uppercase; color:var(--text-muted); }
|
||
.variant .context h2 { font:600 18px/1.35 var(--font-display); margin:0; color:var(--text-secondary); }
|
||
.variant .note { margin:10px 4px 0; font-size:12.5px; color:var(--text-muted); }
|
||
.variant .note b { color:var(--text-secondary); font-weight:600; }
|
||
|
||
/* ── The section itself — mirrors components/school/Section ────────── */
|
||
.card {
|
||
background:var(--bg-card); border:1px solid var(--border);
|
||
border-radius:var(--radius-lg); padding:28px; box-shadow:var(--shadow);
|
||
}
|
||
.card > h2 { font:700 22px/1.25 var(--font-display); letter-spacing:-.4px; margin:0; }
|
||
.lede { margin:8px 0 0; color:var(--text-secondary); font-size:14.5px; max-width:64ch; }
|
||
details.method { margin:14px 0 22px; }
|
||
details.method summary { cursor:pointer; color:var(--brand); font-size:13.5px; font-weight:500; width:fit-content; }
|
||
details.method summary:hover { color:var(--brand-strong); }
|
||
details.method div { margin-top:10px; padding:14px 16px; background:var(--bg-secondary); border-radius:var(--radius-md); }
|
||
details.method p { margin:0 0 9px; font-size:13px; color:var(--text-secondary); max-width:70ch; }
|
||
details.method p:last-child { margin-bottom:0; }
|
||
|
||
.grid { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:14px; }
|
||
@media (max-width:820px) { .grid { grid-template-columns:repeat(2,minmax(0,1fr)); } }
|
||
@media (max-width:560px) { .grid { grid-template-columns:1fr; } .card { padding:20px; } body { padding-left:16px; padding-right:16px; } }
|
||
|
||
.school {
|
||
position:relative; display:flex; flex-direction:column;
|
||
border:1px solid var(--border); border-radius:var(--radius-md);
|
||
padding:16px; background:var(--bg-card);
|
||
}
|
||
.school:has(.add[aria-pressed="true"]) { border-color:var(--brand); background:var(--brand-bg); }
|
||
.distance { display:flex; align-items:center; gap:5px; font-size:12px; color:var(--text-muted); margin-bottom:10px; }
|
||
.distance svg { width:13px; height:13px; flex:none; }
|
||
.school h3 { font:600 16px/1.35 var(--font-display); margin:0 0 6px; }
|
||
/* The whole card is the link target; the button sits above it on z-index so
|
||
it stays independently clickable. Same trick the search result cards use. */
|
||
.school h3 a { color:var(--text-primary); text-decoration:none; }
|
||
.school h3 a::after { content:""; position:absolute; inset:0; border-radius:var(--radius-md); }
|
||
.school:hover { border-color:var(--border-strong); }
|
||
.school h3 a:hover { color:var(--brand); text-decoration:underline; }
|
||
.school h3 a:focus-visible { outline:none; }
|
||
.school:has(h3 a:focus-visible) { outline:2px solid var(--brand); outline-offset:2px; }
|
||
.meta { margin:0 0 12px; font-size:12.5px; color:var(--text-muted); }
|
||
.shared { display:flex; flex-wrap:wrap; gap:6px; margin-bottom:14px; }
|
||
.shared span {
|
||
font-size:11.5px; line-height:1.4; padding:4px 8px; border-radius:999px;
|
||
background:var(--brand-bg); color:var(--brand); border:1px solid transparent;
|
||
}
|
||
.shared span.loose { background:transparent; color:var(--text-muted); border-color:var(--border); }
|
||
.metric { margin-top:auto; padding-top:13px; border-top:1px solid var(--border); }
|
||
.value { font:700 26px/1.1 var(--font-display); letter-spacing:-.6px; }
|
||
.value.absent { font-size:15px; font-weight:600; color:var(--text-muted); letter-spacing:0; }
|
||
.metric .label { margin:4px 0 0; font-size:12px; color:var(--text-secondary); }
|
||
.metric .ref { margin:2px 0 0; font-size:12px; color:var(--text-muted); }
|
||
.add {
|
||
position:relative; z-index:1; margin-top:14px; width:100%; min-height:40px;
|
||
font:500 13px var(--font-ui); cursor:pointer; border-radius:var(--radius-md);
|
||
border:1px solid var(--border-strong); background:var(--bg-card); color:var(--brand);
|
||
}
|
||
.add:hover { border-color:var(--brand); background:var(--brand-bg); }
|
||
.add[aria-pressed="true"] { border-color:var(--brand); background:var(--brand-bg); font-weight:600; }
|
||
.add:focus-visible { outline:2px solid var(--brand); outline-offset:2px; }
|
||
|
||
.footer {
|
||
display:flex; flex-wrap:wrap; align-items:center; justify-content:space-between;
|
||
gap:14px; margin-top:20px; padding-top:18px; border-top:1px solid var(--border);
|
||
}
|
||
.footer p { margin:0; font-size:12.5px; color:var(--text-muted); }
|
||
.footer strong { display:block; font:600 14px var(--font-ui); color:var(--text-primary); }
|
||
/* Coral: the one decisive action in this section, and there is only one. */
|
||
.compare {
|
||
min-height:44px; padding:0 20px; border-radius:var(--radius-md); cursor:pointer;
|
||
font:600 14px var(--font-ui); background:var(--action); color:var(--action-on);
|
||
border:1px solid var(--action); text-decoration:none; display:inline-flex; align-items:center; gap:8px;
|
||
}
|
||
.compare:hover { background:var(--action-strong); border-color:var(--action-strong); }
|
||
.compare[aria-disabled="true"] { opacity:.45; pointer-events:none; }
|
||
.compare:focus-visible { outline:2px solid var(--action); outline-offset:3px; }
|
||
.caption { margin:18px 0 0; font-size:11.5px; color:var(--text-muted); }
|
||
.sr { position:absolute; width:1px; height:1px; padding:0; margin:-1px; overflow:hidden; clip:rect(0 0 0 0); white-space:nowrap; border:0; }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div class="page">
|
||
<header>
|
||
<div>
|
||
<h1>Similar schools nearby</h1>
|
||
<p>A new section on the school detail page. Fictional schools and figures; shipped
|
||
colour, type and section shell taken from <code>globals.css</code>.</p>
|
||
</div>
|
||
<button class="theme-switch" type="button" id="theme">Dark theme</button>
|
||
</header>
|
||
<div id="variants"></div>
|
||
</div>
|
||
|
||
<script>
|
||
const PIN = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M20 10c0 6-8 12-8 12s-8-6-8-12a8 8 0 0 1 16 0Z"/><circle cx="12" cy="10" r="3"/></svg>';
|
||
|
||
const variants = [
|
||
{
|
||
id: 'primary',
|
||
eyebrow: 'Variant 1 · Primary page, full intake match',
|
||
context: 'Meadowbrook Primary School — Ages 4–11 · Mixed · No religious character · Community school',
|
||
lede: 'Other primary schools near Meadowbrook Primary School, with a similar intake.',
|
||
metric: 'Reading, writing & maths',
|
||
caption: 'Meeting the expected standard at key stage 2, 2025.',
|
||
thisValue: '72%',
|
||
note: 'Every card qualified at <b>tier 1</b>: same phase, same gender intake, same religious character, within 3 miles. The chips state exactly what is shared.',
|
||
schools: [
|
||
{ name:'Willow Lane Primary School', distance:'0.6', meta:'Community school · Ages 4–11', shared:['Mixed','No religious character'], value:'74%' },
|
||
{ name:'Oakfield Primary School', distance:'1.1', meta:'Academy converter · Ages 3–11', shared:['Mixed','No religious character'], value:'69%' },
|
||
{ name:'Brookside Primary School', distance:'1.8', meta:'Community school · Ages 4–11', shared:['Mixed','No religious character'], value:'Not published' },
|
||
],
|
||
},
|
||
{
|
||
id: 'secondary',
|
||
eyebrow: 'Variant 2 · Secondary page, mixed tiers',
|
||
context: 'Meadowbrook High School — Ages 11–18 · Mixed · Non-selective · Academy',
|
||
lede: 'Other secondary schools near Meadowbrook High School, with a similar intake.',
|
||
metric: 'Attainment 8',
|
||
caption: 'Average GCSE attainment score across eight qualifications, 2025.',
|
||
thisValue: '51.2',
|
||
note: 'The third card only qualified once the religious-character preference was relaxed at <b>tier 2</b>. Its chip set says so by omission rather than claiming a match it does not have — and selectivity is never relaxed, so no grammar school can appear here.',
|
||
schools: [
|
||
{ name:'Rivermead High School', distance:'0.9', meta:'Academy converter · Ages 11–18', shared:['Mixed','Non-selective','No religious character'], value:'52.8' },
|
||
{ name:'Oakfield Academy', distance:'1.7', meta:'Academy sponsor led · Ages 11–16', shared:['Mixed','Non-selective','No religious character'], value:'49.6' },
|
||
{ name:'St Aidan’s Catholic High School', distance:'2.4', meta:'Voluntary aided · Ages 11–18', shared:['Mixed','Non-selective'], value:'53.4' },
|
||
],
|
||
},
|
||
{
|
||
id: 'sparse',
|
||
eyebrow: 'Variant 3 · Rural page, tier 3 fallback',
|
||
context: 'Little Ashby Church of England Primary School — Ages 4–11 · Mixed · Church of England · Voluntary controlled',
|
||
lede: 'Other primary schools near Little Ashby Church of England Primary School.',
|
||
metric: 'Reading, writing & maths',
|
||
caption: 'Meeting the expected standard at key stage 2, 2025.',
|
||
thisValue: '66%',
|
||
note: 'Nothing matched on religious character within range. At <b>tier 3</b> the lede drops the phrase “with a similar intake”, the chips fall back to the plain phase, and only two schools are offered rather than padding to three. One school fewer than this and the section would not render at all.',
|
||
schools: [
|
||
{ name:'Great Marden Primary School', distance:'4.2', meta:'Community school · Ages 4–11', shared:['Primary school'], looseFrom:0, value:'71%' },
|
||
{ name:'Ashby Vale Academy', distance:'7.8', meta:'Academy converter · Ages 4–11', shared:['Primary school'], looseFrom:0, value:'58%' },
|
||
],
|
||
},
|
||
];
|
||
|
||
const selected = Object.fromEntries(variants.map((v) => [v.id, new Set()]));
|
||
|
||
function schoolCard(v, s, i) {
|
||
const on = selected[v.id].has(i);
|
||
const chips = s.shared.map((label, n) => {
|
||
const loose = s.looseFrom !== undefined && n >= s.looseFrom;
|
||
return `<span class="${loose ? 'loose' : ''}">${label}</span>`;
|
||
}).join('');
|
||
const absent = s.value === 'Not published';
|
||
return `
|
||
<article class="school">
|
||
<p class="distance">${PIN}${s.distance} miles away</p>
|
||
<h3><a href="#">${s.name}</a></h3>
|
||
<p class="meta">${s.meta}</p>
|
||
<div class="shared">${chips}</div>
|
||
<div class="metric">
|
||
<p class="value ${absent ? 'absent' : ''}">${s.value}</p>
|
||
<p class="label">${v.metric}</p>
|
||
<p class="ref">${v.thisValue} at this school</p>
|
||
</div>
|
||
<button class="add" type="button" data-variant="${v.id}" data-index="${i}" aria-pressed="${on}">
|
||
${on ? '✓ Added to compare' : '+ Add to compare'}
|
||
<span class="sr">${s.name}</span>
|
||
</button>
|
||
</article>`;
|
||
}
|
||
|
||
function render() {
|
||
document.getElementById('variants').innerHTML = variants.map((v) => {
|
||
const count = selected[v.id].size;
|
||
return `
|
||
<section class="variant">
|
||
<div class="context">
|
||
<p class="eyebrow">${v.eyebrow}</p>
|
||
<h2>${v.context}</h2>
|
||
</div>
|
||
<div class="card">
|
||
<h2>Similar schools nearby</h2>
|
||
<p class="lede">${v.lede}</p>
|
||
<details class="method">
|
||
<summary>How these schools are chosen</summary>
|
||
<div>
|
||
<p>Schools of the same phase, nearest first, preferring those whose intake
|
||
resembles this one — gender, religious character, and whether the school
|
||
selects by ability. Special schools are only ever compared with other
|
||
special schools.</p>
|
||
<p>The chips on each card list what that school actually shares with this
|
||
one. Where nothing close enough was found nearby, the match is loosened
|
||
and the chips say less.</p>
|
||
<p>Distances are straight-line from this school, not road distance and not
|
||
from your home. Being listed here is not a recommendation, and the
|
||
results shown are context rather than a measure of fit.</p>
|
||
</div>
|
||
</details>
|
||
<div class="grid">${v.schools.map((s, i) => schoolCard(v, s, i)).join('')}</div>
|
||
<div class="footer">
|
||
<p aria-live="polite">
|
||
<strong>${count ? `${count} school${count === 1 ? '' : 's'} selected` : 'Compare side by side'}</strong>
|
||
${count ? 'This school is included automatically.' : 'Add a school to compare it with this one.'}
|
||
</p>
|
||
<a class="compare" href="#" aria-disabled="${count ? 'false' : 'true'}">
|
||
${count ? `Compare ${count + 1} schools` : 'Compare'} →
|
||
</a>
|
||
</div>
|
||
<p class="caption">${v.caption} Fictional schools and figures for this mockup.</p>
|
||
</div>
|
||
<p class="note">${v.note}</p>
|
||
</section>`;
|
||
}).join('');
|
||
}
|
||
|
||
document.getElementById('variants').addEventListener('click', (event) => {
|
||
const button = event.target.closest('.add');
|
||
if (!button) return;
|
||
const { variant, index } = button.dataset;
|
||
const set = selected[variant];
|
||
const i = Number(index);
|
||
set.has(i) ? set.delete(i) : set.add(i);
|
||
render();
|
||
document.querySelector(`.add[data-variant="${variant}"][data-index="${index}"]`).focus();
|
||
});
|
||
|
||
const themeButton = document.getElementById('theme');
|
||
themeButton.addEventListener('click', () => {
|
||
const dark = document.documentElement.dataset.theme === 'dark';
|
||
document.documentElement.dataset.theme = dark ? 'light' : 'dark';
|
||
themeButton.textContent = dark ? 'Dark theme' : 'Light theme';
|
||
});
|
||
|
||
render();
|
||
</script>
|
||
</body>
|
||
</html>
|