{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "stat-tile-ascii-arrive",
  "title": "Stat Tile ASCII Arrive",
  "description": "A KPI number whose arrival is ink condensing into a glyph: every dot of a 5x7 ASCII dot-matrix digit starts at a random density-ramp step and eases independently toward its true value, so the numeral visibly resolves out of noise instead of sliding or flipping into place.",
  "dependencies": [],
  "files": [
    {
      "path": "registry/core/stat-tile-ascii-arrive/component.tsx",
      "content": "\"use client\";\n\nimport { useEffect, useRef } from \"react\";\n\n// ---------------------------------------------------------------------------\n// StatTileAsciiArrive — a KPI number that arrives as ink condensing into a\n// glyph, not as a digit sliding into place (that job belongs to\n// counter-carry-ripple). Each character of the formatted value is rendered\n// on a fixed 5x7 dot-matrix bitmap (the same convention as a classic LED\n// segment display, defined once as a lookup table). On mount, and again on\n// every value change, every \"on\" cell of the target bitmap starts from a\n// RANDOM step of a density ramp and every \"off\" cell starts near the ramp's\n// empty end; each cell then eases independently (its own random start delay\n// + duration jitter) toward its true target step, so the numeral visibly\n// CONDENSES out of noise instead of just fading or sliding in. Once every\n// cell has reached its target the canvas stops redrawing entirely — this is\n// a display-only component, so there is no reason to keep painting an\n// identical frame forever.\n//\n// Pure <canvas>, cell coloring read via getComputedStyle so both themes are\n// correct without a single hardcoded hex. prefers-reduced-motion renders the\n// settled glyph immediately, no condensation pass.\n// ---------------------------------------------------------------------------\n\nconst RAMP = \" .:-=+*#%@\"; // 10-step density ramp, index 0 = empty\nconst CELL_W = 5;\nconst CELL_H = 7;\nconst SETTLE_MS = 620; // worst-case per-cell settle time\nconst MAX_STAGGER_MS = 220; // spread of per-cell start delay\n\n// 5x7 dot-matrix glyphs, 1 = ink. Minimal set: digits, separators, sign.\nconst GLYPHS: Record<string, string[]> = {\n  \"0\": [\"01110\", \"10001\", \"10011\", \"10101\", \"11001\", \"10001\", \"01110\"],\n  \"1\": [\"00100\", \"01100\", \"00100\", \"00100\", \"00100\", \"00100\", \"01110\"],\n  \"2\": [\"01110\", \"10001\", \"00001\", \"00010\", \"00100\", \"01000\", \"11111\"],\n  \"3\": [\"11110\", \"00001\", \"00001\", \"01110\", \"00001\", \"00001\", \"11110\"],\n  \"4\": [\"00010\", \"00110\", \"01010\", \"10010\", \"11111\", \"00010\", \"00010\"],\n  \"5\": [\"11111\", \"10000\", \"11110\", \"00001\", \"00001\", \"10001\", \"01110\"],\n  \"6\": [\"00110\", \"01000\", \"10000\", \"11110\", \"10001\", \"10001\", \"01110\"],\n  \"7\": [\"11111\", \"00001\", \"00010\", \"00100\", \"01000\", \"01000\", \"01000\"],\n  \"8\": [\"01110\", \"10001\", \"10001\", \"01110\", \"10001\", \"10001\", \"01110\"],\n  \"9\": [\"01110\", \"10001\", \"10001\", \"01111\", \"00001\", \"00010\", \"01100\"],\n  \",\": [\"00000\", \"00000\", \"00000\", \"00000\", \"00000\", \"00100\", \"01000\"],\n  \".\": [\"00000\", \"00000\", \"00000\", \"00000\", \"00000\", \"01100\", \"01100\"],\n  \"-\": [\"00000\", \"00000\", \"00000\", \"11111\", \"00000\", \"00000\", \"00000\"],\n  \"+\": [\"00000\", \"00100\", \"00100\", \"11111\", \"00100\", \"00100\", \"00000\"],\n  \"%\": [\"11001\", \"11010\", \"00100\", \"01000\", \"10011\", \"10011\", \"00000\"],\n  \" \": [\"00000\", \"00000\", \"00000\", \"00000\", \"00000\", \"00000\", \"00000\"],\n};\n\nfunction glyphRows(ch: string): string[] {\n  return GLYPHS[ch] ?? GLYPHS[\" \"]!;\n}\n\ninterface CellAnim {\n  target: number; // 0 or 1 (ink or not)\n  from: number; // starting ramp index\n  delay: number; // ms\n  dur: number; // ms\n}\n\nfunction mulberry32(seed: number) {\n  let a = seed >>> 0;\n  return () => {\n    a = (a + 0x6d2b79f5) >>> 0;\n    let t = a;\n    t = Math.imul(t ^ (t >>> 15), t | 1);\n    t ^= t + Math.imul(t ^ (t >>> 7), t | 61);\n    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;\n  };\n}\n\nfunction easeOutCubic(t: number) {\n  return 1 - Math.pow(1 - t, 3);\n}\n\nexport interface StatTileAsciiArriveProps {\n  /** formatted or raw value; numbers get toLocaleString, strings pass through */\n  value: number | string;\n  /** caption above the readout */\n  label?: string;\n  /** shown after the value, e.g. \"%\" or \"ms\" */\n  suffix?: string;\n  /** extra classes merged onto the rendered root element */\n  className?: string;\n}\n\nexport function StatTileAsciiArrive({\n  value,\n  label,\n  suffix = \"\",\n  className = \"\",\n}: StatTileAsciiArriveProps) {\n  const canvasRef = useRef<HTMLCanvasElement>(null);\n  const seedRef = useRef(0);\n\n  const formatted =\n    typeof value === \"number\" ? value.toLocaleString() : value;\n  const text = `${formatted}${suffix}`;\n\n  useEffect(() => {\n    const canvas = canvasRef.current;\n    if (!canvas) return;\n    const ctx = canvas.getContext(\"2d\");\n    if (!ctx) return;\n\n    const reduced = window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches;\n    seedRef.current += 1;\n    const rand = mulberry32(0x9e3779b1 + seedRef.current * 101);\n\n    let fgCss = \"currentColor\";\n    let disposed = false;\n    let raf = 0;\n    let start = 0;\n\n    const readTokens = () => {\n      fgCss = getComputedStyle(canvas).color;\n    };\n    const fontFamily = getComputedStyle(canvas).fontFamily;\n\n    const chars = text.split(\"\");\n    const glyphW = CELL_W;\n    const gapCols = 1; // gap between characters, in dot columns\n    const totalCols = chars.reduce((sum, c, i) => sum + glyphW + (i > 0 ? gapCols : 0), 0);\n    const MAX_DOT_PX = 5; // px per dot cell at full size (before dpr scaling)\n    const MAX_DOT_GAP = 1.4;\n    const MIN_DOT_PX = 2.4; // never shrink glyphs illegibly small\n\n    // scale the whole dot-matrix down to fit whatever width the card\n    // actually has available — a long value (e.g. \"48,200\") in a narrow\n    // tile must never overflow its card, and a stat tile's value length is\n    // arbitrary, so this can't be a fixed constant\n    const available = canvas.parentElement?.getBoundingClientRect().width || 0;\n    const idealPitch = MAX_DOT_PX + MAX_DOT_GAP;\n    const neededW = (totalCols + 1) * idealPitch;\n    const scale = available > 0 ? Math.min(1, available / neededW) : 1;\n    const dotPx = Math.max(MIN_DOT_PX, MAX_DOT_PX * scale);\n    const dotGap = MAX_DOT_GAP * scale;\n    const pitch = dotPx + dotGap;\n\n    let dpr = 1;\n    const resize = () => {\n      const w = totalCols * pitch + pitch;\n      const h = CELL_H * pitch + pitch * 2;\n      dpr = Math.min(window.devicePixelRatio || 1, 2);\n      canvas.width = Math.max(1, Math.round(w * dpr));\n      canvas.height = Math.max(1, 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      ctx.font = `${dotPx * 1.7}px ${fontFamily}`;\n      ctx.textAlign = \"center\";\n      ctx.textBaseline = \"middle\";\n    };\n    resize();\n\n    // build the per-cell animation plan across the whole string\n    const cols = totalCols;\n    const rows = CELL_H;\n    const cells: CellAnim[] = new Array(cols * rows);\n    let colCursor = 0;\n    chars.forEach((ch, ci) => {\n      const rowsFor = glyphRows(ch);\n      for (let r = 0; r < rows; r++) {\n        const bits = rowsFor[r]!;\n        for (let c = 0; c < glyphW; c++) {\n          const target = bits[c] === \"1\" ? 1 : 0;\n          const idx = r * cols + (colCursor + c);\n          cells[idx] = {\n            target,\n            from: target === 1 ? Math.floor(rand() * (RAMP.length - 2)) : RAMP.length - 1 - Math.floor(rand() * 2),\n            delay: rand() * MAX_STAGGER_MS,\n            dur: SETTLE_MS * (0.75 + rand() * 0.35),\n          };\n        }\n      }\n      colCursor += glyphW + (ci < chars.length - 1 ? gapCols : 0);\n    });\n\n    const cellX = (c: number) => pitch / 2 + c * pitch + dotPx / 2;\n    const cellY = (r: number) => pitch + r * pitch + dotPx / 2;\n\n    const drawStatic = () => {\n      ctx.clearRect(0, 0, canvas.width, canvas.height);\n      ctx.fillStyle = fgCss;\n      for (let r = 0; r < rows; r++) {\n        for (let c = 0; c < cols; c++) {\n          const cell = cells[r * cols + c];\n          if (!cell || cell.target === 0) continue;\n          ctx.globalAlpha = 1;\n          ctx.fillText(RAMP[RAMP.length - 1]!, cellX(c), cellY(r));\n        }\n      }\n      ctx.globalAlpha = 1;\n    };\n\n    // draws the character at ramp index `step` (rounded) with alpha keyed to\n    // how far into its ink range that index sits, so the transition between\n    // adjacent glyphs still reads as a continuous density ramp rather than\n    // a visible per-frame glyph pop\n    const drawFrame = (elapsed: number) => {\n      ctx.clearRect(0, 0, canvas.width, canvas.height);\n      ctx.fillStyle = fgCss;\n      let allDone = true;\n      for (let r = 0; r < rows; r++) {\n        for (let c = 0; c < cols; c++) {\n          const cell = cells[r * cols + c];\n          if (!cell) continue;\n          const local = elapsed - cell.delay;\n          let step: number;\n          if (local <= 0) {\n            step = cell.from;\n            allDone = false;\n          } else if (local >= cell.dur) {\n            step = cell.target === 1 ? RAMP.length - 1 : 0;\n          } else {\n            const t = easeOutCubic(local / cell.dur);\n            step = cell.from + (cell.target === 1 ? RAMP.length - 1 - cell.from : 0 - cell.from) * t;\n            allDone = false;\n          }\n          const idx = Math.max(0, Math.min(RAMP.length - 1, Math.round(step)));\n          if (idx === 0) continue;\n          ctx.globalAlpha = 0.4 + (idx / (RAMP.length - 1)) * 0.6;\n          ctx.fillText(RAMP[idx]!, cellX(c), cellY(r));\n        }\n      }\n      ctx.globalAlpha = 1;\n      return allDone;\n    };\n\n    readTokens();\n\n    if (reduced) {\n      drawStatic();\n    } else {\n      const loop = (now: number) => {\n        if (disposed) return;\n        if (!start) start = now;\n        const elapsed = now - start;\n        const done = drawFrame(elapsed);\n        if (!done) {\n          raf = requestAnimationFrame(loop);\n        }\n      };\n      raf = requestAnimationFrame(loop);\n    }\n\n    const mo = new MutationObserver(() => {\n      readTokens();\n      if (reduced) drawStatic();\n    });\n    mo.observe(document.documentElement, { attributes: true, attributeFilter: [\"class\"] });\n\n    return () => {\n      disposed = true;\n      cancelAnimationFrame(raf);\n      mo.disconnect();\n    };\n    // re-run the whole condensation whenever the displayed text changes\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [text]);\n\n  return (\n    <div className={`flex w-full min-w-0 flex-col items-start gap-2 ${className}`}>\n      {label && (\n        <span className=\"font-mono text-[10px] uppercase tracking-widest text-ns-muted\">{label}</span>\n      )}\n      <canvas ref={canvasRef} aria-hidden data-stat-canvas className=\"block font-mono\" />\n      <span className=\"sr-only\" role=\"status\" aria-live=\"polite\" aria-atomic=\"true\">\n        {label ? `${label}: ${text}` : text}\n      </span>\n    </div>\n  );\n}\n",
      "type": "registry:ui",
      "target": "components/ui/stat-tile-ascii-arrive.tsx"
    }
  ],
  "cssVars": {
    "theme": {
      "color-ns-muted": "var(--ns-muted)"
    },
    "light": {
      "ns-muted": "#4d4d4d"
    },
    "dark": {
      "ns-muted": "#8f8f8f"
    }
  },
  "meta": {
    "collection": "core",
    "tags": [
      "stat",
      "kpi",
      "ascii",
      "canvas",
      "data-viz",
      "accessibility"
    ],
    "instruction": "Renders `value` (a number or preformatted string, optionally with a `suffix` like '%' or 'ms') as ASCII ink on a small <canvas>, one 5x7 dot-matrix bitmap per character from a fixed lookup table (digits 0-9, comma, period, minus, plus, percent, space) — the same convention as a classic LED segment display, defined once as string rows of '0'/'1'. On mount, and again whenever the formatted text changes, every dot cell across the WHOLE string is assigned an independent animation: cells that are 'ink' in the target glyph start at a random low step of a 10-step density ramp (' .:-=+*#%@') and ease up to the ramp's densest character; cells that are 'off' start near the ramp's sparse end and ease down to nothing. Each cell gets its own random start delay (0-220ms) and duration jitter (~620ms nominal, +/-17%) via a seeded PRNG, so cells don't all move in lockstep — the numeral visibly CONDENSES out of a field of noise rather than fading or sliding in as a block. Mid-transition, a cell's displayed character is the ramp glyph at its rounded current step with alpha keyed to how far into that step's range it sits, so the eye reads a continuous density gradient rather than discrete glyph pops. Once every cell has reached its target the canvas stops issuing rAF frames entirely (display-only, no reason to keep redrawing an identical frame). ACCESSIBILITY: the canvas is aria-hidden; a visually-hidden role=status aria-live=polite aria-atomic=true span holds the plain locale-formatted value (with label, if provided) as the accessible content. REDUCED MOTION: renders the fully settled glyph on the first frame, no condensation pass, no rAF loop. Ink color is read via getComputedStyle(canvas).color at mount and re-derived on a documentElement class MutationObserver so both themes render correctly with zero hardcoded hex. Zero dependencies, canvas + inline font metrics only."
  },
  "type": "registry:ui"
}