{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"settings-panel","type":"registry:block","title":"Settings Panel","description":"Preference-form building blocks: a titled settings group with hairline-divided rows, a hex color field with auto-contrasting text, a text field, a preset-picker button and a ghost header action.","author":"UI Lab","dependencies":["clsx","lucide-react","motion","tailwind-merge"],"registryDependencies":[],"cssVars":{"light":{"wb-hover":"rgb(0 0 0 / 0.05)","wb-hover-subtle":"rgb(0 0 0 / 0.04)","wb-hover-strong":"rgb(0 0 0 / 0.1)","wb-hover-stronger":"rgb(0 0 0 / 0.1)","wb-inset":"rgb(0 0 0 / 0.05)","wb-inset-strong":"rgb(0 0 0 / 0.06)","wb-inset-subtle":"rgb(0 0 0 / 0.03)","wb-inset-faint":"rgb(0 0 0 / 0.02)","wb-code-inline":"rgb(0 0 0 / 0.07)","wb-code-block":"rgb(0 0 0 / 0.04)","wb-border":"rgb(0 0 0 / 0.12)","wb-border-subtle":"rgb(0 0 0 / 0.1)","wb-border-strong":"rgb(0 0 0 / 0.17)","wb-border-emphasis":"rgb(0 0 0 / 0.22)","wb-divider":"rgb(0 0 0 / 0.07)","wb-hairline":"rgb(0 0 0 / 0.12)","wb-hairline-soft":"rgb(0 0 0 / 0.1)","wb-hairline-subtle":"rgb(0 0 0 / 0.08)","wb-control-hairline":"rgb(0 0 0 / 0.08)","wb-control-tick":"rgb(0 0 0 / 0.2)","wb-resize-handle":"rgb(0 0 0 / 0.2)","wb-surface":"rgb(255 255 255)","wb-surface-translucent":"oklch(94.5% 0.002 250 / 0.75)","wb-overlay-scrim":"rgb(0 0 0 / 0.06)","wb-surface-raised":"rgb(255 255 255 / 0.95)","wb-surface-composer":"rgb(255 255 255 / 0.9)","wb-card":"rgb(0 0 0 / 0.04)","wb-inverse":"oklch(20.5% 0 0)","wb-inverse-fg":"rgb(255 255 255)","wb-accent-fg":"rgb(255 255 255)","wb-solid-control-fg":"oklch(20.5% 0 0)","wb-shimmer-dim":"rgb(0 0 0 / 0.4)","wb-shimmer-bright":"rgb(0 0 0 / 0.85)","wb-accent":"#339cff","wb-success":"oklch(59.6% 0.145 163.225)","wb-success-border":"oklch(69.6% 0.17 162.48)","wb-danger":"oklch(63.7% 0.237 25.331)","wb-danger-strong":"#fa423e","wb-danger-surface":"oklch(63.7% 0.237 25.331)","wb-warning":"#e25507","wb-warning-text":"oklch(64.6% 0.222 41.116)","wb-warning-hover":"oklch(70.5% 0.213 47.604)","wb-warning-surface":"#e25507"},"dark":{"wb-hover":"rgb(255 255 255 / 0.1)","wb-hover-subtle":"rgb(255 255 255 / 0.05)","wb-hover-strong":"rgb(255 255 255 / 0.1)","wb-hover-stronger":"rgb(255 255 255 / 0.15)","wb-inset":"rgb(255 255 255 / 0.05)","wb-inset-strong":"rgb(255 255 255 / 0.1)","wb-inset-subtle":"rgb(255 255 255 / 0.05)","wb-inset-faint":"rgb(255 255 255 / 0.03)","wb-code-inline":"rgb(255 255 255 / 0.1)","wb-code-block":"rgb(255 255 255 / 0.05)","wb-border":"rgb(255 255 255 / 0.1)","wb-border-subtle":"rgb(255 255 255 / 0.1)","wb-border-strong":"rgb(255 255 255 / 0.15)","wb-border-emphasis":"rgb(255 255 255 / 0.2)","wb-divider":"rgb(255 255 255 / 0.06)","wb-hairline":"rgb(255 255 255 / 0.157)","wb-hairline-soft":"rgb(255 255 255 / 0.15)","wb-hairline-subtle":"rgb(255 255 255 / 0.1)","wb-control-hairline":"rgb(255 255 255 / 0.08)","wb-control-tick":"rgb(255 255 255 / 0.3)","wb-resize-handle":"rgb(255 255 255 / 0.25)","wb-surface":"oklch(20.5% 0 0)","wb-surface-translucent":"oklch(20.5% 0 0 / 0.6)","wb-overlay-scrim":"rgb(0 0 0 / 0.24)","wb-surface-raised":"oklch(26.9% 0 0 / 0.95)","wb-surface-composer":"oklch(26.9% 0 0 / 0.9)","wb-card":"oklch(26.9% 0 0 / 0.5)","wb-inverse":"rgb(255 255 255)","wb-inverse-fg":"oklch(20.5% 0 0)","wb-accent-fg":"rgb(255 255 255)","wb-solid-control-fg":"oklch(20.5% 0 0)","wb-shimmer-dim":"rgb(255 255 255 / 0.4)","wb-shimmer-bright":"rgb(255 255 255 / 0.95)","wb-accent":"#339cff","wb-success":"#40c977","wb-success-border":"oklch(69.6% 0.17 162.48)","wb-danger":"#fa423e","wb-danger-strong":"#fa423e","wb-danger-surface":"oklch(63.7% 0.237 25.331)","wb-warning":"#ff8549","wb-warning-text":"#ff8549","wb-warning-hover":"#e25507","wb-warning-surface":"#e25507"}},"files":[{"path":"components/motion/settings-panel/index.tsx","type":"registry:component","target":"@components/motion/settings-panel/index.tsx","content":"\"use client\";\n// ui-lab-ten.vercel.app/components/blocks/settings-panel\n\nimport { ChevronDown } from \"lucide-react\";\nimport { motion } from \"motion/react\";\nimport { type ReactNode, useEffect, useState } from \"react\";\nimport { isLightHexColor, normalizeHexColor } from \"@/lib/color\";\nimport { EASE_OUT } from \"@/lib/ease\";\nimport { cn } from \"@/lib/utils\";\n\nexport interface SettingsGroupProps {\n  /** Card keeps the preference shell; quiet becomes a flat hairline group. */\n  variant?: \"card\" | \"quiet\";\n  title?: ReactNode;\n  /** Header-right slot, e.g. a `SettingsGhostButton` plus a `SettingsSelectButton`. */\n  actions?: ReactNode;\n  className?: string;\n  children?: ReactNode;\n}\n\n/**\n * Card shell for a group of preference rows. Renders a header row (title\n * left, `actions` right) only when either is passed; `children` — typically\n * a stack of `SettingsRow`s — sit in a region whose `[&>*+*]` selector draws\n * a hairline divider between adjacent rows without each row needing to know\n * about its neighbors.\n */\nexport function SettingsGroup({\n  variant = \"card\",\n  title,\n  actions,\n  className,\n  children,\n}: SettingsGroupProps) {\n  const quiet = variant === \"quiet\";\n\n  return (\n    <div\n      data-slot=\"settings-group\"\n      data-variant={variant}\n      className={cn(\n        quiet &&\n          \"border-[var(--wb-border-subtle)] border-x-0 border-y-[0.5px] bg-transparent [&_[data-slot=settings-row]]:px-0\",\n        !quiet &&\n          \"rounded-[20px] border border-[var(--wb-border)] bg-[var(--wb-surface-raised)]\",\n        className,\n      )}\n    >\n      {title || actions ? (\n        <div\n          className={cn(\n            \"flex items-center justify-between gap-3 pb-1\",\n            quiet ? \"px-0 pt-3\" : \"px-4 pt-4\",\n          )}\n        >\n          {title ? <div className=\"text-[13px] font-medium\">{title}</div> : <span />}\n          {actions ? <div className=\"flex items-center gap-1\">{actions}</div> : null}\n        </div>\n      ) : null}\n      <div\n        data-slot=\"settings-group-rows\"\n        className=\"[&>*+*]:border-[var(--wb-border-subtle)] [&>*+*]:border-t-[0.5px]\"\n      >\n        {children}\n      </div>\n    </div>\n  );\n}\n\nexport interface SettingsRowProps {\n  label: ReactNode;\n  /** Muted one-liner rendered under `label`. */\n  description?: ReactNode;\n  className?: string;\n  /** Trailing control slot, e.g. a `SettingsColorField` or a `Switch`. */\n  children?: ReactNode;\n}\n\n/** One preference line inside a `SettingsGroup` — label (+ optional description) on the left, a control slot on the right. */\nexport function SettingsRow({ label, description, className, children }: SettingsRowProps) {\n  return (\n    <div\n      data-slot=\"settings-row\"\n      className={cn(\n        \"flex min-h-[52px] items-center justify-between gap-3 px-4 py-2\",\n        className,\n      )}\n    >\n      <div className=\"min-w-0\">\n        <div className=\"text-[13px] font-medium\">{label}</div>\n        {description ? <div className=\"text-xs text-muted-foreground\">{description}</div> : null}\n      </div>\n      {children ? <div className=\"shrink-0\">{children}</div> : null}\n    </div>\n  );\n}\n\nexport interface SettingsColorFieldProps {\n  /** Current color as `#RRGGBB` (or `#RGB`); the pill background and text contrast follow it. */\n  value: string;\n  onChange?: (hex: string) => void;\n  \"aria-label\"?: string;\n  className?: string;\n}\n\n/**\n * Hex-color pill — a decorative ring dot plus an editable mono hex code, the\n * pill's own background painted in `value` with a smooth color transition.\n * Text and ring contrast auto-flip (dark ink on light swatches, white ink on\n * dark ones) via `isLightColor`. Typing edits a local draft so partial input\n * doesn't get clobbered by the controlled `value`; Enter or blur commits —\n * a valid hex calls `onChange`, an invalid one snaps the draft back to the\n * last committed `value`.\n */\nexport function SettingsColorField({\n  value,\n  onChange,\n  \"aria-label\": ariaLabel,\n  className,\n}: SettingsColorFieldProps) {\n  const [draft, setDraft] = useState(value);\n\n  useEffect(() => {\n    setDraft(value);\n  }, [value]);\n\n  const light = isLightHexColor(value);\n  const textColor = light ? \"#101010\" : \"#FFFFFF\";\n\n  const commit = () => {\n    const normalized = normalizeHexColor(draft);\n    if (normalized) {\n      setDraft(normalized);\n      if (normalized !== value.toUpperCase()) onChange?.(normalized);\n    } else {\n      setDraft(value);\n    }\n  };\n\n  return (\n    <motion.div\n      animate={{ backgroundColor: value }}\n      transition={{ duration: 0.3, ease: EASE_OUT }}\n      className={cn(\"flex h-7 w-[136px] items-center gap-2 rounded-lg px-2\", className)}\n    >\n      <span\n        aria-hidden\n        className={cn(\n          \"h-3.5 w-3.5 shrink-0 rounded-full border bg-transparent\",\n          light ? \"border-black/15\" : \"border-white/40\",\n        )}\n      />\n      <input\n        value={draft}\n        onChange={(event) => setDraft(event.target.value)}\n        onBlur={commit}\n        onKeyDown={(event) => {\n          if (event.key === \"Enter\") {\n            event.preventDefault();\n            commit();\n          }\n        }}\n        aria-label={ariaLabel}\n        spellCheck={false}\n        style={{ color: textColor }}\n        className=\"w-full bg-transparent font-mono text-xs uppercase outline-none\"\n      />\n    </motion.div>\n  );\n}\n\nexport interface SettingsTextFieldProps {\n  value: string;\n  onChange?: (next: string) => void;\n  placeholder?: string;\n  \"aria-label\"?: string;\n  className?: string;\n}\n\n/** Right-aligned lightweight text input, e.g. for a custom UI-font stack. */\nexport function SettingsTextField({\n  value,\n  onChange,\n  placeholder,\n  \"aria-label\": ariaLabel,\n  className,\n}: SettingsTextFieldProps) {\n  return (\n    <input\n      data-slot=\"settings-text-field\"\n      value={value}\n      onChange={(event) => onChange?.(event.target.value)}\n      placeholder={placeholder}\n      aria-label={ariaLabel}\n      className={cn(\n        \"h-7 w-[176px] truncate rounded-lg border border-[var(--wb-control-hairline)] bg-[var(--wb-inset-faint)] px-2 text-sm text-muted-foreground outline-none focus:text-foreground\",\n        className,\n      )}\n    />\n  );\n}\n\nexport interface SettingsSelectButtonProps {\n  /** 14px leading icon. */\n  icon?: ReactNode;\n  onClick?: () => void;\n  children?: ReactNode;\n  className?: string;\n  \"aria-label\"?: string;\n}\n\n/**\n * Preset-picker-styled trigger button — icon, label, trailing chevron. It\n * doesn't render a dropdown itself; wire `onClick` to whatever popover/select\n * the caller already has (see the preview for a stateless demo usage).\n */\nexport function SettingsSelectButton({\n  icon,\n  onClick,\n  children,\n  className,\n  \"aria-label\": ariaLabel,\n}: SettingsSelectButtonProps) {\n  return (\n    <button\n      data-slot=\"settings-select\"\n      type=\"button\"\n      onClick={onClick}\n      aria-label={ariaLabel}\n      className={cn(\n        \"flex h-7 items-center gap-1.5 rounded-lg border border-[var(--wb-control-hairline)] bg-[var(--wb-inset-faint)] px-3 text-sm\",\n        className,\n      )}\n    >\n      {icon ? <span className=\"flex h-3.5 w-3.5 shrink-0 items-center justify-center\">{icon}</span> : null}\n      {children}\n      <ChevronDown className=\"h-3.5 w-3.5 shrink-0 text-muted-foreground\" />\n    </button>\n  );\n}\n\nexport interface SettingsGhostButtonProps {\n  onClick?: () => void;\n  children?: ReactNode;\n  className?: string;\n}\n\n/** Borderless header action, e.g. \"Import\" or \"Copy\" beside a `SettingsGroup` title. */\nexport function SettingsGhostButton({ onClick, children, className }: SettingsGhostButtonProps) {\n  return (\n    <button\n      data-slot=\"settings-ghost\"\n      type=\"button\"\n      onClick={onClick}\n      className={cn(\n        \"flex h-7 items-center gap-1 rounded-lg px-2 text-sm text-muted-foreground transition-colors hover:bg-[var(--wb-hover-subtle)] hover:text-foreground\",\n        className,\n      )}\n    >\n      {children}\n    </button>\n  );\n}\n"},{"path":"lib/color.ts","type":"registry:lib","target":"@lib/color.ts","content":"export type RgbColor = { r: number; g: number; b: number };\n\n/** Parses `#RGB` / `#RRGGBB` into 0–255 channels; `null` on anything else. */\nexport function parseHexColor(input: string): RgbColor | null {\n  const hex = input.trim().replace(/^#/, \"\");\n  if (/^[0-9a-fA-F]{3}$/.test(hex)) {\n    const [r, g, b] = hex.split(\"\");\n    return {\n      r: Number.parseInt(r + r, 16),\n      g: Number.parseInt(g + g, 16),\n      b: Number.parseInt(b + b, 16),\n    };\n  }\n  if (/^[0-9a-fA-F]{6}$/.test(hex)) {\n    return {\n      r: Number.parseInt(hex.slice(0, 2), 16),\n      g: Number.parseInt(hex.slice(2, 4), 16),\n      b: Number.parseInt(hex.slice(4, 6), 16),\n    };\n  }\n  return null;\n}\n\n/** Normalizes to an uppercase `#RRGGBB`; `null` when input is not valid hex. */\nexport function normalizeHexColor(input: string): string | null {\n  const rgb = parseHexColor(input);\n  if (!rgb) return null;\n  const toHex = (channel: number) => channel.toString(16).padStart(2, \"0\");\n  return `#${toHex(rgb.r)}${toHex(rgb.g)}${toHex(rgb.b)}`.toUpperCase();\n}\n\n/** YIQ perceived brightness — values at or above 150 read as a light swatch. */\nexport function isLightHexColor(hex: string): boolean {\n  const rgb = parseHexColor(hex);\n  if (!rgb) return false;\n  return (rgb.r * 299 + rgb.g * 587 + rgb.b * 114) / 1000 >= 150;\n}\n\n/** WCAG relative luminance (0..1); invalid hex resolves to 0. */\nexport function relativeLuminance(hex: string): number {\n  const rgb = parseHexColor(hex);\n  if (!rgb) return 0;\n  const lin = (c: number) => {\n    const s = c / 255;\n    return s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4;\n  };\n  return 0.2126 * lin(rgb.r) + 0.7152 * lin(rgb.g) + 0.0722 * lin(rgb.b);\n}\n\n/** WCAG contrast ratio, 1..21; invalid input resolves to 1 (no contrast). */\nexport function contrastRatio(hex1: string, hex2: string): number {\n  const a = relativeLuminance(hex1);\n  const b = relativeLuminance(hex2);\n  const [hi, lo] = a >= b ? [a, b] : [b, a];\n  return (hi + 0.05) / (lo + 0.05);\n}\n\n/** Mixes `top` at `weightPercent` (0..100) over `bottom`; returns `#RRGGBB` — the equivalent opaque color behind a translucent glass surface. */\nexport function mixHex(top: string, bottom: string, weightPercent: number): string {\n  const t = parseHexColor(top);\n  const b = parseHexColor(bottom);\n  if (!t || !b) return normalizeHexColor(bottom) ?? \"#000000\";\n  const w = Math.max(0, Math.min(100, weightPercent)) / 100;\n  const ch = (x: number, y: number) => Math.round(x * w + y * (1 - w));\n  const toHex = (n: number) => n.toString(16).padStart(2, \"0\");\n  return `#${toHex(ch(t.r, b.r))}${toHex(ch(t.g, b.g))}${toHex(ch(t.b, b.b))}`.toUpperCase();\n}\n"},{"path":"lib/ease.ts","type":"registry:lib","target":"@lib/ease.ts","content":"// Shared motion tokens. Easing curves mirror the CSS custom properties in\n// globals.css; springs are the canonical physics used across components.\n// Strong custom variants — defaults like `ease-in`/`ease-out` feel weak.\n\nexport const EASE_OUT = [0.16, 1, 0.3, 1] as const;\nexport const EASE_IN_OUT = [0.77, 0, 0.175, 1] as const;\nexport const EASE_DRAWER = [0.32, 0.72, 0, 1] as const;\n\n/** CSS string form of EASE_OUT for inline style transitions. */\nexport const EASE_OUT_CSS = \"cubic-bezier(0.16, 1, 0.3, 1)\";\n\n/** Press feedback on buttons and other tappable surfaces. */\nexport const SPRING_PRESS = {\n  type: \"spring\",\n  stiffness: 500,\n  damping: 30,\n  mass: 0.6,\n} as const;\n\n/** Content swaps — label/icon slots trading places inside a control. */\nexport const SPRING_SWAP = {\n  type: \"spring\",\n  stiffness: 460,\n  damping: 30,\n  mass: 0.55,\n} as const;\n\n/** Overlay panel entrances — modals and sheets summoned by pointer. */\nexport const SPRING_PANEL = {\n  type: \"spring\",\n  stiffness: 420,\n  damping: 40,\n  mass: 0.5,\n} as const;\n\n/** Shared-layout glides — pills, indicators and panels morphing between positions. */\nexport const SPRING_LAYOUT = {\n  type: \"spring\",\n  stiffness: 360,\n  damping: 32,\n  mass: 0.6,\n} as const;\n\n/** Cursor-follow physics for decorative mouse tracking (magnetic, tilt, dock). */\nexport const SPRING_MOUSE = {\n  stiffness: 200,\n  damping: 15,\n  mass: 0.3,\n} as const;\n"},{"path":"lib/utils.ts","type":"registry:lib","target":"@lib/utils.ts","content":"import { clsx, type ClassValue } from \"clsx\"\nimport { twMerge } from \"tailwind-merge\"\n\nexport function cn(...inputs: ClassValue[]) {\n  return twMerge(clsx(inputs))\n}\n"}]}