{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"color-palette","type":"registry:component","title":"Color Palette","description":"A wheel of preset colours to pick from: twelve hues around the ring, their tints inward, white at the middle; the chosen swatch lifts and carries a check.","categories":["forms"],"dependencies":["@radix-ui/react-direction@1.1.4","clsx@^2.1.1","lucide-react@^1.48.0","tailwind-merge@^3.7.0"],"registryDependencies":["https://beamline.io/registry/look-prism.json"],"files":[{"path":"beamline/components/color-palette/color-palette.module.css","type":"registry:file","target":"src/ui/beamline/components/color-palette/color-palette.module.css","content":"@layer properties, theme, base, components, utilities;\n/* organism-allow-file: literal-colour -- the hue wheel and swatches are the palette's own colour data, and the check mark keeps a fixed dark ink on the pale tints */\n/* the selection ring's place in polar terms, so a change of choice orbits the wheel instead of cutting across it */\n@property --pui-palette-a {\n  syntax: \"<angle>\";\n  inherits: false;\n  initial-value: 0deg;\n}\n@property --pui-palette-r {\n  syntax: \"<percentage>\";\n  inherits: false;\n  initial-value: 0%;\n}\n@layer components {\n  /* a square that keeps its size (size) until its room is narrower, then shrinks with it: every part is placed in\n     percentages and container units, so the wheel scales whole */\n  .root {\n    position: relative;\n    flex: none;\n    max-width: 100%;\n    aspect-ratio: 1;\n    container-type: inline-size;\n    border-radius: 50%;\n    isolation: isolate;\n  }\n  /* The rim is the hue circle itself, blurred behind as a halo and drawn crisp as a thin ring. */\n  .halo,\n  .rim {\n    position: absolute;\n    inset: 1%;\n    border-radius: 50%;\n    pointer-events: none;\n    background: conic-gradient(\n      from 0deg,\n      oklch(0.7 0.17 25),\n      oklch(0.72 0.17 60),\n      oklch(0.8 0.16 90),\n      oklch(0.75 0.18 140),\n      oklch(0.75 0.14 180),\n      oklch(0.72 0.14 225),\n      oklch(0.62 0.19 270),\n      oklch(0.65 0.22 310),\n      oklch(0.68 0.2 350),\n      oklch(0.7 0.17 25)\n    );\n  }\n  .rim {\n    mask: radial-gradient(closest-side, transparent calc(100% - 2px), #000 calc(100% - 1.5px));\n    opacity: 0.9;\n  }\n  .halo {\n    filter: blur(6cqw);\n    opacity: 0.35;\n    mask: radial-gradient(closest-side, transparent 80%, #000 96%);\n  }\n  .root::before {\n    content: \"\";\n    position: absolute;\n    inset: 3%;\n    border-radius: 50%;\n    background: var(--pui-surface);\n    box-shadow:\n      inset 0 0 0 1px var(--pui-line-soft),\n      var(--pui-shadow-raised);\n  }\n  .swatch {\n    position: absolute;\n    translate: -50% -50%;\n    border-radius: 50%;\n    display: grid;\n    place-items: center;\n    cursor: pointer;\n    box-shadow: var(--pui-shadow-sm);\n    transition:\n      scale var(--pui-duration-fast) var(--pui-ease-standard),\n      box-shadow var(--pui-duration-fast) var(--pui-ease-standard);\n    outline-offset: 2px;\n  }\n  .swatch:hover:not(:disabled) {\n    scale: 1.08;\n    z-index: 6 !important;\n  }\n  .swatch[data-state=\"checked\"] {\n    scale: 1.1;\n    box-shadow: var(--pui-shadow-md);\n  }\n  /* The selection ring: one ring that travels to the chosen swatch along the wheel (its angle and radius are\n     registered properties, so the path is an arc; between the rings the radius changes too) over\n     --pui-duration-standard, critically damped. The swatch lifts and its check scales in at once; only the ring\n     settles. Reduced motion: the duration token is 1 ms. */\n  .ring {\n    position: absolute;\n    z-index: 7;\n    left: calc(50% + cos(var(--pui-palette-a)) * var(--pui-palette-r));\n    top: calc(50% + sin(var(--pui-palette-a)) * var(--pui-palette-r));\n    width: var(--_d);\n    height: var(--_d);\n    translate: -50% -50%;\n    border-radius: 50%;\n    box-shadow:\n      0 0 0 2px var(--pui-surface),\n      0 0 0 3.5px var(--pui-ink);\n    pointer-events: none;\n    transition:\n      --pui-palette-a var(--pui-duration-standard) var(--pui-ease-standard),\n      --pui-palette-r var(--pui-duration-standard) var(--pui-ease-standard),\n      width var(--pui-duration-standard) var(--pui-ease-standard),\n      height var(--pui-duration-standard) var(--pui-ease-standard);\n  }\n  /* the swatch's name arrives with attention, above it (hover at 0 ms, keyboard focus at once; it leaves over\n     --pui-motion-out): one label, drawn by the swatch itself, instead of the browser's slow title tooltip */\n  .swatch::after {\n    content: attr(data-name);\n    position: absolute;\n    bottom: calc(100% + var(--pui-space-1-5));\n    left: 50%;\n    z-index: 1;\n    padding: var(--pui-space-0-5) var(--pui-space-1-5);\n    border-radius: var(--pui-radius-chip);\n    background: var(--pui-tooltip-bg);\n    color: var(--pui-tooltip-fg);\n    font-size: var(--pui-text-2xs);\n    font-weight: var(--pui-weight-medium);\n    white-space: nowrap;\n    pointer-events: none;\n    opacity: 0;\n    translate: -50% calc(2px * var(--pui-motion-travel, 1));\n    scale: calc(1 / 1.08);\n    transition:\n      opacity var(--pui-motion-out) var(--pui-ease-default),\n      translate var(--pui-motion-out) var(--pui-ease-default);\n  }\n  .swatch:hover::after,\n  .swatch:focus-visible::after {\n    opacity: 1;\n    translate: -50% 0;\n    transition: none;\n  }\n  .swatch:disabled {\n    cursor: not-allowed;\n    opacity: var(--pui-opacity-disabled);\n  }\n  /* §14.2 data exception: the check sits on the swatch's own colour, so its ink is chosen against that data colour\n     (dark on the pale inner tints, white on the saturated outer ring), not from the theme */\n  .check {\n    width: 42%;\n    height: 42%;\n    color: oklch(0.18 0.01 260);\n    filter: drop-shadow(0 1px 0 oklch(1 0 0 / 0.4));\n  }\n  .swatch[data-ring=\"0\"] .check {\n    color: white;\n    filter: drop-shadow(0 1px 1px oklch(0 0 0 / 0.35));\n  }\n}\n"},{"path":"beamline/components/color-palette/color-palette.tsx","type":"registry:file","target":"src/ui/beamline/components/color-palette/color-palette.tsx","content":"\"use client\";\n\nimport { createTranslator } from \"../../runtime/messages\";\nimport { type Translator } from \"../../runtime/messages\";\nimport { useMessages } from \"../../runtime/localization\";\nimport { english as englishMessages } from \"./messages\";\nimport { useDeprecatedProp } from \"../../hooks/use-deprecated-prop\";\n\nimport { useComponentDefaults } from \"../../runtime/appearance\";\nimport type { ColorPaletteDefaults } from \"./defaults\";\nexport type { ColorPaletteDefaults } from \"./defaults\";\n\nimport { useRef, useState, type CSSProperties, type ComponentProps, type KeyboardEvent } from \"react\";\nimport { Check } from \"lucide-react\";\nimport { cn } from \"../../lib/utils\";\nimport { useControllable } from \"../../hooks/use-controllable\";\nimport styles from \"./color-palette.module.css\";\n// organism-allow-file: literal-colour -- the swatch values are the palette's colour data, not theme paint\n\nexport interface PaletteSwatch {\n  /** Stored value: a CSS colour or a token name your app maps to one. */\n  value: string;\n  /** Spoken and shown on hover: \"Coral\", \"Mint\". */\n  name: string;\n  /** CSS colour painted by this swatch. Required. */\n  color: string;\n}\n\nexport type ColorPaletteClassNames = Partial<Record<\"root\" | \"halo\" | \"rim\" | \"swatch\" | \"check\" | \"ring\", string>>;\n\nexport interface ColorPaletteProps extends Omit<ComponentProps<\"div\">, \"defaultValue\" | \"onChange\"> {\n  /** Classes for the named visual parts; no part overrides by default. */\n  classNames?: ColorPaletteClassNames;\n  /** Controlled selected swatch value; omit to use defaultValue. */\n  value?: string;\n  /** Initial selected swatch value when uncontrolled. Defaults to no selected swatch. */\n  defaultValue?: string;\n  /** Reports selected swatch value after an edit; no callback by default. */\n  onValueChange?: (value: string) => void;\n  /** 19 by default: twelve hues around the ring, six tints inside, white at the centre. Any count works; the first\n   *  twelve form the outer ring, the next six the inner ring, and one more sits at the centre. */\n  swatches?: PaletteSwatch[];\n  /** Maximum wheel diameter in pixels; defaults to 224 and shrinks to fit its room. */\n  diameter?: number;\n  /** Legacy diameter setting; omitted by default. @deprecated Use diameter instead. */\n  size?: number;\n  /** Accessible name of the group. */\n  label?: string;\n  /** Prevents interaction with the control. Defaults to false. */\n  disabled?: boolean;\n}\nconst HUES = [\n  [\"red\", 25],\n  [\"coral\", 40],\n  [\"orange\", 60],\n  [\"amber\", 80],\n  [\"lime\", 125],\n  [\"green\", 150],\n  [\"mint\", 170],\n  [\"teal\", 195],\n  [\"sky\", 230],\n  [\"blue\", 262],\n  [\"violet\", 295],\n  [\"pink\", 345],\n] as const;\nexport const DEFAULT_SWATCHES = /* @__PURE__ */ localizedDEFAULT_SWATCHES(\n  /* @__PURE__ */ createTranslator(\"color-palette\", englishMessages),\n);\nfunction localizedDEFAULT_SWATCHES(i18n: Translator<typeof englishMessages>): PaletteSwatch[] {\n  return [\n    ...HUES.map(([key, h]) => ({ value: key, name: i18n(key), color: `oklch(0.7 0.17 ${h})` })),\n    ...HUES.filter((_, i) => i % 2 === 0).map(([key, h]) => ({\n      value: `${key}-tint`,\n      name: i18n(\"pale\", { value: i18n(key).toLocaleLowerCase(i18n.locale) }),\n      color: `oklch(0.9 0.06 ${h})`,\n    })),\n    { value: \"white\", name: i18n(\"white\"), color: \"oklch(0.98 0 0)\" },\n  ];\n}\n\n/** Where swatch `i` of `n` sits: its ring (0 outer, 1 inner, 2 centre), angle in degrees, radius and diameter as\n *  fractions of the wheel. */\nfunction place(i: number, n: number) {\n  const [ring, index, count] = i < 12 ? [0, i, Math.min(12, n)] : i < 18 ? [1, i - 12, Math.min(6, n - 12)] : [2, 0, 1];\n  return { ring, angle: (index / count) * 360 - 90, radius: [0.305, 0.155, 0][ring], d: [0.2, 0.17, 0.15][ring] };\n}\n/** The turn to `to` nearest to `from` (so the ring orbits the short way round, never through a full lap). */\nconst nearestTurn = (from: number, to: number) => to + 360 * Math.round((from - to) / 360);\n\n/**\n * A wheel of preset colours to pick from: hues around the ring, their tints inward, white at the middle. Each swatch is a\n * radio; the chosen one lifts and carries a check, and the selection ring orbits the wheel to it the short way round.\n * For labels, projects and calendars. Arbitrary colours: color-picker.\n */\nexport function ColorPalette({\n  value: valueProp,\n  defaultValue,\n  onValueChange,\n  swatches: swatchesInput,\n  diameter: diameterProp,\n  size: sizeProp,\n  label: labelProp,\n  disabled,\n  className,\n  style,\n  classNames,\n  ...props\n}: ColorPaletteProps) {\n  const i18n = useMessages(\"color-palette\", englishMessages);\n  const swatches = swatchesInput === undefined ? localizedDEFAULT_SWATCHES(i18n) : swatchesInput;\n\n  const label = labelProp === undefined ? i18n(\"label\") : labelProp;\n\n  const scopeDefaults = useComponentDefaults<ColorPaletteDefaults>(\"color-palette\", [\"size\"]);\n  useDeprecatedProp(\n    \"color-palette\",\n    \"size\",\n    \"diameter\",\n    sizeProp !== undefined || (diameterProp === undefined && scopeDefaults.size !== undefined),\n  );\n  const size = diameterProp ?? sizeProp ?? scopeDefaults.size ?? 224;\n\n  const [value, set] = useControllable(valueProp, defaultValue, onValueChange);\n  const refs = useRef<(HTMLButtonElement | null)[]>([]);\n  const selectedIndex = swatches.findIndex((s) => s.value === value);\n  const focusIndex = selectedIndex >= 0 ? selectedIndex : 0;\n  // the selection ring's place along the wheel: its angle is kept continuous (350° → 10° turns 20°, not back 340°)\n  const chosen = selectedIndex >= 0 ? place(selectedIndex, swatches.length) : null;\n  const [orbit, setOrbit] = useState({ key: value, angle: chosen?.angle ?? 0 });\n  if (orbit.key !== value)\n    setOrbit({ key: value, angle: chosen ? nearestTurn(orbit.angle, chosen.angle) : orbit.angle });\n\n  const onKey = (e: KeyboardEvent, i: number) => {\n    const n = swatches.length;\n    const to =\n      e.key === \"ArrowRight\" || e.key === \"ArrowDown\"\n        ? (i + 1) % n\n        : e.key === \"ArrowLeft\" || e.key === \"ArrowUp\"\n          ? (i - 1 + n) % n\n          : e.key === \"Home\"\n            ? 0\n            : e.key === \"End\"\n              ? n - 1\n              : -1;\n    if (to < 0) return;\n    e.preventDefault();\n    refs.current[to]?.focus();\n    set(swatches[to].value);\n  };\n\n  return (\n    <div\n      role=\"radiogroup\"\n      aria-label={label}\n      aria-disabled={disabled || undefined}\n      data-slot=\"color-palette\"\n      className={cn(styles.root, classNames?.root, className)}\n      style={{ width: size, ...style }}\n      {...props}\n    >\n      <span data-slot=\"color-palette-halo\" className={cn(styles.halo, classNames?.halo)} aria-hidden />\n      <span data-slot=\"color-palette-rim\" className={cn(styles.rim, classNames?.rim)} aria-hidden />\n      {chosen && (\n        <span\n          aria-hidden\n          data-slot=\"color-palette-ring\"\n          className={cn(styles.ring, classNames?.ring)}\n          style={\n            {\n              \"--pui-palette-a\": `${orbit.angle}deg`,\n              \"--pui-palette-r\": `${chosen.radius * 100}%`,\n              \"--_d\": `${chosen.d * 110}%`,\n            } as CSSProperties\n          }\n        />\n      )}\n      {swatches.map((s, i) => {\n        const { ring, angle, radius, d } = place(i, swatches.length);\n        const checked = s.value === value;\n        return (\n          <button\n            key={s.value}\n            ref={(el) => {\n              refs.current[i] = el;\n            }}\n            type=\"button\"\n            role=\"radio\"\n            aria-checked={checked}\n            aria-label={s.name}\n            data-name={s.name}\n            tabIndex={i === focusIndex ? 0 : -1}\n            disabled={disabled}\n            data-slot=\"color-palette-swatch\"\n            data-ring={ring}\n            data-state={checked ? \"checked\" : \"unchecked\"}\n            className={cn(styles.swatch, classNames?.swatch)}\n            onClick={() => set(s.value)}\n            onKeyDown={(e) => onKey(e, i)}\n            style={{\n              background: s.color,\n              width: `${d * 100}%`,\n              height: `${d * 100}%`,\n              left: `${50 + Math.cos((angle * Math.PI) / 180) * radius * 100}%`,\n              top: `${50 + Math.sin((angle * Math.PI) / 180) * radius * 100}%`,\n              zIndex: checked ? 5 : ring + 1,\n            }}\n          >\n            {checked && (\n              <Check\n                data-slot=\"color-palette-check\"\n                className={cn(styles.check, \"animate-enter-scale\", classNames?.check)}\n                strokeWidth={3}\n                aria-hidden\n              />\n            )}\n          </button>\n        );\n      })}\n    </div>\n  );\n}\n\nexport default ColorPalette;\n"},{"path":"beamline/components/color-palette/defaults.ts","type":"registry:file","target":"src/ui/beamline/components/color-palette/defaults.ts","content":"/** Pure allowed presentation defaults for ColorPalette; no component or runtime imports. */\nexport interface ColorPaletteDefaults {\n  /** Shared diameter setting; omit to inherit the component default. Scoped defaults retain the legacy key size. */\n  size?: number;\n}\n"},{"path":"beamline/components/color-palette/messages.ts","type":"registry:file","target":"src/ui/beamline/components/color-palette/messages.ts","content":"import type { MessageDefinitions } from \"../../runtime/messages\";\n/** Built-in English copy; stable keys are the public translation API. */\nexport const english = {\n  label: \"Colour\",\n  red: \"Red\",\n  coral: \"Coral\",\n  orange: \"Orange\",\n  amber: \"Amber\",\n  lime: \"Lime\",\n  green: \"Green\",\n  mint: \"Mint\",\n  teal: \"Teal\",\n  sky: \"Sky\",\n  blue: \"Blue\",\n  violet: \"Violet\",\n  pink: \"Pink\",\n  pale: \"Pale {value}\",\n  white: \"White\",\n} satisfies MessageDefinitions;\n"},{"path":"beamline/hooks/use-controllable.ts","type":"registry:file","target":"src/ui/beamline/hooks/use-controllable.ts","content":"\"use client\";\n\nimport { useCallback, useRef, useState } from \"react\";\n\n/**\n * One value in both forms (rules/CONVENTIONS.md): the prop while it is passed, otherwise the element's own copy that\n * starts at `initial` (a value, or a function that makes it once). The setter takes a value or an updater; an updater\n * reads the latest value even twice in one event, and every change reaches `onChange`, controlled or not.\n * `Next` is what a change can carry when it is narrower than the state: a range that starts empty (null) but is only\n * ever changed to a range.\n */\nexport function useControllable<T, Next extends T = T>(\n  value: T | undefined,\n  initial: T | (() => T),\n  onChange?: (next: Next) => void,\n) {\n  const [own, setOwn] = useState<T>(initial);\n  const current = value !== undefined ? value : own;\n  const latest = useRef(current);\n  latest.current = current;\n  const set = useCallback(\n    (next: Next | ((was: T) => Next)) => {\n      const resolved = typeof next === \"function\" ? (next as (was: T) => Next)(latest.current) : next;\n      latest.current = resolved;\n      if (value === undefined) setOwn(resolved);\n      onChange?.(resolved);\n    },\n    [value, onChange],\n  );\n  return [current, set] as const;\n}\n"},{"path":"beamline/hooks/use-deprecated-prop.ts","type":"registry:file","target":"src/ui/beamline/hooks/use-deprecated-prop.ts","content":"\"use client\";\n\nimport { useEffect } from \"react\";\n\n// Bundlers replace NODE_ENV for browser builds; this declaration emits no Node dependency.\ndeclare const process: { env: { NODE_ENV?: string } };\n\nconst warned = new Set<string>();\n\n/** Report a legacy prop once after mount in development; production and server renders stay silent. */\nexport function useDeprecatedProp(component: string, prop: string, replacement: string, supplied: boolean) {\n  useEffect(() => {\n    if (process.env.NODE_ENV !== \"development\" || !supplied) return;\n    const key = `${component}.${prop}`;\n    if (warned.has(key)) return;\n    warned.add(key);\n    console.warn(`[Beamline] ${key} is deprecated. Use ${replacement} instead; the legacy prop still works.`);\n  }, [component, prop, replacement, supplied]);\n}\n"},{"path":"beamline/lib/utils.ts","type":"registry:file","target":"src/ui/beamline/lib/utils.ts","content":"import { clsx, type ClassValue } from \"clsx\";\nimport { extendTailwindMerge } from \"tailwind-merge\";\n\n/* The type roles (utilities.css) and the 2xs step are sizes, not text colours: without this, tailwind-merge reads\n   `text-overline` as a colour and drops it next to `text-ink-3`. A role and a size step still replace each other.\n   Likewise `font-title` is the look's title weight, not a family: read as a family it dropped `font-display` beside it,\n   and every merged page title lost the look's display face. */\nconst twMerge = extendTailwindMerge({\n  extend: {\n    classGroups: {\n      \"font-weight\": [{ font: [\"title\"] }],\n      \"font-family\": [{ font: [\"display\", \"numeric\", \"figure\"] }],\n      \"font-size\": [\n        {\n          text: [\n            \"2xs\",\n            \"display\",\n            \"title\",\n            \"heading\",\n            \"subheading\",\n            \"body\",\n            \"body-sm\",\n            \"reading\",\n            \"label\",\n            \"caption\",\n            \"overline\",\n            \"numeric\",\n            \"code\",\n          ],\n        },\n      ],\n    },\n  },\n});\n\n/** Merge class names, letting later Tailwind utilities win over earlier ones. */\nexport function cn(...inputs: ClassValue[]) {\n  return twMerge(clsx(inputs));\n}\n\n/** A small seeded random generator (LCG): the same seed gives the same sequence, for stable sample data and jitter. */\nexport function rng(seed = 1) {\n  let s = seed >>> 0 || 1;\n  return () => (s = (s * 1664525 + 1013904223) >>> 0) / 2 ** 32;\n}\n\nexport const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value));\n"}],"docs":"Color Palette is in src/ui/beamline/components/color-palette/color-palette.tsx. The source is in src/ui/beamline (ui/beamline in a project without src/). After Tailwind, import its styles/beamline.css once, then styles/look-prism.css in your app stylesheet (use relative paths). Keep the shared base before all chosen looks. Wrap your app in <UIRoot preset={prism}> with UIRoot from its runtime/appearance and prism from its looks/prism.  shadcn replaces files when asked; use the Beamline managed-source CLI from the start for three-way upgrades that preserve edits.","meta":{"free":true,"version":"0.2.0-rc.42","library":"https://beamline.io/components/color-palette"}}