The mark shipped so far was my SVG approximation, and it was wrong: the real
mark is a teardrop pin with a white window and a path flowing out of its base,
carrying three leaves — not a circle with a separate tail.
Both colourways are extracted from the supplied sheet, which has a genuinely
transparent background, so these are the artwork rather than a trace:
public/brand/mark.png teal pin, white window and path, green leaves
public/brand/mark-on-dark.png white pin with the counter knocked through
Two colourways are needed, not one. Rendered against every real ground, the
teal pin holds up on Warm White, white cards, Sand and both dark-theme grounds
— but it vanishes on the teal footer band, where only the white window
survives. The header therefore serves the teal artwork and swaps to the
on-dark artwork for dark-theme viewers through a <picture> source, needing no
JavaScript; the footer forces on-dark, because its band is teal in both
themes.
Everything downstream now derives from those two files: the favicon
(app/icon.png, replacing icon.svg), the touch icon (app/apple-icon.png,
replacing the generated apple-icon.tsx), the three PWA rasters, and the share
card, which reads public/brand/mark.png off disk so it can never drift from
the header. The logo sizing CSS keyed off a square box, which would have
squashed a 153:189 artwork — height now drives and width follows.
The wordmark stays live text in Manrope. The sheet's wordmark is a raster with
visible edge fringing, and the written style guide specifies Manrope; live
text also stays selectable, scales cleanly and recolours with the theme.
KNOWN LIMITATION: the largest instance on the sheet is 153×189. That is ample
for the header at 38px, the favicon and the share card, but short of the 512px
PWA icon, which is upscaled and slightly soft. A vector would fix it and is a
single swap — every consumer goes through components/Logo.tsx or public/brand.
Verified: tsc clean, 159/159 tests, build green, /icon.png and /apple-icon.png
emit as static routes, and the header, footer, share card and all five icons
were rendered and inspected in both themes.
Review follow-up on #86.
The blind coral -> brand rename recreated the exact collision this PR set out
to remove: coral had been both the primary CTA and the "below average" signal,
so every negative indicator followed --primary onto iris. Sixteen rules moved
back onto the status ramp — delta chips, trend-down arrows, progress-negative
values, statusBad, chipBad/badgeBad, and the urgent deadline chips.
The Ofsted scale had also lost its worst step, with grade 4 landing on brand
while 1-2 were teal and 3 was amber. It now escalates by weight rather than by
reaching for another hue: a tinted amber chip for "requires improvement", a
solid amber one for "inadequate" (5.1:1 light, 7.7:1 dark). Report-card grade 5
follows the same rule.
globals.css now describes status as valence — teal above/good, amber
below/needs-attention — which is what it has to mean for an urgent deadline,
rather than the narrower "comparison point only" the first draft claimed.
On the share-card fonts: /opengraph-image is prerendered, so the font read
happens in the builder stage where assets/ exists, and the baked PNG ships
inside .next/standalone/.next/server/app/. File tracing independently places
the fonts at .next/standalone/assets, which the existing standalone COPY
carries to /app/assets. So the reported ENOENT doesn't occur — but it depends
on the tracer resolving a runtime join(), and a miss would be a silent 500
rather than a build failure. Declared outputFileTracingIncludes for the route
and made the Dockerfile COPY explicit so neither is left to inference.
Also repointed the immutable Cache-Control rule from the deleted favicon.svg
to app/icon.svg, where it was caching a 404.
Verified: tsc clean, 159/159 tests, clean rebuild prerenders all three image
routes with the fonts present in standalone.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Replace X-Frame-Options: SAMEORIGIN with a CSP frame-ancestors directive
so analytics.schoolcompare.co.uk (Umami heatmap/recorder) can embed the
site while all other origins stay blocked. X-Frame-Options cannot allow a
specific sibling subdomain (ALLOW-FROM is deprecated/ignored by modern
browsers), so frame-ancestors is the correct replacement.
Also update the nginx snippet in DEPLOYMENT.md to match, so the reverse
proxy doesn't re-inject a conflicting X-Frame-Options header.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
next.config.js rewrites() bakes its destination into the build
(routes-manifest.json), capturing FASTAPI_URL at build time. Because one
frontend image is promoted staging->prod, the baked backend host forced
every environment to name the backend service identically; staging names
it 'backend_stg', so the browser's /api/* calls proxied to the baked
'http://backend' and failed with getaddrinfo ENOTFOUND backend. (SSR was
unaffected because lib/api.ts reads FASTAPI_URL at runtime.)
Replace the rewrites with route handlers that read FASTAPI_URL per
request:
- app/api/[...path]/route.ts — transparent proxy for all methods, streams
the response, strips hop-by-hop headers, and returns 502 on upstream
failure instead of crashing.
- app/sitemap.xml/route.ts — proxies the backend sitemap (robots.ts points
crawlers here).
The same promoted image now adapts to whatever the backend is called in
each environment. Verified: production build succeeds with /api/[...path]
and /sitemap.xml as dynamic routes and an empty rewrites manifest.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Frontend
- Dynamic-import Chart.js components on detail/compare views so Chart.js
no longer ships in initial JS.
- Drop force-dynamic on home, compare, rankings so internal data fetches
reuse Next.js's per-call revalidate cache.
- Switch /school/[slug] to ISR with a 7-day revalidate window (school
data updates annually).
- Preconnect to analytics + postcodes.io; remove redundant defer on the
Umami Script tag (afterInteractive already covers it).
- Bump images.minimumCacheTTL to 1 year.
- Extract HowItWorks and Editorial sections as server components passed
to HomeView via slot props so their JSX stays out of the client bundle.
Backend
- Add GZipMiddleware (min 512 bytes).
- Add CacheAndETagMiddleware: per-path Cache-Control with long s-maxage
+ stale-while-revalidate, ETag generation, and 304 on If-None-Match.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- Backend builds sitemap.xml from school data at startup (in-memory)
- POST /api/admin/regenerate-sitemap refreshes it after data updates
- New Airflow DAG (sitemap_generate) runs Sundays 05:00 and calls the endpoint
- Next.js proxies /sitemap.xml to the backend; removes the slow dynamic sitemap.ts
- docker-compose passes BACKEND_URL + ADMIN_API_KEY to Airflow env
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The rewrite destination was using FASTAPI_URL directly, which
replaced the entire destination including the :path* parameter.
This caused /api/compare to rewrite to just http://backend:80/api
instead of http://backend:80/api/compare.
Now properly constructs: ${FASTAPI_URL}/:path*
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- Migrate from vanilla JavaScript SPA to Next.js 16 with App Router
- Add server-side rendering for all pages (Home, Compare, Rankings)
- Create individual school pages with dynamic routing (/school/[urn])
- Implement Chart.js and Leaflet map integrations
- Add comprehensive SEO with sitemap, robots.txt, and JSON-LD
- Set up Docker multi-service architecture (PostgreSQL, FastAPI, Next.js)
- Update CI/CD pipeline to build both backend and frontend images
- Fix Dockerfile to include devDependencies for TypeScript compilation
- Add Jest testing configuration
- Implement performance optimizations (code splitting, caching)
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>