{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "less-more",
  "title": "Less More",
  "description": "'Less explaining.' dims while 'More showing.' lands in bright blue below it.",
  "dependencies": ["remotion", "@remotion/google-fonts"],
  "files": [
    {
      "path": "registry/remocn/less-more/index.tsx",
      "content": "\"use client\";\n\nimport { loadFont as loadFigtree } from \"@remotion/google-fonts/Figtree\";\nimport type React from \"react\";\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 figtree = loadFigtree(\"normal\", {\n  weights: [\"500\"],\n  subsets: [\"latin\"],\n});\n\nexport interface LessMoreProps {\n  less?: string;\n  more?: string;\n  accent?: string;\n  background?: string;\n  speed?: number;\n  theme?: \"dark\" | \"light\";\n}\n\nconst SKY = \"#1185FE\";\n\nconst THEMES = {\n  dark: { bg: \"#0B0F14\", ink: \"#F5F8FA\", muted: \"#5F6B7A\" },\n  light: { bg: \"#FFFFFF\", ink: \"#0B0F14\", muted: \"#A3ACB7\" },\n} as const;\n\nconst MEASURE_PX = 200;\n\nconst RISE_AT = 0;\nconst RISE_LEN = 18;\nconst RISE_GAP = 1.2;\nconst RISE_SPAN = 14;\nconst LINE_GAP = 6;\nconst DRAIN_AT = 34;\nconst DRAIN_SPREAD = 30;\nconst DRAIN_LEN = 18;\nconst FILL_AT = 80;\nconst FILL_SPREAD = 12;\nconst FILL_LEN = 12;\n\nconst EASE_OUT = Easing.bezier(0.22, 1, 0.36, 1);\nconst EASE_IO = Easing.bezier(0.65, 0, 0.35, 1);\nconst CLAMP = {\n  extrapolateLeft: \"clamp\",\n  extrapolateRight: \"clamp\",\n} as const;\n\ninterface Metrics {\n  adv: number[][];\n  cap: number;\n}\n\nfunction measure(lines: string[]): Metrics {\n  const host = document.createElement(\"div\");\n  host.style.cssText =\n    \"position:absolute;left:-100000px;top:0;visibility:hidden;white-space:pre;\";\n  document.body.appendChild(host);\n  const make = (text: string) => {\n    const s = document.createElement(\"span\");\n    s.textContent = text;\n    s.style.fontFamily = `\"${figtree.fontFamily}\"`;\n    s.style.fontSize = `${MEASURE_PX}px`;\n    s.style.fontWeight = \"500\";\n    s.style.fontKerning = \"normal\";\n    s.style.display = \"inline-block\";\n    host.appendChild(s);\n    return s;\n  };\n  const jobs = lines.map((line) => {\n    const chars = Array.from(line);\n    return chars.map((ch, i) => {\n      const nx = chars[i + 1];\n      return { pair: make(nx ? ch + nx : ch), next: nx ? make(nx) : null };\n    });\n  });\n  const adv = jobs.map((line) =>\n    line.map(({ pair, next }) => {\n      const w = pair.getBoundingClientRect().width;\n      const n = next ? next.getBoundingClientRect().width : 0;\n      return (w - n) / MEASURE_PX;\n    }),\n  );\n  document.body.removeChild(host);\n  const canvas = document.createElement(\"canvas\");\n  const ctx = canvas.getContext(\"2d\");\n  let cap = 0.7;\n  if (ctx) {\n    ctx.font = `500 ${MEASURE_PX}px \"${figtree.fontFamily}\"`;\n    cap = ctx.measureText(\"H\").actualBoundingBoxAscent / MEASURE_PX;\n  }\n  return { adv, cap };\n}\n\nfunction useMetrics(lines: string[]) {\n  const [metrics, setMetrics] = useState<Metrics | null>(null);\n  const [handle] = useState(() => delayRender(\"less-more: measuring\"));\n  const released = useRef(false);\n  const key = lines.join(\"\\u0000\");\n  useEffect(() => {\n    let live = true;\n    const ls = key.split(\"\\u0000\");\n    Promise.all([\n      figtree.waitUntilDone(),\n      document.fonts.load(`500 100px \"${figtree.fontFamily}\"`),\n    ])\n      .catch(() => undefined)\n      .then(() => {\n        if (!live) return;\n        setMetrics(measure(ls));\n        if (!released.current) {\n          released.current = true;\n          continueRender(handle);\n        }\n      });\n    return () => {\n      live = false;\n    };\n  }, [key, handle]);\n  return metrics;\n}\n\nfunction trackFor(size: number, small: number, big: number) {\n  return interpolate(size, [small, big], [-0.004, -0.04], CLAMP);\n}\n\ninterface Row {\n  group: 0 | 1;\n  from: number;\n  to: number;\n}\n\nfunction splitRows(\n  chars: string[],\n  widthOf: (from: number, to: number) => number,\n  rows: number,\n): Row[] {\n  const spaces = chars.flatMap((ch, i) => (ch === \" \" ? [i] : []));\n  if (rows <= 1 || spaces.length < rows - 1) {\n    return [{ group: 1, from: 0, to: chars.length }];\n  }\n  let best: Row[] = [{ group: 1, from: 0, to: chars.length }];\n  let bestW = Number.POSITIVE_INFINITY;\n  const pick = (start: number, left: number, cuts: number[]) => {\n    if (left === 0) {\n      const out: Row[] = [];\n      let from = 0;\n      for (const cut of cuts) {\n        out.push({ group: 1, from, to: cut });\n        from = cut + 1;\n      }\n      out.push({ group: 1, from, to: chars.length });\n      const w = Math.max(...out.map((r) => widthOf(r.from, r.to)));\n      if (w < bestW) {\n        bestW = w;\n        best = out;\n      }\n      return;\n    }\n    for (let k = start; k < spaces.length; k++) {\n      pick(k + 1, left - 1, [...cuts, spaces[k]]);\n    }\n  };\n  pick(0, rows - 1, []);\n  return best;\n}\n\nexport function LessMore({\n  less = \"Less explaining.\",\n  more = \"More showing.\",\n  accent,\n  background,\n  speed = 1,\n  theme = \"dark\",\n}: LessMoreProps) {\n  const t = useCurrentFrame() * speed;\n  const { width: W, height: H, fps } = useVideoConfig();\n  const c = THEMES[theme];\n  const bg = background ?? c.bg;\n  const sky = accent ?? SKY;\n  const uid = useId().replace(/[^a-zA-Z0-9_-]/g, \"\");\n  const lines = useMemo(() => [less.trim(), more.trim()], [less, more]);\n  const m = useMetrics(lines);\n\n  if (!m) {\n    return <div style={{ position: \"absolute\", inset: 0, background: bg }} />;\n  }\n\n  const portrait = H > W * 1.1;\n  const marginX = W * (portrait ? 0.08 : 0.065);\n  const marginY = portrait ? H * 0.07 : H * 0.1;\n  const measureW = W - marginX * 2;\n  const chars = lines.map((l) => Array.from(l));\n  const n1 = chars[0].length;\n  const n2 = chars[1].length;\n\n  const emRange = (g: number, from: number, to: number, track: number) => {\n    let w = 0;\n    for (let i = from; i < to; i++) w += m.adv[g][i] + track;\n    return w;\n  };\n\n  const fitFor = (rows: Row[]) => {\n    const widest = Math.max(\n      ...rows.map((r) => emRange(1, r.from, r.to, -0.04)),\n    );\n    return Math.min(H * (portrait ? 0.16 : 0.32), measureW / widest);\n  };\n  let moreRows = splitRows(chars[1], (a, b) => emRange(1, a, b, -0.04), 1);\n  let B = fitFor(moreRows);\n  const enough = Math.min(W, H) * (portrait ? 0.24 : 0.2);\n  if (B < enough) {\n    for (const r of [2, 3]) {\n      const rows = splitRows(chars[1], (a, b) => emRange(1, a, b, -0.04), r);\n      const fit = fitFor(rows);\n      if (fit > B * 1.08) {\n        B = fit;\n        moreRows = rows;\n      }\n    }\n  }\n  const S = Math.min(\n    Math.max(B * 0.26, Math.min(W, H) * 0.045),\n    measureW / emRange(0, 0, n1, -0.004),\n  );\n  const fitEqual =\n    measureW /\n    Math.max(\n      emRange(0, 0, n1, -0.025),\n      ...moreRows.map((r) => emRange(1, r.from, r.to, -0.025)),\n    );\n  const M = Math.max(S * 1.4, Math.min(fitEqual, B * 0.62));\n  const rows: Row[] = [{ group: 0, from: 0, to: n1 }, ...moreRows];\n\n  const drainStep = n1 > 1 ? DRAIN_SPREAD / (n1 - 1) : 0;\n  const growStep = n2 > 1 ? DRAIN_SPREAD / (n2 - 1) : 0;\n  const shrink = chars[0].map((_, i) =>\n    interpolate(\n      t,\n      [DRAIN_AT + i * drainStep, DRAIN_AT + i * drainStep + DRAIN_LEN],\n      [0, 1],\n      { ...CLAMP, easing: EASE_IO },\n    ),\n  );\n  const grow = chars[1].map((_, j) =>\n    spring({\n      frame: t - (DRAIN_AT + 3 + j * growStep),\n      fps,\n      config: { damping: 20, stiffness: 120, mass: 0.9 },\n    }),\n  );\n\n  const sizes = [\n    shrink.map((p) => M + (S - M) * p),\n    grow.map((p) => M + (B - M) * p),\n  ];\n  const placed = rows.map((r) => {\n    const xs: number[] = [];\n    let x = 0;\n    let max = 0;\n    for (let i = r.from; i < r.to; i++) {\n      const sz = sizes[r.group][i];\n      xs.push(x);\n      x += m.adv[r.group][i] * sz + trackFor(sz, S, B) * sz;\n      max = Math.max(max, sz);\n    }\n    return { xs, max };\n  });\n  const bases: number[] = [];\n  let y = marginY;\n  placed.forEach((p, k) => {\n    if (k > 0) {\n      const prev = placed[k - 1].max;\n      y += k === 1 ? 0.36 * prev + 0.1 * p.max : 0.24 * prev + 0.02 * p.max;\n    }\n    y += m.cap * p.max;\n    bases.push(y);\n  });\n\n  const riseGap = Math.min(RISE_GAP, RISE_SPAN / Math.max(1, n1, n2));\n  const riseEnd = (row: number, len: number) =>\n    RISE_AT + row * LINE_GAP + (len - 1) * riseGap + RISE_LEN;\n  const rise = (row: number, i: number) =>\n    interpolate(\n      t,\n      [\n        RISE_AT + row * LINE_GAP + i * riseGap,\n        RISE_AT + row * LINE_GAP + i * riseGap + RISE_LEN,\n      ],\n      [0, 1],\n      { ...CLAMP, easing: EASE_OUT },\n    );\n  const fillStep = n2 > 1 ? FILL_SPREAD / (n2 - 1) : 0;\n  const fill = chars[1].map((_, j) =>\n    interpolate(\n      t,\n      [FILL_AT + j * fillStep, FILL_AT + j * fillStep + FILL_LEN],\n      [0, 1],\n      { ...CLAMP, easing: EASE_OUT },\n    ),\n  );\n\n  const glyph = (\n    ch: string,\n    x: number,\n    gy: number,\n    s: number,\n    color: string,\n    key: string,\n    opacity: number,\n  ) =>\n    ch === \" \" || opacity <= 0.001 ? null : (\n      <text\n        key={key}\n        x={x}\n        y={gy}\n        fontSize={s}\n        fill={color}\n        opacity={opacity}\n        style={{\n          fontFamily: figtree.fontFamily,\n          fontWeight: 500,\n          fontKerning: \"none\",\n        }}\n      >\n        {ch}\n      </text>\n    );\n\n  const groups = rows.map((r, k) => {\n    const lay = placed[k];\n    const base = bases[k];\n    const nodes: React.ReactNode[] = [];\n    for (let i = r.from; i < r.to; i++) {\n      const ch = chars[r.group][i];\n      const x = marginX + lay.xs[i - r.from];\n      const sz = sizes[r.group][i];\n      const gy = base + (1 - rise(k, i - r.from)) * M * 1.05;\n      if (r.group === 0) {\n        const mix = interpolate(shrink[i], [0.15, 0.85], [0, 1], CLAMP);\n        nodes.push(glyph(ch, x, gy, sz, c.ink, `i${i}`, 1 - mix));\n        nodes.push(glyph(ch, x, gy, sz, c.muted, `m${i}`, mix));\n      } else {\n        nodes.push(glyph(ch, x, gy, sz, c.ink, `i${i}`, 1 - fill[i]));\n        nodes.push(glyph(ch, x, gy, sz, sky, `f${i}`, fill[i]));\n      }\n    }\n    const masking = t < riseEnd(k, r.to - r.from);\n    const clipId = `${uid}c${k}`;\n    return (\n      <g\n        key={`r${r.group}-${r.from}`}\n        clipPath={masking ? `url(#${clipId})` : undefined}\n      >\n        <defs>\n          <clipPath id={clipId}>\n            <rect\n              x={0}\n              y={base - m.cap * lay.max - 0.36 * lay.max}\n              width={W}\n              height={m.cap * lay.max + 0.7 * lay.max}\n            />\n          </clipPath>\n        </defs>\n        {nodes}\n      </g>\n    );\n  });\n\n  return (\n    <AbsoluteFill style={{ background: bg, overflow: \"hidden\" }}>\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        {groups}\n      </svg>\n    </AbsoluteFill>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/remocn/less-more.tsx"
    }
  ],
  "type": "registry:component"
}
