Sofia Dives
A storytelling and sponsorship website for Sofia, a 14-year-old elite junior diver on the Olympic pathway. Astro, TypeScript and a self-hosted Directus CMS on a bespoke Navy & Gold design system, deployed static to Cloudflare Pages. Enquiry-based with no on-site payments — every approach routes privately to her parents, and safeguarding leads every decision.
Overview
A public, static-first website for Sofia — a 14-year-old elite junior diver on the Olympic pathway. It does two jobs: tell her story compellingly, and turn sponsors, funders, press and supporters into backers through an enquiry flow her parents manage end to end. No commerce, no login, no fundraising totals — just the story and a way to get in touch.
Built with Astro and strict TypeScript, styled with Tailwind on a bespoke Navy & Gold design system, and powered by a self-hosted Directus CMS so every headline, stat, milestone, result and news post is editable without touching code. The frontend ships to Cloudflare Pages as a static build that reads committed JSON snapshots: on any content change a Directus flow commits fresh snapshots to GitHub and the push triggers the build, so the live site never depends on the CMS being up.
Safeguarding is the project’s highest-priority rule. First name only; no location, school, club or training specifics anywhere — copy, alt text, metadata or structured data — and parent-approved imagery throughout. Support is enquiry-based with no on-site payments: every tier and contact CTA opens one form that emails her parents server-side via EmailIt, behind Cloudflare Turnstile and a honeypot. Analytics are cookieless and the whole site is built to WCAG 2.1 AA.
Build log
23 entries
Enquiries route through Cloudflare Email Routing, and PARENT_EMAIL retires
Enquiries now send to a role address on our own domain, committed in
siteConfig, with Cloudflare Email Routing forwarding to the parents. Two reasons. Recipients change in the Email Routing UI — immediate, no redeploy, several destinations supported — where a Pages secret needs a deploy to take effect. And safeguarding: this is a child's site, so no parent's personal inbox is written into the repo, the build, or the Pages environment. Fewer copies is strictly better.EmailIt still sends; Email Routing only receives and forwards. The dead
parent_emailplumbing went with it — the value was injected into settings but no component ever rendered it. The field stays on the Directus singleton, still excluded from public read; the site just ignores it.Directus auth emails were never sent — undeclared EMAIL_* vars never reach the container
Password resets and user invites silently disappeared. The EMAIL_* values were set in the Coolify UI, but the directus service's
environment:block never declared them — and Compose uses the env file for${...}interpolation only, so an undeclared variable never reaches the container. With noEMAIL_TRANSPORT, Directus falls back tosendmail, which isn't in the image, so mail vanishes without a clear error.The password now uses
:?so a missing value fails the deploy loudly instead of booting with quietly broken email. ThePUBLIC_URLdefault was corrected to the live host at the same time, since auth-email links are built from it.Copy and meta refreshed after the NAGs treble
Sofia came out of the 2026 NAGs a triple national champion — gold on 1m, 3m and platform. Most of the site reads from Directus, but the fallback fixtures, the hardcoded StatCards standfirst and the SEO meta descriptions do not, so those were brought in line by hand. The results meta also drops “synchro”, since there are no synchro results; the enum values stay, because they are a schema contract and the filter only renders chips for boards that have results.
The news hero now respects the Directus focal point
The news hero, the news cards and the home hero all ignored each image's Directus focal point and cropped dead-centre, which pushed the subject out of frame on anything that wasn't centred to begin with. Focal point is now honoured, and the article hero scrim was rebalanced at the same time.
Results board labels read “Platform”, not “Platform (10m)”
A small labelling correction on the results board filter chips. The height qualifier was redundant and made the chips read as a different event.
Markdown content images softened with a 6px radius
Images embedded in CMS markdown — the story body and inline news images — were hard-edged against the page. Scoped to
.prose imgso logos and UI imagery are unaffected.The support page CTA was inheriting the homepage's two-column grid
The “Ready to talk?” block on /support reused
.contact, which is the homepage's two-column copy-and-figure grid. With flat children, the rule, heading, paragraph and button auto-placed across two columns — heading and button right, text left. Given its own centred.contact-ctaclass; the homepage grid is untouched.News cards get a badge row, and the index gets client-side pagination
Category badges shared the date meta-row and crowded it whenever a post carried more than one. They now sit in their own wrapping row pinned to the card bottom, so they align across cards. Pagination mirrors the Results pattern — progressive enhancement, nine per page, pager only past that, current page in gold.
It did not work first time: hiding relied on the
[hidden]attribute, but.news-card{display:flex}beat the UA[hidden]{display:none}, so every card stayed visible.special:['file'] does not create the FK — and Studio uploads break silently
Creating an image field over the Directus MCP with
special:['file']gives you the interface but not thedirectus_filesforeign key. The failure mode is nasty: uploads through Studio break while the public asset still resolves, so nothing looks wrong from the outside. Now a standing rule in CLAUDE.md — declare the FK relation explicitly on all five image fields.A discrete “Created & maintained by Useful Labs” colophon in the footer
A quiet maker's mark at the very bottom of the footer: muted small-caps text with a 16px white square logomark, Useful Labs linking out in a new tab. Two passes to get it right — the underline was displacing the vertical alignment, and the hairline divider framed the block and drew the eye. Both dropped. Discrete is the point.
The gold-medal SD monogram — a ring brand mark and a filled-disc favicon
The gold rounded-square mark is replaced by a thin gold ring with a gold SD on navy — reusing the site's signature ring motif and reading as achievement. One CSS change to
.brand .monoupdates both the nav and the footer, since both sit on navy; the news-card placeholder monogram is separately scoped and untouched. A single ring, not a double — the double lost definition at this size. The favicon is the filled gold disc with a navy SD, which stays crisp at 16–32px.A frosted-glass header while scrolling over the hero
An intermediate state between transparent and solid. The moment scrolling starts over the hero, the bar condenses and picks up a translucent navy with a backdrop blur, so hero content passing behind it stays legible instead of colliding with the brand and links — most noticeable on mobile. Once the hero is past, it resolves to solid navy and the blur is dropped behind the opaque bar. The toggle is rAF-throttled, and reduced-motion is still instant.
A sticky header — transparent over the hero, condensing on scroll
Fixed header, transparent and airy at 84px over the homepage hero, resolving to solid navy and condensing to 62px once the hero scrolls past — driven by an IntersectionObserver on the hero rather than a scroll handler, so there is no jank. Interior pages get the compact bar from the top and reserve
--nav-h, so no layout shift. Active page carries a gold underline viaaria-current, and anchor targets getscroll-margin-topto clear the bar.One trap worth recording: the homepage nav modifier was named
hero, which collided with the hero section's.herorule and stacked the nav vertically. Renamed to.over-hero.Navigation layout shift eliminated — a font preload and a stable scrollbar gutter
Two root causes. Fonts used
font-display:swapwith no preload, so every page painted in the fallback and then reflowed — about 6% width shift on the nav brand. Preloading the primary latin Hanken woff2 keeps the swap policy but removes the reflow. Second, with no stable scrollbar gutter, moving between scrolling and non-scrolling pages nudged centred content ~15px;scrollbar-gutter:stableonhtmlsettles it.The supporters wall becomes an engraved honour board, plus a homepage ribbon
Supporters were a plain grid. They are now text-first plates with a logomark, link and popover, given an engraved honour-board treatment with a hover lift and shimmer, and a champion frame for the top tier. A Team Sofia ribbon renders after the support section on the homepage — full-width pills on mobile, 44px touch targets, radius matched to the support plates.
News gets the magazine treatment: full-bleed hero, navy drop cap, calculated reading time
Single news posts now open with a full-bleed cover hero and an overlaid headline (a navy gradient and ring motif when there is no cover), then a bone reading body with a navy drop cap and Newsreader pull-quotes. Reading time is computed from word count, never entered by hand.
Results turns into a broadcast scoreboard — board filter, PB highlights, pagination
The Results page now leads with an airy achievement stat row and a Latest Result card, then a medal-badge scoreboard: filter by board (1m / 3m / platform / synchro), personal-best rows get a faint gold wash and a PB tag, and client-side pagination kicks in past 20 entries.
Enquiries now reach the parents’ inbox: Turnstile + EmailIt, zero payment surface
One shared enquiry form sits behind every tier and contact CTA. Cloudflare Turnstile plus a honeypot guard it, the send runs server-side in a Cloudflare Pages Function, and it routes to a single parent address. No checkout, no donations — support is always an enquiry.
EmailIt kept 404ing — turns out the endpoint is /v2/, not /v1/
The contact send was failing against the /v1/emails path. The live EmailIt API is /v2/emails — swapped it and the enquiry flow went green end to end.
Draft news stays invisible to the public API; the parent’s email never leaves the server
Safeguarding guardrails on the data layer: the public role reads only published news and active supporters, the parent email is excluded from every public response and injected server-side, and a draft news item was confirmed invisible to the public API. First name only, everywhere.
Image uploads land on Wasabi and come back as on-the-fly WebP
Directus stores uploads in an S3-compatible Wasabi bucket and serves them through on-the-fly transforms. A 1.8 MB PNG comes back as a ~92 KB WebP at the right width — hero, news covers and supporter logos are all sized at request time.
Editors edit in Directus; the site rebuilds itself from committed snapshots
Content-as-code: a Directus flow posts to /api/directus-sync, which pulls the public content and commits fresh JSON snapshots to GitHub. The push triggers the Cloudflare Pages build — so the live site reads committed snapshots and never depends on Directus being up.
From brief to a running Astro build in a day — 8 pages, Directus-wired, compiling clean
Day one and the whole thing stands up: Home, Her Story, Results, News, Support, Contact and Privacy, all ported from the mockups, wired to a self-hosted Directus, and passing astro check + build with zero errors. Static-first on Cloudflare Pages, strict TypeScript, self-hosted fonts.