主题体系跟上设计令牌化:派生文字色按对比度下限、主题 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:
@@ -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