{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "split-match",
  "title": "Split Match",
  "description": "Split-flap reels lock onto each word in turn, the last one becoming the big logo.",
  "dependencies": ["remotion", "@remotion/google-fonts"],
  "files": [
    {
      "path": "registry/remocn/split-match/index.tsx",
      "content": "\"use client\";\n\nimport { loadFont } from \"@remotion/google-fonts/BricolageGrotesque\";\nimport { loadFont as loadManrope } from \"@remotion/google-fonts/Manrope\";\nimport type { ReactNode } from \"react\";\nimport {\n  useCallback,\n  useEffect,\n  useId,\n  useMemo,\n  useRef,\n  useState,\n} from \"react\";\nimport {\n  AbsoluteFill,\n  continueRender,\n  delayRender,\n  Easing,\n  interpolate,\n  useCurrentFrame,\n  useVideoConfig,\n} from \"remotion\";\n\nconst bricolage = loadFont(\"normal\", {\n  weights: [\"500\"],\n  subsets: [\"latin\"],\n});\nconst manrope = loadManrope(\"normal\", {\n  weights: [\"600\"],\n  subsets: [\"latin\"],\n});\n\nexport interface SplitMatchProps {\n  /**\n   * Words in order; the reels lock on each one, the last one is the poster.\n   * \"remocn\" is set as the real logo (R mark + \"emocn\" in Manrope).\n   */\n  words?: string[];\n  accent?: string;\n  theme?: \"dark\" | \"light\";\n  speed?: number;\n}\n\n// Beat sheet (30fps), four words:\n//  0–10    both reels slam in from opposite sides and lock on word 1\n//  10–26   hold: left half on stage, right half on a gradient panel, one word\n//  26–35   step: left reel climbs, right reel drops, halves blur past each other\n//  ...     two more step + lock beats\n//  85      final lock on the last word\n//  93–109  the seam closes: the gradient floods the left panel, neighbours fall away\n//  109–145 poster hold with a slow push\nconst ENTER = 10;\nconst HOLD = 16;\nconst STEP = 9;\nconst MERGE_DELAY = 8;\nconst MERGE = 16;\nconst TAIL = 36;\n\nexport function splitMatchDuration(wordCount = 4) {\n  return (\n    ENTER +\n    Math.max(0, wordCount - 1) * (HOLD + STEP) +\n    MERGE_DELAY +\n    MERGE +\n    TAIL\n  );\n}\n\nconst WEIGHT = 500;\nconst TRACK = -0.035;\nconst LOGO_TRACK = -0.025;\nconst REMOCN_R_PATH =\n  \"M 0.01 0.81 C 0.01 1.73, 0.36 2.79, 1.09 4.13 C 4.91 11.04, 13.45 17.16, 21.7 18.9 C 22.94 19.16, 23.18 19.16, 51.39 19.27 C 76.07 19.36, 80.02 19.4, 81.32 19.57 C 89.89 20.69, 96.2 24.68, 99.38 31.01 C 103.19 38.56, 102.53 50.1, 97.91 57.07 C 94.66 61.96, 89.68 64.99, 83.26 66 C 82.81 66.07, 70.18 66.83, 55.2 67.69 C 24.82 69.43, 27.03 69.23, 24.18 70.4 C 19.9 72.15, 14.84 75.7, 10.65 79.89 C 4.86 85.68, 1.3 91.91, 0.25 98.13 C 0.12 98.85, 0.08 103.13, 0.04 116.66 L 0 134.26 9.5 134.26 L 19 134.26 19.05 119.41 C 19.1 103.27, 19.08 103.82, 19.82 101.04 C 21.79 93.65, 27.86 88.75, 36.45 87.63 C 37.23 87.53, 39.41 87.5, 43.57 87.53 C 50.12 87.59, 50.75 87.65, 53.22 88.45 C 56.61 89.56, 59.67 91.86, 62.02 95.07 C 62.52 95.76, 69.35 103.85, 77.2 113.07 C 85.04 122.28, 91.63 130.04, 91.85 130.32 C 92.07 130.59, 92.5 131.34, 92.82 131.97 C 93.52 133.35, 94.11 133.93, 95.06 134.13 C 95.5 134.23, 98.97 134.26, 106.36 134.23 L 117.01 134.19 100.82 113.07 C 91.91 101.45, 84.52 91.78, 84.39 91.57 C 83.36 89.89, 83.66 87.53, 85.09 86 C 85.79 85.25, 86.36 84.94, 88.07 84.38 C 96.18 81.72, 104.15 76.62, 109.97 70.36 C 120.59 58.93, 124.06 44.32, 119.44 30.43 C 114.59 15.81, 101.93 4.02, 87.64 0.83 C 83.93 0.01, 88.09 0.08, 41.85 0.04 L 0.01 0 0.01 0.81 Z\";\n/** As on remocn.dev: the mark is 0.76em tall and sits 0.04em before \"emocn\". */\nconst MARK_H = 0.76;\nconst MARK_W = (MARK_H * 124.06) / 134.26;\nconst MARK_GAP = 0.04;\nconst NARROW = \"iljtfr.,'’!:;|\";\n\nconst SNAP = Easing.bezier(0.78, 0, 0.18, 1);\nconst EASE_IO = Easing.bezier(0.65, 0, 0.35, 1);\nconst CLAMP = {\n  extrapolateLeft: \"clamp\",\n  extrapolateRight: \"clamp\",\n} as const;\n\nconst THEMES = {\n  dark: {\n    bg: \"#0A0A0B\",\n    key: \"rgba(255,255,255,0.05)\",\n    fg: \"#F3F2EF\",\n    onAccent: \"#FFFFFF\",\n    dim: 0.26,\n    dimAccent: 0.34,\n    grain: 0.07,\n  },\n  light: {\n    bg: \"#F5F4F0\",\n    key: \"rgba(255,255,255,0.9)\",\n    fg: \"#121214\",\n    onAccent: \"#FFFFFF\",\n    dim: 0.2,\n    dimAccent: 0.34,\n    grain: 0.045,\n  },\n} as const;\n\n/**\n * The panel: gradients.sh \"mesh\" on dark, \"fluted\" on light, with the\n * accent standing in for the palette's core blue. Blobs drift a few percent.\n */\nfunction panelGradient(theme: \"dark\" | \"light\", accent: string, t: number) {\n  const d = Math.sin(t / 40) * 3;\n  const blob = (w: number, h: number, x: number, y: number, c: string) =>\n    `radial-gradient(${w}% ${h}% at ${x}% ${y}%, ${c} 0%, transparent 68%)`;\n  if (theme === \"dark\") {\n    return [\n      blob(46, 52, 88 - d, 12 + d, \"rgba(236,220,242,0.85)\"),\n      blob(80, 90, 76 + d, 30, \"#8796EE\"),\n      blob(90, 95, 40, 70 - d, accent),\n      blob(85, 95, 12 + d, 96, \"#0E1645\"),\n      \"linear-gradient(160deg, #2E45B7, #0A0D24)\",\n    ].join(\", \");\n  }\n  return [\n    blob(30, 36, 92 - d, 6 + d, \"rgba(255,226,196,0.9)\"),\n    blob(56, 64, 90 - d, 12 + d, \"rgba(255,122,61,0.8)\"),\n    blob(80, 90, 70 + d, 40, \"#2F7BFF\"),\n    blob(90, 95, 20, 96 - d, \"#1D2A8F\"),\n    `linear-gradient(165deg, #2F7BFF, ${accent})`,\n  ].join(\", \");\n}\n\nfunction useMeasurer() {\n  const [ready, setReady] = useState(false);\n  const [handle] = useState(() => delayRender(\"split-match: measuring\"));\n  const released = useRef(false);\n  const font = `${WEIGHT} 100px \"${bricolage.fontFamily}\"`;\n  const logoFont = `600 100px \"${manrope.fontFamily}\"`;\n\n  useEffect(() => {\n    let live = true;\n    Promise.all([\n      bricolage.waitUntilDone(),\n      manrope.waitUntilDone(),\n      document.fonts.load(font),\n      document.fonts.load(logoFont),\n    ])\n      .catch(() => undefined)\n      .then(() => {\n        if (!live) return;\n        setReady(true);\n        if (!released.current) {\n          released.current = true;\n          continueRender(handle);\n        }\n      });\n    return () => {\n      live = false;\n    };\n  }, [font, logoFont, handle]);\n\n  const ctx = useMemo(\n    () => (ready ? document.createElement(\"canvas\").getContext(\"2d\") : null),\n    [ready],\n  );\n  const measure = useCallback(\n    (s: string, logo = false) => {\n      if (!ctx) return s.length * 0.55;\n      ctx.font = logo ? logoFont : font;\n      return ctx.measureText(s).width / 100;\n    },\n    [ctx, font, logoFont],\n  );\n  return ready ? measure : null;\n}\n\ntype Measure = (s: string, logo?: boolean) => number;\n\ninterface WordLayout {\n  text: string;\n  /** Set as the real logo: R mark + \"emocn\". */\n  logo: boolean;\n  /** Natural width in em, tracking included. */\n  width: number;\n  /** Where the seam cuts, in em from the start of the word. */\n  cut: number;\n}\n\n/**\n * Picks the letter the seam runs through: a wide one near the middle, so the\n * cut lands inside a bowl or between two stems instead of grazing an \"i\".\n */\nfunction layoutWord(text: string, measure: Measure): WordLayout {\n  const logo = text.trim().toLowerCase() === \"remocn\";\n  if (logo) {\n    // R mark, then \"emocn\" in the logo's Manrope 600; cut through the \"m\".\n    const lead = MARK_W + MARK_GAP;\n    const width = lead + measure(\"emocn\", true) + LOGO_TRACK * 4;\n    const m0 = lead + measure(\"e\", true) + LOGO_TRACK;\n    const cut = m0 + measure(\"m\", true) / 2;\n    return { text: \"emocn\", logo, width, cut };\n  }\n  const chars = [...text];\n  const width = measure(text) + TRACK * chars.length;\n  let best = { score: Number.POSITIVE_INFINITY, cut: width / 2 };\n  chars.forEach((ch, i) => {\n    const adv = measure(ch);\n    const start = measure(chars.slice(0, i + 1).join(\"\")) - adv + TRACK * i;\n    const center = start + adv / 2;\n    const narrow = NARROW.includes(ch) ? 0.4 : 0;\n    const score = Math.abs(center - width / 2) - adv * 0.35 + narrow;\n    if (score < best.score) best = { score, cut: center };\n  });\n  return { text, logo, width, cut: best.cut };\n}\n\nfunction Grain({ opacity }: { opacity: number }) {\n  const id = `g${useId().replace(/[^a-zA-Z0-9_-]/g, \"\")}`;\n  return (\n    <svg\n      width=\"100%\"\n      height=\"100%\"\n      style={{ position: \"absolute\", inset: 0, opacity, pointerEvents: \"none\" }}\n      aria-hidden\n    >\n      <filter id={id}>\n        <feTurbulence\n          type=\"fractalNoise\"\n          baseFrequency=\"0.9\"\n          numOctaves={2}\n          seed={17}\n          stitchTiles=\"stitch\"\n        />\n        <feColorMatrix values=\"0 0 0 0 0.5  0 0 0 0 0.5  0 0 0 0 0.5  0 0 0 0.9 0\" />\n      </filter>\n      <rect width=\"100%\" height=\"100%\" filter={`url(#${id})`} />\n    </svg>\n  );\n}\n\n/** Reel position at frame t: integer = locked on that word. */\nfunction reelAt(t: number, n: number) {\n  if (t < ENTER) {\n    const k = interpolate(t, [-4, ENTER], [0, 1], { ...CLAMP, easing: SNAP });\n    return -1 + k;\n  }\n  let p = 0;\n  for (let i = 1; i < n; i++) {\n    const start = ENTER + (i - 1) * (HOLD + STEP) + HOLD;\n    p += interpolate(t, [start, start + STEP], [0, 1], {\n      ...CLAMP,\n      easing: SNAP,\n    });\n  }\n  return p;\n}\n\nexport function SplitMatch({\n  words = [\"build\", \"ship\", \"launch\", \"remocn\"],\n  accent = \"#3B5BFD\",\n  theme = \"dark\",\n  speed = 1,\n}: SplitMatchProps) {\n  const t = useCurrentFrame() * speed;\n  const { width: W, height: H } = useVideoConfig();\n  const c = THEMES[theme];\n  const uid = useId().replace(/[^a-zA-Z0-9_-]/g, \"\");\n  const measure = useMeasurer();\n\n  const list = words.length ? words : [\"remocn\"];\n  const n = list.length;\n\n  if (!measure) {\n    return <div style={{ position: \"absolute\", inset: 0, background: c.bg }} />;\n  }\n\n  const layouts = list.map((w) => layoutWord(w, measure));\n\n  const widest = Math.max(...layouts.map((l) => l.width));\n  const F = Math.min(H * 0.33, (W * 0.8) / widest);\n  const seam = W / 2;\n  const cy = H / 2;\n  const pitch = H * 0.5;\n  const baseline = cy + F * 0.33;\n\n  const p = reelAt(t, n);\n  const v = (p - reelAt(t - 0.5, n)) * 2 * pitch;\n  const blur = Math.min(Math.abs(v) * 0.15, H * 0.022);\n\n  const lastLock = ENTER + (n - 1) * (HOLD + STEP);\n  const merge = interpolate(\n    t,\n    [lastLock + MERGE_DELAY, lastLock + MERGE_DELAY + MERGE],\n    [0, 1],\n    { ...CLAMP, easing: EASE_IO },\n  );\n  const edge = seam * (1 - merge);\n  const push = interpolate(\n    t,\n    [lastLock, lastLock + MERGE + TAIL + 10],\n    [0, 1],\n    {\n      ...CLAMP,\n      easing: Easing.bezier(0.33, 0, 0.2, 1),\n    },\n  );\n  const scale = 1 + push * 0.035;\n\n  const reel = (dir: 1 | -1, color: string, dim: number) => {\n    const nodes: ReactNode[] = [];\n    for (let j = Math.floor(p) - 2; j <= Math.ceil(p) + 2; j++) {\n      if (j < 0 || j >= n) continue;\n      const d = j - p;\n      if (Math.abs(d) > 1.6) continue;\n      const near = interpolate(Math.abs(d), [0, 1], [1, dim], CLAMP);\n      const opacity =\n        Math.abs(d) < 0.01 ? 1 : near * (Math.abs(d) > 0.5 ? 1 - merge : 1);\n      const l = layouts[j];\n      const x = seam - l.cut * F;\n      const y = baseline + d * pitch * dir;\n      if (l.logo) {\n        const k = (MARK_H * F) / 134.26;\n        nodes.push(\n          <g key={`${j}-logo`} opacity={opacity}>\n            <path\n              d={REMOCN_R_PATH}\n              fill={color}\n              transform={`translate(${x} ${y - MARK_H * F}) scale(${k})`}\n            />\n            <text\n              x={x + (MARK_W + MARK_GAP) * F}\n              y={y}\n              fill={color}\n              fontFamily={manrope.fontFamily}\n              fontWeight={600}\n              fontSize={F}\n              style={{ letterSpacing: `${LOGO_TRACK}em` }}\n            >\n              {l.text}\n            </text>\n          </g>,\n        );\n        continue;\n      }\n      nodes.push(\n        <text\n          key={`${j}-${l.text}`}\n          x={x}\n          y={y}\n          fill={color}\n          opacity={opacity}\n          fontFamily={bricolage.fontFamily}\n          fontWeight={WEIGHT}\n          fontSize={F}\n          style={{ letterSpacing: `${TRACK}em` }}\n        >\n          {l.text}\n        </text>,\n      );\n    }\n    return nodes;\n  };\n\n  const ids = {\n    left: `l${uid}`,\n    right: `r${uid}`,\n    blur: `b${uid}`,\n  };\n  const blurAttr = blur > 0.3 ? `url(#${ids.blur})` : undefined;\n\n  return (\n    <AbsoluteFill style={{ background: c.bg, overflow: \"hidden\" }}>\n      <AbsoluteFill\n        style={{\n          background: `radial-gradient(ellipse 60% 80% at 25% 35%, ${c.key}, transparent 70%)`,\n        }}\n      />\n      <div\n        style={{\n          position: \"absolute\",\n          inset: 0,\n          transform: `scale(${scale})`,\n          transformOrigin: \"50% 50%\",\n        }}\n      >\n        <div\n          style={{\n            position: \"absolute\",\n            inset: `${-H * 0.05}px ${-W * 0.05}px`,\n            background: panelGradient(theme, accent, t),\n            clipPath: `inset(0 0 0 ${edge + W * 0.05}px)`,\n          }}\n        />\n        <svg\n          width={W}\n          height={H}\n          viewBox={`0 0 ${W} ${H}`}\n          style={{ position: \"absolute\", inset: 0, overflow: \"visible\" }}\n          aria-hidden\n        >\n          <defs>\n            <clipPath id={ids.left}>\n              <rect x={-W} y={-H} width={W + edge} height={H * 3} />\n            </clipPath>\n            <clipPath id={ids.right}>\n              <rect x={edge} y={-H} width={W * 2} height={H * 3} />\n            </clipPath>\n            <filter\n              id={ids.blur}\n              filterUnits=\"userSpaceOnUse\"\n              x={-W}\n              y={-H}\n              width={W * 3}\n              height={H * 3}\n            >\n              <feGaussianBlur stdDeviation={`0 ${blur.toFixed(2)}`} />\n            </filter>\n          </defs>\n\n          <g clipPath={`url(#${ids.left})`}>\n            <g filter={blurAttr}>{reel(1, c.fg, c.dim)}</g>\n          </g>\n\n          <g clipPath={`url(#${ids.right})`}>\n            <g filter={blurAttr}>{reel(-1, c.onAccent, c.dimAccent)}</g>\n          </g>\n        </svg>\n      </div>\n      <Grain opacity={c.grain} />\n    </AbsoluteFill>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/remocn/split-match.tsx"
    }
  ],
  "type": "registry:component"
}
