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 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
| Key | Step |
|---|---|
| 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 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). Cursor switches
grab → grabbingon mousedown via CSS:activefor instant feedback (matches thecursor-grab active:cursor-grabbingpattern 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.