{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"pull-cord-switch","type":"registry:component","title":"Pull cord switch","description":"Pull the ball chain to switch the site's lights; light pours from the bulb as a cone.","author":"Yash Bavadiya <https://xevrion.dev>","dependencies":[],"registryDependencies":["utils"],"files":[{"path":"components/pull-cord-switch.tsx","type":"registry:component","content":"\"use client\";\n\nimport { useEffect, useId, useRef, useState, useSyncExternalStore } from \"react\";\nimport { flushSync } from \"react-dom\";\nimport { useReducedMotion } from \"@/hooks/use-reduced-motion\";\nimport { cn } from \"@/lib/utils\";\n\n// The scene is drawn in a fixed 400 x 360 space, centred and cropped on\n// narrow screens, so pointer math never has to scale.\nconst W = 400;\nconst H = 360;\nconst LAMP_X = 200;\n// Where the chain leaves the switch in the socket, just beside the bulb.\nconst ANCHOR_X = 228;\nconst ANCHOR_Y = 154;\nconst POINTS = 8;\nconst SEG = 12;\nconst CHAIN = SEG * (POINTS - 1);\n// How far the switch's plunger can be drawn down, and where it clicks.\n// Clicking before the end of travel is what makes a real pull switch feel\n// like it gave way under your hand.\nconst TRAVEL = 18;\nconst CLICK = 12;\n// The plunger must come most of the way home before it can click again.\nconst REARM = 4;\nconst GRAVITY = 1800;\n// Velocity kept per substep: a hard swing looks settled in about two seconds.\nconst DRAG = 0.986;\nconst PLUNGER_K = 900;\nconst PLUNGER_D = 48;\nconst SUBSTEP = 1 / 120;\n// A tap or key press pulls this far past slack: past CLICK, short of TRAVEL.\nconst TAP_PULL = CLICK + 4;\nconst TAP_DOWN = 0.12;\nconst TAP_HOLD = 0.05;\n// Hit area for the knob, centred on it.\nconst HIT = 44;\n// Two sub-100ms flickers before the light holds, like a filament catching.\n// The theme waits this long so the room lights only once the bulb has.\nexport const FLICKER_MS = 200;\nconst FLICKER: Keyframe[] = [\n  { opacity: 0, offset: 0 },\n  { opacity: 0.9, offset: 0.08 },\n  { opacity: 0.1, offset: 0.3 },\n  { opacity: 1, offset: 0.42 },\n  { opacity: 0.2, offset: 0.62 },\n  { opacity: 1, offset: 0.78 },\n  { opacity: 1, offset: 1 },\n];\n\n// The lamp is a physical object and keeps its materials in both themes:\n// green enamel shade, braided flex, nickel chain, and warm incandescent\n// light, which is a colour of light rather than a theme colour.\nconst ENAMEL = \"oklch(0.34 0.06 165)\";\nconst ENAMEL_LIT = \"oklch(0.46 0.07 165)\";\nconst FLEX = \"oklch(0.35 0.01 60)\";\nconst NICKEL = \"oklch(0.64 0.01 80)\";\nconst WARM = \"oklch(0.93 0.1 85)\";\n\n// Where the bulb sits on screen at the moment of the pull, in viewport px.\nexport type BulbOrigin = { x: number; y: number; r: number };\n\ntype Sim = {\n  x: Float64Array;\n  y: Float64Array;\n  px: Float64Array;\n  py: Float64Array;\n  plunger: number;\n  plungerV: number;\n  clicked: boolean;\n};\n\ntype Hold =\n  | {\n      kind: \"pointer\";\n      id: number;\n      offX: number;\n      offY: number;\n      tx: number;\n      ty: number;\n      downX: number;\n      downY: number;\n      moved: boolean;\n      at: number;\n    }\n  | { kind: \"tap\"; t: number; fromX: number; fromY: number };\n\nfunction restSim(): Sim {\n  const x = new Float64Array(POINTS).fill(ANCHOR_X);\n  const y = Float64Array.from({ length: POINTS }, (_, i) => ANCHOR_Y + i * SEG);\n  return { x, y, px: x.slice(), py: y.slice(), plunger: 0, plungerV: 0, clicked: false };\n}\n\nfunction chainPath(s: Sim) {\n  let d = `M${s.x[0].toFixed(2)} ${s.y[0].toFixed(2)}`;\n  for (let i = 1; i < POINTS; i++) d += `L${s.x[i].toFixed(2)} ${s.y[i].toFixed(2)}`;\n  return d;\n}\n\nfunction knobTransform(s: Sim) {\n  const i = POINTS - 1;\n  const angle = (Math.atan2(s.x[i - 1] - s.x[i], s.y[i] - s.y[i - 1]) * 180) / Math.PI;\n  return `translate(${s.x[i] - HIT / 2}px, ${s.y[i] - 4}px) rotate(${angle}deg)`;\n}\n\nconst INITIAL = restSim();\nconst INITIAL_PATH = chainPath(INITIAL);\nconst INITIAL_KNOB = knobTransform(INITIAL);\n\nexport function PullCordSwitch({\n  checked,\n  onCheckedChange,\n  label,\n  className,\n}: {\n  checked: boolean;\n  onCheckedChange: (checked: boolean, bulb: BulbOrigin) => void;\n  label: string;\n  className?: string;\n}) {\n  const id = useId();\n  const reduceMotion = useReducedMotion();\n  const sceneRef = useRef<HTMLDivElement>(null);\n  const chainRef = useRef<SVGPathElement>(null);\n  const plungerRef = useRef<SVGLineElement>(null);\n  const knobRef = useRef<HTMLButtonElement>(null);\n  const glowRef = useRef<SVGGElement>(null);\n  const filamentRef = useRef<SVGCircleElement>(null);\n\n  const sim = useRef<Sim>(restSim());\n  const hold = useRef<Hold | null>(null);\n  const frame = useRef(0);\n  const last = useRef(0);\n  const carry = useRef(0);\n\n  // The loop outlives renders, so it reads the latest props through refs.\n  const checkedRef = useRef(checked);\n  const onChangeRef = useRef(onCheckedChange);\n  const reduceRef = useRef(reduceMotion);\n  useEffect(() => {\n    checkedRef.current = checked;\n    onChangeRef.current = onCheckedChange;\n    reduceRef.current = reduceMotion;\n  });\n\n  useEffect(() => () => cancelAnimationFrame(frame.current), []);\n\n  const toggle = () => {\n    const next = !checkedRef.current;\n    checkedRef.current = next;\n    if (next && !reduceRef.current) {\n      glowRef.current?.animate(FLICKER, { duration: FLICKER_MS, easing: \"linear\" });\n      filamentRef.current?.animate(FLICKER, { duration: FLICKER_MS, easing: \"linear\" });\n    }\n    const box = filamentRef.current!.getBoundingClientRect();\n    onChangeRef.current(next, { x: box.x + box.width / 2, y: box.y + box.height / 2, r: box.width / 2 });\n  };\n\n  const paint = () => {\n    const s = sim.current;\n    chainRef.current?.setAttribute(\"d\", chainPath(s));\n    plungerRef.current?.setAttribute(\"y2\", String(ANCHOR_Y + s.plunger));\n    if (knobRef.current) knobRef.current.style.transform = knobTransform(s);\n  };\n\n  const target = (h: Hold, dt: number): [number, number] => {\n    if (h.kind === \"pointer\") return [h.tx, h.ty];\n    h.t += dt;\n    const k = Math.min(h.t / TAP_DOWN, 1);\n    // Ease out, like a hand that yanks and then stops.\n    const e = 1 - (1 - k) ** 3;\n    const toY = ANCHOR_Y + CHAIN + TAP_PULL;\n    return [h.fromX + (ANCHOR_X - h.fromX) * e, h.fromY + (toY - h.fromY) * e];\n  };\n\n  const step = (dt: number) => {\n    const s = sim.current;\n    const h = hold.current;\n    let pinned: [number, number] | null = null;\n\n    if (h) {\n      let [tx, ty] = target(h, dt);\n      const dx = tx - ANCHOR_X;\n      const dy = ty - ANCHOR_Y;\n      const dist = Math.hypot(dx, dy);\n      // The chain can't stretch, so pulling past slack draws the plunger\n      // out instead, up to the end of its travel.\n      if (dist > CHAIN + TRAVEL) {\n        tx = ANCHOR_X + (dx / dist) * (CHAIN + TRAVEL);\n        ty = ANCHOR_Y + (dy / dist) * (CHAIN + TRAVEL);\n      }\n      const pull = Math.max(0, Math.min(dist - CHAIN, TRAVEL));\n      if (pull > s.plunger) {\n        s.plunger = pull;\n        s.plungerV = 0;\n      }\n      pinned = [tx, ty];\n      if (h.kind === \"tap\" && h.t >= TAP_DOWN + TAP_HOLD) hold.current = null;\n    }\n\n    // The plunger's return spring; while held it can still ease back.\n    s.plungerV += (-PLUNGER_K * s.plunger - PLUNGER_D * s.plungerV) * dt;\n    s.plunger = Math.max(0, s.plunger + s.plungerV * dt);\n\n    if (!s.clicked && s.plunger >= CLICK) {\n      s.clicked = true;\n      toggle();\n    } else if (s.clicked && s.plunger < REARM) {\n      s.clicked = false;\n    }\n\n    s.x[0] = ANCHOR_X;\n    s.y[0] = ANCHOR_Y + s.plunger;\n    for (let i = 1; i < POINTS; i++) {\n      const vx = (s.x[i] - s.px[i]) * DRAG;\n      const vy = (s.y[i] - s.py[i]) * DRAG;\n      s.px[i] = s.x[i];\n      s.py[i] = s.y[i];\n      s.x[i] += vx;\n      s.y[i] += vy + GRAVITY * dt * dt;\n    }\n    const end = POINTS - 1;\n    if (pinned) {\n      s.x[end] = pinned[0];\n      s.y[end] = pinned[1];\n    }\n\n    // Rope constraints: links can go slack but never stretch, like ball chain.\n    for (let iter = 0; iter < 12; iter++) {\n      for (let i = 0; i < end; i++) {\n        const dx = s.x[i + 1] - s.x[i];\n        const dy = s.y[i + 1] - s.y[i];\n        const d = Math.hypot(dx, dy);\n        if (d <= SEG || d === 0) continue;\n        const wa = i === 0 ? 0 : 1;\n        const wb = pinned && i + 1 === end ? 0 : 1;\n        if (wa + wb === 0) continue;\n        const f = (d - SEG) / d / (wa + wb);\n        s.x[i] += dx * f * wa;\n        s.y[i] += dy * f * wa;\n        s.x[i + 1] -= dx * f * wb;\n        s.y[i + 1] -= dy * f * wb;\n      }\n    }\n  };\n\n  const atRest = () => {\n    const s = sim.current;\n    if (hold.current || s.plunger > 0.05 || Math.abs(s.plungerV) > 0.05) return false;\n    // A swing is momentarily still at each extreme, so it must also hang\n    // plumb before the loop can sleep.\n    if (Math.abs(s.x[POINTS - 1] - ANCHOR_X) > 0.3) return false;\n    for (let i = 1; i < POINTS; i++) {\n      if (Math.abs(s.x[i] - s.px[i]) > 0.02 || Math.abs(s.y[i] - s.py[i]) > 0.02) return false;\n    }\n    return true;\n  };\n\n  const tick = (now: number) => {\n    // Capped so a backgrounded tab doesn't come back to a huge time step.\n    const elapsed = Math.min((now - last.current) / 1000, 1 / 20);\n    last.current = now;\n    carry.current += elapsed;\n    while (carry.current >= SUBSTEP) {\n      step(SUBSTEP);\n      carry.current -= SUBSTEP;\n    }\n    paint();\n    if (atRest()) {\n      frame.current = 0;\n      return;\n    }\n    frame.current = requestAnimationFrame(tick);\n  };\n\n  // Sleeps at rest; anything that disturbs the chain wakes it.\n  const wake = () => {\n    if (frame.current) return;\n    last.current = performance.now();\n    carry.current = 0;\n    frame.current = requestAnimationFrame(tick);\n  };\n\n  const pullOnce = () => {\n    if (reduceRef.current) {\n      toggle();\n      return;\n    }\n    const s = sim.current;\n    hold.current = { kind: \"tap\", t: 0, fromX: s.x[POINTS - 1], fromY: s.y[POINTS - 1] };\n    wake();\n  };\n\n  const local = (e: React.PointerEvent) => {\n    const box = sceneRef.current!.getBoundingClientRect();\n    return [e.clientX - box.left, e.clientY - box.top] as const;\n  };\n\n  const onPointerDown = (e: React.PointerEvent<HTMLButtonElement>) => {\n    if (e.button !== 0 || hold.current?.kind === \"pointer\") return;\n    e.currentTarget.setPointerCapture(e.pointerId);\n    const [x, y] = local(e);\n    const s = sim.current;\n    const end = POINTS - 1;\n    hold.current = {\n      kind: \"pointer\",\n      id: e.pointerId,\n      offX: x - s.x[end],\n      offY: y - s.y[end],\n      tx: s.x[end],\n      ty: s.y[end],\n      downX: x,\n      downY: y,\n      moved: false,\n      at: performance.now(),\n    };\n    wake();\n  };\n\n  const onPointerMove = (e: React.PointerEvent<HTMLButtonElement>) => {\n    const h = hold.current;\n    if (h?.kind !== \"pointer\" || e.pointerId !== h.id) return;\n    const [x, y] = local(e);\n    const tx = x - h.offX;\n    const ty = y - h.offY;\n    // Past a few px it's a drag; below that, a tap that pulls for you.\n    if (!h.moved) h.moved = Math.hypot(x - h.downX, y - h.downY) > 4;\n    h.tx = tx;\n    h.ty = ty;\n    wake();\n  };\n\n  const onPointerEnd = (e: React.PointerEvent<HTMLButtonElement>) => {\n    const h = hold.current;\n    if (h?.kind !== \"pointer\" || e.pointerId !== h.id) return;\n    hold.current = null;\n    // Letting go leaves the chain its own velocity, so it swings on from\n    // wherever the hand left it.\n    if (e.type === \"pointerup\" && !h.moved && performance.now() - h.at < 400) pullOnce();\n    else if (reduceRef.current) {\n      sim.current = restSim();\n      paint();\n    } else wake();\n  };\n\n  return (\n    <div className={cn(\"flex w-[400px] max-w-full flex-col gap-4\", className)}>\n      <div\n        className=\"relative h-[360px] w-full overflow-hidden rounded-3xl bg-muted shadow-raised [--glow:var(--background)] [--glow-strength:1] dark:[--glow:var(--foreground)] dark:[--glow-strength:0.14]\"\n      >\n        {/* The room dims with the lamp off. Switching off fades slower, the\n            way a filament cools rather than cutting out. */}\n        <div\n          aria-hidden\n          className={cn(\n            \"absolute inset-0 bg-foreground/[0.07] transition-opacity ease-out dark:bg-background/55\",\n            checked ? \"opacity-0 duration-150\" : \"opacity-100 duration-[350ms]\",\n          )}\n        />\n        <div ref={sceneRef} className=\"absolute top-0 left-1/2 h-full -translate-x-1/2\" style={{ width: W }}>\n          <svg viewBox={`0 0 ${W} ${H}`} width={W} height={H} className=\"absolute inset-0\" aria-hidden>\n            <defs>\n              <linearGradient id={`${id}-cone`} x1=\"0\" y1=\"0\" x2=\"0\" y2=\"1\">\n                <stop offset=\"0\" style={{ stopColor: \"var(--glow)\", stopOpacity: \"calc(var(--glow-strength) * 0.9)\" }} />\n                <stop offset=\"1\" style={{ stopColor: \"var(--glow)\", stopOpacity: 0 }} />\n              </linearGradient>\n              <radialGradient id={`${id}-pool`}>\n                <stop offset=\"0\" style={{ stopColor: \"var(--glow)\", stopOpacity: \"var(--glow-strength)\" }} />\n                <stop offset=\"1\" style={{ stopColor: \"var(--glow)\", stopOpacity: 0 }} />\n              </radialGradient>\n              <radialGradient id={`${id}-halo`}>\n                <stop offset=\"0.3\" stopColor={WARM} stopOpacity={0.85} />\n                <stop offset=\"1\" stopColor={WARM} stopOpacity={0} />\n              </radialGradient>\n              {/* Enamelled steel, lit from above. */}\n              <linearGradient id={`${id}-shade`} x1=\"0\" y1=\"0\" x2=\"0\" y2=\"1\">\n                <stop offset=\"0\" stopColor={ENAMEL_LIT} />\n                <stop offset=\"1\" stopColor={ENAMEL} />\n              </linearGradient>\n              <linearGradient id={`${id}-brass`} x1=\"0\" y1=\"0\" x2=\"1\" y2=\"0\">\n                <stop offset=\"0\" stopColor=\"oklch(0.62 0.09 75)\" />\n                <stop offset=\"0.45\" stopColor=\"oklch(0.86 0.1 85)\" />\n                <stop offset=\"1\" stopColor=\"oklch(0.58 0.09 70)\" />\n              </linearGradient>\n            </defs>\n\n            <g\n              ref={glowRef}\n              className={cn(\n                \"transition-opacity ease-out\",\n                checked ? \"opacity-100 duration-150\" : \"opacity-0 duration-[350ms]\",\n              )}\n            >\n              <path d={`M132 152L272 152L${W} ${H}L0 ${H}Z`} fill={`url(#${id}-cone)`} />\n              <ellipse cx={LAMP_X} cy={H - 14} rx={170} ry={26} fill={`url(#${id}-pool)`} />\n              <circle cx={LAMP_X} cy={160} r={46} fill={`url(#${id}-halo)`} />\n            </g>\n\n            {/* A ceiling rose, and the braided flex down to a brass cap. */}\n            <ellipse cx={LAMP_X} cy={0} rx={20} ry={7} fill={`url(#${id}-shade)`} />\n            <line x1={LAMP_X} y1={4} x2={LAMP_X} y2={90} strokeWidth={2} stroke={FLEX} />\n            <rect x={LAMP_X - 8} y={84} width={16} height={18} rx={3} fill={`url(#${id}-brass)`} />\n\n            {/* The bulb hangs just below the rim: clear glass when off. */}\n            <circle\n              cx={LAMP_X}\n              cy={160}\n              r={15}\n              // Unlit glass reads paler than the room: dimmer in the dark.\n              style={{ fill: \"light-dark(oklch(0.95 0.01 85 / 0.85), oklch(0.62 0.01 85))\" }}\n              stroke=\"oklch(0 0 0 / 0.18)\"\n            />\n            {/* The lit filament is its own layer so it can flicker on with the glow. */}\n            <circle\n              ref={filamentRef}\n              cx={LAMP_X}\n              cy={160}\n              r={15.5}\n              fill={WARM}\n              className={cn(\n                \"transition-opacity ease-out\",\n                checked ? \"opacity-100 duration-150\" : \"opacity-0 duration-[350ms]\",\n              )}\n            />\n\n            <path d=\"M128 152C128 119 158 98 200 98C242 98 272 119 272 152Z\" fill={`url(#${id}-shade)`} />\n            {/* A highlight along the crown, and the rolled rim, whose white\n                enamel inside glows when the bulb is lit. */}\n            <path d=\"M146 128C156 112 176 104 200 103\" fill=\"none\" stroke=\"oklch(1 0 0 / 0.28)\" strokeWidth={2} strokeLinecap=\"round\" />\n            <rect x={126} y={149} width={148} height={5} rx={2.5} fill={ENAMEL} />\n            <rect\n              x={130}\n              y={151.5}\n              width={140}\n              height={2}\n              rx={1}\n              fill={WARM}\n              className={cn(\n                \"transition-opacity ease-out\",\n                checked ? \"opacity-100 duration-150\" : \"opacity-0 duration-[350ms]\",\n              )}\n            />\n\n            {/* The plunger rod that the chain draws out of the socket. */}\n            <line\n              ref={plungerRef}\n              x1={ANCHOR_X}\n              y1={ANCHOR_Y - 4}\n              x2={ANCHOR_X}\n              y2={ANCHOR_Y}\n              strokeWidth={2.5}\n              strokeLinecap=\"round\"\n              stroke={NICKEL}\n            />\n            {/* Zero-length dashes with round caps draw a ball chain. */}\n            <path\n              ref={chainRef}\n              d={INITIAL_PATH}\n              fill=\"none\"\n              strokeWidth={3.5}\n              strokeLinecap=\"round\"\n              strokeLinejoin=\"round\"\n              strokeDasharray=\"0 4.5\"\n              stroke={NICKEL}\n            />\n          </svg>\n\n          <button\n            ref={knobRef}\n            type=\"button\"\n            role=\"switch\"\n            aria-checked={checked}\n            aria-label={label}\n            className=\"absolute top-0 left-0 flex cursor-grab touch-none justify-center rounded-full outline-hidden select-none focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-foreground active:cursor-grabbing\"\n            style={{ width: HIT, height: HIT, transform: INITIAL_KNOB, transformOrigin: `${HIT / 2}px 4px` }}\n            onPointerDown={onPointerDown}\n            onPointerMove={onPointerMove}\n            onPointerUp={onPointerEnd}\n            onPointerCancel={onPointerEnd}\n            onClick={(e) => {\n              // Pointer taps are handled on pointerup; detail 0 means the\n              // click came from Space or Enter.\n              if (e.detail === 0) pullOnce();\n            }}\n          >\n            {/* A turned brass pull, heavier at the bottom like a plumb bob. */}\n            <span\n              className=\"mt-1 h-[26px] w-4 rounded-t-[6px] rounded-b-full shadow-[0_2px_4px_oklch(0_0_0/0.3),inset_0_-2px_3px_oklch(0_0_0/0.2)]\"\n              style={{\n                background:\n                  \"linear-gradient(90deg, oklch(0.6 0.09 72), oklch(0.88 0.1 86) 45%, oklch(0.56 0.09 68))\",\n              }}\n            />\n          </button>\n        </div>\n      </div>\n\n      <div className=\"flex items-center justify-between px-1 text-sm\">\n        <span className=\"text-muted-foreground\">Pull the chain</span>\n        <span className=\"grid font-medium\">\n          <State visible={checked}>On</State>\n          <State visible={!checked}>Off</State>\n        </span>\n      </div>\n    </div>\n  );\n}\n\n// Both labels share a grid cell so the row never changes width.\nfunction State({ visible, children }: { visible: boolean; children: React.ReactNode }) {\n  return (\n    <span\n      aria-hidden\n      className={cn(\n        \"col-start-1 row-start-1 text-right transition-[opacity,filter] duration-200 ease-out\",\n        !visible && \"opacity-0 blur-[4px]\",\n      )}\n    >\n      {children}\n    </span>\n  );\n}\n\n// Site theme: the same key, attribute and \"system when nothing is saved\"\n// rule as the header toggle, duplicated so the two stay independent but in sync.\ntype Theme = \"light\" | \"dark\";\nconst DARK_QUERY = \"(prefers-color-scheme: dark)\";\n// Strong ease-out: the light arrives fast and settles.\nconst EASE_OUT = \"cubic-bezier(0.23, 1, 0.32, 1)\";\n// Longer than UI motion on purpose: switching the whole site is a rare,\n// deliberate act, and the cone has the entire viewport to sweep.\nconst POUR_MS = 550;\n// Going dark is quicker, the way a room loses light faster than it gains it.\nconst DRAW_MS = 400;\n// Keyframes are evenly spaced in cone angle; the easing is applied over the\n// whole run, so more frames only make the polygon's arc smoother.\nconst CONE_FRAMES = 32;\nconst CONE_RAYS = 8;\n// Starting half-angle of the beam, in radians (about 6 degrees).\nconst CONE_START = 0.1;\n\nfunction subscribeTheme(onChange: () => void) {\n  const media = matchMedia(DARK_QUERY);\n  const observer = new MutationObserver(onChange);\n  media.addEventListener(\"change\", onChange);\n  observer.observe(document.documentElement, { attributes: true, attributeFilter: [\"data-theme\"] });\n  return () => {\n    media.removeEventListener(\"change\", onChange);\n    observer.disconnect();\n  };\n}\n\nfunction getTheme(): Theme {\n  const stored = document.documentElement.dataset.theme;\n  if (stored === \"light\" || stored === \"dark\") return stored;\n  return matchMedia(DARK_QUERY).matches ? \"dark\" : \"light\";\n}\n\nfunction applyTheme(theme: Theme) {\n  // Same as the toggle: stop every color transition firing during the swap.\n  const pause = document.createElement(\"style\");\n  pause.textContent = \"*,*::before,*::after{transition:none!important}\";\n  document.head.append(pause);\n  document.documentElement.dataset.theme = theme;\n  try {\n    localStorage.setItem(\"theme\", theme);\n  } catch {}\n  void document.body.offsetHeight;\n  requestAnimationFrame(() => pause.remove());\n}\n\n// A beam from the bulb whose edges swing outward from straight down until\n// they meet straight up, so the last frame is a full disc over the viewport.\nfunction coneFrames({ x, y, r }: BulbOrigin) {\n  // 25% past the farthest corner so the chords between rays still clear it.\n  const reach = Math.hypot(Math.max(x, innerWidth - x), Math.max(y, innerHeight - y)) * 1.25;\n  const frames: string[] = [];\n  for (let f = 0; f <= CONE_FRAMES; f++) {\n    const p = f / CONE_FRAMES;\n    // Closes a little early so the ease-out tail isn't spent on a sliver\n    // of old theme above the lamp.\n    const k = Math.min(1, p / 0.9);\n    const spread = CONE_START + (Math.PI - CONE_START) * k;\n    // The beam reaches the floor in the first third, then only widens.\n    const len = reach * Math.min(1, 0.1 + p * 2.7);\n    // The beam leaves the bulb's full width but narrows to a point by the\n    // end, or the two edges meeting overhead would leave a gap between them.\n    const lip = r * (1 - k);\n    const pts = [`${x - lip}px ${y}px`, `${x + lip}px ${y}px`];\n    for (let i = 0; i <= CONE_RAYS; i++) {\n      const a = spread - (2 * spread * i) / CONE_RAYS;\n      pts.push(`${(x + len * Math.sin(a)).toFixed(1)}px ${(y + len * Math.cos(a)).toFixed(1)}px`);\n    }\n    frames.push(`polygon(${pts.join(\",\")})`);\n  }\n  return frames;\n}\n\nfunction pourTheme(theme: Theme, bulb: BulbOrigin, update: () => void) {\n  const on = theme === \"light\";\n  // Going dark, the old light page is the one being drawn in, so it must\n  // sit above the new one for the length of the transition.\n  let order: HTMLStyleElement | null = null;\n  if (!on) {\n    order = document.createElement(\"style\");\n    order.textContent =\n      \"::view-transition-old(root){z-index:1}::view-transition-new(root){z-index:0}\";\n    document.head.append(order);\n  }\n  const transition = document.startViewTransition(() => {\n    applyTheme(theme);\n    update();\n  });\n  transition.ready\n    .then(() => {\n      const frames = coneFrames(bulb);\n      document.documentElement.animate(\n        { clipPath: on ? frames : frames.reverse() },\n        {\n          duration: on ? POUR_MS : DRAW_MS,\n          easing: EASE_OUT,\n          fill: \"forwards\",\n          pseudoElement: on ? \"::view-transition-new(root)\" : \"::view-transition-old(root)\",\n        },\n      );\n    })\n    .catch(() => {});\n  return transition.finished.finally(() => order?.remove());\n}\n\nexport default function PullCordSwitchDemo() {\n  // Reading only: nothing here writes the theme until someone pulls.\n  const theme = useSyncExternalStore(subscribeTheme, getTheme, (): Theme => \"light\");\n  // The lamp runs ahead of the theme while the bulb flickers and the cone pours.\n  const [lamp, setLamp] = useState<boolean | null>(null);\n  const timer = useRef(0);\n  const run = useRef(0);\n  useEffect(() => () => clearTimeout(timer.current), []);\n\n  const pull = (on: boolean, bulb: BulbOrigin) => {\n    clearTimeout(timer.current);\n    const id = ++run.current;\n    const target: Theme = on ? \"light\" : \"dark\";\n    if (!document.startViewTransition || matchMedia(\"(prefers-reduced-motion: reduce)\").matches) {\n      applyTheme(target);\n      setLamp(null);\n      return;\n    }\n    // Pulled back off during the flicker, before the theme ever changed.\n    if (getTheme() === target) {\n      setLamp(null);\n      return;\n    }\n    setLamp(on);\n    const go = () =>\n      pourTheme(target, bulb, () => flushSync(() => setLamp(on))).then(() => {\n        if (run.current === id) setLamp(null);\n      });\n    if (on) timer.current = window.setTimeout(go, FLICKER_MS);\n    else go();\n  };\n\n  return <PullCordSwitch label=\"Lights\" checked={lamp ?? theme === \"light\"} onCheckedChange={pull} />;\n}\n"},{"path":"hooks/use-reduced-motion.ts","type":"registry:hook","target":"hooks/use-reduced-motion.ts","content":"\"use client\";\n\nimport { useSyncExternalStore } from \"react\";\n\n// Motion's useReducedMotion reads the media query on the very first client\n// render, but the server can't know it, so every component that renders\n// differently under reduced motion broke hydration for those users. This\n// reports false during hydration, matching the server HTML, then the real\n// preference straight after, and follows it if it changes.\nconst query = \"(prefers-reduced-motion: reduce)\";\n\nfunction subscribe(onChange: () => void) {\n  const media = window.matchMedia(query);\n  media.addEventListener(\"change\", onChange);\n  return () => media.removeEventListener(\"change\", onChange);\n}\n\nexport function useReducedMotion() {\n  return useSyncExternalStore(\n    subscribe,\n    () => window.matchMedia(query).matches,\n    () => false,\n  );\n}\n"}],"cssVars":{"light":{"shadow-raised":"0 0 0 1px oklch(0 0 0 / 0.06), 0 1px 2px oklch(0 0 0 / 0.06), 0 6px 16px -6px oklch(0 0 0 / 0.12)"},"dark":{"shadow-raised":"0 0 0 1px oklch(1 0 0 / 0.08), 0 1px 2px oklch(0 0 0 / 0.4), 0 6px 16px -6px oklch(0 0 0 / 0.6)"},"theme":{"shadow-raised":"var(--shadow-raised)"}},"categories":["objects"],"docs":"From ui lab: https://lab.xevrion.dev/lab/pull-cord-switch"}