From 77d8eff7b9407f405512bf0d12a43e4d6d072d62 Mon Sep 17 00:00:00 2001 From: Jasper Kang Date: Thu, 26 Mar 2026 10:02:34 +1300 Subject: [PATCH 1/4] fix(charts): Handle non-hex colors in conversion funnel chart Add colorToRgba() utility that gracefully handles any valid CSS color format (hex, rgb, hsl, named colors) and returns null for unparseable values instead of throwing. Replace hexToRgba() with colorToRgba() in ConversionFunnelChart to prevent crashes when themes provide non-hex color values. Fixes CHARTS-196 --- ...fix-CHARTS-196-funnel-chart-non-hex-colors | 4 ++ .../update-charts-remove-individual-exports | 4 ++ .../conversion-funnel-chart.tsx | 4 +- .../charts/src/utils/color-utils.ts | 22 ++++++++++ .../charts/src/utils/test/color-utils.test.ts | 40 +++++++++++++++++++ 5 files changed, 72 insertions(+), 2 deletions(-) create mode 100644 projects/js-packages/charts/changelog/fix-CHARTS-196-funnel-chart-non-hex-colors create mode 100644 projects/js-packages/charts/changelog/update-charts-remove-individual-exports diff --git a/projects/js-packages/charts/changelog/fix-CHARTS-196-funnel-chart-non-hex-colors b/projects/js-packages/charts/changelog/fix-CHARTS-196-funnel-chart-non-hex-colors new file mode 100644 index 000000000000..63a9fe055fe3 --- /dev/null +++ b/projects/js-packages/charts/changelog/fix-CHARTS-196-funnel-chart-non-hex-colors @@ -0,0 +1,4 @@ +Significance: patch +Type: fixed + +Charts: Fix conversion funnel chart crash when using non-hex color formats. diff --git a/projects/js-packages/charts/changelog/update-charts-remove-individual-exports b/projects/js-packages/charts/changelog/update-charts-remove-individual-exports new file mode 100644 index 000000000000..1844c5783cb7 --- /dev/null +++ b/projects/js-packages/charts/changelog/update-charts-remove-individual-exports @@ -0,0 +1,4 @@ +Significance: major +Type: removed + +Remove individual chart entry point exports in favor of the main package entry point for v1. diff --git a/projects/js-packages/charts/src/charts/conversion-funnel-chart/conversion-funnel-chart.tsx b/projects/js-packages/charts/src/charts/conversion-funnel-chart/conversion-funnel-chart.tsx index a6996939c664..d3dc6332b796 100644 --- a/projects/js-packages/charts/src/charts/conversion-funnel-chart/conversion-funnel-chart.tsx +++ b/projects/js-packages/charts/src/charts/conversion-funnel-chart/conversion-funnel-chart.tsx @@ -11,7 +11,7 @@ import { useGlobalChartsTheme, useGlobalChartsContext, } from '../../providers'; -import { formatPercentage, hexToRgba } from '../../utils'; +import { formatPercentage, colorToRgba } from '../../utils'; import styles from './conversion-funnel-chart.module.scss'; import { useFunnelSelection } from './private'; import type { FunnelStep, ConversionFunnelChartProps } from './types'; @@ -247,7 +247,7 @@ const ConversionFunnelChartInternal: FC< ConversionFunnelChartProps > = ( { // Create light background version of primary color if not set const barBackgroundColor = - backgroundColor || hexToRgba( barColor, 0.08 ) || 'rgba(0, 0, 0, 0.08)'; + backgroundColor || colorToRgba( barColor, 0.08 ) || 'rgba(0, 0, 0, 0.08)'; // Default main metric rendering function const renderDefaultMainMetric = () => ( diff --git a/projects/js-packages/charts/src/utils/color-utils.ts b/projects/js-packages/charts/src/utils/color-utils.ts index 50fe36bd39d7..a527dcd0a0f8 100644 --- a/projects/js-packages/charts/src/utils/color-utils.ts +++ b/projects/js-packages/charts/src/utils/color-utils.ts @@ -51,6 +51,28 @@ export const hexToRgba = ( hex: string, alpha: number ): string => { return d3Color( hex )!.copy( { opacity: alpha } ).formatRgb(); }; +/** + * Convert any valid CSS color to rgba with specified opacity. + * Unlike hexToRgba, this gracefully handles non-hex formats (rgb, hsl, named colors, etc.) + * and returns null for unparseable values instead of throwing. + * + * @param color - Any valid CSS color string (e.g., '#ff0000', 'rgb(255, 0, 0)', 'red') + * @param alpha - The opacity value. Values outside the [0, 1] range will be clamped by d3. + * @return The rgba color string (e.g., 'rgba(255, 0, 0, 0.5)'), or null if the color is invalid + */ +export const colorToRgba = ( color: string, alpha: number ): string | null => { + if ( typeof color !== 'string' || typeof alpha !== 'number' || isNaN( alpha ) ) { + return null; + } + + const parsed = d3Color( color ); + if ( ! parsed ) { + return null; + } + + return parsed.copy( { opacity: alpha } ).formatRgb(); +}; + /** * Calculate the perceptual distance between two HSL colors * @param hsl1 - first color in HSL format [h, s, l] diff --git a/projects/js-packages/charts/src/utils/test/color-utils.test.ts b/projects/js-packages/charts/src/utils/test/color-utils.test.ts index 4c2f2eb89a4f..43ff6294788b 100644 --- a/projects/js-packages/charts/src/utils/test/color-utils.test.ts +++ b/projects/js-packages/charts/src/utils/test/color-utils.test.ts @@ -1,5 +1,6 @@ import { hsl as d3Hsl } from '@visx/vendor/d3-color'; import { + colorToRgba, getColorDistance, lightenHexColor, isValidHexColor, @@ -522,6 +523,45 @@ describe( 'hexToRgba', () => { } ); } ); +describe( 'colorToRgba', () => { + it( 'converts hex colors', () => { + expect( colorToRgba( '#ff0000', 0.5 ) ).toBe( 'rgba(255, 0, 0, 0.5)' ); + } ); + + it( 'converts named CSS colors', () => { + expect( colorToRgba( 'red', 0.5 ) ).toBe( 'rgba(255, 0, 0, 0.5)' ); + } ); + + it( 'converts rgb() colors', () => { + expect( colorToRgba( 'rgb(0, 128, 255)', 0.3 ) ).toBe( 'rgba(0, 128, 255, 0.3)' ); + } ); + + it( 'converts hsl() colors', () => { + const result = colorToRgba( 'hsl(0, 100%, 50%)', 0.8 ); + expect( result ).toBe( 'rgba(255, 0, 0, 0.8)' ); + } ); + + it( 'converts 3-digit hex colors', () => { + expect( colorToRgba( '#f00', 0.5 ) ).toBe( 'rgba(255, 0, 0, 0.5)' ); + } ); + + it( 'returns null for invalid color strings', () => { + expect( colorToRgba( 'not-a-color', 0.5 ) ).toBeNull(); + } ); + + it( 'returns null for empty string', () => { + expect( colorToRgba( '', 0.5 ) ).toBeNull(); + } ); + + it( 'returns null for non-string input', () => { + expect( colorToRgba( 123 as unknown as string, 0.5 ) ).toBeNull(); + } ); + + it( 'returns null for NaN alpha', () => { + expect( colorToRgba( '#ff0000', NaN ) ).toBeNull(); + } ); +} ); + describe( 'lightenHexColor', () => { describe( 'Valid inputs', () => { it( 'returns original color with blend of 0', () => { From ce30f648e788e775d4a9e35cc7152838e43e7b3c Mon Sep 17 00:00:00 2001 From: Jasper Kang Date: Thu, 26 Mar 2026 14:19:26 +1300 Subject: [PATCH 2/4] Delete projects/js-packages/charts/changelog/update-charts-remove-individual-exports --- .../charts/changelog/update-charts-remove-individual-exports | 4 ---- 1 file changed, 4 deletions(-) delete mode 100644 projects/js-packages/charts/changelog/update-charts-remove-individual-exports diff --git a/projects/js-packages/charts/changelog/update-charts-remove-individual-exports b/projects/js-packages/charts/changelog/update-charts-remove-individual-exports deleted file mode 100644 index 1844c5783cb7..000000000000 --- a/projects/js-packages/charts/changelog/update-charts-remove-individual-exports +++ /dev/null @@ -1,4 +0,0 @@ -Significance: major -Type: removed - -Remove individual chart entry point exports in favor of the main package entry point for v1. From 991ad95888fedcbb1d2f040113405ba9a29a6d4e Mon Sep 17 00:00:00 2001 From: Jasper Kang Date: Thu, 26 Mar 2026 14:30:49 +1300 Subject: [PATCH 3/4] refactor(charts): Remove hexToRgba in favor of colorToRgba hexToRgba was only used in the conversion funnel chart (now migrated) and a leaderboard storybook story. colorToRgba is a strict superset that handles any CSS color format, making the hex-only variant dead code. --- .../stories/index.stories.tsx | 4 +- .../charts/src/utils/color-utils.ts | 21 +- .../charts/src/utils/test/color-utils.test.ts | 232 ------------------ 3 files changed, 3 insertions(+), 254 deletions(-) diff --git a/projects/js-packages/charts/src/charts/leaderboard-chart/stories/index.stories.tsx b/projects/js-packages/charts/src/charts/leaderboard-chart/stories/index.stories.tsx index 4ca1af9e4170..8f3d5a8e1c8d 100644 --- a/projects/js-packages/charts/src/charts/leaderboard-chart/stories/index.stories.tsx +++ b/projects/js-packages/charts/src/charts/leaderboard-chart/stories/index.stories.tsx @@ -13,7 +13,7 @@ import { themeArgTypes, } from '../../../stories'; import { legendArgTypes, extractLegendConfig } from '../../../stories/legend-config'; -import { formatMetricValue, hexToRgba } from '../../../utils'; +import { formatMetricValue, colorToRgba } from '../../../utils'; import LeaderboardChart from '../leaderboard-chart'; import type { Meta, StoryObj } from '@storybook/react'; @@ -349,7 +349,7 @@ const LeaderboardChartWithOverlayLabelImage = ( args: StoryArgs ) => { overrideColor: args.primaryColor, } ); - const primaryColorWithAlpha = hexToRgba( primaryColor, 0.08 ); + const primaryColorWithAlpha = colorToRgba( primaryColor, 0.08 ); return ; }; diff --git a/projects/js-packages/charts/src/utils/color-utils.ts b/projects/js-packages/charts/src/utils/color-utils.ts index a527dcd0a0f8..b8d8f497027f 100644 --- a/projects/js-packages/charts/src/utils/color-utils.ts +++ b/projects/js-packages/charts/src/utils/color-utils.ts @@ -32,28 +32,9 @@ export const validateHexColor = ( hex: unknown ): void => { throw new Error( 'Hex color contains invalid characters. Only 0-9, a-f, A-F are allowed' ); }; -/** - * Convert hex color to rgba with specified opacity. - * This is genuinely reusable across chart components. - * @param hex - The hex color string (e.g., '#ff0000') - * @param alpha - The opacity value. Values outside the [0, 1] range will be clamped by the underlying d3 color library. - * @return The rgba color string (e.g., 'rgba(255, 0, 0, 0.5)') - * @throws {Error} if hex string is malformed or alpha is not a valid number - */ -export const hexToRgba = ( hex: string, alpha: number ): string => { - validateHexColor( hex ); - - if ( typeof alpha !== 'number' || isNaN( alpha ) ) { - throw new Error( 'Alpha must be a number' ); - } - - // Safe to use non-null assertion since validateHexColor ensures valid hex - return d3Color( hex )!.copy( { opacity: alpha } ).formatRgb(); -}; - /** * Convert any valid CSS color to rgba with specified opacity. - * Unlike hexToRgba, this gracefully handles non-hex formats (rgb, hsl, named colors, etc.) + * Gracefully handles any CSS color format (hex, rgb, hsl, named colors, etc.) * and returns null for unparseable values instead of throwing. * * @param color - Any valid CSS color string (e.g., '#ff0000', 'rgb(255, 0, 0)', 'red') diff --git a/projects/js-packages/charts/src/utils/test/color-utils.test.ts b/projects/js-packages/charts/src/utils/test/color-utils.test.ts index 43ff6294788b..1645938d8834 100644 --- a/projects/js-packages/charts/src/utils/test/color-utils.test.ts +++ b/projects/js-packages/charts/src/utils/test/color-utils.test.ts @@ -4,7 +4,6 @@ import { getColorDistance, lightenHexColor, isValidHexColor, - hexToRgba, validateHexColor, parseHslString, parseRgbString, @@ -292,237 +291,6 @@ describe( 'getColorDistance', () => { } ); } ); -describe( 'hexToRgba', () => { - describe( 'Valid hex colors', () => { - it( 'converts 6-digit hex to rgb with full opacity', () => { - const result = hexToRgba( '#ff0000', 1 ); - expect( result ).toBe( 'rgb(255, 0, 0)' ); - } ); - - it( 'converts 6-digit hex to rgba with partial opacity', () => { - const result = hexToRgba( '#00ff00', 0.5 ); - expect( result ).toBe( 'rgba(0, 255, 0, 0.5)' ); - } ); - - it( 'converts 6-digit hex to rgba with zero opacity', () => { - const result = hexToRgba( '#0000ff', 0 ); - expect( result ).toBe( 'rgba(0, 0, 255, 0)' ); - } ); - - it( 'handles lowercase hex colors', () => { - const result = hexToRgba( '#abcdef', 0.8 ); - expect( result ).toBe( 'rgba(171, 205, 239, 0.8)' ); - } ); - - it( 'handles uppercase hex colors', () => { - const result = hexToRgba( '#ABCDEF', 0.8 ); - expect( result ).toBe( 'rgba(171, 205, 239, 0.8)' ); - } ); - - it( 'handles mixed case hex colors', () => { - const result = hexToRgba( '#AbCdEf', 0.3 ); - expect( result ).toBe( 'rgba(171, 205, 239, 0.3)' ); - } ); - } ); - - describe( 'Edge cases', () => { - it( 'handles black color', () => { - const result = hexToRgba( '#000000', 1 ); - expect( result ).toBe( 'rgb(0, 0, 0)' ); - } ); - - it( 'handles white color', () => { - const result = hexToRgba( '#ffffff', 1 ); - expect( result ).toBe( 'rgb(255, 255, 255)' ); - } ); - - it( 'handles high precision alpha values', () => { - const result = hexToRgba( '#ff0000', 0.123456 ); - expect( result ).toBe( 'rgba(255, 0, 0, 0.123456)' ); - } ); - - // Function now validates hex input format - it( 'throws error for hex without # prefix', () => { - expect( () => hexToRgba( 'ff0000', 1 ) ).toThrow( 'Hex color must start with #' ); - } ); - } ); - - describe( 'Input validation', () => { - describe( 'Invalid hex format', () => { - it( 'throws error for non-string input', () => { - expect( () => hexToRgba( 123 as unknown as string, 1 ) ).toThrow( - 'Hex color must be a string' - ); - expect( () => hexToRgba( null as unknown as string, 1 ) ).toThrow( - 'Hex color must be a string' - ); - expect( () => hexToRgba( undefined as unknown as string, 1 ) ).toThrow( - 'Hex color must be a string' - ); - } ); - - it( 'throws error for hex without # prefix', () => { - expect( () => hexToRgba( 'ff0000', 1 ) ).toThrow( 'Hex color must start with #' ); - expect( () => hexToRgba( '000000', 1 ) ).toThrow( 'Hex color must start with #' ); - } ); - - it( 'throws error for wrong length hex strings', () => { - expect( () => hexToRgba( '#ff', 1 ) ).toThrow( - 'Hex color must be 7 characters long (e.g., #ff0000)' - ); - expect( () => hexToRgba( '#fff', 1 ) ).toThrow( - 'Hex color must be 7 characters long (e.g., #ff0000)' - ); - expect( () => hexToRgba( '#ffff', 1 ) ).toThrow( - 'Hex color must be 7 characters long (e.g., #ff0000)' - ); - expect( () => hexToRgba( '#fffff', 1 ) ).toThrow( - 'Hex color must be 7 characters long (e.g., #ff0000)' - ); - expect( () => hexToRgba( '#ff00000', 1 ) ).toThrow( - 'Hex color must be 7 characters long (e.g., #ff0000)' - ); - expect( () => hexToRgba( '#', 1 ) ).toThrow( - 'Hex color must be 7 characters long (e.g., #ff0000)' - ); - } ); - - it( 'throws error for invalid hex characters', () => { - expect( () => hexToRgba( '#gggggg', 1 ) ).toThrow( - 'Hex color contains invalid characters. Only 0-9, a-f, A-F are allowed' - ); - expect( () => hexToRgba( '#ff00gg', 1 ) ).toThrow( - 'Hex color contains invalid characters. Only 0-9, a-f, A-F are allowed' - ); - expect( () => hexToRgba( '#zz0000', 1 ) ).toThrow( - 'Hex color contains invalid characters. Only 0-9, a-f, A-F are allowed' - ); - expect( () => hexToRgba( '#ff@000', 1 ) ).toThrow( - 'Hex color contains invalid characters. Only 0-9, a-f, A-F are allowed' - ); - expect( () => hexToRgba( '#ff 000', 1 ) ).toThrow( - 'Hex color contains invalid characters. Only 0-9, a-f, A-F are allowed' - ); - } ); - - it( 'throws error for empty string', () => { - expect( () => hexToRgba( '', 1 ) ).toThrow( 'Hex color must start with #' ); - } ); - } ); - - describe( 'Invalid alpha values', () => { - it( 'throws error for non-number alpha', () => { - expect( () => hexToRgba( '#ff0000', 'invalid' as unknown as number ) ).toThrow( - 'Alpha must be a number' - ); - expect( () => hexToRgba( '#ff0000', null as unknown as number ) ).toThrow( - 'Alpha must be a number' - ); - expect( () => hexToRgba( '#ff0000', undefined as unknown as number ) ).toThrow( - 'Alpha must be a number' - ); - expect( () => hexToRgba( '#ff0000', {} as unknown as number ) ).toThrow( - 'Alpha must be a number' - ); - } ); - - it( 'throws error for NaN alpha', () => { - expect( () => hexToRgba( '#ff0000', NaN ) ).toThrow( 'Alpha must be a number' ); - } ); - - it( 'accepts negative and greater than 1 alpha values without throwing (d3 color formatRgb() clamps them)', () => { - // These should not throw - CSS allows alpha values outside 0-1 range - expect( () => hexToRgba( '#ff0000', -0.5 ) ).not.toThrow(); - expect( () => hexToRgba( '#ff0000', 1.5 ) ).not.toThrow(); - expect( () => hexToRgba( '#ff0000', 2 ) ).not.toThrow(); - } ); - } ); - } ); - - describe( 'Real-world color examples', () => { - it( 'converts primary blue color', () => { - const result = hexToRgba( '#4f46e5', 0.08 ); - expect( result ).toBe( 'rgba(79, 70, 229, 0.08)' ); - } ); - - it( 'converts success green color', () => { - const result = hexToRgba( '#10b981', 0.15 ); - expect( result ).toBe( 'rgba(16, 185, 129, 0.15)' ); - } ); - - it( 'converts error red color', () => { - const result = hexToRgba( '#ef4444', 0.2 ); - expect( result ).toBe( 'rgba(239, 68, 68, 0.2)' ); - } ); - - it( 'converts gray color', () => { - const result = hexToRgba( '#6b7280', 0.6 ); - expect( result ).toBe( 'rgba(107, 114, 128, 0.6)' ); - } ); - } ); - - describe( 'Boundary alpha values', () => { - it( 'handles alpha value of 0', () => { - const result = hexToRgba( '#ff0000', 0 ); - expect( result ).toBe( 'rgba(255, 0, 0, 0)' ); - } ); - - it( 'handles alpha value of 1 (returns rgb format)', () => { - const result = hexToRgba( '#ff0000', 1 ); - expect( result ).toBe( 'rgb(255, 0, 0)' ); - } ); - - it( 'clamps negative alpha values to 0', () => { - const result = hexToRgba( '#ff0000', -0.5 ); - expect( result ).toBe( 'rgba(255, 0, 0, 0)' ); - } ); - - it( 'clamps alpha values greater than 1 (returns rgb format)', () => { - const result = hexToRgba( '#ff0000', 1.5 ); - expect( result ).toBe( 'rgb(255, 0, 0)' ); - } ); - } ); - - describe( 'Color component extraction', () => { - it( 'correctly extracts red component', () => { - const result = hexToRgba( '#ff0000', 1 ); - expect( result ).toContain( '255, 0, 0' ); - } ); - - it( 'correctly extracts green component', () => { - const result = hexToRgba( '#00ff00', 1 ); - expect( result ).toContain( '0, 255, 0' ); - } ); - - it( 'correctly extracts blue component', () => { - const result = hexToRgba( '#0000ff', 1 ); - expect( result ).toContain( '0, 0, 255' ); - } ); - - it( 'correctly extracts all components for mixed color', () => { - const result = hexToRgba( '#8a2be2', 1 ); // BlueViolet - expect( result ).toBe( 'rgb(138, 43, 226)' ); - } ); - } ); - - describe( 'Typical usage patterns', () => { - it( 'works with common CSS background opacity', () => { - const result = hexToRgba( '#4f46e5', 0.08 ); - expect( result ).toBe( 'rgba(79, 70, 229, 0.08)' ); - } ); - - it( 'works with hover state opacity', () => { - const result = hexToRgba( '#4f46e5', 0.15 ); - expect( result ).toBe( 'rgba(79, 70, 229, 0.15)' ); - } ); - - it( 'works with disabled state opacity', () => { - const result = hexToRgba( '#4f46e5', 0.3 ); - expect( result ).toBe( 'rgba(79, 70, 229, 0.3)' ); - } ); - } ); -} ); - describe( 'colorToRgba', () => { it( 'converts hex colors', () => { expect( colorToRgba( '#ff0000', 0.5 ) ).toBe( 'rgba(255, 0, 0, 0.5)' ); From bc3ac3cb9ace38952f46a716198d30f66ff3e979 Mon Sep 17 00:00:00 2001 From: Jasper Kang Date: Fri, 27 Mar 2026 10:40:21 +1300 Subject: [PATCH 4/4] Charts: Keep hexToRgba as deprecated wrapper around colorToRgba --- .../charts/src/utils/color-utils.ts | 19 +++++++++++++++++++ .../charts/src/utils/test/color-utils.test.ts | 15 +++++++++++++++ 2 files changed, 34 insertions(+) diff --git a/projects/js-packages/charts/src/utils/color-utils.ts b/projects/js-packages/charts/src/utils/color-utils.ts index b8d8f497027f..2026b7e8744e 100644 --- a/projects/js-packages/charts/src/utils/color-utils.ts +++ b/projects/js-packages/charts/src/utils/color-utils.ts @@ -54,6 +54,25 @@ export const colorToRgba = ( color: string, alpha: number ): string | null => { return parsed.copy( { opacity: alpha } ).formatRgb(); }; +/** + * Convert hex color to rgba with specified opacity. + * + * @deprecated Use `colorToRgba` instead, which accepts any CSS color format and returns null instead of throwing. + * @param hex - The hex color string (e.g., '#ff0000') + * @param alpha - The opacity value. Values outside the [0, 1] range will be clamped by d3. + * @return The rgba color string (e.g., 'rgba(255, 0, 0, 0.5)') + * @throws {Error} if hex string is malformed or alpha is not a valid number + */ +export const hexToRgba = ( hex: string, alpha: number ): string => { + validateHexColor( hex ); + + if ( typeof alpha !== 'number' || isNaN( alpha ) ) { + throw new Error( 'Alpha must be a number' ); + } + + return colorToRgba( hex, alpha ) as string; +}; + /** * Calculate the perceptual distance between two HSL colors * @param hsl1 - first color in HSL format [h, s, l] diff --git a/projects/js-packages/charts/src/utils/test/color-utils.test.ts b/projects/js-packages/charts/src/utils/test/color-utils.test.ts index 1645938d8834..7c2ec4b1fb50 100644 --- a/projects/js-packages/charts/src/utils/test/color-utils.test.ts +++ b/projects/js-packages/charts/src/utils/test/color-utils.test.ts @@ -2,6 +2,7 @@ import { hsl as d3Hsl } from '@visx/vendor/d3-color'; import { colorToRgba, getColorDistance, + hexToRgba, lightenHexColor, isValidHexColor, validateHexColor, @@ -330,6 +331,20 @@ describe( 'colorToRgba', () => { } ); } ); +describe( 'hexToRgba (deprecated)', () => { + it( 'converts hex to rgba', () => { + expect( hexToRgba( '#ff0000', 0.5 ) ).toBe( 'rgba(255, 0, 0, 0.5)' ); + } ); + + it( 'throws for invalid hex', () => { + expect( () => hexToRgba( 'not-hex', 0.5 ) ).toThrow( 'Hex color must start with #' ); + } ); + + it( 'throws for invalid alpha', () => { + expect( () => hexToRgba( '#ff0000', NaN ) ).toThrow( 'Alpha must be a number' ); + } ); +} ); + describe( 'lightenHexColor', () => { describe( 'Valid inputs', () => { it( 'returns original color with blend of 0', () => {