fix(charts): make the national-average marker visible on both templates #99
No files matched your search
@@ -800,6 +800,108 @@ test('sticky section nav jumps to server-rendered sections', async ({ page }) =>
|
|||||||
* These are silent failures — nothing on the page looks wrong — so they need
|
* These are silent failures — nothing on the page looks wrong — so they need
|
||||||
* a gate.
|
* a gate.
|
||||||
*/
|
*/
|
||||||
|
/**
|
||||||
|
* Chart benchmark markers, against whatever they actually overlap.
|
||||||
|
*
|
||||||
|
* The national-average marker shipped as var(--brand) on both templates —
|
||||||
|
* identical to the bar fill it sits on, so it measured 1.00:1 wherever it
|
||||||
|
* crossed a bar and was visible only for schools BELOW the benchmark.
|
||||||
|
*
|
||||||
|
* The WCAG journey below never saw it, and could not have: it composites
|
||||||
|
* backgrounds by walking the ANCESTOR chain, while these markers are
|
||||||
|
* absolutely positioned over a SIBLING. Ancestor-walking is structurally blind
|
||||||
|
* to overlap. This test asks the stacking order instead —
|
||||||
|
* document.elementsFromPoint returns what is genuinely beneath a point — which
|
||||||
|
* is the same question a reader's eye asks.
|
||||||
|
*/
|
||||||
|
const MARKER_PROBE = `(() => {
|
||||||
|
const ps = c => { const m=(c||'').match(/[\\d.]+/g); if(!m) return null;
|
||||||
|
const a=m.map(Number); return {r:a[0],g:a[1],b:a[2],a:m.length>3?a[3]:1}; };
|
||||||
|
const ov = (f,b) => ({r:f.r*f.a+b.r*(1-f.a), g:f.g*f.a+b.g*(1-f.a), b:f.b*f.a+b.b*(1-f.a), a:1});
|
||||||
|
const L = c => { const f=v=>{v/=255; return v<=0.03928?v/12.92:Math.pow((v+.055)/1.055,2.4);};
|
||||||
|
return .2126*f(c.r)+.7152*f(c.g)+.0722*f(c.b); };
|
||||||
|
const RT = (a,b) => { const x=L(a),y=L(b); return (Math.max(x,y)+.05)/(Math.min(x,y)+.05); };
|
||||||
|
|
||||||
|
const markers = [...document.querySelectorAll('[class*="natTick"], [class*="NatLine"]')]
|
||||||
|
.filter(el => el.getBoundingClientRect().width > 0);
|
||||||
|
const out = { count: markers.length, failures: [], unmeasured: 0 };
|
||||||
|
|
||||||
|
for (const el of markers) {
|
||||||
|
/*
|
||||||
|
* Scroll it in first. elementsFromPoint takes VIEWPORT coordinates and
|
||||||
|
* returns an empty stack for anything off-screen — and these markers sit
|
||||||
|
* well down the page. Without this the backdrop silently defaults to
|
||||||
|
* white, which makes a teal-on-teal marker look like teal-on-white and
|
||||||
|
* pass. 'instant' because globals.css sets scroll-behavior: smooth on
|
||||||
|
* html, and a smooth scroll would still be moving when we measure.
|
||||||
|
*/
|
||||||
|
el.scrollIntoView({ block: 'center', behavior: 'instant' });
|
||||||
|
const r = el.getBoundingClientRect();
|
||||||
|
const cx = r.left + r.width / 2, cy = r.top + r.height / 2;
|
||||||
|
const own = ps(getComputedStyle(el).backgroundColor);
|
||||||
|
const edge = ps((getComputedStyle(el).boxShadow.match(/rgba?\\([^)]*\\)/) || [])[0] || '');
|
||||||
|
if (!own) continue;
|
||||||
|
|
||||||
|
// What is genuinely underneath, by stacking order rather than by ancestry.
|
||||||
|
const stack = document.elementsFromPoint(cx, cy);
|
||||||
|
if (stack.length === 0) { out.unmeasured++; continue; } // never assume white
|
||||||
|
let backdrop = null;
|
||||||
|
for (const under of stack) {
|
||||||
|
if (under === el || el.contains(under)) continue;
|
||||||
|
const c = ps(getComputedStyle(under).backgroundColor);
|
||||||
|
if (c && c.a > 0.9) { backdrop = c; break; }
|
||||||
|
}
|
||||||
|
if (!backdrop) { out.unmeasured++; continue; }
|
||||||
|
|
||||||
|
// A knockout marker only needs ONE of its two parts to separate.
|
||||||
|
const best = Math.max(RT(ov(own, backdrop), backdrop),
|
||||||
|
edge ? RT(ov(edge, backdrop), backdrop) : 0);
|
||||||
|
if (best < 3) {
|
||||||
|
out.failures.push((el.className || '?').toString().split(' ')[0] +
|
||||||
|
' ' + best.toFixed(2) + ':1 over rgb(' +
|
||||||
|
[backdrop.r, backdrop.g, backdrop.b].map(Math.round).join(',') + ')');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
})()`;
|
||||||
|
|
||||||
|
async function firstSchoolLink(page: Page, query: string): Promise<string> {
|
||||||
|
await page.goto(`/?search=${encodeURIComponent(query)}`);
|
||||||
|
const link = schoolLinks(page).first();
|
||||||
|
await expect(link).toBeVisible({ timeout: 15_000 });
|
||||||
|
return (await link.getAttribute('href'))!;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const scheme of ['light', 'dark'] as const) {
|
||||||
|
test(`chart benchmark markers stay visible in the ${scheme} theme`, async ({ browser }) => {
|
||||||
|
const context = await browser.newContext({ colorScheme: scheme });
|
||||||
|
const page = await context.newPage();
|
||||||
|
const failures: string[] = [];
|
||||||
|
|
||||||
|
// One primary (SATs bars) and one secondary (Attainment 8 bar) — the two
|
||||||
|
// templates carry separate implementations of the same marker.
|
||||||
|
for (const query of ['primary', 'academy']) {
|
||||||
|
const href = await firstSchoolLink(page, query);
|
||||||
|
await page.goto(href);
|
||||||
|
await expect(page.locator('h1').first()).toBeVisible({ timeout: 15_000 });
|
||||||
|
await page.waitForTimeout(900); // bar widths animate in from an effect
|
||||||
|
|
||||||
|
const res = (await page.evaluate(MARKER_PROBE)) as
|
||||||
|
{ count: number; failures: string[]; unmeasured: number };
|
||||||
|
test.skip(res.count === 0 && query === 'primary',
|
||||||
|
'no benchmark markers rendered — environment has no national averages');
|
||||||
|
// A marker we could not resolve a backdrop for is a hole in the check,
|
||||||
|
// not a pass. Fail loudly rather than quietly measuring nothing.
|
||||||
|
expect(res.unmeasured,
|
||||||
|
`${href}: ${res.unmeasured} of ${res.count} markers had no resolvable backdrop`).toBe(0);
|
||||||
|
failures.push(...res.failures.map(f => `${href} → ${f}`));
|
||||||
|
}
|
||||||
|
|
||||||
|
await context.close();
|
||||||
|
expect(failures, `benchmark markers under 3:1 in ${scheme}:\n ${failures.join('\n ')}`).toEqual([]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
test('the brand asset set is complete and served', async ({ page }) => {
|
test('the brand asset set is complete and served', async ({ page }) => {
|
||||||
const response = await page.goto('/');
|
const response = await page.goto('/');
|
||||||
expect(response?.ok()).toBe(true);
|
expect(response?.ok()).toBe(true);
|
||||||
|
|||||||
@@ -48,13 +48,32 @@
|
|||||||
Each bar compares against its own benchmark (expected vs higher standard /
|
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
|
greater depth), so the marker sits on the individual bar's track rather than
|
||||||
as one line spanning both bars. */
|
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 {
|
.natTick {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: -3px;
|
top: -3px;
|
||||||
bottom: -3px;
|
bottom: -3px;
|
||||||
width: 2px;
|
width: 3px;
|
||||||
transform: translateX(-50%);
|
transform: translateX(-50%);
|
||||||
background: var(--brand);
|
background: var(--bg-card);
|
||||||
|
box-shadow: 0 0 0 1px var(--text-primary);
|
||||||
border-radius: 2px;
|
border-radius: 2px;
|
||||||
z-index: 4;
|
z-index: 4;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
@@ -63,13 +82,14 @@
|
|||||||
.natTick::before {
|
.natTick::before {
|
||||||
content: '';
|
content: '';
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: -3px;
|
top: -4px;
|
||||||
left: 50%;
|
left: 50%;
|
||||||
transform: translateX(-50%);
|
transform: translateX(-50%);
|
||||||
width: 5px;
|
width: 6px;
|
||||||
height: 5px;
|
height: 6px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: var(--brand);
|
background: var(--bg-card);
|
||||||
|
box-shadow: 0 0 0 1px var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.barHeaderRight {
|
.barHeaderRight {
|
||||||
@@ -122,12 +142,24 @@
|
|||||||
transition: width 0.8s cubic-bezier(0.25, 0.46, 0.45, 0.94);
|
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 {
|
.barExpected {
|
||||||
background: var(--brand);
|
background: var(--brand);
|
||||||
}
|
}
|
||||||
|
|
||||||
.barExceeding {
|
.barExceeding {
|
||||||
background: var(--brand);
|
background: var(--brand-stronger);
|
||||||
}
|
}
|
||||||
|
|
||||||
.barLabel {
|
.barLabel {
|
||||||
|
|||||||
@@ -143,17 +143,40 @@ export default function SatsChart({ subjects }: SatsChartProps) {
|
|||||||
<SubjectColumn key={subject.name} subject={subject} />
|
<SubjectColumn key={subject.name} subject={subject} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</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.legend}>
|
||||||
<div className={styles.legendItem}>
|
<div className={styles.legendItem}>
|
||||||
<div className={styles.legendSwatch} style={{ background: 'var(--status-above)' }} />
|
<div className={styles.legendSwatch} style={{ background: 'var(--brand)' }} />
|
||||||
Expected standard
|
Expected standard
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.legendItem}>
|
<div className={styles.legendItem}>
|
||||||
<div className={styles.legendSwatch} style={{ background: 'var(--status-above)' }} />
|
<div className={styles.legendSwatch} style={{ background: 'var(--brand-stronger)' }} />
|
||||||
Exceeding / high score
|
Exceeding / high score
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.legendItem}>
|
<div className={styles.legendItem}>
|
||||||
<div className={styles.legendSwatch} style={{ background: 'var(--brand)', width: '3px', height: '12px', borderRadius: '2px' }} />
|
<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
|
National average
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1807,12 +1807,23 @@
|
|||||||
border-radius: 4px 0 0 4px;
|
border-radius: 4px 0 0 4px;
|
||||||
transition: width 0.6s ease;
|
transition: width 0.6s ease;
|
||||||
}
|
}
|
||||||
|
/*
|
||||||
|
* Knockout, for the same reason as .natTick in SatsChart.module.css: this was
|
||||||
|
* var(--brand), identical to .att8VizFill above it, so where the marker
|
||||||
|
* crossed the school's own bar it measured 1.00:1 — invisible for exactly the
|
||||||
|
* schools at or above the national average.
|
||||||
|
*
|
||||||
|
* A light core with a dark edge reads on the teal fill and on the pale track
|
||||||
|
* alike, and both tokens flip with the theme. Keep this in step with
|
||||||
|
* SatsChart's .natTick; they are the same marker on two templates.
|
||||||
|
*/
|
||||||
.att8VizNatLine {
|
.att8VizNatLine {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: -4px;
|
top: -4px;
|
||||||
bottom: -4px;
|
bottom: -4px;
|
||||||
width: 2px;
|
width: 3px;
|
||||||
background: var(--brand);
|
background: var(--bg-card);
|
||||||
|
box-shadow: 0 0 0 1px var(--text-primary);
|
||||||
border-radius: 2px;
|
border-radius: 2px;
|
||||||
z-index: 2;
|
z-index: 2;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user