{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "search-typing",
  "title": "Search Typing",
  "description": "A search field wider than the shot: two thirds in frame, then it comes forward, types across its left half, pages to its right half, and goes back. Every proportion is measured, and the scale pivots on the text baseline so the type never climbs the pixel grid.",
  "dependencies": [
    "remotion",
    "@remotion/google-fonts"
  ],
  "registryDependencies": [
    "https://snapcn.dev/r/snap-cn-ui.json",
    "https://snapcn.dev/r/caret.json",
    "https://snapcn.dev/r/input.json"
  ],
  "files": [
    {
      "path": "registry/snap-cn/search-typing/index.tsx",
      "content": "\"use client\";\n\nimport { loadFont } from \"@remotion/google-fonts/Outfit\";\nimport {\n  type CSSProperties,\n  useEffect,\n  useLayoutEffect,\n  useRef,\n  useState,\n} from \"react\";\nimport {\n  continueRender,\n  delayRender,\n  Easing,\n  getRemotionEnvironment,\n  interpolate,\n  random,\n  useCurrentFrame,\n  useVideoConfig,\n} from \"remotion\";\nimport { Caret } from \"@/components/snap-cn/caret\";\nimport { inputStyleContext } from \"@/components/snap-cn/input\";\nimport { mixOklch, type SnapCnTheme, useSnapCnTheme } from \"@/lib/snap-cn-ui\";\n\n// Outfit Light. Identified by measuring the reference's letterforms against a\n// field of candidates: its stem/cap ratio is 0.121, and Outfit 300 is the only\n// geometric face that hits that exactly (stem 12px at a 99px cap, like for like).\n// Loaded through @remotion/google-fonts rather than a CSS variable so the Player,\n// the rendered mp4 and a user's own project all get the same face.\nconst { fontFamily: OUTFIT } = loadFont(\"normal\", {\n  weights: [\"300\", \"400\"],\n  subsets: [\"latin\"],\n});\n\n/**\n * `shadcn` — the field is a shadcn Input: a card surface, a hairline border, and a\n * shadow you have to look for. Correct on a light page, and the default.\n * `glass`  — the field the reference uses: a grey crown falling to white, lit from\n * below. It is measured, and it only works on a DARK backdrop; on a light one the\n * crown reads as dirt and the shadow as a smear.\n */\nexport type SearchTypingSurface = \"shadcn\" | \"glass\";\nexport type SearchTypingIcon = \"search\" | \"sparkle\" | \"none\";\n\nexport interface SearchTypingProps {\n  /** The sentence typed into the field, one character at a time. */\n  text: string;\n  /** Typing speed. 14 is what the reference does. */\n  charsPerSecond?: number;\n  /**\n   * Keystroke-interval variance, 0–1. 0 is a metronome. Deterministic — every\n   * interval is drawn from `seed`, never from a clock.\n   */\n  humanize?: number;\n  /** Beat after a space, as a multiple of the base keystroke interval. */\n  wordPause?: number;\n  /** Beat after `.,!?;:`, as a multiple of the base keystroke interval. */\n  punctuationPause?: number;\n  /** Seconds the field holds, parked and blinking, before the first keystroke. */\n  startDelay?: number;\n  /** Seconds the field takes to come forward once typing starts. */\n  dollyDuration?: number;\n  /** Seconds the field takes to slide from its left half to its right half. */\n  panDuration?: number;\n  /** Seconds the finished sentence holds at the front before the field goes back. */\n  holdAfter?: number;\n  /**\n   * Seconds the field takes to travel back. It goes back *further* than it\n   * started — far enough that the whole field lands in frame, which is the only\n   * moment you see it and the finished sentence at once. 0 parks it at the front.\n   */\n  recedeDuration?: number;\n  /**\n   * How much bigger the field gets as it comes forward. 1.25 is what the\n   * reference measures. `frontVisible × dolly` is how much of the field is in\n   * frame at rest — 0.56 × 1.25 = 0.70, roughly the two thirds you see there.\n   */\n  dolly?: number;\n  /**\n   * The field's height on screen, as a fraction of the frame's. This is the knob\n   * that makes the field look long and slim rather than chunky: it sets the\n   * height directly, and the text — which is a fixed ratio of it — comes with it.\n   */\n  fieldHeight?: number;\n  /**\n   * How much of the field is in frame once it has come forward. The field is\n   * padded out to whatever length that needs, so this and `fieldHeight` are\n   * independent — which they are not if the field is only as long as its\n   * sentence. (A real search field is longer than what you type into it.)\n   */\n  frontVisible?: number;\n  /** Margin, in px, between the field's visible cap and the edge of the frame. */\n  edgeInset?: number;\n  /** Blinks per second. 1 is the ~500ms-on/500ms-off of a real text field. */\n  caretBlinksPerSecond?: number;\n  caret?: boolean;\n  surface?: SearchTypingSurface;\n  /** Design-system token overrides, same shape every snap-cn-ui component takes. */\n  theme?: Partial<SnapCnTheme>;\n  mode?: \"light\" | \"dark\";\n  icon?: SearchTypingIcon;\n  fontFamily?: string;\n  fontWeight?: number;\n  /** Seeds `humanize`. Same seed, same performance, forever. */\n  seed?: string;\n  /** Global playback multiplier applied to the whole sequence. */\n  speed?: number;\n  className?: string;\n}\n\n/**\n * Every proportion below was measured off the reference frames, as a ratio of the\n * field's height (373px there). They are ratios and not pixels so the whole\n * component scales as one piece.\n *\n *   radius 103/373 = 0.276  — NOT a pill. A pill would be 0.500.\n *   cap height 99/373, and cap ≈ 0.70em, so fontSize/height = 0.379\n *   left cap → text start = 382/373\n *   left cap → icon = 133/373;  icon box 141/373;  icon stroke 12/373\n *   caret 9×162 /373\n */\nconst R = {\n  radius: 0.276,\n  fontSize: 0.379,\n  /**\n   * Left cap → the first glyph's INK. The text box has to start a touch earlier\n   * than that, because the \"W\" carries a side bearing: measured on the render,\n   * that bearing is 0.021 × the field's height.\n   */\n  textInk: 1.024,\n  sideBearing: 0.021,\n  iconInset: 0.357,\n  iconSize: 0.378,\n  /**\n   * The reference measures 0.62 here — but the reference's field is never seen\n   * whole, so nobody ever sees its right padding next to its left. This one IS\n   * seen whole at the end, and 0.62 against a 0.357 left inset does not read as\n   * padding, it reads as a word missing off the end of the sentence. Balanced\n   * against the left instead.\n   */\n  rightPad: 0.4,\n  caretWidth: 0.024,\n  caretHeight: 0.434,\n  /**\n   * Outfit sits its baseline 0.017 × height lower than the reference does once the\n   * line box is centred in the field. The line box's height cannot fix that — a\n   * centred line box puts the baseline at `H/2 − content/2 + ascent`, which is a\n   * property of the face, not of the leading — so the text is nudged instead.\n   */\n  baselineNudge: 0.017,\n} as const;\n\n/**\n * The field's height in layout px. Purely a unit — every other measurement is a\n * ratio of it, and the camera rescales the whole thing to whatever `fieldHeight`\n * asks for. Nothing on screen changes if you change it.\n */\nconst LAYOUT_UNIT = 240;\n\n/** Where the text box starts, so that its first glyph's ink lands on the mark. */\nconst R_TEXT_LEFT = R.textInk - R.sideBearing;\n/** Left cap → text, minus the icon and its inset, is the gap after the icon. */\nconst R_GAP = R_TEXT_LEFT - R.iconInset - R.iconSize;\n\nconst PUNCTUATION = /[.,!?;:]/;\n\n/** The push runs while the sentence is being typed, so it must not crawl at the end. */\nconst DOLLY_EASING = Easing.inOut(Easing.sin);\n/** The page across is a deliberate move: it leaves and arrives. */\nconst PAN_EASING = Easing.inOut(Easing.cubic);\nconst BACK_EASING = Easing.inOut(Easing.cubic);\n\nfunction clamp01(t: number): number {\n  return Math.max(0, Math.min(1, t));\n}\n\nfunction smoothstep(t: number): number {\n  const x = clamp01(t);\n  return x * x * (3 - 2 * x);\n}\n\nexport interface TypingScheduleOptions {\n  fps: number;\n  charsPerSecond: number;\n  humanize: number;\n  wordPause: number;\n  punctuationPause: number;\n  seed: string;\n}\n\n/**\n * The frame each prefix length lands on: `schedule[n]` is the frame at which the\n * field shows exactly `n` characters. Strictly monotonic by construction, which\n * is what {@link typedCount} relies on and why a scrub can never un-type a letter.\n */\nexport function buildTypingSchedule(\n  text: string,\n  opts: TypingScheduleOptions,\n): number[] {\n  const { fps, charsPerSecond, humanize, wordPause, punctuationPause, seed } =\n    opts;\n  const base = fps / Math.max(0.1, charsPerSecond);\n  const jitter = clamp01(humanize);\n\n  const schedule: number[] = [0];\n  let acc = 0;\n\n  for (let i = 0; i < text.length; i++) {\n    // The rest belongs to the gap *before* this character — a typist pauses after\n    // finishing a word, not before typing the space itself.\n    const prev = i > 0 ? text[i - 1] : \"\";\n    let interval = base;\n    if (prev === \" \") {\n      interval *= wordPause;\n    } else if (prev !== \"\" && PUNCTUATION.test(prev)) {\n      interval *= punctuationPause;\n    }\n    if (jitter > 0) {\n      const r = random(`${seed}-key-${i}`) * 2 - 1;\n      interval *= 1 + r * 0.35 * jitter;\n    }\n    acc += Math.max(0.001, interval);\n    schedule.push(acc);\n  }\n\n  return schedule;\n}\n\n/** Characters visible at `localFrame`, given a monotonic {@link buildTypingSchedule}. */\nexport function typedCount(localFrame: number, schedule: number[]): number {\n  if (localFrame <= 0 || schedule.length <= 1) return 0;\n  let n = 0;\n  while (n + 1 < schedule.length && schedule[n + 1] <= localFrame) n++;\n  return n;\n}\n\n/**\n * The character that starts the page across. Null when the sentence fits in the\n * first half and the field never has to move.\n *\n * `triggerX` is deliberately NOT the frame's edge. The pan eases in, so for its\n * first few frames the field has barely moved while the typing carries the caret\n * on rightwards — fire the pan *at* the edge and the caret spills over it before\n * the field catches up. {@link PAN_TRIGGER} keeps a couple of characters' worth of\n * lead in hand.\n */\nexport function pageTriggerIndex(\n  advances: number[],\n  textLeft: number,\n  scale: number,\n  triggerX: number,\n): number | null {\n  for (let n = 1; n < advances.length; n++) {\n    if ((textLeft + advances[n]) * scale > triggerX) return n;\n  }\n  return null;\n}\n\n/** Fraction of the page at which the caret hands the shot over to the pan. */\nconst PAN_TRIGGER = 0.88;\n\nexport interface CameraMarks {\n  typingStart: number;\n  dollyEnd: number;\n  panStart: number | null;\n  panEnd: number;\n  holdEnd: number;\n  recedeEnd: number;\n}\n\n/**\n * 0 = parked back, 1 = fully forward. Rises across the dolly and then holds — it\n * does NOT come back down. The retreat is a separate move to a separate depth.\n */\nexport function cameraPush(frame: number, m: CameraMarks): number {\n  if (m.dollyEnd <= m.typingStart) return frame >= m.typingStart ? 1 : 0;\n  return interpolate(frame, [m.typingStart, m.dollyEnd], [0, 1], {\n    extrapolateLeft: \"clamp\",\n    extrapolateRight: \"clamp\",\n    easing: DOLLY_EASING,\n  });\n}\n\n/**\n * 0 = still at the front, 1 = fully retreated.\n *\n * The field does not go back to where it started. It goes back **further** — far\n * enough that the whole of it lands in frame, which is the only moment in the clip\n * you ever see the complete field and the complete sentence at once. That is the\n * payoff, so it gets its own depth rather than reusing the resting one.\n */\nexport function cameraRetreat(frame: number, m: CameraMarks): number {\n  if (m.recedeEnd <= m.holdEnd) return 0;\n  return interpolate(frame, [m.holdEnd, m.recedeEnd], [0, 1], {\n    extrapolateLeft: \"clamp\",\n    extrapolateRight: \"clamp\",\n    easing: BACK_EASING,\n  });\n}\n\n/** 0 = the field's left half is in frame, 1 = its right half is. */\nexport function cameraPan(frame: number, m: CameraMarks): number {\n  if (m.panStart === null || m.panEnd <= m.panStart) return 0;\n  return interpolate(frame, [m.panStart, m.panEnd], [0, 1], {\n    extrapolateLeft: \"clamp\",\n    extrapolateRight: \"clamp\",\n    easing: PAN_EASING,\n  });\n}\n\nexport interface CaretBlinkOptions {\n  fps: number;\n  blinksPerSecond: number;\n  softness?: number;\n}\n\n/**\n * A real caret is a hard square wave. At 60fps a hard edge reads as a strobe, so\n * only the edges are eased — it still spends most of the cycle fully on and fully\n * off, it just doesn't snap between them.\n */\nexport function caretOpacity(frame: number, opts: CaretBlinkOptions): number {\n  const { fps, blinksPerSecond } = opts;\n  if (blinksPerSecond <= 0 || fps <= 0) return 1;\n\n  const period = fps / blinksPerSecond;\n  if (period <= 0) return 1;\n\n  const t = (((frame % period) + period) % period) / period;\n  const edge = Math.min(0.25, Math.max(0, opts.softness ?? 0.12));\n  if (edge === 0) return t < 0.5 ? 1 : 0;\n\n  if (t < 0.5 - edge) return 1;\n  if (t < 0.5) return 1 - smoothstep((t - (0.5 - edge)) / edge);\n  if (t < 1 - edge) return 0;\n  return smoothstep((t - (1 - edge)) / edge);\n}\n\n/**\n * The magnifier's INK fills the viewBox exactly — circle at (9, 9) r=7.98 with a\n * 2.03 stroke reaches x=0, and the round-capped handle reaches x=24. That matters:\n * `iconSize` is the reference's measured ink extent (0.378 × the field's height),\n * so if the glyph sat inside its box with air around it, it would come out\n * noticeably smaller than the reference's. It did, the first time.\n */\nfunction SearchGlyph({ size, color }: { size: number; color: string }) {\n  return (\n    <svg\n      width={size}\n      height={size}\n      viewBox=\"0 0 24 24\"\n      fill=\"none\"\n      aria-hidden=\"true\"\n      style={{ display: \"block\", flexShrink: 0, overflow: \"visible\" }}\n    >\n      <circle cx=\"9\" cy=\"9\" r=\"7.98\" stroke={color} strokeWidth={2.03} />\n      <path\n        d=\"M14.65 14.65 L22.98 22.98\"\n        stroke={color}\n        strokeWidth={2.03}\n        strokeLinecap=\"round\"\n      />\n    </svg>\n  );\n}\n\nfunction SparkleGlyph({ size, color }: { size: number; color: string }) {\n  return (\n    <svg\n      width={size}\n      height={size}\n      viewBox=\"0 0 24 24\"\n      fill=\"none\"\n      aria-hidden=\"true\"\n      style={{ display: \"block\", flexShrink: 0, overflow: \"visible\" }}\n    >\n      <path\n        d=\"M12 2.6 L13.9 9.1 L20.4 11 L13.9 12.9 L12 19.4 L10.1 12.9 L3.6 11 L10.1 9.1 Z\"\n        fill={color}\n      />\n      <path\n        d=\"M18.9 16.4 L19.7 19.1 L22.4 19.9 L19.7 20.7 L18.9 23.4 L18.1 20.7 L15.4 19.9 L18.1 19.1 Z\"\n        fill={color}\n        opacity={0.55}\n      />\n    </svg>\n  );\n}\n\ninterface Metrics {\n  /** Cumulative advance of each character prefix, in layout px. */\n  advances: number[];\n  /** Y of the text baseline, in layout px from the top of the field. */\n  baselineY: number;\n  /** The field's full width in layout px — it is sized by its own content. */\n  fieldWidth: number;\n}\n\n/**\n * A search field that is wider than the shot, on a dolly.\n *\n * Two thirds of it are in frame to begin with, parked back. On the first keystroke\n * it comes forward, and now only its left half fits. The sentence types across that\n * half; when the caret runs out of frame the field slides, and its right half comes\n * in. The sentence finishes there, and the field goes back.\n *\n * The two things that make scaled type look cheap are both handled:\n *\n *  1. **The scale pivots on the text baseline.** A browser gives glyph origins no\n *     vertical sub-pixel precision — it rounds them to a whole device pixel — so a\n *     scale that *moves* the baseline makes the type climb the pixel grid in whole\n *     pixel jumps. Pivot on it and its device Y never changes. Measured, not\n *     guessed from a line-height.\n *  2. **`text-rendering: geometricPrecision`.** Hinting bends each glyph so its\n *     stems land on whole pixels; under a sliding scale they re-snap every frame\n *     and the letterforms boil.\n *\n * The sentence itself is laid out once, in full, and revealed by clipping to a\n * measured character boundary — never re-sliced, so a settled glyph cannot re-kern\n * as the next one lands.\n */\nexport function SearchTyping({\n  text,\n  charsPerSecond = 14,\n  humanize = 0.35,\n  wordPause = 1.55,\n  punctuationPause = 2.2,\n  startDelay = 0.5,\n  dollyDuration = 0.8,\n  panDuration = 0.5,\n  holdAfter = 0.9,\n  recedeDuration = 1.2,\n  dolly = 1.25,\n  fieldHeight = 0.27,\n  frontVisible = 0.56,\n  edgeInset = 40,\n  caretBlinksPerSecond = 1,\n  caret = true,\n  surface = \"shadcn\",\n  theme,\n  mode,\n  icon = \"search\",\n  fontFamily,\n  fontWeight = 300,\n  seed = \"search-typing\",\n  speed = 1,\n  className,\n}: SearchTypingProps) {\n  const frame = useCurrentFrame();\n  const { fps, width: frameWidth, height: frameHeight } = useVideoConfig();\n  const t = useSnapCnTheme(theme, mode);\n  // The very same context the shadcn <Input> paints itself from, so this field and\n  // that one cannot drift apart.\n  const ui = inputStyleContext(t);\n  const resolvedFontFamily = fontFamily ?? OUTFIT;\n\n  const H = LAYOUT_UNIT;\n  const fontSize = R.fontSize * H;\n  const textLeft = R_TEXT_LEFT * H;\n  const caretWidth = Math.max(1, R.caretWidth * H);\n  const caretHeight = R.caretHeight * H;\n  const iconSize = R.iconSize * H;\n  const lineHeight = Math.round(fontSize * 1.4);\n\n  const schedule = buildTypingSchedule(text, {\n    fps,\n    charsPerSecond,\n    humanize,\n    wordPause,\n    punctuationPause,\n    seed,\n  });\n\n  const clock = frame * speed;\n  const typingStart = startDelay * fps;\n  const typingEnd = typingStart + (schedule[schedule.length - 1] ?? 0);\n  const count = typedCount(clock - typingStart, schedule);\n\n  // ---- measurement -------------------------------------------------------\n  const cameraRef = useRef<HTMLDivElement>(null);\n  const fieldRef = useRef<HTMLDivElement>(null);\n  const textRef = useRef<HTMLSpanElement>(null);\n  const baselineRef = useRef<HTMLSpanElement>(null);\n  const [metrics, setMetrics] = useState<Metrics | null>(null);\n  const [handle] = useState(() =>\n    delayRender(\"search-typing: measuring field, baseline and advances\"),\n  );\n\n  useLayoutEffect(() => {\n    const camEl = cameraRef.current;\n    const fieldEl = fieldRef.current;\n    const textEl = textRef.current;\n    const baseEl = baselineRef.current;\n    if (!camEl || !fieldEl || !textEl || !baseEl) return;\n    let cancelled = false;\n\n    const measure = () => {\n      if (cancelled) return;\n\n      // getBoundingClientRect reports *device* px, so it sees the camera's scale\n      // and would hand back every advance multiplied by it. Switch the transform\n      // off for the read; useLayoutEffect runs before paint, so it never shows.\n      const previous = camEl.style.transform;\n      camEl.style.transform = \"none\";\n\n      const fieldRect = fieldEl.getBoundingClientRect();\n      const baselineY = baseEl.getBoundingClientRect().top - fieldRect.top;\n      const fieldWidth = fieldRect.width;\n\n      const advances: number[] = [0];\n      const node = textEl.firstChild;\n      if (node) {\n        // The run is `white-space: pre` and holds the *whole* sentence, so a range\n        // ending on a space is never a trailing space — its advance is real. That\n        // is why the caret sits after the space in \"the most |\".\n        const range = document.createRange();\n        for (let i = 1; i <= text.length; i++) {\n          range.setStart(node, 0);\n          range.setEnd(node, i);\n          advances.push(range.getBoundingClientRect().width);\n        }\n      }\n\n      camEl.style.transform = previous;\n      setMetrics({ advances, baselineY, fieldWidth });\n    };\n\n    // Measuring against a fallback face measures the wrong face: the real one swaps\n    // in a frame later and every advance — and the baseline — moves.\n    if (typeof document !== \"undefined\" && document.fonts) {\n      if (document.fonts.status === \"loaded\") measure();\n      else void document.fonts.ready.then(measure);\n    } else {\n      measure();\n    }\n\n    return () => {\n      cancelled = true;\n    };\n  }, [text]);\n\n  useEffect(() => {\n    if (metrics) continueRender(handle);\n  }, [metrics, handle]);\n\n  // ---- camera ------------------------------------------------------------\n  const pageWidth = frameWidth - edgeInset * 2;\n\n  // The scale is set by the height we want the field to READ at, not by its\n  // length. Length is then whatever the framing needs — the field is padded out\n  // to it. Doing this the other way round is what made the field chunky: a field\n  // only as long as its sentence has a fixed aspect, so demanding that half of it\n  // fill the frame forces its height.\n  const frontScale = (fieldHeight * frameHeight) / H;\n  const restScale = frontScale / Math.max(1.0001, dolly);\n\n  // The field's natural length: icon + sentence + the reference's padding.\n  const naturalWidth = metrics?.fieldWidth ?? frameWidth * 2;\n  const framedWidth =\n    pageWidth / Math.max(0.05, Math.min(1, frontVisible)) / frontScale;\n  // Never shorter than its own content — a long sentence simply makes a long field.\n  const fieldWidth = Math.max(naturalWidth, framedWidth);\n\n  const advances = metrics?.advances ?? null;\n  const triggerIndex = advances\n    ? pageTriggerIndex(advances, textLeft, frontScale, pageWidth * PAN_TRIGGER)\n    : null;\n\n  const marks: CameraMarks = {\n    typingStart,\n    dollyEnd: typingStart + dollyDuration * fps,\n    panStart:\n      triggerIndex !== null ? typingStart + schedule[triggerIndex] : null,\n    panEnd:\n      triggerIndex !== null\n        ? typingStart + schedule[triggerIndex] + panDuration * fps\n        : 0,\n    holdEnd: typingEnd + holdAfter * fps,\n    recedeEnd: typingEnd + holdAfter * fps + recedeDuration * fps,\n  };\n\n  // The depth the field ends at: exactly wide enough that the whole of it fits the\n  // page. `edgeInset` then leaves it an equal margin on both sides, so anchoring\n  // the retreat on its right cap lands it centred without any special case.\n  const endScale = pageWidth / Math.max(1, fieldWidth);\n\n  const push = cameraPush(clock, marks);\n  const back = cameraRetreat(clock, marks);\n  const pan = cameraPan(clock, marks);\n\n  // rest → front on the push, then front → end on the retreat.\n  const forward = restScale + (frontScale - restScale) * push;\n  const scale = forward + (endScale - forward) * back;\n\n  // Left cap pinned to the frame's left edge, or right cap pinned to its right —\n  // the pan crossfades between the two anchors. Because the right anchor is a\n  // function of the *current* scale, the retreat pulls back around the right cap\n  // instead of dragging it off screen.\n  const leftAnchored = edgeInset;\n  const rightAnchored = frameWidth - edgeInset - fieldWidth * scale;\n  const originX = leftAnchored + (rightAnchored - leftAnchored) * pan;\n\n  const contentWidth = advances ? advances[advances.length - 1] : 0;\n  const caretX = advances ? advances[Math.min(count, advances.length - 1)] : 0;\n\n  const blinkOpacity = caretOpacity(clock, {\n    fps,\n    blinksPerSecond: caretBlinksPerSecond,\n  });\n\n  const baselineY = metrics?.baselineY ?? H / 2;\n\n  // NO drop shadow. shadcn defines a control with a hairline border, not with a\n  // shadow doing the border's job — and a shadow big enough to be seen under a\n  // field this size is a grey smear on a light page, which is what the first two\n  // attempts here looked like. The border carries it, and the field is clean.\n  // A hairline is specified for a ~40px control, where 1px is 2.5% of its height.\n  // This field is five times that, so the same hairline all but disappears — the\n  // border has to be stepped up, in weight AND in contrast, or the field has no\n  // edge at all. Both still come from the design system: the width is a ratio of\n  // the field's height, and the colour is the hairline walked toward the\n  // foreground with the system's own `mixOklch`.\n  const border = Math.max(1, 0.009 * H);\n  const borderColor = mixOklch(ui.idleBorder, t.foreground, 0.28);\n  const shadcnSurface = {\n    background: t.card,\n    border: `${border}px solid ${borderColor}`,\n    boxShadow: \"none\",\n  };\n  // The reference's field, measured: a grey crown falling to white, lit from below.\n  // It is lit for a DARK backdrop — on a light one the crown reads as an inner\n  // shadow and the drop shadow as a smear. Opt in only over something dark.\n  const glassSurface = {\n    background:\n      \"linear-gradient(180deg, #C4C4C4 0%, #D6D6D6 15%, #E7E7E7 30%, #F3F3F3 40%, #FDFDFD 50%, #FDFDFD 100%)\",\n    border: \"none\",\n    boxShadow: `0 ${0.045 * H}px ${0.12 * H}px rgba(9, 9, 12, 0.45)`,\n  };\n  const field = surface === \"glass\" ? glassSurface : shadcnSurface;\n  const inkColor = surface === \"glass\" ? \"#000000\" : ui.foreground;\n  const iconColor = surface === \"glass\" ? \"#000000\" : ui.mutedForeground;\n\n  const textStyle: CSSProperties = {\n    fontSize,\n    fontWeight,\n    fontFamily: resolvedFontFamily,\n    lineHeight: `${lineHeight}px`,\n    color: inkColor,\n    whiteSpace: \"pre\",\n    // A ligature is one glyph for two characters; clipping through one would paint\n    // half an \"fi\".\n    fontVariantLigatures: \"none\",\n    fontKerning: \"normal\",\n    // The type rides a moving scale, so hinting would re-snap every stem to the\n    // pixel grid each frame and the letterforms would boil.\n    textRendering: \"geometricPrecision\",\n    display: \"block\",\n  };\n\n  // box-shadow takes no percentages, so the offsets are computed from the field's\n  // height — the shadow then scales with the field instead of detaching from it.\n\n  return (\n    <div style={{ position: \"absolute\", inset: 0, overflow: \"hidden\" }}>\n      <div\n        ref={cameraRef}\n        style={{\n          position: \"absolute\",\n          left: 0,\n          top: (frameHeight - H) / 2,\n          transform: `translateX(${originX}px) scale(${scale})`,\n          // x=0 is the field's left cap, so translateX places it exactly. y is the\n          // baseline, so the type cannot climb the pixel grid as the scale moves.\n          transformOrigin: `0px ${baselineY}px`,\n          ...(getRemotionEnvironment().isRendering\n            ? null\n            : { willChange: \"transform\" as const }),\n        }}\n      >\n        <div\n          ref={fieldRef}\n          className={className}\n          style={{\n            display: \"flex\",\n            alignItems: \"center\",\n            // max-content until the content has been measured; then the field is\n            // padded out to the length the framing wants.\n            width: metrics ? fieldWidth : \"max-content\",\n            height: H,\n            paddingLeft: R.iconInset * H,\n            paddingRight: R.rightPad * H,\n            gap: R_GAP * H,\n            borderRadius: R.radius * H,\n            background: field.background,\n            border: field.border,\n            boxShadow: field.boxShadow,\n            boxSizing: \"border-box\",\n          }}\n        >\n          {icon === \"search\" ? (\n            <SearchGlyph size={iconSize} color={iconColor} />\n          ) : null}\n          {icon === \"sparkle\" ? (\n            <SparkleGlyph size={iconSize} color={iconColor} />\n          ) : null}\n\n          <div\n            style={{\n              position: \"relative\",\n              height: lineHeight,\n              flexShrink: 0,\n              // Lifts the line onto the reference's baseline. The caret and the\n              // measured baselineY ride along with it, so the pivot stays true.\n              marginTop: -2 * R.baselineNudge * H,\n            }}\n          >\n            <span\n              ref={textRef}\n              style={{\n                ...textStyle,\n                // Negative vertical insets so ascenders and descenders are never\n                // clipped; only the right edge does any work.\n                clipPath: `inset(-0.5em ${Math.max(0, contentWidth - caretX)}px -0.5em 0)`,\n              }}\n            >\n              {text}\n              <span\n                ref={baselineRef}\n                style={{ display: \"inline-block\", width: 0, height: 0 }}\n              />\n            </span>\n\n            {caret ? (\n              <Caret\n                color={inkColor}\n                width={caretWidth}\n                height={caretHeight}\n                radius={0}\n                opacity={blinkOpacity}\n                style={{\n                  position: \"absolute\",\n                  left: caretX,\n                  top: (lineHeight - caretHeight) / 2,\n                }}\n              />\n            ) : null}\n          </div>\n        </div>\n      </div>\n    </div>\n  );\n}\n\nexport interface SearchTypingDurationOptions extends TypingScheduleOptions {\n  startDelay: number;\n  holdAfter: number;\n  recedeDuration: number;\n}\n\n/**\n * Frames the whole clip needs: the lead-in, the typing, the hold at the front and\n * the retreat. The camera's dolly and pan both finish inside the typing, so they\n * never extend it.\n */\nexport function searchTypingDuration(\n  text: string,\n  opts: SearchTypingDurationOptions,\n): number {\n  const schedule = buildTypingSchedule(text, opts);\n  const typing = schedule[schedule.length - 1] ?? 0;\n  return Math.ceil(\n    opts.startDelay * opts.fps +\n      typing +\n      opts.holdAfter * opts.fps +\n      opts.recedeDuration * opts.fps,\n  );\n}\n",
      "type": "registry:component",
      "target": "components/snap-cn/search-typing.tsx"
    }
  ],
  "type": "registry:component"
}