diff --git a/models/setting/src/index.ts b/models/setting/src/index.ts index 9778abb96e0..f0c0038ea82 100644 --- a/models/setting/src/index.ts +++ b/models/setting/src/index.ts @@ -134,6 +134,9 @@ export class TOfficeSettings extends TConfiguration implements OfficeSettings { @Model(setting.class.WorkspaceSetting, core.class.Doc, DOMAIN_SETTING) export class TWorkspaceSetting extends TDoc implements WorkspaceSetting { icon?: Ref + identificationColor?: string | null + syncWorkspaceLogo?: boolean + identificationColorEnabled?: boolean } @Mixin(setting.mixin.SpaceTypeEditor, core.class.Class) diff --git a/packages/presentation/src/___tests___/workspaceIdentity.test.ts b/packages/presentation/src/___tests___/workspaceIdentity.test.ts new file mode 100644 index 00000000000..01fe6a94ef1 --- /dev/null +++ b/packages/presentation/src/___tests___/workspaceIdentity.test.ts @@ -0,0 +1,57 @@ +// Copyright © 2026 Huly Contributors. Licensed under the Eclipse Public License, Version 2.0. + +import { mixLogoColor, normalizeIdentityColor, renderWorkspaceIdentity } from '../workspaceIdentity' + +describe('workspace identification colour', () => { + it('mixes colours in linear light', () => { + expect(mixLogoColor(new Uint8ClampedArray([255, 0, 0, 255, 0, 0, 255, 255]))).toBe('#bc00bc') + }) + + it('ignores transparent padding and weights partially transparent pixels', () => { + expect(mixLogoColor(new Uint8ClampedArray([255, 255, 255, 0, 0, 128, 255, 255]))).toBe('#0080ff') + expect(mixLogoColor(new Uint8ClampedArray([255, 0, 0, 255, 0, 0, 255, 85]))).toBe('#e10089') + }) + + it('uses a stable fallback for empty or transparent images', () => { + expect(mixLogoColor(new Uint8ClampedArray())).toBe('#64748b') + expect(mixLogoColor(new Uint8ClampedArray([255, 0, 0, 0]))).toBe('#64748b') + }) + + it('normalizes manual choices and rejects malformed persisted values', () => { + expect(normalizeIdentityColor(' #FF8800 ')).toBe('#ff8800') + for (const value of [null, undefined, '', '#fff', 'red', '#12345678', 'url(x)']) { + expect(normalizeIdentityColor(value)).toBeUndefined() + } + }) +}) + +describe('optional workspace favicon', () => { + const originalFetch = globalThis.fetch + afterEach(() => { globalThis.fetch = originalFetch }) + + it('does not load images when both options are disabled', async () => { + const fetchIcon = jest.fn() + globalThis.fetch = fetchIcon + await expect(renderWorkspaceIdentity('/workspace.png', null, undefined, { + syncLogo: false, showColor: false + })).resolves.toEqual({ color: '#64748b' }) + expect(fetchIcon).not.toHaveBeenCalled() + }) + + it('does not wait for a workspace logo when applying a manual badge to the site icon', async () => { + const fetchIcon = jest.fn().mockRejectedValue(new Error('Site icon unavailable')) + globalThis.fetch = fetchIcon + await expect(renderWorkspaceIdentity('/workspace.png', '#ff8800', undefined, { + syncLogo: false, showColor: true, defaultIconUrl: '/site.ico' + })).rejects.toThrow('Site icon unavailable') + expect(fetchIcon).toHaveBeenCalledTimes(1) + expect(fetchIcon).toHaveBeenCalledWith('/site.ico', { signal: undefined }) + }) + + it('falls back to the unchanged site favicon if the workspace logo is unavailable', async () => { + globalThis.fetch = jest.fn().mockRejectedValue(new Error('Workspace logo unavailable')) + await expect(renderWorkspaceIdentity('/workspace.png', null, undefined, { + syncLogo: true, showColor: false + })).resolves.toEqual({ color: '#64748b' }) + }) +}) diff --git a/packages/presentation/src/index.ts b/packages/presentation/src/index.ts index a10eb625916..9218c95e83f 100644 --- a/packages/presentation/src/index.ts +++ b/packages/presentation/src/index.ts @@ -80,3 +80,4 @@ export * from './drawingCommandsProcessor' export * from './link-preview' export * from './communication' export * from './pulse' +export * from './workspaceIdentity' diff --git a/packages/presentation/src/workspaceIdentity.ts b/packages/presentation/src/workspaceIdentity.ts new file mode 100644 index 00000000000..8907080951e --- /dev/null +++ b/packages/presentation/src/workspaceIdentity.ts @@ -0,0 +1,184 @@ +// Copyright © 2026 Huly Contributors. Licensed under the Eclipse Public License, Version 2.0. + +export const defaultIdentityColor = '#64748b' + +/** @public */ +export function normalizeIdentityColor (value?: string | null): string | undefined { + if (typeof value !== 'string') return undefined + const color = value.trim().toLowerCase() + return /^#[0-9a-f]{6}$/.test(color) ? color : undefined +} + +/** Mix visible pixels in linear RGB. Transparent padding contributes no colour. @public */ +export function mixLogoColor (pixels: Uint8ClampedArray): string { + const total = [0, 0, 0] + let weight = 0 + for (let i = 0; i + 3 < pixels.length; i += 4) { + const alpha = pixels[i + 3] / 255 + weight += alpha + for (let channel = 0; channel < 3; channel++) { + const srgb = pixels[i + channel] / 255 + total[channel] += (srgb <= 0.04045 ? srgb / 12.92 : ((srgb + 0.055) / 1.055) ** 2.4) * alpha + } + } + if (weight === 0) return defaultIdentityColor + return '#' + total.map((sum) => { + const linear = sum / weight + const srgb = linear <= 0.0031308 ? linear * 12.92 : 1.055 * linear ** (1 / 2.4) - 0.055 + return Math.round(Math.max(0, Math.min(1, srgb)) * 255).toString(16).padStart(2, '0') + }).join('') +} + +/** @public */ +export interface WorkspaceIdentityImage { + color: string + favicon?: string +} + +/** @public */ +export interface WorkspaceFaviconOptions { + syncLogo: boolean + showColor: boolean + defaultIconUrl?: string +} + +const originalIcons = new WeakMap() +function getOriginalIcons (ownerDocument: Document): HTMLLinkElement[] { + let icons = originalIcons.get(ownerDocument) + if (icons === undefined) { + icons = Array.from(ownerDocument.head.querySelectorAll('link[rel~="icon"]:not(#workspace-favicon)')) + originalIcons.set(ownerDocument, icons) + } + return icons +} + +/** Resolve the site's icon even while the workspace favicon owns the head links. @public */ +export function getDefaultWorkspaceFaviconUrl (ownerDocument: Document = document): string { + const icons = getOriginalIcons(ownerDocument) + return icons[icons.length - 1]?.href ?? new URL('/favicon.ico', ownerDocument.baseURI).href +} + +async function decodeIcon (url: string, signal?: AbortSignal): Promise { + const response = await fetch(url, { signal }) + if (!response.ok) throw new Error('Unable to load icon') + const objectUrl = URL.createObjectURL(await response.blob()) + try { + const image = new Image() + image.src = objectUrl + await image.decode() + signal?.throwIfAborted() + return image + } finally { + URL.revokeObjectURL(objectUrl) + } +} + +/** Decode a logo once, without changing the stored asset. @public */ +export async function renderWorkspaceIdentity ( + logoUrl?: string, + manualColor?: string | null, + signal?: AbortSignal, + options: WorkspaceFaviconOptions = { syncLogo: true, showColor: true } +): Promise { + const override = normalizeIdentityColor(manualColor) + let color = override ?? defaultIdentityColor + if (!options.syncLogo && !options.showColor) return { color } + let logo: HTMLImageElement | undefined + if (logoUrl !== undefined && (options.syncLogo || (options.showColor && override === undefined))) { + try { + logo = await decodeIcon(logoUrl, signal) + } catch { + signal?.throwIfAborted() + // A removed/unavailable workspace logo falls back to the original site icon. + } + } + if (logo !== undefined && options.showColor && override === undefined) { + const sample = document.createElement('canvas') + const ratio = Math.min(64 / logo.naturalWidth, 64 / logo.naturalHeight) + sample.width = Math.max(1, Math.round(logo.naturalWidth * ratio)) + sample.height = Math.max(1, Math.round(logo.naturalHeight * ratio)) + const sampleContext = sample.getContext('2d', { willReadFrequently: true }) + if (sampleContext === null) throw new Error('Canvas is unavailable') + sampleContext.drawImage(logo, 0, 0, sample.width, sample.height) + color = mixLogoColor(sampleContext.getImageData(0, 0, sample.width, sample.height).data) + } + let image = options.syncLogo ? logo : undefined + if (image === undefined) { + if (!options.showColor || options.defaultIconUrl === undefined) return { color } + image = await decodeIcon(options.defaultIconUrl, signal) + } + const canvas = document.createElement('canvas') + canvas.width = canvas.height = 32 + const context = canvas.getContext('2d') + if (context === null) throw new Error('Canvas is unavailable') + // Keep the full favicon footprint; the badge overlays the artwork without reserving space. + const scale = Math.min(canvas.width / image.naturalWidth, canvas.height / image.naturalHeight) + const width = image.naturalWidth * scale + const height = image.naturalHeight * scale + context.drawImage(image, (canvas.width - width) / 2, (canvas.height - height) / 2, width, height) + if (options.showColor) { + // At 16px this is a 5px marker. Dual edging works on light and dark browser chrome. + context.beginPath() + context.arc(25, 25, 6, 0, Math.PI * 2) + context.fillStyle = '#ffffff' + context.fill() + context.strokeStyle = '#334155' + context.lineWidth = 0.75 + context.stroke() + context.beginPath() + context.arc(25, 25, 4.75, 0, Math.PI * 2) + context.fillStyle = color + context.fill() + } + return { color, favicon: canvas.toDataURL('image/png') } +} + +/** Own favicon links only; preserve touch icons/manifest and restore on disposal. @public */ +export function createWorkspaceFavicon (ownerDocument: Document = document): { + update: (logoUrl?: string, color?: string | null, options?: WorkspaceFaviconOptions) => Promise + dispose: () => void +} { + const defaults = getOriginalIcons(ownerDocument) + const link = ownerDocument.createElement('link') + link.rel = 'icon' + link.type = 'image/png' + link.sizes.value = '32x32' + link.id = 'workspace-favicon' + let revision = 0 + let disposed = false + let request: AbortController | undefined + function restore (): void { + link.remove() + for (const original of defaults) { + if (!original.isConnected) ownerDocument.head.appendChild(original) + } + } + return { + async update (logoUrl, color, options) { + if (disposed) return + const current = ++revision + request?.abort() + request = new AbortController() + try { + const result = await renderWorkspaceIdentity(logoUrl, color, request.signal, + options === undefined ? undefined : { ...options, defaultIconUrl: getDefaultWorkspaceFaviconUrl(ownerDocument) }) + if (disposed || current !== revision) return + if (result.favicon === undefined) { + restore() + return + } + link.href = result.favicon + for (const original of defaults) original.remove() + if (!link.isConnected) ownerDocument.head.appendChild(link) + } catch { + if (!disposed && current === revision) restore() + } + }, + dispose () { + disposed = true + revision++ + request?.abort() + restore() + } + } +} diff --git a/plugins/setting-assets/lang/cs.json b/plugins/setting-assets/lang/cs.json index acbd43fc2ce..88387e354e6 100644 --- a/plugins/setting-assets/lang/cs.json +++ b/plugins/setting-assets/lang/cs.json @@ -1,5 +1,10 @@ { "string": { + "IdentificationColor": "Rozlišovací barva", + "ColorDefault": "Výchozí", + "ColorLogoUnavailable": "Ikonu se nepodařilo načíst. Používá se výchozí ikona karty.", + "ColorSaveFailed": "Nastavení se nepodařilo uložit. Zkuste to znovu.", + "SyncWorkspaceLogo": "Synchronizovat logo s kartou prohlížeče", "Setting": "Nastavení", "Spaces": "Prostory", "Integrations": "Integrace", diff --git a/plugins/setting-assets/lang/de.json b/plugins/setting-assets/lang/de.json index 44ab70feeca..9d8c0f3333f 100644 --- a/plugins/setting-assets/lang/de.json +++ b/plugins/setting-assets/lang/de.json @@ -1,5 +1,10 @@ { "string": { + "IdentificationColor": "Erkennungsfarbe", + "ColorDefault": "Standard", + "ColorLogoUnavailable": "Das Symbol konnte nicht geladen werden. Das Standardsymbol des Tabs wird verwendet.", + "ColorSaveFailed": "Die Einstellungen konnten nicht gespeichert werden. Bitte erneut versuchen.", + "SyncWorkspaceLogo": "Logo mit Browser-Tab synchronisieren", "Setting": "Einstellung", "Spaces": "Bereiche", "Integrations": "Integrationen", diff --git a/plugins/setting-assets/lang/en.json b/plugins/setting-assets/lang/en.json index 2693e105cd6..231da8cfc9e 100644 --- a/plugins/setting-assets/lang/en.json +++ b/plugins/setting-assets/lang/en.json @@ -1,5 +1,10 @@ { "string": { + "IdentificationColor": "Identification color", + "ColorDefault": "Default", + "ColorLogoUnavailable": "The icon could not be loaded. The default tab icon is used.", + "ColorSaveFailed": "Could not save the settings. Please try again.", + "SyncWorkspaceLogo": "Sync logo with browser tab", "Setting": "Setting", "Spaces": "Spaces", "Integrations": "Integrations", diff --git a/plugins/setting-assets/lang/es.json b/plugins/setting-assets/lang/es.json index 0e1e39559ae..dff9485a34e 100644 --- a/plugins/setting-assets/lang/es.json +++ b/plugins/setting-assets/lang/es.json @@ -1,5 +1,10 @@ { "string": { + "IdentificationColor": "Color identificativo", + "ColorDefault": "Predeterminado", + "ColorLogoUnavailable": "No se pudo cargar el icono. Se usa el icono predeterminado de la pestaña.", + "ColorSaveFailed": "No se pudo guardar la configuración. Inténtalo de nuevo.", + "SyncWorkspaceLogo": "Sincronizar logotipo con la pestaña", "Setting": "Configuración", "Spaces": "Espacios", "Integrations": "Integraciones", diff --git a/plugins/setting-assets/lang/fr.json b/plugins/setting-assets/lang/fr.json index 2d4e38c7a04..328f16db057 100644 --- a/plugins/setting-assets/lang/fr.json +++ b/plugins/setting-assets/lang/fr.json @@ -1,5 +1,10 @@ { "string": { + "IdentificationColor": "Couleur distinctive", + "ColorDefault": "Par défaut", + "ColorLogoUnavailable": "Impossible de charger l’icône. L’icône par défaut de l’onglet est utilisée.", + "ColorSaveFailed": "Impossible d’enregistrer les paramètres. Veuillez réessayer.", + "SyncWorkspaceLogo": "Synchroniser le logo avec l’onglet", "Setting": "Paramètre", "Spaces": "Espaces", "Integrations": "Intégrations", diff --git a/plugins/setting-assets/lang/it.json b/plugins/setting-assets/lang/it.json index fd2b379f309..5eea9ffed82 100644 --- a/plugins/setting-assets/lang/it.json +++ b/plugins/setting-assets/lang/it.json @@ -1,5 +1,10 @@ { "string": { + "IdentificationColor": "Colore identificativo", + "ColorDefault": "Predefinito", + "ColorLogoUnavailable": "Impossibile caricare l’icona. Viene utilizzata l’icona predefinita della scheda.", + "ColorSaveFailed": "Impossibile salvare le impostazioni. Riprova.", + "SyncWorkspaceLogo": "Sincronizza logo con la scheda", "Setting": "Impostazione", "Spaces": "Spazi", "Integrations": "Integrazioni", diff --git a/plugins/setting-assets/lang/ja.json b/plugins/setting-assets/lang/ja.json index 9d49ce7d1ab..2d2bbb5a3e9 100644 --- a/plugins/setting-assets/lang/ja.json +++ b/plugins/setting-assets/lang/ja.json @@ -1,5 +1,10 @@ { "string": { + "IdentificationColor": "識別色", + "ColorDefault": "デフォルト", + "ColorLogoUnavailable": "アイコンを読み込めませんでした。既定のタブアイコンを使用します。", + "ColorSaveFailed": "設定を保存できませんでした。もう一度お試しください。", + "SyncWorkspaceLogo": "ロゴをブラウザーのタブと同期", "Setting": "設定", "Spaces": "スペース", "Integrations": "連携", diff --git a/plugins/setting-assets/lang/ko.json b/plugins/setting-assets/lang/ko.json index 891fc5adbfa..9f5a3e74917 100644 --- a/plugins/setting-assets/lang/ko.json +++ b/plugins/setting-assets/lang/ko.json @@ -1,5 +1,10 @@ { "string": { + "IdentificationColor": "식별 색상", + "ColorDefault": "기본값", + "ColorLogoUnavailable": "아이콘을 불러올 수 없습니다. 기본 탭 아이콘을 사용합니다.", + "ColorSaveFailed": "설정을 저장할 수 없습니다. 다시 시도해 주세요.", + "SyncWorkspaceLogo": "로고를 브라우저 탭과 동기화", "Setting": "설정", "Spaces": "스페이스", "Integrations": "연동", diff --git a/plugins/setting-assets/lang/pl.json b/plugins/setting-assets/lang/pl.json index 575890db1b4..445759ab2e1 100644 --- a/plugins/setting-assets/lang/pl.json +++ b/plugins/setting-assets/lang/pl.json @@ -1,5 +1,10 @@ { "string": { + "IdentificationColor": "Kolor identyfikacyjny", + "ColorDefault": "Domyślny", + "ColorLogoUnavailable": "Nie udało się wczytać ikony. Używana jest domyślna ikona karty.", + "ColorSaveFailed": "Nie udało się zapisać ustawień. Spróbuj ponownie.", + "SyncWorkspaceLogo": "Synchronizuj logo z kartą przeglądarki", "Setting": "Ustawienia", "Spaces": "Przestrzenie", "Integrations": "Integracje", diff --git a/plugins/setting-assets/lang/pt-br.json b/plugins/setting-assets/lang/pt-br.json index 5942268930d..87db7882d20 100644 --- a/plugins/setting-assets/lang/pt-br.json +++ b/plugins/setting-assets/lang/pt-br.json @@ -1,5 +1,10 @@ { "string": { + "IdentificationColor": "Cor de identificação", + "ColorDefault": "Padrão", + "ColorLogoUnavailable": "Não foi possível carregar o ícone. O ícone padrão da aba está sendo usado.", + "ColorSaveFailed": "Não foi possível salvar as configurações. Tente novamente.", + "SyncWorkspaceLogo": "Sincronizar logotipo com a aba", "Setting": "Configuração", "Spaces": "Espaços", "Integrations": "Integrações", diff --git a/plugins/setting-assets/lang/pt.json b/plugins/setting-assets/lang/pt.json index 27330bffa66..024d1c37d2d 100644 --- a/plugins/setting-assets/lang/pt.json +++ b/plugins/setting-assets/lang/pt.json @@ -1,5 +1,10 @@ { "string": { + "IdentificationColor": "Cor de identificação", + "ColorDefault": "Predefinido", + "ColorLogoUnavailable": "Não foi possível carregar o ícone. Está a ser utilizado o ícone predefinido do separador.", + "ColorSaveFailed": "Não foi possível guardar as definições. Tente novamente.", + "SyncWorkspaceLogo": "Sincronizar logótipo com o separador", "Setting": "Configuração", "Spaces": "Espaços", "Integrations": "Integrações", diff --git a/plugins/setting-assets/lang/ru.json b/plugins/setting-assets/lang/ru.json index 4a70535508c..36b3c3f4af1 100644 --- a/plugins/setting-assets/lang/ru.json +++ b/plugins/setting-assets/lang/ru.json @@ -1,5 +1,10 @@ { "string": { + "IdentificationColor": "Цвет для различения", + "ColorDefault": "По умолчанию", + "ColorLogoUnavailable": "Не удалось загрузить значок. Используется стандартный значок вкладки.", + "ColorSaveFailed": "Не удалось сохранить настройки. Попробуйте ещё раз.", + "SyncWorkspaceLogo": "Синхронизировать логотип со вкладкой", "Setting": "Настройки", "Spaces": "Пространства", "Integrations": "Интеграции", diff --git a/plugins/setting-assets/lang/tr.json b/plugins/setting-assets/lang/tr.json index 241f718e22f..a574349f62e 100644 --- a/plugins/setting-assets/lang/tr.json +++ b/plugins/setting-assets/lang/tr.json @@ -1,5 +1,10 @@ { "string": { + "IdentificationColor": "Ayırt edici renk", + "ColorDefault": "Varsayılan", + "ColorLogoUnavailable": "Simge yüklenemedi. Varsayılan sekme simgesi kullanılıyor.", + "ColorSaveFailed": "Ayarlar kaydedilemedi. Lütfen tekrar deneyin.", + "SyncWorkspaceLogo": "Logoyu tarayıcı sekmesiyle eşitle", "Setting": "Ayar", "Spaces": "Alanlar", "Integrations": "Entegrasyonlar", diff --git a/plugins/setting-assets/lang/zh.json b/plugins/setting-assets/lang/zh.json index cb1fe9fc184..67774a9c7e0 100644 --- a/plugins/setting-assets/lang/zh.json +++ b/plugins/setting-assets/lang/zh.json @@ -1,5 +1,10 @@ { "string": { + "IdentificationColor": "识别色", + "ColorDefault": "默认", + "ColorLogoUnavailable": "无法加载图标,已使用默认标签页图标。", + "ColorSaveFailed": "无法保存设置,请重试。", + "SyncWorkspaceLogo": "Logo 与标签页同步", "Setting": "设置", "Spaces": "空间", "Integrations": "集成", diff --git a/plugins/setting-resources/src/components/General.svelte b/plugins/setting-resources/src/components/General.svelte index 8cd1e8c18c6..9c41a2ec610 100644 --- a/plugins/setting-resources/src/components/General.svelte +++ b/plugins/setting-resources/src/components/General.svelte @@ -44,6 +44,7 @@ } from '@hcengineering/ui' import settingsRes from '../plugin' import WorkspacePermissionEditor from './WorkspacePermissionEditor.svelte' + import WorkspaceIdentityColor from './WorkspaceIdentityColor.svelte' let loading = true let isEditingName = false @@ -108,8 +109,9 @@ let workspaceSettings: WorkspaceSetting | undefined = undefined const client = getClient() - void client.findOne(settingsRes.class.WorkspaceSetting, {}).then((r) => { - workspaceSettings = r + const workspaceSettingsQuery = createQuery() + workspaceSettingsQuery.query(settingsRes.class.WorkspaceSetting, { _id: settingsRes.ids.WorkspaceSetting }, (result) => { + workspaceSettings = result[0] }) async function handleAvatarDone (): Promise { @@ -218,7 +220,7 @@
{/if}
+
@@ -337,6 +340,7 @@ .ws { display: flex; align-items: center; + flex-wrap: wrap; gap: 1rem; } diff --git a/plugins/setting-resources/src/components/WorkspaceIdentityColor.svelte b/plugins/setting-resources/src/components/WorkspaceIdentityColor.svelte new file mode 100644 index 00000000000..b8735c8d022 --- /dev/null +++ b/plugins/setting-resources/src/components/WorkspaceIdentityColor.svelte @@ -0,0 +1,161 @@ + + + +
+
+ +
+ {#key saveRevision} + save({ syncWorkspaceLogo: event.detail })} /> + {/key} +
+
+
+ +
+ {#key saveRevision} + save({ identificationColorEnabled: event.detail })} /> + {/key} + {#if showColor} +
+ + +
+ {/if} +
+
+ {#if imageError}
{/if} + {#if error}{/if} +
+ + diff --git a/plugins/setting/src/index.ts b/plugins/setting/src/index.ts index bf8d3ef6a0a..d7a65ffeacf 100644 --- a/plugins/setting/src/index.ts +++ b/plugins/setting/src/index.ts @@ -165,6 +165,10 @@ export interface OfficeSettings extends Configuration { */ export interface WorkspaceSetting extends Doc { icon?: Ref | null + /** A manual colour override. Missing/null follows the current logo automatically. */ + identificationColor?: string | null + syncWorkspaceLogo?: boolean + identificationColorEnabled?: boolean } export enum IntegrationError { @@ -255,6 +259,11 @@ export default plugin(settingId, { Setting: '' as IntlString, Spaces: '' as IntlString, WorkspaceSettings: '' as IntlString, + IdentificationColor: '' as IntlString, + SyncWorkspaceLogo: '' as IntlString, + ColorDefault: '' as IntlString, + ColorLogoUnavailable: '' as IntlString, + ColorSaveFailed: '' as IntlString, Integrations: '' as IntlString, Support: '' as IntlString, Privacy: '' as IntlString, diff --git a/plugins/workbench-resources/src/components/Workbench.svelte b/plugins/workbench-resources/src/components/Workbench.svelte index 1f81828e780..0e27b2ecf41 100644 --- a/plugins/workbench-resources/src/components/Workbench.svelte +++ b/plugins/workbench-resources/src/components/Workbench.svelte @@ -111,6 +111,7 @@ import AppSwitcher from './AppSwitcher.svelte' import Applications from './Applications.svelte' import Logo from './Logo.svelte' + import WorkspaceFavicon from './WorkspaceFavicon.svelte' import NavFooter from './NavFooter.svelte' import NavHeader from './NavHeader.svelte' import Navigator from './Navigator.svelte' @@ -839,6 +840,7 @@ />
{:else if $myEmployeeStore || account.role === AccountRole.Owner || isAdminUser()} + diff --git a/plugins/workbench-resources/src/components/WorkspaceFavicon.svelte b/plugins/workbench-resources/src/components/WorkspaceFavicon.svelte new file mode 100644 index 00000000000..fcb4e49cde4 --- /dev/null +++ b/plugins/workbench-resources/src/components/WorkspaceFavicon.svelte @@ -0,0 +1,23 @@ + +