diff --git a/e2e/tests/journeys.spec.ts b/e2e/tests/journeys.spec.ts index 98ac194..49cecac 100644 --- a/e2e/tests/journeys.spec.ts +++ b/e2e/tests/journeys.spec.ts @@ -2554,3 +2554,32 @@ test('the destinations section never claims a pupil stayed at this school', asyn const text = (await section.textContent()) ?? ''; expect(text).not.toMatch(/stayed on (here|at this school)/i); }); + +/** + * The About page and the blog exist to give the site a named human author. + * These journeys assert the load-bearing parts of that — a name, a face, the + * honesty claim, and a resolvable Person entity — rather than exact copy, + * which will be edited. + */ +test('the about page names a human author and is reachable from the footer', async ({ page }) => { + await page.goto('/'); + const aboutLink = page.locator('footer a[href="/about"]'); + await expect(aboutLink).toBeVisible(); + await aboutLink.click(); + await page.waitForURL(/\/about$/); + + await expect(page.getByRole('heading', { level: 1 })).toContainText('Tudor'); + await expect(page.locator('img[alt*="Tudor"]')).toBeVisible(); + + // The credibility claim is lived experience plus stated provenance, not + // expertise. If this sentence ever disappears the positioning has drifted. + await expect(page.getByText(/not an education expert/i)).toBeVisible(); + + const jsonLd = await page + .locator('script[type="application/ld+json"]') + .first() + .textContent(); + expect(jsonLd).toContain('"Person"'); + // First name only — a surname here would be the one place it leaks. + expect(jsonLd).not.toMatch(/familyName/); +}); diff --git a/nextjs-app/__tests__/app/aboutMetadata.test.ts b/nextjs-app/__tests__/app/aboutMetadata.test.ts new file mode 100644 index 0000000..192b811 --- /dev/null +++ b/nextjs-app/__tests__/app/aboutMetadata.test.ts @@ -0,0 +1,34 @@ +import { metadata } from '@/app/(frontend)/about/page'; +import { personJsonLd, organizationJsonLd } from '@/lib/jsonld'; + +describe('/about metadata', () => { + it('canonicalises to the bare path', () => { + expect(metadata.alternates?.canonical) + .toBe('https://www.schoolcompare.co.uk/about'); + }); +}); + +describe('author structured data', () => { + it('describes a Person with a first name and a photo', () => { + const person = personJsonLd(); + expect(person['@type']).toBe('Person'); + expect(person.name).toBe('Tudor'); + expect(person.image).toBe('https://www.schoolcompare.co.uk/brand/tudor.jpg'); + expect(person.url).toBe('https://www.schoolcompare.co.uk/about'); + }); + + it('never publishes a surname or an employer', () => { + // Author identity constraint: first name only. A surname here would be + // the one place it leaks, since JSON-LD is machine-read and archived. + const serialised = JSON.stringify(personJsonLd()); + expect(serialised).not.toMatch(/familyName|Sitaru/i); + expect(serialised).not.toMatch(/worksFor|affiliation/i); + }); + + it('describes the site as an Organization the Person authors for', () => { + const org = organizationJsonLd(); + expect(org['@type']).toBe('Organization'); + expect(org.name).toBe('schoolcompare'); + expect(org.url).toBe('https://www.schoolcompare.co.uk'); + }); +}); diff --git a/nextjs-app/app/(frontend)/about/About.module.css b/nextjs-app/app/(frontend)/about/About.module.css new file mode 100644 index 0000000..09dafcb --- /dev/null +++ b/nextjs-app/app/(frontend)/about/About.module.css @@ -0,0 +1,83 @@ +.page { + max-width: 42rem; + margin: 0 auto; + padding: 2.5rem 1.25rem 4rem; +} + +.header { + display: flex; + align-items: center; + gap: 1.25rem; + margin-bottom: 2rem; +} + +.portrait { + border-radius: 50%; + border: 2px solid var(--border); + object-fit: cover; + flex-shrink: 0; +} + +.kicker { + font-family: var(--font-ui); + font-size: 0.75rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--brand); + margin: 0 0 0.35rem; +} + +.heading { + font-family: var(--font-display); + font-size: clamp(1.5rem, 4vw, 2rem); + font-weight: 700; + line-height: 1.2; + color: var(--text-primary); + margin: 0; +} + +.subheading { + font-family: var(--font-display); + font-size: 1.15rem; + font-weight: 600; + color: var(--text-primary); + margin: 2.25rem 0 0.75rem; +} + +.prose p { + font-family: var(--font-ui); + font-size: 1rem; + line-height: 1.7; + color: var(--text-secondary); + margin: 0 0 1.1rem; +} + +/* The opening paragraph carries the page. Larger, and in the primary ink + rather than the secondary, so it reads as a voice rather than as body copy. */ +.lede { + font-size: 1.125rem; + color: var(--text-primary); +} + +.prose .lede { + font-size: 1.125rem; + color: var(--text-primary); +} + +.link { + color: var(--brand); + font-weight: 600; +} + +.link:hover { + color: var(--brand-strong); +} + +@media (max-width: 480px) { + .header { + flex-direction: column; + align-items: flex-start; + gap: 1rem; + } +} diff --git a/nextjs-app/app/(frontend)/about/page.tsx b/nextjs-app/app/(frontend)/about/page.tsx new file mode 100644 index 0000000..8dea136 --- /dev/null +++ b/nextjs-app/app/(frontend)/about/page.tsx @@ -0,0 +1,129 @@ +import type { Metadata } from 'next'; +import Image from 'next/image'; +import { absoluteUrl } from '@/lib/site'; +import { personJsonLd, organizationJsonLd } from '@/lib/jsonld'; +import styles from './About.module.css'; + +export const metadata: Metadata = { + title: 'About', + description: + 'Who builds schoolcompare, why it exists, and where its numbers come from.', + alternates: { canonical: absoluteUrl('/about') }, +}; + +export default function AboutPage() { + const jsonLd = { + '@context': 'https://schema.org', + '@graph': [personJsonLd(), organizationJsonLd()], + }; + + return ( +
+