Skip to main content

ns-ui

Caddisfly Case Assembly

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.

Use when an indeterminate wait that should read as a real selection-and-rejection process picking through noisy material, rather than a smooth continuous fill — a candidate grain visibly drifts in, pauses at the gap, and either snaps into place or bounces off roughly every 340ms, so a viewer can watch individual accept/reject decisions accumulate into a spiralling structure. Pick loader-die-tumble instead for a compact always-on inline glyph (20-96px) where the wait should read as discrete units ticking over on a fixed four-beat cadence with no accept/reject branching; pick loader-thread-spool or loader-braille for the same inline slot when a continuous or texture-based motif fits better; pick vacuum-filtration-cake-build instead when the point is one continuous, ever-thickening single-process build (a rate that decays under Darcy's law) with no discrete per-unit acceptance test at all.

Install

npx shadcn add https://design.helpmarq.com/r/caddisfly-case-assembly.json

Ask AI

Point an assistant at this component's docs (llms-full.txt) with one click.

Claude, ChatGPT, Grok, and Perplexity open with the prompt already in. Gemini copies it to your clipboard first. Paste it in once the chat opens.

Source
registry/core/caddisfly-case-assembly/component.tsx
"use client";

import { useEffect, useRef } from "react";

// ---------------------------------------------------------------------------
// CaddisflyCaseAssembly — a card-scale ambient loader modelled on Trichoptera
// (caddisfly larva) case-building: 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 (Hansell 1968, "The house-building behaviour of the
// caddis fly larva").
//
// One candidate grain is evaluated every 340ms, sampled from a bimodal
// substrate-grade size mix (60% "fine" 3-5px, 40% "coarse" 6-9px at card
// scale). It is accepted only if the current rim gap is within +-22% of its
// size — about half of all candidates visibly bounce off before one sticks,
// which is the mechanic's whole point, not incidental noise. An accepted
// grain eases into place over 220ms and the build cursor advances along the
// rim by its width; a rejected one drifts 18px away at a random angle while
// fading over 260ms — a clearly different, clearly slower motion than a
// cement, so accept vs. reject reads as two distinct verdicts, not one blur.
//
// Only the ACTIVE course renders individual cemented grains (--ns-muted
// fill, --border outline per grain). Once a course wraps a full
// circumference the whole case's visible "depth" grows: that course's
// grains are dropped and replaced by a single offset guide ring in
// --border, drawn behind the new (larger-radius) active rim, and a fresh
// course starts. After 5 courses the finished case holds for 4s, then its
// last course's grain fill crossfades into its own guide ring over 1.5s (so
// the whole case reads as border-outline-only for a beat) before the entire
// case clears and a fresh one restarts from a bare rim.
// ---------------------------------------------------------------------------

const TOTAL_COURSES = 5;
const CANDIDATE_INTERVAL_MS = 340; // one candidate evaluated per this
const DRIFT_MS = 100; // candidate drifts in from outside the rim
const PAUSE_MS = 20; // brief hover at the gap before the verdict plays
const ACCEPT_MS = 220; // ease-out into cemented position
const REJECT_MS = 260; // bounce-away-and-fade
const ACCEPT_BAND = 0.22; // +-22% gap-vs-grain tolerance
const HOLD_MS = 4000; // completed case holds before it fades
const FADE_MS = 1500; // last course crossfades grains -> guide ring

const REF_DIM = 260; // reference min-dimension the px numbers below are tuned at
const FINE_MIN = 3;
const FINE_MAX = 5;
const COARSE_MIN = 6;
const COARSE_MAX = 9;
const FINE_PROB = 0.6;
const REJECT_DRIFT_REF = 18;
const HOVER_GAP_REF = 6; // radial standoff a candidate hovers at before its verdict

const CONTRAST_THRESHOLD = 0.12; // normalised luminance gap below which "low contrast"
const OUTLINE_ALPHA_BASE = 0.55;
const OUTLINE_ALPHA_LOW_CONTRAST = 0.85;

interface Tokens {
  muted: string;
  border: string;
  grainOutlineAlpha: number;
}

/** rasterises a CSS colour string through a 1x1 canvas to read its actual luminance —
 * used only to compare --border against --background, never to hardcode a colour. */
function luminanceOf(color: string): number {
  const probe = document.createElement("canvas");
  probe.width = 1;
  probe.height = 1;
  const pctx = probe.getContext("2d", { willReadFrequently: true } as CanvasRenderingContext2DSettings);
  if (!pctx) return 0;
  pctx.fillStyle = color;
  pctx.fillRect(0, 0, 1, 1);
  const d = pctx.getImageData(0, 0, 1, 1).data;
  return 0.2126 * d[0]! + 0.7152 * d[1]! + 0.0722 * d[2]!;
}

function readTokens(): Tokens | null {
  if (typeof document === "undefined") return null;
  const cs = getComputedStyle(document.documentElement);
  const muted = cs.getPropertyValue("--ns-muted").trim();
  const border = cs.getPropertyValue("--border").trim();
  const background = cs.getPropertyValue("--background").trim();
  if (!muted || !border || !background) return null; // not loaded yet — no paint before this
  let grainOutlineAlpha = OUTLINE_ALPHA_BASE;
  try {
    const contrast = Math.abs(luminanceOf(border) - luminanceOf(background)) / 255;
    grainOutlineAlpha = contrast < CONTRAST_THRESHOLD ? OUTLINE_ALPHA_LOW_CONTRAST : OUTLINE_ALPHA_BASE;
  } catch {
    grainOutlineAlpha = OUTLINE_ALPHA_BASE;
  }
  return { muted, border, grainOutlineAlpha };
}

function mulberry32(seed: number) {
  let a = seed >>> 0;
  return () => {
    a = (a + 0x6d2b79f5) >>> 0;
    let t = a;
    t = Math.imul(t ^ (t >>> 15), t | 1);
    t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
  };
}

/** samples one grain size (px, already scaled) from the fine/coarse substrate mix */
function sampleGrainSize(scale: number, rand: () => number): number {
  const fine = rand() < FINE_PROB;
  const [lo, hi] = fine ? [FINE_MIN, FINE_MAX] : [COARSE_MIN, COARSE_MAX];
  return (lo + rand() * (hi - lo)) * scale;
}

/** irregular tube-cross-section radius at angle theta for a given course —
 * two low harmonics, seeded per course, so the rim reads as a real substrate
 * boundary rather than a perfect circle */
function rimNoise(courseIdx: number, theta: number): number {
  return (
    1 +
    0.035 * Math.sin(3 * theta + courseIdx * 1.7) +
    0.02 * Math.sin(7 * theta + courseIdx * 0.6 + 1.1)
  );
}

function traceRim(
  ctx: CanvasRenderingContext2D,
  cx: number,
  cy: number,
  radius: number,
  courseIdx: number
) {
  const steps = 64;
  ctx.beginPath();
  for (let i = 0; i <= steps; i++) {
    const theta = (i / steps) * Math.PI * 2;
    const r = radius * rimNoise(courseIdx, theta);
    const x = cx + r * Math.cos(theta);
    const y = cy + r * Math.sin(theta);
    if (i === 0) ctx.moveTo(x, y);
    else ctx.lineTo(x, y);
  }
  ctx.closePath();
}

function drawRoundedQuad(
  ctx: CanvasRenderingContext2D,
  x: number,
  y: number,
  size: number,
  rotation: number,
  fillStyle: string,
  fillAlpha: number,
  strokeStyle: string,
  strokeAlpha: number
) {
  ctx.save();
  ctx.translate(x, y);
  ctx.rotate(rotation);
  const r = size * 0.28;
  ctx.beginPath();
  ctx.roundRect(-size / 2, -size / 2, size, size, r);
  ctx.globalAlpha = fillAlpha;
  ctx.fillStyle = fillStyle;
  ctx.fill();
  ctx.globalAlpha = strokeAlpha;
  ctx.strokeStyle = strokeStyle;
  ctx.lineWidth = Math.max(0.6, size * 0.08);
  ctx.stroke();
  ctx.restore();
}

interface CementedGrain {
  angle: number;
  size: number;
  rotation: number;
}

interface Candidate {
  spawnMs: number;
  angle: number;
  size: number;
  rotation: number;
  radius: number; // active course radius at spawn time
  courseAtSpawn: number; // which course this candidate belongs to
  accepted: boolean;
  driftAngle: number; // random escape heading, only used if rejected
}

interface SimState {
  phase: "grow" | "hold" | "fade";
  phaseStartMs: number;
  courseIdx: number; // 0-indexed active course
  cursorAngle: number; // 0..2*PI build progress within the active course
  gapWidth: number; // current open-gap size at the active build edge
  completedRadii: number[]; // guide-ring radii for finished courses
  grains: CementedGrain[]; // cemented grains of the ACTIVE course only
  candidates: Candidate[];
  nextCandidateAtMs: number;
}

function freshSim(rand: () => number, scale: number): SimState {
  return {
    phase: "grow",
    phaseStartMs: 0,
    courseIdx: 0,
    cursorAngle: 0,
    gapWidth: sampleGrainSize(scale, rand),
    completedRadii: [],
    grains: [],
    candidates: [],
    nextCandidateAtMs: 0,
  };
}

interface Geo {
  W: number;
  H: number;
  cx: number;
  cy: number;
  R0: number;
  scale: number;
  depthStep: number;
}

function computeGeo(W: number, H: number): Geo {
  const minDim = Math.min(W, H);
  const scale = minDim / REF_DIM;
  return {
    W,
    H,
    cx: W / 2,
    cy: H / 2,
    R0: minDim * 0.18,
    scale,
    depthStep: 5 * scale,
  };
}

function courseRadius(g: Geo, courseIdx: number): number {
  return g.R0 + courseIdx * g.depthStep;
}

/** advances the deterministic case-building sim up to nowMs, spawning/resolving
 * candidates and committing accepted grains as their cement animation completes */
function stepSim(sim: SimState, nowMs: number, g: Geo, rand: () => number) {
  if (sim.phase !== "grow") return;

  while (sim.nextCandidateAtMs <= nowMs) {
    const spawnMs = sim.nextCandidateAtMs;
    sim.nextCandidateAtMs += CANDIDATE_INTERVAL_MS;
    const size = sampleGrainSize(g.scale, rand);
    const accepted = Math.abs(sim.gapWidth - size) <= ACCEPT_BAND * size;
    const angle = sim.cursorAngle + (accepted ? size / courseRadius(g, sim.courseIdx) / 2 : 0);
    sim.candidates.push({
      spawnMs,
      angle,
      size,
      rotation: rand() * Math.PI * 2,
      radius: courseRadius(g, sim.courseIdx),
      courseAtSpawn: sim.courseIdx,
      accepted,
      driftAngle: rand() * Math.PI * 2,
    });

    if (accepted) {
      const arc = size / courseRadius(g, sim.courseIdx);
      sim.cursorAngle += arc;
      sim.gapWidth = sampleGrainSize(g.scale, rand);
      if (sim.cursorAngle >= Math.PI * 2) {
        // course complete: it becomes a guide ring, active course resets
        const finishedCourse = sim.courseIdx;
        sim.grains = [];
        sim.cursorAngle = 0;
        sim.gapWidth = sampleGrainSize(g.scale, rand);
        if (finishedCourse >= TOTAL_COURSES - 1) {
          sim.phase = "hold";
          sim.phaseStartMs = spawnMs;
          // the just-finished last course still shows its cemented grains
          // through hold; only completed courses BEFORE it become rings now
          return;
        }
        sim.completedRadii.push(courseRadius(g, finishedCourse));
        sim.courseIdx = finishedCourse + 1;
      }
    }
  }

  // fold any candidate whose accept animation has finished into the
  // permanent grain list for the (still-active) course it belongs to
  sim.candidates = sim.candidates.filter((c) => {
    const age = nowMs - c.spawnMs;
    const total = DRIFT_MS + PAUSE_MS + (c.accepted ? ACCEPT_MS : REJECT_MS);
    if (age < total) return true;
    // only fold into the permanent list if its course is still the active
    // one — a candidate whose course completed mid-animation is already
    // represented by that course's guide ring instead
    if (c.accepted && c.courseAtSpawn === sim.courseIdx) {
      sim.grains.push({ angle: c.angle, size: c.size, rotation: c.rotation });
    }
    return false;
  });
}

function drawGrainAt(
  ctx: CanvasRenderingContext2D,
  g: Geo,
  radius: number,
  angle: number,
  size: number,
  rotation: number,
  tokens: Tokens,
  fillAlpha: number,
  strokeAlpha: number
) {
  const x = g.cx + radius * Math.cos(angle);
  const y = g.cy + radius * Math.sin(angle);
  drawRoundedQuad(ctx, x, y, size, rotation, tokens.muted, fillAlpha, tokens.border, strokeAlpha);
}

function drawCandidate(
  ctx: CanvasRenderingContext2D,
  g: Geo,
  c: Candidate,
  nowMs: number,
  tokens: Tokens
) {
  const age = nowMs - c.spawnMs;
  const hoverR = c.radius + HOVER_GAP_REF * g.scale;
  if (age < DRIFT_MS) {
    const p = age / DRIFT_MS;
    const eased = 1 - (1 - p) * (1 - p);
    const fromR = c.radius + HOVER_GAP_REF * g.scale * 4;
    const r = fromR + (hoverR - fromR) * eased;
    drawGrainAt(ctx, g, r, c.angle, c.size, c.rotation, tokens, eased, eased * tokens.grainOutlineAlpha);
    return;
  }
  if (age < DRIFT_MS + PAUSE_MS) {
    drawGrainAt(ctx, g, hoverR, c.angle, c.size, c.rotation, tokens, 1, tokens.grainOutlineAlpha);
    return;
  }
  const resolveAge = age - DRIFT_MS - PAUSE_MS;
  if (c.accepted) {
    const p = Math.min(1, resolveAge / ACCEPT_MS);
    const eased = 1 - (1 - p) ** 3; // ease-out cubic
    const r = hoverR + (c.radius - hoverR) * eased;
    drawGrainAt(ctx, g, r, c.angle, c.size, c.rotation, tokens, 1, tokens.grainOutlineAlpha);
  } else {
    const p = Math.min(1, resolveAge / REJECT_MS);
    const dist = REJECT_DRIFT_REF * g.scale * p;
    const x = g.cx + hoverR * Math.cos(c.angle) + Math.cos(c.driftAngle) * dist;
    const y = g.cy + hoverR * Math.sin(c.angle) + Math.sin(c.driftAngle) * dist;
    const alpha = 1 - p;
    drawRoundedQuad(ctx, x, y, c.size, c.rotation, tokens.muted, alpha, tokens.border, alpha * tokens.grainOutlineAlpha);
  }
}

/** deterministic mid-third-course frame for prefers-reduced-motion: 2 complete
 * courses as guide rings, third course half-built with an irregular mixed rim */
function buildReducedFrame(g: Geo): SimState {
  const rand = mulberry32(0x6c1d5f);
  const sim: SimState = {
    phase: "hold", // static — no candidates in flight, no further ticks
    phaseStartMs: 0,
    courseIdx: 2,
    cursorAngle: Math.PI,
    gapWidth: sampleGrainSize(g.scale, rand),
    completedRadii: [courseRadius(g, 0), courseRadius(g, 1)],
    grains: [],
    candidates: [],
    nextCandidateAtMs: 0,
  };
  const radius = courseRadius(g, 2);
  let angle = 0;
  while (angle < Math.PI) {
    const size = sampleGrainSize(g.scale, rand);
    const arc = size / radius;
    sim.grains.push({ angle: angle + arc / 2, size, rotation: rand() * Math.PI * 2 });
    angle += arc;
  }
  return sim;
}

function render(
  ctx: CanvasRenderingContext2D,
  g: Geo,
  sim: SimState,
  nowMs: number,
  tokens: Tokens
) {
  ctx.clearRect(0, 0, g.W, g.H);

  sim.completedRadii.forEach((r, i) => {
    ctx.save();
    ctx.globalAlpha = tokens.grainOutlineAlpha;
    ctx.strokeStyle = tokens.border;
    ctx.lineWidth = 1;
    traceRim(ctx, g.cx, g.cy, r, i);
    ctx.stroke();
    ctx.restore();
  });

  const activeRadius = courseRadius(g, sim.courseIdx);

  let ringAlpha = tokens.grainOutlineAlpha;
  let grainAlpha = 1;
  if (sim.phase === "fade") {
    const p = Math.min(1, (nowMs - sim.phaseStartMs) / FADE_MS);
    grainAlpha = 1 - p;
    ringAlpha = tokens.grainOutlineAlpha * p;
  }

  ctx.save();
  ctx.globalAlpha = ringAlpha;
  ctx.strokeStyle = tokens.border;
  ctx.lineWidth = 1;
  traceRim(ctx, g.cx, g.cy, activeRadius, sim.courseIdx);
  ctx.stroke();
  ctx.restore();

  if (grainAlpha > 0.01) {
    for (const grain of sim.grains) {
      drawGrainAt(
        ctx,
        g,
        activeRadius,
        grain.angle,
        grain.size,
        grain.rotation,
        tokens,
        grainAlpha,
        grainAlpha * tokens.grainOutlineAlpha
      );
    }
    if (sim.phase === "grow") {
      for (const c of sim.candidates) drawCandidate(ctx, g, c, nowMs, tokens);
    }
  }
}

export interface CaddisflyCaseAssemblyProps {
  /** small mono label above the case */
  label?: string;
  /** extra classes merged onto the rendered root element */
  className?: string;
}

export function CaddisflyCaseAssembly({
  label = "ASSEMBLING",
  className = "",
}: CaddisflyCaseAssemblyProps) {
  const chartWrapRef = useRef<HTMLDivElement>(null);
  const canvasRef = useRef<HTMLCanvasElement>(null);

  useEffect(() => {
    const chartWrap = chartWrapRef.current;
    const canvas = canvasRef.current;
    if (!chartWrap || !canvas) return;
    const ctx = canvas.getContext("2d");
    if (!ctx) return;

    const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;

    let disposed = false;
    let tokens: Tokens | null = null;
    let dpr = 1;
    let g: Geo = computeGeo(1, 1);
    let sized = false;
    let visible = true;

    const rand = mulberry32(0x9a3c1e);
    let sim: SimState = freshSim(rand, g.scale);
    let cycleStartMs = 0;
    let raf = 0;
    let tokenWaitRaf = 0;

    const fitCanvas = () => {
      canvas.width = Math.max(1, Math.round(g.W * dpr));
      canvas.height = Math.max(1, Math.round(g.H * dpr));
      ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
    };

    const resizeAll = () => {
      if (!tokens) return;
      const rect = chartWrap.getBoundingClientRect();
      if (rect.width < 4 || rect.height < 4) {
        sized = false;
        return;
      }
      dpr = Math.min(window.devicePixelRatio || 1, 2);
      g = computeGeo(rect.width, rect.height);
      fitCanvas();
      sized = true;
    };

    const loop = (nowRaf: number) => {
      if (disposed) return;
      if (!visible) {
        raf = 0; // IntersectionObserver re-arms this on re-entering view
        return;
      }
      raf = requestAnimationFrame(loop);
      if (!sized || !tokens) return;
      if (cycleStartMs === 0) cycleStartMs = nowRaf;
      const elapsed = nowRaf - cycleStartMs;

      if (sim.phase === "grow") {
        stepSim(sim, elapsed, g, rand);
      } else if (sim.phase === "hold" && elapsed - sim.phaseStartMs >= HOLD_MS) {
        sim.phase = "fade";
        sim.phaseStartMs = elapsed;
      } else if (sim.phase === "fade" && elapsed - sim.phaseStartMs >= FADE_MS) {
        sim = freshSim(rand, g.scale);
        cycleStartMs = nowRaf;
        render(ctx, g, sim, 0, tokens);
        return;
      }
      render(ctx, g, sim, elapsed, tokens);
    };

    let started = false;
    const kick = () => {
      if (started || disposed || !tokens || !sized) return;
      started = true;
      if (reduced) {
        sim = buildReducedFrame(g);
        render(ctx, g, sim, 0, tokens);
        return; // no rAF loop, no timers, no observers driving motion
      }
      raf = requestAnimationFrame(loop);
    };

    const start = () => {
      if (disposed) return;
      tokens = readTokens();
      if (!tokens) {
        tokenWaitRaf = requestAnimationFrame(start);
        return;
      }
      resizeAll();
      kick();
    };

    const ro = new ResizeObserver(() => {
      if (!tokens) return;
      resizeAll();
      if (reduced) {
        sim = buildReducedFrame(g);
        render(ctx, g, sim, 0, tokens);
      }
      kick();
    });
    ro.observe(chartWrap);

    const mo = new MutationObserver(() => {
      tokens = readTokens();
      if (tokens) {
        resizeAll();
        if (reduced) {
          sim = buildReducedFrame(g);
          render(ctx, g, sim, 0, tokens);
        }
        kick();
      }
    });
    mo.observe(document.documentElement, { attributes: true, attributeFilter: ["class"] });

    const io = new IntersectionObserver((entries) => {
      const wasVisible = visible;
      visible = entries[0]?.isIntersecting ?? true;
      if (visible && !wasVisible && !reduced && tokens && !raf) {
        tokens = readTokens() ?? tokens; // pick up any theme flip that happened while hidden
        resizeAll();
        // restart the whole case rather than resuming mid-flight — an
        // arbitrarily long time off-screen must not resume a stale build
        sim = freshSim(rand, g.scale);
        cycleStartMs = 0;
        raf = requestAnimationFrame(loop);
      }
    });
    io.observe(chartWrap);

    start();

    return () => {
      disposed = true;
      cancelAnimationFrame(raf);
      cancelAnimationFrame(tokenWaitRaf);
      ro.disconnect();
      mo.disconnect();
      io.disconnect();
    };
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);

  return (
    <div
      className={`relative w-full max-w-sm overflow-hidden rounded-md border border-border bg-surface p-4 ${className}`}
    >
      <div className="mb-3 flex items-center justify-between gap-2">
        <p className="truncate font-mono text-[11px] tracking-widest text-ns-muted">{label}</p>
        <p className="shrink-0 font-mono text-[10px] tracking-widest text-ns-muted">CASE</p>
      </div>
      <div ref={chartWrapRef} className="relative w-full" style={{ aspectRatio: "1 / 1" }}>
        <canvas
          ref={canvasRef}
          aria-hidden="true"
          className="pointer-events-none absolute inset-0 h-full w-full"
        />
      </div>
    </div>
  );
}
Build spec

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.

Props

PropTypeDefaultDescription
label?string"ASSEMBLING"small mono label above the case
className?stringextra classes merged onto the rendered root element