{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "granule-churn",
  "title": "Granule Churn",
  "description": "A full-bleed stellar photosphere: bright convection cells packed edge to edge, born, swelling against their neighbours, splitting and dying, with the cooled material draining back down the dark intergranular lanes between them and bright flecks flaring in the strongest downdrafts.",
  "dependencies": [],
  "files": [
    {
      "path": "registry/loud/granule-churn/component.tsx",
      "content": "\"use client\";\n\nimport { useEffect, useRef } from \"react\";\n\n// ---------------------------------------------------------------------------\n// GranuleChurn — a full-bleed stellar photosphere. Solar granulation: bright\n// upwelling convection cells packed edge to edge, separated by the dark\n// intergranular lanes the cooled material drains back down through.\n//\n// The mechanism is a small compressible surface flow, not a cell diagram.\n// A potential field S(x,y,t) is built from a lattice of gaussian plumes, each\n// born at a jittered position, swelling over its own lifetime and fading out\n// again. Horizontal velocity is u = -k grad(S), so material runs DOWNHILL away\n// from every plume's summit and piles up in the troughs where three or four\n// plumes' skirts meet. Nothing anywhere computes a nearest-site distance: the\n// lanes are not the edges of a partition, they are where a real advected\n// density has been squeezed together, and they inherit a fluid's memory —\n// varying width, broken segments, a lag behind the plumes that pushed them.\n//\n// Four channels ride one RGBA16F ping-pong target and are advected by that\n// same velocity in a single semi-Lagrangian pass:\n//   T  temperature — injected where the flow diverges (a granule's summit),\n//      cooling radiatively as it travels out, so a cell is hot in the middle\n//      and dim by the time it reaches its own boundary\n//   c  material density — multiplied by exp(-div u dt), the continuity term,\n//      which is what actually darkens a lane rather than a painted stroke\n//   f  faculae — bright flecks seeded stochastically in the strongest\n//      downdrafts and carried along the lane they were born in\n//   a  pointer freshness — lifts luminance under the cursor. Pure brightness,\n//      no hue: --ns-accent is reserved for interaction chrome elsewhere in the\n//      registry, and a resting frame (which is what every screenshot is) has\n//      no pointer \"interacting\" with it, so no accent belongs in this pass\n//\n// Birth and death are structural. Each lattice site runs its own period; when\n// a plume's envelope reaches zero its successor is born at a new jittered\n// position with a new radius, so its neighbours immediately expand into the\n// space and the lane network re-knits. Sites drawn above a threshold develop\n// an \"exploding granule\" late in life — a negative dimple opening at the\n// summit that pushes a new lane straight through the middle of the cell and\n// splits it in two, which is exactly how the real ones die.\n//\n// Palette: five stops from --background, --foreground, --ns-muted and --border\n// via getComputedStyle, re-read on a documentElement class MutationObserver.\n// The ramp INVERTS between themes rather than shifting its distribution: dark\n// theme is a luminous star, light theme its deliberate negative — graphite\n// granules on a paper field, tuned so the frame stays mid-toned instead of\n// going to a black page. No colour literal appears anywhere, shaders included.\n// ---------------------------------------------------------------------------\n\nexport interface GranuleChurnProps {\n  /** Granule count: cells across the container's short side. @default 1 */\n  density?: number;\n  /** Convection speed multiplier. @default 1 */\n  speed?: number;\n  /** Faculae (bright lane flecks) amount, 0 disables them. @default 1 */\n  faculae?: number;\n  /** Freezes the surface on a fully developed still frame without unmounting. */\n  paused?: boolean;\n  /** Rendered in the DOM over the surface — eyebrow, headline, CTA. */\n  children?: React.ReactNode;\n  className?: string;\n  style?: React.CSSProperties;\n}\n\nconst VERT_SRC = `#version 300 es\nin vec2 a_pos;\nout vec2 v_uv;\nvoid main() {\n  v_uv = a_pos * 0.5 + 0.5;\n  gl_Position = vec4(a_pos, 0.0, 1.0);\n}`;\n\n// Shared by both passes. Coordinates are in LATTICE CELLS, isotropic in screen\n// space: q = vec2(uv.x * aspect, uv.y) * cells. Working in uv instead would\n// make every gaussian an ellipse that restretches with the window, and would\n// make the divergence operator anisotropic — which on a compressible flow is\n// not a cosmetic error, it is lanes that are systematically wider in one axis.\nconst COMMON = `\nprecision highp float;\n\nuniform float u_aspect;\nuniform float u_cells;\nuniform float u_time;\n\nvec3 hash32(vec2 p) {\n  vec3 p3 = fract(vec3(p.xyx) * vec3(0.1031, 0.1030, 0.0973));\n  p3 += dot(p3, p3.yxz + 33.33);\n  return fract((p3.xxy + p3.yzz) * p3.zyx);\n}\n\nvec4 hash42(vec2 p) {\n  vec4 p4 = fract(vec4(p.xyxy) * vec4(0.1031, 0.1030, 0.0973, 0.1099));\n  p4 += dot(p4, p4.wzxy + 33.33);\n  return fract((p4.xxyz + p4.yzzw) * p4.zywx);\n}\n\nfloat vnoise(vec2 p) {\n  vec2 i = floor(p);\n  vec2 f = fract(p);\n  vec2 u = f * f * (3.0 - 2.0 * f);\n  float a = hash32(i).x;\n  float b = hash32(i + vec2(1.0, 0.0)).x;\n  float c = hash32(i + vec2(0.0, 1.0)).x;\n  float d = hash32(i + vec2(1.0, 1.0)).x;\n  return mix(mix(a, b, u.x), mix(c, d, u.x), u.y);\n}\n\nvec2 cellCoord(vec2 uv) {\n  return vec2(uv.x * u_aspect, uv.y) * u_cells;\n}\n`;\n\n// The plume lattice. One site per lattice cell, three by three neighbours\n// gathered — with a jitter of 0.36 cells and a radius under half a cell, a\n// contribution from further out is below 1e-5 and invisible.\n//\n// Returns (S, dS/dx, dS/dy, laplacian S). Having the laplacian analytically is\n// what makes the whole thing cheap: divergence of u = -k grad(S) is exactly\n// -k lap(S), so the compression that builds the lanes needs no extra taps.\n//\n// A supergranular warp is applied to the lookup coordinate before the gather.\n// Without it the lattice reads as a lattice within about two seconds of\n// looking — jitter alone leaves every cell centre inside its own square, and\n// the eye finds that grid immediately on a full-bleed field. The warp is slow\n// and shallow enough that its Jacobian stays close to identity, so the\n// gradient taken in warped coordinates is still very nearly the true one.\nconst PLUMES = `\nuniform float u_split;\n\nvec2 warp(vec2 q) {\n  return q + 0.62 * vec2(\n    vnoise(q * 0.115 + vec2(0.0, u_time * 0.012)) - 0.5,\n    vnoise(q * 0.115 + vec2(9.71, -u_time * 0.009) + 4.3) - 0.5\n  ) + 0.26 * vec2(\n    vnoise(q * 0.31 + vec2(u_time * 0.02, 2.1)) - 0.5,\n    vnoise(q * 0.31 + vec2(-1.7, u_time * 0.017) + 8.9) - 0.5\n  )\n  // a third octave at sub-cell scale. The two above move whole groups of\n  // granules around; this one is what stops each individual cell from being a\n  // circle. Real granules are ragged polygons, and the difference between\n  // \"photosphere\" and \"soap foam\" is almost entirely in the boundary: a\n  // perfectly round cell reads as a bubble no matter what its interior does.\n  // Amplitude is held under a tenth of a cell so the warp's Jacobian stays\n  // near identity and the gradient taken in warped coordinates is still the\n  // true one.\n  + 0.085 * vec2(\n    vnoise(q * 0.95 + vec2(u_time * 0.03, 5.3)) - 0.5,\n    vnoise(q * 0.95 + vec2(3.9, -u_time * 0.026) + 1.7) - 0.5\n  );\n}\n\nvec4 plumes(vec2 q) {\n  vec2 base = floor(q);\n  vec4 acc = vec4(0.0);\n  for (int j = -1; j <= 1; j++) {\n    for (int i = -1; i <= 1; i++) {\n      vec2 id = base + vec2(float(i), float(j));\n      vec4 h = hash42(id);\n      // every site keeps its own period and phase, so births are decorrelated\n      // across the field: a shared clock makes the entire surface pulse\n      float per = mix(8.5, 16.5, h.x);\n      float u = u_time / per + h.y * 11.0;\n      float gen = floor(u);\n      float life = u - gen;\n      // a new draw per generation — position, size and fate all change when a\n      // site's plume is replaced, which is what makes neighbours jostle\n      vec3 g = hash32(id * 1.37 + vec2(gen * 19.73, gen * 7.31) + 3.1);\n      vec2 c = id + 0.5 + (g.xy - 0.5) * 0.72;\n      float rad = mix(0.30, 0.47, g.z) * (0.78 + 0.52 * life);\n      float amp = pow(sin(3.14159265 * life), 0.7);\n      vec2 d = q - c;\n      float ir2 = 1.0 / (rad * rad);\n      float r2 = dot(d, d) * ir2;\n      float e = exp(-r2) * amp;\n      acc.x += e;\n      acc.yz += e * (-2.0 * d * ir2);\n      acc.w += e * (4.0 * r2 * ir2 - 4.0 * ir2);\n\n      // exploding granule: a dimple opens at the summit late in life, so a\n      // lane is driven through the middle of the cell and it splits rather\n      // than simply fading. Roughly a third of sites are drawn for it.\n      //\n      // The dimple has to be BROAD, not deep. At rad*0.44 it was narrower than\n      // the sim texel scale, so instead of splitting the cell it punched a\n      // single-texel negative spike that clamped T to zero — a hard black\n      // pinprick sitting in the middle of a bright granule, which read as a\n      // rendering defect rather than as a pore. At 0.66 of the parent radius\n      // the dimple is wide enough that the flow actually reverses across a\n      // band, which is what drives a lane through and cleaves the cell in two.\n      float fate = step(0.66, h.z) * u_split;\n      float amp2 = fate * amp * 0.46 * smoothstep(0.48, 0.92, life);\n      float rad2 = rad * 0.66;\n      float ir2b = 1.0 / (rad2 * rad2);\n      float r2b = dot(d, d) * ir2b;\n      float eb = exp(-r2b) * amp2;\n      acc.x -= eb;\n      acc.yz -= eb * (-2.0 * d * ir2b);\n      acc.w -= eb * (4.0 * r2b * ir2b - 4.0 * ir2b);\n    }\n  }\n  return acc;\n}\n`;\n\n// One pass per step: velocity is evaluated analytically at the destination\n// pixel, all four channels ride one backtrace, and every source term lands in\n// the same write. There is no pressure solve because there is nothing to\n// project — a photosphere's surface flow is COMPRESSIBLE by construction, and\n// the divergence is not an error to be removed, it is the entire signal.\nconst SIM_SRC = `#version 300 es\n${COMMON}\n${PLUMES}\nin vec2 v_uv;\nout vec4 fragColor;\n\nuniform sampler2D u_field;\nuniform vec2 u_texel;\nuniform vec2 u_grid;\nuniform float u_dt;\nuniform float u_flow;\nuniform float u_fleck;\nuniform vec3 u_ptr;      // xy uv, z strength\nuniform float u_ptrAcc;\n\nvoid main() {\n  vec2 q = warp(cellCoord(v_uv));\n  vec4 P = plumes(q);\n\n  // the pointer is an extra upwelling: it pushes the lanes apart and floats\n  // fresh hot material up under the cursor, the same way every other plume\n  // does, so the interaction is the mechanism rather than a decal on it\n  if (u_ptr.z > 0.0) {\n    vec2 pc = cellCoord(u_ptr.xy);\n    vec2 d = q - pc;\n    float rad = 1.35;\n    float ir2 = 1.0 / (rad * rad);\n    float r2 = dot(d, d) * ir2;\n    float e = exp(-r2) * u_ptr.z * 1.5;\n    P.x += e;\n    P.yz += e * (-2.0 * d * ir2);\n    P.w += e * (4.0 * r2 * ir2 - 4.0 * ir2);\n  }\n\n  vec2 vel = -u_flow * P.yz;      // cells/s\n  float div = -u_flow * P.w;      // 1/s\n\n  // soft walls: without them the outward half of every edge plume drags the\n  // clamped border texel inward across a tenth of the frame\n  vec2 edge = smoothstep(vec2(0.0), vec2(0.035, 0.035 * u_aspect), min(v_uv, 1.0 - v_uv));\n  vel *= min(edge.x, edge.y) * 0.85 + 0.15;\n\n  vec2 duv = vel * u_dt / u_cells * vec2(1.0 / u_aspect, 1.0);\n  vec4 s = texture(u_field, v_uv - duv);\n\n  float T = s.x;\n  float c = s.y;\n  float f = s.z;\n  float a = s.w;\n\n  // continuity. This single line is what makes a lane: converging flow\n  // multiplies the density it carries, diverging flow thins it out, and the\n  // result is a network whose brightness records how hard the surface has been\n  // squeezing there rather than where a boundary was declared to be.\n  c *= exp(-div * u_dt * 0.052);\n  c += (1.0 - c) * (1.0 - exp(-u_dt / 22.0));\n  c = clamp(c, 0.12, 4.0);\n\n  // radiative cooling on the way out from the summit is what gives a granule\n  // its interior falloff, and the rate has to be tuned against the crossing\n  // time rather than to taste: cool too slowly and every cell is one flat\n  // plateau of the same value, which is foam, not a photosphere. At ~0.7s\n  // against a ~0.5s centre-to-lane crossing, material arrives at its own\n  // boundary noticeably dimmer than it left the summit.\n  T += max(div, 0.0) * u_dt * 0.115;\n  T -= T * u_dt * 1.45;\n  T = clamp(T, 0.0, 3.0);\n\n  // faculae: sparse, seeded on a 6Hz tick so the rate is independent of frame\n  // rate, gated on both a piled-up lane and a live downdraft, then advected\n  // with the lane that made them\n  float lane = smoothstep(1.30, 2.10, c);\n  float conv = smoothstep(0.30, 1.40, max(-div, 0.0));\n  vec3 hs = hash32(floor(v_uv * u_grid * 0.5) + vec2(floor(u_time * 6.0) * 31.7));\n  // the threshold is the seeding rate, and it was set so far out that a\n  // 640x400 field produced under one live fleck at a time — invisible in the\n  // dark theme, which is exactly where a bright point on a black lane should\n  // be the most rewarding thing in the frame\n  float spark = step(0.9991, hs.x) * lane * conv * u_fleck;\n  f = max(f - f * u_dt * 2.3, spark * (0.55 + 0.75 * hs.y));\n\n  a = max(a - a * u_dt * 0.9, u_ptrAcc * exp(-dot(cellCoord(v_uv) - cellCoord(u_ptr.xy), cellCoord(v_uv) - cellCoord(u_ptr.xy)) / 1.4));\n\n  fragColor = vec4(T, c, f, a);\n}`;\n\nconst RENDER_SRC = `#version 300 es\n${COMMON}\nin vec2 v_uv;\nout vec4 fragColor;\n\nuniform sampler2D u_field;\nuniform vec2 u_texel;\nuniform vec2 u_res;\nuniform vec3 u_c0;\nuniform vec3 u_c1;\nuniform vec3 u_c2;\nuniform vec3 u_c3;\nuniform vec3 u_c4;\nuniform float u_bias;\nuniform float u_contrast;\nuniform float u_mottle;\n\nvec3 ramp(float x) {\n  vec3 c = mix(u_c0, u_c1, smoothstep(0.0, 0.26, x));\n  c = mix(c, u_c2, smoothstep(0.24, 0.52, x));\n  c = mix(c, u_c3, smoothstep(0.50, 0.80, x));\n  c = mix(c, u_c4, smoothstep(0.78, 1.0, x));\n  return c;\n}\n\nvoid main() {\n  vec4 s = texture(u_field, v_uv);\n  float T = s.x;\n  float c = s.y;\n  float f = s.z;\n  float a = s.w;\n\n  float lane = max(c - 1.0, 0.0);\n  float open = max(1.0 - c, 0.0);\n\n  // The four neighbours, fetched once and used twice — the lane seam below\n  // needs grad(c), and T needs an unsharp.\n  //\n  // The unsharp is not a cosmetic sharpen. Semi-Lagrangian advection is\n  // unconditionally stable precisely because it interpolates, and every step\n  // pays for that with a bilinear fetch that low-passes the field; run it at\n  // 60Hz and the surface is being blurred sixty times a second. The sim is\n  // then magnified roughly four times onto the backing store, so what reaches\n  // the eye is a soft field with no edge anywhere and the whole photosphere\n  // reads out of focus. Subtracting the discrete laplacian puts back the\n  // frequency band that dissipation just removed, at the cost of nothing —\n  // these taps were already paid for.\n  vec4 sl = texture(u_field, v_uv - vec2(u_texel.x, 0.0));\n  vec4 sr = texture(u_field, v_uv + vec2(u_texel.x, 0.0));\n  vec4 sd = texture(u_field, v_uv - vec2(0.0, u_texel.y));\n  vec4 su = texture(u_field, v_uv + vec2(0.0, u_texel.y));\n  float Tlap = (sl.x + sr.x + sd.x + su.x) * 0.25 - T;\n  T = max(T - Tlap * 0.85, 0.0);\n\n  // T goes through a saturating exposure rather than straight into the ramp.\n  // Linear, the summit of every cell clips to the top stop within a second of\n  // spin-up and the whole frame becomes white blobs in black seams — soap\n  // foam, which is the exact failure this component has to avoid.\n  //\n  // Saturating alone was not enough: at an exposure of 2.1 the shoulder was\n  // reached by the time T was ~1, so most of a granule's area still landed on\n  // the top stop and the interiors were flat plateaus with all the variation\n  // crushed out of them. A star does not look like that — granule-to-lane\n  // contrast on the real photosphere is a fraction of full range, and every\n  // cue that makes the surface worth staring at lives INSIDE that fraction.\n  // Exposure 1.15 against a gain of 0.55 puts a typical summit near 0.75 of\n  // the ramp rather than pinned at 1.0, which leaves the top quarter for the\n  // hottest few percent of the field and gives the mottle and the cooling\n  // falloff somewhere to be seen.\n  float hot = 1.0 - exp(-T * 1.15);\n\n  float L = 0.205;\n  L += hot * 0.60;\n  L -= smoothstep(0.03, 1.15, lane) * 0.32;\n  L += open * 0.07;\n\n  // the lane's own gradient, added as a thin seam. A density field advected\n  // semi-Lagrangian loses its highest frequencies every step; putting a little\n  // of the gradient back is the difference between a lane with an edge and a\n  // soft grey smear where one used to be.\n  float grad = length(vec2(sr.y - sl.y, su.y - sd.y));\n  // gated to the lane side. Unclamped it also fires on the granule side of\n  // every boundary, which laid a dark ring just inside each cell and embossed\n  // the whole field — every granule beaded up as a separate object instead of\n  // packing against its neighbours.\n  L -= min(grad * 3.4, 0.24) * 0.5 * smoothstep(0.85, 1.15, c);\n\n  // micro-mottle, confined to the hot interiors — a granule is not a smooth\n  // plateau. Its lookup is displaced by the field itself, which costs nothing\n  // and makes the texture travel with the material instead of sitting still\n  // behind a moving surface.\n  //\n  // It carries most of the load now that the exposure no longer clips: with a\n  // flat plateau there was nothing for it to modulate, and with headroom above\n  // the summit it is the only thing between a granule and a painted disc.\n  // Three octaves, and gated to open early (T = 0.04) so it reaches the cooler\n  // outer thirds of a cell rather than only the summit.\n  //\n  // The displacement is small and CENTRED, and both of those were learned the\n  // hard way. At vec2(c, T) * 2.3 the offset ran to several noise periods, so\n  // the lookup did not travel with the material, it sheared with it: every\n  // granule grew a bright cap on its upflow side and a speckled tail opposite,\n  // and forty of those in a frame read as a field of mushrooms. Subtracting\n  // c's rest value of 1 also matters — uncentred, the whole field carries a\n  // constant offset and the term stops being a perturbation at all.\n  vec2 mp = v_uv * vec2(u_aspect, 1.0) * u_cells * 6.5 + vec2(c - 1.0, T) * 0.85;\n  float m = vnoise(mp) * 0.55 + vnoise(mp * 2.3 + 7.1) * 0.28 + vnoise(mp * 4.7 + 2.9) * 0.14;\n  L += (m - 0.46) * u_mottle * smoothstep(0.04, 0.42, T);\n\n  L += f * 0.62;\n\n  // pointer freshness lifts luminance under the cursor, same ramp as\n  // everything else — brighter or dimmer depending where L already sits,\n  // never a hue laid on top. Folded in before the ramp so it also picks up\n  // contrast/bias like every other term, rather than being a flat overlay.\n  L += clamp(a, 0.0, 1.0) * 0.22;\n\n  // a full-bleed surface should reach the edge of the frame. The vignette is\n  // here only to stop the clamped border texels from reading as a hard cut,\n  // so it starts late and lands shallow — at 0.09 it put visible black bands\n  // across the top and bottom of a 16:10 frame and undid the full bleed.\n  vec2 vp = v_uv - 0.5;\n  float vig = smoothstep(0.62, 1.18, length(vp * vec2(u_aspect / max(u_aspect, 1.0), 1.0)) * 1.3);\n  L -= vig * 0.045;\n\n  L = clamp((L - 0.5) * u_contrast + 0.5 + u_bias, 0.0, 1.0);\n  vec3 col = ramp(L);\n\n  // the low end of a full-bleed ramp is a very long flat gradient, which is\n  // exactly where 8-bit banding shows\n  float dith = (hash32(gl_FragCoord.xy).x - 0.5) / 255.0;\n  fragColor = vec4(col + dith, 1.0);\n}`;\n\ntype RGB = [number, number, number];\ntype FBO = { tex: WebGLTexture; fbo: WebGLFramebuffer; w: number; h: number };\ntype Double = { read: FBO; write: FBO; swap: () => void };\n\nfunction parseColor(raw: string): RGB | null {\n  const v = raw.trim();\n  if (!v) return null;\n  const hex = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(v);\n  if (hex) {\n    let h = hex[1];\n    if (h.length === 3) h = h.split(\"\").map((ch) => ch + ch).join(\"\");\n    const n = parseInt(h, 16);\n    return [((n >> 16) & 255) / 255, ((n >> 8) & 255) / 255, (n & 255) / 255];\n  }\n  const fn = /^rgba?\\(([^)]+)\\)$/i.exec(v);\n  if (fn) {\n    const parts = fn[1].split(/[\\s,/]+/).filter(Boolean).map(parseFloat);\n    if (parts.length >= 3) return [parts[0] / 255, parts[1] / 255, parts[2] / 255];\n  }\n  return null;\n}\n\nfunction mixRGB(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 luminance([r, g, b]: RGB): number {\n  return 0.2126 * r + 0.7152 * g + 0.0722 * b;\n}\n\n// ---------------------------------------------------------------------------\n// Surface — the GL host: context, programs, ping-pong targets, one fullscreen\n// triangle. It knows nothing about convection, so a second preset could mount\n// it with different shader sources.\n// ---------------------------------------------------------------------------\nclass Surface {\n  gl: WebGL2RenderingContext | null = null;\n  private vao: WebGLVertexArrayObject | null = null;\n  private buffer: WebGLBuffer | null = null;\n  private programs: WebGLProgram[] = [];\n  private locs = new WeakMap<WebGLProgram, Map<string, WebGLUniformLocation | null>>();\n  private fbos: FBO[] = [];\n  private active: WebGLProgram | null = null;\n\n  constructor(private canvas: HTMLCanvasElement) {}\n\n  init(): boolean {\n    const gl = this.canvas.getContext(\"webgl2\", {\n      alpha: false,\n      antialias: false,\n      depth: false,\n      stencil: false,\n      premultipliedAlpha: false,\n      preserveDrawingBuffer: false,\n      powerPreference: \"high-performance\",\n    });\n    if (!gl) return false;\n    this.gl = gl;\n    // RGBA16F is only colour-renderable behind one of these. Without it\n    // nothing renders and the caller's children sit on --background.\n    const ext =\n      gl.getExtension(\"EXT_color_buffer_float\") ??\n      gl.getExtension(\"EXT_color_buffer_half_float\");\n    if (!ext) return false;\n    gl.getExtension(\"OES_texture_float_linear\");\n\n    this.buffer = gl.createBuffer();\n    this.vao = gl.createVertexArray();\n    gl.bindVertexArray(this.vao);\n    gl.bindBuffer(gl.ARRAY_BUFFER, this.buffer);\n    gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 3, -1, -1, 3]), gl.STATIC_DRAW);\n    gl.enableVertexAttribArray(0);\n    gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0);\n    gl.disable(gl.BLEND);\n    gl.disable(gl.DEPTH_TEST);\n    return true;\n  }\n\n  program(frag: string): WebGLProgram | null {\n    const gl = this.gl;\n    if (!gl) return null;\n    const vs = this.compile(gl.VERTEX_SHADER, VERT_SRC);\n    const fs = this.compile(gl.FRAGMENT_SHADER, frag);\n    if (!vs || !fs) return null;\n    const p = gl.createProgram();\n    if (!p) return null;\n    gl.attachShader(p, vs);\n    gl.attachShader(p, fs);\n    gl.bindAttribLocation(p, 0, \"a_pos\");\n    gl.linkProgram(p);\n    gl.deleteShader(vs);\n    gl.deleteShader(fs);\n    if (!gl.getProgramParameter(p, gl.LINK_STATUS)) {\n      gl.deleteProgram(p);\n      return null;\n    }\n    this.programs.push(p);\n    this.locs.set(p, new Map());\n    return p;\n  }\n\n  private compile(type: number, src: string): WebGLShader | null {\n    const gl = this.gl!;\n    const s = gl.createShader(type);\n    if (!s) return null;\n    gl.shaderSource(s, src);\n    gl.compileShader(s);\n    if (!gl.getShaderParameter(s, gl.COMPILE_STATUS)) {\n      gl.deleteShader(s);\n      return null;\n    }\n    return s;\n  }\n\n  use(p: WebGLProgram) {\n    this.gl?.useProgram(p);\n    this.active = p;\n  }\n\n  private loc(name: string): WebGLUniformLocation | null {\n    const p = this.active;\n    if (!p || !this.gl) return null;\n    const map = this.locs.get(p)!;\n    if (!map.has(name)) map.set(name, this.gl.getUniformLocation(p, name));\n    return map.get(name) ?? null;\n  }\n\n  f(n: string, x: number) {\n    this.gl?.uniform1f(this.loc(n), x);\n  }\n  v2(n: string, x: number, y: number) {\n    this.gl?.uniform2f(this.loc(n), x, y);\n  }\n  v3(n: string, c: RGB) {\n    this.gl?.uniform3f(this.loc(n), c[0], c[1], c[2]);\n  }\n  v3f(n: string, x: number, y: number, z: number) {\n    this.gl?.uniform3f(this.loc(n), x, y, z);\n  }\n  tex(n: string, unit: number, t: WebGLTexture) {\n    const gl = this.gl;\n    if (!gl) return;\n    gl.activeTexture(gl.TEXTURE0 + unit);\n    gl.bindTexture(gl.TEXTURE_2D, t);\n    gl.uniform1i(this.loc(n), unit);\n  }\n\n  makeFBO(w: number, h: number): FBO | null {\n    const gl = this.gl!;\n    const tex = gl.createTexture();\n    if (!tex) return null;\n    gl.activeTexture(gl.TEXTURE0);\n    gl.bindTexture(gl.TEXTURE_2D, tex);\n    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);\n    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);\n    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);\n    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);\n    gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA16F, w, h, 0, gl.RGBA, gl.HALF_FLOAT, null);\n    const fbo = gl.createFramebuffer();\n    if (!fbo) {\n      gl.deleteTexture(tex);\n      return null;\n    }\n    gl.bindFramebuffer(gl.FRAMEBUFFER, fbo);\n    gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, tex, 0);\n    if (gl.checkFramebufferStatus(gl.FRAMEBUFFER) !== gl.FRAMEBUFFER_COMPLETE) {\n      gl.deleteTexture(tex);\n      gl.deleteFramebuffer(fbo);\n      gl.bindFramebuffer(gl.FRAMEBUFFER, null);\n      return null;\n    }\n    // the field's rest state is T = 0, c = 1: a surface with nothing moving on\n    // it yet and its material spread evenly\n    gl.clearColor(0, 1, 0, 0);\n    gl.clear(gl.COLOR_BUFFER_BIT);\n    gl.bindFramebuffer(gl.FRAMEBUFFER, null);\n    const f: FBO = { tex, fbo, w, h };\n    this.fbos.push(f);\n    return f;\n  }\n\n  makeDouble(w: number, h: number): Double | null {\n    const a = this.makeFBO(w, h);\n    const b = this.makeFBO(w, h);\n    if (!a || !b) return null;\n    const d: Double = {\n      read: a,\n      write: b,\n      swap: () => {\n        const t = d.read;\n        d.read = d.write;\n        d.write = t;\n      },\n    };\n    return d;\n  }\n\n  blit(target: FBO | null) {\n    const gl = this.gl;\n    if (!gl) return;\n    if (target) {\n      gl.bindFramebuffer(gl.FRAMEBUFFER, target.fbo);\n      gl.viewport(0, 0, target.w, target.h);\n    } else {\n      gl.bindFramebuffer(gl.FRAMEBUFFER, null);\n      gl.viewport(0, 0, this.canvas.width, this.canvas.height);\n    }\n    gl.drawArrays(gl.TRIANGLES, 0, 3);\n  }\n\n  free(f: FBO | null) {\n    const gl = this.gl;\n    if (!gl || !f) return;\n    gl.deleteTexture(f.tex);\n    gl.deleteFramebuffer(f.fbo);\n    this.fbos = this.fbos.filter((x) => x !== f);\n  }\n\n  freeDouble(d: Double | null) {\n    if (!d) return;\n    this.free(d.read);\n    this.free(d.write);\n  }\n\n  destroy() {\n    const gl = this.gl;\n    if (!gl) return;\n    for (const f of this.fbos.slice()) this.free(f);\n    for (const p of this.programs) gl.deleteProgram(p);\n    if (this.buffer) gl.deleteBuffer(this.buffer);\n    if (this.vao) gl.deleteVertexArray(this.vao);\n    this.programs = [];\n    this.fbos = [];\n    this.buffer = null;\n    this.vao = null;\n    this.active = null;\n    this.gl = null;\n  }\n}\n\n// A convecting surface has no meaningful t = 0 — an unstirred field is a flat\n// grey rectangle. The sim is spun up before the first paint in EVERY mode, at\n// a coarse step (semi-Lagrangian advection is unconditionally stable, so the\n// only cost of a big dt is a little extra diffusion that settles out within a\n// second of live stepping). 300 steps at 1/16s is nearly twenty seconds of\n// convection: cells have already been born, expanded and died, and the lane\n// network is fully knitted before anyone looks at it.\nconst WARMUP_STEPS = 300;\nconst WARMUP_DT = 1 / 16;\nconst STATIC_STEPS = 460;\n\n// Pointer smoothing. A plain exponential follower has a steady-state error of\n// exactly velocity*tau under constant motion, so the upwelling would sit that\n// far behind the cursor and read as the surface responding late. Extrapolating\n// the target one tau ahead cancels the term algebraically; the smoothing is\n// then spent only on direction changes and on interpolating between events\n// that arrived sparser than frames. The velocity window has to outlive the gap\n// between two pointer events or the estimate — and with it the compensation —\n// collapses to zero on any frame that carried none.\nconst POINTER_TAU = 0.012;\nconst VEL_TAU = 0.06;\nconst LEAD_MAX = 26;\n\nexport function GranuleChurn({\n  density = 1,\n  speed = 1,\n  faculae = 1,\n  paused = false,\n  children,\n  className = \"\",\n  style,\n}: GranuleChurnProps) {\n  const wrapRef = useRef<HTMLDivElement | null>(null);\n  const canvasRef = useRef<HTMLCanvasElement | null>(null);\n\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\n    const surface = new Surface(canvas);\n    if (!surface.init()) return;\n    const simProg = surface.program(SIM_SRC);\n    const renderProg = surface.program(RENDER_SRC);\n    if (!simProg || !renderProg) {\n      surface.destroy();\n      return;\n    }\n\n    let raf = 0;\n    let running = false;\n    let staticMode = false;\n    let disposed = false;\n    let cssW = 0;\n    let cssH = 0;\n    let dpr = 1;\n    let simW = 0;\n    let simH = 0;\n    let field: Double | null = null;\n    let lastMs = performance.now();\n    let simTime = 0;\n\n    // Adaptive ladder. Every threshold is wall clock, never frames: a\n    // frame-counted gate waits longer the slower the machine is, which is\n    // backwards. Display scale goes first because the render pass is the one\n    // that scales with DPR; sim resolution is last because it is what keeps\n    // the lanes thin.\n    const DISPLAY_SCALES = [1, 0.8, 0.62];\n    // the sim runs far below display resolution and is magnified with a linear\n    // fetch, so its long side sets how fine a lane can be. At 640 against a\n    // 2880px backing a lane was four device pixels wide before filtering and\n    // the whole field read soft. 768 is the point where lanes come back to a\n    // hard edge; the pass is ~0.3M fragments, a rounding error next to the\n    // 5.2M-fragment render pass it feeds.\n    const SIM_LONG = [896, 704, 512];\n    const BUDGET_OVER = 26;\n    let tier = 0;\n    let frameEma = 16.7;\n    let overMs = 0;\n    let underMs = 0;\n    let upWindow = 8000;\n\n    let hoverTarget = 0;\n    let hoverAmt = 0;\n    let pressAmt = 0;\n    let pressTarget = 0;\n\n    let havePointer = false;\n    let tgtX = 0;\n    let tgtY = 0;\n    let ptrX = 0;\n    let ptrY = 0;\n    let velX = 0;\n    let velY = 0;\n    let lastTgtX = 0;\n    let lastTgtY = 0;\n    let rectLeft = 0;\n    let rectTop = 0;\n    let rectDirty = true;\n\n    let c0: RGB = [0.02, 0.02, 0.03];\n    let c1: RGB = [0.1, 0.1, 0.11];\n    let c2: RGB = [0.42, 0.42, 0.44];\n    let c3: RGB = [0.86, 0.86, 0.87];\n    let c4: RGB = [1, 1, 1];\n    let bias = 0;\n    let contrast = 1.18;\n    let mottle = 0.14;\n\n    // Five stops, and the direction genuinely inverts between themes. A\n    // reflective surface can span black-to-white in both because it is lit by\n    // a room; a self-luminous one cannot — brightness IS the subject, so light\n    // theme has to be the negative, not a dimmed positive. Its stops are\n    // written apart rather than derived with a bias term, and they stop short\n    // of black on purpose: granules cover most of the frame, so ramping their\n    // interiors all the way down would hand the light theme a black page.\n    const readColors = () => {\n      const cs = getComputedStyle(document.documentElement);\n      const bg = parseColor(cs.getPropertyValue(\"--background\")) ?? [1, 1, 1];\n      const fg = parseColor(cs.getPropertyValue(\"--foreground\")) ?? [0.09, 0.09, 0.09];\n      const muted = parseColor(cs.getPropertyValue(\"--ns-muted\")) ?? [0.55, 0.55, 0.55];\n      const border = parseColor(cs.getPropertyValue(\"--border\")) ?? [0.2, 0.2, 0.2];\n      const black: RGB = [0, 0, 0];\n      const white: RGB = [1, 1, 1];\n      if (luminance(bg) < 0.5) {\n        // a star: lanes sink below the page, granule tops climb past --foreground\n        c0 = mixRGB(bg, black, 0.6);\n        c1 = mixRGB(border, bg, 0.1);\n        c2 = mixRGB(muted, fg, 0.25);\n        c3 = fg;\n        c4 = mixRGB(fg, white, 0.9);\n        bias = -0.04;\n        contrast = 1.2;\n        mottle = 0.17;\n      } else {\n        // the negative: pale lanes, graphite granules, and the darkest stop\n        // held well off black so the field reads as inked paper\n        c0 = bg;\n        c1 = mixRGB(bg, muted, 0.42);\n        c2 = mixRGB(muted, fg, 0.3);\n        c3 = mixRGB(fg, muted, 0.34);\n        c4 = mixRGB(fg, black, 0.15);\n        bias = 0.02;\n        contrast = 1.14;\n        mottle = 0.15;\n      }\n    };\n    readColors();\n\n    const cellsAcross = () => 15 * Math.max(0.35, Math.min(2.5, density));\n\n    const setSimUniforms = (dt: number, ptrStrength: number) => {\n      surface.use(simProg);\n      surface.f(\"u_aspect\", cssW / Math.max(1, cssH));\n      surface.f(\"u_cells\", cellsAcross());\n      surface.f(\"u_time\", simTime);\n      surface.f(\"u_dt\", dt);\n      surface.f(\"u_flow\", 0.55 * Math.max(0.05, speed));\n      surface.f(\"u_split\", 1);\n      surface.f(\"u_fleck\", Math.max(0, faculae));\n      surface.v2(\"u_texel\", 1 / simW, 1 / simH);\n      surface.v2(\"u_grid\", simW, simH);\n      surface.v3f(\"u_ptr\", ptrX / Math.max(1, cssW), ptrY / Math.max(1, cssH), ptrStrength);\n      surface.f(\"u_ptrAcc\", ptrStrength > 0 ? Math.min(1, ptrStrength * 1.1) : 0);\n    };\n\n    const step = (dt: number) => {\n      if (!field) return;\n      const strength = havePointer ? hoverAmt * (0.5 + 0.9 * pressAmt) : 0;\n      simTime += dt * Math.max(0.05, speed);\n      setSimUniforms(dt, strength);\n      surface.tex(\"u_field\", 0, field.read.tex);\n      surface.blit(field.write);\n      field.swap();\n    };\n\n    const render = () => {\n      if (!field) return;\n      surface.use(renderProg);\n      surface.f(\"u_aspect\", cssW / Math.max(1, cssH));\n      surface.f(\"u_cells\", cellsAcross());\n      surface.f(\"u_time\", simTime);\n      surface.v2(\"u_texel\", 1 / simW, 1 / simH);\n      surface.v2(\"u_res\", cssW, cssH);\n      surface.v3(\"u_c0\", c0);\n      surface.v3(\"u_c1\", c1);\n      surface.v3(\"u_c2\", c2);\n      surface.v3(\"u_c3\", c3);\n      surface.v3(\"u_c4\", c4);\n      surface.f(\"u_bias\", bias);\n      surface.f(\"u_contrast\", contrast);\n      surface.f(\"u_mottle\", mottle);\n      surface.tex(\"u_field\", 0, field.read.tex);\n      surface.blit(null);\n    };\n\n    const seed = (steps: number) => {\n      const savedTime = simTime;\n      simTime = 0;\n      for (let i = 0; i < steps; i++) step(WARMUP_DT);\n      // the clock keeps running from where the spin-up left it, so a resize\n      // that re-seeds does not restart every plume's phase\n      simTime = savedTime > 0 ? savedTime : simTime;\n    };\n\n    const buildTargets = () => {\n      const gl = surface.gl;\n      if (!gl || cssW < 2 || cssH < 2) return;\n      const long = SIM_LONG[tier];\n      const aspect = cssW / cssH;\n      const w = aspect >= 1 ? long : Math.max(96, Math.round(long * aspect));\n      const h = aspect >= 1 ? Math.max(96, Math.round(long / aspect)) : long;\n      if (field && simW === w && simH === h) return;\n      surface.freeDouble(field);\n      simW = w;\n      simH = h;\n      field = surface.makeDouble(w, h);\n      seed(WARMUP_STEPS);\n    };\n\n    const applyBacking = () => {\n      if (cssW < 2 || cssH < 2) return;\n      // Capped at 1.7 rather than the usual 2. The render pass is full-bleed\n      // and costs five texture fetches plus three octaves of value noise per\n      // fragment, so the area term dominates everything else in the component:\n      // measured with a readPixels fence on an M3 (ANGLE/Metal — gl.finish()\n      // is a no-op there and reads 0.00ms, which is how a naive bench calls\n      // this free), an uncapped 2880x1800 costs 8.2ms of a 16.7ms budget. That\n      // is inside it, but it is half the frame for a BACKGROUND, and this\n      // surface is never the only thing on the page. 1.7 spends 5.9ms for a\n      // field of continuous noise whose finest structure is well under a\n      // device pixel either way — there is no visible loss, only headroom.\n      dpr = Math.min(window.devicePixelRatio || 1, 1.7) * DISPLAY_SCALES[tier];\n      const pw = Math.max(1, Math.round(cssW * dpr));\n      const ph = Math.max(1, Math.round(cssH * dpr));\n      if (canvas.width !== pw || canvas.height !== ph) {\n        canvas.width = pw;\n        canvas.height = ph;\n      }\n      canvas.style.width = `${cssW}px`;\n      canvas.style.height = `${cssH}px`;\n    };\n\n    let resizeRaf = 0;\n    const doResize = () => {\n      resizeRaf = 0;\n      const rect = wrap.getBoundingClientRect();\n      if (rect.width < 2 || rect.height < 2) return;\n      cssW = rect.width;\n      cssH = rect.height;\n      rectLeft = rect.left;\n      rectTop = rect.top;\n      rectDirty = false;\n      // a new size is a new cost, so the ladder starts over rather than\n      // carrying a verdict earned at a different number of fragments\n      tier = 0;\n      overMs = 0;\n      underMs = 0;\n      upWindow = 8000;\n      frameEma = 16.7;\n      applyBacking();\n      buildTargets();\n      render();\n    };\n    // ResizeObserver fires on every step of a window drag; coalescing to one\n    // rAF is what stops a drag from paying for a re-seed per pixel\n    const resize = () => {\n      if (resizeRaf) return;\n      resizeRaf = requestAnimationFrame(doResize);\n    };\n\n    const stepPointer = (dt: number) => {\n      if (!havePointer || dt <= 0) return;\n      const vk = 1 - Math.exp(-dt / VEL_TAU);\n      velX += ((tgtX - lastTgtX) / dt - velX) * vk;\n      velY += ((tgtY - lastTgtY) / dt - velY) * vk;\n      lastTgtX = tgtX;\n      lastTgtY = tgtY;\n      let leadX = velX * POINTER_TAU;\n      let leadY = velY * POINTER_TAU;\n      const lead = Math.hypot(leadX, leadY);\n      if (lead > LEAD_MAX) {\n        leadX = (leadX / lead) * LEAD_MAX;\n        leadY = (leadY / lead) * LEAD_MAX;\n      }\n      const k = 1 - Math.exp(-dt / POINTER_TAU);\n      ptrX += (tgtX + leadX - ptrX) * k;\n      ptrY += (tgtY + leadY - ptrY) * k;\n    };\n\n    const loop = (nowMs: number) => {\n      const rawMs = nowMs - lastMs;\n      lastMs = nowMs;\n      const dt = Math.min(0.05, Math.max(0.0005, rawMs / 1000));\n      hoverAmt += (hoverTarget - hoverAmt) * (1 - Math.exp(-dt * 7));\n      pressAmt += (pressTarget - pressAmt) * (1 - Math.exp(-dt * 6));\n      stepPointer(dt);\n      step(dt);\n      render();\n\n      const clamped = Math.min(50, rawMs);\n      frameEma += (clamped - frameEma) * (1 - Math.exp(-clamped / 120));\n      if (frameEma > BUDGET_OVER) {\n        overMs += clamped;\n        underMs = 0;\n      } else {\n        underMs += clamped;\n        overMs = 0;\n      }\n      // asymmetric: drop after a sustained stretch of stutter, climb back only\n      // after a much longer clean one, and double the wait each time — the\n      // frame time being watched is the PAGE's, and this surface is rarely\n      // what blew it, so a sibling's layout storm must not permanently soften it\n      const down = overMs > 1600 && tier < DISPLAY_SCALES.length - 1;\n      const up = underMs > upWindow && tier > 0;\n      if (down || up) {\n        tier += down ? 1 : -1;\n        if (down) upWindow = Math.min(64000, upWindow * 2);\n        overMs = 0;\n        underMs = 0;\n        frameEma = 16.7;\n        applyBacking();\n        buildTargets();\n      }\n      raf = requestAnimationFrame(loop);\n    };\n\n    const wake = () => {\n      if (running || disposed) return;\n      running = true;\n      lastMs = performance.now();\n      raf = requestAnimationFrame(loop);\n    };\n    const sleep = () => {\n      cancelAnimationFrame(raf);\n      running = false;\n    };\n\n    const syncRect = () => {\n      if (!rectDirty) return;\n      const rect = wrap.getBoundingClientRect();\n      rectLeft = rect.left;\n      rectTop = rect.top;\n      rectDirty = false;\n    };\n    const markRectDirty = () => {\n      rectDirty = true;\n    };\n\n    const setTarget = (e: PointerEvent) => {\n      syncRect();\n      const co = typeof e.getCoalescedEvents === \"function\" ? e.getCoalescedEvents() : null;\n      const last = co && co.length > 0 ? co[co.length - 1] : e;\n      tgtX = last.clientX - rectLeft;\n      tgtY = last.clientY - rectTop;\n    };\n    const snapPointer = () => {\n      ptrX = tgtX;\n      ptrY = tgtY;\n      velX = 0;\n      velY = 0;\n      lastTgtX = tgtX;\n      lastTgtY = tgtY;\n      havePointer = true;\n    };\n    // static mode has no loop to smooth in, so pointer input runs one further\n    // step under the cursor and repaints the frozen frame\n    const staticPoke = () => {\n      hoverAmt = 1;\n      step(1 / 60);\n      render();\n    };\n\n    const onPointerEnter = (e: PointerEvent) => {\n      hoverTarget = 1;\n      setTarget(e);\n      snapPointer();\n      if (staticMode) staticPoke();\n    };\n    const onPointerMove = (e: PointerEvent) => {\n      setTarget(e);\n      if (!havePointer) {\n        snapPointer();\n        hoverTarget = 1;\n      }\n      if (staticMode) {\n        ptrX = tgtX;\n        ptrY = tgtY;\n        staticPoke();\n      }\n    };\n    const onPointerLeave = () => {\n      hoverTarget = 0;\n      pressTarget = 0;\n      havePointer = false;\n    };\n    const onPointerDown = (e: PointerEvent) => {\n      setTarget(e);\n      snapPointer();\n      hoverTarget = 1;\n      pressTarget = 1;\n      if (staticMode) staticPoke();\n    };\n    const onPointerUp = (e: PointerEvent) => {\n      pressTarget = 0;\n      // a lifted touch has no position any more and no pointerleave is coming\n      if (e.pointerType !== \"mouse\") {\n        hoverTarget = 0;\n        havePointer = false;\n      }\n    };\n    const onPointerCancel = () => {\n      hoverTarget = 0;\n      pressTarget = 0;\n      havePointer = false;\n    };\n\n    const ro = new ResizeObserver(resize);\n    ro.observe(wrap);\n    doResize();\n\n    wrap.addEventListener(\"pointerenter\", onPointerEnter);\n    wrap.addEventListener(\"pointerleave\", onPointerLeave);\n    wrap.addEventListener(\"pointermove\", onPointerMove);\n    wrap.addEventListener(\"pointerdown\", onPointerDown);\n    wrap.addEventListener(\"pointerup\", onPointerUp);\n    wrap.addEventListener(\"pointercancel\", onPointerCancel);\n    window.addEventListener(\"scroll\", markRectDirty, { passive: true, capture: true });\n    window.addEventListener(\"resize\", markRectDirty, { passive: true });\n\n    const mq = window.matchMedia(\"(prefers-reduced-motion: reduce)\");\n    let reduced = mq.matches;\n    const applyMode = () => {\n      if (reduced || pausedRef.current) {\n        if (!staticMode) {\n          staticMode = true;\n          sleep();\n          // reduced motion still gets a fully developed surface, just a frozen\n          // one: a longer spin-up and then no further stepping\n          seed(STATIC_STEPS - WARMUP_STEPS);\n          render();\n        }\n      } else if (staticMode || !running) {\n        staticMode = false;\n        wake();\n      }\n    };\n    const onMq = () => {\n      reduced = mq.matches;\n      applyMode();\n    };\n    mq.addEventListener(\"change\", onMq);\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    applyMode();\n\n    // polled rather than made an effect dependency: either would tear down and\n    // rebuild the GL context to change a boolean\n    let lastPolledPaused = pausedRef.current;\n    let poll = 0;\n    const tick = () => {\n      if (pausedRef.current !== lastPolledPaused) {\n        lastPolledPaused = pausedRef.current;\n        if (!pausedRef.current && !reduced) staticMode = true; // force applyMode to wake\n        applyMode();\n      }\n      poll = window.setTimeout(tick, 140);\n    };\n    tick();\n\n    const themeObserver = new MutationObserver(() => {\n      readColors();\n      if (staticMode) render();\n    });\n    themeObserver.observe(document.documentElement, {\n      attributes: true,\n      attributeFilter: [\"class\"],\n    });\n\n    const onLost = (e: Event) => {\n      e.preventDefault();\n      sleep();\n    };\n    const onRestored = () => {\n      field = null;\n      if (surface.init()) doResize();\n    };\n    canvas.addEventListener(\"webglcontextlost\", onLost);\n    canvas.addEventListener(\"webglcontextrestored\", onRestored);\n\n    return () => {\n      disposed = true;\n      sleep();\n      cancelAnimationFrame(resizeRaf);\n      ro.disconnect();\n      io.disconnect();\n      mq.removeEventListener(\"change\", onMq);\n      document.removeEventListener(\"visibilitychange\", onVis);\n      themeObserver.disconnect();\n      canvas.removeEventListener(\"webglcontextlost\", onLost);\n      canvas.removeEventListener(\"webglcontextrestored\", onRestored);\n      wrap.removeEventListener(\"pointerenter\", onPointerEnter);\n      wrap.removeEventListener(\"pointerleave\", onPointerLeave);\n      wrap.removeEventListener(\"pointermove\", onPointerMove);\n      wrap.removeEventListener(\"pointerdown\", onPointerDown);\n      wrap.removeEventListener(\"pointerup\", onPointerUp);\n      wrap.removeEventListener(\"pointercancel\", onPointerCancel);\n      window.removeEventListener(\"scroll\", markRectDirty, {\n        capture: true,\n      } as EventListenerOptions);\n      window.removeEventListener(\"resize\", markRectDirty);\n      window.clearTimeout(poll);\n      surface.destroy();\n    };\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [density, speed, faculae]);\n\n  return (\n    <div\n      ref={wrapRef}\n      className={`relative isolate h-full w-full touch-none 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\nGranuleChurn.displayName = \"GranuleChurn\";\n",
      "type": "registry:ui",
      "target": "components/ui/granule-churn.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",
      "webgl",
      "shader",
      "convection",
      "cursor",
      "decorative"
    ],
    "instruction": "Build <GranuleChurn density? speed? faculae? paused? children? className? style?> as a full-bleed WebGL2 background rendering SOLAR GRANULATION — a stellar photosphere seen from above, boiling continuously with no input at all. The mechanism is a compressible surface flow, not a cell diagram: a potential field is built from a lattice of gaussian plumes, each born at a jittered position with its own period, swelling over its lifetime and fading; horizontal velocity is the negative gradient of that field, so material runs downhill off every summit and piles up where three or four plumes' skirts meet. Nothing computes a nearest-site distance — the dark intergranular lanes are not the edges of a partition, they are where an advected density has genuinely been squeezed together, so they vary in width, break into segments and lag behind the plumes that pushed them. Four channels ride one RGBA16F ping-pong target through a single semi-Lagrangian pass: temperature injected where the flow diverges and cooling radiatively as it travels out (so a granule is bright at the middle and dim by the time it reaches its own boundary), material density multiplied by the continuity term (which is what actually darkens a lane), faculae seeded stochastically in the strongest downdrafts and carried along the lane that made them, and pointer freshness. Birth and death are structural: when a plume's envelope reaches zero its successor is born at a new jittered position, neighbours immediately expand into the space and the lane network re-knits, and about a third of sites develop an exploding-granule dimple late in life that drives a lane through the middle of the cell and cleaves it in two. The pointer is an extra upwelling rather than a decal — it pushes the lanes apart and floats fresh hot material up under the cursor, on a lead-compensated follower so the response is not late, and --ns-accent tints only that freshly disturbed material. Colours come from --background, --foreground, --ns-muted, --border and --ns-accent via getComputedStyle, re-read on a documentElement class MutationObserver; the ramp inverts between themes rather than shifting its distribution, so dark theme is a luminous star and light theme its deliberate negative, graphite granules on a paper field. The sim is spun up for twenty seconds of convection before the first paint in every mode, so the surface is already boiling at mount; prefers-reduced-motion gets a longer spin-up and then a frozen frame. Pauses offscreen and on tab hidden, and steps down display then sim resolution only after a sustained stretch of missed frames."
  },
  "type": "registry:ui"
}