feat(workbench): add optional workspace logos and color badges to browser tabs (#11035)

* feat: add optional workspace favicon and identification color

Signed-off-by: Ratel-pwn <2448574643@qq.com>

* refactor: simplify workspace identification color control

Signed-off-by: Ratel-pwn <2448574643@qq.com>

* fix: preserve logo size when overlaying favicon color

Signed-off-by: Ratel-pwn <2448574643@qq.com>

* fix(workbench): separate favicon badge with a transparent cutout

Signed-off-by: Ratel-pwn <2448574643@qq.com>

* fix(workbench): restore workspace favicon before app startup

Signed-off-by: Ratel-pwn <2448574643@qq.com>

---------

Signed-off-by: Ratel-pwn <2448574643@qq.com>
This commit is contained in:
DawnRatel
2026-09-25 11:00:19 +02:00
committed by GitHub
parent 5eb50ee39b
commit bda10fa45e
26 changed files with 670 additions and 7 deletions
+5
View File
@@ -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",
+5
View File
@@ -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",
+5
View File
@@ -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",
+5
View File
@@ -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",
+5
View File
@@ -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",
+5
View File
@@ -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",
+5
View File
@@ -1,5 +1,10 @@
{
"string": {
"IdentificationColor": "識別色",
"ColorDefault": "デフォルト",
"ColorLogoUnavailable": "アイコンを読み込めませんでした。既定のタブアイコンを使用します。",
"ColorSaveFailed": "設定を保存できませんでした。もう一度お試しください。",
"SyncWorkspaceLogo": "ロゴをブラウザーのタブと同期",
"Setting": "設定",
"Spaces": "スペース",
"Integrations": "連携",
+5
View File
@@ -1,5 +1,10 @@
{
"string": {
"IdentificationColor": "식별 색상",
"ColorDefault": "기본값",
"ColorLogoUnavailable": "아이콘을 불러올 수 없습니다. 기본 탭 아이콘을 사용합니다.",
"ColorSaveFailed": "설정을 저장할 수 없습니다. 다시 시도해 주세요.",
"SyncWorkspaceLogo": "로고를 브라우저 탭과 동기화",
"Setting": "설정",
"Spaces": "스페이스",
"Integrations": "연동",
+5
View File
@@ -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",
+5
View File
@@ -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",
+5
View File
@@ -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",
+5
View File
@@ -1,5 +1,10 @@
{
"string": {
"IdentificationColor": "Цвет для различения",
"ColorDefault": "По умолчанию",
"ColorLogoUnavailable": "Не удалось загрузить значок. Используется стандартный значок вкладки.",
"ColorSaveFailed": "Не удалось сохранить настройки. Попробуйте ещё раз.",
"SyncWorkspaceLogo": "Синхронизировать логотип со вкладкой",
"Setting": "Настройки",
"Spaces": "Пространства",
"Integrations": "Интеграции",
+5
View File
@@ -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",
+5
View File
@@ -1,5 +1,10 @@
{
"string": {
"IdentificationColor": "识别色",
"ColorDefault": "默认",
"ColorLogoUnavailable": "无法加载图标,已使用默认标签页图标。",
"ColorSaveFailed": "无法保存设置,请重试。",
"SyncWorkspaceLogo": "Logo 与标签页同步",
"Setting": "设置",
"Spaces": "空间",
"Integrations": "集成",
@@ -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<void> {
@@ -218,7 +220,7 @@
<div class="ws">
<EditableAvatar
person={{
avatarType: workspaceSettings?.icon !== undefined ? AvatarType.IMAGE : AvatarType.COLOR,
avatarType: workspaceSettings?.icon != null ? AvatarType.IMAGE : AvatarType.COLOR,
avatar: workspaceSettings?.icon
}}
size="medium"
@@ -247,6 +249,7 @@
<Button icon={IconClose} kind="ghost" size="small" on:click={handleCancelEditName} />
{/if}
</div>
<WorkspaceIdentityColor workspaceSetting={workspaceSettings} />
<div class="flex-col flex-gap-4 mt-6">
<div class="title"><Label label={settingsRes.string.PasswordAgingRule} /></div>
@@ -337,6 +340,7 @@
.ws {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 1rem;
}
@@ -0,0 +1,161 @@
<!-- Copyright © 2026 Huly Contributors. Licensed under the Eclipse Public License, Version 2.0. -->
<script lang="ts">
import core, { AccountRole, getCurrentAccount } from '@hcengineering/core'
import {
defaultIdentityColor, getClient, getFileUrl, getDefaultWorkspaceFaviconUrl, normalizeIdentityColor,
renderWorkspaceIdentity, type WorkspaceIdentityImage
} from '@hcengineering/presentation'
import setting, { type WorkspaceSetting } from '@hcengineering/setting'
import { Button, Label, Toggle } from '@hcengineering/ui'
import { onDestroy, tick } from 'svelte'
export let workspaceSetting: WorkspaceSetting | undefined
const client = getClient()
const canEdit = getCurrentAccount().role === AccountRole.Owner
let preview: WorkspaceIdentityImage = { color: defaultIdentityColor }
let busy = false
let error = false
let imageError = false
let colorInput: HTMLInputElement
let revision = 0
let saveRevision = 0
let request: AbortController | undefined
const defaultIconUrl = getDefaultWorkspaceFaviconUrl()
$: syncLogo = workspaceSetting?.syncWorkspaceLogo === true
$: showColor = workspaceSetting?.identificationColorEnabled === true
$: manualColor = normalizeIdentityColor(workspaceSetting?.identificationColor)
$: logoUrl = workspaceSetting?.icon != null ? getFileUrl(workspaceSetting.icon) : undefined
$: void updatePreview(logoUrl, manualColor, syncLogo, showColor)
async function updatePreview (url: string | undefined, color: string | undefined, syncLogo: boolean, showColor: boolean): Promise<void> {
const current = ++revision
request?.abort()
request = new AbortController()
imageError = false
try {
const next = await renderWorkspaceIdentity(url, color, request.signal, { syncLogo, showColor, defaultIconUrl })
if (current === revision) preview = next
} catch {
if (current === revision) {
imageError = true
preview = { color: color ?? defaultIdentityColor }
}
}
}
async function saveColor (value: string | null): Promise<void> {
const color = value === null ? null : normalizeIdentityColor(value)
if (color !== undefined) await save({ identificationColor: color })
}
async function save (patch: Partial<Pick<WorkspaceSetting, 'identificationColor' | 'syncWorkspaceLogo' | 'identificationColorEnabled'>>): Promise<void> {
if (!canEdit || busy) return
const focused = document.activeElement as HTMLElement | null
const focusLabel = focused?.closest('label')?.getAttribute('aria-labelledby')
busy = true
error = false
try {
const existing = await client.findOne(setting.class.WorkspaceSetting, { _id: setting.ids.WorkspaceSetting })
if (existing === undefined) {
await client.createDoc(setting.class.WorkspaceSetting, core.space.Workspace,
patch, setting.ids.WorkspaceSetting)
} else {
await client.diffUpdate(existing, patch)
}
} catch {
error = true
if (colorInput !== undefined) colorInput.value = preview.color
saveRevision++
} finally {
busy = false
await tick()
if (document.activeElement === document.body) {
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<HTMLInputElement>(`label[aria-labelledby="${focusLabel}"] input`)?.focus()
}
}
}
}
onDestroy(() => {
revision++
request?.abort()
})
</script>
<div class="identity" aria-busy={busy}>
<div class="setting-row">
<span id="workspace-sync-logo-label"><Label label={setting.string.SyncWorkspaceLogo} /></span>
<div class="controls">
{#key saveRevision}
<Toggle on={syncLogo} disabled={!canEdit || busy} aria-labelledby="workspace-sync-logo-label"
on:change={(event) => save({ syncWorkspaceLogo: event.detail })} />
{/key}
</div>
</div>
<div class="setting-row">
<span id="workspace-color-label"><Label label={setting.string.IdentificationColor} /></span>
<div class="controls">
{#key saveRevision}
<Toggle on={showColor} disabled={!canEdit || busy} aria-labelledby="workspace-color-label"
on:change={(event) => save({ identificationColorEnabled: event.detail })} />
{/key}
{#if showColor}
<div class="color-control" role="group" aria-labelledby="workspace-color-label">
<label class="color-value">
<span class="color-swatch" style:background={preview.color} />
<code>{preview.color.toUpperCase()}</code>
<span class="sr-only"><Label label={setting.string.IdentificationColor} /></span>
<input bind:this={colorInput} type="color" value={preview.color} disabled={!canEdit || busy}
on:change={(event) => saveColor(event.currentTarget.value)} />
</label>
<span class="color-reset">
<Button kind="ghost" size="small" label={setting.string.ColorDefault} padding="0 .5rem"
disabled={!canEdit || busy || manualColor === undefined} on:click={() => saveColor(null)} />
</span>
</div>
{/if}
</div>
</div>
{#if imageError}<div class="error" role="status"><Label label={setting.string.ColorLogoUnavailable} /></div>{/if}
{#if error}<div class="error" role="alert"><Label label={setting.string.ColorSaveFailed} /></div>{/if}
</div>
<style>
.identity { display: flex; flex-direction: column; gap: 1rem; }
.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-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; }
.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; }
</style>
+9
View File
@@ -165,6 +165,10 @@ export interface OfficeSettings extends Configuration {
*/
export interface WorkspaceSetting extends Doc {
icon?: Ref<Blob> | 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,
@@ -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 @@
/>
</div>
{:else if $myEmployeeStore || account.role === AccountRole.Owner || isAdminUser()}
<WorkspaceFavicon />
<ActionHandler {currentSpace} />
<svg class="svg-mask">
<clipPath id="notify-normal">
@@ -0,0 +1,25 @@
<!-- Copyright © 2026 Huly Contributors. Licensed under the Eclipse Public License, Version 2.0. -->
<script lang="ts">
import { createQuery, createWorkspaceFavicon, getFileUrl } from '@hcengineering/presentation'
import setting, { type WorkspaceSetting } from '@hcengineering/setting'
import { onMount } from 'svelte'
let workspaceSetting: WorkspaceSetting | undefined
let settingsLoaded = false
let favicon: ReturnType<typeof createWorkspaceFavicon> | undefined
const query = createQuery()
query.query(setting.class.WorkspaceSetting, { _id: setting.ids.WorkspaceSetting }, (result) => {
workspaceSetting = result[0]
settingsLoaded = true
})
$: logoUrl = workspaceSetting?.icon != null ? getFileUrl(workspaceSetting.icon) : undefined
$: if (settingsLoaded) void favicon?.update(logoUrl, workspaceSetting?.identificationColor, {
syncLogo: workspaceSetting?.syncWorkspaceLogo === true,
showColor: workspaceSetting?.identificationColorEnabled === true
})
onMount(() => {
favicon = createWorkspaceFavicon()
return () => favicon?.dispose()
})
</script>
+2 -1
View File
@@ -29,7 +29,7 @@ import type {
import core, { hasAccountRole } from '@hcengineering/core'
import login from '@hcengineering/login'
import { getMetadata, getResource, setMetadata } from '@hcengineering/platform'
import presentation, { closeClient, getClient, setPresentationCookie } from '@hcengineering/presentation'
import presentation, { clearWorkspaceFaviconCache, closeClient, getClient, setPresentationCookie } from '@hcengineering/presentation'
import {
closePanel,
getCurrentLocation,
@@ -217,6 +217,7 @@ export async function logIn (loginInfo: { account: string, token?: string }): Pr
}
export async function logOut (): Promise<void> {
clearWorkspaceFaviconCache()
const accountsUrl = getMetadata(login.metadata.AccountsUrl)
try {
await getAccountClient(accountsUrl).deleteCookie()