{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "keyframes-editor",
  "title": "Keyframes Editor",
  "description": "Ridiculously typed editor for a CSS @keyframes body — the composition flagship. A two-level type validates each stop selector and dispatches every declaration into its property's own validator (transform, filter, color, easing, …). A timeline embeds the real editors per stop, with a scrubbed preview.",
  "dependencies": [],
  "registryDependencies": [
    "ridiculous-type-kit",
    "transform-builder",
    "filter-builder",
    "color-picker",
    "gradient-editor",
    "easing-picker",
    "unit-input",
    "button",
    "popover",
    "input"
  ],
  "files": [
    {
      "path": "src/components/ui/keyframes-editor/index.ts",
      "content": "export type {\n  DeclarationRowProps,\n  KeyframePreviewProps,\n  KeyframesEditorPanelProps,\n  KeyframesEditorProps,\n  KeyframeTimelineProps,\n  MiniSelectProps,\n} from \"./keyframes-editor\"\nexport {\n  DeclarationRow,\n  KeyframePreview,\n  KeyframesEditor,\n  KeyframesEditorPanel,\n  KeyframeTimeline,\n  LiveString,\n  MiniSelect,\n} from \"./keyframes-editor\"\nexport {\n  defaultKeyframes,\n  formatKeyframes,\n  parseKeyframes,\n  percentToSelector,\n  propertyEditorKind,\n  selectorToPercent,\n} from \"./keyframes-editor.helpers\"\nexport type {\n  Declaration,\n  KeyframeBlock,\n  KeyframePropertyKind,\n  KeyframesLiteral,\n  KeyframesString,\n  KeyframesValue,\n  StopsOf,\n} from \"./keyframes-editor.types\"\nexport { cssKeyframes } from \"./keyframes-editor.types\"\n",
      "type": "registry:ui",
      "target": "components/ui/keyframes-editor/index.ts"
    },
    {
      "path": "src/components/ui/keyframes-editor/keyframes-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 { DeclarationRow } from \"./declaration-row\"\nimport { KeyframePreview } from \"./keyframe-preview\"\nimport { KeyframeTimeline } from \"./keyframe-timeline\"\nimport {\n  defaultKeyframes,\n  formatKeyframes,\n  parseKeyframes,\n  percentToSelector,\n  selectorToPercent,\n} from \"./keyframes-editor.helpers\"\nimport type {\n  Declaration,\n  KeyframeBlock,\n  KeyframesString,\n} from \"./keyframes-editor.types\"\n\n// Re-export the public sub-components + their prop types so consumers (and the\n// barrel) can import them from `./keyframes-editor`.\nexport type { DeclarationRowProps } from \"./declaration-row\"\nexport { DeclarationRow } from \"./declaration-row\"\nexport type { KeyframePreviewProps } from \"./keyframe-preview\"\nexport { KeyframePreview } from \"./keyframe-preview\"\nexport type { KeyframeTimelineProps } from \"./keyframe-timeline\"\nexport { KeyframeTimeline } from \"./keyframe-timeline\"\nexport type { MiniSelectProps } from \"./mini-select\"\nexport { MiniSelect } from \"./mini-select\"\n\n// ---------------------------------------------------------------------------\n// Props\n// ---------------------------------------------------------------------------\n\nexport interface KeyframesEditorPanelProps {\n  value: KeyframesString | (string & {})\n  onChange: (value: KeyframesString) => void\n  className?: string\n  \"aria-label\"?: string\n}\n\nexport interface KeyframesEditorProps extends KeyframesEditorPanelProps {}\n\n// ---------------------------------------------------------------------------\n// KeyframesEditor — popover-wrapped\n// ---------------------------------------------------------------------------\n\nexport function KeyframesEditor(props: KeyframesEditorProps) {\n  const {\n    value,\n    className,\n    \"aria-label\": ariaLabel = \"Edit a CSS @keyframes body\",\n  } = props\n  const { blocks, error } = parseKeyframes(String(value))\n  const label =\n    error !== null\n      ? \"invalid\"\n      : `${blocks.length} stop${blocks.length === 1 ? \"\" : \"s\"}`\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 aria-hidden=\"true\" className=\"text-foreground/60\">\n            ⏱\n          </span>\n          <span className=\"text-[10px] text-muted-foreground\">{label}</span>\n          <span className=\"max-w-[220px] truncate text-xs\">{value}</span>\n        </Button>\n      </PopoverTrigger>\n      <PopoverContent className=\"w-auto p-0\" align=\"start\">\n        <KeyframesEditorPanel {...props} />\n      </PopoverContent>\n    </Popover>\n  )\n}\n\n// ---------------------------------------------------------------------------\n// KeyframesEditorPanel — inline\n// ---------------------------------------------------------------------------\n\nexport function KeyframesEditorPanel({\n  value,\n  onChange,\n  className,\n  \"aria-label\": ariaLabel = \"CSS @keyframes editor\",\n}: KeyframesEditorPanelProps) {\n  const [blocks, setBlocks] = useState<KeyframeBlock[]>(() => {\n    const parsed = parseKeyframes(String(value) || defaultKeyframes())\n    return parsed.error === null ? parsed.blocks : []\n  })\n  const [selected, setSelected] = useState(0)\n  const [position, setPosition] = useState(0)\n  const lastEmittedRef = useRef<string | null>(null)\n\n  // Resync from an external value (skip our own emits).\n  useEffect(() => {\n    if (value === lastEmittedRef.current) return\n    const parsed = parseKeyframes(String(value))\n    if (parsed.error === null) {\n      setBlocks(parsed.blocks)\n      setSelected((s) => Math.min(s, Math.max(0, parsed.blocks.length - 1)))\n    }\n  }, [value])\n\n  const commit = (next: KeyframeBlock[]) => {\n    setBlocks(next)\n    const str = formatKeyframes(next)\n    lastEmittedRef.current = str\n    onChange(str as KeyframesString)\n  }\n\n  const updateDeclaration = (declIndex: number, decl: Declaration) => {\n    commit(\n      blocks.map((b, i) =>\n        i === selected\n          ? {\n              ...b,\n              declarations: b.declarations.map((d, j) =>\n                j === declIndex ? decl : d,\n              ),\n            }\n          : b,\n      ),\n    )\n  }\n\n  const removeDeclaration = (declIndex: number) => {\n    commit(\n      blocks.map((b, i) =>\n        i === selected\n          ? {\n              ...b,\n              declarations: b.declarations.filter((_, j) => j !== declIndex),\n            }\n          : b,\n      ),\n    )\n  }\n\n  const addDeclaration = () => {\n    commit(\n      blocks.map((b, i) =>\n        i === selected\n          ? {\n              ...b,\n              declarations: [\n                ...b.declarations,\n                { property: \"opacity\", value: \"1\" },\n              ],\n            }\n          : b,\n      ),\n    )\n  }\n\n  const addStop = () => {\n    // Insert a stop at a free percent between the selected stop and the next.\n    const used = new Set(\n      blocks.map((b) => selectorToPercent(b.selectors[0] ?? \"from\")),\n    )\n    let pct = 50\n    for (let p = 50; p <= 95; p += 5) {\n      if (!used.has(p)) {\n        pct = p\n        break\n      }\n    }\n    const fresh: KeyframeBlock = {\n      selectors: [percentToSelector(pct)],\n      declarations: [{ property: \"opacity\", value: \"1\" }],\n    }\n    commit([...blocks, fresh])\n  }\n\n  const removeStop = (index: number) => {\n    const next = blocks.filter((_, i) => i !== index)\n    commit(next)\n    setSelected((s) => Math.min(s, Math.max(0, next.length - 1)))\n  }\n\n  const selectedBlock = blocks[selected]\n  const produced = formatKeyframes(blocks)\n\n  return (\n    <fieldset\n      className={cn(\n        \"m-0 w-[560px] space-y-3 border-0 bg-background p-3\",\n        className,\n      )}\n      aria-label={ariaLabel}\n    >\n      <KeyframeTimeline\n        blocks={blocks}\n        selected={selected}\n        position={position}\n        onSelect={setSelected}\n        onPosition={setPosition}\n        onAddStop={addStop}\n        onRemoveStop={removeStop}\n      />\n\n      <div data-testid=\"keyframe-declarations\" className=\"space-y-1.5\">\n        <div className=\"flex items-center justify-between\">\n          <span className=\"font-mono text-[10px] text-muted-foreground uppercase\">\n            {selectedBlock\n              ? `stop ${selectorToPercent(selectedBlock.selectors[0] ?? \"from\")}% declarations`\n              : \"declarations\"}\n          </span>\n          <button\n            type=\"button\"\n            aria-label=\"Add declaration\"\n            onClick={addDeclaration}\n            disabled={!selectedBlock}\n            className=\"rounded border border-dashed px-2 py-0.5 font-mono text-[10px] text-muted-foreground hover:text-foreground disabled:opacity-40\"\n          >\n            + declaration\n          </button>\n        </div>\n\n        <div className=\"max-h-[240px] space-y-1.5 overflow-y-auto pr-1\">\n          {selectedBlock?.declarations.map((decl, i) => (\n            <DeclarationRow\n              // biome-ignore lint/suspicious/noArrayIndexKey: declarations are a positional list edited in place; index is the stable identity.\n              key={i}\n              index={i}\n              declaration={decl}\n              onChange={(next) => updateDeclaration(i, next)}\n              onRemove={() => removeDeclaration(i)}\n            />\n          ))}\n        </div>\n      </div>\n\n      <LiveString value={produced} />\n\n      <KeyframePreview\n        blocks={blocks}\n        position={position}\n        onPosition={setPosition}\n      />\n    </fieldset>\n  )\n}\n\n// ---------------------------------------------------------------------------\n// LiveString — the produced keyframes body in a `<code>` (internal helper,\n// exported for parity with the sibling sub-components and demos).\n// ---------------------------------------------------------------------------\n\nexport function 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",
      "type": "registry:ui",
      "target": "components/ui/keyframes-editor/keyframes-editor.tsx"
    },
    {
      "path": "src/components/ui/keyframes-editor/keyframes-editor.types.ts",
      "content": "// =====================================================================\n// keyframes-editor.types.ts — ridiculously typed CSS @keyframes body.\n//\n// The composition flagship. KeyframesLiteral<S> is a TWO-LEVEL fold:\n//   (1) each `<keyframe-selector>` block header — from | to | a comma list\n//       of <percentage> 0–100;\n//   (2) each declaration's value, dispatched on the PROPERTY NAME into that\n//       property's own ridiculous validator —\n//         transform                    → transform-builder's TransformLiteral\n//         filter | backdrop-filter     → filter-builder's FilterLiteral\n//         color | background-color | … → color-picker's ColorLiteral\n//         *-timing-function            → easing-picker's EasingLiteral\n//         opacity                      → 0–1 number\n//         length properties            → <length-percentage>\n//         unknown property             → lenient (value not gated)\n//\n// The only registry component whose strict tier delegates to four sibling\n// validators. Unknown properties + background + calc()/var() are deferred\n// (see spec §3.2). tsc-budget gate: spec §3.1.\n//\n// Spec: docs/superpowers/specs/2026-06-19-keyframes-editor-design.md\n// =====================================================================\n\nimport type { ColorLiteral } from \"@/components/ui/color-picker\"\nimport type { EasingLiteral } from \"@/components/ui/easing-picker\"\nimport type { FilterLiteral } from \"@/components/ui/filter-builder\"\nimport type { TransformLiteral } from \"@/components/ui/transform-builder\"\nimport type {\n  And,\n  IsLength,\n  IsNumber0To1,\n  IsPercent0To100,\n  IsPercentage,\n  KeepIf,\n  Or,\n  SplitByComma,\n  Trim,\n} from \"@/lib/ridiculous-type-kit\"\n\n// `Literal<V>` resolves to `V` (valid) or `never` (invalid); collapse to bool.\ntype Sat<L extends string> = [L] extends [never] ? false : true\n\n// ---------------------------------------------------------------------------\n// local splitters (declarations split on `;`; CSS values carry no top-level `;`)\n// ---------------------------------------------------------------------------\n\ntype SplitBySemi<\n  S extends string,\n  Acc extends string[] = [],\n> = S extends `${infer H};${infer R}`\n  ? SplitBySemi<R, [...Acc, H]>\n  : [...Acc, S]\n\n// ---------------------------------------------------------------------------\n// property → value dispatch\n// ---------------------------------------------------------------------------\n\ntype ColorProp =\n  | \"color\"\n  | \"background-color\"\n  | \"border-color\"\n  | \"outline-color\"\n  | \"caret-color\"\n  | \"text-decoration-color\"\n  | \"fill\"\n  | \"stroke\"\n\ntype LengthProp =\n  | \"width\"\n  | \"height\"\n  | \"min-width\"\n  | \"min-height\"\n  | \"max-width\"\n  | \"max-height\"\n  | \"top\"\n  | \"left\"\n  | \"right\"\n  | \"bottom\"\n  | \"inset\"\n  | \"margin\"\n  | \"margin-top\"\n  | \"margin-right\"\n  | \"margin-bottom\"\n  | \"margin-left\"\n  | \"padding\"\n  | \"padding-top\"\n  | \"padding-right\"\n  | \"padding-bottom\"\n  | \"padding-left\"\n  | \"gap\"\n  | \"row-gap\"\n  | \"column-gap\"\n  | \"font-size\"\n  | \"line-height\"\n  | \"border-radius\"\n  | \"border-width\"\n\n/** Which embedded editor / validator a property routes to. */\nexport type KeyframePropertyKind =\n  | \"transform\"\n  | \"filter\"\n  | \"color\"\n  | \"easing\"\n  | \"opacity\"\n  | \"length\"\n  | \"plain\"\n\ntype DispatchValue<\n  Prop extends string,\n  Value extends string,\n> = Prop extends \"transform\"\n  ? Sat<TransformLiteral<Value>>\n  : Prop extends \"filter\" | \"backdrop-filter\"\n    ? Sat<FilterLiteral<Value>>\n    : Prop extends ColorProp\n      ? Sat<ColorLiteral<Value>>\n      : Prop extends \"animation-timing-function\" | \"transition-timing-function\"\n        ? Sat<EasingLiteral<Value>>\n        : Prop extends \"opacity\"\n          ? IsNumber0To1<Value>\n          : Prop extends LengthProp\n            ? Or<IsLength<Value>, IsPercentage<Value>>\n            : // unknown property → lenient (value not gated)\n              true\n\n// ---------------------------------------------------------------------------\n// declarations\n// ---------------------------------------------------------------------------\n\ntype ValidateDecl<D extends string> = D extends `${infer Prop}:${infer Value}`\n  ? DispatchValue<Trim<Prop>, Trim<Value>>\n  : false\n\ntype AllDecls<Decls extends string[]> = Decls extends [\n  infer H extends string,\n  ...infer R extends string[],\n]\n  ? Trim<H> extends \"\"\n    ? AllDecls<R>\n    : ValidateDecl<Trim<H>> extends true\n      ? AllDecls<R>\n      : false\n  : true\n\n// ---------------------------------------------------------------------------\n// selectors\n// ---------------------------------------------------------------------------\n\ntype IsSelector<S extends string> = S extends \"from\" | \"to\"\n  ? true\n  : IsPercent0To100<S>\n\ntype ValidateSelectors<Sels extends string[]> = Sels extends [\n  infer H extends string,\n  ...infer R extends string[],\n]\n  ? IsSelector<Trim<H>> extends true\n    ? ValidateSelectors<R>\n    : false\n  : true\n\n// ---------------------------------------------------------------------------\n// blocks\n// ---------------------------------------------------------------------------\n\ntype ValidateBlock<Sel extends string, Decls extends string> = And<\n  ValidateSelectors<SplitByComma<Sel>>,\n  AllDecls<SplitBySemi<Decls>>\n>\n\ntype ParseBlocks<S extends string> =\n  Trim<S> extends \"\"\n    ? true\n    : Trim<S> extends `${infer Sel}{${infer Decls}}${infer Rest}`\n      ? ValidateBlock<Sel, Decls> extends true\n        ? ParseBlocks<Rest>\n        : false\n      : false\n\n/** Strict validator for a `@keyframes` body. `S` or `never`. */\nexport type KeyframesLiteral<S extends string> =\n  S extends `${string}{${string}}${string}` ? KeepIf<ParseBlocks<S>, S> : never\n\n// ---------------------------------------------------------------------------\n// call-site helper + suggestion + utility\n// ---------------------------------------------------------------------------\n\nexport const cssKeyframes = <S extends string>(\n  value: S & KeyframesLiteral<S>,\n): S => value\n\nexport type KeyframesString = string & {}\n\n/** Count of keyframe blocks (stops) in a body. */\ntype CountBlocks<\n  S extends string,\n  N extends unknown[] = [],\n> = S extends `${string}{${string}}${infer Rest}`\n  ? CountBlocks<Rest, [...N, unknown]>\n  : N[\"length\"]\n\nexport type StopsOf<S extends string> = CountBlocks<S>\n\n// ---------------------------------------------------------------------------\n// internal state (exported for advanced use)\n// ---------------------------------------------------------------------------\n\nexport interface Declaration {\n  property: string\n  value: string\n}\n\nexport interface KeyframeBlock {\n  selectors: string[]\n  declarations: Declaration[]\n}\n\nexport interface KeyframesValue {\n  blocks: KeyframeBlock[]\n}\n",
      "type": "registry:ui",
      "target": "components/ui/keyframes-editor/keyframes-editor.types.ts"
    },
    {
      "path": "src/components/ui/keyframes-editor/keyframes-editor.helpers.ts",
      "content": "// =====================================================================\n// keyframes-editor.helpers.ts\n//\n// Pure runtime parse / format / dispatch for the body of a CSS `@keyframes`\n// rule. This is the SUPERSET of the strict type tier in\n// keyframes-editor.types.ts: it splits the block list (`sel { decls }`),\n// splits selectors on commas and declarations on `;` then the FIRST `:`, and\n// surfaces `propertyEditorKind` — the runtime twin of the type's\n// `DispatchValue` table that tells the UI which embedded editor a declaration\n// opens. The single source the timeline UI parses from and serializes to.\n//\n// The component edits the @keyframes BODY (block list), not the\n// `@keyframes name { }` wrapper (spec §1, A1). Stops are sorted on format:\n// from=0 < % < to=100 (spec §4, A7).\n//\n// Spec: docs/superpowers/specs/2026-06-19-keyframes-editor-design.md §4 / §4.1\n// =====================================================================\n\nimport type {\n  Declaration,\n  KeyframeBlock,\n  KeyframePropertyKind,\n} from \"./keyframes-editor.types\"\n\n// ---------------------------------------------------------------------------\n// dispatch vocabulary — runtime mirror of the type's ColorProp / LengthProp\n// unions + DispatchValue ladder.\n// ---------------------------------------------------------------------------\n\nconst COLOR_PROPS = new Set<string>([\n  \"color\",\n  \"background-color\",\n  \"border-color\",\n  \"outline-color\",\n  \"caret-color\",\n  \"text-decoration-color\",\n  \"fill\",\n  \"stroke\",\n])\n\nconst LENGTH_PROPS = new Set<string>([\n  \"width\",\n  \"height\",\n  \"min-width\",\n  \"min-height\",\n  \"max-width\",\n  \"max-height\",\n  \"top\",\n  \"left\",\n  \"right\",\n  \"bottom\",\n  \"inset\",\n  \"margin\",\n  \"margin-top\",\n  \"margin-right\",\n  \"margin-bottom\",\n  \"margin-left\",\n  \"padding\",\n  \"padding-top\",\n  \"padding-right\",\n  \"padding-bottom\",\n  \"padding-left\",\n  \"gap\",\n  \"row-gap\",\n  \"column-gap\",\n  \"font-size\",\n  \"line-height\",\n  \"border-radius\",\n  \"border-width\",\n])\n\nconst FILTER_PROPS = new Set<string>([\"filter\", \"backdrop-filter\"])\n\nconst EASING_PROPS = new Set<string>([\n  \"animation-timing-function\",\n  \"transition-timing-function\",\n])\n\n/**\n * Which embedded editor a declaration opens — the runtime mirror of the type\n * `DispatchValue`. `transform`→transform, `filter`/`backdrop-filter`→filter,\n * the color-ish properties→color, `*-timing-function`→easing, `opacity`→\n * opacity, the length properties→length. `background`/`background-image` are\n * `plain` here (the type defers them; the UI routes them to a gradient editor\n * separately — spec §3.2, A4); every other property is `plain`.\n */\nexport function propertyEditorKind(property: string): KeyframePropertyKind {\n  const p = property.trim()\n  if (p === \"transform\") return \"transform\"\n  if (FILTER_PROPS.has(p)) return \"filter\"\n  if (COLOR_PROPS.has(p)) return \"color\"\n  if (EASING_PROPS.has(p)) return \"easing\"\n  if (p === \"opacity\") return \"opacity\"\n  if (LENGTH_PROPS.has(p)) return \"length\"\n  return \"plain\"\n}\n\n// ---------------------------------------------------------------------------\n// parseKeyframes — string → { blocks, error }\n// ---------------------------------------------------------------------------\n\n/**\n * Split a declaration list (the inside of a `{ }`) into typed declarations.\n * Declarations split on `;`; each is split on its FIRST `:` so a value half\n * may carry a colon (`url(http://x)`). Empty segments (a trailing `;`) are\n * dropped. A segment with no `:` is skipped.\n */\nfunction parseDeclarations(body: string): Declaration[] {\n  const out: Declaration[] = []\n  for (const seg of body.split(\";\")) {\n    const s = seg.trim()\n    if (s === \"\") continue\n    const colon = s.indexOf(\":\")\n    if (colon === -1) continue\n    const property = s.slice(0, colon).trim()\n    const value = s.slice(colon + 1).trim()\n    if (property === \"\") continue\n    out.push({ property, value })\n  }\n  return out\n}\n\n/**\n * Parse a `@keyframes` body into its block list. `error` is `null` on success\n * and a message otherwise; on error `blocks` holds whatever parsed so far.\n * Each block is `<selector-list> { <declaration-list> }`: selectors split on\n * commas, declarations split on `;` then the first `:`. Rejects an empty body,\n * a body with no blocks, an unclosed block, and a block with no selector. CSS\n * values carry no top-level `{ }`, so a flat `{`/`}` scan is sufficient.\n */\nexport function parseKeyframes(src: string): {\n  blocks: KeyframeBlock[]\n  error: string | null\n} {\n  const trimmed = src.trim()\n  if (trimmed === \"\") {\n    return { blocks: [], error: \"empty keyframes body\" }\n  }\n\n  const blocks: KeyframeBlock[] = []\n  let rest = trimmed\n  while (rest.trim() !== \"\") {\n    const open = rest.indexOf(\"{\")\n    if (open === -1) {\n      return { blocks, error: `expected a block: ${rest.trim()}` }\n    }\n    const close = rest.indexOf(\"}\", open)\n    if (close === -1) {\n      return { blocks, error: \"unclosed keyframe block\" }\n    }\n    const header = rest.slice(0, open).trim()\n    if (header === \"\") {\n      return { blocks, error: \"a keyframe block needs a selector\" }\n    }\n    const selectors = header\n      .split(\",\")\n      .map((s) => s.trim())\n      .filter((s) => s.length > 0)\n    if (selectors.length === 0) {\n      return { blocks, error: \"a keyframe block needs a selector\" }\n    }\n    const declarations = parseDeclarations(rest.slice(open + 1, close))\n    blocks.push({ selectors, declarations })\n    rest = rest.slice(close + 1)\n  }\n\n  if (blocks.length === 0) {\n    return { blocks, error: \"no keyframe blocks found\" }\n  }\n  return { blocks, error: null }\n}\n\n// ---------------------------------------------------------------------------\n// selectorToPercent / percentToSelector — the timeline coordinate map (§4.1)\n// ---------------------------------------------------------------------------\n\n/**\n * Map a `<keyframe-selector>` to its timeline percent: `from`→0, `to`→100, an\n * `N%` selector→its numeric part. A non-numeric/unknown selector yields `0`.\n */\nexport function selectorToPercent(sel: string): number {\n  const s = sel.trim()\n  if (s === \"from\") return 0\n  if (s === \"to\") return 100\n  const n = Number.parseFloat(s)\n  return Number.isFinite(n) ? n : 0\n}\n\n/**\n * Map a timeline percent back to a canonical `<keyframe-selector>`: 0→`from`,\n * 100→`to`, anything in between→`N%`.\n */\nexport function percentToSelector(n: number): string {\n  if (n === 0) return \"from\"\n  if (n === 100) return \"to\"\n  return `${n}%`\n}\n\n// ---------------------------------------------------------------------------\n// formatKeyframes — KeyframeBlock[] → canonical string (sorted)\n// ---------------------------------------------------------------------------\n\nfunction declarationToCss(d: Declaration): string {\n  return `${d.property}: ${d.value}`\n}\n\nfunction blockToCss(block: KeyframeBlock): string {\n  const head = block.selectors.join(\", \")\n  const body = block.declarations.map(declarationToCss).join(\"; \")\n  return `${head} { ${body} }`\n}\n\n/**\n * Canonical re-serialization of a `@keyframes` body. Stops are sorted by their\n * FIRST selector's percent (`from`=0 < `N%` < `to`=100; spec §4, A7); within a\n * block, selectors join with `, ` and declarations with `; `. An empty\n * declaration list serializes as `sel {  }`.\n */\nexport function formatKeyframes(blocks: KeyframeBlock[]): string {\n  const sorted = [...blocks].sort(\n    (a, b) =>\n      selectorToPercent(a.selectors[0] ?? \"from\") -\n      selectorToPercent(b.selectors[0] ?? \"from\"),\n  )\n  return sorted.map(blockToCss).join(\" \")\n}\n\n// ---------------------------------------------------------------------------\n// defaultKeyframes\n// ---------------------------------------------------------------------------\n\n/** A valid, parseable two-stop seed for a freshly-created editor. */\nexport function defaultKeyframes(): string {\n  return \"from { opacity: 0 } to { opacity: 1 }\"\n}\n",
      "type": "registry:ui",
      "target": "components/ui/keyframes-editor/keyframes-editor.helpers.ts"
    },
    {
      "path": "src/components/ui/keyframes-editor/keyframe-timeline.tsx",
      "content": "\"use client\"\n\nimport { cn } from \"@/lib/utils\"\nimport { selectorToPercent } from \"./keyframes-editor.helpers\"\nimport type { KeyframeBlock } from \"./keyframes-editor.types\"\n\n// ---------------------------------------------------------------------------\n// KeyframeTimeline (public) — a horizontal 0–100% track with one draggable stop\n// marker per keyframe block (`from`=0%, `to`=100%, `N%` in between) plus an\n// add-stop control and a labelled play-head slider that scrubs the live\n// preview. Selecting a marker raises `onSelect`; the container reveals that\n// stop's declaration list. The container owns the `KeyframeBlock[]`; this is\n// presentational. a11y: every marker is a labelled button; the play head is a\n// labelled `<input type=range>`.\n// ---------------------------------------------------------------------------\n\n/** A marker's label percent — the first selector's position on the track. */\nfunction blockPercent(block: KeyframeBlock): number {\n  return selectorToPercent(block.selectors[0] ?? \"from\")\n}\n\nexport interface KeyframeTimelineProps {\n  blocks: KeyframeBlock[]\n  selected: number\n  position: number\n  onSelect: (index: number) => void\n  onPosition: (percent: number) => void\n  onAddStop: () => void\n  onRemoveStop: (index: number) => void\n  className?: string\n}\n\nexport function KeyframeTimeline({\n  blocks,\n  selected,\n  position,\n  onSelect,\n  onPosition,\n  onAddStop,\n  onRemoveStop,\n  className,\n}: KeyframeTimelineProps) {\n  return (\n    <div className={cn(\"space-y-2\", className)}>\n      <div className=\"flex items-center justify-between\">\n        <span className=\"font-mono text-[10px] text-muted-foreground uppercase\">\n          timeline\n        </span>\n        <button\n          type=\"button\"\n          aria-label=\"Add stop\"\n          onClick={onAddStop}\n          className=\"rounded border border-dashed px-2 py-0.5 font-mono text-[10px] text-muted-foreground hover:text-foreground\"\n        >\n          + stop\n        </button>\n      </div>\n\n      {/* The 0–100% track. Markers are absolutely positioned by percent. */}\n      <div className=\"relative h-10 rounded-md border bg-muted/30\">\n        {/* The play head — a vertical line at the scrub position. */}\n        <div\n          aria-hidden=\"true\"\n          className=\"absolute top-0 bottom-0 w-px bg-primary\"\n          style={{ left: `${position}%` }}\n        />\n        {blocks.map((block, i) => {\n          const pct = blockPercent(block)\n          const isSelected = i === selected\n          return (\n            <div\n              // biome-ignore lint/suspicious/noArrayIndexKey: stops are a positional list reordered only by add/remove; index is the stable identity.\n              key={`stop-${i}`}\n              className=\"absolute top-1.5 flex -translate-x-1/2 flex-col items-center\"\n              style={{ left: `${pct}%` }}\n            >\n              <button\n                type=\"button\"\n                aria-label={`stop at ${pct}%`}\n                aria-pressed={isSelected}\n                onClick={() => onSelect(i)}\n                className={cn(\n                  \"size-4 rounded-full border-2 bg-background transition-colors\",\n                  isSelected\n                    ? \"border-primary bg-primary\"\n                    : \"border-muted-foreground/50 hover:border-primary\",\n                )}\n              />\n              <span className=\"mt-0.5 font-mono text-[9px] text-muted-foreground\">\n                {pct}%\n              </span>\n              {blocks.length > 1 && (\n                <button\n                  type=\"button\"\n                  aria-label={`remove stop at ${pct}%`}\n                  onClick={() => onRemoveStop(i)}\n                  className=\"absolute -top-1.5 -right-2 rounded text-[9px] text-muted-foreground hover:text-destructive\"\n                >\n                  ×\n                </button>\n              )}\n            </div>\n          )\n        })}\n      </div>\n\n      {/* The play head slider — scrubs the preview. */}\n      <input\n        type=\"range\"\n        aria-label=\"Play head position\"\n        min={0}\n        max={100}\n        step={1}\n        value={position}\n        onChange={(e) => onPosition(Number(e.target.value))}\n        className=\"w-full accent-primary\"\n      />\n    </div>\n  )\n}\n",
      "type": "registry:ui",
      "target": "components/ui/keyframes-editor/keyframe-timeline.tsx"
    },
    {
      "path": "src/components/ui/keyframes-editor/declaration-row.tsx",
      "content": "\"use client\"\n\nimport { ColorPicker } from \"@/components/ui/color-picker\"\nimport { EasingPicker } from \"@/components/ui/easing-picker\"\nimport { FilterBuilder } from \"@/components/ui/filter-builder\"\nimport { GradientEditor } from \"@/components/ui/gradient-editor\"\nimport { Input } from \"@/components/ui/input\"\nimport { TransformBuilder } from \"@/components/ui/transform-builder\"\nimport { UnitInput } from \"@/components/ui/unit-input\"\nimport { cn } from \"@/lib/utils\"\nimport { propertyEditorKind } from \"./keyframes-editor.helpers\"\nimport type { Declaration } from \"./keyframes-editor.types\"\nimport { MiniSelect } from \"./mini-select\"\n\n// ---------------------------------------------------------------------------\n// DeclarationRow (public) — one `<property> : <value>` row inside a keyframe\n// stop. The property `<select>` chooses the CSS property; the VALUE editor is\n// dispatched by `propertyEditorKind` — the runtime mirror of the type's\n// `DispatchValue` table — into the matching sibling editor:\n//   transform → TransformBuilder · filter → FilterBuilder · color → ColorPicker\n//   background → GradientEditor · easing → EasingPicker · length → UnitInput\n//   opacity → a 0–1 UnitInput-style number input · plain → a bare <input>.\n//\n// This is the composition spectacle (spec §4.1): real typed editors per\n// declaration. The container owns the `Declaration`; this is presentational.\n// Add/remove are driven from the container via the parent's callbacks.\n// ---------------------------------------------------------------------------\n\n/** The property menu — common animatable properties, grouped by editor kind. */\nconst PROPERTY_OPTIONS: readonly string[] = [\n  \"transform\",\n  \"opacity\",\n  \"filter\",\n  \"backdrop-filter\",\n  \"color\",\n  \"background-color\",\n  \"border-color\",\n  \"fill\",\n  \"stroke\",\n  \"background\",\n  \"background-image\",\n  \"animation-timing-function\",\n  \"transition-timing-function\",\n  \"width\",\n  \"height\",\n  \"top\",\n  \"left\",\n  \"right\",\n  \"bottom\",\n  \"margin\",\n  \"padding\",\n  \"border-radius\",\n  \"font-size\",\n  \"line-height\",\n  \"visibility\",\n]\n\n/** `background`/`background-image` route to the gradient editor (spec §3.2, A4). */\nfunction isGradientProp(property: string): boolean {\n  return property === \"background\" || property === \"background-image\"\n}\n\n/** Split a `<length-percentage>` into its unit so UnitInput can edit it. */\nfunction unitOf(value: string): string {\n  const m = value.match(/[a-z%]+$/i)\n  return m ? m[0] : \"px\"\n}\n\nexport interface DeclarationRowProps {\n  index: number\n  declaration: Declaration\n  onChange: (next: Declaration) => void\n  onRemove: () => void\n  className?: string\n}\n\nexport function DeclarationRow({\n  index,\n  declaration,\n  onChange,\n  onRemove,\n  className,\n}: DeclarationRowProps) {\n  const n = index + 1\n  const { property, value } = declaration\n  const kind = propertyEditorKind(property)\n  const setValue = (next: string) => onChange({ property, value: next })\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      <MiniSelect\n        aria-label={`declaration ${n} property`}\n        value={property}\n        onValueChange={(p) => onChange({ property: p, value })}\n        className=\"w-[150px]\"\n      >\n        {PROPERTY_OPTIONS.includes(property) ? null : (\n          <option value={property}>{property}</option>\n        )}\n        {PROPERTY_OPTIONS.map((p) => (\n          <option key={p} value={p}>\n            {p}\n          </option>\n        ))}\n      </MiniSelect>\n\n      <span aria-hidden=\"true\" className=\"text-muted-foreground text-xs\">\n        :\n      </span>\n\n      <ValueEditor\n        index={index}\n        property={property}\n        kind={kind}\n        value={value}\n        onChange={setValue}\n      />\n\n      <button\n        type=\"button\"\n        aria-label={`Remove declaration ${n}`}\n        onClick={onRemove}\n        className=\"ml-auto rounded p-1 text-muted-foreground hover:text-destructive\"\n      >\n        ×\n      </button>\n    </div>\n  )\n}\n\n// ---------------------------------------------------------------------------\n// ValueEditor — the dispatched value control. Pure routing over the kind.\n// ---------------------------------------------------------------------------\n\ninterface ValueEditorProps {\n  index: number\n  property: string\n  kind: ReturnType<typeof propertyEditorKind>\n  value: string\n  onChange: (next: string) => void\n}\n\nfunction ValueEditor({\n  index,\n  property,\n  kind,\n  value,\n  onChange,\n}: ValueEditorProps) {\n  const n = index + 1\n\n  // `background`/`background-image` are `plain` per the dispatch table, but the\n  // UI still embeds the gradient editor for them (spec §3.2, A4).\n  if (isGradientProp(property)) {\n    return <GradientEditor value={value} onChange={onChange} />\n  }\n\n  switch (kind) {\n    case \"transform\":\n      return <TransformBuilder value={value} onChange={onChange} />\n    case \"filter\":\n      return <FilterBuilder value={value} onChange={onChange} />\n    case \"color\":\n      return <ColorPicker value={value} onChange={onChange} />\n    case \"easing\":\n      return <EasingPicker value={value} onChange={onChange} />\n    case \"opacity\":\n      return (\n        <UnitInput\n          aria-label={`declaration ${n} value`}\n          unit=\"\"\n          value={value}\n          onChange={onChange}\n          min={0}\n          max={1}\n          step={0.05}\n          precision={2}\n          className=\"w-[110px]\"\n        />\n      )\n    case \"length\":\n      return (\n        <UnitInput\n          aria-label={`declaration ${n} value`}\n          unit={unitOf(value)}\n          value={value}\n          onChange={onChange}\n          className=\"w-[110px]\"\n        />\n      )\n    default:\n      return (\n        <Input\n          aria-label={`declaration ${n} value`}\n          value={value}\n          onChange={(e) => onChange(e.target.value)}\n          className=\"h-8 w-[160px] font-mono text-xs\"\n        />\n      )\n  }\n}\n",
      "type": "registry:ui",
      "target": "components/ui/keyframes-editor/declaration-row.tsx"
    },
    {
      "path": "src/components/ui/keyframes-editor/keyframe-preview.tsx",
      "content": "\"use client\"\n\nimport { useEffect, useRef, useState } from \"react\"\nimport { cn } from \"@/lib/utils\"\nimport { selectorToPercent } from \"./keyframes-editor.helpers\"\nimport type { KeyframeBlock } from \"./keyframes-editor.types\"\n\n// ---------------------------------------------------------------------------\n// KeyframePreview (public) — a LIGHTWEIGHT JS interpolation of the keyframe body\n// at the current play-head position (spec §4.1, A6). It is NOT a CSS animation\n// or timing engine: it linearly interpolates the surrounding stops' numeric /\n// length / opacity declarations and applies the result as the preview box's\n// inline style. Non-numeric declarations (transform, color, …) snap to the\n// nearest stop rather than interpolate — the demo affordance is \"scrub to see\n// the box move\", not a full renderer. A play/pause toggle auto-advances the\n// position via the parent's `onPosition`.\n//\n// `position` is a controlled 0–100 percent. When `onPosition` is omitted the\n// preview is static (the play toggle is hidden) so the component degrades to a\n// pure render in environments without a parent scrubber.\n// ---------------------------------------------------------------------------\n\n/** A handful of inline-style-safe properties we interpolate or snap. */\nconst NUMERIC_PROPS = new Set<string>([\"opacity\"])\n\n/** camelCase a kebab CSS property for React's inline-style object. */\nfunction toCamel(prop: string): string {\n  return prop.replace(/-([a-z])/g, (_, c: string) => c.toUpperCase())\n}\n\n/** Strip the unit off a `<length-percentage>`; returns null if non-numeric. */\nfunction splitUnit(value: string): { n: number; unit: string } | null {\n  const m = value.trim().match(/^(-?\\d*\\.?\\d+)([a-z%]*)$/i)\n  if (!m) return null\n  const n = Number.parseFloat(m[1])\n  if (!Number.isFinite(n)) return null\n  return { n, unit: m[2] }\n}\n\n/** Linear interpolate two declaration values; snap to `b` when non-numeric. */\nfunction lerpValue(a: string, b: string, t: number): string {\n  const av = splitUnit(a)\n  const bv = splitUnit(b)\n  if (av && bv && av.unit === bv.unit) {\n    const n = av.n + (bv.n - av.n) * t\n    return `${Number.parseFloat(n.toFixed(3))}${av.unit}`\n  }\n  // Non-numeric or mismatched units → snap at the midpoint.\n  return t < 0.5 ? a : b\n}\n\n/** Sort blocks by track percent so neighbour lookup is monotonic. */\nfunction sortByPercent(blocks: KeyframeBlock[]): KeyframeBlock[] {\n  return [...blocks].sort(\n    (x, y) =>\n      selectorToPercent(x.selectors[0] ?? \"from\") -\n      selectorToPercent(y.selectors[0] ?? \"from\"),\n  )\n}\n\n/**\n * Build the interpolated inline style at `position` (0–100). For each property\n * present anywhere, find the surrounding stops and lerp; opacity defaults to 1.\n */\nfunction interpolatedStyle(\n  blocks: KeyframeBlock[],\n  position: number,\n): React.CSSProperties {\n  const sorted = sortByPercent(blocks)\n  if (sorted.length === 0) return {}\n\n  // Collect every property declared in any stop.\n  const props = new Set<string>()\n  for (const block of sorted) {\n    for (const d of block.declarations) props.add(d.property)\n  }\n\n  const style: Record<string, string> = {}\n  for (const prop of props) {\n    // The stops (with their percent) that declare this property.\n    const points = sorted\n      .map((b) => ({\n        pct: selectorToPercent(b.selectors[0] ?? \"from\"),\n        decl: b.declarations.find((d) => d.property === prop),\n      }))\n      .filter(\n        (p): p is { pct: number; decl: { property: string; value: string } } =>\n          Boolean(p.decl),\n      )\n    if (points.length === 0) continue\n\n    // Clamp before the first / after the last declared stop.\n    if (position <= points[0].pct) {\n      style[toCamel(prop)] = points[0].decl.value\n      continue\n    }\n    if (position >= points[points.length - 1].pct) {\n      style[toCamel(prop)] = points[points.length - 1].decl.value\n      continue\n    }\n    // Find the bracketing pair and lerp.\n    for (let i = 0; i < points.length - 1; i++) {\n      const lo = points[i]\n      const hi = points[i + 1]\n      if (position >= lo.pct && position <= hi.pct) {\n        const span = hi.pct - lo.pct || 1\n        const t = (position - lo.pct) / span\n        const interp =\n          NUMERIC_PROPS.has(prop) || splitUnit(lo.decl.value)\n            ? lerpValue(lo.decl.value, hi.decl.value, t)\n            : t < 0.5\n              ? lo.decl.value\n              : hi.decl.value\n        style[toCamel(prop)] = interp\n        break\n      }\n    }\n  }\n  return style as React.CSSProperties\n}\n\nexport interface KeyframePreviewProps {\n  blocks: KeyframeBlock[]\n  /** The play-head position, 0–100. */\n  position: number\n  /** Controls play/pause auto-advance; omit for a static preview. */\n  onPosition?: (percent: number) => void\n  className?: string\n}\n\nexport function KeyframePreview({\n  blocks,\n  position,\n  onPosition,\n  className,\n}: KeyframePreviewProps) {\n  const [playing, setPlaying] = useState(false)\n  const rafRef = useRef<number | null>(null)\n  const lastRef = useRef<number>(0)\n  // Track the live position in a ref so the rAF loop advances from the latest\n  // value without restarting every frame (depending on `position` would).\n  const posRef = useRef(position)\n  posRef.current = position\n\n  // Auto-advance the play head while playing (≈ a 2s loop). Pure JS, no CSS\n  // animation engine (A6). Disabled when no `onPosition` is wired.\n  useEffect(() => {\n    if (!playing || !onPosition) return\n    lastRef.current = performance.now()\n    const tick = (now: number) => {\n      const dt = now - lastRef.current\n      lastRef.current = now\n      const next = (posRef.current + (dt / 2000) * 100) % 100.0001\n      onPosition(next)\n      rafRef.current = requestAnimationFrame(tick)\n    }\n    rafRef.current = requestAnimationFrame(tick)\n    return () => {\n      if (rafRef.current !== null) cancelAnimationFrame(rafRef.current)\n    }\n  }, [playing, onPosition])\n\n  const style = interpolatedStyle(blocks, position)\n\n  return (\n    <div className={cn(\"space-y-2 rounded-lg border p-3\", className)}>\n      <div className=\"flex items-center justify-between\">\n        <span className=\"text-muted-foreground text-xs\">\n          preview · {Math.round(position)}%\n        </span>\n        {/* Play/pause is always present (spec §5); it only auto-advances the\n            play head when a parent wires `onPosition`. */}\n        <button\n          type=\"button\"\n          aria-label={playing ? \"Pause\" : \"Play\"}\n          disabled={!onPosition}\n          onClick={() => setPlaying((p) => !p)}\n          className=\"rounded bg-primary/10 px-2 py-0.5 font-mono text-[10px] text-primary hover:bg-primary/20 disabled:opacity-40\"\n        >\n          {playing ? \"❚❚ pause\" : \"▶ play\"}\n        </button>\n      </div>\n\n      <div className=\"grid h-28 place-items-center rounded-md bg-[conic-gradient(at_30%_30%,theme(colors.muted.DEFAULT),transparent)] bg-muted/20\">\n        <div\n          data-testid=\"keyframe-preview-box\"\n          role=\"img\"\n          aria-label={`Preview at ${Math.round(position)}%`}\n          className=\"size-12 rounded-md bg-primary\"\n          style={style}\n        />\n      </div>\n\n      <p className=\"text-[10px] text-muted-foreground/70 leading-relaxed\">\n        Lightweight JS interpolation between adjacent stops (numbers / lengths /\n        opacity). Non-numeric values snap to the nearest stop — this is a scrub\n        demo, not a full CSS animation engine.\n      </p>\n    </div>\n  )\n}\n",
      "type": "registry:ui",
      "target": "components/ui/keyframes-editor/keyframe-preview.tsx"
    },
    {
      "path": "src/components/ui/keyframes-editor/mini-select.tsx",
      "content": "\"use client\"\n\nimport type { ReactNode } from \"react\"\nimport { cn } from \"@/lib/utils\"\n\n// ---------------------------------------------------------------------------\n// MiniSelect — the compact `<select>` chrome shared by every keyframes-editor\n// dropdown (the per-declaration property select). Owns the one class-string so\n// the controls never drift. A LOCAL copy: registry self-containment means this\n// component carries its own MiniSelect rather than importing query-builder's\n// (`shadcn add` must pull a self-contained tree). `onValueChange` hands back\n// the raw `e.target.value`.\n// ---------------------------------------------------------------------------\n\nexport const selectClass =\n  \"h-8 rounded-md border border-input bg-background px-1 font-mono text-xs\"\n\nexport interface MiniSelectProps {\n  \"aria-label\": string\n  value: string\n  onValueChange: (value: string) => void\n  children: ReactNode\n  className?: string\n}\n\nexport function MiniSelect({\n  \"aria-label\": ariaLabel,\n  value,\n  onValueChange,\n  children,\n  className,\n}: MiniSelectProps) {\n  return (\n    <select\n      aria-label={ariaLabel}\n      value={value}\n      onChange={(e) => onValueChange(e.target.value)}\n      className={cn(selectClass, className)}\n    >\n      {children}\n    </select>\n  )\n}\n",
      "type": "registry:ui",
      "target": "components/ui/keyframes-editor/mini-select.tsx"
    }
  ],
  "type": "registry:ui"
}