{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "box-shadow-editor",
  "title": "Box Shadow Editor",
  "description": "Ridiculously typed CSS box-shadow editor — a comma-separated list of shadow layers, each validated at compile time (2–4 lengths, at most one inset, an optional trailing color). Ships a live preview with a draggable light source and elevation scrubber.",
  "dependencies": [],
  "registryDependencies": [
    "ridiculous-type-kit",
    "color-picker",
    "unit-input",
    "button",
    "popover",
    "input",
    "label",
    "slider"
  ],
  "files": [
    {
      "path": "src/components/ui/box-shadow-editor/index.ts",
      "content": "export type {\n  AddLayerButtonProps,\n  BoxShadowEditorPanelProps,\n  BoxShadowEditorProps,\n  BoxShadowPreviewProps,\n  ShadowLayerRowProps,\n  ShadowLengthEditorProps,\n} from \"./box-shadow-editor\"\nexport {\n  AddLayerButton,\n  BoxShadowEditor,\n  BoxShadowEditorPanel,\n  BoxShadowPreview,\n  ShadowLayerRow,\n  ShadowLengthEditor,\n} from \"./box-shadow-editor\"\nexport {\n  boxShadowLayerCount,\n  defaultLayer,\n  formatBoxShadow,\n  layerToCss,\n  parseBoxShadow,\n} from \"./box-shadow-editor.helpers\"\nexport type {\n  BoxShadowKind,\n  BoxShadowLiteral,\n  BoxShadowString,\n  BoxShadowStringMap,\n  HasInset,\n  IsInsetLayer,\n  LayerCountOf,\n  LayersOf,\n  ShadowLayer,\n  ShadowLayerLiteral,\n} from \"./box-shadow-editor.types\"\nexport { cssBoxShadow } from \"./box-shadow-editor.types\"\n",
      "type": "registry:ui",
      "target": "components/ui/box-shadow-editor/index.ts"
    },
    {
      "path": "src/components/ui/box-shadow-editor/box-shadow-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  boxShadowLayerCount,\n  defaultLayer,\n  formatBoxShadow,\n  parseBoxShadow,\n} from \"./box-shadow-editor.helpers\"\nimport type { BoxShadowString, ShadowLayer } from \"./box-shadow-editor.types\"\nimport { BoxShadowPreview } from \"./box-shadow-preview\"\nimport { ShadowLayerRow } from \"./shadow-layer-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 { BoxShadowPreviewProps } from \"./box-shadow-preview\"\nexport { BoxShadowPreview } from \"./box-shadow-preview\"\nexport type { ShadowLayerRowProps } from \"./shadow-layer-row\"\nexport { ShadowLayerRow } from \"./shadow-layer-row\"\nexport type { ShadowLengthEditorProps } from \"./shadow-length-editor\"\nexport { ShadowLengthEditor } from \"./shadow-length-editor\"\n\n// ---------------------------------------------------------------------------\n// Shared props\n// ---------------------------------------------------------------------------\n\nexport interface BoxShadowEditorPanelProps {\n  value: BoxShadowString | (string & {})\n  onChange: (value: BoxShadowString) => void\n  className?: string\n  \"aria-label\"?: string\n}\n\nexport type BoxShadowEditorProps = BoxShadowEditorPanelProps\n\n// ---------------------------------------------------------------------------\n// BoxShadowEditor — popover-wrapped\n// ---------------------------------------------------------------------------\n\nexport function BoxShadowEditor(props: BoxShadowEditorProps) {\n  const {\n    value,\n    className,\n    \"aria-label\": ariaLabel = \"Edit a CSS box-shadow\",\n  } = props\n  const count = boxShadowLayerCount(String(value))\n  const applied = String(value) === \"none\" ? undefined : 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\n            aria-hidden=\"true\"\n            className=\"inline-block h-4 w-4 rounded-sm bg-background\"\n            style={{ boxShadow: applied }}\n          />\n          <span className=\"text-[10px] text-muted-foreground\">\n            {count} {count === 1 ? \"layer\" : \"layers\"}\n          </span>\n          <span className=\"max-w-[180px] truncate text-xs\">{value}</span>\n        </Button>\n      </PopoverTrigger>\n      <PopoverContent className=\"w-auto p-0\" align=\"start\">\n        <BoxShadowEditorPanel {...props} />\n      </PopoverContent>\n    </Popover>\n  )\n}\n\n// ---------------------------------------------------------------------------\n// BoxShadowEditorPanel — inline\n// ---------------------------------------------------------------------------\n\nexport function BoxShadowEditorPanel({\n  value,\n  onChange,\n  className,\n  \"aria-label\": ariaLabel = \"CSS box-shadow editor\",\n}: BoxShadowEditorPanelProps) {\n  const [layers, setLayers] = useState<ShadowLayer[]>(\n    () => parseBoxShadow(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 = parseBoxShadow(String(value))\n    if (parsed !== null) setLayers(parsed)\n  }, [value])\n\n  const commit = (next: ShadowLayer[]) => {\n    setLayers(next)\n    const str = formatBoxShadow(next)\n    lastEmittedRef.current = str\n    onChange(str as BoxShadowString)\n  }\n\n  const updateAt = (index: number, layer: ShadowLayer) => {\n    commit(layers.map((it, i) => (i === index ? layer : it)))\n  }\n  const removeAt = (index: number) => {\n    commit(layers.filter((_, i) => i !== index))\n  }\n  const add = () => {\n    commit([...layers, defaultLayer()])\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        {layers.map((layer, i) => (\n          <ShadowLayerRow\n            // biome-ignore lint/suspicious/noArrayIndexKey: rows are positional and reorderable only by add/remove\n            key={`layer-${i}`}\n            index={i}\n            layer={layer}\n            onChange={(next) => updateAt(i, next)}\n            onRemove={() => removeAt(i)}\n          />\n        ))}\n      </div>\n      <AddLayerButton onAdd={add} />\n      <LiveString value={formatBoxShadow(layers)} />\n      <BoxShadowPreview\n        value={formatBoxShadow(layers)}\n        onChange={(str) => {\n          const parsed = parseBoxShadow(str)\n          if (parsed !== null) commit(parsed)\n        }}\n      />\n    </fieldset>\n  )\n}\n\n// ---------------------------------------------------------------------------\n// AddLayerButton (public)\n// ---------------------------------------------------------------------------\n\nexport interface AddLayerButtonProps {\n  onAdd: () => void\n  className?: string\n}\n\nexport function AddLayerButton({ onAdd, className }: AddLayerButtonProps) {\n  return (\n    <button\n      type=\"button\"\n      onClick={onAdd}\n      aria-label=\"Add a shadow layer\"\n      className={cn(\n        \"h-9 w-full rounded-md border border-dashed bg-background px-2 font-mono text-muted-foreground text-xs hover:text-foreground\",\n        className,\n      )}\n    >\n      + add layer\n    </button>\n  )\n}\n\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/box-shadow-editor/box-shadow-editor.tsx"
    },
    {
      "path": "src/components/ui/box-shadow-editor/box-shadow-editor.types.ts",
      "content": "// =====================================================================\n// box-shadow-editor.types.ts\n//\n// The \"ridiculous\" tier: compile-time PER-LAYER TOKEN VALIDATION over a\n// CSS `box-shadow` value (a COMMA-separated list of shadow layers). This\n// is the INVERSE NESTING of the filter-builder dispatch: filter splits by\n// space into functions; box-shadow splits by COMMA into layers, then by\n// SPACE into the tokens of each layer.\n//\n// Built on `ridiculous-type-kit` plus the color-picker's `ColorLiteral`\n// for the per-layer color. `BoxShadowLiteral<S>` resolves to `S` when\n// every layer validates, `never` otherwise.\n//\n//   \"0px 2px 4px rgb(0 0 0 / 0.2)\"           →  the literal\n//   \"inset 0px 0px 10px 2px #000, 0px 4px 8px #0008\"  →  the literal\n//   \"0px 4px red\"                             →  never (bare keyword color)\n//   \"#000 0px 4px\"                            →  never (leading color)\n//   \"0px 4px -8px\"                            →  never (negative blur)\n//   \"0px\"                                     →  never (too few lengths)\n//\n// Each layer:  [inset?] <offset-x> <offset-y> <blur>? <spread>? <color>?\n//   - exactly 2-4 lengths (offset-x, offset-y required; blur, spread opt.)\n//   - blur (3rd length) is non-negative; spread (4th) may be signed\n//   - at most one `inset` keyword, LEADING or TRAILING only\n//   - at most one <color>, TRAILING only, validated via ColorLiteral\n//     (hex / functional; bare keyword colors like `red` are NOT in\n//     ColorLiteral — strict tier rejects them, the runtime parser accepts)\n// =====================================================================\n\nimport type { ColorLiteral } from \"@/components/ui/color-picker/color-picker.types\"\nimport type {\n  And,\n  IsLength,\n  Or,\n  SplitByComma,\n  SplitBySpace,\n  Trim,\n} from \"@/lib/ridiculous-type-kit\"\n\n// =====================================================================\n// 1. PER-TOKEN PREDICATE ALIASES\n// =====================================================================\n\n/** Collapse the literal-or-never color validator into a boolean. */\ntype IsColor<S extends string> =\n  ColorLiteral<Trim<S>> extends never ? false : true\n\n/** A length whose numeric part is non-negative (no leading `-`). */\ntype IsNonNegLength<S extends string> =\n  Trim<S> extends `-${string}` ? false : IsLength<Trim<S>>\n\n// =====================================================================\n// 2. LENGTH-GROUP VALIDATION\n//\n// Given the tuple of a layer's tokens AFTER any inset keyword has been\n// stripped, validate 2-4 lengths with an OPTIONAL TRAILING color (so the\n// token count ranges 2-5). The legal positional shapes:\n//\n//   [x, y]                     two lengths\n//   [x, y, blur]               three lengths (blur non-negative) …\n//   [x, y, color]              … OR two lengths + a trailing color\n//   [x, y, blur, spread]       four lengths (blur non-neg, spread signed) …\n//   [x, y, blur, color]        … OR three lengths + a trailing color\n//   [x, y, blur, spread, color]   four lengths + a trailing color (full form)\n//\n// x, y are plain lengths (signed OK). blur is a NON-NEGATIVE length.\n// spread is a plain length. color is a ColorLiteral.\n// =====================================================================\n\ntype ValidateLengths<Parts extends string[]> = Parts extends [\n  infer X extends string,\n  infer Y extends string,\n]\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<IsNonNegLength<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<\n            IsLength<Trim<Y>>,\n            And<IsNonNegLength<Z>, Or<IsLength<Trim<W>>, IsColor<W>>>\n          >\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            infer C extends string,\n          ]\n        ? And<\n            IsLength<Trim<X>>,\n            And<\n              IsLength<Trim<Y>>,\n              And<IsNonNegLength<Z>, And<IsLength<Trim<W>>, IsColor<C>>>\n            >\n          >\n        : false // < 2 or > 5 tokens (more than 4 lengths + a color)\n\n// =====================================================================\n// 3. INSET STRIPPING + PER-LAYER TOKEN VALIDATION\n//\n// `inset` is allowed LEADING or TRAILING only. We peel a leading inset,\n// else a trailing inset, then validate the remaining tokens as a length\n// group. A mid-token inset falls through to ValidateLengths where it\n// fails (inset is neither a length nor a ColorLiteral). A doubled inset\n// is rejected because after peeling one end the other inset remains in\n// the length group and fails.\n// =====================================================================\n\ntype ValidateLayerTokens<Parts extends string[]> = Parts extends [\n  \"inset\",\n  ...infer Rest extends string[],\n]\n  ? ValidateLengths<Rest>\n  : Parts extends [...infer Init extends string[], \"inset\"]\n    ? ValidateLengths<Init>\n    : ValidateLengths<Parts>\n\n// =====================================================================\n// 4. STRICT VALIDATORS + CALL-SITE HELPER\n// =====================================================================\n\n/**\n * Strict single-layer validator. Resolves to `S` when `S` is one valid\n * shadow layer (inset placement + 2-4 lengths + optional trailing color),\n * `never` otherwise. A comma-separated list is NOT a single layer.\n *\n * @example\n * type A = ShadowLayerLiteral<\"inset 0px 4px 8px #000\"> // the literal\n * type B = ShadowLayerLiteral<\"0px 4px red\">            // never\n */\nexport type ShadowLayerLiteral<S extends string> =\n  SplitBySpace<Trim<S>> extends infer Parts extends string[]\n    ? ValidateLayerTokens<Parts> extends true\n      ? S\n      : never\n    : never\n\n// Depth-capped fold over the comma layer list. Up to 32 layers are fully\n// validated; beyond the cap the tail is weak-validated (each layer must be\n// non-empty). The runtime parser validates fully regardless of count.\ntype ValidateLayers<\n  Layers extends string[],\n  Depth extends unknown[] = [],\n> = Layers extends [infer H extends string, ...infer T extends string[]]\n  ? Depth[\"length\"] extends 32\n    ? Trim<H> extends \"\" // past the cap: weak-validate non-empty\n      ? false\n      : ValidateLayers<T, Depth>\n    : ValidateLayerTokens<\n          SplitBySpace<Trim<H>> extends infer P extends string[] ? P : []\n        > extends true\n      ? ValidateLayers<T, [...Depth, unknown]>\n      : false\n  : true // reached the end without a failure\n\n/**\n * Strict literal validator. Resolves to `S` when `S` is a valid CSS\n * `box-shadow` value (or the `none` keyword), `never` otherwise.\n * `calc()` / `var()` inside a token resolve to `never` here (undecidable\n * at compile time) — use the casual / IntelliSense tier; the runtime\n * parser accepts them.\n *\n * @example\n * type A = BoxShadowLiteral<\"0px 2px 4px #000\">  // the literal\n * type B = BoxShadowLiteral<\"0px 4px red\">       // never (bare keyword)\n * type C = BoxShadowLiteral<\"none\">               // \"none\"\n */\nexport type BoxShadowLiteral<S extends string> =\n  Trim<S> extends \"none\"\n    ? S\n    : Trim<S> extends \"\"\n      ? never\n      : SplitByComma<Trim<S>> extends infer Layers extends string[]\n        ? Layers extends []\n          ? never\n          : ValidateLayers<Layers> extends true\n            ? S\n            : never\n        : never\n\n/**\n * Call-site validator helper. Mirrors `cssFilter()` / `cssTransform()` /\n * `color()` / `easing()`. An invalid box-shadow becomes a type error at\n * the argument.\n */\nexport const cssBoxShadow = <S extends string>(\n  value: S & BoxShadowLiteral<S>,\n): S => value\n\n// =====================================================================\n// 5. SUGGESTION STRINGS — IntelliSense + onChange return types\n// =====================================================================\n\n/**\n * Suggestion union — \"this is a box-shadow string\". Kept permissive (like\n * filter-builder's `FilterString`): a single layer is two-or-more\n * space-separated tokens; multi-layer lists are head-anchored on a layer\n * followed by a comma; plus `none`. The STRICT tier is the real gate.\n */\nexport type ShadowLayerString = `${string} ${string}`\n\nexport type BoxShadowString =\n  | ShadowLayerString\n  | `${ShadowLayerString}, ${string}`\n  | \"none\"\n\n/**\n * Layer-kind → output-string map. `box-shadow` has a single render target\n * (no filter/backdrop-filter-style mode), so the map is keyed by layer\n * KIND — `inset` shadows lead with the `inset` keyword; `outset` shadows\n * are a plain layer. Backs the per-kind suggestion shapes.\n */\nexport interface BoxShadowStringMap {\n  outset: ShadowLayerString\n  inset: `inset ${string}`\n}\n\nexport type BoxShadowKind = keyof BoxShadowStringMap\n\n// =====================================================================\n// 6. UTILITY TYPES — operate on box-shadow literals at the type level\n// =====================================================================\n\n/**\n * The raw per-layer strings of a box-shadow value.\n *\n * @example\n * type T = LayersOf<\"0px 4px #000, inset 0px 0px 2px\">\n * //   [\"0px 4px #000\", \"inset 0px 0px 2px\"]\n * type N = LayersOf<\"none\"> // []\n */\nexport type LayersOf<S extends string> =\n  Trim<S> extends \"none\" | \"\" ? [] : SplitByComma<Trim<S>>\n\n/**\n * The number of shadow layers.\n *\n * @example\n * type C = LayerCountOf<\"0px 1px, 0px 4px\"> // 2\n */\nexport type LayerCountOf<S extends string> = LayersOf<S>[\"length\"]\n\n// Whether a single layer's tokens carry a leading or trailing `inset`.\ntype LayerTokensHaveInset<Parts extends string[]> = Parts extends [\n  \"inset\",\n  ...string[],\n]\n  ? true\n  : Parts extends [...string[], \"inset\"]\n    ? true\n    : false\n\n/**\n * Whether a SINGLE layer string is an inset shadow.\n *\n * @example\n * type A = IsInsetLayer<\"inset 0px 0px 2px\">       // true\n * type B = IsInsetLayer<\"0px 0px 2px #000 inset\">  // true\n * type C = IsInsetLayer<\"0px 4px\">                  // false\n */\nexport type IsInsetLayer<S extends string> = LayerTokensHaveInset<\n  SplitBySpace<Trim<S>> extends infer P extends string[] ? P : []\n>\n\ntype AnyLayerInset<Layers extends string[]> = Layers extends [\n  infer H extends string,\n  ...infer T extends string[],\n]\n  ? IsInsetLayer<H> extends true\n    ? true\n    : AnyLayerInset<T>\n  : false\n\n/**\n * Whether ANY layer in the box-shadow is an inset shadow.\n *\n * @example\n * type A = HasInset<\"0px 4px, inset 0px 0px 2px\"> // true\n * type B = HasInset<\"0px 4px #000\">                // false\n */\nexport type HasInset<S extends string> = AnyLayerInset<LayersOf<S>>\n\n// =====================================================================\n// 7. INTERNAL STATE — the per-layer record (exported)\n//\n// The editor's state is `ShadowLayer[]`. Every layer has the same shape,\n// so the meaningful discriminant is the boolean `inset` (not a tagged\n// union on a function name as in filter-builder). Exported for advanced\n// use (custom serialization, programmatic build). Values are kept as\n// strings (they carry units / colors), mirroring how the literal\n// preserves the raw text.\n// =====================================================================\n\nexport interface ShadowLayer {\n  /** Whether this is an inner (`inset`) shadow. */\n  inset: boolean\n  /** Horizontal offset (signed length). */\n  offsetX: string\n  /** Vertical offset (signed length). */\n  offsetY: string\n  /** Blur radius (non-negative length). Optional. */\n  blur?: string\n  /** Spread radius (signed length). Optional. */\n  spread?: string\n  /** Color (hex / functional / — at runtime — keyword). Optional. */\n  color?: string\n}\n",
      "type": "registry:ui",
      "target": "components/ui/box-shadow-editor/box-shadow-editor.types.ts"
    },
    {
      "path": "src/components/ui/box-shadow-editor/box-shadow-editor.helpers.ts",
      "content": "// =====================================================================\n// box-shadow-editor.helpers.ts\n//\n// Pure runtime parse / format for the CSS `box-shadow` property — a\n// COMMA-separated list of shadow layers. This is the SUPERSET of the\n// strict type tier: it tolerates calc()/var() tokens (kept verbatim),\n// bare keyword colors (`red`), and a LEADING color (normalizing it to\n// color-last), validates per-layer arity, and is the single source of\n// truth the UI drives off.\n//\n// Each layer:  [inset?] <offset-x> <offset-y> <blur>? <spread>? <color>?\n// =====================================================================\n\nimport type { ShadowLayer } from \"./box-shadow-editor.types\"\n\n// ---------------------------------------------------------------------------\n// Top-level splitter (paren-aware, runtime mirror of the kit combinator)\n// ---------------------------------------------------------------------------\n\n/** Split on `sep` only at bracket depth 0. */\nfunction splitTopLevel(src: string, sep: string): string[] {\n  const out: string[] = []\n  let depth = 0\n  let cur = \"\"\n  for (const ch of src) {\n    if (ch === \"(\" || ch === \"[\") depth++\n    else if (ch === \")\" || ch === \"]\") depth--\n    if (ch === sep && depth === 0) {\n      out.push(cur)\n      cur = \"\"\n    } else {\n      cur += ch\n    }\n  }\n  out.push(cur)\n  return out\n}\n\n/** Split the comma layer list into trimmed layer strings (drops empties). */\nfunction splitLayers(src: string): string[] {\n  return splitTopLevel(src, \",\")\n    .map((s) => s.trim())\n    .filter((s) => s.length > 0)\n}\n\n/** Split a layer into space-separated tokens (drops empty runs). */\nfunction splitTokens(layer: string): string[] {\n  return splitTopLevel(layer, \" \")\n    .map((s) => s.trim())\n    .filter((s) => s.length > 0)\n}\n\n// A token that looks like a CSS length / numeric value (possibly an opaque\n// calc()/var()) — NOT a color and NOT the `inset` keyword.\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()/env() length expressions are opaque.\n  return /^(calc|var|min|max|clamp|env)\\(/i.test(token)\n}\n\n// ---------------------------------------------------------------------------\n// parseLayer — tokens → ShadowLayer | null\n// ---------------------------------------------------------------------------\n\n/**\n * Build one ShadowLayer from a layer's tokens. Accepts `inset` leading or\n * trailing, and a color before OR after the lengths (CSS allows either);\n * normalizes to inset-leading + color-last. Requires 2-4 length tokens,\n * at most one color, at most one inset.\n */\nfunction parseLayer(tokens: string[]): ShadowLayer | null {\n  let inset = false\n  let insetSeen = false\n  const lengths: string[] = []\n  let color: string | undefined\n\n  for (const token of tokens) {\n    if (token.toLowerCase() === \"inset\") {\n      if (insetSeen) return null // a second inset — invalid\n      insetSeen = true\n      inset = true\n    } else if (isLengthish(token)) {\n      lengths.push(token)\n    } else if (color === undefined) {\n      color = token\n    } else {\n      return null // a second non-length, non-inset token — invalid\n    }\n  }\n\n  if (lengths.length < 2 || lengths.length > 4) return null\n\n  const [offsetX, offsetY, blur, spread] = lengths\n  const layer: ShadowLayer = { inset, offsetX, offsetY }\n  if (blur !== undefined) layer.blur = blur\n  if (spread !== undefined) layer.spread = spread\n  if (color !== undefined) layer.color = color\n  return layer\n}\n\n// ---------------------------------------------------------------------------\n// parseBoxShadow — string → ShadowLayer[] | null\n// ---------------------------------------------------------------------------\n\n/**\n * Parse a CSS `box-shadow` value into typed layers, or `null` on any\n * syntax / arity error. `none` / empty → `[]`. Tolerant: keeps calc()/var()\n * verbatim, accepts bare keyword colors and a leading color (normalized to\n * color-last).\n */\nexport function parseBoxShadow(src: string): ShadowLayer[] | null {\n  const trimmed = src.trim()\n  if (trimmed === \"\" || trimmed === \"none\") return []\n\n  const layerStrings = splitLayers(trimmed)\n  if (layerStrings.length === 0) return []\n\n  const layers: ShadowLayer[] = []\n  for (const layerStr of layerStrings) {\n    const tokens = splitTokens(layerStr)\n    const layer = parseLayer(tokens)\n    if (layer === null) return null\n    layers.push(layer)\n  }\n  return layers\n}\n\n/** Runtime mirror of `LayerCountOf` — the number of layers. */\nexport function boxShadowLayerCount(src: string): number {\n  const layers = parseBoxShadow(src)\n  if (layers === null) return 0\n  return layers.length\n}\n\n// ---------------------------------------------------------------------------\n// defaultLayer — seed a fresh layer\n// ---------------------------------------------------------------------------\n\n/**\n * The canonical soft-drop-shadow color. A single source of truth for the\n * default-layer seed and the preview's auto-generated elevation layer (both\n * a 25%-opacity black). NOTE: the per-row \"+ color\" affordance seeds a more\n * opaque `rgb(0 0 0 / 0.5)` on purpose — that is an explicit, user-visible\n * color, distinct from this implicit default.\n */\nexport const DEFAULT_SHADOW_COLOR = \"rgb(0 0 0 / 0.25)\"\n\n/** A sensible default layer — a soft drop shadow. */\nexport function defaultLayer(): ShadowLayer {\n  return {\n    inset: false,\n    offsetX: \"0px\",\n    offsetY: \"4px\",\n    blur: \"8px\",\n    color: DEFAULT_SHADOW_COLOR,\n  }\n}\n\n// ---------------------------------------------------------------------------\n// layerToCss / formatBoxShadow — canonical serialization\n// ---------------------------------------------------------------------------\n\n/**\n * Serialize one layer to its CSS string: `[inset ]x y[ blur][ spread][ color]`\n * — inset leading, color last.\n */\nexport function layerToCss(layer: ShadowLayer): string {\n  const parts: string[] = []\n  if (layer.inset) parts.push(\"inset\")\n  parts.push(layer.offsetX, layer.offsetY)\n  if (layer.blur !== undefined) parts.push(layer.blur)\n  if (layer.spread !== undefined) parts.push(layer.spread)\n  if (layer.color !== undefined) parts.push(layer.color)\n  return parts.join(\" \")\n}\n\n/** Canonical re-serialization of a layer list. Empty → `none`. */\nexport function formatBoxShadow(layers: ShadowLayer[]): string {\n  if (layers.length === 0) return \"none\"\n  return layers.map(layerToCss).join(\", \")\n}\n",
      "type": "registry:ui",
      "target": "components/ui/box-shadow-editor/box-shadow-editor.helpers.ts"
    },
    {
      "path": "src/components/ui/box-shadow-editor/box-shadow-preview.tsx",
      "content": "\"use client\"\n\nimport { useEffect, useId, useRef, useState } from \"react\"\nimport { UnitInput } from \"@/components/ui/unit-input\"\nimport { cn } from \"@/lib/utils\"\nimport {\n  DEFAULT_SHADOW_COLOR,\n  formatBoxShadow,\n  parseBoxShadow,\n} from \"./box-shadow-editor.helpers\"\nimport type { ShadowLayer } from \"./box-shadow-editor.types\"\n\n// ---------------------------------------------------------------------------\n// BoxShadowPreview (public) — the showcase, with a draggable light source\n// ---------------------------------------------------------------------------\n\nexport interface BoxShadowPreviewProps {\n  value: string\n  onChange?: (value: string) => void\n  className?: string\n}\n\n// The \"light\" is a point in the stage; the shadow is cast OPPOSITE to it.\n// We map the light's offset from center to a per-layer offset, scaled so a\n// stack reads as one coherent light. Magnitude scales gently with each\n// layer's blur (deeper/softer layers cast a longer shadow).\nconst LIGHT_GAIN = 24 // px of shadow offset at the stage edge\n\n/** Pull the dominant blur (px) from the first layer, defaulting to 8. */\nfunction dominantBlur(layers: ShadowLayer[]): number {\n  for (const layer of layers) {\n    if (layer.blur) {\n      const n = Number.parseFloat(layer.blur)\n      if (!Number.isNaN(n)) return n\n    }\n  }\n  return 8\n}\n\n/**\n * Re-cast every layer's offset from a light vector in [-1, 1] (x,y from the\n * stage center). Shadow is opposite the light. Blur / spread / color / inset\n * are preserved.\n */\nfunction castFromLight(\n  layers: ShadowLayer[],\n  lx: number,\n  ly: number,\n): ShadowLayer[] {\n  return layers.map((layer, i) => {\n    const depth = 1 + i * 0.6 // stacked layers fan out a touch\n    const ox = Math.round(-lx * LIGHT_GAIN * depth)\n    const oy = Math.round(-ly * LIGHT_GAIN * depth)\n    return { ...layer, offsetX: `${ox}px`, offsetY: `${oy}px` }\n  })\n}\n\n/** Scale blur (and a touch of y-offset) across all layers — \"elevation\". */\nfunction applyElevation(layers: ShadowLayer[], blurPx: number): ShadowLayer[] {\n  if (layers.length === 0) {\n    return [\n      {\n        inset: false,\n        offsetX: \"0px\",\n        offsetY: `${Math.round(blurPx / 2)}px`,\n        blur: `${blurPx}px`,\n        color: DEFAULT_SHADOW_COLOR,\n      },\n    ]\n  }\n  return layers.map((layer, i) => ({\n    ...layer,\n    blur: `${Math.round(blurPx * (1 + i * 0.5))}px`,\n  }))\n}\n\nexport function BoxShadowPreview({\n  value,\n  onChange,\n  className,\n}: BoxShadowPreviewProps) {\n  const id = useId()\n  const stageRef = useRef<HTMLDivElement>(null)\n  const [dragging, setDragging] = useState(false)\n\n  const layers = parseBoxShadow(value) ?? []\n  const applied = value === \"none\" ? undefined : value\n  const blurPx = dominantBlur(layers)\n\n  // Light position in [0,1] across the stage, derived from the cast offset of\n  // the first layer so the dot tracks the shadow. Default: top-left.\n  const first = layers[0]\n  const lightFromState = (() => {\n    if (!first) return { x: 0.3, y: 0.3 }\n    const ox = Number.parseFloat(first.offsetX) || 0\n    const oy = Number.parseFloat(first.offsetY) || 0\n    // invert the cast: light = -offset / gain, mapped back to [0,1]\n    const lx = clamp01(0.5 - ox / (LIGHT_GAIN * 2))\n    const ly = clamp01(0.5 - oy / (LIGHT_GAIN * 2))\n    return { x: lx, y: ly }\n  })()\n\n  const moveLight = (clientX: number, clientY: number) => {\n    const stage = stageRef.current\n    if (stage === null || !onChange) return\n    const rect = stage.getBoundingClientRect()\n    if (rect.width === 0 || rect.height === 0) return\n    const px = clamp01((clientX - rect.left) / rect.width)\n    const py = clamp01((clientY - rect.top) / rect.height)\n    // light vector from center in [-1, 1]\n    const lx = (px - 0.5) * 2\n    const ly = (py - 0.5) * 2\n    onChange(formatBoxShadow(castFromLight(layers, lx, ly)))\n  }\n\n  // Keep the latest moveLight in a ref so the window listeners — attached ONCE\n  // per drag (not per render / per drag tick) — always read current state.\n  const moveLightRef = useRef(moveLight)\n  moveLightRef.current = moveLight\n\n  // Subscribe to window pointer events only WHILE dragging. Gating on the\n  // `dragging` state means we attach on drag-start and detach on drag-end,\n  // instead of re-subscribing on every render (incl. each drag tick).\n  useEffect(() => {\n    if (!dragging) return\n    const onPointerMove = (e: PointerEvent) => {\n      moveLightRef.current(e.clientX, e.clientY)\n    }\n    const onPointerUp = () => setDragging(false)\n    window.addEventListener(\"pointermove\", onPointerMove)\n    window.addEventListener(\"pointerup\", onPointerUp)\n    return () => {\n      window.removeEventListener(\"pointermove\", onPointerMove)\n      window.removeEventListener(\"pointerup\", onPointerUp)\n    }\n  }, [dragging])\n\n  const nudgeLight = (dx: number, dy: number) => {\n    if (!onChange) return\n    const lx = clamp(lightFromState.x * 2 - 1 + dx, -1, 1)\n    const ly = clamp(lightFromState.y * 2 - 1 + dy, -1, 1)\n    onChange(formatBoxShadow(castFromLight(layers, lx, ly)))\n  }\n\n  const xPct = Math.round(lightFromState.x * 100)\n  const yPct = Math.round(lightFromState.y * 100)\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=\"text-[10px] text-muted-foreground\">drag the light</div>\n      </div>\n\n      <div\n        ref={stageRef}\n        data-testid=\"box-shadow-stage\"\n        className=\"relative flex h-44 items-center justify-center overflow-hidden rounded-md bg-[radial-gradient(circle_at_50%_40%,#1e293b,#0f172a)]\"\n      >\n        <div\n          data-shadow-target\n          className=\"h-20 w-28 rounded-xl bg-white\"\n          style={{ boxShadow: applied }}\n          aria-hidden=\"true\"\n        />\n        {onChange ? (\n          // A 2-D positional control: a single-axis `slider` can only carry\n          // ONE `aria-valuenow`, so it can't honestly describe both x and y.\n          // Instead this is an `application` region the user drives with the\n          // pointer or arrow keys, with BOTH dimensions folded into its\n          // accessible name; `aria-roledescription` names the widget pattern.\n          // It stays a <button> so it is natively focusable + operable (no\n          // manual tabIndex, no keyboard a11y gaps). `aria-valuetext` /\n          // `aria-valuenow` are deliberately omitted: they are range-widget\n          // props the `application` role does not support — the live 2-axis\n          // readout lives in the accessible name instead.\n          // biome-ignore lint/a11y/noInteractiveElementToNoninteractiveRole: a draggable 2-D handle has no native role; `application` is the documented pattern, and a <button> keeps it focusable/operable\n          <button\n            type=\"button\"\n            role=\"application\"\n            aria-roledescription=\"2D light position\"\n            aria-label={`Light source position: x ${xPct}%, y ${yPct}%`}\n            onPointerDown={(e) => {\n              setDragging(true)\n              if (e.currentTarget.setPointerCapture) {\n                e.currentTarget.setPointerCapture(e.pointerId)\n              }\n              moveLight(e.clientX, e.clientY)\n            }}\n            onKeyDown={(e) => {\n              const step = e.shiftKey ? 0.2 : 0.06\n              if (e.key === \"ArrowLeft\") {\n                e.preventDefault()\n                nudgeLight(-step, 0)\n              } else if (e.key === \"ArrowRight\") {\n                e.preventDefault()\n                nudgeLight(step, 0)\n              } else if (e.key === \"ArrowUp\") {\n                e.preventDefault()\n                nudgeLight(0, -step)\n              } else if (e.key === \"ArrowDown\") {\n                e.preventDefault()\n                nudgeLight(0, step)\n              }\n            }}\n            className=\"absolute h-5 w-5 -translate-x-1/2 -translate-y-1/2 cursor-grab rounded-full border-2 border-yellow-300 bg-yellow-200 shadow-[0_0_12px_4px_rgba(253,224,71,0.7)] active:cursor-grabbing\"\n            style={{\n              left: `${lightFromState.x * 100}%`,\n              top: `${lightFromState.y * 100}%`,\n            }}\n          >\n            <span className=\"sr-only\">light source</span>\n          </button>\n        ) : null}\n      </div>\n\n      {onChange ? (\n        <label\n          htmlFor={`${id}-elevation`}\n          className=\"flex items-center gap-2 text-xs\"\n        >\n          <span className=\"w-20 font-mono text-muted-foreground\">\n            elevation\n          </span>\n          <UnitInput\n            unit=\"px\"\n            value={`${blurPx}px`}\n            min={0}\n            max={80}\n            aria-label=\"Elevation (blur) in px\"\n            className=\"h-7 w-20\"\n            onChange={(next) => {\n              const n = Number.parseFloat(next)\n              onChange(\n                formatBoxShadow(\n                  applyElevation(layers, Number.isNaN(n) ? 0 : n),\n                ),\n              )\n            }}\n          />\n        </label>\n      ) : null}\n    </div>\n  )\n}\n\n// ---------------------------------------------------------------------------\n// small numeric helpers\n// ---------------------------------------------------------------------------\n\nfunction clamp(n: number, lo: number, hi: number): number {\n  return Math.min(hi, Math.max(lo, n))\n}\n\nfunction clamp01(n: number): number {\n  return clamp(n, 0, 1)\n}\n",
      "type": "registry:ui",
      "target": "components/ui/box-shadow-editor/box-shadow-preview.tsx"
    },
    {
      "path": "src/components/ui/box-shadow-editor/shadow-layer-row.tsx",
      "content": "\"use client\"\n\nimport { ColorPicker } from \"@/components/ui/color-picker\"\nimport { cn } from \"@/lib/utils\"\nimport type { ShadowLayer } from \"./box-shadow-editor.types\"\nimport { ShadowLengthEditor } from \"./shadow-length-editor\"\n\nexport interface ShadowLayerRowProps {\n  layer: ShadowLayer\n  onChange: (layer: ShadowLayer) => void\n  onRemove: () => void\n  /** Positional index — used only for stable control labels. */\n  index?: number\n  className?: string\n}\n\nexport function ShadowLayerRow({\n  layer,\n  onChange,\n  onRemove,\n  index,\n  className,\n}: ShadowLayerRowProps) {\n  const n = index === undefined ? \"\" : ` ${index + 1}`\n  const setField = (patch: Partial<ShadowLayer>) => {\n    onChange({ ...layer, ...patch })\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      <button\n        type=\"button\"\n        aria-label={`Inset shadow${n}`}\n        aria-pressed={layer.inset}\n        onClick={() => setField({ inset: !layer.inset })}\n        className={cn(\n          \"h-8 rounded border px-2 font-mono text-[10px]\",\n          layer.inset\n            ? \"bg-primary text-primary-foreground\"\n            : \"bg-background text-muted-foreground\",\n        )}\n      >\n        inset\n      </button>\n      <ShadowLengthEditor\n        label={`offset-x${n}`}\n        value={layer.offsetX}\n        allowNegative\n        onChange={(offsetX) => setField({ offsetX })}\n      />\n      <ShadowLengthEditor\n        label={`offset-y${n}`}\n        value={layer.offsetY}\n        allowNegative\n        onChange={(offsetY) => setField({ offsetY })}\n      />\n      <ShadowLengthEditor\n        label={`blur${n}`}\n        value={layer.blur ?? \"\"}\n        onChange={(blur) => setField({ blur: blur === \"\" ? undefined : blur })}\n      />\n      <ShadowLengthEditor\n        label={`spread${n}`}\n        value={layer.spread ?? \"\"}\n        allowNegative\n        onChange={(spread) =>\n          setField({ spread: spread === \"\" ? undefined : spread })\n        }\n      />\n      {layer.color === undefined ? (\n        <button\n          type=\"button\"\n          aria-label={`Add color${n}`}\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={layer.color}\n            onChange={(color) => setField({ color })}\n            aria-label={`color${n}`}\n          />\n          <button\n            type=\"button\"\n            aria-label={`Remove color${n}`}\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      <button\n        type=\"button\"\n        onClick={onRemove}\n        aria-label={`Remove layer${n}`}\n        className=\"ml-auto rounded p-1 text-muted-foreground hover:bg-destructive/10 hover:text-destructive\"\n      >\n        <span aria-hidden=\"true\">×</span>\n      </button>\n    </div>\n  )\n}\n",
      "type": "registry:ui",
      "target": "components/ui/box-shadow-editor/shadow-layer-row.tsx"
    },
    {
      "path": "src/components/ui/box-shadow-editor/shadow-length-editor.tsx",
      "content": "\"use client\"\n\nimport { Input } from \"@/components/ui/input\"\nimport { cn } from \"@/lib/utils\"\n\n// ---------------------------------------------------------------------------\n// Length units offered by the per-slot editor.\n// ---------------------------------------------------------------------------\n\nexport const LENGTH_UNITS = [\"px\", \"rem\", \"em\", \"%\", \"vw\", \"vh\"] as const\n\n// A non-empty value that splits into a numeric part (≥1 digit, optional\n// exponent) followed by an optional unit. A lone sign, a bare unit, or a\n// solitary \".\" does NOT match — those are treated as opaque (raw) values.\nconst NUMBER_UNIT_RE = /^(-?(?:\\d+\\.?\\d*|\\.\\d+)(?:e[+-]?\\d+)?)([a-z%]*)$/i\n\nexport interface ShadowLengthEditorProps {\n  label: string\n  value: string\n  onChange: (value: string) => void\n  /** Allow a leading minus sign (offsets + spread). Blur disallows it. */\n  allowNegative?: boolean\n  className?: string\n}\n\nexport function ShadowLengthEditor({\n  label,\n  value,\n  onChange,\n  allowNegative = false,\n  className,\n}: ShadowLengthEditorProps) {\n  // Split \"10px\" / \"-2px\" / \"150%\" / \"1e3px\" into number + unit. The number\n  // part requires AT LEAST ONE digit (so a lone \"-\", a bare unit, or \".\" is\n  // NOT a match) and accepts an optional exponent. An empty slot stays in the\n  // split editor (the unit select seeds \"0\"); any non-empty, non-matching\n  // value (calc()/var()/lone-sign/bare-unit) is shown raw.\n  const m = value.match(NUMBER_UNIT_RE)\n  const numPart = m ? m[1] : \"\"\n  const unitPart = m ? m[2] : \"\"\n  const opaque = value !== \"\" && m === null // calc()/var()/lone-sign — 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-[110px] font-mono text-xs\", className)}\n      />\n    )\n  }\n\n  return (\n    <span className={cn(\"inline-flex items-center\", className)}>\n      <Input\n        aria-label={label}\n        value={value}\n        spellCheck={false}\n        autoComplete=\"off\"\n        inputMode={allowNegative ? \"text\" : \"decimal\"}\n        onChange={(e) => onChange(e.target.value)}\n        className=\"h-8 w-[56px] rounded-r-none border-r-0 font-mono text-xs\"\n      />\n      <select\n        aria-label={`${label} unit`}\n        value={unitPart || LENGTH_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        {LENGTH_UNITS.map((u) => (\n          <option key={u} value={u}>\n            {u}\n          </option>\n        ))}\n      </select>\n    </span>\n  )\n}\n",
      "type": "registry:ui",
      "target": "components/ui/box-shadow-editor/shadow-length-editor.tsx"
    }
  ],
  "type": "registry:ui"
}