{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"markdown-editor","type":"registry:component","title":"Markdown Editor","description":"Textarea Markdown editor with an undo-safe formatting toolbar and shortcuts, list continuation and Tab indenting, a floating selection toolbar, Write/Preview/Split tabs with a gliding indicator, and a built-in safe renderer that outputs React elements, never raw HTML.","author":"kara <ztzyrant@gmail.com>","dependencies":["clsx","lucide-react","motion","tailwind-merge"],"registryDependencies":[],"files":[{"path":"components/motion/markdown-editor.tsx","type":"registry:component","target":"@components/motion/markdown-editor.tsx","content":"\"use client\";\n// karaui.kamisama-v0.my.id/components/motion/markdown-editor\n\nimport {\n  Bold,\n  Code,\n  Columns2,\n  Eye,\n  Heading2,\n  Italic,\n  Link,\n  List,\n  ListOrdered,\n  ListTodo,\n  PenLine,\n  Quote,\n  Strikethrough,\n  type LucideIcon,\n} from \"lucide-react\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport {\n  useCallback,\n  useDeferredValue,\n  useEffect,\n  useId,\n  useLayoutEffect,\n  useMemo,\n  useRef,\n  useState,\n  type KeyboardEvent as ReactKeyboardEvent,\n  type ReactNode,\n} from \"react\";\nimport { EASE_OUT, SPRING_LAYOUT, SPRING_PRESS } from \"@/lib/ease\";\nimport { cn } from \"@/lib/utils\";\n\n/* -------------------------------------------------------------------------- */\n/*                                   Types                                    */\n/* -------------------------------------------------------------------------- */\n\nexport type MarkdownEditorMode = \"write\" | \"preview\" | \"split\";\n\nexport type MarkdownAction =\n  | \"bold\"\n  | \"italic\"\n  | \"strike\"\n  | \"code\"\n  | \"link\"\n  | \"heading\"\n  | \"quote\"\n  | \"bullet\"\n  | \"numbered\"\n  | \"checklist\";\n\nexport interface MarkdownEditorProps {\n  /** Controlled Markdown source. */\n  value?: string;\n  /** Initial source when uncontrolled. */\n  defaultValue?: string;\n  onChange?: (value: string) => void;\n  /** Controlled view mode. */\n  mode?: MarkdownEditorMode;\n  defaultMode?: MarkdownEditorMode;\n  onModeChange?: (mode: MarkdownEditorMode) => void;\n  placeholder?: string;\n  /** Textarea auto-grows between these heights (px), then scrolls. */\n  minHeight?: number;\n  maxHeight?: number;\n  /** Floating formatting toolbar that appears over a text selection. */\n  selectionToolbar?: boolean;\n  /** Word / character count footer. */\n  showFooter?: boolean;\n  disabled?: boolean;\n  readOnly?: boolean;\n  name?: string;\n  id?: string;\n  /** Accessible name for the textarea. */\n  label?: string;\n  className?: string;\n  textareaClassName?: string;\n  previewClassName?: string;\n}\n\n/* -------------------------------------------------------------------------- */\n/*                              Safe MD renderer                              */\n/* -------------------------------------------------------------------------- */\n\n/**\n * Only http(s), mailto and relative URLs survive. Browsers strip tabs/newlines\n * and leading control chars from URLs, so the scheme check runs on a cleaned\n * copy (`java\\tscript:` must not slip through).\n */\nexport function safeHref(raw: string): string | undefined {\n  const href = raw.trim();\n  if (!href) return undefined;\n  // biome-ignore lint/suspicious/noControlCharactersInRegex: stripping control chars is the point.\n  const cleaned = href.replace(/[\\u0000- \\u007f-\\u009f]/g, \"\");\n  const scheme = /^([a-z][a-z0-9+.-]*):/i.exec(cleaned);\n  if (scheme) {\n    const s = scheme[1].toLowerCase();\n    return s === \"http\" || s === \"https\" || s === \"mailto\" ? href : undefined;\n  }\n  // No scheme: relative path, query, fragment or protocol-relative (http/https).\n  return href;\n}\n\nconst ESCAPABLE = \"\\\\`*_{}[]()#+-.!~>|\";\n\nfunction findClosing(text: string, marker: string, from: number): number {\n  let j = text.indexOf(marker, from);\n  while (j !== -1) {\n    if (text[j - 1] === \"\\\\\") {\n      j = text.indexOf(marker, j + 1);\n      continue;\n    }\n    // A single `*`/`_` must not close on half of a `**`/`__` run.\n    if (marker.length === 1 && (text[j + 1] === marker || text[j - 1] === marker)) {\n      j = text.indexOf(marker, j + 2);\n      continue;\n    }\n    return j;\n  }\n  return -1;\n}\n\nfunction isWordChar(char: string | undefined) {\n  return char !== undefined && /[\\p{L}\\p{N}]/u.test(char);\n}\n\nfunction renderInline(text: string, keyPrefix: string): ReactNode[] {\n  const out: ReactNode[] = [];\n  let buffer = \"\";\n  let i = 0;\n  let k = 0;\n  const flush = () => {\n    if (buffer) {\n      out.push(buffer);\n      buffer = \"\";\n    }\n  };\n  const key = () => `${keyPrefix}-${k++}`;\n\n  while (i < text.length) {\n    const ch = text[i];\n\n    if (ch === \"\\\\\" && i + 1 < text.length && ESCAPABLE.includes(text[i + 1])) {\n      buffer += text[i + 1];\n      i += 2;\n      continue;\n    }\n\n    if (ch === \"`\") {\n      let run = 1;\n      while (text[i + run] === \"`\") run++;\n      const fence = \"`\".repeat(run);\n      const end = text.indexOf(fence, i + run);\n      if (end !== -1) {\n        flush();\n        out.push(\n          <code\n            key={key()}\n            className=\"rounded-md bg-muted px-1.5 py-0.5 font-mono text-[0.85em] text-foreground\"\n          >\n            {text.slice(i + run, end).replace(/^ (.+) $/, \"$1\")}\n          </code>,\n        );\n        i = end + run;\n        continue;\n      }\n    }\n\n    const two = text.slice(i, i + 2);\n    if (two === \"**\" || two === \"__\" || two === \"~~\") {\n      const end = text.indexOf(two, i + 2);\n      const inner = end === -1 ? \"\" : text.slice(i + 2, end);\n      if (end !== -1 && inner.trim() && !/^\\s/.test(inner)) {\n        flush();\n        const children = renderInline(inner, key());\n        out.push(\n          two === \"~~\" ? (\n            <del key={key()} className=\"text-muted-foreground\">\n              {children}\n            </del>\n          ) : (\n            <strong key={key()} className=\"font-semibold text-foreground\">\n              {children}\n            </strong>\n          ),\n        );\n        i = end + 2;\n        continue;\n      }\n    }\n\n    if ((ch === \"*\" || ch === \"_\") && text[i + 1] !== ch) {\n      // `snake_case_words` stay literal: `_` only opens at a word boundary.\n      const opens = ch === \"*\" || !isWordChar(text[i - 1]);\n      const end = opens ? findClosing(text, ch, i + 1) : -1;\n      const inner = end === -1 ? \"\" : text.slice(i + 1, end);\n      if (\n        end !== -1 &&\n        inner.trim() &&\n        !/^\\s/.test(inner) &&\n        (ch === \"*\" || !isWordChar(text[end + 1]))\n      ) {\n        flush();\n        out.push(<em key={key()}>{renderInline(inner, key())}</em>);\n        i = end + 1;\n        continue;\n      }\n    }\n\n    if (ch === \"[\") {\n      const close = findBracket(text, i);\n      if (close !== -1 && text[close + 1] === \"(\") {\n        const parenEnd = findParen(text, close + 1);\n        if (parenEnd !== -1) {\n          const label = text.slice(i + 1, close);\n          const target = text.slice(close + 2, parenEnd).trim().split(/\\s+/)[0] ?? \"\";\n          const href = safeHref(target);\n          flush();\n          const children = renderInline(label || target, key());\n          if (href) {\n            const external = /^(https?:)?\\/\\//i.test(href);\n            out.push(\n              <a\n                key={key()}\n                href={href}\n                className=\"font-medium text-primary underline decoration-primary/30 underline-offset-2 transition-colors hover:decoration-primary\"\n                {...(external ? { target: \"_blank\", rel: \"noopener noreferrer\" } : {})}\n              >\n                {children}\n              </a>,\n            );\n          } else {\n            // Unsafe scheme: keep the label, drop the link.\n            out.push(<span key={key()}>{children}</span>);\n          }\n          i = parenEnd + 1;\n          continue;\n        }\n      }\n    }\n\n    buffer += ch;\n    i++;\n  }\n  flush();\n  return out;\n}\n\n/** Matching `)` for the `(` at `open`, so URLs like `/wiki/A_(b)` stay whole. */\nfunction findParen(text: string, open: number) {\n  let depth = 0;\n  for (let j = open; j < text.length; j++) {\n    if (text[j] === \"\\\\\") {\n      j++;\n      continue;\n    }\n    if (text[j] === \"(\") depth++;\n    else if (text[j] === \")\") {\n      depth--;\n      if (depth === 0) return j;\n    }\n  }\n  return -1;\n}\n\nfunction findBracket(text: string, open: number) {\n  let depth = 0;\n  for (let j = open; j < text.length; j++) {\n    if (text[j] === \"\\\\\") {\n      j++;\n      continue;\n    }\n    if (text[j] === \"[\") depth++;\n    else if (text[j] === \"]\") {\n      depth--;\n      if (depth === 0) return j;\n    }\n  }\n  return -1;\n}\n\nfunction renderLines(lines: string[], keyPrefix: string): ReactNode[] {\n  const out: ReactNode[] = [];\n  for (let line = 0; line < lines.length; line++) {\n    // Lines are positional by nature: the key is the line slot, not data identity.\n    if (line > 0) out.push(<br key={`${keyPrefix}-br-${line}`} />);\n    out.push(...renderInline(lines[line], `${keyPrefix}-${line}`));\n  }\n  return out;\n}\n\nconst FENCE_RE = /^\\s{0,3}(```+|~~~+)\\s*([\\w+#.-]*)\\s*$/;\nconst HEADING_RE = /^\\s{0,3}(#{1,6})\\s+(.*?)(?:\\s+#+)?\\s*$/;\nconst HR_RE = /^\\s{0,3}([-*_])(\\s*\\1){2,}\\s*$/;\nconst QUOTE_RE = /^\\s{0,3}>\\s?/;\nconst LIST_ITEM_RE = /^(\\s*)([-*+]|\\d{1,9}[.)])\\s+(?:\\[([ xX])\\]\\s+)?(.*)$/;\n\ntype ListItem = {\n  ordered: boolean;\n  start: number;\n  checked: boolean | null;\n  text: string[];\n  children: ListItem[];\n  indent: number;\n};\n\nconst HEADING_CLASS = [\n  \"text-2xl font-semibold tracking-tight\",\n  \"text-xl font-semibold tracking-tight\",\n  \"text-lg font-semibold\",\n  \"text-base font-semibold\",\n  \"text-sm font-semibold\",\n  \"text-sm font-semibold text-muted-foreground\",\n];\n\nfunction renderList(items: ListItem[], key: string): ReactNode {\n  const ordered = items[0]?.ordered ?? false;\n  const isTaskList = items.every((item) => item.checked !== null);\n  const body = items.map((item, index) => {\n    const itemKey = `${key}-${index}`;\n    const content = (\n      <>\n        {renderLines(item.text, itemKey)}\n        {item.children.length > 0 ? renderList(item.children, `${itemKey}-sub`) : null}\n      </>\n    );\n    if (item.checked !== null) {\n      return (\n        <li key={itemKey} className=\"flex list-none items-start gap-2\">\n          <input\n            type=\"checkbox\"\n            checked={item.checked}\n            readOnly\n            disabled\n            aria-label={item.checked ? \"Completed\" : \"Not completed\"}\n            className=\"mt-1 h-3.5 w-3.5 shrink-0 accent-primary\"\n          />\n          <span className={cn(\"min-w-0\", item.checked && \"text-muted-foreground line-through\")}>\n            {content}\n          </span>\n        </li>\n      );\n    }\n    return (\n      <li key={itemKey} className=\"pl-1\">\n        {content}\n      </li>\n    );\n  });\n  const listClass = cn(\"my-2 space-y-1\", isTaskList ? \"pl-0\" : \"pl-5\", ordered ? \"list-decimal\" : \"list-disc\");\n  return ordered ? (\n    <ol key={key} start={items[0]?.start !== 1 ? items[0]?.start : undefined} className={listClass}>\n      {body}\n    </ol>\n  ) : (\n    <ul key={key} className={listClass}>\n      {body}\n    </ul>\n  );\n}\n\nfunction parseBlocks(lines: string[], keyPrefix: string): ReactNode[] {\n  const out: ReactNode[] = [];\n  let i = 0;\n  let b = 0;\n  const key = () => `${keyPrefix}-${b++}`;\n\n  while (i < lines.length) {\n    const line = lines[i];\n\n    if (!line.trim()) {\n      i++;\n      continue;\n    }\n\n    const fence = FENCE_RE.exec(line);\n    if (fence) {\n      const marker = fence[1];\n      const lang = fence[2];\n      const body: string[] = [];\n      i++;\n      while (i < lines.length && !lines[i].trimStart().startsWith(marker)) {\n        body.push(lines[i]);\n        i++;\n      }\n      i++; // closing fence (or end of input)\n      out.push(\n        <pre\n          key={key()}\n          data-language={lang || undefined}\n          className=\"my-3 overflow-x-auto rounded-xl border border-border bg-muted/60 p-3 font-mono text-[12.5px] leading-5 text-foreground [scrollbar-color:var(--border-strong)_transparent] [scrollbar-width:thin]\"\n        >\n          <code>{body.join(\"\\n\")}</code>\n        </pre>,\n      );\n      continue;\n    }\n\n    const heading = HEADING_RE.exec(line);\n    if (heading) {\n      const level = heading[1].length as 1 | 2 | 3 | 4 | 5 | 6;\n      const Tag = `h${level}` as const;\n      out.push(\n        <Tag key={key()} className={cn(\"mb-2 mt-4 first:mt-0 text-foreground\", HEADING_CLASS[level - 1])}>\n          {renderInline(heading[2], key())}\n        </Tag>,\n      );\n      i++;\n      continue;\n    }\n\n    if (HR_RE.test(line)) {\n      out.push(<hr key={key()} className=\"my-4 border-0 border-t border-border\" />);\n      i++;\n      continue;\n    }\n\n    if (QUOTE_RE.test(line)) {\n      const inner: string[] = [];\n      while (i < lines.length && QUOTE_RE.test(lines[i])) {\n        inner.push(lines[i].replace(QUOTE_RE, \"\"));\n        i++;\n      }\n      const quoteKey = key();\n      out.push(\n        <blockquote\n          key={quoteKey}\n          className=\"my-3 border-l-2 border-primary/40 pl-3 text-muted-foreground\"\n        >\n          {parseBlocks(inner, quoteKey)}\n        </blockquote>,\n      );\n      continue;\n    }\n\n    if (LIST_ITEM_RE.test(line)) {\n      const roots: ListItem[] = [];\n      const stack: ListItem[] = [];\n      while (i < lines.length) {\n        const current = lines[i];\n        const match = LIST_ITEM_RE.exec(current);\n        if (match) {\n          const indent = match[1].replace(/\\t/g, \"  \").length;\n          const marker = match[2];\n          const item: ListItem = {\n            ordered: /\\d/.test(marker),\n            start: Number.parseInt(marker, 10) || 1,\n            checked: match[3] === undefined ? null : match[3] !== \" \",\n            text: [match[4]],\n            children: [],\n            indent,\n          };\n          while (stack.length && stack[stack.length - 1].indent >= indent) stack.pop();\n          const parent = stack[stack.length - 1];\n          if (parent) parent.children.push(item);\n          else roots.push(item);\n          stack.push(item);\n          i++;\n          continue;\n        }\n        // Lazy continuation: an indented, non-blank line belongs to the last item.\n        if (current.trim() && /^\\s+/.test(current) && stack.length) {\n          stack[stack.length - 1].text.push(current.trim());\n          i++;\n          continue;\n        }\n        break;\n      }\n      // Split the root run whenever the list type flips (bullets → numbers).\n      let run: ListItem[] = [];\n      const flushRun = () => {\n        if (run.length) out.push(renderList(run, key()));\n        run = [];\n      };\n      for (const item of roots) {\n        if (run.length && run[0].ordered !== item.ordered) flushRun();\n        run.push(item);\n      }\n      flushRun();\n      continue;\n    }\n\n    const para: string[] = [];\n    while (\n      i < lines.length &&\n      lines[i].trim() &&\n      !FENCE_RE.test(lines[i]) &&\n      !HEADING_RE.test(lines[i]) &&\n      !HR_RE.test(lines[i]) &&\n      !QUOTE_RE.test(lines[i]) &&\n      !LIST_ITEM_RE.test(lines[i])\n    ) {\n      para.push(lines[i].trim());\n      i++;\n    }\n    const paraKey = key();\n    out.push(\n      <p key={paraKey} className=\"my-2 first:mt-0 last:mb-0\">\n        {renderLines(para, paraKey)}\n      </p>,\n    );\n  }\n  return out;\n}\n\n/** Render Markdown to React elements — no HTML strings, no dangerouslySetInnerHTML. */\nexport function renderMarkdown(source: string): ReactNode[] {\n  return parseBlocks(source.replace(/\\r\\n?/g, \"\\n\").split(\"\\n\"), \"md\");\n}\n\nexport function MarkdownPreview({\n  source,\n  className,\n  emptyLabel = \"Nothing to preview\",\n}: {\n  source: string;\n  className?: string;\n  emptyLabel?: ReactNode;\n}) {\n  const nodes = useMemo(() => renderMarkdown(source), [source]);\n  return (\n    <div className={cn(\"break-words text-sm leading-6 text-foreground/90\", className)}>\n      {nodes.length ? nodes : <p className=\"text-muted-foreground\">{emptyLabel}</p>}\n    </div>\n  );\n}\n\n/* -------------------------------------------------------------------------- */\n/*                               Editing engine                               */\n/* -------------------------------------------------------------------------- */\n\n/**\n * Replace a range so the browser's undo stack records it. `insertText` keeps\n * Ctrl+Z working; where it is unsupported (or refuses), `setRangeText` edits\n * the DOM and `commit` hands React the new value directly — a synthetic\n * input event is not reliable, since React's value tracker may swallow it.\n */\nfunction replaceRange(textarea: HTMLTextAreaElement, edit: Edit, commit: (value: string) => void) {\n  const { start, end, text, selectStart, selectEnd = selectStart } = edit;\n  const expected = textarea.value.slice(0, start) + text + textarea.value.slice(end);\n  textarea.focus();\n  textarea.setSelectionRange(start, end);\n  let applied = false;\n  try {\n    applied =\n      typeof document.execCommand === \"function\" &&\n      (text === \"\" && start !== end\n        ? document.execCommand(\"delete\", false)\n        : document.execCommand(\"insertText\", false, text));\n  } catch {\n    applied = false;\n  }\n  if (!applied || textarea.value !== expected) {\n    textarea.setRangeText(text, start, end, \"end\");\n    commit(expected);\n  }\n  textarea.setSelectionRange(selectStart, selectEnd);\n}\n\ntype Edit = { start: number; end: number; text: string; selectStart: number; selectEnd?: number };\n\nfunction lineBounds(value: string, start: number, end: number) {\n  const lineStart = value.lastIndexOf(\"\\n\", start - 1) + 1;\n  // A selection ending right after a newline should not pull in the next line.\n  const effectiveEnd = end > start && value[end - 1] === \"\\n\" ? end - 1 : end;\n  const nl = value.indexOf(\"\\n\", effectiveEnd);\n  return { lineStart, lineEnd: nl === -1 ? value.length : nl };\n}\n\nconst PLACEHOLDER: Record<\"bold\" | \"italic\" | \"strike\" | \"code\", string> = {\n  bold: \"bold text\",\n  italic: \"italic text\",\n  strike: \"struck text\",\n  code: \"code\",\n};\n\nconst WRAP_MARKER: Record<\"bold\" | \"italic\" | \"strike\" | \"code\", string> = {\n  bold: \"**\",\n  italic: \"_\",\n  strike: \"~~\",\n  code: \"`\",\n};\n\nfunction wrapEdit(\n  value: string,\n  start: number,\n  end: number,\n  kind: \"bold\" | \"italic\" | \"strike\" | \"code\",\n): Edit {\n  const selected = value.slice(start, end);\n\n  if (kind === \"code\" && selected.includes(\"\\n\")) {\n    const fenced = `\\`\\`\\`\\n${selected.replace(/\\n$/, \"\")}\\n\\`\\`\\``;\n    const before = start > 0 && value[start - 1] !== \"\\n\" ? \"\\n\" : \"\";\n    const text = `${before}${fenced}`;\n    return {\n      start,\n      end,\n      text,\n      selectStart: start + before.length + 4,\n      selectEnd: start + before.length + 4 + selected.replace(/\\n$/, \"\").length,\n    };\n  }\n\n  const m = WRAP_MARKER[kind];\n  // Unwrap: markers hug the selection from outside…\n  if (value.slice(start - m.length, start) === m && value.slice(end, end + m.length) === m) {\n    // …but `**bold**` must not read as italic `*` around `*bold*`.\n    const outerBefore = value[start - m.length - 1];\n    const outerAfter = value[end + m.length];\n    if (m.length > 1 || (outerBefore !== m && outerAfter !== m)) {\n      return {\n        start: start - m.length,\n        end: end + m.length,\n        text: selected,\n        selectStart: start - m.length,\n        selectEnd: end - m.length,\n      };\n    }\n  }\n  // …or sit inside it.\n  if (selected.length >= m.length * 2 + 1 && selected.startsWith(m) && selected.endsWith(m)) {\n    const inner = selected.slice(m.length, -m.length);\n    return { start, end, text: inner, selectStart: start, selectEnd: start + inner.length };\n  }\n\n  const body = selected || PLACEHOLDER[kind];\n  // Keep surrounding whitespace outside the markers (`** text**` won't parse).\n  const lead = /^\\s*/.exec(body)?.[0] ?? \"\";\n  const trail = /\\s*$/.exec(body)?.[0] ?? \"\";\n  const core = body.slice(lead.length, body.length - trail.length) || body;\n  const text = `${lead}${m}${core}${m}${trail}`;\n  const coreStart = start + lead.length + m.length;\n  return { start, end, text, selectStart: coreStart, selectEnd: coreStart + core.length };\n}\n\nfunction linkEdit(value: string, start: number, end: number): Edit {\n  const selected = value.slice(start, end);\n  if (/^(https?:\\/\\/|mailto:)\\S+$/i.test(selected.trim())) {\n    const text = `[link text](${selected.trim()})`;\n    return { start, end, text, selectStart: start + 1, selectEnd: start + 10 };\n  }\n  const label = selected || \"link text\";\n  const text = `[${label}](https://)`;\n  const urlStart = start + label.length + 3;\n  // With a label already chosen, jump to the URL; otherwise select the label.\n  return selected\n    ? { start, end, text, selectStart: urlStart, selectEnd: urlStart + 8 }\n    : { start, end, text, selectStart: start + 1, selectEnd: start + 1 + label.length };\n}\n\nconst LIST_PREFIX_RE = /^(\\s*)([-*+]\\s+\\[[ xX]\\]\\s+|[-*+]\\s+|\\d{1,9}[.)]\\s+)/;\nconst TASK_RE = /^\\s*[-*+]\\s+\\[[ xX]\\]\\s+/;\nconst BULLET_RE = /^\\s*[-*+]\\s+(?!\\[[ xX]\\]\\s)/;\nconst ORDERED_RE = /^\\s*\\d{1,9}[.)]\\s+/;\nconst HEADING_PREFIX_RE = /^#{1,6}\\s+/;\nconst QUOTE_PREFIX_RE = /^>\\s?/;\n\nfunction linePrefixEdit(\n  value: string,\n  start: number,\n  end: number,\n  kind: \"heading\" | \"quote\" | \"bullet\" | \"numbered\" | \"checklist\",\n): Edit {\n  const { lineStart, lineEnd } = lineBounds(value, start, end);\n  const lines = value.slice(lineStart, lineEnd).split(\"\\n\");\n  const content = lines.filter((line) => line.trim());\n  const targets = content.length ? content : lines;\n\n  const has: Record<typeof kind, RegExp> = {\n    heading: HEADING_PREFIX_RE,\n    quote: QUOTE_PREFIX_RE,\n    bullet: BULLET_RE,\n    numbered: ORDERED_RE,\n    checklist: TASK_RE,\n  };\n  const allHave = targets.every((line) => has[kind].test(line));\n\n  let n = 0;\n  const next = lines.map((line) => {\n    if (!line.trim() && content.length) return line;\n    if (kind === \"heading\") {\n      return allHave ? line.replace(HEADING_PREFIX_RE, \"\") : `## ${line.replace(HEADING_PREFIX_RE, \"\")}`;\n    }\n    if (kind === \"quote\") {\n      return allHave ? line.replace(QUOTE_PREFIX_RE, \"\") : `> ${line}`;\n    }\n    const indent = /^\\s*/.exec(line)?.[0] ?? \"\";\n    const rest = line.replace(LIST_PREFIX_RE, \"\").replace(/^\\s*/, \"\");\n    if (allHave) return `${indent}${rest}`;\n    n++;\n    const marker = kind === \"bullet\" ? \"- \" : kind === \"numbered\" ? `${n}. ` : \"- [ ] \";\n    return `${indent}${marker}${rest}`;\n  });\n\n  const text = next.join(\"\\n\");\n  if (start === end && lines.length === 1) {\n    const caret = Math.max(lineStart, start + (text.length - lines[0].length));\n    return { start: lineStart, end: lineEnd, text, selectStart: caret, selectEnd: caret };\n  }\n  return { start: lineStart, end: lineEnd, text, selectStart: lineStart, selectEnd: lineStart + text.length };\n}\n\nfunction computeEdit(value: string, start: number, end: number, action: MarkdownAction): Edit {\n  switch (action) {\n    case \"bold\":\n    case \"italic\":\n    case \"strike\":\n    case \"code\":\n      return wrapEdit(value, start, end, action);\n    case \"link\":\n      return linkEdit(value, start, end);\n    default:\n      return linePrefixEdit(value, start, end, action);\n  }\n}\n\n/** Enter inside a list/quote: continue it, or end it on an empty item. */\nfunction continuationEdit(value: string, caret: number): Edit | null {\n  const lineStart = value.lastIndexOf(\"\\n\", caret - 1) + 1;\n  const line = value.slice(lineStart, caret);\n  const list = /^(\\s*)(?:([-*+])\\s+(\\[[ xX]\\]\\s+)?|(\\d{1,9})([.)])\\s+)/.exec(line);\n  const quote = /^(\\s*>\\s?)/.exec(line);\n  const match = list ?? quote;\n  if (!match) return null;\n\n  if (!line.slice(match[0].length).trim()) {\n    // Empty item: drop the marker and leave a plain line.\n    const nlAfter = value.indexOf(\"\\n\", caret);\n    const rest = value.slice(caret, nlAfter === -1 ? value.length : nlAfter);\n    if (rest.trim()) return null;\n    return { start: lineStart, end: caret, text: \"\", selectStart: lineStart };\n  }\n\n  let marker: string;\n  if (list) {\n    const indent = list[1];\n    if (list[4]) marker = `${indent}${Number.parseInt(list[4], 10) + 1}${list[5]} `;\n    else marker = `${indent}${list[2]} ${list[3] ? \"[ ] \" : \"\"}`;\n  } else {\n    marker = match[1];\n  }\n  const text = `\\n${marker}`;\n  return { start: caret, end: caret, text, selectStart: caret + text.length };\n}\n\n/** Tab / Shift+Tab on list lines: indent or outdent by two spaces. */\nfunction indentEdit(value: string, start: number, end: number, outdent: boolean): Edit | null {\n  const { lineStart, lineEnd } = lineBounds(value, start, end);\n  const lines = value.slice(lineStart, lineEnd).split(\"\\n\");\n  if (!lines.some((line) => LIST_PREFIX_RE.test(line))) return null;\n  let firstDelta = 0;\n  const next = lines.map((line, index) => {\n    if (!LIST_PREFIX_RE.test(line)) return line;\n    if (outdent) {\n      const remove = Math.min(2, (/^ */.exec(line)?.[0] ?? \"\").length);\n      if (index === 0) firstDelta = -remove;\n      return line.slice(remove);\n    }\n    if (index === 0) firstDelta = 2;\n    return `  ${line}`;\n  });\n  const text = next.join(\"\\n\");\n  const delta = text.length - (lineEnd - lineStart);\n  return {\n    start: lineStart,\n    end: lineEnd,\n    text,\n    selectStart: Math.max(lineStart, start + firstDelta),\n    selectEnd: Math.max(lineStart, end + (start === end ? firstDelta : delta)),\n  };\n}\n\n/* -------------------------------------------------------------------------- */\n/*                           Caret geometry (mirror)                          */\n/* -------------------------------------------------------------------------- */\n\nconst MIRRORED_PROPS = [\n  \"boxSizing\",\n  \"width\",\n  \"borderTopWidth\",\n  \"borderRightWidth\",\n  \"borderBottomWidth\",\n  \"borderLeftWidth\",\n  \"paddingTop\",\n  \"paddingRight\",\n  \"paddingBottom\",\n  \"paddingLeft\",\n  \"fontStyle\",\n  \"fontVariant\",\n  \"fontWeight\",\n  \"fontStretch\",\n  \"fontSize\",\n  \"fontFamily\",\n  \"lineHeight\",\n  \"letterSpacing\",\n  \"textTransform\",\n  \"textIndent\",\n  \"wordSpacing\",\n  \"tabSize\",\n] as const;\n\n/** Pixel position of a caret index inside a textarea (relative to its border box). */\nfunction caretCoordinates(textarea: HTMLTextAreaElement, index: number) {\n  const mirror = document.createElement(\"div\");\n  const style = window.getComputedStyle(textarea);\n  for (const prop of MIRRORED_PROPS) {\n    mirror.style[prop] = style[prop];\n  }\n  mirror.style.position = \"absolute\";\n  mirror.style.visibility = \"hidden\";\n  mirror.style.top = \"0\";\n  mirror.style.left = \"-9999px\";\n  mirror.style.whiteSpace = \"pre-wrap\";\n  mirror.style.overflowWrap = \"break-word\";\n  mirror.style.overflow = \"hidden\";\n  mirror.textContent = textarea.value.slice(0, index);\n  const marker = document.createElement(\"span\");\n  marker.textContent = textarea.value.slice(index) || \".\";\n  mirror.appendChild(marker);\n  document.body.appendChild(mirror);\n  const top = marker.offsetTop - textarea.scrollTop;\n  const left = marker.offsetLeft - textarea.scrollLeft;\n  const lineHeight = Number.parseFloat(style.lineHeight) || Number.parseFloat(style.fontSize) * 1.5;\n  document.body.removeChild(mirror);\n  return { top, left, lineHeight };\n}\n\n/* -------------------------------------------------------------------------- */\n/*                                 Component                                  */\n/* -------------------------------------------------------------------------- */\n\ntype ToolDef = { action: MarkdownAction; label: string; icon: LucideIcon; shortcut?: string };\n\nconst TOOLS: ToolDef[][] = [\n  [\n    { action: \"bold\", label: \"Bold\", icon: Bold, shortcut: \"B\" },\n    { action: \"italic\", label: \"Italic\", icon: Italic, shortcut: \"I\" },\n    { action: \"strike\", label: \"Strikethrough\", icon: Strikethrough, shortcut: \"Shift+X\" },\n    { action: \"code\", label: \"Code\", icon: Code, shortcut: \"E\" },\n    { action: \"link\", label: \"Link\", icon: Link, shortcut: \"K\" },\n  ],\n  [\n    { action: \"heading\", label: \"Heading\", icon: Heading2 },\n    { action: \"quote\", label: \"Quote\", icon: Quote },\n  ],\n  [\n    { action: \"bullet\", label: \"Bulleted list\", icon: List, shortcut: \"Shift+8\" },\n    { action: \"numbered\", label: \"Numbered list\", icon: ListOrdered, shortcut: \"Shift+7\" },\n    { action: \"checklist\", label: \"Checklist\", icon: ListTodo },\n  ],\n];\n\nconst FLOATING_TOOLS: MarkdownAction[] = [\"bold\", \"italic\", \"strike\", \"code\", \"link\"];\nconst FLOATING_WIDTH = 168; // 5 × 28px buttons + gaps/padding; fixed so clamping is exact.\nconst FLOATING_HEIGHT = 36;\n\nconst MODES: { value: MarkdownEditorMode; label: string; icon: LucideIcon }[] = [\n  { value: \"write\", label: \"Write\", icon: PenLine },\n  { value: \"preview\", label: \"Preview\", icon: Eye },\n  { value: \"split\", label: \"Split\", icon: Columns2 },\n];\n\nfunction countWords(text: string) {\n  const trimmed = text.trim();\n  return trimmed ? trimmed.split(/\\s+/).length : 0;\n}\n\nexport function MarkdownEditor({\n  value,\n  defaultValue = \"\",\n  onChange,\n  mode,\n  defaultMode = \"write\",\n  onModeChange,\n  placeholder = \"Write in Markdown…\",\n  minHeight = 160,\n  maxHeight = 360,\n  selectionToolbar = true,\n  showFooter = true,\n  disabled = false,\n  readOnly = false,\n  name,\n  id,\n  label = \"Markdown editor\",\n  className,\n  textareaClassName,\n  previewClassName,\n}: MarkdownEditorProps) {\n  const reduce = useReducedMotion();\n  const uid = useId();\n  const textareaId = id ?? `${uid}-input`;\n  const previewId = `${uid}-preview`;\n  const textareaRef = useRef<HTMLTextAreaElement>(null);\n  const surfaceRef = useRef<HTMLDivElement>(null);\n\n  const [internal, setInternal] = useState(defaultValue);\n  const controlled = value !== undefined;\n  const source = controlled ? value : internal;\n\n  const [internalMode, setInternalMode] = useState<MarkdownEditorMode>(defaultMode);\n  const currentMode = mode ?? internalMode;\n  const setMode = useCallback(\n    (next: MarkdownEditorMode) => {\n      if (mode === undefined) setInternalMode(next);\n      onModeChange?.(next);\n    },\n    [mode, onModeChange],\n  );\n\n  const [modKey, setModKey] = useState(\"Ctrl\");\n  useEffect(() => {\n    // Read the platform after mount so server and client markup match.\n    if (/Mac|iPhone|iPad/.test(navigator.platform || navigator.userAgent)) setModKey(\"⌘\");\n  }, []);\n\n  const [floating, setFloating] = useState<{ x: number; y: number } | null>(null);\n\n  const editable = !disabled && !readOnly;\n  const showWrite = currentMode !== \"preview\";\n  const showPreview = currentMode !== \"write\";\n  const deferredSource = useDeferredValue(source);\n\n  const commit = useCallback(\n    (next: string) => {\n      if (!controlled) setInternal(next);\n      onChange?.(next);\n    },\n    [controlled, onChange],\n  );\n\n  // Auto-grow: height is the purpose here, so this writes layout directly\n  // (no animation) and only once per value change.\n  // biome-ignore lint/correctness/useExhaustiveDependencies: re-measure whenever the text or view changes.\n  useLayoutEffect(() => {\n    const textarea = textareaRef.current;\n    if (!textarea) return;\n    textarea.style.height = \"auto\";\n    const full = textarea.scrollHeight;\n    const next = Math.min(Math.max(full, minHeight), maxHeight);\n    textarea.style.height = `${next}px`;\n    textarea.style.overflowY = full > maxHeight ? \"auto\" : \"hidden\";\n  }, [source, minHeight, maxHeight, currentMode]);\n\n  const updateFloating = useCallback(() => {\n    const textarea = textareaRef.current;\n    const surface = surfaceRef.current;\n    if (!selectionToolbar || !textarea || !surface || !editable) return setFloating(null);\n    const { selectionStart, selectionEnd } = textarea;\n    if (\n      document.activeElement !== textarea ||\n      selectionStart === selectionEnd ||\n      !textarea.value.slice(selectionStart, selectionEnd).trim()\n    ) {\n      return setFloating(null);\n    }\n    const a = caretCoordinates(textarea, selectionStart);\n    const b = caretCoordinates(textarea, selectionEnd);\n    const sameLine = Math.abs(a.top - b.top) < 2;\n    const anchorX = sameLine ? (a.left + b.left) / 2 : a.left;\n    const offsetX = textarea.offsetLeft;\n    const offsetY = textarea.offsetTop;\n    const width = surface.clientWidth;\n    const x = Math.min(\n      Math.max(offsetX + anchorX - FLOATING_WIDTH / 2, 8),\n      Math.max(width - FLOATING_WIDTH - 8, 8),\n    );\n    let y = offsetY + a.top - FLOATING_HEIGHT - 6;\n    if (y < 4) y = offsetY + b.top + b.lineHeight + 6;\n    y = Math.min(Math.max(y, 4), offsetY + textarea.clientHeight - 4);\n    setFloating({ x, y });\n  }, [editable, selectionToolbar]);\n\n  useEffect(() => {\n    if (!selectionToolbar) return;\n    const onSelectionChange = () => {\n      if (document.activeElement === textareaRef.current) updateFloating();\n    };\n    document.addEventListener(\"selectionchange\", onSelectionChange);\n    return () => document.removeEventListener(\"selectionchange\", onSelectionChange);\n  }, [selectionToolbar, updateFloating]);\n\n  const apply = useCallback(\n    (action: MarkdownAction) => {\n      const textarea = textareaRef.current;\n      if (!textarea || !editable) return;\n      const edit = computeEdit(\n        textarea.value,\n        textarea.selectionStart,\n        textarea.selectionEnd,\n        action,\n      );\n      replaceRange(textarea, edit, commit);\n      updateFloating();\n    },\n    [commit, editable, updateFloating],\n  );\n\n  const onKeyDown = (event: ReactKeyboardEvent<HTMLTextAreaElement>) => {\n    if (!editable || event.nativeEvent.isComposing) return;\n    const textarea = event.currentTarget;\n    const mod = event.metaKey || event.ctrlKey;\n    const { selectionStart: start, selectionEnd: end } = textarea;\n\n    if (mod && !event.altKey) {\n      const key = event.key.toLowerCase();\n      let action: MarkdownAction | null = null;\n      if (!event.shiftKey && key === \"b\") action = \"bold\";\n      else if (!event.shiftKey && key === \"i\") action = \"italic\";\n      else if (!event.shiftKey && key === \"k\") action = \"link\";\n      else if (!event.shiftKey && key === \"e\") action = \"code\";\n      else if (event.shiftKey && key === \"x\") action = \"strike\";\n      // `code` keeps Shift+7/8 layout-independent of what Shift produces.\n      else if (event.shiftKey && event.code === \"Digit7\") action = \"numbered\";\n      else if (event.shiftKey && event.code === \"Digit8\") action = \"bullet\";\n      if (action) {\n        event.preventDefault();\n        apply(action);\n      }\n      return;\n    }\n\n    if (event.key === \"Tab\" && !mod && !event.altKey) {\n      // Only list lines capture Tab; elsewhere Tab keeps moving focus.\n      const edit = indentEdit(textarea.value, start, end, event.shiftKey);\n      if (edit) {\n        event.preventDefault();\n        replaceRange(textarea, edit, commit);\n      }\n      return;\n    }\n\n    if (event.key === \"Enter\" && !event.shiftKey && !mod && !event.altKey && start === end) {\n      const edit = continuationEdit(textarea.value, start);\n      if (edit) {\n        event.preventDefault();\n        replaceRange(textarea, edit, commit);\n      }\n      return;\n    }\n\n    if (event.key === \"Escape\" && floating) {\n      setFloating(null);\n    }\n  };\n\n  const onTabKeyDown = (event: ReactKeyboardEvent<HTMLButtonElement>, index: number) => {\n    let next = index;\n    if (event.key === \"ArrowRight\") next = (index + 1) % MODES.length;\n    else if (event.key === \"ArrowLeft\") next = (index - 1 + MODES.length) % MODES.length;\n    else if (event.key === \"Home\") next = 0;\n    else if (event.key === \"End\") next = MODES.length - 1;\n    else return;\n    event.preventDefault();\n    setMode(MODES[next].value);\n    const buttons = event.currentTarget.parentElement?.parentElement?.querySelectorAll<HTMLButtonElement>(\n      \"[role=tab]\",\n    );\n    buttons?.[next]?.focus();\n  };\n\n  const words = useMemo(() => countWords(source), [source]);\n  const shortcutLabel = (tool: ToolDef) =>\n    tool.shortcut ? `${tool.label} (${modKey}+${tool.shortcut})` : tool.label;\n  const tapScale = reduce ? undefined : { scale: 0.88 };\n\n  return (\n    <div\n      className={cn(\n        \"flex w-full flex-col overflow-hidden rounded-2xl border border-border bg-card text-foreground shadow-sm\",\n        disabled && \"opacity-60\",\n        className,\n      )}\n    >\n      {/* Header: view tabs + formatting toolbar */}\n      <div className=\"flex flex-wrap items-center justify-between gap-2 border-b border-border px-2 py-1.5\">\n        <motion.div layoutRoot role=\"tablist\" aria-label=\"Editor view\" className=\"flex items-center gap-0.5\">\n          {MODES.map((item, index) => {\n            const active = currentMode === item.value;\n            const Icon = item.icon;\n            return (\n              <div key={item.value} className=\"relative\">\n                {active ? (\n                  <motion.span\n                    layoutId={`${uid}-mode-indicator`}\n                    transition={reduce ? { duration: 0 } : SPRING_LAYOUT}\n                    className=\"absolute inset-0 rounded-lg bg-muted\"\n                  />\n                ) : null}\n                <button\n                  type=\"button\"\n                  role=\"tab\"\n                  aria-selected={active}\n                  aria-controls={item.value === \"preview\" ? previewId : textareaId}\n                  tabIndex={active ? 0 : -1}\n                  onClick={() => setMode(item.value)}\n                  onKeyDown={(event) => onTabKeyDown(event, index)}\n                  className={cn(\n                    \"relative z-10 inline-flex h-8 items-center gap-1.5 rounded-lg px-2.5 text-xs font-medium outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring\",\n                    active ? \"text-foreground\" : \"text-muted-foreground hover:text-foreground\",\n                  )}\n                >\n                  <Icon className=\"h-3.5 w-3.5\" aria-hidden />\n                  {item.label}\n                </button>\n              </div>\n            );\n          })}\n        </motion.div>\n\n        <div\n          role=\"toolbar\"\n          aria-label=\"Formatting\"\n          aria-controls={textareaId}\n          className=\"flex items-center gap-0.5 overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden\"\n        >\n          {TOOLS.map((group, groupIndex) => (\n            <div key={group[0].action} className=\"flex items-center gap-0.5\">\n              {groupIndex > 0 ? <span aria-hidden className=\"mx-1 h-4 w-px bg-border\" /> : null}\n              {group.map((tool) => {\n                const Icon = tool.icon;\n                return (\n                  <motion.button\n                    key={tool.action}\n                    type=\"button\"\n                    aria-label={tool.label}\n                    title={shortcutLabel(tool)}\n                    disabled={!editable || !showWrite}\n                    // Keep the textarea selection: don't let the button take focus on press.\n                    onMouseDown={(event) => event.preventDefault()}\n                    onClick={() => apply(tool.action)}\n                    whileTap={tapScale}\n                    transition={SPRING_PRESS}\n                    className=\"inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-lg text-muted-foreground outline-none transition-colors hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-40\"\n                  >\n                    <Icon className=\"h-4 w-4\" aria-hidden />\n                  </motion.button>\n                );\n              })}\n            </div>\n          ))}\n        </div>\n      </div>\n\n      {/* Body */}\n      <div\n        className={cn(\n          \"grid min-w-0\",\n          currentMode === \"split\" && \"sm:grid-cols-2 sm:divide-x sm:divide-border\",\n        )}\n      >\n        <div ref={surfaceRef} className={cn(\"relative min-w-0\", !showWrite && \"hidden\")}>\n          <textarea\n            ref={textareaRef}\n            id={textareaId}\n            name={name}\n            aria-label={label}\n            value={source}\n            placeholder={placeholder}\n            disabled={disabled}\n            readOnly={readOnly}\n            spellCheck\n            onChange={(event) => commit(event.target.value)}\n            onKeyDown={onKeyDown}\n            onMouseUp={updateFloating}\n            onBlur={() => setFloating(null)}\n            onScroll={() => setFloating(null)}\n            style={{ minHeight, maxHeight }}\n            className={cn(\n              \"block w-full resize-none bg-transparent px-4 py-3 font-mono text-[13px] leading-6 text-foreground outline-none placeholder:text-muted-foreground/70\",\n              \"[scrollbar-color:var(--border-strong)_transparent] [scrollbar-width:thin]\",\n              textareaClassName,\n            )}\n          />\n\n          <AnimatePresence>\n            {floating && showWrite ? (\n              <motion.div\n                key=\"selection-toolbar\"\n                role=\"toolbar\"\n                aria-label=\"Selection formatting\"\n                initial={\n                  reduce\n                    ? { opacity: 0, x: floating.x, y: floating.y }\n                    : { opacity: 0, x: floating.x, y: floating.y + 6, scale: 0.92, filter: \"blur(4px)\" }\n                }\n                animate={\n                  reduce\n                    ? { opacity: 1, x: floating.x, y: floating.y }\n                    : { opacity: 1, x: floating.x, y: floating.y, scale: 1, filter: \"blur(0px)\" }\n                }\n                exit={\n                  reduce\n                    ? { opacity: 0, transition: { duration: 0.12 } }\n                    : {\n                        opacity: 0,\n                        scale: 0.94,\n                        filter: \"blur(4px)\",\n                        transition: { duration: 0.12, ease: EASE_OUT },\n                      }\n                }\n                transition={reduce ? { duration: 0.15 } : SPRING_LAYOUT}\n                style={{ width: FLOATING_WIDTH, height: FLOATING_HEIGHT }}\n                className=\"absolute left-0 top-0 z-20 flex items-center justify-center gap-0.5 rounded-xl border border-border bg-card/95 p-1 shadow-lg backdrop-blur-md\"\n              >\n                {FLOATING_TOOLS.map((action) => {\n                  const tool = TOOLS[0].find((item) => item.action === action);\n                  if (!tool) return null;\n                  const Icon = tool.icon;\n                  return (\n                    <motion.button\n                      key={action}\n                      type=\"button\"\n                      // Mirrors the main toolbar, which stays the keyboard path.\n                      tabIndex={-1}\n                      aria-label={tool.label}\n                      title={shortcutLabel(tool)}\n                      onMouseDown={(event) => event.preventDefault()}\n                      onClick={() => apply(action)}\n                      whileTap={tapScale}\n                      transition={SPRING_PRESS}\n                      className=\"inline-flex h-7 w-7 items-center justify-center rounded-lg text-muted-foreground transition-colors hover:bg-muted hover:text-foreground\"\n                    >\n                      <Icon className=\"h-3.5 w-3.5\" aria-hidden />\n                    </motion.button>\n                  );\n                })}\n              </motion.div>\n            ) : null}\n          </AnimatePresence>\n        </div>\n\n        {showPreview ? (\n          <motion.div\n            key={currentMode}\n            id={previewId}\n            role=\"tabpanel\"\n            aria-label=\"Preview\"\n            tabIndex={0}\n            initial={reduce ? { opacity: 0 } : { opacity: 0, y: 4 }}\n            animate={reduce ? { opacity: 1 } : { opacity: 1, y: 0 }}\n            transition={{ duration: 0.2, ease: EASE_OUT }}\n            style={{ minHeight, maxHeight }}\n            className={cn(\n              \"min-w-0 overflow-y-auto px-4 py-3 outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring\",\n              \"[scrollbar-color:var(--border-strong)_transparent] [scrollbar-width:thin]\",\n              currentMode === \"split\" && \"border-t border-border sm:border-t-0\",\n              previewClassName,\n            )}\n          >\n            <MarkdownPreview source={currentMode === \"split\" ? deferredSource : source} />\n          </motion.div>\n        ) : null}\n      </div>\n\n      {showFooter ? (\n        <div className=\"flex items-center justify-between gap-3 border-t border-border px-4 py-2 text-[11px] text-muted-foreground\">\n          <span>Markdown supported</span>\n          <span className=\"tabular-nums\">\n            {words} {words === 1 ? \"word\" : \"words\"} · {source.length}{\" \"}\n            {source.length === 1 ? \"character\" : \"characters\"}\n          </span>\n        </div>\n      ) : null}\n    </div>\n  );\n}\n"},{"path":"lib/ease.ts","type":"registry:lib","target":"@lib/ease.ts","content":"// Shared motion tokens. Easing curves mirror the CSS custom properties in\n// globals.css; springs are the canonical physics used across components.\n// Strong custom variants — defaults like `ease-in`/`ease-out` feel weak.\n\nexport const EASE_OUT = [0.16, 1, 0.3, 1] as const;\nexport const EASE_IN_OUT = [0.77, 0, 0.175, 1] as const;\nexport const EASE_DRAWER = [0.32, 0.72, 0, 1] as const;\n\n/** CSS string form of EASE_OUT for inline style transitions. */\nexport const EASE_OUT_CSS = \"cubic-bezier(0.16, 1, 0.3, 1)\";\n\n/** Press feedback on buttons and other tappable surfaces. */\nexport const SPRING_PRESS = {\n  type: \"spring\",\n  stiffness: 500,\n  damping: 30,\n  mass: 0.6,\n} as const;\n\n/** Content swaps — label/icon slots trading places inside a control. */\nexport const SPRING_SWAP = {\n  type: \"spring\",\n  stiffness: 460,\n  damping: 30,\n  mass: 0.55,\n} as const;\n\n/** Overlay panel entrances — modals and sheets summoned by pointer. */\nexport const SPRING_PANEL = {\n  type: \"spring\",\n  stiffness: 420,\n  damping: 40,\n  mass: 0.5,\n} as const;\n\n/** Shared-layout glides — pills, indicators and panels morphing between positions. */\nexport const SPRING_LAYOUT = {\n  type: \"spring\",\n  stiffness: 360,\n  damping: 32,\n  mass: 0.6,\n} as const;\n\n/** Cursor-follow physics for decorative mouse tracking (magnetic, tilt, dock). */\nexport const SPRING_MOUSE = {\n  stiffness: 200,\n  damping: 15,\n  mass: 0.3,\n} as const;\n\n/** Dragged handles and fills (sliders) — critically damped `useSpring` config,\n * so the value follows the pointer butterily and never rebounds off an end. */\nexport const SPRING_GLIDE = {\n  stiffness: 700,\n  damping: 50,\n  mass: 0.5,\n} as const;\n"},{"path":"lib/utils.ts","type":"registry:lib","target":"@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"}]}