feat(destinations): the After Year 11 section
Question cards over one bar, with the cards acting as a lens on the bar rather than a summary beside it — focusing a card dims everything it is not made of, so the grouping we chose is inspectable rather than asserted. The bar renders only when canRenderBar allows it. Where a category is withheld the section says so and shows the table instead: the categories sum to the cohort, so a bar drawn from the published segments leaves a gap whose width is the withheld figure. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BvdDKvFFSZuMVDH5fEyTob
This commit is contained in:
1 parent
68b6417149
commit
cef2f77149
7 files changed
+680
-2
No files matched your search
@@ -0,0 +1,92 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { DestinationsSection } from '@/components/school/DestinationsSection';
|
||||
import type { DestinationPhase } from '@/lib/types';
|
||||
import type { DestinationCategory, DestinationStatus } from '@/lib/destinations';
|
||||
|
||||
const cell = (
|
||||
category: DestinationCategory,
|
||||
pupils: number | null,
|
||||
status: DestinationStatus = 'published',
|
||||
) => ({
|
||||
category, pupils,
|
||||
percentage: pupils === null ? null : (pupils / 180) * 100,
|
||||
status,
|
||||
});
|
||||
|
||||
const ALL_PUBLISHED = [
|
||||
cell('school_sixth_form', 75), cell('sixth_form_college', 21),
|
||||
cell('further_education', 55), cell('other_education', 6),
|
||||
cell('apprenticeship', 8), cell('employment', 6),
|
||||
cell('not_sustained', 5), cell('not_captured', 4),
|
||||
];
|
||||
|
||||
const fullPhase: DestinationPhase = {
|
||||
cohort_year: '2022/23',
|
||||
groups: { all: { cohort: 180, categories: ALL_PUBLISHED, aggregates: {} } },
|
||||
};
|
||||
|
||||
const suppressedPhase: DestinationPhase = {
|
||||
cohort_year: '2022/23',
|
||||
groups: {
|
||||
all: {
|
||||
cohort: 180,
|
||||
categories: [
|
||||
cell('school_sixth_form', 75), cell('sixth_form_college', null, 'suppressed'),
|
||||
cell('further_education', 55), cell('other_education', 6),
|
||||
cell('apprenticeship', 8), cell('employment', 6),
|
||||
cell('not_sustained', 5), cell('not_captured', 4),
|
||||
],
|
||||
aggregates: {},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
describe('DestinationsSection', () => {
|
||||
it('dates its own cohort so it is not read as stale next to the GCSE section', () => {
|
||||
render(<DestinationsSection destinations={fullPhase} />);
|
||||
expect(screen.getByText(/2022\/23/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders one bar segment per published category', () => {
|
||||
const { container } = render(<DestinationsSection destinations={fullPhase} />);
|
||||
expect(container.querySelectorAll('[data-destination-segment]')).toHaveLength(8);
|
||||
});
|
||||
|
||||
it('renders NO bar at all when a category is withheld', () => {
|
||||
const { container } = render(<DestinationsSection destinations={suppressedPhase} />);
|
||||
// R1: a bar with a gap in it publishes the withheld figure by its width.
|
||||
expect(container.querySelectorAll('[data-destination-segment]')).toHaveLength(0);
|
||||
expect(screen.getAllByText(/withheld/i).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('never states the remainder for a partially suppressed group', () => {
|
||||
const { container } = render(<DestinationsSection destinations={suppressedPhase} />);
|
||||
// 180 cohort - 159 published = 21, the withheld figure. It must appear nowhere.
|
||||
expect(container.textContent).not.toMatch(/\b21\b/);
|
||||
});
|
||||
|
||||
it('shows a card value for a group whose components are all published', () => {
|
||||
render(<DestinationsSection destinations={fullPhase} />);
|
||||
// academic route = 75 + 21 = 96 of 180 = 53%
|
||||
expect(screen.getByText('53%')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('refuses a card value when one of its components is withheld', () => {
|
||||
render(<DestinationsSection destinations={suppressedPhase} />);
|
||||
// academic route needs sixth_form_college, which is suppressed.
|
||||
expect(screen.getByText(/not published/i)).toBeInTheDocument();
|
||||
expect(screen.queryByText('53%')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('never claims a pupil stayed at this school', () => {
|
||||
const { container } = render(<DestinationsSection destinations={fullPhase} />);
|
||||
// The published file reports destination TYPE, never destination institution.
|
||||
expect(container.textContent).not.toMatch(/stayed on (here|at this school)/i);
|
||||
});
|
||||
|
||||
it('renders nothing when no group carries categories', () => {
|
||||
const empty: DestinationPhase = { cohort_year: '2022/23', groups: {} };
|
||||
const { container } = render(<DestinationsSection destinations={empty} />);
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -148,7 +148,7 @@ export default async function SchoolPage({ params }: SchoolPageProps) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const { school_info, yearly_data, absence_data, ofsted, census, admissions, admissions_history, admission_distance, deprivation, finance } = data;
|
||||
const { school_info, yearly_data, absence_data, ofsted, census, admissions, admissions_history, admission_distance, deprivation, finance, destinations } = data;
|
||||
|
||||
// Redirect bare URN to canonical slug URL
|
||||
const canonicalSlug = schoolUrl(urn, school_info.school_name).replace('/school/', '');
|
||||
@@ -171,6 +171,7 @@ export default async function SchoolPage({ params }: SchoolPageProps) {
|
||||
schoolInfo: school_info, yearlyData: yearly_data,
|
||||
absenceData: absence_data, census: census ?? null,
|
||||
deprivation: deprivation ?? null, finance: finance ?? null,
|
||||
destinations: destinations ?? null,
|
||||
};
|
||||
const primaryFlags = computeSchoolFlags(sectionInput);
|
||||
const secondaryFlags = computeSecondaryFlags(sectionInput);
|
||||
@@ -236,6 +237,7 @@ export default async function SchoolPage({ params }: SchoolPageProps) {
|
||||
deprivation={deprivation ?? null}
|
||||
finance={finance ?? null}
|
||||
nationalAvg={nationalAvg}
|
||||
destinations={destinations ?? null}
|
||||
flags={secondaryFlags}
|
||||
/>
|
||||
</SchoolDetailShell>
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* DestinationsSection — where a school's Year 11 leavers went. Server component.
|
||||
*
|
||||
* The headline is deliberately NOT the sustained-destination rate. That figure
|
||||
* sits between 92% and 97% for nearly every school in England, so leading with
|
||||
* it would say nothing; the mix is what actually varies between schools.
|
||||
*
|
||||
* The section dates its own cohort because destination measures are published
|
||||
* about two GCSE years behind the results in the section above — undated, the
|
||||
* figures read as stale rather than as a different question.
|
||||
*/
|
||||
|
||||
import type { DestinationPhase } from '@/lib/types';
|
||||
import { Section, sectionStyles } from './sectionShared';
|
||||
import { DestinationsView } from './DestinationsView';
|
||||
|
||||
export function DestinationsSection({ destinations }: { destinations: DestinationPhase }) {
|
||||
const all = destinations.groups.all;
|
||||
const hasContent = Object.values(destinations.groups)
|
||||
.some(group => (group?.categories?.length ?? 0) > 0);
|
||||
if (!hasContent) return null;
|
||||
|
||||
const cohort = all?.cohort ?? null;
|
||||
const year = destinations.cohort_year;
|
||||
|
||||
return (
|
||||
<Section id="destinations">
|
||||
<h2 className={sectionStyles.sectionTitle}>After Year 11</h2>
|
||||
<p className={sectionStyles.sectionSubtitle}>
|
||||
Where {cohort ? `the ${cohort} pupils` : 'the pupils'} who left Year 11
|
||||
{year ? ` in ${year}` : ''} were during the following year. The Department
|
||||
for Education tracks leavers for two terms, so these figures cover an
|
||||
earlier year group than the GCSE results above.
|
||||
</p>
|
||||
<DestinationsView destinations={destinations} phase="ks4" />
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* DestinationsView — the interactive body of both destination sections.
|
||||
*
|
||||
* Three question cards over one bar, with the cards acting as a lens on the
|
||||
* bar rather than a summary beside it: focusing a card dims everything the
|
||||
* card is not made of, so the grouping we chose is inspectable rather than
|
||||
* asserted.
|
||||
*
|
||||
* Everything here defers to lib/destinations.ts for what may be shown. In
|
||||
* particular the bar is rendered only when canRenderBar() allows it: the
|
||||
* destination categories sum to the cohort, so a bar drawn from the published
|
||||
* segments leaves a gap whose width IS the withheld figure.
|
||||
*
|
||||
* The one client component in this directory besides AdmissionsViewToggle.
|
||||
* The all-pupils view is what the server renders into the HTML; the switch and
|
||||
* the hover linkage are the only parts that need the browser.
|
||||
*/
|
||||
|
||||
import { useState } from 'react';
|
||||
import type { DestinationPhase, DestinationGroupPayload } from '@/lib/types';
|
||||
import {
|
||||
CARD_GROUPS, CARD_QUESTIONS, CATEGORY_LABELS, CATEGORY_ORDER,
|
||||
aggregateCells, canRenderBar, toBarSegments, cardGroupFor,
|
||||
type CardGroup, type DestinationCell, type DestinationGroup, type PupilGroup,
|
||||
} from '@/lib/destinations';
|
||||
import styles from './destinations.module.css';
|
||||
|
||||
const GROUP_LABELS: Record<PupilGroup, string> = {
|
||||
all: 'All pupils',
|
||||
disadvantaged: 'Disadvantaged',
|
||||
other: 'All other pupils',
|
||||
};
|
||||
|
||||
const GROUP_ORDER: PupilGroup[] = ['all', 'disadvantaged', 'other'];
|
||||
|
||||
function toGroup(payload: DestinationGroupPayload): DestinationGroup {
|
||||
return {
|
||||
cohort: payload.cohort ?? 0,
|
||||
cells: payload.categories,
|
||||
aggregates: payload.aggregates,
|
||||
};
|
||||
}
|
||||
|
||||
function cellsFor(group: DestinationGroup, card: CardGroup): DestinationCell[] {
|
||||
const wanted = new Set(CARD_GROUPS[card]);
|
||||
return group.cells.filter(c => wanted.has(c.category));
|
||||
}
|
||||
|
||||
export function DestinationsView({
|
||||
destinations, phase,
|
||||
}: { destinations: DestinationPhase; phase: 'ks4' | 'ks5' }) {
|
||||
const available = GROUP_ORDER.filter(
|
||||
g => (destinations.groups[g]?.categories?.length ?? 0) > 0,
|
||||
);
|
||||
const [selected, setSelected] = useState<PupilGroup>(available[0] ?? 'all');
|
||||
const [focused, setFocused] = useState<CardGroup | null>(null);
|
||||
|
||||
const payload = destinations.groups[selected];
|
||||
if (!payload) return null;
|
||||
const group = toGroup(payload);
|
||||
|
||||
const barDrawable = canRenderBar(group);
|
||||
const segments = barDrawable ? toBarSegments(group) : [];
|
||||
const withheld = group.cells.filter(c => c.status === 'suppressed');
|
||||
|
||||
const dimmed = (card: CardGroup | null) => focused !== null && focused !== card;
|
||||
|
||||
return (
|
||||
<div className={styles.view}>
|
||||
{available.length > 1 && (
|
||||
<div className={styles.switchRow}>
|
||||
<span className={styles.switchLabel} id={`${phase}-cohort-label`}>Show</span>
|
||||
<div
|
||||
className={styles.switchButtons}
|
||||
role="radiogroup"
|
||||
aria-labelledby={`${phase}-cohort-label`}
|
||||
>
|
||||
{available.map(g => (
|
||||
<button
|
||||
key={g}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={selected === g}
|
||||
className={styles.switchButton}
|
||||
onClick={() => { setSelected(g); setFocused(null); }}
|
||||
>
|
||||
{GROUP_LABELS[g]}
|
||||
<span className={styles.switchCount}>
|
||||
{destinations.groups[g]?.cohort ?? ''}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={styles.cards}>
|
||||
{(Object.keys(CARD_GROUPS) as CardGroup[]).map(card => {
|
||||
const cells = cellsFor(group, card);
|
||||
if (cells.length === 0) return null;
|
||||
const total = aggregateCells(cells, group.cohort);
|
||||
const { question, hint } = CARD_QUESTIONS[card];
|
||||
const keys = cells.map(c => (
|
||||
<span key={c.category} className={`${styles.swatch} ${styles[c.category]}`} />
|
||||
));
|
||||
|
||||
if (total === null) {
|
||||
return (
|
||||
<div key={card} className={`${styles.card} ${styles.cardWithheld}`}>
|
||||
<span className={styles.cardQuestion}>{question}</span>
|
||||
<span className={styles.cardWithheldValue}>Not published</span>
|
||||
<span className={styles.cardHint}>
|
||||
Too few pupils went to {hint} for the Department for Education
|
||||
to release a figure.
|
||||
</span>
|
||||
<span className={styles.cardKeys}>{keys}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
key={card}
|
||||
type="button"
|
||||
className={`${styles.card} ${dimmed(card) ? styles.dim : ''}`}
|
||||
data-group={card}
|
||||
onMouseEnter={() => setFocused(card)}
|
||||
onMouseLeave={() => setFocused(null)}
|
||||
onFocus={() => setFocused(card)}
|
||||
onBlur={() => setFocused(null)}
|
||||
>
|
||||
<span className={styles.cardQuestion}>{question}</span>
|
||||
<span className={styles.cardValue}>{Math.round(total.percentage)}%</span>
|
||||
<span className={styles.cardHint}>went to {hint}.</span>
|
||||
<span className={styles.cardKeys}>{keys}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{barDrawable ? (
|
||||
<div className={styles.barBlock}>
|
||||
<div className={styles.bar}>
|
||||
{segments.map(seg => {
|
||||
const card = cardGroupFor(seg.category);
|
||||
return (
|
||||
<div
|
||||
key={seg.category}
|
||||
data-destination-segment={seg.category}
|
||||
data-group={card ?? 'none'}
|
||||
className={`${styles.segment} ${styles[seg.category]} ${dimmed(card) ? styles.dim : ''}`}
|
||||
style={{ width: `${seg.widthPct}%` }}
|
||||
title={`${CATEGORY_LABELS[seg.category]} — ${seg.labelPct}% (${seg.pupils} pupils)`}
|
||||
>
|
||||
{seg.widthPct >= 9 ? `${seg.labelPct}%` : ''}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className={styles.barScale}>
|
||||
<span>0%</span><span>25%</span><span>50%</span><span>75%</span><span>100%</span>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className={styles.withheldPanel}>
|
||||
<strong className={styles.withheldTitle}>
|
||||
No breakdown chart for this group
|
||||
</strong>
|
||||
<p className={styles.withheldBody}>
|
||||
{withheld.length === 1
|
||||
? 'One of the destinations is withheld'
|
||||
: `${withheld.length} of the destinations are withheld`}
|
||||
{' '}because too few pupils went there. These destinations add up to
|
||||
the whole year group, so drawing the rest as a chart would give the
|
||||
withheld figures away. The table below shows what was published,
|
||||
and nothing more.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={styles.tableWrap}>
|
||||
<table className={styles.table}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Destination</th>
|
||||
<th scope="col">Pupils</th>
|
||||
<th scope="col">Share</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{CATEGORY_ORDER.flatMap(category => {
|
||||
const cell = group.cells.find(c => c.category === category);
|
||||
if (!cell) return [];
|
||||
const card = cardGroupFor(category);
|
||||
const isWithheld = cell.status === 'suppressed';
|
||||
return [(
|
||||
<tr
|
||||
key={category}
|
||||
data-group={card ?? 'none'}
|
||||
className={dimmed(card) ? styles.dim : ''}
|
||||
>
|
||||
<th scope="row" className={styles.rowName}>
|
||||
<span className={`${styles.swatch} ${styles[category]}`} />
|
||||
{CATEGORY_LABELS[category]}
|
||||
</th>
|
||||
<td>
|
||||
{isWithheld
|
||||
? <span className={styles.withheldMark}>withheld</span>
|
||||
: cell.pupils}
|
||||
</td>
|
||||
<td>
|
||||
{isWithheld || cell.percentage === null
|
||||
? <span className={styles.withheldMark}>withheld</span>
|
||||
: `${Math.round(cell.percentage)}%`}
|
||||
</td>
|
||||
</tr>
|
||||
)];
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<p className={styles.footnote}>
|
||||
Shares are rounded and may not add up to 100%. A pupil counted under a
|
||||
school sixth form may have moved to a different school's sixth
|
||||
form — the published data records the type of place, not which one.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -14,11 +14,13 @@
|
||||
import type {
|
||||
School, SchoolResult, AbsenceData, OfstedInspection, SchoolCensus,
|
||||
SchoolAdmissions, SchoolAdmissionDistance, SchoolDeprivation, SchoolFinance, NationalAverages,
|
||||
SchoolDestinations,
|
||||
} from '@/lib/types';
|
||||
import { ofstedLegacyAreas } from '@/lib/utils';
|
||||
import type { SecondaryFlags } from '@/lib/schoolSections';
|
||||
import { OfstedSection } from './OfstedSection';
|
||||
import { GcseSection } from './GcseSection';
|
||||
import { DestinationsSection } from './DestinationsSection';
|
||||
import { SecondaryAdmissionsSection } from './SecondaryAdmissionsSection';
|
||||
import { DistanceSection } from './DistanceSection';
|
||||
import { SecondaryHistorySection } from './SecondaryHistorySection';
|
||||
@@ -40,13 +42,14 @@ export interface SecondarySchoolSectionsProps {
|
||||
deprivation: SchoolDeprivation | null;
|
||||
finance: SchoolFinance | null;
|
||||
nationalAvg: NationalAverages | null;
|
||||
destinations: SchoolDestinations | null;
|
||||
flags: SecondaryFlags;
|
||||
}
|
||||
|
||||
export function SecondarySchoolSections({
|
||||
schoolInfo, yearlyData, ofsted, census,
|
||||
admissions, admissionsHistory, admissionDistance,
|
||||
deprivation, finance, nationalAvg, flags,
|
||||
deprivation, finance, nationalAvg, destinations, flags,
|
||||
}: SecondarySchoolSectionsProps) {
|
||||
const secondaryAvg = nationalAvg?.secondary ?? {};
|
||||
|
||||
@@ -85,6 +88,12 @@ export function SecondarySchoolSections({
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Destinations follow attainment: they answer "and then what happened",
|
||||
which only lands once the results are in view. */}
|
||||
{flags.hasKs4Destinations && destinations?.ks4 && (
|
||||
<DestinationsSection destinations={destinations.ks4} />
|
||||
)}
|
||||
|
||||
{/* See PrimarySchoolSections: distance and EES admissions are independent
|
||||
sources, so either one warrants the section. */}
|
||||
{(admissions || admissionDistance) && (
|
||||
|
||||
@@ -0,0 +1,299 @@
|
||||
/*
|
||||
* Destination sections.
|
||||
*
|
||||
* Every colour comes from the --dest-* tokens in globals.css, which are
|
||||
* defined in both themes. Nothing here is a literal colour — see
|
||||
* __tests__/components/darkThemeSafety.test.ts for why.
|
||||
*/
|
||||
|
||||
.view {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
/* ── Cohort switch ─────────────────────────────────────────────────────── */
|
||||
|
||||
.switchRow {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.switchLabel {
|
||||
font-size: var(--step--2);
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.switchButtons {
|
||||
display: inline-flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 2px;
|
||||
padding: 2px;
|
||||
background: var(--bg-secondary);
|
||||
border-radius: var(--radius-md);
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.switchButton {
|
||||
appearance: none;
|
||||
border: none;
|
||||
background: transparent;
|
||||
font: inherit;
|
||||
font-size: var(--step--1);
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
padding: 0.5rem 0.9rem;
|
||||
border-radius: calc(var(--radius-md) - 2px);
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
transition: background var(--transition), color var(--transition);
|
||||
}
|
||||
|
||||
.switchButton:hover { color: var(--text-primary); }
|
||||
.switchButton:focus-visible { outline: 2px solid var(--brand); outline-offset: 1px; }
|
||||
|
||||
.switchButton[aria-checked='true'] {
|
||||
background: var(--bg-card);
|
||||
color: var(--text-primary);
|
||||
box-shadow: var(--shadow-soft);
|
||||
}
|
||||
|
||||
.switchCount {
|
||||
margin-left: 0.4rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* ── Question cards ────────────────────────────────────────────────────── */
|
||||
|
||||
.cards {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(14rem, 1fr));
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
text-align: left;
|
||||
font: inherit;
|
||||
background: var(--brand-bg);
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-md);
|
||||
padding: 1.15rem 1.25rem;
|
||||
cursor: pointer;
|
||||
transition: border-color var(--transition), opacity var(--transition);
|
||||
}
|
||||
|
||||
.card:hover { border-color: var(--brand); }
|
||||
.card:focus-visible { outline: 2px solid var(--brand); outline-offset: 2px; }
|
||||
|
||||
.cardWithheld {
|
||||
background: var(--bg-secondary);
|
||||
border-style: dashed;
|
||||
border-color: var(--border-strong);
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.cardQuestion {
|
||||
font-family: var(--font-display);
|
||||
font-size: var(--step--1);
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
text-wrap: balance;
|
||||
}
|
||||
|
||||
.cardValue {
|
||||
font-family: var(--font-display);
|
||||
font-size: var(--step-3);
|
||||
font-weight: 800;
|
||||
line-height: 1.05;
|
||||
letter-spacing: -0.02em;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.cardWithheldValue {
|
||||
font-family: var(--font-display);
|
||||
font-size: var(--step-1);
|
||||
font-weight: 700;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.cardHint {
|
||||
font-size: var(--step--2);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.cardKeys {
|
||||
display: flex;
|
||||
gap: 0.3rem;
|
||||
margin-top: 0.2rem;
|
||||
}
|
||||
|
||||
.cardKeys .swatch {
|
||||
width: 1.5rem;
|
||||
height: 0.35rem;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
/* ── Bar ───────────────────────────────────────────────────────────────── */
|
||||
|
||||
.barBlock {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
/* 2px surface gaps between segments so adjacent fills stay distinguishable
|
||||
without a border darkening the palette. */
|
||||
.bar {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
height: 3rem;
|
||||
border-radius: var(--radius-sm);
|
||||
overflow: hidden;
|
||||
background: var(--bg-card);
|
||||
}
|
||||
|
||||
.segment {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
min-width: 2px;
|
||||
overflow: hidden;
|
||||
color: var(--text-inverse);
|
||||
font-size: var(--step--2);
|
||||
font-weight: 700;
|
||||
font-variant-numeric: tabular-nums;
|
||||
transition: opacity var(--transition);
|
||||
}
|
||||
|
||||
.barScale {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: var(--step--2);
|
||||
color: var(--text-muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* ── Category colours ──────────────────────────────────────────────────── */
|
||||
|
||||
.higher_education,
|
||||
.school_sixth_form { background: var(--dest-sixthform); }
|
||||
.sixth_form_college { background: var(--dest-sfcollege); }
|
||||
.further_education,
|
||||
.other_education { background: var(--dest-fecollege); color: var(--text-primary); }
|
||||
.apprenticeship { background: var(--dest-apprentice); }
|
||||
.employment { background: var(--dest-employment); }
|
||||
|
||||
/* The absence is hatched neutral, never a colour: "activity not captured"
|
||||
covers independent schools, moving abroad and training the department holds
|
||||
no data on, so a red segment would state something false. The hatch is also
|
||||
the secondary encoding that separates it from the employment blue. */
|
||||
.not_sustained,
|
||||
.not_captured {
|
||||
background-color: var(--bg-card);
|
||||
background-image: repeating-linear-gradient(
|
||||
45deg,
|
||||
var(--dest-none-hatch) 0 3px,
|
||||
transparent 3px 7px
|
||||
);
|
||||
box-shadow: inset 0 0 0 1px var(--dest-none);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.swatch {
|
||||
display: inline-block;
|
||||
width: 0.9rem;
|
||||
height: 0.9rem;
|
||||
border-radius: 3px;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
/* ── Dimming, for the card-to-bar linkage ──────────────────────────────── */
|
||||
|
||||
.dim { opacity: 0.3; }
|
||||
|
||||
/* ── Withheld panel ────────────────────────────────────────────────────── */
|
||||
|
||||
.withheldPanel {
|
||||
background: var(--bg-secondary);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 1.15rem 1.25rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.withheldTitle {
|
||||
font-family: var(--font-display);
|
||||
font-size: var(--step--1);
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.withheldBody {
|
||||
margin: 0;
|
||||
font-size: var(--step--1);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.withheldMark {
|
||||
display: inline-block;
|
||||
font-size: var(--step--2);
|
||||
font-weight: 700;
|
||||
color: var(--text-muted);
|
||||
background: var(--bg-secondary);
|
||||
border: 1px dashed var(--border-strong);
|
||||
border-radius: 999px;
|
||||
padding: 0.1rem 0.5rem;
|
||||
}
|
||||
|
||||
/* ── Table ─────────────────────────────────────────────────────────────── */
|
||||
|
||||
.tableWrap { overflow-x: auto; }
|
||||
|
||||
.table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: var(--step--1);
|
||||
}
|
||||
|
||||
.table th,
|
||||
.table td {
|
||||
padding: 0.55rem 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.table thead th {
|
||||
font-size: var(--step--2);
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.05em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-muted);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.table th:first-child { text-align: left; }
|
||||
|
||||
.rowName {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.footnote {
|
||||
margin: 0;
|
||||
font-size: var(--step--2);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
@@ -369,6 +369,12 @@ export interface SchoolDetailsResponse {
|
||||
admission_distance?: SchoolAdmissionDistance | null;
|
||||
deprivation: SchoolDeprivation | null;
|
||||
finance: SchoolFinance | null;
|
||||
/**
|
||||
* Optional so an older backend, which does not send the key at all, still
|
||||
* typechecks. Null means the school has no published destination data;
|
||||
* either way the sections simply do not render.
|
||||
*/
|
||||
destinations?: SchoolDestinations | null;
|
||||
}
|
||||
|
||||
export interface ComparisonData {
|
||||
|
||||
Reference in new issue
Block a user