:root{
  --off:      #000000;
  --room:     #08090A;   /* the walls, barely not black */
  --ash:      #3A3A3A;
  --signal:   #9A9A9A;
  --phosphor: #E8E6E1;   /* warm white — all text */
  --burn:     #FFFFFF;   /* RESERVED: live channel + buy */

  /* The only chroma on the site. It is light, never ink. */
  --cold:     168, 200, 232;
  /* Between --ash and --signal. The tuner has three states — resting, hover,
     tuned — and resting at --ash was 1.7:1 on --room, which is not readable on
     a control people are meant to click. Lifting it straight to --signal would
     have collided with the hover state, so this is its own step: about 3.7:1,
     clearly dimmer than --signal without being invisible. Still not for body
     copy; --signal remains the floor for that. */
  --dim:      #6B6B6B;

  --gap: clamp(1rem, 2.2vw, 2.1rem);
  --hair: 1px solid #191919;

  --mono: "DM Mono", ui-monospace, Menlo, monospace;
  --grot: "Archivo", system-ui, sans-serif;
}

*{ box-sizing:border-box; margin:0; padding:0; min-width:0; }
html,body{ height:100%; max-width:100%; overflow:hidden; }
/* The overscroll area on iOS takes its colour from the root element, not from
   body. Without this, rubber-banding past the top on an iPhone exposes the
   browser's own default backdrop under the room. */
html{ background:var(--off); }

/* iOS paints a blue rectangle over any text it thinks you are selecting, and a
   drag on a fixed, non-scrolling element is easy to misread as a selection
   gesture rather than a scroll. The room is scenery — there is nothing in it
   anyone would want to copy — so it opts out entirely. The copy in .ui is
   deliberately NOT included: prices and format lists are worth selecting.
   -webkit-tap-highlight-color kills the separate flash iOS puts under a tap. */
.room, .room *{
  -webkit-user-select:none; user-select:none;
  -webkit-touch-callout:none;
}
*{ -webkit-tap-highlight-color:transparent; }

body{
  background: var(--off);
  color: var(--phosphor);
  font-family: var(--grot);
  -webkit-font-smoothing: antialiased;
}

/* Film grain over the whole room. Suppressed when the GL room is live
   (data-gl-ready on <html>) because the GL post pass already applies grain. */
body::after{
  content:"";
  position:fixed; inset:-50%;
  pointer-events:none; z-index:80;
  opacity:.055;
  background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.9' numOctaves='3'/%3E%3C/filter%3E%3Crect width='160' height='160' filter='url(%23n)'/%3E%3C/svg%3E");
  animation: grain 900ms steps(4,end) infinite;
}
:root[data-gl-ready="true"] body::after{ display:none; }

/* The CSS room is the pre-JS paint and the no-WebGL2 fallback. Once the GL
   room is live it is dead weight sitting directly behind a canvas that covers
   it — and on a window resize the canvas reallocates its backing store and is
   blank for a frame or two, which let the CSS room flash through. Taking it
   out of the layer entirely is what stops that. It stays fully present when
   data-gl-ready is absent, which is the fallback path. */
:root[data-gl-ready="true"] .set{ display:none; }
@keyframes grain{
  0%{transform:translate(0,0)} 25%{transform:translate(-3%,2%)}
  50%{transform:translate(2%,-3%)} 75%{transform:translate(-2%,-2%)}
  100%{transform:translate(0,0)}
}

/* ============================================================ THE ROOM */

.room{
  position:fixed; inset:0;
  perspective: 1100px;
  perspective-origin: 44% 44%;
  background: var(--off);
  overflow:hidden;
}

/* back wall */
.wall{
  position:absolute; left:0; right:0; top:0; height:62%;
  background:
    radial-gradient(120% 150% at 42% 78%,
      rgba(var(--cold), .085) 0%,
      rgba(var(--cold), .028) 26%,
      transparent 58%),
    linear-gradient(#050506, var(--room));
}
/* skirting board — the line that sells the corner */
.wall::after{
  content:""; position:absolute; left:0; right:0; bottom:0;
  height:1.4%; min-height:5px;
  background:linear-gradient(#101113, #060607);
  box-shadow:0 -1px 0 #17191C;
}

/* window on the back wall, blinds shut, night behind it */
.window{
  position:absolute; left:5%; top:9%;
  width:19%; height:30%;
  background:linear-gradient(#0A0C10, #070809);
  box-shadow: inset 0 0 0 1px #0F1113, 0 0 50px 10px rgba(var(--cold),.025);
}
.window::before{
  content:""; position:absolute; inset:6% 7%;
  background:repeating-linear-gradient(
    to bottom,
    rgba(var(--cold),.055) 0 1px,
    rgba(0,0,0,.5) 1px 8px);
}

/* The desk. This is the plane that used to be the floor — laid at a shallower
   angle and higher in frame, because you're sitting at it rather than standing
   in the room. The floor is gone entirely: the desk occludes it. */
.desk{
  position:absolute; left:-60%; right:-60%; top:60%; height:150%;
  transform-origin: top center;
  transform: rotateX(68deg);
  background:
    radial-gradient(40% 30% at 44% 3%,
      rgba(var(--cold), .22) 0%,
      rgba(var(--cold), .07) 38%,
      transparent 70%),
    linear-gradient(#0D0E10 0%, #070708 40%, #000 100%);
}
/* Semi-gloss: the screen smears down the desk toward you. Masked to the pool
   of light so the surface stays black everywhere the telly isn't reaching. */
.desk::before{
  content:""; position:absolute; inset:0;
  background:linear-gradient(to bottom,
    rgba(var(--cold),.13) 0%,
    rgba(var(--cold),.05) 14%,
    transparent 34%);
  -webkit-mask-image:radial-gradient(26% 30% at 44% 0%, #000 0%, transparent 76%);
  mask-image:radial-gradient(26% 30% at 44% 0%, #000 0%, transparent 76%);
}

/* the pool of light thrown forward across the desk */
.spill{
  position:absolute; left:50%; top:58%;
  width:78%; height:42%;
  transform:translateX(-50%);
  pointer-events:none;
  background:radial-gradient(50% 50% at 44% 0%,
    rgba(var(--cold), .11) 0%,
    rgba(var(--cold), .035) 45%,
    transparent 72%);
  filter:blur(6px);
}

/* the telly's light thrown across the whole scene — sits above the wall and
   floor so it washes both, below the set so the box stays solid. This is what
   makes the room feel lit by the screen rather than tinted by CSS. */
.bloom{
  position:absolute; left:44%; top:47%;
  width:150vmax; height:150vmax;
  transform:translate(-50%,-50%);
  pointer-events:none; z-index:1;
  background:radial-gradient(closest-side,
    rgba(var(--cold),.16) 0%,
    rgba(var(--cold),.085) 18%,
    rgba(var(--cold),.035) 34%,
    rgba(var(--cold),.012) 52%,
    transparent 72%);
  opacity:.55;
  transition:opacity .6s ease;
  /* the one ambient motion in the scene. a real screen never sits perfectly
     still, and an almost-imperceptible drift is the difference between a
     render and a room. brightness rather than opacity because opacity is
     already carrying the live/dead state below and the two would fight. */
  animation:breathe 11s ease-in-out infinite alternate;
}
@keyframes breathe{
  from{ filter:brightness(.86); }
  to  { filter:brightness(1.08); }
}
/* a dead channel throws almost nothing */
body:has(.tv__screen[data-live="true"]) .bloom{ opacity:1; }

/* ============================================================ THE SET */

.set{
  position:absolute;
  /* 44% keeps the box clear of the tuner column at the narrowest desktop
     width; at 38% it slid underneath the channel list. */
  left:44%; top:47%;
  transform:translate(-50%,-50%) rotateY(7deg);
  transform-style:preserve-3d;
  width:min(36vw, 52vh);
  z-index:2;            /* above .bloom, so the box stays solid in the wash */
}

/* The telly stands directly on the desk now — no unit. All that's left is the
   contact shadow, which is what actually makes it look like it's resting on
   something rather than floating. */
.unit{
  position:absolute; left:50%; top:99.5%;
  width:96%; height:min(5vh,38px);
  transform:translateX(-50%);
  background:radial-gradient(60% 100% at 50% 0%,
    rgba(0,0,0,.92) 0%, rgba(0,0,0,.55) 45%, transparent 78%);
  filter:blur(5px);
}

.tv{
  position:relative;
  padding:2.6% 2.6% 6.4%;
  border-radius:12px;
  background:linear-gradient(#141516, #050506 66%);
  box-shadow:
    inset 0 1px 0 #26282B,
    inset 0 -1px 0 #000,
    0 30px 70px -20px #000,
    0 0 90px 14px rgba(var(--cold), .05);
  transition:box-shadow .5s ease;
}
.tv:has(.tv__screen[data-live="true"]){
  box-shadow:
    inset 0 1px 0 #26282B,
    inset 0 -1px 0 #000,
    0 30px 70px -20px #000,
    0 0 150px 40px rgba(var(--cold), .10);
}
.tv__screen{
  position:relative; width:100%; aspect-ratio:4/3;
  border-radius:6% / 8%;
  overflow:hidden; background:#000;
  box-shadow: inset 0 0 34px 8px #000;
  cursor:pointer;
}
/* a live channel is the room's light source — the glass itself has to emit */
.tv__screen[data-live="true"]{
  background:
    radial-gradient(72% 62% at 50% 42%,
      rgba(var(--cold),.16) 0%,
      rgba(var(--cold),.05) 46%,
      #000 78%);
}
.tv__screen:focus-visible{ outline:2px solid var(--phosphor); outline-offset:5px; }

.tv__foot{
  position:absolute; left:0; right:0; bottom:1.6%;
  display:flex; align-items:center; justify-content:center; gap:.45rem;
}
.tv__brand{
  font-family:var(--mono); font-size:.48rem; letter-spacing:.32em;
  color:#2C2E30; text-transform:uppercase;
}
.led{ width:4px; height:4px; border-radius:50%; background:#202224; }
.led[data-on="true"]{
  background:rgb(var(--cold));
  box-shadow:0 0 7px rgba(var(--cold),.9);
}

/* layers inside the glass */
.layer{ position:absolute; inset:0; }
/* canvas is a replaced element: inset:0 alone leaves it at its intrinsic
   size, which made sizeCanvas() shrink it on every pass. Pin it explicitly. */
#static{ z-index:1; width:100%; height:100%; }
.media{ z-index:2; border:0; width:100%; height:100%; display:none; }
.media[data-live="true"]{ display:block; }

.card{
  z-index:3; display:flex; flex-direction:column;
  align-items:center; justify-content:center; gap:.6rem;
  text-align:center; padding:1rem;
  /* scrim so the channel name holds up against the snow behind it */
  background:radial-gradient(58% 46% at 50% 50%, rgba(0,0,0,.82) 0%, rgba(0,0,0,.45) 62%, transparent 100%);
}
.card[hidden]{ display:none; }
.card__pat{
  width:min(38%,120px); aspect-ratio:1; border-radius:50%;
  display:grid; place-items:center;
  box-shadow:inset 0 0 0 1px #232527;
  background:repeating-conic-gradient(#000 0 15deg, #0E0F10 15deg 30deg);
}
.card__pat span{
  font-family:var(--mono); font-size:.55rem; letter-spacing:.2em; color:var(--signal);
}
.card__name{
  font-variation-settings:"wdth" 125,"wght" 300;
  font-size:clamp(.9rem,1.9vw,1.5rem); letter-spacing:.2em; text-transform:uppercase;
}
.card__state{
  font-family:var(--mono); font-size:.58rem; letter-spacing:.24em;
  color:var(--signal); text-transform:uppercase;
}
.card__play{
  font-family:var(--mono); font-size:.56rem; letter-spacing:.22em;
  color:var(--burn); text-transform:uppercase;
}

.scan{
  z-index:4; pointer-events:none; opacity:.5;
  background:repeating-linear-gradient(to bottom,
    rgba(0,0,0,.36) 0 1px, transparent 1px 3px);
}
.glass{
  z-index:5; pointer-events:none;
  background:
    radial-gradient(118% 88% at 50% 50%, transparent 50%, rgba(0,0,0,.88) 100%),
    linear-gradient(158deg, rgba(255,255,255,.05) 0 24%, transparent 44%);
}
.burst{ z-index:6; pointer-events:none; opacity:0; background:var(--phosphor); }
.tv__screen[data-tuning="true"] .burst{ animation: tune .34s steps(3,end); }
@keyframes tune{ 0%{opacity:.45} 30%{opacity:.05} 60%{opacity:.28} 100%{opacity:0} }

.tv__screen[data-boot="true"]{ animation: boot .62s cubic-bezier(.2,.8,.2,1); }
@keyframes boot{
  0%  { transform:scaleY(.004) scaleX(.7); filter:brightness(4); }
  55% { transform:scaleY(.02) scaleX(1);   filter:brightness(3); }
  100%{ transform:scaleY(1) scaleX(1);     filter:brightness(1); }
}

/* ==================================================== THE GEAR (SVG) */

/* Monitor speakers, on the desk, angled inward. SVG because these need real
   silhouettes — the old sofa was built from rounded rectangles and read as a
   rounded rectangle. The near edge of each cabinet is rim-lit by the screen,
   which is what sells the room as three-dimensional. */
/* z-index 1 puts these behind the CRT (z-index 2), so each cabinet is part
   occluded by the telly. That is how nearfields actually sit, and it keeps
   them tucked in the middle of the frame where the copy columns never reach —
   an earlier pass had the right one sitting directly behind the BUY button. */
.mon{
  position:absolute; z-index:1;
  width:min(10vw, 14vh); height:auto;
  pointer-events:none;
}
/* flanked about the CRT's centre (44%), not about the viewport */
.mon--l{ left:19%; bottom:29%; transform:rotate(-1.2deg); }
.mon--r{ left:57%; bottom:29%; transform:rotate(1.2deg) scaleX(-1); }
.mon__body{ fill:#0A0B0C; }
.mon__baffle{ fill:#0D0E10; }
.mon__cone{ fill:#08090A; stroke:#151719; stroke-width:.6; }
.mon__dust{ fill:#121416; }
.mon__port{ fill:#050506; }
/* rim: the edge facing the telly catches the light */
.mon__rim{ fill:none; stroke:rgba(var(--cold),.30); stroke-width:1.1; }
.mon__led{ fill:rgba(var(--cold),.55); }

/* MIDI keyboard, immediate foreground, heavily out of focus — this is your
   POV, you're sitting at the desk. Deliberately cropped by the frame. */
/* above the vignette: the vignette is centred on the screen, and anything it
   covers at the bottom of the frame goes to black — which erased these */
.fg{ position:absolute; inset:0; pointer-events:none; z-index:16; }
.keys{
  position:absolute; left:50%; bottom:-7%;
  width:min(78vw, 108vh); height:auto;
  transform:translateX(-50%);
  filter:blur(9px);
}
.keys__body{ fill:#000; }
.keys__white{ fill:#0A0B0C; }
.keys__black{ fill:#000; }
/* the tops of the keys catch the screen */
.keys__lit{ fill:none; stroke:rgba(var(--cold),.26); stroke-width:1.6; }

/* near edge of the desk, closest to you and furthest out of focus */
.fg__edge{
  position:absolute; left:-5%; right:-5%; bottom:0; height:5%;
  background:linear-gradient(transparent, rgba(0,0,0,.75) 70%);
  filter:blur(8px);
}

/* The 3D room. Sits above the CSS room, which stays as the pre-JS paint and
   the no-WebGL fallback.

   No transition here on purpose. This used to crossfade in over .6s, and
   because the CSS room underneath is a genuinely different, cruder scene, the
   whole load read as the site showing an old version and then correcting
   itself. The reveal is now the warm-up curtain below: black, then the room.
   Snapping the canvas on under an opaque curtain is invisible; fading it in
   under a lifting curtain would show both rooms mixed together. */
.gl{
  position:absolute; inset:0; width:100%; height:100%;
  z-index:15; pointer-events:none;
  opacity:0;
  image-rendering:pixelated;
}
.gl[data-ready="true"]{ opacity:1; }

/* The warm-up curtain. Black until the renderer has something to show, which
   is also just what a CRT does. It sits above the canvas (15) and the
   foreground (16) but below the UI (20), so the tuner and the headline are
   readable immediately while the room comes up behind them.

   It lifts on html[data-gl-ready], set by room/boot.js on its first frame.
   Two ways out if that never happens, because a curtain that never lifts is
   a black page — worse than the flash it was added to fix:
     - no JavaScript at all: the <noscript> rule below removes it outright
     - WebGL missing, or boot.js throwing or 404ing: the watchdog in the page
       script sets data-gl-failed, and the CSS room shows as it always did */
.warmup{
  position:absolute; inset:0; z-index:17; pointer-events:none;
  background:#000;
  opacity:1; transition:opacity .5s ease-out;
}
[data-gl-ready="true"] .warmup,
[data-gl-failed="true"] .warmup{ opacity:0; }

/* The sign-off blackout. Reuses the warm-up curtain rather than adding a
   second full-screen layer — it is already black, already above the canvas,
   and already below the UI. It comes back down on the overload's peak and the
   navigation happens behind it, so the page swap is never seen. */

/* The selling copy comes up with the head.

   The wake-up lifts the view from the desk to the screen over 2.2s, and the
   copy rising into place on the same move makes the two read as one arrival
   rather than as an animation with text sitting on top of it. Slightly behind
   the head and slightly quicker, so it settles just before the view does.

   Three ways to become visible, because copy that never appears is a page
   with no product on it:
     - the room reports ready (the normal path, and the one that syncs)
     - the room fails, or takes too long: data-wake="go" from the page script
     - no JavaScript at all: the <noscript> rule in the markup */
.info{
  opacity:0;
  transform:translateY(14px);
  transition:opacity 1.5s ease .45s, transform 1.6s cubic-bezier(.16,.84,.34,1) .45s;
}
[data-gl-ready="true"] .info,
[data-gl-failed="true"] .info,
[data-wake="go"] .info{ opacity:1; transform:none; }

@media (prefers-reduced-motion: reduce){
  /* No head motion to sync with, so there is nothing for a fade to belong to. */
  .info{ opacity:1; transform:none; transition:none; }
}

[data-signoff="true"] .warmup{
  opacity:1;
  transition-duration:.2s;
  transition-timing-function:ease-in;
}


/* Do not paint the CSS room before we know whether it is the one being used.

   The warm-up curtain is the LAST element in .room, and Chrome will happily
   paint the first screenful before it has finished parsing the body — so on a
   refresh the CSS fallback room was drawn, and the curtain arrived a beat
   later and covered it. That beat is the flash.

   data-js is set by a classic inline script in <head>, which runs before the
   body is parsed at all, so this rule is in force for the very first paint.
   With no JavaScript the attribute never appears and the fallback room is
   visible immediately, which is exactly right. */
[data-js="on"] .room > *{ visibility:hidden; }
[data-js="on"] .room > .warmup{ visibility:visible; }
[data-gl-ready="true"] .room > *,
[data-gl-failed="true"] .room > *{ visibility:visible; }

/* ...except the near-field foreground, which must NOT come back when the 3D
   room does.

   .fg is the CSS room's out-of-focus foreground: your own keyboard, a black
   640x96 rounded rect under a 9px blur, pinned across the bottom of frame.
   At z-index 16 it deliberately sits above everything — including the GL
   canvas at 15 — so once the rule above made the fallback's children visible
   again it painted a soft-edged black slab across the bottom of the real
   room, right where the copy and the buy button sit. It reads as an overlay
   rather than as furniture because that is exactly what it is: screen space,
   so it never moves with the camera, and it is there just as much when you
   are looking at the floor.

   The 3D room draws its own keyboard and its own desk edge, so there is
   nothing here to keep. On the fallback path neither attribute is set, the
   rule above does not fire, and .fg stays as it was. */
[data-gl-ready="true"] .room > .fg{ visibility:hidden; }

/* Room vignette for the CSS fallback ONLY. When WebGL runs, the canvas at
   z-index 15 covers this entirely and POST_FRAG's own vignette does the work —
   which is where it belongs, since it has to sit inside the grade to dither
   with the rest of the image. Do not raise this above .gl to "fix" it; that
   puts a smooth un-dithered gradient over a dithered room. */
.vig{
  position:absolute; inset:0; pointer-events:none; z-index:14;
  background:radial-gradient(86% 76% at 44% 46%, transparent 38%, #000 96%);
}

/* ============================================================ THE UI */

.ui{
  position:absolute; inset:0; z-index:20;
  /* header, plugin rail, room copy, strip */
  /* minmax(0,1fr), not 1fr. A 1fr track still takes its content as a minimum,
     so the taller product copy grew the grid and pushed the page past the
     viewport — 81px of scroll on a 375x812 phone, on a room that is meant to
     be a locked single screen. With a zero minimum the track keeps its size
     and the copy overflows upward over the room instead, which is what the
     product view's scrim is there to make readable. */
  display:grid; grid-template-rows:auto auto minmax(0, 1fr) auto;
  pointer-events:none;
}
.ui > *{ pointer-events:auto; }

/* ================================================== THE PRODUCT VIEW SCROLLS

   And ONLY the product view. The tuner stays a locked single screen, which is
   what the minmax(0, 1fr) above exists to guarantee — a room you can
   accidentally scroll off the top of was a real bug on a 375x812 phone and
   the reason that track has a zero minimum.

   So every rule here is scoped to [data-mode="product"] and nothing widens
   the lock in general.

   The room stays FIXED while the copy travels over it. That is what makes it
   read as a page laid over a room rather than a room that got taller, and it
   costs nothing: the canvas keeps rendering one viewport, whatever the
   document height.

   The copy track becomes auto so it can grow. Left at minmax(0, 1fr) the
   grid would hold its size and the content would spill out of it invisibly,
   with nothing to scroll to. */
:root[data-mode="product"]{ overflow-y:auto; overflow-x:hidden; }
:root[data-mode="product"] body{ height:auto; min-height:100%; overflow:visible; }
:root[data-mode="product"] .room{ position:fixed; }
:root[data-mode="product"] .ui{
  position:relative; inset:auto;
  /* minmax(min-content, 1fr) on the copy row, not auto.
     With all four rows auto, .mid was exactly as tall as its content — so
     .mid's own align-content had no free space to distribute and centred
     nothing, and .ui's leftover height piled up at the bottom of the page as
     a dead band. The row grows to fill what is left and grows PAST it when
     the copy is long, which is what keeps a tall product scrolling. */
  grid-template-rows:auto auto minmax(min-content, 1fr) auto;
  min-height:100vh;
  min-height:100svh;   /* the small viewport, so mobile chrome cannot add a scrollbar of its own */
}

/* The dismiss target stops at the first screen.

   It fills .mid, and .mid is now as tall as the content — so left alone it
   would stretch the whole way down and turn every empty patch beside a video
   into "leave the page". The copy still sits above it (z-index:1), so
   anything inside .info keeps taking its own taps either way; this is about
   not making the accidental exit easy on a long page. */
:root[data-mode="product"] .dismiss{
  bottom:auto;
  height:100vh;
  height:100svh;
}

.ident{
  display:flex; align-items:center; justify-content:space-between; gap:1rem;
  padding: var(--gap); border-bottom: var(--hair);
  /* A proper scrim, not a hint of one. This bar sits over a grainy, dithered
     room, and grain eats small text: the noise is +-0.13 of full scale, which
     against .66rem letters is most of the contrast they had. The bar has to
     bring its own ground. */
  background:linear-gradient(#000 0%, rgba(0,0,0,.78) 58%, transparent 100%);
}

/* ========================================================== THE TWO CATALOGUES

   Somna Audio and Somna Visuals are separate pages, so these are links and
   not a control. Both are always present: the one you are on is lit, the
   other is dim and clickable. Showing only the alternative would save a word
   and lose the thing that matters — that there are two of these at all.

   .ident is space-between, so the auto margin keeps the wordmark and these
   together on the left while the clock stays on the right. */
.ident__mark:hover{ color:var(--phosphor); }
.ident__mark:focus-visible{ outline:1px solid var(--phosphor); outline-offset:4px; }
/* The separator between the wordmark and the catalogue links. It had no rule
   at all — it was lost with the dropdown this replaced — so it inherited the
   header's own colour and sat as bright as the wordmark it divides. */
.ident__dot{ color:var(--ash); margin:0 -.5rem; }
.cat{ display:inline-flex; align-items:center; gap:.4rem; margin-right:auto; }
.cat__link{
  text-decoration:none;
  font-family:var(--mono); font-size:.62rem;
  letter-spacing:.22em; text-transform:uppercase;
  color:var(--ash);
  transition:color .18s ease;
}
.cat__link:hover{ color:var(--phosphor); }
.cat__link[aria-current="page"]{ color:var(--signal); }
.cat__link:focus-visible{ outline:1px solid var(--phosphor); outline-offset:3px; }
.cat__sep{ color:var(--ash); font-size:.55rem; }

.ident__mark{
  /* An anchor now — the way back to the catalogue chooser — so the browser's
     defaults have to be undone: an underline and a link colour would both
     fight a wordmark. It brightens on hover instead, which is the affordance
     the rail and the catalogue links already use, so the page has one idea of
     what clickable looks like. */
  display:inline-block; text-decoration:none; color:inherit;
  transition:color .18s ease;
  font-variation-settings:"wdth" 125,"wght" 600;
  /* .42em was set for the five letters of "Somna". Tracking that wide turns a
     longer title into a row of separated characters rather than a word, which
     is most of why it read as hard. */
  font-size:.86rem; letter-spacing:.24em; text-transform:uppercase;
  color:var(--phosphor);
  /* The wordmark is the site's own title, which on the live site is
     "somnaplugins.com" — sixteen characters at .42em tracking, not the five
     this was designed around. It has to be the thing that gives: min-width:0
     lets it shrink inside the flex row, and the ellipsis stops it shoving the
     clock off the right edge on a narrow phone. */
  min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;
}
.ident__meta{
  font-family:var(--mono); font-size:.74rem; letter-spacing:.12em;
  color:var(--signal); text-transform:uppercase;
  display:flex; align-items:center; gap:.7rem;
  /* Never shrink, never wrap: the time is the one thing up here that has to
     stay readable, and a wrapped "ON AIR" breaks the bar's height. */
  flex:none; white-space:nowrap;
}

/* Narrow phones. Tracking goes first because it is what makes a long title
   wide, and it is decoration; the letters are the information. */
@media (max-width: 620px){
  .ident{ gap:.6rem; }
  .ident__mark{ letter-spacing:.2em; font-size:.74rem; }
  .ident__meta{ gap:.5rem; }
}
@media (max-width: 430px){
  .ident__mark{ letter-spacing:.1em; font-size:.7rem; }
  /* The status word goes, not the clock. On-air state is already reported by
     the strip along the bottom and by the tally dot beside the tuned channel,
     so this is the third copy of it; the time is stated nowhere else. */
  .ident__meta > :not(.ident__clock){ display:none; }
}
.ident__clock{ color:var(--phosphor); font-variant-numeric:tabular-nums; }

/* The arrows either side of the monitor. They are the tuner now.

   Plain glyphs again — the stepped chevrons were a nice idea and read as
   ornament. What these needed was not a different shape but more presence:
   bigger, brighter, and lit.

   These values are only the pre-script fallback: alignNav() overwrites top
   and left inline once it can measure the name, so that the arrows sit level
   with it. They are kept roughly right rather than arbitrary so that a frame
   rendered before that runs is not visibly wrong. */
.nav{
  position:absolute; z-index:21;
  /* Anchored to the CENTRE, not to the edges of the frame.
     Pinned at left/right 7% they sat against the bezel on a phone and drifted
     halfway across the room on a desktop, because a percentage of the viewport
     has nothing to do with where the monitor is.
     The camera has a fixed VERTICAL field of view, so the CRT's on-screen size
     scales with viewport HEIGHT — vh is the unit that tracks it. The set is
     about 33vh across, so half of it plus a gap is roughly 22vh from centre.
     min() with a vw term stops them running off a very short, wide window. */
  top:72%; transform:translate(-50%, -50%);
  width:4.5rem; height:4.5rem;
  display:grid; place-items:center;
  background:none; border:0; cursor:pointer;
  color:var(--phosphor);
  font-family:var(--grot); font-size:3.1rem; line-height:1;
  /* Chroma as light: the arrow is lit, not coloured in. */
  text-shadow:0 2px 0 #000, 0 0 14px rgba(var(--cold), .55);
  /* Arrives with the copy, rather than being there before the room is.

     The arrows sat at full strength from the first painted frame, while the
     head was still lifting off the desk and the copy was still fading up —
     which made them read as page furniture rather than as part of the room
     waking. They now fade in on the same cue.

     A keyframe rather than a transition, because the resting state is itself
     an animation: a transition to opacity would be overwritten the moment
     navPulse started. navIn holds at 0 through its delay (fill both), fades
     to .8, and hands over to the pulse after it has finished. */
  opacity:0;
  transition:transform .18s ease, text-shadow .18s ease;
  animation:none;
}
@keyframes navIn{ from{ opacity:0; } to{ opacity:.8; } }
[data-gl-ready="true"] .nav,
[data-gl-failed="true"] .nav,
[data-wake="go"] .nav{
  animation:navIn .9s ease .55s both,
            navPulse 3.4s ease-in-out 1.5s infinite;
}
@keyframes navPulse{
  0%, 100%{ opacity:.72; }
  50%     { opacity:.95; }
}
.nav:hover{
  opacity:1; color:var(--burn);
  text-shadow:0 2px 0 #000, 0 0 20px rgba(var(--cold), .85);
}
.nav:focus-visible{ outline:1px solid rgb(var(--cold)); outline-offset:4px; }
/* [hidden] loses to a display of our own.

   .nav sets display:grid and .rail display:flex, both of which beat the user
   agent's [hidden]{display:none} — so marking them hidden for the product
   view left them on screen, arrows and all. This is the same trap the trial
   link fell into. */
.nav[hidden]{ display:none; }

/* The rail hides but keeps its slot.

   .ui is a four-row grid — header, rail, copy, strip — and display:none takes
   the rail out of the grid entirely, so every row below it shifts up one:
   the copy inherited the rail's auto row and the ticker inherited the copy's
   1fr, which made a 44px ticker 277px tall and threw the copy to the top of
   the screen. visibility keeps the row occupied, and the room's geometry then
   stays identical between the two views, which is what you want anyway. */
.rail[hidden]{ visibility:hidden; }

.nav--prev{ left:calc(50% - 9rem); }
.nav--next{ left:calc(50% + 9rem); }
.nav--prev:hover{ transform:translate(-50%, -50%) translateX(-4px); }
.nav--next:hover{ transform:translate(-50%, -50%) translateX(4px); }
@media (prefers-reduced-motion: reduce){
  /* Matches the awake selectors as well as the bare one. Those carry an
     attribute AND a class, so a lone .nav here would lose to them and the
     pulse would keep running for someone who asked for no motion. */
  .nav,
  [data-gl-ready="true"] .nav,
  [data-gl-failed="true"] .nav,
  [data-wake="go"] .nav{ animation:none; opacity:.9; }
}

/* The per-channel crossfade. Short and small: this is a caption changing
   under a picture, not a slide transition. */
.info__slide{
  transition:opacity .19s ease, transform .19s ease;
}


.info__slide[data-swap="true"]{
  opacity:0; transform:translateY(7px);
}
@media (prefers-reduced-motion: reduce){
  .info__slide{ transition:none; }
  .info__slide[data-swap="true"]{ opacity:1; transform:none; }
}

/* The scrim FADES, it does not appear.

   It used to exist only in product mode, so a full-viewport dark veil
   snapped into being the instant the mode flipped — the one genuinely
   abrupt thing about entering a product. It is always present now and
   crossfades on opacity, which is also what lets the copy's own 190ms swap
   and the room's cool-to-blue read as one movement instead of three.

   Fixed to the viewport, not to the copy: sized to the block it slid upward
   as the page scrolled and dragged the dark ground off the bottom of the
   frame. The bottom edge is part of the room's look and holds still.

   Weighted for copy that starts high. Clear-at-top-dark-at-bottom suits a
   block sitting on the floor of the frame; with the page starting under the
   header it left the first paragraphs on bare room. Dark from a fifth down,
   and the bottom is unchanged. */
.info::before{
  content:"";
  position:fixed; inset:0; z-index:-1; pointer-events:none;
  opacity:0;
  transition:opacity .34s ease;
  /* A GRADIENT, not a panel.
     Re-weighting this for copy that starts high went too far the other way:
     .84 by 30% is flat by the time anyone sees it, and the room — the whole
     reason there is a room — stopped showing at all. The ramp is long again,
     so the top third is genuinely the room, and it still reaches the same
     .94 at the bottom, which is the part that reads as the frame. */
  background:
    linear-gradient(180deg,
      rgba(5,7,11,0)   0,
      rgba(5,7,11,.22) 12%,
      rgba(5,7,11,.55) 30%,
      rgba(5,7,11,.80) 52%,
      rgba(5,7,11,.90) 74%,
      rgba(5,7,11,.94) 100%);
}
:root[data-mode="product"] .info::before{ opacity:1; }

@media (prefers-reduced-motion: reduce){
  .info::before{ transition:none; }
}

/* ====================================== THE PRODUCT VIEW IS A PAGE, NOT A BLOCK

   The copy block hugs the BOTTOM of the room, which is right for the tuner:
   one screen, the room above it, the plugin's name sitting on the desk. It is
   wrong the moment the thing scrolls. Everything piled against the bottom
   edge left the first screenful mostly empty room with the content crammed
   under it, and the room's own CRT saying GODNOB directly above a headline
   saying GODNOB.

   In the product view the copy starts under the header and runs down the
   page, which is what a page does.

   The scrim is re-weighted to match. A gradient that is clear at the top and
   dark at the bottom suits copy that lives at the bottom; with the copy
   starting high it left the first two paragraphs sitting on bare room. It is
   dark from a fifth of the way down now and holds — the BOTTOM of it is
   unchanged, which is the part that reads as the frame. */
:root[data-mode="product"] .mid{
  /* safe center, not start.
     A product with a lot to say fills the page and start-aligns, which is
     right. A sparse one — a name, a price, a button — start-aligned left a
     screen and a half of nothing under it and read as a page that had failed
     to finish loading. Centred, a short product sits in the frame.
     The `safe` keyword is what makes both true at once: it centres while the
     content fits and falls back to start the moment it does not, so nothing
     is ever pushed off the top where it cannot be scrolled back to. */
  align-content:safe center;
  padding-top:1.6rem;
}


/* ONE RHYTHM, and one scale, down the whole column.

   Two problems, both mine.

   Making .info__act a flex column STRETCHED its children, so .trial — an
   inline-block whose underline is meant to be the width of its own words —
   became full width and drew a rule across the column. Centred now, with the
   two blocks that genuinely want the full measure opting in.

   And the gap between the copy and the video was not being set by anything:
   .info__slide still used justify-content:space-between from when this was
   one locked screen, which distributes SLACK — and once the content is taller
   than the box there is none, so the claims sat hard against the video.

   The scale is three steps and nothing else:

     .55rem  a pair            name to its line, claim to its sentence
     1.0rem  within a group    between claims
     1.7rem  between groups    copy, video, transport, price, buy

   Two lines opt out on purpose. The offer date belongs to the price above it
   and the formats line to the button above it — a caption sitting a full step
   from its subject reads as a new thought rather than a footnote. */
:root[data-mode="product"] .info__slide{
  display:flex; flex-direction:column;
  justify-content:flex-start;
  gap:1.7rem;
  min-height:0;
}
:root[data-mode="product"] .info__lead{
  display:flex; flex-direction:column; gap:.55rem;
  /* Centred, not stretched. fitHead() puts an explicit width on the name so
     a long one can be condensed to fit, and a flex item with a width set does
     not stretch — it took that width and sat against the left edge while
     everything under it stayed centred. text-align cannot save it: that
     centres a box's contents, not the box. */
  align-items:center;
}
:root[data-mode="product"] .info__lead > *{ margin:0; }
:root[data-mode="product"] .claims{ margin:.5rem 0 0; gap:1rem; }

:root[data-mode="product"] .info__act{
  display:flex; flex-direction:column; align-items:center; gap:1.7rem;
}
:root[data-mode="product"] .info__act > *{ margin:0; }
/* NOT align-self:stretch, which is what these had and what pushed the signup
   off centre.

   A stretched flex item whose size is then capped by max-width aligns to the
   START of the cross axis — it does not re-centre in the space it gave back.
   So the signup took 416px of a 672px column and sat against the left edge
   while the price, the button and the title were all centred on 700. The
   video, at 34rem, would have done the same.

   align-items:center on the column already handles it: both carry width:100%,
   which resolves against the column and is then capped by their own max-width,
   and centred. Everything else takes its natural width, which is what keeps
   the trial's underline the width of the trial. */

:root[data-mode="product"] .info__ends{ margin-top:-1.15rem; }
:root[data-mode="product"] .info__formats{ margin-top:-1.15rem; }
/* The underline is the link's own, so it sits a pair-step from the words and
   a group-step from the button — not .2rem from one and 1.7 from the other. */
/* The trial is the second thing this page is for, so it stops being a
   caption under the button. Bigger type, more air, and it carries you to the
   field rather than straight out to a checkout — the address is the point.
   Still an underlined link and not a second filled shape: .buy stays the one
   solid block, or the page has two equal calls to action and no hierarchy. */
:root[data-mode="product"] .trial{
  font-size:.92rem;
  letter-spacing:.16em;
  padding-bottom:.5rem;
}

/* ============================================ THE PRODUCT VIEW'S OWN HEADER

   In the product view the catalogue links step aside for the plugin's name
   and the way back — one control, in the slot the links were using, rather
   than a second row of chrome.

   It is what the page was missing. Scrolled down, nothing said which plugin
   this was, and the only exit was a small button below a block taller than
   the screen. This answers both, and it stays put: the header is sticky in
   product mode so the name and the exit are never scrolled away from. */
.ident__back{
  display:inline-flex; align-items:center; gap:.5rem;
  margin-right:auto; padding:0;
  background:none; border:0; cursor:pointer;
  font-family:var(--mono); font-size:.62rem;
  letter-spacing:.22em; text-transform:uppercase;
  color:var(--signal);
  transition:color .18s ease;
}
.ident__back[hidden]{ display:none; }
.ident__back:hover{ color:var(--phosphor); }
.ident__back:focus-visible{ outline:1px solid var(--phosphor); outline-offset:3px; }
.ident__chev{ color:var(--dim); transition:transform .18s ease, color .18s ease; }
.ident__back:hover .ident__chev{ transform:translateX(-2px); color:var(--phosphor); }

:root[data-mode="product"] .cat{ display:none; }
:root[data-mode="product"] .ident{
  position:sticky; top:0; z-index:3;
}

/* ======================================================= THE DEMO VIDEO

   A poster with a play button until it is clicked, then the real player.
   The facade is deliberate: an embed is half a megabyte of someone else's
   player loaded before anyone asked to watch, which is precisely the cost
   that made the checkout lag. Nothing third-party loads until a click. */
.vid[hidden]{ display:none; }
.vid{
  position:relative; margin:0 auto 1.1rem;
  width:100%; max-width:34rem; aspect-ratio:16 / 9;
  background:#05070B;
  box-shadow:inset 0 0 0 1px rgba(255,255,255,.12);
  overflow:hidden;
}
.vid__start{
  position:absolute; inset:0; width:100%; height:100%;
  display:grid; place-items:center;
  background:none; border:0; cursor:pointer;
}
.vid__start:focus-visible{ outline:2px solid var(--phosphor); outline-offset:-4px; }
/* A triangle, drawn rather than fetched. */
.vid__play{
  width:0; height:0;
  border-left:1.15rem solid var(--phosphor);
  border-top:.72rem solid transparent;
  border-bottom:.72rem solid transparent;
  margin-left:.28rem;
  filter:drop-shadow(0 0 12px rgba(var(--cold), .5));
  transition:transform .18s ease;
}
.vid__start:hover .vid__play{ transform:scale(1.12); }
.vid iframe, .vid video{
  position:absolute; inset:0; width:100%; height:100%; border:0; display:block;
}

/* ====================================================== EMAIL CAPTURE

   Posts to Hostinger Reach. Sized and coloured as a quiet row, not a
   banner: it sits below the thing someone came here to buy and must not
   compete with it. */
.sub[hidden]{ display:none; }
.sub{ margin:1rem auto .2rem; width:100%; max-width:26rem; }
.sub__label{
  display:block; margin-bottom:.4rem;
  font-family:var(--mono); font-size:.6rem;
  letter-spacing:.16em; text-transform:uppercase; color:var(--dim);
}
.sub__row{ display:flex; gap:.4rem; }
.sub__email{
  flex:1 1 auto; min-width:0;
  /* Brighter than the room around it. At .05 on .16 this read as a disabled
     field on a dark page — something already filled in and closed, rather
     than somewhere to type. A field has to look like it is waiting. */
  background:rgba(255,255,255,.12); color:var(--phosphor);
  border:0; box-shadow:inset 0 0 0 1px rgba(255,255,255,.34);
  padding:.72rem .8rem;
  font-family:var(--mono); font-size:.76rem; letter-spacing:.06em;
}
.sub__email::placeholder{ color:#8A8782; }
.sub__email:focus{ outline:0; box-shadow:inset 0 0 0 1px rgba(var(--cold), .7); }
.sub__go{
  flex:0 0 auto; cursor:pointer; border:0;
  background:rgba(255,255,255,.18); color:var(--phosphor);
  padding:.72rem 1.15rem;
  font-family:var(--mono); font-size:.62rem;
  letter-spacing:.18em; text-transform:uppercase;
  transition:background .18s ease, color .18s ease;
}
.sub__go:hover{ background:var(--phosphor); color:var(--off); }
.sub__go[disabled]{ opacity:.5; cursor:default; }
.sub__note{
  margin:.45rem 0 0; min-height:1em;
  font-family:var(--mono); font-size:.6rem; letter-spacing:.1em;
  color:var(--dim);
}
.sub__note[data-state="ok"]{ color:var(--phosphor); }
.sub__note[data-state="err"]{ color:#E8A0A0; }

/* ============================================ TAP THE ROOM TO COME BACK OUT

   The empty part of .mid — everything above the copy block — is a button in
   the product view. Getting out was a single small link at the very bottom of
   a tall block, which on a phone is the furthest point from where a thumb
   rests and below the fold as often as not.

   It fills .mid rather than the viewport so it cannot swallow the header: the
   wordmark and the catalogue links live outside .mid and keep working. The
   copy block is raised above it so its own buttons still take their taps. */
.dismiss{ display:none; }
:root[data-mode="product"] .dismiss{
  display:block;
  position:absolute; inset:0;
  background:none; border:0; padding:0;
  cursor:pointer;
}
:root[data-mode="product"] .dismiss:focus-visible{
  outline:1px solid rgba(var(--cold), .5); outline-offset:-6px;
}
/* .mid has to be a containing block for it, and .info has to sit on top. */
.mid{ position:relative; }
:root[data-mode="product"] .info{ z-index:1; }

.mid{
  display:grid;
  /* 204px floor = the longest channel name ("BEATGRABBER") at full tracking.
     Below that the tuner overflows its column at mid-range widths. */
  /* One column. The copy sits under the monitor rather than beside it, now
     that the channel list is not taking a column of its own. */
  grid-template-columns:1fr;
  justify-items:center; align-content:end;
  gap:var(--gap);
  padding:0 var(--gap) 2vh; min-height:0;
}
/* width:100% matters more than it looks. .mid centres its items, so without it
   .info is shrink-to-fit - its width becomes whatever the current channel's
   copy happens to need. Everything sized in percentages inside it then moves
   per channel, and the name's own max-width:calc(100% - 10.5rem) fed off a box
   that depended on the name, collapsing it to 44px. */
.info{ position:relative; width:100%; max-width:34rem; text-align:center; }

/* Hold the title still.

   The copy block is anchored to the BOTTOM of the room, so every channel that
   is a different height moved the title: measured across the four channels the
   name sat at 538 / 562 / 626 / 626 on a 375x812 screen - an 88px jump just
   from switching. The block varies because the description runs one line or
   two, and because an off-air channel has no price and no offer line.

   Reserving the tallest channel's height fixes the block's top edge, which is
   what the title is measured from. The slack then falls at the BOTTOM, under
   the formats line - deliberately not above the button, which is where an
   earlier attempt at this put it and read as a hole in the page.

   The numbers are the tallest channel measured at each breakpoint plus a
   little headroom. If a description ever grows past them the title starts
   moving again for that channel, so they are worth re-measuring if the copy
   changes materially.

   The vh cap is what stops the reserve from breaking the locked single
   screen: 15rem on a 320x568 phone pushed the page 40px past the viewport
   and it began to scroll. Where the cap bites, the reserve is smaller than
   the tallest channel and a little movement comes back - on a 568px screen
   that is the honest trade, because the room has nowhere to put 240px of
   copy. */
.info__slide{
  /* 16.25rem covers the tallest channel now that the trial link is a row:
     measured 255px for Godnob, which carries the offer line and the trial
     where the off-air channels carry neither. The cap is 40vh rather than
     35vh so that it does not bite on a 667px phone, where 35vh was 233 and
     would have started the title moving again. */
  min-height:min(13.5rem, 30vh);
  /* flex-start, NOT space-between.
   *
   * space-between pinned both ends and let the reserve's slack fall between
   * the copy and the price. That was defensible while the only live channels
   * were two plugins whose blocks differed by a single row. It stopped being
   * true the moment Heist shipped: a one-line tagline where Godnob and Smite
   * carry two, and no offer line where Godnob has one, so ~38px of reserve
   * opened as a hole directly under the tagline.
   *
   * The reserve's actual job is keeping the NAME still — the arrows are
   * positioned off the title, so a title that moves takes them with it — and
   * packing from the top does that exactly as well. The slack now falls
   * under the buy button, where there is already a strip, instead of through
   * the middle of the copy. The buy button can shift a row between channels;
   * that is much cheaper than a hole that reads as a missing element.
   *
   * This is what .info__slide--quiet did for off-air channels and for any
   * channel with no description. Both of those were the same bug seen from
   * different sides, and the toggle that drove them asked whether there was
   * copy rather than how much — which is why Heist, having a description,
   * took the pinned path and showed the hole anyway. */
  display:flex; flex-direction:column; justify-content:flex-start;
}

/* The copy, in two groups.

   Measured before changing anything: eight elements stacked 287px tall — 31%
   of the screen — inside a 483px column, with 981px of viewport width sitting
   unused beside it. The problem was never how much there is to say, it was
   saying all of it in one narrow stack over the room.

   So: identity on the left (what this is), action on the right (hear it,
   what it costs, buy it). That order is the reference's — proof sits ABOVE
   the price in the action group, because hearing it is what makes the price
   worth reading. */
/* Centred at both breakpoints. These were left-aligned for the two-column
   desktop layout, which is gone — with the copy stacked, the price and offer
   line sitting hard left under a centred name read as a different column. */
.info__lead, .info__act{ text-align:center; }

@media (min-width: 860px){
  /* One column here too, stacked exactly as it is on a phone: name, then the
     description, then the price and the button under it.

     This was a two-column split - identity left, price and button right -
     which read fine but put the description BESIDE the thing it was meant to
     be arguing for. Reading order is the point: nobody decides to buy from a
     price, they decide from the sentence above it.

     width:100% still matters. .mid centres its items, so without it .info is
     shrink-to-fit and every percentage inside it moves with the copy. */
  .info{ width:100%; max-width:44rem; text-align:center; }
  /* 19rem: stacked, the tallest channel measured 300px at 1280x820, against
     the 200px this was when the copy sat in two columns. Same 40vh cap as the
     phone, so a short laptop window trades a little title drift for not
     scrolling. */
  .info__slide{ min-height:min(14.5rem, 32vh); }

  /* Bigger type than the phone, same arrangement. The name box stays capped
     and centred because the arrows are positioned off that box, not off the
     letters - a wider box would strand them past the end of a short name. */
  .info__head{
    font-size:clamp(1.8rem, 3.4vw, 3.2rem); line-height:clamp(1.8rem, 3.4vw, 3.2rem);
    max-width:22rem; margin-inline:auto;
  }
  .info__sub{ max-width:46ch; margin-inline:auto; }
}

/* Stacked below that, and centred — but tightened, because this is the layout
   the phone gets and it is the one that was cramped. */
@media (max-width: 859px){
  .info{ text-align:center; }
  /* Room for an arrow either side. The arrows are positioned off the name's
     own text, so without this a long name ("Level Detective") pushed the left
     one to -38px on a 375px screen - off the edge, unclickable. Capping the
     name instead lets fitHead shrink it to fit, which is what it is for. */
  .info__head{ margin-bottom:.45rem; max-width:calc(100% - 10.5rem); margin-inline:auto; }
  .info__sub{ margin-bottom:.7rem; }

  /* The eyebrow read "Channel 01 - transmitting". The ticker directly under
     the room already reads "CH 01 Godnob - transmitting", so on a phone this
     was a row of duplicate text sitting between the room and the name. */
  .info__eyebrow{ display:none; }

  /* One column, tight margins. A 375px screen has no spare width to split
     into two, so the saving here comes from having fewer rows, not from
     rearranging them: measured at 375x812 this block was 304px - 37% of the
     screen - and is now 235px. */
  .info__price{ margin:0; }
  .info__ends{ margin:.15rem 0 0; }
  .buy{ margin:.7rem 0 0; }

.info__formats{ margin:.35rem 0 0; }
}



/* The plugin rail. A row of presets on a piece of gear, not a nav bar.

   Up top, under the wordmark: what is on offer should be readable before you
   have touched anything, and down beside the buy button it was the last thing
   on the page rather than the first. */
.rail{
  display:flex; flex-wrap:wrap; justify-content:center;
  gap:.1rem .35rem;
  padding:.55rem var(--gap) .2rem;
  font-family:var(--mono); font-size:.6rem; letter-spacing:.16em;
  text-transform:uppercase;
}
.rail__item{
  background:none; border:0; cursor:pointer;
  padding:.3rem .45rem;
  font:inherit; letter-spacing:inherit; text-transform:inherit;
  color:var(--dim);
  text-shadow:0 1px 0 #000;
  transition:color .18s ease;
}
.rail__item:hover{ color:var(--signal); }
.rail__item[aria-current="true"]{
  color:var(--burn);
  /* Lit, not boxed — same rule as the tally lamp it replaces. */
  text-shadow:0 1px 0 #000, 0 0 10px rgba(var(--cold), .55);
}
.rail__item:focus-visible{ outline:1px solid var(--phosphor); outline-offset:2px; }
.rail__sep{ color:var(--ash); align-self:center; }
.info__price{ justify-content:center; }

.tuner__label{
  font-family:var(--mono); font-size:.58rem; letter-spacing:.22em;
  color:var(--dim); text-transform:uppercase; margin-bottom:.8rem;
  /* etched: dark below, a hairline catch above */
  text-shadow:0 1px 0 #000, 0 -1px 0 rgba(255,255,255,.05);
}
.chan{
  display:flex; align-items:center; gap:.6rem; width:100%;
  /* Wraps on purpose. On desktop the tuner sits in a minmax(204px,15%) column,
     and a channel carrying an offer badge cannot fit both on one line there —
     the badge rode over the name. Wrapping is the only option that does not
     either truncate a plugin's name or rewrite its copy. */
  flex-wrap:wrap; row-gap:.15rem;
  padding:.5rem 0; background:none; border:0; border-top: var(--hair);
  color:var(--dim); text-align:left; cursor:pointer;
  font-family:var(--grot); font-size:.76rem;
  font-variation-settings:"wdth" 112,"wght" 400;
  letter-spacing:.09em; text-transform:uppercase;
  transition:color .18s ease;
  text-shadow:0 1px 0 #000;
}
.chan:last-child{ border-bottom: var(--hair); }
.chan__no{ font-family:var(--mono); font-size:.64rem; letter-spacing:.05em; }
.chan__name{ flex:1 1 auto; min-width:0; white-space:nowrap; }

/* Offer badge on a channel. Glow, never fill: chroma in this room is light,
   so the badge is lit text rather than a coloured chip — a filled pill here
   would be the one piece of ink on the page and would look pasted on.

   The pulse is slow and shallow on purpose. A fast or deep blink reads as an
   ad; this reads as something with power running through it. */
.chan__badge{
  font-family:var(--mono); font-size:.56rem; letter-spacing:.14em;
  text-transform:uppercase; white-space:nowrap;
  color:rgb(var(--cold));
  text-shadow:0 0 8px rgba(var(--cold), .55), 0 0 18px rgba(var(--cold), .28);
  animation:badgePulse 2.6s ease-in-out infinite;
  /* Its own line, under the name. The tally dot stays up on the first line
     with the channel it belongs to. */
  order:4; flex:0 0 100%;
}
.chan__dot{ order:3; }
@keyframes badgePulse{
  0%, 100%{ opacity:.62; text-shadow:0 0 6px rgba(var(--cold), .38), 0 0 14px rgba(var(--cold), .16); }
  50%     { opacity:1;   text-shadow:0 0 10px rgba(var(--cold), .75), 0 0 24px rgba(var(--cold), .40); }
}
@media (prefers-reduced-motion: reduce){
  .chan__badge{ animation:none; opacity:1; }
}

/* tally light: a dark recessed dot until its channel is tuned. lit in cold
   blue because a tally lamp is light, and light is the one place chroma is
   allowed — this keeps --burn for the live text and the buy button. */
.chan__dot{
  width:7px; height:7px; flex:0 0 auto; border-radius:50%;
  background:#050506;
  box-shadow:inset 0 1px 1px #000, 0 1px 0 rgba(255,255,255,.05);
  transition:background .18s ease, box-shadow .18s ease;
}
.chan:hover{ color:var(--signal); }
.chan[aria-current="true"]{ color:var(--burn); }
.chan[aria-current="true"] .chan__dot{
  background:rgba(var(--cold),.95);
  box-shadow:0 0 6px 1px rgba(var(--cold),.55), 0 0 14px 3px rgba(var(--cold),.22);
}
.chan:focus-visible{ outline:1px solid var(--phosphor); outline-offset:3px; }

/* ---- product ---- */
.info{ align-self:center; }
.info__eyebrow{
  font-family:var(--mono); font-size:.6rem; letter-spacing:.24em;
  color:var(--signal); text-transform:uppercase; margin-bottom:1rem;
}
.info__head{
  /* wdth is re-set by fitHead() for names with no break opportunity —
     "BEATGRABBER" at wdth 125 is 432px in a 400px column. Deliberately no
     overflow-wrap here: the word must be allowed to overflow so fitHead can
     measure it, otherwise it silently breaks mid-word instead. */
  font-variation-settings:"wdth" 125,"wght" 200;
  font-size:clamp(1.6rem,2.9vw,2.8rem);
  /* A LENGTH, not a multiplier, and deliberately the same expression as the
     font-size. fitHead shrinks the font to make a long name fit, and with
     line-height:1 that shrank the line box too - so the title's own centre
     moved between channels, and the arrows tracking it wobbled with it. As a
     length the box keeps its height whatever fitHead does to the glyphs, and
     the text stays centred in it by half-leading. */
  line-height:clamp(1.6rem,2.9vw,2.8rem);
  letter-spacing:.015em; text-transform:uppercase;
  margin-bottom:.9rem;
  /* One line, always. Two-word names ("Level Detective") wrapped at the space
     once the column got narrower, which made the title 52px tall against 26px
     for the rest - the name itself changing height as you switch channels.
     Wrapping also hid the overflow that fitHead measures, so it never shrank
     the name either. Forbidding the wrap gives fitHead the overflow it needs
     and keeps every title one line high. */
  white-space:nowrap;
}
.info__sub{
  /* One step above --signal, and only here. This is the page's only block
     of running prose; the labels and units around it stay at --signal so
     the hierarchy does not flatten. */
  color:#ADACA9; font-size:clamp(.8rem,1vw,.92rem);
  line-height:1.55; margin-bottom:1.5rem;
}
.info__price{ display:flex; align-items:baseline; gap:.7rem; margin-bottom:.9rem; font-family:var(--mono); }
/* An author display: beats the UA rule for [hidden], so the price row needs
   this or setting .hidden does nothing at all to it. */
.info__price[hidden]{ display:none; }
.price__now{ font-size:1.45rem; color:var(--burn); }
/* --signal, not --ash. At --ash this measured 1.85:1 against the room and
   simply did not render — the anchor that makes the live price read as a
   discount was invisible on the page every visitor sees first. */
.price__was{ font-size:.78rem; color:var(--signal); text-decoration:line-through; }
.price__cur{ font-size:.62rem; font-family:var(--mono); letter-spacing:.14em;
             color:var(--signal); text-transform:uppercase; }
.info__ends{ font-family:var(--mono); font-size:.58rem; letter-spacing:.14em;
             text-transform:uppercase; color:var(--signal); margin-bottom:.6rem; }

.buy{
  display:flex; align-items:center; justify-content:space-between; gap:2.5rem;
  /* Sized to its label, not to the column. At full width on a desktop the
     button ran the whole 44rem of the copy block, which put the arrow a
     hand's width from the words and read as a banner rather than a control.
     The min-width keeps it substantial for a short label like "Buy Smite",
     and collapses to the full column on a phone where full width is right. */
  width:fit-content; min-width:min(21rem, 100%);
  margin-inline:auto;
  padding:.95rem 1.8rem;
  /* Not --burn. Pure white against a room this dark blew out the whole right
     side of the frame and pulled the eye off the CRT. Dimmed to a bone white,
     with --burn kept for hover so the button still brightens on approach. */
  background:#B9B6B0; color:var(--off); border:0; cursor:pointer;
  font-family:var(--grot); font-size:.74rem;
  font-variation-settings:"wdth" 112,"wght" 600;
  letter-spacing:.2em; text-transform:uppercase;
}
.buy:hover{ background:var(--phosphor); }
.buy:focus-visible{ outline:2px solid var(--phosphor); outline-offset:3px; }
.buy[disabled]{ background:none; color:var(--ash); box-shadow:inset 0 0 0 1px #1A1A1A; cursor:not-allowed; }
/* The product view reads over the room, so the room has to give way.

   The claims make this block about 120px taller than the tuner's, which lifts
   it over the monitor — there is no arrangement of three claims, a price and
   two buttons that fits under the CRT on a phone. A scrim was rejected for
   the tuner, and rightly: there the room IS the page and blocking it defeats
   the point. Here the room is deliberately backdrop, already cooled to blue
   to say so, and the text has to be readable over the brightest thing in the
   frame. Transparent at the top so the room still bleeds through.

   Sized to the copy block, not the viewport, so it cannot creep up the room
   when the copy is short. */
:root[data-mode="product"] .info{
  padding:1.6rem 1rem .4rem;
}
/* Full bleed, wall to wall.

   Painted on .info's own background this stopped at the copy block's
   max-width, which put a hard vertical seam down each side with the room
   still bright beyond it — the backdrop read as a panel dropped on the room
   rather than the room going dark. left:50% with a -50vw margin escapes the
   centred column and spans the viewport instead.

   z-index:-1 sits it behind the copy but still inside .ui, which is z-index
   20 and above the canvas, so it darkens the room without hiding the text. */


/* The scrim stops travelling once the page can scroll.

   Sized to the copy block it slid upward with it, so scrolling dragged the
   dark ground off the bottom of the frame and let the room come up
   underneath the buttons. The bottom edge of that gradient is part of the
   room's look — the frame is meant to sit in darkness whatever the copy is
   doing.

   Fixed to the VIEWPORT in product mode: the same gradient, in the same place,
   at every scroll position. The copy now travels through it rather than
   carrying it, which also means content arriving from below is already
   readable before it gets there.

   The 6rem tail is dropped with it. That existed to reach past the end of a
   block-sized scrim; a viewport-sized one already ends exactly where the
   frame does. */


/* The product view has to fit a phone.

   Three claims, a price, two buttons and a way back came to 403px of copy on
   a 375x812 screen, which is 81px more than there is room for once the room,
   the header and the ticker have taken theirs. These are the savings that do
   not cost content: tighter type, tighter gaps, less padding. */
@media (max-width: 859px){
  :root[data-mode="product"] .info{ padding-top:.5rem; }
  :root[data-mode="product"] .claims{ gap:.3rem; }
  :root[data-mode="product"] .claims li{ font-size:.76rem; line-height:1.26; }
  :root[data-mode="product"] .info__head{ margin-bottom:.25rem; }
  /* The short line under the name costs a row; the rest of the view pays for
     it rather than the line being dropped, because a plugin nobody has heard
     of needs one sentence saying what it is. */
  :root[data-mode="product"] .info__sub{ margin-bottom:.4rem; font-size:.86rem; }
  :root[data-mode="product"] .buy{ margin-top:.4rem; }
  :root[data-mode="product"] .trial{ margin:.6rem 0 .1rem; }
  :root[data-mode="product"] .back{ margin-top:.35rem; }
  :root[data-mode="product"] .info__formats{ margin-top:.15rem; }
  /* The player is another row on a screen that had none spare. These are the
     savings that cost no content — the claims lose a little size and the gaps
     tighten — rather than dropping a claim or the formats line. */
  :root[data-mode="product"] .claims li{ font-size:.72rem; line-height:1.24; }
  :root[data-mode="product"] .claims{ gap:.24rem; }
  :root[data-mode="product"] .ab{ margin:.45rem 0 .1rem; }
  :root[data-mode="product"] .ab__play{ width:2.1rem; height:2.1rem; }
  :root[data-mode="product"] .ab__switchtrack{ width:6.8rem; height:2.1rem; }
  :root[data-mode="product"] .info__sub{ margin-bottom:.3rem; }
  :root[data-mode="product"] .back{ margin-top:.25rem; }
}

/* "Free 7-day trial available", under the description in the tuner.

   Lit the same way the trial link is — bright ink, cold halo — because it is
   the same offer said earlier, and the room's rule is that the blue is light
   and never ink. Small enough not to compete with the price. */
.freetrial{
  /* Tight to the price and clear of the button. It qualifies the PRICE — a
     cheaper way in — so it belongs with it. Measured the other way round it
     sat 14px under the price and 0px above the button, welded to the control
     it is not part of and floating off the line it describes. */
  margin:.1rem 0 .6rem;
  font-family:var(--mono); font-size:.66rem;
  letter-spacing:.16em; text-transform:uppercase;
  color:var(--phosphor);
  text-shadow:0 1px 0 #000, 0 0 10px rgba(var(--cold), .45);
}
.freetrial[hidden]{ display:none; }

/* The three claims, shown only in the product view.

   Each is a bolded assertion with a plain sentence under it — the shape the
   old product page used, and the one that reads fastest: you can take the
   three bold fragments alone and still know what the plugin does. */
/* Six blocks now, not three, and at three sizes that did not matter.
   At one size with a .55rem gap six of them read as a single wall of grey —
   the leads and the bodies were the same weight of information, so there was
   nothing for the eye to land on and the whole set had to be read or skipped.
   The lead steps UP and the body steps DOWN, and the gap between blocks
   roughly doubles: you can now read six leads in a glance and drop into the
   one that catches. Same total copy, a third of the work to scan it. */
.claims{
  list-style:none; margin:.2rem auto 0; padding:0;
  display:flex; flex-direction:column; gap:1.15rem;
  max-width:46ch;
}
.claims li{ font-size:.85rem; line-height:1.5; color:var(--signal); }
.claims b{
  display:block; color:var(--phosphor); font-weight:500;
  font-variation-settings:"wght" 600;
  font-size:.95rem; line-height:1.35; margin-bottom:.22rem;
}
.claims[hidden]{ display:none; }

/* The last line before the price. Sized between the claim bodies and the
   claim leads — it is one sentence doing more work than any single claim, and
   at the body size it disappeared into the block above it. No rule, no box:
   the flex gap on .info__act is the separation. */
.info__note{
  max-width:44ch; text-align:center;
  /* The same size as a claim LEAD, in the colour of a claim BODY. .88rem
     against .85rem bodies was a 3% step, which is not a step — it just read
     as a seventh paragraph. Matching the leads makes it land as its own beat;
     staying at --signal rather than --phosphor stops it reading as a heading
     for the price underneath it. */
  font-size:.95rem; line-height:1.6; color:var(--signal);
}
.info__note[hidden]{ display:none; }

/* Quietest text on the page, and deliberately so — a condition of use, set
   with the spec line it qualifies rather than dressed up as copy. Mono to
   match the formats above it, but sentence case: the formats line is a list
   of tokens and shouts in caps, whereas this is a sentence and would read as
   a warning label. Still --signal, never --dim, which the palette reserves
   against body copy. */
.info__caution{
  margin:.45rem 0 0; font-family:var(--mono);
  font-size:.62rem; line-height:1.5; letter-spacing:.04em;
  color:var(--signal);
}
.info__caution[hidden]{ display:none; }

/* Back to the tuner. Quiet — leaving is not the action being encouraged. */
.back{
  /* The only way out of the product view, so it has to look like a control.
     As bare dim text it was indistinguishable from the formats line directly
     above it and people had no visible way back — the browser's own button
     was the only exit, which on a single-page room is not an exit at all.
     An outlined pill reads as pressable without competing with .buy, which
     stays the one filled shape on the page. */
  display:inline-flex; align-items:center; gap:.5rem;
  margin:.9rem auto 0; padding:.6rem 1.15rem;
  background:rgba(255,255,255,.04);
  box-shadow:inset 0 0 0 1px rgba(255,255,255,.18);
  border:0; cursor:pointer;
  font-family:var(--mono); font-size:.68rem;
  letter-spacing:.16em; text-transform:uppercase;
  color:var(--signal);
  transition:color .18s ease, background .18s ease, box-shadow .18s ease;
}
.back:hover{
  color:var(--phosphor);
  background:rgba(255,255,255,.09);
  box-shadow:inset 0 0 0 1px rgba(255,255,255,.34);
}
.back:focus-visible{ outline:1px solid rgb(var(--cold)); outline-offset:3px; }
.back[hidden]{ display:none; }

/* ============================================================ A/B IN THE ROOM

   Recovered from before the toggle was removed from the tuner, and now used
   by the PRODUCT VIEW instead. Same component, same surface treatment; only
   where it appears has changed. --ink-dim in the original is wall.css's token
   and does not exist here, so it reads --signal, the room's readable dim.
   The example-picker rules came out with it: there is one pair per plugin. */
.ab[hidden]{ display:none; }
.ab{ margin:1.1rem 0 .2rem; }
.ab__controls{ display:flex; align-items:center; justify-content:center; gap:.8rem; }
.ab__controls{ display:flex; gap:.6rem; align-items:center; flex-wrap:wrap; }
.ab__play{
  flex:0 0 auto; width:3.4rem; height:3.4rem; border-radius:50%;
  display:grid; place-items:center; cursor:pointer;
  background:rgba(255,255,255,.05);
  border:1px solid rgba(255,255,255,.16);
  transition:background .12s linear, border-color .12s linear;
}
.ab__play:hover{ background:rgba(255,255,255,.1); border-color:rgba(var(--cold),.5); }
.ab__play:focus-visible{ outline:2px solid rgba(var(--cold),.9); outline-offset:3px; }
.ab__icon{
  width:0; height:0; margin-left:3px;
  border-left:14px solid var(--phosphor);
  border-top:9px solid transparent;
  border-bottom:9px solid transparent;
}
.ab__play[data-playing="true"] .ab__icon{
  margin-left:0; width:14px; height:17px; border:0;
  background:linear-gradient(to right,
    var(--phosphor) 0 5px, transparent 5px 9px, var(--phosphor) 9px 14px);
}
.ab__switch{
  flex:0 0 auto; padding:0; cursor:pointer;
  background:none; border:0;
}
.ab__switch:focus-visible{ outline:2px solid rgba(var(--cold),.9); outline-offset:3px; }
.ab__switchtrack{
  position:relative; display:block;
  width:9.5rem; height:3.4rem;
  background:rgba(0,0,0,.55);
  border:1px solid rgba(255,255,255,.12);
  box-shadow:inset 0 2px 8px rgba(0,0,0,.8);
  overflow:hidden;
}
.ab__switchlabel{
  position:absolute; top:50%; transform:translateY(-50%);
  font-family:var(--mono); font-size:.66rem; letter-spacing:.16em;
  text-transform:uppercase; color:var(--signal);
  transition:color .14s linear; z-index:2; pointer-events:none;
}
.ab__switchlabel--off{ left:.85rem; }
.ab__switchlabel--on{ right:.9rem; }
.ab__knob{
  position:absolute; top:3px; left:3px; z-index:1;
  width:calc(50% - 3px); height:calc(100% - 6px);
  background:linear-gradient(#3A3D44, #24262B);
  border:1px solid rgba(255,255,255,.14);
  box-shadow:0 2px 6px rgba(0,0,0,.7);
  transition:transform .16s cubic-bezier(.4,.9,.3,1), background .16s linear, box-shadow .16s linear;
}
.ab__switch[data-on="true"] .ab__knob{
  transform:translateX(100%);
  background:linear-gradient(rgba(var(--cold),.38), rgba(var(--cold),.16));
  border-color:rgba(var(--cold),.6);
  box-shadow:0 2px 6px rgba(0,0,0,.7), 0 0 20px rgba(var(--cold),.28);
}
.ab__switch[data-on="true"] .ab__switchlabel--on{ color:var(--phosphor); }
.ab__switch[data-on="false"] .ab__switchlabel--off{ color:var(--phosphor); }
.ab__status{
  font-family:var(--mono); font-size:.62rem; letter-spacing:.14em;
  text-transform:uppercase; color:var(--signal);
}


/* Compact and rounded, for the room only.

   The lifted control was built for a product page with room to spare: 3.4rem
   tall with square corners, which in the room ate a band of the copy column
   and read as a piece of hardware bolted onto a page that has no other square
   edges in its UI. Here it is a pill — smaller, softer, and one line.

   The design system's rule against rounded corners governs the RENDERED
   layer, where a radius stops reading as an era artifact. This is page
   furniture sitting above the canvas, and the same rule does not apply. */
.ab{ margin:.55rem 0 .15rem; }
.ab__controls{ gap:.55rem; }

.ab__play{
  width:2.5rem; height:2.5rem;
}
.ab__icon{ transform:scale(.8); }

.ab__switchtrack{
  width:11rem; height:2.5rem;
  border-radius:999px;
}
.ab__knob{ border-radius:999px; }
.ab__switchlabel{ font-size:.6rem; letter-spacing:.12em; }
.ab__switchlabel--off{ left:1.1rem; }
.ab__switchlabel--on{ right:1.1rem; }

/* The status word is what the switch already says. Two labels and a knob
   position for one piece of state is one too many. */
.ab .ab__status{ display:none; }

/* Smaller than the product page's version.
   That one is the page's main event, sitting alone in a section. Here it is
   one row among the price, the buy button and the trial link, and at 3.4rem
   tall it out-weighed all of them. Rounded to a pill deliberately — the
   design system's no-radius rule governs the RENDERED layer, and this is page
   furniture over the canvas. */
.ab{ margin:.75rem 0 .15rem; }
.ab__controls{ gap:.5rem; }
.ab__play{ width:2.3rem; height:2.3rem; }
.ab__icon{ transform:scale(.72); }
.ab__switchtrack{ width:7.4rem; height:2.3rem; border-radius:999px; }
.ab__knob{ border-radius:999px; }
.ab__switchlabel{ font-size:.56rem; letter-spacing:.1em; }
.ab__switchlabel--off{ left:.7rem; }
.ab__switchlabel--on{ right:.7rem; }
/* The status word repeats what the switch already says. */
.ab .ab__status{ display:none; }

/* ============================================================ LATE INTRO

   Shown only between midnight and five, once a session. Built around a piece
   of luck in the name: SOMNA's letters already sit inside INSOMNIAC, in order —

       I N [S O M N] I [A] C
            S O M N     A

   SOMN is contiguous and the A follows, so the brand letters never move or
   fade. The four that arrive — the N of "an", then IN, I and C — open out
   from zero width around them. Nothing cross-fades, because nothing needs to:
   the word was there all along.

   One face, one size, mono throughout, because this is the site's retro
   register and a second face here would read as a different product. Only
   SOMNA glows; the sentence around it is plain. */
.intro{
  position:fixed; inset:0; z-index:60;
  display:grid; place-content:center; justify-items:center;
  gap:1.1rem; padding:2rem;
  background:var(--off);
  transition:opacity .7s ease;
}
.intro[hidden]{ display:none; }
.intro[data-done="true"]{ opacity:0; pointer-events:none; }

/* The clock is the arrival: it is what you would actually look at first. */
.intro__time{
  margin:0; display:flex; align-items:baseline; gap:.55rem;
  font-family:var(--mono); font-weight:300;
  font-size:clamp(3.2rem, 17vw, 6rem); line-height:1;
  letter-spacing:.02em; color:var(--phosphor);
  font-variant-numeric:tabular-nums;
  opacity:0; transform:translateY(8px);
  transition:opacity .9s ease, transform .9s cubic-bezier(.16,.84,.34,1);
}
.intro__ampm{
  font-size:.26em; letter-spacing:.22em; color:var(--signal);
}
.intro[data-step="1"] .intro__time,
.intro[data-step="2"] .intro__time,
.intro[data-step="3"] .intro__time{ opacity:1; transform:none; }

.intro__say{
  margin:0; opacity:0;
  font-family:var(--mono); font-size:clamp(.78rem, 3.4vw, .95rem);
  letter-spacing:.14em; text-transform:lowercase; color:var(--signal);
  white-space:nowrap;
  transition:opacity .8s ease;
}
.intro[data-step="2"] .intro__say,
.intro[data-step="3"] .intro__say{ opacity:1; }

/* SOMNA glows; the letters that arrive around it do not.

   That is the whole reveal: the brand is already sitting inside the longer
   word, lit, and INSOMNIAC assembles around it —

       I N [S O M N] I [A] C
            S O M N     A

   SOMN is contiguous and the A follows, so these five never move and never
   fade. They are readable as "somna" from the moment the line lands. */
.intro__lit{
  color:var(--phosphor);
  text-shadow:0 0 10px rgba(var(--cold), .75), 0 0 26px rgba(var(--cold), .35);
}

/* Width, not opacity — a letter fading in on top of the others is a dissolve,
   and the point is that the word is being MADE longer around letters that
   stay put. */
.intro__grow{
  display:inline-block; overflow:hidden; vertical-align:bottom;
  max-width:0; opacity:0;
  transition:max-width .5s cubic-bezier(.16,.84,.34,1), opacity .35s ease .1s;
}
.intro[data-step="3"] .intro__grow{ max-width:2.2em; opacity:1; }
/* The middle I lands last, so SOMN and A hold as one word for a beat before
   being pushed apart. That beat is the whole joke. */
.intro[data-step="3"] .intro__grow[data-grow="mid"]{ transition-delay:.28s, .34s; }

.intro__skip{
  margin-top:1rem; background:none; border:0; cursor:pointer;
  font-family:var(--mono); font-size:.62rem; letter-spacing:.2em;
  text-transform:uppercase; color:var(--ash);
  transition:color .18s ease;
}
.intro__skip:hover{ color:var(--phosphor); }
.intro__skip:focus-visible{ outline:1px solid rgb(var(--cold)); outline-offset:4px; }

/* No motion, no intro. */
@media (prefers-reduced-motion: reduce){
  .intro{ display:none; }
}

/* The trial link. Sits under the buy button, reads as a way out rather than
   the main road: mono, small, letterspaced, and lit only on hover — the same
   rule the rest of the room follows, where chroma is light and never ink. */
.trial{
  display:inline-block;
  font-family:var(--mono);
  font-size:.76rem; letter-spacing:.14em; text-transform:uppercase;
  /* Lit rather than coloured in. At --signal grey against a dark room this
     read as a caption under the button instead of a second thing you could
     do. The room's rule is that the cold blue is light and never ink, so the
     way to make it carry is to give it the glow the arrows and the neon sign
     already have — bright text, cold halo — not blue letters. */
  color:var(--phosphor); text-decoration:none;
  text-shadow:0 1px 0 #000, 0 0 12px rgba(var(--cold), .5);
  border-bottom:1px solid rgba(var(--cold), .38);
  padding-bottom:.2rem;
  /* Clear of the button. It was sitting hard under it and reading as part of
     the same block. */
  margin:1.15rem 0 .2rem;
  transition:color .18s ease, border-color .18s ease, text-shadow .18s ease;
}
.trial:hover{
  color:var(--burn); border-color:rgba(var(--cold), .85);
  text-shadow:0 1px 0 #000, 0 0 18px rgba(var(--cold), .9);
}
.trial:focus-visible{ outline:1px solid rgb(var(--cold)); outline-offset:3px; }
/* Removed, not merely hidden.

   This held its space so the buy button could not move between a channel with
   a trial and one without. That reservation stopped earning its keep once the
   link became product-view-only: in the tuner it now never shows on any
   channel, so the reserved row was a permanent empty band under the button,
   and within the product view every channel you can reach has a trial. */
.trial--none{ display:none; }

.info__formats{
  margin-top:.9rem; font-family:var(--mono); font-size:.6rem;
  letter-spacing:.16em; color:var(--signal); text-transform:uppercase;
}

/* ---- strip ---- */
.strip{
  display:flex; align-items:center; gap:1.2rem;
  padding:.7rem var(--gap); border-top: var(--hair);
  background:linear-gradient(transparent, #000 60%);
  font-family:var(--mono); font-size:.61rem; letter-spacing:.18em;
  text-transform:uppercase; color:var(--signal);
}
.strip__state{ color:var(--phosphor); white-space:nowrap; }
.strip__ticker{ flex:1; overflow:hidden; white-space:nowrap; }
.strip__ticker span{ display:inline-block; padding-left:100%; animation: roll 36s linear infinite; }
@keyframes roll{ to{ transform:translateX(-100%); } }

/* ============================================================ NIGHT */

body[data-night="true"]{ --signal:#6C6C6C; }
body[data-night="true"] .wall,
body[data-night="true"] .floor{ filter:brightness(.62); }
body[data-night="true"] .tv{ filter:brightness(1.25); }
body[data-night="true"] .spill{ opacity:1.35; }
body[data-night="true"]::after{ opacity:.075; }

/* ============================================================ SMALL */

@media (max-width: 1024px){
  /* height:100% from the base rule caps the scroll container at one viewport,
     so anything past it is unreachable — that silently hid two channels */
  html,body{ height:auto; min-height:100%; overflow:auto; }
  .room{ position:fixed; }
  /* vh FIRST, then dvh. dvh landed in iOS Safari 15.4, and an older iPhone
     drops the whole declaration as unknown — leaving .ui with no min-height at
     all, collapsing it to its content and squeezing the page up to the top.
     The vh line is what every such device actually gets; the dvh line only
     overrides it where the browser understands it. Do not merge these. */
  /* vh first, dvh second: a browser that does not know dvh drops that
     declaration and keeps the one before. Do not merge them. */
  .ui{ position:relative; min-height:100vh; min-height:100dvh; }

  /* -webkit-fill-available, for iOS Safari ONLY.
     It is Safari's own answer to its bottom bar and predates dvh by years, so
     an old iPhone needs it — but Chrome also understands the keyword and
     resolves it to `stretch`, where it can size the page taller than the
     viewport and leave a band of page background below the room. The
     -webkit-touch-callout probe is true on iOS and nowhere else, which is
     what keeps this on the one engine that wants it. */
  @supports (-webkit-touch-callout: none){
    html,body{ min-height:-webkit-fill-available; }
    .ui{ min-height:-webkit-fill-available; }
  }
  /* Stop the rubber-band entirely rather than styling what it reveals. */
  html,body{ overscroll-behavior-y:none; }
  .mid{
    grid-template-columns:1fr;
    align-content:end;
    /* top pad clears the telly — the copy must never ride up over the glass */
    gap:1.2rem; padding:37vh var(--gap) 1rem;
  }
  /* No order overrides. These put .info second when there was a channel list
     to sit above it; with that gone they only pushed the plugin rail — which
     has no order of its own — in front of the copy it belongs under. */
  .set{ left:50%; top:23%; width:min(58vw, 30vh); transform:translate(-50%,-50%); }
  /* the bloom reads much hotter at phone scale — the screen is a bigger share
     of the viewport, so the same wash flattens the room to grey */
  .bloom{ left:50%; top:23%; width:150vh; height:150vh; opacity:.6; }
  body:has(.tv__screen[data-live="true"]) .bloom{ opacity:.78; }
  /* tighter room on a phone, or the wall washes out to flat grey */
  .vig{ background:radial-gradient(72% 44% at 50% 24%, transparent 26%, #000 90%); }
  /* at phone width the speakers get cropped to slivers at the frame edges and
     read as noise rather than as gear, so they come out entirely */
  .mon{ display:none; }
  .keys{ width:min(150vw, 108vh); bottom:-9%; filter:blur(7px); }
  .info__sub{ margin-bottom:1.1rem; }
  .strip{ position:sticky; bottom:0; background:#000; }
}

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