{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "hook-uncurl",
  "title": "Hook Uncurl",
  "description": "Stacked condensed caps lines uncurl into place, ending on 'Just make the video.'.",
  "dependencies": ["remotion", "@remotion/google-fonts"],
  "files": [
    {
      "path": "registry/remocn/hook-uncurl/index.tsx",
      "content": "\"use client\";\n\nimport { loadFont } from \"@remotion/google-fonts/BarlowCondensed\";\nimport { useEffect, useId, useMemo, useRef, useState } from \"react\";\nimport {\n  AbsoluteFill,\n  continueRender,\n  delayRender,\n  Easing,\n  interpolate,\n  spring,\n  useCurrentFrame,\n  useVideoConfig,\n} from \"remotion\";\n\nconst barlow = loadFont(\"normal\", { weights: [\"500\"], subsets: [\"latin\"] });\n\nexport interface HookUncurlProps {\n  question?: string;\n  answer?: string;\n  accent?: string;\n  background?: string;\n  speed?: number;\n  theme?: \"dark\" | \"light\";\n}\n\nconst THEMES = {\n  dark: {\n    bg: \"#000000\",\n    ink: \"#FFFFFF\",\n    accent: \"#D4FF00\",\n  },\n  light: {\n    bg: \"#D4FF00\",\n    ink: \"#000000\",\n    accent: \"#000000\",\n  },\n} as const;\n\nconst TRACK = 0;\nconst PITCH = 0.9;\nconst CURL_SX = 0.74;\n\nconst ENTER_LEN = 16;\nconst ENTER_STAGGER = 3;\nconst ENTER_SPREAD = 18;\nconst UNCURL_AT = 40;\nconst UNCURL_LEN = 15;\nconst SWAP_AT = 45;\nconst SWAP_STAGGER = 2;\nconst SWAP_SPREAD = 6;\nconst SINK_LEN = 6;\nconst ROLL_LEN = 12;\nconst RETRACT_AT = 63;\nconst RETRACT_LEN = 7;\n\nconst EASE_OUT = Easing.bezier(0.22, 1, 0.36, 1);\nconst EASE_UNCURL = Easing.bezier(0.5, 0, 0.18, 1);\nconst EASE_SINK = Easing.bezier(0.5, 0, 0.75, 0);\nconst EASE_SNAP = Easing.bezier(0.6, 0, 0.9, 0.5);\nconst CLAMP = {\n  extrapolateLeft: \"clamp\",\n  extrapolateRight: \"clamp\",\n} as const;\n\nconst SEGS = [\n  { len: 0.18, turn: 0 },\n  { len: 0.28, turn: 70 },\n  { len: 1.87, turn: -255 },\n];\nconst SEG_TOTAL = SEGS.reduce((s, g) => s + g.len, 0);\nconst STEPS = 120;\n\ntype Terminal = \".\" | \"!\" | \"\";\n\nfunction words(s: string) {\n  return s\n    .trim()\n    .replace(/[?.!…]+$/u, \"\")\n    .split(/\\s+/)\n    .filter(Boolean)\n    .map((w) => w.toUpperCase());\n}\n\nfunction terminalOf(s: string): Terminal {\n  const m = s.trim().match(/([.!])$/);\n  return m ? (m[1] as Terminal) : \"\";\n}\n\nfunction curl(u: number) {\n  const ds = SEG_TOTAL / STEPS;\n  const pts: [number, number][] = [[0, 0]];\n  let x = 0;\n  let y = 0;\n  let phi = 0;\n  let segEnd = SEGS[0].len;\n  let si = 0;\n  for (let i = 0; i < STEPS; i++) {\n    const s = (i + 0.5) * ds;\n    while (s > segEnd && si < SEGS.length - 1) {\n      si++;\n      segEnd += SEGS[si].len;\n    }\n    const g = SEGS[si];\n    const k = (((g.turn * Math.PI) / 180) * (1 - u)) / g.len;\n    phi += (k * ds) / 2;\n    x += Math.sin(phi) * ds;\n    y -= Math.cos(phi) * ds;\n    phi += (k * ds) / 2;\n    pts.push([x, y]);\n  }\n  return pts;\n}\n\nconst REST = curl(0);\nconst REST_BOX = REST.reduce(\n  (b, [x, y]) => ({\n    minX: Math.min(b.minX, x),\n    maxX: Math.max(b.maxX, x),\n    minY: Math.min(b.minY, y),\n  }),\n  { minX: 0, maxX: 0, minY: 0 },\n);\n\ninterface Glyph {\n  adv: number;\n  inkL: number;\n  inkR: number;\n}\n\ninterface Metrics {\n  key: string;\n  w: Map<string, number>;\n  space: number;\n  baseline: number;\n  capH: number;\n  asc: number;\n  desc: number;\n  stem: number;\n  dotD: number;\n  q: Glyph;\n  dot: Glyph;\n  bang: Glyph;\n}\n\nfunction useMetrics(strings: string[]) {\n  const key = strings.join(\"\\u0001\");\n  const [state, setState] = useState<Metrics | null>(null);\n  const [handle] = useState(() => delayRender(\"hook-uncurl: measuring\"));\n  const released = useRef(false);\n  useEffect(() => {\n    let live = true;\n    Promise.all([\n      barlow.waitUntilDone(),\n      document.fonts.load(`500 100px \"${barlow.fontFamily}\"`),\n    ])\n      .catch(() => undefined)\n      .then(() => {\n        if (!live) return;\n        const probe = document.createElement(\"span\");\n        Object.assign(probe.style, {\n          position: \"absolute\",\n          left: \"-99999px\",\n          top: \"0\",\n          visibility: \"hidden\",\n          whiteSpace: \"pre\",\n          fontFamily: `\"${barlow.fontFamily}\"`,\n          fontWeight: \"500\",\n          fontSize: \"100px\",\n          lineHeight: \"1\",\n          letterSpacing: `${TRACK}em`,\n        });\n        document.body.appendChild(probe);\n        const w = new Map<string, number>();\n        for (const s of key.split(\"\\u0001\")) {\n          if (!s) continue;\n          probe.textContent = s;\n          w.set(s, probe.getBoundingClientRect().width / 100);\n        }\n        probe.textContent = \"a a\";\n        const aa = probe.getBoundingClientRect().width;\n        probe.textContent = \"aa\";\n        const space = Math.max(\n          0.18,\n          (aa - probe.getBoundingClientRect().width) / 100,\n        );\n        probe.remove();\n\n        const m: Metrics = {\n          key,\n          w,\n          space,\n          baseline: 0.8,\n          capH: 0.7,\n          asc: 0.76,\n          desc: 0.22,\n          stem: 0.1,\n          dotD: 0.12,\n          q: { adv: 0.42, inkL: 0.04, inkR: 0.38 },\n          dot: { adv: 0.24, inkL: 0.06, inkR: 0.18 },\n          bang: { adv: 0.24, inkL: 0.06, inkR: 0.18 },\n        };\n        const ctx = document.createElement(\"canvas\").getContext(\"2d\");\n        if (ctx) {\n          const S = 400;\n          ctx.font = `500 ${S}px \"${barlow.fontFamily}\"`;\n          const fm = ctx.measureText(\"H\");\n          const a = fm.fontBoundingBoxAscent / S;\n          const d = fm.fontBoundingBoxDescent / S;\n          m.baseline = (1 - (a + d)) / 2 + a;\n          m.capH = fm.actualBoundingBoxAscent / S;\n          m.asc = ctx.measureText(\"HDLJ\").actualBoundingBoxAscent / S;\n          m.desc = ctx.measureText(\"Q,\").actualBoundingBoxDescent / S;\n          const l = ctx.measureText(\"I\");\n          m.stem = (l.actualBoundingBoxLeft + l.actualBoundingBoxRight) / S;\n          const glyph = (ch: string): Glyph => {\n            const g = ctx.measureText(ch);\n            return {\n              adv: g.width / S + TRACK,\n              inkL: -g.actualBoundingBoxLeft / S,\n              inkR: g.actualBoundingBoxRight / S,\n            };\n          };\n          m.q = glyph(\"?\");\n          m.dot = glyph(\".\");\n          m.bang = glyph(\"!\");\n          m.dotD = m.dot.inkR - m.dot.inkL;\n        }\n        setState(m);\n        if (!released.current) {\n          released.current = true;\n          continueRender(handle);\n        }\n      });\n    return () => {\n      live = false;\n    };\n  }, [key, handle]);\n  return state && state.key === key ? state : null;\n}\n\ninterface Placed {\n  line: number;\n  x: number;\n}\n\nfunction place(\n  lines: number[][],\n  list: string[],\n  wOf: (s: string) => number,\n  space: number,\n  F: number,\n  left: number,\n) {\n  const out: Placed[] = [];\n  let anchor = { line: 0, x: left };\n  lines.forEach((ids, li) => {\n    const isLast = li === lines.length - 1;\n    let x = left;\n    ids.forEach((id, k) => {\n      if (k > 0) x += space * F;\n      out[id] = { line: li, x };\n      x += wOf(list[id]) * F;\n    });\n    if (isLast) anchor = { line: li, x };\n  });\n  return { out, anchor, count: lines.length };\n}\n\nexport function HookUncurl({\n  question = \"Why make the video?\",\n  answer = \"Just make the video.\",\n  accent,\n  background,\n  speed = 1,\n  theme = \"dark\",\n}: HookUncurlProps) {\n  const t = useCurrentFrame() * speed;\n  const { width: W, height: H, fps } = useVideoConfig();\n  const c = THEMES[theme];\n  const spot = accent ?? c.accent;\n  const clipId = `huc${useId().replace(/[^a-zA-Z0-9_-]/g, \"\")}`;\n\n  const A = useMemo(() => words(question), [question]);\n  const B = useMemo(() => words(answer), [answer]);\n  const term = terminalOf(answer);\n  const m = useMetrics(useMemo(() => [...new Set([...A, ...B])], [A, B]));\n\n  const transparent = background === \"transparent\";\n  const stage = transparent ? \"transparent\" : (background ?? c.bg);\n\n  if (!m) {\n    return (\n      <div style={{ position: \"absolute\", inset: 0, background: stage }} />\n    );\n  }\n\n  const portrait = H > W * 1.1;\n  const wOf = (s: string) => m.w.get(s) ?? 0;\n  const tailA = m.q.adv;\n  const tailB = term === \"!\" ? m.bang.adv : m.dot.adv;\n\n  const margin = portrait ? W * 0.08 : W * 0.06;\n  const maxW = W * (portrait ? 0.84 : 0.62);\n  const maxBlock = H - margin * 2;\n  const widest = (list: string[], tail: number) =>\n    Math.max(\n      0.2,\n      ...list.map((w, i) => wOf(w) + (i === list.length - 1 ? tail : 0)),\n    );\n  const nLines = Math.max(A.length, B.length, 1);\n  const F = Math.min(\n    maxW / Math.max(widest(A, tailA), widest(B, tailB)),\n    maxBlock / ((nLines - 1) * PITCH + m.capH),\n  );\n  const linesA = A.map((_, i) => [i]);\n  const linesB = B.map((_, i) => [i]);\n  const pa = place(linesA, A, wOf, m.space, F, margin);\n  const pb = place(linesB, B, wOf, m.space, F, margin);\n  const baseOf = (count: number) => (line: number) =>\n    H - margin - (count - 1 - line) * PITCH * F;\n  const baseA = baseOf(pa.count);\n  const baseB = baseOf(pb.count);\n\n  let pre = 0;\n  while (pre < A.length && pre < B.length && A[pre] === B[pre]) pre++;\n  let suf = 0;\n  while (\n    suf < Math.min(A.length, B.length) - pre &&\n    A[A.length - 1 - suf] === B[B.length - 1 - suf]\n  )\n    suf++;\n\n  const glide = spring({\n    frame: t - SWAP_AT - 2,\n    fps,\n    config: { damping: 17, stiffness: 120, mass: 0.9 },\n  });\n  const es = Math.min(ENTER_STAGGER, ENTER_SPREAD / Math.max(1, A.length));\n  const enterAt = (i: number) => i * es - 2;\n  const nOut = Math.max(1, A.length - pre - suf);\n  const nIn = Math.max(1, B.length - pre - suf);\n  const xs = Math.min(SWAP_STAGGER, SWAP_SPREAD / nOut);\n  const ns = Math.min(SWAP_STAGGER, SWAP_SPREAD / nIn);\n  const inAt = SWAP_AT + SINK_LEN - 1;\n  const floor = 0.03 * F;\n  const enterK = (i: number) =>\n    interpolate(t, [enterAt(i), enterAt(i) + ENTER_LEN], [0, 1], {\n      ...CLAMP,\n      easing: EASE_OUT,\n    });\n\n  const clipTopOff = (m.asc + 0.08) * F;\n  const clipBotOff = (Math.max(0.04, m.desc) + 0.06) * F;\n  const clipH = clipTopOff + clipBotOff;\n  const pad = 0.12 * F;\n\n  type Item = {\n    key: string;\n    text: string;\n    x: number;\n    base: number;\n    shift: number;\n    bot: number;\n    lit: boolean;\n  };\n  const items: Item[] = [];\n  const lerp = (a: number, b: number, k: number) => a + (b - a) * k;\n\n  A.forEach((word, i) => {\n    const shared = i < pre || i >= A.length - suf;\n    const e = enterK(i);\n    const a = pa.out[i];\n    if (shared) {\n      const j = i < pre ? i : B.length - (A.length - i);\n      const b = pb.out[j];\n      items.push({\n        key: `s${i}`,\n        text: word,\n        x: lerp(a.x, b.x, glide),\n        base: lerp(baseA(a.line), baseB(b.line), glide),\n        shift: (1 - e) * clipH,\n        bot: lerp(floor, clipBotOff, e * e),\n        lit: false,\n      });\n    } else {\n      const k = i - pre;\n      const out = interpolate(\n        t,\n        [SWAP_AT + k * xs, SWAP_AT + k * xs + SINK_LEN],\n        [0, 1],\n        { ...CLAMP, easing: EASE_SINK },\n      );\n      if (out >= 1) return;\n      const cut = interpolate(\n        t,\n        [SWAP_AT + k * xs, SWAP_AT + k * xs + 2],\n        [0, 1],\n        CLAMP,\n      );\n      items.push({\n        key: `a${i}`,\n        text: word,\n        x: a.x,\n        base: baseA(a.line),\n        shift: (1 - e + out) * clipH,\n        bot: lerp(lerp(floor, clipBotOff, e * e), floor, cut),\n        lit: false,\n      });\n    }\n  });\n  B.forEach((word, j) => {\n    if (j < pre || j >= B.length - suf) return;\n    const k = j - pre;\n    const b = pb.out[j];\n    const inK = interpolate(\n      t,\n      [inAt + k * ns, inAt + k * ns + ROLL_LEN],\n      [0, 1],\n      { ...CLAMP, easing: EASE_OUT },\n    );\n    if (inK <= 0) return;\n    items.push({\n      key: `b${j}`,\n      text: word,\n      x: b.x,\n      base: baseB(b.line),\n      shift: (1 - inK) * clipH,\n      bot: lerp(floor, clipBotOff, inK * inK),\n      lit: true,\n    });\n  });\n\n  const qInkC = (m.q.inkL + m.q.inkR) / 2;\n  const endGlyph = term === \"!\" ? m.bang : m.dot;\n  const endInkC = (endGlyph.inkL + endGlyph.inkR) / 2;\n  const dotR = (m.dotD / 2) * F;\n  const gap = m.dotD * 0.62;\n  const footEm = m.dotD + gap;\n  const sQ = (m.capH - m.stem / 2 - footEm) / -REST_BOX.minY;\n  const shapeC = ((REST_BOX.minX + REST_BOX.maxX) / 2) * sQ * CURL_SX;\n  const footShift = qInkC - shapeC;\n\n  const dotX = lerp(\n    pa.anchor.x + footShift * F,\n    pb.anchor.x + endInkC * F,\n    glide,\n  );\n  const footX = dotX;\n  const baseP = lerp(baseA(pa.anchor.line), baseB(pb.anchor.line), glide);\n\n  const u = interpolate(t, [UNCURL_AT, UNCURL_AT + UNCURL_LEN], [0, 1], {\n    ...CLAMP,\n    easing: EASE_UNCURL,\n  });\n  const r = interpolate(t, [RETRACT_AT, RETRACT_AT + RETRACT_LEN], [0, 1], {\n    ...CLAMP,\n    easing: EASE_SNAP,\n  });\n  const fullLen = SEG_TOTAL * sQ;\n  const stacked = pa.anchor.line > 0 || pb.anchor.line > 0;\n  const topLen = stacked\n    ? Math.min(fullLen, m.asc + 0.02 - footEm - m.stem / 2)\n    : fullLen;\n  const sCurl = sQ * lerp(1, topLen / fullLen, u);\n  const bangLen = m.capH - m.stem / 2 - footEm;\n  const endLen = term === \"!\" ? bangLen : 0;\n  const lenEm = lerp(topLen, endLen, r);\n  const footYem = term === \"!\" ? -footEm : lerp(-footEm, -m.dotD / 2, r);\n  const footY = baseP + footYem * F;\n\n  let d = \"\";\n  if (r <= 0) {\n    const pts = u > 0 ? curl(u) : REST;\n    d = pts\n      .map(\n        ([px, py], i) =>\n          `${i === 0 ? \"M\" : \"L\"}${(footX + px * sCurl * CURL_SX * F).toFixed(2)} ${(footY + py * sCurl * F).toFixed(2)}`,\n      )\n      .join(\" \");\n  } else if (lenEm * F > 0.5) {\n    d = `M${footX.toFixed(2)} ${footY.toFixed(2)} L${footX.toFixed(2)} ${(footY - lenEm * F).toFixed(2)}`;\n  }\n\n  const landed = r >= 1;\n  const pop = landed\n    ? spring({\n        frame: t - RETRACT_AT - RETRACT_LEN,\n        fps,\n        config: { damping: 11, stiffness: 220, mass: 0.6 },\n      })\n    : 0;\n  const popScale = 1 + Math.sin(Math.min(1, pop) * Math.PI) * 0.16;\n  const dotFade = term === \"\" ? 1 - r : 1;\n  const dotColor = interpolate(\n    t,\n    [RETRACT_AT + RETRACT_LEN - 2, RETRACT_AT + RETRACT_LEN + 6],\n    [0, 1],\n    CLAMP,\n  );\n  const lastE = enterK(Math.max(0, A.length - 1) + 1);\n  const pShift = (1 - lastE) * clipH;\n  const pClip = baseA(pa.anchor.line) + clipBotOff;\n\n  return (\n    <AbsoluteFill style={{ background: stage, overflow: \"hidden\" }}>\n      {items.map((it) => (\n        <div\n          key={it.key}\n          style={{\n            position: \"absolute\",\n            left: it.x - pad,\n            top: it.base - clipTopOff,\n            width: wOf(it.text) * F + pad * 2,\n            height: clipTopOff + it.bot,\n            overflow: \"hidden\",\n          }}\n        >\n          <span\n            style={{\n              position: \"absolute\",\n              left: pad,\n              top: clipTopOff - m.baseline * F,\n              height: F,\n              lineHeight: `${F}px`,\n              whiteSpace: \"pre\",\n              fontFamily: barlow.fontFamily,\n              fontWeight: 500,\n              fontSize: F,\n              letterSpacing: `${TRACK}em`,\n              color: it.lit ? spot : c.ink,\n              transform: `translateY(${it.shift.toFixed(2)}px)`,\n            }}\n          >\n            {it.text}\n          </span>\n        </div>\n      ))}\n\n      <svg\n        width={W}\n        height={H}\n        viewBox={`0 0 ${W} ${H}`}\n        style={{ position: \"absolute\", inset: 0 }}\n        aria-hidden\n      >\n        <defs>\n          <clipPath id={clipId}>\n            <rect x={0} y={0} width={W} height={pClip} />\n          </clipPath>\n        </defs>\n        <g clipPath={lastE < 1 ? `url(#${clipId})` : undefined}>\n          <g transform={`translate(0 ${pShift.toFixed(2)})`}>\n            {d ? (\n              <path\n                d={d}\n                fill=\"none\"\n                stroke={c.ink}\n                strokeWidth={m.stem * F}\n                strokeLinecap=\"round\"\n                strokeLinejoin=\"round\"\n              />\n            ) : null}\n            <circle\n              cx={dotX}\n              cy={baseP - dotR}\n              r={dotR * popScale}\n              fill={interpolateHex(c.ink, spot, dotColor)}\n              opacity={dotFade}\n            />\n          </g>\n        </g>\n      </svg>\n    </AbsoluteFill>\n  );\n}\n\nfunction interpolateHex(a: string, b: string, k: number) {\n  const pa = hex(a);\n  const pb = hex(b);\n  if (!pa || !pb) return k < 0.5 ? a : b;\n  const mix = pa.map((v, i) => Math.round(v + (pb[i] - v) * k));\n  return `rgb(${mix[0]}, ${mix[1]}, ${mix[2]})`;\n}\n\nfunction hex(s: string) {\n  const mm = s.trim().match(/^#?([0-9a-f]{6})$/i);\n  if (!mm) return null;\n  const n = Number.parseInt(mm[1], 16);\n  return [(n >> 16) & 255, (n >> 8) & 255, n & 255];\n}\n",
      "type": "registry:component",
      "target": "components/remocn/hook-uncurl.tsx"
    }
  ],
  "type": "registry:component"
}
