{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "inline-ai-edit",
  "title": "Inline AI edit",
  "description": "Inline AI edit is a production-ready ai component from BJORK UI, packaged for shadcn registries.",
  "dependencies": [
    "clsx",
    "framer-motion",
    "lucide-react",
    "next-themes",
    "tailwind-merge"
  ],
  "files": [
    {
      "path": "components/bjork-ui/ai/inline-ai-edit.tsx",
      "content": "\"use client\";\n\nimport {\n  Fragment,\n  useEffect,\n  useId,\n  useLayoutEffect,\n  useRef,\n  useState,\n  type ButtonHTMLAttributes,\n  type FormEvent,\n  type KeyboardEvent as ReactKeyboardEvent,\n  type ReactNode,\n} from \"react\";\nimport { ArrowUp, Sparkles } from \"lucide-react\";\nimport { LiveRegion } from \"@/components/bjork-ui/_core/a11y\";\nimport {\n  FOCUS_RING,\n  PRESS,\n  SHIMMER_TEXT_CSS,\n  useAiTone,\n  useControllable,\n  useReduceMotion,\n  type BjorkTone,\n} from \"@/components/bjork-ui/ai/_shared\";\nimport { StrokeMorphIcon } from \"@/components/bjork-ui/utilities/stroke-morph-icon\";\nimport { cn } from \"@/lib/utils\";\n\nexport type RewriteResult = AsyncIterable<string> | Promise<string>;\n\nexport interface InlineAiEditPose {\n  /** String offsets of the rewritten range in `value`. */\n  start: number;\n  end: number;\n  /** The proposed replacement, shown as finished. */\n  proposal: string;\n  /** Label for the instruction, e.g. \"Shorter\". */\n  label?: string;\n}\n\nexport interface InlineAiEditProps {\n  /** Paragraphs separated by blank lines. */\n  value?: string;\n  defaultValue?: string;\n  onValueChange?: (value: string) => void;\n  /** Rewrites the selected text. Return an async iterable of chunks to stream, or a promise of the full text. */\n  onRewrite?: (selectedText: string, instruction: string) => RewriteResult;\n  /** Accessible name for the document. Default \"Document\". */\n  label?: string;\n  /** Demo/preview only: render a finished proposal without a real selection. */\n  pose?: InlineAiEditPose;\n  tone?: BjorkTone;\n  className?: string;\n}\n\nexport const SAMPLE_DOCUMENT = `Fieldnotes 4.2 is the biggest update to the editor since we launched. Drafts now sync in the background, so you can close the lid in the middle of a sentence and pick it up on your phone a few seconds later.\n\nWe also rebuilt search from scratch. It is a lot more faster then before and it actually understands what you mean, even when you only remember one word from a note you wrote three months ago in a hurry on the train.\n\nShared libraries are rolling out to every Studio workspace this week. If you run a team, you can now keep one set of templates, tags and snippets that everyone pulls from, instead of copying them between notebooks.`;\n\nconst QUICK = [\n  { label: \"Improve\", instruction: \"Improve the writing. Keep the meaning.\" },\n  { label: \"Shorter\", instruction: \"Make it shorter.\" },\n  { label: \"Longer\", instruction: \"Make it longer with one concrete detail.\" },\n  { label: \"Fix grammar\", instruction: \"Fix spelling and grammar only.\" },\n] as const;\n\nconst TONES = [\"Friendly\", \"Confident\", \"Formal\", \"Casual\"] as const;\n\n// ---------------------------------------------------------------------------------------------------------------\n// Mock model\n\nconst wait = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));\n\nfunction rewriteLocally(text: string, instruction: string): string {\n  const i = instruction.toLowerCase();\n  const fixed = text\n    .replace(/\\ba lot more faster\\b/gi, \"much faster\")\n    .replace(/\\bmore faster\\b/gi, \"faster\")\n    .replace(/\\bthen before\\b/gi, \"than before\")\n    .replace(/\\s{2,}/g, \" \");\n  if (i.includes(\"grammar\")) return fixed;\n  if (i.includes(\"shorter\")) {\n    const first = fixed.split(/(?<=[.!?])\\s/)[0] ?? fixed;\n    return first\n      .replace(/,? even when [^.]*/i, \"\")\n      .replace(/\\b(actually|really|just|now|also)\\s/gi, \"\")\n      .replace(/\\s+([.,])/g, \"$1\");\n  }\n  if (i.includes(\"longer\")) {\n    return `${fixed.replace(/[.!?]?$/, \"\")}, and results now appear as you type, ranked by how recently you opened each note.`;\n  }\n  if (i.includes(\"friendly\")) return `Good news: ${fixed.charAt(0).toLowerCase()}${fixed.slice(1)}`;\n  if (i.includes(\"formal\")) return fixed.replace(/\\bIt is\\b/g, \"It is now\").replace(/\\bactually\\b/gi, \"reliably\");\n  if (i.includes(\"confident\")) return fixed.replace(/\\bactually\\b/gi, \"reliably\").replace(/\\ba lot\\b/gi, \"far\");\n  if (i.includes(\"casual\")) return fixed.replace(/\\bIt is\\b/g, \"It's\").replace(/\\bdo not\\b/g, \"don't\");\n  return fixed\n    .replace(/\\bIt is much faster than before and it actually understands what you mean\\b/i, \"It is much faster, and it understands what you mean\")\n    .replace(/\\bin a hurry on the train\\b/i, \"on a rushed train ride\");\n}\n\n/** A local stand-in for a model: rewrites deterministically and streams the result a word at a time. */\nexport async function* mockRewrite(selectedText: string, instruction: string): AsyncGenerator<string> {\n  const out = rewriteLocally(selectedText, instruction);\n  await wait(320);\n  for (const word of out.match(/\\S+\\s*/g) ?? []) {\n    await wait(34 + (word.length % 5) * 9);\n    yield word;\n  }\n}\n\nfunction isAsyncIterable(x: unknown): x is AsyncIterable<string> {\n  return typeof x === \"object\" && x !== null && Symbol.asyncIterator in x;\n}\n\n// ---------------------------------------------------------------------------------------------------------------\n// Geometry\n\ninterface Anchor {\n  top: number;\n  bottom: number;\n  left: number;\n  width: number;\n}\n\nconst GAP = 8;\n\n/** Places a floating box near an anchor: preferred side first, flipped when cramped, clamped inside the box. */\nfunction place(el: HTMLElement, anchor: Anchor, boxWidth: number, prefer: \"above\" | \"below\") {\n  const w = el.offsetWidth;\n  const h = el.offsetHeight;\n  const above = anchor.top - h - GAP;\n  const below = anchor.bottom + GAP;\n  const top = prefer === \"above\" ? (above >= -4 ? above : below) : below;\n  const left = Math.max(0, Math.min(boxWidth - w, anchor.left + anchor.width / 2 - w / 2));\n  el.style.transform = `translate(${Math.round(left)}px, ${Math.round(top)}px)`;\n}\n\nfunction relativeAnchor(rect: DOMRect, box: DOMRect): Anchor {\n  return { top: rect.top - box.top, bottom: rect.bottom - box.top, left: rect.left - box.left, width: rect.width };\n}\n\n/** String offset in `value` for a DOM point inside a paragraph, or null when the point is outside one. */\nfunction offsetOf(root: HTMLElement, node: Node, offset: number): number | null {\n  const el = node.nodeType === Node.ELEMENT_NODE ? (node as Element) : node.parentElement;\n  const para = el?.closest<HTMLElement>(\"[data-para-offset]\");\n  if (!para || !root.contains(para)) return null;\n  const r = document.createRange();\n  r.selectNodeContents(para);\n  try {\n    r.setEnd(node, offset);\n  } catch {\n    return null;\n  }\n  return Number(para.dataset.paraOffset) + r.toString().length;\n}\n\n// ---------------------------------------------------------------------------------------------------------------\n// Component\n\ninterface Selection {\n  start: number;\n  end: number;\n  anchor: Anchor;\n}\n\ninterface Proposal {\n  start: number;\n  end: number;\n  original: string;\n  text: string;\n  instruction: string;\n  label: string;\n  status: \"streaming\" | \"done\" | \"error\";\n}\n\n/** Select text, ask AI, and review the rewrite inline before it replaces anything. */\nexport function InlineAiEdit({\n  value,\n  defaultValue = SAMPLE_DOCUMENT,\n  onValueChange,\n  onRewrite = mockRewrite,\n  label = \"Document\",\n  pose,\n  tone: toneProp,\n  className,\n}: InlineAiEditProps) {\n  const { style } = useAiTone(toneProp);\n  const reduce = useReduceMotion();\n  const [doc, setDoc] = useControllable(value, defaultValue, onValueChange);\n  const [selection, setSelection] = useState<Selection | null>(null);\n  const [frozen, setFrozen] = useState(false);\n  const [proposalState, setProposal] = useState<Proposal | null>(null);\n  const [ask, setAsk] = useState(\"\");\n  const [toneOpen, setToneOpen] = useState(false);\n  const [message, setMessage] = useState(\"\");\n  const inputId = useId();\n\n  const rootRef = useRef<HTMLDivElement>(null);\n  const docRef = useRef<HTMLDivElement>(null);\n  const toolbarRef = useRef<HTMLDivElement>(null);\n  const inputRef = useRef<HTMLInputElement>(null);\n  const barRef = useRef<HTMLDivElement>(null);\n  const acceptRef = useRef<HTMLButtonElement>(null);\n  const insRef = useRef<HTMLModElement>(null);\n  const lastPara = useRef(0);\n  const runId = useRef(0);\n\n  const proposal: Proposal | null = pose\n    ? {\n        start: pose.start,\n        end: pose.end,\n        original: doc.slice(pose.start, pose.end),\n        text: pose.proposal,\n        instruction: pose.label ?? \"Improve\",\n        label: pose.label ?? \"Improve\",\n        status: \"done\",\n      }\n    : proposalState;\n\n  // Paragraphs and their offsets in the string.\n  const paras = doc.split(\"\\n\\n\").reduce<{ text: string; start: number }[]>((acc, text, i) => {\n    const start = i === 0 ? 0 : acc[i - 1].start + acc[i - 1].text.length + 2;\n    return [...acc, { text, start }];\n  }, []);\n\n  // ----- Selection tracking.\n  const latest = useRef({ proposal, selection, frozen });\n  useLayoutEffect(() => {\n    latest.current = { proposal, selection, frozen };\n  });\n\n  useEffect(() => {\n    if (pose) return;\n    const onChange = () => {\n      const root = docRef.current;\n      if (!root || latest.current.proposal) return;\n      // Focus moved into the toolbar: keep the selection the toolbar was opened for.\n      if (toolbarRef.current?.contains(document.activeElement)) return;\n      const sel = window.getSelection();\n      if (!sel || sel.rangeCount === 0 || sel.isCollapsed) {\n        setSelection(null);\n        return;\n      }\n      const range = sel.getRangeAt(0);\n      if (!root.contains(range.commonAncestorContainer)) {\n        setSelection(null);\n        return;\n      }\n      let start = offsetOf(root, range.startContainer, range.startOffset);\n      let end = offsetOf(root, range.endContainer, range.endOffset);\n      if (start === null || end === null) return;\n      const text = root.dataset.value ?? \"\";\n      while (start < end && /\\s/.test(text[start])) start++;\n      while (end > start && /\\s/.test(text[end - 1])) end--;\n      if (end <= start) {\n        setSelection(null);\n        return;\n      }\n      const box = root.getBoundingClientRect();\n      setSelection({ start, end, anchor: relativeAnchor(range.getBoundingClientRect(), box) });\n    };\n    document.addEventListener(\"selectionchange\", onChange);\n    return () => document.removeEventListener(\"selectionchange\", onChange);\n  }, [pose]);\n\n  // Cmd/Ctrl+K opens the AI input for the current selection, or the paragraph last clicked.\n  useEffect(() => {\n    if (pose) return;\n    const onKey = (e: KeyboardEvent) => {\n      if (e.key.toLowerCase() !== \"k\" || !(e.metaKey || e.ctrlKey)) return;\n      const root = docRef.current;\n      if (!root || latest.current.proposal) return;\n      const active = document.activeElement;\n      const inside = rootRef.current?.contains(active) || active === document.body;\n      if (!latest.current.selection && !inside) return;\n      // Wins over app-wide Cmd+K palettes: this listener runs in the capture phase on window.\n      e.preventDefault();\n      e.stopImmediatePropagation();\n      if (!latest.current.selection) {\n        const para = root.querySelectorAll<HTMLElement>(\"[data-para-offset]\")[lastPara.current];\n        if (!para) return;\n        const start = Number(para.dataset.paraOffset);\n        const box = root.getBoundingClientRect();\n        setSelection({\n          start,\n          end: start + (para.textContent ?? \"\").length,\n          anchor: relativeAnchor(para.getBoundingClientRect(), box),\n        });\n      }\n      setFrozen(true);\n      requestAnimationFrame(() => inputRef.current?.focus());\n    };\n    window.addEventListener(\"keydown\", onKey, true);\n    return () => window.removeEventListener(\"keydown\", onKey, true);\n  }, [pose]);\n\n  // ----- Floating placement, written straight to the DOM after layout.\n  useLayoutEffect(() => {\n    const root = docRef.current;\n    if (!root) return;\n    const width = root.clientWidth;\n    if (toolbarRef.current && selection && !proposal) place(toolbarRef.current, selection.anchor, width, \"above\");\n    if (barRef.current && insRef.current && proposal) {\n      const rects = insRef.current.getClientRects();\n      const last = rects[rects.length - 1] ?? insRef.current.getBoundingClientRect();\n      place(barRef.current, relativeAnchor(last, root.getBoundingClientRect()), width, \"below\");\n    }\n  });\n\n  // Focus Accept once the proposal is complete.\n  const proposalDone = proposalState?.status === \"done\";\n  useEffect(() => {\n    if (proposalDone) acceptRef.current?.focus();\n  }, [proposalDone]);\n\n  const close = () => {\n    setSelection(null);\n    setFrozen(false);\n    setToneOpen(false);\n    setAsk(\"\");\n  };\n\n  const run = async (instruction: string, labelText: string, range?: { start: number; end: number }) => {\n    const target = range ?? selection;\n    if (!target) return;\n    const id = ++runId.current;\n    const original = doc.slice(target.start, target.end);\n    setProposal({ start: target.start, end: target.end, original, text: \"\", instruction, label: labelText, status: \"streaming\" });\n    close();\n    window.getSelection()?.removeAllRanges();\n    requestAnimationFrame(() => barRef.current?.focus());\n    setMessage(`Rewriting: ${labelText}`);\n    try {\n      const result = onRewrite(original, instruction);\n      if (isAsyncIterable(result)) {\n        let acc = \"\";\n        for await (const chunk of result) {\n          if (runId.current !== id) return;\n          acc += chunk;\n          const next = acc;\n          setProposal((p) => (p ? { ...p, text: next } : p));\n        }\n      } else {\n        const full = await result;\n        if (runId.current !== id) return;\n        setProposal((p) => (p ? { ...p, text: full } : p));\n      }\n      if (runId.current !== id) return;\n      setProposal((p) => (p ? { ...p, text: p.text.trimEnd(), status: \"done\" } : p));\n      setMessage(\"Suggestion ready. Enter to accept, Escape to discard.\");\n    } catch {\n      if (runId.current !== id) return;\n      setProposal((p) => (p ? { ...p, status: \"error\" } : p));\n      setMessage(\"The rewrite failed.\");\n    }\n  };\n\n  const accept = () => {\n    if (!proposalState || proposalState.status !== \"done\") return;\n    runId.current++;\n    setDoc(doc.slice(0, proposalState.start) + proposalState.text + doc.slice(proposalState.end));\n    setProposal(null);\n    setMessage(\"Rewrite accepted\");\n    docRef.current?.focus();\n  };\n\n  const discard = () => {\n    if (!proposalState) return;\n    runId.current++;\n    setProposal(null);\n    setMessage(\"Rewrite discarded\");\n    docRef.current?.focus();\n  };\n\n  const retry = () => {\n    if (!proposalState) return;\n    void run(proposalState.instruction, proposalState.label, proposalState);\n  };\n\n  const onRootKey = (e: ReactKeyboardEvent<HTMLDivElement>) => {\n    if (proposalState) {\n      const onControl = (e.target as HTMLElement).closest(\"button,input\");\n      if (e.key === \"Escape\") {\n        e.preventDefault();\n        discard();\n      } else if (e.key === \"Enter\" && !onControl) {\n        e.preventDefault();\n        accept();\n      }\n      return;\n    }\n    if (e.key === \"Escape\" && selection) {\n      e.preventDefault();\n      close();\n      window.getSelection()?.removeAllRanges();\n      docRef.current?.focus();\n    }\n  };\n\n  const onSubmit = (e: FormEvent) => {\n    e.preventDefault();\n    const text = ask.trim();\n    if (text) void run(text, text.length > 28 ? `${text.slice(0, 26)}…` : text);\n  };\n\n  // ----- Paragraph rendering with an inline proposal or a held selection.\n  const mark = proposal\n    ? { start: proposal.start, end: proposal.end, kind: \"proposal\" as const }\n    : selection && frozen\n      ? { start: selection.start, end: selection.end, kind: \"held\" as const }\n      : null;\n\n  const lastOverlap = mark ? paras.findLastIndex((p) => p.start <= mark.end && p.start + p.text.length >= mark.start) : -1;\n\n  const renderPara = (p: { text: string; start: number }, i: number): ReactNode => {\n    if (!mark) return p.text;\n    const end = p.start + p.text.length;\n    const os = Math.max(mark.start, p.start);\n    const oe = Math.min(mark.end, end);\n    if (os > oe || (os === oe && i !== lastOverlap)) return p.text;\n    const before = p.text.slice(0, os - p.start);\n    const inside = p.text.slice(os - p.start, oe - p.start);\n    const after = p.text.slice(oe - p.start);\n    if (mark.kind === \"held\") {\n      return (\n        <>\n          {before}\n          <span className=\"rounded-[2px] bg-[color:var(--bjork-accent-muted)] text-[color:var(--bjork-text)]\">{inside}</span>\n          {after}\n        </>\n      );\n    }\n    return (\n      <>\n        {before}\n        {inside && (\n          <del className=\"text-[color:var(--bjork-text-faint)] decoration-[color:var(--bjork-text-faint)] decoration-1\">\n            <span className=\"sr-only\">Original: </span>\n            {inside}\n          </del>\n        )}\n        {i === lastOverlap && proposal && (\n          <>\n            {inside ? \" \" : null}\n            <ins\n              ref={insRef}\n              className=\"rounded-[3px] bg-[color:var(--bjork-accent-soft)] px-0.5 text-[color:var(--bjork-text)] no-underline shadow-[inset_0_-1px_0_var(--bjork-accent-muted)] [box-decoration-break:clone]\"\n            >\n              <span className=\"sr-only\">Suggested: </span>\n              {proposal.text}\n              {proposal.status === \"streaming\" && (\n                <span\n                  aria-hidden=\"true\"\n                  className=\"ml-px inline-block h-[1em] w-[2px] translate-y-[3px] bg-[color:var(--bjork-accent)] motion-safe:animate-pulse\"\n                />\n              )}\n            </ins>\n          </>\n        )}\n        {after}\n      </>\n    );\n  };\n\n  return (\n    <div\n      ref={rootRef}\n      style={style}\n      onKeyDown={onRootKey}\n      className={cn(\"@container relative w-full min-w-0 max-w-[640px] font-bjork-alpha text-[color:var(--bjork-text)]\", className)}\n    >\n      <style href=\"bjork-ai-shimmer\" precedence=\"default\">\n        {SHIMMER_TEXT_CSS}\n      </style>\n      <style href=\"bjork-inline-ai-pop\" precedence=\"default\">\n        {\"@keyframes bjork-inline-ai-pop{from{opacity:0;scale:0.96}to{opacity:1;scale:1}}\"}\n      </style>\n\n      <div\n        ref={docRef}\n        role=\"document\"\n        aria-label={label}\n        aria-describedby={`${inputId}-hint`}\n        tabIndex={0}\n        data-value={doc}\n        onPointerDown={(e) => {\n          const para = (e.target as HTMLElement).closest<HTMLElement>(\"[data-para-index]\");\n          if (para) lastPara.current = Number(para.dataset.paraIndex);\n        }}\n        className={cn(\n          \"relative flex flex-col gap-3.5 rounded-[10px] text-[15px] leading-[26px] text-[color:var(--bjork-text-medium)] selection:bg-[color:var(--bjork-accent-muted)] selection:text-[color:var(--bjork-text)]\",\n          FOCUS_RING,\n        )}\n      >\n        {paras.map((p, i) => (\n          <p key={i} data-para-offset={p.start} data-para-index={i} className=\"whitespace-pre-wrap\">\n            {renderPara(p, i)}\n          </p>\n        ))}\n\n        {selection && !proposal && (\n          <div\n            ref={toolbarRef}\n            role=\"group\"\n            aria-label=\"Ask AI about the selection\"\n            onFocus={() => setFrozen(true)}\n            onBlur={(e) => {\n              if (!e.currentTarget.contains(e.relatedTarget as Node | null)) {\n                setFrozen(false);\n                setToneOpen(false);\n                if (!docRef.current?.contains(e.relatedTarget as Node | null)) setSelection(null);\n              }\n            }}\n            onPointerDown={(e) => {\n              // Buttons keep the document selection alive. The input takes focus normally.\n              if (!(e.target as HTMLElement).closest(\"input\")) e.preventDefault();\n            }}\n            className=\"absolute left-0 top-0 z-20 w-[min(372px,100%)] select-none text-[13px] leading-5\"\n          >\n            <div\n              className=\"rounded-[12px] border border-[color:var(--bjork-border)] bg-[color:var(--bjork-menu)] p-1.5 shadow-[var(--bjork-shadow-menu)] backdrop-blur-md\"\n              style={{ animation: reduce ? \"none\" : \"bjork-inline-ai-pop 160ms cubic-bezier(0.23,1,0.32,1)\" }}\n            >\n              <form onSubmit={onSubmit} className=\"flex h-8 items-center gap-2 pl-2\">\n                <Sparkles size={14} strokeWidth={1.75} aria-hidden=\"true\" className=\"shrink-0 text-[color:var(--bjork-accent-ink)]\" />\n                <label htmlFor={inputId} className=\"sr-only\">\n                  Ask AI to edit the selection\n                </label>\n                <input\n                  id={inputId}\n                  ref={inputRef}\n                  value={ask}\n                  onChange={(e) => setAsk(e.target.value)}\n                  placeholder=\"Ask AI to edit…\"\n                  autoComplete=\"off\"\n                  className=\"h-8 min-w-0 flex-1 bg-transparent text-[13px] text-[color:var(--bjork-text)] outline-none placeholder:text-[color:var(--bjork-text-faint)]\"\n                />\n                <span className=\"hidden shrink-0 font-mono text-[11px] text-[color:var(--bjork-text-faint)] @[420px]:inline\">⌘K</span>\n                <button\n                  type=\"submit\"\n                  aria-label=\"Run\"\n                  disabled={!ask.trim()}\n                  className={cn(\n                    \"grid size-7 shrink-0 cursor-pointer place-items-center rounded-[8px] bg-[color:var(--bjork-accent-fill)] text-[color:var(--bjork-accent-foreground)] disabled:cursor-default disabled:bg-[color:var(--bjork-surface-active)] disabled:text-[color:var(--bjork-text-faint)]\",\n                    FOCUS_RING,\n                    PRESS,\n                  )}\n                >\n                  <ArrowUp size={14} strokeWidth={2} />\n                </button>\n              </form>\n              <div className=\"mt-1 flex flex-wrap gap-0.5 border-t border-[color:var(--bjork-border)] pt-1\">\n                {QUICK.map((q) => (\n                  <ChipButton key={q.label} onClick={() => void run(q.instruction, q.label)}>\n                    {q.label}\n                  </ChipButton>\n                ))}\n                <ChipButton\n                  aria-haspopup=\"menu\"\n                  aria-expanded={toneOpen}\n                  onClick={() => setToneOpen((v) => !v)}\n                  className={toneOpen ? \"bg-[color:var(--bjork-surface-active)] text-[color:var(--bjork-text)]\" : undefined}\n                >\n                  Tone\n                  <StrokeMorphIcon name={toneOpen ? \"chevron-down\" : \"chevron-right\"} size={10} strokeWidth={2} color=\"currentColor\" />\n                </ChipButton>\n              </div>\n              {toneOpen && (\n                <ToneMenu\n                  onPick={(t) => void run(`Rewrite in a ${t.toLowerCase()} tone.`, t)}\n                  onClose={() => setToneOpen(false)}\n                />\n              )}\n            </div>\n          </div>\n        )}\n\n        {proposal && (\n          <div\n            ref={barRef}\n            role=\"group\"\n            aria-label=\"AI suggestion\"\n            tabIndex={-1}\n            className=\"absolute left-0 top-0 z-20 outline-none\"\n          >\n            <div\n              className=\"flex items-center gap-1 rounded-[11px] border border-[color:var(--bjork-border)] bg-[color:var(--bjork-menu)] p-1 pl-2.5 text-[12px] shadow-[var(--bjork-shadow-menu)] backdrop-blur-md\"\n              style={{ animation: reduce || pose ? \"none\" : \"bjork-inline-ai-pop 160ms cubic-bezier(0.23,1,0.32,1)\" }}\n            >\n              <span className=\"mr-1 flex min-w-0 items-center gap-1.5\">\n                <Sparkles size={12} strokeWidth={1.75} aria-hidden=\"true\" className=\"shrink-0 text-[color:var(--bjork-accent-ink)]\" />\n                {proposal.status === \"streaming\" ? (\n                  <span className=\"bjork-ai-shimmer whitespace-nowrap font-mono text-[11px]\">Rewriting…</span>\n                ) : proposal.status === \"error\" ? (\n                  <span className=\"whitespace-nowrap font-mono text-[11px] text-[color:var(--bjork-error)]\">Failed</span>\n                ) : (\n                  <span className=\"max-w-[12ch] truncate font-mono text-[11px] text-[color:var(--bjork-text-muted)]\">{proposal.label}</span>\n                )}\n              </span>\n              <ChipButton onClick={discard} aria-keyshortcuts=\"Escape\">\n                Discard\n                <Key>esc</Key>\n              </ChipButton>\n              <ChipButton onClick={retry} disabled={proposal.status === \"streaming\"}>\n                Retry\n              </ChipButton>\n              <button\n                ref={acceptRef}\n                type=\"button\"\n                aria-keyshortcuts=\"Enter\"\n                disabled={proposal.status !== \"done\"}\n                onClick={accept}\n                className={cn(\n                  \"inline-flex h-7 cursor-pointer items-center gap-1.5 rounded-[8px] bg-[color:var(--bjork-accent-fill)] px-2.5 text-[12px] font-medium text-[color:var(--bjork-accent-foreground)] disabled:cursor-default disabled:opacity-45\",\n                  FOCUS_RING,\n                  PRESS,\n                )}\n              >\n                Accept\n                <span aria-hidden=\"true\" className=\"font-mono text-[11px] opacity-70\">\n                  ↵\n                </span>\n              </button>\n            </div>\n          </div>\n        )}\n      </div>\n\n      <p id={`${inputId}-hint`} className=\"sr-only\">\n        Select text, then press Command or Control K to ask AI to rewrite it.\n      </p>\n      <LiveRegion message={message} />\n    </div>\n  );\n}\n\nfunction Key({ children }: { children: string }) {\n  return (\n    <span aria-hidden=\"true\" className=\"font-mono text-[11px] text-[color:var(--bjork-text-faint)]\">\n      {children}\n    </span>\n  );\n}\n\nfunction ChipButton({\n  className,\n  children,\n  ...rest\n}: ButtonHTMLAttributes<HTMLButtonElement> & { children: ReactNode }) {\n  return (\n    <button\n      type=\"button\"\n      {...rest}\n      className={cn(\n        \"inline-flex h-7 cursor-pointer items-center gap-1 rounded-[8px] px-2 text-[12px] font-medium text-[color:var(--bjork-text-medium)] transition-colors duration-150 hover:bg-[color:var(--bjork-surface-active)] hover:text-[color:var(--bjork-text)] disabled:cursor-default disabled:opacity-40\",\n        FOCUS_RING,\n        PRESS,\n        className,\n      )}\n    >\n      {children}\n    </button>\n  );\n}\n\nfunction ToneMenu({ onPick, onClose }: { onPick: (tone: string) => void; onClose: () => void }) {\n  const items = useRef<(HTMLButtonElement | null)[]>([]);\n  useEffect(() => {\n    items.current[0]?.focus();\n  }, []);\n  const onKey = (e: ReactKeyboardEvent<HTMLDivElement>) => {\n    const i = items.current.findIndex((el) => el === document.activeElement);\n    let next = -1;\n    if (e.key === \"ArrowRight\" || e.key === \"ArrowDown\") next = (i + 1) % TONES.length;\n    else if (e.key === \"ArrowLeft\" || e.key === \"ArrowUp\") next = (i - 1 + TONES.length) % TONES.length;\n    else if (e.key === \"Home\") next = 0;\n    else if (e.key === \"End\") next = TONES.length - 1;\n    else if (e.key === \"Escape\") {\n      e.preventDefault();\n      e.stopPropagation();\n      onClose();\n      return;\n    } else return;\n    e.preventDefault();\n    items.current[next]?.focus();\n  };\n  return (\n    <div\n      role=\"menu\"\n      aria-label=\"Tone\"\n      aria-orientation=\"horizontal\"\n      onKeyDown={onKey}\n      className=\"mt-1 flex flex-wrap gap-0.5 border-t border-[color:var(--bjork-border)] pt-1\"\n    >\n      {TONES.map((t, i) => (\n        <Fragment key={t}>\n          <button\n            ref={(el) => {\n              items.current[i] = el;\n            }}\n            type=\"button\"\n            role=\"menuitem\"\n            tabIndex={i === 0 ? 0 : -1}\n            onClick={() => onPick(t)}\n            className={cn(\n              \"inline-flex h-7 cursor-pointer items-center rounded-[8px] px-2 text-[12px] text-[color:var(--bjork-text-muted)] transition-colors hover:bg-[color:var(--bjork-surface-active)] hover:text-[color:var(--bjork-text)] focus-visible:bg-[color:var(--bjork-surface-active)]\",\n              FOCUS_RING,\n            )}\n          >\n            {t}\n          </button>\n        </Fragment>\n      ))}\n    </div>\n  );\n}\n",
      "type": "registry:component",
      "target": "@components/bjork-ui/ai/inline-ai-edit.tsx"
    },
    {
      "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/ai/_shared.ts",
      "content": "\"use client\";\n\n// Shared plumbing for the bjork-ui AI components: tone tokens, reduced motion, controllable state and\n// number formatting. Every AI component imports from here so they read as one family.\n\nimport {\n  useCallback,\n  useLayoutEffect,\n  useRef,\n  useState,\n  useSyncExternalStore,\n  type CSSProperties,\n} from \"react\";\nimport { useReducedMotion } from \"framer-motion\";\nimport { BJORK_PALETTE, type BjorkTone } from \"@/components/bjork-ui/_core/palette\";\nimport { useBjorkTone } from \"@/components/bjork-ui/_core/tone\";\nimport { surfaceVars } from \"@/components/bjork-ui/_core/surface\";\n\nexport type { BjorkTone };\nexport type BjorkPalette = (typeof BJORK_PALETTE)[BjorkTone];\n\nconst noopSubscribe = () => () => {};\n\n/** False on the server and the first client render, true after hydration. */\nexport function useHydrated(): boolean {\n  return useSyncExternalStore(\n    noopSubscribe,\n    () => true,\n    () => false,\n  );\n}\n\n/** Hydration-safe reduced motion: both sides report \"not reduced\" until mount, then the real value applies. */\nexport function useReduceMotion(): boolean {\n  const hydrated = useHydrated();\n  const reduced = useReducedMotion();\n  return hydrated && reduced === true;\n}\n\n/**\n * Resolves the tone and returns the CSS custom properties for it. Spread `style` on the component root so an\n * explicit `tone` prop wins over the page theme, and status colours (success, warning, error) are always defined.\n */\nexport function useAiTone(toneProp?: BjorkTone) {\n  const tone = useBjorkTone(toneProp);\n  const pal = BJORK_PALETTE[tone];\n  const style = {\n    ...surfaceVars(tone),\n    \"--bjork-text\": pal.text,\n    \"--bjork-text-medium\": pal.textMedium,\n    \"--bjork-text-muted\": pal.textMuted,\n    \"--bjork-text-soft\": pal.textSoft,\n    \"--bjork-text-faint\": pal.textFaint,\n    \"--bjork-hair\": pal.hair,\n    \"--bjork-border\": pal.border,\n    \"--bjork-border-strong\": pal.borderStrong,\n    \"--bjork-surface\": pal.surface,\n    \"--bjork-raised\": pal.raised,\n    \"--bjork-surface-active\": pal.active,\n    \"--bjork-accent\": pal.accent,\n    \"--bjork-accent-ink\": pal.accentInk,\n    \"--bjork-accent-fill\": pal.accentFill,\n    \"--bjork-accent-soft\": pal.accentSoft,\n    \"--bjork-accent-muted\": pal.accentMuted,\n    \"--bjork-accent-foreground\": pal.accentFg,\n    \"--bjork-success\": pal.success,\n    \"--bjork-warning\": pal.warning,\n    \"--bjork-error\": pal.error,\n    \"--bjork-ring-offset\": pal.bg,\n    colorScheme: tone,\n  } as CSSProperties;\n  return { tone, pal, style };\n}\n\n/** Classes for the shared keyboard focus ring. */\nexport const FOCUS_RING =\n  \"outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--bjork-accent)] focus-visible:ring-offset-2 focus-visible:ring-offset-[color:var(--bjork-ring-offset)]\";\n\n/** Classes for the shared press feedback. */\nexport const PRESS = \"transition-transform duration-150 ease-out active:scale-[0.97] motion-reduce:transition-none\";\n\n/**\n * Controlled-or-uncontrolled state. Pass `value` to control it; `onChange` fires for every change either way.\n */\nexport function useControllable<T>(\n  value: T | undefined,\n  defaultValue: T,\n  onChange?: (next: T) => void,\n): [T, (next: T) => void] {\n  const controlled = value !== undefined;\n  const [inner, setInner] = useState(defaultValue);\n  const current = controlled ? value : inner;\n  const onChangeRef = useRef(onChange);\n  useLayoutEffect(() => {\n    onChangeRef.current = onChange;\n  });\n  const set = useCallback(\n    (next: T) => {\n      if (!controlled) setInner(next);\n      onChangeRef.current?.(next);\n    },\n    [controlled],\n  );\n  return [current, set];\n}\n\n/** 1234 -> \"1,234\", 18400 -> \"18.4k\", 1_200_000 -> \"1.2M\". */\nexport function formatTokens(n: number): string {\n  if (n < 10_000) return Math.round(n).toLocaleString(\"en-US\");\n  if (n < 1_000_000) return `${(n / 1000).toFixed(n < 100_000 ? 1 : 0).replace(/\\.0$/, \"\")}k`;\n  return `${(n / 1_000_000).toFixed(n < 10_000_000 ? 2 : 1).replace(/\\.?0+$/, \"\")}M`;\n}\n\n/** US dollars with sub-cent precision when it matters: 0.0042 -> \"$0.0042\", 12.5 -> \"$12.50\". */\nexport function formatCost(usd: number): string {\n  if (usd === 0) return \"$0.00\";\n  if (Math.abs(usd) < 0.01) return `$${usd.toFixed(4)}`;\n  if (Math.abs(usd) < 1) return `$${usd.toFixed(3)}`;\n  return `$${usd.toLocaleString(\"en-US\", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;\n}\n\n/** 640 -> \"640ms\", 4200 -> \"4.2s\", 95000 -> \"1m 35s\". */\nexport function formatDuration(ms: number): string {\n  if (ms < 1000) return `${Math.round(ms)}ms`;\n  if (ms < 60_000) return `${(ms / 1000).toFixed(1)}s`;\n  const m = Math.floor(ms / 60_000);\n  const s = Math.round((ms % 60_000) / 1000);\n  return `${m}m ${s}s`;\n}\n\n/** 2_400_000 bytes -> \"2.4 MB\". */\nexport function formatBytes(bytes: number): string {\n  if (bytes < 1024) return `${bytes} B`;\n  const units = [\"KB\", \"MB\", \"GB\"];\n  let v = bytes / 1024;\n  let i = 0;\n  while (v >= 1024 && i < units.length - 1) {\n    v /= 1024;\n    i++;\n  }\n  return `${v.toFixed(v < 10 ? 1 : 0)} ${units[i]}`;\n}\n\n/** Shimmer keyframes for \"working\" text. Mount once with <style href=... precedence=\"default\">. */\nexport const SHIMMER_TEXT_CSS =\n  \"@keyframes bjork-ai-shimmer{from{background-position:100% 0}to{background-position:0% 0}}\" +\n  \".bjork-ai-shimmer{background-image:linear-gradient(90deg,var(--bjork-text-muted) 0%,var(--bjork-text-muted) 40%,var(--bjork-text) 50%,var(--bjork-text-muted) 60%,var(--bjork-text-muted) 100%);background-size:300% 100%;-webkit-background-clip:text;background-clip:text;color:transparent;animation:bjork-ai-shimmer 1.8s linear infinite}\" +\n  \"@media (prefers-reduced-motion: reduce){.bjork-ai-shimmer{animation:none;background:none;color:var(--bjork-text-muted)}}\";\n\n/** CANON §2. Panel = a component's own root surface; Card = a standalone card; Tile = a card inside a panel; Well = recessed code/field. */\nexport const AI_PANEL = \"rounded-[20px] border border-[color:var(--bjork-border-muted)] bg-[color:var(--bjork-surface)] p-4 shadow-[var(--bjork-shadow-surface)]\";\nexport const AI_CARD = \"rounded-[18px] border border-[color:var(--bjork-border-muted)] bg-[color:var(--bjork-surface)] shadow-[var(--bjork-shadow-surface)]\";\nexport const AI_TILE = \"rounded-[12px] border border-[color:var(--bjork-border-muted)] bg-[color:var(--bjork-surface)] shadow-[var(--bjork-shadow-surface)]\";\nexport const AI_WELL = \"rounded-[8px] border border-[color:var(--bjork-border)] bg-[color:var(--bjork-field-inset)] shadow-[var(--bjork-shadow-inset)]\";\n",
      "type": "registry:component",
      "target": "@components/bjork-ui/ai/_shared.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/surface.ts",
      "content": "import type { CSSProperties } from \"react\";\nimport type { BjorkTone } from \"./palette\";\n\n// The library surface (CANON.md §1). Values mirror app/globals.css :root and .dark. Change both together.\nexport const BJORK_SURFACE = {\n  light: {\n    surface: \"#fffcf6\", surfaceMuted: \"rgba(250, 246, 237, 0.94)\", surfaceHover: \"#f5efe3\", surfaceActive: \"#efe7d8\",\n    panel: \"#f8f2e7\", menu: \"rgba(255, 252, 246, 0.98)\", field: \"rgba(255, 252, 246, 0.96)\",\n    fieldMuted: \"rgba(248, 242, 231, 0.82)\", fieldInset: \"rgba(239, 231, 216, 0.72)\",\n    border: \"#eee6db\", borderMuted: \"#f5ede2\", borderStrong: \"#e1d7c8\",\n    shadowSurface: \"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)\",\n    shadowSoft: \"inset 0 1px 0 rgba(88, 72, 49, 0.045), inset 0 0.5px 0.5px rgba(255, 255, 255, 0.86)\",\n    shadowPanel: \"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)\",\n    shadowMenu: \"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)\",\n    shadowInset: \"inset 0 1px 10px rgba(88, 72, 49, 0.12), inset 0 0.5px 0.5px rgba(255, 255, 255, 0.72)\",\n  },\n  dark: {\n    surface: \"#121212\", surfaceMuted: \"rgba(22, 22, 22, 0.92)\", surfaceHover: \"#161616\", surfaceActive: \"#202020\",\n    panel: \"#0d0d0d\", menu: \"rgba(18, 18, 18, 0.98)\", field: \"rgba(18, 18, 18, 0.95)\",\n    fieldMuted: \"rgba(18, 18, 18, 0.55)\", fieldInset: \"rgba(9, 9, 9, 0.75)\",\n    border: \"#232323\", borderMuted: \"#1c1c1c\", borderStrong: \"#343434\",\n    shadowSurface: \"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)\",\n    shadowSoft: \"inset 0 1px 0 rgba(255, 255, 255, 0.045)\",\n    shadowPanel: \"inset 0 1px 0 rgba(255, 255, 255, 0.035), 0 18px 36px -28px rgba(0, 0, 0, 0.9)\",\n    shadowMenu: \"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)\",\n    shadowInset: \"inset 0 1px 10px rgba(0, 0, 0, 0.45)\",\n  },\n} as const;\n\n/** Writes the surface tokens for a pinned tone as --bjork-* custom properties. Spread first; kit-specific keys may override. */\nexport function surfaceVars(tone: BjorkTone): CSSProperties {\n  const s = BJORK_SURFACE[tone];\n  return {\n    \"--bjork-surface\": s.surface, \"--bjork-surface-muted\": s.surfaceMuted, \"--bjork-surface-hover\": s.surfaceHover,\n    \"--bjork-surface-active\": s.surfaceActive, \"--bjork-panel\": s.panel, \"--bjork-menu\": s.menu, \"--bjork-field\": s.field,\n    \"--bjork-field-muted\": s.fieldMuted, \"--bjork-field-inset\": s.fieldInset, \"--bjork-border\": s.border,\n    \"--bjork-border-muted\": s.borderMuted, \"--bjork-border-strong\": s.borderStrong,\n    \"--bjork-shadow-surface\": s.shadowSurface, \"--bjork-shadow-soft\": s.shadowSoft, \"--bjork-shadow-panel\": s.shadowPanel,\n    \"--bjork-shadow-menu\": s.shadowMenu, \"--bjork-shadow-inset\": s.shadowInset,\n  } as CSSProperties;\n}\n",
      "type": "registry:component",
      "target": "@components/bjork-ui/_core/surface.ts"
    },
    {
      "path": "components/bjork-ui/utilities/stroke-morph-icon.tsx",
      "content": "\"use client\";\n\nimport { useLayoutEffect, useRef, useState, type RefObject } from \"react\";\nimport { useReducedMotion } from \"framer-motion\";\nimport { cn } from \"@/lib/utils\";\nimport { useVisibleLoop } from \"@/components/bjork-ui/_core/loop\";\nimport { stepSpring, type SpringState } from \"@/components/bjork-ui/_core/spring\";\nimport { easeCss } from \"@/components/bjork-ui/_core/motion\";\n\nexport type StrokeIconName =\n  | \"menu\"\n  | \"equal\"\n  | \"minus\"\n  | \"plus\"\n  | \"close\"\n  | \"chevron-down\"\n  | \"chevron-up\"\n  | \"chevron-right\"\n  | \"chevron-left\"\n  | \"arrow-right\"\n  | \"arrow-down\"\n  | \"arrow-left\"\n  | \"arrow-up\"\n  | \"check\"\n  | \"play\"\n  | \"pause\"\n  | \"dot\"\n  | \"ellipsis\"\n  | \"busy\"\n  | (string & {});\n\n// Three line segments in a 24 x 24 box. `hidden` lines render at opacity 0 and collapse\n// onto the centroid of the visible endpoints. `group` icons share geometry and only rotate.\nexport interface StrokeIconDef {\n  lines: [number, number, number, number][];\n  hidden?: [boolean, boolean, boolean];\n  group?: string;\n  rotation?: number;\n  spin?: boolean;\n  strokeScale?: number;\n}\n\nexport interface StrokeMorphIconProps {\n  name: StrokeIconName;\n  size?: number;\n  strokeWidth?: number;\n  color?: string;\n  spring?: { stiffness: number; damping: number; mass: number };\n  spin?: boolean;\n  label?: string;\n  className?: string;\n}\n\ntype Seg = [number, number, number, number];\n\nconst DEFAULT_SPRING = { stiffness: 380, damping: 30, mass: 0.7 };\nconst CROSS_LINES: StrokeIconDef[\"lines\"] = [\n  [12, 5, 12, 19],\n  [5, 12, 19, 12],\n  [12, 12, 12, 12],\n];\nconst CHEVRON_LINES: StrokeIconDef[\"lines\"] = [\n  [6, 9, 12, 15],\n  [12, 15, 18, 9],\n  [12, 15, 12, 15],\n];\nconst ARROW_LINES: StrokeIconDef[\"lines\"] = [\n  [5, 12, 19, 12],\n  [13, 6, 19, 12],\n  [13, 18, 19, 12],\n];\n\nexport const strokeIcons: Record<string, StrokeIconDef> = {\n  menu: {\n    lines: [\n      [4, 7, 20, 7],\n      [4, 12, 20, 12],\n      [4, 17, 20, 17],\n    ],\n  },\n  equal: {\n    lines: [\n      [5, 9, 19, 9],\n      [5, 15, 19, 15],\n      [12, 12, 12, 12],\n    ],\n    hidden: [false, false, true],\n  },\n  minus: {\n    lines: [\n      [5, 12, 19, 12],\n      [12, 12, 12, 12],\n      [12, 12, 12, 12],\n    ],\n    hidden: [false, true, true],\n  },\n  plus: { lines: CROSS_LINES, hidden: [false, false, true], group: \"cross\", rotation: 0 },\n  close: { lines: CROSS_LINES, hidden: [false, false, true], group: \"cross\", rotation: 45 },\n  \"chevron-down\": { lines: CHEVRON_LINES, hidden: [false, false, true], group: \"chevron\", rotation: 0 },\n  \"chevron-up\": { lines: CHEVRON_LINES, hidden: [false, false, true], group: \"chevron\", rotation: 180 },\n  \"chevron-right\": { lines: CHEVRON_LINES, hidden: [false, false, true], group: \"chevron\", rotation: -90 },\n  \"chevron-left\": { lines: CHEVRON_LINES, hidden: [false, false, true], group: \"chevron\", rotation: 90 },\n  \"arrow-right\": { lines: ARROW_LINES, group: \"arrow\", rotation: 0 },\n  \"arrow-down\": { lines: ARROW_LINES, group: \"arrow\", rotation: 90 },\n  \"arrow-left\": { lines: ARROW_LINES, group: \"arrow\", rotation: 180 },\n  \"arrow-up\": { lines: ARROW_LINES, group: \"arrow\", rotation: -90 },\n  check: {\n    lines: [\n      [5, 12.5, 10, 17.5],\n      [10, 17.5, 19, 7],\n      [10, 17.5, 10, 17.5],\n    ],\n    hidden: [false, false, true],\n  },\n  // The +0.75px optical x nudge is already in these coordinates (OPTICAL-ALIGNMENT R2).\n  play: {\n    lines: [\n      [8.75, 5, 8.75, 19],\n      [8.75, 5, 19.75, 12],\n      [8.75, 19, 19.75, 12],\n    ],\n  },\n  pause: {\n    lines: [\n      [9, 6, 9, 18],\n      [15, 6, 15, 18],\n      [12, 12, 12, 12],\n    ],\n    hidden: [false, false, true],\n  },\n  dot: {\n    lines: [\n      [12, 12, 12, 12],\n      [12, 12, 12, 12],\n      [12, 12, 12, 12],\n    ],\n    hidden: [false, true, true],\n    strokeScale: 1.6,\n  },\n  ellipsis: {\n    lines: [\n      [6, 12, 6, 12],\n      [12, 12, 12, 12],\n      [18, 12, 18, 12],\n    ],\n  },\n  busy: {\n    lines: [\n      [12, 12, 12, 5],\n      [12, 12, 18.06, 15.5],\n      [12, 12, 5.94, 15.5],\n    ],\n    group: \"busy\",\n    rotation: 0,\n    spin: true,\n  },\n};\n\n// Optical nudges, in the icon's own frame (the group rotation carries them with the icon).\n// Measured with the blur test (OPTICAL-ALIGNMENT R1 / checklist 1 and 4): arrows carry their\n// visual weight toward the head, so they move back toward the tail; the check's weight sits low.\nconst OPTICAL_NUDGE = new Map<StrokeIconDef, [number, number]>([\n  [strokeIcons[\"arrow-right\"], [-1.6, 0]], // arrow-right: weight +1.63 units right of centre\n  [strokeIcons[\"arrow-down\"], [-1.6, 0]],\n  [strokeIcons[\"arrow-left\"], [-1.6, 0]],\n  [strokeIcons[\"arrow-up\"], [-1.6, 0]],\n  [strokeIcons.check, [0, -0.9]], // check: weight +0.94 units below centre\n]);\n\nfunction nudgeOf(def: StrokeIconDef): [number, number] {\n  return OPTICAL_NUDGE.get(def) ?? [0, 0];\n}\n\nfunction isSeg(v: unknown): v is Seg {\n  return Array.isArray(v) && v.length === 4 && v.every((n) => typeof n === \"number\" && Number.isFinite(n));\n}\n\n// Registers a custom icon. Throws a TypeError unless it has exactly three finite line segments.\nexport function defineStrokeIcon(name: string, def: StrokeIconDef): void {\n  if (!name) throw new TypeError(\"defineStrokeIcon: name must be a non-empty string\");\n  if (!Array.isArray(def.lines) || def.lines.length !== 3 || !def.lines.every(isSeg)) {\n    throw new TypeError(`defineStrokeIcon(\"${name}\"): lines must be exactly 3 [x1, y1, x2, y2] segments`);\n  }\n  if (def.hidden !== undefined && (!Array.isArray(def.hidden) || def.hidden.length !== 3)) {\n    throw new TypeError(`defineStrokeIcon(\"${name}\"): hidden must have exactly 3 booleans`);\n  }\n  strokeIcons[name] = def;\n}\n\nconst warnedNames = new Set<string>();\n\nfunction resolveStrokeIcon(name: string): StrokeIconDef {\n  if (Object.prototype.hasOwnProperty.call(strokeIcons, name)) return strokeIcons[name];\n  if (process.env.NODE_ENV !== \"production\" && !warnedNames.has(name)) {\n    warnedNames.add(name);\n    console.warn(`StrokeMorphIcon: unknown name \"${name}\", rendering \"dot\" instead.`);\n  }\n  return strokeIcons.dot;\n}\n\n// Effective geometry: hidden lines collapse onto the centroid of the visible endpoints.\nconst effCache = new WeakMap<StrokeIconDef, Seg[]>();\n\nfunction effOf(def: StrokeIconDef): Seg[] {\n  const cached = effCache.get(def);\n  if (cached) return cached;\n  const visible = [0, 1, 2].map((i) => !def.hidden?.[i]);\n  let sx = 0;\n  let sy = 0;\n  let n = 0;\n  def.lines.forEach(([x1, y1, x2, y2], i) => {\n    if (!visible[i]) return;\n    sx += x1 + x2;\n    sy += y1 + y2;\n    n += 2;\n  });\n  const cx = n ? sx / n : 12;\n  const cy = n ? sy / n : 12;\n  const eff = def.lines.map((seg, i): Seg => (visible[i] ? seg : [cx, cy, cx, cy]));\n  effCache.set(def, eff);\n  return eff;\n}\n\nfunction visibleOf(def: StrokeIconDef): boolean[] {\n  return [0, 1, 2].map((i) => !def.hidden?.[i]);\n}\n\ninterface Assignment {\n  perm: number[]; // source line i goes to target line perm[i]\n  flip: boolean[]; // swap that line's endpoints\n}\n\nconst PERMS = [\n  [0, 1, 2],\n  [0, 2, 1],\n  [1, 0, 2],\n  [1, 2, 0],\n  [2, 0, 1],\n  [2, 1, 0],\n];\n\n// Best endpoint matching: 6 permutations, and per line the cheaper of the 2 endpoint flips\n// (the 8 flip combinations reduce to this because each line's flip is independent).\nfunction bestAssignment(src: Seg[], dst: Seg[]): Assignment {\n  let best: Assignment = { perm: [0, 1, 2], flip: [false, false, false] };\n  let bestCost = Infinity;\n  for (const perm of PERMS) {\n    let total = 0;\n    const flip: boolean[] = [];\n    for (let i = 0; i < 3; i++) {\n      const [ax1, ay1, ax2, ay2] = src[i];\n      const [bx1, by1, bx2, by2] = dst[perm[i]];\n      const straight = (ax1 - bx1) ** 2 + (ay1 - by1) ** 2 + (ax2 - bx2) ** 2 + (ay2 - by2) ** 2;\n      const crossed = (ax1 - bx2) ** 2 + (ay1 - by2) ** 2 + (ax2 - bx1) ** 2 + (ay2 - by1) ** 2;\n      flip.push(crossed < straight);\n      total += Math.min(straight, crossed);\n    }\n    if (total < bestCost) {\n      bestCost = total;\n      best = { perm: [...perm], flip };\n    }\n  }\n  return best;\n}\n\n// Memoised per (from, to) pair of defs. Used when the icon has settled on `from`.\nconst matchCache = new WeakMap<StrokeIconDef, WeakMap<StrokeIconDef, Assignment>>();\n\nfunction canonicalMatch(from: StrokeIconDef, to: StrokeIconDef): Assignment {\n  let inner = matchCache.get(from);\n  if (!inner) {\n    inner = new WeakMap();\n    matchCache.set(from, inner);\n  }\n  const cached = inner.get(to);\n  if (cached) return cached;\n  const result = bestAssignment(effOf(from), effOf(to));\n  inner.set(to, result);\n  return result;\n}\n\n// Shortest signed rotation from `from` to `to` (degrees), in (-180, 180].\nfunction shortestDelta(from: number, to: number): number {\n  return ((((to - from) % 360) + 540) % 360) - 180;\n}\n\n// Attributes are written to 2 decimals (0.005 of a 24-unit box). Finer precision only adds settle time.\nconst fmt = (v: number) => String(Math.round(v * 100) / 100);\nconst COORDS = [\"x1\", \"y1\", \"x2\", \"y2\"] as const;\n\ninterface MorphEls {\n  group: SVGGElement;\n  lines: (SVGLineElement | null)[];\n}\n\n// Owns the imperative morph. React renders once; every frame writes attributes through refs.\nclass Morph {\n  private els: MorphEls | null = null;\n  private cur = new Float64Array(12);\n  private from = new Float64Array(12);\n  private to = new Float64Array(12);\n  private curAngle: number;\n  private fromAngle: number;\n  private toAngle: number;\n  private curScale = 1;\n  private fromScale = 1;\n  private toScale = 1;\n  private curNudge: [number, number];\n  private fromNudge: [number, number];\n  private toNudge: [number, number];\n  private slotLine = [0, 1, 2]; // settled: element s shows canonical line slotLine[s]\n  private targetLine = [0, 1, 2];\n  private visibleTo = [true, true, true];\n  private spring: SpringState = { x: 0, v: 0 };\n  private written: (string | undefined)[] = [];\n  private strokeWidth = 2;\n  private active = false;\n  private cfg = DEFAULT_SPRING;\n  target: StrokeIconDef;\n\n  constructor(def: StrokeIconDef) {\n    this.target = def;\n    effOf(def).forEach((seg, s) => this.to.set(seg, s * 4));\n    this.cur.set(this.to);\n    this.from.set(this.to);\n    this.curAngle = this.fromAngle = this.toAngle = def.rotation ?? 0;\n    this.curScale = this.fromScale = this.toScale = def.strokeScale ?? 1;\n    this.curNudge = this.fromNudge = this.toNudge = nudgeOf(def);\n  }\n\n  bind(els: MorphEls) {\n    this.els = els;\n    this.paint(1);\n  }\n\n  setConfig(cfg: { stiffness: number; damping: number; mass: number }) {\n    this.cfg = cfg;\n  }\n\n  setStrokeWidth(strokeWidth: number) {\n    this.strokeWidth = strokeWidth;\n    this.paint(this.active ? this.spring.x : 1);\n  }\n\n  retarget(next: StrokeIconDef, reduce: boolean) {\n    const prev = this.target;\n    const sameGroup = !!next.group && next.group === prev.group;\n    this.target = next;\n\n    if (!sameGroup) {\n      let assignment: Assignment;\n      if (this.active) {\n        // Mid-flight: match from the current lerped geometry, not from a settled def.\n        const current: Seg[] = [0, 1, 2].map((s) => [\n          this.cur[s * 4],\n          this.cur[s * 4 + 1],\n          this.cur[s * 4 + 2],\n          this.cur[s * 4 + 3],\n        ]);\n        assignment = bestAssignment(current, effOf(next));\n      } else {\n        assignment = canonicalMatch(prev, next);\n      }\n      const eff = effOf(next);\n      const visible = visibleOf(next);\n      for (let s = 0; s < 3; s++) {\n        const c = this.active ? s : this.slotLine[s];\n        const t = this.active ? assignment.perm[s] : assignment.perm[c];\n        const flip = this.active ? assignment.flip[s] : assignment.flip[c];\n        this.targetLine[s] = t;\n        const [x1, y1, x2, y2] = eff[t];\n        const seg: Seg = flip ? [x2, y2, x1, y1] : [x1, y1, x2, y2];\n        this.to.set(seg, s * 4);\n        this.visibleTo[s] = visible[t];\n      }\n      // A line that changes visibility fades over 120ms ease-out, or instantly when reduced.\n      this.applyVisibility(reduce);\n    }\n\n    this.fromAngle = this.curAngle;\n    this.fromScale = this.curScale;\n    this.fromNudge = [this.curNudge[0], this.curNudge[1]];\n    this.from.set(this.cur);\n    this.toAngle = this.curAngle + shortestDelta(this.curAngle, next.rotation ?? 0);\n    this.toScale = next.strokeScale ?? 1;\n    this.toNudge = nudgeOf(next);\n\n    if (reduce) {\n      this.settle();\n      return;\n    }\n    this.spring.x = 0; // keep the velocity so an interrupted move carries its momentum\n    this.active = true;\n  }\n\n  private applyVisibility(reduce: boolean) {\n    const els = this.els;\n    if (!els) return;\n    for (let s = 0; s < 3; s++) {\n      const line = els.lines[s];\n      if (!line) continue;\n      line.style.transition = reduce ? \"none\" : `opacity 120ms ${easeCss.out}`;\n      line.style.opacity = this.visibleTo[s] ? \"1\" : \"0\";\n    }\n  }\n\n  // Frame step for useVisibleLoop. Returns false when idle.\n  step(dt: number): boolean {\n    if (!this.active) return false;\n    stepSpring(this.spring, 1, this.cfg, dt);\n    if (Math.abs(this.spring.x - 1) < 5e-4 && Math.abs(this.spring.v) < 5e-3) {\n      this.settle();\n      return false;\n    }\n    this.paint(this.spring.x);\n    return true;\n  }\n\n  // Snap to the exact canonical geometry of the target and go idle.\n  private settle() {\n    const eff = effOf(this.target);\n    for (let s = 0; s < 3; s++) {\n      this.slotLine[s] = this.targetLine[s];\n      const seg = eff[this.targetLine[s]];\n      this.to.set(seg, s * 4);\n    }\n    this.from.set(this.to);\n    this.fromAngle = this.toAngle;\n    this.curAngle = this.toAngle;\n    this.fromScale = this.toScale;\n    this.curScale = this.toScale;\n    this.fromNudge = [this.toNudge[0], this.toNudge[1]];\n    this.curNudge = [this.toNudge[0], this.toNudge[1]];\n    this.active = false;\n    this.spring.x = 1;\n    this.spring.v = 0;\n    this.paint(1);\n  }\n\n  private paint(p: number) {\n    const els = this.els;\n    if (!els) return;\n    for (let s = 0; s < 3; s++) {\n      const line = els.lines[s];\n      for (let k = 0; k < 4; k++) {\n        const i = s * 4 + k;\n        const v = this.from[i] + (this.to[i] - this.from[i]) * p;\n        this.cur[i] = v;\n        const str = fmt(v);\n        if (line && this.written[i] !== str) {\n          line.setAttribute(COORDS[k], str);\n          this.written[i] = str;\n        }\n      }\n    }\n    this.curAngle = this.fromAngle + (this.toAngle - this.fromAngle) * p;\n    this.curScale = this.fromScale + (this.toScale - this.fromScale) * p;\n    const nx = this.fromNudge[0] + (this.toNudge[0] - this.fromNudge[0]) * p;\n    const ny = this.fromNudge[1] + (this.toNudge[1] - this.fromNudge[1]) * p;\n    this.curNudge = [nx, ny];\n    const transform = `rotate(${fmt(this.curAngle)} 12 12) translate(${fmt(nx)} ${fmt(ny)})`;\n    if (this.written[12] !== transform) {\n      els.group.setAttribute(\"transform\", transform);\n      this.written[12] = transform;\n    }\n    const sw = fmt(this.strokeWidth * this.curScale);\n    if (this.written[13] !== sw) {\n      els.group.setAttribute(\"stroke-width\", sw);\n      this.written[13] = sw;\n    }\n  }\n}\n\nexport function StrokeMorphIcon({\n  name,\n  size = 24,\n  strokeWidth = 2,\n  color = \"currentColor\",\n  spring,\n  spin,\n  label,\n  className,\n}: StrokeMorphIconProps) {\n  const reduceMotion = useReducedMotion() === true;\n  const def = resolveStrokeIcon(name);\n\n  // Initial markup only. After mount, attributes are owned by the Morph, so these\n  // constants never change and React never overwrites an in-flight frame.\n  const [initial] = useState(() => {\n    const eff = effOf(def);\n    const visible = visibleOf(def);\n    return {\n      segs: eff,\n      visible,\n      transform: `rotate(${def.rotation ?? 0} 12 12) translate(${nudgeOf(def).map(fmt).join(\" \")})`,\n      strokeWidth: fmt(strokeWidth * (def.strokeScale ?? 1)),\n    };\n  });\n  const [morph] = useState(() => new Morph(def));\n\n  const svgRef = useRef<SVGSVGElement>(null);\n  const groupRef = useRef<SVGGElement>(null);\n  const lineRefs = useRef<(SVGLineElement | null)[]>([null, null, null]);\n\n  // The <svg> stays the root (icon-with-label rules target `>svg`). The loop hook only reads\n  // the element's box and dataset, which SVG elements also have.\n  const loop = useVisibleLoop(svgRef as RefObject<HTMLElement | null>, (dt) => morph.step(dt));\n\n  useLayoutEffect(() => {\n    if (!groupRef.current) return;\n    morph.bind({ group: groupRef.current, lines: lineRefs.current });\n  }, [morph]);\n\n  useLayoutEffect(() => {\n    morph.setConfig(spring ?? DEFAULT_SPRING);\n  });\n\n  useLayoutEffect(() => {\n    if (morph.target === def) return;\n    morph.retarget(def, reduceMotion);\n    if (!reduceMotion) loop.wake();\n  }, [def, reduceMotion, morph, loop]);\n\n  useLayoutEffect(() => {\n    morph.setStrokeWidth(strokeWidth);\n  }, [morph, strokeWidth]);\n\n  const spinning = (spin ?? def.spin) === true;\n\n  return (\n    <svg\n      ref={svgRef}\n      width={size}\n      height={size}\n      viewBox=\"0 0 24 24\"\n      fill=\"none\"\n      stroke={color}\n      strokeLinecap=\"round\"\n      strokeLinejoin=\"round\"\n      role={label ? \"img\" : undefined}\n      aria-label={label}\n      aria-hidden={label ? undefined : true}\n      className={cn(\"shrink-0\", className)}\n    >\n      <g\n        className={spinning ? \"motion-safe:animate-spin\" : undefined}\n        style={{ transformBox: \"view-box\", transformOrigin: \"12px 12px\" }}\n      >\n        <g ref={groupRef} strokeWidth={initial.strokeWidth} transform={initial.transform}>\n          {initial.segs.map(([x1, y1, x2, y2], i) => (\n            <line\n              key={i}\n              ref={(el) => {\n                lineRefs.current[i] = el;\n              }}\n              x1={x1}\n              y1={y1}\n              x2={x2}\n              y2={y2}\n              style={{ opacity: initial.visible[i] ? 1 : 0 }}\n            />\n          ))}\n        </g>\n      </g>\n    </svg>\n  );\n}\n",
      "type": "registry:component",
      "target": "@components/bjork-ui/utilities/stroke-morph-icon.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/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/spring.ts",
      "content": "// Pure spring math, no React. Units: stiffness k, damping d (coefficient), mass m, time in seconds.\n\nexport interface SpringConfig {\n  stiffness: number;\n  damping: number;\n  mass: number;\n}\n\nexport interface SpringState {\n  x: number;\n  v: number;\n}\n\n// Damping ratio zeta = d / (2 * sqrt(k * m)).\nexport function dampingRatio(c: SpringConfig): number {\n  return c.damping / (2 * Math.sqrt(c.stiffness * c.mass));\n}\n\n// Exact solution of y'' = -(k/m) y - (d/m) y' for displacement y0 and velocity v0 at time t.\n// Returns displacement and velocity together so stepping can be exact, not integrated.\nfunction solveLinear(t: number, c: SpringConfig, y0: number, v0: number): { y: number; v: number } {\n  const w0 = Math.sqrt(c.stiffness / c.mass);\n  const z = dampingRatio(c);\n\n  if (Math.abs(z - 1) < 1e-9) {\n    // Critically damped.\n    const e = Math.exp(-w0 * t);\n    const b = v0 + w0 * y0;\n    return { y: e * (y0 + b * t), v: e * (b - w0 * (y0 + b * t)) };\n  }\n  if (z < 1) {\n    // Underdamped.\n    const wd = w0 * Math.sqrt(1 - z * z);\n    const a = z * w0;\n    const B = (v0 + a * y0) / wd;\n    const e = Math.exp(-a * t);\n    const cos = Math.cos(wd * t);\n    const sin = Math.sin(wd * t);\n    return {\n      y: e * (y0 * cos + B * sin),\n      v: e * (-a * (y0 * cos + B * sin) + wd * (-y0 * sin + B * cos)),\n    };\n  }\n  // Overdamped.\n  const s = w0 * Math.sqrt(z * z - 1);\n  const r1 = -z * w0 + s;\n  const r2 = -z * w0 - s;\n  const B = (v0 - r1 * y0) / (r2 - r1);\n  const A = y0 - B;\n  const e1 = Math.exp(r1 * t);\n  const e2 = Math.exp(r2 * t);\n  return { y: A * e1 + B * e2, v: A * r1 * e1 + B * r2 * e2 };\n}\n\n// Analytic position at time t for a spring from `from` to `to` with initial velocity v0.\nexport function springAt(t: number, c: SpringConfig, from = 0, to = 1, v0 = 0): number {\n  return to + solveLinear(t, c, from - to, v0).y;\n}\n\n// Overshoot as a fraction of the total travel. Zero when critically or overdamped.\nexport function overshoot(c: SpringConfig): number {\n  const z = dampingRatio(c);\n  if (z >= 1) return 0;\n  return Math.exp((-Math.PI * z) / Math.sqrt(1 - z * z));\n}\n\n// Seconds until the spring stays within epsilon of its target (fraction of travel).\n// Found by scanning backwards from a bound derived from the slowest decay rate.\nexport function settleTime(c: SpringConfig, epsilon = 0.001): number {\n  const w0 = Math.sqrt(c.stiffness / c.mass);\n  const z = dampingRatio(c);\n  const slowest = z < 1 ? z * w0 : (z - Math.sqrt(z * z - 1)) * w0;\n  const tEnd = (Math.log(1 / epsilon) / slowest) * 1.5 + 0.05;\n  const steps = 4000;\n  const dt = tEnd / steps;\n  const band = epsilon;\n  let last = 0;\n  for (let i = 0; i <= steps; i++) {\n    const t = i * dt;\n    if (Math.abs(springAt(t, c, 1, 0, 0)) >= band) last = t;\n  }\n  return last + dt;\n}\n\n// Advances the state by dt towards `target`. Uses the exact solution of the linear spring\n// (no integration), so it tracks springAt to rounding error at any step size and stays stable\n// for stiff configs where a fixed-step integrator drifts.\nexport function stepSpring(s: SpringState, target: number, c: SpringConfig, dt: number): void {\n  if (dt <= 0) return;\n  const r = solveLinear(dt, c, s.x - target, s.v);\n  s.x = target + r.y;\n  s.v = r.v;\n}\n",
      "type": "registry:component",
      "target": "@components/bjork-ui/_core/spring.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"
    }
  ],
  "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": "AI",
    "registryUrl": "https://ui.isaiahbjork.com/inline-ai-edit.json",
    "installCommand": "npx shadcn@latest add https://ui.isaiahbjork.com/inline-ai-edit.json"
  },
  "type": "registry:component"
}