{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "sula-core",
  "type": "registry:ui",
  "dependencies": [
    "ogl"
  ],
  "registryDependencies": [
    "https://usva.build/r/sula-motion.json"
  ],
  "files": [
    {
      "path": "geometry.ts",
      "target": "components/ui/geometry.ts",
      "type": "registry:ui",
      "content": "import { clamp01, mix, smoothstep } from \"./curves\";\n\n/** Brand + views + detached satellite fields + the drip's transient reservoir.\n * The shader loops break on the live count, so headroom here costs nothing per\n * fragment; overflowing it silently drops parts, which is far worse. */\nexport const MAX_BLOBS = 12;\n/** Neighbour bridges while a switch fuses the row, the drip tether on load, and\n * one separation neck per part still pulling away from the body. */\nexport const MAX_NECKS = 8;\n\n/** A rounded box in canvas space: centre, half-extents, corner radius. */\nexport interface Blob {\n  cx: number;\n  cy: number;\n  hw: number;\n  hh: number;\n  r: number;\n}\n\n/** A capsule from a to b. Round caps read as surface tension; square ones do not. */\nexport interface Neck {\n  ax: number;\n  ay: number;\n  bx: number;\n  by: number;\n  r: number;\n  /**\n   * How much of the neck's smooth-min bridge is applied, 0 to 1. The visible\n   * bridge width is set by the merge radius, not by `r`, so a neck cannot be\n   * melted by thinning it; fading strength to 0 recedes the bridge from the\n   * surface inward instead, which is the only way it melts without snapping.\n   * Absent means a solid bridge (1).\n   */\n  strength?: number;\n}\n\nexport interface Field {\n  blobs: Blob[];\n  necks: Neck[];\n  k: number;\n}\n\nexport interface Rect {\n  left: number;\n  top: number;\n  width: number;\n  height: number;\n}\n\n/** Default snap distance, as a multiple of the merge radius. */\nconst BREAK_FACTOR = 0.9;\n/** Neck radius at zero separation, as a multiple of the merge radius. A fat neck\n * reads as surface tension; a thin one reads as a stray thread. */\nconst NECK_THICKNESS = 1.15;\n/** Geometric floor for a neck while its field strength melts to zero. The\n * capsule must stay substantial enough to read as a waist; `strength`, not a\n * vanishing radius, owns the continuous attach/detach tail. */\nexport const NECK_MIN = 1.5;\n\nexport function neckBreakDistance(k: number): number {\n  return k * BREAK_FACTOR;\n}\n\nexport function neckRadius(\n  distance: number,\n  k: number,\n  breakDistance: number = neckBreakDistance(k),\n): number {\n  if (breakDistance <= 0 || distance >= breakDistance) return 0;\n  const t = Math.max(0, distance) / breakDistance;\n  return k * NECK_THICKNESS * (1 - t) ** 1.1;\n}\n\nexport function toCanvasSpace(rect: Rect, canvas: Rect): Blob {\n  const hw = rect.width / 2;\n  const hh = rect.height / 2;\n  return {\n    cx: rect.left - canvas.left + hw,\n    cy: rect.top - canvas.top + hh,\n    hw,\n    hh,\n    r: Math.min(hw, hh),\n  };\n}\n\n/**\n * Measures each part at its CSS rest position without exposing the temporary\n * transform reset to the next paint. The liquid field owns the inline transform\n * while it animates, but its target geometry must stay transform-free.\n */\nexport function measureRestBlobs(\n  parts: Array<{\n    style: { transform: string };\n    getBoundingClientRect(): Rect;\n  }>,\n  canvas: Rect,\n): Blob[] {\n  const transforms = parts.map((part) => part.style.transform);\n  for (const part of parts) part.style.transform = \"\";\n  try {\n    return parts.map((part) =>\n      toCanvasSpace(part.getBoundingClientRect(), canvas),\n    );\n  } finally {\n    for (const [index, part] of parts.entries()) {\n      part.style.transform = transforms[index] ?? \"\";\n    }\n  }\n}\n\nexport function lerpBlob(a: Blob, b: Blob, t: number): Blob {\n  return {\n    cx: mix(a.cx, b.cx, t),\n    cy: mix(a.cy, b.cy, t),\n    hw: mix(a.hw, b.hw, t),\n    hh: mix(a.hh, b.hh, t),\n    r: mix(a.r, b.r, t),\n  };\n}\n\n/**\n * A spring hands back values past 1. Position may overshoot, because that is the\n * bounce; half-extents may not, because a blob wider than its DOM box paints\n * glass out from under the text.\n */\nexport function springToBlob(from: Blob, to: Blob, s: number): Blob {\n  const clamped = Math.min(1, Math.max(0, s));\n  return {\n    cx: mix(from.cx, to.cx, s),\n    cy: mix(from.cy, to.cy, s),\n    hw: mix(from.hw, to.hw, clamped),\n    hh: mix(from.hh, to.hh, clamped),\n    r: mix(from.r, to.r, clamped),\n  };\n}\n\n/** How far past the merge radius the goo will still bridge two neighbours. */\nexport const BRIDGE_REACH = 1.6;\n/** Merge at or past this holds the bridge at full strength. Holding a partial\n * strength recedes the bridge into two sharp cusps (the mix of a thin capsule\n * into the round surface never crosses zero mid-gap), so a standing rest pull\n * must be a full-strength smooth-min; only a transient melt may fade below. */\nconst BRIDGE_FULL = 0.3;\n\n/** The blob's surface reach from its centre along a direction, exact for a\n * rounded box: the core box support plus the corner radius. */\nfunction supportAlong(b: Blob, ux: number, uy: number): number {\n  return Math.abs(ux) * (b.hw - b.r) + Math.abs(uy) * (b.hh - b.r) + b.r;\n}\n\n/**\n * Surface-tension bridges between adjacent blobs, in whatever direction the pair\n * sits: each neck runs centre-to-centre along the pair's own axis, from surface\n * to surface. Thickness grows with `merge` (REST at rest, 1 at the peak of a\n * transition) and with how close the two sit, so near pairs join in a soft\n * concave waist. At the edge of the reach the capsule holds its geometric floor\n * while its field strength eases from zero, matching the same melt used by\n * authored Sula necks: approaching bodies reach toward each other before joining,\n * and separating bodies flow back into themselves instead of losing a whole neck\n * in one frame. Blobs must be handed in adjacency order.\n */\nexport function bridgeNecks(blobs: Blob[], k: number, merge: number): Neck[] {\n  const m = clamp01(merge);\n  if (m <= 0.001 || k <= 0) return [];\n  const reach = k * BRIDGE_REACH;\n  const necks: Neck[] = [];\n  for (let i = 0; i < blobs.length - 1; i++) {\n    const a = blobs[i] as Blob;\n    const b = blobs[i + 1] as Blob;\n    const dx = b.cx - a.cx;\n    const dy = b.cy - a.cy;\n    const dist = Math.hypot(dx, dy) || 1;\n    const ux = dx / dist;\n    const uy = dy / dist;\n    const supA = supportAlong(a, ux, uy);\n    const supB = supportAlong(b, ux, uy);\n    const gap = dist - supA - supB;\n    if (gap > reach) continue;\n    const closeness = 1 - clamp01(Math.max(gap, 0) / reach);\n    const base = Math.min(a.hw, a.hh, b.hw, b.hh);\n    const thickness = base * mix(0.25, 0.9, m) * closeness;\n    const mergeStrength = smoothstep(0, BRIDGE_FULL, m);\n    const distanceStrength = smoothstep(0, BRIDGE_FULL, closeness);\n    necks.push({\n      ax: a.cx + ux * supA,\n      ay: a.cy + uy * supA,\n      bx: b.cx - ux * supB,\n      by: b.cy - uy * supB,\n      r: Math.max(thickness, NECK_MIN),\n      strength: mergeStrength * distanceStrength,\n    });\n  }\n  return necks;\n}\n\nexport function activePillRect(\n  items: Blob[],\n  activeIndex: number,\n): Blob | null {\n  return items[activeIndex] ?? null;\n}\n\n/**\n * Position and size move on separate clocks. The whole row glides to its new\n * layout on one shared `posT` so it stays coherent and never tears a gap, while\n * each part's width follows its own staggered `sizeT` so a collapsing bar can\n * shed width fast without the row lagging behind it.\n */\nexport function morphBlob(\n  from: Blob,\n  to: Blob,\n  posT: number,\n  sizeT: number,\n): Blob {\n  return {\n    cx: mix(from.cx, to.cx, posT),\n    cy: mix(from.cy, to.cy, posT),\n    hw: mix(from.hw, to.hw, sizeT),\n    hh: mix(from.hh, to.hh, sizeT),\n    r: mix(from.r, to.r, sizeT),\n  };\n}\n\n/**\n * True when any part moved or resized past the tolerance. A webfont swap or\n * rounding jitter re-measures within it, and applying that would twitch a bar\n * that already looks settled.\n */\nexport function restDiffers(a: Blob[], b: Blob[], eps = 0.5): boolean {\n  if (a.length !== b.length) return true;\n  return a.some((blob, i) => {\n    const other = b[i];\n    if (!other) return true;\n    return (\n      Math.abs(blob.cx - other.cx) > eps ||\n      Math.abs(blob.cy - other.cy) > eps ||\n      Math.abs(blob.hw - other.hw) > eps ||\n      Math.abs(blob.hh - other.hh) > eps\n    );\n  });\n}\n\n/** Radius of the cursor wave in CSS px. Broad enough to bend a section of a long\n * pill, while remaining local enough that neighbouring parts stay calm. */\nconst HOVER_SPREAD = 90;\n\nexport interface PackedHover {\n  point: [number, number];\n  amount: number;\n  spread: number;\n}\n\n/** Flattens the hover focus the way packUniforms does: device px, Y flipped. */\nexport function packHover(\n  blob: Blob,\n  amount: number,\n  dpr: number,\n  canvasHeight: number,\n  point?: { x: number; y: number },\n): PackedHover {\n  const focus = point ?? { x: blob.cx, y: blob.cy };\n  return {\n    point: [focus.x * dpr, (canvasHeight - focus.y) * dpr],\n    amount: amount * dpr,\n    spread: HOVER_SPREAD * dpr,\n  };\n}\n\nexport interface PackedField {\n  blobs: number[];\n  radii: number[];\n  necks: number[];\n  neckRadii: number[];\n  neckStrengths: number[];\n  blobCount: number;\n  neckCount: number;\n}\n\n/**\n * Flattens to device pixels and flips Y, because `gl_FragCoord` counts up from the\n * bottom. These stay plain arrays, not Float32Arrays: ogl gates array uniforms on\n * `Array.isArray`, and a typed array fails it and uploads nothing at all.\n */\nexport function packUniforms(\n  field: Field,\n  dpr: number,\n  canvasHeight: number,\n): PackedField {\n  const blobs = new Array<number>(MAX_BLOBS * 4).fill(0);\n  const radii = new Array<number>(MAX_BLOBS).fill(0);\n  const necks = new Array<number>(MAX_NECKS * 4).fill(0);\n  const neckRadii = new Array<number>(MAX_NECKS).fill(0);\n  const neckStrengths = new Array<number>(MAX_NECKS).fill(0);\n\n  const blobCount = Math.min(field.blobs.length, MAX_BLOBS);\n  for (let i = 0; i < blobCount; i++) {\n    const b = field.blobs[i] as Blob;\n    blobs[i * 4] = b.cx * dpr;\n    blobs[i * 4 + 1] = (canvasHeight - b.cy) * dpr;\n    blobs[i * 4 + 2] = b.hw * dpr;\n    blobs[i * 4 + 3] = b.hh * dpr;\n    radii[i] = b.r * dpr;\n  }\n\n  const neckCount = Math.min(field.necks.length, MAX_NECKS);\n  for (let i = 0; i < neckCount; i++) {\n    const n = field.necks[i] as Neck;\n    necks[i * 4] = n.ax * dpr;\n    necks[i * 4 + 1] = (canvasHeight - n.ay) * dpr;\n    necks[i * 4 + 2] = n.bx * dpr;\n    necks[i * 4 + 3] = (canvasHeight - n.by) * dpr;\n    neckRadii[i] = n.r * dpr;\n    neckStrengths[i] = n.strength ?? 1;\n  }\n\n  return {\n    blobs,\n    radii,\n    necks,\n    neckRadii,\n    neckStrengths,\n    blobCount,\n    neckCount,\n  };\n}\n"
    },
    {
      "path": "pause.ts",
      "target": "components/ui/pause.ts",
      "type": "registry:ui",
      "content": "export interface PauseGateOptions {\n  /** The element whose visibility decides whether the surface may draw. */\n  target: Element;\n  /** Runs when the surface goes offscreen or the tab is hidden. */\n  onPause: () => void;\n  /** Runs when it comes back. */\n  onResume: () => void;\n  /**\n   * Slack around the viewport before a surface counts as gone. Chrome like a nav\n   * sits at the very edge of the viewport and a stricter test would park it while\n   * it is still in front of the reader, so any overlap at all counts as visible\n   * and this margin keeps a hair of hysteresis around that.\n   */\n  margin?: string;\n}\n\nexport interface PauseGate {\n  /** False while the surface is offscreen or the tab is hidden. */\n  awake(): boolean;\n  dispose(): void;\n}\n\nconst DEFAULT_MARGIN = \"20%\";\n\n/**\n * The one place a Sula surface decides whether it is allowed to draw. Every\n * surface parks its own loop on idle, but idle is not the same as unseen: a\n * hovered nav, a live switch or a standing ambient loop will happily burn frames\n * in a background tab. The gate is the shared answer, so a new surface inherits\n * it by construction rather than by remembering to bolt an observer on.\n *\n * It reports transitions only, never the current frame's state, so a resume\n * hands control back to a loop that picks up from its live values: nothing here\n * rewinds a choreography.\n */\nexport function createPauseGate(options: PauseGateOptions): PauseGate {\n  const { target, onPause, onResume, margin = DEFAULT_MARGIN } = options;\n\n  let onScreen = true;\n  let awake = true;\n\n  const sync = () => {\n    const next = onScreen && !isHidden();\n    if (next === awake) return;\n    awake = next;\n    if (next) onResume();\n    else onPause();\n  };\n\n  const io =\n    typeof IntersectionObserver === \"undefined\"\n      ? null\n      : new IntersectionObserver(\n          (entries) => {\n            onScreen = entries[entries.length - 1]?.isIntersecting ?? true;\n            sync();\n          },\n          { rootMargin: margin },\n        );\n  io?.observe(target);\n\n  const onVisibility = () => sync();\n  if (typeof document !== \"undefined\") {\n    document.addEventListener(\"visibilitychange\", onVisibility);\n  }\n\n  return {\n    awake: () => awake,\n    dispose() {\n      io?.disconnect();\n      if (typeof document !== \"undefined\") {\n        document.removeEventListener(\"visibilitychange\", onVisibility);\n      }\n    },\n  };\n}\n\nfunction isHidden(): boolean {\n  return typeof document !== \"undefined\" && document.hidden;\n}\n"
    },
    {
      "path": "shader.ts",
      "target": "components/ui/shader.ts",
      "type": "registry:ui",
      "content": "import { MAX_BLOBS, MAX_NECKS } from \"./geometry\";\n\nexport const vertexShader = `#version 300 es\nin vec2 position;\nvoid main() { gl_Position = vec4(position, 0.0, 1.0); }\n`;\n\n/**\n * The field is signed distance, not a density sum: exact rounded pills merged with\n * a polynomial smooth-min (Inigo Quilez, MIT). A density kernel makes round balls,\n * and the nav needs pills of a given width.\n *\n * The surface normal comes from screen-space derivatives rather than a central\n * difference, which would cost four more field() evaluations per fragment, and\n * field() is a loop of smooth-mins. They must stay outside every branch, because\n * derivatives require uniform control flow.\n *\n * The chromatic fringe shifts the isoline per channel instead of resampling: d is\n * already a distance, so the offset is a first-order step along the normal.\n */\nexport const fragmentShader = `#version 300 es\nprecision highp float;\n\nuniform float uTime;\nuniform int uBlobCount;\nuniform vec4 uBlobs[${MAX_BLOBS}];\nuniform float uRadius[${MAX_BLOBS}];\nuniform int uNeckCount;\nuniform vec4 uNecks[${MAX_NECKS}];\nuniform float uNeckR[${MAX_NECKS}];\nuniform float uNeckStr[${MAX_NECKS}];\nuniform float uK;\nuniform vec3 uTint;\nuniform vec3 uBackdrop;\nuniform vec3 uAccent;\nuniform float uAlpha;\nuniform float uWobble;\nuniform float uShine;\nuniform float uDpr;\nuniform vec2 uHoverPoint;\nuniform float uHoverAmt;\nuniform float uHoverSpread;\n\nout vec4 outColor;\n\nfloat sdRoundBox(vec2 p, vec2 b, float r){\n  vec2 q = abs(p) - b + r;\n  return min(max(q.x, q.y), 0.0) + length(max(q, 0.0)) - r;\n}\n\nfloat sdSegment(vec2 p, vec2 a, vec2 b, float r){\n  vec2 pa = p - a, ba = b - a;\n  float denom = max(dot(ba, ba), 1e-4);\n  float h = clamp(dot(pa, ba) / denom, 0.0, 1.0);\n  return length(pa - ba * h) - r;\n}\n\nfloat smin(float a, float b, float k){\n  float h = clamp(0.5 + 0.5 * (b - a) / k, 0.0, 1.0);\n  return mix(b, a, h) - k * h * (1.0 - h);\n}\n\nfloat field(vec2 p){\n  float d = 1e5;\n  for (int i = 0; i < ${MAX_BLOBS}; i++) {\n    if (i >= uBlobCount) break;\n    d = smin(d, sdRoundBox(p - uBlobs[i].xy, uBlobs[i].zw, uRadius[i]), uK);\n  }\n  for (int i = 0; i < ${MAX_NECKS}; i++) {\n    if (i >= uNeckCount) break;\n    float sd = sdSegment(p, uNecks[i].xy, uNecks[i].zw, uNeckR[i]);\n    d = mix(d, smin(d, sd, uK), clamp(uNeckStr[i], 0.0, 1.0));\n  }\n  /* Two long travelling waves replace value noise: the edge bends in coherent\n     runs instead of accumulating small random bumps. Work in CSS pixels so the\n     wavelength stays the same on standard and retina displays. */\n  vec2 cssP = p / max(uDpr, 1.0);\n  float flow = sin(cssP.x * 0.024 + uTime * 1.25) * 0.62\n             + sin((cssP.x + cssP.y) * 0.012 - uTime * 0.8) * 0.38;\n\n  /* Hover follows the live pointer with a broad elliptical falloff. Its target\n     position is eased on the CPU, so quick cursor movement pushes a responsive\n     wave through the surface without teleporting the deformation. */\n  vec2 delta = p - uHoverPoint;\n  vec2 local = delta / vec2(max(uHoverSpread * 1.25, 1.0), max(uHoverSpread, 1.0));\n  float hoverFall = exp(-dot(local, local) * 2.4);\n  vec2 cssDelta = delta / max(uDpr, 1.0);\n  float cursorWave = sin(cssDelta.x * 0.042 - uTime * 2.2) * 0.72\n                   + sin((cssDelta.x + cssDelta.y) * 0.021 + uTime * 1.1) * 0.28;\n  return d + flow * uWobble + cursorWave * uHoverAmt * hoverFall;\n}\n\nvoid main(){\n  vec2 p = gl_FragCoord.xy;\n  float d = field(p);\n\n  float aa = max(fwidth(d), 1e-4);\n  float alpha = 1.0 - smoothstep(-aa, aa, d);\n\n  vec2 g = vec2(dFdx(d), dFdy(d));\n  vec2 n = length(g) > 1e-5 ? normalize(g) : vec2(0.0, 1.0);\n\n  /* Curvature: how fast the surface normal turns across one pixel. It is near\n     zero on the flat flank of a large blob and spikes at necks, cusps and small\n     beads, so surface tension lights up brightest exactly where the goo is\n     pinching. Scaled by uK so a fat-merge field and a tight one read alike. */\n  float curv = clamp(\n    (abs(dFdx(n.x)) + abs(dFdy(n.x)) + abs(dFdx(n.y)) + abs(dFdy(n.y)))\n      * uK * 0.6,\n    0.0, 1.2);\n\n  float rim = smoothstep(-11.0, 0.0, d) * alpha;\n  float fres = pow(1.0 - abs(dot(n, vec2(0.0, 1.0))), 3.0);\n  float spec = pow(max(dot(n, normalize(vec2(0.35, 0.94))), 0.0), 18.0);\n  float hairline = exp(-pow((d + 1.1) / 1.4, 2.0)) * alpha;\n  /* A broader inner band the curvature glow bleeds into, so a lit neck reads as\n     a soft waist of light rather than a single-pixel wire. */\n  float edge = exp(-pow((d + 2.4) / 2.8, 2.0)) * alpha;\n\n  vec3 glass = mix(uBackdrop, uTint, 0.92);\n  glass += uAccent * (fres * 0.22 + spec * 0.5) * (1.0 + curv * 0.8) * rim * uShine;\n  glass += uAccent * (0.16 + curv * 1.0) * hairline * uShine;\n  glass += uAccent * curv * edge * 0.55 * uShine;\n  glass += vec3(0.05) * mix(0.3, 1.0, uShine) * hairline;\n\n  vec3 chroma = vec3(\n    1.0 - smoothstep(-aa, aa, d - 0.8),\n    alpha,\n    1.0 - smoothstep(-aa, aa, d + 0.8)\n  );\n  glass = mix(glass, glass * chroma, rim * 0.5 * uShine);\n\n  outColor = vec4(glass, alpha * uAlpha);\n}\n`;\n"
    },
    {
      "path": "field.ts",
      "target": "components/ui/field.ts",
      "type": "registry:ui",
      "content": "import { Mesh, Program, Renderer, Triangle } from \"ogl\";\nimport {\n  MAX_BLOBS,\n  MAX_NECKS,\n  type PackedField,\n  type PackedHover,\n} from \"./geometry\";\nimport { fragmentShader, vertexShader } from \"./shader\";\n\nexport type Rgb = [number, number, number];\n\nexport interface FieldColors {\n  tint: Rgb;\n  backdrop: Rgb;\n  accent: Rgb;\n  /** 0 keeps the glass flat and matte, 1 is the full neon rim. */\n  shine: number;\n}\n\n/**\n * Relative luminance of the surface the glass floats on. A dark theme wants the\n * full glow; a pale one would only look garish, so its shine is dialled down.\n */\nexport function shineForBackdrop(backdrop: Rgb): number {\n  const [r, g, b] = backdrop;\n  const luminance = 0.2126 * r + 0.7152 * g + 0.0722 * b;\n  return 0.32 + 0.68 * (1 - luminance) ** 1.4;\n}\n\n/**\n * Pulls a fill off the backdrop toward the accent hue and up in lightness, so an\n * actor blob reads as violet-charcoal glass rather than a bg-adjacent grey lump.\n * `amount` biases toward the accent; the small lift raises absolute lightness so\n * even a dark accent still separates from a near-black bg.\n */\nexport function liftTint(\n  tint: Rgb,\n  accent: Rgb,\n  amount = 0.18,\n  lift = 0.03,\n): Rgb {\n  const clamp = (v: number) => Math.min(1, Math.max(0, v));\n  return [\n    clamp(tint[0] + (accent[0] - tint[0]) * amount + lift),\n    clamp(tint[1] + (accent[1] - tint[1]) * amount + lift),\n    clamp(tint[2] + (accent[2] - tint[2]) * amount + lift),\n  ];\n}\n\nexport interface FieldFrame {\n  packed: PackedField;\n  k: number;\n  time: number;\n  wobble: number;\n  alpha: number;\n  /** Localized hover ripple around one blob; null leaves the field calm. */\n  hover?: PackedHover | null;\n  /** false composites this frame over the last without clearing, so a back and a\n   * front pass can share one context and one canvas. Defaults to true. */\n  clear?: boolean;\n}\n\nexport interface NavField {\n  resize(width: number, height: number, dpr: number): void;\n  setColors(colors: FieldColors): void;\n  draw(frame: FieldFrame): void;\n  dispose(): void;\n}\n\nconst FALLBACK: Rgb = [0, 0, 0];\n\n/**\n * A canvas cannot sample the page behind it, so every colour the glass needs has\n * to be handed to the shader as a number. Tokens are authored in oklch, and the\n * only reliable way to turn any CSS colour string into channels is to paint it.\n */\nexport function resolveColor(value: string): Rgb {\n  if (typeof document === \"undefined\") return FALLBACK;\n  try {\n    const canvas = document.createElement(\"canvas\");\n    canvas.width = 1;\n    canvas.height = 1;\n    const ctx = canvas.getContext(\"2d\", { willReadFrequently: true });\n    if (!ctx) return FALLBACK;\n    ctx.fillStyle = value;\n    ctx.fillRect(0, 0, 1, 1);\n    const [r, g, b] = ctx.getImageData(0, 0, 1, 1).data;\n    return [(r ?? 0) / 255, (g ?? 0) / 255, (b ?? 0) / 255];\n  } catch {\n    return FALLBACK;\n  }\n}\n\nexport interface CreateFieldOptions {\n  canvas: HTMLCanvasElement;\n  colors: FieldColors;\n  onContextLost?: () => void;\n}\n\nexport function createField(options: CreateFieldOptions): NavField | null {\n  const { canvas, colors, onContextLost } = options;\n\n  let renderer: Renderer;\n  try {\n    renderer = new Renderer({\n      canvas,\n      webgl: 2,\n      dpr: 1,\n      alpha: true,\n      premultipliedAlpha: false,\n      antialias: false,\n    });\n  } catch {\n    return null;\n  }\n\n  const gl = renderer.gl;\n  if (!(\"drawBuffers\" in gl)) return null;\n  gl.clearColor(0, 0, 0, 0);\n\n  const program = new Program(gl, {\n    vertex: vertexShader,\n    fragment: fragmentShader,\n    transparent: true,\n    depthTest: false,\n    uniforms: {\n      uTime: { value: 0 },\n      uBlobCount: { value: 0 },\n      uBlobs: { value: new Array<number>(MAX_BLOBS * 4).fill(0) },\n      uRadius: { value: new Array<number>(MAX_BLOBS).fill(0) },\n      uNeckCount: { value: 0 },\n      uNecks: { value: new Array<number>(MAX_NECKS * 4).fill(0) },\n      uNeckR: { value: new Array<number>(MAX_NECKS).fill(0) },\n      uNeckStr: { value: new Array<number>(MAX_NECKS).fill(0) },\n      uK: { value: 14 },\n      uTint: { value: colors.tint },\n      uBackdrop: { value: colors.backdrop },\n      uAccent: { value: colors.accent },\n      uAlpha: { value: 1 },\n      uWobble: { value: 0 },\n      uShine: { value: colors.shine },\n      uDpr: { value: 1 },\n      uHoverPoint: { value: [0, 0] },\n      uHoverAmt: { value: 0 },\n      uHoverSpread: { value: 1 },\n    },\n  });\n  const mesh = new Mesh(gl, { geometry: new Triangle(gl), program });\n  const u = program.uniforms;\n\n  const handleLost = (event: Event) => {\n    event.preventDefault();\n    onContextLost?.();\n  };\n  canvas.addEventListener(\"webglcontextlost\", handleLost);\n\n  return {\n    resize(width, height, dpr) {\n      u.uDpr.value = dpr;\n      renderer.dpr = dpr;\n      renderer.setSize(width, height);\n    },\n    setColors(next) {\n      u.uTint.value = next.tint;\n      u.uBackdrop.value = next.backdrop;\n      u.uAccent.value = next.accent;\n      u.uShine.value = next.shine;\n    },\n    draw({ packed, k, time, wobble, alpha, hover, clear }) {\n      u.uTime.value = time;\n      u.uK.value = k;\n      u.uWobble.value = wobble;\n      u.uAlpha.value = alpha;\n      u.uHoverPoint.value = hover?.point ?? [0, 0];\n      u.uHoverAmt.value = hover?.amount ?? 0;\n      u.uHoverSpread.value = hover?.spread ?? 1;\n      u.uBlobCount.value = packed.blobCount;\n      u.uBlobs.value = packed.blobs;\n      u.uRadius.value = packed.radii;\n      u.uNeckCount.value = packed.neckCount;\n      u.uNecks.value = packed.necks;\n      u.uNeckR.value = packed.neckRadii;\n      u.uNeckStr.value = packed.neckStrengths;\n      /* A back pass renders first and clears; a front pass rides over it with\n       * clear=false so the two glass layers alpha-composite in one context. */\n      renderer.autoClear = clear ?? true;\n      renderer.render({ scene: mesh });\n    },\n    dispose() {\n      canvas.removeEventListener(\"webglcontextlost\", handleLost);\n      program.remove();\n      mesh.geometry.remove();\n    },\n  };\n}\n"
    },
    {
      "path": "emerge.ts",
      "target": "components/ui/emerge.ts",
      "type": "registry:ui",
      "content": "import { c1Settle, clamp01, mix, smoothstep } from \"./curves\";\nimport { type Blob, NECK_MIN, type Neck } from \"./geometry\";\n\nexport interface EmergeOptions {\n  swellEnd?: number;\n  travelStart?: number;\n  pinchStart?: number;\n  pinchEnd?: number;\n  startScale?: number;\n}\n\n/**\n * A droplet separating from a parent blob and settling at a target: it swells out\n * while still absorbed, then travels trailing a neck that thins and pinches. `t`\n * is 0 (absorbed in the parent) to 1 (settled at target). Travel runs through\n * c1Settle so an underdamped spring lands it with a settle wobble rather than a\n * hard stop; raw t past 1 overshoots position, not size.\n *\n * Generalizes the nav's `revealSide` from its bar/side axis to any parent->target\n * vector: the travel axis is the unit vector between centres, so a droplet can\n * emerge in any direction rather than only along x.\n */\nexport function emergeDroplet(\n  parent: Blob,\n  target: Blob,\n  t: number,\n  options: EmergeOptions = {},\n): { blob: Blob; neck: Neck | null } {\n  const {\n    swellEnd = 0.36,\n    travelStart = 0.3,\n    pinchStart = 0.84,\n    pinchEnd = 0.94,\n    startScale = 0.5,\n  } = options;\n\n  const p = clamp01(t);\n\n  const dx = target.cx - parent.cx;\n  const dy = target.cy - parent.cy;\n  const dist = Math.hypot(dx, dy) || 1;\n  const ux = dx / dist;\n  const uy = dy / dist;\n\n  const swell = smoothstep(0, swellEnd, p);\n  /* Travel reaches the rest line at t=1 still moving (slope 1), so an underdamped\n   * spring carries a small settle wobble past the line and back instead of the\n   * droplet landing hard. Raw t, not clamped p, so the overshoot is not flattened. */\n  const travel = c1Settle(t, travelStart);\n  const pinch = smoothstep(pinchStart, pinchEnd, p);\n\n  const scale = mix(startScale, 1, swell);\n  const hw = target.hw * scale;\n  const hh = target.hh * scale;\n\n  /* The parent's box boundary along the travel axis, and a start point pulled\n   * just inside it so the droplet is absorbed at rest. */\n  const edgeX = parent.cx + ux * parent.hw;\n  const edgeY = parent.cy + uy * parent.hh;\n  const back = Math.abs(ux) * target.hw + Math.abs(uy) * target.hh;\n  const startX = edgeX - ux * back * 0.62;\n  const startY = edgeY - uy * back * 0.62;\n\n  const cx = mix(startX, target.cx, travel);\n  const cy = mix(startY, target.cy, travel);\n  const blob: Blob = { cx, cy, hw, hh, r: Math.min(hw, hh) };\n\n  if (p >= pinchEnd || travel <= 0) return { blob, neck: null };\n\n  const trailX = cx - ux * hw;\n  const trailY = cy - uy * hh;\n  const r = Math.max(target.hh * mix(0.86, 0.08, pinch), NECK_MIN);\n  const neck: Neck = {\n    ax: edgeX,\n    ay: edgeY,\n    bx: trailX,\n    by: trailY,\n    r,\n    strength: 1 - pinch,\n  };\n  return { blob, neck };\n}\n"
    },
    {
      "path": "border-shader.ts",
      "target": "components/ui/border-shader.ts",
      "type": "registry:ui",
      "content": "export const borderVertexShader = `#version 300 es\nin vec2 position;\nvoid main() { gl_Position = vec4(position, 0.0, 1.0); }\n`;\n\n/** How many pointer blobs the ring can smooth-union at once. The cursor is one;\n * the slot for a second keeps the door open for an eased trailing blob without\n * touching the shader. */\nexport const MAX_FRAME_BLOBS = 2;\n\n/**\n * The frame is signed distance, not a density sum: an exact rounded-box ring\n * (`abs(win) - thickness`) with the pointer merged in by the same polynomial\n * smooth-min the fill field uses. This is the clean-room answer to LiquidBorder's\n * density-summed metaball, so the two share no technique.\n *\n * Lighting reuses the fill path's vocabulary: curvature-keyed accent rim, a\n * fresnel-ish flank, a directional shine, and a hairline core.\n */\nexport const borderFragmentShader = `#version 300 es\nprecision highp float;\n\nuniform float uTime;\nuniform vec2 uCenter;\nuniform vec2 uHalf;\nuniform float uRadius;\nuniform float uThickness;\nuniform float uWobble;\nuniform float uEnergy;\nuniform float uSweep;\nuniform float uDpr;\nuniform int uBlobCount;\nuniform vec4 uBlobs[${MAX_FRAME_BLOBS}];\nuniform float uBlobK;\nuniform vec3 uTint;\nuniform vec3 uBackdrop;\nuniform vec3 uAccent;\nuniform float uShine;\nuniform float uIntro;\n\nout vec4 outColor;\n\nfloat sdRoundBox(vec2 p, vec2 b, float r){\n  vec2 q = abs(p) - b + r;\n  return min(max(q.x, q.y), 0.0) + length(max(q, 0.0)) - r;\n}\n\nfloat smin(float a, float b, float k){\n  float h = clamp(0.5 + 0.5 * (b - a) / max(k, 1e-4), 0.0, 1.0);\n  return mix(b, a, h) - k * h * (1.0 - h);\n}\n\nfloat ringField(vec2 p){\n  vec2 q = p - uCenter;\n  float win = sdRoundBox(q, uHalf, uRadius);\n\n  /* Two long travelling waves bend the edge in coherent runs. Measured in CSS\n     pixels so the wavelength holds on retina, and scaled by the live energy so\n     the ring is a still faint band at rest. */\n  vec2 cssP = p / max(uDpr, 1.0);\n  float introTurbulence = 1.0 + (1.0 - uIntro) * 2.2;\n  float flow = sin(cssP.x * 0.020 + uTime * 1.10) * 0.60\n             + sin((cssP.x + cssP.y) * 0.011 - uTime * 0.70) * 0.40;\n  win += flow * uWobble * introTurbulence;\n\n  /* Material occupies the outside of the rounded window, including the canvas\n     corners. During intro its inner front travels from the canvas edge to the\n     final border instead of fading a fully formed ring. */\n  float edgeInset = max(min(uCenter.x - uHalf.x, uCenter.y - uHalf.y), 0.0);\n  float front = mix(edgeInset, -uThickness, uIntro);\n  float d = front - win;\n\n  /* The pointer disc is smooth-union'd into the band, so the nearest edge necks\n     and bulges toward the cursor. Its strength is gated on the CPU by how close\n     the cursor sits to the ring, so it never reads as a dot in open space. */\n  for (int i = 0; i < ${MAX_FRAME_BLOBS}; i++) {\n    if (i >= uBlobCount) break;\n    vec4 b = uBlobs[i];\n    float disc = length(p - b.xy) - b.z;\n    d = mix(d, smin(d, disc, uBlobK), clamp(b.w, 0.0, 1.0));\n  }\n  return d;\n}\n\nvoid main(){\n  vec2 p = gl_FragCoord.xy;\n  float d = ringField(p);\n\n  float dCss = d / max(uDpr, 1.0);\n  float aa = max(fwidth(d), 0.75 * uDpr);\n  float alpha = 1.0 - smoothstep(-aa, aa, d);\n\n  vec2 g = vec2(dFdx(d), dFdy(d));\n  vec2 n = length(g) > 1e-5 ? normalize(g) : vec2(0.0, 1.0);\n\n  float curv = clamp(\n    (abs(dFdx(n.x)) + abs(dFdy(n.x)) + abs(dFdx(n.y)) + abs(dFdy(n.y)))\n      * uBlobK * 0.6,\n    0.0, 1.2);\n\n  float rim = smoothstep(-3.0, 0.0, dCss) * alpha;\n  float fres = pow(1.0 - abs(dot(n, vec2(0.0, 1.0))), 3.0);\n  float spec = pow(max(dot(n, normalize(vec2(0.35, 0.94))), 0.0), 18.0);\n  float hairline = exp(-pow((dCss + 0.55) / 0.9, 2.0)) * alpha;\n\n  vec2 q = p - uCenter;\n  float ang = atan(q.y, q.x) / 6.28318530718 + 0.5;\n  float arc = abs(fract(ang - uSweep + 0.5) - 0.5);\n  float sweep = exp(-pow(arc * 7.0, 2.0)) * uEnergy;\n\n  /* The exterior shell is the exact theme background, so wrapper canvases\n     disappear into the page instead of exposing tinted square corners. Only a\n     narrow band at the liquid edge receives the SULA glass material. */\n  float glassMask = smoothstep(-7.0, -0.15, dCss) * alpha * 0.82;\n  vec3 edgeGlass = mix(uTint, uAccent, 0.12);\n  vec3 glass = uBackdrop;\n  glass = mix(glass, edgeGlass, glassMask);\n  glass += uAccent * (fres * 0.22 + spec * 0.5) * (1.0 + curv * 0.8)\n             * rim * uShine * (1.0 + uEnergy * 0.9);\n  glass += uAccent * (0.16 + curv * 1.0) * hairline * uShine;\n  glass += uAccent * sweep * glassMask * 0.6 * uShine;\n  glass += vec3(0.05) * mix(0.3, 1.0, uShine) * hairline;\n\n  outColor = vec4(glass, alpha);\n}\n`;\n"
    },
    {
      "path": "border.ts",
      "target": "components/ui/border.ts",
      "type": "registry:ui",
      "content": "import { Mesh, Program, Renderer, Triangle } from \"ogl\";\nimport {\n  borderFragmentShader,\n  borderVertexShader,\n  MAX_FRAME_BLOBS,\n} from \"./border-shader\";\nimport type { FieldColors } from \"./field\";\n\n/** One animation frame of the liquid ring, already flattened to device pixels\n * with Y flipped, since `gl_FragCoord` counts up from the bottom. */\nexport interface BorderFrame {\n  /** Ring centre in device px, Y-flipped. */\n  center: [number, number];\n  /** Ring half-extents in device px. */\n  half: [number, number];\n  /** Corner radius in device px. */\n  radius: number;\n  /** Half the band width in device px. */\n  thickness: number;\n  /** Peak edge displacement in device px, already scaled by energy. */\n  wobble: number;\n  /** Focus energy 0..1: lifts the rim and drives the perimeter sweep. */\n  energy: number;\n  /** Perimeter highlight position, 0..1 around the ring. */\n  sweep: number;\n  /** Seconds since start. */\n  time: number;\n  /** Up to MAX_FRAME_BLOBS pointer discs as flat vec4s (x, y, radius, strength). */\n  blobs: number[];\n  blobCount: number;\n  /** Smooth-min radius for the pointer merge, in device px. */\n  blobK: number;\n  /** Edge-to-frame geometry reveal, 0..1. */\n  intro: number;\n}\n\nexport interface BorderField {\n  resize(width: number, height: number, dpr: number): void;\n  setColors(colors: FieldColors): void;\n  draw(frame: BorderFrame): void;\n  dispose(): void;\n}\n\nexport interface CreateBorderFieldOptions {\n  canvas: HTMLCanvasElement;\n  colors: FieldColors;\n  onContextLost?: () => void;\n}\n\n/**\n * A second small program in sula-core, parallel to `createField`. It shares the\n * colour and lighting helpers (`FieldColors`, the accent rim vocabulary) so the\n * fill and the frame can never drift in look, but renders a rounded-box ring\n * rather than a filled field.\n */\nexport function createBorderField(\n  options: CreateBorderFieldOptions,\n): BorderField | null {\n  const { canvas, colors, onContextLost } = options;\n\n  let renderer: Renderer;\n  try {\n    renderer = new Renderer({\n      canvas,\n      webgl: 2,\n      dpr: 1,\n      alpha: true,\n      premultipliedAlpha: false,\n      antialias: false,\n    });\n  } catch {\n    return null;\n  }\n\n  const gl = renderer.gl;\n  if (!(\"drawBuffers\" in gl)) return null;\n  gl.clearColor(0, 0, 0, 0);\n\n  const program = new Program(gl, {\n    vertex: borderVertexShader,\n    fragment: borderFragmentShader,\n    transparent: true,\n    depthTest: false,\n    uniforms: {\n      uTime: { value: 0 },\n      uCenter: { value: [0, 0] },\n      uHalf: { value: [0, 0] },\n      uRadius: { value: 0 },\n      uThickness: { value: 1 },\n      uWobble: { value: 0 },\n      uEnergy: { value: 0 },\n      uSweep: { value: 0 },\n      uDpr: { value: 1 },\n      uBlobCount: { value: 0 },\n      uBlobs: { value: new Array<number>(MAX_FRAME_BLOBS * 4).fill(0) },\n      uBlobK: { value: 14 },\n      uTint: { value: colors.tint },\n      uBackdrop: { value: colors.backdrop },\n      uAccent: { value: colors.accent },\n      uShine: { value: colors.shine },\n      uIntro: { value: 1 },\n    },\n  });\n  const mesh = new Mesh(gl, { geometry: new Triangle(gl), program });\n  const u = program.uniforms;\n\n  const handleLost = (event: Event) => {\n    event.preventDefault();\n    onContextLost?.();\n  };\n  canvas.addEventListener(\"webglcontextlost\", handleLost);\n\n  return {\n    resize(width, height, dpr) {\n      u.uDpr.value = dpr;\n      renderer.dpr = dpr;\n      renderer.setSize(width, height);\n    },\n    setColors(next) {\n      u.uTint.value = next.tint;\n      u.uBackdrop.value = next.backdrop;\n      u.uAccent.value = next.accent;\n      u.uShine.value = next.shine;\n    },\n    draw(frame) {\n      u.uTime.value = frame.time;\n      u.uCenter.value = frame.center;\n      u.uHalf.value = frame.half;\n      u.uRadius.value = frame.radius;\n      u.uThickness.value = frame.thickness;\n      u.uWobble.value = frame.wobble;\n      u.uEnergy.value = frame.energy;\n      u.uSweep.value = frame.sweep;\n      u.uBlobCount.value = frame.blobCount;\n      u.uBlobs.value = frame.blobs;\n      u.uBlobK.value = frame.blobK;\n      u.uIntro.value = frame.intro;\n      renderer.render({ scene: mesh });\n    },\n    dispose() {\n      canvas.removeEventListener(\"webglcontextlost\", handleLost);\n      const lose = gl.getExtension(\"WEBGL_lose_context\");\n      lose?.loseContext();\n    },\n  };\n}\n"
    },
    {
      "path": "recovery.ts",
      "target": "components/ui/recovery.ts",
      "type": "registry:ui",
      "content": "\"use client\";\nimport * as React from \"react\";\n\n/** Backoff between retries, in ms. The last value repeats until attempts run out. */\nconst BACKOFF = [400, 900, 2000, 4000];\nconst MAX_ATTEMPTS = 6;\n\n/**\n * Failure state for a field, and the way back out of it.\n *\n * A lost context is not permanent: the browser takes one to satisfy a new\n * request elsewhere on the page and hands it back once the pressure drops. It\n * only makes that offer to a canvas still in the document, so a component that\n * unmounts its canvas on failure is the reason a static fallback lasts until a\n * reload. Keep the canvas mounted, hide it, and rebuild on `generation`.\n */\nexport function useContextRecovery(\n  canvasRef: React.RefObject<HTMLCanvasElement | null>,\n) {\n  const [failed, setFailed] = React.useState(false);\n  const [generation, setGeneration] = React.useState(0);\n  const attempts = React.useRef(0);\n\n  const retry = React.useCallback(() => {\n    setFailed(false);\n    setGeneration((n) => n + 1);\n  }, []);\n\n  React.useEffect(() => {\n    if (!failed) return;\n\n    const canvas = canvasRef.current;\n    const onRestored = () => {\n      attempts.current = 0;\n      retry();\n    };\n    canvas?.addEventListener(\"webglcontextrestored\", onRestored);\n\n    let timer = 0;\n    if (attempts.current < MAX_ATTEMPTS) {\n      const wait = BACKOFF[Math.min(attempts.current, BACKOFF.length - 1)];\n      attempts.current += 1;\n      timer = window.setTimeout(retry, wait);\n    }\n\n    return () => {\n      window.clearTimeout(timer);\n      canvas?.removeEventListener(\"webglcontextrestored\", onRestored);\n    };\n  }, [failed, canvasRef, retry]);\n\n  const onContextLost = React.useCallback(() => setFailed(true), []);\n\n  const onContextReady = React.useCallback(() => {\n    attempts.current = 0;\n  }, []);\n\n  return { failed, generation, onContextLost, onContextReady };\n}\n"
    },
    {
      "path": "retune.ts",
      "target": "components/ui/retune.ts",
      "type": "registry:ui",
      "content": "\"use client\";\nimport * as React from \"react\";\nimport type { FieldColors } from \"./field\";\n\nexport function useFieldRetune(\n  fieldRef: React.RefObject<{ setColors(colors: FieldColors): void } | null>,\n  read: () => FieldColors | null,\n  token: unknown,\n  wakeRef?: React.RefObject<(() => void) | null>,\n) {\n  const readRef = React.useRef(read);\n  readRef.current = read;\n\n  // biome-ignore lint/correctness/useExhaustiveDependencies: `token` is not read here, it is what decides when a retune runs\n  React.useEffect(() => {\n    const next = readRef.current();\n    if (!next) return;\n    fieldRef.current?.setColors(next);\n    wakeRef?.current?.();\n  }, [token, fieldRef, wakeRef]);\n}\n"
    }
  ]
}