{"$schema":"https://ui.shadcn.com/schema/registry.json","author":"Kaiyu Hsu <uicapsule@kyh.io>","categories":["controls","skeuomorphism","silly"],"dependencies":["lucide-react","motion"],"description":"Five volume controls that refuse to just be a slider: tilt, cannon, curling, dot-matrix, and whack-a-mole.","devDependencies":[],"files":[{"content":"\"use client\";\n\nimport { useEffect, useRef, useState } from \"react\";\nimport { animate, AnimatePresence, motion, useMotionValue, useReducedMotion } from \"motion/react\";\n\nimport type { MotionValue } from \"motion/react\";\n\nimport { HUD_INNER_WIDTH } from \"./macos-chrome\";\nimport {\n  arrowDelta,\n  clamp,\n  clampVolume,\n  DETENT_STEP,\n  snapVolume,\n  VOLUME_MAX,\n  VOLUME_MIN,\n} from \"./volume-scale\";\n\nconst BOARD_WIDTH = HUD_INNER_WIDTH;\nconst BOARD_HEIGHT = 190;\nconst FLOOR_Y = BOARD_HEIGHT - 26;\n\nconst BALL_SIZE = 14;\n/** Where the barrel ends and the ball leaves. Volume is read off the floor, so\n * the muzzle sits at the zero end of the scale. */\nconst MUZZLE = { x: 30, y: FLOOR_Y - 22 };\n\nconst GRAVITY = 1500;\n/** Launch speed (px/s) per px of pull-back. */\nconst POWER_GAIN = 6.2;\nconst MAX_PULL = 120;\n/** A twitch is not a shot. */\nconst MIN_PULL = 6;\n/** Speed kept after the ball hits the floor, and the bite the floor takes out of\n * its forward motion. Cannonballs don't skip like stones. */\nconst BOUNCE = 0.42;\nconst FLOOR_DRAG = 0.66;\n/** Below this the ball has stopped, whatever it thinks. */\nconst SETTLE_SPEED = 42;\nconst MAX_FLIGHT_SECONDS = 6;\n\nconst RESET_SPRING = { damping: 26, stiffness: 300, type: \"spring\" } as const;\n\ninterface Shot {\n  x: number;\n  y: number;\n  vx: number;\n  vy: number;\n}\n\nconst floorToVolume = (x: number) => clamp((x / BOARD_WIDTH) * VOLUME_MAX, VOLUME_MIN, VOLUME_MAX);\nconst volumeToFloor = (volume: number) => (clampVolume(volume) / VOLUME_MAX) * BOARD_WIDTH;\n\n/** The volume scale, painted on the floor where the ball has to land on it. Ticks\n * sit exactly on their value — the ball is scored against them, so they can't lie\n * — while the two end labels shuffle inwards to clear the board's rounded corner. */\nconst endLabelShift = (value: number) => {\n  if (value === 0) {\n    return \"translate-x-2\";\n  }\n  if (value === 100) {\n    return \"-translate-x-2.5\";\n  }\n  return \"\";\n};\n\nconst FloorScale = () => (\n  <div aria-hidden className=\"absolute inset-x-0 bottom-0 h-[26px] border-t border-white/10\">\n    {[0, 25, 50, 75, 100].map((value) => (\n      <span\n        key={value}\n        className=\"absolute top-0 flex -translate-x-1/2 flex-col items-center\"\n        style={{ left: volumeToFloor(value) }}\n      >\n        <span className=\"h-2 w-px bg-white/25\" />\n        <span\n          className={`mt-0.5 text-[10px] tabular-nums text-neutral-500 ${endLabelShift(value)}`}\n        >\n          {value}\n        </span>\n      </span>\n    ))}\n    {Array.from({ length: VOLUME_MAX / DETENT_STEP + 1 }, (_, index) => index * DETENT_STEP)\n      .filter((value) => value % 25 !== 0)\n      .map((value) => (\n        <span\n          key={value}\n          className=\"absolute top-0 h-1 w-px bg-white/10\"\n          style={{ left: volumeToFloor(value) }}\n        />\n      ))}\n  </div>\n);\n\n/** One step of ballistics. Shared by the animated flight and the reduced-motion\n * fast-forward, so both land the ball in exactly the same place. */\nconst advance = (shot: Shot, dt: number): Shot => {\n  const vy = shot.vy + GRAVITY * dt;\n  let next = { vx: shot.vx, vy, x: shot.x + shot.vx * dt, y: shot.y + vy * dt };\n\n  if (next.y >= FLOOR_Y) {\n    next = {\n      vx: next.vx * FLOOR_DRAG,\n      vy: -Math.abs(next.vy) * BOUNCE,\n      x: next.x,\n      y: FLOOR_Y,\n    };\n  }\n  return next;\n};\n\ninterface CannonTrackProps {\n  volume: MotionValue<number>;\n}\n\n/**\n * Artillery, as a volume control. Haul back off the muzzle to load angle and\n * power together, let go, and watch a cannonball arc across the scale. Wherever\n * it comes to rest is your new volume — no aiming line, no preview, no second\n * guess. Overshoot the board and the shot is simply wide: the volume doesn't\n * change, and you get to do it again.\n */\nexport const CannonTrack = ({ volume }: CannonTrackProps) => {\n  const reduceMotion = useReducedMotion();\n  const [phase, setPhase] = useState<\"idle\" | \"aiming\" | \"firing\">(\"idle\");\n  const [wide, setWide] = useState(false);\n\n  const ballX = useMotionValue(MUZZLE.x);\n  const ballY = useMotionValue(MUZZLE.y);\n  /** Where the pointer has hauled the ball back to: it aims the barrel and sizes\n   * the band, so it renders — but the shot is read on pointerup, and a render is\n   * not guaranteed to have landed by then, hence the mirror. */\n  const [pullVector, setPullVector] = useState({ x: 0, y: 0 });\n  const pull = useRef({ x: 0, y: 0 });\n\n  const frame = useRef(0);\n  const wideTimer = useRef(0);\n  const boardRef = useRef<HTMLDivElement>(null);\n\n  // Written to the attribute rather than held in state, matching the other\n  // controls: nothing about the reported value needs a render to land.\n  useEffect(() => {\n    const report = (value: number) =>\n      boardRef.current?.setAttribute(\"aria-valuenow\", String(Math.round(value)));\n    report(volume.get());\n    return volume.on(\"change\", report);\n  }, [volume]);\n\n  useEffect(\n    () => () => {\n      cancelAnimationFrame(frame.current);\n      window.clearTimeout(wideTimer.current);\n    },\n    [],\n  );\n\n  const resetBall = () => {\n    void animate(ballX, MUZZLE.x, RESET_SPRING);\n    void animate(ballY, MUZZLE.y, RESET_SPRING);\n  };\n\n  const land = (x: number) => {\n    setPhase(\"idle\");\n    if (x > BOARD_WIDTH) {\n      // Sailed clean off the board. The volume keeps whatever it had.\n      setWide(true);\n      window.clearTimeout(wideTimer.current);\n      wideTimer.current = window.setTimeout(() => setWide(false), 1600);\n      resetBall();\n      return;\n    }\n    const landed = snapVolume(floorToVolume(x));\n    volume.set(landed);\n    void animate(ballX, volumeToFloor(landed), RESET_SPRING);\n  };\n\n  const fire = (vx: number, vy: number) => {\n    if (reduceMotion) {\n      // No arc, same maths: run the flight to its conclusion and report the result.\n      let shot: Shot = { vx, vy, x: MUZZLE.x, y: MUZZLE.y };\n      for (let t = 0; t < MAX_FLIGHT_SECONDS * 60; t += 1) {\n        shot = advance(shot, 1 / 60);\n        const resting = shot.y >= FLOOR_Y - 0.5 && Math.abs(shot.vy) < SETTLE_SPEED;\n        if (resting || shot.x > BOARD_WIDTH) {\n          break;\n        }\n      }\n      land(shot.x);\n      return;\n    }\n\n    setPhase(\"firing\");\n    let shot: Shot = { vx, vy, x: MUZZLE.x, y: MUZZLE.y };\n    let last = performance.now();\n    let elapsed = 0;\n\n    const step = (now: number) => {\n      const dt = Math.min((now - last) / 1000, 1 / 30);\n      last = now;\n      elapsed += dt;\n\n      shot = advance(shot, dt);\n      ballX.set(shot.x);\n      ballY.set(shot.y);\n\n      const grounded = shot.y >= FLOOR_Y - 0.5;\n      const spent =\n        grounded && Math.abs(shot.vy) < SETTLE_SPEED && Math.abs(shot.vx) < SETTLE_SPEED;\n      if (spent || shot.x > BOARD_WIDTH + BALL_SIZE || elapsed > MAX_FLIGHT_SECONDS) {\n        land(shot.x);\n        return;\n      }\n      frame.current = requestAnimationFrame(step);\n    };\n\n    frame.current = requestAnimationFrame(step);\n  };\n\n  const pointerInBoard = (event: React.PointerEvent<HTMLDivElement>) => {\n    const box = boardRef.current?.getBoundingClientRect();\n    if (!box) {\n      return { x: 0, y: 0 };\n    }\n    return { x: event.clientX - box.left, y: event.clientY - box.top };\n  };\n\n  const handlePointerDown = (event: React.PointerEvent<HTMLDivElement>) => {\n    if (phase === \"firing\") {\n      return;\n    }\n    cancelAnimationFrame(frame.current);\n    event.currentTarget.setPointerCapture(event.pointerId);\n    setWide(false);\n    setPhase(\"aiming\");\n    ballX.set(MUZZLE.x);\n    ballY.set(MUZZLE.y);\n    pull.current = { x: 0, y: 0 };\n    setPullVector({ x: 0, y: 0 });\n  };\n\n  const handlePointerMove = (event: React.PointerEvent<HTMLDivElement>) => {\n    if (phase !== \"aiming\") {\n      return;\n    }\n    const point = pointerInBoard(event);\n    // Pull is measured from the muzzle and capped, so the barrel can't be hauled\n    // through the back of the cannon and the shot has a ceiling.\n    const dx = point.x - MUZZLE.x;\n    const dy = point.y - MUZZLE.y;\n    const distance = Math.hypot(dx, dy);\n    const scale = distance > MAX_PULL ? MAX_PULL / distance : 1;\n    const vector = { x: dx * scale, y: dy * scale };\n\n    pull.current = vector;\n    setPullVector(vector);\n    ballX.set(MUZZLE.x + vector.x);\n    ballY.set(MUZZLE.y + vector.y);\n  };\n\n  const handlePointerUp = () => {\n    if (phase !== \"aiming\") {\n      return;\n    }\n    const vector = pull.current;\n    const distance = Math.hypot(vector.x, vector.y);\n    if (distance < MIN_PULL) {\n      setPhase(\"idle\");\n      resetBall();\n      setPullVector({ x: 0, y: 0 });\n      return;\n    }\n    // Fired opposite the pull, like every slingshot ever built.\n    fire(-vector.x * POWER_GAIN, -vector.y * POWER_GAIN);\n    setPullVector({ x: 0, y: 0 });\n  };\n\n  const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {\n    const delta = arrowDelta(event.key);\n    if (delta === 0) {\n      return;\n    }\n    event.preventDefault();\n    const next = clampVolume(snapVolume(volume.get()) + delta);\n    volume.set(next);\n    void animate(ballX, volumeToFloor(next), RESET_SPRING);\n    void animate(ballY, FLOOR_Y, RESET_SPRING);\n  };\n\n  const pullDistance = Math.hypot(pullVector.x, pullVector.y);\n  const barrelAngle =\n    pullDistance > 0 ? (Math.atan2(-pullVector.y, -pullVector.x) * 180) / Math.PI : -45;\n  const power = clamp(pullDistance / MAX_PULL, 0, 1);\n\n  return (\n    <div\n      ref={boardRef}\n      // oxlint-disable-next-line jsx-a11y/prefer-tag-over-role, jsx-a11y/role-has-required-aria-props -- a physics toy, not a range input; aria-valuenow is written straight to the DOM every frame (see the effect above)\n      role=\"slider\"\n      tabIndex={0}\n      aria-label=\"Volume\"\n      aria-valuemin={VOLUME_MIN}\n      aria-valuemax={VOLUME_MAX}\n      onPointerDown={handlePointerDown}\n      onPointerMove={handlePointerMove}\n      onPointerUp={handlePointerUp}\n      onPointerCancel={handlePointerUp}\n      onKeyDown={handleKeyDown}\n      style={{ height: BOARD_HEIGHT, touchAction: \"none\", width: BOARD_WIDTH }}\n      className={`relative overflow-hidden rounded-2xl bg-neutral-950/80 outline-none focus-visible:ring-2 focus-visible:ring-white/70 ${\n        phase === \"aiming\" ? \"cursor-grabbing\" : \"cursor-crosshair\"\n      }`}\n    >\n      <FloorScale />\n\n      {/* The barrel points wherever the shot is going, and swells with the load. */}\n      <motion.span\n        aria-hidden\n        className=\"absolute origin-left rounded-r-[3px] bg-gradient-to-b from-neutral-400 to-neutral-600\"\n        style={{\n          height: 10 + power * 3,\n          left: MUZZLE.x - 16,\n          rotate: barrelAngle,\n          top: MUZZLE.y - 5,\n          width: 34 + power * 6,\n        }}\n      />\n      <span\n        aria-hidden\n        className=\"absolute size-7 rounded-full border-2 border-neutral-600 bg-neutral-800\"\n        style={{ left: MUZZLE.x - 26, top: MUZZLE.y - 8 }}\n      />\n\n      {/* The band you're hauling against — the only read on how hard this will go. */}\n      {phase === \"aiming\" && pullDistance > MIN_PULL && (\n        <svg aria-hidden className=\"pointer-events-none absolute inset-0 size-full\">\n          <line\n            x1={MUZZLE.x}\n            y1={MUZZLE.y}\n            x2={MUZZLE.x + pullVector.x}\n            y2={MUZZLE.y + pullVector.y}\n            stroke={power > 0.85 ? \"#fca5a5\" : \"#a78bfa\"}\n            strokeWidth={2 + power * 2}\n            strokeLinecap=\"round\"\n          />\n        </svg>\n      )}\n\n      <motion.span\n        aria-hidden\n        className=\"absolute rounded-full bg-gradient-to-b from-neutral-200 to-neutral-500 shadow-[0_2px_6px_rgba(0,0,0,0.6)]\"\n        style={{\n          height: BALL_SIZE,\n          left: -BALL_SIZE / 2,\n          top: -BALL_SIZE / 2,\n          width: BALL_SIZE,\n          x: ballX,\n          y: ballY,\n        }}\n      />\n\n      <AnimatePresence>\n        {wide && (\n          <motion.p\n            key=\"wide\"\n            initial={{ opacity: 0, y: 6 }}\n            animate={{ opacity: 1, y: 0 }}\n            exit={{ opacity: 0 }}\n            className=\"absolute top-4 right-4 rounded-full bg-rose-500/15 px-3 py-1 text-[13px] text-rose-300\"\n          >\n            Wide. Volume unchanged.\n          </motion.p>\n        )}\n      </AnimatePresence>\n    </div>\n  );\n};\n","path":"/cannon-track.tsx","target":"uicapsule/volume-control/cannon-track.tsx","type":"registry:file"},{"content":"\"use client\";\n\nimport { useEffect, useRef, useState } from \"react\";\nimport { animate, motion, useMotionValue, useReducedMotion, useTransform } from \"motion/react\";\n\nimport type { MotionValue } from \"motion/react\";\n\nimport { HUD_INNER_WIDTH } from \"./macos-chrome\";\nimport { arrowDelta, clamp, clampVolume, snapVolume, VOLUME_MAX, VOLUME_MIN } from \"./volume-scale\";\n\nconst SHEET_WIDTH = HUD_INNER_WIDTH;\nconst SHEET_HEIGHT = 132;\nconst STONE_SIZE = 34;\nconst HACK_X = 8;\nconst TRAVEL = SHEET_WIDTH - STONE_SIZE;\n\n/** Throwing speed (px/s) per px of draw-back. */\nconst THROW_GAIN = 7.4;\nconst MAX_DRAW = 110;\nconst MIN_DRAW = 6;\n\n/** Friction on unswept ice, per second. Sweeping cuts it to a fraction of this —\n * that difference is the entire sport. */\nconst ICE_FRICTION = 1.35;\nconst SWEPT_FRICTION = 0.42;\n/** How fast a burst of sweeping decays back to nothing. Stop scrubbing and the\n * ice pebbles back over within about a third of a second. */\nconst SWEEP_DECAY = 3.2;\n/** Pointer px of scrubbing needed to fully polish the ice ahead of the stone. */\nconst SWEEP_FULL = 260;\n/** Sweeping only counts within this many px of the stone. Waving the broom at the\n * far end of the sheet does nothing, as in life. */\nconst BROOM_REACH = 90;\n\nconst BACKBOARD = 0.3;\nconst SETTLE_SPEED = 12;\nconst MAX_SLIDE_SECONDS = 8;\n\nconst SETTLE_SPRING = { damping: 30, stiffness: 380, type: \"spring\" } as const;\nconst RETURN_SPRING = { damping: 24, stiffness: 220, type: \"spring\" } as const;\n\nconst stoneToVolume = (x: number) => clamp((x / TRAVEL) * VOLUME_MAX, VOLUME_MIN, VOLUME_MAX);\nconst volumeToStone = (volume: number) => (clampVolume(volume) / VOLUME_MAX) * TRAVEL;\n\n/** Centre of the house, in sheet px — pulled in from the 100 mark so its rings\n * stay on the ice instead of falling off the end of it. */\nconst HOUSE_X = volumeToStone(92) + STONE_SIZE / 2;\n\n/** The scale, printed on the ice. */\nconst SheetScale = () => (\n  <div aria-hidden className=\"absolute inset-x-0 bottom-0 h-5\">\n    {[0, 25, 50, 75, 100].map((value) => (\n      <span\n        key={value}\n        className=\"absolute bottom-0 flex -translate-x-1/2 flex-col items-center\"\n        style={{ left: volumeToStone(value) + STONE_SIZE / 2 }}\n      >\n        <span className=\"h-2 w-px bg-sky-100/30\" />\n        <span className=\"text-[10px] tabular-nums text-sky-100/40\">{value}</span>\n      </span>\n    ))}\n  </div>\n);\n\ninterface CurlingTrackProps {\n  volume: MotionValue<number>;\n}\n\n/**\n * Volume by curling. Draw the stone back off the hack, release, and it goes out\n * onto the ice with whatever weight you gave it — and then you're not done. While\n * it's travelling you can *sweep*: scrub the pointer back and forth just ahead of\n * the stone and the ice polishes, friction drops, and the stone carries further.\n * Stop sweeping and it grabs. Where it finally sits is the volume.\n */\nexport const CurlingTrack = ({ volume }: CurlingTrackProps) => {\n  const reduceMotion = useReducedMotion();\n  const [phase, setPhase] = useState<\"idle\" | \"drawing\" | \"sliding\">(\"idle\");\n\n  const stoneX = useMotionValue(volumeToStone(volume.get()));\n  /** 0–1. How polished the ice under the broom is right now. */\n  const sweep = useMotionValue(0);\n  /** Where the broom is, in sheet px. Only meaningful while sweeping. */\n  const broomX = useMotionValue(-100);\n  const [drawing, setDrawing] = useState(0);\n\n  const velocity = useRef(0);\n  const frame = useRef(0);\n  const drawOrigin = useRef(0);\n  /** Draw distance, mirrored out of state: the throw is read on pointerup, and a\n   * render is not guaranteed to have landed by then. */\n  const drawn = useRef(0);\n  const lastPointer = useRef<number | null>(null);\n  const sheetRef = useRef<HTMLDivElement>(null);\n\n  // Deliberately NOT mirrored off the stone's position: hauling the stone back\n  // behind the hack would drag the volume to zero on the way, and the throw would\n  // start from a lie. The volume only follows a stone that's actually travelling.\n\n  // A travelling stone moves the volume every frame; the attribute is written\n  // directly so none of that turns into a render.\n  useEffect(() => {\n    const report = (value: number) =>\n      sheetRef.current?.setAttribute(\"aria-valuenow\", String(Math.round(value)));\n    report(volume.get());\n    return volume.on(\"change\", report);\n  }, [volume]);\n\n  useEffect(() => () => cancelAnimationFrame(frame.current), []);\n\n  const pointerInSheet = (event: React.PointerEvent<HTMLDivElement>) => {\n    const box = sheetRef.current?.getBoundingClientRect();\n    return box ? event.clientX - box.left : 0;\n  };\n\n  const settle = (x: number) => {\n    velocity.current = 0;\n    setPhase(\"idle\");\n    lastPointer.current = null;\n    void animate(sweep, 0, { duration: 0.3 });\n    broomX.set(-100);\n    const landed = snapVolume(stoneToVolume(x));\n    volume.set(landed);\n    void animate(stoneX, volumeToStone(landed), SETTLE_SPRING);\n  };\n\n  const deliver = (speed: number) => {\n    velocity.current = speed;\n    setPhase(\"sliding\");\n    // The stone leaves from the hack, not from wherever your hand hauled it back\n    // to. Releasing from behind the line and letting the wall catch it would eat\n    // the throw before it started.\n    stoneX.set(HACK_X);\n\n    let last = performance.now();\n    let elapsed = 0;\n\n    const step = (now: number) => {\n      const dt = Math.min((now - last) / 1000, 1 / 30);\n      last = now;\n      elapsed += dt;\n\n      // The broom's effect is local: polished ice only helps while the stone is\n      // actually on it.\n      const swept = sweep.get();\n      const friction = ICE_FRICTION + (SWEPT_FRICTION - ICE_FRICTION) * swept;\n\n      let v = velocity.current * Math.exp(-friction * dt);\n      let x = stoneX.get() + v * dt;\n\n      // Walls only stop a stone travelling into them — a stone leaving the hack is\n      // moving away from the near wall and must be allowed to go.\n      if (x < 0 && v < 0) {\n        x = 0;\n        v = 0;\n      } else if (x > TRAVEL && v > 0) {\n        // The backboard. Hit it hard enough and you come back down the sheet.\n        x = TRAVEL - (x - TRAVEL);\n        v = -v * BACKBOARD;\n      }\n\n      stoneX.set(x);\n      // The stone in motion IS the volume: the menu bar climbs with it the whole\n      // way down the sheet, and drops again if it comes back off the backboard.\n      volume.set(snapVolume(stoneToVolume(x)));\n      velocity.current = v;\n      sweep.set(swept * Math.exp(-SWEEP_DECAY * dt));\n\n      if (Math.abs(v) < SETTLE_SPEED || elapsed > MAX_SLIDE_SECONDS) {\n        settle(x);\n        return;\n      }\n      frame.current = requestAnimationFrame(step);\n    };\n\n    frame.current = requestAnimationFrame(step);\n  };\n\n  const handlePointerDown = (event: React.PointerEvent<HTMLDivElement>) => {\n    if (phase === \"sliding\" || reduceMotion) {\n      return;\n    }\n    cancelAnimationFrame(frame.current);\n    event.currentTarget.setPointerCapture(event.pointerId);\n    drawOrigin.current = event.clientX;\n    drawn.current = 0;\n    stoneX.set(HACK_X);\n    setPhase(\"drawing\");\n    setDrawing(0);\n  };\n\n  const handlePointerMove = (event: React.PointerEvent<HTMLDivElement>) => {\n    if (phase === \"drawing\") {\n      // Only backwards counts. Shoving the stone forwards by hand is not a throw.\n      const distance = clamp(drawOrigin.current - event.clientX, 0, MAX_DRAW);\n      drawn.current = distance;\n      setDrawing(distance);\n      stoneX.set(clamp(HACK_X - distance * 0.28, -24, TRAVEL));\n      return;\n    }\n    if (phase !== \"sliding\") {\n      return;\n    }\n\n    // Sweeping: raw scrubbing distance, but only if the broom is near the stone.\n    const at = pointerInSheet(event);\n    broomX.set(at);\n    const previous = lastPointer.current;\n    lastPointer.current = at;\n    if (previous === null) {\n      return;\n    }\n\n    const stoneCentre = stoneX.get() + STONE_SIZE / 2;\n    if (Math.abs(at - stoneCentre) > BROOM_REACH) {\n      return;\n    }\n\n    const scrubbed = Math.abs(at - previous);\n    sweep.set(clamp(sweep.get() + scrubbed / SWEEP_FULL, 0, 1));\n  };\n\n  const handlePointerUp = () => {\n    if (phase !== \"drawing\") {\n      return;\n    }\n    const distance = drawn.current;\n    if (distance < MIN_DRAW) {\n      setPhase(\"idle\");\n      setDrawing(0);\n      // No throw: the stone goes back to where the volume already was.\n      void animate(stoneX, volumeToStone(volume.get()), RETURN_SPRING);\n      return;\n    }\n    lastPointer.current = null;\n    setDrawing(0);\n    deliver(distance * THROW_GAIN);\n  };\n\n  const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {\n    const delta = arrowDelta(event.key);\n    if (delta === 0) {\n      return;\n    }\n    event.preventDefault();\n    cancelAnimationFrame(frame.current);\n    settle(volumeToStone(clampVolume(snapVolume(volume.get()) + delta)));\n  };\n\n  const stoneSpin = useTransform(stoneX, (x) => (x / (Math.PI * STONE_SIZE)) * 220);\n  const broomOpacity = useTransform(sweep, [0, 0.15], [0, 1]);\n  const iceGlow = useTransform(sweep, [0, 1], [0, 0.5]);\n  const drawPower = clamp(drawing / MAX_DRAW, 0, 1);\n\n  return (\n    <div\n      ref={sheetRef}\n      // oxlint-disable-next-line jsx-a11y/prefer-tag-over-role, jsx-a11y/role-has-required-aria-props -- a physics toy, not a range input; aria-valuenow is written straight to the DOM every frame (see the effect above)\n      role=\"slider\"\n      tabIndex={0}\n      aria-label=\"Volume\"\n      aria-valuemin={VOLUME_MIN}\n      aria-valuemax={VOLUME_MAX}\n      onPointerDown={handlePointerDown}\n      onPointerMove={handlePointerMove}\n      onPointerUp={handlePointerUp}\n      onPointerCancel={handlePointerUp}\n      onKeyDown={handleKeyDown}\n      style={{ height: SHEET_HEIGHT, touchAction: \"none\", width: SHEET_WIDTH }}\n      className={`relative overflow-hidden rounded-2xl bg-gradient-to-b from-sky-100/10 to-sky-50/[0.04] outline-none focus-visible:ring-2 focus-visible:ring-white/70 ${\n        phase === \"drawing\" ? \"cursor-grabbing\" : \"cursor-grab\"\n      }`}\n    >\n      {/* The house, painted at the loud end — the target nobody asked for. It sits\n          a shade inside 100 so the rings don't run off the end of the sheet. */}\n      <span\n        aria-hidden\n        className=\"absolute top-1/2 size-20 -translate-x-1/2 -translate-y-1/2 rounded-full border border-sky-300/25\"\n        style={{ left: HOUSE_X }}\n      />\n      <span\n        aria-hidden\n        className=\"absolute top-1/2 size-10 -translate-x-1/2 -translate-y-1/2 rounded-full border border-rose-300/30 bg-rose-400/10\"\n        style={{ left: HOUSE_X }}\n      />\n\n      <SheetScale />\n\n      {/* Polished ice: a wash of light under the broom while it's working. */}\n      <motion.span\n        aria-hidden\n        className=\"absolute inset-y-0 w-40 -translate-x-1/2 bg-[radial-gradient(ellipse_at_center,rgba(186,230,253,0.9),transparent_70%)]\"\n        style={{ left: broomX, opacity: iceGlow }}\n      />\n\n      <motion.span\n        aria-hidden\n        className=\"absolute top-1/2 -translate-y-1/2 rounded-full bg-gradient-to-b from-neutral-300 to-neutral-500 shadow-[0_3px_10px_rgba(0,0,0,0.55)]\"\n        style={{ height: STONE_SIZE, rotate: stoneSpin, width: STONE_SIZE, x: stoneX }}\n      >\n        <span className=\"absolute inset-[6px] rounded-full border border-neutral-600/60 bg-neutral-700\" />\n        <span className=\"absolute inset-x-[13px] top-[3px] h-2 rounded-full bg-rose-400/80\" />\n      </motion.span>\n\n      {/* The broom only exists while you're actually sweeping with it. */}\n      <motion.span\n        aria-hidden\n        className=\"absolute top-1/2 h-9 w-2 -translate-x-1/2 -translate-y-1/2 rotate-12 rounded-sm bg-amber-200/90 shadow-[0_0_10px_rgba(253,230,138,0.5)]\"\n        style={{ left: broomX, opacity: broomOpacity }}\n      />\n\n      {phase === \"drawing\" && drawing > MIN_DRAW && (\n        <div\n          aria-hidden\n          className=\"absolute right-4 bottom-3 left-4 h-1 overflow-hidden rounded-full bg-white/10\"\n        >\n          <div\n            className=\"h-full rounded-full bg-gradient-to-r from-sky-300 to-rose-300\"\n            style={{ width: `${drawPower * 100}%` }}\n          />\n        </div>\n      )}\n    </div>\n  );\n};\n","path":"/curling-track.tsx","target":"uicapsule/volume-control/curling-track.tsx","type":"registry:file"},{"content":"\"use client\";\n\nimport { useEffect, useRef, useState } from \"react\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { EraserIcon, ScanLineIcon } from \"lucide-react\";\n\nimport type { MotionValue } from \"motion/react\";\n\nimport { clampVolume } from \"./volume-scale\";\n\nconst COLS = 5;\nconst ROWS = 7;\nconst CELL_SIZE = COLS * ROWS;\nconst DOT = 13;\nconst DOT_GAP = 5;\n\n/** The dots, as fixed positions rather than an anonymous array — so each one can\n * carry its own key and say where it lives out loud for a screen reader. */\nconst POSITIONS = Array.from({ length: CELL_SIZE }, (_, index) => ({\n  column: (index % COLS) + 1,\n  index,\n  row: Math.floor(index / COLS) + 1,\n}));\n\n/** A 5×7 glyph for every digit, indexed by digit — the machine's entire idea of\n * what numbers look like. It matches whatever you draw against these and picks a winner, no matter\n * how little your drawing deserves one. */\nconst GLYPHS: readonly (readonly string[])[] = [\n  [\"01110\", \"10001\", \"10011\", \"10101\", \"11001\", \"10001\", \"01110\"],\n  [\"00100\", \"01100\", \"00100\", \"00100\", \"00100\", \"00100\", \"01110\"],\n  [\"01110\", \"10001\", \"00001\", \"00010\", \"00100\", \"01000\", \"11111\"],\n  [\"11111\", \"00010\", \"00100\", \"00010\", \"00001\", \"10001\", \"01110\"],\n  [\"00010\", \"00110\", \"01010\", \"10010\", \"11111\", \"00010\", \"00010\"],\n  [\"11111\", \"10000\", \"11110\", \"00001\", \"00001\", \"10001\", \"01110\"],\n  [\"00110\", \"01000\", \"10000\", \"11110\", \"10001\", \"10001\", \"01110\"],\n  [\"11111\", \"00001\", \"00010\", \"00100\", \"01000\", \"01000\", \"01000\"],\n  [\"01110\", \"10001\", \"10001\", \"01110\", \"10001\", \"10001\", \"01110\"],\n  [\"01110\", \"10001\", \"10001\", \"01111\", \"00001\", \"00010\", \"01100\"],\n];\n\nconst GLYPH_CELLS: readonly (readonly boolean[])[] = GLYPHS.map((rows) =>\n  Array.from(rows.join(\"\"), (bit) => bit === \"1\"),\n);\n\nconst EMPTY_CELL = (): boolean[] => Array.from({ length: CELL_SIZE }, () => false);\n\n/** How many lit dots it takes before the machine bothers looking at a cell. Under\n * this, the cell is blank and the digit is a zero. */\nconst INK_THRESHOLD = 3;\n\n/**\n * Nearest glyph wins, full stop. There is no \"I'm not sure\" — every drawing is\n * read as *something*, and a lazy 7 becoming a 1 is the entire point of the\n * exercise. Ties go to the lower digit, purely so it's deterministic.\n */\nconst readCell = (cells: boolean[]): number => {\n  const ink = cells.filter(Boolean).length;\n  if (ink < INK_THRESHOLD) {\n    return 0;\n  }\n\n  let best = 0;\n  let bestScore = -1;\n  for (const [digit, glyph] of GLYPH_CELLS.entries()) {\n    let score = 0;\n    for (let index = 0; index < CELL_SIZE; index += 1) {\n      if (cells[index] === glyph[index]) {\n        score += 1;\n      }\n    }\n    if (score > bestScore) {\n      bestScore = score;\n      best = digit;\n    }\n  }\n  return best;\n};\n\nconst SCAN_MS = 620;\n\nconst Verdict = ({ scanning, verdict }: { scanning: boolean; verdict: number | null }) => {\n  if (scanning) {\n    return <span className=\"text-neutral-600\">scanning…</span>;\n  }\n  if (verdict === null) {\n    return <span className=\"text-neutral-700\">—</span>;\n  }\n  return verdict;\n};\n\ninterface DotCellProps {\n  label: \"tens\" | \"ones\";\n  cells: boolean[];\n  onDown: (cell: \"tens\" | \"ones\", index: number, lit: boolean) => void;\n  onEnter: (cell: \"tens\" | \"ones\", index: number) => void;\n}\n\nconst DotCell = ({ label, cells, onDown, onEnter }: DotCellProps) => (\n  <div\n    className=\"grid\"\n    style={{\n      gap: DOT_GAP,\n      gridTemplateColumns: `repeat(${COLS}, ${DOT}px)`,\n      touchAction: \"none\",\n    }}\n  >\n    {POSITIONS.map((dot) => {\n      const lit = cells[dot.index] ?? false;\n      return (\n        <button\n          key={`${label}-${dot.row}-${dot.column}`}\n          type=\"button\"\n          aria-pressed={lit}\n          aria-label={`${label} digit, row ${dot.row}, column ${dot.column}`}\n          onPointerDown={() => onDown(label, dot.index, lit)}\n          onPointerEnter={() => onEnter(label, dot.index)}\n          style={{ height: DOT, margin: 0, width: DOT }}\n          className={`rounded-full outline-none transition-colors duration-75 focus-visible:ring-2 focus-visible:ring-white/70 ${\n            lit\n              ? \"bg-emerald-300 shadow-[0_0_8px_rgba(110,231,183,0.65)]\"\n              : \"bg-neutral-800 hover:bg-neutral-700\"\n          }`}\n        />\n      );\n    })}\n  </div>\n);\n\ninterface DotsTrackProps {\n  volume: MotionValue<number>;\n}\n\n/**\n * The machine reads dot-matrix. It does not read sliders, it does not read\n * numbers you type — it reads dots. So draw the volume you want, digit by digit,\n * and it will squint at your handwriting and commit to whatever it decides you\n * meant. Two cells, which is to say the loudest this thing goes is 99.\n */\nexport const DotsTrack = ({ volume }: DotsTrackProps) => {\n  const reduceMotion = useReducedMotion();\n  const [tens, setTens] = useState<boolean[]>(EMPTY_CELL);\n  const [ones, setOnes] = useState<boolean[]>(EMPTY_CELL);\n  const [scanning, setScanning] = useState(false);\n  const [verdict, setVerdict] = useState<number | null>(null);\n\n  /** What we're painting for the length of this drag: on, or off. Set by whichever\n   * dot you pressed first, so a drag never flickers dots on and off under itself. */\n  const painting = useRef<boolean | null>(null);\n  const scanTimer = useRef(0);\n\n  useEffect(() => () => window.clearTimeout(scanTimer.current), []);\n\n  const paint = (cell: \"tens\" | \"ones\", index: number, value: boolean) => {\n    const setter = cell === \"tens\" ? setTens : setOnes;\n    setter((current) => current.map((lit, position) => (position === index ? value : lit)));\n    setVerdict(null);\n  };\n\n  const handleDotDown = (cell: \"tens\" | \"ones\", index: number, lit: boolean) => {\n    painting.current = !lit;\n    paint(cell, index, !lit);\n  };\n\n  const handleDotEnter = (cell: \"tens\" | \"ones\", index: number) => {\n    if (painting.current === null) {\n      return;\n    }\n    paint(cell, index, painting.current);\n  };\n\n  const clear = () => {\n    setTens(EMPTY_CELL());\n    setOnes(EMPTY_CELL());\n    setVerdict(null);\n  };\n\n  const read = () => {\n    const value = clampVolume(readCell(tens) * 10 + readCell(ones));\n\n    if (reduceMotion) {\n      setVerdict(value);\n      volume.set(value);\n      return;\n    }\n\n    // The scan is theatre, but it's the theatre that sells the misread: you watch\n    // it look, and then you watch it be wrong with total confidence.\n    setScanning(true);\n    setVerdict(null);\n    scanTimer.current = window.setTimeout(() => {\n      setScanning(false);\n      setVerdict(value);\n      volume.set(value);\n    }, SCAN_MS);\n  };\n\n  return (\n    <div\n      onPointerUp={() => {\n        painting.current = null;\n      }}\n      onPointerLeave={() => {\n        painting.current = null;\n      }}\n      className=\"flex items-center gap-5\"\n    >\n      <div className=\"relative flex gap-3 rounded-2xl bg-neutral-950/80 p-3\">\n        <DotCell label=\"tens\" cells={tens} onDown={handleDotDown} onEnter={handleDotEnter} />\n        <DotCell label=\"ones\" cells={ones} onDown={handleDotDown} onEnter={handleDotEnter} />\n\n        <AnimatePresence>\n          {scanning && (\n            <motion.span\n              key=\"scan\"\n              aria-hidden\n              className=\"pointer-events-none absolute inset-y-2 w-10 rounded-full bg-gradient-to-r from-transparent via-emerald-300/50 to-transparent blur-[2px]\"\n              initial={{ left: 0, opacity: 0 }}\n              animate={{ left: \"calc(100% - 40px)\", opacity: 1 }}\n              exit={{ opacity: 0 }}\n              transition={{ duration: SCAN_MS / 1000, ease: \"linear\" }}\n            />\n          )}\n        </AnimatePresence>\n      </div>\n\n      <div className=\"flex flex-1 flex-col items-start gap-3\">\n        <div aria-live=\"polite\" className=\"min-h-[52px]\">\n          <p className=\"text-[11px] tracking-wide text-neutral-500 uppercase\">Reads as</p>\n          <p className=\"text-[30px] leading-tight font-semibold tabular-nums text-emerald-300\">\n            <Verdict scanning={scanning} verdict={verdict} />\n          </p>\n        </div>\n\n        <div className=\"flex gap-2\">\n          <button\n            type=\"button\"\n            onClick={read}\n            disabled={scanning}\n            className=\"flex items-center gap-1.5 rounded-full border border-emerald-400/50 bg-emerald-400/10 px-3 py-1.5 text-[13px] text-neutral-100 outline-none focus-visible:ring-2 focus-visible:ring-emerald-400/60 disabled:opacity-50\"\n          >\n            <ScanLineIcon className=\"size-3.5 text-emerald-300\" />\n            Read\n          </button>\n          <button\n            type=\"button\"\n            onClick={clear}\n            className=\"flex items-center gap-1.5 rounded-full border border-white/15 px-3 py-1.5 text-[13px] text-neutral-300 outline-none hover:bg-white/5 focus-visible:ring-2 focus-visible:ring-white/60\"\n          >\n            <EraserIcon className=\"size-3.5 text-neutral-400\" />\n            Clear\n          </button>\n        </div>\n      </div>\n    </div>\n  );\n};\n","path":"/dots-track.tsx","target":"uicapsule/volume-control/dots-track.tsx","type":"registry:file"},{"content":"\"use client\";\n\nimport { useRef } from \"react\";\nimport { motion, useMotionValueEvent, useTransform } from \"motion/react\";\nimport {\n  BatteryMediumIcon,\n  SearchIcon,\n  Volume1Icon,\n  Volume2Icon,\n  VolumeIcon,\n  VolumeOffIcon,\n  WifiIcon,\n} from \"lucide-react\";\n\nimport type { MotionValue } from \"motion/react\";\nimport type { ReactNode } from \"react\";\n\nimport { clampVolume, speakerTier, VOLUME_MAX } from \"./volume-scale\";\n\n/** Every control is built to fit inside the HUD with 20px of air on each side, so\n * this one number sets the groove's length, the cannon's range, and the sheet of\n * ice. Widen it and the whole set widens with it. */\nexport const HUD_WIDTH = 520;\nexport const HUD_INNER_WIDTH = HUD_WIDTH - 40;\n\nconst TIERS = [\"muted\", \"low\", \"mid\", \"high\"] as const;\n\nconst TIER_ICON = {\n  high: Volume2Icon,\n  low: VolumeIcon,\n  mid: Volume1Icon,\n  muted: VolumeOffIcon,\n} as const;\n\nconst TierIcon = ({\n  tier,\n  volume,\n}: {\n  tier: (typeof TIERS)[number];\n  volume: MotionValue<number>;\n}) => {\n  const Icon = TIER_ICON[tier];\n  const opacity = useTransform(volume, (value) => (speakerTier(value) === tier ? 1 : 0));\n  return (\n    <motion.span aria-hidden className=\"absolute inset-0\" style={{ opacity }}>\n      <Icon className=\"size-[22px] text-neutral-100\" />\n    </motion.span>\n  );\n};\n\n/**\n * Four glyphs stacked, cross-fading on the volume value. A tier change is an icon\n * swap, and an icon swap is a re-render — so instead the icons all stay mounted\n * and opacity does the work, off the motion value, on the compositor.\n */\nconst SpeakerGlyph = ({ volume }: { volume: MotionValue<number> }) => (\n  <span className=\"relative flex size-[22px] items-center justify-center\">\n    {TIERS.map((tier) => (\n      <TierIcon key={tier} tier={tier} volume={volume} />\n    ))}\n  </span>\n);\n\ninterface MacosChromeProps {\n  /** Volume 0–100, owned by the shell. The menu bar follows it frame by frame. */\n  volume: MotionValue<number>;\n  open: boolean;\n  onToggle: () => void;\n  /** The HUD, anchored under the speaker icon. */\n  hud: ReactNode;\n}\n\n/**\n * The desktop the controls live on: a menu bar running off the left edge of the\n * frame, with the speaker icon as the one thing you can actually click. It opens\n * the HUD and, from then on, only reports — it never sets anything itself.\n */\nexport const MacosChrome = ({ volume, open, onToggle, hud }: MacosChromeProps) => (\n  <div className=\"flex items-center gap-6 select-none\">\n    <div aria-hidden className=\"flex items-center gap-6 text-[15px] whitespace-nowrap\">\n      <span className=\"font-semibold text-neutral-100\">Music</span>\n      <span className=\"text-neutral-300\">File</span>\n      <span className=\"text-neutral-300\">Edit</span>\n      <span className=\"text-neutral-300\">Controls</span>\n      <span className=\"text-neutral-300\">Window</span>\n    </div>\n\n    <div className=\"ml-auto flex shrink-0 items-center gap-5\">\n      <BatteryMediumIcon aria-hidden className=\"size-6 text-neutral-300\" />\n      <WifiIcon aria-hidden className=\"size-[19px] text-neutral-300\" />\n\n      <div className=\"relative\">\n        <button\n          type=\"button\"\n          onClick={onToggle}\n          aria-expanded={open}\n          aria-label=\"Sound\"\n          className={`flex size-9 cursor-pointer items-center justify-center rounded-lg outline-none focus-visible:ring-2 focus-visible:ring-white/70 ${\n            open ? \"bg-white/15\" : \"hover:bg-white/10\"\n          }`}\n        >\n          <SpeakerGlyph volume={volume} />\n        </button>\n\n        {hud}\n      </div>\n\n      <SearchIcon aria-hidden className=\"size-[19px] text-neutral-300\" />\n      <span aria-hidden className=\"text-[15px] whitespace-nowrap text-neutral-100\">\n        Tue 9:41\n      </span>\n    </div>\n  </div>\n);\n\ninterface VolumeHudProps {\n  volume: MotionValue<number>;\n  /** What the HUD calls the control it's showing. */\n  title: string;\n  /** A line of instruction under the title — the only help you get. */\n  hint: string;\n  children: ReactNode;\n}\n\n/**\n * The HUD itself: a readout that reports whatever the control below it decides,\n * and no way to set the volume directly. The number and the meter are written\n * straight to the DOM — a marble rolling or a stone sliding moves the volume\n * every frame, and none of that is worth a React render.\n */\nexport const VolumeHud = ({ volume, title, hint, children }: VolumeHudProps) => {\n  const readoutRef = useRef<HTMLSpanElement>(null);\n  useMotionValueEvent(volume, \"change\", (value) => {\n    if (readoutRef.current) {\n      readoutRef.current.textContent = String(Math.round(value));\n    }\n  });\n  const meterScale = useTransform(volume, (value) => clampVolume(value) / VOLUME_MAX);\n\n  return (\n    <div\n      style={{ width: HUD_WIDTH }}\n      className=\"rounded-[22px] border border-white/10 bg-neutral-800/95 p-5 shadow-2xl shadow-black/60 backdrop-blur-xl\"\n    >\n      <div className=\"mb-1 flex items-baseline justify-between gap-3\">\n        <p className=\"text-[15px] font-medium text-neutral-100\">{title}</p>\n        <p aria-live=\"polite\" className=\"text-[15px] tabular-nums text-neutral-400\">\n          <span ref={readoutRef} className=\"text-neutral-100\">\n            {Math.round(volume.get())}\n          </span>\n          <span className=\"text-neutral-500\"> / {VOLUME_MAX}</span>\n        </p>\n      </div>\n\n      <p className=\"mb-3 text-[13px] text-neutral-500\">{hint}</p>\n\n      {/* The honest slider, demoted to a read-only meter. It shows you exactly\n          where you are and offers no way to change it. */}\n      <div aria-hidden className=\"mb-4 h-1.5 overflow-hidden rounded-full bg-neutral-700/70\">\n        <motion.div\n          className=\"h-full origin-left rounded-full bg-neutral-100\"\n          style={{ scaleX: meterScale }}\n        />\n      </div>\n\n      {children}\n    </div>\n  );\n};\n","path":"/macos-chrome.tsx","target":"uicapsule/volume-control/macos-chrome.tsx","type":"registry:file"},{"content":"\"use client\";\n\nimport { useEffect, useRef, useState } from \"react\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { MinusIcon, PlusIcon } from \"lucide-react\";\n\nimport type { MotionValue } from \"motion/react\";\n\nimport { HUD_INNER_WIDTH } from \"./macos-chrome\";\nimport { clampVolume, DETENT_STEP, snapVolume, VOLUME_MAX, VOLUME_MIN } from \"./volume-scale\";\n\nconst FIELD_WIDTH = HUD_INNER_WIDTH;\nconst FIELD_HEIGHT = 150;\nconst COLS = 5;\nconst ROWS = 3;\nconst HOLES = COLS * ROWS;\n\n/** What a hit is worth. Both directions cost the same — the minus is not a\n * penalty, it's just the other button, and it counts exactly as much. */\nconst STEP = DETENT_STEP;\n\n/** How long a button stays up at volume 0, and at volume 100. The louder it gets,\n * the less time you have — the last twenty points are a scramble. */\nconst SHOW_MS_LOUD = 520;\nconst SHOW_MS_QUIET = 1000;\n/** Pill size shrinks the same way. */\nconst SIZE_QUIET = 54;\nconst SIZE_LOUD = 38;\n\n/** How often a minus comes up instead of a plus. Enough to keep you honest. */\nconst MINUS_ODDS = 0.38;\n\nconst POP = { damping: 26, stiffness: 520, type: \"spring\" } as const;\n\ninterface Mole {\n  id: number;\n  hole: number;\n  kind: \"plus\" | \"minus\";\n}\n\nconst lerp = (from: number, to: number, t: number) => from + (to - from) * t;\n\nconst keyDelta = (key: string) => {\n  if (key === \"ArrowRight\") {\n    return STEP;\n  }\n  if (key === \"ArrowLeft\") {\n    return -STEP;\n  }\n  return 0;\n};\n\nconst StaticButton = ({ kind, onClick }: { kind: \"plus\" | \"minus\"; onClick: () => void }) => (\n  <button\n    type=\"button\"\n    onClick={onClick}\n    aria-label={kind === \"plus\" ? \"Louder\" : \"Quieter\"}\n    className=\"flex size-11 items-center justify-center rounded-full border border-white/15 bg-neutral-800 text-neutral-100 outline-none hover:bg-neutral-700 focus-visible:ring-2 focus-visible:ring-white/70\"\n  >\n    {kind === \"plus\" ? <PlusIcon className=\"size-5\" /> : <MinusIcon className=\"size-5\" />}\n  </button>\n);\n\ninterface PlusMinusTrackProps {\n  volume: MotionValue<number>;\n}\n\n/**\n * The + and − are still there. They just won't hold still.\n *\n * They surface for a moment, somewhere in the grid, and drop back down whether or\n * not you got to them. Hit the + and you gain five; hit the − — and you will,\n * because it looks exactly as inviting — and you lose five. The louder it gets,\n * the faster they duck and the smaller they are, so the last stretch to 100 is a\n * fight you're supposed to lose at least once.\n */\nexport const PlusMinusTrack = ({ volume }: PlusMinusTrackProps) => {\n  const reduceMotion = useReducedMotion();\n  const [mole, setMole] = useState<Mole | null>(null);\n  const [level, setLevel] = useState(() => Math.round(volume.get()));\n\n  const nextId = useRef(0);\n  const timer = useRef(0);\n  const lastHole = useRef(-1);\n  /** The loop reads the volume, and the volume changes on every hit — so it reads\n   * it from a ref rather than closing over a stale render's copy. `apply` is the\n   * only writer and updates both, so the two never drift. */\n  const levelRef = useRef(level);\n  /** A hit has to be able to summon the next pop, and it lives outside the effect\n   * that defines the loop. This is the doorbell. */\n  const popRef = useRef(() => {\n    /* empty */\n  });\n  /** Pops already scored, so an exiting pill can't be scored twice. */\n  const whacked = useRef(new Set<number>());\n\n  // Reduced motion gets what it should have had all along: two buttons that stay\n  // where you left them.\n  useEffect(() => {\n    if (reduceMotion) {\n      return;\n    }\n\n    const pop = () => {\n      // Never the same hole twice running — a pill that reappears under a resting\n      // cursor is a gift, and this control does not give gifts.\n      const drift = 1 + Math.floor(Math.random() * (HOLES - 1));\n      const hole =\n        lastHole.current < 0\n          ? Math.floor(Math.random() * HOLES)\n          : (lastHole.current + drift) % HOLES;\n      lastHole.current = hole;\n\n      const kind = Math.random() < MINUS_ODDS ? \"minus\" : \"plus\";\n      nextId.current += 1;\n      setMole({ hole, id: nextId.current, kind });\n\n      // Louder means less time to react. The window closes as you climb.\n      const loudness = clampVolume(levelRef.current) / VOLUME_MAX;\n      timer.current = window.setTimeout(\n        () => {\n          setMole(null);\n          timer.current = window.setTimeout(pop, 180);\n        },\n        lerp(SHOW_MS_QUIET, SHOW_MS_LOUD, loudness),\n      );\n    };\n\n    popRef.current = pop;\n    pop();\n    return () => window.clearTimeout(timer.current);\n  }, [reduceMotion]);\n\n  const apply = (delta: number) => {\n    const next = clampVolume(snapVolume(levelRef.current) + delta);\n    levelRef.current = next;\n    setLevel(next);\n    volume.set(next);\n  };\n\n  const whack = (hit: Mole) => {\n    // A pill is still on screen — and still clickable — for as long as its exit\n    // animation runs. Without this, a fast enough hand scores the same pop over\n    // and over on its way down the hole.\n    if (whacked.current.has(hit.id)) {\n      return;\n    }\n    whacked.current.add(hit.id);\n\n    window.clearTimeout(timer.current);\n    setMole(null);\n    apply(hit.kind === \"plus\" ? STEP : -STEP);\n    // The next one comes up quicker than if this had timed out. No rest.\n    timer.current = window.setTimeout(() => popRef.current(), 200);\n  };\n\n  if (reduceMotion) {\n    return (\n      <div className=\"flex items-center justify-center gap-4\">\n        <StaticButton kind=\"minus\" onClick={() => apply(-STEP)} />\n        <span\n          // oxlint-disable-next-line jsx-a11y/prefer-tag-over-role -- a range input would be the honest slider this piece refuses to be; the keyboard contract is the same\n          role=\"slider\"\n          tabIndex={0}\n          aria-label=\"Volume\"\n          aria-valuemin={VOLUME_MIN}\n          aria-valuemax={VOLUME_MAX}\n          aria-valuenow={level}\n          onKeyDown={(event) => {\n            const delta = keyDelta(event.key);\n            if (delta === 0) {\n              return;\n            }\n            event.preventDefault();\n            apply(delta);\n          }}\n          className=\"text-[28px] tabular-nums text-neutral-100 outline-none focus-visible:ring-2 focus-visible:ring-white/70\"\n        >\n          {level}\n        </span>\n        <StaticButton kind=\"plus\" onClick={() => apply(STEP)} />\n      </div>\n    );\n  }\n\n  const loudness = clampVolume(level) / VOLUME_MAX;\n  const size = lerp(SIZE_QUIET, SIZE_LOUD, loudness);\n\n  return (\n    <div\n      // oxlint-disable-next-line jsx-a11y/prefer-tag-over-role -- the whack-a-mole field is the slider; a range input can't host the buttons\n      role=\"slider\"\n      tabIndex={0}\n      aria-label=\"Volume\"\n      aria-valuemin={VOLUME_MIN}\n      aria-valuemax={VOLUME_MAX}\n      aria-valuenow={level}\n      onKeyDown={(event) => {\n        const delta = keyDelta(event.key);\n        if (delta === 0) {\n          return;\n        }\n        // The keyboard is the cheat code: the buttons stop running from you.\n        event.preventDefault();\n        apply(delta);\n      }}\n      style={{ height: FIELD_HEIGHT, width: FIELD_WIDTH }}\n      className=\"relative grid rounded-2xl bg-neutral-950/80 p-2 outline-none focus-visible:ring-2 focus-visible:ring-white/70\"\n    >\n      <div\n        className=\"grid size-full\"\n        style={{\n          gridTemplateColumns: `repeat(${COLS}, 1fr)`,\n          gridTemplateRows: `repeat(${ROWS}, 1fr)`,\n        }}\n      >\n        {Array.from({ length: HOLES }, (_, hole) => (\n          <div key={hole} className=\"relative flex items-center justify-center\">\n            <span aria-hidden className=\"size-2 rounded-full bg-white/[0.06]\" />\n\n            <AnimatePresence>\n              {mole?.hole === hole && (\n                <motion.button\n                  key={mole.id}\n                  type=\"button\"\n                  aria-label={mole.kind === \"plus\" ? \"Louder\" : \"Quieter\"}\n                  onPointerDown={() => whack(mole)}\n                  initial={{ opacity: 0, scale: 0.4, y: 10 }}\n                  animate={{ opacity: 1, scale: 1, y: 0 }}\n                  exit={{ opacity: 0, scale: 0.5, y: 8 }}\n                  transition={POP}\n                  style={{ height: size, width: size }}\n                  className={`absolute flex cursor-pointer items-center justify-center rounded-full outline-none focus-visible:ring-2 focus-visible:ring-white/70 ${\n                    mole.kind === \"plus\"\n                      ? \"bg-emerald-400/90 shadow-[0_0_18px_rgba(52,211,153,0.45)]\"\n                      : \"bg-rose-400/90 shadow-[0_0_18px_rgba(251,113,133,0.45)]\"\n                  }`}\n                >\n                  {mole.kind === \"plus\" ? (\n                    <PlusIcon className=\"size-5 text-neutral-950\" />\n                  ) : (\n                    <MinusIcon className=\"size-5 text-neutral-950\" />\n                  )}\n                </motion.button>\n              )}\n            </AnimatePresence>\n          </div>\n        ))}\n      </div>\n    </div>\n  );\n};\n","path":"/plus-minus-track.tsx","target":"uicapsule/volume-control/plus-minus-track.tsx","type":"registry:file"},{"content":"\"use client\";\n\nimport { useState } from \"react\";\nimport { ChevronDownIcon } from \"lucide-react\";\n\nimport type { VolumeVariant } from \"./volume-control\";\n\nimport { VolumeControl } from \"./volume-control\";\n\nconst VARIANTS: { value: VolumeVariant; label: string }[] = [\n  { label: \"Tilt\", value: \"tilt\" },\n  { label: \"Cannon\", value: \"cannon\" },\n  { label: \"Curling\", value: \"curling\" },\n  { label: \"Dots\", value: \"dots\" },\n  { label: \"+/− game\", value: \"plus-minus\" },\n];\n\nconst isVariant = (value: string): value is VolumeVariant =>\n  VARIANTS.some((variant) => variant.value === value);\n\nconst Preview = () => {\n  const [variant, setVariant] = useState<VolumeVariant>(\"tilt\");\n\n  return (\n    <div className=\"relative h-screen w-full bg-neutral-950\">\n      {/* Bottom-left, out of the way: the HUD hangs off the top-right corner. */}\n      <span className=\"absolute bottom-4 left-4 z-10 flex items-center rounded-full border border-white/10 bg-neutral-900\">\n        <select\n          aria-label=\"Variation\"\n          value={variant}\n          onChange={(event) => {\n            if (isVariant(event.target.value)) {\n              setVariant(event.target.value);\n            }\n          }}\n          className=\"appearance-none bg-transparent py-1.5 pr-7 pl-3 text-xs text-neutral-400 outline-none\"\n        >\n          {VARIANTS.map((option) => (\n            <option key={option.value} value={option.value}>\n              {option.label}\n            </option>\n          ))}\n        </select>\n        <ChevronDownIcon className=\"pointer-events-none absolute right-2.5 size-3.5 text-neutral-500\" />\n      </span>\n\n      <VolumeControl variant={variant} />\n    </div>\n  );\n};\n\nexport default Preview;\n","path":"/preview.tsx","target":"uicapsule/volume-control/preview.tsx","type":"registry:file"},{"content":"\"use client\";\n\nimport { useEffect, useRef, useState } from \"react\";\nimport { animate, motion, useMotionValue, useReducedMotion, useTransform } from \"motion/react\";\n\nimport type { MotionValue } from \"motion/react\";\n\nimport { HUD_INNER_WIDTH } from \"./macos-chrome\";\nimport {\n  arrowDelta,\n  clamp,\n  clampVolume,\n  DETENT_STEP,\n  snapVolume,\n  VOLUME_MAX,\n  VOLUME_MIN,\n} from \"./volume-scale\";\n\nconst MARBLE_SIZE = 22;\nconst GROOVE_HEIGHT = 34;\nconst TRAVEL = HUD_INNER_WIDTH - MARBLE_SIZE;\n\n/** Degrees of tilt at the ends of the drag. Past this the marble is uncatchable\n * and the joke stops being funny — and the panel starts sweeping through the\n * readout above it (see the clearance the track reserves for its swing). */\nconst MAX_TILT = 15;\n/** Pointer pixels per degree — how heavy the panel feels to lean on. */\nconst PX_PER_DEGREE = 7;\n\n/** Acceleration (px/s²) the marble would feel if the panel were stood on its end. */\nconst GRAVITY = 1500;\n/** Exponential speed decay per second. Ice would be unplayable; this is more like wood. */\nconst FRICTION = 2.3;\n/** Fraction of speed kept off a wall. The ends are rubber, not a trampoline. */\nconst RESTITUTION = 0.35;\n\n/** A detent can only catch a marble that's barely moving... */\nconst CATCH_SPEED = 46;\n/** ...and only if it's basically on top of it. */\nconst CATCH_RADIUS = 7;\n/** ...and it lets go the moment the panel leans hard enough to overcome it. This\n * is the whole game: level the panel to let a detent bite, lean to break out. */\nconst DETENT_GRIP = 300;\n\nconst LEVEL_SPRING = { damping: 18, stiffness: 220, type: \"spring\" } as const;\nconst SETTLE_SPRING = { damping: 30, stiffness: 400, type: \"spring\" } as const;\n\nconst volumeToX = (volume: number) => (clampVolume(volume) / VOLUME_MAX) * TRAVEL;\nconst xToVolume = (x: number) => clamp((x / TRAVEL) * VOLUME_MAX, VOLUME_MIN, VOLUME_MAX);\nconst detentX = (volume: number) => volumeToX(snapVolume(volume));\n\n/** A notch every five points. They're the only places the marble can rest. */\nconst Detents = () => (\n  <>\n    {Array.from({ length: VOLUME_MAX / DETENT_STEP + 1 }, (_, index) => {\n      const value = index * DETENT_STEP;\n      const major = value % 25 === 0;\n      return (\n        <span\n          key={value}\n          aria-hidden\n          className={`absolute top-1/2 w-px -translate-y-1/2 ${\n            major ? \"h-4 bg-white/25\" : \"h-2 bg-white/10\"\n          }`}\n          style={{ left: volumeToX(value) + MARBLE_SIZE / 2 + (GROOVE_HEIGHT - MARBLE_SIZE) / 2 }}\n        />\n      );\n    })}\n  </>\n);\n\ninterface TiltTrackProps {\n  volume: MotionValue<number>;\n}\n\n/**\n * The panel is a spirit level and the volume is a marble in the groove.\n *\n * Lean on it and gravity does the rest: the marble rolls, gathers speed, and\n * rattles off the ends. Detents every five points will catch it — but only if\n * it's crawling, and only while the panel is near enough to level that the\n * detent can hold it. So you don't set the volume, you *land* it: lean to get\n * moving, then flatten out at exactly the right moment and pray it sticks.\n */\nexport const TiltTrack = ({ volume }: TiltTrackProps) => {\n  const reduceMotion = useReducedMotion();\n  const [tilting, setTilting] = useState(false);\n\n  const panelRef = useRef<HTMLDivElement>(null);\n\n  /** Panel lean, in degrees. Positive = right side down. */\n  const tilt = useMotionValue(0);\n  /** Marble offset along the groove, in px. */\n  const x = useMotionValue(volumeToX(volume.get()));\n\n  const velocity = useRef(0);\n  const frame = useRef(0);\n  const grabX = useRef(0);\n  const grabTilt = useRef(0);\n  /** The detent the marble is currently sitting in, or null if it's loose. */\n  const seated = useRef<number | null>(snapVolume(volume.get()));\n\n  // The marble's position IS the volume — no commit step, no confirmation. The\n  // menu bar's speaker icon follows it the entire way down the groove.\n  useEffect(() => {\n    const unsubscribe = x.on(\"change\", (next) => volume.set(xToVolume(next)));\n    return unsubscribe;\n  }, [x, volume]);\n\n  // A rolling marble moves the volume every frame. Reporting that through state\n  // would re-render the whole panel — detents and all — for each one, so the\n  // slider's value is written straight to the attribute instead.\n  useEffect(() => {\n    const report = (value: number) =>\n      panelRef.current?.setAttribute(\"aria-valuenow\", String(Math.round(value)));\n    report(volume.get());\n    return volume.on(\"change\", report);\n  }, [volume]);\n\n  useEffect(() => () => cancelAnimationFrame(frame.current), []);\n\n  const run = () => {\n    cancelAnimationFrame(frame.current);\n    let last = performance.now();\n\n    const step = (now: number) => {\n      // Clamped so a backgrounded tab doesn't resume with one enormous step and\n      // fire the marble straight through a wall.\n      const dt = Math.min((now - last) / 1000, 1 / 30);\n      last = now;\n\n      const radians = (tilt.get() * Math.PI) / 180;\n      const accel = GRAVITY * Math.sin(radians);\n      let v = velocity.current;\n      let next = x.get();\n\n      const seatedAt = seated.current;\n      if (seatedAt !== null) {\n        // Sitting in a detent: nothing happens until the lean beats the grip.\n        if (Math.abs(accel) < DETENT_GRIP) {\n          velocity.current = 0;\n          frame.current = requestAnimationFrame(step);\n          return;\n        }\n        seated.current = null;\n      }\n\n      v += accel * dt;\n      v *= Math.exp(-FRICTION * dt);\n      next += v * dt;\n\n      if (next < 0) {\n        next = -next;\n        v = -v * RESTITUTION;\n      } else if (next > TRAVEL) {\n        next = 2 * TRAVEL - next;\n        v = -v * RESTITUTION;\n      }\n\n      // A detent bites only a slow marble sitting right on top of it, and only\n      // while the panel is flat enough to hold it there.\n      const candidate = detentX(xToVolume(next));\n      if (\n        Math.abs(v) < CATCH_SPEED &&\n        Math.abs(candidate - next) < CATCH_RADIUS &&\n        Math.abs(accel) < DETENT_GRIP\n      ) {\n        x.set(candidate);\n        velocity.current = 0;\n        seated.current = snapVolume(xToVolume(candidate));\n        frame.current = requestAnimationFrame(step);\n        return;\n      }\n\n      x.set(next);\n      velocity.current = v;\n      frame.current = requestAnimationFrame(step);\n    };\n\n    frame.current = requestAnimationFrame(step);\n  };\n\n  const handlePointerDown = (event: React.PointerEvent<HTMLDivElement>) => {\n    if (reduceMotion) {\n      return;\n    }\n    event.currentTarget.setPointerCapture(event.pointerId);\n    grabX.current = event.clientX;\n    grabTilt.current = tilt.get();\n    setTilting(true);\n    run();\n  };\n\n  const handlePointerMove = (event: React.PointerEvent<HTMLDivElement>) => {\n    if (!tilting) {\n      return;\n    }\n    const delta = (event.clientX - grabX.current) / PX_PER_DEGREE;\n    tilt.set(clamp(grabTilt.current + delta, -MAX_TILT, MAX_TILT));\n  };\n\n  const handlePointerUp = () => {\n    if (!tilting) {\n      return;\n    }\n    setTilting(false);\n    // Hands off: the panel finds level again, and whatever the marble was doing,\n    // it now has to do it on a flat surface. Usually that means it stops.\n    void animate(tilt, 0, LEVEL_SPRING);\n  };\n\n  const settleTo = (next: number) => {\n    cancelAnimationFrame(frame.current);\n    velocity.current = 0;\n    seated.current = snapVolume(next);\n    void animate(x, detentX(next), SETTLE_SPRING);\n  };\n\n  const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {\n    const delta = arrowDelta(event.key);\n    if (delta === 0) {\n      return;\n    }\n    // Keyboard gets the boring volume control. The joke isn't worth locking anyone out.\n    event.preventDefault();\n    settleTo(clampVolume(snapVolume(volume.get()) + delta));\n  };\n\n  const bubbleX = useTransform(tilt, [-MAX_TILT, MAX_TILT], [-22, 22]);\n  const marbleSpin = useTransform(x, (value) => (value / (Math.PI * MARBLE_SIZE)) * 360);\n\n  return (\n    <div>\n      {/* The panel swings, so it needs room to swing in: a level bar this wide sweeps\n          a box far taller than itself once it leans, and without the clearance it would\n          scythe straight through the readout above it. */}\n      <motion.div\n        ref={panelRef}\n        // oxlint-disable-next-line jsx-a11y/prefer-tag-over-role, jsx-a11y/role-has-required-aria-props -- a physics toy, not a range input; aria-valuenow is written straight to the DOM every frame (see the effect above)\n        role=\"slider\"\n        tabIndex={0}\n        aria-label=\"Volume\"\n        aria-valuemin={VOLUME_MIN}\n        aria-valuemax={VOLUME_MAX}\n        onPointerDown={handlePointerDown}\n        onPointerMove={handlePointerMove}\n        onPointerUp={handlePointerUp}\n        onPointerCancel={handlePointerUp}\n        onKeyDown={handleKeyDown}\n        style={{ rotate: reduceMotion ? 0 : tilt, touchAction: \"none\" }}\n        className={`relative my-[62px] rounded-2xl bg-neutral-900/70 px-3 py-3 outline-none focus-visible:ring-2 focus-visible:ring-white/70 ${\n          tilting ? \"cursor-grabbing\" : \"cursor-grab\"\n        }`}\n      >\n        <div\n          className=\"relative overflow-hidden rounded-full bg-neutral-950/80 shadow-[inset_0_2px_6px_rgba(0,0,0,0.7)]\"\n          style={{ height: GROOVE_HEIGHT }}\n        >\n          <Detents />\n\n          <motion.span\n            aria-hidden\n            className=\"absolute top-1/2 left-0 rounded-full bg-gradient-to-b from-white to-neutral-400 shadow-[0_2px_5px_rgba(0,0,0,0.6)]\"\n            style={{\n              height: MARBLE_SIZE,\n              marginLeft: (GROOVE_HEIGHT - MARBLE_SIZE) / 2,\n              marginTop: -MARBLE_SIZE / 2,\n              rotate: reduceMotion ? 0 : marbleSpin,\n              width: MARBLE_SIZE,\n              x,\n            }}\n          >\n            {/* One off-centre highlight, so the roll is visible. A featureless\n                ball sliding down a groove reads as a slider handle. */}\n            <span className=\"absolute top-1 left-[6px] size-1.5 rounded-full bg-white/90\" />\n          </motion.span>\n        </div>\n      </motion.div>\n\n      {/* The bubble level: the only feedback telling you how hard you're leaning. */}\n      <div\n        aria-hidden\n        className=\"mx-auto mt-3 flex h-5 w-24 items-center justify-center rounded-full border border-white/10 bg-neutral-900/70\"\n      >\n        <span className=\"absolute h-5 w-5 rounded-full border-x border-white/15\" />\n        <motion.span\n          className=\"size-3 rounded-full bg-emerald-400/80 shadow-[0_0_8px_rgba(52,211,153,0.6)]\"\n          style={{ x: reduceMotion ? 0 : bubbleX }}\n        />\n      </div>\n    </div>\n  );\n};\n","path":"/tilt-track.tsx","target":"uicapsule/volume-control/tilt-track.tsx","type":"registry:file"},{"content":"\"use client\";\n\nimport { useState } from \"react\";\nimport { AnimatePresence, motion, useMotionValue, useTransform } from \"motion/react\";\n\nimport type { MotionValue } from \"motion/react\";\nimport type { ReactNode } from \"react\";\n\nimport { CannonTrack } from \"./cannon-track\";\nimport { CurlingTrack } from \"./curling-track\";\nimport { DotsTrack } from \"./dots-track\";\nimport { MacosChrome, VolumeHud } from \"./macos-chrome\";\nimport { PlusMinusTrack } from \"./plus-minus-track\";\nimport { TiltTrack } from \"./tilt-track\";\nimport { DEFAULT_VOLUME } from \"./volume-scale\";\n\nexport type VolumeVariant = \"tilt\" | \"cannon\" | \"curling\" | \"dots\" | \"plus-minus\";\n\ninterface VolumeControlProps {\n  variant?: VolumeVariant;\n}\n\nconst HUD_POP = { damping: 30, stiffness: 380, type: \"spring\" } as const;\n\nconst HINTS = {\n  cannon: \"Pull back, let go, and hope.\",\n  curling: \"Throw the stone. Sweep to keep it going.\",\n  dots: \"Draw the number you want. It reads dots, nothing else.\",\n  \"plus-minus\": \"Hit + for louder. Good luck catching it.\",\n  tilt: \"Tilt the panel. Roll the marble. Don't overshoot.\",\n} satisfies Record<VolumeVariant, string>;\n\n/** Every control takes the volume and nothing else, so the shell can pick one by\n * name. Keyed by variant rather than branched on it: adding a sixth control makes\n * this table and HINTS fail to compile until both know about it. */\nconst TRACKS = {\n  cannon: CannonTrack,\n  curling: CurlingTrack,\n  dots: DotsTrack,\n  \"plus-minus\": PlusMinusTrack,\n  tilt: TiltTrack,\n} satisfies Record<VolumeVariant, (props: { volume: MotionValue<number> }) => ReactNode>;\n\n/** Each bar is named, so the set can be walked without leaning on its index. */\nconst BARS = [\n  { height: 0.45, id: \"a\" },\n  { height: 0.8, id: \"b\" },\n  { height: 0.35, id: \"c\" },\n  { height: 1, id: \"d\" },\n  { height: 0.6, id: \"e\" },\n  { height: 0.9, id: \"f\" },\n  { height: 0.5, id: \"g\" },\n  { height: 0.75, id: \"h\" },\n  { height: 0.4, id: \"i\" },\n  { height: 0.85, id: \"j\" },\n  { height: 0.55, id: \"k\" },\n  { height: 0.7, id: \"l\" },\n];\n\nconst LevelBar = ({ height, volume }: { height: number; volume: MotionValue<number> }) => {\n  const scaleY = useTransform(volume, [0, 100], [0.06, height]);\n  return (\n    <motion.span\n      className=\"h-full w-2.5 origin-bottom rounded-full bg-gradient-to-t from-indigo-400/60 to-rose-300/60\"\n      style={{ scaleY }}\n    />\n  );\n};\n\n/** The only honest feedback in the whole component: bars that get taller when the\n * volume does. They're a readout, not a control — you cannot drag them either. */\nconst Levels = ({ volume }: { volume: MotionValue<number> }) => (\n  <div className=\"flex h-12 items-end gap-1.5\">\n    {BARS.map((bar) => (\n      <LevelBar key={bar.id} height={bar.height} volume={volume} />\n    ))}\n  </div>\n);\n\n/**\n * The app you were trying to turn down, sitting behind everything, bleeding off the\n * bottom-left of the frame. Pure scenery with one exception: its levels ride the\n * volume, so whatever the control up there finally decides, you see it land here.\n */\nconst NowPlayingWindow = ({ volume }: { volume: MotionValue<number> }) => (\n  <div\n    aria-hidden\n    className=\"absolute top-1/2 -left-20 w-[640px] -translate-y-1/2 rounded-3xl border border-white/[0.07] bg-neutral-900/60 p-7 shadow-2xl shadow-black/50 backdrop-blur-sm\"\n  >\n    <div className=\"mb-6 flex gap-2\">\n      <span className=\"size-3 rounded-full bg-neutral-700\" />\n      <span className=\"size-3 rounded-full bg-neutral-700\" />\n      <span className=\"size-3 rounded-full bg-neutral-700\" />\n    </div>\n\n    <div className=\"flex items-center gap-6\">\n      <div className=\"size-32 shrink-0 rounded-2xl bg-gradient-to-br from-indigo-500/40 to-rose-500/30\" />\n\n      <div className=\"min-w-0 flex-1\">\n        <p className=\"truncate text-[19px] font-medium text-neutral-200\">Everything Louder</p>\n        <p className=\"mb-5 truncate text-[15px] text-neutral-500\">\n          Motörhead — No Sleep &apos;til Hammersmith\n        </p>\n        <Levels volume={volume} />\n      </div>\n    </div>\n  </div>\n);\n\n/**\n * Five volume controls, none of which will let you simply set the volume.\n *\n * The staging is the same trick as the effort picker: we're zoomed into the top\n * corner of somebody's desktop, the menu bar bleeds off the frame, and the HUD\n * hangs off the speaker icon like it would in the real thing. The shell owns the\n * volume as a motion value so the menu bar can follow a rolling marble frame by\n * frame without re-rendering React — the controls write to it, the chrome reads\n * it, and neither knows anything about the other.\n */\nexport const VolumeControl = ({ variant = \"tilt\" }: VolumeControlProps) => {\n  const volume = useMotionValue(DEFAULT_VOLUME);\n  const [open, setOpen] = useState(true);\n\n  // Swapping controls would otherwise hand the next one a volume the last one\n  // earned, which reads as a bug rather than a joke. Everyone starts at 35.\n  const [renderedVariant, setRenderedVariant] = useState(variant);\n  if (renderedVariant !== variant) {\n    setRenderedVariant(variant);\n    volume.set(DEFAULT_VOLUME);\n  }\n\n  const Track = TRACKS[variant];\n\n  const hud = (\n    <AnimatePresence>\n      {open && (\n        <motion.div\n          key={variant}\n          initial={{ opacity: 0, scale: 0.96, y: -6 }}\n          animate={{ opacity: 1, scale: 1, y: 0 }}\n          exit={{ opacity: 0, scale: 0.96, y: -4 }}\n          transition={HUD_POP}\n          style={{ transformOrigin: \"top right\" }}\n          className=\"absolute top-full right-0 z-10 mt-2.5\"\n        >\n          <VolumeHud volume={volume} title=\"Output level\" hint={HINTS[variant]}>\n            <Track volume={volume} />\n          </VolumeHud>\n        </motion.div>\n      )}\n    </AnimatePresence>\n  );\n\n  return (\n    <div className=\"relative flex h-full w-full items-start justify-end overflow-hidden bg-neutral-950 text-neutral-100\">\n      {/* Somebody else's desktop, cropped: the wallpaper, the menu bar and the window\n          behind it all run wider than the frame. The zoom is intrinsic sizing, never a\n          transform — scaling this subtree would put the controls' pointer deltas (screen\n          px) out of step with their physics (local px), and the marble would outrun your\n          hand. */}\n      <div\n        aria-hidden\n        className=\"absolute inset-0 bg-gradient-to-br from-indigo-950/50 via-neutral-950 to-neutral-950\"\n      />\n\n      <NowPlayingWindow volume={volume} />\n\n      <div className=\"relative w-[calc(100%+220px)] shrink-0 px-6 pt-4\">\n        <MacosChrome\n          volume={volume}\n          open={open}\n          onToggle={() => setOpen((was) => !was)}\n          hud={hud}\n        />\n      </div>\n    </div>\n  );\n};\n","path":"/volume-control.tsx","target":"uicapsule/volume-control/volume-control.tsx","type":"registry:file"},{"content":"/**\n * The volume domain, shared by the HUD and all five controls.\n *\n * Volume is a number 0–100 and nothing else. Each control owns its own geometry\n * — a groove, a board, a sheet of ice — and converts to and from this domain at\n * its edges, so no control ever has to know what another one's pixels mean.\n */\n\nexport const VOLUME_MIN = 0;\nexport const VOLUME_MAX = 100;\nexport const DEFAULT_VOLUME = 35;\n\n/** Volume lands on multiples of this. Nothing here is precise enough to deserve\n * finer resolution, and a round number reads better in the readout. */\nexport const DETENT_STEP = 5;\n\nexport const clamp = (value: number, min: number, max: number) =>\n  Math.min(Math.max(value, min), max);\n\nexport const clampVolume = (value: number) => clamp(value, VOLUME_MIN, VOLUME_MAX);\n\nexport const snapVolume = (value: number) =>\n  clampVolume(Math.round(value / DETENT_STEP) * DETENT_STEP);\n\nexport type SpeakerTier = \"muted\" | \"low\" | \"mid\" | \"high\";\n\n/** Which speaker glyph the menu bar wears. */\nexport const speakerTier = (volume: number): SpeakerTier => {\n  if (volume <= 0) {\n    return \"muted\";\n  }\n  if (volume < 34) {\n    return \"low\";\n  }\n  if (volume < 67) {\n    return \"mid\";\n  }\n  return \"high\";\n};\n\n/** One detent per arrow key: right/up louder, left/down quieter, anything else nothing. */\nexport const arrowDelta = (key: string) => {\n  if (key === \"ArrowRight\" || key === \"ArrowUp\") {\n    return DETENT_STEP;\n  }\n  if (key === \"ArrowLeft\" || key === \"ArrowDown\") {\n    return -DETENT_STEP;\n  }\n  return 0;\n};\n","path":"/volume-scale.ts","target":"uicapsule/volume-control/volume-scale.ts","type":"registry:file"}],"homepage":"https://www.uicapsule.com/ui/volume-control","name":"volume-control","registryDependencies":[],"title":"Volume Control","type":"registry:block"}