{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "shdr-11",
  "type": "registry:ui",
  "title": "SHDR-11",
  "description": "A quantum-orbital orb: |psi|^2 of a hydrogen-like wave function projected onto a rotating dome, shaded with rainbow chromatic bands over dark metal. Precession and a drifting flow field keep the pattern from ever visibly looping.",
  "dependencies": [],
  "registryDependencies": [],
  "meta": {
    "renderer": "webgl"
  },
  "files": [
    {
      "path": "components/ui/orbkit-core.tsx",
      "type": "registry:ui",
      "content": "\"use client\";\n\nimport { useEffect, useRef, useState, type CSSProperties, type ReactNode } from \"react\";\n\n/* ----------------------------------------------------------------------------\n   Orbkit core — raw WebGL shader orb runtime. No dependencies.\n\n   An orb is a full-screen triangle rendered into a transparent canvas by a\n   fragment shader. Every orb declares a parameter schema (sliders + colors);\n   the values are uploaded as uniforms each frame from a ref, so a controls\n   panel can tune them live without ever remounting the canvas (a remount would\n   drop the WebGL context).\n\n   Animation model: every state synthesizes two volume signals — input (user\n   speech energy) and output (agent speech energy) — smooths them, and the\n   shaders react to those. The flow clock's speed itself follows the output\n   volume, so orbs visibly quicken when the agent is talking.\n---------------------------------------------------------------------------- */\n\nexport type OrbState = \"idle\" | \"thinking\" | \"speaking\";\n\nexport const ORB_STATES = [\"idle\", \"thinking\", \"speaking\"] as const;\n\nfunction clamp01(n: number) {\n  return Math.min(1, Math.max(0, n));\n}\n\n/** Per-state [input, output] volume synthesis. */\n/**\n * Transition rate shared by params, colours and the flow-speed multiplier.\n * They must move together: if the rate multiplier eases faster than the look,\n * a state change spins the orb up before it has finished cross-fading, which\n * reads as a lurch.\n *\n * This drives a critically damped spring rather than the exponential ease it\n * used to. An exponential's velocity is highest at the instant the target\n * changes, so every state change began with a jolt — and for params that are\n * spatial frequencies (Corona's warpFreq travels 5.25 -> 19.5 between states)\n * that jolt sweeps the field through its intermediate frequencies at maximum\n * rate, which is what read as the transition \"scrambling\".\n *\n * A spring starts at rest and accelerates, so the sweep is spread across the\n * transition instead of front-loaded. Measured on that warpFreq move it is a\n * 20% lower peak rate of change (20.3/s vs 25.3/s) AND it arrives sooner —\n * 1.50s to within 2% against the exponential's 2.18s, since an exponential\n * only ever asymptotes toward its target.\n */\nconst PARAM_EASE = 4;\n\n/*\n  One step of a critically damped spring, implicit (semi-implicit Euler would\n  blow up at the frame times a backgrounded tab produces). Returns nothing and\n  writes through the scratch pair so the hot loop allocates nothing.\n*/\nconst springOut = { x: 0, v: 0 };\nfunction springStep(x: number, v: number, target: number, dt: number, omega: number) {\n  const f = 1 + 2 * dt * omega;\n  const oo = omega * omega;\n  const hoo = dt * oo;\n  const hhoo = dt * hoo;\n  const detInv = 1 / (f + hhoo);\n  springOut.x = (f * x + dt * v + hhoo * target) * detInv;\n  springOut.v = (v + hoo * (target - x)) * detInv;\n}\n\nfunction targetVolumes(state: OrbState, t: number): [number, number] {\n  switch (state) {\n    case \"idle\":\n      return [0, 0.3];\n    case \"speaking\":\n      return [\n        clamp01(0.65 + Math.sin(t * 4.8) * 0.22),\n        clamp01(0.75 + Math.sin(t * 3.6) * 0.22)\n      ];\n    case \"thinking\": {\n      const base = 0.38 + 0.07 * Math.sin(t * 0.7);\n      const wander = 0.05 * Math.sin(t * 2.1) * Math.sin(t * 0.37 + 1.2);\n      return [clamp01(base + wander), clamp01(0.48 + 0.12 * Math.sin(t * 1.05 + 0.6))];\n    }\n  }\n}\n\n/* ------------------------------ param schema ------------------------------- */\n\nexport interface OrbParamDef {\n  key: string;\n  label: string;\n  min: number;\n  max: number;\n  step: number;\n  default: number;\n  /**\n   * Rate params. The engine integrates them into a clock\n   * (`clock += dt * value * volumeSpeed`) and uploads the clock instead of the\n   * raw value, so changing the rate never jumps the phase — the motion speeds\n   * up or slows down rather than snapping to a new position.\n   */\n  integrate?: boolean;\n}\n\nexport interface OrbColorDef {\n  key: string;\n  label: string;\n  /** hex, e.g. `#ff8b73` */\n  default: string;\n}\n\nexport interface OrbVariant {\n  key: string;\n  label: string;\n  note: string;\n  /** GLSL fragment shader body. Uniform declarations are generated for you. */\n  frag: string;\n  params: OrbParamDef[];\n  colors: OrbColorDef[];\n  /**\n   * Per-state parameter targets. The engine glides each param toward the\n   * active state's preset. Params passed explicitly via the `params` prop\n   * always win over the preset.\n   */\n  statePresets?: Partial<Record<OrbState, Record<string, number>>>;\n  /**\n   * Per-state colour targets, the colour counterpart of `statePresets`.\n   * Kept a separate map because presets are numeric and colours are hex\n   * strings — a union would lose type safety on both. Colours glide in RGB\n   * on the same easing as params, so a state change cross-fades rather\n   * than cutting. Colours passed explicitly via the `colors` prop always\n   * win, exactly as with params.\n   */\n  stateColors?: Partial<Record<OrbState, Record<string, string>>>;\n}\n\nexport type OrbParamValues = Partial<Record<string, number>>;\nexport type OrbColorValues = Partial<Record<string, string>>;\n\n/** Every param and color at its schema default. */\nexport function defaultValuesFor(variant: OrbVariant): {\n  params: Record<string, number>;\n  colors: Record<string, string>;\n} {\n  return {\n    params: Object.fromEntries(variant.params.map((p) => [p.key, p.default])),\n    colors: Object.fromEntries(variant.colors.map((c) => [c.key, c.default]))\n  };\n}\n\nexport function hexToRgb(hex: string): [number, number, number] {\n  let h = hex.replace(\"#\", \"\").trim();\n  if (h.length === 3) h = h[0] + h[0] + h[1] + h[1] + h[2] + h[2];\n  const n = parseInt(h, 16);\n  if (h.length !== 6 || Number.isNaN(n)) return [1, 1, 1];\n  return [((n >> 16) & 255) / 255, ((n >> 8) & 255) / 255, (n & 255) / 255];\n}\n\n/* ------------------------------- GLSL shared ------------------------------- */\n\nconst VERT = `\nattribute vec2 aPos;\nvoid main() { gl_Position = vec4(aPos, 0.0, 1.0); }\n`;\n\n/**\n * Prelude prepended to every orb fragment shader: uniforms, value noise, fbm,\n * and the centered aspect-corrected UV helper.\n */\nexport const ORB_GLSL_HELPERS = `\nprecision highp float;\nuniform vec2 uRes;\nuniform float uTime;   // slow ambient clock (half real-time)\nuniform float uAnim;   // flow clock — its speed follows the output volume\nuniform float uInput;  // input volume 0..1: user speech energy\nuniform float uOutput; // output volume 0..1: agent speech energy\n\nfloat hash(vec2 p) { return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453123); }\nfloat noise(vec2 p) {\n  vec2 i = floor(p);\n  vec2 f = fract(p);\n  f = f * f * (3.0 - 2.0 * f);\n  return mix(\n    mix(hash(i), hash(i + vec2(1.0, 0.0)), f.x),\n    mix(hash(i + vec2(0.0, 1.0)), hash(i + vec2(1.0, 1.0)), f.x),\n    f.y\n  );\n}\nfloat fbm(vec2 p) {\n  float v = 0.0;\n  float a = 0.5;\n  for (int i = 0; i < 5; i++) {\n    v += a * noise(p);\n    p = p * 2.03 + vec2(11.7, 7.3);\n    a *= 0.5;\n  }\n  return v;\n}\nvec2 orbUV() { return (2.0 * gl_FragCoord.xy - uRes) / min(uRes.x, uRes.y); }\n\n// GLSL ES 1.0 has no tanh() — it arrived in ES 3.0. Shader-golf listings lean\n// on it as a tone-mapper, so it ships here. Clamped against exp() overflow;\n// accurate for the non-negative accumulators those shaders produce.\nvec3 tanh3(vec3 x) {\n  x = clamp(x, -10.0, 10.0);\n  vec3 e = exp(2.0 * x);\n  return (e - 1.0) / (e + 1.0);\n}\n\n`;\n\nfunction paramUniformDecls(variant: OrbVariant): string {\n  return [\n    ...variant.params.map((p) => `uniform float uP_${p.key};`),\n    ...variant.colors.map((c) => `uniform vec3 uC_${c.key};`)\n  ].join(\"\\n\");\n}\n\n/* ------------------------------- engine ------------------------------------ */\n\n/**\n * Per-canvas context-lifecycle controller. Created on first mount of a canvas\n * and kept for the element's whole life — the router can hide a page and show\n * the same DOM again, and React re-runs effects on the same canvas, so the\n * lost/restored listeners must outlive any single effect run: an uncanceled\n * webglcontextlost event marks the context permanently unrestorable.\n */\ninterface CanvasContextController {\n  /** Whether a mounted orb currently wants this context alive. */\n  desired: boolean;\n  /** Builds a render generation; returns its teardown. Rebound per effect run. */\n  start: (() => () => void) | null;\n  /** Teardown of the live generation, if one is running. */\n  stopGen: (() => void) | null;\n}\n\nconst canvasControllers = new WeakMap<HTMLCanvasElement, CanvasContextController>();\n\nfunction compile(gl: WebGLRenderingContext, type: number, src: string): WebGLShader | null {\n  const shader = gl.createShader(type);\n  if (!shader) return null;\n  gl.shaderSource(shader, src);\n  gl.compileShader(shader);\n  if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {\n    console.error(\"[orbkit] shader compile error:\", gl.getShaderInfoLog(shader));\n    gl.deleteShader(shader);\n    return null;\n  }\n  return shader;\n}\n\n/* ----------------------------------------------------------------------------\n   Wrappers — optional decoration drawn around, under and over the orb.\n\n   A wrapper is pure CSS/SVG, never a shader: the orb keeps its own canvas and\n   the decoration is composited on top of it by the browser. That keeps the\n   whole set free for any orb (no per-variant shader work), costs nothing on\n   the GPU budget the shaders are already spending, and means swapping one\n   wrapper for another at runtime never touches the WebGL context. Turning a\n   wrapper on or off does, since that is what moves the canvas into or out of\n   the wrapper element — see the effect's `wrapped` dependency.\n\n   Layout: a wrapped orb becomes a square box, and the canvas is absolutely\n   positioned inside it with the spec's `inset`. The FOOTPRINT is unchanged —\n   `size` is still the outer diameter — so dropping a wrapper onto an existing\n   orb reflows nothing; the orb itself just shrinks to leave the ring room.\n\n   Colour: every layer that isn't physically light or shadow paints in\n   `currentColor`, so a wrapper picks up the surrounding text colour and reads\n   correctly on light and dark pages with no configuration. `wrapperColor`\n   sets that colour when you want something other than the inherited one.\n---------------------------------------------------------------------------- */\n\nexport const ORB_WRAPPERS = [\n  \"none\",\n  \"glass\",\n  \"ring\",\n  \"dotted\",\n  \"ticks\",\n  \"reticle\",\n  \"grid\",\n  \"halftone\",\n  \"scanlines\"\n] as const;\n\nexport type OrbWrapper = (typeof ORB_WRAPPERS)[number];\n\n/*\n  Keyframes for the animated wrappers, shipped inside the component so an orb\n  stays a single self-contained file with nothing to add to a global\n  stylesheet. React 19 hoists a <style href precedence> into <head> and\n  de-duplicates it, so a page full of wrapped orbs emits this exactly once;\n  older React renders it inline, which is redundant but harmless.\n\n  Reduced motion parks all of it. The shader runtime already honours the same\n  preference for the orb itself (see the reduce-motion branch in the render\n  loop), and a ring that keeps spinning around a frozen orb would be the worse\n  half of the two still moving.\n*/\nconst WRAPPER_STYLE_HREF = \"orbkit-wrapper\";\nconst WRAPPER_CSS = `\n@keyframes orbkit-w-spin { to { transform: rotate(360deg); } }\n@keyframes orbkit-w-roll { from { transform: translateY(-110%); } to { transform: translateY(360%); } }\n@media (prefers-reduced-motion: reduce) {\n  .orbkit-w-anim { animation: none !important; }\n}\n`;\n\ninterface WrapperSpec {\n  /**\n   * How far the canvas sits inside the box, in percent, leaving the\n   * decoration room. Applied as explicit width/height rather than as `inset`:\n   * a canvas is a REPLACED element, so an absolutely positioned one with\n   * `left` and `right` both set does not stretch between them — it keeps its\n   * intrinsic size and the over-constrained edge is dropped. The orb would\n   * then be drawn into a canvas the size of the page.\n   */\n  inset: number;\n  /**\n   * Soft circular mask on the canvas. Only the wrappers that read as a\n   * CONTAINER set one — a bubble has to hold the orb, whereas a bezel sits\n   * beside it and clipping the halo there would just amputate the glow.\n   */\n  mask?: string;\n  /** Cast by the assembly as a whole, on the outer box. */\n  shadow?: string;\n\n  /** True when the spec uses one of the keyframes above. */\n  animated?: boolean;\n  /** Painted beneath the canvas. */\n  under?: ReactNode;\n  /** Painted over it. */\n  over?: ReactNode;\n}\n\nconst DISC: CSSProperties = { borderRadius: \"50%\" };\n\n/** One absolutely-positioned decoration layer, filling the wrapper box. */\nfunction Layer({\n  inset = 0,\n  style,\n  className\n}: {\n  inset?: number | string;\n  style: CSSProperties;\n  className?: string;\n}) {\n  return (\n    <span\n      aria-hidden=\"true\"\n      className={className}\n      style={{ position: \"absolute\", inset, pointerEvents: \"none\", ...style }}\n    />\n  );\n}\n\n/**\n * A free-floating highlight — glass's specular and its bounce. These do not go\n * through `Layer` because they are placed with `left`/`top`/`width`, and a\n * style object that sets those on top of `Layer`'s `inset` shorthand is mixing\n * shorthand and longhand for the same property: React warns about it, and the\n * result depends on key order rather than on anything you would want to rely\n * on.\n */\nfunction Highlight({ style }: { style: CSSProperties }) {\n  return (\n    <span\n      aria-hidden=\"true\"\n      style={{ position: \"absolute\", borderRadius: \"50%\", pointerEvents: \"none\", ...style }}\n    />\n  );\n}\n\n/**\n * The mask that makes a wrapper HOLD the orb: cuts the canvas back to the\n * bubble and stops a hairline short of the rim, so the orb never quite touches\n * the glass.\n *\n * The percentage has to be derived rather than written down. `closest-side`\n * measures the CANVAS, and a wrapper with a negative inset draws its canvas\n * LARGER than the bubble — at -7 the canvas is 114% of the box, so the rim\n * sits at 100/1.14 = 87.7% of the canvas's own radius, not at 100%.\n *\n * The gap is real pixels rather than a share of the size: it reads as the same\n * band at 120px and at 900px, which a percentage would not.\n */\nconst RIM_GAP_PX = 4;\n\nfunction rimMask(inset: number): string {\n  const rim = (100 / (1 - (2 * inset) / 100)).toFixed(2);\n  // Feathered over the final pixel, so the cut is not a razor edge.\n  const solid = RIM_GAP_PX + 0.5;\n  const clear = RIM_GAP_PX - 0.5;\n  return `radial-gradient(circle closest-side, #000 calc(${rim}% - ${solid}px), rgba(0,0,0,0) calc(${rim}% - ${clear}px))`;\n}\n\n/** Both spellings, since Safari still wants the prefix for mask-image. */\nfunction masked(image: string): CSSProperties {\n  return { WebkitMaskImage: image, maskImage: image };\n}\n\nconst svgLayer: CSSProperties = {\n  position: \"absolute\",\n  inset: 0,\n  width: \"100%\",\n  height: \"100%\",\n  pointerEvents: \"none\",\n  overflow: \"visible\"\n};\n\n/** Glass's overfill, shared by its inset and the mask derived from it. */\nconst GLASS_INSET = -4;\n\nconst WRAPPER_SPECS: Record<Exclude<OrbWrapper, \"none\">, WrapperSpec> = {\n  /*\n    glass — a blown bubble with the orb suspended inside it.\n\n    Five layers in the order light actually arrives: the body brightening\n    toward the key light, the Fresnel ring where a sphere's edge turns almost\n    edge-on and reflects nearly everything, the rim itself, the window\n    reflection, and the bounce coming back up off whatever the bubble is\n    sitting on. All of it is white and black rather than `currentColor` —\n    glass has no colour of its own, only the light it moves around.\n  */\n  glass: {\n    /*\n      Negative on purpose. An orb's shader does not necessarily paint to the\n      edge of its canvas — most draw a sphere with transparent margin around\n      it — so a canvas sized to the bubble leaves a dead ring between the orb\n      and the rim, which is not what a thing suspended in glass looks like.\n      Oversizing the canvas by 14% pushes the sphere out to the rim, and\n      `rimMask` cuts whatever overflows — a few pixels short of the glass, so\n      the orb sits just inside it rather than welded to it. Orbs that already fill\n      their canvas lose a few percent off the limb, which is the same crop the\n      reference bubble makes.\n    */\n    inset: GLASS_INSET,\n    mask: rimMask(GLASS_INSET),\n    shadow: \"0 24px 48px -26px rgba(0,0,0,0.55)\",\n    over: (\n      <>\n        <Layer\n          style={{\n            ...DISC,\n            background:\n              \"radial-gradient(ellipse 80% 70% at 28% 20%, rgba(255,255,255,0.16), rgba(255,255,255,0.03) 45%, rgba(255,255,255,0) 72%)\"\n          }}\n        />\n        <Layer\n          style={{\n            ...DISC,\n            background:\n              \"radial-gradient(circle closest-side, rgba(255,255,255,0) 0%, rgba(255,255,255,0.0) 55%, rgba(255,255,255,0.05) 99.5%, rgba(255,255,255,0) 100%)\",\n              overflow: 'hidden'\n          }}\n        />\n        <Layer\n          style={{\n            ...DISC,\n           boxShadow: '3px 6px 10px #ffffff20 inset'\n          }}\n        />\n        {/*\n          The shell's own darkening, just inside the rim. Invisible on a dark\n          page — it is black over black — and doing all the work on a light\n          one, where the white highlights below have nothing to stand out\n          against and the bubble would otherwise read as a bare drop shadow.\n        */}\n        <Layer\n          style={{\n            ...DISC,\n            background:\n              \"radial-gradient(circle closest-side, rgba(0,0,0,0) 78%, rgba(0,0,0,0.05) 93%, rgba(0,0,0,0.02) 100%)\"\n          }}\n        />\n        <Layer\n          style={{\n            ...DISC,\n            boxShadow:\n              \"inset 0 6px 12px -7px rgba(255,255,255,0.05), inset 0 -9px 16px -9px rgba(255,255,255,0.1), 0 0 0 1px rgba(0,0,0,0.07)\"\n          }}\n        />\n        <Highlight\n          style={{\n            left: \"15%\",\n            top: \"10%\",\n            width: \"38%\",\n            height: \"22%\",\n            transform: \"rotate(-25deg)\",\n            background:\n              \"radial-gradient(closest-side, rgba(255,255,255,0.9), rgba(255,255,255,0.3) 55%, rgba(255,255,255,0) 100%)\",\n              filter: 'blur(10px)'\n          }}\n        />\n       \n      </>\n    )\n  },\n\n  /* ring — two hairlines and nothing else. The restrained one. */\n  ring: {\n    inset: 9,\n    over: (\n      <>\n        <Layer style={{ ...DISC, border: \"1px solid currentColor\", opacity: 0.22 }} />\n        <Layer inset=\"5%\" style={{ ...DISC, border: \"1px solid currentColor\", opacity: 0.1 }} />\n      </>\n    )\n  },\n\n  /*\n    dotted — evenly spaced dots around the circumference, turning slowly.\n\n    Drawn as one dashed circle with round caps and a near-zero dash length, so\n    each dash collapses to a dot. `pathLength=\"64\"` renormalizes the path to 64\n    units first, which is what makes the count exact: the dash period is\n    literally 1/64th of the circle, so the pattern closes on itself with no\n    seam where the last gap would otherwise be short.\n  */\n  dotted: {\n    inset: 10,\n    animated: true,\n    over: (\n      <svg\n        aria-hidden=\"true\"\n        viewBox=\"0 0 100 100\"\n        className=\"orbkit-w-anim\"\n        style={{ ...svgLayer, animation: \"orbkit-w-spin 48s linear infinite\" }}\n      >\n        <circle\n          cx=\"50\"\n          cy=\"50\"\n          r=\"47\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeWidth=\"1.7\"\n          strokeLinecap=\"round\"\n          pathLength={64}\n          strokeDasharray=\"0.0001 0.9999\"\n          opacity={0.45}\n        />\n      </svg>\n    )\n  },\n\n  /*\n    ticks — an instrument bezel: a fine minor scale every 6 degrees with a\n    longer major tick every 30. Both are one repeating conic gradient masked\n    down to an annulus, so the tick count is set by the gradient's period and\n    the tick LENGTH by how far in the mask reaches.\n  */\n  ticks: {\n    inset: 12,\n    over: (\n      <>\n        <Layer\n          style={{\n            ...DISC,\n            opacity: 0.32,\n            background:\n              \"repeating-conic-gradient(from -0.5deg, transparent 0deg 0.2deg, currentColor 0.4deg 0.6deg, transparent 0.8deg 6deg)\",\n            ...masked(\n              \"radial-gradient(circle closest-side, transparent 88%, #000 90%, #000 97%, transparent 99%)\"\n            )\n          }}\n        />\n        <Layer\n          style={{\n            ...DISC,\n            opacity: 0.6,\n            background:\n              \"repeating-conic-gradient(from -0.75deg, transparent 0deg 0.25deg, currentColor 0.5deg 1deg, transparent 1.25deg 30deg)\",\n            ...masked(\n              \"radial-gradient(circle closest-side, transparent 80%, #000 82%, #000 97%, transparent 99%)\"\n            )\n          }}\n        />\n        <Layer style={{ ...DISC, border: \"1px solid currentColor\", opacity: 0.12 }} />\n      </>\n    )\n  },\n\n  /* reticle — viewfinder furniture: corner brackets, cardinal ticks, a track. */\n  reticle: {\n    inset: 13,\n    over: (\n      <svg aria-hidden=\"true\" viewBox=\"0 0 100 100\" style={svgLayer}>\n        <g fill=\"none\" stroke=\"currentColor\" strokeWidth=\"1.2\" opacity=\"0.5\">\n          <path d=\"M1 13 L1 1 L13 1\" />\n          <path d=\"M87 1 L99 1 L99 13\" />\n          <path d=\"M99 87 L99 99 L87 99\" />\n          <path d=\"M13 99 L1 99 L1 87\" />\n        </g>\n        <g fill=\"none\" stroke=\"currentColor\" strokeWidth=\"1\" opacity=\"0.38\">\n          <path d=\"M50 1 L50 9\" />\n          <path d=\"M50 91 L50 99\" />\n          <path d=\"M1 50 L9 50\" />\n          <path d=\"M91 50 L99 50\" />\n        </g>\n        <circle\n          cx=\"50\"\n          cy=\"50\"\n          r=\"46\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeWidth=\"0.7\"\n          opacity=\"0.22\"\n        />\n      </svg>\n    )\n  },\n\n  /*\n    grid — a graticule laid over the orb and masked back to the disc, so the\n    mesh appears to be etched on the glass in front of it rather than drawn on\n    the page behind. The lines are 1px whatever the size; the SPACING is a\n    percentage, so the cell count holds from a gallery thumbnail to a\n    full-bleed hero.\n  */\n  grid: {\n    inset: 8,\n    /*\n      The ruling sits UNDER the canvas: graph paper the orb rests on, not a\n      mesh laid over its face. Drawn on top it crosshatched the shader — the\n      one thing the wrapper is meant to frame. The ring stays over, since it\n      only ever meets the transparent margin at the canvas edge.\n    */\n    under: (\n      <Layer\n        style={{\n          ...DISC,\n          opacity: 0.18,\n          backgroundImage:\n            \"repeating-linear-gradient(to right, currentColor 0 1px, transparent 1px 12.5%), repeating-linear-gradient(to bottom, currentColor 0 1px, transparent 1px 12.5%)\",\n          ...masked(\"radial-gradient(circle closest-side, #000 86%, rgba(0,0,0,0) 99%)\")\n        }}\n      />\n    ),\n    over: <Layer style={{ ...DISC, border: \"1px solid currentColor\", opacity: 0.2 }} />\n  },\n\n  /*\n    halftone — a print screen over the outer band of the orb. The mask keeps\n    the middle clear, so the dots read as the image breaking up toward its\n    edge instead of a texture pasted across the whole face.\n  */\n  halftone: {\n    inset: 6,\n    over: (\n      <Layer\n        style={{\n          ...DISC,\n          opacity: 0.5,\n          backgroundImage: \"radial-gradient(currentColor 22%, transparent 24%)\",\n          backgroundSize: \"7px 7px\",\n          ...masked(\n            \"radial-gradient(circle closest-side, transparent 40%, #000 80%, #000 94%, rgba(0,0,0,0) 100%)\"\n          )\n        }}\n      />\n    )\n  },\n\n  /*\n    scanlines — a phosphor tube. Black lines rather than `currentColor`,\n    because scanlines are the UNLIT gaps between rows and stay dark whatever\n    the page is; the slow bright band rolling down is the vertical hold\n    drifting, which is the part that reads as a CRT rather than as stripes.\n  */\n  scanlines: {\n    inset: 0,\n    animated: true,\n    over: (\n      <>\n        <Layer\n          style={{\n            ...DISC,\n            backgroundImage:\n              \"repeating-linear-gradient(to bottom, rgba(0,0,0,0.45) 0 1px, rgba(0,0,0,0) 1px 3px)\",\n            ...masked(\"radial-gradient(circle closest-side, #000 84%, rgba(0,0,0,0) 100%)\")\n          }}\n        />\n        <span\n          aria-hidden=\"true\"\n          style={{\n            position: \"absolute\",\n            inset: 0,\n            borderRadius: \"50%\",\n            overflow: \"hidden\",\n            pointerEvents: \"none\"\n          }}\n        >\n          <span\n            className=\"orbkit-w-anim\"\n            style={{\n              position: \"absolute\",\n              left: 0,\n              right: 0,\n              top: 0,\n              height: \"30%\",\n              background:\n                \"linear-gradient(to bottom, rgba(255,255,255,0) 0%, rgba(255,255,255,0.07) 50%, rgba(255,255,255,0) 100%)\",\n              animation: \"orbkit-w-roll 7s linear infinite\"\n            }}\n          />\n        </span>\n        <Layer style={{ ...DISC, boxShadow: \"inset 0 0 40px -8px rgba(0,0,0,0.5)\" }} />\n      </>\n    )\n  }\n};\n\nexport interface ShaderOrbProps {\n  /** The orb definition: shader + param schema + state presets. */\n  variant: OrbVariant;\n  /** Drives the synthesized volume signals. Defaults to `\"idle\"`. */\n  state?: OrbState;\n  /** Rendered size in CSS pixels. Ignored when `className` sizes the canvas. */\n  size?: number;\n  /** Explicit param overrides. Any key present here wins over the state preset. */\n  params?: OrbParamValues;\n  /** Explicit color overrides, as hex strings. */\n  colors?: OrbColorValues;\n  /**\n   * Per-state parameter targets, overriding the variant's own. Merged KEY BY\n   * KEY over what the orb already defines, so `{ thinking: { churn: 1.62 } }`\n   * retouches one param of one state and leaves every other param — and the\n   * other two states — exactly as the orb ships them.\n   *\n   * This is the prop form of the variant's `statePresets`, so you can retune\n   * an orb's states from the outside without forking its file. Values still\n   * glide, so switching states cross-fades into your targets. An explicit\n   * `params` value outranks this, the same way it outranks the variant.\n   */\n  statePresets?: Partial<Record<OrbState, Record<string, number>>>;\n  /** The colour counterpart of `statePresets`, merged the same key-by-key way. */\n  stateColors?: Partial<Record<OrbState, Record<string, string>>>;\n  /**\n   * Per-state volume drive, the third member of the same family. Use it to\n   * give each state its own energy; use `volumes` below instead when you have\n   * a real signal to feed in, such as live mic level.\n   */\n  stateVolumes?: Partial<Record<OrbState, { input?: number; output?: number }>>;\n  /**\n   * Overrides the synthesized volume signals for the active state. The engine\n   * normally derives these from `state` — a slow breath at idle, a restless\n   * wander while thinking, speech-shaped peaks while speaking — and most\n   * shaders read them as their reactivity. Setting either channel here pins\n   * it instead, which is how the playground lets you dial each state's drive\n   * independently. Omit a channel to keep its synthesized motion.\n   */\n  volumes?: { input?: number; output?: number };\n  /** Freeze the animation on the current frame. */\n  paused?: boolean;\n  /**\n   * Stop rendering while the orb is scrolled out of view. Defaults to `true` —\n   * a page full of orbs would otherwise run a WebGL loop per card.\n   */\n  pauseOffscreen?: boolean;\n  /** Device-pixel-ratio ceiling. Defaults to `2`. */\n  maxDpr?: number;\n  /**\n   * Decoration drawn around the orb — a glass bubble, a dotted bezel, a\n   * viewfinder. Defaults to `\"none\"`, which renders the bare canvas exactly as\n   * it always has, with no extra element in the tree.\n   *\n   * A wrapper never changes the orb's footprint: `size` stays the outer\n   * diameter and the canvas is inset inside it, so switching one on reflows\n   * nothing around it.\n   */\n  wrapper?: OrbWrapper;\n  /**\n   * The colour a wrapper draws its lines and dots in. Defaults to\n   * `currentColor` — the inherited text colour — which is what makes the\n   * bezels legible on a light and a dark page without being told which one\n   * they are on. `glass` ignores it: glass has no colour of its own.\n   */\n  wrapperColor?: string;\n  /** Applied to the outermost element — the wrapper when there is one. */\n  className?: string;\n  /** Merged onto the outermost element's style. */\n  style?: CSSProperties;\n  /** Accessible label. When omitted the orb is hidden from assistive tech. */\n  ariaLabel?: string;\n}\n\nexport function ShaderOrb({\n  variant,\n  state = \"idle\",\n  size,\n  params,\n  colors,\n  statePresets,\n  stateColors,\n  stateVolumes,\n  volumes,\n  paused = false,\n  pauseOffscreen = true,\n  maxDpr = 2,\n  wrapper = \"none\",\n  wrapperColor,\n  className,\n  style,\n  ariaLabel\n}: ShaderOrbProps) {\n  const canvasRef = useRef<HTMLCanvasElement>(null);\n  const spec = wrapper === \"none\" ? undefined : WRAPPER_SPECS[wrapper];\n  const wrapped = spec !== undefined;\n\n  // Live refs: the render loop reads these every frame, so changing a param\n  // never re-runs the GL setup effect (which would drop the context). Synced in\n  // an effect rather than during render — a ref write during render is unsafe\n  // under concurrent rendering, and the loop picks the new value up on the very\n  // next frame anyway.\n  /*\n    Whether the canvas has drawn a frame yet, tracked per variant because a\n    variant swap mounts a brand new canvas (see the `key` below).\n\n    A mounted-but-never-drawn canvas is at the browser's mercy: rather than\n    the transparent rectangle you would expect, a page that mounts a dozen at\n    once gets white boxes — and in some browsers a broken-image placeholder —\n    for as long as the compositor has nothing to raster. That window is not\n    small here: every orb compiles a full fragment shader synchronously in its\n    own mount effect, so on the gallery grid the first canvases sit empty\n    while the last ones are still compiling. Holding each canvas invisible\n    until its own first frame lands is what makes the grid fade in cleanly\n    instead of flashing. One state change per orb, once, on mount.\n  */\n  const [paintedKey, setPaintedKey] = useState<string | null>(null);\n  const painted = paintedKey === variant.key;\n\n  const stateRef = useRef<OrbState>(state);\n  const paramsRef = useRef<OrbParamValues | undefined>(params);\n  const colorsRef = useRef<OrbColorValues | undefined>(colors);\n  const statePresetsRef = useRef(statePresets);\n  const stateColorsRef = useRef(stateColors);\n  const stateVolumesRef = useRef(stateVolumes);\n  const volumesRef = useRef(volumes);\n  const pausedRef = useRef(paused);\n\n  useEffect(() => {\n    stateRef.current = state;\n    paramsRef.current = params;\n    colorsRef.current = colors;\n    statePresetsRef.current = statePresets;\n    stateColorsRef.current = stateColors;\n    stateVolumesRef.current = stateVolumes;\n    volumesRef.current = volumes;\n    pausedRef.current = paused;\n  }, [state, params, colors, statePresets, stateColors, stateVolumes, volumes, paused]);\n\n  useEffect(() => {\n    const canvas = canvasRef.current;\n    if (!canvas) return;\n\n    const gl = canvas.getContext(\"webgl\", {\n      alpha: true,\n      // No MSAA: the geometry is a single full-screen triangle, so there are no\n      // primitive edges to antialias — softness comes from the shaders. Leaving\n      // it on costs the multisample buffers plus a resolve every frame.\n      antialias: false,\n      premultipliedAlpha: true\n    });\n    if (!gl) return;\n\n    const loseExt = gl.getExtension(\"WEBGL_lose_context\");\n\n    /*\n      A \"generation\" is everything tied to a live context: program, buffers,\n      observers, render loop. Browsers cap live WebGL contexts per page and\n      evict the oldest past the cap, and an evicted orb's canvas stays blank\n      forever unless the app rebuilds — so generations tear down and rebuild on\n      the lost/restored events instead of assuming the context is immortal.\n    */\n    let announcedPaint = false;\n\n    const startGeneration = (): (() => void) => {\n      if (gl.isContextLost()) return () => {};\n      // Every generation announces its own first frame: a context that was\n      // lost and restored has an empty drawing buffer and is hidden again\n      // (below), so it has to earn its reveal back.\n      announcedPaint = false;\n\n      const vs = compile(gl, gl.VERTEX_SHADER, VERT);\n      const fs = compile(\n        gl,\n        gl.FRAGMENT_SHADER,\n        ORB_GLSL_HELPERS + paramUniformDecls(variant) + variant.frag\n      );\n      const releaseShaders = () => {\n        if (vs) gl.deleteShader(vs);\n        if (fs) gl.deleteShader(fs);\n      };\n      if (!vs || !fs) return releaseShaders;\n\n      const prog = gl.createProgram();\n      if (!prog) return releaseShaders;\n      gl.attachShader(prog, vs);\n      gl.attachShader(prog, fs);\n      gl.linkProgram(prog);\n      if (!gl.getProgramParameter(prog, gl.LINK_STATUS)) {\n        console.error(\"[orbkit] program link error:\", gl.getProgramInfoLog(prog));\n        gl.deleteProgram(prog);\n        return releaseShaders;\n      }\n      gl.useProgram(prog);\n\n      const buf = gl.createBuffer();\n      gl.bindBuffer(gl.ARRAY_BUFFER, buf);\n      gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 3, -1, -1, 3]), gl.STATIC_DRAW);\n      const aPos = gl.getAttribLocation(prog, \"aPos\");\n      gl.enableVertexAttribArray(aPos);\n      gl.vertexAttribPointer(aPos, 2, gl.FLOAT, false, 0, 0);\n\n      gl.enable(gl.BLEND);\n      gl.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA);\n\n      const uRes = gl.getUniformLocation(prog, \"uRes\");\n      const uTime = gl.getUniformLocation(prog, \"uTime\");\n      const uAnim = gl.getUniformLocation(prog, \"uAnim\");\n      const uInput = gl.getUniformLocation(prog, \"uInput\");\n      const uOutput = gl.getUniformLocation(prog, \"uOutput\");\n\n      const paramLocs = variant.params.map((p) => ({\n        def: p,\n        loc: gl.getUniformLocation(prog, `uP_${p.key}`)\n      }));\n      const colorLocs = variant.colors.map((c) => ({\n        def: c,\n        loc: gl.getUniformLocation(prog, `uC_${c.key}`)\n      }));\n\n      /* --- sizing: track the element box, not a one-shot measurement ------- */\n      // Backing-store scale, stepped down by the adaptive-resolution logic in\n      // the loop when the GPU can't hold frame rate. CSS size never changes —\n      // the browser upscales, which these soft shaders absorb gracefully.\n      let resScale = 1;\n      const resize = () => {\n        const dpr = Math.min(window.devicePixelRatio || 1, maxDpr) * resScale;\n        const w = Math.max(1, Math.round(canvas.clientWidth * dpr));\n        const h = Math.max(1, Math.round(canvas.clientHeight * dpr));\n        if (canvas.width !== w || canvas.height !== h) {\n          canvas.width = w;\n          canvas.height = h;\n          gl.viewport(0, 0, w, h);\n        }\n        /*\n          Uploaded UNCONDITIONALLY, outside the size guard. A rebuilt\n          generation (React strict-mode remount, a restored context) links a\n          fresh program whose uRes starts at zero — and the canvas usually\n          already holds the right backing size, so an upload gated behind\n          the resize never ran. With uRes = 0, orbUV() divides by zero and\n          every fragment lands transparent: a healthy context, a bound\n          program, and a permanently blank orb.\n        */\n        gl.uniform2f(uRes, w, h);\n      };\n      resize();\n\n      const resizeObserver =\n        typeof ResizeObserver !== \"undefined\" ? new ResizeObserver(resize) : null;\n      resizeObserver?.observe(canvas);\n\n      /* --- visibility: don't burn a render loop on an offscreen orb -------- */\n      let visible = !pauseOffscreen;\n      const intersectionObserver =\n        pauseOffscreen && typeof IntersectionObserver !== \"undefined\"\n          ? new IntersectionObserver(\n              (entries) => {\n                visible = Boolean(entries[0]?.isIntersecting);\n                if (visible) {\n                  last = performance.now() / 1000;\n                }\n              },\n              { rootMargin: \"150px 0px\", threshold: 0 }\n            )\n          : null;\n      if (intersectionObserver) {\n        intersectionObserver.observe(canvas);\n      } else {\n        visible = true;\n      }\n\n      const reduceMotion =\n        typeof window.matchMedia === \"function\" &&\n        window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches;\n\n      /* --- driver state ---------------------------------------------------- */\n      let tSec = 0;\n      // random phase so two orbs on the same page never look synchronized\n      let anim = Math.random() * 100;\n      let speed = 0.1;\n      const cur = { in: 0, out: 0.3 };\n      const presets = variant.statePresets;\n      const paramCur: Record<string, number> = {};\n      const paramVel: Record<string, number> = {};\n      const paramClocks: Record<string, number> = {};\n      const colorCur: Record<string, [number, number, number]> = {};\n      const colorVel: Record<string, [number, number, number]> = {};\n      let speedVel = 0;\n      const [initialIn, initialOut] = targetVolumes(stateRef.current, 0);\n      cur.in = initialIn;\n      cur.out = initialOut;\n      let last = performance.now() / 1000;\n      let raf = 0;\n      // smoothed frame time for the adaptive-resolution check\n      let frameEma = 1 / 60;\n\n      const uploadAndDraw = (dt: number, snap = false) => {\n        // Synthesized from the state, unless a channel is pinned via\n        // `volumes`. Pinned values still glide on the same easing, so dialing\n        // one in the playground cross-fades rather than jumping.\n        const [tin, tout] = targetVolumes(stateRef.current, tSec);\n        // Same order as params and colours: direct prop, then the per-state\n        // map, then the engine's own synthesis.\n        const liveVolumes = volumesRef.current;\n        const stateVolume = stateVolumesRef.current?.[stateRef.current];\n        const targetIn = liveVolumes?.input ?? stateVolume?.input ?? tin;\n        const targetOut = liveVolumes?.output ?? stateVolume?.output ?? tout;\n        const kVol = 1 - Math.exp(-dt * 12);\n        cur.in += (targetIn - cur.in) * kVol;\n        cur.out += (targetOut - cur.out) * kVol;\n\n        /*\n          Flow speed follows the output volume. It multiplies every integrated\n          clock's increment, so it is a RATE: easing it quickly makes the orb\n          visibly lurch — a state change would spin the orb up hard before the\n          params had finished gliding.\n\n          It is therefore eased on the same constant as the params below, so a\n          state change ramps its motion over the same half second that its look\n          takes to cross-fade. The steady-state values are unchanged, so a\n          speaking orb still flows faster than an idle one; only the transition\n          into that rate is gradual.\n        */\n        const targetSpeed = 0.1 + (1 - Math.pow(cur.out - 1, 2)) * 0.9;\n        if (snap) {\n          speed = targetSpeed;\n          speedVel = 0;\n        } else {\n          springStep(speed, speedVel, targetSpeed, dt, PARAM_EASE);\n          speed = springOut.x;\n          speedVel = springOut.v;\n        }\n        anim += dt * speed;\n\n        gl.uniform1f(uTime, tSec * 0.5);\n        gl.uniform1f(uAnim, anim);\n        gl.uniform1f(uInput, cur.in);\n        gl.uniform1f(uOutput, cur.out);\n\n        // Resolution order per param: explicit `params` → `statePresets`\n        // prop → the variant's own preset → schema default. The two middle\n        // steps are per-key, so overriding one param of one state leaves the\n        // rest of that state alone. Values glide rather than snap.\n        const liveParams = paramsRef.current;\n        const statePreset = presets?.[stateRef.current];\n        const overridePreset = statePresetsRef.current?.[stateRef.current];\n\n        for (const { def, loc } of paramLocs) {\n          const explicit = liveParams?.[def.key];\n          const target =\n            typeof explicit === \"number\"\n              ? explicit\n              : (overridePreset?.[def.key] ?? statePreset?.[def.key] ?? def.default);\n          const curVal = paramCur[def.key] ?? target;\n          let next: number;\n          if (snap) {\n            next = target;\n            paramVel[def.key] = 0;\n          } else {\n            springStep(curVal, paramVel[def.key] ?? 0, target, dt, PARAM_EASE);\n            next = springOut.x;\n            paramVel[def.key] = springOut.v;\n          }\n          paramCur[def.key] = next;\n\n          if (def.integrate) {\n            const clock =\n              (paramClocks[def.key] ?? (paramClocks[def.key] = Math.random() * 100)) +\n              dt * speed * next;\n            paramClocks[def.key] = clock;\n            gl.uniform1f(loc, clock);\n          } else {\n            gl.uniform1f(loc, next);\n          }\n        }\n\n        // Same resolution order and same easing as params, so a state change\n        // cross-fades the palette instead of cutting to it.\n        const liveColors = colorsRef.current;\n        const stateColor = variant.stateColors?.[stateRef.current];\n        const overrideColor = stateColorsRef.current?.[stateRef.current];\n        for (const { def, loc } of colorLocs) {\n          const target = hexToRgb(\n            liveColors?.[def.key] ??\n              overrideColor?.[def.key] ??\n              stateColor?.[def.key] ??\n              def.default\n          );\n          const curCol = (colorCur[def.key] ??= [...target] as [number, number, number]);\n          const velCol = (colorVel[def.key] ??= [0, 0, 0]);\n          for (let i = 0; i < 3; i++) {\n            if (snap) {\n              curCol[i] = target[i];\n              velCol[i] = 0;\n            } else {\n              springStep(curCol[i], velCol[i], target[i], dt, PARAM_EASE);\n              curCol[i] = springOut.x;\n              velCol[i] = springOut.v;\n            }\n          }\n          gl.uniform3f(loc, curCol[0], curCol[1], curCol[2]);\n        }\n\n        gl.clearColor(0, 0, 0, 0);\n        gl.clear(gl.COLOR_BUFFER_BIT);\n        gl.drawArrays(gl.TRIANGLES, 0, 3);\n        // Deferred a microtask: the first of these draws runs synchronously\n        // inside this effect, and a sync setState there trips the compiler\n        // lint. A microtask still resolves before the browser paints, so the\n        // reveal is not delayed by a frame.\n        if (!announcedPaint) {\n          announcedPaint = true;\n          queueMicrotask(() => {\n            /*\n              Re-check: the context can be evicted between this draw and the\n              microtask, and mounting one more orb anywhere on the page is\n              enough to do it — opening the details drawer over a full gallery\n              is exactly that. Revealing on the strength of a frame that has\n              already been thrown away puts a dead canvas on screen, which is\n              what the browser draws its broken-canvas placeholder over. The\n              generation that follows the restore announces again.\n            */\n            if (gl.isContextLost()) return;\n            setPaintedKey(variant.key);\n          });\n        }\n      };\n\n      const releaseGL = () => {\n        resizeObserver?.disconnect();\n        intersectionObserver?.disconnect();\n        gl.deleteProgram(prog);\n        gl.deleteShader(vs);\n        gl.deleteShader(fs);\n        gl.deleteBuffer(buf);\n      };\n\n      if (reduceMotion) {\n        // One representative frame, then stop — snapped straight onto the\n        // state's targets, since a spring would only be part-way there.\n        tSec = 1;\n        uploadAndDraw(1, true);\n        return releaseGL;\n      }\n\n      const loop = () => {\n        raf = requestAnimationFrame(loop);\n        const now = performance.now() / 1000;\n        const dt = Math.min(now - last, 0.05);\n        last = now;\n        if (!visible || pausedRef.current) return;\n        tSec += dt;\n\n        /*\n          Adaptive resolution. When the smoothed frame time sits above ~30fps,\n          the GPU is drowning in fragment work (these shaders are pure fill\n          cost), so step the backing store down 20% and re-measure. Steps only\n          go down — never back up — so the resolution can't oscillate. The\n          warm-up guard keeps page-load jank (hydration, first compiles) from\n          triggering a downgrade the GPU never asked for.\n        */\n        /*\n          Only unstalled frames are evidence about GPU fill cost. `dt` above is\n          clamped at 0.05, so a frame that hits the clamp is the main thread\n          having been blocked — a slider drag re-rendering React, a GC pause, a\n          tab regaining focus — and feeding those in made UI jank look\n          identical to a drowning GPU.\n        */\n        if (dt < 0.05) frameEma += (dt - frameEma) * 0.08;\n\n        if (tSec > 1.5 && frameEma > 1 / 34 && resScale > 0.5) {\n          resScale = Math.max(0.5, resScale * 0.8);\n          frameEma = 1 / 60; // require fresh evidence before the next step\n          resize();\n        } else if (tSec > 1.5 && frameEma < 1 / 55 && resScale < 1) {\n          /*\n            And step back up once frames are comfortably fast again. This used\n            to be one-way, on the reasoning that it could not then oscillate —\n            but that also meant one transient stall permanently halved the\n            orb's resolution, and at half resolution a high-frequency shader\n            aliases into shimmer that reads as the shader itself misbehaving.\n            The gap between the two thresholds (29ms down, 18ms up) is the\n            hysteresis that stops it hunting.\n          */\n          resScale = Math.min(1, resScale / 0.8);\n          frameEma = 1 / 60;\n          resize();\n        }\n\n        uploadAndDraw(dt);\n      };\n      /*\n        First frame synchronously, before entering the rAF loop. rAF does not\n        run at all in hidden documents (background tabs, embedded previews),\n        so a freshly mounted orb would otherwise sit fully transparent until\n        the page next becomes visible — a grid of mounted, healthy, blank\n        canvases. The sync frame guarantees every mount paints: background\n        documents get a static frame, visible ones start animating over it.\n        dt = 1 lands the param glide on its targets, as in the reduce-motion\n        frame above.\n      */\n      uploadAndDraw(1);\n      loop();\n\n      return () => {\n        cancelAnimationFrame(raf);\n        releaseGL();\n      };\n    };\n\n    /*\n      Wire the canvas's lifecycle controller. The listeners are attached ONCE\n      per canvas element and never removed, deliberately: the lost event must\n      be canceled even while no orb is mounted on the canvas — an uncanceled\n      webglcontextlost marks the context permanently unrestorable, and the\n      router can show this exact canvas again later. Whether a loss leads to\n      a revival is decided by `desired`, not by listener presence.\n    */\n    /*\n      A canvas whose context has gone is not simply blank: Chrome paints its\n      broken-image placeholder over the element's whole box — the white square\n      you see on a reloaded grid. Hide the canvas the moment the context is\n      lost, and let the generation that follows a restore reveal it again.\n    */\n    const hideNow = () => {\n      /*\n        Both, deliberately. The style write lands in this tick — the browser\n        paints its placeholder over a dead canvas immediately, and a busy main\n        thread can hold a React update for several frames. The state change is\n        what keeps React's own view in sync, so the reveal that follows a\n        restore clears the inline value again rather than fighting it.\n      */\n      canvas.style.opacity = \"0\";\n      setPaintedKey(null);\n    };\n\n    const onContextLostHide = () => hideNow();\n    canvas.addEventListener(\"webglcontextlost\", onContextLostHide);\n\n    /*\n      Hand the context back before the next document asks for one.\n\n      A hard reload never runs this effect's cleanup — the document is\n      discarded whole — so the outgoing page's contexts are still alive while\n      the incoming page allocates its own. On a grid of orbs that puts the\n      live count past the browser's ~16 cap, and the ones it evicts are\n      exactly the canvases that come back as placeholders until the restore\n      path catches them. `persisted` is a bfcache suspend, where the page is\n      shown again untouched and must keep everything it holds.\n    */\n    const onPageHide = (event: PageTransitionEvent) => {\n      if (event.persisted) return;\n      try {\n        loseExt?.loseContext();\n      } catch {\n        // Already released — nothing to hand back.\n      }\n    };\n    window.addEventListener(\"pagehide\", onPageHide);\n\n    let ctl = canvasControllers.get(canvas);\n    if (!ctl) {\n      const created: CanvasContextController = { desired: false, start: null, stopGen: null };\n      canvas.addEventListener(\"webglcontextlost\", (event) => {\n        event.preventDefault(); // always cancel — keeps the context restorable\n        created.stopGen?.();\n        created.stopGen = null;\n        if (created.desired) {\n          /*\n            Ask for the context back — but in a LATER task. The browser only\n            marks a loss as restorable once the lost event's dispatch has\n            completed and it has seen the canceled flag, so a restoreContext()\n            issued during dispatch (or before it, as the mount path may) is\n            silently refused. This is the path a synchronous cleanup+setup\n            pair hits — React re-running the effect on the same canvas loses\n            the context and wants it right back. For losses we didn't cause\n            (eviction, GPU reset) the call may refuse; the canceled event then\n            lets the browser restore on its own schedule.\n          */\n          setTimeout(() => {\n            if (!created.desired) return;\n            try {\n              loseExt?.restoreContext();\n            } catch {\n              // Natural loss — restoration is the browser's call now.\n            }\n          }, 0);\n        }\n      });\n      canvas.addEventListener(\"webglcontextrestored\", () => {\n        if (created.desired && created.start) {\n          created.stopGen = created.start();\n        }\n      });\n      canvasControllers.set(canvas, created);\n      ctl = created;\n    }\n    const controller = ctl;\n\n    controller.desired = true;\n    controller.start = startGeneration;\n    if (gl.isContextLost()) {\n      // A previous run on this canvas released the context (effect re-run, or\n      // the router re-showing a kept-alive page). If the lost event already\n      // dispatched this request is honored now; if it is still queued, the\n      // lost handler above re-requests it on dispatch.\n      try {\n        loseExt?.restoreContext();\n      } catch {\n        // No restore path — the orb stays blank rather than throwing.\n      }\n    } else {\n      controller.stopGen = startGeneration();\n    }\n\n    return () => {\n      /*\n        Hide BEFORE tearing the context down.\n\n        This cleanup releases the context deliberately, and the lost event it\n        provokes is dispatched asynchronously — by which point the listener\n        below is unhooked and the replacement effect has not drawn yet. That\n        leaves a revealed canvas with a dead context, which is precisely what\n        the browser paints its broken-canvas placeholder over. The window is\n        not rare: any prop change in this effect's deps re-runs it, so it hits\n        every time the drawer switches preview example (maxDpr differs on the\n        layout one) or a wrapper is toggled.\n      */\n      hideNow();\n      canvas.removeEventListener(\"webglcontextlost\", onContextLostHide);\n      window.removeEventListener(\"pagehide\", onPageHide);\n      controller.desired = false;\n      controller.start = null;\n      controller.stopGen?.();\n      controller.stopGen = null;\n      /*\n        Release the context NOW instead of when the canvas is garbage\n        collected. Browsers cap live WebGL contexts per page (~8–16) and evict\n        the oldest when the cap is hit — client-side navigation that unmounts\n        and remounts a page of orbs otherwise piles up zombie contexts until\n        freshly mounted orbs get evicted and render blank.\n      */\n      try {\n        loseExt?.loseContext();\n      } catch {\n        // Context already lost — nothing to release.\n      }\n    };\n    /*\n      `wrapped` is in here because turning a wrapper on or off moves the canvas\n      from being this component's root element to being a child of the wrapper\n      div — React drops the old element and mounts a new one, and the GL\n      context, its observers and its render loop all belong to the old one. Any\n      other prop leaves the element alone, INCLUDING a swap between two\n      wrappers: the canvas keeps its slot among the decoration layers, so\n      glass -> ring reuses the context instead of rebuilding it.\n    */\n  }, [variant, pauseOffscreen, maxDpr, wrapped]);\n\n  const sizeStyle: CSSProperties =\n    size === undefined ? {} : { width: size, height: size };\n\n  /*\n    Spread ahead of the caller's `style`, so an orb that wants to own its own\n    opacity still can — it simply opts out of the reveal.\n\n    A hard flip, deliberately: no transition, no fade. A hidden document\n    (background tab, embedded preview) does not advance CSS transitions, so a\n    faded reveal left orbs pinned at zero in exactly the case the synchronous\n    first frame above exists to serve — mounted, healthy, and invisible. The\n    cut is not a pop either way, since it happens on the frame the orb first\n    has something to show.\n  */\n  const revealStyle: CSSProperties = painted ? {} : { opacity: 0 };\n\n  const canvas = (\n    <canvas\n      // A lost WebGL context can't be reused, so each variant gets a fresh canvas.\n      key={variant.key}\n      ref={canvasRef}\n      className={spec ? undefined : className}\n      style={\n        spec\n          ? {\n              display: \"block\",\n              position: \"absolute\",\n              left: `${spec.inset}%`,\n              top: `${spec.inset}%`,\n              width: `${100 - 2 * spec.inset}%`,\n              height: `${100 - 2 * spec.inset}%`,\n              ...revealStyle,\n              ...(spec.mask ? masked(spec.mask) : {})\n            }\n          : { display: \"block\", ...sizeStyle, ...revealStyle, ...style }\n      }\n      role={!spec && ariaLabel ? \"img\" : undefined}\n      aria-label={spec ? undefined : ariaLabel}\n      aria-hidden={!spec && ariaLabel ? undefined : true}\n    />\n  );\n\n  if (!spec) return canvas;\n\n  /*\n    Wrapped: the box becomes the orb's footprint and the canvas is absolutely\n    positioned inside it. `under`, the canvas and `over` are all positioned\n    with an auto z-index, so they paint in DOM order — decoration behind the\n    orb, then the orb, then decoration in front of it.\n\n    `aspectRatio` is the fallback for the sizeless case: `size` is optional\n    (callers may size the orb with a class instead), and an absolutely\n    positioned canvas contributes nothing to its parent's height, so without\n    it a class that only sets a width would collapse the box to zero.\n  */\n  return (\n    <div\n      className={className}\n      style={{\n        position: \"relative\",\n        aspectRatio: \"1 / 1\",\n        ...(spec.shadow ? { borderRadius: \"50%\", boxShadow: spec.shadow } : {}),\n        ...sizeStyle,\n        ...(wrapperColor ? { color: wrapperColor } : {}),\n        ...style\n      }}\n      role={ariaLabel ? \"img\" : undefined}\n      aria-label={ariaLabel}\n      aria-hidden={ariaLabel ? undefined : true}\n    >\n      {spec.animated ? (\n        <style\n          href={WRAPPER_STYLE_HREF}\n          precedence=\"default\"\n          dangerouslySetInnerHTML={{ __html: WRAPPER_CSS }}\n        />\n      ) : null}\n      {spec.under}\n      {canvas}\n      {spec.over}\n    </div>\n  );\n}\n"
    },
    {
      "path": "components/ui/shdr-11.tsx",
      "type": "registry:ui",
      "content": "/*\n * Deliberately not a `\"use client\"` module. The directive lives on the runtime\n * in `orbkit-core`, which owns the hooks; keeping it off this file lets server\n * components read `shdr11Orb` as real data (its param schema drives the docs\n * tables and the playground controls) instead of an opaque client reference.\n */\nimport {\n  ShaderOrb,\n  type OrbVariant,\n  type ShaderOrbProps\n} from \"@/components/ui/orbkit-core\";\n\n/* ----------------------------------------------------------------------------\n   SHDR-11 — the quantum-orbital orb.\n\n   Renders |psi|^2 of a hydrogen-like wave function projected onto the orb's\n   dome, shaded with rainbow chromatic bands over a dark metallic sphere.\n\n   The dome point is rotated around Y (the fake 3D of a flat disc), its spin\n   axis precesses so the pattern never settles into a visible loop, and a\n   drifting fbm field warps the 3D domain so the wave function smears and\n   migrates around the sphere instead of wobbling in place.\n---------------------------------------------------------------------------- */\n\nconst HYDROGEN_FRAG = `\nconst float PI = 3.14159265359;\nvoid main() {\n  vec2 uv = orbUV();\n  float r2d = length(uv);\n  float R = uP_radius + uP_swell * uInput;\n  float mask = smoothstep(0.012, -0.012, r2d - R);\n  float nr = clamp(r2d / max(R, 0.001), 0.0, 1.0);\n  float z = sqrt(max(1.0 - nr * nr, 0.0));\n\n  // uP_speed and uP_flowSpeed arrive pre-integrated as clocks (see\n  // OrbParamDef.integrate), so state transitions stay phase-continuous.\n  // The state volumes reshape the orbital itself: the params set the base,\n  // input/output excitement bends zoom, radial form, probability and chroma,\n  // so each state settles into a different interference pattern.\n  float posScale = uP_posScale * (0.8 + 0.45 * uOutput + 0.2 * uInput);\n  float radialPow = uP_radialPow * (0.7 + 0.8 * uOutput);\n  float radialDecay = uP_radialDecay * (1.25 - 0.5 * uOutput);\n  float probPow = uP_probPow * (1.3 - 0.55 * uOutput);\n  float probGain = uP_probGain * (0.7 + 0.6 * uOutput + 0.5 * uInput);\n  float waveFreq = uP_waveFreq * (0.6 + 1.0 * uOutput);\n  float chromaSpread = uP_chromaSpread * (0.6 + 0.9 * uOutput + 0.5 * uInput);\n\n  // dome point rotated around Y — the fake 3D of the flat disc\n  float animTime = uP_speed; // integrated clock\n  float cosT = cos(animTime * uP_rotSpeed);\n  float sinT = sin(animTime * uP_rotSpeed);\n  vec3 sp = vec3(uv / max(R, 0.001), z) * posScale;\n  vec3 pos = vec3(sp.x * cosT - sp.z * sinT, sp.y, sp.x * sinT + sp.z * cosT);\n\n  // precession: the rotation axis itself drifts, so the pattern never\n  // settles into a repeating spin\n  float tilt = sin(animTime * 0.21 + 1.7) * uP_precess;\n  float cx = cos(tilt), sx = sin(tilt);\n  pos = vec3(pos.x, pos.y * cx - pos.z * sx, pos.y * sx + pos.z * cx);\n\n  // liquid flow: drifting fbm warps the 3D domain, so the wave function\n  // smears and migrates around the sphere instead of wobbling in place.\n  // (sampled on pos components — continuous everywhere, no phi seam)\n  float flowT = uP_flowSpeed; // integrated clock\n  float fAmp = uP_flowAmp * (0.7 + 0.6 * uOutput + 0.4 * uInput);\n  vec3 w;\n  w.x = fbm(pos.yz * uP_flowScale + vec2(flowT * 0.70, -flowT * 0.40));\n  w.y = fbm(pos.zx * uP_flowScale + vec2(-flowT * 0.55, flowT * 0.62) + 3.7);\n  w.z = fbm(pos.xy * uP_flowScale + vec2(flowT * 0.50, flowT * 0.85) + 7.1);\n  pos += (w - 0.5) * fAmp;\n\n  float r = length(pos) + 0.001;\n  float theta = acos(clamp(pos.y / r, -1.0, 1.0));\n  float phi = atan(pos.z, pos.x);\n\n  float a0 = 0.5;\n  float rho = 2.0 * r / (5.0 * a0);\n  float radial = pow(rho, radialPow) * exp(-rho / radialDecay);\n  float angular = pow(sin(theta), 3.0) * cos(phi + animTime * 0.2); // single lobe\n\n  float psi = radial * angular;\n  float probability = psi * psi;\n\n  // travelling spiral wave — the modulation moves across the surface instead\n  // of pulsing in place. The azimuthal harmonic count must be a whole number,\n  // else sin(phi * f) doesn't line up across the +/-PI wrap and leaves a\n  // vertical meridian seam. Snap it to the nearest integer.\n  float waveN = max(1.0, floor(waveFreq + 0.5));\n  float wavePhase = phi * waveN + theta * 2.5 - animTime * 2.0;\n  probability *= (0.85 + 0.15 * sin(wavePhase));\n\n  // drifting bright patches, like convection cells wandering the surface\n  float patches = fbm(pos.xy * 1.6 + vec2(flowT * 0.4, -flowT * 0.3));\n  probability *= 0.65 + 0.7 * patches;\n\n  probability = pow(probability, probPow) * probGain;\n  probability = clamp(probability, 0.0, 1.0);\n\n  float fresnel = pow(1.0 - z, 1.5);\n\n  // rainbow chromatic aberration\n  float chromaOffset = phi * 2.0 + theta * 1.5 + animTime * 0.3 + probability * 3.0;\n  vec3 rainbow;\n  rainbow.r = sin(chromaOffset) * 0.5 + 0.5;\n  rainbow.g = sin(chromaOffset + chromaSpread) * 0.5 + 0.5;\n  rainbow.b = sin(chromaOffset + chromaSpread * 2.0) * 0.5 + 0.5;\n  rainbow = normalize(rainbow + 0.01) * length(rainbow);\n\n  float bandFreq = chromaOffset * 3.0 + fresnel * 2.4;\n  vec3 chromaticBands;\n  chromaticBands.r = sin(bandFreq) * 0.5 + 0.5;\n  chromaticBands.g = sin(bandFreq + 2.094) * 0.5 + 0.5;\n  chromaticBands.b = sin(bandFreq + 4.189) * 0.5 + 0.5;\n\n  vec3 glowColor = mix(rainbow, chromaticBands, 0.12);\n  glowColor = pow(glowColor, vec3(0.8));\n\n  vec3 darkMetal = vec3(uP_metalDark);\n  vec3 lightMetal = mix(vec3(0.9, 0.92, 0.95), glowColor, 0.7);\n\n  float metalGradient = smoothstep(0.0, 1.0, probability * 0.7 + fresnel * 0.3);\n  vec3 metalColor = mix(darkMetal, lightMetal, metalGradient);\n\n  float orbGlow = uP_glow + 0.6 * uOutput;\n  float totalGlow = (0.25 + fresnel * 0.6 + probability * 0.8) * orbGlow;\n  float glowAmount = clamp(pow(totalGlow, 0.7), 0.0, 1.0);\n\n  vec3 surfaceColor = mix(metalColor, glowColor, glowAmount);\n\n  vec3 normal = vec3(uv / max(R, 0.001), z);\n  float specular = pow(max(dot(normal, normalize(vec3(1.0, 1.0, 2.0))), 0.0), 32.0);\n  surfaceColor += mix(vec3(1.0), glowColor, 0.6) * specular * 0.4;\n\n  float visibility = clamp(probability * 1.2 + fresnel * 0.3 + uP_baseVis + uInput * 0.15, 0.0, 1.0);\n\n  float a = mask * visibility;\n  gl_FragColor = vec4(surfaceColor * a, a);\n}\n`;\n\nexport const shdr11Orb: OrbVariant = {\n  key: \"shdr-11\",\n  label: \"SHDR-11\",\n  note: \"quantum orbital, rainbow chroma\",\n  frag: HYDROGEN_FRAG,\n  params: [\n    { key: \"speed\", label: \"Anim speed\", min: 0.015, max: 10, step: 0.05, default: 0.9, integrate: true },\n    { key: \"rotSpeed\", label: \"Rotation speed\", min: 0, max: 5, step: 0.05, default: 0.5 },\n    { key: \"radius\", label: \"Radius\", min: 0.15, max: 3, step: 0.015, default: 0.9 },\n    { key: \"swell\", label: \"Input swell\", min: 0, max: 1, step: 0.01, default: 0.07 },\n    { key: \"posScale\", label: \"Orbital zoom\", min: 0.15, max: 10, step: 0.05, default: 0.5 },\n    { key: \"flowSpeed\", label: \"Flow speed\", min: 0, max: 10, step: 0.05, default: 0.35, integrate: true },\n    { key: \"flowAmp\", label: \"Flow amount\", min: 0, max: 4, step: 0.05, default: 0.45 },\n    { key: \"flowScale\", label: \"Flow scale\", min: 0.3, max: 10, step: 0.1, default: 0.3 },\n    { key: \"precess\", label: \"Precession\", min: 0, max: 4, step: 0.05, default: 0.3 },\n    { key: \"radialPow\", label: \"Radial power\", min: 0.5, max: 15, step: 0.1, default: 0.5 },\n    { key: \"radialDecay\", label: \"Radial decay\", min: 0.3, max: 30, step: 0.15, default: 1 },\n    { key: \"probPow\", label: \"Probability curve\", min: 0.1, max: 3, step: 0.015, default: 0.4 },\n    { key: \"probGain\", label: \"Probability gain\", min: 0.15, max: 15, step: 0.1, default: 3 },\n    { key: \"waveFreq\", label: \"Wave frequency\", min: 0, max: 20, step: 0.5, default: 4 },\n    { key: \"chromaSpread\", label: \"Chroma spread\", min: 0, max: 1.5, step: 0.01, default: 0.18 },\n    { key: \"glow\", label: \"Glow\", min: 0, max: 5, step: 0.05, default: 0.9 },\n    { key: \"metalDark\", label: \"Metal darkness\", min: 0, max: 3, step: 0.015, default: 0 },\n    { key: \"baseVis\", label: \"Base visibility\", min: 0, max: 1.5, step: 0.01, default: 0.12 }\n  ],\n  colors: [],\n  statePresets: {\n    // idle look, tuned by hand — the schema defaults mirror this set\n    idle: {\n      speed: 0.9,\n      rotSpeed: 0.5,\n      radius: 0.9,\n      swell: 0.07,\n      posScale: 0.5,\n      flowSpeed: 0.35,\n      flowAmp: 0.45,\n      flowScale: 0.3,\n      precess: 0.3,\n      radialPow: 0.5,\n      radialDecay: 1,\n      probPow: 0.4,\n      probGain: 3,\n      waveFreq: 4,\n      chromaSpread: 0.18,\n      glow: 0.9,\n      metalDark: 0,\n      baseVis: 0.12\n    },\n    // thinking: wider zoom, heavier flow, tighter shells, wide chroma —\n    // restless but not loud\n    thinking: {\n      speed: 0.9,\n      rotSpeed: 0.5,\n      radius: 0.9,\n      swell: 0.07,\n      posScale: 0.65,\n      flowSpeed: 0.35,\n      flowAmp: 1.1,\n      flowScale: 0.3,\n      precess: 0,\n      radialPow: 0.5,\n      radialDecay: 1.9,\n      probPow: 0.4,\n      probGain: 3,\n      waveFreq: 4,\n      chromaSpread: 0.41,\n      glow: 0.9,\n      metalDark: 0,\n      baseVis: 0.12\n    },\n    // speaking: fast anim, full zoom, quick fine-grained flow, strong\n    // precession, bright gain — the loudest, most energetic pattern\n    speaking: {\n      speed: 2.45,\n      rotSpeed: 0.5,\n      radius: 0.9,\n      swell: 0.07,\n      posScale: 1,\n      flowSpeed: 2.75,\n      flowAmp: 0.8,\n      flowScale: 2.2,\n      precess: 1.3,\n      radialPow: 0.5,\n      radialDecay: 1,\n      probPow: 0.31,\n      probGain: 4.3,\n      waveFreq: 4,\n      chromaSpread: 0.12,\n      glow: 0.9,\n      metalDark: 0,\n      baseVis: 0.12\n    }\n  }\n};\n\nexport type Shdr11Props = Omit<ShaderOrbProps, \"variant\">;\n\nexport function Shdr11({ size = 280, ...rest }: Shdr11Props) {\n  return <ShaderOrb variant={shdr11Orb} size={size} {...rest} />;\n}\n\nexport default Shdr11;\n"
    }
  ]
}
