Skip to content

Commit fc3126b

Browse files
committed
fix(workflows): preserve contrast on gradient tiles
1 parent 2173a70 commit fc3126b

6 files changed

Lines changed: 77 additions & 8 deletions

File tree

apps/sim/blocks/icon-color.test.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,10 @@ describe('isLightTileColor', () => {
1818
expect(isLightTileColor('#B2C147')).toBe(false)
1919
})
2020

21-
it('treats dark tiles, gradients, and empty values as dark', () => {
21+
it('uses gradient stops while keeping dark and empty values dark', () => {
2222
expect(isLightTileColor('#171717')).toBe(false)
2323
expect(isLightTileColor('#9B5CFF')).toBe(false)
24+
expect(isLightTileColor('linear-gradient(180deg, #E0F7FA 0%, #FFFFFF 100%)')).toBe(true)
2425
expect(isLightTileColor('linear-gradient(45deg, #fff, #000)')).toBe(false)
2526
expect(isLightTileColor(null)).toBe(false)
2627
expect(isLightTileColor(undefined)).toBe(false)

apps/sim/blocks/icon-color.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
* these without pulling 282 block configs and the tool registry into its bundle.
55
* Registry-backed icon styling lives in `@/blocks/brand-icon`.
66
*/
7-
import { isLightColor } from '@/lib/colors'
7+
import { perceivedBackgroundBrightness } from '@sim/utils/color'
88

99
/**
1010
* Brightness above which a brand tile is "clearly light" and a white foreground
@@ -18,11 +18,12 @@ const LIGHT_TILE_THRESHOLD = 0.75
1818

1919
/**
2020
* True when a block's {@link BlockConfig.bgColor} tile is light enough that a
21-
* white foreground icon would wash out. Gradients and unknown values are
22-
* treated as dark (the common case for brand tiles).
21+
* white foreground icon would wash out. Gradients use the average brightness
22+
* of their supported color stops; unknown values are treated as dark.
2323
*/
2424
export function isLightTileColor(bgColor: string | null | undefined): boolean {
25-
return Boolean(bgColor) && isLightColor(bgColor as string, LIGHT_TILE_THRESHOLD)
25+
const brightness = bgColor ? perceivedBackgroundBrightness(bgColor) : null
26+
return brightness !== null && brightness > LIGHT_TILE_THRESHOLD
2627
}
2728

2829
/**

packages/utils/src/color.test.ts

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { describe, expect, it } from 'vitest'
2-
import { perceivedBrightness } from './color'
2+
import { perceivedBackgroundBrightness, perceivedBrightness } from './color'
33

44
describe('perceivedBrightness', () => {
55
it('returns 1 for white and 0 for black (hex and keywords)', () => {
@@ -27,3 +27,27 @@ describe('perceivedBrightness', () => {
2727
expect((perceivedBrightness('#3B82F6') as number) < 0.6).toBe(true)
2828
})
2929
})
30+
31+
describe('perceivedBackgroundBrightness', () => {
32+
it('preserves solid-color brightness', () => {
33+
expect(perceivedBackgroundBrightness('#ffffff')).toBe(1)
34+
expect(perceivedBackgroundBrightness('#000000')).toBe(0)
35+
})
36+
37+
it('averages supported CSS gradient stops', () => {
38+
expect(
39+
perceivedBackgroundBrightness('linear-gradient(180deg, #E0F7FA 0%, #FFFFFF 100%)')
40+
).toBeCloseTo(0.9715)
41+
expect(perceivedBackgroundBrightness('linear-gradient(45deg, #000, #fff)')).toBe(0.5)
42+
expect(
43+
perceivedBackgroundBrightness('radial-gradient(circle, black, #fff, white)')
44+
).toBeCloseTo(2 / 3)
45+
})
46+
47+
it('returns null for unsupported backgrounds', () => {
48+
expect(
49+
perceivedBackgroundBrightness('linear-gradient(45deg, currentColor, transparent)')
50+
).toBeNull()
51+
expect(perceivedBackgroundBrightness('currentColor')).toBeNull()
52+
})
53+
})

packages/utils/src/color.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,36 @@
2020
*/
2121
export function perceivedBrightness(color: string): number | null {
2222
const value = color.trim().replace(/['"]/g, '').toLowerCase()
23+
return parseSolidBrightness(value)
24+
}
25+
26+
/**
27+
* Perceived brightness of a solid color or static CSS gradient background.
28+
* Gradient brightness is the average of supported hex/black/white color stops,
29+
* a small deterministic heuristic for choosing readable tile foregrounds
30+
* without a browser color parser. Unsupported backgrounds return `null`.
31+
*/
32+
export function perceivedBackgroundBrightness(background: string): number | null {
33+
const value = background.trim().replace(/['"]/g, '').toLowerCase()
34+
const solidBrightness = parseSolidBrightness(value)
35+
if (solidBrightness !== null) return solidBrightness
36+
37+
if (!/^(?:repeating-)?(?:linear|radial|conic)-gradient\(/.test(value)) return null
38+
39+
const colorStops = value.match(/#[0-9a-f]{6}\b|#[0-9a-f]{3}\b|\b(?:white|black)\b/g)
40+
if (!colorStops || colorStops.length < 2) return null
41+
42+
let totalBrightness = 0
43+
for (const colorStop of colorStops) {
44+
const brightness = parseSolidBrightness(colorStop)
45+
if (brightness === null) return null
46+
totalBrightness += brightness
47+
}
48+
49+
return totalBrightness / colorStops.length
50+
}
51+
52+
function parseSolidBrightness(value: string): number | null {
2353
if (value === 'white') return 1
2454
if (value === 'black') return 0
2555
const hex = value.replace('#', '')
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import { describe, expect, it } from 'vitest'
2+
import { isLightTileColor } from './tile-icon-color'
3+
4+
describe('isLightTileColor', () => {
5+
it('detects light gradients that need a dark foreground', () => {
6+
expect(isLightTileColor('linear-gradient(180deg, #E0F7FA 0%, #FFFFFF 100%)')).toBe(true)
7+
})
8+
9+
it('keeps a light foreground on dark and mixed gradients', () => {
10+
expect(isLightTileColor('linear-gradient(45deg, #4D27A8 0%, #A166FF 100%)')).toBe(false)
11+
expect(isLightTileColor('linear-gradient(45deg, #000, #fff)')).toBe(false)
12+
})
13+
})

packages/workflow-renderer/src/lib/tile-icon-color.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { perceivedBrightness } from '@sim/utils/color'
1+
import { perceivedBackgroundBrightness } from '@sim/utils/color'
22

33
/**
44
* Foreground class for a brand icon rendered inside its colored block tile.
@@ -18,6 +18,6 @@ const LIGHT_TILE_THRESHOLD = 0.75
1818

1919
/** Whether a provider tile needs dark foreground content for legibility. */
2020
export function isLightTileColor(bgColor: string | null | undefined): boolean {
21-
const brightness = bgColor ? perceivedBrightness(bgColor) : null
21+
const brightness = bgColor ? perceivedBackgroundBrightness(bgColor) : null
2222
return brightness !== null && brightness > LIGHT_TILE_THRESHOLD
2323
}

0 commit comments

Comments
 (0)