{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "caddisfly-case-assembly",
  "title": "Caddisfly Case Assembly",
  "description": "A card-scale ambient loader modelled on caddisfly larva case-building: mineral grains drift in one at a time, get measured against the open gap at the case's growing rim, and either cement into place (extending the tube in a slow spiral) or get rejected and drift away, working around the rim course by course.",
  "dependencies": [],
  "files": [
    {
      "path": "registry/core/caddisfly-case-assembly/component.tsx",
      "content": "\"use client\";\n\nimport { useEffect, useRef } from \"react\";\n\n// ---------------------------------------------------------------------------\n// CaddisflyCaseAssembly — a card-scale ambient loader modelled on Trichoptera\n// (caddisfly larva) case-building: the larva selects mineral grains from the\n// substrate one at a time, tests each roughly by size against the open gap\n// at the case's growing rim, and either cements an accepted grain onto the\n// rim's advancing edge with silk or lets a rejected one drift away, working\n// around the rim in a rough spiral course so the tube extends and widens as\n// courses complete (Hansell 1968, \"The house-building behaviour of the\n// caddis fly larva\").\n//\n// One candidate grain is evaluated every 340ms, sampled from a bimodal\n// substrate-grade size mix (60% \"fine\" 3-5px, 40% \"coarse\" 6-9px at card\n// scale). It is accepted only if the current rim gap is within +-22% of its\n// size — about half of all candidates visibly bounce off before one sticks,\n// which is the mechanic's whole point, not incidental noise. An accepted\n// grain eases into place over 220ms and the build cursor advances along the\n// rim by its width; a rejected one drifts 18px away at a random angle while\n// fading over 260ms — a clearly different, clearly slower motion than a\n// cement, so accept vs. reject reads as two distinct verdicts, not one blur.\n//\n// Only the ACTIVE course renders individual cemented grains (--ns-muted\n// fill, --border outline per grain). Once a course wraps a full\n// circumference the whole case's visible \"depth\" grows: that course's\n// grains are dropped and replaced by a single offset guide ring in\n// --border, drawn behind the new (larger-radius) active rim, and a fresh\n// course starts. After 5 courses the finished case holds for 4s, then its\n// last course's grain fill crossfades into its own guide ring over 1.5s (so\n// the whole case reads as border-outline-only for a beat) before the entire\n// case clears and a fresh one restarts from a bare rim.\n// ---------------------------------------------------------------------------\n\nconst TOTAL_COURSES = 5;\nconst CANDIDATE_INTERVAL_MS = 340; // one candidate evaluated per this\nconst DRIFT_MS = 100; // candidate drifts in from outside the rim\nconst PAUSE_MS = 20; // brief hover at the gap before the verdict plays\nconst ACCEPT_MS = 220; // ease-out into cemented position\nconst REJECT_MS = 260; // bounce-away-and-fade\nconst ACCEPT_BAND = 0.22; // +-22% gap-vs-grain tolerance\nconst HOLD_MS = 4000; // completed case holds before it fades\nconst FADE_MS = 1500; // last course crossfades grains -> guide ring\n\nconst REF_DIM = 260; // reference min-dimension the px numbers below are tuned at\nconst FINE_MIN = 3;\nconst FINE_MAX = 5;\nconst COARSE_MIN = 6;\nconst COARSE_MAX = 9;\nconst FINE_PROB = 0.6;\nconst REJECT_DRIFT_REF = 18;\nconst HOVER_GAP_REF = 6; // radial standoff a candidate hovers at before its verdict\n\nconst CONTRAST_THRESHOLD = 0.12; // normalised luminance gap below which \"low contrast\"\nconst OUTLINE_ALPHA_BASE = 0.55;\nconst OUTLINE_ALPHA_LOW_CONTRAST = 0.85;\n\ninterface Tokens {\n  muted: string;\n  border: string;\n  grainOutlineAlpha: number;\n}\n\n/** rasterises a CSS colour string through a 1x1 canvas to read its actual luminance —\n * used only to compare --border against --background, never to hardcode a colour. */\nfunction luminanceOf(color: string): number {\n  const probe = document.createElement(\"canvas\");\n  probe.width = 1;\n  probe.height = 1;\n  const pctx = probe.getContext(\"2d\", { willReadFrequently: true } as CanvasRenderingContext2DSettings);\n  if (!pctx) return 0;\n  pctx.fillStyle = color;\n  pctx.fillRect(0, 0, 1, 1);\n  const d = pctx.getImageData(0, 0, 1, 1).data;\n  return 0.2126 * d[0]! + 0.7152 * d[1]! + 0.0722 * d[2]!;\n}\n\nfunction readTokens(): Tokens | null {\n  if (typeof document === \"undefined\") return null;\n  const cs = getComputedStyle(document.documentElement);\n  const muted = cs.getPropertyValue(\"--ns-muted\").trim();\n  const border = cs.getPropertyValue(\"--border\").trim();\n  const background = cs.getPropertyValue(\"--background\").trim();\n  if (!muted || !border || !background) return null; // not loaded yet — no paint before this\n  let grainOutlineAlpha = OUTLINE_ALPHA_BASE;\n  try {\n    const contrast = Math.abs(luminanceOf(border) - luminanceOf(background)) / 255;\n    grainOutlineAlpha = contrast < CONTRAST_THRESHOLD ? OUTLINE_ALPHA_LOW_CONTRAST : OUTLINE_ALPHA_BASE;\n  } catch {\n    grainOutlineAlpha = OUTLINE_ALPHA_BASE;\n  }\n  return { muted, border, grainOutlineAlpha };\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/** samples one grain size (px, already scaled) from the fine/coarse substrate mix */\nfunction sampleGrainSize(scale: number, rand: () => number): number {\n  const fine = rand() < FINE_PROB;\n  const [lo, hi] = fine ? [FINE_MIN, FINE_MAX] : [COARSE_MIN, COARSE_MAX];\n  return (lo + rand() * (hi - lo)) * scale;\n}\n\n/** irregular tube-cross-section radius at angle theta for a given course —\n * two low harmonics, seeded per course, so the rim reads as a real substrate\n * boundary rather than a perfect circle */\nfunction rimNoise(courseIdx: number, theta: number): number {\n  return (\n    1 +\n    0.035 * Math.sin(3 * theta + courseIdx * 1.7) +\n    0.02 * Math.sin(7 * theta + courseIdx * 0.6 + 1.1)\n  );\n}\n\nfunction traceRim(\n  ctx: CanvasRenderingContext2D,\n  cx: number,\n  cy: number,\n  radius: number,\n  courseIdx: number\n) {\n  const steps = 64;\n  ctx.beginPath();\n  for (let i = 0; i <= steps; i++) {\n    const theta = (i / steps) * Math.PI * 2;\n    const r = radius * rimNoise(courseIdx, theta);\n    const x = cx + r * Math.cos(theta);\n    const y = cy + r * Math.sin(theta);\n    if (i === 0) ctx.moveTo(x, y);\n    else ctx.lineTo(x, y);\n  }\n  ctx.closePath();\n}\n\nfunction drawRoundedQuad(\n  ctx: CanvasRenderingContext2D,\n  x: number,\n  y: number,\n  size: number,\n  rotation: number,\n  fillStyle: string,\n  fillAlpha: number,\n  strokeStyle: string,\n  strokeAlpha: number\n) {\n  ctx.save();\n  ctx.translate(x, y);\n  ctx.rotate(rotation);\n  const r = size * 0.28;\n  ctx.beginPath();\n  ctx.roundRect(-size / 2, -size / 2, size, size, r);\n  ctx.globalAlpha = fillAlpha;\n  ctx.fillStyle = fillStyle;\n  ctx.fill();\n  ctx.globalAlpha = strokeAlpha;\n  ctx.strokeStyle = strokeStyle;\n  ctx.lineWidth = Math.max(0.6, size * 0.08);\n  ctx.stroke();\n  ctx.restore();\n}\n\ninterface CementedGrain {\n  angle: number;\n  size: number;\n  rotation: number;\n}\n\ninterface Candidate {\n  spawnMs: number;\n  angle: number;\n  size: number;\n  rotation: number;\n  radius: number; // active course radius at spawn time\n  courseAtSpawn: number; // which course this candidate belongs to\n  accepted: boolean;\n  driftAngle: number; // random escape heading, only used if rejected\n}\n\ninterface SimState {\n  phase: \"grow\" | \"hold\" | \"fade\";\n  phaseStartMs: number;\n  courseIdx: number; // 0-indexed active course\n  cursorAngle: number; // 0..2*PI build progress within the active course\n  gapWidth: number; // current open-gap size at the active build edge\n  completedRadii: number[]; // guide-ring radii for finished courses\n  grains: CementedGrain[]; // cemented grains of the ACTIVE course only\n  candidates: Candidate[];\n  nextCandidateAtMs: number;\n}\n\nfunction freshSim(rand: () => number, scale: number): SimState {\n  return {\n    phase: \"grow\",\n    phaseStartMs: 0,\n    courseIdx: 0,\n    cursorAngle: 0,\n    gapWidth: sampleGrainSize(scale, rand),\n    completedRadii: [],\n    grains: [],\n    candidates: [],\n    nextCandidateAtMs: 0,\n  };\n}\n\ninterface Geo {\n  W: number;\n  H: number;\n  cx: number;\n  cy: number;\n  R0: number;\n  scale: number;\n  depthStep: number;\n}\n\nfunction computeGeo(W: number, H: number): Geo {\n  const minDim = Math.min(W, H);\n  const scale = minDim / REF_DIM;\n  return {\n    W,\n    H,\n    cx: W / 2,\n    cy: H / 2,\n    R0: minDim * 0.18,\n    scale,\n    depthStep: 5 * scale,\n  };\n}\n\nfunction courseRadius(g: Geo, courseIdx: number): number {\n  return g.R0 + courseIdx * g.depthStep;\n}\n\n/** advances the deterministic case-building sim up to nowMs, spawning/resolving\n * candidates and committing accepted grains as their cement animation completes */\nfunction stepSim(sim: SimState, nowMs: number, g: Geo, rand: () => number) {\n  if (sim.phase !== \"grow\") return;\n\n  while (sim.nextCandidateAtMs <= nowMs) {\n    const spawnMs = sim.nextCandidateAtMs;\n    sim.nextCandidateAtMs += CANDIDATE_INTERVAL_MS;\n    const size = sampleGrainSize(g.scale, rand);\n    const accepted = Math.abs(sim.gapWidth - size) <= ACCEPT_BAND * size;\n    const angle = sim.cursorAngle + (accepted ? size / courseRadius(g, sim.courseIdx) / 2 : 0);\n    sim.candidates.push({\n      spawnMs,\n      angle,\n      size,\n      rotation: rand() * Math.PI * 2,\n      radius: courseRadius(g, sim.courseIdx),\n      courseAtSpawn: sim.courseIdx,\n      accepted,\n      driftAngle: rand() * Math.PI * 2,\n    });\n\n    if (accepted) {\n      const arc = size / courseRadius(g, sim.courseIdx);\n      sim.cursorAngle += arc;\n      sim.gapWidth = sampleGrainSize(g.scale, rand);\n      if (sim.cursorAngle >= Math.PI * 2) {\n        // course complete: it becomes a guide ring, active course resets\n        const finishedCourse = sim.courseIdx;\n        sim.grains = [];\n        sim.cursorAngle = 0;\n        sim.gapWidth = sampleGrainSize(g.scale, rand);\n        if (finishedCourse >= TOTAL_COURSES - 1) {\n          sim.phase = \"hold\";\n          sim.phaseStartMs = spawnMs;\n          // the just-finished last course still shows its cemented grains\n          // through hold; only completed courses BEFORE it become rings now\n          return;\n        }\n        sim.completedRadii.push(courseRadius(g, finishedCourse));\n        sim.courseIdx = finishedCourse + 1;\n      }\n    }\n  }\n\n  // fold any candidate whose accept animation has finished into the\n  // permanent grain list for the (still-active) course it belongs to\n  sim.candidates = sim.candidates.filter((c) => {\n    const age = nowMs - c.spawnMs;\n    const total = DRIFT_MS + PAUSE_MS + (c.accepted ? ACCEPT_MS : REJECT_MS);\n    if (age < total) return true;\n    // only fold into the permanent list if its course is still the active\n    // one — a candidate whose course completed mid-animation is already\n    // represented by that course's guide ring instead\n    if (c.accepted && c.courseAtSpawn === sim.courseIdx) {\n      sim.grains.push({ angle: c.angle, size: c.size, rotation: c.rotation });\n    }\n    return false;\n  });\n}\n\nfunction drawGrainAt(\n  ctx: CanvasRenderingContext2D,\n  g: Geo,\n  radius: number,\n  angle: number,\n  size: number,\n  rotation: number,\n  tokens: Tokens,\n  fillAlpha: number,\n  strokeAlpha: number\n) {\n  const x = g.cx + radius * Math.cos(angle);\n  const y = g.cy + radius * Math.sin(angle);\n  drawRoundedQuad(ctx, x, y, size, rotation, tokens.muted, fillAlpha, tokens.border, strokeAlpha);\n}\n\nfunction drawCandidate(\n  ctx: CanvasRenderingContext2D,\n  g: Geo,\n  c: Candidate,\n  nowMs: number,\n  tokens: Tokens\n) {\n  const age = nowMs - c.spawnMs;\n  const hoverR = c.radius + HOVER_GAP_REF * g.scale;\n  if (age < DRIFT_MS) {\n    const p = age / DRIFT_MS;\n    const eased = 1 - (1 - p) * (1 - p);\n    const fromR = c.radius + HOVER_GAP_REF * g.scale * 4;\n    const r = fromR + (hoverR - fromR) * eased;\n    drawGrainAt(ctx, g, r, c.angle, c.size, c.rotation, tokens, eased, eased * tokens.grainOutlineAlpha);\n    return;\n  }\n  if (age < DRIFT_MS + PAUSE_MS) {\n    drawGrainAt(ctx, g, hoverR, c.angle, c.size, c.rotation, tokens, 1, tokens.grainOutlineAlpha);\n    return;\n  }\n  const resolveAge = age - DRIFT_MS - PAUSE_MS;\n  if (c.accepted) {\n    const p = Math.min(1, resolveAge / ACCEPT_MS);\n    const eased = 1 - (1 - p) ** 3; // ease-out cubic\n    const r = hoverR + (c.radius - hoverR) * eased;\n    drawGrainAt(ctx, g, r, c.angle, c.size, c.rotation, tokens, 1, tokens.grainOutlineAlpha);\n  } else {\n    const p = Math.min(1, resolveAge / REJECT_MS);\n    const dist = REJECT_DRIFT_REF * g.scale * p;\n    const x = g.cx + hoverR * Math.cos(c.angle) + Math.cos(c.driftAngle) * dist;\n    const y = g.cy + hoverR * Math.sin(c.angle) + Math.sin(c.driftAngle) * dist;\n    const alpha = 1 - p;\n    drawRoundedQuad(ctx, x, y, c.size, c.rotation, tokens.muted, alpha, tokens.border, alpha * tokens.grainOutlineAlpha);\n  }\n}\n\n/** deterministic mid-third-course frame for prefers-reduced-motion: 2 complete\n * courses as guide rings, third course half-built with an irregular mixed rim */\nfunction buildReducedFrame(g: Geo): SimState {\n  const rand = mulberry32(0x6c1d5f);\n  const sim: SimState = {\n    phase: \"hold\", // static — no candidates in flight, no further ticks\n    phaseStartMs: 0,\n    courseIdx: 2,\n    cursorAngle: Math.PI,\n    gapWidth: sampleGrainSize(g.scale, rand),\n    completedRadii: [courseRadius(g, 0), courseRadius(g, 1)],\n    grains: [],\n    candidates: [],\n    nextCandidateAtMs: 0,\n  };\n  const radius = courseRadius(g, 2);\n  let angle = 0;\n  while (angle < Math.PI) {\n    const size = sampleGrainSize(g.scale, rand);\n    const arc = size / radius;\n    sim.grains.push({ angle: angle + arc / 2, size, rotation: rand() * Math.PI * 2 });\n    angle += arc;\n  }\n  return sim;\n}\n\nfunction render(\n  ctx: CanvasRenderingContext2D,\n  g: Geo,\n  sim: SimState,\n  nowMs: number,\n  tokens: Tokens\n) {\n  ctx.clearRect(0, 0, g.W, g.H);\n\n  sim.completedRadii.forEach((r, i) => {\n    ctx.save();\n    ctx.globalAlpha = tokens.grainOutlineAlpha;\n    ctx.strokeStyle = tokens.border;\n    ctx.lineWidth = 1;\n    traceRim(ctx, g.cx, g.cy, r, i);\n    ctx.stroke();\n    ctx.restore();\n  });\n\n  const activeRadius = courseRadius(g, sim.courseIdx);\n\n  let ringAlpha = tokens.grainOutlineAlpha;\n  let grainAlpha = 1;\n  if (sim.phase === \"fade\") {\n    const p = Math.min(1, (nowMs - sim.phaseStartMs) / FADE_MS);\n    grainAlpha = 1 - p;\n    ringAlpha = tokens.grainOutlineAlpha * p;\n  }\n\n  ctx.save();\n  ctx.globalAlpha = ringAlpha;\n  ctx.strokeStyle = tokens.border;\n  ctx.lineWidth = 1;\n  traceRim(ctx, g.cx, g.cy, activeRadius, sim.courseIdx);\n  ctx.stroke();\n  ctx.restore();\n\n  if (grainAlpha > 0.01) {\n    for (const grain of sim.grains) {\n      drawGrainAt(\n        ctx,\n        g,\n        activeRadius,\n        grain.angle,\n        grain.size,\n        grain.rotation,\n        tokens,\n        grainAlpha,\n        grainAlpha * tokens.grainOutlineAlpha\n      );\n    }\n    if (sim.phase === \"grow\") {\n      for (const c of sim.candidates) drawCandidate(ctx, g, c, nowMs, tokens);\n    }\n  }\n}\n\nexport interface CaddisflyCaseAssemblyProps {\n  /** small mono label above the case */\n  label?: string;\n  /** extra classes merged onto the rendered root element */\n  className?: string;\n}\n\nexport function CaddisflyCaseAssembly({\n  label = \"ASSEMBLING\",\n  className = \"\",\n}: CaddisflyCaseAssemblyProps) {\n  const chartWrapRef = useRef<HTMLDivElement>(null);\n  const canvasRef = useRef<HTMLCanvasElement>(null);\n\n  useEffect(() => {\n    const chartWrap = chartWrapRef.current;\n    const canvas = canvasRef.current;\n    if (!chartWrap || !canvas) return;\n    const ctx = canvas.getContext(\"2d\");\n    if (!ctx) return;\n\n    const reduced = window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches;\n\n    let disposed = false;\n    let tokens: Tokens | null = null;\n    let dpr = 1;\n    let g: Geo = computeGeo(1, 1);\n    let sized = false;\n    let visible = true;\n\n    const rand = mulberry32(0x9a3c1e);\n    let sim: SimState = freshSim(rand, g.scale);\n    let cycleStartMs = 0;\n    let raf = 0;\n    let tokenWaitRaf = 0;\n\n    const fitCanvas = () => {\n      canvas.width = Math.max(1, Math.round(g.W * dpr));\n      canvas.height = Math.max(1, Math.round(g.H * dpr));\n      ctx.setTransform(dpr, 0, 0, dpr, 0, 0);\n    };\n\n    const resizeAll = () => {\n      if (!tokens) return;\n      const rect = chartWrap.getBoundingClientRect();\n      if (rect.width < 4 || rect.height < 4) {\n        sized = false;\n        return;\n      }\n      dpr = Math.min(window.devicePixelRatio || 1, 2);\n      g = computeGeo(rect.width, rect.height);\n      fitCanvas();\n      sized = true;\n    };\n\n    const loop = (nowRaf: number) => {\n      if (disposed) return;\n      if (!visible) {\n        raf = 0; // IntersectionObserver re-arms this on re-entering view\n        return;\n      }\n      raf = requestAnimationFrame(loop);\n      if (!sized || !tokens) return;\n      if (cycleStartMs === 0) cycleStartMs = nowRaf;\n      const elapsed = nowRaf - cycleStartMs;\n\n      if (sim.phase === \"grow\") {\n        stepSim(sim, elapsed, g, rand);\n      } else if (sim.phase === \"hold\" && elapsed - sim.phaseStartMs >= HOLD_MS) {\n        sim.phase = \"fade\";\n        sim.phaseStartMs = elapsed;\n      } else if (sim.phase === \"fade\" && elapsed - sim.phaseStartMs >= FADE_MS) {\n        sim = freshSim(rand, g.scale);\n        cycleStartMs = nowRaf;\n        render(ctx, g, sim, 0, tokens);\n        return;\n      }\n      render(ctx, g, sim, elapsed, tokens);\n    };\n\n    let started = false;\n    const kick = () => {\n      if (started || disposed || !tokens || !sized) return;\n      started = true;\n      if (reduced) {\n        sim = buildReducedFrame(g);\n        render(ctx, g, sim, 0, tokens);\n        return; // no rAF loop, no timers, no observers driving motion\n      }\n      raf = requestAnimationFrame(loop);\n    };\n\n    const start = () => {\n      if (disposed) return;\n      tokens = readTokens();\n      if (!tokens) {\n        tokenWaitRaf = requestAnimationFrame(start);\n        return;\n      }\n      resizeAll();\n      kick();\n    };\n\n    const ro = new ResizeObserver(() => {\n      if (!tokens) return;\n      resizeAll();\n      if (reduced) {\n        sim = buildReducedFrame(g);\n        render(ctx, g, sim, 0, tokens);\n      }\n      kick();\n    });\n    ro.observe(chartWrap);\n\n    const mo = new MutationObserver(() => {\n      tokens = readTokens();\n      if (tokens) {\n        resizeAll();\n        if (reduced) {\n          sim = buildReducedFrame(g);\n          render(ctx, g, sim, 0, tokens);\n        }\n        kick();\n      }\n    });\n    mo.observe(document.documentElement, { attributes: true, attributeFilter: [\"class\"] });\n\n    const io = new IntersectionObserver((entries) => {\n      const wasVisible = visible;\n      visible = entries[0]?.isIntersecting ?? true;\n      if (visible && !wasVisible && !reduced && tokens && !raf) {\n        tokens = readTokens() ?? tokens; // pick up any theme flip that happened while hidden\n        resizeAll();\n        // restart the whole case rather than resuming mid-flight — an\n        // arbitrarily long time off-screen must not resume a stale build\n        sim = freshSim(rand, g.scale);\n        cycleStartMs = 0;\n        raf = requestAnimationFrame(loop);\n      }\n    });\n    io.observe(chartWrap);\n\n    start();\n\n    return () => {\n      disposed = true;\n      cancelAnimationFrame(raf);\n      cancelAnimationFrame(tokenWaitRaf);\n      ro.disconnect();\n      mo.disconnect();\n      io.disconnect();\n    };\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, []);\n\n  return (\n    <div\n      className={`relative w-full max-w-sm overflow-hidden rounded-md border border-border bg-surface p-4 ${className}`}\n    >\n      <div className=\"mb-3 flex items-center justify-between gap-2\">\n        <p className=\"truncate font-mono text-[11px] tracking-widest text-ns-muted\">{label}</p>\n        <p className=\"shrink-0 font-mono text-[10px] tracking-widest text-ns-muted\">CASE</p>\n      </div>\n      <div ref={chartWrapRef} className=\"relative w-full\" style={{ aspectRatio: \"1 / 1\" }}>\n        <canvas\n          ref={canvasRef}\n          aria-hidden=\"true\"\n          className=\"pointer-events-none absolute inset-0 h-full w-full\"\n        />\n      </div>\n    </div>\n  );\n}\n",
      "type": "registry:ui",
      "target": "components/ui/caddisfly-case-assembly.tsx"
    }
  ],
  "cssVars": {
    "theme": {
      "color-ns-muted": "var(--ns-muted)",
      "color-surface": "var(--surface)"
    },
    "light": {
      "ns-muted": "#4d4d4d",
      "surface": "#fafafa"
    },
    "dark": {
      "ns-muted": "#8f8f8f",
      "surface": "#171717"
    }
  },
  "meta": {
    "collection": "core",
    "tags": [
      "loader",
      "loading",
      "canvas",
      "progress",
      "ambient",
      "physics",
      "biology",
      "particle",
      "status",
      "monochrome"
    ],
    "instruction": "A card-scale single-canvas ambient loader modelled on Trichoptera (caddisfly larva) case-building ethology (Hansell 1968): the larva selects mineral grains from the substrate one at a time, tests each roughly by size against the open gap at the case's growing rim, and either cements an accepted grain onto the rim's advancing edge with silk or lets a rejected one drift away, working around the rim in a rough spiral course so the tube extends and widens as courses complete. All geometry derives from the chart wrapper's getBoundingClientRect via computeGeo(W, H): the active course's rim radius is min(W,H) * 0.18 for course 0, growing by a 5px-at-260px-reference depth step (scaled by minDim/260) per completed course, so 'the case's visible depth' is literally a second, third, fourth... offset rim drawn behind the active one. Every rim (active and completed) is traced as a slightly irregular closed polyline via traceRim — two low sine harmonics perturbing the radius by angle so it reads as a real substrate boundary, never a perfect circle. One candidate grain is evaluated every 340ms (CANDIDATE_INTERVAL_MS), sampled from a bimodal substrate-grade size mix scaled to card size (60% 'fine' 3-5px, 40% 'coarse' 6-9px at the 260px reference). Acceptance is a pure size test against the currently open rim gap (itself resampled from the same bimodal mix after every acceptance, standing in for the substrate's natural irregular opening): accepted only if the gap is within +-22% of the candidate's size (ACCEPT_BAND), giving roughly the spec's 55-65% real-world acceptance rate since gap and grain are drawn from the same distribution. Each candidate's full lifecycle plays out over drift-in (100ms, ease-out from a wider radius down to a hover standoff at the gap), a brief 20ms pause at the gap, then its verdict: an accepted grain eases (cubic ease-out) from the hover standoff into its exact cemented radius over 220ms and the build cursor advances by its angular width (size / rim radius); a rejected grain drifts 18px (scaled) away from the hover point at a random heading while fading its opacity to 0 over 260ms — a visibly slower, visibly divergent motion from a cement, so the two verdicts never read as 'something appeared near the rim' at a glance. Only the ACTIVE course renders individual cemented grains, as small rounded quadrilaterals (canvas roundRect, corner radius 28% of size) at random rotation, --ns-muted fill with a --border outline per grain so adjacent grains stay legible against each other, not just against the background; once the active course's build cursor wraps a full 2*PI, its cemented grains are dropped and replaced by a permanent --border guide ring at that course's radius, and a fresh course begins at the next, larger radius. After 5 courses (TOTAL_COURSES) the case holds for 4000ms (HOLD_MS) at its finished state, then the last course's cemented-grain fill crossfades into its own guide ring over 1500ms (FADE_MS, grain alpha 1->0 while that course's ring alpha ramps 0->1 in lockstep) so the whole case genuinely reads as border-outline-only for a beat, then the entire sim resets (all rings cleared, cursor and course index zeroed, a fresh gap sampled) and a new case starts growing from a bare rim — an unbounded loop with no forced duration, never wired to any actual async operation's real progress. Colour: --ns-muted and --border are read via getComputedStyle(document.documentElement) before the first paint (retried on the next rAF if either is empty) and re-read on a MutationObserver watching documentElement's class; --border's per-grain outline alpha is not a flat constant but measured — a 1x1 offscreen canvas rasterises --border against --background and compares luminance (0.2126/0.7152/0.0722 weights), and low-contrast themes (typical of light mode, where --border sits close to --background at ~1.1:1) get a bumped 0.85 outline alpha instead of the normal 0.55, so grain-to-grain edges stay separated rather than blurring into one shape specifically in light theme. --ns-accent never appears anywhere in this component; it has no interaction, only an ambient clock. A ResizeObserver on the chart wrapper recomputes geometry and the canvas backing store (devicePixelRatio capped at 2); an IntersectionObserver (threshold 0) restarts the whole case from a fresh bare rim on re-entering view rather than resuming a stale mid-build state, and picks up any theme flip that happened while off-screen. Under prefers-reduced-motion the component runs zero rAF loops and zero timers: it deterministically builds (a seeded mulberry32 PRNG, not Math.random, so the frame is byte-stable) a frame named CASE_MIDBUILD — two complete courses as guide rings, a third course cemented exactly halfway (cursor at PI) with a real mixed fine/coarse grain rim — chosen because it shows both the depth-ring structure of finished courses and the raw grain-by-grain rim texture of an active one, and repaints only on theme/resize changes. Cleanup on unmount cancels both the animation and token-wait rAF handles and disconnects all three observers. Props: label (mono header text, default 'ASSEMBLING'), className. Zero dependencies, DOM+canvas only, no colour literal anywhere including fallbacks."
  },
  "type": "registry:ui"
}