The archive
06 / SD· Live·Launching soon

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.

Added2 Jun 2026
8Pages
16CMS models
AAAccessible

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

  1. 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_email plumbing 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.

  2. 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 no EMAIL_TRANSPORT, Directus falls back to sendmail, 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. The PUBLIC_URL default was corrected to the live host at the same time, since auth-email links are built from it.

  3. 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.

  4. 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.

  5. 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.

  6. 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 img so logos and UI imagery are unaffected.

  7. 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-cta class; the homepage grid is untouched.

  8. 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.

  9. 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 the directus_files foreign 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.

  10. 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.

  11. 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 .mono updates 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.

  12. 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.

  13. 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 via aria-current, and anchor targets get scroll-margin-top to clear the bar.

    One trap worth recording: the homepage nav modifier was named hero, which collided with the hero section's .hero rule and stacked the nav vertically. Renamed to .over-hero.

  14. Navigation layout shift eliminated — a font preload and a stable scrollbar gutter

    Two root causes. Fonts used font-display:swap with 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:stable on html settles it.

  15. 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.

  16. 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.

  17. 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.

  18. 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.

  19. 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.

  20. 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.

  21. 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.

  22. 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.

  23. 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.