生成器新增「顶栏」面板:实色 / 半透明磨砂 / 全透明(仅预览)
以前顶栏只能在配色里填一个颜色字符串:写 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:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user