字号阶梯收尾 + 主色补到 WCAG AA(新增 --tb-on-primary)

一号事项(剩余令牌化):
- templates/user/profile.html 20 处、base.css 9 处 font-size 全部落到 --tb-fs-*
- base.css 导航四档(0.95/0.88/0.82/0.78)合并成「桌面 md → ≤1000 xs」;logo 三档统一 lg
- 有理由保留的:.tb-pin-flag / .md-body code 的 0.9em(相对父级)、验证码 1.4/1.2rem(等宽字形)、
  chat 关闭按钮 40px、password 页 3em、mail/*(邮件客户端不保证 var());行内 12px/0.75rem 等值顺手换掉

二号事项(主色对比度):
- base/default #1e87f0(3.42) → #0e6fd0(4.70);shiroko #4a7fc1(3.69) → #3b6dac(4.74);
  sakura #e5678f(2.80) → #ba3e66(4.70,饱和度压到 .5 保持柔和)。同色相只压亮度,目标 4.7 留余量
- 深色块 muted 之前只对 --tb-bg 算(4.7 以为过了),对压平后的卡片其实 4.31/4.17 → 提到 #a18790 / #7f8ea1
- 新增 --tb-on-primary(亮 #ffffff / 暗 #0b1016):深色模式主色偏亮,白字只有 2.2~3.1:1,
  用同一个令牌兼顾「链接色要深」和「按钮底要深」是矛盾的。.uk-button-primary 与
  .uk-label:not(语义色) 改吃它 → 深色按钮变成浅底深字(6.10 / 8.65 / 7.27)
- 顺手封掉几处主色泄漏:header 验证码 focus、profile .feat-day.is-on、explore 随机投票的 JS outline、
  vote-results 行内色,全部改成令牌(否则换主题时不跟着变)

生成器同步:paletteVars 导出 --tb-on-primary(白字/深字择优);?dump=palette 增加
onPrimaryCr / onPrimaryPickedBest / warnings —— 主色卡中间调时只警告不判失败;
配色面板给橙色提示。_scaffold 令牌 29 → 30。StaticVersion → 20261021c。

验证:TestSwatch 断言同步为 #ba3e66;8 种组合(base 亮暗 + 三主题亮暗)对比度全部 ≥4.5;
go test internal/{theme,templates,conf,i18n} 全绿;生成器 validate/fit/quality/palette/layers/fx/fxpipe 全过。
This commit is contained in:
tamakyi
2026-10-21 18:39:08 +08:00
parent 252b22a342
commit 27856dd4a2
20 changed files with 168 additions and 64 deletions
+44 -2
View File
@@ -685,6 +685,13 @@ function textBackdrops(p){
return [p.bg, flatten(p.surface, p.bg)];
}
/** 主色作背景时的文字色:白字和深字里挑对比度更高的那个。
浅色模式主色深 → 白字;深色模式主色普遍偏亮(#5c8dff 之流),白字只有 2~3:1,
必须换成深字。不写这个令牌就只能吃 base.css 的默认值,深浅两套必然错一套。 */
function onPrimary(primary){
return contrastRatio('#ffffff', primary) >= contrastRatio('#0b1016', primary) ? '#ffffff' : '#0b1016';
}
function paletteVars(p){
const back = textBackdrops(p);
return [
@@ -699,6 +706,7 @@ function paletteVars(p){
` --tb-text-muted: ${mixToContrast(p.text, back, 4.5)};`,
` --tb-primary: ${p.primary};`,
` --tb-primary-weak: ${withAlpha(p.primary, 0.12)};`,
` --tb-on-primary: ${onPrimary(p.primary)};`,
].join('\n');
}
@@ -1610,6 +1618,19 @@ function renderPalette(){
).join(' · ') + '<br>低于 4.5:1(WCAG AA 正文)会变橙。次要/淡文字由正文自动派生并保底。';
wrap.appendChild(crL);
// 主色卡在中间调时,白字和深字都不到 4.5 —— 这不是文字色挑错,是主色本身要动。
const bestOn = Math.max(contrastRatio('#ffffff', S.palette.primary),
contrastRatio('#0b1016', S.palette.primary));
if (bestOn < 4.5){
const warn = document.createElement('p');
warn.className = 'hint';
warn.style.color = '#e0a33e';
warn.textContent = `⚠ 主色偏中间调:白字 ${contrastRatio('#ffffff', S.palette.primary).toFixed(2)}:1、`
+ `深字 ${contrastRatio('#0b1016', S.palette.primary).toFixed(2)}:1,按钮上的字都不够清楚。`
+ '把主色压深一点(配白字)或提亮一点(配深字)就能过。';
wrap.appendChild(warn);
}
const note = document.createElement('p');
note.className = 'hint';
note.textContent = '深色配色写在生成结果里(深色不加载图片);要改深色请直接改 CSS。';
@@ -3038,7 +3059,7 @@ function boot(){
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:'#eef3f9', surface:'rgba(255,255,255,0.86)', navbar:'rgba(255,255,255,0.52)', text:'#222d3b', primary:'#3d6fac', 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' },
@@ -3046,6 +3067,7 @@ function boot(){
'白卡彩底': { bg:'#f6d9e4', surface:'#ffffff', navbar:'#f6d9e4', text:'#3b2b33', primary:'#c2557d', border:'#efd3de' },
};
let allOk = true;
const warns = [];
for (const name in cases){
const p = cases[name];
const back = textBackdrops(p);
@@ -3057,9 +3079,21 @@ function boot(){
muted: +worst(mut).toFixed(2),
rawText: +worst(p.text).toFixed(2),
mutedDarkerThanSecondary: worst(mut) <= worst(sec), // 层级:淡文字必须比次要更淡
// 主色作底的文字:白字 vs 深字里必须挑更优的那个。
// 注意这里不断言「一定 ≥4.5」:主色卡在中间调时,两种文字色都不够,
// 这不是挑错了,而是主色本身要调 —— 列进 warnings 提醒,不判失败。
onPrimary: onPrimary(p.primary),
onPrimaryCr: +contrastRatio(onPrimary(p.primary), p.primary).toFixed(2),
onPrimaryPickedBest: Math.abs(
contrastRatio(onPrimary(p.primary), p.primary) -
Math.max(contrastRatio('#ffffff', p.primary), contrastRatio('#0b1016', p.primary))
) < 0.01,
// 关键断言:只要有 4.5 的空间就必须达标;本来就不达标的不算错
ok: (worst(p.text) < 4.5) ? (mut === p.text) : (worst(mut) >= 4.5 && worst(sec) >= 4.5),
ok: ((worst(p.text) < 4.5) ? (mut === p.text) : (worst(mut) >= 4.5 && worst(sec) >= 4.5))
&& Math.abs(contrastRatio(onPrimary(p.primary), p.primary) -
Math.max(contrastRatio('#ffffff', p.primary), contrastRatio('#0b1016', p.primary))) < 0.01,
};
if (row.onPrimaryCr < 4.5) warns.push(name + '(主色 ' + p.primary + ' 中间调,白字/深字都不到 4.5)');
// 卡片半透明时必须按压平后的颜色算,否则白卡 86% 会被当成 #fff
row.surfaceFlat = flatten(p.surface, p.bg);
r.cases[name] = row;
@@ -3069,6 +3103,14 @@ function boot(){
for (const name in r.cases){
if (!r.cases[name].mutedDarkerThanSecondary) allOk = false;
}
// demo 的两套配色也要过:导出后的 CSS 直接吃这两个值
r.demoLight = { primary: S.palette.primary, on: onPrimary(S.palette.primary),
cr: +contrastRatio(onPrimary(S.palette.primary), S.palette.primary).toFixed(2) };
r.demoDark = { primary: S.dark.primary, on: onPrimary(S.dark.primary),
cr: +contrastRatio(onPrimary(S.dark.primary), S.dark.primary).toFixed(2) };
if (r.demoLight.cr < 4.5) warns.push('demo 亮色(可改 palette.primary)');
if (r.demoDark.cr < 4.5) warns.push('demo 深色(可改 dark.primary)');
r.warnings = warns;
// 把生成结果里的派生值也打出来,方便和 base.css 的取值对照
const css = buildCSS(false);
r.mutedLine = (css.match(/--tb-text-muted:[^;]+;/) || [''])[0];