/* ---------------------------------------------------------------------------
 * theflir.com — page styles.
 *
 * Consumes the design system's real tokens (public/styles/tokens.css, generated
 * by tools/sync-tokens.py). No raw colours, sizes or faces below. Anything that
 * has to be decided here rather than taken from the system is namespaced
 * --site-* so it is obvious it is local, and is listed with its reason.
 *
 * House rules this file is holding to:
 *   GDL-01  grey is the instrument, red is heat. ONE red per surface.
 *   GDL-02  Placard/Frankfurter for the wordmark, Archivo for prose,
 *           IBM Plex Mono for anything that reads as instrument output.
 *   GDL-03  the system is square. radius-hair on inputs, nothing else rounds.
 *   GDL-04  ALL CAPS for labels and data, sentence case for prose.
 * ------------------------------------------------------------------------- */

:root {
  /* --site-measure is local: the system has no reading measure, and prose
     needs one whatever the shell is doing.

     --site-container was 44rem, chosen when this was a single landing page.
     The site is five pages on the kit's layout now, and the kit lays out to
     the system's own page width -- so it uses --flir-page-max, which is what
     that token is for. At 44rem the release title wrapped to three lines and
     the archive cells came out 229px against the kit's ~400.

     Prose does NOT widen with it. Every prose block caps at --site-measure
     independently, so line length is unchanged; only the shell moved. */
  /* The kit pads pages space-7 wide and space-4 narrow; body carries it here,
     and the full-bleed blocks cancel exactly this. */
  --site-gutter: var(--flir-space-7);
  --site-measure: 62ch;
  --site-container: var(--flir-page-max);
}

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

body,
h1,
h2,
h3,
p,
figure,
ul,
ol,
dl,
dd {
  margin: 0;
}

/* Tracklists and data rows are ordered lists for their semantics, not for the
 * numbering — the track number is set in the row itself, as mono data. */
ul,
ol {
  padding: 0;
  list-style: none;
}

html {
  -webkit-text-size-adjust: 100%;
}

body {
  min-height: 100svh;
  /* space-7 inline, matching the kit's .pg / .pg-tight rather than the
     tighter landing-page gutter this started with.

     The block-end is space-5. It was space-9, 96px, closing every page BELOW
     the footer with nothing after it -- on a phone, most of a scroll of empty
     ink, and it read as the footer floating above a void rather than sitting
     at the end of the page. 48px was tried first, to match the footer's own
     top padding, and the band still read it as a gap: on mobile the footer
     wraps to three stacked rows, so the credit line ends far down a tall block
     and any trailing space reads as slack rather than margin. 24px ends the
     page without the credit touching the edge.

     The 96px ABOVE the footer is deliberately untouched: that is this flex
     column's gap, shared with the header, the hero and main, and it is the
     page's rhythm rather than slack. Changing it would move every major block
     on all five pages. */
  padding: var(--flir-space-8) var(--site-gutter) var(--flir-space-5);
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: var(--flir-space-9);

  background: var(--flir-bg);
  color: var(--flir-fg);
  font-family: var(--flir-font-body);
  font-size: var(--flir-text-body);
  line-height: var(--flir-lh-body);
  -webkit-font-smoothing: antialiased;
}

/* The instrument overlay. GDL-07 wants screens to read as a monitor feed;
   with no photographic field on this page, the scanline alone carries it.
   Decorative, sits behind everything, never over type at any real opacity.
   Overhangs the top by exactly its drift distance -- see the motion section
   at the end of this file -- so the drift never exposes a band of page. */
body::before {
  /* One scanline period (tokens.css:70). The overhang and the drift distance in
   * the motion section are the same value by construction, so the loop stays
   * seamless and the overhang stays exactly big enough. */
  --site-scan-drift: 4px;

  content: "";
  position: fixed;
  inset: calc(var(--site-scan-drift) * -1) 0 0 0;
  z-index: -1;
  pointer-events: none;
  background: var(--flir-scanline), var(--flir-vignette);
}

:where(a, button, input):focus-visible {
  outline: var(--flir-rule-accent);
  outline-offset: 3px;
}

.skip-link {
  position: absolute;
  left: -9999px;
}

.skip-link:focus {
  left: var(--flir-space-5);
  top: var(--flir-space-5);
  padding: var(--flir-space-2) var(--flir-space-3);
  background: var(--flir-fg);
  color: var(--flir-fg-inverse);
  z-index: 1;
}

.stack {
  width: 100%;
  max-width: var(--site-container);
}

/* body gaps its own children; main has to space its sections itself, or the
 * next section label sits on the previous section's last row. */
main.stack {
  display: grid;
  gap: var(--flir-space-8);
}

/* --- site header (kit Chrome.jsx SiteHeader) ------------------------------
 *
 * The site had no nav at all: each page carried a bare wordmark and a label,
 * which made five documents rather than one site. This is the kit's header,
 * the one fixed element the system allows on web.
 *
 * NAV in the kit is Home / Music / Live / Contact. Band is added here because
 * the site has a /band the kit never had as its own route. A nav that omits a
 * page the site actually serves is worse than one that deviates by a row.
 */
.site-head {
  position: sticky;
  top: 0;
  z-index: 10;
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--flir-space-5);
  padding: var(--flir-space-4) var(--flir-space-7);
  background: var(--flir-bg);
  border-bottom: var(--flir-rule);
  /* Full bleed by cancelling the gutter, not by 100vw. Under body's
     align-items:center a 100vw child overflows its padded column on BOTH sides
     and widens the scroll area; adding margin-left:calc(50% - 50vw) on top of
     that shifted it left again, which is how the wordmark ended up off-screen.
     Negative margins equal to the gutter are exact and involve no vw at all. */
  /* align-self:stretch is required, not decorative: body is align-items:center,
     and .hero's own content is absolutely positioned, so without it the element
     has nothing to size against and collapses to zero width. */
  align-self: stretch;
  margin-inline: calc(var(--site-gutter) * -1);
  margin-top: calc(var(--flir-space-8) * -1);
}

.site-head__home {
  display: inline-flex;
  text-decoration: none;
  color: inherit;
}

.site-head__mark { font-size: 34px; }

.site-nav {
  display: flex;
  gap: var(--flir-space-6);
}

.site-nav__link {
  font-family: var(--flir-font-data);
  font-weight: var(--flir-fw-medium);
  font-size: var(--flir-text-micro);
  letter-spacing: var(--flir-ls-wider);
  text-transform: uppercase;
  color: var(--flir-fg-muted);
  text-decoration: none;
  padding-bottom: var(--flir-space-1);
  border-bottom: var(--flir-border-mark) solid transparent;
  transition: color var(--flir-transition), border-color var(--flir-transition);
}

.site-nav__link:hover { color: var(--flir-fg); }

/* Current page. The accent underline is the kit's, and it is the only thing
 * telling you where you are now that the label beside the wordmark is gone. */
.site-nav__link[aria-current='page'] {
  color: var(--flir-fg);
  border-bottom-color: var(--flir-accent);
}

.head-cta {
  font-family: var(--flir-font-data);
  font-weight: var(--flir-fw-medium);
  font-size: var(--flir-text-micro);
  letter-spacing: var(--flir-ls-wider);
  text-transform: uppercase;
  color: var(--flir-accent-text);
  text-decoration: none;
  border-bottom: var(--flir-border-hair) solid var(--flir-accent-text);
}

/* The kit drops the CTA below its breakpoint rather than wrapping the row. */
@media (max-width: 45rem) {
  :root { --site-gutter: var(--flir-space-4); }
  .site-head { padding: var(--flir-space-3) var(--flir-space-4); flex-wrap: wrap; }
  /* Five items where the kit had four: let the row wrap instead of running off
     the screen. The kit hides the CTA here, which buys back one slot. */
  .site-nav { gap: var(--flir-space-4); flex-wrap: wrap; }
  .head-cta { display: none; }
}

/* --- hero ---------------------------------------------------------------- */

/* WEB-01, ported from the kit's HomeScreen + PhotoField. This was the piece
 * the site never got: it ran wordmark-over-nothing, centred, while the design
 * system specified a photographic field with the type sitting on it.
 *
 * PhotoField is four stacked layers in the kit, and they are four here: the
 * photograph under the Corbijn filter, then crush, grain-hard and scanline
 * over it. Those are all tokens, already vendored — this composes them, it
 * does not invent anything.
 *
 * The kit uses inline styles for all of it. The site's CSP is style-src
 * 'self', so every one of them has to be a class. That is the whole reason
 * this page is hand-written rather than snapshotted. */
/* Full-bleed, and that means escaping body's padding on three sides — the kit
 * puts this flush to the top edge of the viewport, not inside a margin. body
 * pads space-8 top and space-5 inline, so both are pulled back here. */
.hero {
  /* The photograph's travel in the motion section at the end of this file, and
   * the slack every layer needs to make room for it. These are DERIVED rather
   * than two independent numbers on purpose: the pass's one silently-breakable
   * invariant is slack >= travel, and writing 32 and 28 as separate literals
   * meant an ordinary edit to the drift could open a band of --flir-bg-sunken
   * along the hero's top edge with nothing to catch it. Raising the drift now
   * raises the slack with it.
   *
   * The photograph carries the largest travel; the crush is derived from it,
   * and grain and scanline stay literal because they are locked to their tile
   * periods.
   *
   * The drift is far larger at desktop than at mobile, and that asymmetry is
   * deliberate. object-fit: cover only rescales the photograph when the taller
   * box changes which axis drives the fit -- at 1280 the fit stays width-driven
   * until the box passes ~2000px tall, so slack is free there and costs nothing
   * off the frame. At 375 the crossover is around 590px, so every pixel of
   * slack is paid for. Mobile therefore keeps the original 28px and the ~5%
   * frame cost that was accepted with it. */
  --site-hero-drift: 120px;
  --site-hero-slack: calc(var(--site-hero-drift) + 4px);

  /* How far the wordmark, rule, lede and buttons rise as the hero leaves.
   * Against the photograph drifting DOWN, this opens roughly 190px of
   * separation between the type and the field behind it, which is the part
   * that actually reads as depth. Costs nothing: .hero__pad is absolutely
   * positioned, so moving it cannot crop the photograph or shift layout. */
  --site-hero-lift: 72px;

  position: relative;
  height: 620px;
  overflow: hidden;
  background: var(--flir-bg-sunken);
  max-width: none;
  /* Full bleed by cancelling the gutter, not by 100vw. Under body's
     align-items:center a 100vw child overflows its padded column on BOTH sides
     and widens the scroll area; adding margin-left:calc(50% - 50vw) on top of
     that shifted it left again, which is how the wordmark ended up off-screen.
     Negative margins equal to the gutter are exact and involve no vw at all. */
  /* align-self:stretch is required, not decorative: body is align-items:center,
     and .hero's own content is absolutely positioned, so without it the element
     has nothing to size against and collapses to zero width. */
  align-self: stretch;
  margin-inline: calc(var(--site-gutter) * -1);
  /* Cancels body's flex gap, not its top padding: the sticky header now sits
     above this and has already taken the padding. Pulling by space-8 here left
     a 32px band of page background between header and hero. */
  margin-top: calc(var(--flir-space-9) * -1);
}

/* Every layer is oversized vertically by more than its travel in the motion
 * section at the end of this file, and that slack is load-bearing: .hero is
 * overflow: hidden, so a layer sitting at inset: 0 exactly cannot move at all
 * without dragging --flir-bg-sunken in at the trailing edge.
 *
 * The slack must be GREATER THAN OR EQUAL TO the largest travel; it is derived
 * from it on .hero above so the two cannot drift apart. This is not a free
 * parameter: when the slack was 24px against 28px of travel, the photograph's
 * leading edge ended 4px inside the hero and opened a visible band of
 * --flir-bg-sunken along the top -- an edge-coverage failure in measurement,
 * and easy to miss by eye on a dark photograph.
 *
 * It is inline-flush on purpose -- nothing moves horizontally, and widening
 * these would push against the full-bleed geometry above.
 *
 * WHAT THE OVERSIZING ACTUALLY COSTS, measured rather than assumed. It is not
 * a uniform crop and it is not paid at every width, because object-fit: cover
 * only rescales when the taller box changes which axis drives the fit:
 *
 *   1280 -- fit stays width-driven, scale unchanged at 2.2417, and the box
 *           shares its centre with the hero. The framing through the hero
 *           window is IDENTICAL to before. No cost at all.
 *   375  -- fit crosses over from width-driven to height-driven, scale rises
 *           0.6567 to 0.6933, and the visible field drops from 100% to ~94.7%
 *           of the frame horizontally and ~94.7% to ~89.7% vertically.
 *
 * So the cost is roughly 5% of the frame on mobile and nothing on desktop.
 * Mobile readers in Firefox, and anyone with reduced motion, pay that 5% and
 * see none of the motion it buys. Accepted, but it is the mobile frame that
 * pays -- an earlier version of this comment claimed "~32px tighter, always",
 * which was wrong in both magnitude and distribution. */
.hero__photo,
.hero__layer {
  position: absolute;
  inset: calc(var(--site-hero-slack) * -1) 0;
}

.hero__photo {
  width: 100%;
  /* Required, not redundant. An absolutely positioned replaced element with
   * height: auto resolves to its INTRINSIC height, not the height its insets
   * imply -- so without this the photograph would size to 900px regardless of
   * the hero. */
  height: calc(100% + var(--site-hero-slack) * 2);
  object-fit: cover;
  filter: var(--flir-photo-filter-corbijn);
}

.hero__layer--crush { background: var(--flir-crush); }
.hero__layer--grain { background-image: var(--flir-grain-hard); }
.hero__layer--scan  { background-image: var(--flir-scanline); }

/* Type sits clean on top: label pinned high, the block pinned low. */
.hero__pad {
  position: absolute;
  inset: 0;
  box-sizing: border-box;
  padding: var(--flir-space-8) var(--flir-space-7);
  display: flex;
  flex-direction: column;
  justify-content: space-between;
  align-items: flex-start;
}

.hero__est {
  font-family: var(--flir-font-data);
  font-weight: var(--flir-fw-medium);
  font-size: var(--flir-text-micro);
  letter-spacing: var(--flir-ls-wider);
  text-transform: uppercase;
  color: var(--flir-fg-muted);
}

.hero__block {
  display: flex;
  flex-direction: column;
  gap: var(--flir-space-5);
  align-items: flex-start;
}

/* The hero's red. GDL-01 allows one per surface and the signup button below
 * is the other candidate — see the note on .hero__btn. */
.hero__rule {
  width: 320px;
  height: var(--flir-border-heavy);
  background: var(--flir-accent);
}

.hero__actions {
  display: flex;
  gap: var(--flir-space-4);
  align-items: center;
  flex-wrap: wrap;
}

/* Outlined, not filled. The kit's Listen button is solid red because the kit's
 * home has no mailing-list section to compete with; this page does, and the
 * signup CTA is the reason the domain was bought. Two filled reds on one
 * surface breaks GDL-01, so the rule above carries the accent and these carry
 * the structure. Flip it if the band would rather Listen be the loud one. */
/* Solid, per the kit: HomeScreen's Listen is variant="primary". An earlier
 * pass outlined it to keep one red on the page, which was stricter than the
 * design system itself -- the kit's home spends accent on the nav underline,
 * the Bandcamp CTA, the hero rule and this button. Matching the kit. */
.hero__btn {
  padding: var(--flir-space-4) var(--flir-space-6);
  background: var(--flir-accent);
  color: var(--flir-accent-fg);
  border: var(--flir-border-mark) solid var(--flir-accent);
  border-radius: var(--flir-radius-none);
  font-family: var(--flir-font-data);
  font-weight: var(--flir-fw-medium);
  font-size: var(--flir-text-small);
  letter-spacing: var(--flir-ls-wider);
  text-transform: uppercase;
  text-decoration: none;
  transition: background var(--flir-transition), border-color var(--flir-transition);
}

.hero__btn:hover,
.hero__btn:focus-visible {
  background: var(--flir-accent-hover);
  border-color: var(--flir-accent-hover);
}

.hero__btn--quiet {
  background: transparent;
  border-color: var(--flir-line-strong);
  color: var(--flir-fg-muted);
}

.hero__btn--quiet:hover,
.hero__btn--quiet:focus-visible {
  background: transparent;
  border-color: var(--flir-fg);
  color: var(--flir-fg);
}

@media (max-width: 45rem) {
  .hero {
    height: 560px;
    /* See the note on --site-hero-drift: slack is nearly free at desktop and
     * paid for out of the visible frame at mobile, so the travel stays small
     * here. The type still lifts, and lifting costs nothing. */
    --site-hero-drift: 28px;
    --site-hero-lift: 48px;
  }
  .hero__pad { padding: var(--flir-space-6) var(--flir-space-4); }
  .hero__rule { width: 160px; }
}

/* The wordmark is .flir-wordmark from the token layer — one word, two faces,
   locked 1:2 ratio, never re-typed by hand (GDL-05). All this does is size it.
   It is white: the page spends its one red on the signup button. */
/* 132px desktop / 68px narrow, the kit's wm-hero. Clamped rather than switched
 * at a breakpoint so it survives the widths between the kit's two. */
.hero__wordmark {
  font-size: clamp(68px, 9vw, 132px);
  color: var(--flir-fg);
  line-height: var(--flir-lh-tight);
}

/* grey-80, not fg-muted: it sits on a photograph now, not on the page. */
.hero__lede {
  max-width: 46ch;
  font-size: var(--flir-text-lead);
  line-height: var(--flir-lh-body);
  color: var(--flir-grey-80);
  margin: 0;
}

@media (max-width: 45rem) {
  .hero__lede {
    max-width: none;
    font-size: var(--flir-text-body);
  }
}

/* --- section furniture --------------------------------------------------- */

.section__label {
  font-family: var(--flir-font-data);
  font-weight: var(--flir-fw-medium);
  font-size: var(--flir-text-micro);
  letter-spacing: var(--flir-ls-wider);
  text-transform: uppercase;
  color: var(--flir-fg-muted);
  margin-bottom: var(--flir-space-4);
  padding-bottom: var(--flir-space-3);
  border-bottom: var(--flir-rule);
}

/* --- latest release ------------------------------------------------------ */

/* One link, so cover and title share a hit target rather than competing. */
.release__card {
  display: flex;
  align-items: center;
  gap: var(--flir-space-5);
  padding: var(--flir-space-4);
  border: var(--flir-rule);
  border-radius: var(--flir-radius-none);
  background: var(--flir-surface);
  color: inherit;
  text-decoration: none;
  transition: border-color var(--flir-transition), background var(--flir-transition);
}

.release__card:hover,
.release__card:focus-visible {
  border-color: var(--flir-line-strong);
  background: var(--flir-surface-raised);
}

.release__cover {
  flex: none;
  width: 8rem;
  height: auto;
  aspect-ratio: 1;
  object-fit: cover;
  border-radius: var(--flir-radius-none);
  background: var(--flir-bg-sunken);
}

.release__body {
  display: grid;
  gap: var(--flir-space-2);
  min-width: 0;
}

.release__title {
  font-family: var(--flir-font-headline);
  font-variation-settings: 'wdth' var(--flir-wdth-wide), 'wght' var(--flir-fw-bold);
  font-size: var(--flir-text-h3);
  line-height: var(--flir-lh-tight);
  text-wrap: balance;
}

/* Catalogue row — mono and uppercase per GDL-04. Wraps rather than truncates,
 * so the catalogue number is never the thing that gets cut off on a phone. */
.release__meta {
  font-family: var(--flir-font-data);
  font-size: var(--flir-text-nano);
  letter-spacing: var(--flir-ls-wider);
  text-transform: uppercase;
  color: var(--flir-fg-muted);
}

/* Not red. The signup button is this surface's one red (GDL-01). */
.release__cta {
  margin-top: var(--flir-space-1);
  font-family: var(--flir-font-data);
  font-size: var(--flir-text-micro);
  letter-spacing: var(--flir-ls-wide);
  text-transform: uppercase;
  color: var(--flir-fg);
}

.release__card:hover .release__cta,
.release__card:focus-visible .release__cta {
  color: var(--flir-accent-text);
}

@media (max-width: 26rem) {
  .release__card {
    flex-direction: column;
    align-items: start;
  }

  .release__cover {
    width: 100%;
  }
}

/* --- home split: latest release + next live (kit HomeScreen) --------------
 *
 * The site ran a single compact release card and a "No dates" link row. The
 * kit gives the record a proper brief -- cover, badges, the tracklist inline --
 * and puts the live status beside it as a card rather than a row. Two columns
 * at 1.1fr / 1fr, stacking below the breakpoint, exactly as the kit does.
 */
.split {
  display: grid;
  grid-template-columns: 1.1fr 1fr;
  gap: var(--flir-space-8);
  align-items: start;
}

/* SectionHead: kicker over title, optional right-hand meta, rule beneath. */
.sechead {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: var(--flir-space-5);
  border-bottom: var(--flir-rule);
  padding-bottom: var(--flir-space-3);
  margin-bottom: var(--flir-space-6);
}

.sechead__stack { display: grid; gap: var(--flir-space-2); }

.sechead__kicker,
.sechead__right {
  font-family: var(--flir-font-data);
  font-weight: var(--flir-fw-medium);
  font-size: var(--flir-text-micro);
  letter-spacing: var(--flir-ls-wider);
  text-transform: uppercase;
  color: var(--flir-fg-faint);
}

.sechead__right { white-space: nowrap; }

.sechead__title {
  margin: 0;
  font-family: var(--flir-font-headline);
  font-variation-settings: 'wdth' var(--flir-wdth-wide), 'wght' var(--flir-fw-bold);
  font-size: var(--flir-text-h2);
  line-height: var(--flir-lh-tight);
}

.release-brief {
  display: grid;
  grid-template-columns: 200px 1fr;
  gap: var(--flir-space-6);
  align-items: start;
}

/* height:auto is load-bearing, not tidiness. The img carries height="700" for
 * layout stability, and without height:auto that attribute wins over
 * aspect-ratio -- the box became 200x700 and object-fit cropped a narrow
 * vertical strip out of a square cover. .rel__cover on /music already had it,
 * which is why that one rendered and this one did not. */
.release-brief__cover {
  width: 100%;
  height: auto;
  display: block;
  aspect-ratio: 1;
  object-fit: cover;
  border: var(--flir-rule);
  background: var(--flir-black);
}

.release-brief__body { display: grid; gap: var(--flir-space-4); justify-items: start; }

.badges { display: flex; gap: var(--flir-space-2); flex-wrap: wrap; }

.badge {
  font-family: var(--flir-font-data);
  font-size: var(--flir-text-nano);
  letter-spacing: var(--flir-ls-wide);
  text-transform: uppercase;
  padding: var(--flir-space-1) var(--flir-space-2);
  border: var(--flir-rule);
  color: var(--flir-fg-muted);
}

.badge--outline { border-color: var(--flir-line-strong); color: var(--flir-fg); }

/* The tracklist, inline. This is the part the compact card had no room for,
 * and it is the reason to open the page: the record's contents, not a link. */
.tracklist { width: 100%; font-family: var(--flir-font-data); color: var(--flir-fg-muted); }

.tracklist__row {
  display: flex;
  /* NOT space-between. That worked while the row held two children and put one
   * at each edge; the play cue made it three, and the title drifted into the
   * middle of the row. The credit is pushed right on its own instead, which
   * holds however many children the row grows. */
  justify-content: flex-start;
  gap: var(--flir-space-4);
  border-bottom: var(--flir-rule);
  padding: var(--flir-space-2) 0;
  font-size: var(--flir-text-small);
}

.tracklist__note {
  margin-left: auto;
  color: var(--flir-fg-faint);
  font-size: var(--flir-text-nano);
  letter-spacing: var(--flir-ls-wide);
  text-transform: uppercase;
  white-space: nowrap;
}

/* Live status as a card, per the kit -- index, status, title, body. */
.card {
  border: var(--flir-rule);
  background: var(--flir-surface);
  padding: var(--flir-space-5);
  display: grid;
  gap: var(--flir-space-3);
  justify-items: start;
}

.card__meta {
  display: flex;
  justify-content: space-between;
  gap: var(--flir-space-4);
  width: 100%;
  font-family: var(--flir-font-data);
  font-size: var(--flir-text-nano);
  letter-spacing: var(--flir-ls-wide);
  text-transform: uppercase;
  color: var(--flir-fg-faint);
}

.card__title {
  margin: 0;
  font-family: var(--flir-font-headline);
  font-variation-settings: 'wdth' var(--flir-wdth-wide), 'wght' var(--flir-fw-bold);
  font-size: var(--flir-text-h3);
  line-height: var(--flir-lh-tight);
}

.card__copy { margin: 0; color: var(--flir-fg-muted); font-size: var(--flir-text-small); }

/* ---- Shop ----------------------------------------------------------------
 *
 * Two rules, because the product cards are .card and the badges, data lists
 * and buttons all already exist. The only thing the system had no answer for
 * was the product photograph: there is no global img rule in this stylesheet,
 * and the 2008 garment shots are 350px wide, which overflows a card on a
 * 375px phone. max-width bounds them without stretching the small ones. */
.shop__grid {
  display: grid;
  gap: var(--flir-space-5);
  grid-template-columns: repeat(auto-fit, minmax(15rem, 1fr));
}

.shop__img {
  display: block;
  max-width: 100%;
  height: auto;
  background: var(--flir-bg-sunken);
}

/* Fan photographs. Five snapshots from five different cameras, none of them
 * good, taken over years -- the rest filter is the thing that makes them read
 * as one wall instead of a folder someone emptied onto a page. It is the same
 * pass every other photograph on this site gets.
 *
 * The columns are deliberately narrow. These originals are 300px wide, and a
 * wider column would only magnify the grain of a 2008 phone camera. */
.fans {
  display: grid;
  gap: var(--flir-space-3);
  grid-template-columns: repeat(auto-fill, minmax(8rem, 1fr));
}

.fans img {
  display: block;
  width: 100%;
  height: auto;
  filter: var(--flir-photo-filter-rest);
  background: var(--flir-bg-sunken);
}

/* Ghost button: the kit's variant for secondary actions. Never filled. */
.btn-ghost {
  display: inline-flex;
  align-items: baseline;
  gap: var(--flir-space-3);
  padding: var(--flir-space-3) var(--flir-space-5);
  border: var(--flir-border-mark) solid var(--flir-line-strong);
  font-family: var(--flir-font-data);
  font-weight: var(--flir-fw-medium);
  font-size: var(--flir-text-micro);
  letter-spacing: var(--flir-ls-wider);
  text-transform: uppercase;
  color: var(--flir-fg);
  text-decoration: none;
  transition: border-color var(--flir-transition);
}

.btn-ghost:hover,
.btn-ghost:focus-visible { border-color: var(--flir-fg); }

.btn-ghost__meta { color: var(--flir-fg-faint); font-size: var(--flir-text-nano); }

@media (max-width: 45rem) {
  .split,
  .release-brief { grid-template-columns: 1fr; gap: var(--flir-space-6); }
}

/* --- archive grid + site footer (kit HomeScreen / Chrome.jsx) -------------
 *
 * The kit's home closes with the photograph archive and the site footer. The
 * site had neither: no grid, and a one-line footer that was not the kit's.
 *
 * Named archive-grid, NOT photo-grid: /band already owns .photo-grid and
 * deliberately uses 3:4 cells, because five of the six scans are portrait
 * and a 4:3 crop throws most of each frame away. Reusing the name silently
 * overrode that. This carries the kit's 4:3 as asked, under its own name,
 * and the same portrait-cropping caveat applies here.
 *
 * The grid is PHOTOS[1..] -- index 0 is the hero, so it is not repeated here.
 * 4:3 under the Corbijn filter with grain over it, the same treatment the hero
 * and the personnel cards use. /band carries a photographs section too; this
 * is the kit's composition and Mo asked for the kit.
 */
.archive-grid {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  gap: var(--flir-space-2);
}

.archive-grid__cell {
  position: relative;
  aspect-ratio: 4 / 3;
  overflow: hidden;
  border: var(--flir-rule);
}

.archive-grid__cell img {
  width: 100%;
  height: 100%;
  object-fit: cover;
  /* -rest, not -corbijn: identical rendering, but shaped so it can interpolate
   * to -reveal. See "Reveal" at the end of this file. */
  filter: var(--flir-photo-filter-rest);
}

/* Grain sits over the photograph, never over type. */
.archive-grid__grain {
  position: absolute;
  inset: 0;
  background-image: var(--flir-grain-hard);
}

.site-foot {
  border-top: var(--flir-rule);
  display: flex;
  align-items: flex-end;
  justify-content: space-between;
  gap: var(--flir-space-5);
  flex-wrap: wrap;
  padding: var(--flir-space-7) 0 0;
  width: 100%;
}

.site-foot__mark { font-size: 28px; }

.site-foot__links {
  display: flex;
  gap: var(--flir-space-5);
  flex-wrap: wrap;
}

.site-foot__link {
  font-family: var(--flir-font-data);
  font-size: var(--flir-text-micro);
  letter-spacing: var(--flir-ls-wider);
  text-transform: uppercase;
  color: var(--flir-fg-muted);
  text-decoration: none;
}

.site-foot__link:hover { color: var(--flir-fg); }

.site-foot__credit {
  font-family: var(--flir-font-data);
  font-size: var(--flir-text-nano);
  letter-spacing: var(--flir-ls-wider);
  text-transform: uppercase;
  color: var(--flir-fg-faint);
}

@media (max-width: 45rem) {
  .archive-grid { grid-template-columns: repeat(2, 1fr); }
}

/* --- links --------------------------------------------------------------- */

/* Rules instead of gaps, per WEB-02. The row is obviously tappable from its
 * rule structure; the right column is mono metadata, never a chevron. */
.links {
  display: grid;
}

.link-row {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: var(--flir-space-5);
  min-height: 3rem;
  padding: var(--flir-space-4) var(--flir-space-3);
  border-bottom: var(--flir-rule);
  color: inherit;
  text-decoration: none;
  transition: background var(--flir-transition), padding-inline var(--flir-transition);
}

.link-row:hover,
.link-row:focus-visible {
  background: var(--flir-surface);
  padding-inline: var(--flir-space-4);
}

.link-row__name {
  font-family: var(--flir-font-headline);
  font-variation-settings: 'wdth' var(--flir-wdth-wide), 'wght' var(--flir-fw-bold);
  font-size: var(--flir-text-lead);
  text-transform: uppercase;
}

.link-row__meta {
  font-family: var(--flir-font-data);
  font-size: var(--flir-text-nano);
  letter-spacing: var(--flir-ls-wide);
  text-transform: uppercase;
  color: var(--flir-fg-muted);
  white-space: nowrap;
}

.link-row:hover .link-row__meta,
.link-row:focus-visible .link-row__meta {
  color: var(--flir-fg);
}

/* --- signup — WEB-03 ----------------------------------------------------- */

.signup {
  padding: var(--flir-space-6) var(--flir-space-5);
  background: var(--flir-surface);
  border: var(--flir-rule);
  border-radius: var(--flir-radius-none);
}

.signup__copy {
  max-width: var(--site-measure);
  margin-bottom: var(--flir-space-4);
  font-size: var(--flir-text-small);
  color: var(--flir-fg-muted);
}

.signup__form {
  display: flex;
  flex-wrap: wrap;
  gap: var(--flir-space-3);
  align-items: flex-end;
}

.signup__field {
  flex: 1 1 18rem;
  min-width: 0;
  display: flex;
  flex-direction: column;
  gap: var(--flir-space-2);
}

/* Visible, mono, uppercase. A placeholder is not a label — it leaves on focus
 * and takes the field's name with it. */
.signup__label {
  font-family: var(--flir-font-data);
  font-weight: var(--flir-fw-medium);
  font-size: var(--flir-text-nano);
  letter-spacing: var(--flir-ls-wider);
  text-transform: uppercase;
  color: var(--flir-fg-muted);
}

/* radius-hair is the one radius GDL-03 grants to inputs. */
.signup__input {
  width: 100%;
  padding: var(--flir-space-3) var(--flir-space-4);
  font-family: var(--flir-font-data);
  font-size: var(--flir-text-small);
  letter-spacing: var(--flir-ls-wide);
  color: var(--flir-fg);
  background: var(--flir-bg);
  border: var(--flir-rule-strong);
  border-radius: var(--flir-radius-hair);
}

.signup__input::placeholder {
  color: var(--flir-fg-faint);
}

/* THE one red on this page. Square — GDL-03 forbids a pill on a button. */
.signup__button {
  padding: var(--flir-space-3) var(--flir-space-6);
  font-family: var(--flir-font-data);
  font-weight: var(--flir-fw-bold);
  font-size: var(--flir-text-micro);
  letter-spacing: var(--flir-ls-wider);
  text-transform: uppercase;
  color: var(--flir-accent-fg);
  background: var(--flir-accent);
  border: var(--flir-border-mark) solid var(--flir-accent);
  border-radius: var(--flir-radius-none);
  cursor: pointer;
  transition: background var(--flir-transition), border-color var(--flir-transition);
}

.signup__button:hover:not(:disabled) {
  background: var(--flir-accent-hover);
  border-color: var(--flir-accent-hover);
}

.signup__button:disabled {
  cursor: progress;
  color: var(--flir-fg-muted);
  background: transparent;
  border-color: var(--flir-line-strong);
}

/* Holds its line while empty so a response never reflows the rows below it. */
.signup__status {
  flex-basis: 100%;
  min-height: 1.5em;
  margin: 0;
  font-family: var(--flir-font-data);
  font-size: var(--flir-text-nano);
  letter-spacing: var(--flir-ls-wide);
  text-transform: uppercase;
  color: var(--flir-fg-faint);
}

/* DEVIATION: the system has no danger/success tokens — it is grey plus one
 * red, and states are not part of the palette. Success is plain foreground.
 * Failure takes --flir-accent-text, and is the only case where a second red
 * appears on this surface: at that moment the error genuinely is the hottest
 * element, and the button it sits under has returned to its disabled grey. */
.signup__status[data-state="error"] {
  color: var(--flir-accent-text);
}

.signup__status[data-state="success"] {
  color: var(--flir-fg);
}

/* ===========================================================================
 * /music — discography. Ported from ui_kits/theflir-com.
 * ======================================================================== */

/* --- page header (interior pages only) ----------------------------------- */

.pagehead {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: var(--flir-space-5);
  padding-bottom: var(--flir-space-4);
  border-bottom: var(--flir-rule);
}

.pagehead__home {
  text-decoration: none;
  color: inherit;
}

.pagehead__mark {
  font-size: var(--flir-text-h3);
  color: var(--flir-fg);
}

.pagehead__where {
  font-family: var(--flir-font-data);
  font-size: var(--flir-text-nano);
  letter-spacing: var(--flir-ls-wider);
  text-transform: uppercase;
  color: var(--flir-fg-muted);
}

/* Count sits on the label's own line, hard right — the kit's SectionHead. */
.section__count {
  float: right;
  font-weight: var(--flir-fw-regular);
  color: var(--flir-fg-faint);
}

/* --- releases ------------------------------------------------------------ */

.releases {
  display: grid;
  gap: var(--flir-space-8);
}

.rel {
  display: grid;
  grid-template-columns: minmax(0, 15rem) minmax(0, 1fr);
  gap: var(--flir-space-6);
  align-items: start;
}

.rel__cover {
  width: 100%;
  height: auto;
  display: block;
  aspect-ratio: 1;
  object-fit: cover;
  border: var(--flir-rule);
  background: var(--flir-bg-sunken);
}

.rel__body {
  display: grid;
  gap: var(--flir-space-3);
  min-width: 0;
}

.rel__cat {
  font-family: var(--flir-font-data);
  font-size: var(--flir-text-nano);
  letter-spacing: var(--flir-ls-wider);
  text-transform: uppercase;
  color: var(--flir-fg-faint);
}

.rel__title {
  font-family: var(--flir-font-headline);
  font-variation-settings: 'wdth' var(--flir-wdth-wide), 'wght' var(--flir-fw-bold);
  font-size: var(--flir-text-h2);
  line-height: var(--flir-lh-tight);
  text-wrap: balance;
}

.rel__sub {
  margin-top: var(--flir-space-4);
  font-family: var(--flir-font-data);
  font-weight: var(--flir-fw-medium);
  font-size: var(--flir-text-nano);
  letter-spacing: var(--flir-ls-wider);
  text-transform: uppercase;
  color: var(--flir-fg-muted);
  padding-bottom: var(--flir-space-2);
  border-bottom: var(--flir-rule);
}

/* Release data and credits are the same shape: a stacked key over its value.
 * Stacked rather than 2-up because these values are phrases, and a right
 * aligned column tears them into ragged lines. */
.rel__data,
.rel__credits {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(9rem, 1fr));
  gap: 0 var(--flir-space-5);
}

.rel__data > div,
.rel__credits > div {
  display: flex;
  flex-direction: column;
  gap: var(--flir-space-1);
  padding: var(--flir-space-3) 0;
  border-bottom: var(--flir-rule);
}

.rel__data dt,
.rel__credits dt {
  font-family: var(--flir-font-data);
  font-size: var(--flir-text-nano);
  letter-spacing: var(--flir-ls-wide);
  text-transform: uppercase;
  color: var(--flir-fg-faint);
}

.rel__data dd,
.rel__credits dd {
  margin: 0;
  font-family: var(--flir-font-data);
  font-size: var(--flir-text-small);
  color: var(--flir-fg-muted);
}

.rel__credits dd {
  color: var(--flir-fg);
}

/* Not red — the Bandcamp block at the foot of the page is the one red. */
.rel__link {
  justify-self: start;
  margin-top: var(--flir-space-4);
  padding: var(--flir-space-3) var(--flir-space-5);
  font-family: var(--flir-font-data);
  font-weight: var(--flir-fw-medium);
  font-size: var(--flir-text-micro);
  letter-spacing: var(--flir-ls-wider);
  text-transform: uppercase;
  color: var(--flir-fg);
  text-decoration: none;
  border: var(--flir-rule-strong);
  border-radius: var(--flir-radius-none);
  transition: background var(--flir-transition), border-color var(--flir-transition);
}

.rel__link:hover,
.rel__link:focus-visible {
  background: var(--flir-surface);
  border-color: var(--flir-fg-muted);
}

/* --- tracklist ----------------------------------------------------------- */

.tracks {
  display: grid;
}

.track {
  display: grid;
  grid-template-columns: 2rem minmax(0, 1fr) auto;
  align-items: baseline;
  gap: var(--flir-space-4);
  padding: var(--flir-space-3) 0;
  border-bottom: var(--flir-rule);
  font-family: var(--flir-font-data);
}

.track__n {
  font-size: var(--flir-text-small);
  color: var(--flir-fg-faint);
  font-variant-numeric: tabular-nums;
}

.track__title {
  font-size: var(--flir-text-body);
  color: var(--flir-fg);
}

/* ---- The homepage play cue ----------------------------------------------
 *
 * One button, one track, on the page everyone lands on. The expanded players
 * live on /music, which is a page people open in order to listen; the homepage
 * is not, and four players there would add 664px on a phone, push the archive
 * and the signup most of a screen down, and hand SoundCloud a cookie for every
 * visitor to the site rather than every visitor to a discography.
 *
 * Measured before this was built: the homepage makes 12 requests, all
 * same-origin, zero third-party. Keeping that true for anyone who does not
 * press play is the whole point of the cue. Check it again before adding a
 * second embed here.
 *
 * Sized and typed to sit beside .btn-ghost as a peer, because the two are the
 * same kind of offer -- hear this now, or go and see the rest. */
.release-brief__actions {
  display: flex;
  flex-wrap: wrap;
  /* stretch, not center: the cue and .btn-ghost carry different padding, and
   * centring left them 6px apart in height -- close enough to look like a
   * mistake rather than a choice. Stretching makes the shorter one match. */
  align-items: stretch;
  gap: var(--flir-space-3);
  margin-top: var(--flir-space-5);
}

.sc-cue {
  display: inline-flex;
  align-items: center;
  gap: var(--flir-space-3);
  padding: var(--flir-space-3) var(--flir-space-4);
  border: var(--flir-rule);
  color: var(--flir-fg);
  text-decoration: none;
  font-family: var(--flir-font-data);
  font-size: var(--flir-text-nano);
  letter-spacing: var(--flir-ls-wider);
  text-transform: uppercase;
  transition: border-color var(--flir-transition), color var(--flir-transition);
}

.sc-cue:hover,
.sc-cue:focus-visible {
  border-color: var(--flir-accent);
  color: var(--flir-accent-text);
}

/* Drawn, not shipped. One triangle is not worth a request. */
.sc-cue__mark {
  width: 0;
  height: 0;
  border-left: 0.5rem solid currentColor;
  border-top: 0.34rem solid transparent;
  border-bottom: 0.34rem solid transparent;
}

/* The per-row cue. Square and small enough to sit inside a tracklist line
 * without turning it into a toolbar -- the row is still a line of type that
 * happens to be playable, not a control strip. */
.sc-cue--row {
  flex: 0 0 auto;
  align-self: center;
  width: 1.5rem;
  height: 1.5rem;
  padding: 0;
  justify-content: center;
  gap: 0;
  margin-right: var(--flir-space-3);
}

/* The row currently playing. Red marks the one thing making noise, which is
 * the job GDL-01 gives it -- and only one row can be playing, because there is
 * only one player and it moves. */
.tracklist__row--playing {
  color: var(--flir-accent-text);
}

.tracklist__row--playing .sc-cue--row {
  border-color: var(--flir-accent);
  color: var(--flir-accent-text);
}

/* The player's own row. A list item, because the tracklist is an <ol> and an
 * iframe dropped straight into one gets hoisted out by the parser. */
.tracklist__player {
  padding: var(--flir-space-3) 0;
  border-bottom: var(--flir-rule);
}

.sc-frame {
  width: 100%;
  display: block;
  border: var(--flir-rule);
  background: var(--flir-bg-sunken);
}

/* ---- SoundCloud players --------------------------------------------------
 *
 * The band's tracks already live on SoundCloud, and so do their play counts
 * and -- more to the point -- listener comments pinned to positions in the
 * waveform. Neither is reproducible on a static site without running a
 * backend, which is the whole argument for embedding rather than self-hosting
 * audio here.
 *
 * The trade is design control. This is someone else's player: their chrome,
 * their type, their layout. The only lever the widget offers is the accent
 * colour, set to the brand red in the embed URL so it does not arrive orange.
 * Everything below is about containing it, not restyling it -- there is no
 * reaching inside a cross-origin iframe.
 *
 * THE PLAYERS SHOW ON LOAD, and that is a deliberate reversal. They were
 * behind a click-to-load button, which kept SoundCloud's cookies away from
 * anyone who had not asked to listen; the band chose the expanded player
 * because it reads better, knowing the cost. Every visitor to this page now
 * loads five third-party documents.
 *
 * loading="lazy" is set and is NOT a defence -- measured, all five fetched on
 * page load with nobody scrolling near them, because Chrome's thresholds are
 * generous. It is kept because it can only help on a slow connection or a
 * short viewport, but nothing here should be read as deferring the third
 * party. If that ever needs to be true, the facade is the way back and the
 * consent question in customHttp.yml is the reason to consider it.
 *
 * The compact player, not visual=true: it already carries the waveform, the
 * comment markers and the play count, which is the whole reason for being on
 * SoundCloud at all. The visual one mostly adds cover art the release header
 * is already showing, at about 400px a track. */
.track__sc {
  grid-column: 1 / -1;
  width: 100%;
  display: block;
  margin-top: var(--flir-space-3);

  /* The widget draws its own rounded corners and there is no reaching into a
   * cross-origin frame to square them (GDL-03). This rule is the
   * reconciliation: the frame is ours and square, the player sits inside it. */
  border: var(--flir-rule);
  background: var(--flir-bg-sunken);
}

.track__note {
  font-size: var(--flir-text-nano);
  letter-spacing: var(--flir-ls-wide);
  text-transform: uppercase;
  color: var(--flir-fg-faint);
  text-align: right;
}

.track__time {
  font-size: var(--flir-text-small);
  color: var(--flir-fg-faint);
  font-variant-numeric: tabular-nums;
  text-align: right;
}

/* --- in progress --------------------------------------------------------- */

.inprogress {
  display: grid;
  gap: var(--flir-space-3);
  padding: var(--flir-space-6);
  border: var(--flir-border-hair) dashed var(--flir-line-strong);
}

.inprogress__label {
  font-family: var(--flir-font-data);
  font-weight: var(--flir-fw-medium);
  font-size: var(--flir-text-nano);
  letter-spacing: var(--flir-ls-wider);
  text-transform: uppercase;
  color: var(--flir-fg-faint);
}

.inprogress p {
  max-width: var(--site-measure);
  font-size: var(--flir-text-small);
  color: var(--flir-fg-muted);
}

/* --- simple data listings ------------------------------------------------ */

.rows {
  display: grid;
}

.row {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: var(--flir-space-5);
  padding: var(--flir-space-3) 0;
  border-bottom: var(--flir-rule);
  font-family: var(--flir-font-data);
}

.row__a {
  font-size: var(--flir-text-body);
  color: var(--flir-fg);
}

.row__b {
  font-size: var(--flir-text-small);
  color: var(--flir-fg-muted);
  font-variant-numeric: tabular-nums;
}

/* The row is the link, and the anchor IS the grid rather than a wrapper around
 * one -- an <a> holding a grid would collapse the columns. align-items is
 * center, not baseline: with an image in the row, baseline pins every span to
 * the cover's bottom edge. No chevron, per the note above. */
.comp {
  border-bottom: var(--flir-rule);
}

.comp__link {
  display: grid;
  grid-template-columns: 7rem 3.5rem minmax(0, 1fr) minmax(0, 1fr) auto;
  align-items: center;
  gap: var(--flir-space-4);
  padding: var(--flir-space-4) 0;
  font-family: var(--flir-font-data);
  color: inherit;
  text-decoration: none;
}

.comp__link:hover .comp__track,
.comp__link:focus-visible .comp__track {
  text-decoration: underline;
  text-underline-offset: 0.2em;
}

/* Both the img and the empty span hold a 7rem square, so the one coverless
 * row keeps its text in line with the other five rather than sliding left. */
.comp__cover {
  width: 7rem;
  height: auto;
  aspect-ratio: 1;
  object-fit: cover;
}

/* Only a real cover gets the sunken fill, and only as a decode placeholder.
 * The empty cell stays transparent: on a near-black page a filled square reads
 * as a black album sleeve, which is worse than showing nothing -- it invents a
 * cover for the one record whose art we could not find. */
img.comp__cover {
  background: var(--flir-bg-sunken);
}

.comp__year {
  font-size: var(--flir-text-small);
  color: var(--flir-fg-faint);
  font-variant-numeric: tabular-nums;
}

.comp__track {
  font-size: var(--flir-text-body);
  color: var(--flir-fg);
}

.comp__album,
.comp__time {
  font-size: var(--flir-text-nano);
  letter-spacing: var(--flir-ls-wide);
  text-transform: uppercase;
  color: var(--flir-fg-muted);
}

/* Who sang. Sits under the track title rather than beside the album, because
 * it is a fact about the performance and not about the record it landed on. */
.comp__vocals {
  grid-column: 3;
  font-size: var(--flir-text-nano);
  letter-spacing: var(--flir-ls-wide);
  text-transform: uppercase;
  color: var(--flir-fg-faint);
}

.comp__time {
  color: var(--flir-fg-faint);
  white-space: nowrap;
  text-align: right;
}

.listing__foot {
  margin-top: var(--flir-space-4);
  font-family: var(--flir-font-data);
  font-size: var(--flir-text-nano);
  letter-spacing: var(--flir-ls-wider);
  text-transform: uppercase;
  color: var(--flir-fg-faint);
}

/* The compilations note is prose, not a data row — sentence case, body face. */
.listing__foot--prose {
  max-width: var(--site-measure);
  font-family: var(--flir-font-body);
  font-size: var(--flir-text-small);
  letter-spacing: var(--flir-ls-none);
  text-transform: none;
  line-height: var(--flir-lh-body);
  color: var(--flir-fg-muted);
}

/* --- callout block -------------------------------------------------------- */

/* Copy plus one action under a heavy red top rule. Whichever page uses it,
   this is that page's one red (GDL-01), so a surface gets exactly one — on
   /music it is the Bandcamp block, on /contact the mailing list. Named for
   its construction rather than for Bandcamp, which was the first thing to
   need it and is no longer the only one. */
.callout {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: space-between;
  gap: var(--flir-space-5);
  padding: var(--flir-space-6);
  border: var(--flir-rule);
  border-top: var(--flir-border-heavy) solid var(--flir-accent);
}

.callout__copy {
  flex: 1 1 24rem;
  max-width: 58ch;
  font-size: var(--flir-text-small);
  color: var(--flir-fg-muted);
}

.callout__cta {
  padding: var(--flir-space-4) var(--flir-space-6);
  font-family: var(--flir-font-data);
  font-weight: var(--flir-fw-medium);
  font-size: var(--flir-text-micro);
  letter-spacing: var(--flir-ls-wider);
  text-transform: uppercase;
  color: var(--flir-fg);
  text-decoration: none;
  border: var(--flir-rule-strong);
  transition: background var(--flir-transition), border-color var(--flir-transition);
}

/* ---- Buying is the loud action ------------------------------------------
 *
 * SoundCloud took over listening, so these three are the only thing on the
 * page asking for a decision, and they were reading as quiet outlines beside
 * five players. They now carry the same solid accent .hero__btn uses -- the
 * site's existing primary action, not a new red invented for them.
 *
 * On GDL-01's one-red-per-surface: red here is not decoration, it means "this
 * is where you buy the record", used once per release plus the section that
 * exists to say so. The players' own red is SoundCloud's transport, inside an
 * iframe, and not ours to spend. */
.rel__link,
.callout__cta {
  background: var(--flir-accent);
  color: var(--flir-accent-fg);
  border: var(--flir-border-mark) solid var(--flir-accent);
}

.rel__link:hover,
.rel__link:focus-visible,
.callout__cta:hover,
.callout__cta:focus-visible {
  background: var(--flir-accent-hover);
  border-color: var(--flir-accent-hover);
  color: var(--flir-accent-fg);
}

.callout__cta:hover,
.callout__cta:focus-visible {
  background: var(--flir-surface);
  border-color: var(--flir-fg-muted);
}

/* ---- Stepping a callout down off the red ---------------------------------
 *
 * GDL-01 spends red on one meaning per surface. /contact used to hold exactly
 * one callout, the mailing list, so its CTA could be red without ambiguity.
 * Booking now sits above it, and booking is what that page exists for -- two
 * red buttons would make red mean "reach the band" and "join the list" at the
 * same time, which is the thing the rule exists to prevent.
 *
 * So the frame stays and the fill goes. Must follow the .rel__link grouping
 * above: same specificity, later wins.
 *
 * The border is --flir-fg-muted, not --flir-line-strong. Quiet is not the same
 * as gone: line-strong against the ink background measures 1.88:1, under the
 * 3:1 WCAG 1.4.11 wants for a control's visible boundary, and it reads as
 * floating text rather than a button. fg-muted measures 5.73:1 and is already
 * what .callout__cta:hover borrows, which leaves --flir-fg free as the hover
 * step below. */
.callout__cta--quiet {
  background: transparent;
  color: var(--flir-fg);
  border: var(--flir-border-hair) solid var(--flir-fg-muted);
}

.callout__cta--quiet:hover,
.callout__cta--quiet:focus-visible {
  background: transparent;
  border-color: var(--flir-fg);
  color: var(--flir-fg);
}

/* --- personnel — /band --------------------------------------------------- */

/* A heavy red rule, used once per surface and only where the page's weight
   actually sits (GDL-01). Same construction as the kit's hero-rule. */
.section__rule {
  height: var(--flir-border-heavy);
  margin: calc(var(--flir-space-5) * -1) 0 var(--flir-space-5);
  background: var(--flir-accent);
}

/* Two grids under two headings. The heading carries the members/collaborators
   distinction, which is why no card labels anyone "former" or "hired" — the
   same rule the kit's HomeScreen holds to, and the reason data.js tracks
   'member', 'credited' and 'tenure' as three separate facts rather than
   collapsing them into one status.

   Cards stop at 9rem. The narrowest supplied portrait is 213px on its short
   edge, and a tighter grid would be asking for pixels that do not exist. */
.people {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(9rem, 1fr));
  gap: var(--flir-space-6) var(--flir-space-4);
}

.person {
  display: grid;
  gap: var(--flir-space-2);
  align-content: start;
  padding-top: var(--flir-space-4);
  border-top: var(--flir-rule);
}

/* The only difference between the two grids is the weight of this rule. */
.people--members .person {
  border-top: var(--flir-rule-strong);
}

.person__frame {
  position: relative;
  aspect-ratio: 1;
  overflow: hidden;
  margin-bottom: var(--flir-space-1);
  border: var(--flir-rule);
  background: var(--flir-bg-sunken);
}

.person__photo,
.photo img {
  width: 100%;
  height: 100%;
  display: block;
  object-fit: cover;
  filter: var(--flir-photo-filter-rest);
}

/* Jessica Isaac and Luis Hermosillo are shot under red stage washes that the
   Corbijn pass — grayscale first — crushes to solid black. These two keep
   their colour. data.js records the exception against each of them.

   -reveal is -native's interpolable twin, and it is also what the reveal below
   sets on hover. So these two are already at the revealed state at rest: the
   colour change is a natural no-op on them and only the zoom reads. That falls
   out of the token choice rather than needing a selector to carve them out. */
.person__photo--native {
  filter: var(--flir-photo-filter-reveal);
}

/* Grain is a material here, not a texture hint (GDL-07). Decorative, so it is
   a pseudo-element and never lands in the accessibility tree.

   `background`, not `background-image`: --flir-grain-hard is a full background
   shorthand — it carries its own position and 2px tile size ("... 0 0 / 2px
   2px"). Handed to background-image the declaration is invalid and the browser
   drops it silently, which is exactly how the kit's own grain goes missing. */
.person__frame::after,
.photo::after {
  content: "";
  position: absolute;
  inset: 0;
  background: var(--flir-grain-hard);
  pointer-events: none;
}

.person__name {
  font-family: var(--flir-font-headline);
  font-variation-settings: 'wdth' var(--flir-wdth-wide), 'wght' var(--flir-fw-bold);
  font-size: var(--flir-text-lead);
  line-height: var(--flir-lh-tight);
}

.person__role {
  font-family: var(--flir-font-data);
  font-size: var(--flir-text-nano);
  letter-spacing: var(--flir-ls-wide);
  text-transform: uppercase;
  color: var(--flir-fg-muted);
}

/* Tenure and sleeve credit. Both are data about a person, not description of
   them, so both take the instrument face at the smallest size on the page. */
.person__note {
  font-family: var(--flir-font-data);
  font-size: var(--flir-text-nano);
  line-height: var(--flir-lh-body);
  letter-spacing: var(--flir-ls-wide);
  text-transform: uppercase;
  color: var(--flir-fg-faint);
}

/* --- photograph archive -------------------------------------------------- */

/* 3:4. Five of the six scans are portrait; the kit's 4:3 cell was chosen when
   the first image was carrying a hero and only landscape crops reached the
   grid. Cropping portraits to 4:3 here would throw away most of each frame. */
.photo-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(10rem, 1fr));
  gap: var(--flir-space-4);
}

.photo {
  position: relative;
  aspect-ratio: 3 / 4;
  overflow: hidden;
  border: var(--flir-rule);
  background: var(--flir-bg-sunken);
}

/* --- dates — /live ------------------------------------------------------- */

/* /live spends its one red here (GDL-01). The page is mostly archive, and the
   single most important thing on it is that nothing is currently booked — so
   the status label takes the heat and the past-show rows stay grey.
   --flir-accent-text, not --flir-accent: this is type, and the text variant is
   the one carrying a contrast ratio. */
.inprogress--flagged .inprogress__label {
  color: var(--flir-accent-text);
}

.show {
  display: grid;
  grid-template-columns: 5.5rem minmax(0, 1fr) auto;
  align-items: baseline;
  gap: var(--flir-space-4);
  padding: var(--flir-space-4) 0;
  border-bottom: var(--flir-rule);
  font-family: var(--flir-font-data);
}

.show__date {
  font-size: var(--flir-text-body);
  color: var(--flir-fg);
  font-variant-numeric: tabular-nums;
}

.show__city {
  font-size: var(--flir-text-nano);
  letter-spacing: var(--flir-ls-wide);
  text-transform: uppercase;
  color: var(--flir-fg-muted);
}

/* Bills and door detail sit on their own lines in the middle column rather
 * than earning columns that would be empty on most rows. Both are quiet: the
 * date and the venue are what the eye should catch scanning the list, and
 * these are what it finds on the row it stops at. */
.show__with {
  grid-column: 2;
  font-size: var(--flir-text-nano);
  letter-spacing: var(--flir-ls-wide);
  text-transform: uppercase;
  color: var(--flir-fg-faint);
}

/* ---- Bands on the bill, where they still have a home ---------------------
 *
 * Bandcamp for most, a label or artist site where there is one, Discogs for
 * the acts that predate all of it. Not every name is linked -- some of these
 * bands left nothing behind -- so the link has to be visible as a link.
 *
 * It carries a resting underline rather than relying on being brighter than
 * the row text. --flir-fg-muted against the row's --flir-fg-faint measures
 * 2.06:1, under the 3:1 WCAG 1.4.1 wants before colour alone may distinguish
 * a link, and on a line already set in nano uppercase the difference is easy
 * to miss besides.
 *
 * The underline is currentColor, not --flir-line-strong: that token measures
 * 1.88:1 against the ink background, which is the same value rejected for
 * .callout__cta--quiet on /contact. An affordance nobody can see is not one. */
.show__with a {
  color: var(--flir-fg-muted);
  text-decoration: none;
  border-bottom: var(--flir-border-hair) solid currentColor;
  transition: color var(--flir-transition), border-color var(--flir-transition);
}

.show__with a:hover,
.show__with a:focus-visible {
  color: var(--flir-fg);
  border-bottom-color: var(--flir-fg);
}

/* Not uppercased. This is the door talking -- prices, ages, set times, the
 * night's own name for itself -- and it is quoted from whatever listing still
 * carries it, so it keeps sentence case and reads as a record rather than as
 * another data label. */
.show__detail {
  grid-column: 2;
  font-family: var(--flir-font-body);
  font-size: var(--flir-text-nano);
  line-height: var(--flir-lh-body);
  color: var(--flir-fg-faint);
}

/* Only 27.10.06 has a scan, so only 27.10.06 gets a badge. The other thirty-four
   carry nothing rather than an empty slot dressed up as one. */
.show__flyer {
  justify-self: end;
  padding: var(--flir-space-1) var(--flir-space-2);
  font-size: var(--flir-text-nano);
  letter-spacing: var(--flir-ls-wide);
  text-transform: uppercase;
  color: var(--flir-fg-faint);
  border: var(--flir-rule);
  text-decoration: none;
  white-space: nowrap;
}

.show__flyer:hover,
.show__flyer:focus-visible {
  color: var(--flir-fg);
  border-color: var(--flir-fg-muted);
}

.flyer {
  display: grid;
  gap: var(--flir-space-3);
  margin-top: var(--flir-space-6);
}

.flyer img {
  width: 100%;
  max-width: 28rem;
  height: auto;
  display: block;
  border: var(--flir-rule);
  filter: var(--flir-photo-filter-rest);
}

.flyer figcaption {
  font-family: var(--flir-font-data);
  font-size: var(--flir-text-nano);
  letter-spacing: var(--flir-ls-wider);
  text-transform: uppercase;
  color: var(--flir-fg-faint);
}

/* Below the breakpoint the cover stops earning a column of its own. */
@media (max-width: 40rem) {
  .rel {
    grid-template-columns: minmax(0, 1fr);
    gap: var(--flir-space-5);
  }

  .rel__art {
    max-width: 15rem;
  }

  /* The cover takes the leading slot the year used to hold, so the year joins
     the stacked text column with everything else. */
  .comp__link {
    grid-template-columns: 7rem minmax(0, 1fr);
    row-gap: var(--flir-space-1);
  }

  .comp__year,
  .comp__album,
  .comp__time {
    grid-column: 2;
    text-align: left;
  }

  .comp__cover {
    grid-row: 1 / span 4;
    align-self: start;
  }

  .track {
    grid-template-columns: 2rem minmax(0, 1fr);
    row-gap: var(--flir-space-1);
  }

  .track__note,
  .track__time {
    grid-column: 2;
    text-align: left;
  }
}

/* --- footer -------------------------------------------------------------- */

/* Credit line: once per surface, smallest type on it (GDL-04). */
.footer {
  font-family: var(--flir-font-data);
  font-size: var(--flir-text-nano);
  letter-spacing: var(--flir-ls-wider);
  text-transform: uppercase;
  color: var(--flir-grey-50);
  text-align: center;
}

.footer a {
  color: inherit;
}

@media (prefers-reduced-motion: reduce) {
  *,
  *::before,
  *::after {
    transition-duration: 0.01ms !important;
    animation-duration: 0.01ms !important;
  }

  /* The rule above does nothing to the motion section at the end of this
   * file, and that is not obvious. A scroll-driven animation takes its
   * progress from the scroll timeline and ignores animation-duration
   * entirely, so clamping duration leaves every parallax effect running at
   * full strength. The ambient drift at body::before is infinite and equally
   * unaffected.
   *
   * Killing the animation outright is the only full stop. Keep this list in
   * sync with the motion section -- a selector that moves and is not named
   * here is an accessibility bug that looks handled.
   *
   * This list also stops the colour reveal on devices without a pointer, and
   * that is accepted rather than overlooked. The thaw's only vehicle there is
   * a scroll-driven animation, so killing the animation kills the colour with
   * it; a reader on a phone with reduced motion set sees the grey site. On a
   * pointer device the hover reveal survives -- it is a transition, not an
   * animation, and only its duration is clamped, so the colour still arrives,
   * instantly. The zoom that accompanies it is declared under a
   * no-preference query and never reaches here at all. */
  body::before,
  .hero__photo,
  .hero__layer,
  .hero__pad,
  .archive-grid__cell,
  .archive-grid__cell img,
  .photo,
  .photo img,
  .person__photo,
  .flyer img,
  .rel__cover,
  .comp__cover,
  .site-head {
    animation: none !important;
  }
}

/* One collaborator links out. Inherits the name's type so the card does not
 * grow a second visual weight; the underline on hover is the whole signal. */
.person__link {
  color: inherit;
  text-decoration: none;
}

.person__link:hover,
.person__link:focus-visible {
  text-decoration: underline;
  text-underline-offset: 0.2em;
}

/* --- motion -------------------------------------------------------------
 *
 * ClickUp 86bbav09u. Spec: docs/superpowers/specs/2026-08-08-parallax-motion-design.md
 *
 * Every effect here is CSS-only and scroll-driven, with one deliberate
 * exception noted at body::before. There is no JavaScript on this site and
 * this section is not the place to introduce it: script-src is 'self', a
 * scroll listener would be the only jank risk in the file, and CSS expresses
 * all of this without one.
 *
 * Firefox keeps scroll-driven animations behind
 * layout.css.scroll-driven-animations.enabled in stable, so Firefox readers
 * get the static site. That is the intended degradation, not a gap to close.
 *
 * TWO RULES FOR ANYTHING ADDED BELOW:
 *
 * 1. Animate transform, never background-position. The grain and scanline
 *    layers are full-bleed; moving their background repaints the whole
 *    element every frame, where a transform stays on the compositor. This is
 *    also why nothing here can shift layout -- transforms do not participate
 *    in it at all.
 *
 * 2. Anything that moves must be oversized by its own travel first. Every
 *    animated layer on this site sits at inset: 0 inside overflow: hidden, so
 *    an un-oversized translate drags the background in at the trailing edge.
 *
 * Timing comes from the system (tokens.css:460). Scroll-driven animations run
 * linear on purpose: easing a scroll-linked animation makes it lag the finger.
 *
 * 3. The animation shorthand RESETS animation-timeline and animation-range to
 *    their initial values, so the shorthand must stay above them in every rule
 *    below. Reordering those lines silently turns the effect off -- it does not
 *    error, it just quietly becomes a static site again.
 *
 * Keyframes defined here are named site-* rather than flir-*. They are local to
 * this file; nothing in the token layer defines or references them, and the
 * flir- prefix would imply a design-system origin they do not have.
 *
 * will-change is deliberately absent. Chrome already promotes an element it
 * is animating by transform, so declaring it buys nothing and would pin four
 * full-bleed compositor layers for the life of the page.
 */

@keyframes site-drift-y {
  to { transform: translateY(var(--site-drift-y, 0)); }
}

@keyframes site-rise {
  from { transform: translateY(var(--site-rise-y, 0)); }
  to   { transform: none; }
}

/* Hero. The photograph lags the frame that clips it -- that is the whole
 * parallax, and the other three layers are seasoning on it.
 *
 * Progress is tied to the hero's own passage through the viewport, not to the
 * document. The hero sits at the top of the page and so is already partway
 * through its range at load, which is what stops it jumping on first paint.
 *
 * THE TIMELINE IS NAMED, AND IT HAS TO BE. The obvious spelling --
 * animation-timeline: view() on each layer -- silently does nothing here. view()
 * resolves against the subject's nearest scroll container, .hero declares
 * overflow: hidden, and an overflow: hidden box IS a scroll container. So each
 * layer measured its progress against a box that never scrolls and froze at a
 * constant offset: still visibly displaced, never moving. It reads as a working
 * parallax in a screenshot and is not one.
 *
 * Naming the timeline on .hero fixes it because .hero's own nearest scrollport
 * is the document. Do not "simplify" this back to view().
 *
 * THE GRAIN AND SCANLINE DISTANCES ARE NOT ADJUSTABLE. They are exactly one
 * tile period each -- --flir-grain-hard is a conic gradient sized 2px 2px
 * (tokens.css:117) and --flir-scanline repeats every 4px (tokens.css:70).
 * Travelling one whole period puts the pattern back where it started, so the
 * endpoints are seamless and it reads as continuous crawl. Round these up and
 * the layer visibly stops at the end of its range; move them by a multiple of
 * the period and the motion becomes invisible. */
.hero {
  view-timeline: --site-hero block;
}

/* THE RANGE IS exit, NOT cover, AND THAT IS HALF THE EFFECT.
 *
 * cover counts from before the subject enters the viewport to after it has
 * fully left. The hero sits at the top of the document, so it is already about
 * halfway through a cover range at load -- meaning only the back half of the
 * travel ever plays where a reader can see it, and the first thing they see is
 * the layers already offset. The visible effect was half the declared
 * amplitude, which is most of why the first version read as nothing happening.
 *
 * exit runs from the moment the hero begins to leave to the moment it is gone:
 * progress 0 at rest on load, full amplitude spent across the departure. */
.hero__photo,
.hero__layer--crush,
.hero__layer--grain,
.hero__layer--scan,
.hero__pad {
  animation: site-drift-y var(--flir-ease-linear) both;
  animation-timeline: --site-hero;
  animation-range: exit;
}

/* The type is part of the parallax, not a static overlay on it. It rises while
 * the photograph drifts down, so the two separate rather than travelling
 * together -- that opposition is what reads as depth, and moving the field
 * alone never will however far it moves.
 *
 * Negative, so it shares the site-drift-y keyframe with everything else. The
 * top label clips against .hero's overflow as it goes, which is fine: by the
 * time it does, the top of the hero is off-screen anyway. */
.hero__pad { --site-drift-y: calc(var(--site-hero-lift) * -1); }

/* The photograph's travel is declared on .hero, because the layers' slack is
 * derived from it -- see the note there. Changing it here is safe; changing it
 * to a literal is not. */
.hero__photo        { --site-drift-y: var(--site-hero-drift); }
/* Half the photograph's travel, derived so the two stay in proportion. Shifts
 * where --flir-crush's dark band falls across the photograph; reads as the
 * light changing, not as a slide. */
.hero__layer--crush { --site-drift-y: calc(var(--site-hero-drift) * 0.5); }
.hero__layer--grain { --site-drift-y: 2px; }  /* one tile period -- see above */
.hero__layer--scan  { --site-drift-y: 4px; }  /* one tile period -- see above */

/* The one effect here that is not scroll-driven, on purpose. A CRT drifts on
 * its own; it does not wait for the reader to scroll. Tying this to scroll
 * would make the instrument look like it powers down when the page is still,
 * which is exactly backwards.
 *
 * Two consequences, both accepted: this is the only motion Firefox shows, and
 * it is the only thing running while the page sits idle. 4px is one scanline
 * period (tokens.css:70), so the loop is seamless -- see the hero block above
 * for why that matters. Transform, not background-position: this element is
 * fixed and full-bleed, and repainting it every frame for four pixels of
 * travel would be an absurd trade. */
@keyframes site-scan-drift {
  from { transform: translateY(0); }
  to   { transform: translateY(var(--site-scan-drift)); }
}

body::before {
  /* No token covers this. The system's motion scale tops out at
   * --flir-motion-slow: 400ms (tokens.css:464), which is right for a state
   * change and an order of magnitude too fast for an ambient drift. Named
   * locally rather than left as a bare 4s, per this file's --site-* rule. */
  --site-scan-drift-duration: 4s;

  animation: site-scan-drift var(--site-scan-drift-duration)
             var(--flir-ease-linear) infinite;
}

/* Content rises as it enters view. Ten pixels, settled well before the element
 * reaches the middle of the screen -- the archive has to stay readable while it
 * is arriving, so nothing here fades and nothing arrives late.
 *
 * view() is correct on these and wrong on the hero layers, which is worth being
 * precise about. view() resolves against the subject's nearest scroll container
 * ANCESTOR. .archive-grid__cell and .photo declare overflow: hidden themselves,
 * but an element's own overflow does not trap its own view timeline -- only its
 * descendants'. The hero layers were descendants of an overflow: hidden box;
 * these are the boxes.
 *
 * The stagger is positional rather than per-element because style-src is 'self':
 * the usual approach sets a custom property in an inline style attribute on each
 * cell, and this site cannot. nth-child does the same job with no markup change.
 *
 * It is a stagger, not a column march, and 3n matches the column count almost
 * nowhere: .archive-grid is three columns only above 45rem and drops to two
 * below it, and .photo-grid on /band is auto-fill so its count moves with the
 * viewport. Breaking the uniformity is the whole intent -- do not "fix" this
 * later by trying to match column counts.
 *
 * The two /music covers carry no stagger on purpose: two release covers and a
 * six-row listing do not read as a grid, and offsetting them would look like a
 * fault rather than a rhythm.
 *
 * EVERY RANGE ENDS INSIDE THE entry PHASE, and that is a correctness
 * requirement rather than a taste one. A range ending in cover -- the obvious
 * spelling, and what this was first written as -- cannot complete for elements
 * near the end of the document: the page runs out of scroll before the element
 * reaches that much of its cover range, so it is left permanently displaced by
 * a fraction of a pixel. Measured, the last archive cells and /band
 * photographs sat between 0.16px and 1.8px out at full scroll and stayed
 * there.
 *
 * entry 100% is reached the moment the element is fully in view. THAT IS ONLY
 * GUARANTEED FOR ELEMENTS NO TALLER THAN THE VIEWPORT -- for those, every
 * entry percentage is reachable wherever the element sits in the document. A
 * subject taller than the viewport still needs a full viewport of content
 * below its top edge, and near the end of a document that reintroduces exactly
 * the stuck-fraction-of-a-pixel bug above.
 *
 * Nothing here comes close: the covers cap at 15rem and 7rem, archive cells run
 * ~82px at 375 and ~360px at 1280, and /band photographs peak near 384px on a
 * narrow screen. If a taller subject is ever added to this list, check it. */
.archive-grid__cell,
.photo,
.rel__cover,
.comp__cover {
  --site-rise-y: 10px;
  animation: site-rise var(--flir-ease-linear) both;
  animation-timeline: view();
  animation-range: entry 0% entry 80%;
}

.archive-grid__cell:nth-child(3n + 2),
.photo:nth-child(3n + 2) {
  animation-range: entry 0% entry 90%;
}

.archive-grid__cell:nth-child(3n + 3),
.photo:nth-child(3n + 3) {
  animation-range: entry 0% entry 100%;
}

/* The hero's mechanic at card scale: each photograph drifts inside the frame
 * that clips it as the card passes through view. Same idea, two orders of
 * magnitude quieter -- 16px, so it reads as the photograph breathing against
 * its border rather than as the card coming apart.
 *
 * The timeline is named for the same reason it is named on the hero, and this
 * is the exact case that caught us there: every one of these frames declares
 * overflow: hidden, which makes it a scroll container, so view() on the image
 * inside would resolve against a box that never scrolls and freeze. The name
 * is declared on the frame, whose own scrollport is the document. Reusing one
 * name across every frame is correct -- lookup is nearest-ancestor, so each
 * image finds its own card.
 *
 * cover rather than exit, unlike the hero: these cards sit mid-document and
 * genuinely enter and leave, so cover sweeps the whole passage. The hero
 * needed exit only because it starts life already on screen.
 *
 * The images become absolutely positioned here. That is safe because every one
 * of these frames sizes itself from an aspect-ratio rather than from its
 * image, so taking the image out of flow cannot shift layout -- verified by
 * measuring the frames before and after.
 *
 * Album covers are deliberately excluded. Cropping a documentary photograph is
 * free; cropping album art is destroying someone's composition, and at 7rem
 * .comp__cover has no room to give. They keep the rise and nothing else. */
.archive-grid__cell,
.photo,
.person__frame {
  view-timeline: --site-frame block;
}

.archive-grid__cell img,
.photo img,
.person__photo {
  --site-frame-slack: 20px;
  --site-drift-y: 16px;

  position: absolute;
  inset: calc(var(--site-frame-slack) * -1) 0;
  width: 100%;
  height: calc(100% + var(--site-frame-slack) * 2);

  animation: site-drift-y var(--flir-ease-linear) both;
  animation-timeline: --site-frame;
  animation-range: cover;
}

/* ---- Reveal -------------------------------------------------------------
 *
 * Grey is the resting state and stays the site's voice. Colour is something
 * the reader causes: point at a photograph and it thaws to its native grade
 * and pushes in slightly. Where there is no pointer, scroll position stands in
 * for one.
 *
 * WHY THE FILTER TOKENS ARE -rest/-reveal AND NOT -corbijn/-native. A filter
 * transition interpolates only when both values carry the same functions in
 * the same order. -corbijn leads with grayscale, -native with contrast, so
 * animating between those two does not fade -- it lands on the end value on
 * frame one. Measured, not assumed. -rest and -reveal are the same two
 * renderings written with a shared function list; saturate(1) and grayscale(0)
 * are identity operations and change nothing visually.
 *
 * THE ZOOM IS `scale`, NOT `transform: scale()`. These images carry the
 * running drift animation above, and a running animation beats a plain
 * declaration in the cascade -- a transform here would be silently discarded.
 * The independent scale property composes with the animation instead.
 *
 * Scaling cannot expose a frame edge. The image is already oversized by
 * --site-frame-slack for the drift, and scaling above 1 only grows that
 * margin.
 *
 * The hero sits this out. It is full-bleed, so "hover" there means the pointer
 * is anywhere in the top viewport -- it would fire constantly, and a
 * page-sized colour swap is not a pointed gesture. Album and compilation
 * covers sit it out too: they never carried a photo filter, and cropping or
 * recolouring someone's cover art is not ours to do.
 *
 * Keyboard is deliberately not wired up. Every participating image is alt=""
 * and none sits inside a link, so making the frames focusable would add tab
 * stops that announce nothing and do nothing, for a purely visual change. */

@keyframes site-thaw {
  0%, 100% { filter: var(--flir-photo-filter-rest); }
  50%      { filter: var(--flir-photo-filter-reveal); }
}

/* Pointer. Hover the FRAME, not the image: the archive cells carry a grain
 * overlay above the photograph, and the images are absolutely positioned. The
 * frame is the only target that matches what the reader thinks they are
 * pointing at. */
@media (hover: hover) {
  .archive-grid__cell img,
  .photo img,
  .person__photo {
    transition: filter var(--flir-transition), scale var(--flir-transition);
  }

  .flyer img,
  .fans img {
    transition: filter var(--flir-transition);
  }

  /* .fans img:hover, not a frame:hover like the others -- the fan photographs
   * have no wrapper and no overlay above them, so the image is exactly what
   * the reader is pointing at. Filter only, no zoom: the 1.06 scale was
   * measured on 220px archive cells, and these sit at ~166px in a tight grid
   * where growth would collide with the neighbour. */
  .archive-grid__cell:hover img,
  .photo:hover img,
  .person__frame:hover .person__photo,
  .flyer:hover img,
  .fans img:hover {
    filter: var(--flir-photo-filter-reveal);
  }

  /* The zoom is motion and stops for reduced motion; the colour above is not,
   * and stays. Scoped by a no-preference query rather than undone later with
   * !important, so there is only one place the zoom is declared. */
  @media (prefers-reduced-motion: no-preference) {
    .archive-grid__cell:hover img,
    .photo:hover img,
    .person__frame:hover .person__photo {
      /* ~13px of growth on a 220px cell, ~6.6px per edge. Measured at both
       * breakpoints; see the reveal spec. */
      scale: 1.06;
    }
  }
}

/* No pointer. Scroll position stands in for the reader's attention: the
 * photograph thaws as it crosses the middle of the viewport and re-freezes as
 * it leaves, on the same --site-frame timeline the drift already uses.
 *
 * BOTH ANIMATIONS GO IN ONE SHORTHAND LIST. Two `animation` declarations would
 * overwrite rather than stack, and the drift would lose. The shorthand also
 * resets animation-timeline and animation-range, so it must stay above them
 * exactly as in the rule above.
 *
 * `cover` rather than `contain` for the thaw: contain collapses to an empty
 * range on any subject taller than the scrollport, which the flyer scan can be
 * on a narrow screen. cover is well defined at every size and puts 50% -- full
 * colour -- at the element's passage through the middle of the viewport.
 *
 * :not(--native) matters. The two red-wash portraits rest at -reveal, so the
 * thaw's 0% keyframe would drag them TO grey at the frame edges -- the exact
 * thing their exception exists to prevent. Excluded here, they fall through to
 * the drift rule above and keep colour throughout. */
@media (hover: none) {
  .archive-grid__cell img,
  .photo img,
  .person__photo:not(.person__photo--native) {
    animation: site-drift-y var(--flir-ease-linear) both,
               site-thaw var(--flir-ease-linear) both;
    animation-timeline: --site-frame, --site-frame;
    animation-range: cover, cover;
  }

  /* The flyer has no drift to compose with -- it is in flow with height: auto
   * and nothing clips it -- so it needs its own timeline to thaw against.
   * Without this the one photograph on /live would be the only surface the
   * reveal never reaches on a phone. */
  .flyer {
    view-timeline: --site-frame block;
  }

  .flyer img {
    animation: site-thaw var(--flir-ease-linear) both;
    animation-timeline: --site-frame;
    animation-range: cover;
  }

  /* Same reasoning as the flyer, and the same fix: the fan photographs are in
   * flow, nothing clips them, and they have no drift to compose with, so each
   * one carries its own timeline. Without this the shop's wall of photographs
   * would be the one surface on the site the reveal never reaches on a phone
   * -- which is the surface it matters most on, since a phone is where anyone
   * scrolling merch actually is. */
  .fans img {
    view-timeline: --site-frame block;
    animation: site-thaw var(--flir-ease-linear) both;
    animation-timeline: --site-frame;
    animation-range: cover;
  }
}

/* Keyboard gets the reveal too, now that the frames are links and therefore
 * focusable. Deliberately OUTSIDE the hover query above: focus is not a
 * pointer capability, and a keyboard on a touch device would otherwise be
 * excluded from an effect it can perfectly well trigger. */
.archive-grid__cell:focus-visible img,
.photo:focus-visible img,
.person__frame:focus-visible .person__photo {
  filter: var(--flir-photo-filter-reveal);
}

@media (prefers-reduced-motion: no-preference) {
  .archive-grid__cell:focus-visible img,
  .photo:focus-visible img,
  .person__frame:focus-visible .person__photo {
    scale: 1.06;
  }
}

/* ---- Lightbox -----------------------------------------------------------
 *
 * The grid crops hard. A 613x900 portrait in a 4:3 cell shows about half the
 * photograph; the square personnel frames are no kinder. Clicking one opens it
 * whole, at its own aspect ratio, uncropped.
 *
 * :target and nothing else. The site ships no JavaScript, so the open state
 * has to live somewhere the browser already owns -- and the URL turns out to
 * be the better place anyway: the back button closes it, the view is
 * linkable, and there is no state that can drift out of sync with the DOM.
 *
 * THE OVERLAY IS ITSELF THE CLOSE LINK, so a click anywhere dismisses it. A
 * bare :target lightbox traps the reader with no way out but the back button,
 * which is the usual reason this technique gets called unusable.
 *
 * Each close link points at the id of the FRAME IT CAME FROM, not at the top
 * of the page. Dismissing returns the reader to the photograph they opened
 * rather than to the masthead, which matters most on /band where the
 * personnel grid is a long way down.
 *
 * Escape does not close it. That needs a key listener, and there is no script
 * to hang one on; back and click both work. Noted rather than hidden.
 *
 * z-index 20 sits above .site-head, which is position: sticky at z-index 10.
 * A tie would resolve on document order and happen to work, since the
 * overlays sit at the end of main -- but "happens to work" is how the header
 * ends up painted across a photograph the first time someone moves a block. */

.lightbox {
  display: none;
}

.lightbox:target {
  display: grid;
  place-items: center;
  position: fixed;
  inset: 0;
  z-index: 20;
  padding: var(--flir-space-5);
  background: var(--flir-overlay);
}

/* Sized against the viewport, NOT with max-width/height: 100%. The percentage
 * looks like it should work and does not: it resolves against an auto-sized
 * grid area, which is an indefinite height, so the max-height is ignored and a
 * tall photograph overflows the screen. Measured -- a 613x900 portrait
 * rendered 902px tall in a 900px viewport before this changed. */
.lightbox img {
  max-width: calc(100vw - var(--flir-space-5) * 2);
  max-height: calc(100dvh - var(--flir-space-5) * 2);
  width: auto;
  height: auto;
  display: block;
  border: var(--flir-rule);
  /* Full colour, and no crop. Opening a photograph is a request to see what
   * was actually shot -- answering it with the Corbijn pass would be answering
   * a different question. */
  filter: var(--flir-photo-filter-reveal);
}

/* The dismiss affordance. Decorative and aria-hidden: the overlay's own link
 * text already names the action for anyone not looking at it.
 *
 * Opaque, not floating text. The backdrop is 86% rather than solid, so the
 * header stays legible underneath it -- and this label lands exactly on the
 * Bandcamp link in the top right, where the two read as one broken word.
 * Measured, after a screenshot showed "BANDCAMCLOSE". A solid chip covers what
 * it sits on and looks like the button it is standing in for. */
.lightbox__hint {
  position: absolute;
  top: var(--flir-space-5);
  right: var(--flir-space-5);
  padding: var(--flir-space-2) var(--flir-space-3);
  background: var(--flir-bg);
  border: var(--flir-rule);
  font-family: var(--flir-font-data);
  font-size: var(--flir-text-nano);
  letter-spacing: var(--flir-ls-wider);
  text-transform: uppercase;
  color: var(--flir-fg);
}

/* The three frames are anchors now. An anchor is inline by default, which
 * would collapse the aspect-ratio boxes every one of these relies on -- and it
 * would do it silently, taking the crop, the drift and the reveal with it. */
.archive-grid__cell,
.photo,
.person__frame,
.flyer__link {
  display: block;

  /* Closing the lightbox navigates back to the frame, and :target alignment
   * puts it flush against the top of the viewport -- which is underneath the
   * sticky header. Enough margin to clear it, so the photograph the reader
   * just closed is the first thing they see rather than the thing the header
   * is sitting on. */
  scroll-margin-top: var(--flir-space-9);
}

/* The header earns its rule by scrolling. At the top of the page there is
 * nothing above the header for a rule to separate it from; once the page has
 * moved under it, there is.
 *
 * Border colour only. The obvious companion move -- fading the background in as
 * well -- is a no-op on this site: .site-head sits above the hero rather than
 * over it (see its margin-top), so a transparent header reveals body
 * background, which is the same --flir-bg the header already paints. It would
 * cost a repaint to change nothing.
 *
 * The unconditional border-bottom further up this file is deliberately left in
 * place. It is what browsers without scroll timelines keep, and it is the state
 * this animation resolves to, so nothing regresses when the animation is absent
 * or stopped for reduced motion.
 *
 * scroll(root) rather than view(): the header is sticky and never leaves the
 * viewport, so it has no view progress to animate against. 120px is roughly the
 * header's own height again -- far enough that the rule does not flicker on a
 * one-notch scroll. */
@keyframes site-head-rule {
  from { border-bottom-color: transparent; }
  to   { border-bottom-color: var(--flir-line); }
}

.site-head {
  /* Roughly the header's own height again -- far enough that the rule does not
   * flicker on a one-notch scroll. Named rather than left as a bare 120px,
   * since it is the one tunable in this effect. */
  --site-head-rule-range: 120px;

  animation: site-head-rule var(--flir-ease-linear) both;
  animation-timeline: scroll(root);
  animation-range: 0 var(--site-head-rule-range);
}
