/*
 * First-generation iPod — Geoff's Figma mockup (node 0:3641, Frame 15,
 * 272 × 449.143). Every visual layer is one of the mock's own SVG exports.
 *
 * Two structural decisions that keep this working:
 *
 * 1. Art layers are <span>s that app.js fills with INLINE <svg>, never
 *    background-image or <img>. WKWebView rasterizes an SVG background-image
 *    at its intrinsic 1x size and upscales the bitmap, which shipped blocky
 *    sprite edges on Retina; inline SVG stays in the vector layer and is
 *    crisp at every window size. (The spans began as background-image hosts
 *    in the blog era, dodging the `.prose figure img` paper-rectangle rule.)
 *
 * 2. All lengths that were px in Figma are written as calc(N * var(--px)),
 *    where --px is one Figma pixel of the 272-wide device (0.36765cqw).
 *    Percentages are exact layout-box fractions measured from the mock.
 */

@font-face {
  /* ChicagoFLF — Robin Casady's public-domain Chicago; the mock's LCD face. */
  font-family: "ChicagoFLF";
  src: url("assets/ChicagoFLF.woff") format("woff");
  font-weight: 400;
  font-style: normal;
  font-display: swap;
}

/* The device renders at the mock's own scale — 272px wide, centered — not
   stretched to the article measure. It is a drawing, not a photograph, so it
   skips the feathered-photo treatment (the tape-screen precedent). */
.frame {
  position: relative;
  width: min(272px, 100%);
  aspect-ratio: 272 / 449.143;
  margin: 0 auto;
  container-type: inline-size;
  --px: 0.36765cqw;
  /* Three soft layers (Geoff, Aug 27: "more shadow still! it looks like
     it's floating"): the broad wash carries the mass, the mid pass builds
     the falloff, and a tight low-offset pass hugs the silhouette — the
     contact density that reads as grounded instead of hovering.
     DEAD IN-APP: app.css overrides this to `filter: none` and carries the
     live shadow on .deviceShadow — the native Shadow slider lands there,
     not here. This rule survives only as the blog article's original. */
  filter: drop-shadow(0 30px 50px rgb(28 34 40 / 20%))
    drop-shadow(0 12px 22px rgb(28 34 40 / 13%))
    drop-shadow(0 3px 7px rgb(28 34 40 / 12%));
}

.device,
.interactive {
  position: absolute;
  inset: 0;
}

/* One class per art layer; .sprite is the shared plumbing. The background
   properties only serve the file:// fallback path in app.js — the real app
   injects inline SVG children. */
.sprite {
  position: absolute;
  background-repeat: no-repeat;
  background-size: 100% 100%;
  pointer-events: none;
  user-select: none;
}

/* The injected root mirrors the old background-size: 100% 100% stretch and
   the image path's viewport clip (several exports say overflow="visible",
   which an image render ignored but an inline render would honor). */
.sprite > svg {
  display: block;
  width: 100%;
  height: 100%;
  overflow: hidden;
}

/* --- Device art (masked media layers) --- */

.bodyStroke,
.bodyMask {
  inset: 0;
}

/* The bodyMask sprite IS the shell's glare — the whole file is Vector 7,
   the blurred white wedge, masked to the body's rounded rect. It used to
   sit at full strength no matter what the Glare control said, which is why
   "there is still glare on the top white part" (Geoff, Aug 29). Both glare
   copies now ride one scalar: at --glare 1 this is the as-shipped look
   (shell wedge full, glass a 0.45 step below), at 0 the device carries no
   reflection at all. */
.bodyMask {
  opacity: var(--glare, 1);
}

.bodyStrokeInner,
.bodyOverlay,
.bodyMultiply {
  left: 0;
  top: 0.223%;
  width: 100%;
  height: 99.3%;
}

.bodyOverlay {
  mix-blend-mode: overlay;
}

.bodyMultiply {
  mix-blend-mode: multiply;
}

.bodySide {
  left: 94.53%;
  top: 90.172%;
  width: 5.47%;
  height: 9.238%;
}

.bodySideCap {
  left: 90.526%;
  top: 93.237%;
  width: 9.287%;
  height: 6.593%;
}

/* The brushed acrylic face — Figma's five-layer fill, verbatim. The three
   data-URI radials are the mock's matrix-transformed sheens (a glint low on
   the face, a glow at the bottom edge, a graze of light off the right side)
   that CSS radial-gradient cannot rotate. */
.bodyFill {
  position: absolute;
  left: 1.838%;
  top: 1.113%;
  width: 96.324%;
  height: 97.296%;
  border-radius: calc(20 * var(--px));
  pointer-events: none;
  box-shadow:
    calc(1 * var(--px)) calc(2 * var(--px)) calc(1 * var(--px)) rgb(0 0 0 / 15%),
    inset calc(2 * var(--px)) calc(-3 * var(--px)) calc(4 * var(--px))
      rgb(255 255 255 / 25%),
    inset calc(7 * var(--px)) calc(-10 * var(--px)) calc(9 * var(--px))
      calc(-3 * var(--px)) #cbd3db;
  background-image:
    linear-gradient(
      90deg,
      rgb(255 255 255 / 0%) 7.25%,
      rgb(255 255 255 / 20%) 13.65%,
      rgb(255 255 255 / 0%) 19.85%
    ),
    url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 262 437' preserveAspectRatio='none'><rect width='262' height='437' fill='url(%23a)' fill-opacity='0.6'/><defs><radialGradient id='a' cx='0' cy='0' r='1' gradientUnits='userSpaceOnUse' gradientTransform='matrix(3.3 8.4 -8.0225 3.1517 98 353)'><stop stop-color='white'/><stop offset='1' stop-color='white' stop-opacity='0'/></radialGradient></defs></svg>"),
    url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 262 437' preserveAspectRatio='none'><rect width='262' height='437' fill='url(%23b)'/><defs><radialGradient id='b' cx='0' cy='0' r='1' gradientUnits='userSpaceOnUse' gradientTransform='matrix(4.7 9.8 -15.3 7.3378 84 437)'><stop stop-color='%23C8D2D9'/><stop offset='0.65352' stop-color='%23D3D8DD' stop-opacity='0.34648'/><stop offset='1' stop-color='%23D8DCDF' stop-opacity='0'/></radialGradient></defs></svg>"),
    url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 262 437' preserveAspectRatio='none'><rect width='262' height='437' fill='url(%23c)'/><defs><radialGradient id='c' cx='0' cy='0' r='1' gradientUnits='userSpaceOnUse' gradientTransform='matrix(-21.769 -29.773 17.85 -13.316 339.06 242.27)'><stop offset='0.50861' stop-color='%23F4F4F4'/><stop offset='1' stop-color='%23F4F4F4' stop-opacity='0'/></radialGradient></defs></svg>"),
    linear-gradient(42.11deg, #dde1e4 27.31%, #e9ecf1 89.2%);
  background-repeat: no-repeat;
  background-size: 100% 100%;
}

/* The LCD panel — greenish glass under the overlay's live pixels. */
.screenPanel {
  position: absolute;
  left: 16.176%;
  top: 4.676%;
  width: 67.279%;
  height: 33.174%;
  border-radius: calc(7 * var(--px));
  pointer-events: none;
  box-shadow:
    0 calc(4 * var(--px)) calc(4 * var(--px)) rgb(255 255 255 / 25%),
    inset 0 calc(-1 * var(--px)) calc(3 * var(--px)) rgb(0 0 0 / 35%),
    inset 0 calc(1 * var(--px)) calc(7 * var(--px)) rgb(0 0 0 / 65%);
  background:
    linear-gradient(145deg, rgb(255 255 255 / 0%) 23%, rgb(245 243 233 / 60%) 98%),
    linear-gradient(51deg, #a09b8c 0%, #bab7b0 100%);
  opacity: 0.9;
}

.wheelOuter {
  left: 5.884%;
  top: 39.632%;
  width: 88.235%;
  height: 56.552%;
}

/* The wheel is a separate part sitting in an opening, and a real fit shows
   a hairline gap (Geoff, Aug 30: the outer edge sat "a little too flush" —
   "make a tiny dark gap. like 0.5 px"). Same box as the ring sprite, so
   closest-side percentages line up with its metrics: the solid metal ends
   at 94.8%, and this band lives in the sliver just outside it — a 0.3%
   band is ~0.5px at Large. The vertical mask leaves it a touch stronger up
   top, where the opening's edge shadows the gap against the room's light,
   and lets it thin toward the lit bottom. */
.wheelEdgeSeam {
  position: absolute;
  left: 5.884%;
  top: 39.632%;
  width: 88.235%;
  height: 56.552%;
  pointer-events: none;
  background: radial-gradient(
    circle closest-side,
    transparent 0 94.75%,
    rgb(15 19 24 / 0.24) 95.05% 95.35%,
    transparent 95.7%
  );
  -webkit-mask-image: linear-gradient(180deg, #000 22%, rgb(0 0 0 / 0.5) 92%);
  mask-image: linear-gradient(180deg, #000 22%, rgb(0 0 0 / 0.5) 92%);
}

/* The second seam of the same fit (Geoff, Aug 30: the hardware "always had
   a TINY gap" between the wheel and the buttons — ours read perfectly
   flush). The rotating disc ends at r 89 (the wheelControl's own number),
   which is 74.17% of this box's closest-side, so the hairline lives
   centered there: ink 74.0–74.3%, the same 0.3% (~0.5px at Large) band
   width and top-shadowed vertical mask as the outer seam. The press
   surface's annulus mask only begins fading in at 74%, so a held ring key
   never buries this line. */
.wheelDiscSeam {
  position: absolute;
  left: 5.884%;
  top: 39.632%;
  width: 88.235%;
  height: 56.552%;
  pointer-events: none;
  background: radial-gradient(
    circle closest-side,
    transparent 0 73.7%,
    rgb(15 19 24 / 0.26) 74% 74.3%,
    transparent 74.65%
  );
  -webkit-mask-image: linear-gradient(180deg, #000 22%, rgb(0 0 0 / 0.5) 92%);
  mask-image: linear-gradient(180deg, #000 22%, rgb(0 0 0 / 0.5) 92%);
}

/* The four button segment seams — the mock's two 1px gradient-stroke
   diagonals (Vector 9/10, back in Geoff's Aug 27 wheel revision). They run
   corner-to-corner through the wheel center; the disc paints over their
   middle, so only the annulus between disc and outer ring shows them —
   exactly the molded seams between the four buttons. They belong to the
   FIXED ring, never the rotor. Boxes are the 159.707 stroke bounds
   (container 159 + the -0.22% overflow inset) at the mock's own offsets —
   the two are deliberately not concentric; that's how the file draws them. */
.wheelSeamA {
  left: 20.643%;
  top: 50.017%;
  width: 58.716%;
  height: 35.558%;
}

.wheelSeamB {
  left: 19.908%;
  top: 49.683%;
  width: 58.716%;
  height: 35.558%;
  /* The box is square in rendered px (159.7 both ways), so rotating the
     same NW–SE stroke 90° yields the NE–SW pair, exactly as the mock does. */
  transform: rotate(90deg);
}

/* The disc's LIGHT never rotates; its molded TEXTURE does (Geoff, Aug 27:
   a static grain kills the spinning illusion). The mock bakes both into one
   filter, so the export is split: wheelFace keeps the fills, gradients, and
   inner shadows fixed under the studio light, and the rotor carries the
   identical fractal-noise speckle (wheel-grain.svg) plus the wear marks.
   The Aug 27 revision draws the disc rotated -2.08° in the file; the grain
   sprite repeats that bake so rest is still the export, pixel for pixel. */
.wheelFace {
  left: 17.279%;
  top: 48.092%;
  width: 65.441%;
  height: 39.631%;
  transform: rotate(-2.08deg);
}

.wheelRotor {
  position: absolute;
  left: 17.279%;
  top: 48.092%;
  width: 65.441%;
  height: 39.631%;
  transform: rotate(var(--wheel-turn, 0deg));
  will-change: transform;
}

.wheelGrain {
  inset: 0;
  transform: rotate(-2.08deg);
}

.wheelWearA {
  left: 4.213%;
  top: 38.067%;
  width: 26.795%;
  height: 26.795%;
}

.wheelWearB {
  left: 6.348%;
  top: 48.034%;
  width: 26.795%;
  height: 26.795%;
}

.wheelWearC {
  left: 59.029%;
  top: 11.27%;
  width: 20.815%;
  height: 20.815%;
}

.wheelWearD {
  left: 68.989%;
  top: 57.573%;
  width: 20.815%;
  height: 20.815%;
}

.wheelCenter {
  left: 36.949%;
  top: 60.448%;
  width: 26.103%;
  height: 15.808%;
}

.wheelLabels {
  left: 9.743%;
  top: 44.41%;
  width: 80.331%;
  height: 47.208%;
  mix-blend-mode: hard-light;
}

/* --- Interactive overlay (unmasked) --- */

.interactive {
  pointer-events: auto;
}

.screen {
  position: absolute;
  left: 16.176%;
  top: 4.676%;
  width: 67.279%;
  height: 33.174%;
  overflow: hidden;
  border-radius: calc(7 * var(--px));
  font-family: "ChicagoFLF", "Chicago", Geneva, "Helvetica Neue", sans-serif;
  font-size: calc(14.5 * var(--px));
  letter-spacing: calc(-0.6 * var(--px));
  line-height: 1;
  color: #282425;
  pointer-events: none;
}

.screen:focus {
  outline: none;
}

/* The mock's own top glare (Vector 7 in the bodyMask export) continued over
   the glass (Geoff, Aug 27: "matches the glare already at the top of the
   ipod. match perfectly"). The sprite is the identical blurred wedge drawn
   on the full 272×449.143 device canvas, so registering it means parking
   that canvas at the screen's device offset — the screen rect is (44, 21,
   183×149) in mock px — and letting the screen's overflow+radius clip it to
   the glass. One art layer, one geometry, so the sheet cannot mismatch.
   Toned down to 45% (Geoff, Aug 27: "tone down the glare") — at full
   strength the wedge washed the top rows of the list toward illegible;
   the 0.45 step below the shell's copy reads as the glass sitting a step
   below the shell. Both copies ride the native Glare slider's one scalar
   (see .bodyMask above), so the ratio holds at every slider position. */
.screenGlare {
  position: absolute;
  left: calc(-44 * var(--px));
  top: calc(-21 * var(--px));
  width: calc(272 * var(--px));
  height: calc(449.143 * var(--px));
  opacity: calc(var(--glare, 1) * 0.45);
}

/* The title bar truncates; only the selected row and Now Playing scroll.
   The measure is symmetric about the center, so the binding edge is the
   battery frame at 84.973%: stopping at 83% leaves ~2% of air, which puts
   the left edge at 17% — clear of the lock glyph, which ends near 13.5%.
   Without this an album title like "Fashion Killa Blue Steel (feat. Anna
   Jeanine)" ran off BOTH edges and straight through the battery (Geoff,
   Aug 31). The clip box needed for the ellipsis would otherwise shave
   Chicago's descenders, so line-height grows it the way .rowTitle does;
   translateY(-50%) centers the box, so the glyphs don't move. */
.screenTitle {
  position: absolute;
  left: 50%;
  top: 7.718%;
  transform: translate(-50%, -50%);
  margin: 0;
  max-width: 66%;
  overflow: hidden;
  text-overflow: ellipsis;
  font-size: 1em;
  line-height: 1.35;
  white-space: nowrap;
}

.batteryFrame {
  left: 84.973%;
  top: 3.02%;
  width: 13.115%;
  height: 8.725%;
}

/* Four bars inside the frame: 3.25px bars on a 4.5px pitch of the 16.75px
   well → 19.4% bar, 26.87% period. The meter is live: native pushes the host
   device's battery and JS sets --battery-bars (0–4); the clip reveals that
   many whole bars (k bars end at k × 26.87% − 7.47%). The box itself never
   resizes — the gradient's percentages resolve against it, so shrinking the
   element would change the bar pitch instead of the count. No push (desktop
   Mac, simulator) leaves the fallback: the drawn full meter. */
.batteryBars {
  position: absolute;
  left: 86.339%;
  top: 4.698%;
  width: 9.153%;
  height: 5.369%;
  background: repeating-linear-gradient(
    90deg,
    #282425 0 19.4%,
    transparent 19.4% 26.87%
  );
  clip-path: inset(
    0 calc(100% - clamp(0%, var(--battery-bars, 4) * 26.87% - 7.47%, 100%)) 0 0
  );
}

/* Charging runs the hardware's own filling loop: the bars step 1 → 4 and
   wrap. A full battery on power never carries this attribute — it holds the
   solid charged face (JS gates it). */
.batteryBars[data-charging] {
  animation: batteryCharge 2.6s step-end infinite;
}

@keyframes batteryCharge {
  0% {
    clip-path: inset(0 80.6% 0 0);
  }
  25% {
    clip-path: inset(0 53.73% 0 0);
  }
  50% {
    clip-path: inset(0 26.86% 0 0);
  }
  75%,
  100% {
    clip-path: inset(0 0 0 0);
  }
}

.screenRule {
  left: 0;
  top: 14.262%;
  width: 100%;
  height: 1.007%;
}

/* --- Song list: 6 rows of 21px fill the 126px under the rule --- */

.listViewport {
  position: absolute;
  left: 0;
  top: 15.436%;
  width: 100%;
  height: 84.564%;
  overflow: hidden;
}

.listTrack {
  position: absolute;
  left: 0;
  top: 0;
  width: 100%;
  transition: top 90ms ease-out;
}

/* Firmware dialog (Music Access): explanation above, action rows seated on
   the LCD's bottom edge. The rows reuse .listRow at the list's exact 1/6
   row height so the highlight bar and Chicago metrics match every menu. */
.dialogBody {
  position: absolute;
  inset: 0 6% calc(100% / 6 * var(--song-count, 2)) 6%;
  display: grid;
  place-items: center;
  text-align: center;
  line-height: 1.3;
  white-space: pre-line;
  /* The copy is measured to fit; this keeps a future longer string from
     ever bleeding under the action rows. */
  overflow: hidden;
}

.dialogRows {
  position: absolute;
  left: 0;
  bottom: 0;
  width: 100%;
  height: calc(100% / 6 * var(--song-count, 2));
}

.listRow {
  position: relative;
  display: flex;
  align-items: center;
  height: calc(100% / var(--song-count));
  padding: 0 8% 0 2.732%;
  box-sizing: border-box;
}

/* The selection bar sits 1px below the row's top edge in the mock. */
.listRow[data-selected]::before {
  content: "";
  position: absolute;
  inset: 4.762% 0 0;
  background: #282425;
}

.rowTitle {
  position: relative;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  /* The screen's line-height: 1 makes this clip box exactly font-size
     tall, and Chicago's descenders live BELOW that box — "Legend" lost
     its g's (Geoff, Aug 30). 1.35 grows the clip window symmetrically;
     the flex row centers the line box, so the glyphs don't move. */
  line-height: 1.35;
}

.listRow[data-selected] .rowTitle {
  color: #bfbcb5;
}

/* The selected row carries the mock's chevron, flipped to the LCD paper
   tone so it survives the dark bar. */
.chevron {
  right: 2.338%;
  top: 28.05%;
  width: 3.936%;
  height: 46.13%;
}

.listRow[data-selected] .chevron {
  filter: invert(1) brightness(0.87);
}

/* --- Screen push --- */

/* The firmware navigates with a horizontal push, not a cut (VEEB footage of
   the real first-gen device, 24.3s): the outgoing panel slides off one edge
   while the incoming one rides in from the other as a single surface — one
   moving seam, linear timing (an LCD blitting whole columns has no easing),
   ~8 frames at 30fps. Only the panel region below the title rule moves; the
   title bar swaps instantly. Durations are COUPLED to PUSH_MS = 280 in
   IpodScrollWheel.tsx, which unmounts the outgoing copy when they finish.
   Reduced motion never reaches these classes — navigateScreen skips the
   push state entirely and the swap is instant. */

.pushOutLeft {
  animation: ipodPushOutLeft 280ms linear both;
}

.pushInRight {
  animation: ipodPushInRight 280ms linear both;
}

.pushOutRight {
  animation: ipodPushOutRight 280ms linear both;
}

.pushInLeft {
  animation: ipodPushInLeft 280ms linear both;
}

@keyframes ipodPushOutLeft {
  from {
    transform: translateX(0);
  }

  to {
    transform: translateX(-100%);
  }
}

@keyframes ipodPushInRight {
  from {
    transform: translateX(100%);
  }

  to {
    transform: translateX(0);
  }
}

@keyframes ipodPushOutRight {
  from {
    transform: translateX(0);
  }

  to {
    transform: translateX(100%);
  }
}

@keyframes ipodPushInLeft {
  from {
    transform: translateX(-100%);
  }

  to {
    transform: translateX(0);
  }
}

/* --- Now Playing --- */

.nowPlaying {
  position: absolute;
  left: 0;
  top: 15.436%;
  width: 100%;
  height: 84.564%;
  display: flex;
  flex-direction: column;
  align-items: center;
  padding: 4% 5% 6%;
  box-sizing: border-box;
}

.nowPlayingTitle {
  margin: 2% 0 0;
  width: 100%;
  font-size: 1.05em;
  /* 1.15 still shaved Chicago's descender tips inside the overflow clip
     (the rowTitle lesson, Aug 30). The column layout flows from margins,
     so the extra leading costs ~1px of rhythm and saves the g's. */
  line-height: 1.3;
  text-align: center;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

/* A long title anchors LEFT and scrolls — centering a nowrap line wider than
   the panel overflows it on BOTH sides, and the container's own clip then cut
   the head of the title off (Geoff, Aug 27: "Some of the text gets cut off on
   track names"). The container keeps the overflow window; the marquee rides
   the inner span. */
.nowPlayingTitle[data-long] {
  text-align: left;
  text-overflow: clip;
}

.nowPlayingTitle[data-long] .titleScroll {
  display: inline-block;
  animation: ipodMarquee 9s linear infinite;
}

/* Exact travel with no measured widths: translateX(-100%) resolves against
   the SPAN (the text), margin-left: 100% against the CONTAINER (the panel) —
   their sum is -(text − panel), the precise overflow, so the scroll parks
   with the title's tail flush to the panel's right edge whatever the text
   or device scale. Margin animation costs layout, but this is one short
   nowrap line on an LCD — and the firmware's scroll was linear too. */
@keyframes ipodMarquee {
  0%,
  14% {
    transform: translateX(0);
    margin-left: 0;
  }

  46%,
  56% {
    transform: translateX(-100%);
    margin-left: 100%;
  }

  88%,
  100% {
    transform: translateX(0);
    margin-left: 0;
  }
}

.nowPlayingArtist {
  margin: 3% 0 auto;
  font-size: 0.82em;
  /* Shadowed by app.css (loaded later), which owns this element's type at
     line-height: 1.3 — Chicago's descenders clip below 1.3 under the
     overflow: hidden it also sets. Kept in agreement so a stylesheet
     reorder can't quietly bring the shaved g's back. */
  line-height: 1.3;
  color: rgb(40 36 37 / 72%);
  text-align: center;
}

.progressRow {
  display: flex;
  align-items: center;
  gap: 3%;
  width: 100%;
  margin-top: auto;
}

.timeReadout {
  flex: 0 0 auto;
  font-size: 0.72em;
  line-height: 1;
  font-variant-numeric: tabular-nums;
}

.progressTrack {
  position: relative;
  flex: 1 1 auto;
  height: 0.45em;
  border: calc(1 * var(--px)) solid #282425;
  background: rgb(191 188 181 / 45%);
}

.progressFill {
  position: absolute;
  left: 0;
  top: 0;
  bottom: 0;
  width: var(--progress, 0%);
  background: #282425;
}

/* The scrub diamond rides the progress bar only while the scrubber is up
   (Select on Now Playing raises it; it times back out after 3s quiet). */
.progressHead {
  display: none;
  position: absolute;
  left: var(--progress, 0%);
  top: 50%;
  width: 0.55em;
  height: 0.55em;
  margin-left: -0.275em;
  margin-top: -0.275em;
  background: #282425;
  transform: rotate(45deg);
}

.nowPlaying[data-scrub] .progressHead {
  display: block;
}

/* The volume bar's speaker cone, drawn in LCD ink — Chicago carries no
   speaker glyph, so it's a clip-path, sized to the time readouts it
   replaces. */
.volumeGlyph {
  flex: 0 0 auto;
  width: 0.9em;
  height: 0.78em;
  background: #282425;
  clip-path: polygon(0 32%, 30% 32%, 76% 0, 76% 100%, 30% 68%, 0 68%);
}

.transportGlyph {
  margin: 5% 0 0;
  font-size: 0.95em;
  line-height: 1;
  letter-spacing: 0.08em;
}

/* --- Wheel + button hit targets --- */

/* The drag ring is the wheel disc itself (r 89 at 136,305), minus the
   select button (r 32 → 36% of the disc radius). */
.wheelControl {
  position: absolute;
  left: 17.279%;
  top: 48.092%;
  width: 65.441%;
  height: 39.631%;
  border-radius: 50%;
  pointer-events: auto;
  cursor:
    url("assets/rotate-cursor.svg") 16 16,
    crosshair;
  touch-action: none;
  -webkit-user-select: none;
  user-select: none;
  z-index: 2;
  -webkit-mask: radial-gradient(circle at 50% 50%, transparent 0 35%, #000 36% 100%);
  mask: radial-gradient(circle at 50% 50%, transparent 0 35%, #000 36% 100%);
}

.interactive[data-dragging] .wheelControl {
  cursor:
    url("assets/rotate-cursor-active.svg")
      16 16,
    crosshair;
}

.interactive[data-dragging] {
  cursor:
    url("assets/rotate-cursor-active.svg")
      16 16,
    crosshair;
}

/*
 * Button travel duplicates the exact Figma wheel art, clipped to the ring
 * and to ONE KEY — the feathered 90° sector between that key's two molded
 * seams. The journey here matters: pass one shaded each rectangular hit
 * target (four dark lozenges); pass two clipped a hard sector whose straight
 * edges invented dividers the art didn't draw yet; pass three feathered a
 * radial pool at the label — which read as a smudge, not a key going down
 * (Geoff, Aug 29: "still looks super cheap"). The art has since gained the
 * four molded seams (wheelSeamA/B), so the sector is now the HONEST shape:
 * each button is its own molded part, and a press is that whole part
 * sinking, its edges landing IN the grooves. The sector runs solid across
 * ±43.5° of the key's center and dies by ±46.5°, a 3° fade that lives
 * INSIDE the seam groove (Geoff, Aug 29: the earlier 7° ramp was the
 * "dumb feather at the edge" — a fade wider than the groove reads as a
 * smudge, one that fits inside it reads as the part's own edge).
 */
.buttonPressSurface {
  position: absolute;
  left: 5.884%;
  top: 39.632%;
  width: 88.235%;
  height: 56.552%;
  z-index: 3;
  pointer-events: none;
  opacity: 0;
  filter: brightness(1);
  transition:
    opacity 190ms cubic-bezier(0.16, 1, 0.3, 1),
    filter 190ms cubic-bezier(0.16, 1, 0.3, 1);
  /* The annulus must size against closest-side (half-width = ring outer
     radius): a bare circle defaults to farthest-CORNER, whose 78.5% lands
     beyond the ring entirely — the intersection was empty and no press ever
     painted a pixel. Conic angles are true angles regardless of the box's
     240×254 aspect, so the sector needs no compensation. */
  -webkit-mask-image:
    radial-gradient(circle closest-side, transparent 0 74%, #000 77% 100%),
    conic-gradient(
      from calc(var(--sector, 0deg) - 47deg),
      transparent 0deg 0.5deg,
      #000 3.5deg 90.5deg,
      transparent 93.5deg 360deg
    );
  -webkit-mask-composite: source-in;
  mask-image:
    radial-gradient(circle closest-side, transparent 0 74%, #000 77% 100%),
    conic-gradient(
      from calc(var(--sector, 0deg) - 47deg),
      transparent 0deg 0.5deg,
      #000 3.5deg 90.5deg,
      transparent 93.5deg 360deg
    );
  mask-composite: intersect;
}

/*
 * The press is a CROSSFADE TO A BAKED SPRITE (Aug 30). Five generations of
 * CSS shading — filters, then gradient wells/occlusion/catch-lights on
 * pseudo-elements — all failed the same way: a gradient has to guess where
 * the sprite's rim sits in percentage space, and any miss paints a line at
 * exactly the edge the eye is on ("there is some dumb stroke", "hit state
 * looks unrealistic"). Scripts/bake-sprites.mjs now MEASURES the rim off
 * the baked PNG's alpha channel and composites the whole pressed state —
 * the ring dropped 1 CSS px straight down (the dome keeps its radial
 * scale), the gap that drop opens above the part and closes below it,
 * bezel occlusion where the wall shades the sunken face, and the contact
 * shadow where the rim meets the bezel — into wheel-outer-pressed@6x.png /
 * wheel-center-pressed@6x.png. The press here is: fade the pressed pixels
 * in over the rest pixels. Nothing is drawn at runtime, so nothing can
 * misregister. The seam and label copies still sink via .pressArt's
 * matching 1px drop — the SAME travel the bake used, so glyph and part
 * move as one. (The ring's sink is a translate since Aug 30; the earlier
 * scale-about-center moved the bottom key UP, which read as raising.)
 *
 * --sector is each key's center angle (conic 0deg = 12 o'clock). The hover
 * selectors carry :not([data-button]) so a held press always outranks a
 * stale hover on a DIFFERENT key regardless of source order.
 */
.buttonPressSurface[data-button="menu"],
.buttonPressSurface[data-hover="menu"]:not([data-button]) {
  --sector: 0deg;
}

.buttonPressSurface[data-button="next"],
.buttonPressSurface[data-hover="next"]:not([data-button]) {
  --sector: 90deg;
}

.buttonPressSurface[data-button="play"],
.buttonPressSurface[data-hover="play"]:not([data-button]) {
  --sector: 180deg;
}

.buttonPressSurface[data-button="prev"],
.buttonPressSurface[data-hover="prev"]:not([data-button]) {
  --sector: 270deg;
}

.buttonPressSurface[data-button] {
  opacity: 1;
  transition-duration: 110ms;
  transition-timing-function: cubic-bezier(0.2, 0.7, 0.25, 1);
}

/* The two ring copies. The rest copy serves the hover state; the pressed
   copy — the baked sunken ring with its gap shadow, occlusion, and catch
   light in the pixels — fades in over it on press. Its alpha footprint is
   clipped to the base sprite's in the bake, so the fade can never reveal
   or invent an edge. Press lands on the click's 110ms clock; release
   rebounds on the slower 190ms ease so the key visibly comes back up. */
.pressRing,
.pressRingDown {
  inset: 0;
}

.pressRingDown {
  opacity: 0;
  transition: opacity 190ms cubic-bezier(0.16, 1, 0.3, 1);
}

.buttonPressSurface[data-button] .pressRingDown {
  opacity: 1;
  transition-duration: 110ms;
  transition-timing-function: cubic-bezier(0.2, 0.7, 0.25, 1);
}

/* The travel for the layers the bake can't carry: the seam and label
   copies drop by the SAME 1 CSS px the bake translated the ring by
   (0.8 units of the 254-unit box = 0.315%), so glyph and part move as
   one piece. A drop, not a scale — scaling about the wheel center moved
   the bottom key's glyph UP, which read as the key raising (Geoff,
   Aug 30). Down is the one direction that reads as "pressed" on every
   key under the top light. */
.pressArt {
  position: absolute;
  inset: 0;
  transform: translateY(0);
  transition: transform 190ms cubic-bezier(0.16, 1, 0.3, 1);
}

.buttonPressSurface[data-button] .pressArt {
  transform: translateY(0.315%);
  transition-duration: 110ms;
  transition-timing-function: cubic-bezier(0.2, 0.7, 0.25, 1);
}

/* Hover - the finger's shadow arrives before the touch: the same sector,
   dimmed a quiet step, no occlusion layers (those are the press's
   language). Brightening was tried first and is invisible here - the
   plastic sits near white, so brightness(1.05) clips to nothing. The old
   treatment before that was a white ellipse floating on the BUTTON's
   rectangular hit box - a web glow unrelated to the molded key. A flat
   dim of the whole seam-to-seam sector reads as attention on that one
   part; the deep press keeps its bezel shadows so the two states never
   blur together. Press outranks hover by the same :not([data-button])
   guard the --sector variables use. */
@media (hover: hover) and (pointer: fine) {
  .buttonPressSurface[data-hover]:not([data-button]) {
    opacity: 1;
    filter: brightness(0.972);
    transition-duration: 150ms;
  }
}

/* The seam copies sit at the same offsets as the device layers, re-based
   to this 240×254 box. */
.pressSeamA {
  left: 16.729%;
  top: 18.366%;
  width: 66.545%;
  height: 62.877%;
}

.pressSeamB {
  left: 15.896%;
  top: 17.776%;
  width: 66.545%;
  height: 62.877%;
  transform: rotate(90deg);
}

/* Without its own labels copy the pressed ring PAINTED OVER the device's
   label layer — pressing a button made its glyph vanish. Same art, same
   hard-light blend, re-based from frame % into this box. */
.pressLabels {
  left: 4.374%;
  top: 8.449%;
  width: 91.042%;
  height: 83.477%;
  mix-blend-mode: hard-light;
}

/*
 * The center button presses the same way the ring keys do now: a
 * crossfade to the baked pressed sprite (see the .buttonPressSurface
 * comment — the bake carries the 0.978 sink, the gap seam the retreating
 * rim opens against the bezel graded by the top light, occlusion on the
 * dome's lit edge, and the catch light at its foot, all measured off the
 * base sprite's own alpha). The rest copy serves the hover dim; nothing
 * is drawn at runtime.
 */
.selectPressSurface {
  position: absolute;
  left: 36.949%;
  top: 60.448%;
  width: 26.103%;
  height: 15.808%;
  z-index: 3;
  border-radius: 50%;
  opacity: 0;
  filter: brightness(1);
  pointer-events: none;
  transition:
    opacity 190ms cubic-bezier(0.16, 1, 0.3, 1),
    filter 190ms cubic-bezier(0.16, 1, 0.3, 1);
}

.selectPressArt,
.selectPressArtDown {
  inset: 0;
}

.selectPressArtDown {
  opacity: 0;
  transition: opacity 190ms cubic-bezier(0.16, 1, 0.3, 1);
}

.selectPressSurface[data-active] {
  opacity: 1;
  transition-duration: 110ms;
  transition-timing-function: cubic-bezier(0.2, 0.7, 0.25, 1);
}

.selectPressSurface[data-active] .selectPressArtDown {
  opacity: 1;
  transition-duration: 110ms;
  transition-timing-function: cubic-bezier(0.2, 0.7, 0.25, 1);
}

/* Hover on the dome - same move as the ring keys: the finger's shadow,
   a quiet flat dim, no occlusion layers. */
@media (hover: hover) and (pointer: fine) {
  .selectPressSurface[data-hover]:not([data-active]) {
    opacity: 1;
    filter: brightness(0.975);
    transition-duration: 150ms;
  }
}

.btnMenu,
.btnPrev,
.btnNext,
.btnPlay,
.btnSelect {
  position: absolute;
  margin: 0;
  padding: 0;
  border: 0;
  background: transparent;
  pointer-events: auto;
  cursor: pointer;
  -webkit-tap-highlight-color: transparent;
  z-index: 4;
  transition: filter 140ms ease;
  overflow: visible;
}

/* No visual treatment on the hit targets themselves. The old hover was a
   white radial ellipse floating on these invisible rectangles — a web glow
   hovering over the plastic, unrelated to the molded key's shape (Geoff,
   Aug 29: "super cheap"). Hover now lights the actual wheel art through
   the press surfaces' sector masks; app.js forwards pointerenter/leave
   to data-hover on those surfaces. */

/* Each ring key is a whole 90° annular sector, not a lozenge over its
   printed label — the molded quadrant IS the button on the hardware, and
   the label rects left most of the ring dead (clicks near the edge fell
   through to the chassis and dragged the window). All four share ONE
   polygon and box — the top sector's own bounding rectangle — rotated
   about the wheel's axis, so the quadrants cannot drift apart. Geometry,
   in the 272 × 449.143 face space: center (136, 305), inner radius 89
   (the disc's own edge, so the rotating disc stays entirely with
   .wheelControl), outer radius 118 — 4 units past the r-114 ring as free
   forgiveness onto bare body. The box is deliberately NOT a square
   centered on the wheel: that would park every button's center ON the
   axis, where center-anchored activation lands on Select instead. Being
   off-center, the rotation states its axis: 118 units below the box's
   top edge = 214.28% of its 55.068-unit height. The clip polygon samples
   the outer arc every 7.5° (chord sagitta ~0.25 units — under any
   rendered pixel) and the inner arc every 15° (its chords bow into the
   disc, where the wheel control owns the paint anyway).
   NOTE: DeviceFace in PlayerWindowController.swift mirrors this ring
   (control surface = one ellipse at r 118); the firmware suite fails if
   they drift. */
.btnMenu,
.btnPrev,
.btnNext,
.btnPlay {
  left: 19.324%;
  top: 41.635%;
  width: 61.352%;
  height: 12.261%;
  transform-origin: 50% 214.28%;
  clip-path: polygon(
    0% 62.76%,
    6.95% 44.28%,
    14.65% 28.71%,
    22.94% 16.31%,
    31.7% 7.3%,
    40.77% 1.83%,
    50% 0%,
    59.23% 1.83%,
    68.3% 7.3%,
    77.06% 16.31%,
    85.36% 28.71%,
    93.05% 44.28%,
    100% 62.76%,
    87.71% 100%,
    76.67% 74.32%,
    63.8% 58.17%,
    50% 52.66%,
    36.2% 58.17%,
    23.33% 74.32%,
    12.29% 100%
  );
}

.btnNext {
  transform: rotate(90deg);
}

.btnPlay {
  transform: rotate(180deg);
}

.btnPrev {
  transform: rotate(270deg);
}

/* clip-path clips the UA focus outline along with everything else, so the
   ring keys carry their keyboard indicator as a sector wash INSIDE the
   clip — the focus blue over exactly the quadrant Enter will press. */
.btnMenu:focus-visible,
.btnPrev:focus-visible,
.btnNext:focus-visible,
.btnPlay:focus-visible {
  outline: none;
  background: rgba(0, 125, 165, 0.3);
}

.btnSelect {
  left: 38.24%;
  top: 60.78%;
  width: 23.53%;
  height: 14.25%;
  border-radius: 50%;
}

@media (prefers-reduced-motion: reduce) {
  /* The device stays whole and legible — it just doesn't move or click. */
  .listTrack {
    transition: none;
  }

  /* A charging battery holds at its true level instead of animating. */
  .batteryBars[data-charging] {
    animation: none;
  }

  /* A parked long title reads like a short one: centered, ellipsized. */
  .nowPlayingTitle[data-long] {
    text-align: center;
    text-overflow: ellipsis;
  }

  /* Back to plain inline text: text-overflow's ellipsis is only reliable on
     character content — engines disagree on ellipsizing atomic inline boxes. */
  .nowPlayingTitle[data-long] .titleScroll {
    display: inline;
    animation: none;
  }

  .wheelControl,
  .btnMenu,
  .btnPrev,
  .btnNext,
  .btnPlay,
  .btnSelect {
    pointer-events: none;
  }

  .interactive {
    pointer-events: none;
  }
}
