Files
school_compare/nextjs-app/components/SatsChart.tsx
T
TudorandClaude Opus 5 59ac9c10b9
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m1s
PR Checks / Backend Smoke (pull_request) Successful in 7s
PR Checks / Build Backend (no push) (pull_request) Successful in 11s
PR Checks / Build Frontend (no push) (pull_request) Successful in 46s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 10s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 1m5s
fix(charts): make the national-average marker visible on both templates
The marker was var(--brand) — the identical value to the bar fill it sits on.
Measured by pixel-sampling every bar on both templates, in both themes:

  primary   SatsChart .natTick        1.00:1  (6 bars)
  secondary .att8VizNatLine           1.00:1

Not low contrast. The same colour. It scored 5.47:1 only against the empty
track, which means it was visible precisely when a school was BELOW the
national average and vanished for every school at or above it — failing for
exactly the schools people are looking for.

No single colour fixes this, because the marker's position is data-driven: it
can land on the bar, on the empty track, or across the boundary. It is now a
knockout — a light core carrying a dark edge, both from tokens that flip with
the theme, so one part or the other always separates:

              on the bar        on the track
  light       5.47 / 9.70:1     15.17:1
  dark        7.81 / 10.85:1    13.52:1

THE LEGEND DESCRIBED A CHART THAT DID NOT EXIST

Both data swatches were var(--status-above) green while their bars were
var(--brand) teal, and the two were identical to each other — one swatch for
two series. Worse, the only swatch matching the bar colour was the one
labelled "National average", so reading the chart by matching colours told you
the teal bars were the benchmark. Each swatch now carries its bar's exact
value, and the marker swatch mirrors the knockout.

TWO SERIES, ONE COLOUR

Expected and Exceeding were both var(--brand), distinguished only by row.
They are a sequential pair — exceeding is the same cohort at a harder bar — so
they take two steps of one hue, the harder measure being the step further from
the ground in each theme.

They measure 1.77:1 (light) and 1.39:1 (dark) against each other, and that is
accepted rather than overlooked: two fills that must EACH clear 3:1 against
the same white track are geometrically forced close together. The distinction
is carried by the row labels and printed values; colour is redundant here, not
load-bearing.

THE TEST THAT SHOULD HAVE CAUGHT THIS

The WCAG journey composites backgrounds by walking the ancestor chain, but
these markers are absolutely positioned over a sibling — ancestor-walking is
structurally blind to overlap, which is why this shipped in two templates and
passed every gate. The new journey asks the stacking order instead, via
document.elementsFromPoint.

Writing it surfaced a second trap worth recording: elementsFromPoint takes
viewport coordinates and returns an empty stack off-screen, and these markers
sit ~1200px down. The first version defaulted an empty stack to white, which
made teal-on-teal look like teal-on-white and PASS in the light theme. It now
scrolls each marker into view and counts any marker it cannot resolve a
backdrop for as a failure rather than a pass.

Verified both directions: the new journey fails against current staging with
"1.00:1 over rgb(15,118,110)" in both themes, and passes against this build
with 6/6 markers measured at 5.47–15.17:1.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 09:48:41 +01:00

186 lines
5.8 KiB
TypeScript

'use client';
import { useEffect, useRef } from 'react';
import styles from './SatsChart.module.css';
interface SubjectData {
name: string;
expectedPct: number | null;
exceedingPct: number | null;
nationalExpectedPct: number | null;
/** National higher-standard / greater-depth benchmark for the exceeding
* bar — a different figure from the expected national. */
nationalExceedingPct: number | null;
}
interface SatsChartProps {
subjects: SubjectData[];
}
const RULER_TICKS = [0, 25, 50, 75, 100];
const GRIDLINE_POSITIONS = [25, 50, 75];
/** One results bar with a national marker positioned on its own track, so the
* expected and exceeding bars each compare against their own benchmark. */
function BarRow({
label,
value,
national,
barRef,
barClass,
}: {
label: string;
value: number;
national: number | null;
barRef: React.RefObject<HTMLDivElement | null>;
barClass: string;
}) {
return (
<div className={styles.barRow}>
<div className={styles.barHeader}>
<span className={styles.barLabelSuffix}>{label}</span>
<span className={styles.barHeaderRight}>
{national != null && <span className={styles.natLabel}>nat {national.toFixed(0)}%</span>}
<span className={styles.barLabel}>{value.toFixed(0)}%</span>
</span>
</div>
<div className={styles.barTrack}>
<div ref={barRef} className={`${styles.bar} ${barClass}`} data-width={value} />
{national != null && (
<div className={styles.natTick} style={{ left: `${national}%` }} aria-hidden="true" />
)}
</div>
</div>
);
}
function SubjectColumn({ subject }: { subject: SubjectData }) {
const expectedRef = useRef<HTMLDivElement>(null);
const exceedingRef = useRef<HTMLDivElement>(null);
const { name, expectedPct, exceedingPct, nationalExpectedPct, nationalExceedingPct } = subject;
// Animate bars on mount
useEffect(() => {
const bars = [expectedRef.current, exceedingRef.current];
bars.forEach((bar) => {
if (!bar) return;
const target = bar.dataset.width;
bar.style.width = '0%';
requestAnimationFrame(() => {
requestAnimationFrame(() => {
bar.style.width = `${target}%`;
});
});
});
}, [expectedPct, exceedingPct]);
if (expectedPct == null && exceedingPct == null) return null;
return (
<div className={styles.subjectChart}>
<div className={styles.subjectName}>{name}</div>
<div className={styles.chartArea}>
{/* Gridlines */}
<div className={styles.gridlines}>
{GRIDLINE_POSITIONS.map((pct) => (
<div key={pct} className={styles.gridline} style={{ left: `${pct}%` }} />
))}
</div>
{/* Bars — each carries its own national marker */}
<div className={styles.barGroup}>
{expectedPct != null && (
<BarRow
label="Expected"
value={expectedPct}
national={nationalExpectedPct}
barRef={expectedRef}
barClass={styles.barExpected}
/>
)}
{exceedingPct != null && (
<BarRow
label="Exceeding"
value={exceedingPct}
national={nationalExceedingPct}
barRef={exceedingRef}
barClass={styles.barExceeding}
/>
)}
</div>
{/* Ruler */}
<div className={styles.ruler}>
{RULER_TICKS.map((pct, i) => (
<span key={pct}>
<div className={styles.rulerTick} style={{ left: `${pct}%` }} />
<div
className={`${styles.rulerLabel} ${i === 0 ? styles.rulerLabelFirst : ''} ${i === RULER_TICKS.length - 1 ? styles.rulerLabelLast : ''}`}
style={{ left: `${pct}%` }}
>
{pct}%
</div>
</span>
))}
</div>
</div>
</div>
);
}
export default function SatsChart({ subjects }: SatsChartProps) {
const visibleSubjects = subjects.filter(
(s) => s.expectedPct != null || s.exceedingPct != null
);
if (visibleSubjects.length === 0) return null;
return (
<div>
<div className={styles.container}>
{visibleSubjects.map((subject) => (
<SubjectColumn key={subject.name} subject={subject} />
))}
</div>
{/*
The legend describes what is drawn, which it previously did not.
Both data swatches were var(--status-above) — green — while the bars
they labelled were var(--brand) teal, and they were identical to each
other, so two different series shared one swatch. Worse, the only
swatch that matched the bar colour was the one labelled "National
average": anyone reading the chart by matching colours would conclude
the teal bars WERE the national average.
Each swatch now carries the exact value its bar carries, and the
national-average swatch mirrors the knockout marker rather than being
a flat colour, so it is recognisable as the thing on the chart.
*/}
<div className={styles.legend}>
<div className={styles.legendItem}>
<div className={styles.legendSwatch} style={{ background: 'var(--brand)' }} />
Expected standard
</div>
<div className={styles.legendItem}>
<div className={styles.legendSwatch} style={{ background: 'var(--brand-stronger)' }} />
Exceeding / high score
</div>
<div className={styles.legendItem}>
<div
className={styles.legendSwatch}
style={{
background: 'var(--bg-card)',
boxShadow: '0 0 0 1px var(--text-primary)',
width: '3px',
height: '12px',
borderRadius: '2px',
}}
/>
National average
</div>
</div>
</div>
);
}