{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "thruster-button",
  "type": "registry:ui",
  "title": "ThrusterButton",
  "description": "A magnetic button that rests on the floor of its container, then flies to the cursor anywhere inside it on compressed-air jets, trailing WebGL smoke, and lands where it is when the cursor leaves.",
  "files": [
    {
      "path": "components/evil-buttons/thruster-button.tsx",
      "type": "registry:ui",
      "target": "components/evil-buttons/thruster-button.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport {\n  motion,\n  useMotionValue,\n  useReducedMotion,\n  useSpring,\n  type MotionValue,\n} from \"motion/react\";\nimport { cn } from \"@/lib/utils\";\n\nexport interface ThrusterButtonProps\n  extends Omit<\n    React.ButtonHTMLAttributes<HTMLButtonElement>,\n    \"onDrag\" | \"onDragStart\" | \"onDragEnd\" | \"onAnimationStart\"\n  > {\n  /** Net thruster acceleration in px/s². Lower values drift, higher values snap. */\n  thrust?: number;\n  /** Maximum bank in degrees while the craft accelerates sideways. */\n  tilt?: number;\n  /** Classes for the playground, which fills the nearest positioned ancestor by default. */\n  playgroundClassName?: string;\n}\n\ntype Side = \"left\" | \"right\" | \"bottom\";\n\n// Autopilot gains: a slightly underdamped spring, so the craft overshoots the\n// pointer and fires its opposite jets to brake.\nconst KP = 90;\nconst KD = 2 * 0.55 * Math.sqrt(KP);\n// There are no top jets, so the craft descends by easing off the bottom ones.\nconst GRAVITY = 1100;\n// Jet visuals are scaled against this fixed force, not the `thrust` prop, so the\n// same push always looks the same: a stronger craft only blasts harder when it\n// actually accelerates harder.\nconst FULL_BLAST = 3200;\n// Valve chatter in px at full blast.\nconst RUMBLE = 0.35;\n// Nozzle length in px; gas leaves from its mouth.\nconst NOZZLE = 5;\n// Bottom pair sits this far in from the corners; side pairs this far from the middle.\nconst BOTTOM_INSET = 14;\nconst SIDE_SPREAD = 8;\n// Gap kept between the craft and the playground walls.\nconst WALL_GAP = 2;\n// How long the smoke keeps drifting after the craft docks.\nconst LINGER = 2.2;\n\n// Two valves per edge. Each is drawn pointing down, then rotated onto its edge.\nconst NOZZLES: {\n  side: Side;\n  left: string;\n  top: string;\n  rotate: number;\n}[] = [\n  { side: \"bottom\", left: `${BOTTOM_INSET}px`, top: \"100%\", rotate: 0 },\n  { side: \"bottom\", left: `calc(100% - ${BOTTOM_INSET}px)`, top: \"100%\", rotate: 0 },\n  { side: \"left\", left: \"0px\", top: `calc(50% - ${SIDE_SPREAD}px)`, rotate: 90 },\n  { side: \"left\", left: \"0px\", top: `calc(50% + ${SIDE_SPREAD}px)`, rotate: 90 },\n  { side: \"right\", left: \"100%\", top: `calc(50% - ${SIDE_SPREAD}px)`, rotate: -90 },\n  { side: \"right\", left: \"100%\", top: `calc(50% + ${SIDE_SPREAD}px)`, rotate: -90 },\n];\n\n/** Resolves any CSS color (oklch included) to 0–1 RGB. */\nfunction toRgb(color: string): [number, number, number] {\n  const probe = document.createElement(\"canvas\");\n  probe.width = probe.height = 1;\n  const ctx = probe.getContext(\"2d\");\n  if (!ctx) return [0.8, 0.8, 0.8];\n  ctx.fillStyle = color;\n  ctx.fillRect(0, 0, 1, 1);\n  const [r, g, b] = ctx.getImageData(0, 0, 1, 1).data;\n  return [r / 255, g / 255, b / 255];\n}\n\n/* ------------------------------------------------------------------------ */\n/* Smoke: a small GPU stable-fluids solver (advect, project, confine curl). */\n/* ------------------------------------------------------------------------ */\n\nconst MAX_JETS = 6;\nconst SIM_RES = 128;\nconst DYE_RES = 384;\nconst PRESSURE_ITERATIONS = 20;\nconst VORTICITY = 25;\nconst VELOCITY_DISSIPATION = 0.6;\nconst DYE_DISSIPATION = 1.5;\n\nconst VERTEX = `#version 300 es\nin vec2 aPosition;\nout vec2 vUv, vL, vR, vT, vB;\nuniform vec2 texelSize;\nvoid main() {\n  vUv = aPosition * 0.5 + 0.5;\n  vL = vUv - vec2(texelSize.x, 0.0);\n  vR = vUv + vec2(texelSize.x, 0.0);\n  vT = vUv + vec2(0.0, texelSize.y);\n  vB = vUv - vec2(0.0, texelSize.y);\n  gl_Position = vec4(aPosition, 0.0, 1.0);\n}`;\n\nconst HEADER = `#version 300 es\nprecision highp float;\nprecision highp sampler2D;\nin vec2 vUv, vL, vR, vT, vB;\nout vec4 fragColor;\n`;\n\nconst SHADERS = {\n  // Elongated gaussians stretched along each jet's direction. Velocity is\n  // pinned to the jet's (an inflow), while smoke density accumulates.\n  splat: `${HEADER}\nuniform sampler2D uTarget;\nuniform float aspectRatio;\nuniform float radius;\nuniform bool pin;\nuniform int count;\nuniform vec2 points[${MAX_JETS}];\nuniform vec2 dirs[${MAX_JETS}];\nuniform vec3 values[${MAX_JETS}];\nvoid main() {\n  vec3 base = texture(uTarget, vUv).xyz;\n  for (int i = 0; i < ${MAX_JETS}; i++) {\n    if (i >= count) break;\n    vec2 p = vUv - points[i];\n    p.x *= aspectRatio;\n    float along = dot(p, dirs[i]);\n    float across = dot(p, vec2(-dirs[i].y, dirs[i].x));\n    float g = exp(-(along * along * 0.3 + across * across) / radius);\n    base = pin ? mix(base, values[i], min(g, 1.0)) : base + values[i] * g;\n  }\n  fragColor = vec4(base, 1.0);\n}`,\n  advection: `${HEADER}\nuniform sampler2D uVelocity;\nuniform sampler2D uSource;\nuniform vec2 velocityTexel;\nuniform float dt;\nuniform float dissipation;\nvoid main() {\n  vec2 coord = vUv - dt * texture(uVelocity, vUv).xy * velocityTexel;\n  fragColor = texture(uSource, coord) / (1.0 + dissipation * dt);\n}`,\n  divergence: `${HEADER}\nuniform sampler2D uVelocity;\nvoid main() {\n  float L = texture(uVelocity, vL).x;\n  float R = texture(uVelocity, vR).x;\n  float T = texture(uVelocity, vT).y;\n  float B = texture(uVelocity, vB).y;\n  vec2 C = texture(uVelocity, vUv).xy;\n  if (vL.x < 0.0) L = -C.x;\n  if (vR.x > 1.0) R = -C.x;\n  if (vT.y > 1.0) T = -C.y;\n  if (vB.y < 0.0) B = -C.y;\n  fragColor = vec4(0.5 * (R - L + T - B), 0.0, 0.0, 1.0);\n}`,\n  curl: `${HEADER}\nuniform sampler2D uVelocity;\nvoid main() {\n  float L = texture(uVelocity, vL).y;\n  float R = texture(uVelocity, vR).y;\n  float T = texture(uVelocity, vT).x;\n  float B = texture(uVelocity, vB).x;\n  fragColor = vec4(0.5 * (R - L - T + B), 0.0, 0.0, 1.0);\n}`,\n  // Vorticity confinement feeds the small eddies back so the smoke keeps curling.\n  vorticity: `${HEADER}\nuniform sampler2D uVelocity;\nuniform sampler2D uCurl;\nuniform float curl;\nuniform float dt;\nvoid main() {\n  float L = texture(uCurl, vL).x;\n  float R = texture(uCurl, vR).x;\n  float T = texture(uCurl, vT).x;\n  float B = texture(uCurl, vB).x;\n  float C = texture(uCurl, vUv).x;\n  vec2 force = 0.5 * vec2(abs(T) - abs(B), abs(R) - abs(L));\n  force /= length(force) + 0.0001;\n  force *= curl * C;\n  force.y *= -1.0;\n  vec2 velocity = texture(uVelocity, vUv).xy + force * dt;\n  fragColor = vec4(clamp(velocity, -1000.0, 1000.0), 0.0, 1.0);\n}`,\n  pressure: `${HEADER}\nuniform sampler2D uPressure;\nuniform sampler2D uDivergence;\nvoid main() {\n  float L = texture(uPressure, vL).x;\n  float R = texture(uPressure, vR).x;\n  float T = texture(uPressure, vT).x;\n  float B = texture(uPressure, vB).x;\n  float divergence = texture(uDivergence, vUv).x;\n  fragColor = vec4((L + R + B + T - divergence) * 0.25, 0.0, 0.0, 1.0);\n}`,\n  gradientSubtract: `${HEADER}\nuniform sampler2D uPressure;\nuniform sampler2D uVelocity;\nvoid main() {\n  float L = texture(uPressure, vL).x;\n  float R = texture(uPressure, vR).x;\n  float T = texture(uPressure, vT).x;\n  float B = texture(uPressure, vB).x;\n  vec2 velocity = texture(uVelocity, vUv).xy - vec2(R - L, T - B);\n  fragColor = vec4(velocity, 0.0, 1.0);\n}`,\n  scale: `${HEADER}\nuniform sampler2D uTexture;\nuniform float value;\nvoid main() {\n  fragColor = value * texture(uTexture, vUv);\n}`,\n  // Density becomes flat, soft smoke in the tint color.\n  display: `${HEADER}\nuniform sampler2D uTexture;\nuniform vec3 tint;\nuniform float opacity;\nvoid main() {\n  float alpha = (1.0 - exp(-texture(uTexture, vUv).r * 1.8)) * opacity;\n  fragColor = vec4(tint * alpha, alpha);\n}`,\n} as const;\n\ntype ShaderName = keyof typeof SHADERS;\n\ntype Target = {\n  texture: WebGLTexture;\n  fbo: WebGLFramebuffer;\n  width: number;\n  height: number;\n};\n\ntype DoubleTarget = { read: Target; write: Target; swap: () => void };\n\ntype Jet = {\n  /** Mouth position in canvas UV, y up. */\n  x: number;\n  y: number;\n  /** Unit direction in aspect-corrected space, y up. */\n  dx: number;\n  dy: number;\n  /** Jet speed in px/s. */\n  speed: number;\n  /** Smoke emitted this frame. */\n  density: number;\n};\n\nfunction createSmoke(canvas: HTMLCanvasElement) {\n  const gl = canvas.getContext(\"webgl2\", {\n    alpha: true,\n    premultipliedAlpha: true,\n    antialias: false,\n    depth: false,\n    stencil: false,\n    preserveDrawingBuffer: false,\n  });\n  if (!gl || !gl.getExtension(\"EXT_color_buffer_float\")) return null;\n\n  const compile = (type: number, source: string) => {\n    const shader = gl.createShader(type)!;\n    gl.shaderSource(shader, source);\n    gl.compileShader(shader);\n    return shader;\n  };\n  const vertex = compile(gl.VERTEX_SHADER, VERTEX);\n\n  const programs = {} as Record<\n    ShaderName,\n    { program: WebGLProgram; uniform: (name: string) => WebGLUniformLocation | null }\n  >;\n  for (const name of Object.keys(SHADERS) as ShaderName[]) {\n    const program = gl.createProgram()!;\n    gl.attachShader(program, vertex);\n    gl.attachShader(program, compile(gl.FRAGMENT_SHADER, SHADERS[name]));\n    gl.bindAttribLocation(program, 0, \"aPosition\");\n    gl.linkProgram(program);\n    if (!gl.getProgramParameter(program, gl.LINK_STATUS)) return null;\n    const cache = new Map<string, WebGLUniformLocation | null>();\n    programs[name] = {\n      program,\n      uniform: (uniform) => {\n        if (!cache.has(uniform)) cache.set(uniform, gl.getUniformLocation(program, uniform));\n        return cache.get(uniform)!;\n      },\n    };\n  }\n\n  const quad = gl.createBuffer();\n  gl.bindBuffer(gl.ARRAY_BUFFER, quad);\n  gl.bufferData(\n    gl.ARRAY_BUFFER,\n    new Float32Array([-1, -1, 1, -1, -1, 1, -1, 1, 1, -1, 1, 1]),\n    gl.STATIC_DRAW,\n  );\n  gl.enableVertexAttribArray(0);\n  gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0);\n\n  const targets: Target[] = [];\n  const createTarget = (width: number, height: number): Target => {\n    const texture = gl.createTexture()!;\n    gl.bindTexture(gl.TEXTURE_2D, texture);\n    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);\n    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);\n    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);\n    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);\n    gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA16F, width, height, 0, gl.RGBA, gl.HALF_FLOAT, null);\n    const fbo = gl.createFramebuffer()!;\n    gl.bindFramebuffer(gl.FRAMEBUFFER, fbo);\n    gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, texture, 0);\n    gl.clearColor(0, 0, 0, 0);\n    gl.clear(gl.COLOR_BUFFER_BIT);\n    const target = { texture, fbo, width, height };\n    targets.push(target);\n    return target;\n  };\n  const createDouble = (width: number, height: number): DoubleTarget => {\n    const pair = {\n      read: createTarget(width, height),\n      write: createTarget(width, height),\n      swap: () => {\n        [pair.read, pair.write] = [pair.write, pair.read];\n      },\n    };\n    return pair;\n  };\n\n  let velocity: DoubleTarget;\n  let dye: DoubleTarget;\n  let pressure: DoubleTarget;\n  let divergence: Target;\n  let curl: Target;\n  let aspect = 1;\n\n  const program = (name: ShaderName) => {\n    const entry = programs[name];\n    gl.useProgram(entry.program);\n    return entry.uniform;\n  };\n  const bindTexture = (\n    uniform: (name: string) => WebGLUniformLocation | null,\n    name: string,\n    target: Target,\n    unit: number,\n  ) => {\n    gl.activeTexture(gl.TEXTURE0 + unit);\n    gl.bindTexture(gl.TEXTURE_2D, target.texture);\n    gl.uniform1i(uniform(name), unit);\n  };\n  const draw = (\n    uniform: (name: string) => WebGLUniformLocation | null,\n    target: Target | null,\n    texel: Target,\n  ) => {\n    gl.uniform2f(uniform(\"texelSize\"), 1 / texel.width, 1 / texel.height);\n    gl.bindFramebuffer(gl.FRAMEBUFFER, target ? target.fbo : null);\n    gl.viewport(0, 0, target ? target.width : gl.drawingBufferWidth, target ? target.height : gl.drawingBufferHeight);\n    gl.drawArrays(gl.TRIANGLES, 0, 6);\n  };\n\n  const gridFor = (base: number) =>\n    aspect >= 1\n      ? [Math.round(base * aspect), base]\n      : [base, Math.round(base / aspect)];\n\n  const resize = (width: number, height: number) => {\n    for (const target of targets.splice(0)) {\n      gl.deleteTexture(target.texture);\n      gl.deleteFramebuffer(target.fbo);\n    }\n    aspect = width / height;\n    const [simW, simH] = gridFor(SIM_RES);\n    const [dyeW, dyeH] = gridFor(DYE_RES);\n    velocity = createDouble(simW, simH);\n    pressure = createDouble(simW, simH);\n    divergence = createTarget(simW, simH);\n    curl = createTarget(simW, simH);\n    dye = createDouble(dyeW, dyeH);\n  };\n\n  const splat = (\n    field: DoubleTarget,\n    jets: Jet[],\n    radius: number,\n    pin: boolean,\n    value: (jet: Jet) => number[],\n  ) => {\n    const u = program(\"splat\");\n    bindTexture(u, \"uTarget\", field.read, 0);\n    gl.uniform1i(u(\"pin\"), pin ? 1 : 0);\n    gl.uniform1f(u(\"aspectRatio\"), aspect);\n    gl.uniform1f(u(\"radius\"), radius);\n    gl.uniform1i(u(\"count\"), jets.length);\n    gl.uniform2fv(u(\"points\"), jets.flatMap((jet) => [jet.x, jet.y]));\n    gl.uniform2fv(u(\"dirs\"), jets.flatMap((jet) => [jet.dx, jet.dy]));\n    gl.uniform3fv(u(\"values\"), jets.flatMap(value));\n    draw(u, field.write, field.read);\n    field.swap();\n  };\n\n  return {\n    resize,\n    clear() {\n      for (const target of targets) {\n        gl.bindFramebuffer(gl.FRAMEBUFFER, target.fbo);\n        gl.clearColor(0, 0, 0, 0);\n        gl.clear(gl.COLOR_BUFFER_BIT);\n      }\n    },\n    /** `cssWidth`/`cssHeight` map jet speeds in px/s onto the grid. */\n    step(dt: number, jets: Jet[], jetRadiusPx: number, cssWidth: number, cssHeight: number) {\n      gl.disable(gl.BLEND);\n\n      if (jets.length) {\n        const radius = (jetRadiusPx / cssHeight) ** 2;\n        const pxToGridX = velocity.read.width / cssWidth;\n        const pxToGridY = velocity.read.height / cssHeight;\n        splat(velocity, jets, radius, true, (jet) => [\n          jet.dx * jet.speed * pxToGridX,\n          jet.dy * jet.speed * pxToGridY,\n          0,\n        ]);\n        splat(dye, jets, radius, false, (jet) => [jet.density, jet.density, jet.density]);\n      }\n\n      let u = program(\"curl\");\n      bindTexture(u, \"uVelocity\", velocity.read, 0);\n      draw(u, curl, velocity.read);\n\n      u = program(\"vorticity\");\n      bindTexture(u, \"uVelocity\", velocity.read, 0);\n      bindTexture(u, \"uCurl\", curl, 1);\n      gl.uniform1f(u(\"curl\"), VORTICITY);\n      gl.uniform1f(u(\"dt\"), dt);\n      draw(u, velocity.write, velocity.read);\n      velocity.swap();\n\n      u = program(\"divergence\");\n      bindTexture(u, \"uVelocity\", velocity.read, 0);\n      draw(u, divergence, velocity.read);\n\n      u = program(\"scale\");\n      bindTexture(u, \"uTexture\", pressure.read, 0);\n      gl.uniform1f(u(\"value\"), 0.8);\n      draw(u, pressure.write, pressure.read);\n      pressure.swap();\n\n      u = program(\"pressure\");\n      bindTexture(u, \"uDivergence\", divergence, 1);\n      for (let i = 0; i < PRESSURE_ITERATIONS; i++) {\n        bindTexture(u, \"uPressure\", pressure.read, 0);\n        draw(u, pressure.write, pressure.read);\n        pressure.swap();\n      }\n\n      u = program(\"gradientSubtract\");\n      bindTexture(u, \"uPressure\", pressure.read, 0);\n      bindTexture(u, \"uVelocity\", velocity.read, 1);\n      draw(u, velocity.write, velocity.read);\n      velocity.swap();\n\n      u = program(\"advection\");\n      gl.uniform2f(u(\"velocityTexel\"), 1 / velocity.read.width, 1 / velocity.read.height);\n      gl.uniform1f(u(\"dt\"), dt);\n      bindTexture(u, \"uVelocity\", velocity.read, 0);\n      bindTexture(u, \"uSource\", velocity.read, 1);\n      gl.uniform1f(u(\"dissipation\"), VELOCITY_DISSIPATION);\n      draw(u, velocity.write, velocity.read);\n      velocity.swap();\n\n      bindTexture(u, \"uVelocity\", velocity.read, 0);\n      bindTexture(u, \"uSource\", dye.read, 1);\n      gl.uniform1f(u(\"dissipation\"), DYE_DISSIPATION);\n      draw(u, dye.write, dye.read);\n      dye.swap();\n    },\n    render(tint: [number, number, number], opacity: number) {\n      gl.bindFramebuffer(gl.FRAMEBUFFER, null);\n      gl.viewport(0, 0, gl.drawingBufferWidth, gl.drawingBufferHeight);\n      gl.clearColor(0, 0, 0, 0);\n      gl.clear(gl.COLOR_BUFFER_BIT);\n      const u = program(\"display\");\n      bindTexture(u, \"uTexture\", dye.read, 0);\n      gl.uniform3f(u(\"tint\"), ...tint);\n      gl.uniform1f(u(\"opacity\"), opacity);\n      draw(u, null, dye.read);\n    },\n    // Frees GPU memory but keeps the context alive: the same canvas can be set\n    // up again (an effect re-run, or Strict Mode's double mount), and a lost\n    // context would come back dead.\n    dispose() {\n      for (const target of targets.splice(0)) {\n        gl.deleteTexture(target.texture);\n        gl.deleteFramebuffer(target.fbo);\n      }\n      for (const { program } of Object.values(programs)) gl.deleteProgram(program);\n      gl.deleteShader(vertex);\n      gl.deleteBuffer(quad);\n    },\n  };\n}\n\ntype Smoke = NonNullable<ReturnType<typeof createSmoke>>;\n\n/* ------------------------------------------------------------------------ */\n\nfunction Nozzle({\n  left,\n  top,\n  rotate,\n  power,\n}: {\n  left: string;\n  top: string;\n  rotate: number;\n  power: MotionValue<number>;\n}) {\n  return (\n    <span\n      aria-hidden\n      className=\"pointer-events-none absolute size-0\"\n      style={{ left, top, rotate: `${rotate}deg` }}\n    >\n      {/* Valve body */}\n      <span className=\"absolute top-0 left-0 h-[5px] w-2 -translate-x-1/2 rounded-b-[2px] border border-t-0 border-border bg-muted shadow-[inset_0_-1px_0_rgb(0_0_0/0.12)] dark:shadow-[inset_0_-1px_0_rgb(0_0_0/0.5)]\" />\n      {/* Jet core: the narrow, fast stream right at the mouth */}\n      <motion.span\n        className=\"absolute top-[5px] left-0 h-4 w-[3px] -translate-x-1/2 origin-top rounded-full bg-[linear-gradient(to_bottom,rgb(148_163_184/0.6),rgb(148_163_184/0))] blur-[0.5px] dark:bg-[linear-gradient(to_bottom,rgb(226_232_240/0.65),rgb(226_232_240/0))]\"\n        style={{ scaleY: power, opacity: power }}\n      />\n    </span>\n  );\n}\n\nconst ThrusterButton = React.forwardRef<HTMLButtonElement, ThrusterButtonProps>(\n  (\n    {\n      children,\n      className,\n      thrust = 3200,\n      tilt = 10,\n      playgroundClassName,\n      disabled,\n      type = \"button\",\n      ...props\n    },\n    ref,\n  ) => {\n    const playgroundRef = React.useRef<HTMLDivElement>(null);\n    const craftRef = React.useRef<HTMLSpanElement>(null);\n    const canvasRef = React.useRef<HTMLCanvasElement>(null);\n    const reduceMotion = useReducedMotion();\n\n    // Offset from the landing pad at the bottom middle; y is negative in the air.\n    const x = useMotionValue(0);\n    const y = useMotionValue(0);\n    const bank = useMotionValue(0);\n    const rotate = useSpring(bank, { stiffness: 220, damping: 18 });\n    const jetLeft = useMotionValue(0);\n    const jetRight = useMotionValue(0);\n    const jetBottom = useMotionValue(0);\n\n    // Read live by the flight loop, so tuning these mid-flight doesn't restart it.\n    const settings = React.useRef({ thrust, tilt });\n    React.useEffect(() => {\n      settings.current = { thrust, tilt };\n    }, [thrust, tilt]);\n\n    React.useEffect(() => {\n      const playground = playgroundRef.current;\n      const craft = craftRef.current;\n      if (!playground || !craft || reduceMotion || disabled) return;\n\n      const pos = { x: x.get(), y: 0 };\n      const vel = { x: 0, y: 0 };\n      const target = { x: 0, y: 0 };\n      let mode: \"landed\" | \"flying\" | \"landing\" = \"landed\";\n      let frame = 0;\n      let last = 0;\n      let lingering = 0;\n      // Set on landing so the next frame blasts a cloud along the floor.\n      let touchdown = false;\n      // undefined: not tried yet; null: no WebGL2, so the craft flies without smoke.\n      let smoke: Smoke | null | undefined;\n      let tint: [number, number, number] = [0.8, 0.8, 0.8];\n      let opacity = 0.6;\n      let width = 0;\n      let height = 0;\n      let simW = 0;\n      let simH = 0;\n      let halfW = 0;\n      let halfH = 0;\n\n      // How far the craft can travel from its pad before touching a wall.\n      const bounds = () => {\n        const reachX = Math.max(0, width / 2 - halfW - NOZZLE - WALL_GAP);\n        const ceiling = Math.min(0, -(height - NOZZLE - halfH * 2 - WALL_GAP));\n        return { reachX, ceiling };\n      };\n\n      const measure = () => {\n        width = playground.clientWidth;\n        height = playground.clientHeight;\n        halfW = craft.offsetWidth / 2;\n        halfH = craft.offsetHeight / 2;\n      };\n\n      const prepareSmoke = () => {\n        const canvas = canvasRef.current;\n        if (!canvas) return;\n        if (smoke === undefined) smoke = createSmoke(canvas);\n        if (!smoke) return;\n        if (width !== simW || height !== simH) {\n          simW = width;\n          simH = height;\n          const dpr = Math.min(window.devicePixelRatio || 1, 1.5);\n          canvas.width = Math.max(1, Math.round(width * dpr));\n          canvas.height = Math.max(1, Math.round(height * dpr));\n          smoke.resize(Math.max(width, 1), Math.max(height, 1));\n        }\n        // The smoke tint follows the theme through the canvas's text color.\n        const style = getComputedStyle(canvas);\n        tint = toRgb(style.color);\n        opacity = Number.parseFloat(style.getPropertyValue(\"--smoke-opacity\")) || 0.6;\n      };\n\n      const jetPower = (force: number, max: number) => {\n        if (force <= max * 0.03) return 0;\n        return Math.min(1, force / max) ** 0.7;\n      };\n\n      const cutJets = () => {\n        jetLeft.set(0);\n        jetRight.set(0);\n        jetBottom.set(0);\n        bank.set(0);\n      };\n\n      const step = (now: number) => {\n        const dt = last ? Math.min((now - last) / 1000, 1 / 30) : 1 / 60;\n        last = now;\n        const jets: Jet[] = [];\n        const sideThrust = Math.max(settings.current.thrust, 1);\n        // The bottom jets also carry the craft's weight.\n        const liftThrust = sideThrust + GRAVITY;\n        if (touchdown && smoke) {\n          touchdown = false;\n          // Trapped air bursts out sideways from under the craft as it settles.\n          const floorY = 1 - (height - 1) / height;\n          for (const side of [-1, 1]) {\n            for (const reach of [0.4, 1]) {\n              jets.push({\n                x: (width / 2 + pos.x + side * halfW * reach) / width,\n                y: floorY,\n                dx: side,\n                dy: 0.15,\n                speed: 900,\n                density: 0.8,\n              });\n            }\n          }\n        }\n\n        if (mode !== \"landed\") {\n          const { reachX, ceiling } = bounds();\n          const wantX = KP * (target.x - pos.x) - KD * vel.x;\n          let wantY = KP * (target.y - pos.y) - KD * vel.y;\n          // Only gravity can stop a climb, so cut lift once the craft would\n          // need all of it to stop in the height that's left.\n          if (vel.y < 0) {\n            const stopping = (vel.y * vel.y) / (2 * GRAVITY * 0.7);\n            if (stopping >= pos.y - target.y) wantY = Math.max(wantY, GRAVITY * 0.7);\n          }\n          const sideForce = Math.max(-sideThrust, Math.min(sideThrust, wantX));\n          // Thrusting up is negative y; the jets can only push, never pull.\n          const liftForce = Math.max(0, Math.min(liftThrust, GRAVITY - wantY));\n\n          vel.x += sideForce * dt;\n          vel.y += (GRAVITY - liftForce) * dt;\n          pos.x += vel.x * dt;\n          pos.y += vel.y * dt;\n\n          // Walls and ceiling knock it back; the floor soaks up most of the hit.\n          if (Math.abs(pos.x) > reachX) {\n            pos.x = Math.sign(pos.x) * reachX;\n            vel.x *= -0.35;\n          }\n          if (pos.y < ceiling) {\n            pos.y = ceiling;\n            vel.y *= -0.35;\n          }\n          if (pos.y > 0) {\n            pos.y = 0;\n            if (vel.y > 0) vel.y *= -0.2;\n            vel.x *= 0.85;\n          }\n\n          if (mode === \"landing\" && pos.y > -0.5 && Math.hypot(vel.x, vel.y) < 8) {\n            // Touched down: valves shut and the smoke is left to drift.\n            mode = \"landed\";\n            lingering = LINGER;\n            touchdown = true;\n            pos.y = 0;\n            vel.x = vel.y = 0;\n            x.set(pos.x);\n            y.set(0);\n            cutJets();\n          } else {\n            // Pushing right means the left jets fire, and so on.\n            const power: Record<Side, number> = {\n              left: jetPower(sideForce, FULL_BLAST),\n              right: jetPower(-sideForce, FULL_BLAST),\n              bottom: jetPower(liftForce, FULL_BLAST + GRAVITY),\n            };\n            jetLeft.set(power.left);\n            jetRight.set(power.right);\n            jetBottom.set(power.bottom);\n            bank.set(Math.max(-1, Math.min(1, sideForce / FULL_BLAST)) * settings.current.tilt);\n\n            const blast = Math.max(power.left, power.right, power.bottom) * RUMBLE;\n            x.set(pos.x + (Math.random() - 0.5) * blast);\n            y.set(Math.min(0, pos.y + (Math.random() - 0.5) * blast));\n\n            if (smoke) {\n              // Craft center in playground pixels, and its current bank.\n              const centerX = width / 2 + pos.x;\n              const centerY = height - NOZZLE - halfH + pos.y;\n              const angle = (rotate.get() * Math.PI) / 180;\n              const cos = Math.cos(angle);\n              const sin = Math.sin(angle);\n              const mouths: [Side, number, number, number, number][] = [\n                [\"bottom\", -halfW + BOTTOM_INSET, halfH + NOZZLE, 0, 1],\n                [\"bottom\", halfW - BOTTOM_INSET, halfH + NOZZLE, 0, 1],\n                [\"left\", -halfW - NOZZLE, -SIDE_SPREAD, -1, 0],\n                [\"left\", -halfW - NOZZLE, SIDE_SPREAD, -1, 0],\n                [\"right\", halfW + NOZZLE, -SIDE_SPREAD, 1, 0],\n                [\"right\", halfW + NOZZLE, SIDE_SPREAD, 1, 0],\n              ];\n              for (const [side, mx, my, dx, dy] of mouths) {\n                const p = power[side];\n                if (p <= 0) continue;\n                const ex = dx * cos - dy * sin;\n                const ey = dx * sin + dy * cos;\n                // Valves sputter a little, so paired jets never look identical.\n                const sputter = 0.75 + Math.random() * 0.5;\n                jets.push({\n                  x: (centerX + mx * cos - my * sin) / width,\n                  y: 1 - (centerY + mx * sin + my * cos) / height,\n                  dx: ex,\n                  dy: -ey,\n                  speed: (380 + 520 * p) * sputter,\n                  density: p * 0.3 * sputter * dt * 60,\n                });\n              }\n            }\n          }\n        } else {\n          lingering -= dt;\n        }\n\n        if (smoke) {\n          smoke.step(dt, jets, 3, width, height);\n          // Ease the last of the smoke out before the loop stops and clears it.\n          const fade = mode === \"landed\" ? Math.min(1, lingering / 1.2) : 1;\n          smoke.render(tint, opacity * Math.max(0, fade));\n        }\n\n        if (mode === \"landed\" && lingering <= 0) {\n          smoke?.clear();\n          smoke?.render(tint, opacity);\n          frame = 0;\n          last = 0;\n          return;\n        }\n        frame = requestAnimationFrame(step);\n      };\n\n      const ignite = () => {\n        if (frame) return;\n        measure();\n        prepareSmoke();\n        frame = requestAnimationFrame(step);\n      };\n\n      const land = () => {\n        if (mode !== \"flying\") return;\n        // Come straight down from wherever it is.\n        mode = \"landing\";\n        target.x = pos.x;\n        target.y = 0;\n        ignite();\n      };\n\n      const onPointerMove = (event: PointerEvent) => {\n        const rect = playground.getBoundingClientRect();\n        measure();\n        // Back out any ancestor scale so the pointer lands in layout pixels.\n        const px = (event.clientX - rect.left) * (width / (rect.width || 1));\n        const py = (event.clientY - rect.top) * (height / (rect.height || 1));\n        if (px < 0 || py < 0 || px > width || py > height) {\n          land();\n          return;\n        }\n\n        const { reachX, ceiling } = bounds();\n        // Fly the craft's center onto the pointer, as far as the walls allow.\n        target.x = Math.max(-reachX, Math.min(reachX, px - width / 2));\n        target.y = Math.max(ceiling, Math.min(0, py - (height - NOZZLE - halfH)));\n        mode = \"flying\";\n        ignite();\n      };\n\n      const onPointerEnd = (event: PointerEvent) => {\n        if (event.pointerType === \"touch\") land();\n      };\n\n      // Keep a landed craft inside the playground when it resizes.\n      const observer = new ResizeObserver(() => {\n        measure();\n        const { reachX } = bounds();\n        if (Math.abs(pos.x) > reachX) {\n          pos.x = Math.sign(pos.x) * reachX;\n          if (mode === \"landed\") x.set(pos.x);\n        }\n      });\n      observer.observe(playground);\n\n      window.addEventListener(\"pointermove\", onPointerMove, { passive: true });\n      window.addEventListener(\"pointerdown\", onPointerMove, { passive: true });\n      window.addEventListener(\"pointerup\", onPointerEnd);\n      window.addEventListener(\"pointercancel\", onPointerEnd);\n      document.documentElement.addEventListener(\"pointerleave\", land);\n      window.addEventListener(\"blur\", land);\n      return () => {\n        observer.disconnect();\n        window.removeEventListener(\"pointermove\", onPointerMove);\n        window.removeEventListener(\"pointerdown\", onPointerMove);\n        window.removeEventListener(\"pointerup\", onPointerEnd);\n        window.removeEventListener(\"pointercancel\", onPointerEnd);\n        document.documentElement.removeEventListener(\"pointerleave\", land);\n        window.removeEventListener(\"blur\", land);\n        cancelAnimationFrame(frame);\n        smoke?.dispose();\n        x.set(pos.x);\n        y.set(0);\n        cutJets();\n      };\n    }, [\n      reduceMotion,\n      disabled,\n      x,\n      y,\n      bank,\n      rotate,\n      jetLeft,\n      jetRight,\n      jetBottom,\n    ]);\n\n    return (\n      <div\n        ref={playgroundRef}\n        className={cn(\n          \"pointer-events-none absolute inset-0 overflow-hidden\",\n          playgroundClassName,\n        )}\n      >\n        {/* Smoke fills the playground; its floor is the bottom edge. */}\n        <canvas\n          ref={canvasRef}\n          aria-hidden\n          width={0}\n          height={0}\n          className=\"absolute inset-0 size-full text-slate-400 [--smoke-opacity:0.6] dark:text-slate-200 dark:[--smoke-opacity:0.55]\"\n        />\n        <motion.span\n          ref={craftRef}\n          className=\"pointer-events-auto absolute left-1/2 inline-flex -translate-x-1/2\"\n          style={{ bottom: NOZZLE, x, y, rotate }}\n        >\n          {NOZZLES.map((nozzle, i) => (\n            <Nozzle\n              key={i}\n              left={nozzle.left}\n              top={nozzle.top}\n              rotate={nozzle.rotate}\n              power={\n                nozzle.side === \"left\"\n                  ? jetLeft\n                  : nozzle.side === \"right\"\n                    ? jetRight\n                    : jetBottom\n              }\n            />\n          ))}\n          <motion.button\n            ref={ref}\n            type={type}\n            disabled={disabled}\n            whileTap={disabled ? undefined : { scale: 0.95 }}\n            transition={{ type: \"spring\", stiffness: 400, damping: 22 }}\n            className={cn(\n              \"relative inline-flex h-9 cursor-pointer items-center justify-center gap-1.5 whitespace-nowrap rounded-md border border-border bg-background px-4 text-sm font-medium text-foreground shadow-[0_1px_2px_rgb(0_0_0/0.05),0_2px_6px_-3px_rgb(0_0_0/0.07),inset_0_-1px_0_rgb(0_0_0/0.03)] dark:shadow-[0_1px_2px_rgb(0_0_0/0.4),inset_0_1px_0_rgb(255_255_255/0.05)] outline-none select-none\",\n              \"focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50\",\n              className,\n            )}\n            {...props}\n          >\n            {children}\n          </motion.button>\n        </motion.span>\n      </div>\n    );\n  },\n);\n\nThrusterButton.displayName = \"ThrusterButton\";\n\nexport { ThrusterButton };\nexport default ThrusterButton;\n"
    }
  ],
  "dependencies": [
    "motion",
    "clsx",
    "tailwind-merge"
  ]
}
