{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "background-editor",
  "title": "Background Editor",
  "description": "Ridiculously typed editor for the CSS background shorthand. An index-aware fold permits a <color> token only on the final layer, so a color in any earlier layer is a compile error. A reorderable layer stack embeds the real gradient + color pickers over a live composite preview.",
  "dependencies": [],
  "registryDependencies": [
    "ridiculous-type-kit",
    "color-picker",
    "gradient-editor",
    "unit-input",
    "button",
    "popover",
    "input"
  ],
  "files": [
    {
      "path": "src/components/ui/background-editor/index.ts",
      "content": "export type {\n  BackgroundEditorPanelProps,\n  BackgroundEditorProps,\n  BackgroundPreviewProps,\n  LayerCardProps,\n  LayerStackProps,\n  MiniSelectProps,\n  PositionPadProps,\n} from \"./background-editor\"\nexport {\n  BackgroundEditor,\n  BackgroundEditorPanel,\n  BackgroundPreview,\n  LayerCard,\n  LayerStack,\n  LiveString,\n  MiniSelect,\n  PositionPad,\n} from \"./background-editor\"\nexport type { BgTokenKind } from \"./background-editor.helpers\"\nexport {\n  attachmentOptions,\n  boxOptions,\n  classifyToken,\n  defaultBackground,\n  formatBackground,\n  parseBackground,\n  repeatOptions,\n  sizeKeywords,\n} from \"./background-editor.helpers\"\nexport type {\n  BackgroundLiteral,\n  BackgroundString,\n  BackgroundValue,\n  BgLayer,\n  LayerCountOf,\n  LayersOf,\n} from \"./background-editor.types\"\nexport { cssBackground } from \"./background-editor.types\"\n",
      "type": "registry:ui",
      "target": "components/ui/background-editor/index.ts"
    },
    {
      "path": "src/components/ui/background-editor/background-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 {\n  defaultBackground,\n  formatBackground,\n  parseBackground,\n} from \"./background-editor.helpers\"\nimport type { BackgroundString, BgLayer } from \"./background-editor.types\"\nimport { BackgroundPreview } from \"./background-preview\"\nimport { LayerStack } from \"./layer-stack\"\n\n// Re-export the public sub-components + their prop types so consumers (and the\n// barrel) can import them from `./background-editor`.\nexport type { BackgroundPreviewProps } from \"./background-preview\"\nexport { BackgroundPreview } from \"./background-preview\"\nexport type { LayerCardProps } from \"./layer-card\"\nexport { LayerCard } from \"./layer-card\"\nexport type { LayerStackProps } from \"./layer-stack\"\nexport { LayerStack } from \"./layer-stack\"\nexport type { MiniSelectProps } from \"./mini-select\"\nexport { MiniSelect } from \"./mini-select\"\nexport type { PositionPadProps } from \"./position-pad\"\nexport { PositionPad } from \"./position-pad\"\n\n// ---------------------------------------------------------------------------\n// Props\n// ---------------------------------------------------------------------------\n\nexport interface BackgroundEditorPanelProps {\n  value: BackgroundString | (string & {})\n  onChange: (value: BackgroundString) => void\n  className?: string\n  \"aria-label\"?: string\n}\n\nexport interface BackgroundEditorProps extends BackgroundEditorPanelProps {}\n\n// ---------------------------------------------------------------------------\n// color re-homing — the index-aware invariant, kept true under reorder\n// ---------------------------------------------------------------------------\n\n/**\n * Keep the `<color>` on the FINAL layer no matter how the stack is reordered.\n * A reorder swaps whole layer objects, so a color that was on the last layer\n * would otherwise travel with its object into a non-final slot and be dropped\n * by `formatBackground` (which only emits a color on the last layer). This\n * collects any color found anywhere in the stack and re-homes it onto the last\n * layer, stripping it from every other — so the invariant survives a reorder.\n */\nfunction migrateColorToFinal(layers: BgLayer[]): BgLayer[] {\n  if (layers.length === 0) return layers\n  // The last non-empty color wins (mirrors CSS: only the final layer's color\n  // is meaningful, but a user-edited color should not be silently lost).\n  let color: string | undefined\n  for (const layer of layers) {\n    if (layer.color !== undefined && layer.color !== \"\") color = layer.color\n  }\n  return layers.map((layer, i) => {\n    const isFinal = i === layers.length - 1\n    if (isFinal) return color === undefined ? layer : { ...layer, color }\n    if (layer.color === undefined) return layer\n    const { color: _drop, ...rest } = layer\n    return rest\n  })\n}\n\n// ---------------------------------------------------------------------------\n// BackgroundEditor — popover-wrapped\n// ---------------------------------------------------------------------------\n\nexport function BackgroundEditor(props: BackgroundEditorProps) {\n  const {\n    value,\n    className,\n    \"aria-label\": ariaLabel = \"Edit a CSS background shorthand\",\n  } = props\n  const { layers, error } = parseBackground(String(value))\n  const label =\n    error !== null\n      ? \"invalid\"\n      : `${layers.length} layer${layers.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        <BackgroundEditorPanel {...props} />\n      </PopoverContent>\n    </Popover>\n  )\n}\n\n// ---------------------------------------------------------------------------\n// BackgroundEditorPanel — inline\n// ---------------------------------------------------------------------------\n\nexport function BackgroundEditorPanel({\n  value,\n  onChange,\n  className,\n  \"aria-label\": ariaLabel = \"CSS background editor\",\n}: BackgroundEditorPanelProps) {\n  const [layers, setLayers] = useState<BgLayer[]>(() => {\n    const parsed = parseBackground(String(value) || defaultBackground())\n    return parsed.error === null && parsed.layers.length > 0\n      ? parsed.layers\n      : parseBackground(defaultBackground()).layers\n  })\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 = parseBackground(String(value))\n    if (parsed.error === null && parsed.layers.length > 0) {\n      setLayers(parsed.layers)\n    }\n  }, [value])\n\n  const commit = (next: BgLayer[]) => {\n    const homed = migrateColorToFinal(next)\n    setLayers(homed)\n    const str = formatBackground(homed)\n    lastEmittedRef.current = str\n    onChange(str as BackgroundString)\n  }\n\n  const produced = formatBackground(layers)\n\n  return (\n    <fieldset\n      className={cn(\n        \"m-0 w-[460px] space-y-3 border-0 bg-background p-3\",\n        className,\n      )}\n      aria-label={ariaLabel}\n    >\n      <div className=\"max-h-[420px] space-y-2 overflow-y-auto pr-1\">\n        <LayerStack layers={layers} onChange={commit} />\n      </div>\n\n      <LiveString value={produced} />\n\n      <BackgroundPreview value={produced} />\n    </fieldset>\n  )\n}\n\n// ---------------------------------------------------------------------------\n// LiveString — the produced `background` value 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/background-editor/background-editor.tsx"
    },
    {
      "path": "src/components/ui/background-editor/background-editor.types.ts",
      "content": "// =====================================================================\n// background-editor.types.ts — ridiculously typed CSS `background`\n// shorthand (multi-layer).\n//\n// BackgroundLiteral<S> splits the value into comma-stacked layers and folds\n// them via head/tail recursion that KNOWS WHEN IT IS AT THE LAST LAYER,\n// permitting a <color> token ONLY there. This index-aware positional-list\n// invariant — the final element of a list is special — is new to the\n// registry (transform-builder / box-shadow-editor have no index-dependent\n// rule). <color> defers to color-picker's ColorLiteral.\n//\n// Per-layer token validation is membership-based + order-free (the `||`\n// ordering/cardinality is deferred to the runtime parser, spec §3.1 A4).\n//\n// Spec: docs/superpowers/specs/2026-06-19-background-editor-design.md\n// =====================================================================\n\nimport type { ColorLiteral } from \"@/components/ui/color-picker\"\nimport type {\n  And,\n  IsLength,\n  IsPercentage,\n  KeepIf,\n  Or,\n  SplitByComma,\n  SplitBySpace,\n} from \"@/lib/ridiculous-type-kit\"\n\ntype Sat<L extends string> = [L] extends [never] ? false : true\n\n// ---------------------------------------------------------------------------\n// per-layer token vocabulary\n// ---------------------------------------------------------------------------\n\ntype PositionKw = \"left\" | \"center\" | \"right\" | \"top\" | \"bottom\"\ntype SizeKw = \"cover\" | \"contain\" | \"auto\"\ntype RepeatKw =\n  | \"repeat\"\n  | \"repeat-x\"\n  | \"repeat-y\"\n  | \"space\"\n  | \"round\"\n  | \"no-repeat\"\ntype AttachmentKw = \"scroll\" | \"fixed\" | \"local\"\ntype BoxKw = \"border-box\" | \"padding-box\" | \"content-box\"\ntype BgKw = PositionKw | SizeKw | RepeatKw | AttachmentKw | BoxKw\n\n// An image is `none` or any parenthesized function (gradient / url / image-set).\n// SplitBySpace is paren-aware, so a gradient token arrives whole.\ntype IsImage<T extends string> = T extends \"none\"\n  ? true\n  : T extends `${string}(${string})`\n    ? true\n    : false\n\ntype IsBgToken<T extends string, AllowColor extends boolean> = T extends \"/\"\n  ? true\n  : T extends BgKw\n    ? true\n    : Or<IsLength<T>, IsPercentage<T>> extends true\n      ? true\n      : IsImage<T> extends true\n        ? true\n        : // the invariant: a color token is legal only in the final layer\n          AllowColor extends true\n          ? Sat<ColorLiteral<T>>\n          : false\n\n// ---------------------------------------------------------------------------\n// layer fold (head/tail — the last layer allows a color)\n// ---------------------------------------------------------------------------\n\ntype AllBgTokens<\n  Toks extends string[],\n  AllowColor extends boolean,\n> = Toks extends [infer H extends string, ...infer R extends string[]]\n  ? IsBgToken<H, AllowColor> extends true\n    ? AllBgTokens<R, AllowColor>\n    : false\n  : true\n\ntype ValidateLayer<L extends string, AllowColor extends boolean> =\n  SplitBySpace<L> extends infer Toks extends string[]\n    ? Toks extends []\n      ? false\n      : AllBgTokens<Toks, AllowColor>\n    : false\n\ntype ValidateLayers<Layers extends string[]> = Layers extends [\n  infer L extends string,\n  ...infer R extends string[],\n]\n  ? R extends []\n    ? ValidateLayer<L, true>\n    : And<ValidateLayer<L, false>, ValidateLayers<R>>\n  : false\n\n/** Strict validator for a CSS `background` shorthand. `S` or `never`. */\nexport type BackgroundLiteral<S extends string> = KeepIf<\n  ValidateLayers<SplitByComma<S>>,\n  S\n>\n\n// ---------------------------------------------------------------------------\n// call-site helper + suggestion + utility\n// ---------------------------------------------------------------------------\n\nexport const cssBackground = <S extends string>(\n  value: S & BackgroundLiteral<S>,\n): S => value\n\nexport type BackgroundString = string & {}\n\n/** The comma-split layers of a `background` value. */\nexport type LayersOf<S extends string> = SplitByComma<S>\nexport type LayerCountOf<S extends string> = LayersOf<S>[\"length\"]\n\n// ---------------------------------------------------------------------------\n// internal state (exported for advanced use)\n// ---------------------------------------------------------------------------\n\nexport interface BgLayer {\n  image: string\n  position: string\n  size: string\n  repeat: string\n  attachment: string\n  origin: string\n  clip: string\n  /** Only meaningful on the final layer. */\n  color?: string\n}\n\nexport interface BackgroundValue {\n  layers: BgLayer[]\n}\n",
      "type": "registry:ui",
      "target": "components/ui/background-editor/background-editor.types.ts"
    },
    {
      "path": "src/components/ui/background-editor/background-editor.helpers.ts",
      "content": "// =====================================================================\n// background-editor.helpers.ts\n//\n// Pure runtime parse / format / classify for the CSS `background` shorthand —\n// a comma-stacked list of layers painted back-to-front, each carrying an image\n// (gradient / url / image-set / none), a position with an optional `/ <size>`,\n// and repeat / attachment / origin / clip keywords; ONLY the final layer may\n// carry a <color>. This is the SUPERSET of the strict type tier in\n// background-editor.types.ts: it splits the structure (paren-aware comma split\n// into layers, then per-layer space-token slotting) and surfaces a verdict as\n// `error`, but keeps the literals the UI needs to round-trip (mirrors\n// query-builder.helpers.ts / gradient-editor.helpers.ts).\n//\n// `classifyToken` is the runtime twin of the type `IsBgToken` — separately\n// authored, reviewed together (the query-builder precedent). The same example\n// tokens appear in both the type-test and the parse-test, so any drift fails a\n// test. <color> defers to color-picker's `isColorString` (hex / functional\n// forms — #fff / oklch(...), not named colors; spec §3.1 A7).\n//\n// Per-layer token slotting is order-free (the `||` ordering / cardinality is\n// the spec §3.1 A4 deferral): tokens are classified by membership, in any\n// order, and slotted by kind; a size token is the one that follows `/`.\n//\n// Spec: docs/superpowers/specs/2026-06-19-background-editor-design.md §4 / §4.1\n// =====================================================================\n\nimport { isColorString } from \"@/components/ui/color-picker\"\nimport type { BgLayer } from \"./background-editor.types\"\n\n// ---------------------------------------------------------------------------\n// per-layer token vocabulary — runtime mirror of the type's keyword unions\n// ---------------------------------------------------------------------------\n\nconst POSITION_KEYWORDS = [\"left\", \"center\", \"right\", \"top\", \"bottom\"] as const\nconst SIZE_KEYWORDS = [\"cover\", \"contain\", \"auto\"] as const\nconst REPEAT_KEYWORDS = [\n  \"repeat\",\n  \"repeat-x\",\n  \"repeat-y\",\n  \"space\",\n  \"round\",\n  \"no-repeat\",\n] as const\nconst ATTACHMENT_KEYWORDS = [\"scroll\", \"fixed\", \"local\"] as const\nconst BOX_KEYWORDS = [\"border-box\", \"padding-box\", \"content-box\"] as const\n\nconst POSITION_SET = new Set<string>(POSITION_KEYWORDS)\nconst SIZE_SET = new Set<string>(SIZE_KEYWORDS)\nconst REPEAT_SET = new Set<string>(REPEAT_KEYWORDS)\nconst ATTACHMENT_SET = new Set<string>(ATTACHMENT_KEYWORDS)\nconst BOX_SET = new Set<string>(BOX_KEYWORDS)\n\n// A <length-percentage> token: an optional sign, digits/dot, optional unit or %.\n// Mirror of box-shadow-editor's LENGTHISH_RE.\nconst LENGTHISH_RE = /^-?[\\d.]+[a-z%]*$/i\n\n/** The kind a single background space-token classifies as. */\nexport type BgTokenKind =\n  | \"image\"\n  | \"position\"\n  | \"size\"\n  | \"repeat\"\n  | \"attachment\"\n  | \"box\"\n  | \"length\"\n  | \"color\"\n  | \"slash\"\n  | \"unknown\"\n\n// ---------------------------------------------------------------------------\n// classifyToken — runtime mirror of the type IsBgToken\n// ---------------------------------------------------------------------------\n\n/**\n * Classify a single background space-token — the runtime mirror of the type\n * `IsBgToken`. The position/size keyword overlap (`auto` etc. never collide)\n * resolves by checked order: slash, then keyword sets, then a length /\n * percentage, then a color (hex / functional, via color-picker's\n * `isColorString`) — checked BEFORE image so a functional color like\n * `oklch(...)` wins over the parenthesized-function image rule — then an image\n * (`none` or any other parenthesized function: gradient / url / image-set),\n * else `\"unknown\"`. Note: a color token is classified regardless of layer index —\n * the last-layer-only invariant is enforced by `parseBackground`, not here.\n */\nexport function classifyToken(token: string): BgTokenKind {\n  const t = token.trim()\n  if (t === \"/\") return \"slash\"\n  if (POSITION_SET.has(t)) return \"position\"\n  if (SIZE_SET.has(t)) return \"size\"\n  if (REPEAT_SET.has(t)) return \"repeat\"\n  if (ATTACHMENT_SET.has(t)) return \"attachment\"\n  if (BOX_SET.has(t)) return \"box\"\n  if (LENGTHISH_RE.test(t)) return \"length\"\n  // A functional color (oklch(...) / rgb(...)) is also a parenthesized\n  // function, so color must be checked BEFORE image — otherwise it would slot\n  // as an image. Gradients / url() are not colors, so they fall through to\n  // image. (A hex token like #fff is not parenthesized, so order is moot for it.)\n  if (isColorString(t)) return \"color\"\n  if (isImage(t)) return \"image\"\n  return \"unknown\"\n}\n\n/** An image is `none` or any parenthesized function (gradient / url / image-set). */\nfunction isImage(token: string): boolean {\n  if (token === \"none\") return true\n  return /^[a-z-]+\\([\\s\\S]*\\)$/i.test(token)\n}\n\n// ---------------------------------------------------------------------------\n// paren-aware splitters\n// ---------------------------------------------------------------------------\n\n/**\n * Split a string on commas, ignoring commas inside parens. Trims each segment.\n * The top-level layer split — a gradient's inner commas stay intact.\n */\nfunction splitTopLevelCommas(input: string): string[] {\n  const trimmed = input.trim()\n  if (trimmed === \"\") return []\n  const out: string[] = []\n  let depth = 0\n  let start = 0\n  for (let i = 0; i < trimmed.length; i++) {\n    const ch = trimmed[i]\n    if (ch === \"(\") depth++\n    else if (ch === \")\") depth--\n    else if (ch === \",\" && depth === 0) {\n      out.push(trimmed.slice(start, i).trim())\n      start = i + 1\n    }\n  }\n  out.push(trimmed.slice(start).trim())\n  return out\n}\n\n/**\n * Split a layer on whitespace, ignoring whitespace inside parens (so a gradient\n * / color function arrives as one token) and keeping a bare `/` as its own\n * token even when it abuts another token.\n */\nfunction splitTopLevelSpaces(input: string): string[] {\n  const trimmed = input.trim()\n  if (trimmed === \"\") return []\n  const out: string[] = []\n  let depth = 0\n  let buf = \"\"\n  const flush = () => {\n    if (buf !== \"\") {\n      out.push(buf)\n      buf = \"\"\n    }\n  }\n  for (let i = 0; i < trimmed.length; i++) {\n    const ch = trimmed[i]\n    if (ch === \"(\") {\n      depth++\n      buf += ch\n    } else if (ch === \")\") {\n      depth--\n      buf += ch\n    } else if (depth === 0 && /\\s/.test(ch)) {\n      flush()\n    } else if (depth === 0 && ch === \"/\") {\n      flush()\n      out.push(\"/\")\n    } else {\n      buf += ch\n    }\n  }\n  flush()\n  return out\n}\n\n// ---------------------------------------------------------------------------\n// parseBackground — string → { layers, error }\n// ---------------------------------------------------------------------------\n\nfunction emptyLayer(): BgLayer {\n  return {\n    image: \"\",\n    position: \"\",\n    size: \"\",\n    repeat: \"\",\n    attachment: \"\",\n    origin: \"\",\n    clip: \"\",\n  }\n}\n\n/**\n * Slot one layer's space-tokens into a `BgLayer`. Membership-based + order-free\n * (spec §3.1 A4): position keywords and lengths/percentages accumulate into\n * `position` until a `/` flips into `size`; the first box keyword is `origin`,\n * the second is `clip`; a color is allowed only when `allowColor` (the final\n * layer). Returns an error message string on an unrecognized / misplaced token,\n * else `null`.\n */\nfunction parseLayer(\n  src: string,\n  allowColor: boolean,\n): { layer: BgLayer; error: string | null } {\n  const layer = emptyLayer()\n  const tokens = splitTopLevelSpaces(src)\n  if (tokens.length === 0) {\n    return { layer, error: \"an empty background layer\" }\n  }\n  const positionParts: string[] = []\n  const sizeParts: string[] = []\n  let afterSlash = false\n\n  for (const tok of tokens) {\n    const kind = classifyToken(tok)\n    switch (kind) {\n      case \"slash\":\n        afterSlash = true\n        break\n      case \"position\":\n      case \"length\":\n        if (afterSlash) sizeParts.push(tok)\n        else positionParts.push(tok)\n        break\n      case \"size\":\n        sizeParts.push(tok)\n        break\n      case \"image\":\n        layer.image = tok\n        break\n      case \"repeat\":\n        layer.repeat = tok\n        break\n      case \"attachment\":\n        layer.attachment = tok\n        break\n      case \"box\":\n        if (layer.origin === \"\") layer.origin = tok\n        else layer.clip = tok\n        break\n      case \"color\":\n        if (!allowColor) {\n          return {\n            layer,\n            error: `a color is only allowed on the final layer: ${tok}`,\n          }\n        }\n        layer.color = tok\n        break\n      default:\n        return { layer, error: `unrecognized token: ${tok}` }\n    }\n  }\n\n  if (positionParts.length > 0) layer.position = positionParts.join(\" \")\n  if (sizeParts.length > 0) layer.size = sizeParts.join(\" \")\n  return { layer, error: null }\n}\n\n/**\n * Parse a CSS `background` shorthand into its layer stack. The value is split\n * paren-aware on top-level commas into layers (a gradient's inner commas stay\n * intact); each layer's space-tokens are slotted into a `BgLayer`. A `<color>`\n * is permitted ONLY on the final layer (the index-aware invariant — spec §3):\n * a color in any non-final layer is an error. `error` is `null` on success and\n * a message otherwise; on error `layers` holds whatever parsed so far. Empty\n * input is an error.\n */\nexport function parseBackground(src: string): {\n  layers: BgLayer[]\n  error: string | null\n} {\n  const segments = splitTopLevelCommas(src)\n  if (segments.length === 0) {\n    return { layers: [], error: \"empty background\" }\n  }\n\n  const layers: BgLayer[] = []\n  for (let i = 0; i < segments.length; i++) {\n    const isFinal = i === segments.length - 1\n    const { layer, error } = parseLayer(segments[i], isFinal)\n    layers.push(layer)\n    if (error !== null) return { layers, error }\n  }\n  return { layers, error: null }\n}\n\n// ---------------------------------------------------------------------------\n// formatBackground — BgLayer[] → canonical string\n// ---------------------------------------------------------------------------\n\n/**\n * Serialize one layer in canonical token order: image, then `position` (with a\n * space-separated `/ size` when a size is present), then repeat, attachment,\n * origin, clip, and — only when `allowColor` (the final layer) — the color.\n */\nfunction formatLayer(layer: BgLayer, allowColor: boolean): string {\n  const parts: string[] = []\n  if (layer.image !== \"\") parts.push(layer.image)\n  if (layer.position !== \"\") {\n    parts.push(\n      layer.size !== \"\" ? `${layer.position} / ${layer.size}` : layer.position,\n    )\n  } else if (layer.size !== \"\") {\n    parts.push(`/ ${layer.size}`)\n  }\n  if (layer.repeat !== \"\") parts.push(layer.repeat)\n  if (layer.attachment !== \"\") parts.push(layer.attachment)\n  if (layer.origin !== \"\") parts.push(layer.origin)\n  if (layer.clip !== \"\") parts.push(layer.clip)\n  if (allowColor && layer.color !== undefined && layer.color !== \"\") {\n    parts.push(layer.color)\n  }\n  return parts.join(\" \")\n}\n\n/**\n * Canonical re-serialization of a layer stack to a `background` value. Layers\n * join with `, `; within a layer, `position / size` uses a space-separated\n * slash; the `<color>` is emitted ONLY on the final layer (spec §3). An empty\n * list serializes to the empty string.\n */\nexport function formatBackground(layers: BgLayer[]): string {\n  return layers\n    .map((layer, i) => formatLayer(layer, i === layers.length - 1))\n    .join(\", \")\n}\n\n// ---------------------------------------------------------------------------\n// option sources (<select> data) + defaults\n// ---------------------------------------------------------------------------\n\n/** The `<repeat>` keywords. */\nexport function repeatOptions(): readonly string[] {\n  return REPEAT_KEYWORDS\n}\n\n/** The `<attachment>` keywords. */\nexport function attachmentOptions(): readonly string[] {\n  return ATTACHMENT_KEYWORDS\n}\n\n/** The `<box>` keywords (origin / clip). */\nexport function boxOptions(): readonly string[] {\n  return BOX_KEYWORDS\n}\n\n/** The `<size>` keywords (a length / percentage is also valid). */\nexport function sizeKeywords(): readonly string[] {\n  return SIZE_KEYWORDS\n}\n\n/** A valid, parseable single-layer seed for a freshly-created editor. */\nexport function defaultBackground(): string {\n  return \"linear-gradient(#3b82f6, #8b5cf6) center / cover no-repeat\"\n}\n",
      "type": "registry:ui",
      "target": "components/ui/background-editor/background-editor.helpers.ts"
    },
    {
      "path": "src/components/ui/background-editor/layer-stack.tsx",
      "content": "\"use client\"\n\nimport { cn } from \"@/lib/utils\"\nimport type { BgLayer } from \"./background-editor.types\"\nimport { LayerCard } from \"./layer-card\"\n\n// ---------------------------------------------------------------------------\n// LayerStack (public) — the reorderable vertical stack of LayerCards. CSS\n// paints layers back-to-front (the first layer is on top), so the stack offers\n// up/down buttons to reorder (no DnD dependency, per the registry dependency\n// policy) plus per-card remove and a single add-layer button. The parent owns\n// the layer array (controlled); the stack only emits the reordered / edited /\n// resized array — it does NOT decide which layer carries the color (the parent\n// re-derives `isFinal` from array position so the color always rides the last\n// layer after a reorder).\n// ---------------------------------------------------------------------------\n\nexport interface LayerStackProps {\n  layers: BgLayer[]\n  onChange: (next: BgLayer[]) => void\n  className?: string\n}\n\nexport function LayerStack({ layers, onChange, className }: LayerStackProps) {\n  const updateAt = (index: number, layer: BgLayer) =>\n    onChange(layers.map((l, i) => (i === index ? layer : l)))\n\n  const removeAt = (index: number) =>\n    onChange(layers.filter((_, i) => i !== index))\n\n  const move = (index: number, dir: -1 | 1) => {\n    const target = index + dir\n    if (target < 0 || target >= layers.length) return\n    const next = layers.slice()\n    const [moved] = next.splice(index, 1)\n    next.splice(target, 0, moved)\n    onChange(next)\n  }\n\n  const add = () =>\n    onChange([\n      ...layers,\n      {\n        image: \"none\",\n        position: \"center\",\n        size: \"cover\",\n        repeat: \"no-repeat\",\n        attachment: \"\",\n        origin: \"\",\n        clip: \"\",\n      },\n    ])\n\n  return (\n    <div className={cn(\"space-y-2\", className)}>\n      {layers.map((layer, i) => (\n        <LayerCard\n          // biome-ignore lint/suspicious/noArrayIndexKey: layers are a positional list reordered only by the up/down buttons; index is the stable identity.\n          key={`layer-${i}`}\n          index={i}\n          isFinal={i === layers.length - 1}\n          layer={layer}\n          onChange={(next) => updateAt(i, next)}\n          onMoveUp={() => move(i, -1)}\n          onMoveDown={() => move(i, 1)}\n          onRemove={() => removeAt(i)}\n          canMoveUp={i > 0}\n          canMoveDown={i < layers.length - 1}\n          canRemove={layers.length > 1}\n        />\n      ))}\n\n      <button\n        type=\"button\"\n        aria-label=\"Add layer\"\n        onClick={add}\n        className=\"w-full rounded border border-dashed py-1.5 font-mono text-[10px] text-muted-foreground hover:text-foreground\"\n      >\n        + add layer\n      </button>\n    </div>\n  )\n}\n",
      "type": "registry:ui",
      "target": "components/ui/background-editor/layer-stack.tsx"
    },
    {
      "path": "src/components/ui/background-editor/layer-card.tsx",
      "content": "\"use client\"\n\nimport { ColorPicker } from \"@/components/ui/color-picker\"\nimport {\n  GradientEditor,\n  isGradientString,\n} from \"@/components/ui/gradient-editor\"\nimport { Input } from \"@/components/ui/input\"\nimport { UnitInput } from \"@/components/ui/unit-input\"\nimport { cn } from \"@/lib/utils\"\nimport {\n  attachmentOptions,\n  boxOptions,\n  repeatOptions,\n  sizeKeywords,\n} from \"./background-editor.helpers\"\nimport type { BgLayer } from \"./background-editor.types\"\nimport { MiniSelect } from \"./mini-select\"\nimport { PositionPad } from \"./position-pad\"\n\n// ---------------------------------------------------------------------------\n// LayerCard (public) — one background layer's editors. The image is an embedded\n// GradientEditor (when the layer's image is a gradient) or a plain `url()` /\n// image input otherwise; a thumbnail mirrors the produced image. A PositionPad\n// 2D crosshair plus x/y UnitInputs drive the position; a size control toggles\n// cover/contain/auto vs a length; repeat / attachment / origin / clip are\n// MiniSelects. The FINAL card additionally renders a ColorPicker for the\n// layer's `<color>` (the index-aware invariant — only the last layer may carry\n// a color). The parent owns the layer value (controlled).\n// ---------------------------------------------------------------------------\n\n// Keyword positions map to their conventional percent anchors so the pad and\n// the keyword forms round-trip.\nconst POSITION_PERCENT: Record<string, number> = {\n  left: 0,\n  top: 0,\n  center: 50,\n  right: 100,\n  bottom: 100,\n}\n\nfunction lengthToPercent(token: string): number | null {\n  const m = /^(-?[\\d.]+)%$/.exec(token)\n  if (m) return Number.parseFloat(m[1])\n  return null\n}\n\n/** Derive an { x, y } percent pair from a position string for the pad. */\nfunction positionToXY(position: string): { x: number; y: number } {\n  const toks = position.trim().split(/\\s+/).filter(Boolean)\n  if (toks.length === 0) return { x: 50, y: 50 }\n  // Resolve the x token (first horizontal-ish) and y token (second).\n  const resolve = (tok: string | undefined, fallback: number): number => {\n    if (tok === undefined) return fallback\n    if (tok in POSITION_PERCENT) return POSITION_PERCENT[tok]\n    const pct = lengthToPercent(tok)\n    return pct === null ? fallback : pct\n  }\n  return { x: resolve(toks[0], 50), y: resolve(toks[1], 50) }\n}\n\nconst SIZE_KEYWORD_SET = new Set<string>(sizeKeywords())\n\nexport interface LayerCardProps {\n  index: number\n  isFinal: boolean\n  layer: BgLayer\n  onChange: (next: BgLayer) => void\n  /** Move this layer up one slot (toward the front / top of the paint order). */\n  onMoveUp?: () => void\n  /** Move this layer down one slot (toward the back of the paint order). */\n  onMoveDown?: () => void\n  /** Remove this layer from the stack. */\n  onRemove?: () => void\n  /** Whether the up button is disabled (the layer is already first). */\n  canMoveUp?: boolean\n  /** Whether the down button is disabled (the layer is already last). */\n  canMoveDown?: boolean\n  /** Whether the remove button is disabled (only one layer remains). */\n  canRemove?: boolean\n  className?: string\n}\n\nexport function LayerCard({\n  index,\n  isFinal,\n  layer,\n  onChange,\n  onMoveUp,\n  onMoveDown,\n  onRemove,\n  canMoveUp = true,\n  canMoveDown = true,\n  canRemove = true,\n  className,\n}: LayerCardProps) {\n  const { x, y } = positionToXY(layer.position)\n  const sizeIsKeyword = layer.size === \"\" || SIZE_KEYWORD_SET.has(layer.size)\n  const sizeSelectValue = SIZE_KEYWORD_SET.has(layer.size) ? layer.size : \"auto\"\n\n  const setPosition = (next: { x: number; y: number }) =>\n    onChange({ ...layer, position: `${next.x}% ${next.y}%` })\n\n  const imageIsGradient = isGradientString(layer.image)\n\n  return (\n    <div\n      data-testid=\"background-layer-card\"\n      data-slot=\"background-layer-card\"\n      className={cn(\"space-y-3 rounded-lg border p-3\", className)}\n    >\n      {/* header — label + reorder / remove controls */}\n      <div className=\"flex items-center justify-between\">\n        <span className=\"font-mono text-[10px] text-muted-foreground uppercase\">\n          layer {index + 1}\n          {isFinal ? \" (final)\" : \"\"}\n        </span>\n        <div className=\"flex items-center gap-1\">\n          <button\n            type=\"button\"\n            aria-label=\"Move layer up\"\n            onClick={onMoveUp}\n            disabled={!onMoveUp || !canMoveUp}\n            className=\"flex size-6 items-center justify-center rounded border text-muted-foreground hover:text-foreground disabled:opacity-30\"\n          >\n            ↑\n          </button>\n          <button\n            type=\"button\"\n            aria-label=\"Move layer down\"\n            onClick={onMoveDown}\n            disabled={!onMoveDown || !canMoveDown}\n            className=\"flex size-6 items-center justify-center rounded border text-muted-foreground hover:text-foreground disabled:opacity-30\"\n          >\n            ↓\n          </button>\n          <button\n            type=\"button\"\n            aria-label=\"Remove layer\"\n            onClick={onRemove}\n            disabled={!onRemove || !canRemove}\n            className=\"flex size-6 items-center justify-center rounded border text-muted-foreground hover:text-foreground disabled:opacity-30\"\n          >\n            ×\n          </button>\n        </div>\n      </div>\n\n      {/* image — gradient editor or url() input */}\n      <div className=\"flex items-center gap-2\">\n        <span className=\"w-14 shrink-0 font-mono text-[10px] text-muted-foreground uppercase\">\n          image\n        </span>\n        {imageIsGradient ? (\n          <GradientEditor\n            value={layer.image}\n            onChange={(next) => onChange({ ...layer, image: next })}\n          />\n        ) : (\n          <Input\n            aria-label={`Layer ${index + 1} image`}\n            value={layer.image}\n            placeholder=\"url(image.png) | none\"\n            onChange={(e) => onChange({ ...layer, image: e.target.value })}\n            className=\"h-8 font-mono text-xs\"\n          />\n        )}\n      </div>\n\n      {/* position — 2D pad + x/y unit inputs */}\n      <div className=\"flex items-center gap-3\">\n        <PositionPad x={x} y={y} onChange={setPosition} />\n        <div className=\"flex flex-col gap-1\">\n          <div className=\"flex items-center gap-1 font-mono text-[10px] text-muted-foreground\">\n            <span aria-hidden=\"true\">x:</span>\n            <UnitInput\n              unit=\"%\"\n              value={`${Math.round(x)}%`}\n              onChange={(v) => setPosition({ x: lengthToPercent(v) ?? x, y })}\n              min={0}\n              max={100}\n              aria-label=\"Position x\"\n              className=\"h-6 w-14\"\n            />\n          </div>\n          <div className=\"flex items-center gap-1 font-mono text-[10px] text-muted-foreground\">\n            <span aria-hidden=\"true\">y:</span>\n            <UnitInput\n              unit=\"%\"\n              value={`${Math.round(y)}%`}\n              onChange={(v) => setPosition({ x, y: lengthToPercent(v) ?? y })}\n              min={0}\n              max={100}\n              aria-label=\"Position y\"\n              className=\"h-6 w-14\"\n            />\n          </div>\n        </div>\n      </div>\n\n      {/* size — keyword vs length */}\n      <div className=\"flex items-center gap-2\">\n        <span className=\"w-14 shrink-0 font-mono text-[10px] text-muted-foreground uppercase\">\n          size\n        </span>\n        <MiniSelect\n          aria-label={`Layer ${index + 1} size`}\n          value={sizeSelectValue}\n          onValueChange={(v) => onChange({ ...layer, size: v })}\n        >\n          {sizeKeywords().map((s) => (\n            <option key={s} value={s}>\n              {s}\n            </option>\n          ))}\n          <option value=\"__length\">length…</option>\n        </MiniSelect>\n        {!sizeIsKeyword && (\n          <UnitInput\n            unit=\"%\"\n            value={layer.size}\n            onChange={(v) => onChange({ ...layer, size: v })}\n            aria-label={`Layer ${index + 1} size length`}\n            className=\"h-7 w-20\"\n          />\n        )}\n      </div>\n\n      {/* repeat / attachment / origin / clip selects */}\n      <div className=\"flex flex-wrap items-center gap-2\">\n        <MiniSelect\n          aria-label={`Layer ${index + 1} repeat`}\n          value={layer.repeat || \"repeat\"}\n          onValueChange={(v) => onChange({ ...layer, repeat: v })}\n        >\n          {repeatOptions().map((r) => (\n            <option key={r} value={r}>\n              {r}\n            </option>\n          ))}\n        </MiniSelect>\n        <MiniSelect\n          aria-label={`Layer ${index + 1} attachment`}\n          value={layer.attachment || \"scroll\"}\n          onValueChange={(v) => onChange({ ...layer, attachment: v })}\n        >\n          {attachmentOptions().map((a) => (\n            <option key={a} value={a}>\n              {a}\n            </option>\n          ))}\n        </MiniSelect>\n        <MiniSelect\n          aria-label={`Layer ${index + 1} origin`}\n          value={layer.origin || \"padding-box\"}\n          onValueChange={(v) => onChange({ ...layer, origin: v })}\n        >\n          {boxOptions().map((b) => (\n            <option key={b} value={b}>\n              {b}\n            </option>\n          ))}\n        </MiniSelect>\n        <MiniSelect\n          aria-label={`Layer ${index + 1} clip`}\n          value={layer.clip || \"border-box\"}\n          onValueChange={(v) => onChange({ ...layer, clip: v })}\n        >\n          {boxOptions().map((b) => (\n            <option key={b} value={b}>\n              {b}\n            </option>\n          ))}\n        </MiniSelect>\n      </div>\n\n      {/* the FINAL layer additionally carries a <color> */}\n      {isFinal && (\n        <div className=\"flex items-center gap-2\">\n          <span className=\"w-14 shrink-0 font-mono text-[10px] text-muted-foreground uppercase\">\n            color\n          </span>\n          <ColorPicker\n            aria-label=\"Layer color\"\n            value={layer.color ?? \"#ffffff\"}\n            onChange={(next) => onChange({ ...layer, color: next })}\n          />\n        </div>\n      )}\n    </div>\n  )\n}\n",
      "type": "registry:ui",
      "target": "components/ui/background-editor/layer-card.tsx"
    },
    {
      "path": "src/components/ui/background-editor/position-pad.tsx",
      "content": "\"use client\"\n\nimport { cn } from \"@/lib/utils\"\n\n// ---------------------------------------------------------------------------\n// PositionPad (public) — the local 2D crosshair background-position picker. A\n// labelled `role=\"slider\"` pad: a pointer press / drag picks the x/y as a\n// percent of the pad, and ArrowKeys nudge by 1% (Shift = 10%). Re-implemented\n// IN SPIRIT from gradient-editor's PositionPicker, NOT imported — registry\n// self-containment means each component carries its own crosshair so a\n// `shadcn add` pulls a self-contained tree. The crosshair marker reflects the\n// current x/y; the parent owns the value (controlled).\n// ---------------------------------------------------------------------------\n\nconst NUDGE_STEP = 1\nconst NUDGE_SHIFT_STEP = 10\n\nfunction clamp(n: number): number {\n  return Math.max(0, Math.min(100, n))\n}\n\nexport interface PositionPadProps {\n  /** Horizontal position, 0..100 (percent). */\n  x: number\n  /** Vertical position, 0..100 (percent). */\n  y: number\n  onChange: (next: { x: number; y: number }) => void\n  className?: string\n  \"aria-label\"?: string\n}\n\nexport function PositionPad({\n  x,\n  y,\n  onChange,\n  className,\n  \"aria-label\": ariaLabel = \"Background position\",\n}: PositionPadProps) {\n  const handlePointer = (event: React.PointerEvent<HTMLDivElement>) => {\n    const rect = event.currentTarget.getBoundingClientRect()\n    const nx = clamp(((event.clientX - rect.left) / rect.width) * 100)\n    const ny = clamp(((event.clientY - rect.top) / rect.height) * 100)\n    onChange({ x: Math.round(nx), y: Math.round(ny) })\n  }\n\n  const onKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {\n    const step = event.shiftKey ? NUDGE_SHIFT_STEP : NUDGE_STEP\n    switch (event.key) {\n      case \"ArrowLeft\":\n        event.preventDefault()\n        onChange({ x: clamp(x - step), y })\n        break\n      case \"ArrowRight\":\n        event.preventDefault()\n        onChange({ x: clamp(x + step), y })\n        break\n      case \"ArrowUp\":\n        event.preventDefault()\n        onChange({ x, y: clamp(y - step) })\n        break\n      case \"ArrowDown\":\n        event.preventDefault()\n        onChange({ x, y: clamp(y + step) })\n        break\n      default:\n        break\n    }\n  }\n\n  return (\n    <div\n      role=\"slider\"\n      aria-label={ariaLabel}\n      aria-valuemin={0}\n      aria-valuemax={100}\n      // The pad is a 2D control; aria-valuenow carries x (the primary axis) and\n      // aria-valuetext spells out both x% y% for assistive tech.\n      aria-valuenow={Math.round(x)}\n      aria-valuetext={`${Math.round(x)}% ${Math.round(y)}%`}\n      tabIndex={0}\n      className={cn(\n        \"relative size-16 shrink-0 cursor-crosshair touch-none rounded border bg-muted/40 outline-hidden focus-visible:ring-2 focus-visible:ring-ring\",\n        className,\n      )}\n      onPointerDown={(event) => {\n        // jsdom lacks setPointerCapture; guard so the pad works under test.\n        event.currentTarget.setPointerCapture?.(event.pointerId)\n        handlePointer(event)\n      }}\n      onPointerMove={(event) => {\n        if (event.buttons) handlePointer(event)\n      }}\n      onKeyDown={onKeyDown}\n      data-slot=\"background-position-pad\"\n    >\n      <div\n        aria-hidden=\"true\"\n        className=\"absolute size-3 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-white shadow ring-1 ring-black/40\"\n        style={{ left: `${clamp(x)}%`, top: `${clamp(y)}%` }}\n      />\n    </div>\n  )\n}\n",
      "type": "registry:ui",
      "target": "components/ui/background-editor/position-pad.tsx"
    },
    {
      "path": "src/components/ui/background-editor/background-preview.tsx",
      "content": "\"use client\"\n\nimport { cn } from \"@/lib/utils\"\n\n// ---------------------------------------------------------------------------\n// BackgroundPreview (public) — the live composite tile. Renders the produced\n// `background` shorthand directly as the tile's `background` style so every\n// layer (gradients, url() images, position / size / repeat, and the final\n// color) composites in real time, back-to-front, exactly as the browser paints\n// it. The value is a vetted shorthand string from `formatBackground`, so there\n// is no injection surface beyond the CSS the user is already authoring. A\n// checkerboard underlay makes transparency visible.\n// ---------------------------------------------------------------------------\n\nconst CHECKER =\n  \"repeating-conic-gradient(rgb(0 0 0 / 0.06) 0% 25%, transparent 0% 50%) 50% / 16px 16px\"\n\nexport interface BackgroundPreviewProps {\n  /** The produced `background` shorthand to paint. */\n  value: string\n  className?: string\n}\n\nexport function BackgroundPreview({\n  value,\n  className,\n}: BackgroundPreviewProps) {\n  return (\n    <div className={cn(\"space-y-2 rounded-lg border p-3\", className)}>\n      <span className=\"text-muted-foreground text-xs\">preview</span>\n      <div\n        data-testid=\"background-preview-tile\"\n        role=\"img\"\n        aria-label={`A tile painted with the background ${value}`}\n        className=\"h-28 w-full overflow-hidden rounded-md border\"\n        style={{ background: value || CHECKER }}\n      />\n    </div>\n  )\n}\n",
      "type": "registry:ui",
      "target": "components/ui/background-editor/background-preview.tsx"
    },
    {
      "path": "src/components/ui/background-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 background-editor\n// dropdown (repeat / attachment / origin / clip / size keyword). Owns the one\n// class-string so the controls never drift. A LOCAL copy: registry\n// self-containment means this component carries its own MiniSelect rather than\n// importing query-builder's (`shadcn add` must pull a self-contained tree).\n// `onValueChange` hands back 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/background-editor/mini-select.tsx"
    }
  ],
  "type": "registry:ui"
}