生成器新增「顶栏」面板:实色 / 半透明磨砂 / 全透明(仅预览)

以前顶栏只能在配色里填一个颜色字符串:写 rgba 拿不到模糊,档位和颜色又分在两处,
改了一处就忘了另一处。现在三档一个面板说话。

- S.navbar = {mode, alpha, blur},基色仍取「配色 → 顶栏」,navbarValue() 统一计算:
  solid 先 flatten 压平(旧工程里可能自带 alpha)、glass 加 alpha、clear 出 transparent。
  半透明档额外写 .uk-navbar-container{backdrop-filter:blur(N)} —— 令牌只能给颜色,
  模糊必须落成规则;选择器不带 data-theme,深浅两套共用一条。
- 全透明**只给预览**:validate() 拦导出,与 internal/theme 的 TestNavbarNotFullyTransparent
  同一条规矩,面板上直接写明原因。
- 面板带「导航文字 x.x:1」读数,按压平后的顶栏算(半透明时是顶栏压在底色上的结果),
  低于 4.5 变橙。
- 旧工程迁移:配色里塞过 rgba(...,0.52) 的,按 alpha 反推档位(1→实色 / 0→全透明 / 其余→半透明),
  打开旧工程不会突然变实色。
- 自检 ?dump=navbar(10 项:三档产出、blur 有无、全透明被拦、alpha 0.2/0.95 边界)。
- 滑杆步长 0.02 → 0.01:0.55 会被吸附成 0.54/0.56,手感像坏了。
This commit is contained in:
tamakyi
2026-10-22 16:15:49 +08:00
parent 8941744621
commit e4810040a7
3 changed files with 203 additions and 2 deletions
+18
View File
@@ -169,6 +169,23 @@ python dev/theme-builder/serve.py --host 127.0.0.1 # 只给本机用(公共
顺带:这套字号阶梯(`--tb-fs-xs…hero`)来自 `base.css`,生成器**不碰字号** ——
主题的职责是配色与背景,改字号会连带挤坏所有页面排版。
**顶栏** —— 三档,只管「配色 → 顶栏」那个基色怎么用:
| 档位 | 输出 | 适用 |
| --- | --- | --- |
| 实色 | `--tb-navbar: rgb(...)` | 页面没有装饰层穿过顶栏;最简单 |
| 半透明 | `--tb-navbar: rgba(...,0.72)` + `backdrop-filter: blur(12px)` | 装饰层从角落长出来、会经过顶栏时(推荐) |
| 全透明 | `--tb-navbar: transparent` | **只能预览**,导出会被拦 |
- 半透明档的两个滑杆:不透明度 0.2~0.95、背景模糊 0~24px。令牌只能是颜色值,
模糊另外写成 `.uk-navbar-container:not(.uk-navbar-transparent)` 规则(深浅两套共用一条)。
- 面板底部有 `导航文字 6.0:1` 的读数,按**压平后的顶栏**算(半透明时是「顶栏压在底色上」的结果,
不是顶栏色本身)。低于 4.5 变橙 —— 装饰层从顶栏后面穿过时还会更低,取最不利情况定透明度。
- ⚠️ **全透明不给导出**:顶栏一透明导航就失去依托、文字直接压在花纹上,比"被裁一段"更糟。
这一档留着的唯一用途是**预览对比**;`validate()` 会拦,仓库的 `TestNavbarNotFullyTransparent`
也会拦(两处同一条规矩,生成器里绕过也没用)。
- 旧工程(配色里直接塞 `rgba(...,0.52)`)打开时按 alpha 反推档位:1 → 实色、0 → 全透明、其余 → 半透明。
**图片池** —— 上传任意张,浏览器本地转 WebP。导出时只打包实际用到的图。
每张图左上角有 `fx` 按钮,进处理面板(处理过的会高亮)。
@@ -362,6 +379,7 @@ INSTALL-<slug>.txt
?dump=fit 尺寸自适应回归(五档视口占用、溢出检测、fitw、旧工程兼容、@media 覆盖各作用域)
?dump=quality 画质回归(q 单调性、q=1 被夹到 0.95、长边上限真的压像素)
?dump=palette 派生文字色的对比度回归(四种配色下 ≥AA、层级不倒挂、手填低对比不继续变淡)
?dump=navbar 顶栏三档回归(实色无 blur、半透明带 alpha+blur、全透明被 validate 拦、alpha 边界)
?dump=fxkey 端到端去白底:载入白底图,统计透明像素比例
?loadurl=/static/images/x.webp&fx=x
直接把项目里的图当素材载入(不必先下载再上传),fx= 打开处理面板
+171 -2
View File
@@ -191,6 +191,9 @@
.mbox footer{display:flex;align-items:center;gap:8px;padding:10px 14px;border-top:1px solid var(--ui-line)}
.mbox footer .info{flex:1;font-size:11px;color:var(--ui-text2)}
.seg{display:flex;gap:6px;margin:8px 0 9px}
.seg button{flex:1;font-size:11px;padding:4px 6px}
.pal{display:flex;flex-direction:column;gap:5px}
.pal .p{display:flex;align-items:center;gap:8px;font-size:11px;color:var(--ui-text2)}
.pal .p input[type=text]{width:100%;font-size:11px;padding:2px 6px}
@@ -278,6 +281,11 @@
<h2>配色</h2>
<div class="pal" id="palette"></div>
</section>
<section>
<h2>顶栏</h2>
<div id="navbar-editor"></div>
</section>
</aside>
<section class="stage">
@@ -367,13 +375,17 @@ 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)',
bg:'#eef3f9', surface:'rgba(255,255,255,0.86)', navbar:'#ffffff',
text:'#222d3b', primary:'#3d6fac', border:'#d6e0ec',
},
/* 窄屏:手机是「卡片占满宽度」,桌面那套 16:9 场景会被 cover 裁成一条无意义纹理。
这一份是全局的(不分页面)—— 窄屏的问题是空间不够,各页面诉求差别不大。
scale 是「每层策略选缩放」时的系数;hideDeco 是「一次全藏」的快捷开关。 */
narrow: { enabled: true, bg: null, hideDeco: false, maxWidth: 900, scale: MOB_SCALE_DEFAULT },
/* 顶栏三档:solid 实色 / glass 半透明磨砂 / clear 全透明。
clear 只给预览 —— 顶栏一透明导航就没了依托(仓库的 TestNavbarNotFullyTransparent 也会拦导出)。
alpha 与 blur 只对 glass 生效;基色取「配色 → 顶栏」。 */
navbar: { mode: 'glass', alpha: 0.72, blur: 12 },
/* 导出画质:webp 体积 ≈ 像素数 × 质量。压体积最有效的不是 q,是先砍像素
—— CSS 里显示 400px 高的图,2 倍屏只需要 800px,上传 3000px 的立绘纯属白扛。 */
export: { quality: 0.88, maxSide: 1600 },
@@ -409,6 +421,7 @@ const S = {
curLayer: null,
scopes: {},
palette: Object.assign({}, D.palette),
navbar: Object.assign({}, D.navbar),
narrow: Object.assign({}, D.narrow),
export: Object.assign({}, D.export),
assetBase: '',
@@ -759,6 +772,15 @@ function onPrimary(primary){
return contrastRatio('#ffffff', primary) >= contrastRatio('#0b1016', primary) ? '#ffffff' : '#0b1016';
}
/** 顶栏实际颜色:三档统一从一个基色算出来,避免「配色里的顶栏」与「档位」互相打架。
实色/半透明都先压平基色 —— 旧工程里基色本身可能是带 alpha 的 rgba。 */
function navbarValue(p){
const base = flatten(p.navbar, p.bg);
if (S.navbar.mode === 'clear') return 'transparent';
if (S.navbar.mode === 'solid') return base;
return withAlpha(base, S.navbar.alpha);
}
function paletteVars(p){
const back = textBackdrops(p);
return [
@@ -766,7 +788,7 @@ function paletteVars(p){
` --tb-surface: ${p.surface};`,
` --tb-surface-2: ${mixRGB(p.surface, p.text, 0.06)};`,
` --tb-surface-hover: ${mixRGB(p.surface, p.primary, 0.10)};`,
` --tb-navbar: ${p.navbar};`,
` --tb-navbar: ${navbarValue(p)};`,
` --tb-border: ${p.border};`,
` --tb-text: ${p.text};`,
` --tb-text-secondary: ${mixToContrast(p.text, back, 5.5)};`,
@@ -794,6 +816,16 @@ function buildCSS(preview){
L.push(decoVars(S.scopes.global, preview));
L.push('}');
// 令牌只能给颜色,模糊得另写规则;选择器不带 data-theme,深浅两套共用一条。
if (S.navbar.mode === 'glass' && S.navbar.blur > 0){
L.push('');
L.push('/* 顶栏磨砂:半透明底由 --tb-navbar 给,模糊只能写在规则里。 */');
L.push(`html[data-theme-style="${slug}"] .uk-navbar-container:not(.uk-navbar-transparent) {`);
L.push(` -webkit-backdrop-filter: blur(${S.navbar.blur}px);`);
L.push(` backdrop-filter: blur(${S.navbar.blur}px);`);
L.push('}');
}
L.push('');
L.push(`html[data-theme="dark"][data-theme-style="${slug}"] {`);
L.push(paletteVars(S.dark));
@@ -2237,6 +2269,85 @@ async function applyQuality(what){
+ (before > 0 && after < before ? `(省 ${Math.round((1 - after / before) * 100)}%)` : ''), 'ok');
}
/** 顶栏面板:档位 + 两个只对半透明生效的滑杆 + 对比度读数。
没有这套控件时,「顶栏」只能靠配色里填一个 rgba 字符串 —— 模糊根本无从设置,
而档位与颜色分在两个地方,用户改了一处就忘了另一处。 */
function renderNavbar(){
const el = $('navbar-editor');
el.innerHTML = '';
const n = S.navbar;
const seg = document.createElement('div');
seg.className = 'seg';
[['solid', '实色'], ['glass', '半透明'], ['clear', '全透明']].forEach(([k, t]) => {
const b = document.createElement('button');
b.textContent = t;
b.className = n.mode === k ? 'on' : '';
b.title = k === 'glass' ? '半透明 + 背景模糊(推荐)'
: k === 'solid' ? '不透明:会盖住从角落穿过顶栏的装饰层'
: '顶栏完全消失,只能预览 —— 导出会被拦';
b.onclick = () => { n.mode = k; renderAll(); };
seg.appendChild(b);
});
el.appendChild(seg);
const slider = (label, value, min, max, step, set, fmt) => {
const row = document.createElement('div');
row.className = 'fxrow';
const lab = document.createElement('div');
lab.className = 'lab';
const name = document.createElement('span');
name.textContent = label;
const val = document.createElement('b');
val.textContent = fmt(value);
lab.append(name, val);
const r = document.createElement('input');
r.type = 'range'; r.min = min; r.max = max; r.step = step; r.value = value;
r.oninput = () => {
set(Number(r.value));
val.textContent = fmt(Number(r.value));
applyTheme();
};
// 松手才重绘整面板:拖动过程中重建 DOM 会把滑杆从鼠标下抽走
r.onchange = () => renderNavbar();
row.append(lab, r);
return row;
};
if (n.mode === 'glass'){
// 步长 0.01:0.02 会让「想调 0.55」被吸附成 0.54/0.56,手感像坏了
el.appendChild(slider('不透明度', n.alpha, 0.2, 0.95, 0.01,
v => { n.alpha = v; }, v => v.toFixed(2)));
el.appendChild(slider('背景模糊', n.blur, 0, 24, 1,
v => { n.blur = v; }, v => v + 'px'));
}
// 对比度:顶栏是半透明时,文字的对手是「顶栏压在底色上」的结果,不是顶栏色本身
const back = textBackdrops(S.palette);
const navText = mixToContrast(S.palette.text, back, 5.5);
const eff = n.mode === 'clear' ? S.palette.bg : flatten(navbarValue(S.palette), S.palette.bg);
const cr = contrastRatio(navText, eff);
const line = document.createElement('p');
line.className = 'hint';
line.innerHTML = `<span style="color:${cr < 4.5 ? '#e0a33e' : 'inherit'}">导航文字 ${cr.toFixed(2)}:1</span>`
+ `(${esc(eff)})· 装饰层从顶栏后面穿过时会更低,取最不利情况定透明度。`;
el.appendChild(line);
if (n.mode === 'clear'){
const warn = document.createElement('p');
warn.className = 'hint';
warn.style.color = '#e0a33e';
warn.textContent = '全透明只用于预览:顶栏会消失,导航失去依托,花纹直接从字底下穿过。'
+ '导出会被拦下(仓库的 TestNavbarNotFullyTransparent 也会拦)。';
el.appendChild(warn);
}
const note = document.createElement('p');
note.className = 'hint';
note.textContent = '基色在「配色 → 顶栏」里改;这里决定它怎么用。深色用深色配色里的顶栏色,规则相同。';
el.appendChild(note);
}
function renderPalette(){
const wrap = $('palette');
wrap.innerHTML = '';
@@ -2323,6 +2434,7 @@ function renderAll(){
renderHostEditor();
renderNarrowEditor();
renderPalette();
renderNavbar();
applyTheme();
scheduleSave();
}
@@ -2342,6 +2454,7 @@ function toJSON(){
viewport: S.viewport,
mode: S.mode,
palette: S.palette,
navbar: S.navbar,
dark: S.dark,
narrow: S.narrow,
export: S.export,
@@ -2375,6 +2488,18 @@ function fromJSON(o){
S.mode = o.mode === 'dark' ? 'dark' : 'light';
S.palette = Object.assign({}, S.palette, o.palette || {});
S.dark = Object.assign({}, S.dark, o.dark || {});
// 旧工程没有 navbar 段:从配色里的顶栏色反推档位,别让打开旧工程时顶栏突然变实色
S.navbar = Object.assign({}, D.navbar, o.navbar || {});
if (!o.navbar){
const legacy = S.palette.navbar;
if (legacy === 'transparent') S.navbar.mode = 'clear';
else {
const a = parseColor(legacy)[3];
S.navbar.mode = a >= 0.999 ? 'solid' : 'glass';
if (a > 0 && a < 1) S.navbar.alpha = Math.round(a * 100) / 100;
}
}
if (!['solid', 'glass', 'clear'].includes(S.navbar.mode)) S.navbar.mode = 'glass';
S.narrow = Object.assign({ enabled: true, bg: null, hideDeco: false, maxWidth: 900,
scale: MOB_SCALE_DEFAULT }, o.narrow || {});
S.export = Object.assign({}, D.export, o.export || {});
@@ -2499,6 +2624,12 @@ function validate(){
if (S.narrow.bg && !assetById(S.narrow.bg)) errs.push('窄屏竖版图引用了已删除的图片');
// 与 internal/theme/navbar_test.go 同一条规矩:顶栏可以调透明度,但不能全透明。
if (S.navbar.mode === 'clear'){
errs.push('顶栏不能全透明:导航会失去依托、文字直接压在花纹上。改成「半透明」或「实色」再导出'
+ '(仓库的 TestNavbarNotFullyTransparent 也会拦)。');
}
// 结构自检:三组装饰值必须都是 10 个(与 Go 的 TestDecoSlotCount 同一条规则)。
// 这条是给工具自己兜底的 —— 真跑出问题说明生成逻辑有 bug,而不是用户配错。
const css = buildCSS(false);
@@ -3814,6 +3945,44 @@ function boot(){
r.ok = false;
}
pre.textContent = JSON.stringify(r, null, 1);
} else if (dump === 'navbar'){
// 三档的产出各是什么、磨砂规则有没有写、全透明有没有被拦 —— 无头里抓 #dump 判读。
const keep = Object.assign({}, S.navbar);
const rows = [];
let ok = true;
const chk = (name, cond, got) => {
if (!cond) ok = false;
rows.push((cond ? 'OK ' : 'FAIL ') + name + (cond ? '' : ' ← ' + got));
};
S.navbar.mode = 'solid'; S.navbar.blur = 12;
let v = navbarValue(S.palette);
let css = buildCSS(false);
chk('实色 → rgb(),无 alpha', /^rgb\(/.test(v), v);
chk('实色不写 blur 规则', !css.includes('backdrop-filter'), '');
S.navbar.mode = 'glass'; S.navbar.alpha = 0.72; S.navbar.blur = 12;
v = navbarValue(S.palette);
css = buildCSS(false);
chk('半透明 → rgba(...,0.72)', /^rgba\(.*0\.72\)$/.test(v), v);
chk('半透明写 blur(12px)', css.includes('blur(12px)'), '');
chk('深色也走同一档', /--tb-navbar: rgba\(/.test(css.split('data-theme="dark"')[1] || ''), '');
S.navbar.mode = 'clear';
chk('全透明 → transparent(仅预览)', navbarValue(S.palette) === 'transparent', navbarValue(S.palette));
const errs = validate();
chk('全透明被 validate 拦下', errs.some(e => e.includes('顶栏不能全透明')), JSON.stringify(errs));
chk('全透明不写 blur', !buildCSS(false).includes('backdrop-filter'), '');
// 透明度边界:0.2 仍要带 alpha,0.95 不能被四舍五入成 1
S.navbar.mode = 'glass'; S.navbar.alpha = 0.2;
chk('alpha=0.2 不塌成实色', /rgba\(.*0\.2\)/.test(navbarValue(S.palette)), navbarValue(S.palette));
S.navbar.alpha = 0.95;
chk('alpha=0.95 仍是 rgba', /rgba\(.*0\.95\)/.test(navbarValue(S.palette)), navbarValue(S.palette));
Object.assign(S.navbar, keep);
rows.push(ok ? 'ALL OK' : 'HAS FAIL');
pre.textContent = rows.join('\n');
} else {
const files = [{ name: `${S.slug}.css`, data: enc(buildCSS(false)) }];
const bytes = zipStore(files);