/* ═══════════════════════════════════════════════════════════════
   Slowfire

   The page has two resting states and one continuous move between
   them. At scroll 0 it is the holding page, unchanged. At the end of
   the runway the wordmark has travelled into a fixed header and the
   site proper begins. Everything in between is driven by two numbers,
   --p and --e, written once per frame by app.js; every other value
   the transition touches is derived from them here rather than in JS.
   ═══════════════════════════════════════════════════════════════ */

/* ═══ Typefaces ══════════════════════════════════════════════════
   Self-hosted rather than fetched from Google. Three reasons, in
   order of weight: it drops two connections from the critical path in
   front of an entry sequence that is all about timing; it removes a
   runtime dependency on a third party for the one thing the identity
   rests on; and it stops every visitor's IP reaching Google, which is
   the arrangement EU courts have found against.

   Both families are OFL — see OFL-*.txt beside the files.

   latin-ext is declared but costs nothing until it is needed: the
   browser resolves unicode-range before it fetches, so a page with no
   character in that range never asks for the file. Which is why it is
   worth declaring for copy that does not use it yet. */
@font-face {
  font-family: 'EB Garamond';
  font-style: normal;
  font-weight: 400;
  font-display: swap;
  src: url('/assets/fonts/eb-garamond-400-latin.woff2') format('woff2');
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
  font-family: 'EB Garamond';
  font-style: normal;
  font-weight: 400;
  font-display: swap;
  src: url('/assets/fonts/eb-garamond-400-latin-ext.woff2') format('woff2');
  unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
@font-face {
  font-family: 'EB Garamond';
  font-style: italic;
  font-weight: 400;
  font-display: swap;
  src: url('/assets/fonts/eb-garamond-400-italic-latin.woff2') format('woff2');
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
  font-family: 'EB Garamond';
  font-style: italic;
  font-weight: 400;
  font-display: swap;
  src: url('/assets/fonts/eb-garamond-400-italic-latin-ext.woff2') format('woff2');
  unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
@font-face {
  font-family: 'Jost';
  font-style: normal;
  font-weight: 400;
  font-display: swap;
  src: url('/assets/fonts/jost-400-latin.woff2') format('woff2');
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
  font-family: 'Jost';
  font-style: normal;
  font-weight: 400;
  font-display: swap;
  src: url('/assets/fonts/jost-400-latin-ext.woff2') format('woff2');
  unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}

*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }

/* The entry reveal and the scroll engine both want the wordmark's
   tracking. Registering an additive offset lets the reveal animate its
   own share while the scroll engine owns the base; the two sum in
   letter-spacing. An initial-value must be computationally independent,
   hence px here — em is fine everywhere else. */
@property --wm-track-intro { syntax: '<length>'; inherits: false; initial-value: 0px; }

/* The entry reveal fades the corner marks in, but a filled animation
   outranks normal declarations — so animating `opacity` directly would
   permanently outvote both the resting weight and :hover. The reveal gets
   its own multiplier instead, and the two compose. */
@property --intro-o { syntax: '<number>'; inherits: false; initial-value: 1; }

/* Scroll progress, raw and eased. */
@property --p { syntax: '<number>'; inherits: true; initial-value: 0; }
@property --e { syntax: '<number>'; inherits: true; initial-value: 0; }

/* The polaroids' reveal. Same reasoning as --intro-o above, and the same
   trap avoided: a filled animation outranks a normal declaration, so an
   animation on `transform` would permanently outvote the card's :hover
   lift. These two are animated instead and the transform reads them, which
   leaves transform itself a plain declaration that :hover can still win.

   Registering them is what makes them interpolate at all — an unregistered
   custom property animates as a discrete swap at the halfway point.

   The initial values are the RESTING state, visible and level. A browser
   with no scroll-driven animations runs no animation and gets exactly
   this, which is the only acceptable way for a reveal to fail. */
@property --polaroid-in   { syntax: '<number>'; inherits: false; initial-value: 1; }
@property --polaroid-rise { syntax: '<length>'; inherits: false; initial-value: 0px; }

/* Light is the default ground; dark swaps the same roles below. */
:root {
  /* Day-to-night pacing. Kept in sync with THEME_FADE in canvas.js. */
  --theme-fade: 1800ms;
  /* How long the image takes to travel between the two pages. app.js reads
     this rather than keeping its own copy, so the ghost's flight and the
     hold on the real elements can never drift apart. */
  --flight-ms: 620ms;
  /* The beat the outgoing page spends fading before the new one commits.
     app.js reads this one too. */
  --leave-ms: 180ms;
  /* Both grounds, named, in one place. canvas.js reads these two — it has
     to fill its buffers with the same colour the page is painted in, and a
     bloom compositing against a slightly different cream than the one
     behind it shows as a seam at the canvas edge. --bg below then picks
     whichever is in force, so nothing else has to know there are two. */
  --bg-light:  #f5f0e8;
  --bg-dark:   #1c1a18;

  --bg:        var(--bg-light);
  --wordmark:  #5c4a3c;
  --secondary: #6f5a4a;
  /* The quietest a line of TEXT is allowed to get. 5.10:1 on the bare
     --bg, which is not the number that decided it — see below.

     It exists because the site's way of making a label quiet was to dim
     --secondary with opacity, and that does not survive contact with the
     maths: --secondary is only 1.27x the 4.5:1 floor to begin with, so
     0.62 opacity landed the nav at 2.62:1 and the scroll hint at 2.30.
     There is no opacity that reads as "quiet" and still passes. So quiet
     is a colour here, used at full strength, and hover moves to
     --wordmark rather than to opacity 1.

     THE NUMBER THAT DECIDED IT: the ground under this text is not --bg.
     The bloom canvas sits behind it and, in this theme, composites with
     multiply — so it pulls the cream DOWN, to about #f1e3d5 under the
     splash at the worst point of the breath. Darkening the ground under
     dark ink costs contrast rather than adding it, which is the opposite
     of the intuition. Against that real ground this tone measures 4.61,
     and the value it replaced measured 4.24 and failed. Anything picked
     against the flat token is measuring the wrong thing.

     Icon-only controls are a different rule — non-text UI has a 3:1
     floor, and those clear it at their current 0.75, so they keep it. */
  --chrome-ink: #746254;
  --hairline:  rgba(92, 74, 60, 0.13);
  /* A wash for the form fields. Mixed rather than stated so the two dark
     blocks below do not each need a copy: the ink flips, so this flips with
     it — recessed on the light theme, lifted on the dark one, the same 4%
     either way, exactly as --hairline keeps one alpha across both.

     Translucent and not opaque, unlike --placeholder above. main is z-index
     10 and the bloom and the paper texture are 1 and 2, so a field paints
     over both: a solid fill would cut a flat, untextured rectangle out of a
     page whose whole point is reading as one material. At 4% it composites
     to the same colour a solid fill would give and still lets the grain
     through. */
  --field-bg:  color-mix(in srgb, var(--wordmark) 4%, transparent);
  /* Laid over --field-bg on focus rather than replacing it, and carried by
     box-shadow rather than background-color: the background is on the 1800ms
     theme crossfade, so tinting it on focus would take two seconds to show
     up. An inset shadow fills the same box, and the shadow is already the
     fast channel that draws the focus underline — so the wash and the line
     arrive together on the one 200ms ease. 2% over 4% composites to a hair
     under 6%. */
  --field-focus: color-mix(in srgb, var(--wordmark) 2%, transparent);
  /* The same tints, pre-composited over the paper. Chrome repaints an
     autofilled field with an opaque colour of its own and a translucent
     shadow will not cover it, so those rules need solid equivalents. */
  --field-solid:       color-mix(in srgb, var(--wordmark) 4%, var(--bg));
  --field-solid-focus: color-mix(in srgb, var(--wordmark) 6%, var(--bg));
  /* The surface an image sits on before there is an image. Opaque, not a
     wash: the bloom behind it carries its own gradient, and a translucent
     tint laid over that stops reading as a block at any size worth
     noticing. A photograph will cover the bloom too, so this is also the
     honest preview of what lands here. */
  --placeholder:      #dcd3c5;
  --placeholder-edge: rgba(92, 74, 60, 0.28);

  /* The polaroid card. Lighter than the paper it lies on, because that is
     what a photographic print is — the lift is small (1.12:1 against --bg)
     and the shadow does most of the work of making it an object.
     --secondary reads 6.3:1 on it, comfortably clear of the floor, and
     unlike everything else on this page the card is opaque, so the bloom
     never reaches the caption and that number is the real one. */
  --polaroid:      #fdfcf8;
  --polaroid-edge: rgba(92, 74, 60, 0.10);
  --polaroid-shadow: 0 1px 2px rgba(60, 44, 32, 0.10), 0 8px 22px rgba(60, 44, 32, 0.11);
  --polaroid-lift:   0 2px 5px rgba(60, 44, 32, 0.13), 0 20px 42px rgba(60, 44, 32, 0.17);

  /* The map's palette. Set here as plain values rather than mixed at
     runtime, because that is how every other colour on this site is set —
     and because a map that computes its own tones is a map that drifts
     away from the page it sits in. */
  --map-land:       #efe9dc;
  --map-water:      #ddd3c1;
  --map-green:      #e8e3d1;
  --map-building:   #e5ddce;
  --map-road:       #ded4c4;
  --map-road-major: #cec2ac;
  --map-label:      #6f5a4a;
  --map-ctrl-filter: none;
  --grain-blend:   multiply;
  --grain-opacity: 0.022;
  --texture-opacity: 0.75;

  /* Wordmark endpoints. app.js measures the resolved values and writes
     them back as --wm-hero-* / --wm-header-*; these stay the single
     source of truth and double as pre-measurement fallbacks. */
  --wm-size-hero:    clamp(64px, 12vw, 168px);
  --wm-size-header:  clamp(19px, 2.2vw, 24px);
  --wm-track-hero:   0.02em;
  --wm-track-header: 0.02em;

  --header-h: 64px;

  /* Everything that belongs to the assembled site rather than to the splash
     arrives on this one curve, late enough never to crowd the wordmark. */
  --chrome-in: clamp(0, calc((var(--p) - 0.45) / 0.4), 1);
}

/* The dark palette is declared once, below. There is deliberately no
   @media (prefers-color-scheme: dark) block here any more.

   There used to be one, and it held a verbatim second copy of every
   declaration in the [data-theme="dark"] rule — fifteen of them, kept in
   agreement by hand. Adding a token to one and forgetting the other broke
   exactly one of the two routes into dark mode: the toggle would work and
   the system preference would not, or the reverse. That is close to the
   worst shape a bug can take, because nothing looks wrong until someone
   arrives by the other route.

   The system preference is now resolved by the bootstrap in index.html,
   which stamps data-theme before first paint. One source, one block.

   Not light-dark() either, which is the obvious modern answer and is
   wrong here: map.mjs reads this palette back with getComputedStyle().
   getPropertyValue(), and an unregistered custom property holding
   light-dark(...) hands back the unresolved function text rather than a
   colour — which MapLibre rejects, taking the whole map style with it. */

/* An explicit choice wins in both directions. Light needs no block of its
   own — it is the bare :root default, and the query above is guarded. */
:root[data-theme="dark"] {
  --bg:        var(--bg-dark);
  --wordmark:  #e8e0d4;
  --secondary: #b8a898;
  --chrome-ink: #968c82;
  --hairline:  rgba(232, 224, 212, 0.13);
  --placeholder:      #3a352f;
  --placeholder-edge: rgba(232, 224, 212, 0.26);
  /* Not a white print. A white card in a dark room is a light source, and
     five of them across the page would be the brightest thing on a site
     whose whole argument is quiet. A warm grey a step above the ground
     reads as the same object in the same light — 1.24:1 against --bg,
     --secondary at 6.1:1 on it. The shadow is deeper and the hairline is
     doing more, because a cast shadow barely registers on charcoal. */
  --polaroid:      #302b27;
  --polaroid-edge: rgba(232, 224, 212, 0.10);
  --polaroid-shadow: 0 1px 2px rgba(0, 0, 0, 0.36), 0 10px 26px rgba(0, 0, 0, 0.30);
  --polaroid-lift:   0 2px 6px rgba(0, 0, 0, 0.44), 0 22px 46px rgba(0, 0, 0, 0.42);
  --map-land:       #201e1b;
  --map-water:      #2c2825;
  --map-green:      #232120;
  --map-building:   #272421;
  --map-road:       #35312c;
  --map-road-major: #48423b;
  --map-label:      #b8a898;
  --map-ctrl-filter: invert(1);
  --grain-blend:   screen;
  --grain-opacity: 0.012;
  --texture-opacity: 0.40;
}

html {
  background: var(--bg);
  transition: background-color var(--theme-fade) ease-in-out;
  /* Smooth scrolling is a JS tween instead — it needs a completion
     callback to hand over to the router. */
  scroll-behavior: auto;
}

body {
  width: 100%;
  background: var(--bg);
  transition: background-color var(--theme-fade) ease-in-out;
  position: relative;
  overflow-x: hidden;
}

/* ─── Bloom canvas ───
   Backing store is a third of viewport size (see SCALE); the browser
   scales it up and the blur hides the difference entirely. */
/* Overhangs the viewport by BLOOM_MARGIN. blur(35px) samples well past
   the element's edge and fades to transparent there; with the canvas
   flush to the viewport that falloff showed as a pale border. The
   overhang pushes it off-screen. The size must be stated explicitly: a
   canvas is a replaced element, so width:auto would resolve to its
   backing-store size instead of stretching between the insets. */
#bloom-canvas {
  position: fixed;
  inset: -110px;
  width:  calc(100% + 220px);
  height: calc(100% + 220px);
  z-index: 1;
  pointer-events: none;
  filter: blur(35px);
}

/* ─── Paper texture ───
   Sits above the bloom but below the type, so it grounds the page without
   muddying the wordmark. Carries both dark and light ink at low alpha, so it
   composites normally and needs no per-theme blend mode — only a change
   of strength. */
#texture-canvas {
  position: fixed;
  inset: 0;
  width: 100%; height: 100%;
  z-index: 2;
  pointer-events: none;
  opacity: var(--texture-opacity);
  transition: opacity var(--theme-fade) ease-in-out;
}

/* ─── Grain canvas ───
   Stays on top of everything, the menu overlay included, so the whole
   site reads as one material. */
#grain-canvas {
  position: fixed;
  inset: 0;
  width: 100%; height: 100%;
  z-index: 50;
  pointer-events: none;
  opacity: var(--grain-opacity);
  mix-blend-mode: var(--grain-blend);
}

/* ═══ The splash ═══════════════════════════════════════════════
   A fixed layer rather than a sticky one: the runway below is pure
   scroll distance, so page content begins exactly where the header
   finishes assembling instead of a viewport-height later. */
.hero {
  position: fixed;
  inset: 0;
  z-index: 9;
  display: flex;
  align-items: center;
  justify-content: center;
  pointer-events: none;
  opacity: clamp(0, calc((0.35 - var(--p)) / 0.35), 1);
}

/* A full screen, so the splash is alone on it at rest and page content
   arrives from below only once the header has finished assembling. The
   travel itself is spent over the first TRANSITION_SPAN of this. */
.hero-runway { height: 100vh; }

/* Fully out once the transition has landed — otherwise an invisible splash
   keeps its Instagram link in the accessibility tree alongside the corner's,
   and offers two identical links to a screen reader. visibility, not display:
   the slot must stay measurable. */
html[data-collapsed] .hero { visibility: hidden; }

/* Both exist only under the splash route. */
html:not([data-splash]) .hero,
html:not([data-splash]) .hero-runway { display: none; }

.stack {
  position: relative;
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 20px;
  /* Leaves with the wordmark rather than sitting still beneath it. */
  transform: translateY(calc(-12px * var(--e)));
}

/* Holds the wordmark's place in the column. line-height is 1, so this
   height is exactly the line box the wordmark would occupy — which is
   what keeps the splash's layout identical now that the wordmark is
   out of flow. It is also where app.js measures the hero endpoint. */
.wordmark-slot {
  width: 1px;
  height: var(--wm-size-hero);
}

/* ─── Instagram link ─── */
.social {
  display: flex;
  align-items: center;
  justify-content: center;
  margin-top: 8px;
}

/* ─── Icon marks ───
   Every 18px mark on the page shares one resting weight, one hover and one
   focus ring: both Instagram links, the colour toggle, the menu button.
   They read as one family in the corner and as one set on the splash. */
.social a,
.theme-toggle,
.menu-button {
  display: flex;
  width: 18px;
  height: 18px;
  color: var(--secondary);
  opacity: calc(0.75 * var(--intro-o, 1));
  text-decoration: none;
  transition: opacity 0.4s ease, color var(--theme-fade) ease-in-out;
  -webkit-tap-highlight-color: transparent;
}

.social a:hover,
.theme-toggle:hover,
.menu-button:hover { opacity: var(--intro-o, 1); }

.social a:focus-visible,
.theme-toggle:focus-visible,
.menu-button:focus-visible {
  opacity: var(--intro-o, 1);
  outline: 1px solid currentColor;
  outline-offset: 6px;
}

/* Button chrome the anchors have no need of. */
.theme-toggle,
.menu-button {
  padding: 0;
  border: 0;
  background: none;
  cursor: pointer;
}

/* The splash layer is pointer-events:none so the runway scrolls through it;
   its one link has to opt back in. */
.hero .social a { pointer-events: auto; }

.social svg { width: 18px; height: 18px; }

/* ─── Scroll hint ───
   In the stack now, where the modality line used to sit — it was pinned to
   the floor of the screen while there were two things under the wordmark
   and only one of them was an invitation. Being in flow from the first
   frame is what keeps the stack centred around it: the entry animation
   fills backwards, so it occupies its space at opacity 0 and nothing
   moves when it arrives. */
.scroll-hint {
  display: flex;
  justify-content: center;
  /* Clear of the wordmark by more than the stack's own 20px: the mark is
     up to 168px tall and a cue tucked straight under it reads as a
     descender rather than as a separate invitation. */
  margin-top: clamp(10px, 2.4vh, 26px);
}

.scroll-hint__btn {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 9px;
  padding: 4px 10px;
  border: 0;
  background: none;
  cursor: pointer;
  pointer-events: auto;
  /* Was --secondary at 0.55, the worst ratio on the site at 2.30:1, and
     the one place the bloom is at full strength directly behind text —
     which is what set --chrome-ink's value. The container above owns the
     entry fade, so full strength here costs the choreography nothing. */
  color: var(--chrome-ink);
  transition: color 0.4s ease;
  -webkit-tap-highlight-color: transparent;
}

.scroll-hint__btn:hover { color: var(--wordmark); }

.scroll-hint__btn:focus-visible {
  color: var(--wordmark);
  outline: 1px solid currentColor;
  outline-offset: 4px;
}

/* The tagline's type, one step quieter by opacity rather than by size. */
.hint-scroll,
.hint-swipe {
  font-family: 'Jost', sans-serif;
  font-weight: 400;
  font-size: 10px;
  letter-spacing: 0.35em;
  text-transform: uppercase;
  /* The tracking adds a trailing space; pull it back so the word sits
     optically centred over the caret. */
  text-indent: 0.35em;
  white-space: nowrap;
  user-select: none;
}

/* The word names the gesture; the caret points at what is below it. */
.hint-swipe { display: none; }

@media (hover: none) and (pointer: coarse) {
  .hint-scroll { display: none; }
  .hint-swipe  { display: block; }
}

.scroll-hint__caret {
  width: 15px;
  height: 15px;
  display: block;
  /* Half the breath period, so the invitation keeps the page's pace
     instead of nagging over the top of it. */
  animation: hint-drift 5.45s ease-in-out infinite;
}

@keyframes hint-drift {
  0%, 100% { transform: translateY(0); }
  50%      { transform: translateY(4px); }
}

/* ═══ The wordmark ═════════════════════════════════════════════
   One element, fixed, living above both the splash and the menu. It is
   never duplicated and never crossfaded — it simply travels.

   font-size is interpolated rather than transform: scale(). A scaled
   layer rasterises once and goes soft at whichever end it wasn't
   rasterised for, which is not acceptable on the one element the whole
   identity rests on. This is a single contained line of out-of-flow
   text; re-laying it out per frame is cheap and crisp throughout. */
.wordmark {
  position: fixed;
  z-index: 45;
  left: 50%;
  top: calc(
    var(--wm-hero-y, 50vh) +
    (var(--wm-header-y, calc(var(--header-h) / 2)) - var(--wm-hero-y, 50vh)) * var(--e)
  );
  transform: translate(-50%, -50%);

  font-family: 'EB Garamond', serif;
  font-weight: 400;
  font-size: calc(
    var(--wm-hero-size, var(--wm-size-hero)) +
    (var(--wm-header-size, var(--wm-size-header)) - var(--wm-hero-size, var(--wm-size-hero))) * var(--e)
  );
  letter-spacing: calc(
    var(--wm-track-hero) +
    (var(--wm-track-header) - var(--wm-track-hero)) * var(--e) +
    var(--wm-track-intro)
  );

  /* Light mode uses a warm sepia rather than charcoal — ink on cream reads
     too hard at this size. Dark mode keeps the original cream. */
  color: var(--wordmark);
  transition: color var(--theme-fade) ease-in-out;
  white-space: nowrap;
  line-height: 1;
  user-select: none;
  contain: layout style;
}

/* Off-screen twin at the header's typography. Gives app.js the resolved
   header font-size and the width to reserve between the nav groups,
   without having to reason about either in JS. */
#wm-measure {
  position: absolute;
  top: 0; left: -9999px;
  visibility: hidden;
  pointer-events: none;
  font-family: 'EB Garamond', serif;
  font-weight: 400;
  font-size: var(--wm-size-header);
  letter-spacing: var(--wm-track-header);
  white-space: nowrap;
  line-height: 1;
}

/* ═══ Header ═══════════════════════════════════════════════════ */
.site-header {
  position: fixed;
  top: 0; left: 0; right: 0;
  height: var(--header-h);
  z-index: 20;
  opacity: var(--chrome-in);
  pointer-events: none;
  /* The bar has to stop the page rather than merely sit in front of it.
     It was transparent while every page was one screen tall and nothing
     ever passed underneath; a class page is now long enough that the
     reading would otherwise scroll straight through the nav. */
  background: var(--bg);
  transition: background-color var(--theme-fade) ease-in-out;
}

/* Where it is supported, the flame keeps burning through the bar the same
   way it burns through the menu — softened rather than covered. Opaque
   enough that what passes underneath is gone, not merely dimmed. */
@supports (backdrop-filter: blur(10px)) or (-webkit-backdrop-filter: blur(10px)) {
  .site-header {
    background: color-mix(in srgb, var(--bg) 92%, transparent);
    backdrop-filter: blur(10px);
    -webkit-backdrop-filter: blur(10px);
  }
}

/* pointer-events inherits, so the whole bar arms at once — and only once
   the transition has effectively landed. */
html[data-collapsed] .site-header { pointer-events: auto; }

.site-header::after {
  content: '';
  position: absolute;
  left: 0; right: 0; bottom: 0;
  height: 1px;
  background: var(--hairline);
  transition: background-color var(--theme-fade) ease-in-out;
}

/* One landmark for the whole bar — two <nav>s both named "Primary" is a
   worse answer than one nav that happens to sit in three columns. */
.nav-bar {
  display: grid;
  grid-template-columns: 1fr auto 1fr;
  align-items: center;
  height: 100%;
}

.nav { display: flex; align-items: center; gap: 34px; }
.nav--left  { grid-column: 1; justify-content: flex-end; }
.nav--right { grid-column: 3; justify-content: flex-start; }

/* Deliberately the same type as the splash's own small caps — the header
   speaks in the voice the splash already established, and it needs no
   extra weight fetched from Google Fonts. */
/* Full strength, in the chrome tone. This used to be --secondary at
   0.62, which rendered at 2.62:1 — the quietness was coming out of the
   contrast budget rather than out of the type. The type was already
   doing the work: 10px against 19px, uppercase against sentence case,
   0.28em tracking against 0.02em. Nothing about the hierarchy needed
   the dimming, so the response moves to colour and the resting state
   becomes legible. */
.nav a {
  font-family: 'Jost', sans-serif;
  font-weight: 400;
  font-size: 10px;
  letter-spacing: 0.28em;
  text-transform: uppercase;
  color: var(--chrome-ink);
  text-decoration: none;
  white-space: nowrap;
  transition: color 0.4s ease;
}

.nav a:hover,
.nav a[aria-current="page"] { color: var(--wordmark); }

.nav a:focus-visible {
  color: var(--wordmark);
  outline: 1px solid currentColor;
  outline-offset: 5px;
}

/* Reserves the wordmark's landing strip. --wm-w is measured. The explicit
   column matters on mobile: with the nav groups display:none they stop being
   grid items altogether, and an auto-placed gap would slide into column 1. */
.nav-gap { grid-column: 2; width: calc(var(--wm-w, 200px) + 108px); }

/* ═══ Corner controls ══════════════════════════════════════════ */
/* Both marks in one row, so the gap between them is stated once. Above the
   menu overlay: the corner stays live while it is open. */
.corner {
  position: fixed;
  top: 22px;
  right: 22px;
  z-index: 45;
  display: flex;
  align-items: center;
  gap: 15px;
}

/* Positioning context for its two stacked glyphs. */
.theme-toggle { position: relative; }

.menu-button {
  position: fixed;
  top: 22px;
  left: 22px;
  z-index: 45;
  display: none;
}

/* The corner Instagram arrives on the nav's curve, and must not be
   clickable before it does. Its weight is the shared icon weight above. */
.social--corner {
  margin-top: 0;
  opacity: var(--chrome-in);
  pointer-events: none;
}

html[data-collapsed] .social--corner { pointer-events: auto; }

/* Both glyphs are stacked; one fades out as the other turns in. Slower
   than a normal control response — it belongs to the same slow turn as
   the ground, not to the click. */
.theme-toggle svg {
  position: absolute;
  inset: 0;
  width: 18px;
  height: 18px;
  display: block;
  transition: opacity 1100ms ease-in-out, transform 1100ms ease-in-out;
}

.theme-toggle svg.is-hidden {
  opacity: 0;
  transform: scale(0.72) rotate(-35deg);
}

/* Two hairlines that fold into a cross. */
.menu-button i {
  position: absolute;
  left: 0; right: 0;
  height: 1px;
  background: currentColor;
  transition: transform 420ms cubic-bezier(0.4, 0, 0.2, 1);
}
.menu-button i:nth-of-type(1) { top: 5px; }
.menu-button i:nth-of-type(2) { top: 12px; }
html[data-menu] .menu-button i:nth-of-type(1) { transform: translateY(3.5px) rotate(45deg); }
html[data-menu] .menu-button i:nth-of-type(2) { transform: translateY(-3.5px) rotate(-45deg); }

/* ═══ Menu overlay ═════════════════════════════════════════════ */
.menu-overlay {
  position: fixed;
  inset: 0;
  z-index: 40;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 26px;
  background: var(--bg);
  overscroll-behavior: contain;
  opacity: 0;
  visibility: hidden;
  transition: opacity 420ms ease, visibility 0s linear 420ms;
}

/* Where it is supported, the flame keeps burning through the menu,
   softened rather than covered. */
@supports (backdrop-filter: blur(10px)) or (-webkit-backdrop-filter: blur(10px)) {
  .menu-overlay {
    background: color-mix(in srgb, var(--bg) 86%, transparent);
    backdrop-filter: blur(10px);
    -webkit-backdrop-filter: blur(10px);
  }
}

html[data-menu] .menu-overlay {
  opacity: 1;
  visibility: visible;
  transition: opacity 420ms ease, visibility 0s;
}

.menu-overlay a {
  font-family: 'EB Garamond', serif;
  font-weight: 400;
  font-size: clamp(28px, 7vw, 40px);
  letter-spacing: 0.02em;
  text-transform: uppercase;
  color: var(--wordmark);
  text-decoration: none;
  line-height: 1;
  opacity: 0;
  transition: color var(--theme-fade) ease-in-out;
}

.menu-overlay a[aria-current="page"] { color: var(--secondary); }

.menu-overlay:focus { outline: none; }

.menu-overlay a:focus-visible {
  outline: 1px solid currentColor;
  outline-offset: 0.18em;
}

html[data-menu] .menu-overlay a { animation: fade-up 700ms ease both; }
html[data-menu] .menu-overlay a:nth-child(1) { animation-delay: 120ms; }
html[data-menu] .menu-overlay a:nth-child(2) { animation-delay: 180ms; }
html[data-menu] .menu-overlay a:nth-child(3) { animation-delay: 240ms; }
html[data-menu] .menu-overlay a:nth-child(4) { animation-delay: 300ms; }

/* ═══ Pages ════════════════════════════════════════════════════ */
main {
  position: relative;
  z-index: 10;
  /* Enough that the runway can always be scrolled through in full. Stated
     here rather than on each page container: the head is part of the view's
     height too, and a `.page` claiming its own screen underneath one left
     short pages standing on 300px of nothing. */
  min-height: 100vh;
  transition: opacity 260ms ease;
}

/* Focused on route change so the reading position follows the page, but
   it is a scroll target, not a control — no ring. */
main:focus { outline: none; }

main[data-leaving] { opacity: 0; transition-duration: var(--leave-ms); }

.page {
  max-width: 720px;
  margin: 0 auto;
  padding: calc(var(--header-h) + 64px) 26px 140px;
}

.page h1, .page h2,
.class-body h1, .class-body h2,
.about-page h2, .page-head h1,
.home h2, .home h3 {
  font-family: 'EB Garamond', serif;
  font-weight: 400;
  color: var(--wordmark);
  line-height: 1.15;
  transition: color var(--theme-fade) ease-in-out;
}

.page h1, .class-body h1 { font-size: clamp(30px, 4.4vw, 44px); letter-spacing: 0.02em; }
.page h2, .class-body h2, .about-page h2, .home h2 { font-size: clamp(21px, 2.6vw, 26px); letter-spacing: 0.02em; margin-top: 54px; }

/* Nothing leads with a gap. */
.page > :first-child,
.class-body > :first-child,
.page-head > :first-child,
.home-section > :first-child,
.about-body > .about-section:first-child > :first-child { margin-top: 0; }

.page p,
.class-body p,
.about-page p,
.page-head p,
.home p {
  font-family: 'EB Garamond', serif;
  font-weight: 400;
  font-size: 19px;
  letter-spacing: 0.02em;
  line-height: 1.75;
  color: var(--secondary);
  margin-top: 18px;
  max-width: 58ch;
  transition: color var(--theme-fade) ease-in-out;
}

/* Three cards abreast will not sit inside a 720px measure without crushing
   the copy, so the index — and only the index — opens out. */
.page--wide { max-width: 1180px; }

/* ─── Page head ───
   Every page in the nav opens the same way: the title with one line under
   it, centred, and the page's own content beginning below. A class's page
   is not one of them — it opens on the class, not on the site.

   A container of its own rather than a block inside `.page`, because the
   about page has no `.page` to put it in: it is a full-bleed two-column
   grid, and this is what lets it open exactly like the three that aren't. */
.page-head {
  max-width: 720px;
  margin: 0 auto;
  padding: calc(var(--header-h) + 64px) 26px clamp(44px, 4.6vw, 68px);
  text-align: center;
}

/* A page opener rather than a section head — the same size a class page
   gives its title. */
.page-head h1 { font-size: clamp(34px, 5vw, 52px); }

/* Centred prose wants a shorter measure than a column of it: a line the
   full width of the head has too far to travel back for the eye to find
   the next one reliably. `.page p` sets 58ch and is class-plus-element,
   so this has to be named the same way to beat it. */
.page-head p {
  max-width: 50ch;
  /* Close to the title rather than clear of it: the two are one thought, and
     at a paragraph's distance the line below read as the start of the page
     instead of the second half of the heading. */
  margin: 12px auto 0;
  /* Not the 1.75 every other <p> takes. That is a reading leading, set for a
     long left-aligned column where the eye needs help finding the start of
     the next line; here there are two or three centred lines with a ragged
     edge on both sides and nothing holding them together, so the same figure
     simply pulls them apart. Nearer the register the class page's facts are
     set in, which is what makes those read as one block. */
  line-height: 1.5;
}

/* The gap under the head belongs to the head. Whatever follows starts at
   zero, so the distance between them is one number rather than two that
   have to be kept in agreement. */
.page-head + .page { padding-top: 0; }

/* ─── Class cards ─── */
/* One row, always. The offering is small enough to be taken in at a glance,
   and a glance is the point: three columns, not a list that scrolls. */
/* Tracks that cap rather than divide. `repeat(3, 1fr)` always cuts the
   row into thirds, so the one published class sat at a third width with
   two empty columns beside it. `auto-fit` alone overcorrects — it drops
   the empty tracks and lets a lone card stretch the full 1180px. A
   bounded track does both jobs: one card is a card, three fill the row,
   and the count can change without anyone editing this line. */
.class-grid {
  list-style: none;
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(280px, 380px));
  justify-content: center;
  gap: 36px;
  margin-top: 46px;
}

/* Closes the list off. The rule is what makes it read as the end of
   something rather than a stray line under a short grid.

   Named through the page on purpose: this is a <p> sitting inside one, and
   `.page p` is a class plus an element — more specific than a lone class —
   so it would otherwise win and set this line in Garamond at a reading
   measure, with a stub of a rule above it. */
.page .class-soon {
  margin-top: 56px;
  padding-top: 26px;
  border-top: 1px solid var(--hairline);
  font-family: 'Jost', sans-serif;
  font-size: 10.5px;
  letter-spacing: 0.28em;
  text-transform: uppercase;
  /* 3.06:1 as --secondary at 0.70. */
  color: var(--chrome-ink);
  /* The rule runs the width of the list it closes, rather than stopping at
     the 58ch measure `.page p` imposes on prose. */
  max-width: none;
  transition: color var(--theme-fade) ease-in-out,
              border-color var(--theme-fade) ease-in-out;
}

/* The whole card is the link, so the hover belongs to the card and not to
   the title inside it.

   A column rather than a block: side by side, a meta line that wraps to two
   lines in one card and one in the next would leave the three "Learn more"
   marks at three different heights. The card stretches to the row, and the
   mark is pushed to the floor of it. */
/* No resting dim. At 0.85 the card's summary sat at 4.15:1 — the copy is
   --secondary, which passes comfortably at full strength, so the only
   thing breaking it was the recession. The hover response moves to the
   two things that can carry it without touching the text: the frame's
   edge and the "learn more" mark. */
.class-card__link {
  display: flex;
  flex-direction: column;
  height: 100%;
  text-decoration: none;
}

.class-card__link .class-media {
  transition: border-color 0.4s ease, background-color var(--theme-fade) ease-in-out;
}
.class-card__link:hover .class-media { border-color: var(--secondary); }
.class-card__link:hover .class-card__more { color: var(--wordmark); }
.class-card__link .arrow { transition: transform 0.4s ease; }
.class-card__link:hover .arrow { transform: translateX(3px); }

.class-card__link:focus-visible {
  outline: 1px solid currentColor;
  outline-offset: 10px;
}

/* Fixed ratio in both states: the block a photograph will occupy is the
   block that stands there now, so nothing reflows when one arrives. */
.class-media {
  aspect-ratio: 4 / 3;
  background: var(--placeholder);
  border: 1px solid var(--hairline);
  overflow: hidden;
  transition: background-color var(--theme-fade) ease-in-out,
              border-color var(--theme-fade) ease-in-out;
}

/* Declared rather than inferred: the gallery frame always has slides inside
   it, so emptiness stopped being able to tell us anything. The flag goes on
   when nothing in the set has a src yet, and comes off with the first
   photograph — taking the stronger frame with it. */
.class-media[data-placeholder] { border-color: var(--placeholder-edge); }

.class-media img {
  display: block;
  width: 100%;
  height: 100%;
  object-fit: cover;
}

/* Where the crop lands when the frame's shape and the file's disagree.
   A portrait room shot in the cards' 4/3 box shows its middle band by
   default, and for a room that is the floor: the mats, the plant and the
   rest of the room are in the top half. `focus` on the photograph in
   content.mjs moves the band; nothing set and it stays centred.

   EVERY frame takes the same value, and that is not an oversight — it is
   the whole constraint. The shared-element flight grows a ghost from the
   card's rect to the destination's and transitions `top, left, width,
   height` only; object-position cannot tween along with it, so if the
   two ends disagreed the picture would scale up smoothly and then SNAP
   as the real element replaced the ghost. One value for the card, the
   ghost and the page is what keeps the flight continuous.

   It was briefly scoped to `:not(.class-media--hero)` on the theory that
   the class page shows the whole file anyway. It does not: that frame is
   `aspect-ratio: auto`, so its shape follows the viewport, and it hides
   about 12% of the height at 1440x900, 20% at 1920x1080 and 44% on a
   phone, where it is landscape. There was never a frame that did not
   need an answer here. */
.class-media img[data-focus="upper"] { object-position: 50% 22%; }
.class-media img[data-focus="lower"] { object-position: 50% 78%; }

/* On a class's own page the image is not an illustration in the column —
   it is one half of the screen, and it stays there while the other half
   is read. Sticky under the header rather than behind it: the wordmark
   travels into that bar and has to stay legible when it lands.

   The column carries the inset as padding and the image fills what is
   left, so the gap is one number and the image never has to know about
   the header or the edge of the screen. */
.class-hero,
.about-hero {
  position: sticky;
  top: var(--header-h);
  height: calc(100vh - var(--header-h));
  padding: var(--hero-inset);
}

.class-media--hero,
.about-media {
  aspect-ratio: auto;
  height: 100%;
}

/* Inside a card the title follows an image, not a section break — the 54px
   the page scale gives every h2 is a gap between subjects. */
.class-card h2 { margin-top: 22px; }
.class-card p  { margin-top: 12px; font-size: 17px; }

/* ─── Chrome voice ───
   Jost, uppercase, tracked wide: the same register as the nav and the
   scroll hint. These are labels on the page, not part of its prose, so
   they take that voice rather than the Garamond around them. */
.class-card__more,
.back-link,
.book-btn {
  font-family: 'Jost', sans-serif;
  font-weight: 400;
  text-transform: uppercase;
  transition: color var(--theme-fade) ease-in-out, opacity 0.4s ease;
}

/* A <p>, so it has the page's body rules to undo. */
/* Not chrome any more. Uppercase and wide tracking are for labels of two
   or three words; a venue and a date range are neither, and set that way
   they were being spelled out rather than read. This is the page's own
   reading voice, one line per fact. */
.class-card .class-meta {
  font-family: 'EB Garamond', serif;
  font-size: 16px;
  letter-spacing: 0.02em;
  line-height: 1.45;
  color: var(--secondary);
  margin-top: 10px;
  max-width: none;
  transition: color var(--theme-fade) ease-in-out;
}

.class-card .class-meta span { display: block; }

.class-card__more {
  display: inline-flex;
  align-items: center;
  align-self: flex-start;
  gap: 9px;
  /* auto takes up the slack the shorter cards have; the padding is the gap
     it still owes the copy above it once there is no slack left. */
  margin-top: auto;
  padding-top: 20px;
  font-size: 10.5px;
  letter-spacing: 0.28em;
  color: var(--wordmark);
}

.arrow {
  width: 13px;
  height: 13px;
  flex: none;
}
.arrow--back { transform: rotate(180deg); }

/* ─── A class's own page ───
   Half and half, and the halves do not share a container: the image runs
   to the edge of the screen, so nothing here inherits the centred measure
   the rest of the site is set in. */
.class-page {
  /* One number for every gap around the image: screen edge, header, floor. */
  --hero-inset: clamp(20px, 2.2vw, 36px);
  display: grid;
  grid-template-columns: 1fr 1fr;
  align-items: start;
}

.class-body {
  /* The column is already half the screen; this keeps the heading from
     stretching across all of it on a very wide one. */
  max-width: 960px;
  padding: calc(var(--header-h) + 64px) clamp(26px, 4vw, 64px) 140px;
}

/* The page-wide 58ch measure is set for a 720px column standing on its own.
   This one has half a screen to work with and a picture holding the other
   half, so it can carry a longer line without the eye losing its place. */
.class-body p { max-width: 66ch; }

.back-link {
  display: inline-flex;
  align-items: center;
  gap: 9px;
  font-size: 10.5px;
  letter-spacing: 0.28em;
  /* Was --secondary at 0.70 — 3.06:1, under the 4.5 floor for 10.5px
     text. Same treatment as the nav: the tone carries the quiet, and
     hover moves the colour rather than the opacity. */
  color: var(--chrome-ink);
  text-decoration: none;
  transition: color 0.4s ease;
}
.back-link:hover { color: var(--wordmark); }
.back-link:focus-visible {
  color: var(--wordmark);
  outline: 1px solid currentColor;
  outline-offset: 6px;
}

/* The back link is :first-child, so the page's no-leading-gap rule lands
   on it and the heading has to ask for its own space. */
.class-body .back-link + h1 { margin-top: 28px; }

/* Sits straight under the title now, so it takes a title's gap rather than
   a paragraph's.

   Named through its page, and it has to be: `.class-body p` is a class plus
   an element, so a lone `.lede` loses to it no matter how far down the file
   it sits — and this rule spent its whole life being overruled back to 19px.
   The same trap as `.class-soon` further up. */
.class-body .lede {
  font-size: 21px;
  margin-top: 18px;
}

/* ─── Header ───
   Everything needed to decide, above the fold and above the long read:
   what it is, who has it, when and where, and what it costs. */
/* No padding of its own: the gap under the book row is owned by the rule
   that follows it, so the row can sit evenly between the two. */
.class-head { padding-bottom: 0; }

/* A shade above the site-wide h1. This one opens a page of its own rather
   than a section of one, and it has half a screen to fill. */
.class-body h1 { font-size: clamp(34px, 5vw, 52px); }

/* The two facts that decide whether someone can come at all. A list, so
   they carry the weight of a list rather than of a sentence. */
.class-facts {
  list-style: none;
  margin-top: 26px;
  display: grid;
  gap: 11px;
}

.class-facts li {
  display: grid;
  grid-template-columns: 18px 1fr;
  gap: 13px;
  align-items: start;
  font-family: 'EB Garamond', serif;
  font-size: 18px;
  letter-spacing: 0.02em;
  line-height: 1.45;
  color: var(--wordmark);
  transition: color var(--theme-fade) ease-in-out;
}

.fact-icon {
  width: 17px;
  height: 17px;
  /* Optical, not metric: the cap height of the line beside it sits lower
     than the box the icon is drawn in. */
  margin-top: 4px;
  color: var(--secondary);
  transition: color var(--theme-fade) ease-in-out;
}

/* The action, following the facts with nothing drawn between them. One gap
   rather than a margin and a padding, since the padding only ever existed
   to hold the button off a rule that is no longer there. */
.class-book {
  display: flex;
  align-items: center;
  gap: 14px;
  /* The note hangs off this box rather than sitting in it. */
  position: relative;
  margin-top: 34px;
}

/* The primary action takes the width of the column it stands in rather than
   sitting as an island in the middle of it. Written as flex-grow, not a
   width, so it still gives way to the share square when that is switched
   back on and simply takes whatever is left beside it. */
.class-book .book-btn { flex: 1; text-align: center; }

/* The secondary action beside the primary one, and the site's usual
   hairline is exactly the right weight for that: filled says press me,
   outlined says there is also this. Square, and the height of the button
   it stands next to, so the two read as one pair. */
.share-btn {
  display: flex;
  align-items: center;
  justify-content: center;
  flex: none;
  width: 54px;
  height: 54px;
  padding: 0;
  background: none;
  border: 1px solid var(--hairline);
  color: var(--secondary);
  cursor: pointer;
  -webkit-tap-highlight-color: transparent;
  transition: color var(--theme-fade) ease-in-out,
              border-color var(--theme-fade) ease-in-out;
}

.share-btn:hover {
  color: var(--wordmark);
  border-color: var(--secondary);
}

.share-btn:focus-visible {
  color: var(--wordmark);
  outline: 1px solid currentColor;
  outline-offset: 4px;
}

.share-icon { width: 18px; height: 18px; }

/* Beside the pair rather than under it, where it would crowd the rule. The
   buttons sit at the start of the row and the note is last, so text
   appearing fills empty space to its right and moves nothing. */
.share-note {
  flex: none;
  margin-left: 8px;
  font-family: 'Jost', sans-serif;
  font-size: 10px;
  letter-spacing: 0.28em;
  text-transform: uppercase;
  color: var(--secondary);
  white-space: nowrap;
  pointer-events: none;
  opacity: 0;
  transition: opacity 320ms ease, color var(--theme-fade) ease-in-out;
}
.share-note[data-shown] { opacity: 1; }

/* ─── The action on a phone ───
   A class page is long, and on a phone the row up in the head is off the
   top of the screen for almost all of it — the button that decides the
   whole page is somewhere above, and getting back to it means scrolling
   back to the title. So at that width the button in the head comes off the
   page and this takes over as the page's only booking link: always on the
   floor of the screen, from the first frame, with nothing to reveal it and
   nothing it could disagree with. Above 720px it is the one that is not
   there, and the row in the head is the whole story.

   Fixed, so it answers to the screen and not to the column: it is a sibling
   of .class-body for exactly that reason — see app.js.

   The paper is the header's own, blur and all, so the two edges of the
   screen read as one surface. Where there is no backdrop-filter the paper
   above stands in: the bloom burns behind the whole site, and a solid band
   across the bottom of the screen would put a flat panel in front of it. */
.class-cta {
  display: none;
  position: fixed;
  z-index: 30;
  left: 0;
  right: 0;
  bottom: 0;
  padding: 12px 22px;
  border-top: 1px solid var(--hairline);
  background: var(--bg);
  transition: opacity 320ms ease,
              visibility 0s,
              background-color var(--theme-fade) ease-in-out,
              border-color var(--theme-fade) ease-in-out;
}

@supports (backdrop-filter: blur(10px)) or (-webkit-backdrop-filter: blur(10px)) {
  .class-cta {
    background: color-mix(in srgb, var(--bg) 92%, transparent);
    backdrop-filter: blur(10px);
    -webkit-backdrop-filter: blur(10px);
  }
}

/* The width of the bar it sits in, as the button above is the width of the
   column it sits in. */
.class-cta .book-btn { display: block; text-align: center; }

/* The menu is the only thing on the site that takes the whole screen, and
   while it is open the page underneath is out of play. Left up it would
   show through the overlay's blur as a solid slab at the foot of a list of
   four words. Visibility rather than display, so it goes as the overlay
   arrives rather than an instant before it. */
html[data-menu] .class-cta {
  opacity: 0;
  visibility: hidden;
  transition: opacity 320ms ease,
              visibility 0s linear 320ms,
              background-color var(--theme-fade) ease-in-out,
              border-color var(--theme-fade) ease-in-out;
}

/* ═══ Home ═════════════════════════════════════════════════════
   A page of bands rather than one column. Each section owns its own
   measure, which is why `.home` is a container in the shared type rules
   above rather than being nested inside `.page` — `.page p` is a class
   plus an element, so anything living inside it inherits the 720px
   reading measure whether or not it wants it, and no lone class can
   argue with that. Naming the container is the only rule that wins. */
.home { padding-bottom: 140px; }

/* ─── The hero ───
   The home page opens on this instead of the shared `.page-head`. Taller,
   centred, a line above the title and a single action at the foot of it.

   Deliberately SHY of a full screen. The splash has already spent one, and
   a second complete screen of atmosphere puts two of them in front of the
   first piece of information; at 72 the top of the band below shows at the
   bottom edge, which says there is more without asking for another scroll
   to find out. It is a min-height and not a height, so a longer subcopy
   makes the hero taller rather than spilling out of it. */
.home-hero {
  position: relative;
  min-height: 72vh;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  text-align: center;
  padding: calc(var(--header-h) + 72px) 26px clamp(56px, 7vw, 88px);
}

/* svh where it exists: on a phone, vh is the viewport with the browser
   chrome retracted, so a 72vh hero is taller than 72% of what is actually
   on screen the moment you arrive — and the band that is meant to peek in
   at the bottom does not. */
@supports (height: 1svh) {
  .home-hero { min-height: 72svh; }
  @media (max-width: 720px) { .home-hero { min-height: 64svh; } }
}

/* Wider than the 720px reading column the rest of the site is set in, and
   the width is the heading's rather than the prose's: "Stillness that
   generates heat." wants 759px at its full 68px, so in a 720 column it
   wrapped — 39px short of fitting, which is the worst possible margin.
   The line is the page's first statement and reads as one thought, so it
   gets the room to be one line.

   Nothing else widens with it. The subcopy keeps its own 52ch and stays
   centred inside this, which is what a centred paragraph wants; and the
   heading is sized in vw, so as the screen narrows the type shrinks with
   it and the line survives down to about 470px before it has to break.
   Below that it wraps, balanced, which is right — a 30px hero on a phone
   would be a smaller heading than the one on the about page. */
.home-hero__inner {
  position: relative;
  z-index: 1;
  max-width: 1000px;
  width: 100%;
}

/* The splash's voice, a screen later. Same family, same tracking, same
   case as MOVE · REST · RENEW above it and the nav beside it — so this
   reads as the site clearing its throat rather than as a new element.
   --chrome-ink for the same reason the nav uses it: at this size the
   quiet has to come from the colour, because there is no opacity that
   reads as quiet and still passes. */
/* Named through the section, and it has to be. This is a <p> inside
   `.home`, so both `.home p` and `.home-hero p` are a class PLUS an
   element and a lone `.home-hero__eyebrow` loses to either of them no
   matter how far down the file it sits — it rendered as centred Garamond
   at 19px, which is the site's reading voice and the opposite of a label.
   The same trap `.class-soon`, `.lede` and `.form-status` each carry a
   note about. The margin and the measure those rules impose have to be
   undone by hand here for the same reason. */
.home-hero .home-hero__eyebrow {
  font-family: 'Jost', sans-serif;
  font-weight: 400;
  font-size: 10.5px;
  letter-spacing: 0.28em;
  line-height: 1.5;
  text-transform: uppercase;
  color: var(--chrome-ink);
  max-width: none;
  margin: 0 0 clamp(22px, 2.6vw, 32px);
  transition: color var(--theme-fade) ease-in-out;
}

/* A step above the page-head's h1. This is the only heading on the site
   that has a screen to itself and nothing above it but a tracked label. */
.home-hero h1 {
  font-family: 'EB Garamond', serif;
  font-weight: 400;
  font-size: clamp(38px, 6vw, 68px);
  letter-spacing: 0.02em;
  line-height: 1.1;
  color: var(--wordmark);
  /* Only bites once the line has to break at all — balance redistributes
     across lines that already exist, it never causes a wrap. So this is
     the narrow-screen case: two even lines rather than a full first line
     and one stranded word. Ignored where unsupported, which is exactly
     the normal wrap it would otherwise have had. */
  text-wrap: balance;
  transition: color var(--theme-fade) ease-in-out;
}

/* Centred prose wants a shorter measure than a column of it, and a tighter
   leading — the same two corrections `.page-head p` makes, and for the same
   reason: at 1.75 a few centred lines with a ragged edge on both sides have
   nothing holding them together.

   Named through .home-hero because `.home p` is a class plus an element and
   would otherwise win on specificity no matter where this sits. */
.home-hero p {
  max-width: 52ch;
  margin: clamp(20px, 2.4vw, 28px) auto 0;
  font-size: clamp(18px, 1.5vw, 20px);
  line-height: 1.6;
  text-wrap: pretty;
}

/* The site's one filled control, borrowed whole. */
.home-hero__cta { margin-top: clamp(32px, 3.8vw, 44px); }

/* ─── With a photograph behind it ───
   Off until HOME.hero.image is set. The whole treatment hangs off the one
   attribute app.js writes, rather than the stylesheet trying to infer it
   from what is inside the section: inferring means :has(), and a :has()
   that fails to match here leaves pale type on pale paper. */
.home-hero__media {
  position: absolute;
  inset: 0;
  overflow: hidden;
}

.home-hero__media img {
  display: block;
  width: 100%;
  height: 100%;
  object-fit: cover;
}

/* The scrim, and it hangs off the MEDIA element rather than off the
   section. That is not arbitrary. It was written first as
   `.home-hero[data-photo]::before`, which is a pseudo-element inserted as
   the section's FIRST child — so it painted underneath the photograph
   that follows it in the box order, and the hero rendered with no scrim
   at all. Nothing threw, the gradient was there in the computed style,
   and a check that asked "is a gradient declared?" said yes. What caught
   it was sampling the rendered pixels: 1.00:1, type invisible.

   It is heavier than it looks like it should be, too. The first values
   were set by eye at 0.30–0.66; blown highlights in a photograph are
   very close to pure white, and no amount of pale ink beats white. These
   stops are solved for that worst case rather than a flattering one —
   white behind the type, darkened to 0.70. The tone is the site's warm
   ink and not black, so it reads as dusk rather than as a grey card.

   IF THE PHOTOGRAPH CHANGES, MEASURE IT AGAIN — sample the pixels with
   the type hidden, do not look at it. */
.home-hero[data-photo] .home-hero__media::after {
  content: '';
  position: absolute;
  inset: 0;
  background:
    linear-gradient(to bottom,
      rgba(24, 18, 14, 0.58) 0%,
      rgba(24, 18, 14, 0.70) 22%,
      rgba(24, 18, 14, 0.72) 62%,
      rgba(24, 18, 14, 0.60) 100%);
}

/* Brighter than the first pass. The label especially: at 10.5px it needs
   the full 4.5:1 and the tone it had on paper does not survive being put
   on a photograph. */
.home-hero[data-photo] h1 { color: #f8f2e8; }
.home-hero[data-photo] p  { color: #f2ebe0; }
.home-hero[data-photo] .home-hero__eyebrow { color: #f0e8db; }

/* The filled button takes the paper's colour off the theme, and on a dark
   photograph in light mode that is a cream block on a dark ground, which
   is right. In dark mode it would be a dark block on a dark photograph, so
   it is pinned to the light pairing either way. */
.home-hero[data-photo] .book-btn {
  color: #241d18;
  background: #f8f2e8;
  border-color: #f8f2e8;
}
.home-hero[data-photo] .book-btn:hover {
  background: #e8dfd0;
  border-color: #e8dfd0;
}
.home-hero[data-photo] .book-btn:focus-visible { outline-color: #f8f2e8; }


/* Whatever comes first takes its space from the hero's own bottom
   padding instead of adding to it. Written as a rule rather than by
   leaving the first section's margin off, because the order of these
   bands has already changed once — the band led, now the class does —
   and a margin that belongs to whichever is first should not have to be
   moved by hand when they swap.

   Named through .home so it is a class plus a class and beats the
   per-section margins below on specificity rather than on source order,
   which is the fight this stylesheet keeps losing. */
.home > .home-hero + * { margin-top: 0; }

/* The common column. Wide, because two of the three things standing in
   it are half-and-half. */
.home-section {
  max-width: 1180px;
  margin: clamp(72px, 8vw, 108px) auto 0;
  padding: 0 26px;
}

/* ─── What's on ───
   A .class-card laid on its side. Everything visual — the frame, the
   tint, the meta lines, the "learn more" mark, the hover — is the card's
   own, inherited by carrying the class; what is added here is only the
   change of axis. The slug on the wrapper is not decoration either: the
   shared-element flight looks for `.class-card[data-slug]` when it comes
   back from a class page, so the photograph travels between this page and
   that one with nothing added to the flight code.

   Scoped through .home-featured throughout. `.class-card__link` is a lone
   class and so is `.home-featured`, so a bare rule would be an even fight
   settled by source order — which is exactly the silent override this
   stylesheet keeps losing to. */
.home-featured { margin-top: 34px; }

.home-featured .class-card__link {
  flex-direction: row;
  align-items: center;
  gap: clamp(24px, 3.2vw, 52px);
}

/* The picture takes a little under half, so the words keep the longer
   side of the band. flex-basis rather than a grid track: the link is
   already a flex column that this rule turns on its side. */
.home-featured .class-media {
  flex: 0 0 42%;
  align-self: stretch;
}

.home-featured__body { flex: 1; min-width: 0; }

/* The way through to the rest, under the card. .back-link already carries
   the face, the tracking, the tone and the arrow; the only thing wanted
   here is the air above it.

   No centring and no --gutter, unlike .home-feed__link: that one is
   centred because .home-feed centres everything in it, and it holds a
   gutter because the strip beside it scrolls to the page edge. This band
   does neither, so the link sits on the same left edge as the heading. */
.home-section__link { margin-top: clamp(20px, 2.4vw, 28px); }

/* An h3, because the section heading above it is the h2. It takes the
   size a card's title takes rather than the size the page gives an h3 —
   this is a card, and it reads as one. */
.home-featured h3 {
  font-size: clamp(23px, 2.8vw, 29px);
  letter-spacing: 0.02em;
  margin-top: 0;
}

/* The mark sits under the copy rather than at the floor of a stretched
   column: on its side there is no slack for `margin-top: auto` to take
   up, and it would be pinned to the bottom of a tall photograph. */
.home-featured .class-card__more { margin-top: 0; }

/* ─── The polaroid strip ───
   A row of prints on a table. It scrolls sideways when it does not fit,
   which on a phone is always. */
/* Full width, unlike every other band on this page — and it has to be.
   Capped at the page's 1180 column the row was cut off mid-print with
   130px of bare paper to the right of the cut, which reads as clipping
   rather than as a row continuing off the screen.

   The gutter below is what holds the heading above it on the same left
   edge as the rest of the page. It is written with a percentage rather
   than with vw deliberately: 100vw INCLUDES the scrollbar, so on any
   platform that reserves space for one, a vw-based bleed overflows the
   page by its width. A percentage resolves against the containing block,
   which is the scrollbar's width narrower. */
.home-feed {
  margin-top: clamp(72px, 8vw, 108px);
  --gutter: max(26px, calc((100% - 1180px) / 2 + 26px));
}

/* The copy centres on the page; the prints do not. A row that scrolls
   sideways has to begin on the same left edge the rest of the page does,
   or the first print sits half off-screen before anyone has touched it —
   so --gutter stays on the strip and comes off the words. */
.home-feed { text-align: center; }

/* The strip leads the section now that the heading has gone, so its own
   top margin would stack on the section's. Same rule .home-section makes
   for its first child, for the same reason. */
.home-feed > :first-child { margin-top: 0; }

.polaroids {
  list-style: none;
  display: flex;
  /* `safe` is the point: a plain `center` on a flex row that overflows
     puts the first item off the left edge with no way to scroll back to
     it. safe center falls back to flex-start the moment the row is wider
     than the box. */
  justify-content: safe center;
  align-items: flex-start;
  gap: clamp(16px, 2.2vw, 32px);
  margin-top: 40px;
  /* Starts on the page's left edge and runs to the screen's right one, so
     a print can be genuinely half off-screen — which is the only thing
     telling anyone there is more of them. */
  padding-inline: var(--gutter);
  scroll-padding-inline: var(--gutter);
  /* Room above and below for the tilt to lean into and the shadow to
     fall, neither of which is part of the card's box. */
  padding-block: 26px 40px;
  overflow-x: auto;
  overscroll-behavior-x: contain;
  scroll-snap-type: x proximity;
  -webkit-overflow-scrolling: touch;
  /* The scrollbar is chrome this design does not draw anywhere else, and
     the cards are cut off at the edge, which says the same thing. */
  scrollbar-width: none;
  /* The timeline the prints reveal against, named here and referenced by
     them below. It has to be named, and that is not a preference.
     `overflow-x: auto` makes this box a scroll container on BOTH axes —
     the spec turns `overflow-y: visible` into `auto` the moment its
     partner is not visible — so a plain `view(block)` on a child resolves
     to THIS element rather than to the page, and inside a box it never
     scrolls out of, a child never enters or leaves. Measured: every print
     sat at opacity 1 at every scroll position, and the reveal was simply
     inert.

     A named view-timeline is measured against the named element's OWN
     nearest scrollport, which for this box is the document. Which is the
     thing we actually meant. */
  view-timeline-name: --polaroid-strip;
  view-timeline-axis: block;
}
.polaroids::-webkit-scrollbar { display: none; }

/* The <li> carries the reveal and nothing else. The card inside it
   carries the tilt, the surface and the hover — and they are two
   elements on purpose: the reveal drives `transform` through an animated
   custom property, and a transition on the same property would fight it
   frame by frame. Split in two, each transform belongs to one thing. */
.polaroid {
  flex: 0 0 232px;
  scroll-snap-align: center;
  opacity: var(--polaroid-in);
  transform: translateY(var(--polaroid-rise));
}

.polaroid__link {
  display: block;
  position: relative;
  padding: 11px 11px 0;
  background: var(--polaroid);
  border: 1px solid var(--polaroid-edge);
  box-shadow: var(--polaroid-shadow);
  text-decoration: none;
  transform: rotate(var(--tilt, 0deg));
  -webkit-tap-highlight-color: transparent;
  transition: transform 400ms ease, box-shadow 400ms ease,
              background-color var(--theme-fade) ease-in-out,
              border-color var(--theme-fade) ease-in-out;
}

/* Deterministic, and from the stylesheet rather than an inline style on
   each item. Random angles would re-roll on every render — the same photo
   leaning a different way each time you come back to the page is the one
   thing that would stop these reading as objects. Four of them, so a row
   of any length keeps varying. */
.polaroid:nth-child(4n + 1) .polaroid__link { --tilt: -3deg; }
.polaroid:nth-child(4n + 2) .polaroid__link { --tilt:  2deg; }
.polaroid:nth-child(4n + 3) .polaroid__link { --tilt: -4deg; }
.polaroid:nth-child(4n)     .polaroid__link { --tilt:  1deg; }

/* Picked up and straightened. Only the anchor: a polaroid with no
   permalink renders as a <div>, and a card that lifts under the cursor
   without being pressable is a promise the page cannot keep. The element
   selector is what puts this above the rule that sets the tilt. */
a.polaroid__link:hover,
a.polaroid__link:focus-visible {
  transform: rotate(0deg) translateY(-7px);
  box-shadow: var(--polaroid-lift);
  z-index: 2;
}

.polaroid__link:focus-visible {
  outline: 1px solid var(--wordmark);
  outline-offset: 5px;
}

/* Square, because that is the shape Instagram gives back and the shape a
   print is. The tint underneath is the site's placeholder, so an empty
   set renders as blank prints rather than as holes. */
.polaroid__frame {
  display: block;
  aspect-ratio: 1 / 1;
  background: var(--placeholder);
  overflow: hidden;
  transition: background-color var(--theme-fade) ease-in-out;
}

.polaroid__frame img {
  display: block;
  width: 100%;
  height: 100%;
  object-fit: cover;
}

/* The deep band along the bottom, which is the whole silhouette of the
   thing. min-height holds it open when there is no caption, so a set with
   captions on some and not others still reads as one stack of prints.

   Garamond italic rather than the site's Jost chrome: a caption on a
   print is handwritten, and the italic is the nearest thing to a hand
   this site owns. It is also already loaded — /classes sets a word of its
   heading in it — so it costs no new font file. */
.polaroid__caption {
  /* Sized for TWO lines whether or not there are two, and centred in the
     band. A print is a physical object and they are all the same size —
     with the band sized to its contents, a caption that wrapped made one
     card 22px deeper than the ones beside it, and a row of prints at
     three different depths stops reading as a stack of prints and starts
     reading as a layout bug. Nought, one and two lines now all give the
     same card.

     Not clamped: a third line still grows that one card rather than
     silently swallowing the end of Kai's caption. Keep them to a handful
     of words, as the note over INSTAGRAM says. */
  display: flex;
  align-items: center;
  justify-content: center;
  min-height: calc(2.8em + 31px);
  padding: 13px 4px 18px;
  font-family: 'EB Garamond', serif;
  font-style: italic;
  font-size: 14px;
  letter-spacing: 0.01em;
  line-height: 1.4;
  color: var(--secondary);
  text-align: center;
  transition: color var(--theme-fade) ease-in-out;
}

/* .back-link is an inline-flex, so its text is one anonymous flex item
   and will not break inside itself — at the label's old length that never
   mattered, but "See more on Instagram @slowfireco" is 322px at this
   tracking and a 320px phone leaves 276. It was overhanging the viewport
   at both ends and being silently clipped by body{overflow-x:hidden},
   which is the kind of thing that never shows up on the machine it was
   written on. Allowing the row to wrap, and the text item to shrink below
   its own content width, lets it break across two lines instead. */
.home-feed__link {
  margin-top: 8px;
  /* The same gutter the prints keep, so the label breaks before it reaches
     the edge rather than after — at 360px it was fitting with 19px to
     spare against a 22px margin, which is inside the page's own edge. */
  max-width: calc(100% - 2 * var(--gutter));
  flex-wrap: wrap;
  justify-content: center;
}
.home-feed__link > * { min-width: 0; }

/* ─── The reveal ───
   Base state is VISIBLE — the registered properties above initialise to
   1 and 0px, so a browser that runs none of this shows the prints
   plainly. That is deliberate and it is the lesson of two earlier bugs
   here: a reveal gated on `transitionend` and one gated on MapLibre's
   `load` each left an element stuck invisible when the event never came.
   There is no IntersectionObserver in this, and no callback of any kind.
   Where the browser can drive it, it does; where it cannot, nothing has
   to run for the page to be right.

   The stagger is in the range rather than in a delay: a scroll-driven
   animation has no clock to delay against, so each card is given a window
   that opens slightly later than the one before it. They all read the one
   timeline named on the strip — see the note there for why it cannot be
   an anonymous view(). */
@supports (animation-timeline: view()) {
  @media (prefers-reduced-motion: no-preference) {
    .polaroid {
      animation: polaroid-in linear both;
      animation-timeline: --polaroid-strip;
      animation-range: entry 4% entry 46%;
    }
    .polaroid:nth-child(4n + 2) { animation-range: entry 9%  entry 51%; }
    .polaroid:nth-child(4n + 3) { animation-range: entry 14% entry 56%; }
    .polaroid:nth-child(4n)     { animation-range: entry 19% entry 61%; }
  }
}

@keyframes polaroid-in {
  from { --polaroid-in: 0; --polaroid-rise: 18px; }
  to   { --polaroid-in: 1; --polaroid-rise: 0px; }
}

/* ═══ About ════════════════════════════════════════════════════
   The same half and half as a class's own page, and the same two boxes
   doing it: a sticky column holding the position, a frame inside it
   holding the picture. One picture, and no controls over it — it simply
   stays while the column beside it is read. */
.about-page {
  /* One number for every gap around the image: screen edge, header, floor.
     Named as the class page names it, because `.about-hero` shares that
     rule and reads the number from here. */
  --hero-inset: clamp(20px, 2.2vw, 36px);
  display: grid;
  grid-template-columns: 1fr 1fr;
  align-items: start;
}

.about-body {
  /* The column is already half the screen; this keeps the heading from
     stretching across all of it on a very wide one. */
  max-width: 960px;
  /* Level with the top edge of the picture beside it, now that the title
     has moved up into the page head and there is no fixed header left for
     this column to clear. The floor is the site's usual one: with a single
     picture there is no handover to leave room for. */
  padding: var(--hero-inset) clamp(26px, 4vw, 64px) 140px;
}

/* Half a screen, with a picture holding the other half, carries a longer
   line than the 58ch the site sets for a 720px column standing alone. */
.about-page p { max-width: 66ch; }

/* Borrowed whole from the class page, with one thing changed: there the facts
   are set in the ink colour because they are what decides whether you can come
   at all, and they have to carry that weight against a page of prose. Here the
   list is part of the prose, so it reads in the same colour as it. */
.about-section .class-facts li { color: var(--secondary); }

/* No furniture of its own. The column is a heading and its copy, then the
   next — the way a class page sets its long read, where the only thing
   between two subjects is the gap the heading already carries. `.about-section`
   stays in the markup as the grouping; the 54px comes from the page scale. */

/* ─── Contact form ───
   No form control on this site inherits anything: the reset only touches
   box-sizing and the box model, and a browser gives inputs a font of their
   own regardless. Family, size, colour and background all have to be said
   outright or the page ends up with Arial in a box. */
.contact-form { margin-top: 38px; }

/* The gap between fields is not a lead-in to the first of them. */
.contact-form > .field:first-child { margin-top: 0; }

.field { margin-top: 26px; }

.contact-form label {
  display: block;
  font-family: 'Jost', sans-serif;
  font-weight: 400;
  font-size: 10px;
  letter-spacing: 0.28em;
  text-transform: uppercase;
  color: var(--secondary);
  margin-bottom: 10px;
  transition: color var(--theme-fade) ease-in-out;
}

/* An underline rather than a box. The hairline is the edge this design
   already uses everywhere else; a bordered rectangle would be a new shape
   the site does not otherwise draw. */
.contact-form input:not(.hp),
.contact-form textarea {
  display: block;
  /* The tint is pulled 12px wider than the column on each side while the
     text stays on it, so the fields line up with the labels above them and
     the copy above those, and the wash still has room to breathe rather
     than stopping dead against the first letter. */
  width: calc(100% + 24px);
  margin-left: -12px;
  font-family: 'EB Garamond', serif;
  font-weight: 400;
  font-size: 19px;
  letter-spacing: 0.02em;
  line-height: 1.75;
  color: var(--wordmark);
  background-color: var(--field-bg);
  border: 0;
  border-bottom: 1px solid var(--hairline);
  border-radius: 0;
  padding: 10px 12px;
  /* The focus line is declared here as transparent so there is something for
     it to grow out of; the border keeps the slow theme crossfade, the shadow
     gets a duration a keystroke can live with. */
  box-shadow: 0 1px 0 0 transparent, inset 0 0 0 100px transparent;
  transition: border-color var(--theme-fade) ease-in-out,
              background-color var(--theme-fade) ease-in-out,
              color var(--theme-fade) ease-in-out,
              box-shadow 200ms ease;
}

/* Down, never sideways: widening the box would break the measure. */
.contact-form textarea {
  resize: vertical;
  min-height: 96px;
}

/* The line thickens rather than a ring appearing. The outline the rest of
   the site draws is right for a button or a link, which are shapes with an
   edge to trace — around a field that is only an underline it fires as a
   hard rectangle out of nowhere, and it cannot be eased because outlines do
   not transition. A second line under the hairline is the same signal in
   the vocabulary the field is already speaking.

   Not paired with a border-colour change: border-color is on the 1800ms
   theme fade here, so darkening it on focus took two seconds to arrive
   while the ring had already snapped in. */
.contact-form input:not(.hp):focus-visible,
.contact-form textarea:focus-visible {
  outline: none;
  /* The line, and the field lifting a shade under it. The spread just has
     to exceed the field's own size for the inset to fill it. */
  box-shadow: 0 1px 0 0 var(--secondary),
              inset 0 0 0 100px var(--field-focus);
}

/* Chrome paints autofilled fields an opaque near-white and will not let a
   background-color override it — the inset shadow is the only way to put
   the paper back, and without it the dark theme fills with white boxes. */
.contact-form input:-webkit-autofill,
.contact-form input:-webkit-autofill:hover {
  -webkit-box-shadow: inset 0 0 0 100px var(--field-solid);
  -webkit-text-fill-color: var(--wordmark);
  caret-color: var(--wordmark);
}

/* The paper fill and the focus line are the same property, and this rule
   comes last — so it has to carry both or focusing an autofilled field
   would silently drop the underline. */
.contact-form input:-webkit-autofill:focus {
  -webkit-box-shadow: inset 0 0 0 100px var(--field-solid-focus), 0 1px 0 0 var(--secondary);
  -webkit-text-fill-color: var(--wordmark);
  caret-color: var(--wordmark);
}

/* Off-screen rather than display:none. A bot that fills every field is
   reading the styles too, and display:none is the tell it looks for. */
.hp {
  position: absolute;
  left: -9999px;
  width: 1px;
  height: 1px;
  opacity: 0;
}

/* The button owns the full column, so the note wraps to its own line
   underneath rather than sharing the row. */
.form-actions {
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  gap: 16px;
  margin-top: 38px;
}

.form-actions .book-btn { width: 100%; text-align: center; }

.contact-form .book-btn { cursor: pointer; }
.contact-form .book-btn:disabled { opacity: 0.55; cursor: default; }

/* Same voice and same behaviour as the share note, which is the other place
   on this site where a control reports back in words. */
/* Named through the form for the same reason .class-soon is named through
   the page: this is a <p> inside a .page, and `.page p` is a class plus an
   element, so a lone .form-status would lose and the result would come back
   in Garamond at a reading measure. The margin and the measure it imposes
   have to be undone by hand too — in a flex row both would move the button. */
.contact-form .form-status {
  margin: 0;
  max-width: none;
  /* One line's worth, held open whether or not there is anything in it:
     the note sits in flow under a full-width button now, so without this
     the first result to arrive would shove the rule below it down the
     page. 10px on a 1.6 line is 16. */
  min-height: 16px;
  font-family: 'Jost', sans-serif;
  font-size: 10px;
  line-height: 1.6;
  letter-spacing: 0.28em;
  text-transform: uppercase;
  color: var(--secondary);
  opacity: 0;
  transition: opacity 320ms ease, color var(--theme-fade) ease-in-out;
}
.contact-form .form-status[data-state] { opacity: 1; }
.contact-form .form-status[data-state="error"] { color: var(--wordmark); }

/* ─── Elsewhere ───
   Deliberately not .social: that class pins its anchors to 18px square, and
   this link has a handle to carry as well as a mark. */
.elsewhere {
  margin-top: 64px;
  padding-top: 8px;
  border-top: 1px solid var(--hairline);
  transition: border-color var(--theme-fade) ease-in-out;
}

.elsewhere > h2:first-child { margin-top: 38px; }

.social-link {
  display: inline-flex;
  align-items: center;
  gap: 12px;
  margin-top: 22px;
  font-family: 'Jost', sans-serif;
  font-size: 10.5px;
  letter-spacing: 0.28em;
  text-transform: uppercase;
  /* 3.36:1 as --secondary at 0.75. The mark beside the handle is an icon
     and would have passed on its own; the words next to it did not. */
  color: var(--chrome-ink);
  text-decoration: none;
  transition: color 0.4s ease;
}
.social-link:hover { color: var(--wordmark); }
.social-link:focus-visible {
  color: var(--wordmark);
  outline: 1px solid currentColor;
  outline-offset: 6px;
}
.social-link svg { width: 18px; height: 18px; flex: none; }

/* ─── The long read ─── */
.class-detail,
.class-map {
  padding-top: 8px;
  border-top: 1px solid var(--hairline);
  transition: border-color var(--theme-fade) ease-in-out;
}

/* Matched to .class-book's padding-top, which is the distance from the rule
   above the row down to it. Equal on both sides, so the price and the
   button sit centred in their band rather than crowded up against the top
   of it. */
.class-detail { margin-top: 30px; }

/* A section break between two bodies of text, which wants more air than a
   row does. */
.class-map { margin-top: 52px; }

/* First heading after a rule does not need the full gap a heading takes
   between subjects. */
.class-detail > h2:first-child,
.class-map > h2:first-child { margin-top: 38px; }

/* Items rather than argument. The middot is the separator the meta lines
   already use, borrowed here as a marker so the list needs no bullet of its
   own — and hung outside the text so the lines still start on the column. */
.class-items {
  list-style: none;
  margin-top: 18px;
  display: grid;
  gap: 9px;
}

.class-items li {
  position: relative;
  padding-left: 22px;
  font-family: 'EB Garamond', serif;
  font-size: 19px;
  letter-spacing: 0.02em;
  line-height: 1.6;
  color: var(--secondary);
  transition: color var(--theme-fade) ease-in-out;
}

.class-items li::before {
  content: '·';
  position: absolute;
  left: 6px;
}

/* ─── Where it is ───
   The frame is a .class-media, so the tint and the hairline are the ones
   the images already use and a real map export drops straight into it. */
.class-map__frame {
  position: relative;
  aspect-ratio: 16 / 9;
  margin-top: 30px;
}

/* Laid over the drawn plate rather than replacing it, and only painted in
   once the tiles are actually there — so a map that never loads leaves the
   plate showing instead of a blank rectangle where a map was promised.

   Scoped through the frame deliberately: the library's stylesheet is
   appended after this one and sets `.maplibregl-map { position: relative }`,
   which at equal specificity wins and collapses the box to nothing. Naming
   the parent outranks it without resorting to !important. */
.class-map__frame .class-map__canvas {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  opacity: 0;
  transition: opacity 600ms ease;
}
.class-map__canvas[data-ready] { opacity: 1; }

.class-map__canvas:focus-visible {
  outline: 1px solid var(--wordmark);
  outline-offset: -1px;
}

/* The pin the facts list uses, at the size it uses it. */
.class-map__pin-mark {
  width: 26px;
  height: 26px;
  color: var(--wordmark);
  filter: drop-shadow(0 1px 3px rgba(0, 0, 0, 0.35));
  transition: color var(--theme-fade) ease-in-out;
}
.class-map__pin-mark svg { display: block; width: 100%; height: 100%; }

/* ─── The library's own chrome, brought into the site's voice ───
   Attribution is a licence condition, not decoration: it stays legible in
   both themes rather than being faded into the paper.

   Every selector here carries the frame in front of it on purpose. The
   library's stylesheet is appended after this one and its own rules are
   already two classes deep — `.maplibregl-ctrl-attrib.maplibregl-compact`
   sets a flat white background — so matching its specificity would lose on
   order alone. Naming the frame puts these one level above it. */
.class-map__frame .maplibregl-ctrl-attrib,
.class-map__frame .maplibregl-ctrl-attrib a {
  font-family: 'Jost', sans-serif;
  font-size: 10px;
  letter-spacing: 0.06em;
  color: var(--secondary);
}
.class-map__frame .maplibregl-ctrl.maplibregl-ctrl-attrib,
.class-map__frame .maplibregl-ctrl-attrib.maplibregl-compact {
  background-color: color-mix(in srgb, var(--map-land) 86%, transparent);
  padding: 2px 7px;
}
.class-map__frame .maplibregl-ctrl-attrib a { text-decoration: underline; }
.class-map__frame .maplibregl-ctrl-attrib-button {
  background-color: color-mix(in srgb, var(--map-land) 86%, transparent);
}

.class-map__frame .maplibregl-ctrl-group {
  background: var(--map-land);
  border: 1px solid var(--hairline);
  border-radius: 0;
  box-shadow: none;
}
.class-map__frame .maplibregl-ctrl-group button + button { border-top: 1px solid var(--hairline); }
/* Their control glyphs are dark PNG-ish SVGs; on a dark ground they need
   inverting to stay visible. */
.class-map__frame .maplibregl-ctrl-group button span { filter: var(--map-ctrl-filter, none); }

/* The two-finger / ctrl-scroll hint, in the page's palette rather than the
   library's default black wash. */
.class-map__canvas .maplibregl-cooperative-gesture-screen {
  font-family: 'Jost', sans-serif;
  font-size: 11px;
  letter-spacing: 0.16em;
  text-transform: uppercase;
  background: color-mix(in srgb, var(--bg) 74%, transparent);
  color: var(--wordmark);
}

.class-map__plate {
  display: block;
  width: 100%;
  height: 100%;
  color: var(--wordmark);
  opacity: 0.55;
  transition: color var(--theme-fade) ease-in-out;
}

.class-map__pin { fill: currentColor; opacity: 0.75; }

.class-map__address {
  font-family: 'EB Garamond', serif;
  font-size: 18px;
  letter-spacing: 0.02em;
  line-height: 1.6;
  color: var(--secondary);
  margin-top: 20px;
  max-width: 42ch;
  transition: color var(--theme-fade) ease-in-out;
}

.class-map__link { margin-top: 18px; }

/* The one filled control on the site, and deliberately so: everything else
   is a hairline, which is exactly what makes this read as the thing to
   press. It is the ink colour inverted rather than a new colour — the
   palette already holds both halves of it, so it turns over with the theme
   without anything being added.

   The border matches the fill, so the box is the size it was as an outline
   and the edge stays crisp against the paper. */
.book-btn {
  display: inline-block;
  padding: 18px 34px;
  font-size: 11px;
  letter-spacing: 0.28em;
  color: var(--bg);
  background: var(--wordmark);
  border: 1px solid var(--wordmark);
  text-decoration: none;
  transition: color var(--theme-fade) ease-in-out,
              background-color var(--theme-fade) ease-in-out,
              border-color var(--theme-fade) ease-in-out;
}

/* Softening toward the paper reads the same way in both themes, where
   "darken" would only read in one of them. */
.book-btn:hover {
  background: color-mix(in srgb, var(--wordmark) 84%, var(--bg));
  border-color: color-mix(in srgb, var(--wordmark) 84%, var(--bg));
}

/* currentColor is the paper now, so the ring has to name the ink instead —
   an outline the colour of the page would be drawn and never seen. */
.book-btn:focus-visible {
  outline: 1px solid var(--wordmark);
  outline-offset: 4px;
}

/* ─── Gallery ───
   The frame is the .class-media itself, so the tint, the hairline and the
   rect the flight aims at are all the ones the card already had. The images
   stack inside it and only one is up at a time; the controls lie over the
   top of them. */
.class-gallery { position: relative; }

.class-gallery__slide {
  position: absolute;
  inset: 0;
  opacity: 0;
  transition: opacity 520ms ease;
}
.class-gallery__slide.is-current { opacity: 1; }

/* Chrome over a photograph, not over the page — so it takes the ink colour
   for the theme and a soft shadow to keep it off whatever is underneath. */
.class-gallery__arrow,
.class-gallery__count {
  position: absolute;
  color: var(--wordmark);
  filter: drop-shadow(0 1px 3px rgba(0, 0, 0, 0.35));
  transition: color var(--theme-fade) ease-in-out, opacity 0.4s ease;
}

.class-gallery__arrow {
  top: 50%;
  transform: translateY(-50%);
  display: flex;
  align-items: center;
  justify-content: center;
  width: 44px;
  height: 44px;
  padding: 0;
  border: 0;
  background: none;
  cursor: pointer;
  opacity: 0.7;
  -webkit-tap-highlight-color: transparent;
}
.class-gallery__arrow:hover { opacity: 1; }
.class-gallery__arrow:focus-visible {
  opacity: 1;
  outline: 1px solid currentColor;
  outline-offset: -6px;
}

.class-gallery__arrow--prev { left: 6px; }
.class-gallery__arrow--next { right: 6px; }

.caret { width: 18px; height: 18px; }

.class-gallery__count {
  left: 50%;
  bottom: 18px;
  transform: translateX(-50%);
  font-family: 'Jost', sans-serif;
  font-weight: 400;
  font-size: 10px;
  letter-spacing: 0.28em;
  text-transform: uppercase;
  opacity: 0.75;
  white-space: nowrap;
  pointer-events: none;
}

/* ─── The hold ───
   While the ghost is in flight the two real images must not be on screen,
   or the same picture is in two places at once. They are held by an
   animation rather than by a style the script clears afterwards, and that
   is the whole point: an animation ends on its own. A dropped
   transitionend, a navigation that never renders, a script that throws —
   none of them can strand an image in the hidden state, because nothing
   has to run for it to come back. steps(1, end) makes it a hold and then
   a switch rather than a fade, so the image appears exactly as the ghost
   reaches it. */
@keyframes flight-hold { from { opacity: 0; } to { opacity: 1; } }

/* Departing: normally destroyed with the old view long before this ends.
   The generous duration is for the case where it is not. */
.class-media.is-departing { animation: flight-hold calc(var(--flight-ms) + 600ms) steps(1, end) both; }

/* Arriving: exactly the flight, so the switch and the ghost's removal land
   on the same frame. */
.class-media.is-arriving  { animation: flight-hold var(--flight-ms) steps(1, end) both; }

/* ─── Controls, on the way out and the way in ───
   The arrows and the count belong to the page, not to the picture. They go
   first — over exactly the beat the outgoing page spends fading — so that
   what travels back to the index is only ever the image, which is the one
   thing the card it is heading for is going to show. Without this they
   would fly along with it and shrink into a card that has no controls.

   They are faded inside the ghost rather than in the page, because the
   ghost sits above it: the real ones are already behind an opaque copy of
   themselves by the time anything moves. */
@keyframes control-dismiss { from { opacity: 1; } to { opacity: 0; } }

.flip-ghost .class-gallery__arrow,
.flip-ghost .class-gallery__count {
  animation: control-dismiss var(--leave-ms) ease both;
}

/* And coming the other way they wait for the image to land before showing
   up at all. Scoped to .class-hero so it cannot reach into a ghost, which
   is the one place these two rules would otherwise fight. */
html[data-flight] .class-hero .class-gallery__arrow,
html[data-flight] .class-hero .class-gallery__count {
  animation: fade-up 420ms ease var(--flight-ms) both;
}

/* ─── The travelling image ───
   Out of the flow entirely, above the page but under the header, so the
   nav and the wordmark keep the top of the screen while it passes beneath
   them. It is a .class-media too, so tint, hairline and object-fit are
   the ones it already had in the card. */
.flip-ghost {
  position: fixed;
  z-index: 15;
  pointer-events: none;
  transition-timing-function: cubic-bezier(0.25, 0.1, 0.25, 1);
}

/* The words do not travel with the image, they arrive under it — the same
   order the splash uses, where the wordmark lands before the page it
   belongs to assembles itself. */
html[data-flight] .class-body,
html[data-flight] .class-grid,
html[data-flight] .home { animation: fade-up 620ms ease 140ms both; }

.visually-hidden {
  position: absolute;
  width: 1px; height: 1px;
  margin: -1px; padding: 0;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
  border: 0;
}

/* ─── Skip link ───
   Out of sight until it is focused, then it lands in the top-left corner
   as a real target. Above everything, the wordmark and the menu included,
   because it is the first thing the tab order reaches and it has to be
   visible when it gets there.

   It is not .visually-hidden: that class clips to a 1px box, and the
   moment this is focused it needs a real one. Moving it off the top edge
   and back is the version that animates.

   Parked above the top edge is not the same as parked out of sight, which
   is what the opacity is for. Overscroll at the top of a page — a trackpad
   flick upward when there is nowhere left to go — rubber-bands the whole
   layer down, fixed elements included, and everything sitting above the
   edge is walked into view for as long as the bounce lasts. This is the
   only thing on the site parked up there, and it was appearing in the
   corner of every page anyone pulled at. Transparent, it stays hidden
   however far the page is dragged, and it is still focusable and still the
   first thing in the tab order — which visibility or display would both
   have cost. */
.skip-link {
  position: fixed;
  top: 0;
  left: 12px;
  z-index: 60;
  transform: translateY(-120%);
  opacity: 0;
  padding: 12px 18px;
  font-family: 'Jost', sans-serif;
  font-size: 10.5px;
  letter-spacing: 0.28em;
  text-transform: uppercase;
  color: var(--bg);
  background: var(--wordmark);
  text-decoration: none;
  transition: transform 200ms ease, opacity 200ms ease;
}

.skip-link:focus-visible {
  transform: translateY(0);
  opacity: 1;
  outline: 1px solid var(--wordmark);
  outline-offset: 3px;
}

/* ═══ Entry choreography ═══════════════════════════════════════
   Only on a cold landing at the splash. A deep link should not spend
   five seconds assembling itself before it can be read. */
@keyframes wordmark-in {
  from { opacity: 0; --wm-track-intro: 0.19em; }
  to   { opacity: 1; --wm-track-intro: 0em; }
}

/* fade-up's twin for the corner marks: identical timing and rise, but it
   moves --intro-o rather than opacity, leaving hover free. */
@keyframes icon-in {
  from { --intro-o: 0; transform: translateY(5px); }
  to   { --intro-o: 1; transform: translateY(0); }
}

@keyframes fade-up {
  from { opacity: 0; transform: translateY(5px); }
  to   { opacity: 1; transform: translateY(0); }
}

html[data-intro] .wordmark { animation: wordmark-in 2.4s cubic-bezier(0.25, 0.1, 0.25, 1) 2.2s both; }
html[data-intro] .hero .social { animation: fade-up 1.4s ease 4.6s both; }
html[data-intro] .theme-toggle,
html[data-intro] .menu-button { animation: icon-in 1.4s ease 5.0s both; }

/* The animation above has a 5s delay and a `both` fill, so --intro-o is 0
   from the very first frame — these controls are invisible for five
   seconds. Nothing used to stop them being clicked or tabbed to in the
   meantime: a keyboard user entering the page landed on a button that was
   not there. The pointer/tab block is lifted by the same animation that
   brings them in, so the control becomes available exactly when it becomes
   visible, and neither half can drift from the other. */
html[data-intro] .theme-toggle,
html[data-intro] .menu-button,
html[data-intro] .scroll-hint__btn {
  pointer-events: none;
  animation-name: icon-in, intro-arm;
  animation-fill-mode: both, both;
}

html[data-intro] .scroll-hint__btn {
  animation-duration: 1.4s, 1.4s;
  animation-delay: 5.4s, 5.4s;
  animation-timing-function: ease, steps(1, end);
}

html[data-intro] .theme-toggle,
html[data-intro] .menu-button {
  animation-duration: 1.4s, 1.4s;
  animation-delay: 5.0s, 5.0s;
  animation-timing-function: ease, steps(1, end);
}

/* A hold, then a switch — the control arms on the frame it appears on. */
@keyframes intro-arm {
  from { pointer-events: none; }
  to   { pointer-events: auto; }
}
html[data-intro] .scroll-hint  { animation: fade-up 1.4s ease 5.4s both; }


/* ═══ Mobile ═══════════════════════════════════════════════════ */
@media (max-width: 720px) {
  :root { --header-h: 56px; }
  .nav { display: none; }
  .menu-button { display: block; }
  .page { padding-left: 22px; padding-right: 22px; }
  /* Less air over the title than a wide screen wants, and the page's own
     side margins. */
  .page-head { padding: calc(var(--header-h) + 38px) 22px clamp(32px, 7vw, 46px); }
  /* One column, and the gap tightens: side by side the cards needed the
     air to stay apart, stacked they only need to stay distinct. */
  .class-grid { grid-template-columns: 1fr; gap: 44px; }
  .class-body .lede { font-size: 20px; }

  /* Half and half needs a screen with halves worth having. Stacked, the
     image goes back to being a picture at the top of a page — and stops
     being sticky, which on a phone would cost most of the screen. */
  .class-page {
    grid-template-columns: 1fr;
    padding-top: var(--header-h);
    /* Level with the page's own side margins, so the image lines up with
       the copy underneath it. */
    --hero-inset: 22px;
  }
  .class-hero { position: static; top: auto; height: auto; }
  .class-media--hero { height: auto; aspect-ratio: 4 / 3; }
  .class-body { max-width: none; padding: 40px 22px 140px; }

  /* One book button at this width, and it is the bar at the floor. Display
     rather than visibility, so what goes leaves the tab order and the
     accessibility tree with it: exactly one booking link on the page,
     rather than one that can be reached and one that cannot.

     The row goes too when the button was all it held — which is what
     data-book-only says. With the share control switched on it stays, and
     keeps it. */
  .class-book .book-btn { display: none; }
  .class-book[data-book-only] { display: none; }
  .class-cta { display: block; }

  /* One column, and the same answer a class page gives: stacked, the image
     goes back to being a picture at the top of a page and stops being
     sticky, which on a phone would cost most of the screen. */
  .about-page {
    grid-template-columns: 1fr;
    /* Level with the page's own side margins, so the image lines up with
       the copy under it. */
    --hero-inset: 22px;
    padding-top: 22px;
  }
  .about-hero { position: static; top: auto; height: auto; }
  /* Portrait, not the 4/3 a class page uses: the photograph is a standing
     figure, and a landscape frame cropped from the middle of it takes a band
     across the hips and loses the head entirely. */
  .about-media { height: auto; aspect-ratio: 3 / 4; }
  .about-body { max-width: none; padding: 34px 22px 140px; }
  /* ─── Home ───
     Every section stacks, and the side margins come back to the page's own. */
  .home-section { padding-left: 22px; padding-right: 22px; }

  /* Less air over the title than a wide screen wants, exactly as the
     shared page head does at this width. Shorter, too: 72% of a phone
     screen is a long way to scroll past four lines of type. */
  .home-hero { min-height: 64vh; padding: calc(var(--header-h) + 44px) 22px 56px; }

  /* Back to a card the way the index draws one: picture, then words. */
  .home-featured .class-card__link {
    flex-direction: column;
    align-items: stretch;
    gap: 0;
  }
  .home-featured .class-media { flex: none; align-self: auto; }
  .home-featured h3 { margin-top: 22px; }

  /* Wide enough to be a photograph and narrow enough that the next one is
     visibly waiting, which is what says the row swipes. */
  .home-feed { --gutter: 22px; }
  .polaroids {
    padding-block: 22px 36px;
    justify-content: flex-start;
  }
  .polaroid { flex: 0 0 62vw; }

  /* The button fills the column at every width now, so there is no room
     beside it for the note — on a narrow screen it goes under the row,
     where there is space, since the rule below sits 30px away. */
  .share-note {
    position: absolute;
    left: 0;
    /* Sits in the gap between the row and the rule, nearer the buttons it
       is reporting on than the section it is not. */
    top: 100%;
    margin: 6px 0 0;
  }
}

/* ═══ Reduced motion ═══════════════════════════════════════════
   The scroll-linked travel stays — it is direct manipulation, not
   motion for its own sake. What goes is everything self-propelled. */
@media (prefers-reduced-motion: reduce) {
  :root { --theme-fade: 0ms; }
  html[data-intro] .wordmark,
  html[data-intro] .hero .social,
  html[data-intro] .theme-toggle,
  html[data-intro] .menu-button,
  html[data-intro] .scroll-hint { animation: none; }
  .scroll-hint__caret { animation: none; }
  .stack { transform: none; }
  .theme-toggle svg,
  .menu-button i,
  .menu-overlay,
  main { transition: none; }
  html[data-menu] .menu-overlay a { animation: none; opacity: 1; }
  html[data-flight] .class-body,
  html[data-flight] .class-grid,
  html[data-flight] .home { animation: none; }
  /* The tilt stays. It is an angle, not a movement — the card is lying at
     it, not travelling to it. What goes is the lift under the cursor and
     the reveal, and the reveal is already gated on no-preference where it
     is declared. */
  .polaroid__link { transition: none; }
  a.polaroid__link:hover,
  a.polaroid__link:focus-visible { transform: rotate(var(--tilt, 0deg)); }

  .class-gallery__slide { transition: none; }
  .share-note { transition: none; }
  .contact-form .form-status,
  .contact-form input,
  .contact-form textarea { transition: none; }
  .class-map__canvas { transition: none; }
  html[data-flight] .class-hero .class-gallery__arrow,
  html[data-flight] .class-hero .class-gallery__count { animation: none; }
}
