{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "loader-braille",
  "title": "Loader Braille",
  "description": "Determinate/indeterminate loader built from braille dot patterns, where each cell's eight dots are individually addressable for far finer granularity than a block-character bar.",
  "dependencies": [],
  "files": [
    {
      "path": "registry/core/loader-braille/component.tsx",
      "content": "\"use client\";\n\n// A loader built from braille cells (U+2800 block), where each cell's eight\n// dots are individually addressable bits rather than a single glyph from a\n// spinner sprite sheet. Indeterminate: a wave travels through the row — each\n// cell's fill level is a sine function of time, phase-shifted by its column,\n// so dots rise and fall like a stadium wave rather than a rotating glyph.\n// Determinate: `progress` maps onto every dot across the row in reading\n// order (cell 0's eight dots fill first, then cell 1's, ...), so 0-100% has\n// N_CELLS * 8 addressable steps — far finer than a block-character bar could\n// offer at the same character width. Reaching 100 triggers a one-shot accent\n// pulse across the row before settling.\n//\n// One direct-DOM rAF loop builds the row string and writes it to a single\n// ref's textContent each frame (never React state per frame); glyph color is\n// read from getComputedStyle so it tracks the surrounding theme. Settling\n// on completion is the only thing that goes through React state, since it's\n// a discrete one-shot transition, not a per-frame value.\n\nimport { useEffect, useId, useRef, useState } from \"react\";\n\nconst N_CELLS = 14;\n// dot fill priority within a cell, bottom row first: rises like a level meter\nconst PRIORITY = [7, 8, 3, 6, 2, 5, 1, 4] as const;\nconst BIT: Record<number, number> = { 1: 0x01, 2: 0x02, 3: 0x04, 4: 0x08, 5: 0x10, 6: 0x20, 7: 0x40, 8: 0x80 };\n\nfunction cellChar(n: number): string {\n  const count = Math.max(0, Math.min(8, Math.round(n)));\n  let mask = 0;\n  for (let i = 0; i < count; i++) mask |= BIT[PRIORITY[i]];\n  return String.fromCharCode(0x2800 + mask);\n}\n\nconst WAVE_SPEED = 2.6; // rad/s\nconst PHASE_STEP = 0.55; // rad per column\nconst SETTLE_MS = 480;\n\nexport interface BrailleSpinProps {\n  /** 0-100 for a determinate fill; omit (or leave undefined) for the indeterminate wave. */\n  progress?: number;\n  /** accessible label for the progressbar. */\n  \"aria-label\"?: string;\n  /** extra classes merged onto the rendered root element */\n  className?: string;\n}\n\nexport function BrailleSpin({\n  progress,\n  \"aria-label\": ariaLabel = \"Loading\",\n  className = \"\",\n}: BrailleSpinProps) {\n  const rowRef = useRef<HTMLSpanElement>(null);\n  const progressRef = useRef(progress);\n  const [settled, setSettled] = useState(progress != null && progress >= 100);\n  const settledOnceRef = useRef(settled);\n  const idBase = useId();\n\n  useEffect(() => {\n    progressRef.current = progress;\n  }, [progress]);\n\n  useEffect(() => {\n    if (progress != null && progress >= 100) {\n      if (!settledOnceRef.current) {\n        settledOnceRef.current = true;\n        setSettled(true);\n        const t = window.setTimeout(() => setSettled(false), SETTLE_MS);\n        return () => window.clearTimeout(t);\n      }\n    } else {\n      settledOnceRef.current = false;\n    }\n  }, [progress]);\n\n  // mount-once animated loop; skipped entirely under reduced motion\n  useEffect(() => {\n    const row = rowRef.current;\n    if (!row) return;\n    if (window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches) return;\n\n    const paint = (levels: number[]) => {\n      row.textContent = levels.map(cellChar).join(\"\");\n    };\n    let raf = 0;\n    const start = performance.now();\n    const loop = (now: number) => {\n      const p = progressRef.current;\n      if (p == null) {\n        const t = (now - start) / 1000;\n        paint(\n          Array.from({ length: N_CELLS }, (_, i) => 4 + 4 * Math.sin(t * WAVE_SPEED - i * PHASE_STEP))\n        );\n      } else {\n        const totalDots = N_CELLS * 8;\n        const litDots = Math.round((Math.max(0, Math.min(100, p)) / 100) * totalDots);\n        paint(Array.from({ length: N_CELLS }, (_, i) => litDots - i * 8));\n      }\n      raf = requestAnimationFrame(loop);\n    };\n    raf = requestAnimationFrame(loop);\n    return () => cancelAnimationFrame(raf);\n  }, []);\n\n  // reduced motion: one correct static frame, repainted live if `progress` changes\n  // (there is no running loop above to pick that change up on its own)\n  useEffect(() => {\n    const row = rowRef.current;\n    if (!row) return;\n    if (!window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches) return;\n    if (progress == null) {\n      // gentle standing arc stands in for the wave with nothing moving\n      row.textContent = Array.from({ length: N_CELLS }, (_, i) => cellChar(4 + 2 * Math.sin(i * PHASE_STEP))).join(\"\");\n    } else {\n      const totalDots = N_CELLS * 8;\n      const litDots = Math.round((Math.max(0, Math.min(100, progress)) / 100) * totalDots);\n      row.textContent = Array.from({ length: N_CELLS }, (_, i) => cellChar(litDots - i * 8)).join(\"\");\n    }\n  }, [progress]);\n\n  const determinate = progress != null;\n  const clamped = determinate ? Math.max(0, Math.min(100, progress as number)) : undefined;\n\n  return (\n    <div\n      role=\"progressbar\"\n      aria-label={ariaLabel}\n      aria-valuemin={determinate ? 0 : undefined}\n      aria-valuemax={determinate ? 100 : undefined}\n      aria-valuenow={determinate ? Math.round(clamped as number) : undefined}\n      className={`inline-block font-mono leading-none ${className}`}\n    >\n      <style>{`\n.ns-braille-row{transition:color 260ms ease-out}\n.ns-braille-settled{color:var(--ns-accent) !important}\n`}</style>\n      <span\n        ref={rowRef}\n        id={idBase}\n        aria-hidden\n        className={`ns-braille-row whitespace-pre text-foreground ${settled ? \"ns-braille-settled\" : \"\"}`}\n      >\n        {cellChar(0).repeat(N_CELLS)}\n      </span>\n    </div>\n  );\n}\n",
      "type": "registry:ui",
      "target": "components/ui/loader-braille.tsx"
    }
  ],
  "cssVars": {
    "theme": {
      "color-ns-accent": "var(--ns-accent)"
    },
    "light": {
      "ns-accent": "#006bff"
    }
  },
  "meta": {
    "collection": "core",
    "tags": [
      "loader",
      "progress",
      "ascii",
      "braille",
      "mono"
    ],
    "instruction": "A loader rendered as a row of braille cells (U+2800 block), where the eight dots of each cell are addressable bits rather than a spinner glyph pulled from a fixed set. A cell's fill count 0-8 is converted to a character via a fixed per-cell dot priority list `[7,8,3,6,2,5,1,4]` (bottom row first, so a partially-filled cell reads as a rising level meter rather than a scattered dot cluster) OR'd into the bitmask added to 0x2800. Indeterminate (`progress` undefined): each of the 14 cells' fill level is `4 + 4*sin(t*2.6 - i*0.55)`, i.e. one continuous sine sampled with a per-column phase offset, so the wave genuinely travels left to right across the row with dots rising and falling like a stadium wave, not a rotating sprite. Determinate (`progress` 0-100): the value maps onto all 14*8=112 dots in reading order — cell 0's eight dots fill before cell 1's — giving well over an order of magnitude finer resolution than a block-character bar could offer at the same character width; reaching 100 triggers a one-shot ~480ms accent-color pulse across the row (a discrete React state transition, the only state change in the whole animation) before it settles back to foreground ink. Everything else is a single direct-DOM rAF loop that builds the full row string each frame and writes it once to a ref's textContent — never per-frame React state — reading glyph color from the surrounding `text-foreground`/`--ns-accent` tokens so it works unmodified in both themes. `role=progressbar` carries `aria-valuemin/max/now` only in the determinate case (per spec, an indeterminate progressbar omits `aria-valuenow` rather than reporting a fake value); the glyph row itself is `aria-hidden` since the numeric state is exposed through the ARIA attributes, not by parsing braille. prefers-reduced-motion renders one correct static frame — a gentle standing arc for the indeterminate case, the literal frame implied by `progress` for the determinate one — and a separate light effect keyed on `progress` keeps that static frame in sync if the value changes while reduced motion is on, since the animated loop (which would otherwise pick that up) never starts."
  },
  "type": "registry:ui"
}