{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "transition-editor",
  "title": "Transition + Animation Editor",
  "description": "Ridiculously typed CSS transition + animation editor (mode prop) — a comma-separated layer list with per-kind token typing and cardinality caps. Live preview that actually animates, with an embedded easing picker and duration scrubber.",
  "dependencies": [],
  "registryDependencies": [
    "ridiculous-type-kit",
    "easing-picker",
    "unit-input",
    "button",
    "popover",
    "input",
    "label",
    "select"
  ],
  "files": [
    {
      "path": "src/components/ui/transition-editor/index.ts",
      "content": "export type {\n  AddLayerButtonProps,\n  KeywordSelectProps,\n  TimeFieldProps,\n} from \"./controls\"\nexport { AddLayerButton, KeywordSelect, TimeField } from \"./controls\"\nexport type { TransitionPreviewProps } from \"./preview\"\nexport { TransitionPreview } from \"./preview\"\nexport type {\n  TransitionEditorPanelProps,\n  TransitionEditorProps,\n  TransitionLayerRowProps,\n} from \"./transition-editor\"\nexport {\n  TransitionEditor,\n  TransitionEditorPanel,\n  TransitionLayerRow,\n} from \"./transition-editor\"\nexport {\n  animationLayerToCss,\n  defaultAnimationLayer,\n  defaultTransitionLayer,\n  formatAnimation,\n  formatTransition,\n  layerCount,\n  parseAnimation,\n  parseTransition,\n  transitionLayerToCss,\n} from \"./transition-editor.helpers\"\nexport type {\n  AnimationDirection,\n  AnimationFillMode,\n  AnimationLayer,\n  AnimationLayerLiteral,\n  AnimationLiteral,\n  AnimationNamesOf,\n  AnimationPlayState,\n  AnimationString,\n  EditorMode,\n  LayerCountOf,\n  LayersOf,\n  TransitionBehavior,\n  TransitionEditorState,\n  TransitionEditorStringMap,\n  TransitionLayer,\n  TransitionLayerLiteral,\n  TransitionLayerString,\n  TransitionLiteral,\n  TransitionPropertiesOf,\n  TransitionString,\n} from \"./transition-editor.types\"\nexport { cssAnimation, cssTransition } from \"./transition-editor.types\"\n",
      "type": "registry:ui",
      "target": "components/ui/transition-editor/index.ts"
    },
    {
      "path": "src/components/ui/transition-editor/transition-editor.tsx",
      "content": "\"use client\"\n\nimport { useEffect, useRef, useState } from \"react\"\nimport { Button } from \"@/components/ui/button\"\nimport {\n  Popover,\n  PopoverContent,\n  PopoverTrigger,\n} from \"@/components/ui/popover\"\nimport { cn } from \"@/lib/utils\"\nimport { AddLayerButton } from \"./controls\"\nimport { AnimationFields, TransitionFields } from \"./fields\"\nimport { TransitionPreview } from \"./preview\"\nimport {\n  defaultAnimationLayer,\n  defaultTransitionLayer,\n  formatAnimation,\n  formatTransition,\n  layerCount,\n  parseAnimation,\n  parseTransition,\n} from \"./transition-editor.helpers\"\nimport type {\n  AnimationLayer,\n  AnimationString,\n  EditorMode,\n  TransitionEditorState,\n  TransitionEditorStringMap,\n  TransitionLayer,\n  TransitionString,\n} from \"./transition-editor.types\"\n\n// ---------------------------------------------------------------------------\n// Mode-keyed helper indirection\n// ---------------------------------------------------------------------------\n\ntype AnyLayer = TransitionLayer | AnimationLayer\n\n/**\n * Parse a value into a discriminated `{ mode, layers }` state, or `null` on a\n * parse error. The `mode` literal tags the layer list so downstream `format`\n * narrows the element type without a cast.\n */\nfunction parseState(\n  mode: EditorMode,\n  value: string,\n): TransitionEditorState | null {\n  if (mode === \"transition\") {\n    const layers = parseTransition(value)\n    return layers === null ? null : { mode, layers }\n  }\n  const layers = parseAnimation(value)\n  return layers === null ? null : { mode, layers }\n}\n\n/** Serialize a discriminated editor state. The `mode` tag narrows `layers`. */\nfunction formatState(state: TransitionEditorState): string {\n  return state.mode === \"transition\"\n    ? formatTransition(state.layers)\n    : formatAnimation(state.layers)\n}\n\n/** A fresh default layer for the mode. */\nfunction defaultLayerFor(mode: EditorMode): AnyLayer {\n  return mode === \"transition\"\n    ? defaultTransitionLayer()\n    : defaultAnimationLayer()\n}\n\n/**\n * Re-tag a working `AnyLayer[]` as a discriminated `{ mode, layers }` state.\n *\n * This is the ONE place the editor asserts the layer kind matches the mode —\n * unavoidable because the public `TransitionLayerRow` exposes an untagged\n * `layer: AnyLayer` alongside a separate `mode` prop, so the type system cannot\n * prove their correspondence. The editor only ever stores layers of the\n * matching kind (parse + defaults + per-mode fields all produce them), so the\n * per-branch assertion is sound. Routing every serialization through this one\n * boundary keeps `formatState` / `parseState` / the field dispatch cast-free.\n */\nfunction toState(mode: EditorMode, layers: AnyLayer[]): TransitionEditorState {\n  return mode === \"transition\"\n    ? { mode, layers: layers as TransitionLayer[] }\n    : { mode, layers: layers as AnimationLayer[] }\n}\n\n// ---------------------------------------------------------------------------\n// Props\n// ---------------------------------------------------------------------------\n\nexport interface TransitionEditorPanelProps<\n  TMode extends EditorMode = \"transition\",\n> {\n  /** Which shorthand to edit. Defaults to `\"transition\"`. */\n  mode?: TMode\n  value: TransitionEditorStringMap[TMode] | (string & {})\n  onChange: (value: TransitionEditorStringMap[TMode]) => void\n  className?: string\n  \"aria-label\"?: string\n}\n\nexport type TransitionEditorProps<TMode extends EditorMode = \"transition\"> =\n  TransitionEditorPanelProps<TMode>\n\n// ---------------------------------------------------------------------------\n// TransitionEditor — popover-wrapped\n// ---------------------------------------------------------------------------\n\nexport function TransitionEditor<TMode extends EditorMode = \"transition\">(\n  props: TransitionEditorProps<TMode>,\n) {\n  const {\n    value,\n    mode = \"transition\" as TMode,\n    className,\n    \"aria-label\": ariaLabel = \"Edit a CSS transition or animation\",\n  } = props\n  const count = layerCount(mode, String(value))\n\n  return (\n    <Popover>\n      <PopoverTrigger asChild>\n        <Button\n          variant=\"outline\"\n          className={cn(\"h-9 gap-2 px-3 font-mono\", className)}\n          aria-label={ariaLabel}\n        >\n          <span className=\"text-[10px] text-muted-foreground uppercase\">\n            {mode}\n          </span>\n          <span className=\"text-[10px] text-muted-foreground\">\n            {count} {count === 1 ? \"layer\" : \"layers\"}\n          </span>\n          <span className=\"max-w-[180px] truncate text-xs\">{value}</span>\n        </Button>\n      </PopoverTrigger>\n      <PopoverContent className=\"w-auto p-0\" align=\"start\">\n        <TransitionEditorPanel {...props} />\n      </PopoverContent>\n    </Popover>\n  )\n}\n\n// ---------------------------------------------------------------------------\n// TransitionEditorPanel — inline\n// ---------------------------------------------------------------------------\n\nexport function TransitionEditorPanel<TMode extends EditorMode = \"transition\">({\n  value,\n  onChange,\n  mode = \"transition\" as TMode,\n  className,\n  \"aria-label\": ariaLabel = \"CSS transition / animation editor\",\n}: TransitionEditorPanelProps<TMode>) {\n  const [layers, setLayers] = useState<AnyLayer[]>(\n    () => parseState(mode, String(value))?.layers ?? [],\n  )\n  const lastEmittedRef = useRef<string | null>(null)\n\n  // Resync from external value (skip our own emits).\n  useEffect(() => {\n    if (value === lastEmittedRef.current) return\n    const parsed = parseState(mode, String(value))\n    if (parsed !== null) setLayers(parsed.layers)\n  }, [value, mode])\n\n  const commit = (next: AnyLayer[]) => {\n    setLayers(next)\n    const str = formatState(toState(mode, next))\n    lastEmittedRef.current = str\n    onChange(str as TransitionEditorStringMap[TMode])\n  }\n\n  const updateAt = (index: number, layer: AnyLayer) => {\n    commit(layers.map((it, i) => (i === index ? layer : it)))\n  }\n  const removeAt = (index: number) => {\n    commit(layers.filter((_, i) => i !== index))\n  }\n  const add = () => {\n    commit([...layers, defaultLayerFor(mode)])\n  }\n\n  const live = formatState(toState(mode, layers))\n\n  return (\n    <fieldset\n      className={cn(\n        \"m-0 w-[520px] space-y-3 border-0 bg-background p-3\",\n        className,\n      )}\n      aria-label={ariaLabel}\n    >\n      <div className=\"space-y-2\">\n        {layers.map((layer, i) => (\n          <TransitionLayerRow\n            // biome-ignore lint/suspicious/noArrayIndexKey: rows are positional and reorderable only by add/remove\n            key={`layer-${i}`}\n            mode={mode}\n            index={i}\n            layer={layer}\n            onChange={(next) => updateAt(i, next)}\n            onRemove={() => removeAt(i)}\n          />\n        ))}\n      </div>\n      <AddLayerButton onAdd={add} />\n      <LiveString value={live} />\n      <TransitionPreview\n        mode={mode}\n        value={live}\n        onChange={(str) => {\n          const parsed = parseState(mode, str)\n          if (parsed !== null) commit(parsed.layers)\n        }}\n      />\n    </fieldset>\n  )\n}\n\n// ---------------------------------------------------------------------------\n// TransitionLayerRow (public)\n// ---------------------------------------------------------------------------\n\nexport interface TransitionLayerRowProps {\n  mode: EditorMode\n  layer: AnyLayer\n  onChange: (layer: AnyLayer) => void\n  onRemove: () => void\n  /** Positional index — used only for stable control labels. */\n  index?: number\n  className?: string\n}\n\nexport function TransitionLayerRow({\n  mode,\n  layer,\n  onChange,\n  onRemove,\n  index,\n  className,\n}: TransitionLayerRowProps) {\n  const n = index === undefined ? \"\" : ` ${index + 1}`\n  // Re-tag the untagged props as a discriminated state once, so the per-mode\n  // field group receives a properly-narrowed layer with no cast at the call\n  // site. `onChange` (taking the wider `AnyLayer`) is assignable to a handler\n  // taking the narrower per-mode layer by parameter contravariance.\n  const state = toState(mode, [layer])\n\n  return (\n    <div\n      className={cn(\n        \"flex flex-wrap items-center gap-1.5 rounded-md border p-1.5\",\n        className,\n      )}\n    >\n      {state.mode === \"transition\" ? (\n        <TransitionFields n={n} layer={state.layers[0]} onChange={onChange} />\n      ) : (\n        <AnimationFields n={n} layer={state.layers[0]} onChange={onChange} />\n      )}\n      <button\n        type=\"button\"\n        onClick={onRemove}\n        aria-label={`Remove layer${n}`}\n        className=\"ml-auto rounded p-1 text-muted-foreground hover:bg-destructive/10 hover:text-destructive\"\n      >\n        <span aria-hidden=\"true\">×</span>\n      </button>\n    </div>\n  )\n}\n\n// ---------------------------------------------------------------------------\n// LiveString (internal)\n// ---------------------------------------------------------------------------\n\nfunction LiveString({ value }: { value: string }) {\n  return (\n    <code className=\"block overflow-x-auto rounded bg-muted/50 px-2 py-1.5 font-mono text-foreground text-xs\">\n      {value}\n    </code>\n  )\n}\n\n// Re-export the extracted controls / preview so the component module keeps the\n// same public surface after the internal split (the index barrel re-exports the\n// same names from their new homes).\nexport type {\n  AddLayerButtonProps,\n  KeywordSelectProps,\n  TimeFieldProps,\n} from \"./controls\"\nexport { AddLayerButton, KeywordSelect, TimeField } from \"./controls\"\nexport type { TransitionPreviewProps } from \"./preview\"\nexport { TransitionPreview } from \"./preview\"\n// Re-export the suggestion-string types so consumers can pull everything from\n// the component module if they prefer (the barrel also re-exports them).\nexport type { AnimationString, TransitionString }\n",
      "type": "registry:ui",
      "target": "components/ui/transition-editor/transition-editor.tsx"
    },
    {
      "path": "src/components/ui/transition-editor/transition-editor.types.ts",
      "content": "// =====================================================================\n// transition-editor.types.ts\n//\n// The \"ridiculous\" tier: compile-time PER-LAYER TOKEN-KIND CLASSIFICATION\n// over the CSS `transition` AND `animation` shorthands (both are\n// COMMA-separated layer lists whose space-separated tokens are largely\n// ORDER-INDEPENDENT within a layer — CSS classifies each token by KIND,\n// not by position). This is the SAME inverse-nesting as box-shadow-editor\n// (comma → layers, space → tokens) but classified by KIND + cardinality\n// instead of by ordered position.\n//\n// Built on `ridiculous-type-kit` plus the easing-picker's `EasingLiteral`\n// for the per-layer <easing-function> token. `TransitionLiteral<S>` /\n// `AnimationLiteral<S>` resolve to `S` when every layer's tokens classify\n// within their per-kind caps, `never` otherwise.\n//\n//   \"opacity 200ms ease-in\"                  → the literal\n//   \"all 200ms, color 100ms ease\"            → the literal (2 layers)\n//   \"opacity 200ms 100ms 50ms ease\"          → never (3 <time>)\n//   \"opacity 200ms wobble @x\"                → never (unknown token)\n//   \"spin 1s ease-in-out infinite\"           → the literal (animation)\n//   \"spin 1s 2 3\"                            → never (2 iteration-counts)\n//\n// TRANSITION layer tokens:  { <=2 <time> (duration, delay), <=1\n//   <easing-function>, <=1 <single-transition-property> (all|none|ident),\n//   <=1 allow-discrete }.\n// ANIMATION layer tokens:   { <=2 <time>, <=1 <easing-function>, <=1\n//   iteration-count (<number>|infinite), <=1 direction, <=1 fill-mode,\n//   <=1 play-state, <=1 <keyframes-name> ident }.\n//\n// See `2026-05-29-transition-editor-design.md` §3 for the token-kind\n// precedence and the documented ambiguity resolutions.\n// =====================================================================\n\nimport type { EasingLiteral } from \"@/components/ui/easing-picker/easing-picker.types\"\nimport type {\n  AllChars,\n  Digit,\n  IsNumber,\n  IsTime,\n  Letter,\n  SplitByComma,\n  SplitBySpace,\n  Trim,\n} from \"@/lib/ridiculous-type-kit\"\n\n// =====================================================================\n// 1. PER-TOKEN PREDICATE ALIASES\n// =====================================================================\n\n/** Collapse the easing literal-or-never validator into a boolean. */\ntype IsEasing<S extends string> =\n  EasingLiteral<Trim<S>> extends never ? false : true\n\n// `<custom-ident>` — weak validation (design §3.3): a non-empty token of\n// ident-safe chars (letters / digits / `-` / `_`) that does NOT start with a\n// digit. Full <custom-ident> grammar (escapes, CSS-wide-keyword exclusion) is\n// deferred to the runtime parser; the type budget does not warrant it for the\n// single catch-all slot.\ntype IdentChar = Letter | Digit | \"-\" | \"_\"\n\ntype IsIdent<S extends string> = S extends \"\"\n  ? false\n  : S extends `${Digit}${string}`\n    ? false\n    : AllChars<S, IdentChar>\n\n// =====================================================================\n// 2. KEYWORD SETS\n// =====================================================================\n\n/** The `transition`-shorthand behavior flag (CSS Transitions L2). */\nexport type TransitionBehavior = \"allow-discrete\"\n\n/** `all` / `none` — the keyword forms of <single-transition-property>. */\ntype TransitionPropKeyword = \"all\" | \"none\"\n\n/** <single-animation-direction>. */\nexport type AnimationDirection =\n  | \"normal\"\n  | \"reverse\"\n  | \"alternate\"\n  | \"alternate-reverse\"\n\n/** <single-animation-fill-mode>. */\nexport type AnimationFillMode = \"none\" | \"forwards\" | \"backwards\" | \"both\"\n\n/** <single-animation-play-state>. */\nexport type AnimationPlayState = \"running\" | \"paused\"\n\n/** <single-animation-iteration-count> — a <number> or `infinite`. */\ntype IsIterationCount<S extends string> =\n  Trim<S> extends \"infinite\" ? true : IsNumber<Trim<S>>\n\n// =====================================================================\n// 3. PER-LAYER TOKEN CLASSIFICATION (the engine)\n//\n// Fold the layer's tokens by KIND, threading a tuple-length counter per\n// kind. Reject the moment a counter would exceed its cap or a token matches\n// nothing. Precedence is fixed (design §3): easing keywords beat the\n// <custom-ident> catch-all so `ease` / `linear` count as easing, not as a\n// property / keyframes-name.\n// =====================================================================\n\n// --- transition: counters [time, easing, prop, behavior] ---\ntype ClassifyTransition<\n  Tokens extends string[],\n  T extends unknown[] = [],\n  E extends unknown[] = [],\n  P extends unknown[] = [],\n  B extends unknown[] = [],\n> = Tokens extends [infer H extends string, ...infer R extends string[]]\n  ? Trim<H> extends TransitionBehavior\n    ? B[\"length\"] extends 1\n      ? false\n      : ClassifyTransition<R, T, E, P, [...B, 0]>\n    : IsTime<Trim<H>> extends true\n      ? T[\"length\"] extends 2\n        ? false\n        : ClassifyTransition<R, [...T, 0], E, P, B>\n      : IsEasing<H> extends true\n        ? E[\"length\"] extends 1\n          ? false\n          : ClassifyTransition<R, T, [...E, 0], P, B>\n        : Trim<H> extends TransitionPropKeyword\n          ? P[\"length\"] extends 1\n            ? false\n            : ClassifyTransition<R, T, E, [...P, 0], B>\n          : IsIdent<Trim<H>> extends true\n            ? P[\"length\"] extends 1\n              ? false\n              : ClassifyTransition<R, T, E, [...P, 0], B>\n            : false // unknown token\n  : true // every token classified within its cap\n\n// --- animation: counters [time, easing, iter, dir, fill, play, name] ---\ntype ClassifyAnimation<\n  Tokens extends string[],\n  T extends unknown[] = [],\n  E extends unknown[] = [],\n  I extends unknown[] = [],\n  D extends unknown[] = [],\n  F extends unknown[] = [],\n  PL extends unknown[] = [],\n  N extends unknown[] = [],\n> = Tokens extends [infer H extends string, ...infer R extends string[]]\n  ? IsTime<Trim<H>> extends true\n    ? T[\"length\"] extends 2\n      ? false\n      : ClassifyAnimation<R, [...T, 0], E, I, D, F, PL, N>\n    : IsIterationCount<H> extends true\n      ? I[\"length\"] extends 1\n        ? false\n        : ClassifyAnimation<R, T, E, [...I, 0], D, F, PL, N>\n      : Trim<H> extends AnimationDirection\n        ? D[\"length\"] extends 1\n          ? false\n          : ClassifyAnimation<R, T, E, I, [...D, 0], F, PL, N>\n        : Trim<H> extends AnimationFillMode\n          ? F[\"length\"] extends 1\n            ? false\n            : ClassifyAnimation<R, T, E, I, D, [...F, 0], PL, N>\n          : Trim<H> extends AnimationPlayState\n            ? PL[\"length\"] extends 1\n              ? false\n              : ClassifyAnimation<R, T, E, I, D, F, [...PL, 0], N>\n            : IsEasing<H> extends true\n              ? E[\"length\"] extends 1\n                ? false\n                : ClassifyAnimation<R, T, [...E, 0], I, D, F, PL, N>\n              : IsIdent<Trim<H>> extends true\n                ? N[\"length\"] extends 1\n                  ? false\n                  : ClassifyAnimation<R, T, E, I, D, F, PL, [...N, 0]>\n                : false // unknown token\n  : true\n\n// =====================================================================\n// 4. STRICT VALIDATORS + CALL-SITE HELPERS\n// =====================================================================\n\n/**\n * Strict single-`transition`-layer validator. Resolves to `S` when `S` is\n * one valid layer (tokens classify within caps), `never` otherwise. A\n * comma-separated list is NOT a single layer.\n *\n * @example\n * type A = TransitionLayerLiteral<\"opacity 200ms ease\"> // the literal\n * type B = TransitionLayerLiteral<\"opacity 200ms 100ms 50ms ease\"> // never\n */\nexport type TransitionLayerLiteral<S extends string> =\n  SplitBySpace<Trim<S>> extends infer Parts extends string[]\n    ? Parts extends []\n      ? never\n      : ClassifyTransition<Parts> extends true\n        ? S\n        : never\n    : never\n\n/**\n * Strict single-`animation`-layer validator. Resolves to `S` for one valid\n * layer, `never` otherwise.\n */\nexport type AnimationLayerLiteral<S extends string> =\n  SplitBySpace<Trim<S>> extends infer Parts extends string[]\n    ? Parts extends []\n      ? never\n      : ClassifyAnimation<Parts> extends true\n        ? S\n        : never\n    : never\n\n// Depth-capped fold over the comma layer list. Up to 32 layers are fully\n// validated; beyond the cap the tail is weak-validated (non-empty). The\n// runtime parser validates fully regardless of count. (box-shadow precedent.)\ntype ValidateLayers<\n  Layers extends string[],\n  Mode extends EditorMode,\n  Depth extends unknown[] = [],\n> = Layers extends [infer H extends string, ...infer Rest extends string[]]\n  ? Depth[\"length\"] extends 32\n    ? Trim<H> extends \"\"\n      ? false\n      : ValidateLayers<Rest, Mode, Depth>\n    : (\n          Mode extends \"transition\"\n            ? TransitionLayerLiteral<H>\n            : AnimationLayerLiteral<H>\n        ) extends never\n      ? false\n      : ValidateLayers<Rest, Mode, [...Depth, 0]>\n  : true\n\n/**\n * Strict `transition` validator. Resolves to `S` when `S` is a valid CSS\n * `transition` value (or the `none` keyword), `never` otherwise.\n * `calc()` / `var()` inside a token resolve to `never` here (undecidable at\n * compile time) — use the casual / IntelliSense tier; the runtime parser\n * accepts them.\n *\n * @example\n * type A = TransitionLiteral<\"opacity 200ms ease-in\"> // the literal\n * type B = TransitionLiteral<\"opacity 200ms 100ms 50ms\"> // never\n * type C = TransitionLiteral<\"none\"> // \"none\"\n */\nexport type TransitionLiteral<S extends string> =\n  Trim<S> extends \"none\"\n    ? S\n    : Trim<S> extends \"\"\n      ? never\n      : SplitByComma<Trim<S>> extends infer L extends string[]\n        ? L extends []\n          ? never\n          : ValidateLayers<L, \"transition\"> extends true\n            ? S\n            : never\n        : never\n\n/**\n * Strict `animation` validator. Resolves to `S` for a valid CSS `animation`\n * value (or `none`), `never` otherwise. Same calc()/var() caveat.\n *\n * @example\n * type A = AnimationLiteral<\"spin 1s ease infinite\"> // the literal\n * type B = AnimationLiteral<\"spin 1s 2 3\"> // never\n */\nexport type AnimationLiteral<S extends string> =\n  Trim<S> extends \"none\"\n    ? S\n    : Trim<S> extends \"\"\n      ? never\n      : SplitByComma<Trim<S>> extends infer L extends string[]\n        ? L extends []\n          ? never\n          : ValidateLayers<L, \"animation\"> extends true\n            ? S\n            : never\n        : never\n\n/**\n * Call-site validator helper for `transition`. Mirrors `cssBoxShadow()` /\n * `color()` / `easing()`. An invalid transition becomes a type error at the\n * argument.\n */\nexport const cssTransition = <S extends string>(\n  value: S & TransitionLiteral<S>,\n): S => value\n\n/** Call-site validator helper for `animation`. */\nexport const cssAnimation = <S extends string>(\n  value: S & AnimationLiteral<S>,\n): S => value\n\n// =====================================================================\n// 5. SUGGESTION STRINGS — IntelliSense + onChange return types\n//\n// Permissive (like box-shadow-editor's `BoxShadowString`): a single layer is\n// one-or-more space-separated tokens; multi-layer lists are head-anchored on\n// a layer followed by a comma; plus `none`. The STRICT tier is the real gate.\n// =====================================================================\n\nexport type TransitionLayerString = `${string} ${string}` | (string & {})\n\nexport type TransitionString =\n  | `${string} ${string}`\n  | `${string}, ${string}`\n  | \"none\"\n  | (string & {})\n\nexport type AnimationString =\n  | `${string} ${string}`\n  | `${string}, ${string}`\n  | \"none\"\n  | (string & {})\n\n/**\n * Mode → output-string map. The `mode` prop narrows the `onChange` return:\n * `transition` → `TransitionString`, `animation` → `AnimationString`.\n */\nexport interface TransitionEditorStringMap {\n  transition: TransitionString\n  animation: AnimationString\n}\n\nexport type EditorMode = keyof TransitionEditorStringMap\n\n// =====================================================================\n// 6. UTILITY TYPES — operate on transition / animation literals\n// =====================================================================\n\n/**\n * The raw per-layer strings of a transition / animation value.\n *\n * @example\n * type T = LayersOf<\"opacity 1s, color 2s\"> // [\"opacity 1s\", \"color 2s\"]\n * type N = LayersOf<\"none\"> // []\n */\nexport type LayersOf<S extends string> =\n  Trim<S> extends \"none\" | \"\" ? [] : SplitByComma<Trim<S>>\n\n/**\n * The number of layers.\n *\n * @example\n * type C = LayerCountOf<\"opacity 1s, color 2s\"> // 2\n */\nexport type LayerCountOf<S extends string> = LayersOf<S>[\"length\"]\n\n// Pull the single <custom-ident> token (property / keyframes-name) out of a\n// layer's classified tokens. Uses the SAME precedence as the classifier: a\n// token is the ident slot only if it is not a time / easing / number / known\n// keyword. Returns `never` for a layer with no ident (so the per-layer map\n// can stay total we fall back to \"\" — see PropOfLayer).\ntype FirstIdentTransition<Tokens extends string[]> = Tokens extends [\n  infer H extends string,\n  ...infer R extends string[],\n]\n  ? IsTime<Trim<H>> extends true\n    ? FirstIdentTransition<R>\n    : IsEasing<H> extends true\n      ? FirstIdentTransition<R>\n      : Trim<H> extends TransitionBehavior\n        ? FirstIdentTransition<R>\n        : Trim<H> extends \"all\" | \"none\"\n          ? Trim<H>\n          : IsIdent<Trim<H>> extends true\n            ? Trim<H>\n            : FirstIdentTransition<R>\n  : never\n\ntype FirstIdentAnimation<Tokens extends string[]> = Tokens extends [\n  infer H extends string,\n  ...infer R extends string[],\n]\n  ? IsTime<Trim<H>> extends true\n    ? FirstIdentAnimation<R>\n    : IsIterationCount<H> extends true\n      ? FirstIdentAnimation<R>\n      : Trim<H> extends\n            | AnimationDirection\n            | AnimationFillMode\n            | AnimationPlayState\n        ? FirstIdentAnimation<R>\n        : IsEasing<H> extends true\n          ? FirstIdentAnimation<R>\n          : IsIdent<Trim<H>> extends true\n            ? Trim<H>\n            : FirstIdentAnimation<R>\n  : never\n\ntype MapIdents<\n  Layers extends string[],\n  Kind extends \"transition\" | \"animation\",\n> = Layers extends [infer H extends string, ...infer R extends string[]]\n  ? [\n      Kind extends \"transition\"\n        ? FirstIdentTransition<SplitBySpace<Trim<H>>>\n        : FirstIdentAnimation<SplitBySpace<Trim<H>>>,\n      ...MapIdents<R, Kind>,\n    ]\n  : []\n\n/**\n * The transition-property ident of each layer (`all` / `none` / custom).\n *\n * @example\n * type T = TransitionPropertiesOf<\"opacity 1s, transform 2s\">\n * //   [\"opacity\", \"transform\"]\n */\nexport type TransitionPropertiesOf<S extends string> = MapIdents<\n  LayersOf<S>,\n  \"transition\"\n>\n\n/**\n * The keyframes-name of each animation layer.\n *\n * @example\n * type T = AnimationNamesOf<\"spin 1s, pulse 2s\"> // [\"spin\", \"pulse\"]\n */\nexport type AnimationNamesOf<S extends string> = MapIdents<\n  LayersOf<S>,\n  \"animation\"\n>\n\n// =====================================================================\n// 7. INTERNAL STATE — the per-layer records + discriminated-union editor\n//    state (exported for advanced use: custom serialization, programmatic\n//    build). Values are kept as strings (they carry units / idents),\n//    mirroring how the literal preserves the raw text.\n// =====================================================================\n\n/** One `transition` layer. */\nexport interface TransitionLayer {\n  /** <single-transition-property> — `all` | `none` | a <custom-ident>. */\n  property?: string\n  /** Duration — a <time>. */\n  duration?: string\n  /** Delay — a <time>. */\n  delay?: string\n  /** Timing function — an <easing-function> (validated via EasingLiteral). */\n  easing?: string\n  /** The `allow-discrete` <transition-behavior> flag. */\n  allowDiscrete?: boolean\n}\n\n/** One `animation` layer. */\nexport interface AnimationLayer {\n  /** <keyframes-name> — a <custom-ident>. */\n  name?: string\n  /** Duration — a <time>. */\n  duration?: string\n  /** Delay — a <time>. */\n  delay?: string\n  /** Timing function — an <easing-function>. */\n  easing?: string\n  /** Iteration count — a <number> or `infinite`. */\n  iterationCount?: string\n  /** Direction. */\n  direction?: AnimationDirection\n  /** Fill mode. */\n  fillMode?: AnimationFillMode\n  /** Play state. */\n  playState?: AnimationPlayState\n}\n\n/**\n * The editor's internal state — a discriminated union on `mode`. A transition\n * layer and an animation layer carry different fields, so the mode tags the\n * layer list.\n */\nexport type TransitionEditorState =\n  | { mode: \"transition\"; layers: TransitionLayer[] }\n  | { mode: \"animation\"; layers: AnimationLayer[] }\n",
      "type": "registry:ui",
      "target": "components/ui/transition-editor/transition-editor.types.ts"
    },
    {
      "path": "src/components/ui/transition-editor/transition-editor.helpers.ts",
      "content": "// =====================================================================\n// transition-editor.helpers.ts\n//\n// Pure runtime parse / format for the CSS `transition` AND `animation`\n// shorthands — each a COMMA-separated list of layers whose space-separated\n// tokens are classified by KIND (largely order-independent within a layer).\n// This is the SUPERSET of the strict type tier: it tolerates calc()/var()\n// tokens (kept verbatim) and applies the SAME token-kind precedence the type\n// tier uses (design §3). It is the single source of truth the UI drives off.\n//\n// Transition layer:  property? duration? delay? easing? allow-discrete?\n//   (first <time> = duration, second = delay)\n// Animation layer:   duration? delay? easing? iteration? direction? fill?\n//   play-state? name?\n// =====================================================================\n\nimport type {\n  AnimationDirection,\n  AnimationFillMode,\n  AnimationLayer,\n  AnimationPlayState,\n  EditorMode,\n  TransitionLayer,\n} from \"./transition-editor.types\"\n\n// ---------------------------------------------------------------------------\n// Top-level splitter (paren-aware, runtime mirror of the kit combinator)\n// ---------------------------------------------------------------------------\n\n/** Split on `sep` only at bracket depth 0. */\nfunction splitTopLevel(src: string, sep: string): string[] {\n  const out: string[] = []\n  let depth = 0\n  let cur = \"\"\n  for (const ch of src) {\n    if (ch === \"(\" || ch === \"[\") depth++\n    else if (ch === \")\" || ch === \"]\") depth--\n    if (ch === sep && depth === 0) {\n      out.push(cur)\n      cur = \"\"\n    } else {\n      cur += ch\n    }\n  }\n  out.push(cur)\n  return out\n}\n\n/** Split the comma layer list into trimmed layer strings (drops empties). */\nfunction splitLayers(src: string): string[] {\n  return splitTopLevel(src, \",\")\n    .map((s) => s.trim())\n    .filter((s) => s.length > 0)\n}\n\n/** Split a layer into space-separated tokens (drops empty runs). */\nfunction splitTokens(layer: string): string[] {\n  return splitTopLevel(layer, \" \")\n    .map((s) => s.trim())\n    .filter((s) => s.length > 0)\n}\n\n// ---------------------------------------------------------------------------\n// Token classifiers (runtime mirrors of the type-tier predicates)\n// ---------------------------------------------------------------------------\n\n// A <time> token (Ns / Nms) OR an opaque math function kept verbatim.\nconst OPAQUE_FN_RE = /^(calc|var|min|max|clamp|env)\\(/i\n\n/** Time unit suffix for a CSS <time>. */\nexport type TimeUnit = \"ms\" | \"s\"\n\n// The single <time> grammar shared by every call site (classifier, the editing\n// field's number+unit split, and the ms-conversion in the preview). Numeric\n// part is a well-formed signed decimal; the unit is captured separately. The\n// optional numeric part lets the editing field show an in-progress edit.\nconst TIME_RE = /^(-?\\d*\\.?\\d*)(ms|s)$/i\n\n/**\n * Split a CSS `<time>` into its numeric and unit parts, or `null` when the\n * token is not a `<time>` (e.g. `calc()`, a bare ident, the empty string).\n * This is the single source of truth for every `<time>` regex in the editor.\n */\nexport function parseTime(\n  value: string,\n): { num: string; unit: TimeUnit } | null {\n  const m = TIME_RE.exec(value)\n  if (m === null) return null\n  return { num: m[1], unit: m[2].toLowerCase() as TimeUnit }\n}\n\nfunction isTimeish(token: string): boolean {\n  const t = parseTime(token)\n  // A classifiable <time> needs an actual digit (reject a bare unit like `ms`).\n  return (t !== null && /\\d/.test(t.num)) || OPAQUE_FN_RE.test(token)\n}\n\n// A bare <number> (no unit) — iteration count.\nconst NUMBER_RE = /^[+-]?[\\d.]+$/\n\nfunction isNumberish(token: string): boolean {\n  return NUMBER_RE.test(token)\n}\n\n// An <easing-function>: a CSS easing keyword or a known easing function call.\nconst EASING_KEYWORDS = new Set([\n  \"linear\",\n  \"ease\",\n  \"ease-in\",\n  \"ease-out\",\n  \"ease-in-out\",\n  \"step-start\",\n  \"step-end\",\n])\nconst EASING_FN_RE = /^(cubic-bezier|steps|linear)\\(/i\n\nfunction isEasingish(token: string): boolean {\n  return EASING_KEYWORDS.has(token.toLowerCase()) || EASING_FN_RE.test(token)\n}\n\n// A weak <custom-ident>: non-empty, ident-safe chars, no leading digit.\nconst IDENT_RE = /^[A-Za-z_][A-Za-z0-9_-]*$/\n\nfunction isIdent(token: string): boolean {\n  return IDENT_RE.test(token)\n}\n\nconst DIRECTIONS = new Set<AnimationDirection>([\n  \"normal\",\n  \"reverse\",\n  \"alternate\",\n  \"alternate-reverse\",\n])\nconst FILL_MODES = new Set<AnimationFillMode>([\n  \"none\",\n  \"forwards\",\n  \"backwards\",\n  \"both\",\n])\nconst PLAY_STATES = new Set<AnimationPlayState>([\"running\", \"paused\"])\n\n// ---------------------------------------------------------------------------\n// parseTransitionLayer — tokens → TransitionLayer | null\n// ---------------------------------------------------------------------------\n\n/**\n * Build one TransitionLayer from a layer's tokens, classified by kind with the\n * type-tier precedence: allow-discrete → <time> → easing → all/none → ident.\n * First <time> = duration, second = delay. Enforces per-kind caps.\n */\nfunction parseTransitionLayer(tokens: string[]): TransitionLayer | null {\n  if (tokens.length === 0) return null\n  const layer: TransitionLayer = {}\n  const times: string[] = []\n\n  for (const token of tokens) {\n    if (token.toLowerCase() === \"allow-discrete\") {\n      if (layer.allowDiscrete) return null\n      layer.allowDiscrete = true\n    } else if (isTimeish(token)) {\n      if (times.length === 2) return null\n      times.push(token)\n    } else if (isEasingish(token)) {\n      if (layer.easing !== undefined) return null\n      layer.easing = token\n    } else if (token === \"all\" || token === \"none\") {\n      if (layer.property !== undefined) return null\n      layer.property = token\n    } else if (isIdent(token)) {\n      if (layer.property !== undefined) return null\n      layer.property = token\n    } else {\n      return null // unknown token\n    }\n  }\n\n  if (times[0] !== undefined) layer.duration = times[0]\n  if (times[1] !== undefined) layer.delay = times[1]\n  return layer\n}\n\n// ---------------------------------------------------------------------------\n// parseAnimationLayer — tokens → AnimationLayer | null\n// ---------------------------------------------------------------------------\n\n/**\n * Build one AnimationLayer. Precedence: <time> → infinite/<number> →\n * direction → fill-mode → play-state → easing → ident name. First <time> =\n * duration, second = delay.\n */\nfunction parseAnimationLayer(tokens: string[]): AnimationLayer | null {\n  if (tokens.length === 0) return null\n  const layer: AnimationLayer = {}\n  const times: string[] = []\n\n  for (const token of tokens) {\n    const lower = token.toLowerCase()\n    if (isTimeish(token)) {\n      if (times.length === 2) return null\n      times.push(token)\n    } else if (lower === \"infinite\" || isNumberish(token)) {\n      if (layer.iterationCount !== undefined) return null\n      layer.iterationCount = token\n    } else if (DIRECTIONS.has(lower as AnimationDirection)) {\n      if (layer.direction !== undefined) return null\n      layer.direction = lower as AnimationDirection\n    } else if (FILL_MODES.has(lower as AnimationFillMode)) {\n      if (layer.fillMode !== undefined) return null\n      layer.fillMode = lower as AnimationFillMode\n    } else if (PLAY_STATES.has(lower as AnimationPlayState)) {\n      if (layer.playState !== undefined) return null\n      layer.playState = lower as AnimationPlayState\n    } else if (isEasingish(token)) {\n      if (layer.easing !== undefined) return null\n      layer.easing = token\n    } else if (isIdent(token)) {\n      if (layer.name !== undefined) return null\n      layer.name = token\n    } else {\n      return null // unknown token\n    }\n  }\n\n  if (times[0] !== undefined) layer.duration = times[0]\n  if (times[1] !== undefined) layer.delay = times[1]\n  return layer\n}\n\n// ---------------------------------------------------------------------------\n// parseTransition / parseAnimation — string → layers | null\n// ---------------------------------------------------------------------------\n\n/**\n * Parse a CSS `transition` value into typed layers, or `null` on any\n * cardinality / unknown-token / syntax error. `none` / empty → `[]`. Tolerant:\n * keeps calc()/var() verbatim.\n */\nexport function parseTransition(src: string): TransitionLayer[] | null {\n  const trimmed = src.trim()\n  if (trimmed === \"\" || trimmed === \"none\") return []\n  const layerStrings = splitLayers(trimmed)\n  if (layerStrings.length === 0) return []\n  const layers: TransitionLayer[] = []\n  for (const layerStr of layerStrings) {\n    const layer = parseTransitionLayer(splitTokens(layerStr))\n    if (layer === null) return null\n    layers.push(layer)\n  }\n  return layers\n}\n\n/**\n * Parse a CSS `animation` value into typed layers, or `null` on error.\n * `none` / empty → `[]`. Tolerant of calc()/var().\n */\nexport function parseAnimation(src: string): AnimationLayer[] | null {\n  const trimmed = src.trim()\n  if (trimmed === \"\" || trimmed === \"none\") return []\n  const layerStrings = splitLayers(trimmed)\n  if (layerStrings.length === 0) return []\n  const layers: AnimationLayer[] = []\n  for (const layerStr of layerStrings) {\n    const layer = parseAnimationLayer(splitTokens(layerStr))\n    if (layer === null) return null\n    layers.push(layer)\n  }\n  return layers\n}\n\n/** Runtime mirror of `LayerCountOf` for either mode (invalid → 0). */\nexport function layerCount(mode: EditorMode, src: string): number {\n  const layers =\n    mode === \"transition\" ? parseTransition(src) : parseAnimation(src)\n  return layers === null ? 0 : layers.length\n}\n\n// ---------------------------------------------------------------------------\n// defaults — seed a fresh layer\n// ---------------------------------------------------------------------------\n\n/** A sensible default transition layer — animate all over 200ms ease. */\nexport function defaultTransitionLayer(): TransitionLayer {\n  return { property: \"all\", duration: \"200ms\", easing: \"ease\" }\n}\n\n/** A sensible default animation layer — the `slide` keyframes over 1s ease. */\nexport function defaultAnimationLayer(): AnimationLayer {\n  return { name: \"slide\", duration: \"1s\", easing: \"ease\", iterationCount: \"1\" }\n}\n\n// ---------------------------------------------------------------------------\n// layerToCss / format — canonical serialization\n// ---------------------------------------------------------------------------\n\n/**\n * Serialize one transition layer in canonical order:\n * `property? duration? delay? easing? allow-discrete?` (absent tokens omitted).\n */\nexport function transitionLayerToCss(layer: TransitionLayer): string {\n  const parts: string[] = []\n  if (layer.property !== undefined) parts.push(layer.property)\n  if (layer.duration !== undefined) parts.push(layer.duration)\n  if (layer.delay !== undefined) parts.push(layer.delay)\n  if (layer.easing !== undefined) parts.push(layer.easing)\n  if (layer.allowDiscrete) parts.push(\"allow-discrete\")\n  return parts.join(\" \")\n}\n\n/**\n * Serialize one animation layer in canonical order:\n * `duration? delay? easing? iteration? direction? fill-mode? play-state? name?`.\n */\nexport function animationLayerToCss(layer: AnimationLayer): string {\n  const parts: string[] = []\n  if (layer.duration !== undefined) parts.push(layer.duration)\n  if (layer.delay !== undefined) parts.push(layer.delay)\n  if (layer.easing !== undefined) parts.push(layer.easing)\n  if (layer.iterationCount !== undefined) parts.push(layer.iterationCount)\n  if (layer.direction !== undefined) parts.push(layer.direction)\n  if (layer.fillMode !== undefined) parts.push(layer.fillMode)\n  if (layer.playState !== undefined) parts.push(layer.playState)\n  if (layer.name !== undefined) parts.push(layer.name)\n  return parts.join(\" \")\n}\n\n/** Canonical re-serialization of a transition layer list. Empty → `none`. */\nexport function formatTransition(layers: TransitionLayer[]): string {\n  if (layers.length === 0) return \"none\"\n  return layers.map(transitionLayerToCss).join(\", \")\n}\n\n/** Canonical re-serialization of an animation layer list. Empty → `none`. */\nexport function formatAnimation(layers: AnimationLayer[]): string {\n  if (layers.length === 0) return \"none\"\n  return layers.map(animationLayerToCss).join(\", \")\n}\n",
      "type": "registry:ui",
      "target": "components/ui/transition-editor/transition-editor.helpers.ts"
    },
    {
      "path": "src/components/ui/transition-editor/controls.tsx",
      "content": "\"use client\"\n\nimport { Input } from \"@/components/ui/input\"\nimport { cn } from \"@/lib/utils\"\nimport { parseTime } from \"./transition-editor.helpers\"\n\n// ---------------------------------------------------------------------------\n// Shared constants\n// ---------------------------------------------------------------------------\n\nconst TIME_UNITS = [\"ms\", \"s\"] as const\n\n// ---------------------------------------------------------------------------\n// KeywordSelect — a labelled native select with an empty option\n// ---------------------------------------------------------------------------\n\nexport interface KeywordSelectProps<T extends string = string> {\n  label: string\n  value: T | undefined\n  options: readonly T[]\n  onChange: (value: T | undefined) => void\n  className?: string\n}\n\nexport function KeywordSelect<T extends string = string>({\n  label,\n  value,\n  options,\n  onChange,\n  className,\n}: KeywordSelectProps<T>) {\n  return (\n    <select\n      aria-label={label}\n      value={value ?? \"\"}\n      onChange={(e) =>\n        // `HTMLSelectElement.value` is necessarily `string`; the rendered\n        // <option>s are exactly `options`, so the value is a `T` or \"\".\n        onChange(e.target.value === \"\" ? undefined : (e.target.value as T))\n      }\n      className={cn(\n        \"h-8 rounded-md border border-input bg-background px-1 font-mono text-xs\",\n        className,\n      )}\n    >\n      <option value=\"\">{label.replace(/\\s\\d+$/, \"\")}</option>\n      {options.map((o) => (\n        <option key={o} value={o}>\n          {o}\n        </option>\n      ))}\n    </select>\n  )\n}\n\n// ---------------------------------------------------------------------------\n// TimeField — a UnitInput for <time>, with opaque passthrough\n// ---------------------------------------------------------------------------\n\nexport interface TimeFieldProps {\n  label: string\n  value: string\n  onChange: (value: string) => void\n  className?: string\n}\n\nexport function TimeField({\n  label,\n  value,\n  onChange,\n  className,\n}: TimeFieldProps) {\n  // Split \"200ms\" / \"0.3s\" into number + unit (shared `<time>` parser).\n  const t = parseTime(value)\n  const opaque = value !== \"\" && t === null // calc()/var() etc — raw text\n  const numPart = t ? t.num : \"\"\n  const unitPart = t ? t.unit : \"ms\"\n\n  if (opaque) {\n    return (\n      <Input\n        aria-label={label}\n        value={value}\n        spellCheck={false}\n        autoComplete=\"off\"\n        onChange={(e) => onChange(e.target.value)}\n        className={cn(\"h-8 w-[110px] font-mono text-xs\", className)}\n      />\n    )\n  }\n\n  return (\n    <span className={cn(\"inline-flex items-center\", className)}>\n      <Input\n        aria-label={label}\n        value={value === \"\" ? \"\" : numPart}\n        spellCheck={false}\n        autoComplete=\"off\"\n        inputMode=\"decimal\"\n        placeholder=\"—\"\n        onChange={(e) => {\n          const v = e.target.value\n          onChange(v === \"\" ? \"\" : `${v}${unitPart}`)\n        }}\n        className=\"h-8 w-[56px] rounded-r-none border-r-0 font-mono text-xs\"\n      />\n      <select\n        aria-label={`${label} unit`}\n        value={unitPart}\n        onChange={(e) =>\n          // Picking a concrete unit is an explicit intent to set a time, so an\n          // empty field seeds 0 (`numPart || \"0\"`) rather than emitting \"\".\n          onChange(`${numPart || \"0\"}${e.target.value}`)\n        }\n        className=\"h-8 rounded-r-md rounded-l-none border border-input bg-background px-1 font-mono text-xs\"\n      >\n        {TIME_UNITS.map((u) => (\n          <option key={u} value={u}>\n            {u}\n          </option>\n        ))}\n      </select>\n    </span>\n  )\n}\n\n// ---------------------------------------------------------------------------\n// AddLayerButton\n// ---------------------------------------------------------------------------\n\nexport interface AddLayerButtonProps {\n  onAdd: () => void\n  className?: string\n}\n\nexport function AddLayerButton({ onAdd, className }: AddLayerButtonProps) {\n  return (\n    <button\n      type=\"button\"\n      onClick={onAdd}\n      aria-label=\"Add a layer\"\n      className={cn(\n        \"h-9 w-full rounded-md border border-dashed bg-background px-2 font-mono text-muted-foreground text-xs hover:text-foreground\",\n        className,\n      )}\n    >\n      + add layer\n    </button>\n  )\n}\n",
      "type": "registry:ui",
      "target": "components/ui/transition-editor/controls.tsx"
    },
    {
      "path": "src/components/ui/transition-editor/fields.tsx",
      "content": "\"use client\"\n\nimport { EasingPicker } from \"@/components/ui/easing-picker\"\nimport { Input } from \"@/components/ui/input\"\nimport { cn } from \"@/lib/utils\"\nimport { KeywordSelect, TimeField } from \"./controls\"\nimport type {\n  AnimationDirection,\n  AnimationFillMode,\n  AnimationLayer,\n  AnimationPlayState,\n  TransitionLayer,\n} from \"./transition-editor.types\"\n\n// ---------------------------------------------------------------------------\n// Field-group constants\n// ---------------------------------------------------------------------------\n\nconst COMMON_PROPERTIES = [\n  \"all\",\n  \"none\",\n  \"opacity\",\n  \"transform\",\n  \"color\",\n  \"background-color\",\n  \"width\",\n  \"height\",\n  \"box-shadow\",\n  \"filter\",\n] as const\n\nconst DIRECTIONS: readonly AnimationDirection[] = [\n  \"normal\",\n  \"reverse\",\n  \"alternate\",\n  \"alternate-reverse\",\n]\nconst FILL_MODES: readonly AnimationFillMode[] = [\n  \"none\",\n  \"forwards\",\n  \"backwards\",\n  \"both\",\n]\nconst PLAY_STATES: readonly AnimationPlayState[] = [\"running\", \"paused\"]\n\n// ---------------------------------------------------------------------------\n// TransitionFields — the transition-mode controls\n// ---------------------------------------------------------------------------\n\ninterface TransitionFieldsProps {\n  n: string\n  layer: TransitionLayer\n  onChange: (layer: TransitionLayer) => void\n}\n\nexport function TransitionFields({\n  n,\n  layer,\n  onChange,\n}: TransitionFieldsProps) {\n  const setField = (patch: Partial<TransitionLayer>) => {\n    onChange({ ...layer, ...patch })\n  }\n  return (\n    <>\n      <Input\n        aria-label={`transition-property${n}`}\n        list=\"te-property-list\"\n        value={layer.property ?? \"\"}\n        spellCheck={false}\n        autoComplete=\"off\"\n        placeholder=\"property\"\n        onChange={(e) =>\n          setField({\n            property: e.target.value === \"\" ? undefined : e.target.value,\n          })\n        }\n        className=\"h-8 w-[120px] font-mono text-xs\"\n      />\n      <datalist id=\"te-property-list\">\n        {COMMON_PROPERTIES.map((p) => (\n          <option key={p} value={p} />\n        ))}\n      </datalist>\n      <TimeField\n        label={`duration${n}`}\n        value={layer.duration ?? \"\"}\n        onChange={(duration) =>\n          setField({ duration: duration === \"\" ? undefined : duration })\n        }\n      />\n      <TimeField\n        label={`delay${n}`}\n        value={layer.delay ?? \"\"}\n        onChange={(delay) =>\n          setField({ delay: delay === \"\" ? undefined : delay })\n        }\n      />\n      <EasingPicker\n        value={layer.easing ?? \"ease\"}\n        onChange={(easing) => setField({ easing })}\n        aria-label={`easing${n}`}\n        className=\"h-8\"\n      />\n      <button\n        type=\"button\"\n        aria-label={`allow-discrete${n}`}\n        aria-pressed={layer.allowDiscrete ?? false}\n        onClick={() => setField({ allowDiscrete: !layer.allowDiscrete })}\n        className={cn(\n          \"h-8 rounded border px-2 font-mono text-[10px]\",\n          layer.allowDiscrete\n            ? \"bg-primary text-primary-foreground\"\n            : \"bg-background text-muted-foreground\",\n        )}\n      >\n        allow-discrete\n      </button>\n    </>\n  )\n}\n\n// ---------------------------------------------------------------------------\n// AnimationFields — the animation-mode controls\n// ---------------------------------------------------------------------------\n\ninterface AnimationFieldsProps {\n  n: string\n  layer: AnimationLayer\n  onChange: (layer: AnimationLayer) => void\n}\n\nexport function AnimationFields({ n, layer, onChange }: AnimationFieldsProps) {\n  const setField = (patch: Partial<AnimationLayer>) => {\n    onChange({ ...layer, ...patch })\n  }\n  const isInfinite = layer.iterationCount === \"infinite\"\n  return (\n    <>\n      <Input\n        aria-label={`animation-name${n}`}\n        value={layer.name ?? \"\"}\n        spellCheck={false}\n        autoComplete=\"off\"\n        placeholder=\"name\"\n        onChange={(e) =>\n          setField({ name: e.target.value === \"\" ? undefined : e.target.value })\n        }\n        className=\"h-8 w-[110px] font-mono text-xs\"\n      />\n      <TimeField\n        label={`duration${n}`}\n        value={layer.duration ?? \"\"}\n        onChange={(duration) =>\n          setField({ duration: duration === \"\" ? undefined : duration })\n        }\n      />\n      <TimeField\n        label={`delay${n}`}\n        value={layer.delay ?? \"\"}\n        onChange={(delay) =>\n          setField({ delay: delay === \"\" ? undefined : delay })\n        }\n      />\n      <EasingPicker\n        value={layer.easing ?? \"ease\"}\n        onChange={(easing) => setField({ easing })}\n        aria-label={`easing${n}`}\n        className=\"h-8\"\n      />\n      <span className=\"inline-flex items-center\">\n        <Input\n          aria-label={`iteration-count${n}`}\n          value={isInfinite ? \"\" : (layer.iterationCount ?? \"\")}\n          disabled={isInfinite}\n          inputMode=\"decimal\"\n          spellCheck={false}\n          autoComplete=\"off\"\n          placeholder=\"count\"\n          onChange={(e) =>\n            setField({\n              iterationCount:\n                e.target.value === \"\" ? undefined : e.target.value,\n            })\n          }\n          className=\"h-8 w-[56px] rounded-r-none border-r-0 font-mono text-xs\"\n        />\n        <button\n          type=\"button\"\n          aria-label={`infinite${n}`}\n          aria-pressed={isInfinite}\n          onClick={() =>\n            setField({ iterationCount: isInfinite ? \"1\" : \"infinite\" })\n          }\n          className={cn(\n            \"h-8 rounded-r-md border px-1.5 font-mono text-[10px]\",\n            isInfinite\n              ? \"bg-primary text-primary-foreground\"\n              : \"bg-background text-muted-foreground\",\n          )}\n        >\n          ∞\n        </button>\n      </span>\n      <KeywordSelect\n        label={`direction${n}`}\n        value={layer.direction}\n        options={DIRECTIONS}\n        onChange={(direction) => setField({ direction })}\n      />\n      <KeywordSelect\n        label={`fill-mode${n}`}\n        value={layer.fillMode}\n        options={FILL_MODES}\n        onChange={(fillMode) => setField({ fillMode })}\n      />\n      <KeywordSelect\n        label={`play-state${n}`}\n        value={layer.playState}\n        options={PLAY_STATES}\n        onChange={(playState) => setField({ playState })}\n      />\n    </>\n  )\n}\n",
      "type": "registry:ui",
      "target": "components/ui/transition-editor/fields.tsx"
    },
    {
      "path": "src/components/ui/transition-editor/preview.tsx",
      "content": "\"use client\"\n\nimport { useState } from \"react\"\nimport { UnitInput } from \"@/components/ui/unit-input\"\nimport { cn } from \"@/lib/utils\"\nimport {\n  formatAnimation,\n  formatTransition,\n  parseAnimation,\n  parseTime,\n  parseTransition,\n} from \"./transition-editor.helpers\"\nimport type { EditorMode } from \"./transition-editor.types\"\n\n// ---------------------------------------------------------------------------\n// Preview constants\n// ---------------------------------------------------------------------------\n\n// Demo keyframes for the preview (animation mode needs @keyframes to exist).\nconst PREVIEW_KEYFRAMES = `\n@keyframes te-slide { from { transform: translateX(0); } to { transform: translateX(96px); } }\n@keyframes te-pulse { 0%,100% { transform: scale(1); opacity: 1; } 50% { transform: scale(1.4); opacity: 0.6; } }\n@keyframes te-spin { to { transform: rotate(360deg); } }\n`\n\n// Map a demo keyframes-name to the @keyframes we actually ship in the preview.\nconst KEYFRAME_ALIAS: Record<string, string> = {\n  slide: \"te-slide\",\n  pulse: \"te-pulse\",\n  spin: \"te-spin\",\n}\n\n// ---------------------------------------------------------------------------\n// Preview value helpers\n// ---------------------------------------------------------------------------\n\n/** Rewrite each animation layer's name to the preview's prefixed @keyframes. */\nfunction aliasAnimation(value: string): string {\n  const layers = parseAnimation(value)\n  if (layers === null) return value\n  return formatAnimation(\n    layers.map((l) =>\n      l.name && KEYFRAME_ALIAS[l.name]\n        ? { ...l, name: KEYFRAME_ALIAS[l.name] }\n        : l,\n    ),\n  )\n}\n\n/** Replace the first layer's duration with `ms` while preserving the rest. */\nfunction setFirstDuration(mode: EditorMode, value: string, ms: number): string {\n  const dur = `${ms}ms`\n  if (mode === \"transition\") {\n    const layers = parseTransition(value)\n    if (layers === null || layers.length === 0) {\n      return formatTransition([{ property: \"all\", duration: dur }])\n    }\n    return formatTransition(\n      layers.map((l, i) => (i === 0 ? { ...l, duration: dur } : l)),\n    )\n  }\n  const layers = parseAnimation(value)\n  if (layers === null || layers.length === 0) {\n    return formatAnimation([{ name: \"slide\", duration: dur }])\n  }\n  return formatAnimation(\n    layers.map((l, i) => (i === 0 ? { ...l, duration: dur } : l)),\n  )\n}\n\n/** Pull the first layer's duration in ms, defaulting to 200. */\nfunction firstDurationMs(mode: EditorMode, value: string): number {\n  const layers =\n    mode === \"transition\" ? parseTransition(value) : parseAnimation(value)\n  const d = layers?.[0]?.duration\n  if (!d) return 200\n  const t = parseTime(d)\n  if (t === null) return 200\n  const n = Number.parseFloat(t.num)\n  if (Number.isNaN(n)) return 200\n  return t.unit === \"s\" ? n * 1000 : n\n}\n\n// ---------------------------------------------------------------------------\n// TransitionPreview — the live showcase\n// ---------------------------------------------------------------------------\n\nexport interface TransitionPreviewProps {\n  mode: EditorMode\n  value: string\n  onChange?: (value: string) => void\n  className?: string\n}\n\nexport function TransitionPreview({\n  mode,\n  value,\n  onChange,\n  className,\n}: TransitionPreviewProps) {\n  // `tick` remounts the animation target to restart it; `toggled` flips the\n  // transition target so the transition fires on each play press.\n  const [tick, setTick] = useState(0)\n  const [toggled, setToggled] = useState(false)\n\n  const applied = value === \"none\" ? \"\" : value\n  const durationMs = firstDurationMs(mode, value)\n\n  const transitionStyle =\n    mode === \"transition\"\n      ? {\n          transition: applied,\n          transform: toggled ? \"translateX(96px)\" : \"translateX(0)\",\n        }\n      : { animation: aliasAnimation(applied) }\n\n  const replay = () => {\n    if (mode === \"transition\") {\n      setToggled((t) => !t)\n    } else {\n      setTick((t) => t + 1)\n    }\n  }\n\n  return (\n    <div className={cn(\"space-y-3 rounded-lg border p-3\", className)}>\n      {/* Static demo keyframes (no user input); rendered as a text child, not\n          dangerouslySetInnerHTML, so there is no injection surface. */}\n      <style>{PREVIEW_KEYFRAMES}</style>\n      <div className=\"flex items-center justify-between\">\n        <div className=\"text-muted-foreground text-xs\">preview</div>\n        <button\n          type=\"button\"\n          onClick={replay}\n          aria-label={\n            mode === \"transition\" ? \"Play transition\" : \"Replay animation\"\n          }\n          className=\"rounded border px-2 py-0.5 font-mono text-[10px] text-muted-foreground hover:text-foreground\"\n        >\n          {mode === \"transition\" ? \"play\" : \"replay\"}\n        </button>\n      </div>\n\n      <div className=\"flex h-28 items-center overflow-hidden rounded-md bg-[radial-gradient(circle_at_30%_40%,#1e293b,#0f172a)] px-4\">\n        <div\n          key={mode === \"animation\" ? `anim-${tick}` : \"trans\"}\n          data-preview-target\n          className=\"h-10 w-10 rounded-lg bg-gradient-to-br from-cyan-300 to-violet-400\"\n          style={transitionStyle}\n          aria-hidden=\"true\"\n        />\n      </div>\n\n      {onChange ? (\n        <div className=\"flex items-center gap-2 text-xs\">\n          <span className=\"w-16 font-mono text-muted-foreground\">duration</span>\n          <UnitInput\n            unit=\"ms\"\n            value={`${Math.round(durationMs)}ms`}\n            min={0}\n            max={5000}\n            step={50}\n            aria-label=\"Duration (first layer) in ms\"\n            className=\"h-7 w-24\"\n            onChange={(next) => {\n              const n = Number.parseFloat(next)\n              onChange(setFirstDuration(mode, value, Number.isNaN(n) ? 0 : n))\n            }}\n          />\n        </div>\n      ) : null}\n    </div>\n  )\n}\n",
      "type": "registry:ui",
      "target": "components/ui/transition-editor/preview.tsx"
    }
  ],
  "type": "registry:ui"
}