{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "filter-builder",
  "title": "Filter Builder",
  "description": "Ridiculously typed CSS filter / backdrop-filter editor — a space-separated function list with compile-time per-function arity and dimension typing, plus a drop-shadow color validated against the color-picker ColorLiteral. Ships a tolerant runtime parser and a live preview with a filter ↔ backdrop-filter toggle.",
  "dependencies": [],
  "registryDependencies": [
    "ridiculous-type-kit",
    "color-picker",
    "button",
    "popover",
    "input",
    "label",
    "slider"
  ],
  "files": [
    {
      "path": "src/components/ui/filter-builder/index.ts",
      "content": "export type {\n  AddFilterMenuProps,\n  FilterArgEditorProps,\n  FilterBuilderPanelProps,\n  FilterBuilderProps,\n  FilterFunctionRowProps,\n  FilterPreviewProps,\n} from \"./filter-builder\"\nexport {\n  AddFilterMenu,\n  FilterArgEditor,\n  FilterBuilder,\n  FilterBuilderPanel,\n  FilterFunctionRow,\n  FilterPreview,\n} from \"./filter-builder\"\nexport type {\n  ArgKind,\n  ArgSpec,\n} from \"./filter-builder.helpers\"\nexport {\n  argSpec,\n  defaultItem,\n  filterFunctions,\n  formatFilter,\n  isAmountFn,\n  itemToCss,\n  parseFilter,\n} from \"./filter-builder.helpers\"\nexport type {\n  AmountFn,\n  Dimension,\n  FilterFn,\n  FilterFunctionName,\n  FilterItem,\n  FilterLiteral,\n  FilterString,\n  FilterStringMap,\n  FunctionCountOf,\n  FunctionsOf,\n  HasDropShadow,\n} from \"./filter-builder.types\"\nexport { cssFilter } from \"./filter-builder.types\"\n",
      "type": "registry:ui",
      "target": "components/ui/filter-builder/index.ts"
    },
    {
      "path": "src/components/ui/filter-builder/filter-builder.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 type { FilterMode } from \"./filter-builder.constants\"\nimport {\n  defaultItem,\n  formatFilter,\n  parseFilter,\n} from \"./filter-builder.helpers\"\nimport type {\n  FilterFunctionName,\n  FilterItem,\n  FilterString,\n} from \"./filter-builder.types\"\nimport { FilterPreview } from \"./filter-preview\"\nimport { FilterFunctionRow, FunctionOptions } from \"./filter-row\"\n\n// Re-export the extracted sub-components + their prop types so the public\n// entry surface (and existing deep imports) is unchanged by the file split.\nexport type { FilterPreviewProps } from \"./filter-preview\"\nexport { FilterPreview } from \"./filter-preview\"\nexport type {\n  FilterArgEditorProps,\n  FilterFunctionRowProps,\n} from \"./filter-row\"\nexport { FilterArgEditor, FilterFunctionRow } from \"./filter-row\"\n\n// ---------------------------------------------------------------------------\n// Shared props\n// ---------------------------------------------------------------------------\n\nexport interface FilterBuilderPanelProps {\n  value: FilterString | (string & {})\n  onChange: (value: FilterString) => void\n  /**\n   * Which CSS property the live preview targets. Both `filter` and\n   * `backdrop-filter` share the identical function-list grammar, so this does\n   * not change validation or narrow the `onChange` output — it only drives the\n   * preview render target + labels. Defaults to `\"filter\"`.\n   */\n  mode?: FilterMode\n  className?: string\n  \"aria-label\"?: string\n}\n\nexport interface FilterBuilderProps extends FilterBuilderPanelProps {}\n\n// ---------------------------------------------------------------------------\n// FilterBuilder — popover-wrapped\n// ---------------------------------------------------------------------------\n\nexport function FilterBuilder(props: FilterBuilderProps) {\n  const {\n    value,\n    className,\n    \"aria-label\": ariaLabel = \"Edit a CSS filter\",\n  } = props\n  const items = parseFilter(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 aria-hidden=\"true\" className=\"text-foreground/60\">\n            ✦\n          </span>\n          <span className=\"text-[10px] text-muted-foreground\">\n            {items.length} {items.length === 1 ? \"fn\" : \"fns\"}\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        <FilterBuilderPanel {...props} />\n      </PopoverContent>\n    </Popover>\n  )\n}\n\n// ---------------------------------------------------------------------------\n// FilterBuilderPanel — inline\n// ---------------------------------------------------------------------------\n\nexport function FilterBuilderPanel({\n  value,\n  onChange,\n  mode = \"filter\",\n  className,\n  \"aria-label\": ariaLabel = \"CSS filter builder\",\n}: FilterBuilderPanelProps) {\n  const [items, setItems] = useState<FilterItem[]>(\n    () => parseFilter(String(value)) ?? [],\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 = parseFilter(String(value))\n    if (parsed !== null) setItems(parsed)\n  }, [value])\n\n  const commit = (next: FilterItem[]) => {\n    setItems(next)\n    const str = formatFilter(next)\n    lastEmittedRef.current = str\n    onChange(str as FilterString)\n  }\n\n  const updateAt = (index: number, item: FilterItem) => {\n    commit(items.map((it, i) => (i === index ? item : it)))\n  }\n  const removeAt = (index: number) => {\n    commit(items.filter((_, i) => i !== index))\n  }\n  const add = (fn: FilterFunctionName) => {\n    commit([...items, defaultItem(fn)])\n  }\n\n  return (\n    <fieldset\n      className={cn(\n        \"m-0 w-[480px] space-y-3 border-0 bg-background p-3\",\n        className,\n      )}\n      aria-label={ariaLabel}\n    >\n      <div className=\"space-y-2\">\n        {items.map((item, i) => (\n          <FilterFunctionRow\n            // biome-ignore lint/suspicious/noArrayIndexKey: rows are positional and reorderable only by add/remove\n            key={`${item.fn}-${i}`}\n            item={item}\n            onChange={(next) => updateAt(i, next)}\n            onRemove={() => removeAt(i)}\n          />\n        ))}\n      </div>\n      <AddFilterMenu onAdd={add} />\n      <LiveString value={formatFilter(items)} />\n      <FilterPreview\n        value={formatFilter(items)}\n        mode={mode}\n        onChange={(str) => {\n          const parsed = parseFilter(str)\n          if (parsed !== null) commit(parsed)\n        }}\n      />\n    </fieldset>\n  )\n}\n\n// ---------------------------------------------------------------------------\n// AddFilterMenu (public)\n// ---------------------------------------------------------------------------\n\nexport interface AddFilterMenuProps {\n  onAdd: (fn: FilterFunctionName) => void\n  className?: string\n}\n\nexport function AddFilterMenu({ onAdd, className }: AddFilterMenuProps) {\n  return (\n    <select\n      aria-label=\"Add a filter function\"\n      value=\"\"\n      onChange={(e) => {\n        const fn = e.target.value\n        if (fn) onAdd(fn as FilterFunctionName)\n        e.target.value = \"\"\n      }}\n      className={cn(\n        \"h-9 w-full rounded-md border border-dashed bg-background px-2 font-mono text-muted-foreground text-xs\",\n        className,\n      )}\n    >\n      <option value=\"\">+ add function…</option>\n      <FunctionOptions />\n    </select>\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",
      "type": "registry:ui",
      "target": "components/ui/filter-builder/filter-builder.tsx"
    },
    {
      "path": "src/components/ui/filter-builder/filter-builder.types.ts",
      "content": "// =====================================================================\n// filter-builder.types.ts\n//\n// The \"ridiculous\" tier: compile-time FUNCTION-LIST DISPATCH over a CSS\n// `filter` / `backdrop-filter` value (a space-separated list of filter\n// functions). Built on `ridiculous-type-kit` plus the color-picker's\n// `ColorLiteral` for the drop-shadow color argument. The strict validator\n// `FilterLiteral<S>` resolves to `S` when every function in the list\n// validates (its arity and each argument's DIMENSION), `never` otherwise.\n//\n//   \"blur(4px) brightness(1.2)\"          →  the literal\n//   \"blur(45deg)\"                         →  never (wants length)\n//   \"drop-shadow(2px 2px 4px #000)\"       →  the literal\n//   \"drop-shadow(2px 2px 4px wrong)\"      →  never (bad color)\n//\n// This REUSES the function-list dispatch pattern from\n// transform-builder.types.ts (Phase 2) with a filter function table:\n//   SplitBySpace → ParseFunction → ValidateFn signature table → flat folds.\n// =====================================================================\n\nimport type { ColorLiteral } from \"@/components/ui/color-picker/color-picker.types\"\nimport type {\n  And,\n  IsAngle,\n  IsLength,\n  IsNonNegativeNumber,\n  IsPercentage,\n  Or,\n  ParseFunction,\n  SplitByComma,\n  SplitBySpace,\n  Trim,\n} from \"@/lib/ridiculous-type-kit\"\n\n// =====================================================================\n// 1. PER-DIMENSION PREDICATE ALIASES\n// =====================================================================\n\n/** non-negative number OR percentage — the amount functions. */\ntype IsAmount<S extends string> = Or<\n  IsNonNegativeNumber<Trim<S>>,\n  IsPercentage<Trim<S>>\n>\n\n/** Collapse the literal-or-never color validator into a boolean. */\ntype IsColor<S extends string> =\n  ColorLiteral<Trim<S>> extends never ? false : true\n\n// =====================================================================\n// 2. drop-shadow ARGUMENT VALIDATION\n//\n// drop-shadow args are SPACE-separated (offset-x offset-y blur? color?),\n// unlike the comma-separated transform functions. The kit's SplitBySpace\n// is paren-aware, so a functional color whose own body contains spaces\n// and a slash — rgb(0 0 0 / 0.5) — stays a single token.\n//\n// Strict tier accepts color-LAST only:\n//   [X, Y]          two lengths\n//   [X, Y, Z]       three lengths, OR two lengths + a color in slot 3\n//   [X, Y, Z, W]    three lengths + a color\n// =====================================================================\n\ntype ValidateDropShadow<ArgStr extends string> =\n  SplitBySpace<ArgStr> extends infer Parts extends string[]\n    ? Parts extends [infer X extends string, infer Y extends string]\n      ? And<IsLength<Trim<X>>, IsLength<Trim<Y>>>\n      : Parts extends [\n            infer X extends string,\n            infer Y extends string,\n            infer Z extends string,\n          ]\n        ? And<\n            IsLength<Trim<X>>,\n            And<IsLength<Trim<Y>>, Or<IsLength<Trim<Z>>, IsColor<Z>>>\n          >\n        : Parts extends [\n              infer X extends string,\n              infer Y extends string,\n              infer Z extends string,\n              infer W extends string,\n            ]\n          ? And<\n              IsLength<Trim<X>>,\n              And<IsLength<Trim<Y>>, And<IsLength<Trim<Z>>, IsColor<W>>>\n            >\n          : false\n    : false\n\n/** url(...) — accept any non-empty body. */\ntype ValidateUrl<ArgStr extends string> = Trim<ArgStr> extends \"\" ? false : true\n\n// =====================================================================\n// 3. DISPATCH TABLE — ValidateFn<Name, ArgStr> → true | false.\n//    The namesake. Arity by tuple length; argument dimensions by the\n//    folds above (drop-shadow / url get their own validators).\n// =====================================================================\n\ntype ValidateFn<Name extends string, ArgStr extends string> =\n  // --- blur (length) -------------------------------------------------\n  Name extends \"blur\"\n    ? SplitByComma<ArgStr> extends [infer L extends string]\n      ? IsLength<Trim<L>>\n      : false\n    : // --- hue-rotate (angle) -----------------------------------------\n      Name extends \"hue-rotate\"\n      ? SplitByComma<ArgStr> extends [infer A extends string]\n        ? IsAngle<Trim<A>>\n        : false\n      : // --- amount functions (non-neg number | percentage) -----------\n        Name extends\n            | \"brightness\"\n            | \"contrast\"\n            | \"grayscale\"\n            | \"invert\"\n            | \"opacity\"\n            | \"saturate\"\n            | \"sepia\"\n        ? SplitByComma<ArgStr> extends [infer N extends string]\n          ? IsAmount<N>\n          : false\n        : // --- drop-shadow (2-3 lengths + optional color) -------------\n          Name extends \"drop-shadow\"\n          ? ValidateDropShadow<ArgStr>\n          : // --- url (non-empty body) ---------------------------------\n            Name extends \"url\"\n            ? ValidateUrl<ArgStr>\n            : false // unknown function name\n\n// Validate one space-separated token (`name(args)`), or false.\ntype ValidateToken<Token extends string> =\n  ParseFunction<Token> extends {\n    name: infer Name extends string\n    args: infer ArgStr extends string\n  }\n    ? ValidateFn<Name, ArgStr>\n    : false\n\n// Fold the space-separated function list; every token must validate.\ntype ValidateList<Tokens extends string[]> = Tokens extends [\n  infer H extends string,\n  ...infer T extends string[],\n]\n  ? ValidateToken<H> extends true\n    ? ValidateList<T>\n    : false\n  : true // reached the end without a failure\n\n// =====================================================================\n// 4. STRICT VALIDATOR + CALL-SITE HELPER\n// =====================================================================\n\n/**\n * Strict literal validator. Resolves to `S` when `S` is a dimensionally-\n * and arity-valid CSS `filter` / `backdrop-filter` value (or the `none`\n * keyword), `never` otherwise. `calc()` / `var()` inside an argument\n * resolve to `never` here (undecidable at compile time) — use the casual /\n * IntelliSense tier for those; the runtime parser accepts them.\n *\n * @example\n * type A = FilterLiteral<\"blur(4px) brightness(1.2)\"> // the literal\n * type B = FilterLiteral<\"blur(45deg)\">               // never\n * type C = FilterLiteral<\"none\">                       // \"none\"\n */\nexport type FilterLiteral<S extends string> =\n  Trim<S> extends \"none\"\n    ? S\n    : Trim<S> extends \"\"\n      ? never\n      : SplitBySpace<Trim<S>> extends infer Tokens extends string[]\n        ? Tokens extends []\n          ? never\n          : ValidateList<Tokens> extends true\n            ? S\n            : never\n        : never\n\n/**\n * Call-site validator helper. Mirrors `cssTransform()` / `cssCalc()` /\n * `color()` / `easing()`. An invalid filter becomes a type error at the\n * argument.\n */\nexport const cssFilter = <S extends string>(value: S & FilterLiteral<S>): S =>\n  value\n\n// =====================================================================\n// 5. SUGGESTION STRINGS — IntelliSense + onChange return types\n// =====================================================================\n\n/** Every supported filter function name. */\nexport type FilterFunctionName =\n  | \"blur\"\n  | \"brightness\"\n  | \"contrast\"\n  | \"grayscale\"\n  | \"hue-rotate\"\n  | \"invert\"\n  | \"opacity\"\n  | \"saturate\"\n  | \"sepia\"\n  | \"drop-shadow\"\n  | \"url\"\n\n/** The amount functions — single non-negative number | percentage arg. */\nexport type AmountFn =\n  | \"brightness\"\n  | \"contrast\"\n  | \"grayscale\"\n  | \"invert\"\n  | \"opacity\"\n  | \"saturate\"\n  | \"sepia\"\n\n/**\n * Suggestion union — \"this is a filter string\". A head-anchored\n * `` `${fn}(${string})` `` per function also matches multi-function lists\n * (the list starts with a function name and ends in `)`), plus `none`.\n */\nexport type FilterString = `${FilterFunctionName}(${string})` | \"none\"\n\n/** Function → output-string map. Backs the per-function suggestion shapes. */\nexport interface FilterStringMap {\n  blur: `blur(${string})`\n  brightness: `brightness(${string})`\n  contrast: `contrast(${string})`\n  grayscale: `grayscale(${string})`\n  \"hue-rotate\": `hue-rotate(${string})`\n  invert: `invert(${string})`\n  opacity: `opacity(${string})`\n  saturate: `saturate(${string})`\n  sepia: `sepia(${string})`\n  \"drop-shadow\": `drop-shadow(${string})`\n  url: `url(${string})`\n}\n\nexport type FilterFn = keyof FilterStringMap\n\n// =====================================================================\n// 6. UTILITY TYPES — operate on filter literals at the type level\n// =====================================================================\n\ntype NamesOf<Tokens extends string[]> = Tokens extends [\n  infer H extends string,\n  ...infer T extends string[],\n]\n  ? ParseFunction<H> extends { name: infer Name extends string }\n    ? [Name, ...NamesOf<T>]\n    : NamesOf<T>\n  : []\n\n/**\n * The ordered tuple of function names in a filter string.\n *\n * @example\n * type T = FunctionsOf<\"blur(4px) brightness(1.2)\"> // [\"blur\",\"brightness\"]\n * type N = FunctionsOf<\"none\">                       // []\n */\nexport type FunctionsOf<S extends string> =\n  Trim<S> extends \"none\" | \"\" ? [] : NamesOf<SplitBySpace<Trim<S>>>\n\n/**\n * The number of filter functions in the list.\n *\n * @example\n * type C = FunctionCountOf<\"blur(4px) brightness(1.2)\"> // 2\n */\nexport type FunctionCountOf<S extends string> = FunctionsOf<S>[\"length\"]\n\ntype IncludesDropShadow<Names extends string[]> = Names extends [\n  infer H extends string,\n  ...infer T extends string[],\n]\n  ? H extends \"drop-shadow\"\n    ? true\n    : IncludesDropShadow<T>\n  : false\n\n/**\n * Whether the filter list contains a `drop-shadow` — the one function with\n * a color argument.\n *\n * @example\n * type A = HasDropShadow<\"blur(4px) drop-shadow(1px 1px #000)\"> // true\n * type B = HasDropShadow<\"blur(4px)\">                            // false\n */\nexport type HasDropShadow<S extends string> = IncludesDropShadow<FunctionsOf<S>>\n\n// =====================================================================\n// 7. INTERNAL STATE — discriminated union (exported)\n//\n// The editor's state is `FilterItem[]`. Each item is one function,\n// discriminated by `fn`. Exported for advanced use (custom serialization,\n// programmatic build). Argument values are kept as strings (they carry\n// units/colors), mirroring how the literal preserves the raw text.\n// =====================================================================\n\nexport type FilterItem =\n  // one length\n  | { fn: \"blur\"; value: string }\n  // one angle\n  | { fn: \"hue-rotate\"; value: string }\n  // one non-negative number | percentage\n  | { fn: AmountFn; value: string }\n  // 2-3 lengths + optional trailing color\n  | { fn: \"drop-shadow\"; x: string; y: string; blur?: string; color?: string }\n  // opaque non-empty body\n  | { fn: \"url\"; url: string }\n\n// Re-export the kit's Dimension for convenience.\nexport type { Dimension } from \"@/lib/ridiculous-type-kit\"\n",
      "type": "registry:ui",
      "target": "components/ui/filter-builder/filter-builder.types.ts"
    },
    {
      "path": "src/components/ui/filter-builder/filter-builder.helpers.ts",
      "content": "// =====================================================================\n// filter-builder.helpers.ts\n//\n// Pure runtime parse / format / spec for CSS `filter` / `backdrop-filter`\n// values. This is the SUPERSET of the strict type tier: it tolerates\n// calc()/var() inside arguments (kept verbatim, opaque), accepts a\n// leading-color drop-shadow (normalizing it to color-last), validates\n// arity, and drives the UI from a single ARG_SPEC dispatch table.\n// =====================================================================\n\nimport type {\n  AmountFn,\n  FilterFunctionName,\n  FilterItem,\n} from \"./filter-builder.types\"\n\n// ---------------------------------------------------------------------------\n// ARG_SPEC — the runtime dispatch table (single source of truth)\n// ---------------------------------------------------------------------------\n\n/** Dimension family a function's argument(s) accept. */\nexport type ArgKind = \"length\" | \"amount\" | \"angle\" | \"shadow\" | \"url\"\n\nexport interface ArgSpec {\n  /** Minimum token count. */\n  min: number\n  /** Maximum token count. */\n  max: number\n  /** The argument kind — drives both parsing and the UI. */\n  kind: ArgKind\n  /** Human label for the single-arg control (unused for shadow/url). */\n  label: string\n}\n\nconst AMOUNT_FNS: readonly AmountFn[] = [\n  \"brightness\",\n  \"contrast\",\n  \"grayscale\",\n  \"invert\",\n  \"opacity\",\n  \"saturate\",\n  \"sepia\",\n]\n\nconst ARG_SPEC: Record<FilterFunctionName, ArgSpec> = {\n  blur: { min: 1, max: 1, kind: \"length\", label: \"radius\" },\n  brightness: { min: 1, max: 1, kind: \"amount\", label: \"amount\" },\n  contrast: { min: 1, max: 1, kind: \"amount\", label: \"amount\" },\n  grayscale: { min: 1, max: 1, kind: \"amount\", label: \"amount\" },\n  invert: { min: 1, max: 1, kind: \"amount\", label: \"amount\" },\n  opacity: { min: 1, max: 1, kind: \"amount\", label: \"amount\" },\n  saturate: { min: 1, max: 1, kind: \"amount\", label: \"amount\" },\n  sepia: { min: 1, max: 1, kind: \"amount\", label: \"amount\" },\n  \"hue-rotate\": { min: 1, max: 1, kind: \"angle\", label: \"angle\" },\n  // 2-4 tokens: offset-x offset-y blur? color?\n  \"drop-shadow\": { min: 2, max: 4, kind: \"shadow\", label: \"shadow\" },\n  url: { min: 1, max: 1, kind: \"url\", label: \"url\" },\n}\n\nconst FUNCTION_NAMES = new Set<string>(Object.keys(ARG_SPEC))\n\nfunction isFunctionName(name: string): name is FilterFunctionName {\n  return FUNCTION_NAMES.has(name)\n}\n\n/** The argument spec for a function — drives both parsing and the UI. */\nexport function argSpec(fn: FilterFunctionName): ArgSpec {\n  return ARG_SPEC[fn]\n}\n\n/** Whether a function takes a single non-negative number | percentage arg. */\nexport function isAmountFn(fn: FilterFunctionName): fn is AmountFn {\n  return (AMOUNT_FNS as readonly string[]).includes(fn)\n}\n\n// ---------------------------------------------------------------------------\n// Top-level splitters (paren-aware, runtime mirror of the kit combinators)\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 a filter list into function tokens, dropping empty runs. */\nfunction splitFunctions(src: string): string[] {\n  return splitTopLevel(src, \" \")\n    .map((s) => s.trim())\n    .filter((s) => s.length > 0)\n}\n\n/** Split a function's comma argument string into trimmed args (drops empty). */\nfunction splitCommaArgs(argStr: string): string[] {\n  const trimmed = argStr.trim()\n  if (trimmed === \"\") return []\n  return splitTopLevel(trimmed, \",\").map((s) => s.trim())\n}\n\n/** Split a drop-shadow's space argument string into tokens (drops empty). */\nfunction splitSpaceArgs(argStr: string): string[] {\n  return splitTopLevel(argStr, \" \")\n    .map((s) => s.trim())\n    .filter((s) => s.length > 0)\n}\n\n// Name regex allows the hyphen so `hue-rotate` / `drop-shadow` match.\nconst CALL_RE = /^([a-zA-Z][a-zA-Z-]*)\\((.*)\\)$/s\n\n// A token that looks like a CSS length / numeric value (possibly opaque\n// calc()/var()) — NOT a color. Used to classify drop-shadow tokens.\nconst LENGTHISH_RE = /^-?[\\d.]+[a-z%]*$/i\n\nfunction isLengthish(token: string): boolean {\n  if (LENGTHISH_RE.test(token)) return true\n  // calc()/var()/min()/max()/clamp() length expressions are opaque lengths.\n  return /^(calc|var|min|max|clamp|env)\\(/i.test(token)\n}\n\n// ---------------------------------------------------------------------------\n// parseFilter — string → FilterItem[] | null\n// ---------------------------------------------------------------------------\n\n/**\n * Build a drop-shadow item from its space-separated tokens. Accepts color\n * before or after the offsets (CSS allows either); normalizes to color-last.\n * Requires exactly 2 or 3 length tokens (x, y, blur?) and at most 1 color.\n */\nfunction buildDropShadow(tokens: string[]): FilterItem | null {\n  const lengths: string[] = []\n  let color: string | undefined\n  for (const token of tokens) {\n    if (isLengthish(token)) {\n      lengths.push(token)\n    } else if (color === undefined) {\n      color = token\n    } else {\n      return null // a second non-length token — invalid\n    }\n  }\n  if (lengths.length < 2 || lengths.length > 3) return null\n  const [x, y, blur] = lengths\n  const item: FilterItem = { fn: \"drop-shadow\", x, y }\n  if (blur !== undefined) item.blur = blur\n  if (color !== undefined) item.color = color\n  return item\n}\n\nfunction buildItem(fn: FilterFunctionName, argStr: string): FilterItem | null {\n  if (fn === \"drop-shadow\") {\n    const tokens = splitSpaceArgs(argStr)\n    if (tokens.length < ARG_SPEC[fn].min || tokens.length > ARG_SPEC[fn].max) {\n      return null\n    }\n    return buildDropShadow(tokens)\n  }\n\n  if (fn === \"url\") {\n    const body = argStr.trim()\n    if (body === \"\") return null\n    return { fn: \"url\", url: body }\n  }\n\n  // single-arg families (length / angle / amount)\n  const args = splitCommaArgs(argStr)\n  if (args.length !== 1) return null\n  if (fn === \"hue-rotate\") return { fn, value: args[0] }\n  if (fn === \"blur\") return { fn, value: args[0] }\n  // amount family\n  return { fn, value: args[0] }\n}\n\n/**\n * Parse a CSS `filter` / `backdrop-filter` value into typed items, or `null`\n * on any syntax, unknown-function, or arity error. `none` / empty → `[]`.\n */\nexport function parseFilter(src: string): FilterItem[] | null {\n  const trimmed = src.trim()\n  if (trimmed === \"\" || trimmed === \"none\") return []\n\n  const tokens = splitFunctions(trimmed)\n  if (tokens.length === 0) return []\n\n  const items: FilterItem[] = []\n  for (const token of tokens) {\n    const m = token.match(CALL_RE)\n    if (!m) return null\n    const name = m[1]\n    if (!isFunctionName(name)) return null\n    const item = buildItem(name, m[2])\n    if (item === null) return null\n    items.push(item)\n  }\n  return items\n}\n\n/** Runtime mirror of `FunctionsOf` — list the function names in order. */\nexport function filterFunctions(src: string): string[] {\n  const items = parseFilter(src)\n  if (items === null) return []\n  return items.map((it) => it.fn)\n}\n\n// ---------------------------------------------------------------------------\n// defaultItem — seed a fresh row\n// ---------------------------------------------------------------------------\n\n/** A sensible default item for a freshly-added function row. */\nexport function defaultItem(fn: FilterFunctionName): FilterItem {\n  switch (fn) {\n    case \"blur\":\n      return { fn, value: \"4px\" }\n    case \"hue-rotate\":\n      return { fn, value: \"90deg\" }\n    case \"drop-shadow\":\n      return {\n        fn,\n        x: \"4px\",\n        y: \"4px\",\n        blur: \"8px\",\n        color: \"rgb(0 0 0 / 0.5)\",\n      }\n    case \"url\":\n      return { fn, url: \"#filter\" }\n    default:\n      // amount family — opacity/brightness/contrast/saturate default 1,\n      // grayscale/invert/sepia default 0; use 1 as the neutral identity for\n      // the multiplier-style functions, 0 for the additive ones.\n      return {\n        fn,\n        value:\n          fn === \"grayscale\" || fn === \"invert\" || fn === \"sepia\" ? \"0\" : \"1\",\n      }\n  }\n}\n\n// ---------------------------------------------------------------------------\n// itemToCss / formatFilter — canonical serialization\n// ---------------------------------------------------------------------------\n\n/** Serialize one item to its CSS function string. */\nexport function itemToCss(item: FilterItem): string {\n  switch (item.fn) {\n    case \"blur\":\n    case \"hue-rotate\":\n      return `${item.fn}(${item.value})`\n    case \"url\":\n      return `url(${item.url})`\n    case \"drop-shadow\": {\n      const parts = [item.x, item.y]\n      if (item.blur !== undefined) parts.push(item.blur)\n      if (item.color !== undefined) parts.push(item.color)\n      return `drop-shadow(${parts.join(\" \")})`\n    }\n    default:\n      // amount family\n      return `${item.fn}(${item.value})`\n  }\n}\n\n/** Canonical re-serialization of a filter list. Empty → `none`. */\nexport function formatFilter(items: FilterItem[]): string {\n  if (items.length === 0) return \"none\"\n  return items.map(itemToCss).join(\" \")\n}\n",
      "type": "registry:ui",
      "target": "components/ui/filter-builder/filter-builder.helpers.ts"
    },
    {
      "path": "src/components/ui/filter-builder/filter-builder.constants.ts",
      "content": "// =====================================================================\n// filter-builder.constants.ts\n//\n// Shared, render-free constants and small types/helpers for the filter\n// builder UI: the function option-groups, the unit lists, the live\n// preview mode type, the scrub descriptors, and the item ↔ single-value\n// helpers used by both the row editors and the preview scrubbers. Kept in\n// a non-component module so the .tsx files stay component-only (avoids\n// react-refresh/only-export-components churn).\n// =====================================================================\n\nimport type { FilterFunctionName, FilterItem } from \"./filter-builder.types\"\n\n// ---------------------------------------------------------------------------\n// Function groups (for the <select> option-groups)\n// ---------------------------------------------------------------------------\n\nexport const FUNCTION_GROUPS: ReadonlyArray<{\n  label: string\n  fns: readonly FilterFunctionName[]\n}> = [\n  { label: \"blur\", fns: [\"blur\"] },\n  {\n    label: \"color\",\n    fns: [\n      \"brightness\",\n      \"contrast\",\n      \"grayscale\",\n      \"invert\",\n      \"opacity\",\n      \"saturate\",\n      \"sepia\",\n      \"hue-rotate\",\n    ],\n  },\n  { label: \"shadow\", fns: [\"drop-shadow\"] },\n  { label: \"svg\", fns: [\"url\"] },\n]\n\n// ---------------------------------------------------------------------------\n// Unit lists (per argument kind)\n// ---------------------------------------------------------------------------\n\nexport const LENGTH_UNITS = [\"px\", \"rem\", \"em\", \"%\", \"vw\", \"vh\"] as const\nexport const ANGLE_UNITS = [\"deg\", \"grad\", \"rad\", \"turn\"] as const\nexport const AMOUNT_UNITS = [\"\", \"%\"] as const\n\n// ---------------------------------------------------------------------------\n// Live-preview mode\n// ---------------------------------------------------------------------------\n\nexport type FilterMode = \"filter\" | \"backdrop-filter\"\n\n// ---------------------------------------------------------------------------\n// item ↔ single-value helpers (UI-local)\n// ---------------------------------------------------------------------------\n\n/** The single editable value for the non-shadow / non-url families. */\nexport function singleValue(\n  item: Exclude<FilterItem, { fn: \"drop-shadow\" | \"url\" }>,\n): string {\n  return item.value\n}\n\n/** Rebuild a single-arg item from an edited value string. */\nexport function withSingleValue(\n  fn: FilterFunctionName,\n  value: string,\n): FilterItem {\n  if (fn === \"url\") return { fn, url: value }\n  // blur / hue-rotate / amount families all carry `value`\n  return { fn, value } as FilterItem\n}\n\n// ---------------------------------------------------------------------------\n// Preview scrub descriptors\n// ---------------------------------------------------------------------------\n\nexport interface Scrub {\n  fn: FilterFunctionName\n  label: string\n  min: number\n  max: number\n  step: number\n  unit: string\n}\n\nexport const SCRUBS: readonly Scrub[] = [\n  { fn: \"blur\", label: \"blur\", min: 0, max: 20, step: 0.5, unit: \"px\" },\n  {\n    fn: \"brightness\",\n    label: \"brightness\",\n    min: 0,\n    max: 3,\n    step: 0.05,\n    unit: \"\",\n  },\n  { fn: \"contrast\", label: \"contrast\", min: 0, max: 3, step: 0.05, unit: \"\" },\n  { fn: \"saturate\", label: \"saturate\", min: 0, max: 3, step: 0.05, unit: \"\" },\n  {\n    fn: \"hue-rotate\",\n    label: \"hue-rotate\",\n    min: 0,\n    max: 360,\n    step: 1,\n    unit: \"deg\",\n  },\n]\n",
      "type": "registry:ui",
      "target": "components/ui/filter-builder/filter-builder.constants.ts"
    },
    {
      "path": "src/components/ui/filter-builder/filter-preview.tsx",
      "content": "\"use client\"\n\nimport { useEffect, useId, useState } from \"react\"\nimport { cn } from \"@/lib/utils\"\nimport {\n  type FilterMode,\n  SCRUBS,\n  type Scrub,\n  withSingleValue,\n} from \"./filter-builder.constants\"\nimport { formatFilter, parseFilter } from \"./filter-builder.helpers\"\nimport type { FilterItem } from \"./filter-builder.types\"\n\n// ---------------------------------------------------------------------------\n// Scrub helpers (UI-local)\n// ---------------------------------------------------------------------------\n\n/** Read the current scalar for a scrub fn from a parsed list (or a default). */\nfunction scrubValue(items: FilterItem[], scrub: Scrub): number {\n  const item = items.find((it) => it.fn === scrub.fn)\n  if (!item || !(\"value\" in item)) {\n    return scrub.fn === \"blur\" || scrub.fn === \"hue-rotate\" ? 0 : 1\n  }\n  const n = Number.parseFloat(item.value)\n  if (Number.isNaN(n)) {\n    return scrub.fn === \"blur\" || scrub.fn === \"hue-rotate\" ? 0 : 1\n  }\n  return n\n}\n\n/** Merge a scrub change into the list (replace or append the function). */\nfunction applyScrub(\n  items: FilterItem[],\n  scrub: Scrub,\n  n: number,\n): FilterItem[] {\n  const next = withSingleValue(scrub.fn, `${n}${scrub.unit}`)\n  const idx = items.findIndex((it) => it.fn === scrub.fn)\n  if (idx === -1) return [...items, next]\n  return items.map((it, i) => (i === idx ? next : it))\n}\n\n// ---------------------------------------------------------------------------\n// FilterPreview (public) — the showcase\n// ---------------------------------------------------------------------------\n\nexport interface FilterPreviewProps {\n  value: string\n  mode?: FilterMode\n  onChange?: (value: string) => void\n  className?: string\n}\n\nexport function FilterPreview({\n  value,\n  mode: modeProp = \"filter\",\n  onChange,\n  className,\n}: FilterPreviewProps) {\n  const id = useId()\n  const [mode, setMode] = useState<FilterMode>(modeProp)\n  useEffect(() => setMode(modeProp), [modeProp])\n\n  const items = parseFilter(value) ?? []\n  const applied = value === \"none\" ? undefined : value\n\n  return (\n    <div className={cn(\"space-y-3 rounded-lg border p-3\", className)}>\n      <div className=\"flex items-center justify-between\">\n        <div className=\"text-muted-foreground text-xs\">preview</div>\n        <div className=\"inline-flex overflow-hidden rounded-md border text-[10px]\">\n          {([\"filter\", \"backdrop-filter\"] as const).map((m) => (\n            <button\n              key={m}\n              type=\"button\"\n              aria-pressed={mode === m}\n              onClick={() => setMode(m)}\n              className={cn(\n                \"px-2 py-1 font-mono\",\n                mode === m\n                  ? \"bg-primary text-primary-foreground\"\n                  : \"bg-background text-muted-foreground\",\n              )}\n            >\n              {m}\n            </button>\n          ))}\n        </div>\n      </div>\n\n      <div\n        className=\"relative flex h-40 items-center justify-center overflow-hidden rounded-md bg-[conic-gradient(at_30%_30%,#6366f1,#ec4899,#f59e0b,#10b981,#6366f1)]\"\n        aria-hidden=\"true\"\n      >\n        {mode === \"filter\" ? (\n          <div\n            data-filter-target\n            className=\"flex h-24 w-40 items-center justify-center rounded-lg bg-white/90 font-mono text-[10px] text-black shadow-lg\"\n            style={{ filter: applied }}\n          >\n            filter\n          </div>\n        ) : (\n          <div\n            data-backdrop-target\n            className=\"flex h-24 w-40 items-center justify-center rounded-lg border border-white/30 bg-white/10 font-mono text-[10px] text-white\"\n            style={{ backdropFilter: applied }}\n          >\n            backdrop-filter\n          </div>\n        )}\n      </div>\n\n      {onChange ? (\n        <div className=\"space-y-1.5\">\n          {SCRUBS.map((scrub) => {\n            const current = scrubValue(items, scrub)\n            return (\n              <label\n                key={scrub.fn}\n                htmlFor={`${id}-${scrub.fn}`}\n                className=\"flex items-center gap-2 text-xs\"\n              >\n                <span className=\"w-20 font-mono text-muted-foreground\">\n                  {scrub.label}\n                </span>\n                <input\n                  id={`${id}-${scrub.fn}`}\n                  type=\"range\"\n                  aria-label={scrub.label}\n                  min={scrub.min}\n                  max={scrub.max}\n                  step={scrub.step}\n                  value={current}\n                  onChange={(e) =>\n                    onChange(\n                      formatFilter(\n                        applyScrub(items, scrub, Number(e.target.value)),\n                      ),\n                    )\n                  }\n                  className=\"flex-1\"\n                />\n                <span className=\"w-14 text-right font-mono text-muted-foreground\">\n                  {current}\n                  {scrub.unit}\n                </span>\n              </label>\n            )\n          })}\n        </div>\n      ) : null}\n    </div>\n  )\n}\n",
      "type": "registry:ui",
      "target": "components/ui/filter-builder/filter-preview.tsx"
    },
    {
      "path": "src/components/ui/filter-builder/filter-row.tsx",
      "content": "\"use client\"\n\nimport { ColorPicker } from \"@/components/ui/color-picker\"\nimport { Input } from \"@/components/ui/input\"\nimport { cn } from \"@/lib/utils\"\nimport {\n  AMOUNT_UNITS,\n  ANGLE_UNITS,\n  FUNCTION_GROUPS,\n  LENGTH_UNITS,\n  singleValue,\n  withSingleValue,\n} from \"./filter-builder.constants\"\nimport { type ArgKind, argSpec, defaultItem } from \"./filter-builder.helpers\"\nimport type { FilterFunctionName, FilterItem } from \"./filter-builder.types\"\n\n// ---------------------------------------------------------------------------\n// FunctionOptions (internal) — the shared <optgroup> option list\n//\n// Single source for the grouped function options rendered inside both\n// `FunctionSelect` (a row's function picker) and the entry's `AddFilterMenu`.\n// ---------------------------------------------------------------------------\n\nexport function FunctionOptions() {\n  return (\n    <>\n      {FUNCTION_GROUPS.map((group) => (\n        <optgroup key={group.label} label={group.label}>\n          {group.fns.map((fn) => (\n            <option key={fn} value={fn}>\n              {fn}\n            </option>\n          ))}\n        </optgroup>\n      ))}\n    </>\n  )\n}\n\n// ---------------------------------------------------------------------------\n// FilterFunctionRow (public)\n// ---------------------------------------------------------------------------\n\nexport interface FilterFunctionRowProps {\n  item: FilterItem\n  onChange: (item: FilterItem) => void\n  onRemove: () => void\n  className?: string\n}\n\nexport function FilterFunctionRow({\n  item,\n  onChange,\n  onRemove,\n  className,\n}: FilterFunctionRowProps) {\n  const spec = argSpec(item.fn)\n\n  const changeFn = (fn: FilterFunctionName) => {\n    onChange(defaultItem(fn))\n  }\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      <FunctionSelect value={item.fn} onChange={changeFn} />\n      <div className=\"flex flex-1 flex-wrap items-center gap-1\">\n        {item.fn === \"drop-shadow\" ? (\n          <DropShadowControls item={item} onChange={onChange} />\n        ) : item.fn === \"url\" ? (\n          <Input\n            aria-label=\"url body\"\n            value={item.url}\n            spellCheck={false}\n            autoComplete=\"off\"\n            onChange={(e) => onChange({ fn: \"url\", url: e.target.value })}\n            className=\"h-8 w-full font-mono text-xs\"\n          />\n        ) : (\n          <FilterArgEditor\n            label={`${item.fn} ${spec.label}`}\n            kind={spec.kind}\n            value={singleValue(item)}\n            onChange={(next) => onChange(withSingleValue(item.fn, next))}\n          />\n        )}\n      </div>\n      <button\n        type=\"button\"\n        onClick={onRemove}\n        aria-label={`Remove ${item.fn}`}\n        className=\"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\nfunction FunctionSelect({\n  value,\n  onChange,\n}: {\n  value: FilterFunctionName\n  onChange: (fn: FilterFunctionName) => void\n}) {\n  return (\n    <select\n      aria-label=\"Filter function\"\n      value={value}\n      onChange={(e) => onChange(e.target.value as FilterFunctionName)}\n      className=\"h-8 rounded border bg-background px-1.5 font-mono text-xs\"\n    >\n      <FunctionOptions />\n    </select>\n  )\n}\n\n// ---------------------------------------------------------------------------\n// DropShadowControls (internal) — x/y/blur length editors + color\n// ---------------------------------------------------------------------------\n\nfunction DropShadowControls({\n  item,\n  onChange,\n}: {\n  item: Extract<FilterItem, { fn: \"drop-shadow\" }>\n  onChange: (item: FilterItem) => void\n}) {\n  const setField = (patch: Partial<typeof item>) => {\n    onChange({ ...item, ...patch })\n  }\n\n  return (\n    <>\n      <FilterArgEditor\n        label=\"drop-shadow offset-x\"\n        kind=\"length\"\n        value={item.x}\n        onChange={(x) => setField({ x })}\n      />\n      <FilterArgEditor\n        label=\"drop-shadow offset-y\"\n        kind=\"length\"\n        value={item.y}\n        onChange={(y) => setField({ y })}\n      />\n      <FilterArgEditor\n        label=\"drop-shadow blur\"\n        kind=\"length\"\n        value={item.blur ?? \"\"}\n        onChange={(blur) => setField({ blur: blur === \"\" ? undefined : blur })}\n      />\n      {item.color === undefined ? (\n        <button\n          type=\"button\"\n          aria-label=\"Add drop-shadow color\"\n          onClick={() => setField({ color: \"rgb(0 0 0 / 0.5)\" })}\n          className=\"h-8 rounded border border-dashed px-2 font-mono text-[10px] text-muted-foreground\"\n        >\n          + color\n        </button>\n      ) : (\n        <span className=\"inline-flex items-center gap-1\">\n          <ColorPicker\n            native\n            value={item.color}\n            onChange={(color) => setField({ color })}\n            aria-label=\"drop-shadow color\"\n          />\n          <button\n            type=\"button\"\n            aria-label=\"Remove drop-shadow color\"\n            onClick={() => setField({ color: undefined })}\n            className=\"rounded p-0.5 text-[10px] text-muted-foreground hover:text-destructive\"\n          >\n            ×\n          </button>\n        </span>\n      )}\n    </>\n  )\n}\n\n// ---------------------------------------------------------------------------\n// FilterArgEditor (public)\n// ---------------------------------------------------------------------------\n\nexport interface FilterArgEditorProps {\n  label: string\n  kind: ArgKind\n  value: string\n  onChange: (value: string) => void\n  className?: string\n}\n\nexport function FilterArgEditor({\n  label,\n  kind,\n  value,\n  onChange,\n  className,\n}: FilterArgEditorProps) {\n  const units =\n    kind === \"angle\"\n      ? ANGLE_UNITS\n      : kind === \"amount\"\n        ? AMOUNT_UNITS\n        : LENGTH_UNITS\n\n  // Split a value like \"10px\" / \"150%\" / \"1.2\" into number + unit.\n  const m = value.match(/^(-?\\d*\\.?\\d*)([a-z%]*)$/i)\n  const numPart = m ? m[1] : value\n  const unitPart = m ? m[2] : \"\"\n  const opaque = m === null // calc()/var() etc — show raw\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-[120px] 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}\n        spellCheck={false}\n        autoComplete=\"off\"\n        onChange={(e) => onChange(e.target.value)}\n        className=\"h-8 w-[72px] rounded-r-none border-r-0 font-mono text-xs\"\n      />\n      <select\n        aria-label={`${label} unit`}\n        value={unitPart || units[0]}\n        onChange={(e) => onChange(`${numPart || \"0\"}${e.target.value}`)}\n        className=\"h-8 rounded-r-md rounded-l-none border border-input bg-background px-1 font-mono text-xs\"\n      >\n        {units.map((u) => (\n          <option key={u || \"none\"} value={u}>\n            {u === \"\" ? \"×\" : u}\n          </option>\n        ))}\n      </select>\n    </span>\n  )\n}\n",
      "type": "registry:ui",
      "target": "components/ui/filter-builder/filter-row.tsx"
    }
  ],
  "type": "registry:ui"
}