diff --git a/.workbuddy/memory/2026-10-22.md b/.workbuddy/memory/2026-10-22.md index f93c002..770209a 100644 --- a/.workbuddy/memory/2026-10-22.md +++ b/.workbuddy/memory/2026-10-22.md @@ -203,3 +203,17 @@ - ⚠️ 跑 go 命令时**别加 `GOSUMDB=off`**:本机 go 1.25.5,go.mod 要 1.27.3, `GOTOOLCHAIN=auto` 要校验 toolchain 模块,关掉 sumdb 会直接报 "verifying module"。 只设 `GOPROXY=https://goproxy.cn,direct GOFLAGS=-mod=mod` 即可(toolchain 已缓存)。 + +**8) 生成器新增「顶栏」面板:实色 / 半透明(磨砂)/ 全透明** +- 状态 `S.navbar = {mode, alpha, blur}`(默认 glass 0.72 / 12px),基色仍取「配色 → 顶栏」, + `navbarValue(p)` 统一算:solid → `rgb()`(先 flatten 压平旧工程里的 rgba)、glass → `rgba()`、 + clear → `transparent`。模糊单独写 `.uk-navbar-container:not(.uk-navbar-transparent)` 规则, + 一条同时管深浅(选择器不带 data-theme)。 +- **全透明只给预览**:`validate()` 直接拦(导出报错),与 `TestNavbarNotFullyTransparent` 同一条规矩。 + 面板上写明原因 —— 不然下一轮又会有人问「为什么不能导出」。 +- 对比度读数按「压平后的顶栏」算(半透明时是顶栏压在底色上的结果),低于 4.5 变橙。 +- 旧工程迁移:配色里塞过 `rgba(...,0.52)` 的,按 alpha 反推档位(1→solid,0→clear,其余→glass)。 +- 自检 `?dump=navbar` 10 项;Playwright 端到端 17 项(含滑杆跟手、刷新后保持、迁移三例)。 +- ⚠️ `?fresh=1` 每次加载都会清草稿 —— 测「刷新后配置还在」必须导航到不带 fresh 的地址。 +- ⚠️ Playwright 的 `fill()` 对 `input[type=range]` 报 Malformed value,要 evaluate 设 value + dispatch input。 +- ⚠️ 滑杆步长别用 0.02:0.55 会被吸附成 0.54/0.56,用户以为坏了。改 0.01。 diff --git a/dev/theme-builder/README.md b/dev/theme-builder/README.md index 6d8f3ca..2592656 100644 --- a/dev/theme-builder/README.md +++ b/dev/theme-builder/README.md @@ -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-.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= 打开处理面板 diff --git a/dev/theme-builder/index.html b/dev/theme-builder/index.html index 013b8c8..831b49f 100644 --- a/dev/theme-builder/index.html +++ b/dev/theme-builder/index.html @@ -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 @@

配色

+ +
+

顶栏

+ +
@@ -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 = `导航文字 ${cr.toFixed(2)}:1` + + `(${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);