From 9b2f0f51b7898c8face979531ea442f0beee05a0 Mon Sep 17 00:00:00 2001 From: Ratel-pwn <2448574643@qq.com> Date: Wed, 9 Sep 2026 01:34:53 +0800 Subject: [PATCH 1/5] feat: add optional workspace favicon and identification color Signed-off-by: Ratel-pwn <2448574643@qq.com> --- models/setting/src/index.ts | 3 + .../src/___tests___/workspaceIdentity.test.ts | 57 ++++++ packages/presentation/src/index.ts | 1 + .../presentation/src/workspaceIdentity.ts | 183 ++++++++++++++++++ plugins/setting-assets/lang/cs.json | 6 + plugins/setting-assets/lang/de.json | 6 + plugins/setting-assets/lang/en.json | 6 + plugins/setting-assets/lang/es.json | 6 + plugins/setting-assets/lang/fr.json | 6 + plugins/setting-assets/lang/it.json | 6 + plugins/setting-assets/lang/ja.json | 6 + plugins/setting-assets/lang/ko.json | 6 + plugins/setting-assets/lang/pl.json | 6 + plugins/setting-assets/lang/pt-br.json | 6 + plugins/setting-assets/lang/pt.json | 6 + plugins/setting-assets/lang/ru.json | 6 + plugins/setting-assets/lang/tr.json | 6 + plugins/setting-assets/lang/zh.json | 6 + .../src/components/General.svelte | 10 +- .../components/WorkspaceIdentityColor.svelte | 136 +++++++++++++ plugins/setting/src/index.ts | 10 + .../src/components/Workbench.svelte | 2 + .../src/components/WorkspaceFavicon.svelte | 23 +++ 23 files changed, 506 insertions(+), 3 deletions(-) create mode 100644 packages/presentation/src/___tests___/workspaceIdentity.test.ts create mode 100644 packages/presentation/src/workspaceIdentity.ts create mode 100644 plugins/setting-resources/src/components/WorkspaceIdentityColor.svelte create mode 100644 plugins/workbench-resources/src/components/WorkspaceFavicon.svelte 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..54b720f1a0e --- /dev/null +++ b/packages/presentation/src/workspaceIdentity.ts @@ -0,0 +1,183 @@ +// 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') + const scale = Math.min(28 / image.naturalWidth, 28 / image.naturalHeight) + const width = image.naturalWidth * scale + const height = image.naturalHeight * scale + context.drawImage(image, 1 + (28 - width) / 2, 1 + (28 - 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..c41488eae36 100644 --- a/plugins/setting-assets/lang/cs.json +++ b/plugins/setting-assets/lang/cs.json @@ -1,5 +1,11 @@ { "string": { + "IdentificationColor": "Rozlišovací barva", + "ColorFromLogo": "Z loga", + "ColorResetAutomatic": "Obnovit automatickou barvu", + "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..826e1878d5a 100644 --- a/plugins/setting-assets/lang/de.json +++ b/plugins/setting-assets/lang/de.json @@ -1,5 +1,11 @@ { "string": { + "IdentificationColor": "Erkennungsfarbe", + "ColorFromLogo": "Aus dem Logo", + "ColorResetAutomatic": "Auf automatisch zurücksetzen", + "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..0e76ad4674e 100644 --- a/plugins/setting-assets/lang/en.json +++ b/plugins/setting-assets/lang/en.json @@ -1,5 +1,11 @@ { "string": { + "IdentificationColor": "Identification color", + "ColorFromLogo": "From logo", + "ColorResetAutomatic": "Reset to automatic", + "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..b40375bf00e 100644 --- a/plugins/setting-assets/lang/es.json +++ b/plugins/setting-assets/lang/es.json @@ -1,5 +1,11 @@ { "string": { + "IdentificationColor": "Color identificativo", + "ColorFromLogo": "Del logotipo", + "ColorResetAutomatic": "Restablecer color automático", + "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..4fba7a0f9a2 100644 --- a/plugins/setting-assets/lang/fr.json +++ b/plugins/setting-assets/lang/fr.json @@ -1,5 +1,11 @@ { "string": { + "IdentificationColor": "Couleur distinctive", + "ColorFromLogo": "Du logo", + "ColorResetAutomatic": "Rétablir la couleur automatique", + "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..66a836b407d 100644 --- a/plugins/setting-assets/lang/it.json +++ b/plugins/setting-assets/lang/it.json @@ -1,5 +1,11 @@ { "string": { + "IdentificationColor": "Colore identificativo", + "ColorFromLogo": "Dal logo", + "ColorResetAutomatic": "Ripristina colore automatico", + "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..884475eaea3 100644 --- a/plugins/setting-assets/lang/ja.json +++ b/plugins/setting-assets/lang/ja.json @@ -1,5 +1,11 @@ { "string": { + "IdentificationColor": "識別色", + "ColorFromLogo": "ロゴから抽出", + "ColorResetAutomatic": "自動設定に戻す", + "ColorLogoUnavailable": "アイコンを読み込めませんでした。既定のタブアイコンを使用します。", + "ColorSaveFailed": "設定を保存できませんでした。もう一度お試しください。", + "SyncWorkspaceLogo": "ロゴをブラウザーのタブと同期", "Setting": "設定", "Spaces": "スペース", "Integrations": "連携", diff --git a/plugins/setting-assets/lang/ko.json b/plugins/setting-assets/lang/ko.json index 891fc5adbfa..6b5c96e04ad 100644 --- a/plugins/setting-assets/lang/ko.json +++ b/plugins/setting-assets/lang/ko.json @@ -1,5 +1,11 @@ { "string": { + "IdentificationColor": "식별 색상", + "ColorFromLogo": "로고에서 추출", + "ColorResetAutomatic": "자동 설정으로 재설정", + "ColorLogoUnavailable": "아이콘을 불러올 수 없습니다. 기본 탭 아이콘을 사용합니다.", + "ColorSaveFailed": "설정을 저장할 수 없습니다. 다시 시도해 주세요.", + "SyncWorkspaceLogo": "로고를 브라우저 탭과 동기화", "Setting": "설정", "Spaces": "스페이스", "Integrations": "연동", diff --git a/plugins/setting-assets/lang/pl.json b/plugins/setting-assets/lang/pl.json index 575890db1b4..34e2bf8d764 100644 --- a/plugins/setting-assets/lang/pl.json +++ b/plugins/setting-assets/lang/pl.json @@ -1,5 +1,11 @@ { "string": { + "IdentificationColor": "Kolor identyfikacyjny", + "ColorFromLogo": "Z logo", + "ColorResetAutomatic": "Przywróć kolor automatyczny", + "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..32c39765621 100644 --- a/plugins/setting-assets/lang/pt-br.json +++ b/plugins/setting-assets/lang/pt-br.json @@ -1,5 +1,11 @@ { "string": { + "IdentificationColor": "Cor de identificação", + "ColorFromLogo": "Do logotipo", + "ColorResetAutomatic": "Restaurar cor automática", + "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..d2949ce5246 100644 --- a/plugins/setting-assets/lang/pt.json +++ b/plugins/setting-assets/lang/pt.json @@ -1,5 +1,11 @@ { "string": { + "IdentificationColor": "Cor de identificação", + "ColorFromLogo": "Do logótipo", + "ColorResetAutomatic": "Repor cor automática", + "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..0f0074227a2 100644 --- a/plugins/setting-assets/lang/ru.json +++ b/plugins/setting-assets/lang/ru.json @@ -1,5 +1,11 @@ { "string": { + "IdentificationColor": "Цвет для различения", + "ColorFromLogo": "Из логотипа", + "ColorResetAutomatic": "Вернуть автоматический цвет", + "ColorLogoUnavailable": "Не удалось загрузить значок. Используется стандартный значок вкладки.", + "ColorSaveFailed": "Не удалось сохранить настройки. Попробуйте ещё раз.", + "SyncWorkspaceLogo": "Синхронизировать логотип со вкладкой", "Setting": "Настройки", "Spaces": "Пространства", "Integrations": "Интеграции", diff --git a/plugins/setting-assets/lang/tr.json b/plugins/setting-assets/lang/tr.json index 241f718e22f..2ba33e3b7ae 100644 --- a/plugins/setting-assets/lang/tr.json +++ b/plugins/setting-assets/lang/tr.json @@ -1,5 +1,11 @@ { "string": { + "IdentificationColor": "Ayırt edici renk", + "ColorFromLogo": "Logodan", + "ColorResetAutomatic": "Otomatik renge sıfırla", + "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..bb43e41275b 100644 --- a/plugins/setting-assets/lang/zh.json +++ b/plugins/setting-assets/lang/zh.json @@ -1,5 +1,11 @@ { "string": { + "IdentificationColor": "识别色", + "ColorFromLogo": "来自 Logo", + "ColorResetAutomatic": "恢复自动", + "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..2634d5a708e --- /dev/null +++ b/plugins/setting-resources/src/components/WorkspaceIdentityColor.svelte @@ -0,0 +1,136 @@ + + + +
+
+ +
+ {#key saveRevision} + save({ syncWorkspaceLogo: event.detail })} /> + {/key} +
+
+
+ +
+ {#key saveRevision} + save({ identificationColorEnabled: event.detail })} /> + {/key} + {#if showColor} + + {preview.color.toUpperCase()} + {#if manualColor !== undefined} +
+
+ {#if imageError}
{/if} + {#if error}{/if} +
+ + diff --git a/plugins/setting/src/index.ts b/plugins/setting/src/index.ts index bf8d3ef6a0a..22a28e86cf2 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,12 @@ export default plugin(settingId, { Setting: '' as IntlString, Spaces: '' as IntlString, WorkspaceSettings: '' as IntlString, + IdentificationColor: '' as IntlString, + SyncWorkspaceLogo: '' as IntlString, + ColorFromLogo: '' as IntlString, + ColorResetAutomatic: '' 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 @@ + + From 9a776f022fb66b42912d83d27d6d2dbbdd8db13e Mon Sep 17 00:00:00 2001 From: Ratel-pwn <2448574643@qq.com> Date: Wed, 9 Sep 2026 01:46:29 +0800 Subject: [PATCH 2/5] refactor: simplify workspace identification color control Signed-off-by: Ratel-pwn <2448574643@qq.com> --- plugins/setting-assets/lang/cs.json | 3 +- plugins/setting-assets/lang/de.json | 3 +- plugins/setting-assets/lang/en.json | 3 +- plugins/setting-assets/lang/es.json | 3 +- plugins/setting-assets/lang/fr.json | 3 +- plugins/setting-assets/lang/it.json | 3 +- plugins/setting-assets/lang/ja.json | 3 +- plugins/setting-assets/lang/ko.json | 3 +- plugins/setting-assets/lang/pl.json | 3 +- plugins/setting-assets/lang/pt-br.json | 3 +- plugins/setting-assets/lang/pt.json | 3 +- plugins/setting-assets/lang/ru.json | 3 +- plugins/setting-assets/lang/tr.json | 3 +- plugins/setting-assets/lang/zh.json | 3 +- .../components/WorkspaceIdentityColor.svelte | 77 ++++++++++++------- plugins/setting/src/index.ts | 3 +- 16 files changed, 66 insertions(+), 56 deletions(-) diff --git a/plugins/setting-assets/lang/cs.json b/plugins/setting-assets/lang/cs.json index c41488eae36..88387e354e6 100644 --- a/plugins/setting-assets/lang/cs.json +++ b/plugins/setting-assets/lang/cs.json @@ -1,8 +1,7 @@ { "string": { "IdentificationColor": "Rozlišovací barva", - "ColorFromLogo": "Z loga", - "ColorResetAutomatic": "Obnovit automatickou barvu", + "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", diff --git a/plugins/setting-assets/lang/de.json b/plugins/setting-assets/lang/de.json index 826e1878d5a..9d8c0f3333f 100644 --- a/plugins/setting-assets/lang/de.json +++ b/plugins/setting-assets/lang/de.json @@ -1,8 +1,7 @@ { "string": { "IdentificationColor": "Erkennungsfarbe", - "ColorFromLogo": "Aus dem Logo", - "ColorResetAutomatic": "Auf automatisch zurücksetzen", + "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", diff --git a/plugins/setting-assets/lang/en.json b/plugins/setting-assets/lang/en.json index 0e76ad4674e..231da8cfc9e 100644 --- a/plugins/setting-assets/lang/en.json +++ b/plugins/setting-assets/lang/en.json @@ -1,8 +1,7 @@ { "string": { "IdentificationColor": "Identification color", - "ColorFromLogo": "From logo", - "ColorResetAutomatic": "Reset to automatic", + "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", diff --git a/plugins/setting-assets/lang/es.json b/plugins/setting-assets/lang/es.json index b40375bf00e..dff9485a34e 100644 --- a/plugins/setting-assets/lang/es.json +++ b/plugins/setting-assets/lang/es.json @@ -1,8 +1,7 @@ { "string": { "IdentificationColor": "Color identificativo", - "ColorFromLogo": "Del logotipo", - "ColorResetAutomatic": "Restablecer color automático", + "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", diff --git a/plugins/setting-assets/lang/fr.json b/plugins/setting-assets/lang/fr.json index 4fba7a0f9a2..328f16db057 100644 --- a/plugins/setting-assets/lang/fr.json +++ b/plugins/setting-assets/lang/fr.json @@ -1,8 +1,7 @@ { "string": { "IdentificationColor": "Couleur distinctive", - "ColorFromLogo": "Du logo", - "ColorResetAutomatic": "Rétablir la couleur automatique", + "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", diff --git a/plugins/setting-assets/lang/it.json b/plugins/setting-assets/lang/it.json index 66a836b407d..5eea9ffed82 100644 --- a/plugins/setting-assets/lang/it.json +++ b/plugins/setting-assets/lang/it.json @@ -1,8 +1,7 @@ { "string": { "IdentificationColor": "Colore identificativo", - "ColorFromLogo": "Dal logo", - "ColorResetAutomatic": "Ripristina colore automatico", + "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", diff --git a/plugins/setting-assets/lang/ja.json b/plugins/setting-assets/lang/ja.json index 884475eaea3..2d2bbb5a3e9 100644 --- a/plugins/setting-assets/lang/ja.json +++ b/plugins/setting-assets/lang/ja.json @@ -1,8 +1,7 @@ { "string": { "IdentificationColor": "識別色", - "ColorFromLogo": "ロゴから抽出", - "ColorResetAutomatic": "自動設定に戻す", + "ColorDefault": "デフォルト", "ColorLogoUnavailable": "アイコンを読み込めませんでした。既定のタブアイコンを使用します。", "ColorSaveFailed": "設定を保存できませんでした。もう一度お試しください。", "SyncWorkspaceLogo": "ロゴをブラウザーのタブと同期", diff --git a/plugins/setting-assets/lang/ko.json b/plugins/setting-assets/lang/ko.json index 6b5c96e04ad..9f5a3e74917 100644 --- a/plugins/setting-assets/lang/ko.json +++ b/plugins/setting-assets/lang/ko.json @@ -1,8 +1,7 @@ { "string": { "IdentificationColor": "식별 색상", - "ColorFromLogo": "로고에서 추출", - "ColorResetAutomatic": "자동 설정으로 재설정", + "ColorDefault": "기본값", "ColorLogoUnavailable": "아이콘을 불러올 수 없습니다. 기본 탭 아이콘을 사용합니다.", "ColorSaveFailed": "설정을 저장할 수 없습니다. 다시 시도해 주세요.", "SyncWorkspaceLogo": "로고를 브라우저 탭과 동기화", diff --git a/plugins/setting-assets/lang/pl.json b/plugins/setting-assets/lang/pl.json index 34e2bf8d764..445759ab2e1 100644 --- a/plugins/setting-assets/lang/pl.json +++ b/plugins/setting-assets/lang/pl.json @@ -1,8 +1,7 @@ { "string": { "IdentificationColor": "Kolor identyfikacyjny", - "ColorFromLogo": "Z logo", - "ColorResetAutomatic": "Przywróć kolor automatyczny", + "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", diff --git a/plugins/setting-assets/lang/pt-br.json b/plugins/setting-assets/lang/pt-br.json index 32c39765621..87db7882d20 100644 --- a/plugins/setting-assets/lang/pt-br.json +++ b/plugins/setting-assets/lang/pt-br.json @@ -1,8 +1,7 @@ { "string": { "IdentificationColor": "Cor de identificação", - "ColorFromLogo": "Do logotipo", - "ColorResetAutomatic": "Restaurar cor automática", + "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", diff --git a/plugins/setting-assets/lang/pt.json b/plugins/setting-assets/lang/pt.json index d2949ce5246..024d1c37d2d 100644 --- a/plugins/setting-assets/lang/pt.json +++ b/plugins/setting-assets/lang/pt.json @@ -1,8 +1,7 @@ { "string": { "IdentificationColor": "Cor de identificação", - "ColorFromLogo": "Do logótipo", - "ColorResetAutomatic": "Repor cor automática", + "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", diff --git a/plugins/setting-assets/lang/ru.json b/plugins/setting-assets/lang/ru.json index 0f0074227a2..36b3c3f4af1 100644 --- a/plugins/setting-assets/lang/ru.json +++ b/plugins/setting-assets/lang/ru.json @@ -1,8 +1,7 @@ { "string": { "IdentificationColor": "Цвет для различения", - "ColorFromLogo": "Из логотипа", - "ColorResetAutomatic": "Вернуть автоматический цвет", + "ColorDefault": "По умолчанию", "ColorLogoUnavailable": "Не удалось загрузить значок. Используется стандартный значок вкладки.", "ColorSaveFailed": "Не удалось сохранить настройки. Попробуйте ещё раз.", "SyncWorkspaceLogo": "Синхронизировать логотип со вкладкой", diff --git a/plugins/setting-assets/lang/tr.json b/plugins/setting-assets/lang/tr.json index 2ba33e3b7ae..a574349f62e 100644 --- a/plugins/setting-assets/lang/tr.json +++ b/plugins/setting-assets/lang/tr.json @@ -1,8 +1,7 @@ { "string": { "IdentificationColor": "Ayırt edici renk", - "ColorFromLogo": "Logodan", - "ColorResetAutomatic": "Otomatik renge sıfırla", + "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", diff --git a/plugins/setting-assets/lang/zh.json b/plugins/setting-assets/lang/zh.json index bb43e41275b..67774a9c7e0 100644 --- a/plugins/setting-assets/lang/zh.json +++ b/plugins/setting-assets/lang/zh.json @@ -1,8 +1,7 @@ { "string": { "IdentificationColor": "识别色", - "ColorFromLogo": "来自 Logo", - "ColorResetAutomatic": "恢复自动", + "ColorDefault": "默认", "ColorLogoUnavailable": "无法加载图标,已使用默认标签页图标。", "ColorSaveFailed": "无法保存设置,请重试。", "SyncWorkspaceLogo": "Logo 与标签页同步", diff --git a/plugins/setting-resources/src/components/WorkspaceIdentityColor.svelte b/plugins/setting-resources/src/components/WorkspaceIdentityColor.svelte index 2634d5a708e..b8735c8d022 100644 --- a/plugins/setting-resources/src/components/WorkspaceIdentityColor.svelte +++ b/plugins/setting-resources/src/components/WorkspaceIdentityColor.svelte @@ -70,7 +70,8 @@ busy = false await tick() if (document.activeElement === document.body) { - if (focused?.isConnected === true) focused.focus() + if (focused instanceof HTMLButtonElement && focused.disabled) colorInput?.focus() + else if (focused?.isConnected === true) focused.focus() else if (focusLabel === 'workspace-sync-logo-label' || focusLabel === 'workspace-color-label') { document.querySelector(`label[aria-labelledby="${focusLabel}"] input`)?.focus() } @@ -85,36 +86,37 @@
-
- -
+
+ +
{#key saveRevision} save({ syncWorkspaceLogo: event.detail })} /> {/key} -
+
- {#key saveRevision} - save({ identificationColorEnabled: event.detail })} /> - {/key} - {#if showColor} - - {preview.color.toUpperCase()} - {#if manualColor !== undefined} -
+ {/if}
{#if imageError}
{/if} @@ -126,11 +128,34 @@ .setting-row { display: grid; grid-template-columns: min(11rem, 45%) minmax(0, 1fr); align-items: center; gap: 0.75rem; min-height: 2rem; } .controls { display: flex; align-items: center; flex-wrap: wrap; gap: 0.75rem; min-width: 0; } .setting-row :global(.toggle:focus-within) { outline: 2px solid var(--primary-button-outline); outline-offset: 3px; border-radius: 1rem; } - .color-picker { position: relative; width: 2rem; height: 2rem; border-radius: 0.5rem; border: 1px solid var(--theme-divider-color); overflow: hidden; } - .color-picker:focus-within { outline: 2px solid var(--theme-content-color); outline-offset: 3px; } + .color-control { + display: inline-flex; + align-items: center; + padding: 0.125rem; + border: 1px solid var(--theme-divider-color); + border-radius: 0.5rem; + max-width: 100%; + } + .color-value { + position: relative; + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.25rem 0.5rem 0.25rem 0.25rem; + border-radius: 0.25rem; + min-width: 0; + } + .color-value:focus-within { outline: 2px solid var(--theme-content-color); outline-offset: 2px; } + .color-swatch { + flex-shrink: 0; + width: 1.25rem; + height: 1.25rem; + border-radius: 0.25rem; + box-shadow: inset 0 0 0 1px var(--theme-divider-color); + } + .color-reset { border-left: 1px solid var(--theme-divider-color); padding-left: 0.125rem; } input[type='color'] { position: absolute; inset: 0; opacity: 0; width: 100%; height: 100%; cursor: pointer; } code { font-size: 0.8rem; } - .mode { color: var(--theme-content-color); font-size: 0.8125rem; } .error { color: var(--theme-error-color, #e05252); font-size: 0.8125rem; } .sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; } diff --git a/plugins/setting/src/index.ts b/plugins/setting/src/index.ts index 22a28e86cf2..d7a65ffeacf 100644 --- a/plugins/setting/src/index.ts +++ b/plugins/setting/src/index.ts @@ -261,8 +261,7 @@ export default plugin(settingId, { WorkspaceSettings: '' as IntlString, IdentificationColor: '' as IntlString, SyncWorkspaceLogo: '' as IntlString, - ColorFromLogo: '' as IntlString, - ColorResetAutomatic: '' as IntlString, + ColorDefault: '' as IntlString, ColorLogoUnavailable: '' as IntlString, ColorSaveFailed: '' as IntlString, Integrations: '' as IntlString, From 7de8244626f17281fb00c0f4143cfab6fc5c39bd Mon Sep 17 00:00:00 2001 From: Ratel-pwn <2448574643@qq.com> Date: Wed, 9 Sep 2026 01:50:52 +0800 Subject: [PATCH 3/5] fix: preserve logo size when overlaying favicon color Signed-off-by: Ratel-pwn <2448574643@qq.com> --- packages/presentation/src/workspaceIdentity.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/presentation/src/workspaceIdentity.ts b/packages/presentation/src/workspaceIdentity.ts index 54b720f1a0e..8907080951e 100644 --- a/packages/presentation/src/workspaceIdentity.ts +++ b/packages/presentation/src/workspaceIdentity.ts @@ -111,10 +111,11 @@ export async function renderWorkspaceIdentity ( canvas.width = canvas.height = 32 const context = canvas.getContext('2d') if (context === null) throw new Error('Canvas is unavailable') - const scale = Math.min(28 / image.naturalWidth, 28 / image.naturalHeight) + // 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, 1 + (28 - width) / 2, 1 + (28 - height) / 2, width, height) + 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() From 16bc6dc3d05308e1e97530d2c65c918f5be7afa3 Mon Sep 17 00:00:00 2001 From: Ratel-pwn <2448574643@qq.com> Date: Wed, 9 Sep 2026 11:21:33 +0800 Subject: [PATCH 4/5] fix(workbench): separate favicon badge with a transparent cutout Signed-off-by: Ratel-pwn <2448574643@qq.com> --- packages/presentation/src/workspaceIdentity.ts | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/packages/presentation/src/workspaceIdentity.ts b/packages/presentation/src/workspaceIdentity.ts index 8907080951e..95a461d4b11 100644 --- a/packages/presentation/src/workspaceIdentity.ts +++ b/packages/presentation/src/workspaceIdentity.ts @@ -117,14 +117,13 @@ export async function renderWorkspaceIdentity ( 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. + // Cut a transparent arc around the marker so the browser's own background separates it from the logo. + context.save() + context.globalCompositeOperation = 'destination-out' context.beginPath() - context.arc(25, 25, 6, 0, Math.PI * 2) - context.fillStyle = '#ffffff' + context.arc(25, 25, 7, 0, Math.PI * 2) context.fill() - context.strokeStyle = '#334155' - context.lineWidth = 0.75 - context.stroke() + context.restore() context.beginPath() context.arc(25, 25, 4.75, 0, Math.PI * 2) context.fillStyle = color From 23a26bb83719c02f943e40fe267dff72da327505 Mon Sep 17 00:00:00 2001 From: Ratel-pwn <2448574643@qq.com> Date: Sat, 12 Sep 2026 03:28:43 +0800 Subject: [PATCH 5/5] fix(workbench): restore workspace favicon before app startup Signed-off-by: Ratel-pwn <2448574643@qq.com> --- dev/prod/src/index.ejs | 29 ++++++- dev/prod/src/platform.ts | 7 +- .../src/___tests___/workspaceIdentity.test.ts | 49 ++++++++++++ .../presentation/src/workspaceIdentity.ts | 78 +++++++++++++++++-- .../src/components/WorkspaceFavicon.svelte | 4 +- plugins/workbench-resources/src/utils.ts | 3 +- 6 files changed, 160 insertions(+), 10 deletions(-) diff --git a/dev/prod/src/index.ejs b/dev/prod/src/index.ejs index 96a11cc5810..87229f8a45e 100644 --- a/dev/prod/src/index.ejs +++ b/dev/prod/src/index.ejs @@ -4,10 +4,35 @@ Huly - + + + - \ No newline at end of file + diff --git a/dev/prod/src/platform.ts b/dev/prod/src/platform.ts index 943b4a1529e..2bfa1f67b70 100644 --- a/dev/prod/src/platform.ts +++ b/dev/prod/src/platform.ts @@ -454,7 +454,12 @@ export async function configurePlatform() { for (const link of links) { const htmlLink = document.createElement('link') htmlLink.rel = link.rel - htmlLink.href = link.href + if (link.rel.split(/\s+/).includes('icon') && document.getElementById('workspace-favicon') !== null) { + // Preserve the startup icon until workspace settings are available. + htmlLink.dataset.defaultHref = link.href + } else { + htmlLink.href = link.href + } if (link.type !== undefined) { htmlLink.type = link.type diff --git a/packages/presentation/src/___tests___/workspaceIdentity.test.ts b/packages/presentation/src/___tests___/workspaceIdentity.test.ts index 01fe6a94ef1..c6169b6f816 100644 --- a/packages/presentation/src/___tests___/workspaceIdentity.test.ts +++ b/packages/presentation/src/___tests___/workspaceIdentity.test.ts @@ -1,6 +1,55 @@ // Copyright © 2026 Huly Contributors. Licensed under the Eclipse Public License, Version 2.0. import { mixLogoColor, normalizeIdentityColor, renderWorkspaceIdentity } from '../workspaceIdentity' +import { readFileSync } from 'fs' +import { resolve } from 'path' +import { runInNewContext } from 'vm' + +describe('workspace favicon before application startup', () => { + const icon = 'data:image/png;base64,iVBORw0KGgo=' + const template = readFileSync(resolve(__dirname, '../../../../dev/prod/src/index.ejs'), 'utf8') + + function bootstrap (pathname: string, cached?: string, denied = false): { fallback: any, icons: any[] } { + const fallback = { href: '', dataset: { defaultHref: '/huly/favicon.ico' } } + const icons: any[] = [] + const script = template.match(/