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

html, body { height: 100%; }

body {
  font-family: var(--font-body);
  background: var(--bg-deep);
  color: var(--text);
  min-height: 100dvh;
  overflow: hidden;
  -webkit-tap-highlight-color: transparent;
  -webkit-user-select: none;
  user-select: none;
  overscroll-behavior: none;
}

/* ============================================================
   AURORA
   Three slow drifting fields on their own long loops so the
   motion never visibly repeats. Composited, not repainted.
   ============================================================ */
#sky {
  position: fixed; inset: 0; z-index: 0; overflow: hidden;
  background:
    url('../img/bg-night.webp') center top / cover no-repeat,
    radial-gradient(ellipse at 50% 0%, var(--bg-mid), var(--bg-deep) 62%);
}

/* ============================================================
   HALLOWEEN SKY
   A hand drawn cartoon scene, NOT the photographic night plate
   and NOT a starfield. The scenery lives in two bands pinned to
   the top and the bottom, so the middle third stays empty. That
   band is where the wheel, the question card and the full bleed
   name reveal live, and scenery behind any of them is noise.
   ============================================================ */
html[data-season="halloween"] #sky {
  /* Two transparent SVG bands over a gradient, rather than one full-height
     plate. A single plate has to be `cover`ed, and on a tall phone `cover`
     either crops the moon off the top or slides the graveyard off the bottom.
     Pinning one band to the top and one to the bottom keeps both, and it is
     what keeps the middle of the screen clear for the wheel and the cards.

     THE GROUND BAND WAS SHORTENED IN THE ART, NOT IN CSS. It used to paint
     about 203 px of scenery on a 390 px phone, which crowded the wheel from
     below. hw-ground.svg's viewBox went 480x250 -> 480x150, so the same
     `auto` height now resolves to 122 px on that phone: a 40% cut with the
     aspect ratio intact. Pinning an explicit height instead (`100% min(22vh,
     140px)`) would also have shortened it, but it scales the two axes
     independently, so it would squash every pumpkin and headstone in the band
     on anything that is not roughly 390 px wide.

     NEITHER BAND IS WIDTH CAPPED, and both were. A cap is only safe while the
     art's own edges are empty, and after the redraw neither band's are: the
     graveyard runs its fence, floor and trees out to both sides, and the sky
     band has a clawed branch reaching in from each top corner. Rendered at
     820x1180 with the bands held to 560 and 620, both showed a hard
     rectangular plate seam floating mid-screen with bare gradient either side
     of it. `100%` costs a moon that gets big on a tablet; a cap costs a visible
     plate edge on one, and this is a phone game. The floor layer below still
     deepens the very bottom, which is what stops the horizon reading brighter
     than the ground standing on it.

     The night itself is deep indigo and black-purple. The old stack washed the
     bottom half of every screen in harvest orange, which is a sunset, and a
     sunset is not spooky. The only warm light now comes from the lantern moon,
     the lit windows and the ::before glow, which is what makes them read as
     light sources instead of as a tint. */
  background:
    url('../img/hw-sky-top.svg') center top / 100% auto no-repeat,
    url('../img/hw-ground.svg') center bottom / 100% auto no-repeat,
    linear-gradient(180deg,
      rgba(6, 3, 12, 0) 0%, rgba(6, 3, 12, 0) 86%,
      rgba(6, 3, 12, 0.9) 94%, #060310 100%),
    linear-gradient(180deg,
      #050310 0%, #0a0518 16%, #130826 34%,
      #1c0b32 52%, #24103a 70%, #2b1338 86%, #23102c 100%);
}

/* The aurora belongs to the regular night sky. Three drifting neon fields over
   a painted cartoon graveyard read as smeared blobs, which is the opposite of
   picture book. */
html[data-season="halloween"] #sky span { display: none; }

/* Warm light breathing out of the house and the lanterns along the bottom.
   This is the only thing in the scene that moves on its own. */
html[data-season="halloween"] #sky::before {
  content: '';
  position: absolute;
  left: 50%; bottom: -6vh;
  width: 150vw; height: 34vh;
  transform: translateX(-50%);
  background: radial-gradient(ellipse at 50% 100%, rgba(255, 138, 30, 0.22), transparent 58%);
  animation: hw-breathe 6.5s var(--ease-in-out) infinite alternate;
  pointer-events: none;
}
@keyframes hw-breathe {
  from { opacity: 0.55; }
  to   { opacity: 1; }
}

/* Ambient critters. Injected by js/season.js so the regular sky keeps a clean
   DOM, and parked in the top third where nothing of the app's own sits. */
.hw-critters {
  position: absolute; inset: 0;
  overflow: hidden;
  pointer-events: none;
}
.hw-critters img {
  position: absolute;
  will-change: transform;
}
.hw-ghost { opacity: 0.9; filter: drop-shadow(0 6px 14px rgba(0, 0, 0, 0.35)); }

.hw-ghost-a { width: 62px; top: 31vh; left: -22vw; animation: hw-float-a 26s linear infinite; }
.hw-ghost-b { width: 44px; top: 45vh; left: -18vw; animation: hw-float-b 34s linear infinite; }
.hw-bat-a   { width: 46px; top: 17vh; left: -20vw; animation: hw-fly-a 17s linear infinite; }
.hw-bat-b   { width: 32px; top: 22vh; left: -20vw; animation: hw-fly-b 23s linear infinite; }
.hw-bat-c   { width: 38px; top: 13vh; left: -20vw; animation: hw-fly-c 29s linear infinite; }

/* Bob is baked into the horizontal travel rather than layered as a second
   animation, because two competing transform animations on one element cannot
   both win. */
@keyframes hw-float-a {
  0%   { transform: translate(0, 0) rotate(-4deg); opacity: 0; }
  8%   { opacity: 0.9; }
  25%  { transform: translate(34vw, -3vh) rotate(5deg); }
  50%  { transform: translate(68vw, 2vh) rotate(-5deg); }
  75%  { transform: translate(102vw, -2vh) rotate(4deg); }
  92%  { opacity: 0.9; }
  100% { transform: translate(140vw, 1vh) rotate(-3deg); opacity: 0; }
}
@keyframes hw-float-b {
  0%   { transform: translate(136vw, 0) rotate(4deg); opacity: 0; }
  10%  { opacity: 0.75; }
  30%  { transform: translate(94vw, 4vh) rotate(-6deg); }
  60%  { transform: translate(48vw, -3vh) rotate(6deg); }
  88%  { opacity: 0.75; }
  100% { transform: translate(-20vw, 2vh) rotate(-4deg); opacity: 0; }
}
@keyframes hw-fly-a {
  0%   { transform: translate(0, 0) rotate(-6deg); }
  30%  { transform: translate(42vw, -5vh) rotate(7deg); }
  65%  { transform: translate(88vw, 4vh) rotate(-7deg); }
  100% { transform: translate(140vw, -2vh) rotate(5deg); }
}
@keyframes hw-fly-b {
  0%   { transform: translate(0, 0) rotate(5deg); }
  40%  { transform: translate(56vw, 6vh) rotate(-8deg); }
  100% { transform: translate(140vw, -4vh) rotate(6deg); }
}
@keyframes hw-fly-c {
  0%   { transform: translate(0, 0) rotate(-4deg); }
  25%  { transform: translate(36vw, 5vh) rotate(6deg); }
  55%  { transform: translate(76vw, -4vh) rotate(-6deg); }
  100% { transform: translate(140vw, 3vh) rotate(4deg); }
}

@media (prefers-reduced-motion: reduce) {
  html[data-season="halloween"] #sky::before { animation: none; opacity: 0.8; }
  .hw-critters { display: none; }
}

/* Short phones: the scene's bottom band would otherwise crowd the Spin button,
   so let the plate sit lower and lean on the gradient for the upper half. */
@media (max-height: 700px) {
  .hw-ghost-a { top: 27vh; }
  .hw-ghost-b { top: 39vh; }
  .hw-bat-a   { top: 15vh; }
  .hw-bat-b   { top: 20vh; }
  .hw-bat-c   { top: 12vh; }
}

/* Over a painted sky these read as live light rather than fog. */
#sky span {
  position: absolute;
  border-radius: 50%;
  filter: blur(80px);
  will-change: transform;
  mix-blend-mode: screen;
  opacity: 0.55;
}
#sky span:nth-child(1) {
  width: 90vw; height: 90vw; top: -28vw; left: -26vw;
  background: radial-gradient(circle, var(--aurora-a), transparent 68%);
  animation: drift-a 41s var(--ease-in-out) infinite alternate;
}
#sky span:nth-child(2) {
  width: 76vw; height: 76vw; bottom: -22vw; right: -24vw;
  background: radial-gradient(circle, var(--aurora-b), transparent 68%);
  animation: drift-b 56s var(--ease-in-out) infinite alternate;
}
#sky span:nth-child(3) {
  width: 68vw; height: 68vw; top: 34vh; left: 12vw;
  background: radial-gradient(circle, var(--aurora-c), transparent 70%);
  animation: drift-c 71s var(--ease-in-out) infinite alternate;
}
/* The active player's colour bleeds into the sky during their turn. */
#sky::after {
  content: ''; position: absolute; inset: 0;
  background: radial-gradient(ellipse at 50% 42%, var(--active-wash), transparent 66%);
  transition: background var(--dur-slow) var(--ease-out);
}

@keyframes drift-a { to { transform: translate3d(14vw, 10vh, 0) scale(1.18); } }
@keyframes drift-b { to { transform: translate3d(-16vw, -8vh, 0) scale(1.12); } }
@keyframes drift-c { to { transform: translate3d(10vw, -14vh, 0) scale(0.86); } }

@media (prefers-reduced-motion: reduce) {
  #sky span { animation: none; }
}

/* ============================================================
   SHELL
   ============================================================ */
#app {
  position: relative; z-index: 1;
  /* Decoration must never be able to widen the page. `clip` does this without
     creating a scroll container, so it cannot break sticky or absolute
     positioning the way overflow:hidden can. */
  overflow-x: clip;
  height: 100dvh;
  max-width: 560px; margin: 0 auto;
  padding: max(10px, env(safe-area-inset-top)) 16px max(14px, env(safe-area-inset-bottom));
  display: flex; flex-direction: column;
}

#bar {
  display: flex; align-items: center; gap: var(--gap-s);
  flex: 0 0 auto; padding-bottom: 6px;
}
#bar .wordmark {
  font-weight: 600; font-size: var(--fs-small);
  letter-spacing: 2.5px; text-transform: uppercase;
  color: var(--text-faint); margin-right: auto;
}
.chip {
  font-size: var(--fs-micro); font-weight: 600;
  padding: 5px 11px; border-radius: var(--radius-pill);
  background: var(--surface); border: 1px solid var(--surface-line);
  color: var(--text-dim); white-space: nowrap;
}
.chip b { color: var(--text); font-weight: 700; }
.chip.streak { color: #FFCF6B; border-color: rgba(255, 207, 107, 0.28); }

#mute {
  position: relative;
  width: 40px; height: 40px; flex: 0 0 40px;
  display: grid; place-items: center;
  border-radius: 50%; border: 1px solid var(--surface-line);
  background: var(--surface); color: var(--text-dim);
  font-size: 16px; cursor: pointer;
}
/* The header is tight, so the button stays visually small but its actual hit
   area is padded out to a real thumb target. A 36px control on a phone passed
   to an 8 year old is a miss. */
#mute::after {
  content: '';
  position: absolute;
  inset: calc((var(--tap-min) - 40px) / -2);
}
#mute[aria-pressed="true"] { color: #FF6B8A; border-color: rgba(255,107,138,0.35); }

/* ============================================================
   SCREENS
   ============================================================ */
.screen {
  flex: 1 1 auto;
  display: none;
  flex-direction: column;
  align-items: center;
  min-height: 0;
}
.screen.active { display: flex; animation: screen-in var(--dur-mid) var(--ease-spring); }

@keyframes screen-in {
  from { opacity: 0; transform: translate3d(0, 14px, 0) scale(0.985); }
  to   { opacity: 1; transform: none; }
}

/* ---------------- Wheel ---------------- */
#wheel-screen { justify-content: center; gap: var(--gap-m); }

/* The square is enforced by width + aspect-ratio AND by an explicit height,
   because Safari can resolve aspect-ratio late inside a flex column. JS then
   pins the canvas to a measured square on top of that, so three independent
   things have to fail before the wheel can render as an ellipse. */
.wheel-wrap {
  position: relative;
  /* Bounded by height as well as width. In landscape a purely vw-based size
     pushed the spin button off the bottom of the screen. Landscape is not a
     supported layout, but it must not break when a kid rotates the phone. */
  width: min(86vw, 62vh, 400px);
  height: min(86vw, 62vh, 400px);
  aspect-ratio: 1;
  flex: 0 0 auto;
  display: block;
}

/* The bulbs are lit, so the wheel should throw WARM light onto the sky rather
   than a dark shadow onto nothing. There is no floor in a night sky for a
   shadow to land on, and the wheel was floating without something here.
   Sits behind everything in the wrapper, and never rotates. */
.wheel-wrap::before {
  content: '';
  position: absolute;
  inset: 6%;
  border-radius: 50%;
  background:
    radial-gradient(circle,
      rgba(255, 186, 84, 0.10) 40%,
      rgba(255, 150, 40, 0.04) 62%,
      transparent 74%);
  filter: none;
  pointer-events: none;
  z-index: 0;
}
html[data-season="halloween"] .wheel-wrap::before {
  display: none;
}

/* THE WHEEL IS THE HERO. On Halloween it gets the screen.
   The generic size above is bounded by 86vw / 62vh / 400px, which on a 390 px
   phone is 335 px. That is a component. This is a fairground attraction, so it
   takes 90vw and the cap goes up. Width and height are both set, and the
   aspect-ratio above still stands, so the canvas's intrinsic 300x150 has three
   independent things to defeat before it can leak. */
html[data-season="halloween"] .wheel-wrap {
  width: min(90vw, 70vh, 420px);
  height: min(90vw, 70vh, 420px);
}

/* Short phones. The generic short-phone rule further down capped this at
   min(66vw, 250px), and 250 px was the single biggest reason the wheel read as
   small: an iPhone SE got a wheel barely wider than the Spin button. There is
   room for far more. Measured on 390x667: the column has 441 px of height to
   give the wheel once the bar, both buttons and their gaps are paid for, so
   343 px fits with 96 px to spare and Spin and Reset stay on screen.

   88vw, NOT 92vw. #app's content box is min(560px, 100vw) - 32px of padding,
   which is 358 px on a 390 px phone, and 92vw is 358.8: measured, the wheel was
   overhanging its own column by 0.4 px a side and taking the ::after rim glow
   and the cobweb overlay, both of which are inset OUTSIDE the wrapper, past the
   edge of #app's `overflow-x: clip`. 88vw is 343 px with 23 px of margin, which
   is enough to hold the -6% web box and the rim glow inside the clip.

   This rule wins over the generic one on specificity, not on order, so the
   regular season keeps its old behaviour untouched. */
@media (max-height: 700px) {
  html[data-season="halloween"] .wheel-wrap {
    width: min(88vw, 58vh, 360px);
    height: min(88vw, 58vh, 360px);
  }
}

/* Pumpkin light hugging the rim. A RING, transparent through the middle: the
   ::before smear under the wheel face is what made the hub look like it was
   bleeding, and nothing may paint under the face again. Behind the canvas.

   `closest-side` is load bearing, not tidiness. A bare `circle` sizes to
   farthest-corner, so its 100% lands at S/sqrt(2) = 0.707S from the centre
   while `border-radius: 50%` clips the box at 0.5S. Everything past 70.7% of
   the gradient is thrown away: with the stops written at 74 / 84 / 95 the
   entire ring rendered as nothing but the leading edge of the orange ramp,
   ending in a hard circular cut right at the rim. `closest-side` puts the
   gradient's 100% ON the clip circle, so the stops mean what they say.

   The stops then have to hug the RIM, not the box. The box is inset -7%, so the
   wheel's painted edge sits at 1/1.07 = 93.5% of this radius: peak at 90%,
   gone by 100%, which is a band of light bleeding out from under the rim. */
html[data-season="halloween"] .wheel-wrap::after {
  content: '';
  position: absolute;
  inset: -7%;
  border-radius: 50%;
  background: radial-gradient(circle closest-side,
    transparent 0%, transparent 74%,
    rgba(255, 122, 24, 0.30) 90%,
    rgba(150, 44, 210, 0.18) 96%,
    transparent 100%);
  pointer-events: none;
  z-index: 0;
  animation: hw-wheel-glow 5.5s var(--ease-in-out) infinite alternate;
}
@keyframes hw-wheel-glow {
  from { opacity: 0.72; }
  to   { opacity: 1; }
}
@media (prefers-reduced-motion: reduce) {
  html[data-season="halloween"] .wheel-wrap::after { animation: none; }
}
/* Absolutely positioned rather than a percentage-sized grid item: a canvas
   carries its own intrinsic 300x150 ratio, and percentage sizing against a
   not-yet-resolved container is what let that ratio leak through. */
#wheel-canvas {
  position: absolute; inset: 0;
  width: 100%; height: 100%;
  display: block;
  z-index: 1;
}
.wheel-wrap .hub {
  position: absolute; top: 50%; left: 50%;
  transform: translate(-50%, -50%);
}

.pointer {
  position: absolute; top: -8px; left: 50%;
  transform: translateX(-50%);
  width: 0; height: 0; z-index: 3;
  border-left: 13px solid transparent;
  border-right: 13px solid transparent;
  border-top: 26px solid #FFF;
  filter: drop-shadow(0 3px 10px rgba(0,0,0,0.6));
  transform-origin: 50% 12%;
  transition: transform 45ms var(--ease-out);
}
.pointer.tick { transform: translateX(-50%) rotate(13deg); }

.hub {
  position: absolute; width: 23%; aspect-ratio: 1;
  border-radius: 50%;
  background: url('../img/hub.webp') center / cover no-repeat;
  border: none;
  font-size: 0;
  /* A TIGHT contact shadow, not a drop shadow.
     This was 0 14px 46px, which smeared a big dark blob down across whichever
     segment happened to be below it. A hub sits ON the wheel face, so its
     shadow is a small hard-ish contact under the rim, and it must be close to
     centred because the wheel spins underneath a hub that does not. */
  box-shadow:
    0 2px 6px rgba(0, 0, 0, 0.55),
    0 0 0 1px rgba(0, 0, 0, 0.35);
  z-index: 3;
}

/* ============================================================
   HAUNTED WHEEL
   Black, orange, purple. Aged iron and weathered wood, lit from
   inside like a lantern. The rim, the face grain and the pitting
   are painted in js/wheel.js; everything here is the furniture
   around it that is cheaper to do in CSS than on the canvas.
   ============================================================ */

/* Aged iron is not glossy. The brass wheel's specular sheet at 0.45 was
   spraying a showroom highlight across rusted metal. */
html[data-season="halloween"] .wheel-gloss { opacity: 0.26; }

/* The hub stops being a brass boss and becomes the plate's cast iron nut: a
   cold grey disc with a dark bevel, four bolts through it, and one hot line of
   pumpkin light along its lower edge where the face is burning underneath.

   The previous pass made this a lantern, a dark ring with a bright ember at the
   middle, and it was the last carnival object left on the wheel: a glowing
   orange ball sitting exactly where five wedges meet. The plate's hub is a
   piece of hardware, and hardware is what holds a wheel this heavy together.

   Drawn rather than filtered. A hue rotated photograph of brass still reads as
   brass, just an unhealthy one. */
html[data-season="halloween"] .wheel-wrap .hub {
  background:
    radial-gradient(circle at 50% 128%,
                    rgba(255, 146, 34, 0.55) 0%, rgba(255, 110, 14, 0) 46%),
    radial-gradient(circle at 34% 26%, #5f656c 0%, #464b52 30%,
                    #31363c 58%, #1f2328 80%, #101215 100%);
  box-shadow:
    0 2px 7px rgba(0, 0, 0, 0.65),
    0 0 0 1px rgba(10, 8, 10, 0.75),
    inset 0 0 0 3px rgba(24, 26, 30, 0.55),
    inset 0 2px 2px rgba(214, 222, 232, 0.20),
    inset 0 -3px 5px rgba(255, 122, 20, 0.34);
}

/* The four bolts. Fixed pixel sizes: the hub is 23 percent of a wheel that is
   at most 420 px, so these never have to scale far, and a bolt that resizes
   with the viewport stops looking like a machined part. */
html[data-season="halloween"] .wheel-wrap .hub::before {
  content: '';
  position: absolute;
  inset: 0;
  border-radius: 50%;
  background:
    radial-gradient(circle 5px at 75.5% 75.5%, #b9bec6 0%, #6a7078 42%, #23262b 78%, transparent 82%),
    radial-gradient(circle 5px at 24.5% 75.5%, #b9bec6 0%, #6a7078 42%, #23262b 78%, transparent 82%),
    radial-gradient(circle 5px at 24.5% 24.5%, #b9bec6 0%, #6a7078 42%, #23262b 78%, transparent 82%),
    radial-gradient(circle 5px at 75.5% 24.5%, #b9bec6 0%, #6a7078 42%, #23262b 78%, transparent 82%);
}

/* The ember, demoted. It is now the last of the fire showing round the centre
   bolt rather than a lamp in its own right, so the hub stays iron and the wheel
   still has something alive at the middle of it. Same clock as the rim glow. */
html[data-season="halloween"] .wheel-wrap .hub::after {
  content: '';
  position: absolute;
  inset: 33%;
  border-radius: 50%;
  background:
    radial-gradient(circle, rgba(255, 206, 132, 0.9) 0%, rgba(255, 132, 26, 0.62) 44%,
                    rgba(255, 92, 8, 0) 80%);
  box-shadow: 0 0 10px rgba(255, 122, 20, 0.5);
  animation: hw-ember 3.1s var(--ease-in-out) infinite alternate;
}
@keyframes hw-ember {
  from { opacity: 0.6; transform: scale(0.95); }
  to   { opacity: 1;   transform: scale(1.05); }
}

/* The pointer becomes the plate's iron marker: a cold pale wedge lit warm from
   the wheel underneath, rather than the bone splinter it was. */
html[data-season="halloween"] .pointer {
  border-top-color: #d9dee6;
  filter:
    drop-shadow(0 1px 0 rgba(255, 150, 40, 0.85))
    drop-shadow(0 0 9px rgba(255, 138, 26, 0.55))
    drop-shadow(0 3px 10px rgba(0, 0, 0, 0.75));
}

/* ---- Cobwebs ----
   Injected into .wheel-wrap by js/season.js. Two corner webs in the square's
   dead corners, where the inscribed circle leaves nothing, plus strands that
   hang OFF the bottom of the rim. Above everything and inert: a web over the
   pointer or the hub is the point, a web that eats a tap is not. */
.hw-web {
  position: absolute;
  inset: -6%;
  width: 112%; height: 112%;
  pointer-events: none;
  z-index: 4;
  overflow: visible;
}
/* Held to a WHISPER. The first pass ran these at 0.34 with a 1.1 stroke and
   they read as three white plastic hand fans clipped to the wheel. Silk is
   almost invisible until light catches it, so: hairline widths, and opacities
   an order of magnitude closer to nothing. These also sit over five saturated
   identity colours and over the names, and anything heavier greys them out. */
.hw-web .strand {
  fill: none;
  stroke: #e8dcff;
  stroke-opacity: 0.13;
  stroke-width: 0.5;
  stroke-linecap: round;
  vector-effect: non-scaling-stroke;
}
.hw-web .strand-fine { stroke-opacity: 0.08; stroke-width: 0.4; }
.hw-web .anchor { stroke-opacity: 0.17; stroke-width: 0.6; }

/* Everything HANGING off the rim is allowed to be seen.
   The whisper above is calibrated for silk lying over five saturated identity
   colours and over the names, where anything heavier greys them out. The orb
   web and the drop threads hang in front of a night sky instead, where a
   whisper is simply invisible, and the plate this season is drawn from puts a
   plainly visible web under the lower left of the iron. Different backdrop,
   different exposure. */
.hw-web .hung .strand { stroke-opacity: 0.30; stroke-width: 0.55; }
.hw-web .hung .strand-fine { stroke-opacity: 0.20; stroke-width: 0.45; }
.hw-web .hung .anchor { stroke-opacity: 0.36; stroke-width: 0.65; }
.hw-web .hung .mote { fill-opacity: 0.40; }

/* Silk lying across the face. Between the two: it is over the wedges, so it
   stays under the threshold where it would grey them, but the plate's are
   plainly there and at 0.08 ours were not. */
.hw-web .face-silk { stroke-opacity: 0.15; stroke-width: 0.45; }

/* Dust caught in the silk. A few brighter nodes are what makes a web read as a
   web at an opacity this low, because the strands themselves are below the
   threshold where the eye can trace a line. */
.hw-web .mote { fill: #e8dcff; fill-opacity: 0.22; }

/* The bead on the end of a hanging thread. Warm, not white: these hang below
   the rim, and the only light down there comes off the wheel. */
.hw-web .bead {
  fill: #ffb469;
  fill-opacity: 0.32;
  filter: drop-shadow(0 0 1.5px rgba(255, 138, 26, 0.5));
}

/* The hanging strands sway from where they are pinned to the rim, not from
   their own middles, so they read as hung rather than as wiggling. */
.hw-web .drop {
  transform-box: fill-box;
  transform-origin: top center;
  animation: hw-web-sway 7.5s var(--ease-in-out) infinite alternate;
}
.hw-web .drop-b { animation-duration: 9.5s; animation-delay: -2.4s; }
.hw-web .drop-c { animation-duration: 6.4s; animation-delay: -4.1s; }
@keyframes hw-web-sway {
  from { transform: rotate(-2.2deg); }
  to   { transform: rotate(2.2deg); }
}

/* The spider. Abseils a short way down its own thread and climbs back. */
.hw-web .spider {
  transform-box: fill-box;
  transform-origin: top center;
  animation: hw-spider 11s var(--ease-in-out) infinite alternate;
}
@keyframes hw-spider {
  from { transform: translateY(0); }
  to   { transform: translateY(9px); }
}

@media (prefers-reduced-motion: reduce) {
  .hw-web .drop, .hw-web .spider,
  html[data-season="halloween"] .wheel-wrap .hub::after { animation: none; }
}

/* ---- The plate, looping ----
   img/hw-wheel-a-loop.mp4 is the Higgsfield plate animated: silk lifting, embers
   drifting, the strands under the rim swinging. Injected by js/season.js.

   IT IS NOT THE WHEEL. The plate has its own fake wedges and fake hub in it, and
   those must never cover the live canvas, the family's five colours or the names.
   So the middle is MASKED OUT and only the plate's iron band and the empty space
   below it survive. Two numbers do that, both measured off img/hw-wheel-a.png:

     - the plate's disc edge sits at 430/512 = 0.840 of its own half-width, while
       the painted rim's outer edge sits at 0.996 of the wrapper's. Scaling the
       video to 118.6% and insetting it -9.3% makes those two circles the same
       circle, so the plate's silhouette lands exactly on the painted one and the
       strands hang off the real rim rather than out of thin air.
     - the plate's rim INNER edge sits at 338/512 = 0.660 of its half-width. The
       mask is opaque from 66% outward and transparent inside it, so not one pixel
       of the plate's own wedges is ever composited. The cut lands in the painted
       groove, which is the darkest ring on the wheel, so it has nothing to show.

   screen at 0.32, not source-over. Screen cannot darken, so the painted iron
   underneath survives; the silk is the brightest thing in the frame so it is what
   comes through. And silk is ACHROMATIC, which is why this is allowed to feather
   a little over the wedges where the warm orange rim glow was not: lifting value
   does not rotate hue, and a hue rotation on the outer band is what would pull
   Georgina's violet toward Charleigh's pink.

   Inert. pointer-events: none here and inline in js/season.js. #spin-btn is a
   SIBLING of .wheel-wrap, so this is not over the button under any circumstances.
   The wheel itself is not tappable; Spin is the only control. */
.hw-loop {
  position: absolute;
  inset: -9.3%;
  width: 118.6%;
  height: 118.6%;
  aspect-ratio: 1;
  object-fit: cover;
  pointer-events: none;
  z-index: 2;
  /* 0.24, not 0.32. The plate carries a hard orange key from its lower left,
     and this overlay does NOT rotate while the painted segments underneath do,
     which is correct for light but means the key sits on one part of the ring
     permanently. At 0.32 it screened that key hard enough to push the wood at
     the bottom of the rim into terracotta. Silk is near white and the rim is
     mid grey, so pulling the whole layer down costs the webs much less than it
     costs the lighting: the strands still read, the barrel goes away. */
  opacity: 0.24;
  mix-blend-mode: screen;
  -webkit-mask-image: radial-gradient(circle closest-side,
    transparent 0%, transparent 66%, #000 71%, #000 100%);
  mask-image: radial-gradient(circle closest-side,
    transparent 0%, transparent 66%, #000 71%, #000 100%);
  -webkit-mask-repeat: no-repeat;
  mask-repeat: no-repeat;
}
/* js/season.js already declines to mount it, but a media query that changes
   after boot must not leave it running. */
@media (prefers-reduced-motion: reduce) {
  .hw-loop { display: none; }
}

/* Static specular over the spinning wheel. Never rotates. */
.wheel-gloss {
  position: absolute; inset: 0;
  width: 100%; height: 100%;
  aspect-ratio: 1;
  object-fit: contain;
  object-position: center;
  border-radius: 50%;
  mix-blend-mode: screen;
  opacity: 0.45;
  pointer-events: none;
  z-index: 2;
}

#spin-btn {
  position: relative;
  min-width: 210px; min-height: 68px;
  padding: 0 var(--gap-l);
  font-family: var(--font-display);
  font-size: 1.55rem; font-weight: 700; letter-spacing: 0.5px;
  color: #0B0821;
  background: linear-gradient(150deg, #FFF 0%, #E9E2FF 100%);
  border: none; border-radius: var(--radius-pill);
  box-shadow: 0 10px 0 rgba(255,255,255,0.14), var(--shadow-pop);
  cursor: pointer;
  transition: transform var(--dur-fast) var(--ease-spring), box-shadow var(--dur-fast);
}
#spin-btn:active:not(:disabled) {
  transform: translateY(6px);
  box-shadow: 0 4px 0 rgba(255,255,255,0.14), var(--shadow-lift);
}
#spin-btn:disabled { opacity: 0.42; cursor: default; }

#spin-btn::after {
  content: ''; position: absolute; inset: -6px;
  border-radius: var(--radius-pill);
  border: 2px solid rgba(255,255,255,0.5);
  animation: pulse-ring 2.4s var(--ease-out) infinite;
  pointer-events: none;
}
#spin-btn:disabled::after { animation: none; opacity: 0; }
@keyframes pulse-ring {
  0%   { transform: scale(1);    opacity: 0.7; }
  70%  { transform: scale(1.18); opacity: 0; }
  100% { transform: scale(1.18); opacity: 0; }
}

.ghost-btn {
  background: none; border: none; cursor: pointer;
  color: var(--text-faint); font-family: var(--font-body);
  font-size: var(--fs-small); padding: 12px 18px;
  min-height: var(--tap-min);
}

/* On the Halloween sky this sits over the lit horizon, and faint white on warm
   orange measured 1.8:1. Give it its own dark pill rather than dimming the
   scenery: the link is the least important thing on screen and the sky is the
   point. */
html[data-season="halloween"] .ghost-btn {
  color: #fff2dd;
  background: rgba(18, 8, 6, 0.58);
  border-radius: var(--radius-pill);
  padding: 12px 22px;
  backdrop-filter: blur(3px);
  -webkit-backdrop-filter: blur(3px);
}

/* ---------------- Category ---------------- */
#category-screen { gap: var(--gap-s); width: 100%; }

.screen-title {
  font-family: var(--font-display);
  font-size: var(--fs-title); font-weight: 600;
  text-align: center; padding: var(--gap-s) 0;
  flex: 0 0 auto;
}
.screen-title .who { color: var(--active); }

.cat-grid {
  width: 100%;
  display: grid; grid-template-columns: repeat(3, 1fr);
  gap: 9px;
  overflow-y: auto; padding: 2px 2px 10px;
  -webkit-overflow-scrolling: touch;
}

.cat-card {
  --c: var(--surface-line);
  min-height: 92px;
  display: flex; flex-direction: column;
  align-items: center; justify-content: center; gap: 3px;
  padding: 10px 5px;
  border-radius: var(--radius-m);
  background: linear-gradient(165deg, var(--surface-hi), var(--surface));
  border: 1px solid var(--c);
  cursor: pointer;
  transition: transform var(--dur-fast) var(--ease-spring), border-color var(--dur-fast);
}
.cat-card:active { transform: scale(0.94); }
.cat-card .e { font-size: 1.65rem; line-height: 1; }
.cat-card .n {
  font-size: 0.7rem; font-weight: 600; text-align: center;
  line-height: 1.15; color: var(--text);
}
.cat-card .r { font-size: var(--fs-micro); color: var(--text-dim); }
.cat-card.depleted { opacity: 0.32; cursor: default; }
.cat-card.depleted:active { transform: none; }
.cat-card.is-locked {
  opacity: 0.5; cursor: default;
  border-style: dashed;
}
.cat-card.is-locked .e { filter: grayscale(1); }

/* ---------------- Question ---------------- */
#question-screen { justify-content: center; gap: var(--gap-m); width: 100%; }

.q-card {
  width: 100%;
  /* An OPAQUE base under the translucent sheen.
     The card used to be rgba white at 0.10 over whatever the sky happened to
     be doing. Over the dark part that read fine; over the bright aurora the
     question was close to unreadable, which is the one thing on this screen
     that must always be legible. */
  background:
    linear-gradient(165deg, var(--surface-hi), var(--surface)),
    linear-gradient(180deg, rgba(11, 8, 33, 0.90), rgba(11, 8, 33, 0.84));
  backdrop-filter: blur(14px) saturate(0.85);
  -webkit-backdrop-filter: blur(14px) saturate(0.85);
  border: 1px solid var(--surface-line);
  border-radius: var(--radius-l);
  padding: var(--gap-l) var(--gap-m);
  box-shadow: var(--shadow-lift);
  display: flex; flex-direction: column; align-items: center; gap: var(--gap-m);
  animation: q-in var(--dur-slow) var(--ease-spring);
}
@keyframes q-in {
  from { opacity: 0; transform: translate3d(0, 22px, 0) scale(0.95); }
  to   { opacity: 1; transform: none; }
}

.q-badge {
  font-size: var(--fs-micro); font-weight: 700;
  letter-spacing: 1.4px; text-transform: uppercase;
  padding: 6px 14px; border-radius: var(--radius-pill);
  background: color-mix(in srgb, var(--cat, #fff) 26%, transparent);
  border: 1px solid color-mix(in srgb, var(--cat, #fff) 55%, transparent);
  /* The category colour carries the identity through the border and fill. The
     LABEL is plain white, because several category colours (violet, indigo,
     slate) only reached about 3.7:1 as text on the dark card. */
  color: #FFFFFF;
}

/* The thing people actually read. Gets the most generous treatment. */
.q-text {
  font-size: var(--fs-question);
  font-weight: 500; line-height: 1.34;
  text-align: center; text-wrap: balance;
}

.q-who { font-size: var(--fs-small); color: var(--text-dim); text-align: center; }
.q-who b {
  color: var(--active);
  font-weight: 700;
  /* The identity colours are fixed and some of them (Charleigh pink, Liam
     yellow) are low contrast against a lit card. A dark halo keeps the name
     readable without changing the family's colour. */
  text-shadow: 0 1px 3px rgba(11, 8, 33, 0.95), 0 0 10px rgba(11, 8, 33, 0.8);
}

/* A wild card rides on top of the question. */
.wild-tag {
  display: flex; flex-direction: column; align-items: center; gap: 4px;
  width: 100%; padding: var(--gap-s);
  border-radius: var(--radius-m);
  background: rgba(255, 205, 90, 0.13);
  border: 1px dashed rgba(255, 205, 90, 0.5);
  animation: q-in var(--dur-slow) var(--ease-spring) 120ms backwards;
}
.wild-tag .label {
  font-size: var(--fs-micro); font-weight: 800;
  letter-spacing: 2px; color: #FFCF6B;
}
.wild-tag .text { font-size: 0.95rem; text-align: center; color: var(--text); }

.q-actions { display: flex; gap: var(--gap-s); width: 100%; }

.btn-primary {
  flex: 1 1 auto; min-height: var(--tap-min);
  font-family: var(--font-display); font-size: 1.05rem; font-weight: 700;
  color: #0B0821; background: var(--active);
  border: none; border-radius: var(--radius-pill);
  box-shadow: 0 6px 22px var(--active-glow);
  cursor: pointer;
  transition: transform var(--dur-fast) var(--ease-spring);
}
.btn-primary:active { transform: scale(0.96); }

.btn-secondary {
  flex: 0 0 auto; min-width: var(--tap-min); min-height: var(--tap-min);
  padding: 0 var(--gap-m);
  font-family: var(--font-body); font-size: 0.9rem; font-weight: 600;
  color: var(--text-dim);
  background: var(--surface); border: 1px solid var(--surface-line);
  border-radius: var(--radius-pill); cursor: pointer;
}
.btn-secondary:active { transform: scale(0.96); }

/* ---------------- Round complete + unlock ---------------- */
.panel {
  margin: auto; width: 100%;
  display: flex; flex-direction: column; align-items: center; gap: var(--gap-s);
  padding: var(--gap-xl) var(--gap-m);
  text-align: center;
}
.panel .big { font-size: 3.6rem; line-height: 1; }
.panel h2 { font-family: var(--font-display); font-size: var(--fs-title); font-weight: 700; }
.panel p { font-size: var(--fs-body); color: var(--text-dim); }
.panel .btn-primary { flex: 0 0 auto; margin-top: var(--gap-m); padding: 0 var(--gap-xl); }

.unlock-row {
  display: flex; align-items: center; gap: var(--gap-s);
  width: 100%; padding: var(--gap-s) var(--gap-m);
  background: rgba(255, 205, 90, 0.11);
  border: 1px solid rgba(255, 205, 90, 0.34);
  border-radius: var(--radius-m);
  animation: q-in var(--dur-slow) var(--ease-spring) backwards;
}
.unlock-row .e { font-size: 1.7rem; }
.unlock-row .t { text-align: left; font-size: 0.95rem; font-weight: 600; }
.unlock-row .s { font-size: var(--fs-micro); color: var(--text-dim); }

/* ============================================================
   OVERLAYS: announce and handoff
   ============================================================ */
.overlay {
  position: fixed; inset: 0; z-index: 40;
  display: grid; place-items: center;
  opacity: 0; pointer-events: none;
  transition: opacity var(--dur-mid) var(--ease-out);
}
.overlay.visible { opacity: 1; pointer-events: auto; }
.overlay::before {
  content: ''; position: absolute; inset: 0;
  /* A solid dark base with the player's colour tinted ON TOP, rather than a
     gradient that starts at a 10 percent wash in the middle. The old version
     left the bright wheel showing through the centre, which is exactly where
     the name and the tap hint sit. Measured against the real backdrop the hint
     was 1.92:1. */
  background:
    radial-gradient(ellipse at 50% 45%, var(--active-wash), transparent 68%),
    rgba(6, 4, 20, 0.88);
  backdrop-filter: blur(11px) saturate(0.9);
  -webkit-backdrop-filter: blur(11px) saturate(0.9);
}

.overlay .inner {
  position: relative; text-align: center;
  padding: var(--gap-m); max-width: 92vw;
}

.announce-name {
  font-family: var(--font-display);
  font-size: var(--fs-name); font-weight: 700; line-height: 1;
  color: var(--active);
  text-shadow: 0 0 26px var(--active-glow), 0 0 70px var(--active-glow);
}
.overlay.visible .announce-name { animation: slam var(--dur-slow) var(--ease-spring); }
@keyframes slam {
  0%   { opacity: 0; transform: scale(2.1); filter: blur(12px); }
  55%  { opacity: 1; transform: scale(0.94); filter: blur(0); }
  100% { transform: scale(1); }
}

.overlay .sub {
  margin-top: var(--gap-s);
  font-size: 1.1rem; font-weight: 600; color: var(--text);
  text-shadow: 0 2px 8px rgba(11, 8, 33, 0.9);
}
/* The announce overlay floods with the player's colour, so anything faint
   here sits on a bright, saturated field. Measured against the real rendered
   backdrop this hint was 1.92:1, which is effectively invisible, and it is the
   instruction telling you how to continue. */
.overlay .hint {
  margin-top: var(--gap-m);
  font-size: var(--fs-small);
  color: var(--text-dim);
  text-shadow: 0 2px 8px rgba(11, 8, 33, 0.95), 0 0 3px rgba(11, 8, 33, 0.9);
  animation: breathe 2.6s var(--ease-in-out) infinite;
}
@keyframes breathe { 50% { opacity: 0.72; } }

/* Handoff gets a nudging arrow so a passed phone reads instantly. */
.handoff-arrow { font-size: 3rem; animation: nudge 1.5s var(--ease-in-out) infinite; }
@keyframes nudge { 50% { transform: translateY(9px); } }

@media (prefers-reduced-motion: reduce) {
  .overlay.visible .announce-name,
  .handoff-arrow, .hint, #spin-btn::after { animation: none; }
}

/* ============================================================
   FX canvas, above everything, never interactive
   ============================================================ */
#fx-canvas {
  position: fixed; inset: 0; z-index: 60;
  width: 100%; height: 100%;
  pointer-events: none;
}

/* Full screen tint used by gags and the Irish scene. */
.flash {
  position: fixed; inset: 0; z-index: 55;
  pointer-events: none; opacity: 0;
}

/* Actors used by gag scenes: emoji text or rendered images. */
.actor {
  position: fixed; z-index: 58;
  pointer-events: none; will-change: transform, opacity;
}
/* Grounds a cutout against the night sky so it does not look pasted on. */
.actor img, img.actor {
  display: block; width: 100%; height: auto;
  filter: drop-shadow(0 10px 16px rgba(0, 0, 0, 0.45));
}

/* ---- Georgina's Irish scene ---- */

/* Knocks the wheel back so the pint and shamrocks actually read. */
.irish-scrim {
  position: fixed; inset: 0; z-index: 54; pointer-events: none;
  background:
    radial-gradient(ellipse at 50% 48%, rgba(10, 46, 24, 0.55), rgba(4, 10, 8, 0.88) 74%);
  backdrop-filter: blur(6px) saturate(0.7);
  -webkit-backdrop-filter: blur(6px) saturate(0.7);
}

.pint { z-index: 57; }
.pint-img {
  display: block; width: 100%; height: 100%;
  object-fit: contain;
  filter: drop-shadow(0 14px 26px rgba(0, 0, 0, 0.6));
}

/* Backlight. A stout is black, and on a dark scrim a black shape is just a
   hole. Lighting it from behind is what makes the pint read, and it happens
   to be exactly how a real one looks held up to a window. */
.pint::before {
  content: ''; position: absolute;
  left: -22%; top: -8%; width: 144%; height: 116%;
  border-radius: 50%;
  background: radial-gradient(circle, rgba(255, 202, 110, 0.72), rgba(255, 150, 40, 0.26) 48%, transparent 72%);
  filter: blur(16px);
}


/* ---- Short screens: give the wheel less room rather than scrolling ---- */
@media (max-height: 700px) {
  .wheel-wrap {
    width: min(66vw, 250px);
    height: min(66vw, 250px);
  }
  #spin-btn { min-height: 60px; font-size: 1.35rem; }
  .cat-card { min-height: 78px; }
}
