{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "keymap-ascii-heat",
  "title": "Keymap ASCII Heat",
  "description": "An ASCII keyboard layout that accumulates real ink density per key as you type into it: each keystroke inks the key, heat decays on an exponential half-life, and the legend rescales to whichever key is currently hottest.",
  "dependencies": [],
  "files": [
    {
      "path": "registry/core/keymap-ascii-heat/component.tsx",
      "content": "\"use client\";\n\nimport { useEffect, useMemo, useRef, useState } from \"react\";\n\n// ---------------------------------------------------------------------------\n// KeymapAsciiHeat — an ASCII keyboard layout that accumulates real ink\n// density per key as you type into a real text input: every keydown adds\n// heat to that key's cell, heat decays on an exponential half-life so a key\n// you stop pressing visibly fades back to blank paper, and the legend's\n// scale is recomputed against whichever key is currently hottest. Distinct\n// from shortcuts-cheat-sheet (a static keycap depresses itself for a listed\n// COMBINATION and swallows the keydown — a rehearsal aid, no accumulation,\n// no decay, no legend) and from heatmap-year-stipple (density via jittered\n// dot count, not a character ramp, and driven by a canned yearly dataset,\n// not live keystrokes). The ramp (\" .:-=+*#%@\") still names each of the 10\n// heat steps in the legend, but a key's OWN cell never draws that ramp\n// character behind its letter — two glyphs sharing one small cell just\n// overlaid into an illegible mess (a real bug this component shipped with:\n// hammer A/S/D/F and the ramp character stacks on the letterform itself).\n// Heat instead reads as a flat --foreground fill behind the letter, opacity\n// scaled 0 -> ~0.55 by the key's decayed ink over the live max, so every key\n// shows exactly one legible glyph at every heat level, in both themes. No\n// canvas: every cell is real DOM, colored from --foreground via a Tailwind\n// opacity utility, no hex.\n// ---------------------------------------------------------------------------\n\nconst RAMP = [\" \", \".\", \":\", \"-\", \"=\", \"+\", \"*\", \"#\", \"%\", \"@\"];\nconst HALF_LIFE_MS = 7000;\nconst REPAINT_MS = 90;\nconst SLEEP_EPS = 0.02;\n\nconst ROWS: string[][] = [\n  [\"Q\", \"W\", \"E\", \"R\", \"T\", \"Y\", \"U\", \"I\", \"O\", \"P\"],\n  [\"A\", \"S\", \"D\", \"F\", \"G\", \"H\", \"J\", \"K\", \"L\"],\n  [\"Z\", \"X\", \"C\", \"V\", \"B\", \"N\", \"M\"],\n];\n\nfunction decay(ink: number, dtMs: number): number {\n  return ink * Math.pow(0.5, dtMs / HALF_LIFE_MS);\n}\n\nfunction keyFor(e: { key: string }): string | null {\n  if (e.key === \" \") return \"SPACE\";\n  if (e.key.length === 1 && /[a-zA-Z]/.test(e.key)) return e.key.toUpperCase();\n  return null;\n}\n\nexport interface KeymapAsciiHeatProps {\n  /** placeholder text for the typing input */\n  placeholder?: string;\n  /** accessible name for the typing input */\n  label?: string;\n  /** extra classes merged onto the rendered root element */\n  className?: string;\n}\n\nexport function KeymapAsciiHeat({\n  placeholder = \"Type here — the keys below heat up as you go\",\n  label = \"Type to build heat\",\n  className = \"\",\n}: KeymapAsciiHeatProps) {\n  const inkRef = useRef(new Map<string, { ink: number; lastAt: number }>());\n  const rafRef = useRef(0);\n  const lastRepaintRef = useRef(0);\n  const reducedRef = useRef(false);\n\n  const [levels, setLevels] = useState<Record<string, number>>({});\n  const [maxInk, setMaxInk] = useState(0);\n  const [hoveredKey, setHoveredKey] = useState<string | null>(null);\n  const [text, setText] = useState(\"\");\n\n  useEffect(() => {\n    const mq = window.matchMedia(\"(prefers-reduced-motion: reduce)\");\n    const sync = () => {\n      reducedRef.current = mq.matches;\n    };\n    sync();\n    mq.addEventListener(\"change\", sync);\n    return () => mq.removeEventListener(\"change\", sync);\n  }, []);\n\n  const recompute = (now: number) => {\n    const raw: Record<string, number> = {};\n    let max = 0;\n    let anyLive = false;\n    inkRef.current.forEach((v, key) => {\n      const cur = decay(v.ink, now - v.lastAt);\n      if (cur > SLEEP_EPS) anyLive = true;\n      raw[key] = cur;\n      if (cur > max) max = cur;\n    });\n    setLevels(raw);\n    setMaxInk(max);\n    return anyLive;\n  };\n\n  const stop = () => {\n    cancelAnimationFrame(rafRef.current);\n    rafRef.current = 0;\n  };\n\n  const step = (now: number) => {\n    rafRef.current = 0;\n    if (now - lastRepaintRef.current >= REPAINT_MS) {\n      lastRepaintRef.current = now;\n      const anyLive = recompute(now);\n      if (!anyLive) return; // sleep — pulse() wakes it again\n    }\n    rafRef.current = requestAnimationFrame(step);\n  };\n\n  const wake = () => {\n    if (!rafRef.current) rafRef.current = requestAnimationFrame(step);\n  };\n\n  useEffect(() => stop, []);\n\n  const pulse = (key: string) => {\n    const now = performance.now();\n    const m = inkRef.current;\n    const prev = m.get(key);\n    const cur = prev ? decay(prev.ink, now - prev.lastAt) : 0;\n    m.set(key, { ink: cur + 1, lastAt: now });\n    if (reducedRef.current) {\n      lastRepaintRef.current = now;\n      recompute(now);\n    } else {\n      wake();\n    }\n  };\n\n  const onKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {\n    const key = keyFor(e);\n    if (key) pulse(key);\n  };\n\n  const MAX_FILL_OPACITY = 0.55;\n\n  const heatOpacityFor = (key: string): number => {\n    const v = levels[key] ?? 0;\n    if (maxInk <= SLEEP_EPS) return 0;\n    return Math.min(1, v / maxInk) * MAX_FILL_OPACITY;\n  };\n\n  const legend = useMemo(() => RAMP.join(\"\"), []);\n\n  const renderKey = (key: string, wide = false) => (\n    <div\n      key={key}\n      aria-hidden\n      data-key={key}\n      onPointerEnter={() => setHoveredKey(key)}\n      onPointerLeave={() => setHoveredKey((h) => (h === key ? null : h))}\n      className={`relative flex h-9 items-center justify-center rounded-sm border border-border bg-background text-foreground transition-colors duration-100 motion-reduce:transition-none hover:bg-foreground/[0.06] ${\n        wide ? \"w-40\" : \"w-9\"\n      }`}\n    >\n      <span\n        aria-hidden\n        className=\"pointer-events-none absolute inset-0 rounded-sm bg-foreground\"\n        style={{ opacity: heatOpacityFor(key) }}\n      />\n      <span className=\"relative z-10 select-none text-[11px] font-semibold tracking-wide\">\n        {key === \"SPACE\" ? \"␣\" : key}\n      </span>\n    </div>\n  );\n\n  return (\n    <div className={`inline-flex flex-col gap-3 font-mono ${className}`}>\n      <input\n        type=\"text\"\n        value={text}\n        onChange={(e) => setText(e.target.value)}\n        onKeyDown={onKeyDown}\n        placeholder={placeholder}\n        aria-label={label}\n        className=\"w-full rounded-sm border border-border bg-background px-3 py-2 text-sm text-foreground outline-none transition-colors duration-100 motion-reduce:transition-none placeholder:text-ns-muted focus-visible:ring-2 focus-visible:ring-ns-accent\"\n      />\n\n      <div className=\"flex flex-col items-center gap-1.5 rounded-sm border border-border bg-surface p-3\">\n        {ROWS.map((row, i) => (\n          <div key={i} className=\"flex gap-1.5\">\n            {row.map((k) => renderKey(k))}\n          </div>\n        ))}\n        <div className=\"flex gap-1.5 pt-0.5\">{renderKey(\"SPACE\", true)}</div>\n      </div>\n\n      <div className=\"flex items-center justify-between gap-3 rounded-sm border border-border bg-background px-3 py-1.5 text-[11px] text-ns-muted\">\n        <span data-legend className=\"tabular-nums\">\n          ink {legend} max={maxInk.toFixed(1)}\n        </span>\n        <span data-hover-readout className=\"tabular-nums\">\n          {hoveredKey ? `${hoveredKey === \"SPACE\" ? \"␣\" : hoveredKey}: ${(levels[hoveredKey] ?? 0).toFixed(1)}` : \"hover a key\"}\n        </span>\n      </div>\n    </div>\n  );\n}\n",
      "type": "registry:ui",
      "target": "components/ui/keymap-ascii-heat.tsx"
    }
  ],
  "cssVars": {
    "theme": {
      "color-ns-muted": "var(--ns-muted)",
      "color-ns-accent": "var(--ns-accent)",
      "color-surface": "var(--surface)"
    },
    "light": {
      "ns-muted": "#4d4d4d",
      "ns-accent": "#006bff",
      "surface": "#fafafa"
    },
    "dark": {
      "ns-muted": "#8f8f8f",
      "surface": "#171717"
    }
  },
  "meta": {
    "collection": "core",
    "tags": [
      "keyboard",
      "heatmap",
      "ascii",
      "mono",
      "typing",
      "data-visualization",
      "live"
    ],
    "instruction": "Build <KeymapAsciiHeat placeholder label className> around a real, visible <input type=text> that the user actually types into (aria-label from the `label` prop). MECHANISM: the keydown handler lives directly on the input's own onKeyDown (never a document-level listener gated on document.activeElement — an autoplay-driven demo runs inside an inert subtree where focus never truly lands, so a focus-gated listener would leave the card dead; binding straight to the input's React event fires regardless). Every keydown whose key resolves to a letter or Space maps to a key id (A-Z, or 'SPACE') and calls pulse(key): a Map<string,{ink,lastAt}> in a ref holds each key's raw ink and the timestamp it was last touched; pulse reads the PREVIOUS entry, decays it forward to now via ink * 0.5^(dt/7000) (7s half-life), adds 1, and stores the new {ink,lastAt} — so heat is a pure function of elapsed time, never a per-frame accumulator, and 'what is this key's ink right now' can be asked at any instant without having run every frame in between. A throttled rAF loop (repaints at most every ~90ms) recomputes every key's CURRENT decayed value from its stored {ink,lastAt} and the loop's own `now`, finds the live max across all keys, and stores both in React state; the loop sleeps (cancels itself) once every key's decayed value has fallen under a small epsilon, and pulse() wakes it again on the next keystroke — so there is no animation running while nobody is typing. RENDERING: each key is a fixed-size cell showing its letter in the foreground, always as the ONLY glyph in the cell — heat never draws a second character on top of the letter (that overlay was a real bug: hammering A/S/D/F used to stack a heavy ramp glyph directly over the letterform and render it illegible). Instead each cell has an absolutely-positioned fill behind the letter, a plain --foreground rect whose opacity is that key's current decayed ink divided by the LIVE max across all keys (0 at rest, up to ~0.55 at the hottest key), so heat reads as density/opacity, not as stacked ink. A legend line beneath the keyboard still prints the 10-step ASCII ramp (' .:-=+*#%@') alongside 'max=<current max ink, one decimal>' as a scale reference, and a second readout beside it names whichever key is currently hovered along with its live decayed ink value, updating on pointerenter/leave — since the keyboard is otherwise a static picture at rest, this hover readout is what makes hover state visibly differ from resting. REDUCED MOTION: skip the rAF loop outright — pulse() still runs the exact same decay math and still updates state, it just does so synchronously inside the keydown handler instead of on a subsequent repaint tick, so a key's ink is still correct at every keystroke, there is simply no continuously-fading glyph between keystrokes. A11Y: the input is the only real control and carries the accessible name; every key cell is a plain aria-hidden decorative div (not a button, not tabbable) since it represents live derived state rather than something to activate — Tab reaches the input, which is the control the 'Tab must reach something' rule cares about. No gate: heat can only be produced by real keystrokes, which the verify gate's single-click model cannot simulate — the `autoplay: type` descriptor is what exercises this component's characteristic non-resting state instead, and its screenshot is what an owner should look at, not a synthetic gate click. Colors are token-only: --foreground at full and reduced opacity for the glyph/letter, --border/--background/--surface for the chrome, --ns-accent only on the input's focus ring. No canvas, zero dependencies."
  },
  "type": "registry:ui"
}