From 488a4ace51ebc3a26376df91c7bc91495b81eca7 Mon Sep 17 00:00:00 2001 From: waleed Date: Tue, 30 Jun 2026 11:59:47 -0700 Subject: [PATCH] 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. --- .claude/commands/add-block.md | 7 + .claude/commands/add-integration.md | 26 ++++ .github/workflows/test-build.yml | 3 + .../preview-block-node.tsx | 9 +- .../components/integration-icon.tsx | 7 +- .../[block]/integration-block-detail.tsx | 6 +- .../integrations-showcase.tsx | 6 +- .../add-connector-modal.tsx | 8 +- .../connectors-section/connectors-section.tsx | 6 +- .../logs/components/log-details/utils.ts | 27 ++-- .../workflow-sidebar/workflow-sidebar.tsx | 5 +- .../output-select/output-select.tsx | 5 +- .../connection-blocks/connection-blocks.tsx | 4 +- .../credential-selector.tsx | 2 +- .../components/tag-dropdown/tag-dropdown.tsx | 5 +- .../components/tool-input/tool-input.tsx | 23 ++- .../panel/components/editor/editor.tsx | 8 +- .../panel/components/toolbar/toolbar.tsx | 4 +- .../components/terminal/terminal.tsx | 13 +- .../preview-editor/preview-editor.tsx | 11 +- .../components/block/block.tsx | 5 +- .../components/subflow/subflow.tsx | 8 +- .../command-items/command-items.tsx | 3 +- apps/sim/blocks/icon-color.test.ts | 42 +++++ apps/sim/blocks/icon-color.ts | 38 +++++ apps/sim/components/icons.tsx | 40 ++--- apps/sim/ee/whitelabeling/inject-theme.ts | 18 +-- .../ee/whitelabeling/org-branding-utils.ts | 19 +-- apps/sim/lib/colors/brightness.test.ts | 82 ++++++++++ apps/sim/lib/colors/brightness.ts | 74 +++++++++ apps/sim/lib/colors/convert.test.ts | 48 ++++++ apps/sim/lib/colors/convert.ts | 100 ++++++++++++ apps/sim/lib/colors/index.ts | 2 + .../pptx-renderer/renderer/style-resolver.ts | 41 ++--- .../pptx-renderer/renderer/text-renderer.ts | 31 +--- apps/sim/lib/pptx-renderer/utils/color.ts | 97 +----------- apps/sim/lib/workspaces/colors.ts | 15 +- package.json | 1 + .../src/lib/tile-icon-color.ts | 43 ++++++ .../src/subflow/subflow-node-view.tsx | 8 +- .../workflow-block/workflow-block-view.tsx | 8 +- scripts/check-bare-icons.ts | 146 ++++++++++++++++++ 42 files changed, 789 insertions(+), 265 deletions(-) create mode 100644 apps/sim/blocks/icon-color.test.ts create mode 100644 apps/sim/lib/colors/brightness.test.ts create mode 100644 apps/sim/lib/colors/brightness.ts create mode 100644 apps/sim/lib/colors/convert.test.ts create mode 100644 apps/sim/lib/colors/convert.ts create mode 100644 apps/sim/lib/colors/index.ts create mode 100644 packages/workflow-renderer/src/lib/tile-icon-color.ts create mode 100644 scripts/check-bare-icons.ts diff --git a/.claude/commands/add-block.md b/.claude/commands/add-block.md index 4ff595cce9e..93f0997320a 100644 --- a/.claude/commands/add-block.md +++ b/.claude/commands/add-block.md @@ -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: diff --git a/.claude/commands/add-integration.md b/.claude/commands/add-integration.md index 3fd58eff52e..6a379857c50 100644 --- a/.claude/commands/add-integration.md +++ b/.claude/commands/add-integration.md @@ -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. @@ -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 diff --git a/.github/workflows/test-build.yml b/.github/workflows/test-build.yml index c4ffd7449ea..29c1058b0b0 100644 --- a/.github/workflows/test-build.yml +++ b/.github/workflows/test-build.yml @@ -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 diff --git a/apps/sim/app/(landing)/components/landing-preview/components/landing-preview-workflow/preview-block-node.tsx b/apps/sim/app/(landing)/components/landing-preview/components/landing-preview-workflow/preview-block-node.tsx index 2daa4ce61de..347a12e0c6d 100644 --- a/apps/sim/app/(landing)/components/landing-preview/components/landing-preview-workflow/preview-block-node.tsx +++ b/apps/sim/app/(landing)/components/landing-preview/components/landing-preview-workflow/preview-block-node.tsx @@ -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> = { @@ -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 && } {name} @@ -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 && ( + + )} {tool.name} diff --git a/apps/sim/app/(landing)/integrations/components/integration-icon.tsx b/apps/sim/app/(landing)/integrations/components/integration-icon.tsx index 8856aaad140..db5e662c02b 100644 --- a/apps/sim/app/(landing)/integrations/components/integration-icon.tsx +++ b/apps/sim/app/(landing)/integrations/components/integration-icon.tsx @@ -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 { bgColor: string @@ -39,9 +40,11 @@ export function IntegrationIcon({ {...rest} > {Icon ? ( - + ) : ( - {name.charAt(0)} + + {name.charAt(0)} + )} ) diff --git a/apps/sim/app/workspace/[workspaceId]/integrations/[block]/integration-block-detail.tsx b/apps/sim/app/workspace/[workspaceId]/integrations/[block]/integration-block-detail.tsx index 0647775fafd..a4d9523fdd0 100644 --- a/apps/sim/app/workspace/[workspaceId]/integrations/[block]/integration-block-detail.tsx +++ b/apps/sim/app/workspace/[workspaceId]/integrations/[block]/integration-block-detail.tsx @@ -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, @@ -176,7 +177,10 @@ export function IntegrationBlockDetail({ integration, workspaceId }: Integration ) : (
{integration.name.charAt(0)} diff --git a/apps/sim/app/workspace/[workspaceId]/integrations/components/integrations-showcase/integrations-showcase.tsx b/apps/sim/app/workspace/[workspaceId]/integrations/components/integrations-showcase/integrations-showcase.tsx index 0f1d34ae6cd..e3b01070117 100644 --- a/apps/sim/app/workspace/[workspaceId]/integrations/components/integrations-showcase/integrations-showcase.tsx +++ b/apps/sim/app/workspace/[workspaceId]/integrations/components/integrations-showcase/integrations-showcase.tsx @@ -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 @@ -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} > - +
) @@ -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} > - + ) diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/add-connector-modal/add-connector-modal.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/add-connector-modal/add-connector-modal.tsx index c3eb6292a23..4e1e7a5ca87 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/add-connector-modal/add-connector-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/add-connector-modal/add-connector-modal.tsx @@ -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' @@ -477,7 +478,12 @@ function ConnectorTypeCard({ type, config, onClick }: ConnectorTypeCardProps) { )} style={brandBg ? { background: brandBg } : undefined} > - +
diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connectors-section/connectors-section.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connectors-section/connectors-section.tsx index 49c852eb666..43be80879e4 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connectors-section/connectors-section.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connectors-section/connectors-section.tsx @@ -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 { @@ -346,7 +347,10 @@ function ConnectorCard({ > {Icon && ( )}
diff --git a/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/utils.ts b/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/utils.ts index 2fa855ae7cc..e88c30a17cd 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/utils.ts +++ b/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/utils.ts @@ -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' @@ -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 { diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/workflow-sidebar/workflow-sidebar.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/workflow-sidebar/workflow-sidebar.tsx index 0143ae0c062..f6fe1149990 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/workflow-sidebar/workflow-sidebar.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/workflow-sidebar/workflow-sidebar.tsx @@ -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, @@ -175,11 +176,11 @@ const TagIcon: React.FC<{ style={{ background: color }} > {typeof icon === 'string' ? ( - {icon} + {icon} ) : ( (() => { const IconComponent = icon - return + return })() )} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/components/output-select/output-select.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/components/output-select/output-select.tsx index 8ad659a4535..a4a0b8fd1f9 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/components/output-select/output-select.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/components/output-select/output-select.tsx @@ -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' @@ -31,11 +32,11 @@ const TagIcon: React.FC<{ style={{ background: color }} > {typeof icon === 'string' ? ( - {icon} + {icon} ) : ( (() => { const IconComponent = icon - return + return })() )} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/connection-blocks/connection-blocks.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/connection-blocks/connection-blocks.tsx index 66778d5f15d..7899d1cf8fd 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/connection-blocks/connection-blocks.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/connection-blocks/connection-blocks.tsx @@ -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' @@ -150,7 +151,8 @@ function ConnectionItem({ {Icon && ( } const Icon: StyleableIcon = baseProviderConfig.icon - return + return }, []) const getProviderName = useCallback((providerName: OAuthProvider) => { diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tag-dropdown/tag-dropdown.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tag-dropdown/tag-dropdown.tsx index e113a9e9ac1..6f62851c289 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tag-dropdown/tag-dropdown.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tag-dropdown/tag-dropdown.tsx @@ -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' @@ -390,11 +391,11 @@ const TagIcon: React.FC<{ style={{ background: color }} > {typeof icon === 'string' ? ( - {icon} + {icon} ) : ( (() => { const IconComponent = icon - return + return })() )} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/tool-input.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/tool-input.tsx index d6470cac6bd..8441a0e2fe0 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/tool-input.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/tool-input.tsx @@ -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' @@ -426,7 +427,7 @@ function createToolIcon( className='flex size-[16px] flex-shrink-0 items-center justify-center rounded-sm' style={{ background: bgColor }} > - + ) } @@ -1857,13 +1858,25 @@ export const ToolInput = memo(function ToolInput({ }} > {isCustomTool ? ( - + ) : isMcpTool ? ( - + ) : isWorkflowTool ? ( - + ) : ( - + )} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/editor.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/editor.tsx index dbaa774a3af..af1adb3a6aa 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/editor.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/editor.tsx @@ -1,7 +1,7 @@ 'use client' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' -import { Button, DashedDividerLine, FieldDivider, Loader, Tooltip } from '@sim/emcn' +import { Button, cn, DashedDividerLine, FieldDivider, Loader, Tooltip } from '@sim/emcn' import { isEqual } from 'es-toolkit' import { BookOpen, @@ -49,6 +49,7 @@ import { isBlockProtected, } from '@/app/workspace/[workspaceId]/w/[workflowId]/utils/block-protection-utils' import { PreviewWorkflow } from '@/app/workspace/[workspaceId]/w/components/preview' +import { getTileIconColorClass } from '@/blocks/icon-color' import { getBlock } from '@/blocks/registry' import { useFolderMap } from '@/hooks/queries/folders' import { isWorkflowEffectivelyLocked } from '@/hooks/queries/utils/folder-tree' @@ -375,7 +376,10 @@ export function Editor() { > )} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/toolbar/toolbar.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/toolbar/toolbar.tsx index dcf0338c368..0969a117502 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/toolbar/toolbar.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/toolbar/toolbar.tsx @@ -29,6 +29,7 @@ import { ToolbarItemContextMenu } from '@/app/workspace/[workspaceId]/w/[workflo import { useToolbarItemInteractions } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/toolbar/hooks' 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' +import { getTileIconColorClass } from '@/blocks/icon-color' import { getCanonicalBlocksByCategory } from '@/blocks/registry' import type { BlockConfig } from '@/blocks/types' import { usePermissionConfig } from '@/hooks/use-permission-config' @@ -125,7 +126,8 @@ const ToolbarItem = memo(function ToolbarItem({ {Icon && ( - {BlockIcon && } + {BlockIcon && ( + + )} - {BlockIcon && } + {BlockIcon && ( + + )} - {BlockIcon && } + {BlockIcon && ( + + )} - + {subflowName} @@ -1199,7 +1201,10 @@ function PreviewEditorContent({ className='flex size-[18px] flex-shrink-0 items-center justify-center rounded-sm' style={{ backgroundColor: blockConfig.bgColor }} > - + )} diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/components/block/block.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/components/block/block.tsx index 0fbe922788d..c28f99bbc8b 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/components/block/block.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/components/block/block.tsx @@ -19,6 +19,7 @@ import { isSubBlockVisibleForMode, } from '@/lib/workflows/subblocks/visibility' import { getBlock } from '@/blocks' +import { getTileIconColorClass } from '@/blocks/icon-color' import { SELECTOR_TYPES_HYDRATION_REQUIRED, type SubBlockConfig } from '@/blocks/types' import { useVariablesStore } from '@/stores/variables/store' import type { WorkflowMetadata } from '@/stores/workflows/registry/types' @@ -375,7 +376,9 @@ function WorkflowPreviewBlockInner({ data }: NodeProps className='flex size-[24px] flex-shrink-0 items-center justify-center rounded-md' style={{ background: enabled ? blockConfig.bgColor : 'gray' }} > - + )} - + diff --git a/apps/sim/blocks/icon-color.test.ts b/apps/sim/blocks/icon-color.test.ts new file mode 100644 index 00000000000..804561ce36e --- /dev/null +++ b/apps/sim/blocks/icon-color.test.ts @@ -0,0 +1,42 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { getTileIconColorClass, isLightTileColor } from '@/blocks/icon-color' + +describe('isLightTileColor', () => { + it('treats clearly light tiles (white, Mailchimp/Infisical/Linkup) as light', () => { + expect(isLightTileColor('#FFFFFF')).toBe(true) + expect(isLightTileColor('#FFE01B')).toBe(true) + expect(isLightTileColor('#F7FE62')).toBe(true) + expect(isLightTileColor('#D6D3C7')).toBe(true) + }) + + it('keeps mid-bright saturated brand tiles (HubSpot, amber, olive) on white icons', () => { + expect(isLightTileColor('#FF7A59')).toBe(false) + expect(isLightTileColor('#F59E0B')).toBe(false) + expect(isLightTileColor('#B2C147')).toBe(false) + }) + + it('treats dark tiles, gradients, and empty values as dark', () => { + expect(isLightTileColor('#171717')).toBe(false) + expect(isLightTileColor('#9B5CFF')).toBe(false) + expect(isLightTileColor('linear-gradient(45deg, #fff, #000)')).toBe(false) + expect(isLightTileColor(null)).toBe(false) + expect(isLightTileColor(undefined)).toBe(false) + }) +}) + +describe('getTileIconColorClass', () => { + it('returns a dark foreground on light tiles, white on dark tiles', () => { + expect(getTileIconColorClass('#FFFFFF')).toBe('text-black') + expect(getTileIconColorClass('#FFE01B')).toBe('text-black') + expect(getTileIconColorClass('#171717')).toBe('text-white') + expect(getTileIconColorClass('#FF7A59')).toBe('text-white') + }) + + it('emits the important variant when requested', () => { + expect(getTileIconColorClass('#FFFFFF', true)).toBe('!text-black') + expect(getTileIconColorClass('#171717', true)).toBe('!text-white') + }) +}) diff --git a/apps/sim/blocks/icon-color.ts b/apps/sim/blocks/icon-color.ts index 08dccd5f214..9e3030127ec 100644 --- a/apps/sim/blocks/icon-color.ts +++ b/apps/sim/blocks/icon-color.ts @@ -1,4 +1,5 @@ import type { ComponentType, CSSProperties } from 'react' +import { isLightColor } from '@/lib/colors' import { getAllBlocks } from '@/blocks/registry' /** A brand icon component that accepts standard styling props. */ @@ -37,3 +38,40 @@ export function getBareIconStyle(icon: StyleableIcon): CSSProperties | undefined const color = getIconColorMap().get(icon) return color ? { color } : undefined } + +/** + * Brightness above which a brand tile is "clearly light" and a white foreground + * icon would wash out. Set deliberately high (0.75) so only genuinely light + * tiles flip their icon to dark: it keeps monochrome `currentColor` icons + * legible on their pale tiles (Notion/Mailchimp/Infisical sit at ~0.83+) while + * leaving mid-bright saturated brand tiles (HubSpot orange, amber notes) on the + * white icon they have always used — avoiding a needless app-wide recolor. + */ +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). + */ +export function isLightTileColor(bgColor: string | null | undefined): boolean { + return Boolean(bgColor) && isLightColor(bgColor as string, LIGHT_TILE_THRESHOLD) +} + +/** + * Tailwind foreground class for a brand icon rendered inside its + * {@link BlockConfig.bgColor} tile. Dark tiles get white; light tiles get + * near-black so monochrome `currentColor` icons (Notion, Mailchimp, …) stay + * legible instead of rendering white-on-white. Hardcoded multi-color icons + * ignore the class and keep their own fills. Pass `important` when overriding + * an inherited text color (the legacy `!text-white` tile rows). + * + * All four literals are spelled out so Tailwind's JIT scanner emits them. + */ +export function getTileIconColorClass( + bgColor: string | null | undefined, + important = false +): string { + if (isLightTileColor(bgColor)) return important ? '!text-black' : 'text-black' + return important ? '!text-white' : 'text-white' +} diff --git a/apps/sim/components/icons.tsx b/apps/sim/components/icons.tsx index 43c94aa8871..ff429f8285f 100644 --- a/apps/sim/components/icons.tsx +++ b/apps/sim/components/icons.tsx @@ -1142,7 +1142,7 @@ export function NotionIcon(props: SVGProps) { ) @@ -1980,7 +1980,7 @@ export function SquareIcon(props: SVGProps) { return ( @@ -2086,7 +2086,7 @@ export function ContextDevIcon(props: SVGProps) { return ( @@ -2328,7 +2328,7 @@ export function BrexIcon(props: SVGProps) { return ( @@ -2584,7 +2584,7 @@ export function LinkupIcon(props: SVGProps) { ) @@ -2665,7 +2665,7 @@ export function LangsmithIcon(props: SVGProps) { export function LatexIcon(props: SVGProps) { return ( - + ) { ) @@ -5099,7 +5099,7 @@ export function InfisicalIcon(props: SVGProps) { ) @@ -5119,7 +5119,7 @@ export function IntercomIcon(props: SVGProps) { @@ -5160,7 +5160,7 @@ export function MailchimpIcon(props: SVGProps) { y='0px' viewBox='0 0 230.81 244.96' xmlSpace='preserve' - fill='#000000' + fill='currentColor' > @@ -5957,7 +5957,7 @@ export function TemporalIcon(props: SVGProps) { ) @@ -5989,31 +5989,31 @@ export function DatadogIcon(props: SVGProps) { export function DaytonaIcon(props: SVGProps) { return ( - - + + ) { width='20.6556' height='8.54718' transform='rotate(90 22.1582 12.9094)' - fill='#000000' + fill='currentColor' /> ) { width='25.6415' height='8.54718' transform='rotate(90 52.0732 42.825)' - fill='#000000' + fill='currentColor' /> ) @@ -6213,7 +6213,7 @@ export function KetchIcon(props: SVGProps) { diff --git a/apps/sim/ee/whitelabeling/inject-theme.ts b/apps/sim/ee/whitelabeling/inject-theme.ts index 07e3d64bdcd..3dc48801801 100644 --- a/apps/sim/ee/whitelabeling/inject-theme.ts +++ b/apps/sim/ee/whitelabeling/inject-theme.ts @@ -1,18 +1,4 @@ -/** - * Helper to detect if background is dark - */ -function isDarkBackground(hexColor: string): boolean { - const hex = hexColor.replace('#', '') - const r = Number.parseInt(hex.substr(0, 2), 16) - const g = Number.parseInt(hex.substr(2, 2), 16) - const b = Number.parseInt(hex.substr(4, 2), 16) - const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255 - return luminance < 0.5 -} - -function getContrastTextColor(hexColor: string): string { - return isDarkBackground(hexColor) ? '#ffffff' : '#000000' -} +import { getContrastTextColor, isDarkColor } from '@/lib/colors' export function generateThemeCSS(): string { const cssVars: string[] = [] @@ -57,7 +43,7 @@ export function generateThemeCSS(): string { } if (process.env.NEXT_PUBLIC_BRAND_BACKGROUND_COLOR) { - const isDark = isDarkBackground(process.env.NEXT_PUBLIC_BRAND_BACKGROUND_COLOR) + const isDark = isDarkColor(process.env.NEXT_PUBLIC_BRAND_BACKGROUND_COLOR) if (isDark) { cssVars.push(`--brand-is-dark: 1;`) } diff --git a/apps/sim/ee/whitelabeling/org-branding-utils.ts b/apps/sim/ee/whitelabeling/org-branding-utils.ts index f859209f812..daaf44a4c0c 100644 --- a/apps/sim/ee/whitelabeling/org-branding-utils.ts +++ b/apps/sim/ee/whitelabeling/org-branding-utils.ts @@ -1,4 +1,5 @@ import type { BrandConfig, OrganizationWhitelabelSettings } from '@/lib/branding/types' +import { getContrastTextColor } from '@/lib/colors' /** * Merge org-level whitelabel settings over the instance-level brand config. @@ -39,24 +40,6 @@ export function mergeOrgBrandConfig( } } -function isDarkBackground(hex: string): boolean { - let clean = hex.replace('#', '') - if (clean.length === 3) { - clean = clean - .split('') - .map((c) => c + c) - .join('') - } - const r = Number.parseInt(clean.slice(0, 2), 16) - const g = Number.parseInt(clean.slice(2, 4), 16) - const b = Number.parseInt(clean.slice(4, 6), 16) - return (0.299 * r + 0.587 * g + 0.114 * b) / 255 < 0.5 -} - -function getContrastTextColor(hex: string): string { - return isDarkBackground(hex) ? '#ffffff' : '#000000' -} - /** * Generate CSS variable overrides from org whitelabel settings. * Returns an empty string when no color overrides are set. diff --git a/apps/sim/lib/colors/brightness.test.ts b/apps/sim/lib/colors/brightness.test.ts new file mode 100644 index 00000000000..f2a5044d057 --- /dev/null +++ b/apps/sim/lib/colors/brightness.test.ts @@ -0,0 +1,82 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { + getContrastTextColor, + isDarkColor, + isLightColor, + perceivedBrightness, +} from '@/lib/colors/brightness' + +describe('perceivedBrightness', () => { + it('returns 1 for white and 0 for black (hex and keywords)', () => { + expect(perceivedBrightness('#ffffff')).toBe(1) + expect(perceivedBrightness('#000000')).toBe(0) + expect(perceivedBrightness('white')).toBe(1) + expect(perceivedBrightness('black')).toBe(0) + }) + + it('parses 3-digit hex, optional # and quotes, case-insensitively', () => { + expect(perceivedBrightness('#FFF')).toBe(1) + expect(perceivedBrightness('fff')).toBe(1) + expect(perceivedBrightness("'#FFFFFF'")).toBe(1) + }) + + it('returns null for non-color values', () => { + expect(perceivedBrightness('currentColor')).toBeNull() + expect(perceivedBrightness('linear-gradient(45deg, #000, #fff)')).toBeNull() + expect(perceivedBrightness('rebeccapurple')).toBeNull() + expect(perceivedBrightness('#12')).toBeNull() + }) + + it('reads saturated brand colors perceptually (bright yellow is light)', () => { + expect((perceivedBrightness('#EAB308') as number) > 0.6).toBe(true) + expect((perceivedBrightness('#3B82F6') as number) < 0.6).toBe(true) + }) +}) + +describe('isLightColor', () => { + it('classifies light vs dark tiles at the default threshold', () => { + expect(isLightColor('#FFFFFF')).toBe(true) + expect(isLightColor('#FFE01B')).toBe(true) + expect(isLightColor('#EAB308')).toBe(true) + expect(isLightColor('#171717')).toBe(false) + expect(isLightColor('#3B82F6')).toBe(false) + }) + + it('treats non-color values (gradients) as dark', () => { + expect(isLightColor('linear-gradient(45deg, #fff, #000)')).toBe(false) + expect(isLightColor('currentColor')).toBe(false) + }) + + it('respects a custom threshold', () => { + expect(isLightColor('#808080', 0.9)).toBe(false) + }) +}) + +describe('isDarkColor', () => { + it('classifies dark vs light at the 0.5 midpoint', () => { + expect(isDarkColor('#000000')).toBe(true) + expect(isDarkColor('#3B82F6')).toBe(true) + expect(isDarkColor('#ffffff')).toBe(false) + expect(isDarkColor('#FFE01B')).toBe(false) + }) + + it('treats unparseable values as not dark', () => { + expect(isDarkColor('currentColor')).toBe(false) + }) +}) + +describe('getContrastTextColor', () => { + it('picks black on light colors and white on dark colors', () => { + expect(getContrastTextColor('#ffffff')).toBe('#000000') + expect(getContrastTextColor('#FFE01B')).toBe('#000000') + expect(getContrastTextColor('#000000')).toBe('#ffffff') + expect(getContrastTextColor('#3B82F6')).toBe('#ffffff') + }) + + it('treats unparseable colors as light (black text), matching legacy behavior', () => { + expect(getContrastTextColor('currentColor')).toBe('#000000') + }) +}) diff --git a/apps/sim/lib/colors/brightness.ts b/apps/sim/lib/colors/brightness.ts new file mode 100644 index 00000000000..e212502d341 --- /dev/null +++ b/apps/sim/lib/colors/brightness.ts @@ -0,0 +1,74 @@ +/** + * Perceived brightness (0 = black, 1 = white) of a CSS color, using the ITU-R + * BT.601 (YIQ) luma weights `0.299 R + 0.587 G + 0.114 B`. + * + * This is the perceptual "is it light or dark" measure the app uses for + * foreground/background contrast decisions. It tracks human brightness + * perception better than gamma-corrected relative luminance for the saturated + * brand colors used as tile backgrounds (e.g. it correctly reads bright yellows + * as light), which is why every contrast helper in the app builds on it. + * + * Accepts `#rgb`/`#rrggbb` hex (with or without `#`, optionally quoted) and the + * `white`/`black` keywords. Returns `null` for anything else (named colors, + * gradients, `currentColor`, malformed input) so callers can treat unknown + * values explicitly instead of guessing. + */ +export function perceivedBrightness(color: string): number | null { + const value = color.trim().replace(/['"]/g, '').toLowerCase() + if (value === 'white') return 1 + if (value === 'black') return 0 + const hex = value.replace('#', '') + let r: number + let g: number + let b: number + if (/^[0-9a-f]{3}$/.test(hex)) { + r = Number.parseInt(hex[0] + hex[0], 16) + g = Number.parseInt(hex[1] + hex[1], 16) + b = Number.parseInt(hex[2] + hex[2], 16) + } else if (/^[0-9a-f]{6}$/.test(hex)) { + r = Number.parseInt(hex.slice(0, 2), 16) + g = Number.parseInt(hex.slice(2, 4), 16) + b = Number.parseInt(hex.slice(4, 6), 16) + } else { + return null + } + return (0.299 * r + 0.587 * g + 0.114 * b) / 255 +} + +/** + * True when `color` is light enough that a white foreground would wash out. + * Non-color values (gradients, `currentColor`, unknown) are treated as not + * light. `threshold` is the perceived-brightness cutoff (default 0.6, tuned so + * only clearly light tiles flip to a dark foreground). + */ +export function isLightColor(color: string, threshold = 0.6): boolean { + const brightness = perceivedBrightness(color) + return brightness !== null && brightness > threshold +} + +/** + * True when `color` is dark enough to warrant a light foreground. Non-color + * values (gradients, `currentColor`, unknown) are treated as not dark. + * `threshold` is the perceived-brightness cutoff (default 0.5, the conventional + * midpoint for binary text contrast). + */ +export function isDarkColor(color: string, threshold = 0.5): boolean { + const brightness = perceivedBrightness(color) + return brightness !== null && brightness < threshold +} + +/** + * Black or white — whichever reads on top of `color`. Dark colors get white + * text; light colors (and unparseable values) get black. Uses the 0.5 midpoint + * ({@link isDarkColor}'s default), the conventional binary text-contrast cutoff. + * + * Note this is intentionally a different cutoff than the brand-tile *icon* + * decision ({@link isLightColor}'s 0.6 default, raised to 0.75 for tiles), which + * biases toward white more aggressively: a colored tile reads better with a + * white icon until it is clearly light, whereas plain text wants the + * mathematically closer of black/white. The two helpers therefore answer + * "what foreground reads here" differently by design, per surface. + */ +export function getContrastTextColor(color: string): '#000000' | '#ffffff' { + return isDarkColor(color) ? '#ffffff' : '#000000' +} diff --git a/apps/sim/lib/colors/convert.test.ts b/apps/sim/lib/colors/convert.test.ts new file mode 100644 index 00000000000..b3b68104fec --- /dev/null +++ b/apps/sim/lib/colors/convert.test.ts @@ -0,0 +1,48 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { hexToRgb, hslToRgb, rgbToHex, rgbToHsl, toCssColor } from '@/lib/colors/convert' + +describe('hexToRgb', () => { + it('parses 6- and 3-digit hex, with or without #', () => { + expect(hexToRgb('#ff8800')).toEqual({ r: 255, g: 136, b: 0 }) + expect(hexToRgb('f80')).toEqual({ r: 255, g: 136, b: 0 }) + }) + + it('returns black for malformed input', () => { + expect(hexToRgb('nope')).toEqual({ r: 0, g: 0, b: 0 }) + }) +}) + +describe('rgbToHex', () => { + it('clamps and pads to #rrggbb', () => { + expect(rgbToHex(255, 136, 0)).toBe('#ff8800') + expect(rgbToHex(-10, 300, 5)).toBe('#00ff05') + }) +}) + +describe('rgbToHsl / hslToRgb round-trip', () => { + it('round-trips a saturated color within rounding', () => { + const { h, s, l } = rgbToHsl(59, 130, 246) + const { r, g, b } = hslToRgb(h, s, l) + expect(Math.abs(r - 59)).toBeLessThanOrEqual(1) + expect(Math.abs(g - 130)).toBeLessThanOrEqual(1) + expect(Math.abs(b - 246)).toBeLessThanOrEqual(1) + }) + + it('handles grays (zero saturation)', () => { + expect(hslToRgb(0, 0, 0.5)).toEqual({ r: 128, g: 128, b: 128 }) + }) +}) + +describe('toCssColor', () => { + it('returns bare hex when fully opaque', () => { + expect(toCssColor('#3b82f6', 1)).toBe('#3b82f6') + expect(toCssColor('3b82f6', 1)).toBe('#3b82f6') + }) + + it('returns rgba with 3-decimal alpha when translucent', () => { + expect(toCssColor('#3b82f6', 0.5)).toBe('rgba(59,130,246,0.500)') + }) +}) diff --git a/apps/sim/lib/colors/convert.ts b/apps/sim/lib/colors/convert.ts new file mode 100644 index 00000000000..8ffec5a99dc --- /dev/null +++ b/apps/sim/lib/colors/convert.ts @@ -0,0 +1,100 @@ +/** + * Generic color-space conversions shared across the app (brand tiles, presence + * avatars, the PPTX renderer, …). Pure and dependency-free. + */ + +/** Parse a hex color string (with or without `#`) into RGB components. */ +export function hexToRgb(hex: string): { r: number; g: number; b: number } { + const cleaned = hex.replace(/^#/, '') + if (cleaned.length !== 6 && cleaned.length !== 3) { + return { r: 0, g: 0, b: 0 } + } + const full = + cleaned.length === 3 + ? cleaned[0] + cleaned[0] + cleaned[1] + cleaned[1] + cleaned[2] + cleaned[2] + : cleaned + const num = Number.parseInt(full, 16) + return { + r: (num >> 16) & 0xff, + g: (num >> 8) & 0xff, + b: num & 0xff, + } +} + +/** Convert RGB components (0-255 each) to a 6-digit `#rrggbb` hex string. */ +export function rgbToHex(r: number, g: number, b: number): string { + const clamp = (v: number): number => Math.max(0, Math.min(255, Math.round(v))) + return `#${[clamp(r), clamp(g), clamp(b)].map((c) => c.toString(16).padStart(2, '0')).join('')}` +} + +/** Convert RGB (0-255) to HSL (h: 0-360, s: 0-1, l: 0-1). */ +export function rgbToHsl(r: number, g: number, b: number): { h: number; s: number; l: number } { + const rn = r / 255 + const gn = g / 255 + const bn = b / 255 + const max = Math.max(rn, gn, bn) + const min = Math.min(rn, gn, bn) + const l = (max + min) / 2 + let h = 0 + let s = 0 + + if (max !== min) { + const d = max - min + s = l > 0.5 ? d / (2 - max - min) : d / (max + min) + switch (max) { + case rn: + h = ((gn - bn) / d + (gn < bn ? 6 : 0)) * 60 + break + case gn: + h = ((bn - rn) / d + 2) * 60 + break + case bn: + h = ((rn - gn) / d + 4) * 60 + break + } + } + + return { h, s, l } +} + +/** Convert HSL (h: 0-360, s: 0-1, l: 0-1) to RGB (0-255). */ +export function hslToRgb(h: number, s: number, l: number): { r: number; g: number; b: number } { + h = ((h % 360) + 360) % 360 + s = Math.max(0, Math.min(1, s)) + l = Math.max(0, Math.min(1, l)) + + if (s === 0) { + const v = Math.round(l * 255) + return { r: v, g: v, b: v } + } + + const hueToRgb = (p: number, q: number, t: number): number => { + if (t < 0) t += 1 + if (t > 1) t -= 1 + if (t < 1 / 6) return p + (q - p) * 6 * t + if (t < 1 / 2) return q + if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6 + return p + } + + const q = l < 0.5 ? l * (1 + s) : l + s - l * s + const p = 2 * l - q + const hNorm = h / 360 + + return { + r: Math.round(hueToRgb(p, q, hNorm + 1 / 3) * 255), + g: Math.round(hueToRgb(p, q, hNorm) * 255), + b: Math.round(hueToRgb(p, q, hNorm - 1 / 3) * 255), + } +} + +/** + * Render a resolved color + alpha as a CSS color string: the bare hex when + * fully opaque, otherwise `rgba(r,g,b,a)`. Accepts hex with or without `#`. + */ +export function toCssColor(color: string, alpha: number): string { + const hex = color.startsWith('#') ? color : `#${color}` + if (alpha >= 1) return hex + const { r, g, b } = hexToRgb(hex) + return `rgba(${r},${g},${b},${alpha.toFixed(3)})` +} diff --git a/apps/sim/lib/colors/index.ts b/apps/sim/lib/colors/index.ts new file mode 100644 index 00000000000..c9bb82ab1ea --- /dev/null +++ b/apps/sim/lib/colors/index.ts @@ -0,0 +1,2 @@ +export { getContrastTextColor, isDarkColor, isLightColor, perceivedBrightness } from './brightness' +export { hexToRgb, hslToRgb, rgbToHex, rgbToHsl, toCssColor } from './convert' diff --git a/apps/sim/lib/pptx-renderer/renderer/style-resolver.ts b/apps/sim/lib/pptx-renderer/renderer/style-resolver.ts index d0a515ce4cf..a9eb8295655 100644 --- a/apps/sim/lib/pptx-renderer/renderer/style-resolver.ts +++ b/apps/sim/lib/pptx-renderer/renderer/style-resolver.ts @@ -2,10 +2,11 @@ * Style resolver — converts OOXML color and fill nodes to CSS values. */ +import { toCssColor } from '@/lib/colors' import { angleToDeg, emuToPx, pctToDecimal } from '../parser/units' import type { SafeXmlNode } from '../parser/xml-parser' import type { ColorModifier } from '../utils/color' -import { applyColorModifiers, hexToRgb, hslToRgb, presetColorToHex, rgbToHex } from '../utils/color' +import { applyColorModifiers, hslToRgb, presetColorToHex, rgbToHex } from '../utils/color' import type { RenderContext } from './render-context' // --------------------------------------------------------------------------- @@ -188,23 +189,11 @@ function resolveColorUncached( /** * Resolve a color node and return a CSS color string. - * Convenience wrapper combining resolveColor + colorToCss. + * Convenience wrapper combining resolveColor + toCssColor. */ export function resolveColorToCss(node: SafeXmlNode, ctx: RenderContext): string { const { color, alpha } = resolveColor(node, ctx) - return colorToCss(color, alpha) -} - -/** - * Convert a resolved color + alpha into a CSS rgba() string. - */ -function colorToCss(color: string, alpha: number): string { - const hex = color.startsWith('#') ? color : `#${color}` - const { r, g, b } = hexToRgb(hex) - if (alpha >= 1) { - return hex - } - return `rgba(${r},${g},${b},${alpha.toFixed(3)})` + return toCssColor(color, alpha) } function resolveColorWithPlaceholder( @@ -233,7 +222,7 @@ export function resolveFill(spPr: SafeXmlNode, ctx: RenderContext): string { const solidFill = spPr.child('solidFill') if (solidFill.exists()) { const { color, alpha } = resolveColor(solidFill, ctx) - return colorToCss(color, alpha) + return toCssColor(color, alpha) } // gradFill @@ -294,13 +283,13 @@ function resolvePatternFill(pattFill: SafeXmlNode, ctx: RenderContext): string { const fgClr = pattFill.child('fgClr') if (fgClr.exists()) { const { color, alpha } = resolveColor(fgClr, ctx) - fg = colorToCss(color, alpha) + fg = toCssColor(color, alpha) } const bgClr = pattFill.child('bgClr') if (bgClr.exists()) { const { color, alpha } = resolveColor(bgClr, ctx) - bg = colorToCss(color, alpha) + bg = toCssColor(color, alpha) } // Size of pattern tile in px @@ -452,7 +441,7 @@ function resolveGradient( const pos = gs.numAttr('pos') ?? 0 const posPercent = pctToDecimal(pos) * 100 const { color, alpha } = resolveColorWithPlaceholder(gs, ctx, placeholderColorNode) - stops.push({ position: posPercent, color: colorToCss(color, alpha) }) + stops.push({ position: posPercent, color: toCssColor(color, alpha) }) } if (stops.length === 0) { @@ -543,10 +532,10 @@ export function resolveLineStyle( const base = resolveColor(lnRef, ctx) const baseHex = base.color.startsWith('#') ? base.color.slice(1) : base.color const adjusted = applyColorModifiers(baseHex, collectModifiers(phClr)) - color = colorToCss(adjusted.color, adjusted.alpha * base.alpha) + color = toCssColor(adjusted.color, adjusted.alpha * base.alpha) } else { const resolved = resolveColor(solidFill, ctx) - color = colorToCss(resolved.color, resolved.alpha) + color = toCssColor(resolved.color, resolved.alpha) } } else if (lnRef?.exists() && (lnRef.numAttr('idx') ?? 0) > 0) { const idx = lnRef.numAttr('idx') ?? 0 @@ -560,11 +549,11 @@ export function resolveLineStyle( } // Get color: prefer lnRef's own color child, fall back to theme line's solidFill const resolved = resolveColor(lnRef, ctx) - color = colorToCss(resolved.color, resolved.alpha) + color = toCssColor(resolved.color, resolved.alpha) } else { // Fallback: use lnRef color directly, approximate width from idx const resolved = resolveColor(lnRef, ctx) - color = colorToCss(resolved.color, resolved.alpha) + color = toCssColor(resolved.color, resolved.alpha) if (width === 0 && idx > 0) { width = idx * 0.75 // approximate: idx 1 = ~0.75px, idx 2 = ~1.5px } @@ -665,7 +654,7 @@ function resolveGradientFillNode( const pos = gs.numAttr('pos') ?? 0 const posPercent = pctToDecimal(pos) * 100 const { color, alpha } = resolveColorWithPlaceholder(gs, ctx, placeholderColorNode) - stops.push({ position: posPercent, color: colorToCss(color, alpha) }) + stops.push({ position: posPercent, color: toCssColor(color, alpha) }) } if (stops.length === 0) return null @@ -743,7 +732,7 @@ export function resolveThemeFillReference( if (themeFill.localName === 'solidFill') { const resolved = resolveColorWithPlaceholder(themeFill, ctx, fillRef) - return { fillCss: colorToCss(resolved.color, resolved.alpha), gradientFillData: null } + return { fillCss: toCssColor(resolved.color, resolved.alpha), gradientFillData: null } } if (themeFill.localName === 'gradFill') { @@ -793,7 +782,7 @@ export function resolveGradientStroke( const pos = gs.numAttr('pos') ?? 0 const posPercent = pctToDecimal(pos) * 100 const { color, alpha } = resolveColor(gs, ctx) - const cssColor = colorToCss(color, alpha) + const cssColor = toCssColor(color, alpha) stops.push({ position: posPercent, color: cssColor }) } diff --git a/apps/sim/lib/pptx-renderer/renderer/text-renderer.ts b/apps/sim/lib/pptx-renderer/renderer/text-renderer.ts index e0f775fb83d..e66a802bfcd 100644 --- a/apps/sim/lib/pptx-renderer/renderer/text-renderer.ts +++ b/apps/sim/lib/pptx-renderer/renderer/text-renderer.ts @@ -3,6 +3,7 @@ * with full 7-level style inheritance. */ +import { hexToRgb, toCssColor } from '@/lib/colors' import type { PlaceholderInfo } from '../model/nodes/base-node' import type { TextBody } from '../model/nodes/shape-node' import { angleToDeg, emuToPx, pctToDecimal } from '../parser/units' @@ -285,7 +286,7 @@ function mergeRunProps(target: MergedRunStyle, rPr: SafeXmlNode, ctx: RenderCont const { color, alpha } = resolveColor(solidFill, ctx) const hex = color.startsWith('#') ? color : `#${color}` if (alpha < 1) { - const { r, g, b: bl } = hexToRgbInternal(hex) + const { r, g, b: bl } = hexToRgb(hex) target.color = `rgba(${r},${g},${bl},${alpha.toFixed(3)})` } else { target.color = hex @@ -367,7 +368,7 @@ function mergeRunProps(target: MergedRunStyle, rPr: SafeXmlNode, ctx: RenderCont const lnSolid = ln.child('solidFill') if (lnSolid.exists()) { const { color: c, alpha: a } = resolveColor(lnSolid, ctx) - target.textOutlineColor = colorToCssLocal(c, a) + target.textOutlineColor = toCssColor(c, a) } // Gradient fill on outline — build CSS gradient for mask effect const lnGrad = ln.child('gradFill') @@ -394,30 +395,6 @@ function resolveThemeFont(typeface: string, ctx: RenderContext): string { return typeface } -/** - * Minimal hex-to-rgb parser for inline use. - */ -function hexToRgbInternal(hex: string): { r: number; g: number; b: number } { - const cleaned = hex.replace(/^#/, '') - const num = Number.parseInt( - cleaned.length === 3 - ? cleaned[0] + cleaned[0] + cleaned[1] + cleaned[1] + cleaned[2] + cleaned[2] - : cleaned, - 16 - ) - return { r: (num >> 16) & 0xff, g: (num >> 8) & 0xff, b: num & 0xff } -} - -/** - * Convert resolved color + alpha to CSS color string. - */ -function colorToCssLocal(color: string, alpha: number): string { - const hex = color.startsWith('#') ? color : `#${color}` - if (alpha >= 1) return hex - const { r, g, b } = hexToRgbInternal(hex) - return `rgba(${r},${g},${b},${alpha.toFixed(3)})` -} - /** * Resolve a gradient fill node into a CSS linear-gradient string. * Used for text outline gradient effects. @@ -429,7 +406,7 @@ function resolveGradientForText(gradFill: SafeXmlNode, ctx: RenderContext): stri const pos = gs.numAttr('pos') ?? 0 const posPercent = pctToDecimal(pos) * 100 const { color, alpha } = resolveColor(gs, ctx) - stops.push({ position: posPercent, color: colorToCssLocal(color, alpha) }) + stops.push({ position: posPercent, color: toCssColor(color, alpha) }) } if (stops.length === 0) return '' stops.sort((a, b) => a.position - b.position) diff --git a/apps/sim/lib/pptx-renderer/utils/color.ts b/apps/sim/lib/pptx-renderer/utils/color.ts index 592d1459ac7..cb7be995976 100644 --- a/apps/sim/lib/pptx-renderer/utils/color.ts +++ b/apps/sim/lib/pptx-renderer/utils/color.ts @@ -3,102 +3,9 @@ // Full color manipulation for PowerPoint XML color processing // ============================================================================ -// --------------------------------------------------------------------------- -// Basic Color Conversions -// --------------------------------------------------------------------------- +export { hexToRgb, hslToRgb, rgbToHex, rgbToHsl, toCssColor } from '@/lib/colors' -/** - * Parse a hex color string (with or without '#') into RGB components. - */ -export function hexToRgb(hex: string): { r: number; g: number; b: number } { - const cleaned = hex.replace(/^#/, '') - if (cleaned.length !== 6 && cleaned.length !== 3) { - return { r: 0, g: 0, b: 0 } - } - const full = - cleaned.length === 3 - ? cleaned[0] + cleaned[0] + cleaned[1] + cleaned[1] + cleaned[2] + cleaned[2] - : cleaned - const num = Number.parseInt(full, 16) - return { - r: (num >> 16) & 0xff, - g: (num >> 8) & 0xff, - b: num & 0xff, - } -} - -/** - * Convert RGB components (0-255 each) to a 6-digit hex string with '#' prefix. - */ -export function rgbToHex(r: number, g: number, b: number): string { - const clamp = (v: number): number => Math.max(0, Math.min(255, Math.round(v))) - return `#${[clamp(r), clamp(g), clamp(b)].map((c) => c.toString(16).padStart(2, '0')).join('')}` -} - -/** - * Convert RGB (0-255) to HSL (h: 0-360, s: 0-1, l: 0-1). - */ -export function rgbToHsl(r: number, g: number, b: number): { h: number; s: number; l: number } { - const rn = r / 255 - const gn = g / 255 - const bn = b / 255 - const max = Math.max(rn, gn, bn) - const min = Math.min(rn, gn, bn) - const l = (max + min) / 2 - let h = 0 - let s = 0 - - if (max !== min) { - const d = max - min - s = l > 0.5 ? d / (2 - max - min) : d / (max + min) - switch (max) { - case rn: - h = ((gn - bn) / d + (gn < bn ? 6 : 0)) * 60 - break - case gn: - h = ((bn - rn) / d + 2) * 60 - break - case bn: - h = ((rn - gn) / d + 4) * 60 - break - } - } - - return { h, s, l } -} - -/** - * Convert HSL (h: 0-360, s: 0-1, l: 0-1) to RGB (0-255). - */ -export function hslToRgb(h: number, s: number, l: number): { r: number; g: number; b: number } { - h = ((h % 360) + 360) % 360 // normalize hue - s = Math.max(0, Math.min(1, s)) - l = Math.max(0, Math.min(1, l)) - - if (s === 0) { - const v = Math.round(l * 255) - return { r: v, g: v, b: v } - } - - const hueToRgb = (p: number, q: number, t: number): number => { - if (t < 0) t += 1 - if (t > 1) t -= 1 - if (t < 1 / 6) return p + (q - p) * 6 * t - if (t < 1 / 2) return q - if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6 - return p - } - - const q = l < 0.5 ? l * (1 + s) : l + s - l * s - const p = 2 * l - q - const hNorm = h / 360 - - return { - r: Math.round(hueToRgb(p, q, hNorm + 1 / 3) * 255), - g: Math.round(hueToRgb(p, q, hNorm) * 255), - b: Math.round(hueToRgb(p, q, hNorm - 1 / 3) * 255), - } -} +import { hexToRgb, hslToRgb, rgbToHex, rgbToHsl } from '@/lib/colors' // --------------------------------------------------------------------------- // sRGB ↔ Linear RGB conversion (IEC 61966-2-1) diff --git a/apps/sim/lib/workspaces/colors.ts b/apps/sim/lib/workspaces/colors.ts index fac7afe97d3..61401b68ad1 100644 --- a/apps/sim/lib/workspaces/colors.ts +++ b/apps/sim/lib/workspaces/colors.ts @@ -1,4 +1,5 @@ import { randomItem } from '@sim/utils/random' +import { hexToRgb } from '@/lib/colors' /** Color palette for workspace accents. */ export const WORKSPACE_COLORS = [ @@ -63,19 +64,7 @@ function withAlpha(hexColor: string, alpha: number): string { return hexColor } - const normalized = hexColor.slice(1) - const expanded = - normalized.length === 3 - ? normalized - .split('') - .map((char) => `${char}${char}`) - .join('') - : normalized - - const r = Number.parseInt(expanded.slice(0, 2), 16) - const g = Number.parseInt(expanded.slice(2, 4), 16) - const b = Number.parseInt(expanded.slice(4, 6), 16) - + const { r, g, b } = hexToRgb(hexColor) return `rgba(${r}, ${g}, ${b}, ${Math.min(Math.max(alpha, 0), 1)})` } diff --git a/package.json b/package.json index 5bb7f979950..1d97f5a14ad 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,7 @@ "check:react-query": "bun run scripts/check-react-query-patterns.ts --check", "check:client-boundary": "bun run scripts/check-client-boundary-imports.ts --check", "check:utils": "bun run scripts/check-utils-enforcement.ts", + "check:bare-icons": "bun run scripts/check-bare-icons.ts", "check:migrations": "bun run scripts/check-migrations-safety.ts", "mship-contracts:generate": "bun run scripts/sync-mothership-stream-contract.ts", "mship-contracts:check": "bun run scripts/sync-mothership-stream-contract.ts --check", diff --git a/packages/workflow-renderer/src/lib/tile-icon-color.ts b/packages/workflow-renderer/src/lib/tile-icon-color.ts new file mode 100644 index 00000000000..ce6ca930a69 --- /dev/null +++ b/packages/workflow-renderer/src/lib/tile-icon-color.ts @@ -0,0 +1,43 @@ +/** + * Foreground class for a brand icon rendered inside its colored block tile. + * + * This is a self-contained mirror of `apps/sim/lib/colors` + + * `getTileIconColorClass` (apps/sim/blocks/icon-color.ts). The renderer package + * is intentionally isolated and must not import app code, so the small bit of + * brightness math it needs lives here. Keep the threshold and behavior in sync + * with the canonical helper. + * + * Block icons are increasingly drawn with `fill='currentColor'`, so a tile must + * give them a foreground that contrasts the (fixed, non-theme) brand + * background: white on dark tiles, near-black on clearly light tiles. Hardcoded + * multi-color icons ignore the class and keep their own fills. + */ + +/** ITU-R BT.601 perceived brightness (0–1) of a `#rgb`/`#rrggbb` color, else null. */ +function perceivedBrightness(color: string): number | null { + const hex = color.trim().replace(/['"#]/g, '').toLowerCase() + let r: number + let g: number + let b: number + if (/^[0-9a-f]{3}$/.test(hex)) { + r = Number.parseInt(hex[0] + hex[0], 16) + g = Number.parseInt(hex[1] + hex[1], 16) + b = Number.parseInt(hex[2] + hex[2], 16) + } else if (/^[0-9a-f]{6}$/.test(hex)) { + r = Number.parseInt(hex.slice(0, 2), 16) + g = Number.parseInt(hex.slice(2, 4), 16) + b = Number.parseInt(hex.slice(4, 6), 16) + } else { + return null + } + return (0.299 * r + 0.587 * g + 0.114 * b) / 255 +} + +/** Tiles brighter than this flip their icon foreground to near-black. */ +const LIGHT_TILE_THRESHOLD = 0.75 + +/** `text-white` on dark/unknown tiles, `text-black` on clearly light tiles. */ +export function tileIconColorClass(bgColor: string | null | undefined): string { + const brightness = bgColor ? perceivedBrightness(bgColor) : null + return brightness !== null && brightness > LIGHT_TILE_THRESHOLD ? 'text-black' : 'text-white' +} diff --git a/packages/workflow-renderer/src/subflow/subflow-node-view.tsx b/packages/workflow-renderer/src/subflow/subflow-node-view.tsx index 4e179d0ceff..335a97b4591 100644 --- a/packages/workflow-renderer/src/subflow/subflow-node-view.tsx +++ b/packages/workflow-renderer/src/subflow/subflow-node-view.tsx @@ -3,6 +3,7 @@ import { Badge, cn, handleKeyboardActivation } from '@sim/emcn' import { RepeatIcon, SplitIcon } from 'lucide-react' import { Handle, Position } from 'reactflow' import { HANDLE_POSITIONS } from '../dimensions' +import { tileIconColorClass } from '../lib/tile-icon-color' import type { BlockRunStatus, DiffStatus } from '../types' /** Data attached to loop/parallel container nodes. */ @@ -163,7 +164,12 @@ export function SubflowNodeView({ className='flex size-[24px] flex-shrink-0 items-center justify-center rounded-md' style={{ backgroundColor: isEnabled ? blockIconBg : 'gray' }} > - + - + { + const b = perceivedBrightness(c) + return b !== null && b > 0.9 +} +const isNearBlack = (c: string) => { + const b = perceivedBrightness(c) + return b !== null && b < 0.1 +} + +/** Extract each exported icon's source body, keyed by component name. */ +function indexIconBodies(src: string): Map { + const bodies = new Map() + const starts: Array<{ name: string; index: number }> = [] + for (const m of src.matchAll(/export (?:function|const) (\w+)\s*[=(]/g)) { + starts.push({ name: m[1], index: m.index }) + } + for (let i = 0; i < starts.length; i++) { + const end = i + 1 < starts.length ? starts[i + 1].index : src.length + bodies.set(starts[i].name, src.slice(starts[i].index, end)) + } + return bodies +} + +interface Hazard { + block: string + icon: string + kind: 'light' | 'dark' + detail: string +} + +function analyzeIcon(body: string): { hazard: 'light' | 'dark' | null; detail: string } { + if (/currentColor/.test(body)) return { hazard: null, detail: 'uses currentColor' } + if (/url\(#| c.toLowerCase() !== 'currentcolor') + if (literal.length === 0) return { hazard: null, detail: 'no literal fills' } + const vivid = literal.filter((c) => !isNearWhite(c) && !isNearBlack(c)) + if (vivid.length > 0) return { hazard: null, detail: `multi-color (${vivid[0]})` } + if (literal.every(isNearWhite)) + return { hazard: 'light', detail: `only near-white fills (${literal.join(', ')})` } + if (literal.every(isNearBlack)) + return { hazard: 'dark', detail: `only near-black fills (${literal.join(', ')})` } + return { hazard: null, detail: 'mixed' } +} + +async function main() { + const iconsSrc = await readFile(ICONS_FILE, 'utf8') + const iconBodies = indexIconBodies(iconsSrc) + + const blockFiles = (await readdir(BLOCKS_DIR)).filter((f) => f.endsWith('.ts')) + const hazards: Hazard[] = [] + const seen = new Set() + + for (const file of blockFiles) { + const src = await readFile(path.join(BLOCKS_DIR, file), 'utf8') + if (!/\btemplates:\s*\[/.test(src)) continue + + const brandIcons = new Set() + for (const m of src.matchAll( + /import\s*(?:type\s*)?{([^}]*)}\s*from\s*'@\/components\/icons'/g + )) { + for (const name of m[1].split(',')) { + const trimmed = name.trim() + if (trimmed) brandIcons.add(trimmed) + } + } + + for (const m of src.matchAll(/\bicon:\s*(\w+)/g)) { + const iconName = m[1] + if (!brandIcons.has(iconName)) continue + const key = `${file}:${iconName}` + if (seen.has(key)) continue + seen.add(key) + const body = iconBodies.get(iconName) + if (!body) continue + const { hazard, detail } = analyzeIcon(body) + if (hazard) { + hazards.push({ block: file.replace('.ts', ''), icon: iconName, kind: hazard, detail }) + } + } + } + + if (hazards.length === 0) { + console.log('✓ All suggested-action brand icons render safely bare in light and dark mode.') + process.exit(0) + } + + console.error(`\nFound ${hazards.length} bare-icon hazard(s):\n`) + for (const h of hazards) { + const mode = h.kind === 'light' ? 'invisible on LIGHT pages' : 'invisible on DARK pages' + console.error(` ${h.block} (${h.icon}) — ${mode}`) + console.error(` ${h.detail}`) + console.error( + ` Fix: draw the monochrome shape with fill='currentColor' in components/icons.tsx` + ) + console.error( + ` so it adapts to the theme bare and to the tile foreground (getTileIconColorClass).\n` + ) + } + process.exit(1) +} + +main()