Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion apps/sim/blocks/icon-color.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
9 changes: 5 additions & 4 deletions apps/sim/blocks/icon-color.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
}

/**
Expand Down
30 changes: 29 additions & 1 deletion packages/utils/src/color.test.ts
Original file line number Diff line number Diff line change
@@ -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)', () => {
Expand Down Expand Up @@ -27,3 +27,31 @@ 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('linear-gradient(45deg, #fff, rebeccapurple, #000)')
).toBeNull()
expect(perceivedBackgroundBrightness('linear-gradient(rebeccapurple, #fff, #000)')).toBeNull()
expect(perceivedBackgroundBrightness('currentColor')).toBeNull()
})
})
58 changes: 58 additions & 0 deletions packages/utils/src/color.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,64 @@
*/
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

const gradient = value.match(/^(?:repeating-)?(linear|radial|conic)-gradient\((.*)\)$/)
if (!gradient) 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 = parseSupportedColorStop(colorStop)
if (brightness === null) return null
totalBrightness += brightness
}

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
const hex = value.replace('#', '')
Expand Down
13 changes: 13 additions & 0 deletions packages/workflow-renderer/src/lib/tile-icon-color.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
4 changes: 2 additions & 2 deletions packages/workflow-renderer/src/lib/tile-icon-color.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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
}
Loading