Appearance
Color Picker
Located in src/components/HctColorPicker.tsx. A compact inline color picker with three sliders.
Sliders
| Slider | Range | Visual |
|---|---|---|
| Hue (H) | 0--360 | Rainbow gradient |
| Chroma (C) | 0--150 | Gradient from neutral gray to full saturation at the current hue/tone |
| Tone (T) | 0--100 | Gradient 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 inline linear-gradient track strings (built with the local hctToHexSafe helper) 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
entityCost = sum( max(1, colorIds) × max(1, contrastRatios|levelConfig.count) × max(1, parentEntityIds) )
base = entityCost × enabledModes × enabledInteractions × enabledConditions
weight = base + totalGradientPoints × 2 // totalGradientPoints = Σ color.hex.length
ms = clamp(16 + round(weight × 0.08), 16, 200)That means a tiny theme (≤ 4 entities, 1 mode, no conditions/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
Sliders and number inputs use slightly different arrow steps:
Slider (focused thumb) — arrows also respond to Left/Right:
| Key | Step |
|---|---|
| Arrow | +/- 0.1 |
| Shift + Arrow | +/- 10 |
| Alt/Option + Arrow | +/- 0.01 |
Number input (H / C / T):
| Key | Step |
|---|---|
| Arrow Up / Down | +/- 1 |
| Shift + Arrow | +/- 10 |
| Alt/Option + Arrow | +/- 0.1 |
Dragging a slider thumb snaps differently again: Shift = snap to integer, Alt/Option = 0.1 precision, no modifier = round to integer.
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 all three channels. Any channel (Hue, Chroma, or Tone) that drifts by more than 0.5 after the round-trip is flagged — hue uses shortest-arc distance on the 360° wheel so values near 0°/360° don't false-positive.
When clamped, a snap affordance appears overlaid on the right edge of the hex input:
- Default editor layout (
gridAligned): a compactSnappill. Its tooltip reads "Snap to gamut" with the exact drifting channels listed below (e.g.H:204.8 C:45.2). - Legacy standalone layout: a button showing the drifting channels inline (
H:X C:Y) with a warning icon; tooltip "Clamped — click to snap to gamut".
Either way, clicking snaps only the drifted channels to their gamut-safe (round-tripped) values, rounded to 0.1.
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 lacksclipboard-writein its permissions-policy, sonavigator.clipboard.writeTextwould 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 (IconEditPencilfrom coolicons — coolicons has no eyedropper; the pencil semantically conveys "edit color"). Selecting a color updates the picker viahandleHexChange, but is routed through the same adaptivedebouncedOnChange(16–200 msdepending on theme complexity) the HCT sliders use, so rapidonChangeevents 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). The marker toggles its cursor
grab → grabbingvia React drag state (draggingPid === pid) rather than CSS:active. (The Slider thumb and SortableItem drag handles are the ones that use the CSScursor-grab active:cursor-grabbingpattern.) - 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 markers when contrast with the background is insufficient — mode-dependent: light markers (tone > 85) in light mode, dark markers (tone < 15) in dark mode
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.