/* =============================================================================
   Sky Relics — ONE system stylesheet  (draft; this REPLACES site.css when signed off)
   -----------------------------------------------------------------------------
   Not a parallel sheet. This is the single house style: one token layer, one
   theme mechanism, one naming system. Existing pages break against it on purpose
   and get repaired onto this design (builder markup + hand-built pages) before
   the next upload.

   NAMING RULE (applies to every class, id, token and data-attribute):
   Names are FULLY SPELLED and self-documenting. Reading the markup or the CSS
   should make the intent obvious with no lookup. We never abbreviate, shorten or
   use clever shorthands to save bytes — clarity always wins over size.

   ARCHITECTURE
   • @layer keeps the cascade predictable → no specificity wars, no !important.
       reset < tokens < base < layout < components < chrome < engine < utilities
   • Layout PRIMITIVES do the work. Most blocks are "a reading column", "a card
       grid", "a full-bleed frame" or "a glass panel" — declared ONCE here, blocks
       just compose them. This is what collapses the old 3000 lines.
   • Colour comes from TOKENS only. The theme flips in exactly THREE small blocks
       below and cannot leak (the invisible-header bug is structurally impossible).
   • BEHAVIOUR is read by JavaScript from data-* attributes (data-reveal-on-scroll,
       data-parallax, data-section, data-background-image…), NEVER from class names,
       so the CSS class names can change freely without breaking the scripts.
   ============================================================================= */

@layer reset, tokens, base, layout, components, chrome, engine, utilities;

/* ── reset ─────────────────────────────────────────────────────────────────── */
@layer reset {
  *, *::before, *::after { box-sizing: border-box; margin: 0; }
  html { -webkit-text-size-adjust: 100%; }
  body { min-height: 100vh; }
  img, picture, svg, video, canvas { display: block; max-width: 100%; height: auto; }
  input, button, textarea, select { font: inherit; color: inherit; }
  button { background: none; border: 0; cursor: pointer; }
  a { color: inherit; text-decoration: none; }
  :where(ul, ol) { list-style: none; padding: 0; }
  :target { scroll-margin-block: 5rem; }
}

/* ── design tokens + THEME (the entire theme system is these three blocks) ───── */

/* ── base element styles ───────────────────────────────────────────────────── */
@layer base {
  html { scroll-behavior: smooth; }
  body {
    background: var(--page-background);
    color: var(--text-color);
    font-family: var(--font-family-body);
    font-size: var(--font-size-base);
    line-height: 1.6;
    -webkit-font-smoothing: antialiased;
    text-rendering: optimizeLegibility;
  }
  h1, h2, h3 {
    font-family: var(--font-family-display);
    font-weight: 300;
    line-height: 1.1;
    letter-spacing: -0.01em;
    color: var(--heading-color);
    text-shadow: var(--heading-text-shadow);
    text-wrap: balance;
  }
  h1 { font-size: var(--font-size-display); }
  h2 { font-size: var(--font-size-xlarge); }
  h3 { font-size: var(--font-size-large); }
  p  { text-wrap: pretty; }
  a:hover { color: var(--accent-color); }
  :focus-visible { outline: 2px solid var(--accent-color); outline-offset: 3px; }
  ::selection { background: var(--accent-color); color: #ffffff; }
}

/* ── layout PRIMITIVES (every block composes these) ────────────────────────── */
@layer layout {
  /* Stacks its children vertically with even spacing between them. */
  .vertical-stack {
    display: flex;
    flex-direction: column;
    gap: var(--stack-gap, var(--spacing-medium));
  }

  /* Centres content and caps its width so it never runs edge-to-edge. */
  .centered-container {
    width: min(100% - 2 * var(--spacing-medium), var(--content-max-width));
    margin-inline: auto;
  }

  /* Caps text at a comfortable reading line length. */
  .reading-column { max-width: var(--reading-column-width); }
  .reading-column > * + * { margin-block-start: 1em; }

  /* ONE COLUMN PER ITEM — unless that would squeeze a card below --card-min-width, in which case
     the grid wraps.
     ──────────────────────────────────────────────────────────────────────────────────────────
     The renderer tells us how many items there are (--card-count), and that fact is what makes
     this reliable. The usual idiom — repeat(auto-fit, minmax(<min>, 1fr)) — has to INFER the
     column count from the minimum width, and the inference breaks precisely when it matters:
     three gateway tiles at a 320px minimum fit on one line only above a ~1090px viewport, and
     silently wrap to two rows on any smaller window. Lowering the minimum just moves the cliff.

     So the ideal track is the item's fair share of the row: (100% - the gaps) / count. We ask for
     at LEAST that, so exactly `count` tracks fit and no more. On a narrow screen the fair share
     shrinks below --card-min-width, max() clamps the track back up to the readable minimum, and
     auto-fit does what it is good at: drops to as many columns as genuinely fit.

     --card-min-width therefore means the honest thing — the narrowest a card may READ at — rather
     than doubling as a hidden column-count dial. The max track stays 1fr so the row always fills;
     never give it a definite max globally (that would restore the inference bug in a new disguise).
     A grid that deliberately wants slack opts out — see .row-cell[data-place]. */
  .card-grid {
    display: grid;
    gap: var(--grid-gap, var(--spacing-medium));
    /* The item's fair share of the row, MINUS a pixel. That 1px is not a fudge, it is the point:
       an exactly-1/count track leaves auto-fit zero slack, so the faintest sub-pixel rounding drops
       a column and the row silently wraps. A pixel of headroom guarantees `count` tracks fit and is
       far too small to let a (count + 1)th one in. */
    --card-fair-share: calc(
      (100% - (var(--card-count, 1) - 1) * var(--grid-gap, var(--spacing-medium))) / var(--card-count, 1)
      - 1px
    );
    grid-template-columns: repeat(auto-fit,
      minmax(min(max(var(--card-min-width, 240px), var(--card-fair-share)), 100%),
             var(--card-track-max, 1fr)));
  }

  /* A horizontal group of items that wraps to the next line when it runs out of room. */
  .inline-row {
    display: flex;
    flex-wrap: wrap;
    gap: var(--row-gap, var(--spacing-small));
    align-items: center;
  }

  /* Breaks out of a centred container to span the full viewport width. The 50% resolves
     against the container, so this pulls correctly back out to the viewport edges. */
  .full-bleed-width {
    width: 100vw;
    margin-inline: calc(50% - 50vw);
  }

  /* ── THE ROW: how blocks sit beside each other ────────────────────────────────────
     Blocks declare a width and PACK THEMSELVES into rows (the builder groups them). A
     row's columns come from those widths as `fr` units, which means a row ALWAYS fills:
     a lone two-thirds block simply takes the whole row. That is the agreed behaviour and
     it needs no arithmetic — fr distributes whatever space there is.

     Crucially the row lives INSIDE .centered-container, so it divides the CONTENT COLUMN,
     never the viewport. "left" means the left edge of the column, not of a 27-inch screen.
     The single, explicit way out of that margin is .full-bleed-width (width: edge-to-edge). */
  .block-row {
    display: grid;
    grid-template-columns: var(--row-columns, 1fr);
    gap: var(--spacing-large);
    align-items: start;
  }

  /* One block's cell in a row. Its own content stacks vertically. */
  /* A plain block container, NOT a flex column — and that is deliberate.
     A flex item with an auto inline margin is NOT stretched (align-items: stretch is ignored for
     it), so it shrink-wraps its content instead. That is fatal for a block like .video-embed whose
     iframe is absolutely positioned: with no in-flow content to size against, it collapses to
     nothing. In normal block flow, auto margins mean the one thing we want them to mean —
     "width: auto fills the cell; a max-width'd box centres in it" — so the centring rule below is
     safe for every block type, present and future. */
  .row-cell {
    min-width: 0;                            /* let long content shrink rather than blow the grid out */
  }
  .row-cell > * + * { margin-block-start: var(--spacing-medium); }   /* the stack gap flex used to give us */

  /* VERTICAL placement of the block within its row. */
  .row-cell[data-align="top"]    { align-self: start; }
  .row-cell[data-align="middle"] { align-self: center; }
  .row-cell[data-align="bottom"] { align-self: end; }

  /* HORIZONTAL placement, and the default that makes it unnecessary most of the time.
     ────────────────────────────────────────────────────────────────────────────────
     Some blocks simply CANNOT fill their cell: a callout and a reading column are capped at
     --reading-column-width for legibility, a pull-quote at 22ch. Left to itself a capped block
     is parked against the left edge with a lake of empty space beside it, which reads like a
     bug rather than a decision. So the rule is: a block that cannot fill its cell CENTRES in it.

     One declaration does the whole job, because auto margins only absorb space that actually
     exists. A grid or an image that already fills its cell has no free space, so this does
     precisely nothing to it — no per-component list to maintain, and nothing to forget when a
     new block type arrives. An explicit data-place then overrides the default, and it works for
     capped content and grids alike, so "put it right" means one thing everywhere. */
  .row-cell > *                      { margin-inline: auto; }     /* centre what can't fill */
  .row-cell[data-place="left"]   > * { margin-inline: 0 auto; }
  .row-cell[data-place="middle"] > * { margin-inline: auto; }
  .row-cell[data-place="right"]  > * { margin-inline: auto 0; }

  /* Display-size cap. Narrows a block WITHIN its cell without touching which blocks share its
     row: a full-width video still owns its row but can draw at a portrait-friendly size. The cap
     is the block's max-width; the existing `.row-cell > * { margin-inline: auto }` above then
     centres it, and data-place can pull it left or right instead. min(100%, Nrem) is deliberately
     relative — never wider than the cell, so on a phone the block simply fills, and it scales with
     the root font so a reader who zooms gets a proportionally larger cap. */
  .row-cell[data-measure="wide"]   > * { max-width: min(100%, 56rem); }   /* a reined-in landscape        */
  .row-cell[data-measure="medium"] > * { max-width: min(100%, 40rem); }   /* 4:3 video, a lone figure     */
  .row-cell[data-measure="narrow"] > * { max-width: min(100%, 29rem); }   /* portrait / square video      */
  .row-cell[data-measure="slim"]   > * { max-width: min(100%, 21rem); }   /* a tall 9:16 clip, a quote    */

  /* A stacked column (cmsGroupStackedBlocks): one grid slot holding several blocks. It is itself a
     .row-cell, so its members — each a .row-cell in its own right — already inherit the vertical stack
     gap from `.row-cell > * + *` above and keep the full per-block toolkit (size cap, placement). This
     rule just names the structure and lets each member fill the column. */
  .row-cell--stack > .row-cell { width: 100%; }

  /* Placing a grid needs slack to place it INTO, and a grid whose tracks stretch has none —
     two photos would just inflate until they filled the row. So a PLACED grid, and only a
     placed one, is capped to the width its own items actually want. --card-count is set by
     the renderer, the only thing that knows how many items there are. The tracks stay 1fr,
     so the column count is still worked out from --card-min-width and nothing wraps.
     This is why the renderer omits data-place entirely unless the author asked for one:
     the attribute's mere PRESENCE is what stops a grid filling its row. */
  .row-cell[data-place] .card-grid {
    width: min(100%, calc(var(--card-count, 3) * var(--card-max-width, 420px)
                        + (var(--card-count, 3) - 1) * var(--grid-gap, var(--spacing-medium))));
  }

  /* Too narrow for side-by-side: every block takes the full row, in order. Alignment
     stops meaning anything here, and correctly does nothing. */
  @media (width <= 52rem) {
    .block-row { grid-template-columns: 1fr; }
  }

  /* A fixed-aspect-ratio media box; its image fills and crops to fit. */
  /* display: block is REQUIRED, not tidiness. A photo you can enlarge is an <a> (its href is the full-size
     image — the lightbox's source, and the fallback when JavaScript is off), and an anchor is inline by
     default. aspect-ratio does not apply to an inline box, so without this the frame loses its shape
     entirely. The gallery cells got away with it only because a grid item is blockified for free; a feature
     hero or a figure is not. */
  .media-frame {
    display: block;
    position: relative;
    overflow: hidden;
    border-radius: var(--corner-radius);
    aspect-ratio: var(--aspect-ratio, 3 / 2);
    background: var(--panel-background-subtle);
  }
  /* THE <picture> HAS TO FILL THE FRAME TOO, or the image inside it cannot.
     
     The web tier wraps its image in a <picture> (for the WebP ladder), and the reset makes that a block with
     height: auto. So `height: 100%` on the img resolved against the PICTURE's height, not the frame's — and a
     percentage against an auto-height parent is simply auto. The photo therefore kept its own natural height
     inside the cell: a 16:9 photograph in a 3:2 cell sat there with a gap under it, and object-fit could not
     help, because object-fit only decides how a picture fills ITS BOX — and the box was never the cell.
     
     Left as auto for a frame that has no definite height of its own (a breather sizes itself to its photo),
     which a percentage height correctly degrades to. */
  .media-frame > picture {
    display: block;
    width: 100%;
    height: 100%;
  }

  /* A DESCENDANT selector, not a child one. The img is a GRANDCHILD of the frame whenever a <picture> wraps
     it, so `.media-frame > img` matched only the bare thumbnails and silently skipped every full-size photo:
     object-fit never applied to them, and --crop-focus — the crop the Library lets you set on a photograph —
     did nothing at all on exactly the images big enough for it to matter. */
  .media-frame img {
    width: 100%;
    height: 100%;
    object-fit: cover;
    object-position: var(--crop-focus, 50% 50%);
  }

  /* HOVER: a photo you can open lifts slightly and brightens under the pointer.

     Scoped to a.media-frame — an ANCHOR, i.e. a photo that actually opens something. A frame that is not a
     link does nothing when clicked, and a thing that animates under the pointer but cannot be used is a lie.

     :not([data-fold]) is load-bearing. A folding hero's photo already owns its transform (the pointer drift
     inside its frame, driven every frame by the engine), and transform is a single property — a hover rule
     setting scale() here would not add to that, it would REPLACE it, and the drift would die the moment the
     pointer arrived. Which is exactly when it is meant to be working. The hero has its own, better response
     to the pointer already; this is for the thumbnails.

     Behind @media (hover: hover) so a touch screen never gets a stuck hover state on tap. */
  @media (hover: hover) {
    a.media-frame:not([data-fold]) img {
      transition: transform 0.3s ease, filter 0.3s ease;
    }
    a.media-frame:not([data-fold]):hover img {
      transform: scale(1.03);
      filter: brightness(1.08);
    }
  }

  /* A frosted, translucent surface used for cards and info panels. */
  .glass-panel {
    background: var(--panel-background);
    border: 1px solid var(--hairline-color);
    border-radius: var(--corner-radius);
    -webkit-backdrop-filter: var(--panel-blur);
    backdrop-filter: var(--panel-blur);
    padding: var(--spacing-large);
  }
}

/* ── site chrome: header (logo + wordmark), pill navigation, footer ────────── */
@layer chrome {
  .site-header {
    text-align: center;
    padding: var(--spacing-large) var(--spacing-medium) var(--spacing-medium);
  }

  /* The Sky Relics mark is a dark SVG, so it is inverted to white on the dark skin
     and left untouched on the light skin (--logo-filter flips with the theme). */
  /* Header chrome uses FIXED sizes, not the fluid type/space scale — letting the
     header grow with the viewport made it read too large and soft. These are the
     original header's values. */
  .site-logo {
    width: 90px; height: auto;
    margin-inline: auto;
    filter: var(--logo-filter);
  }

  /* "SKY RELICS" wordmark beneath the logo. */
  .site-wordmark {
    margin-block-start: 0.7rem;
    font-size: 0.8rem;
    letter-spacing: 0.2em;
    text-transform: uppercase;
    color: var(--text-color);

    & a:hover { color: var(--heading-color); }
  }

  /* The official lockup sets "SKY" thin and "RELICS" a shade heavier. Standalone (not
     BEM-scoped to the wordmark) because the footer copyright uses the same pair. */
  .brand-name-sky    { font-weight: 200; }
  .brand-name-relics { font-weight: 300; }

  /* A page leading with its FULL logotype in the header (header_logo — the homepage): the mark grows to own the
     top and the wordmark grows with it. The logo KEEPS the shared theme invert (--logo-filter from the base
     .site-logo) — it is a dark SVG, so on the dark skin it must invert to read white, exactly like the small
     mark. Kept in @layer chrome (the same layer as .site-logo) so it beats the base sizes; an override in an
     earlier layer (components) would lose to chrome regardless of source order or specificity. */
  .page-big-header .site-logo--full {
    width: min(220px, 52vw);
  }
  .page-big-header .site-wordmark {
    /* Scales with the viewport rather than sitting at one fixed size, because the mark above it already
       does (min(220px, 52vw)) — a fixed wordmark drifts out of proportion with it on a phone, which is
       exactly where the lockup is tightest. The clamp floor keeps it readable, the ceiling stops it
       outgrowing the mark it belongs to. */
    font-size: clamp(1.9rem, 5.2vw, 2.5rem);
    letter-spacing: 0.3em;
    margin-block-start: 1rem;
  }

  .site-footer__copyright { text-transform: uppercase; }

  /* Navigation is a page-wide BAND holding a centred PILL. The band is a full-viewport
     strip of blurred glass that fades to nothing at both edges, with a thin hairline
     along its top and bottom (the "line across the page"). The pill sits above it and
     holds the links; each link reveals a skewed highlight box on hover / when current
     (the same -19° slant as .text-highlight). */
  .navigation-band {
    position: relative;
    display: flex;
    justify-content: center;
    width: 100vw;
    margin-inline: calc(50% - 50vw);          /* break out to the full viewport width */
    margin-block-start: 1.15rem;
    padding-block: 0.4rem;
    overflow: hidden;
  }
  /* The wide glass: a horizontal gradient that fades out at both edges, blurred. */
  .navigation-band::before {
    content: "";
    position: absolute;
    inset: 0;
    background: var(--navigation-band-glass);
    -webkit-backdrop-filter: blur(6px);
    backdrop-filter: blur(6px);
    pointer-events: none;
  }
  /* The page-wide line: the same edge-faded gradient, masked to just the top + bottom 1px. */
  .navigation-band::after {
    content: "";
    position: absolute;
    inset: 0;
    background: var(--navigation-band-hairline);
    -webkit-mask: linear-gradient(#000 0 0) top / 100% 1px no-repeat,
                  linear-gradient(#000 0 0) bottom / 100% 1px no-repeat;
            mask: linear-gradient(#000 0 0) top / 100% 1px no-repeat,
                  linear-gradient(#000 0 0) bottom / 100% 1px no-repeat;
    pointer-events: none;
  }

  .site-navigation {
    position: relative;
    z-index: 1;                               /* above the band's glass + hairlines */
    display: inline-flex;
    flex-wrap: wrap;
    justify-content: center;
    gap: 0.3rem 1.3rem;
    padding: 0.55rem 1.6rem;
    overflow: hidden;                         /* clips each link's skewed box to the pill */
    border-radius: 30px;
    background: var(--navigation-background);
    -webkit-backdrop-filter: blur(10px);      /* the pill has its OWN blur, over the band's */
    backdrop-filter: blur(10px);
    box-shadow: 0 0 22px rgba(90, 138, 90, 0.12);   /* faint brand-green glow */

    & a {
      position: relative;
      z-index: 0;
      padding: 0.32rem 0.1rem;
      /* Deliberately a FIXED size, not the fluid type scale: the nav is chrome, and
         letting it grow with the viewport made the bar read too big and soft. */
      font-size: 0.74rem;
      letter-spacing: 0.14em;
      text-transform: uppercase;
      color: var(--navigation-link-color);
      transition: color 0.2s, text-shadow 0.2s;
    }
    & a::before {
      content: "";
      position: absolute;
      inset: -0.55rem -0.8rem;
      z-index: -1;
      background: var(--navigation-hover-fill);
      border-inline: 1px solid var(--navigation-hover-edge);
      opacity: 0;
      transform: skewX(-19deg) scaleX(0.82);
      transition: opacity 0.2s, transform 0.2s;
    }
    & a:hover, & a:focus-visible, & a[aria-current="page"] {
      color: var(--heading-color);
      text-shadow: 0 0 14px rgba(255, 255, 255, 0.22);
      outline: none;
    }
    & a:hover::before, & a:focus-visible::before, & a[aria-current="page"]::before {
      opacity: 1; transform: skewX(-19deg) scaleX(1);
    }
  }
  /* Narrow screens: the pill dissolves so the band alone carries the links. */
  @media (width <= 44rem) {
    .site-navigation { background: transparent; box-shadow: none; }
  }

  .site-footer {
    display: grid;
    gap: var(--spacing-medium);
    justify-items: center;
    margin-block-start: var(--spacing-huge);
    padding: var(--spacing-large) var(--spacing-medium);
    text-align: center;
    color: var(--text-color-faint);
    font-size: var(--font-size-small);

    & a:hover { color: var(--heading-color); }
  }
}

/* ── components: hero, sections, and example blocks that compose the primitives ─ */
@layer components {
  /* The opening hero on a page (title + optional eyebrow and lede). */
  /* Symmetric, and deliberately NOT --spacing-huge on top.
     The site header already ends with its own bottom padding, so a "huge" top padding here meant the same
     gap was paid for twice: 136px of dead air between the nav pill and the first word, putting the hero
     title 344px down a 900px screen — over a third of the viewport before anything was said. The header
     owns the air below itself; the hero only adds its own. */
  .page-hero {
    position: relative;
    padding-block: var(--spacing-xlarge);
    text-align: center;

    /* THE OPTIONAL HERO IMAGE — a CONTAINED banner inside the standard content column, not a full-bleed wash.
       (Big type over an unpredictable, busy, mid-tone photograph was the problem: the difference blend's blind
       spot is mid-grey, so a title over beige timber came out muddy and unreadable. This drops the blend.)

       The photograph is a rounded figure the width of the page's content column. Its lower half DISSOLVES into
       the page's ground colour via a theme-aware gradient — dark on a dark page, light on a light one — and
       the eyebrow / title / lede sit over that dissolve in the page's normal ink. So the type reads as well as
       body text on ANY photo, with nothing to tune per image.

       Image and text share ONE centred grid cell: the backdrop fills it, the text overlays the bottom. */
    &.page-hero--has-image {
      display: grid;
      grid-template-columns: min(100% - 2 * var(--spacing-medium), var(--content-max-width));
      justify-content: center;                 /* centre the single content-width column */
      padding-block: var(--spacing-large);
    }
    &.page-hero--has-image .page-hero__backdrop,
    &.page-hero--has-image .centered-container {
      grid-row: 1; grid-column: 1;             /* stack in the same cell */
    }
    .page-hero__backdrop {
      position: relative; overflow: hidden;
      align-self: stretch;                     /* fill the cell's height */
      min-height: 58vh;
      border-radius: var(--corner-radius);
    }
    .page-hero__backdrop img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; }
    /* A UNIFORM wash of the page's ground tone over the WHOLE photo — dark on a dark page, light on a light one
       (--hero-image-veil flips with the theme). The image becomes one controlled tone the text sits on
       everywhere, top to bottom. No bottom gradient: a fade to the near-white page colour read as an odd pale
       band, and a uniform wash is what actually makes an unpredictable photo carry legible type. */
    .page-hero__backdrop::after {
      content: ""; position: absolute; inset: 0;
      background: var(--hero-image-veil);
    }
    /* The text overlays the bottom of the banner, inset from its rounded edges, in the page's normal ink —
       no forced colour, no blend. It reads because the photo beneath is washed to a single tone. */
    &.page-hero--has-image .centered-container {
      align-self: end;
      z-index: 1;
      width: 100%;
      padding-inline: var(--spacing-large);
      padding-block: var(--spacing-huge) var(--spacing-large);
    }

    .page-hero__eyebrow {
      color: var(--accent-color);
      letter-spacing: 0.14em;
      text-transform: uppercase;
      font-size: var(--font-size-small);
    }
  /* ── An optional project logotype above the title ────────────────────────────────────────────────
     The artwork's own colour is IGNORED. The logo is used as a mask and painted in the page's ink, so
     it reads correctly on every theme with nothing declared about it.

     That is not over-engineering, it is the only rule that works. Our two logotypes have OPPOSITE
     polarity — the site mark is black artwork, the Freshspring mark is white — so "invert it on the
     light theme" fixes one and destroys the other. Reusing --logo-filter (which inverts on DARK, for
     the black site mark) on the white Freshspring mark made it invisible in BOTH themes: white on a
     white page, and inverted-to-black on a black one.

     Painting a silhouette sidesteps the question entirely. A full-colour brand mark that must keep its
     own colours opts out with .page-hero__logo--original-colours.

     The <img> inside is load-bearing: a masked <span> has no intrinsic size, so the hidden image is what
     gives the box its width and aspect ratio. The mask then cuts the painted background to the letterforms. */
  .page-hero__logo {
    display: block;
    width: 100%;
    max-width: 240px;
    margin-inline: auto;

    background-color: var(--heading-color);

    /* The mask IMAGE is set inline on the element, because a relative url() resolves against whatever
       declares it — and anything declared here resolves against this stylesheet, which sits at the site
       root, not next to the page's artwork. Everything about HOW the mask is drawn stays here; only the
       WHICH-file part has to travel with the page. */
    -webkit-mask-position: center;
    -webkit-mask-size: contain;
    -webkit-mask-repeat: no-repeat;
    mask-position: center;
    mask-size: contain;
    mask-repeat: no-repeat;

    & img {
      width: 100%;
      height: auto;
      visibility: hidden;   /* it sizes the box; the mask draws the shape */
    }
  }

  /* A logo whose own colours matter (a client's brand mark). Left exactly as it was drawn. */
  .page-hero__logo--original-colours {
    display: block;
    width: 100%;
    max-width: 240px;
    height: auto;
    margin-inline: auto;
    background: none;
    -webkit-mask-image: none;
    mask-image: none;
  }

  .page-hero__lede {
      max-width: 46ch;
      margin-inline: auto;
      margin-block-start: var(--spacing-small);
      color: var(--text-color-soft);
      font-size: var(--font-size-medium);
    }
  }


  /* One scroll section. Its kind is declared with data-section (which the engine
     also reads); the styling below keys off that same attribute. */
  .showcase-section {
    padding-block: var(--spacing-xlarge);

    &[data-section="feature"]  { min-height: 88vh; display: grid; align-items: center; }
    &[data-section="breather"] { padding: 0; }
  }

  /* The small accent label above a section title. */
  .section-eyebrow {
    font-size: 0.78rem;
    letter-spacing: 0.3em;
    text-transform: uppercase;
    color: var(--accent-color);
  }

  /* Feature section: a 60/40 two-column split — the media (hero + thumbs) on one side,
     the title + copy on the other, alternating section to section.
     data-side declares WHICH SIDE THE MEDIA SITS ON (so side="left" puts the copy right). */
  /* No max-width of its own. It used to be min(1200px, 92vw) — 100px wider than every other
     block — so the page's left edge visibly SHIFTED as you scrolled past a feature. A block
     is either inside the margin (--content-max-width) or explicitly outside it (edge-to-edge);
     a secret third width is neither. It now simply fills its row like everything else. */
  .feature-layout {
    display: grid;
    align-items: center;
    gap: clamp(2rem, 5vw, 5rem);
    width: 100%;
  }
  /* data-side lives on the LAYOUT, not the section — a section can now hold several blocks,
     so "which side the media sits" is a property of the feature itself, not of its section. */
  .feature-layout[data-side="left"]  { grid-template-columns: 60fr 40fr; }
  .feature-layout[data-side="right"] { grid-template-columns: 40fr 60fr; }
  .feature-layout[data-side="right"] .feature-layout__media { order: 2; }
  .feature-layout[data-side="right"] .feature-layout__text  { order: 1; }

  /* THE FOLDING HERO.

     The feature's photograph arrives turned away from you, swings face-on as it reaches the middle of the
     screen — its "read me" moment — and then stays flat as it rides off the top. The engine publishes the
     angles (--fold-angle, --fold-tip, --fold-flatness from data-fold); everything about how it LOOKS is here.

     The hinge is the OUTER edge — the one nearest the screen margin — so the plane opens toward the centre of
     the page like a page turning, its inner edge receding. Pivoting about its own middle instead reads as a
     spinning card, not a page: half of it comes toward you while half goes away, and the eye reads it as a
     trick rather than as depth.

     The perspective lives on the WRAPPER, not the plane. A perspective declared on the element being rotated
     is applied per-element, so each photo would get its own private vanishing point; declared on the parent,
     the media column shares one, and the fold is consistent with the page rather than with itself. */
  .feature-layout__media {
    perspective: 1400px;
    perspective-origin: center;
  }
  .media-frame[data-fold] {
    transform: rotateY(var(--fold-angle, 0deg)) rotateX(var(--fold-tip, 0deg));
    will-change: transform;
    box-shadow: 0 30px 70px rgb(0 0 0 / 0.45), 0 4px 14px rgb(0 0 0 / 0.3);
  }
  .feature-layout[data-side="left"]  .media-frame[data-fold] { transform-origin: left center; }
  .feature-layout[data-side="right"] .media-frame[data-fold] { transform-origin: right center; }

  /* The photo drifts inside its frame with the pointer — the 1.06 overscan gives it somewhere to go. The
     engine scales that drift by how CENTRED the plane is, peaking face-on and tapering to nothing at both
     ends of its pass: on the way in that is exactly where the fold is deepest (a pointer drift on a folded
     plane just reads as a wobble, so the two hand over instead of fighting), and on the way out it simply
     lets the photo settle as the hero leaves.

     The transition smooths the pointer, which arrives in jumps. The fold is driven every frame and needs no
     transition of its own — and must not have one, or it would lag the scroll. */
  .media-frame[data-fold] img {
    width: 100%;
    transform: scale(1.06) translate3d(var(--pointer-x, 0px), var(--pointer-y, 0px), 0);
    transition: transform 0.6s cubic-bezier(0.22, 0.61, 0.36, 1);
  }

  /* HOW A BLOCK'S HEADING READS — set per block in the CMS (align_text → data-align-text on the row-cell).
     Absent means "whatever this block type has always done", so no existing page moves until someone asks.

     Scoped to the HEADING and the EYEBROW, never the body copy: a centred multi-line paragraph is harder to
     read, because the eye has to hunt for the start of every line. Card titles are spans, not headings
     (.gateway-tile__title), so this cannot leak into a grid of cards and centre their contents too.

     A descendant selector beats .call-to-action's own text-align, so a CTA's heading can be pulled left
     without the block having to know anything about it. */
  .row-cell[data-align-text="center"] :is(h1, h2, h3, .section-eyebrow) { text-align: center; }
  .row-cell[data-align-text="left"]   :is(h1, h2, h3, .section-eyebrow) { text-align: left; }
  .row-cell[data-align-text="right"]  :is(h1, h2, h3, .section-eyebrow) { text-align: right; }

  /* PLACING THE BODY COPY — but only when it is a SINGLE LINE. A centred or right-aligned multi-line
     paragraph is hard to read (the eye loses the start of every line), so a wrapping paragraph must stay
     left even when its heading has moved.

     width: fit-content is what tells the two apart, with no line-counting. A block shrinks to fit its
     content up to the space available: a single line is narrower than the column, so the box shrinks to the
     line's width and the auto margin places that box — the line reads as centred, or right. A paragraph
     long enough to wrap is as wide as the column allows, so the box fills the width and its text stays
     left-aligned. One rule, and the paragraph decides which it is.

     Note it is the BOX that moves, not the text inside it: that is what keeps a wrapped paragraph honest
     while a lone line follows its heading. */
  :is(.row-cell[data-align-text="center"], .row-cell[data-align-text="right"]) .text-passage > * {
    width: fit-content;
    max-width: 100%;
  }
  .row-cell[data-align-text="center"] .text-passage > * { margin-inline: auto; }
  .row-cell[data-align-text="right"]  .text-passage > * { margin-inline: auto 0; }

  /* Too narrow for two columns — stack them, media first. */
  @media (width <= 52rem) {
    .feature-layout { grid-template-columns: 1fr; }
    .feature-layout[data-side="right"] .feature-layout__media,
    .feature-layout[data-side="right"] .feature-layout__text { order: 0; }
  }

  /* Call-to-action block — composes .glass-panel around centred copy + a button. */
  .call-to-action {
    text-align: center;

    .call-to-action__title { font-size: var(--font-size-large); }
    .call-to-action__text  { max-width: 44ch; margin-inline: auto; color: var(--text-color-soft); }
  }

  /* A single captioned image — composes .media-frame. */
  .image-figure .image-figure__caption {
    margin-block-start: var(--spacing-small);
    font-size: var(--font-size-small);
    color: var(--text-color-faint);
  }

  /* CIRCULAR PORTRAIT variant — the image is square-cropped and masked to a circle, centred and sized for a
     headshot (the "me at work" portrait that sits beside a section's copy). Works whatever the photo's aspect:
     object-fit: cover fills the circle. */
  .image-figure--circle .media-frame {
    width: min(20rem, 100%);
    margin-inline: auto;
    aspect-ratio: 1;
    border-radius: 50%;
    overflow: hidden;
  }
  .image-figure--circle .media-frame img { width: 100%; height: 100%; object-fit: cover; }
  .image-figure--circle .image-figure__caption { text-align: center; }

  /* ── A mosaic of photos — composes .card-grid + .media-frame ──────────────────────────────────
     A photo occupies columns-spanned × rows-spanned CELLS. Both default to 1, so an ordinary photo
     needs no markup at all; only a promoted one carries the custom properties.

     grid-auto-flow: dense is what lets a big photo pay for itself. Without it, a 3-wide photo that
     meets a row with only 2 columns left drops to the next row and leaves a hole behind it. Dense
     lets the small photos that follow slide up into that hole. The cost is that the visual order
     can differ from the authored order — which is the trade you asked for.

     The column span never overflows: for an auto-placed item, CSS Grid clamps a span to the number
     of columns that actually exist, so a 4-wide photo in a 2-column phone layout simply becomes
     2-wide. Nothing to code, and no media queries. */
  .photo-gallery {
    --card-min-width: 300px;
    --cell-aspect-ratio: 3 / 2;                 /* one cell's shape — the historic look, unless set below */
    grid-auto-flow: dense;
    grid-auto-rows: var(--mosaic-row-height, auto);
  }
  .photo-gallery[data-cell-shape="square"] { --cell-aspect-ratio: 1; }
  .photo-gallery[data-cell-shape="4:3"]    { --cell-aspect-ratio: 4 / 3; }

  .photo-gallery__item {
    grid-column: span var(--columns-spanned, 1);
    grid-row:    span var(--rows-spanned, 1);
    aspect-ratio: var(--cell-aspect-ratio);
  }

  /* Once engine.js has measured a real column width, the rows get an exact height and a photo simply
     FILLS the cells it was given — which is the only way a 2×2 can be truly twice as tall as a 1×1.
     Until then (and with JS off) the aspect-ratio above keeps every cell the right shape on its own,
     so the mosaic is never broken, just less exact. */
  .photo-gallery[data-mosaic-rows="measured"] .photo-gallery__item {
    aspect-ratio: auto;
    height: 100%;
  }

  /* Standard button. */
  .button {
    display: inline-flex;
    align-items: center;
    gap: 0.5em;
    padding: 0.75em 1.4em;
    border-radius: 999px;
    font-size: var(--font-size-small);
    letter-spacing: 0.08em;
    text-transform: uppercase;
    background: var(--button-background);
    color: var(--button-text-color);
    transition: background 0.2s;

    &:hover { background: var(--button-background-hover); }
  }

  /* ── A LINK IN THE MIDDLE OF A PARAGRAPH ───────────────────────────────────────────────────────
     Scoped to PROSE. Every other anchor on the site is a thing you can see — a button, a photo, a card, a
     nav item — and carries a class that styles it. A link inside a sentence has nothing but its own text to
     announce it with, which is why it needs this and they do not. (:not(.button) so a call-to-action dropped
     into a text block stays a button.)

     THE UNDERLINE IS NOT DECORATION. Colour alone cannot do this job: on the light theme, a colour readable
     as body text against the page (4.5:1) and a colour 3:1 distinct from the near-black prose around it are
     mutually exclusive — the luminance window between the two requirements is empty, and an exhaustive
     search of the sRGB gamut returns nothing. So the dotted rule is the part that actually says "link", and
     the colour is what makes it feel like one.

     Dotted at rest, so it reads as a link without shouting over the sentence it sits in; solid under the
     pointer, which is the moment it stops being prose and becomes a thing you are about to do. */
  :is(.text-passage, .reading-column, .info-panel__body, .image-figure__caption) a:not(.button) {
    color: var(--link-color);
    text-decoration: underline;
    text-decoration-style: dotted;
    text-decoration-thickness: 1px;
    text-underline-offset: 0.18em;
    /* position: relative so the press (:active, below) can nudge the link down with `top`. A TRANSFORM
       cannot do it: transforms — and the `translate` property — do not apply to a non-replaced inline box,
       and a link in a sentence is exactly that, so `translate: 0 1px` moved it precisely nowhere. Relative
       positioning DOES apply to inline boxes, which is why the press uses `top`. */
    position: relative;
    top: 0;
    /* The colour eases over half a second — a slow, cool bloom as the pointer arrives and lingers as it
       leaves. The PRESS stays at 0.05s on purpose: a button that takes half a second to depress feels
       broken, not smooth, so `top` keeps its snap while `color` takes its time. */
    transition: color 0.5s ease, text-decoration-color 0.5s ease, top 0.05s ease;

    /* LVHA — :visited must come before :hover, or a link you have already followed refuses to light up
       under the pointer, which is exactly the link you are most likely to hover next. */
    &:visited { color: var(--link-color-visited); }

    &:hover {
      color: var(--link-color-hover);
      text-decoration-style: solid;
      text-decoration-thickness: 2px;
    }

    /* The keyboard gets the same promotion the pointer does. A focus ring alone tells you WHERE you are; it
       does not tell you the thing under it is a link. */
    &:focus-visible {
      color: var(--link-color-hover);
      text-decoration-style: solid;
      text-decoration-thickness: 2px;
    }

    /* THE PRESS. The link depresses like a button made of text — no background, just the give. It is the one
       state the reader causes directly, so it has to be FELT: `top: 1px` shifts the word down a hair on
       pointer-down and it springs back on release (transition, above). transform would be ignored here — see
       position: relative on the base. */
    &:active {
      color: var(--link-color-hover);
      top: 1px;
    }

    /* LEAVING THE SITE. The mark goes on external links ONLY, because it is telling you something true —
       this one takes you away — rather than decorating a link that is already visibly a link. An arrow on
       every link would say the same thing about an internal one, and that would be a lie.
       
       inline-block keeps the underline from being drawn straight through the glyph (a decoration is not
       painted across an atomic inline box). The " / \"\"" gives it empty alt text, so a screen reader reads
       the sentence and not "north-east arrow" — it is a visual cue, and it should stay one. */
    &[href^="http://"]::after,
    &[href^="https://"]::after {
      content: "\2197" / "";
      display: inline-block;
      margin-inline-start: 0.15em;
      font-size: 0.85em;
      line-height: 1;
    }
  }

  /* Inline emphasis produced by markdown ==text== — a SLANTED highlight box: the box
     is skewed -19° (matching the nav's active item) while the text stays upright. */
  .text-highlight {
    position: relative;
    z-index: 0;
    white-space: nowrap;
    padding: 0 0.28em;
    color: var(--page-background);
    /* Headings carry a soft glow for legibility over photos, but this text sits on a SOLID
       box — inheriting that shadow just smears it. The box is the contrast; kill the glow. */
    text-shadow: none;
  }
  .text-highlight::before {
    content: "";
    position: absolute;
    inset: 0 -0.02em;
    z-index: -1;
    background: var(--heading-color);
    /* No radius. It used to be a flat 2px, which is a hairline behind a display-size heading but 12% of
       the height of the small mark in the footer — so the same rule read as "crisp" in one place and
       "rounded" in another. A skewed slab wants a sharp corner at every size; an absolute radius on a box
       whose height is set by the font size can only ever be right for one of them. */
    transform: skewX(-19deg);
  }
}

/* ── engine: scroll behaviours, all keyed on data-* so class names stay free ──── */
@layer engine {
  /* A plain reveal: fade + rise as it enters view. A STAGGERED group animates its children
     instead of itself, so it is excluded here. */
  [data-reveal-on-scroll]:not([data-stagger]) {
    opacity: 0;
    transform: translateY(1.5rem);
    transition: opacity 0.8s ease, transform 0.8s ease;
  }
  [data-reveal-on-scroll]:not([data-stagger]).is-in-view { opacity: 1; transform: none; }

  /* ── Staggered groups: children cascade in one after another ──────────────────────
     The engine sets ONE custom property per child — its ordinal, --stagger-index — and
     knows nothing about class names. All the timing lives here, so a component can tune
     its own rhythm with --stagger-step, and any new grid gets the cascade just by adding
     data-stagger. (data-stagger-wrap restarts the count every N, so a long grid doesn't
     leave its last tile waiting seconds.) */
     The hide is keyed on BOTH attributes on purpose. Whatever element hides the children must
     be the same element the engine later marks .is-in-view — so if a group ever carries
     data-stagger without data-reveal-on-scroll, it degrades to "no animation" rather than
     "content invisible forever". Fail visible. */
  [data-reveal-on-scroll][data-stagger] > * {
    opacity: 0;
    transform: translateY(26px);
    transition: opacity 0.9s ease, transform 0.9s cubic-bezier(0.22, 0.61, 0.36, 1);
    transition-delay: calc(var(--stagger-index, 0) * var(--stagger-step, 70ms));
  }
  [data-reveal-on-scroll][data-stagger].is-in-view > * { opacity: 1; transform: none; }

  /* Per-component rhythm: a pair of big images wants a slower beat than a contact sheet. */
  .diptych       { --stagger-step: 110ms; }
  .photo-gallery { --stagger-step: 60ms; }

  [data-parallax] > img { transform: scale(1.15) translateY(var(--parallax-offset, 0)); will-change: transform; }

  /* Content only animates in on a STORY page. On any other mode the reveal machinery must be explicitly
     released — its children start at opacity 0, so a page that never runs the engine would otherwise be
     blank. (That exact bug has bitten once already; hence the belt and braces.) */
  :is([data-page-mode="backdrop"], [data-page-mode="minimal"]) [data-reveal-on-scroll],
  :is([data-page-mode="backdrop"], [data-page-mode="minimal"]) [data-stagger] > * {
    opacity: 1;
    transform: none;
  }

  @media (prefers-reduced-motion: reduce) {
    html { scroll-behavior: auto; }
    .breather-overlay { transform: none; }
    /* No motion means no drift, and the overscan exists ONLY to give the drift somewhere to go — so keeping
       it would crop a tenth off the photograph in exchange for nothing. Drop it and show the whole picture. */
    .breather-frame img { transform: none; }
    .media-frame[data-fold] { transform: none; }
    .media-frame[data-fold] img { transform: none; transition: none; }
    [data-reveal-on-scroll] { opacity: 1; transform: none; transition: none; }
    [data-stagger] > * { opacity: 1; transform: none; transition: none; transition-delay: 0s; }
    [data-parallax] > img { transform: none; }
  }
}

/* ── the ONE media modal (photo lightbox, 360° panorama, 3D model, video) ────────
   Every viewer type mounts into this same shell, so the overlay, close button and
   poster-while-loading exist once. viewers.js drives it. */
@layer components {
  .media-modal {
    position: fixed;
    inset: 0;
    z-index: 100;
    display: grid;
    place-items: center;
    padding: var(--spacing-medium);
    background: rgba(0, 0, 0, 0.82);
    -webkit-backdrop-filter: blur(6px);
    backdrop-filter: blur(6px);
  }

  /* The card's poster paints instantly underneath, so the modal is never an empty
     black box while the real viewer downloads. */
  .media-modal__poster {
    position: absolute;
    inset: 0;
    width: 100%; height: 100%;
    object-fit: contain;
    opacity: 0.35;
    filter: blur(8px);
  }

  .media-modal__content {
    position: relative;
    display: grid;
    place-items: center;
    width: min(100%, 1600px);
    height: min(100%, 90vh);
  }

  /* The standalone viewer app, embedded. Fades in once it has actually loaded. */
  .media-modal__frame {
    width: 100%; height: 100%;
    border: 0;
    border-radius: var(--corner-radius);
    opacity: 0;
    transition: opacity 0.3s ease;

    &.is-ready { opacity: 1; }
  }

  .media-modal__image {
    max-width: 100%;
    max-height: 100%;
    width: auto; height: auto;
    object-fit: contain;
    border-radius: var(--corner-radius);
  }

  /* The cross is DRAWN, not typed.
     A "×" character does not sit at the centre of its own em box — it hangs off the baseline — so centring
     the text box (place-items: center) still leaves the glyph visibly low in the circle, and how low depends
     on the font. Two rotated bars have no baseline to hang off: they sit on the button's true centre and
     stay there whatever the font does. */
  .media-modal__close {
    position: absolute;
    inset-block-start: var(--spacing-medium);
    inset-inline-end: var(--spacing-medium);
    width: 2.6rem; height: 2.6rem;
    border-radius: 50%;
    background: rgba(255, 255, 255, 0.12);
    transition: background 0.2s;

    &::before, &::after {
      content: "";
      position: absolute;
      inset-block-start: 50%;
      inset-inline-start: 50%;
      width: 1.05rem;
      height: 1.5px;
      border-radius: 1px;
      background: #ffffff;
    }
    &::before { transform: translate(-50%, -50%) rotate(45deg); }
    &::after  { transform: translate(-50%, -50%) rotate(-45deg); }

    &:hover { background: rgba(255, 255, 255, 0.26); }
  }
}

/* ── cinema mode ──────────────────────────────────────────────────────────────────
   While a film is playing AND on screen, the rest of the page dims so it holds your
   attention. This is a page-level STATE, not a modal: the page still scrolls normally.
   Scrolling the film out of view, clicking anywhere outside it, or pressing Escape all
   lift the dim — three independent escapes, so you can never get stranded in it. */
@layer components {
  .cinema-dim {
    position: fixed;
    inset: 0;
    z-index: 40;
    background: rgba(0, 0, 0, 0.86);
    opacity: 0;
    animation: cinema-dim-fade-in 0.35s ease forwards;
  }
  @keyframes cinema-dim-fade-in { to { opacity: 1; } }

  /* The playing film lifts above the dim. Nothing in the page creates a stacking context
     around it (the reveal settles at opacity:1 / transform:none), so this genuinely wins. */
  .video-embed.is-playing {
    position: relative;
    z-index: 41;
    box-shadow: 0 30px 90px rgba(0, 0, 0, 0.6);
  }

  @media (prefers-reduced-motion: reduce) {
    .cinema-dim { animation: none; opacity: 1; }
  }
}

/* The page must not scroll behind an open modal. */
@layer utilities {
  .media-modal-is-open { overflow: hidden; }
}

/* ── utilities ─────────────────────────────────────────────────────────────── */
@layer utilities {
  .visually-hidden {
    position: absolute;
    width: 1px; height: 1px;
    overflow: hidden;
    clip-path: inset(50%);
  }
  [hidden] { display: none !important; }
}

/* =============================================================================
   BLOCK CATALOG — every block the CMS can emit, each composing the primitives
   above. The markup each block expects is noted inline; that comment doubles as
   the spec for the builder rewrite. Layers are re-opened (valid CSS: they merge
   into the order declared at the top) so related rules stay grouped.
   ============================================================================= */

/* More theme-flipping tokens: the immersive backdrop veil; the logo filter (the
   logo SVG is dark, so it inverts to white on the dark skin only); and the
   NAVIGATION tokens — the page-wide band's glass tint + hairline, the inner pill's
   glass, and the pill link's skewed hover box. All carry the exact dark/light
   values from the old header so it looks the same in both themes. */

@layer components {
  /* ── content furniture ───────────────────────────────────────────────────── */

  /* Body copy. Markup: <div class="text-passage reading-column">…paragraphs…</div> */
  .text-passage { color: var(--text-color-soft); }
  .text-passage strong { color: var(--text-color); }

  /* READING HALO — a soft, theme-aware glow on body copy (NO box), so glyphs separate from a busy backdrop by
     crisping their own edges: a dark glow lifts light text on a dark page, a light glow lifts dark text on a
     light one. Only where there's a backdrop to fight — a minimal page's copy sits on a solid ground and needs
     none. One dial: --reading-halo (set it to `none` to switch the effect off). */
  :is([data-page-mode="story"], [data-page-mode="backdrop"]) .text-passage {
    text-shadow: var(--reading-halo);
  }

  /* Specifications — a titled list of label/value rows.
     Markup: <dl class="specification-list"><div><dt>Label</dt><dd>Value</dd></div>…</dl> */
  .specification-list {
    display: grid;
    gap: var(--spacing-small);
    max-width: var(--reading-column-width);

    > div {
      display: grid;
      grid-template-columns: minmax(8rem, 12rem) 1fr;
      gap: var(--spacing-small);
      padding-block: var(--spacing-small);
      border-block-start: 1px solid var(--hairline-color);
    }
    & dt { color: var(--text-color-faint); text-transform: uppercase; letter-spacing: 0.06em;
           font-size: var(--font-size-small); }
    & dd { color: var(--text-color); }
  }

  /* Aside / callout — a set-apart note with an accent edge. Composes .glass-panel. */
  .callout-aside {
    max-width: var(--reading-column-width);
    border-inline-start: 3px solid var(--accent-color);
    color: var(--text-color-soft);
  }

  /* Two or three parallel columns of copy. A GRID, not CSS multi-column: each column is
     its own authored block of text, not one flow chopped into columns. */
  .text-columns {
    display: grid;
    gap: var(--spacing-large);
    grid-template-columns: repeat(var(--column-count, 2), 1fr);
    color: var(--text-color-soft);
  }
  @media (width <= 44rem) {
    .text-columns { grid-template-columns: 1fr; }
  }

  /* Divider between sections. Markup: <hr class="divider"> */
  .divider {
    border: 0;
    height: 1px;
    background: var(--hairline-color);
    max-width: var(--content-max-width);
    margin-inline: auto;
  }

  /* ── media compositions ──────────────────────────────────────────────────── */

  /* Two images side by side; stacks on narrow screens. Composes .media-frame twice. */
  .diptych {
    display: grid;
    gap: var(--spacing-small);
    grid-template-columns: repeat(2, 1fr);
  }
  @media (width <= 40rem) { .diptych { grid-template-columns: 1fr; } }

  /* Cross-link tiles to other pages. Sit inside a .card-grid.
     Markup: <a class="gateway-tile" href="…"><img …><span class="gateway-tile__title">…</span></a>
             …or <a class="gateway-tile" href="…"><picture><source><img …></picture>…</a>

     TWO shapes, one rule. A tile's photo arrives either as a bare <img> (a gallery photo) or wrapped in
     a <picture> (a linked page's social card, which offers WebP + a jpg ladder). This used to be
     `> img`, a DIRECT-child selector, so a picture-wrapped image was a grandchild and matched nothing:
     it fell back to an ordinary in-flow block — no cover, no fill — and shoved the caption out of the
     bottom of the tile. The descendant form covers both, and the <picture> itself must be positioned
     too, since it is the element actually laid out. */
  .gateway-tile {
    position: relative;
    display: grid;
    aspect-ratio: 4 / 3;
    overflow: hidden;
    border-radius: var(--corner-radius);

    > picture { position: absolute; inset: 0; display: block; }
    img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; transition: transform 0.5s; }
    &:hover img { transform: scale(1.05); }

    /* Title + description sit over the photo on a gradient, so they always read. */
    .gateway-tile__caption {
      position: relative;
      align-self: end;
      display: grid;
      gap: var(--spacing-tiny);
      padding: var(--spacing-medium);
      background: linear-gradient(to top, rgba(0, 0, 0, 0.78), rgba(0, 0, 0, 0.35) 55%, transparent);
      color: #ffffff;
    }
    .gateway-tile__title {
      font-family: var(--font-family-display);
      font-size: var(--font-size-large);
      line-height: 1.1;
    }
    .gateway-tile__text {
      font-size: var(--font-size-small);
      color: rgba(255, 255, 255, 0.78);
    }
  }

  /* ── Download cards: one per platform, from the `downloads` block ─────────────────────────────
     The grid is .card-grid's (responsive tracks + --card-count); only the card itself is new. A card
     with no button is a real state, not a failure — a platform that is coming soon still belongs on
     the page, so .download-card__note occupies the same slot the button would. */
  .download-card {
    display: grid; align-content: start; gap: var(--spacing-tiny);
    padding: var(--spacing-medium);
    background: var(--panel-background-subtle);
    border: 1px solid var(--panel-background-subtle);
    border-radius: var(--corner-radius);
  }
  .download-card__platform { font-size: var(--font-size-large); margin: 0; }
  .download-card__requires { font-size: var(--font-size-small); color: var(--text-color-faint); margin: 0; }
  .download-card__button {
    margin-block-start: var(--spacing-small); justify-self: start;
    display: inline-flex; align-items: baseline; gap: 0.45em;
    padding: 0.5rem 0.9rem; border-radius: var(--corner-radius);
    background: var(--accent-color); color: #ffffff; text-decoration: none;
    font-size: var(--font-size-small);
  }
  .download-card__button:hover { filter: brightness(1.1); }
  .download-card__size { opacity: 0.75; font-variant-numeric: tabular-nums; }
  .download-card__note {
    margin-block-start: var(--spacing-small);
    font-size: var(--font-size-small); color: var(--text-color-faint); font-style: italic;
  }

  /* ── Text cards: a heading and its bullets, from the `cards` block ───────────────────────────── */
  .text-card { display: grid; align-content: start; gap: var(--spacing-small); padding: var(--spacing-medium); }
  .text-card__title { font-size: var(--font-size-small); letter-spacing: 0.14em; text-transform: uppercase;
    color: var(--text-color-faint); margin: 0; }
  .text-card__list { margin: 0; padding: 0; list-style: none; display: grid; gap: 0.4rem; }
  .text-card__list li { font-size: var(--font-size-small); color: var(--text-color-soft); line-height: 1.45; }

  /* ── the breather: one photograph, whole, with the mark and the quote drifting over it ──────────

     THE WHOLE PHOTOGRAPH. .media-frame normally imposes an aspect ratio and clips to it; a breather opts
     out, so the frame takes the picture's own shape and nothing is cut off the top or the bottom. That is
     also why the photo itself does not drift: an uncropped image has no overscan to drift INTO, so moving it
     would drag its own edge into view. The parallax lives on the foreground instead — which is the half of
     the effect that was doing the work anyway, since it is the SEPARATION between the two layers that reads
     as depth, not the absolute motion of either.

     isolation: isolate makes the frame the stacking context its overlay blends into, so the quote and the
     mark react to THE PHOTOGRAPH and to nothing behind it. */
  .breather-frame {
    aspect-ratio: auto;     /* the picture's own shape — no letterbox forced on the composition */
    isolation: isolate;

    /* THE OVERSCAN IS THE PARALLAX BUDGET. The photo is blown up a little beyond its frame and the frame
       clips the surplus, which is the only crop here — a margin off each edge, not a slice off the top and
       bottom of the composition. That surplus is also exactly what the drift moves within: --parallax-y can
       travel up to half the overscan before it would drag the photo's own edge into shot.

       The two numbers are a pair and must stay one: 1.2 hides a tenth of the height past each edge, and the
       drift (data-parallax="8%", set on the photo by the renderer) is eight hundredths of it. Raise the drift
       past 10% and the photo's own edge swings into shot.

       ORDER MATTERS. scale() then translate3d() would scale the translation too — a 90px drift inside a 1.12
       scale actually moves 100.8px, which is how this first overflowed its budget. Written this way round the
       element is scaled first and then moved by exactly the distance asked for, so the sums above hold.

       A transform does not affect layout, so the frame's height stays the picture's natural height however
       far it is scaled or moved — which is what lets the shape stay honest while the content drifts. */
    & img {
      width: 100%;
      height: auto;
      transform: translate3d(0, var(--parallax-y, 0px), 0) scale(1.2);
      will-change: transform;
    }
  }

  /* The mark and the quote, centred on the photograph and drifting as you scroll (--parallax-y, set by
     engine.js from data-parallax).

     PAINTED IN THE INVERSE OF THE PICTURE BENEATH. Everything in here is pure white and the layer is
     composited with difference, which computes |picture − ink|: white ink (255) yields 255 − picture, the
     inversion. White is not a preference here, it is the only value that does anything — black ink is 0, and
     |picture − 0| is the picture itself, so a black quote under this blend would be perfectly invisible.

     So it reads on a dark sky and on a bright hull alike, with no way of knowing the photograph in advance.
     Its one blind spot is flat mid-grey (|128 − 255| is 127, mid-grey again); photographs are rarely that
     even, but that is the failure mode if one ever is. */
  .breather-overlay {
    position: absolute;
    inset: 0;
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;      /* vertically centred on the photograph */
    gap: var(--spacing-medium);
    padding-inline: var(--spacing-medium);

    color: #ffffff;
    mix-blend-mode: difference;

    transform: translate3d(0, var(--parallax-y, 0px), 0);
    will-change: transform;
  }

  /* The quote takes the overlay's white and the overlay's blending.

     Scoped to .breather-overlay ON PURPOSE, and not written as a bare .breather-quote. The blockquote carries
     BOTH classes (pull-quote breather-quote), and .pull-quote paints itself in --heading-color — same
     specificity, same layer, so the winner was decided purely by which rule sat lower in the file. .pull-quote
     does, so it won: the quote came out in the page's ink, which is white on a dark theme (so it looked
     right) and BLACK on a light one, where it is unreadable and, under a difference blend, subtracts nothing
     at all. Being more specific is what makes this correct rather than lucky.

     No text-shadow, for the same reason: under difference a black shadow is a subtraction of zero, so it
     would be a line of CSS that does literally nothing. */
  .breather-overlay .breather-quote {
    color: inherit;
    text-shadow: none;
  }

  /* The company mark. Same silhouette masking as the hero (cmsLogotypeMarkup and .page-hero__logo) — painted
     white so the overlay's difference blend inverts it against the picture. */
  .breather-logo {
    display: block;
    width: 100%;
    max-width: clamp(120px, 14vw, 220px);

    background-color: #ffffff;

    -webkit-mask-position: center;
    -webkit-mask-size: contain;
    -webkit-mask-repeat: no-repeat;
    mask-position: center;
    mask-size: contain;
    mask-repeat: no-repeat;

    & img {
      width: 100%;
      height: auto;
      visibility: hidden;   /* it sizes the box; the mask draws the shape */
    }
  }

  /* Info panel — a titled block of copy on a glass surface. Composes .glass-panel. */
  .info-panel {
    max-width: var(--content-max-width);
    margin-inline: auto;

    .info-panel__title { font-size: var(--font-size-large); }
    .info-panel__body  { margin-block-start: var(--spacing-small); color: var(--text-color-soft); }
  }

  /* THE X-RAY SLICES ARE BLACK-ON-TRANSPARENT, so on a dark page they vanish. The slice widget's own
     stylesheet inverts them — but it keys on @media (prefers-color-scheme: dark), i.e. the SYSTEM theme,
     which is independent of the page theme our pages actually set. So drive the invert from the PAGE:
     invert on a dark page (silhouette reads as a white outline), and never on a light one. */
  :root[data-theme="dark"]  .slice-viewer-img { filter: invert(1); }
  :root[data-theme="light"] .slice-viewer-img { filter: none; }

  /* BUILDER'S PLAN (the "blueprint" block). A draughtsman's lines-plan sheet composited live from the same
     slice stacks the slice viewer scrubs — plan, profile, and bow/stern body plans, each a stack of 1-px
     outlines extracted from many cross-sections (see media_viewers/blueprint/blueprint.js).

     THEME: the black-ink lines are drawn onto transparent canvases, so on a dark sheet they'd vanish — the
     canvases invert (light lines on dark ground) exactly as the slice widget does. The original page keyed
     this on @media (prefers-color-scheme) — the SYSTEM theme — which breaks on our pages that set their own.
     So the look is driven by blueprint-local tokens whose values flip with the PAGE theme: DARK is the base
     (the site's default), and LIGHT applies under the same two conditions the design tokens use (OS-light on
     a non-forced-dark page, or an explicitly light page). One structural ruleset, three theme states. */
  .builder-plan {
    --bp-sheet-bg: #1a1408;
    --bp-border: rgba(255, 255, 255, 0.18);
    --bp-divider: rgba(255, 255, 255, 0.12);
    --bp-ink: #c5a878;
    --bp-ink-faint: #957848;
    --bp-canvas-filter: invert(1);
    --bp-logo-filter: invert(1);
    --bp-shadow: inset 0 0 30px rgba(0, 0, 0, 0.4);
    --bp-select-bg: rgba(255, 255, 255, 0.06);
    --bp-select-bg-hover: rgba(255, 255, 255, 0.14);
    --bp-select-border: rgba(255, 255, 255, 0.3);
    --bp-select-color: #fdf6e3;

    width: 100%;
    max-width: 960px;
    margin-inline: auto;
  }
  /* LIGHT skin — cream draughting paper, dark ink, canvases un-inverted. Applied under the SAME two
     conditions the token layer uses to mean "light": OS-light on a page that hasn't forced dark, or a
     page explicitly set to light. Keeping both selectors means auto pages follow the OS correctly. */
  @media (prefers-color-scheme: light) {
    :root:not([data-theme="dark"]) .builder-plan {
      --bp-sheet-bg: #fdf6e3;
      --bp-border: rgba(0, 0, 0, 0.18);
      --bp-divider: rgba(0, 0, 0, 0.12);
      --bp-ink: #4a3a20;
      --bp-ink-faint: #7a6a48;
      --bp-canvas-filter: none;
      --bp-logo-filter: none;
      --bp-shadow: inset 0 0 30px rgba(0, 0, 0, 0.04);
      --bp-select-bg: rgba(0, 0, 0, 0.06);
      --bp-select-bg-hover: rgba(0, 0, 0, 0.12);
      --bp-select-border: rgba(0, 0, 0, 0.32);
      --bp-select-color: #4a3a20;
    }
  }
  :root[data-theme="light"] .builder-plan {
    --bp-sheet-bg: #fdf6e3;
    --bp-border: rgba(0, 0, 0, 0.18);
    --bp-divider: rgba(0, 0, 0, 0.12);
    --bp-ink: #4a3a20;
    --bp-ink-faint: #7a6a48;
    --bp-canvas-filter: none;
    --bp-logo-filter: none;
    --bp-shadow: inset 0 0 30px rgba(0, 0, 0, 0.04);
    --bp-select-bg: rgba(0, 0, 0, 0.06);
    --bp-select-bg-hover: rgba(0, 0, 0, 0.12);
    --bp-select-border: rgba(0, 0, 0, 0.32);
    --bp-select-color: #4a3a20;
  }

  .builder-plan > figcaption {
    margin-top: 0.7rem;
    font-size: 0.8rem;
    line-height: 1.6;
    color: var(--text-color-faint);
    text-align: center;
  }
  /* The cream/dark sheet: a flex column holding the 2-column diagram area over a credits+knob footer band. */
  .builder-plan-grid {
    display: flex;
    flex-direction: column;
    background: var(--bp-sheet-bg);
    border: 1px solid var(--bp-border);
    border-radius: var(--corner-radius, 8px);
    box-shadow: var(--bp-shadow);
  }
  .builder-plan-cols {
    display: flex;
    align-items: stretch;
    gap: 0;
    padding: 1.4rem 1.6rem;
  }
  .builder-plan-col {
    display: flex;
    flex-direction: column;
    justify-content: space-between;
    min-width: 0;   /* let canvases shrink in narrow viewports */
  }
  .builder-plan-col-left  { flex: 1; border-right: 1px dashed var(--bp-divider); }
  .builder-plan-col-right { flex: 3; }
  .builder-plan-col > * + * { border-top: 1px dashed var(--bp-divider); }   /* divider between stacked views */
  /* Footer band: credits at one end, the slice-count knob at the other, one dashed rule above. */
  .builder-plan-footer {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    justify-content: space-between;
    gap: 0.55rem 1.5rem;
    padding: 0.85rem 1.6rem 0.9rem;
    border-top: 1px dashed var(--bp-border);
  }
  .builder-plan-credits {
    margin: 0;
    font-family: 'Times New Roman', Georgia, serif;
    font-style: italic;
    font-size: 0.78rem;
    line-height: 1.5;
    color: var(--bp-ink-faint);
  }
  .builder-plan-global {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: 0.55rem 1rem;
    margin: 0;
    font-family: 'Times New Roman', Georgia, serif;
    font-style: italic;
    color: var(--bp-ink);
  }
  .builder-plan-global label {
    display: inline-flex;
    align-items: center;
    gap: 0.55rem;
    text-transform: uppercase;
    letter-spacing: 0.16em;
    font-style: normal;
    font-size: 0.7rem;
  }
  .builder-plan-global select {
    padding: 0.28rem 0.6rem;
    font-family: 'Times New Roman', Georgia, serif;
    font-style: italic;
    font-size: 0.85rem;
    cursor: pointer;
    color: var(--bp-select-color);
    border: 1px solid var(--bp-select-border);
    border-radius: 3px;
    background: var(--bp-select-bg);
  }
  .builder-plan-global select:hover { background: var(--bp-select-bg-hover); }
  .builder-plan-view,
  .builder-plan-title { padding: 1rem 1.2rem; }
  .builder-plan-view h4 {
    margin: 0 0 0.7rem;
    font-family: 'Times New Roman', Georgia, serif;
    font-style: italic;
    font-weight: 400;
    text-transform: uppercase;
    letter-spacing: 0.18em;
    font-size: 0.78rem;
    color: var(--bp-ink);
    text-align: center;
  }
  .builder-plan-view h4 span {
    display: block;
    margin-top: 0.25rem;
    font-size: 0.66rem;
    text-transform: none;
    letter-spacing: normal;
    font-style: italic;
    color: var(--bp-ink-faint);
  }
  .builder-plan-view canvas {
    display: block;
    width: 100%;
    height: auto;
    max-width: 100%;
    filter: var(--bp-canvas-filter);
  }
  /* Title cartouche: mark + boat name + key dimensions, in the same serif-italic draughtsman's hand. */
  .builder-plan-title {
    display: flex;
    flex-direction: column;
    justify-content: center;
    align-items: center;
    text-align: center;
    font-family: 'Times New Roman', Georgia, serif;
    color: var(--bp-ink);
  }
  .builder-plan-title .bp-title-logo {
    display: block;
    margin: 0 auto 0.75rem;
    width: 3.4rem;
    height: auto;
    filter: var(--bp-logo-filter);
  }
  .builder-plan-title h3 {
    margin: 0 0 0.7rem;
    font-style: italic;
    font-weight: 400;
    font-size: clamp(0.95rem, 1.8vw, 1.2rem);
    line-height: 1.25;
  }
  .builder-plan-title .bp-title-dims {
    margin: 0 0 0.6rem;
    font-style: normal;
    font-size: 0.7rem;
    text-transform: uppercase;
    letter-spacing: 0.16em;
  }
  .builder-plan-title .bp-title-meta {
    margin: 0;
    font-style: italic;
    font-size: 0.72rem;
    line-height: 1.6;
    color: var(--bp-ink-faint);
  }
  /* Narrow viewports: flatten the 2-col flex into a single ordered stack via display:contents on the
     column wrappers, so the mobile order is title / profile / plan / bow / stern regardless of source order. */
  @media (max-width: 700px) {
    .builder-plan-cols {
      display: grid;
      grid-template-columns: minmax(0, 1fr);
      grid-template-areas: "title" "profile" "plan" "bow" "stern";
      align-items: initial;
    }
    .builder-plan-col { display: contents; }
    .builder-plan-col-left { border-right: 0; }
    .builder-plan-title           { grid-area: title; }
    .builder-plan-view-body-bow   { grid-area: bow; }
    .builder-plan-view-body-stern { grid-area: stern; }
    .builder-plan-view-profile    { grid-area: profile; }
    .builder-plan-view-plan       { grid-area: plan; }
    .builder-plan-col > * + * { border-top: 0; }
    .builder-plan-title,
    .builder-plan-view-profile,
    .builder-plan-view-plan,
    .builder-plan-view-body-bow { border-bottom: 1px dashed var(--bp-divider); }
  }

  /* 360° panorama cards; sit inside a .card-grid. The badge opens the sphere viewer.
     Markup: <a class="panorama-card media-frame" data-pano="…">
               <img …><span class="panorama-card__badge">360°</span>
               <span class="panorama-card__caption">…</span></a>

     The card is a THUMBNAIL stacked over a CAPTION BAR, reading as one unit. The photo keeps its full 2:1
     height (the caption no longer overlays and shortens it) with rounded top corners; the caption is a filled
     strip below with rounded bottom corners. Both are shaped by the card's own overflow:hidden + corner
     radius (from .media-frame) — the photo's top corners and the bar's bottom corners are the card's outer
     corners, so they round; the seam between them stays square. */
  .panorama-card {
    aspect-ratio: auto;              /* the card is photo + bar now, not one 2:1 box (overrides .media-frame) */
    display: flex;
    flex-direction: column;
    cursor: pointer;

    & > img {
      width: 100%;
      height: auto;
      aspect-ratio: 2 / 1;           /* the PHOTO keeps the panorama shape; the bar adds height below it */
      object-fit: cover;
    }

    .panorama-card__badge {
      position: absolute; inset-block-start: var(--spacing-small); inset-inline-end: var(--spacing-small);
      padding: 0.2em 0.6em; border-radius: 999px;
      background: rgba(0, 0, 0, 0.55); color: #ffffff;
      font-size: var(--font-size-small); letter-spacing: 0.08em;
    }

    /* The caption bar: in FLOW below the photo (not an overlay), a SOLID strip that adapts to the theme — a
       near-black bar on the dark theme, a warm off-white on the light one (--pano-caption-background). Opaque,
       so it needs no hairline to separate from the photo; it reads as its own attached label area. */
    .panorama-card__caption {
      position: static;
      padding: var(--spacing-small) var(--spacing-medium);
      background: var(--pano-caption-background);
      color: var(--text-color);
      font-size: var(--font-size-small);
      font-style: italic;
    }
  }

  /* ── registered rich blocks ──────────────────────────────────────────────── */

  /* Large centred pull-quote. Markup: <blockquote class="pull-quote">…<cite>…</cite></blockquote> */
  .pull-quote {
    max-width: 22ch;
    margin-inline: auto;
    text-align: center;
    font-family: var(--font-family-display);
    font-size: var(--font-size-xlarge);
    line-height: 1.16;
    color: var(--heading-color);
    text-shadow: var(--heading-text-shadow);

    & cite {
      display: block;
      margin-block-start: var(--spacing-medium);
      font-style: normal; font-size: var(--font-size-small);
      letter-spacing: 0.08em; text-transform: uppercase; color: var(--text-color-faint);
    }
  }

  /* Band of statistics; sits inside a .card-grid. Markup per item:
     <div class="statistic"><span class="statistic__value">…</span><span class="statistic__label">…</span></div> */
  .statistic-band { --card-minimum-width: 150px; text-align: center; }
  .statistic {
    .statistic__value {
      font-family: var(--font-family-display); font-size: var(--font-size-xlarge);
      line-height: 1; color: var(--heading-color); text-shadow: var(--heading-text-shadow);
    }
    .statistic__label {
      margin-block-start: var(--spacing-tiny);
      font-size: var(--font-size-small); letter-spacing: 0.09em; text-transform: uppercase;
      color: var(--text-color-faint);
    }
  }

  /* Trust band — a centred row of short credential chips (CAA licensed, since 2003, local). Glass pills that
     wrap on narrow screens. Markup: <ul class="trust-band"><li>…</li>…</ul>. */
  .trust-band {
    display: flex; flex-wrap: wrap; justify-content: center; align-items: center;
    gap: var(--spacing-small);
    list-style: none; margin: 0; padding: 0;
  }
  .trust-band li {
    padding: 0.42em 0.95em;
    border: 1px solid var(--hairline-color);
    border-radius: 40px;
    background: var(--panel-background-subtle);
    -webkit-backdrop-filter: var(--panel-blur); backdrop-filter: var(--panel-blur);
    font-size: var(--font-size-small); letter-spacing: 0.06em; text-transform: uppercase;
    color: var(--text-color-soft);
  }

  /* Kinetic strapline — cycles one phrase at a time. Every phrase is stacked in ONE grid cell (grid-area 1/1)
     so the box is as tall as the tallest line and NOTHING reflows as the active line changes; only .is-active
     is opaque, and engine.js (startRotators) fades between them on a timer. With no JS the first line stays
     showing (never a blank). Markup: <p class="kinetic-strapline" data-rotator><span …__line>…</span>…</p>. */
  .kinetic-strapline {
    display: grid;
    max-width: 40ch;
    margin-inline: auto;
    font-size: var(--font-size-large);
    line-height: 1.25;
    color: var(--text-color);
    text-align: center;
  }
  .kinetic-strapline__line {
    grid-area: 1 / 1;
    opacity: 0;
    transition: opacity 0.6s ease;
  }
  .kinetic-strapline__line.is-active { opacity: 1; }
  @media (prefers-reduced-motion: reduce) {
    .kinetic-strapline__line { transition: none; }
  }

  /* Vertical timeline of dated entries.
     Markup: <ol class="timeline"><li class="timeline__entry">
               <p class="timeline__date">…</p><div class="timeline__body">…</div></li>…</ol> */
  .timeline {
    max-width: var(--reading-column-width);
    display: grid; gap: var(--spacing-large);
    padding-inline-start: var(--spacing-large);
    border-inline-start: 1px solid var(--hairline-color);

    .timeline__entry {
      position: relative;
      &::before {
        content: ""; position: absolute;
        inset-inline-start: calc(-1 * var(--spacing-large) - 5px); inset-block-start: 0.4em;
        width: 9px; height: 9px; border-radius: 50%; background: var(--accent-color);
      }
    }
    .timeline__date { color: var(--text-color-faint); font-size: var(--font-size-small);
      text-transform: uppercase; letter-spacing: 0.06em; }
    .timeline__body { margin-block-start: var(--spacing-tiny); color: var(--text-color-soft); }
  }

  /* Video — a poster "facade" clicked to swap in the real embed (keeps the page fast).
     Markup: <div class="video-embed media-frame" data-video="ID" data-video-type="youtube|vimeo|file">
               <img class="video-embed__poster" …>
               <button class="video-embed__play" aria-label="Play video">▶</button></div> */
  /* The host's own player fills the frame — it brings its own thumbnail and play button, so we
     no longer draw a facade over it (ours cost an extra click and broke claimed videos). */
  .video-embed {
    --aspect-ratio: 16 / 9;

    & iframe, & video { position: absolute; inset: 0; width: 100%; height: 100%; border: 0; }

    /* Beneath the frame, not over it — an overlaid caption would cover the player's controls. */
    .video-embed__caption {
      margin-block-start: var(--spacing-small);
      font-size: var(--font-size-small);
      color: var(--text-color-faint);
    }
  }

  /* Before / after comparison; a range input drives the reveal via --comparison-position.
     Markup: <div class="before-after-comparison">
               <img class="before-after-comparison__before" …>
               <img class="before-after-comparison__after" …>
               <span class="before-after-comparison__divider"></span>
               <input class="before-after-comparison__range" type="range" …></div> */
  .before-after-comparison {
    --comparison-position: 50%;
    position: relative; overflow: hidden; border-radius: var(--corner-radius);
    line-height: 0; user-select: none; touch-action: pan-y; cursor: ew-resize;

    /* No forced aspect. The BEFORE picture sits IN FLOW and gives the box its height, so nothing is cropped;
       the two frames are the same size, so they register exactly. The AFTER picture is laid absolutely OVER
       it and clipped back to the slider — dragging reveals more or less of it ON TOP of the before. The after
       is served as WebP-with-alpha, so where it is transparent the before shows through (a true overlay). */
    .before-after-comparison__before { display: block; width: 100%; }
    .before-after-comparison__before img { display: block; width: 100%; height: auto; }
    .before-after-comparison__after {
      position: absolute; inset: 0; z-index: 1;
      clip-path: inset(0 0 0 var(--comparison-position));
    }
    .before-after-comparison__after img { width: 100%; height: 100%; object-fit: cover; }

    /* A label on each side, naming what it is — bottom-left for the before, bottom-right for the after. */
    .before-after-comparison__label {
      position: absolute; inset-block-end: 0.7rem; z-index: 2; line-height: 1.2;
      padding: 0.25rem 0.6rem; border-radius: 4px;
      background: rgba(0, 0, 0, 0.55); color: #ffffff;
      font-size: 0.64rem; letter-spacing: 0.14em; text-transform: uppercase;
      pointer-events: none;
    }
    .before-after-comparison__label--before { inset-inline-start: 0.7rem; }
    .before-after-comparison__label--after  { inset-inline-end: 0.7rem; }

    /* The divider line, with a round grab handle at its centre. The handle carries a two-way ‹ › chevron
       drawn as an inline-SVG background — crisp, always centred, and never subject to a font's baseline (the
       reason the earlier glyph sat off-centre and clipped to a half). */
    .before-after-comparison__divider {
      position: absolute; inset-block: 0; inset-inline-start: var(--comparison-position);
      width: 2px; margin-inline-start: -1px; z-index: 3; pointer-events: none;
      background: rgba(255, 255, 255, 0.85); box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.3);
    }
    .before-after-comparison__divider::after {
      content: ""; position: absolute; inset-block-start: 50%; inset-inline-start: 50%;
      width: 40px; height: 40px; transform: translate(-50%, -50%);
      border-radius: 50%; background-color: rgba(255, 255, 255, 0.92);
      box-shadow: 0 2px 8px rgba(0, 0, 0, 0.4);
      background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23111' stroke-width='2.4' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M10 7l-4 5 4 5M14 7l4 5-4 5'/%3E%3C/svg%3E");
      background-repeat: no-repeat; background-position: center; background-size: 22px 22px;
    }
    .before-after-comparison__range {
      position: absolute; inset: 0; width: 100%; height: 100%; opacity: 0; cursor: ew-resize; z-index: 4;
    }
  }

  /* 3D model / gaussian-splat viewer. A poster FACADE (the <a>) shows until clicked; then viewers.js mounts
     SkyViewer inline into .model-viewer__stage, which is TRANSPARENT so the page shows through the model.
     Markup: <figure class="model-viewer" data-viewer-file="…" data-viewer-type="model|splat">
               <a class="model-viewer__open" href="standalone-viewer">
                 <img …><span class="model-viewer__badge">3D</span><span class="model-viewer__play">View in 3D</span></a>
               <figcaption>…</figcaption></figure> */
  .model-viewer {
    position: relative; overflow: hidden; border-radius: var(--corner-radius);
    aspect-ratio: 4 / 3;

    /* The facade — a real <a> to the standalone viewer, so it works with no JavaScript; viewers.js upgrades
       the click into an inline mount. It sits on the subtle panel while it is still a poster. */
    .model-viewer__open {
      position: absolute; inset: 0; display: grid; place-items: center;
      background: var(--panel-background-subtle); cursor: pointer;
    }
    .model-viewer__open > img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; }
    .model-viewer__badge {
      position: absolute; inset-block-start: var(--spacing-small); inset-inline-start: var(--spacing-small);
      padding: 0.2em 0.6em; border-radius: 999px;
      background: rgba(0, 0, 0, 0.55); color: #ffffff; font-size: var(--font-size-small);
    }
    /* The load call-to-action — a real BUTTON with a play icon, centred over the poster, so it plainly reads
       as "press me to load the 3D". The triangle is drawn with CSS borders (currentColor → white), so there
       is no icon asset. It brightens and lifts on hover. */
    .model-viewer__play {
      position: relative; z-index: 1;
      display: inline-flex; align-items: center; gap: 0.6rem;
      padding: 0.8rem 1.5rem; border-radius: 40px;
      background: rgba(0, 0, 0, 0.5); color: #ffffff;
      border: 1px solid rgba(255, 255, 255, 0.4);
      -webkit-backdrop-filter: blur(6px); backdrop-filter: blur(6px);
      font-size: 0.76rem; letter-spacing: 0.14em; text-transform: uppercase;
      transition: background 0.2s, border-color 0.2s, transform 0.12s;
    }
    .model-viewer__play::before {
      content: ""; flex: none;
      width: 0; height: 0; border-style: solid;
      border-width: 6px 0 6px 11px;
      border-color: transparent transparent transparent currentColor;   /* right-pointing play ▶ */
    }
    .model-viewer__open:hover .model-viewer__play,
    .model-viewer__open:focus-visible .model-viewer__play {
      background: rgba(0, 0, 0, 0.68); border-color: #ffffff; transform: scale(1.03);
    }
    /* The live viewer, once mounted. TRANSPARENT — the whole point of an inline mount over an iframe. */
    .model-viewer__stage { position: absolute; inset: 0; background: transparent; }
    .model-viewer__stage canvas { position: absolute; inset: 0; width: 100%; height: 100%; }
  }

  /* Location map — a dependency-free SVG plot of GPS points.
     Markup: <figure class="location-map"><div class="location-map__figure"><svg>…
               <circle class="location-map__point"/><text class="location-map__label"/>…</svg></div>
               <figcaption class="location-map__caption">…</figcaption></figure> */
  .location-map {
    max-width: var(--content-max-width); margin-inline: auto;

    .location-map__figure {
      position: relative; aspect-ratio: 16 / 9; overflow: hidden;
      border-radius: var(--corner-radius); background: var(--panel-background-subtle);
    }
    /* The framed Time and Place map. It fills the figure and brings its own controls and
       attribution, so there is nothing to style inside it — the frame is another document.
       The figure sets --aspect-ratio from the block's chosen shape. */
    .location-map__figure { aspect-ratio: var(--aspect-ratio, 16 / 9); }
    .location-map__frame { display: block; width: 100%; height: 100%; border: 0; }

    /* Deep link into the full map app at the same place. */
    .location-map__link {
      display: inline-block; margin-block-start: var(--spacing-small);
      font-size: var(--font-size-small); color: var(--text-color-soft);
      text-decoration: none; border-block-end: 1px solid currentColor;
    }
    .location-map__link:hover { color: var(--text-color); }

    .location-map__point { fill: var(--accent-color); }
    .location-map__label { fill: var(--text-color); font-size: 0.7rem; }
    .location-map__caption { margin-block-start: var(--spacing-small);
      color: var(--text-color-faint); font-size: var(--font-size-small); }
  }

  /* ── project index (auto-generated list of project pages) ─────────────────── */
  .project-index { --card-minimum-width: 280px; }
  .project-card {
    display: block; overflow: hidden;
    border-radius: var(--corner-radius); border: 1px solid var(--hairline-color);
    background: var(--panel-background);
    transition: border-color 0.2s;

    &:hover { border-color: var(--accent-color); }
    .project-card__thumbnail { aspect-ratio: 3 / 2; }
    .project-card__title {
      padding: var(--spacing-medium) var(--spacing-medium) var(--spacing-tiny);
      font-family: var(--font-family-display); font-size: var(--font-size-medium); color: var(--heading-color);
    }
    .project-card__summary {
      padding: 0 var(--spacing-medium) var(--spacing-medium);
      color: var(--text-color-soft); font-size: var(--font-size-small);
    }
  }
}

@layer engine {
  /* Full-bleed parallax backdrop behind immersive sections. Layers crossfade; the
     veil keeps foreground text legible over any photo, and flips with the theme.
     Markup: <div class="full-bleed-background" data-parallax>
               <div class="full-bleed-background__layer" data-layer="a"></div>
               <div class="full-bleed-background__layer" data-layer="b"></div>
               <div class="full-bleed-background__veil"></div></div> */
  .full-bleed-background {
    position: fixed; inset: 0; z-index: -1; overflow: hidden;

    .full-bleed-background__layer {
      position: absolute; inset: 0;
      background-size: cover; background-position: center;
      opacity: 0; transition: opacity 1.2s ease;
    }
    .full-bleed-background__layer.is-active { opacity: 1; }
    /* The particle backdrop's host. The visualiser inserts its own canvas, sized to fill this. It is drawn
     TRANSPARENT, so the page's background colour shows through beneath it and the veil still sits over it
     for legibility — exactly like a photograph would. */
  .full-bleed-background__particles {
    position: absolute;
    inset: 0;
    overflow: hidden;
  }

  .full-bleed-background__veil { position: absolute; inset: 0; background: var(--backdrop-veil); }
  }

  /* Chapter rail — fixed dots linking each section, on immersive pages only.
     Markup: <nav class="chapter-rail"><a class="chapter-rail__link" href="#…"></a>…</nav> */
  .chapter-rail {
    position: fixed; inset-block-start: 50%; inset-inline-start: var(--spacing-medium);
    transform: translateY(-50%); z-index: 5;
    display: grid; gap: var(--spacing-small);

    .chapter-rail__link {
      position: relative;
      width: 10px; height: 10px; border-radius: 50%;
      background: var(--hairline-color); transition: background 0.2s, transform 0.2s;
    }
    .chapter-rail__link.is-current { background: var(--accent-color); transform: scale(1.4); }

    /* On hover (and keyboard focus), name the chapter beside its dot — just the words, no panel, no shadow.
       The label IS the dot's aria-label, pulled in with attr() so there is one source of truth. */
    .chapter-rail__link::after {
      content: attr(aria-label);
      position: absolute; inset-inline-start: calc(100% + 0.7rem); inset-block-start: 50%;
      transform: translateY(-50%) translateX(-4px);
      white-space: nowrap;
      color: var(--text-color-soft);
      font-size: var(--font-size-small); line-height: 1.2;
      opacity: 0; pointer-events: none;
      transition: opacity 0.15s ease, transform 0.15s ease;
    }
    .chapter-rail__link:hover::after,
    .chapter-rail__link:focus-visible::after { opacity: 1; transform: translateY(-50%) translateX(0); }
  }
  @media (width <= 60rem) { .chapter-rail { display: none; } }

  /* "Scroll to begin" — a real button sitting IN THE PAGE FLOW beneath the hero. It is
     deliberately NOT positioned: an absolutely-positioned cue anchors to whatever ancestor
     happens to be positioned and drifts to odd places as you scroll.
     The chevron is a small square showing only its right + bottom edges, rotated 45° — that
     reads as a quiet "v". It fades in and out (opacity only, so the rotation survives) rather
     than bobbing about. Clicking it scrolls to the first section. */
  .scroll-cue {
    display: flex;
    flex-direction: column;
    align-items: center;
    gap: 0.55rem;
    margin: 1.6rem auto 0;
    padding: 0.4rem 0.8rem;
    font-size: 0.72rem;
    letter-spacing: 0.22em;
    text-transform: uppercase;
    color: var(--text-color-faint);
    cursor: pointer;
    transition: color 0.25s ease;

    &:hover, &:focus-visible { color: var(--heading-color); }
    &:focus-visible { outline: 2px solid var(--accent-color); outline-offset: 3px; }

    &::after {
      content: "";
      width: 8px; height: 8px;
      border-right: 1px solid currentColor;
      border-bottom: 1px solid currentColor;
      transform: rotate(45deg);
      transition: transform 0.25s ease;
      animation: scroll-cue-fade 1.8s ease-in-out infinite;
    }
    &:hover::after, &:focus-visible::after { transform: rotate(45deg) translate(2px, 2px); }
  }
  @keyframes scroll-cue-fade { 0%, 100% { opacity: 0.35; } 50% { opacity: 1; } }

  /* THE THREE CHROME MODES, in one place.
       story     backdrop + chapter rail + scroll reveal
       backdrop  the backdrop, and nothing else — no rail, no cue, nothing animating
       minimal   a plain reading page
     The renderer already omits the elements it does not want; these rules are the guarantee, so a stale
     build can never leave a rail or a cue floating on a page that has switched mode. */
  [data-page-mode="minimal"] .full-bleed-background { display: none; }
  :is([data-page-mode="backdrop"], [data-page-mode="minimal"]) :is(.chapter-rail, .scroll-cue) { display: none; }

  @media (prefers-reduced-motion: reduce) {
    .scroll-cue::after { animation: none; }   /* the fade lives on the chevron, not the button */
    .full-bleed-background__layer { transition: none; }
  }
}

/* NOTE — still to fold in during the rollout: the photo lightbox and the 360°/3D
   sphere viewer currently live in media_viewers/*.css and get migrated onto these
   tokens + names in the same pass that rewrites the builder markup. */
    /* A TWO-TONE mark (intro.logo + intro.logo_dark): the same artwork drawn for each background, with
       CSS choosing. Both are in the markup; exactly one is ever shown.

       Keyed on data-theme, NOT prefers-color-scheme. The page this replaced used the media query, which
       is wrong here for the same reason it was wrong in the map frame: this site has its own theme
       toggle, so a reader on a light OS who switches the site to dark would be shown the dark-background
       logo on a dark page. The site's chosen theme is the authority.

       Default is the light-background artwork, matching the CSS default skin; the dark rules below take
       over on the dark skin, whether that came from the toggle or from the OS preference. */
    .page-hero__logo--for-dark { display: none; }
    .page-hero__logo--for-light { display: block; }

    @media (prefers-color-scheme: dark) {
      :root:not([data-theme="light"]) .page-hero__logo--for-light { display: none; }
      :root:not([data-theme="light"]) .page-hero__logo--for-dark { display: block; }
    }
    [data-theme="dark"] .page-hero__logo--for-light { display: none; }
    [data-theme="dark"] .page-hero__logo--for-dark { display: block; }
    [data-theme="light"] .page-hero__logo--for-light { display: block; }
    [data-theme="light"] .page-hero__logo--for-dark { display: none; }


