Technical documentation

Colophon

How the site works, what it's made from, and why those choices were made.

The stack

Astro 6, statically generated, hosted on Netlify. Pages are HTML; interactivity is vanilla TypeScript. Netlify edge functions handle the genuinely server-side work: Last.fm album data, the AI endpoint behind /ask.

Fathom instead of Google Analytics — no cookies, no consent banner, no third-party reporting. The DLM RSS feed fetches at each deploy and is injected automatically into / and /ask.

SOURCES BUILD SITE LAST.FM HARDCOVER TRAKT BLUESKY SERVICES WEBMENTION.IO FATHOM RUNTIME MCP SERVER ASTRO 6 STATIC BUILD NETLIFY DEPLOY /MUSIC /LIBRARY /NOW / WEBMENTIONS ANALYTICS /ASK /MUSIC /LIBRARY /NOW /NOW BUILD-TIME RUNTIME

The data

The pages that feel most like live databases are all static HTML assembled at build time from offline source material.

Music starts in Roon — a library export matched against iTunes for cover art, then enriched with Last.fm play counts and genre data. The book library was bootstrapped from photographs of physical shelves: OCR'd into JSON, imported into Hardcover, and syncing normally since. Photo essays assemble from EXIF coordinates and timestamps, enriched with bank transaction exports and unpublished images for narrative context. DLM posts fetch from RSS on every deploy.

MUSIC ROON EXPORT ARTWORK SCRIPT LAST.FM API /MUSIC
LIBRARY SHELF PHOTOS OCR + JSON HARDCOVER API /LIBRARY
ESSAYS EXIF + GPS + BANK TX + DRAFTS PHOTO ESSAYS
DLM RSS FEED BUILD FETCH / + /ASK

The type system

Three typefaces: Oswald 700 for labels, headings, and any number that needs presence — its condensed proportions compress well and read confidently at small sizes. Barlow 300 for body text — light enough to feel editorial, not so light it disappears on a coloured background. Special Elite for the guestbook.

The MRFRISBY hero is sized by a canvas API at render time. A function called fitHero() measures the viewport width, calculates the exact font-size that fills it wall to wall and updates on resize. It fires again on Astro's astro:page-load event so it survives view transitions back to the home page. Both font files are self-hosted WOFF2 with font-display: swap and latin-subset preloading.

OSWALD · 700 · CONDENSED SANS

MRFRISBY

  • Site titleclamp(2rem, 6vw, 3.5rem) / −0.02em / uppercase
  • Page headings1rem / 0.08em / uppercase
  • Section labels, eyebrows0.55rem / 0.22em / uppercase
  • Navigation, data labels0.65rem / 0.12em / uppercase

BARLOW · 300 · HUMANIST SANS

I lead design organisations and build the conditions for great design work.

  • Body text, profile copy0.875rem / normal / normal
  • Long-form reading0.9rem / normal / normal
  • Update text, captions0.75–0.85rem / normal / normal

SPECIAL ELITE · 400 · TYPEWRITER SERIF

Why have you done this?

  • Guestbook onlyvariable / normal / normal
  • Typewriter texture reinforces the found-object metaphor of the EX-WORD format

The colour system

The palette is defined in OKLCH — a perceptually uniform colour space where equal steps in lightness actually look equal, unlike HSL or hex. The dark mode anchor is a deep forest green; light mode is a cool mint. The hue rotates slightly across the lightness axis: warmer at darker values, cooler at lighter ones, which gives the two modes a coherent relationship rather than feeling like unrelated palettes.

Colour-critical properties work via progressive enhancement: base hex values first, then oklch() inside @supports (color: oklch(0 0 0)). P3 gamut values are noticeably more saturated on displays that support it. The full palette, with contrast ratios, is on /colours.

VAR DARK L · C · H LIGHT L · C · H ROLE
--bg 0.30 · 0.090 · 143 0.87 · 0.075 · 158 Page background
--bg-surface 0.38 · 0.105 · 146 0.82 · 0.095 · 155 Panel backgrounds
--bg-elevated 0.47 · 0.118 · 148 0.77 · 0.110 · 152 Hover states, raised UI
--border-subtle 0.55 · 0.120 · 150 0.72 · 0.100 · 150 Hairlines, dividers
--border 0.66 · 0.130 · 152 0.63 · 0.115 · 147 Component borders
--text-mid 0.82 · 0.045 · 155 0.38 · 0.075 · 144 Labels, timestamps, secondary
--text 0.98 · 0.005 · 147 0.12 · 0.010 · 147 Body text, headings
--accent 0.82 · 0.275 · 145 0.42 · 0.210 · 145 Interactive, highlights
--opposite 0.63 · 0.220 · 027 0.88 · 0.070 · 027 Error states, complement

The semantic layer

Every page carries a JSON-LD @graph with typed Person and WebSite nodes. Dates use <time datetime="…">. Mobile nav uses a <details> element to avoid needing state management. Webmentions are received via webmention.io; the <link rel="webmention"> header lives in the document <head>. Social profiles carry rel="me". Interactive elements that lack visible text labels get aria-label. Photo essays use appropriate landmark roles.

mrfrisby.com — src
layouts/Base.astro
<script type="application/ld+json">
{
  "@context": "https://schema.org",
  "@graph": [
    { "@type": "Person",
      "name": "Stuart Frisby",
      "sameAs": ["…bsky.app/profile/mrfrisby.com"] },
    { "@type": "WebSite",
      "url": "https://mrfrisby.com" }
  ]
}
</script>

<link rel="webmention"
     href="https://webmention.io/mrfrisby.com/webmention"
/>
components/Nav.astro
<details>
  <summary>Menu</summary>
  <nav aria-label="Site navigation">
    <a href="https://bsky.app/…"
       rel="me">Bluesky</a>
  </nav>
</details>

<time datetime="2026-06-18">
  18 June 2026
</time>

<button aria-label="Close panel">×</button>

<!-- photo essays -->
<article role="article">
  <main aria-label="Photo essay"></main>
</article>