fix(ui,a11y): 修 logo 深色模式不可见 + 补 tailwind font-calligraphy 工具类

修复 A:header logo 深色模式字被吞
- logo.svg 右侧公司名(睿新致遠 + NOVALON)硬编码 fill='#0A0E14',
  深色背景下 background 同色不可见,红色印章下书法字与英文全部被吞。
- 复用既有 logo-white.svg(白字 + 红印章)作为深色版,
  header 渲染双 <Image>,按 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 ✓
This commit is contained in:
2026-09-20 11:50:58 +08:00
parent 09ff3e026b
commit 918d845aef
9 changed files with 383 additions and 1 deletions
+34
View File
@@ -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'}`);
+62
View File
@@ -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);
+49
View File
@@ -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);
+79
View File
@@ -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);
+77
View File
@@ -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);
+55
View File
@@ -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); });
+11
View File
@@ -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(白字)用于深色背景。
两个 <Image> 都在 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 反色为浅色(其语义收窄为"文字/描边跟随主题"
+12 -1
View File
@@ -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。 */}
<Image
src="/logo.svg"
alt={config.name}
width={160}
height={40}
className="relative transition-all duration-300 ease-out group-hover/logo:scale-[1.02] w-auto h-8 md:h-10"
className="logo-on-light relative transition-all duration-300 ease-out group-hover/logo:scale-[1.02] w-auto h-8 md:h-10"
loading="eager"
priority
suppressHydrationWarning
/>
<Image
src="/logo-white.svg"
alt={config.name}
width={160}
height={40}
className="logo-on-dark relative transition-all duration-300 ease-out group-hover/logo:scale-[1.02] w-auto h-8 md:h-10"
loading="eager"
suppressHydrationWarning
/>
</StaticLink>
<nav className="hidden md:flex items-center gap-0.5" role="navigation" aria-label="主导航" data-testid="desktop-navigation">
+4
View File
@@ -124,6 +124,10 @@ module.exports = {
mono: ['var(--font-mono)'],
brand: ['var(--font-brand)'],
serif: ['var(--font-serif)'],
// 书法体:globals.css 的 --font-calligraphy-systemSTKaiti / KaiTi 系统字栈)
// 此前 fontFamily 未声明该 key,导致 font-calligraphy utility 不生成,
// 4 处装饰书法字(why-us / challenge / brand-visuals)静默回退 sans。
calligraphy: ['var(--font-calligraphy-system)'],
},
fontSize: {
caption: 'var(--font-size-caption)',