runlocally

runlocally engineering notes

Convert Color

How Convert Color is built

By Geppetto · · Open Convert Color →

Convert Color converts between HEX, RGB and HSL entirely client-side; this post covers the conversion math in src/utils/colorEngine.ts and the update logic that keeps three independent text fields showing the same color.

Tech used

The sRGB ↔ HSL conversion math

rgbToHsl and hslToRgb implement the standard CSS Color Module algorithm — the same formulas browsers use internally for hsl()rgb() — reproduced by hand so the tool has no runtime dependency. There is no color-conversion library in package.json; the only dependencies are astro and @astrojs/preact/preact.

rgbToHsl normalizes each channel to 0–1, takes max/min across the three, and sets lightness to (max + min) / 2. Hue and saturation are only computed inside an if (max !== min) branch: saturation uses the standard d / (2 - max - min) (lightness above 0.5) or d / (max + min) (at or below 0.5) split, and hue is picked by a three-way switch on which channel is max, each case offset so hue lands in the right 60°-wide sector before dividing by 6. When max === min — an achromatic gray, including pure black and white — that branch is skipped entirely and h/s stay at their initialized 0. The three outputs are rounded to the nearest integer degree/percent before being returned, which matters for how the fields round-trip (more below).

hslToRgb normalizes hue into 0–1 with ((h % 360) + 360) % 360 / 360 (defensive against out-of-range hue, even though parseHsl already rejects anything outside 0–360) and special-cases saturation 0 directly: it returns a flat gray from lightness alone (Math.round(lN * 255) on all three channels) without touching hue at all. For chromatic colors it computes the p/q midpoint values from the standard formula and calls a hueToRgbChannel helper three times, once per channel, offset by +1/3, 0, and -1/3 — a four-way piecewise function on where the hue falls relative to 1/6, 1/2 and 2/3.

Parsing HEX, RGB and HSL without throwing

parseHex, parseRgb and parseHsl each return either a value or null — none of them throw — so the caller can turn a bad parse into a field-level error rather than a crash. parseHex accepts an optional leading #, 3- or 6-digit hex, case-insensitively, and expands a 3-digit shorthand by doubling each digit. parseRgb and parseHsl both accept either the wrapped CSS form (rgb(...), hsl(...)) or a bare comma/space-separated triple, via a regex that strips the wrapper if present and then splits on [\s,]+. RGB channels must be integers 0–255 (checked with Number.isInteger); HSL accepts a hue 0–360 and saturation/lightness 0–100, with the % on the latter two optional in either direction.

Keeping three fields in sync: echo, then derive

The tool has no shared “current color” state beyond an RGB swatch value plus three independent text strings (hexText, rgbText, hslText). Each field’s change handler in ConvertColorTool.tsx follows the same shape: write the raw typed text back into that field’s own state first (so a keystroke is never blocked or reformatted mid-edit), then try to parse it. On failure, only that field’s error state is set and the handler returns — the other two fields’ text, their errors, and the swatch are left untouched. On success, all three errors clear and the other two fields are recomputed from the one color that was just parsed (rgbToHex/formatRgb/formatHsl/rgbToHsl/hslToRgb, depending on direction).

The field being edited never has its own derived value written back into itself — only its raw input is echoed once. That absence of a self-referential write is what keeps three separately-useState’d fields from fighting each other on every keystroke; there’s no equality check or debounce standing in for it.

Site shell

Like the HEIC to JPG tool, this is an Astro page that server-renders the static shell and hydrates a single Preact island — here, ConvertColorTool — for the interactive part, with a Service Worker for offline use. That machinery is unchanged from the earlier writeup; everything above is what’s specific to this tool.

Implementation & operational notes

Hue on a gray doesn’t survive a round trip through RGB. Because rgbToHsl only computes h inside the max !== min branch, any achromatic RGB triple — including one produced by converting an HSL value with saturation 0 and some arbitrary hue — always maps back to h: 0. Typing 180, 0%, 50% into the HSL field itself is fine: that field only ever echoes its own raw text, so it keeps showing 180, 0%, 50%. But if you then edit either the HEX or RGB field afterward (even to the same resulting gray), the recompute calls rgbToHsl on that gray and overwrites the HSL field with hsl(0, 0%, 50%) — the 180 is gone, because RGB has no channel to have kept it in. This falls directly out of RGB being the only value carried between fields; there’s no separate “remembered hue” state.

Rounding is applied on every conversion, and a ±1-per-channel round-trip test documents the consequence. rgbToHsl rounds h/s/l to whole numbers and hslToRgb rounds each RGB channel to a whole number, so HEX → HSL → HEX is not always byte-identical. The test suite (tests/unit/colorEngine.test.ts) checks a set of named colors (orange, teal, slate gray, dodger blue, etc.) round-trips either exactly or within 1 on every channel — never more — rather than asserting exact equality across the board.

Invalid input never blanks a field. The three text fields and the swatch are separate state; a failed parse in one field sets only that field’s error and returns before touching the other two, so switching back and forth between a valid and invalid HEX value, say, never resets the RGB/HSL fields or the swatch to empty.

The default swatch color is the site’s own accent color (0x63, 0x66, 0xf1, matching the page’s theme-color meta tag) rather than an arbitrary placeholder like black or white, so the tool never opens with a technically-empty-looking swatch.

Scope is fixed at HEX/RGB/HSL. CMYK, HSB/HSV and named CSS colors are not implemented, and there’s no way to pick a color from an image — that lives in a separate tool.

Try it / source