# color-value-tools — AI Reference Parse, convert, and manipulate colors across hex/RGB/HSL/HSV/HWB/Lab/LCH/ OKLab/OKLCH/CMYK/Display-P3, with WCAG contrast checks, color-blindness simulation, interpolation/scales, and a `cvt` CLI. Zero runtime dependencies, pure vanilla TS — no Vue/React bindings. Everything lives in a single ~1300-line `src/index.ts` (95 exported symbols) plus a separate CLI entry (`bin/cli.ts`). Version 1.1.12+ (this document reflects source with 6 real parsing/output bugs fixed — see section 11; if you're reading a `dist/` older than that, expect the specific behaviors called out as "fixed" to instead match their "before" state). This document is hand-written for AI agents and other tools that generate code against this package: every signature, default, and behavior note below is verified directly against the TypeScript source (not summarized from prose docs). For human-readable narrative docs, see the interactive site instead: - Full docs (EN): https://npm.vuecraft.ru/en/packages/color-value-tools/guide/overview - Full docs (RU): https://npm.vuecraft.ru/packages/color-value-tools/guide/overview - GitHub: https://github.com/macrulezru/color-value-tools - npm: https://www.npmjs.com/package/color-value-tools Links below starting with "/" are relative to https://npm.vuecraft.ru. --- ## 1. Package map | Import path | Contains | |---|---| | `color-value-tools` | Everything — all 95 functions/types, single entry point, no subpaths. | | `cvt` (bin, `npx cvt ...`) | CLI wrapping a subset of the library (`info`/`convert`/`contrast`/`shades`/`harmonies`/`nearest`) — section 10. | No framework integration of any kind (confirmed: no Vue/React references anywhere in source) — pure functions only. --- ## 2. `normalizeColor()` — the parsing entry point almost everything else calls through ```ts function normalizeColor( input: string | { r: number; g: number; b: number } | { h: number; s: number; l: number } ): | { type: 'rgb' | 'hsl'; hex: string; r: any; g: any; b: any; a: 1; h: any; s: any; l: any; v: number } // object input — see gotcha 1 | { type: 'unknown' } // object matching neither shape, or unparseable string | { type: 'css-var'; raw: string } // var(--x) — never resolved, no DOM access | { type: 'hex' | 'rgb' | 'hsl' | 'named' | 'oklch' | 'color'; hex: string; r: number; g: number; b: number; a: number; h: number; s: number; l: number; v: number } ``` **There is no exported `NormalizedColor` interface** — the return type is a TS-inferred union of these differently-shaped anonymous objects. For the `{r,g,b}`/`{h,s,l}` object-input branches, `r/g/b`/`h/s/l` on the result are typed `any` (the implementation casts `input as any`) — type safety is lost there even though the runtime values are real numbers. **String-input parse order** (first match wins): `var(--...)` → 8-digit/ 4-digit hex (`#rrggbbaa`/`#rgba`) → 6/3-digit hex → `rgb()`/`rgba()` → `hsl()`/`hsla()` → the literal string `'transparent'` (its own special-case, `a: 0`) → CSS named-color table lookup (case-insensitive) → `oklch()`/`oklcha()` → `color(display-p3|srgb|srgb-linear ...)` → `{ type: 'unknown' }`. **Unresolvable input returns `{ type: 'unknown' }` with literally no other keys present** (not even `undefined`-valued ones). Most of this library's other functions don't check `type` before using the result — they use `n.r ?? 0` / `n.hex ?? '#000000'` idioms, so an unresolved input silently computes as if it were black rather than raising an error. Keep this in mind for any color-manipulation function below that takes a raw string. --- ## 3. Named-color resolution ```ts const NAMED_COLORS: Record // 148 real CSS named colors + 'transparent' function getColorType(value: string): ColorType // 'hex'|'css-var'|'rgb'|'hsl'|'named'|'oklch'|'color'|'unknown' function isCssVariable/isHexColor/isOklchColor/isColorFunction/isRgbColor/isHslColor(value: string): boolean ``` - All 148 standard CSS named colors, case-insensitive lookup (`gray`/`grey` spelling variants both present, as CSS itself defines). - **`'currentcolor'`/`'inherit'`/`'initial'`/`'unset'` are deliberately NOT in this table** (as of this fix) — none of them has a fixed color value; they only resolve given DOM/CSSOM context this library never has. `normalizeColor()`/`getColorType()` correctly report `'unknown'` for all four. **Before the fix**, these were present in the table mapped to their own literal keyword string (e.g. `currentcolor: 'currentcolor'`), which got run through `normalizeHex()`'s invalid-hex fallback (a hardcoded pale yellow, `#f5e477` — see section 5) and returned as a fully-fabricated `{ type: 'named', hex: 'currentcolor', r:245,g:228,b:119,a:1 }`, indistinguishable from a real resolved color. If you're looking at an older build, expect that behavior instead. - `'transparent'` IS a real, fixed CSS color (fully transparent black) — `normalizeColor()` special-cases the literal string `'transparent'` *before* ever reaching the table (so the table's own `transparent` entry is effectively only consulted by `getColorType()`/ `toNearestNamedColor()`, which read the table directly rather than going through `normalizeColor()`'s full cascade). - `isHexColor()` accepts 3/4/6/8-digit hex (with or without leading `#`) — as of this fix, matching what `normalizeColor()`'s own separate hex8 pre-check already handled; **before the fix**, `isHexColor()` (and therefore `getColorType()`) only recognized 3/4/6-digit, so `getColorType('#ff0000ff')` returned `'unknown'` even though `normalizeColor('#ff0000ff')` parsed it correctly. - `isColorFunction()` accepts `color(display-p3 ...)`, `color(srgb ...)`, and `color(srgb-linear ...)` — all three CSS `color()` predefined spaces this library actually supports. --- ## 4. `toNearestNamedColor(color): string` Linear-scan Euclidean RGB distance against every `NAMED_COLORS` entry, skipping any table value that isn't a real hex string (a defensive guard — as of this fix, `'transparent'` is the only such entry, and its non-hex value is excluded from the distance comparison). **Before this fix**, the loop ran `hexToRgb()` on every table entry unconditionally, including the (since-removed) `currentcolor`/`inherit`/`initial`/ `unset` pseudo-entries — all four collapsed onto the same `normalizeHex()`-fallback-yellow RGB point, so any real color close to that specific yellow nonsensically reported `"transparent"` as its nearest named color (object key insertion order decided which of the five tied keywords "won"). No longer possible — the table's only remaining non-hex entry is explicitly skipped. --- ## 5. Hex/RGB primitives — the fallback-yellow gotcha ```ts function normalizeHex(hex: string): string // expands 3→6 digit, lowercases, adds '#' if missing function hexToRgb(hex: string): [number, number, number] function rgbToHex({r,g,b}): string // toHex2() via Math.round().toString(16), NOT clamped to 0-255 by rgbToHex itself ``` **`normalizeHex()` returns a hardcoded fallback `'#f5e477'` (a pale yellow) for any input that isn't ultimately valid 6-hex-digit** — not black, not an exception, not `undefined`. This fallback silently propagates through `hexToRgb()` into anything built on it. Genuinely invalid hex-like input (e.g. `'#zzzzzz'`, or historically the pseudo- keyword strings described in section 3) resolves to `rgb(245, 228, 119)` with no error raised anywhere in the call chain. `adjustHexBrightness(hex, offsetPercent)` — the one channel-adjusting function in the whole file that rounds via `Math.floor()` instead of `Math.round()` (both the lighten and darken branches) — every other RGB-producing function uses `Math.round`. A silent, undocumented rounding-behavior inconsistency; results can be off-by-one versus what you'd get computing the same adjustment through `lighten()`/`darken()`. --- ## 6. `rgbaStringToRgba(str): {r,g,b,a} | null` — now clamped ```ts function rgbaStringToRgba(str: string): { r: number; g: number; b: number; a: number } | null ``` Parses `rgb(...)`/`rgba(...)`, percentage channels (`v.endsWith('%')`) scaled by `2.55`. **As of this fix**, channels are clamped to `[0,255]` and alpha to `[0,1]`. **Before the fix**, out-of-range input passed straight through unclamped — `rgbaStringToRgba('rgb(300,-20,0)')` used to return `{r:300,g:-20,b:0,a:1}` verbatim, and feeding that into `normalizeColor()`/`rgbToHex()` could produce a malformed hex string (a negative channel stringifies with a leading `-` via `Number.prototype.toString(16)`). --- ## 7. `color(...)` CSS function parsing — `srgb-linear` gamma fix ```ts function parseColorFn(str: string): { space: string; r: number; g: number; b: number; alpha: number } | null ``` `normalizeColor()` branches on `parsed.space`: - `'display-p3'` → routed through `displayP3ToRgb()` (proper P3→sRGB matrix + gamma). - `'srgb-linear'` (as of this fix) → each channel run through the linear-to-sRGB transfer function (`linearChanToSrgb`, the same helper `rgbToDisplayP3`/`displayP3ToRgb` use) before scaling to a byte. **Before the fix**, `srgb-linear` was treated identically to plain `srgb` (just `* 255` + clamp, no gamma step) — only the `0`/`1` channel extremes happened to convert correctly that way; any mid-range linear value (e.g. `0.5`, which should gamma-encode to ≈0.735 before scaling) came out numerically wrong. - anything else (`'srgb'`) → already gamma-encoded, just scaled/clamped directly (`Math.round(Math.max(0, Math.min(255, v * 255)))`). --- ## 8. Color-space conversions — rounding/clamping/gamut summary - **Hex/RGB/HSL/HSV/HWB**: all round to integers via `Math.round()` (H in degrees, S/L/V/W/B in percent) — except `adjustHexBrightness` (section 5, `Math.floor`). - **`hwbToRgb`**: if `W/100 + B/100 >= 1`, short-circuits to a flat gray (`Math.round((w/(w+b))*255)` for every channel) without computing hue at all. - **Lab/LCH** (`rgbToLab`/`labToRgb`, `rgbToLch`/`lchToRgb`): CIE D65, standard piecewise cube-root thresholds. `L`/`a`/`b` (and `L`/`C`/`H`) are returned as **raw, unrounded floats** — the CLI's `fmt2()` is what rounds them for display; library consumers get full precision. - **OKLab/OKLCH** (`rgbToOklab`/`oklabToRgb`, `rgbToOklch`/`oklchToRgb`): Björn Ottosson's matrices. `oklabToRgb`'s channel outputs are clamped via the shared linear→sRGB helper (`Math.max(0, Math.min(1, t))` before `*255`) — out-of-gamut OKLab input is hard-clipped into sRGB, no perceptual gamut mapping, which can shift hue/chroma for wide-gamut colors. - **CMYK** (`rgbToCmyk`/`cmykToRgb`): fractions in `[0,1]`, **not** percentages (the CLI multiplies by 100 for display). `k === 1` (pure black) short-circuits to `{c:0,m:0,y:0,k:1}` to avoid a divide-by-zero. - **Display P3** (`rgbToDisplayP3`/`displayP3ToRgb`/`toDisplayP3Hex`): hardcoded 3×3 sRGB↔P3 matrices. (Historically had its own now-fixed bug — the linear-to-sRGB output wasn't scaled by 255 before clamping/rounding, clamping everything to 0 or 1; long since corrected, verified `toDisplayP3Hex('#ffffff') === '#ffffff'`.) - **Color-blindness simulation** (`simulateProtanopia`/ `simulateDeuteranopia`/`simulateTritanopia`/`simulateColorBlindness`): Viénot 1999 matrices applied to linearized RGB, then re-encoded. --- ## 9. Manipulation, mixing, palettes, accessibility (selected signatures) ```ts lighten(color, amount): string // HSL l + amount, clamped [0,100] darken(color, amount): string // HSL l - amount, clamped [0,100] saturate(color, amount): string // HSL s + amount, clamped [0,100] desaturate(color, amount): string // HSL s - amount, clamped [0,100] rotateHue(hex, degrees): string // wraps mod 360 invertColor(color): string // 255 - each channel grayscale(color): string // ITU-R BT.709 luminance weights setAlpha(color, alpha): string // → 'rgba(...)' string, alpha clamped [0,1] getAlpha(color): number // n.a ?? 1 mixColors(c1, c2, t, opts?): string // opts.mode: 'rgb'(default)|'hsl'|'lab'|'lch'|'oklab'|'oklch' // opts.format: 'hex'(default)|'rgb'|'rgba'|'hsl' // opts.hueInterpolation: 'shorter'(default)|'longer'|'increasing'|'decreasing' — only used for hsl/lch/oklch (lab/oklab have no hue) interpolateColors(color1, color2, steps, options?): string[] // steps has NO default; steps===1 → midpoint via mixColors(...,0.5,...); steps<=0 → [] createColorScale(anchors, steps, options?): string[] // anchors: string[] (evenly spaced) or {color,position?}[]; steps has NO default midpointColor(color1, color2, options?): string // mixColors(...,0.5,{mode: options?.space ?? 'oklab'}) tints/shades/tones(color, steps=5, ...): string[] // thin wrappers over interpolateColors(...,'#ffffff'|'#000000'|gray,steps,{space:'oklab'}) colorShades(color, steps=9): string[] // HSL lightness 100→0 at fixed h/s; steps<=1 guarded (as of this fix — see below) monochromatic(color, steps=5): string[] // HSL saturation 0→100 at fixed h/l; steps<=1 guarded (as of this fix) ``` **`colorShades(color, 1)` / `monochromatic(color, 1)`** — as of this fix, return a single hex representing the color's own lightness/ saturation, matching the "steps===1 → the meaningful single value" pattern already used by `interpolateColors`/`tints`/`shades`/`tones`/ `createColorScale`. **Before the fix**, both divided by `steps - 1` unconditionally (`0/0 = NaN` when `steps === 1`) and returned the literal string `"#NaNNaNNaN"` — these two functions predate the interpolation-based helpers and were never brought in line with their `steps===1` handling. ```ts relativeLuminance(color): number contrastRatio(a, b): number // WCAG formula, rounded to 2 decimals via toFixed wcagLevel(fg, bg): WcagLevel // >=7 'AAA', >=4.5 'AA', >=3 'AA-large', else 'fail' isDark/isLight(color, threshold=0.5): boolean bestTextColor(background): '#000000' | '#ffffff' // ties go to black (>=) bestContrastColor(background, candidates: string[]): string // linear scan; returns undefined for an empty candidates array — no guard bestContrastPalette(background, palettes: string[][], options?): PaletteScore & {paletteIndex} // score = avg*0.4 + min*0.6 (weighted avg, heavy penalty on the worst color in a palette) isReadableOnBackground(textColor, background: BackgroundSpec, options?): {readable, minContrastRatio, wcagLevel} // BackgroundSpec: string | {type:'semi-transparent', color, underlay?} | {type:'gradient', stops: string[]} // 'semi-transparent': composites color over underlay (default '#ffffff') via manual alpha blend // 'gradient': minContrastRatio is the MINIMUM contrast across every stop colorDeltaE(c1, c2): number // full CIEDE2000 (not simplified ΔE76) — G/T/SL/SC/SH/RT rotation term all implemented ``` --- ## 10. CLI (`cvt`) ``` cvt # 'info' — default command cvt convert # every format at once cvt contrast # ratio + WCAG pass/fail at AA/AAA/AA-large cvt shades [n] # default 9; errors (exit 1) if n < 2 or NaN — a stricter guard than the library's own colorShades() cvt harmonies # complement/triadic/analogous/split-complementary/tetradic cvt nearest # toNearestNamedColor() cvt --help / -h / (no args) # usage, exit 0 ``` Verified live: `cvt "cornflowerblue" harmonies` (the README's own canonical example) → `Complement: #edbd64`, `Triadic: #6495ed, #ed6494, #94ed64`, `Analogous: #64d8ed, #6495ed, #7864ed`, `Split-comp: #6495ed, #ed7864, #d8ed64`, `Tetradic: #6495ed, #ed64d8, #edbd64, #64ed78`. Both the main color argument and (for `contrast`) the background argument are independently run through `normalizeColor()` and rejected (`Cannot resolve color "..."`, exit 1) if `type` is `'unknown'` or `'css-var'` — **before** dispatching to any subcommand, so every subcommand shares this validation. `cvt currentcolor` now correctly errors (as of the fix in section 3) instead of silently succeeding against a fabricated yellow. --- ## 11. Fixed-bug history (verify against your installed version) The named-color-fabrication bug this package is best known for (the original motivation for auditing it) has two parts, both now fixed — confirmed via `git log -p`: - **Named colors themselves** (`'red'`, `'cornflowerblue'`, etc.) were never in `NAMED_COLORS` at all in the package's first several releases — including the release that introduced the CLI and its own canonical `cvt "cornflowerblue" harmonies` --help example, which was broken from the moment it shipped. Fixed in commit `b1729cf`. - **The four CSS pseudo-keywords** (`currentcolor`/`inherit`/`initial`/ `unset`) were a *separate*, longer-lived bug — fixed together with the five other issues in this same audit pass (sections 3, 4, 6, 7, 9). If you're generating code against an `os-detect`-family sibling package's older published version, don't assume any of the "as of this fix" behaviors above — check the installed `dist/`'s actual behavior for `normalizeColor('currentcolor')`/`colorShades(color, 1)` directly if it matters for your use case. --- ## 12. Consolidated gotcha list 1. `normalizeColor()` has no exported TS interface — its return type is inferred, and the `{r,g,b}`/`{h,s,l}` object-input branches produce `r/g/b`/`h/s/l` typed `any` (section 2). 2. Unresolvable input silently becomes `{ type: 'unknown' }` with no other keys, and most downstream functions don't check `type` — they just do `?? 0`/`?? '#000000'`, treating any unresolved color as black (section 2). This is the systemic root cause the named-color and CSS-keyword bugs both grew from — still present for any *other* future unresolvable input, even though the two known live cases are now fixed. 3. `normalizeHex()` falls back to a hardcoded pale yellow `#f5e477` for invalid hex — not black, not an exception (section 5). 4. `adjustHexBrightness()` uses `Math.floor()` for channel rounding; every other RGB-producing function in the file uses `Math.round()` — a silent, undocumented inconsistency (section 5). 5. `bestContrastColor(background, [])` (empty candidates) returns `undefined` with no guard or thrown error (section 9). 6. `isReadableOnBackground()`'s returned `wcagLevel` field is computed against a hardcoded `'#ffffff'` background whenever `background` is a `'semi-transparent'` or `'gradient'` spec — **not** against the actual composited/worst-case color that `minContrastRatio` and `readable` were correctly computed from. The three returned fields can disagree with each other for those two `BackgroundSpec` shapes (section 9). 7. `interpolateColors`/`createColorScale` have **no default** for their `steps` parameter (it's required) — unlike `tints`/`shades`/`tones`/ `colorShades`/`monochromatic`, which all default to 5 or 9. 8. `colorDeltaE` implements the *full* CIEDE2000 formula (rotation term included), not a simplified ΔE76/94 — don't assume it's the cheaper Euclidean-Lab-distance approximation some other color libraries use under the same function name (section 9). 9. CMYK values throughout this library are fractions in `[0,1]`, not percentages — only the CLI's `convert` output multiplies by 100 for display (section 8). 10. `rgbToHex()` itself does not clamp its `r`/`g`/`b` inputs to `0-255` — clamping happens at the call sites that need it (e.g. `rgbaStringToRgba`'s fix, section 6); calling `rgbToHex()` directly with out-of-range numbers is still the caller's responsibility. 11. The two now-fixed pseudo-keyword and 8-digit-hex bugs (sections 3, 4) both trace back to the same root pattern: a helper function (`getColorType`, `toNearestNamedColor`) independently re-implementing logic `normalizeColor()` already had correct, rather than sharing it — worth keeping in mind if new color-format support is ever added, since the same drift could recur.