{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "spark-test-id",
  "title": "Spark Test ID",
  "description": "A file dropzone that identifies a specimen the way a machinist reads unknown steel: an idle grinding-wheel spark shower runs continuously, and the detected file type sets a characteristic spark signature of burst count, fork depth, and carrier length.",
  "dependencies": [],
  "files": [
    {
      "path": "registry/loud/spark-test-id/component.tsx",
      "content": "\"use client\";\n\nimport {\n  useCallback,\n  useEffect,\n  useId,\n  useLayoutEffect,\n  useRef,\n  useState,\n  type DragEvent,\n  type KeyboardEvent,\n} from \"react\";\n\n// ---------------------------------------------------------------------------\n// SparkTestId — a file dropzone that identifies a specimen the way a\n// machinist identifies unknown steel: SPARK TESTING. Held to a grinding\n// wheel, different alloys throw different SPARK STREAMS — carrier-line\n// length, how many times each burst forks, how dense the shower runs. The\n// wheel idles with a low continuous shower before anything is dropped, so\n// the canvas is never a dead frame. Dropping/selecting a file classifies it\n// into a coarse type family and swaps the shower's spawn rate, carrier\n// length, fork probability and fork depth to that family's characteristic\n// signature — the identification is legible from the spark pattern itself,\n// same as reading a real stream off a bench grinder. Discrete ballistic\n// particles with branching fork events, arced by gravity from one contact\n// point — distinct from file-upload-thermal's continuous rising wisps (no\n// discrete burst/fork topology) and file-upload-seal's vector spring\n// outline (no particles at all). Real sparks are hue-coded; this adaptation\n// carries intensity as LUMINANCE only, stated plainly below, not as a claim\n// of color fidelity. Direct-DOM canvas rAF, no React state in the hot path;\n// ink derived from CSS tokens at mount and re-derived on documentElement\n// class changes.\n// ---------------------------------------------------------------------------\n\ntype TypeFamily =\n  | \"idle\"\n  | \"image\"\n  | \"text\"\n  | \"avmedia\"\n  | \"archive\"\n  | \"unknown\";\n\ninterface SparkSignature {\n  label: string; // spoken/visible description of the metallurgical analogue\n  spawnRate: number; // primary sparks per second\n  speedMul: number; // carrier speed relative to base\n  carrierMul: number; // carrier-line length relative to base\n  forkProb: number; // probability a spark forks at its burst point\n  maxForkDepth: number; // how many generations of forking are possible\n  sprigMin: number;\n  sprigMax: number;\n  spreadDeg: number; // half-angle of the shower cone\n  intensity: number; // 0..1, luminance ceiling for this signature\n}\n\nconst SIGNATURES: Record<TypeFamily, SparkSignature> = {\n  idle: {\n    label: \"idle shower — wheel turning, no specimen held\",\n    spawnRate: 9,\n    speedMul: 0.82,\n    carrierMul: 0.75,\n    forkProb: 0.12,\n    maxForkDepth: 1,\n    sprigMin: 1,\n    sprigMax: 2,\n    spreadDeg: 22,\n    intensity: 0.5,\n  },\n  image: {\n    label: \"high-carbon-steel pattern — dense, repeatedly forking bursts\",\n    spawnRate: 30,\n    speedMul: 1.05,\n    carrierMul: 0.55,\n    forkProb: 0.62,\n    maxForkDepth: 2,\n    sprigMin: 2,\n    sprigMax: 4,\n    spreadDeg: 28,\n    intensity: 1,\n  },\n  text: {\n    label: \"mild-steel pattern — moderate forked bursts, medium carrier\",\n    spawnRate: 20,\n    speedMul: 1,\n    carrierMul: 0.85,\n    forkProb: 0.38,\n    maxForkDepth: 1,\n    sprigMin: 1,\n    sprigMax: 3,\n    spreadDeg: 22,\n    intensity: 0.85,\n  },\n  avmedia: {\n    label: \"wrought-iron pattern — long straight carrier lines, sparse forking\",\n    spawnRate: 12,\n    speedMul: 1.3,\n    carrierMul: 1.55,\n    forkProb: 0.08,\n    maxForkDepth: 1,\n    sprigMin: 1,\n    sprigMax: 1,\n    spreadDeg: 12,\n    intensity: 0.72,\n  },\n  archive: {\n    label: \"cast-iron pattern — short, dense, low-lying streams\",\n    spawnRate: 34,\n    speedMul: 0.58,\n    carrierMul: 0.32,\n    forkProb: 0.1,\n    maxForkDepth: 1,\n    sprigMin: 1,\n    sprigMax: 1,\n    spreadDeg: 36,\n    intensity: 0.6,\n  },\n  unknown: {\n    label: \"unclassified specimen — mixed baseline stream\",\n    spawnRate: 16,\n    speedMul: 0.9,\n    carrierMul: 0.9,\n    forkProb: 0.25,\n    maxForkDepth: 1,\n    sprigMin: 1,\n    sprigMax: 2,\n    spreadDeg: 26,\n    intensity: 0.68,\n  },\n};\n\nconst MAX_PARTICLES = 260;\n\ninterface Spark {\n  x: number;\n  y: number;\n  px: number; // previous frame position, for the trail segment\n  py: number;\n  vx: number;\n  vy: number;\n  age: number;\n  life: number;\n  forkAt: number; // fraction of life at which a fork can trigger\n  forked: boolean;\n  depth: number;\n  intensity: number;\n}\n\nfunction classify(name: string, type: string): TypeFamily {\n  const lower = name.toLowerCase();\n  if (type.startsWith(\"image/\") || /\\.(png|jpe?g|gif|webp|svg|bmp|avif)$/.test(lower)) {\n    return \"image\";\n  }\n  if (\n    type.startsWith(\"video/\") ||\n    type.startsWith(\"audio/\") ||\n    /\\.(mp4|mov|webm|mkv|mp3|wav|flac|ogg|m4a)$/.test(lower)\n  ) {\n    return \"avmedia\";\n  }\n  if (\n    /\\.(zip|rar|7z|tar|gz|tgz|bz2|dmg|exe|iso)$/.test(lower) ||\n    type === \"application/zip\" ||\n    type === \"application/x-tar\" ||\n    type === \"application/gzip\"\n  ) {\n    return \"archive\";\n  }\n  if (\n    type.startsWith(\"text/\") ||\n    type === \"application/json\" ||\n    type === \"application/pdf\" ||\n    /\\.(txt|md|json|js|ts|tsx|jsx|css|html|csv|log|pdf|docx?|xml|yml|yaml)$/.test(lower)\n  ) {\n    return \"text\";\n  }\n  return \"unknown\";\n}\n\nfunction familyDisplayName(f: TypeFamily): string {\n  switch (f) {\n    case \"image\":\n      return \"Image\";\n    case \"avmedia\":\n      return \"Audio/video\";\n    case \"archive\":\n      return \"Archive/binary\";\n    case \"text\":\n      return \"Text/document\";\n    case \"unknown\":\n      return \"Unrecognized\";\n    default:\n      return \"Idle\";\n  }\n}\n\nfunction formatSize(bytes: number): string {\n  if (bytes >= 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;\n  if (bytes >= 1024) return `${Math.round(bytes / 1024)} KB`;\n  return `${bytes} B`;\n}\n\nfunction mulberry32(seed: number) {\n  let a = seed;\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\nconst STATIC_TIME = 1.35; // s — reduced-motion freeze: a long-exposure window\n// with several carrier lines and at least one fully-formed fork visible,\n// never a t0 frame (t0 for the idle signature is a bare shower start).\n\nexport interface SparkTestIdFile {\n  id: string;\n  name: string;\n  size: number;\n  type: string;\n  family: TypeFamily;\n}\n\nexport function SparkTestId({\n  accept = [],\n  maxSizeBytes,\n  onFileChange,\n  className = \"\",\n  \"aria-label\": ariaLabel = \"Upload a file to identify its type\",\n}: {\n  /** allowed extensions/mimes (\".png\", \"image/*\"); empty = any */\n  accept?: string[];\n  /** rejects a file larger than this. No default — unlimited. */\n  maxSizeBytes?: number;\n  /** called with the identified file, or null when cleared */\n  onFileChange?: (file: SparkTestIdFile | null) => void;\n  /** extra classes merged onto the rendered root element */\n  className?: string;\n  /** accessible name for the dropzone control */\n  \"aria-label\"?: string;\n}) {\n  const rootRef = useRef<HTMLDivElement>(null);\n  const zoneRef = useRef<HTMLDivElement>(null);\n  const canvasRef = useRef<HTMLCanvasElement>(null);\n  const inputRef = useRef<HTMLInputElement>(null);\n  const signatureRef = useRef<TypeFamily>(\"idle\");\n  const reducedRef = useRef(false);\n\n  const [file, setFile] = useState<SparkTestIdFile | null>(null);\n  const [isOver, setIsOver] = useState(false);\n  const [announce, setAnnounce] = useState(\"Wheel idling. No specimen held.\");\n  const hintId = useId();\n  const liveId = useId();\n\n  const acceptFile = useCallback(\n    (f: File): { ok: true; family: TypeFamily } | { ok: false; reason: string } => {\n      if (maxSizeBytes != null && f.size > maxSizeBytes) {\n        return { ok: false, reason: `exceeds size limit (${formatSize(maxSizeBytes)})` };\n      }\n      if (accept.length > 0) {\n        const lower = f.name.toLowerCase();\n        const matches = accept.some((a) => {\n          if (a.startsWith(\".\")) return lower.endsWith(a.toLowerCase());\n          if (a.endsWith(\"/*\")) return f.type.startsWith(a.slice(0, -1));\n          return f.type === a;\n        });\n        if (!matches) return { ok: false, reason: \"type not accepted\" };\n      }\n      return { ok: true, family: classify(f.name, f.type) };\n    },\n    [accept, maxSizeBytes]\n  );\n\n  const handleFiles = useCallback(\n    (list: FileList | null) => {\n      if (!list || list.length === 0) return;\n      const f = list[0];\n      const result = acceptFile(f);\n      if (!result.ok) {\n        setAnnounce(`Specimen rejected: ${f.name}, ${result.reason}.`);\n        return;\n      }\n      const next: SparkTestIdFile = {\n        id: `spk-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`,\n        name: f.name,\n        size: f.size,\n        type: f.type || \"unknown\",\n        family: result.family,\n      };\n      setFile(next);\n      signatureRef.current = result.family;\n      const sig = SIGNATURES[result.family];\n      setAnnounce(\n        `Detected type: ${familyDisplayName(result.family)}, ${f.type || \"unknown MIME type\"}. Spark signature: ${sig.label}.`\n      );\n      onFileChange?.(next);\n    },\n    [acceptFile, onFileChange]\n  );\n\n  const clearFile = useCallback(() => {\n    setFile(null);\n    signatureRef.current = \"idle\";\n    setAnnounce(\"Specimen cleared. Wheel idling. No specimen held.\");\n    onFileChange?.(null);\n    if (inputRef.current) inputRef.current.value = \"\";\n  }, [onFileChange]);\n\n  const onDrop = useCallback(\n    (e: DragEvent<HTMLDivElement>) => {\n      e.preventDefault();\n      setIsOver(false);\n      handleFiles(e.dataTransfer.files);\n    },\n    [handleFiles]\n  );\n\n  const onKeyDown = useCallback((e: KeyboardEvent<HTMLDivElement>) => {\n    if (e.key === \"Enter\" || e.key === \" \") {\n      e.preventDefault();\n      inputRef.current?.click();\n    }\n  }, []);\n\n  // -- engine: spark shower canvas, direct-DOM only --------------------------\n  useLayoutEffect(() => {\n    const root = rootRef.current;\n    const zone = zoneRef.current;\n    const canvas = canvasRef.current;\n    if (!root || !zone || !canvas) return;\n    const ctx = canvas.getContext(\"2d\");\n    if (!ctx) return;\n\n    const reduced = window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches;\n    reducedRef.current = reduced;\n\n    // token ink — read at mount, re-derived live on theme class change.\n    // fields start empty and are assigned unconditionally before any paint.\n    let fg = \"\";\n    let muted = \"\";\n    let bg = \"\";\n    let border = \"\";\n    const deriveTokens = () => {\n      const cs = getComputedStyle(document.documentElement);\n      fg = cs.getPropertyValue(\"--foreground\").trim();\n      muted = cs.getPropertyValue(\"--ns-muted\").trim();\n      bg = cs.getPropertyValue(\"--background\").trim();\n      border = cs.getPropertyValue(\"--border\").trim();\n    };\n    deriveTokens();\n    const mo = new MutationObserver(deriveTokens);\n    mo.observe(document.documentElement, { attributes: true, attributeFilter: [\"class\"] });\n\n    let w = 0;\n    let h = 0;\n    let dpr = 1;\n    let sized = false;\n    const DPR_CAP = 2;\n\n    const resize = () => {\n      const rect = zone.getBoundingClientRect();\n      w = Math.max(1, rect.width);\n      h = Math.max(1, rect.height);\n      dpr = Math.min(DPR_CAP, window.devicePixelRatio || 1);\n      canvas.width = Math.round(w * dpr);\n      canvas.height = Math.round(h * dpr);\n      canvas.style.width = `${w}px`;\n      canvas.style.height = `${h}px`;\n      ctx.setTransform(dpr, 0, 0, dpr, 0, 0);\n      sized = w > 1 && h > 1;\n    };\n    resize();\n    const ro = new ResizeObserver(resize);\n    ro.observe(zone);\n\n    let visible = true;\n    const io = new IntersectionObserver(\n      (entries) => {\n        for (const entry of entries) visible = entry.isIntersecting;\n      },\n      { threshold: 0 }\n    );\n    io.observe(zone);\n    let pageVisible = document.visibilityState === \"visible\";\n    const onVis = () => {\n      pageVisible = document.visibilityState === \"visible\";\n    };\n    document.addEventListener(\"visibilitychange\", onVis);\n\n    let particles: Spark[] = [];\n    let spawnAcc = 0;\n\n    // origin: the \"wheel contact point\" — centre-left rather than the\n    // corner, scaled to the container's smaller dimension so density/length\n    // read at any preview size, per the scale rule. Sitting mid-height\n    // leaves the ballistic cone room to arc up through the top of the\n    // frame and fall back down without colliding with the label block\n    // pinned to the bottom of the dropzone.\n    const scaleDim = () => Math.min(w, h);\n    const originX = () => w * 0.22;\n    const originY = () => h * 0.52;\n    const baseAngle = -55; // degrees, up-and-right cone axis\n    const gravity = () => scaleDim() * 1.7; // px/s^2, arcs the stream down\n    const baseSpeed = () => scaleDim() * 1.55; // px/s\n    const baseLife = 0.78; // s, before signature carrierMul\n\n    const spawnSpark = (\n      arr: Spark[],\n      rng: () => number,\n      sig: SparkSignature,\n      depth: number,\n      x: number,\n      y: number,\n      angleDeg: number,\n      speedScale: number\n    ) => {\n      if (arr.length >= MAX_PARTICLES) return;\n      const jitter = (rng() - 0.5) * 2 * sig.spreadDeg;\n      const angle = ((angleDeg + jitter) * Math.PI) / 180;\n      const speed = baseSpeed() * sig.speedMul * speedScale * (0.85 + rng() * 0.3);\n      const life =\n        depth === 0\n          ? baseLife * sig.carrierMul * (0.8 + rng() * 0.4)\n          : (0.12 + rng() * 0.1) * (1 + sig.carrierMul * 0.15); // short terminal sprig\n      arr.push({\n        x,\n        y,\n        px: x,\n        py: y,\n        vx: Math.cos(angle) * speed,\n        vy: Math.sin(angle) * speed,\n        age: 0,\n        life,\n        forkAt: 0.35 + rng() * 0.3,\n        forked: depth >= sig.maxForkDepth,\n        depth,\n        intensity: sig.intensity * (depth === 0 ? 1 : 0.85),\n      });\n    };\n\n    const drawWheel = () => {\n      const ox = originX();\n      const oy = originY();\n      const r = scaleDim() * 0.09;\n      ctx.save();\n      ctx.strokeStyle = muted;\n      ctx.globalAlpha = 0.55;\n      ctx.lineWidth = Math.max(1, scaleDim() * 0.008);\n      ctx.beginPath();\n      ctx.arc(ox - r * 0.7, oy, r, 0, Math.PI * 2);\n      ctx.stroke();\n      ctx.globalAlpha = 0.35;\n      ctx.beginPath();\n      ctx.arc(ox - r * 0.7, oy, r * 0.35, 0, Math.PI * 2);\n      ctx.stroke();\n      ctx.restore();\n    };\n\n    const stepSpark = (s: Spark, dt: number, sig: SparkSignature, rng: () => number, out: Spark[]) => {\n      s.px = s.x;\n      s.py = s.y;\n      s.vy += gravity() * dt;\n      s.x += s.vx * dt;\n      s.y += s.vy * dt;\n      s.age += dt;\n      if (!s.forked && s.age >= s.life * s.forkAt && s.depth < sig.maxForkDepth) {\n        s.forked = true;\n        if (rng() < sig.forkProb) {\n          const count = sig.sprigMin + Math.floor(rng() * (sig.sprigMax - sig.sprigMin + 1));\n          const parentAngle = (Math.atan2(s.vy, s.vx) * 180) / Math.PI;\n          for (let i = 0; i < count; i++) {\n            const branch = parentAngle + (rng() - 0.5) * 70;\n            spawnSpark(out, rng, sig, s.depth + 1, s.x, s.y, branch, 0.55 + rng() * 0.2);\n          }\n        }\n      }\n    };\n\n    const drawSpark = (s: Spark) => {\n      const a = Math.max(0, 1 - s.age / s.life);\n      if (a <= 0) return;\n      // glow halo in muted, bright core in foreground — luminance-only \"hot\"\n      // read, no hue; per the monochrome adaptation this stands in for the\n      // real spark-stream color signal.\n      ctx.strokeStyle = muted;\n      ctx.globalAlpha = a * s.intensity * 0.5;\n      ctx.lineWidth = Math.max(1, scaleDim() * 0.006);\n      ctx.beginPath();\n      ctx.moveTo(s.px, s.py);\n      ctx.lineTo(s.x, s.y);\n      ctx.stroke();\n      ctx.strokeStyle = fg;\n      ctx.globalAlpha = a * s.intensity;\n      ctx.lineWidth = Math.max(0.75, scaleDim() * 0.0032);\n      ctx.beginPath();\n      ctx.moveTo(s.px, s.py);\n      ctx.lineTo(s.x, s.y);\n      ctx.stroke();\n    };\n\n    // ---- reduced motion: one deterministic long-exposure freeze frame -----\n    const renderStaticFrame = () => {\n      if (!sized) return;\n      const sig = SIGNATURES[signatureRef.current];\n      const rng = mulberry32(signatureRef.current.length * 7919 + 13);\n      ctx.clearRect(0, 0, w, h);\n      ctx.fillStyle = bg;\n      ctx.fillRect(0, 0, w, h);\n      drawWheel();\n      let arr: Spark[] = [];\n      let acc = 0;\n      const dt = 1 / 60;\n      let t = 0;\n      while (t < STATIC_TIME) {\n        acc += sig.spawnRate * dt;\n        while (acc >= 1) {\n          spawnSpark(arr, rng, sig, 0, originX(), originY(), baseAngle, 1);\n          acc -= 1;\n        }\n        const spawned: Spark[] = [];\n        for (const s of arr) {\n          stepSpark(s, dt, sig, rng, spawned);\n        }\n        arr = arr.concat(spawned);\n        t += dt;\n      }\n      // paint every particle's final segment plus a short fading trail so\n      // carrier lines and forks are both legible in one static composite —\n      // this is a long exposure, not a literal single instant.\n      for (const s of arr) {\n        if (s.age >= s.life * 1.4) continue; // long-dead, would just be noise\n        drawSpark(s);\n      }\n      ctx.globalAlpha = 1;\n      if (border) {\n        // legitimate --border use: the dropzone's own outline, drawn by CSS,\n        // not touched here — canvas leaves the frame edge alone.\n      }\n    };\n\n    let raf = 0;\n    let last = 0;\n    const loop = (now: number) => {\n      raf = requestAnimationFrame(loop);\n      if (!sized || !visible || !pageVisible) {\n        last = now;\n        return;\n      }\n      const dt = last ? Math.min(0.05, (now - last) / 1000) : 1 / 60;\n      last = now;\n      const sig = SIGNATURES[signatureRef.current];\n\n      ctx.clearRect(0, 0, w, h);\n      ctx.fillStyle = bg;\n      ctx.fillRect(0, 0, w, h);\n      drawWheel();\n\n      spawnAcc += sig.spawnRate * dt;\n      const rng = Math.random;\n      while (spawnAcc >= 1 && particles.length < MAX_PARTICLES) {\n        spawnSpark(particles, rng, sig, 0, originX(), originY(), baseAngle, 1);\n        spawnAcc -= 1;\n      }\n\n      const spawned: Spark[] = [];\n      const alive: Spark[] = [];\n      for (const s of particles) {\n        stepSpark(s, dt, sig, rng, spawned);\n        if (s.age < s.life && s.y < h + 40) alive.push(s);\n      }\n      particles = alive.concat(spawned).slice(-MAX_PARTICLES);\n\n      for (const s of particles) drawSpark(s);\n      ctx.globalAlpha = 1;\n    };\n\n    if (reduced) {\n      renderStaticFrame();\n    } else {\n      raf = requestAnimationFrame(loop);\n    }\n\n    // re-render the static frame if the signature changes while reduced\n    // motion is active, or if tokens flip theme — both need a fresh paint\n    // since there is no running loop to pick it up on its own.\n    const staticRerender = () => {\n      if (reducedRef.current) renderStaticFrame();\n    };\n    const moForStatic = new MutationObserver(() => {\n      deriveTokens();\n      staticRerender();\n    });\n    moForStatic.observe(document.documentElement, { attributes: true, attributeFilter: [\"class\"] });\n\n    return () => {\n      cancelAnimationFrame(raf);\n      ro.disconnect();\n      io.disconnect();\n      mo.disconnect();\n      moForStatic.disconnect();\n      document.removeEventListener(\"visibilitychange\", onVis);\n    };\n  }, [file?.id]);\n\n  return (\n    <div ref={rootRef} className={`relative w-full ${className}`}>\n      <div\n        ref={zoneRef}\n        role=\"button\"\n        tabIndex={0}\n        aria-label={ariaLabel}\n        aria-describedby={hintId}\n        onClick={() => inputRef.current?.click()}\n        onKeyDown={onKeyDown}\n        onDragOver={(e) => {\n          e.preventDefault();\n          setIsOver(true);\n        }}\n        onDragLeave={() => setIsOver(false)}\n        onDrop={onDrop}\n        className={`relative isolate flex min-h-56 w-full flex-col items-center justify-end overflow-hidden rounded-md border outline-none transition-colors ${\n          isOver ? \"border-ns-accent\" : \"border-border\"\n        } focus-visible:ring-2 focus-visible:ring-ns-accent/30`}\n      >\n        <canvas ref={canvasRef} className=\"absolute inset-0 h-full w-full\" aria-hidden=\"true\" />\n        <div className=\"relative z-10 flex w-full flex-col items-center gap-1 px-4 pb-4 pt-10\">\n          <p className=\"font-mono text-xs uppercase tracking-[0.18em] text-ns-muted\">\n            {file ? file.name : \"Drop a file on the wheel\"}\n          </p>\n          <p className=\"text-xs text-ns-muted\">\n            {file\n              ? `${familyDisplayName(file.family)} · ${formatSize(file.size)}`\n              : \"or press Enter to browse\"}\n          </p>\n        </div>\n      </div>\n\n      <input\n        ref={inputRef}\n        type=\"file\"\n        accept={accept.join(\",\") || undefined}\n        onChange={(e) => handleFiles(e.target.files)}\n        className=\"sr-only\"\n        tabIndex={-1}\n        aria-hidden=\"true\"\n      />\n\n      <div className=\"mt-3 flex items-center justify-between gap-3\">\n        <p id={hintId} className=\"text-xs text-ns-muted\">\n          {accept.length > 0 ? `Accepts ${accept.join(\", \")}` : \"Accepts any file type\"}\n          {maxSizeBytes != null ? ` · up to ${formatSize(maxSizeBytes)}` : \"\"}\n        </p>\n        {file && (\n          <button\n            type=\"button\"\n            onClick={clearFile}\n            className=\"text-xs text-ns-muted underline decoration-border underline-offset-2 outline-none transition-colors hover:text-foreground focus-visible:ring-2 focus-visible:ring-ns-accent/30\"\n          >\n            Clear\n          </button>\n        )}\n      </div>\n\n      <div id={liveId} role=\"status\" aria-live=\"polite\" className=\"sr-only\">\n        {announce}\n      </div>\n    </div>\n  );\n}\n",
      "type": "registry:ui",
      "target": "components/ui/spark-test-id.tsx"
    }
  ],
  "cssVars": {
    "theme": {
      "color-ns-muted": "var(--ns-muted)",
      "color-ns-accent": "var(--ns-accent)"
    },
    "light": {
      "ns-muted": "#4d4d4d",
      "ns-accent": "#006bff"
    },
    "dark": {
      "ns-muted": "#8f8f8f"
    }
  },
  "meta": {
    "collection": "loud",
    "tags": [
      "dropzone",
      "file-upload",
      "form",
      "canvas",
      "particles",
      "physics",
      "input"
    ],
    "instruction": "Build a file dropzone modeled on real shop-floor SPARK TESTING — the documented practice machinists use to identify unknown steel and alloy grades by holding a specimen against a grinding wheel and reading the SPARK STREAM: carrier-line length, how many times a burst forks, and shower density. A canvas layer over the zone runs a continuous ballistic-particle spark shower from a fixed 'wheel contact point' near the lower-left, arced by gravity — this must run unconditionally from mount, a low baseline idle shower, so the component is never a dead frame before any file is dropped. On drop or select, classify the file (image / text-or-document / audio-video / archive-or-binary / unclassified) and swap the shower's spawn rate, carrier length, fork probability and max fork depth to that family's signature: image files throw a high-carbon-steel pattern (dense, repeatedly forking bursts, short carrier), audio/video throws a wrought-iron pattern (long straight carrier lines, sparse forking), archives/binaries throw a cast-iron pattern (short, dense, low-lying streams), text/documents sit in between. Each spark is a discrete particle with its own fork point along its life; forking spawns 1-4 short terminal sprig particles at a randomized branch angle, and only the highest-fork-depth family allows a sprig to fork again — this is discrete ballistic particle physics with branching topology, categorically distinct from a continuous rising-wisp field or a vector spring outline. ADAPTATION, STATED PLAINLY: real spark streams are read partly by hue (color is part of how a machinist identifies the grade); this component has no hue to spend and maps spark intensity to LUMINANCE only, a stated adaptation, not a claim of color fidelity. Sparks render in two token passes: a soft --ns-muted halo stroke under a --foreground core stroke, both scaled to the container's smaller dimension so the pattern reads at small preview-card sizes, not just full-bleed. The dropzone itself is a real control: a native file input reachable by keyboard (Enter/Space opens the picker), a genuine accessible name, drag-and-drop as an enhancement over that same input rather than the only path, and the detected type is announced through a polite aria-live status line as plain text (family, MIME type, signature name) — never conveyed by the spark pattern alone, since that reading is purely visual. prefers-reduced-motion freezes on a deterministic long-exposure composite of the active signature's first ~1.35s (multiple carrier lines and at least one completed fork visible together), not a t0 frame, and re-renders that same freeze whenever the signature or theme changes since no loop is running to pick it up. Canvas: DPR-capped backing store, ResizeObserver on the zone, paused via IntersectionObserver and visibilitychange, particle count capped at 260, all ink read via getComputedStyle at mount and re-derived on a MutationObserver watching documentElement's class. Props: accept (string[]), maxSizeBytes, onFileChange, className, aria-label."
  },
  "type": "registry:ui"
}