diff --git a/frontend/public/brand-icons/01-database-hug-about.png b/frontend/public/brand-icons/01-database-hug-about.png new file mode 100644 index 00000000..9f9609ae Binary files /dev/null and b/frontend/public/brand-icons/01-database-hug-about.png differ diff --git a/frontend/public/brand-icons/02-database-search-about.png b/frontend/public/brand-icons/02-database-search-about.png new file mode 100644 index 00000000..2c091ad3 Binary files /dev/null and b/frontend/public/brand-icons/02-database-search-about.png differ diff --git a/frontend/public/brand-icons/03-bandana-badge-about.png b/frontend/public/brand-icons/03-bandana-badge-about.png new file mode 100644 index 00000000..092a5473 Binary files /dev/null and b/frontend/public/brand-icons/03-bandana-badge-about.png differ diff --git a/frontend/public/brand-icons/04-magnifier-wink-about.png b/frontend/public/brand-icons/04-magnifier-wink-about.png new file mode 100644 index 00000000..8082b6bc Binary files /dev/null and b/frontend/public/brand-icons/04-magnifier-wink-about.png differ diff --git a/frontend/public/brand-icons/05-window-peek-about.png b/frontend/public/brand-icons/05-window-peek-about.png new file mode 100644 index 00000000..c0c0dd43 Binary files /dev/null and b/frontend/public/brand-icons/05-window-peek-about.png differ diff --git a/frontend/public/brand-icons/06-hex-collar-about.png b/frontend/public/brand-icons/06-hex-collar-about.png new file mode 100644 index 00000000..dd3877fd Binary files /dev/null and b/frontend/public/brand-icons/06-hex-collar-about.png differ diff --git a/frontend/public/brand-icons/07-graph-sit-about.png b/frontend/public/brand-icons/07-graph-sit-about.png new file mode 100644 index 00000000..e5c1dcd7 Binary files /dev/null and b/frontend/public/brand-icons/07-graph-sit-about.png differ diff --git a/frontend/public/brand-icons/08-cloud-banner-about.png b/frontend/public/brand-icons/08-cloud-banner-about.png new file mode 100644 index 00000000..ab76068a Binary files /dev/null and b/frontend/public/brand-icons/08-cloud-banner-about.png differ diff --git a/frontend/public/brand-icons/09-terminal-sit-about.png b/frontend/public/brand-icons/09-terminal-sit-about.png new file mode 100644 index 00000000..22e4c44e Binary files /dev/null and b/frontend/public/brand-icons/09-terminal-sit-about.png differ diff --git a/frontend/public/brand-icons/10-compass-bandana-about.png b/frontend/public/brand-icons/10-compass-bandana-about.png new file mode 100644 index 00000000..2a9f5610 Binary files /dev/null and b/frontend/public/brand-icons/10-compass-bandana-about.png differ diff --git a/frontend/src/App.about-brand-lockup.test.ts b/frontend/src/App.about-brand-lockup.test.ts index 1bae260a..6fcf8768 100644 --- a/frontend/src/App.about-brand-lockup.test.ts +++ b/frontend/src/App.about-brand-lockup.test.ts @@ -1,5 +1,6 @@ import { readdirSync, readFileSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; +import { unzlibSync } from 'fflate'; import { describe, expect, it } from 'vitest'; const appSource = readFileSync( @@ -17,6 +18,36 @@ const defaultTitlebarMark = fileURLToPath( new globalThis.URL('../public/brand-marks/02-database-search-transparent.png', import.meta.url), ); +const readFirstPngPixelAlpha = (assetBase64: string): number => { + const binary = globalThis.atob(assetBase64); + const pngBytes = Uint8Array.from(binary, (character) => character.charCodeAt(0)); + const idatChunks: Uint8Array[] = []; + + for (let offset = 8; offset + 12 <= pngBytes.length;) { + const chunkLength = new DataView( + pngBytes.buffer, + pngBytes.byteOffset + offset, + 4, + ).getUint32(0); + const chunkType = String.fromCharCode(...pngBytes.slice(offset + 4, offset + 8)); + if (chunkType === 'IDAT') { + idatChunks.push(pngBytes.slice(offset + 8, offset + 8 + chunkLength)); + } + offset += chunkLength + 12; + } + + const compressedLength = idatChunks.reduce((total, chunk) => total + chunk.length, 0); + const compressedData = new Uint8Array(compressedLength); + let compressedOffset = 0; + for (const chunk of idatChunks) { + compressedData.set(chunk, compressedOffset); + compressedOffset += chunk.length; + } + + const scanlines = unzlibSync(compressedData); + return scanlines[4] ?? -1; +}; + describe('about brand lockup', () => { it('uses a transparent lockup without a tile background on the about page', () => { expect(appSource).toContain('resolveBrandAboutSrc'); @@ -37,18 +68,33 @@ describe('about brand lockup', () => { expect(readFileSync(defaultTitlebarMark, 'base64')).not.toHaveLength(0); }); - it('keeps one shared lossless WebP asset for every selectable mascot', () => { + it('keeps a tile asset and a transparent about lockup for every selectable mascot', () => { const iconAssetPaths = brandIconsSource.match(/iconPath: '\/brand-icons\/\d{2}-.+\.webp'/g) || []; + const aboutAssetPaths = brandIconsSource.match(/aboutPath: '\/brand-icons\/\d{2}-.+-about\.png'/g) || []; expect(iconAssetPaths).toHaveLength(10); + expect(aboutAssetPaths).toHaveLength(10); + for (const declaration of iconAssetPaths) { const assetPath = declaration.match(/'([^']+)'/)?.[1]; expect(assetPath).toBeTruthy(); - expect(readFileSync(`${brandIconsDirectory}${assetPath?.replace('/brand-icons/', '')}`, 'utf8')).not.toBe(''); + expect(readFileSync(`${brandIconsDirectory}${assetPath?.replace('/brand-icons/', '')}`, 'base64')).not.toBe(''); + } + + for (const declaration of aboutAssetPaths) { + const assetPath = declaration.match(/'([^']+)'/)?.[1]; + expect(assetPath).toBeTruthy(); + const assetBase64 = readFileSync( + `${brandIconsDirectory}${assetPath?.replace('/brand-icons/', '')}`, + 'base64', + ); + expect(assetBase64).not.toBe(''); + expect(globalThis.atob(assetBase64).charCodeAt(25)).toBe(6); + expect(readFirstPngPixelAlpha(assetBase64)).toBe(0); } const webpFiles = readdirSync(brandIconsDirectory).filter((file) => file.endsWith('.webp')); expect(webpFiles).toHaveLength(10); - const legacyPngFiles = readdirSync(brandIconsDirectory).filter((file) => file.endsWith('.png')); - expect(legacyPngFiles).toEqual([]); + const aboutPngFiles = readdirSync(brandIconsDirectory).filter((file) => file.endsWith('-about.png')); + expect(aboutPngFiles).toHaveLength(10); }); }); diff --git a/frontend/src/brand/brandIcons.test.ts b/frontend/src/brand/brandIcons.test.ts index d0b1f4a7..75d0535a 100644 --- a/frontend/src/brand/brandIcons.test.ts +++ b/frontend/src/brand/brandIcons.test.ts @@ -9,13 +9,16 @@ import { } from './brandIcons'; describe('brand icon asset resolution', () => { - it('uses one selected lossless asset for the favicon, picker, about page, and Dock', () => { + it('keeps tile assets for app surfaces and uses a transparent lockup on the about page', () => { for (const icon of BRAND_ICONS) { const selectedAsset = resolveBrandIconSrc(icon.id); expect(selectedAsset).toMatch(/^\/brand-icons\/\d{2}-.+\.webp$/); expect(resolveBrandFullSrc(icon.id)).toBe(selectedAsset); - expect(resolveBrandAboutSrc(icon.id)).toBe(selectedAsset); expect(resolveBrandDockSrc(icon.id)).toBe(selectedAsset); + + const aboutAsset = resolveBrandAboutSrc(icon.id); + expect(aboutAsset).toMatch(/^\/brand-icons\/\d{2}-.+-about\.png$/); + expect(aboutAsset).not.toBe(selectedAsset); } }); @@ -26,4 +29,10 @@ describe('brand icon asset resolution', () => { expect(resolveBrandTitlebarSrc('unknown')).toBe(defaultTitlebarAsset); expect(resolveBrandTitlebarSrc('01')).toBe(resolveBrandIconSrc('01')); }); + + it('falls back to the default transparent about lockup for invalid selections', () => { + const defaultAboutAsset = resolveBrandAboutSrc('02'); + expect(resolveBrandAboutSrc()).toBe(defaultAboutAsset); + expect(resolveBrandAboutSrc('unknown')).toBe(defaultAboutAsset); + }); }); diff --git a/frontend/src/brand/brandIcons.ts b/frontend/src/brand/brandIcons.ts index e569ab2c..cf653af9 100644 --- a/frontend/src/brand/brandIcons.ts +++ b/frontend/src/brand/brandIcons.ts @@ -16,6 +16,7 @@ export type BrandIconDefinition = { titleZh: string; titleEn: string; iconPath: string; + aboutPath: string; titlebarPath?: string; }; @@ -28,6 +29,7 @@ export const BRAND_ICONS: BrandIconDefinition[] = [ titleZh: '抱库小狗', titleEn: 'Database hug', iconPath: '/brand-icons/01-database-hug.webp', + aboutPath: '/brand-icons/01-database-hug-about.png', }, { id: '02', @@ -35,6 +37,7 @@ export const BRAND_ICONS: BrandIconDefinition[] = [ titleZh: '搜库小狗', titleEn: 'Database search', iconPath: '/brand-icons/02-database-search.webp', + aboutPath: '/brand-icons/02-database-search-about.png', titlebarPath: '/brand-marks/02-database-search-transparent.png', }, { @@ -43,6 +46,7 @@ export const BRAND_ICONS: BrandIconDefinition[] = [ titleZh: '头巾徽章', titleEn: 'Bandana badge', iconPath: '/brand-icons/03-bandana-badge.webp', + aboutPath: '/brand-icons/03-bandana-badge-about.png', }, { id: '04', @@ -50,6 +54,7 @@ export const BRAND_ICONS: BrandIconDefinition[] = [ titleZh: '放大镜眨眼', titleEn: 'Magnifier wink', iconPath: '/brand-icons/04-magnifier-wink.webp', + aboutPath: '/brand-icons/04-magnifier-wink-about.png', }, { id: '05', @@ -57,6 +62,7 @@ export const BRAND_ICONS: BrandIconDefinition[] = [ titleZh: '窗口探头', titleEn: 'Window peek', iconPath: '/brand-icons/05-window-peek.webp', + aboutPath: '/brand-icons/05-window-peek-about.png', }, { id: '06', @@ -64,6 +70,7 @@ export const BRAND_ICONS: BrandIconDefinition[] = [ titleZh: '六边项圈', titleEn: 'Hex collar', iconPath: '/brand-icons/06-hex-collar.webp', + aboutPath: '/brand-icons/06-hex-collar-about.png', }, { id: '07', @@ -71,6 +78,7 @@ export const BRAND_ICONS: BrandIconDefinition[] = [ titleZh: '关系图', titleEn: 'Graph sit', iconPath: '/brand-icons/07-graph-sit.webp', + aboutPath: '/brand-icons/07-graph-sit-about.png', }, { id: '08', @@ -78,6 +86,7 @@ export const BRAND_ICONS: BrandIconDefinition[] = [ titleZh: '云朵横幅', titleEn: 'Cloud banner', iconPath: '/brand-icons/08-cloud-banner.webp', + aboutPath: '/brand-icons/08-cloud-banner-about.png', }, { id: '09', @@ -85,6 +94,7 @@ export const BRAND_ICONS: BrandIconDefinition[] = [ titleZh: '终端旁坐', titleEn: 'Terminal sit', iconPath: '/brand-icons/09-terminal-sit.webp', + aboutPath: '/brand-icons/09-terminal-sit-about.png', }, { id: '10', @@ -92,6 +102,7 @@ export const BRAND_ICONS: BrandIconDefinition[] = [ titleZh: '罗盘头巾', titleEn: 'Compass bandana', iconPath: '/brand-icons/10-compass-bandana.webp', + aboutPath: '/brand-icons/10-compass-bandana-about.png', }, ]; @@ -118,7 +129,7 @@ export function resolveBrandFullSrc(id?: unknown): string { } export function resolveBrandAboutSrc(id?: unknown): string { - return resolveBrandIconSrc(id); + return resolveBrandIcon(id).aboutPath; } export function resolveBrandTitlebarSrc(id?: unknown): string {