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
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>
255 lines
5.1 KiB
CSS
255 lines
5.1 KiB
CSS
/* SatsChart — Cascade bar chart for KS2 SATs results */
|
|
|
|
.container {
|
|
display: grid;
|
|
grid-template-columns: repeat(3, 1fr);
|
|
gap: 1.75rem;
|
|
padding-top: 0.25rem;
|
|
}
|
|
|
|
/* ── Individual subject column ── */
|
|
.subjectChart {
|
|
position: relative;
|
|
}
|
|
|
|
.subjectName {
|
|
font-size: 0.65rem;
|
|
font-weight: 700;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.1em;
|
|
color: var(--text-muted);
|
|
margin-bottom: 0.75rem;
|
|
}
|
|
|
|
.chartArea {
|
|
position: relative;
|
|
padding-bottom: 0.25rem;
|
|
}
|
|
|
|
/* ── Gridlines ── */
|
|
.gridlines {
|
|
position: absolute;
|
|
top: 0;
|
|
left: 0;
|
|
right: 0;
|
|
bottom: 20px;
|
|
pointer-events: none;
|
|
}
|
|
|
|
.gridline {
|
|
position: absolute;
|
|
top: 0;
|
|
height: 100%;
|
|
width: 1px;
|
|
background: var(--bg-secondary);
|
|
}
|
|
|
|
/* ── Per-bar national average marker ──
|
|
Each bar compares against its own benchmark (expected vs higher standard /
|
|
greater depth), so the marker sits on the individual bar's track rather than
|
|
as one line spanning both bars. */
|
|
/*
|
|
* Knockout, not a colour.
|
|
*
|
|
* This marker was var(--brand) — the same value as .barExpected and
|
|
* .barExceeding, so wherever it crossed a bar it measured 1.00:1 and was not
|
|
* rendered distinguishably at all. 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.
|
|
*
|
|
* No single colour fixes this, because the marker's position is data-driven:
|
|
* it can land on the bar, on the empty track, or straddle the boundary. So it
|
|
* is drawn as a knockout — a light core carrying a dark edge. On the teal bar
|
|
* the core reads; on the pale track the edge reads. Both tokens flip with the
|
|
* theme, so the pairing holds in dark mode too.
|
|
*
|
|
* The edge is box-shadow rather than border so it costs no layout width and
|
|
* cannot shift the 50% translate.
|
|
*/
|
|
.natTick {
|
|
position: absolute;
|
|
top: -3px;
|
|
bottom: -3px;
|
|
width: 3px;
|
|
transform: translateX(-50%);
|
|
background: var(--bg-card);
|
|
box-shadow: 0 0 0 1px var(--text-primary);
|
|
border-radius: 2px;
|
|
z-index: 4;
|
|
pointer-events: none;
|
|
}
|
|
|
|
.natTick::before {
|
|
content: '';
|
|
position: absolute;
|
|
top: -4px;
|
|
left: 50%;
|
|
transform: translateX(-50%);
|
|
width: 6px;
|
|
height: 6px;
|
|
border-radius: 50%;
|
|
background: var(--bg-card);
|
|
box-shadow: 0 0 0 1px var(--text-primary);
|
|
}
|
|
|
|
.barHeaderRight {
|
|
display: flex;
|
|
align-items: baseline;
|
|
gap: 0.4rem;
|
|
}
|
|
|
|
.natLabel {
|
|
font-size: 0.55rem;
|
|
font-weight: 700;
|
|
color: var(--brand-strong);
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.03em;
|
|
}
|
|
|
|
/* ── Bar rows ── */
|
|
.barGroup {
|
|
position: relative;
|
|
z-index: 1;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.35rem;
|
|
padding-top: 0.9rem;
|
|
}
|
|
|
|
.barRow {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.18rem;
|
|
}
|
|
|
|
.barHeader {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: baseline;
|
|
gap: 0.5rem;
|
|
}
|
|
|
|
.barTrack {
|
|
width: 100%;
|
|
height: 14px;
|
|
position: relative;
|
|
}
|
|
|
|
.bar {
|
|
height: 100%;
|
|
border-radius: 4px;
|
|
position: relative;
|
|
transition: width 0.8s cubic-bezier(0.25, 0.46, 0.45, 0.94);
|
|
}
|
|
|
|
/*
|
|
* Expected and Exceeding were both var(--brand) — one colour for two series,
|
|
* distinguished only by which row you were looking at, while the legend
|
|
* claimed two.
|
|
*
|
|
* They are a sequential pair, not two categories: "exceeding" is a subset of
|
|
* the same cohort at a harder bar. So they take two steps of the same hue
|
|
* rather than two different hues, with the harder measure the more intense
|
|
* step. --brand-stronger is darker than --brand in the light theme and lighter
|
|
* in the dark one, which is the right direction in both: further from the
|
|
* ground.
|
|
*/
|
|
.barExpected {
|
|
background: var(--brand);
|
|
}
|
|
|
|
.barExceeding {
|
|
background: var(--brand-stronger);
|
|
}
|
|
|
|
.barLabel {
|
|
font-family: var(--font-display);
|
|
font-size: 0.78rem;
|
|
font-weight: 700;
|
|
color: var(--text-primary);
|
|
white-space: nowrap;
|
|
position: relative;
|
|
z-index: 3;
|
|
}
|
|
|
|
.barLabelSuffix {
|
|
font-weight: 500;
|
|
color: var(--text-muted);
|
|
font-size: 0.6rem;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.06em;
|
|
}
|
|
|
|
/* ── Ruler ── */
|
|
.ruler {
|
|
position: relative;
|
|
height: 14px;
|
|
margin-top: 0.3rem;
|
|
border-top: 1px solid var(--border);
|
|
}
|
|
|
|
.rulerTick {
|
|
position: absolute;
|
|
top: 0;
|
|
width: 1px;
|
|
height: 3px;
|
|
background: var(--border);
|
|
}
|
|
|
|
.rulerLabel {
|
|
position: absolute;
|
|
top: 5px;
|
|
font-size: 0.5rem;
|
|
font-weight: 500;
|
|
color: var(--text-muted);
|
|
transform: translateX(-50%);
|
|
letter-spacing: 0.01em;
|
|
opacity: 0.7;
|
|
}
|
|
|
|
/* Anchor first/last labels to edges */
|
|
.rulerLabelFirst {
|
|
transform: translateX(0);
|
|
}
|
|
|
|
.rulerLabelLast {
|
|
transform: translateX(-100%);
|
|
}
|
|
|
|
/* ── Legend ── */
|
|
.legend {
|
|
margin-top: 1rem;
|
|
display: flex;
|
|
gap: 1.25rem;
|
|
justify-content: center;
|
|
}
|
|
|
|
.legendItem {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.35rem;
|
|
font-size: 0.65rem;
|
|
font-weight: 500;
|
|
color: var(--text-muted);
|
|
}
|
|
|
|
.legendSwatch {
|
|
width: 8px;
|
|
height: 8px;
|
|
border-radius: 3px;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
/* ── Responsive ── */
|
|
@media (max-width: 640px) {
|
|
.container {
|
|
grid-template-columns: 1fr;
|
|
gap: 1.5rem;
|
|
}
|
|
|
|
.legend {
|
|
flex-wrap: wrap;
|
|
gap: 0.75rem;
|
|
}
|
|
}
|