/* ==========================================================================
   CASA
   Tokens and components per DESIGN_SYSTEM.md. No value is hardcoded in a
   component that exists as a token here.
   ========================================================================== */

/* --- Fonts, self hosted so nothing third party loads on first paint ------- */
@font-face{font-family:'Anton';src:url('../fonts/anton.woff2') format('woff2');font-weight:400;font-style:normal;font-display:swap}
@font-face{font-family:'Inter';src:url('../fonts/inter-400.woff2') format('woff2');font-weight:400;font-style:normal;font-display:swap}
@font-face{font-family:'Inter';src:url('../fonts/inter-500.woff2') format('woff2');font-weight:500;font-style:normal;font-display:swap}

/* --- Tokens -------------------------------------------------------------- */
:root{
  --ink:#14181A;
  --bone:#F2EAD9;
  --azure:#1B7FB5;
  --coral:#E2574B;
  --palm:#3C7A56;
  --mango:#E8A33D;
  --magenta:#7A2B63;

  --text-on-ink:#F2EAD9;
  --text-on-ink-secondary:#9AA3A7;
  --text-on-bone:#14181A;
  --text-on-bone-secondary:#4A4038;
  --text-on-bone-muted:#7A6E60;

  --on-azure:#E1F0F9;
  --on-coral:#FCEFEC;
  --on-palm:#DCEBE1;
  --on-mango:#412402;
  --on-magenta:#F2D9EA;

  --font-display:'Anton',Impact,system-ui,sans-serif;
  --font-body:'Inter',system-ui,-apple-system,Segoe UI,sans-serif;

  --step-hero:clamp(2.75rem,7vw + 0.5rem,6.5rem);
  --step-h1:clamp(2rem,4.5vw + 0.5rem,4rem);
  --step-h2:clamp(1.5rem,2.5vw + 0.5rem,2.5rem);
  --step-h3:clamp(1.25rem,1.2vw + 0.75rem,1.5rem);
  --step-body:clamp(0.9375rem,0.3vw + 0.875rem,1rem);
  --step-small:0.8125rem;
  --step-micro:0.75rem;

  --leading-display:1.05;
  --leading-body:1.7;
  --tracking-display:0.005em;

  --space-3xs:4px; --space-2xs:8px; --space-xs:12px;
  --space-s:16px;  --space-m:24px;  --space-l:40px;
  --space-xl:64px; --space-2xl:96px; --space-3xl:144px;

  --radius:4px;
  --frame-weight-s:5px;
  --frame-weight-l:clamp(8px,1.2vw,18px);
  --measure:68ch;
  --gutter:clamp(20px,4vw,64px);
  /* Measured from the real bar in JavaScript; this is the fallback. */
  --header-h:84px;
  --max-width:1440px;

  --dur-quick:0.3s;
  --dur-base:0.6s;
  --dur-slow:0.9s;
  --dur-epic:1.4s;
  --ease-out:cubic-bezier(0.16,1,0.3,1);
  --ease-in-out:cubic-bezier(0.65,0,0.35,1);

  /* The travelling frame colour. One property, changed on scroll in phase 3. */
  --frame:var(--azure);
}

/* --- Reset --------------------------------------------------------------- */
*,*::before,*::after{box-sizing:border-box}
html{-webkit-text-size-adjust:100%}
body,h1,h2,h3,p,figure,blockquote,ul,ol,fieldset,legend{margin:0;padding:0}
ul,ol{list-style:none}
img,video,svg{display:block;max-width:100%}
button,input,select,textarea{font:inherit;color:inherit}
:where(a){color:inherit}

html{scroll-behavior:smooth}
@media (prefers-reduced-motion:reduce){
  html{scroll-behavior:auto}
  *,*::before,*::after{animation-duration:.01ms !important;animation-iteration-count:1 !important;transition-duration:.01ms !important}
}

body{
  background:var(--ink);
  color:var(--text-on-ink);
  font-family:var(--font-body);
  font-weight:400;
  font-size:var(--step-body);
  line-height:var(--leading-body);
  overflow-x:hidden;
  text-rendering:optimizeLegibility;
}

/* --- Shared ------------------------------------------------------------- */
.wrap{width:100%;max-width:var(--max-width);margin-inline:auto;padding-inline:var(--gutter)}
.section{padding-block:var(--space-2xl)}
@media (min-width:900px){.section{padding-block:var(--space-3xl)}}

.s-ink{background:var(--ink);color:var(--text-on-ink)}
.s-bone{background:var(--bone);color:var(--text-on-bone)}

/* Bone carries a very quiet paper tone beneath the grain. Flat, not a gradient. */
.s-bone{background-image:none}

.display{
  font-family:var(--font-display);
  font-weight:400;
  line-height:var(--leading-display);
  letter-spacing:var(--tracking-display);
  text-transform:uppercase;
}
.h1{font-size:var(--step-h1)}
.h2{font-size:var(--step-h2)}
.h3{font-size:var(--step-h3);font-family:var(--font-body);font-weight:500;text-transform:none;letter-spacing:0;line-height:1.3}

.lede,.body p{max-width:var(--measure)}
.body p + p{margin-top:var(--space-m)}
.muted{color:var(--text-on-bone-secondary)}
.s-ink .muted{color:var(--text-on-ink-secondary)}

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

/* Focus. Visible everywhere, never removed. */
:focus-visible{outline:2px solid var(--coral);outline-offset:2px;border-radius:2px}
.s-ink :focus-visible{outline-color:var(--mango)}

/* --- The frame ----------------------------------------------------------- */
/* One device. Colour comes from --frame and travels down the page.
   Never nested, never magenta, radius never above --radius. */
.frame{
  border:var(--frame-weight-l) solid var(--frame);
  border-radius:var(--radius);
  background:var(--ink);
  overflow:hidden;
  transition:border-color var(--dur-base) var(--ease-in-out);
}
.frame--s{border-width:var(--frame-weight-s)}
.frame > img,.frame > video{width:100%;height:100%;object-fit:cover;display:block}

/* Per section colour travel. Phase 3 replaces these with a tween on :root. */
[data-frame="azure"]{--frame:var(--azure)}
[data-frame="coral"]{--frame:var(--coral)}
[data-frame="mango"]{--frame:var(--mango)}

/* --- Buttons ------------------------------------------------------------- */
.btn{
  display:inline-block;
  font-family:var(--font-body);
  font-weight:500;
  font-size:var(--step-small);
  letter-spacing:.02em;
  line-height:1;
  padding:var(--space-s) var(--space-m);
  border-radius:var(--radius);
  border:1px solid transparent;
  text-decoration:none;
  cursor:pointer;
  transition:transform var(--dur-quick) var(--ease-out);
  will-change:transform;
}
.btn span{display:inline-block;transition:transform var(--dur-quick) var(--ease-out)}
.btn:hover{transform:scale(1.02)}
.btn:hover span{transform:translateX(2px)}
@media (prefers-reduced-motion:reduce){.btn,.btn span{transition:none}.btn:hover{transform:none}.btn:hover span{transform:none}}

/* Coral at 13px against --on-coral measures 3.28:1 and fails AA.
   Ink on coral measures 4.90:1 and passes. See BUILD-NOTES. */
.btn--primary{background:var(--coral);color:var(--ink);border-color:var(--coral)}
.btn--primary:focus-visible{outline-color:var(--coral)}

.btn--outline{background:transparent;color:var(--text-on-bone);border-color:var(--text-on-bone)}
.s-ink .btn--outline{color:var(--text-on-ink);border-color:var(--text-on-ink)}

/* --- Links --------------------------------------------------------------- */
/* Azure on bone is 3.7:1 and fails AA for body sized text, so link text stays
   ink and azure does the marking. Colour never carries meaning alone. */
.link{
  color:inherit;text-decoration:none;
  border-bottom:2px solid var(--azure);
  padding-bottom:1px;
  transition:border-color var(--dur-quick) var(--ease-in-out);
}
.link:hover{border-color:var(--magenta)}
.s-ink .link{border-bottom-color:var(--mango)}
.s-ink .link:hover{border-bottom-color:var(--coral)}

/* --- Grain --------------------------------------------------------------- */
.grain{
  position:fixed;inset:0;
  width:100%;height:100%;
  pointer-events:none;
  opacity:.05;
  mix-blend-mode:overlay;
  z-index:60;
}

/* ==========================================================================
   Header and navigation
   ========================================================================== */
.site-header{
  position:fixed;top:0;left:0;right:0;z-index:40;
  display:flex;align-items:center;justify-content:space-between;gap:var(--space-m);
  background:var(--ink);
  padding-inline:var(--gutter);
  padding-block:var(--space-xs);
}

/* --- The toolbar's colour field ------------------------------------------
   Image 18, the same picture as the videos section, parallaxing through its
   full height as the page scrolls and fading to ink when the bar reaches the
   testimonials section. Added 10 September on the manager's instruction.

   THE SCRIM IS NOT OPTIONAL. The bar carries the white wordmark, bone nav
   links and a bone Instagram glyph. Measured against this picture at every
   position in the traversal, bone runs 1.37:1 to 2.22:1 and ink 1.76:1 to
   3.88:1 — the content is illegible on the bare image ANYWHERE, in either
   colour, because the picture is coral led and coral sits in the middle of the
   tonal range. An ink layer at 0.58 over it takes bone to 4.65:1 at the
   brightest pixel in the whole image, so it holds wherever the traversal
   happens to be. 0.50 gives 4.03 and fails. Do not lower it.

   The scrim is the first background layer so a single opacity on the pseudo
   element fades the picture and its scrim together, leaving the bar's own ink.

   Both custom properties are written by casa.js. With JavaScript off the bar
   simply keeps the top of the picture and never fades, which is legible
   because the scrim is baked in, just not animated. */
.site-header--sticky{position:fixed;isolation:isolate}
.site-header--sticky::before{
  content:"";
  position:absolute;inset:0;z-index:0;
  pointer-events:none;
  background-image:
    linear-gradient(rgba(20,24,26,.58), rgba(20,24,26,.58)),
    url("../assets/img/field-03-1600.webp");
  background-size:100% 100%, 100% auto;
  background-position:0 0, 50% var(--hdr-pos,0%);
  background-repeat:no-repeat,no-repeat;
  opacity:var(--hdr-field,1);
}
@media (max-width:700px){
  .site-header--sticky::before{
    background-image:
      linear-gradient(rgba(20,24,26,.58), rgba(20,24,26,.58)),
      url("../assets/img/field-03-1000.webp");
  }
}
/* The bar's content sits above the field. */
.site-header--sticky > *{position:relative;z-index:1}
.wordmark{display:block;flex:0 0 auto}
/* The wordmark lives in the bar, on the ink field, outside the frame. */
.wordmark img{
  width:clamp(112px,13vw,196px);height:auto;
  transition:width var(--dur-quick) var(--ease-out);
}
.nav{display:none}
/* The gap tightens in the band just above the breakpoint, and this is measured
   rather than eyed. The bar's tightest case is 860px with the visitor scrolled
   past the hero, which is when the nav's own coral button is showing: four text
   links, the Instagram glyph and a 209px button. At 40px gaps that wants 696px
   and the bar has 679px to give once the gutters and the wordmark are taken
   out, so it overflowed by 17px when "Setlist" was added on 10 September.

   24px gaps bring it to 616px, which clears with 63px spare, and by 1000px
   there is room for the full 40px again. Tightening the gap was chosen over
   raising the breakpoint, which would have taken the whole nav away from
   anyone between 860 and 940px, and over dropping a link.

   If a sixth nav item is ever added, redo this sum. The type here is a fixed
   13px, so nothing in the bar shrinks to absorb it. */
@media (min-width:860px){
  .nav{display:flex;align-items:center;gap:var(--space-m)}
}
@media (min-width:1000px){
  .nav{gap:var(--space-l)}
}
.nav a{
  position:relative;
  font-weight:500;font-size:var(--step-small);letter-spacing:.1em;
  text-transform:uppercase;
  text-decoration:none;color:var(--text-on-ink);
  padding-block:var(--space-2xs);
}
.nav .btn{text-transform:uppercase;letter-spacing:.1em}
.nav__icon{display:inline-flex;align-items:center;color:var(--text-on-ink)}
.nav__icon svg{display:block}
.nav a:not(.btn):not(.nav__icon)::after{
  content:"";position:absolute;left:0;right:0;bottom:0;height:2px;
  background:var(--magenta);
  transform:scaleX(0);transform-origin:left;
  transition:transform var(--dur-quick) var(--ease-out);
}
.nav a:not(.btn):hover::after,.nav a:not(.btn):focus-visible::after{transform:scaleX(1)}
/* The current page holds its rule open. This reuses the hover device rather
   than inventing a second indicator, and it is still the same 2px of magenta,
   which keeps that colour inside the 5% cap in CLAUDE.md s5. The link text
   itself is the label, so the colour is not carrying the meaning alone. */
.nav a[aria-current="page"]:not(.btn)::after{transform:scaleX(1)}
@media (prefers-reduced-motion:reduce){.nav__icon{display:inline-flex;align-items:center;color:var(--text-on-ink)}
.nav__icon svg{display:block}
.nav a:not(.btn):not(.nav__icon)::after{transition:none}}

/* The nav CTA is absent in the hero and appears once the hero is passed, and
   it stands down again whenever a section CTA is on screen. One primary coral
   button per viewport, never two. */
.nav .btn--primary{display:none}
.is-scrolled .nav .btn--primary{display:inline-block}
.is-scrolled.cta-suppressed .nav .btn--primary{display:none}

.wordmark--bar img{width:clamp(88px,8vw,118px)}
@media (prefers-reduced-motion:reduce){.wordmark img{transition:none}}

/* --- Mobile menu -----------------------------------------------------------
   Below 860px .nav above is display:none, which otherwise left no way to
   reach Band, Videos, the setlist or Contact at all: only the wordmark and
   the bottom cta-bar. This toggle and panel are the mobile equivalent of
   .nav, sharing its ink/bone pairing and link styling rather than inventing
   a second visual language. */
.nav-toggle{
  display:flex;flex-direction:column;justify-content:center;align-items:flex-end;
  gap:5px;
  width:32px;height:32px;padding:0;flex:0 0 auto;
  background:none;border:0;
  cursor:pointer;
}
@media (min-width:860px){.nav-toggle{display:none}}
.nav-toggle__bar{
  display:block;width:100%;height:2px;background:var(--text-on-ink);
  transition:transform var(--dur-quick) var(--ease-in-out),
             opacity var(--dur-quick) var(--ease-in-out),
             width var(--dur-quick) var(--ease-in-out);
}
.nav-toggle__bar:nth-child(2){width:70%}
.nav-toggle[aria-expanded="true"] .nav-toggle__bar{width:100%}
.nav-toggle[aria-expanded="true"] .nav-toggle__bar:nth-child(1){transform:translateY(7px) rotate(45deg)}
.nav-toggle[aria-expanded="true"] .nav-toggle__bar:nth-child(2){opacity:0}
.nav-toggle[aria-expanded="true"] .nav-toggle__bar:nth-child(3){transform:translateY(-7px) rotate(-45deg)}
@media (prefers-reduced-motion:reduce){.nav-toggle__bar{transition:none}}

/* Sits under the fixed bar at its measured height, never a guessed number. */
.nav-mobile{
  display:none;
  position:fixed;top:var(--header-h);left:0;right:0;z-index:45;
  flex-direction:column;
  background:var(--ink);color:var(--text-on-ink);
  padding:0 var(--gutter) var(--space-m);
  max-height:calc(100vh - var(--header-h));
  overflow-y:auto;
}
.nav-mobile.is-open{display:flex}
@media (min-width:860px){.nav-mobile{display:none !important}}
.nav-mobile a{
  display:block;
  padding-block:var(--space-s);
  border-bottom:1px solid rgba(242,234,217,.14);
  font-weight:500;font-size:var(--step-small);letter-spacing:.1em;
  text-transform:uppercase;
  text-decoration:none;color:var(--text-on-ink);
}
.nav-mobile a:last-child{border-bottom:0}
/* Same device as the desktop nav's current-page indicator: mango rather than
   a second colour, since this is the only page-scoped link that leaves the
   page. */
.nav-mobile a[aria-current="page"]{color:var(--mango)}
.nav-mobile .nav-mobile__icon{display:flex;align-items:center;gap:var(--space-2xs)}
.nav-mobile__icon svg{flex:0 0 auto}

/* Anchors clear the fixed bar. */
[id]{scroll-margin-top:calc(var(--header-h) + var(--space-s))}

/* ==========================================================================
   Hero
   ========================================================================== */
/* Full bleed, on Ed's instruction. The azure frame is gone and with it the
   16:9 letterboxing: the video now covers the whole hero and is cropped by the
   window, which is what full bleed means. The wordmark and the locked headline
   are burned into the centre of the picture.

   Note for whoever reads DESIGN_SYSTEM s5 next: the frame device no longer
   appears on this page at all. 404.html is the only place it survives. */
/* The section carries the source video's own aspect ratio, 16:9, measured off
   the file rather than assumed: hero-1080.mp4 is 1920x1080 with square pixels,
   hero-720.mp4 is 1280x720, and the source .mov is 1920x1080. So the box is
   exactly the shape of the picture and nothing is cropped at any width.

   Two things keep that true rather than merely likely. The media below uses
   `contain`, not `cover`, so the video cannot be cropped even if the box stops
   being 16:9. And `min-height:min-content` lets the section grow taller than
   16:9 when the burn-in needs the room, which is what will happen on a narrow
   portrait window: the picture then letterboxes against the ink ground instead
   of losing its edges. `overflow:hidden` is deliberately NOT set here, because
   on a block box it would zero the automatic minimum size and clip the
   burn-in instead of letting the section grow. */
.hero{
  --hero-sat:1.35;
  --hero-bright:0.42;
  --hero-ar:16 / 9;
  position:relative;
  isolation:isolate;
  aspect-ratio:var(--hero-ar);
  /* `auto`, NOT `min-content`, and the difference is only visible on a phone.
     A box with an aspect-ratio already gets a content-based automatic minimum
     size in the block axis, which is exactly the "grow past 16:9 when the
     burn-in needs the room" behaviour this line was always meant to buy.
     Naming `min-content` explicitly REPLACED that automatic minimum with the
     flex container's min-content height, which resolves shorter than the
     overlay actually is, so the section stayed at 16:9 and align-items:center
     split the overflow evenly: at 500px the wordmark sat 7px behind the fixed
     bar and the call to action 8px under the testimonials section.

     Found 10 September, the first time the build was ever rendered below
     900px. It had been wrong since the full-bleed hero was built. Above about
     900px the content fits inside 16:9 and the two values are identical, which
     is why nothing showed on desktop. */
  min-height:auto;
  display:flex;align-items:center;justify-content:center;
  padding-inline:var(--gutter);
  /* The header is a fixed bar sitting over the top of the picture, so the
     content box is inset by its height and the burn-in centres in the part of
     the video you can actually see rather than in the box. There is no bottom
     padding for the same reason: adding one would push the group back up. */
  padding-top:var(--header-h);
  background:var(--ink);
}

/* The picture is darkened with a filter, never with a veil, for the reason
   recorded in DESIGN_SYSTEM: a veil lifts the blacks and crushes saturation.
   The filter sits on the media wrapper, which holds nothing but the poster and
   the video, so it can never touch the burned-in type above it.

   Sampled across the whole hero video, one frame a second for all 23 seconds,
   not just the poster: at these values the worst frame carries bone type at
   5.33:1. Do not take --hero-bright above 0.45, which is 4.79:1 and thin. */
.hero__media{
  position:absolute;inset:0;z-index:0;
  background:var(--ink);
  filter:saturate(var(--hero-sat)) brightness(var(--hero-bright));
}
/* `contain`, not `cover`. The section is already the video's own 16:9, so at
   that shape the two are identical; contain is what guarantees no crop if the
   section ever grows taller than 16:9 to fit the burn-in. */
.hero__media video,.hero__media > img{position:absolute;inset:0;width:100%;height:100%;object-fit:contain}
.hero__media > img{z-index:1}
.hero__media video{z-index:2}
.no-js .hero__media video{display:none}

/* The burn-in. Wordmark over the locked headline over the first call to
   action, centred on the picture as one stack. The perspective lives here so
   the wordmark and the headline share a vanishing point when the intro brings
   them towards the screen; without it, GSAP would give each its own. */
.hero__overlay{
  position:relative;z-index:1;
  width:100%;max-width:var(--max-width);
  display:flex;flex-direction:column;align-items:center;
  gap:var(--space-m);
  text-align:center;
  perspective:900px;
}
.hero__mark{
  display:block;height:auto;
  width:clamp(180px,30vw,420px);
}
/* Half the scale it was, on Ed's instruction. This is --step-h1 with both ends
   of the clamp and the vw term halved, so it tracks the same curve across the
   breakpoints rather than becoming a fixed size that stops responding:
   --step-h1 is clamp(2rem, 4.5vw + 0.5rem, 4rem). */
.hero__headline{
  font-size:clamp(1rem,2.25vw + 0.25rem,2rem);
  max-width:34ch;
  color:var(--text-on-ink);
  text-wrap:balance;
}
/* --- The hero on a phone -------------------------------------------------
   THE BUG. The hero is the video's own 16:9. On a landscape window that is a
   big picture; on a 309px portrait window it is a 174px STRIP, and the burn-in
   needs 228px. So the wordmark, the headline and the call to action overflowed
   the picture and landed on the bare ink letterbox below it: the button sat
   15px clear of the video with nothing behind it. The composition the whole
   hero is built around, type burned into footage, simply stopped happening.

   THE FIX, on Ed's instruction that the hero read the same at both widths.
   Below 700px the section stops being 16:9 and takes a portrait height, and
   the video covers it rather than fitting inside it. The burn-in then sits on
   picture the way it does on desktop, which is the structure he asked for.

   THE TRADE, and Ed approved it on 10 September. CLAUDE.md s11 and
   DESIGN_SYSTEM s4 used to say the hero carries the video's own ratio so the
   footage is never cropped; that is a DESKTOP-ONLY rule now, and s11 says so.
   `cover` crops the SIDES on a portrait window, never the bottom, and the
   singer sits centre frame, so the subject survives. The alternative was the
   full frame as a 174px strip with the type falling off it, which is what Ed
   sent back.

   Proportions cannot be identical at both widths and that is geometry, not
   laziness: on desktop the wordmark is 30% of the hero's width and 53% of its
   height, and no single scale holds both when the box turns portrait. These
   values keep the ORDER, the centring and the relative weight of the three
   elements, and size them to sit inside the picture with room around them. */
@media (max-width:700px){
  .hero{
    aspect-ratio:auto;
    /* svh, not vh: vh on iOS is the tallest the viewport ever gets, so the
       bottom of the hero would sit under the browser chrome on first paint.

       THE HEIGHT IS A CROP BUDGET, which is the thing to understand before
       changing it. Covering a portrait box with a 16:9 frame throws away the
       sides, and the taller the hero, the more it throws away: at 513px only
       34% of the frame width survives, at 420px it is 41%, at 340px it is 51%.
       Taller also reads more like the desktop hero, which fills about 88% of
       its screen.

       420px is the settled trade at a 693px viewport, about 61% of the screen.
       The burn-in only needs 174px plus the 69px header, so the height is not
       being spent on fitting the content; it is being spent on presence, and
       every pixel of it costs frame. Compared at 340, 420 and 513: 340 crowds
       the call to action against the bottom edge, 513 loses the saxophone and
       most of the band either side of the singer, 420 keeps them and still
       reads as a hero rather than a banner. */
    min-height:min(60svh,420px);
  }
  .hero__media video,.hero__media > img{object-fit:cover}
  .hero__overlay{gap:var(--space-s)}
  .hero__mark{width:clamp(124px,38vw,190px)}
  .hero__headline{font-size:clamp(12.5px,4vw,16px)}
  /* The hero button only. On desktop it is 15% of the hero's width; at these
     global paddings it came out 53% on a phone, which is what Ed meant by "too
     large". Tightened to sit around a third, while staying above the 44px
     touch minimum: 13px of line plus 2 x 15px of padding is 43px of box plus
     the border, and the inline-block keeps it as wide as its label needs.
     Scoped to .hero__foot so the section buttons and the sticky bar, which are
     sized for thumbs rather than for composition, are untouched. */
  .hero__foot .btn{padding:15px var(--space-s)}
}

/* Kept as .hero__foot because the CTA suppression observer looks the CTA up by
   `.hero__foot [data-cta]`. Renaming it here silently breaks the rule that no
   two coral buttons share a viewport. */
.hero__foot{margin-top:var(--space-2xs)}

/* ==========================================================================
   Testimonials. A single row that drifts on its own. Duplicated once so the
   loop is seamless; the copy is hidden from assistive tech so nothing is
   announced twice.
   ========================================================================== */
/* --- The field -----------------------------------------------------------
   Swapped with the band section on Ed's instruction. This section now carries
   the field the band cards used to, and the band cards carry this section's
   old one.

   The swap forced the copy from ink to bone, and that is not a liberty: the
   field that arrived here is a dark red one. Lifting it far enough to carry
   ink type needs brightness 1.55, and at that setting 77% of its pixels clip a
   channel, which blows the image out and throws away the reason for using a
   picture at all. Darkened for bone type it keeps its colour and clears AA
   comfortably. Measured, both ways, before choosing.

   Filtered, not veiled, for the reason recorded in DESIGN_SYSTEM: a veil lifts
   the blacks and crushes saturation.

   Raised 10 September on the manager's note that the fields were not bright or
   fun enough: saturation 1.7 to 3.0 and brightness 0.40 to 0.46. This is the
   one surface of the three that could take a brightness rise as well as a
   saturation rise, because it started with a very large margin (7.48:1).
   Re-sampled under the real copy at the real section height: the worst point is
   now **5.04:1** for bone, at the 13px attribution.
   **Do not take --testi-bright above 0.46 at this saturation.** 0.48 is 4.71
   and thin. If you lower saturation you buy brightness back, and vice versa;
   the two trade against each other on this field, unlike the videos one.

   No halftone here. The texture treatment stays on the band cards only, per
   Ed's note with the swap. */
/* Self-sufficient: this carries no surface class. Background, text colour and
   focus are all set here. */
.testimonials{
  --testi-sat:1.3;
  --testi-bright:1.0;
  /* Section height cut 30% on 10 September, with everything inside scaled to
     match rather than the padding alone: padding alone could only find 62px of
     the 139 needed, so the type, the quote mark and the controls all take the
     same factor. One number drives all of it — change this and the whole
     section rescales together.

     The two exceptions are the controls, which carry floors. A 30% cut takes
     the dots to 19.6px and the arrows to 30.8px, and WCAG 2.2 wants an
     interactive target of at least 24px. The dots are floored at 24 and the
     arrows at 32, so they shrink visually but stay operable.

     0.74, not 0.70, and that is not a rounding slip. The type rewraps as it
     shrinks, so height does not track the factor linearly: 0.70 overshot to a
     33.6% cut because the longest quote dropped a line. Measured at the real
     section height, 0.74 lands at 322px against 462px, which is the 30% asked
     for. If the copy changes, re-measure rather than trusting the number. */
  --testi-scale:0.74;
  position:relative;
  isolation:isolate;
  /* Mango, not ink. The copy here is ink now, so an ink fallback would render
     the section invisible if the WebP fails. Ink on mango is 8.28:1 and mango
     is the closest flat token to this field's key. */
  background-color:var(--mango);
  color:var(--text-on-bone);
  padding-block:calc(var(--space-l) * var(--testi-scale));
}
.testimonials::before{
  content:"";
  position:absolute;inset:0;z-index:0;
  pointer-events:none;
  background-image:url("../assets/img/testimonials-bg-1600.webp");
  background-size:cover;
  background-position:center;
  filter:saturate(var(--testi-sat)) brightness(var(--testi-bright));
}
@media (max-width:700px){
  .testimonials::before{background-image:url("../assets/img/testimonials-bg-1000.webp")}
}
@media (min-width:900px){.testimonials{padding-block:calc(var(--space-xl) * var(--testi-scale))}}

/* Centred, on Ed's instruction, and with the heading gone there is nothing to
   set the block against on the left any more, so the whole column is centred
   rather than just the type. */
.testimonials > .wrap{
  position:relative;z-index:1;
  display:flex;flex-direction:column;align-items:center;
  text-align:center;
}

/* The controls sit under the quote with the dots now the heading has gone, so
   .quotes__head went with it. */
.quotes__foot{
  display:flex;align-items:center;justify-content:center;
  flex-wrap:wrap;
  gap:calc(var(--space-m) * var(--testi-scale,1));
  margin-top:calc(var(--space-l) * var(--testi-scale,1));
}
/* Scaled with the section but floored at 32px so the target stays operable. */
.testimonials .rail__arrow{
  width:max(32px, calc(44px * var(--testi-scale,1)));
  height:max(32px, calc(44px * var(--testi-scale,1)));
}
/* Scoped to the section, not just the modifier class: the base .rail__arrow
   rule is defined later in this file, so a bare .rail__arrow--bone loses the
   cascade to it on equal specificity. Bone now, not ink: the field beneath is
   dark. */
.testimonials .rail__arrow{color:var(--text-on-bone);border-color:var(--text-on-bone);background:transparent}
.testimonials .rail__arrow:hover:not(:disabled){background:var(--text-on-bone);border-color:var(--text-on-bone);color:var(--bone)}
/* Ink, not mango: the field is mango-keyed now, so a mango ring disappears
   into it. Ink reads against every part of this picture. */
.testimonials :focus-visible{outline-color:var(--text-on-bone)}

/* --- The stage ----------------------------------------------------------
   All three quotes are laid one on top of the other in a single grid cell, so
   the section's height is the tallest quote and nothing jumps when the visitor
   moves between them. Without JavaScript every quote stays visible and the
   stack simply becomes three quotes in a column. */
.quotes{display:grid;justify-items:center}
.js .quotes > .quote{grid-area:1 / 1}
.js .quote{visibility:hidden;opacity:0}
.js .quote.is-current{visibility:visible;opacity:1}
@media (prefers-reduced-motion:no-preference){
  .js .quote{transition:opacity var(--dur-base) var(--ease-out)}
}
.no-js .quote + .quote{margin-top:var(--space-l)}

/* Capitals need a longer line than lower case, not a shorter one: at 42ch the
   longest quote ran to ten lines and read as a wall.

   Widened from 780px to 920px on 10 September when the quote went to Anton.
   That is not a taste change, it is what pays for the display face: Anton is
   condensed, so a longer line holds more characters, and the longest quote
   drops from four lines to three. Three taller lines fit the height four
   shorter ones used, which is how the section keeps its footprint. Narrow the
   measure again and the quote goes back to four lines and the section grows.

   `ch` here is still an Inter measurement: this max-width sits on the
   blockquote, which inherits the body face, while the Anton is on the
   paragraph inside it. Do not move the font-family up to `.quote` or this
   number changes meaning. */
/* No cap below 700px: the wrap is already the constraint there and capping
   again only costs lines. The old 36ch was tuned for 12.9px Inter, and against
   24px Anton it held the column to 364px on a 500px screen and ran the longest
   quote to EIGHT lines. Letting it use the full wrap brings that back to six.
   See the note in .quote__text about what the 24px floor costs on a phone. */
.quote{max-width:100%;margin:0}
@media (min-width:700px){.quote{max-width:min(92ch,920px)}}

/* Bone now the field beneath is a dark one. With the heading gone this mark is
   what opens the section, so it is centred with everything else. */
.quotes__mark{
  color:var(--text-on-bone);
  width:calc(clamp(44px,5vw,60px) * var(--testi-scale,1));
  height:auto;
  margin-bottom:calc(var(--space-s) * var(--testi-scale,1));
}

/* ANTON, from 10 September on Ed's instruction: the quote is now set in the
   same face as the hero tagline and the section headings. It was Inter 500
   capitals, which was a stand-in for exactly this.

   The attribution below is deliberately NOT changed. It stays Inter italic, and
   that contrast is now doing more work than it was: display face against body
   face separates the quote from who said it better than size and slant alone.

   SIZE IS CONSTRAINED FROM BOTH ENDS, which is the whole story of this rule:

   - CLAUDE.md s6 puts a hard floor under Anton at 24px. Below that it is
     "illegible and cheap" and the rule is not mine to bend.
   - Ed asked for the section to keep the footprint it had. The budget is the
     91.2px the longest quote occupied as four lines of 16.28px Inter.

   26px at 1.1 lands the longest quote in three lines at 85.8px, inside that
   budget, and at the 920px measure all three quotes come out three lines, so
   the stage no longer has one quote deciding its height alone. Below about
   1290px the clamp holds at the 24px floor, which still gives three lines.

   --testi-scale is deliberately NOT applied here. That number exists to
   compress the section and Anton has a floor, so multiplying by 0.74 would
   only ever be caught by the floor and would make the "one number scales
   everything" comment above untrue in a way that is hard to spot. The mark,
   the gaps, the controls and the attribution all still take it.

   Anton has a single weight, so font-weight is 400 and there is nothing to
   bolden. Tracking is the display token, not the .012em Inter capitals needed.

   WHAT THIS COSTS ON A PHONE, stated plainly because it is the one place the
   instruction and the brand rule could not both be honoured. Ed asked for the
   same footprint; Anton cannot go below 24px. Above 1000px that is free, three
   lines in the space four took. From 900px down the quote goes to four lines,
   and below 700px it keeps growing: six lines at 500px against the five that
   12.9px Inter used, and ten at 320px. There is no setting that avoids this.
   Ed's answer, 10 September: Anton drops below its floor on a phone. Approved,
   scoped to this element at this breakpoint, and carried in CLAUDE.md s6.

   Re-measure the surface if you change any of this: the block is wider than it
   was, so the type crosses picture it did not cross before. */
.quote__text{
  color:var(--text-on-bone);
  font-family:var(--font-display);
  font-weight:400;
  text-transform:uppercase;
  font-size:clamp(24px,1.86vw,26px);
  line-height:1.1;
  letter-spacing:var(--tracking-display);
}

/* Italic, per the brief. Inter italic is not self-hosted, so this is the
   browser's synthetic slant. Ship fonts/inter-400-italic.woff2 and add a
   font-face for it to make this a real italic. */
/* Full bone, not --text-on-ink-secondary. The secondary tone measures 2.61:1
   over this field and fails outright; there is no dimmer bone available that
   clears, so the attribution is separated by size and italic instead of by
   tone. The years were removed on Ed's instruction, 9 September. */
/* --- The testimonial on a phone -----------------------------------------
   Halved on Ed's instruction, 10 September: at 309px the section ran to 499px,
   which is 72% of the viewport for one quote, and eleven lines of display
   capitals reads as a wall rather than as a quote.

   Two numbers do it together, because the quote alone could not: even at 13px
   the section only reached 295px, since roughly 209px of it is padding, the
   quote mark, the attribution and the controls. So --testi-scale drops from
   0.74 to 0.5, which takes all of those with it, AND the quote drops to 15px.
   Measured: 499px to 263px, six lines rather than eleven.

   *** THIS BREAKS THE 24px ANTON FLOOR IN CLAUDE.md s6, DELIBERATELY, AND ED
   APPROVED IT ON 10 SEPTEMBER. *** That rule calls Anton below 24px "illegible
   and cheap" and it is canonical, so s6 now carries this as a named exception
   rather than the rule simply being broken here. It cannot both hold and give
   the half-height while the quote stays in Anton: the arithmetic above is what
   it is, and Ed asked for both. The floor still holds at every other width and
   on every other element. It was granted because two instructions collided, not
   because 24px is the wrong number, so do not cite it for a second exception.

   The controls keep their own WCAG floors, 24px dots and 32px arrows, so they
   do not shrink with the scale. */
@media (max-width:700px){
  .testimonials{--testi-scale:0.5}
  .quote__text{font-size:15px}
}

.quote__by{
  margin-top:calc(var(--space-m) * var(--testi-scale,1));
  font-style:italic;
  /* Floored at 11px. The scale alone would put this at 9.1px, which is below
     anything worth setting an attribution in. */
  font-size:max(11px, calc(var(--step-small) * var(--testi-scale,1)));
  color:var(--text-on-bone);
  letter-spacing:.02em;
  text-transform:none;
}

/* --- The dots -----------------------------------------------------------
   Real navigation, not decoration: each one selects its quote and reports
   which is current to assistive tech. */
.quotes__dots{display:flex;gap:var(--space-2xs)}
.quotes__dot{
  /* Floored at 24px, the WCAG 2.2 minimum target. The visible dot inside
     shrinks with the section; the hit area does not go below 24. */
  width:max(24px, calc(28px * var(--testi-scale,1)));
  height:max(24px, calc(28px * var(--testi-scale,1)));
  padding:0;
  display:inline-flex;align-items:center;justify-content:center;
  background:none;border:0;cursor:pointer;border-radius:var(--radius);
}
.quotes__dot::before{
  content:"";
  width:calc(9px * var(--testi-scale,1));height:calc(9px * var(--testi-scale,1));
  border-radius:50%;
  border:1px solid var(--text-on-bone);
  transition:background-color var(--dur-quick) var(--ease-in-out),border-color var(--dur-quick) var(--ease-in-out);
}
.quotes__dot[aria-current="true"]::before{background:var(--text-on-bone);border-color:var(--text-on-bone)}
@media (prefers-reduced-motion:reduce){.quotes__dot::before{transition:none}}

/* ==========================================================================
   The band
   ========================================================================== */
/* A bone field, on Ed's instruction. The background still and the ink scrim
   that used to sit under these cards are both gone, so there is no photography
   in this section at all now and nothing is competing with the type. */
.band{padding-block:var(--space-l)}
@media (min-width:900px){.band{padding-block:var(--space-xl)}}
.band__grid{display:grid;gap:var(--space-m)}
@media (min-width:1000px){
  /* Both columns start and finish together, so the block reads as one object. */
  .band__grid{grid-template-columns:minmax(0,1fr) clamp(248px,24vw,308px);gap:var(--space-m);align-items:stretch}
}
@media (min-width:1280px){.band__grid{gap:var(--space-l)}}
.band__main{display:grid;gap:var(--space-m);align-content:start}

/* --- The copy card -------------------------------------------------------
   A dark card on the bone field, with the heading and the copy both in mango,
   on Ed's instruction. Mango on ink is 8.28:1, so it clears AA as body text and
   not only as a heading, which is what makes setting the whole block in it
   defensible. Fill only: no border, no radius, no shadow. */
.plane{
  background:var(--ink);
  color:var(--mango);
  border-radius:0;
  padding:var(--space-m);
  /* The copy is the whole left column, so it centres in whatever height the
     row takes. */
  display:flex;flex-direction:column;justify-content:center;
}
@media (min-width:700px){.plane{padding:var(--space-l) var(--space-l) var(--space-m)}}

.plane h2{font-size:var(--step-h2);margin-bottom:var(--space-s);color:var(--mango)}

/* One size for the whole block, on Ed's instruction: the type does not change
   part way down. The hierarchy is carried by the heading above it instead of
   by a step between the opening paragraph and the rest. */
/* Bold capitals from 10 September, on Ed's instruction, at both widths.
   "Bold" is Inter 500: CLAUDE.md s6 allows two weights only, 400 and 500, and
   never 600 or 700, so 500 is the heaviest this site owns.

   Capitals need a longer line than lower case, so the measure opens from 62ch
   to 76ch to stop the block running to a wall of lines. The copy itself is
   untouched: this is text-transform, not a rewrite, and the About copy is
   locked in CLAUDE.md s12. */
.plane p{
  font-size:clamp(1rem,0.4vw + 0.9rem,1.125rem);
  font-weight:500;
  text-transform:uppercase;
  letter-spacing:.012em;
  line-height:1.5;
  max-width:76ch;
}
.plane p + p{margin-top:var(--space-s)}

.plane__cta{margin-top:var(--space-m)}

/* --- The shared colour field ---------------------------------------------
   The band cards carry the same generated image as the videos section, on Ed's
   instruction, replacing the azure-to-palm gradient that was here before.

   It still runs continuously across both cards: each paints the image sized to
   the whole grid and offsets its own paint by its position within that grid, so
   the two read as one sheet with a gutter cut out of it rather than as two
   cards showing the same picture twice. The offsets are measured in
   `casa.js` because the columns are fluid.

   Darkened by filter, not by veil, for the reason recorded in DESIGN_SYSTEM:
   a veil desaturates as it darkens. That means the image has to live on a
   pseudo element, or the filter would take the copy with it. So there are two
   layers here now: ::before is the picture, ::after is the halftone.

   The picture here was swapped with the testimonials section on Ed's
   instruction, 9 September, and both controls were then raised 25% because it
   read as too muted: saturation 1.7 to 2.125, brightness 0.36 to 0.45.

   Raised again 10 September, on the manager's note that the fields were not
   bright or fun enough: **saturation 2.125 to 4.5**, brightness 0.45 to 0.43.
   That is the largest saturation rise on the page and it is where he wanted the
   most colour. The two points of brightness pay for it: on this field, unlike
   the videos one, saturation does cost contrast, though not much (2.125 to 4.5
   at fixed brightness costs about 0.27). Nothing clips even at 4.5.

   Re-sampled under the real copy at the real section height: worst point
   **5.11:1** on the card and 6.27:1 in the Instagram column, both bone.
   **Do not go above --band-bright 0.45 at this saturation** (4.80 and thinning);
   0.47 is 4.47 and under. The old 0.48 ceiling was measured at saturation
   2.125 and no longer applies now that saturation is 4.5.

   Note both selectors below share these two variables, so the card and the
   Instagram column always move together. That is deliberate: they read as one
   continuous sheet, and splitting the controls would show a seam at the gutter.

   The halftone stays on these two cards and nowhere else. It is the only
   texture treatment on the page beyond the site-wide film grain, and it did
   not travel with the picture when the fields were swapped.

   Saturation is unaffected by any of this: measured across the range it held
   at 0.91 throughout. */
.band__grid{--band-w:100%;--band-h:100%}

.plane,.band .ig{
  /* Undarkened from 10 September, round 16. Same treatment as the testimonials
     section: a saturation nudge and no brightness reduction at all, with the
     copy in ink. Mango fallback, not ink, or the cards go invisible without the
     WebP. */
  --band-sat:1.3;
  --band-bright:1.0;
  /* Zoom 1 and no offset: the whole picture spans the two cards exactly, with
     the gutter cut out of it, on the manager's instruction 10 September. He
     wants to see all of it rather than a crop.

     That is why these cards use `field-03-lift` and not `field-03`. The
     picture is coral led, ink on coral is 4.85:1 flat, and at full extent the
     deep red through the middle of the frame sits straight under the long
     About block: 3.56:1 on seven of its eleven lines.

     Cropping fixed it before but hides most of the picture, which is the thing
     he asked to stop. A CSS filter cannot fix it either — brightness enough to
     clear AA clips 93% of the image, and contrast() below 1 makes it WORSE,
     because it pulls the bright areas down toward mid grey faster than it
     lifts the dark ones. Measured, both.

     So the lift is baked into the asset instead: the same picture with a gamma
     2.2 curve, which raises the shadows without touching hue and without
     clipping anything. It reads as a lighter print of the same frame. That
     takes the card to 4.81:1 and the Instagram column to 5.58:1 with the whole
     image on show.

     If the picture is ever replaced, regenerate the lift with it — a raw
     field-03 dropped in here fails immediately. */
  --band-zoom:1;
  --band-ox:0px;
  --band-oy:0px;
  position:relative;
  isolation:isolate;
  background-color:var(--mango);
}
.plane > *,.band .ig > *{position:relative;z-index:2}

/* The picture. Sized to the grid, offset per card, so the two are continuous. */
/* Image 17, chosen by the manager 10 September.

   --band-zoom and --band-ox are a MEASURED crop, not a preference. At zoom 1
   and no offset this picture put a dark passage under the About copy and ink
   measured 4.11:1 against the 4.5 that 18px needs, on three separate lines.
   A 5% zoom with the sheet nudged 50px left takes the worst point to 5.16:1.

   Both cards take the SAME zoom and the SAME global offset, which is what keeps
   the two reading as one continuous sheet with the gutter cut out of it. The
   per-card offsets stay the grid positions the JavaScript measures. Change one
   and you must change both, and then re-measure. */
.plane::before,.band .ig::before{
  content:"";
  position:absolute;inset:0;z-index:0;
  pointer-events:none;
  background-image:url("../assets/img/field-03-lift-1600.webp");
  background-size:calc(var(--band-w) * var(--band-zoom)) calc(var(--band-h) * var(--band-zoom));
  background-repeat:no-repeat;
  background-position:calc(var(--card-x,0px) * -1 + var(--band-ox)) calc(var(--card-y,0px) * -1 + var(--band-oy));
  filter:saturate(var(--band-sat)) brightness(var(--band-bright));
}
@media (max-width:700px){
  .plane::before,.band .ig::before{background-image:url("../assets/img/field-03-lift-1000.webp")}
}

/* The halftone was REMOVED on 10 September. The manager called the dots out by
   name: "currently has dots all over it". It was the only texture treatment on
   the page beyond the site-wide film grain, and Ed had previously confirmed it
   as band-cards-only, so this reverses a decision of his — flagged in HANDOFF.

   The rule is kept, commented, because the reason it existed is still real: a
   wide smooth colour field bands on an 8 bit display, and the halftone was
   breaking that up. If banding shows on the new field, this is the fix, at a
   lower opacity and a finer cell rather than not at all.

.plane::after,.band .ig::after{
  content:"";
  position:absolute;inset:0;z-index:1;
  pointer-events:none;
  opacity:var(--halftone);
  background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='6' height='6'%3E%3Ccircle cx='1.5' cy='1.5' r='1.15' fill='%23F2EAD9'/%3E%3C/svg%3E");
  background-size:6px 6px;
}
*/

/* The field is undarkened now, so the copy is INK. The call to action stays
   coral: its label is ink on coral at 4.85:1 and it is the only coral on the
   section. The glyph went ink too — mango on a mango-keyed field disappears. */
.plane{color:var(--text-on-bone)}
.plane h2{color:var(--text-on-bone)}
.band .ig{color:var(--text-on-bone)}
.band .ig__title{color:var(--text-on-bone)}
.band .ig__glyph{color:var(--text-on-bone)}
.band .ig__title:hover .ig__glyph,.band .ig__title:focus-visible .ig__glyph{color:var(--coral)}
.band .ig__loading{color:var(--text-on-bone)}

/* Both cards are bright fields now, so focus rings are ink. Mango on mango is
   invisible. */
.plane :focus-visible,.band .ig :focus-visible{outline-color:var(--text-on-bone)}

/* --- Instagram column ---------------------------------------------------- */
.ig{
  background:var(--mango);
  border:var(--frame-weight-s) solid var(--azure);
  border-radius:var(--radius);
  padding:var(--space-s) var(--space-s) var(--space-s);
  display:flex;flex-direction:column;gap:var(--space-s);
  min-height:0;
}
/* Centred and set at the h2 size on the manager's instruction, 10 September,
   so it matches "The band" across the gutter rather than sitting under it as a
   small label. */
.ig__head{flex:0 0 auto;text-align:center}
.ig__head h3{margin-bottom:var(--space-3xs)}

/* Glyph plus title, on Ed's instruction, replacing the handle and the
   "latest from Instagram" line. The whole thing stays one link, so the route to
   the profile that the handle used to provide is not lost with the text. */
.ig__title{
  /* Wraps rather than forcing one line. At the h2 size "From the gram" does not
     fit the Instagram column on a single line, and a glyph floating beside two
     wrapped lines reads like a mistake. Wrapping drops the glyph onto its own
     centred line above the words, which reads as a deliberate stack — and on a
     wide enough column the two still sit on one line as intended. */
  display:flex;flex-wrap:wrap;align-items:center;gap:var(--space-2xs);
  text-decoration:none;
  color:var(--text-on-ink);
  font-family:var(--font-display);
  text-transform:uppercase;
  /* The same token "The band" uses, so the two headings match. At this size the
     run counts as large text, so its floor drops from 4.5:1 to 3.0:1. */
  font-size:var(--step-h2);
  letter-spacing:var(--tracking-display);
  line-height:1.1;
  justify-content:center;
}
.ig__glyph{
  color:var(--mango);
  width:0.82em;height:0.82em;flex:0 0 auto;
  transition:color var(--dur-quick) var(--ease-in-out);
}
.ig__title:hover .ig__glyph,.ig__title:focus-visible .ig__glyph{color:var(--text-on-ink)}
@media (prefers-reduced-motion:reduce){.ig__glyph{transition:none}}
.ig__loading{font-size:var(--step-small);color:var(--text-on-ink-secondary)}

/* Two saved stills, not a live embed: see casa.js for why. Two posts
   visible, the column creeping upwards on desktop, paused on hover or focus.
   Two, not three. With the line-up panels gone the copy alone drives the row
   height, and dividing that by three left each post about 149px tall in a
   272px column, so every square Instagram photo was cropped to a letterbox.
   Two posts come out close to square and the crop stops eating faces.
   Below 1000px the column sits under the copy and sizes itself from the
   viewport; above it, the post height is measured from whatever room the copy
   leaves, so the two columns finish level. */
.ig__viewport{
  --ig-gap:var(--space-2xs);
  --ig-post:clamp(150px,44vw,240px);
  position:relative;
  height:calc((var(--ig-post) * 2) + var(--ig-gap));
  overflow:hidden;
}
@media (min-width:1000px){
  /* A zero basis, not auto. On auto the viewport's own content counts towards
     the column's height, the column then drives the grid row, and measuring the
     room to size the posts grows the row again on every pass. */
  .ig__viewport{flex:1 1 0;height:auto;min-height:0}
}
.ig__track{display:flex;flex-direction:column;gap:var(--ig-gap)}
.ig__track.is-running{animation:ig-rise var(--ig-duration,48s) linear infinite}
.ig:hover .ig__track.is-running,
.ig:focus-within .ig__track.is-running{animation-play-state:paused}
@keyframes ig-rise{
  from{transform:translate3d(0,0,0)}
  to{transform:translate3d(0,calc(var(--ig-shift, 0px) * -1),0)}
}
@media (prefers-reduced-motion:reduce){
  .ig__viewport{overflow-y:auto}
  .ig__track.is-running{animation:none}
}
.ig__post{display:block;position:relative;height:var(--ig-post);border-radius:var(--radius);overflow:hidden;flex:0 0 auto}
.ig__post img{width:100%;height:100%;object-fit:cover}
.ig__post::after{
  content:"";position:absolute;inset:0;
  border:2px solid transparent;border-radius:var(--radius);
  transition:border-color var(--dur-quick) var(--ease-in-out);
}
.ig__post:hover::after,.ig__post:focus-visible::after{border-color:var(--mango)}
.ig__chip{
  position:absolute;right:var(--space-2xs);bottom:var(--space-2xs);
  display:inline-flex;align-items:center;justify-content:center;
  width:26px;height:26px;
  background:var(--mango);color:var(--on-mango);
  border-radius:var(--radius);
}
@media (prefers-reduced-motion:reduce){.ig__post::after{transition:none}}

/* Below 700px this stops being a narrower version of the vertical marquee
   above and becomes a different component: one full-scale post per screen,
   in a single row, auto-advanced by casa.js and swiped right to left.
   Nothing here sizes a post but its own image, so nothing can crop it. */
@media (max-width:700px){
  .ig__viewport{
    height:auto;
    overflow-x:auto;
    overflow-y:visible;
    -webkit-overflow-scrolling:touch;
    scroll-snap-type:x mandatory;
    scrollbar-width:none;
  }
  .ig__viewport::-webkit-scrollbar{display:none}
  .ig__track{flex-direction:row;gap:0}
  .ig__track.is-running{animation:none}
  .ig__post{
    flex:0 0 100%;
    width:100%;height:auto;
    aspect-ratio:1/1;
    scroll-snap-align:start;
  }
  .ig__post img{width:100%;height:100%;object-fit:contain}
}

/* ==========================================================================
   Video gallery
   ========================================================================== */
/* The section default is 96px, and 144px above 900px, which left a lot of empty
   bone above the heading and below the buttons. This section carries a heading,
   a rail and two buttons and does not need that much room around it.

   The hairline is the same device used at the top of the footer: the band
   section went bone too, so this is 1300px of continuous bone with no boundary
   otherwise, and the two sections read as one slab. */
/* --- The field ----------------------------------------------------------
   An Artlist-generated ambient colour field, on Ed's instruction. This is the
   first and only AI-generated asset on the site; CLAUDE.md bans "AI-generated
   imagery" and s10 now records the exemption for abstract, non-representational
   work.

   THE IMPORTANT BIT, because the first attempt got this wrong: the image is
   darkened with a filter, NOT with a black veil over it. A black veil
   desaturates as it darkens, so the first pass came out grey and cosmic and Ed
   rightly rejected it. `saturate()` up plus `brightness()` down darkens it for
   legibility while pushing the colour the other way. Same source image, and
   measured saturation went from 0.62 to 0.96.

   --videos-sat and --videos-bright are the two controls, and the useful fact
   about them is that **brightness does not cost saturation**. Measured across
   the whole range, saturation held at 0.91 whether brightness was 0.62 or 0.29,
   because saturate() is a ratio and survives the multiply. The black veil in
   the first attempt did the opposite: it lifted the blacks and crushed measured
   saturation to 0.34. So buying contrast here is close to free.

   On THIS field the reverse is also true: **saturation costs no contrast at
   all.** Swept 1.7 to 8.0 at fixed brightness and the worst text point moved by
   0.03. The pixel that sets the floor is a near-neutral highlight, and
   saturate() barely moves a neutral, so saturation here is genuinely free and
   brightness is the only lever that costs anything. That is not true of the
   testimonials or band fields; do not generalise it.

   Raised 10 September on the manager's note, to saturation 5.0 with brightness
   down 0.44 to 0.40.

   **The brightness drop was not a preference, it was a fix.** At 1.7/0.44 this
   section was FAILING AA and had been since the field was added: sampled under
   the real text at the real section height, a 13px video title measured 4.24:1
   and a 12px duration 4.26:1, both against the 4.5 small text needs. The token
   checker cannot see it because the surface is an image, so nothing caught it.
   At 5.0/0.40 the worst point is **4.93:1** and all ten text runs pass.

   **Do not take --videos-bright above 0.42** (4.57 and thin); 0.44 is where it
   fails again. Saturation you may raise freely.

   WebP only, with the ink fill as the fallback. A browser without WebP gets a
   flat ink section, which degrades gracefully and saves carrying a 200KB JPEG.

   The image sits on a pseudo element so the filter cannot touch the content.
   The hairline that used to be here is gone: the section above is a bone field,
   so this is its own boundary now. */
.gallery{
  /* Undarkened from 10 September, round 16, carrying the same field as the
     testimonials section. Copy is ink. Mango fallback, not ink: the section
     still carries .s-ink in the markup and this rule comes later, so it wins
     for both background and colour. */
  --videos-sat:1.3;
  --videos-bright:1.0;
  position:relative;
  isolation:isolate;
  padding-block:var(--space-l);
  background-color:var(--mango);
  color:var(--text-on-bone);
}
.gallery::before{
  content:"";
  position:absolute;inset:0;z-index:0;
  pointer-events:none;
  background-image:url("../assets/img/field-02-1600.webp");
  /* NOT cover, and this is load bearing. This section is a short wide strip,
     so `cover` cuts a horizontal band straight through the middle of the
     picture, and on this field that band puts the blue passage under the first
     tile title. Ink on it measured 3.52:1 against the 4.5 that 13px needs, and
     no background-position, mirror or brightness lift fixes it: brightness
     enough to clear it clips 98% of the image.

     Image 18, chosen by the manager 10 September, is coral led, and ink on
     coral is only 4.85:1 flat — 0.35 over threshold before any shading at all.
     The 13px tile titles and durations could not clear AA on it: 3.71:1 at
     `cover`, and the only crops that held across viewport widths were 200% and
     above, which throws away the blue, green and magenta and shows a quarter
     of the picture.

     Solved instead by giving the tile meta its own ground — see .tile__meta,
     which now reuses the ink chip already on .tile__play. That leaves the 64px
     heading as the only type sitting directly on this field, and a 64px run
     needs 3.0:1, which it clears at 4.06 and 4.17 at the two widths measured.

     So the picture can be shown whole, at plain cover, which is the point of
     having chosen it. */
  background-size:cover;
  background-position:center;
  filter:saturate(var(--videos-sat)) brightness(var(--videos-bright));
}
@media (max-width:700px){
  .gallery::before{background-image:url("../assets/img/field-02-1000.webp")}
}
.gallery > .wrap{position:relative;z-index:1}

/* An outline button on a bright colour field measured 2.67:1 and was the worst
   point in the section. It gets a solid ink fill here, so the label sits on its
   own ground at 14.93:1 and the control reads as a control. */
.gallery .btn--outline{background:var(--ink);border-color:var(--ink);color:var(--text-on-ink)}
.gallery .btn--outline:hover{background:var(--text-on-ink);border-color:var(--text-on-ink);color:var(--ink)}

/* Same reasoning for the rail arrows, which sit over the top of the field. */
.gallery .rail__arrow{background:var(--ink);border-color:var(--ink)}
.gallery .rail__arrow:hover:not(:disabled){background:var(--text-on-ink);border-color:var(--text-on-ink);color:var(--ink)}

.gallery__head{
  display:flex;flex-wrap:wrap;align-items:flex-end;justify-content:space-between;
  gap:var(--space-m);
  margin-bottom:var(--space-l);
}

/* --- The rail -----------------------------------------------------------
   One row, scrolled sideways. Native overflow scrolling does the work, so it
   keeps a real scrollbar, trackpad and touch swipes, and arrow keys when the
   rail has focus. The buttons are an addition on top, not the only way in. */
.rail{
  overflow-x:auto;
  overscroll-behavior-x:contain;
  scroll-snap-type:x mandatory;
  /* Smoothing is left to the arrow handler, which already drops to an instant
     jump under reduced motion. Declaring it here as well would smooth the
     scrollbar and keyboard too, and override that choice. */
  scrollbar-width:thin;
  scrollbar-color:var(--text-on-ink-secondary) transparent;
  /* Bleed the rail to the window edges so tiles run off the side rather than
     stopping short of it, while the first tile still lines up with the type. */
  margin-inline:calc(var(--gutter) * -1);
  padding-inline:var(--gutter);
  /* Snap against the gutter, not the raw scrollport, or the first tile pulls
     itself out of alignment with the heading above it. */
  scroll-padding-inline:var(--gutter);
  padding-bottom:var(--space-s);
}
.rail:focus-visible{outline:2px solid var(--mango);outline-offset:4px}
.rail__track{display:flex;gap:var(--space-l);width:max-content}
.rail .tile{flex:0 0 clamp(240px,72vw,460px);scroll-snap-align:start}
@media (min-width:900px){.rail .tile{flex-basis:460px}}

.rail::-webkit-scrollbar{height:6px}
.rail::-webkit-scrollbar-track{background:transparent}
.rail::-webkit-scrollbar-thumb{background:var(--text-on-ink-secondary);border-radius:var(--radius)}

.rail__controls{display:flex;gap:var(--space-2xs);flex:0 0 auto}
.rail__arrow{
  display:inline-flex;align-items:center;justify-content:center;
  width:44px;height:44px;
  background:transparent;color:var(--text-on-ink);
  border:1px solid var(--text-on-ink);border-radius:var(--radius);
  cursor:pointer;
  transition:background-color var(--dur-quick) var(--ease-in-out),color var(--dur-quick) var(--ease-in-out);
}
/* Bone fill on hover, never coral. A coral arrow would read as a second call to
   action and DESIGN_SYSTEM s6 allows only one on a viewport. */
.rail__arrow:hover:not(:disabled){background:var(--text-on-ink);border-color:var(--text-on-ink);color:var(--ink)}
.rail__arrow:disabled{opacity:.35;cursor:default}
@media (prefers-reduced-motion:reduce){.rail__arrow{transition:none}}

.tile{position:relative;margin:0}
.tile__button{
  display:block;width:100%;padding:0;border:0;background:none;cursor:pointer;
  text-align:left;
}
/* No frame. The manager asked for the mango border off these, so the poster is
   the object itself: a clean 16:9 plate on bone, with the ink ground showing
   only through the picture. The 16:9 sits on this element, not on a border box,
   for the reason recorded in HANDOFF s6. */
.tile__media{
  aspect-ratio:16/9;position:relative;overflow:hidden;
  border-radius:var(--radius);
  background:var(--ink);
}
.tile__poster{position:absolute;inset:0;overflow:hidden}
.tile__poster img{width:100%;height:100%;object-fit:cover;transition:transform var(--dur-base) var(--ease-out)}
.tile__button:hover .tile__poster img,.tile__button:focus-visible .tile__poster img{transform:scale(1.03)}
@media (prefers-reduced-motion:reduce){
  .tile__poster img{transition:none}
  .tile__button:hover .tile__poster img{transform:none}
}
.tile__play{
  position:absolute;left:var(--space-s);bottom:var(--space-s);
  display:inline-flex;align-items:center;
  background:var(--ink);color:var(--text-on-ink);
  font-weight:500;font-size:var(--step-micro);letter-spacing:.06em;
  padding:var(--space-2xs) var(--space-xs);border-radius:var(--radius);
}
/* The meta row carries its own ink ground, added 10 September. The 13px title
   and duration cannot clear AA in ink on the coral-led field behind them —
   3.71:1 — and the crops that would fix it destroy the picture. On their own
   ink strip they measure 14.93:1 and the field can be shown whole.

   This reuses the chip already on .tile__play rather than inventing a device:
   same ink, same radius, so the tile reads as one object. */
.tile__meta{
  display:flex;justify-content:space-between;gap:var(--space-s);
  margin-top:var(--space-s);
  background:var(--ink);
  border-radius:var(--radius);
  padding:var(--space-2xs) var(--space-xs);
}
.tile__title{font-weight:500;font-size:var(--step-small);color:var(--text-on-ink)}
/* Bone, and never the secondary token: over a colour field the secondary greys
   fail in both directions. Full ink or full bone, nothing in between. */
.tile__dur{font-size:var(--step-small);color:var(--text-on-ink);font-variant-numeric:tabular-nums}
.tile iframe{position:absolute;inset:0;width:100%;height:100%;border:0}

.gallery__foot{
  margin-top:var(--space-l);
  display:flex;flex-wrap:wrap;align-items:center;gap:var(--space-m) var(--space-l);
  justify-content:space-between;
}

/* ==========================================================================
   Contact
   ========================================================================== */
/* Inverted to ink on Ed's instruction. The videos section above it is bone, so
   the surface change is its own boundary and the hairline that used to mark the
   seam is gone with it.

   Everything inside had to flip with the background, not just the fill. What
   `.s-ink` already handles: links, outline buttons, focus colour, muted text.
   What it does not, and is set below: the field chrome, the hints and errors,
   the facts row, and the required mark.

   `color-scheme` is the one that is easy to miss. Without it the native date
   picker, the select menu and the field scrollbars keep rendering their light
   internals, so the calendar icon comes out near black on a near black field. */
/* --- The knockout --------------------------------------------------------
   On the manager's instruction, 10 September: the section keeps its ink
   ground, and every piece of content is cut out of the videos field so the
   picture shows through the type and the boxes.

   The image has to read as ONE sheet behind the whole section, not as a copy
   per element, so each element paints the picture sized to the section and
   offsets its own paint by its position within it. That is the same technique
   the band cards use for their shared sheet, generalised: casa.js writes
   --ko-w/--ko-h on the section and --ko-x/--ko-y on every knocked-out element.

   background-attachment:fixed would have been one line instead of all this,
   and was rejected: iOS Safari ignores it, which would silently drop the
   effect on a large share of visitors.

   Not knocked out, deliberately:
   - the error messages, which stay solid bone. An error the visitor cannot
     read is worse than an error that breaks the effect.
   - the submit button, which is the section's coral call to action.
   Both are noted in HANDOFF. */
.contact-section{
  --ko-img:url("../assets/img/field-02-1600.webp");
}
/* The crop is measured, not chosen. Painted at the section's own width the
   picture puts its dark blue and green corners under the intro copy and the
   optional hint, and knocked-out text over ink needs the pixel behind it to be
   BRIGHT: at 4.5:1 against ink the image luminance has to clear 0.209. Two runs
   measured 2.16:1 that way.

   Doubling the picture and pushing it right samples the bright upper band
   instead and takes the worst run to 7.76:1. A grid search over zoom and
   position found nothing usable below 180%. casa.js writes --ko-ox from these
   two, because the offset depends on the section width. */
@media (max-width:700px){
  .contact-section{--ko-img:url("../assets/img/field-02-1000.webp")}
}
.contact-section .ko{
  background-image:var(--ko-img);
  background-size:var(--ko-w,100%) auto;
  background-position:
    calc(var(--ko-x,0px) * -1 + var(--ko-ox,0px))
    calc(var(--ko-y,0px) * -1 + var(--ko-oy,0px));
  background-repeat:no-repeat;
  -webkit-background-clip:text;
  background-clip:text;
  color:transparent;
}
/* The required asterisk rides with its label rather than staying mango. */
.contact-section .ko .req{color:inherit}

/* Without background-clip:text the copy would be invisible, so the whole
   effect stands down and the section renders as it did before. */
@supports not ((-webkit-background-clip:text) or (background-clip:text)){
  .contact-section .ko{background-image:none;color:var(--text-on-ink)}
  .contact-section .ko.contact__intro,.contact-section .ko .hint{color:var(--text-on-ink-secondary)}
  .contact-section .ko .req{color:var(--mango)}
}

/* The boxes. An input cannot carry a pseudo element, and masking the input
   itself would mask the text the visitor types into it, so the ring is drawn
   on the field wrapper and positioned over the control from a measurement.
   --ctl-t and --ctl-h come from casa.js. */
.contact-section .field{position:relative}
.contact-section .field.has-ring input,
.contact-section .field.has-ring select,
.contact-section .field.has-ring textarea{border-color:transparent}
.contact-section .field.has-ring::after{
  content:"";
  position:absolute;left:0;right:0;
  top:var(--ctl-t,0px);height:var(--ctl-h,0px);
  border-radius:var(--radius);
  pointer-events:none;
  padding:1px;
  background-image:var(--ko-img);
  background-size:var(--ko-w,100%) auto;
  background-position:
    calc(var(--ko-x,0px) * -1 + var(--ko-ox,0px))
    calc((var(--ko-y,0px) + var(--ctl-t,0px)) * -1 + var(--ko-oy,0px));
  background-repeat:no-repeat;
  -webkit-mask:linear-gradient(#fff 0 0) content-box,linear-gradient(#fff 0 0);
  -webkit-mask-composite:destination-out;
  mask:linear-gradient(#fff 0 0) content-box,linear-gradient(#fff 0 0);
  mask-composite:exclude;
}
/* An invalid field takes its coral border back: the ring is decoration, the
   coral is information, and information wins. */
.contact-section .field.is-invalid.has-ring::after{display:none}
.contact-section .field.is-invalid.has-ring input,
.contact-section .field.is-invalid.has-ring select,
.contact-section .field.is-invalid.has-ring textarea{border-color:var(--coral)}

/* --- The knockout on a phone: stacked copies, not one sheet ---------------
   THE BUG THIS FIXES. Above, the picture is painted at twice the section width
   and every element offsets its own paint by its position, so the whole
   section reads as one continuous sheet. The picture's height is therefore
   `sectionWidth * 2 * 0.562`, which is about 1.12 times the section width, and
   `background-repeat` is `no-repeat`.

   On a phone the contact section is roughly three times taller than it is
   wide. At 309px that is a 347px picture inside a 983px section, so every
   element more than 347px down had NO IMAGE to be cut from. `.ko` sets
   `color:transparent`, so those elements rendered as nothing at all: four
   required labels, two hints and the mailto fallback were invisible, and three
   inputs had no visible border either. Found 10 September, the first time the
   form was seen at a phone width.

   THE FIX, on Ed's instruction: below 700px the sheet illusion is abandoned
   and each element paints its own copy of the picture, stacked one above the
   other. The illusion was never visible on a phone anyway, because the
   elements are stacked in a single column and there is nothing beside them for
   the sheet to line up with.

   `cover`, NOT `100% auto`, and that is the second bug rather than a
   preference. `100% auto` spans the picture across the element and lets its
   height fall where it will, which is fine for a one-line label but leaves a
   transparent band top and bottom on any element TALLER than its own width
   times 0.562 - the intro paragraph and the message hint are both close to
   that line and would lose their first and last rows as the copy grows.
   `cover` guarantees the element is filled whichever way its aspect runs, so
   the failure cannot come back.

   THE CROP IS MEASURED, exactly as the desktop one is. `cover` alone was not
   enough: it scales to the element, so a tall element like the intro paragraph
   samples a tall slice of the picture and reaches into its blue and green
   passages. Measured that way the intro came out at 2.33:1, the optional hint
   3.28 and the mailto fallback 3.26, against the 4.5 that knocked-out body
   type needs. The one-line labels all passed at 4.65, because they only ever
   see the middle.

   So the picture is zoomed instead. A row-by-row scan of `field-02-1000` finds
   its tallest fully-safe band, where EVERY pixel clears 4.5:1 against ink, at
   rows 100 to 339 of 562. At 400% the painted picture is roughly 605px tall
   behind a 269px column, so even the tallest element here samples under 100
   source rows and cannot leave that band.

   48.5% is then the best position WITHIN it, not the first that passed. A
   search over every window position for the tallest element found 39% giving
   4.54:1, which clears 4.5 by 0.04 and is no margin at all, against 48.5%
   giving 7.49:1 for the same element and 8.18:1 for the one-line labels. Same
   picture, same zoom, a different nine percent down it.

   Everything shows the same soft patch of colour rather than a recognisable
   crop, which is the right result at this size: it reads as a warm wash
   through the type instead of a busy picture fighting 13px labels.

   If the picture is ever replaced, re-run that scan. The band is a property of
   this image, not a general safe value. */
@media (max-width:700px){
  .contact-section .ko,
  .contact-section .field.has-ring::after{
    background-size:400% auto;
    background-position:50% 48.5%;
  }
}

.contact-section{color-scheme:dark}
.contact-section{padding-block:var(--space-l) var(--space-m)}
@media (min-width:900px){.contact-section{padding-block:var(--space-xl) var(--space-l)}}
/* One centred column, stacked. The section was close to a thousand pixels tall
   with the form on the left and a mostly empty aside column on the right; this
   is the same six fields and the same facts at roughly half the height. */
.contact{
  display:grid;
  gap:var(--space-m);
  max-width:720px;
  margin-inline:auto;
}

/* The heading and its line are centred. The fields are not: centred labels
   above left-aligned inputs make a form measurably slower to fill in, so the
   labels stay left where the eye already is. */
.contact__head{text-align:center}
.contact h2{margin-bottom:var(--space-xs)}
.contact__intro{
  color:var(--text-on-ink-secondary);
  max-width:64ch;
  margin-inline:auto;
}

/* Six columns so the five short fields sit on two rows rather than three. That
   one recovered row is most of the height this section gives back. */
.form{display:grid;gap:var(--space-s)}
@media (min-width:640px){
  .form{grid-template-columns:repeat(6,1fr);gap:var(--space-s) var(--space-m)}
  .field{grid-column:span 6}
  .field--half{grid-column:span 3}
  .field--third{grid-column:span 2}
  .field--wide{grid-column:1 / -1}
}
.field{display:flex;flex-direction:column;gap:var(--space-2xs);min-width:0}
.field label{font-weight:500;font-size:var(--step-small);letter-spacing:.02em}
/* Magenta is 2.00:1 on ink, so the required mark cannot stay magenta once the
   section inverts. Mango is 8.28:1 and is otherwise idle now that the videos
   moved to bone. This is a token swap forced by contrast, not a palette change,
   but Ed should see it. */
.field .req{color:var(--mango);font-weight:500}
.field input,.field select,.field textarea{
  width:100%;
  background:transparent;
  color:var(--text-on-ink);
  border:1px solid var(--text-on-ink-secondary);
  border-radius:var(--radius);
  padding:9px var(--space-xs);
  font-size:var(--step-body);
  line-height:1.4;
  transition:border-color var(--dur-quick) var(--ease-in-out);
}
.field textarea{resize:vertical;min-height:4rem;line-height:1.5}
.field select{appearance:none;background-image:none;padding-right:var(--space-m)}
/* Mango, matching every other focus ring on an ink surface. Coral would still
   clear 3:1 here but it is the call to action colour and a coral ring beside a
   coral button reads as two of the same thing. */
.field input:focus-visible,.field select:focus-visible,.field textarea:focus-visible{
  outline:2px solid var(--mango);outline-offset:1px;border-color:var(--mango)
}
.field .hint{font-size:var(--step-micro);color:var(--text-on-ink-secondary)}
.field .error{font-size:var(--step-micro);color:var(--text-on-ink);font-weight:500;display:none}
.field.is-invalid input,.field.is-invalid select,.field.is-invalid textarea{border-color:var(--coral);border-width:2px}
.field.is-invalid .error{display:block}
/* Stacked, not a row. Centring the button and the fallback line together as a
   pair leaves the button itself sitting left of the page's centre line. */
.form__actions{
  grid-column:1 / -1;
  display:flex;flex-direction:column;align-items:center;text-align:center;
  gap:var(--space-xs);
  margin-top:var(--space-3xs);
}
.form__fallback{font-size:var(--step-small);color:var(--text-on-ink-secondary)}

.form-success{display:none;border:1px solid var(--palm);border-radius:var(--radius);padding:var(--space-m);max-width:46ch;margin-inline:auto;text-align:center}
.form-success h3{font-size:var(--step-h3);font-weight:500;margin-bottom:var(--space-2xs)}
.is-sent .form{display:none}
.is-sent .form-success{display:block}

/* ==========================================================================
   Footer
   ========================================================================== */
/* The contact section inverted to ink, so it and the footer are now the same
   surface. The hairline that used to sit at the top of contact moves here. */
/* Contact is ink and so is this, so the hairline is the only thing marking the
   join. The top padding is deliberately modest: the facts row that used to sit
   between the form and here has gone, and this block is what fills that space. */
.site-footer{
  padding-block:var(--space-l) var(--space-xl);
  border-top:1px solid color-mix(in srgb,var(--text-on-ink) 14%,transparent);
}
.site-footer .wrap{display:grid;gap:var(--space-l)}
@media (min-width:760px){.site-footer .wrap{grid-template-columns:1fr auto;align-items:end}}
.site-footer h2{font-size:var(--step-h3);font-weight:500;margin-bottom:var(--space-2xs)}
.site-footer p,.site-footer li{font-size:var(--step-small);color:var(--text-on-ink-secondary)}
.footer-links{display:flex;flex-wrap:wrap;gap:var(--space-m) var(--space-l)}
.footer-links a{font-size:var(--step-small);text-decoration:none;color:var(--text-on-ink)}

/* ==========================================================================
   Mobile sticky call to action
   ========================================================================== */
.cta-bar{
  position:fixed;left:0;right:0;bottom:0;z-index:50;
  background:var(--ink);
  padding:var(--space-xs) var(--gutter);
  padding-bottom:calc(var(--space-xs) + env(safe-area-inset-bottom,0px));
  display:none;
  align-items:center;justify-content:space-between;gap:var(--space-s);
  border-top:1px solid var(--text-on-ink-secondary);
  transform:translateY(101%);
  transition:transform var(--dur-base) var(--ease-out);
}
.cta-bar__label{font-size:var(--step-small);color:var(--text-on-ink-secondary)}
@media (max-width:859px){
  .cta-bar{display:flex}
  .is-scrolled .cta-bar{transform:translateY(0)}
  /* Never two coral buttons at once: the bar retreats while a section CTA is on screen. */
  .cta-suppressed .cta-bar{transform:translateY(101%)}
  body{padding-bottom:0}
}
@media (prefers-reduced-motion:reduce){.cta-bar{transition:none}}

/* ==========================================================================
   404
   ========================================================================== */
.notfound{min-height:100svh;display:flex;flex-direction:column;padding-inline:var(--gutter);padding-bottom:var(--space-xl)}
.notfound__body{flex:1 1 auto;display:flex;flex-direction:column;justify-content:center;gap:var(--space-m);padding-block:var(--space-l)}
.notfound .frame{aspect-ratio:16/9;max-width:min(520px,100%);width:100%}

/* ==========================================================================
   The band, continued
   ========================================================================== */
/* --- The Instagram column, in this section -------------------------------
   A clean ink card. No border: the frame device does not appear in this
   section at all, so the coral leg of the scroll colour travel has nothing to
   land on here. Still open with Ed. */
.band .ig{
  border:0;
  padding:var(--space-s);
}
/* Posts lose their own radius so the column stops being a card of cards. */
.band .ig__post,
.band .ig__post::after{border-radius:0}
/* Ink rather than mango, so the section does not spend mango four times over
   in chips. */
.band .ig__chip{
  background:var(--ink);
  color:var(--text-on-ink);
  border-radius:0;
}

/* ==========================================================================
   Setlist page
   ========================================================================== */
/* A second page, off the main scroll, added 10 September on Ed's instruction.
   It reuses the tokens, the bar, the footer and the grain rather than carrying
   a stylesheet of its own, which is also why it does not get a build step.

   THE SHAPE OF IT: the colour field carries the Anton title and nothing else,
   and the 32 rows sit on flat ink below. That split is not decoration, it is
   the tonal-middle finding in DESIGN_SYSTEM s3 applied: a 64px display run
   needs 3.0:1 and clears it on any of the four fields, but 15px song titles
   need 4.5:1 and would be the marginal case on every one of them. Type that
   small belongs on its own ground. Ink gives it 14.93:1 and the question stops
   being a question. */
.setlist-hero{
  --sl-sat:1.3;
  --sl-bright:1.0;
  position:relative;
  isolation:isolate;
  overflow:hidden;
  display:flex;align-items:flex-end;
  min-height:clamp(240px,36vh,400px);
  /* The bar is fixed over the top of this, same as the hero on index.html. */
  padding-top:calc(var(--header-h) + var(--space-l));
  padding-bottom:var(--space-l);
  /* Mango, not ink: the title on this field is INK, so an ink fallback would
     render it invisible if the WebP fails. Ink on mango is 8.28:1. Same
     reasoning as .testimonials and .gallery. */
  background-color:var(--mango);
  color:var(--text-on-bone);
}
/* testimonials-bg, and the choice is on the record: it is the brightest of the
   four fields and the least arguable as "abstract" under CLAUDE.md s9, which
   permits non-representational fields and bans representational imagery. The
   unused videos-bg pair reads as pool water, which sits close enough to the
   banned stock-beach territory that it should be Ed's call, not mine. See
   HANDOFF s8. */
.setlist-hero::before{
  content:"";
  position:absolute;inset:0;z-index:0;
  pointer-events:none;
  background-image:url("../assets/img/testimonials-bg-1600.webp");
  background-size:cover;
  background-position:center;
  filter:saturate(var(--sl-sat)) brightness(var(--sl-bright));
}
@media (max-width:700px){
  .setlist-hero::before{background-image:url("../assets/img/testimonials-bg-1000.webp")}
}
.setlist-hero > .wrap{position:relative;z-index:1}

.setlist{padding-block:var(--space-xl)}
.setlist__intro{
  max-width:60ch;
  color:var(--text-on-ink-secondary);   /* 6.95:1 on ink */
  margin-bottom:var(--space-xl);
}

/* Two columns above 860px, one below. `columns` rather than a grid so the list
   stays a single <ol> and the numbering is real: two lists with a `start` on
   the second would break the moment a song is added or removed. */
.setlist__list{
  list-style:none;
  margin:0;padding:0;
  counter-reset:song;
  column-gap:var(--space-2xl);
}
@media (min-width:860px){.setlist__list{column-count:2}}

.setlist__list li{
  counter-increment:song;
  /* Stops a row splitting across the column break, which would leave a title
     at the foot of column one and its artist at the head of column two. */
  break-inside:avoid;
  display:grid;
  grid-template-columns:2.25rem 1fr;
  align-items:baseline;
  /* Column gap only. A shared `gap` also opens the row axis, which pushes the
     artist a full 16px off its own title and makes 32 rows read as 64. */
  column-gap:var(--space-s);
  row-gap:0;
  padding-block:10px;
  /* NO dividing rule, and that was an A/B rather than an assumption. A hairline
     in --text-on-ink-secondary is the only one the palette allows (s3 forbids
     opacity variants as new colours) and at full strength across two wide
     columns it put 32 bright grey lines on the page, which read as decoration
     and fought the "chrome is minimal" principle in DESIGN_SYSTEM s2. The
     number plus the tight title-and-artist pairing separates the rows on their
     own. If a future set runs long enough to need banding, band it with space,
     not with a line. */
}
/* The number. Tabular figures so 1 and 31 align on the same axis down the
   column, which is the whole reason to set a list this way. */
.setlist__list li::before{
  content:counter(song);
  grid-column:1;
  font-size:var(--step-small);
  font-weight:500;
  font-variant-numeric:tabular-nums;
  color:var(--text-on-ink-secondary);
}
.setlist__title{
  grid-column:2;
  display:block;
  font-weight:500;
  font-size:var(--step-body);
  /* Uppercase, tracking opened a touch. This is the testimonial component's
     treatment reused rather than a new one invented for this page: it is
     already how Casa sets a short run of Inter 500 that has to carry weight
     without Anton. Anton itself is not an option here, it is headline only and
     never below 24px. */
  text-transform:uppercase;
  letter-spacing:.02em;
  color:var(--text-on-ink);
}
.setlist__artist{
  grid-column:2;
  display:block;
  font-size:var(--step-small);
  color:var(--text-on-ink-secondary);
}

.setlist__foot{
  margin-top:var(--space-xl);
  display:flex;flex-direction:column;align-items:flex-start;gap:var(--space-s);
}
.setlist__foot p{color:var(--text-on-ink);margin:0}
