/* ============================================================================
   S1c viewer — layout and geometry.
   ----------------------------------------------------------------------------
   TWO TOKEN PLANES, and keeping them apart is the whole design of this file.

   --t-*  TENANT plane.  Injected at runtime by src/theme.mjs from
          GET /v1/config/theme. NOT declared here. Rebranding a tenant changes
          these and touches no code — which is the white-label requirement.

   --w-*  WEB GEOMETRY plane.  Tenant-INVARIANT. Type ramp, space scale, focus
          metrics and motion, transcribed from
          docs/design/blck-web-concepts/tokens/blck-web.css (design lane, 2026-08-21).
          test/token-drift.test.mjs fails if any value below stops matching that
          file, so this is a VERIFIED copy, not a fork.

          🔴 THAT SENTENCE WAS FALSE UNTIL 2026-08-22, and it is recorded because it
          is the same defect the review round found three times elsewhere: a comment
          asserting a check that was not happening. The drift test pinned TWENTY
          hand-listed pairs while sixteen --w- tokens with a --blck- counterpart —
          every font weight, most of the space scale, both radii, the whole motion
          set — were pinned by nothing. The list is now DERIVED from the two files
          rather than typed, and the five tokens with no counterpart are named in the
          test with a reason each. A hand-maintained list of what is checked is one
          more thing that falls behind quietly.

   ⚠️ WHY THE WEB RAMP IS NOT IN THE BRAND KIT, AND THIS IS A FINDING.
   docs/design/brand/tokens/blck.tokens.json's LAYOUT / FOCUS / type-size blocks
   are tagged INHERITED from the DropTV TV token set — the kit says so in its own
   `unknowns`: "BLCK's 10-foot / TV layout still does not exist to be measured."
   typography.sizes.body is 32 and blck.css heads its output "Sizes are design-dp
   at referenceWidthDp 1920". Bridging that into a browser sets body copy at 32px.
   So the generator's tokens supply COLOUR (measured, correct, tenant-owned) and
   supply NO WEB GEOMETRY. See README §Findings — F-3.

   NO RAW HEX BELOW except inside the one fenced exemption at the foot of this
   file, which must not use tenant colour by definition.
   test/token-drift.test.mjs enforces exactly that.

   🔴 THE FENCE IS AN EXACT MARKER LINE, AND THAT IS NOT PEDANTRY. The first
   version of the lint opened its exemption window on any line MENTIONING the
   marker — including this paragraph — which silently exempted the entire
   stylesheet. Mutation M9 (a raw #hex typed into .btn-primary) SURVIVED a green
   suite because of it. The lint was wrong before it was right, and the only
   reason anybody knows that is that the mutation was run.
   ============================================================================ */

:root {
  /* TYPE — one family, fluid between 390px and 1400px. blck-web.css §4. */
  --w-fs-display:  clamp(2.50rem, 1.30rem + 4.90vw, 4.50rem);
  --w-fs-h1:       clamp(2.00rem, 1.40rem + 2.45vw, 3.00rem);
  --w-fs-h2:       clamp(1.625rem, 1.25rem + 1.53vw, 2.25rem);
  --w-fs-h3:       clamp(1.25rem, 1.10rem + 0.61vw, 1.50rem);
  --w-fs-body-lg:  1.125rem;
  --w-fs-body:     1rem;
  --w-fs-label:    0.875rem;
  --w-fs-caption:  0.8125rem;
  --w-fs-eyebrow:  0.75rem;

  --w-lh-tight:  1.05;
  --w-lh-snug:   1.2;
  --w-lh-normal: 1.5;

  /* blck-web.css §4. Bridged as GEOMETRY, not as colour: the tenant plane owns the
     font FAMILY, this plane owns how that family is set. Measured `normal` before
     this line existed, so the kit's disambiguated l/I/1 and slashed zero — argued
     there as "a real legibility gain on a page full of prices, SKUs and episode
     numbers" — were declared in the kit and reached nothing. */
  --w-font-features: "cv05" 1, "cv08" 1, "zero" 1;

  --w-tracking-display: -0.03em;
  --w-tracking-heading: -0.015em;
  --w-tracking-eyebrow:  0.1em;

  --w-fw-medium:   500;
  --w-fw-semibold: 600;
  --w-fw-bold:     700;
  --w-fw-black:    800;

  /* SPACE — blck-web.css §5. Ten steps, nothing between them. */
  --w-space-1: 4px;  --w-space-2: 8px;   --w-space-3: 12px;
  --w-space-4: 16px; --w-space-5: 24px;  --w-space-6: 32px;
  --w-space-7: 48px; --w-space-8: 64px;  --w-space-9: 96px;
  --w-section-gap: clamp(3rem, 1.8rem + 4.9vw, 6rem);

  --w-radius-sm: 4px;
  --w-radius-md: 8px;

  /* FOCUS — 3px + 2px offset. NOT the TV token's 9dp ring: that figure is argued
     from angular subtense at ten feet and evaporates at arm's length. The
     two-tone construction is kept, because it is what keeps the indicator
     visible if a tenant's accent is ever re-skinned darker. */
  --w-focus-width: 3px;
  --w-focus-offset: 2px;

  --w-dur-fast: 120ms;
  --w-dur-base: 200ms;
  --w-ease-out: cubic-bezier(0.20, 0.80, 0.20, 1);

  --w-shelf-card-height: 168px;
  --w-max-width: 1400px;
}

/* ⭐ THE FONT IS SERVED, NOT MERELY NAMED — AND THAT SENTENCE IS THE WHOLE FINDING.
   This app declared `Inter` in its tokens, bridged the NAME over the wire, and
   shipped no font resource of any kind: zero @font-face rules, no link tag, nothing
   in `assets/`. Every screen, on every route, in all eight committed evidence
   captures, rendered in Helvetica Neue. Measured in the running page: asking the
   canvas for `Inter` returned 335.5078125px — byte-identical to asking for a family
   that does not exist — while the live stack returned 360.0398559px, byte-identical
   to Helvetica Neue.

   Nothing caught it because nothing looked at a glyph. The drift test compared the
   STRING "Inter" to the STRING "Inter" and passed; `document.fonts.check('40px
   Inter')` returns TRUE when the request can be satisfied by FALLBACK. A string
   cannot be compared to a glyph.

   `docs/design/blck-web-concepts/tokens/blck-web.css:73-76` anticipated exactly
   this, named the licence and specified the delivery: "Inter is served under the
   SIL Open Font Licence — self-host the variable .woff2 in assets/ with
   font-display: swap. Do not ship a webfont the store has no licence for."
   `src/assets/InterVariable.woff2` is the official rsms/inter v4.1 web build and
   `src/assets/Inter-LICENSE.txt` is the OFL that travels with it.

   ⚠️ THIS RULE IS THE FALLBACK, NOT THE ONLY WAY A FACE ARRIVES — F-8.
   It is a BUILD-TIME constant naming one face, while the family a tenant renders in
   arrives at RUNTIME from its tokens. Those two facts are structurally
   incompatible, so the client also carries a runtime path: the theme payload's
   `assets.fontBody` / `assets.fontDisplay` slots, registered by `loadTenantFaces()`
   in app.mjs under the family the tenant's own tokens name. A tenant that populates
   NO slot and names a family this build does not self-host still falls through to
   the stack — that residue is the part of F-8 the theming pipeline (S2b) owns, and
   `checkTypeface()` refuses to let it happen silently.
   ⛔ DO NOT ADD A SECOND @font-face HERE FOR A TENANT. Per-tenant font licences are
   per-tenant — "do not ship a webfont the store has no licence for" — so a tenant
   face belongs in that tenant's asset slot, never in this stylesheet.
   ENFORCED BY: test/render.test.mjs "⭐ V-2" (advance-width probe), "⭐ F-8" (the
   slot pair, measured the same way) and test/assets.test.mjs (the bytes, the
   licence, and the URL actually resolving). */
@font-face {
  font-family: Inter;
  font-style: normal;
  font-weight: 100 900;
  font-display: swap;
  src: url('/assets/InterVariable.woff2') format('woff2');
}

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

/* ⭐ THE FALLBACK IN THESE TWO `var()`s IS V-3, AND IT IS NOT A STYLE PREFERENCE.
   `--t-bg-root` does not exist until the theme round-trip lands. Without a
   fallback the background computed to TRANSPARENT and the browser's default white
   showed through, so a product whose canvas is #000000 painted a full-screen WHITE
   1440x900 viewport for the whole of every cold boot — and for the entire failure
   interval on a hung or 500ing theme endpoint, which is exactly when the M-1
   ladder is trying to protect the tenant from an unbranded surface.
   The boot values live in the fenced exemption at the foot of this file: the same
   charter as the rung-2 neutral page — a surface rendered when NO tenant token is
   available yet — and the only place a literal colour is permitted.
   ENFORCED BY: test/render.test.mjs "⭐ V-3", which holds the theme response open
   and measures the painted canvas mid-boot. */
html, body {
  margin: 0;
  background: var(--t-bg-root, var(--w-boot-canvas));
  color: var(--t-text-primary, var(--w-boot-ink));
  font-family: var(--t-font-body, var(--w-boot-font));
  font-size: var(--w-fs-body);
  line-height: var(--w-lh-normal);
  font-feature-settings: var(--w-font-features);
  -webkit-font-smoothing: antialiased;
}

/* The pre-theme surface. Non-branded by construction: it carries no tenant value,
   and its indicator is `currentColor`, so it inherits the boot ink before the theme
   lands and the tenant's own text colour the instant it does. */
.boot {
  position: fixed; inset: 0;
  display: flex; align-items: center; justify-content: center;
}
.boot-spinner {
  width: 28px; height: 28px; border-radius: 50%;
  border: 2px solid currentColor; border-top-color: transparent;
  opacity: 0.55;
  animation: boot-spin 900ms linear infinite;
}
@keyframes boot-spin { to { transform: rotate(360deg); } }

/* Announced, not merely drawn. */
.sr-only {
  position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
  overflow: hidden; clip-path: inset(50%); white-space: nowrap; border: 0;
}

/* Focus is marked by what the focused element GAINS, never by dimming everything
   else. Dimming 95% of a page to indicate 5% is worse on a pointer device. */
:focus-visible {
  outline: var(--w-focus-width) solid var(--t-focus-ring);
  outline-offset: var(--w-focus-offset);
}
@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after { transition-duration: 1ms !important; animation-duration: 1ms !important; }
  /* A 1ms INFINITE animation is a strobe, not a courtesy. The duration override
     above cannot be beaten (it is !important), so the NAME is removed instead. */
  .boot-spinner, .sk::after, .player-spinner { animation-name: none; }
}

/* ── MOCK RIBBON ───────────────────────────────────────────────────────────
   Non-negotiable while this app runs on fixtures. The catalog on screen is NOT
   BLCK's; blck.tokens.json records that no BLCK card title or artwork was ever
   captured. A demo that does not say so is a demo that lies. */
/* One sticky block for the whole chrome. See app.mjs `chrome()` for why the
   `.ribbon + .topbar { top: 33px }` constant this replaces could not simply be
   corrected: it was a transcription of a rendered line box, and the line box moves
   when the typeface does. */
.chrome { position: sticky; top: 0; z-index: 30; }

.ribbon {
  display: flex; gap: var(--w-space-3); align-items: center; justify-content: center;
  padding: var(--w-space-2) var(--w-space-4);
  background: var(--t-warning);
  color: var(--t-on-accent);
  font-size: var(--w-fs-caption);
  font-weight: var(--w-fw-bold);
  letter-spacing: var(--w-tracking-eyebrow);
  text-transform: uppercase;
}

/* ── TOP BAR ────────────────────────────────────────────────────────────────
   Reference: docs/design/blck-web-concepts/concepts/01-home-desktop.png —
   wordmark far left, nav band beside it, accent underline on the active item.
   NOT carried from that concept: search, cart and account. blck.tokens.json's
   nav._provenance records "No Search, no Guide/EPG, no My List — those routes do
   not exist for this tenant", and D-32.4 removes monetization from the MVP, so a
   cart would be an invented affordance on an unmonetized product. */
.topbar {
  display: flex; align-items: center; gap: var(--w-space-7);
  padding: var(--w-space-4) var(--w-space-6);
  background: var(--t-bg-root);
  border-bottom: 1px solid var(--t-border);
}
/* 🔴 THE BRAND LINK, AND WHY IT NEEDS A RULE AT ALL.
   This anchor had no class, so no rule in this file reached it and the user agent's
   own `a { color: -webkit-link }` won. Measured on the running page: the wordmark
   text fallback painted `rgb(0, 0, 238)` — Chromium's default link blue — while
   `--t-text-primary: #FFFFFF` and `--t-accent-primary: #F7D742` were resolved and
   correct on the very same element's root. The tokens were there; the element never
   asked for one. Blue is not in this tenant's palette, and the most prominent brand
   element on a white-label viewer was rendering a colour NOBODY CHOSE.

   `color: inherit` on the anchor rather than a colour: the fix belongs at the point
   the UA default enters, so any future child of this link is covered too, not just
   the one span that happened to expose it.

   ⚠️ WHY NO FUNCTIONAL GATE SAW IT, WHICH IS THE MORE USEFUL HALF.
   `test/token-drift.test.mjs` compares DECLARED values against DECLARED values, and
   nothing here was declared — there is no wrong value and no missing pair, only an
   absent declaration, which a drift test cannot represent. `test/render.test.mjs`
   even rendered this exact element (R5-3) and asserted its TEXT and never its
   colour. The check that can see it had to ask a different question, on the rendered
   page: "⭐ R7-1 · every colour painted in the app is one the TENANT resolved."
   ENFORCED BY: test/render.test.mjs — R7-1, both arms. */
.brand { color: inherit; text-decoration: none; display: inline-flex; align-items: center; }
.wordmark { height: 30px; width: auto; display: block; }
/* ⚠️ NO `color` HERE, AND THAT IS A DECISION A MUTATION FORCED.
   This rule first carried `color: var(--t-text-primary)` as well — belt and braces
   over `.brand`'s `color: inherit`. M67 removed it and NOTHING WENT RED, because the
   anchor's reset already covers every child. By this repository's own standard a
   declaration no test can fail is decoration, and two mechanisms for one outcome is
   the second-copy shape that has cost this lane six rounds. `.brand` is the single
   mechanism: it is strictly more general, since it covers any future child of the
   link and not just this span. The colour it inherits is `--t-text-primary`, which
   is the right one — this text stands in for `blck-wordmark-white-SOURCE.png`, which
   is WHITE, and this file's own rule two hundred lines down says accent is spent on
   focus, the primary action and nothing else. */
.wordmark-text {
  font-family: var(--t-font-display);
  font-size: var(--w-fs-h3); font-weight: var(--w-fw-black);
  letter-spacing: var(--w-tracking-heading); text-transform: uppercase;
}
/* A SCROLLING TAB BAND — a mitigation, NOT the mobile nav pattern the reference
   concept specifies, and stated so it is not mistaken for one. At 390 the labels
   used to break mid-phrase (ON / DEMAND, WATCH / LIVE) and the bar grew from 73px
   to 94px. Nothing here is a hamburger; `02-home-mobile.png` has one and carrying
   it is a design decision, still owed (README F-9).
   Below 600 the band takes a row of its own rather than competing with the wordmark
   for 390px — otherwise it fits by CLIPPING, and a nav item cut off mid-word is not
   an improvement on one that wrapped. Three modules then fit; seven will scroll,
   which is the honest degradation and is what DropTV's seven would do here today. */
.nav-band { min-width: 0; overflow-x: auto; }
.nav { display: flex; gap: var(--w-space-6); list-style: none; margin: 0; padding: 0; }
.nav a {
  display: block; padding: var(--w-space-2) 0;
  color: var(--t-text-dim); text-decoration: none;
  font-size: var(--w-fs-label); font-weight: var(--w-fw-semibold);
  letter-spacing: var(--w-tracking-eyebrow); text-transform: uppercase;
  white-space: nowrap;
  border-bottom: 3px solid transparent;
  transition: color var(--w-dur-fast) var(--w-ease-out);
}
@media (max-width: 600px) {
  .topbar { flex-wrap: wrap; gap: var(--w-space-4); padding: var(--w-space-3) var(--w-space-4); }
  .nav-band { flex: 1 0 100%; }
  .nav { gap: var(--w-space-4); }
}
.nav a:hover { color: var(--t-text-primary); }
.nav a[aria-current='page'] { color: var(--t-accent-primary); border-bottom-color: var(--t-accent-primary); }

/* ── PAGE ──────────────────────────────────────────────────────────────────── */
.page { max-width: var(--w-max-width); margin: 0 auto; padding: var(--w-space-7) var(--w-space-6) var(--w-space-9); }
.eyebrow {
  font-size: var(--w-fs-eyebrow); font-weight: var(--w-fw-bold);
  letter-spacing: var(--w-tracking-eyebrow); text-transform: uppercase;
  color: var(--t-accent-primary); margin: 0 0 var(--w-space-3);
}
.h-display {
  font-family: var(--t-font-display);
  font-size: var(--w-fs-display); font-weight: var(--w-fw-black);
  line-height: var(--w-lh-tight); letter-spacing: var(--w-tracking-display);
  text-transform: uppercase; margin: 0 0 var(--w-space-4); max-width: 16ch;
}
.lede { font-size: var(--w-fs-body-lg); color: var(--t-text-dim); max-width: 62ch; margin: 0; }

/* ── SHELF ─────────────────────────────────────────────────────────────────── */
.shelf { margin-top: var(--w-section-gap); }
/* The rhythm token is the gap BETWEEN sections; the first one does not need a gap
   above it. Per-section padding overrides are how a page ends up 6,900px long. */
.shelf:first-child { margin-top: 0; }
.shelf-head { display: flex; align-items: baseline; justify-content: space-between; margin-bottom: var(--w-space-4); }
.shelf-title {
  font-size: var(--w-fs-h2); font-weight: var(--w-fw-bold);
  letter-spacing: var(--w-tracking-heading); margin: 0;
}
.shelf-count { font-size: var(--w-fs-caption); color: var(--t-text-quiet); }

/* Cards align by ARTWORK HEIGHT; width derives from the aspect ratio. A landscape
   card is 2.667x the width of a portrait card at equal height, so tops and bottoms
   land on two clean lines and a mixed shelf is the reference, not a degraded case
   (contract §7.2 rule 2). */
/* A REAL <ul>/<li>, not `role="list"` over bare anchors. That markup announces
   "list, 0 items" for a shelf of twelve — worse than no role, because it asserts a
   structure it does not have. `.detail-meta` already did this correctly, so the
   pattern existed in the file and was missed in one place. */
.rail {
  display: flex; gap: var(--w-space-4);
  overflow-x: auto; scroll-snap-type: x proximity;
  margin: 0; padding: 0 0 var(--w-space-3);
  list-style: none;
  scrollbar-width: thin;
}
.card-slot { flex: 0 0 auto; scroll-snap-align: start; }
.card {
  display: block; width: 100%;
  text-align: left; text-decoration: none; color: inherit;
  background: none; border: 0; padding: 0; cursor: pointer;
  transition: transform var(--w-dur-base) var(--w-ease-out);
}
.card:hover, .card:focus-visible { transform: translateY(-2px); }
.card-art {
  height: var(--w-shelf-card-height);
  background: var(--t-surface-card);
  border: 1px solid var(--t-border);
  border-radius: var(--w-radius-sm);
  overflow: hidden;
  display: flex; align-items: center; justify-content: center;
  position: relative;
}
.card:hover .card-art, .card:focus-visible .card-art { background: var(--t-surface-card-focused); border-color: var(--t-accent-primary); }
.card-art img { width: 100%; height: 100%; object-fit: cover; display: block; }

/* The artwork-ABSENT state. Specced, not left to chance: this is what most of
   BLCK's real catalog would render as today, because no artwork was captured and
   §13 item 10 has not said how a URL is minted. */
.art-empty {
  display: flex; flex-direction: column; align-items: center; justify-content: center;
  gap: var(--w-space-2); padding: var(--w-space-3); text-align: center; width: 100%; height: 100%;
}
.art-empty-mark {
  font-size: var(--w-fs-h3); font-weight: var(--w-fw-black);
  color: var(--t-text-faint); letter-spacing: var(--w-tracking-eyebrow);
}
/* Measured 3.61:1 at a raw 10px in `--t-text-faint`, below AA's 4.5:1 for text
   under 18.66px — on the label carrying the actual explanation, on 8 of 12 home
   cards. The kit characterises `#6E6E6E` at 4.12:1 against the CANVAS; on the card
   surface it drops to 3.61. `--t-text-quiet` on the same surface measures 5.48:1.
   The 10px was also off the ramp entirely — below the smallest token — and passed
   unnoticed because the lint enforces hex, so raw GEOMETRY is unchecked.
   ENFORCED BY: test/render.test.mjs "V-7b", which computes the ratio in the page
   against the background actually behind the element. */
/* 🔴 B4 · the browse-tile honesty marks. `.card-art` is the positioning context so
   both sit ON the artwork, where the eye already is, rather than in the metadata
   line where a caveat reads as a genre. */
.card-art { position: relative; }
.card-chip {
  position: absolute; top: var(--w-space-2); left: var(--w-space-2);
  /* Opaque, not a scrim: R7-1 requires every painted colour to be one the TENANT
     resolved, and a `color-mix` with `transparent` mints a fourth value that no
     token names. It is also the same argument as the toast's — a caveat whose
     legibility depends on the artwork behind it is not a caveat. */
  background: var(--t-bg-root);
}
/* Not the error colour and not the accent: "this one has no source published here"
   is a FACT ABOUT THE CATALOGUE, not a fault and not a feature. Dim ink on an opaque
   plate, so it is legible over any artwork and quiet enough that eleven of them do
   not shout. */
.card-flag {
  position: absolute; left: 0; right: 0; bottom: 0;
  padding: var(--w-space-1) var(--w-space-2);
  background: var(--t-bg-root);
  border-top: 1px solid var(--t-border);
  color: var(--t-text-dim);
  font-size: var(--w-fs-caption);
  text-align: center;
}
.art-empty-why {
  font-size: var(--w-fs-eyebrow); letter-spacing: var(--w-tracking-eyebrow); text-transform: uppercase;
  color: var(--t-text-quiet);
}
/* The alignment discipline applied to artwork used to stop at the text: 1- and
   2-line titles pushed `.card-meta` to five different baselines across one rail.
   Two lines is the clamp, so two lines is the reserved height — derived from the
   ramp, not typed as a pixel figure. */
.card-title {
  margin: var(--w-space-3) 0 0; font-size: var(--w-fs-label); font-weight: var(--w-fw-semibold);
  line-height: var(--w-lh-snug);
  min-height: calc(var(--w-fs-label) * var(--w-lh-snug) * 2);
  display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden;
}
.card-meta { margin: var(--w-space-1) 0 0; font-size: var(--w-fs-caption); color: var(--t-text-quiet); }

/* ── DETAIL ────────────────────────────────────────────────────────────────── */
/* 🔴 VQA-1 · THE PRIMARY CONTROL SITS UNDER THE STAGE, NOT UNDER THE PROSE.
   Named grid areas rather than source order, because the two layouts this screen
   has want opposite arrangements and `order` would divorce tab order from reading
   order. The DOM order IS the reading order (see `detailView`); these areas only
   choose which column each row lands in. */
.detail {
  display: grid;
  grid-template-columns: minmax(0, 360px) minmax(0, 1fr);
  grid-template-areas: "stage info" "stage actions" "stage why";
  align-content: start;
  align-items: start;
  column-gap: var(--w-space-8);
  row-gap: var(--w-space-5);
}
.detail > .detail-art, .detail > .player { grid-area: stage; }
.detail > .detail-info { grid-area: info; }
.detail > .detail-actions { grid-area: actions; }
.detail > .why-disabled { grid-area: why; }
@media (max-width: 860px) {
  .detail { grid-template-columns: minmax(0, 1fr); grid-template-areas: "stage" "actions" "why" "info"; }
}
.detail-art { background: var(--t-surface-card); border: 1px solid var(--t-border); border-radius: var(--w-radius-md); overflow: hidden; aspect-ratio: 2 / 3; display: flex; }
.detail-art img { width: 100%; height: 100%; object-fit: cover; }
.detail h1 { font-size: var(--w-fs-h1); font-weight: var(--w-fw-black); letter-spacing: var(--w-tracking-heading); line-height: var(--w-lh-tight); margin: 0 0 var(--w-space-4); }
.detail-meta { display: flex; flex-wrap: wrap; gap: var(--w-space-2); margin: 0 0 var(--w-space-5); padding: 0; list-style: none; }
.chip {
  font-size: var(--w-fs-caption); font-weight: var(--w-fw-semibold);
  letter-spacing: var(--w-tracking-eyebrow); text-transform: uppercase;
  padding: var(--w-space-1) var(--w-space-3);
  border: 1px solid var(--t-border); border-radius: var(--w-radius-sm);
  color: var(--t-text-dim);
}
.detail p { color: var(--t-text-dim); max-width: 68ch; }

/* Accent scarcity: accent is spent on focus, the primary action, and nothing
   else. The live storefront is the counter-example — four yellow things on one
   product page, at which point yellow emphasises nothing. */
.btn-primary {
  display: inline-flex; align-items: center; gap: var(--w-space-2);
  padding: var(--w-space-3) var(--w-space-6);
  background: var(--t-accent-primary); color: var(--t-on-accent);
  border: 0; border-radius: var(--w-radius-sm);
  font-size: var(--w-fs-label); font-weight: var(--w-fw-bold);
  letter-spacing: var(--w-tracking-eyebrow); text-transform: uppercase; cursor: pointer;
}
.btn-primary[disabled] { background: var(--t-surface-card-focused); color: var(--t-text-faint); cursor: not-allowed; }
.btn-quiet {
  display: inline-flex; align-items: center; gap: var(--w-space-2);
  padding: var(--w-space-3) var(--w-space-5);
  background: none; color: var(--t-text-primary); text-decoration: none;
  border: 1px solid var(--t-border); border-radius: var(--w-radius-sm);
  font-size: var(--w-fs-label); font-weight: var(--w-fw-semibold);
  letter-spacing: var(--w-tracking-eyebrow); text-transform: uppercase; cursor: pointer;
}

/* A disabled control must say WHY. "Play" greyed out with no reason is the state
   everybody ships badly; the reason here is that no playback URL is served by any
   route in the platform, and the UI says so rather than implying a temporary
   glitch. */
/* 🔴 VQA-8 · FIVE DISTINCT FACTS THAT ALL LOOKED THE SAME. The copy was distinct
   and the TREATMENT was not: 13px dim text, no icon, no severity, no action, for
   every one of them — including the one that tells the viewer support will be
   needed. A viewer cannot tell "this title has nothing published yet" (nothing to
   do, wait) from "this app is talking to a service that does not publish playback
   sources" (a platform fault, report it) when both render as the same grey line.
   Severity is part of the fact. */
.why-disabled {
  display: grid;
  grid-template-columns: auto minmax(0, 1fr);
  gap: var(--w-space-3);
  align-items: start;
  margin: 0;
  padding: var(--w-space-3) var(--w-space-4);
  max-width: 60ch;
  border: 1px solid var(--t-border);
  border-left-width: 3px;
  border-radius: var(--w-radius-sm);
  background: var(--t-surface-card);
}
.why-mark {
  font-size: var(--w-fs-caption); font-weight: var(--w-fw-semibold); line-height: var(--w-lh-normal);
}
.why-text { margin: 0; font-size: var(--w-fs-body); color: var(--t-text-primary); max-width: none; }
/* The support reference. `PLAYBACK_FIELD_ABSENT` said "Support will need this
   screen" on a screen that carried no reference of any kind, while this same app's
   `manifestFailureState()` renders one two hundred lines away. */
.why-ref { margin: var(--w-space-2) 0 0; color: var(--t-text-quiet); font-family: ui-monospace, monospace; }
.why-disabled-notice { border-left-color: var(--t-border); }
.why-disabled-notice .why-mark { color: var(--t-text-quiet); }
/* --t-warning and --t-error, NEVER --t-live. V-1's rule: the on-air red means "this
   is live" and nothing else. */
.why-disabled-warning { border-left-color: var(--t-warning); }
.why-disabled-warning .why-mark { color: var(--t-warning); }
.why-disabled-error { border-left-color: var(--t-error); }
.why-disabled-error .why-mark { color: var(--t-error); }

/* A block that FOLLOWS content owns the rhythm gap; a block that IS the page does
   not need one above it. Same rule as `.shelf:first-child`, same reason — and it has
   to cover the SKELETON too, or the loading state is the one screen in the app that
   ignores the section rhythm, which is visible the moment you look at it. */
.page > .state:not(:first-child),
.page > .skeleton:not(:first-child) { margin-top: var(--w-section-gap); }
.state {
  border: 1px solid var(--t-border); border-radius: var(--w-radius-md);
  background: var(--t-surface-card); padding: var(--w-space-6);
  max-width: 60ch;
}
/* `--t-error`, and the class is named for the state it serves. It was `.state-live`
   painting `var(--t-live)` — #FF3B30, which the kit reserves with the words "ON AIR.
   Broadcast convention. Reserved." and against which it mints a separate error
   magenta "because red is spent on live". On a LIVE-TV product that made the failed
   state and the transmitting state the same signal. `--t-live` had exactly one
   consumer in this stylesheet and it was the error heading, so the token did not
   mean two things — it meant only the wrong one. */
.state-error { color: var(--t-error); font-weight: var(--w-fw-bold); }
.state h2 { margin: 0 0 var(--w-space-3); font-size: var(--w-fs-h3); }
.state p { margin: 0 0 var(--w-space-4); color: var(--t-text-dim); }
.alarm {
  display: flex; gap: var(--w-space-3); align-items: center;
  padding: var(--w-space-3) var(--w-space-6);
  background: var(--t-surface-card-focused);
  border-bottom: 1px solid var(--t-warning);
  color: var(--t-warning);
  font-size: var(--w-fs-caption); font-weight: var(--w-fw-semibold);
  letter-spacing: var(--w-tracking-eyebrow); text-transform: uppercase;
}

/* ── LOADING ────────────────────────────────────────────────────────────────
   Built from the same two planes as everything else: the block sizes come from the
   geometry ramp and the surfaces come from the tenant's own tokens, so a skeleton
   is never a grey nobody chose. See app.mjs `loadingState()` for why this state
   had to exist at all. */
.skeleton { display: block; }
.sk {
  display: block; position: relative; overflow: hidden;
  background: var(--t-surface-card);
  border: 1px solid var(--t-border);
  border-radius: var(--w-radius-sm);
}
.sk::after {
  content: ''; position: absolute; inset: 0;
  background: linear-gradient(90deg, transparent, var(--t-surface-card-focused), transparent);
  transform: translateX(-100%);
  animation: sk-sweep 1400ms var(--w-ease-out) infinite;
}
@keyframes sk-sweep { to { transform: translateX(100%); } }
.sk-title { width: 12ch; height: var(--w-fs-h2); margin-bottom: var(--w-space-4); border: 0; }
.sk-rail { display: flex; gap: var(--w-space-4); overflow: hidden; }
.sk-card { height: var(--w-shelf-card-height); }
.sk-detail { display: grid; grid-template-columns: minmax(0, 360px) minmax(0, 1fr); gap: var(--w-space-8); }
@media (max-width: 860px) { .sk-detail { grid-template-columns: 1fr; } }
.sk-art { aspect-ratio: 2 / 3; border-radius: var(--w-radius-md); }
.sk-heading { height: var(--w-fs-h1); margin-bottom: var(--w-space-5); border: 0; }
.sk-line { height: var(--w-fs-body); margin-bottom: var(--w-space-3); border: 0; }
.sk-line-short { width: 40%; }

/* ── the player · D-204 R4-18 ─────────────────────────────────────────────
   MSE + hls.js. There is no `<video src="…m3u8">` anywhere in this app, and the
   reason is portability rather than purity: native HLS exists in Safari and in
   this Chromium and NOT in Firefox, so the one-liner passes every instrument this
   repository owns and fails a Firefox viewer.

   ⚠️ NATIVE TRANSPORT CONTROLS, AND THE LIMITATION IS NAMED RATHER THAN STYLED
   OVER. `controls` is keyboard-reachable, labelled for assistive tech and correct
   per platform for free — and it is unmistakably not this tenant's brand. A
   branded transport bar is its own increment, and it is the increment where the
   10-foot surfaces get their focus model, because a mouse-shaped scrubber cannot
   be driven by a D-pad. Half-branding it now would buy a screenshot and cost the
   accessibility. */
.detail[data-playback="AVAILABLE"] {
  grid-template-columns: minmax(0, 1fr);
  grid-template-areas: "stage" "actions" "why" "info";
}
.player {
  /* 🔴 A PLAYER MUST NOT PUSH ITS OWN CONTROLS OFF THE SCREEN — AND THIS COMMENT
     USED TO SAY THAT WAS FIXED WHILE IT WAS NOT.

     The rule here was `max-width: min(100%, calc(58vh * 16 / 9))`, which bounds the
     stage by the VIEWPORT height. Measured at first paint, fonts loaded, scrolled
     to the top, phase IDLE, identical on all three engines: PLAY sat +150px below
     the fold at 1366×768, +137 at 1280×800, +95 at 1440×900 and 1600×900, +19 at
     1920×1080, +207 at 360×640, and +263…+341px on every landscape phone. It passed
     on 3 of 15 viewports. 58vh leaves 42vh for banner + header (~140px) plus the
     whole title/meta/description/actions block (~290px), which does not fit below
     about 1024px of viewport height — and no stage size makes an unbounded prose
     block fit, which is why the structural half of this fix is in `detailView`.

     ⚠️ AND NOTHING ENFORCED IT. A comment stating a behaviour with no test behind it
     is a claim with no falsifier, and this repository's own README §2b says every
     such comment either cites its test or is deleted. This one now cites one.

     BOUND BY THE SPACE THAT REMAINS: the reserve below is the chrome above the
     stage plus the one 44px action row beneath it, MEASURED, not estimated — and
     the test is what keeps it true when the chrome changes.
     ENFORCED BY: test/render.test.mjs — "🔴 VQA-1 · the primary control is above the
     fold on every measured viewport" (15 viewports, all three of the measured
     failures among them). */
  --w-player-reserve: 16rem;
  /* `vh` first for any engine without `dvh`; `dvh` wins where it is understood, and
     it is the honest unit on a mobile browser whose toolbar retracts. */
  max-width: min(100%, max(10rem, calc((100vh - var(--w-player-reserve)) * 16 / 9)));
  max-width: min(100%, max(10rem, calc((100dvh - var(--w-player-reserve)) * 16 / 9)));
  background: var(--t-surface-card);
  border: 1px solid var(--t-border);
  border-radius: var(--w-radius-md);
  overflow: hidden;
  display: flex;
  flex-direction: column;
}
.player-frame { position: relative; }
/* 🔴 VQA-5 · THE IDLE STAGE IS NOT A VOID. Every Blender-seeded demo row has no
   poster, so this surface's resting state was an empty dark rectangle. */
.player-idle {
  position: absolute; inset: 0; overflow: hidden;
  display: flex; flex-direction: column; align-items: center; justify-content: center;
  gap: var(--w-space-3);
}
.player:not([data-player-phase="IDLE"]) .player-idle { display: none; }
/* COVER + BLUR, not CONTAIN. `resolveArtwork` returns a 2:3 poster and this frame is
   16:9; contained, that is the portrait-card-between-two-pillars VQA-6 measured on
   the `poster=` attribute. Blurred and covering, a 2:3 image fills a 16:9 frame with
   no distortion and no pillars, and it reads as atmosphere rather than as a mistake. */
.player-idle-art {
  position: absolute; inset: 0; width: 100%; height: 100%;
  object-fit: cover; transform: scale(1.15); filter: blur(28px); opacity: 0.55;
}
/* The mark and the title must clear AA over whatever the artwork happens to be, and
   a blurred photograph is not a colour anyone can reason about. The scrim is what
   makes the contrast a property of the design rather than of the image. */
.player-idle-scrim { position: absolute; inset: 0; background: color-mix(in srgb, var(--t-bg-root) 72%, transparent); }
.player-idle-mark { position: relative; font-size: 34px; line-height: 1; color: var(--t-text-primary); }
.player-idle-title {
  position: relative; max-width: 32ch; text-align: center;
  font-size: var(--w-fs-caption); font-weight: var(--w-fw-semibold);
  letter-spacing: var(--w-tracking-eyebrow); text-transform: uppercase;
  color: var(--t-text-dim);
}
.player-video {
  display: block;
  width: 100%;
  aspect-ratio: 16 / 9;
  /* The letterbox is the ROOT canvas, not the card: a clip whose shape does not
     match the frame should read as the product's own background rather than as a
     lighter box the tenant never designed. */
  background: var(--t-bg-root);
}
/* An empty status region must occupy NO space. A permanently reserved strip under
   the video reads as a broken caption bar on every title that plays cleanly. */
.player-status:empty { display: none; }
/* 🔴 AND IT MUST NOT CHANGE THE GEOMETRY EITHER — VQA-1, SECOND ORDER. As a ROW
   beneath the video this element added ~50px the moment anything was announced, and
   it pushed PLAY back off the bottom edge in exactly the states a viewer most needs
   the controls: buffering, stalled, blocked, errored. The fold measurement was taken
   in phase IDLE, where this row does not exist, so a fold fix verified at rest would
   have been false everywhere else. Found on the refused capture from the BLOCKER 1
   sabotage run, which is the first artifact this lane has produced that shows a
   NON-IDLE player.
   A toast at the TOP of the frame, not the bottom: the bottom belongs to the native
   transport bar, and a status line over a scrubber is a status line nobody can read.
   ENFORCED BY: test/render.test.mjs — "VQA-1 · a status line does not push the
   controls off the fold". */
.player-status {
  position: absolute; top: var(--w-space-3); left: var(--w-space-3); right: var(--w-space-3);
  z-index: 1;
  margin: 0;
  padding: var(--w-space-2) var(--w-space-3);
  border-radius: var(--w-radius-sm);
  background: color-mix(in srgb, var(--t-bg-root) 86%, transparent);
  font-size: var(--w-fs-caption);
  line-height: var(--w-lh-normal);
  color: var(--t-text-dim);
}
/* 🔴 B2 · THE WHOLE SEVERITY REGISTER NEVER REACHED THE SCREEN, AND TWO ROUNDS OF
   COMMENT DEFENDED IT ANYWAY.
   `.detail p` (line 437) is specificity 0,1,1. Every rule below was 0,1,0. So error,
   warning, info and notice ALL computed to `rgb(179,179,179)` — one colour, four
   names — and the `notice`-is-not-`error` argument in the comment beneath was moot
   because neither was ever painted. The same prose rule also capped this toast at
   `68ch`, which is why it measured 558px inside a 1323px frame.
   The classes are doubled to reach 0,2,0. A bare-element descendant selector
   reaching into a component is the actual cause; `.detail p` is left alone because
   `.why-text` and `.why-ref` legitimately inherit from it, and narrowing it would
   move the defect rather than remove it.
   ENFORCED BY: test/render.test.mjs — "B2 · the four status tones compute to FOUR
   different colours on the real screen". Before that test existed, NOTHING asserted
   the four differ. */
.player-status.player-status-error { color: var(--t-error); }
.player-status.player-status-warning { color: var(--t-warning); }
.player-status.player-status-info { color: var(--t-text-dim); }
/* ⚠️ `notice` IS NOT `error`, AND THE AUTOPLAY MESSAGE IS THE REASON. Being asked
   to press play is the DEFAULT path on iOS, Safari and tvOS — the platform behaving
   exactly as designed. Painting the product's error colour over correct platform
   behaviour teaches a viewer to distrust the one colour that should mean something
   is wrong. */
.player-status.player-status-notice { color: var(--t-text-primary); }
.player-status { display: flex; align-items: center; gap: var(--w-space-3); flex-wrap: wrap; }
/* The other half of the same specificity defect: `.detail p`'s `max-width: 68ch` is
   prose measure, and this is a toast pinned to both edges of the frame. */
.player .player-status { max-width: none; }
/* 🔴 CONTRAST IS A PROPERTY OF THE DESIGN, NOT OF WHAT IS PLAYING BEHIND IT.
   This was `color-mix(in srgb, var(--t-bg-root) 86%, transparent)`. MEASURED by
   visual-QA over a real ENDED frame: worst case 2.10:1 against a 4.5:1 AA floor at
   13px (median 8.45:1) — the fixture's burnt-in timecode read BRIGHTER than the
   sentence in front of it. A 14% window onto arbitrary video is not a scrim, and the
   idle plate's own scrim exists precisely because this problem was already solved
   once for less important text. Opaque, with a hairline so it still reads as a layer
   over the picture rather than as part of the chrome. */
.player .player-status { background: var(--t-bg-root); border: 1px solid var(--t-border); }
/* 🔴 B1 · the error sentence's action. `.player-again` is ENDED's; this is ERROR's. */
.player-retry { margin-left: auto; }
/* 🔴 BLOCKER 3 · THE STATE WITH NO INDICATION AT ALL. A starved segment froze the
   picture for six seconds while the phase still read PLAYING and this line was
   empty. The spinner never appears alone — there is always a sentence beside it,
   because a spinner with no sentence IS "the spinner that never stops". */
.player-spinner {
  flex: 0 0 auto; width: 14px; height: 14px; border-radius: 50%;
  border: 2px solid var(--t-border); border-top-color: var(--t-text-dim);
  animation: player-spin 900ms linear infinite;
}
@keyframes player-spin { to { transform: rotate(360deg); } }
/* VQA-4 · a frozen last frame with no way to start again is a dead end. */
.player-again { margin-left: auto; }
.detail-info { min-width: 0; }
.detail-actions { display: flex; align-items: center; gap: var(--w-space-3); }
/* The is_test_asset badge. It is deliberately the WARNING role and not the accent:
   "this is a sample clip, not this property's catalogue" is a caveat, and dressing
   a caveat in the brand colour is how a demo starts reading as a product. */
.chip-test {
  border-color: var(--t-warning);
  color: var(--t-warning);
}

/* ⚠️ THE EXEMPTION BELOW COVERS TWO SURFACES, AND THEY SHARE ONE CHARTER: both are
   rendered when NO tenant token is available, so neither can use a tenant colour and
   neither may use a platform default that could read as one.
     · the BOOT CANVAS — painted for the length of the theme round-trip, on every
       cold load. Achromatic (R=G=B), and deliberately not #000000, which is a real
       tenant's canvas. A dark boot surface is the honest default for a live-TV
       product; a light-canvas tenant would still see a transition, and the fix that
       removes it for returning viewers — pre-painting from last-known-good before
       first paint — is stated in README F-10 and NOT built here.
     · the RUNG-2 NEUTRAL PAGE — D-31 M-1's last resort.
   Keeping them in ONE window is deliberate: the lint pins the window's shape, and
   two windows is how an exemption starts to grow. */
/* BEGIN-NEUTRAL-EXEMPTION */
:root {
  --w-boot-canvas: #101010;
  --w-boot-ink: #E6E6E6;
  --w-boot-font: system-ui, -apple-system, sans-serif;
}
.neutral {
  position: fixed; inset: 0; z-index: 50;
  background: #ffffff; color: #1a1a1a;
  font-family: system-ui, -apple-system, sans-serif;
  display: flex; align-items: center; justify-content: center; padding: 32px;
}
.neutral-box { max-width: 44ch; }
.neutral h1 { font-size: 20px; font-weight: 600; margin: 0 0 12px; }
.neutral p { font-size: 15px; line-height: 1.6; margin: 0 0 8px; color: #444444; }
.neutral code { font-family: ui-monospace, monospace; font-size: 13px; color: #666666; }
/* END-NEUTRAL-EXEMPTION */
