{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "testimonial-stack",
  "title": "Testimonial Stack",
  "description": "A social post card with a highlighted line stacks in with other testimonials.",
  "dependencies": ["remotion", "@remotion/google-fonts"],
  "registryDependencies": ["@remocn/gsh"],
  "files": [
    {
      "path": "registry/remocn/testimonial-stack/index.tsx",
      "content": "\"use client\";\n\nimport { loadFont } from \"@remotion/google-fonts/InstrumentSans\";\nimport { loadFont as loadSerif } from \"@remotion/google-fonts/InstrumentSerif\";\nimport {\n  AbsoluteFill,\n  Easing,\n  interpolate,\n  interpolateColors,\n  spring,\n  useCurrentFrame,\n  useVideoConfig,\n} from \"remotion\";\nimport type { GradientParams } from \"@/lib/remocn/gsh/gradient-engine\";\nimport { GshGround } from \"@/lib/remocn/gsh/gsh-ground\";\nimport type { GshPresetId } from \"@/lib/remocn/gsh/presets\";\n\nconst { fontFamily } = loadFont(\"normal\", {\n  weights: [\"400\"],\n  subsets: [\"latin\"],\n});\nconst { fontFamily: serif } = loadSerif(\"normal\", {\n  weights: [\"400\"],\n  subsets: [\"latin\"],\n});\n\nexport interface TestimonialQuote {\n  text: string;\n  highlight?: string;\n  name: string;\n  role: string;\n  handle?: string;\n  avatarColor?: string;\n  stars?: number;\n  date?: string;\n  source?: \"x\" | \"none\";\n}\n\nexport interface TestimonialStackProps {\n  quotes?: TestimonialQuote[];\n  ending?: \"pile\" | \"fan\";\n  theme?: \"light\" | \"dark\";\n  accent?: string;\n  speed?: number;\n  title?: string;\n  framesPerQuote?: number;\n  ground?: GshPresetId;\n  groundParams?: Partial<GradientParams>;\n}\n\nconst DEFAULT_QUOTES: TestimonialQuote[] = [\n  {\n    text: \"We shipped our launch video in a single afternoon. The motion looks like a design team spent weeks on it.\",\n    highlight: \"looks like a design team spent weeks on it\",\n    name: \"Maya Chen\",\n    role: \"Founder, Kitebase\",\n    handle: \"@mayachen\",\n    date: \"Oct 14\",\n    source: \"x\",\n  },\n  {\n    text: \"Dropped three components into our Remotion project and the demo basically built itself. Owning the code is the right call.\",\n    highlight: \"the demo basically built itself\",\n    name: \"Leo Park\",\n    role: \"Staff Engineer, Lumen\",\n    handle: \"@leopark\",\n    date: \"Oct 15\",\n    source: \"x\",\n  },\n  {\n    text: \"Our launch clip got 3.4× the replies of anything we posted this year. The best tool we added this quarter.\",\n    highlight: \"3.4× the replies\",\n    name: \"Priya Raman\",\n    role: \"Head of Growth, Fernway\",\n    stars: 5,\n    date: \"Oct 16\",\n    source: \"none\",\n  },\n  {\n    text: \"I’m not a motion designer and it doesn’t matter anymore. Every scene is production-ready out of the box.\",\n    highlight: \"production-ready out of the box\",\n    name: \"Daniel Okafor\",\n    role: \"Indie hacker\",\n    handle: \"@danokafor\",\n    date: \"Oct 18\",\n    source: \"x\",\n  },\n];\n\nfunction hash(n: number) {\n  const x = Math.sin(n * 127.1 + 311.7) * 43758.5453;\n  return x - Math.floor(x);\n}\n\nfunction initials(name: string) {\n  return name\n    .split(/\\s+/)\n    .map((p) => p[0] ?? \"\")\n    .slice(0, 2)\n    .join(\"\")\n    .toUpperCase();\n}\n\nconst ENTER = Easing.bezier(0.22, 1, 0.36, 1);\nconst MOVE = Easing.bezier(0.65, 0, 0.35, 1);\nconst CARD = { mass: 0.8, damping: 18, stiffness: 160 };\nconst MORPH = { damping: 200 };\nconst ramp = (f: number, a: number, b: number, ease = ENTER) =>\n  interpolate(f, [a, b], [0, 1], {\n    extrapolateLeft: \"clamp\",\n    extrapolateRight: \"clamp\",\n    easing: ease,\n  });\n\nconst CARD_W = 560;\nconst CARD_H = 252;\nconst RADIUS = 20;\nconst PAD = 30;\n\nconst THEMES = {\n  light: {\n    ground: \"paper-crease\" as GshPresetId,\n    base: \"#EEECE8\",\n    card: \"#FFFFFF\",\n    ink: \"#141416\",\n    body: \"rgba(20,20,22,0.5)\",\n    muted: \"rgba(20,20,22,0.55)\",\n    hairline: \"rgba(20,20,22,0.08)\",\n    avatar: \"#EDEBE6\",\n    avatarInk: \"rgba(20,20,22,0.7)\",\n    star: \"#141416\",\n    starOff: \"rgba(20,20,22,0.12)\",\n    title: \"#141416\",\n    shadow: (air: number) =>\n      `0 0 0 1px rgba(20,20,30,0.06), 0 1px 2px rgba(20,20,30,0.05), 0 ${10 + air * 24}px ${28 + air * 32}px -8px rgba(30,30,60,${0.14 + air * 0.05})`,\n  },\n  dark: {\n    ground: \"charcoal-satin\" as GshPresetId,\n    base: \"#050506\",\n    card: \"#141416\",\n    ink: \"#EDEDEF\",\n    body: \"rgba(237,237,239,0.48)\",\n    muted: \"rgba(237,237,239,0.52)\",\n    hairline: \"rgba(255,255,255,0.07)\",\n    avatar: \"#232327\",\n    avatarInk: \"rgba(237,237,239,0.75)\",\n    star: \"#EDEDEF\",\n    starOff: \"rgba(255,255,255,0.14)\",\n    title: \"#F4F4F5\",\n    shadow: (air: number) =>\n      `inset 0 1px 0 rgba(255,255,255,0.06), 0 0 0 1px rgba(255,255,255,0.08), 0 ${12 + air * 24}px ${36 + air * 32}px -8px rgba(0,0,0,0.7)`,\n  },\n} as const;\n\ntype Palette = (typeof THEMES)[keyof typeof THEMES];\n\nfunction Star({ size, color }: { size: number; color: string }) {\n  return (\n    <svg width={size} height={size} viewBox=\"0 0 24 24\" aria-hidden>\n      <path\n        d=\"M12 2.6l2.85 5.95 6.55.8-4.83 4.5 1.24 6.48L12 17.2l-5.81 3.13 1.24-6.48-4.83-4.5 6.55-.8z\"\n        fill={color}\n      />\n    </svg>\n  );\n}\n\nfunction QuoteCard({\n  quote,\n  lit,\n  c,\n  dim,\n}: {\n  quote: TestimonialQuote;\n  lit: number;\n  c: Palette;\n  dim: number;\n}) {\n  const idx = quote.highlight ? quote.text.indexOf(quote.highlight) : -1;\n  const before = idx >= 0 ? quote.text.slice(0, idx) : quote.text;\n  const marked = idx >= 0 ? (quote.highlight as string) : \"\";\n  const after = idx >= 0 ? quote.text.slice(idx + marked.length) : \"\";\n  const markColor = interpolateColors(lit, [0, 1], [c.body, c.ink]);\n  const sub = quote.handle ? `${quote.handle} · ${quote.role}` : quote.role;\n\n  return (\n    <div\n      style={{\n        width: CARD_W,\n        minHeight: CARD_H,\n        boxSizing: \"border-box\",\n        padding: `${PAD}px ${PAD}px ${PAD - 6}px`,\n        display: \"flex\",\n        flexDirection: \"column\",\n        fontFamily,\n        fontWeight: 400,\n        color: c.ink,\n        WebkitFontSmoothing: \"antialiased\",\n        opacity: 1 - dim * 0.9,\n      }}\n    >\n      {quote.stars ? (\n        <div style={{ display: \"flex\", gap: 3, marginBottom: 14 }}>\n          {[0, 1, 2, 3, 4].map((s) => (\n            <Star\n              key={s}\n              size={17}\n              color={s < (quote.stars ?? 0) ? c.star : c.starOff}\n            />\n          ))}\n        </div>\n      ) : null}\n      <div\n        style={{\n          position: \"relative\",\n          fontSize: 25,\n          lineHeight: 1.36,\n          letterSpacing: \"-0.018em\",\n          color: c.body,\n          textWrap: \"pretty\",\n        }}\n      >\n        <span\n          style={{\n            position: \"absolute\",\n            left: -16,\n            top: -3,\n            fontFamily: serif,\n            fontSize: 40,\n            lineHeight: 1,\n            color: c.muted,\n          }}\n        >\n          “\n        </span>\n        {before}\n        {marked ? <span style={{ color: markColor }}>{marked}</span> : null}\n        {after}”\n      </div>\n\n      <div\n        style={{\n          marginTop: \"auto\",\n          paddingTop: 16,\n          borderTop: `1px solid ${c.hairline}`,\n          display: \"flex\",\n          alignItems: \"center\",\n          gap: 12,\n        }}\n      >\n        <div\n          style={{\n            width: 38,\n            height: 38,\n            borderRadius: 999,\n            flexShrink: 0,\n            background: c.avatar,\n            display: \"flex\",\n            alignItems: \"center\",\n            justifyContent: \"center\",\n            color: c.avatarInk,\n            fontSize: 15,\n            letterSpacing: \"0.02em\",\n          }}\n        >\n          {initials(quote.name)}\n        </div>\n        <div style={{ flex: 1, minWidth: 0 }}>\n          <div style={{ fontSize: 19, lineHeight: 1.25 }}>{quote.name}</div>\n          <div\n            style={{\n              fontSize: 18,\n              lineHeight: 1.3,\n              color: c.muted,\n              whiteSpace: \"nowrap\",\n              overflow: \"hidden\",\n              textOverflow: \"ellipsis\",\n            }}\n          >\n            {sub}\n          </div>\n        </div>\n        {quote.date ? (\n          <div\n            style={{\n              alignSelf: \"flex-start\",\n              fontSize: 18,\n              lineHeight: 1.25,\n              color: c.muted,\n              fontVariantNumeric: \"tabular-nums\",\n              whiteSpace: \"nowrap\",\n            }}\n          >\n            {quote.date}\n          </div>\n        ) : null}\n      </div>\n    </div>\n  );\n}\n\nexport function TestimonialStack({\n  quotes = DEFAULT_QUOTES,\n  ending = \"pile\",\n  title = \"What builders said after launch week\",\n  theme = \"light\",\n  speed = 1,\n  framesPerQuote = 50,\n  ground,\n  groundParams,\n}: TestimonialStackProps) {\n  const rawFrame = useCurrentFrame();\n  const { fps, width, height } = useVideoConfig();\n  const frame = rawFrame * speed;\n  const tall = height > width;\n  const SW = tall ? 700 : 1280;\n  const k = width / SW;\n  const stageH = height / k;\n  const c = THEMES[theme];\n\n  const n = quotes.length;\n  const per = framesPerQuote;\n  const startOf = (i: number) => (i === 0 ? -6 : 4 + i * per);\n\n  const lands = quotes.map((_, i) =>\n    spring({ frame: frame - startOf(i), fps, config: CARD }),\n  );\n  const covers = quotes.map((_, i) =>\n    ramp(frame, startOf(i) + 4, startOf(i) + 16),\n  );\n\n  const fanStart = startOf(n - 1) + per - 6;\n  const cols = tall ? 1 : n <= 3 ? n : Math.ceil(n / 2);\n  const rows = Math.ceil(n / cols);\n  const gap = tall ? 20 : 22;\n  const titleBlock = title ? (tall ? 120 : 64) : 0;\n  const fanScale = Math.min(\n    tall ? 1 : 0.88,\n    (SW - (tall ? 60 : 220) - gap * (cols - 1)) / (cols * CARD_W),\n    (stageH - (tall ? 200 : 70) - titleBlock - gap * (rows - 1)) /\n      (rows * CARD_H),\n  );\n  const cellW = CARD_W * fanScale + gap;\n  const cellH = CARD_H * fanScale + gap;\n  const wallH = titleBlock + rows * cellH - gap;\n  const wallTop = -wallH / 2;\n  const fanOf = (i: number) =>\n    ending === \"fan\"\n      ? spring({\n          frame: frame - fanStart - (n - 1 - i) * 3,\n          fps,\n          config: MORPH,\n          durationInFrames: 22,\n        })\n      : 0;\n  const fanAll =\n    ending === \"fan\"\n      ? spring({\n          frame: frame - fanStart,\n          fps,\n          config: MORPH,\n          durationInFrames: 22 + (n - 1) * 3,\n        })\n      : 0;\n  const titleIn = ramp(frame, fanStart + 10, fanStart + 24);\n\n  const camScale = interpolate(frame, [0, fanStart + 60], [1.02, 1.06], {\n    extrapolateRight: \"clamp\",\n  });\n  const cam = interpolate(fanAll, [0, 1], [camScale, 1]);\n  const pileScale = 1;\n\n  return (\n    <AbsoluteFill style={{ backgroundColor: c.base, overflow: \"hidden\" }}>\n      <GshGround\n        preset={groundParams ? undefined : (ground ?? c.ground)}\n        params={groundParams}\n        timeOffset={theme === \"dark\" ? 6 : 4}\n        timeScale={0.35}\n      />\n      <div\n        style={{\n          position: \"absolute\",\n          left: 0,\n          top: 0,\n          width: SW,\n          height: stageH,\n          transform: `scale(${k})`,\n          transformOrigin: \"0 0\",\n        }}\n      >\n        <div\n          style={{\n            position: \"absolute\",\n            inset: 0,\n            transform: `scale(${cam})`,\n            transformOrigin: \"50% 50%\",\n          }}\n        >\n          {ending === \"fan\" && title ? (\n            <div\n              style={{\n                position: \"absolute\",\n                left: tall ? 30 : 0,\n                right: tall ? 30 : 0,\n                top: stageH / 2 + wallTop,\n                textAlign: \"center\",\n                fontFamily,\n                fontSize: tall ? 44 : 38,\n                lineHeight: 1.1,\n                fontWeight: 400,\n                letterSpacing: \"-0.03em\",\n                textWrap: \"balance\",\n                WebkitFontSmoothing: \"antialiased\",\n                color: c.title,\n                opacity: titleIn,\n                transform: `translateY(${(1 - titleIn) * 10}px)`,\n                filter:\n                  titleIn < 1 ? `blur(${(1 - titleIn) * 4}px)` : undefined,\n              }}\n            >\n              {title}\n            </div>\n          ) : null}\n          {quotes.map((quote, i) => {\n            if (frame < startOf(i)) return null;\n            const p = lands[i];\n            const dx = i % 2 === 0 ? -1 : 1;\n            const finalRot = dx * (1 + hash(i + 1) * 1.8);\n            const jitterX = (hash(i + 7) - 0.5) * (tall ? 20 : 36);\n            const jitterY = (hash(i + 13) - 0.5) * 10;\n\n            let depth = 0;\n            for (let j = i + 1; j < n; j++) depth += covers[j];\n            const visibleDepth = Math.min(depth, 3);\n\n            let x = interpolate(p, [0, 1], [dx * SW * 0.78, jitterX]);\n            let y =\n              interpolate(\n                p,\n                [0, 1],\n                [stageH * 0.58 + hash(i + 3) * 120, jitterY],\n              ) -\n              visibleDepth * 12;\n            let rot = interpolate(p, [0, 1], [finalRot + dx * 12, finalRot]);\n            let scale =\n              interpolate(p, [0, 1], [1.06, 1]) *\n              (1 - visibleDepth * 0.045) *\n              pileScale;\n            let dim = Math.min(1, depth);\n            let opacity = depth > 3 ? Math.max(0, 4 - depth) : 1;\n\n            if (ending === \"fan\") {\n              const f = fanOf(i);\n              const col = i % cols;\n              const r = Math.floor(i / cols);\n              const tx = (col - (cols - 1) / 2) * cellW;\n              const ty =\n                wallTop + titleBlock + r * cellH + (CARD_H * fanScale) / 2;\n              x = interpolate(f, [0, 1], [x, tx]);\n              y = interpolate(f, [0, 1], [y, ty]);\n              rot = interpolate(f, [0, 1], [rot, 0]);\n              scale = interpolate(f, [0, 1], [scale, fanScale]);\n              dim = interpolate(f, [0, 1], [dim, 0]);\n              opacity = interpolate(f, [0, 1], [opacity, 1]);\n            }\n\n            const air = 1 - Math.min(1, p);\n            const lit = ramp(frame - startOf(i), 14, 30, MOVE);\n\n            return (\n              <div\n                key={`${quote.name}-${i}`}\n                style={{\n                  position: \"absolute\",\n                  left: SW / 2,\n                  top: stageH / 2,\n                  transform: `translate(-50%, -50%) translate(${x}px, ${y}px) rotate(${rot}deg) scale(${scale})`,\n                  opacity,\n                  zIndex: i,\n                  borderRadius: RADIUS,\n                  background: c.card,\n                  boxShadow: c.shadow(air),\n                }}\n              >\n                <QuoteCard quote={quote} lit={lit} c={c} dim={dim} />\n              </div>\n            );\n          })}\n        </div>\n      </div>\n    </AbsoluteFill>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/remocn/testimonial-stack.tsx"
    }
  ],
  "type": "registry:component"
}
