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
7 changes: 7 additions & 0 deletions .claude/commands/add-block.md
Original file line number Diff line number Diff line change
Expand Up @@ -732,6 +732,13 @@ Please provide the SVG and I'll convert it to a React component.
You can usually find this in the service's brand/press kit page, or copy it from their website.
```

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

## Advanced Mode for Optional Fields

Optional fields that are rarely used should be set to `mode: 'advanced'` so they don't clutter the basic UI. This includes:
Expand Down
26 changes: 26 additions & 0 deletions .claude/commands/add-integration.md
Original file line number Diff line number Diff line change
Expand Up @@ -279,6 +279,31 @@ Once the user provides the SVG:
2. Create a React component that spreads props
3. Ensure viewBox is preserved from the original SVG

### Theme-safety (bare rendering) — REQUIRED

The icon renders both inside its colored `bgColor` tile AND "bare" (no tile) on a
neutral page — e.g. the home **Suggested actions** list — in both light and dark
mode. A monochrome logo whose paths hardcode a single near-white or near-black
fill is invisible bare on the matching background (white-on-white in light mode,
black-on-black in dark mode).

Rules when adding the SVG:

- **Monochrome logos** (a single white or black mark): draw the shape with
`fill='currentColor'`, not `fill='#fff'` / `fill='#000000'`. It then inherits
white inside dark tiles, near-black inside light tiles (via
`getTileIconColorClass`), and the theme-aware `var(--text-icon)` bare — legible
everywhere. Do NOT set `iconColor` for these.
- **Multi-color brand logos** (their own vivid fills): keep the hardcoded fills.
They read on any background. Only set `iconColor` (a vivid brand hex, never a
near-black/near-white tile color) if the bare icon should adopt a brand tint.
- A large white shape with a tiny vivid accent (e.g. a logo where the body is the
white negative space) still vanishes bare — convert the body to `currentColor`.

Verify with `bun run check:bare-icons` (also runs in CI). It flags purely
monochrome hazards; for partial-accent logos, eyeball the suggested-actions list
in both light and dark mode.

## Step 5: Create Triggers (Optional)

If the service supports webhooks, create triggers using the generic `buildTriggerSubBlocks` helper.
Expand Down Expand Up @@ -466,6 +491,7 @@ If creating V2 versions (API-aligned outputs):
- [ ] Asked user to provide SVG
- [ ] Added icon to `components/icons.tsx`
- [ ] Icon spreads props correctly
- [ ] 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`

### Triggers (if service supports webhooks)
- [ ] Created `triggers/{service}/` directory
Expand Down
3 changes: 3 additions & 0 deletions .github/workflows/test-build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,9 @@ jobs:
- name: Client boundary import audit
run: bun run check:client-boundary

- name: Bare-icon theme-safety audit
run: bun run check:bare-icons

- name: Verify realtime prune graph
run: bun run check:realtime-prune

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ import {
EASE_OUT,
type PreviewTool,
} from '@/app/(landing)/components/landing-preview/components/landing-preview-workflow/workflow-data'
import { getTileIconColorClass } from '@/blocks/icon-color'

/** Map block type strings to their icon components. */
const BLOCK_ICONS: Record<string, React.ComponentType<{ className?: string }>> = {
Expand Down Expand Up @@ -195,7 +196,7 @@ export const PreviewBlockNode = memo(function PreviewBlockNode({
className='flex size-[24px] flex-shrink-0 items-center justify-center rounded-[6px]'
style={{ background: bgColor }}
>
{Icon && <Icon className='size-[16px] text-white' />}
{Icon && <Icon className={`size-[16px] ${getTileIconColorClass(bgColor)}`} />}
</div>
<span className='truncate font-medium text-[16px] text-[var(--text-primary)]'>
{name}
Expand Down Expand Up @@ -246,7 +247,11 @@ export const PreviewBlockNode = memo(function PreviewBlockNode({
className='flex size-[16px] flex-shrink-0 items-center justify-center rounded-[4px]'
style={{ background: tool.bgColor }}
>
{ToolIcon && <ToolIcon className='size-[10px] text-white' />}
{ToolIcon && (
<ToolIcon
className={`size-[10px] ${getTileIconColorClass(tool.bgColor)}`}
/>
)}
</div>
<span className='font-normal text-[12px] text-[var(--text-primary)]'>
{tool.name}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { ComponentType, ElementType, HTMLAttributes, SVGProps } from 'react'
import { cn } from '@sim/emcn'
import { getTileIconColorClass } from '@/blocks/icon-color'

interface IntegrationIconProps extends HTMLAttributes<HTMLElement> {
bgColor: string
Expand Down Expand Up @@ -39,9 +40,11 @@ export function IntegrationIcon({
{...rest}
>
{Icon ? (
<Icon className={cn(iconClassName, 'text-white')} />
<Icon className={cn(iconClassName, getTileIconColorClass(bgColor))} />
) : (
<span className={cn('text-white leading-none', fallbackClassName)}>{name.charAt(0)}</span>
<span className={cn('leading-none', getTileIconColorClass(bgColor), fallbackClassName)}>
{name.charAt(0)}
</span>
)}
</Tag>
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { ConnectServiceAccountModal } from '@/app/workspace/[workspaceId]/integr
import { IntegrationSection } from '@/app/workspace/[workspaceId]/integrations/components/integration-section'
import { IntegrationTile } from '@/app/workspace/[workspaceId]/integrations/components/integrations-showcase'
import { CONNECT_MODE } from '@/app/workspace/[workspaceId]/integrations/connect-route'
import { getTileIconColorClass } from '@/blocks/icon-color'
import { storeCuratedPrompt } from '@/blocks/integration-matcher'
import {
getSuggestedSkillsForBlock,
Expand Down Expand Up @@ -176,7 +177,10 @@ export function IntegrationBlockDetail({ integration, workspaceId }: Integration
<IntegrationTile blockType={integration.type} icon={Icon} />
) : (
<div
className='flex size-9 flex-shrink-0 items-center justify-center rounded-xl border border-[var(--border-1)] text-white'
className={cn(
'flex size-9 flex-shrink-0 items-center justify-center rounded-xl border border-[var(--border-1)]',
getTileIconColorClass(integration.bgColor)
)}
style={{ background: integration.bgColor }}
>
{integration.name.charAt(0)}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import type { ComponentType } from 'react'
import { cn } from '@sim/emcn'
import { getBlock } from '@/blocks'
import { getTileIconColorClass } from '@/blocks/icon-color'

/**
* URL-encoded SVG used as a mask to carve the bottom-right notch out of the
Expand Down Expand Up @@ -72,7 +74,7 @@ export function IntegrationTile({ blockType, icon: Icon, framed = false }: Integ
className='flex size-full items-center justify-center rounded-xl border border-[var(--border-1)] bg-[var(--bg)]'
style={brandBg ? { background: brandBg } : undefined}
>
<Icon className='size-5 text-white' />
<Icon className={cn('size-5', getTileIconColorClass(brandBg))} />
</div>
</div>
)
Expand All @@ -84,7 +86,7 @@ export function IntegrationTile({ blockType, icon: Icon, framed = false }: Integ
className='flex size-full items-center justify-center rounded-[9px] border border-[var(--border-1)] bg-[var(--bg)]'
style={brandBg ? { background: brandBg } : undefined}
>
<Icon className='size-6 text-white' />
<Icon className={cn('size-6', getTileIconColorClass(brandBg))} />
</div>
</div>
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import { MaxBadge } from '@/app/workspace/[workspaceId]/knowledge/[id]/component
import { useConnectorConfigFields } from '@/app/workspace/[workspaceId]/knowledge/[id]/hooks/use-connector-config-fields'
import { isBillingEnabled } from '@/app/workspace/[workspaceId]/settings/navigation'
import { getBlock } from '@/blocks'
import { getTileIconColorClass } from '@/blocks/icon-color'
import { CONNECTOR_META_REGISTRY } from '@/connectors/registry'
import type { ConnectorMeta } from '@/connectors/types'
import { useCreateConnector } from '@/hooks/queries/kb/connectors'
Expand Down Expand Up @@ -477,7 +478,12 @@ function ConnectorTypeCard({ type, config, onClick }: ConnectorTypeCardProps) {
)}
style={brandBg ? { background: brandBg } : undefined}
>
<Icon className={cn('size-5', brandBg ? 'text-white' : 'text-[var(--text-icon)]')} />
<Icon
className={cn(
'size-5',
brandBg ? getTileIconColorClass(brandBg) : 'text-[var(--text-icon)]'
)}
/>
</div>
</div>
<div className='flex min-w-0 flex-1 flex-col'>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import { getMissingRequiredScopes } from '@/lib/oauth/utils'
import { ConnectOAuthModal } from '@/app/workspace/[workspaceId]/components/connect-oauth-modal'
import { EditConnectorModal } from '@/app/workspace/[workspaceId]/knowledge/[id]/components/edit-connector-modal/edit-connector-modal'
import { getBlock } from '@/blocks'
import { getTileIconColorClass } from '@/blocks/icon-color'
import { CONNECTOR_META_REGISTRY } from '@/connectors/registry'
import type { ConnectorData, SyncLogData } from '@/hooks/queries/kb/connectors'
import {
Expand Down Expand Up @@ -346,7 +347,10 @@ function ConnectorCard({
>
{Icon && (
<Icon
className={cn('size-5', brandBg ? 'text-white' : 'text-[var(--text-icon)]')}
className={cn(
'size-5',
brandBg ? getTileIconColorClass(brandBg) : 'text-[var(--text-icon)]'
)}
/>
)}
</div>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type React from 'react'
import { AgentSkillsIcon, WorkflowIcon } from '@/components/icons'
import { formatCreditCost } from '@/lib/billing/credits/conversion'
import { perceivedBrightness } from '@/lib/colors'
import type { TraceSpan } from '@/lib/logs/types'
import { LoopTool } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/loop/loop-config'
import { ParallelTool } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/parallel/parallel-config'
Expand Down Expand Up @@ -81,29 +82,27 @@ export function getBlockIconAndColor(
return { icon: null, bgColor: DEFAULT_BLOCK_COLOR }
}

/**
* Max YIQ weighted sum (255 × (0.299 + 0.587 + 0.114) × 1000). `perceivedBrightness`
* is that sum normalized to 0–1, so the original integer cutoffs map exactly to
* `cutoff / MAX_YIQ_SUM` here.
*/
const MAX_YIQ_SUM = 255_000

/** Returns 'text-white' for dark backgrounds, dark text for light ones. */
export function iconColorClass(bgColor: string): string {
const hex = bgColor.replace('#', '')
if (hex.length !== 6) return 'text-white'
const r = Number.parseInt(hex.slice(0, 2), 16)
const g = Number.parseInt(hex.slice(2, 4), 16)
const b = Number.parseInt(hex.slice(4, 6), 16)
return r * 299 + g * 587 + b * 114 > 160_000 ? 'text-[#111111]' : 'text-white'
const brightness = perceivedBrightness(bgColor)
return brightness !== null && brightness > 160_000 / MAX_YIQ_SUM ? 'text-[#111111]' : 'text-white'
}

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

export function parseTime(value?: string | number | null): number {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ import {
} from '@/app/workspace/[workspaceId]/tables/[tableId]/components/sidebar-fields'
import { PreviewWorkflow } from '@/app/workspace/[workspaceId]/w/components/preview'
import { getBlock } from '@/blocks'
import { getTileIconColorClass } from '@/blocks/icon-color'
import {
useAddWorkflowGroup,
useUpdateColumn,
Expand Down Expand Up @@ -175,11 +176,11 @@ const TagIcon: React.FC<{
style={{ background: color }}
>
{typeof icon === 'string' ? (
<span className='!text-white font-bold text-micro'>{icon}</span>
<span className={cn(getTileIconColorClass(color, true), 'font-bold text-micro')}>{icon}</span>
) : (
(() => {
const IconComponent = icon
return <IconComponent className='!text-white size-[9px]' />
return <IconComponent className={cn(getTileIconColorClass(color, true), 'size-[9px]')} />
})()
)}
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
flattenWorkflowOutputs,
} from '@/lib/workflows/blocks/flatten-outputs'
import { getBlock } from '@/blocks'
import { getTileIconColorClass } from '@/blocks/icon-color'
import { normalizeName } from '@/executor/constants'
import { useWorkflowDiffStore } from '@/stores/workflow-diff/store'
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
Expand All @@ -31,11 +32,11 @@ const TagIcon: React.FC<{
style={{ background: color }}
>
{typeof icon === 'string' ? (
<span className='!text-white font-bold text-micro'>{icon}</span>
<span className={cn(getTileIconColorClass(color, true), 'font-bold text-micro')}>{icon}</span>
) : (
(() => {
const IconComponent = icon
return <IconComponent className='!text-white size-[9px]' />
return <IconComponent className={cn(getTileIconColorClass(color, true), 'size-[9px]')} />
})()
)}
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/connection-blocks/components/field-item/field-item'
import type { ConnectedBlock } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/hooks/use-block-connections'
import { useBlockOutputFields } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-block-output-fields'
import { getTileIconColorClass } from '@/blocks/icon-color'
import { getBlock } from '@/blocks/registry'
import { normalizeName } from '@/executor/constants'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
Expand Down Expand Up @@ -150,7 +151,8 @@ function ConnectionItem({
{Icon && (
<Icon
className={clsx(
'text-white transition-transform duration-200',
'transition-transform duration-200',
getTileIconColorClass(bgColor),
hasFields && 'group-hover:scale-110',
'!h-[9px] !w-[9px]'
)}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -229,7 +229,7 @@ export function CredentialSelector({
return <ExternalLink className='size-3' />
}
const Icon: StyleableIcon = baseProviderConfig.icon
return <Icon className='size-3' style={getBareIconStyle(Icon)} />
return <Icon className='size-3 text-[var(--text-icon)]' style={getBareIconStyle(Icon)} />
}, [])

const getProviderName = useCallback((providerName: OAuthProvider) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import type {
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tag-dropdown/types'
import { useAccessibleReferencePrefixes } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-accessible-reference-prefixes'
import { getBlock } from '@/blocks'
import { getTileIconColorClass } from '@/blocks/icon-color'
import type { BlockConfig } from '@/blocks/types'
import { normalizeName } from '@/executor/constants'
import { useVariablesStore } from '@/stores/variables/store'
Expand Down Expand Up @@ -390,11 +391,11 @@ const TagIcon: React.FC<{
style={{ background: color }}
>
{typeof icon === 'string' ? (
<span className='!text-white font-bold text-micro'>{icon}</span>
<span className={cn(getTileIconColorClass(color, true), 'font-bold text-micro')}>{icon}</span>
) : (
(() => {
const IconComponent = icon
return <IconComponent className='!text-white size-[9px]' />
return <IconComponent className={cn(getTileIconColorClass(color, true), 'size-[9px]')} />
})()
)}
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ import {
useActiveSearchTarget,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/providers/active-search-target-provider'
import { getAllBlocks } from '@/blocks'
import { getTileIconColorClass } from '@/blocks/icon-color'
import type { SubBlockConfig as BlockSubBlockConfig } from '@/blocks/types'
import { BUILT_IN_TOOL_TYPES } from '@/blocks/utils'
import { useMcpOauthPopup } from '@/hooks/mcp/use-mcp-oauth-popup'
Expand Down Expand Up @@ -426,7 +427,7 @@ function createToolIcon(
className='flex size-[16px] flex-shrink-0 items-center justify-center rounded-sm'
style={{ background: bgColor }}
>
<IconComponent className='size-[10px] text-white' />
<IconComponent className={cn('size-[10px]', getTileIconColorClass(bgColor))} />
</div>
)
}
Expand Down Expand Up @@ -1857,13 +1858,25 @@ export const ToolInput = memo(function ToolInput({
}}
>
{isCustomTool ? (
<WrenchIcon className='size-[10px] text-white' />
<WrenchIcon className={cn('size-[10px]', getTileIconColorClass('#3B82F6'))} />
) : isMcpTool ? (
<IconComponent icon={McpIcon} className='size-[10px] text-white' />
<IconComponent
icon={McpIcon}
className={cn(
'size-[10px]',
getTileIconColorClass(mcpTool?.bgColor || '#6366F1')
)}
/>
) : isWorkflowTool ? (
<IconComponent icon={WorkflowIcon} className='size-[10px] text-white' />
<IconComponent
icon={WorkflowIcon}
className={cn('size-[10px]', getTileIconColorClass('#6366F1'))}
/>
) : (
<IconComponent icon={toolBlock?.icon} className='size-[10px] text-white' />
<IconComponent
icon={toolBlock?.icon}
className={cn('size-[10px]', getTileIconColorClass(toolBlock?.bgColor))}
/>
)}
</div>
<span className='truncate font-medium text-[var(--text-primary)] text-small'>
Expand Down
Loading