Skip to content

Color Picker

Located in src/components/HctColorPicker.tsx. A compact inline color picker with three sliders.

Sliders

SliderRangeVisual
Hue (H)0--360Rainbow gradient
Chroma (C)0--150Gradient from neutral gray to full saturation at the current hue/tone
Tone (T)0--100Gradient from black through the current hue/chroma to white

All sliders use step 0.1. Drag modifiers: Shift = snap to integer, Alt/Option = 0.1 precision. Slider thumbs show grab/grabbing cursors.

Slider geometry. Track is h-6 (24 px) with rounded-md corners. Thumb is a 24×24 rounded-md square that echoes the rounded track and matches the adjacent numeric Input's height. The small focus/ring indicator inside the thumb stays circular.

Live track gradients are memoised per-axis inside HctColorPicker: the hue track only recomputes when chroma or tone changes, the chroma track only when hue or tone changes, and the tone track only when hue or chroma changes. Keeps drag rendering cheap since two of the three hctGradientCss(…) strings stay referentially stable during any given axis drag.

Adaptive debounce. onChange commits to the zustand store through a debouncedOnChange whose window scales with theme complexity via pickerDebounceForTheme(theme):

ts
weight = sum(entity.colorIds × max(1, contrastRatios|levelConfig.count) × max(1, parentEntityIds)) ×
         enabledModes × enabledContexts × enabledInteractions
       + totalGradientPoints × 2
ms     = clamp(16 + weight × 0.08, 16, 200)

That means a tiny theme (≤ 4 entities, 1 mode, no contexts/interactions) commits at ~17 ms (perceptually live preview), a busy production theme (10+ colors × 2 modes × 5 interactions with 4 ratios per entity) backs off to ~150 ms, and a worst-case theme caps at 200 ms. The heavier downstream regenerateTokens and JSON.stringify(isDirty) are further debounced inside the store (300 ms / 150 ms respectively) so they only fire after the drag stops.

Slider & Number Input Keyboard Shortcuts

KeyStep
Arrow Up / Down+/- 1
Shift + Arrow+/- 10
Alt/Option + Arrow+/- 0.1

All number inputs use free-text editing — type freely, value is applied on blur or Enter. Commas are auto-replaced with dots.

Gamut Clamping

When the requested HCT values exceed the sRGB gamut, the picker detects the discrepancy by round-tripping through hctToHex then hexToHct and comparing. If clamped, a button with "H:X C:Y" appears (tooltip: "Clamped — click to snap to gamut"). Clicking snaps all clamped channels to their gamut-safe values.

Hex Input

A #RRGGBB text input with round-trip validation. Shorthand expansion on blur (e.g. fff#FFFFFF).

Swatch Copy & Native Picker

The preview pill at the top of the picker is split into two interactive halves on top of the same color background. Hovering anywhere on the swatch reveals both icons at once; only the half under the cursor gets the bg highlight (hover:bg-black/10 dark:hover:bg-white/10) so the click target is unambiguous. Tooltips use shadcn Tooltip (not native title).

  • Left half -- click to copy hex. Uses textarea + execCommand("copy") directly: Figma's plugin iframe lacks clipboard-write in its permissions-policy, so navigator.clipboard.writeText would always reject AND log a console violation. After click, "Done" feedback shows for 1.5s. Adaptive icon contrast (tone > 50 ? rgba(0,0,0,0.85) : rgba(255,255,255,0.85)) — same logic used by gradient marker dots, just at higher opacity for legibility.
  • Right half -- click to open the OS-native color picker (<input type="color">). Hover reveals a pencil-edit icon (IconEditPencil from coolicons — coolicons has no eyedropper; the pencil semantically conveys "edit color"). Selecting a color updates the picker via handleHexChange, but is routed through the same adaptive debouncedOnChange (16–200 ms depending on theme complexity) the HCT sliders use, so rapid onChange events from the OS picker during drag don't trigger full-theme regeneration on every frame.

Gradient Markers

When a color has multiple gradient points, SVG pin markers appear above the gradient bar:

  • Drag markers to reposition (0.1% precision, Shift = 10% snap). Cursor switches grab → grabbing on mousedown via CSS :active for instant feedback (matches the cursor-grab active:cursor-grabbing pattern on SortableItem drag handles).
  • Click the gradient bar to add a new point (color interpolated from surrounding points via CAM16-UCS)
  • Arrow keys on focused marker: ±1%, Shift ±10%
  • Markers sort automatically as positions change, with stable identity tracking across re-renders
  • Editing a color point highlights the corresponding marker with a stroke indicator
  • Dashed outline on light markers when contrast with background is insufficient

Active Point Highlight

While a gradient marker is being dragged or arrow-key navigated, and while focus is inside any input/slider of a particular point's editor block, the entire wrapper block of that point gets a border-foreground outline instead of the default border-border. This makes it visually obvious which gradient point you're currently manipulating — useful when a color has many points and the markers visually overlap. The signal is plumbed through EasingSelectWithPreview's onActivePidChange callback (for marker drag) plus editingPointPid state (for focus-inside).

Position Input

Each gradient point has a position % input with free-text editing, arrow key stepping (±1%, Shift ±10%), and automatic sorting with focus transfer on Enter.

Gradient Interpolation

The gradient bar uses CAM16-UCS perceptual blending with position-based segments. Hard edges are rendered correctly when two points share the same position (no color bleeding). The CSS gradient is generated with 32+ sample stops plus extra stops at each control point for sharp transitions.


All rights reserved.