feat(secondary): apply hero design language to secondary school detail view
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 13s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 46s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 12s
Build and Push Docker Images / Trigger Portainer Update (push) Successful in 1s

- Bump school name to clamp(2rem,5vw,3.25rem) Playfair Display
- Add hero signal chips strip (framework-aware Ofsted + coral Oversubscribed)
- Add at-a-glance stats row: Att8 with delta vs national, Ofsted serif tile, first-choice rate
- Active section highlighting in sticky nav via IntersectionObserver
- Collapse OEIF Ofsted section to prose when all sub-grades match overall
- Pass nationalAtt8Avg reference line to PerformanceChart

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Tudor Sitaru
2026-04-08 21:05:33 +01:00
parent e625addc3b
commit 23f881b797
2 changed files with 313 additions and 22 deletions
@@ -26,11 +26,11 @@
} }
.schoolName { .schoolName {
font-size: 1.5rem; font-size: clamp(2rem, 5vw, 3.25rem);
font-weight: 700; font-weight: 700;
color: var(--text-primary, #1a1612); color: var(--text-primary, #1a1612);
margin-bottom: 0.5rem; margin-bottom: 0.5rem;
line-height: 1.2; line-height: 1.15;
font-family: var(--font-playfair), 'Playfair Display', serif; font-family: var(--font-playfair), 'Playfair Display', serif;
overflow-wrap: break-word; overflow-wrap: break-word;
} }
@@ -207,6 +207,17 @@
color: var(--text-primary, #1a1612); color: var(--text-primary, #1a1612);
} }
.tabBtnActive {
background: var(--accent-coral, #e07256);
color: white;
font-weight: 600;
}
.tabBtnActive:hover {
background: var(--accent-coral-dark, #c45a3f);
color: white;
}
/* ── Card ────────────────────────────────────────────── */ /* ── Card ────────────────────────────────────────────── */
.card { .card {
background: var(--bg-card, white); background: var(--bg-card, white);
@@ -652,6 +663,145 @@
margin-top: 0.25rem; margin-top: 0.25rem;
} }
/* ── Ofsted all-same collapse ────────────────────────── */
.ofstedAllSame {
font-size: 0.9375rem;
color: var(--text-secondary, #5c564d);
margin: 0.5rem 0 0;
line-height: 1.5;
}
.ofstedAllSame strong {
color: var(--text-primary, #1a1612);
}
/* ── Hero chips strip ────────────────────────────────── */
.heroChips {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
margin-top: 1.25rem;
}
.heroChip {
flex: 0 0 240px;
padding: 0.75rem 1rem;
border-radius: 8px;
border-left: 3px solid var(--border-color, #e5dfd5);
background: var(--bg-secondary, #f3ede4);
color: var(--text-primary, #1a1612);
display: flex;
flex-direction: column;
gap: 0.15rem;
}
.heroChipTitle {
font-size: 0.9375rem;
font-weight: 700;
line-height: 1.3;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.heroChipSub {
font-size: 0.75rem;
color: var(--text-secondary, #5c564d);
line-height: 1.4;
}
.heroChipDetail {
font-size: 0.75rem;
font-weight: 600;
line-height: 1.4;
margin-top: 0.1rem;
}
/* Hero tone system */
.tone-teal { --hero-tone: var(--accent-teal, #2d7d7d); }
.tone-green { --hero-tone: #3c8c3c; }
.tone-gold { --hero-tone: var(--accent-gold, #c9a227); }
.tone-coral { --hero-tone: var(--accent-coral, #e07256); }
.tone-neutral { --hero-tone: var(--text-muted, #8a847a); }
.heroChip.tone-teal,
.heroChip.tone-green,
.heroChip.tone-gold,
.heroChip.tone-coral,
.heroChip.tone-neutral {
border-left-color: var(--hero-tone);
background: color-mix(in srgb, var(--hero-tone) 10%, var(--bg-card, white));
}
.heroChip.tone-neutral {
background: var(--bg-secondary, #f3ede4);
}
/* ── Hero at-a-glance stats ──────────────────────────── */
.heroStats {
display: flex;
flex-wrap: wrap;
gap: 1.25rem 3rem;
margin-top: 1.5rem;
padding-top: 1.5rem;
border-top: 1px solid var(--border-color, #e5dfd5);
}
.heroStat {
display: flex;
flex-direction: column;
gap: 0.35rem;
min-width: 0;
flex: 0 0 auto;
}
.heroStatNumber,
.heroStatNumberSerif {
font-family: var(--font-playfair), 'Playfair Display', serif;
font-weight: 700;
line-height: 1;
color: var(--text-primary, #1a1612);
min-height: clamp(2rem, 4vw, 2.75rem);
display: flex;
align-items: flex-end;
}
.heroStatNumber {
font-size: clamp(2rem, 4vw, 2.75rem);
font-variant-numeric: tabular-nums;
}
.heroStatNumberSerif {
font-size: clamp(1.75rem, 3.5vw, 2.25rem);
}
.heroStatNumberSerif.tone-teal,
.heroStatNumberSerif.tone-green,
.heroStatNumberSerif.tone-gold,
.heroStatNumberSerif.tone-coral,
.heroStatNumberSerif.tone-neutral {
color: var(--hero-tone);
}
.heroStatLabel {
font-size: 0.6875rem;
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text-secondary, #5c564d);
}
.heroStatFoot {
font-size: 0.75rem;
color: var(--text-muted, #8a847a);
}
.heroDataNote {
margin: 0.5rem 0 0;
font-size: 0.75rem;
color: var(--text-muted, #8a847a);
}
/* ── Responsive ──────────────────────────────────────── */ /* ── Responsive ──────────────────────────────────────── */
@media (max-width: 768px) { @media (max-width: 768px) {
.header { .header {
@@ -673,7 +823,6 @@
} }
.schoolName { .schoolName {
font-size: 1.25rem;
word-break: break-word; word-break: break-word;
} }
@@ -733,6 +882,19 @@
.admissionsTypeBadge { .admissionsTypeBadge {
font-size: 0.75rem; font-size: 0.75rem;
} }
.heroChips {
gap: 0.5rem;
margin-top: 1rem;
}
.heroChip {
min-width: 100%;
}
.heroStats {
gap: 1rem 1.5rem;
}
} }
@media (max-width: 480px) { @media (max-width: 480px) {
@@ -18,7 +18,8 @@ import type {
SchoolAdmissions, SenDetail, Phonics, SchoolAdmissions, SenDetail, Phonics,
SchoolDeprivation, SchoolFinance, NationalAverages, SchoolDeprivation, SchoolFinance, NationalAverages,
} from '@/lib/types'; } from '@/lib/types';
import { formatPercentage, formatProgress, formatAcademicYear } from '@/lib/utils'; import { formatPercentage, formatProgress, formatAcademicYear, buildOfstedHeroChip } from '@/lib/utils';
import { DeltaChip } from './DeltaChip';
import styles from './SecondarySchoolDetailView.module.css'; import styles from './SecondarySchoolDetailView.module.css';
const OFSTED_LABELS: Record<number, string> = { const OFSTED_LABELS: Record<number, string> = {
@@ -75,6 +76,8 @@ export function SecondarySchoolDetailView({
const { addSchool, removeSchool, isSelected } = useComparison(); const { addSchool, removeSchool, isSelected } = useComparison();
const isInComparison = isSelected(schoolInfo.urn); const isInComparison = isSelected(schoolInfo.urn);
const [activeSection, setActiveSection] = useState<string>('');
const latestResults = yearlyData.length > 0 ? yearlyData[yearlyData.length - 1] : null; const latestResults = yearlyData.length > 0 ? yearlyData[yearlyData.length - 1] : null;
const [nationalAvg, setNationalAvg] = useState<NationalAverages | null>(null); const [nationalAvg, setNationalAvg] = useState<NationalAverages | null>(null);
@@ -124,6 +127,50 @@ export function SecondarySchoolDetailView({
if (hasFinance) navItems.push({ id: 'finances', label: 'Finances' }); if (hasFinance) navItems.push({ id: 'finances', label: 'Finances' });
if (yearlyData.length > 1) navItems.push({ id: 'history', label: 'History' }); if (yearlyData.length > 1) navItems.push({ id: 'history', label: 'History' });
// Track active section as user scrolls
useEffect(() => {
const ids = navItems.map(n => n.id);
if (!ids.length) return;
const observers: IntersectionObserver[] = [];
const ratioMap: Record<string, number> = {};
const pickActive = () => {
const top = Object.entries(ratioMap).sort((a, b) => b[1] - a[1])[0];
setActiveSection(top?.[1] > 0 ? top[0] : '');
};
ids.forEach(id => {
const el = document.getElementById(id);
if (!el) return;
ratioMap[id] = 0;
const obs = new IntersectionObserver(
([entry]) => { ratioMap[id] = entry.intersectionRatio; pickActive(); },
{ threshold: [0, 0.1, 0.25, 0.5, 0.75, 1.0], rootMargin: '-56px 0px 0px 0px' },
);
obs.observe(el);
observers.push(obs);
});
return () => observers.forEach(o => o.disconnect());
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [navItems.map(n => n.id).join(',')]);
// ── Ofsted: detect if all OEIF sub-grades match the overall ───────────
const oeifAllSameGrade = (() => {
if (!ofsted || ofsted.framework === 'ReportCard') return false;
const subs = [
ofsted.quality_of_education,
ofsted.behaviour_attitudes,
ofsted.personal_development,
ofsted.leadership_management,
...(ofsted.early_years_provision != null ? [ofsted.early_years_provision] : []),
].filter((v): v is number => v != null);
return subs.length >= 3 && subs.every(v => v === ofsted.overall_effectiveness);
})();
// ── Hero signal chip & stats ─────────────────────────────────────────
const ofstedHeroChip = buildOfstedHeroChip(ofsted);
const heroAtt8 = latestResults?.attainment_8_score ?? null;
const heroAtt8Nat = secondaryAvg.attainment_8_score ?? null;
const heroAcademicYear = latestResults ? formatAcademicYear(latestResults.year) : '';
return ( return (
<div className={styles.container}> <div className={styles.container}>
{/* ── Header ─────────────────────────────────────── */} {/* ── Header ─────────────────────────────────────── */}
@@ -190,6 +237,75 @@ export function SecondarySchoolDetailView({
</button> </button>
</div> </div>
</div> </div>
{/* Hero signal chips */}
<div className={styles.heroChips}>
<div className={`${styles.heroChip} ${styles[`tone-${ofstedHeroChip.tone}`]}`}>
<div className={styles.heroChipTitle}>{ofstedHeroChip.title}</div>
<div className={styles.heroChipSub}>{ofstedHeroChip.subtitle}</div>
{ofstedHeroChip.detail && (
<div className={styles.heroChipDetail}>{ofstedHeroChip.detail}</div>
)}
</div>
{admissions?.oversubscribed && (
<div className={`${styles.heroChip} ${styles['tone-coral']}`}>
<div className={styles.heroChipTitle}>Oversubscribed</div>
<div className={styles.heroChipSub}>
{admissions.first_preference_offer_pct != null
? `${Math.round(admissions.first_preference_offer_pct)}% of first-choice applicants offered a place`
: 'More applicants than places'}
</div>
</div>
)}
</div>
{/* At-a-glance stats row */}
{latestResults && (
<div className={styles.heroStats}>
{heroAtt8 != null && (
<div className={styles.heroStat}>
<div className={styles.heroStatNumber}>{heroAtt8.toFixed(1)}</div>
<div className={styles.heroStatLabel}>Attainment 8 score</div>
{heroAtt8Nat != null && (
<DeltaChip value={heroAtt8} baseline={heroAtt8Nat} unit="pts" suffix="vs national" />
)}
</div>
)}
{ofsted && (
<div className={styles.heroStat}>
<div className={`${styles.heroStatNumberSerif} ${styles[`tone-${ofstedHeroChip.tone}`]}`}>
{ofstedHeroChip.state === 'oeif'
? ofstedHeroChip.title.replace(/^Ofsted\s+/, '')
: ofstedHeroChip.state === 'reportCard'
? 'Report Card'
: '—'}
</div>
<div className={styles.heroStatLabel}>{ofstedHeroChip.subtitle}</div>
{ofstedHeroChip.detail && (
<div className={styles.heroStatFoot}>{ofstedHeroChip.detail}</div>
)}
</div>
)}
{admissions?.first_preference_offer_pct != null && (
<div className={styles.heroStat}>
<div className={styles.heroStatNumber}>
{Math.round(admissions.first_preference_offer_pct)}%
</div>
<div className={styles.heroStatLabel}>First-choice offer rate</div>
{admissions.oversubscribed && (
<div className={styles.heroStatFoot}>Oversubscribed</div>
)}
</div>
)}
</div>
)}
{heroAcademicYear && (
<p className={styles.heroDataNote}>Latest data: {heroAcademicYear}</p>
)}
</header> </header>
{/* ── Sticky section navigation ─────────────────────── */} {/* ── Sticky section navigation ─────────────────────── */}
@@ -198,7 +314,13 @@ export function SecondarySchoolDetailView({
<button onClick={() => router.back()} className={styles.backBtn}> Back</button> <button onClick={() => router.back()} className={styles.backBtn}> Back</button>
{navItems.length > 0 && <div className={styles.tabNavDivider} />} {navItems.length > 0 && <div className={styles.tabNavDivider} />}
{navItems.map(({ id, label }) => ( {navItems.map(({ id, label }) => (
<a key={id} href={`#${id}`} className={styles.tabBtn}>{label}</a> <a
key={id}
href={`#${id}`}
className={`${styles.tabBtn}${activeSection === id ? ` ${styles.tabBtnActive}` : ''}`}
>
{label}
</a>
))} ))}
</div> </div>
</nav> </nav>
@@ -265,6 +387,11 @@ export function SecondarySchoolDetailView({
<p className={styles.ofstedDisclaimer}> <p className={styles.ofstedDisclaimer}>
From September 2024, Ofsted no longer makes an overall effectiveness judgement in inspections. From September 2024, Ofsted no longer makes an overall effectiveness judgement in inspections.
</p> </p>
{oeifAllSameGrade ? (
<p className={styles.ofstedAllSame}>
Rated <strong>{OFSTED_LABELS[ofsted.overall_effectiveness]}</strong> across all inspected areas Quality of Teaching, Behaviour, Pupils&apos; Development and Leadership.
</p>
) : (
<div className={styles.metricsGrid}> <div className={styles.metricsGrid}>
{[ {[
{ label: 'Quality of Teaching', value: ofsted.quality_of_education }, { label: 'Quality of Teaching', value: ofsted.quality_of_education },
@@ -283,6 +410,7 @@ export function SecondarySchoolDetailView({
</div> </div>
))} ))}
</div> </div>
)}
</> </>
) : ( ) : (
<> <>
@@ -489,6 +617,7 @@ export function SecondarySchoolDetailView({
data={yearlyData} data={yearlyData}
schoolName={schoolInfo.school_name} schoolName={schoolInfo.school_name}
isSecondary={true} isSecondary={true}
nationalAtt8Avg={heroAtt8Nat}
/> />
</div> </div>
</> </>