Skip to content

Commit 488a4ac

Browse files
committed
fix(icons): render brand icons legibly when bare and on light tiles
Monochrome brand icons hardcoded a single white or black fill matched to their colored tile, so they vanished when rendered bare on the home Suggested actions list (white-on-white in light mode, black-on-black in dark mode). Convert those marks to currentColor so they adapt to context, and make tile foregrounds contrast-aware via getTileIconColorClass instead of a hardcoded text-white. Also centralize all color math in apps/sim/lib/colors (perceived brightness, hex/rgb/hsl conversion, contrast-text) and route every consumer through it: the bare-icon audit, block tiles, logs trace view, whitelabeling theming, workspace presence, and the PPTX renderer no longer carry duplicate copies. Adds a bare-icon CI audit (scripts/check-bare-icons.ts) and authoring guidance.
1 parent c7acef2 commit 488a4ac

42 files changed

Lines changed: 789 additions & 265 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.claude/commands/add-block.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -732,6 +732,13 @@ Please provide the SVG and I'll convert it to a React component.
732732
You can usually find this in the service's brand/press kit page, or copy it from their website.
733733
```
734734

735+
When converting the SVG: a **monochrome** logo (single white or black mark) must
736+
use `fill='currentColor'`, never a hardcoded `#fff`/`#000000`. Block icons render
737+
both inside their `bgColor` tile and "bare" on a neutral page (the home Suggested
738+
actions list) in light and dark mode; a hardcoded white/black mark goes invisible
739+
bare on the matching background. Multi-color brand logos keep their own fills.
740+
Verify with `bun run check:bare-icons`.
741+
735742
## Advanced Mode for Optional Fields
736743

737744
Optional fields that are rarely used should be set to `mode: 'advanced'` so they don't clutter the basic UI. This includes:

.claude/commands/add-integration.md

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -279,6 +279,31 @@ Once the user provides the SVG:
279279
2. Create a React component that spreads props
280280
3. Ensure viewBox is preserved from the original SVG
281281

282+
### Theme-safety (bare rendering) — REQUIRED
283+
284+
The icon renders both inside its colored `bgColor` tile AND "bare" (no tile) on a
285+
neutral page — e.g. the home **Suggested actions** list — in both light and dark
286+
mode. A monochrome logo whose paths hardcode a single near-white or near-black
287+
fill is invisible bare on the matching background (white-on-white in light mode,
288+
black-on-black in dark mode).
289+
290+
Rules when adding the SVG:
291+
292+
- **Monochrome logos** (a single white or black mark): draw the shape with
293+
`fill='currentColor'`, not `fill='#fff'` / `fill='#000000'`. It then inherits
294+
white inside dark tiles, near-black inside light tiles (via
295+
`getTileIconColorClass`), and the theme-aware `var(--text-icon)` bare — legible
296+
everywhere. Do NOT set `iconColor` for these.
297+
- **Multi-color brand logos** (their own vivid fills): keep the hardcoded fills.
298+
They read on any background. Only set `iconColor` (a vivid brand hex, never a
299+
near-black/near-white tile color) if the bare icon should adopt a brand tint.
300+
- A large white shape with a tiny vivid accent (e.g. a logo where the body is the
301+
white negative space) still vanishes bare — convert the body to `currentColor`.
302+
303+
Verify with `bun run check:bare-icons` (also runs in CI). It flags purely
304+
monochrome hazards; for partial-accent logos, eyeball the suggested-actions list
305+
in both light and dark mode.
306+
282307
## Step 5: Create Triggers (Optional)
283308

284309
If the service supports webhooks, create triggers using the generic `buildTriggerSubBlocks` helper.
@@ -466,6 +491,7 @@ If creating V2 versions (API-aligned outputs):
466491
- [ ] Asked user to provide SVG
467492
- [ ] Added icon to `components/icons.tsx`
468493
- [ ] Icon spreads props correctly
494+
- [ ] Monochrome marks use `fill='currentColor'` (not hardcoded white/black) so the icon renders bare in light AND dark mode — verified with `bun run check:bare-icons`
469495

470496
### Triggers (if service supports webhooks)
471497
- [ ] Created `triggers/{service}/` directory

.github/workflows/test-build.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,9 @@ jobs:
125125
- name: Client boundary import audit
126126
run: bun run check:client-boundary
127127

128+
- name: Bare-icon theme-safety audit
129+
run: bun run check:bare-icons
130+
128131
- name: Verify realtime prune graph
129132
run: bun run check:realtime-prune
130133

apps/sim/app/(landing)/components/landing-preview/components/landing-preview-workflow/preview-block-node.tsx

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ import {
4040
EASE_OUT,
4141
type PreviewTool,
4242
} from '@/app/(landing)/components/landing-preview/components/landing-preview-workflow/workflow-data'
43+
import { getTileIconColorClass } from '@/blocks/icon-color'
4344

4445
/** Map block type strings to their icon components. */
4546
const BLOCK_ICONS: Record<string, React.ComponentType<{ className?: string }>> = {
@@ -195,7 +196,7 @@ export const PreviewBlockNode = memo(function PreviewBlockNode({
195196
className='flex size-[24px] flex-shrink-0 items-center justify-center rounded-[6px]'
196197
style={{ background: bgColor }}
197198
>
198-
{Icon && <Icon className='size-[16px] text-white' />}
199+
{Icon && <Icon className={`size-[16px] ${getTileIconColorClass(bgColor)}`} />}
199200
</div>
200201
<span className='truncate font-medium text-[16px] text-[var(--text-primary)]'>
201202
{name}
@@ -246,7 +247,11 @@ export const PreviewBlockNode = memo(function PreviewBlockNode({
246247
className='flex size-[16px] flex-shrink-0 items-center justify-center rounded-[4px]'
247248
style={{ background: tool.bgColor }}
248249
>
249-
{ToolIcon && <ToolIcon className='size-[10px] text-white' />}
250+
{ToolIcon && (
251+
<ToolIcon
252+
className={`size-[10px] ${getTileIconColorClass(tool.bgColor)}`}
253+
/>
254+
)}
250255
</div>
251256
<span className='font-normal text-[12px] text-[var(--text-primary)]'>
252257
{tool.name}

apps/sim/app/(landing)/integrations/components/integration-icon.tsx

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { ComponentType, ElementType, HTMLAttributes, SVGProps } from 'react'
22
import { cn } from '@sim/emcn'
3+
import { getTileIconColorClass } from '@/blocks/icon-color'
34

45
interface IntegrationIconProps extends HTMLAttributes<HTMLElement> {
56
bgColor: string
@@ -39,9 +40,11 @@ export function IntegrationIcon({
3940
{...rest}
4041
>
4142
{Icon ? (
42-
<Icon className={cn(iconClassName, 'text-white')} />
43+
<Icon className={cn(iconClassName, getTileIconColorClass(bgColor))} />
4344
) : (
44-
<span className={cn('text-white leading-none', fallbackClassName)}>{name.charAt(0)}</span>
45+
<span className={cn('leading-none', getTileIconColorClass(bgColor), fallbackClassName)}>
46+
{name.charAt(0)}
47+
</span>
4548
)}
4649
</Tag>
4750
)

apps/sim/app/workspace/[workspaceId]/integrations/[block]/integration-block-detail.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import { ConnectServiceAccountModal } from '@/app/workspace/[workspaceId]/integr
1919
import { IntegrationSection } from '@/app/workspace/[workspaceId]/integrations/components/integration-section'
2020
import { IntegrationTile } from '@/app/workspace/[workspaceId]/integrations/components/integrations-showcase'
2121
import { CONNECT_MODE } from '@/app/workspace/[workspaceId]/integrations/connect-route'
22+
import { getTileIconColorClass } from '@/blocks/icon-color'
2223
import { storeCuratedPrompt } from '@/blocks/integration-matcher'
2324
import {
2425
getSuggestedSkillsForBlock,
@@ -176,7 +177,10 @@ export function IntegrationBlockDetail({ integration, workspaceId }: Integration
176177
<IntegrationTile blockType={integration.type} icon={Icon} />
177178
) : (
178179
<div
179-
className='flex size-9 flex-shrink-0 items-center justify-center rounded-xl border border-[var(--border-1)] text-white'
180+
className={cn(
181+
'flex size-9 flex-shrink-0 items-center justify-center rounded-xl border border-[var(--border-1)]',
182+
getTileIconColorClass(integration.bgColor)
183+
)}
180184
style={{ background: integration.bgColor }}
181185
>
182186
{integration.name.charAt(0)}

apps/sim/app/workspace/[workspaceId]/integrations/components/integrations-showcase/integrations-showcase.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import type { ComponentType } from 'react'
2+
import { cn } from '@sim/emcn'
23
import { getBlock } from '@/blocks'
4+
import { getTileIconColorClass } from '@/blocks/icon-color'
35

46
/**
57
* URL-encoded SVG used as a mask to carve the bottom-right notch out of the
@@ -72,7 +74,7 @@ export function IntegrationTile({ blockType, icon: Icon, framed = false }: Integ
7274
className='flex size-full items-center justify-center rounded-xl border border-[var(--border-1)] bg-[var(--bg)]'
7375
style={brandBg ? { background: brandBg } : undefined}
7476
>
75-
<Icon className='size-5 text-white' />
77+
<Icon className={cn('size-5', getTileIconColorClass(brandBg))} />
7678
</div>
7779
</div>
7880
)
@@ -84,7 +86,7 @@ export function IntegrationTile({ blockType, icon: Icon, framed = false }: Integ
8486
className='flex size-full items-center justify-center rounded-[9px] border border-[var(--border-1)] bg-[var(--bg)]'
8587
style={brandBg ? { background: brandBg } : undefined}
8688
>
87-
<Icon className='size-6 text-white' />
89+
<Icon className={cn('size-6', getTileIconColorClass(brandBg))} />
8890
</div>
8991
</div>
9092
)

apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/add-connector-modal/add-connector-modal.tsx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ import { MaxBadge } from '@/app/workspace/[workspaceId]/knowledge/[id]/component
3636
import { useConnectorConfigFields } from '@/app/workspace/[workspaceId]/knowledge/[id]/hooks/use-connector-config-fields'
3737
import { isBillingEnabled } from '@/app/workspace/[workspaceId]/settings/navigation'
3838
import { getBlock } from '@/blocks'
39+
import { getTileIconColorClass } from '@/blocks/icon-color'
3940
import { CONNECTOR_META_REGISTRY } from '@/connectors/registry'
4041
import type { ConnectorMeta } from '@/connectors/types'
4142
import { useCreateConnector } from '@/hooks/queries/kb/connectors'
@@ -477,7 +478,12 @@ function ConnectorTypeCard({ type, config, onClick }: ConnectorTypeCardProps) {
477478
)}
478479
style={brandBg ? { background: brandBg } : undefined}
479480
>
480-
<Icon className={cn('size-5', brandBg ? 'text-white' : 'text-[var(--text-icon)]')} />
481+
<Icon
482+
className={cn(
483+
'size-5',
484+
brandBg ? getTileIconColorClass(brandBg) : 'text-[var(--text-icon)]'
485+
)}
486+
/>
481487
</div>
482488
</div>
483489
<div className='flex min-w-0 flex-1 flex-col'>

apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connectors-section/connectors-section.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import { getMissingRequiredScopes } from '@/lib/oauth/utils'
2222
import { ConnectOAuthModal } from '@/app/workspace/[workspaceId]/components/connect-oauth-modal'
2323
import { EditConnectorModal } from '@/app/workspace/[workspaceId]/knowledge/[id]/components/edit-connector-modal/edit-connector-modal'
2424
import { getBlock } from '@/blocks'
25+
import { getTileIconColorClass } from '@/blocks/icon-color'
2526
import { CONNECTOR_META_REGISTRY } from '@/connectors/registry'
2627
import type { ConnectorData, SyncLogData } from '@/hooks/queries/kb/connectors'
2728
import {
@@ -346,7 +347,10 @@ function ConnectorCard({
346347
>
347348
{Icon && (
348349
<Icon
349-
className={cn('size-5', brandBg ? 'text-white' : 'text-[var(--text-icon)]')}
350+
className={cn(
351+
'size-5',
352+
brandBg ? getTileIconColorClass(brandBg) : 'text-[var(--text-icon)]'
353+
)}
350354
/>
351355
)}
352356
</div>

apps/sim/app/workspace/[workspaceId]/logs/components/log-details/utils.ts

Lines changed: 13 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type React from 'react'
22
import { AgentSkillsIcon, WorkflowIcon } from '@/components/icons'
33
import { formatCreditCost } from '@/lib/billing/credits/conversion'
4+
import { perceivedBrightness } from '@/lib/colors'
45
import type { TraceSpan } from '@/lib/logs/types'
56
import { LoopTool } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/loop/loop-config'
67
import { ParallelTool } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/parallel/parallel-config'
@@ -81,29 +82,27 @@ export function getBlockIconAndColor(
8182
return { icon: null, bgColor: DEFAULT_BLOCK_COLOR }
8283
}
8384

85+
/**
86+
* Max YIQ weighted sum (255 × (0.299 + 0.587 + 0.114) × 1000). `perceivedBrightness`
87+
* is that sum normalized to 0–1, so the original integer cutoffs map exactly to
88+
* `cutoff / MAX_YIQ_SUM` here.
89+
*/
90+
const MAX_YIQ_SUM = 255_000
91+
8492
/** Returns 'text-white' for dark backgrounds, dark text for light ones. */
8593
export function iconColorClass(bgColor: string): string {
86-
const hex = bgColor.replace('#', '')
87-
if (hex.length !== 6) return 'text-white'
88-
const r = Number.parseInt(hex.slice(0, 2), 16)
89-
const g = Number.parseInt(hex.slice(2, 4), 16)
90-
const b = Number.parseInt(hex.slice(4, 6), 16)
91-
return r * 299 + g * 587 + b * 114 > 160_000 ? 'text-[#111111]' : 'text-white'
94+
const brightness = perceivedBrightness(bgColor)
95+
return brightness !== null && brightness > 160_000 / MAX_YIQ_SUM ? 'text-[#111111]' : 'text-white'
9296
}
9397

9498
/**
9599
* Near-black bgColors disappear against the dark-mode surface (--bg: #1b1b1b).
96-
* Below the luminance threshold we fall back to the neutral block color used
100+
* Below the brightness threshold we fall back to the neutral block color used
97101
* for blocks with no distinct identity; everything brighter passes through.
98102
*/
99103
export function adjustBgForContrast(bgColor: string): string {
100-
const hex = bgColor.replace('#', '')
101-
if (hex.length !== 6) return bgColor
102-
const r = Number.parseInt(hex.slice(0, 2), 16)
103-
const g = Number.parseInt(hex.slice(2, 4), 16)
104-
const b = Number.parseInt(hex.slice(4, 6), 16)
105-
if (r * 299 + g * 587 + b * 114 < 30_000) return DEFAULT_BLOCK_COLOR
106-
return bgColor
104+
const brightness = perceivedBrightness(bgColor)
105+
return brightness !== null && brightness < 30_000 / MAX_YIQ_SUM ? DEFAULT_BLOCK_COLOR : bgColor
107106
}
108107

109108
export function parseTime(value?: string | number | null): number {

0 commit comments

Comments
 (0)