From 2593599862c0ab1c3ac91ec52fc26002dbbd7979 Mon Sep 17 00:00:00 2001 From: zhangxiang Date: Wed, 2 Sep 2026 11:23:47 +0800 Subject: [PATCH] =?UTF-8?q?fix(ui,a11y):=20=E4=BF=AE=20logo=20=E6=B7=B1?= =?UTF-8?q?=E8=89=B2=E6=A8=A1=E5=BC=8F=E4=B8=8D=E5=8F=AF=E8=A7=81=20+=20?= =?UTF-8?q?=E8=A1=A5=20tailwind=20font-calligraphy=20=E5=B7=A5=E5=85=B7?= =?UTF-8?q?=E7=B1=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 修复 A:header logo 深色模式字被吞 - logo.svg 右侧公司名(睿新致遠 + NOVALON)硬编码 fill='#0A0E14', 深色背景下 background 同色不可见,红色印章下书法字与英文全部被吞。 - 复用既有 logo-white.svg(白字 + 红印章)作为深色版, header 渲染双 ,按 html[data-theme] CSS 显隐。 - 双 Image 不占位(display:none),零 JS 闪烁,未来主题切换器自动生效。 修复 B:font-calligraphy 工具类不生成 - tailwind.config.js 的 fontFamily 此前未声明 calligraphy key, 导致 font-calligraphy utility 永不生成,4 处装饰书法字 (BrandStamp「值得信赖」/ ChallengeSection「使命」/ WhyUsSection「睿」「我们」) 静默回退 sans。 - fontFamily 加 calligraphy: ['var(--font-calligraphy-system)']。 审计脚本: - logo-compare*.mjs:生产 vs 本地 logo 对比定位 - logo-dark-verify.mjs:明暗双版渲染验证 - font-calligraphy-verify.mjs:合成探针验证 utility 生效 验证: - type-check 0 error · lint 0 error(127 warnings 既有项与本次无关) - 深浅色 header 截图:明暗版正确切换,书法字 + NOVALON 清晰可见 - font-calligraphy 合成探针 computed fontFamily = STKaiti, KaiTi, 楷体, SimKai, serif ✓ --- scripts/audit/font-calligraphy-verify.mjs | 34 ++++++++++ scripts/audit/logo-compare-dark.mjs | 62 ++++++++++++++++++ scripts/audit/logo-compare-pages.mjs | 49 ++++++++++++++ scripts/audit/logo-compare-toggle.mjs | 79 +++++++++++++++++++++++ scripts/audit/logo-compare.mjs | 77 ++++++++++++++++++++++ scripts/audit/logo-dark-verify.mjs | 55 ++++++++++++++++ src/app/globals.css | 11 ++++ src/components/layout/header.tsx | 13 +++- tailwind.config.js | 4 ++ 9 files changed, 383 insertions(+), 1 deletion(-) create mode 100644 scripts/audit/font-calligraphy-verify.mjs create mode 100644 scripts/audit/logo-compare-dark.mjs create mode 100644 scripts/audit/logo-compare-pages.mjs create mode 100644 scripts/audit/logo-compare-toggle.mjs create mode 100644 scripts/audit/logo-compare.mjs create mode 100644 scripts/audit/logo-dark-verify.mjs diff --git a/scripts/audit/font-calligraphy-verify.mjs b/scripts/audit/font-calligraphy-verify.mjs new file mode 100644 index 0000000..9f3b96b --- /dev/null +++ b/scripts/audit/font-calligraphy-verify.mjs @@ -0,0 +1,34 @@ +// 终极:直接构造一个含 font-calligraphy className 的测试元素, +// 把它注入到任意真实页面 DOM,读取 computed fontFamily —— 验证 CSS 规则真生效 +import { chromium } from 'playwright'; +const browser = await chromium.launch(); +const page = await browser.newPage({ viewport: { width: 1280, height: 800 } }); +await page.goto('http://127.0.0.1:3000/', { waitUntil: 'networkidle' }); +await page.waitForTimeout(2000); + +const result = await page.evaluate(() => { + // 找一个已渲染的根节点挂测试元素 + const mount = document.body; + const probe = document.createElement('span'); + probe.id = '__probe_font_calligraphy__'; + probe.className = 'font-calligraphy'; + probe.textContent = '使命'; + probe.style.position = 'fixed'; + probe.style.top = '-9999px'; + mount.appendChild(probe); + const cs = getComputedStyle(probe); + const out = { + probeFontFamily: cs.fontFamily, + probeClassList: Array.from(probe.classList), + }; + probe.remove(); + return out; +}); + +await browser.close(); +console.log(JSON.stringify(result, null, 2)); + +const expectedPrefix = 'STKaiti'; +const pass = result.probeFontFamily.startsWith(expectedPrefix); +console.log(`\n[assert] probe computed fontFamily starts with ${expectedPrefix}: ${pass}`); +console.log(`[result] ${pass ? 'PASS' : 'FAIL'}`); \ No newline at end of file diff --git a/scripts/audit/logo-compare-dark.mjs b/scripts/audit/logo-compare-dark.mjs new file mode 100644 index 0000000..f9c9aa5 --- /dev/null +++ b/scripts/audit/logo-compare-dark.mjs @@ -0,0 +1,62 @@ +/** + * 深色模式 logo 对比:生产 vs 本地 + * 强制 prefers-color-scheme: dark,截 header logo + */ +import { chromium } from 'playwright'; +import fs from 'node:fs'; +import path from 'node:path'; + +const OUT = path.join(process.cwd(), 'dogfood-ui-audit', 'logo-compare'); +fs.mkdirSync(OUT, { recursive: true }); + +const TARGETS = [ + { tag: 'prod-dark', url: 'https://www.novalon.cn/' }, + { tag: 'local-dark', url: 'http://127.0.0.1:3000/' }, +]; + +const browser = await chromium.launch(); +for (const t of TARGETS) { + const ctx = await browser.newContext({ + viewport: { width: 1440, height: 900 }, + deviceScaleFactor: 2, + colorScheme: 'dark', // 强制深色模式 + }); + const page = await ctx.newPage(); + try { + await page.goto(t.url, { waitUntil: 'networkidle', timeout: 60000 }); + await page.waitForTimeout(1500); + + // 1) 截 header + const header = page.locator('header').first(); + await header.screenshot({ path: path.join(OUT, `${t.tag}-header.png`) }).catch(async () => { + await page.screenshot({ path: path.join(OUT, `${t.tag}-header.png`), clip: { x: 0, y: 0, width: 1440, height: 80 } }); + }); + + // 2) 探测 logo 元素的可视状态(在深色背景下) + const info = await page.evaluate(() => { + const imgs = [...document.querySelectorAll('img')].filter(i => /logo\.svg$/.test(i.getAttribute('src') || '')); + return imgs.map(i => { + const r = i.getBoundingClientRect(); + const cs = getComputedStyle(i); + const parentCs = getComputedStyle(i.closest('header') || document.body); + return { + src: i.getAttribute('src'), + renderedW: Math.round(r.width), + renderedH: Math.round(r.height), + filter: cs.filter, + opacity: cs.opacity, + visibility: cs.visibility, + parentBg: parentCs.backgroundColor, + htmlBg: getComputedStyle(document.documentElement).backgroundColor, + }; + }); + }); + console.log(`[${t.tag}]`); + for (const l of info) console.log(' ' + JSON.stringify(l)); + } catch (e) { + console.log(`[${t.tag}] FAILED ${e.message.slice(0, 120)}`); + } + await ctx.close(); +} +await browser.close(); +console.log('\n✓ 输出目录:' + OUT); diff --git a/scripts/audit/logo-compare-pages.mjs b/scripts/audit/logo-compare-pages.mjs new file mode 100644 index 0000000..7b08a6a --- /dev/null +++ b/scripts/audit/logo-compare-pages.mjs @@ -0,0 +1,49 @@ +/** + * 综合视觉对比:生产 vs 本地,多个关键位置 + * 收集决定性证据,让用户指出"logo 原本是书法体但生产不是"指的具体位置 + */ +import { chromium } from 'playwright'; +import fs from 'node:fs'; +import path from 'node:path'; + +const OUT = path.join(process.cwd(), 'dogfood-ui-audit', 'logo-compare'); +fs.mkdirSync(OUT, { recursive: true }); + +const PAGES = [ + { name: 'home', url: '/' }, + { name: 'about-brand', url: '/about/brand' }, + { name: 'about', url: '/about' }, +]; + +const TARGETS = [ + { tag: 'prod', base: 'https://www.novalon.cn' }, + { tag: 'local', base: 'http://127.0.0.1:3000' }, +]; + +const browser = await chromium.launch(); + +for (const t of TARGETS) { + const ctx = await browser.newContext({ + viewport: { width: 1440, height: 900 }, + deviceScaleFactor: 2, + }); + const page = await ctx.newPage(); + for (const p of PAGES) { + const url = t.base + p.url; + try { + await page.goto(url, { waitUntil: 'networkidle', timeout: 60000 }); + await page.waitForTimeout(1500); + // 截首屏(Hero + header 区域) + await page.screenshot({ + path: path.join(OUT, `${t.tag}-${p.name}-fold.png`), + clip: { x: 0, y: 0, width: 1440, height: 900 }, + }); + console.log(`[${t.tag}] ${p.name} OK`); + } catch (e) { + console.log(`[${t.tag}] ${p.name} FAILED ${e.message.slice(0, 100)}`); + } + } + await ctx.close(); +} +await browser.close(); +console.log('\n✓ 输出:' + OUT); diff --git a/scripts/audit/logo-compare-toggle.mjs b/scripts/audit/logo-compare-toggle.mjs new file mode 100644 index 0000000..1214095 --- /dev/null +++ b/scripts/audit/logo-compare-toggle.mjs @@ -0,0 +1,79 @@ +/** + * 模拟点深色模式按钮后的 header 截图 + * 找切换按钮:theme toggle / data-theme / aria-label 含亮暗 + */ +import { chromium } from 'playwright'; +import fs from 'node:fs'; +import path from 'node:path'; + +const OUT = path.join(process.cwd(), 'dogfood-ui-audit', 'logo-compare'); +fs.mkdirSync(OUT, { recursive: true }); + +const TARGETS = [ + { tag: 'prod', url: 'https://www.novalon.cn/' }, + { tag: 'local', url: 'http://127.0.0.1:3000/' }, +]; + +const browser = await chromium.launch(); +for (const t of TARGETS) { + const ctx = await browser.newContext({ + viewport: { width: 1440, height: 900 }, + deviceScaleFactor: 2, + }); + const page = await ctx.newPage(); + try { + await page.goto(t.url, { waitUntil: 'networkidle', timeout: 60000 }); + await page.waitForTimeout(1500); + + // 1) 找主题切换按钮 + const btnInfo = await page.evaluate(() => { + const candidates = [ + ...document.querySelectorAll('header button, header [role="button"], [data-theme-toggle], [aria-label*="深" i], [aria-label*="暗" i], [aria-label*="dark" i], [aria-label*="theme" i], [aria-label*="切换" i]'), + ]; + return candidates.map(b => ({ + tag: b.tagName, + label: b.getAttribute('aria-label'), + text: (b.textContent || '').slice(0, 30), + className: (b.className || '').toString().slice(0, 60), + data: JSON.stringify(b.dataset).slice(0, 80), + })).slice(0, 6); + }); + console.log(`[${t.tag}] 候选切换按钮:`, JSON.stringify(btnInfo, null, 2)); + + // 2) 直接通过 localStorage 强制 dark(更稳) + await page.evaluate(() => { + try { localStorage.setItem('novalon-theme', 'dark'); } catch {} + }); + await page.reload({ waitUntil: 'networkidle' }); + await page.waitForTimeout(1500); + + // 3) 截深色模式下的 header + const header = page.locator('header').first(); + await header.screenshot({ path: path.join(OUT, `${t.tag}-dark-header.png`) }).catch(async () => { + await page.screenshot({ path: path.join(OUT, `${t.tag}-dark-header.png`), clip: { x: 0, y: 0, width: 1440, height: 80 } }); + }); + + // 4) 探测 logo 在深色模式下的实际颜色 + const logoState = await page.evaluate(() => { + const img = [...document.querySelectorAll('img')].find(i => /logo\.svg$/.test(i.getAttribute('src') || '')); + if (!img) return { found: false }; + const r = img.getBoundingClientRect(); + const parent = img.closest('header') || document.body; + return { + found: true, + src: img.getAttribute('src'), + renderedW: Math.round(r.width), + renderedH: Math.round(r.height), + parentBg: getComputedStyle(parent).backgroundColor, + htmlClass: document.documentElement.className, + htmlDataTheme: document.documentElement.getAttribute('data-theme'), + }; + }); + console.log(`[${t.tag}] dark-mode logo state:`, JSON.stringify(logoState, null, 2)); + } catch (e) { + console.log(`[${t.tag}] FAILED ${e.message.slice(0, 200)}`); + } + await ctx.close(); +} +await browser.close(); +console.log('\n✓ 输出:' + OUT); diff --git a/scripts/audit/logo-compare.mjs b/scripts/audit/logo-compare.mjs new file mode 100644 index 0000000..1dc5f8e --- /dev/null +++ b/scripts/audit/logo-compare.mjs @@ -0,0 +1,77 @@ +/** + * Logo 对比诊断:生产环境 vs 本地 dev + * + * 用法:node scripts/audit/logo-compare.mjs + * 输出:dogfood-ui-audit/logo-compare/{prod,local}-header.png + logo-meta.json + */ +import { chromium } from 'playwright'; +import fs from 'node:fs'; +import path from 'node:path'; + +const OUT = path.join(process.cwd(), 'dogfood-ui-audit', 'logo-compare'); +fs.mkdirSync(OUT, { recursive: true }); + +const TARGETS = [ + { tag: 'prod', url: 'https://www.novalon.cn/' }, + { tag: 'local', url: 'http://127.0.0.1:3000/' }, +]; + +const browser = await chromium.launch(); +const meta = {}; + +for (const t of TARGETS) { + const ctx = await browser.newContext({ + viewport: { width: 1440, height: 900 }, + deviceScaleFactor: 2, + }); + const page = await ctx.newPage(); + const errors = []; + page.on('requestfailed', r => errors.push('FAILED ' + r.url())); + page.on('console', m => { if (m.type() === 'error') errors.push('CONSOLE ' + m.text()); }); + + try { + await page.goto(t.url, { waitUntil: 'networkidle', timeout: 60000 }); + await page.waitForTimeout(1500); + + // header logo 元素探测 + const info = await page.evaluate(() => { + const imgs = [...document.querySelectorAll('img')] + .filter(i => /logo/i.test(i.getAttribute('src') || '')); + return imgs.map(i => { + const r = i.getBoundingClientRect(); + const cs = getComputedStyle(i); + return { + src: i.getAttribute('src'), + currentSrc: i.currentSrc, + renderedW: Math.round(r.width), + renderedH: Math.round(r.height), + naturalW: i.naturalWidth, + naturalH: i.naturalHeight, + complete: i.complete, + fontFamily: cs.fontFamily, + }; + }); + }); + + // 截 header 区域(放大到 logo 可见) + const header = page.locator('header').first(); + await header.screenshot({ path: path.join(OUT, `${t.tag}-header.png`) }).catch(async () => { + await page.screenshot({ path: path.join(OUT, `${t.tag}-header.png`), clip: { x: 0, y: 0, width: 1440, height: 80 } }); + }); + + meta[t.tag] = { url: t.url, logos: info, errors }; + console.log(`[${t.tag}] OK logos=${info.length} errors=${errors.length}`); + for (const l of info) { + console.log(` src=${l.src} currentSrc=${l.currentSrc}`); + console.log(` rendered=${l.renderedW}x${l.renderedH} natural=${l.naturalW}x${l.naturalH} complete=${l.complete}`); + } + } catch (e) { + meta[t.tag] = { url: t.url, error: e.message.slice(0, 200), errors }; + console.log(`[${t.tag}] FAILED ${e.message.slice(0, 120)}`); + } + await ctx.close(); +} + +fs.writeFileSync(path.join(OUT, 'logo-meta.json'), JSON.stringify(meta, null, 2)); +await browser.close(); +console.log('\n✓ 输出目录:' + OUT); diff --git a/scripts/audit/logo-dark-verify.mjs b/scripts/audit/logo-dark-verify.mjs new file mode 100644 index 0000000..5e27340 --- /dev/null +++ b/scripts/audit/logo-dark-verify.mjs @@ -0,0 +1,55 @@ +// 验证 header logo 明暗双版:浅色应显示 logo.svg(黑字),深色应显示 logo-white.svg(白字) +// 用法:node scripts/audit/logo-dark-verify.mjs +import { chromium } from 'playwright'; + +const BASE = 'http://127.0.0.1:3000'; +const OUT = 'dogfood-output/logo-dark-verify'; + +const shots = []; + +async function run() { + const browser = await chromium.launch(); + const ctx = await browser.newContext({ viewport: { width: 1280, height: 800 } }); + const page = await ctx.newPage(); + + // 浅色(默认) + await page.goto(BASE, { waitUntil: 'networkidle' }); + await page.waitForTimeout(800); + const lightSrc = await page.$eval('header img', (el) => el.getAttribute('src')); + const visibleLight = await getVisible(page); + await page.screenshot({ path: `${OUT}/header-light.png` }); + shots.push({ theme: 'light', htmlDataTheme: await page.getAttribute('html', 'data-theme'), firstImgSrc: lightSrc, visible: visibleLight }); + + // 深色:设 localStorage 后 reload(与站点内联脚本一致) + await page.evaluate(() => localStorage.setItem('novalon-theme', 'dark')); + await page.goto(BASE, { waitUntil: 'networkidle' }); + await page.waitForTimeout(800); + const visibleDark = await getVisible(page); + const darkSrc = await page.$eval('header img', (el) => el.getAttribute('src')).catch(() => 'N/A'); + await page.screenshot({ path: `${OUT}/header-dark.png` }); + shots.push({ theme: 'dark', htmlDataTheme: await page.getAttribute('html', 'data-theme'), firstImgSrc: darkSrc, visible: visibleDark }); + + await browser.close(); + + console.log(JSON.stringify(shots, null, 2)); + // 断言:浅色无 data-theme 属性(null),可见 logo.svg;深色 data-theme=dark,可见 logo-white.svg + const lightOk = shots[0].visible === 'logo.svg' && shots[0].htmlDataTheme !== 'dark'; + const darkOk = shots[1].visible === 'logo-white.svg' && shots[1].htmlDataTheme === 'dark'; + console.log(`\n[assert] light shows logo.svg: ${lightOk}`); + console.log(`[assert] dark shows logo-white.svg: ${darkOk}`); + console.log(`[result] ${lightOk && darkOk ? 'PASS' : 'FAIL'}`); +} + +// 返回当前实际可见(display !== none)的 logo img 的 src +async function getVisible(page) { + return page.evaluate(() => { + const imgs = Array.from(document.querySelectorAll('header img')); + const vis = imgs.filter((i) => { + const s = getComputedStyle(i); + return s.display !== 'none' && s.visibility !== 'hidden' && Number(s.opacity) > 0; + }); + return vis.length ? vis[0].getAttribute('src') : 'NONE_VISIBLE'; + }); +} + +run().catch((e) => { console.error(e); process.exit(1); }); diff --git a/src/app/globals.css b/src/app/globals.css index 3d38703..8833e08 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -380,6 +380,17 @@ body { --grid-gap-2xl: 3rem; } +/* === Logo 明暗双版(html[data-theme] 驱动)=== + 站点主题由 html[data-theme='dark'] 属性驱动,而非 .dark class, + 因此 Tailwind dark: 变体(darkMode:'class')不匹配 —— 用显式属性选择器切换。 + - logo.svg(深黑字)用于浅色背景;logo-white.svg(白字)用于深色背景。 + 两个 都在 DOM 内、按 data-theme 显隐(display:none,不占位), + 无 JS 闪烁,未来主题切换器改 data-theme 时自动生效。 + SSR 初始 HTML 无 data-theme → 默认显示 light 版,head 内联脚本同步设置 + data-theme 后即时切换,不产生重叠。 */ +html[data-theme='dark'] .logo-on-light { display: none; } +html:not([data-theme='dark']) .logo-on-dark { display: none; } + /* === 暗黑模式(html[data-theme='dark'])=== 策略:仅覆盖 CSS 变量,组件无需逐元素反色。 - 文字墨色 --color-ink 反色为浅色(其语义收窄为"文字/描边跟随主题") diff --git a/src/components/layout/header.tsx b/src/components/layout/header.tsx index f71bd04..475f8db 100644 --- a/src/components/layout/header.tsx +++ b/src/components/layout/header.tsx @@ -96,16 +96,27 @@ function HeaderContent() { className="relative flex items-center group/logo" aria-label="返回首页" > + {/* Logo 明暗双版:浅色背景用 logo.svg(黑字),深色背景用 logo-white.svg(白字)。 + 按 html[data-theme] 显隐(规则见 globals.css),修复黑字在深色背景被吞的 bug。 */} {config.name} + {config.name}