feat(studio): support themed pipeline destination logos (#51073)

## Problem

Some destination marks need different assets to maintain contrast across
light and dark surfaces. Their paths are also duplicated between
Pipelines and Wrappers.

## Solution

Add a shared brand-icon registry that supports either one asset or light
and dark variants. Pipeline destination logos resolve against the active
theme, while Wrappers continue using the light variant for their white
logo tiles.

This keeps single-asset destinations unchanged and preserves the
existing monogram treatment where applicable.

| Light | Dark |
| --- | --- |
| <img width="926" height="742" alt="CleanShot 2026-09-30 at 15 03
31@2x"
src="https://github.com/user-attachments/assets/d76e5995-1bb8-4cb7-b991-c5dc1a5d8cb0"
/> | <img width="926" height="732" alt="CleanShot 2026-09-30 at 15 03
03@2x"
src="https://github.com/user-attachments/assets/83d45f30-3bf6-4ddc-8607-e27628cb4966"
/> |

## Review instructions

1. Open `/project/<ref>/database/replication` with pipelines using the
themed destination marks.
2. Switch between light and dark themes from the account menu.
3. Confirm each themed mark swaps assets and remains legible in the
pipeline list, diagram, and child-route header.
4. Open Database Integrations and confirm the corresponding Wrapper
tiles continue using their light-surface assets.

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **Style**
* Updated the BigQuery, ClickHouse, DuckLake, and Snowflake icons in
integration and replication views to use branded marks. Icons now use
theme-appropriate variants where available, helping them display
consistently across light and dark themes. ClickHouse’s previous “CH”
monogram is replaced with its branded mark.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Danny White authored and GitHub committed 2026-10-01 12:04:07 +10:00
1 parent cf063c4ae8
commit 5fbf1ac4ff
8 files changed
+93 -9

No files matched your search

@@ -1,15 +1,17 @@
import { useTheme } from 'next-themes'
import { cn, StatusIcon } from 'ui'
import { DestinationIcon } from './DestinationIcon'
import type { DestinationType } from './DestinationPanel/DestinationPanel.types'
import { BASE_PATH } from '@/lib/constants'
import { BRAND_ICONS, resolveThemedIconSrc, type ThemedIconSrc } from '@/lib/brand-icons'
import { resolveThemeOverrideMode } from '@/lib/theme-overrides'
type DestinationBrand = { type: 'mark'; src: string } | { type: 'monogram'; label: string }
type DestinationBrand = { type: 'mark'; src: ThemedIconSrc } | { type: 'monogram'; label: string }
const BRAND_BY_TYPE: Partial<Record<DestinationType, DestinationBrand>> = {
BigQuery: { type: 'mark', src: `${BASE_PATH}/img/icons/bigquery-icon.svg` },
ClickHouse: { type: 'monogram', label: 'CH' },
DuckLake: { type: 'mark', src: `${BASE_PATH}/img/icons/ducklake-icon.svg` },
BigQuery: { type: 'mark', src: BRAND_ICONS.bigquery },
ClickHouse: { type: 'mark', src: BRAND_ICONS.clickhouse },
DuckLake: { type: 'mark', src: BRAND_ICONS.ducklake },
Snowflake: { type: 'monogram', label: 'SF' },
}
@@ -36,8 +38,11 @@ export const DestinationLogo = ({
className,
hasErrors = false,
}: DestinationLogoProps) => {
const { resolvedTheme } = useTheme()
const isDark = resolveThemeOverrideMode(resolvedTheme) === 'dark'
const sizing = SIZE_CLASS_NAME[size]
const brand = BRAND_BY_TYPE[type]
const brandMarkSrc = brand?.type === 'mark' ? resolveThemedIconSrc(brand.src, isDark) : undefined
return (
<span className={cn('relative inline-flex shrink-0', className)}>
@@ -46,7 +51,7 @@ export const DestinationLogo = ({
<DestinationIcon type={type} size={sizing.icon} className="text-foreground-light" />
)}
{brand?.type === 'mark' && (
<img src={brand.src} alt="" aria-hidden className={sizing.mark} />
<img src={brandMarkSrc} alt="" aria-hidden className={sizing.mark} />
)}
{brand?.type === 'monogram' && (
<span
@@ -1,4 +1,5 @@
import type { ServerOption, WrapperMeta } from './Wrappers.types'
import { BRAND_ICONS, resolveLightIconSrc } from '@/lib/brand-icons'
import { BASE_PATH, DOCS_URL } from '@/lib/constants'
export const WRAPPER_HANDLERS = {
@@ -1472,7 +1473,8 @@ export const WRAPPERS: WrapperMeta[] = [
name: 'clickhouse_wrapper',
handlerName: WRAPPER_HANDLERS.CLICK_HOUSE,
validatorName: 'click_house_fdw_validator',
icon: `${BASE_PATH}/img/icons/clickhouse-icon.svg`,
// Light variant: IntegrationLogo / wrapper cards force a white tile.
icon: resolveLightIconSrc(BRAND_ICONS.clickhouse),
description: 'Column-oriented analytics database',
extensionName: 'ClickHouseFdw',
label: 'ClickHouse',
@@ -1518,7 +1520,7 @@ export const WRAPPERS: WrapperMeta[] = [
name: 'bigquery_wrapper',
handlerName: WRAPPER_HANDLERS.BIG_QUERY,
validatorName: 'big_query_fdw_validator',
icon: `${BASE_PATH}/img/icons/bigquery-icon.svg`,
icon: resolveLightIconSrc(BRAND_ICONS.bigquery),
description: 'Serverless data warehouse and analytics',
extensionName: 'BigQueryFdw',
label: 'BigQuery',
@@ -2298,7 +2300,7 @@ export const WRAPPERS: WrapperMeta[] = [
name: 'snowflake_wrapper',
handlerName: WRAPPER_HANDLERS.SNOWFLAKE,
validatorName: 'wasm_fdw_validator',
icon: `${BASE_PATH}/img/icons/snowflake-icon.svg`,
icon: resolveLightIconSrc(BRAND_ICONS.snowflake),
description: 'Cloud data warehouse platform',
extensionName: 'snowflakeFdw',
label: 'Snowflake',
+27
View File
@@ -0,0 +1,27 @@
import { describe, expect, it } from 'vitest'
import { resolveLightIconSrc, resolveThemedIconSrc } from './brand-icons'
describe('resolveThemedIconSrc', () => {
it('returns a single-asset path for either theme', () => {
expect(resolveThemedIconSrc('/img/icons/bigquery-icon.svg', false)).toBe(
'/img/icons/bigquery-icon.svg'
)
expect(resolveThemedIconSrc('/img/icons/bigquery-icon.svg', true)).toBe(
'/img/icons/bigquery-icon.svg'
)
})
it('picks light or dark when both are provided', () => {
const src = { light: '/light.svg', dark: '/dark.svg' }
expect(resolveThemedIconSrc(src, false)).toBe('/light.svg')
expect(resolveThemedIconSrc(src, true)).toBe('/dark.svg')
})
})
describe('resolveLightIconSrc', () => {
it('returns the only asset or the light variant', () => {
expect(resolveLightIconSrc('/img/icons/bigquery-icon.svg')).toBe('/img/icons/bigquery-icon.svg')
expect(resolveLightIconSrc({ light: '/light.svg', dark: '/dark.svg' })).toBe('/light.svg')
})
})
+35
View File
@@ -0,0 +1,35 @@
import { BASE_PATH } from '@/lib/constants'
/**
* Brand mark paths shared by Pipelines (`DestinationLogo`) and Integrations
* Wrappers. A single string is theme-agnostic; `{ light, dark }` opts into
* per-theme assets.
*
* Wrappers / `IntegrationLogo` force a white tile, so they should always use
* {@link resolveLightIconSrc}. DestinationLogo sits on `bg-surface-100` and
* should resolve against the active theme.
*/
export type ThemedIconSrc = string | { light: string; dark: string }
export const BRAND_ICONS = {
bigquery: `${BASE_PATH}/img/icons/bigquery-icon.svg`,
clickhouse: {
light: `${BASE_PATH}/img/icons/clickhouse-icon-on-light.svg`,
dark: `${BASE_PATH}/img/icons/clickhouse-icon-on-dark.svg`,
},
ducklake: {
light: `${BASE_PATH}/img/icons/ducklake-icon-on-light.svg`,
dark: `${BASE_PATH}/img/icons/ducklake-icon-on-dark.svg`,
},
snowflake: `${BASE_PATH}/img/icons/snowflake-icon.svg`,
} as const satisfies Record<string, ThemedIconSrc>
export function resolveThemedIconSrc(src: ThemedIconSrc, isDark: boolean): string {
if (typeof src === 'string') return src
return isDark ? src.dark : src.light
}
/** Light (or only) variant — for surfaces that force a white logo tile. */
export function resolveLightIconSrc(src: ThemedIconSrc): string {
return typeof src === 'string' ? src : src.light
}
@@ -0,0 +1,4 @@
<svg width="512" height="512" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="512" height="512" rx="36" fill="#141600"/>
<path d="M90 89.9612C90 87.6505 91.6505 86 93.9612 86H123.67C125.981 86 127.631 87.6505 127.631 89.9612V422.369C127.631 424.68 125.981 426.33 123.67 426.33H93.9612C91.6505 426.33 90 424.68 90 422.369V89.9612ZM165.592 89.9612C165.592 87.6505 167.243 86 169.553 86H199.262C201.573 86 203.223 87.6505 203.223 89.9612V422.369C203.223 424.68 201.573 426.33 199.262 426.33H169.553C167.243 426.33 165.592 424.68 165.592 422.369V89.9612ZM241.184 89.9612C241.184 87.6505 242.835 86 245.146 86H274.854C277.165 86 278.816 87.6505 278.816 89.9612V422.369C278.816 424.68 277.165 426.33 274.854 426.33H245.146C242.835 426.33 241.184 424.68 241.184 422.369V89.9612ZM316.777 89.9612C316.777 87.6505 318.427 86 320.738 86H350.447C352.757 86 354.408 87.6505 354.408 89.9612V422.369C354.408 424.68 352.757 426.33 350.447 426.33H320.738C318.427 426.33 316.777 424.68 316.777 422.369V89.9612ZM392.369 222.33C392.369 220.019 394.019 218.369 396.33 218.369H426.039C428.35 218.369 430 220.019 430 222.33V290C430 292.311 428.35 293.961 426.039 293.961H396.33C394.019 293.961 392.369 292.311 392.369 290V222.33Z" fill="#FCFF74"/>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

@@ -0,0 +1,11 @@
<svg width="512" height="512" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_ducklake_light)">
<path fill-rule="evenodd" clip-rule="evenodd" d="M256 0C114.618 0 0 114.618 0 256C0 397.381 114.618 512 256 512C397.382 512 512 397.381 512 256C512 114.618 397.382 0 256 0Z" fill="#1A1A1A"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M223.708 129.455C270.255 129.455 310.982 154.472 332.508 191.71H355.782C387.782 191.71 414.546 217.31 414.546 249.89C414.546 281.89 388.364 308.654 355.782 308.654H338.908C319.708 351.127 277.817 380.799 228.946 382.545H217.309C150.4 379.054 97.4543 323.781 97.4543 256.29C97.4543 186.472 153.891 129.455 223.708 129.455ZM223.708 167.855C175.417 167.855 135.855 207.419 135.855 255.71C135.855 304.581 175.417 343.563 223.708 343.563C262.691 343.563 295.855 317.963 307.491 283.054L311.564 269.672H355.782C366.255 269.672 375.564 260.363 375.564 249.89C375.564 239.419 366.835 230.11 355.782 230.11H308.655L303.417 219.054C289.455 188.799 259.2 167.855 223.708 167.855Z" fill="#2EAFFF"/>
</g>
<defs>
<clipPath id="clip0_ducklake_light">
<rect width="512" height="512" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB