Skip to content

feat(ColorPicker): add a Glaze-backed color input - #1287

Merged
tenphi merged 7 commits into
mainfrom
feat/color-picker
Aug 7, 2026
Merged

feat(ColorPicker): add a Glaze-backed color input#1287
tenphi merged 7 commits into
mainfrom
feat/color-picker

Conversation

@tenphi

@tenphi tenphi commented Aug 7, 2026

Copy link
Copy Markdown
Member

Adds ColorPicker, a form-attachable color input.

The field shows the current color as a swatch, accepts hex, rgb(), hsl(), okhsl(), okhst() and oklch() text, and opens a popover that tunes the color on three sets of axes. Changing anything in the popover writes straight back into the input.

The color model

Every conversion runs through Glaze's exported color math (srgbToOkhsl, okhstToOkhsl, oklabToOkhsl, okhslToOklch, the format* writers, parseHexAlpha, relativeLuminanceFromLinearRgb). The canonical value is OKHSL: every channel bounded, and every value inside those bounds is inside the sRGB gamut. That makes an unrepresentable state impossible, and lets the same value be re-serialized into any supported notation without drift — covered by a round-trip test across all six formats.

Two places needed care:

  • OKLCh addresses colors outside sRGB. oklabToOkhsl does not invert correctly above the gamut boundary (it maps oklch(0.8 0.4 200) to s: 0.348, which converts back to C ≈ 0.05). fromOklch therefore clips chroma to the gamut at the requested lightness, and takes hue from the input rather than from the conversion, which loses it at zero chroma.
  • Achromatic colors have no hue, and the conversions emit whatever angle the source happened to carry (#808080okhsl(89.88 …)). Formatting zeroes it. CSS HSL needs an extra step, because it derives hue from the sRGB channels rather than carrying it through.

Format modes

formatMode decides how tightly the input text follows format (default hex):

Mode Text Value
forced (default) Rewritten in format on blur / Enter Always written in format
derive Left exactly as typed Normalized in the notation the text is written in
free Left exactly as typed The text itself, verbatim

In all three, an entry that is not a color is rejected: the previous valid color stays the value while it is being typed, and the text snaps back to it on blur. Emptying the field is not an error — it commits null.

The popover

Concise by design: a preview whose caption flips between black and white by measured WCAG contrast, a tabs switcher, and one gradient slider per channel of the active space.

  • HST — Hue, Saturation, Tone
  • LCH — Lightness, Chroma, Hue
  • RGB — Red, Green, Blue

H is the same angle in HST and LCH, so the hue strip is shared. The chroma slider's ceiling is the gamut boundary at the current lightness and hue, so it has no dead zone and never snaps back mid-drag. Padding is 1x.

Each track's gradient samples the real conversion (7 stops for the perceptual channels, 25 for the hue strip, 2 for sRGB channels, which are linear in the space CSS interpolates in). Gradients and colors reach the DOM as inline custom properties rather than fresh styles objects — a color picker moves continuously, and a new style object per frame would emit a new CSS rule per frame.

Notes

  • Named CSS colors (red) are not accepted, matching glaze.color(). Alpha parses but is dropped.
  • Adds PipetteIcon, and #color-picker / #color-picker-contrast to the component-local tokens in tasty.config.ts.
  • The All size-limit budget goes 462 → 465 kB (measured 463.94 kB). Tree-shaking is unchanged at 121.8 kB.
  • 72 tests across the color model and the component; the full suite passes.

🤖 Generated with Claude Code


Note

Low Risk
Additive field API with broad unit tests; main impact is ~3.8 kB on the full bundle for consumers who import ColorPicker, with no changes to auth or existing field contracts.

Overview
Adds ColorPicker, a form field with a swatch, typed color text, and a popover to tune the value on OKHST, OKLCH, or RGB sliders. Parsing and serialization go through Glaze so values stay in-gamut sRGB; supported notations include hex, rgb(), hsl(), okhsl(), okhst(), and oklch() (named CSS colors and alpha are rejected or dropped).

format and formatMode (forced / derive / free) control how display text relates to onChange: rewrite to a fixed format, keep the user’s notation while normalizing the color, or pass text through verbatim when it parses. Invalid partial input keeps the last good color until blur/Enter settles or reverts.

Also exports PipetteIcon, registers #color-picker / #color-picker-contrast in tasty.config.ts, re-exports from fields, and documents the API in Storybook/MDX. The all-bundle size budget rises to 469 kB (~3.8 kB for the picker). A changeset marks @cube-dev/ui-kit minor. The ui-kit-verification Claude skill doc is removed from the repo.

Reviewed by Cursor Bugbot for commit 7f35b9d. Bugbot is set up for automated code reviews on this repo. Configure here.

The field shows the current color as a swatch, reads and writes hex,
`rgb()`, `hsl()`, `okhsl()`, `okhst()` and `oklch()` text, and opens a
popover that tunes the color on HST, LCH or RGB axes.

The canonical value is OKHSL — every channel bounded, every value inside
the sRGB gamut — so an unrepresentable state is impossible and the value
re-serializes into any notation without drift. `formatMode` decides how
tightly the text follows `format`: `forced` rewrites it, `derive` keeps
the author's notation but normalizes the value, `free` passes the text
through after verifying it parses.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@vercel

vercel Bot commented Aug 7, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
cube-ui-kit Ready Ready Preview Aug 7, 2026 3:25pm

Request Review

@changeset-bot

changeset-bot Bot commented Aug 7, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 7f35b9d

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
@cube-dev/ui-kit Minor

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@github-actions

github-actions Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

📦 NPM canary release

Deployed canary version 0.0.0-canary-ce91e8d.

Comment thread src/components/fields/ColorPicker/color.ts Fixed
@github-actions

github-actions Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

🧪 Storybook is successfully deployed!

@github-actions

github-actions Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

🏋️ Size limit report

Name Size Passed?
All 456.91 KB (+0.78% 🔺) Yes 🎉
Tree shaking (just a Button) 118.94 KB (0% 🟰) Yes 🎉

Click here if you want to find out what is changed in this build

Comment thread src/components/fields/ColorPicker/ColorPicker.tsx
`\d+\.?\d*` lets its two quantifiers split a digit run n ways, so
rejecting a long one was quadratic — 818ms for 20k digits, on text the
user types. `\d+(?:\.\d*)?` can only split it one way: 0.04ms, with
identical accept/reject behavior.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`TextInputBase` defaults `suffixPosition` to `before`, which put the
pipette ahead of the validation state. `DateInputBase` already orders it
the other way for the date pickers, so the picker family now matches.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The string is the unit of editing — a channel is tuned with the sliders,
not by hand-editing one number inside `oklch(…)` — so focusing offers the
whole value up for replacement, the way a hex field does.

Browsers disagree on when a click's caret lands, so the selection is
reapplied on release when it comes back collapsed. A dragged range is
left alone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Chrome applies a click's caret after the focus handler has run, so
selecting there was immediately undone. Suppressing the default action of
the press that takes focus means no caret is placed at all; focus moves
programmatically instead, and the selection stands.

Only that first press is suppressed, so clicking an already-focused field
still positions the caret.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Resolves the `.size-limit.cjs` conflict: Board selection and `ColorPicker`
both raised the `All` budget from 462 kB, so the comment now records both
and the limit is set from a fresh measurement of the two together —
467.87 kB, limit 469 kB. Tree shaking is unchanged at 121.8 kB, which is
the check that matters.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

@cursor cursor Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 898e714. Configure here.

Comment thread src/components/fields/ColorPicker/ColorPicker.tsx
`useTextField` was handed the raw `onFocus` prop, so `handleFocus` never
ran and nothing selected the value — the whole select-on-focus path was
dead. Caught by Bugbot on #1287.

The test that was supposed to cover this asserted the resulting selection
range after a Tab, which tabbing into a text input produces natively — so
it passed against dead code. It now spies on the call and covers the
pointer path too, and fails if the handler is unwired again.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@tenphi
tenphi merged commit 946f6b8 into main Aug 7, 2026
15 checks passed
@tenphi
tenphi deleted the feat/color-picker branch August 7, 2026 15:27
@tenphi tenphi mentioned this pull request Aug 7, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants