{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "liquid-text",
  "title": "Liquid text",
  "description": "Liquid text is a production-ready text component from BJORK UI, packaged for shadcn registries.",
  "dependencies": [
    "clsx",
    "framer-motion",
    "next-themes",
    "tailwind-merge"
  ],
  "files": [
    {
      "path": "components/bjork-ui/text/liquid-text.tsx",
      "content": "\"use client\";\n\n// Liquid Text: type rendered as a viscous surface. The word is rasterised once into a signed\n// distance field; a fragment shader unions it (smooth-min) with a short sprung trail of\n// metaballs that follows the pointer, so dragging through a letter pulls a bead of ink off it.\n// Let go and the trail sinks back into the nearest stroke with a little surface-tension wobble.\n\nimport { useCallback, useEffect, useRef, useState, useSyncExternalStore, type CSSProperties } from \"react\";\nimport { useReducedMotion } from \"framer-motion\";\nimport { cn } from \"@/lib/utils\";\nimport { defaultMaxDpr, sizeCanvas, useElementSize } from \"../_core/canvas\";\nimport { useVisibleLoop } from \"../_core/loop\";\nimport { useBjorkTone } from \"../_core/tone\";\nimport { BJORK_PALETTE, type BjorkTone } from \"../_core/palette\";\n\nexport interface LiquidTextProps {\n  text?: string;\n  /** Ink colour. Defaults to the tone's text colour. */\n  color?: string;\n  /** Specular highlight colour. */\n  highlight?: string;\n  /** Optional tint where ink has been pulled away from the letters. Off by default. */\n  accentColor?: string;\n  /** 0 = runny, 1 = thick and slow. Default 0.55. */\n  viscosity?: number;\n  /** Radius of the leading blob in CSS px. Defaults to 13% of the font size. */\n  blobRadius?: number;\n  /** Number of sprung trail points, 2 to 10. Default 8. */\n  trailLength?: number;\n  tone?: BjorkTone;\n  /** Upper bound for the font size in CSS px. The type shrinks to fit narrower containers. */\n  fontSize?: number;\n  fontWeight?: number;\n  fontFamily?: string;\n  /** A slow drifting blob keeps the surface alive when nobody is pointing at it. Default true. */\n  autoplay?: boolean;\n  /** Render one representative frame (a bead mid-pull) and stop. Used for previews. */\n  frozen?: boolean;\n  ariaLabel?: string;\n  className?: string;\n}\n\nconst MAX_TRAIL = 10;\n\n// ─── WebGL2 support (cached, client only) ────────────────────────────────────\n\nlet webgl2Support: boolean | null = null;\nfunction getWebGL2Support(): boolean {\n  if (webgl2Support === null) {\n    try {\n      const probe = document.createElement(\"canvas\");\n      const ctx = probe.getContext(\"webgl2\");\n      webgl2Support = !!ctx;\n      ctx?.getExtension(\"WEBGL_lose_context\")?.loseContext();\n    } catch {\n      webgl2Support = false;\n    }\n  }\n  return webgl2Support;\n}\n// React StrictMode runs effect cleanups and re-runs them on the same canvas. Losing the context\n// right away would hand the second run a dead context, so the release waits a tick and is\n// cancelled if the canvas is picked up again.\nconst pendingRelease = new WeakMap<HTMLCanvasElement, number>();\nfunction claimCanvas(canvas: HTMLCanvasElement) {\n  const t = pendingRelease.get(canvas);\n  if (t !== undefined) {\n    window.clearTimeout(t);\n    pendingRelease.delete(canvas);\n  }\n}\nfunction releaseCanvas(canvas: HTMLCanvasElement, gl: WebGL2RenderingContext) {\n  pendingRelease.set(\n    canvas,\n    window.setTimeout(() => {\n      pendingRelease.delete(canvas);\n      if (!gl.isContextLost()) gl.getExtension(\"WEBGL_lose_context\")?.loseContext();\n    }, 0),\n  );\n}\nconst subscribeNoop = () => () => {};\nconst getServerSupport = () => true;\n\n// ─── Colour + layout helpers ─────────────────────────────────────────────────\n\nlet scratchCtx: CanvasRenderingContext2D | null = null;\nfunction getScratch(): CanvasRenderingContext2D | null {\n  if (!scratchCtx) scratchCtx = document.createElement(\"canvas\").getContext(\"2d\");\n  return scratchCtx;\n}\n\nfunction parseColor(input: string, fallback: [number, number, number]): [number, number, number, number] {\n  const ctx = getScratch();\n  if (!ctx) return [...fallback, 1];\n  ctx.fillStyle = \"#000\";\n  ctx.fillStyle = input;\n  const v = String(ctx.fillStyle);\n  if (v.startsWith(\"#\")) {\n    const n = parseInt(v.slice(1, 7), 16);\n    return [((n >> 16) & 255) / 255, ((n >> 8) & 255) / 255, (n & 255) / 255, 1];\n  }\n  const m = v.match(/[\\d.]+/g);\n  if (!m || m.length < 3) return [...fallback, 1];\n  return [+m[0] / 255, +m[1] / 255, +m[2] / 255, m[3] !== undefined ? +m[3] : 1];\n}\n\ninterface Layout {\n  width: number;\n  height: number;\n  fontSize: number;\n  font: string;\n  tracking: number;\n  baseline: number;\n  textWidth: number;\n  inkTop: number;\n  inkBottom: number;\n}\n\nfunction computeLayout(width: number, text: string, family: string, weight: number, maxFont: number): Layout | null {\n  const ctx = getScratch();\n  if (!ctx || width < 8) return null;\n  const setFont = (px: number) => {\n    ctx.font = `${weight} ${px}px ${family}`;\n    if (\"letterSpacing\" in ctx) (ctx as CanvasRenderingContext2D & { letterSpacing: string }).letterSpacing = `${-0.025 * px}px`;\n  };\n  setFont(100);\n  const w100 = Math.max(1, ctx.measureText(text).width);\n  const fontSize = Math.max(14, Math.min(maxFont, (width * 0.82 * 100) / w100));\n  setFont(fontSize);\n  const m = ctx.measureText(text);\n  const asc = m.actualBoundingBoxAscent || fontSize * 0.72;\n  const desc = m.actualBoundingBoxDescent || 0;\n  const height = Math.round(fontSize * 1.6);\n  const baseline = height * 0.53 + (asc - desc) / 2;\n  return {\n    width,\n    height,\n    fontSize,\n    font: `${weight} ${fontSize}px ${family}`,\n    tracking: -0.025 * fontSize,\n    baseline,\n    textWidth: m.width,\n    inkTop: baseline - asc,\n    inkBottom: baseline + desc,\n  };\n}\n\n// ─── Signed distance field (TinySDF style, anti-aliased seed) ────────────────\n\nconst INF = 1e20;\n\nfunction edt1d(grid: Float64Array, offset: number, stride: number, length: number, f: Float64Array, v: Uint16Array | Uint32Array, z: Float64Array) {\n  v[0] = 0;\n  z[0] = -INF;\n  z[1] = INF;\n  f[0] = grid[offset];\n  for (let q = 1, k = 0, s = 0; q < length; q++) {\n    f[q] = grid[offset + q * stride];\n    const q2 = q * q;\n    do {\n      const r = v[k];\n      s = (f[q] - f[r] + q2 - r * r) / (q - r) / 2;\n    } while (s <= z[k] && --k > -1);\n    k++;\n    v[k] = q;\n    z[k] = s;\n    z[k + 1] = INF;\n  }\n  for (let q = 0, k = 0; q < length; q++) {\n    while (z[k + 1] < q) k++;\n    const r = v[k];\n    const qr = q - r;\n    grid[offset + q * stride] = f[r] + qr * qr;\n  }\n}\n\nfunction edt(grid: Float64Array, w: number, h: number, f: Float64Array, v: Uint32Array, z: Float64Array) {\n  for (let x = 0; x < w; x++) edt1d(grid, x, w, h, f, v, z);\n  for (let y = 0; y < h; y++) edt1d(grid, y * w, 1, w, f, v, z);\n}\n\ninterface Field {\n  data: Float32Array; // signed distance in CSS px, positive outside\n  packed: Float32Array; // RG: sharp distance, softened distance (for normals)\n  w: number;\n  h: number;\n  scale: number; // texels per CSS px\n}\n\nfunction buildField(layout: Layout, text: string): Field | null {\n  const scale = Math.min(1.25, 1800 / layout.width);\n  const w = Math.max(2, Math.round(layout.width * scale));\n  const h = Math.max(2, Math.round(layout.height * scale));\n  const c = document.createElement(\"canvas\");\n  c.width = w;\n  c.height = h;\n  const ctx = c.getContext(\"2d\", { willReadFrequently: true });\n  if (!ctx) return null;\n  ctx.scale(scale, scale);\n  ctx.font = layout.font;\n  if (\"letterSpacing\" in ctx) (ctx as CanvasRenderingContext2D & { letterSpacing: string }).letterSpacing = `${layout.tracking}px`;\n  ctx.textAlign = \"center\";\n  ctx.textBaseline = \"alphabetic\";\n  ctx.fillStyle = \"#fff\";\n  ctx.fillText(text, layout.width / 2, layout.baseline);\n  const img = ctx.getImageData(0, 0, w, h).data;\n\n  const n = w * h;\n  const outer = new Float64Array(n);\n  const inner = new Float64Array(n);\n  for (let i = 0; i < n; i++) {\n    const a = img[i * 4 + 3] / 255;\n    if (a >= 1) {\n      outer[i] = 0;\n      inner[i] = INF;\n    } else if (a <= 0) {\n      outer[i] = INF;\n      inner[i] = 0;\n    } else {\n      const d = 0.5 - a;\n      outer[i] = d > 0 ? d * d : 0;\n      inner[i] = d < 0 ? d * d : 0;\n    }\n  }\n  const len = Math.max(w, h);\n  const f = new Float64Array(len);\n  const v = new Uint32Array(len);\n  const z = new Float64Array(len + 1);\n  edt(outer, w, h, f, v, z);\n  edt(inner, w, h, f, v, z);\n  const data = new Float32Array(n);\n  for (let i = 0; i < n; i++) data[i] = (Math.sqrt(outer[i]) - Math.sqrt(inner[i])) / scale;\n  // A softened copy rounds off the medial-axis ridges so the surface shades like a bead, not a bevel.\n  const soft = Float32Array.from(data);\n  const tmp = new Float32Array(n);\n  const radius = Math.max(1, Math.round(layout.fontSize * 0.035 * scale));\n  for (let pass = 0; pass < 3; pass++) {\n    boxBlur(soft, tmp, w, h, radius, 1, w);\n    boxBlur(tmp, soft, h, w, radius, w, 1);\n  }\n  const packed = new Float32Array(n * 2);\n  for (let i = 0; i < n; i++) {\n    packed[i * 2] = data[i];\n    packed[i * 2 + 1] = soft[i];\n  }\n  return { data, packed, w, h, scale };\n}\n\n// One box blur pass along rows (step 1, lines of `len` with `stride` between lines) or columns.\nfunction boxBlur(src: Float32Array, dst: Float32Array, len: number, lines: number, r: number, step: number, stride: number) {\n  const span = 2 * r + 1;\n  for (let line = 0; line < lines; line++) {\n    const base = line * stride;\n    let acc = 0;\n    for (let k = -r; k <= r; k++) acc += src[base + Math.min(len - 1, Math.max(0, k)) * step];\n    for (let i = 0; i < len; i++) {\n      dst[base + i * step] = acc / span;\n      const out = Math.max(0, i - r);\n      const inn = Math.min(len - 1, i + r + 1);\n      acc += src[base + inn * step] - src[base + out * step];\n    }\n  }\n}\n\nfunction sampleField(field: Field, x: number, y: number): number {\n  const fx = Math.min(field.w - 1, Math.max(0, Math.round(x * field.scale)));\n  const fy = Math.min(field.h - 1, Math.max(0, Math.round(y * field.scale)));\n  return field.data[fy * field.w + fx];\n}\n\n// Walks a point onto the letter surface (slightly inside) along the field gradient.\nfunction nearestInside(field: Field, x: number, y: number, inset: number): [number, number] {\n  let px = x;\n  let py = y;\n  for (let i = 0; i < 10; i++) {\n    const d = sampleField(field, px, py) + inset;\n    if (Math.abs(d) < 0.5) break;\n    const e = 1.5;\n    const gx = (sampleField(field, px + e, py) - sampleField(field, px - e, py)) / (2 * e);\n    const gy = (sampleField(field, px, py + e) - sampleField(field, px, py - e)) / (2 * e);\n    const gl = Math.hypot(gx, gy) || 1;\n    px -= (gx / gl) * d;\n    py -= (gy / gl) * d;\n  }\n  return [px, py];\n}\n\n// ─── Shaders ─────────────────────────────────────────────────────────────────\n\nconst VERT = `#version 300 es\nin vec2 aPos;\nvoid main() { gl_Position = vec4(aPos, 0.0, 1.0); }`;\n\nconst FRAG = `#version 300 es\nprecision highp float;\nuniform sampler2D uSdf;\nuniform vec2 uSize;\nuniform float uDpr;\nuniform float uCanvasH;\nuniform vec4 uBlob[${MAX_TRAIL}];\nuniform vec2 uWarp[${MAX_TRAIL}];\nuniform int uCount;\nuniform float uSigma;\nuniform float uBevel;\nuniform vec3 uInk;\nuniform vec3 uHi;\nuniform vec4 uAccent;\nuniform float uSpec;\nuniform float uShade;\nout vec4 outColor;\n\nfloat smin(float a, float b, float k) {\n  float h = max(k - abs(a - b), 0.0) / k;\n  return min(a, b) - h * h * k * 0.25;\n}\n\nfloat glyph(vec2 p, float soft) {\n  vec2 t = texture(uSdf, p / uSize).rg;\n  return mix(t.r, t.g, soft);\n}\n\nfloat field(vec2 p, float soft, out float g0) {\n  vec2 warp = vec2(0.0);\n  float wsum = 0.0;\n  for (int i = 0; i < ${MAX_TRAIL}; i++) {\n    if (i >= uCount) break;\n    vec2 d = p - uBlob[i].xy;\n    float g = exp(-dot(d, d) / (uSigma * uSigma));\n    warp += uWarp[i] * g;\n    wsum += g;\n  }\n  warp /= max(1.0, wsum);\n  float f = glyph(p - warp, soft);\n  g0 = f;\n  for (int i = 0; i < ${MAX_TRAIL}; i++) {\n    if (i >= uCount) break;\n    float r = uBlob[i].z;\n    if (r < 0.05) continue;\n    float b = length(p - uBlob[i].xy) - r;\n    f = smin(f, b, r * 1.45);\n  }\n  return f;\n}\n\nvoid main() {\n  vec2 p = vec2(gl_FragCoord.x, uCanvasH - gl_FragCoord.y) / uDpr;\n  float g0;\n  float f = field(p, 0.0, g0);\n  float fw = max(fwidth(f), 1e-4);\n  float alpha = clamp(0.5 - f / fw, 0.0, 1.0);\n  if (alpha <= 0.0) { outColor = vec4(0.0); return; }\n\n  float e = 1.0;\n  float tmp;\n  vec2 grad = vec2(\n    field(p + vec2(e, 0.0), 1.0, tmp) - field(p - vec2(e, 0.0), 1.0, tmp),\n    field(p + vec2(0.0, e), 1.0, tmp) - field(p - vec2(0.0, e), 1.0, tmp)\n  ) / (2.0 * e);\n  float fs = field(p, 1.0, tmp);\n  float gl = length(grad);\n  vec2 n2 = gl > 1e-4 ? grad / gl : vec2(0.0);\n\n  // Circular cross-section: steep at the rim, flat in the middle of thick strokes.\n  float s = 1.0 - clamp(-min(fs, f + 0.5) / uBevel, 0.0, 1.0);\n  s = pow(s, 1.6);\n  vec3 N = normalize(vec3(n2 * s, sqrt(max(0.0, 1.0 - s * s))));\n  vec3 L = normalize(vec3(-0.45, -0.7, 0.62));\n  float diff = clamp(dot(N, L), 0.0, 1.0);\n  vec3 H = normalize(L + vec3(0.0, 0.0, 1.0));\n  float spec = pow(max(dot(N, H), 0.0), 48.0);\n\n  vec3 col = uInk * mix(1.0 - uShade, 1.0, diff);\n  float pulled = clamp((g0 - f) / (uBevel * 1.4), 0.0, 1.0);\n  col = mix(col, uAccent.rgb, pulled * uAccent.a);\n  col += uHi * spec * uSpec;\n  outColor = vec4(col * alpha, alpha);\n}`;\n\nfunction compile(gl: WebGL2RenderingContext, type: number, src: string): WebGLShader | null {\n  const sh = gl.createShader(type);\n  if (!sh) return null;\n  gl.shaderSource(sh, src);\n  gl.compileShader(sh);\n  if (!gl.getShaderParameter(sh, gl.COMPILE_STATUS) && !gl.isContextLost()) {\n    console.warn(\"LiquidText shader:\", gl.getShaderInfoLog(sh));\n    gl.deleteShader(sh);\n    return null;\n  }\n  return sh;\n}\n\n// ─── Simulation ──────────────────────────────────────────────────────────────\n\ninterface Sim {\n  n: number;\n  x: Float32Array;\n  y: Float32Array;\n  vx: Float32Array;\n  vy: Float32Array;\n  mass: number;\n  massV: number;\n  load: number;\n  mode: \"idle\" | \"pointer\" | \"release\";\n  tx: number;\n  ty: number;\n  homeX: number;\n  homeY: number;\n  releaseT: number;\n  idleBlend: number;\n  clock: number;\n  seeded: boolean;\n  // Where the neck of a pulled bead is still attached to a stroke, and how firmly (0 to 1).\n  anchorX: number;\n  anchorY: number;\n  tether: number;\n}\n\nfunction createSim(): Sim {\n  return {\n    n: 8,\n    x: new Float32Array(MAX_TRAIL),\n    y: new Float32Array(MAX_TRAIL),\n    vx: new Float32Array(MAX_TRAIL),\n    vy: new Float32Array(MAX_TRAIL),\n    mass: 0,\n    massV: 0,\n    load: 0.5,\n    mode: \"idle\",\n    tx: 0,\n    ty: 0,\n    homeX: 0,\n    homeY: 0,\n    releaseT: 0,\n    idleBlend: 1,\n    clock: 0,\n    seeded: false,\n    anchorX: 0,\n    anchorY: 0,\n    tether: 0,\n  };\n}\n\ninterface SimParams {\n  viscosity: number;\n  trail: number;\n  autoplay: boolean;\n}\n\nfunction idlePoint(layout: Layout, t: number): [number, number] {\n  const cx = layout.width / 2;\n  const capH = layout.inkBottom - layout.inkTop;\n  const ax = layout.textWidth * 0.44;\n  // Rides the top line of the word, dipping into the letters and lifting beads off them.\n  const y = layout.inkTop + capH * (0.04 + 0.2 * Math.sin(t * 0.53 + 1.1) + 0.08 * Math.sin(t * 1.27));\n  return [cx + ax * Math.sin(t * 0.21), y];\n}\n\nfunction stepSim(sim: Sim, dt: number, layout: Layout, field: Field, params: SimParams) {\n  const n = Math.max(2, Math.min(MAX_TRAIL, Math.round(params.trail)));\n  sim.n = n;\n  const visc = Math.min(1, Math.max(0, params.viscosity));\n  if (!sim.seeded) {\n    const [ix, iy] = idlePoint(layout, sim.clock);\n    for (let i = 0; i < MAX_TRAIL; i++) {\n      sim.x[i] = ix;\n      sim.y[i] = iy;\n    }\n    sim.seeded = true;\n  }\n\n  let massTarget = 0;\n  let gx = sim.tx;\n  let gy = sim.ty;\n  if (sim.mode === \"pointer\") {\n    massTarget = 1;\n  } else if (sim.mode === \"release\") {\n    sim.releaseT += dt;\n    gx = sim.homeX;\n    gy = sim.homeY;\n    if (sim.releaseT > 2.2 && params.autoplay) {\n      sim.mode = \"idle\";\n      sim.idleBlend = 0;\n    }\n  }\n  if (sim.mode === \"idle\") {\n    if (!params.autoplay) {\n      gx = sim.homeX;\n      gy = sim.homeY;\n    } else {\n      sim.clock += dt;\n      sim.idleBlend = Math.min(1, sim.idleBlend + dt / 2.4);\n      const b = sim.idleBlend * sim.idleBlend * (3 - 2 * sim.idleBlend);\n      const [ix, iy] = idlePoint(layout, sim.clock);\n      gx = sim.homeX + (ix - sim.homeX) * b;\n      gy = sim.homeY + (iy - sim.homeY) * b;\n      massTarget = 0.55 * b;\n    }\n  }\n\n  const k = 330 - 270 * visc;\n  const c = 2 * 0.62 * Math.sqrt(k);\n  const kf = k * 1.25;\n  const cf = 2 * 0.48 * Math.sqrt(kf);\n  const km = 70;\n  const cm = 2 * 0.3 * Math.sqrt(km);\n\n  // Surface tension: a head that leaves a stroke stays tethered to it by the tail of the trail.\n  // Stretch the neck past the break length and it snaps, leaving a free bead.\n  const headD = sampleField(field, sim.x[0], sim.y[0]);\n  if (sim.mode === \"pointer\" || (sim.mode === \"idle\" && params.autoplay)) {\n    if (headD < 0) {\n      sim.tether = Math.min(1, sim.tether + dt * 8);\n      const [ax, ay] = nearestInside(field, sim.x[0], sim.y[0], layout.fontSize * 0.04);\n      sim.anchorX = ax;\n      sim.anchorY = ay;\n    } else if (sim.tether > 0) {\n      const stretch = Math.hypot(sim.x[0] - sim.anchorX, sim.y[0] - sim.anchorY);\n      const breakLen = layout.fontSize * (0.42 + 0.4 * visc);\n      if (stretch > breakLen) sim.tether = 0;\n    }\n  } else {\n    sim.tether = Math.max(0, sim.tether - dt * 3);\n  }\n\n  const steps = Math.max(1, Math.ceil(dt / (1 / 120)));\n  const h = dt / steps;\n  for (let s = 0; s < steps; s++) {\n    for (let i = 0; i < n; i++) {\n      const tx = i === 0 ? gx : sim.x[i - 1];\n      const ty = i === 0 ? gy : sim.y[i - 1];\n      const kk = i === 0 ? k : kf;\n      const cc = i === 0 ? c : cf;\n      let fx = (tx - sim.x[i]) * kk;\n      let fy = (ty - sim.y[i]) * kk;\n      if (i > 0 && sim.tether > 0) {\n        // Tail points lean toward the anchor, spreading the trail into a neck.\n        const w = sim.tether * Math.pow(i / (n - 1), 1.3) * 1.6;\n        fx += (sim.anchorX - sim.x[i]) * kk * w;\n        fy += (sim.anchorY - sim.y[i]) * kk * w;\n      }\n      sim.vx[i] += (fx - sim.vx[i] * cc) * h;\n      sim.vy[i] += (fy - sim.vy[i] * cc) * h;\n      sim.x[i] += sim.vx[i] * h;\n      sim.y[i] += sim.vy[i] * h;\n    }\n    sim.massV += ((massTarget - sim.mass) * km - sim.massV * cm) * h;\n    sim.mass += sim.massV * h;\n  }\n  // Trail points beyond the active length sit on the tail so lengthening the trail is seamless.\n  for (let i = n; i < MAX_TRAIL; i++) {\n    sim.x[i] = sim.x[n - 1];\n    sim.y[i] = sim.y[n - 1];\n    sim.vx[i] = 0;\n    sim.vy[i] = 0;\n  }\n\n  // Dragging through a letter loads the blob with ink; open air slowly thins it.\n  const inside = sampleField(field, sim.x[0], sim.y[0]) < layout.fontSize * 0.02;\n  sim.load = inside ? Math.min(1, sim.load + dt * 5) : Math.max(0.4, sim.load - dt * 0.35);\n}\n\n// ─── Component ───────────────────────────────────────────────────────────────\n\ninterface GLState {\n  gl: WebGL2RenderingContext;\n  prog: WebGLProgram;\n  buf: WebGLBuffer;\n  tex: WebGLTexture;\n  u: Record<string, WebGLUniformLocation | null>;\n}\n\nconst UNIFORMS = [\"uSdf\", \"uSize\", \"uDpr\", \"uCanvasH\", \"uBlob\", \"uWarp\", \"uCount\", \"uSigma\", \"uBevel\", \"uInk\", \"uHi\", \"uAccent\", \"uSpec\", \"uShade\"];\n\nexport function LiquidText({\n  text = \"Liquid\",\n  color,\n  highlight,\n  accentColor,\n  viscosity = 0.55,\n  blobRadius,\n  trailLength = 8,\n  tone,\n  fontSize = 240,\n  fontWeight = 700,\n  fontFamily,\n  autoplay = true,\n  frozen = false,\n  ariaLabel,\n  className,\n}: LiquidTextProps) {\n  const resolvedTone = useBjorkTone(tone);\n  const reducedMotion = useReducedMotion() ?? false;\n  const supported = useSyncExternalStore(subscribeNoop, getWebGL2Support, getServerSupport);\n\n  const wrapRef = useRef<HTMLDivElement>(null);\n  const canvasRef = useRef<HTMLCanvasElement>(null);\n  const { width } = useElementSize(wrapRef);\n  const [fontsReady, setFontsReady] = useState(false);\n  const [epoch, setEpoch] = useState(0);\n  const [layout, setLayout] = useState<Layout | null>(null);\n\n  const glRef = useRef<GLState | null>(null);\n  const fieldRef = useRef<Field | null>(null);\n  const layoutRef = useRef<Layout | null>(null);\n  const dprRef = useRef(1);\n  const simRef = useRef<Sim>(createSim());\n  const blobBuf = useRef(new Float32Array(MAX_TRAIL * 4));\n  const warpBuf = useRef(new Float32Array(MAX_TRAIL * 2));\n\n  const palette = BJORK_PALETTE[resolvedTone];\n  const look = useRef({\n    ink: [0.93, 0.93, 0.93, 1] as number[],\n    hi: [1, 1, 1, 1] as number[],\n    accent: [0.93, 0.36, 0.07, 0.3] as number[],\n    spec: 0.6,\n    shade: 0.3,\n    blobRadius: 0 as number | undefined,\n    viscosity,\n    trail: trailLength,\n    autoplay,\n  });\n\n  const still = frozen || reducedMotion;\n\n  useEffect(() => {\n    let cancelled = false;\n    const fonts = typeof document !== \"undefined\" ? document.fonts : undefined;\n    if (!fonts) {\n      Promise.resolve().then(() => !cancelled && setFontsReady(true));\n      return;\n    }\n    fonts.ready.then(() => {\n      if (!cancelled) setFontsReady(true);\n    });\n    return () => {\n      cancelled = true;\n    };\n  }, []);\n\n  // Draws one frame from the current sim state.\n  const draw = useCallback(() => {\n    const g = glRef.current;\n    const lay = layoutRef.current;\n    const canvas = canvasRef.current;\n    if (!g || !lay || !canvas || g.gl.isContextLost()) return;\n    const { gl, u } = g;\n    const sim = simRef.current;\n    const L = look.current;\n    const R = L.blobRadius ?? lay.fontSize * 0.13;\n    const n = sim.n;\n    const visc = Math.min(1, Math.max(0, L.viscosity));\n    const blob = blobBuf.current;\n    const warp = warpBuf.current;\n    const maxWarp = lay.fontSize * 0.05;\n    const warpTime = 0.028 - visc * 0.01;\n    const mass = Math.max(0, sim.mass);\n    for (let i = 0; i < MAX_TRAIL; i++) {\n      const taper = i === 0 ? 1 : 0.62 - 0.34 * (1 - 0.6 * sim.tether) * ((i - 1) / Math.max(1, n - 2));\n      blob[i * 4] = sim.x[i];\n      blob[i * 4 + 1] = sim.y[i];\n      blob[i * 4 + 2] = i < n ? R * mass * (0.45 + 0.55 * sim.load) * taper : 0;\n      blob[i * 4 + 3] = 0;\n      let wx = sim.vx[i] * warpTime * Math.min(1, mass * 1.4);\n      let wy = sim.vy[i] * warpTime * Math.min(1, mass * 1.4);\n      const wl = Math.hypot(wx, wy);\n      if (wl > maxWarp) {\n        wx *= maxWarp / wl;\n        wy *= maxWarp / wl;\n      }\n      warp[i * 2] = wx;\n      warp[i * 2 + 1] = wy;\n    }\n    gl.viewport(0, 0, canvas.width, canvas.height);\n    gl.clearColor(0, 0, 0, 0);\n    gl.clear(gl.COLOR_BUFFER_BIT);\n    gl.useProgram(g.prog);\n    gl.activeTexture(gl.TEXTURE0);\n    gl.bindTexture(gl.TEXTURE_2D, g.tex);\n    gl.uniform1i(u.uSdf, 0);\n    gl.uniform2f(u.uSize, lay.width, lay.height);\n    gl.uniform1f(u.uDpr, dprRef.current);\n    gl.uniform1f(u.uCanvasH, canvas.height);\n    gl.uniform4fv(u.uBlob, blob);\n    gl.uniform2fv(u.uWarp, warp);\n    gl.uniform1i(u.uCount, n);\n    gl.uniform1f(u.uSigma, R * 1.8);\n    gl.uniform1f(u.uBevel, Math.max(2, lay.fontSize * 0.085));\n    gl.uniform3f(u.uInk, L.ink[0], L.ink[1], L.ink[2]);\n    gl.uniform3f(u.uHi, L.hi[0], L.hi[1], L.hi[2]);\n    gl.uniform4f(u.uAccent, L.accent[0], L.accent[1], L.accent[2], L.accent[3]);\n    gl.uniform1f(u.uSpec, L.spec);\n    gl.uniform1f(u.uShade, L.shade);\n    gl.bindBuffer(gl.ARRAY_BUFFER, g.buf);\n    gl.drawArrays(gl.TRIANGLES, 0, 3);\n  }, []);\n\n  // Look + tuning props live in a ref so the loop never re-subscribes.\n  useEffect(() => {\n    const dark = resolvedTone === \"dark\";\n    const ink = parseColor(color ?? palette.text, dark ? [0.93, 0.93, 0.93] : [0.09, 0.09, 0.09]);\n    const hi = parseColor(highlight ?? \"#ffffff\", [1, 1, 1]);\n    const acc = accentColor ? parseColor(accentColor, [0.93, 0.36, 0.07]) : [0, 0, 0, 0];\n    const L = look.current;\n    L.ink = ink;\n    L.hi = hi;\n    L.accent = [acc[0], acc[1], acc[2], acc[3] * 0.35];\n    L.spec = dark ? 0.55 : 0.75;\n    L.shade = dark ? 0.32 : 0.0;\n    L.blobRadius = blobRadius;\n    L.viscosity = viscosity;\n    L.trail = trailLength;\n    L.autoplay = autoplay;\n    simRef.current.n = Math.max(2, Math.min(MAX_TRAIL, Math.round(trailLength)));\n    draw();\n  }, [resolvedTone, color, highlight, accentColor, palette, blobRadius, viscosity, trailLength, autoplay, draw]);\n\n  // GL setup / teardown. `epoch` bumps after a context restore.\n  useEffect(() => {\n    const canvas = canvasRef.current;\n    if (!canvas || !supported) return;\n    claimCanvas(canvas);\n    const gl = canvas.getContext(\"webgl2\", { premultipliedAlpha: true, antialias: false, alpha: true });\n    if (!gl) return;\n    const vs = compile(gl, gl.VERTEX_SHADER, VERT);\n    const fs = compile(gl, gl.FRAGMENT_SHADER, FRAG);\n    const prog = gl.createProgram();\n    const buf = gl.createBuffer();\n    const tex = gl.createTexture();\n    if (!vs || !fs || !prog || !buf || !tex) return;\n    gl.attachShader(prog, vs);\n    gl.attachShader(prog, fs);\n    gl.bindAttribLocation(prog, 0, \"aPos\");\n    gl.linkProgram(prog);\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    gl.enableVertexAttribArray(0);\n    gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0);\n    gl.bindTexture(gl.TEXTURE_2D, tex);\n    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);\n    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);\n    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);\n    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);\n    const u: GLState[\"u\"] = {};\n    for (const name of UNIFORMS) u[name] = gl.getUniformLocation(prog, name);\n    glRef.current = { gl, prog, buf, tex, u };\n\n    const onLost = (e: Event) => {\n      e.preventDefault();\n      glRef.current = null;\n    };\n    const onRestored = () => setEpoch((v) => v + 1);\n    canvas.addEventListener(\"webglcontextlost\", onLost);\n    canvas.addEventListener(\"webglcontextrestored\", onRestored);\n    return () => {\n      canvas.removeEventListener(\"webglcontextlost\", onLost);\n      canvas.removeEventListener(\"webglcontextrestored\", onRestored);\n      glRef.current = null;\n      if (!gl.isContextLost()) {\n        gl.deleteTexture(tex);\n        gl.deleteBuffer(buf);\n        gl.deleteProgram(prog);\n        gl.deleteShader(vs);\n        gl.deleteShader(fs);\n        releaseCanvas(canvas, gl);\n      }\n    };\n  }, [supported, epoch]);\n\n  // Layout, canvas size and the distance field. Re-runs on resize, text or font change.\n  useEffect(() => {\n    const wrap = wrapRef.current;\n    const canvas = canvasRef.current;\n    if (!wrap || !canvas || !fontsReady || width < 8) return;\n    let cancelled = false;\n    const family = fontFamily ?? (getComputedStyle(wrap).fontFamily || \"sans-serif\");\n    const fontSpec = `${fontWeight} 100px ${family}`;\n    const run = () => {\n      if (cancelled) return;\n      const lay = computeLayout(width, text, family, fontWeight, fontSize);\n      if (!lay) return;\n      const field = buildField(lay, text);\n      if (!field) return;\n      layoutRef.current = lay;\n      fieldRef.current = field;\n      setLayout((prev) =>\n        prev && prev.width === lay.width && prev.height === lay.height && prev.fontSize === lay.fontSize ? prev : lay,\n      );\n      dprRef.current = sizeCanvas(canvas, lay.width, lay.height, defaultMaxDpr());\n      const g = glRef.current;\n      if (g && !g.gl.isContextLost()) {\n        const { gl } = g;\n        gl.bindTexture(gl.TEXTURE_2D, g.tex);\n        gl.pixelStorei(gl.UNPACK_ALIGNMENT, 1);\n        gl.texImage2D(gl.TEXTURE_2D, 0, gl.RG16F, field.w, field.h, 0, gl.RG, gl.FLOAT, field.packed);\n      }\n\n      // Park the trail on the text so the first frame never flies in from the corner.\n      const sim = simRef.current;\n      const [hx, hy] = nearestInside(field, lay.width / 2, (lay.inkTop + lay.inkBottom) / 2, lay.fontSize * 0.03);\n      sim.homeX = hx;\n      sim.homeY = hy;\n      if (frozen) {\n        primeFrozen(sim, lay, field, look.current);\n      } else if (reducedMotion) {\n        sim.mass = 0;\n        sim.massV = 0;\n      }\n      draw();\n    };\n    // Make sure the exact weight is loaded before rasterising.\n    document.fonts.load(fontSpec, text).then(run, run);\n    return () => {\n      cancelled = true;\n    };\n  }, [width, text, fontWeight, fontFamily, fontSize, fontsReady, frozen, reducedMotion, epoch, draw]);\n\n  // Pointer input.\n  useEffect(() => {\n    const wrap = wrapRef.current;\n    if (!wrap || still || !supported) return;\n    const toLocal = (e: PointerEvent): [number, number] | null => {\n      const lay = layoutRef.current;\n      if (!lay) return null;\n      const rect = wrap.getBoundingClientRect();\n      const sx = rect.width > 0 ? lay.width / rect.width : 1;\n      const sy = rect.height > 0 ? lay.height / rect.height : 1;\n      return [(e.clientX - rect.left) * sx, (e.clientY - rect.top) * sy];\n    };\n    const onMove = (e: PointerEvent) => {\n      const pt = toLocal(e);\n      const lay = layoutRef.current;\n      if (!pt || !lay) return;\n      const sim = simRef.current;\n      sim.mode = \"pointer\";\n      // While dragging, the bead can be pulled to the edge of the surface but no further.\n      const m = lay.fontSize * 0.1;\n      sim.tx = Math.min(lay.width - m, Math.max(m, pt[0]));\n      sim.ty = Math.min(lay.height - m, Math.max(m, pt[1]));\n    };\n    const onDown = (e: PointerEvent) => {\n      if (e.button === 0) {\n        try {\n          wrap.setPointerCapture(e.pointerId);\n        } catch {\n          /* capture is best-effort */\n        }\n      }\n      onMove(e);\n    };\n    const onLeave = () => {\n      const sim = simRef.current;\n      const field = fieldRef.current;\n      const lay = layoutRef.current;\n      if (sim.mode !== \"pointer\" || !field || !lay) return;\n      const [hx, hy] = nearestInside(field, sim.x[0], sim.y[0], lay.fontSize * 0.03);\n      sim.homeX = hx;\n      sim.homeY = hy;\n      sim.mode = \"release\";\n      sim.releaseT = 0;\n    };\n    const onUp = (e: PointerEvent) => {\n      if (wrap.hasPointerCapture(e.pointerId)) wrap.releasePointerCapture(e.pointerId);\n      const rect = wrap.getBoundingClientRect();\n      const outside = e.clientX < rect.left || e.clientX > rect.right || e.clientY < rect.top || e.clientY > rect.bottom;\n      if (e.pointerType !== \"mouse\" || outside) onLeave();\n    };\n    wrap.addEventListener(\"pointermove\", onMove);\n    wrap.addEventListener(\"pointerdown\", onDown);\n    wrap.addEventListener(\"pointerleave\", onLeave);\n    wrap.addEventListener(\"pointercancel\", onLeave);\n    wrap.addEventListener(\"pointerup\", onUp);\n    return () => {\n      wrap.removeEventListener(\"pointermove\", onMove);\n      wrap.removeEventListener(\"pointerdown\", onDown);\n      wrap.removeEventListener(\"pointerleave\", onLeave);\n      wrap.removeEventListener(\"pointercancel\", onLeave);\n      wrap.removeEventListener(\"pointerup\", onUp);\n    };\n  }, [still, supported]);\n\n  const frame = useCallback(\n    (dt: number) => {\n      const lay = layoutRef.current;\n      const field = fieldRef.current;\n      if (!lay || !field || !glRef.current) return;\n      const L = look.current;\n      stepSim(simRef.current, dt, lay, field, { viscosity: L.viscosity, trail: L.trail, autoplay: L.autoplay });\n      draw();\n    },\n    [draw],\n  );\n  useVisibleLoop(wrapRef, frame, { enabled: supported && !still });\n\n  const label = ariaLabel ?? text;\n  const showFallback = !supported;\n  const style: CSSProperties = {\n    height: layout ? layout.height : undefined,\n    minHeight: layout ? undefined : \"1.6em\",\n    touchAction: \"pan-y\",\n  };\n\n  return (\n    <div\n      ref={wrapRef}\n      role=\"img\"\n      aria-label={label}\n      data-tone={resolvedTone}\n      className={cn(\"relative w-full select-none\", className)}\n      style={{\n        ...style,\n        fontFamily: fontFamily ?? \"var(--font-geist-sans), ui-sans-serif, system-ui, sans-serif\",\n        fontWeight,\n      }}\n    >\n      {showFallback ? (\n        <span\n          aria-hidden=\"true\"\n          className=\"absolute inset-0 flex items-center justify-center whitespace-nowrap leading-none\"\n          style={{\n            color: color ?? palette.text,\n            fontSize: layout ? layout.fontSize : \"clamp(48px, 14vw, 160px)\",\n            letterSpacing: \"-0.025em\",\n          }}\n        >\n          {text}\n        </span>\n      ) : (\n        <canvas ref={canvasRef} aria-hidden=\"true\" className=\"absolute left-0 top-0 block\" />\n      )}\n    </div>\n  );\n}\n\n// Plays a scripted drag offline so the preview frame shows a bead mid-pull.\nfunction primeFrozen(sim: Sim, lay: Layout, field: Field, L: { viscosity: number; trail: number; blobRadius?: number }) {\n  const cx = lay.width / 2;\n  const capH = lay.inkBottom - lay.inkTop;\n  const R = L.blobRadius ?? lay.fontSize * 0.13;\n  // Start at the top of the last letter and pull a bead up and away from it.\n  const [sx, sy] = nearestInside(field, cx + lay.textWidth * 0.43, lay.inkTop + capH * 0.06, lay.fontSize * 0.04);\n  const ex = sx + lay.fontSize * 0.12;\n  const ey = Math.max(R * 1.4, lay.inkTop - R * 2.2);\n  sim.mode = \"pointer\";\n  sim.mass = 1;\n  sim.massV = 0;\n  sim.load = 1;\n  sim.clock = 0;\n  for (let i = 0; i < MAX_TRAIL; i++) {\n    sim.x[i] = sx;\n    sim.y[i] = sy;\n    sim.vx[i] = 0;\n    sim.vy[i] = 0;\n  }\n  sim.seeded = true;\n  const params = { viscosity: L.viscosity, trail: L.trail, autoplay: false };\n  const dt = 1 / 120;\n  for (let t = 0; t < FROZEN_AT; t += dt) {\n    const k = Math.min(1, t / FROZEN_PULL);\n    const e = 1 - Math.pow(1 - k, 3);\n    sim.tx = sx + (ex - sx) * e;\n    sim.ty = sy + (ey - sy) * e;\n    stepSim(sim, dt, lay, field, params);\n    sim.load = 1;\n  }\n}\n\nconst FROZEN_PULL = 0.32;\nconst FROZEN_AT = 0.3;\n",
      "type": "registry:component",
      "target": "@components/bjork-ui/text/liquid-text.tsx"
    },
    {
      "path": "lib/utils.ts",
      "content": "import { clsx, type ClassValue } from \"clsx\"\nimport { twMerge } from \"tailwind-merge\"\n\nexport function cn(...inputs: ClassValue[]) {\n  return twMerge(clsx(inputs))\n}\n",
      "type": "registry:lib",
      "target": "@lib/utils.ts"
    },
    {
      "path": "components/bjork-ui/_core/canvas.ts",
      "content": "\"use client\";\n\nimport { useEffect, useState, type RefObject } from \"react\";\nimport { isCoarsePointer } from \"./loop\";\n\n// Caps DPR at 1.5 on coarse pointers and 2 otherwise.\nexport function defaultMaxDpr(): number {\n  return isCoarsePointer() ? 1.5 : 2;\n}\n\n// Sets the backing size for a CSS box and returns the DPR used.\nexport function sizeCanvas(\n  canvas: HTMLCanvasElement,\n  cssW: number,\n  cssH: number,\n  maxDpr: number = defaultMaxDpr(),\n): number {\n  const dpr = Math.min(typeof window !== \"undefined\" ? window.devicePixelRatio || 1 : 1, maxDpr);\n  canvas.width = Math.max(1, Math.round(cssW * dpr));\n  canvas.height = Math.max(1, Math.round(cssH * dpr));\n  canvas.style.width = `${cssW}px`;\n  canvas.style.height = `${cssH}px`;\n  return dpr;\n}\n\n// Rounded size of an element, kept current with a ResizeObserver.\nexport function useElementSize(ref: RefObject<HTMLElement | null>): { width: number; height: number } {\n  const [size, setSize] = useState({ width: 0, height: 0 });\n\n  useEffect(() => {\n    const el = ref.current;\n    if (!el || typeof ResizeObserver === \"undefined\") return;\n    const observer = new ResizeObserver((entries) => {\n      const box = entries[0]?.contentRect;\n      if (!box) return;\n      const width = Math.round(box.width);\n      const height = Math.round(box.height);\n      setSize((prev) => (prev.width === width && prev.height === height ? prev : { width, height }));\n    });\n    observer.observe(el);\n    return () => observer.disconnect();\n  }, [ref]);\n\n  return size;\n}\n",
      "type": "registry:component",
      "target": "@components/bjork-ui/_core/canvas.ts"
    },
    {
      "path": "components/bjork-ui/_core/loop.ts",
      "content": "\"use client\";\n\nimport { useCallback, useEffect, useMemo, useRef, type RefObject } from \"react\";\n\nexport interface VisibleLoopOptions {\n  enabled?: boolean;\n  fpsCap?: number;\n  rootMargin?: string;\n}\n\n/**\n * Runs `frame(dt, t)` on requestAnimationFrame while the element is near the viewport,\n * the tab is visible and `enabled` is not false. Return `false` from `frame` to go idle\n * until `wake()` is called. Writes `data-loop` on the element: running, idle or paused.\n */\nexport function useVisibleLoop(\n  ref: RefObject<HTMLElement | null>,\n  frame: (dt: number, t: number) => boolean | void,\n  opts?: VisibleLoopOptions,\n): { wake: () => void } {\n  const enabled = opts?.enabled !== false;\n  const fpsCap = opts?.fpsCap ?? 0;\n  const rootMargin = opts?.rootMargin ?? \"96px\";\n\n  const frameRef = useRef(frame);\n  useEffect(() => {\n    frameRef.current = frame;\n  }, [frame]);\n\n  const wakeRef = useRef<() => void>(() => {});\n  const wake = useCallback(() => wakeRef.current(), []);\n\n  useEffect(() => {\n    const el = ref.current;\n    if (!el || typeof window === \"undefined\") return;\n\n    let wants = true; // false once the frame callback returns false\n    let inView = false;\n    let raf = 0;\n    let last = 0;\n    let t = 0;\n    const minFrame = fpsCap > 0 ? 1000 / fpsCap : 0;\n\n    const canRun = () => enabled && inView && document.visibilityState === \"visible\";\n    const stop = () => {\n      if (raf) cancelAnimationFrame(raf);\n      raf = 0;\n    };\n\n    const update = () => {\n      if (!enabled) {\n        stop();\n        el.dataset.loop = \"paused\";\n        return;\n      }\n      if (!wants) {\n        stop();\n        el.dataset.loop = \"idle\";\n        return;\n      }\n      if (!canRun()) {\n        stop();\n        el.dataset.loop = \"paused\";\n        return;\n      }\n      el.dataset.loop = \"running\";\n      if (!raf) {\n        last = performance.now();\n        raf = requestAnimationFrame(tick);\n      }\n    };\n\n    const tick = (now: number) => {\n      raf = 0;\n      if (!canRun() || !wants) {\n        update();\n        return;\n      }\n      const elapsed = now - last;\n      if (minFrame && elapsed < minFrame - 0.5) {\n        raf = requestAnimationFrame(tick);\n        return;\n      }\n      last = now;\n      const dt = Math.min(0.05, Math.max(0, elapsed / 1000));\n      t += dt;\n      const keep = frameRef.current(dt, t);\n      if (keep === false) {\n        wants = false;\n        update();\n        return;\n      }\n      raf = requestAnimationFrame(tick);\n    };\n\n    const observer = new IntersectionObserver(\n      ([entry]) => {\n        inView = entry.isIntersecting;\n        update();\n      },\n      { rootMargin },\n    );\n    observer.observe(el);\n\n    const onVisibility = () => update();\n    document.addEventListener(\"visibilitychange\", onVisibility);\n\n    wakeRef.current = () => {\n      wants = true;\n      update();\n    };\n\n    update();\n\n    return () => {\n      stop();\n      observer.disconnect();\n      document.removeEventListener(\"visibilitychange\", onVisibility);\n      wakeRef.current = () => {};\n    };\n  }, [ref, enabled, fpsCap, rootMargin]);\n\n  return useMemo(() => ({ wake }), [wake]);\n}\n\n// matchMedia based check. False on the server.\nexport function isCoarsePointer(): boolean {\n  if (typeof window === \"undefined\" || typeof window.matchMedia !== \"function\") return false;\n  return window.matchMedia(\"(pointer: coarse)\").matches;\n}\n",
      "type": "registry:component",
      "target": "@components/bjork-ui/_core/loop.ts"
    },
    {
      "path": "components/bjork-ui/_core/tone.ts",
      "content": "\"use client\";\n\nimport { useSyncExternalStore } from \"react\";\nimport { useTheme } from \"next-themes\";\nimport type { BjorkTone } from \"./palette\";\n\nconst subscribeNoop = () => () => {};\nconst getMounted = () => true;\nconst getServerMounted = () => false;\n\n/**\n * Resolves the tone for a component. An explicit `tone` always wins.\n * Otherwise it follows next-themes' resolved theme, but only after mount,\n * so the server render and first client render agree on dark.\n */\nexport function useBjorkTone(tone?: BjorkTone): BjorkTone {\n  const { resolvedTheme } = useTheme();\n  const mounted = useSyncExternalStore(subscribeNoop, getMounted, getServerMounted);\n  if (tone) return tone;\n  if (!mounted) return \"dark\";\n  return resolvedTheme === \"light\" ? \"light\" : \"dark\";\n}\n",
      "type": "registry:component",
      "target": "@components/bjork-ui/_core/tone.ts"
    },
    {
      "path": "components/bjork-ui/_core/palette.ts",
      "content": "export type BjorkTone = \"dark\" | \"light\";\n\n// Canvas, WebGL and SVG attribute colours. DOM code uses the CSS tokens with these as fallbacks.\nexport const BJORK_PALETTE = {\n  dark: {\n    bg: \"#050505\", stage: \"#111111\", surface: \"#121212\", raised: \"#161616\", active: \"#202020\",\n    border: \"#232323\", borderStrong: \"#343434\",\n    text: \"#ededed\", textMedium: \"rgba(237,237,237,0.72)\", textMuted: \"rgba(237,237,237,0.6)\",\n    textSoft: \"rgba(237,237,237,0.5)\", textFaint: \"rgba(237,237,237,0.22)\", hair: \"rgba(237,237,237,0.12)\",\n    accent: \"#ec5c13\", accentInk: \"#ec5c13\", accentFill: \"#b84a12\", accentSoft: \"rgba(236,92,19,0.12)\", accentMuted: \"rgba(236,92,19,0.24)\",\n    accentFg: \"#fff2ea\", success: \"#4cc38a\", warning: \"#f2b544\", error: \"#ff5c4d\",\n  },\n  light: {\n    bg: \"#f7f5ef\", stage: \"#f7f5ef\", surface: \"#fffcf6\", raised: \"#f5efe3\", active: \"#efe7d8\",\n    border: \"#eee6db\", borderStrong: \"#e1d7c8\",\n    text: \"#171717\", textMedium: \"rgba(23,23,23,0.78)\", textMuted: \"rgba(23,23,23,0.68)\",\n    textSoft: \"rgba(23,23,23,0.62)\", textFaint: \"rgba(23,23,23,0.22)\", hair: \"rgba(23,23,23,0.10)\",\n    accent: \"#ec7d43\", accentInk: \"#b4531f\", accentFill: \"#ec7d43\", accentSoft: \"rgba(236,125,67,0.10)\", accentMuted: \"rgba(236,125,67,0.20)\",\n    accentFg: \"#3f2112\", success: \"#1f8a55\", warning: \"#a86b00\", error: \"#c8361f\",\n  },\n} as const;\n",
      "type": "registry:component",
      "target": "@components/bjork-ui/_core/palette.ts"
    }
  ],
  "cssVars": {
    "light": {
      "bjork-bg": "#f7f3ea",
      "bjork-surface": "#fffcf6",
      "bjork-surface-muted": "rgba(250, 246, 237, 0.94)",
      "bjork-surface-hover": "#f5efe3",
      "bjork-surface-active": "#efe7d8",
      "bjork-panel": "#f8f2e7",
      "bjork-menu": "rgba(255, 252, 246, 0.98)",
      "bjork-field": "rgba(255, 252, 246, 0.96)",
      "bjork-field-muted": "rgba(248, 242, 231, 0.82)",
      "bjork-field-inset": "rgba(239, 231, 216, 0.72)",
      "bjork-error-bg": "#fff0e8",
      "bjork-text": "#171717",
      "bjork-text-strong": "rgba(23, 23, 23, 0.9)",
      "bjork-text-medium": "rgba(23, 23, 23, 0.78)",
      "bjork-text-muted": "rgba(23, 23, 23, 0.68)",
      "bjork-text-soft": "rgba(23, 23, 23, 0.62)",
      "bjork-text-faint": "rgba(23, 23, 23, 0.22)",
      "bjork-inverted-text": "#fff8f0",
      "bjork-border": "#eee6db",
      "bjork-border-muted": "#f5ede2",
      "bjork-border-strong": "#e1d7c8",
      "bjork-thumb": "#a49b8e",
      "bjork-track": "#ded6ca",
      "bjork-accent": "#ec7d43",
      "bjork-accent-hover": "#f0935f",
      "bjork-accent-soft": "rgba(236, 125, 67, 0.1)",
      "bjork-accent-muted": "rgba(236, 125, 67, 0.2)",
      "bjork-accent-badge": "rgba(236, 125, 67, 0.16)",
      "bjork-accent-badge-foreground": "#8d421d",
      "bjork-accent-foreground": "#3f2112",
      "bjork-accent-fill": "#ec7d43",
      "bjork-accent-fill-hover": "#f0935f",
      "bjork-shadow-surface": "inset 0 7px 14px rgba(88, 72, 49, 0.045), inset 0 0.5px 0.5px rgba(255, 255, 255, 0.92), inset 1px 0 0 rgba(88, 72, 49, 0.026), inset -1px 0 0 rgba(255, 255, 255, 0.68), 0 14px 22px -9px rgba(66, 52, 33, 0.11)",
      "bjork-shadow-soft": "inset 0 1px 0 rgba(88, 72, 49, 0.045), inset 0 0.5px 0.5px rgba(255, 255, 255, 0.86)",
      "bjork-shadow-panel": "inset 0 1px 0 rgba(88, 72, 49, 0.04), inset 1px 0 0 rgba(88, 72, 49, 0.024), inset -1px 0 0 rgba(255, 255, 255, 0.64), 0 18px 38px -30px rgba(66, 52, 33, 0.2)",
      "bjork-shadow-menu": "inset 0 1px 0 rgba(88, 72, 49, 0.04), inset 0 12px 24px rgba(88, 72, 49, 0.022), inset 1px 0 0 rgba(88, 72, 49, 0.024), inset -1px 0 0 rgba(255, 255, 255, 0.62), 0 22px 44px -26px rgba(66, 52, 33, 0.22)",
      "bjork-shadow-inset": "inset 0 1px 10px rgba(88, 72, 49, 0.12), inset 0 0.5px 0.5px rgba(255, 255, 255, 0.72)",
      "bjork-ring-offset": "#f7f3ea"
    },
    "dark": {
      "bjork-bg": "#111111",
      "bjork-surface": "#121212",
      "bjork-surface-muted": "rgba(22, 22, 22, 0.92)",
      "bjork-surface-hover": "#161616",
      "bjork-surface-active": "#202020",
      "bjork-panel": "#0d0d0d",
      "bjork-menu": "rgba(18, 18, 18, 0.98)",
      "bjork-field": "rgba(18, 18, 18, 0.95)",
      "bjork-field-muted": "rgba(18, 18, 18, 0.55)",
      "bjork-field-inset": "rgba(9, 9, 9, 0.75)",
      "bjork-error-bg": "#130d0a",
      "bjork-text": "#ededed",
      "bjork-text-strong": "rgba(237, 237, 237, 0.9)",
      "bjork-text-medium": "rgba(237, 237, 237, 0.72)",
      "bjork-text-muted": "rgba(237, 237, 237, 0.6)",
      "bjork-text-soft": "rgba(237, 237, 237, 0.5)",
      "bjork-text-faint": "rgba(237, 237, 237, 0.22)",
      "bjork-inverted-text": "#080808",
      "bjork-border": "#232323",
      "bjork-border-muted": "#1c1c1c",
      "bjork-border-strong": "#343434",
      "bjork-thumb": "#626262",
      "bjork-track": "#090909",
      "bjork-accent": "#ec5c13",
      "bjork-accent-hover": "#f07832",
      "bjork-accent-soft": "rgba(236, 92, 19, 0.12)",
      "bjork-accent-muted": "rgba(236, 92, 19, 0.24)",
      "bjork-accent-badge": "#b84a12",
      "bjork-accent-badge-foreground": "#fff2ea",
      "bjork-accent-foreground": "#fff2ea",
      "bjork-accent-fill": "#b84a12",
      "bjork-accent-fill-hover": "#a84a14",
      "bjork-shadow-surface": "inset 0 7px 14px rgba(255, 255, 255, 0.03), inset 0 0.5px 0.5px rgba(255, 255, 255, 0.06), 0 14px 20px -6px rgba(0, 0, 0, 0.45)",
      "bjork-shadow-soft": "inset 0 1px 0 rgba(255, 255, 255, 0.045)",
      "bjork-shadow-panel": "inset 0 1px 0 rgba(255, 255, 255, 0.035), 0 18px 36px -28px rgba(0, 0, 0, 0.9)",
      "bjork-shadow-menu": "inset 0 1px 0 rgba(255, 255, 255, 0.055), inset 0 12px 24px rgba(255, 255, 255, 0.018), inset 0 -18px 26px rgba(0, 0, 0, 0.24), 0 22px 42px -22px rgba(0, 0, 0, 0.9)",
      "bjork-shadow-inset": "inset 0 1px 10px rgba(0, 0, 0, 0.45)",
      "bjork-ring-offset": "#080808"
    }
  },
  "css": {
    ".bjork-range": {
      "box-shadow": "inset 0 1px 1px rgba(255, 255, 255, 0.04), 0 0 14px rgba(189, 69, 20, 0.06)"
    },
    ".bjork-range::-webkit-slider-thumb": {
      "appearance": "none",
      "width": "16px",
      "height": "8px",
      "border": "0",
      "border-radius": "999px",
      "background": "var(--bjork-thumb)",
      "box-shadow": "inset 0 1px 0 rgba(255, 255, 255, 0.18), 0 0 0 1px rgba(0, 0, 0, 0.42)"
    },
    ".bjork-range::-moz-range-thumb": {
      "width": "16px",
      "height": "8px",
      "border": "0",
      "border-radius": "999px",
      "background": "var(--bjork-thumb)",
      "box-shadow": "inset 0 1px 0 rgba(255, 255, 255, 0.18), 0 0 0 1px rgba(0, 0, 0, 0.42)"
    },
    ".bjork-layered-button-accent, .bjork-layered-button-raised": {
      "position": "relative",
      "isolation": "isolate",
      "appearance": "none",
      "overflow": "hidden",
      "border": "0",
      "border-radius": "13px",
      "box-shadow": "inset 0 1px 0 #ffffff38, 0 4px 6px -1px #0000001a, 0 2px 4px -2px #0000001a",
      "transition": "filter 150ms ease-out, box-shadow 150ms ease-out"
    },
    ".bjork-layered-button-accent::after, .bjork-layered-button-raised::after": {
      "position": "absolute",
      "inset": "0",
      "pointer-events": "none",
      "content": "\"\"",
      "border": "1.5px solid transparent",
      "border-radius": "inherit",
      "background": "linear-gradient(#ffffffb8, #0000003d 41% 75%, #ffffff47) border-box",
      "mix-blend-mode": "overlay",
      "-webkit-mask-image": "linear-gradient(#000, #000), linear-gradient(#000, #000)",
      "mask-image": "linear-gradient(#000, #000), linear-gradient(#000, #000)",
      "-webkit-mask-clip": "padding-box, border-box",
      "mask-clip": "padding-box, border-box",
      "-webkit-mask-origin": "padding-box, border-box",
      "mask-origin": "padding-box, border-box",
      "-webkit-mask-composite": "xor",
      "mask-composite": "exclude"
    },
    ".bjork-layered-button-accent": {
      "color": "oklch(100% 0 0)",
      "text-shadow": "0 -1px #00000040",
      "background": "radial-gradient(ellipse at -20px top, rgba(255, 255, 255, 0.14), transparent), linear-gradient(180deg, #ec5c13 0%, #ec5c13 62%, color-mix(in oklab, #ec5c13 84%, black 16%) 100%)"
    },
    ".bjork-layered-button-raised": {
      "color": "oklch(100% 0 0)",
      "text-shadow": "0 -1px #00000026",
      "background": "radial-gradient(ellipse at -20px top, #ffffff38, #fff0), linear-gradient(180deg, oklch(0.1881 0.006 265), oklch(0.5493 0.0081 265))"
    },
    ".dark .bjork-layered-button-raised": {
      "color": "oklch(0.2574 0.0056 265)",
      "text-shadow": "0 -1px #00000026",
      "background": "radial-gradient(ellipse at -20px top, #ffffff38, #fff0), linear-gradient(180deg, color-mix(in oklab, oklch(0.9674 0.0013 265) 80%, oklch(0.2574 0.0056 265) 20%), color-mix(in oklab, oklch(0.669 0.0107 265) 80%, oklch(0.2574 0.0056 265) 20%))"
    },
    "@media (hover: hover)": {
      ".bjork-layered-button-accent:hover": {
        "filter": "brightness(1.08) saturate(1.04)"
      },
      ".bjork-layered-button-raised:hover": {
        "filter": "brightness(1.08) contrast(0.9)"
      }
    }
  },
  "meta": {
    "collection": "Text",
    "registryUrl": "https://ui.isaiahbjork.com/liquid-text.json",
    "installCommand": "npx shadcn@latest add https://ui.isaiahbjork.com/liquid-text.json"
  },
  "type": "registry:component"
}