/* ===================================================================
   Crosstown Social — shared site stylesheet (Retro Soul theme)
   Used by index.html (the one-page site) and tailor.html.
   The redirect stubs at the project root (about/listen/contact/gallery/
   packages.html) do NOT load this - they are bare meta-refresh pages.
   =================================================================== */

/* ===================================================================
   SECTION INDEX  (added 2026-07-29)

   DON'T READ THIS FILE END TO END. It is ~1,230 lines and doing so was
   costing more than the edits. Find your section below, then jump to it
   by searching for its banner text, e.g.

       grep -n "============ NAV ============" assets/site.css

   and read a window around the hit. Line numbers here are a hint only
   and go stale as the file changes; THE BANNER TEXT IS THE ANCHOR.
   To re-list every banner:  grep -n "/\* =\{4,\}" assets/site.css

   Before changing anything that looks wrong, read the constraint table
   in Part 1 of "Website Handover.md" (lines 1-85). Several odd-looking
   values here were reached by trying the obvious version and rejecting
   it on sight.

   ---- foundations ------------------------------------------- ~line --
   MOBILE BROWSER CHROME GAP ....................................... 10
   SCROLL SNAPPING (slide sequence) ................................ 32
     ...also holds, unbannered, inside that block:
       reduced-motion overrides ................................... ~99
       focus-outline colour ...................................... ~142
   ACCESSIBILITY: skip link ....................................... 154
   ONE-PAGE ANCHOR NAV ............................................ 167

   ---- chrome ------------------------------------------------ ~line --
   NAV (incl. mobile menu) ........................................ 182
   FOOTER ........................................................ 1217

   ---- the slide sequence ------------------------------------ ~line --
   HERO (landing page, video) ..................................... 259
   PAGE HERO (subpages, static image) ............................. 401
   SECTIONS ....................................................... 463
   SPLIT (image + text) ........................................... 477
   PARALLAX (image + centred text over photo) ..................... 485
     the longest section in the file (~245 lines). Sub-landmarks,
     each greppable by the quoted comment text:
       "PARALLAX OVERSCAN" ....................................... ~509
       "SYMMETRIC SCRIM" ......................................... ~529
       "SEAM FILL" ............................................... ~555
       slide eyebrows ............................................ ~587
       #packages bottom stack .................................... ~601
       .social-icon-link ......................................... ~636
       .carousel-heading (Listen) ................................ ~670

   ---- content blocks ---------------------------------------- ~line --
   GALLERY SCROLLER (auto-scroll ribbon) .......................... 729
   CHIP ROW (genre/style tags) .................................... 745
   SLIDESHOW (Listen page — photo + audio per slide) .............. 751
   FULL PHOTO GRID (gallery subpage) ............................. 1092
   CARD GRID (services / repertoire / packages) .................. 1097
   SETLIST ROWS (tailor.html) .................................... 1122
   QUOTE ......................................................... 1154
     ...currently holds the Instagram follow strip instead of a quote;
     grep "FOLLOW STRIP" for that block .......................... ~1245
   FINAL CTA ..................................................... 1179
   ENQUIRY FORM .................................................. 1195
   =================================================================== */

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

/* ============ MOBILE BROWSER CHROME GAP ============ */
/* The height of the collapsible browser chrome - the URL bar and toolbar
   that slide away as you scroll on a phone. Zero on every desktop browser
   and on any mobile browser whose chrome is already hidden, because lvh,
   svh and dvh are all identical there. Roughly 90px on a Pixel, 60-110px
   across iOS Safari versions.
   Every full-viewport slide is sized in lvh (the LARGEST viewport, i.e.
   chrome collapsed) so that snap points never move - see the note on .hero
   for why that matters. The cost is that whenever the chrome IS showing,
   the bottom --chrome-gap pixels of every slide are below the fold. This
   variable is how each slide's content is kept above that line.
   Two ways it gets used, and which one applies depends on how the content
   is positioned:
     - Content in normal flow (flex-centred or flex-end): padding-bottom on
       the slide shrinks the flex content box to the visible height, so
       centring and bottom-alignment resolve against what you can see.
     - Content positioned absolutely with percentages: padding does nothing
       to it (percentage offsets resolve against the PADDING box, which is
       still the full lvh), so those offsets subtract their own share of the
       gap by hand. See the Listen slide's player controls. */
:root { --chrome-gap: calc(100lvh - 100svh); }

/* ============ SCROLL SNAPPING (slide sequence) ============ */
/* Native CSS scroll-snap rather than a JS scroll handler, deliberately: the
   browser owns the wheel/trackpad/touch momentum curves, so a phone drag and
   a desktop flick both feel right without reimplementing inertia. Every JS
   version of this fights the platform's own scrolling and reads as sticky on
   iOS in particular.
   overflow-x lives HERE now, not on body. It was on body, and body overflow
   propagates to the viewport, which in several browsers stops
   scroll-snap-type on the root from taking effect at all. Moving it up keeps
   the horizontal clamp and leaves html unambiguously the snap container.
   The baseline is NONE, not proximity. site.js adds .snap-slides (mandatory)
   only while the viewport is inside the slide sequence, and there is
   deliberately no snapping whatsoever once you're past the last slide - the
   testimonial, enquiry form and footer are ordinary page content and should
   scroll like it.
   Don't be tempted to use `proximity` anywhere here. Proximity only pulls
   when a gesture happens to end near a snap point and leaves a dead band in
   the middle where the page just sits between two slides - which is the exact
   symptom this was meant to prevent. It is mandatory or nothing.
   2026-07-26: .snap-slides is only applied on TOUCH devices now. Mouse and
   trackpad get no native snapping at all - Chrome jumps a whole slide per
   wheel notch under mandatory, which was too abrupt - and use a JS
   completion nudge instead. See the SCROLL SNAPPING block in site.js; the
   reasoning and the two rejected desktop approaches are documented there. */
html {
  /* auto, NOT smooth. site.js tweens every programmatic scroll itself (arrow
     links, nav anchors, the desktop completion nudge) because the native
     smooth duration is browser-chosen, short, and not adjustable by any CSS
     property or API - it read as jerky. Setting smooth here would make the
     browser animate on top of the tween, and the two curves fight.
     See the SMOOTH SCROLL TWEEN block in site.js. */
  scroll-behavior:auto;
  overflow-x:hidden;
  scroll-snap-type:none;
}
html.snap-slides { scroll-snap-type:y mandatory; }
/* Mobile menu scroll lock (2026-07-28). ON <html>, DELIBERATELY - see the
   note in setMenuOpen() in site.js. body's overflow can only propagate to the
   viewport while html's own overflow is `visible`, and html declares
   overflow-x:hidden two rules up, so the usual `body { overflow:hidden }`
   version of this computes fine and stops nothing at all.
   Scroll position survives: overflow:hidden on the root clamps the scroll
   box, it doesn't reset scrollTop, so closing the menu leaves you where you
   were. Class is toggled from site.js rather than derived with :has() so the
   behaviour is greppable from the JS that owns the menu state. */
html.menu-locked { overflow:hidden; }
/* Only the full-viewport panels are snap targets. Nothing below them is, so
   the testimonial, enquiry form and footer scroll completely normally.
   scroll-snap-stop:always (2026-07-26) forbids a fling from skipping OVER a
   snap point, so one swipe advances at most one slide however hard it's
   thrown. Callum found it too easy to fly past a whole slide on touch.
   This is the native way to get a one-slide-per-gesture lock, and it feels
   right because the browser reconciles it with its own momentum curve - a
   JS equivalent has to cancel that momentum and reads as the page fighting
   you. It only has any effect where scroll-snap-type is active, which in
   this setup is touch devices; desktop has no native snapping. */
.hero, #listen, #about, #packages { scroll-snap-align:start; scroll-snap-stop:always; }
/* The testimonial is the ONE non-slide stop, added 2026-07-28 so that leaving
   #packages on a phone arrives somewhere deliberate instead of dropping into
   free scroll mid-quote. It is not full-viewport like the four above, which is
   fine - a snap point is a line, not a panel.
   This rule costs desktop nothing and needs no media query: scroll-snap-align
   does nothing unless an ancestor sets scroll-snap-type, and site.js only ever
   adds .snap-slides on coarse pointers. The desktop completion nudge works off
   a separate list in site.js that deliberately excludes this. */
#testimonial { scroll-snap-align:start; scroll-snap-stop:always; }

@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after { animation-duration:0.01ms !important; animation-iteration-count:1 !important; transition-duration:0.01ms !important; }
  .gallery-track { animation-duration:110s !important; animation-iteration-count:infinite !important; }
  /* Snapping itself stays mandatory; only motion is reduced. The smooth
     scroll is handled in site.js now (tweenScrollTo returns instantly under
     this preference), so there's nothing left to disable here - the
     scroll-behavior line below is kept as a belt-and-braces default.
     2026-07-26: this block used to also downgrade snapping to proximity, and
     that was a real bug rather than a nicety - Windows reports
     prefers-reduced-motion:reduce whenever "Animation effects" is switched
     off in Accessibility settings, which is a common and completely
     unremarkable thing for someone to have done. Those users silently got
     proximity, i.e. the dead band in the middle where the page parks between
     two slides, and it looked like the snapping was simply broken.
     What this preference asks for is no gratuitous ANIMATION, not a
     different layout behaviour. With scroll-behavior:auto the snap lands
     instantly instead of gliding, which satisfies it properly. */
  html { scroll-behavior:auto; }
}

body {
  background:var(--bg); color:var(--fg);
  font-family:var(--font-body);
  transition:background .4s ease, color .4s ease;
  /* overflow-x moved to html - see the scroll-snap block above. */
  --bg:#f6ead8; --bg2:#fdf6e9; --bg3:#efd9b8;
  --fg:#3a1c17; --muted:#7a5142;
  --accent:#6b1f2a; --accent2:#d9931e;
  --btn-fg:#f6ead8;
  --font-display:'Abril Fatface',serif; --font-body:'Space Grotesk',sans-serif;
  --radius-btn:999px; --radius-card:16px;
  --tt-display:none; --ls-display:0em; --fw-display:400;
  --card-bg:#fdf6e9; --card-border:#6b1f2a;
  /* Parallax overscan, as a fraction - how far .parallax-img overhangs its
     section on each side. This is the hard ceiling on parallax travel:
     site.js's PARALLAX_TRAVEL_PCT (0.20) must stay below it. The two are one
     setting split across two files, and them drifting apart is precisely
     what caused the original grey-bar bug, so change them together. */
  --parallax-inset:0.25;
}
img { display:block; max-width:100%; }
a { color:inherit; text-decoration:none; }
:focus-visible { outline:2px solid var(--accent); outline-offset:3px; }
/* The default outline is burgundy, which is right against cream and close to
   invisible everywhere the page goes dark (2026-07-28). Four contexts need
   the inverse: the three full-bleed photo/video slides, where the outline
   would sit on a dark scrimmed photo, and .cta-final, whose background IS
   --accent - a burgundy ring on a burgundy panel is no ring at all.
   .scroll-cue already set white for itself and is now consistent with the
   rest of the controls around it. */
.hero :focus-visible,
.parallax-section :focus-visible,
.slide-photo :focus-visible,
.cta-final :focus-visible { outline-color:#fff; }

/* ============ ACCESSIBILITY: skip link ============ */
.skip-link {
  position:absolute; top:-100px; left:12px; z-index:1000;
  background:var(--fg); color:var(--bg); padding:12px 20px; border-radius:8px;
  font-size:13px; font-weight:700; letter-spacing:.04em; transition:top .15s ease;
}
.skip-link:focus { top:12px; }

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

/* ============ ONE-PAGE ANCHOR NAV ============ */
/* The nav is fixed, so a plain #anchor jump would land with the section
   title hidden underneath it - scroll-margin-top pushes the landing point
   down by roughly the nav's height (using the larger unscrolled height as a
   safe upper bound) so the heading lands clear.
   2026-07-26: this used to cover #about/#listen/#packages too, and had to be
   removed from them when scroll snapping went in. scroll-margin also offsets
   the SNAP position, so an 88px margin made every slide snap 88px down its
   own start - leaving a permanent 88px strip of the previous slide visible
   at the top of the screen at every rest position. The slides don't need the
   offset anyway: their headings are vertically centred, not near the top,
   and the nav is deliberately transparent over them. #contact is a normal
   content section with a heading close to its top edge, so it keeps it. */
#contact { scroll-margin-top:88px; }

/* ============ NAV ============ */
.nav {
  position:fixed; top:0; left:0; right:0; z-index:100;
  display:flex; align-items:center; justify-content:space-between;
  padding:22px clamp(20px,4vw,48px);
  transition:background .3s ease, padding .3s ease, backdrop-filter .3s ease, border-color .3s ease;
  border-bottom:1px solid transparent;
  /* At the very top the nav sits directly over a dark video/photo, so its own
     text needs to be light regardless of the page's normal (light-bg) theme.
     Once scrolled, the nav gets a solid cream backing, so text switches back
     to the normal dark theme colours for contrast against that. */
  --nav-fg: var(--bg);
  --nav-accent: var(--accent2);
}
.nav.scrolled {
  background:var(--bg); background:color-mix(in srgb, var(--bg) 88%, transparent);
  backdrop-filter:blur(10px); padding:14px clamp(20px,4vw,48px); border-color:var(--card-border);
  --nav-fg: var(--fg);
  --nav-accent: var(--accent);
}
.logo { font-family:var(--font-display); font-weight:var(--fw-display); font-size:19px; letter-spacing:.03em; text-transform:var(--tt-display); color:var(--nav-fg); }
.logo em { font-style:normal; color:var(--nav-accent); }
.nav-links { display:flex; gap:30px; align-items:center; }
/* :not(.nav-cta) on both rules below, fixed 2026-07-27: the Enquire pill
   lives inside .nav-links markup-wise (so the mobile-menu/nav-links styling
   doesn't have to be duplicated), which meant it was quietly picking up the
   plain text links' opacity:.85 - and would have picked up their hover
   colour swap to --nav-accent too, fighting its own --btn-fg. At the top of
   the page that 15% see-through let the busy hero photo bleed through the
   burgundy fill unevenly, reading as a dirty gradient instead of a clean
   solid chip - barely visible once scrolled, because a cream backdrop
   bleeding through cream-adjacent burgundy is a much smaller shift. The CTA
   already declares its own solid background/color below; it was never
   supposed to inherit either rule. */
.nav-links a:not(.nav-cta) { font-size:12.5px; font-weight:600; letter-spacing:.08em; text-transform:uppercase; opacity:.85; color:var(--nav-fg); }
.nav-links a.current:not(.nav-cta), .nav-links a:hover:not(.nav-cta) { opacity:1; color:var(--nav-accent); }
.nav-cta { background:var(--accent); color:var(--btn-fg); padding:11px 20px; border-radius:var(--radius-btn); font-size:12px; font-weight:700; letter-spacing:.08em; text-transform:uppercase; }
.nav-toggle { display:none; width:26px; height:20px; position:relative; cursor:pointer; background:none; border:none; }
.nav-toggle span, .nav-toggle span::before, .nav-toggle span::after { content:''; position:absolute; left:0; width:100%; height:2px; background:var(--nav-fg); transition:transform .2s ease, opacity .2s ease, background .2s ease; }
.nav-toggle span { top:9px; }
.nav-toggle span::before { top:-8px; }
.nav-toggle span::after { top:8px; }
body.menu-open .nav-toggle span { background:transparent; }
body.menu-open .nav-toggle span::before { transform:translateY(8px) rotate(45deg); background:var(--fg); }
body.menu-open .nav-toggle span::after { transform:translateY(-8px) rotate(-45deg); background:var(--fg); }

/* Bug fixed 2026-07-27: opening the mobile menu at the very top of the page
   (nav not yet .scrolled) left "Crosstown" unreadable - cream text (--nav-fg
   defaults to --bg for contrast against the dark hero) sitting on top of the
   mobile-menu panel's own cream background (also --bg). "Social" stayed
   visible by coincidence, since --nav-accent is mustard at the top and that
   reads fine on cream too. The nav bar itself doesn't get a background here
   (that's the whole point of it being transparent over the hero) - the fix
   is switching its text colour variables to the dark/scrolled pair whenever
   the menu is open, regardless of actual scroll position, since the menu
   panel behind it is always cream. Scrolled-down + menu-open already worked,
   because .scrolled's dark colours were already active. */
body.menu-open .nav {
  --nav-fg: var(--fg);
  --nav-accent: var(--accent);
}

.mobile-menu {
  position:fixed; inset:0; z-index:90; background:var(--bg);
  display:flex; flex-direction:column; align-items:center; justify-content:center; gap:26px;
  transform:translateY(-100%); transition:transform .4s ease, visibility 0s linear .4s;
  visibility:hidden; /* keeps its links out of the tab order while closed, not just out of view */
}
body.menu-open .mobile-menu { transform:translateY(0); visibility:visible; transition:transform .4s ease; }
.mobile-menu a { font-family:var(--font-display); font-size:26px; text-transform:var(--tt-display); }
.mobile-menu .nav-cta { font-family:var(--font-body); }

@media (max-width:820px) {
  .nav-links { display:none; }
  .nav-toggle { display:block; }
}

/* ============ HERO (landing page, video) ============ */
/* lvh, NOT dvh and NOT svh (2026-07-27, revised). All four full-screen
   panels use this - .hero, .parallax-section and .slide-photo - and they
   must stay in step.
   THE RULE: a scroll-snap container's snap points must not move while you
   are scrolling. Any viewport unit that changes as the browser chrome
   animates violates it, and mandatory snapping turns that violation into a
   visible fault.
   The three units, and why it landed on lvh:
     svh (chrome expanded, the smallest) - stable, but on Android Chrome the
       layout viewport GROWS when the chrome collapses (measured on a Pixel
       10 Pro: window.innerHeight 760 -> 852 while 100svh stayed 760), so a
       ~92px strip of the NEXT slide appeared below the one you were parked
       on. That strip is where the seam gap lives, so it showed a band of
       transition artwork. This was the state before 2026-07-27.
     dvh (tracks the visible viewport) - fixes the strip, but resizes every
       slide mid-scroll. Because the slides are stacked, slide N's snap point
       moves by N x 92px as the URL bar collapses; under `mandatory` the
       browser must come to rest ON a snap point, so it re-snaps after your
       scroll has already stopped. That is the "stops half way, then catches
       up" fault Callum and his friend both hit, on Android and iOS. The
       comment that used to sit here flagged this as the risk; it landed.
     lvh (chrome collapsed, the largest) - stable, and never smaller than the
       viewport, so no strip either. Its one cost is that the bottom
       --chrome-gap pixels sit below the fold while the chrome IS showing.
       That is a static, known offset rather than a moving target, so it can
       simply be designed around - which is what --chrome-gap is for. See the
       MOBILE BROWSER CHROME GAP block at the top of this file.
   Do not "simplify" this back to dvh. It looks like the obviously correct
   unit and it is the one that breaks snapping. */
.hero { position:relative; height:100lvh; min-height:580px; display:flex; align-items:flex-end; overflow:hidden; padding-bottom:var(--chrome-gap); }
.hero video { position:absolute; inset:0; width:100%; height:100%; object-fit:cover; z-index:0; }
/* 2026-07-26: was a 3-stop gradient (.2 -> .5 -> .94, rgba(6,6,8)) - a
   different shape and tone from the 2-stop scrim now used uniformly on
   Listen/About/Packages (.parallax-overlay / .player-vignette, rgba(4,4,6)
   .4 -> .72). Matched here too so the hero->Listen handoff doesn't look
   like a different treatment from the other slide-to-slide transitions. */
/* 2026-07-26 (later): bottom stop moved .72 -> .66 to match the first stop
   of .parallax-overlay / .player-vignette, so the hero->Listen seam has no
   brightness step either. Top stop stays .4 - nothing sits above the hero,
   so it doesn't need to match anything, and the lighter top keeps the video
   readable behind the nav. */
.hero::after {
  content:''; position:absolute; inset:0; z-index:1;
  background:linear-gradient(180deg, rgba(4,4,6,.4), rgba(4,4,6,.66));
}
.hero-content { position:relative; z-index:2; padding:0 clamp(20px,4vw,64px) clamp(56px,9vh,100px); max-width:920px; text-align:left; }
.eyebrow { color:var(--accent2); font-family:var(--font-body); font-size:12px; letter-spacing:.2em; text-transform:uppercase; margin-bottom:20px; font-weight:700; }
/* Hero eyebrow, 2026-07-26: was a single .eyebrow line reading
   "Live wedding & event band · Sydney to South Coast". Split into two lines
   with the offer bigger and the region stacked beneath it, so the first
   thing read is what the band IS rather than a run-on with a middot in it.
   Deliberately still smaller than .hero-title - it's a supporting line, and
   competing with the band name would flatten the hierarchy.
   The region line is white rather than mustard: two mustard lines of
   different sizes read as one broken heading, whereas the colour change
   makes the subordination obvious at a glance. */
/* The hero's three text lines - "Live Wedding & Event Band", the region list,
   and the tagline - are evenly spaced at 16px, 2026-07-27. It was 9px above
   the region line and 22px below it, which sat that line visibly high.
   THREE values control two gaps, which is why this is easy to get wrong:
     gap 1 = .hero-eyebrow-sub's margin-top
     gap 2 = .hero-eyebrow's margin-bottom COLLAPSED WITH .hero-tagline's
             margin-top - they are adjacent siblings in normal flow, so the
             gap is the LARGER of the two, not their sum. Both are set to 16
             so neither can quietly win.
   The sr-only h1 sits between them but is position:absolute and out of flow,
   so it doesn't separate them. If it is ever un-hidden, this all changes. */
.hero-eyebrow { margin-bottom:16px; }
.hero-eyebrow-main {
  display:block; color:var(--accent2); font-family:var(--font-body);
  font-size:clamp(18px,2.8vw,26px); font-weight:700;
  letter-spacing:.16em; text-transform:uppercase; line-height:1.25;
}
.hero-eyebrow-sub {
  display:block; margin-top:16px; color:rgba(255,255,255,.82);
  font-family:var(--font-body); font-size:clamp(11px,1.25vw,13px); font-weight:600;
  letter-spacing:.24em; text-transform:uppercase; line-height:1.3;
}
.hero-title { font-family:var(--font-display); font-weight:var(--fw-display); font-size:clamp(54px,11vw,150px); line-height:.9; color:#fdfcf9; text-transform:var(--tt-display); letter-spacing:var(--ls-display); }
.hero-title span { display:block; }
.hero-tagline { font-family:var(--font-display); font-style:italic; font-size:clamp(18px,2.6vw,28px); color:var(--accent2); margin-top:16px; }
.hero-sub { margin-top:24px; max-width:520px; font-size:15.5px; line-height:1.7; color:rgba(255,255,255,.85); }
.hero-actions { margin-top:34px; display:flex; gap:14px; flex-wrap:wrap; }
.btn-primary, .btn-ghost { display:inline-block; padding:15px 28px; border-radius:var(--radius-btn); font-weight:700; font-size:13px; letter-spacing:.08em; text-transform:uppercase; cursor:pointer; border:none; font-family:var(--font-body); }
.btn-primary { background:var(--accent); color:var(--btn-fg); }
.btn-ghost { border:1.5px solid rgba(255,255,255,.55); color:#fff; background:transparent; }
/* Scroll cue: an <a> to the next section, not a decorative div. Anchor
   rather than a button because it works with the keyboard and middle-click
   and degrades to a plain jump if JS fails. site.js intercepts the click and
   animates it with tweenScrollTo (see the SMOOTH SCROLL TWEEN block there) -
   the native scroll-behavior:smooth it originally relied on was too fast and
   not adjustable, so scroll-behavior is now `auto` and the tween owns the
   motion. Under prefers-reduced-motion the tween jumps instantly instead.
   site.js still writes opacity/pointer-events inline for the fade, so any
   hover state has to live on the inner chevron rather than on .scroll-cue -
   an inline style would beat a stylesheet :hover rule on the element. */
/* bottom is 26px above the VISIBLE bottom edge, not the slide's own bottom
   edge. Slides are 100lvh, so while the mobile URL bar is showing, the last
   --chrome-gap pixels of the slide are below the fold - a plain bottom:26px
   would put the arrow off-screen on every phone with its chrome expanded,
   which is the state the page loads in. The cue is absolutely positioned, so
   the padding-bottom that handles this on .hero and .parallax-section does
   not reach it and it has to offset itself. --chrome-gap is 0 on desktop, so
   this is exactly bottom:26px there. */
.scroll-cue { display:block; position:absolute; bottom:calc(26px + var(--chrome-gap)); left:50%; transform:translateX(-50%); z-index:2; transition:opacity .15s linear; }
/* Enlarges the tap target to roughly 65x47px without moving the arrow: a
   12px chevron is far below a usable touch target, but padding on the
   element itself would shift it off its 26px baseline. Kept clear of the
   #packages CTA stack above it - see the note on .slide-cta-stack. */
.scroll-cue::before { content:''; position:absolute; inset:-10px -24px; }
.scroll-cue:focus-visible { outline:2px solid #fff; outline-offset:4px; border-radius:4px; }
.scroll-cue .chevron-down { display:block; animation:chevronBounce 1.8s ease-in-out infinite; }
.scroll-cue .chevron-down i { display:block; width:12px; height:12px; border-right:2px solid rgba(255,255,255,.85); border-bottom:2px solid rgba(255,255,255,.85); transform:rotate(45deg); transition:transform .18s ease, border-color .18s ease; }
.scroll-cue:hover .chevron-down i { transform:rotate(45deg) scale(1.25); border-color:#fff; }
@keyframes chevronBounce { 0%,100%{ transform:translateY(0); opacity:.5; } 50%{ transform:translateY(8px); opacity:1; } }
/* Sits on the same 26px baseline as .scroll-cue and needs the same
   --chrome-gap offset for the same reason - it is absolutely positioned, so
   .hero's padding-bottom doesn't reach it, and a raw bottom:26px would put
   the pause control under the mobile URL bar. See the note on .scroll-cue. */
.media-toggle {
  position:absolute; z-index:2; bottom:calc(26px + var(--chrome-gap)); right:clamp(20px,4vw,48px);
  width:42px; height:42px; border-radius:50%; border:1.5px solid rgba(255,255,255,.55);
  background:rgba(6,6,8,.35); color:#fff; cursor:pointer; display:flex; align-items:center; justify-content:center;
  padding:0;
}
.media-toggle:hover { background:rgba(6,6,8,.55); }
.media-toggle .icon-pause, .media-toggle.paused .icon-play { display:flex; }
.media-toggle .icon-play, .media-toggle.paused .icon-pause { display:none; }
/* Pure CSS icons instead of unicode glyphs — unicode ▶ / ❚❚ carry inconsistent
   built-in side-bearing per font, so flexbox centers their box, not their ink,
   and they end up looking off-centre. Hand-drawn shapes center exactly. */
.media-toggle .icon-pause { align-items:center; justify-content:center; gap:3px; width:12px; height:12px; }
.media-toggle .icon-pause i { display:block; width:3px; height:12px; background:#fff; border-radius:1px; }
.media-toggle .icon-play {
  align-items:center; justify-content:center;
  width:0; height:0;
  border-top:6px solid transparent; border-bottom:6px solid transparent;
  border-left:10px solid #fff;
  margin-left:3px; /* optical centering: a right-pointing triangle's visual weight sits left of its bounding box */
}

/* ============ PAGE HERO (subpages, static image) ============ */
.page-hero { position:relative; padding:168px clamp(20px,4vw,48px) 84px; overflow:hidden; }
.page-hero-bg { position:absolute; inset:0; background-size:cover; background-position:center; z-index:0; }
.page-hero::after {
  content:''; position:absolute; inset:0; z-index:1;
  background:linear-gradient(180deg, rgba(6,6,8,.35) 0%, rgba(6,6,8,.55) 55%, rgba(6,6,8,.88) 100%);
}
/* BACK TO THE BIG NIGHT (2026-07-28) - bottom-left of tailor.html's banner.
   Sits in the banner's own 84px of bottom padding, so it needs no layout
   space of its own and can't push the heading below it around.
   left matches .page-hero's horizontal padding exactly, so it lines up with
   the section content underneath rather than floating at an arbitrary inset.
   Change one and change the other.
   NO BUTTON CHROME - Callum's call, 2026-07-28. This was briefly a
   translucent pill (white hairline over a dark fill, matching .btn-ghost and
   .media-toggle) and it read as too heavy for what it is: a quiet way back,
   not something to be drawn towards. Same judgement as the play button on the
   Listen slide, where a ring was tried and rejected. Don't reach for a
   border, background or radius here again - the drop shadow is what lifts it
   off the photo, and .page-hero::after is already near-opaque this far down
   the banner, so there is plenty of contrast without a box. */
.page-hero-back {
  position:absolute; z-index:2;
  left:clamp(20px,4vw,48px); bottom:24px;
  display:inline-flex; align-items:center; gap:8px;
  color:#fff; font-family:var(--font-body);
  font-size:12px; font-weight:700; letter-spacing:.08em; text-transform:uppercase;
  opacity:.85; transition:opacity .15s ease;
  /* drop-shadow, not text-shadow: it has to lift the chevron too, and that is
     drawn with borders on a box, which text-shadow does not touch. */
  filter:drop-shadow(0 2px 8px rgba(0,0,0,.7));
}
.page-hero-back:hover { opacity:1; }
/* Without the pill's padding the text alone is a small target, so the hit
   area is grown invisibly instead - same trick as .scroll-cue::before, and
   for the same reason. Roughly 60x40 without moving the label off its
   baseline. The parent is already absolutely positioned, so it anchors this. */
.page-hero-back::before { content:''; position:absolute; inset:-12px -16px; }
/* Same construction as .scroll-cue's .chevron-down - two borders on a square,
   rotated 45deg - just picking the other adjacent pair so the vertex points
   west instead of south. Sized to sit with 12px text rather than stand alone
   as a control. */
.chevron-left i {
  display:block; width:7px; height:7px;
  border-left:2px solid currentColor; border-bottom:2px solid currentColor;
  transform:rotate(45deg);
}
@media (max-width:640px) {
  .page-hero-back { bottom:18px; }
}

/* The three .page-hero-content rules below are currently UNUSED: tailor.html
   is the only page with a .page-hero and its header went image-only on
   2026-07-27, so there is no text inside it any more. Kept rather than
   deleted because .page-hero and .page-hero-bg are still live and this is the
   rest of that same component - a future subpage wanting a captioned header
   would need exactly these back. */
.page-hero-content { position:relative; z-index:2; max-width:820px; }
.page-hero-content .eyebrow { color:var(--accent2); }
.page-hero-content h1 { font-family:var(--font-display); font-weight:var(--fw-display); font-size:clamp(40px,7vw,84px); line-height:1.03; color:#fdfcf9; text-transform:var(--tt-display); margin-top:14px; }
.page-hero-content p { margin-top:20px; max-width:580px; font-size:16px; line-height:1.7; color:rgba(255,255,255,.88); }

/* ============ SECTIONS ============ */
.section { padding:104px clamp(20px,4vw,48px); max-width:1240px; margin:0 auto; }
.section-head { max-width:640px; margin-bottom:48px; }
.section-head.center { max-width:720px; margin-left:auto; margin-right:auto; text-align:center; }
.section-head .eyebrow { color:var(--accent); }
/* h1 shares this with h2. tailor.html's header is image-only, so the page's
   real headline is the h1 in the first section - it needs the section-heading
   size, not .page-hero-content h1's 40-84px display size. margin-top:10px
   exists to sit under an eyebrow; harmless when the heading is alone. */
.section-head h1, .section-head h2 { font-family:var(--font-display); font-weight:var(--fw-display); font-size:clamp(30px,4.5vw,48px); text-transform:var(--tt-display); margin-top:10px; color:var(--fg); }
.section-head p { margin-top:16px; color:var(--muted); font-size:15.5px; line-height:1.65; }
.section p.body-text { font-size:16px; line-height:1.75; color:var(--fg); max-width:700px; }
.section p.body-text + p.body-text { margin-top:18px; }

/* ============ SPLIT (image + text) ============ */
.split { display:grid; grid-template-columns:1fr; gap:44px; align-items:center; }
@media (min-width:860px) { .split { grid-template-columns:1.05fr 1fr; } .split.reverse { grid-template-columns:1fr 1.05fr; } .split.reverse .split-media { order:2; } }
.split-media img { border-radius:var(--radius-card); width:100%; display:block; object-fit:cover; }
.split-text h2 { font-family:var(--font-display); font-weight:var(--fw-display); font-size:clamp(28px,4.2vw,42px); text-transform:var(--tt-display); color:var(--fg); margin-bottom:18px; }
.split-text p { font-size:15.5px; line-height:1.75; color:var(--muted); }
.split-text p + p { margin-top:14px; }

/* ============ PARALLAX (image + centered text over photo) ============ */
/* 2026-07-26: matches .hero exactly (100lvh / min-height:580px) - was
   84vh/540px, then briefly calc(100svh - 88px) to dodge the fixed nav, but
   that left a visible height mismatch against the hero ("weird in-betweeny
   stuff"). The nav overlays these slides the exact same transparent-then-
   solid way it already overlays the hero, so there's no need to subtract
   its height - full-viewport-to-full-viewport is the consistent version. */
/* 2026-07-26: briefly had clip-path:inset(0) added here to chase a dark-bleed
   report, but that turned out to make things worse - screenshots showed a
   visible grey bar appearing at this same boundary during scroll once
   clip-path was added, so it's been reverted. Kept overflow:hidden (the
   original, working approach) and added an explicit background:var(--bg)
   below as a defensive fallback instead - if a rendering gap ever reveals
   this section's own background for a frame, it should show the page's
   cream tone rather than an undefined/browser-default colour. */
/* height/padding-bottom must match .hero and .slide-photo exactly - see the
   long unit note on .hero. padding-bottom shrinks the flex content box to
   the visible height so .about-content centres against what's on screen
   rather than against the part hidden behind the URL bar. It does NOT move
   .parallax-img or .parallax-overlay, which are absolutely positioned and
   resolve against the padding box, so the photo still covers the whole
   slide. That is the intended split. */
.parallax-section { position:relative; height:100lvh; min-height:580px; overflow:hidden; background:var(--bg); display:flex; align-items:center; justify-content:center; padding-bottom:var(--chrome-gap); }
.parallax-section.short { height:56vh; min-height:400px; }
/* PARALLAX OVERSCAN - read the long comment above updateParallax() in
   site.js before changing this.
   The vertical inset (--parallax-inset, 0.25 = 25%) is the image's overhang
   beyond its frame, and it is the hard ceiling on how far the parallax can
   travel: site.js's PARALLAX_TRAVEL_PCT (0.20) must stay below it, and the
   5-point gap is deliberate headroom for sub-pixel rounding. Percentage
   insets resolve against the section's height, so the image box is 1.5x the
   section tall and background-size:cover zooms the photo to suit; that zoom
   is the cost of the effect and is why the earlier -38% attempt looked like
   the parallax had stopped.
   site.js sets BOTH transform and clip-path on this element every frame, so
   don't add either here - they will be overwritten. The clip is what carves
   the seam gap out of the photo's top edge; see .seam-fill below.
   Horizontal -6% is incidental - the JS only ever translates on Y. */
.parallax-img {
  position:absolute;
  inset:calc(var(--parallax-inset) * -100%) -6%;
  background-size:cover; background-position:center;
  will-change:transform;
}
/* SYMMETRIC SCRIM - the seam depends on this, don't revert it to a simple
   top-to-bottom ramp.
   This was .4 at the top down to .72 at the bottom. That put a slide's dark
   bottom edge directly against the next slide's light top edge, a 2x jump in
   brightness at every seam (a mid-tone pixel goes luminance 34 to 69 across
   it). That step was the "hard line between the two photos" - and no amount
   of blurring hides it, because blur smooths each side independently and
   leaves the discontinuity between them untouched.
   Matching both edges at .66 still matters now that a gap sits between the
   slides rather than a direct join: the gap's top edge (departing photo) and
   bottom edge (arriving photo) are both scrimmed at .66, so the two sides of
   the opening are the same darkness. An asymmetric ramp would make one lip
   of the gap visibly darker than the other. The lighter .5 midpoint keeps
   the photo from being flattened and still backs the centred text at roughly
   its previous value (mid was ~.56 before).
   Note this scrim also covers .seam-fill, which sits behind the photo - so
   the blurred fill is darkened by .66 too. That is why .seam-fill carries a
   brightness() boost; if the gap ever looks muddy, raise that rather than
   lightening this gradient, which would unbalance the gap's two lips.
   If these numbers change, keep the FIRST and LAST stops equal to each other
   and equal to .hero::after's last stop. */
.parallax-overlay {
  position:absolute; inset:0; z-index:1;
  background:linear-gradient(180deg, rgba(4,4,6,.66) 0%, rgba(4,4,6,.5) 50%, rgba(4,4,6,.66) 100%);
}

/* SEAM FILL - the blurred image behind the gap between two slides.
   site.js clips each photo's top edge to carve a gap that opens as a
   transition begins and closes as the slide centres; this layer sits behind
   the photo and is what you see through that gap. See the SEAM_BAND_PCT
   comment in site.js for the full history.
   The gap has HARD edges on both sides - the photo above ends crisply, the
   photo below begins crisply. That is deliberate and was Callum's explicit
   call after a version that blurred the photos into each other was rejected
   ("a bit crappy"). Only the FILL is blurred, never the slides themselves.
   Why blur an image rather than use a flat colour: a flat band read as
   jarring, a hard slab dropped between two photographs. A heavily blurred
   copy of a slide photo is the standard letterbox-blur fill used behind
   mismatched-aspect video and album art - it carries the photo's own colours,
   so the gap belongs to the images either side of it instead of interrupting
   them.
   THE INSET MUST MATCH .parallax-img EXACTLY. site.js slides this layer to
   put a chosen slice of the photo in the gap, and that maths assumes this box
   is the same size and position as the photo's, so the two frame the image
   identically. Changing one without the other puts the wrong slice in the
   gap. site.js sets transform here every frame, so don't set one in CSS.
   The overscan is doing real work beyond that: filter:blur() samples past an
   element's edges and fades to transparent there, so the negative inset keeps
   those soft edges outside the visible gap. Without it you get a pale halo. */
.seam-fill {
  position:absolute;
  inset:calc(var(--parallax-inset) * -100%) -10%;
  z-index:0; pointer-events:none;
  background-size:cover; background-position:center;
  filter:blur(54px) saturate(1.35) brightness(1.45);
}

.about-content { position:relative; z-index:2; max-width:700px; text-align:center; padding:0 24px; color:#fdfcf9; }
/* Slide eyebrows (About / Listen / The Big Night) run larger than the shared
   .eyebrow's 12px, 2026-07-26. Scoped to the slide contexts rather than
   changed on .eyebrow itself, because that class is also used on
   tailor.html section headings, where 12px is right against a cream
   background - these three sit over full-bleed photos and need more presence.
   The Listen slide's copy of this lives on .carousel-heading .eyebrow; keep
   the two in step. */
.about-content .eyebrow { color:var(--accent2); font-size:clamp(13px,1.5vw,15px); }
.about-content h2 { font-family:var(--font-display); font-weight:var(--fw-display); font-size:clamp(32px,5vw,54px); text-transform:var(--tt-display); margin:14px 0 22px; }
.about-content p { font-size:16.5px; line-height:1.75; color:rgba(255,255,255,.9); }
/* NOTE, 2026-08-02: there is deliberately NO inter-paragraph rule here. The
   About copy went in as two paragraphs and briefly had `.about-content p + p
   { margin-top:16px }`, then Callum merged them into one the same day because
   two blocks read as an afterthought bolted on. Every slide using this class
   now has exactly one <p> again, so the rule was dead code and was removed.
   IF A SECOND PARAGRAPH EVER COMES BACK it will have zero spacing until that
   rule is restored - and use `p + p`, not a bottom margin on `p`, or the
   trailing margin shifts the whole flex-centred stack up and eats the
   clearance the scroll cue needs below it. */

/* SHORT-VIEWPORT TIGHTENING FOR #about ONLY (2026-08-02).
   The About copy roughly doubled in length and broke this slide on 667px-tall
   iPhones (SE 2nd/3rd gen, iPhone 8). Measured in a browser against the live
   CSS and real fonts, not estimated:

     .parallax-section is 100lvh with padding-bottom:var(--chrome-gap), and
     --chrome-gap is 100lvh - 100svh, so the flex content box is exactly
     100svh - the viewport WITH browser chrome showing. .about-content is
     centred in it, so clearance top and bottom is (100svh - blockHeight) / 2.

     Clearance has to beat TWO things: the scrolled nav, about 52px, at the
     top, or the "About" eyebrow lands under the nav bar; and the scroll cue
     at the bottom, 26px + a 12px chevron + roughly 17px of bounce, so about
     43px, or the chevron overlaps the last line of the paragraph.

     iPhone SE, 375 wide, svh ~553:  old copy 362px -> 95px   fine
                                     new copy 449px -> 52px   ON THE LIMIT
                                     tightened 354px -> 100px fine

   THE 640px THRESHOLD IS LOAD-BEARING AND WAS WRONG ONCE. It was first
   written as 740px, which is broken in the other direction: Safari reports
   the SMALL viewport (chrome showing) to a height media query, so an iPhone
   14 reports about 716px and would have been caught by a 740px query and had
   its type shrunk for no reason. It has 148px of clearance and needs nothing.
   640px catches the SE class (~553) and clears the 13 mini (~684) and 14
   (~716). If you change it, check it against the reported height WITH chrome
   showing, not the device's spec sheet height.

   A HEIGHT query, not a width one - the problem is vertical, and triggering
   off width shrinks the type on tall phones that have plenty of room.

   Scoped to #about rather than .about-content because #packages uses the same
   class with one short paragraph and an absolutely positioned CTA stack. It
   has no height problem and does not need its type changed.

   Not chased: 320px-wide phones (iPhone SE 1st gen and older) are still tight
   even tightened. The smallest iPhone in circulation is 375. Accepted.

   If this copy grows again, RE-MEASURE. Untightened it is already at 52px
   against a 52px requirement on an SE; the tightening is what is buying the
   headroom, and there is not a second helping of it. */
@media (max-width:820px) and (max-height:640px) {
  #about .about-content .eyebrow { margin-bottom:12px; }
  #about .about-content h2 { font-size:28px; margin:10px 0 14px; }
  #about .about-content p { font-size:15.5px; line-height:1.6; }
}
.tag-row { margin-top:30px; display:flex; flex-wrap:wrap; gap:10px; justify-content:center; }
.tag-row span { border:1px solid rgba(255,255,255,.45); color:#fff; font-size:11.5px; letter-spacing:.1em; text-transform:uppercase; padding:9px 17px; border-radius:999px; }
.about-link { display:inline-block; margin-top:30px; font-size:13px; font-weight:700; letter-spacing:.06em; text-transform:uppercase; color:#fff; border-bottom:1px solid var(--accent2); padding-bottom:3px; }

/* Bottom-middle stack on the #packages "Your Big Night" panel - originally
   an icon row (Instagram, Spotify) above the CTA button; Instagram moved to
   the About slide 2026-07-26 and the Spotify placeholder was dropped
   entirely 2026-07-27, leaving just the button. Lifted from bottom:56px to
   104px the same day so it doesn't sit right at the bottom edge now that
   there's no icon row above it filling that space. */
/* bottom:18%, not a fixed px value (2026-07-27, was 104px). The button should
   sit midway between the bottom of .about-content's text and the bottom of
   the screen, and .about-content is flex-centred in a 100lvh slide (minus --chrome-gap) - so the
   gap it has to halve grows and shrinks with the viewport, which a fixed
   104px can't track. On a 900px viewport the ideal is 161px and 18% gives
   162px; across 700-1080px it stays within ~15px of ideal, where 104px was
   57px out at 900px.

   The percentage is only right while the text block is roughly 254px tall
   (eyebrow + a two-line h2 + a two-line paragraph). If that copy grows or
   shrinks by a line, re-derive it - the target is the midpoint, not 18%. */
/* bottom:18% re-based onto the visible height (+ 0.82 * gap, i.e. 1 - 0.18)
   - see the note on .player-hero-play. Without it this stack sits under the
   URL bar on a phone, and it holds the Packages slide's CTAs, so that is the
   single worst element on the page to lose below the fold. */
.slide-cta-stack {
  position:absolute; left:50%; bottom:calc(18% + 0.82 * var(--chrome-gap)); transform:translateX(-50%); z-index:2;
  display:flex; flex-direction:column; align-items:center; gap:26px;
}
.social-icon-link {
  display:flex; flex-direction:column; align-items:center; gap:10px;
  text-decoration:none;
}
.social-icon-link svg {
  width:60px; height:60px; border-radius:50%; padding:16px; box-sizing:border-box; display:block;
  border:1.5px solid rgba(255,255,255,.55); background:rgba(6,6,8,.35); color:#fff;
  transition:background .15s ease, border-color .15s ease;
}
.social-icon-link:hover svg { background:rgba(6,6,8,.55); border-color:#fff; }
/* Icon-only variant, 2026-07-26: the Instagram link moved from the
   What We Offer slide's CTA stack onto the About slide and lost its "Gallery"
   caption, so it's now just the glass circle. .social-icon-link is a column
   flex with a 10px gap for the label underneath - with no label that gap
   would still add stray space below, hence gap:0. inline-flex so it shrinks
   to the icon instead of spanning the centred text column, and the top
   margin gives it room under the paragraph. */
/* .icon-only is currently UNUSED (2026-08-02). The About slide's bare Instagram
   circle was the only thing wearing it and that moved to the .quote-section
   slot, captioned, so it takes .on-light below instead. Kept rather than
   deleted because a labelless icon over a photo is the obvious thing to want
   again, and the gap:0 detail is the non-obvious part. */
.social-icon-link.icon-only { display:inline-flex; gap:0; margin-top:30px; }
/* .on-light, reinstated 2026-08-02. It existed once before for a large Spotify
   icon on tailor.html, went unused within the day and was deleted, leaving the
   warning that is now the spec for this block: everything above assumes a dark
   full-bleed photo behind it. The translucent white border and dark glass fill
   are invisible and muddy on cream, so on a light background the icon takes the
   card palette instead, and .social-icon-label's near-white has to be overridden
   or the caption disappears. */
.social-icon-link.on-light svg {
  border-color:var(--card-border); background:var(--card-bg); color:var(--accent);
}
.social-icon-link.on-light:hover svg { background:var(--accent); border-color:var(--accent); color:var(--card-bg); }
.social-icon-link.on-light .social-icon-label { color:var(--accent); opacity:1; }
.social-icon-label {
  font-family:var(--font-display); font-style:italic; font-size:24px;
  color:#fdfcf9; opacity:.92; letter-spacing:var(--ls-display);
}
@media (max-width:640px) {
  /* 2026-07-26: was bottom:36px. Every slide now carries a .scroll-cue at
     bottom:26px, and the chevron's animated bounce reaches roughly 43px up,
     so 36px put the CTA stack straight on top of it. 58px clears it with a
     usable gap. If the cue's position or size changes, re-check this. */
  /* No bottom override any more - the base 18% works out at ~126px here and
     the text block shrinks proportionally, so the midpoint lands correctly
     without a separate value. Still clears the scroll cue's bounce (~43px)
     with room to spare. Only the gap is tightened. */
  .slide-cta-stack { gap:18px; }
  .social-icon-link svg { width:48px; height:48px; padding:13px; }
  .social-icon-label { font-size:19px; }
}

/* Persistent heading overlaid on the Listen carousel (#listen) - unlike the
   per-slide title (which changes with the active slide), this sits fixed at
   the top of the frame regardless of which of the 6 slides is showing. */
/* 2026-07-26: was pinned near the top (top:32px); moved to vertical-center
   to match how the About/Packages slides position their eyebrow+heading
   (.about-content, centred via its parent's flex). The play button was
   pushed down below centre (see .player-hero-play) so the two don't overlap. */
/* 2026-07-26: the heading used to be vertically centred and the play button
   was pushed DOWN to 66% to clear it. Callum wanted the button dead centre
   instead, so this is now anchored by its BOTTOM edge just above the middle.
   Anchoring the bottom rather than setting a top percentage matters: the
   heading wraps to two or three lines on narrow screens, and a bottom anchor
   grows upward, so it can never creep down into the button however long the
   copy gets. */
/* 2026-07-27: 48px offset above centre eased to 24px, then to 8px, chasing
   the gap to the play button - but that also dragged the eyebrow down with
   it (same flow block, eyebrow directly above h2), until it sat well below
   where About's eyebrow sits. Fix: the eyebrow's own margin-bottom (10px ->
   48px) now does the work of separating it from the heading, so the two can
   be tuned independently - eyebrow position no longer rides on wherever h2
   needs to land. h2 stays bottom-anchored (wrap-grows-upward safety intact),
   now at calc(50% + 26px), which centres it between the eyebrow (matching
   About's eyebrow height) and the play button (top:55%) - nudged up from
   15px same day, still slightly low. */
/* The 50% is re-based onto the visible height with + 0.5 * --chrome-gap -
   see the note on .player-hero-play for the derivation. Without it this
   block, and the play button below it, drift apart on a phone: the button
   compensates and this would not, so the 26px gap between them would open
   up by half the chrome height. They have to move together. */
.carousel-heading {
  position:absolute; bottom:calc(50% + 26px + 0.5 * var(--chrome-gap)); left:0; right:0; z-index:5;
  text-align:center; padding:0 24px; pointer-events:none;
}
/* Matches .about-content .eyebrow - see the note there. */
.carousel-heading .eyebrow {
  color:var(--accent2); font-size:clamp(13px,1.5vw,15px); letter-spacing:.2em; text-transform:uppercase;
  font-weight:700; margin-bottom:48px; text-shadow:0 1px 6px rgba(0,0,0,.4);
}
.carousel-heading h2 {
  font-family:var(--font-display); font-weight:var(--fw-display); font-size:clamp(28px,4.5vw,46px);
  color:#fdfcf9; text-shadow:0 2px 10px rgba(0,0,0,.5); text-transform:var(--tt-display);
}
/* Added 2026-07-27: h2 reverted to "Chemistry you can hear", with the old
   heading text ("Press play to hear live recordings") demoted to this
   smaller line underneath - same idea as .about-content p, sized down and
   with a text-shadow added since this sits directly on a photo rather than
   the About slide's darker overlay. .carousel-heading's own bottom-anchor
   wasn't retuned for the extra line - it still anchors by the LAST child
   (now this p, not h2), which keeps the same wrap-grows-upward safety
   against the play button; the block as a whole just sits a little higher
   as a result. Revisit spacing if that reads oddly. */
.carousel-heading p {
  font-size:16px; line-height:1.6; color:rgba(255,255,255,.9);
  text-shadow:0 1px 6px rgba(0,0,0,.4); margin-top:14px;
}
@media (max-width:640px) {
  .carousel-heading h2 { font-size:clamp(24px,7vw,34px); }
}

/* ============ GALLERY SCROLLER (auto-scroll ribbon) ============ */
.gallery-outer { width:100vw; margin-left:calc(50% - 50vw); overflow:hidden; padding:6px 0; cursor:grab; }
.gallery-outer.dragging { cursor:grabbing; }
.gallery-track { display:flex; gap:18px; width:max-content; padding:0 clamp(20px,4vw,48px); animation:galleryScroll 110s linear infinite; will-change:transform; }
.gallery-track img { height:380px; width:auto; border-radius:var(--radius-card); object-fit:cover; flex-shrink:0; -webkit-user-drag:none; user-select:none; pointer-events:none; }
@keyframes galleryScroll { from{ transform:translateX(0); } to{ transform:translateX(-50%); } }
@media (max-width:640px) { .gallery-track img { height:260px; } }
.gallery-cta { margin-top:8px; padding:0 clamp(20px,4vw,48px); max-width:1240px; margin-left:auto; margin-right:auto; }
.gallery-toggle {
  display:flex; align-items:center; gap:8px; margin:14px clamp(20px,4vw,48px) 0;
  border:1px solid var(--card-border); background:var(--card-bg); color:var(--fg);
  padding:8px 16px; border-radius:999px; font-size:12px; font-weight:700; letter-spacing:.06em;
  text-transform:uppercase; cursor:pointer; width:fit-content;
}
.gallery-toggle:hover { border-color:var(--accent); color:var(--accent); }

/* ============ CHIP ROW (genre/style tags on light backgrounds) ============ */
.chip-row { display:flex; flex-wrap:wrap; gap:10px; justify-content:center; margin-top:26px; }
.chip-row span { border:1px solid var(--card-border); color:var(--accent); font-size:11.5px; letter-spacing:.1em; text-transform:uppercase; padding:9px 17px; border-radius:999px; background:var(--card-bg); }

.listen-section { padding-top:36px; }

/* ============ SLIDESHOW (Listen page — photo + audio per slide) ============ */
/* Full-bleed breakout: escapes the .section's max-width/padding so the frame
   runs edge-to-edge of the viewport regardless of where it's nested, without
   having to touch the section's own padding (the CTA link below it stays
   normally contained). */
.slideshow {
  position:relative; width:100vw; left:50%; right:50%;
  margin-left:-50vw; margin-right:-50vw; max-width:100vw;
}
.slide { display:none; }
.slide.active { display:block; }
/* Big, near-full-height frame instead of a narrow centred card - the photo
   is the page here, not a thumbnail floating in a box.
   2026-07-26: dropped the earlier blurred-backdrop-layer + object-fit:contain
   + mask-fade approach (it kept reading as a hard cut no matter how the
   scale/mask stops were tuned). Simplified to plain object-fit:cover, same
   technique the About/Packages parallax slides already use - the photo just
   fills the whole frame edge-to-edge, so there's no letterboxing gap left to
   blur/fade/disguise in the first place.
   2026-07-26 (later same day): the photo itself is now a .parallax-img
   background-div (data-bg), not a plain <img> - matching
   About/Packages exactly, so this slide gets the same scroll-linked
   parallax drift they do (a real <img> can't reliably size itself off the
   inset:-12% -6% overscan trick the way a background-image div can, which
   is why the rest of the site already used divs for this). No extra CSS
   needed here - .parallax-img and the JS parallax loop are already generic. */
/* Height matches .parallax-section and .hero exactly (100lvh / min-height:
   580px, 2026-07-26) so this carousel panel, the About panel and the
   What-We-Offer panel all read as the same full-viewport size in the
   sequence - see the note above .parallax-section for why this dropped the
   nav-height subtraction it briefly had. */
/* background was #000 (black) - a leftover from the old letterboxing setup.
   .parallax-section (About/Packages) has no explicit background of its own,
   so it shows the page's cream --bg through any parallax overscan gaps;
   .slide-photo needs the same var(--bg) so Listen matches instead of
   flashing black at the edges during scroll. */
/* height must match .hero and .parallax-section exactly - see the long unit
   note on .hero. No padding-bottom here, unlike those two: every child of
   this slide is absolutely positioned, and padding has no effect on where a
   percentage offset lands. The Listen controls each subtract their own share
   of --chrome-gap instead; see .player-hero-play below. */
.slide-photo {
  position:relative; width:100%; height:100lvh; min-height:580px;
  overflow:hidden; background:var(--bg);
}
.slide-badge {
  position:absolute; top:20px; left:20px; z-index:3; background:rgba(6,6,8,.55); color:#fdfcf9;
  font-size:10.5px; letter-spacing:.12em; text-transform:uppercase; padding:7px 13px; border-radius:999px;
}

/* 2026-07-26: was a 4-stop gradient with a fully clear middle band (dark top,
   clear centre, dark bottom) - a leftover from the old carousel design and
   the only slide overlay on the page not using the plain top-to-bottom
   two-stop scrim (.parallax-overlay, used on About/Packages). Standard
   practice for a photo-with-text overlay is a single consistent gradient,
   not a banded one, so this matches .parallax-overlay's values exactly for a
   uniform look across all three slide transitions. Still pointer-events:none
   and still named .player-vignette since the class is referenced from the
   HTML/JS as-is.
   2026-07-26 (later): tracks .parallax-overlay's switch to a SYMMETRIC
   scrim - see the long note there for why the edges must match. */
.player-vignette {
  position:absolute; inset:0; z-index:1; pointer-events:none;
  background:linear-gradient(180deg, rgba(4,4,6,.66) 0%, rgba(4,4,6,.5) 50%, rgba(4,4,6,.66) 100%);
}
/* 2026-07-28: was `display:contents`, which let the play button and the title
   position themselves independently against .slide-photo. That existed back
   when the play icon hid itself after first press and could have reflowed the
   title. It stopped being true when the icon became a persistent toggle, and
   it stops working entirely now there are three buttons in a row: contents
   gives them no shared box to be centred within, so each would need its own
   hand-tuned left offset and they'd drift apart at different viewport widths.
   A real flex row centres them against each other for free.

   THE 55% AND ITS --chrome-gap COMPENSATION MOVED HERE, unchanged, from
   .player-hero-play. Both notes that used to live there apply to this rule
   now, because this is the element carrying the offset:

   1. top:55%, NOT 50% - optical centring, not a mistake, so don't "correct"
      it. The slide's true midpoint is 50% and the controls were set there,
      but they read as sitting high. Not a layout error (.slideshow and
      .slide-photo are both exactly 100lvh, so their midpoints do coincide):
      the composition is top-heavy, with an eyebrow and a heading above and
      nothing below until the seek bar appears. The eye balances a group, so
      a geometrically centred element in that arrangement looks too high.
      55% is the compromise between the original 66% (clearly low once the
      heading moved above it) and a true 50%. This is the value to adjust if
      the heading's length or position changes again.

   2. The percentage offsets on this slide resolve against .slide-photo's
      padding box, which is the full 100lvh. On a phone with the URL bar
      showing, the bottom --chrome-gap pixels of that box are off-screen, so
      a raw 55% sits low and the seek bar can fall off the screen entirely.
      Each offset subtracts its OWN share of the gap to re-base onto the
      visible height:
        top:P%    -> calc(P% - P/100 * gap)
        bottom:P% -> calc(P% + (1 - P/100) * gap)
      Keep the multiplier in step with the percentage if either is retuned -
      55/0.55 here, 33/0.67 on .player-title, 24/0.76 on .player-seek-wrap,
      50/0.5 on .carousel-heading - or the element drifts off its intended
      line and, worse, drifts relative to the others, since all four are
      positioned as one stack. All of it collapses to the plain percentages
      on desktop, where --chrome-gap is 0. */
.player-center {
  position:absolute; top:calc(55% - 0.55 * var(--chrome-gap)); left:50%;
  transform:translate(-50%, -50%); z-index:2;
  display:flex; align-items:center; justify-content:center;
  gap:clamp(20px,4vw,38px);
}
/* Skip buttons. Hidden until the first press, same as the seek bar - there is
   nothing to skip through before playback has started, and three controls
   sitting over the photo on arrival would compete with the heading.
   44px box around a ~26px glyph: the icon alone is under WCAG 2.5.5's target
   size, and these sit either side of a much larger play triangle where a
   small mis-tap is likely. Same trick as .setlist-row on tailor.html. */
.player-skip {
  width:44px; height:44px; flex-shrink:0; padding:0;
  border:none; background:transparent; color:#fff; cursor:pointer;
  display:flex; align-items:center; justify-content:center;
  filter:drop-shadow(0 3px 12px rgba(0,0,0,.6));
  opacity:0; visibility:hidden; pointer-events:none;
  transition:opacity .25s ease, visibility 0s linear .25s;
}
.player-skip.visible {
  opacity:.78; visibility:visible; pointer-events:auto;
  transition:opacity .25s ease, visibility 0s linear 0s;
}
.player-skip.visible:hover { opacity:1; }
.player-skip svg { width:26px; height:26px; display:block; }
/* Next is disabled on the last song rather than wrapping round to the first.
   Kept visible-but-dimmed instead of hidden so the row doesn't reflow and
   shift the play button sideways at the end of the showreel. */
.player-skip:disabled { cursor:default; }
.player-skip.visible:disabled, .player-skip.visible:disabled:hover { opacity:.3; }
/* Hero play icon: no button chrome at all, just the triangle with a slight
   drop shadow for legibility. 2026-07-26: now a persistent play/pause
   control (was a one-shot "start" trigger that hid itself for good after
   first click) - it swaps its own icon and stays put, see .playing below.
   Also 2026-07-26: pushed down below true-centre (was top:50%) so it clears
   the eyebrow+heading text, which now sits vertically centred to match the
   other slides (see .carousel-heading); and made more transparent by
   default (was fully opaque) so it reads as a lighter-touch overlay control. */
/* No button chrome - just the triangle, lifted off the photo by a drop
   shadow. A 116px ring was tried on 2026-07-27 and rejected on sight
   ("tacky"), so don't reach for a circle, border or filled background here
   again. The shadow is doing the same job of separating the icon from a busy
   photo, without adding a shape that competes with it. */
/* Positioning moved to .player-center on 2026-07-28 - this is now a static
   flex item in that row, not an absolutely positioned element. */
.player-hero-play {
  /* FIXED BOX, added with the skip buttons. The two icons inside are not the
     same width - the play triangle is 42px plus a 9px optical-centring margin
     (51px), the pause bars are 52px - so a content-sized button changes width
     by 1px when it toggles. On its own that was invisible. In a centred flex
     row it nudges both skip buttons sideways by half a pixel every time you
     press play, which is exactly the sort of thing that reads as a wobble
     without being identifiable. A fixed box absorbs both icons and the
     margin, so the row is dimensionally identical in either state.
     If either icon is resized, check it still fits inside this. */
  width:76px; height:56px; flex-shrink:0;
  border:none; background:transparent; padding:0; cursor:pointer;
  display:flex; align-items:center; justify-content:center;
  filter:drop-shadow(0 3px 12px rgba(0,0,0,.6)); opacity:.78; transition:opacity .2s ease;
}
/* Opacities raised across the board 2026-07-26 (were .5 / .65 / .85 / .9).
   The relationship is unchanged - playing reads slightly stronger than
   paused, hover stronger again - just less see-through overall. */
.player-hero-play:hover { opacity:.95; }
.player-hero-play.playing { opacity:.86; }
.player-hero-play.playing:hover { opacity:1; }
.player-hero-play[hidden] { display:none; }
.player-hero-play .icon-play-bare {
  width:0; height:0; border-top:26px solid transparent; border-bottom:26px solid transparent;
  border-left:42px solid #fff; margin-left:9px;
}
.player-hero-play.playing .icon-play-bare { display:none; }
.player-hero-play .icon-pause-bare { display:none; align-items:center; justify-content:center; gap:11px; width:52px; height:52px; }
.player-hero-play.playing .icon-pause-bare { display:flex; }
.player-hero-play .icon-pause-bare i { display:block; width:9px; height:52px; background:#fff; border-radius:2px; }
/* NOW-PLAYING LINE (2026-07-28). This element used to be visually hidden and
   existed only to hand site.js a string ("Showreel") for the play button's
   aria-label - the rule here was dead, overridden by an inline style. It is
   a real visible element now, so this is a rewrite rather than a tweak, and
   the old 34-64px display sizing is gone: that was sized to be the slide's
   headline, and this is a caption sitting in a ~100px gap.

   bottom:33%, i.e. the midpoint of the space between the play row and the
   seek bar. Derived, not picked: the row is centred at 55% and is 56px tall,
   so its lower edge sits at 55% + 28px; the seek bar's top is at bottom:24%.
   Halving what's left lands at bottom ~33%, and because both neighbours are
   percentage-positioned it holds its place as the viewport changes - checked
   at the 580px min-height and at 1080px, where it clears both by 25px+.
   The 0.67 multiplier is the --chrome-gap re-basing; see .player-center.

   Display italic in mustard deliberately matches .hero-tagline rather than
   the 12px uppercase .eyebrow. Uppercasing would flatten "yes, and?" into
   "YES, AND?" and lose the stylised lowercase the track is actually titled
   with - song names are quotations, so they keep their own casing. */
.player-title {
  position:absolute; left:0; right:0; z-index:2;
  bottom:calc(33% + 0.67 * var(--chrome-gap));
  text-align:center; padding:0 24px;
  font-family:var(--font-display); font-weight:var(--fw-display); font-style:italic;
  font-size:clamp(17px,2.4vw,24px); line-height:1.3;
  color:var(--accent2); letter-spacing:var(--ls-display);
  text-shadow:0 2px 10px rgba(0,0,0,.55);
  /* Hidden until first press, in step with the skip buttons and seek bar -
     naming a song nobody has started listening to is just clutter. */
  opacity:0; visibility:hidden; pointer-events:none;
  transition:opacity .25s ease, visibility 0s linear .25s;
}
.player-title.visible {
  opacity:1; visibility:visible;
  transition:opacity .25s ease, visibility 0s linear 0s;
}
/* Lifted off the bottom edge 2026-07-27 - was bottom:0, which sat the seek
   bar hard against the bottom of the screen. 20% puts it roughly midway
   between the play button's lower edge and the bottom of the frame, and
   holds that relationship across viewport heights: the button is centred at
   55% with a fixed-px circle, so on both a 900px desktop and a 700px phone
   the gap above and below the controls comes out within ~15px of equal.

   Inset each side 2026-07-27 - full width read as odd for a control this
   small. First tried at 30% (a 40%-wide bar), then widened by a third to
   24% (52% wide) because 40% was over-corrected, especially on narrower
   desktop windows where it left a very short track.
   THE SCRIM WENT WITH IT, and had to. It was originally a
   bottom-anchored gradient, which only works flush to the bottom edge;
   floated up the page it ends in a hard horizontal line across the photo. A
   symmetric band fixed that, but once the element is only 40% wide the same
   band becomes a floating rectangle with hard LEFT and RIGHT edges instead.
   There is no gradient that survives being both raised and narrowed, so the
   controls now carry their own drop shadow and sit on .player-vignette's
   existing darkening (~.6 at this height) rather than a box of their own.
   That also matches the play button, which had a ring tried and rejected on
   the same day. Don't reintroduce a background here. */
.player-seek-wrap {
  /* bottom:24% re-based onto the visible height - see the note on
     .player-hero-play. This one is measured from the bottom, so the gap is
     ADDED (0.76, i.e. 1 - 0.24), not subtracted. */
  position:absolute; left:24%; right:24%; bottom:calc(24% + 0.76 * var(--chrome-gap)); z-index:2;
  display:flex; align-items:center; gap:14px;
  padding:0;
  filter:drop-shadow(0 2px 8px rgba(0,0,0,.6));
  opacity:0; visibility:hidden; pointer-events:none;
  transition:opacity .25s ease, visibility 0s linear .25s;
}
.player-seek-wrap.visible {
  opacity:1; visibility:visible; pointer-events:auto;
  transition:opacity .25s ease, visibility 0s linear 0s;
}
.player-seek { flex:1; min-width:0; }
.player-seek, .player-volume {
  -webkit-appearance:none; appearance:none; height:4px; border-radius:999px;
  background:rgba(255,255,255,.35); accent-color:var(--accent2); cursor:pointer; display:block; margin:0;
}
.player-seek::-webkit-slider-runnable-track, .player-volume::-webkit-slider-runnable-track { height:4px; border-radius:999px; background:rgba(255,255,255,.35); }
.player-seek::-webkit-slider-thumb, .player-volume::-webkit-slider-thumb {
  -webkit-appearance:none; appearance:none; width:14px; height:14px; border-radius:50%;
  background:#fdfcf9; border:none; margin-top:-5px; cursor:pointer;
}
.player-seek::-moz-range-track, .player-volume::-moz-range-track { height:4px; border-radius:999px; background:rgba(255,255,255,.35); }
.player-seek::-moz-range-thumb, .player-volume::-moz-range-thumb { width:14px; height:14px; border-radius:50%; background:#fdfcf9; border:none; cursor:pointer; }
.player-mute {
  flex-shrink:0; width:32px; height:32px; padding:0; border:none; background:transparent;
  color:#fff; opacity:.85; cursor:pointer; display:flex; align-items:center; justify-content:center;
}
.player-mute:hover { opacity:1; }
.player-mute svg { width:18px; height:18px; display:block; fill:currentColor; }
.player-mute .icon-mute { display:none; }
.player-mute.muted .icon-vol { display:none; }
.player-mute.muted .icon-mute { display:block; }
.player-volume { flex-shrink:0; width:76px; }
/* Volume SLIDER is desktop-only. Two reasons, and the second is the
   load-bearing one: phones and tablets have hardware volume keys that do this
   job better, and iOS ignores HTMLMediaElement.volume outright - it's
   read-only there - so on iPhone/iPad the slider isn't merely redundant, it's
   a dead control that visibly moves and changes nothing.

   The mute button deliberately STAYS on touch: audio.muted does work on iOS,
   and one tap to silence is a different affordance from holding a hardware
   key down. Don't fold it into this rule without checking that's wanted.

   Matched on pointer, not width, to match how site.js picks its scroll
   mechanism - so a large tablet is still treated as touch, and a narrow
   desktop window still gets the slider. */
@media (pointer: coarse) {
  .player-volume { display:none; }
}
@media (max-width:640px) {
  .player-hero-play .icon-play-bare { border-top-width:18px; border-bottom-width:18px; border-left-width:29px; }
  .player-hero-play .icon-pause-bare { width:36px; height:36px; gap:8px; }
  .player-hero-play .icon-pause-bare i { width:6px; height:36px; }
  /* Box shrinks with the icons it contains - 29px triangle + 9px optical
     margin = 38px, pause is 36px, so 52 clears both with room. See the note
     on .player-hero-play for why this is fixed rather than content-sized. */
  .player-hero-play { width:52px; height:40px; }
  /* The GLYPH shrinks; the 44px button does NOT. That is the whole point of
     the separation - 44px is the touch-target floor, and this is the one
     breakpoint where every visitor is using a finger. Row comes to ~180px
     total at the 20px minimum gap, which fits a 320px screen. */
  .player-skip svg { width:22px; height:22px; }
  /* 24% either side is a desktop measurement. On a 400px phone it would
     leave the seek bar about 200px wide, with a mute button inside it -
     cramped. Phones get a near-full-width bar instead. */
  .player-seek-wrap { left:8%; right:8%; gap:10px; }
  .player-volume { width:52px; }
}

/* Nav arrows now sit inset over the photo itself (the frame runs full-bleed,
   so there's no page margin outside it to float them in anymore). Dark
   translucent chrome keeps them legible against any photo. */
.slide-nav-btn {
  position:absolute; top:50%; transform:translateY(-50%); z-index:3;
  background:rgba(6,6,8,.4); border:1.5px solid rgba(255,255,255,.5); color:#fff;
  width:48px; height:48px; border-radius:50%; font-size:20px; cursor:pointer;
  display:flex; align-items:center; justify-content:center;
  backdrop-filter:blur(4px); transition:background .15s ease, border-color .15s ease;
}
.slide-nav-btn:hover { background:rgba(6,6,8,.6); border-color:#fff; }
.slide-prev { left:20px; }
.slide-next { right:20px; }
@media (max-width:640px) {
  .slide-nav-btn { width:40px; height:40px; }
  .slide-prev { left:10px; } .slide-next { right:10px; }
}
.slide-dots { display:flex; justify-content:center; gap:4px; margin-top:22px; }
/* Each dot's clickable button is a full 24px touch target (WCAG 2.5.8); the
   visible dot inside it stays small via ::after so the row doesn't look
   oversized. */
.slide-dots button {
  width:24px; height:24px; border-radius:50%; background:none; border:none; cursor:pointer;
  padding:0; position:relative; display:flex; align-items:center; justify-content:center;
}
.slide-dots button::after {
  content:''; width:8px; height:8px; border-radius:50%; background:var(--card-border); opacity:.4;
  transition:opacity .15s ease, transform .15s ease;
}
.slide-dots button:hover::after { opacity:.7; }
.slide-dots button.active::after { background:var(--accent); opacity:1; transform:scale(1.2); }

/* ============ FULL PHOTO GRID (gallery subpage) ============ */
.photo-grid { columns:3 260px; column-gap:16px; padding:0 clamp(20px,4vw,48px); max-width:1400px; margin:0 auto; }
.photo-grid img { width:100%; border-radius:var(--radius-card); margin-bottom:16px; break-inside:avoid; display:block; }
@media (max-width:700px) { .photo-grid { columns:2 160px; } }

/* ============ CARD GRID (services / repertoire / packages) ============ */
.services-grid, .card-grid { display:grid; grid-template-columns:repeat(auto-fit,minmax(250px,1fr)); gap:22px; margin-top:8px; }
.service-card, .info-card { background:var(--card-bg); border:1px solid var(--card-border); border-radius:var(--radius-card); padding:30px 26px; }
.service-card h3, .info-card h3 { font-family:var(--font-display); font-weight:var(--fw-display); font-size:19px; margin-bottom:12px; text-transform:var(--tt-display); color:var(--fg); }
.service-card p, .info-card p { font-size:14px; line-height:1.65; color:var(--muted); }

/* Group label above a card grid (tailor.html: "Standard Booking",
   "Customise Your Evening"). Sized deliberately between .section-head h2
   (clamp 30-48px) and a card's own 19px title, so it subdivides the section
   without shouting over it or getting lost among the cards. The margin pair
   below does the spacing: grids sit tight under their own label, with a wide
   gap before the next label starts a new group.

   These are <h2> and the cards under them are <h3>. That was briefly h3/h4,
   when this section still opened with a .section-head h2 ("Put together the
   right sound for your day"). That intro was removed 2026-07-27, which left
   the page jumping h1 -> h3, so everything moved up a level. If a section
   heading is ever reinstated above these, they need to go back to h3/h4. */
.group-head {
  font-family:var(--font-display); font-weight:var(--fw-display);
  font-size:clamp(20px,2.6vw,26px); text-transform:var(--tt-display);
  color:var(--fg); margin-bottom:18px;
}
.services-grid + .group-head, .card-grid + .group-head { margin-top:52px; }

/* ============ SETLIST ROWS (tailor.html) ============ */
/* Name on the left, Spotify glyph on the right, one row per playlist.
   The whole row is the anchor, not just the glyph: at 26px the icon alone
   falls under WCAG 2.5.5's target-size guidance and is fiddly on a phone,
   while the row's 18px vertical padding gives roughly a 60px target for an
   identical visual result.

   Hairlines are drawn with border-bottom on every row plus border-top on the
   first, rather than a border on the <li>, so the rules line up with the
   padded link box and hover repaints cleanly against them. The 640px cap
   matches .section-head's measure so the list sits under the intro copy
   rather than running the full 1240px section width - four short names
   stretched that wide would leave the glyph stranded far from its label. */
.setlist-list { list-style:none; max-width:640px; margin-top:8px; }
.setlist-row {
  display:flex; align-items:center; justify-content:space-between; gap:20px;
  padding:18px 4px; text-decoration:none; color:var(--fg);
  border-bottom:1px solid var(--card-border);
  transition:color .15s ease;
}
.setlist-list li:first-child .setlist-row { border-top:1px solid var(--card-border); }
.setlist-name {
  font-family:var(--font-display); font-weight:var(--fw-display);
  font-size:clamp(17px,2vw,20px); text-transform:var(--tt-display);
  letter-spacing:var(--ls-display);
}
.setlist-row svg { width:26px; height:26px; flex-shrink:0; color:var(--muted); transition:color .15s ease; }
.setlist-row:hover, .setlist-row:focus-visible { color:var(--accent); }
.setlist-row:hover svg, .setlist-row:focus-visible svg { color:var(--accent); }
.services-cta { margin-top:38px; }
.link-arrow { color:var(--accent); font-weight:700; font-size:13px; letter-spacing:.05em; text-transform:uppercase; border-bottom:1px solid var(--accent); padding-bottom:3px; }

/* ============ QUOTE ============ */
/* 2026-07-26: vertical padding was a flat 110px, which made this the tallest
   block on the page for two lines of quote. Now scales with the viewport,
   same treatment as .cta-final below. The horizontal padding is unchanged -
   the 840px max-width is what sets the measure, and narrowing that would push
   the quote onto more lines and undo the height saving. */
.quote-section { padding:clamp(44px,6vw,64px) clamp(20px,4vw,48px); text-align:center; max-width:840px; margin:0 auto; }
/* Extra headroom on touch, and ONLY on touch, because this is the one place
   the section gets snapped flush to the top of the viewport (2026-07-28).
   The nav is fixed and by this point in the page it is in its .scrolled
   state with a solid cream background - roughly 52px tall on a phone
   (14px padding, a ~23px logo, 14px padding, 1px border). The base
   padding-top bottoms out at 44px on a narrow screen, so the first line of
   the quote would sit about 8px UNDER the nav bar at the exact moment the
   snap delivers you there.
   76px clears it with ~24px to spare. Scoped to (pointer: coarse) to match
   how site.js picks its scroll mechanism, so nothing changes for a mouse -
   there is no snap on desktop and therefore no collision to avoid.
   If the nav's height or the snap alignment changes, re-check this number. */
@media (pointer: coarse) {
  .quote-section { padding-top:76px; }
}
.quote-section blockquote { font-family:var(--font-display); font-weight:var(--fw-display); font-style:italic; font-size:clamp(22px,3.6vw,34px); line-height:1.42; color:var(--fg); }
.quote-section cite { display:block; margin-top:16px; font-family:var(--font-body); font-style:normal; font-size:12px; letter-spacing:.14em; text-transform:uppercase; color:var(--muted); }

/* ---- FOLLOW STRIP (2026-08-02) ----
   Temporary occupant of .quote-section while the real testimonial is chased.
   It deliberately reuses the quote's own container - padding, 840px measure,
   centring and the (pointer:coarse) 76px nav clearance above all still apply,
   so the snap stop lands the same way it did with a quote in it. Nothing here
   touches the section itself; when the quote comes back this block goes unused
   and can be deleted with the markup.

   The lede is the blockquote's display italic at a smaller clamp. The quote's
   clamp(22,3.6vw,34) was sized for two lines of pull-quote and reads shouty on
   one short line.

   The handle drops the .social-icon-label display italic for body-font small
   caps: "@crosstownsocial" in a 24px serif italic read as a decorative flourish
   rather than as a thing you can go and look up. */
.follow-strip { display:flex; flex-direction:column; align-items:center; }
.follow-lede {
  font-family:var(--font-display); font-weight:var(--fw-display); font-style:italic;
  font-size:clamp(19px,2.6vw,26px); line-height:1.4; color:var(--fg);
  margin-bottom:22px;
}
.follow-strip .social-icon-label {
  font-family:var(--font-body); font-style:normal; font-size:13px; font-weight:700;
  letter-spacing:.08em; opacity:1;
}

/* ============ FINAL CTA ============ */
/* 2026-07-26: padding was a flat 100px top and bottom, which left the banner
   very tall for two short lines of text. Reduced twice - to
   clamp(52px,7vw,72px), then to the values below. Now scales with the
   viewport so it stays proportionate on phones as well. */
.cta-final { background:var(--accent); color:var(--btn-fg); text-align:center; padding:clamp(36px,5vw,52px) 24px; }
.cta-final h2 { font-family:var(--font-display); font-weight:var(--fw-display); font-size:clamp(32px,5vw,56px); text-transform:var(--tt-display); margin-bottom:14px; }
/* The paragraph carried a 32px bottom margin to separate it from a button
   that used to sit here. There's no button in this section any more, so that
   margin was unbalanced dead space below the text - which is what made the
   block read as top-heavy rather than centred. The :not(:last-child) form
   keeps the spacing correct automatically if a button is ever added back. */
.cta-final p { max-width:480px; margin:0 auto; font-size:15.5px; line-height:1.65; }
.cta-final p:not(:last-child) { margin-bottom:32px; }
.cta-final .btn-primary { background:var(--btn-fg); color:var(--accent); }

/* ============ ENQUIRY FORM (contact page) ============ */
/* Single-column now that the contact-panel sidebar was removed (2026-07-26)
   - was a 2-col grid (form + panel) on wider screens, form now just centers
   in a form-sized column on its own. */
.enquiry-layout { display:grid; grid-template-columns:1fr; gap:56px; max-width:640px; margin:0 auto; padding:0 clamp(20px,4vw,48px); }
.enquiry-form { display:grid; gap:20px; }
.form-row { display:grid; gap:20px; }
@media (min-width:560px) { .form-row.two { grid-template-columns:1fr 1fr; } }
.form-field label { display:block; font-size:11.5px; letter-spacing:.1em; text-transform:uppercase; color:var(--muted); margin-bottom:8px; font-weight:700; }
.form-field input, .form-field textarea, .form-field select {
  width:100%; padding:14px 16px; border-radius:10px; border:1.5px solid var(--card-border);
  background:var(--card-bg); color:var(--fg); font-family:var(--font-body); font-size:14.5px;
}
.form-field textarea { min-height:140px; resize:vertical; }
.form-field input:focus, .form-field textarea:focus, .form-field select:focus { outline:none; border-color:var(--accent); }
.form-status { font-size:13.5px; color:var(--accent); min-height:20px; }
.contact-panel { background:var(--card-bg); border:1px solid var(--card-border); border-radius:var(--radius-card); padding:32px 28px; align-self:start; }
.contact-panel h2 { font-family:var(--font-display); font-weight:var(--fw-display); font-size:20px; margin-bottom:18px; color:var(--fg); }
.contact-panel .contact-line { display:block; margin-bottom:14px; font-size:14.5px; }
.contact-panel .contact-line a:hover { color:var(--accent); }
.contact-panel .contact-line span.label { display:block; font-size:10.5px; letter-spacing:.12em; text-transform:uppercase; color:var(--muted); margin-bottom:4px; }

/* ============ FOOTER ============ */
footer { padding:60px clamp(20px,4vw,48px) 32px; max-width:1240px; margin:0 auto; }
.footer-top { display:flex; flex-wrap:wrap; justify-content:space-between; gap:40px; padding-bottom:44px; }
.footer-brand .logo { font-size:22px; }
.footer-brand p { margin-top:14px; max-width:280px; font-size:13.5px; line-height:1.6; color:var(--muted); }
.footer-col .footer-heading { font-size:11px; letter-spacing:.14em; text-transform:uppercase; color:var(--muted); margin-bottom:16px; font-weight:700; }
.footer-links { display:flex; flex-direction:column; gap:10px; }
.footer-links a { font-size:14px; }
.footer-links a:hover { color:var(--accent); }
.footer-bottom { border-top:1px solid var(--card-border); padding-top:22px; display:flex; justify-content:space-between; flex-wrap:wrap; gap:10px; font-size:12px; color:var(--muted); }
