{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "footnoted-answer",
  "type": "registry:component",
  "title": "Footnoted Answer",
  "description": "An answer whose footnotes really point somewhere: hover a number and its sentence lights up in the source's colour, click it and the quoted passage unfolds with the matching words marked.",
  "categories": [
    "component",
    "Conversation"
  ],
  "dependencies": [
    "lucide-react",
    "motion"
  ],
  "registryDependencies": [
    "utils"
  ],
  "files": [
    {
      "path": "registry/devl/footnoted-answer.tsx",
      "type": "registry:component",
      "content": "\"use client\";\n\nimport { QuoteIcon, XIcon } from \"lucide-react\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { Fragment, useMemo, useRef, useState } from \"react\";\nimport { cn } from \"@/lib/utils\";\n\nexport interface FootnotedSentence {\n  text: string;\n  cites?: number[];\n  breakAfter?: boolean;\n}\n\nexport interface FootnoteSource {\n  title: string;\n  domain: string;\n  passage: string;\n}\n\nexport interface FootnotedAnswerProps {\n  sentences?: FootnotedSentence[];\n  sources?: FootnoteSource[];\n  className?: string;\n}\n\nconst defaultSources: FootnoteSource[] = [\n  {\n    title: \"Field notes on urban beekeeping\",\n    domain: \"hivecraft.org\",\n    passage:\n      \"Rooftop colonies in dense cities often forage across three kilometres or more, and the variety of street trees and balcony plants gives urban honey a surprisingly complex flavour.\",\n  },\n  {\n    title: \"Pollinator decline in managed landscapes\",\n    domain: \"meadowlab.edu\",\n    passage:\n      \"Adding managed hives can increase competition with wild bees for limited flowers, especially where green space is scarce and native pollinators are already under pressure.\",\n  },\n  {\n    title: \"Planting for pollinators: a city guide\",\n    domain: \"greenblock.city\",\n    passage:\n      \"Councils seeing the strongest results pair new hives with native flowers along verges and parks, so that forage grows faster than the number of colonies.\",\n  },\n];\n\nconst defaultSentences: FootnotedSentence[] = [\n  { text: \"City bees tend to do well: rooftop colonies forage over kilometres and urban honey has a complex flavour.\", cites: [1] },\n  {\n    text: \"The catch is competition, since extra hives can crowd out wild bees where flowers are scarce.\",\n    cites: [2],\n    breakAfter: true,\n  },\n  { text: \"The best programs plant native flowers first and add colonies slowly.\", cites: [3, 2] },\n];\n\nconst spring = { type: \"spring\", stiffness: 420, damping: 34 } as const;\nconst easeOut = [0.23, 1, 0.32, 1] as const;\n\nconst tone = [\n  { dot: \"bg-orange-500\", hl: \"from-orange-500/20 to-orange-500/20\", ring: \"ring-orange-500/60\", mark: \"from-orange-500/25 to-orange-500/25\" },\n  { dot: \"bg-sky-500\", hl: \"from-sky-500/20 to-sky-500/20\", ring: \"ring-sky-500/60\", mark: \"from-sky-500/25 to-sky-500/25\" },\n  { dot: \"bg-emerald-500\", hl: \"from-emerald-500/20 to-emerald-500/20\", ring: \"ring-emerald-500/60\", mark: \"from-emerald-500/25 to-emerald-500/25\" },\n  { dot: \"bg-violet-500\", hl: \"from-violet-500/20 to-violet-500/20\", ring: \"ring-violet-500/60\", mark: \"from-violet-500/25 to-violet-500/25\" },\n  { dot: \"bg-rose-500\", hl: \"from-rose-500/20 to-rose-500/20\", ring: \"ring-rose-500/60\", mark: \"from-rose-500/25 to-rose-500/25\" },\n] as const;\n\nconst toneOf = (n: number) => tone[(n - 1) % tone.length] ?? tone[0];\n\nconst stop = new Set(\n  \"that this with from have they their there where which while about into over than then them these those when what will would could should been being also very just more most some such only other many much each your ours\".split(\n    \" \",\n  ),\n);\n\nfunction stem(word: string) {\n  return word\n    .toLowerCase()\n    .replace(/[^a-z0-9]/g, \"\")\n    .replace(/(ing|ed|es|s)$/, \"\");\n}\n\nfunction keywords(texts: string[]) {\n  const set = new Set<string>();\n  for (const t of texts)\n    for (const w of t.split(/\\s+/)) {\n      const clean = w.toLowerCase().replace(/[^a-z0-9]/g, \"\");\n      if (clean.length >= 4 && !stop.has(clean)) set.add(stem(clean));\n    }\n  return set;\n}\n\ntype Focus = { source: number; sentence?: number } | null;\n\nexport function FootnotedAnswer({ sentences = defaultSentences, sources = defaultSources, className }: FootnotedAnswerProps) {\n  const reduce = useReducedMotion();\n  const [hover, setHover] = useState<Focus>(null);\n  const [open, setOpen] = useState<Focus>(null);\n  const focus = hover ?? open;\n  const lastSource = useRef(1);\n  if (focus) lastSource.current = focus.source;\n\n  const lit = (i: number) => {\n    if (!focus) return false;\n    const s = sentences[i];\n    if (focus.sentence !== undefined) return focus.sentence === i;\n    return Boolean(s?.cites?.includes(focus.source));\n  };\n\n  const toggle = (next: { source: number; sentence?: number }) =>\n    setOpen((cur) => (cur && cur.source === next.source && cur.sentence === next.sentence ? null : next));\n\n  const paragraphs = useMemo(() => {\n    const out: number[][] = [[]];\n    sentences.forEach((s, i) => {\n      out[out.length - 1]?.push(i);\n      if (s.breakAfter && i < sentences.length - 1) out.push([]);\n    });\n    return out;\n  }, [sentences]);\n\n  const openSource = open ? sources[open.source - 1] : undefined;\n  const markWords = useMemo(() => {\n    if (!open) return new Set<string>();\n    const texts =\n      open.sentence !== undefined\n        ? [sentences[open.sentence]?.text ?? \"\"]\n        : sentences.filter((s) => s.cites?.includes(open.source)).map((s) => s.text);\n    return keywords(texts);\n  }, [open, sentences]);\n\n  return (\n    <article\n      className={cn(\"w-full max-w-lg rounded-2xl border bg-card p-4 text-sm shadow-xs/5\", className)}\n      onPointerLeave={() => setHover(null)}\n    >\n      <div className=\"leading-7\">\n        {paragraphs.map((ids) => (\n          <p className=\"mb-3 text-foreground/90 last:mb-0\" key={ids.join(\"-\")}>\n            {ids.map((i) => {\n              const s = sentences[i];\n              if (!s) return null;\n              const on = lit(i);\n              const t = toneOf(lastSource.current);\n              const hl = cn(\n                \"rounded-sm bg-linear-to-r bg-no-repeat transition-[background-size,color] duration-300 ease-out motion-reduce:transition-none\",\n                t.hl,\n                on ? \"bg-size-[100%_100%] text-foreground\" : \"bg-size-[0%_100%]\",\n              );\n              const cut = s.text.lastIndexOf(\" \");\n              const head = cut > 0 ? s.text.slice(0, cut + 1) : \"\";\n              const tail = cut > 0 ? s.text.slice(cut + 1) : s.text;\n              return (\n                <Fragment key={s.text}>\n                  {head && <span className={cn(hl, \"rounded-e-none\")}>{head}</span>}\n                  <span className=\"whitespace-nowrap\">\n                    <span className={cn(hl, head && \"rounded-s-none\")}>{tail}</span>\n                  {s.cites?.map((n) => {\n                    const active = focus?.source === n && (focus.sentence === undefined || focus.sentence === i);\n                    const pressed = open?.source === n && open.sentence === i;\n                    return (\n                      <button\n                        key={n}\n                        aria-expanded={pressed}\n                        aria-label={`Show source ${n}: ${sources[n - 1]?.title ?? \"\"}`}\n                        className={cn(\n                          \"relative ms-1 inline-flex h-4 min-w-4 -translate-y-px cursor-pointer items-center justify-center rounded-full px-1 align-middle font-medium font-mono text-[10px] tabular-nums outline-none transition-[background-color,color,scale] duration-150 ease-out after:absolute after:-inset-x-1.5 after:-inset-y-2 focus-visible:ring-2 focus-visible:ring-ring active:scale-[0.94]\",\n                          active ? cn(toneOf(n).dot, \"text-white\") : \"bg-muted text-muted-foreground\",\n                        )}\n                        onBlur={() => setHover(null)}\n                        onClick={() => toggle({ source: n, sentence: i })}\n                        onFocus={() => setHover({ source: n, sentence: i })}\n                        onPointerEnter={(e) => e.pointerType === \"mouse\" && setHover({ source: n, sentence: i })}\n                        onPointerLeave={() => setHover(null)}\n                        type=\"button\"\n                      >\n                        {n}\n                      </button>\n                    );\n                  })}\n                  </span>{\" \"}\n                </Fragment>\n              );\n            })}\n          </p>\n        ))}\n      </div>\n\n      <AnimatePresence initial={false}>\n        {open && openSource && (\n          <motion.div\n            animate={{ height: \"auto\", opacity: 1 }}\n            className=\"overflow-hidden\"\n            exit={{ height: 0, opacity: 0 }}\n            initial={{ height: 0, opacity: 0 }}\n            transition={reduce ? { duration: 0 } : spring}\n          >\n            <AnimatePresence initial={false} mode=\"popLayout\">\n              <motion.blockquote\n                animate={{ opacity: 1, y: 0 }}\n                className=\"relative mt-3 rounded-xl bg-muted/60 p-3 ps-5\"\n                exit={{ opacity: 0, y: -4, transition: { duration: 0.12 } }}\n                initial={reduce ? { opacity: 0 } : { opacity: 0, y: 4 }}\n                key={`${open.source}-${open.sentence ?? \"all\"}`}\n                transition={{ duration: 0.22, ease: easeOut }}\n              >\n                <span aria-hidden className={cn(\"absolute inset-y-3 start-2 w-0.5 rounded-full\", toneOf(open.source).dot)} />\n                <div className=\"mb-1.5 flex items-center gap-1.5 text-muted-foreground text-xs\">\n                  <QuoteIcon className=\"size-3\" />\n                  <span className=\"truncate\">{openSource.domain}</span>\n                  <button\n                    aria-label=\"Close passage\"\n                    className=\"relative ms-auto grid size-5 place-items-center rounded-md outline-none transition-[background-color,color,scale] duration-150 ease-out after:absolute after:-inset-1.5 hover:bg-accent hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring active:scale-[0.94]\"\n                    onClick={() => setOpen(null)}\n                    type=\"button\"\n                  >\n                    <XIcon className=\"size-3\" />\n                  </button>\n                </div>\n                <p className=\"text-foreground/80 text-xs leading-5\">\n                  <Passage mark={toneOf(open.source).mark} text={openSource.passage} words={markWords} />\n                </p>\n              </motion.blockquote>\n            </AnimatePresence>\n          </motion.div>\n        )}\n      </AnimatePresence>\n\n      <div className=\"mt-4 grid grid-cols-1 gap-2 border-t pt-3 sm:grid-cols-3\">\n        {sources.map((src, idx) => {\n          const n = idx + 1;\n          const active = focus?.source === n;\n          return (\n            <motion.button\n              animate={{ y: active && !reduce ? -2 : 0 }}\n              aria-pressed={open?.source === n && open.sentence === undefined}\n              className={cn(\n                \"flex min-w-0 cursor-pointer flex-col gap-1 rounded-xl border bg-background p-2.5 text-start outline-none ring-0 transition-[box-shadow,background-color,scale] duration-150 ease-out hover:bg-accent/60 focus-visible:ring-2 focus-visible:ring-ring active:scale-[0.98]\",\n                active && cn(\"ring-2\", toneOf(n).ring),\n              )}\n              key={src.domain}\n              onBlur={() => setHover(null)}\n              onClick={() => toggle({ source: n })}\n              onFocus={() => setHover({ source: n })}\n              onPointerEnter={(e) => e.pointerType === \"mouse\" && setHover({ source: n })}\n              onPointerLeave={() => setHover(null)}\n              transition={spring}\n              type=\"button\"\n            >\n              <span className=\"flex items-center gap-1.5 text-muted-foreground text-xs\">\n                <span className={cn(\"size-2 shrink-0 rounded-full\", toneOf(n).dot)} />\n                <span className=\"truncate\">{src.domain}</span>\n                <span className=\"ms-auto font-mono tabular-nums\">{n}</span>\n              </span>\n              <span className=\"line-clamp-2 text-foreground text-xs leading-4\">{src.title}</span>\n            </motion.button>\n          );\n        })}\n      </div>\n    </article>\n  );\n}\n\nfunction Passage({ text, words, mark }: { text: string; words: Set<string>; mark: string }) {\n  const reduce = useReducedMotion();\n  let hit = 0;\n  return (\n    <>\n      {text.split(/(\\s+)/).map((part, i) => {\n        if (/^\\s+$/.test(part) || !words.has(stem(part))) return <Fragment key={`${i}-${part}`}>{part}</Fragment>;\n        const delay = 0.1 + Math.min(hit++, 6) * 0.04;\n        const [, word = part, trail = \"\"] = /^(.*?)([.,;:!?]*)$/.exec(part) ?? [];\n        return (\n          <Fragment key={`${i}-${part}`}>\n          <motion.mark\n            animate={{ backgroundSize: \"100% 100%\" }}\n            className={cn(\"rounded-sm bg-transparent bg-linear-to-r bg-no-repeat px-0.5 text-foreground\", mark)}\n            initial={reduce ? false : { backgroundSize: \"0% 100%\" }}\n            transition={{ duration: 0.3, delay, ease: \"easeOut\" }}\n          >\n            {word}\n          </motion.mark>\n          {trail}\n          </Fragment>\n        );\n      })}\n    </>\n  );\n}\n"
    }
  ]
}