主题体系跟上设计令牌化:派生文字色按对比度下限、主题 muted 补到 AA

生成器(dev/theme-builder):
- paletteVars 的 text-secondary/text-muted 从「往底色混 36%/55%」改成 mixToContrast(5.5/4.5):二分找最淡且达标的位置,文字本身不达标就原样返回
- 半透明卡片先 flatten 压平到底色再算对比度;参考背景取「页面底色 + 压平后的卡片」中更不利的
- 配色面板加读数(正文/次要/淡文字/主色),低于 4.5 变橙;默认 primary #4a7fc1(3.69) → #3d6fac(4.63)
- 新增 ?dump=palette 四种配色回归;README 补配色一节

主题/base(含用户本轮 base.css 改动):
- sakura muted #a08c94(2.8:1) → #78696e(4.61)、shiroko #8794a7(2.76:1) → #656e7a(4.63):主题覆盖令牌,基座的 AA 修复到不了主题页
- 深色顶栏链接补 secondary 覆盖:亮色块已是次要文字色,深色块原来 !important 定成正文色,同一顶栏权重跳一档
- _scaffold.css 令牌清单 23 → 29(补 fs 阶梯与 page-position),muted 说明 AA 下限
- StaticVersion 20261021a → 20261021b
This commit is contained in:
tamakyi
2026-10-21 17:42:43 +08:00
parent 54df166d4e
commit db4b3fa095
11 changed files with 317 additions and 21 deletions
+113 -3
View File
@@ -326,8 +326,10 @@ const MOB_SCALE_DEFAULT = 0.62;
/* 默认配色/窄屏抽出来,「新建」时能原样还原;S 里存的是副本,不会被重置污染 */
const D = {
palette: {
/* primary 从 #4a7fc1 压深到 #3d6fac:前者在 #eef3f9 上只有 3.69:1,
14px 的链接/按钮文字不达 AA。默认值应当是可用的,不能一上手就踩红线。 */
bg:'#eef3f9', surface:'rgba(255,255,255,0.86)', navbar:'rgba(255,255,255,0.52)',
text:'#222d3b', primary:'#4a7fc1', border:'#d6e0ec',
text:'#222d3b', primary:'#3d6fac', border:'#d6e0ec',
},
/* 窄屏:手机是「卡片占满宽度」,桌面那套 16:9 场景会被 cover 裁成一条无意义纹理。
这一份是全局的(不分页面)—— 窄屏的问题是空间不够,各页面诉求差别不大。
@@ -641,8 +643,50 @@ function withAlpha(c, a){
const A = parseColor(c);
return `rgba(${A[0]}, ${A[1]}, ${A[2]}, ${a})`;
}
/** 半透明色压到某个不透明底色上,得到「实际看到」的颜色。
卡片的 --tb-surface 常写成 rgba(255,255,255,0.86),直接拿它算对比度是错的。 */
function flatten(c, under){
const A = parseColor(c), B = parseColor(under);
if (A[3] >= 1) return `rgb(${A[0]}, ${A[1]}, ${A[2]})`;
const m = i => Math.round(A[i] * A[3] + B[i] * (1 - A[3]));
return `rgb(${m(0)}, ${m(1)}, ${m(2)})`;
}
/* WCAG 相对亮度 / 对比度。文字色的下限不是审美问题,是硬指标:
正文 4.5:1(AA)。base.css 已经按这个标准重算过 --tb-text-muted,
主题若按「往底色混 55%」拍脑袋派生,等于每次生成都把那个修复覆盖回去。 */
function relLum(c){
const A = parseColor(c);
const f = v => {
v /= 255;
return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4);
};
return 0.2126 * f(A[0]) + 0.7152 * f(A[1]) + 0.0722 * f(A[2]);
}
function contrastRatio(a, b){
const la = relLum(a), lb = relLum(b);
return (Math.max(la, lb) + 0.05) / (Math.min(la, lb) + 0.05);
}
/** 在 text→bg 的连线上取「最淡但仍达标」的点:二分找出刚好达标的混合比例。
文字本来就不达标(底色与文字太接近)时原样返回文字色 —— 宁可不够淡,
也不能产出一行看不见的字。bgs 传多个时按最不利的那个算(卡片叠在底色上)。 */
function mixToContrast(text, bgs, target){
const worst = t => Math.min(...bgs.map(b => contrastRatio(mixRGB(text, bgs[0], t), b)));
if (worst(0) <= target) return text;
let lo = 0, hi = 0.92;
for (let i = 0; i < 14; i++){
const mid = (lo + hi) / 2;
if (worst(mid) >= target) lo = mid; else hi = mid;
}
// 往下收半档:浮点四舍五入后掉回 4.49:1 这种「差一点」最难查
return mixRGB(text, bgs[0], Math.floor(lo * 100) / 100);
}
/** 文字要同时落在页面底色和卡片上:把卡片的不透明度压平后,取两者中更不利的那个。 */
function textBackdrops(p){
return [p.bg, flatten(p.surface, p.bg)];
}
function paletteVars(p){
const back = textBackdrops(p);
return [
` --tb-bg: ${p.bg};`,
` --tb-surface: ${p.surface};`,
@@ -651,8 +695,8 @@ function paletteVars(p){
` --tb-navbar: ${p.navbar};`,
` --tb-border: ${p.border};`,
` --tb-text: ${p.text};`,
` --tb-text-secondary: ${mixRGB(p.text, p.bg, 0.36)};`,
` --tb-text-muted: ${mixRGB(p.text, p.bg, 0.55)};`,
` --tb-text-secondary: ${mixToContrast(p.text, back, 5.5)};`,
` --tb-text-muted: ${mixToContrast(p.text, back, 4.5)};`,
` --tb-primary: ${p.primary};`,
` --tb-primary-weak: ${withAlpha(p.primary, 0.12)};`,
].join('\n');
@@ -1547,6 +1591,25 @@ function renderPalette(){
r.append(s, c, t);
wrap.appendChild(r);
});
// 对比度读数:主题能改文字色,也就有能力把页面改到看不清。
// 派生值现在有下限(不达标就自动收淡),但用户手填的 text / primary 没有 —— 只提醒、不拦。
const back = textBackdrops(S.palette);
const worstOf = c => Math.min(...back.map(b => contrastRatio(c, b)));
const sec = mixToContrast(S.palette.text, back, 5.5);
const mut = mixToContrast(S.palette.text, back, 4.5);
const crL = document.createElement('p');
crL.className = 'hint';
const items = [
['正文', worstOf(S.palette.text), 4.5],
['次要', worstOf(sec), 4.5],
['淡文字', worstOf(mut), 4.5],
['主色', worstOf(S.palette.primary), 4.5],
];
crL.innerHTML = items.map(([n, v, lim]) =>
`<span style="color:${v < lim ? '#e0a33e' : 'inherit'}">${n} ${v.toFixed(1)}:1</span>`
).join(' · ') + '<br>低于 4.5:1(WCAG AA 正文)会变橙。次要/淡文字由正文自动派生并保底。';
wrap.appendChild(crL);
const note = document.createElement('p');
note.className = 'hint';
note.textContent = '深色配色写在生成结果里(深色不加载图片);要改深色请直接改 CSS。';
@@ -2969,6 +3032,53 @@ function boot(){
}
pre.textContent = JSON.stringify(r, null, 1);
})();
} else if (dump === 'palette'){
// 派生色的对比度下限:base.css 把 --tb-text-muted 按 WCAG AA(4.5:1) 重算过,
// 生成器若还按固定比例往底色混,等于每次导出都把那个修复覆盖回去。
const r = { cases: {} };
try {
const cases = {
'默认亮色': { bg:'#eef3f9', surface:'rgba(255,255,255,0.86)', navbar:'rgba(255,255,255,0.52)', text:'#222d3b', primary:'#4a7fc1', border:'#d6e0ec' },
'默认深色': { bg:'#141a22', surface:'#1d2530', navbar:'#141a22', text:'#e3eaf4', primary:'#6fa3e0', border:'#334052' },
// 极端:文字色本身就不达标 → 必须原样返回,不能继续往底色淡
'低对比手填': { bg:'#f4f4f4', surface:'#ffffff', navbar:'#ffffff', text:'#c8ccd2', primary:'#dddddd', border:'#e5e5e5' },
// 纯白卡片压在彩色底上:卡片侧才是更不利的那个
'白卡彩底': { bg:'#f6d9e4', surface:'#ffffff', navbar:'#f6d9e4', text:'#3b2b33', primary:'#c2557d', border:'#efd3de' },
};
let allOk = true;
for (const name in cases){
const p = cases[name];
const back = textBackdrops(p);
const sec = mixToContrast(p.text, back, 5.5);
const mut = mixToContrast(p.text, back, 4.5);
const worst = c => Math.min(...back.map(b => contrastRatio(c, b)));
const row = {
secondary: +worst(sec).toFixed(2),
muted: +worst(mut).toFixed(2),
rawText: +worst(p.text).toFixed(2),
mutedDarkerThanSecondary: worst(mut) <= worst(sec), // 层级:淡文字必须比次要更淡
// 关键断言:只要有 4.5 的空间就必须达标;本来就不达标的不算错
ok: (worst(p.text) < 4.5) ? (mut === p.text) : (worst(mut) >= 4.5 && worst(sec) >= 4.5),
};
// 卡片半透明时必须按压平后的颜色算,否则白卡 86% 会被当成 #fff
row.surfaceFlat = flatten(p.surface, p.bg);
r.cases[name] = row;
if (!row.ok) allOk = false;
}
// 阶梯:淡文字绝不能比次要文字更醒目
for (const name in r.cases){
if (!r.cases[name].mutedDarkerThanSecondary) allOk = false;
}
// 把生成结果里的派生值也打出来,方便和 base.css 的取值对照
const css = buildCSS(false);
r.mutedLine = (css.match(/--tb-text-muted:[^;]+;/) || [''])[0];
r.secondaryLine = (css.match(/--tb-text-secondary:[^;]+;/) || [''])[0];
r.ok = allOk;
} catch (e){
r.error = e.message + ' | ' + (e.stack || '').split('\n')[1];
r.ok = false;
}
pre.textContent = JSON.stringify(r, null, 1);
} else {
const files = [{ name: `${S.slug}.css`, data: enc(buildCSS(false)) }];
const bytes = zipStore(files);