{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "gluten-windowpane",
  "title": "Gluten Windowpane",
  "description": "A background-task progress indicator built on the baker's windowpane test: a dough membrane is stretched, held, and released on a repeating 4-cycle knead loop, going from opaque and jittery-stranded to translucent, straight-stranded and light-through as kneading 'develops', then rests fully developed before a fresh dough test begins.",
  "dependencies": [],
  "files": [
    {
      "path": "registry/core/gluten-windowpane/component.tsx",
      "content": "\"use client\";\n\nimport { useEffect, useRef } from \"react\";\n\n// ---------------------------------------------------------------------------\n// GlutenWindowpane — the baker's windowpane test as a background-task\n// progress indicator: a dough membrane is stretched, held, and released on a\n// repeating 4-cycle knead loop, going from opaque/jittery-stranded (cycle 1)\n// to translucent/straight-stranded (cycle 4) as kneading \"develops\" the\n// gluten. Strand geometry (count, angular jitter off the horizontal stretch\n// axis) and membrane translucency are fixed per cycle — what animates within\n// a cycle is the stretch itself: scaleX 1 -> 1.6 over 1800ms, a 600ms hold\n// at full stretch, an 800ms snap-back, 3200ms per cycle. After cycle 4 the\n// membrane rests fully developed for 1500ms before a fresh dough test\n// begins (full loop 14.3s). Translucency is genuine canvas alpha: the\n// membrane's own dough-tinted fill is painted at (1 - translucency) opacity\n// over a transparent canvas, so the real page --background underneath shows\n// through more as kneading progresses — no painted background layer. The\n// radial \"light passes through most\" highlight is the same fill pushed\n// toward whichever of --background/--foreground is the lighter of the two\n// (computed by luminance, never hardcoded), so it reads as a bright glow in\n// dark theme and, in light theme, as the surrounding membrane leaning\n// slightly toward the darker token so the near-white highlight still has\n// something to contrast against.\n// ---------------------------------------------------------------------------\n\ntype RGB = [number, number, number];\n\nfunction parseColor(raw: string): RGB | null {\n  const probe = parseColor.ctx ?? (parseColor.ctx = document.createElement(\"canvas\").getContext(\"2d\"));\n  if (!probe) return null;\n  probe.fillStyle = \"#000\";\n  try {\n    probe.fillStyle = raw;\n  } catch {\n    return null;\n  }\n  const m = probe.fillStyle.match(/\\d+/g);\n  if (!m || m.length < 3) return null;\n  return [Number(m[0]), Number(m[1]), Number(m[2])];\n}\nparseColor.ctx = null as CanvasRenderingContext2D | null;\n\nfunction luminance([r, g, b]: RGB): number {\n  return 0.2126 * r + 0.7152 * g + 0.0722 * b;\n}\n\nfunction mix(a: RGB, b: RGB, t: number): RGB {\n  return [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t];\n}\n\nfunction rgbaStr(c: RGB, a: number): string {\n  return `rgba(${c[0].toFixed(0)},${c[1].toFixed(0)},${c[2].toFixed(0)},${a.toFixed(3)})`;\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\nconst easeInOut = (t: number) => (t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2);\n\ninterface CycleParams {\n  count: number;\n  jitterDeg: number;\n  alpha: number;\n}\n\n// real numbers: strand count 8 -> 14 -> 22 -> 34, jitter +-35deg -> +-4deg,\n// membrane alpha (translucency, 0 = opaque) 0.05 -> 0.62\nconst CYCLES: CycleParams[] = [\n  { count: 8, jitterDeg: 35, alpha: 0.05 },\n  { count: 14, jitterDeg: 22, alpha: 0.18 },\n  { count: 22, jitterDeg: 11, alpha: 0.35 },\n  { count: 34, jitterDeg: 4, alpha: 0.62 },\n];\n\nconst STRETCH_MS = 1800;\nconst HOLD_MS = 600;\nconst SNAP_MS = 800;\nconst CYCLE_MS = STRETCH_MS + HOLD_MS + SNAP_MS; // 3200\nconst POST_REST_MS = 1500;\nconst LOOP_MS = CYCLE_MS * CYCLES.length + POST_REST_MS; // 14300\nconst MAX_STRANDS = CYCLES[CYCLES.length - 1]!.count;\nconst MEMBRANE_TINT = 0.22; // how far the dough fill leans off the lighter token toward the darker one (by luminance, not a fixed --background/--foreground direction)\n\n// Midpoint-subdivision activation order over the 34 strand slots: any\n// prefix of this order is itself evenly spread across the full slot range,\n// so raising strand count only ever ADDS a strand between two that already\n// exist — a slot, once occupied, never moves or vacates. This is what lets\n// a strand keep its own jitter/position identity as count climbs within a\n// stretch (5 -> 34 across the whole loop) instead of every strand\n// reshuffling on each integer step.\nfunction buildSlotOrder(n: number): number[] {\n  const order = [0, n - 1];\n  const queue: [number, number][] = [[0, n - 1]];\n  while (queue.length) {\n    const [lo, hi] = queue.shift()!;\n    if (hi - lo < 2) continue;\n    const mid = (lo + hi) >> 1;\n    order.push(mid);\n    queue.push([lo, mid], [mid, hi]);\n  }\n  return order;\n}\nconst SLOT_ORDER = buildSlotOrder(MAX_STRANDS);\n\ninterface FrameState {\n  count: number;\n  jitterDeg: number;\n  alpha: number;\n  scaleX: number;\n  thin: number; // 0..1, how \"thinned\" (stretched) the membrane is right now\n}\n\n// a synthetic \"cycle -1\" baseline the first cycle's stretch departs from, so\n// cycle 1 also shows a within-cycle develop, not just a pop to its target\nconst BASELINE: CycleParams = { count: 5, jitterDeg: 45, alpha: 0.02 };\n\nfunction lerp(a: number, b: number, t: number): number {\n  return a + (b - a) * t;\n}\n\nfunction frameState(tMs: number): FrameState {\n  const loopT = ((tMs % LOOP_MS) + LOOP_MS) % LOOP_MS;\n  if (loopT >= CYCLE_MS * CYCLES.length) {\n    // post-cycle-4 rest: fully developed, relaxed, no active stretch\n    const p = CYCLES[CYCLES.length - 1]!;\n    return { count: p.count, jitterDeg: p.jitterDeg, alpha: p.alpha * 0.85, scaleX: 1, thin: 0 };\n  }\n  const cycleIdx = Math.min(CYCLES.length - 1, Math.floor(loopT / CYCLE_MS));\n  const local = loopT - cycleIdx * CYCLE_MS;\n  const from = cycleIdx === 0 ? BASELINE : CYCLES[cycleIdx - 1]!;\n  const to = CYCLES[cycleIdx]!;\n  let scaleX: number;\n  let thin: number;\n  // develop: the ONE thing a viewer follows — opaque/jittery -> translucent/\n  // aligned across the 1800ms stretch, holding its arrival through hold+snap\n  let develop: number;\n  if (local < STRETCH_MS) {\n    const f = easeInOut(local / STRETCH_MS);\n    scaleX = 1 + 0.6 * f;\n    thin = f;\n    develop = f;\n  } else if (local < STRETCH_MS + HOLD_MS) {\n    scaleX = 1.6;\n    thin = 1;\n    develop = 1;\n  } else {\n    const f = easeInOut((local - STRETCH_MS - HOLD_MS) / SNAP_MS);\n    scaleX = 1.6 - 0.6 * f;\n    thin = 1 - f;\n    develop = 1;\n  }\n  const count = lerp(from.count, to.count, develop);\n  const jitterDeg = lerp(from.jitterDeg, to.jitterDeg, develop);\n  const alpha = lerp(from.alpha, to.alpha, develop) * (0.85 + 0.15 * thin);\n  return { count, jitterDeg, alpha, scaleX, thin };\n}\n\n// reduced-motion freeze: cycle 4's own 600ms hold, mid-point — max\n// translucency, tightest strand alignment, full stretch, the most\n// structured frame (never t0).\nconst REDUCED_FREEZE_MS = CYCLE_MS * (CYCLES.length - 1) + STRETCH_MS + HOLD_MS / 2;\n\nexport interface GlutenWindowpaneProps {\n  /** accessible label for the status region */\n  label?: string;\n  /** extra classes merged onto the rendered root element */\n  className?: string;\n}\n\nexport function GlutenWindowpane({\n  label = \"Processing\",\n  className = \"\",\n}: GlutenWindowpaneProps) {\n  const containerRef = useRef<HTMLDivElement>(null);\n  const canvasRef = useRef<HTMLCanvasElement>(null);\n\n  useEffect(() => {\n    const container = containerRef.current;\n    const canvas = canvasRef.current;\n    if (!container || !canvas) return;\n    const ctx = canvas.getContext(\"2d\");\n    if (!ctx) return;\n\n    const reduced = window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches;\n    const rand = mulberry32(0x9a10a9); // stable per-strand jitter/spread seed\n\n    let disposed = false;\n    let visible = true;\n    let raf = 0;\n    let w = 0;\n    let h = 0;\n    let dpr = 1;\n\n    // -- token read: no paint happens before this runs at least once. -----\n    let lightColor: RGB = [255, 255, 255];\n    let darkColor: RGB = [23, 23, 23];\n    let isDarkTheme = false;\n    const deriveColors = () => {\n      const cs = getComputedStyle(document.documentElement);\n      const bg = parseColor(cs.getPropertyValue(\"--background\").trim()) ?? lightColor;\n      const fg = parseColor(cs.getPropertyValue(\"--foreground\").trim()) ?? darkColor;\n      isDarkTheme = luminance(bg) < luminance(fg);\n      lightColor = isDarkTheme ? fg : bg;\n      darkColor = isDarkTheme ? bg : fg;\n    };\n\n    // per-strand stable jitter angle sign/magnitude and perpendicular spread\n    // slot, precomputed once so a given strand index keeps its identity as\n    // count grows across cycles (34 slots, subsets used at lower counts)\n    const strandJitterUnit = new Float32Array(MAX_STRANDS);\n    for (let i = 0; i < MAX_STRANDS; i++) strandJitterUnit[i] = (rand() - 0.5) * 2;\n\n    // pointer-driven stretch-center bias (springed, decays to container\n    // center when idle) — luminance-only highlight, never accent-tinted\n    let centerX = 0;\n    let centerY = 0;\n    let targetX = 0;\n    let targetY = 0;\n\n    const build = () => {\n      const rect = container.getBoundingClientRect();\n      w = rect.width;\n      h = rect.height;\n      if (w < 2 || h < 2) return;\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      centerX = w / 2;\n      centerY = h / 2;\n      targetX = centerX;\n      targetY = centerY;\n    };\n\n    const draw = (state: FrameState) => {\n      if (w < 2 || h < 2) return;\n      ctx.setTransform(dpr, 0, 0, dpr, 0, 0);\n      ctx.clearRect(0, 0, w, h);\n\n      const minDim = Math.min(w, h);\n      const baseLen = minDim * 0.8;\n      const patchHalfW = (baseLen / 2) * state.scaleX;\n      const patchHalfH = minDim * 0.34;\n\n      // clip to the membrane patch ellipse — translucency and highlight only\n      // ever apply within the dough itself, never the surrounding card\n      ctx.save();\n      ctx.beginPath();\n      ctx.ellipse(centerX, centerY, patchHalfW * 1.05, patchHalfH * 1.05, 0, 0, Math.PI * 2);\n      ctx.clip();\n\n      // dough base fill: a fixed tint off the lighter token toward the\n      // darker one, painted at (1 - translucency) so more of the real page\n      // --background shows through the transparent canvas as alpha climbs\n      const doughColor = mix(lightColor, darkColor, MEMBRANE_TINT);\n      ctx.fillStyle = rgbaStr(doughColor, Math.max(0, 1 - state.alpha));\n      ctx.fillRect(centerX - patchHalfW * 1.1, centerY - patchHalfH * 1.1, patchHalfW * 2.2, patchHalfH * 2.2);\n\n      // radial light-through highlight: pushes further toward the LIGHTER\n      // token at the hotspot, fading back to the dough tint — brightness\n      // only, never var(--ns-accent)\n      const hotR = Math.max(patchHalfW, patchHalfH) * (0.35 + 0.25 * state.thin);\n      const grad = ctx.createRadialGradient(targetX, targetY, 0, targetX, targetY, hotR);\n      const hotColor = mix(doughColor, lightColor, 0.9);\n      grad.addColorStop(0, rgbaStr(hotColor, 0.16 + 0.5 * state.alpha));\n      grad.addColorStop(1, rgbaStr(hotColor, 0));\n      ctx.fillStyle = grad;\n      ctx.fillRect(centerX - patchHalfW * 1.1, centerY - patchHalfH * 1.1, patchHalfW * 2.2, patchHalfH * 2.2);\n\n      // strands: thin --foreground lines, angle jitter around the\n      // horizontal stretch axis, evenly spread across the patch height.\n      // Strands activate in SLOT_ORDER (fixed, midpoint-subdivided) rather\n      // than by live index into `count`, so a slot once drawn keeps its own\n      // jitter/position for the rest of the loop — raising count only adds\n      // strands between the ones already there, never reshuffles them. The\n      // newest (fractional) strand fades in by alpha instead of popping.\n      const drawCount = Math.min(MAX_STRANDS, Math.ceil(state.count));\n      const jitterRad = (state.jitterDeg * Math.PI) / 180;\n      // floor kept well above the \"invisible at dpr-scaled subpixel\" line\n      const lineWidth = Math.max(0.85, 1.9 - drawCount * 0.03);\n      // strands read as --foreground: the darker-reading token carries ink\n      const strandColor = isDarkTheme ? lightColor : darkColor;\n      ctx.lineWidth = lineWidth;\n      ctx.lineCap = \"round\";\n      for (let i = 0; i < drawCount; i++) {\n        const slotIdx = SLOT_ORDER[i] ?? Math.floor(MAX_STRANDS / 2);\n        const slot = slotIdx / (MAX_STRANDS - 1) - 0.5;\n        const unit = strandJitterUnit[slotIdx] ?? 0;\n        const angle = unit * jitterRad;\n        const len = baseLen * state.scaleX * (0.92 + 0.08 * Math.abs(unit));\n        const dx = Math.cos(angle);\n        const dy = Math.sin(angle);\n        const px = -dy; // perpendicular for spread offset\n        const py = dx;\n        const spread = slot * patchHalfH * 1.7;\n        const ox = centerX + px * spread;\n        const oy = centerY + py * spread;\n        const fade = Math.max(0, Math.min(1, state.count - i));\n        ctx.strokeStyle = rgbaStr(strandColor, 0.82 * fade);\n        ctx.beginPath();\n        ctx.moveTo(ox - (dx * len) / 2, oy - (dy * len) / 2);\n        ctx.lineTo(ox + (dx * len) / 2, oy + (dy * len) / 2);\n        ctx.stroke();\n      }\n\n      ctx.restore();\n    };\n\n    let last = 0;\n    const startPerf = performance.now();\n    // phase-desync the mount so any two page loads show different states\n    const mountOffsetMs = Date.now() % LOOP_MS;\n\n    const loop = (now: number) => {\n      if (disposed || !visible) {\n        raf = 0;\n        return;\n      }\n      const dt = last === 0 ? 1 / 60 : Math.min(0.05, (now - last) / 1000);\n      last = now;\n\n      const springRate = 1 - Math.exp(-dt * 10);\n      centerX += (targetX - centerX) * springRate;\n      centerY += (targetY - centerY) * springRate;\n\n      const elapsedMs = now - startPerf + mountOffsetMs;\n      draw(frameState(elapsedMs));\n      raf = requestAnimationFrame(loop);\n    };\n\n    const startLoop = () => {\n      if (!raf && !reduced) {\n        last = 0;\n        raf = requestAnimationFrame(loop);\n      }\n    };\n\n    const drawReduced = () => {\n      draw(frameState(REDUCED_FREEZE_MS));\n    };\n\n    const onPointerMove = (e: PointerEvent) => {\n      const rect = container.getBoundingClientRect();\n      const px = e.clientX - rect.left;\n      const py = e.clientY - rect.top;\n      // shift toward the cursor, clamped to a modest fraction of the patch\n      // so the stretch center never leaves the dough itself\n      const maxShift = Math.min(w, h) * 0.18;\n      targetX = w / 2 + Math.max(-maxShift, Math.min(maxShift, px - w / 2)) * 0.6;\n      targetY = h / 2 + Math.max(-maxShift, Math.min(maxShift, py - h / 2)) * 0.6;\n    };\n    const onPointerLeave = () => {\n      targetX = w / 2;\n      targetY = h / 2;\n    };\n\n    deriveColors();\n    build();\n    if (reduced) {\n      drawReduced();\n    } else {\n      startLoop();\n    }\n\n    const ro = new ResizeObserver(() => {\n      build();\n      if (reduced) drawReduced();\n    });\n    ro.observe(container);\n\n    const mo = new MutationObserver(() => {\n      deriveColors();\n      if (reduced) drawReduced();\n    });\n    mo.observe(document.documentElement, { attributes: true, attributeFilter: [\"class\"] });\n\n    const io = new IntersectionObserver((entries) => {\n      visible = entries[0]?.isIntersecting ?? true;\n      if (visible && !reduced) startLoop();\n    });\n    io.observe(container);\n\n    if (!reduced) {\n      container.addEventListener(\"pointermove\", onPointerMove);\n      container.addEventListener(\"pointerleave\", onPointerLeave);\n    }\n\n    return () => {\n      disposed = true;\n      cancelAnimationFrame(raf);\n      raf = 0;\n      ro.disconnect();\n      mo.disconnect();\n      io.disconnect();\n      container.removeEventListener(\"pointermove\", onPointerMove);\n      container.removeEventListener(\"pointerleave\", onPointerLeave);\n    };\n  }, []);\n\n  return (\n    <div\n      ref={containerRef}\n      role=\"status\"\n      className={`relative aspect-[4/3] w-full max-w-[320px] touch-none overflow-hidden rounded-md border border-border bg-background ${className}`}\n    >\n      <span className=\"sr-only\">{label}</span>\n      <canvas ref={canvasRef} aria-hidden=\"true\" className=\"absolute inset-0 h-full w-full\" />\n    </div>\n  );\n}\n",
      "type": "registry:ui",
      "target": "components/ui/gluten-windowpane.tsx"
    }
  ],
  "cssVars": {
    "theme": {
      "color-ns-accent": "var(--ns-accent)"
    },
    "light": {
      "ns-accent": "#006bff"
    }
  },
  "meta": {
    "collection": "core",
    "tags": [
      "loader",
      "progress",
      "canvas",
      "membrane",
      "elasticity",
      "dough",
      "ambient",
      "processing",
      "status"
    ],
    "instruction": "A card-scale 2D canvas loader modeling the baker's windowpane test for gluten development. Strand geometry derives from the container's smaller dimension: strand length = minDim * 0.8, patch half-height = minDim * 0.34. The loop runs 4 knead cycles of 3200ms each (1800ms stretch easing scaleX from 1 to 1.6 with an ease-in-out, 600ms hold at scaleX 1.6, 800ms ease-in-out snap-back from 1.6 to 1), then an extra 1500ms rest at scaleX 1 holding cycle 4's fully-developed values before the loop restarts fresh (total 14300ms). Per-cycle targets (strand count 8/14/22/34, angular jitter off the horizontal stretch axis +-35/+-22/+-11/+-4 degrees, membrane translucency/canvas alpha 0.05/0.18/0.35/0.62) are not stepped at cycle boundaries — they are the ARRIVAL values a linear interpolation reaches by the end of each cycle's 1800ms stretch, departing from the previous cycle's own arrival values (cycle 1 departs from a synthetic pre-test baseline: count 5, jitter +-45deg, alpha 0.02), so the spec's one legibility thing — opaque/jittery going to translucent/aligned — plays out visibly within a single stretch, not as a jump-cut between cycles. Hold and snap-back keep the arrived values (alpha gets one further +-15% ripple tied to stretch/relax for a felt 'thinning', but strand count and jitter stay put once arrived). Strand count is rounded to the nearest integer only at render time. Rendering: a dough-tinted fill (mix of whichever of --background/--foreground reads lighter by relative luminance, toward the darker one, at a fixed 22% tint) is painted at (1 - alpha) opacity onto an otherwise-transparent canvas clipped to the patch ellipse, so the real page --background underneath shows through more as translucency climbs — there is no separately painted background rectangle. Strands are thin lines in whichever token reads as ink (the darker-luminance one, i.e. --foreground in both themes as authored), evenly spread across the patch height. Each strand occupies a fixed slot in a 34-slot jitter/spread table (seeded mulberry32); slots activate in a midpoint-subdivision order whose every prefix is itself evenly spread, so raising the (continuously interpolated) strand count only ever adds a strand between two that already exist — a slot never moves or vacates once drawn, and the newest fractional strand fades in by alpha rather than popping in. A radial highlight, centered on the (optionally pointer-biased) stretch center, blends the dough fill toward the lighter of the two tokens and never toward var(--ns-accent) — brightness/alpha only. Colors are read once via getComputedStyle(document.documentElement) before any paint, with a MutationObserver on the documentElement class re-deriving on theme change and repainting immediately when reduced motion is active. Optional pointer interaction: pointermove within the card springs the stretch/highlight center toward the cursor (clamped to 18% of min(w,h), scaled 0.6x), decaying back to the container center on pointerleave — purely a highlight-position bias, never a state that persists (mode: none in autoplay, since nothing but the internal clock drives it). A ResizeObserver rebuilds canvas backing-store size and center on layout change; an IntersectionObserver pauses the rAF loop while off-screen and resumes it on re-intersection; all observers and the rAF are torn down on unmount. Under prefers-reduced-motion the whole rAF loop never starts — a single frame is drawn synchronously at cycle 4's own 600ms hold, mid-point (max translucency, tightest strand alignment, scaleX 1.6, full light-through highlight) rather than at t0, which would be the least structured, least informative frame. Root is `role=\"status\"` with a `sr-only` accessible label prop (default 'Processing'); canvas is `aria-hidden`. Props: `label` (default 'Processing'), `className`. Zero dependencies, canvas + DOM only, no color literals anywhere."
  },
  "type": "registry:ui"
}