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-02 11:23:47 +08:00
parent c22e9154c4
commit 2593599862
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); });