{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "fax-line-slip",
  "title": "Fax Line Slip",
  "description": "A document-preview / attachment-thumbnail loading state reproducing a Group 3 fax handshake and page transmission: two waveform-rendered handshake tones, then the page builds in as horizontal scan lines top to bottom, with one deliberate paper-slip shear partway down the frame before the lines resume true.",
  "dependencies": [],
  "files": [
    {
      "path": "registry/core/fax-line-slip/component.tsx",
      "content": "\"use client\";\n\nimport { useEffect, useRef } from \"react\";\nimport type { CSSProperties } from \"react\";\n\n// ---------------------------------------------------------------------------\n// FaxLineSlip — a document-preview / attachment-thumbnail loading state\n// reproducing a Group 3 fax handshake and page transmission, not a generic\n// spinner or shimmer. Source: ITU-T T.30 handshake tones (calling machine\n// sends a pulsed CNG tone, the answering machine holds a CED tone) followed\n// by the page transmitting as sequential horizontal scan lines at the\n// negotiated modem rate, plus the documented thermal-fax mechanical fault\n// where the paper momentarily slips through the feed rollers mid-page,\n// shearing a thin band of the received image sideways before lines resume\n// true.\n//\n// One cycle, four phases, drawn onto a single persistent canvas that is\n// never cleared mid-build (a real received page is composed, not repainted):\n//\n// 1. Handshake (2200ms): a waveform trace occupies the top ~15% of the\n//    frame. A tight-period sine burst stands in for the 1100Hz CNG tone\n//    (500ms), a silent 500ms gap, then a tighter-period burst stands in for\n//    the 2100Hz CED tone (1200ms) — 500+500+1200 = 2200ms exactly, drawn at\n//    human-followable pace because it already is one (not sped up).\n// 2. Scan build: one scan line commits every 45ms (~22 lines/sec — real G3\n//    at 9600bps is ~5-6 lines/sec, but rendering 220 lines at that real rate\n//    would take ~40s, unbearably slow for a resting loop, so this is\n//    compressed for pace, not decoupled for alias-avoidance). Line count is\n//    derived from the container's own smaller dimension (one line per ~2px\n//    of height, capped at 220) so the build reads at card scale. At a\n//    randomized point 55-75% down the frame, one 3-line band commits with a\n//    hard 14-22px horizontal offset baked permanently into those three\n//    lines — the paper-slip artifact — then the very next line resumes at\n//    zero offset with no easing, because a real slip is a sudden mechanical\n//    discontinuity, not a glitch that repeats.\n// 3. Hold (1800ms): the fully composed page sits still.\n// 4. Reset (200ms): the frame does not fade — it clears top-to-bottom in one\n//    wipe, mirroring a fresh page feeding in, then the handshake replays\n//    with a freshly reseeded page and a freshly randomized slip position.\n//\n// The \"document\" content itself is a procedural stand-in for scanned text —\n// deterministic hash-derived horizontal strokes with word-like gaps, grouped\n// into text lines with occasional paragraph breaks — reseeded every cycle so\n// no two received pages look identical. `--ns-accent` never appears; ink is\n// `--foreground` over a `--background` field, read live via\n// getComputedStyle + a MutationObserver on the theme class, matching the dark\n// marks on light stock a real thermal print reads (dark theme inverts the\n// relationship rather than swapping which token is which).\n// ---------------------------------------------------------------------------\n\nexport interface FaxLineSlipProps {\n  /** extra classes merged onto the rendered root element */\n  className?: string;\n  /** inline styles merged onto the root element */\n  style?: CSSProperties;\n  /** sr-only status text describing the loading state */\n  label?: string;\n}\n\ntype RGB = [number, number, number];\n\nfunction parseColor(raw: string): RGB | null {\n  const s = raw.trim();\n  if (s.startsWith(\"#\")) {\n    let hex = s.slice(1);\n    if (hex.length === 3) hex = hex.split(\"\").map((c) => c + c).join(\"\");\n    if (hex.length < 6) return null;\n    const r = parseInt(hex.slice(0, 2), 16);\n    const g = parseInt(hex.slice(2, 4), 16);\n    const b = parseInt(hex.slice(4, 6), 16);\n    return Number.isNaN(r + g + b) ? null : [r, g, b];\n  }\n  const m = s.match(/rgba?\\(\\s*([\\d.]+)[,\\s]+([\\d.]+)[,\\s]+([\\d.]+)/);\n  return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : null;\n}\n\nfunction hash01(x: number, y: number): number {\n  const n = Math.sin(x * 127.1 + y * 311.7) * 43758.5453123;\n  return n - Math.floor(n);\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\n// -- handshake timing: 500ms CNG-stand-in burst, 500ms silence, 1200ms\n// CED-stand-in burst — total 2200ms, real ITU-T T.30 pace, not compressed. --\nconst BURST1_MS = 500;\nconst GAP_MS = 500;\nconst BURST2_MS = 1200;\nconst HANDSHAKE_MS = BURST1_MS + GAP_MS + BURST2_MS; // 2200ms\nconst BURST1_CYCLES = 15; // tight-period trace standing in for 1100Hz\nconst BURST2_CYCLES = 28; // tighter-period trace standing in for 2100Hz\n\n// -- scan build: one committed line every 45ms, capped at 220 for card scale --\nconst LINE_INTERVAL_MS = 45;\nconst MAX_LINES = 220;\nconst MIN_LINES = 30;\nconst SLIP_BAND_LINES = 3;\nconst SLIP_MIN_PX = 14;\nconst SLIP_MAX_PX = 22;\nconst SLIP_RANGE_START = 0.55; // fraction down the frame the slip may start\nconst SLIP_RANGE_SPAN = 0.2; // 0.55 - 0.75\n\nconst HOLD_MS = 1800;\nconst WIPE_MS = 200;\n\nexport function FaxLineSlip({ className = \"\", style, label = \"Connecting\" }: FaxLineSlipProps) {\n  const rootRef = useRef<HTMLDivElement>(null);\n  const canvasRef = useRef<HTMLCanvasElement>(null);\n\n  useEffect(() => {\n    const root = rootRef.current;\n    const canvas = canvasRef.current;\n    if (!root || !canvas) return;\n    const ctx = canvas.getContext(\"2d\");\n    if (!ctx) return;\n\n    const mq = window.matchMedia(\"(prefers-reduced-motion: reduce)\");\n    let reduced = mq.matches;\n\n    // -- token-derived ink/paper: read at mount, re-derived on theme change --\n    let fg: RGB = [0, 0, 0];\n    let bg: RGB = [255, 255, 255];\n    let fgCSS = \"rgb(0,0,0)\";\n    let bgCSS = \"rgb(255,255,255)\";\n    const derive = () => {\n      const cs = getComputedStyle(document.documentElement);\n      fg = parseColor(cs.getPropertyValue(\"--foreground\")) ?? fg;\n      bg = parseColor(cs.getPropertyValue(\"--background\")) ?? bg;\n      fgCSS = `rgb(${fg[0]},${fg[1]},${fg[2]})`;\n      bgCSS = `rgb(${bg[0]},${bg[1]},${bg[2]})`;\n    };\n    derive();\n\n    let w = 0;\n    let h = 0;\n    let dpr = 1;\n    let lineCount = MIN_LINES;\n    let rowH = 1;\n    let letterCell = 3;\n    let lineSpacing = 7;\n    let marginPx = 6;\n\n    let seed = 0;\n    let slipStartLine = -1;\n    let slipOffsetPx = 0;\n    let cycleBase = 0;\n    let cycleIndex = 0;\n    let lastLineDrawn = -1;\n    let scanCleared = false;\n\n    let raf = 0;\n    let visible = true;\n\n    // -- one procedurally-baked text line's metadata: blank (paragraph gap)\n    // or a right margin fraction, deterministic per lineIndex+seed so every\n    // raster row belonging to that text line agrees on its shape. ----------\n    const lineMeta = (lineIndex: number) => {\n      const blankRoll = hash01(lineIndex * 1.7 + seed, 3.1);\n      if (blankRoll < 0.12) return { blank: true, widthFrac: 0 };\n      const widthFrac = 0.45 + hash01(lineIndex * 2.3 + seed, 7.7) * 0.45;\n      return { blank: false, widthFrac };\n    };\n\n    // -- commit one raster row of the document's baked content, offset in x\n    // by xOffsetPx when this row falls inside the current cycle's slip band. --\n    const drawDocumentRow = (y0: number, y1: number, xOffsetPx: number) => {\n      const midY = (y0 + y1) / 2;\n      const lineIndex = Math.floor(midY / lineSpacing);\n      const meta = lineMeta(lineIndex);\n      if (meta.blank) return;\n      const bandFrac = (midY - lineIndex * lineSpacing) / lineSpacing;\n      if (bandFrac > 0.5) return; // gap between text lines\n      const usableW = w - marginPx * 2;\n      const rightEdge = marginPx + meta.widthFrac * usableW;\n      ctx.fillStyle = fgCSS;\n      for (let x = marginPx; x < rightEdge; x += letterCell) {\n        const bucket = Math.floor(x / letterCell);\n        const v = hash01(lineIndex * 5.13 + seed, bucket * 3.71 + seed);\n        if (v > 0.34) {\n          ctx.fillRect(x + xOffsetPx, y0, letterCell * 0.82, Math.max(1, y1 - y0));\n        }\n      }\n    };\n\n    const clearCanvas = () => {\n      ctx.setTransform(dpr, 0, 0, dpr, 0, 0);\n      ctx.fillStyle = bgCSS;\n      ctx.fillRect(0, 0, w, h);\n    };\n\n    const commitLine = (lineIdx: number) => {\n      const y0 = lineIdx * rowH;\n      const y1 = y0 + rowH;\n      const inSlip = lineIdx >= slipStartLine && lineIdx < slipStartLine + SLIP_BAND_LINES;\n      drawDocumentRow(y0, y1, inSlip ? slipOffsetPx : 0);\n    };\n\n    const drawWaveform = (t: number) => {\n      const bandH = h * 0.15;\n      ctx.fillStyle = bgCSS;\n      ctx.fillRect(0, 0, w, bandH);\n      const midY = bandH * 0.5;\n      const amp = bandH * 0.32;\n      ctx.strokeStyle = fgCSS;\n      ctx.lineWidth = Math.max(1, Math.min(w, h) / 220);\n\n      const drawBurst = (revealFrac: number, cycles: number) => {\n        if (revealFrac <= 0) return;\n        const revealW = w * revealFrac;\n        const steps = Math.max(2, Math.floor(revealW / 2));\n        ctx.beginPath();\n        for (let i = 0; i <= steps; i++) {\n          const x = (i / steps) * revealW;\n          const phase = (x / w) * cycles * Math.PI * 2;\n          const y = midY + Math.sin(phase) * amp;\n          if (i === 0) ctx.moveTo(x, y);\n          else ctx.lineTo(x, y);\n        }\n        ctx.stroke();\n      };\n\n      if (t < BURST1_MS) {\n        drawBurst(t / BURST1_MS, BURST1_CYCLES);\n      } else if (t < BURST1_MS + GAP_MS) {\n        // silent gap — band stays clear\n      } else {\n        const t2 = t - BURST1_MS - GAP_MS;\n        drawBurst(Math.min(1, t2 / BURST2_MS), BURST2_CYCLES);\n      }\n    };\n\n    const drawWipe = (frac: number) => {\n      ctx.fillStyle = bgCSS;\n      ctx.fillRect(0, 0, w, h * frac);\n    };\n\n    const reseedCycle = () => {\n      seed = cycleIndex * 811.7;\n      const rand = mulberry32(0x9e3779b9 ^ (cycleIndex + 1));\n      slipStartLine = Math.max(\n        0,\n        Math.min(lineCount - SLIP_BAND_LINES, Math.floor(lineCount * (SLIP_RANGE_START + rand() * SLIP_RANGE_SPAN)))\n      );\n      const mag = SLIP_MIN_PX + rand() * (SLIP_MAX_PX - SLIP_MIN_PX);\n      slipOffsetPx = rand() < 0.5 ? mag : -mag;\n      lastLineDrawn = -1;\n      scanCleared = false;\n    };\n\n    const totalCycleMs = () => HANDSHAKE_MS + lineCount * LINE_INTERVAL_MS + HOLD_MS + WIPE_MS;\n\n    const drawStaticFrame = () => {\n      // reduced-motion freeze: the fully-scanned page at rest, slip baked in\n      reseedCycle();\n      clearCanvas();\n      for (let i = 0; i < lineCount; i++) commitLine(i);\n    };\n\n    const loop = (now: number) => {\n      let cycleTime = now - cycleBase;\n      const total = totalCycleMs();\n      if (cycleTime >= total) {\n        const laps = Math.floor(cycleTime / total);\n        cycleBase += laps * total;\n        cycleIndex += laps;\n        reseedCycle();\n        clearCanvas();\n        cycleTime = now - cycleBase;\n      }\n\n      const scanMs = lineCount * LINE_INTERVAL_MS;\n      if (cycleTime < HANDSHAKE_MS) {\n        drawWaveform(cycleTime);\n      } else if (cycleTime < HANDSHAKE_MS + scanMs) {\n        if (!scanCleared) {\n          clearCanvas();\n          scanCleared = true;\n        }\n        const scanElapsed = cycleTime - HANDSHAKE_MS;\n        const targetLine = Math.min(lineCount - 1, Math.floor(scanElapsed / LINE_INTERVAL_MS));\n        while (lastLineDrawn < targetLine) {\n          lastLineDrawn++;\n          commitLine(lastLineDrawn);\n        }\n      } else if (cycleTime < HANDSHAKE_MS + scanMs + HOLD_MS) {\n        if (!scanCleared) {\n          clearCanvas();\n          scanCleared = true;\n        }\n        while (lastLineDrawn < lineCount - 1) {\n          lastLineDrawn++;\n          commitLine(lastLineDrawn);\n        }\n      } else {\n        const wipeT = cycleTime - HANDSHAKE_MS - scanMs - HOLD_MS;\n        drawWipe(Math.min(1, wipeT / WIPE_MS));\n      }\n\n      if (!reduced && visible) raf = requestAnimationFrame(loop);\n      else raf = 0;\n    };\n\n    const wake = () => {\n      if (raf === 0 && !reduced && visible) raf = requestAnimationFrame(loop);\n    };\n\n    const resize = () => {\n      const rect = root.getBoundingClientRect();\n      w = rect.width;\n      h = rect.height;\n      dpr = Math.min(2, window.devicePixelRatio || 1);\n      canvas.width = Math.max(1, Math.round(w * dpr));\n      canvas.height = Math.max(1, Math.round(h * dpr));\n\n      // scan-line resolution derived from the container's smaller dimension\n      const minDim = Math.min(w, h);\n      lineCount = Math.min(MAX_LINES, Math.max(MIN_LINES, Math.round(h / 2)));\n      rowH = h / lineCount;\n      letterCell = Math.min(5, Math.max(2, minDim / 70));\n      lineSpacing = Math.min(9, Math.max(5, minDim / 28));\n      marginPx = Math.max(4, w * 0.05);\n\n      cycleBase = performance.now();\n      cycleIndex = 0;\n      reseedCycle();\n\n      if (reduced) {\n        drawStaticFrame();\n      } else {\n        clearCanvas();\n        wake();\n      }\n    };\n\n    resize();\n\n    const ro = new ResizeObserver(resize);\n    ro.observe(root);\n\n    const mo = new MutationObserver(() => {\n      derive();\n      if (reduced) drawStaticFrame();\n    });\n    mo.observe(document.documentElement, { attributes: true, attributeFilter: [\"class\"] });\n\n    const onReducedChange = () => {\n      reduced = mq.matches;\n      if (reduced) {\n        cancelAnimationFrame(raf);\n        raf = 0;\n        drawStaticFrame();\n      } else {\n        cycleBase = performance.now();\n        cycleIndex = 0;\n        reseedCycle();\n        clearCanvas();\n        wake();\n      }\n    };\n    mq.addEventListener(\"change\", onReducedChange);\n\n    const onVisibility = () => {\n      visible = document.visibilityState === \"visible\";\n      if (visible) wake();\n      else {\n        cancelAnimationFrame(raf);\n        raf = 0;\n      }\n    };\n    document.addEventListener(\"visibilitychange\", onVisibility);\n\n    const io = new IntersectionObserver((entries) => {\n      const intersecting = entries[0]?.isIntersecting ?? true;\n      visible = intersecting && document.visibilityState === \"visible\";\n      if (visible) wake();\n      else {\n        cancelAnimationFrame(raf);\n        raf = 0;\n      }\n    });\n    io.observe(root);\n\n    return () => {\n      cancelAnimationFrame(raf);\n      ro.disconnect();\n      mo.disconnect();\n      io.disconnect();\n      mq.removeEventListener(\"change\", onReducedChange);\n      document.removeEventListener(\"visibilitychange\", onVisibility);\n    };\n  }, []);\n\n  return (\n    <div\n      ref={rootRef}\n      role=\"status\"\n      aria-busy=\"true\"\n      className={`ns-fls relative h-full w-full overflow-hidden bg-background ${className}`}\n      style={style}\n    >\n      <span className=\"sr-only\">{label}</span>\n      <canvas ref={canvasRef} aria-hidden=\"true\" className=\"pointer-events-none absolute inset-0 h-full w-full\" />\n    </div>\n  );\n}\n\nFaxLineSlip.displayName = \"FaxLineSlip\";\n\nexport default FaxLineSlip;\n",
      "type": "registry:ui",
      "target": "components/ui/fax-line-slip.tsx"
    }
  ],
  "cssVars": {
    "theme": {
      "color-ns-accent": "var(--ns-accent)"
    },
    "light": {
      "ns-accent": "#006bff"
    }
  },
  "meta": {
    "collection": "core",
    "tags": [
      "loading",
      "canvas",
      "fax",
      "print",
      "document",
      "scan",
      "ambient",
      "monochrome"
    ],
    "instruction": "Build <FaxLineSlip label? className? style?>, a self-contained canvas-rendered loading/connecting state for a document preview or attachment thumbnail, sourced from the ITU-T T.30 Group 3 fax handshake and page-transmission sequence plus the documented thermal-fax paper-slip mechanical fault, not an invented spinner texture. Root is `relative h-full w-full overflow-hidden bg-background` with `role=\"status\" aria-busy=\"true\"`, an sr-only status label (default 'Connecting'), and an absolutely positioned `aria-hidden` canvas filling the host (`w-full h-full`, DPR-capped at 2, backing store sized off `getBoundingClientRect`).\n\nOne cycle runs four phases on a SINGLE canvas that is never cleared mid-build (content accumulates, like a page actually being received), driven by elapsed time via `performance.now() - cycleBase`, never frame count:\n\n1. Handshake, fixed 2200ms (`BURST1_MS=500, GAP_MS=500, BURST2_MS=1200`): a waveform trace occupies the top 15% of the frame height. First 500ms draws a tight-period sine trace (15 cycles across the container width) standing in for the 1100Hz CNG tone, revealed left-to-right as `revealFrac = t/500` (redraw the band's background then stroke the partial trace every frame — the trace visibly builds, it does not appear instantly). Next 500ms the band is silent (cleared to background, nothing drawn). Final 1200ms draws a tighter-period trace (28 cycles) standing in for the 2100Hz CED tone, same left-to-right reveal logic. This handshake plays at its real, human-followable pace — it is the ONE audible-register detail rendered visually and is deliberately not sped up.\n\n2. Scan build: once the handshake ends, clear the whole canvas to `--background` once, then commit exactly one scan line every 45ms (`LINE_INTERVAL_MS=45`, ~22 lines/sec — real G3 at 9600bps is ~5-6 lines/sec, but rendering 220 lines at that real rate would take ~40s, too slow for a resting loop, so this is compressed for pace, not decoupled for alias-avoidance). Line count is derived from the container's own smaller dimension: `lineCount = clamp(round(height/2), 30, 220)`, each line's raster row height `rowH = height/lineCount`. Track a `lastLineDrawn` cursor and, each frame, advance it up to `floor(scanElapsed/45)` clamped to `lineCount-1`, drawing each newly-committed row exactly once and leaving already-drawn rows untouched (this is what makes the build read as content arriving, not a repaint).\n\nEach row's content is procedurally baked 'scanned text', deterministic per `(lineIndex, seed)` so every raster row belonging to the same text line agrees: `lineSpacing = clamp(minDim/28, 5, 9)` px groups raster rows into text lines; a per-line hash (`hash01(lineIndex*1.7+seed, 3.1) < 0.12`) marks ~12% of lines as blank paragraph gaps; surviving lines get a right-margin `widthFrac` in [0.45, 0.9] from a second per-line hash; within a line's top half band (`bandFrac <= 0.5`, giving each text line visible gaps between it and the next), fill `letterCell`-wide buckets (`letterCell = clamp(minDim/70, 2, 5)`) left-to-right from a 5% left margin to the right edge, each bucket inked via `hash01(lineIndex*5.13+seed, bucket*3.71+seed) > 0.34` — word-like dashes, not a solid bar. `seed` is reseeded every cycle (`seed = cycleIndex*811.7`) so no two received pages match.\n\nPaper slip: once per cycle, at reseed time, pick `slipStartLine = clamp(floor(lineCount*(0.55+rand()*0.20)), 0, lineCount-3)` and `slipOffsetPx = ±(14 + rand()*8)` from a per-cycle seeded RNG. The 3 rows in `[slipStartLine, slipStartLine+3)` are drawn with their x positions shifted by `slipOffsetPx`; every other row draws at zero offset. Because each row is committed once, in order, with no easing between rows, the shift appears as a hard, instantaneous discontinuity exactly when the scan front reaches that band, and the very next committed row snaps back to true — never repeating within the cycle.\n\n3. Hold, fixed 1800ms: the fully composed page sits static, no redraw needed (canvas already holds the final pixels).\n\n4. Reset, fixed 200ms: the frame does not fade — each frame fill a `--background` rect from y=0 to `height * min(1, wipeElapsed/200)`, wiping the page away top to bottom like a fresh page feeding in. At the end of this phase the cycle wraps: reseed (`seed`, new slip position/offset), clear the canvas, and the handshake replays.\n\nColour: `--foreground` ink over a `--background` field, both read via `getComputedStyle(document.documentElement)` at mount and re-derived on a `MutationObserver` watching `documentElement`'s class (no colour literal anywhere except the pre-first-read JS fallback pair) — dark marks on light stock in light theme, and dark theme inverts which token paints which without swapping the token names. `--ns-accent` never appears anywhere in the canvas. Verify at card scale in light theme specifically that the paper-slip band's edge stays legible — it needs the same value contrast as every other row, only a position shift, so check it isn't reading as imperceptible against the already-lower light-theme base contrast.\n\nThe rAF loop is paused via `document.visibilitychange` and an `IntersectionObserver` (both gate a `wake()` that only restarts the loop if not reduced-motion and currently visible), and a `ResizeObserver` triggers a full `resize()` that recomputes geometry, restarts `cycleBase`, reseeds, and clears the canvas (a resized panel gets a freshly regenerated page, not a stretched old one). `prefers-reduced-motion: reduce` never starts the rAF loop at all: it reseeds once, clears, and synchronously commits every line via the same `commitLine` path used by the live build, freezing on the fully-scanned page WITH the paper-slip artifact baked into its band — the most information-carrying frame, showing both the completed content and the fault that gives the component its identity. No interaction — this is a passive connecting state. On unmount: cancel the rAF, disconnect the ResizeObserver/MutationObserver/IntersectionObserver, remove the visibilitychange listener and the reduced-motion media query listener. No dependencies."
  },
  "type": "registry:ui"
}