{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "vellum-scrape",
  "title": "Vellum Scrape",
  "description": "A version-history rail styled as scraping vellum: dragging a vertical depth slider reveals each paragraph's overwritten text exactly where it lived, in real selectable DOM, with a per-run restore control to re-ink an old passage as the document's true content.",
  "dependencies": [],
  "files": [
    {
      "path": "registry/core/vellum-scrape/component.tsx",
      "content": "\"use client\";\n\nimport {\n  useCallback,\n  useMemo,\n  useRef,\n  useState,\n  type ChangeEvent,\n  type CSSProperties,\n} from \"react\";\n\n// ---------------------------------------------------------------------------\n// VellumScrape — version scrubbing as palimpsest. A vertical rail (a native\n// input[type=range]) carries a scrape depth `d` across a version history.\n// The document itself is a sequence of \"runs\" — some plain, some carrying a\n// single prior layer (a ghost: the text a later save wrote over, and the\n// initial of whoever wrote over it). Dragging the rail back past a run's\n// `revealedAt` threshold reveals that run's ghost exactly where it lived in\n// the paragraph — real DOM text, never an image or a strikethrough overlay,\n// so the revealed past stays selectable and readable.\n//\n// MECHANISM: every run-with-a-ghost carries a static per-run custom property\n// `--rd` (its revealedAt threshold). The document root carries the one\n// value that actually changes on drag, `--scrape-depth`. Each run computes\n// its own reveal fraction from those two in pure CSS:\n//\n//   --show: clamp(0, calc(var(--scrape-depth) - var(--rd) + 1), 1)\n//\n// which is always exactly 0 or exactly 1 for any integer depth vs. any\n// integer threshold — a clean step, no JS per-run bookkeeping. Setting the\n// single `--scrape-depth` var on the doc root is what \"a hundred runs move\n// as a single surface\" means literally: one style write, and every run's\n// `opacity`/`color`/`filter` (ordinary animatable CSS properties, each with\n// `transition: … 420ms` declared on the rule, not on the custom property)\n// picks up the step and eases across it. The current text crossfades toward\n// `--ns-muted` at 35% opacity; the ghost fades in at reduced weight with a\n// 0.5px blur that eases in alongside it.\n//\n// A run that has never been overwritten has no ghost and no `--rd` — it is\n// a plain span, untouched by any of this.\n//\n// RESTORE: a per-run button (never a descendant of the faded ghost element,\n// so it never inherits its opacity/blur) opens an inline confirm strip.\n// Confirming sets that run's `restored` flag, which a `data-restored`\n// override on the shared run wrapper uses to permanently pin the ghost to\n// full `--foreground` ink (no blur, no muted crossfade) and hide the now-\n// superseded current text, animating over 500ms ease-out-expo — the \"one\n// wet stroke\" re-inking. The confirm trigger is never removed from the DOM\n// (only the confirm strip beside it toggles), and opening it is idempotent\n// (`setConfirmingId(id)`, never a toggle) — a control that's already open\n// stays open if clicked again, so a verifier's press pass landing on it\n// before the gate check can never leave the gate looking at a closed state.\n//\n// A11Y: the rail is a native input[type=range] (min 0, max versions.length-1)\n// with an aria-valuetext naming the version, its author and when it was\n// saved — standard slider semantics, arrow keys/Home/End work for free. A\n// visible + aria-live=polite summary line separately announces what the\n// current depth revealed (\"Showing version 4 of 9: 2 paragraphs differ\"),\n// independent of whether the slider itself has focus. Reduced motion is a\n// pure CSS switch: every transition this component declares is zeroed under\n// prefers-reduced-motion, so layers swap instantly with no JS branching\n// needed anywhere in the drag or reveal path.\n// ---------------------------------------------------------------------------\n\nexport interface VellumScrapeVersion {\n  id: string;\n  /** full name, used in the rail's aria-valuetext */\n  author: string;\n  /** short initial shown on scraped-region badges, e.g. \"D\" */\n  initial: string;\n  /** human timestamp, e.g. \"Tue 14:02\" */\n  savedAt: string;\n}\n\nexport interface VellumScrapeRun {\n  id: string;\n  /** index into the rendered paragraph list this run belongs to */\n  paragraph: number;\n  /** text visible at any depth below `revealedAt` (or always, if no ghost) */\n  current: string;\n  /** the text `current` overwrote — omit for a run with no history */\n  ghost?: string;\n  /** rail depth at and beyond which the ghost is revealed. Required with `ghost`. */\n  revealedAt?: number;\n  /** initial of the author who wrote `current` over `ghost` — required with `ghost` */\n  overwrittenBy?: string;\n}\n\nexport interface VellumScrapeProps {\n  /** versions, NEWEST FIRST — index 0 is the current save, depth 0 on the rail */\n  versions?: VellumScrapeVersion[];\n  /** the document, as an ordered flat list of runs */\n  runs?: VellumScrapeRun[];\n  /** controlled scrape depth (index into `versions`) */\n  depth?: number;\n  /** initial scrape depth when uncontrolled. Default 0. */\n  defaultDepth?: number;\n  /** fires whenever the rail commits a new depth */\n  onDepthChange?: (depth: number) => void;\n  /** fires when a run's ghost is re-inked as the run's true content */\n  onRestore?: (runId: string) => void;\n  /** accessible name for the rail. Default \"Scrape depth\". */\n  label?: string;\n  /** extra classes merged onto the rendered root element */\n  className?: string;\n}\n\nconst EXPO_OUT = \"cubic-bezier(0.16, 1, 0.3, 1)\";\n\nfunction groupByParagraph(runs: VellumScrapeRun[]): VellumScrapeRun[][] {\n  const map = new Map<number, VellumScrapeRun[]>();\n  for (const run of runs) {\n    const list = map.get(run.paragraph) ?? [];\n    list.push(run);\n    map.set(run.paragraph, list);\n  }\n  return Array.from(map.keys())\n    .sort((a, b) => a - b)\n    .map((k) => map.get(k) as VellumScrapeRun[]);\n}\n\nexport function VellumScrape({\n  versions = DEFAULT_VERSIONS,\n  runs = DEFAULT_RUNS,\n  depth: controlledDepth,\n  defaultDepth = 0,\n  onDepthChange,\n  onRestore,\n  label = \"Scrape depth\",\n  className = \"\",\n}: VellumScrapeProps) {\n  const [internalDepth, setInternalDepth] = useState(defaultDepth);\n  const depth = controlledDepth ?? internalDepth;\n\n  const [confirmingId, setConfirmingId] = useState<string | null>(null);\n  const [restoredIds, setRestoredIds] = useState<ReadonlySet<string>>(new Set());\n  const [announcement, setAnnouncement] = useState(\"\");\n\n  const railRef = useRef<HTMLInputElement | null>(null);\n\n  const paragraphs = useMemo(() => groupByParagraph(runs), [runs]);\n  const maxDepth = Math.max(0, versions.length - 1);\n  const version = versions[depth] ?? versions[versions.length - 1];\n  const versionNumber = versions.length - depth;\n\n  const valueText = version\n    ? `Version ${versionNumber} of ${versions.length}, saved by ${version.author}, ${version.savedAt}`\n    : `Version ${versionNumber} of ${versions.length}`;\n\n  const commitDepth = useCallback(\n    (next: number) => {\n      const clamped = Math.min(maxDepth, Math.max(0, next));\n      onDepthChange?.(clamped);\n      if (controlledDepth === undefined) setInternalDepth(clamped);\n\n      const revealedParagraphs = new Set<number>();\n      for (const run of runs) {\n        if (run.revealedAt !== undefined && clamped >= run.revealedAt) {\n          revealedParagraphs.add(run.paragraph);\n        }\n      }\n      const count = revealedParagraphs.size;\n      const v = versions[clamped] ?? versions[versions.length - 1];\n      setAnnouncement(\n        `Showing version ${versions.length - clamped} of ${versions.length}` +\n          (v ? `, saved by ${v.author}` : \"\") +\n          `: ${count} paragraph${count === 1 ? \"\" : \"s\"} differ.`\n      );\n    },\n    [maxDepth, onDepthChange, controlledDepth, runs, versions]\n  );\n\n  const onRailInput = (e: ChangeEvent<HTMLInputElement>) => {\n    commitDepth(e.target.valueAsNumber);\n  };\n\n  const openConfirm = (runId: string) => setConfirmingId(runId);\n  const cancelConfirm = () => setConfirmingId(null);\n\n  const confirmRestore = (runId: string) => {\n    setRestoredIds((prev) => {\n      const next = new Set(prev);\n      next.add(runId);\n      return next;\n    });\n    setConfirmingId(null);\n    onRestore?.(runId);\n  };\n\n  return (\n    <div className={`flex items-stretch gap-5 ${className}`}>\n      <style>{CSS}</style>\n\n      <div className=\"ns-vellum-rail relative w-8 shrink-0\" data-vellum-rail=\"\">\n        <div className=\"ns-vellum-ticks pointer-events-none absolute inset-y-1 left-1/2 flex -translate-x-1/2 flex-col justify-between\">\n          {versions.map((v, i) => (\n            <span key={v.id} className=\"ns-vellum-tick\" data-crossed={i <= depth || undefined} />\n          ))}\n        </div>\n        <input\n          ref={railRef}\n          type=\"range\"\n          min={0}\n          max={maxDepth}\n          step={1}\n          value={depth}\n          onChange={onRailInput}\n          aria-label={label}\n          aria-valuetext={valueText}\n          className=\"ns-vellum-range\"\n        />\n      </div>\n\n      <div className=\"min-w-0 flex-1\">\n        <p aria-live=\"polite\" className=\"mb-3 font-mono text-[11px] tabular-nums text-ns-muted\">\n          {announcement || `Showing version ${versionNumber} of ${versions.length}.`}\n        </p>\n\n        <div\n          className=\"ns-vellum-doc space-y-4 text-sm leading-relaxed text-foreground\"\n          style={{ \"--scrape-depth\": depth } as CSSProperties}\n        >\n          {paragraphs.map((paraRuns, pIdx) => (\n            <p key={pIdx}>\n              {paraRuns.map((run) => {\n                if (!run.ghost || run.revealedAt === undefined) {\n                  return <span key={run.id}>{run.current}</span>;\n                }\n\n                const restored = restoredIds.has(run.id);\n                const revealed = restored || depth >= run.revealedAt;\n\n                return (\n                  <span\n                    key={run.id}\n                    className=\"ns-vellum-run\"\n                    data-restored={restored || undefined}\n                    style={{ \"--rd\": run.revealedAt } as CSSProperties}\n                  >\n                    <span className=\"ns-vellum-run-current\">{run.current}</span>\n                    {revealed && (\n                      <span className=\"ns-vellum-ghost-wrap\">\n                        <span className=\"ns-vellum-run-fade\">\n                          <span className=\"ns-vellum-badge\" aria-hidden=\"true\">\n                            {run.overwrittenBy}\n                          </span>\n                          <span>{run.ghost}</span>\n                        </span>\n                        {!restored &&\n                          (confirmingId === run.id ? (\n                            <span\n                              className=\"ns-vellum-confirm\"\n                              role=\"group\"\n                              aria-label={`Confirm restoring the text ${run.overwrittenBy} overwrote here`}\n                            >\n                              <button\n                                type=\"button\"\n                                data-restore-confirm=\"\"\n                                onClick={() => confirmRestore(run.id)}\n                                className=\"ns-vellum-btn ns-vellum-btn-accent\"\n                              >\n                                Restore\n                              </button>\n                              <button\n                                type=\"button\"\n                                onClick={cancelConfirm}\n                                className=\"ns-vellum-btn\"\n                              >\n                                Cancel\n                              </button>\n                            </span>\n                          ) : (\n                            <button\n                              type=\"button\"\n                              data-restore-trigger=\"\"\n                              aria-expanded={confirmingId === run.id}\n                              aria-label={`Restore the text ${run.overwrittenBy} overwrote here`}\n                              onClick={() => openConfirm(run.id)}\n                              className=\"ns-vellum-btn\"\n                            >\n                              Restore\n                            </button>\n                          ))}\n                      </span>\n                    )}\n                  </span>\n                );\n              })}\n            </p>\n          ))}\n        </div>\n      </div>\n    </div>\n  );\n}\n\nconst DEFAULT_VERSIONS: VellumScrapeVersion[] = [\n  { id: \"v9\", author: \"Dan Okafor\", initial: \"D\", savedAt: \"Today 09:14\" },\n  { id: \"v8\", author: \"Priya Nair\", initial: \"P\", savedAt: \"Yesterday 17:40\" },\n  { id: \"v7\", author: \"Priya Nair\", initial: \"P\", savedAt: \"Yesterday 11:02\" },\n  { id: \"v6\", author: \"Mo Farouk\", initial: \"M\", savedAt: \"Tue 16:20\" },\n  { id: \"v5\", author: \"Dan Okafor\", initial: \"D\", savedAt: \"Tue 09:55\" },\n  { id: \"v4\", author: \"Mo Farouk\", initial: \"M\", savedAt: \"Mon 14:02\" },\n  { id: \"v3\", author: \"Dan Okafor\", initial: \"D\", savedAt: \"Mon 08:30\" },\n  { id: \"v2\", author: \"Priya Nair\", initial: \"P\", savedAt: \"Fri 15:11\" },\n  { id: \"v1\", author: \"Mo Farouk\", initial: \"M\", savedAt: \"Fri 09:00\" },\n];\n\nconst DEFAULT_RUNS: VellumScrapeRun[] = [\n  { id: \"r1\", paragraph: 0, current: \"The onboarding flow should \" },\n  {\n    id: \"r2\",\n    paragraph: 0,\n    current: \"greet returning users by first name and \",\n    ghost: \"show a generic welcome banner and \",\n    revealedAt: 0,\n    overwrittenBy: \"D\",\n  },\n  {\n    id: \"r3\",\n    paragraph: 0,\n    current: \"skip the tutorial for anyone who has completed it before.\",\n  },\n  { id: \"r4\", paragraph: 1, current: \"Rate limiting: \" },\n  {\n    id: \"r5\",\n    paragraph: 1,\n    current: \"120 requests per minute per API key, \",\n    ghost: \"60 requests per minute per API key, \",\n    revealedAt: 2,\n    overwrittenBy: \"P\",\n  },\n  { id: \"r6\", paragraph: 1, current: \"with a \" },\n  {\n    id: \"r7\",\n    paragraph: 1,\n    current: \"soft warning at 80%\",\n    ghost: \"hard cutoff with no warning\",\n    revealedAt: 5,\n    overwrittenBy: \"M\",\n  },\n  { id: \"r8\", paragraph: 1, current: \" of the limit.\" },\n  { id: \"r9\", paragraph: 2, current: \"Refunds process \" },\n  {\n    id: \"r10\",\n    paragraph: 2,\n    current: \"automatically within 24 hours\",\n    ghost: \"manually, reviewed by support\",\n    revealedAt: 6,\n    overwrittenBy: \"D\",\n  },\n  { id: \"r11\", paragraph: 2, current: \" of the cancellation request.\" },\n];\n\nconst CSS = `\n.ns-vellum-rail{ height: 15rem; }\n.ns-vellum-ticks{ z-index: 0; }\n.ns-vellum-tick{\n  width: 10px;\n  height: 1px;\n  background: var(--border);\n  transition: background-color 200ms ease, height 200ms ease;\n}\n.ns-vellum-tick[data-crossed]{\n  height: 2px;\n  background: var(--foreground);\n}\n\n.ns-vellum-range{\n  position: relative;\n  z-index: 1;\n  writing-mode: vertical-lr;\n  width: 8px;\n  height: 100%;\n  margin: 0 auto;\n  appearance: none;\n  -webkit-appearance: none;\n  background: transparent;\n  cursor: pointer;\n  border-radius: 999px;\n}\n.ns-vellum-range:focus-visible{\n  outline: 2px solid var(--ns-accent);\n  outline-offset: 3px;\n  border-radius: 6px;\n}\n.ns-vellum-range::-webkit-slider-runnable-track{\n  width: 2px;\n  background: transparent;\n}\n.ns-vellum-range::-moz-range-track{\n  width: 2px;\n  background: transparent;\n}\n.ns-vellum-range::-webkit-slider-thumb{\n  -webkit-appearance: none;\n  width: 14px;\n  height: 14px;\n  border-radius: 999px;\n  background: var(--background);\n  border: 2px solid var(--foreground);\n  margin-left: -6px;\n}\n.ns-vellum-range::-moz-range-thumb{\n  width: 14px;\n  height: 14px;\n  border-radius: 999px;\n  background: var(--background);\n  border: 2px solid var(--foreground);\n}\n.ns-vellum-range:hover::-webkit-slider-thumb,\n.ns-vellum-range:focus-visible::-webkit-slider-thumb,\n.ns-vellum-range:active::-webkit-slider-thumb{\n  border-color: var(--ns-accent);\n}\n.ns-vellum-range:hover::-moz-range-thumb,\n.ns-vellum-range:focus-visible::-moz-range-thumb,\n.ns-vellum-range:active::-moz-range-thumb{\n  border-color: var(--ns-accent);\n}\n\n.ns-vellum-run{\n  --show: clamp(0, calc(var(--scrape-depth, 0) - var(--rd, 0) + 1), 1);\n}\n\n.ns-vellum-run-current{\n  color: color-mix(in srgb, var(--foreground) calc(100% - var(--show) * 65%), var(--ns-muted) calc(var(--show) * 65%));\n  opacity: calc(1 - var(--show) * 0.65);\n  transition: opacity 420ms ease, color 420ms ease;\n}\n.ns-vellum-run[data-restored]  .ns-vellum-run-current{\n  opacity: 0;\n  transition-duration: 500ms;\n  transition-timing-function: ${EXPO_OUT};\n}\n\n.ns-vellum-run-fade{\n  color: var(--foreground);\n  opacity: calc(var(--show) * 0.6);\n  filter: blur(calc(var(--show) * 0.5px));\n  transition: opacity 420ms ease, filter 420ms ease;\n}\n@starting-style{\n  .ns-vellum-run-fade{ opacity: 0; filter: blur(0px); }\n}\n.ns-vellum-run[data-restored] .ns-vellum-run-fade{\n  opacity: 1;\n  filter: blur(0px);\n  transition-duration: 500ms;\n  transition-timing-function: ${EXPO_OUT};\n}\n.ns-vellum-run[data-restored] .ns-vellum-badge{ display: none; }\n\n.ns-vellum-badge{\n  font-family: var(--font-mono);\n  font-size: 9px;\n  letter-spacing: 0.04em;\n  padding: 0 3px;\n  margin-right: 3px;\n  border: 1px solid var(--border);\n  border-radius: 4px;\n  color: var(--ns-muted);\n  vertical-align: 1px;\n}\n\n.ns-vellum-btn{\n  display: inline-flex;\n  align-items: center;\n  margin-left: 4px;\n  padding: 1px 7px;\n  font-family: var(--font-mono);\n  font-size: 10px;\n  letter-spacing: 0.02em;\n  color: var(--ns-muted);\n  background: var(--background);\n  border: 1px solid var(--border);\n  border-radius: 6px;\n  cursor: pointer;\n  transition: color 150ms ease, border-color 150ms ease;\n}\n.ns-vellum-btn:hover,\n.ns-vellum-btn:focus-visible{\n  color: var(--ns-accent);\n  border-color: var(--ns-accent);\n}\n.ns-vellum-btn:focus-visible{\n  outline: 2px solid var(--ns-accent);\n  outline-offset: 2px;\n}\n.ns-vellum-btn-accent{\n  color: var(--ns-accent);\n  border-color: var(--ns-accent);\n}\n\n.ns-vellum-confirm{ display: inline-flex; align-items: center; }\n\n@media (prefers-reduced-motion: reduce){\n  .ns-vellum-run-current,\n  .ns-vellum-run-fade,\n  .ns-vellum-tick{\n    transition: none !important;\n  }\n}\n`;\n\nexport default VellumScrape;\n",
      "type": "registry:ui",
      "target": "components/ui/vellum-scrape.tsx"
    }
  ],
  "cssVars": {
    "theme": {
      "color-ns-muted": "var(--ns-muted)",
      "color-ns-accent": "var(--ns-accent)"
    },
    "light": {
      "ns-muted": "#4d4d4d",
      "ns-accent": "#006bff"
    },
    "dark": {
      "ns-muted": "#8f8f8f"
    }
  },
  "meta": {
    "collection": "core",
    "tags": [
      "version-history",
      "diff",
      "slider",
      "range",
      "scrub",
      "document",
      "revision",
      "restore",
      "accessibility"
    ],
    "instruction": "Build VellumScrape, a document viewer where a single vertical rail (a real input[type=range], min 0, max versions.length-1) scrubs the whole document's edit history at once. Props: `versions` (NEWEST FIRST — index 0 is depth 0, the current save), `runs` (the document as an ordered flat list of `{id, paragraph, current, ghost?, revealedAt?, overwrittenBy?}` — a run with no `ghost` has never been overwritten and renders as a plain span, untouched by any of this), controlled/uncontrolled `depth`/`defaultDepth`, `onDepthChange`, `onRestore`, `label`, `className`. MECHANISM: every run carrying a ghost gets a static per-run CSS custom property `--rd` (its `revealedAt` threshold); the document root carries the one value that actually changes on drag, `--scrape-depth`. Each run computes its own reveal fraction in pure CSS as `--show: clamp(0, calc(var(--scrape-depth) - var(--rd) + 1), 1)`, which is always exactly 0 or exactly 1 for any integer depth vs. any integer threshold — a clean step with no per-run JS bookkeeping. Setting the single `--scrape-depth` var on the doc root is what makes every run's opacity/color/filter (ordinary animatable properties, each with its own `transition: … 420ms`, not a transition on the custom property itself) pick up the step and ease across it together: current text crossfades toward `--ns-muted` at 35% opacity while the ghost fades in at reduced weight with a 0.5px blur. This is a CSS-driven reveal, not a JS-computed diff view or a strikethrough overlay — the revealed past is real DOM text, selectable and readable, never an image. RESTORE: a per-run button (never a descendant of the faded ghost element, so it never inherits its opacity/blur) opens an inline confirm strip beside it (Restore/Cancel), never a modal. Confirming sets that run's `restored` flag, which a `data-restored` attribute on the run wrapper uses to permanently pin the ghost to full `--foreground` ink (no blur, no muted crossfade) and hide the superseded current text, animating over 500ms cubic-bezier(0.16,1,0.3,1) — the \"one wet stroke\" re-inking, independent of the rail's depth from that point on. The confirm trigger itself is never removed from the DOM; only the confirm strip beside it toggles, and opening it is idempotent (`setConfirmingId(id)`, never a toggle), so a second click on an already-open trigger cannot close it. A11Y: the rail carries `aria-valuetext` naming the version, its author and save time — standard slider semantics, so arrow keys/Home/End work for free with no extra keydown handling. A separate visible + `aria-live=\"polite\"` summary line announces what the current depth revealed (\"Showing version N of total: k paragraphs differ\"), independent of whether the rail has focus. Every transition this component declares is zeroed under `prefers-reduced-motion`, a pure CSS switch with no JS branching in the drag or reveal path. Colors are strictly --background/--foreground/--ns-muted/--border/--ns-accent, read as CSS custom properties, never hardcoded hex. Zero dependencies, DOM+CSS only, no canvas."
  },
  "type": "registry:ui"
}