{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "screen-flood-stroke",
  "title": "Screen Flood Stroke",
  "description": "A card-scale loader modelled on the real screen-printing flood/print stroke cycle: a squeegee alternates a light flood pass that spreads ink across the mesh and a firm print pass that forces it through, printing one fading impression per cycle.",
  "dependencies": [],
  "files": [
    {
      "path": "registry/core/screen-flood-stroke/component.tsx",
      "content": "\"use client\";\n\nimport { useEffect, useRef } from \"react\";\nimport type { CSSProperties } from \"react\";\n\n// ---------------------------------------------------------------------------\n// ScreenFloodStroke — a loader / ambient \"something is being produced\"\n// affordance modelled on the real screen-printing flood/print stroke cycle,\n// not a percentage bar. A squeegee makes two passes per cycle across a\n// printing mesh: a FLOOD stroke (light pressure, left->right) that spreads\n// ink evenly across the mesh weave without forcing it through, then a PRINT\n// stroke (firm pressure, blade angled ~18deg from vertical, right->left)\n// that forces ink through the mesh openings the blade has already crossed\n// and deposits a real impression on the substrate underneath. The two\n// passes are deliberately drawn with different weight and different effect\n// on the surface (a transient low-alpha wash during flood vs a persistent\n// high-alpha deposit during print) so the two-stroke mechanic stays legible\n// rather than reading as one generic wipe.\n//\n// The mesh fabric itself is a fixed low-alpha grid, always visible under\n// whatever ink sits on top of it — screen pitch is derived from the\n// canvas's own smaller dimension via ResizeObserver, finer than the ink\n// grain since it represents woven mesh, not a printed dot.\n//\n// Ink-through is a persistent per-cell field, not a redraw-from-scratch\n// wash: an opening the print stroke has just crossed jumps straight to the\n// alpha cap (0.9 of --foreground), then decays exponentially (tau = 2.4s)\n// toward a floor of 0.2 between impressions — residual staining that never\n// reaches 0, so a resting card always shows the ghost of at least one prior\n// impression plus whatever the current stroke is doing. That is what keeps\n// the loop visibly different at t0 / 2.5s / 5s: t0 catches a bare flood\n// pass with no fresh deposit yet, 2.5s shows one impression mid-fade under\n// a new flood pass, and 5s stacks two ghosts of different ages under the\n// live stroke.\n// ---------------------------------------------------------------------------\n\nexport interface ScreenFloodStrokeProps {\n  /** Optional 0-1 determinate progress. When set, ink only takes on\n   * openings within that fraction of the mesh's width — the print stroke\n   * still sweeps the full mesh every cycle and the flood wash keeps\n   * animating unforced, so the component never reads as \"finished and\n   * stopped\" even once progress reaches 1. Omit for a purely ambient loop. */\n  progress?: number;\n  /** accessible label for the loader */\n  \"aria-label\"?: string;\n  /** extra classes merged onto the rendered root element */\n  className?: string;\n  /** inline styles merged onto the root element */\n  style?: CSSProperties;\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\n// --- real numbers from the spec's observed flood/print/dwell cycle --------\nconst FLOOD_MS = 600; // light pass, left -> right, spreads without printing\nconst PRINT_MS = 900; // firm pass, right -> left, forces ink through\nconst DWELL_MS = 300; // blade lifts clear before the next flood pass\nconst CYCLE_MS = FLOOD_MS + PRINT_MS + DWELL_MS; // 1800\n\nconst ANGLE = (18 * Math.PI) / 180; // squeegee tilt from vertical\nconst FLOOD_ALPHA = 0.15; // flat wash alpha while the flood pass is over a cell\nconst MESH_ALPHA = 0.08; // fixed mesh-fabric line alpha, always visible\nconst INK_CAP = 0.9; // alpha a freshly printed opening jumps to\nconst INK_FLOOR = 0.2; // residual-stain floor ink decays toward, never 0\nconst INK_TAU = 2.4; // seconds, exponential decay time constant\n\n// Reduced-motion freeze frame. The spec names FREEZE_PHASE =\n// \"print-stroke-60pct\" (squeegee mid print-stroke, ~60% of the mesh printed,\n// 40% not yet — the most legible \"process caught in the act\" frame). Taking\n// that 60%-through-print-stroke intent at face value rather than the spec's\n// literal t=1.5s (which lands exactly at this cycle's flood(0.6)+print(0.9)\n// boundary, i.e. a FULLY printed frame with no unprinted mesh left to show)\n// is what actually reproduces the described 60/40 split; the value below is\n// derived from the phase math instead of the raw seconds figure.\nconst FREEZE_PHASE = \"print-stroke-60pct\";\nconst STATIC_TIME_MS = FLOOD_MS + 0.6 * PRINT_MS; // 1140ms\n\nexport function ScreenFloodStroke({\n  progress,\n  \"aria-label\": ariaLabel = \"Loading\",\n  className = \"\",\n  style,\n}: ScreenFloodStrokeProps) {\n  const rootRef = useRef<HTMLDivElement>(null);\n  const canvasRef = useRef<HTMLCanvasElement>(null);\n  const progressRef = useRef(progress);\n  progressRef.current = progress;\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: read at mount, re-derived on theme class change.\n    // No literal fallback — draw() bails out while fg is still null, so\n    // there is genuinely no paint before the first successful token read. --\n    let fg: RGB | null = null;\n    const derive = () => {\n      const cs = getComputedStyle(document.documentElement);\n      const parsed = parseColor(cs.getPropertyValue(\"--foreground\"));\n      if (parsed) fg = parsed;\n    };\n    derive();\n\n    let w = 0;\n    let h = 0;\n    let dpr = 1;\n    let pitch = 6;\n    let cols = 0;\n    let rows = 0;\n    let ink = new Float32Array(0); // per-cell accumulated print-through alpha\n    let raf = 0;\n    let visible = true;\n    let last = 0;\n\n    // Only reallocates (and so only wipes accumulated ghosts) when the grid\n    // shape actually changed. A ResizeObserver commonly fires more than\n    // once for the same effective size (initial observe, font-load reflow,\n    // flex settling); without this guard every one of those calls would\n    // erase every ink impression and quietly break the t=2.5s/5s aliveness\n    // this component depends on.\n    const resizeField = () => {\n      const nextCols = Math.max(1, Math.ceil(w / pitch));\n      const nextRows = Math.max(1, Math.ceil(h / pitch));\n      if (nextCols === cols && nextRows === rows && ink.length === nextCols * nextRows) return;\n      cols = nextCols;\n      rows = nextRows;\n      ink = new Float32Array(cols * rows);\n    };\n\n    // squeegee's local x position at a given row, angled ANGLE from vertical\n    // around the blade's centreline (baseX, h/2)\n    const localX = (baseX: number, cy: number) => baseX + (cy - h / 2) * Math.sin(ANGLE);\n\n    const step = (timeMs: number, dtMs: number) => {\n      const t = timeMs % CYCLE_MS;\n      const phase: \"flood\" | \"print\" | \"dwell\" = t < FLOOD_MS ? \"flood\" : t < FLOOD_MS + PRINT_MS ? \"print\" : \"dwell\";\n\n      let baseX: number;\n      if (phase === \"flood\") {\n        baseX = (t / FLOOD_MS) * w;\n      } else if (phase === \"print\") {\n        const p = (t - FLOOD_MS) / PRINT_MS;\n        baseX = w * (1 - p);\n      } else {\n        baseX = 0;\n      }\n\n      // decay every already-inked cell toward the residual-stain floor —\n      // untouched cells (ink 0) stay 0 until their first impression\n      const decay = Math.exp(-dtMs / 1000 / INK_TAU);\n      for (let i = 0; i < ink.length; i++) {\n        const v = ink[i];\n        if (v > 0) ink[i] = INK_FLOOR + (v - INK_FLOOR) * decay;\n      }\n\n      // print stroke deposits: every cell already crossed by the blade\n      // (to the right of its current, leftward-travelling position) jumps\n      // straight to the alpha cap\n      if (phase === \"print\") {\n        const cap = progressRef.current;\n        for (let j = 0; j < rows; j++) {\n          const cy = j * pitch + pitch / 2;\n          const lx = localX(baseX, cy);\n          for (let i = 0; i < cols; i++) {\n            const cx = i * pitch + pitch / 2;\n            if (cx < lx) continue; // not yet crossed by the blade\n            if (cap !== undefined && cx / w > Math.max(0, Math.min(1, cap))) continue;\n            ink[j * cols + i] = INK_CAP;\n          }\n        }\n      }\n\n      return { phase, baseX };\n    };\n\n    const draw = (phase: \"flood\" | \"print\" | \"dwell\", baseX: number, timeMs: number) => {\n      if (w <= 0 || h <= 0 || !fg) return;\n      ctx.setTransform(dpr, 0, 0, dpr, 0, 0);\n      ctx.clearRect(0, 0, w, h);\n\n      // mesh fabric — fixed low alpha, always drawn first so ink sits on\n      // top of it while the grid still shows through the ink's own alpha\n      ctx.strokeStyle = `rgba(${fg[0]},${fg[1]},${fg[2]},${MESH_ALPHA})`;\n      ctx.lineWidth = 1;\n      ctx.beginPath();\n      for (let i = 0; i <= cols; i++) {\n        const x = Math.min(w, i * pitch);\n        ctx.moveTo(x, 0);\n        ctx.lineTo(x, h);\n      }\n      for (let j = 0; j <= rows; j++) {\n        const y = Math.min(h, j * pitch);\n        ctx.moveTo(0, y);\n        ctx.lineTo(w, y);\n      }\n      ctx.stroke();\n\n      // print-through ink, per cell\n      for (let j = 0; j < rows; j++) {\n        for (let i = 0; i < cols; i++) {\n          const v = ink[j * cols + i];\n          if (v <= 0.005) continue;\n          ctx.fillStyle = `rgba(${fg[0]},${fg[1]},${fg[2]},${v})`;\n          ctx.fillRect(i * pitch, j * pitch, pitch, pitch);\n        }\n      }\n\n      // flood wash — transient, recomputed live every frame, never stored:\n      // it represents ink sitting on the mesh surface, not yet forced\n      // through, so it has no persistence beyond the current flood pass\n      if (phase === \"flood\") {\n        ctx.fillStyle = `rgba(${fg[0]},${fg[1]},${fg[2]},${FLOOD_ALPHA})`;\n        for (let j = 0; j < rows; j++) {\n          const cy = j * pitch + pitch / 2;\n          const lx = localX(baseX, cy);\n          const xEnd = Math.max(0, Math.min(w, lx));\n          if (xEnd > 0) ctx.fillRect(0, j * pitch, xEnd, pitch);\n        }\n      }\n\n      // squeegee blade itself\n      const barLen = h / Math.cos(ANGLE) + pitch * 2;\n      const barWidth = Math.max(2, Math.min(w, h) * 0.045);\n      let alpha = phase === \"print\" ? 0.95 : phase === \"flood\" ? 0.5 : 0;\n      if (phase === \"dwell\") {\n        const lift = (timeMs % CYCLE_MS) - (FLOOD_MS + PRINT_MS);\n        alpha = Math.max(0, 0.4 * (1 - lift / DWELL_MS));\n      }\n      if (alpha > 0.01) {\n        ctx.save();\n        ctx.translate(baseX, h / 2);\n        ctx.rotate(ANGLE);\n        ctx.fillStyle = `rgba(${fg[0]},${fg[1]},${fg[2]},${alpha})`;\n        ctx.fillRect(-barWidth / 2, -barLen / 2, barWidth, barLen);\n        ctx.restore();\n      }\n    };\n\n    const loop = (now: number) => {\n      if (last === 0) last = now;\n      const dt = Math.min(100, now - last);\n      last = now;\n      const { phase, baseX } = step(now, dt);\n      draw(phase, baseX, now);\n      if (!reduced && visible) raf = requestAnimationFrame(loop);\n      else raf = 0;\n    };\n    const wake = () => {\n      if (raf === 0 && !reduced && visible) {\n        last = 0;\n        raf = requestAnimationFrame(loop);\n      }\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      // mesh pitch off the container's own smaller dimension — finer than\n      // a printed-dot pitch since it represents woven fabric, not ink\n      pitch = Math.min(9, Math.max(4, Math.min(w, h) / 60));\n      resizeField();\n      if (reduced) {\n        const { phase, baseX } = step(STATIC_TIME_MS, 0);\n        draw(phase, baseX, STATIC_TIME_MS);\n      } else {\n        last = 0;\n        const { phase, baseX } = step(0, 0);\n        draw(phase, baseX, 0);\n      }\n    };\n\n    resize();\n    if (!reduced) wake();\n\n    const ro = new ResizeObserver(resize);\n    ro.observe(root);\n\n    const mo = new MutationObserver(() => {\n      derive();\n      if (reduced) {\n        const { phase, baseX } = step(STATIC_TIME_MS, 0);\n        draw(phase, baseX, STATIC_TIME_MS);\n      }\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        ink.fill(0);\n        resizeField();\n        const { phase, baseX } = step(STATIC_TIME_MS, 0);\n        draw(phase, baseX, STATIC_TIME_MS);\n      } else {\n        ink.fill(0);\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 isVisible = entries[0]?.isIntersecting ?? true;\n      visible = isVisible && 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=\"img\"\n      aria-label={ariaLabel}\n      data-freeze-phase={FREEZE_PHASE}\n      className={`ns-sfs relative h-40 w-40 overflow-hidden rounded-[10px] border border-border bg-background ${className}`}\n      style={style}\n    >\n      <canvas ref={canvasRef} aria-hidden=\"true\" className=\"pointer-events-none absolute inset-0 h-full w-full\" />\n    </div>\n  );\n}\n\nScreenFloodStroke.displayName = \"ScreenFloodStroke\";\n\nexport default ScreenFloodStroke;\n",
      "type": "registry:ui",
      "target": "components/ui/screen-flood-stroke.tsx"
    }
  ],
  "meta": {
    "collection": "core",
    "tags": [
      "loader",
      "progress",
      "canvas",
      "print",
      "ambient",
      "monochrome"
    ],
    "instruction": "Build a card-scale canvas-2D loader whose entire surface reproduces one screen-printing flood/print stroke cycle, not a percentage bar or generic spinner. Root is a `relative h-40 w-40 overflow-hidden rounded-[10px] border border-border bg-background` element (`role='img'`, caller-supplied `aria-label`) holding an absolutely positioned `aria-hidden` canvas at `w-full h-full`, sized off the root's own `getBoundingClientRect` via `ResizeObserver`.\n\nThe cycle is a fixed 1800ms loop: a 600ms FLOOD stroke (left-to-right, low pressure), a 900ms PRINT stroke (right-to-left, firm pressure, blade angled 18deg from vertical), then a 300ms dwell where the blade lifts clear before the next flood pass. The blade's local x position at row `cy` is `baseX + (cy - h/2) * sin(18deg)` so the angle is geometrically real, not a CSS skew. During flood, `baseX` runs `0 -> w` over 600ms; during print it runs `w -> 0` over 900ms; during dwell it holds at 0 while the blade's own render alpha fades to 0.\n\nA persistent per-cell Float32Array (grid pitch = `clamp(min(w,h)/60, 4px, 9px)`, resized with the canvas) tracks print-through ink. Every frame during the print stroke, any cell whose centre the blade has already crossed (`cx >= localX(cy)`, since the blade travels right-to-left and 'already crossed' means to its right) jumps straight to alpha 0.9 of `--foreground`. Every frame, every cell with ink > 0 decays via `ink = FLOOR + (ink - FLOOR) * exp(-dt/2400ms)` toward a floor of 0.2 — residual staining that never reaches 0, so a resting frame always carries the ghost of at least one prior impression. Untouched cells stay exactly 0 (never drift toward the floor) until their first impression. The flood stroke does NOT write into this persistent field: it is drawn live, every frame, as a flat `rgba(fg, 0.15)` wash over whichever cells sit behind the blade's current flood position, and it has zero persistence once the flood pass ends — the flood/print distinction has to be legible as two different effects on the surface, not two speeds of the same wipe.\n\nMesh fabric is drawn first, every frame, as a fixed grid of 1px lines at the same pitch, stroked at a flat, non-decaying `rgba(--foreground, 0.08)` — never `--border`, which is a near-invisible separator token in light theme and would defeat the always-faintly-visible-mesh requirement. Ink and the flood wash draw on top of the mesh at their own alpha (never fully opaque), so the grid lines still show through everywhere.\n\nAn optional `progress` prop (0-1) makes the loader determinate without ever letting it read as finished-and-stopped: when set, a print-stroke deposit is only allowed onto a cell if `cx / w <= progress`, capping how far into the mesh fresh ink can land, while the blade itself keeps sweeping the full width and the flood wash keeps animating every cycle regardless of `progress`'s value — even at `progress = 1` the flood/print cycle keeps running unforced, satisfying the 'alive at rest, unbounded loop' requirement rather than settling into a static finished frame.\n\nColour is `--foreground` only, read via `getComputedStyle(document.documentElement)` at mount and re-derived on a `MutationObserver` watching `documentElement`'s class attribute; `--ns-accent` is never touched anywhere in this component, since there is no pointer/press interaction to give it interaction-chrome duty. DPR-capped (max 2) backing store sized off the root's own rect. The rAF loop pauses on `document.visibilitychange` and on an `IntersectionObserver` (threshold implicit, any intersection keeps it awake) so an offscreen or backgrounded loader costs nothing, and both observers are disconnected and the frame cancelled on unmount. Under `prefers-reduced-motion: reduce` the clock freezes at a computed instant equivalent to 60% through the print stroke (`FLOOD_MS + 0.6 * PRINT_MS` = 1140ms into the 1800ms cycle, exposed as `data-freeze-phase='print-stroke-60pct'` on the root) — squeegee visibly angled mid-pass, roughly 60% of the mesh printed and 40% still bare mesh, the single most legible 'process caught in the act' frame; the frame is computed by running a single deposit step directly at that instant against a fresh, zeroed ink field (never left at whatever state the live loop happened to be in when the media query flipped), so the freeze is deterministic on every mount and re-mount. No dependencies."
  },
  "type": "registry:ui"
}