Skip to main content

ns-ui / loud

Spark Test ID

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.

Usage

registry/loud/spark-test-id/demo.tsx
"use client";

import { useState } from "react";
import { SparkTestId, type SparkTestIdFile } from "./component";

export default function SparkTestIdDemo() {
  const [file, setFile] = useState<SparkTestIdFile | null>(null);

  return (
    <div className="flex min-h-screen flex-col items-center justify-center gap-8 px-6 py-16">
      <p className="font-mono text-xs uppercase tracking-[0.2em] text-ns-muted">
        ns-ui / spark-test-id — drop a file, read its spark stream
      </p>

      <div className="w-full max-w-lg rounded-md border border-border bg-surface p-6">
        <h2 className="text-sm font-semibold text-foreground">Identify a specimen</h2>
        <p className="mt-1 mb-5 text-sm text-ns-muted">
          The wheel runs a low idle shower at rest. Drop or select a file and its
          detected type sets a characteristic spark signature — burst count, fork
          depth, carrier length — the same way a machinist reads an unknown steel
          grade off a bench grinder.
        </p>
        <SparkTestId
          aria-label="Upload a file to identify its type"
          accept={[".png", ".jpg", ".pdf", ".zip", ".mp4", ".txt", ".json"]}
          maxSizeBytes={16 * 1024 * 1024}
          onFileChange={setFile}
        />
        <p className="mt-4 font-mono text-xs text-ns-muted">
          {file ? `last identified: ${file.name} (${file.type || "unknown"})` : "no specimen yet"}
        </p>
      </div>
    </div>
  );
}

Component

registry/loud/spark-test-id/component.tsx
"use client";

import {
  useCallback,
  useEffect,
  useId,
  useLayoutEffect,
  useRef,
  useState,
  type DragEvent,
  type KeyboardEvent,
} from "react";

// ---------------------------------------------------------------------------
// SparkTestId — a file dropzone that identifies a specimen the way a
// machinist identifies unknown steel: SPARK TESTING. Held to a grinding
// wheel, different alloys throw different SPARK STREAMS — carrier-line
// length, how many times each burst forks, how dense the shower runs. The
// wheel idles with a low continuous shower before anything is dropped, so
// the canvas is never a dead frame. Dropping/selecting a file classifies it
// into a coarse type family and swaps the shower's spawn rate, carrier
// length, fork probability and fork depth to that family's characteristic
// signature — the identification is legible from the spark pattern itself,
// same as reading a real stream off a bench grinder. Discrete ballistic
// particles with branching fork events, arced by gravity from one contact
// point — distinct from file-upload-thermal's continuous rising wisps (no
// discrete burst/fork topology) and file-upload-seal's vector spring
// outline (no particles at all). Real sparks are hue-coded; this adaptation
// carries intensity as LUMINANCE only, stated plainly below, not as a claim
// of color fidelity. Direct-DOM canvas rAF, no React state in the hot path;
// ink derived from CSS tokens at mount and re-derived on documentElement
// class changes.
// ---------------------------------------------------------------------------

type TypeFamily =
  | "idle"
  | "image"
  | "text"
  | "avmedia"
  | "archive"
  | "unknown";

interface SparkSignature {
  label: string; // spoken/visible description of the metallurgical analogue
  spawnRate: number; // primary sparks per second
  speedMul: number; // carrier speed relative to base
  carrierMul: number; // carrier-line length relative to base
  forkProb: number; // probability a spark forks at its burst point
  maxForkDepth: number; // how many generations of forking are possible
  sprigMin: number;
  sprigMax: number;
  spreadDeg: number; // half-angle of the shower cone
  intensity: number; // 0..1, luminance ceiling for this signature
}

const SIGNATURES: Record<TypeFamily, SparkSignature> = {
  idle: {
    label: "idle shower — wheel turning, no specimen held",
    spawnRate: 9,
    speedMul: 0.82,
    carrierMul: 0.75,
    forkProb: 0.12,
    maxForkDepth: 1,
    sprigMin: 1,
    sprigMax: 2,
    spreadDeg: 22,
    intensity: 0.5,
  },
  image: {
    label: "high-carbon-steel pattern — dense, repeatedly forking bursts",
    spawnRate: 30,
    speedMul: 1.05,
    carrierMul: 0.55,
    forkProb: 0.62,
    maxForkDepth: 2,
    sprigMin: 2,
    sprigMax: 4,
    spreadDeg: 28,
    intensity: 1,
  },
  text: {
    label: "mild-steel pattern — moderate forked bursts, medium carrier",
    spawnRate: 20,
    speedMul: 1,
    carrierMul: 0.85,
    forkProb: 0.38,
    maxForkDepth: 1,
    sprigMin: 1,
    sprigMax: 3,
    spreadDeg: 22,
    intensity: 0.85,
  },
  avmedia: {
    label: "wrought-iron pattern — long straight carrier lines, sparse forking",
    spawnRate: 12,
    speedMul: 1.3,
    carrierMul: 1.55,
    forkProb: 0.08,
    maxForkDepth: 1,
    sprigMin: 1,
    sprigMax: 1,
    spreadDeg: 12,
    intensity: 0.72,
  },
  archive: {
    label: "cast-iron pattern — short, dense, low-lying streams",
    spawnRate: 34,
    speedMul: 0.58,
    carrierMul: 0.32,
    forkProb: 0.1,
    maxForkDepth: 1,
    sprigMin: 1,
    sprigMax: 1,
    spreadDeg: 36,
    intensity: 0.6,
  },
  unknown: {
    label: "unclassified specimen — mixed baseline stream",
    spawnRate: 16,
    speedMul: 0.9,
    carrierMul: 0.9,
    forkProb: 0.25,
    maxForkDepth: 1,
    sprigMin: 1,
    sprigMax: 2,
    spreadDeg: 26,
    intensity: 0.68,
  },
};

const MAX_PARTICLES = 260;

interface Spark {
  x: number;
  y: number;
  px: number; // previous frame position, for the trail segment
  py: number;
  vx: number;
  vy: number;
  age: number;
  life: number;
  forkAt: number; // fraction of life at which a fork can trigger
  forked: boolean;
  depth: number;
  intensity: number;
}

function classify(name: string, type: string): TypeFamily {
  const lower = name.toLowerCase();
  if (type.startsWith("image/") || /\.(png|jpe?g|gif|webp|svg|bmp|avif)$/.test(lower)) {
    return "image";
  }
  if (
    type.startsWith("video/") ||
    type.startsWith("audio/") ||
    /\.(mp4|mov|webm|mkv|mp3|wav|flac|ogg|m4a)$/.test(lower)
  ) {
    return "avmedia";
  }
  if (
    /\.(zip|rar|7z|tar|gz|tgz|bz2|dmg|exe|iso)$/.test(lower) ||
    type === "application/zip" ||
    type === "application/x-tar" ||
    type === "application/gzip"
  ) {
    return "archive";
  }
  if (
    type.startsWith("text/") ||
    type === "application/json" ||
    type === "application/pdf" ||
    /\.(txt|md|json|js|ts|tsx|jsx|css|html|csv|log|pdf|docx?|xml|yml|yaml)$/.test(lower)
  ) {
    return "text";
  }
  return "unknown";
}

function familyDisplayName(f: TypeFamily): string {
  switch (f) {
    case "image":
      return "Image";
    case "avmedia":
      return "Audio/video";
    case "archive":
      return "Archive/binary";
    case "text":
      return "Text/document";
    case "unknown":
      return "Unrecognized";
    default:
      return "Idle";
  }
}

function formatSize(bytes: number): string {
  if (bytes >= 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
  if (bytes >= 1024) return `${Math.round(bytes / 1024)} KB`;
  return `${bytes} B`;
}

function mulberry32(seed: number) {
  let a = seed;
  return () => {
    a |= 0;
    a = (a + 0x6d2b79f5) | 0;
    let t = Math.imul(a ^ (a >>> 15), 1 | a);
    t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
  };
}

const STATIC_TIME = 1.35; // s — reduced-motion freeze: a long-exposure window
// with several carrier lines and at least one fully-formed fork visible,
// never a t0 frame (t0 for the idle signature is a bare shower start).

export interface SparkTestIdFile {
  id: string;
  name: string;
  size: number;
  type: string;
  family: TypeFamily;
}

export function SparkTestId({
  accept = [],
  maxSizeBytes,
  onFileChange,
  className = "",
  "aria-label": ariaLabel = "Upload a file to identify its type",
}: {
  /** allowed extensions/mimes (".png", "image/*"); empty = any */
  accept?: string[];
  /** rejects a file larger than this. No default — unlimited. */
  maxSizeBytes?: number;
  /** called with the identified file, or null when cleared */
  onFileChange?: (file: SparkTestIdFile | null) => void;
  /** extra classes merged onto the rendered root element */
  className?: string;
  /** accessible name for the dropzone control */
  "aria-label"?: string;
}) {
  const rootRef = useRef<HTMLDivElement>(null);
  const zoneRef = useRef<HTMLDivElement>(null);
  const canvasRef = useRef<HTMLCanvasElement>(null);
  const inputRef = useRef<HTMLInputElement>(null);
  const signatureRef = useRef<TypeFamily>("idle");
  const reducedRef = useRef(false);

  const [file, setFile] = useState<SparkTestIdFile | null>(null);
  const [isOver, setIsOver] = useState(false);
  const [announce, setAnnounce] = useState("Wheel idling. No specimen held.");
  const hintId = useId();
  const liveId = useId();

  const acceptFile = useCallback(
    (f: File): { ok: true; family: TypeFamily } | { ok: false; reason: string } => {
      if (maxSizeBytes != null && f.size > maxSizeBytes) {
        return { ok: false, reason: `exceeds size limit (${formatSize(maxSizeBytes)})` };
      }
      if (accept.length > 0) {
        const lower = f.name.toLowerCase();
        const matches = accept.some((a) => {
          if (a.startsWith(".")) return lower.endsWith(a.toLowerCase());
          if (a.endsWith("/*")) return f.type.startsWith(a.slice(0, -1));
          return f.type === a;
        });
        if (!matches) return { ok: false, reason: "type not accepted" };
      }
      return { ok: true, family: classify(f.name, f.type) };
    },
    [accept, maxSizeBytes]
  );

  const handleFiles = useCallback(
    (list: FileList | null) => {
      if (!list || list.length === 0) return;
      const f = list[0];
      const result = acceptFile(f);
      if (!result.ok) {
        setAnnounce(`Specimen rejected: ${f.name}, ${result.reason}.`);
        return;
      }
      const next: SparkTestIdFile = {
        id: `spk-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`,
        name: f.name,
        size: f.size,
        type: f.type || "unknown",
        family: result.family,
      };
      setFile(next);
      signatureRef.current = result.family;
      const sig = SIGNATURES[result.family];
      setAnnounce(
        `Detected type: ${familyDisplayName(result.family)}, ${f.type || "unknown MIME type"}. Spark signature: ${sig.label}.`
      );
      onFileChange?.(next);
    },
    [acceptFile, onFileChange]
  );

  const clearFile = useCallback(() => {
    setFile(null);
    signatureRef.current = "idle";
    setAnnounce("Specimen cleared. Wheel idling. No specimen held.");
    onFileChange?.(null);
    if (inputRef.current) inputRef.current.value = "";
  }, [onFileChange]);

  const onDrop = useCallback(
    (e: DragEvent<HTMLDivElement>) => {
      e.preventDefault();
      setIsOver(false);
      handleFiles(e.dataTransfer.files);
    },
    [handleFiles]
  );

  const onKeyDown = useCallback((e: KeyboardEvent<HTMLDivElement>) => {
    if (e.key === "Enter" || e.key === " ") {
      e.preventDefault();
      inputRef.current?.click();
    }
  }, []);

  // -- engine: spark shower canvas, direct-DOM only --------------------------
  useLayoutEffect(() => {
    const root = rootRef.current;
    const zone = zoneRef.current;
    const canvas = canvasRef.current;
    if (!root || !zone || !canvas) return;
    const ctx = canvas.getContext("2d");
    if (!ctx) return;

    const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
    reducedRef.current = reduced;

    // token ink — read at mount, re-derived live on theme class change.
    // fields start empty and are assigned unconditionally before any paint.
    let fg = "";
    let muted = "";
    let bg = "";
    let border = "";
    const deriveTokens = () => {
      const cs = getComputedStyle(document.documentElement);
      fg = cs.getPropertyValue("--foreground").trim();
      muted = cs.getPropertyValue("--ns-muted").trim();
      bg = cs.getPropertyValue("--background").trim();
      border = cs.getPropertyValue("--border").trim();
    };
    deriveTokens();
    const mo = new MutationObserver(deriveTokens);
    mo.observe(document.documentElement, { attributes: true, attributeFilter: ["class"] });

    let w = 0;
    let h = 0;
    let dpr = 1;
    let sized = false;
    const DPR_CAP = 2;

    const resize = () => {
      const rect = zone.getBoundingClientRect();
      w = Math.max(1, rect.width);
      h = Math.max(1, rect.height);
      dpr = Math.min(DPR_CAP, window.devicePixelRatio || 1);
      canvas.width = Math.round(w * dpr);
      canvas.height = Math.round(h * dpr);
      canvas.style.width = `${w}px`;
      canvas.style.height = `${h}px`;
      ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
      sized = w > 1 && h > 1;
    };
    resize();
    const ro = new ResizeObserver(resize);
    ro.observe(zone);

    let visible = true;
    const io = new IntersectionObserver(
      (entries) => {
        for (const entry of entries) visible = entry.isIntersecting;
      },
      { threshold: 0 }
    );
    io.observe(zone);
    let pageVisible = document.visibilityState === "visible";
    const onVis = () => {
      pageVisible = document.visibilityState === "visible";
    };
    document.addEventListener("visibilitychange", onVis);

    let particles: Spark[] = [];
    let spawnAcc = 0;

    // origin: the "wheel contact point" — centre-left rather than the
    // corner, scaled to the container's smaller dimension so density/length
    // read at any preview size, per the scale rule. Sitting mid-height
    // leaves the ballistic cone room to arc up through the top of the
    // frame and fall back down without colliding with the label block
    // pinned to the bottom of the dropzone.
    const scaleDim = () => Math.min(w, h);
    const originX = () => w * 0.22;
    const originY = () => h * 0.52;
    const baseAngle = -55; // degrees, up-and-right cone axis
    const gravity = () => scaleDim() * 1.7; // px/s^2, arcs the stream down
    const baseSpeed = () => scaleDim() * 1.55; // px/s
    const baseLife = 0.78; // s, before signature carrierMul

    const spawnSpark = (
      arr: Spark[],
      rng: () => number,
      sig: SparkSignature,
      depth: number,
      x: number,
      y: number,
      angleDeg: number,
      speedScale: number
    ) => {
      if (arr.length >= MAX_PARTICLES) return;
      const jitter = (rng() - 0.5) * 2 * sig.spreadDeg;
      const angle = ((angleDeg + jitter) * Math.PI) / 180;
      const speed = baseSpeed() * sig.speedMul * speedScale * (0.85 + rng() * 0.3);
      const life =
        depth === 0
          ? baseLife * sig.carrierMul * (0.8 + rng() * 0.4)
          : (0.12 + rng() * 0.1) * (1 + sig.carrierMul * 0.15); // short terminal sprig
      arr.push({
        x,
        y,
        px: x,
        py: y,
        vx: Math.cos(angle) * speed,
        vy: Math.sin(angle) * speed,
        age: 0,
        life,
        forkAt: 0.35 + rng() * 0.3,
        forked: depth >= sig.maxForkDepth,
        depth,
        intensity: sig.intensity * (depth === 0 ? 1 : 0.85),
      });
    };

    const drawWheel = () => {
      const ox = originX();
      const oy = originY();
      const r = scaleDim() * 0.09;
      ctx.save();
      ctx.strokeStyle = muted;
      ctx.globalAlpha = 0.55;
      ctx.lineWidth = Math.max(1, scaleDim() * 0.008);
      ctx.beginPath();
      ctx.arc(ox - r * 0.7, oy, r, 0, Math.PI * 2);
      ctx.stroke();
      ctx.globalAlpha = 0.35;
      ctx.beginPath();
      ctx.arc(ox - r * 0.7, oy, r * 0.35, 0, Math.PI * 2);
      ctx.stroke();
      ctx.restore();
    };

    const stepSpark = (s: Spark, dt: number, sig: SparkSignature, rng: () => number, out: Spark[]) => {
      s.px = s.x;
      s.py = s.y;
      s.vy += gravity() * dt;
      s.x += s.vx * dt;
      s.y += s.vy * dt;
      s.age += dt;
      if (!s.forked && s.age >= s.life * s.forkAt && s.depth < sig.maxForkDepth) {
        s.forked = true;
        if (rng() < sig.forkProb) {
          const count = sig.sprigMin + Math.floor(rng() * (sig.sprigMax - sig.sprigMin + 1));
          const parentAngle = (Math.atan2(s.vy, s.vx) * 180) / Math.PI;
          for (let i = 0; i < count; i++) {
            const branch = parentAngle + (rng() - 0.5) * 70;
            spawnSpark(out, rng, sig, s.depth + 1, s.x, s.y, branch, 0.55 + rng() * 0.2);
          }
        }
      }
    };

    const drawSpark = (s: Spark) => {
      const a = Math.max(0, 1 - s.age / s.life);
      if (a <= 0) return;
      // glow halo in muted, bright core in foreground — luminance-only "hot"
      // read, no hue; per the monochrome adaptation this stands in for the
      // real spark-stream color signal.
      ctx.strokeStyle = muted;
      ctx.globalAlpha = a * s.intensity * 0.5;
      ctx.lineWidth = Math.max(1, scaleDim() * 0.006);
      ctx.beginPath();
      ctx.moveTo(s.px, s.py);
      ctx.lineTo(s.x, s.y);
      ctx.stroke();
      ctx.strokeStyle = fg;
      ctx.globalAlpha = a * s.intensity;
      ctx.lineWidth = Math.max(0.75, scaleDim() * 0.0032);
      ctx.beginPath();
      ctx.moveTo(s.px, s.py);
      ctx.lineTo(s.x, s.y);
      ctx.stroke();
    };

    // ---- reduced motion: one deterministic long-exposure freeze frame -----
    const renderStaticFrame = () => {
      if (!sized) return;
      const sig = SIGNATURES[signatureRef.current];
      const rng = mulberry32(signatureRef.current.length * 7919 + 13);
      ctx.clearRect(0, 0, w, h);
      ctx.fillStyle = bg;
      ctx.fillRect(0, 0, w, h);
      drawWheel();
      let arr: Spark[] = [];
      let acc = 0;
      const dt = 1 / 60;
      let t = 0;
      while (t < STATIC_TIME) {
        acc += sig.spawnRate * dt;
        while (acc >= 1) {
          spawnSpark(arr, rng, sig, 0, originX(), originY(), baseAngle, 1);
          acc -= 1;
        }
        const spawned: Spark[] = [];
        for (const s of arr) {
          stepSpark(s, dt, sig, rng, spawned);
        }
        arr = arr.concat(spawned);
        t += dt;
      }
      // paint every particle's final segment plus a short fading trail so
      // carrier lines and forks are both legible in one static composite —
      // this is a long exposure, not a literal single instant.
      for (const s of arr) {
        if (s.age >= s.life * 1.4) continue; // long-dead, would just be noise
        drawSpark(s);
      }
      ctx.globalAlpha = 1;
      if (border) {
        // legitimate --border use: the dropzone's own outline, drawn by CSS,
        // not touched here — canvas leaves the frame edge alone.
      }
    };

    let raf = 0;
    let last = 0;
    const loop = (now: number) => {
      raf = requestAnimationFrame(loop);
      if (!sized || !visible || !pageVisible) {
        last = now;
        return;
      }
      const dt = last ? Math.min(0.05, (now - last) / 1000) : 1 / 60;
      last = now;
      const sig = SIGNATURES[signatureRef.current];

      ctx.clearRect(0, 0, w, h);
      ctx.fillStyle = bg;
      ctx.fillRect(0, 0, w, h);
      drawWheel();

      spawnAcc += sig.spawnRate * dt;
      const rng = Math.random;
      while (spawnAcc >= 1 && particles.length < MAX_PARTICLES) {
        spawnSpark(particles, rng, sig, 0, originX(), originY(), baseAngle, 1);
        spawnAcc -= 1;
      }

      const spawned: Spark[] = [];
      const alive: Spark[] = [];
      for (const s of particles) {
        stepSpark(s, dt, sig, rng, spawned);
        if (s.age < s.life && s.y < h + 40) alive.push(s);
      }
      particles = alive.concat(spawned).slice(-MAX_PARTICLES);

      for (const s of particles) drawSpark(s);
      ctx.globalAlpha = 1;
    };

    if (reduced) {
      renderStaticFrame();
    } else {
      raf = requestAnimationFrame(loop);
    }

    // re-render the static frame if the signature changes while reduced
    // motion is active, or if tokens flip theme — both need a fresh paint
    // since there is no running loop to pick it up on its own.
    const staticRerender = () => {
      if (reducedRef.current) renderStaticFrame();
    };
    const moForStatic = new MutationObserver(() => {
      deriveTokens();
      staticRerender();
    });
    moForStatic.observe(document.documentElement, { attributes: true, attributeFilter: ["class"] });

    return () => {
      cancelAnimationFrame(raf);
      ro.disconnect();
      io.disconnect();
      mo.disconnect();
      moForStatic.disconnect();
      document.removeEventListener("visibilitychange", onVis);
    };
  }, [file?.id]);

  return (
    <div ref={rootRef} className={`relative w-full ${className}`}>
      <div
        ref={zoneRef}
        role="button"
        tabIndex={0}
        aria-label={ariaLabel}
        aria-describedby={hintId}
        onClick={() => inputRef.current?.click()}
        onKeyDown={onKeyDown}
        onDragOver={(e) => {
          e.preventDefault();
          setIsOver(true);
        }}
        onDragLeave={() => setIsOver(false)}
        onDrop={onDrop}
        className={`relative isolate flex min-h-56 w-full flex-col items-center justify-end overflow-hidden rounded-md border outline-none transition-colors ${
          isOver ? "border-ns-accent" : "border-border"
        } focus-visible:ring-2 focus-visible:ring-ns-accent/30`}
      >
        <canvas ref={canvasRef} className="absolute inset-0 h-full w-full" aria-hidden="true" />
        <div className="relative z-10 flex w-full flex-col items-center gap-1 px-4 pb-4 pt-10">
          <p className="font-mono text-xs uppercase tracking-[0.18em] text-ns-muted">
            {file ? file.name : "Drop a file on the wheel"}
          </p>
          <p className="text-xs text-ns-muted">
            {file
              ? `${familyDisplayName(file.family)} · ${formatSize(file.size)}`
              : "or press Enter to browse"}
          </p>
        </div>
      </div>

      <input
        ref={inputRef}
        type="file"
        accept={accept.join(",") || undefined}
        onChange={(e) => handleFiles(e.target.files)}
        className="sr-only"
        tabIndex={-1}
        aria-hidden="true"
      />

      <div className="mt-3 flex items-center justify-between gap-3">
        <p id={hintId} className="text-xs text-ns-muted">
          {accept.length > 0 ? `Accepts ${accept.join(", ")}` : "Accepts any file type"}
          {maxSizeBytes != null ? ` · up to ${formatSize(maxSizeBytes)}` : ""}
        </p>
        {file && (
          <button
            type="button"
            onClick={clearFile}
            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"
          >
            Clear
          </button>
        )}
      </div>

      <div id={liveId} role="status" aria-live="polite" className="sr-only">
        {announce}
      </div>
    </div>
  );
}

The usage example imports ./component because it sits next to the component file in this repo. Installed, that file lands at components/ui/spark-test-id.tsx, so point the import there.

Use when an upload/detection surface where the identification moment itself is the spectacle — a continuously running spark shower whose burst/fork pattern changes to match the detected file type; pick file-upload-thermal instead for a drag-accept/reject moment carried by rising wisps and buoyant chips, or file-upload-seal for a per-file progress read carried by a spring-driven outline.

Install

npx shadcn add https://design.helpmarq.com/r/spark-test-id.json

Ask AI

Point an assistant at this component's docs (llms-full.txt) with one click.

Claude, ChatGPT, Grok, and Perplexity open with the prompt already in. Gemini copies it to your clipboard first. Paste it in once the chat opens.

Build spec

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.

Props

PropTypeDefaultDescription
accept?string[][]allowed extensions/mimes (".png", "image/*"); empty = any
maxSizeBytes?number—rejects a file larger than this. No default — unlimited.
onFileChange?(file: SparkTestIdFile | null) => void—called with the identified file, or null when cleared
className?string—extra classes merged onto the rendered root element
aria-label?string"Upload a file to identify its type"accessible name for the dropzone control