{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "prompt-zoom",
  "title": "Prompt Zoom",
  "description": "An assistant landing screen that offers its suggestions, then cuts — hard, in a single frame — into the caret, where the prompt types itself. The push-in is a measured 2.547× anchored on the text insertion point, not an animated zoom.",
  "dependencies": [
    "remotion",
    "@remotion/google-fonts"
  ],
  "registryDependencies": [
    "https://snapcn.dev/r/snap-cn-ui.json",
    "https://snapcn.dev/r/input.json"
  ],
  "files": [
    {
      "path": "registry/snap-cn/prompt-zoom/index.tsx",
      "content": "\"use client\";\n\nimport { loadFont as loadSans } from \"@remotion/google-fonts/Inter\";\nimport { loadFont as loadSerif } from \"@remotion/google-fonts/SourceSerif4\";\nimport {\n  AbsoluteFill,\n  getRemotionEnvironment,\n  useCurrentFrame,\n  useVideoConfig,\n} from \"remotion\";\nimport { inputStyleContext } from \"@/components/snap-cn/input\";\nimport { mixOklch, type SnapCnTheme, useSnapCnTheme } 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: SERIF } = loadSerif(\"normal\", {\n  weights: [\"400\"],\n  subsets: [\"latin\"],\n});\nconst { fontFamily: SANS } = loadSans(\"normal\", {\n  weights: [\"400\", \"500\"],\n  subsets: [\"latin\"],\n});\n\nexport interface PromptZoomProps {\n  /** The greeting above the field. */\n  greeting?: string;\n  /** Field placeholder, shown until the first character is typed. */\n  placeholder?: string;\n  /** The prompt that types itself once the cut has landed. */\n  text?: string;\n  /** Suggestion pills under the field. Empty by default — pass some to show\n   *  them. They are static: the shot belongs to the typing and the cut. */\n  chips?: string[];\n  /** Model label in the field's footer, and the effort beside it. */\n  model?: string;\n  effort?: string;\n  /** Seconds at which typing begins. */\n  typeStart?: number;\n  /** Seconds at which the view cuts. Not a transition — one frame. It lands\n   *  *while the prompt is still being typed*, which is the whole gag. */\n  cutAt?: number;\n  /** Typing speed. 18 is what the reference measures. */\n  charsPerSecond?: number;\n  /** How far the cut pushes in. 2.547 is what the reference measures. */\n  zoom?: number;\n  /** Focal point of the cut, as a fraction of the frame. */\n  focusX?: number;\n  focusY?: number;\n  /** The starburst mark. Defaults to `theme.primary`. */\n  accentColor?: string;\n  theme?: Partial<SnapCnTheme>;\n  mode?: \"light\" | \"dark\";\n  speed?: number;\n}\n\n// --- Pure helpers (unit-tested) -------------------------------------------\n\n/** Characters revealed by effective frame `fc`, clamped to `total`. */\nexport function typedCount(\n  fc: number,\n  startF: number,\n  perFrame: number,\n  total: number,\n): number {\n  if (fc <= startF) return 0;\n  if (perFrame <= 0) return total;\n  return Math.min(total, Math.floor((fc - startF) * perFrame));\n}\n\n/**\n * The cut, as a hard boolean. There is no ramp and no easing on purpose.\n *\n * Measured on the reference: the wide view is intact on one frame and fully\n * pushed in on the very next (frames 39 → 40, 803ms → 824ms). Nothing in\n * between. Interpolating this — even over two or three frames — turns a cut into\n * a zoom, and the whole read of the shot is that it *snaps*.\n */\nexport function isZoomed(fc: number, cutF: number): boolean {\n  return fc >= cutF;\n}\n\n/**\n * Caret visibility. Solid whenever a character is actively landing, blinking on\n * a 1.06s cycle when it is not — which is what a real text caret does, and what\n * stops the shot looking frozen while the prompt waits to start.\n */\nexport function caretOn(fc: number, fps: number, typing: boolean): boolean {\n  if (typing) return true;\n  const halfCycle = 0.53 * fps;\n  return Math.floor(fc / halfCycle) % 2 === 0;\n}\n\n// --- Icons -----------------------------------------------------------------\n\nfunction Glyph({\n  d,\n  size,\n  color,\n  fill,\n}: {\n  d: string;\n  size: number;\n  color: string;\n  fill?: boolean;\n}) {\n  return (\n    <svg\n      viewBox=\"0 0 24 24\"\n      width={size}\n      height={size}\n      fill={fill ? color : \"none\"}\n      stroke={fill ? \"none\" : color}\n      strokeWidth={1.8}\n      strokeLinecap=\"round\"\n      strokeLinejoin=\"round\"\n      style={{ display: \"block\", flexShrink: 0 }}\n    >\n      <title>icon</title>\n      <path d={d} />\n    </svg>\n  );\n}\n\n/** Paths for the default chip set, in order. */\nconst CHIP_ICONS = [\n  \"M12 20h9M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4Z\", // pencil\n  \"M22 10 12 5 2 10l10 5 10-5ZM6 12v5c3 3 9 3 12 0v-5\", // cap\n  \"m8 6-6 6 6 6M16 6l6 6-6 6\", // code\n  \"M17 8h1a4 4 0 1 1 0 8h-1M3 8h14v9a4 4 0 0 1-4 4H7a4 4 0 0 1-4-4Z\", // cup\n  \"M12 3v18M3 12h18M5.6 5.6l12.8 12.8M18.4 5.6 5.6 18.4\", // spark\n];\n\n/**\n * The starburst that leads the greeting.\n *\n * Twelve rays with a solid hub, measured off the reference: they are roughly a\n * ninth of the mark's diameter and clearly separated, and their overlap at the\n * centre reads as a filled disc.\n *\n * Six bars, not twelve. A bar is centred on the origin so it draws a ray at both\n * ends — the first version stepped twelve bars by 15° and produced *twenty-four*\n * rays, which at a 19px mark is not a starburst, it is a dot.\n */\nfunction Starburst({ size, color }: { size: number; color: string }) {\n  const rays = Array.from({ length: 6 }, (_, i) => i * 30);\n  return (\n    <svg\n      viewBox=\"0 0 24 24\"\n      width={size}\n      height={size}\n      style={{ display: \"block\", flexShrink: 0 }}\n    >\n      <title>Assistant</title>\n      {rays.map((a) => (\n        <rect\n          key={a}\n          x={10.9}\n          y={1.6}\n          width={2.2}\n          height={20.8}\n          rx={1.1}\n          fill={color}\n          transform={`rotate(${a} 12 12)`}\n        />\n      ))}\n      <circle cx={12} cy={12} r={3.6} fill={color} />\n    </svg>\n  );\n}\n\n// --- Main composition ------------------------------------------------------\n\n/**\n * An assistant's field that types a prompt into itself and, partway through the\n * sentence, **cuts** — hard, in a single frame — into the caret and keeps going.\n *\n * ## The cut is the component\n *\n * Everything here was taken off a frame-by-frame read of the reference, and the\n * one number that matters most is that the push-in happens between two adjacent\n * frames with nothing in between. It is a cut, not a move. The scale is\n * **2.547×** and it is anchored on a point that sits **10% across and 41% down\n * the input field** — which is exactly where the caret is.\n *\n * The typing starts *before* the cut and does not pause for it: the sentence is\n * already running when the frame snaps in, and carries straight on at the new\n * size. That is what stops the cut reading as a scene change — there is one\n * continuous action across it, and the cut just gets you closer to it.\n *\n * That focal point is over-determined by the measurement rather than guessed:\n * fitting the scale and centre to the field's top and left edges then predicts\n * the `+` glyph's centre to within 1.2px and the field's bottom edge to 0.0px.\n *\n * ## Layout is in the reference's own pixels\n *\n * The stage below is literally 810×450 — the recording's frame — scaled to fit\n * whatever composition it is given. Every position in it is a number measured\n * off that recording, so there is no second conversion to get wrong.\n *\n * ## What is not measured\n *\n * The colours and the wording. The reference is a specific product: its coral\n * mark, its model names, its suggestion labels. None of that belongs in a\n * component that ships to strangers and lands next to somebody else's `Input`\n * (design-system rule 5). The paint is the shadcn token set, the field takes its\n * surface from the `Input` primitive's own style context so the two cannot\n * drift, and every label is a prop.\n */\nexport function PromptZoom({\n  greeting = \"Up late?\",\n  placeholder = \"How can I help you today?\",\n  text = \"Get me a plan for tomorrow\",\n  chips = [],\n  model = \"Auto\",\n  effort = \"Medium\",\n  typeStart = 0.35,\n  cutAt = 1.0,\n  charsPerSecond = 18,\n  zoom = 2.547,\n  focusX = 0.27,\n  focusY = 0.516,\n  accentColor,\n  theme,\n  mode,\n  speed = 1,\n}: PromptZoomProps) {\n  const frame = useCurrentFrame();\n  const { width, height, fps } = useVideoConfig();\n  const t = useSnapCnTheme(theme, mode);\n  const ui = inputStyleContext(t);\n  const accent = accentColor ?? t.primary;\n\n  const fc = frame * speed;\n\n  // The reference's own frame. Laying out in these units means every number\n  // below is one that was measured, not one that was converted.\n  const REF_W = 810;\n  const REF_H = 450;\n  const stageScale = Math.min(width / REF_W, height / REF_H);\n\n  // --- measured layout (recording px) ---\n  const BOX = { x: 173, y: 197, w: 461, h: 86, r: 11 };\n  const HEAD_Y = 145; // ink top of the greeting\n  const HEAD_SIZE = 30; // ~28px ink height including descenders\n  const MARK = 19;\n  const CHIP_Y = 301;\n  const CHIP_H = 26;\n  const PAD = 20; // field's inner padding\n\n  // A hairline is specified at a 40px control; this field is 86 tall, so the\n  // token is walked toward the foreground through the system's own mix rather\n  // than by picking a darker grey (design-system rule 3b). No drop shadow —\n  // under a control this size a visible one is a grey smear (rule 3).\n  const borderColor = mixOklch(ui.idleBorder, t.foreground, 0.18);\n\n  // --- timeline ---\n  const cutF = cutAt * fps;\n  const zoomed = isZoomed(fc, cutF);\n  const typedN = typedCount(\n    fc,\n    typeStart * fps,\n    charsPerSecond / fps,\n    text.length,\n  );\n  const typing = typedN > 0 && typedN < text.length;\n  const typed = text.slice(0, typedN);\n  const caret = caretOn(fc, fps, typing);\n\n  const isRendering = getRemotionEnvironment().isRendering;\n  const willChange = isRendering ? undefined : (\"transform\" as const);\n\n  const chipRow =\n    chips.length === 0 ? null : (\n      <div\n        style={{\n          position: \"absolute\",\n          left: BOX.x,\n          top: CHIP_Y,\n          width: BOX.w,\n          display: \"flex\",\n          justifyContent: \"center\",\n          gap: 9,\n        }}\n      >\n        {chips.map((chip, i) => {\n          return (\n            <div\n              key={chip}\n              style={{\n                display: \"inline-flex\",\n                alignItems: \"center\",\n                gap: 5,\n                height: CHIP_H,\n                padding: \"0 11px\",\n                borderRadius: CHIP_H / 2,\n                background: t.card,\n                border: `1px solid ${borderColor}`,\n                color: t.foreground,\n                fontFamily: SANS,\n                fontWeight: 500,\n                fontSize: 11,\n                lineHeight: 1,\n                whiteSpace: \"nowrap\",\n                textRendering: \"geometricPrecision\",\n              }}\n            >\n              <Glyph\n                d={CHIP_ICONS[i % CHIP_ICONS.length]}\n                size={12}\n                color={t.mutedForeground}\n              />\n              {chip}\n            </div>\n          );\n        })}\n      </div>\n    );\n\n  return (\n    <AbsoluteFill style={{ background: t.background }}>\n      <div\n        style={{\n          position: \"absolute\",\n          left: \"50%\",\n          top: \"50%\",\n          width: REF_W,\n          height: REF_H,\n          transform: `translate(-50%, -50%) scale(${stageScale})`,\n        }}\n      >\n        {/* The cut. A plain static scale about the caret — no interpolation, so\n            there is never a frame of it \"zooming\". `transformOrigin` is the\n            measured focal point, in the same units as everything else. */}\n        <div\n          style={{\n            position: \"absolute\",\n            inset: 0,\n            transform: `scale(${zoomed ? zoom : 1})`,\n            transformOrigin: `${focusX * REF_W}px ${focusY * REF_H}px`,\n            willChange,\n          }}\n        >\n          {/* ---- Greeting */}\n          <div\n            style={{\n              position: \"absolute\",\n              left: 0,\n              right: 0,\n              top: HEAD_Y - 6,\n              display: \"flex\",\n              alignItems: \"center\",\n              justifyContent: \"center\",\n              gap: 12,\n              fontFamily: SERIF,\n              fontWeight: 400,\n              fontSize: HEAD_SIZE,\n              lineHeight: 1.1,\n              color: t.foreground,\n              textRendering: \"geometricPrecision\",\n            }}\n          >\n            <Starburst size={MARK} color={accent} />\n            {greeting}\n          </div>\n\n          {/* ---- The field */}\n          <div\n            style={{\n              position: \"absolute\",\n              left: BOX.x,\n              top: BOX.y,\n              width: BOX.w,\n              height: BOX.h,\n              borderRadius: BOX.r,\n              background: t.card,\n              border: `1px solid ${borderColor}`,\n              boxShadow: \"none\",\n            }}\n          >\n            {/* The prompt line, with the caret riding its end. */}\n            <div\n              style={{\n                position: \"absolute\",\n                left: PAD,\n                top: PAD - 4,\n                right: PAD,\n                display: \"flex\",\n                alignItems: \"center\",\n                fontFamily: SANS,\n                fontWeight: 400,\n                fontSize: 12,\n                lineHeight: 1.4,\n                color: typedN > 0 ? t.foreground : t.mutedForeground,\n                whiteSpace: \"pre\",\n                textRendering: \"geometricPrecision\",\n              }}\n            >\n              {typedN > 0 ? typed : placeholder}\n              <span\n                style={{\n                  display: \"inline-block\",\n                  width: 1.4,\n                  height: 15,\n                  marginLeft: 1,\n                  background: t.foreground,\n                  opacity: caret ? 1 : 0,\n                }}\n              />\n            </div>\n\n            {/* Footer: the add affordance, and the model readout. */}\n            <div\n              style={{\n                position: \"absolute\",\n                left: PAD,\n                right: PAD,\n                bottom: 12,\n                display: \"flex\",\n                alignItems: \"center\",\n                justifyContent: \"space-between\",\n                fontFamily: SANS,\n                fontSize: 10,\n                color: t.mutedForeground,\n              }}\n            >\n              <Glyph d=\"M12 5v14M5 12h14\" size={13} color={t.mutedForeground} />\n              <div style={{ display: \"flex\", alignItems: \"center\", gap: 6 }}>\n                <span style={{ color: t.foreground, fontWeight: 500 }}>\n                  {model}\n                </span>\n                <span>{effort}</span>\n              </div>\n            </div>\n          </div>\n\n          {chipRow}\n        </div>\n      </div>\n    </AbsoluteFill>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/snap-cn/prompt-zoom.tsx"
    }
  ],
  "type": "registry:component"
}