/* ============================================================
   conor.bio — SITE (new design system)
   Shared chrome for every restyled page: base reset, grid,
   header, mobile overlay, dropdown/accordion, stamped stack.
   Values come from tokens.css only — see CLAUDE.md. This file
   assumes tokens.css is linked first in the page <head>.
   ============================================================ */

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

html { background: var(--ground); }

/* CURSORS — tokens.css --cursor-hand / --cursor-grabbed. Hand is the one
   cursor sitewide, universal selector on purpose so it also replaces
   the UA's pointer (links, buttons), text caret (inputs) and default
   arrow. The grabbed rule that pairs with it is the LAST rule in this
   file, so it wins against any same-specificity cursor set above. */
*, *::before, *::after { cursor: var(--cursor-hand); }

body {
  margin: 0;
  background: var(--ground);
  color: var(--ink);
  font-family: var(--font-body);
  font-weight: var(--weight-body);
  font-size: var(--t-body-m);
  line-height: var(--lh-body);
  /* Reported: mobile content not reaching the right edge (a gap), and
     separately the wedding-date field pushing past the viewport —
     neither reproduced in headless Chromium/WebKit at 390px (every
     section measured symmetric, no scrollWidth overflow), so this is
     a defensive guard for a device-specific case rather than a fix
     for a located cause. .header-row2-scroll and the testimonial
     carousel are still independently scrollable — this only clips
     overflow at the body level, it doesn't touch their own
     overflow-x. */
  overflow-x: hidden;
}
/* iOS Safari ignores overflow-x:hidden at the viewport level for touch
   panning, so any 1px of overhang lets the page wobble left/right.
   html keeps the viewport-level hidden; body additionally clips its
   own box (clip, unlike hidden, makes no scroll container), so
   overhang never becomes scrollable overflow. */
html { overflow-x: hidden; }
body { overflow-x: clip; }
@media (min-width: 768px) {
  body { font-size: var(--t-body); }
}

a { color: inherit; text-decoration: none; }

button {
  font-family: inherit;
  color: inherit;
  background: none;
  border: none;
  padding: 0;
  cursor: var(--cursor-hand);
}

img { display: block; max-width: 100%; }

/* Inputs need >=16px to stop iOS zoom-on-focus. Not a design
   token — a platform floor, applied on top of --t-body. */
input, button, select { font-size: max(var(--t-body-m), 16px); }

:focus-visible {
  outline: var(--focus-ring);
  outline-offset: var(--focus-offset);
}

/* Standard sr-only clipping technique — content read by screen
   readers, removed from visual layout and the tab order's visual
   flow. The 1px/-1px figures are the established pattern for this,
   not design values. */
.visually-hidden {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip: rect(0, 0, 0, 0);
  white-space: nowrap;
  border: 0;
}


/* ---------- GRID ----------------------------------------------
   Mobile-first. Three breakpoints, min-width only. --col-w and
   --span-3 are computed from the grid tokens so anything needing
   "N columns wide" derives it instead of hardcoding a pixel value.
   ---------------------------------------------------------- */

:root {
  --grid-cols:    var(--cols-m);
  --grid-margin:  var(--margin-m);
  --grid-gutter:  var(--gutter-m);
}
@media (min-width: 768px) {
  :root {
    --grid-cols:    var(--cols);
    --grid-margin:  var(--margin-t);
    --grid-gutter:  var(--gutter-t);
  }
}
@media (min-width: 1180px) {
  :root {
    --grid-margin:  var(--margin);
    --grid-gutter:  var(--gutter);
  }
}

/* One column's width at the current breakpoint. */
:root {
  --col-w: calc((100vw - 2 * var(--grid-margin) - (var(--grid-cols) - 1) * var(--grid-gutter)) / var(--grid-cols));
}

.grid {
  display: grid;
  grid-template-columns: repeat(var(--grid-cols), 1fr);
  column-gap: var(--grid-gutter);
  padding-inline: var(--grid-margin);
}


/* ---------- HEADER ---------------------------------------------- */

/* <header> IS row 1 now (class + element merged), not a wrapper
   around it — a wrapper-only <header> was exactly the "sticky can't
   hold past its own parent's bottom edge" bug row 2 already worked
   around: <header> containing just row 1 was only 64px tall, giving
   row1's position:sticky no room to actually range as the page
   scrolled, so it just scrolled away like a static element despite
   the sticky declaration. Confirmed via getBoundingClientRect()
   returning top:-2000 after scrolling 2000px — a sticky element
   pinned at top:0 would read top:0 regardless of scroll position. */

/* Row 1 and row 2 no longer need matching zone widths — row 2 gave
   up its left/right split (see HEADER ROW TWO below) in favor of
   one evenly-spread row, so row 1's nav and signup are each free to
   be their own natural width again. Centering the wordmark just
   needs the two side groups to be able to differ in width without
   dragging it off-center, which a 1fr auto 1fr grid already
   guarantees regardless of viewport — no special >=1180px case
   needed any more (that whole override existed only to force
   fixed-width zones for row 2 to match; removed along with it). */
.header-row1 {
  /* AMENDMENTS ROUND 2 §7 — mobile row 1 scrolls away again (it had
     briefly become pinned like row 2 earlier in this project's
     history). Desktop is unchanged: still pinned, see the >=768px
     override below. Needs its own explicit background regardless,
     since at desktop content still scrolls underneath it. */
  position: static;
  background: var(--ground);
  /* AMENDMENTS ROUND 2 §1, rule location 1: "between header row 1
     and row 2." Missed this in the initial pass — caught when asked
     what happened to the header dividers. */
  border-bottom: var(--rule);
  /* AMENDMENTS ROUND 7, rule location 10: a divider above the header
     itself, added by explicit instruction alongside the footer's own
     new top/bottom rules (locations 11-12, see tokens.css). */
  border-top: var(--rule);
  height: var(--header-row1-m);
  display: flex;
  align-items: flex-end; /* text sits at the row's bottom edge, not vertically centered — see HEADER ROW TWO for why this matters for the row1/row2 gap */
  justify-content: space-between;
  gap: var(--s3);
  /* Pulled in further than the page's standard margin (was
     var(--grid-margin) alone) — row 2 now uses the standard margin
     to line up with the rest of the page's content, so row 1 moves
     in by --s3 more to keep the same width difference between the
     two rows that existed before (48 vs 24 -> 72 vs 48, same 24px
     gap either way). Nav and signup both sit at this inset via
     justify-self:start/end below; the centered wordmark is
     unaffected since 1fr-auto-1fr centers it regardless of padding. */
  padding-inline: calc(var(--grid-margin) + var(--s3));
  /* Was 2px, just enough clearance so the (since-removed) hover
     outline box didn't get clipped by row 2's opaque background.
     Now serves a different purpose: matches row 2's own padding-top
     (--s3, the text-to-line gap below the divider) so the divider
     line sits centered in a symmetric thin band — text, --s3 gap,
     line, --s3 gap, text — rather than sitting flush against row 1's
     text like an underline. */
  padding-bottom: var(--s3);
}
@media (max-width: 767px) {
  /* Mobile only: the wordmark was sitting off the row's true
     vertical center (align-items:flex-end above anchors it to the
     row1/row2 divider gap, right for nav text at desktop but reads
     as "too high" once nav is hidden and the wordmark is the only
     tall item) and inset 44px from the left edge (grid-margin + s3,
     row1's shared padding) instead of flush with row 2's own 20px
     (grid-margin alone) below it.

     padding-top:var(--s3) makes the content box symmetric
     (--s3 top and bottom, matching), so align-self:center below
     lands the wordmark's midpoint on the row's true center instead
     of the padding-bottom-only box's off-center one. The hamburger
     stays flex-end/unaffected — its bottom edge is set by
     padding-bottom alone, which this doesn't touch.

     padding-inline-start overrides just that one logical side (the
     shared padding-inline shorthand above still sets the end side,
     for the hamburger) down to the plain grid-margin, dropping
     row1's extra +s3 inset for the wordmark specifically so its left
     edge lines up with row 2's links beneath it. */
  .header-row1 {
    padding-top: var(--s3);
    padding-inline-start: var(--grid-margin);
    /* Mobile fixes pass — .mobile-overlay now starts below row 1
       instead of covering it (see .mobile-overlay's own comment) and
       needs row 1 to actually paint above it, not just sit outside
       its box. position:static (the base rule, above) can't hold a
       z-index at all — static elements ignore it — so this needs its
       own stacking context. relative changes nothing about row 1's
       own layout (no offset given), it only makes the z-index below
       take effect. One above the overlay's own z-index. Both sit
       above the intro arrows (39) and the draggable windows (40 and
       up, js/dialog.js raises each click by one), by direct
       instruction: the mobile menu is never covered by a popup. */
    position: relative;
    z-index: 1001;
  }
  .header-wordmark {
    align-self: center;
  }
}
@media (min-width: 768px) {
  .header-row1 {
    position: sticky;
    top: 0;
    z-index: 21;
    display: grid;
    grid-template-columns: 1fr auto 1fr;
    align-items: flex-end;
    gap: var(--s3);
  }
  /* Grid, not flex: with unequal-width nav/signup groups,
     justify-content:space-between spaces the GAPS evenly, not the
     wordmark itself — it drifts toward whichever side is narrower.
     A 1fr auto 1fr grid centers the middle column exactly regardless
     of how wide the two side groups are — at every width, now. */
  .header-nav { justify-self: start; }
  .header-wordmark {
    justify-self: center;
    /* .header-row1 aligns everything by its bottom edge
       (align-items: flex-end), which levels the wordmark image's
       bottom with the nav text's bottom — but the taller wordmark's
       own vertical midpoint then sits above the text's midpoint.
       transform, not margin-top: with flex-end/end alignment, the
       browser anchors the item's BOTTOM margin edge to the row's
       bottom regardless of top margin (margin-top just adds empty
       space above the item, never moving its visible content) —
       confirmed by measurement, margin-top had zero visual effect.
       translateY moves the already-aligned box directly instead. */
    transform: translateY(var(--wordmark-offset));
  }
  .header-nav--right { justify-self: end; }
}

.header-nav {
  display: none;
  align-items: flex-end;
  gap: var(--s4);
}
@media (min-width: 768px) {
  .header-nav { display: flex; }
}
.header-nav a,
.header-nav button {
  font-size: var(--t-label);
  /* One line always: the active face (--font-nav-active, caps) is wider
     and "Performance Log" wrapped where the row is tight (768, 1180). */
  white-space: nowrap;
  line-height: normal; /* <a> inherits body's line-height (23.25px at 15px); <button> gets the browser's UA-default "normal" (~16px) — explicit here so both tags produce the same hover-box height */
  color: var(--ink);
  /* --font-body (Host Grotesk), sentence case — plain like row 2,
     "except when active" (below). Was --font-masthead/uppercase
     unconditionally; corrected after row 1 was supposed to stay
     quiet except for its one active item. */
}
/* Active state: current page (aria-current="page", set per-page in
   markup — e.g. "Weddings" on weddings.html) or an open Links/Contact
   accordion ([aria-expanded="true"], toggled by js/header.js). The
   boldest weight in the family, not a stencil cut — see --font-nav-
   active in tokens.css for why no bolder stencil exists. Uppercase +
   --track-caps live here now, not on the base rule — only the active
   item gets the bold caps look, everything else stays quiet. */
.header-nav a[aria-current="page"],
.header-nav a[aria-expanded="true"],
.header-nav [data-accordion-trigger][aria-expanded="true"] {
  font-family: var(--font-nav-active);
  text-transform: uppercase;
  letter-spacing: var(--track-caps);
}
.header-nav > .header-nav-item {
  position: relative;
  /* Without this, the span inherits body's line-height (27.9px, from
     --t-body/--lh-body) instead of the 15px/normal its button child
     actually uses — the button then sits ~6px above the span's own
     bottom edge within that oversized inherited line box, throwing
     off the shared bottom alignment with the plain <a> nav items. */
  font-size: var(--t-label);
  line-height: normal;
}

/* Wordmark is lettering-only now — the keys graphic that used to be
   part of wordmark_full moved to row 2 as its own centerpiece (see
   below), so there's no more full/text swap by breakpoint. Always
   --wordmark-height-m, per the confirmed mobile-tier sizing that now
   applies at every width, not just mobile. */
.header-wordmark { display: block; line-height: 0; }
.header-wordmark img { height: var(--wordmark-height-m); width: auto; }

/* Was a live email-capture input + submit button; replaced with a
   plain text link to the same destination as every other Substack
   reference on the page ("replace email input as 'sign up for my
   email list'"). No popup/modal exists on the current live site to
   match — its own header/footer links are plain new-tab links to
   conorthaxter.substack.com, so this matches that actual behavior
   rather than the popup the request described; flagged, not silent.
   Was briefly Aujournuit italic-on-hover — reverted to default
   --font-body ("turn sign up for my email list back to default host
   grotesk"), no special hover effect. */
.header-signup {
  display: none;
  font-size: var(--t-label);
  color: var(--ink);
}
@media (min-width: 768px) {
  .header-signup { display: flex; align-items: center; }
}

/* Three bars again — mobile fixes round 3, direct instruction,
   reversing the prior "one line" pass immediately above/below this
   comment in git history. Thicker than that line (--hamburger-bar-
   weight, 2px vs the old 1px) and condensed (--hamburger-bar-gap,
   5px, tighter than a standard hamburger's spacing).

   "All 3 lines turn vertical when clicked" — rather than rotating
   each bar individually in place (which would need a separate
   translate per bar to fan them out into a side-by-side |||, since a
   90deg rotation-in-place just leaves all three stacked on top of
   each other), the transform is on the BUTTON itself: rotating the
   whole three-bar assembly 90deg turns the stacked-horizontal "≡"
   into a side-by-side-vertical glyph directly, same mechanism the
   single-line version already used, just applied to the icon as a
   rigid group instead of to one span. The button is a square
   (--hamburger-size both dimensions) so rotating it in place doesn't
   shift its own tap-target footprint. */
.header-hamburger {
  display: flex;
  flex-direction: column;
  justify-content: center;
  gap: var(--hamburger-bar-gap);
  width: var(--hamburger-size);
  height: var(--hamburger-size);
  transition: transform var(--popin-duration) var(--ease);
  transform: rotate(0deg);
}
@media (min-width: 768px) {
  .header-hamburger { display: none; }
}
.header-hamburger span {
  height: var(--hamburger-bar-weight);
  background: var(--ink);
  width: 100%;
}
/* [aria-expanded] is already set by js/header.js's open/closeOverlay
   (openOverlay: "true", closeOverlay: "false") — reusing that
   existing state instead of adding a second is-open class the JS
   would also need to toggle. "...and stays vertical while menu is
   active": this is the only rule, no separate mid-transition state —
   it holds at 90deg for as long as aria-expanded stays "true". */
.header-hamburger[aria-expanded="true"] {
  transform: rotate(90deg);
}
@media (prefers-reduced-motion: reduce) {
  .header-hamburger { transition: none; }
}

/* ---------- HEADER ROW TWO --------------------------------------- */

/* Row 2 is a sibling of <header>, NOT nested inside it. Position:
   sticky can't hold an element past the bottom edge of its own
   parent's box — a wrapper containing just row 1 + row 2 is only
   ~112px tall, so anything sticky inside it would stop pinning the
   moment the page scrolled past that height (this broke once
   already; see the git history). Keeping row 2 at the body level
   gives it a full-page-height containing block instead. */
/* AMENDMENTS ROUND 4 §1 — replaces ROUND 2 §7's mobile structure
   entirely (was three bands within row 2: links wrapped 2 rows +
   a full-width CTA row, 128px pinned total). Now: one row, one
   height (--header-row2-m, 36px), section links only — Request a
   Quote is no longer part of row 2 at any width; it moved to a
   floating sticky button (see MOBILE STICKY CTA below), and the
   header's own .header-cta (still the desktop-pinned version) is
   display:none below 768px accordingly.

   .header-row2-scroll holds the actual horizontally-scrollable link
   list, obeying both margins itself (padding-inline, not the row's
   own) so the scrollable area's content starts/ends exactly at the
   margins rather than bleeding — "it does not bleed." .header-row2-
   indicator is a separate, non-scrolling sibling, absolutely
   positioned over the row's right edge with an OPAQUE --ground
   background: content that scrolls to the far right passes behind
   it and is simply covered by a solid color, a hard cutoff with no
   blending — satisfies "stays fixed while links slide beneath it"
   and "no fade, no gradient mask" at the same time, without needing
   the fade the amendment's own stop-condition anticipated might be
   necessary. Verified visually, not just assumed to work — see
   BRIEF.md. */
.header-row2 {
  position: sticky;
  top: 0;
  z-index: 20;
  background: var(--ground);
  /* AMENDMENTS ROUND 2 §1, rule location 2: "below header row 2." */
  border-bottom: var(--rule);
  display: flex;
  align-items: center;
  height: var(--header-row2-m);
}
.header-row2-scroll {
  flex: 1 1 auto;
  min-width: 0;
  display: flex;
  align-items: center;
  gap: var(--s2);
  overflow-x: auto;
  scrollbar-width: none;
  padding-inline-start: var(--grid-margin);
  /* Reserves room so the scroll starts with real gap before the
     indicator, not content already touching it — roughly the
     indicator's own footprint (glyph + its padding). */
  padding-inline-end: var(--s6);
}
.header-row2-scroll::-webkit-scrollbar { display: none; }
.header-row2-sep {
  flex: 0 0 auto;
  color: var(--ink-quiet);
}
.header-row2-indicator {
  position: absolute;
  top: 0;
  bottom: 0;
  right: 0;
  display: flex;
  align-items: center;
  background: var(--ground);
  color: var(--ink);
  padding-inline: var(--s2) var(--grid-margin);
  pointer-events: none;
}
.header-cta { display: none; }
@media (min-width: 768px) {
  .header-row2 {
    top: var(--header-row1-m); /* row 1 IS pinned again at this width — pins right below it */
    height: var(--header-row2-m);
    padding-inline: var(--grid-margin);
  }
  .header-row2-sep,
  .header-row2-indicator {
    display: none;
  }
  .header-cta { display: flex; }
  /* AMENDMENTS ROUND 8 §1's two-mechanism split is reversed by direct
     instruction ("Request a Quote and Song List are cramped together,
     it's not even spacing across"): that split gave the 4 links their
     own internal space-between (~283.5px apart, measured at 1440px)
     but only a fixed --s3 (24px) gap into the CTA after them — an
     intentional seam at the time, now the exact thing flagged as
     cramped. Restores the mechanism this replaced (see git history,
     pre-ROUND-8): .header-row2-scroll unwraps via display:contents so
     the CTA rejoins the links as flat flex siblings, and .header-row2
     itself is the one space-between container across all of them —
     no separate seam between the last link and the CTA. */
  .header-row2-scroll {
    display: contents;
  }
  .header-row2 {
    display: flex;
    justify-content: space-between;
    gap: var(--s3);
    /* Overrides the mobile base rule's align-items:center, which is
       unconditional there (correct for the mobile 3-row grid) and
       was otherwise leaking into this row too, capping .header-cta
       at its own content height instead of stretching it to the
       row's full height. */
    align-items: stretch;
    /* Padding-block is a mobile-only concept here (breathing room
       around the wrapped 3-row links grid) — at this fixed 32px
       single-row height, it just eats into the space align-items:
       stretch needs to fill; zeroed instead of inherited. */
    padding-block: 0;
    /* Matches the page's standard content margin — row 2 lines up
       with where Packages/masthead/etc. content starts. */
    padding-inline: var(--grid-margin);
    /* Safety net, not a mobile-style scroller: at narrow desktop
       widths (768-900px-ish) four links + CTA might still not fit
       comfortably. "No horizontal scroller" in the amendment is
       scoped to mobile — nothing said to remove this at desktop. */
    overflow-x: auto;
    scrollbar-width: none;
  }
  .header-row2::-webkit-scrollbar { display: none; }
  .header-cta {
    grid-column: unset;
    height: auto;
    flex: 0 0 auto;
    /* Divider — a real border again now that it's a plain flex item
       spaced the same way as everything else, not something needing
       its own positioning math. */
    border-left: var(--rule);
  }
}

/* Type only — no box, no enclosure. The stamped stack that used to
   style this is retired; see tokens.css. */
.header-row2-link {
  flex: 0 0 auto;
  /* .header-row2 is align-items:stretch at desktop now (so .header-
     cta can fill the row's full height as a plain flex item) — that
     stretches these links' own boxes to the full row height too,
     but a plain <a> has no internal alignment of its own, so its
     text just sat at the box's top instead of centering within it.
     flex + align-items:center here re-centers the text within
     whatever height the outer stretch gives it. */
  display: flex;
  align-items: center;
  /* AMENDMENTS ROUND 4 §1 — "15px, single line, no wrapping." Was
     white-space:normal at mobile (the old wrapped 3-column grid
     needed "What Couples Say" to wrap within its own ~1/3-width
     cell); the new mobile row is a single horizontally-scrollable
     line instead, so nowrap now applies at every width, same as
     desktop already had via its own override (removed — redundant
     with this). */
  white-space: nowrap;
  font-size: var(--t-label);
  line-height: normal; /* consistent hover-box height with .header-nav — see comment there */
  color: var(--ink);
  /* REST state stays --font-body (Host Grotesk), sentence case — the
     stencil treatment is row 1 only ("those links were supposed to
     stay in host grotesk. only in row 1, the active page shows up in
     full boldness uncut"). Corrects an earlier overreach where this
     rule picked up --font-masthead/uppercase along with row 1.
     Declared explicitly (not just inherited from body) so .is-active
     below has a real value to transition away from. */
  font-family: var(--font-body);
  text-transform: none;
  letter-spacing: normal;
  /* MOTION entry 4 (tokens.css) — eases toward/away from .is-active's
     stencil-caps state as the active section changes (js/header.js's
     scrollspy). font-family and text-transform can't interpolate
     (static font files, no variable-weight axis) so they snap at the
     transition midpoint exactly like .cta-letter's font-swap already
     does; only letter-spacing actually eases, which is what reads as
     the "morph." */
  transition: font-family var(--timeline-transition) var(--ease),
              text-transform var(--timeline-transition) var(--ease),
              letter-spacing var(--timeline-transition) var(--ease);
  /* AMENDMENTS ROUND 2 §4, motion entry 1: section links pop in,
     left to right, on load, once. Staggered via nth-of-type on the
     links (four now, AMENDMENTS ROUND 8 §1 — was five before What
     Couples Say's link was removed), not a wrapper animation — see
     the masthead cluster comment elsewhere in this file for why
     animation-delay needs its own declared animation per element,
     not just a delay set on a child whose parent owns the animation.

     Selector is .header-row2-scroll a:nth-of-type(N), not
     .header-row2 > *:nth-child(N) or *:nth-child on .header-row2-scroll
     itself — :nth-of-type on the <a> tag specifically skips over the
     interleaved <span> separator dots, which :nth-child alone would
     have counted. (This selector no longer also has to route around a
     display:contents unwrap at desktop — AMENDMENTS ROUND 8 §1 made
     .header-row2-scroll a real flex container at every width — but
     the sep-dot-skipping reason on its own is enough to keep it.) */
  opacity: 0;
  animation: section-popin var(--popin-duration) var(--ease) forwards;
}
.header-row2-scroll a:nth-of-type(1) { animation-delay: 0; }
.header-row2-scroll a:nth-of-type(2) { animation-delay: var(--popin-stagger); }
.header-row2-scroll a:nth-of-type(3) { animation-delay: calc(var(--popin-stagger) * 2); }
.header-row2-scroll a:nth-of-type(4) { animation-delay: calc(var(--popin-stagger) * 3); }
/* .header-cta (5th now, was 6th) sets its own animation-delay directly
   in its own rule further down instead of an nth-of-type rule here —
   a rule here would get reset back to delay:0 by that rule's own
   `animation` shorthand, which comes later in the file and implies
   delay:0 unless stated inline. */
@keyframes section-popin {
  to { opacity: 1; }
}
@media (prefers-reduced-motion: reduce) {
  .header-row2-link { animation: none; opacity: 1; transition: none; }
}

/* ACTIVE state — the section link for whatever's currently under the
   sticky header, tracked by js/header.js's IntersectionObserver.
   Reuses --font-masthead (same file as row 1's own rest state,
   .pkg-price-col, etc. — see FACE ASSIGNMENT in tokens.css), all caps
   + --track-caps, not --font-nav-active (Extra Black) — row 1's own
   active state uses that instead, and this role was asked for as
   specifically "stencil semibold." */
.header-row2-link.is-active {
  font-family: var(--font-masthead);
  text-transform: uppercase;
  letter-spacing: var(--track-caps);
}

/* A plain flex item now, not sticky. It was position:sticky;right:0
   (the "sticky last column" trick) so it stayed pinned during the
   mobile horizontal scroll per BRIEF's "outside the scroll
   container" spec — but once row 2 flattened to one evenly-spaced
   row (this turn's instruction), that broke: the CTA's stuck
   position visually collided with "Song List" rendering underneath
   it at scroll position 0, since the two overlapped rather than
   cleanly handing off. Simplifying to a plain item is a real
   deviation from BRIEF's mobile-pinning spec — flagged, not silent —
   but matches "forget the split, just space them all evenly" more
   directly than patching the sticky trick further would have. */
/* Blue + pulse removed entirely ("take away the blue and the pulse").
   Replaced with a divider (separating it from the row 2 links list —
   "divide off request a quote link with a dividing line"), then a
   perpetual letter-by-letter font-swap animation, then the dark-cell
   treatment below — the animation itself was later removed too
   ("take out text animation from quote cta in header"), so this is
   plain static text now. The overlay's own .overlay-cta (mobile
   menu) keeps the letter-swap animation — see .cta-letter/@keyframes
   cta-font-swap further down — that removal was scoped to the header
   only. */
/* Its own filled cell now — dark background, light text, so it reads
   as the row's one obvious call to action ("turn the background of
   the cell for request a quote to the dark color, font light color
   so it stands out"). Real tension with tokens.css's own stated
   color rule (--ink as a GROUND is meant to exist "in exactly one
   place: the error plate," specifically so an error is impossible to
   miss) — a second ink-background place dilutes that exclusivity.
   Implemented as asked and flagged, not silently resolved. */
.header-cta {
  /* display is NOT set here — AMENDMENTS ROUND 4 §1 needs it
     display:none at mobile (the header's own .header-cta is gone
     from the mobile header entirely, replaced by .mobile-sticky-cta)
     and display:flex at desktop (see the two rules near
     .header-row2 above). This rule used to set display:flex
     unconditionally, which — being later in the cascade than both of
     those — silently won at every width including mobile, undoing
     the mobile display:none regardless of which one "should" apply.
     Same class of bug as this file's other mobile-base-rule-leaks-
     into-desktop cases, just in the opposite direction (a later
     unconditional rule leaking into an earlier conditional one). */
  flex: 0 0 auto;
  align-items: center;
  justify-content: center;
  font-size: var(--t-label);
  line-height: normal; /* consistent hover-box height with .header-nav — see comment there */
  background: var(--ink);
  color: var(--ink-inverse);
  font-family: var(--font-masthead);
  text-transform: uppercase;
  letter-spacing: var(--track-caps);
  padding-inline: var(--s3);
  align-self: stretch;
  /* Load-once pop-in only now — see .header-row2-link. 5th in the
     stagger now (4 links + this — was 6th/5 links before AMENDMENTS
     ROUND 8 §1 removed What Couples Say's link), set directly here,
     not via nth-child, since .header-cta is a real, un-wrapped
     .header-row2 child and this is the LAST .header-cta rule in the
     file: an earlier rule just setting animation-delay would get
     reset back to 0 by this rule's own `animation` shorthand, which
     implies delay:0 unless stated here explicitly. */
  opacity: 0;
  animation: section-popin var(--popin-duration) var(--ease) forwards;
  animation-delay: calc(var(--popin-stagger) * 4);
}
@media (max-width: 767px) {
  .header-cta {
    /* Mobile: CTA is its own full-width row stacked below the links
       grid, not a row alongside them — divider reads as a line above
       it instead of to its left. */
    border-left: none;
    border-top: var(--rule);
    padding-left: 0;
    padding-top: var(--s2);
  }
}
@media (prefers-reduced-motion: reduce) {
  .header-cta { animation: none; opacity: 1; }
}

/* Split into one <span class="cta-letter"> per character by
   js/header.js at load — see initCtaSwap(). Each letter runs the same
   keyframes but at its own staggered delay (--i, set inline per
   letter), so the swap reads as a wave passing through the word one
   letter at a time, not an instant all-at-once cut. Non-interpolable
   properties (font-family, font-style, text-transform) just snap
   between keyframe stops, which is the wanted effect here — a hard
   cut per letter, not a blend. */
.cta-letter {
  display: inline-block;
  animation: cta-font-swap var(--pulse-duration) var(--ease) infinite;
  animation-delay: calc(var(--i, 0) * 40ms);
}
@keyframes cta-font-swap {
  0%, 40%   { font-family: var(--font-nav-active); font-style: normal; text-transform: uppercase; }
  50%, 90%  { font-family: var(--font-serif); font-style: italic; text-transform: none; }
  100%      { font-family: var(--font-nav-active); font-style: normal; text-transform: uppercase; }
}
@media (prefers-reduced-motion: reduce) {
  .cta-letter { animation: none; font-family: var(--font-nav-active); text-transform: uppercase; }
}

/* AMENDMENTS ROUND 2 §2's color-only hover (--ink to --accent, via
   --hover-color) is removed per later instruction ("take away blue
   hover state") — hover no longer changes color anywhere in the
   header. No replacement affordance was requested; .header-cta still
   has its own perpetual pulse, unrelated to hover. */


/* ---------- DESKTOP DROPDOWN (LINKS / CONTACT) -------------------- */

/* No transition here — AMENDMENTS ROUND 2 §4/§13 removes the
   accordion as a motion entry. Still uses the grid-template-rows
   0fr/1fr toggle for the open/close mechanism itself (this dropdown
   still needs SOME show/hide — six platform links plus two copy
   buttons can't sit inline in the header row — inferred, not stated
   outright), just with no animated transition between the two
   states, so it snaps instantly. */
.header-dropdown-wrap {
  position: absolute;
  top: 100%;
  left: 0;
  z-index: 25;
  width: calc(3 * var(--col-w) + 2 * var(--grid-gutter));
  display: grid;
  grid-template-rows: 0fr;
}
.header-dropdown-wrap.is-open { grid-template-rows: 1fr; }
/* Right-hand nav group: Links/Contact sit at the viewport's right
   edge, so their panels anchor to the trigger's right edge instead
   of its left, else they'd run off the page. */
.header-nav--right .header-dropdown-wrap { left: auto; right: 0; }
.header-dropdown {
  min-height: 0;
  overflow: hidden;
}
/* Border lives on the INNER box, not the collapsing element itself.
   A bordered box still paints its own border even at 0 height (top
   and bottom borders just draw on top of each other) — overflow:
   hidden only clips a child that's too big for its box, never the
   box's own border. Putting the border here means the parent's
   collapse to 0fr clips this whole box away, border included. */
.header-dropdown-inner {
  border: var(--rule);
  background: var(--ground);
}
/* RULE + SHAPE location 17 (tokens.css) — border-bottom between every
   item, --rule-pad the "rule to text" spacing on both sides (same
   token Packages/Song List use), replacing the old margin-top: --s2
   spacing entirely rather than stacking a second value onto it. */
.header-dropdown-inner > * {
  padding-block: var(--rule-pad);
  padding-inline: var(--s3);
}
.header-dropdown-inner > *:first-child { padding-top: var(--s3); }
.header-dropdown-inner > *:last-child { padding-bottom: var(--s3); }
.header-dropdown-inner > *:not(:last-child) {
  border-bottom: var(--rule);
}

.header-dropdown a,
.header-dropdown .copy-item {
  display: block;
  font-size: var(--t-label);
  line-height: normal; /* consistent hover-box height with .header-nav — see comment there */
  color: var(--ink);
}
.header-dropdown .copy-item { width: 100%; }
@media (hover: hover) and (pointer: fine) {
  .header-dropdown a:hover,
  .header-dropdown .copy-item:hover {
    background: var(--ink);
    color: var(--ink-inverse);
  }
}

/* Contact popup's "Click to copy" caption was an unstyled <p> — no
   rule targeted it, so it kept the UA default 1em top/bottom margin.
   The container's own padding blocks margin collapse with it, so
   that margin added ON TOP of the box's padding rather than being
   absorbed by it: measured live (Playwright, both dropdowns open at
   1440px) at box-top-to-first-item — Links (no <p> inside) sits at a
   symmetric 25px top/25px bottom inset, Contact was 40px top/25px
   bottom, the 15px difference being the label's own unreset
   margin-top. font-size was already inheriting to the correct 15px
   in both cases, not part of the bug — margin:0 alone fixes it. */
.header-dropdown-label {
  margin: 0;
  font-size: var(--t-label);
  line-height: normal;
}

/* Reported: "no indent for contact content in menu." Root cause,
   confirmed via rendered DOM (Playwright, not assumed): .copy-label
   is a <button>, and nothing in this file resets the UA default
   text-align:center a <button> carries — every sibling item in the
   same list (.overlay-link, the Links platforms) is an <a>, which
   defaults to left/start, so only the two Contact emails visually
   centered inside their own grid column instead of starting flush
   with everything else. text-align: left fixes both the mobile
   overlay's 2-up grid and the desktop CONTACT dropdown, which shares
   this same class and had the identical latent bug, just less
   visible in that dropdown's narrower column. */
.copy-label {
  display: inline-grid;
  text-align: left;
}
/* .overlay-link (display:block, declared later) beats .copy-label on
   source order, which un-stacked the two state spans: the hidden
   "Copied" span took its own line under each email. Compound selector
   restores the stack so Copied replaces the email in place. */
.overlay-link.copy-label { display: inline-grid; }
.copy-label span { grid-area: 1 / 1; }
.copy-label .state-copied { visibility: hidden; }
.copy-label.is-copied .state-default { visibility: hidden; }
.copy-label.is-copied .state-copied { visibility: visible; }


/* ---------- MOBILE STICKY CTA --------------------------------------- */

/* AMENDMENTS ROUND 4 §2. A bottom-right floating control is the exact
   pattern "no free floating anything" was written against — it
   survives here specifically because it's corner-anchored, square-
   cornered, bordered, and temporary (gone once Packages is in view),
   per the amendment's own note. --ink fill / --ink-inverse text, NOT
   --accent — this amendment's own §2 is explicit about that, even
   though §5 separately restates --accent for "Request a Quote";
   see tokens.css's --accent comment for how that's scoped instead.
   Hidden entirely at desktop — mobile-only control, the header's own
   pinned .header-cta already covers desktop. */
.mobile-sticky-cta {
  display: none;
}
@media (max-width: 767px) {
  .mobile-sticky-cta {
    display: flex;
    align-items: center;
    justify-content: center;
    position: fixed;
    right: 0; /* flush to the viewport's right edge */
    bottom: var(--margin-m);
    z-index: 25;
    background: var(--ink);
    color: var(--ink-inverse);
    border: var(--rule);
    border-radius: var(--shape-key); /* one-element exception, see tokens.css */
    padding-inline: var(--rule-pad);
    padding-block: var(--s2);
    min-height: var(--tap-target-min);
    font-family: var(--font-masthead);
    font-size: var(--t-label);
    text-transform: uppercase;
    letter-spacing: var(--track-caps);
    /* Notch collapsed: the same six points as the notched polygon,
       with the three notch points parked on the bottom-right corner,
       so clip-path interpolates between the two states. */
    clip-path: polygon(0 0, 100% 0, 100% 100%, 100% 100%, 100% 100%, 0 100%);
    transition: transform var(--duration-release) var(--ease-release),
                background-color var(--timeline-transition) var(--ease),
                color var(--timeline-transition) var(--ease),
                border-color var(--timeline-transition) var(--ease),
                clip-path var(--timeline-transition) var(--ease);
  }
  /* js/sticky-cta.js toggles these by which section sits under the
     button. .is-offscreen slides it out past the right edge (its own
     width — it sits flush right) — transform only, no opacity.
     .is-alt is the alabaster-fill / ink-text state over the ink plate
     and the photo. */
  .mobile-sticky-cta.is-offscreen {
    /* Strike: transform only — the colour/border/notch lists keep
       --timeline-transition/--ease (a bare transition-duration would
       override all five comma-separated transitions). */
    transition-duration: var(--duration-strike), var(--timeline-transition), var(--timeline-transition), var(--timeline-transition), var(--timeline-transition);
    transition-timing-function: var(--ease-strike), var(--ease), var(--ease), var(--ease), var(--ease);
    transform: translateX(100%);
  }
  /* White key: alabaster fill, ink text, no border, and a
     --key-notch-w x --key-notch-h rectangle taken out of the bottom-
     right corner. The border stays in the box model (transparent, not
     removed) so the button doesn't shift by 1px when it swaps. */
  .mobile-sticky-cta.is-alt {
    background: var(--ground);
    color: var(--ink);
    border-color: transparent;
    clip-path: polygon(0 0, 100% 0, 100% calc(100% - var(--key-notch-h)), calc(100% - var(--key-notch-w)) calc(100% - var(--key-notch-h)), calc(100% - var(--key-notch-w)) 100%, 0 100%);
  }
  /* Rounded notch corners (--key-notch-r): shape() with arcs, same
     command list in both states so it still interpolates. The
     polygon() above is the fallback where shape() isn't supported —
     same notch, square corners. */
  @supports (clip-path: shape(from 0 0, line to 100% 0)) {
    .mobile-sticky-cta {
      clip-path: shape(from 0 0, line to 100% 0, line to 100% 100%, line to 100% 100%, arc to 100% 100% of var(--key-notch-r) ccw, line to 100% 100%, arc to 100% 100% of var(--key-notch-r) cw, line to 0 100%, close);
    }
    .mobile-sticky-cta.is-alt {
      clip-path: shape(from 0 0, line to 100% 0, line to 100% calc(100% - var(--key-notch-h)), line to calc(100% - var(--key-notch-w) + var(--key-notch-r)) calc(100% - var(--key-notch-h)), arc to calc(100% - var(--key-notch-w)) calc(100% - var(--key-notch-h) + var(--key-notch-r)) of var(--key-notch-r) ccw, line to calc(100% - var(--key-notch-w)) calc(100% - var(--key-notch-r)), arc to calc(100% - var(--key-notch-w) - var(--key-notch-r)) 100% of var(--key-notch-r) cw, line to 0 100%, close);
    }
    .mobile-sticky-cta:focus-visible { clip-path: none; }
  }
  /* clip-path would clip the focus ring (it draws outside the box). */
  .mobile-sticky-cta:focus-visible { clip-path: none; }
}
@media (max-width: 767px) and (prefers-reduced-motion: reduce) {
  .mobile-sticky-cta { transition: none; }
}


/* ---------- MOBILE OVERLAY ---------------------------------------- */

/* Mobile fixes pass — direct instruction: "the menu [should] slide
   down from the header, retaining the header as is, so conorthaxter
   is still showing. row 2 and contents of page get overlapped by
   menu." Was inset:0 (covered row 1 too, which is what made the old
   close-button-vs-first-link alignment a problem worth having in the
   first place — see git history). top is now var(--header-row1-m)
   instead: the overlay starts exactly at row 1's own bottom edge and
   covers everything below it (row 2 + page content), leaving row 1
   itself — the wordmark, the hamburger — outside the overlay's own
   box entirely, not just visually on top of it.

   The overlay's z-index is still below .header-row1's own mobile z-index (one higher,
   see .header-row1's own comment) so row 1 paints above this even
   though the overlay is position:fixed — row 1 is position:static at
   this width by default and needs its own explicit stacking context
   to win against a fixed, z-indexed sibling regardless of DOM order;
   see there for why. js/header.js locks body scroll while this is
   open (document.body.style.overflow), so row 1 — normal-flow at
   this width, would otherwise scroll away with the page — stays
   exactly where it was when the hamburger was clicked. */
.mobile-overlay {
  position: fixed;
  display: none;
  flex-direction: column;
  top: var(--header-row1-m);
  right: 0;
  bottom: 0;
  left: 0;
  z-index: 1000; /* above the intro arrows and draggable windows — see .header-row1 (mobile) */
  background: var(--ground);
  padding: var(--s3);
}
.mobile-overlay.is-open { display: flex; }
/* Programmatic focus target only (js/header.js openOverlay) — not an
   interactive control, so no ring. */
.mobile-overlay:focus { outline: none; }

/* The links are type only, separated by --s2 (16px, tightened from
   --s4/32 in two steps — mobile fixes pass, direct instruction: "the
   entire menu should fit in the height of the viewport, no scrolling
   necessary"). Combined with the LINKS/CONTACT 2-column grids below,
   this is the mechanism that actually closes the gap: at --s3/24px
   the overlay's full content still measured 721px tall against a
   667px iPhone SE-height viewport (54px over); --s2 plus the grids
   gets it to fit with room at every common phone height tested
   (375x667 through 428x926) — see the grid rule's own comment for
   the exact number. No box, no overhang, no empty spacer — the
   stamped stack that used to style this is retired, see tokens.css.
   align-items:stretch (was flex-start) — needs full-width boxes now
   that dividers (see .overlay-link/.mobile-overlay-sublinks below)
   and the solid-fill CTA both span edge to edge; plain text stays
   left-aligned regardless, block-level elements don't center just
   because their box got wider. */
.mobile-overlay-stack {
  margin-top: 0;
  display: flex;
  flex-direction: column;
  align-items: stretch;
  gap: var(--s2);
  overflow-y: auto;
}

.overlay-link {
  display: block;
  font-size: var(--t-label);
  line-height: normal; /* consistent hover-box height with .header-nav — see comment there */
  color: var(--ink);
}
/* Active state — same cue as the pinned header's own row 1 nav
   (aria-current="page", set per-page in markup): the boldest weight
   in the family, uppercase, tracked caps. Mobile fixes pass — this
   overlay never had the treatment row 1 already uses. */
.overlay-link[aria-current="page"] {
  font-family: var(--font-nav-active);
  text-transform: uppercase;
  letter-spacing: var(--track-caps);
}

/* RULE + SHAPE location 16 (tokens.css) — dividers between the
   stack's top-level items: the three plain links, and each of the
   Links/Contact groups as a WHOLE (the rule sits after their
   sublinks, not between a group's trigger and its own sublinks —
   .overlay-link-trigger is excluded here for exactly that reason).
   :not(:last-child) so whichever element actually ends the stack
   (the signup link) never gets a trailing rule under it. */
.mobile-overlay-stack > .overlay-link:not(.overlay-cta):not(.overlay-link-trigger):not(.overlay-spacer):not(:last-child),
.mobile-overlay-stack > .mobile-overlay-sublinks:not(:last-child) {
  padding-bottom: var(--s2); /* matches the stack's own gap, see its comment — kept in step rather than adding a second value */
  border-bottom: var(--rule);
}

/* Empty row before the Links group — one link-row tall (a non-breaking
   space in an .overlay-link), no rule, no value of its own. */
.overlay-spacer { user-select: none; }

/* The overlay's own Request a Quote — replaces the letter-swap
   treatment (.cta-swap/.cta-letter, still in this file for the
   pinned header's own history — see there) with a full-width solid
   fill: --ink background, --ink-inverse (Alabaster) text, same
   ink/inverse pairing as .mobile-sticky-cta and the desktop
   .header-cta. Direct instruction — "the request a quote button is
   broken. replace it with a full width solid ink row... an eye
   catching CTA." Diagnosed first: the letter-swap JS
   (initCtaSwap in js/header.js) measures each letter's width via
   getBoundingClientRect() inside document.fonts.ready — which
   resolves shortly after page load, while .mobile-overlay is still
   display:none (only .is-open sets display:flex). Measuring inside a
   display:none ancestor returns an all-zero rect, so every letter got
   width:0 permanently baked in as an inline style — confirmed live:
   every .cta-letter's rect was {width:0}, collapsing the whole button
   to zero width. Removing the .cta-swap marker class from this
   element (weddings.html) stops that code path from touching it at
   all, rather than patching the timing bug for a treatment that's
   being replaced anyway. */
.overlay-cta {
  display: flex;
  align-items: center;
  justify-content: center;
  background: var(--ink);
  color: var(--ink-inverse);
  font-family: var(--font-masthead);
  font-size: var(--t-label);
  text-transform: uppercase;
  letter-spacing: var(--track-caps);
  padding-block: var(--s3);
  min-height: var(--tap-target-min);
  border-radius: var(--shape-firm-c); /* same cell shape as .mobile-sticky-cta's own ink fill */
}

/* Mobile LINKS/CONTACT sublinks are always expanded now (AMENDMENTS
   ROUND 2 §7) — no trigger, no toggle, no accordion. They render as
   plain items in .mobile-overlay-stack. See weddings.html for the
   markup change.

   No longer indented — mobile fixes pass, direct instruction ("take
   indents out of links and contact sections within menu"). Was
   padding-inline-start: var(--expand-indent) (24px); --expand-indent
   itself is unused now (still defined in tokens.css, not deleted —
   same "flag an orphaned token rather than silently remove it"
   practice this file already uses elsewhere, e.g. --font-display).

   padding-top removed (was --s3/24px) — mobile fixes pass: it stacked
   with .mobile-overlay-stack's own gap between this element and its
   trigger label above it, reading as "double spaced for no reason"
   (confirmed: 24px padding + the stack's own gap, two mechanisms for
   one edge). The stack's gap is now the sole mechanism, same as every
   other pair in the stack. */
.mobile-overlay-sublinks {
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  gap: var(--s3);
}
/* LINKS' 6 platforms and CONTACT's 2 emails, gridded 2-up instead of
   stacked in one column — LINKS' own single column (6 × 23px text +
   5 × --s3 gaps = 258px) was the single largest contributor to the
   overlay overflowing the viewport (measured 853px of content against
   an 844px viewport before any of this section's changes). Neither
   grid was asked for by name — a layout call made to satisfy the
   explicit "entire menu fits in the viewport, no scrolling"
   requirement, flagged as a deviation. Combined with the stack-gap
   tightening (see .mobile-overlay-stack's own comment), final
   measured overlay content height is 593px, fitting with room at
   every common phone height tested, 375x667 (iPhone SE, the shortest
   still sold) through 428x926. */
.mobile-overlay-sublinks--grid {
  display: grid;
  grid-template-columns: 1fr 1fr;
  column-gap: var(--s3);
  row-gap: var(--s3);
  align-items: flex-start;
}

/* .mobile-signup-label/.mobile-signup (the old live email-capture
   input) are retired — mobile fixes pass, direct instruction ("take
   out email field in hamburger"). Desktop's own header.header-signup
   made the same move earlier (see its own comment, further up this
   file): a plain link to the same Substack destination, not a form.
   Replaced by .overlay-signup below (weddings.html) — a plain
   .overlay-link, no dedicated rule needed. */

/* ---------- MASTHEAD ---------------------------------------------- */

/* Fills the viewport below the header exactly (100svh, not 100vh —
   vh breaks the peek on mobile browser chrome). Heading+clusters sit
   at the top with their own natural content height; the image
   stretches to the section's full height, so it naturally continues
   --hero-peek past where the text ends — that's the "peek" scroll
   cue, not a separate mechanism to build. */
.masthead {
  /* min-height, not height: on a real (non-placeholder) photo, forcing
     the image into whatever's left after the heading+clusters can
     starve it down to a sliver too short to show its subject at all
     — happened here (232px left the face cropped out entirely). The
     section is allowed to run taller than the viewport remainder on
     mobile so the image can have a sane minimum instead. */
  min-height: calc(100svh - var(--header-row1-m) - var(--header-row2-m));
  display: flex;
  flex-direction: column;
  justify-content: flex-start;
  padding-inline: var(--grid-margin);
  /* Was --s6 (64px) — reduced to --rule-gap (12px) so the gap
     between the pinned header and the masthead content matches the
     same 12px used for the other rule-to-rule/section-to-section
     gaps sitewide ("keep uniformity"). */
  padding-block-start: var(--rule-gap);
  gap: var(--s2); /* heading→subhead, subhead→photo; photo→table stays --s5 via .masthead-clusters margin-block-start */
}
@media (min-width: 768px) {
  .masthead {
    /* Back to a hard height, not the mobile min-height: desktop's
       image relies on height:100% resolving against a truly fixed
       ancestor height to stretch to exactly the section height —
       min-height would let the row grow to content instead, which
       is what min-height is for on mobile but breaks this. */
    height: calc(100svh - var(--header-row1-m) - var(--header-row2-m));
    display: grid;
    grid-template-columns: repeat(var(--grid-cols), 1fr);
    grid-template-rows: 1fr; /* without this the row defaults to auto (content-sized), so the image's height:100% resolves against that instead of the section's actual height — it wouldn't stretch to the full masthead height at all */
    column-gap: var(--grid-gutter);
    padding-inline: var(--grid-margin);
    /* Was --s7 (96px), then --s6 (64px) to sit closer to the header.
       Now --rule-gap (12px) — the same 12px used for the other
       section/rule gaps sitewide ("keep uniformity"), a bigger
       reduction than the earlier step. */
    padding-block-start: var(--rule-gap);
    gap: 0;
  }
}

/* AMENDMENTS ROUND 2 §7 — mobile order is heading, tagline, PHOTO,
   then the credentials table (was: heading, tagline, table, photo).
   display:contents unwraps this element at mobile so its children
   (heading/tagline/clusters) become direct flex items of .masthead
   itself, alongside .masthead-image (a true sibling already) — that
   lets `order` interleave the photo between tagline and the table
   even though they live at different DOM nesting levels. Desktop
   reverts to a normal box below since its own left-column layout
   (heading+tagline+clusters stacked together, image beside) doesn't
   need reordering — clusters already follows tagline in DOM order,
   which is what desktop wants too. */
.masthead-text {
  display: contents;
}
@media (min-width: 768px) {
  .masthead-text {
    display: flex;
    flex-direction: column;
    /* No single gap value works here anymore: heading-to-subhead is
       --rule-gap (12px, "tuck it tight"), subhead-to-table is the
       full --s8 (128px) that used to be the ONLY gap back when the
       subhead lived inside the table and .masthead-text had just two
       children. Margins on the individual children below carry both
       distances now instead of one shared flex gap. */
    grid-column: 1 / 7;
    align-self: start;
    container-type: inline-size;
  }
}

.masthead-heading { margin: 0 0 var(--rule-gap) 0; order: 1; }
@media (max-width: 767px) {
  /* AMENDMENTS ROUND 4 §3 budget check found gaps totaling 204px
     (60 + 96 + 48) against a stated ~120px target — heading and
     subhead's own margin-bottom were stacking with .masthead's own
     flex gap (--s5, 48px) on top of each, the same double-gap
     pattern fixed elsewhere in this file (Approach<->Videos, the
     Videos heading-to-player gap). Desktop doesn't have this problem
     — .masthead-text has no gap of its own at that width, margins are
     the only mechanism there (see its own comment) — so this is
     scoped to mobile only, not a change to the margin values
     themselves at desktop. Result: three gaps at a uniform 48px
     (144px total) instead of 60/96/48 (204px) — closer to the ~120px
     target than before, though not an exact match; no single --s
     token lands on 40px (the implied even split), and nothing asked
     for a new one. */
  .masthead-heading { margin-bottom: 0; }
}

/* Standalone line again — was moved into the credentials table as a
   spanning banner row, then moved back out ("take it out of that
   table and tuck it tight 12px underneath the masthead text").
   Aujournuit italic: the same serif-accent face used for package
   titles and half of Request a Quote's font-swap — see
   --font-serif in tokens.css. */
.masthead-subhead {
  margin: 0 0 var(--s5) 0;
  font-family: var(--font-serif);
  font-style: italic;
  font-size: var(--t-lead-m);
  color: var(--ink);
  order: 2;
}
@media (max-width: 767px) {
  /* See .masthead-heading's own comment above — same double-gap fix,
     same scope (mobile only). */
  .masthead-subhead { margin-bottom: 0; }
}
@media (min-width: 768px) {
  .masthead-subhead {
    /* Was --s8 (128px) — "move callouts table way closer to
       subheading," down to --rule-gap (12px), the sitewide close-
       adjacency standard used everywhere else a box needs to hug its
       neighbor. */
    margin: 0 0 var(--rule-gap) 0;
    font-size: var(--t-lead);
  }
}
.masthead-line {
  display: block;
  /* --font-masthead, not --font-display: confirmed after live
     preview in font-preview.html (Living Uncut Sans Stencil
     Semi-Bold, "gun it in... love it"). line-height/letter-spacing/
     uppercase are unchanged from Gloock's own treatment on purpose —
     the preview that got approved used these exact --lh-display/
     --track-display values (font-preview.html's masthead specimen
     references them directly), so reusing them here reproduces
     what was actually approved rather than guessing at new numbers
     for a font nobody asked to retune. */
  font-family: var(--font-masthead);
  font-size: var(--t-display-m);
  line-height: var(--lh-display);
  letter-spacing: var(--track-display);
  text-transform: uppercase;
  color: var(--ink);
  /* AMENDMENTS ROUND 2 §4 — the masthead reveal is removed
     completely. Heading and clusters appear immediately: no mask,
     no stagger, no fade. Was a staggered fade-up animation
     (--reveal-duration/--reveal-stagger, both since removed from
     tokens.css). */
}
@media (min-width: 768px) {
  .masthead-line { font-size: min(var(--t-masthead-heading), calc(var(--masthead-heading-fit) * 1cqw)); }
}


/* There is no credentials table any more: Top 12 became the Voice
   lockup (.masthead-voice below), Coverage moved to the structured
   data and footer, and Booking / Best of moved onto the Packages and
   My Approach heading rows (.heading-meta below), by direct
   instruction. Only the type classes the moved text still uses
   survive: .cl-tier1, .cl-tier2, .cl-link, .cl-ext. */

.cl-tier1 {
  margin: 0;
  font-size: var(--t-label);
  text-transform: uppercase;
  letter-spacing: var(--track-caps);
  color: var(--ink-quiet);
}

.cl-tier2 {
  margin: 0;
  font-size: var(--t-body);
  color: var(--ink);
}

/* "Take out all blue accent fonts for now" — was --accent/--font-accent
   (ParaRubber), same as .prose-link below. Weight is the only
   remaining non-color cue now that color isn't doing any of the
   work either. */
.cl-link {
  color: var(--ink);
  font-weight: var(--weight-strong);
}

.cl-ext {
  color: var(--ink-quiet);
  margin-inline-start: var(--s1); /* 8px after the text */
  font-weight: var(--weight-body); /* the arrow isn't part of the emphasized link text */
}

/* THE VOICE LOCKUP — the masthead's closing block, in the footprint the
   credentials table used to take: logo centred in the column, as big as the box
   allows, "Top 12 Finalist" tucked into its empty bottom-right corner, whole thing one link (the
   Top 12 playlist, same target the old cell had). The label reuses
   .cl-tier1 and the arrow reuses .cl-ext: no new type role. The logo
   asset has its white outline matched to --ground, so it sits on the
   page without a halo.

   DESKTOP: the block is exactly as tall as the old 2x2 table (two rows
   of rule-pad + label line + gap + value line, plus its three 1px
   rules, all from tokens), so its bottom edge lands where the table's
   did. That edge is what js (the interlock script on each page)
   measures to place the piano strip, so the strip, Packages and
   everything below stay put. The logo takes whatever the label and one
   --s1 gap leave, the same on /weddings and /events (no per-page growth) (object-fit: contain, so it scales by the smaller of
   box width and height and never distorts). Horizontally it centres in
   the same cols 1-6 span the table had (same right-edge gutter split).
   MOBILE: no fixed height; the logo is the full column wide, height from its ratio.
   order:4 — after the photo (order:3), as the table was. */
.masthead-voice {
  order: 4;
  margin-block-start: var(--s4); /* mobile: photo to this block, as it was photo to table */
}
.voice-lockup {
  position: relative; /* anchor for the label, tucked into the logo's empty corner */
  display: block;
  width: fit-content; /* the box is exactly the logo, left-aligned in the column */
  max-width: 100%;
  color: var(--ink);
  text-decoration: none;
}
/* "Top 12 Finalist" sits inside the logo's box at its bottom-right: bottom
   edge on the logo's bottom edge, right edge on the logo's right edge. The
   wordmark leans up to the right, which leaves that corner empty. */
.voice-lockup .cl-tier1 {
  position: absolute;
  right: 0;
  bottom: 0;
  white-space: nowrap;
  text-align: right;
}
.voice-logo {
  display: block;
  width: 100%;
  height: auto; /* the width/height attributes carry the ratio */
}
@media (min-width: 768px) {
  .masthead-voice {
    margin-block-start: 0; /* mobile-only spacer */
    margin-right: calc(var(--grid-gutter) / 2); /* same right edge the table had */
    height: calc(2 * (2 * var(--rule-pad) + (var(--t-label) + var(--t-body)) * var(--lh-body) + var(--s1)) + 3 * var(--rule-width));
  }
  /* The label line and its gap used to take room under the logo; the label
     now sits inside the logo's box, so that room is taken out here. Then
     doubled, by direct instruction ("twice as big as current"). Same
     height on /weddings and /events. */
  .voice-lockup { height: calc(2 * (100% - var(--voice-logo-shrink-h) - (var(--t-label) * var(--lh-body) + var(--s1)))); }
  /* Extra air between the subhead and the lockup. A relative offset, not a margin: the
     block's own box (what the piano-strip script measures) must not move, so neither
     can the strip, Packages, or anything below. The shrink above leaves the lockup
     well clear of the strip, so the room is there. */
  .voice-lockup { top: var(--s3); }
  .voice-logo { width: auto; height: 100%; max-width: 100%; }
}

/* A section heading with a quiet line on its right: Booking on Packages,
   Best of on My Approach. Heading left, line pushed to the right margin
   and baseline-aligned to the heading's first line; if the two don't fit
   (mobile) the line drops under the heading, still right-aligned. The row
   owns the heading's bottom margin so the heading itself carries none. */
.heading-row {
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  justify-content: space-between;
  column-gap: var(--s3);
  row-gap: var(--s1);
}
.heading-meta {
  display: flex;
  align-items: baseline;
  gap: var(--s1);
  margin: 0 0 0 auto;
  white-space: nowrap;
}

/* Placeholder — no photo assigned yet (Phase 5 TBD), and its implied
   shape (fixed width, viewport-dependent height) doesn't match any
   of tokens.css's five canonical ratios anyway. Needs a decision
   before a real photo replaces this. */
.masthead-image {
  /* Mobile fixes pass reverses AMENDMENTS ROUND 4 §3 again: heading,
     subhead, PHOTO, then credentials (order:3, third — see
     .masthead-clusters' own comment, now order:4/last). Back to
     AMENDMENTS ROUND 2 §7's original reading. The old special
     margin-top:var(--s3) that ROUND 4 §3 removed (it was justified by
     sitting "between tagline and photo" specifically) doesn't need
     reinstating — the parent's own gap (--s5, .masthead) still
     provides the only spacing here, same as every other adjacent
     pair, and the photo is adjacent to the subhead again now. */
  order: 3;
  /* Was --ratio-cine (2.39:1 letterbox, AMENDMENTS ROUND 4 §3) — at
     mobile content width that rendered the photo at ~146px tall,
     "cropped way too short." Switched to --ratio-portrait (4:5),
     the same token tokens.css already labels "homepage hero mobile"
     — a real crop-shape reversal, not a tweak, flagged rather than
     silent. See the object-position override below: the same 31%
     vertical anchor still reads correctly at this taller ratio since
     cover crops much less of the image now (confirmed via
     screenshot), so it was kept rather than re-measured from
     scratch. */
  aspect-ratio: var(--ratio-portrait);
  overflow: hidden;
  /* AMENDMENTS ROUND 2 §3 — every photograph sits inside a --rule
     box now, --rule-pad-photo padding. Reverses "flush in grid
     cells." box-sizing:border-box (global) keeps the outer
     dimensions this rule already computes unchanged — border+padding
     eat into the content area, not add to the box. */
  border: var(--rule);
  padding: var(--rule-pad-photo);
  border-radius: var(--shape-soft-a); /* AMENDMENTS ROUND 5 */
}
@media (min-width: 768px) {
  .masthead-image {
    grid-column: 7 / 13;
    grid-row: 1;
    aspect-ratio: auto;
    /* Other half of the gutter split with .masthead-clusters — see
       its own comment. Together: exactly one --grid-gutter between
       the table and the image, where there was 0px before. */
    margin-left: calc(var(--grid-gutter) / 2);
    /* Top is no longer pulled above the section's own padding — it
       now lines up with the heading's top edge instead of the
       header. Height is unchanged (still 100% + s7 + s9, same total
       photo size per instruction — "keep the dimensions") so
       removing the top pull just shifts the whole box down: the
       bottom now extends that same s7 further past the section's
       bottom edge than it used to, rather than the top reaching
       above it. .masthead has no overflow set (default visible), so
       this is free to actually show rather than get clipped. */
    margin-top: 0;
    margin-bottom: calc(var(--s9) * -1);
    height: calc(100% + var(--s7) + var(--s9));
  }
}
.masthead-image img {
  width: 100%;
  height: 100%;
  object-fit: cover;
  /* Anchored low, not flush to the bottom: this photo (book_mob.webp,
     shot in portrait, 9:16 — doesn't match any of tokens.css's five
     canonical ratios, see DEVIATIONS) has the subject in its lower
     half with a lot of empty wall space above, so most of the crop
     should still come off the top — but a full "bottom" anchor left
     no headroom at all above his raised hand. 70% keeps that
     headroom while still cropping most of the empty wall. Desktop
     only now — see the mobile override below for AMENDMENTS ROUND 4
     §3's different crop. */
  object-position: center 70%;
}
@media (max-width: 767px) {
  /* Originally measured for the 2.39:1 letterbox (AMENDMENTS ROUND 4
     §3 — centered the crop window on where the reflected face and
     the direct headshot's key features cluster, since the letterbox
     was too short to fit both faces whole). Now that the box is
     --ratio-portrait (4:5) instead, cover crops far less of the
     1400px-tall source overall, and 31% still lands the visible
     window on both faces plus most of the body — verified with a
     screenshot after switching, not just carried over blind. */
  .masthead-image img { object-position: center 31%; }
  .masthead-image--events img { object-position: center calc(31% - var(--events-hero-shift-m)); }
}



/* ---------- PACKAGES ------------------------------------------- */

/* Mobile layout intentionally not built yet — pending your review of
   the proposed stacked arrangement (see chat) before assuming one. */
.packages {
  position: relative; /* anchor for the piano strip's absolute position at desktop */
  padding-block-start: var(--section-gap-m);
  padding-inline: var(--grid-margin);
}
@media (min-width: 768px) {
  .packages { padding-block-start: var(--section-gap); }
}

/* AMENDMENTS ROUND 3 §2 — cols 1-3 is its own unit now: piano strip
   + the micro-weddings note stacked beneath it, both at the same
   width. Deliberately NOT position:relative: that would make this
   div the containing block for the strip's position:absolute
   instead of .packages, and since this div already sits inset by
   .packages's own padding-inline, the strip's `left: var(--grid-
   margin)` would then apply on TOP of that inset instead of from
   the true edge — doubling the offset and shifting the strip right.
   (This is exactly what happened when the rule briefly did set
   position:relative here — "piano image moved right for some
   reason.") Stays a plain, non-positioned wrapper; .packages remains
   the strip's containing block. */

/* AMENDMENTS ROUND 2 §3 — every photograph sits inside a --rule box.
   AMENDMENTS ROUND 3 §1 — --rule-pad-photo is 0 now, edge to edge,
   no inset. box-sizing:border-box (global) keeps this rule's own
   outer width/aspect-ratio math unchanged. */
.packages-piano-strip {
  display: block;
  width: 100%;
  margin-bottom: var(--rule-gap);
  border: var(--rule);
  padding: var(--rule-pad-photo);
  border-radius: var(--shape-soft-b); /* AMENDMENTS ROUND 5 */
  overflow: hidden; /* corners stay square inside a rounded border otherwise */
}
.packages-piano-strip img {
  display: block;
  width: 100%;
  height: auto;
}
@media (min-width: 768px) {
  .packages-piano-strip { margin-bottom: 0; }
  .packages-piano-strip {
    display: block;
    position: absolute;
    left: var(--grid-margin);
    /* 192px (--s9) above .packages-container's own top — the
       interlock script sets this exactly against the credentials
       table, this CSS value is only the pre-JS/no-JS fallback. */
    top: calc(var(--section-gap) - var(--s9));
    width: calc(3 * var(--col-w) + 2 * var(--grid-gutter)); /* cols 1-3, ~318-324px depending on breakpoint */
    aspect-ratio: var(--ratio-tall); /* 1:2 — ~636-648px tall */
    overflow: hidden;
  }
  .packages-piano-strip img {
    width: 100%;
    height: 100%;
    object-fit: cover;
  }
}

/* AMENDMENTS ROUND 3 §2 — the heading moves inside the container,
   sharing its border with the rows beneath (same construction as the
   credentials table's banner row) — no longer a standalone element
   floating above the rows box. */
/* Packages TABLE removed ("get rid of packages table"): no outer box
   around heading + rows, no inner channel padding. The row cells keep
   their own blob boxes and now run flush to the container's edges. */
.packages-container {
  border: 0;
}
@media (min-width: 768px) {
  .packages-container {
    /* Starts at col 4 — cols 1-3 are reserved for the piano strip. */
    margin-inline-start: calc(3 * var(--col-w) + 3 * var(--grid-gutter));
  }
}

/* Shared treatment for every free-standing section title (currently
   My Approach and Videos; What Couples Say / Song List will pick
   this up once built). Was inconsistent — My Approach centered,
   Videos left, both full-width — unified to: stacked one word per
   line, cols 1-6, left aligned, 56px/40px, and the masthead's own
   already-approved --lh-display/--track-display numbers. --font-
   heading is unchanged (still the assigned face for this role) —
   these are the SAME literal font file as --font-masthead, so
   reusing its approved leading/tracking here isn't a new font
   decision, just matching numbers already confirmed for this face.

   Packages' heading is deliberately NOT part of this — it's the
   shared bordered-table header cell built in AMENDMENTS ROUND 3 §2
   (padding: var(--rule-pad), border-bottom shared with the rows,
   positioned inside a box that itself starts at column 4). Forcing
   it into cols 1-6/stacked-by-word would break that structure. Ask
   before folding Packages into this — not done silently either way. */
.section-heading {
  grid-column: 1 / -1;
  margin: 0 0 var(--s5) 0;
  text-align: left;
  color: var(--ink);
  font-family: var(--font-heading);
  text-transform: uppercase;
  font-size: var(--t-section-m);
  line-height: var(--lh-display);
  letter-spacing: var(--track-display);
}
.section-heading-word { display: block; }
/* MOBILE SPACING FIX — diagnosed, not guessed: .approach/.videos/
   .songs each set their own row-gap: var(--s5) (48px) to space ALL
   their stacked children at mobile, including the heading→content
   edge. This rule's base margin-bottom above is ALSO --s5 (48px) and
   was never zeroed at that edge for mobile (only for desktop, per
   section, below) — so the two mechanisms stack to 96px measured,
   double the --heading-gap-m token (24px) this edge is supposed to
   use. Cancels the row-gap's contribution at just this edge via a
   negative margin so the two ADD to exactly --heading-gap-m, without
   touching row-gap itself — each section's OTHER internal gaps
   (approach-left→photo, search→filter→decades→list in Song List)
   still need their own --s5 and weren't reported broken. Not applied
   to the base rule or to .quote-heading: Request a Quote's own
   section (.quote) sets no row-gap at all, so its heading's
   margin-bottom is already its ONLY spacing mechanism and is already
   correct — this cancellation would double-subtract there. */
@media (max-width: 767px) {
  .section-heading.approach-heading,
  .section-heading.videos-heading {
    margin-bottom: calc(var(--heading-gap-m) - var(--s5));
  }
}
@media (min-width: 768px) {
  .section-heading {
    grid-column: 1 / 7;
    font-size: var(--t-section);
  }
}

.packages-heading {
  margin: 0;
  padding: 0;
  border: 0;
  color: var(--ink);
  font-family: var(--font-heading);
  text-transform: uppercase;
  letter-spacing: var(--track-caps);
  font-size: var(--t-section-m);
}
.packages-heading-row { margin: 0 0 var(--heading-gap-m); }
@media (min-width: 768px) {
  .packages-heading { font-size: var(--t-section); }
  .packages-heading-row { margin-bottom: var(--heading-gap); }
}

/* AMENDMENTS ROUND 3 §2 — no longer its own bordered box (the shared
   container border above covers it); --rule-gap (12px) is now just
   its own padding, the channel to the container's border on every
   side. Gap between row boxes is --s4 (32px) now, not --rule-gap —
   the container's outer channel and the inter-row gap are two
   different values, where they used to be the same one. */
.packages-rows {
  display: flex;
  flex-direction: column;
  padding: 0;
  /* AMENDMENTS ROUND 5 — rows get their own border + shape again (see
     .packages-row below), which needs real separation to avoid
     recreating the doubled-line look the border-bottom-divider
     version was built to fix. --s4 (32px) is the same gap value this
     project already proved sufficient for that, earlier in its
     history. */
  gap: var(--s4);
}

/* "Get rid of the double lines in the packages table. so now boxes
   WITHIN the table" — each row was its own fully-bordered box nested
   inside the container's own border, so two adjacent rows'
   borders (plus the gap between them) read as doubled lines. One box
   now: the container's own border only, rows separated by a single
   border-bottom divider each (none on the last), no gap between them.
   Not clickable (no <a>, no hover, no form prefill) — plain content. */
.packages-row {
  display: flex;
  flex-direction: column;
  gap: var(--s2);
  padding: var(--rule-pad);
  /* AMENDMENTS ROUND 5 — reintroduces a full per-row border (was
     border-bottom only, one shared box) so "Package row boxes" has
     corners to round via --shape-mid-c. Confirmed directly with the
     designer: this reverses the ROUND 3/4 double-line fix; --s4 gap
     between rows (see .packages-rows) replaces the border-bottom
     dividers as the thing that keeps rows visually distinct. */
  border: var(--rule);
  border-radius: var(--shape-mid-c);
  color: inherit;
  text-decoration: none;
}
@media (max-width: 767px) {
  /* Mobile fixes pass — "--rule-pad 16px inside each card on mobile,
     not 24px." Scoped to just this component's own padding, not a
     change to --rule-pad itself (still 24px everywhere else it's
     used — the credentials table cells, etc.). --s2 already equals
     the requested 16px, reused rather than adding a new token for
     the same value. */
  .packages-row { padding: var(--s2); }
}

/* Mobile-only compact layout — direct instruction: name+price on one
   line, keys+description on a second line beneath it, replacing the
   previous four-line stack (name / keys / description / price, each
   its own flex row). display:contents at desktop unwraps both back
   into flat children of .packages-row's own grid (name/keys/desc/
   price at grid-column 1/4, 4/5, 5/8, 8/10) — same technique as
   .masthead-cluster-col and .songs-sidebar/.songs-table elsewhere in
   this file, so the existing desktop grid needs no changes at all. */
.pkg-row-line1 {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: var(--s2);
}
.pkg-row-line2 {
  display: flex;
  align-items: center;
  gap: var(--s2);
}
@media (min-width: 768px) {
  .pkg-row-line1,
  .pkg-row-line2 {
    display: contents;
  }
}

/* AMENDMENTS ROUND 4 §4 reverses ROUND 2 §6 — rows are <a> again,
   linking to Request a Quote. "The only affordance is the hover
   state: the row inverts — --ink fill, #E5E0DC text, including the
   blobs. No arrow, no 'Inquire ->', no underline, no cursor change
   beyond pointer." Gated to hover:hover on purpose — on touch there's
   no indication at all and the row just navigates on tap, "accepted"
   per the amendment's own text, not an oversight. */
@media (hover: hover) and (pointer: fine) {
  .packages-row:not(.packages-row--note):hover {
    background: var(--ink);
  }
  .packages-row:hover .pkg-name,
  .packages-row:hover .pkg-desc-col,
  .packages-row:hover .pkg-price-col,
  .packages-row:hover .pkg-most-popular {
    color: var(--ink-inverse);
  }
  .packages-row:hover .pkg-key svg path {
    stroke: var(--ink-inverse);
  }
  .packages-row:hover .pkg-key--filled svg path {
    fill: var(--ink-inverse);
  }
}
@media (min-width: 768px) {
  .packages-row {
    display: grid;
    /* cols 4-12 renumbered 1-9 internally: name=1-3, keys=4, desc=5-7, price=8-9 */
    grid-template-columns: repeat(9, 1fr);
    column-gap: var(--grid-gutter);
    align-items: center;
    min-height: var(--s8); /* 128px — a floor, not a hard clip: The Full Journey's longer description can push a row taller */
  }
}

.pkg-name-col { grid-column: 1 / 4; }
.pkg-keys-col {
  grid-column: 4 / 5;
  display: flex;
  align-items: center;
  gap: var(--pkg-key-gap);
}
.pkg-desc-col {
  grid-column: 5 / 8;
  font-size: var(--t-body);
  color: var(--ink-quiet);
}
@media (max-width: 767px) {
  /* Fills the rest of .pkg-row-line2 beside the fixed-width keys and
     wraps beneath if long — min-width:0 is the standard flex-item fix
     so a long description (e.g. The Full Journey's) actually wraps
     inside its own share of the row instead of overflowing past it. */
  .pkg-desc-col {
    flex: 1 1 auto;
    min-width: 0;
  }
}
.pkg-price-col {
  font-size: var(--t-body);
  color: var(--ink);
  font-family: var(--font-masthead);
  text-transform: uppercase;
  letter-spacing: var(--track-caps);
}
@media (max-width: 767px) {
  /* Diagnosed: .pkg-row-line1 is a flex row with default flex-shrink
     (1) on both children — even though name+price together fit the
     row's own width with room to spare, the browser's automatic
     min-content shrink still wrapped this one's short text onto two
     lines ("FROM" / "$1,750") for no visible reason. flex-shrink:0 +
     nowrap pins it to its single-line width so it never wraps; the
     name (which can legitimately be long — "The Full Journey") keeps
     the default shrink/wrap behavior and takes whatever's left. */
  .pkg-price-col {
    flex-shrink: 0;
    white-space: nowrap;
  }
}
@media (min-width: 768px) {
  /* Bug found live on production, not anticipated: all four cells
     (name/keys/desc/price) only ever specified grid-COLUMN, leaving
     grid-row: auto — fine as long as the auto-placement cursor's
     column position only ever increases. It doesn't: DOM order here
     is name, price, keys, desc (line1 holds name+price, line2 holds
     keys+desc — the mobile grouping, unwrapped via display:contents
     above), but COLUMN order is name(1-3), keys(4), desc(5-7),
     price(8-9). After price places at columns 8-9, the cursor sits
     past column 9; keys' column-start (4) is behind the cursor, which
     CSS Grid's sparse packing reads as "doesn't fit here," so it
     wraps keys+desc onto a second implicit row instead of overlapping
     row 1 — two full row-heights stacked (measured 196px live, vs.
     the intended single ~128px row), with each row's own content
     merely centered inside the inflated box, which is what actually
     made it "look weird" rather than simply too short or clipped.
     grid-row: 1 on every cell removes the guesswork the DOM-order/
     column-order mismatch was exploiting. Scoped to this media query
     since grid-row only takes effect once these are actual grid items
     (desktop, after the contents-unwrap above) — harmless no-op on
     the flex-item mobile layout otherwise. */
  .pkg-name-col,
  .pkg-keys-col,
  .pkg-desc-col,
  .pkg-price-col {
    grid-row: 1;
  }
  .pkg-price-col {
    grid-column: 8 / 10;
    text-align: right;
  }
}

.pkg-most-popular {
  display: block;
  margin-bottom: var(--s1);
  font-size: var(--t-label);
  text-transform: uppercase;
  letter-spacing: var(--track-caps);
  color: var(--ink);
}
.pkg-name {
  display: block;
  font-size: var(--t-lead-m);
  color: var(--ink);
  font-family: var(--font-pkg-title);
}
@media (min-width: 768px) {
  .pkg-name { font-size: var(--t-lead); }
}

/* Each key is its own hand-drawn-looking blob outline now — "little
   squigglies reminiscent of the logo," each one a different shape
   (see the inline SVGs in weddings.html) rather than three identical
   squares. Same stroke/fill rule as the squares they replace: empty
   = outline only, filled = solid fill (plus the outline, matching the
   old square's border-and-background-together look). */
.pkg-key {
  flex: 0 0 auto;
  width: var(--pkg-key-size);
  height: var(--pkg-key-size);
}
.pkg-key svg { width: 100%; height: 100%; display: block; }
.pkg-key svg path {
  fill: none;
  stroke: var(--ink);
  stroke-width: 1.5;
}
.pkg-key--filled svg path { fill: var(--ink); }

/* AMENDMENTS ROUND 3 §2 — moved to cols 1-3, directly beneath the
   piano strip (was cols 4-12, below the rows). At desktop this sits
   in the same absolute-positioning context as the strip — see
   positionPackagesInterlock() in weddings.html, which now also sets
   this element's `top` (strip bottom + --rule-gap) and matches its
   left/width. The plain margin-top here is the mobile/no-JS value
   only. */
/* Moved from .packages-side (cols 1-3, under the piano strip) into
   its own final row of the table — "move it into its own final cell,
   shorter than the others, text in italics." Plain paragraph now, no
   positioning of its own; the row it lives in handles layout. */
.packages-note {
  margin: 0;
  font-size: var(--t-body);
  font-style: italic;
  color: var(--ink);
}

/* Last row, note only — not part of the name/keys/desc/price grid or
   the equal-height set below (.packages-row:not(.packages-row--note)).
   min-height:0 overrides the desktop 128px floor so this row sizes to
   its own (shorter) content instead. */
@media (min-width: 768px) {
  .packages-row--note {
    display: block;
    min-height: 0;
  }
}

.prose-link {
  color: var(--link-color);
  font-weight: var(--link-weight);
  text-decoration: var(--link-decoration);
  /* AMENDMENTS ROUND 4 §5 — "permanent 1px underline, 0.15em offset."
     text-decoration alone only sets the LINE (underline/none), not
     its thickness or offset — these were missing entirely before,
     so the underline (once --link-decoration became "underline"
     instead of "none") was rendering at the browser's own auto
     thickness/offset, not the specified values. Same two tokens the
     Videos external link already used for its own underline. */
  text-decoration-thickness: var(--rule-width);
  text-underline-offset: var(--link-underline-offset);
}

/* "Reach out" used to carry --accent as an explicit, flagged
   exception to the sitewide "take out all blue accent" instruction.
   --accent is now removed sitewide, permanently ("never blue on the
   page") — that tension is resolved, not deferred. The override rule
   that used to live here is gone: with no color left to apply, it
   was identical to the base .prose-link rule below, so the markup's
   .packages-note-link class is now inert (harmless, left in place —
   not part of this change). */

/* No hover rule here for .prose-link — the sitewide color-only hover
   system was removed entirely earlier ("take away blue hover state"),
   and --link-color is --ink now anyway (see tokens.css — "take out
   all blue accent fonts for now"). Weight-700 is the only remaining
   cue (§9, "prose links marked by weight, not colour alone"), which
   was always the fallback distinction regardless of color. .packages-row
   has no hover at all now — rows aren't clickable (§6). */

/* My Approach's own closing line — "Reach out now" opens the CONTACT
   modal. Used to carry --accent, same scope decision as
   .packages-note-link above; --accent is gone sitewide now, so
   color/weight/decoration all match the base .prose-link rule and
   aren't restated here. The one real difference that survives:
   hover gains an underline, which .prose-link never has on its own. */
@media (hover: hover) and (pointer: fine) {
  .prose-link.approach-prose-link:hover { text-decoration: underline; }
}


/* ---------- ANCHOR SCROLL OFFSET ----------------------------------- */

/* AMENDMENTS ROUND 4 §1 — mobile value simplified along with the
   header rebuild: "scroll-margin-top: 36px on every section — only
   row two pins now" (row 1 scrolls away at mobile, so it's not part
   of the pinned stack a jump-link needs to clear). Was
   --header-links-m + --header-cta-row-m (128px), the old wrapped-
   links-plus-CTA-row total — both tokens retired with that structure.

   Desktop (>=768px) is unchanged by ROUND 4 and still pins both rows,
   so it still needs their sum, not a flat 36px. Pre-existing
   discrepancy, not introduced or fixed here: an earlier comment
   claimed this calc equals "112px, the value that matches what's
   actually built," but the literal token math has never produced
   that — it's --header-row1-m (64px) + --header-row2-m, which is
   now 100px (was 96px before ROUND 4's 32->36 change to
   --header-row2-m). Flagged, not silently corrected — a real desktop
   value question outside this amendment's mobile-only scope. */
main > section[id] {
  scroll-margin-top: var(--header-row2-m);
}
@media (min-width: 768px) {
  main > section[id] {
    scroll-margin-top: calc(var(--header-row1-m) + var(--header-row2-m));
  }
}


/* ---------- MY APPROACH ------------------------------------------ */

/* AMENDMENTS ROUND 3 §3 — the three-equal-panels reading ("a caption
   stranded between two pictures") is gone. One wide prose column
   against one photo on the right — column 7 stays empty as a
   deliberate channel, wider than the grid's own gutter alone.

   The Videos section's own desktop photo was originally stacked
   under the My Approach portrait in that same right column (per the
   amendment's own text); moved into the left column, underneath the
   prose, per later direction ("pull that videos image over to the
   left column underneath my approach copy") — .approach-left now
   groups prose + that photo as one column-1-6 unit, leaving the
   portrait alone in cols 8-12. */
/* padding-block-START only, not the shorthand (both sides) — matches
   .packages's own pattern. Diagnosed: with full padding-block on both
   .approach and .videos, two adjacent sections' bottom+top padding
   added up to a DOUBLE section-gap (256px) between them — "the giant
   gap between my approach and videos." .packages already established
   the correct sitewide pattern (top-only; the next section's own top
   padding is what creates the boundary gap), .approach and .videos
   just weren't following it. Fixed on both, not just one, so the
   same bug doesn't reappear at the Videos -> next-section boundary
   once that section is built. */
.approach {
  padding-block-start: var(--section-gap-m);
  row-gap: var(--s5);
}
@media (min-width: 768px) {
  .approach {
    padding-block-start: var(--section-gap);
    align-items: start; /* prose top-aligns with the portrait photo, not vertically centered */
    /* Was --rule-gap (12px, "move up the contents of My Approach to
       be 12px from the content above it"). Superseded: "move the
       body of My Approach to that 128px divide... keep My Approach
       [the heading] exactly where it's at" — the heading's own
       position is set independently (see the interlock script
       aligning it to the Packages table's bottom edge) and is
       untouched by this; only the gap between the heading and
       everything below it changes, from 12px to --s8 (128px, the
       same value --section-gap itself uses, though this is heading-
       to-body spacing within one section, not a section boundary,
       so it's written as the raw scale token here rather than
       --section-gap). .section-heading's shared margin-bottom stays
       zeroed for this instance (see below) so this row-gap is still
       the only mechanism — no stacking. */
    row-gap: var(--s8);
  }
}

/* .approach-heading's own layout/type moved to the shared
   .section-heading rule — see there. .approach's own row-gap
   (above) is now the ONLY gap between the heading and the content
   below it — the shared rule's margin-bottom is zeroed here so the
   two don't stack. Compound selector, not just .approach-heading
   alone: guarantees this wins over .section-heading regardless of
   source order (same reasoning as the .prose-link compound
   overrides elsewhere in this file). */
@media (min-width: 768px) {
  .section-heading.approach-heading { margin-bottom: 0; }
}

/* The My Approach heading now sits in a .heading-row (heading left, Best
   of line right). The row is the grid item and takes over what the
   heading had: full-width, and its bottom margin (mobile only, the
   heading-to-content correction above; desktop's is the row-gap). */
.approach-heading-row { grid-column: 1 / -1; }
@media (max-width: 767px) {
  .approach-heading-row { margin-bottom: calc(var(--heading-gap-m) - var(--s5)); }
}
.heading-row > .section-heading.approach-heading { margin: 0; grid-column: auto; }

/* One line, not stacked-by-word — mobile only now. Was unconditional
   (same exception as .songs-heading/.quote-heading, "My Approach on
   one line on mobile" — verified "MY APPROACH" measures 254px at the
   unchanged --t-section-m (40px), well inside the 350px mobile
   content column). Desktop reverts to the shared rule's default
   (.section-heading-word { display: block }, stacked one word per
   line) per "bring the My Approach title down so the lines split
   along the bottom line of the Packages table, My above it, Approach
   under it" — that needs two stacked lines, not one, so the
   unconditional override had to narrow to mobile. */
@media (max-width: 767px) {
  .section-heading.approach-heading .section-heading-word { display: inline; }
}

/* Gap above the reviews plate is --s6 (64px) from the last paragraph
   of prose, per direct instruction — supersedes the earlier 12px
   --rule-gap reading (a stale carryover from when this gap's other
   named reference point was a since-removed floral photo, not the
   carousel that actually occupies this flex slot now). One value at
   every width; nothing in the instruction distinguishes mobile from
   desktop here. */
.approach-left {
  grid-column: 1 / -1;
  display: flex;
  flex-direction: column;
  gap: var(--s6);
}
@media (min-width: 768px) {
  .approach-left {
    grid-column: 1 / 7;
  }
}

.approach-prose { color: var(--ink); }
.approach-prose p {
  /* Sitewide --measure (68ch), not the old two-photo layout's
     squeezed 48-character column — this column is wide enough now
     that the full cap applies. */
  max-width: var(--measure);
  margin: 0 0 var(--s3) 0;
}
.approach-prose p:last-child { margin-bottom: 0; }

.approach-photo--portrait {
  grid-column: 1 / -1;
}
@media (min-width: 768px) {
  /* Was 8/13 (cols 8-12, leaving col 7 as the deliberate empty
     channel next to .approach-left — see this section's opening
     comment). Widened to match .masthead-image's own span exactly
     (grid-column: 7/13, site.css MASTHEAD) — measured equal on both
     ends (left: 728px, width: 664px at 1440px viewport) — per "bring
     the My Approach image over... so it's the same width as the hero
     image." The column-7 channel is gone at this breakpoint now,
     the photo butts directly against .approach-left. */
  .approach-photo--portrait { grid-column: 7 / 13; }
}

/* AMENDMENTS ROUND 3 §1 — --rule-pad-photo is 0, edge to edge. */
.approach-photo {
  border: var(--rule);
  padding: var(--rule-pad-photo);
  overflow: hidden;
  border-radius: var(--shape-soft-c); /* AMENDMENTS ROUND 5 */
}
.approach-photo img {
  display: block;
  width: 100%;
  height: 100%;
  object-fit: cover;
}
.approach-photo--portrait { aspect-ratio: var(--ratio-portrait); } /* 4:5 at every width — cropped via object-fit:cover from the same asset, not a native ratio. Desktop height is then overridden by the inline position() script to match the text column. */

/* ---------- MY APPROACH — REVIEWS CAROUSEL, INVERTED PLATE ---------
   Rebuilt as a filled --ink plate — the site's third (after the error
   plate and the selected Videos tab; flagged per instruction each
   time one of these ships, not silent — see .videos-tab.is-selected's
   own comment). Same cols 1-6 slot beneath the prose, same
   --shape-mid-a shape (RULE + SHAPE location 14, unchanged) and same
   MOTION entry 5 transform-only slide (tokens.css — unchanged). The
   fill, the kicker, and the merged bottom controls row are new.

   Slides sit side by side in a flex track; .approach-reviews-viewport
   clips to one slide's width via overflow:hidden, and paging is a
   translateX on the track (js in weddings.html). The track's own
   height is never set explicitly, so flex's default cross-axis
   stretch sizes it to the TALLEST of the three reviews automatically
   — "height fixed to the longest, so nothing jumps between slides"
   falls out of that stretch for free, no per-slide measurement
   needed. The shortest review's slide just gets empty space below its
   content instead — the top-aligned-content / bottom-pinned-controls
   split described in the brief is that same stretch, plus controls
   sitting outside the stretched viewport in normal flow below it. */
.approach-reviews {
  position: relative;
  display: flex;
  flex-direction: column;
  background: var(--ink);
  border: var(--rule); /* wraps the whole plate, so the page-coloured controls bar reads as inside the box */
  border-radius: var(--shape-mid-a);
  /* Block-only here: 32px at the top (--s4), --rule-pad (24px) at the
     bottom. Horizontal --rule-pad moves to .approach-review and
     .approach-reviews-controls instead of living here — see
     .approach-reviews-viewport's comment for why. */
  padding: var(--s4) 0 0 0;
  overflow: hidden; /* the bottom bar's square edges take the plate's blob corners */
}
.approach-reviews-viewport {
  overflow: hidden;
  /* Deliberately zero inline padding, same reasoning as this
     component's original build: overflow:hidden clips at the PADDING
     edge, not the content edge, so any inline padding here would
     move the clip boundary inward and leave a strip where the
     adjacent (off-screen) slide's leading glyph can bleed through
     mid-slide. Confirmed by removing this and screenshotting at
     390px — a sliver of the next review's text showed at the right
     edge. Each .approach-review insets its own text from the box's
     left/right edges instead (below), so the clip boundary stays
     exactly flush with the viewport's true edge and there's nothing
     of the next slide's content sitting in the danger zone to bleed. */
}
.approach-reviews-track {
  display: flex;
  transition: transform var(--timeline-transition) var(--ease);
}
.approach-review {
  flex: 0 0 100%;
  min-width: 0;
  margin: 0;
  padding-inline: var(--rule-pad);
}
/* Static — lives directly in the plate now, not inside a sliding
   .approach-review, so it needs its own horizontal inset to line up
   with the quote text below it (the plate itself is padding-inline:0;
   see .approach-reviews' own comment for why). */
.approach-review-kicker {
  margin: 0 0 var(--s3) 0;
  padding-inline: var(--rule-pad);
  color: var(--ink-quiet-inverse);
  font-size: var(--t-label);
  text-transform: uppercase;
  letter-spacing: var(--track-caps);
}
.approach-review-quote {
  margin: 0 0 var(--s2) 0;
  color: var(--ink-inverse);
  font-family: var(--font-display);
  font-size: var(--t-lead);
  line-height: var(--lh-label);
}
/* Name/event: 15px caps, +0.10em tracking, both --font-body — the
   amendment that first specified this said "all Archivo," but
   Archivo isn't a live face on this site (swapped for Host Grotesk
   before this round — see tokens.css FONT FACES and BRIEF.md
   AMENDMENTS ROUND 6 §18, which resolved this exact same stale
   reference the same way). --s0 for the name-to-event gap reuses the
   value BRIEF.md's original What Couples Say section established
   (4px) — unchanged, not addressed by this rebuild. */
.approach-review-attr {
  display: flex;
  flex-direction: column;
  gap: var(--s0);
  font-size: var(--t-label);
  font-family: var(--font-body);
  text-transform: uppercase;
  letter-spacing: var(--track-caps);
}
.approach-review-name { color: var(--ink-inverse); }
.approach-review-event { color: var(--ink-quiet-inverse); }

/* Bottom bar: an --ground (Alabaster) strip flush to the plate's left,
   right and bottom edges, holding the arrows and the "more reviews"
   links (The Knot, GigSalad) as cells split by --ink hairlines
   (--rule). Gap above it (--s4) is carried over unchanged from this
   component's original build. Below 768px the bar wraps: arrows on
   their own row, the two links sharing the row beneath. */
.approach-reviews-controls {
  display: flex;
  flex-wrap: wrap;
  align-items: stretch;
  margin-top: var(--s4);
  background: var(--ground);
  color: var(--ink);
}
.approach-reviews-controls > * {
  display: flex;
  align-items: center;
  padding: var(--s2) var(--rule-pad);
}
.approach-reviews-controls > * + * { border-left: var(--rule); }
@media (max-width: 767px) {
  .approach-reviews-arrows { flex: 1 1 100%; border-bottom: var(--rule); }
  .approach-reviews-controls > .approach-reviews-more-link { flex: 1 1 0; }
  .approach-reviews-controls > .approach-reviews-arrows + .approach-reviews-more-link { border-left: 0; }
}
@media (min-width: 768px) {
  .approach-reviews-controls > .approach-reviews-more-link { flex: 1 1 0; }
}
/* Arrows — blob-shaped, --shape-firm family ("interactive, so they
   get the least organic level"). Distinct variants per the site's
   existing paired-element convention (e.g. .reviews-photo--couples/
   songs took different --shape-soft variants) — logged in BRIEF.md's
   shape assignment table. Outline + transparent fill at rest (dark-
   ground counterparts of the light-plate original: --ink-inverse
   instead of --ink for both border and glyph), fill/glyph invert on
   hover, desktop only — no rest/hover distinction was ever specified
   beyond that, so nothing else changes. */
.approach-reviews-arrows {
  display: flex;
  gap: var(--s2);
}
.approach-reviews-arrow {
  display: flex;
  align-items: center;
  justify-content: center;
  width: var(--tap-target-min);
  height: var(--tap-target-min);
  border: var(--rule);
  background: transparent;
  color: var(--ink);
  font-size: var(--t-lead);
  line-height: 1;
  padding: 0;
  cursor: var(--cursor-hand);
}
.approach-reviews-arrow--prev { border-radius: var(--shape-firm-b); }
.approach-reviews-arrow--next { border-radius: var(--shape-firm-c); }
@media (hover: hover) and (pointer: fine) {
  .approach-reviews-arrow:hover {
    background: var(--ink);
    color: var(--ink-inverse);
  }
}
/* The sitewide --ink focus ring (tokens.css, applied globally via
   :focus-visible) doesn't show on an --ink plate — every focusable
   control in here (both arrows, the Knot link) gets the --ink-inverse
   counterpart instead, scoped to the plate rather than added per
   element. */
.approach-reviews :focus-visible {
  outline: var(--focus-ring-inverse);
  outline-offset: var(--focus-offset);
}
/* ...except in the Alabaster bottom bar, where the normal --ink ring is
   the one that shows. Inset so the plate's overflow clip can't cut it. */
.approach-reviews-controls :focus-visible {
  outline: var(--focus-ring);
  outline-offset: calc(-1 * var(--s0));
}

/* Carried over unchanged from the retired What Couples Say section —
   not named in the amendment that first moved it here (only the
   section it used to live in was), kept rather than silently dropped
   since nothing said to retire this external CTA. Flagged in
   DEVIATIONS. Now inside the plate's bottom row instead of stacked
   below it. */
.approach-reviews-more-link {
  margin: 0;
}
.approach-reviews-more-link a {
  color: var(--ink);
  font-size: var(--t-label);
  text-decoration: underline;
  text-decoration-thickness: var(--rule-width);
  text-underline-offset: var(--link-underline-offset);
}
.approach-reviews-more-ext {
  color: var(--ink-quiet);
  margin-inline-start: var(--s1);
}

@media (prefers-reduced-motion: reduce) {
  .approach-reviews-track { transition: none; }
}

/* ---------- VIDEOS -------------------------------------------------- */

/* Tab table: same cell construction as the credentials table
   (.masthead-clusters) — one outer rule box, shared borders between
   cells, no double lines. Two columns (text, keys) x three rows.
   The vertical divider is .videos-tab-text's own border-right, not a
   CSS grid column line — every row's keys cell is the same fixed
   width (3 keys, constant gap), so the divider lands at the same x
   position on every row without needing grid/display:contents at
   all. That sidesteps the accessibility risk of display:contents on
   an interactive, role="tab" element (a real concern in at least
   older Safari; the credentials table's own use of the technique is
   on plain non-interactive cells, not click/keyboard targets).

   Selected state is now a full --ink fill on the row, all type
   inverted to --ink-inverse — a second filled dark plate on the
   site, after the error plate (see .header-cta's own comment on the
   same tension). Flagged per instruction, not silent; watch whether
   a third one shows up. Hover (unselected rows only) is a color-only
   cue, title --ink-quiet to --ink, no weight change — weight-strong
   is reserved for the actual selected state so hover reads as a
   preview, not a second "selected" look. */
/* padding-block-START only — see the matching comment on .approach
   above; same double-gap bug, same fix. */
.videos {
  padding-block-start: var(--section-gap-m);
  row-gap: var(--s5);
}
@media (min-width: 768px) {
  .videos {
    padding-block-start: var(--section-gap);
    /* Was --s5 (48px), stacking with .section-heading's own 48px
       margin-bottom for 96px between the heading and the row below
       it — same double-gap pattern as .approach's heading, fixed the
       same way: --rule-gap here, the shared rule's margin zeroed for
       this instance specifically (see .section-heading.videos-heading
       below), so the two mechanisms don't add up anymore. */
    row-gap: var(--rule-gap);
  }
}

/* .videos-heading's own layout/type moved to the shared
   .section-heading rule — see there. Margin zeroed for this instance
   only (Approach keeps the same override, independently, on its own
   heading) so .videos's row-gap above is the ONLY gap to the content
   row. Compound selector for source-order independence, same
   reasoning as .section-heading.approach-heading. */
@media (min-width: 768px) {
  .section-heading.videos-heading { margin-bottom: 0; }
}

/* Facade, not a live embed — BRIEF: "your own poster frame and play
   mark; the YouTube iframe loads only on click." --rule-pad-photo is
   0 (AMENDMENTS ROUND 3 §1), same as every other photo on the page. */
.videos-player {
  grid-column: 1 / -1;
  position: relative;
  border: var(--rule);
  padding: var(--rule-pad-photo);
  aspect-ratio: var(--ratio-video);
  overflow: hidden;
  background: var(--ink);
  border-radius: var(--shape-mid-a); /* AMENDMENTS ROUND 5 */
}
@media (min-width: 768px) {
  /* Flipped — was cols 1-9 (left, wide). Same 8-column width, now on
     the right instead ("flip it so the titles table is on the left
     ... and the player is on the right"). DOM order unchanged (the
     player element still comes first in source), so keyboard/tab
     order still visits it before the tabs — a minor mismatch with
     visual order, not something raised as a concern here.

     grid-row:2 is explicit, not left to auto-placement: with the
     heading only spanning cols 1-6, auto-placement's forward-only
     cursor put .videos-player (cols 5-13, conflicts with the heading
     in row 1) into row 2 as expected, but then placed .videos-tabs
     (cols 1-5, which doesn't actually conflict with the player in
     row 2) into a THIRD row anyway — confirmed via
     getComputedStyle(...).gridTemplateRows showing three tracks, not
     two. Pinning both to row 2 explicitly sidesteps whatever
     auto-placement quirk that was, rather than debugging the exact
     cause further. */
  .videos-player { grid-column: 5 / 13; grid-row: 2; align-self: center; }
}

.videos-play {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  padding: 0;
  border: none;
  background: none;
  cursor: var(--cursor-hand);
}
.videos-poster {
  display: block;
  width: 100%;
  height: 100%;
  object-fit: cover;
}
.videos-play-mark {
  position: absolute;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%);
  width: var(--s7);
  height: var(--s7);
  color: var(--ground);
  /* Solid fill behind the mark, not text — the no-text-opacity rule
     doesn't apply to a decorative icon's own background, and a
     scrim guarantees contrast regardless of which frame of which
     photo happens to sit behind it. */
  background: var(--ink);
  border-radius: 50%;
}
.videos-play-mark svg { width: 100%; height: 100%; }
@media (min-width: 768px) {
  .videos-play-mark { width: var(--s9); height: var(--s9); }
}
.videos-iframe-wrap {
  display: none;
  position: absolute;
  inset: 0;
}
.videos-iframe-wrap.is-active { display: block; }
.videos-iframe-wrap iframe {
  width: 100%;
  height: 100%;
  border: none;
}
.videos-player.is-playing .videos-play { display: none; }

/* Two stacked boxes now, not one — box one (the three wedding rows)
   and box two (the Voice tab + external link), a --rule-gap (12px)
   channel between them. The border moved from .videos-tabs itself
   onto each .videos-tabs-box — .videos-tabs is now a plain flex
   column wrapping both. */
.videos-tabs {
  grid-column: 1 / -1;
  display: flex;
  flex-direction: column;
  gap: var(--rule-gap);
}
.videos-tabs-box { border: var(--rule); overflow: hidden; }
.videos-tabs-box > :last-child { border-bottom: none; }
/* AMENDMENTS ROUND 5 — outer container (three wedding-clip rows) vs.
   the Voice box (box two), per the assignment table. overflow:hidden
   keeps a selected row's ink fill from squaring off the rounded
   corners it shares with the box edge. */
.videos-tabs-box--weddings { border-radius: var(--shape-mid-b); }
.videos-tabs-box--voice { border-radius: var(--shape-mid-c); }
@media (min-width: 768px) {
  /* Flipped — was cols 9-13 (right, narrow). Same 4-column width,
     now on the left instead. grid-row:2 explicit — see the comment
     on .videos-player's own grid-row for why.

     align-self:start is the fix for "the table is stretching to
     match the video." Diagnosed first: .videos (the grid parent) has
     no align-items set, which computes to `normal` — and `normal`
     resolves to `stretch` for a grid item's block-axis sizing unless
     something overrides it. With no align-self of its own,
     .videos-tabs inherited that stretch and filled the row's full
     height (whatever .videos-player's aspect-ratio made that row —
     501px at 1440px wide), even though its own 3 rows only needed
     ~323px. align-self:start opts this one item out, sizing it to
     its own content instead — the player, in the same row, is
     unaffected (aspect-ratio already gives it an explicit height, so
     stretch was never doing anything visible to it). */
  .videos-tabs { grid-column: 1 / 5; grid-row: 2; align-self: start; }
}

.videos-tab {
  display: flex;
  align-items: center;
  justify-content: space-between;
  width: 100%;
  padding: 0;
  border: none;
  border-bottom: var(--rule);
  background: var(--ground);
  text-align: left;
  cursor: var(--cursor-hand);
  color: inherit;
  font: inherit;
}
.videos-tab.is-selected { background: var(--ink); }

.videos-tab-text {
  flex: 1 1 auto;
  min-width: 0;
  padding: var(--rule-pad);
  border-right: var(--rule);
}
.videos-tab-stage {
  display: block;
  margin-bottom: var(--s1);
  font-size: var(--t-label);
  text-transform: uppercase;
  letter-spacing: var(--track-caps);
  color: var(--ink-quiet);
}
.videos-tab-keys {
  /* Explicit fixed width, not just an auto-sized flex item that
     happens to come out identical every row (it would — same 3
     blobs, same size, same gap, on every row, so content-driven auto
     sizing was never actually going to vary) — "fixed width" stated
     as a requirement, so made literal rather than incidental. */
  flex: 0 0 auto;
  width: calc(3 * var(--pkg-key-size) + 2 * var(--pkg-key-gap) + 2 * var(--rule-pad));
  display: flex;
  align-items: center;
  justify-content: flex-end;
  gap: var(--pkg-key-gap);
  padding: var(--rule-pad);
}
.videos-key {
  flex: 0 0 auto;
  width: var(--pkg-key-size);
  height: var(--pkg-key-size);
}
.videos-key svg { width: 100%; height: 100%; display: block; }
.videos-key svg path {
  fill: none;
  stroke: var(--ink);
  stroke-width: 1.5;
}
.videos-key--filled svg path { fill: var(--ink); }
.videos-tab-title {
  display: block;
  font-size: var(--t-body); /* matches .cl-tier2 exactly, per "same cell construction as the credentials table" */
  color: var(--ink-quiet);
}
.videos-tab.is-selected .videos-tab-title { font-weight: var(--weight-strong); }

/* Selected row: full ink fill, both lines invert. The row's own
   border-bottom is left as --rule (ink) — on an ink-filled row
   that's invisible by design and the boundary with a neighboring
   unselected row still reads fine since it's the neighbor's own edge
   that shows. The vertical divider (.videos-tab-text's border-right)
   is different: it runs the full height of the table THROUGH the
   selected row, not around it, so leaving it ink-on-ink would make
   it vanish exactly where it needs to keep reading as one continuous
   line, and would make the row look like its two cells had merged
   into one. Explicit override to --ink-inverse fixes both. */
.videos-tab.is-selected .videos-tab-text {
  border-right-color: var(--ink-inverse);
}
/* Stage label is quiet text (like the reviews carousel's kicker/
   event line) — --ink-quiet-inverse, not the full-contrast
   --ink-inverse the title gets. Unselected rows already give the
   stage label --ink-quiet and the title --ink-quiet-until-promoted
   (see .videos-tab-stage/.videos-tab-title above); this just carries
   that same quiet/prominent split across into the selected state
   instead of flattening both lines to one color. */
.videos-tab.is-selected .videos-tab-stage {
  color: var(--ink-quiet-inverse);
}
.videos-tab.is-selected .videos-tab-title {
  color: var(--ink-inverse);
}
.videos-tab.is-selected .videos-key svg path { stroke: var(--ink-inverse); }
.videos-tab.is-selected .videos-key--filled svg path { fill: var(--ink-inverse); }

@media (hover: hover) and (pointer: fine) {
  .videos-tab:not(.is-selected):hover .videos-tab-title { color: var(--ink); }
}

/* Box two: the Voice tab is a fourth .videos-tab (same selection
   group, same is-selected treatment — nothing new needed for that,
   it already shares every rule above), but has no blob/keys column,
   so the divider that column split from .videos-tab-text doesn't
   apply here. */
.videos-tab--voice .videos-tab-text { border-right: none; }

/* Row two of box two — a real external link, not a tab, and never
   takes the selected state. Same --rule-pad as every other cell in
   this table, but its own type treatment: a permanent underline
   (BRIEF's other exception to the sitewide weight-only .prose-link
   convention, alongside the credentials table's own "NBC's The
   Voice" link — see .cl-link/.cl-ext — though that one is weight,
   not underline; this one is underline, not weight. Two different
   external-link treatments on the same page, both explicit, neither
   accidental.) */
.videos-voice-link {
  /* This cell's own height is set at runtime to whatever's left over
     matching the player's height (see the interlock script above) —
     often taller than the text itself, so the text needs vertical
     centering rather than sitting at the block's top edge. flex +
     align-items keeps the horizontal flow (text, then the external-
     link mark) unchanged, just centered on the cross axis. */
  display: flex;
  align-items: center;
  padding: var(--rule-pad);
  color: var(--ink);
  font-size: var(--t-body);
  text-decoration: underline;
  text-decoration-thickness: var(--rule-width);
  text-underline-offset: var(--link-underline-offset);
}
.videos-voice-ext {
  color: var(--ink-quiet);
  margin-inline-start: var(--s1); /* 8px after the text */
}


/* ---------- WHAT COUPLES SAY (RETIRED, AMENDMENTS ROUND 8 §1) -----
   Section removed. Its three reviews moved into My Approach as a
   carousel (see .approach-reviews* above) — same content, new
   presentation. This whole block (.reviews, .review*, .reviews-photo*,
   .reviews-more-link) is gone with it; nothing here is reused by the
   carousel except the token values it already cites in its own
   comments (--s0/--s1 for the attribution gaps). The two photos this
   section used (.reviews-photo--couples/songs) have no home on this
   page anymore — not deleted from /images, just unreferenced. */

/* ---------- SONG LIST ------------------------------------------------ */

/* All 323 rows render in the actual HTML source (see weddings.html) —
   BRIEF: "a list built by JavaScript from JSON is invisible to
   crawlers." Content is the site's own live repertoire data
   (fetched once from /api/repertoire, the same endpoint the old
   site's js/songlist.js already used client-side — this is the same
   real data, not fabricated, just baked into static markup instead
   of fetched at runtime). 323, not BRIEF's stated 328 — the
   repertoire is a live, evolving dataset; this is the current real
   count, not a discrepancy to force-match.

   JS enhances the static list in place (search filters, the wedding
   checkbox re-sorts, the title button reverses order) by re-parenting
   the EXISTING row elements into sorted order within the single
   column container and toggling visibility — it never rebuilds rows
   from data, so a crawler (or a browser with JS disabled) still sees
   the complete, real list regardless. */
.songs {
  padding-block-start: var(--section-gap-m);
  row-gap: var(--s2);
}
/* Mobile compaction: row-gap is --s2 here (not --s5 like approach/
   videos), so the heading's margin cancels THIS gap, not --s5, to
   still land on --heading-gap-m. */
@media (max-width: 767px) {
  .section-heading.songs-heading {
    margin-bottom: calc(var(--heading-gap-m) - var(--s2));
  }
}
@media (min-width: 768px) {
  .songs {
    padding-block-start: var(--section-gap);
    row-gap: var(--rule-gap);
  }
  .section-heading.songs-heading { margin-bottom: 0; }
}

/* One line, not stacked-by-word — AMENDMENTS ROUND 8 §3 ("heading on
   one line, SONG LIST, not stacked to two"), unconditional like
   .reviews-heading/.quote-heading's own exceptions, not desktop-only.
   Unlike those two, this heading is never widened to span the full
   row — it's a flex item inside .songs-sidebar (cols 1-4 at desktop),
   not a grid cell, so grid-column has no effect on it either way;
   "SONG LIST" at --t-section fits the sidebar's own width without
   needing more room (verified live, see below). */
.section-heading.songs-heading .section-heading-word { display: inline; }

/* Sidebar (heading, search, filter+count) and table (list header +
   scroll list) are each a single grid cell at desktop, laid out
   internally with flex — that's what lets the table sit in cols 5-12
   at whatever height its own content needs (the fixed --songlist-height
   scroll box) while the sidebar independently stacks its own three
   pieces in cols 1-4, tight at the top, rather than every piece
   fighting over shared grid row tracks. display:contents at mobile
   keeps each child a direct grid item of .songs, same as before this
   split existed — mobile stacking is unchanged. */
.songs-sidebar,
.songs-table {
  display: contents;
}
@media (min-width: 768px) {
  .songs-sidebar {
    display: flex;
    flex-direction: column;
    grid-column: 1 / 5;
    gap: var(--rule-gap);
  }
  .songs-table {
    display: flex;
    flex-direction: column;
    grid-column: 5 / 13;
    gap: var(--rule-gap);
  }
}

.songs-search-wrap {
  grid-column: 1 / -1;
}
.songs-search {
  width: 100%;
  height: var(--tap-target-min);
  border: var(--rule);
  /* AMENDMENTS ROUND 5 — not in the original assignment table (which
     is otherwise exhaustive); confirmed directly rather than guessed:
     same token as the other text-input form fields. */
  border-radius: var(--shape-firm-a);
  padding-inline: var(--rule-pad);
  font-family: var(--font-body);
  /* 18px, not --t-label (15px) — inputs need >=16px or iOS zooms in
     on focus (tokens.css's own ENVIRONMENT rule). */
  font-size: var(--t-body);
  color: var(--ink);
  background: var(--ground);
}
.songs-search::placeholder { color: var(--ink-quiet); }

.songs-filter-wrap {
  grid-column: 1 / -1;
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--s3);
}

/* Custom control, not a key mark — BRIEF: "the keys are the
   signature and a sixth context dilutes them." Native checkbox
   visually hidden (not display:none — stays in the tab order and
   keeps native check/space-bar behavior), a plain box + :checked
   sibling selector does the drawing. */
.songs-checkbox {
  display: flex;
  align-items: center;
  gap: var(--s2);
  cursor: var(--cursor-hand);
}
.songs-checkbox-input {
  position: absolute;
  width: 1px;
  height: 1px;
  overflow: hidden;
  clip: rect(0, 0, 0, 0);
  white-space: nowrap;
}
.songs-checkbox-box {
  flex: 0 0 auto;
  width: var(--checkbox-size);
  height: var(--checkbox-size);
  border: var(--rule);
  background: var(--ground);
}
.songs-checkbox-input:checked + .songs-checkbox-box {
  background: var(--ink);
}
.songs-checkbox-input:focus-visible + .songs-checkbox-box {
  outline: var(--focus-ring);
  outline-offset: var(--focus-offset);
}
.songs-checkbox-label {
  font-size: var(--t-label);
  color: var(--ink);
}

.songs-count {
  margin: 0;
  font-size: var(--t-label);
  color: var(--ink-quiet);
  white-space: nowrap;
}

/* Decade filter — AMENDMENTS ROUND 8 §3. Two-column grid, 8 blob
   buttons, one shape each (see tokens.css DECADE BLOB SET). Button
   height (--songlist-decade-btn) is computed, not guessed — sized so
   this grid's bottom edge lands on .songs-index's bottom edge (600px,
   --songlist-height) at desktop; see tokens.css for the math. Gap is
   --s2 (16px) — not specified in the source document, a plain scale
   pick rather than measured against anything, flagged as such.

   A per-button static rotation (±2°, counter-rotated label) was
   tried here and then reverted by direct instruction — "just corner
   radius." Border-radius only now, no transform. */
/* Mobile fixes round 3, direct instruction: decade filter buttons
   removed from mobile entirely (not just hidden-but-tappable) — the
   song list stays searchable/sortable there, just without the
   8-button decade grid. Desktop keeps them, unchanged. */
.songs-decades {
  display: none;
}
@media (min-width: 768px) {
  .songs-decades {
    grid-column: 1 / -1;
    display: grid;
    grid-template-columns: repeat(2, 1fr);
    gap: var(--s2);
  }
}
.songs-decade {
  height: var(--songlist-decade-btn);
  border: var(--rule);
  background: var(--ground);
  color: var(--ink-quiet);
  font-family: var(--font-pkg-title); /* Living Uncut Sans Stencil, 400 — confirmed, not --font-masthead's Semi-Bold */
  font-size: var(--t-decade-label);
  text-transform: uppercase;
  letter-spacing: var(--track-caps);
  cursor: var(--cursor-hand);
}
.songs-decade[data-decade="50s"] { border-radius: var(--blob-50s); }
.songs-decade[data-decade="60s"] { border-radius: var(--blob-60s); }
.songs-decade[data-decade="70s"] { border-radius: var(--blob-70s); }
.songs-decade[data-decade="80s"] { border-radius: var(--blob-80s); }
.songs-decade[data-decade="90s"] { border-radius: var(--blob-90s); }
.songs-decade[data-decade="00s"] { border-radius: var(--blob-00s); }
.songs-decade[data-decade="10s"] { border-radius: var(--blob-10s); }
.songs-decade[data-decade="20s"] { border-radius: var(--blob-20s); }
@media (hover: hover) and (pointer: fine) {
  .songs-decade:hover { color: var(--ink); }
}
.songs-decade:focus-visible {
  outline: var(--focus-ring);
  outline-offset: var(--focus-offset);
}
.songs-decade.is-active {
  background: var(--ink);
  color: var(--ink-inverse);
}

/* Mobile fixes round 3, direct instruction: hidden below 768px. The
   header's own layout (flex + space-between, "Title" left / "Artist"
   right) describes the DESKTOP row layout only — .song-row is
   flex-direction: column at mobile (title stacked above artist, see
   its own comment below), so a left/right header at that width
   labels columns that don't exist. Side effect, flagged: the "Title"
   button also doubles as the sort-by-title control, so mobile loses
   that tap target along with the header — not asked for separately,
   follows from "there aren't columns at this width" applying to the
   whole header, not just the "Artist" label. Desktop is unchanged. */
.songs-list-header {
  display: none;
}
@media (min-width: 768px) {
  .songs-list-header {
    grid-column: 1 / -1;
    display: flex;
    justify-content: space-between;
    padding-inline: var(--rule-pad);
    font-size: var(--t-label);
    text-transform: uppercase;
    letter-spacing: var(--track-caps);
    color: var(--ink-quiet);
  }
}
/* BRIEF's literal line calls for "the hover box" here — the universal
   hover-box pattern was removed sitewide (AMENDMENTS ROUND 2 §2,
   restated again in ROUND 4 §5: "no hover box anywhere; hover is
   colour or inversion") before this section was ever built. Applying
   the now-current sitewide rule instead of the stale literal
   instruction — color-only hover, matching every other interactive
   text control on this page (Videos tabs, header nav), not a
   reintroduced ninth rule-box location. Focus ring is unaffected
   either way. */
.songs-sort-title {
  display: flex;
  align-items: center;
  gap: var(--s1);
  padding: 0;
  border: none;
  background: none;
  font: inherit;
  color: inherit;
  text-transform: inherit;
  letter-spacing: inherit;
  cursor: var(--cursor-hand);
}
@media (hover: hover) and (pointer: fine) {
  .songs-sort-title:hover { color: var(--ink); }
}
.songs-sort-title:focus-visible {
  outline: var(--focus-ring);
  outline-offset: var(--focus-offset);
}
.songs-sort-arrow { display: inline-block; }
.songs-sort-title[data-sort-dir="desc"] .songs-sort-arrow {
  transform: rotate(180deg);
}

.songs-scroll {
  grid-column: 1 / -1;
  position: relative;
}
.songs-index {
  display: grid;
  grid-template-columns: repeat(var(--grid-cols), 1fr);
  column-gap: var(--grid-gutter);
  height: var(--songlist-height);
  overflow-y: auto;
  overscroll-behavior: contain;
  border: var(--rule);
  padding: var(--rule-pad);
  border-radius: var(--shape-mid-a); /* AMENDMENTS ROUND 5 */
}
.songs-col { grid-column: 1 / -1; }

/* AMENDMENTS ROUND 9 reverses the ROUND 5-era "no row separators"
   decision (RULE + SHAPE location 15, tokens.css) — explicit
   instruction ("put divider lines in song list"). border-bottom on
   every row but the last, --rule-pad (rule-to-text spacing, same
   token .packages-row used for its own border-bottom-divider era)
   replaces the --rule-gap "channel with no rule" spacing. */
.song-row {
  display: flex;
  flex-direction: column;
  gap: var(--s1);
  padding-block: var(--rule-pad);
  border-bottom: var(--rule);
}
/* Not :last-child — filtering (search/decade/Popular) hides rows via
   .is-hidden without reordering the DOM, so "last child" and "last
   visible row" are different rows once a filter is active. :has()
   tracks whichever row is currently last among the visible ones. */
.song-row:not(:has(~ .song-row:not(.is-hidden))) {
  border-bottom: none;
}
@media (min-width: 768px) {
  /* Side by side only once there's room — at mobile widths, a long
     title (e.g. "Ain't No Mountain High Enough") plus its artist
     fighting for space in one row wrapped to 5 cramped lines each;
     stacked reads far better in a narrow column. Not something BRIEF
     specified either way for mobile, so this is a judgment call
     rather than a literal instruction, made after seeing the actual
     wrap behavior, not assumed. */
  .song-row {
    flex-direction: row;
    justify-content: space-between;
    gap: var(--s3);
  }
}
.song-title {
  color: var(--ink);
  min-width: 0;
}
.song-artist {
  color: var(--ink-quiet);
  font-size: var(--t-label);
}
@media (min-width: 768px) {
  .song-artist {
    flex: 0 0 auto;
    text-align: right;
    font-size: inherit;
  }
}
.song-row.is-hidden { display: none; }
/* Mobile compaction — after the base .songs-index/.song-row rules so
   it wins on source order. */
@media (max-width: 767px) {
  .songs-index {
    padding: var(--s2);
    height: auto;
    aspect-ratio: var(--ratio-portrait); /* 4:5, rows scroll inside */
  }
  .song-row {
    padding-block: var(--s1);
    gap: 0;
  }
}

.songs-empty {
  position: absolute;
  inset: 0;
  margin: 0;
  padding: var(--rule-pad);
  border: var(--rule);
  border-radius: var(--shape-mid-a); /* matches .songs-index, which it overlays — was unset, so the corners went square on no matches */
  background: var(--ground);
  color: var(--ink-quiet);
}


/* ---------- REQUEST A QUOTE -------------------------------------
   BRIEF: "seven fields, uneven widths — each matches its expected
   content" (the one surviving Kippenberger reference in this
   system). BRIEF's own table left a remainder in every row (e.g.
   Name 4 + Email 5 = 9 of 12) — explicitly overridden by direct
   instruction: "make sure each row spans the entire width." Column
   ranges below now sum to exactly 12 per row instead, still uneven
   per field but with no leftover gap. Also per direct instruction: a
   "Date Confirmed" checkbox added next to Wedding Date, and Wedding
   Date/Confirmed/Venue/Guest Count all merged onto one row (row 2) —
   achieved by widening Name+Email to fill row 1 completely (nothing
   left for Date to fit into), not by hardcoding a row number. Placed
   in source order with no explicit
   grid-row: a field whose column range no longer fits in the current
   row is pushed to the next one automatically by the grid's own
   placement algorithm, so reordering a field in markup is still
   enough on its own to move it a row. Package is a type=hidden
   input; those are never laid out, so it takes no grid slot and
   needs no rule here. */
/* One line, not stacked-by-word — same exception as .reviews-heading
   above ("put heading on one line"), scoped to just this heading.
   Widened to the full row for the same reason: cols 1-7 was sized for
   a stacked, narrower heading and doesn't have room for "Request a
   Quote" on one line at --t-section's size. */
.section-heading.quote-heading .section-heading-word { display: inline; }
@media (min-width: 768px) {
  .section-heading.quote-heading { grid-column: 1 / -1; }
}

.quote {
  padding-block: var(--section-gap-m);
}
@media (min-width: 768px) {
  .quote { padding-block: var(--section-gap); }
}

.quote-form {
  grid-column: 1 / -1;
  display: grid;
  grid-template-columns: repeat(var(--grid-cols), 1fr);
  column-gap: var(--grid-gutter);
  row-gap: var(--s2);
}
@media (min-width: 768px) {
  .quote-form { row-gap: var(--s4); }
}

.quote-field {
  grid-column: 1 / -1;
  display: flex;
  flex-direction: column;
  gap: var(--s2);
  /* Grid/flex items default to min-width:auto, whose automatic
     minimum is the item's own content size — for a native replaced
     control like <input type="date"> that's whatever width iOS/
     Android draws the widget at, which can exceed the box's assigned
     width and push it (and the grid track carrying it) past the
     viewport. Reported: "wedding date field extending out of range
     of viewport" — not reproducible in headless Chromium or WebKit
     at 390px (both size the date input to the assigned 100% width
     with no overflow), consistent with this being the same class of
     OS-drawn-control-width issue already diagnosed just below for
     the date/confirmed overlap bug, on a device this couldn't be
     tested against directly. min-width: 0 removes the content-based
     floor so the field can never force its grid track wider than its
     assigned column, regardless of what width the native control
     wants to draw at. */
  min-width: 0;
}
/* Date/Confirmed's mobile 3/6 pairing (grid-column 1/4 + 4/7,
   168.99px each) is REVERSED — mobile fixes pass, direct report:
   "date confirmed box is overlapping with the date field." Diagnosed:
   `<input type="date">` is a native control, and its rendered width
   (the calendar-icon affordance plus the OS's own minimum comfortable
   size for the mm/dd/yyyy text) is browser/OS-drawn, not something
   this box's own CSS controls — the 168.99px column was measured
   against the *box*, not against how wide iOS/Android actually draw
   the native widget inside it, which runs wider on real devices than
   in this desktop-Chromium build and pushes past the column into
   "Date Confirmed" next to it. The previous comment's "fit
   comfortably" claim was exactly this — a desktop-browser measurement
   standing in for a native mobile control's own size, which doesn't
   hold. Fixed by dropping the mobile-only pairing entirely: both
   fields fall through to the base `.quote-field` rule (grid-column:
   1/-1, full width), stacking the same way Venue and Guest Count
   already do at this width — no more narrow shared row for a native
   control to overflow out of. Desktop's own pairing (media query
   below) is unaffected; a mouse-driven date picker doesn't have this
   problem. */
/* Checkbox has no label-above like its row neighbor (Wedding Date)
   does, so top-aligning would sit it visibly higher — align-self
   bottom-anchors it in the row instead, and .quote-checkbox's own
   height (below) is set to match .quote-input's so the two line up
   exactly, not approximately. Only matters once they're paired again
   at desktop, below — harmless on its own full-width mobile row. */
.quote-field--date-confirmed { align-self: end; }
@media (min-width: 768px) {
  .quote-field--name            { grid-column: 1 / 6; }   /* 5 cols, row 1 */
  .quote-field--email           { grid-column: 6 / 13; }  /* 7 cols, row 1 — 5+7=12 */
  /* Wedding Date, Date Confirmed, Venue, and Guest Count all on one
     row — direct instruction. 2+2+6+2 = 12. */
  .quote-field--date            { grid-column: 1 / 3; }   /* 2 cols, row 2 (row 1 is full) */
  .quote-field--date-confirmed  { grid-column: 3 / 5; }   /* 2 cols */
  .quote-field--venue           { grid-column: 5 / 11; }  /* 6 cols */
  .quote-field--guests          { grid-column: 11 / 13; } /* 2 cols */
  /* .quote-field--notes: no override — the base 1/-1 rule already
     gives it the full row alone, row 3. */
}
.quote-field--submit { grid-column: 1 / -1; }

.quote-label {
  font-size: var(--t-label);
  text-transform: uppercase;
  letter-spacing: var(--track-caps);
  color: var(--ink-quiet);
}

/* "Every field is boxed... the same 1px box, one of the three
   places a box exists on this site" — RULE + SHAPE location 7
   (tokens.css). Bottom-rule underlines are explicitly ruled out. */
.quote-input,
.quote-select,
.quote-textarea {
  width: 100%;
  max-width: 100%;
  border: var(--rule);
  border-radius: var(--shape-firm-a); /* AMENDMENTS ROUND 5 */
  padding-inline: var(--rule-pad);
  font-family: var(--font-body);
  font-size: var(--t-body); /* floor is 16px effective, via the sitewide input/select rule above */
  color: var(--ink);
  background: var(--ground);
}
.quote-input,
.quote-select {
  height: var(--tap-target-min);
}
/* iOS draws a native date control with its own intrinsic minimum
   width that ignores width:100%, pushing the field off the right edge.
   appearance:none + min-width:0 makes it obey the column. */
.quote-input[type="date"] {
  -webkit-appearance: none;
  appearance: none;
  display: block;
  min-width: 0;
}
.quote-textarea {
  padding-block: var(--rule-pad);
  min-height: calc(var(--tap-target-min) * 2);
  resize: vertical;
}
.quote-input::placeholder,
.quote-textarea::placeholder {
  color: var(--ink-quiet);
}

/* Date Confirmed — same custom-checkbox construction as the Song
   List "Popular at Weddings" filter (.songs-checkbox and siblings):
   native input visually hidden but still in the tab order and still
   answering to :checked/space-bar, a plain box + sibling selector
   does the drawing. Reuses --checkbox-size (tokens.css) rather than
   inventing a second checkbox size — same control, second instance.
   Height matches .quote-input's so align-self:end (above) lines the
   two up exactly. */
.quote-checkbox {
  display: flex;
  align-items: center;
  gap: var(--s2);
  height: var(--tap-target-min);
  cursor: var(--cursor-hand);
}
.quote-checkbox-input {
  position: absolute;
  width: 1px;
  height: 1px;
  overflow: hidden;
  clip: rect(0, 0, 0, 0);
  white-space: nowrap;
}
.quote-checkbox-box {
  flex: 0 0 auto;
  width: var(--checkbox-size);
  height: var(--checkbox-size);
  border: var(--rule);
  background: var(--ground);
}
.quote-checkbox-input:checked + .quote-checkbox-box {
  background: var(--ink);
}
.quote-checkbox-input:focus-visible + .quote-checkbox-box {
  outline: var(--focus-ring);
  outline-offset: var(--focus-offset);
}
.quote-checkbox-label {
  font-size: var(--t-label);
  color: var(--ink);
}

/* Error — "the field inverts to a --ink plate with --ink-inverse
   text; the message sits beneath in --ink at the heavier weight...
   the only filled dark plate on the site." Reuses the ERROR tokens
   tokens.css already reserved for exactly this component. */
.quote-field.has-error .quote-input,
.quote-field.has-error .quote-select,
.quote-field.has-error .quote-textarea {
  background: var(--error-plate);
  color: var(--error-plate-text);
}
.quote-field.has-error .quote-input::placeholder,
.quote-field.has-error .quote-textarea::placeholder {
  color: var(--error-plate-text);
}
.quote-error {
  margin: 0;
  color: var(--error-message-color);
  font-weight: var(--error-message-weight);
  font-size: var(--t-label);
}

/* --ink fill per direct instruction ("make the send quote request
   button ink colored") — overrides the --accent scoping this button
   was given when it was first built (see tokens.css's AMENDMENTS
   ROUND 4 §5 comment). That leaves --accent (tokens.css) without a
   live use anywhere on the site again — flagged there, not deleted,
   same treatment already given to --font-accent's own orphaning.
   --ink-inverse text for contrast, the same light-on-dark pairing
   the error plate and package-row hover already use. */
.quote-submit {
  border: var(--rule);
  background: var(--ink);
  color: var(--ink-inverse);
  padding-inline: var(--rule-pad);
  min-height: var(--tap-target-min);
  font-family: var(--font-body);
  font-weight: var(--weight-strong);
}
.quote-submit:disabled {
  cursor: not-allowed;
}

/* No-reflow label swap — identical technique to .copy-label above:
   three states stacked in one grid cell, only one ever visible, so
   the button's own box never resizes when its label changes. */
.quote-submit-label {
  display: inline-grid;
}
.quote-submit-label span { grid-area: 1 / 1; }
.quote-submit-label .state-sending,
.quote-submit-label .state-sent {
  visibility: hidden;
}
.quote-submit.is-sending .state-default,
.quote-submit.is-sending .state-sent {
  visibility: hidden;
}
.quote-submit.is-sending .state-sending {
  visibility: visible;
}
.quote-submit.is-sent .state-default,
.quote-submit.is-sent .state-sending {
  visibility: hidden;
}
.quote-submit.is-sent .state-sent {
  visibility: visible;
}


/* ---------- STAGE PLACEHOLDER (build scaffolding only) ------------ */

.stage-placeholder {
  padding-block: var(--section-gap-m);
  padding-inline: var(--grid-margin);
  color: var(--ink-disabled);
  font-size: var(--t-label);
  text-transform: uppercase;
  letter-spacing: var(--track-caps);
  border-block-end: var(--rule);
}
@media (min-width: 768px) {
  .stage-placeholder { padding-block: var(--section-gap); }
}


/* ---------- FOOTER -------------------------------------------------
   Four lists (BRIEF.md §9), type only, replacing the retired
   Kippenberger stamped stack — same "15px caps, --ink item /
   --ink-quiet label, --s3 gap, no enclosure" treatment already used
   for /card, /wipa, /jar, the /music release list, and 404 nav (see
   tokens.css). Signup reuses the mobile overlay's own field exactly
   (.mobile-signup) — border-block-end only, unboxed submit — just
   under its own class since it renders at every width here, not just
   inside the overlay.

   AMENDMENTS ROUND 7 — the footer now carries its own top/bottom
   dividers (RULE + SHAPE locations 11-12, tokens.css), added by
   explicit instruction. This reverses the prior round's stance that a
   border anywhere in the footer would be an unpermitted tenth rule
   location; that stance is superseded, not silently dropped.
   ------------------------------------------------------------------ */

.site-footer {
  padding-block: var(--section-gap-m);
  display: flex;
  flex-direction: column;
  gap: var(--s6);
  border-top: var(--rule);
  border-bottom: var(--rule);
}
@media (min-width: 768px) {
  .site-footer { padding-block: var(--section-gap); }
}

/* ---------- FOOTER SIGNUP (RETIRED) ---------------------------------
   The live email-capture input + submit button, then briefly a plain
   text link to the same Substack destination (matching .header-signup)
   — taken out of the footer entirely now, by direct instruction. The
   newsletter link lives only in the header. --shape-firm-b/
   --signup-height (the box treatment the original input used) are no
   longer referenced here; still live on .mobile-signup. */

/* Each stack is span 3 at every breakpoint — 4-across at 12 columns
   (tablet/desktop), 2-across at 6 columns (mobile) — the grid wraps
   on its own with no media-query override needed, since 3 divides
   both column counts evenly. */
.footer-stacks {
  row-gap: var(--s5);
}
/* Mobile-only 2x2 fix. The shared --gutter-m (12px) column-gap left
   the two columns at ~169px — CONOR@CONOR.BIO set in caps measures
   ~175px there, wider than its own column. Widened to --s3 (24px)
   here only (columns become ~163px), by direct instruction. Desktop
   keeps the sitewide --grid-gutter — not asked for there, and the
   wider desktop columns never collided. */
@media (max-width: 767px) {
  .footer-stacks { column-gap: var(--s3); }
}
/* Mobile fixes round 3, direct report: "For Planners" (and its own
   links) sat far below "Contact" with a large empty gap, because
   CSS Grid rows are shared tracks — Contact/Follow occupied the same
   implicit row, and that row's height was Follow's (6 links, taller
   than Contact's 2), pushing the ENTIRE next row (Planners/More) down
   to clear it, regardless of how short Contact's own column was.
   .footer-col groups Contact+Planners and Follow+More into their own
   flex columns so each pair stacks at its own height, independent of
   the other pair — Planners now starts right after Contact, not
   after Follow. Desktop is unaffected: display:contents unwraps the
   group so the four .footer-stack children become flat grid items
   again, reproducing the exact pre-existing 4-across row. */
.footer-col {
  grid-column: span 3;
  display: flex;
  flex-direction: column;
  gap: var(--s5);
}
@media (min-width: 768px) {
  .footer-col { display: contents; }
}
.footer-stack {
  grid-column: span 3;
  display: flex;
  flex-direction: column;
  gap: var(--s3);
}
/* AMENDMENTS ROUND 7 — the four columns previously sat at staggered
   vertical positions per BRIEF.md ("first item, second, second-to-
   last, last", via --footer-row offsets), so the headings didn't
   share a line. Reversed by explicit instruction ("align the heading
   for each footer section") — all four now sit at the stack's own
   top with no per-column offset, so plain align-items: stretch
   (CSS Grid's default) is enough. */

/* Bold at every width, direct instruction — was --weight-body
   (inherited, no weight declared), the same regular weight as the
   items beneath it. --weight-strong is the sitewide heavier of the
   site's only two weights (tokens.css), same token .footer-stack-
   link's mailto exception and the error message already use, not a
   new value. */
.footer-stack-label {
  margin: 0;
  color: var(--ink-quiet);
  font-size: var(--t-label);
  font-weight: var(--weight-strong);
  text-transform: uppercase;
  letter-spacing: var(--track-caps);
}
.footer-stack-link,
.footer-stack-copyright {
  margin: 0;
  color: var(--ink);
  font-size: var(--t-label);
  text-transform: uppercase;
  letter-spacing: var(--track-caps);
}
/* Links are set as written (regular case) at every width, by direct
   instruction; labels and the copyright line stay caps. Was caps
   everywhere except the mobile email addresses. */
.footer-stack-link {
  text-transform: none;
  letter-spacing: var(--track-body);
}

/* The Knot + GigSalad's own badge assets, already live elsewhere on
   the site (conor-james.html, book.html, events.html) — same CDN
   URLs, reused here rather than re-sourced. Off-palette colour, on
   purpose: "The badges are the only off-palette colour on the
   site... they cannot be recoloured" (BRIEF.md §9).

   AMENDMENTS ROUND 7 — moved from their own full-width band beneath
   all four stacks into .footer-stack--contact, by explicit
   instruction ("tuck [them] underneath the email addresses"). Both
   source assets are square, so a single height token plus width:auto
   equalizes them without distorting either. No border added here —
   the footer's new top/bottom rules (locations 11-12) already bracket
   the whole footer, badges included; a third rule around the band
   itself was not asked for. Spacing above it comes from
   .footer-stack's own --s3 gap (it's just another flex child now),
   not a margin of its own — consistent with every other item in the
   four lists rather than a one-off value. */
.footer-badges {
  display: flex;
  align-items: center;
  gap: var(--s4);
}
.footer-badges img {
  height: var(--footer-badge-height);
  width: auto;
}

/* Mobile-only band, by direct instruction — reverses AMENDMENTS
   ROUND 7's "badges move into Contact" at this width only (desktop
   keeps that placement, unchanged). Two badge blocks exist in
   weddings.html: this stack's own (desktop) and .footer-badges--band
   (mobile), each hidden at the other's breakpoint, rather than one
   block repositioned via grid-row — moving a single instance out of
   .footer-stack--contact's flex column into its own full-width grid
   row would pull it under the grid's --s5 (48px) row-gap instead of
   the stack's own --s3 (24px) child gap, silently changing desktop
   spacing. Duplicating avoids that risk entirely. */
.footer-stack--contact .footer-badges {
  display: none;
}
@media (min-width: 768px) {
  .footer-stack--contact .footer-badges {
    display: flex;
  }
}

/* Full width beneath the 2x2 (grid-column: 1 / -1 forces it onto its
   own row after the two stack rows fill columns 1-6) — the row-gap
   already on .footer-stacks (--s5, 48px) becomes the "48px above"
   gap for free, no separate margin needed. Left-aligned is the
   flex default (justify-content: flex-start). Item gap is --s2
   (16px) between the two badges — not given directly in spec, a
   plain scale pick close to the stack's own reading, flagged. */
.footer-badges--band {
  justify-content: flex-start;
  gap: var(--s2);
}
@media (min-width: 768px) {
  .footer-badges--band {
    display: none;
  }
}

/* ============================================================
   /events — scoped additions (BRIEF section 10). Everything here is
   keyed off a modifier class only events.html carries, so nothing on
   /weddings changes. No new tokens; every value is an existing one.
   ============================================================ */

/* Offerings: four categories, two-up. The piano strip keeps cols 1-3
   and the container keeps cols 4-12 exactly as on /weddings, so the two
   cards share those 9 columns. Category rows reuse .packages-row's
   box, hover invert and type; only their internal layout changes,
   from the weddings name/keys/desc/price grid to a plain stack. */
@media (min-width: 768px) {
  .packages--events .packages-rows {
    display: grid;
    grid-template-columns: repeat(2, 1fr);
  }
  .packages--events .packages-row {
    display: flex;
    flex-direction: column;
    align-items: stretch; /* base rule's align-items:center would center single-line titles in a column */
    justify-content: center;
    min-height: 0;
  }
  .packages--events .pkg-desc-col { grid-column: auto; }
}

/* Videos: no keys and no stage labels on /events, so no divider
   between title and keys column. Same box shape as the weddings box. */
.videos-tabs-box--events { border-radius: var(--shape-mid-b); }
.videos-tabs-box--events .videos-tab-text { border-right: none; }

/* Request a Quote: Event Date + Venue + Duration share row 2
   (2 + 6 + 4 = 12). No Date Confirmed or Guest Count on /events. */
@media (min-width: 768px) {
  .quote-form--events .quote-field--venue    { grid-column: 3 / 9; }
  .quote-form--events .quote-field--duration { grid-column: 9 / 13; }
}



/* ============================================================
   / (homepage) — scoped additions (BRIEF section 8). Everything is
   keyed off .home-* classes only index.html carries. No raw values.
   ============================================================ */

/* The photograph runs from the header's bottom edge to the footer's
   top edge: viewport height minus row 1 (the only header row on this
   page), full bleed horizontally, no rule box, no shape — full bleed
   means no edge to enclose. Not one of the six permitted ratios: its
   ratio is whatever the viewport makes it. */
.home-hero {
  position: relative;
  height: calc(100svh - var(--header-row1-m));
}
.home-hero-img {
  width: 100%;
  height: 100%;
  object-fit: cover;
  object-position: center;
}

/* Homepage Request a Quote key: the shared .mobile-sticky-cta, as a
   button (it opens the picker instead of following a link). */
button.mobile-sticky-cta { cursor: var(--cursor-hand); }
@media (max-width: 767px) {
  #home-cta {
    width: var(--key-w-m);
    bottom: calc(var(--margin-m) + var(--home-key-shift));
  }
}

/* Quote picker: the tip jar sub-modal's panel (bordered box, ruled
   rows), but the layer behind it is transparent — the page stays
   visible around it. The panel itself is --ground so its text reads
   over the photo. */
.quote-picker {
  position: fixed;
  inset: 0;
  z-index: 100;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: var(--s4) var(--margin-m);
  font-size: var(--t-body-m);
  line-height: var(--lh-body);
  text-transform: none;
}
.quote-picker[hidden] { display: none; }
.quote-picker-panel {
  width: 100%;
  max-width: var(--measure);
  padding: var(--rule-pad);
  background: var(--ground);
  border: var(--rule);
  border-radius: var(--shape-mid-b);
  display: flex;
  flex-direction: column;
  gap: var(--s3);
}
.quote-picker-heading {
  margin: 0;
  font-family: var(--font-body);
  font-weight: var(--weight-body);
  font-size: var(--t-body-m);
  line-height: var(--lh-body);
  letter-spacing: var(--track-body);
}
.quote-picker-rows { display: flex; flex-direction: column; }
.quote-picker-row {
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  justify-content: center;
  width: 100%;
  min-height: var(--tap-target-min);
  padding: var(--rule-pad) 0;
  background: none;
  border: 0;
  border-bottom: var(--rule);
  border-radius: var(--shape-none);
  color: var(--nav-color);
  font: inherit;
  letter-spacing: var(--track-body);
  text-transform: none;
  text-align: left;
  text-decoration: none;
  cursor: var(--cursor-hand);
}
.quote-picker-row:first-child { padding-top: 0; }
.quote-picker-row:last-child { border-bottom: 0; padding-bottom: 0; }


/* ---------- HEADER DIALOGS (Links / Contact / Newsletter / Log) ----
   Built by js/dialog.js. Tokens: "HEADER DIALOGS" in tokens.css.
   Desktop only — the mobile overlay keeps its own links. --ink ground,
   --ink-inverse features. Texture: dense grain on the title bar
   (--dialog-grain-bar), sparse grain on the frame around the panel
   (--dialog-grain-frame) — the two places that read as "you can grab
   this". Shape: --shape-mid-b outside, --shape-firm-a on the panel. */
.dialog {
  position: fixed;
  z-index: 40;
  color: var(--ink-inverse);
  font-size: var(--t-label);
  line-height: var(--lh-label);
  outline: none;
}
.dialog--small { width: calc(3 * var(--col-w) + 2 * var(--grid-gutter)); }
.dialog--embed { width: min(var(--dialog-w-embed), calc(100vw - 2 * var(--grid-margin))); }
@media (max-width: 767px) {
  .dialog--embed { width: calc(var(--dialog-embed-scale-m) * min(var(--dialog-w-embed), 100vw - 2 * var(--grid-margin))); }
}
.dialog--big {
  width: min(calc(9 * var(--col-w) + 8 * var(--grid-gutter)), calc(100vw - 2 * var(--grid-margin)));
  height: calc(100vh - 2 * var(--s6));
}
.dialog--big .dialog-frame,
.dialog--big .dialog-panel { height: 100%; }
.dialog--big .dialog-panel { display: flex; flex-direction: column; }
.dialog--big .dialog-body { flex: 1 1 auto; min-height: 0; }

.dialog-frame {
  padding: var(--s1);
  background-color: var(--ink);
  background-image: var(--dialog-grain-frame);
  border: var(--rule-width) solid var(--ink-inverse);
  border-radius: var(--shape-mid-b);
}
.dialog-panel {
  background: var(--ink);
  border: var(--rule-width) solid var(--ink-inverse);
  border-radius: var(--shape-firm-a);
  overflow: hidden;
}

.dialog-bar {
  position: relative;
  display: flex;
  align-items: stretch;
  justify-content: center;
  min-height: var(--tap-target-min);
  background-color: var(--ink);
  background-image: var(--dialog-grain-bar);
  border-bottom: var(--rule-width) solid var(--ink-inverse);
  touch-action: none;
  user-select: none;
}
.dialog-title {
  align-self: center;
  padding-inline: var(--s2);
  background: var(--ink);
  font-size: var(--t-label);
  text-transform: uppercase;
  letter-spacing: var(--track-caps);
}
.dialog-close {
  position: absolute;
  top: 0;
  right: 0;
  width: var(--tap-target-min);
  height: 100%;
  display: grid;
  place-items: center;
  background: var(--ink);
  border-left: var(--rule-width) solid var(--ink-inverse);
  color: var(--ink-inverse);
}
.dialog-close svg {
  width: var(--s2);
  height: var(--s2);
  fill: none;
  stroke: currentColor;
  stroke-width: var(--hamburger-bar-weight);
  stroke-linecap: square;
}
.dialog-close:focus-visible,
.dialog a:focus-visible,
.dialog .copy-item:focus-visible {
  outline: var(--focus-ring-inverse);
  outline-offset: calc(-1 * var(--focus-offset));
}
@media (hover: hover) and (pointer: fine) {
  .dialog-close:hover { background: var(--ink-inverse); color: var(--ink); }
}

.dialog-body a,
.dialog-body .copy-item,
.dialog-body .header-dropdown-label {
  display: block;
  width: 100%;
  margin: 0;
  padding: var(--rule-pad) var(--s3);
  font-size: var(--t-label);
  line-height: var(--lh-label);
  text-align: left;
  color: var(--ink-inverse);
  border-bottom: var(--rule-width) solid var(--ink-inverse);
}
.dialog-body > :last-child { border-bottom: 0; }
.dialog-body .header-dropdown-label { color: var(--ink-quiet-inverse); }
@media (hover: hover) and (pointer: fine) {
  .dialog-body a:hover,
  .dialog-body .copy-item:hover {
    background: var(--ink-inverse);
    color: var(--ink);
  }
}

/* Intro popups (index.html, js/intro.js): the same window, opened
   without a close box. Copy in .dialog-text; the one way out is the
   .dialog-action button under it, inverted on hover like the link rows. */
.dialog-text {
  margin: 0;
  padding: var(--s3);
  font-size: var(--t-body-m);
  line-height: var(--lh-body);
  color: var(--ink-inverse);
}
.dialog-action {
  display: block;
  width: 100%;
  min-height: var(--tap-target-min);
  padding: var(--rule-pad) var(--s3);
  background: var(--ink);
  border: 0;
  border-top: var(--rule-width) solid var(--ink-inverse);
  border-radius: var(--shape-none);
  color: var(--ink-inverse);
  font: inherit;
  font-size: var(--t-body-m);
  text-align: center;
  cursor: var(--cursor-hand);
}
.dialog-action:focus-visible {
  outline: var(--focus-ring-inverse);
  outline-offset: calc(-1 * var(--focus-offset));
}
@media (hover: hover) and (pointer: fine) {
  .dialog-action:hover { background: var(--ink-inverse); color: var(--ink); }
}

/* Zoom rects (js/dialog.js `zoom`; values in tokens.css): the outline
   frames a window steps through opening and minimising. Above every
   draggable window (40 and up), below the mobile menu (1000). Outline
   only: --rule-width in --ink, no fill. */
.zoom-rects {
  position: fixed;
  inset: 0;
  z-index: 999;
  pointer-events: none;
}
.zoom-rect {
  position: absolute;
  box-sizing: border-box;
  border: var(--rule-width) solid var(--ink);
}

/* The intro arrow: dashes drawn by js/intro.js as separate segments so
   they can form and evaporate one at a time. Each dash has a solid
   --ink-inverse halo underlay (--bookings-halo outward, round caps,
   overlapping into a continuous underlay) beneath the --ink dash. Sits
   under the dialogs (z 40) so it emerges from the popup's corner. */
.intro-arrows {
  position: fixed;
  inset: 0;
  width: 100%;
  height: 100%;
  z-index: 39;
  pointer-events: none;
}
.intro-arrow-halo,
.intro-arrow-dash,
.intro-arrow-head,
.intro-arrow-head-halo {
  fill: none;
  stroke-linejoin: round;
}
.intro-arrow-halo,
.intro-arrow-head-halo {
  stroke: var(--ink-inverse);
  stroke-width: calc(var(--arrow-stroke) + 2 * var(--bookings-halo));
  stroke-linecap: round;
}
.intro-arrow-dash {
  stroke: var(--ink);
  stroke-width: var(--arrow-stroke);
  stroke-linecap: butt;
}
.intro-arrow-head {
  stroke: var(--ink);
  stroke-width: var(--arrow-stroke);
  stroke-linecap: round;
}

/* iframe bodies. Newsletter (--embed) takes Substack's own 480x320
   shape; the Log (--big) fills the window. Pointer events are dropped
   mid-drag so the iframe never swallows the move. */
.dialog-frame-embed {
  display: block;
  width: 100%;
  border: 0;
  background: var(--ink-inverse);
}
.dialog--embed .dialog-frame-embed { aspect-ratio: var(--dialog-embed-ratio); }
.dialog--big .dialog-frame-embed { height: 100%; }
.dialog.is-dragging .dialog-frame-embed { pointer-events: none; }


/* ---------- /MUSIC ---------------------------------------------------
   BRIEF.md §11. Row one header only. Desktop: type-only release list in
   cols 1-5, detail panel in cols 7-12. The first release (Sunshower)
   is open on load; another is selected by its fragment (:target), so
   it is a real URL. Generated markup:
   scripts/build-music.mjs. */

.music {
  padding-block: var(--section-gap-m);
  row-gap: var(--s5);
  align-items: start;
}
@media (min-width: 768px) {
  /* Padding lives on the columns, not the grid, so the divider on the
     list column runs unbroken from the header's rule to the footer's. */
  .music { padding-block: 0; }
  .release-list-wrap,
  .release-panels { padding-block: var(--section-gap); }
}

.release-list-wrap { grid-column: 1 / -1; }
.release-panels    { grid-column: 1 / -1; }
@media (min-width: 768px) {
  .release-list-wrap {
    grid-column: 1 / span 5;
    align-self: stretch; /* the divider runs the full row, alongside the panel */
    border-right: var(--rule);
  }
  .release-panels    { grid-column: 7 / span 6; }
}

.release-list {
  list-style: none;
  margin: 0;
  padding: 0;
  display: flex;
  flex-direction: column;
  gap: var(--s3);
}
.release-item {
  display: flex;
  flex-direction: column;
  gap: var(--s0);
  font-size: var(--t-label);
  line-height: var(--lh-label);
  text-transform: uppercase;
  letter-spacing: var(--track-caps);
}
.release-title { color: var(--ink); }
.release-year  { color: var(--ink-quiet); }
.release-item:focus-visible {
  outline: var(--focus-ring);
  outline-offset: var(--focus-offset);
}

.release {
  display: none;
  flex-direction: column;
  align-items: flex-start;
  gap: var(--s3);
}
.release:target { display: flex; }
/* Sunshower (first in the list) is open when no release is targeted. */
.release-panels:not(:has(.release:target)) .release:first-child { display: flex; }
/* Desktop: the panel sits beside the list, so selecting a release must
   not scroll the page. The browser aligns a :target to the top of the
   viewport; this margin equals the panel's own offset (sticky header +
   .music's top padding), so the resulting scroll is 0. */
@media (min-width: 768px) {
  .release { scroll-margin-top: calc(var(--header-row1-m) + var(--section-gap)); }
}

.release-art {
  display: block;
  width: 100%;
  height: auto;
  aspect-ratio: 1 / 1;
  border-radius: var(--shape-soft-a);
}
.release-date {
  margin: 0;
  color: var(--ink-quiet);
  font-size: var(--t-label);
  line-height: var(--lh-label);
  text-transform: uppercase;
  letter-spacing: var(--track-caps);
}
.release-link {
  color: var(--link-color);
  font-size: var(--t-label);
  line-height: var(--lh-label);
  font-weight: var(--link-weight);
  text-decoration: var(--link-decoration);
  text-underline-offset: var(--link-underline-offset);
  text-transform: uppercase;
  letter-spacing: var(--track-caps);
}
.release-link:focus-visible {
  outline: var(--focus-ring);
  outline-offset: var(--focus-offset);
}



/* ---------- CURSOR: GRABBED (keep last) ---------------------------
   Mouse down on anything, and any drag in progress, shows the grabbed
   hand. Last in the file so it beats every earlier same-specificity
   cursor declaration. */
html *:active,
html .is-dragging,
html .is-dragging * { cursor: var(--cursor-grabbed); }

/* Two-hands cursor over the packages image. js/piano-hands.js adds
   .has-hands to the strip on pointer enter (desktop, fine pointer only),
   hides the native cursor, and drives --hx/--hy on .piano-hands. */
.has-hands,
.has-hands * { cursor: none; }
.piano-hands {
  position: fixed;
  inset: 0;
  pointer-events: none;
  z-index: 60;
}
.piano-hand {
  position: absolute;
  left: 0;
  top: 0;
  width: var(--hand-size);
  height: var(--hand-size);
  transform-origin: var(--hand-hot-x) var(--hand-hot-y);
}
.piano-hand--a {
  transform: translate(calc(var(--hx) - var(--hand-hot-x)), calc(var(--hy) - var(--hand-hot-y))) rotate(var(--hand-rotate));
}
.piano-hand--b {
  transform: translate(calc(var(--hx) - var(--hand-hot-x)), calc(var(--hy) + var(--hands-gap) - var(--hand-hot-y))) rotate(var(--hand-rotate)) scaleX(-1);
}

/* ---------- /404 and THANKS (/weddings/thanks, /events/thanks) -------
   Section-scale moment (.section-heading, not the masthead H1): stacked
   heading, two lines of body copy, one small portrait photograph
   right-aligned below them. The full site header carries navigation.
   Thanks pages: same section, heading + body paragraphs, no photo. */
.notfound,
.thanks {
  padding-block: var(--section-gap-m);
  row-gap: var(--s2);
  align-items: start;
}
.notfound > *,
.thanks > * { grid-column: 1 / -1; }
.notfound .section-heading,
.thanks .section-heading { margin-bottom: var(--heading-gap-m); }
.notfound-body,
.thanks-body {
  margin: 0;
  max-width: var(--measure);
  font-size: var(--t-body-m);
}
.notfound-photo {
  margin-block-start: var(--s5);
  justify-self: end;
  width: var(--s8);
  aspect-ratio: var(--ratio-portrait);
  border: var(--rule);
  overflow: hidden;
  border-radius: var(--shape-soft-c);
}
.notfound-photo img {
  display: block;
  width: 100%;
  height: 100%;
  object-fit: cover;
  object-position: 30% center;
}
@media (min-width: 768px) {
  /* Text in cols 1-6, photo fills the right half (cols 7-12, the
     masthead image's span) from the heading's top edge down. The
     photo spans a fourth, 1fr row so its extra height lands there
     instead of being spread between the heading and body lines. */
  .notfound {
    padding-block: var(--section-gap);
    grid-template-rows: auto auto auto 1fr;
  }
  .notfound .section-heading { grid-column: 1 / 7; margin-bottom: var(--heading-gap); }
  .notfound-body { grid-column: 1 / 7; }
  .notfound-body,
  .thanks-body { font-size: var(--t-body); }
  .notfound-photo {
    grid-column: 7 / 13;
    grid-row: 1 / span 4;
    justify-self: stretch;
    width: auto;
    margin-block-start: 0;
  }
}

/* ---------- /JAR and /CARD — shared utility-page template ---------------
   Moved verbatim from /jar's inline <style>. Class names are unchanged so
   /jar renders identically; /card uses the same rules. */
/* CONOR THAXTER — TIP JAR. Values come from tokens.css only; header
   and base styles come from styles/site.css. */

.jar{
  width: 100%;
  max-width: var(--measure);
  margin: 0 auto;
  padding: var(--s4) var(--margin-m) var(--s6);
  display: flex;
  flex-direction: column;
  gap: var(--s4);
  font-size: var(--t-body-m);
  line-height: var(--lh-body);
  letter-spacing: var(--track-body);
  text-transform: none;
}

/* tip jar trigger: stencil, above the photo */
.jar-tip-trigger{
  display: block;
  width: 100%;
  min-height: var(--tap-target-min);
  padding: var(--rule-pad);
  background: none;
  border: var(--rule);
  border-radius: var(--shape-mid-a);
  color: var(--ink);
  font-family: var(--font-body);
  font-weight: var(--weight-body);
  font-size: var(--t-body-m);
  line-height: var(--lh-body);
  letter-spacing: var(--track-body);
  text-transform: none;
  text-align: center;
  cursor: pointer;
}

.jar-portrait{
  margin: 0;
  aspect-ratio: var(--ratio-square);
  border: var(--rule);
  border-radius: var(--shape-soft-a);
  padding: var(--rule-pad-photo);
  overflow: hidden;
}
.jar-portrait img{
  display:block;
  width:100%; height:100%;
  object-fit: cover;
  object-position: top;
}

.jar-schedule{ margin:0; letter-spacing: var(--track-body); }
.jar-schedule strong{ font-weight: var(--weight-strong); }

/* action list: type-only rows, --ink label, --ink-quiet value beneath,
   a rule between each row (none after the last) */
.jar-rows{ display:flex; flex-direction:column; }

.jar-row{
  display:flex;
  flex-direction:column;
  align-items:flex-start;
  justify-content:center;
  width: 100%;
  min-height: var(--tap-target-min);
  padding: var(--rule-pad) 0;
  background: none;
  border: 0;
  border-bottom: var(--rule);
  border-radius: var(--shape-none);
  color: var(--nav-color);
  font: inherit;
  letter-spacing: var(--track-body);
  text-transform: none;
  text-align: left;
  text-decoration: none;
  cursor: pointer;
}
.jar-row:first-child{ padding-top: 0; }
.jar-row:last-child{ border-bottom: 0; padding-bottom: 0; }
.jar-row .jar-value{ color: var(--ink-quiet); }

.jar-status{
  position: fixed;
  left: var(--margin-m); right: var(--margin-m);
  bottom: var(--s4);
  margin: 0;
  background: var(--ground);
  color: var(--ink);
  font-weight: var(--weight-strong);
  letter-spacing: var(--track-body);
  text-align: center;
  visibility: hidden;
}
.jar-status[data-show="true"]{ visibility: visible; }

/* Tip jar sub-modal: solid --ground, full screen; the panel inside is
   the one boxed element */
.jar-modal{
  display:none;
  position: fixed; inset:0;
  background: var(--ground);
  z-index: 100;
  padding: var(--s4) var(--margin-m);
  overflow-y: auto;
  align-items:center; justify-content:center;
  font-size: var(--t-body-m);
  line-height: var(--lh-body);
  text-transform: none;
}
.jar-modal.active{ display:flex; }
.jar-tip-panel{
  width:100%; max-width: var(--measure);
  margin: auto;
  padding: var(--rule-pad);
  border: var(--rule);
  border-radius: var(--shape-mid-b);
  display:flex; flex-direction:column;
  gap: var(--s3);
}
.jar-tip-heading{
  margin:0;
  font-family: var(--font-body);
  font-weight: var(--weight-body);
  font-size: var(--t-body-m);
  line-height: var(--lh-body);
  letter-spacing: var(--track-body);
}
.jar-tip-panel .jar-rows{ gap: 0; }
.jar-zelle-reveal{ padding: 0 0 var(--rule-pad); border-bottom: var(--rule); }
.jar-zelle-reveal[hidden]{ display:none; }
#zelle-btn[aria-expanded="true"]{ border-bottom: 0; padding-bottom: var(--s1); }
.jar-zelle-reveal p{ margin: 0 0 var(--s1); color: var(--ink-quiet); letter-spacing: var(--track-body); }
.jar-zelle-reveal .jar-row{ border-bottom: 0; padding: 0; min-height: var(--tap-target-min); }
.jar-modal-copy-feedback{ display:none; font-weight: var(--weight-strong); }
.jar-modal-copy-btn.copied .jar-modal-copy-text{ display:none; }
.jar-modal-copy-btn.copied .jar-modal-copy-feedback{ display:inline; }

@media (min-width: 768px){
  .jar{ padding-inline: var(--margin-t); font-size: var(--t-body); }
  .jar-modal{ font-size: var(--t-body); }
}
@media (min-width: 1180px){
  .jar{ padding-inline: var(--margin); }
}

/* /CARD — the Voice clip. 16:9 poster + tap-to-play; the Play label is a
   full-width type-only bar across the frame's bottom edge (bigger tap
   target, and no circular control anywhere else on the site). */
.jar-reel{
  position: relative;
  aspect-ratio: var(--ratio-video);
  border: var(--rule);
  border-radius: var(--shape-mid-a);
  overflow: hidden;
  background: var(--ground);
}
.jar-reel-poster{ position:absolute; inset:0; display:block; width:100%; height:100%; object-fit:cover; }
.jar-reel iframe{ position:absolute; inset:0; width:100%; height:100%; border:0; }
.jar-reel-play{
  position: absolute;
  left: 0; right: 0; bottom: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  min-height: var(--tap-target-min);
  padding: var(--rule-pad);
  background: var(--ground);
  border: 0;
  border-top: var(--rule);
  border-radius: var(--shape-none);
  color: var(--ink);
  font-family: var(--font-body);
  font-weight: var(--weight-body);
  font-size: var(--t-label);
  letter-spacing: var(--track-caps);
  text-transform: uppercase;
  cursor: pointer;
}

/* ---------- /CONOR-JAMES -----------------------------------------------
   SEO bridge page: masthead-scale heading block, then prose in a
   --measure column left-aligned at the grid margin, rows for the actions,
   type-only FAQ accordions. */
.cj-hero{ padding-bottom: var(--section-gap-m); }
.cj-eyebrow,
.cj-label{
  margin: 0;
  color: var(--ink);
  font-family: var(--font-body);
  font-size: var(--t-label);
  letter-spacing: var(--track-caps);
  text-transform: uppercase;
}
.cj-label{ margin-bottom: var(--s3); }
.cj-subline{
  margin: 0;
  font-family: var(--font-serif);
  font-style: italic;
  font-size: var(--t-lead-m);
  color: var(--ink);
}
.cj-section{
  padding-block: var(--section-gap-m);
  border-top: var(--rule);
  align-items: start;
}
.cj-body{
  grid-column: 1 / -1;
  max-width: var(--measure);
  font-size: var(--t-body-m);
}
.cj-body p{ margin: 0 0 var(--s3); }
.cj-body p:last-child{ margin-bottom: 0; }
.cj-body .jar-rows{ margin: 0; }
.cj-body .section-heading{ grid-column: auto; }
.cj-work-links{
  margin-top: var(--s3);
  display: flex;
  flex-wrap: wrap;
  gap: var(--s2) var(--s4);
}
.cj-faq-item{ border-top: var(--rule); }
.cj-faq-item:last-of-type{ border-bottom: var(--rule); }
.cj-faq-item summary{
  display: flex;
  justify-content: space-between;
  align-items: center;
  gap: var(--s3);
  padding: var(--s3) 0;
  font-weight: var(--weight-strong);
  list-style: none;
}
.cj-faq-item summary::-webkit-details-marker{ display: none; }
.cj-faq-item summary::after{ content: '+'; flex: none; }
.cj-faq-item[open] summary::after{ content: '\2212'; }
.cj-faq-item p{ margin: 0; padding-bottom: var(--s3); }
@media (min-width: 768px){
  .cj-hero{ padding-bottom: var(--section-gap); }
  .cj-section{ padding-block: var(--section-gap); }
  .cj-subline{ font-size: var(--t-lead); }
  .cj-body{ font-size: var(--t-body); }
}

/* ---------- HEADER ROW 1, 768-1179px ----------------------------------
   The 1fr-auto-1fr grid cannot fit all seven items plus a centred
   wordmark below ~1118px. Newsletter and Contact leave the header in
   this band; Newsletter is already the Substack entry in Links, and
   Contact's two copy-to-clipboard emails are appended to the Links
   window (.header-band). From 1180px up they are back in the header
   and the band items are hidden. Below 768px the mobile overlay is
   used and none of this applies. */
@media (min-width: 768px) and (max-width: 1179px) {
  .header-nav--right .header-nav-newsletter,
  .header-nav--right .header-nav-item--contact { display: none; }
  /* At the default --s4 gap the left group alone is 230.5px, too wide
     for a centred layout at 768px. At --s2 it is 185.2px against a
     200.9px track (measured at 768px: about 15px of slack). */
  .header-nav { gap: var(--s2); }
}
@media (min-width: 1180px) {
  .header-dropdown .header-band,
  .dialog-body .header-band { display: none; }
  .dialog-body > :has(+ .header-band),
  .header-dropdown-inner > :has(+ .header-band) { border-bottom: 0; }
}
