From fc3126bd0f687bf33623be108e88afed6720b489 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 31 Aug 2026 19:13:45 -0700 Subject: [PATCH 1/2] fix(workflows): preserve contrast on gradient tiles --- apps/sim/blocks/icon-color.test.ts | 3 +- apps/sim/blocks/icon-color.ts | 9 +++--- packages/utils/src/color.test.ts | 26 +++++++++++++++- packages/utils/src/color.ts | 30 +++++++++++++++++++ .../src/lib/tile-icon-color.test.ts | 13 ++++++++ .../src/lib/tile-icon-color.ts | 4 +-- 6 files changed, 77 insertions(+), 8 deletions(-) create mode 100644 packages/workflow-renderer/src/lib/tile-icon-color.test.ts diff --git a/apps/sim/blocks/icon-color.test.ts b/apps/sim/blocks/icon-color.test.ts index 804561ce36e..8ccc76d51e1 100644 --- a/apps/sim/blocks/icon-color.test.ts +++ b/apps/sim/blocks/icon-color.test.ts @@ -18,9 +18,10 @@ describe('isLightTileColor', () => { expect(isLightTileColor('#B2C147')).toBe(false) }) - it('treats dark tiles, gradients, and empty values as dark', () => { + it('uses gradient stops while keeping dark and empty values dark', () => { expect(isLightTileColor('#171717')).toBe(false) expect(isLightTileColor('#9B5CFF')).toBe(false) + expect(isLightTileColor('linear-gradient(180deg, #E0F7FA 0%, #FFFFFF 100%)')).toBe(true) expect(isLightTileColor('linear-gradient(45deg, #fff, #000)')).toBe(false) expect(isLightTileColor(null)).toBe(false) expect(isLightTileColor(undefined)).toBe(false) diff --git a/apps/sim/blocks/icon-color.ts b/apps/sim/blocks/icon-color.ts index 083cd0ddffd..b3dea250d50 100644 --- a/apps/sim/blocks/icon-color.ts +++ b/apps/sim/blocks/icon-color.ts @@ -4,7 +4,7 @@ * these without pulling 282 block configs and the tool registry into its bundle. * Registry-backed icon styling lives in `@/blocks/brand-icon`. */ -import { isLightColor } from '@/lib/colors' +import { perceivedBackgroundBrightness } from '@sim/utils/color' /** * Brightness above which a brand tile is "clearly light" and a white foreground @@ -18,11 +18,12 @@ const LIGHT_TILE_THRESHOLD = 0.75 /** * True when a block's {@link BlockConfig.bgColor} tile is light enough that a - * white foreground icon would wash out. Gradients and unknown values are - * treated as dark (the common case for brand tiles). + * white foreground icon would wash out. Gradients use the average brightness + * of their supported color stops; unknown values are treated as dark. */ export function isLightTileColor(bgColor: string | null | undefined): boolean { - return Boolean(bgColor) && isLightColor(bgColor as string, LIGHT_TILE_THRESHOLD) + const brightness = bgColor ? perceivedBackgroundBrightness(bgColor) : null + return brightness !== null && brightness > LIGHT_TILE_THRESHOLD } /** diff --git a/packages/utils/src/color.test.ts b/packages/utils/src/color.test.ts index c7ca1bc950e..ebbf1729c46 100644 --- a/packages/utils/src/color.test.ts +++ b/packages/utils/src/color.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { perceivedBrightness } from './color' +import { perceivedBackgroundBrightness, perceivedBrightness } from './color' describe('perceivedBrightness', () => { it('returns 1 for white and 0 for black (hex and keywords)', () => { @@ -27,3 +27,27 @@ describe('perceivedBrightness', () => { expect((perceivedBrightness('#3B82F6') as number) < 0.6).toBe(true) }) }) + +describe('perceivedBackgroundBrightness', () => { + it('preserves solid-color brightness', () => { + expect(perceivedBackgroundBrightness('#ffffff')).toBe(1) + expect(perceivedBackgroundBrightness('#000000')).toBe(0) + }) + + it('averages supported CSS gradient stops', () => { + expect( + perceivedBackgroundBrightness('linear-gradient(180deg, #E0F7FA 0%, #FFFFFF 100%)') + ).toBeCloseTo(0.9715) + expect(perceivedBackgroundBrightness('linear-gradient(45deg, #000, #fff)')).toBe(0.5) + expect( + perceivedBackgroundBrightness('radial-gradient(circle, black, #fff, white)') + ).toBeCloseTo(2 / 3) + }) + + it('returns null for unsupported backgrounds', () => { + expect( + perceivedBackgroundBrightness('linear-gradient(45deg, currentColor, transparent)') + ).toBeNull() + expect(perceivedBackgroundBrightness('currentColor')).toBeNull() + }) +}) diff --git a/packages/utils/src/color.ts b/packages/utils/src/color.ts index e9742d24f0b..1eb6a474c30 100644 --- a/packages/utils/src/color.ts +++ b/packages/utils/src/color.ts @@ -20,6 +20,36 @@ */ export function perceivedBrightness(color: string): number | null { const value = color.trim().replace(/['"]/g, '').toLowerCase() + return parseSolidBrightness(value) +} + +/** + * Perceived brightness of a solid color or static CSS gradient background. + * Gradient brightness is the average of supported hex/black/white color stops, + * a small deterministic heuristic for choosing readable tile foregrounds + * without a browser color parser. Unsupported backgrounds return `null`. + */ +export function perceivedBackgroundBrightness(background: string): number | null { + const value = background.trim().replace(/['"]/g, '').toLowerCase() + const solidBrightness = parseSolidBrightness(value) + if (solidBrightness !== null) return solidBrightness + + if (!/^(?:repeating-)?(?:linear|radial|conic)-gradient\(/.test(value)) return null + + const colorStops = value.match(/#[0-9a-f]{6}\b|#[0-9a-f]{3}\b|\b(?:white|black)\b/g) + if (!colorStops || colorStops.length < 2) return null + + let totalBrightness = 0 + for (const colorStop of colorStops) { + const brightness = parseSolidBrightness(colorStop) + if (brightness === null) return null + totalBrightness += brightness + } + + return totalBrightness / colorStops.length +} + +function parseSolidBrightness(value: string): number | null { if (value === 'white') return 1 if (value === 'black') return 0 const hex = value.replace('#', '') diff --git a/packages/workflow-renderer/src/lib/tile-icon-color.test.ts b/packages/workflow-renderer/src/lib/tile-icon-color.test.ts new file mode 100644 index 00000000000..8542d8ef6bf --- /dev/null +++ b/packages/workflow-renderer/src/lib/tile-icon-color.test.ts @@ -0,0 +1,13 @@ +import { describe, expect, it } from 'vitest' +import { isLightTileColor } from './tile-icon-color' + +describe('isLightTileColor', () => { + it('detects light gradients that need a dark foreground', () => { + expect(isLightTileColor('linear-gradient(180deg, #E0F7FA 0%, #FFFFFF 100%)')).toBe(true) + }) + + it('keeps a light foreground on dark and mixed gradients', () => { + expect(isLightTileColor('linear-gradient(45deg, #4D27A8 0%, #A166FF 100%)')).toBe(false) + expect(isLightTileColor('linear-gradient(45deg, #000, #fff)')).toBe(false) + }) +}) diff --git a/packages/workflow-renderer/src/lib/tile-icon-color.ts b/packages/workflow-renderer/src/lib/tile-icon-color.ts index e4f12a6f812..3f633f54e96 100644 --- a/packages/workflow-renderer/src/lib/tile-icon-color.ts +++ b/packages/workflow-renderer/src/lib/tile-icon-color.ts @@ -1,4 +1,4 @@ -import { perceivedBrightness } from '@sim/utils/color' +import { perceivedBackgroundBrightness } from '@sim/utils/color' /** * Foreground class for a brand icon rendered inside its colored block tile. @@ -18,6 +18,6 @@ const LIGHT_TILE_THRESHOLD = 0.75 /** Whether a provider tile needs dark foreground content for legibility. */ export function isLightTileColor(bgColor: string | null | undefined): boolean { - const brightness = bgColor ? perceivedBrightness(bgColor) : null + const brightness = bgColor ? perceivedBackgroundBrightness(bgColor) : null return brightness !== null && brightness > LIGHT_TILE_THRESHOLD } From 3a9fef9cd2b6abdc5eff6de165e9ab8165764668 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 31 Aug 2026 19:30:06 -0700 Subject: [PATCH 2/2] fix(workflows): validate gradient color stops --- packages/utils/src/color.test.ts | 4 ++++ packages/utils/src/color.ts | 36 ++++++++++++++++++++++++++++---- 2 files changed, 36 insertions(+), 4 deletions(-) diff --git a/packages/utils/src/color.test.ts b/packages/utils/src/color.test.ts index ebbf1729c46..f05e8bb8600 100644 --- a/packages/utils/src/color.test.ts +++ b/packages/utils/src/color.test.ts @@ -48,6 +48,10 @@ describe('perceivedBackgroundBrightness', () => { expect( perceivedBackgroundBrightness('linear-gradient(45deg, currentColor, transparent)') ).toBeNull() + expect( + perceivedBackgroundBrightness('linear-gradient(45deg, #fff, rebeccapurple, #000)') + ).toBeNull() + expect(perceivedBackgroundBrightness('linear-gradient(rebeccapurple, #fff, #000)')).toBeNull() expect(perceivedBackgroundBrightness('currentColor')).toBeNull() }) }) diff --git a/packages/utils/src/color.ts b/packages/utils/src/color.ts index 1eb6a474c30..88648efaae9 100644 --- a/packages/utils/src/color.ts +++ b/packages/utils/src/color.ts @@ -34,14 +34,23 @@ export function perceivedBackgroundBrightness(background: string): number | null const solidBrightness = parseSolidBrightness(value) if (solidBrightness !== null) return solidBrightness - if (!/^(?:repeating-)?(?:linear|radial|conic)-gradient\(/.test(value)) return null + const gradient = value.match(/^(?:repeating-)?(linear|radial|conic)-gradient\((.*)\)$/) + if (!gradient) return null - const colorStops = value.match(/#[0-9a-f]{6}\b|#[0-9a-f]{3}\b|\b(?:white|black)\b/g) - if (!colorStops || colorStops.length < 2) return null + const [, gradientType, contents] = gradient + const parts = contents.split(',').map((part) => part.trim()) + const firstStop = parseSupportedColorStop(parts[0]) + const colorStops = firstStop === null ? parts.slice(1) : parts + if ( + colorStops.length < 2 || + (firstStop === null && !isSupportedGradientPreamble(gradientType, parts[0])) + ) { + return null + } let totalBrightness = 0 for (const colorStop of colorStops) { - const brightness = parseSolidBrightness(colorStop) + const brightness = parseSupportedColorStop(colorStop) if (brightness === null) return null totalBrightness += brightness } @@ -49,6 +58,25 @@ export function perceivedBackgroundBrightness(background: string): number | null return totalBrightness / colorStops.length } +function parseSupportedColorStop(value: string): number | null { + const match = value.match(/^(#[0-9a-f]{6}\b|#[0-9a-f]{3}\b|(?:white|black)\b)(?:\s|$)/) + return match ? parseSolidBrightness(match[1]) : null +} + +function isSupportedGradientPreamble(type: string, value: string): boolean { + if (type === 'linear') { + return /^(?:-?(?:\d+(?:\.\d+)?|\.\d+)(?:deg|grad|rad|turn)|to\s+(?:top|right|bottom|left)(?:\s+(?:top|right|bottom|left))?)$/.test( + value + ) + } + if (type === 'radial') { + return /^(?:(?:circle|ellipse|closest-side|closest-corner|farthest-side|farthest-corner|at)\b|-?(?:\d|\.\d))/.test( + value + ) + } + return /^(?:from|at)\b/.test(value) +} + function parseSolidBrightness(value: string): number | null { if (value === 'white') return 1 if (value === 'black') return 0