/* ==========================================================================
   METAL — theme engine (public site)

   Ported from docs/design/theme.css. The ~11-line :root block is rendered
   per band by Themes::CssVariables; everything below is static and cached
   forever. Do not add per-band values here.
   --------------------------------------------------------------------------
   Everything a band can change lives in the --ui-* block below. Every other
   token in this file is DERIVED from those, so a theme record in the CMS is
   ~10 columns, not a stylesheet. Swap --ui-ground/--ui-ink and the entire
   page re-tones, including light-on-dark inversions.

   CMS contract (one row in `themes`, belongs_to :band):
     accent, ground, ink, font_display, font_body, font_mono,
     density, grain, texture, header_mark
   ========================================================================== */

:root {
  /* ------------------------------------------------ user-editable (the CMS) */
  --ui-accent:  #9c5f3c;
  --ui-ground:  #080807;
  --ui-ink:     #efeae2;
  --ui-display: 'Oswald', 'Helvetica Neue', Arial, sans-serif;
  --ui-body:    'Cormorant Garamond', Georgia, 'Times New Roman', serif;
  --ui-mono:    'IBM Plex Mono', ui-monospace, SFMono-Regular, Menlo, monospace;
  --ui-grain:   .085;   /* 0 – .2   film grain opacity                        */
  --ui-texture: 1;      /* 0 | 1    haze blooms + scanlines                    */
  --ui-pad:     116px;  /* vertical rhythm, set by density                     */
  --ui-tracking: 1;     /* .5 – 1.4 multiplier on all uppercase letterspacing  */
  --ui-optical: 1;      /* per-family x-height compensation, set by the type set */

  /* ------------------------------------------------ derived: ground scale   */
  --ink-void:     var(--ui-ground);
  --ink-pit:      color-mix(in srgb, var(--ui-ground) 97%, var(--ui-ink));
  --ink-hairline: color-mix(in srgb, var(--ui-ground) 93%, var(--ui-ink));
  --ink-rule:     color-mix(in srgb, var(--ui-ground) 91%, var(--ui-ink));
  --ink-edge:     color-mix(in srgb, var(--ui-ground) 88%, var(--ui-ink));
  --ink-edge-lit: color-mix(in srgb, var(--ui-ground) 85%, var(--ui-ink));
  --ink-edge-hot: color-mix(in srgb, var(--ui-ground) 72%, var(--ui-ink));

  /* ------------------------------------------------ derived: ink scale      */
  /* FIVE RUNGS, AND THE BOTTOM ONE IS LEGIBLE. There were eight: --text-dim
     (40%), --text-faint (32%) and --text-ghost (26%) measured 3.31:1, 2.50:1
     and 2.02:1 on the default palette, and every element that carried words on
     those three tiers has been moved off them. Norway's forskrift om universell
     utforming av IKT binds this page to WCAG 2.0 AA with no size or turnover
     exemption, and WCAG's decoration exemption is a test on the WORDS — text is
     decorative only if it can be rearranged or substituted without changing its
     purpose. An instrument, a genre, an origin, a formation year, a catalogue
     number and "Sold out" all fail that test. All three tokens are DELETED
     rather than left lying about for the next author to reach for.

     --text-faint outlived the other two by a day, on the stated ground that
     press.css still used it — which was true, and was the defect rather than
     the justification: the five rules holding it were the press kit's section
     labels, its "Booking" / "Origin" field names, its release years and its
     "and N more" line, on the one page this product expects a booker to PRINT.
     They were 2.50:1 dark and 2.03:1 light. They are on --text-muted now and
     the token has no consumer anywhere. spec/system/press_kit_contrast_spec.rb
     is the sweep that says so, and it is closed in both directions, so a rung
     reintroduced below --text-muted fails it rather than waiting a year.

     THERE IS NO ROOM FOR A SIXTH RUNG UNDER --text-muted, and that is an
     arithmetic fact rather than a preference: 58% is the percentage
     Theme::MIN_PALETTE_CONTRAST = 14.0 was solved for. Anything quieter needs a
     higher palette floor, which is a change to the Theme model and would refuse
     palettes this product already ships.

     THE SCALE IS TONE-DEPENDENT BELOW --text, and that is not a flourish.
     `color-mix()` is a linear mix in a GAMMA-ENCODED space, so the same
     percentage does not mean the same contrast in the two directions: 54% of a
     cream ink into a near-black ground measures 5.23:1, while 54% of a
     near-black ink into a cream ground measures 3.74:1. The light half of the
     scale was therefore below AA for its whole life and nothing measured it,
     because the only sweep in the suite ran on one palette. The percentages
     under :root[data-tone="light"] below are chosen so each rung lands on the
     same contrast its dark twin does — measured, not guessed. */
  --text-bright: var(--ui-ink);
  --text:        color-mix(in srgb, var(--ui-ink) 92%, var(--ui-ground));
  --text-quiet:  color-mix(in srgb, var(--ui-ink) 80%, var(--ui-ground));
  --text-body:   color-mix(in srgb, var(--ui-ink) 70%, var(--ui-ground));
  /* 58%, and it was 54%. This is now the tier EVERY small label on the page
     sits on, so it is the tier Theme::MIN_PALETTE_CONTRAST is derived from: at
     58% no palette clearing 14.0:1 between ground and ink puts it under 4.5:1,
     measured against --ink-pit (the worst backdrop the page paints). 54% would
     have needed a 15.7:1 floor, which the shipped light demo band at 14.60:1
     does not clear. The four extra points of ink are what that band costs, and
     they move the default palette from 5.02:1 to 5.66:1 — the smallest step
     that keeps every palette this product ships. */
  --text-muted:  color-mix(in srgb, var(--ui-ink) 58%, var(--ui-ground));

  /* ------------------------------------------------ fixed                   */
  --on-image: #efeae2;   /* hero copy always sits on a dark photograph        */
  /* Two accents, not one. The authentic colours fail WCAG on purpose — dried blood
     on black is 2.11:1, and that is what it is. So --accent is decorative only
     (rules, marks, ornament) and --accent-ui carries anything you click.

     THE 46% IS DERIVED, AND IT USED TO BE 68% (011 OD-004). At 68% the mix did
     not carry 10px text: measured against --ink-pit, the brightest backdrop the
     page paints behind these labels, Transilvanian resolved to 3.28:1 and Kaldr
     to 3.88:1 against WCAG's 4.5:1 for text this size. Two of the eight
     documented design families, and the platform's own reference material.

     The owner's answer to OD-004 was to fix the MIX rather than to soften the
     advisory — "an advisory calibrated so our defaults stay quiet is an advisory
     built to approve of us". So the ink share went up until the defaults
     genuinely passed, and the number comes from a scan rather than a guess.
     Swept across all eight families plus the shipped default and the light demo
     palette, worst case at each accent share:

         P=52% -> 4.40   P=50% -> 4.49   P=49% -> 4.55   P=46% -> 4.63

     49% is the boundary — the highest share at which all ten clear 4.5:1. 46%
     is what ships, three points inside it, and the margin is not superstition:
     `color-mix` rounds to integer channels, so the curve has steps in it
     (48% measures 4.562 and 47% measures 4.620) and a value sitting on a step
     is a value that moves when a palette does. The tightest palette is Kaldr at
     4.63:1. Re-derived on every build by spec/models/theme_accent_floor_spec.rb.

     WHAT THIS DOES NOT DO, and it is the reason FR-007 is an advisory rather
     than a validation: NO share can guarantee 4.5:1. Sweeping the bounded
     palette space (ground/ink already >= MIN_PALETTE_CONTRAST) with an
     adversarial accent, 46% still bottoms out at 3.26:1 — a light ground, black
     ink and a white accent. The mix protects the palettes we ship; only the
     advisory can speak about the one a band types. */
  --accent:    var(--ui-accent);
  --accent-ui: color-mix(in srgb, var(--ui-accent) 46%, var(--ui-ink));
  --pad-y:    var(--ui-pad);
  --gutter:   30px;
  --measure:  1180px;
  --ease:     cubic-bezier(.2, .7, .3, 1);

  --track-wide:  calc(.40em * var(--ui-tracking));
  --track-mid:   calc(.26em * var(--ui-tracking));
  --track-tight: calc(.20em * var(--ui-tracking));
}

/* ---------------------------------------------------------------------------
   THE LIGHT HALF OF THE INK SCALE.

   data-tone is on <html>, written by Themes::CssVariables#tone from the ground's
   relative luminance, and re-written live by theme_preview_controller.js as a
   band drags an unsaved colour — so the editor's preview and the published page
   agree without a second mechanism.

   WHY THIS IS A SELECTOR AND NOT A MEDIA QUERY, and why it is safe where the
   `prefers-contrast` block at the foot of this file was not: `:root[data-tone]`
   is (0,2,0) and beats the bare `:root` above at (0,1,0) on specificity, so it
   wins regardless of document order. It touches NO --ui-* variable, so it
   cannot flatten a band's palette — it only re-derives three steps that this
   file owns outright and the per-band inline block never mentions.

   The one rule it does outrank is press.css's @media print restatement of the
   same three steps, which is declared on a bare :root. That block's selector
   was raised to :root[data-tone] to match — a printed press kit must stay on
   paper values, and a (0,1,0) rule cannot say so over this one. Same trap, same
   file, third time: see .claude/skills/theme-css-cascade.

   The percentages are measured against the shipped light palette (#e7e3da on
   #14120f): 67% -> 5.63:1, 79% -> 8.34:1, 86% -> 10.38:1, against the dark
   default's 5.91 / 8.28 / 10.69. Body and quiet move because muted moved: at
   67% and the old 70%, --text-muted and --text-body were 5.63 and 6.20 — one
   rung, not two, and the ladder had collapsed rather than lifted.
   --------------------------------------------------------------------------- */
:root[data-tone="light"] {
  --text-quiet:  color-mix(in srgb, var(--ui-ink) 86%, var(--ui-ground));
  --text-body:   color-mix(in srgb, var(--ui-ink) 79%, var(--ui-ground));
  --text-muted:  color-mix(in srgb, var(--ui-ink) 67%, var(--ui-ground));
}

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

/* prefers-contrast: more is honoured at the END of this file. It has to be, and
   the reason is in the block itself. */

/* Windows High Contrast overrides the whole palette, so structure must survive
   without colour: keep the rules that carry meaning as real borders. */
@media (forced-colors: active) {
  .frame, .slot, .player, .subscribe { border: 1px solid CanvasText; }

  /* The page is divided entirely by hairlines in a color-mix()'d ink. The forced
     palette flattens the whole derived scale, so restate the divisions that say
     "a new section starts here" as borders the UA will paint. */
  /* .section was in this list and no template has ever emitted it, so the
     public page has had NO section divisions under forced colours. The real
     wrappers are .band-section and .hall__section. */
  .hero, .band-section, .hall__section { border-bottom: 1px solid CanvasText; }
  .roster, .footer, .live__foot, .date { border-top: 1px solid CanvasText; }

  /* Drawn as backgrounds and gradients, which forced colours suppress: they
     would survive as invisible slivers rather than as anything meaningful. */
  /* .ambient joins them per 010 FR-008. This list and the prefers-contrast one
     at the end of the file are NOT the same list — that one does not carry
     .hero__wash or .hero__stem — so the layer is added to each deliberately
     rather than by copying one over the other. */
  .grain, .haze, .scanlines, .ambient, .hero__wash, .hero__stem { display: none; }
  /* 005. The facade's play mark and the title's scrim are drawn as a background
     and a gradient, which forced colours suppress — they would survive as
     invisible slivers over the still. The mark is restated as a real border so
     the affordance does not vanish, because without it the still becomes a
     picture with no indication it is a video. */
  .video__title { background: none; }
  .video__play { background: Canvas; border: 2px solid CanvasText; }
  .video__play::after { border-left-color: CanvasText; }
  /* The copier's speckle joins them for the same reason and on the same
     evidence: it is a background-image layer, and this file's practice is to
     hide those explicitly here rather than trust the UA to. Safe as a bare
     selector even though this block sits near the TOP of the file — nothing
     later declares `display` on .plate::after, and the copier's own overlay
     rule sets content/position/blend/background and stops there. */
  .plate::after { display: none; }

  /* THE FILTER IS THE PART THAT ACTUALLY HAS TO GO, and it needs the specificity
     dance. Forced colours replaces the palette; a photograph crushed to
     two-tone on top of that is a picture the reader cannot resolve at all.
     `.plate img` here would LOSE — that selector is (0,1,1) and it is declared
     again further down this same file at the same specificity, so document order
     decides and this block is earlier. This is the exact failure the
     prefers-contrast header at the end of the file documents at length.
     `html:root[data-treatment="copier"] .plate img` is (0,2,2) and wins on
     specificity regardless of order. Scoped to `copier` deliberately: the other
     four treatments are tonal nudges and this packet does not change them. */
  html:root[data-treatment="copier"] .plate img,
  html:root[data-treatment="copier"] .plate video { filter: none; }
  /* A masked background box is not guaranteed to paint under forced colours.
     CanvasText keeps the wordmark present rather than leaving a blank space
     where the band's name is. */
  .mark--image { background-color: CanvasText; filter: none; }
}

/* ==========================================================================
   base
   ========================================================================== */

*, *::before, *::after { box-sizing: border-box; }
html { scroll-behavior: smooth; }

body {
  margin: 0;
  background: var(--ink-void);
  color: var(--text-body);
  font-family: var(--ui-body);
  font-size: calc(19px * var(--ui-optical));
  line-height: 1.75;
  overflow-x: hidden;
  
  -webkit-font-smoothing: antialiased;
  transition: background .4s var(--ease), color .4s var(--ease);
}

a { color: var(--text-body); text-decoration: none; transition: color .25s var(--ease); }
a:hover { color: var(--text-bright); }
a:focus-visible, button:focus-visible, input:focus-visible { outline: 2px solid var(--accent-ui); outline-offset: 3px; }

img { display: block; max-width: 100%; }
::selection { background: var(--ink-edge-lit); color: var(--text-bright); }
input, button { font: inherit; color: inherit; }

/* ==========================================================================
   atmosphere
   ========================================================================== */

@keyframes hazeA {
  0%, 100% { transform: translate3d(-3%, 0, 0) scale(1.15); opacity: .50 }
  50%      { transform: translate3d(4%, -2%, 0) scale(1.30); opacity: .78 }
}
@keyframes hazeB {
  0%, 100% { transform: translate3d(4%, 2%, 0) scale(1.25); opacity: .40 }
  50%      { transform: translate3d(-5%, -3%, 0) scale(1.08); opacity: .66 }
}
@keyframes slowPulse { 0%, 100% { opacity: .3 } 50% { opacity: .85 } }

/* GRAIN — film stock, not a gradient. STATIC: it used to translate on a 1.3s
   step loop, which is movement in the corner of the eye for the whole visit and
   costs a composite every frame for texture nobody asked to have animate.

   The noise is CRUSHED before it is blended. A plain fractalNoise rect is
   mid-grey everywhere, and screen-blending mid-grey over a near-black ground
   raises the floor uniformly — the page goes from #080807 to a flat charcoal,
   which is the opposite of the intent. `feComponentTransfer` with a steep gamma
   pushes the midtones to black and leaves sparse bright specks, so only the
   specks lift and the band's ground stays exactly where they set it.
   The point is NOT visible film grain — it is that the black stops being
   mathematically identical in every pixel. Uniform black reads as a swatch;
   black with a little variance reads as a surface. Tuned so you notice the
   flatness is gone rather than noticing the grain. */
.grain {
  position: fixed; inset: 0; z-index: 0; pointer-events: none;
  opacity: min(1, calc(var(--ui-grain) * 2.2)); mix-blend-mode: screen;
  background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='300' height='300'><filter id='n'><feTurbulence type='fractalNoise' baseFrequency='0.8' numOctaves='4'/><feComponentTransfer><feFuncR type='gamma' exponent='6' amplitude='0.55'/><feFuncG type='gamma' exponent='6' amplitude='0.55'/><feFuncB type='gamma' exponent='6' amplitude='0.55'/></feComponentTransfer></filter><rect width='300' height='300' filter='url(%23n)'/></svg>");
}
/* on a light ground, screen-blended noise is invisible — multiply instead, and
   the crushing has to run the other way so the specks are dark on light */
:root[data-tone="light"] .grain {
  mix-blend-mode: multiply;
  background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='300' height='300'><filter id='n'><feTurbulence type='fractalNoise' baseFrequency='0.8' numOctaves='4'/><feComponentTransfer><feFuncR type='gamma' exponent='0.2' amplitude='1'/><feFuncG type='gamma' exponent='0.2' amplitude='1'/><feFuncB type='gamma' exponent='0.2' amplitude='1'/></feComponentTransfer></filter><rect width='300' height='300' filter='url(%23n)'/></svg>");
}

/* UNEVEN INK — big soft blotches, an order of magnitude lower in frequency than
   the grain, so the page reads as unevenly printed rather than uniformly dark.
   Multiply only: it can darken the ground, never lift it. */
.grain::after {
  content: ""; position: absolute; inset: 0;
  mix-blend-mode: multiply; opacity: calc(var(--ui-texture) * .28);
  background-size: 1100px 1100px;
  background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='700' height='700'><filter id='b'><feTurbulence type='fractalNoise' baseFrequency='0.006' numOctaves='2'/></filter><rect width='700' height='700' filter='url(%23b)'/></svg>");
}

/* EDGES — the page ends rather than stopping. Darkens only, so it deepens the
   ground instead of washing it. */
.grain::before {
  content: ""; position: absolute; inset: 0;
  background: radial-gradient(125% 95% at 50% 35%, transparent 55%, rgba(0,0,0,.45) 100%);
}
:root[data-tone="light"] .grain::before { display: none; }

.haze { position: absolute; pointer-events: none; opacity: var(--ui-texture); }
.haze--a {
  left: -18%; top: -12%; width: 85%; height: 90%; filter: blur(34px);
  background: radial-gradient(closest-side, color-mix(in srgb, var(--accent) 30%, transparent), transparent 70%);
  animation: hazeA 38s ease-in-out infinite;
}
.haze--b {
  right: -22%; bottom: -18%; width: 105%; height: 100%; filter: blur(42px);
  background: radial-gradient(closest-side, rgba(120,110,100,.13), transparent 72%);
  animation: hazeB 52s ease-in-out infinite;
}
.scanlines {
  position: absolute; inset: 0; pointer-events: none;
  /* HORIZONTAL. This was 90deg, which runs the gradient left-to-right and paints
     vertical pinstripes every 8px — read as stripes rather than as texture, and
     not what the name means: a scanline is horizontal. 0deg, wider spacing and a
     third of the opacity make it grain you feel instead of lines you see.
     --ui-texture still switches it off entirely for a band that wants none. */
  opacity: calc(.18 * var(--ui-texture));
  background: repeating-linear-gradient(0deg, rgba(255,255,255,.02) 0 1px, transparent 1px 12px);
}

/* THE AMBIENT LAYER — 010 FR-002. The page stops being flat black.

   Beside .grain, not instead of it: .grain gives the black variance, this gives
   it material. Same construction as .grain — fixed, inset 0, z-index 0, screen
   — which is the point: that construction already ships and has never been a
   rendering problem. The only difference is that the source is a decoded raster
   instead of a 300x300 SVG data URI, which is why the files are cut at 640/1280
   and never at the 1600 master (research Q3).

   DELIBERATELY ABSENT, and none of these is an oversight: no `will-change`, no
   `transform: translateZ(0)`, no `contain`, no `background-attachment: fixed`.
   The layer is already promoted by position:fixed plus a blend mode, so a hint
   only pins an extra texture; and background-attachment:fixed is the classic
   scroll-jank source, because the background repaints against a moving
   viewport where a fixed ELEMENT is just moved by the compositor.

   `cover` / `no-repeat` and nothing else is what satisfies FR-011 by
   construction: a position:fixed layer is always exactly one viewport tall
   whatever the document height, so the four plates that do not tile can never
   be tiled here.

   The opacity is the band's chosen strength, emitted as --ambient-strength by
   Themes::CssVariables under the same condition that renders this element. No
   fallback value on the var(): the property and the element are produced by one
   condition, and a fallback would be a second answer to a question that already
   has one. */
.ambient {
  position: fixed; inset: 0; z-index: 0; pointer-events: none;
  opacity: var(--ambient-strength); mix-blend-mode: screen;
  background-position: center; background-repeat: no-repeat; background-size: cover;
}

/* 010 OD-001 — DARK-TONE BANDS ONLY, AND IT IS CSS RATHER THAN THE SERVER.
   The plates were measured under near-white hero ink over a near-black ground;
   a light-tone family would be a second measured set and its own packet.

   A server gate would leave the Studio's live preview showing the layer until
   the band pressed save, so a band dragging their ground toward white would see
   the change only after committing to it — the exact inverse of the failure
   OD-001 exists to prevent. Chromium does not fetch a background-image on a
   display:none element, so a light-tone band pays no bytes for the gate being
   here instead. */
:root[data-tone="light"] .ambient { display: none; }

/* ONE RULE PER PLATE, ALL SIX IN THE CACHEABLE STYLESHEET.

   `url()`, ALWAYS — INCLUDING INSIDE image-set(). Propshaft's CssAssetUrls
   compiler matches `url("...")` and nothing else, so the bare-string form
   `image-set("plates/…" type("image/webp"))` ships an UNREWRITTEN relative
   URL. There is no warning: the "Unable to resolve" log branch is only reached
   when url() matched and the asset was missing, so an unmatched pattern logs
   nothing at all. That failure is a 404 on every band's page with no line in
   any log, which is why spec/assets/plate_stylesheet_spec.rb exists.

   THE WIDTH SWITCH IS A MEDIA QUERY, NOT A DENSITY DESCRIPTOR. image-set()
   selects on device pixel ratio, so a 390px phone at DPR 3 would take a `2x`
   candidate — the 1280 file, three to four times the bytes, on exactly the
   device FR-016 exists to protect. image-set() carries the FORMAT switch only.

   The plain url() JPEG line before each image-set() is the CSS-level fallback:
   image-set() is a VALUE, so a parser that does not understand it drops the
   whole declaration and the preceding one survives. No -webkit- prefix —
   -webkit-image-set() never supported type(), so a prefixed line could only
   carry a resolution set, and a browser old enough to need it but lacking WebP
   would take it and render nothing. */
:root[data-ambient="ashfall"] .ambient {
  background-image: url("/assets/plates/ashfall-640-92c09c32.jpg");
  background-image: image-set(url("/assets/plates/ashfall-640-1e510b1b.webp") type("image/webp"),
                              url("/assets/plates/ashfall-640-92c09c32.jpg")  type("image/jpeg"));
}
:root[data-ambient="thicket"] .ambient {
  background-image: url("/assets/plates/thicket-640-8197a96c.jpg");
  background-image: image-set(url("/assets/plates/thicket-640-4ee8bde2.webp") type("image/webp"),
                              url("/assets/plates/thicket-640-8197a96c.jpg")  type("image/jpeg"));
}
:root[data-ambient="slate"] .ambient {
  background-image: url("/assets/plates/slate-640-abf22325.jpg");
  background-image: image-set(url("/assets/plates/slate-640-7d3fa371.webp") type("image/webp"),
                              url("/assets/plates/slate-640-abf22325.jpg")  type("image/jpeg"));
}
:root[data-ambient="peat"] .ambient {
  background-image: url("/assets/plates/peat-640-9e4f00bc.jpg");
  background-image: image-set(url("/assets/plates/peat-640-bfbc59de.webp") type("image/webp"),
                              url("/assets/plates/peat-640-9e4f00bc.jpg")  type("image/jpeg"));
}
:root[data-ambient="weave"] .ambient {
  background-image: url("/assets/plates/weave-640-7ce34ab9.jpg");
  background-image: image-set(url("/assets/plates/weave-640-a0e7ab26.webp") type("image/webp"),
                              url("/assets/plates/weave-640-7ce34ab9.jpg")  type("image/jpeg"));
}
:root[data-ambient="silt"] .ambient {
  background-image: url("/assets/plates/silt-640-93494ad2.jpg");
  background-image: image-set(url("/assets/plates/silt-640-12e28396.webp") type("image/webp"),
                              url("/assets/plates/silt-640-93494ad2.jpg")  type("image/jpeg"));
}

/* THIS BLOCK MUST STAY AFTER THE SIX ABOVE. @media contributes NO specificity,
   so these are the same (0,2,1) as the rules they override and win on document
   order alone. Moved above them and every viewport would take the 640 file,
   silently, with both blocks visibly present in the file. */
@media (min-width: 700px) {
  :root[data-ambient="ashfall"] .ambient {
    background-image: url("/assets/plates/ashfall-1280-b673077e.jpg");
    background-image: image-set(url("/assets/plates/ashfall-1280-3f6579f5.webp") type("image/webp"),
                                url("/assets/plates/ashfall-1280-b673077e.jpg")  type("image/jpeg"));
  }
  :root[data-ambient="thicket"] .ambient {
    background-image: url("/assets/plates/thicket-1280-cf3f1483.jpg");
    background-image: image-set(url("/assets/plates/thicket-1280-52472b14.webp") type("image/webp"),
                                url("/assets/plates/thicket-1280-cf3f1483.jpg")  type("image/jpeg"));
  }
  :root[data-ambient="slate"] .ambient {
    background-image: url("/assets/plates/slate-1280-dccd89bc.jpg");
    background-image: image-set(url("/assets/plates/slate-1280-300bcf4b.webp") type("image/webp"),
                                url("/assets/plates/slate-1280-dccd89bc.jpg")  type("image/jpeg"));
  }
  :root[data-ambient="peat"] .ambient {
    background-image: url("/assets/plates/peat-1280-d7762943.jpg");
    background-image: image-set(url("/assets/plates/peat-1280-81dab476.webp") type("image/webp"),
                                url("/assets/plates/peat-1280-d7762943.jpg")  type("image/jpeg"));
  }
  :root[data-ambient="weave"] .ambient {
    background-image: url("/assets/plates/weave-1280-9eb20684.jpg");
    background-image: image-set(url("/assets/plates/weave-1280-79c5f561.webp") type("image/webp"),
                                url("/assets/plates/weave-1280-9eb20684.jpg")  type("image/jpeg"));
  }
  :root[data-ambient="silt"] .ambient {
    background-image: url("/assets/plates/silt-1280-d92fb118.jpg");
    background-image: image-set(url("/assets/plates/silt-1280-ddda4231.webp") type("image/webp"),
                                url("/assets/plates/silt-1280-d92fb118.jpg")  type("image/jpeg"));
  }
}

/* ==========================================================================
   THE PLATE — what sits behind the hero, and how a band's images are treated
   ==========================================================================

   Four states. `gradient` is the default AND the universal fallback: it is
   arithmetic on three NOT NULL hex columns, so it has no I/O, no readiness
   state and can never fail. A photograph that is still being measured, was
   refused, or has been purged all land here — one condition, not a state
   machine.

   The TREATMENT replaces four hard-coded recipes that used to live one per
   image context (grayscale .8/.35/.55/.4, each with its own contrast and
   brightness). The platform was already filtering every band's artwork; it was
   just doing it inconsistently and invisibly.

   --plate-push scales the treatment to the MEASURED lightness of the band's own
   photograph, so a dark photograph is barely touched and a bright one is pushed
   hard enough to keep the wordmark legible. On a light ground the direction
   inverts: the photograph is pushed UP toward white under dark copy. */

:root {
  /* 0 when there is no measurement, which is also the no-photograph case. */
  --plate-lightness: 0;
  /* How hard the image has to be pushed away from the copy. Calibrated so a
     MID-DARK photograph — the demo plate measures 0.369 — lands at the 0.62
     brightness the old fixed recipe used, which is what keeps "a band who
     changes nothing sees no change" true. The floor of 0.35 is there because
     even a nearly black photograph needs some separation from the wordmark;
     the slope is what makes a bright one get pushed much harder. */
  --plate-push: clamp(0, calc(0.35 + var(--plate-lightness) * 0.9), 1);
  --plate-dim: calc(1 - var(--plate-push) * 0.55);
}
/* THERE IS NO LIGHT-TONE INVERSION, and removing it was a fix rather than a
   simplification.

   A `:root[data-tone="light"]` rule used to set
   `--plate-dim: calc(1 + (1 - var(--plate-push)) * 0.5)`, on the stated reason
   that "a photograph under DARK copy has to go lighter, not darker". The
   premise is false: hero copy is `--on-image` (#efeae2, near-white) in BOTH
   tones — the wordmark, the claim and the hero foot all resolve to it — so the
   copy over the plate is never dark, and brightening the plate only ever moved
   the image TOWARD the copy. It did so in both directions, because a value
   above 1 is a brightening whatever `--plate-push` is.

   Measured on a light-tone band with a bright photograph (lightness 0.647):
   `brightness(1.034)`, and the origin line rendered at 1.34:1 against the sky
   behind it. Invisible, and the treatment exists precisely to prevent that. */

/* One definition, five values, every image context.

   THE COPIER IS NOT LIKE THE OTHER FOUR AND THE NUMBERS SAY SO. The four above
   are tonal nudges — the strongest, `duotone`, is contrast(1.18) — and they
   leave a midtone a midtone. The copier takes the transfer curve near-vertical,
   which is what a photocopier physically does: crushed blacks, blown highlights,
   and a midtone that does not survive at all (011 FR-003).

   MEASURED, NOT CHOSEN — docs/design/copier-calibration.html, headless Chrome,
   nine candidates sampled from the capture (011 T005). Under this recipe a flat
   patch renders as:

       #1a1a1a -> 0      #646464 -> 0      #c8c8c8 -> 255    #e6e6e6 -> 255

   Only inputs 102-181 survive as grey — 79 of 256 levels. That narrow surviving
   band IS the effect.

   WHY brightness IS BELOW 1, WHICH IS THE PART THAT LOOKS WRONG. CSS contrast(k)
   is affine about 0.5 in sRGB: out = (in - 0.5) * k + 0.5. brightness(b) applied
   first puts the pivot — the input that survives as mid-grey — at 0.5/b. So
   brightness ABOVE 1 moves the pivot DOWN, towards the midtone we are trying to
   kill, and a value close to the pivot barely moves however hard you push:
   011's research recommended brightness(1.15) contrast(2.5-4), which put the
   pivot at 111/255 and left #646464 (100) at 96 — byte-identical to what
   `monochrome` renders. An assertion written against that recipe would have
   passed with this feature deleted. brightness BELOW 1 moves the pivot UP and
   away (0.90 -> 142/255), so contrast(3.6) is enough to collapse the midtone.

   Do NOT "simplify" this to a single contrast(): the pivot is fixed at 0.5
   without the brightness term, and there is then no control over WHERE the
   midtones die. The two functions are one mechanism, in this order. */
:root                          { --treat: grayscale(.45) contrast(1.1); }
:root[data-treatment="natural"]    { --treat: none; }
:root[data-treatment="muted"]      { --treat: grayscale(.5) contrast(1.06) saturate(.8); }
:root[data-treatment="monochrome"] { --treat: grayscale(1) contrast(1.14); }
:root[data-treatment="duotone"]    { --treat: grayscale(1) contrast(1.18); }
:root[data-treatment="copier"]     { --treat: grayscale(1) brightness(.90) contrast(3.6); }

/* ==========================================================================
   005 — video and photographs
   ========================================================================== */

/* THE FACADE. What is on the page before a visitor presses play is a still, a
   title and a play mark — and nothing that talks to anybody else (FR-004).
   `aspect-ratio` rather than a padding hack so the box reserves its height
   before the image arrives: that is the CLS half of SC-004, which plan flag F4
   records as a design constraint rather than an assertion, because nothing in
   this repo can measure it. */
.videos, .photos { list-style: none; margin: 0; padding: 0; display: grid; gap: var(--gutter); }

.video__facade {
  position: relative; display: block; aspect-ratio: 16 / 9; overflow: hidden;
  background: var(--ink-pit); text-decoration: none; color: var(--on-image);
  border: 1px solid var(--ink-edge);
}
.video__still { width: 100%; height: 100%; object-fit: cover; display: block; filter: var(--treat); }

/* The play mark is drawn, not an image: one less request, and it inherits the
   band's accent instead of being a foreign brand's triangle. */
.video__play {
  position: absolute; inset: 50% auto auto 50%; translate: -50% -50%;
  width: 68px; height: 68px; border-radius: 50%;
  background: color-mix(in srgb, var(--ui-ground) 62%, transparent);
  border: 1px solid var(--accent-ui);
}
.video__play::after {
  content: ""; position: absolute; inset: 50% auto auto 54%; translate: -50% -50%;
  border-style: solid; border-width: 11px 0 11px 18px;
  border-color: transparent transparent transparent var(--accent-ui);
}
/* A title that sits ON the still needs its own ground: --on-image exists because
   hero copy always sits on a photograph, and this is the same problem. */
.video__title {
  position: absolute; inset: auto 0 0 0; padding: 14px 16px;
  font-family: var(--ui-mono); font-size: 12px; letter-spacing: var(--track-mid);
  text-transform: uppercase;
  background: linear-gradient(0deg, color-mix(in srgb, #000 72%, transparent), transparent);
}
/* FR-009. No still — deleted, private, region-blocked — so there is no image to
   sit behind the title. The box stops pretending to be a video and reads as the
   link it is. */
.video__facade:not(:has(.video__still)) { aspect-ratio: auto; padding: 22px; }
.video__facade:not(:has(.video__still)) .video__play { display: none; }
.video__facade:not(:has(.video__still)) .video__title { position: static; background: none; padding: 0; }

.video__frame { width: 100%; aspect-ratio: 16 / 9; display: block; border: 0; }

/* THE PRESS PHOTO. The credit is part of the figure, not a caption bolted under
   it — a credit a journalist has to hunt for is a credit that does not travel
   with the file. */
.photo__figure { margin: 0; }
.photo__image {
  width: 100%; height: auto; display: block;
  border: 1px solid var(--ink-edge); filter: var(--treat);
}
.photo__credit {
  margin-top: 10px; display: flex; flex-wrap: wrap; gap: 4px 16px; align-items: baseline;
  font-family: var(--ui-mono); font-size: 12px; letter-spacing: var(--track-mid);
}
.photo__by { color: var(--text-body); text-transform: uppercase; }
.photo__terms { color: var(--text-muted); }
/* --accent-ui and not --accent: this is a link, and 011 derived that mix so 10px
   interface text clears 4.5:1 on every palette this product ships. */
.photo__download { color: var(--accent-ui); text-transform: uppercase; }

/* THE PLATE ELEMENT — one class, all three layouts. It was three different
   background-image rules with three different filters; a background-image
   cannot take srcset, which is why a photograph could never be served at a size
   that suits the screen. */
.plate { position: relative; overflow: hidden; isolation: isolate; }
/* In monolith the plate is the hero's own backdrop; elsewhere it is a band of
   its own that sizes itself. */
.hero > .plate { position: absolute; inset: 0; z-index: 0; }
.marquee__plate .plate { height: 100%; }
.plate img, .plate video {
  width: 100%; height: 100%; object-fit: cover; display: block;
  filter: var(--treat) brightness(var(--plate-dim));
}

/* TONER SPECKLE — 011 FR-003, and the half of the copier that is not a filter.
   Scoped exactly as duotone's overlay is, which means it treats the PLATE and
   not the covers: `treat_covers` was removed as a dead switch (011 T003), so the
   plate is the only scope there is.

   WHY IT IS A THRESHOLD AND NOT A GRID. FR-003 requires speckle and forbids a
   visible regular dot pattern — a coarse ordered dither was built once and
   rejected on sight for reading as retro video game rather than as a photocopy.
   No `repeating-linear-gradient`, no ordered matrix, no `background-size` that
   would quantise the noise. The spatial structure is feTurbulence exactly as
   `.grain` does it; what differs is the transfer.

   EVERY NUMBER HERE WAS MEASURED, and the first draft of this rule was wrong in
   two ways that a screenshot caught and a spec would not have
   (docs/design/copier-calibration.html, and the sweep behind it):

   1. `feColorMatrix saturate 0` IS LOAD-BEARING. feTurbulence generates R, G and
      B independently, so thresholding each channel on its own produces CYAN,
      MAGENTA AND YELLOW specks — confirmed on screen. Toner is neutral. The
      channels have to be collapsed to luminance BEFORE the transfer, not after.
   2. THE THRESHOLD IS 10/32, NOT 1/8. With the channels collapsed, the noise
      clusters hard around 0.5 and a 1/8 threshold passes almost nothing: the
      first draft of this rule measured **0.00% ink coverage** — a rule that
      renders, validates, and does nothing. Measured coverage against threshold,
      at this baseFrequency:

          8/32 (0.250) -> 0.17%    9/32 (0.281) -> 0.54%
          10/32 (0.312) -> 1.62%   11/32 (0.344) -> 3.96%

      1.62% is toner. 0.17% is invisible and 3.96% reads as dirt. The 32-bucket
      table exists because 8 buckets cannot express this: the step from 2/8 to
      3/8 jumps 0.17% straight to 3.96%.

   MULTIPLY ON BOTH TONES, which is ONE recipe where the research expected two.
   Dark specks need somewhere light to land and after the crush both tones have
   it — the highlights blow to 255 either way (see the recipe above). What
   `.grain` flips blend mode for is a different problem: it lays noise over the
   PAGE ground, which really is dark on one tone and light on the other. Verified
   rendered on both tones; a second rule that changes nothing would be a control
   that cannot fire, which is what this packet spent T003 deleting. */
:root[data-treatment="copier"] .plate::after {
  content: ""; position: absolute; inset: 0; pointer-events: none;
  mix-blend-mode: multiply; opacity: .85;
  background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='512' height='512'><filter id='s'><feTurbulence type='fractalNoise' baseFrequency='0.72' numOctaves='2' seed='11'/><feColorMatrix type='saturate' values='0'/><feComponentTransfer><feFuncR type='discrete' tableValues='0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1'/><feFuncG type='discrete' tableValues='0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1'/><feFuncB type='discrete' tableValues='0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1'/><feFuncA type='discrete' tableValues='1'/></feComponentTransfer></filter><rect width='512' height='512' filter='url(%23s)'/></svg>");
}

/* Duotone: greyscale underneath, the band's own accent laid over it. Pure CSS —
   no processing, no derivative, and it changes with the palette. */
:root[data-treatment="duotone"] .plate::after {
  content: ""; position: absolute; inset: 0; pointer-events: none;
  background: var(--ui-accent); mix-blend-mode: color; opacity: .8;
}

/* THE GRADIENT — built from the band's own two ends, so two bands with
   different palettes produce visibly different plates and neither is a stock
   image. Exactly one look; there is no shape setting and none is stored. */
.plate--gradient {
  background:
    radial-gradient(120% 85% at 30% 20%, color-mix(in srgb, var(--ui-accent) 22%, transparent), transparent 62%),
    linear-gradient(168deg, color-mix(in srgb, var(--ui-ink) 12%, var(--ui-ground)) 0%, var(--ui-ground) 58%);
}

/* THE PLATE BREATHING -- the one ambient motion that survived review.
   Six were built and looked at; this was the only one visible on a photograph,
   because it modulates the plate's OWN exposure and that is the one layer with
   dynamic range left underneath .hero__wash. The others (a slow Ken Burns, a
   drifting haze, a breathing vignette, moving grain) either modulated a layer
   the wash overwhelms or were rejected on sight. Measured: with the wash at
   full strength a fourfold brightness swing on the plate moves the rendered
   frame by 5.7/255; with it off, 48.1. If this ever reads as too subtle, the
   wash is the lever, not this.

   IT BREATHES DOWN, NEVER UP, and that is the whole safety argument.
   --plate-dim is a MEASURED value: the push curve chose it so the band's name
   stays readable over their own photograph. Swinging either side of it would
   spend part of every cycle brighter than the measurement said was safe. So
   the measured value is the CEILING -- the plate darkens from it and returns,
   and legibility at the worst moment of the cycle is strictly better than the
   still page it replaces.

   Keyed on a data attribute so a second motion is a second keyframes block and
   one more value in Theme::MOTIONS, not a new mechanism. */
:root { --breath-depth: .25; --breath-period: 6s; }

@keyframes breath {
  0%, 100% { filter: var(--treat) brightness(calc(var(--plate-dim) * (1 - var(--breath-depth)))); }
  50%      { filter: var(--treat) brightness(var(--plate-dim)); }
}
:root[data-motion="breath"] .plate img,
:root[data-motion="breath"] .plate video {
  animation: breath var(--breath-period) ease-in-out infinite;
}

/* Absolute, and unlike the video this needs no controller: there IS an element
   to animate, so removing the animation is a rule rather than a decision about
   whether to render at all. Same specificity as the rule above and later in the
   file, so it wins on order. */
@media (prefers-reduced-motion: reduce) {
  :root[data-motion="breath"] .plate img,
  :root[data-motion="breath"] .plate video { animation: none; }
}

/* THE MOVING PLATE — silent, looping, no controls. Not a video a visitor
   watches; a background that happens to move. */
.plate video { pointer-events: none; }
/* The still is what loads first and what carries the hero, so the page is
   complete if the video never arrives — on a slow connection, a metered one, or
   where automatic playback is refused. */
.plate--video img { position: absolute; inset: 0; z-index: 1; }
.plate--video video { position: relative; z-index: 2; }

/* REDUCED MOTION IS ABSOLUTE — and it has no CSS-only answer, which is why the
   video is not in the markup at all.
   `animation: none` does not touch a <video>; there is no CSS property that
   pauses one; and `display: none` may still let the browser fetch the bytes,
   which would break the conserve-data requirement even where it satisfies the
   motion one. So the server renders the STILL and nothing else, and a tiny
   controller adds the video only when the preference allows it. A visitor who
   asked for reduced motion never has a video element in their document.
   That one decision also satisfies "the still must be the LCP", "the page must
   be complete if the video never arrives", and "it must work with no
   JavaScript" — all of which the still-only markup gives for free. */

/* ==========================================================================
   typography primitives
   ========================================================================== */

/* SECTION TITLE — in the band's own face, at the rank of a heading.
   It was a 10px mono micro-label at 40% ink with no margin, prefixed by a
   generated numeral: "I — BAND", "01 / DISCOGRAPHY". Three things were wrong
   with that, and the number was only one of them.
     * It was outranked by its own navigation. The nav link pointing AT a
       section was 11px at 68% ink; the heading it pointed at was 10px at 40%,
       tracked looser. The same word appeared twice on screen and the menu won.
     * It shared its exact treatment with .meta and near-exactly with .caption —
       a section heading set at the rank of a photo credit.
     * margin-bottom was 0. The design reference this rule came from carried a
       hand-tuned 24-38px on every single instance, which the app never
       inherited, so the label sat flush on whatever followed it.
   The number is gone rather than restyled: the counter ran over RENDERED
   sections, so a band with no bio started at "I — DISCOGRAPHY", nothing
   referenced it, and no navigation or deep link used it. It was a decoration
   that cost a reader a parse — and numbered sections in wide-tracked mono is
   the house style of every agency template of the last five years, which is
   the one thing a band site cannot afford to look like.
   Uses --ui-display, so the four biggest structural moments on the page finally
   carry the band's voice rather than the platform's. */
.section-title {
  display: block; margin: 0 0 calc(var(--pad-y) * .3);
  font-family: var(--ui-display); font-weight: 300;
  font-size: 21px; line-height: 1.15;
  letter-spacing: calc(.06em * var(--ui-tracking));
  text-transform: uppercase; color: var(--text);
  text-wrap: balance;
}

/* THE MICRO-LABEL, and everything on the page that is set this way now shares
   ONE colour. It was --text-dim (3.31:1). It carries the band's origin line in
   the monolith hero and the "LP · 2024 · Extremely Rotten Prod." line on every
   release card — a catalogue number is the definition of text that cannot be
   substituted without changing its purpose.

   THE HIERARCHY DID NOT MOVE INTO COLOUR AND IT DOES NOT MOVE OUT OF IT. This
   rule already outranked itself four other ways: 10px against the 19px body it
   sits under, mono against the band's display and body faces, uppercase, and
   .22em of tracking. Colour was the fifth signal and the only illegible one.
   Nothing here changes size, family, case or tracking, because those four are
   what make it read as an annotation and they are untouched. */
.meta {
  font-family: var(--ui-mono); font-size: 10px;
  letter-spacing: calc(.22em * var(--ui-tracking)); text-transform: uppercase; color: var(--text-muted);
}
.caption {
  font-family: var(--ui-mono); font-size: 9px;
  letter-spacing: calc(.24em * var(--ui-tracking)); text-transform: uppercase; color: var(--text-muted);
}
.display {
  margin: 0; font-family: var(--ui-display); font-weight: 200;
  line-height: 1.25; letter-spacing: .01em; color: var(--text); text-wrap: pretty;
}
.title {
  font-family: var(--ui-display); font-weight: 300;
  letter-spacing: .03em; text-transform: uppercase; color: var(--text); line-height: 1.25;
}
.lede { margin: 0 0 18px; color: var(--text-muted); }
.pull { margin: 0; font-size: 22px; font-style: italic; line-height: 1.65; color: var(--text-body); }

/* ==========================================================================
   layout
   ========================================================================== */

.wrap { max-width: var(--measure); margin: 0 auto; }

/* EMPHASIS — the weight a section carries against its neighbours.
   `plain` is today's uniform treatment and is the default, so a band who
   changes nothing sees no change. Two of the four are a --measure rebinding,
   which is the whole reason .wrap was already written that way. */
[data-emphasis="contained"] { --measure: 68ch; }
[data-emphasis="wide"]      { --measure: min(1560px, 100% - var(--gutter) * 2); }
/* Derived from the ground scale, so it works on a light theme too — the
   gradient runs between the band's own two darkest steps rather than to black.
   The pair that used to be here ran void→pit and pit→void, a 3% step in both
   directions: two names for one invisible effect. */
[data-emphasis="sunken"] {
  background: linear-gradient(180deg, var(--ink-pit), var(--ink-void) 60%);
  box-shadow: inset 0 1px 0 var(--ink-hairline);
}

/* THE PROMO LOCKUP — cover as a contained panel beside type-dominant title.
   Replaces .split, which was `repeat(auto-fit, minmax(320px,1fr))`: two EQUAL
   columns, i.e. 560px of cover inside a 1180px measure, which is the
   full-bleed-adjacent position the research says almost nobody holds. auto-fit
   with 1fr cannot express a ratio at all.
   flex-wrap rather than grid because the same three declarations give the
   no-cover case and the 320px stack without a second rule or a media query. */
.lockup { display: flex; flex-wrap: wrap; gap: clamp(24px, 4vw, 56px); align-items: center; margin-bottom: var(--pad-y); }
.lockup__art { flex: 0 1 clamp(180px, 26vw, 320px); }
.lockup__body { flex: 1 1 340px; min-width: 0; }
.lockup__flag {
  font-family: var(--ui-mono); font-size: 10px; color: var(--accent-ui);
  letter-spacing: calc(.3em * var(--ui-tracking)); text-transform: uppercase;
}
/* Type-dominant: against .card__title's 22px, this is the difference between a
   record being announced and a record being listed. */
.lockup__title {
  font-family: var(--ui-display); font-weight: 400;
  font-size: clamp(40px, 7vw, 88px); line-height: .95;
  letter-spacing: calc(.02em * var(--ui-tracking));
  color: var(--text-bright); margin: 10px 0 14px;
}
.lockup__meta { margin-bottom: 10px; }
.lockup__formats { color: var(--text-muted); margin-bottom: 16px; }
.lockup__listen { font-size: 11px; }

/* COVERS — the reduced-detail discography, for a band whose grid is a wall.
   The card partial omits tracklists and pressings; this tightens the cells so
   the covers are the content. */
/* `auto-fill` here too, for the reason at `.grid` below: a band who picks the
   reduced-detail wall and has one record must not get a 1180px sleeve. */
[data-discography="covers"] .grid { grid-template-columns: repeat(auto-fill, minmax(170px, 1fr)); gap: 20px; }
[data-discography="covers"] .card__title { font-size: 16px; }

/* LIST — for a band with two records, where a grid built for twelve reads as
   an accident. Modelled on .date, which is the same problem already solved. */
.reclist { display: flex; flex-direction: column; }
.reclist article {
  display: grid; grid-template-columns: 84px 1fr; gap: 24px; align-items: start;
  padding: 26px 0; border-top: 1px solid var(--ink-hairline);
}
/* 015 T016.4, CORRECTED AFTER THE VERIFY GATE. `span 4` was a hardcoded count of
   the rows the text column happened to have — title, meta, tracks, pressings —
   and a release description is a fifth, at which point the cover silently stops
   spanning the article.

   The first fix made `grid-row: 1 / -1` UNCONDITIONAL, and 015's adversarial
   review measured what that cost: an undescribed, tracklist-less release in the
   `list` presentation went from 216px to 256px tall, with `grid-template-rows`
   changing from `65.5px 25.5px 0px 0px` to `65.5px 113px`. That is a visible
   per-record change on pages that are already published, from a packet whose own
   contract — `_releases.html.erb`'s "a band who changes nothing must see no
   change" — forbids exactly that.

   So the count-free spelling applies to the cards that HAVE the fifth row, and
   `:has()` is what asks that question of the card rather than of the grid: the
   `grid--noted` modifier is on the grid container and says nothing about which
   card carries a note. An undescribed band's list page is byte-identical again. */
.reclist .frame { grid-row: span 4; }
.reclist article:has(.card__note) .frame { grid-row: 1 / -1; }
@media (max-width: 620px) {
  .reclist article { grid-template-columns: 64px 1fr; gap: 16px; }
}

/* ==========================================================================
   nav
   ========================================================================== */

.nav {
  position: fixed; top: 0; left: 0; right: 0; z-index: 40;
  display: flex; align-items: center; justify-content: space-between;
  gap: 24px; padding: 15px var(--gutter);
  background: linear-gradient(180deg,
    color-mix(in srgb, var(--ink-void) 97%, transparent),
    color-mix(in srgb, var(--ink-void) 62%, transparent) 70%,
    transparent);
}
.nav__mark {
  font-family: var(--ui-display); font-weight: 400; font-size: 14px;
  letter-spacing: calc(.42em * var(--ui-tracking)); text-transform: uppercase; color: var(--text-bright);
}
.nav__links {
  display: flex; gap: 20px; flex-wrap: wrap; justify-content: flex-end;
  font-family: var(--ui-mono); font-size: 10px;
  letter-spacing: var(--track-mid); text-transform: uppercase;
}
.nav__links a[aria-current="true"] { color: var(--text-bright); }

/* ==========================================================================
   hero — copy sits on a photograph, so it keeps a fixed light palette
   ========================================================================== */

.hero {
  position: relative; height: 100vh; min-height: 700px;
  display: grid; place-items: center; overflow: hidden;
  border-bottom: 1px solid var(--ink-rule);
}
/* THE OUTER STOP IS DARK INK, NOT THE BAND'S GROUND, and that is the same
   contract line 52 states: "hero copy always sits on a dark photograph".
   --on-image is a FIXED near-white in both tones, so whatever this wash fades
   to is what that copy has to be read against.

   It used to fade to `var(--ink-void)`, which is the band's own ground — dark
   for a dark-tone band, and for a LIGHT-tone band a near-white outer edge with
   near-white copy on top of it. The gradient is anchored at 50% 100%, so the
   outer edge is the TOP of the hero, which is exactly where the origin line
   sits. Measured on a light-tone band: 1.34:1, invisible.

   The two inner stops were already fixed dark ink and always have been; only
   the third followed the theme. For a dark-tone band this is a no-op — their
   --ink-void is rgb(7,8,10) against the 52% stop's rgba(8,8,7,.84). */
.hero__wash {
  position: absolute; inset: 0;
  background: radial-gradient(115% 85% at 50% 100%, rgba(24,20,17,.55) 0%, rgba(8,8,7,.84) 52%, rgb(8,8,7) 100%);
}
.hero__body { position: relative; z-index: 2; text-align: center; padding: 0 24px; color: var(--on-image); }

/* NO PLATE — the common case, and until a band can upload one, the only case.
   --on-image and .hero__wash both exist to make type readable over a dark
   photograph. With nothing behind the hero the wash is a dark smear across the
   band's own ground, and on a light theme it lands under cream copy that is
   then invisible. Both are conditional on there actually being a picture. */
/* Keyed on the state the band CHOSE, not on whether a file happens to be
   attached — data-plate now carries photograph/video/gradient/none.
   The distinction that matters is whether there is a PICTURE behind the hero.
   A gradient is built from the band's own two ends, so copy sits on their own
   ink scale exactly as it does with no plate at all; cream-on-a-photograph
   would be cream-on-cream for a light-palette band. */
.hero[data-plate="none"] .hero__wash,
.hero[data-plate="gradient"] .hero__wash { display: none; }
.hero[data-plate="none"] .hero__body,
.hero[data-plate="gradient"] .hero__body { color: var(--text-bright); }
.hero[data-plate="none"] .hero__origin,
.hero[data-plate="none"] .hero__foot,
.hero[data-plate="gradient"] .hero__origin,
.hero[data-plate="gradient"] .hero__foot { color: var(--text-muted); }
/* THE CLAIM IS NOT HERO FURNITURE, and it is split out of the rule above rather
   than lifting that rule: .hero__origin and .hero__foot carry the genre and the
   scroll cue, which the owner has declared decoration and which stay exactly
   where they are. The band's one sentence is not decoration.

   21px IS NOT LARGE TEXT under WCAG 2.2 §1.4.3 — large is 24px at any weight or
   18.66px at 700, and this is 21px at 400 — so it needs 4.5:1, not 3:1.
   --text-dim (40% ink) gave it 3.31:1. --text-body is the tier this file already
   reserves for words a reader has to read: it is what `body` and every `a` are
   set to, and it measures 8.28:1 on the default palette. */
.hero[data-plate="none"] .hero__claim,
.hero[data-plate="gradient"] .hero__claim { color: var(--text-body); }
/* 60% of --on-image, matching .hero__claim below, and it was 44%. Over a
   photograph there is no ground the sweep can measure, so this is consistency
   rather than proof: the three pieces of hero copy that are not the wordmark
   now share one alpha, and the one that carried the smallest type was the
   faintest of the three. */
.hero__origin {
  margin-bottom: 34px; letter-spacing: calc(.46em * var(--ui-tracking));
  color: color-mix(in srgb, var(--on-image) 60%, transparent);
}
.hero__logo { width: clamp(260px, 44vw, 640px); margin: 0 auto; filter: drop-shadow(0 0 60px color-mix(in srgb, var(--accent) 45%, transparent)); }
.hero__wordmark {
  margin: 0; font-family: var(--ui-display); font-weight: 200;
  font-size: clamp(64px, 17vw, 250px); line-height: .84;
  letter-spacing: calc(.1em * var(--ui-tracking));
  text-transform: uppercase; color: var(--on-image);
  text-shadow: 0 0 70px color-mix(in srgb, var(--accent) 35%, transparent);
}
.hero__stem {
  margin: 30px auto 0; width: 1px; height: 58px;
  background: linear-gradient(180deg, color-mix(in srgb, var(--on-image) 35%, transparent), transparent);
  animation: slowPulse 8s ease-in-out infinite;
}
.hero__claim {
  margin: 26px auto 0; max-width: 26ch;
  font-size: 21px; font-style: italic; line-height: 1.6;
  color: color-mix(in srgb, var(--on-image) 60%, transparent);
}
/* The genre and the scroll cue. 60% of --on-image and it was 32% — see
   .hero__origin: over a picture this is the same unmeasurable case, and 32% of
   a cream over a dark plate is the faintest thing that has ever been on this
   page. With no picture the two rules above take over and put it on
   --text-muted, which IS measured. */
.hero__foot {
  position: absolute; bottom: 20px; left: var(--gutter); right: var(--gutter);
  display: flex; justify-content: space-between;
  font-family: var(--ui-mono); font-size: 9px;
  letter-spacing: calc(.3em * var(--ui-tracking)); text-transform: uppercase;
  color: color-mix(in srgb, var(--on-image) 60%, transparent);
}

/* THE BOOKING ADDRESS IN THE FIRST SCREEN. FR-011a, contract §B.2. One rule for
   all three layouts, because one partial renders it in all three.

   NOT IN A MEDIA QUERY, and not after the high-contrast block at the end of
   this file — both are the trap the theme-css-cascade skill documents. It sits
   here, with the hero copy it belongs to.

   COLOUR IS DECLARED, AND IT USED TO BE `inherit`. §B.2 asks for the
   since-deleted --text-faint, which was already refused for the right reason —
   32% ink into ground was the faintest thing in the hero — but `inherit` was
   the wrong replacement, and the measurement is what says so.

   The old argument was that the element renders in three different grounds
   (monolith's .hero__body, pinned to --on-image because a photograph may be
   behind it; marquee's strip on --text-quiet; hall's nav, then on the retired
   --text-dim) and that each container already resolves its own. Two of the
   three did. HALL'S DID NOT: .hall__nav was a decorative tier, so on that
   layout the most load-bearing string on the page rendered at 3.31:1 —
   measured, not argued. That nav is on --text-muted now, but the reasoning
   stands and the declaration stays: `inherit` fails in the unsafe direction,
   because a fourth container with a quiet colour would make the booking
   address quiet too, silently.

   So the address takes --text-body, the tier `body` and every `a` already use
   for words a reader has to read (8.28:1 on the default palette), and the ONE
   container that must override it says so positively below. That fails safe:
   a new container gets a legible address until someone decides otherwise.

   It has to be SAID: `a { color: var(--text-body) }` (0,0,1) at line 122 means
   an anchor inherits nothing by default. `.hero__booking` is (0,1,0) and beats
   it — it now sets the same value, so the two agree rather than compete;
   `a:hover` is (0,1,1) and still wins, so the site-wide hover affordance
   survives. On hall the address lands inside .hall__nav, so it also picks up
   that nav's ::after tap-target expansion (WCAG 2.5.8) for free.

   text-transform is reset because the marquee strip and the hall nav both
   uppercase their contents, and an address is not a heading — a reader
   comparing what is on screen against what they typed should see the same
   string. */
.hero__booking {
  font-family: var(--ui-mono); font-size: 11px;
  letter-spacing: calc(.12em * var(--ui-tracking));
  text-transform: none; color: var(--text-body);
}
/* Monolith only, and TWO unrelated jobs in one rule because the selector is the
   same one:
     * in the hero the address follows a block, so it needs the air its siblings
       get from their own margins (in the strip and the nav it is a flex item
       and the container's gap already spaces it);
     * this is the one container that must win over --text-body. .hero__body is
       --on-image when a photograph or a video is behind the hero and
       --text-bright when nothing is, and both are already right — an ink-derived
       colour over a band's dark photograph is the case --on-image exists for.
   (0,2,0) beats `a:hover` at (0,1,1), so the hover has to be restated or the
   affordance disappears on this layout only. */
.hero__body .hero__booking { display: inline-block; margin-top: 22px; color: inherit; }
.hero__body .hero__booking:hover { color: var(--text-bright); }

/* ==========================================================================
   controls
   ========================================================================== */

.actions { display: flex; gap: 12px; justify-content: center; flex-wrap: wrap; margin-top: 40px; }
.btn {
  padding: 13px 26px; border: 1px solid color-mix(in srgb, var(--on-image) 22%, transparent);
  background: transparent; color: color-mix(in srgb, var(--on-image) 78%, transparent);
  font-family: var(--ui-mono); font-size: 10px;
  letter-spacing: calc(.3em * var(--ui-tracking)); text-transform: uppercase; cursor: pointer;
  transition: background .25s var(--ease), border-color .25s var(--ease), color .25s var(--ease);
}
.btn:hover { border-color: color-mix(in srgb, var(--on-image) 45%, transparent); color: var(--on-image); }
.btn--primary { border-color: var(--accent-ui); color: var(--on-image); }
.btn--primary:hover { background: rgba(255,255,255,.06); }

.linkrow {
  display: flex; gap: 20px; flex-wrap: wrap;
  font-family: var(--ui-mono); font-size: 10px;
  letter-spacing: var(--track-mid); text-transform: uppercase;
}

/* PLATFORM MARKS ARE OUTSIDE THE THEME ENGINE. FR-016e / D-005.
   003's contract specifies this rule and it had never been written, which made
   shipping a vendored trademark WORSE than shipping none: the mark is an inline
   <svg fill="currentColor"> inside a themed <a>, so without this it takes the
   band's palette — a trademark violation produced because the system is working
   as designed. It is also the condition every monochrome permission in
   app/assets/images/platforms/SOURCES.md depends on; Spotify's guidelines allow
   the icon black-on-light or white-on-dark and nothing else.

   A direct declaration on the element beats what it would otherwise inherit
   from the anchor, so this does not have to out-specify `a { color: … }`, and
   it sets a COLOUR rather than a `--ui-*` variable, so the band's inline :root
   block has nothing to say about it.

   21px, not the contract's 14px: Spotify states a 21px digital minimum for the
   icon used alone, and 14 would have broken the terms the file was vendored
   under. The gap is the exclusion zone — their rule is half the icon height
   clear on every side, and the mark previously sat hard against the link text. */
.platform-mark {
  width: 21px; height: 21px;
  margin-inline-end: 11px;
  vertical-align: -5px;
  color: #000;
}
[data-tone="dark"] .platform-mark { color: #fff; }

/* ==========================================================================
   frames & placeholder slots
   ========================================================================== */

.frame { border: 1px solid var(--ink-rule); overflow: hidden; }
.frame img { width: 100%; height: auto; }
.frame--fill img { height: 100%; object-fit: cover; }

.slot { border: 1px solid var(--ink-rule); display: grid; place-items: center; transition: border-color .25s var(--ease); }
.slot:hover { border-color: var(--ink-edge-lit); }
.slot > span { padding: 14px; text-align: center; }
.slot--hatch-a { background-image: repeating-linear-gradient(45deg,  color-mix(in srgb, var(--ui-ink) 4%, transparent) 0 2px, transparent 2px 9px); }
.slot--hatch-b { background-image: repeating-linear-gradient(115deg, color-mix(in srgb, var(--ui-ink) 4%, transparent) 0 2px, transparent 2px 10px); }
.slot--hatch-c { background-image: repeating-linear-gradient(160deg, color-mix(in srgb, var(--ui-ink) 4%, transparent) 0 2px, transparent 2px 10px); }

/* ==========================================================================
   listen
   ========================================================================== */

.release__title { margin-top: 22px; font-size: 30px; letter-spacing: .02em; color: var(--text-bright); }

.player {
  margin-top: 24px; display: flex; align-items: center; gap: 16px;
  padding: 14px 18px; border: 1px solid var(--ink-rule);
  background: color-mix(in srgb, var(--ui-ink) 2%, transparent);
}
.player__toggle {
  width: 42px; height: 42px; flex: none; display: grid; place-items: center;
  border: 1px solid var(--ink-edge-lit); background: transparent;
  cursor: pointer; color: var(--text-bright); font-size: 12px;
  transition: border-color .25s var(--ease);
}
.player__toggle:hover { border-color: var(--ink-edge-hot); }
.player__body { flex: 1; min-width: 0; }
.player__now {
  font-family: var(--ui-mono); font-size: 10px; letter-spacing: var(--track-tight);
  text-transform: uppercase; color: var(--text-muted);
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.player__rail { margin-top: 10px; height: 1px; background: var(--ink-edge); position: relative; }
.player__fill { position: absolute; inset: 0 auto 0 0; width: 0; background: var(--accent); }

.tracks { list-style: none; margin: 0; padding: 0; }
.tracks button {
  display: grid; grid-template-columns: 34px 1fr; gap: 16px; align-items: baseline;
  width: 100%; padding: 18px 4px; text-align: left;
  background: transparent; border: 0; border-bottom: 1px solid var(--ink-hairline);
  cursor: pointer; transition: background .25s var(--ease);
}
.tracks button:hover { background: color-mix(in srgb, var(--ui-ink) 3%, transparent); }
/* The track number, not an ornament: "01" and "07" are not interchangeable. */
.tracks .num { font-family: var(--ui-mono); font-size: 10px; color: var(--text-muted); }
.tracks .name {
  font-family: var(--ui-display); font-weight: 300; font-size: 22px;
  letter-spacing: .03em; text-transform: uppercase; color: var(--text-quiet);
  transition: color .25s var(--ease);
}
.tracks [aria-current="true"] .name { color: var(--text-bright); }

/* ==========================================================================
   grids
   ========================================================================== */

/* `auto-fill`, NOT `auto-fit`, AND THAT ONE WORD WAS A REPORTED DEFECT.
   2026-08-27, the first real band, with one record: *"its way too big."*

   `auto-fit` COLLAPSES the empty tracks, so the remaining items stretch to fill
   the measure — one release became a single 1180px column and `.frame img`'s
   `width: 100%` turned a 1400px cover into a full-bleed wall taller than the
   viewport. Two releases get 575px each, which is the same defect halved.
   `auto-fill` keeps the tracks, so a card is card-sized whether the band has one
   record or twelve, which is what the discography's own comment claims to be
   about.

   The minimum is the card width and the maximum is one column of it. A band with
   one record now sees a record sleeve, not a billboard. */
.grid { display: grid; gap: 30px; grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); }
.grid--gallery { gap: 18px; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); }
.grid--merch   { gap: 26px; grid-template-columns: repeat(auto-fill, minmax(230px, 1fr)); }

.card__title { margin-top: 16px; font-size: 22px; }
.card__meta  { margin-top: 8px; }
.card__tracks { margin-top: 12px; font-size: 17px; line-height: 1.7; color: var(--text-muted); }

/* The listen link on a release card. Deliberately NOT .btn: that keys off
   --on-image, the fixed light colour for hero copy sitting on a photograph, so
   inside a section on a light-ground theme it would be cream on cream. This
   inherits the section's own ink and leans on the accent instead. */
.card__listen {
  display: inline-block; margin-top: 14px;
  font-family: var(--ui-mono); font-size: 10px;
  letter-spacing: calc(.24em * var(--ui-tracking)); text-transform: uppercase;
  color: var(--accent-ui); border-bottom: 1px solid transparent; padding-bottom: 2px;
  transition: border-color .25s var(--ease), color .25s var(--ease);
}
.card__listen:hover { color: var(--text-bright); border-bottom-color: var(--accent-ui); }

/* 015 · THE RELEASE DESCRIPTION. Four rules, and the ORDER of the last two is
   load-bearing.

   The note takes .card__tracks' secondary-prose scale — 17px at 1.7 — and the
   section's own muted ink, never a fixed grey: a light-ground theme is what
   press.css's print block already documents having got wrong. `overflow-wrap`
   is the answer this repo gives twice already, in `.pk__pairs dd` and
   `.pk__row-main`, to 600 characters pasted as one token with no spaces. */
/* The lockup shares every declaration and is deliberately NOT reached by the
   clamp below, which keys on `[data-discography="grid"] .card__note`. A copy of
   these four values for `.lockup__note` was the second-copy-of-a-value shape
   §VII's third sub-rule names — found by 015's verify gate — so it is a shared
   selector list instead. */
.card__note,
.lockup__note {
  margin-top: 12px; font-size: 17px; line-height: 1.7;
  color: var(--text-muted); overflow-wrap: anywhere;
}

/* THE CLAMP IS VISUAL AND THE WHOLE TEXT SHIPS (015 FR-007). Nothing is cut in
   Ruby: a `truncate` in the partial renders beautifully and deletes text from
   the document a crawler and a screen reader receive, and it cannot deliver
   FR-008 anyway because a character count is not a line count — the same string
   is a different number of lines in every column width.

   Three lines, and the arithmetic: at 17px/1.7 that is 28.9px a line and ~87px
   of block; at the grid's minimum column (250px) three lines carry roughly
   ninety characters, which is a sentence or two. `--note-lines` is declared ONCE
   because the print override below needs the same number and a second copy of it
   is §VII's third sub-rule waiting to happen.

   Keyed on `[data-discography="grid"]`, which is why `list` and the campaign
   lockup render in full without a rule of their own. */
[data-discography="grid"] .card__note {
  --note-lines: 3;
  display: -webkit-box; -webkit-box-orient: vertical;
  -webkit-line-clamp: var(--note-lines); line-clamp: var(--note-lines);
  overflow: hidden;
}

/* PAPER UN-CLAMPS (015 FR-011), and this is theme.css's FIRST @media print
   block — in the file that is §VII's own worked example of a print rule that was
   visibly present and had never once applied.

   Three things make it apply. It uses the SAME selector at the SAME specificity
   (0,2,0) as the clamp above; it is written IMMEDIATELY AFTER it, because
   document order decides at equal specificity and `@media` contributes nothing
   to specificity (see .claude/skills/theme-css-cascade); and it resets the clamp
   AND its companions — a clamp left half-undone clips at a line boundary with no
   ellipsis, which reads as the end of the sentence.

   A clamp is how a grid fits a screen. Paper does not scroll, so a clamp on
   paper is text deleted, found by a booker at a venue. */
@media print {
  [data-discography="grid"] .card__note {
    display: block;
    -webkit-line-clamp: none; line-clamp: none;
    overflow: visible;
  }

  /* T022 said this block resets the clamp AND the flex reservation, and the
     first cut reset only the clamp — a task written and not done, found at the
     verify gate. Practically inert (a printed article is auto-height), and it is
     here because the reservation exists to level cards against a row height that
     paper does not have. */
  [data-discography="grid"] .grid--noted article { display: block; }
  [data-discography="grid"] .grid--noted .card__listen { margin-top: 14px; }
}

/* THE RESERVATION, SCOPED TO THE MODIFIER AND TO NOTHING ELSE (015 FR-008).
   `.grid` is `repeat(auto-fill, minmax(250px, 1fr))` with automatic row heights,
   so a described card makes its row taller and every other card's Listen link
   stays high against it. Grid items already stretch to the row height, so
   bottoming the link is enough to level them.

   The scoping IS the requirement rather than tidiness: unscoped, this changes
   the layout of every existing band's grid — a card with no tracklist would drop
   its link to the bottom of a row set by a card that has one — and
   `_releases.html.erb` promises that a band who changes nothing sees no change.
   C9 asserts the class is absent for an undescribed band, which is what makes
   the scoping checkable rather than merely intended. */
[data-discography="grid"] .grid--noted article { display: flex; flex-direction: column; }
[data-discography="grid"] .grid--noted .card__listen { margin-top: auto; }



.merch__name { margin-top: 14px; font-size: 18px; letter-spacing: .04em; }
.merch__foot {
  margin-top: 8px; display: flex; justify-content: space-between; gap: 12px;
  font-family: var(--ui-mono); font-size: 10px;
  letter-spacing: var(--track-tight); text-transform: uppercase; color: var(--text-muted);
}

/* ==========================================================================
   live
   ========================================================================== */

/* Each .date is its OWN grid container, so tracks are sized per row and not
   shared between them. With `auto` as the last track, a row carrying "Cancelled"
   sized that column to ~90px while a row with no ticket link sized it to 0 —
   so the city and venue columns sat at different x-positions from row to row
   and the list appeared to shift. A fixed final track makes every row reserve
   the same space whether or not it has anything to say. */
.date {
  display: grid; grid-template-columns: 96px 1fr 1fr 104px; gap: 22px; align-items: baseline;
  padding: 21px 4px; border-top: 1px solid var(--ink-hairline);
}
/* --text-body, not --text-dim. A show date is the row's primary key for the
   reader this product is built around, and 11px mono at 40% ink measured
   3.18:1 under `sunken`. It is now louder than .date__venue, which is the
   correct order for a tour list and was not the order before. */
.date__when { font-family: var(--ui-mono); font-size: 11px; letter-spacing: .16em; color: var(--text-body); }
.date__city {
  font-family: var(--ui-display); font-weight: 300; font-size: 21px;
  letter-spacing: .05em; text-transform: uppercase; color: var(--text-quiet);
}
.date__venue { font-size: 17px; font-style: italic; color: var(--text-muted); }
.date__cta {
  font-family: var(--ui-mono); font-size: 10px; letter-spacing: calc(.24em * var(--ui-tracking));
  text-transform: uppercase; color: var(--accent-ui); text-align: right;
}
/* inherit, so the anchor takes the row's state colour rather than the global
   link colour — but the hover has to be restated, because this rule and
   `a:hover` have the same specificity and this one is later in the source. */
.date__cta a { color: inherit; }
.date__cta a:hover { color: var(--text-bright); }
/* A date that is not happening is stated, not advertised — but it is STATED.
   "Cancelled" and "Sold out" are the two strings on a tour list that change a
   reader's plans, and at --text-ghost they were the faintest text in the row.
   They stay quieter than the accent-coloured "Tickets" beside them by being a
   neutral rather than the accent, which is the distinction that carries the
   meaning; being illegible was never part of it. */
.date__cta[data-state="cancelled"],
.date__cta[data-state="sold_out"] { color: var(--text-muted); }
.date__cta[data-state="cancelled"] { text-decoration: line-through; }
.live__foot {
  border-top: 1px solid var(--ink-hairline); padding-top: 20px;
  font-family: var(--ui-mono); font-size: 10px;
  letter-spacing: calc(.22em * var(--ui-tracking)); text-transform: uppercase; color: var(--text-muted);
}

@media (max-width: 720px) {
  /* Fixed here too. The mobile branch had the same auto-track bug: a row
     saying "Cancelled" narrowed the text column by ~77px, so the venue could
     wrap to a second line where its neighbours did not. */
  .date { grid-template-columns: 1fr 92px; gap: 6px 16px; }
  .date__when { grid-column: 1; }
  .date__cta  { grid-row: 1 / span 2; grid-column: 2; align-self: center; }
  .date__city, .date__venue { grid-column: 1; }
}

/* ==========================================================================
   strip, quote, forms, footer
   ========================================================================== */

.quote { margin: 0; font-size: 30px; font-style: italic; line-height: 1.55; color: var(--text-quiet); text-wrap: pretty; }
.quote__source { margin-top: 16px; letter-spacing: calc(.3em * var(--ui-tracking)); }

.subscribe { display: flex; flex-wrap: wrap; border: 1px solid var(--ink-edge); max-width: 460px; }
.subscribe input {
  flex: 1; min-width: 180px; padding: 15px 16px;
  background: transparent; border: 0; outline: none;
  font-family: var(--ui-mono); font-size: 12px; color: var(--text);
}
.subscribe button {
  padding: 15px 22px; background: transparent; border: 0;
  border-left: 1px solid var(--ink-edge); cursor: pointer;
  font-family: var(--ui-mono); font-size: 10px;
  letter-spacing: calc(.3em * var(--ui-tracking)); text-transform: uppercase; color: var(--text-bright);
  transition: background .25s var(--ease);
}
.subscribe button:hover { background: color-mix(in srgb, var(--ui-ink) 6%, transparent); }
.subscribe__status {
  margin-top: 14px; min-height: 18px;
  font-family: var(--ui-mono); font-size: 10px;
  letter-spacing: calc(.22em * var(--ui-tracking)); text-transform: uppercase; color: var(--accent-ui);
}

.contact { display: grid; gap: 24px; }
/* "BOOKING" / "PRESS" / "LABEL" ARE THE SIGNPOSTS ON THE ONE SECTION A PROMOTER
   CAME FOR, and on the since-deleted --text-faint (32% ink) they measured
   2.40:1 — the second worst number on the page. The label and its value now share a colour; the
   hierarchy between them is carried by size, family, case and tracking, which
   is where it already was. Colour was carrying nothing here except the
   difference between a legible label and an illegible one. */
.contact__label {
  font-family: var(--ui-mono); font-size: 10px; letter-spacing: calc(.24em * var(--ui-tracking));
  text-transform: uppercase; color: var(--text-body); margin-bottom: 8px;
}
.contact__value { font-family: var(--ui-display); font-weight: 300; font-size: 21px; letter-spacing: .04em; }

/* The band's name, where they are from, and who owns the record. At
   --text-ghost that measured 2.02:1, and it is the one line on the page that
   says whose site this is. */
.footer {
  position: relative; z-index: 2; padding: 28px var(--gutter);
  border-top: 1px solid var(--ink-rule);
  display: flex; justify-content: space-between; gap: 20px; flex-wrap: wrap;
  font-family: var(--ui-mono); font-size: 9px;
  letter-spacing: calc(.3em * var(--ui-tracking)); text-transform: uppercase; color: var(--text-muted);
}
/* The footer's two links, now on --text-body rather than --text-muted. The
   footer's own spans just came UP to --text-muted, and a link that is the same
   colour as the caption beside it is a link only to whoever already knows it is
   one. The underline is not enough on its own here: everything in this strip is
   9px uppercase mono, so the rank has to be restated. --text-body is what `a`
   is set to everywhere else on the page, so this stops being an exception. */
.footer__press { color: var(--text-body); text-decoration: none; border-bottom: 1px solid var(--ink-edge); }
.footer__press:hover, .footer__press:focus-visible { color: var(--text-bright); border-bottom-color: currentColor; }

.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}

/* ==========================================================================
   LAYOUTS — four different arrangements, not four palettes.
   The band's theme picks one. They must not read as the same page.
   ========================================================================== */

/* shared section furniture -------------------------------------------------- */
.band-section { position: relative; z-index: 2; padding: var(--pad-y) var(--gutter); border-bottom: 1px solid var(--ink-rule); }
.prose { color: var(--text-muted); max-width: 62ch; }
.prose p { margin: 0 0 1em; }
/* The person is the loud thing and the instrument is the annotation. This had
   it backwards: .roster set 11px mono UPPERCASE for the whole list, the name
   overrode only the family — so a band's display face rendered at 11px in caps,
   which is mush in a blackletter or a condensed grotesque — and the role was
   14px, larger than the person playing it.
   The name is now display-face at a size that face survives, sentence case, and
   the role is small mono beside it. Same relationship as .contact__value, which
   is the existing "a display-face value at a legible size" pattern. */
.roster {
  list-style: none; display: grid; gap: 14px;
  margin: 40px 0 0; padding: 26px 0 0; border-top: 1px solid var(--ink-rule);
}
.roster li { display: flex; flex-wrap: wrap; align-items: baseline; gap: 6px 14px; }
.roster__name {
  font-family: var(--ui-display); font-weight: 300;
  /* Flat, not a vw clamp: in hall the line-up sits in a ~330px column while the
     viewport is 1440px, so a viewport-relative size resolves to its maximum
     inside a narrow box. 21px matches .contact__value, which is the same
     "display face at a legible size" job. */
  font-size: 21px; line-height: 1.15;
  letter-spacing: calc(.03em * var(--ui-tracking)); color: var(--text-quiet);
}
/* No em-dash: the gap separates them, and a dash between two different faces
   sits on neither baseline.

   THE INSTRUMENT IS NOT DECORATION. At --text-ghost this was 1.94:1, the worst
   number on the page, and "guitars" cannot be swapped for "drums" without
   changing what the line says. It stays subordinate to the name by every means
   it already had — 10px against 21px, mono against the band's display face,
   uppercase, .22em of tracking — and by none that require it to be unreadable.
   The name is --text-quiet (10.24:1) and this is --text-muted (5.66:1), so
   there is still a full rung of the scale between them. */
.roster__role {
  font-family: var(--ui-mono); font-size: 10px;
  letter-spacing: calc(.22em * var(--ui-tracking));
  text-transform: uppercase; color: var(--text-muted);
}
/* The logo is a FILL THROUGH the stored mask, not the mask drawn as a picture.
   currentColor means it takes the ink of wherever it sits — light theme, dark
   theme, or over a photograph — from one rule, which is the whole reason the
   mask is stored single-channel. --logo-ratio carries the artwork's own shape,
   which an <img> would have supplied for free and a masked box does not. */
.mark--image {
  display: block; width: clamp(220px, 38vw, 560px);
  aspect-ratio: var(--logo-ratio, 5 / 1);
  background-color: currentColor;
  /* LUMINANCE, via the SHORTHAND. Two traps in one line:
     1. The stored mask is a single-channel PNG with NO alpha channel, and CSS
        masks default to mask-mode: alpha — which reads every pixel as fully
        opaque and paints a solid rectangle. White ink on black paper only means
        anything under luminance.
     2. The `mask-mode` longhand does not survive alongside the `-webkit-mask-*`
        longhands: the aliases reset it back to match-source. Setting the mode
        inside the shorthand is what actually sticks. Verified in the browser. */
  -webkit-mask: var(--logo-mask) luminance center / contain no-repeat;
  mask: var(--logo-mask) luminance center / contain no-repeat;
  filter: drop-shadow(0 0 60px color-mix(in srgb, var(--accent) 45%, transparent));
}
/* 2026-08-27 · THE BAND'S FILE AS UPLOADED, for `themes.logo_finish = "exact"`.
   A real <img>, so NONE of the rules above apply: no mask, no currentColor fill,
   no --logo-ratio, because an <img> carries its own intrinsic shape. It shares
   only the WIDTH with .mark--image, and it shares it by being named alongside it
   in the per-layout rules below rather than by inheriting anything — the two
   render by different mechanisms and the day one changes the other must not move.
   No drop-shadow: the accent glow reads as a halo behind letterforms and as a
   smudge behind a full-colour illustration with its own background. */
.mark--exact { display: block; width: clamp(220px, 38vw, 560px); height: auto; max-width: 100%; }

.mark--word { font-family: var(--ui-display); font-weight: 200; text-transform: uppercase; letter-spacing: calc(.1em * var(--ui-tracking)); }

:root[data-align="left"] .hero__body,
:root[data-align="left"] .wrap { text-align: left; }
:root[data-align="left"] .mark--image,
:root[data-align="left"] .mark--exact { margin-left: 0; }

:root[data-frame="bare"]  .frame { border: 0; }
:root[data-frame="boxed"] .band-section > .wrap { border: 1px solid var(--ink-rule); padding: var(--gutter); }

/* ── MONOLITH — the cathedral ───────────────────────────────────────────── */
:root[data-layout="monolith"] .hero {
  /* svh, not vh: vh measures the LARGE viewport, so on load with the address
     bar visible the hero overflows and clips. And 72 rather than 100 so the
     next section peeks — a full-height hero spends the highest-attention
     screen on a logo and a tagline. */
  min-height: 72svh;
  display: grid;
  place-items: center;
}
:root[data-layout="monolith"] .hero--none { min-height: 40vh; }
:root[data-layout="monolith"] .mark--word { font-size: clamp(52px, 15vw, 210px); line-height: .86; }

/* ── MARQUEE — campaign first ─────────────────────────────────────────────────
   Announcement strip, centre-split masthead, full-bleed plate. The most-attested
   real architecture in the survey: what is on offer NOW reads before anything
   else, and the mark anchors the centre of the masthead rather than sitting at
   the top of a stack.

   Distinct from monolith in structure, not decoration: monolith centres one
   column down a single axis; this splits navigation around the mark and leads
   with a sentence rather than an identity. */
:root[data-layout="marquee"] .marquee__strip {
  display: flex; align-items: center; justify-content: center; gap: 20px;
  flex-wrap: wrap; padding: 11px var(--gutter);
  border-bottom: 1px solid var(--ink-rule);
  background: color-mix(in srgb, var(--ui-ink) 3%, transparent);
  font-family: var(--ui-mono); font-size: 10.5px;
  letter-spacing: var(--track-tight); text-transform: uppercase;
  color: var(--text-quiet); text-align: center;
}
:root[data-layout="marquee"] .marquee__strip p { margin: 0; }
/* The genre, in the strip that leads the page. 1.94:1 at --text-ghost, against
   the strip's own 3%-ink background. It stays quieter than the sentence beside
   it — that is --text-quiet at 10.24:1 — by a rung of the scale rather than by
   being invisible. */
:root[data-layout="marquee"] .marquee__tags { color: var(--text-muted); }

:root[data-layout="marquee"] .marquee__masthead {
  display: grid; grid-template-columns: 1fr auto 1fr; align-items: center;
  gap: 24px; padding: 22px var(--gutter);
}
/* The colour is inherited by the bare <span>s only — the anchors take
   `a { color: var(--text-body) }`, which is why the section links measured 7.94
   and the origin and formation year beside them measured 3.31. A band's origin
   and the year they formed are the two facts a booker checks first; they are
   not chrome. */
:root[data-layout="marquee"] .marquee__nav {
  display: flex; gap: 22px; flex-wrap: wrap;
  font-family: var(--ui-mono); font-size: 10px;
  letter-spacing: var(--track-mid); text-transform: uppercase; color: var(--text-muted);
}
:root[data-layout="marquee"] .marquee__nav--end { justify-content: flex-end; }
:root[data-layout="marquee"] .marquee__mark { display: grid; place-items: center; }
:root[data-layout="marquee"] .marquee__mark .mark--image,
:root[data-layout="marquee"] .marquee__mark .mark--exact { width: clamp(150px, 20vw, 280px); }
:root[data-layout="marquee"] .marquee__mark .mark--word { font-size: clamp(24px, 3.4vw, 46px); }

:root[data-layout="marquee"] .marquee__plate {
  height: 62svh; min-height: 320px;
  border-block: 1px solid var(--ink-rule);
}

@media (max-width: 720px) {
  /* The split masthead has nowhere to split. Stack it and keep the mark first —
     on a phone the identity is what orients you, not the anchors. */
  :root[data-layout="marquee"] .marquee__masthead { grid-template-columns: 1fr; justify-items: center; gap: 14px; }
  :root[data-layout="marquee"] .marquee__mark { order: -1; }
  :root[data-layout="marquee"] .marquee__nav--end { justify-content: center; }
  :root[data-layout="marquee"] .marquee__plate { height: 44svh; }
}

/* ── HALL — a plate hung in a dark room ──────────────────────────────────────
   From Enslaved: mark left, navigation in one row beside it, and the image
   CONTAINED with a wide black margin rather than bleeding to the edges. The
   space around the picture is the composition — it reads as something hung
   rather than something published. */
:root[data-layout="hall"] .hall__bar {
  display: flex; align-items: center; gap: 40px; flex-wrap: wrap;
  padding: 26px var(--gutter);
  max-width: 1240px; margin: 0 auto;
}
:root[data-layout="hall"] .hall__mark .mark--image { width: clamp(90px, 12vw, 150px); filter: none; }
:root[data-layout="hall"] .hall__mark .mark--exact { width: clamp(90px, 12vw, 150px); }
:root[data-layout="hall"] .hall__mark .mark--word { font-size: clamp(22px, 2.6vw, 34px); }
/* Same as marquee's nav: the anchors carry their own --text-body and only the
   origin <span> inherits this. */
:root[data-layout="hall"] .hall__nav {
  display: flex; gap: 30px; flex-wrap: wrap; margin-left: auto;
  font-family: var(--ui-mono); font-size: 11px;
  letter-spacing: var(--track-mid); text-transform: uppercase; color: var(--text-muted);
}
/* FULL-BLEED, on the owner's call. This was a contained panel with a wide black
   margin — the Enslaved "hung, not published" idea. In practice the margin read
   as dead space rather than as gallery wall, and the picture floated.
   Consequence to be honest about: the contained plate was hall's ONLY structural
   difference from monolith and marquee below the masthead, so hall now needs a
   different distinguishing move or the three layouts are two. */
:root[data-layout="hall"] .hall__plate { margin: 26px 0 0; }
:root[data-layout="hall"] .hall__plate .plate { height: 62svh; }
:root[data-layout="hall"] .hall__claim {
  max-width: 46ch; margin: 44px auto 0; padding: 0 var(--gutter);
  text-align: center; font-size: 20px; font-style: italic;
  line-height: 1.6; color: var(--text-muted);
}
/* Bound to --measure rather than hardcoded, so an emphasis rule reaches it.
   The old `max-width: 1080px` sat at specificity 0-2-0 and an emphasis rule at
   0-1-0 would lose to it — doing nothing on one of three layouts, silently. */
:root[data-layout="hall"] { --measure: 1080px; }
:root[data-layout="hall"] .hall__section {
  max-width: var(--measure); margin: 0 auto; padding: var(--pad-y) var(--gutter) 0;
}
:root[data-layout="hall"] .hall__section:last-of-type { padding-bottom: var(--pad-y); }
:root[data-layout="hall"] .footer { max-width: 1080px; margin: 0 auto; }
@media (max-width: 720px) {
  :root[data-layout="hall"] .hall__bar { gap: 16px; }
  :root[data-layout="hall"] .hall__nav { margin-left: 0; gap: 18px; }
}

/* ---------------------------------------------------------------------------
   TAP TARGETS — WCAG 2.2 AA 2.5.8, WITHOUT MOVING ANYTHING.

   Measured at a 390px viewport: the section nav renders at 18–19px tall on
   marquee and hall, and the footer's press-kit link at 17px. All are
   `display: block` inside a nav or a footer, so none of them takes 2.5.8's
   inline-within-a-sentence exemption. ("Listen" is 21px but does sit inside
   prose, so it is left alone rather than leant on either way.)

   THE HIT AREA GROWS AND THE LAYOUT DOES NOT. Padding or min-height would both
   work and both would push the masthead around — this type is set tight on
   purpose and the gap between the nav and the wordmark is a design decision, not
   slack. An absolutely-positioned ::after inside a relatively-positioned anchor
   extends the target a browser hit-tests against while contributing nothing to
   layout. `inset: -4px 0` takes 17px to 25px and 18px to 26px.

   aria-hidden is not needed: a pseudo-element has no content and never reaches
   the accessibility tree.
   --------------------------------------------------------------------------- */
:root[data-layout="marquee"] .marquee__nav a,
:root[data-layout="hall"] .hall__nav a,
.footer__press {
  position: relative;
}
:root[data-layout="marquee"] .marquee__nav a::after,
:root[data-layout="hall"] .hall__nav a::after,
.footer__press::after {
  content: ""; position: absolute; inset: -4px 0;
}

/* ---------------------------------------------------------------------------
   HIGH CONTRAST — LAST IN THE FILE, AND THAT IS THE IMPLEMENTATION.

   The scene values illegibility; the OS already knows who can't afford it.
   Honour the signal silently — an explicit "readable mode" button reads as an
   apology.

   This block used to sit near the top, where it did NOTHING AT ALL, on every
   band, for its whole life. @media contributes no specificity and opens no
   cascade layer, so `.meta { color: var(--text) }` inside a media query is a
   plain (0,1,0) rule — and `.meta` is declared again at its own home further
   down this same file, at the same specificity. Later wins. Every colour in
   here lost to the rule it was written to override, and the stylesheet still
   contained exactly what a spec would grep for. Verified by driving a browser
   with the preference on: the computed colour of every element below was
   identical with and without it.

   Two separate fixes, because there are two separate losses:

   1. THE CLASS RULES lost to later rules in this file. Fixed by document
      order — nothing is declared after this block.
   2. THE --ui-* VARIABLES lost to the per-band `:root{…}` that the layout emits
      AFTER this stylesheet, which no amount of reordering inside this file can
      beat. `html:root` is (0,1,1) and wins on specificity regardless of order.
      It may only be used INSIDE this media query: on the open page it would
      flatten every band's palette. See the theme-css-cascade skill.
   --------------------------------------------------------------------------- */
@media (prefers-contrast: more) {
  html:root { --ui-grain: 0; --ui-texture: 0; }
  /* .ambient joins them per 010 FR-008. Safe as a bare class here for the
     reason the header above gives: nothing is declared after this block, and
     no other rule in this file declares `display` on .ambient at all — the
     layer's own rule sets position, opacity and blend and stops there, and the
     tone gate sets `display: none` too, so the two agree rather than compete. */
  .haze, .scanlines, .grain, .ambient { display: none; }
  /* 011 FR — the copier reverts here, and this is NOT the same list as the
     forced-colours one above, which is the point the .ambient comment makes two
     lines up. A reader who asked for more contrast has asked for legibility, and
     the copier's crush destroys detail to buy contrast it does not need: the
     midtone is gone, so a face in shadow is a black shape.

     SPECIFICITY, because `html:root` alone is not enough here and that is worth
     spelling out. `--treat` is set by `:root[data-treatment="copier"]` at
     (0,2,0). `html:root` is (0,1,1) — it beats the per-band inline `:root{…}`
     at (0,1,0), which is what it was introduced for, and it LOSES to the
     attribute-scoped treatment rule. So the revert is written on the elements
     with the attribute in the selector, at (0,2,2), and it keeps
     `brightness(var(--plate-dim))` rather than dropping the filter wholesale —
     the plate's dimming is a separate control and this preference says nothing
     about it. */
  html:root[data-treatment="copier"] .plate img,
  html:root[data-treatment="copier"] .plate video { filter: brightness(var(--plate-dim)); }
  html:root[data-treatment="copier"] .plate::after { display: none; }
  body { color: var(--text); }

  /* The faint tiers of the ink scale are an aesthetic, and the OS has just said
     this reader cannot afford one. Everything carrying words goes to the
     readable end of its own scale. Hero copy stays on --on-image: the wash and
     the photograph beneath it are still there, so it is a different ground. */
  .hero__origin, .hero__claim, .hero__foot { color: var(--on-image); }
  /* MATCHED, NOT RAISED, for the two elements this file lifted to --text-body
     off the decorative tiers. Both are declared outside this block at a
     specificity a bare class cannot reach from here, so both would otherwise
     sit out the high-contrast lift — pattern 3, in the block written to
     document pattern 3.
       * .hero__claim with no picture behind it is `.hero[data-plate="…"]
         .hero__claim`, (0,2,0). --text and not --on-image: there is no
         photograph in those two states, so the copy is on the band's own
         ground and a fixed cream would be cream-on-cream for a light palette.
       * .hero__booking is (0,1,0) and this block is last in the file, so a
         bare class is enough. `.hero__body .hero__booking` at (0,2,0) still
         wins on monolith, which is the intended answer: that container is
         --on-image over a picture and --text-bright without one, and both are
         already at or above the readable end. */
  .hero[data-plate="none"] .hero__claim,
  .hero[data-plate="gradient"] .hero__claim { color: var(--text); }
  .hero__booking,
  .meta, .caption, .section-title, .lede, .roster, .footer,
  .live__foot, .contact__label, .merch__foot, .lockup__formats, .tracks .name,
  .card__meta, .card__tracks, .date__when, .date__venue, .date__city,
  .footer__press,
  /* The quiet elements are the CHILDREN, which set their own colour, so lifting
     the parent never reached them: under high contrast the instrument and the
     word "Cancelled" stayed on the faintest tier of the scale. They are all on
     --text-muted now rather than on the retired --text-ghost, but the
     specificity argument is unchanged and so is this list — `.date__cta` at
     (0,1,0) still cannot reach `.date__cta[data-state="sold_out"]` at (0,2,0),
     and `.tracks .num` at (0,2,0) needs its own matching selector below. */
  .roster__name, .roster__role, .date__cta, .date__cta[data-state] { color: var(--text); }
  .tracks .num { color: var(--text); }

  /* The layout chrome is declared as `:root[data-layout="…"] .x`, which is
     (0,2,1) — a bare class here is (0,1,0) and loses to it even from the last
     line of the file. Matched, not raised: the same shape the rules it
     overrides already use. The marquee strip and its nav carry the band's
     genre, origin and formation year, which is the whole descriptor a reader
     came for. */
  :root[data-layout="marquee"] .marquee__strip,
  :root[data-layout="marquee"] .marquee__tags,
  :root[data-layout="marquee"] .marquee__nav { color: var(--text); }
  :root[data-layout="monolith"] .hero__foot { color: var(--on-image); }
  /* AND ONLY WHEN THERE IS A PICTURE. The rule above is (0,2,1) and beat the
     plate-scoped rules at (0,2,0), so with no photograph behind the hero this
     put a fixed cream on the band's own ground — invisible on a light palette,
     which is the precise failure --on-image exists to avoid everywhere else in
     this file. (0,3,1) restores the distinction the rest of the hero already
     makes. Only reachable on monolith, which is the only layout that renders
     .hero__foot at all. */
  :root[data-layout="monolith"] .hero[data-plate="none"] .hero__foot,
  :root[data-layout="monolith"] .hero[data-plate="gradient"] .hero__foot { color: var(--text); }
  /* --text, NOT --on-image, and it was --on-image. The hall bar is above the
     plate, not on it: nothing paints a picture behind that nav, so the fixed
     cream was cream on the band's ground — the same defect as the hero rule
     above, on the layout where the string in question is the band's origin. */
  :root[data-layout="hall"] .hall__nav { color: var(--text); }
}
