From 5a14ffe3ce35c6b70c2d67b5313dc4c2bca0a209 Mon Sep 17 00:00:00 2001 From: Chip Cullen Date: Wed, 8 Apr 2026 10:13:36 -0400 Subject: [PATCH 1/4] consolidating rgb & hsl formats --- src/App.tsx | 18 ------------ src/utils/colorTypes.ts | 2 -- src/utils/isValidColor.test.ts | 48 +++++-------------------------- src/utils/isValidColor.ts | 18 ++---------- src/utils/translatedColor.test.ts | 29 +++++++++---------- src/utils/translatedColor.ts | 25 +++++----------- src/utils/typeOfColor.test.ts | 12 +++----- src/utils/typeOfColor.ts | 6 ---- 8 files changed, 33 insertions(+), 125 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 3781e7b..e914ddd 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -62,15 +62,6 @@ const App: React.FC = () => { incomingColorType={incomingColorType} /> - - { incomingColorType={incomingColorType} /> - - { expect(isValidRgb('rgb(255 255 255)')).toBe(true); expect(isValidRgb('rgb(0 0 0)')).toBe(true); expect(isValidRgb('rgb(100% 100% 100%)')).toBe(true); + expect(isValidRgb('rgb(255 0 0 / 0.5)')).toBe(true); + expect(isValidRgb('rgba(255 0 0 / 0.5)')).toBe(true); }); it('return false on invalid rgb values', () => { @@ -55,25 +57,6 @@ describe('isValidRgb', () => { }); }); -describe('isValidRgba', () => { - it('return true on valid rgba values', () => { - expect(isValidRgba('rgba(100%, 100%, 100%, 1)')).toBe(true); - expect(isValidRgba('rgba(255, 255, 255, 0.5)')).toBe(true); - expect(isValidRgba('rgba(255,255,255,0.5)')).toBe(true); - }); - - it('return true on valid modern syntax rgba values', () => { - expect(isValidRgba('rgba(255 255 255 / 0.5)')).toBe(true); - expect(isValidRgba('rgba(0 0 0 / 1)')).toBe(true); - expect(isValidRgba('rgba(100% 100% 100% / 0.5)')).toBe(true); - }); - - it('return false on invalid rgba values', () => { - expect(isValidRgba('rgb(100%, 100%, 100%)')).toBe(false); - expect(isValidRgba('hsl(100, 100%, 100%)')).toBe(false); - }); -}); - describe('isValidHsl', () => { it('return true on valid hsl values', () => { expect(isValidHsl('hsl(100, 100%, 100%)')).toBe(true); @@ -83,6 +66,8 @@ describe('isValidHsl', () => { it('return true on valid modern syntax hsl values', () => { expect(isValidHsl('hsl(100 100% 100%)')).toBe(true); expect(isValidHsl('hsl(0 0% 0%)')).toBe(true); + expect(isValidHsl('hsl(0 100% 50% / 0.5)')).toBe(true); + expect(isValidHsl('hsla(0 100% 50% / 0.5)')).toBe(true); }); it('return false on invalid hsl values', () => { @@ -91,23 +76,6 @@ describe('isValidHsl', () => { }); }); -describe('isValidHsla', () => { - it('return true on valid hsla values', () => { - expect(isValidHsla('hsla(100, 100%, 100%, 0.5)')).toBe(true); - expect(isValidHsla('hsla(100,100%,100%, 1)')).toBe(true); - }); - - it('return true on valid modern syntax hsla values', () => { - expect(isValidHsla('hsla(100 100% 100% / 0.5)')).toBe(true); - expect(isValidHsla('hsla(0 0% 0% / 1)')).toBe(true); - }); - - it('return false on invalid hsla values', () => { - expect(isValidHsla('hsla(100%, 100%, 100%)')).toBe(false); - expect(isValidHsla('hsla (100%, 255, 100%, 1)')).toBe(false); - }); -}); - describe('isValidLch', () => { it('return true on valid lch values', () => { expect(isValidLch('lch(100% 100 100 / 0.5)')).toBe(true); @@ -159,18 +127,16 @@ describe('isValidColor', () => { expect(isValidColor('purple', colorTypes.named)).toBe(true); expect(isValidColor('#ffffff', colorTypes.hex6)).toBe(true); expect(isValidColor('#ffffffff', colorTypes.hex8)).toBe(true); - expect(isValidColor('hsla(100, 100%, 100%, 0.5)', colorTypes.hsla)).toBe(true); expect(isValidColor('hsl(100, 100%, 100%)', colorTypes.hsl)).toBe(true); - expect(isValidColor('rgba(100, 100, 100, 0.5)', colorTypes.rgba)).toBe(true); + expect(isValidColor('hsl(100 100% 50% / 0.5)', colorTypes.hsl)).toBe(true); expect(isValidColor('rgb(100, 100, 100)', colorTypes.rgb)).toBe(true); + expect(isValidColor('rgb(100 100 100 / 0.5)', colorTypes.rgb)).toBe(true); }); it('return false for invalid colors', () => { expect(isValidColor('purplee', colorTypes.named)).toBe(false); expect(isValidColor('#ffffff', colorTypes.hex8)).toBe(false); expect(isValidColor('#fffffff', colorTypes.hex6)).toBe(false); - expect(isValidColor('hsla(100, 100%, 100%, 0.5)', colorTypes.rgba)).toBe(false); expect(isValidColor('rgb(100, 100, 100)', colorTypes.hsl)).toBe(false); - expect(isValidColor('rgb(100, 100, 100)', colorTypes.rgba)).toBe(false); }); }); diff --git a/src/utils/isValidColor.ts b/src/utils/isValidColor.ts index cdc587a..9dcb738 100644 --- a/src/utils/isValidColor.ts +++ b/src/utils/isValidColor.ts @@ -22,19 +22,11 @@ const isValidHex8 = (color: string): boolean => { }; const isValidRgb = (color: string): boolean => { - return color.startsWith('rgb(') && canParseColor(color); -}; - -const isValidRgba = (color: string): boolean => { - return color.startsWith('rgba(') && canParseColor(color); + return (color.startsWith('rgb(') || color.startsWith('rgba(')) && canParseColor(color); }; const isValidHsl = (color: string): boolean => { - return color.startsWith('hsl(') && canParseColor(color); -}; - -const isValidHsla = (color: string): boolean => { - return color.startsWith('hsla(') && canParseColor(color); + return (color.startsWith('hsl(') || color.startsWith('hsla(')) && canParseColor(color); }; const isValidLch = (color: string): boolean => { @@ -58,12 +50,8 @@ const isValidColor = (color: string, colorType: colorTypes): boolean => { return isValidHex8(color); case colorTypes.rgb: return isValidRgb(color); - case colorTypes.rgba: - return isValidRgba(color); case colorTypes.hsl: return isValidHsl(color); - case colorTypes.hsla: - return isValidHsla(color); case colorTypes.lch: return isValidLch(color); case colorTypes.oklch: @@ -82,9 +70,7 @@ export { isValidHex6, isValidHex8, isValidRgb, - isValidRgba, isValidHsl, - isValidHsla, isValidLch, isValidOklch, isValidP3, diff --git a/src/utils/translatedColor.test.ts b/src/utils/translatedColor.test.ts index 86f0726..126659c 100644 --- a/src/utils/translatedColor.test.ts +++ b/src/utils/translatedColor.test.ts @@ -3,25 +3,22 @@ import { colorTypes } from './colorTypes'; describe('translatedColor', () => { - it('Should translate modern rgba syntax to all targets', () => { - expect(translatedColor(`rgba(255 0 0 / 0.5)`, colorTypes.rgba, colorTypes.hex6)).toBe('#ff7f7f'); - expect(translatedColor(`rgba(255 0 0 / 0.5)`, colorTypes.rgba, colorTypes.rgb)).toBe('rgb(255 127 127)'); - expect(translatedColor(`rgba(255 0 0 / 0.5)`, colorTypes.rgba, colorTypes.hex8)).toBe('#ff000080'); - expect(translatedColor(`rgba(255 0 0 / 0.5)`, colorTypes.rgba, colorTypes.hsla)).toBeTruthy(); - expect(translatedColor(`rgba(255 0 0 / 0.5)`, colorTypes.rgba, colorTypes.lch)).toBeTruthy(); + it('Should translate rgb with alpha to all targets', () => { + expect(translatedColor(`rgb(255 0 0 / 0.5)`, colorTypes.rgb, colorTypes.hex6)).toBe('#ff7f7f'); + expect(translatedColor(`rgb(255 0 0 / 0.5)`, colorTypes.rgb, colorTypes.hex8)).toBe('#ff000080'); + expect(translatedColor(`rgb(255 0 0 / 0.5)`, colorTypes.rgb, colorTypes.hsl)).toBe('hsl(0 100% 50% / 50%)'); + expect(translatedColor(`rgb(255 0 0 / 0.5)`, colorTypes.rgb, colorTypes.lch)).toBeTruthy(); }); - it('Should translate modern hsla syntax to all targets', () => { - expect(translatedColor(`hsla(0 100% 50% / 0.5)`, colorTypes.hsla, colorTypes.hex6)).toBe('#ff7f7f'); - expect(translatedColor(`hsla(0 100% 50% / 0.5)`, colorTypes.hsla, colorTypes.rgb)).toBe('rgb(255 127 127)'); - expect(translatedColor(`hsla(0 100% 50% / 0.5)`, colorTypes.hsla, colorTypes.hex8)).toBeTruthy(); - expect(translatedColor(`hsla(0 100% 50% / 0.5)`, colorTypes.hsla, colorTypes.rgba)).toBeTruthy(); - expect(translatedColor(`hsla(0 100% 50% / 0.5)`, colorTypes.hsla, colorTypes.lch)).toBeTruthy(); + it('Should translate hsl with alpha to all targets', () => { + expect(translatedColor(`hsl(0 100% 50% / 0.5)`, colorTypes.hsl, colorTypes.hex6)).toBe('#ff7f7f'); + expect(translatedColor(`hsl(0 100% 50% / 0.5)`, colorTypes.hsl, colorTypes.hex8)).toBeTruthy(); + expect(translatedColor(`hsl(0 100% 50% / 0.5)`, colorTypes.hsl, colorTypes.rgb)).toBe('rgb(255 0 0 / 50%)'); + expect(translatedColor(`hsl(0 100% 50% / 0.5)`, colorTypes.hsl, colorTypes.lch)).toBeTruthy(); }); it('Should return correct translated colors', () => { expect(translatedColor(`#fff`, colorTypes.hex6, colorTypes.rgb)).toBe(`rgb(255 255 255)`); - expect(translatedColor(`#fff`, colorTypes.hex6, colorTypes.rgba)).toBe(`rgba(255 255 255 / 1)`); expect(translatedColor(`#fff`, colorTypes.hex6, colorTypes.named)).toBe(`White`); expect(translatedColor(`#ff00ff`, colorTypes.hex6, colorTypes.named)).toBe(`Fuchsia`); expect(translatedColor(`rgb(255, 0, 0)`, colorTypes.rgb, colorTypes.named)).toBe(`Red`); @@ -35,14 +32,14 @@ describe('translatedColor', () => { it('Should translate hex without # prefix', () => { expect(translatedColor(`fff`, colorTypes.hex6, colorTypes.rgb)).toBe(`rgb(255 255 255)`); expect(translatedColor(`ff0000`, colorTypes.hex6, colorTypes.named)).toBe(`Red`); - expect(translatedColor(`ff000080`, colorTypes.hex8, colorTypes.rgba)).toBe(`rgba(255 0 0 / 0.5019607843137255)`); + expect(translatedColor(`ff000080`, colorTypes.hex8, colorTypes.rgb)).toBe(`rgb(255 0 0 / 50%)`); }); it('Should translate oklch to other formats', () => { expect(translatedColor(`oklch(62.8% 0.258 29.234)`, colorTypes.oklch, colorTypes.hex6)).toBe('#ff0000'); expect(translatedColor(`oklch(100% 0 0)`, colorTypes.oklch, colorTypes.hex6)).toBe('#ffffff'); expect(translatedColor(`oklch(0% 0 0)`, colorTypes.oklch, colorTypes.hex6)).toBe('#000000'); - expect(translatedColor(`oklch(62.8% 0.258 29.234 / 0.5)`, colorTypes.oklch, colorTypes.rgba)).toBeTruthy(); + expect(translatedColor(`oklch(62.8% 0.258 29.234 / 0.5)`, colorTypes.oklch, colorTypes.rgb)).toBeTruthy(); }); it('Should translate from oklch to oklch unchanged', () => { @@ -53,7 +50,7 @@ describe('translatedColor', () => { expect(translatedColor(`color(display-p3 1 0 0)`, colorTypes.p3, colorTypes.hex6)).toBe('#ff0b0c'); expect(translatedColor(`color(display-p3 1 1 1)`, colorTypes.p3, colorTypes.rgb)).toBe('rgb(255 255 255)'); expect(translatedColor(`color(display-p3 0 0 0)`, colorTypes.p3, colorTypes.hex6)).toBe('#000000'); - expect(translatedColor(`color(display-p3 1 0 0 / 0.5)`, colorTypes.p3, colorTypes.rgba)).toBeTruthy(); + expect(translatedColor(`color(display-p3 1 0 0 / 0.5)`, colorTypes.p3, colorTypes.rgb)).toBeTruthy(); }); it('Should translate from display-p3 to display-p3 unchanged', () => { diff --git a/src/utils/translatedColor.ts b/src/utils/translatedColor.ts index f30a90f..eadaeec 100644 --- a/src/utils/translatedColor.ts +++ b/src/utils/translatedColor.ts @@ -1,7 +1,7 @@ import Color from 'colorjs.io'; import { colorTypes } from './colorTypes'; import { calculateOverlay } from './calculateOverlay'; -import { rgbToNamed, rgbaToNamed } from './toNamed'; +import { rgbToNamed } from './toNamed'; const translatedColor = ( color: string, @@ -30,7 +30,7 @@ const translatedColor = ( const hasAlpha = parsed.alpha < 1; const needsOverlay = hasAlpha && - [colorTypes.hex6, colorTypes.picker, colorTypes.rgb, colorTypes.hsl, colorTypes.named].includes(targetColorType); + [colorTypes.hex6, colorTypes.picker, colorTypes.named].includes(targetColorType); const srgb = parsed.toGamut({space: 'srgb'}).to('srgb'); const [r, g, b] = srgb.coords.map((v: number) => Math.round(v * 255)); @@ -51,25 +51,15 @@ const translatedColor = ( } case colorTypes.rgb: { - const [or, og, ob] = overlaid ?? [r, g, b]; - return `rgb(${or} ${og} ${ob})`; + const alphaStr = a < 1 ? ` / ${Math.round(a * 100)}%` : ''; + return `rgb(${r} ${g} ${b}${alphaStr})`; } - case colorTypes.rgba: - return `rgba(${r} ${g} ${b} / ${a})`; - case colorTypes.hsl: { - const [or, og, ob] = overlaid ?? [r, g, b]; - const flat = new Color(`srgb`, [or / 255, og / 255, ob / 255]); - const hsl = flat.to('hsl'); - const [h, s, l] = hsl.coords.map((v: number | null) => Math.round(v ?? 0)); - return `hsl(${h} ${s}% ${l}%)`; - } - - case colorTypes.hsla: { const hsl = srgb.to('hsl'); const [h, s, l] = hsl.coords.map((v: number | null) => Math.round(v ?? 0)); - return `hsla(${h} ${s}% ${l}% / ${a})`; + const alphaStr = a < 1 ? ` / ${Math.round(a * 100)}%` : ''; + return `hsl(${h} ${s}% ${l}%${alphaStr})`; } case colorTypes.lch: { @@ -98,8 +88,7 @@ const translatedColor = ( case colorTypes.named: { const [or, og, ob] = overlaid ?? [r, g, b]; - if (a === 1) return rgbToNamed([or, og, ob]); - return rgbaToNamed([r, g, b, a]); + return rgbToNamed([or, og, ob]); } default: diff --git a/src/utils/typeOfColor.test.ts b/src/utils/typeOfColor.test.ts index bec63ec..17b6ea2 100644 --- a/src/utils/typeOfColor.test.ts +++ b/src/utils/typeOfColor.test.ts @@ -30,20 +30,16 @@ describe("Type Of Color", () => { expect(typeOfColor("ffffff0A")).toBe("hex8"); }); - it("returns rgba", () => { - expect(typeOfColor("rgba(255, 255, 255, 1)")).toBe("rgba"); - }); - it("returns rgb", () => { expect(typeOfColor("rgb(255, 255, 255)")).toBe("rgb"); - }); - - it("returns hsla", () => { - expect(typeOfColor("hsla(0, 0%, 100, 1)")).toBe("hsla"); + expect(typeOfColor("rgb(255 0 0 / 0.5)")).toBe("rgb"); + expect(typeOfColor("rgba(255, 255, 255, 1)")).toBe("rgb"); }); it("returns hsl", () => { expect(typeOfColor("hsl(0, 0%, 100)")).toBe("hsl"); + expect(typeOfColor("hsl(0 100% 50% / 0.5)")).toBe("hsl"); + expect(typeOfColor("hsla(0, 0%, 100, 1)")).toBe("hsl"); }); it("returns lch", () => { diff --git a/src/utils/typeOfColor.ts b/src/utils/typeOfColor.ts index c32c7ff..1adeecc 100644 --- a/src/utils/typeOfColor.ts +++ b/src/utils/typeOfColor.ts @@ -12,15 +12,9 @@ const typeOfColor = (color: string): colorTypes => { case /^(#)?[0-9A-F]{8}$/i.test(color): return colorTypes.hex8; - case color.indexOf("rgba") === 0 && color.indexOf(")") !== -1: - return colorTypes.rgba; - case color.indexOf("rgb") === 0 && color.indexOf(")") !== -1: return colorTypes.rgb; - case color.indexOf("hsla") === 0 && color.indexOf(")") !== -1: - return colorTypes.hsla; - case color.indexOf("hsl") === 0 && color.indexOf(")") !== -1: return colorTypes.hsl; From 26a276718065874ce79d2fb692b9c6edcd206e8f Mon Sep 17 00:00:00 2001 From: Chip Cullen Date: Wed, 8 Apr 2026 10:44:29 -0400 Subject: [PATCH 2/4] copy button --- src/App.css | 34 ++++++++++++++++++++++++++ src/components/Input.tsx | 53 ++++++++++++++++++++++++++++++---------- 2 files changed, 74 insertions(+), 13 deletions(-) diff --git a/src/App.css b/src/App.css index 4efff30..cdfa0e9 100644 --- a/src/App.css +++ b/src/App.css @@ -59,6 +59,10 @@ label { text-align: right; } +.input-and-copy-button { + position: relative; +} + input[type="text"] { width: var(--input-width); font-size: clamp(1rem, 2vw, 2rem); @@ -74,6 +78,36 @@ input[type="text"] { background-color ease-in 0.2s; } +.copy-button { + font-family: inherit; + font-size: clamp(0.6rem, 1.2vw, 1.2rem); + padding: 0.3em 0.6em; + margin-left: 0.5vw; + cursor: pointer; + border: 2px solid var(--black); + background: white; + border-radius: clamp(0.5rem, 1vw, 1rem); + transition: background-color ease-in 0.2s; + vertical-align: middle; + position: absolute; + top: 50%; + transform: translateY(-50%); + right: 1vw; +} + +.copy-button:hover { + background: var(--gray); +} + +.copy-button:active { + transform: scale(0.95) translateY(-50%); +} + +/* hide the copy button if the input is empty */ +input[type="text"][value=""] + .copy-button { + display: none; +} + .color-input-wrapper { width: var(--input-width); display: inline-block; diff --git a/src/components/Input.tsx b/src/components/Input.tsx index 04a4e29..ed4e7bf 100644 --- a/src/components/Input.tsx +++ b/src/components/Input.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect, ChangeEvent } from "react"; +import React, { useState, useEffect, useRef, ChangeEvent } from "react"; import { colorTypes } from "../utils/colorTypes"; import { isOutOfSrgbGamut } from "../utils/isOutOfSrgbGamut"; @@ -37,7 +37,10 @@ const Input: React.FC = (props) => { } = props; const getOutOfFocusState = (colorValue: string) => { - if ([colorTypes.lch, colorTypes.oklch, colorTypes.p3].includes(colorType) && isOutOfSrgbGamut(colorValue)) { + if ( + [colorTypes.lch, colorTypes.oklch, colorTypes.p3].includes(colorType) && + isOutOfSrgbGamut(colorValue) + ) { return inputStates.outOfFocusOutOfGamut; } return inputStates.outOfFocus; @@ -47,13 +50,31 @@ const Input: React.FC = (props) => { const [value, setValue] = useState(incomingColor); const [inputState, setInputState] = useState(initInputState()); + const [copied, setCopied] = useState(false); + const copyTimeoutRef = useRef | null>(null); + + const copyHandler = () => { + navigator.clipboard.writeText(value); + setCopied(true); + if (copyTimeoutRef.current) clearTimeout(copyTimeoutRef.current); + copyTimeoutRef.current = setTimeout(() => setCopied(false), 3000); + }; + + useEffect(() => { + return () => { + if (copyTimeoutRef.current) clearTimeout(copyTimeoutRef.current); + }; + }, []); const localChangeHandler = (e: ChangeEvent) => { const changedValue = e.currentTarget.value; setValue(changedValue); if (isValidColor(changedValue, colorType)) { - if ([colorTypes.lch, colorTypes.oklch, colorTypes.p3].includes(colorType) && isOutOfSrgbGamut(changedValue)) { + if ( + [colorTypes.lch, colorTypes.oklch, colorTypes.p3].includes(colorType) && + isOutOfSrgbGamut(changedValue) + ) { setInputState(inputStates.inFocusValidValueOutOfGamut); } else { setInputState(inputStates.inFocusValidValue); @@ -94,7 +115,8 @@ const Input: React.FC = (props) => { }, [translatedIncomingColor]); if ( - (inputState === inputStates.outOfFocus || inputState === inputStates.outOfFocusOutOfGamut) && + (inputState === inputStates.outOfFocus || + inputState === inputStates.outOfFocusOutOfGamut) && translatedIncomingColor !== colorTypes.none && translatedIncomingColor !== value ) { @@ -130,15 +152,20 @@ const Input: React.FC = (props) => {
{showGamutWarning && ( From bd2cf8b354bae731197e724552249f32395ee1ab Mon Sep 17 00:00:00 2001 From: Chip Cullen Date: Wed, 8 Apr 2026 11:12:06 -0400 Subject: [PATCH 3/4] more feedback --- src/utils/isValidColor.test.ts | 4 ++ src/utils/isValidColor.ts | 24 +++++++---- src/utils/translatedColor.test.ts | 17 +++++--- src/utils/translatedColor.ts | 72 ++++++++++++++++++------------- 4 files changed, 73 insertions(+), 44 deletions(-) diff --git a/src/utils/isValidColor.test.ts b/src/utils/isValidColor.test.ts index 9ba22c3..7f208ad 100644 --- a/src/utils/isValidColor.test.ts +++ b/src/utils/isValidColor.test.ts @@ -129,8 +129,12 @@ describe('isValidColor', () => { expect(isValidColor('#ffffffff', colorTypes.hex8)).toBe(true); expect(isValidColor('hsl(100, 100%, 100%)', colorTypes.hsl)).toBe(true); expect(isValidColor('hsl(100 100% 50% / 0.5)', colorTypes.hsl)).toBe(true); + expect(isValidColor('hsla(100, 100%, 50%, 0.5)', colorTypes.hsl)).toBe(true); + expect(isValidColor('hsla(100 100% 50% / 0.5)', colorTypes.hsl)).toBe(true); expect(isValidColor('rgb(100, 100, 100)', colorTypes.rgb)).toBe(true); expect(isValidColor('rgb(100 100 100 / 0.5)', colorTypes.rgb)).toBe(true); + expect(isValidColor('rgba(100, 100, 100, 0.5)', colorTypes.rgb)).toBe(true); + expect(isValidColor('rgba(100 100 100 / 0.5)', colorTypes.rgb)).toBe(true); }); it('return false for invalid colors', () => { diff --git a/src/utils/isValidColor.ts b/src/utils/isValidColor.ts index 9dcb738..c22dd5a 100644 --- a/src/utils/isValidColor.ts +++ b/src/utils/isValidColor.ts @@ -1,6 +1,6 @@ -import Color from 'colorjs.io'; -import { colorTypes } from './colorTypes'; -import { lowerCaseNamedColors } from './namedColors'; +import Color from "colorjs.io"; +import { colorTypes } from "./colorTypes"; +import { lowerCaseNamedColors } from "./namedColors"; const canParseColor = (color: string): boolean => { try { @@ -21,24 +21,32 @@ const isValidHex8 = (color: string): boolean => { return /^(#)?[0-9A-F]{4}$/i.test(color) || /^(#)?[0-9A-F]{8}$/i.test(color); }; +// accepts either modern rgb/rgba syntax const isValidRgb = (color: string): boolean => { - return (color.startsWith('rgb(') || color.startsWith('rgba(')) && canParseColor(color); + return ( + (color.startsWith("rgb(") || color.startsWith("rgba(")) && + canParseColor(color) + ); }; +// accepts either modern hsl/hsla syntax const isValidHsl = (color: string): boolean => { - return (color.startsWith('hsl(') || color.startsWith('hsla(')) && canParseColor(color); + return ( + (color.startsWith("hsl(") || color.startsWith("hsla(")) && + canParseColor(color) + ); }; const isValidLch = (color: string): boolean => { - return color.startsWith('lch(') && canParseColor(color); + return color.startsWith("lch(") && canParseColor(color); }; const isValidOklch = (color: string): boolean => { - return color.startsWith('oklch(') && canParseColor(color); + return color.startsWith("oklch(") && canParseColor(color); }; const isValidP3 = (color: string): boolean => { - return color.startsWith('color(display-p3') && canParseColor(color); + return color.startsWith("color(display-p3") && canParseColor(color); }; const isValidColor = (color: string, colorType: colorTypes): boolean => { diff --git a/src/utils/translatedColor.test.ts b/src/utils/translatedColor.test.ts index 126659c..5e17499 100644 --- a/src/utils/translatedColor.test.ts +++ b/src/utils/translatedColor.test.ts @@ -18,11 +18,11 @@ describe('translatedColor', () => { }); it('Should return correct translated colors', () => { - expect(translatedColor(`#fff`, colorTypes.hex6, colorTypes.rgb)).toBe(`rgb(255 255 255)`); + expect(translatedColor(`#fff`, colorTypes.hex6, colorTypes.rgb)).toBe(`rgb(255 255 255 / 100%)`); expect(translatedColor(`#fff`, colorTypes.hex6, colorTypes.named)).toBe(`White`); expect(translatedColor(`#ff00ff`, colorTypes.hex6, colorTypes.named)).toBe(`Fuchsia`); expect(translatedColor(`rgb(255, 0, 0)`, colorTypes.rgb, colorTypes.named)).toBe(`Red`); - expect(translatedColor(`rgb(255, 0, 0)`, colorTypes.rgb, colorTypes.hsl)).toBe('hsl(0 100% 50%)'); + expect(translatedColor(`rgb(255, 0, 0)`, colorTypes.rgb, colorTypes.hsl)).toBe('hsl(0 100% 50% / 100%)'); expect(translatedColor(`hsl(200, 66%, 75%)`, colorTypes.hsl, colorTypes.hex6)).toBe('#95cde9'); expect(translatedColor(`lch(54.291% 106.837 40.858)`, colorTypes.lch, colorTypes.hex6)).toBe('#ff0000'); expect(translatedColor(`lch(54.291% 106.837 40.858 / 50%)`, colorTypes.lch, colorTypes.hex8)).toBe('#ff000080'); @@ -30,9 +30,9 @@ describe('translatedColor', () => { }); it('Should translate hex without # prefix', () => { - expect(translatedColor(`fff`, colorTypes.hex6, colorTypes.rgb)).toBe(`rgb(255 255 255)`); + expect(translatedColor(`fff`, colorTypes.hex6, colorTypes.rgb)).toBe(`rgb(255 255 255 / 100%)`); expect(translatedColor(`ff0000`, colorTypes.hex6, colorTypes.named)).toBe(`Red`); - expect(translatedColor(`ff000080`, colorTypes.hex8, colorTypes.rgb)).toBe(`rgb(255 0 0 / 50%)`); + expect(translatedColor(`ff000080`, colorTypes.hex8, colorTypes.rgb)).toBe(`rgb(255 0 0 / 50.2%)`); }); it('Should translate oklch to other formats', () => { @@ -42,13 +42,20 @@ describe('translatedColor', () => { expect(translatedColor(`oklch(62.8% 0.258 29.234 / 0.5)`, colorTypes.oklch, colorTypes.rgb)).toBeTruthy(); }); + it('Should normalize legacy rgba/hsla aliases when same-type', () => { + expect(translatedColor(`rgba(255 0 0 / 0.5)`, colorTypes.rgb, colorTypes.rgb)).toBe('rgb(255 0 0 / 50%)'); + expect(translatedColor(`rgba(255, 0, 0, 1)`, colorTypes.rgb, colorTypes.rgb)).toBe('rgb(255 0 0 / 100%)'); + expect(translatedColor(`hsla(0 100% 50% / 0.5)`, colorTypes.hsl, colorTypes.hsl)).toBe('hsl(0 100% 50% / 50%)'); + expect(translatedColor(`hsla(0, 100%, 50%, 1)`, colorTypes.hsl, colorTypes.hsl)).toBe('hsl(0 100% 50% / 100%)'); + }); + it('Should translate from oklch to oklch unchanged', () => { expect(translatedColor(`oklch(62.8% 0.258 29.234)`, colorTypes.oklch, colorTypes.oklch)).toBe(`oklch(62.8% 0.258 29.234)`); }); it('Should translate display-p3 to other formats', () => { expect(translatedColor(`color(display-p3 1 0 0)`, colorTypes.p3, colorTypes.hex6)).toBe('#ff0b0c'); - expect(translatedColor(`color(display-p3 1 1 1)`, colorTypes.p3, colorTypes.rgb)).toBe('rgb(255 255 255)'); + expect(translatedColor(`color(display-p3 1 1 1)`, colorTypes.p3, colorTypes.rgb)).toBe('rgb(255 255 255 / 100%)'); expect(translatedColor(`color(display-p3 0 0 0)`, colorTypes.p3, colorTypes.hex6)).toBe('#000000'); expect(translatedColor(`color(display-p3 1 0 0 / 0.5)`, colorTypes.p3, colorTypes.rgb)).toBeTruthy(); }); diff --git a/src/utils/translatedColor.ts b/src/utils/translatedColor.ts index eadaeec..3fd8c2f 100644 --- a/src/utils/translatedColor.ts +++ b/src/utils/translatedColor.ts @@ -1,20 +1,24 @@ -import Color from 'colorjs.io'; -import { colorTypes } from './colorTypes'; -import { calculateOverlay } from './calculateOverlay'; -import { rgbToNamed } from './toNamed'; +import Color from "colorjs.io"; +import { colorTypes } from "./colorTypes"; +import { calculateOverlay } from "./calculateOverlay"; +import { rgbToNamed } from "./toNamed"; const translatedColor = ( color: string, startingColorType: colorTypes, - targetColorType: colorTypes + targetColorType: colorTypes, ): string => { - if (startingColorType === targetColorType) { + const isLegacyAlias = + (startingColorType === colorTypes.rgb && color.startsWith("rgba(")) || + (startingColorType === colorTypes.hsl && color.startsWith("hsla(")); + + if (startingColorType === targetColorType && !isLegacyAlias) { return color; } const hexTypes = [colorTypes.hex6, colorTypes.hex8, colorTypes.picker]; const normalizedColor = - hexTypes.includes(startingColorType) && !color.startsWith('#') + hexTypes.includes(startingColorType) && !color.startsWith("#") ? `#${color}` : color; @@ -22,18 +26,20 @@ const translatedColor = ( try { parsed = new Color(normalizedColor); } catch { - return 'none'; + return "none"; } // For alpha-bearing types, flatten to RGB on white before converting - // to formats that don't carry alpha (hex6, rgb, hsl, named) + // to formats that don't carry alpha (hex6, named, picker) const hasAlpha = parsed.alpha < 1; const needsOverlay = hasAlpha && - [colorTypes.hex6, colorTypes.picker, colorTypes.named].includes(targetColorType); + [colorTypes.hex6, colorTypes.picker, colorTypes.named].includes( + targetColorType, + ); - const srgb = parsed.toGamut({space: 'srgb'}).to('srgb'); - const [r, g, b] = srgb.coords.map((v: number) => Math.round(v * 255)); + const srgb = parsed.toGamut({ space: "srgb" }).to("srgb"); + const [r, g, b] = srgb.coords.map((v: number | null) => Math.round((v ?? 0) * 255)); const a = parsed.alpha; const overlaid = needsOverlay ? calculateOverlay([r, g, b, a]) : null; @@ -42,47 +48,51 @@ const translatedColor = ( case colorTypes.hex6: case colorTypes.picker: { const [or, og, ob] = overlaid ?? [r, g, b]; - return `#${[or, og, ob].map(v => v.toString(16).padStart(2, '0')).join('')}`; + return `#${[or, og, ob].map((v) => v.toString(16).padStart(2, "0")).join("")}`; } case colorTypes.hex8: { const alpha255 = Math.round(a * 255); - return `#${[r, g, b, alpha255].map(v => v.toString(16).padStart(2, '0')).join('')}`; + return `#${[r, g, b, alpha255].map((v) => v.toString(16).padStart(2, "0")).join("")}`; } case colorTypes.rgb: { - const alphaStr = a < 1 ? ` / ${Math.round(a * 100)}%` : ''; - return `rgb(${r} ${g} ${b}${alphaStr})`; + return `rgb(${r} ${g} ${b} / ${parseFloat((a * 100).toFixed(2))}%)`; } case colorTypes.hsl: { - const hsl = srgb.to('hsl'); - const [h, s, l] = hsl.coords.map((v: number | null) => Math.round(v ?? 0)); - const alphaStr = a < 1 ? ` / ${Math.round(a * 100)}%` : ''; - return `hsl(${h} ${s}% ${l}%${alphaStr})`; + const hsl = srgb.to("hsl"); + const [h, s, l] = hsl.coords.map((v: number | null) => + Math.round(v ?? 0), + ); + return `hsl(${h} ${s}% ${l}% / ${parseFloat((a * 100).toFixed(2))}%)`; } case colorTypes.lch: { - const lch = parsed.to('lch'); - const [l, c, h] = lch.coords.map((v: number | null) => +((v ?? 0).toFixed(2))); - const alphaStr = a < 1 ? ` / ${a}` : ''; + const lch = parsed.to("lch"); + const [l, c, h] = lch.coords.map( + (v: number | null) => +(v ?? 0).toFixed(2), + ); + const alphaStr = a < 1 ? ` / ${a}` : ""; return `lch(${l}% ${c} ${h}${alphaStr})`; } case colorTypes.oklch: { - const oklch = parsed.to('oklch'); + const oklch = parsed.to("oklch"); const coords = oklch.coords; const l = +((coords[0] ?? 0) * 100).toFixed(2); - const c = +((coords[1] ?? 0).toFixed(2)); - const h = +((coords[2] ?? 0).toFixed(2)); - const alphaStr = a < 1 ? ` / ${a}` : ''; + const c = +(coords[1] ?? 0).toFixed(2); + const h = +(coords[2] ?? 0).toFixed(2); + const alphaStr = a < 1 ? ` / ${a}` : ""; return `oklch(${l}% ${c} ${h}${alphaStr})`; } case colorTypes.p3: { - const p3 = parsed.to('p3'); - const [pr, pg, pb] = p3.coords.map((v: number | null) => +((v ?? 0).toFixed(2))); - const alphaStr = a < 1 ? ` / ${a}` : ''; + const p3 = parsed.to("p3"); + const [pr, pg, pb] = p3.coords.map( + (v: number | null) => +(v ?? 0).toFixed(2), + ); + const alphaStr = a < 1 ? ` / ${a}` : ""; return `color(display-p3 ${pr} ${pg} ${pb}${alphaStr})`; } @@ -92,7 +102,7 @@ const translatedColor = ( } default: - return 'none'; + return "none"; } }; From dd2253fe2c758b6045fc6ff1bbec68c38c139538 Mon Sep 17 00:00:00 2001 From: Chip Cullen Date: Wed, 8 Apr 2026 11:18:16 -0400 Subject: [PATCH 4/4] backing the alpha channel change out --- src/constants.ts | 4 ++-- src/utils/translatedColor.test.ts | 12 ++++++------ src/utils/translatedColor.ts | 6 ++++-- 3 files changed, 12 insertions(+), 10 deletions(-) diff --git a/src/constants.ts b/src/constants.ts index a6a904a..1c032a2 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -1,4 +1,4 @@ -const DEFAULT_COLOR = `rgba(255 0 0 / 1)`; // red +const DEFAULT_COLOR = `rgba(255 0 0 / 60%)`; // red const ASSUMED_BACKGROUND_COLOR = [255, 255, 255]; // white -export { DEFAULT_COLOR, ASSUMED_BACKGROUND_COLOR } +export { DEFAULT_COLOR, ASSUMED_BACKGROUND_COLOR }; diff --git a/src/utils/translatedColor.test.ts b/src/utils/translatedColor.test.ts index 5e17499..636479b 100644 --- a/src/utils/translatedColor.test.ts +++ b/src/utils/translatedColor.test.ts @@ -18,11 +18,11 @@ describe('translatedColor', () => { }); it('Should return correct translated colors', () => { - expect(translatedColor(`#fff`, colorTypes.hex6, colorTypes.rgb)).toBe(`rgb(255 255 255 / 100%)`); + expect(translatedColor(`#fff`, colorTypes.hex6, colorTypes.rgb)).toBe(`rgb(255 255 255)`); expect(translatedColor(`#fff`, colorTypes.hex6, colorTypes.named)).toBe(`White`); expect(translatedColor(`#ff00ff`, colorTypes.hex6, colorTypes.named)).toBe(`Fuchsia`); expect(translatedColor(`rgb(255, 0, 0)`, colorTypes.rgb, colorTypes.named)).toBe(`Red`); - expect(translatedColor(`rgb(255, 0, 0)`, colorTypes.rgb, colorTypes.hsl)).toBe('hsl(0 100% 50% / 100%)'); + expect(translatedColor(`rgb(255, 0, 0)`, colorTypes.rgb, colorTypes.hsl)).toBe('hsl(0 100% 50%)'); expect(translatedColor(`hsl(200, 66%, 75%)`, colorTypes.hsl, colorTypes.hex6)).toBe('#95cde9'); expect(translatedColor(`lch(54.291% 106.837 40.858)`, colorTypes.lch, colorTypes.hex6)).toBe('#ff0000'); expect(translatedColor(`lch(54.291% 106.837 40.858 / 50%)`, colorTypes.lch, colorTypes.hex8)).toBe('#ff000080'); @@ -30,7 +30,7 @@ describe('translatedColor', () => { }); it('Should translate hex without # prefix', () => { - expect(translatedColor(`fff`, colorTypes.hex6, colorTypes.rgb)).toBe(`rgb(255 255 255 / 100%)`); + expect(translatedColor(`fff`, colorTypes.hex6, colorTypes.rgb)).toBe(`rgb(255 255 255)`); expect(translatedColor(`ff0000`, colorTypes.hex6, colorTypes.named)).toBe(`Red`); expect(translatedColor(`ff000080`, colorTypes.hex8, colorTypes.rgb)).toBe(`rgb(255 0 0 / 50.2%)`); }); @@ -44,9 +44,9 @@ describe('translatedColor', () => { it('Should normalize legacy rgba/hsla aliases when same-type', () => { expect(translatedColor(`rgba(255 0 0 / 0.5)`, colorTypes.rgb, colorTypes.rgb)).toBe('rgb(255 0 0 / 50%)'); - expect(translatedColor(`rgba(255, 0, 0, 1)`, colorTypes.rgb, colorTypes.rgb)).toBe('rgb(255 0 0 / 100%)'); + expect(translatedColor(`rgba(255, 0, 0, 1)`, colorTypes.rgb, colorTypes.rgb)).toBe('rgb(255 0 0)'); expect(translatedColor(`hsla(0 100% 50% / 0.5)`, colorTypes.hsl, colorTypes.hsl)).toBe('hsl(0 100% 50% / 50%)'); - expect(translatedColor(`hsla(0, 100%, 50%, 1)`, colorTypes.hsl, colorTypes.hsl)).toBe('hsl(0 100% 50% / 100%)'); + expect(translatedColor(`hsla(0, 100%, 50%, 1)`, colorTypes.hsl, colorTypes.hsl)).toBe('hsl(0 100% 50%)'); }); it('Should translate from oklch to oklch unchanged', () => { @@ -55,7 +55,7 @@ describe('translatedColor', () => { it('Should translate display-p3 to other formats', () => { expect(translatedColor(`color(display-p3 1 0 0)`, colorTypes.p3, colorTypes.hex6)).toBe('#ff0b0c'); - expect(translatedColor(`color(display-p3 1 1 1)`, colorTypes.p3, colorTypes.rgb)).toBe('rgb(255 255 255 / 100%)'); + expect(translatedColor(`color(display-p3 1 1 1)`, colorTypes.p3, colorTypes.rgb)).toBe('rgb(255 255 255)'); expect(translatedColor(`color(display-p3 0 0 0)`, colorTypes.p3, colorTypes.hex6)).toBe('#000000'); expect(translatedColor(`color(display-p3 1 0 0 / 0.5)`, colorTypes.p3, colorTypes.rgb)).toBeTruthy(); }); diff --git a/src/utils/translatedColor.ts b/src/utils/translatedColor.ts index 3fd8c2f..f7bb3ad 100644 --- a/src/utils/translatedColor.ts +++ b/src/utils/translatedColor.ts @@ -57,7 +57,8 @@ const translatedColor = ( } case colorTypes.rgb: { - return `rgb(${r} ${g} ${b} / ${parseFloat((a * 100).toFixed(2))}%)`; + const alphaStr = a < 1 ? ` / ${parseFloat((a * 100).toFixed(2))}%` : ""; + return `rgb(${r} ${g} ${b}${alphaStr})`; } case colorTypes.hsl: { @@ -65,7 +66,8 @@ const translatedColor = ( const [h, s, l] = hsl.coords.map((v: number | null) => Math.round(v ?? 0), ); - return `hsl(${h} ${s}% ${l}% / ${parseFloat((a * 100).toFixed(2))}%)`; + const alphaStr = a < 1 ? ` / ${parseFloat((a * 100).toFixed(2))}%` : ""; + return `hsl(${h} ${s}% ${l}%${alphaStr})`; } case colorTypes.lch: {