From bda10fa45e2e5536e6e31f8fe8c02b8f4cf92c71 Mon Sep 17 00:00:00 2001 From: DawnRatel <85299799+Ratel-pwn@users.noreply.github.com> Date: Fri, 25 Sep 2026 17:00:19 +0800 Subject: [PATCH] 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> --- dev/prod/src/index.ejs | 29 +- dev/prod/src/platform.ts | 7 +- models/setting/src/index.ts | 3 + .../src/___tests___/workspaceIdentity.test.ts | 106 ++++++++ packages/presentation/src/index.ts | 1 + .../presentation/src/workspaceIdentity.ts | 251 ++++++++++++++++++ plugins/setting-assets/lang/cs.json | 5 + plugins/setting-assets/lang/de.json | 5 + plugins/setting-assets/lang/en.json | 5 + plugins/setting-assets/lang/es.json | 5 + plugins/setting-assets/lang/fr.json | 5 + plugins/setting-assets/lang/it.json | 5 + plugins/setting-assets/lang/ja.json | 5 + plugins/setting-assets/lang/ko.json | 5 + plugins/setting-assets/lang/pl.json | 5 + plugins/setting-assets/lang/pt-br.json | 5 + plugins/setting-assets/lang/pt.json | 5 + plugins/setting-assets/lang/ru.json | 5 + plugins/setting-assets/lang/tr.json | 5 + plugins/setting-assets/lang/zh.json | 5 + .../src/components/General.svelte | 10 +- .../components/WorkspaceIdentityColor.svelte | 161 +++++++++++ plugins/setting/src/index.ts | 9 + .../src/components/Workbench.svelte | 2 + .../src/components/WorkspaceFavicon.svelte | 25 ++ plugins/workbench-resources/src/utils.ts | 3 +- 26 files changed, 670 insertions(+), 7 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/dev/prod/src/index.ejs b/dev/prod/src/index.ejs index 96a11cc581..87229f8a45 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 943b4a1529..2bfa1f67b7 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/models/setting/src/index.ts b/models/setting/src/index.ts index 9778abb96e..f0c0038ea8 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 0000000000..c6169b6f81 --- /dev/null +++ b/packages/presentation/src/___tests___/workspaceIdentity.test.ts @@ -0,0 +1,106 @@ +// 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(/ + +
+
+ +
+ {#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 bf8d3ef6a0..d7a65ffeac 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 1f81828e78..0e27b2ecf4 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 0000000000..f12ffac7bf --- /dev/null +++ b/plugins/workbench-resources/src/components/WorkspaceFavicon.svelte @@ -0,0 +1,25 @@ + + diff --git a/plugins/workbench-resources/src/utils.ts b/plugins/workbench-resources/src/utils.ts index a2e5b06735..1aaec0636d 100644 --- a/plugins/workbench-resources/src/utils.ts +++ b/plugins/workbench-resources/src/utils.ts @@ -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 { + clearWorkspaceFaviconCache() const accountsUrl = getMetadata(login.metadata.AccountsUrl) try { await getAccountClient(accountsUrl).deleteCookie()