{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "photostat-reverse",
  "title": "Photostat Reverse",
  "description": "A full-bleed hero headline reproduced like a photostat camera: each exposure flips the field between a negative and a positive generation, a little softer and thicker than the one before, until a fresh crisp exposure resets the drift.",
  "dependencies": [],
  "files": [
    {
      "path": "registry/loud/photostat-reverse/component.tsx",
      "content": "\"use client\";\n\nimport { useEffect, useId, useRef } from \"react\";\n\n// ---------------------------------------------------------------------------\n// PhotostatReverse — a full-bleed hero headline reproduced the way a photostat\n// camera reproduced line art: direct-onto-paper photography that comes out\n// TONE-REVERSED (a negative), so getting a true-tone copy back means shooting\n// the negative a second time. Every generation pass also softens and thickens\n// the line work a little — real, documented generation loss in photographic\n// line-art reproduction — and the whole thing compounds for five generations\n// before a fresh, crisp exposure resets it.\n//\n// The palette problem is solved the same way weld-pool and flyback-tear solve\n// it: the mechanic IS a value inversion already, so there is nothing to\n// re-hue. Two tokens only, --background and --foreground, and which one\n// plays \"ink\" versus \"field\" is exactly what flips every 1.3s.\n//\n// APPROXIMATE SDF: a true signed-distance field is overkill for one rasterized\n// headline, so the mask is baked once (on mount / resize / headline change) as\n// two channels of one texture — R a sharp alpha straight off the 2D canvas\n// (whose own antialiasing already gives a few px of usable gradient at every\n// edge), G the same mask blurred by the canvas 2D `filter: blur()` — and the\n// runtime shader mixes R -> G by how degraded the current generation is, then\n// re-thresholds the mix. Thresholding a blurred alpha below 0.5 dilates it\n// (thicker strokes), above 0.5 erodes it (thinner) — the standard cheap\n// stand-in for a real distance field, and exactly what \"stroke width grows,\n// edges soften\" needs.\n// ---------------------------------------------------------------------------\n\nexport interface PhotostatReverseProps {\n  /** Headline rasterized into the mask. \"\\n\" splits lines. */\n  headline?: string;\n  /** Glyph weight for the headline. @default 800 */\n  headlineWeight?: number;\n  /** Fraction of the width the longest headline line fills. @default 0.62 */\n  headlineFit?: number;\n  /** Vertical centre of the headline, 0..1. @default 0.5 */\n  headlineY?: number;\n  /** Generation-cycle and paper-grain speed. @default 1 */\n  speed?: number;\n  /** Freezes on the composed still frame without unmounting. */\n  paused?: boolean;\n  /** Rendered in the DOM over the field — eyebrow, subhead, CTA. */\n  children?: React.ReactNode;\n  className?: string;\n  style?: React.CSSProperties;\n}\n\nconst VERT_SRC = `\nattribute vec2 a_pos;\nvoid main() { gl_Position = vec4(a_pos, 0.0, 1.0); }\n`;\n\nconst FRAG_SRC = `\nprecision highp float;\n\nuniform vec2 u_size;\nuniform float u_dpr;\nuniform float u_time;\nuniform sampler2D u_mask;\n\n// from-generation (outgoing) params\nuniform float u_threshFrom;\nuniform float u_edgeFrom;\nuniform float u_toneFrom;   // 0 = positive (fg on bg), 1 = negative (bg on fg)\n// to-generation (incoming) params\nuniform float u_threshTo;\nuniform float u_edgeTo;\nuniform float u_toneTo;\nuniform float u_transition; // 0..1, eased, 0 = fully \"from\", 1 = fully \"to\"\n\nuniform vec2 u_ptr;         // css px, y down\nuniform float u_ptrActive;\nuniform float u_ptrRadius;\n\nuniform vec3 u_bg;\nuniform vec3 u_fg;\n\nfloat hash21(vec2 p) {\n  p = fract(p * vec2(287.13, 419.71));\n  p += dot(p, p + 27.31);\n  return fract(p.x * p.y);\n}\n\n// effective in-letter fraction for one generation's params, given the same\n// raw soft-alpha sample: thresholding a blurred mask below 0.5 dilates it,\n// above erodes it, which is the whole \"stroke width\" knob.\nfloat genMask(float soft, float thresh, float edge, float tone) {\n  float m = smoothstep(thresh - edge, thresh + edge, soft);\n  // tone flip swaps which side of the mask is \"ink\": a negative generation\n  // reads the FIELD as ink and the letterform as the gap in it.\n  return mix(m, 1.0 - m, tone);\n}\n\nvoid main() {\n  vec2 fc = gl_FragCoord.xy / u_dpr;\n  vec2 uv = vec2(fc.x / u_size.x, fc.y / u_size.y);\n\n  vec2 mc = texture2D(u_mask, uv).rg;\n  float sharp = mc.r;\n  float softSample = mc.g;\n\n  // local \"fresh exposure\": pointer proximity pulls BOTH the from- and the\n  // to-generation params toward generation-1 crispness (edge -> its floor,\n  // threshold -> 0.5) without touching the tone flip or the global cadence,\n  // so hovering never desyncs the flip elsewhere in the frame.\n  float d = distance(fc, u_ptr);\n  float crisp = u_ptrActive * smoothstep(u_ptrRadius, 0.0, d);\n  float tf = mix(u_threshFrom, 0.5, crisp);\n  float ef = mix(u_edgeFrom, 0.012, crisp);\n  float tt = mix(u_threshTo, 0.5, crisp);\n  float et = mix(u_edgeTo, 0.012, crisp);\n\n  // Softness itself also relaxes toward the sharp channel under the pointer\n  // (a fresh exposure has no accumulated blur to draw from).\n  float soft = mix(softSample, sharp, crisp);\n  soft = mix(soft, sharp, 0.0); // keep sharp channel reachable below\n\n  float mFrom = genMask(mix(soft, sharp, crisp), tf, ef, u_toneFrom);\n  float mTo = genMask(mix(soft, sharp, crisp), tt, et, u_toneTo);\n\n  // THE FLIP: both generations are evaluated against the SAME mask sample and\n  // blended by the eased transition, so the 260ms window genuinely shows\n  // departure (mFrom fading out) and arrival (mTo fading in) rather than a\n  // hard cut between two textures.\n  float m = mix(mFrom, mTo, u_transition);\n\n  vec3 col = mix(u_bg, u_fg, m);\n\n  // paper grain: photostat stock is never perfectly flat. A slow per-texel\n  // hash keeps the field alive at rest even in a stretch between flips, at an\n  // amplitude far below the flip itself so it never competes with it.\n  float grain = hash21(floor(fc * 0.6) + floor(u_time * 3.0));\n  col += (grain - 0.5) * 0.025;\n\n  // a soft vignette toward the deepest stop keeps the frame edges from\n  // competing with the headline\n  vec2 cc = uv * 2.0 - 1.0;\n  float vig = smoothstep(0.55, 1.35, length(cc));\n  col = mix(col, mix(u_bg, u_fg, m * 0.6), vig * 0.12);\n\n  gl_FragColor = vec4(clamp(col, 0.0, 1.0), 1.0);\n}\n`;\n\ntype RGB = [number, number, number];\n\nfunction parseHex(raw: string): RGB | null {\n  const m = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(raw.trim());\n  if (!m) return null;\n  let h = m[1];\n  if (h.length === 3) h = h.split(\"\").map((c) => c + c).join(\"\");\n  const n = parseInt(h, 16);\n  return [((n >> 16) & 255) / 255, ((n >> 8) & 255) / 255, (n & 255) / 255];\n}\n\nfunction luminance([r, g, b]: RGB): number {\n  return 0.2126 * r + 0.7152 * g + 0.0722 * b;\n}\n\nfunction compile(gl: WebGLRenderingContext, type: number, src: string): WebGLShader {\n  const s = gl.createShader(type)!;\n  gl.shaderSource(s, src);\n  gl.compileShader(s);\n  if (!gl.getShaderParameter(s, gl.COMPILE_STATUS)) {\n    const info = gl.getShaderInfoLog(s);\n    gl.deleteShader(s);\n    throw new Error(`photostat-reverse: shader compile failed: ${info ?? \"\"}`);\n  }\n  return s;\n}\n\nclass Program {\n  prog: WebGLProgram | null = null;\n  private vs: WebGLShader | null = null;\n  private fs: WebGLShader | null = null;\n  private locs = new Map<string, WebGLUniformLocation | null>();\n\n  constructor(private gl: WebGLRenderingContext, frag: string) {\n    this.vs = compile(gl, gl.VERTEX_SHADER, VERT_SRC);\n    this.fs = compile(gl, gl.FRAGMENT_SHADER, frag);\n    const p = gl.createProgram();\n    if (!p) throw new Error(\"photostat-reverse: createProgram failed\");\n    this.prog = p;\n    gl.attachShader(p, this.vs);\n    gl.attachShader(p, this.fs);\n    gl.bindAttribLocation(p, 0, \"a_pos\");\n    gl.linkProgram(p);\n    if (!gl.getProgramParameter(p, gl.LINK_STATUS)) {\n      throw new Error(\"photostat-reverse: link failed\");\n    }\n  }\n\n  use() {\n    this.gl.useProgram(this.prog);\n  }\n\n  private loc(name: string) {\n    if (!this.locs.has(name)) {\n      this.locs.set(name, this.gl.getUniformLocation(this.prog!, name));\n    }\n    return this.locs.get(name) ?? null;\n  }\n\n  f(name: string, x: number) {\n    this.gl.uniform1f(this.loc(name), x);\n  }\n  i(name: string, x: number) {\n    this.gl.uniform1i(this.loc(name), x);\n  }\n  v2(name: string, x: number, y: number) {\n    this.gl.uniform2f(this.loc(name), x, y);\n  }\n  v3(name: string, c: RGB) {\n    this.gl.uniform3f(this.loc(name), c[0], c[1], c[2]);\n  }\n\n  destroy() {\n    const gl = this.gl;\n    if (this.prog) gl.deleteProgram(this.prog);\n    if (this.vs) gl.deleteShader(this.vs);\n    if (this.fs) gl.deleteShader(this.fs);\n    this.prog = null;\n    this.vs = null;\n    this.fs = null;\n    this.locs.clear();\n  }\n}\n\n// Generation cadence, in seconds. One flip every 1.3s, a 260ms crossfade for\n// each flip, five generations (indices 0..4) compounding stroke growth (4%)\n// and edge softening (6%) before a crisp, non-flipping reset back to index 0.\nconst GEN_PERIOD = 1.3;\nconst TRANSITION_DUR = 0.26;\nconst STROKE_GROWTH = 1.04;\nconst EDGE_GROWTH = 1.06;\nconst BASE_THRESH_STEP = 0.024;\nconst BASE_EDGE = 0.014;\n\n// generation index -> (thresholdOffset, edgeWidth) for that generation's own\n// degradation level, compounding STROKE_GROWTH / EDGE_GROWTH per step\nfunction genParams(level: number): { thresh: number; edge: number } {\n  const strokeGrowth = Math.pow(STROKE_GROWTH, level) - 1;\n  const edgeGrowth = Math.pow(EDGE_GROWTH, level);\n  return {\n    thresh: 0.5 - BASE_THRESH_STEP * strokeGrowth * 4, // dilation grows with generation\n    edge: BASE_EDGE * edgeGrowth,\n  };\n}\n\n// The reduced-motion / paused still: generation 3 (index 2), positive tone,\n// fully settled — soft enough to show generation loss exists, still legible,\n// never the near-mush of generation 5.\nconst STATIC_LEVEL = 2;\nconst STATIC_TONE = 0;\n\nexport function PhotostatReverse({\n  headline = \"GENERATION\\nLOSS\",\n  headlineWeight = 800,\n  headlineFit = 0.62,\n  headlineY = 0.5,\n  speed = 1,\n  paused = false,\n  children,\n  className = \"\",\n  style,\n}: PhotostatReverseProps) {\n  const wrapRef = useRef<HTMLDivElement | null>(null);\n  const canvasRef = useRef<HTMLCanvasElement | null>(null);\n  const uid = useId();\n\n  const pausedRef = useRef(paused);\n  pausedRef.current = paused;\n  const headlineRef = useRef(headline);\n  headlineRef.current = headline;\n\n  useEffect(() => {\n    const wrap = wrapRef.current;\n    const canvas = canvasRef.current;\n    if (!wrap || !canvas) return;\n\n    const gl = canvas.getContext(\"webgl\", {\n      alpha: false,\n      antialias: false,\n      depth: false,\n      stencil: false,\n      premultipliedAlpha: false,\n      powerPreference: \"high-performance\",\n    }) as WebGLRenderingContext | null;\n    if (!gl) return; // no WebGL: children still render over the page background\n\n    let prog: Program | null = null;\n    let quad: WebGLBuffer | null = null;\n    let mask: WebGLTexture | null = null;\n    let maskReady = false;\n\n    let raf = 0;\n    let running = false;\n    let staticMode = false;\n    let disposed = false;\n    let cssW = 0;\n    let cssH = 0;\n    let dpr = 1;\n    let lastMs = performance.now();\n    let simTime = 0;\n\n    // Cheap single-pass shader (one texture sample, two smoothstep evals) —\n    // full DPR holds comfortably, unlike the multi-tap accumulation passes in\n    // weld-pool/flyback-tear that cap lower.\n    const SCALES = [1, 0.8, 0.65];\n    const BUDGET_OVER = 24;\n    let scaleIdx = 0;\n    let frameEma = 16.7;\n    let overMs = 0;\n    let underMs = 0;\n    let upWindow = 8000;\n    const DPR_CAP = 2;\n\n    // ---- generation state machine -----------------------------------------\n    let genLevel = 0; // 0..4\n    let toneTo = STATIC_TONE;\n    let toneFrom = STATIC_TONE;\n    let threshFrom = 0.5;\n    let edgeFrom = BASE_EDGE;\n    let threshTo = 0.5;\n    let edgeTo = BASE_EDGE;\n    let nextFlipAt = GEN_PERIOD;\n    let transitionStart = 0;\n\n    const armGeneration = (level: number, tone: number) => {\n      const p = genParams(level);\n      threshFrom = threshTo;\n      edgeFrom = edgeTo;\n      toneFrom = toneTo;\n      threshTo = p.thresh;\n      edgeTo = p.edge;\n      toneTo = tone;\n    };\n\n    const stepGenerations = (t: number) => {\n      if (t < nextFlipAt) return;\n      transitionStart = nextFlipAt;\n      nextFlipAt = t + GEN_PERIOD;\n      const wasAtTop = genLevel >= 4;\n      const nextLevel = wasAtTop ? 0 : genLevel + 1;\n      // every step flips tone EXCEPT the reset step (top generation back to\n      // level 0), which lands crisp on whatever tone was already showing\n      const nextTone = wasAtTop ? toneTo : toneTo === 0 ? 1 : 0;\n      genLevel = nextLevel;\n      armGeneration(nextLevel, nextTone);\n    };\n    // seed generation 1 (level 0) as both from/to so frame 0 already reads\n    // as a settled state, not an interpolation from nothing\n    armGeneration(0, STATIC_TONE);\n    threshFrom = threshTo;\n    edgeFrom = edgeTo;\n    toneFrom = toneTo;\n\n    // ---- pointer -----------------------------------------------------------\n    let havePointer = false;\n    let tgtX = 0;\n    let tgtY = 0;\n    let ptrX = 0;\n    let ptrY = 0;\n    let ptrActive = 0;\n    let rectLeft = 0;\n    let rectTop = 0;\n    let rectDirty = true;\n\n    const stepPointer = (dt: number) => {\n      const k = 1 - Math.exp(-dt / 0.05);\n      ptrX += (tgtX - ptrX) * k;\n      ptrY += (tgtY - ptrY) * k;\n      const targetActive = havePointer ? 1 : 0;\n      ptrActive += (targetActive - ptrActive) * (1 - Math.exp(-dt / 0.12));\n    };\n\n    // ---- palette -----------------------------------------------------------\n    let bg: RGB = [1, 1, 1];\n    let fg: RGB = [0.09, 0.09, 0.09];\n\n    const readColors = () => {\n      const cs = getComputedStyle(document.documentElement);\n      bg = parseHex(cs.getPropertyValue(\"--background\")) ?? [1, 1, 1];\n      fg = parseHex(cs.getPropertyValue(\"--foreground\")) ?? [0.09, 0.09, 0.09];\n      // The tone flip already IS the value inversion the monochrome rule asks\n      // for, so both themes read from the same two tokens with no bias/contrast\n      // remap — unlike a shaded material, a two-tone field has no \"direction\"\n      // left to invert; --background/--foreground already carry it correctly\n      // in both themes by construction.\n      void luminance; // referenced for parity with sibling files' palette shape\n    };\n    readColors();\n\n    // ---- headline mask: R = sharp alpha, G = blurred alpha -----------------\n    const sharpCanvas = document.createElement(\"canvas\");\n    const softCanvas = document.createElement(\"canvas\");\n\n    const rasterizeText = () => {\n      if (cssW < 2 || cssH < 2) return;\n      const tw = Math.max(256, Math.min(1024, Math.round(cssW)));\n      const th = Math.max(128, Math.round(tw * (cssH / cssW)));\n      sharpCanvas.width = tw;\n      sharpCanvas.height = th;\n      softCanvas.width = tw;\n      softCanvas.height = th;\n      const sctx = sharpCanvas.getContext(\"2d\", { willReadFrequently: true });\n      const soctx = softCanvas.getContext(\"2d\", { willReadFrequently: true });\n      if (!sctx || !soctx) return;\n\n      const family = getComputedStyle(wrap).fontFamily || \"system-ui, sans-serif\";\n      const lines = headlineRef.current.split(\"\\n\").filter((l) => l.length > 0);\n      sctx.clearRect(0, 0, tw, th);\n      if (lines.length > 0) {\n        const probe = 100;\n        sctx.font = `${headlineWeight} ${probe}px ${family}`;\n        let widest = 1;\n        for (const line of lines) widest = Math.max(widest, sctx.measureText(line).width);\n        const size = Math.min((tw * headlineFit * probe) / widest, (th * 0.42) / lines.length);\n        sctx.font = `${headlineWeight} ${size}px ${family}`;\n        sctx.textAlign = \"center\";\n        sctx.textBaseline = \"middle\";\n        sctx.fillStyle = \"#fff\";\n        const lead = size * 1.06;\n        const top = th * headlineY - ((lines.length - 1) * lead) / 2;\n        for (let i = 0; i < lines.length; i++) sctx.fillText(lines[i], tw / 2, top + i * lead);\n      }\n\n      // soft channel: the same sharp raster, redrawn through a canvas 2D blur\n      // filter — the cheap stand-in for a distance field's falloff, baked\n      // once per text/size change rather than swept per-frame in the shader\n      soctx.clearRect(0, 0, tw, th);\n      const blurPx = Math.max(2, tw * 0.02);\n      soctx.filter = `blur(${blurPx}px)`;\n      soctx.drawImage(sharpCanvas, 0, 0);\n      soctx.filter = \"none\";\n\n      const sharpImg = sctx.getImageData(0, 0, tw, th).data;\n      const softImg = soctx.getImageData(0, 0, tw, th).data;\n      const rgba = new Uint8Array(tw * th * 4);\n      for (let i = 0, j = 0; i < tw * th; i++, j += 4) {\n        rgba[j] = sharpImg[j + 3];\n        rgba[j + 1] = softImg[j + 3];\n        rgba[j + 3] = 255;\n      }\n      if (!mask) {\n        mask = gl.createTexture();\n        gl.bindTexture(gl.TEXTURE_2D, mask);\n        gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);\n        gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);\n        gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);\n        gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);\n      } else {\n        gl.bindTexture(gl.TEXTURE_2D, mask);\n      }\n      gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, tw, th, 0, gl.RGBA, gl.UNSIGNED_BYTE, rgba);\n      maskReady = lines.length > 0;\n    };\n\n    // ---- draw ---------------------------------------------------------------\n    const composite = () => {\n      if (!prog || !mask) return;\n      prog.use();\n      gl.bindFramebuffer(gl.FRAMEBUFFER, null);\n      gl.viewport(0, 0, canvas.width, canvas.height);\n      gl.activeTexture(gl.TEXTURE0);\n      gl.bindTexture(gl.TEXTURE_2D, mask);\n      prog.i(\"u_mask\", 0);\n      prog.v2(\"u_size\", cssW, cssH);\n      prog.f(\"u_dpr\", dpr);\n      prog.f(\"u_time\", simTime);\n\n      let tf = threshFrom;\n      let ef = edgeFrom;\n      let tt = threshTo;\n      let et = edgeTo;\n      let tone0 = toneFrom;\n      let tone1 = toneTo;\n      let transition = staticMode\n        ? 1\n        : Math.min(1, Math.max(0, (simTime - transitionStart) / TRANSITION_DUR));\n      transition = transition * transition * (3 - 2 * transition); // smoothstep ease\n\n      if (staticMode) {\n        const p = genParams(STATIC_LEVEL);\n        tf = p.thresh;\n        ef = p.edge;\n        tt = p.thresh;\n        et = p.edge;\n        tone0 = STATIC_TONE;\n        tone1 = STATIC_TONE;\n      }\n\n      prog.f(\"u_threshFrom\", tf);\n      prog.f(\"u_edgeFrom\", ef);\n      prog.f(\"u_toneFrom\", tone0);\n      prog.f(\"u_threshTo\", tt);\n      prog.f(\"u_edgeTo\", et);\n      prog.f(\"u_toneTo\", tone1);\n      prog.f(\"u_transition\", transition);\n      prog.v2(\"u_ptr\", ptrX, canvas.height / dpr - ptrY);\n      prog.f(\"u_ptrActive\", staticMode ? ptrActive : ptrActive);\n      prog.f(\"u_ptrRadius\", Math.min(cssW, cssH) * 0.22);\n      prog.v3(\"u_bg\", bg);\n      prog.v3(\"u_fg\", fg);\n      gl.drawArrays(gl.TRIANGLES, 0, 6);\n    };\n\n    const loop = (nowMs: number) => {\n      const rawMs = nowMs - lastMs;\n      const dt = Math.min(0.05, Math.max(0.0005, rawMs / 1000));\n      lastMs = nowMs;\n      simTime += dt * Math.max(0.05, speed);\n      stepGenerations(simTime);\n      stepPointer(dt);\n      composite();\n\n      const clamped = Math.min(50, rawMs);\n      frameEma += (clamped - frameEma) * (1 - Math.exp(-clamped / 120));\n      if (frameEma > BUDGET_OVER) {\n        overMs += clamped;\n        underMs = 0;\n      } else {\n        underMs += clamped;\n        overMs = 0;\n      }\n      const down = overMs > 900 && scaleIdx < SCALES.length - 1;\n      const up = underMs > upWindow && scaleIdx > 0;\n      if (down || up) {\n        scaleIdx += down ? 1 : -1;\n        if (down) upWindow = Math.min(64000, upWindow * 2);\n        overMs = 0;\n        underMs = 0;\n        frameEma = 16.7;\n        applyBacking();\n      }\n      raf = requestAnimationFrame(loop);\n    };\n\n    const wake = () => {\n      if (running || disposed) return;\n      running = true;\n      lastMs = performance.now();\n      raf = requestAnimationFrame(loop);\n    };\n    const sleep = () => {\n      cancelAnimationFrame(raf);\n      running = false;\n    };\n\n    const applyBacking = () => {\n      if (cssW < 2 || cssH < 2) return;\n      dpr = Math.min(window.devicePixelRatio || 1, DPR_CAP) * SCALES[scaleIdx];\n      const pw = Math.max(2, Math.round(cssW * dpr));\n      const ph = Math.max(2, Math.round(cssH * dpr));\n      canvas.width = pw;\n      canvas.height = ph;\n      canvas.style.width = `${cssW}px`;\n      canvas.style.height = `${cssH}px`;\n      if (maskReady) composite();\n    };\n\n    const resize = () => {\n      const rect = wrap.getBoundingClientRect();\n      if (rect.width < 2 || rect.height < 2) return;\n      const changed = Math.abs(rect.width - cssW) > 0.5 || Math.abs(rect.height - cssH) > 0.5;\n      cssW = rect.width;\n      cssH = rect.height;\n      rectLeft = rect.left;\n      rectTop = rect.top;\n      rectDirty = false;\n      scaleIdx = 0;\n      overMs = 0;\n      underMs = 0;\n      upWindow = 8000;\n      frameEma = 16.7;\n      if (changed) rasterizeText();\n      applyBacking();\n    };\n\n    const syncRect = () => {\n      if (!rectDirty) return;\n      const rect = wrap.getBoundingClientRect();\n      rectLeft = rect.left;\n      rectTop = rect.top;\n      rectDirty = false;\n    };\n    const markRectDirty = () => {\n      rectDirty = true;\n    };\n\n    const setTarget = (e: PointerEvent) => {\n      syncRect();\n      tgtX = e.clientX - rectLeft;\n      tgtY = e.clientY - rectTop;\n    };\n    const onPointerEnter = (e: PointerEvent) => {\n      setTarget(e);\n      ptrX = tgtX;\n      ptrY = tgtY;\n      havePointer = true;\n      if (staticMode) composite();\n    };\n    const onPointerMove = (e: PointerEvent) => {\n      setTarget(e);\n      havePointer = true;\n      if (staticMode) {\n        ptrX = tgtX;\n        ptrY = tgtY;\n        ptrActive = 1;\n        composite();\n      }\n    };\n    const onPointerLeave = () => {\n      havePointer = false;\n      if (staticMode) {\n        ptrActive = 0;\n        composite();\n      }\n    };\n\n    const buildProgram = (): boolean => {\n      try {\n        prog = new Program(gl, FRAG_SRC);\n      } catch {\n        return false;\n      }\n      quad = gl.createBuffer();\n      gl.bindBuffer(gl.ARRAY_BUFFER, quad);\n      gl.bufferData(\n        gl.ARRAY_BUFFER,\n        new Float32Array([-1, -1, 1, -1, -1, 1, -1, 1, 1, -1, 1, 1]),\n        gl.STATIC_DRAW\n      );\n      gl.enableVertexAttribArray(0);\n      gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0);\n      return true;\n    };\n\n    if (!buildProgram()) return;\n\n    const ro = new ResizeObserver(resize);\n    ro.observe(wrap);\n    resize();\n    // webfont metrics are not final at mount, and the mask is baked once from\n    // measured text — without this a fallback-font raster freezes permanently\n    if (document.fonts?.ready) {\n      document.fonts.ready.then(() => {\n        if (disposed) return;\n        rasterizeText();\n        if (staticMode) composite();\n      });\n    }\n\n    wrap.addEventListener(\"pointerenter\", onPointerEnter);\n    wrap.addEventListener(\"pointermove\", onPointerMove);\n    wrap.addEventListener(\"pointerleave\", onPointerLeave);\n    window.addEventListener(\"scroll\", markRectDirty, { passive: true, capture: true });\n    window.addEventListener(\"resize\", markRectDirty, { passive: true });\n\n    const mq = window.matchMedia(\"(prefers-reduced-motion: reduce)\");\n    let reduced = mq.matches;\n    const applyMode = () => {\n      if (reduced || pausedRef.current) {\n        staticMode = true;\n        sleep();\n        if (maskReady) composite();\n      } else {\n        staticMode = false;\n        wake();\n      }\n    };\n    const onMq = () => {\n      reduced = mq.matches;\n      applyMode();\n    };\n    mq.addEventListener(\"change\", onMq);\n\n    let onScreen = true;\n    const io = new IntersectionObserver(\n      (entries) => {\n        onScreen = entries.some((en) => en.isIntersecting);\n        if (!onScreen) sleep();\n        else if (!staticMode && !document.hidden) wake();\n      },\n      { threshold: 0 }\n    );\n    io.observe(wrap);\n\n    const onVis = () => {\n      if (document.hidden) sleep();\n      else if (!staticMode && onScreen) wake();\n    };\n    document.addEventListener(\"visibilitychange\", onVis);\n    applyMode();\n\n    let lastPolledPaused = pausedRef.current;\n    let lastPolledHeadline = headlineRef.current;\n    let poll = 0;\n    const tick = () => {\n      if (pausedRef.current !== lastPolledPaused) {\n        lastPolledPaused = pausedRef.current;\n        applyMode();\n      }\n      if (headlineRef.current !== lastPolledHeadline) {\n        lastPolledHeadline = headlineRef.current;\n        rasterizeText();\n        if (staticMode) composite();\n      }\n      poll = window.setTimeout(tick, 140);\n    };\n    tick();\n\n    const themeObserver = new MutationObserver(() => {\n      readColors();\n      if (staticMode) composite();\n    });\n    themeObserver.observe(document.documentElement, {\n      attributes: true,\n      attributeFilter: [\"class\"],\n    });\n\n    const onLost = (e: Event) => {\n      e.preventDefault();\n      sleep();\n    };\n    const onRestored = () => {\n      prog = null;\n      mask = null;\n      quad = null;\n      maskReady = false;\n      if (!buildProgram()) return;\n      cssW = 0;\n      cssH = 0;\n      resize();\n      rasterizeText();\n      applyMode();\n    };\n    canvas.addEventListener(\"webglcontextlost\", onLost);\n    canvas.addEventListener(\"webglcontextrestored\", onRestored);\n\n    return () => {\n      disposed = true;\n      ro.disconnect();\n      io.disconnect();\n      mq.removeEventListener(\"change\", onMq);\n      document.removeEventListener(\"visibilitychange\", onVis);\n      themeObserver.disconnect();\n      canvas.removeEventListener(\"webglcontextlost\", onLost);\n      canvas.removeEventListener(\"webglcontextrestored\", onRestored);\n      wrap.removeEventListener(\"pointerenter\", onPointerEnter);\n      wrap.removeEventListener(\"pointermove\", onPointerMove);\n      wrap.removeEventListener(\"pointerleave\", onPointerLeave);\n      window.removeEventListener(\"scroll\", markRectDirty, {\n        capture: true,\n      } as EventListenerOptions);\n      window.removeEventListener(\"resize\", markRectDirty);\n      window.clearTimeout(poll);\n      sleep();\n      if (mask) gl.deleteTexture(mask);\n      mask = null;\n      if (quad) gl.deleteBuffer(quad);\n      quad = null;\n      prog?.destroy();\n      prog = null;\n    };\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [headlineWeight, headlineFit, headlineY, speed]);\n\n  return (\n    <div\n      ref={wrapRef}\n      data-photostat-reverse={uid}\n      className={`relative isolate h-full w-full overflow-hidden bg-background ${className}`}\n      style={style}\n    >\n      <canvas ref={canvasRef} aria-hidden=\"true\" className=\"absolute inset-0 block\" />\n      {/* the headline exists only as a mask driving the shader, so the\n          accessible copy lives here — same string, nothing to keep in sync */}\n      <h1 className=\"sr-only\">{headline.split(\"\\n\").join(\" \")}</h1>\n      {children ? <div className=\"relative z-[1] h-full w-full\">{children}</div> : null}\n    </div>\n  );\n}\n\nPhotostatReverse.displayName = \"PhotostatReverse\";\n",
      "type": "registry:ui",
      "target": "components/ui/photostat-reverse.tsx"
    }
  ],
  "meta": {
    "collection": "loud",
    "tags": [
      "hero",
      "background",
      "canvas",
      "webgl",
      "shader",
      "type",
      "print",
      "cursor"
    ],
    "instruction": "Build <PhotostatReverse headline? headlineWeight? headlineFit? headlineY? speed? paused? children? className?> as a full-bleed WebGL hero reproducing its own headline the way a photostat camera reproduced line art: shot directly onto photosensitive paper and developed in-camera, which comes out TONE-REVERSED, so a true-tone copy needs a second generation shot of the negative — and every generation pass also measurably softens and thickens the line work. MASK, NOT LIVE TEXT: the headline is rasterized once (on mount, resize, and headline change, after document.fonts.ready so fallback letterforms never freeze permanently) to an offscreen 2D canvas at up to 1024px wide, auto-fit so the longest line lands on headlineFit of the width. That sharp raster is baked as the R channel of a texture; a second canvas draws the SAME raster through `ctx.filter = 'blur(Npx)'` (N ~= 2% of the raster width) and its alpha becomes the G channel — a cheap, baked stand-in for a real signed-distance field, since thresholding a blurred alpha below 0.5 dilates it (thicker strokes) and above 0.5 erodes it (thinner), which is exactly the knob generation loss needs. GENERATION STATE MACHINE (CPU, not shader): a 1.3s cadence advances a generation index 0..4, where level L maps to threshold = 0.5 - 0.024*(1.04^L - 1)*4 (dilation growing 4% compounded per level) and edge = 0.014*1.06^L (softness growing 6% compounded per level); every step flips a tone flag EXCEPT the step that wraps level 4 back to level 0, which resets crisp but lands on whichever tone level 4 was already showing, never a flip and a reset at once. THE FLIP ITSELF is rendered as a 260ms crossfade: the shader evaluates the SAME mask sample against both the outgoing generation's (threshold, edge, tone) and the incoming generation's, and mixes the two resulting in-letter fractions by an eased transition progress — this is what makes the flip show genuine departure and arrival rather than a cut, per the round-9 legibility rule. TONE: mask value m produces color = mix(bg, fg, m) for a positive generation; a negative generation instead reads m as mix(bg, fg, 1-m) — the FIELD becomes ink and the letterform becomes the gap in it, a true full-frame inversion, not just a recolored glyph. PALETTE: only --background and --foreground are read (getComputedStyle(document.documentElement), re-read on a documentElement class MutationObserver) — the tone-flip mechanic already IS the monochrome rule's required value inversion, so there is no bias/contrast remap between themes the way a shaded material needs; both themes use the same two tokens directly. A very low-amplitude per-texel hash grain (photostat paper is never perfectly flat) and a soft corner vignette keep the field textured without ever approaching the flip's own amplitude. POINTER: a small radius around the cursor pulls the CURRENTLY-DISPLAYED generation's threshold/edge toward generation-1 crispness (a 'fresh exposure' halo, luminance-only, no --ns-accent anywhere) without touching the global cadence — the flip elsewhere in the frame never pauses or desyncs, and this pointer effect is NOT what makes the component alive (the tone-flip loop runs unconditionally), so autoplay stays 'none'. HOST: single WebGL pass (unlike weld-pool/flyback-tear's multi-pass accumulation, this shader is one texture sample plus two smoothstep evaluations, so DPR holds at the full cap of 2 rather than stepping down preemptively), DPR-aware backing store, ResizeObserver on the host element (not window.resize), IntersectionObserver (threshold 0) and visibilitychange both pausing the single rAF loop, webglcontextlost/restored rebuilding the program and mask cleanly. prefers-reduced-motion (and paused) freeze on generation level 2 (generation 3), positive tone, fully settled — soft enough to show the mechanic exists, still legible, never the near-mush of generation 5 — and pointer input over the frozen frame still recomposites once per event so the crisp halo works even at rest. The accessible copy is an sr-only <h1>, since the visible headline exists only as a mask driving the shader; children render over the field for an eyebrow, subhead and CTA, and because the field spans the full value range in both tone states they need a token scrim (bg-background/70 + backdrop-blur) rather than bare type."
  },
  "type": "registry:ui"
}