From 6b9c01996fccb9789db7faff1c0e39b4fabd923c Mon Sep 17 00:00:00 2001 From: Turtle Date: Sun, 20 Sep 2026 19:54:54 +0800 Subject: [PATCH 1/3] fix(web): refresh favicon colors with the system scheme --- apps/web/src/favicon.ts | 26 +++++++++++++ apps/web/src/main.ts | 2 + apps/web/tests/favicon.spec.ts | 55 +++++++++++++++++++++++++++ apps/web/tests/settings-chrome.e2e.ts | 13 +++++++ 4 files changed, 96 insertions(+) create mode 100644 apps/web/src/favicon.ts create mode 100644 apps/web/tests/favicon.spec.ts diff --git a/apps/web/src/favicon.ts b/apps/web/src/favicon.ts new file mode 100644 index 0000000000..ac08a9f84f --- /dev/null +++ b/apps/web/src/favicon.ts @@ -0,0 +1,26 @@ +/** Browser-tab icons follow the system scheme independently of the application theme. */ +import favicon from '../public/favicon.svg?raw' + +/** + * Install fixed-color icons and a document-lifetime system-scheme listener. + * Browser favicon rasterizers can cache SVG media-query results across scheme changes. + * Distinct data URLs select explicit colors without duplicating the source artwork. + */ +export function installFavicon(): void { + const link = document.querySelector('link[rel="icon"]') + if (link === null) throw new Error('web app: missing favicon link') + const svg = new DOMParser().parseFromString(favicon, 'image/svg+xml') + svg.querySelector('style')?.remove() + const path = svg.querySelector('path') + if (path === null) throw new Error('web app: missing favicon path') + const icon = (fill: string): string => { + path.setAttribute('fill', fill) + return `data:image/svg+xml,${encodeURIComponent(new XMLSerializer().serializeToString(svg))}` + } + const light = icon('#000') + const dark = icon('#fff') + const scheme = window.matchMedia('(prefers-color-scheme: dark)') + const update = (): void => { link.href = scheme.matches ? dark : light } + update() + scheme.addEventListener('change', update) +} diff --git a/apps/web/src/main.ts b/apps/web/src/main.ts index 1a84cd5f63..9f1fa593a1 100644 --- a/apps/web/src/main.ts +++ b/apps/web/src/main.ts @@ -1,5 +1,6 @@ /** Browser entry for the Web client. */ import { AppWebEntry, applyIndexInjections } from '@deepseek-ai/dsh-client-web' +import { installFavicon } from './favicon.ts' interface DesktopBootGlobal { dshDesktopBoot?: { @@ -14,6 +15,7 @@ const reportFailure = (reason: unknown): void => { } try { + installFavicon() const el = document.getElementById('root') if (el === null) throw new Error('web app: missing #root') const entry = new AppWebEntry(el) diff --git a/apps/web/tests/favicon.spec.ts b/apps/web/tests/favicon.spec.ts new file mode 100644 index 0000000000..af932bc4fc --- /dev/null +++ b/apps/web/tests/favicon.spec.ts @@ -0,0 +1,55 @@ +// @vitest-environment jsdom +import { afterEach, expect, it, vi } from 'vitest' +import { installFavicon } from '../src/favicon.ts' + +const originalHead = document.head.innerHTML + +afterEach(() => { + document.head.innerHTML = originalHead + document.documentElement.style.removeProperty('color-scheme') + vi.unstubAllGlobals() +}) + +it.each([false, true])('selects fixed-color tab icons on boot and system changes (dark=%s)', (initialDark) => { + const link = document.createElement('link') + link.rel = 'icon' + link.href = '/favicon.svg' + document.head.append(link) + let dark = initialDark + let onChange = (): void => { throw new Error('missing scheme listener') } + const matchMedia = vi.fn(() => ({ + get matches() { return dark }, + addEventListener: (event: string, listener: () => void) => { + expect(event).toBe('change') + onChange = listener + }, + })) + vi.stubGlobal('matchMedia', matchMedia) + const readIcon = (): string => decodeURIComponent(link.href.slice('data:image/svg+xml,'.length)) + const expectFill = (fill: string): void => { + expect(link.href).toMatch(/^data:image\/svg\+xml,/) + const svg = new DOMParser().parseFromString(readIcon(), 'image/svg+xml') + expect(svg.querySelector('parsererror')).toBeNull() + expect(svg.querySelector('style')).toBeNull() + expect(svg.querySelector('path')?.getAttribute('fill')).toBe(fill) + expect(svg.documentElement.getAttribute('viewBox')).toBe('0 0 50 50') + } + + document.documentElement.style.colorScheme = initialDark ? 'light' : 'dark' + installFavicon() + expect(matchMedia).toHaveBeenCalledWith('(prefers-color-scheme: dark)') + expectFill(initialDark ? '#fff' : '#000') + const initial = link.href + dark = !initialDark + onChange() + expectFill(initialDark ? '#000' : '#fff') + expect(link.href).not.toBe(initial) + dark = initialDark + onChange() + expect(link.href).toBe(initial) +}) + +it('requires the document favicon link', () => { + document.querySelector('link[rel="icon"]')?.remove() + expect(installFavicon).toThrow('web app: missing favicon link') +}) diff --git a/apps/web/tests/settings-chrome.e2e.ts b/apps/web/tests/settings-chrome.e2e.ts index 0c6e79308a..4153763a97 100644 --- a/apps/web/tests/settings-chrome.e2e.ts +++ b/apps/web/tests/settings-chrome.e2e.ts @@ -376,12 +376,18 @@ describe('web e2e: settings modal and General preferences', () => { legacy: string | null themeColor: string | null themeColorCount: number + faviconFill: string | null token: string } const readState = async (target: Page = page): Promise => await target.evaluate(() => { const metas = document.head.querySelectorAll('meta[name="theme-color"]') const computed = getComputedStyle(document.body) + const icon = document.querySelector('link[rel="icon"]')?.href ?? '' + if (!icon.startsWith('data:image/svg+xml,')) throw new Error('favicon is not an explicit-color SVG') + const svg = new DOMParser().parseFromString(decodeURIComponent(icon.slice('data:image/svg+xml,'.length)), 'image/svg+xml') + if (svg.querySelector('style') !== null) throw new Error('favicon still depends on SVG media queries') return { + faviconFill: svg.querySelector('path')?.getAttribute('fill') ?? null, attr: document.body.hasAttribute('data-ds-dark-theme'), background: computed.backgroundColor, legacy: localStorage.getItem('dsh.theme'), @@ -400,6 +406,7 @@ describe('web e2e: settings modal and General preferences', () => { await page.emulateMedia({ colorScheme: 'light' }) const light = await readState() expect(light.attr).toBe(false) + expect(light.faviconFill).toBe('#000') expectThemeColorSynchronized(light) await page.getByRole('button', { name: '设置', exact: true }).click() @@ -413,6 +420,7 @@ describe('web e2e: settings modal and General preferences', () => { await expect.poll(() => darkCube.getAttribute('aria-pressed'), { timeout: 5_000 }).toBe('true') const dark = await readState() expect(dark.attr).toBe(true) + expect(dark.faviconFill).toBe('#000') expect(dark.legacy).toBeNull() expect(dark.token).not.toBe(light.token) expectThemeColorSynchronized(dark) @@ -429,6 +437,7 @@ describe('web e2e: settings modal and General preferences', () => { await expect.poll(async () => (await readState()).attr, { timeout: 5_000 }).toBe(true) const reloaded = await readState() expect(reloaded.legacy).toBeNull() + expect(reloaded.faviconFill).toBe('#000') expectThemeColorSynchronized(reloaded) // A second live Host binds another ephemeral port but shares the same @@ -462,11 +471,15 @@ describe('web e2e: settings modal and General preferences', () => { expectThemeColorSynchronized(await readState()) await page.emulateMedia({ colorScheme: 'dark' }) await expect.poll(async () => (await readState()).attr, { timeout: 5_000 }).toBe(true) + await expect.poll(async () => (await readState()).faviconFill).toBe('#fff') expectThemeColorSynchronized(await readState()) // Restore for the specs that follow: light preference beats the emulated // dark OS scheme, leaving the shared page in the light default. await selectTheme(page.getByRole('dialog', { name: '设置' }).getByRole('button', { name: '浅色' }), 'light') await expect.poll(async () => (await readState()).attr, { timeout: 5_000 }).toBe(false) + expect((await readState()).faviconFill).toBe('#fff') + await page.emulateMedia({ colorScheme: 'light' }) + await expect.poll(async () => (await readState()).faviconFill).toBe('#000') expectThemeColorSynchronized(await readState()) await page.keyboard.press('Escape') expect(tripwire.pageErrors).toEqual([]) From f2a22a31082b3de6dd2e20fbeb7cf058056ec6a6 Mon Sep 17 00:00:00 2001 From: Turtle Date: Sun, 20 Sep 2026 20:05:17 +0800 Subject: [PATCH 2/3] test(web): provide favicon environment in desktop boot fixture --- apps/web/tests/desktop-boot.spec.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/apps/web/tests/desktop-boot.spec.ts b/apps/web/tests/desktop-boot.spec.ts index cac91ad7e7..9bea3def5f 100644 --- a/apps/web/tests/desktop-boot.spec.ts +++ b/apps/web/tests/desktop-boot.spec.ts @@ -1,5 +1,5 @@ // @vitest-environment jsdom -import { afterEach, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' const boot = vi.hoisted(() => ({ run: vi.fn(), @@ -10,10 +10,16 @@ vi.mock('@deepseek-ai/dsh-client-web', () => ({ applyIndexInjections: boot.applyIndexInjections, })) +beforeEach(() => { + document.head.innerHTML = '' + vi.stubGlobal('matchMedia', () => ({ matches: false, addEventListener: vi.fn() })) +}) + afterEach(() => { vi.unstubAllGlobals() vi.clearAllMocks() vi.resetModules() + document.head.replaceChildren() document.body.replaceChildren() }) From a9840f055f0091a3b729191a82fed28025ded3e1 Mon Sep 17 00:00:00 2001 From: Turtle Date: Mon, 21 Sep 2026 16:13:38 +0800 Subject: [PATCH 3/3] fix(web): select static favicons with media-qualified links --- apps/web/index.html | 3 +- apps/web/public/favicon-dark.svg | 3 ++ apps/web/public/favicon.svg | 5 --- apps/web/src/favicon.ts | 26 ------------- apps/web/src/main.ts | 2 - apps/web/tests/desktop-boot.spec.ts | 8 +--- apps/web/tests/favicon.spec.ts | 55 --------------------------- apps/web/tests/pwa-manifest.e2e.ts | 16 +++++--- apps/web/tests/settings-chrome.e2e.ts | 21 +++++----- 9 files changed, 25 insertions(+), 114 deletions(-) create mode 100644 apps/web/public/favicon-dark.svg delete mode 100644 apps/web/src/favicon.ts delete mode 100644 apps/web/tests/favicon.spec.ts diff --git a/apps/web/index.html b/apps/web/index.html index 75c4e5bac6..f5c2ffac06 100644 --- a/apps/web/index.html +++ b/apps/web/index.html @@ -4,7 +4,8 @@ - + + DSH Local Build diff --git a/apps/web/public/favicon-dark.svg b/apps/web/public/favicon-dark.svg new file mode 100644 index 0000000000..648fe4f2b9 --- /dev/null +++ b/apps/web/public/favicon-dark.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/web/public/favicon.svg b/apps/web/public/favicon.svg index c92f15d43b..754615a37a 100644 --- a/apps/web/public/favicon.svg +++ b/apps/web/public/favicon.svg @@ -1,8 +1,3 @@ - diff --git a/apps/web/src/favicon.ts b/apps/web/src/favicon.ts deleted file mode 100644 index ac08a9f84f..0000000000 --- a/apps/web/src/favicon.ts +++ /dev/null @@ -1,26 +0,0 @@ -/** Browser-tab icons follow the system scheme independently of the application theme. */ -import favicon from '../public/favicon.svg?raw' - -/** - * Install fixed-color icons and a document-lifetime system-scheme listener. - * Browser favicon rasterizers can cache SVG media-query results across scheme changes. - * Distinct data URLs select explicit colors without duplicating the source artwork. - */ -export function installFavicon(): void { - const link = document.querySelector('link[rel="icon"]') - if (link === null) throw new Error('web app: missing favicon link') - const svg = new DOMParser().parseFromString(favicon, 'image/svg+xml') - svg.querySelector('style')?.remove() - const path = svg.querySelector('path') - if (path === null) throw new Error('web app: missing favicon path') - const icon = (fill: string): string => { - path.setAttribute('fill', fill) - return `data:image/svg+xml,${encodeURIComponent(new XMLSerializer().serializeToString(svg))}` - } - const light = icon('#000') - const dark = icon('#fff') - const scheme = window.matchMedia('(prefers-color-scheme: dark)') - const update = (): void => { link.href = scheme.matches ? dark : light } - update() - scheme.addEventListener('change', update) -} diff --git a/apps/web/src/main.ts b/apps/web/src/main.ts index 9f1fa593a1..1a84cd5f63 100644 --- a/apps/web/src/main.ts +++ b/apps/web/src/main.ts @@ -1,6 +1,5 @@ /** Browser entry for the Web client. */ import { AppWebEntry, applyIndexInjections } from '@deepseek-ai/dsh-client-web' -import { installFavicon } from './favicon.ts' interface DesktopBootGlobal { dshDesktopBoot?: { @@ -15,7 +14,6 @@ const reportFailure = (reason: unknown): void => { } try { - installFavicon() const el = document.getElementById('root') if (el === null) throw new Error('web app: missing #root') const entry = new AppWebEntry(el) diff --git a/apps/web/tests/desktop-boot.spec.ts b/apps/web/tests/desktop-boot.spec.ts index 9bea3def5f..cac91ad7e7 100644 --- a/apps/web/tests/desktop-boot.spec.ts +++ b/apps/web/tests/desktop-boot.spec.ts @@ -1,5 +1,5 @@ // @vitest-environment jsdom -import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { afterEach, expect, it, vi } from 'vitest' const boot = vi.hoisted(() => ({ run: vi.fn(), @@ -10,16 +10,10 @@ vi.mock('@deepseek-ai/dsh-client-web', () => ({ applyIndexInjections: boot.applyIndexInjections, })) -beforeEach(() => { - document.head.innerHTML = '' - vi.stubGlobal('matchMedia', () => ({ matches: false, addEventListener: vi.fn() })) -}) - afterEach(() => { vi.unstubAllGlobals() vi.clearAllMocks() vi.resetModules() - document.head.replaceChildren() document.body.replaceChildren() }) diff --git a/apps/web/tests/favicon.spec.ts b/apps/web/tests/favicon.spec.ts deleted file mode 100644 index af932bc4fc..0000000000 --- a/apps/web/tests/favicon.spec.ts +++ /dev/null @@ -1,55 +0,0 @@ -// @vitest-environment jsdom -import { afterEach, expect, it, vi } from 'vitest' -import { installFavicon } from '../src/favicon.ts' - -const originalHead = document.head.innerHTML - -afterEach(() => { - document.head.innerHTML = originalHead - document.documentElement.style.removeProperty('color-scheme') - vi.unstubAllGlobals() -}) - -it.each([false, true])('selects fixed-color tab icons on boot and system changes (dark=%s)', (initialDark) => { - const link = document.createElement('link') - link.rel = 'icon' - link.href = '/favicon.svg' - document.head.append(link) - let dark = initialDark - let onChange = (): void => { throw new Error('missing scheme listener') } - const matchMedia = vi.fn(() => ({ - get matches() { return dark }, - addEventListener: (event: string, listener: () => void) => { - expect(event).toBe('change') - onChange = listener - }, - })) - vi.stubGlobal('matchMedia', matchMedia) - const readIcon = (): string => decodeURIComponent(link.href.slice('data:image/svg+xml,'.length)) - const expectFill = (fill: string): void => { - expect(link.href).toMatch(/^data:image\/svg\+xml,/) - const svg = new DOMParser().parseFromString(readIcon(), 'image/svg+xml') - expect(svg.querySelector('parsererror')).toBeNull() - expect(svg.querySelector('style')).toBeNull() - expect(svg.querySelector('path')?.getAttribute('fill')).toBe(fill) - expect(svg.documentElement.getAttribute('viewBox')).toBe('0 0 50 50') - } - - document.documentElement.style.colorScheme = initialDark ? 'light' : 'dark' - installFavicon() - expect(matchMedia).toHaveBeenCalledWith('(prefers-color-scheme: dark)') - expectFill(initialDark ? '#fff' : '#000') - const initial = link.href - dark = !initialDark - onChange() - expectFill(initialDark ? '#000' : '#fff') - expect(link.href).not.toBe(initial) - dark = initialDark - onChange() - expect(link.href).toBe(initial) -}) - -it('requires the document favicon link', () => { - document.querySelector('link[rel="icon"]')?.remove() - expect(installFavicon).toThrow('web app: missing favicon link') -}) diff --git a/apps/web/tests/pwa-manifest.e2e.ts b/apps/web/tests/pwa-manifest.e2e.ts index db5e4c66bf..2cfc21e2c9 100644 --- a/apps/web/tests/pwa-manifest.e2e.ts +++ b/apps/web/tests/pwa-manifest.e2e.ts @@ -28,10 +28,14 @@ it('ships install metadata with the built web application', async () => { }) }) -it('ships a favicon that switches to a light mark under dark color scheme', async () => { - const favicon = await readFile(join(DIST_ROOT, 'favicon.svg'), 'utf8') - // The light fill must live inside the dark-scheme media query, so the icon - // stays black in light mode and only turns white under a dark scheme. - expect(favicon).toMatch(/@media \(prefers-color-scheme: dark\)\s*{\s*path\s*{[^}]*fill:\s*#fff/i) - expect(favicon).toContain('fill="#000"') +it('ships fixed-color favicons selected by document media queries', async () => { + const index = await readFile(join(DIST_ROOT, 'index.html'), 'utf8') + expect(index).toContain('') + expect(index).toContain('') + const light = await readFile(join(DIST_ROOT, 'favicon.svg'), 'utf8') + const dark = await readFile(join(DIST_ROOT, 'favicon-dark.svg'), 'utf8') + expect(light).not.toContain('