{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "device-mockup-ascii-screen",
  "title": "Device Mockup ASCII Screen",
  "description": "A phone-frame device mockup whose screen is a live ASCII/scanline raster; dragging the handle skews the frame while the raster independently resamples its glyph rows to the same tilt.",
  "dependencies": [],
  "files": [
    {
      "path": "registry/core/device-mockup-ascii-screen/component.tsx",
      "content": "\"use client\";\n\nimport { useEffect, useRef, useState, type KeyboardEvent as ReactKeyboardEvent, type PointerEvent as ReactPointerEvent } from \"react\";\n\n// ---------------------------------------------------------------------------\n// DeviceMockupAsciiScreen — a phone-frame device mockup whose screen content\n// is a live ASCII/scanline raster (a fake app UI rendered as monospace glyph\n// density plus a slow scanline sweep), not a static screenshot. Dragging the\n// handle tilts the FRAME via a real CSS skew() transform, while the screen's\n// own canvas draw pass independently RESAMPLES its glyph raster to the same\n// skew — every row's glyphs are shifted horizontally by an amount\n// proportional to that row's distance from center and the current skew\n// angle, recomputed every frame the tilt changes — so the content genuinely\n// re-renders into the tilt rather than being a flat texture wrapped in the\n// bezel's own CSS transform. This stays a 2D skew, deliberately: no\n// perspective/projection engine, no three. Distinct from image-crop-mat\n// (four mat boards defining a crop window over a static photo, no tilt, no\n// raster) and minimap-pantograph (an IK linkage between two scroll\n// viewports, no device chrome and no glyph rendering at all).\n// ---------------------------------------------------------------------------\n\nexport interface DeviceMockupAsciiScreenProps {\n  /** extra classes merged onto the rendered root element */\n  className?: string;\n}\n\nconst MAX_SKEW = 14; // deg\nconst DRAG_RANGE = 120; // px of drag to reach MAX_SKEW\nconst SETTLE_MS = 420;\nconst CELL = 8;\nconst SCAN_PERIOD_MS = 3200;\n\ninterface Tokens {\n  fg: string;\n  bg: string;\n  border: string;\n  accent: string;\n}\n\nfunction readTokens(): Tokens {\n  const cs = getComputedStyle(document.documentElement);\n  const get = (name: string, fallback: string) => cs.getPropertyValue(name).trim() || fallback;\n  return {\n    fg: get(\"--foreground\", \"#ededed\"),\n    bg: get(\"--background\", \"#0a0a0a\"),\n    border: get(\"--border\", \"#2e2e2e\"),\n    accent: get(\"--ns-accent\", \"#006bff\"),\n  };\n}\n\n// fake UI layout: a row-density map (0..1) standing in for a header, a\n// stat block and a few paragraph lines — enough visual structure for the\n// shear to read against.\nfunction densityAt(gx: number, gy: number, cols: number, rows: number): number {\n  const rowFrac = gy / rows;\n  if (rowFrac < 0.08) return 0.95; // header bar\n  if (rowFrac < 0.28) {\n    // stat tiles: three blocks with gaps\n    const colFrac = gx / cols;\n    const inBlock = colFrac % 0.34 < 0.28;\n    return inBlock ? 0.6 : 0;\n  }\n  if (rowFrac > 0.92) return 0.15; // footer hairline\n  // paragraph lines: alternating density bands with ragged right edge\n  const lineIdx = Math.floor((rowFrac - 0.32) * 26);\n  if (lineIdx < 0) return 0;\n  const lineOn = lineIdx % 3 !== 2;\n  if (!lineOn) return 0;\n  const raggedEnd = 0.35 + 0.55 * ((Math.sin(lineIdx * 1.7) + 1) / 2);\n  const colFrac = gx / cols;\n  return colFrac < raggedEnd ? 0.5 : 0;\n}\n\nfunction easeOutCubic(t: number) {\n  const c = Math.min(1, Math.max(0, t));\n  return 1 - Math.pow(1 - c, 3);\n}\n\nexport function DeviceMockupAsciiScreen({ className = \"\" }: DeviceMockupAsciiScreenProps) {\n  const frameRef = useRef<HTMLDivElement>(null);\n  const canvasRef = useRef<HTMLCanvasElement>(null);\n  const tokensRef = useRef<Tokens>(\n    typeof document === \"undefined\"\n      ? { fg: \"#ededed\", bg: \"#0a0a0a\", border: \"#2e2e2e\", accent: \"#006bff\" }\n      : readTokens()\n  );\n  const reducedRef = useRef(false);\n  const rafRef = useRef(0);\n\n  const skewRef = useRef({ x: 0, y: 0 });\n  const targetRef = useRef({ x: 0, y: 0 });\n  const settleRef = useRef({ from: { x: 0, y: 0 }, start: 0, active: false });\n  const draggingRef = useRef(false);\n  const startRef = useRef({ x: 0, y: 0, skewX: 0, skewY: 0 });\n\n  const [, forceTick] = useState(0);\n\n  useEffect(() => {\n    reducedRef.current = window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches;\n    const mo = new MutationObserver(() => {\n      tokensRef.current = readTokens();\n    });\n    mo.observe(document.documentElement, { attributes: true, attributeFilter: [\"class\", \"style\"] });\n    return () => mo.disconnect();\n  }, []);\n\n  const applyFrameTransform = () => {\n    const frame = frameRef.current;\n    if (!frame) return;\n    frame.style.transform = `skew(${skewRef.current.x.toFixed(2)}deg, ${skewRef.current.y.toFixed(2)}deg)`;\n  };\n\n  const paint = (t: number) => {\n    const canvas = canvasRef.current;\n    if (!canvas) return;\n    const ctx = canvas.getContext(\"2d\");\n    if (!ctx) return;\n    const dpr = Math.min(window.devicePixelRatio || 1, 2);\n    const cssW = canvas.clientWidth || 1;\n    const cssH = canvas.clientHeight || 1;\n    const targetW = Math.ceil(cssW * dpr);\n    const targetH = Math.ceil(cssH * dpr);\n    if (canvas.width !== targetW || canvas.height !== targetH) {\n      canvas.width = targetW;\n      canvas.height = targetH;\n    }\n    ctx.setTransform(dpr, 0, 0, dpr, 0, 0);\n    const { fg, bg, accent } = tokensRef.current;\n    ctx.fillStyle = bg;\n    ctx.fillRect(0, 0, cssW, cssH);\n\n    const cols = Math.max(1, Math.ceil(cssW / CELL));\n    const rows = Math.max(1, Math.ceil(cssH / CELL));\n    const cellW = cssW / cols;\n    const cellH = cssH / rows;\n    // shear factor in px-per-row, derived from the live skewX angle: the\n    // raster resamples to the SAME tilt the frame's CSS transform applies\n    const shearPxPerRow = Math.tan((skewRef.current.x * Math.PI) / 180) * cellH * -1;\n    const centerRow = rows / 2;\n\n    ctx.font = `${Math.max(6, Math.round(cellH * 0.92))}px \"GeistMono\", ui-monospace, monospace`;\n    ctx.textAlign = \"left\";\n    ctx.textBaseline = \"top\";\n\n    const scanProgress = reducedRef.current ? 0.5 : (t % SCAN_PERIOD_MS) / SCAN_PERIOD_MS;\n    const scanRow = scanProgress * rows;\n\n    for (let gy = 0; gy < rows; gy++) {\n      const rowShear = shearPxPerRow * (gy - centerRow);\n      const nearScan = Math.abs(gy - scanRow) < 1.4;\n      for (let gx = 0; gx < cols; gx++) {\n        const d = densityAt(gx, gy, cols, rows);\n        if (d <= 0) continue;\n        const ch = d > 0.85 ? \"#\" : d > 0.55 ? \"=\" : d > 0.3 ? \"-\" : \".\";\n        ctx.fillStyle = nearScan ? accent : fg;\n        ctx.globalAlpha = nearScan ? 1 : 0.65 + d * 0.3;\n        ctx.fillText(ch, gx * cellW + rowShear, gy * cellH);\n      }\n    }\n    ctx.globalAlpha = 1;\n  };\n\n  useEffect(() => {\n    let mounted = true;\n    const loop = (t: number) => {\n      if (!mounted) return;\n      // settle animation: eases skew back toward target (usually 0 on release)\n      const s = settleRef.current;\n      if (s.active) {\n        const elapsed = performance.now() - s.start;\n        const p = Math.min(1, elapsed / SETTLE_MS);\n        const e = easeOutCubic(p);\n        skewRef.current = {\n          x: s.from.x + (targetRef.current.x - s.from.x) * e,\n          y: s.from.y + (targetRef.current.y - s.from.y) * e,\n        };\n        applyFrameTransform();\n        if (p >= 1) s.active = false;\n      }\n      paint(t);\n      const needsMotion = !reducedRef.current || s.active || draggingRef.current;\n      if (needsMotion) rafRef.current = requestAnimationFrame(loop);\n    };\n    rafRef.current = requestAnimationFrame(loop);\n    return () => {\n      mounted = false;\n      cancelAnimationFrame(rafRef.current);\n    };\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, []);\n\n  const setSkew = (x: number, y: number) => {\n    skewRef.current = {\n      x: Math.max(-MAX_SKEW, Math.min(MAX_SKEW, x)),\n      y: Math.max(-MAX_SKEW, Math.min(MAX_SKEW, y)),\n    };\n    targetRef.current = { ...skewRef.current };\n    applyFrameTransform();\n    forceTick((n) => n + 1);\n  };\n\n  const onPointerDown = (e: ReactPointerEvent) => {\n    draggingRef.current = true;\n    settleRef.current.active = false;\n    startRef.current = { x: e.clientX, y: e.clientY, skewX: skewRef.current.x, skewY: skewRef.current.y };\n    (e.target as Element).setPointerCapture(e.pointerId);\n    if (rafRef.current === 0) rafRef.current = requestAnimationFrame(paint as unknown as FrameRequestCallback);\n  };\n  const onPointerMove = (e: ReactPointerEvent) => {\n    if (!draggingRef.current) return;\n    const dx = e.clientX - startRef.current.x;\n    const dy = e.clientY - startRef.current.y;\n    const nx = startRef.current.skewX + (dx / DRAG_RANGE) * MAX_SKEW;\n    const ny = startRef.current.skewY + (dy / DRAG_RANGE) * MAX_SKEW;\n    skewRef.current = {\n      x: Math.max(-MAX_SKEW, Math.min(MAX_SKEW, nx)),\n      y: Math.max(-MAX_SKEW, Math.min(MAX_SKEW, ny)),\n    };\n    applyFrameTransform();\n  };\n  const settleToZero = () => {\n    settleRef.current = { from: { ...skewRef.current }, start: performance.now(), active: true };\n    targetRef.current = { x: 0, y: 0 };\n    if (rafRef.current === 0) {\n      const loop = (t: number) => {\n        const s = settleRef.current;\n        if (s.active) {\n          const elapsed = performance.now() - s.start;\n          const p = Math.min(1, elapsed / SETTLE_MS);\n          const e = easeOutCubic(p);\n          skewRef.current = {\n            x: s.from.x + (targetRef.current.x - s.from.x) * e,\n            y: s.from.y + (targetRef.current.y - s.from.y) * e,\n          };\n          applyFrameTransform();\n          if (p >= 1) s.active = false;\n        }\n        paint(t);\n        if (s.active) rafRef.current = requestAnimationFrame(loop);\n        else rafRef.current = 0;\n      };\n      rafRef.current = requestAnimationFrame(loop);\n    }\n  };\n  const onPointerUp = (e: ReactPointerEvent) => {\n    if (!draggingRef.current) return;\n    draggingRef.current = false;\n    try {\n      (e.target as Element).releasePointerCapture(e.pointerId);\n    } catch {\n      // synthetic pointer during autoplay — nothing to release\n    }\n    if (reducedRef.current) {\n      setSkew(0, 0);\n    } else {\n      settleToZero();\n    }\n  };\n\n  const onKeyDown = (e: ReactKeyboardEvent) => {\n    const step = 3;\n    if (e.key === \"ArrowLeft\") {\n      e.preventDefault();\n      setSkew(skewRef.current.x - step, skewRef.current.y);\n    } else if (e.key === \"ArrowRight\") {\n      e.preventDefault();\n      setSkew(skewRef.current.x + step, skewRef.current.y);\n    } else if (e.key === \"ArrowUp\") {\n      e.preventDefault();\n      setSkew(skewRef.current.x, skewRef.current.y - step);\n    } else if (e.key === \"ArrowDown\") {\n      e.preventDefault();\n      setSkew(skewRef.current.x, skewRef.current.y + step);\n    } else if (e.key === \"Enter\" || e.key === \" \" || e.key === \"Escape\") {\n      e.preventDefault();\n      if (reducedRef.current) setSkew(0, 0);\n      else settleToZero();\n    }\n  };\n\n  return (\n    <div className={`flex flex-col items-center gap-4 ${className}`}>\n      <div\n        ref={frameRef}\n        data-device-frame\n        className=\"relative rounded-[28px] border-[6px] border-border bg-background p-2 shadow-sm\"\n        style={{ width: 220, height: 440, transformOrigin: \"center\" }}\n      >\n        <span\n          aria-hidden\n          className=\"absolute left-1/2 top-2 h-1.5 w-14 -translate-x-1/2 rounded-full bg-border\"\n        />\n        <canvas ref={canvasRef} aria-hidden className=\"block h-full w-full rounded-[18px]\" />\n      </div>\n\n      <div\n        role=\"button\"\n        tabIndex={0}\n        aria-label=\"Drag or use arrow keys to tilt the device\"\n        onPointerDown={onPointerDown}\n        onPointerMove={onPointerMove}\n        onPointerUp={onPointerUp}\n        onPointerCancel={onPointerUp}\n        onKeyDown={onKeyDown}\n        className=\"cursor-grab select-none touch-none rounded-full border border-border bg-surface px-4 py-1.5 font-mono text-[11px] tracking-widest text-ns-muted transition-colors duration-150 hover:border-foreground/25 hover:text-foreground focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ns-accent active:cursor-grabbing\"\n      >\n        DRAG TO TILT\n      </div>\n    </div>\n  );\n}\n",
      "type": "registry:ui",
      "target": "components/ui/device-mockup-ascii-screen.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": [
      "device-mockup",
      "phone",
      "ascii",
      "canvas",
      "drag",
      "scanline"
    ],
    "instruction": "Build <DeviceMockupAsciiScreen className?>: a phone-shaped frame (rounded-[28px], thick border, a small pill notch) sized 220x440, its screen a <canvas> rendering a fake app UI (header bar, a row of stat blocks, ragged paragraph lines) as literal ASCII glyphs (# = - . by density) at an 8px cell pitch, plus a slow scanline sweep (one bright accent-colored row cycling top to bottom every ~3.2s, paused at its mid-position under prefers-reduced-motion). THE MECHANIC: a real drag handle below the frame (role=button, tabIndex 0, aria-label 'Drag or use arrow keys to tilt the device') applies a CSS `skew(Xdeg, Ydeg)` transform DIRECTLY to the frame element as the pointer moves (drag range +-120px maps to +-14deg on each axis, arrow keys nudge 3deg per press as a keyboard-accessible equivalent) — that transform tilts the bezel, the notch, everything. Independently, the canvas draw pass reads the CURRENT live skewX angle every frame and resamples its OWN glyph raster to match: each text row's horizontal draw position is offset by `tan(skewX) * cellHeight * (row - centerRow)`, recomputed on every paint while dragging, so the screen's ASCII content visibly shears in sync with the frame's tilt through its own procedural redraw rather than being a flat texture merely wrapped inside the frame's CSS transform (drop it and the content would never re-align with a tilted bezel). On release the skew eases back to 0 over ~420ms (eased, one rAF loop) unless reduced motion is on, in which case it snaps directly to 0. This stays a deliberate 2D skew — no perspective/projection math, no three, no WebGL. Colors are token-only: canvas ink var(--foreground), scanline var(--ns-accent), background var(--background)/var(--border) for the bezel, read via getComputedStyle and re-read on a MutationObserver watching the root's class/style attributes. The drag handle is the only interactive control — a real role=button with a visible hover and focus-visible state distinct from rest — everything else (frame, canvas) is aria-hidden decoration. Zero dependencies."
  },
  "type": "registry:ui"
}