{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "snap-cn-ui",
  "title": "snapcn UI Core",
  "description": "Shared timeline-fold hook, theme context, and color math for snapcn UI primitives.",
  "dependencies": [
    "remotion",
    "culori"
  ],
  "devDependencies": [
    "@types/culori"
  ],
  "files": [
    {
      "path": "registry/snap-cn-ui/core/timeline.ts",
      "content": "import { useCurrentFrame, useVideoConfig } from \"remotion\";\nimport type { Step } from \"./types\";\n\nexport function framesFor(\n  d: number | { seconds: number },\n  fps: number,\n): number {\n  return typeof d === \"number\" ? d : Math.round(d.seconds * fps);\n}\n\nexport function revealCount(\n  localFrame: number,\n  fps: number,\n  len: number,\n  cps: number,\n): number {\n  const over = (len / cps) * fps;\n  if (over <= 0) return len;\n  return Math.max(0, Math.min(len, Math.floor((localFrame / over) * len)));\n}\n\nexport function clamp01(t: number): number {\n  return Math.max(0, Math.min(1, t));\n}\n\nexport function revealedText(full: string, count: number): string {\n  const c = Math.max(0, Math.min(full.length, Math.floor(count)));\n  return full.slice(0, c);\n}\n\nexport interface TypewriterOptions {\n  cps?: number;\n  speed?: number;\n  startFrame?: number;\n}\n\nexport interface TypewriterState {\n  text: string;\n  count: number;\n  done: boolean;\n  typing: boolean;\n}\n\nexport function useTypewriter(\n  full: string,\n  options: TypewriterOptions = {},\n): TypewriterState {\n  const { cps = 20, speed = 1, startFrame = 0 } = options;\n  const frame = useCurrentFrame();\n  const { fps } = useVideoConfig();\n  const local = frame * speed - startFrame;\n  const count = local <= 0 ? 0 : revealCount(local, fps, full.length, cps);\n  return {\n    text: revealedText(full, count),\n    count,\n    done: count >= full.length,\n    typing: count > 0 && count < full.length,\n  };\n}\n\nexport function useCurrentState<S extends string>(\n  steps: Step<S>[],\n  defaultState: S,\n  speed = 1,\n): S {\n  const effectiveFrame = useCurrentFrame() * speed;\n  let current = defaultState;\n  let bestAt = -Infinity;\n  steps.forEach((step) => {\n    if (step.at <= effectiveFrame && step.at >= bestAt) {\n      bestAt = step.at;\n      current = step.state;\n    }\n  });\n  return current;\n}\n\nexport function useStateTransition<S extends string>(\n  steps: Step<S>[],\n  defaultState: S,\n  speed = 1,\n  defaultDuration = 8,\n): { from: S; to: S; progress: number } {\n  const effectiveFrame = useCurrentFrame() * speed;\n  const started = steps\n    .map((step, index) => ({ step, index }))\n    .sort((a, b) => a.step.at - b.step.at || a.index - b.index)\n    .filter((e) => e.step.at <= effectiveFrame)\n    // Same-`at` ties: the later array entry wins and the earlier tied\n    // entries never display, so they can never act as a `from` state.\n    .filter(\n      (e, i, arr) => i === arr.length - 1 || arr[i + 1].step.at !== e.step.at,\n    );\n  if (started.length === 0)\n    return { from: defaultState, to: defaultState, progress: 1 };\n  const to = started[started.length - 1].step;\n  const from = started.length >= 2 ? started[started.length - 2].step : null;\n  const dur = to.duration ?? defaultDuration;\n  const progress = dur > 0 ? clamp01((effectiveFrame - to.at) / dur) : 1;\n  return { from: from ? from.state : defaultState, to: to.state, progress };\n}\n",
      "type": "registry:lib",
      "target": "lib/snap-cn-ui/timeline.ts"
    },
    {
      "path": "registry/snap-cn-ui/core/theme.ts",
      "content": "\"use client\";\n\nimport type { ReactNode } from \"react\";\nimport { createContext, createElement, useContext } from \"react\";\n\nexport interface SnapCnTheme {\n  background: string;\n  foreground: string;\n  card: string;\n  cardForeground: string;\n  popover: string;\n  popoverForeground: string;\n  primary: string;\n  primaryForeground: string;\n  secondary: string;\n  secondaryForeground: string;\n  muted: string;\n  mutedForeground: string;\n  accent: string;\n  accentForeground: string;\n  destructive: string;\n  destructiveForeground: string;\n  border: string;\n  input: string;\n  ring: string;\n  radius: number;\n}\n\n/**\n * The shadcn token set, as concrete values.\n *\n * ## These are a mirror of `app/globals.css`, and that is the whole point\n *\n * A component we ship lands next to somebody's `Button` and `Input`, and those\n * paint from the CSS custom properties in `globals.css`. A scene that paints\n * from a *different* set of greys is a scene that clashes with the app it was\n * installed into — which is exactly what happened here: this file was written\n * first, `globals.css` was later re-skinned warm, and for a while the site and\n * the videos it sells were two different products.\n *\n * So every value below is the **literal string** from the corresponding\n * `--token` in `app/globals.css` (`:root` for light, `.dark` for dark). Hex, not\n * oklch, purely so that drift is a string comparison — if the two files ever\n * disagree again, `pnpm run check:tokens` says so and prints the pairs.\n *\n * ## Why not just read `var(--token)`?\n *\n * Because a Remotion bundle has none of the app's CSS, so `var()` resolves to\n * nothing under the headless renderer and cannot be interpolated by `mixOklch`\n * at all (see the guard in `color.ts`). Concrete values are not a shortcut here,\n * they are the only thing that survives a render. Users on a different palette\n * override via `SnapCnUIProvider` or a component's `theme` prop.\n *\n * ## `radius` is the one value that flows the other way\n *\n * `globals.css` sets `--radius: 0.28rem` *so that* its `--radius-3xl` step lands\n * on this 10 — see the note beside it there. It is not drift; do not \"fix\" it.\n */\nexport const defaultLightTheme: SnapCnTheme = {\n  background: \"#faf9f6\", // page — warm off-white\n  foreground: \"#141414\", // text\n  card: \"#ffffff\", // surface on the page\n  cardForeground: \"#141414\",\n  popover: \"#ffffff\",\n  popoverForeground: \"#141414\",\n  primary: \"#3072db\", // accent blue\n  primaryForeground: \"#ffffff\",\n  secondary: \"#f2f0eb\", // subtle fill\n  secondaryForeground: \"#141414\",\n  muted: \"#f2f0eb\",\n  mutedForeground: \"#6e6a63\", // secondary text, leading icons\n  accent: \"#f2f0eb\", // hover wash\n  accentForeground: \"#141414\",\n  destructive: \"#d92d20\",\n  // `globals.css` defines no --destructive-foreground; white is the only thing\n  // that reads on #d92d20, and shadcn's own default agrees.\n  destructiveForeground: \"#ffffff\",\n  border: \"#d9d9d9\", // hairline\n  input: \"#d9d9d9\",\n  ring: \"#3072db\",\n  radius: 10,\n};\n\nexport const defaultDarkTheme: SnapCnTheme = {\n  background: \"#0a0a0b\", // page — near-black\n  foreground: \"#fafafa\", // text\n  card: \"#141417\", // surface, a shade above the page\n  cardForeground: \"#fafafa\",\n  popover: \"#141417\",\n  popoverForeground: \"#fafafa\",\n  primary: \"#3072db\", // the same accent blue in both modes\n  primaryForeground: \"#ffffff\",\n  secondary: \"#1d1d21\", // subtle fill\n  secondaryForeground: \"#fafafa\",\n  muted: \"#1b1b1f\",\n  mutedForeground: \"#a1a1aa\", // secondary text, leading icons\n  accent: \"#1d1d21\", // hover wash\n  accentForeground: \"#fafafa\",\n  destructive: \"#f97066\",\n  destructiveForeground: \"#fafafa\",\n  border: \"#26272b\", // hairline — separation is borders, not shadows\n  input: \"#26272b\",\n  ring: \"#3072db\",\n  radius: 10,\n};\n\ninterface SnapCnThemeContextValue {\n  theme?: Partial<SnapCnTheme>;\n  mode?: \"light\" | \"dark\";\n}\n\nconst SnapCnThemeContext = createContext<SnapCnThemeContextValue>({});\n\nexport interface SnapCnUIProviderProps {\n  theme?: Partial<SnapCnTheme>;\n  mode?: \"light\" | \"dark\";\n  children: ReactNode;\n}\n\nexport function SnapCnUIProvider({\n  theme,\n  mode,\n  children,\n}: SnapCnUIProviderProps) {\n  return createElement(\n    SnapCnThemeContext.Provider,\n    { value: { theme, mode } },\n    children,\n  );\n}\n\nexport function useSnapCnTheme(\n  override?: Partial<SnapCnTheme>,\n  modeOverride?: \"light\" | \"dark\",\n): SnapCnTheme {\n  const ctx = useContext(SnapCnThemeContext);\n  const mode = modeOverride ?? ctx.mode ?? \"light\";\n  const base = mode === \"dark\" ? defaultDarkTheme : defaultLightTheme;\n  return { ...base, ...ctx.theme, ...override };\n}\n",
      "type": "registry:lib",
      "target": "lib/snap-cn-ui/theme.ts"
    },
    {
      "path": "registry/snap-cn-ui/core/color.ts",
      "content": "import {\n  clampChroma,\n  converter,\n  formatRgb,\n  interpolate,\n  type Oklch,\n  parse,\n  type Rgb,\n} from \"culori\";\n\nconst toRgb = converter(\"rgb\");\nconst toOklch = converter(\"oklch\");\n\nconst BLACK: Rgb = { mode: \"rgb\", r: 0, g: 0, b: 0, alpha: 1 };\n\nexport function parseColor(c: string): Rgb {\n  const s = c.trim();\n\n  if (s.startsWith(\"var(\")) {\n    if (process.env.NODE_ENV !== \"production\") {\n      console.warn(\n        `[snap-cn-ui] parseColor cannot resolve CSS variable \"${s}\" under Remotion's per-frame render. ` +\n          \"Animated colors must be concrete oklch/hex/rgb values supplied via the theme. \" +\n          \"Falling back to the JS default.\",\n      );\n    }\n    return { ...BLACK };\n  }\n\n  const rgb = toRgb(parse(s));\n  if (!rgb) {\n    if (process.env.NODE_ENV !== \"production\") {\n      console.warn(\n        `[snap-cn-ui] parseColor could not parse \"${s}\"; using black.`,\n      );\n    }\n    return { ...BLACK };\n  }\n\n  return { mode: \"rgb\", r: rgb.r, g: rgb.g, b: rgb.b, alpha: rgb.alpha ?? 1 };\n}\n\nexport function oklchToRgb(l: number, c: number, h: number): Rgb {\n  const mapped = clampChroma({ mode: \"oklch\", l, c, h }, \"oklch\", \"rgb\");\n  return toRgb(mapped);\n}\n\nexport function rgbToOklch(rgb: Rgb): Oklch {\n  const { l, c, h } = toOklch(rgb);\n  return { mode: \"oklch\", l, c, h: Number.isFinite(h) ? h : 0 };\n}\n\nfunction resolveColorString(s: string): string {\n  const trimmed = s.trim();\n  if (trimmed.startsWith(\"var(\")) {\n    if (process.env.NODE_ENV !== \"production\") {\n      console.warn(\n        `[snap-cn-ui] mixOklch cannot resolve CSS variable \"${trimmed}\" under Remotion's per-frame render. ` +\n          \"Animated colors must be concrete oklch/hex/rgb values supplied via the theme. \" +\n          \"Falling back to the JS default.\",\n      );\n    }\n    return \"#000\";\n  }\n  if (!parse(trimmed)) {\n    if (process.env.NODE_ENV !== \"production\") {\n      console.warn(\n        `[snap-cn-ui] mixOklch could not parse \"${trimmed}\"; using black.`,\n      );\n    }\n    return \"#000\";\n  }\n  return trimmed;\n}\n\nexport function mixOklch(a: string, b: string, t: number): string {\n  const mixed = clampChroma(\n    interpolate([resolveColorString(a), resolveColorString(b)], \"oklch\")(t),\n    \"oklch\",\n    \"rgb\",\n  );\n  return toCss(toRgb(mixed));\n}\n\nexport function toCss(color: Rgb): string {\n  return formatRgb(color);\n}\n\n/**\n * A token at partial opacity — for scrims, veils and inner washes.\n *\n * A scrim written as a literal `rgba(0,0,0,0.4)` is lit for a light surface and\n * does nothing on a dark one; `withAlpha(t.foreground, 0.4)` follows the theme\n * and a user's override. Resolves through `parseColor`, so it inherits the same\n * `var()` guard — an unresolvable token degrades to black rather than an\n * invalid colour string mid-render.\n */\nexport function withAlpha(color: string, alpha: number): string {\n  const { r, g, b } = parseColor(color);\n  return formatRgb({ mode: \"rgb\", r, g, b, alpha });\n}\n",
      "type": "registry:lib",
      "target": "lib/snap-cn-ui/color.ts"
    },
    {
      "path": "registry/snap-cn-ui/core/motion.ts",
      "content": "export const easings = {\n  linear: (t: number): number => t,\n  out: (t: number): number => 1 - (1 - t) ** 3,\n  in: (t: number): number => t * t * t,\n  inOut: (t: number): number =>\n    t < 0.5 ? 4 * t * t * t : 1 - (-2 * t + 2) ** 3 / 2,\n} as const;\n\nexport type EasingName = keyof typeof easings;\n\nexport const springs = {\n  snappy: { damping: 18, stiffness: 220, mass: 0.7 },\n  soft: { damping: 14, stiffness: 120, mass: 0.9 },\n  bouncy: { damping: 10, stiffness: 180, mass: 0.8 },\n} as const;\n\nexport type SpringName = keyof typeof springs;\n",
      "type": "registry:lib",
      "target": "lib/snap-cn-ui/motion.ts"
    },
    {
      "path": "registry/snap-cn-ui/core/types.ts",
      "content": "export interface Step<S extends string = string> {\n  at: number;\n  state: S;\n  duration?: number;\n}\n",
      "type": "registry:lib",
      "target": "lib/snap-cn-ui/types.ts"
    },
    {
      "path": "registry/snap-cn-ui/core/index.ts",
      "content": "export {\n  mixOklch,\n  oklchToRgb,\n  parseColor,\n  rgbToOklch,\n  toCss,\n  withAlpha,\n} from \"./color\";\nexport type { EasingName, SpringName } from \"./motion\";\nexport { easings, springs } from \"./motion\";\nexport type { SnapCnTheme, SnapCnUIProviderProps } from \"./theme\";\nexport {\n  defaultDarkTheme,\n  defaultLightTheme,\n  SnapCnUIProvider,\n  useSnapCnTheme,\n} from \"./theme\";\nexport type { TypewriterOptions, TypewriterState } from \"./timeline\";\nexport {\n  clamp01,\n  framesFor,\n  revealCount,\n  revealedText,\n  useCurrentState,\n  useStateTransition,\n  useTypewriter,\n} from \"./timeline\";\nexport type { Step } from \"./types\";\n",
      "type": "registry:lib",
      "target": "lib/snap-cn-ui/index.ts"
    }
  ],
  "docs": "snapcn components paint from a shadcn token set in lib/snap-cn-ui/theme.ts. Its defaults are shadcn's, so they will already sit correctly next to your Button and Input. To point them at your own palette instead, wrap your compositions in <SnapCnUIProvider theme={{ primary: '#...', ... }} mode=\"dark\">, or pass the same `theme`/`mode` props to a single component. Use concrete hex/oklch values, not var(--primary) — a Remotion render has none of your app's CSS.",
  "type": "registry:lib"
}