{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "morph-loader",
  "title": "Morph loader",
  "description": "Morph loader is a production-ready feedback component from BJORK UI, packaged for shadcn registries.",
  "dependencies": [
    "clsx",
    "framer-motion",
    "next-themes",
    "tailwind-merge"
  ],
  "files": [
    {
      "path": "components/bjork-ui/feedback/morph-loader.tsx",
      "content": "\"use client\";\n\nimport { useLayoutEffect, useMemo, useRef, useState } from \"react\";\nimport { useReducedMotion } from \"framer-motion\";\nimport { cn } from \"@/lib/utils\";\nimport { BJORK_PALETTE, type BjorkTone } from \"@/components/bjork-ui/_core/palette\";\nimport { useBjorkTone } from \"@/components/bjork-ui/_core/tone\";\nimport { VisuallyHidden } from \"@/components/bjork-ui/_core/a11y\";\nimport { cubicBezier, ease, easeCss } from \"@/components/bjork-ui/_core/motion\";\nimport { resamplePath } from \"@/components/bjork-ui/_core/path\";\nimport { useVisibleLoop } from \"@/components/bjork-ui/_core/loop\";\n\nexport type MorphState = \"loading\" | \"done\" | \"error\" | \"idle\";\nexport type MorphTarget = \"check\" | \"cross\" | \"arrow-down\" | \"spark\" | (string & {});\n\nexport const morphTargets: Record<\"check\" | \"cross\" | \"arrow-down\" | \"spark\", string> = {\n  check: \"M5 12.5 L10 17.5 L19 7\",\n  cross: \"M6.5 6.5 L17.5 17.5 M17.5 6.5 L6.5 17.5\",\n  \"arrow-down\": \"M12 4.5 V18.5 M6.5 13 L12 18.5 L17.5 13\",\n  spark: \"M12 3 L13.8 10.2 L21 12 L13.8 13.8 L12 21 L10.2 13.8 L3 12 L10.2 10.2 Z\",\n};\n\nexport interface MorphLoaderProps {\n  state: MorphState;\n  target?: MorphTarget;\n  size?: number;\n  segments?: number;\n  speedMs?: number;\n  strokeWidth?: number;\n  color?: string;\n  doneColor?: string;\n  errorColor?: string;\n  labels?: Partial<Record<MorphState, string>>;\n  onMorphComplete?: () => void;\n  tone?: BjorkTone;\n  attract?: boolean;\n  /** Static morph frame, 0 to 1, from the ring to the done or error glyph. Turns the rotation off. */\n  progress?: number;\n  className?: string;\n}\n\n// Geometry in the 24-unit box. Each segment is an open polyline of POINTS samples.\nconst VIEW = 24;\nconst CENTRE = 12;\nconst RING_RADIUS = 9.2;\nconst RING_SPAN = 0.62;\nconst POINTS = 8;\nconst MORPH_MS = 560;\nconst STAGGER_MS = 18;\nconst REVERSE_MS = 400;\nconst DONE_WIDTH = 1.1;\nconst SHAKE_PX = [0, -3, 3, -2, 2, 0];\nconst SHAKE_MS = 360;\nconst FADE_MS = 80;\nconst LOADING_REDUCED_OPACITY = 0.6;\nconst ATTRACT_LOADING_MS = 1600;\nconst ATTRACT_FINAL_MS = 1200;\nconst ATTRACT_RESUME_MS = 4000;\nconst DOTS: ReadonlyArray<readonly [number, number]> = [\n  [8, 12],\n  [12, 12],\n  [16, 12],\n];\nconst DEFAULT_LABELS: Record<MorphState, string> = {\n  loading: \"Loading\",\n  done: \"Done\",\n  error: \"Failed\",\n  idle: \"\",\n};\n// Blur test (ink centroid, round caps, 24-space): the unnudged check sits +0.13 right, +0.95 low. Nudged back to the box centre.\nconst CHECK_NUDGE_X = -0.15;\nconst CHECK_NUDGE_Y = -0.95;\n\n// One shared keyframe for every loader. It is injected once into <head>, not into the component's markup.\nconst SPIN_NAME = \"morph-loader-spin\";\nfunction ensureSpinKeyframes() {\n  if (typeof document === \"undefined\" || document.getElementById(SPIN_NAME)) return;\n  const style = document.createElement(\"style\");\n  style.id = SPIN_NAME;\n  style.textContent = `@keyframes ${SPIN_NAME}{from{transform:rotate(0deg)}to{transform:rotate(360deg)}}`;\n  document.head.appendChild(style);\n}\n\nconst morphEase = cubicBezier(...ease.inOut);\nconst clamp01 = (v: number) => (v < 0 ? 0 : v > 1 ? 1 : v);\nconst lerp = (a: number, b: number, t: number) => a + (b - a) * t;\nconst fmt = (v: number) => (Math.round(v * 100) / 100).toString();\n\nfunction tailOpacity(i: number, n: number): number {\n  return 0.22 + 0.78 * (n === 1 ? 1 : i / (n - 1));\n}\n\nfunction ringPoints(n: number): Float32Array {\n  const out = new Float32Array(n * POINTS * 2);\n  const step = 360 / n;\n  for (let i = 0; i < n; i++) {\n    for (let j = 0; j < POINTS; j++) {\n      const rad = ((i * step + (j / (POINTS - 1)) * step * RING_SPAN) * Math.PI) / 180;\n      const at = (i * POINTS + j) * 2;\n      out[at] = CENTRE + RING_RADIUS * Math.cos(rad);\n      out[at + 1] = CENTRE + RING_RADIUS * Math.sin(rad);\n    }\n  }\n  return out;\n}\n\nfunction dotPoints(n: number): Float32Array {\n  const out = new Float32Array(n * POINTS * 2);\n  for (let i = 0; i < n; i++) {\n    const [x, y] = i < DOTS.length ? DOTS[i] : [CENTRE, CENTRE];\n    for (let j = 0; j < POINTS; j++) {\n      const at = (i * POINTS + j) * 2;\n      out[at] = x;\n      out[at + 1] = y;\n    }\n  }\n  return out;\n}\n\nfunction segmentD(pts: Float32Array, seg: number): string {\n  let d = \"\";\n  for (let j = 0; j < POINTS; j++) {\n    const at = (seg * POINTS + j) * 2;\n    d += `${j === 0 ? \"M\" : \" L\"}${fmt(pts[at])} ${fmt(pts[at + 1])}`;\n  }\n  return d;\n}\n\nfunction polylineLength(pts: Float32Array): number {\n  let len = 0;\n  for (let i = 2; i < pts.length; i += 2) {\n    len += Math.hypot(pts[i] - pts[i - 2], pts[i + 1] - pts[i - 1]);\n  }\n  return len;\n}\n\nfunction subpaths(d: string): string[] {\n  return d\n    .split(/(?=[Mm])/)\n    .map((s) => s.trim())\n    .filter((s) => s.length > 0);\n}\n\n// Splits n segments across subpaths in proportion to length, keeping at least one each.\nfunction allocateSegments(lengths: number[], n: number): number[] {\n  const total = lengths.reduce((a, b) => a + b, 0);\n  const exact = lengths.map((l) => (n * l) / total);\n  const counts = exact.map((x) => Math.max(1, Math.round(x)));\n  let sum = counts.reduce((a, b) => a + b, 0);\n  while (sum > n) {\n    let best = -1;\n    let bestOver = -Infinity;\n    counts.forEach((c, i) => {\n      if (c > 1 && c - exact[i] > bestOver) {\n        bestOver = c - exact[i];\n        best = i;\n      }\n    });\n    if (best < 0) break;\n    counts[best] -= 1;\n    sum -= 1;\n  }\n  while (sum < n) {\n    let best = 0;\n    let bestUnder = -Infinity;\n    counts.forEach((c, i) => {\n      if (exact[i] - c > bestUnder) {\n        bestUnder = exact[i] - c;\n        best = i;\n      }\n    });\n    counts[best] += 1;\n    sum += 1;\n  }\n  return counts;\n}\n\n// Resamples a path into n segments of POINTS samples by arc length. Returns null for an invalid path.\nfunction buildTarget(d: string, n: number): Float32Array | null {\n  const parts = subpaths(d);\n  if (parts.length === 0) return null;\n  const lengths = parts.map((part) => polylineLength(resamplePath(part, 64)));\n  const total = lengths.reduce((a, b) => a + b, 0);\n  if (!(total > 1e-6)) return null;\n  const counts = parts.length === 1 ? [n] : allocateSegments(lengths, n);\n  if (counts.reduce((a, b) => a + b, 0) !== n) return null;\n  const out = new Float32Array(n * POINTS * 2);\n  let seg = 0;\n  parts.forEach((part, idx) => {\n    out.set(resamplePath(part, counts[idx] * POINTS), seg * POINTS * 2);\n    seg += counts[idx];\n  });\n  return out;\n}\n\nfunction resolveTarget(target: MorphTarget, n: number): Float32Array {\n  const isKey = Object.prototype.hasOwnProperty.call(morphTargets, target);\n  let d = isKey ? morphTargets[target as keyof typeof morphTargets] : target;\n  let pts = buildTarget(d, n);\n  if (!pts) {\n    if (process.env.NODE_ENV !== \"production\" && typeof document !== \"undefined\") {\n      console.warn(`MorphLoader: \"${target}\" is not a valid path. Using \"check\".`);\n    }\n    d = morphTargets.check;\n    pts = buildTarget(d, n) ?? new Float32Array(n * POINTS * 2);\n  }\n  if (d === morphTargets.check) {\n    for (let at = 0; at < pts.length; at += 2) {\n      pts[at] += CHECK_NUDGE_X;\n      pts[at + 1] += CHECK_NUDGE_Y;\n    }\n  }\n  return pts;\n}\n\nfunction mixColor(from: string, to: string, p: number): string {\n  if (p <= 0) return from;\n  if (p >= 1) return to;\n  return `color-mix(in srgb, ${to} ${(p * 100).toFixed(1)}%, ${from})`;\n}\n\ninterface EngineOptions {\n  size: number;\n  strokeWidth: number;\n  colorLoading: string;\n  colorDone: string;\n  colorError: string;\n  speedMs: number;\n  kf: string;\n  reduced: boolean;\n}\n\ninterface Tween {\n  t0: number;\n  dur: number;\n  stagger: number;\n  from: Float32Array;\n  fromOp: Float32Array;\n  fromW: number;\n  fromColor: string;\n  to: Float32Array;\n  toOp: Float32Array;\n  toW: number;\n  toColor: string;\n  onDone?: () => void;\n}\n\nconst DEFAULT_OPTIONS: EngineOptions = {\n  size: 40,\n  strokeWidth: 2.25,\n  colorLoading: \"currentColor\",\n  colorDone: \"currentColor\",\n  colorError: \"#ff5c4d\",\n  speedMs: 900,\n  kf: \"morph-loader-spin\",\n  reduced: false,\n};\n\n// Owns the segment points and runs every tween on one rAF loop. React only sets props and effects.\nclass MorphEngine {\n  group: SVGGElement | null = null;\n  n = 0;\n  logical: MorphState | null = null;\n  posed = false;\n  private ring: Float32Array = new Float32Array(0);\n  private tail: Float32Array = new Float32Array(0);\n  private ones: Float32Array = new Float32Array(0);\n  private dots: Float32Array = new Float32Array(0);\n  private dotsOp: Float32Array = new Float32Array(0);\n  private target: Float32Array = new Float32Array(0);\n  private cross: Float32Array = new Float32Array(0);\n  private cur: Float32Array = new Float32Array(0);\n  private op: Float32Array = new Float32Array(0);\n  private width = DEFAULT_OPTIONS.strokeWidth;\n  private shownColor = \"currentColor\";\n  private tween: Tween | null = null;\n  private rotating = false;\n  private rotStart = 0;\n  private fade: Animation | null = null;\n  private opts: EngineOptions = DEFAULT_OPTIONS;\n\n  attach(group: SVGGElement | null) {\n    this.group = group;\n  }\n\n  setOptions(opts: EngineOptions) {\n    this.opts = opts;\n  }\n\n  setGeometry(n: number, target: MorphTarget) {\n    if (n !== this.n) {\n      this.n = n;\n      this.ring = ringPoints(n);\n      this.tail = new Float32Array(n);\n      this.ones = new Float32Array(n).fill(1);\n      this.dots = dotPoints(n);\n      this.dotsOp = new Float32Array(n);\n      for (let i = 0; i < n; i++) {\n        this.tail[i] = tailOpacity(i, n);\n        this.dotsOp[i] = i < DOTS.length ? 1 : 0;\n      }\n      this.cur = new Float32Array(n * POINTS * 2);\n      this.op = new Float32Array(n);\n      this.cross = resolveTarget(\"cross\", n);\n    }\n    this.target = resolveTarget(target, n);\n  }\n\n  // Sets the posed state at once, with no tween.\n  snap(state: MorphState) {\n    this.tween = null;\n    this.posed = false;\n    this.stopRotation();\n    this.logical = state;\n    const o = this.opts;\n    if (state === \"loading\") {\n      this.cur.set(this.ring);\n      this.op.set(this.tail);\n      this.width = o.strokeWidth;\n      this.shownColor = o.colorLoading;\n    } else if (state === \"done\") {\n      this.cur.set(this.target);\n      this.op.set(this.ones);\n      this.width = o.strokeWidth * DONE_WIDTH;\n      this.shownColor = o.colorDone;\n    } else if (state === \"error\") {\n      this.cur.set(this.cross);\n      this.op.set(this.ones);\n      this.width = o.strokeWidth;\n      this.shownColor = o.colorError;\n    } else {\n      this.cur.set(this.dots);\n      this.op.set(this.dotsOp);\n      this.width = o.strokeWidth;\n      this.shownColor = o.colorLoading;\n    }\n    if (this.group) {\n      this.group.style.opacity = o.reduced && state === \"loading\" ? String(LOADING_REDUCED_OPACITY) : \"1\";\n    }\n    if (state === \"loading\") this.startRotation();\n    this.paint();\n  }\n\n  // Moves to a state with the morph. Reduced motion crossfades instead.\n  go(state: MorphState, complete?: () => void) {\n    if (this.logical === null || this.posed) {\n      this.snap(state);\n      return;\n    }\n    if (this.logical === state) return;\n    this.logical = state;\n    const o = this.opts;\n    if (state === \"loading\") {\n      this.morph(this.ring, this.tail, o.strokeWidth, o.colorLoading, REVERSE_MS, () => this.startRotation());\n    } else if (state === \"idle\") {\n      this.morph(this.dots, this.dotsOp, o.strokeWidth, o.colorLoading, MORPH_MS);\n    } else if (state === \"done\") {\n      this.morph(this.target, this.ones, o.strokeWidth * DONE_WIDTH, o.colorDone, MORPH_MS, complete);\n    } else {\n      this.morph(this.cross, this.ones, o.strokeWidth, o.colorError, MORPH_MS, complete);\n      this.shake();\n    }\n  }\n\n  // Re-runs the done morph to a new target from the points on screen.\n  retarget(complete?: () => void) {\n    const o = this.opts;\n    this.morph(this.target, this.ones, o.strokeWidth * DONE_WIDTH, o.colorDone, MORPH_MS, complete);\n  }\n\n  // A static frame between the ring (0) and the done or error glyph (1).\n  pose(state: MorphState, p: number) {\n    this.tween = null;\n    this.stopRotation();\n    const o = this.opts;\n    const done = state === \"done\";\n    const fin = done ? this.target : this.cross;\n    const finW = done ? o.strokeWidth * DONE_WIDTH : o.strokeWidth;\n    const finColor = done ? o.colorDone : o.colorError;\n    const total = MORPH_MS + STAGGER_MS * (this.n - 1);\n    for (let i = 0; i < this.n; i++) {\n      const q = morphEase(clamp01((p * total - STAGGER_MS * i) / MORPH_MS));\n      this.op[i] = lerp(this.tail[i], 1, q);\n      for (let j = 0; j < POINTS; j++) {\n        const at = (i * POINTS + j) * 2;\n        this.cur[at] = lerp(this.ring[at], fin[at], q);\n        this.cur[at + 1] = lerp(this.ring[at + 1], fin[at + 1], q);\n      }\n    }\n    const g = morphEase(clamp01(p));\n    this.width = lerp(o.strokeWidth, finW, g);\n    this.shownColor = mixColor(o.colorLoading, finColor, g);\n    this.posed = true;\n    this.logical = state;\n    if (this.group) this.group.style.opacity = \"1\";\n    this.paint();\n  }\n\n  // Reduced motion: fade out, swap the state, fade in (80ms each).\n  fadeTo(state: MorphState) {\n    const g = this.group;\n    if (!g || this.logical === null || this.posed) {\n      this.snap(state);\n      return;\n    }\n    if (this.logical === state) return;\n    this.fade?.cancel();\n    const from = Number(g.style.opacity || 1);\n    const out = g.animate([{ opacity: from }, { opacity: 0 }], { duration: FADE_MS, easing: \"ease-out\" });\n    this.fade = out;\n    out.onfinish = () => {\n      this.snap(state);\n      this.fade = g.animate([{ opacity: 0 }, { opacity: Number(g.style.opacity || 1) }], {\n        duration: FADE_MS,\n        easing: \"ease-out\",\n      });\n    };\n  }\n\n  // Returns true while a tween is running.\n  tickTween(now: number): boolean {\n    const tw = this.tween;\n    if (!tw) return false;\n    const t = now - tw.t0;\n    const total = tw.dur + tw.stagger * (this.n - 1);\n    if (t >= total) {\n      this.finish(tw);\n      return false;\n    }\n    for (let i = 0; i < this.n; i++) {\n      const e = morphEase(clamp01((t - tw.stagger * i) / tw.dur));\n      this.op[i] = lerp(tw.fromOp[i], tw.toOp[i], e);\n      for (let j = 0; j < POINTS; j++) {\n        const at = (i * POINTS + j) * 2;\n        this.cur[at] = lerp(tw.from[at], tw.to[at], e);\n        this.cur[at + 1] = lerp(tw.from[at + 1], tw.to[at + 1], e);\n      }\n    }\n    const g = morphEase(clamp01(t / tw.dur));\n    this.width = lerp(tw.fromW, tw.toW, g);\n    this.shownColor = mixColor(tw.fromColor, tw.toColor, g);\n    this.paint();\n    return true;\n  }\n\n  private finish(tw: Tween) {\n    this.tween = null;\n    this.cur.set(tw.to);\n    this.op.set(tw.toOp);\n    this.width = tw.toW;\n    this.shownColor = tw.toColor;\n    this.paint();\n    tw.onDone?.();\n  }\n\n  private morph(\n    fin: Float32Array,\n    finOp: Float32Array,\n    finW: number,\n    finColor: string,\n    dur: number,\n    onDone?: () => void,\n  ) {\n    this.bake();\n    this.posed = false;\n    this.tween = {\n      t0: performance.now(),\n      dur,\n      stagger: STAGGER_MS,\n      from: this.cur.slice(),\n      fromOp: this.op.slice(),\n      fromW: this.width,\n      fromColor: this.shownColor,\n      to: fin,\n      toOp: finOp,\n      toW: finW,\n      toColor: finColor,\n      onDone,\n    };\n  }\n\n  private startRotation() {\n    const g = this.group;\n    if (!g || this.opts.reduced) return;\n    g.style.animation = `${this.opts.kf} ${this.opts.speedMs}ms linear infinite`;\n    this.rotating = true;\n    this.rotStart = performance.now();\n  }\n\n  private stopRotation() {\n    if (this.group) this.group.style.animation = \"none\";\n    this.rotating = false;\n  }\n\n  // Freezes the spin: bakes the current angle into the points and removes the CSS animation in the same task.\n  private bake() {\n    const g = this.group;\n    if (!this.rotating || !g) return;\n    const angle = this.currentAngle();\n    this.stopRotation();\n    const rad = (angle * Math.PI) / 180;\n    const c = Math.cos(rad);\n    const s = Math.sin(rad);\n    for (let at = 0; at < this.cur.length; at += 2) {\n      const dx = this.cur[at] - CENTRE;\n      const dy = this.cur[at + 1] - CENTRE;\n      this.cur[at] = CENTRE + dx * c - dy * s;\n      this.cur[at + 1] = CENTRE + dx * s + dy * c;\n    }\n  }\n\n  private currentAngle(): number {\n    const speed = this.opts.speedMs;\n    const anim = this.group\n      ?.getAnimations()\n      .find((a) => (a as CSSAnimation).animationName === this.opts.kf) as CSSAnimation | undefined;\n    const ms = anim && typeof anim.currentTime === \"number\" ? anim.currentTime : performance.now() - this.rotStart;\n    return ((((ms % speed) + speed) % speed) / speed) * 360;\n  }\n\n  private shake() {\n    const g = this.group;\n    if (!g || this.opts.reduced) return;\n    const unit = VIEW / this.opts.size;\n    g.animate(\n      SHAKE_PX.map((px) => ({ transform: `translateX(${fmt(px * unit)}px)` })),\n      { duration: SHAKE_MS, easing: easeCss.out },\n    );\n  }\n\n  private paint() {\n    const g = this.group;\n    if (!g) return;\n    const kids = g.children;\n    for (let i = 0; i < this.n && i < kids.length; i++) {\n      const el = kids[i];\n      el.setAttribute(\"d\", segmentD(this.cur, i));\n      el.setAttribute(\"opacity\", this.op[i].toFixed(3));\n    }\n    g.setAttribute(\"stroke-width\", this.width.toFixed(3));\n    g.style.stroke = this.shownColor;\n  }\n}\n\nexport function MorphLoader({\n  state,\n  target = \"check\",\n  size = 40,\n  segments = 10,\n  speedMs = 900,\n  strokeWidth = 2.25,\n  color,\n  doneColor,\n  errorColor,\n  labels,\n  onMorphComplete,\n  tone,\n  attract = false,\n  progress,\n  className,\n}: MorphLoaderProps) {\n  const resolvedTone = useBjorkTone(tone);\n  const reduced = useReducedMotion() ?? false;\n  const kf = SPIN_NAME;\n  const n = size < 20 ? 8 : Math.max(3, Math.round(segments));\n  const colorLoading = color ?? \"currentColor\";\n  const colorDone = doneColor ?? \"currentColor\";\n  const colorError = errorColor ?? BJORK_PALETTE[resolvedTone].error;\n  const driven = attract && !reduced;\n\n  const [engine] = useState(() => new MorphEngine());\n  const [attractState, setAttractState] = useState<MorphState>(\"loading\");\n  const shown: MorphState = driven ? attractState : state;\n  const rootRef = useRef<HTMLSpanElement>(null);\n  const groupRef = useRef<SVGGElement>(null);\n  const completeRef = useRef(onMorphComplete);\n  const attractRef = useRef({ on: false, state: \"loading\" as MorphState, cycle: 1, clock: 0, paused: false, lastInput: 0 });\n\n  const ringD = useMemo(() => {\n    const pts = ringPoints(n);\n    return Array.from({ length: n }, (_, i) => segmentD(pts, i));\n  }, [n]);\n\n  const { wake } = useVisibleLoop(\n    rootRef,\n    (dt) => {\n      let busy = engine.tickTween(performance.now());\n      const a = attractRef.current;\n      if (a.on) {\n        busy = true;\n        if (a.paused && performance.now() - a.lastInput >= ATTRACT_RESUME_MS) a.paused = false;\n        if (!a.paused) {\n          a.clock += dt * 1000;\n          const limit = a.state === \"loading\" ? ATTRACT_LOADING_MS : ATTRACT_FINAL_MS;\n          if (a.clock >= limit) {\n            a.clock -= limit;\n            if (a.state === \"loading\") {\n              a.state = a.cycle % 3 === 0 ? \"error\" : \"done\";\n            } else {\n              a.cycle += 1;\n              a.state = \"loading\";\n            }\n            setAttractState(a.state);\n          }\n        }\n      }\n      return busy;\n    },\n    { enabled: true },\n  );\n\n  // Keep the engine in step with the latest props on every render.\n  useLayoutEffect(() => {\n    ensureSpinKeyframes();\n    engine.attach(groupRef.current);\n    engine.setOptions({\n      size,\n      strokeWidth,\n      colorLoading,\n      colorDone,\n      colorError,\n      speedMs,\n      kf,\n      reduced,\n    });\n  });\n\n  useLayoutEffect(() => {\n    completeRef.current = onMorphComplete;\n  });\n\n  // Geometry. A new segment count re-snaps. A new target morphs when the component is done.\n  useLayoutEffect(() => {\n    const complete = () => completeRef.current?.();\n    const nChanged = engine.n !== n;\n    engine.setGeometry(n, target);\n    if (engine.logical === null) return;\n    if (nChanged) {\n      engine.snap(engine.logical);\n      return;\n    }\n    if (engine.logical === \"done\" && !engine.posed) {\n      engine.retarget(complete);\n      wake();\n    }\n  }, [n, target, wake, engine]);\n\n  // Colours, stroke and speed take effect at once.\n  useLayoutEffect(() => {\n    if (engine.logical !== null && !engine.posed) engine.snap(engine.logical);\n  }, [colorLoading, colorDone, colorError, strokeWidth, speedMs, size, reduced, engine]);\n\n  // State. Progress poses a static frame. Reduced motion crossfades.\n  useLayoutEffect(() => {\n    const complete = () => completeRef.current?.();\n    if (progress !== undefined && (shown === \"done\" || shown === \"error\")) {\n      engine.pose(shown, clamp01(progress));\n      return;\n    }\n    if (reduced) {\n      engine.fadeTo(shown);\n      return;\n    }\n    engine.go(shown, complete);\n    wake();\n  }, [shown, progress, reduced, wake, engine]);\n\n  // Attract runs its cycle on the loop. Starting it sends the state through the effect above.\n  useLayoutEffect(() => {\n    const a = attractRef.current;\n    a.on = driven;\n    a.clock = 0;\n    a.paused = false;\n    if (driven) wake();\n  }, [driven, wake]);\n\n  const markInput = () => {\n    const a = attractRef.current;\n    if (a.on) {\n      a.paused = true;\n      a.lastInput = performance.now();\n    }\n  };\n  const label = labels?.[shown] ?? DEFAULT_LABELS[shown];\n\n  return (\n    <span\n      ref={rootRef}\n      role=\"status\"\n      aria-live=\"polite\"\n      aria-atomic=\"true\"\n      data-state={shown}\n      onPointerEnter={markInput}\n      onPointerMove={markInput}\n      onPointerDown={markInput}\n      onKeyDown={markInput}\n      className={cn(\"relative inline-block shrink-0 align-middle leading-none\", className)}\n    >\n      <svg width={size} height={size} viewBox=\"0 0 24 24\" fill=\"none\" aria-hidden=\"true\" focusable=\"false\" className=\"block overflow-visible\">\n        <g\n          ref={groupRef}\n          stroke={colorLoading}\n          strokeWidth={strokeWidth}\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          style={{ transformBox: \"view-box\", transformOrigin: \"12px 12px\" }}\n        >\n          {ringD.map((d, i) => (\n            <path key={i} d={d} opacity={tailOpacity(i, n)} />\n          ))}\n        </g>\n      </svg>\n      <VisuallyHidden>{label}</VisuallyHidden>\n    </span>\n  );\n}\n",
      "type": "registry:component",
      "target": "@components/bjork-ui/feedback/morph-loader.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/a11y.tsx",
      "content": "import type { ReactNode } from \"react\";\n\n// Text for assistive tech only.\nexport function VisuallyHidden({ children }: { children: ReactNode }) {\n  return <span className=\"sr-only\">{children}</span>;\n}\n\n// Announces `message` when it changes. Same text re-rendered does not re-announce.\nexport function LiveRegion({\n  message,\n  politeness = \"polite\",\n}: {\n  message: string;\n  politeness?: \"polite\" | \"assertive\";\n}) {\n  return (\n    <span role=\"status\" aria-live={politeness} aria-atomic=\"true\" className=\"sr-only\">\n      {message}\n    </span>\n  );\n}\n",
      "type": "registry:component",
      "target": "@components/bjork-ui/_core/a11y.tsx"
    },
    {
      "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/path.ts",
      "content": "// SVG path helpers. Path sampling needs the DOM, so it returns zeros on the server.\n\nconst resampleCache = new Map<string, Float32Array>();\n\n// Interleaved x,y points at equal arc length along `d`, including both endpoints.\nexport function resamplePath(\n  d: string,\n  count: number,\n  viewBox?: [number, number, number, number],\n): Float32Array {\n  if (typeof document === \"undefined\") return new Float32Array(count * 2);\n  const key = `${d}|${count}|${viewBox ? viewBox.join(\",\") : \"\"}`;\n  const cached = resampleCache.get(key);\n  if (cached) return cached;\n\n  const svgNS = \"http://www.w3.org/2000/svg\";\n  const svg = document.createElementNS(svgNS, \"svg\");\n  svg.setAttribute(\"width\", \"0\");\n  svg.setAttribute(\"height\", \"0\");\n  svg.setAttribute(\"aria-hidden\", \"true\");\n  svg.style.position = \"absolute\";\n  svg.style.visibility = \"hidden\";\n  if (viewBox) svg.setAttribute(\"viewBox\", viewBox.join(\" \"));\n  const path = document.createElementNS(svgNS, \"path\");\n  path.setAttribute(\"d\", d);\n  svg.appendChild(path);\n  document.body.appendChild(svg);\n\n  const out = new Float32Array(count * 2);\n  try {\n    const total = path.getTotalLength();\n    for (let i = 0; i < count; i++) {\n      const at = count === 1 ? 0 : (i / (count - 1)) * total;\n      const p = path.getPointAtLength(at);\n      out[i * 2] = p.x;\n      out[i * 2 + 1] = p.y;\n    }\n  } finally {\n    svg.remove();\n  }\n  resampleCache.set(key, out);\n  return out;\n}\n\n// Catmull-Rom through interleaved x,y points, converted to cubic beziers. Returns a path `d`.\nexport function catmullRomToBezier(points: Float32Array, closed: boolean): string {\n  const n = Math.floor(points.length / 2);\n  if (n === 0) return \"\";\n  const P = (i: number) => {\n    let idx = i;\n    if (closed) idx = ((i % n) + n) % n;\n    else idx = Math.min(n - 1, Math.max(0, i));\n    return [points[idx * 2], points[idx * 2 + 1]] as const;\n  };\n  const f = (v: number) => v.toFixed(2);\n  let d = `M${f(points[0])} ${f(points[1])}`;\n  const segments = closed ? n : n - 1;\n  for (let i = 0; i < segments; i++) {\n    const p0 = P(i - 1);\n    const p1 = P(i);\n    const p2 = P(i + 1);\n    const p3 = P(i + 2);\n    const c1x = p1[0] + (p2[0] - p0[0]) / 6;\n    const c1y = p1[1] + (p2[1] - p0[1]) / 6;\n    const c2x = p2[0] - (p3[0] - p1[0]) / 6;\n    const c2y = p2[1] - (p3[1] - p1[1]) / 6;\n    d += ` C${f(c1x)} ${f(c1y)} ${f(c2x)} ${f(c2y)} ${f(p2[0])} ${f(p2[1])}`;\n  }\n  if (closed) d += \" Z\";\n  return d;\n}\n",
      "type": "registry:component",
      "target": "@components/bjork-ui/_core/path.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"
    }
  ],
  "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": "Feedback",
    "registryUrl": "https://ui.isaiahbjork.com/morph-loader.json",
    "installCommand": "npx shadcn@latest add https://ui.isaiahbjork.com/morph-loader.json"
  },
  "type": "registry:component"
}