{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "switch-ascii-knife",
  "title": "Switch ASCII Knife",
  "description": "An accessible switch drawn entirely in box-drawing and block characters, in the register of a physical knife switch: the blade fills one cell at a time and the handle glyph spins through the throw.",
  "dependencies": [],
  "files": [
    {
      "path": "registry/core/switch-ascii-knife/component.tsx",
      "content": "\"use client\";\n\nimport { useEffect, useRef, useState } from \"react\";\n\n// ---------------------------------------------------------------------------\n// ThrowSwitch — an accessible switch drawn entirely in box-drawing and block\n// characters, in the register of a physical knife switch: `[━━━●──]` style,\n// where the filled run is the blade already thrown from its left-hand pivot\n// up to the handle knob, and the rest of the track is open (unthrown) rail.\n// The throw animates one cell of that run at a time (never a slide), and the\n// handle glyph itself cycles through a short rotation sequence while it is\n// mid-transit, settling to a solid circle the instant it lands on a cell.\n// A printed OFF / ON legend sits in the same font-mono row, flanking the\n// track, brightened on whichever side is currently active.\n// ---------------------------------------------------------------------------\n\nconst CELLS = 6; // interior track cells, excluding the brackets\nconst BEAT_MS = 70; // ms per cell the blade advances\nconst HEAVY = \"━\";\nconst LIGHT = \"─\";\nconst SETTLED = \"●\";\nconst ROT = [\"◐\", \"◓\", \"◑\", \"◒\"];\n\nexport interface ThrowSwitchProps {\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 */\n  \"aria-label\"?: string;\n}\n\nexport function ThrowSwitch({\n  checked,\n  defaultChecked = false,\n  onCheckedChange,\n  disabled = false,\n  className = \"\",\n  \"aria-label\": ariaLabel = \"Toggle\",\n}: ThrowSwitchProps) {\n  const cellRefs = useRef<(HTMLSpanElement | null)[]>([]);\n  const posRef = useRef(0); // current handle cell, 0..CELLS-1\n  const mountedRef = useRef(false);\n\n  const isControlled = checked !== undefined;\n  const [internal, setInternal] = useState(defaultChecked);\n  const isChecked = isControlled ? checked : internal;\n\n  const render = (pos: number, settled: boolean, rotFrame: string | null) => {\n    for (let i = 0; i < CELLS; i++) {\n      const el = cellRefs.current[i];\n      if (!el) continue;\n      if (i < pos) {\n        el.textContent = HEAVY;\n        el.className = \"text-foreground\";\n      } else if (i === pos) {\n        el.textContent = settled ? SETTLED : rotFrame ?? SETTLED;\n        el.className = \"text-foreground font-semibold\";\n      } else {\n        el.textContent = LIGHT;\n        el.className = \"text-ns-muted\";\n      }\n    }\n  };\n\n  useEffect(() => {\n    const target = isChecked ? CELLS - 1 : 0;\n\n    if (!mountedRef.current) {\n      mountedRef.current = true;\n      posRef.current = target;\n      render(target, true, null);\n      return;\n    }\n\n    if (posRef.current === target) {\n      render(target, true, null);\n      return;\n    }\n\n    const reduced = window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches;\n    if (reduced) {\n      posRef.current = target;\n      render(target, true, null);\n      return;\n    }\n\n    const start = posRef.current;\n    const dir = target > start ? 1 : -1;\n    const totalSteps = Math.abs(target - start);\n    const totalMs = totalSteps * BEAT_MS;\n    const startTime = performance.now();\n    let raf = 0;\n\n    const tick = (now: number) => {\n      const elapsed = now - startTime;\n      const stepFloat = Math.min(totalSteps, elapsed / BEAT_MS);\n      const stepIndex = Math.floor(stepFloat);\n      const currentPos = start + dir * stepIndex;\n      const arrived = stepIndex >= totalSteps;\n      const within = stepFloat - stepIndex;\n      const rotFrame = arrived ? null : ROT[Math.floor(within * ROT.length) % ROT.length]!;\n      render(currentPos, arrived, rotFrame);\n      if (elapsed < totalMs) {\n        raf = requestAnimationFrame(tick);\n      } else {\n        posRef.current = target;\n        render(target, true, null);\n      }\n    };\n    raf = requestAnimationFrame(tick);\n    return () => cancelAnimationFrame(raf);\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [isChecked]);\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      type=\"button\"\n      role=\"switch\"\n      aria-checked={isChecked}\n      aria-label={ariaLabel}\n      disabled={disabled}\n      onClick={toggle}\n      className={`group inline-flex items-center gap-2 rounded-sm px-1.5 py-0.5 font-mono text-sm tabular-nums transition-colors focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-ns-accent ${\n        disabled ? \"cursor-not-allowed opacity-40\" : \"cursor-pointer hover:bg-surface\"\n      } ${className}`}\n    >\n      <span aria-hidden=\"true\" className={isChecked ? \"text-ns-muted\" : \"text-foreground font-semibold\"}>\n        OFF\n      </span>\n      <span\n        aria-hidden=\"true\"\n        className=\"text-foreground transition-colors group-hover:text-ns-accent\"\n      >\n        [\n        {Array.from({ length: CELLS }).map((_, i) => (\n          <span\n            key={i}\n            ref={(el) => {\n              cellRefs.current[i] = el;\n            }}\n          />\n        ))}\n        ]\n      </span>\n      <span aria-hidden=\"true\" className={isChecked ? \"text-foreground font-semibold\" : \"text-ns-muted\"}>\n        ON\n      </span>\n    </button>\n  );\n}\n",
      "type": "registry:ui",
      "target": "components/ui/switch-ascii-knife.tsx"
    }
  ],
  "cssVars": {
    "theme": {
      "color-ns-muted": "var(--ns-muted)",
      "color-ns-accent": "var(--ns-accent)",
      "color-surface": "var(--surface)"
    },
    "light": {
      "ns-muted": "#4d4d4d",
      "ns-accent": "#006bff",
      "surface": "#fafafa"
    },
    "dark": {
      "ns-muted": "#8f8f8f",
      "surface": "#171717"
    }
  },
  "meta": {
    "collection": "core",
    "tags": [
      "switch",
      "toggle",
      "ascii",
      "mono",
      "accessibility"
    ],
    "instruction": "Build <ThrowSwitch checked? defaultChecked? onCheckedChange? disabled? className? aria-label?> — same controlled/uncontrolled contract as the repo's other switches. STRUCTURE: a single <button role=\"switch\" aria-checked aria-label> containing three font-mono spans in one row: a printed \"OFF\" legend, the bracketed track, a printed \"ON\" legend. Both legends are aria-hidden (the accessible name comes from aria-label) and brighten to text-foreground/font-semibold on whichever side is currently active, dimming to text-ns-muted on the other — no other hue is used to indicate state. TRACK: 6 interior cells wrapped in literal `[` `]` characters. The blade is modeled as thrown from a fixed left-hand pivot: cells with index less than the handle's current position render the heavy box-drawing rule (━, text-foreground) representing blade already thrown across that cell; the cell at the handle's position renders the handle glyph; cells after it render the light rule (─, text-ns-muted) representing untouched rail. OFF rests with the handle at position 0 (no heavy cells, all light); ON rests with the handle at position 5 (heavy cells 0-4, no light). THROW ANIMATION: on a state change, the handle steps one cell at a time toward the target position every 70ms (a plain timestamp-driven requestAnimationFrame loop, not CSS transition) — this is the character-quantised motion the whole ascii suite shares, not a slide. While a step is in flight the handle glyph itself is not the static solid circle; it cycles through a small rotation sequence (◐ ◓ ◑ ◒) driven by progress within the current 70ms beat, and the instant a step lands the glyph snaps back to the solid ● before starting (or finishing) the next step, so the settle reads as a distinct event separate from the spin. ACCESSIBILITY: role=\"switch\" with aria-checked kept in sync, a real <button> so Space/Enter activate it via native semantics with no extra keydown handler, and a visible focus ring built ONLY from focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-ns-accent with no base outline-none on the same element — pairing outline-none with focus-visible:outline-* sets --tw-outline-style to none permanently in Tailwind v4 and the ring never paints even though every class looks correct. prefers-reduced-motion snaps directly to the target position with no rotation frames, and the very first mount never animates regardless of the initial checked value — only a later toggle drives the throw."
  },
  "type": "registry:ui"
}