{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "progress-hatch",
  "title": "Progress Hatch",
  "description": "Engineering-drawing progress meter: a light hatch track and a dense shade-ramp fill with a dithered leading edge, an inline right-aligned numeric readout, and a box-drawing ruler of ticks below.",
  "dependencies": [],
  "files": [
    {
      "path": "registry/core/progress-hatch/component.tsx",
      "content": "\"use client\";\n\n// A progress meter in the engineering-drawing register: the track is a run\n// of a single light hatch glyph (░), the filled portion is solid (█), and\n// the boundary between them is not a hard cut — a few columns of ordered\n// dither (a 1D analogue of the Bayer matrix used elsewhere in this suite)\n// blend ░▒▓█ across the leading edge so the fill reads as a texture gradient\n// rather than a flat block edge. The numeric readout is not a separate\n// element floating over the bar: it is printed into the same character grid,\n// right-aligned, overwriting whichever hatch/fill glyphs would otherwise sit\n// in those cells. Tick marks and labels sit on a second and third monospace\n// line below, drawn as a box-drawing ruler (├──┬──┬──┬──┤).\n//\n// `value` glides toward its target over ~420ms via a single direct-DOM rAF\n// loop that writes the built row string straight to a ref's textContent —\n// never per-frame React state — and sleeps once the ease settles.\n\nimport { useEffect, useRef } from \"react\";\n\nconst RAMP = [\"░\", \"▒\", \"▓\", \"█\"] as const;\n// 1D ordered-dither sequence (a Bayer-matrix analogue in one dimension):\n// comparing a column's local edge position against its own threshold here,\n// rather than a smooth density ramp, gives the edge visible grain instead of\n// a clean gradient.\nconst DITHER = [0.15, 0.65, 0.35, 0.85, 0.5, 0.05, 0.95, 0.45];\nconst EDGE_COLS = 4; // width, in characters, of the dithered leading edge\nconst GLIDE_MS = 420;\n\nfunction easeOutCubic(p: number): number {\n  return 1 - (1 - p) ** 3;\n}\n\nfunction glyphAt(col: number, fillCols: number): string {\n  const coverage = fillCols - col; // how far this column sits behind the fill edge\n  if (coverage >= EDGE_COLS) return RAMP[3]; // \"█\", solidly filled\n  if (coverage <= 0) return \"░\"; // untouched track\n  const local = coverage / EDGE_COLS; // 0..1 through the dithered edge\n  const threshold = DITHER[col % DITHER.length];\n  const level = local > threshold ? Math.floor(local * 4) + 1 : Math.floor(local * 4);\n  return RAMP[Math.max(0, Math.min(3, level))];\n}\n\nfunction buildBarRow(displayValue: number, totalChars: number, readoutWidth: number): string {\n  const fillCols = (Math.max(0, Math.min(100, displayValue)) / 100) * totalChars;\n  const chars: string[] = [];\n  for (let col = 0; col < totalChars; col++) chars.push(glyphAt(col, fillCols));\n  const numText = `${String(Math.round(displayValue)).padStart(readoutWidth - 1, \" \")}%`;\n  for (let i = 0; i < numText.length; i++) {\n    chars[totalChars - numText.length + i] = numText[i];\n  }\n  return chars.join(\"\");\n}\n\nfunction buildTicks(totalChars: number, marks: number[]): { tickLine: string; labelLine: string } {\n  const tick = new Array(totalChars).fill(\"─\");\n  const label = new Array(totalChars).fill(\" \");\n  const cols = marks.map((m) => Math.round((Math.max(0, Math.min(100, m)) / 100) * (totalChars - 1)));\n  cols.forEach((col, i) => {\n    tick[col] = \"┬\";\n    const text = String(marks[i]);\n    let start = col - Math.floor((text.length - 1) / 2);\n    start = Math.max(0, Math.min(totalChars - text.length, start));\n    for (let k = 0; k < text.length; k++) label[start + k] = text[k];\n  });\n  tick[0] = \"├\";\n  tick[totalChars - 1] = \"┤\";\n  return { tickLine: tick.join(\"\"), labelLine: label.join(\"\") };\n}\n\nexport interface HatchFillProps {\n  /** progress, 0-100 (controlled). */\n  value: number;\n  /** bar width in characters. */\n  totalChars?: number;\n  /** percentages to tick and label below the bar. */\n  marks?: number[];\n  /** accessible label for the meter. */\n  \"aria-label\"?: string;\n  /** extra classes merged onto the rendered root element */\n  className?: string;\n}\n\nexport function HatchFill({\n  value,\n  totalChars = 44,\n  marks = [0, 25, 50, 75, 100],\n  \"aria-label\": ariaLabel = \"Progress\",\n  className = \"\",\n}: HatchFillProps) {\n  const rowRef = useRef<HTMLSpanElement>(null);\n  const valueRef = useRef(value);\n  const displayRef = useRef(value);\n  const fromRef = useRef(value);\n  const startRef = useRef(-1);\n  const rafRef = useRef(0);\n  const readoutWidth = 4; // \" 37%\" / \"100%\" — fixed width so digits never jitter the grid\n\n  const { tickLine, labelLine } = buildTicks(totalChars, marks);\n\n  useEffect(() => {\n    const row = rowRef.current;\n    if (!row) return;\n    const reduced = window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches;\n\n    const paint = () => {\n      row.textContent = buildBarRow(displayRef.current, totalChars, readoutWidth);\n    };\n\n    const loop = (now: number) => {\n      const p = Math.min(1, (now - startRef.current) / GLIDE_MS);\n      displayRef.current = fromRef.current + (valueRef.current - fromRef.current) * easeOutCubic(p);\n      paint();\n      if (p < 1) {\n        rafRef.current = requestAnimationFrame(loop);\n      } else {\n        rafRef.current = 0;\n      }\n    };\n\n    if (reduced) {\n      displayRef.current = value;\n      paint();\n    } else if (value !== displayRef.current) {\n      fromRef.current = displayRef.current;\n      startRef.current = performance.now();\n      if (!rafRef.current) rafRef.current = requestAnimationFrame(loop);\n    }\n    valueRef.current = value;\n\n    return () => {\n      if (rafRef.current) cancelAnimationFrame(rafRef.current);\n      // clear the id, not just the frame: the restart guard below tests\n      // `!rafRef.current`, so leaving a stale id here permanently wedges the\n      // glide the first time a new value arrives mid-animation.\n      rafRef.current = 0;\n    };\n    // eslint-disable-next-line react-hooks/exhaustive-deps -- rAF reads valueRef/displayRef live\n  }, [value, totalChars]);\n\n  const clamped = Math.max(0, Math.min(100, value));\n\n  return (\n    <div\n      className={`inline-block font-mono leading-[1.5] text-foreground ${className}`}\n      style={{ width: `${totalChars}ch` }}\n    >\n      <span\n        ref={rowRef}\n        role=\"progressbar\"\n        aria-valuemin={0}\n        aria-valuemax={100}\n        aria-valuenow={Math.round(clamped)}\n        aria-label={ariaLabel}\n        className=\"block whitespace-pre text-foreground\"\n      >\n        {buildBarRow(value, totalChars, readoutWidth)}\n      </span>\n      <span aria-hidden className=\"block whitespace-pre text-border\">\n        {tickLine}\n      </span>\n      <span aria-hidden className=\"block whitespace-pre text-[0.85em] text-ns-muted\">\n        {labelLine}\n      </span>\n    </div>\n  );\n}\n",
      "type": "registry:ui",
      "target": "components/ui/progress-hatch.tsx"
    }
  ],
  "cssVars": {
    "theme": {
      "color-ns-muted": "var(--ns-muted)"
    },
    "light": {
      "ns-muted": "#4d4d4d"
    },
    "dark": {
      "ns-muted": "#8f8f8f"
    }
  },
  "meta": {
    "collection": "core",
    "tags": [
      "progress",
      "meter",
      "ascii",
      "hatch",
      "mono",
      "engineering"
    ],
    "instruction": "A determinate progress meter in the engineering-drawing register, three stacked monospace lines: the bar itself, a box-drawing tick ruler, and a label row. The bar's track is a run of the single light hatch glyph ░; the filled run is solid █; between them is a fixed-width (4 column) dithered edge where each column's fill level is decided not by a smooth density ramp but by comparing that column's local position through the edge against its own entry in an 8-value ordered-dither sequence — a 1D analogue of the Bayer matrix used in background-ascii-dither elsewhere in this suite — so the boundary reads as grain/texture rather than a flat cut or a clean gradient. The numeric readout is not a floating overlay: it is printed directly into the same character array, right-aligned, overwriting whatever hatch or fill glyphs would otherwise occupy those trailing cells, at a fixed 4-column width (' 37%' / '100%') so the grid never jitters as the digit count changes. Below the bar, a second line draws a box-drawing ruler (├──┬──┬──┬──┤) with a ┬ at each `marks` percentage (default 0/25/50/75/100) and a third line centers that mark's number underneath, clipped to the grid bounds at the ends. `value` (0-100, controlled) glides toward its target over a fixed 420ms ease-out-cubic via a single direct-DOM rAF loop that rebuilds the bar row string each frame and writes it straight to a ref's textContent — never per-frame React state — and sleeps once the ease settles; the tick/label lines are static per render and need no loop. `role=progressbar` with aria-valuemin/max/now sits on the bar line itself, aria-label supplied by the caller. Colors are `text-foreground` for the bar, `text-border` for the tick ruler and `text-ns-muted` for labels — no hardcoded hex, so both themes render correctly. prefers-reduced-motion (read live via matchMedia on mount and on every value change) skips the glide and paints the exact frame implied by `value` immediately. The container is sized to `${totalChars}ch` with an explicit line-height so the character grid holds its width regardless of font metrics, and stays legible at small sizes because the hatch ramp only has four density levels rather than a continuous gradient."
  },
  "type": "registry:ui"
}