{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "stroke-draw-type",
  "title": "Stroke draw type",
  "description": "Stroke draw type 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/stroke-draw-type.tsx",
      "content": "\"use client\";\n\n// Stroke Draw Type: outline type that draws itself with a pen, then fills from below like\n// liquid rising in a glass. A small accent glow rides the leading end of every stroke.\n// Hovering drains the fill back down; the surface is a goo-filtered mask, so the liquid\n// clings to the letter tops in beads before it lets go. The glyph edges come from the text\n// itself, so the type stays sharp at any DPR.\n\nimport { useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore } from \"react\";\nimport { useReducedMotion } from \"framer-motion\";\nimport { cn } from \"@/lib/utils\";\nimport { BJORK_PALETTE, type BjorkTone } from \"../_core/palette\";\nimport { useBjorkTone } from \"../_core/tone\";\nimport { useVisibleLoop } from \"../_core/loop\";\nimport { cubicBezier } from \"../_core/motion\";\nimport { mulberry32, hashString } from \"../_core/random\";\n\nexport type StrokeDrawTrigger = \"mount\" | \"in-view\" | \"hover\" | \"loop\";\n\nexport interface StrokeDrawTypeProps {\n  text?: string;\n  /** Outline width in CSS px at the largest font size. */\n  strokeWidth?: number;\n  /** Outline colour. Defaults to the tone's text colour. */\n  color?: string;\n  /** Colour of the rising fill. Defaults to `color`. */\n  fillColor?: string;\n  /** Pen-tip glow colour. */\n  accentColor?: string;\n  /**\n   * mount: draw on mount. in-view: draw when first scrolled into view. hover: draw the outline,\n   * then fill only while hovered. loop: draw, fill, drain and undraw on repeat.\n   */\n  trigger?: StrokeDrawTrigger;\n  /** Seconds one letter's outline takes to draw. */\n  duration?: number;\n  /** Seconds between neighbouring letters. */\n  stagger?: number;\n  /** Seconds before the first letter starts. */\n  delay?: number;\n  /** Seconds the filled word holds before draining, in loop mode. */\n  hold?: number;\n  /** Hovering drains the fill (mount and in-view triggers). */\n  drainOnHover?: boolean;\n  /** Largest font size in CSS px. The word shrinks to fit narrower containers. */\n  fontSize?: number;\n  fontWeight?: number;\n  fontFamily?: string;\n  /** Tracking in em. */\n  letterSpacing?: number;\n  tone?: BjorkTone;\n  /** False holds the first frame until changed. */\n  autoplay?: boolean;\n  /** Renders a still frame this far (0 to 1) through the draw and fill. For previews. */\n  freezeAt?: number;\n  seed?: number;\n  ariaLabel?: string;\n  className?: string;\n}\n\nconst FS = 100; // font size in SVG user units; the viewBox scales it to the container\nconst DASH_MAX = 900;\nconst TIP = 7;\nconst DROPS = 3;\nconst SAMPLES = 9;\nconst subscribeNoop = () => () => {};\nconst getTrue = () => true;\nconst getFalse = () => false;\nconst DEFAULT_FONT = \"var(--font-geist-sans), ui-sans-serif, system-ui, sans-serif\";\n\nconst drawEase = cubicBezier(0.55, 0.06, 0.35, 1);\nconst fillEase = cubicBezier(0.3, 0, 0.2, 1);\nconst clamp01 = (v: number) => (v < 0 ? 0 : v > 1 ? 1 : v);\n\ninterface Letter {\n  ch: string;\n  x: number;\n  left: number;\n  right: number;\n  /** Estimated length of the glyph's longest contour (text has no getTotalLength). */\n  dash: number;\n}\ninterface Layout {\n  letters: Letter[];\n  vb: [number, number, number, number];\n  top: number;\n  bottom: number;\n}\ninterface Drop {\n  x: number;\n  r: number;\n  k: number; // spring stiffness; slow drops hang back as beads\n  off: number;\n}\n\nexport function StrokeDrawType({\n  text = \"Signature\",\n  strokeWidth = 1.6,\n  color,\n  fillColor,\n  accentColor,\n  trigger = \"in-view\",\n  duration = 1.5,\n  stagger = 0.11,\n  delay = 0.15,\n  hold = 1.6,\n  drainOnHover = true,\n  fontSize = 160,\n  fontWeight = 600,\n  fontFamily = DEFAULT_FONT,\n  letterSpacing = -0.035,\n  tone,\n  autoplay = true,\n  freezeAt,\n  seed,\n  ariaLabel,\n  className,\n}: StrokeDrawTypeProps) {\n  const resolvedTone = useBjorkTone(tone);\n  const palette = BJORK_PALETTE[resolvedTone];\n  const ink = color ?? palette.text;\n  const fill = fillColor ?? ink;\n  const accent = accentColor ?? palette.accentInk;\n  // Gate on mount so the server render and hydration agree; the preference applies right after.\n  const mounted = useSyncExternalStore(subscribeNoop, getTrue, getFalse);\n  const reduced = (useReducedMotion() ?? false) && mounted;\n  const frozen = freezeAt !== undefined && !reduced;\n  const animated = !reduced && !frozen;\n\n  const uid = useId().replace(/[^a-zA-Z0-9_-]/g, \"\");\n  const ids = { mask: `sdt-mask-${uid}`, goo: `sdt-goo-${uid}`, glow: `sdt-glow-${uid}` };\n\n  const rootRef = useRef<HTMLDivElement>(null);\n  const measureRef = useRef<SVGTextElement>(null);\n  const strokeEls = useRef<(SVGTextElement | null)[]>([]);\n  const tipEls = useRef<(SVGTextElement | null)[]>([]);\n  const surfaceEls = useRef<(SVGPathElement | null)[]>([]);\n  const dropEls = useRef<(SVGCircleElement | null)[]>([]);\n  const fillGroupRef = useRef<SVGGElement>(null);\n  const [layout, setLayout] = useState<Layout | null>(null);\n\n  const sw = (strokeWidth * FS) / fontSize;\n\n  // Measure per-letter positions (kerning included) and the word's box.\n  useLayoutEffect(() => {\n    const el = measureRef.current;\n    if (!el) return;\n    let alive = true;\n    const measure = () => {\n      if (!alive) return;\n      let box: DOMRect;\n      try {\n        box = el.getBBox();\n      } catch {\n        return;\n      }\n      if (!box.width) return;\n      const letters: Letter[] = [];\n      let idx = 0;\n      for (const ch of Array.from(text)) {\n        if (ch.trim()) {\n          const start = el.getStartPositionOfChar(idx);\n          const ext = el.getExtentOfChar(idx);\n          const w = ext.width;\n          letters.push({ ch, x: start.x, left: ext.x, right: ext.x + w, dash: Math.min(DASH_MAX, 2.4 * w + FS * 1.05) });\n        }\n        idx += ch.length;\n      }\n      const pad = sw * 2 + FS * 0.05;\n      setLayout({\n        letters,\n        vb: [box.x - pad, box.y - pad, box.width + pad * 2, box.height + pad * 2],\n        top: box.y,\n        bottom: box.y + box.height,\n      });\n    };\n    measure();\n    document.fonts?.ready.then(measure);\n    return () => {\n      alive = false;\n    };\n  }, [text, fontFamily, fontWeight, letterSpacing, sw]);\n\n  const n = layout?.letters.length ?? 0;\n  const drawEnd = duration;\n  const fillStart = duration * 0.58;\n  const fillDur = duration * 0.85;\n  const total = delay + Math.max(0, n - 1) * stagger + fillStart + fillDur;\n  const drainDur = duration * 0.9;\n  const cycle = total + hold + (n - 1) * stagger * 0.5 + drainDur + duration * 0.7 + 0.7;\n\n  const drops = useMemo<Drop[]>(() => {\n    if (!layout) return [];\n    const rand = mulberry32(seed ?? hashString(text));\n    const out: Drop[] = [];\n    for (const l of layout.letters) {\n      const w = l.right - l.left;\n      for (let d = 0; d < DROPS; d++) {\n        out.push({\n          x: l.left + w * (0.2 + 0.6 * ((d + 0.5) / DROPS)) + (rand() - 0.5) * w * 0.15,\n          r: FS * (0.035 + rand() * 0.035),\n          k: 55 + rand() * 110,\n          off: FS * (0.02 + rand() * 0.04),\n        });\n      }\n    }\n    return out;\n  }, [layout, seed, text]);\n\n  const sim = useRef({\n    clock: 0,\n    started: false,\n    hoverOn: false,\n    hoverLetter: 0,\n    drain: new Float64Array(0),\n    hoverAt: new Float64Array(0),\n    surface: new Float64Array(0),\n    prevSurface: new Float64Array(0),\n    dropY: new Float64Array(0),\n    dropV: new Float64Array(0),\n    draw: new Float64Array(0),\n    level: new Float64Array(0),\n  });\n\n  // Reset the per-letter buffers when the layout changes.\n  useEffect(() => {\n    const s = sim.current;\n    s.drain = new Float64Array(n).fill(trigger === \"hover\" ? 1 : 0);\n    s.hoverAt = new Float64Array(n);\n    s.surface = new Float64Array(n);\n    s.prevSurface = new Float64Array(n);\n    s.dropY = new Float64Array(n * DROPS).fill(1e4);\n    s.dropV = new Float64Array(n * DROPS);\n    s.draw = new Float64Array(n);\n    s.level = new Float64Array(n);\n  }, [n, trigger]);\n\n  // Writes one frame. `draw[i]` and `level[i]` are 0..1; `vel[i]` is the surface speed for the wave.\n  const paint = useCallback(\n    (draw: ArrayLike<number>, level: ArrayLike<number>, t: number, dt: number, settleDrops: boolean) => {\n      if (!layout) return;\n      const { letters, top, bottom } = layout;\n      const s = sim.current;\n      const lo = top - FS * 0.12;\n      const hi = bottom + FS * 0.16;\n      let anyPartial = false;\n      for (let i = 0; i < letters.length; i++) {\n        const p = draw[i];\n        const stroke = strokeEls.current[i];\n        const tip = tipEls.current[i];\n        if (stroke) {\n          if (p >= 1) {\n            stroke.style.strokeDasharray = \"none\";\n            stroke.style.strokeDashoffset = \"0\";\n          } else {\n            const L = letters[i].dash;\n            stroke.style.strokeDasharray = `${L} ${L}`;\n            stroke.style.strokeDashoffset = (L * (1 - p)).toFixed(2);\n          }\n          stroke.style.opacity = p <= 0 ? \"0\" : \"1\";\n        }\n        if (tip) {\n          const along = p * letters[i].dash;\n          tip.style.strokeDasharray = `${TIP} ${DASH_MAX * 2}`;\n          tip.style.strokeDashoffset = (-(along - TIP)).toFixed(2);\n          const vis = p > 0 && p < 1 ? Math.min(1, p * 12, (1 - p) * 6) : 0;\n          tip.style.opacity = vis.toFixed(3);\n        }\n\n        const lv = clamp01(level[i]);\n        if (lv > 0 && lv < 1) anyPartial = true;\n        const y = hi + (lo - hi) * lv;\n        s.prevSurface[i] = s.surface[i] || y;\n        s.surface[i] = y;\n        const vel = dt > 0 ? (y - s.prevSurface[i]) / dt : 0;\n        const amp = Math.min(FS * 0.035, Math.abs(vel) * 0.012) + (lv > 0 && lv < 1 ? FS * 0.008 : 0);\n        const l = letters[i];\n        const x0 = l.left - FS * 0.08;\n        const x1 = l.right + FS * 0.08;\n        let d = `M${x0.toFixed(1)} ${(hi + FS).toFixed(1)}`;\n        for (let k = 0; k <= SAMPLES; k++) {\n          const x = x0 + ((x1 - x0) * k) / SAMPLES;\n          const wy = y + amp * Math.sin(x * 0.11 + t * 7 + i * 1.7) + amp * 0.5 * Math.sin(x * 0.27 - t * 4.3);\n          d += `L${x.toFixed(1)} ${wy.toFixed(1)}`;\n        }\n        d += `L${x1.toFixed(1)} ${(hi + FS).toFixed(1)}Z`;\n        surfaceEls.current[i]?.setAttribute(\"d\", d);\n\n        for (let k = 0; k < DROPS; k++) {\n          const di = i * DROPS + k;\n          const drop = drops[di];\n          const el = dropEls.current[di];\n          if (!drop || !el) continue;\n          const target = y + drop.off + drop.r;\n          if (settleDrops || dt <= 0 || s.dropY[di] > 9e3) {\n            s.dropY[di] = target;\n            s.dropV[di] = 0;\n          } else {\n            // Damped spring, sub-stepped. Slow drops hang above a falling surface.\n            const steps = 3;\n            const h = dt / steps;\n            for (let q = 0; q < steps; q++) {\n              const a = -drop.k * (s.dropY[di] - target) - 11 * s.dropV[di];\n              s.dropV[di] += a * h;\n              s.dropY[di] += s.dropV[di] * h;\n            }\n          }\n          el.setAttribute(\"cy\", (s.dropY[di] || target).toFixed(2));\n        }\n      }\n      // A full or empty mask is a plain fill; skip the goo pass then.\n      const g = fillGroupRef.current;\n      if (g) {\n        const allFull = !anyPartial && Array.from({ length: letters.length }, (_, i) => level[i] >= 1).every(Boolean);\n        const dropsSettled = s.dropV.every((v) => Math.abs(v) < 0.5);\n        g.setAttribute(\"mask\", allFull && dropsSettled ? \"none\" : `url(#${ids.mask})`);\n      }\n    },\n    [layout, drops, ids.mask],\n  );\n\n  // Timeline: per-letter draw and fill amounts at time `t` (seconds since start).\n  const timeline = useCallback(\n    (t: number, draw: Float64Array, level: Float64Array) => {\n      for (let i = 0; i < n; i++) {\n        const s0 = delay + i * stagger;\n        draw[i] = drawEase(clamp01((t - s0) / drawEnd));\n        level[i] = fillEase(clamp01((t - s0 - fillStart) / fillDur));\n      }\n    },\n    [n, delay, stagger, drawEnd, fillStart, fillDur],\n  );\n\n  const frame = useCallback(\n    (dt: number) => {\n      const s = sim.current;\n      if (!s.started || !layout) return false;\n      s.clock += dt;\n      const t = s.clock;\n      if (s.draw.length !== n) {\n        s.draw = new Float64Array(n);\n        s.level = new Float64Array(n);\n      }\n      const { draw, level } = s;\n      let busy = true;\n\n      if (trigger === \"loop\") {\n        const c = t % cycle;\n        timeline(c, draw, level);\n        const drainStart = total + hold;\n        const undrawStart = drainStart + (n - 1) * stagger * 0.5 + drainDur;\n        for (let i = 0; i < n; i++) {\n          const dn = fillEase(clamp01((c - drainStart - i * stagger * 0.5) / drainDur));\n          level[i] *= 1 - dn;\n          if (c >= undrawStart) {\n            const u = drawEase(clamp01((c - undrawStart - i * stagger * 0.4) / (duration * 0.7)));\n            draw[i] = 1 - u;\n          }\n        }\n      } else {\n        timeline(t, draw, level);\n        const hoverMode = trigger === \"hover\";\n        let moving = t < total + 0.1;\n        for (let i = 0; i < n; i++) {\n          // Hover trigger: empty at rest, fill while hovered. Otherwise: full at rest, drain while hovered.\n          const wantOn = s.hoverOn && t >= s.hoverAt[i];\n          const target = hoverMode ? (wantOn ? 0 : 1) : wantOn && drainOnHover ? 1 : 0;\n          // Constant-rate tween; the ease is applied below. Draining is slower than refilling.\n          const step = dt / (target > s.drain[i] ? duration * 0.85 : duration * 0.6);\n          s.drain[i] = target > s.drain[i] ? Math.min(target, s.drain[i] + step) : Math.max(target, s.drain[i] - step);\n          if (s.drain[i] !== target) moving = true;\n          level[i] *= 1 - fillEase(s.drain[i]);\n        }\n        for (const v of s.dropV) if (Math.abs(v) > 0.5) moving = true;\n        busy = moving || s.hoverOn;\n      }\n      paint(draw, level, t, dt, false);\n      return busy;\n    },\n    [layout, trigger, cycle, timeline, total, hold, n, stagger, drainDur, duration, drainOnHover, paint],\n  );\n\n  const { wake } = useVisibleLoop(rootRef, frame, { enabled: animated && autoplay && !!layout });\n\n  // Start: mount and loop start at once, in-view waits for the first intersection.\n  useEffect(() => {\n    if (!animated || !layout) return;\n    const s = sim.current;\n    if (s.started) return;\n    if (trigger !== \"in-view\") {\n      s.started = true;\n      wake();\n      return;\n    }\n    const el = rootRef.current;\n    if (!el) return;\n    const io = new IntersectionObserver(\n      ([entry]) => {\n        if (entry.isIntersecting) {\n          s.started = true;\n          io.disconnect();\n          wake();\n        }\n      },\n      { threshold: 0.35 },\n    );\n    io.observe(el);\n    return () => io.disconnect();\n  }, [animated, layout, trigger, wake]);\n\n  // Before the loop's first frame, paint the start state so nothing flashes filled.\n  useLayoutEffect(() => {\n    if (!layout) return;\n    const draw = new Float64Array(n);\n    const level = new Float64Array(n);\n    if (reduced) {\n      draw.fill(1);\n      level.fill(1);\n      paint(draw, level, 0, 0, true);\n      return;\n    }\n    if (frozen) {\n      timeline(clamp01(freezeAt ?? 0) * total, draw, level);\n      paint(draw, level, 1.3, 0, true);\n      return;\n    }\n    if (!sim.current.started) paint(draw, level, 0, 0, true);\n  }, [layout, n, reduced, frozen, freezeAt, total, timeline, paint]);\n\n  // Hover input.\n  useEffect(() => {\n    if (!animated || trigger === \"loop\" || !layout) return;\n    if (trigger !== \"hover\" && !drainOnHover) return;\n    const el = rootRef.current;\n    if (!el) return;\n    const s = sim.current;\n    const letterAt = (clientX: number) => {\n      const svg = el.querySelector(\"svg\");\n      if (!svg) return 0;\n      const r = svg.getBoundingClientRect();\n      const ux = layout.vb[0] + ((clientX - r.left) / Math.max(1, r.width)) * layout.vb[2];\n      let best = 0;\n      let bestD = Infinity;\n      layout.letters.forEach((l, i) => {\n        const dd = Math.abs((l.left + l.right) / 2 - ux);\n        if (dd < bestD) {\n          bestD = dd;\n          best = i;\n        }\n      });\n      return best;\n    };\n    const onEnter = (e: PointerEvent) => {\n      s.hoverOn = true;\n      s.hoverLetter = letterAt(e.clientX);\n      for (let i = 0; i < n; i++) s.hoverAt[i] = s.clock + Math.abs(i - s.hoverLetter) * 0.045;\n      wake();\n    };\n    const onLeave = () => {\n      s.hoverOn = false;\n      wake();\n    };\n    el.addEventListener(\"pointerenter\", onEnter);\n    el.addEventListener(\"pointerleave\", onLeave);\n    return () => {\n      el.removeEventListener(\"pointerenter\", onEnter);\n      el.removeEventListener(\"pointerleave\", onLeave);\n    };\n  }, [animated, trigger, drainOnHover, layout, n, wake]);\n\n  const vb = layout?.vb;\n  const textProps = {\n    fontSize: FS,\n    style: { fontFamily, fontWeight, letterSpacing: `${letterSpacing}em`, fontKerning: \"normal\" as const },\n  };\n  const maxWidth = vb ? (vb[2] * fontSize) / FS : undefined;\n\n  return (\n    <div\n      ref={rootRef}\n      className={cn(\"relative flex w-full justify-center\", className)}\n      role={ariaLabel ? \"group\" : undefined}\n      aria-label={ariaLabel}\n    >\n      <span className=\"sr-only\">{text}</span>\n      <svg\n        aria-hidden=\"true\"\n        focusable=\"false\"\n        viewBox={vb ? vb.map((v) => v.toFixed(2)).join(\" \") : \"0 -100 600 130\"}\n        className=\"block h-auto w-full overflow-visible\"\n        style={{ maxWidth, aspectRatio: vb ? `${vb[2]} / ${vb[3]}` : undefined }}\n      >\n        <text ref={measureRef} x={0} y={0} {...textProps} visibility=\"hidden\">\n          {text}\n        </text>\n        {layout && vb ? (\n          <>\n            <defs>\n              <filter\n                id={ids.goo}\n                filterUnits=\"userSpaceOnUse\"\n                x={vb[0]}\n                y={vb[1] - FS * 0.3}\n                width={vb[2]}\n                height={vb[3] + FS * 0.6}\n                colorInterpolationFilters=\"sRGB\"\n              >\n                <feGaussianBlur in=\"SourceGraphic\" stdDeviation={FS * 0.032} result=\"b\" />\n                <feColorMatrix in=\"b\" type=\"matrix\" values=\"1 0 0 0 0  0 1 0 0 0  0 0 1 0 0  0 0 0 22 -9\" result=\"g\" />\n                <feGaussianBlur in=\"g\" stdDeviation={FS * 0.008} />\n              </filter>\n              <mask id={ids.mask} maskUnits=\"userSpaceOnUse\" x={vb[0]} y={vb[1] - FS * 0.3} width={vb[2]} height={vb[3] + FS * 0.6}>\n                <g filter={`url(#${ids.goo})`} fill=\"#fff\">\n                  {layout.letters.map((_, i) => (\n                    <path key={i} ref={(el) => void (surfaceEls.current[i] = el)} />\n                  ))}\n                  {drops.map((d, i) => (\n                    <circle key={i} ref={(el) => void (dropEls.current[i] = el)} cx={d.x} cy={layout.bottom + FS} r={d.r} />\n                  ))}\n                </g>\n              </mask>\n              <filter\n                id={ids.glow}\n                filterUnits=\"userSpaceOnUse\"\n                x={vb[0]}\n                y={vb[1]}\n                width={vb[2]}\n                height={vb[3]}\n                colorInterpolationFilters=\"sRGB\"\n              >\n                <feGaussianBlur in=\"SourceGraphic\" stdDeviation={FS * 0.03} result=\"halo\" />\n                <feMerge>\n                  <feMergeNode in=\"halo\" />\n                  <feMergeNode in=\"halo\" />\n                  <feMergeNode in=\"SourceGraphic\" />\n                </feMerge>\n              </filter>\n            </defs>\n            <g ref={fillGroupRef} mask={`url(#${ids.mask})`} fill={fill}>\n              {layout.letters.map((l, i) => (\n                <text key={i} x={l.x} y={0} {...textProps}>\n                  {l.ch}\n                </text>\n              ))}\n            </g>\n            <g fill=\"none\" stroke={ink} strokeWidth={sw} strokeLinejoin=\"round\" strokeLinecap=\"round\">\n              {layout.letters.map((l, i) => (\n                <text key={i} ref={(el) => void (strokeEls.current[i] = el)} x={l.x} y={0} {...textProps} style={{ ...textProps.style, opacity: 0 }}>\n                  {l.ch}\n                </text>\n              ))}\n            </g>\n            <g\n              fill=\"none\"\n              stroke={accent}\n              strokeWidth={sw * 2.4}\n              strokeLinecap=\"round\"\n              filter={animated || frozen ? `url(#${ids.glow})` : undefined}\n            >\n              {layout.letters.map((l, i) => (\n                <text key={i} ref={(el) => void (tipEls.current[i] = el)} x={l.x} y={0} {...textProps} style={{ ...textProps.style, opacity: 0 }}>\n                  {l.ch}\n                </text>\n              ))}\n            </g>\n          </>\n        ) : null}\n      </svg>\n    </div>\n  );\n}\n",
      "type": "registry:component",
      "target": "@components/bjork-ui/text/stroke-draw-type.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/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"
    },
    {
      "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/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/motion.ts",
      "content": "// Shared springs, easings and durations. Components import these instead of inventing values.\n\nexport const springs = {\n  press: { type: \"spring\", stiffness: 500, damping: 30, mass: 0.6 },\n  snappy: { type: \"spring\", stiffness: 400, damping: 25, mass: 1 },\n  standard: { type: \"spring\", stiffness: 300, damping: 30, mass: 0.8 },\n  soft: { type: \"spring\", stiffness: 200, damping: 25, mass: 1.2 },\n  settle: { type: \"spring\", stiffness: 380, damping: 30, mass: 0.8 },\n  blurIn: { type: \"spring\", stiffness: 400, damping: 28, mass: 0.6 },\n} as const;\n\nexport const ease = {\n  out: [0.23, 1, 0.32, 1],\n  inOut: [0.77, 0, 0.175, 1],\n  drawer: [0.32, 0.72, 0, 1],\n} as const;\n\nexport const easeCss = {\n  out: \"cubic-bezier(0.23,1,0.32,1)\",\n  inOut: \"cubic-bezier(0.77,0,0.175,1)\",\n  drawer: \"cubic-bezier(0.32,0.72,0,1)\",\n} as const;\n\nexport const durations = { press: 140, tooltip: 160, menu: 200, panel: 280 } as const; // ms\n\n// Cubic bezier easing for canvas and rAF code. Returns progress y for input x in [0, 1].\nexport function cubicBezier(x1: number, y1: number, x2: number, y2: number): (t: number) => number {\n  const cx = 3 * x1;\n  const bx = 3 * (x2 - x1) - cx;\n  const ax = 1 - cx - bx;\n  const cy = 3 * y1;\n  const by = 3 * (y2 - y1) - cy;\n  const ay = 1 - cy - by;\n  const sampleX = (s: number) => ((ax * s + bx) * s + cx) * s;\n  const sampleY = (s: number) => ((ay * s + by) * s + cy) * s;\n  const sampleDX = (s: number) => (3 * ax * s + 2 * bx) * s + cx;\n\n  const solveX = (x: number) => {\n    let s = x;\n    for (let i = 0; i < 8; i++) {\n      const err = sampleX(s) - x;\n      if (Math.abs(err) < 1e-6) return s;\n      const d = sampleDX(s);\n      if (Math.abs(d) < 1e-6) break;\n      s -= err / d;\n    }\n    let lo = 0;\n    let hi = 1;\n    s = x;\n    while (lo < hi) {\n      const v = sampleX(s);\n      if (Math.abs(v - x) < 1e-6) return s;\n      if (x > v) lo = s;\n      else hi = s;\n      s = (lo + hi) / 2;\n      if (hi - lo < 1e-7) break;\n    }\n    return s;\n  };\n\n  return (t: number) => {\n    if (t <= 0) return 0;\n    if (t >= 1) return 1;\n    return sampleY(solveX(t));\n  };\n}\n",
      "type": "registry:component",
      "target": "@components/bjork-ui/_core/motion.ts"
    },
    {
      "path": "components/bjork-ui/_core/random.ts",
      "content": "// Seeded randomness and noise. Deterministic for a given seed, so previews and tests repeat.\n\n// Small fast PRNG. Returns floats in [0, 1).\nexport function mulberry32(seed: number): () => number {\n  let a = seed >>> 0;\n  return () => {\n    a = (a + 0x6d2b79f5) >>> 0;\n    let t = a;\n    t = Math.imul(t ^ (t >>> 15), t | 1);\n    t ^= t + Math.imul(t ^ (t >>> 7), t | 61);\n    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;\n  };\n}\n\n// FNV-1a 32-bit.\nexport function hashString(s: string): number {\n  let h = 0x811c9dc5;\n  for (let i = 0; i < s.length; i++) {\n    h ^= s.charCodeAt(i);\n    h = Math.imul(h, 0x01000193);\n  }\n  return h >>> 0;\n}\n\n// Integer lattice hash to [-1, 1].\nfunction latticeHash(seed: number, x: number, y = 0): number {\n  let h = Math.imul(x | 0, 374761393) ^ Math.imul(y | 0, 668265263) ^ Math.imul(seed | 0, 2246822519);\n  h = Math.imul(h ^ (h >>> 13), 1274126177);\n  h ^= h >>> 16;\n  return ((h >>> 0) / 4294967295) * 2 - 1;\n}\n\nfunction smooth(t: number): number {\n  return t * t * (3 - 2 * t);\n}\n\nexport function valueNoise1D(seed: number): (x: number) => number {\n  return (x: number) => {\n    const i = Math.floor(x);\n    const f = x - i;\n    const a = latticeHash(seed, i);\n    const b = latticeHash(seed, i + 1);\n    return a + (b - a) * smooth(f);\n  };\n}\n\nexport function fbm1D(seed: number, octaves = 3): (x: number) => number {\n  const layers = Array.from({ length: octaves }, (_, o) => valueNoise1D(seed + o * 101));\n  let norm = 0;\n  for (let o = 0; o < octaves; o++) norm += 0.5 ** o;\n  return (x: number) => {\n    let sum = 0;\n    let amp = 1;\n    let freq = 1;\n    for (let o = 0; o < octaves; o++) {\n      sum += amp * layers[o](x * freq);\n      amp *= 0.5;\n      freq *= 2;\n    }\n    return sum / norm;\n  };\n}\n\nexport function valueNoise2D(seed: number): (x: number, y: number) => number {\n  return (x: number, y: number) => {\n    const xi = Math.floor(x);\n    const yi = Math.floor(y);\n    const fx = smooth(x - xi);\n    const fy = smooth(y - yi);\n    const a = latticeHash(seed, xi, yi);\n    const b = latticeHash(seed, xi + 1, yi);\n    const c = latticeHash(seed, xi, yi + 1);\n    const d = latticeHash(seed, xi + 1, yi + 1);\n    const top = a + (b - a) * fx;\n    const bottom = c + (d - c) * fx;\n    return top + (bottom - top) * fy;\n  };\n}\n",
      "type": "registry:component",
      "target": "@components/bjork-ui/_core/random.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/stroke-draw-type.json",
    "installCommand": "npx shadcn@latest add https://ui.isaiahbjork.com/stroke-draw-type.json"
  },
  "type": "registry:component"
}