Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
914b429a28 |
No files matched your search
@@ -19,7 +19,7 @@
|
|||||||
- Screenshot `j1-postcode-results-mobile.png` confirms cards carry only the LA name ("Solihull"), no "X miles away".
|
- Screenshot `j1-postcode-results-mobile.png` confirms cards carry only the LA name ("Solihull"), no "X miles away".
|
||||||
|
|
||||||
**Desktop (1440×900) — Attempt A repeat + keyboard + zoom.**
|
**Desktop (1440×900) — Attempt A repeat + keyboard + zoom.**
|
||||||
- Desktop hero additionally shows a trust badge ("● UPDATED WITH 2026/2027 ADMISSIONS RESULTS") and a value-prop subheading ("27,000+ primary and secondary schools with Key Stage 2 SATs, GCSE results, Ofsted grades, progress scores and admissions data — side by side, in one place"). **Both are absent on the mobile hero** (mobile jumps H1 → search box).
|
- Desktop hero additionally shows a trust badge ("● UPDATED WITH 2026/2027 ADMISSIONS RESULTS") and a value-prop subheading ("24,000+ primary and secondary schools with Key Stage 2 SATs, GCSE results, Ofsted grades, progress scores and admissions data — side by side, in one place"). **Both are absent on the mobile hero** (mobile jumps H1 → search box).
|
||||||
- Name search identical, correct, instant (`j1-search-results-desktop.png`).
|
- Name search identical, correct, instant (`j1-search-results-desktop.png`).
|
||||||
- Keyboard: tab order is logical — Skip link → logo → Search/Compare/Rankings/Admissions nav → search input → Search button → Schools near me → content. Skip link, logo, nav links and Search button all get a clear **2px solid orange (#E07256) focus outline**. The search input uses an orange border + very faint ring (`box-shadow rgba(224,114,86,0.12) 0 0 0 3px`, `outline:none`) — visible but weaker than the other controls. Search → results → school link is fully keyboard-operable (standard links/buttons).
|
- Keyboard: tab order is logical — Skip link → logo → Search/Compare/Rankings/Admissions nav → search input → Search button → Schools near me → content. Skip link, logo, nav links and Search button all get a clear **2px solid orange (#E07256) focus outline**. The search input uses an orange border + very faint ring (`box-shadow rgba(224,114,86,0.12) 0 0 0 3px`, `outline:none`) — visible but weaker than the other controls. Search → results → school link is fully keyboard-operable (standard links/buttons).
|
||||||
- 200% reflow (720×450): **no horizontal scroll** (`scrollWidth == clientWidth == 720`); deadline cards reflow from 1×4 to 2×2, no overlap or clipping. Pass.
|
- 200% reflow (720×450): **no horizontal scroll** (`scrollWidth == clientWidth == 720`); deadline cards reflow from 1×4 to 2×2, no overlap or clipping. Pass.
|
||||||
@@ -57,7 +57,7 @@
|
|||||||
- Severity guess: P2
|
- Severity guess: P2
|
||||||
|
|
||||||
- **F6. Mobile hero omits the value proposition shown on desktop**
|
- **F6. Mobile hero omits the value proposition shown on desktop**
|
||||||
- Evidence: Desktop hero has the "UPDATED WITH 2026/2027…" badge + subheading "27,000+ primary and secondary schools with KS2 SATs, GCSE results, Ofsted grades… side by side, in one place" (`j1-home-desktop-fold.png`). The mobile hero (`j1-home-mobile-fold.png`) drops both — below the poetic-but-vague H1 there is only a search box.
|
- Evidence: Desktop hero has the "UPDATED WITH 2026/2027…" badge + subheading "24,000+ primary and secondary schools with KS2 SATs, GCSE results, Ofsted grades… side by side, in one place" (`j1-home-desktop-fold.png`). The mobile hero (`j1-home-mobile-fold.png`) drops both — below the poetic-but-vague H1 there is only a search box.
|
||||||
- Criterion violated: Nielsen #1 (Visibility of system status) / recognition-over-recall; mobile content-parity best practice (the primary 63%-of-entries viewport should not lose the core "what is this and why trust it" copy).
|
- Criterion violated: Nielsen #1 (Visibility of system status) / recognition-over-recall; mobile content-parity best practice (the primary 63%-of-entries viewport should not lose the core "what is this and why trust it" copy).
|
||||||
- Argument: A first-time parent landing on mobile sees "Every school in England, compared." + a bare box, with no statement of coverage, data sources, or freshness. Weak/absent value proposition above the fold is a classic driver of immediate exits — directly relevant to the 46% home-exit rate.
|
- Argument: A first-time parent landing on mobile sees "Every school in England, compared." + a bare box, with no statement of coverage, data sources, or freshness. Weak/absent value proposition above the fold is a classic driver of immediate exits — directly relevant to the 46% home-exit rate.
|
||||||
- Severity guess: P2 (candidate P1 given mobile is the primary, highest-traffic viewport)
|
- Severity guess: P2 (candidate P1 given mobile is the primary, highest-traffic viewport)
|
||||||
|
|||||||
@@ -121,33 +121,6 @@ test('the home page has a real title', async ({ page }) => {
|
|||||||
expect(await page.title()).toMatch(/schoolcompare/i);
|
expect(await page.title()).toMatch(/schoolcompare/i);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('the site states its independence and never claims to be official', async ({ page }) => {
|
|
||||||
await page.goto('/');
|
|
||||||
|
|
||||||
// Carried in the footer, so it appears on every page rather than only here.
|
|
||||||
await expect(
|
|
||||||
page.getByText(/an independent site\.\s*not affiliated with the department for education/i)
|
|
||||||
).toBeVisible();
|
|
||||||
|
|
||||||
/*
|
|
||||||
* Describing the DATA as official is accurate and appears throughout
|
|
||||||
* ("official DfE figures", "the official figure isn't in"). Describing the
|
|
||||||
* SITE as official is not: schoolcompare republishes government data, it is
|
|
||||||
* not a government service. The difference is the grammatical subject, so
|
|
||||||
* this looks for text that opens with "Official" as a standalone label —
|
|
||||||
* the shape a value-prop title or heading takes, and the shape the old
|
|
||||||
* "Official & trusted" took.
|
|
||||||
*/
|
|
||||||
const claims = await page.evaluate(() =>
|
|
||||||
[...document.querySelectorAll('body *')]
|
|
||||||
.flatMap(el => [...el.childNodes]
|
|
||||||
.filter(n => n.nodeType === 3)
|
|
||||||
.map(n => (n.textContent ?? '').trim()))
|
|
||||||
.filter(t => /^Official\b/i.test(t) && !/^Official\s+(DfE|data|figures)/i.test(t))
|
|
||||||
);
|
|
||||||
expect(claims, `text presenting the site itself as official: ${claims.join(' | ')}`).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('the landing bands share one vertical rhythm', async ({ page }) => {
|
test('the landing bands share one vertical rhythm', async ({ page }) => {
|
||||||
await page.goto('/');
|
await page.goto('/');
|
||||||
const gaps = await page.evaluate(() => {
|
const gaps = await page.evaluate(() => {
|
||||||
@@ -800,108 +773,6 @@ 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);
|
||||||
@@ -918,21 +789,9 @@ test('the brand asset set is complete and served', async ({ page }) => {
|
|||||||
expect(og.ok()).toBe(true);
|
expect(og.ok()).toBe(true);
|
||||||
expect(og.headers()['content-type']).toContain('image/png');
|
expect(og.headers()['content-type']).toContain('image/png');
|
||||||
|
|
||||||
/*
|
// iOS ignores SVG touch icons, so this must be a real raster.
|
||||||
* iOS ignores SVG touch icons, so this must be a real raster.
|
const apple = await page.request.get('/apple-icon');
|
||||||
*
|
expect(apple.ok()).toBe(true);
|
||||||
* Read the href off the page rather than hardcoding a path. The route moved
|
|
||||||
* when the generated app/apple-icon.tsx became a static app/apple-icon.png:
|
|
||||||
* a generated icon is served at /apple-icon, a static one at /apple-icon.png
|
|
||||||
* with a content hash. This test kept asking for /apple-icon and got a 404 —
|
|
||||||
* reporting a missing icon while the icon was present and correctly linked.
|
|
||||||
*/
|
|
||||||
const appleLink = page.locator('link[rel="apple-touch-icon"]');
|
|
||||||
await expect(appleLink).toHaveCount(1);
|
|
||||||
const appleHref = await appleLink.getAttribute('href');
|
|
||||||
expect(appleHref).toBeTruthy();
|
|
||||||
const apple = await page.request.get(appleHref!);
|
|
||||||
expect(apple.ok(), `apple touch icon at ${appleHref} should be served`).toBe(true);
|
|
||||||
expect(apple.headers()['content-type']).toContain('image/png');
|
expect(apple.headers()['content-type']).toContain('image/png');
|
||||||
|
|
||||||
// Android needs a maskable PNG or the install prompt has no icon.
|
// Android needs a maskable PNG or the install prompt has no icon.
|
||||||
@@ -1053,22 +912,8 @@ test('the header carries the schoolcompare lockup', async ({ page }) => {
|
|||||||
// hashed at build time and change on any unrelated edit.
|
// hashed at build time and change on any unrelated edit.
|
||||||
await expect(home).toHaveText(/^\s*schoolcompare\s*$/);
|
await expect(home).toHaveText(/^\s*schoolcompare\s*$/);
|
||||||
|
|
||||||
/*
|
const mark = home.locator('svg').first();
|
||||||
* The mark is raster artwork, not an inline SVG — it has been since the
|
|
||||||
* supplied logo replaced the reconstruction, and this locator went on
|
|
||||||
* looking for an <svg> that no longer exists.
|
|
||||||
*
|
|
||||||
* Assert on naturalWidth rather than visibility: a <picture> whose sources
|
|
||||||
* all 404 still lays out and still passes toBeVisible(), so the weaker
|
|
||||||
* assertion would go green on a broken lockup.
|
|
||||||
*/
|
|
||||||
const mark = home.locator('img').first();
|
|
||||||
await expect(mark).toBeVisible();
|
await expect(mark).toBeVisible();
|
||||||
const markState = await mark.evaluate((el: HTMLImageElement) => ({
|
|
||||||
loaded: el.complete && el.naturalWidth > 0,
|
|
||||||
src: el.currentSrc,
|
|
||||||
}));
|
|
||||||
expect(markState.loaded, `header mark failed to load: ${markState.src}`).toBe(true);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('no visible text falls back to the browser default black', async ({ page }) => {
|
test('no visible text falls back to the browser default black', async ({ page }) => {
|
||||||
|
|||||||
@@ -193,17 +193,13 @@
|
|||||||
drawn SVG, so its colours are in the file, not here. What is left is the
|
drawn SVG, so its colours are in the file, not here. What is left is the
|
||||||
ground the artwork sits on and fades into.
|
ground the artwork sits on and fades into.
|
||||||
|
|
||||||
--hero-ground is sampled from the artwork's own copy area, not from
|
--hero-ground is sampled from the artwork's own copy area (#FDF9F3),
|
||||||
Sand — a scrim in Sand (#F5EFE6) is far enough off to leave a visible
|
not from Sand. The artwork reserves an empty cream region on the left
|
||||||
seam straight down the hero.
|
for the headline, and the scrim that guarantees text contrast fades from
|
||||||
|
this colour into it — a scrim in Sand (#F5EFE6, eight to thirteen points
|
||||||
The artwork's copy area is not one flat colour: it runs from a peach
|
darker per channel) leaves a visible seam straight down the hero. */
|
||||||
#FEE8D2 at the top to a cream #FDF3E7 around 43% height, and below ~48%
|
--hero-ground: #FDF9F3;
|
||||||
the left edge is foliage rather than cream. This value is sampled from
|
--hero-ground-rgb: 253, 249, 243;
|
||||||
the middle of that pale run, where the headline and search actually sit;
|
|
||||||
the scrim is what covers the foliage further down. */
|
|
||||||
--hero-ground: #FEF2E1;
|
|
||||||
--hero-ground-rgb: 254, 242, 225;
|
|
||||||
|
|
||||||
/* ── Geometry & motion ──────────────────────────────────────────────
|
/* ── Geometry & motion ──────────────────────────────────────────────
|
||||||
"Soft shapes, rounded corners" — the guideline's geometry is markedly
|
"Soft shapes, rounded corners" — the guideline's geometry is markedly
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 3.0 MiB After Width: | Height: | Size: 1.6 MiB |
@@ -460,30 +460,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
|
||||||
* Narrow phones: the button drops below the input instead of sharing the row.
|
|
||||||
*
|
|
||||||
* "Search schools" is a fixed 134px with `white-space: nowrap`, so on a 390px
|
|
||||||
* screen it took 48% of the row and left the input 102px of text space for a
|
|
||||||
* 194px placeholder — the field showed "School name or" and stopped. At 320px
|
|
||||||
* the input was down to 32px, which is too narrow to read what you are typing.
|
|
||||||
*
|
|
||||||
* Wrapping rather than restructuring: the pill stays one element, so it keeps
|
|
||||||
* its border, shadow and :focus-within ring, and the button gets a full-width
|
|
||||||
* tap target on the way. Giving the button a 100% flex-basis is what forces
|
|
||||||
* the wrap; the input keeps `flex: 1` and so takes the whole first row.
|
|
||||||
*/
|
|
||||||
@media (max-width: 480px) {
|
|
||||||
.heroMode .omniBoxContainer {
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 0.5rem;
|
|
||||||
}
|
|
||||||
.heroMode .searchButton {
|
|
||||||
flex: 1 1 100%;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 420px) {
|
@media (max-width: 420px) {
|
||||||
/* Below this the pin costs more room than it earns. */
|
/* Below this the pin costs more room than it earns. */
|
||||||
.heroMode .omniIcon {
|
.heroMode .omniIcon {
|
||||||
|
|||||||
@@ -92,22 +92,6 @@
|
|||||||
max-width: 34ch;
|
max-width: 34ch;
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
|
||||||
* Quieter than .description, but by size only — not by contrast.
|
|
||||||
*
|
|
||||||
* --on-sunken-faint measured 4.71:1 here, which clears AA by 0.21. That is a
|
|
||||||
* fine margin for decorative text and the wrong one for a disclaimer, whose
|
|
||||||
* whole job is to be legible to someone checking whether this is a government
|
|
||||||
* site. --on-sunken-muted is 5.66:1 on the same band.
|
|
||||||
*/
|
|
||||||
.independence {
|
|
||||||
margin: 0;
|
|
||||||
font-size: var(--step--2);
|
|
||||||
line-height: 1.6;
|
|
||||||
color: var(--on-sunken-muted);
|
|
||||||
max-width: 38ch;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sectionTitle {
|
.sectionTitle {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-family: var(--font-display);
|
font-family: var(--font-display);
|
||||||
|
|||||||
@@ -35,14 +35,6 @@ export function Footer() {
|
|||||||
<p className={styles.description}>
|
<p className={styles.description}>
|
||||||
Compare primary and secondary schools across England.
|
Compare primary and secondary schools across England.
|
||||||
</p>
|
</p>
|
||||||
{/*
|
|
||||||
Says once, plainly, what the landing page's value props only
|
|
||||||
imply: we publish official data, we are not an official body.
|
|
||||||
Cheap to state and expensive to be wrong about.
|
|
||||||
*/}
|
|
||||||
<p className={styles.independence}>
|
|
||||||
An independent site. Not affiliated with the Department for Education or Ofsted.
|
|
||||||
</p>
|
|
||||||
<a
|
<a
|
||||||
href="mailto:contact@schoolcompare.co.uk"
|
href="mailto:contact@schoolcompare.co.uk"
|
||||||
className={styles.link}
|
className={styles.link}
|
||||||
|
|||||||
@@ -161,22 +161,10 @@
|
|||||||
* page and out-shouts the H1 and the search box. Dimming it in CSS is the
|
* page and out-shouts the H1 and the search box. Dimming it in CSS is the
|
||||||
* whole treatment, and the scrim then fades it into the dark panel instead of
|
* whole treatment, and the scrim then fades it into the dark panel instead of
|
||||||
* into cream.
|
* into cream.
|
||||||
*
|
|
||||||
* Raising the brightness raises the background under light dark-theme text, so
|
|
||||||
* this value has a contrast floor, not just a taste range. Measured off
|
|
||||||
* rendered pixels, sampling background up to 120px past each line:
|
|
||||||
*
|
|
||||||
* brightness title body
|
|
||||||
* 0.52 11.01:1 6.44:1
|
|
||||||
* 0.75 9.48:1 5.21:1 ← current
|
|
||||||
*
|
|
||||||
* Body text is the binding one. Going much above 0.75 walks it toward the
|
|
||||||
* 4.5:1 floor, and at that point the scrim needs to carry further right rather
|
|
||||||
* than the artwork being dimmed less.
|
|
||||||
*/
|
*/
|
||||||
@media (prefers-color-scheme: dark) {
|
@media (prefers-color-scheme: dark) {
|
||||||
.heroArt img {
|
.heroArt img {
|
||||||
filter: brightness(0.75) saturate(0.72) contrast(1.02);
|
filter: brightness(0.52) saturate(0.72) contrast(1.02);
|
||||||
}
|
}
|
||||||
.heroPanel::before {
|
.heroPanel::before {
|
||||||
background: linear-gradient(
|
background: linear-gradient(
|
||||||
@@ -189,43 +177,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
|
||||||
* The one line in the first person, so it is set apart from the marketing copy
|
|
||||||
* around it without shouting: display face, a step down in size, and a short
|
|
||||||
* brand rule instead of a bullet or an emoji.
|
|
||||||
*
|
|
||||||
* No italic — Manrope ships no italic in the loaded weights, so font-style
|
|
||||||
* would be synthesised into a slant. Same reason .heroEmph sets font-style
|
|
||||||
* back to normal.
|
|
||||||
*/
|
|
||||||
.heroByline {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.55rem;
|
|
||||||
margin: 1.25rem 0 0;
|
|
||||||
font-family: var(--font-display);
|
|
||||||
font-size: var(--step--1);
|
|
||||||
font-weight: 500;
|
|
||||||
font-style: normal;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.heroByline::before {
|
|
||||||
content: '';
|
|
||||||
flex: 0 0 auto;
|
|
||||||
width: 1.25rem;
|
|
||||||
height: 2px;
|
|
||||||
border-radius: 1px;
|
|
||||||
background: var(--brand);
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 640px) {
|
|
||||||
.heroByline {
|
|
||||||
margin-top: 1rem;
|
|
||||||
font-size: var(--step--2);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.heroEyebrow {
|
.heroEyebrow {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -302,42 +253,18 @@
|
|||||||
* the same decision.
|
* the same decision.
|
||||||
*/
|
*/
|
||||||
@media (max-width: 860px) {
|
@media (max-width: 860px) {
|
||||||
/*
|
|
||||||
* Artwork above the copy, not below it.
|
|
||||||
*
|
|
||||||
* The DOM keeps .heroContent first so the desktop overlay does not depend on
|
|
||||||
* source order, which left the band stranded under the search on phones —
|
|
||||||
* reading as a strip stuck on the end rather than as a hero image. `order`
|
|
||||||
* moves it visually only; it is decorative and aria-hidden, so there is no
|
|
||||||
* reading order to disturb.
|
|
||||||
*
|
|
||||||
* It costs about 136px above the search. Measured on a 667px viewport (the
|
|
||||||
* shortest phone still in use) the search still lands near 400px, well
|
|
||||||
* inside the fold.
|
|
||||||
*/
|
|
||||||
.heroPanel {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
.heroContent {
|
.heroContent {
|
||||||
padding: 1.75rem 1.5rem 1.75rem;
|
padding: 2rem 1.5rem 1.75rem;
|
||||||
max-width: none;
|
max-width: none;
|
||||||
}
|
}
|
||||||
/* 13rem, not 11: at 860px an 11rem band is 4.5:1, and squeezing the 3.2:1
|
|
||||||
crop into that throws away enough height to cut the flag off the roof and
|
|
||||||
the base off the building. 13rem holds it at about 3.8:1, where the
|
|
||||||
building stays whole. */
|
|
||||||
.heroArt {
|
.heroArt {
|
||||||
position: static;
|
position: static;
|
||||||
order: -1;
|
height: 11rem;
|
||||||
height: 13rem;
|
|
||||||
}
|
}
|
||||||
/* The band crop puts the schoolhouse at 73% across — reported by
|
/* The band crop centres the schoolhouse at about 65% across, so squeezing
|
||||||
scripts/build-hero-images.js, which derives it from the crop box rather
|
toward 4.9:1 crops the empty sides rather than the subject. */
|
||||||
than leaving the two to drift. Squeezing narrower then crops the emptier
|
|
||||||
left side rather than the subject. */
|
|
||||||
.heroArt img {
|
.heroArt img {
|
||||||
object-position: 73% center;
|
object-position: 65% center;
|
||||||
}
|
}
|
||||||
/* The scrim exists to protect text sitting on the artwork. Nothing sits on
|
/* The scrim exists to protect text sitting on the artwork. Nothing sits on
|
||||||
it here, and leaving it would wash the band out. */
|
it here, and leaving it would wash the band out. */
|
||||||
@@ -378,12 +305,8 @@
|
|||||||
font-size: 1.75rem;
|
font-size: 1.75rem;
|
||||||
margin-bottom: 0.5rem;
|
margin-bottom: 0.5rem;
|
||||||
}
|
}
|
||||||
/* 10rem, not 8.5: the band is widest-per-height right at this breakpoint —
|
|
||||||
at 640px an 8.5rem band is 4.2:1, worse than anything above it, because
|
|
||||||
the height steps down here while the width does not. 10rem keeps it near
|
|
||||||
3.6:1, in line with the rest of the range. */
|
|
||||||
.heroArt {
|
.heroArt {
|
||||||
height: 10rem;
|
height: 8.5rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -157,20 +157,7 @@ const VALUE_PROPS: ValueProp[] = [
|
|||||||
{
|
{
|
||||||
icon: <ShieldCheckIcon />,
|
icon: <ShieldCheckIcon />,
|
||||||
tintClass: styles.propIconTrust,
|
tintClass: styles.propIconTrust,
|
||||||
/*
|
title: 'Official & trusted',
|
||||||
* "Built on official data", never "Official".
|
|
||||||
*
|
|
||||||
* The previous title was "Official & trusted", whose grammatical subject
|
|
||||||
* is this site — it reads as a claim that schoolcompare is itself an
|
|
||||||
* official service. It is not: it is an independent site that republishes
|
|
||||||
* official figures. The distinction is the difference between describing
|
|
||||||
* the data and describing ourselves, and only the first is true.
|
|
||||||
*
|
|
||||||
* Everywhere else the word appears ("official DfE figures", "the official
|
|
||||||
* figure isn't in") it already qualifies the data, which is correct and
|
|
||||||
* should stay.
|
|
||||||
*/
|
|
||||||
title: 'Built on official data',
|
|
||||||
body: 'Every figure comes from DfE performance tables and Ofsted.',
|
body: 'Every figure comes from DfE performance tables and Ofsted.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -463,6 +450,7 @@ export function HomeView({ initialSchools, filters, totalSchools, howItWorks, ed
|
|||||||
geoState={geoState}
|
geoState={geoState}
|
||||||
geoError={geoError}
|
geoError={geoError}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/*
|
{/*
|
||||||
|
|||||||
@@ -40,8 +40,8 @@
|
|||||||
const SIZES = '(min-width: 1400px) 1352px, (min-width: 769px) calc(100vw - 3rem), calc(100vw - 2rem)';
|
const SIZES = '(min-width: 1400px) 1352px, (min-width: 769px) calc(100vw - 3rem), calc(100vw - 2rem)';
|
||||||
|
|
||||||
/** Intrinsic size of the wide crop — the aspect hint that prevents reflow. */
|
/** Intrinsic size of the wide crop — the aspect hint that prevents reflow. */
|
||||||
const WIDE_W = 2000;
|
const WIDE_W = 1672;
|
||||||
const WIDE_H = 1125;
|
const WIDE_H = 941;
|
||||||
|
|
||||||
export function HeroIllustration() {
|
export function HeroIllustration() {
|
||||||
return (
|
return (
|
||||||
@@ -51,13 +51,13 @@ export function HeroIllustration() {
|
|||||||
<source
|
<source
|
||||||
media="(max-width: 860px)"
|
media="(max-width: 860px)"
|
||||||
type="image/avif"
|
type="image/avif"
|
||||||
srcSet="/brand/hero-band-600.avif 600w, /brand/hero-band-900.avif 900w, /brand/hero-band-1344.avif 1344w"
|
srcSet="/brand/hero-band-500.avif 500w, /brand/hero-band-700.avif 700w, /brand/hero-band-972.avif 972w"
|
||||||
sizes={SIZES}
|
sizes={SIZES}
|
||||||
/>
|
/>
|
||||||
<source
|
<source
|
||||||
media="(max-width: 860px)"
|
media="(max-width: 860px)"
|
||||||
type="image/webp"
|
type="image/webp"
|
||||||
srcSet="/brand/hero-band-600.webp 600w, /brand/hero-band-900.webp 900w, /brand/hero-band-1344.webp 1344w"
|
srcSet="/brand/hero-band-500.webp 500w, /brand/hero-band-700.webp 700w, /brand/hero-band-972.webp 972w"
|
||||||
sizes={SIZES}
|
sizes={SIZES}
|
||||||
/>
|
/>
|
||||||
{/*
|
{/*
|
||||||
@@ -71,15 +71,15 @@ export function HeroIllustration() {
|
|||||||
media query does, and it sits after the modern formats so they still
|
media query does, and it sits after the modern formats so they still
|
||||||
win where supported.
|
win where supported.
|
||||||
*/}
|
*/}
|
||||||
<source media="(max-width: 860px)" srcSet="/brand/hero-band-900.jpg" />
|
<source media="(max-width: 860px)" srcSet="/brand/hero-band-700.jpg" />
|
||||||
<source
|
<source
|
||||||
type="image/avif"
|
type="image/avif"
|
||||||
srcSet="/brand/hero-wide-1000.avif 1000w, /brand/hero-wide-1400.avif 1400w, /brand/hero-wide-2000.avif 2000w"
|
srcSet="/brand/hero-wide-900.avif 900w, /brand/hero-wide-1200.avif 1200w, /brand/hero-wide-1672.avif 1672w"
|
||||||
sizes={SIZES}
|
sizes={SIZES}
|
||||||
/>
|
/>
|
||||||
<source
|
<source
|
||||||
type="image/webp"
|
type="image/webp"
|
||||||
srcSet="/brand/hero-wide-1000.webp 1000w, /brand/hero-wide-1400.webp 1400w, /brand/hero-wide-2000.webp 2000w"
|
srcSet="/brand/hero-wide-900.webp 900w, /brand/hero-wide-1200.webp 1200w, /brand/hero-wide-1672.webp 1672w"
|
||||||
sizes={SIZES}
|
sizes={SIZES}
|
||||||
/>
|
/>
|
||||||
{/*
|
{/*
|
||||||
@@ -90,7 +90,7 @@ export function HeroIllustration() {
|
|||||||
*/}
|
*/}
|
||||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
<img
|
<img
|
||||||
src="/brand/hero-wide-1400.jpg"
|
src="/brand/hero-wide-1200.jpg"
|
||||||
alt=""
|
alt=""
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
width={WIDE_W}
|
width={WIDE_W}
|
||||||
|
|||||||
@@ -48,32 +48,13 @@
|
|||||||
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: 3px;
|
width: 2px;
|
||||||
transform: translateX(-50%);
|
transform: translateX(-50%);
|
||||||
background: var(--bg-card);
|
background: var(--brand);
|
||||||
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;
|
||||||
@@ -82,14 +63,13 @@
|
|||||||
.natTick::before {
|
.natTick::before {
|
||||||
content: '';
|
content: '';
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: -4px;
|
top: -3px;
|
||||||
left: 50%;
|
left: 50%;
|
||||||
transform: translateX(-50%);
|
transform: translateX(-50%);
|
||||||
width: 6px;
|
width: 5px;
|
||||||
height: 6px;
|
height: 5px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: var(--bg-card);
|
background: var(--brand);
|
||||||
box-shadow: 0 0 0 1px var(--text-primary);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.barHeaderRight {
|
.barHeaderRight {
|
||||||
@@ -142,24 +122,12 @@
|
|||||||
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-stronger);
|
background: var(--brand);
|
||||||
}
|
}
|
||||||
|
|
||||||
.barLabel {
|
.barLabel {
|
||||||
|
|||||||
@@ -143,40 +143,17 @@ 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(--brand)' }} />
|
<div className={styles.legendSwatch} style={{ background: 'var(--status-above)' }} />
|
||||||
Expected standard
|
Expected standard
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.legendItem}>
|
<div className={styles.legendItem}>
|
||||||
<div className={styles.legendSwatch} style={{ background: 'var(--brand-stronger)' }} />
|
<div className={styles.legendSwatch} style={{ background: 'var(--status-above)' }} />
|
||||||
Exceeding / high score
|
Exceeding / high score
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.legendItem}>
|
<div className={styles.legendItem}>
|
||||||
<div
|
<div className={styles.legendSwatch} style={{ background: 'var(--brand)', width: '3px', height: '12px', borderRadius: '2px' }} />
|
||||||
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,23 +1807,12 @@
|
|||||||
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: 3px;
|
width: 2px;
|
||||||
background: var(--bg-card);
|
background: var(--brand);
|
||||||
box-shadow: 0 0 0 1px var(--text-primary);
|
|
||||||
border-radius: 2px;
|
border-radius: 2px;
|
||||||
z-index: 2;
|
z-index: 2;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ The landing hero illustration: a path climbing through hills to a schoolhouse,
|
|||||||
with an empty cream area on the left reserved for the headline.
|
with an empty cream area on the left reserved for the headline.
|
||||||
|
|
||||||
- **Source:** supplied by the project owner
|
- **Source:** supplied by the project owner
|
||||||
- **Master:** `nextjs-app/assets/hero-source.png` (2000×1125 PNG), committed so
|
- **Master:** `nextjs-app/assets/hero-source.png` (1672×941 PNG), committed so
|
||||||
the derivatives can be regenerated
|
the derivatives can be regenerated
|
||||||
- **Derivatives:** `node scripts/build-hero-images.js` — never hand-edit these
|
- **Derivatives:** `node scripts/build-hero-images.js` — never hand-edit these
|
||||||
- **Licence:** **to be confirmed by the project owner**
|
- **Licence:** **to be confirmed by the project owner**
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 9.1 KiB |
|
After Width: | Height: | Size: 9.9 KiB |
|
Before Width: | Height: | Size: 9.6 KiB |
|
Before Width: | Height: | Size: 9.6 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 46 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 41 KiB |
|
Before Width: | Height: | Size: 82 KiB |
|
Before Width: | Height: | Size: 49 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 44 KiB |
|
Before Width: | Height: | Size: 59 KiB |
|
Before Width: | Height: | Size: 73 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 20 KiB |
@@ -45,24 +45,14 @@ const SRC = path.join(__dirname, '..', 'assets', 'hero-source.png');
|
|||||||
const OUT = path.join(__dirname, '..', 'public', 'brand');
|
const OUT = path.join(__dirname, '..', 'public', 'brand');
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Band crop, in source pixels, measured against the artwork rather than
|
* Band crop, in source pixels. The schoolhouse occupies roughly x 1180–1540,
|
||||||
* guessed: the schoolhouse spans roughly x 1420–1860, y 420–660, with its flag
|
* y 300–520, so this keeps it whole with headroom above the flag and enough
|
||||||
* up at y 330.
|
* hillside below to read as landscape rather than as a cutout.
|
||||||
*
|
|
||||||
* The crop is flush to the right edge because it has to be. The school sits at
|
|
||||||
* about 82% across the frame, so there is only ~140px of artwork to its right
|
|
||||||
* — not enough to centre it. Anchoring right and taking 1050px back puts the
|
|
||||||
* school at ~66% across the crop, which is what BAND_FOCUS_X below reports to
|
|
||||||
* the CSS so the narrowest band crops the empty left side rather than the
|
|
||||||
* subject.
|
|
||||||
*/
|
*/
|
||||||
const BAND = { left: 656, top: 300, width: 1344, height: 420 };
|
const BAND = { left: 700, top: 260, width: 972, height: 380 };
|
||||||
|
|
||||||
/** Where the schoolhouse lands in the band crop, for CSS object-position. */
|
const WIDE_WIDTHS = [1672, 1200, 900];
|
||||||
const BAND_FOCUS_X = Math.round(((1420 + 1860) / 2 - BAND.left) / BAND.width * 100);
|
const BAND_WIDTHS = [972, 700, 500];
|
||||||
|
|
||||||
const WIDE_WIDTHS = [2000, 1400, 1000];
|
|
||||||
const BAND_WIDTHS = [1344, 900, 600];
|
|
||||||
|
|
||||||
async function emit(pipeline, name, width) {
|
async function emit(pipeline, name, width) {
|
||||||
const base = pipeline.clone().resize({ width, withoutEnlargement: true });
|
const base = pipeline.clone().resize({ width, withoutEnlargement: true });
|
||||||
@@ -77,26 +67,25 @@ async function main() {
|
|||||||
|
|
||||||
const meta = await sharp(SRC).metadata();
|
const meta = await sharp(SRC).metadata();
|
||||||
console.log(`source ${meta.width}×${meta.height}`);
|
console.log(`source ${meta.width}×${meta.height}`);
|
||||||
console.log(`band focus x: ${BAND_FOCUS_X}% (keep .heroArt object-position in step)`);
|
|
||||||
|
|
||||||
const wide = sharp(SRC);
|
const wide = sharp(SRC);
|
||||||
for (const w of WIDE_WIDTHS) await emit(wide, 'hero-wide', w);
|
for (const w of WIDE_WIDTHS) await emit(wide, 'hero-wide', w);
|
||||||
// Fallback for the <img> itself. Flattened onto the artwork's own cream so a
|
// Fallback for the <img> itself. Flattened onto the artwork's own cream so a
|
||||||
// JPEG (no alpha) can never composite against black.
|
// JPEG (no alpha) can never composite against black.
|
||||||
await sharp(SRC)
|
await sharp(SRC)
|
||||||
.resize({ width: 1400 })
|
.resize({ width: 1200 })
|
||||||
.flatten({ background: '#FEF2E1' })
|
.flatten({ background: '#FDF9F3' })
|
||||||
.jpeg({ quality: 82, mozjpeg: true })
|
.jpeg({ quality: 82, mozjpeg: true })
|
||||||
.toFile(path.join(OUT, 'hero-wide-1400.jpg'));
|
.toFile(path.join(OUT, 'hero-wide-1200.jpg'));
|
||||||
|
|
||||||
const band = sharp(SRC).extract(BAND);
|
const band = sharp(SRC).extract(BAND);
|
||||||
for (const w of BAND_WIDTHS) await emit(band, 'hero-band', w);
|
for (const w of BAND_WIDTHS) await emit(band, 'hero-band', w);
|
||||||
await sharp(SRC)
|
await sharp(SRC)
|
||||||
.extract(BAND)
|
.extract(BAND)
|
||||||
.resize({ width: 900 })
|
.resize({ width: 700 })
|
||||||
.flatten({ background: '#FEF2E1' })
|
.flatten({ background: '#FDF9F3' })
|
||||||
.jpeg({ quality: 82, mozjpeg: true })
|
.jpeg({ quality: 82, mozjpeg: true })
|
||||||
.toFile(path.join(OUT, 'hero-band-900.jpg'));
|
.toFile(path.join(OUT, 'hero-band-700.jpg'));
|
||||||
|
|
||||||
const files = fs.readdirSync(OUT).filter(f => f.startsWith('hero-')).sort();
|
const files = fs.readdirSync(OUT).filter(f => f.startsWith('hero-')).sort();
|
||||||
let total = 0;
|
let total = 0;
|
||||||
|
|||||||