主题体系跟上设计令牌化:派生文字色按对比度下限、主题 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
+18
View File
@@ -133,6 +133,23 @@ http://127.0.0.1:8777/dev/theme-builder/
生成 `--tb-page-position`(`base.css` 里以 `var(--tb-page-position, center center)` 兜底,
旧主题不写这个令牌也不会坏)。
**配色** —— 只填 6 个基础色(底色 / 卡片 / 顶栏 / 正文 / 主色 / 边框),
其余 4 个派生:`surface-2` 往正文靠、`surface-hover` 往主色靠、`primary-weak` 加 12% 透明、
`text-secondary` / `text-muted` 往底色淡出。
⚠️ **派生文字色有对比度下限,不是固定比例**。正文往背景淡出时,二分找到「最淡但仍 ≥ 目标」的位置:
次要文字 5.5:1、弱化文字 4.5:1(WCAG AA 正文)。旧版按「混 55%」硬算,
在浅底上直接掉到 2.9:1 —— `base.css` 特意把 `--tb-text-muted` 从 `#8a919c` 提到 `#6b7280`(4.83:1),
生成器按固定比例算等于每导出一次就把那个修复覆盖回去。
- 卡片是半透明(`rgba(255,255,255,0.86)`)时先**压平到页面底色**再算,否则白卡会被当成纯白、对比度偏低;
- 同时检查页面底色与卡片两个背景,取更不利的那个;
- 手填的正文本身就达不到目标时**原样返回**(不再往淡里调)——宁可不够淡,不能产出看不见的字;
- 面板底部有读数 `正文 12.5:1 · 次要 5.5:1 · 淡文字 4.6:1 · 主色 3.4:1`,低于 4.5 变橙(手填色只提醒不拦)。
顺带:这套字号阶梯(`--tb-fs-xs…hero`)来自 `base.css`,生成器**不碰字号** ——
主题的职责是配色与背景,改字号会连带挤坏所有页面排版。
**图片池** —— 上传任意张,浏览器本地转 WebP。导出时只打包实际用到的图。
每张图左上角有 `fx` 按钮,进处理面板(处理过的会高亮)。
@@ -252,6 +269,7 @@ INSTALL-<slug>.txt
?dump=layers 图层/作用域回归(加层默认不绑图、行内换图、自动切独立、新建清空)
?dump=fit 尺寸自适应回归(五档视口占用、溢出检测、fitw、旧工程兼容、@media 覆盖各作用域)
?dump=quality 画质回归(q 单调性、q=1 被夹到 0.95、长边上限真的压像素)
?dump=palette 派生文字色的对比度回归(四种配色下 ≥AA、层级不倒挂、手填低对比不继续变淡)
?dump=fxkey 端到端去白底:载入白底图,统计透明像素比例
?loadurl=/static/images/x.webp&fx=x
直接把项目里的图当素材载入(不必先下载再上传),fx= 打开处理面板
+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);