{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "thallus-siege",
  "title": "Thallus Siege",
  "description": "A full-bleed pane of crustose lichen: a dozen colonies grow lobed radial fronts across a shared occupancy lattice, stall on contact, occasionally creep over a markedly slower rival, and senesce from the centre outward to reopen ground the mosaic keeps renegotiating on a scale of minutes, not frames.",
  "dependencies": [],
  "files": [
    {
      "path": "registry/loud/thallus-siege/component.tsx",
      "content": "\"use client\";\n\nimport { useEffect, useRef } from \"react\";\n\n// ---------------------------------------------------------------------------\n// ThallusSiege — a pane of crustose lichen fighting a slow, never-finished war\n// for the same patch of rock.\n//\n// TERRITORY IS GROWN, NOT PARTITIONED. A dozen concurrent \"thalli\" nucleate at\n// staggered moments on bare substrate. Each grows a front of ~180 radial\n// spokes; every spoke advances at its own thallus's rate, scaled by a slow\n// angular/temporal noise term so the margin comes out lobed rather than a\n// clean circle. A spoke stamps the cell it enters into a shared occupancy\n// lattice (owner id + claim time). Stepping into a cell another thallus\n// already owns kills the spoke's advance there UNLESS this thallus's rate\n// exceeds the rival's by more than 1.3x, in which case it keeps creeping in\n// at (ratio - 1.3) * base — slow, but real reconquest, not a redraw. Nothing\n// here ever asks \"which seed is nearest\" — the tells that would give away a\n// Voronoi/distance-field impostor (boundaries bowing toward the slow side,\n// a late seed walled into a concave cell, a centre that never reopens) are\n// exactly the behaviours this front-growth mechanism produces for free.\n//\n// THE GOVERNING SCALAR is growth-rate variance across thalli (the `variance`\n// prop). At variance = 0 every thallus grows at the same pace, the 1.3x\n// dead-band is never crossed, no front ever reconquers another, and the\n// mosaic settles into flat, static, roughly-equidistant cells — a fair\n// near-Voronoi partition in every way but how it was computed. That is the\n// exact failure this piece exists to avoid, which is why variance defaults\n// to a real spread rather than a token one.\n//\n// SENESCENCE falls out of the same lattice for free: a cell's claim time is\n// stamped once and not refreshed, so the cells nearest a thallus's own\n// nucleation point are always its oldest. Aging a cell out past its thallus's\n// senescence limit therefore clears centre-first, outward, automatically —\n// no separate \"shrink from the middle\" pass is needed. A cleared cell is\n// bare substrate again; the neighbour whose front already stalled against it\n// resumes growing into it on the very next step. When a thallus's last cell\n// clears, its slot goes quiet for a staggered cooldown and then nucleates\n// again elsewhere, so the war never actually finishes — it only ever pauses\n// between shifts, which is the whole point of a 20-simulated-minute prewarm:\n// the frame you land on already has one or two turnovers baked into it.\n//\n// A contested cell that reverts to substrate — either by senescence or by\n// being overrun — leaves a mark in a separate, slowly-decaying \"seam\" field.\n// Rendered as a hairline lattice of --border, it is the pane's memory of\n// where a border used to run even after both sides that drew it are gone.\n//\n// RENDER is plain 2D canvas, no WebGL. The occupancy lattice is coarse\n// (quarter the density of anything a viewer could resolve as a grid); its\n// blocky per-cell ownership is smoothed into an organic contour with a real\n// marching-squares pass over the lattice (binary field per species, a single\n// segment per 2-crossing square, the diagonal pair for the 4-crossing saddle\n// case) rather than any blur or upscale trick. Fill is per-species ordered\n// dithering between --background and --ns-muted — 12 fixed densities, one\n// per lattice slot, so a slot keeps a recognisable \"species\" texture across\n// however many generations nucleate into it. Every colour is read from\n// getComputedStyle at mount and on a documentElement class mutation; nothing\n// is a literal. --ns-accent never appears — this is idle rock, nothing here\n// is being interacted with.\n// ---------------------------------------------------------------------------\n\nexport interface ThallusSiegeProps {\n  /** Concurrent lichen thalli contesting the pane. @default 12 */\n  count?: number;\n  /**\n   * Spread of growth rate sampled per thallus — the mechanism's governing\n   * scalar. 0 flattens every front to the same pace, the 1.3x reconquest\n   * band is never crossed, and the mosaic settles into fair, static,\n   * near-Voronoi cells: the exact failure this piece is built to avoid.\n   * @default 1\n   */\n  variance?: number;\n  /** Simulation-time multiplier. @default 1 */\n  speed?: number;\n  /** Freezes the pane on its current developed frame without unmounting. */\n  paused?: boolean;\n  /** Rendered over the pane — eyebrow, headline, CTA. */\n  children?: React.ReactNode;\n  className?: string;\n  style?: React.CSSProperties;\n}\n\nconst TAU = Math.PI * 2;\n\n// Lattice + spoke geometry.\nconst CELL = 6; // css px per occupancy cell — coarse on purpose\nconst SPOKES = 180;\n\n// Growth mechanics.\nconst BASE_RATE = 0.85; // px/s at variance's centre — deliberately glacial\nconst CONTACT_RATIO = 1.3; // dead-band: a spoke stalls below this rate ratio\nconst NOISE_AMOUNT = 0.3; // fraction of rate the margin noise can add/remove\n\n// Lifecycle timing, all in SIMULATED seconds.\nconst T_SEN_BASE = 780; // ~13 sim-minutes before a thallus's oldest cells fade\nconst T_SEN_SPREAD = 0.4; // +/- fraction of variety across individuals\nconst SEAM_TAU = 220; // ghost-seam decay time constant\nconst STAGGER_WINDOW = 300; // initial dozen nucleate across this span\nconst COOLDOWN_MIN = 40; // shortest gap before a retired slot tries again\nconst COOLDOWN_SPREAD = 140;\nconst NUCLEATION_CLEARANCE = CELL * 2.5;\n\n// The pane arrives already having fought over itself for a while.\nconst PREWARM_SECONDS = 1200; // 20 simulated minutes\nconst PREWARM_DT = 2; // sim seconds per prewarm step -> 600 steps\n\nconst DRAW_INTERVAL_MS = 380; // growth is glacial; redraw far under 60fps\n\n// 8x8 ordered (Bayer) dither matrix, values 0..63.\nconst BAYER8: number[][] = [\n  [0, 32, 8, 40, 2, 34, 10, 42],\n  [48, 16, 56, 24, 50, 18, 58, 26],\n  [12, 44, 4, 36, 14, 46, 6, 38],\n  [60, 28, 52, 20, 62, 30, 54, 22],\n  [3, 35, 11, 43, 1, 33, 9, 41],\n  [51, 19, 59, 27, 49, 17, 57, 25],\n  [15, 47, 7, 39, 13, 45, 5, 37],\n  [63, 31, 55, 23, 61, 29, 53, 21],\n];\n\ntype Thallus = {\n  id: number;\n  slot: number;\n  cx: number;\n  cy: number;\n  rate: number;\n  seed: number;\n  senLimit: number;\n  spokeR: Float32Array;\n  bboxMinC: number;\n  bboxMaxC: number;\n  bboxMinR: number;\n  bboxMaxR: number;\n  cellCount: number;\n};\n\n/** Slow, lobed angular perturbation. Not true curl noise — a stand-in built\n * from a few incommensurate sine terms in theta with a slow phase drift in\n * t, tuned so the margin scallops rather than either vanishing flat or\n * spiking into fingers. */\nfunction marginNoise(theta: number, simTime: number, seed: number): number {\n  const a = Math.sin(theta * 3 + seed * 7.1 + simTime * 0.013);\n  const b = Math.sin(theta * 5 - seed * 3.3 + simTime * 0.021) * 0.6;\n  const c = Math.sin(theta * 8 + seed * 11.7 - simTime * 0.009) * 0.35;\n  return (a + b + c) / 1.95;\n}\n\nexport function ThallusSiege({\n  count = 12,\n  variance = 1,\n  speed = 1,\n  paused = false,\n  children,\n  className = \"\",\n  style,\n}: ThallusSiegeProps) {\n  const wrapRef = useRef<HTMLDivElement | null>(null);\n  const canvasRef = useRef<HTMLCanvasElement | null>(null);\n  const pausedRef = useRef(paused);\n  pausedRef.current = paused;\n\n  useEffect(() => {\n    const wrap = wrapRef.current;\n    const canvas = canvasRef.current;\n    if (!wrap || !canvas) return;\n    const ctx = canvas.getContext(\"2d\");\n    if (!ctx) return;\n\n    const nSlots = Math.max(4, Math.min(24, Math.round(count)));\n    const varAmt = Math.max(0, variance);\n    const mq = window.matchMedia(\"(prefers-reduced-motion: reduce)\");\n    let reduced = mq.matches;\n\n    let disposed = false;\n    let cssW = 0;\n    let cssH = 0;\n    let dpr = 1;\n    let cols = 0;\n    let rows = 0;\n    let sized = false;\n    let staticMode = false;\n\n    // -- occupancy lattice -----------------------------------------------\n    let owner: Uint16Array = new Uint16Array(0);\n    let claimedAt: Float32Array = new Float32Array(0);\n    let seam: Float32Array = new Float32Array(0);\n    const seamCells = new Set<number>();\n\n    let simTime = 0;\n    let idSeq = 1;\n    let rngState = 0x9e3779b9 >>> 0;\n    const rng = () => {\n      rngState ^= rngState << 13;\n      rngState >>>= 0;\n      rngState ^= rngState >>> 17;\n      rngState ^= rngState << 5;\n      rngState >>>= 0;\n      return rngState / 4294967296;\n    };\n\n    const slots: (Thallus | null)[] = new Array(nSlots).fill(null);\n    const slotCooldown = new Float32Array(nSlots);\n    const activeById = new Map<number, Thallus>();\n\n    // -- colors -------------------------------------------------------------\n    let bgColor = \"#ffffff\";\n    let mutedColor = \"#4d4d4d\";\n    let borderColor = \"#ebebeb\";\n    const readColors = () => {\n      const cs = getComputedStyle(document.documentElement);\n      bgColor = (cs.getPropertyValue(\"--background\") || \"#ffffff\").trim() || \"#ffffff\";\n      mutedColor = (cs.getPropertyValue(\"--ns-muted\") || \"#4d4d4d\").trim() || \"#4d4d4d\";\n      borderColor = (cs.getPropertyValue(\"--border\") || \"#ebebeb\").trim() || \"#ebebeb\";\n    };\n    readColors();\n\n    // Per-slot dithered fill, rebuilt on resize (dpr change) and theme change.\n    let patterns: CanvasPattern[] = [];\n    const buildPatterns = () => {\n      const tileCss = 8;\n      const size = Math.max(1, Math.round(tileCss * dpr));\n      const next: CanvasPattern[] = [];\n      for (let s = 0; s < nSlots; s++) {\n        const density = nSlots > 1 ? 0.14 + (s / (nSlots - 1)) * 0.46 : 0.3;\n        const off = document.createElement(\"canvas\");\n        off.width = size;\n        off.height = size;\n        const octx = off.getContext(\"2d\");\n        if (!octx) continue;\n        for (let py = 0; py < size; py++) {\n          const by = Math.min(7, Math.floor((py / size) * 8));\n          for (let px = 0; px < size; px++) {\n            const bx = Math.min(7, Math.floor((px / size) * 8));\n            const thr = (BAYER8[by]![bx]! + 0.5) / 64;\n            octx.fillStyle = thr < density ? mutedColor : bgColor;\n            octx.fillRect(px, py, 1, 1);\n          }\n        }\n        const pat = ctx.createPattern(off, \"repeat\");\n        if (!pat) continue;\n        pat.setTransform(new DOMMatrix([1 / dpr, 0, 0, 1 / dpr, 0, 0]));\n        next[s] = pat;\n      }\n      patterns = next;\n    };\n\n    // -- lattice helpers ------------------------------------------------\n    const cellIndexAt = (x: number, y: number): number => {\n      const gx = Math.floor(x / CELL);\n      const gy = Math.floor(y / CELL);\n      if (gx < 0 || gy < 0 || gx >= cols || gy >= rows) return -1;\n      return gy * cols + gx;\n    };\n\n    const clearAround = (x: number, y: number, r: number): boolean => {\n      const gx = Math.floor(x / CELL);\n      const gy = Math.floor(y / CELL);\n      const rc = Math.ceil(r / CELL);\n      for (let j = -rc; j <= rc; j++) {\n        const gy2 = gy + j;\n        if (gy2 < 0 || gy2 >= rows) continue;\n        const row = gy2 * cols;\n        for (let i = -rc; i <= rc; i++) {\n          const gx2 = gx + i;\n          if (gx2 < 0 || gx2 >= cols) continue;\n          if (owner[row + gx2] !== 0) return false;\n        }\n      }\n      return true;\n    };\n\n    const markSeam = (ci: number) => {\n      seam[ci] = 1;\n      seamCells.add(ci);\n    };\n\n    const retireThallus = (t: Thallus) => {\n      activeById.delete(t.id);\n      slots[t.slot] = null;\n      slotCooldown[t.slot] = simTime + COOLDOWN_MIN + rng() * COOLDOWN_SPREAD;\n    };\n\n    const releaseCell = (ci: number) => {\n      const prevId = owner[ci];\n      if (prevId === 0) return;\n      const t = activeById.get(prevId);\n      if (t) {\n        t.cellCount--;\n        if (t.cellCount <= 0) retireThallus(t);\n      }\n      owner[ci] = 0;\n    };\n\n    const updateBBox = (t: Thallus, ci: number) => {\n      const gx = ci % cols;\n      const gy = (ci / cols) | 0;\n      if (gx < t.bboxMinC) t.bboxMinC = gx;\n      if (gx > t.bboxMaxC) t.bboxMaxC = gx;\n      if (gy < t.bboxMinR) t.bboxMinR = gy;\n      if (gy > t.bboxMaxR) t.bboxMaxR = gy;\n    };\n\n    const claimCell = (ci: number, t: Thallus, now: number) => {\n      const prevId = owner[ci];\n      if (prevId === t.id) return;\n      if (prevId !== 0) {\n        const rival = activeById.get(prevId);\n        if (rival) {\n          rival.cellCount--;\n          if (rival.cellCount <= 0) retireThallus(rival);\n        }\n        markSeam(ci);\n      }\n      owner[ci] = t.id;\n      claimedAt[ci] = now;\n      t.cellCount++;\n      updateBBox(t, ci);\n    };\n\n    // -- nucleation -------------------------------------------------------\n    const nucleate = (slot: number, now: number) => {\n      for (let attempt = 0; attempt < 10; attempt++) {\n        const x = rng() * cssW;\n        const y = rng() * cssH;\n        if (clearAround(x, y, NUCLEATION_CLEARANCE)) {\n          const id = idSeq++;\n          const rate = BASE_RATE * Math.pow(2, (rng() * 2 - 1) * varAmt);\n          const t: Thallus = {\n            id,\n            slot,\n            cx: x,\n            cy: y,\n            rate,\n            seed: rng() * 1000,\n            senLimit: T_SEN_BASE * (1 - T_SEN_SPREAD / 2 + rng() * T_SEN_SPREAD),\n            spokeR: new Float32Array(SPOKES),\n            bboxMinC: Infinity,\n            bboxMaxC: -Infinity,\n            bboxMinR: Infinity,\n            bboxMaxR: -Infinity,\n            cellCount: 0,\n          };\n          slots[slot] = t;\n          activeById.set(id, t);\n          return;\n        }\n      }\n      slotCooldown[slot] = now + 5; // no room yet; try again soon\n    };\n\n    const growThallus = (t: Thallus, dt: number) => {\n      for (let k = 0; k < SPOKES; k++) {\n        const theta = (k * TAU) / SPOKES;\n        const n = marginNoise(theta, simTime, t.seed);\n        const vel = t.rate * Math.max(0.15, 1 + NOISE_AMOUNT * n);\n        const candR = t.spokeR[k]! + vel * dt;\n        const cos = Math.cos(theta);\n        const sin = Math.sin(theta);\n        const ci = cellIndexAt(t.cx + cos * candR, t.cy + sin * candR);\n        if (ci < 0) continue; // spoke stalls at the pane's edge\n        const own = owner[ci];\n        if (own === 0 || own === t.id) {\n          claimCell(ci, t, simTime);\n          t.spokeR[k] = candR;\n          continue;\n        }\n        const rival = activeById.get(own);\n        if (!rival) {\n          claimCell(ci, t, simTime);\n          t.spokeR[k] = candR;\n          continue;\n        }\n        const ratio = t.rate / rival.rate;\n        if (ratio > CONTACT_RATIO) {\n          const advVel = (ratio - CONTACT_RATIO) * BASE_RATE;\n          const advR = t.spokeR[k]! + advVel * dt;\n          const ci2 = cellIndexAt(t.cx + cos * advR, t.cy + sin * advR);\n          if (ci2 >= 0) claimCell(ci2, t, simTime);\n          t.spokeR[k] = advR;\n        }\n        // else: rate ratio inside the dead band — this spoke stalls here.\n      }\n    };\n\n    const neighborDiffers = (gx: number, gy: number, ownBefore: number): boolean => {\n      const dirs: Array<[number, number]> = [\n        [1, 0],\n        [-1, 0],\n        [0, 1],\n        [0, -1],\n      ];\n      for (const [dx, dy] of dirs) {\n        const nx = gx + dx;\n        const ny = gy + dy;\n        if (nx < 0 || ny < 0 || nx >= cols || ny >= rows) continue;\n        const no = owner[ny * cols + nx];\n        if (no !== 0 && no !== ownBefore) return true;\n      }\n      return false;\n    };\n\n    const senescenceSweep = () => {\n      const living = Array.from(activeById.values());\n      for (const t of living) {\n        if (!activeById.has(t.id)) continue; // retired earlier this sweep\n        const c0 = Math.max(0, Math.floor(t.bboxMinC));\n        const c1 = Math.min(cols - 1, Math.floor(t.bboxMaxC));\n        const r0 = Math.max(0, Math.floor(t.bboxMinR));\n        const r1 = Math.min(rows - 1, Math.floor(t.bboxMaxR));\n        for (let gy = r0; gy <= r1; gy++) {\n          const row = gy * cols;\n          for (let gx = c0; gx <= c1; gx++) {\n            const ci = row + gx;\n            if (owner[ci] !== t.id) continue;\n            const age = simTime - claimedAt[ci]!;\n            if (age > t.senLimit) {\n              if (neighborDiffers(gx, gy, t.id)) markSeam(ci);\n              releaseCell(ci);\n            }\n          }\n        }\n      }\n    };\n\n    const decaySeams = (dt: number) => {\n      if (seamCells.size === 0) return;\n      const decay = Math.exp(-dt / SEAM_TAU);\n      for (const ci of seamCells) {\n        const v = seam[ci]! * decay;\n        seam[ci] = v;\n        if (v < 0.02) seamCells.delete(ci);\n      }\n    };\n\n    const stepSim = (dt: number) => {\n      if (dt <= 0) return;\n      simTime += dt;\n      for (let s = 0; s < nSlots; s++) {\n        if (!slots[s] && simTime >= slotCooldown[s]!) nucleate(s, simTime);\n      }\n      for (let s = 0; s < nSlots; s++) {\n        const t = slots[s];\n        if (t) growThallus(t, dt);\n      }\n      senescenceSweep();\n      decaySeams(dt);\n    };\n\n    const resetSim = () => {\n      cols = Math.max(1, Math.ceil(cssW / CELL));\n      rows = Math.max(1, Math.ceil(cssH / CELL));\n      owner = new Uint16Array(cols * rows);\n      claimedAt = new Float32Array(cols * rows);\n      seam = new Float32Array(cols * rows);\n      seamCells.clear();\n      simTime = 0;\n      idSeq = 1;\n      rngState = 0x9e3779b9 >>> 0;\n      activeById.clear();\n      for (let s = 0; s < nSlots; s++) {\n        slots[s] = null;\n        slotCooldown[s] = rng() * STAGGER_WINDOW;\n      }\n    };\n\n    const prewarm = () => {\n      const steps = Math.round(PREWARM_SECONDS / PREWARM_DT);\n      for (let i = 0; i < steps; i++) stepSim(PREWARM_DT);\n    };\n\n    // -- marching squares over the binary field for one species ----------\n    const marchSpecies = (t: Thallus, path: Path2D) => {\n      if (t.cellCount <= 0) return;\n      const i0 = Math.max(0, Math.floor(t.bboxMinC) - 1);\n      const i1 = Math.min(cols - 2, Math.floor(t.bboxMaxC));\n      const j0 = Math.max(0, Math.floor(t.bboxMinR) - 1);\n      const j1 = Math.min(rows - 2, Math.floor(t.bboxMaxR));\n      if (i1 < i0 || j1 < j0) return;\n      const sampleAt = (gx: number, gy: number): number => {\n        if (gx < 0 || gy < 0 || gx >= cols || gy >= rows) return 0;\n        return owner[gy * cols + gx] === t.id ? 1 : 0;\n      };\n      for (let j = j0; j <= j1; j++) {\n        for (let i = i0; i <= i1; i++) {\n          const tl = sampleAt(i, j);\n          const tr = sampleAt(i + 1, j);\n          const br = sampleAt(i + 1, j + 1);\n          const bl = sampleAt(i, j + 1);\n          if (tl === tr && tr === br && br === bl) continue;\n          const x0 = i * CELL;\n          const x1 = x0 + CELL;\n          const y0 = j * CELL;\n          const y1 = y0 + CELL;\n          const midX = (x0 + x1) / 2;\n          const midY = (y0 + y1) / 2;\n          const T: [number, number] = [midX, y0];\n          const R: [number, number] = [x1, midY];\n          const B: [number, number] = [midX, y1];\n          const L: [number, number] = [x0, midY];\n          const crossTop = tl !== tr;\n          const crossRight = tr !== br;\n          const crossBottom = bl !== br;\n          const crossLeft = tl !== bl;\n          const count =\n            (crossTop ? 1 : 0) + (crossRight ? 1 : 0) + (crossBottom ? 1 : 0) + (crossLeft ? 1 : 0);\n          if (count === 2) {\n            const pts: Array<[number, number]> = [];\n            if (crossTop) pts.push(T);\n            if (crossRight) pts.push(R);\n            if (crossBottom) pts.push(B);\n            if (crossLeft) pts.push(L);\n            path.moveTo(pts[0]![0], pts[0]![1]);\n            path.lineTo(pts[1]![0], pts[1]![1]);\n          } else if (count === 4) {\n            if (tl === 1) {\n              path.moveTo(T[0], T[1]);\n              path.lineTo(L[0], L[1]);\n              path.moveTo(B[0], B[1]);\n              path.lineTo(R[0], R[1]);\n            } else {\n              path.moveTo(T[0], T[1]);\n              path.lineTo(R[0], R[1]);\n              path.moveTo(B[0], B[1]);\n              path.lineTo(L[0], L[1]);\n            }\n          }\n        }\n      }\n    };\n\n    const fillThallus = (t: Thallus) => {\n      if (t.cellCount <= 0) return;\n      const pat = patterns[t.slot];\n      if (!pat) return;\n      const c0 = Math.max(0, Math.floor(t.bboxMinC));\n      const c1 = Math.min(cols - 1, Math.floor(t.bboxMaxC));\n      const r0 = Math.max(0, Math.floor(t.bboxMinR));\n      const r1 = Math.min(rows - 1, Math.floor(t.bboxMaxR));\n      const path = new Path2D();\n      let any = false;\n      for (let gy = r0; gy <= r1; gy++) {\n        const row = gy * cols;\n        for (let gx = c0; gx <= c1; gx++) {\n          if (owner[row + gx] === t.id) {\n            path.rect(gx * CELL, gy * CELL, CELL, CELL);\n            any = true;\n          }\n        }\n      }\n      if (!any) return;\n      ctx.fillStyle = pat;\n      ctx.fill(path);\n    };\n\n    const SEAM_BUCKET_T = [0.25, 0.5, 0.75, 1.01];\n    const SEAM_BUCKET_ALPHA = [0.12, 0.22, 0.32, 0.42];\n\n    const drawSeams = () => {\n      if (seamCells.size === 0) return;\n      const buckets = [new Path2D(), new Path2D(), new Path2D(), new Path2D()];\n      for (const ci of seamCells) {\n        const v = seam[ci]!;\n        if (v < 0.02) continue;\n        let b = 0;\n        while (b < 3 && v > SEAM_BUCKET_T[b]!) b++;\n        const gx = ci % cols;\n        const gy = (ci / cols) | 0;\n        buckets[b]!.rect(gx * CELL + 1, gy * CELL + 1, CELL - 2, CELL - 2);\n      }\n      ctx.lineWidth = 1;\n      ctx.strokeStyle = borderColor;\n      for (let b = 0; b < 4; b++) {\n        ctx.globalAlpha = SEAM_BUCKET_ALPHA[b]!;\n        ctx.stroke(buckets[b]!);\n      }\n      ctx.globalAlpha = 1;\n    };\n\n    const draw = () => {\n      if (!sized) return;\n      ctx.clearRect(0, 0, cssW, cssH);\n      for (const t of activeById.values()) fillThallus(t);\n      const boundary = new Path2D();\n      for (const t of activeById.values()) marchSpecies(t, boundary);\n      ctx.lineWidth = 1;\n      ctx.strokeStyle = borderColor;\n      ctx.globalAlpha = 0.55;\n      ctx.stroke(boundary);\n      ctx.globalAlpha = 1;\n      drawSeams();\n    };\n\n    // -- host plumbing ------------------------------------------------------\n    const applyBacking = () => {\n      dpr = Math.min(window.devicePixelRatio || 1, 2);\n      canvas.width = Math.max(1, Math.round(cssW * dpr));\n      canvas.height = Math.max(1, Math.round(cssH * dpr));\n      ctx.setTransform(dpr, 0, 0, dpr, 0, 0);\n    };\n\n    const rebuild = () => {\n      if (cssW < 2 || cssH < 2) {\n        sized = false;\n        return;\n      }\n      applyBacking();\n      buildPatterns();\n      resetSim();\n      prewarm();\n      sized = true;\n      draw();\n    };\n\n    let lastW = 0;\n    let lastH = 0;\n    const resize = () => {\n      const rect = wrap.getBoundingClientRect();\n      if (rect.width < 2 || rect.height < 2) return;\n      if (Math.abs(rect.width - lastW) < 2 && Math.abs(rect.height - lastH) < 2) return;\n      lastW = rect.width;\n      lastH = rect.height;\n      cssW = rect.width;\n      cssH = rect.height;\n      rebuild();\n    };\n\n    let raf = 0;\n    let running = false;\n    let lastMs = 0;\n    let lastDrawMs = 0;\n\n    const loop = (now: number) => {\n      const dt = lastMs ? Math.min(0.25, (now - lastMs) / 1000) : 1 / 60;\n      lastMs = now;\n      stepSim(dt * Math.max(0, speed));\n      if (now - lastDrawMs >= DRAW_INTERVAL_MS) {\n        draw();\n        lastDrawMs = now;\n      }\n      raf = requestAnimationFrame(loop);\n    };\n\n    const wake = () => {\n      if (running || disposed || !sized || staticMode) return;\n      running = true;\n      lastMs = 0;\n      raf = requestAnimationFrame(loop);\n    };\n    const sleep = () => {\n      cancelAnimationFrame(raf);\n      running = false;\n    };\n\n    const applyMode = () => {\n      staticMode = reduced || pausedRef.current;\n      if (staticMode) {\n        sleep();\n        draw();\n      } else {\n        wake();\n      }\n    };\n\n    const ro = new ResizeObserver(resize);\n    ro.observe(wrap);\n    resize();\n    applyMode();\n\n    let onScreen = true;\n    const io = new IntersectionObserver(\n      (entries) => {\n        onScreen = entries.some((en) => en.isIntersecting);\n        if (!onScreen) sleep();\n        else if (!staticMode && !document.hidden) wake();\n      },\n      { threshold: 0 }\n    );\n    io.observe(wrap);\n\n    const onVis = () => {\n      if (document.hidden) sleep();\n      else if (!staticMode && onScreen) wake();\n    };\n    document.addEventListener(\"visibilitychange\", onVis);\n\n    const onMq = () => {\n      reduced = mq.matches;\n      applyMode();\n    };\n    mq.addEventListener(\"change\", onMq);\n\n    const themeObserver = new MutationObserver(() => {\n      readColors();\n      if (sized) {\n        buildPatterns();\n        draw();\n      }\n    });\n    themeObserver.observe(document.documentElement, {\n      attributes: true,\n      attributeFilter: [\"class\"],\n    });\n\n    // paused is polled rather than an effect dependency, so toggling it does\n    // not tear down and re-prewarm 20 minutes of simulated history.\n    let lastPolledPaused = pausedRef.current;\n    let pollTimer = 0;\n    const poll = () => {\n      if (pausedRef.current !== lastPolledPaused) {\n        lastPolledPaused = pausedRef.current;\n        applyMode();\n      }\n      pollTimer = window.setTimeout(poll, 160);\n    };\n    poll();\n\n    return () => {\n      disposed = true;\n      ro.disconnect();\n      io.disconnect();\n      mq.removeEventListener(\"change\", onMq);\n      document.removeEventListener(\"visibilitychange\", onVis);\n      themeObserver.disconnect();\n      window.clearTimeout(pollTimer);\n      sleep();\n    };\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [count, variance, speed]);\n\n  return (\n    <div\n      ref={wrapRef}\n      className={`relative isolate h-full w-full overflow-hidden bg-background ${className}`}\n      style={style}\n    >\n      <canvas ref={canvasRef} aria-hidden=\"true\" className=\"absolute inset-0 block\" />\n      {children ? <div className=\"relative z-[1] h-full w-full\">{children}</div> : null}\n    </div>\n  );\n}\n\nThallusSiege.displayName = \"ThallusSiege\";\n",
      "type": "registry:ui",
      "target": "components/ui/thallus-siege.tsx"
    }
  ],
  "cssVars": {
    "theme": {
      "color-ns-muted": "var(--ns-muted)",
      "color-ns-accent": "var(--ns-accent)"
    },
    "light": {
      "ns-muted": "#4d4d4d",
      "ns-accent": "#006bff"
    },
    "dark": {
      "ns-muted": "#8f8f8f"
    }
  },
  "meta": {
    "collection": "loud",
    "tags": [
      "background",
      "hero",
      "canvas",
      "simulation",
      "organic",
      "monochrome",
      "dither",
      "decorative",
      "ambient"
    ],
    "instruction": "Build <ThallusSiege count? variance? speed? paused? children? className? style?> as a full-bleed 2D canvas pane of crustose lichen fighting a slow, never-finished territorial war. TERRITORY IS GROWN, NOT PARTITIONED — the mechanism that must never regress into a Voronoi or distance-field lookup. A dozen (count, default 12) thalli occupy independent slots and nucleate at staggered simulated times on bare substrate. Each grows a front of ~180 radial spokes; every spoke's radius advances at that thallus's own inherited rate scaled by 1 + 0.3*marginNoise(theta, simTime), a slow multi-term sine perturbation in angle and time that gives the margin lobes instead of a computed circle. A spoke's target cell is looked up in a shared occupancy lattice (coarse: 6px cells, roughly a quarter the density anything on screen could resolve) storing an owner id and a claim time. Empty or self-owned cells are simply claimed. A cell owned by a DIFFERENT thallus stalls the spoke UNLESS this thallus's rate exceeds the rival's by more than 1.3x (CONTACT_RATIO), in which case the spoke keeps advancing at (ratio - 1.3) * baseRate — real, slow reconquest of the rival's ground, not a redraw of a boundary. The 1.3x dead-band exists so the mosaic can visually settle between reconquests instead of every border crawling forever. GOVERNING SCALAR: growth-rate variance, exposed as the `variance` prop (default 1) and sampled per thallus as baseRate * 2^((rng()*2-1)*variance). At variance = 0 every thallus grows at an identical pace, the 1.3x band is never crossed, no front ever reconquers another, and the mosaic settles into flat, static, roughly-equidistant cells — a fair near-Voronoi partition in every respect except how it was computed. That is the literal failure state this piece exists to avoid, which is why the default carries a real spread (rates commonly span roughly 0.5x-2x of each other). SENESCENCE falls out of the same lattice for free: a cell's claim time is written once and never refreshed, so the cells nearest a thallus's own nucleation point are always its oldest. Each thallus carries a senescence limit (~13 simulated minutes, +/-40% per individual); any of its cells whose age exceeds that limit clears back to substrate, which happens centre-first and spreads outward automatically as more time passes, with no separate shrink pass required. A cleared cell is bare rock again, and the neighbour whose front already stalled against it resumes growing into it the very next step — reclaiming ground rather than a new partition being recomputed. When a thallus's last cell clears, its slot goes silent for a staggered cooldown (40-180 simulated seconds) and then nucleates again elsewhere with a freshly sampled rate, so the war only ever pauses between shifts. RETIRED SEAMS: a cell that reverts to substrate while a differently-owned neighbour is still alive stamps a separate, slowly decaying (per-cell, ~220s time constant) seam field; wherever that field is still non-trivial the render adds a faint inset hairline stroke of --border, so the pane keeps a visible memory of where a border used to run after both sides that drew it are gone. PREWARM: at mount (and on every resize) the simulation is fast-forwarded 1200 simulated seconds (20 minutes) at 2s steps before the first paint, so the first frame is already a developed mosaic with at least one senescence-and-reclaim cycle baked in, not an empty pane proving its concept via autoplay. RENDER: the lattice's blocky per-cell ownership is smoothed into an organic contour with a genuine marching-squares pass — a binary occupied/not field per species, one segment for a normal 2-edge-crossing square, the diagonal-consistent pair of segments for the 4-crossing saddle case — stroked in --border at low alpha; no blur or upscale trick stands in for it. Fill is per-species ordered (8x8 Bayer) dithering between --background and --ns-muted, with 12 fixed dither densities assigned one per lattice slot (not per generation), so a slot keeps a recognizable texture across however many thalli nucleate into it over the piece's lifetime. Colors are read via getComputedStyle on mount and re-read on a documentElement class MutationObserver so both themes render correctly; --ns-accent never appears anywhere in the simulation or render, since nothing here is ever being interacted with. HOST: ResizeObserver rebuilds the lattice and reruns the full prewarm on a real size change; IntersectionObserver and visibilitychange stop the single rAF loop off-screen; redraws are throttled to roughly 2.6/s since the growth itself is glacial and a 60fps redraw of an unchanging frame would be pure waste; paused is polled rather than an effect dependency so toggling it does not discard 20 minutes of simulated history. prefers-reduced-motion (and paused) skip the loop entirely and paint the prewarmed mosaic once — the component's best single frame is the resting state, not a fallback. The canvas is aria-hidden and fully non-interactive (no autoplay descriptor: the stillness is the point, motion here rewards a second visit rather than demonstrating itself on the first); children render over the pane behind a bg-background/75 + backdrop-blur scrim."
  },
  "type": "registry:ui"
}