{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "glyph-morph",
  "title": "Glyph morph",
  "description": "Glyph morph 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/glyph-morph.tsx",
      "content": "\"use client\";\n\n// Glyph Morph: words that melt into one another by shape, not by opacity.\n// Each word is rasterised once with the page font and turned into a signed distance\n// field (exact Euclidean transform with an anti-aliased seed). A WebGL2 fragment shader\n// interpolates the two fields along a left-to-right sweep, softens them with a few taps\n// so neighbouring strokes bridge, drifts them with low-frequency noise and a little\n// gravity, then thresholds with derivative-based anti-aliasing. Mid-morph the ink\n// thickens slightly (surface tension) and only the moving edge picks up an orange rim.\n\nimport { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } 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 type GlyphMorphEasing = \"inOutCubic\" | \"inOutQuint\" | \"inOutSine\" | \"inOutExpo\" | \"linear\";\n\nexport interface GlyphMorphProps {\n  /** Words to cycle through. Two or more for a morph; one renders statically. */\n  words?: string[];\n  /** How long each word holds before morphing, in ms. Default 1600. */\n  interval?: number;\n  /** Length of one morph, in ms. Default 1500. */\n  duration?: number;\n  /** Easing applied per pixel along the sweep. Default \"inOutCubic\". */\n  easing?: GlyphMorphEasing;\n  /** 0 = the whole word morphs at once, 1 = a slow left-to-right sweep. Default 0.4. */\n  stagger?: number;\n  /** 0 to 1. How much the ink thickens and bridges at the middle of a morph. Default 0.6. */\n  tension?: number;\n  /** Ink colour. Defaults to the tone's text colour. */\n  color?: string;\n  /** Rim colour on the moving edge. Pass \"transparent\" to turn it off. */\n  accentColor?: string;\n  tone?: BjorkTone;\n  /** Upper bound for the font size in CSS px. Type shrinks so the longest word fits. Default 168. */\n  fontSize?: number;\n  /** Geist is variable, so any weight from 100 to 900 works. Default 640. */\n  fontWeight?: number;\n  /** Defaults to the page font. */\n  fontFamily?: string;\n  /** Cycle through `words` on its own. Default true. Ignored when `index` is set. */\n  autoplay?: boolean;\n  /** Controlled mode: morph to this index whenever it changes. */\n  index?: number;\n  /** Word shown first. Default 0. */\n  startIndex?: number;\n  /** Freeze on one frame of the morph from `startIndex` to the next word (0 to 1). For previews. */\n  progress?: number;\n  /** Announce each new word politely to screen readers. Default false. */\n  announce?: boolean;\n  ariaLabel?: string;\n  className?: string;\n  onWordChange?: (index: number, word: string) => void;\n}\n\nconst DEFAULT_WORDS = [\"Shape\", \"Shift\", \"Form\", \"Flow\"];\nconst EASE_INDEX: Record<GlyphMorphEasing, number> = { inOutCubic: 0, inOutQuint: 1, inOutSine: 2, inOutExpo: 3, linear: 4 };\nconst TRACKING_EM = -0.035;\nconst LINE_HEIGHT = 1.42;\n\n// ─── WebGL2 support, cached ──────────────────────────────────────────────────\n\nlet webgl2Support: boolean | null = null;\nfunction hasWebGL2(): 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}\nconst subscribeNoop = () => () => {};\nconst getServerSupport = () => true;\n\n// ─── Colour + layout ─────────────────────────────────────────────────────────\n\nlet scratch: CanvasRenderingContext2D | null = null;\nfunction scratchCtx(): CanvasRenderingContext2D | null {\n  if (!scratch && typeof document !== \"undefined\") scratch = document.createElement(\"canvas\").getContext(\"2d\");\n  return scratch;\n}\n\nfunction rgba(input: string): [number, number, number, number] {\n  const ctx = scratchCtx();\n  if (!ctx) return [1, 1, 1, 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 [1, 1, 1, 1];\n  return [+m[0] / 255, +m[1] / 255, +m[2] / 255, m[3] !== undefined ? +m[3] : 1];\n}\n\ntype TrackedCtx = CanvasRenderingContext2D & { letterSpacing?: string };\n\nfunction setType(ctx: CanvasRenderingContext2D, weight: number, px: number, family: string) {\n  ctx.font = `${weight} ${px}px ${family}`;\n  const t = ctx as TrackedCtx;\n  if (\"letterSpacing\" in t) t.letterSpacing = `${TRACKING_EM * px}px`;\n}\n\ninterface Layout {\n  width: number;\n  height: number;\n  fontSize: number;\n  baseline: number;\n  family: string;\n  weight: number;\n  key: string;\n}\n\nfunction computeLayout(width: number, words: string[], family: string, weight: number, maxFont: number): Layout | null {\n  const ctx = scratchCtx();\n  if (!ctx || width < 8 || !words.length) return null;\n  setType(ctx, weight, 100, family);\n  let widest = 1;\n  for (const w of words) widest = Math.max(widest, ctx.measureText(w).width);\n  const fontSize = Math.max(12, Math.min(maxFont, (width * 0.9 * 100) / widest));\n  setType(ctx, weight, fontSize, family);\n  const cap = ctx.measureText(\"H\").actualBoundingBoxAscent || fontSize * 0.7;\n  const height = Math.round(fontSize * LINE_HEIGHT);\n  // Centre the cap height, nudged up a touch so descenders have room to drip.\n  const baseline = height * 0.47 + cap / 2;\n  return {\n    width,\n    height,\n    fontSize,\n    baseline,\n    family,\n    weight,\n    key: `${width}|${height}|${fontSize.toFixed(2)}|${weight}|${family}`,\n  };\n}\n\n// ─── Signed distance field ───────────────────────────────────────────────────\n// Felzenszwalb & Huttenlocher's lower-envelope transform, run once for the\n// distance to ink and once for the distance to paper. Edge pixels seed with\n// their coverage so the zero crossing lands between pixels.\n\nconst BIG = 1e20;\n\nfunction envelope(grid: Float32Array, start: number, step: number, n: number, f: Float32Array, v: Int32Array, z: Float32Array) {\n  for (let i = 0; i < n; i++) f[i] = grid[start + i * step];\n  let k = 0;\n  v[0] = 0;\n  z[0] = -BIG;\n  z[1] = BIG;\n  for (let q = 1; q < n; q++) {\n    const fq = f[q] + q * q;\n    let r = v[k];\n    let s = (fq - (f[r] + r * r)) / (2 * (q - r));\n    while (s <= z[k] && k > 0) {\n      k--;\n      r = v[k];\n      s = (fq - (f[r] + r * r)) / (2 * (q - r));\n    }\n    k++;\n    v[k] = q;\n    z[k] = s;\n    z[k + 1] = BIG;\n  }\n  k = 0;\n  for (let q = 0; q < n; q++) {\n    while (z[k + 1] < q) k++;\n    const d = q - v[k];\n    grid[start + q * step] = f[v[k]] + d * d;\n  }\n}\n\nfunction transform(grid: Float32Array, w: number, h: number, f: Float32Array, v: Int32Array, z: Float32Array) {\n  for (let x = 0; x < w; x++) envelope(grid, x, w, h, f, v, z);\n  for (let y = 0; y < h; y++) envelope(grid, y * w, 1, w, f, v, z);\n}\n\ninterface Field {\n  data: Float32Array; // signed distance in em, positive outside the ink\n  w: number;\n  h: number;\n  cov: Uint8Array; // ink coverage, mipmapped on the GPU into the goo blur\n  texEm: number; // field texels per em\n  inkWidth: number; // advance width in CSS px, for the sweep\n}\n\nfunction buildField(word: string, lay: Layout, dpr: number): Field | null {\n  // Aim for a field font around 150 texels: plenty for display sizes, cheap to transform.\n  const target = Math.min(lay.fontSize * dpr, 156);\n  const s = Math.max(0.35, Math.min(2, target / lay.fontSize));\n  const w = Math.max(2, Math.round(lay.width * s));\n  const h = Math.max(2, Math.round(lay.height * s));\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  const px = lay.fontSize * s;\n  setType(ctx, lay.weight, px, lay.family);\n  ctx.textAlign = \"center\";\n  ctx.textBaseline = \"alphabetic\";\n  ctx.fillStyle = \"#fff\";\n  // letterSpacing adds trailing space after the last glyph; shift back by half of it.\n  const trail = \"letterSpacing\" in (ctx as TrackedCtx) ? TRACKING_EM * px : 0;\n  ctx.fillText(word, w / 2 - trail / 2, lay.baseline * s);\n  const inkWidth = ctx.measureText(word).width / s;\n  const img = ctx.getImageData(0, 0, w, h).data;\n\n  const n = w * h;\n  const cov = new Uint8Array(n);\n  for (let i = 0; i < n; i++) cov[i] = img[i * 4 + 3];\n  const toInk = new Float32Array(n);\n  const toPaper = new Float32Array(n);\n  for (let i = 0; i < n; i++) {\n    const a = img[i * 4 + 3] / 255;\n    if (a >= 0.999) {\n      toInk[i] = 0;\n      toPaper[i] = BIG;\n    } else if (a <= 0.001) {\n      toInk[i] = BIG;\n      toPaper[i] = 0;\n    } else {\n      const o = Math.max(0, 0.5 - a);\n      const iIn = Math.max(0, a - 0.5);\n      toInk[i] = o * o;\n      toPaper[i] = iIn * iIn;\n    }\n  }\n  const m = Math.max(w, h);\n  const f = new Float32Array(m);\n  const v = new Int32Array(m);\n  const z = new Float32Array(m + 1);\n  transform(toInk, w, h, f, v, z);\n  transform(toPaper, w, h, f, v, z);\n  const data = new Float32Array(n);\n  const inv = 1 / px;\n  for (let i = 0; i < n; i++) data[i] = (Math.sqrt(toInk[i]) - Math.sqrt(toPaper[i])) * inv;\n  return { data, cov, w, h, texEm: px, inkWidth };\n}\n\n// ─── Shaders ─────────────────────────────────────────────────────────────────\n\nconst VERT = `#version 300 es\nin vec2 aPos;\nout vec2 vUv;\nvoid main() {\n  vUv = vec2(aPos.x * 0.5 + 0.5, 0.5 - aPos.y * 0.5);\n  gl_Position = vec4(aPos, 0.0, 1.0);\n}`;\n\nconst FRAG = `#version 300 es\nprecision highp float;\nin vec2 vUv;\nout vec4 outColor;\nuniform sampler2D uA;     // signed distance, em\nuniform sampler2D uB;\nuniform sampler2D uGA;    // ink coverage, mipmapped: the mip chain is our blur\nuniform sampler2D uGB;\nuniform float uT;\nuniform float uStagger;\nuniform int uEase;\nuniform float uTime;\nuniform float uTension;\nuniform vec2 uEm;         // one em in uv units (x, y)\nuniform vec2 uSpan;       // sweep span in uv x: start, end\nuniform vec2 uTexel;      // one field texel in uv\nuniform float uTexEm;     // field texels per em\nuniform float uBaseY;     // baseline-ish centre in uv y\nuniform vec3 uInk;\nuniform float uInkA;\nuniform vec4 uAccent;\nuniform float uMode;      // 0 morph, 1 plain crossfade\nuniform float uSeed;\nuniform float uRimPx;\n\nconst float PI = 3.14159265;\n\nfloat ease(float t) {\n  if (uEase == 0) return t < 0.5 ? 4.0 * t * t * t : 1.0 - pow(-2.0 * t + 2.0, 3.0) * 0.5;\n  if (uEase == 1) return t < 0.5 ? 16.0 * pow(t, 5.0) : 1.0 - pow(-2.0 * t + 2.0, 5.0) * 0.5;\n  if (uEase == 2) return 0.5 - 0.5 * cos(PI * t);\n  if (uEase == 3) {\n    if (t <= 0.0) return 0.0;\n    if (t >= 1.0) return 1.0;\n    return t < 0.5 ? pow(2.0, 20.0 * t - 10.0) * 0.5 : (2.0 - pow(2.0, -20.0 * t + 10.0)) * 0.5;\n  }\n  return t;\n}\n\nfloat hash(vec2 p) {\n  p = fract(p * vec2(127.13, 311.71));\n  p += dot(p, p + 34.17);\n  return fract(p.x * p.y);\n}\nfloat vnoise(vec2 p) {\n  vec2 i = floor(p);\n  vec2 f = fract(p);\n  vec2 u = f * f * (3.0 - 2.0 * f);\n  return mix(mix(hash(i), hash(i + vec2(1.0, 0.0)), u.x), mix(hash(i + vec2(0.0, 1.0)), hash(i + vec2(1.0, 1.0)), u.x), u.y);\n}\n\n// Blurred coverage: a trilinear mip lookup softened by a rotated ring of taps so the\n// box-filtered mip levels never show their grid.\nfloat goo(sampler2D s, vec2 uv, float lod, float rad) {\n  float c = textureLod(s, uv, lod).r * 2.0;\n  for (int i = 0; i < 6; i++) {\n    float an = float(i) * 1.0471976 + 0.3;\n    c += textureLod(s, uv + vec2(cos(an), sin(an)) * rad * uTexel, lod).r;\n  }\n  return c / 8.0;\n}\n\nvoid main() {\n  vec2 uv = vUv;\n  // Position along the word, leaning a little so the front travels like a wave.\n  float xn = clamp((uv.x - uSpan.x) / max(1e-4, uSpan.y - uSpan.x), 0.0, 1.0);\n  xn = clamp(xn + (uv.y - 0.5) * 0.1, 0.0, 1.0);\n  float lt = clamp(uT * (1.0 + uStagger) - uStagger * xn, 0.0, 1.0);\n  float e = ease(lt);\n  float b = sin(PI * lt);                 // 0 at rest, 1 mid-morph\n  b *= b;\n\n  if (uMode > 0.5) {\n    float dA = texture(uA, uv).r;\n    float dB = texture(uB, uv).r;\n    float aA = clamp(0.5 - dA / max(fwidth(dA), 1e-5), 0.0, 1.0);\n    float aB = clamp(0.5 - dB / max(fwidth(dB), 1e-5), 0.0, 1.0);\n    float a = mix(aA, aB, e) * uInkA;\n    outColor = vec4(uInk * a, a);\n    return;\n  }\n\n  // Melt: mid-morph the ink slumps toward its centre line and drifts on slow flow noise.\n  vec2 p = (uv / uEm) * 0.55 + uSeed;\n  vec2 flow = vec2(vnoise(p + vec2(0.0, uTime * 0.5)), vnoise(p + vec2(5.2, -uTime * 0.4))) - 0.5;\n  vec2 q = uv;\n  q.y = uBaseY + (q.y - uBaseY) * (1.0 + 0.1 * b);\n  q += flow * 0.07 * b * uEm;\n  vec2 qA = q + vec2(0.0, -0.035 * b * e) * uEm;          // the outgoing word sags\n  vec2 qB = q + vec2(0.0, 0.035 * b * (1.0 - e)) * uEm;   // the incoming one settles\n\n  // Crisp: blend the two distance fields.\n  float dA = texture(uA, qA).r;\n  float dB = texture(uB, qB).r;\n  float dS = mix(dA, dB, e);\n\n  // Goo: blur both words (mip chain) and threshold their mixed coverage, the way an\n  // SVG blur + alpha matrix fuses shapes, but in one pass and with both words at once.\n  float sigma = (0.02 + 0.085 * b) * uTexEm;               // blur radius in texels\n  float lod = log2(max(1.0, sigma));\n  float cov = mix(goo(uGA, qA, lod, sigma * 0.7), goo(uGB, qB, lod, sigma * 0.7), e);\n  float thr = 0.5 - 0.05 * uTension * b;                  // surface tension: swell, bridge\n  float dG = (thr - cov) * (0.05 + 0.2 * b);             // coverage to em-ish distance\n\n  float w = smoothstep(0.0, 0.3, b);\n  float d = mix(dS, dG, w) - uTension * b * 0.012;\n\n  float fw = max(fwidth(d), 1e-5);\n  float alpha = clamp(0.5 - d / fw, 0.0, 1.0);\n\n  // Rim only where the two words disagree, i.e. the edge that is actually travelling.\n  float moving = smoothstep(0.03, 0.2, abs(dA - dB)) * smoothstep(0.1, 0.6, b);\n  float px = -d / fw;                     // depth inside the ink, in device pixels\n  float band = 1.0 - smoothstep(uRimPx * 0.45, uRimPx, px);\n  float rim = band * moving * uAccent.a * 0.9;\n  vec3 col = mix(uInk, uAccent.rgb, rim);\n\n  float a = alpha * uInkA;\n  outColor = vec4(col * a, a);\n}`;\n\ninterface FieldTextures {\n  sdf: WebGLTexture;\n  cov: WebGLTexture;\n}\n\ninterface Renderer {\n  gl: WebGL2RenderingContext;\n  prog: WebGLProgram;\n  vao: WebGLVertexArrayObject;\n  buf: WebGLBuffer;\n  u: Record<string, WebGLUniformLocation | null>;\n  textures: Map<string, FieldTextures>;\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(\"GlyphMorph shader:\", gl.getShaderInfoLog(sh));\n    gl.deleteShader(sh);\n    return null;\n  }\n  return sh;\n}\n\nfunction createRenderer(gl: WebGL2RenderingContext): Renderer | null {\n  const vs = compile(gl, gl.VERTEX_SHADER, VERT);\n  const fs = compile(gl, gl.FRAGMENT_SHADER, FRAG);\n  if (!vs || !fs) return null;\n  const prog = gl.createProgram();\n  if (!prog) return null;\n  gl.attachShader(prog, vs);\n  gl.attachShader(prog, fs);\n  gl.bindAttribLocation(prog, 0, \"aPos\");\n  gl.linkProgram(prog);\n  gl.deleteShader(vs);\n  gl.deleteShader(fs);\n  if (!gl.getProgramParameter(prog, gl.LINK_STATUS)) return null;\n  const vao = gl.createVertexArray();\n  const buf = gl.createBuffer();\n  if (!vao || !buf) return null;\n  gl.bindVertexArray(vao);\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.bindVertexArray(null);\n  const names = [\"uA\", \"uB\", \"uGA\", \"uGB\", \"uTexel\", \"uTexEm\", \"uBaseY\", \"uT\", \"uStagger\", \"uEase\", \"uTime\", \"uTension\", \"uEm\", \"uSpan\", \"uInk\", \"uInkA\", \"uAccent\", \"uMode\", \"uSeed\", \"uRimPx\"];\n  const u: Renderer[\"u\"] = {};\n  for (const n of names) u[n] = gl.getUniformLocation(prog, n);\n  return { gl, prog, vao, buf, u, textures: new Map() };\n}\n\nfunction uploadField(r: Renderer, key: string, field: Field): FieldTextures | null {\n  const cached = r.textures.get(key);\n  if (cached) return cached;\n  const { gl } = r;\n  const sdf = gl.createTexture();\n  const cov = gl.createTexture();\n  if (!sdf || !cov) return null;\n  gl.pixelStorei(gl.UNPACK_ALIGNMENT, 1);\n  gl.bindTexture(gl.TEXTURE_2D, sdf);\n  gl.texImage2D(gl.TEXTURE_2D, 0, gl.R16F, field.w, field.h, 0, gl.RED, gl.FLOAT, field.data);\n  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);\n  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);\n  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);\n  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);\n  gl.bindTexture(gl.TEXTURE_2D, cov);\n  gl.texImage2D(gl.TEXTURE_2D, 0, gl.R8, field.w, field.h, 0, gl.RED, gl.UNSIGNED_BYTE, field.cov);\n  gl.generateMipmap(gl.TEXTURE_2D);\n  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR_MIPMAP_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 pair = { sdf, cov };\n  r.textures.set(key, pair);\n  return pair;\n}\n\n// ─── Component ───────────────────────────────────────────────────────────────\n\ninterface Frame {\n  a: number;\n  b: number;\n  t: number;\n}\n\nexport function GlyphMorph({\n  words: wordsProp = DEFAULT_WORDS,\n  interval = 1600,\n  duration = 1500,\n  easing = \"inOutCubic\",\n  stagger = 0.4,\n  tension = 0.6,\n  color,\n  accentColor,\n  tone: toneProp,\n  fontSize = 168,\n  fontWeight = 640,\n  fontFamily,\n  autoplay = true,\n  index,\n  startIndex = 0,\n  progress,\n  announce = false,\n  ariaLabel,\n  className,\n  onWordChange,\n}: GlyphMorphProps) {\n  const tone = useBjorkTone(toneProp);\n  const reducedMotion = useReducedMotion() ?? false;\n  const gl2 = useSyncExternalStore(subscribeNoop, hasWebGL2, getServerSupport);\n\n  const wordsKey = wordsProp.join(\"\\u0000\");\n  // eslint-disable-next-line react-hooks/exhaustive-deps\n  const words = useMemo(() => (wordsProp.length ? wordsProp.slice() : [\"\"]), [wordsKey]);\n  const count = words.length;\n  const first = ((startIndex % count) + count) % count;\n\n  const wrapRef = useRef<HTMLDivElement>(null);\n  const canvasRef = useRef<HTMLCanvasElement | null>(null);\n  const srRef = useRef<HTMLSpanElement>(null);\n  const { width } = useElementSize(wrapRef);\n\n  const [fontsReady, setFontsReady] = useState(false);\n  const [family, setFamily] = useState<string | null>(null);\n  const [failed, setFailed] = useState(false);\n  const [epoch, setEpoch] = useState(0);\n\n  useEffect(() => {\n    let alive = true;\n    const el = wrapRef.current;\n    if (el) setFamily(fontFamily ?? getComputedStyle(el).fontFamily ?? \"sans-serif\");\n    const fonts = typeof document !== \"undefined\" ? document.fonts : undefined;\n    if (!fonts) {\n      setFontsReady(true);\n      return;\n    }\n    fonts.ready.then(() => alive && setFontsReady(true));\n    return () => {\n      alive = false;\n    };\n  }, [fontFamily]);\n\n  // Make sure the exact weight is loaded before we rasterise with it.\n  const [weightReady, setWeightReady] = useState<string>(\"\");\n  useEffect(() => {\n    if (!fontsReady || !family) return;\n    let alive = true;\n    const spec = `${fontWeight} 64px ${family}`;\n    const done = () => alive && setWeightReady(spec);\n    document.fonts.load(spec, words.join(\" \")).then(done, done);\n    return () => {\n      alive = false;\n    };\n  }, [fontsReady, family, fontWeight, words]);\n\n  const layout = useMemo(() => {\n    if (!weightReady || !family || width < 8) return null;\n    return computeLayout(width, words, family, fontWeight, fontSize);\n  }, [weightReady, family, width, words, fontWeight, fontSize]);\n\n  // Colours\n  const palette = BJORK_PALETTE[tone];\n  const ink = useMemo(() => rgba(color ?? palette.text), [color, palette.text]);\n  const accent = useMemo(() => rgba(accentColor ?? (tone === \"light\" ? \"#e2560f\" : \"#ec5c13\")), [accentColor, tone]);\n\n  // Mutable engine state\n  const fieldsRef = useRef(new Map<string, Field>());\n  const rendererRef = useRef<Renderer | null>(null);\n  const ctx2dRef = useRef<CanvasRenderingContext2D | null>(null);\n  const dprRef = useRef(1);\n  const dirtyRef = useRef(true);\n  const [seed] = useState(() => Math.random() * 40);\n  const seedRef = useRef(seed);\n  const stateRef = useRef({ idx: first, next: first, morphing: false, elapsed: 0, holdUntil: 0, time: 0 });\n  const timerRef = useRef<number | null>(null);\n\n  const propsRef = useRef({ interval, duration, easing, stagger, tension, ink, accent, autoplay, index, progress, reducedMotion, onWordChange, announce });\n  useEffect(() => {\n    propsRef.current = { interval, duration, easing, stagger, tension, ink, accent, autoplay, index, progress, reducedMotion, onWordChange, announce };\n    dirtyRef.current = true;\n  });\n\n  const getField = useCallback(\n    (i: number): Field | null => {\n      if (!layout) return null;\n      const word = words[i] ?? \"\";\n      const key = `${word}|${layout.key}|${dprRef.current}`;\n      let f = fieldsRef.current.get(key) ?? null;\n      if (!f) {\n        f = buildField(word, layout, dprRef.current);\n        if (!f) return null;\n        if (fieldsRef.current.size > 16) {\n          const oldest = fieldsRef.current.keys().next().value;\n          if (oldest !== undefined) fieldsRef.current.delete(oldest);\n        }\n        fieldsRef.current.set(key, f);\n      }\n      return f;\n    },\n    [layout, words],\n  );\n\n  const fieldKey = useCallback(\n    (i: number) => (layout ? `${words[i] ?? \"\"}|${layout.key}|${dprRef.current}` : \"\"),\n    [layout, words],\n  );\n\n  // Draw one frame: morph from word a to word b at linear progress t.\n  const draw = useCallback(\n    (fr: Frame) => {\n      const canvas = canvasRef.current;\n      if (!canvas || !layout) return;\n      const p = propsRef.current;\n      const fa = getField(fr.a);\n      const fb = fr.b === fr.a ? fa : getField(fr.b);\n      if (!fa || !fb) return;\n      const r = rendererRef.current;\n      if (r) {\n        const { gl, u } = r;\n        if (gl.isContextLost()) return;\n        // Evict textures whose layout is stale.\n        if (r.textures.size > 8) {\n          const keep = new Set([fieldKey(fr.a), fieldKey(fr.b)]);\n          for (const [k, tex] of r.textures) {\n            if (!keep.has(k)) {\n              gl.deleteTexture(tex.sdf);\n              gl.deleteTexture(tex.cov);\n              r.textures.delete(k);\n            }\n          }\n        }\n        const ta = uploadField(r, fieldKey(fr.a), fa);\n        const tb = uploadField(r, fieldKey(fr.b), fb);\n        if (!ta || !tb) return;\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(r.prog);\n        gl.activeTexture(gl.TEXTURE0);\n        gl.bindTexture(gl.TEXTURE_2D, ta.sdf);\n        gl.activeTexture(gl.TEXTURE1);\n        gl.bindTexture(gl.TEXTURE_2D, tb.sdf);\n        gl.activeTexture(gl.TEXTURE2);\n        gl.bindTexture(gl.TEXTURE_2D, ta.cov);\n        gl.activeTexture(gl.TEXTURE3);\n        gl.bindTexture(gl.TEXTURE_2D, tb.cov);\n        gl.uniform1i(u.uA, 0);\n        gl.uniform1i(u.uB, 1);\n        gl.uniform1i(u.uGA, 2);\n        gl.uniform1i(u.uGB, 3);\n        gl.uniform2f(u.uTexel, 1 / fa.w, 1 / fa.h);\n        gl.uniform1f(u.uTexEm, fa.texEm);\n        gl.uniform1f(u.uBaseY, (layout.baseline - layout.fontSize * 0.36) / layout.height);\n        gl.uniform1f(u.uT, fr.t);\n        gl.uniform1f(u.uStagger, Math.max(0, Math.min(1.5, p.stagger)));\n        gl.uniform1i(u.uEase, EASE_INDEX[p.easing] ?? 0);\n        gl.uniform1f(u.uTime, stateRef.current.time);\n        gl.uniform1f(u.uTension, Math.max(0, Math.min(1.5, p.tension)));\n        gl.uniform2f(u.uEm, layout.fontSize / layout.width, layout.fontSize / layout.height);\n        const span = Math.max(fa.inkWidth, fb.inkWidth) / layout.width;\n        gl.uniform2f(u.uSpan, 0.5 - span / 2, 0.5 + span / 2);\n        gl.uniform3f(u.uInk, p.ink[0], p.ink[1], p.ink[2]);\n        gl.uniform1f(u.uInkA, p.ink[3]);\n        gl.uniform4f(u.uAccent, p.accent[0], p.accent[1], p.accent[2], p.accent[3]);\n        gl.uniform1f(u.uMode, p.reducedMotion ? 1 : 0);\n        gl.uniform1f(u.uSeed, seedRef.current);\n        gl.uniform1f(u.uRimPx, Math.max(1.5, Math.min(5, layout.fontSize * dprRef.current * 0.018)));\n        gl.bindVertexArray(r.vao);\n        gl.drawArrays(gl.TRIANGLES, 0, 3);\n        gl.bindVertexArray(null);\n        return;\n      }\n      // Canvas2D fallback: a soft blurred crossfade.\n      const ctx = ctx2dRef.current;\n      if (!ctx) return;\n      const dpr = dprRef.current;\n      ctx.setTransform(dpr, 0, 0, dpr, 0, 0);\n      ctx.clearRect(0, 0, layout.width, layout.height);\n      setType(ctx, layout.weight, layout.fontSize, layout.family);\n      ctx.textAlign = \"center\";\n      ctx.textBaseline = \"alphabetic\";\n      const [cr, cg, cb, ca] = p.ink;\n      ctx.fillStyle = `rgba(${Math.round(cr * 255)},${Math.round(cg * 255)},${Math.round(cb * 255)},${ca})`;\n      const t = fr.t * fr.t * (3 - 2 * fr.t);\n      const bump = 4 * t * (1 - t);\n      const blur = p.reducedMotion ? 0 : bump * layout.fontSize * 0.06;\n      const trail = TRACKING_EM * layout.fontSize;\n      const pass = (word: string, alpha: number) => {\n        if (alpha <= 0.002) return;\n        ctx.globalAlpha = alpha;\n        ctx.filter = blur > 0.2 ? `blur(${blur.toFixed(2)}px)` : \"none\";\n        ctx.fillText(word, layout.width / 2 - trail / 2, layout.baseline);\n      };\n      pass(words[fr.a] ?? \"\", 1 - t);\n      if (fr.b !== fr.a) pass(words[fr.b] ?? \"\", t);\n      ctx.globalAlpha = 1;\n      ctx.filter = \"none\";\n    },\n    [layout, words, getField, fieldKey],\n  );\n\n  // Context setup. The canvas is created here rather than in JSX so a remount (StrictMode,\n  // fast refresh) never inherits a context we already lost on cleanup.\n  useEffect(() => {\n    const wrap = wrapRef.current;\n    if (!wrap) return;\n    const canvas = document.createElement(\"canvas\");\n    canvas.setAttribute(\"aria-hidden\", \"true\");\n    canvas.className = \"pointer-events-none absolute left-0 top-0 block\";\n    wrap.prepend(canvas);\n    canvasRef.current = canvas;\n    const detach = () => {\n      canvas.remove();\n      if (canvasRef.current === canvas) canvasRef.current = null;\n    };\n    if (gl2) {\n      const gl = canvas.getContext(\"webgl2\", { alpha: true, premultipliedAlpha: true, antialias: false });\n      if (!gl) {\n        setFailed(true);\n        return detach;\n      }\n      const init = () => {\n        rendererRef.current = createRenderer(gl);\n        if (!rendererRef.current && !gl.isContextLost()) setFailed(true);\n        dirtyRef.current = true;\n        setEpoch((n) => n + 1);\n      };\n      const onLost = (e: Event) => {\n        e.preventDefault();\n        rendererRef.current = null;\n      };\n      canvas.addEventListener(\"webglcontextlost\", onLost);\n      canvas.addEventListener(\"webglcontextrestored\", init);\n      init();\n      return () => {\n        canvas.removeEventListener(\"webglcontextlost\", onLost);\n        canvas.removeEventListener(\"webglcontextrestored\", init);\n        const r = rendererRef.current;\n        if (r && !gl.isContextLost()) {\n          for (const tex of r.textures.values()) {\n            gl.deleteTexture(tex.sdf);\n            gl.deleteTexture(tex.cov);\n          }\n          gl.deleteBuffer(r.buf);\n          gl.deleteVertexArray(r.vao);\n          gl.deleteProgram(r.prog);\n        }\n        rendererRef.current = null;\n        gl.getExtension(\"WEBGL_lose_context\")?.loseContext();\n        detach();\n      };\n    }\n    const init2d = () => {\n      ctx2dRef.current = canvas.getContext(\"2d\");\n      if (!ctx2dRef.current) setFailed(true);\n      setEpoch((n) => n + 1);\n    };\n    init2d();\n    return () => {\n      ctx2dRef.current = null;\n      detach();\n    };\n  }, [gl2]);\n\n  // Size the canvas when the layout changes; fields rebuild lazily.\n  useEffect(() => {\n    const canvas = canvasRef.current;\n    if (!canvas || !layout) return;\n    dprRef.current = sizeCanvas(canvas, layout.width, layout.height, defaultMaxDpr());\n    // Drop fields from older layouts.\n    for (const k of fieldsRef.current.keys()) if (!k.includes(`|${layout.key}|`)) fieldsRef.current.delete(k);\n    dirtyRef.current = true;\n  }, [layout, epoch]);\n\n  const announceWord = useCallback(\n    (i: number) => {\n      const node = srRef.current?.lastChild;\n      if (node && node.nodeType === Node.TEXT_NODE) node.nodeValue = words[i] ?? \"\";\n      propsRef.current.onWordChange?.(i, words[i] ?? \"\");\n    },\n    [words],\n  );\n\n  // Reset the sequence when the word list changes.\n  useEffect(() => {\n    const st = stateRef.current;\n    st.idx = st.next = first;\n    st.morphing = false;\n    st.elapsed = 0;\n    st.holdUntil = 0;\n    dirtyRef.current = true;\n  }, [words, first]);\n\n  const loopRef = useRef<{ wake: () => void }>({ wake: () => {} });\n\n  const frame = useCallback(\n    (dt: number) => {\n      if (!layout) return false;\n      const p = propsRef.current;\n      const st = stateRef.current;\n\n      if (p.progress !== undefined && count > 1) {\n        st.time = 1.7; // a fixed, pleasant noise phase for the frozen frame\n        const t = Math.max(0, Math.min(1, p.progress));\n        draw({ a: st.idx, b: (st.idx + 1) % count, t });\n        dirtyRef.current = false;\n        return false;\n      }\n\n      const now = performance.now();\n      if (!st.morphing) {\n        let target: number | null = null;\n        if (p.index !== undefined) {\n          const want = ((p.index % count) + count) % count;\n          if (want !== st.idx) target = want;\n        } else if (p.autoplay && count > 1) {\n          if (!st.holdUntil) st.holdUntil = now + p.interval;\n          if (now >= st.holdUntil) target = (st.idx + 1) % count;\n        }\n        if (target === null) {\n          if (dirtyRef.current) {\n            draw({ a: st.idx, b: st.idx, t: 0 });\n            dirtyRef.current = false;\n          }\n          // Warm the next field while idle so the morph starts without a hitch.\n          if (count > 1) getField((st.idx + 1) % count);\n          if (timerRef.current) window.clearTimeout(timerRef.current);\n          timerRef.current = null;\n          if (p.index === undefined && p.autoplay && count > 1) {\n            timerRef.current = window.setTimeout(() => loopRef.current.wake(), Math.max(16, st.holdUntil - now));\n          }\n          return false;\n        }\n        st.morphing = true;\n        st.next = target;\n        st.elapsed = 0;\n      }\n\n      const dur = p.reducedMotion ? Math.min(450, p.duration) : p.duration;\n      st.elapsed += dt * 1000;\n      st.time += dt;\n      const t = Math.min(1, st.elapsed / Math.max(1, dur));\n      draw({ a: st.idx, b: st.next, t });\n      if (t >= 1) {\n        st.idx = st.next;\n        st.morphing = false;\n        st.holdUntil = now + p.interval;\n        dirtyRef.current = true;\n        announceWord(st.idx);\n      }\n      return true;\n    },\n    [layout, count, draw, getField, announceWord],\n  );\n\n  const loop = useVisibleLoop(wrapRef, frame, { enabled: !failed });\n  useEffect(() => {\n    loopRef.current = loop;\n  }, [loop]);\n\n  // Wake the loop whenever something visible changes.\n  useEffect(() => {\n    dirtyRef.current = true;\n    loop.wake();\n  }, [loop, layout, epoch, ink, accent, index, progress, autoplay, reducedMotion, words, easing, stagger, tension]);\n\n  useEffect(\n    () => () => {\n      if (timerRef.current) window.clearTimeout(timerRef.current);\n    },\n    [],\n  );\n\n  const height = layout ? layout.height : Math.round(Math.min(fontSize, 120) * LINE_HEIGHT);\n\n  return (\n    <div\n      ref={wrapRef}\n      className={cn(\"relative w-full select-none\", className)}\n      style={{ height, color: color ?? palette.text }}\n      data-glyph-morph=\"\"\n    >\n      <span\n        ref={srRef}\n        className={failed ? \"absolute inset-0 flex items-center justify-center text-[clamp(2rem,8vw,6rem)] font-semibold tracking-tight\" : \"sr-only\"}\n        aria-live={announce ? \"polite\" : undefined}\n        aria-atomic={announce ? \"true\" : undefined}\n      >\n        {ariaLabel ? <span className=\"sr-only\">{`${ariaLabel}: `}</span> : null}\n        {words[first]}\n      </span>\n    </div>\n  );\n}\n\nexport default GlyphMorph;\n",
      "type": "registry:component",
      "target": "@components/bjork-ui/text/glyph-morph.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/glyph-morph.json",
    "installCommand": "npx shadcn@latest add https://ui.isaiahbjork.com/glyph-morph.json"
  },
  "type": "registry:component"
}