{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "answer-highlight",
  "title": "Answer Highlight",
  "description": "A question in a pill, an answer that writes itself a word at a time, and then the beat the whole shot is for: a caret lands mid-sentence and a selection band drags across one statement, wrapping the line the way a real one does — and settles onto the single word that matters. Name the sentence and the word as strings; the run is found in the answer for you, punctuation and all.",
  "dependencies": [
    "remotion",
    "@remotion/google-fonts"
  ],
  "registryDependencies": [
    "https://snapcn.dev/r/snap-cn-ui.json"
  ],
  "files": [
    {
      "path": "registry/snap-cn/answer-highlight/index.tsx",
      "content": "\"use client\";\n\nimport { loadFont as loadSans } from \"@remotion/google-fonts/Inter\";\nimport {\n  AbsoluteFill,\n  Easing,\n  interpolate,\n  useCurrentFrame,\n  useVideoConfig,\n} from \"remotion\";\nimport {\n  mixOklch,\n  resolveFont,\n  type SnapCnTheme,\n  useSnapCnTheme,\n  withAlpha,\n} from \"@/lib/snap-cn-ui\";\n\n// Loaded through @remotion/google-fonts, never a CSS variable — a Remotion\n// bundle has none of the app's CSS, so a `var(--font-…)` gets you the right face\n// in the Player and a fallback in the mp4 (design-system rule 4).\nconst { fontFamily: SANS } = loadSans(\"normal\", {\n  weights: [\"400\", \"500\"],\n  subsets: [\"latin\"],\n});\n\n// ---------------------------------------------------------------------------\n// Proportions\n//\n// Fractions of the composition height, never pixels, so the scene holds its\n// shape at 720p, 1080p or a vertical crop.\n//\n// The question pill's numbers are the same ones `agent-steps` carries, measured\n// off the same 718x398 recording by the mass of the pill's fill and by\n// cross-correlating its column ink under a scale sweep. They are repeated here\n// rather than imported because a registry file is copied whole into somebody's\n// project — a shared constants module would be a dependency they did not ask\n// for. If you change one, change the other.\n// ---------------------------------------------------------------------------\n\n/** Question pill height. 22.0px of 398. */\nconst PILL_H = 0.0553;\n/** Corner radius as a fraction of pill height. Not a stadium — that would be 0.5. */\nconst PILL_R = 0.28;\n/** Pill padding either side of the label, as a fraction of pill height. */\nconst PILL_PAD = 0.42;\n/** Question type size, as a fraction of composition height. */\nconst QUESTION_FONT = 0.0223;\n/** Answer type size. Bigger than the question: the question is a caption on the\n *  thing you are here to read. */\nconst ANSWER_FONT = 0.0385;\n/**\n * Answer line height, as a multiple of its own type size.\n *\n * Looser than a paragraph would normally want. The selection band fills its\n * line box the way a browser's does, so at 1.5 the band on one line sits\n * against the type on the next and the whole thing reads as a solid block of\n * colour instead of as two selected lines.\n */\nconst ANSWER_LEADING = 1.64;\n/** Answer measure, as a fraction of frame width. Long enough for three or four\n *  lines of a real answer, short enough that the eye does not lose the line. */\nconst MEASURE = 0.6;\n/** Gap between the pill's bottom and the answer's first baseline box. */\nconst PILL_GAP = 0.075;\n\n/** Selection band padding above and below the type, as a fraction of the answer\n *  type size — a browser's own selection sits a little proud of the leading. */\nconst BAND_PAD_Y = 0.18;\n/** Band corner radius, as a fraction of the answer type size. */\nconst BAND_R = 0.1;\n/** The caret at the leading edge of the drag, as a fraction of the type size. */\nconst CARET_W = 0.06;\n\n// ---------------------------------------------------------------------------\n// Motion\n//\n// The pill's entrance is measured (see `agent-steps`). Everything below it is\n// *chosen*, not fitted — there is no reference recording for this scene — so the\n// numbers say what they are for rather than quoting an rms.\n// ---------------------------------------------------------------------------\n\n/** The question's entrance: scale, blur and opacity, in seconds. */\nconst INTRO_S = 0.375;\nconst INTRO_BLUR_S = 0.2;\nconst INTRO_SCALE = 1.31;\n/** Blur at t=0, as a fraction of composition height. */\nconst INTRO_BLUR = 0.0141;\n\n/**\n * One word of the answer's arrival, in seconds, and the gap between two of them.\n *\n * The stagger is the read: at 0.055s a 30-word answer takes 1.65s, which is fast\n * enough to feel generated and slow enough that the eye can start reading before\n * it finishes. Each word takes longer to arrive (0.22s) than the gap between two\n * — so four or five words are always mid-arrival, which is what stops it looking\n * like a ticker.\n */\nconst WORD_IN_S = 0.22;\nconst WORD_STEP_S = 0.055;\n/** How far a word rises as it arrives, as a fraction of its own type size. */\nconst WORD_RISE = 0.34;\n\n/**\n * Seconds the drag spends on each word of the statement.\n *\n * A selection is not a fade. The band is painted as a gradient with a hard stop\n * inside the word the drag is currently crossing, so the leading edge moves\n * *through* a word rather than jumping over it — at 30fps a per-word step would\n * advance the edge three whole words a second and read as four chips appearing,\n * not as a hand pulling across the line.\n *\n * 0.11s is a real drag: four words in a little under half a second.\n */\nconst BAND_STEP_S = 0.11;\n\n/** The word inside the statement, once the statement is selected. */\nconst WORD_BAND_S = 0.2;\n/** Word gap, as an em of the answer type. Padding, not margin — see the span. */\nconst WORD_GAP = 0.28;\n\n// ---------------------------------------------------------------------------\n\nexport interface AnswerHighlightProps {\n  /** The prompt, in the pill at the top. */\n  question?: string;\n  /** The answer. It writes itself a word at a time; line breaks are the\n   *  browser's, so write a paragraph and let `measure` set the rag. */\n  answer?: string;\n  /**\n   * The run inside `answer` that gets selected — word for word, as written.\n   * A run that is not found simply never highlights, which is the signal that\n   * the two strings have drifted apart.\n   */\n  statement?: string;\n  /** The one word inside `statement` that takes the second, stronger band. */\n  word?: string;\n  /** Seconds the question holds alone before the answer starts. */\n  questionHold?: number;\n  /** Seconds between the answer finishing and the drag starting. */\n  beforeDrag?: number;\n  /** Seconds the selected statement holds before the word is picked out. */\n  beforeWord?: number;\n  /** Seconds per word of the answer's arrival, and of the drag. */\n  wordStep?: number;\n  dragStep?: number;\n  /** Page behind the scene. Defaults to `theme.background`. */\n  paperColor?: string;\n  /** Pill fill. Defaults to `theme.card`. */\n  pillColor?: string;\n  /** Question ink. Defaults to `theme.mutedForeground`. */\n  questionColor?: string;\n  /** Answer ink. Defaults to `theme.foreground`. */\n  answerColor?: string;\n  /** The selection, the caret and the picked word. Defaults to `theme.primary`. */\n  accentColor?: string;\n  /** Where the lockup sits, as a fraction of frame height. */\n  centerY?: number;\n  fontFamily?: string;\n  theme?: Partial<SnapCnTheme>;\n  mode?: \"light\" | \"dark\";\n  speed?: number;\n}\n\n// --- Pure helpers (unit-tested) -------------------------------------------\n\n/**\n * The answer, as the tokens the scene actually animates.\n *\n * Split on whitespace and *keep the punctuation attached to its word*, because\n * the selection band is painted per token: a comma that is its own token gets\n * its own band and its own arrival, and the band comes apart at every clause.\n */\nexport function splitWords(text: string): string[] {\n  return text.split(/\\s+/).filter(Boolean);\n}\n\n/**\n * A token with its outer punctuation taken off, for matching only.\n *\n * So that `statement=\"keep the timeline declarative\"` finds the answer's\n * `declarative,` — writing the comma into the prop to make the match land is\n * the kind of thing nobody remembers and everybody debugs twice.\n */\nexport function bare(wordToken: string): string {\n  return wordToken.replace(/^[^\\p{L}\\p{N}]+|[^\\p{L}\\p{N}]+$/gu, \"\");\n}\n\n/**\n * Where `phrase` sits in `words`, as a half-open [start, end) word range.\n *\n * Matched on the joined text rather than word by word so that a phrase written\n * with its own internal spacing still lands. Returns null when it is not there —\n * the caller draws no band, which makes a drifted prop visible instead of\n * highlighting some near-miss run of words.\n */\nexport function findRun(\n  words: string[],\n  phrase: string,\n): [number, number] | null {\n  const target = splitWords(phrase).map(bare);\n  if (target.length === 0) return null;\n  const wanted = target.join(\" \");\n  const haystack = words.map(bare);\n  for (let i = 0; i + target.length <= words.length; i += 1) {\n    if (haystack.slice(i, i + target.length).join(\" \") === wanted) {\n      return [i, i + target.length];\n    }\n  }\n  return null;\n}\n\n/**\n * The selection band on one word, as a CSS background.\n *\n * `across` is how far the drag has crossed this word: nothing at 0, a solid\n * band at 1, and in between a hard-edged gradient with the caret sitting just\n * past the stop. Returned as `undefined` when there is nothing to paint so the\n * span keeps its cheap \"no background\" path.\n */\nexport function paintBand(\n  across: number,\n  fill: string,\n  caret: string,\n  caretWidth: number,\n): string | undefined {\n  if (across <= 0) return undefined;\n  if (across >= 1) return fill;\n  const stop = `${(across * 100).toFixed(2)}%`;\n  return (\n    `linear-gradient(to right, ${fill} 0 ${stop}, ` +\n    `${caret} ${stop}, ${caret} calc(${stop} + ${caretWidth.toFixed(2)}px), ` +\n    `transparent calc(${stop} + ${caretWidth.toFixed(2)}px))`\n  );\n}\n\n/** Progress of a value that starts at `at` and takes `dur` frames. */\nexport function ramp(fc: number, at: number, dur: number): number {\n  if (dur <= 0) return fc >= at ? 1 : 0;\n  return Math.max(0, Math.min(1, (fc - at) / dur));\n}\n\n/**\n * The frame the drag's leading edge is on, as a fractional word index.\n *\n * Fractional on purpose: the caret is drawn on `floor(edge)` and the band on\n * everything behind it, so the edge can sit between two words and the bar does\n * not jump a whole word at a time on a 30fps clock.\n */\nexport function dragEdge(\n  fc: number,\n  at: number,\n  stepF: number,\n  count: number,\n): number {\n  if (stepF <= 0) return fc >= at ? count : 0;\n  return Math.max(0, Math.min(count, (fc - at) / stepF));\n}\n\n// --- Defaults --------------------------------------------------------------\n\nconst DEFAULT_ANSWER =\n  \"Build one composition per scene and keep the timeline declarative, \" +\n  \"so the same props always render the same frames. Anything that reads \" +\n  \"the clock at render time is a bug you will find in the export.\";\n\n// ---------------------------------------------------------------------------\n\nexport function AnswerHighlight({\n  question = \"How should we structure the demo video?\",\n  answer = DEFAULT_ANSWER,\n  statement = \"keep the timeline declarative\",\n  word = \"declarative\",\n  questionHold = 0.6,\n  beforeDrag = 0.45,\n  beforeWord = 0.6,\n  wordStep = WORD_STEP_S,\n  dragStep = BAND_STEP_S,\n  paperColor,\n  pillColor,\n  questionColor,\n  answerColor,\n  accentColor,\n  centerY = 0.5,\n  fontFamily,\n  theme: themeOverride,\n  mode,\n  speed = 1,\n}: AnswerHighlightProps) {\n  const frame = useCurrentFrame();\n  const { fps, height, width } = useVideoConfig();\n  const t = useSnapCnTheme(themeOverride, mode);\n  const face = resolveFont(fontFamily ?? t.fontFamily) ?? SANS;\n\n  const fc = frame * speed;\n  const paper = paperColor ?? t.background;\n  const pill = pillColor ?? t.card;\n  const askInk = questionColor ?? t.mutedForeground;\n  const ink = answerColor ?? t.foreground;\n  const accent = accentColor ?? t.primary;\n\n  // A selection is a *tint of the page*, never the accent at full strength.\n  // Primary behind body type at any real opacity puts white-on-blue in the\n  // middle of a paragraph and the line stops being readable — which is exactly\n  // why a browser's own selection is pale until the window loses focus.\n  //\n  // Alpha, not `mixOklch`. Mixing a warm off-white (#faf9f6) toward a blue in\n  // oklch takes the short way round the hue wheel and passes through green: the\n  // first cut of this scene highlighted the answer in pale mint. A tint of a\n  // colour over paper is an alpha composite, not a hue interpolation.\n  const band = withAlpha(accent, 0.16);\n\n  const font = ANSWER_FONT * height;\n  const words = splitWords(answer);\n  const run = findRun(words, statement);\n  const wordRun = run ? findRun(words, word) : null;\n  // Only a word *inside* the selected statement is picked out. One that happens\n  // to appear earlier in the answer is not the one meant.\n  const picked =\n    wordRun && run && wordRun[0] >= run[0] && wordRun[1] <= run[1]\n      ? wordRun\n      : null;\n\n  // --- the clock -----------------------------------------------------------\n\n  const answerAt = questionHold * fps;\n  const stepF = wordStep * fps;\n  const answerDone = answerAt + (words.length - 1) * stepF + WORD_IN_S * fps;\n  const dragAt = answerDone + beforeDrag * fps;\n  const dragStepF = dragStep * fps;\n  const runLen = run ? run[1] - run[0] : 0;\n  const dragDone = dragAt + runLen * dragStepF;\n  const wordAt = dragDone + beforeWord * fps;\n\n  const edge = run ? dragEdge(fc, dragAt, dragStepF, runLen) : 0;\n  const wordIn = Easing.out(Easing.cubic)(ramp(fc, wordAt, WORD_BAND_S * fps));\n\n  const intro = Easing.out(Easing.cubic)(ramp(fc, 0, INTRO_S * fps));\n  const introBlur =\n    INTRO_BLUR *\n    height *\n    (1 - Easing.out(Easing.cubic)(ramp(fc, 0, INTRO_BLUR_S * fps)));\n\n  return (\n    <AbsoluteFill\n      style={{\n        backgroundColor: paper,\n        alignItems: \"center\",\n        justifyContent: \"center\",\n        overflow: \"hidden\",\n      }}\n    >\n      <div\n        style={{\n          position: \"absolute\",\n          top: `${centerY * 100}%`,\n          transform: \"translateY(-50%)\",\n          width: MEASURE * width,\n          display: \"flex\",\n          flexDirection: \"column\",\n          alignItems: \"flex-start\",\n        }}\n      >\n        {/* The question. Same pill, same entrance, as `agent-steps`. */}\n        <div\n          style={{\n            opacity: intro,\n            filter: introBlur > 0.01 ? `blur(${introBlur}px)` : undefined,\n            transform: `scale(${interpolate(intro, [0, 1], [INTRO_SCALE, 1])})`,\n            transformOrigin: \"0% 50%\",\n            marginBottom: PILL_GAP * height,\n          }}\n        >\n          <div\n            style={{\n              fontFamily: face,\n              fontSize: QUESTION_FONT * height,\n              fontWeight: 500,\n              lineHeight: 1,\n              color: askInk,\n              background: pill,\n              height: PILL_H * height,\n              borderRadius: PILL_R * PILL_H * height,\n              padding: `0 ${PILL_PAD * PILL_H * height}px`,\n              display: \"flex\",\n              alignItems: \"center\",\n              whiteSpace: \"nowrap\",\n            }}\n          >\n            {question}\n          </div>\n        </div>\n\n        {/* The answer. Every word is its own inline span, which is what lets the\n            selection band be a background rather than a measured rectangle: the\n            browser wraps the spans, and a band that is painted per word wraps\n            with them and breaks square at the line end, exactly like a real one.\n            No DOM measurement, and it survives any copy you put in it. */}\n        <div\n          style={{\n            fontFamily: face,\n            fontSize: font,\n            fontWeight: 400,\n            lineHeight: ANSWER_LEADING,\n            color: ink,\n          }}\n        >\n          {words.map((w, i) => {\n            const arrive = Easing.out(Easing.cubic)(\n              ramp(fc, answerAt + i * stepF, WORD_IN_S * fps),\n            );\n            const inRun = run ? i >= run[0] && i < run[1] : false;\n            const isPicked = picked ? i >= picked[0] && i < picked[1] : false;\n            // How far the drag has crossed *this* word: 0 not yet, 1 fully past,\n            // and everything between is where the leading edge actually is.\n            const across =\n              inRun && run ? Math.max(0, Math.min(1, edge - (i - run[0]))) : 0;\n            const fill = isPicked\n              ? withAlpha(accent, 0.16 + 0.14 * wordIn)\n              : band;\n\n            return (\n              <span\n                // biome-ignore lint/suspicious/noArrayIndexKey: the answer is a fixed ordered token list, so the index is the identity\n                key={i}\n                style={{\n                  display: \"inline-block\",\n                  opacity: arrive,\n                  transform: `translateY(${(1 - arrive) * WORD_RISE * font}px)`,\n                  color: isPicked ? mixOklch(ink, accent, wordIn) : ink,\n                  // One background does the band, its leading edge and the\n                  // caret: solid behind everything the drag has passed, a hard\n                  // stop where it has got to, and a sliver of the accent just\n                  // ahead of that stop while it is still moving. A caret drawn\n                  // as a separate element would have to be positioned, and\n                  // positioning it means measuring the text.\n                  background: paintBand(across, fill, accent, CARET_W * font),\n                  // The word gap is padding, not margin, so the band is drawn\n                  // behind the space as well: a selection over four words comes\n                  // out as one unbroken run rather than four chips with holes\n                  // where the spaces were.\n                  padding: `${BAND_PAD_Y * font}px ${WORD_GAP}em ${BAND_PAD_Y * font}px 0`,\n                  borderTopLeftRadius:\n                    run && i === run[0] ? BAND_R * font : undefined,\n                  borderBottomLeftRadius:\n                    run && i === run[0] ? BAND_R * font : undefined,\n                  borderTopRightRadius:\n                    run && i === run[1] - 1 ? BAND_R * font : undefined,\n                  borderBottomRightRadius:\n                    run && i === run[1] - 1 ? BAND_R * font : undefined,\n                }}\n              >\n                {w}\n              </span>\n            );\n          })}\n        </div>\n      </div>\n    </AbsoluteFill>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/snap-cn/answer-highlight.tsx"
    }
  ],
  "docs": "Docs and props: https://snapcn.dev/docs/ai-input/answer-highlight?ref=cli — pass mode=\"dark\" if you are rendering onto a dark frame — these default to shadcn's light palette and a bare Remotion render is black.",
  "type": "registry:component"
}