{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "switch-frost",
  "title": "Switch Frost",
  "description": "iOS-style switch whose OFF state freezes over: seeded dendritic frost feathers creep in from the track edges over a frosted-glass film, with sparkle grain that glistens at idle; switching ON drives a melt front ahead of the sliding thumb with droplet run-off.",
  "dependencies": [],
  "files": [
    {
      "path": "registry/core/switch-frost/component.tsx",
      "content": "\"use client\";\n\nimport { useEffect, useRef, useState } from \"react\";\n\n// ---------------------------------------------------------------------------\n// FrostbiteSwitch — iOS-style switch whose OFF state freezes over: seeded\n// dendritic spines creep in from the track edges throwing alternating side\n// needles (real frost-finger anatomy), rendered in three passes (haze film,\n// cold shade, bright ice body) over a frosted-glass backdrop layer that blurs\n// and brightens whatever sits beneath — including the thumb. A separate\n// sparkle canvas carries grain + four-point glints and glistens on idle by\n// mutating layer opacity only (no redraw). Switching ON drives a spatial melt\n// front left-to-right ahead of the sliding thumb: a wet gleam edge, receding\n// glass clip, and a few droplets of run-off. All ink derives from CSS tokens\n// at mount and re-derives live on documentElement class changes.\n// ---------------------------------------------------------------------------\n\nconst TRACK_W = 50; // content-box px (52 outer minus 1px border per side)\nconst THUMB = 26;\nconst TRAVEL = TRACK_W - THUMB - 2; // 22px, 1px inset at each end\nconst STEP = 2; // walker step length px\nconst SPEED = 140; // spine advance px/s\nconst GROW_S = 0.7; // grow-in budget s (seeded pattern finishes within this)\nconst MELT_S = 0.45; // melt-front sweep duration s\nconst SLIDE_DELAY_MS = 120; // thumb waits this long into the melt\nconst GRAVITY = 1200; // droplet gravity px/s^2\nconst DROP_LIFE = 0.4; // droplet lifetime s\nconst SPRING_K = 170; // s^-2\nconst SPRING_ZETA = 0.85; // one small overshoot\nconst MAX_SEGS = 700;\n\ntype Vec3 = readonly [number, number, number];\n\ninterface Seg {\n  x1: number;\n  y1: number;\n  x2: number;\n  y2: number;\n  depth: number; // 0 spine, 1 needle, 2 sub-needle\n  birth: number; // seconds into the grow timeline\n}\ninterface Spark {\n  x: number;\n  y: number;\n  r: number;\n  a: number;\n  birth: number;\n}\ninterface Glint {\n  x: number;\n  y: number;\n  arm: number;\n  a: number;\n  birth: number;\n}\ninterface DropPlan {\n  x: number;\n  y: number;\n  vx: number;\n  vy: number;\n  r: number;\n  birth: number;\n  spawned: boolean;\n}\ninterface Drop {\n  x: number;\n  y: number;\n  vx: number;\n  vy: number;\n  r: number;\n  age: number;\n}\n\n// deterministic PRNG — reseeded per state-change so regrowth repeats in-session\nfunction mulberry32(seed: number) {\n  let a = seed >>> 0;\n  return () => {\n    a |= 0;\n    a = (a + 0x6d2b79f5) | 0;\n    let t = Math.imul(a ^ (a >>> 15), 1 | a);\n    t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;\n    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;\n  };\n}\n\nfunction parseColor(raw: string): Vec3 | null {\n  const s = raw.trim();\n  if (s.startsWith(\"#\")) {\n    const hex = s.slice(1);\n    if (hex.length === 3) {\n      const r = parseInt(hex.slice(0, 1) + hex.slice(0, 1), 16);\n      const g = parseInt(hex.slice(1, 2) + hex.slice(1, 2), 16);\n      const b = parseInt(hex.slice(2, 3) + hex.slice(2, 3), 16);\n      return Number.isNaN(r + g + b) ? null : [r, g, b];\n    }\n    if (hex.length >= 6) {\n      const r = parseInt(hex.slice(0, 2), 16);\n      const g = parseInt(hex.slice(2, 4), 16);\n      const b = parseInt(hex.slice(4, 6), 16);\n      return Number.isNaN(r + g + b) ? null : [r, g, b];\n    }\n    return null;\n  }\n  const m = s.match(/rgba?\\(\\s*([\\d.]+)[,\\s]+([\\d.]+)[,\\s]+([\\d.]+)/);\n  return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : null;\n}\n\nfunction mix(a: Vec3, b: Vec3, t: number): Vec3 {\n  return [\n    Math.round(a[0] + (b[0] - a[0]) * t),\n    Math.round(a[1] + (b[1] - a[1]) * t),\n    Math.round(a[2] + (b[2] - a[2]) * t),\n  ];\n}\n\n// cold-cast a token color: pull red down, push blue up — still token-derived\nfunction cold(c: Vec3): Vec3 {\n  return [\n    Math.max(0, Math.round(c[0] * 0.93)),\n    Math.max(0, Math.round(c[1] * 0.985)),\n    Math.min(255, Math.round(c[2] * 1.06 + 6)),\n  ];\n}\n\nconst rgba = (c: Vec3, a: number) =>\n  `rgba(${c[0]},${c[1]},${c[2]},${a.toFixed(3)})`;\n\nconst easeOutCubic = (p: number) => 1 - (1 - p) * (1 - p) * (1 - p);\n\ninterface Crystal {\n  segs: Seg[];\n  sparks: Spark[];\n  glints: Glint[];\n  plan: DropPlan[];\n  maxBirth: number;\n}\n\n// Dendritic frost: seeded spines creep in from the right/top/bottom track\n// edges, mostly straight with gentle curvature, throwing short alternating\n// side needles (~60 deg) every few steps; longer needles may fork once more.\nfunction buildCrystal(rand: () => number, w: number, h: number): Crystal {\n  const segs: Seg[] = [];\n  const stepDur = STEP / SPEED;\n\n  const needle = (\n    x: number,\n    y: number,\n    a: number,\n    birth: number,\n    depth: number,\n    len: number\n  ) => {\n    let b = birth;\n    for (let k = 0; k < len && segs.length < MAX_SEGS; k++) {\n      a += (rand() - 0.5) * 0.1;\n      const nx = x + Math.cos(a) * STEP;\n      const ny = y + Math.sin(a) * STEP;\n      if (nx < 0.5 || nx > w - 0.5 || ny < 0.5 || ny > h - 0.5) break;\n      segs.push({ x1: x, y1: y, x2: nx, y2: ny, depth, birth: b });\n      // feathering: longer needles throw their own barbs at the same angle\n      if (depth === 1 && k >= 1 && rand() < 0.3) {\n        const side = rand() < 0.5 ? 1 : -1;\n        needle(\n          nx,\n          ny,\n          a + side * (0.9 + rand() * 0.3),\n          b + stepDur * 0.4,\n          2,\n          1 + Math.floor(rand() * 2)\n        );\n      }\n      b += stepDur * 0.55;\n      x = nx;\n      y = ny;\n    }\n  };\n\n  interface Seed {\n    x: number;\n    y: number;\n    a: number;\n    birth: number;\n  }\n  const seeds: Seed[] = [];\n  const nR = 3 + Math.floor(rand() * 2); // right edge, sweeping left\n  for (let i = 0; i < nR; i++) {\n    seeds.push({\n      x: w - 0.5,\n      y: h * (0.12 + (0.76 * (i + 0.1 + rand() * 0.8)) / nR),\n      a: Math.PI + (rand() - 0.5) * 0.5,\n      birth: rand() * 0.08,\n    });\n  }\n  // one steeper feather from the top and bottom edges keeps the lattice open\n  seeds.push({\n    x: w * (0.5 + 0.35 * rand()),\n    y: 0.5,\n    a: Math.PI - 0.7 - rand() * 0.3,\n    birth: rand() * 0.1,\n  });\n  seeds.push({\n    x: w * (0.5 + 0.35 * rand()),\n    y: h - 0.5,\n    a: Math.PI + 0.7 + rand() * 0.3,\n    birth: rand() * 0.1,\n  });\n\n  for (const seed of seeds) {\n    let { x, y, a, birth } = seed;\n    const curv = (rand() - 0.5) * 0.05;\n    let needleClock = 1 + Math.floor(rand() * 2);\n    let side = rand() < 0.5 ? 1 : -1;\n    const maxSteps = Math.ceil(w / STEP) + 4;\n    for (let steps = 0; steps < maxSteps && segs.length < MAX_SEGS; steps++) {\n      if (steps > 10 && rand() < 0.03) break; // some fingers stall early\n      a += curv + (rand() - 0.5) * 0.1;\n      let nx = x + Math.cos(a) * STEP;\n      let ny = y + Math.sin(a) * STEP;\n      if (ny < 0.5 || ny > h - 0.5) {\n        a = -a;\n        ny = y + Math.sin(a) * STEP;\n        ny = Math.min(h - 0.5, Math.max(0.5, ny));\n      }\n      if (nx < 0.5) break; // spine reached the far edge\n      if (nx > w - 0.5) {\n        a = Math.PI - a;\n        nx = x + Math.cos(a) * STEP;\n      }\n      segs.push({ x1: x, y1: y, x2: nx, y2: ny, depth: 0, birth });\n      if (--needleClock <= 0) {\n        needleClock = 2 + Math.floor(rand() * 2);\n        side = -side;\n        needle(\n          nx,\n          ny,\n          a + side * (1.0 + rand() * 0.25),\n          birth + stepDur * 0.5,\n          1,\n          2 + Math.floor(rand() * 3)\n        );\n      }\n      birth += stepDur;\n      x = nx;\n      y = ny;\n    }\n  }\n\n  let maxBirth = 0;\n  for (const s of segs) if (s.birth > maxBirth) maxBirth = s.birth;\n  maxBirth = Math.min(maxBirth, GROW_S);\n\n  // sparkle grain + four-point glints, all deterministic with the same seed\n  const sparks: Spark[] = [];\n  const glints: Glint[] = [];\n  for (const s of segs) {\n    if (rand() < 0.3) {\n      sparks.push({\n        x: s.x2 + (rand() - 0.5) * 1.6,\n        y: s.y2 + (rand() - 0.5) * 1.6,\n        r: 0.3 + rand() * 0.45,\n        a: 0.35 + rand() * 0.55,\n        birth: s.birth,\n      });\n    }\n  }\n  if (segs.length > 0) {\n    const n = 7 + Math.floor(rand() * 3);\n    for (let i = 0; i < n; i++) {\n      const s = segs[Math.floor(rand() * segs.length)];\n      if (!s) continue;\n      glints.push({\n        x: s.x2,\n        y: s.y2,\n        arm: 1.5 + rand() * 1.3,\n        a: 0.65 + rand() * 0.35,\n        birth: s.birth,\n      });\n    }\n  }\n\n  // droplet run-off plan — a few, from the lower half, released by the front\n  const plan: DropPlan[] = [];\n  const low = segs.filter((s) => s.y2 > h * 0.45);\n  if (low.length > 0) {\n    const n = 3 + Math.floor(rand() * 3);\n    for (let i = 0; i < n; i++) {\n      const s = low[Math.floor(rand() * low.length)];\n      if (!s) continue;\n      plan.push({\n        x: s.x2,\n        y: s.y2,\n        vx: (rand() - 0.5) * 30,\n        vy: 8 + rand() * 24,\n        r: 0.9 + rand() * 0.7,\n        birth: s.birth,\n        spawned: false,\n      });\n    }\n  }\n  return { segs, sparks, glints, plan, maxBirth };\n}\n\nexport function FrostbiteSwitch({\n  checked,\n  defaultChecked = false,\n  onCheckedChange,\n  disabled = false,\n  className = \"\",\n  \"aria-label\": ariaLabel = \"Toggle\",\n}: {\n  /** controlled state; omit for uncontrolled */\n  checked?: boolean;\n  /** uncontrolled initial state. Default false. */\n  defaultChecked?: boolean;\n  /** called with the new state after a toggle */\n  onCheckedChange?: (checked: boolean) => void;\n  /** blocks the switch */\n  disabled?: boolean;\n  /** extra classes merged onto the rendered root element */\n  className?: string;\n  /** accessible name for the switch. Default \"Toggle\". */\n  \"aria-label\"?: string;\n}) {\n  const btnRef = useRef<HTMLButtonElement>(null);\n  const canvasRef = useRef<HTMLCanvasElement>(null);\n  const sparkRef = useRef<HTMLCanvasElement>(null);\n  const glassRef = useRef<HTMLSpanElement>(null);\n  const thumbRef = useRef<HTMLSpanElement>(null);\n  const engineRef = useRef<{\n    transition: (on: boolean) => void;\n    setDisabled: (d: boolean) => void;\n  } | null>(null);\n\n  const isControlled = checked !== undefined;\n  const [internal, setInternal] = useState(defaultChecked);\n  const isChecked = isControlled ? checked : internal;\n  const checkedRef = useRef(isChecked);\n  checkedRef.current = isChecked;\n  const disabledRef = useRef(disabled);\n  disabledRef.current = disabled;\n\n  useEffect(() => {\n    const btn = btnRef.current;\n    const canvas = canvasRef.current;\n    const sparkCanvas = sparkRef.current;\n    const glass = glassRef.current;\n    const thumb = thumbRef.current;\n    if (!btn || !canvas || !sparkCanvas || !glass || !thumb) return;\n    const ctx = canvas.getContext(\"2d\");\n    const sctx = sparkCanvas.getContext(\"2d\");\n    if (!ctx || !sctx) return;\n\n    const reduced = window.matchMedia(\n      \"(prefers-reduced-motion: reduce)\"\n    ).matches;\n\n    // -- token-derived ink: read at mount, re-derived on theme class change --\n    let fg: Vec3 = [237, 237, 237];\n    let bg: Vec3 = [10, 10, 10];\n    let isDark = true;\n    let bodyCol: Vec3 = [255, 255, 255]; // bright ice ridge\n    let shadeCol: Vec3 = [40, 55, 75]; // cold definition under the ridge\n    let hazeA = 0.05;\n    let shadeA = 0.4;\n    const derive = () => {\n      const cs = getComputedStyle(document.documentElement);\n      fg = parseColor(cs.getPropertyValue(\"--foreground\")) ?? fg;\n      bg = parseColor(cs.getPropertyValue(\"--background\")) ?? bg;\n      const lum = 0.2126 * bg[0] + 0.7152 * bg[1] + 0.0722 * bg[2];\n      isDark = lum < 128;\n      if (isDark) {\n        bodyCol = cold(mix(fg, [255, 255, 255], 0.4));\n        shadeCol = cold(mix(fg, bg, 0.72));\n        hazeA = 0.055;\n        shadeA = 0.4;\n      } else {\n        bodyCol = cold(mix(fg, [255, 255, 255], 0.96)); // near-white ridge\n        shadeCol = cold(mix(fg, bg, 0.48)); // mid cold slate\n        hazeA = 0.3;\n        shadeA = 0.6;\n      }\n      // frosted-glass film over whatever sits beneath (track AND thumb)\n      glass.style.background = isDark\n        ? \"rgba(255,255,255,0.08)\"\n        : \"rgba(255,255,255,0.34)\";\n      glass.style.backdropFilter = isDark\n        ? \"blur(1.5px) brightness(1.3) saturate(0.85)\"\n        : \"blur(1.5px) brightness(1.04) saturate(0.85)\";\n    };\n    derive();\n\n    // -- hot-path state: locals only, never React state --------------------\n    let w = 0;\n    let h = 0;\n    let dpr = 1;\n    let sized = false;\n    let raf = 0;\n    let last = 0;\n    let visible = true;\n    let phase: \"offIdle\" | \"grow\" | \"melt\" | \"onIdle\" = \"onIdle\";\n    let current = checkedRef.current;\n    let phaseStart = 0;\n    let grownT = 0; // birth threshold currently visible\n    let meltThr = 0; // birth threshold frozen when the melt began\n    let crystal: Crystal = {\n      segs: [],\n      sparks: [],\n      glints: [],\n      plan: [],\n      maxBirth: 0,\n    };\n    const drops: Drop[] = [];\n    let growCount = 0;\n    let glassLevel = 0;\n    let glistenPhase = 0; // per-cycle random offset so the breathing isn't a locked metronome\n    let glistenPeriodS = 4; // jittered 3.4-4.6s, reseeded each freeze cycle\n    let thumbX = current ? TRAVEL : 0;\n    let thumbV = 0;\n    let target = thumbX;\n    let springOn = false;\n    let pendingTarget = 0;\n    let pendingAt = -1; // performance.now() when a delayed spring target lands\n\n    const setThumb = () => {\n      thumb.style.transform = `translateX(${thumbX.toFixed(2)}px)`;\n    };\n    const base = () => (disabledRef.current ? 0.4 : 1);\n    const setGlass = (level: number) => {\n      glassLevel = level;\n      glass.style.opacity = (level * base()).toFixed(3);\n    };\n\n    const clearLayer = (c: CanvasRenderingContext2D) => {\n      c.setTransform(dpr, 0, 0, dpr, 0, 0);\n      c.clearRect(0, 0, w, h);\n    };\n\n    // Three passes: wide translucent haze (frost mass), cold shade (definition\n    // on light surfaces / the thumb), bright ice body. Melt clips to x>=front.\n    const drawCrystal = (thr: number, front: number | null) => {\n      clearLayer(ctx);\n      const segs = crystal.segs;\n      if (segs.length === 0) return;\n      ctx.save();\n      if (front !== null) {\n        ctx.beginPath();\n        ctx.rect(front, 0, Math.max(0, w - front), h);\n        ctx.clip();\n      }\n      ctx.lineCap = \"round\";\n      // haze film\n      ctx.beginPath();\n      for (const s of segs) {\n        if (s.birth > thr) continue;\n        ctx.moveTo(s.x1, s.y1);\n        ctx.lineTo(s.x2, s.y2);\n      }\n      ctx.strokeStyle = rgba([255, 255, 255], hazeA);\n      ctx.lineWidth = 4.5;\n      ctx.stroke();\n      // rim frost creeping in from the pill edge\n      const prog = crystal.maxBirth > 0 ? Math.min(1, thr / crystal.maxBirth) : 1;\n      if (typeof ctx.roundRect === \"function\" && prog > 0.05) {\n        ctx.beginPath();\n        ctx.roundRect(0.75, 0.75, w - 1.5, h - 1.5, h / 2);\n        ctx.strokeStyle = rgba(bodyCol, (isDark ? 0.16 : 0.3) * prog);\n        ctx.lineWidth = 2.2;\n        ctx.stroke();\n      }\n      // shade + body, batched by depth\n      const shadeW = [1.7, 1.1, 0.8];\n      const bodyW = [1.15, 0.7, 0.5];\n      const bodyAl = [0.9, 0.75, 0.55];\n      for (let pass = 0; pass < 2; pass++) {\n        for (let d = 0; d <= 2; d++) {\n          let any = false;\n          ctx.beginPath();\n          for (const s of segs) {\n            if (s.depth !== d || s.birth > thr) continue;\n            ctx.moveTo(s.x1, s.y1);\n            ctx.lineTo(s.x2, s.y2);\n            any = true;\n          }\n          if (!any) continue;\n          if (pass === 0) {\n            ctx.strokeStyle = rgba(shadeCol, shadeA * (1 - d * 0.2));\n            ctx.lineWidth = shadeW[d] ?? 1;\n          } else {\n            ctx.strokeStyle = rgba(bodyCol, bodyAl[d] ?? 0.6);\n            ctx.lineWidth = bodyW[d] ?? 0.6;\n          }\n          ctx.stroke();\n        }\n      }\n      ctx.restore();\n      // wet gleam edge at the melt front, over the still-frozen side\n      if (front !== null && front > 0 && front < w) {\n        const g = ctx.createLinearGradient(front, 0, front + 6, 0);\n        g.addColorStop(0, rgba([255, 255, 255], 0.4));\n        g.addColorStop(1, rgba([255, 255, 255], 0));\n        ctx.fillStyle = g;\n        ctx.fillRect(front, 0, 6, h);\n        ctx.fillStyle = rgba(bodyCol, 0.55);\n        ctx.fillRect(front, 0, 1, h);\n      }\n    };\n\n    const drawSparks = (thr: number, front: number | null) => {\n      clearLayer(sctx);\n      if (crystal.segs.length === 0) return;\n      sctx.save();\n      if (front !== null) {\n        sctx.beginPath();\n        sctx.rect(front, 0, Math.max(0, w - front), h);\n        sctx.clip();\n      }\n      for (const p of crystal.sparks) {\n        if (p.birth > thr) continue;\n        sctx.beginPath();\n        sctx.arc(p.x, p.y, p.r, 0, Math.PI * 2);\n        sctx.fillStyle = rgba([255, 255, 255], p.a);\n        sctx.fill();\n      }\n      sctx.lineCap = \"round\";\n      sctx.lineWidth = 0.7;\n      for (const p of crystal.glints) {\n        if (p.birth > thr) continue;\n        sctx.strokeStyle = rgba([255, 255, 255], p.a);\n        sctx.beginPath();\n        sctx.moveTo(p.x - p.arm, p.y);\n        sctx.lineTo(p.x + p.arm, p.y);\n        sctx.moveTo(p.x, p.y - p.arm);\n        sctx.lineTo(p.x, p.y + p.arm);\n        sctx.stroke();\n        sctx.beginPath();\n        sctx.arc(p.x, p.y, 0.7, 0, Math.PI * 2);\n        sctx.fillStyle = rgba([255, 255, 255], Math.min(1, p.a + 0.2));\n        sctx.fill();\n      }\n      sctx.restore();\n    };\n\n    const drawDrops = () => {\n      for (const dr of drops) {\n        const a = 1 - dr.age / DROP_LIFE;\n        ctx.beginPath();\n        ctx.ellipse(dr.x, dr.y, dr.r * 0.72, dr.r * 1.1, 0, 0, Math.PI * 2);\n        ctx.fillStyle = rgba(bodyCol, a * 0.85);\n        ctx.fill();\n        ctx.beginPath();\n        ctx.arc(dr.x - dr.r * 0.25, dr.y - dr.r * 0.35, dr.r * 0.3, 0, Math.PI * 2);\n        ctx.fillStyle = rgba([255, 255, 255], a * 0.9);\n        ctx.fill();\n      }\n    };\n\n    const rebuild = () => {\n      if (!sized) {\n        crystal = { segs: [], sparks: [], glints: [], plan: [], maxBirth: 0 };\n        return;\n      }\n      const rand = mulberry32(0x9e3779b9 ^ (growCount++ * 101));\n      glistenPhase = rand() * Math.PI * 2;\n      glistenPeriodS = 3.4 + rand() * 1.2;\n      crystal = buildCrystal(rand, w, h);\n    };\n\n    const redrawStatic = () => {\n      if (!sized) return;\n      if (phase === \"onIdle\") {\n        clearLayer(ctx);\n        clearLayer(sctx);\n      } else if (phase === \"offIdle\" || phase === \"grow\") {\n        drawCrystal(grownT, null);\n        drawSparks(grownT, null);\n      }\n      // melt frames are repainted by the running loop\n    };\n\n    const resize = () => {\n      const rect = canvas.getBoundingClientRect();\n      if (rect.width < 4 || rect.height < 4) {\n        sized = false; // zero-size guard — never seed walkers into nothing\n        return;\n      }\n      w = rect.width;\n      h = rect.height;\n      dpr = Math.min(2, window.devicePixelRatio || 1);\n      for (const c of [canvas, sparkCanvas]) {\n        c.width = Math.round(w * dpr);\n        c.height = Math.round(h * dpr);\n        c.style.width = `${w}px`;\n        c.style.height = `${h}px`;\n      }\n      sized = true;\n      if (phase === \"offIdle\" && crystal.segs.length === 0) {\n        rebuild();\n        grownT = crystal.maxBirth;\n      }\n      redrawStatic();\n    };\n    resize();\n\n    const loop = (now: number) => {\n      raf = 0;\n      const dt = last ? Math.min(0.033, (now - last) / 1000) : 1 / 60;\n      last = now;\n      let active = false;\n\n      // delayed spring target (thumb starts 120 ms into the melt)\n      if (pendingAt >= 0) {\n        if (now >= pendingAt) {\n          target = pendingTarget;\n          pendingAt = -1;\n          springOn = true;\n        } else active = true;\n      }\n\n      if (springOn) {\n        const c = 2 * SPRING_ZETA * Math.sqrt(SPRING_K);\n        thumbV += (-SPRING_K * (thumbX - target) - c * thumbV) * dt;\n        thumbX += thumbV * dt;\n        if (Math.abs(thumbX - target) < 0.05 && Math.abs(thumbV) < 0.5) {\n          thumbX = target;\n          thumbV = 0;\n          springOn = false;\n        } else active = true;\n        setThumb();\n      }\n\n      if (phase === \"grow\") {\n        const t = (now - phaseStart) / 1000;\n        grownT = Math.min(t, crystal.maxBirth);\n        if (sized) {\n          drawCrystal(grownT, null);\n          drawSparks(grownT, null);\n        }\n        setGlass(\n          crystal.maxBirth > 0 ? Math.min(1, grownT / crystal.maxBirth) : 1\n        );\n        if (t >= crystal.maxBirth) phase = \"offIdle\"; // glisten takes over\n        active = true;\n      } else if (phase === \"melt\") {\n        const t = (now - phaseStart) / 1000;\n        const p = Math.min(1, t / MELT_S);\n        // front recedes just ahead of the sliding thumb: a near-linear sweep,\n        // but never closer than a few px past the thumb's leading edge\n        const thumbFloor =\n          pendingAt < 0 ? Math.min(w + 6, thumbX + THUMB + 5) : 0;\n        const front = Math.max(\n          (0.3 * p + 0.7 * easeOutCubic(p) * p) * (w + 6),\n          thumbFloor\n        );\n        for (const d of crystal.plan) {\n          if (!d.spawned && d.birth <= meltThr && front > d.x) {\n            d.spawned = true;\n            drops.push({ x: d.x, y: d.y, vx: d.vx, vy: d.vy, r: d.r, age: 0 });\n          }\n        }\n        for (let i = drops.length - 1; i >= 0; i--) {\n          const dr = drops[i];\n          if (!dr) continue;\n          dr.vy += GRAVITY * dt;\n          dr.x += dr.vx * dt;\n          dr.y += dr.vy * dt;\n          dr.age += dt;\n          if (dr.age >= DROP_LIFE) drops.splice(i, 1);\n        }\n        if (sized) {\n          drawCrystal(meltThr, front);\n          drawDrops();\n          drawSparks(meltThr, front);\n        }\n        glass.style.clipPath = `inset(0 0 0 ${front.toFixed(1)}px)`;\n        if (p >= 1 && drops.length === 0) {\n          phase = \"onIdle\";\n          crystal = { segs: [], sparks: [], glints: [], plan: [], maxBirth: 0 };\n          clearLayer(ctx);\n          clearLayer(sctx);\n          setGlass(0);\n          glass.style.clipPath = \"\";\n          canvas.style.opacity = String(base());\n          sparkCanvas.style.opacity = String(base());\n        } else active = true;\n      } else if (phase === \"offIdle\" && crystal.segs.length > 0) {\n        // idle glisten: the ONLY idle work — sparkle-layer opacity, no redraw\n        if (visible && !document.hidden && !disabledRef.current && !reduced) {\n          sparkCanvas.style.opacity = (\n            base() *\n            (0.62 +\n              0.38 *\n                Math.sin((now / 1000) * ((Math.PI * 2) / glistenPeriodS) + glistenPhase))\n          ).toFixed(3);\n          active = true;\n        }\n      }\n\n      if (active) raf = requestAnimationFrame(loop);\n    };\n\n    const wake = () => {\n      if (!raf && !reduced) {\n        last = 0;\n        raf = requestAnimationFrame(loop);\n      }\n    };\n\n    const transition = (on: boolean) => {\n      if (on === current) return;\n      current = on;\n      if (reduced) {\n        // two static frames: full crystal OFF / clear ON; thumb snaps\n        thumbX = on ? TRAVEL : 0;\n        thumbV = 0;\n        springOn = false;\n        pendingAt = -1;\n        setThumb();\n        drops.length = 0;\n        glass.style.clipPath = \"\";\n        if (on) {\n          crystal = { segs: [], sparks: [], glints: [], plan: [], maxBirth: 0 };\n          phase = \"onIdle\";\n          clearLayer(ctx);\n          clearLayer(sctx);\n          setGlass(0);\n        } else {\n          rebuild();\n          grownT = crystal.maxBirth;\n          phase = \"offIdle\";\n          redrawStatic();\n          setGlass(1);\n        }\n        canvas.style.opacity = String(base());\n        sparkCanvas.style.opacity = String(base());\n        return;\n      }\n      const now = performance.now();\n      canvas.style.opacity = String(base());\n      sparkCanvas.style.opacity = String(base());\n      if (on) {\n        drops.length = 0;\n        if (crystal.segs.length === 0) {\n          phase = \"onIdle\";\n          target = TRAVEL;\n          springOn = true;\n          pendingAt = -1;\n          setGlass(0);\n        } else {\n          phase = \"melt\";\n          phaseStart = now;\n          meltThr = grownT;\n          for (const d of crystal.plan) d.spawned = false;\n          pendingTarget = TRAVEL;\n          pendingAt = now + SLIDE_DELAY_MS;\n        }\n      } else {\n        rebuild(); // fresh deterministic seed per state-change\n        grownT = 0;\n        phase = \"grow\";\n        phaseStart = now;\n        drops.length = 0;\n        glass.style.clipPath = \"\";\n        setGlass(0);\n        target = 0;\n        springOn = true;\n        pendingAt = -1;\n      }\n      wake();\n    };\n\n    const setDisabledFn = (d: boolean) => {\n      canvas.style.opacity = d ? \"0.4\" : \"1\";\n      sparkCanvas.style.opacity = d ? \"0.4\" : \"1\";\n      thumb.style.opacity = d ? \"0.4\" : \"1\";\n      setGlass(glassLevel);\n      if (!d) wake(); // glisten resumes\n    };\n\n    // -- init ---------------------------------------------------------------\n    setThumb();\n    canvas.style.opacity = String(base());\n    sparkCanvas.style.opacity = String(base());\n    if (!current) {\n      rebuild();\n      grownT = crystal.maxBirth;\n      phase = \"offIdle\";\n      redrawStatic();\n      setGlass(1);\n      wake();\n    } else {\n      setGlass(0);\n    }\n\n    engineRef.current = { transition, setDisabled: setDisabledFn };\n\n    // -- observers ----------------------------------------------------------\n    const ro = new ResizeObserver(resize);\n    ro.observe(canvas);\n    const io = new IntersectionObserver((entries) => {\n      visible = entries[0]?.isIntersecting ?? true;\n      if (visible) wake();\n    });\n    io.observe(btn);\n    // live theme re-derive: watch documentElement class flips\n    const mo = new MutationObserver(() => {\n      derive();\n      redrawStatic();\n    });\n    mo.observe(document.documentElement, {\n      attributes: true,\n      attributeFilter: [\"class\"],\n    });\n    const onVis = () => {\n      if (!document.hidden) wake();\n    };\n    document.addEventListener(\"visibilitychange\", onVis);\n\n    return () => {\n      cancelAnimationFrame(raf);\n      ro.disconnect();\n      io.disconnect();\n      mo.disconnect();\n      document.removeEventListener(\"visibilitychange\", onVis);\n      crystal = { segs: [], sparks: [], glints: [], plan: [], maxBirth: 0 };\n      drops.length = 0;\n      engineRef.current = null;\n    };\n  }, []);\n\n  useEffect(() => {\n    engineRef.current?.transition(isChecked);\n  }, [isChecked]);\n\n  useEffect(() => {\n    engineRef.current?.setDisabled(disabled);\n  }, [disabled]);\n\n  const toggle = () => {\n    if (disabled) return;\n    const next = !isChecked;\n    if (!isControlled) setInternal(next);\n    onCheckedChange?.(next);\n  };\n\n  return (\n    <button\n      ref={btnRef}\n      type=\"button\"\n      role=\"switch\"\n      aria-checked={isChecked}\n      aria-label={ariaLabel}\n      disabled={disabled}\n      onClick={toggle}\n      className={`relative h-[30px] w-[52px] shrink-0 overflow-hidden rounded-full border outline-none transition-[background-color,border-color,box-shadow] duration-200 focus-visible:ring-2 focus-visible:ring-ns-accent focus-visible:ring-offset-2 focus-visible:ring-offset-background motion-reduce:transition-none ${\n        isChecked\n          ? \"border-ns-accent/40 bg-ns-accent delay-150\"\n          : \"border-foreground/10 bg-border delay-0\"\n      } ${\n        disabled ? \"cursor-not-allowed\" : \"cursor-pointer hover:border-foreground/25\"\n      } ${className}`}\n    >\n      <span\n        ref={thumbRef}\n        aria-hidden\n        className=\"absolute left-[1px] top-[1px] h-[26px] w-[26px] rounded-full bg-foreground shadow-[0_1px_3px_rgba(0,0,0,0.35)] will-change-transform\"\n      />\n      <span\n        ref={glassRef}\n        aria-hidden\n        className=\"pointer-events-none absolute inset-0 rounded-full\"\n        style={{ opacity: 0 }}\n      />\n      <canvas\n        ref={canvasRef}\n        aria-hidden\n        className=\"pointer-events-none absolute inset-0 h-full w-full\"\n      />\n      <canvas\n        ref={sparkRef}\n        aria-hidden\n        className=\"pointer-events-none absolute inset-0 h-full w-full\"\n      />\n    </button>\n  );\n}\n",
      "type": "registry:ui",
      "target": "components/ui/switch-frost.tsx"
    }
  ],
  "cssVars": {
    "theme": {
      "color-ns-accent": "var(--ns-accent)"
    },
    "light": {
      "ns-accent": "#006bff"
    }
  },
  "meta": {
    "collection": "core",
    "tags": [
      "switch",
      "toggle",
      "canvas",
      "particles",
      "micro-interaction",
      "form"
    ],
    "instruction": "Build an iOS-style toggle (real <button role=switch aria-checked>, 52x30 track, 26px thumb, rounded-full) with three overlay layers above the thumb, all clipped to the track: a frosted-glass span (backdrop-filter blur+brightness, translucent white film) and two canvas 2D layers (crystal + sparkle, DPR clamp 2, explicit style width/height since canvas is a replaced element). On switch-to-OFF, mulberry32-seeded dendritic spines creep in from the right/top/bottom track edges (2px steps at 140 px/s, gentle curvature, some fingers stall early), throwing alternating side needles at ~60 deg every 2-3 steps; longer needles fork sub-barbs (depth 0-2, ~700ms total, deterministic per state-change so each freeze traces a fresh pattern). Crystals render in three passes so ice reads on both themes: wide translucent white haze, a cold shade stroke (definition on light surfaces and the thumb), and a bright cold-cast ice body, widths tapering 1.15 to 0.5px by depth, plus a rim-frost stroke around the pill edge; the glass layer fades in with growth so the thumb blurs beneath the ice. The sparkle canvas carries seeded grain dots and four-point glints; idle OFF glisten mutates only that layer's style opacity via a slow sine (no redraw), its period and phase re-seeded (3.4-4.6s, random offset) each freeze cycle so the breathing never locks into an exact metronome; paused offscreen via IntersectionObserver and when the document is hidden; rAF fully sleeps at end states. On switch-to-ON a spatial melt front sweeps left-to-right over 450ms, always a few px ahead of the thumb's leading edge: canvases clip to the frozen side, a white wet-gleam gradient marks the front, the glass layer recedes via clip-path inset, and 3-5 droplets detach when the front passes them (gravity 1200 px/s^2, 400ms lifetime, elongated body + white highlight); the thumb slide starts 120ms into the melt on a spring (k=170 s^-2, zeta=0.85, one small overshoot). All ink derives from --foreground/--background via getComputedStyle at mount (cold-cast toward blue: r*0.93, b*1.06+6) and re-derives on a MutationObserver watching documentElement class changes; theme is detected by background luminance. Accent focus-visible ring, token-relative hover border, disabled renders all layers at 40% opacity with no glisten, prefers-reduced-motion swaps to two static frames (full crystal OFF / clear ON) with a snapping thumb. Guard zero-size tracks before seeding; tear down every observer, listener, and rAF on unmount and free the crystal arrays."
  },
  "type": "registry:ui"
}