:root {
  /* Stride cadence. The hip pivot sits 37px above the floor and sweeps 58deg,
     carrying a planted foot 35.9px backwards — and it does that over the
     STANCE phase only, which is the first 58% of the cycle. So the body speed
     that keeps the foot from slipping is 35.9 / (0.58 * step) = 77px/s.
     SPEED in scrappy.js must match, or he skates. */
  --step: 0.8s;
  --ink: #eaf3ff;
  --bubble-bg: #222c42;
  --bubble-edge: #3d4b6b;
  --mint: #6fe3c0;
  --amber: #e8734a;
}

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

html,
body {
  width: 100%;
  height: 100%;
  overflow: hidden;
  background: transparent;
  font-family: system-ui, "Segoe UI", sans-serif;
  user-select: none;
  cursor: default;
}

#stage {
  position: relative;
  width: 100%;
  height: 100%;
  pointer-events: none;
}

/* ---------- character shell ---------- */

.scrappy {
  position: absolute;
  bottom: -14px;
  left: 0;
  width: 120px;
  height: 190px;
  pointer-events: auto;
  cursor: grab;
  will-change: transform;
  /* Tumble around his middle, not his feet. */
  transform-origin: 60px 118px;
  z-index: 2;
}

.scrappy[hidden] {
  display: none !important;
}

.scrappy[data-state="held"],
.scrappy[data-state="fly"] {
  cursor: grabbing;
}

.scrappy-flip {
  width: 100%;
  height: 100%;
  transition: transform 0.18s ease;
}

.scrappy[data-facing="left"] .scrappy-flip {
  transform: scaleX(-1);
}

.scrappy-svg {
  width: 100%;
  height: 100%;
  display: block;
  overflow: visible;
}

.scrappy-shadow {
  transition: opacity 0.3s ease;
}

.j-body {
  transform-box: view-box;
  transform-origin: 60px 176px;
}

.j-head {
  transition: rotate 0.4s cubic-bezier(0.2, 1.4, 0.4, 1);
}

/* When a state's animation is removed the joint snaps to its resting pose.
   Transitioning lets him settle out of a walk instead of popping. */
.j-hip,
.j-knee,
.j-shoulder,
.j-elbow {
  transition: transform 0.2s cubic-bezier(0.3, 1, 0.4, 1);
}

.scrappy-shadow {
  transform-box: fill-box;
  transform-origin: center;
}

/* Live meters — voice.js scales these per frame from real audio levels. */
.vu,
#vu-mouth {
  transform-box: fill-box;
  transform-origin: center;
}

.blinker {
  transform-box: fill-box;
  transform-origin: center;
  animation: blink 4.6s ease-in-out infinite;
}

/* ---------- idle ---------- */

.scrappy[data-state="idle"] .j-body {
  animation: idleServo 5.2s ease-in-out infinite;
}
.scrappy[data-state="idle"] .j-head {
  animation: idleHead 6.5s ease-in-out infinite;
}
.scrappy[data-state="idle"] .j-shoulder-l,
.scrappy[data-state="idle"] .j-shoulder-r {
  animation: idleArm 3.4s ease-in-out infinite;
}
.scrappy[data-state="idle"] .j-shoulder-r {
  animation-delay: -1.1s;
}

/* ---------- walk ---------- */

.scrappy[data-state="walk"] .j-body {
  rotate: 3.5deg;
  animation: bodyBob var(--step) linear infinite;
}
.scrappy[data-state="walk"] .j-hip-l,
.scrappy[data-state="walk"] .j-hip-r {
  animation: hip var(--step) infinite;
}
.scrappy[data-state="walk"] .j-knee-l,
.scrappy[data-state="walk"] .j-knee-r {
  animation: knee var(--step) infinite;
}
.scrappy[data-state="walk"] .j-shoulder-l,
.scrappy[data-state="walk"] .j-shoulder-r {
  animation: shoulder var(--step) infinite;
}
.scrappy[data-state="walk"] .j-elbow-l,
.scrappy[data-state="walk"] .j-elbow-r {
  animation: elbow var(--step) infinite;
}
.scrappy[data-state="walk"] .j-hip-r,
.scrappy[data-state="walk"] .j-knee-r,
.scrappy[data-state="walk"] .j-shoulder-l,
.scrappy[data-state="walk"] .j-elbow-l {
  animation-delay: calc(var(--step) / -2);
}
.scrappy[data-state="walk"] .j-head {
  animation: headWobble var(--step) ease-in-out infinite;
}
/* Contact shadow spreads on each footfall — cheap, but it plants him. */
.scrappy[data-state="walk"] .scrappy-shadow {
  animation: shadowStep var(--step) ease-in-out infinite;
}

/* ---------- sit + sleep ---------- */

/* Sitting on the lip of the taskbar. His hip pivot is 37px above the floor
   line when standing, so a 32px drop rests his backside on the edge with 5px
   to spare, and the shins hang past the floor over the taskbar.

   The offset is a static `transform` and the servo idle animates `rotate`.
   They are separate properties, so they compose. Animating `transform` here
   instead would override this declaration outright and he'd sit in mid-air —
   which is exactly what used to happen. The pivot moves to his hip so the
   servo twitch rotates around where he's actually resting. */
.scrappy[data-state="sit"] .j-body,
.scrappy[data-state="sleep"] .j-body {
  transform: translateY(32px);
  transform-origin: 60px 139px;
  animation: idleServo 6.4s ease-in-out infinite;
}

.scrappy[data-state="sit"] .scrappy-shadow,
.scrappy[data-state="sleep"] .scrappy-shadow {
  opacity: 0;
}
/* Head tipped back to look up at the empty desktop. .j-head already
   transitions `rotate`, so this eases in and out on its own. */
.scrappy.is-gazing .j-head {
  rotate: -22deg;
}

/* Feet kicking over the edge. The two legs run at deliberately mismatched
   periods so they drift in and out of sync — a metronome reads as a machine,
   this reads as someone idly swinging their legs. */
.scrappy[data-state="sit"] .j-knee-l {
  animation: dangleFootL 1.15s ease-in-out infinite;
}
.scrappy[data-state="sit"] .j-knee-r {
  animation: dangleFootR 1.43s ease-in-out infinite;
}

@keyframes dangleFootL {
  0%,
  100% {
    transform: rotate(63deg);
  }
  50% {
    transform: rotate(93deg);
  }
}

@keyframes dangleFootR {
  0%,
  100% {
    transform: rotate(91deg);
  }
  55% {
    transform: rotate(65deg);
  }
}
.scrappy[data-state="sit"] .j-hip-l,
.scrappy[data-state="sit"] .j-hip-r,
.scrappy[data-state="sleep"] .j-hip-l,
.scrappy[data-state="sleep"] .j-hip-r {
  transform: rotate(-78deg);
}
.scrappy[data-state="sit"] .j-knee-l,
.scrappy[data-state="sit"] .j-knee-r,
.scrappy[data-state="sleep"] .j-knee-l,
.scrappy[data-state="sleep"] .j-knee-r {
  transform: rotate(76deg);
}
.scrappy[data-state="sit"] .j-shoulder-l,
.scrappy[data-state="sit"] .j-shoulder-r {
  transform: rotate(-14deg);
}
.scrappy[data-state="sit"] .j-elbow-l,
.scrappy[data-state="sit"] .j-elbow-r {
  transform: rotate(-22deg);
}
.scrappy[data-state="sleep"] .j-head {
  rotate: -13deg;
  animation: snooze 4.6s ease-in-out infinite;
}
.scrappy[data-state="sleep"] .j-shoulder-l,
.scrappy[data-state="sleep"] .j-shoulder-r {
  transform: rotate(-6deg);
}

/* ---------- alert: he wants you back ---------- */

.scrappy[data-state="alert"] .j-body {
  animation: jump 0.72s cubic-bezier(0.3, 0, 0.2, 1) infinite;
}
.scrappy[data-state="alert"] .j-shoulder-l {
  animation: armUpL 0.72s ease-in-out infinite;
}
.scrappy[data-state="alert"] .j-shoulder-r {
  animation: armUpR 0.72s ease-in-out infinite;
}
.scrappy[data-state="alert"] .j-elbow-l,
.scrappy[data-state="alert"] .j-elbow-r {
  transform: rotate(-28deg);
}
.scrappy[data-state="alert"] .j-knee-l,
.scrappy[data-state="alert"] .j-knee-r {
  animation: tuck 0.72s ease-in-out infinite;
}
.scrappy[data-state="alert"] .j-head {
  animation: headShake 0.72s ease-in-out infinite;
}

/* ---------- wave (the greeting / the ack) ---------- */

.scrappy[data-state="wave"] .j-body {
  animation: idleServo 4.6s ease-in-out infinite;
}
.scrappy[data-state="wave"] .j-shoulder-l {
  transform: rotate(-148deg);
}
.scrappy[data-state="wave"] .j-elbow-l {
  animation: waveArm 0.42s ease-in-out infinite;
}
.scrappy[data-state="wave"] .j-head {
  rotate: -4deg;
}

/* ---------- point (nagging you at your work) ---------- */

.scrappy[data-state="point"] .j-shoulder-l {
  transform: rotate(-96deg);
}
.scrappy[data-state="point"] .j-elbow-l {
  transform: rotate(4deg);
}
.scrappy[data-state="point"] .j-body {
  rotate: 2.5deg;
}
.scrappy[data-state="point"] .j-head {
  rotate: -6deg;
}

/* ---------- lying down on the taskbar ---------- */

/* Body swung almost flat about the hip, then dropped so his head comes to
   rest on the bar. The head counter-rotates by the same amount so his screen
   stays upright and readable while everything else lies horizontal. */
.scrappy[data-state="lie"] .j-body {
  /* Swept empirically. At 45 his torso sank 26px into the taskbar and he read
     as sliding off it; 28 rests his head on the lip with only the underside
     of his back below the line. */
  transform: translate(22px, 28px) rotate(-82deg);
  transform-origin: 60px 139px;
  animation: idleServo 7.6s ease-in-out infinite;
}
.scrappy[data-state="lie"] .j-head {
  rotate: 58deg;
}
/* Every so often he picks his head up to look at something. */
.scrappy[data-state="lie"].is-headup .j-head {
  rotate: 76deg;
}
/* Perspective. Rotating the standing torso 90deg would present him as taller
   than he is long, which never reads as a body lying down. Squashing across
   and stretching along the spine turns the silhouette into something we're
   looking at from above-ish instead of a tipped-over standing pose. */
.scrappy[data-state="lie"] .j-torso {
  transform: scale(0.78, 1.22);
}
/* Near arm and near leg hang straight down over the lip. +82 cancels the
   body's rotation, so they point at the floor in world space. */
.scrappy[data-state="lie"] .j-shoulder-l {
  animation: lieDangleArm 2.9s ease-in-out infinite;
}
.scrappy[data-state="lie"] .j-hip-l {
  animation: lieDangleLeg 3.4s ease-in-out infinite;
}
/* Only a light bend now. The old 72deg folded the shin right back under him
   and it crossed his hanging arm, which read as broken rather than relaxed. */
.scrappy[data-state="lie"] .j-knee-l {
  transform: rotate(38deg);
}
/* Upper arm hangs off the edge, forearm folded back up so his hand rests on
   his own chest. The elbow's local rotation has to be nearly a full fold
   because the body is already lying at -82deg: upper arm down plus -150 at
   the elbow lands the hand back up above the shoulder. */
.scrappy[data-state="lie"] .j-elbow-l {
  animation: lieHandOnChest 4.1s ease-in-out infinite;
}

@keyframes lieHandOnChest {
  0%,
  100% {
    transform: rotate(-158deg);
  }
  50% {
    transform: rotate(-167deg);
  }
}
/* Far leg stretched out lengthwise along the bar, far arm folded up behind
   the torso where it barely shows. */
.scrappy[data-state="lie"] .j-hip-r {
  transform: translateX(-4px) rotate(4deg);
}
.scrappy[data-state="lie"] .j-knee-r {
  transform: rotate(-2deg);
}
.scrappy[data-state="lie"] .j-shoulder-r {
  transform: translateX(-3px) rotate(-104deg);
}
.scrappy[data-state="lie"] .j-elbow-r {
  transform: rotate(-24deg);
}
.scrappy[data-state="lie"] .scrappy-shadow {
  opacity: 0;
}

@keyframes lieDangleArm {
  0%,
  100% {
    transform: translateX(4px) rotate(76deg);
  }
  50% {
    transform: translateX(4px) rotate(89deg);
  }
}

@keyframes lieDangleLeg {
  0%,
  100% {
    transform: translateX(5px) rotate(102deg);
  }
  50% {
    transform: translateX(5px) rotate(90deg);
  }
}

/* ---------- terror ---------- */

.terror,
.pupils,
.terror-mouth,
.glitch-bar {
  transform-box: fill-box;
  transform-origin: center;
}
.terror {
  animation: terrorJudder 0.08s steps(2, jump-none) infinite;
}
.pupils {
  animation: pupilPinprick 0.42s ease-in-out infinite;
}
.terror-mouth {
  animation: terrorMouth 0.34s ease-in-out infinite;
}
/* A scanline rolling down the display — the tell that he's a machine. */
.glitch-bar {
  animation: glitchRoll 0.9s linear infinite;
}

@keyframes terrorJudder {
  0% {
    transform: translate(-0.7px, 0.4px);
  }
  50% {
    transform: translate(0.7px, -0.5px);
  }
  100% {
    transform: translate(-0.4px, 0.6px);
  }
}

@keyframes pupilPinprick {
  0%,
  100% {
    transform: scale(1);
  }
  50% {
    transform: scale(0.55);
  }
}

@keyframes terrorMouth {
  0%,
  100% {
    transform: scale(1, 1);
  }
  50% {
    transform: scale(1.25, 0.72);
  }
}

@keyframes glitchRoll {
  0% {
    transform: translateY(-320%);
    opacity: 0;
  }
  15% {
    opacity: 0.3;
  }
  85% {
    opacity: 0.3;
  }
  100% {
    transform: translateY(320%);
    opacity: 0;
  }
}

/* ---------- held: dangling from your cursor ---------- */

.scrappy[data-state="held"] .j-shoulder-l {
  animation: dangleL 0.33s ease-in-out infinite;
}
.scrappy[data-state="held"] .j-shoulder-r {
  animation: dangleR 0.41s ease-in-out infinite;
}
.scrappy[data-state="held"] .j-elbow-l {
  animation: elbowFlail 0.27s ease-in-out infinite;
}
.scrappy[data-state="held"] .j-elbow-r {
  animation: elbowFlail 0.36s ease-in-out infinite reverse;
}
.scrappy[data-state="held"] .j-hip-l {
  animation: kickL 0.39s ease-in-out infinite;
}
.scrappy[data-state="held"] .j-hip-r {
  animation: kickR 0.48s ease-in-out infinite;
}
.scrappy[data-state="held"] .j-knee-l {
  animation: kneeFlail 0.31s ease-in-out infinite;
}
.scrappy[data-state="held"] .j-knee-r {
  animation: kneeFlail 0.44s ease-in-out infinite reverse;
}
.scrappy[data-state="held"] .scrappy-shadow,
.scrappy[data-state="fly"] .scrappy-shadow {
  opacity: 0;
}

/* ---------- airborne ---------- */

.scrappy[data-state="fly"] .j-shoulder-l {
  animation: flyArmL 0.36s ease-in-out infinite;
}
.scrappy[data-state="fly"] .j-shoulder-r {
  animation: flyArmR 0.44s ease-in-out infinite;
}
.scrappy[data-state="fly"] .j-elbow-l {
  animation: elbowFlail 0.29s ease-in-out infinite;
}
.scrappy[data-state="fly"] .j-elbow-r {
  animation: elbowFlail 0.38s ease-in-out infinite reverse;
}
.scrappy[data-state="fly"] .j-hip-l {
  animation: flyLegL 0.42s ease-in-out infinite;
}
.scrappy[data-state="fly"] .j-hip-r {
  animation: flyLegR 0.35s ease-in-out infinite;
}
.scrappy[data-state="fly"] .j-knee-l {
  animation: kneeFlail 0.33s ease-in-out infinite reverse;
}
.scrappy[data-state="fly"] .j-knee-r {
  animation: kneeFlail 0.46s ease-in-out infinite;
}

.scrappy.is-landing .j-body {
  animation: landSquash 0.42s cubic-bezier(0.2, 1.2, 0.3, 1);
}

/* ---------- conversation ---------- */

.scrappy[data-state="listen"] .j-body {
  animation: idleServo 4.6s ease-in-out infinite;
}
.scrappy[data-state="listen"] .j-head {
  rotate: -6deg;
}
.scrappy[data-state="listen"] .j-shoulder-l,
.scrappy[data-state="listen"] .j-shoulder-r {
  transform: rotate(-7deg);
}

.scrappy[data-state="speak"] .j-body {
  animation: idleServo 3.6s ease-in-out infinite;
}
.scrappy[data-state="speak"] .j-head {
  animation: talkNod 1.5s ease-in-out infinite;
}
.scrappy[data-state="speak"] .j-shoulder-l {
  animation: talkArmL 1.7s ease-in-out infinite;
}
.scrappy[data-state="speak"] .j-shoulder-r {
  animation: talkArmR 2.1s ease-in-out infinite;
}
.scrappy[data-state="speak"] .j-elbow-l,
.scrappy[data-state="speak"] .j-elbow-r {
  transform: rotate(-26deg);
}

@keyframes talkNod {
  0%,
  100% {
    rotate: -3deg;
  }
  50% {
    rotate: 3deg;
  }
}

@keyframes talkArmL {
  0%,
  100% {
    transform: rotate(-14deg);
  }
  50% {
    transform: rotate(-32deg);
  }
}

@keyframes talkArmR {
  0%,
  100% {
    transform: rotate(-28deg);
  }
  50% {
    transform: rotate(-10deg);
  }
}

/* ---------- glass: punching through into the next monitor ---------- */

.shatter {
  position: absolute;
  width: 0;
  height: 0;
  pointer-events: none;
  z-index: 3;
}

.shatter svg {
  position: absolute;
  left: -220px;
  top: -220px;
  width: 440px;
  height: 440px;
  overflow: visible;
}

.shard {
  transform-box: fill-box;
  transform-origin: center;
  animation: shardFly 0.82s cubic-bezier(0.12, 0.7, 0.28, 1) both;
}

.crack {
  animation: crackFade 0.62s ease-out both;
}

.flash {
  transform-box: fill-box;
  transform-origin: center;
  animation: flashPop 0.34s ease-out both;
}

@keyframes shardFly {
  0% {
    transform: translate(0, 0) rotate(0deg) scale(1);
    opacity: 0.95;
  }
  70% {
    opacity: 0.7;
  }
  100% {
    transform: translate(var(--dx), var(--dy)) rotate(var(--rot)) scale(0.45);
    opacity: 0;
  }
}

@keyframes crackFade {
  0% {
    opacity: 0.9;
    stroke-dashoffset: 60;
  }
  25% {
    opacity: 0.9;
    stroke-dashoffset: 0;
  }
  100% {
    opacity: 0;
    stroke-dashoffset: 0;
  }
}

@keyframes flashPop {
  0% {
    transform: scale(0.2);
    opacity: 0.85;
  }
  100% {
    transform: scale(1.6);
    opacity: 0;
  }
}

/* ---------- speech ---------- */

.bubble {
  position: absolute;
  bottom: 152px;
  left: 50%;
  transform: translateX(-50%) translateY(6px) scale(0.94);
  /* The bubble is absolutely positioned inside .scrappy, which is only 120px
     wide, so shrink-to-fit was sizing it against that and wrapping every
     sentence into a narrow column. max-content sizes to the text instead. */
  width: max-content;
  min-width: 84px;
  max-width: 260px;
  padding: 9px 13px;
  border-radius: 14px;
  background: var(--bubble-bg);
  border: 1px solid var(--bubble-edge);
  color: var(--ink);
  font-size: 13px;
  line-height: 1.45;
  text-align: center;
  opacity: 0;
  pointer-events: none;
  transition: opacity 0.22s ease, transform 0.22s cubic-bezier(0.2, 1.3, 0.4, 1);
  box-shadow: 0 10px 24px rgba(0, 0, 0, 0.35);
}

.bubble::after {
  content: "";
  position: absolute;
  bottom: -7px;
  left: 50%;
  width: 12px;
  height: 12px;
  margin-left: -6px;
  background: var(--bubble-bg);
  border-right: 1px solid var(--bubble-edge);
  border-bottom: 1px solid var(--bubble-edge);
  transform: rotate(45deg);
}

.scrappy.is-talking .bubble {
  opacity: 1;
  transform: translateX(-50%) translateY(0) scale(1);
  pointer-events: auto;
}

.bubble .hint {
  display: block;
  margin-top: 5px;
  font-size: 11px;
  color: #8ea2c4;
}

/* ---------- right-click menu ---------- */

.scrappy-menu {
  position: absolute;
  z-index: 20;
  pointer-events: auto;
  min-width: 176px;
  padding: 6px;
  border-radius: 12px;
  background: var(--bubble-bg);
  border: 1px solid var(--bubble-edge);
  box-shadow: 0 10px 24px rgba(0, 0, 0, 0.35);
}

.scrappy-menu[hidden] {
  display: none;
}

.scrappy-menu button {
  display: block;
  width: 100%;
  margin: 0;
  padding: 8px 12px;
  border: 0;
  border-radius: 8px;
  background: transparent;
  color: var(--ink);
  font: inherit;
  font-size: 13px;
  line-height: 1.35;
  text-align: left;
  cursor: pointer;
}

.scrappy-menu button:hover,
.scrappy-menu button:focus-visible {
  background: #31405c;
  outline: none;
}

.scrappy-menu button.is-quiet {
  color: #8ea2c4;
}

/* ---------- keyframes ---------- */

/* Positive rotation swings a downward limb BACKWARD, so heel strike is the
   negative extreme. Stance runs 0-58% and must be linear: the planted foot
   tracks straight back at the same rate the body travels forward, or he
   moonwalks. Swing (58-100%) snaps the leg back out in front. */
@keyframes hip {
  0% {
    transform: rotate(-30deg);
    animation-timing-function: linear;
  }
  58% {
    transform: rotate(28deg);
    animation-timing-function: cubic-bezier(0.3, 0, 0.2, 1);
  }
  100% {
    transform: rotate(-30deg);
  }
}

@keyframes knee {
  0% {
    transform: rotate(3deg);
  }
  14% {
    transform: rotate(15deg);
  }
  40% {
    transform: rotate(2deg);
  }
  58% {
    transform: rotate(12deg);
  }
  72% {
    transform: rotate(50deg);
  }
  88% {
    transform: rotate(16deg);
  }
  100% {
    transform: rotate(3deg);
  }
}

/* Arms are pendulums — symmetric both ways, unlike the legs. The half-cycle
   delay on the opposite side is what holds them out of phase with the legs. */
@keyframes shoulder {
  0% {
    transform: rotate(-22deg);
  }
  50% {
    transform: rotate(18deg);
  }
  100% {
    transform: rotate(-22deg);
  }
}

/* Elbow carries most of its bend on the forward swing. */
@keyframes elbow {
  0% {
    transform: rotate(-30deg);
  }
  50% {
    transform: rotate(-12deg);
  }
  100% {
    transform: rotate(-30deg);
  }
}

@keyframes shadowStep {
  0%,
  100% {
    transform: scale(1.06, 1);
    opacity: 0.2;
  }
  20% {
    transform: scale(1.12, 1.05);
    opacity: 0.24;
  }
  50% {
    transform: scale(0.9, 0.88);
    opacity: 0.13;
  }
  70% {
    transform: scale(1.12, 1.05);
    opacity: 0.24;
  }
}

@keyframes bodyBob {
  0% {
    transform: translateY(-1px);
  }
  20% {
    transform: translateY(2px);
  }
  50% {
    transform: translateY(-3px);
  }
  70% {
    transform: translateY(2px);
  }
  100% {
    transform: translateY(-1px);
  }
}

/* Big head, loose neck — it lags a beat behind the body. */
@keyframes headWobble {
  0% {
    rotate: -3.2deg;
  }
  35% {
    rotate: 2.6deg;
  }
  68% {
    rotate: -1.4deg;
  }
  100% {
    rotate: -3.2deg;
  }
}



/* Breathing, not hovering. .j-body pivots at his feet, so scaling swells his
   chest and lifts the top of his head about 1.6px while his soles never move.
   Translating the whole body vertically instead is what read as a hover. */
/* The robot equivalent of breathing: a servo holding position. Long holds
   broken by small quick corrections, never a smooth sine, and it animates
   `rotate` rather than `transform` so it can never translate him vertically
   — that vertical drift is what read as hovering.
   At ±0.45deg about his feet the top of his head shifts about a millimetre. */
@keyframes idleServo {
  0% {
    rotate: 0.45deg;
  }
  17% {
    rotate: 0.45deg;
  }
  21% {
    rotate: -0.3deg;
  }
  52% {
    rotate: -0.3deg;
  }
  55% {
    rotate: 0.22deg;
  }
  81% {
    rotate: 0.22deg;
  }
  85% {
    rotate: -0.16deg;
  }
  97% {
    rotate: -0.16deg;
  }
  100% {
    rotate: 0.45deg;
  }
}

/* Status lamps ticking over — a vital sign that needs no movement at all. */
.led {
  animation: ledIdle 3.8s ease-in-out infinite;
}
.led-2 {
  animation-delay: 0.55s;
}
.led-3 {
  animation-delay: 1.1s;
}

@keyframes ledIdle {
  0%,
  100% {
    opacity: 1;
  }
  45% {
    opacity: 0.3;
  }
}

@keyframes idleHead {
  0%,
  100% {
    rotate: 0deg;
  }
  25% {
    rotate: -4deg;
  }
  60% {
    rotate: 3.5deg;
  }
}

@keyframes idleArm {
  0%,
  100% {
    transform: rotate(-3deg);
  }
  50% {
    transform: rotate(5deg);
  }
}

@keyframes blink {
  0%,
  92%,
  100% {
    transform: scaleY(1);
  }
  95.5% {
    transform: scaleY(0.1);
  }
}

@keyframes jump {
  0%,
  100% {
    transform: translateY(0);
  }
  30% {
    transform: translateY(-22px);
  }
  55% {
    transform: translateY(-18px);
  }
  80% {
    transform: translateY(3px);
  }
}

@keyframes tuck {
  0%,
  100% {
    transform: rotate(4deg);
  }
  40% {
    transform: rotate(38deg);
  }
}

@keyframes armUpL {
  0%,
  100% {
    transform: rotate(-132deg);
  }
  45% {
    transform: rotate(-168deg);
  }
}

@keyframes armUpR {
  0%,
  100% {
    transform: rotate(132deg);
  }
  45% {
    transform: rotate(168deg);
  }
}

@keyframes headShake {
  0%,
  100% {
    rotate: -5deg;
  }
  50% {
    rotate: 5deg;
  }
}

@keyframes waveArm {
  0%,
  100% {
    transform: rotate(-18deg);
  }
  50% {
    transform: rotate(20deg);
  }
}


@keyframes snooze {
  0%,
  100% {
    rotate: -13deg;
  }
  50% {
    rotate: -9deg;
  }
}


@keyframes dangleL {
  0%,
  100% {
    transform: rotate(-118deg);
  }
  50% {
    transform: rotate(-192deg);
  }
}

@keyframes dangleR {
  0%,
  100% {
    transform: rotate(188deg);
  }
  50% {
    transform: rotate(114deg);
  }
}

@keyframes kickL {
  0%,
  100% {
    transform: rotate(-46deg);
  }
  50% {
    transform: rotate(52deg);
  }
}

@keyframes kickR {
  0%,
  100% {
    transform: rotate(50deg);
  }
  50% {
    transform: rotate(-42deg);
  }
}

@keyframes kneeFlail {
  0%,
  100% {
    transform: rotate(2deg);
  }
  50% {
    transform: rotate(78deg);
  }
}

@keyframes landSquash {
  0% {
    transform: scale(1.16, 0.8) translateY(6px);
  }
  45% {
    transform: scale(0.94, 1.08) translateY(-3px);
  }
  100% {
    transform: scale(1, 1) translateY(0);
  }
}


@keyframes mouthTalk {
  0%,
  100% {
    transform: scaleY(0.35);
  }
  50% {
    transform: scaleY(1);
  }
}


.mouth-talk {
  transform-box: fill-box;
  transform-origin: center;
  animation: mouthTalk 0.16s ease-in-out infinite;
}

/* Alert now reads on the chest lamps instead of a head antenna. */
.scrappy[data-state="alert"] .led {
  animation: ledFlash 0.42s steps(1) infinite;
}
.scrappy[data-state="alert"] .led-2 {
  animation-delay: 0.14s;
}
.scrappy[data-state="alert"] .led-3 {
  animation-delay: 0.28s;
}

.eye-throb {
  transform-box: fill-box;
  transform-origin: center;
  animation: eyeThrob 0.5s ease-in-out infinite;
}

@keyframes ledFlash {
  0%,
  100% {
    fill: var(--amber);
    opacity: 1;
  }
  50% {
    fill: var(--mint);
    opacity: 0.35;
  }
}

@keyframes eyeThrob {
  0%,
  100% {
    transform: scale(1);
  }
  50% {
    transform: scale(1.14);
  }
}

@keyframes elbowFlail {
  0%,
  100% {
    transform: rotate(-6deg);
  }
  50% {
    transform: rotate(-72deg);
  }
}

@keyframes flyArmL {
  0%,
  100% {
    transform: rotate(-108deg);
  }
  50% {
    transform: rotate(-186deg);
  }
}

@keyframes flyArmR {
  0%,
  100% {
    transform: rotate(182deg);
  }
  50% {
    transform: rotate(104deg);
  }
}

@keyframes flyLegL {
  0%,
  100% {
    transform: rotate(-38deg);
  }
  50% {
    transform: rotate(58deg);
  }
}

@keyframes flyLegR {
  0%,
  100% {
    transform: rotate(56deg);
  }
  50% {
    transform: rotate(-34deg);
  }
}

/* ---------- watching the cursor ----------
   scrappy.js writes --head-aim every frame. This has to come last in the file:
   it ties on specificity with the per-state head rules, so source order is
   what lets it win, and `animation: none` clears any state animation that
   would otherwise override a static rotate. */
.scrappy.is-watching .j-head {
  animation: none;
  rotate: var(--head-aim, 0deg);
  transition: rotate 0.13s linear;
}

/* Eyes slide within the screen to follow the cursor. scrappy.js writes these
   two custom properties; `translate` is its own property so it composes with
   the blink scale on the parent instead of fighting it. */
.eye-track {
  translate: var(--eye-x, 0px) var(--eye-y, 0px);
  transition: translate 0.1s ease-out;
}

/* ---------- flexible neck ----------
   Registering the property makes the angle interpolable, so the bend eases
   between poses instead of snapping. Each of the four segments takes a
   quarter, which compounds up the chain into a curve. */
@property --neck-bend {
  syntax: "<angle>";
  inherits: true;
  initial-value: 0deg;
}

.scrappy {
  --neck-bend: 0deg;
  transition: --neck-bend 0.38s cubic-bezier(0.2, 1.1, 0.4, 1);
}

.neck-seg {
  rotate: calc(var(--neck-bend, 0deg) / 4);
}

/* The neck leans wherever the head goes, so the two never pull apart. */
.scrappy[data-state="lie"] {
  --neck-bend: 54deg;
}
.scrappy[data-state="lie"].is-headup {
  --neck-bend: 72deg;
}
.scrappy[data-state="sleep"] {
  --neck-bend: -11deg;
}
.scrappy[data-state="listen"] {
  --neck-bend: -6deg;
}
.scrappy[data-state="point"] {
  --neck-bend: -6deg;
}
.scrappy.is-gazing {
  --neck-bend: -18deg;
}
.scrappy.is-watching {
  --neck-bend: var(--head-aim, 0deg);
}

/* ---------- talking to him ---------- */

.say-box {
  position: absolute;
  /* Clear of his head — at 128 it sat right across his face. His head tops
     out around 150px up the element, so this floats above him. */
  bottom: 208px;
  left: 50%;
  transform: translateX(-50%) translateY(6px);
  opacity: 0;
  pointer-events: none;
  transition: opacity 0.18s ease, transform 0.18s ease;
}

.scrappy.is-chatting .say-box {
  opacity: 1;
  transform: translateX(-50%) translateY(0);
  pointer-events: auto;
}

.say-box input {
  width: 250px;
  padding: 8px 12px;
  border-radius: 999px;
  border: 1px solid var(--bubble-edge);
  background: var(--bubble-bg);
  color: var(--ink);
  font: inherit;
  font-size: 13px;
  outline: none;
  box-shadow: 0 8px 20px rgba(0, 0, 0, 0.4);
}

.say-box input:focus {
  border-color: var(--mint);
}

.say-box input::placeholder {
  color: #7d8fb0;
}

/* ---------- lift off the desktop ----------
   Two stacked drop-shadows rather than one: a tight dark contact shadow to
   define the silhouette edge, and a wide diffuse one for ambient depth. A
   single shadow at this opacity either reads as a hard outline or as nothing.
   drop-shadow follows the alpha, so it traces his actual outline — gears,
   dangling legs and all — instead of a box. */
.scrappy-svg {
  filter: drop-shadow(0 1px 1.5px rgba(0, 0, 0, 0.32)) drop-shadow(0 5px 11px rgba(0, 0, 0, 0.22));
}

/* Off the ground he's further from the surface, so the ambient shadow throws
   longer and softer. */
.scrappy[data-state="held"] .scrappy-svg,
.scrappy[data-state="fly"] .scrappy-svg {
  filter: drop-shadow(0 2px 3px rgba(0, 0, 0, 0.28)) drop-shadow(0 13px 24px rgba(0, 0, 0, 0.26));
}

/* His chest panel faces you no matter which way he's walking, so the lamp
   order must not mirror with the rest of him. Counter-flipping the group
   about its own centre restores red-yellow-green while leaving the panel
   where the three-quarter yaw put it. */
.scrappy[data-facing="left"] .chest-lights {
  transform: scaleX(-1);
}

/* ---------- dust ---------- */

.dust {
  position: absolute;
  width: 0;
  height: 0;
  pointer-events: none;
  /* Behind him: kicked-up dust belongs at his heels, not over his shins. */
  z-index: 1;
}

.dust svg {
  position: absolute;
  left: -70px;
  top: -70px;
  width: 140px;
  height: 140px;
  overflow: visible;
}

.mote {
  transform-box: fill-box;
  transform-origin: center;
  animation: dustPuff 0.62s cubic-bezier(0.15, 0.7, 0.3, 1) both;
}

/* The listening halo is driven per-frame from the microphone level. */
.vu-glow {
  transform-box: fill-box;
  transform-origin: center;
}

@keyframes dustPuff {
  0% {
    transform: translate(0, 0) scale(0.35);
    opacity: 0.42;
  }
  35% {
    opacity: 0.34;
  }
  100% {
    transform: translate(var(--dx), var(--dy)) scale(var(--gs));
    opacity: 0;
  }
}
