主题体系跟上设计令牌化:派生文字色按对比度下限、主题 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:
@@ -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= 打开处理面板
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user