{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "status-cycle",
  "title": "Status Cycle",
  "description": "Status badge animation: a pill whose label cycles through states. A centred line whose status pill cycles: the label rolls up behind a hard clip while the pill's width springs past its target and back, and the line re-centres around it. The field then crossfades and a column of chips steps up from below. Copy, palette and every timing are props; the curves are fitted from frame measurements, not eyeballed.",
  "dependencies": [
    "remotion",
    "@remotion/google-fonts"
  ],
  "registryDependencies": [
    "https://snapcn.dev/r/snap-cn-ui.json"
  ],
  "files": [
    {
      "path": "registry/snap-cn/status-cycle/index.tsx",
      "content": "// Status Cycle · snapcn — https://snapcn.dev/docs/scenes/status-cycle\n\"use client\";\n\nimport { loadFont as loadInter } from \"@remotion/google-fonts/Inter\";\nimport { useEffect, useMemo, useRef, useState } from \"react\";\nimport {\n  AbsoluteFill,\n  continueRender,\n  delayRender,\n  Easing,\n  getRemotionEnvironment,\n  interpolate,\n  spring,\n  useCurrentFrame,\n  useVideoConfig,\n} from \"remotion\";\nimport {\n  Item,\n  mixOklch,\n  resolveFont,\n  type SnapCnTheme,\n  useSnapCnTheme,\n} from \"@/lib/snap-cn-ui\";\n\nconst { fontFamily: SANS } = loadInter(\"normal\", {\n  weights: [\"400\"],\n  subsets: [\"latin\"],\n});\n\nconst CLAMP = {\n  extrapolateLeft: \"clamp\",\n  extrapolateRight: \"clamp\",\n} as const;\n\n// ---------------------------------------------------------------------------\n// Proportions\n//\n// Every constant below is a *fraction* of the composition, never a pixel, so\n// the scene holds its shape at 720p, 1080p or a vertical crop. They were\n// measured off a reference recording at 802x450 by sub-pixel edge integration\n// and divided through; the comment on each names what was measured.\n// ---------------------------------------------------------------------------\n\n/**\n * Pill height as a multiple of the act-1 type size. Measured 44.21px against an\n * implied 39.2px em, both at 450.\n *\n * Keyed to the type rather than to the frame so that when a long status forces\n * the line to shrink, the pill shrinks with it instead of turning into a slab\n * with a small word floating in it.\n */\nconst PILL_H = 1.128;\n/** Pill centre, as a fraction of frame height. The lockup rides 1.6% low. */\nconst PILL_CY = 0.5164;\n/**\n * Corner radius as a fraction of the pill's own height.\n *\n * Not a stadium. Fitting a circle to the sub-pixel left edge over the pill's\n * top 12 rows gives R/h = 0.222–0.235 at rms 0.07–0.17px; R = h/2 fits the same\n * rows at rms 6.9–7.7px, two orders of magnitude worse. A stadium here reads as\n * a completely different object — it is the single easiest detail to get wrong.\n */\nconst PILL_R = 0.229;\n/** Pill padding either side of the label, as a fraction of pill height. */\nconst PILL_PAD = 0.19;\n/** Prefix-to-pill gap, as a fraction of pill height. Measured 6.5px of 44.2. */\nconst PILL_GAP = 0.147;\n/** Act-1 type size, as a fraction of frame height. Cap 29px / 0.72em of 450. */\nconst ACT1_EM = 0.087;\n/** Act-2 type size, as a fraction of frame height. Cap 72px / 0.72em of 450. */\nconst ACT2_EM = 0.222;\n/** Chip padding, as a fraction of its own type size. */\nconst CHIP_PAD_X = 0.42;\nconst CHIP_PAD_Y = 0.165;\n/** Chip corner radius and hairline, as fractions of chip height. */\nconst CHIP_R = 0.162;\nconst CHIP_BORDER = 0.017;\n/** Chip margin from the frame edge, as a fraction of frame width. */\nconst CHIP_INSET = 0.04;\n/** Share of the frame width the act-1 lockup may occupy before it is scaled. */\nconst ACT1_FIT = 0.92;\n/** Gap between stacked chips, as a fraction of frame height. */\nconst CHIP_GAP = 0.057;\n\n/**\n * The width morph is a **cubic-bezier, not a spring** — the `y2 > 1` control\n * value is the overshoot.\n *\n * This is not a stylistic preference. Fitting Remotion's `spring()` over a full\n * (damping, stiffness, rest) grid lands at rms 19.6px on a 207px travel, and the\n * failure is structural: a spring released from rest is at pi^2/242 = 4.1% of\n * travel one frame into an eleven-frame rise, and the reference is at 0.65%. No\n * triple is six times flatter at the start and still peaks that early. Fitted\n * per morph the reference gives (0.6,0,0.3,1.8) and (0.7,0.4,0.3,1.3); this is\n * their average, at rms 1.1–1.7% of travel.\n */\nconst WIDTH_EASE = Easing.bezier(0.65, 0.2, 0.3, 1.5);\n/** Seconds the width morph runs. Measured 15 and 14 frames on a 24fps clock. */\nconst WIDTH_SECONDS = 0.6;\n\n/**\n * The label roll: a symmetric ease-in-out — deliberately gentler and longer than\n * the reference.\n *\n * The reference rolls in 8 frames of 24fps on (0.75, 0, 0.25, 1) (measured\n * progress 0.010, 0.053, 0.164, 0.499, 0.833, 0.946, 0.990, 1.000). Rendered at\n * 30fps that is 10 frames whose middle two each move a third of the travel —\n * 32px a frame at 1080p — so the frame between them shows two half-cut labels and\n * the swap reads as a flicker, not a roll. Reported as \"flipping too fast\".\n *\n * (0.45, 0, 0.55, 1) over 0.5s is 15 frames at 30: no step above 13px at 1080p,\n * none below 0.8px (so no frozen frames in the tails, motion-quality rule 0).\n * 0.6s was tried first and read as too slow; 0.5s is the step between the two.\n * The label lands three frames before the width morph finishes its overshoot.\n */\nconst ROLL_EASE = Easing.bezier(0.45, 0, 0.55, 1);\n/** Seconds the roll runs. See ROLL_EASE for why it is not the reference's 1/3. */\nconst ROLL_SECONDS = 0.5;\n/** Roll travel, as a multiple of pill height. Measured 47.1px on a 46.2 pill. */\nconst ROLL_TRAVEL = 1.02;\n\n/** Seconds the green-to-cream crossfade takes. Measured 18 frames of 60fps. */\nconst FADE_SECONDS = 0.3;\n/** Seconds the lockup takes to leave upward. Measured 0.19s, exponential. */\nconst EXIT_SECONDS = 0.2;\n/** Time constant of the act-2 scroll step, in seconds. Fitted 1-exp(-t/tau). */\nconst SCROLL_TAU = 0.06;\n\n/**\n * The per-letter intro scale.\n *\n * Measured peak is 1.45x the settled size, and a spring's overshoot is\n * `exp(-pi*zeta/sqrt(1-zeta^2))` — so zeta = 0.25, which at stiffness 100 and\n * mass 1 is damping 5. That is where these numbers come from; they are not a\n * taste setting, and changing the damping moves the peak off the reference.\n */\nconst LETTER_SPRING = { damping: 5, stiffness: 100, mass: 1 } as const;\n\n/**\n * Pure scheduling and fitting math. Everything above the component is\n * frame-deterministic and side-effect free so it can be unit tested.\n */\n\n/** Accepts an array or the comma-separated string the customizer passes. */\nexport function toList(value?: string[] | string): string[] {\n  const list = typeof value === \"string\" ? value.split(\",\") : (value ?? []);\n  return list.map((s) => s.trim()).filter(Boolean);\n}\n\n/**\n * Exponential ease-out, in frames.\n *\n * The reference's scroll step is not any fixed-duration curve: easeOutCubic\n * predicts 0.977 at t=0.167s against a measured 0.918, and quint/expo are far\n * too fast at the head. `1 - exp(-t/tau)` fits every sample of every step at\n * tau = 0.060s, which is what a lerp-toward-target does, and it never overshoots\n * — the reference's peak progress is exactly 1.000 at every step.\n */\nexport function settle(frames: number, tau: number): number {\n  if (frames <= 0) return 0;\n  return 1 - Math.exp(-frames / tau);\n}\n\n/**\n * Which status is up at `frame`, and how many frames since it arrived.\n *\n * The index only ever advances, and `since` is relative to the swap that put\n * the current label up — so a swap still settling when the next one starts\n * hands over cleanly instead of compounding.\n */\nexport function statusAt({\n  frame,\n  introFrames,\n  statusHold,\n  count,\n}: {\n  frame: number;\n  introFrames: number;\n  statusHold: number;\n  count: number;\n}): { index: number; since: number } {\n  if (count <= 0) return { index: 0, since: 0 };\n  let index = 0;\n  for (let i = 1; i < count; i += 1) {\n    if (frame >= introFrames + (i - 1) * statusHold) index = i;\n  }\n  return { index, since: frame - (introFrames + (index - 1) * statusHold) };\n}\n\n/**\n * How many chips have arrived at `frame`, and how far the step they triggered\n * has run. Clamped to `count`, so the field stops stepping once the last chip\n * is in rather than scrolling the whole column off the top.\n */\nexport function chipAt({\n  frame,\n  beat,\n  stagger,\n  count,\n}: {\n  frame: number;\n  beat: number;\n  stagger: number;\n  count: number;\n}): { arrived: number; since: number } {\n  if (count <= 0 || frame < beat) return { arrived: 0, since: 0 };\n  const arrived = Math.min(count, Math.floor((frame - beat) / stagger) + 1);\n  return { arrived, since: frame - beat - (arrived - 1) * stagger };\n}\n\n/**\n * How much a measured line has to shrink to sit inside its budget.\n *\n * Only ever <= 1. Scaling *up* to fill the budget would make a two-word status\n * larger than a five-word one, which is the opposite of a design system.\n */\nexport function fitScale(measured: number, budget: number): number {\n  if (!(measured > 0) || !(budget > 0)) return 1;\n  return Math.min(1, budget / measured);\n}\n\nexport interface StatusCycleMotion {\n  /** Easing of the pill's width. `y2 > 1` on a bezier is what gives overshoot. */\n  widthEase: (t: number) => number;\n  /** Seconds the width morph runs. */\n  widthSeconds: number;\n  /** Easing of the label roll. */\n  rollEase: (t: number) => number;\n  /** Seconds the roll runs. */\n  rollSeconds: number;\n  /** Roll travel, as a multiple of pill height. */\n  rollTravel: number;\n  /** Seconds the field crossfade takes. */\n  fadeSeconds: number;\n  /** Seconds the lockup takes to leave upward. */\n  exitSeconds: number;\n  /** Time constant of an act-2 scroll step, in seconds. */\n  scrollTau: number;\n  /** Spring the intro's per-letter scale runs on. Damping sets the peak. */\n  letterSpring: { damping: number; stiffness: number; mass: number };\n  /** Frames between one letter's entry and the next. */\n  letterStagger: number;\n}\n\n/**\n * Measured defaults. Every one but the roll is fitted from frame data — see the\n * constant it reads from — so overriding one is a deliberate departure, not a\n * tweak. The roll departs from its reference on purpose; ROLL_EASE says why.\n */\nexport const STATUS_CYCLE_MOTION: StatusCycleMotion = {\n  widthEase: WIDTH_EASE,\n  widthSeconds: WIDTH_SECONDS,\n  rollEase: ROLL_EASE,\n  rollSeconds: ROLL_SECONDS,\n  rollTravel: ROLL_TRAVEL,\n  fadeSeconds: FADE_SECONDS,\n  exitSeconds: EXIT_SECONDS,\n  scrollTau: SCROLL_TAU,\n  letterSpring: LETTER_SPRING,\n  letterStagger: 1,\n};\n\nexport interface StatusCycleProps {\n  /** The line that does not change, to the left of the pill. */\n  prefix?: string;\n  /** Pill labels, in order. Also accepts a comma-separated string. */\n  statuses?: string[] | string;\n  /** Act-2 chip labels, in order. Also accepts a comma-separated string. */\n  chips?: string[] | string;\n  /** Act-1 field. Defaults to the design system's `primary`. */\n  fieldColor?: string;\n  /** Act-2 field. Defaults to the design system's `background`. */\n  pageColor?: string;\n  /** Pill fill. Defaults to `foreground`. */\n  pillColor?: string;\n  /** Pill label. Defaults to a pale tint of the field. */\n  pillLabelColor?: string;\n  /** Prefix ink. Defaults to `foreground`. */\n  prefixColor?: string;\n  /**\n   * Chip fills, cycled in order. The reference runs a period-3 cycle — a card,\n   * a pale tint, a saturated one — and that is what the default builds from the\n   * theme. Also accepts a comma-separated string.\n   */\n  chipFills?: string[] | string;\n  /** Chip ink. One colour on every chip, including the saturated one. */\n  chipTextColor?: string;\n  /** Chip hairline. Defaults to the design system's border, scaled for a\n   * surface this size (`mixOklch(border, foreground, 0.28)`). */\n  chipBorderColor?: string;\n  /** Act-1 type weight. */\n  fontWeight?: number;\n  /** Act-2 type weight. Measured 400 in the reference — the chips are bigger,\n   * not heavier, and bumping this is the usual way to make them look cheap. */\n  chipFontWeight?: number;\n  /**\n   * Pill corner radius in px. Defaults to 0.229 x pill height — which is very\n   * nearly shadcn's own `radius` token over a 40px control (10/40 = 0.25), so\n   * the default already sits in the design system rather than beside it. Pass\n   * `theme.radius` scaled to your own control size to match exactly.\n   */\n  pillRadius?: number;\n  /** Chip corner radius in px. Defaults to 0.162 x chip height. */\n  chipRadius?: number;\n  /** Chip hairline width in px. Defaults to 0.017 x chip height. */\n  chipBorderWidth?: number;\n  /** Pill centre, as a fraction of frame height. The reference rides 1.6% low. */\n  pillCenterY?: number;\n  /** Curve and timing overrides. Merged over {@link STATUS_CYCLE_MOTION}. */\n  motion?: Partial<StatusCycleMotion>;\n  /** Act-1 type size in px. Defaults to 8.7% of the composition height. */\n  fontSize?: number;\n  /** Act-2 type size in px. Defaults to 22.2% of the composition height. */\n  chipFontSize?: number;\n  /** Font stack. Defaults to Inter, loaded through `@remotion/google-fonts`. */\n  fontFamily?: string;\n  /**\n   * Frames from one status swap to the next. Keep it above the roll (0.5s, 15\n   * frames at 30fps) plus time to read the label: at or below the roll, the pill\n   * never rests and every label is read mid-flight.\n   */\n  statusHold?: number;\n  /** Frames the intro cascade is given before the first swap. */\n  introFrames?: number;\n  /** Frames from one chip's arrival to the next. */\n  chipStagger?: number;\n  /** Frame the scene starts on. */\n  startAt?: number;\n  /** Playback rate. */\n  speed?: number;\n  /** Design-system token overrides. */\n  theme?: Partial<SnapCnTheme>;\n  mode?: \"light\" | \"dark\";\n}\n\ninterface Metrics {\n  /** Left offset and width of every prefix glyph, in layout px. */\n  letters: { left: number; width: number }[];\n  prefixWidth: number;\n  /** Distance from the prefix box's top to its baseline. */\n  baseline: number;\n  statusWidths: number[];\n  chipWidths: number[];\n}\n\nexport function StatusCycle({\n  prefix = \"snapcn is\",\n  statuses = [\"animating\", \"transitioning\", \"rendering a scene\", \"installed\"],\n  chips = [\n    \"text-reveal\",\n    \"phone-frame\",\n    \"answer-stream\",\n    \"word-flip\",\n    \"orbit-gallery\",\n  ],\n  fieldColor,\n  pageColor,\n  pillColor,\n  pillLabelColor,\n  prefixColor,\n  chipFills,\n  chipTextColor,\n  chipBorderColor,\n  fontWeight = 400,\n  chipFontWeight = 400,\n  pillRadius,\n  chipRadius,\n  chipBorderWidth,\n  pillCenterY = PILL_CY,\n  motion,\n  fontSize,\n  chipFontSize,\n  fontFamily,\n  statusHold = 30,\n  introFrames = 24,\n  chipStagger = 8,\n  startAt = 0,\n  speed = 1,\n  theme,\n  mode,\n}: StatusCycleProps) {\n  const frame = useCurrentFrame() * speed - startAt;\n  const { fps, width, height } = useVideoConfig();\n  const t = useSnapCnTheme(theme, mode);\n  const face = resolveFont(fontFamily ?? t.fontFamily) ?? SANS;\n\n  // Memoised because they are `useEffect` dependencies: rebuilt every render,\n  // the measuring effect would re-run forever.\n  const labels = useMemo(() => toList(statuses), [statuses]);\n  const chipLabels = useMemo(() => toList(chips), [chips]);\n  const glyphs = useMemo(() => [...prefix], [prefix]);\n\n  const field = fieldColor ?? t.primary;\n  const page = pageColor ?? t.background;\n  const pillFill = pillColor ?? t.foreground;\n  const prefixInk = prefixColor ?? t.foreground;\n  // A pale tint of the field: the reference's label is its own brand colour\n  // lightened, and this reproduces that relationship in whatever palette the\n  // component lands in.\n  //\n  // Mixed toward `card`, NOT `background`, and that is load-bearing. `mixOklch`\n  // interpolates hue the short way round, and `background` (#faf9f6) is a *warm*\n  // off-white with a real hue near 90 degrees — so a mix from a 264-degree blue\n  // sweeps 174 degrees through green and lands on rgb(164,213,180). `card` is\n  // achromatic, its hue is undefined, and the blue's hue is carried through\n  // unchanged: rgb(178,205,247), the pale blue anyone would expect.\n  const labelInk = pillLabelColor ?? mixOklch(field, t.card, 0.62);\n  const chipInk = chipTextColor ?? t.foreground;\n  // One hairline on every chip, from the system's own recipe for scaling a\n  // border token up to a surface this size (design-system rule 3b) — not a\n  // per-fill blend, because the reference likewise inks every chip the same.\n  const chipEdge = chipBorderColor ?? mixOklch(t.border, t.foreground, 0.28);\n  const m = { ...STATUS_CYCLE_MOTION, ...motion };\n  const fills = toList(chipFills);\n  const fillCycle =\n    fills.length > 0 ? fills : [t.card, mixOklch(t.card, field, 0.14), field];\n\n  const nominalEm = fontSize ?? ACT1_EM * height;\n  const nominalChipEm = chipFontSize ?? ACT2_EM * height;\n\n  // -------------------------------------------------------------------------\n  // Measure. `offsetWidth`/`offsetLeft` are layout px, untouched by the\n  // transforms this scene is about to apply, which is exactly what makes them\n  // the right tool. Measured in a hidden, untransformed copy so frame 0 can\n  // never be captured against the wrong geometry.\n  // -------------------------------------------------------------------------\n  const [metrics, setMetrics] = useState<Metrics | null>(null);\n  const [handle] = useState(() => delayRender(\"status-cycle: measuring\"));\n  const letterRefs = useRef<(HTMLSpanElement | null)[]>([]);\n  const prefixRef = useRef<HTMLSpanElement | null>(null);\n  const baselineRef = useRef<HTMLSpanElement | null>(null);\n  const statusRefs = useRef<(HTMLSpanElement | null)[]>([]);\n  const chipRefs = useRef<(HTMLSpanElement | null)[]>([]);\n\n  useEffect(() => {\n    if (metrics) {\n      continueRender(handle);\n      return;\n    }\n    const prefixWidth = prefixRef.current?.offsetWidth ?? 0;\n    if (prefixWidth <= 0) return;\n    setMetrics({\n      letters: glyphs.map((_, i) => ({\n        left: letterRefs.current[i]?.offsetLeft ?? 0,\n        width: letterRefs.current[i]?.offsetWidth ?? 0,\n      })),\n      prefixWidth,\n      baseline: baselineRef.current?.offsetTop ?? 0,\n      statusWidths: labels.map(\n        (_, i) => statusRefs.current[i]?.offsetWidth ?? 0,\n      ),\n      chipWidths: chipLabels.map(\n        (_, i) => chipRefs.current[i]?.offsetWidth ?? 0,\n      ),\n    });\n  }, [metrics, handle, glyphs, labels, chipLabels]);\n\n  // -------------------------------------------------------------------------\n  // Timeline\n  // -------------------------------------------------------------------------\n  const swapAt = (i: number) => introFrames + (i - 1) * statusHold;\n  const act1End = swapAt(labels.length) + statusHold;\n  const fadeStart = act1End;\n  const fadeFrames = m.fadeSeconds * fps;\n  // The three events are one beat, not three. In the reference the lockup leaves\n  // and the first chip arrives on the same frame, and that frame is the exact\n  // midpoint of the crossfade — colour starts moving, the swap happens under it,\n  // colour finishes. Splitting them reads as three separate cuts.\n  const beat = fadeStart + fadeFrames / 2;\n\n  // Two opaque layers with an opacity ramp, not `mixOklch` between the two.\n  // The reference's own crossfade does look like an OKLCH mix — its R channel\n  // runs 2-5 points behind G and B, which an sRGB lerp cannot do — but its two\n  // colours are a green and a cream, 30 degrees of hue apart. Ours are whatever\n  // the theme says, and `primary` to `background` is 174 degrees, which OKLCH\n  // walks the short way *through green*. A layered crossfade is what the word\n  // means and it cannot swing.\n  const fadeP = interpolate(\n    frame,\n    [fadeStart, fadeStart + fadeFrames],\n    [0, 1],\n    CLAMP,\n  );\n\n  // -------------------------------------------------------------------------\n  // Fit\n  //\n  // A size measured off one sentence is a size that clips the next one. Both\n  // acts are measured at their nominal type size and then scaled down — never\n  // up — by however much the widest line overruns its share of the frame. Text\n  // width is linear in font size, so the measured widths scale with the same\n  // factor and no second measuring pass is needed.\n  // -------------------------------------------------------------------------\n  const rawPad = PILL_PAD * PILL_H * nominalEm;\n  const rawGap = PILL_GAP * PILL_H * nominalEm;\n  const rawLockup =\n    (metrics?.prefixWidth ?? 0) +\n    rawGap +\n    Math.max(0, ...(metrics?.statusWidths ?? [0])) +\n    2 * rawPad;\n  const act1Fit = metrics ? fitScale(rawLockup, ACT1_FIT * width) : 1;\n\n  const rawChip =\n    Math.max(0, ...(metrics?.chipWidths ?? [0])) +\n    2 * CHIP_PAD_X * nominalChipEm;\n  const chipFit = metrics ? fitScale(rawChip, (1 - 2 * CHIP_INSET) * width) : 1;\n\n  const em = nominalEm * act1Fit;\n  const chipEm = nominalChipEm * chipFit;\n  const pillH = PILL_H * em;\n  const pillPad = PILL_PAD * pillH;\n  const gap = PILL_GAP * pillH;\n  const prefixWidth = (metrics?.prefixWidth ?? 0) * act1Fit;\n\n  const pillWidths = (metrics?.statusWidths ?? labels.map(() => 0)).map(\n    (w) => w * act1Fit + 2 * pillPad,\n  );\n\n  /** Which label is up, and how far the swap into it has progressed. */\n  const widthSeconds = m.widthSeconds * fps;\n  const rollSeconds = m.rollSeconds * fps;\n  const { index, since } = statusAt({\n    frame,\n    introFrames,\n    statusHold,\n    count: labels.length,\n  });\n  // Index 0 opens from nothing rather than cutting in at full width. The tape\n  // cannot settle this — its first recorded frame already has a pill, and the\n  // reference's own calling->typing morph is arithmetically inseparable from\n  // the intro's tail — so this runs the same curve the other morphs run, over\n  // the intro, which is the least invented thing available.\n  const widthP =\n    index === 0\n      ? m.widthEase(Math.min(1, Math.max(0, frame / introFrames)))\n      : m.widthEase(Math.min(1, Math.max(0, since / widthSeconds)));\n  const rollP =\n    index === 0 ? 1 : m.rollEase(Math.min(1, Math.max(0, since / rollSeconds)));\n\n  const pillW = interpolate(\n    widthP,\n    [0, 1],\n    [index === 0 ? 0 : (pillWidths[index - 1] ?? 0), pillWidths[index] ?? 0],\n  );\n\n  // The whole lockup arrives on one slow scale settle. The reference's is still\n  // drifting -7% two seconds in; this one lands, because a settle nobody can see\n  // is frames spent on travel that only costs sharpness.\n  const groupScale = spring({\n    frame,\n    fps,\n    config: { damping: 26, stiffness: 90, mass: 1.4 },\n    from: 1.16,\n    to: 1,\n  });\n\n  const exitP = settle((frame - beat) * (1 / m.exitSeconds / fps), 0.28);\n  const exitY = frame < beat ? 0 : -exitP * 0.62 * height;\n\n  const total = prefixWidth + gap + pillW;\n  const originX = (width - total) / 2;\n  const cy = pillCenterY * height;\n\n  // Only the Player gets `will-change`: a render is spread across parallel tabs\n  // and each inherits a stale raster, so the same style comes out as several\n  // different rasterisations and the type shimmers while standing still.\n  const promote = getRemotionEnvironment().isRendering\n    ? null\n    : ({ willChange: \"transform\" } as const);\n\n  const typeStyle = {\n    fontFamily: face,\n    fontWeight,\n    // Hinting re-snaps every stem as the size slides, so the letterforms boil.\n    // Off, shape drift over a scale falls from 3.41% to 0.22%.\n    textRendering: \"geometricPrecision\" as const,\n    whiteSpace: \"nowrap\" as const,\n  };\n\n  return (\n    <AbsoluteFill style={{ backgroundColor: page, overflow: \"hidden\" }}>\n      <AbsoluteFill style={{ backgroundColor: field, opacity: 1 - fadeP }} />\n      {metrics ? null : (\n        <Measuring\n          glyphs={glyphs}\n          labels={labels}\n          chipLabels={chipLabels}\n          em={em}\n          chipEm={chipEm}\n          typeStyle={typeStyle}\n          letterRefs={letterRefs}\n          prefixRef={prefixRef}\n          baselineRef={baselineRef}\n          statusRefs={statusRefs}\n          chipRefs={chipRefs}\n        />\n      )}\n\n      {metrics && frame < beat + m.exitSeconds * fps ? (\n        <div\n          style={{\n            position: \"absolute\",\n            left: 0,\n            top: 0,\n            transform: `translateY(${exitY}px) scale(${groupScale})`,\n            transformOrigin: `${width / 2}px ${cy}px`,\n            width,\n            height,\n            ...promote,\n          }}\n        >\n          <Prefix\n            glyphs={glyphs}\n            metrics={metrics}\n            frame={frame}\n            fps={fps}\n            em={em}\n            fit={act1Fit}\n            spring={m.letterSpring}\n            stagger={m.letterStagger}\n            x={originX}\n            cy={cy}\n            color={prefixInk}\n            typeStyle={typeStyle}\n          />\n          <Pill\n            labels={labels}\n            index={index}\n            rollP={rollP}\n            x={originX + prefixWidth + gap}\n            cy={cy}\n            width={pillW}\n            height={pillH}\n            em={em}\n            fill={pillFill}\n            ink={labelInk}\n            radius={pillRadius ?? PILL_R * pillH}\n            travel={m.rollTravel * pillH}\n            typeStyle={typeStyle}\n          />\n        </div>\n      ) : null}\n\n      {metrics ? (\n        <Chips\n          labels={chipLabels}\n          widths={metrics.chipWidths.map((w) => w * chipFit)}\n          frame={frame}\n          fps={fps}\n          beat={beat}\n          stagger={chipStagger}\n          em={chipEm}\n          width={width}\n          height={height}\n          fills={fillCycle}\n          ink={chipInk}\n          edge={chipEdge}\n          radius={chipRadius}\n          borderWidth={chipBorderWidth}\n          weight={chipFontWeight}\n          tau={m.scrollTau}\n          typeStyle={typeStyle}\n        />\n      ) : null}\n    </AbsoluteFill>\n  );\n}\n\n/**\n * The intro cascade: every glyph sits at its **final advance** and scales about\n * its own baseline, staggered left to right.\n *\n * Measured, not guessed. Tracking one 'o' by its counter gives 1.01x → 1.45x →\n * 1.00x while its stroke-to-height ratio holds at 0.149 ± 0.006 — so nothing is\n * animating a weight or width axis, it is a plain scale, and the letters only\n * *look* heavier early because at 1.45x they overlap their neighbours' fixed\n * slots. The prefix's total ink span is unchanged from the first frame to the\n * last, which is only possible if the layout is static and the transform is\n * per-glyph.\n *\n * The one deliberate departure from the reference is the pivot. The reference\n * scales each letter about its centre, which drags the baseline; a moving\n * baseline climbs the pixel grid in whole-pixel jumps, because the rasteriser\n * has no vertical sub-pixel positioning at all. Pivoting on the baseline takes\n * judder from 0.284px to 0.014px and direction reversals from 29 to 0, and it\n * costs a downward growth nobody can name.\n */\nfunction Prefix({\n  glyphs,\n  metrics,\n  frame,\n  fps,\n  em,\n  fit,\n  spring: springConfig,\n  stagger,\n  x,\n  cy,\n  color,\n  typeStyle,\n}: {\n  glyphs: string[];\n  metrics: Metrics;\n  frame: number;\n  fps: number;\n  em: number;\n  /** Scale the measured glyph offsets were taken at, relative to `em`. */\n  fit: number;\n  spring: { damping: number; stiffness: number; mass: number };\n  stagger: number;\n  x: number;\n  cy: number;\n  color: string;\n  typeStyle: React.CSSProperties;\n}) {\n  return (\n    <div\n      style={{\n        position: \"absolute\",\n        left: x,\n        top: cy - em,\n        height: 2 * em,\n        color,\n        fontSize: em,\n        lineHeight: 2,\n        ...typeStyle,\n      }}\n    >\n      {glyphs.map((g, i) => {\n        // Stagger of one frame. Measured: the shared scale curve takes about one\n        // 30fps frame to travel 0.71x → 1.01x, which is the gap the reference\n        // shows between adjacent letters.\n        const p = spring({\n          frame: frame - i * stagger,\n          fps,\n          config: springConfig,\n        });\n        // The spring carries the overshoot; `interpolate` extrapolates past 1,\n        // so mapping [0,1] onto [0.1,1] puts the peak at 1.4x on its own — no\n        // second curve, and nothing to keep in step with the damping.\n        const s = interpolate(p, [0, 1], [0.1, 1]);\n        return (\n          <span\n            // biome-ignore lint/suspicious/noArrayIndexKey: positional glyph slot\n            key={i}\n            style={{\n              position: \"absolute\",\n              left: (metrics.letters[i]?.left ?? 0) * fit,\n              top: 0,\n              transform: `scale(${s})`,\n              transformOrigin: `50% ${metrics.baseline * fit}px`,\n              ...typeStyle,\n            }}\n          >\n            {g === \" \" ? \" \" : g}\n          </span>\n        );\n      })}\n    </div>\n  );\n}\n\n/**\n * The pill: a width that animates straight through a label that never changes\n * size, and two stacked labels rolling upward behind a hard clip.\n *\n * The label is laid out at its full final width and centred, and the widening\n * pill reveals it from the middle out — proven on the reference by the two\n * inter-word gaps of \"sharing a moment\" sitting at x = 486.0 and 521.5 across\n * three frames while the pill grew 83px. So the width cannot be derived from a\n * padded text box each frame; it is its own animated quantity.\n *\n * There is no crossfade. The label ink's mean brightness holds inside a 4% band\n * through every swap, including the frames where both labels are half-clipped —\n * a fade would collapse it toward the fill. Pure translation plus clipping.\n */\nfunction Pill({\n  labels,\n  index,\n  rollP,\n  x,\n  cy,\n  width,\n  height,\n  em,\n  fill,\n  ink,\n  radius,\n  travel,\n  typeStyle,\n}: {\n  labels: string[];\n  index: number;\n  rollP: number;\n  x: number;\n  cy: number;\n  width: number;\n  height: number;\n  em: number;\n  fill: string;\n  ink: string;\n  radius: number;\n  travel: number;\n  typeStyle: React.CSSProperties;\n}) {\n  const showOutgoing = rollP < 1 && index > 0;\n\n  const label = (text: string, offset: number) => (\n    <div\n      style={{\n        position: \"absolute\",\n        left: \"50%\",\n        top: 0,\n        height,\n        display: \"flex\",\n        alignItems: \"center\",\n        transform: `translate(-50%, ${offset}px)`,\n        color: ink,\n        fontSize: em,\n        ...typeStyle,\n      }}\n    >\n      {text}\n    </div>\n  );\n\n  return (\n    <div\n      style={{\n        position: \"absolute\",\n        left: x,\n        top: cy - height / 2,\n        width,\n        height,\n        borderRadius: radius,\n        backgroundColor: fill,\n        overflow: \"hidden\",\n      }}\n    >\n      {showOutgoing ? label(labels[index - 1] ?? \"\", -rollP * travel) : null}\n      {label(labels[index] ?? \"\", (1 - rollP) * travel)}\n    </div>\n  );\n}\n\n/**\n * Act 2: chips ride a field that steps, not scrolls.\n *\n * The field is dead still between beats — residual velocity under 1px per\n * captured frame — and then jumps exactly one slot with a hard ease-out on the\n * frame a new chip appears. The chips themselves do nothing: measured across\n * every frame each is visible, width and fill are final from the first frame and\n * never change, and two chips in the same step travelled 140.02px and 139.34px,\n * so it is one transform on the container rather than per-chip animation.\n */\nfunction Chips({\n  labels,\n  widths,\n  frame,\n  fps,\n  beat,\n  stagger,\n  em,\n  width,\n  height,\n  fills,\n  ink,\n  edge,\n  radius,\n  borderWidth,\n  weight,\n  tau: scrollTau,\n  typeStyle,\n}: {\n  labels: string[];\n  widths: number[];\n  frame: number;\n  fps: number;\n  beat: number;\n  stagger: number;\n  em: number;\n  width: number;\n  height: number;\n  fills: string[];\n  ink: string;\n  edge: string;\n  radius?: number;\n  borderWidth?: number;\n  weight: number;\n  tau: number;\n  typeStyle: React.CSSProperties;\n}) {\n  if (frame < beat) return null;\n\n  const chipH = em * (1 + 2 * CHIP_PAD_Y);\n  const step = chipH + CHIP_GAP * height;\n  const tau = scrollTau * fps;\n\n  // How many chips have arrived, and how far the current step has run.\n  const { arrived, since } = chipAt({\n    frame,\n    beat,\n    stagger,\n    count: labels.length,\n  });\n  const p = settle(since, tau);\n  // The newest chip comes to rest one gap clear of the bottom edge. Landing it\n  // flush reads as clipped rather than as arrived — its border sits on the\n  // frame edge and half of it is lost to the codec.\n  const fieldY = height - CHIP_GAP * height - chipH - (arrived - 2 + p) * step;\n\n  return (\n    <div style={{ position: \"absolute\", inset: 0, overflow: \"hidden\" }}>\n      {labels.slice(0, arrived).map((text, i) => {\n        const w = (widths[i] ?? 0) + 2 * CHIP_PAD_X * em;\n        const left = i % 2 === 0;\n        const fill = fills[i % fills.length] ?? fills[0];\n        return (\n          // biome-ignore lint/suspicious/noArrayIndexKey: positional chip slot\n          <Item key={i} index={i}>\n            <div\n              style={{\n                position: \"absolute\",\n                top: 0,\n                left: left ? CHIP_INSET * width : undefined,\n                right: left ? undefined : CHIP_INSET * width,\n                transform: `translateY(${fieldY + i * step}px)`,\n                width: w,\n                height: chipH,\n                borderRadius: radius ?? CHIP_R * chipH,\n                backgroundColor: fill,\n                // A hairline and nothing else. The reference has no shadow — cream\n                // measured through a chip's top edge recovers to its flat value\n                // within 2px on both sides, with no directional darkening — and a\n                // drop shadow under a light chip on a light page is a grey smear.\n                border: `${borderWidth ?? CHIP_BORDER * chipH}px solid ${edge}`,\n                display: \"flex\",\n                alignItems: \"center\",\n                justifyContent: \"center\",\n                color: ink,\n                fontSize: em,\n                ...typeStyle,\n                fontWeight: weight,\n              }}\n            >\n              {text}\n            </div>\n          </Item>\n        );\n      })}\n    </div>\n  );\n}\n\n/**\n * The hidden, untransformed copy every measurement is taken from. Rendered only\n * until `metrics` exists, and `delayRender` holds frame 0 until then.\n */\nfunction Measuring({\n  glyphs,\n  labels,\n  chipLabels,\n  em,\n  chipEm,\n  typeStyle,\n  letterRefs,\n  prefixRef,\n  baselineRef,\n  statusRefs,\n  chipRefs,\n}: {\n  glyphs: string[];\n  labels: string[];\n  chipLabels: string[];\n  em: number;\n  chipEm: number;\n  typeStyle: React.CSSProperties;\n  // Plain `{ current }` shapes, not RefObject: React 18's RefObject is\n  // read-only and rejects what `useRef(null)` hands back in a React 19 project.\n  letterRefs: { current: (HTMLSpanElement | null)[] };\n  prefixRef: { current: HTMLSpanElement | null };\n  baselineRef: { current: HTMLSpanElement | null };\n  statusRefs: { current: (HTMLSpanElement | null)[] };\n  chipRefs: { current: (HTMLSpanElement | null)[] };\n}) {\n  return (\n    <div\n      aria-hidden\n      style={{\n        position: \"absolute\",\n        visibility: \"hidden\",\n        left: 0,\n        top: 0,\n        pointerEvents: \"none\",\n        // Each measured span is `inline-block` and gets its own line, so it\n        // reports its own text width. Left as blocks they fill this\n        // shrink-to-fit box and every label comes back the widest one's width —\n        // which is four identical pills and a prefix pushed off the frame.\n        display: \"flex\",\n        flexDirection: \"column\",\n        alignItems: \"flex-start\",\n      }}\n    >\n      <span\n        ref={prefixRef}\n        style={{ fontSize: em, lineHeight: 2, ...typeStyle }}\n      >\n        {glyphs.map((g, i) => (\n          <span\n            // biome-ignore lint/suspicious/noArrayIndexKey: positional glyph slot\n            key={i}\n            ref={(el) => {\n              letterRefs.current[i] = el;\n            }}\n            style={{ display: \"inline-block\" }}\n          >\n            {g === \" \" ? \" \" : g}\n          </span>\n        ))}\n        {/* A zero-sized inline-block sits ON the baseline, so `offsetTop` reads\n            it rather than a line-height ratio anyone has to guess. */}\n        <span\n          ref={baselineRef}\n          style={{ display: \"inline-block\", width: 0, height: 0 }}\n        />\n      </span>\n      {labels.map((text, i) => (\n        <span\n          // biome-ignore lint/suspicious/noArrayIndexKey: positional label slot\n          key={i}\n          ref={(el) => {\n            statusRefs.current[i] = el;\n          }}\n          style={{ display: \"inline-block\", fontSize: em, ...typeStyle }}\n        >\n          {text}\n        </span>\n      ))}\n      {chipLabels.map((text, i) => (\n        <span\n          // biome-ignore lint/suspicious/noArrayIndexKey: positional chip slot\n          key={i}\n          ref={(el) => {\n            chipRefs.current[i] = el;\n          }}\n          style={{ display: \"inline-block\", fontSize: chipEm, ...typeStyle }}\n        >\n          {text}\n        </span>\n      ))}\n    </div>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/snap-cn/status-cycle.tsx"
    }
  ],
  "docs": "Docs and props: https://snapcn.dev/docs/scenes/status-cycle?ref=cli — pass mode=\"dark\" if you are rendering onto a dark frame — these default to shadcn's light palette and a bare Remotion render is black. — 51 Pro components too — charts, launch scenes, device shots: https://snapcn.dev/docs/pricing?ref=cli-docs",
  "type": "registry:component"
}
