From c9f22cb81b82d6a9bc3d96212af6e175a529f491 Mon Sep 17 00:00:00 2001 From: tamakyi Date: Thu, 22 Oct 2026 11:18:29 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E7=AA=84=E5=B1=8F=E8=A3=85=E9=A5=B0?= =?UTF-8?q?=E5=B1=82=E5=8E=8B=E4=B8=8D=E4=BD=8F=E4=BD=9C=E7=94=A8=E5=9F=9F?= =?UTF-8?q?=E5=9D=97=EF=BC=88=E6=89=8B=E6=9C=BA=E4=B8=8A=E4=B8=80=E7=9B=B4?= =?UTF-8?q?=E6=98=BE=E7=A4=BA=E6=A1=8C=E9=9D=A2=E7=89=88=E6=A8=B1=E8=8A=B1?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit @media 不提升特异性:作用域块 html[data-theme-style][data-page-scope="box"] 是 (0,2,1), 媒体查询里写的 html[data-theme-style] 只有 (0,1,1),于是「窄屏装饰层全置 none」这行 从来没生效 —— 手机上照样铺开桌面的花枝与花朵。深色作用域块是 (0,3,1),更压不住。 - 媒体查询里补三条同特异性的通配规则(作用域 0,2,1 / 深色基础 0,2,1 / 深色+作用域 0,3,1), 媒体块在文件更后,同特异性即胜出。 - 新增 TestNarrowMediaOverridesScopedDeco:外层每处非 none 的 --tb-deco,都要求窄屏里有 同属性名、属性数不少于它的声明。变异验证过(删掉深层那条立即 FAIL)。 它同时报出 shiroko 的 900px 块只覆盖浅色 —— 但深色块本就是 none,不算缺陷,故跳过。 - 窄屏改由加强版底纹承担樱花(透明度 ×1.9、格子 180px):桌面那套 0.18~0.34 的透明度 在 390px 上实测接近纯粉。顶栏与卡片之间那条 35px 带子试过放小花,question/admin 页卡片 从 y=110 起会切掉一半,最终不放装饰层。 --- .workbuddy/memory/2026-10-22.md | 14 +++ dev/theme-tools/gen_new_sakura.py | 45 ++++++-- docs/theme-new-sakura.md | 28 +++-- internal/theme/narrow_test.go | 170 ++++++++++++++++++++++++++++++ static/css/themes/new-sakura.css | 21 +++- 5 files changed, 259 insertions(+), 19 deletions(-) create mode 100644 internal/theme/narrow_test.go diff --git a/.workbuddy/memory/2026-10-22.md b/.workbuddy/memory/2026-10-22.md index d737ae7..2668ac0 100644 --- a/.workbuddy/memory/2026-10-22.md +++ b/.workbuddy/memory/2026-10-22.md @@ -35,3 +35,17 @@ 位图版 2.7KB + 5 张图 48.9KB ≈ 51.6KB。所以"换位图省 CSS 体积"是错的 —— 位图唯一的理由是素材要给非 CSS 场景复用。文档与 INSTALL.txt 都已改。 - `image_bucket_cdn_host` 沿用 app.ini 同名键(只写主机名),`export_assets.py` 据此拼公开地址。 + +**6) 窄屏覆盖被作用域块压死(真 bug,已修)** +- 现象:`@media (max-width:640px)` 里把 `--tb-deco` 全写 none,手机上却照样是桌面装饰层。 +- 根因:媒体查询**不提升特异性**。作用域块 `html[data-theme-style][data-page-scope="box"]` + 是 (0,2,1),媒体里写的 `html[data-theme-style]` 只有 (0,1,1) → 压不住;深色作用域块 (0,3,1) 更难。 +- 修法:媒体查询里按「作用域通配 `[data-page-scope]`(0,2,1)」+「深色基础(0,2,1)」+ + 「深色+作用域(0,3,1)」各写一条,靠文件更晚取胜。shiroko 的 900px 块只对浅色有效, + 但它的深色块本来就是 none,所以不算坏。 +- 守门:新增 `internal/theme/narrow_test.go::TestNarrowMediaOverridesScopedDeco` + ——外层每处非 none 的 `--tb-deco`,媒体查询里必须有「同属性名、属性数不少于它」的选择器; + 变异验证过(删掉深色基础那条立刻 FAIL)。 +- 手机端最终决定**不放装饰层**:试过顶栏与卡片之间那条 35px 带子,box 页 OK、question/admin + 页卡片从 y=110 起就切半个 → 变成黏在卡片边的粉色碎块。改用加强版底纹(透明度 ×1.9、格子 180px)。 +- 埋点:`gofmt -l internal/theme` 会报 `slots_test.go`(它是 CRLF),新增文件是 LF,别顺手格式化它。 diff --git a/dev/theme-tools/gen_new_sakura.py b/dev/theme-tools/gen_new_sakura.py index 8396c8f..0eb6b74 100644 --- a/dev/theme-tools/gen_new_sakura.py +++ b/dev/theme-tools/gen_new_sakura.py @@ -92,7 +92,10 @@ def branch(wood, light, deep, heart, flip=False): # 密度是刻意的:最早一版放了 2 朵花 + 2 片瓣并且 260px 平铺,页面上读出来是 # 「整齐的包装纸」——因为每个元素都在固定格点上重复。现在只留 1 朵 + 2 片, # 尺寸放大到 300px 且各自错位,平铺时才看不出周期。 -def pattern(light): +def pattern(light, strong=False): + """平铺底纹。strong 给窄屏用:那里装饰层全关,底纹是屏幕上唯一的樱花, + 照桌面那套 0.18~0.34 的透明度在 390px 上基本看不出来(实测截图接近纯粉底)。""" + k = 1.9 if strong else 1.0 one = ( '' % light + "".join(f'' @@ -101,13 +104,14 @@ def pattern(light): ) petal_path = ('') + o = lambda v: min(0.92, round(v * k, 2)) return ( '' - f'{one}' - f'' + f'{one}' + f'' + (petal_path % light) + "" - f'' + f'' + (petal_path % light) + "" "" @@ -140,6 +144,7 @@ def uris(p, asset_base=""): "branch_l": 'url("%s/branch-left.webp")' % b, "branch_r": 'url("%s/branch-right.webp")' % b, "pattern": 'url("%s/pattern.webp")' % b, + "pattern_narrow": 'url("%s/pattern.webp")' % b, } return { "blossom_l": uri(blossom(p["light"], p["deep"], p["heart"])), @@ -147,6 +152,7 @@ def uris(p, asset_base=""): "branch_l": uri(branch(p["wood"], p["light"], p["deep"], p["heart"], flip=True)), "branch_r": uri(branch(p["wood"], p["light"], p["deep"], p["heart"], flip=False)), "pattern": uri(pattern(p["pattern"])), + "pattern_narrow": uri(pattern(p["pattern"], strong=True)), } @@ -213,8 +219,12 @@ SCOPES = { ("var(--sk-petal)", "28px", "right 18% top 27%"), ("var(--sk-blossom)", "auto 52px", "right 6% bottom 8%"), ], - # 窄屏:卡片占满宽度,留白只剩顶栏上下那两条 —— 装饰层放什么都没有立足之地, - # 樱花改由底纹承担(@media 里把 pattern 缩小到 200px,密度上来才看得出来) + # 窄屏:卡片占满宽度,左右只剩 ~16px 边距,花枝放哪儿都糊在字上。 + # 实测底纹铺在 16px 边距里根本落不到花 —— 花朵在格子里的位置固定在 x≈58, + # 所以只在「顶栏与卡片之间」那条 30px 的横带里放几朵小的,其余交给加强底纹。 + # 试过在「顶栏与卡片之间」那条 ~35px 的带子里放小樱花:box 页卡片从 y=122 起还行, + # question/admin 页卡片从 y=110 起就把它切掉半个,结果是贴在卡片边上的粉色碎块。 + # 手机上没有可靠的留白,干脆不放装饰层 —— 观感交给配色与底纹(加强版 pattern)。 "narrow": [], } @@ -320,13 +330,30 @@ html[data-theme="dark"][data-theme-style="new-sakura"] {{ f'html[data-theme="dark"][data-theme-style="new-sakura"][data-page-scope="{key}"]', SCOPES[key]) + "\n" + # 作用域块的选择器是 html[data-theme-style][data-page-scope="box"](特异性 0,2,1), + # 比 html[data-theme-style] (0,1,1)高 —— 光写一条压不住,窄屏照样是桌面版装饰。 + # 所以用同等特异性的通配属性选择器再写一遍,靠「在文件里更晚」取胜; + # 深色作用域块是 0,3,1,还得再补一条。 + narrow_rules = "" + for sel in ( + 'html[data-theme-style="new-sakura"][data-page-scope]', + 'html[data-theme="dark"][data-theme-style="new-sakura"]', + 'html[data-theme="dark"][data-theme-style="new-sakura"][data-page-scope]', + ): + narrow_rules += " %s {\n%s }\n" % ( + sel, deco_block_inline(SCOPES["narrow"], indent=" ")) + narrow = ( "\n@media (max-width: 640px) {\n" ' html[data-theme-style="new-sakura"] {\n' + deco_block_inline(SCOPES["narrow"], indent=" ") - + " /* 手机上唯一的留白是顶栏上下两条,装饰层没有立足之地 —— 缩小底纹让樱花密起来 */\n" - " --tb-page-pattern-size: 200px 200px;\n" - " }\n}\n" + + " /* 手机上装饰层整个收掉(卡片占满宽度),樱花只能由底纹承担:\n" + " 换成加强版 + 缩小格子,否则 390px 上看就是一片纯粉 */\n" + " --tb-page-pattern: %s;\n" % u["pattern_narrow"] + + " --tb-page-pattern-size: 180px 180px;\n" + " }\n" + + narrow_rules + + "}\n" ) return HEADER + body + dark_block + narrow + FOOTER diff --git a/docs/theme-new-sakura.md b/docs/theme-new-sakura.md index daa57bd..02d5abf 100644 --- a/docs/theme-new-sakura.md +++ b/docs/theme-new-sakura.md @@ -11,7 +11,7 @@ - 换花色只改 `dev/theme-tools/gen_new_sakura.py` 里的一个色值,重跑即得; - 没有额外 HTTP 请求 —— SVG 就在 CSS 里。 -代价是 CSS 有 51KB(服务端有 gzip,实际传输约 3.9KB)。主题 CSS 走 `StaticVersion` 缓存,一次性开销。 +代价是 CSS 有 54KB(服务端有 gzip,实际传输约 4KB)。主题 CSS 走 `StaticVersion` 缓存,一次性开销。 ## 结构 @@ -46,8 +46,20 @@ python dev/theme-tools/gen_new_sakura.py ### 窄屏(≤640px) -装饰层全部置 `none`:卡片占满宽度后,留白只剩顶栏上下两条,枝条会直接糊在文字上。 -樱花改由底纹承担 —— `@media` 里把 `--tb-page-pattern-size` 从 300px 收到 200px,密度上来才看得见。 +装饰层全部置 `none`:卡片占满宽度,左右只剩 ~16px 边距,枝条放哪儿都会糊在字上。 +樱花改由底纹承担 —— `@media` 里换成**加强版 pattern**(透明度 ×1.9)并把格子收到 180px; +桌面那套 0.18~0.34 的透明度在 390px 上实测接近一片纯粉。 + +试过在「顶栏与卡片之间」那条 35px 的带子里放两朵小樱花:box 页卡片从 y=122 起还行, +question / admin 页卡片从 y=110 起就切掉半个,结果是黏在卡片边上的粉色碎块。手机上没有 +可靠的留白,就不放装饰层 —— 观感交给配色与底纹。 + +⚠️ **窄屏覆盖必须重新声明每个作用域选择器**:`@media` 不提升特异性。 +作用域块是 `html[data-theme-style][data-page-scope="box"]`(0,2,1),而媒体查询里常见的 +`html[data-theme-style]`(0,1,1)根本压不住它 —— 表现是「手机上装饰层没关掉,桌面版照样铺开」。 +深色作用域块更是 0,3,1,还得再补一条 `[data-theme="dark"][data-theme-style][data-page-scope]`。 +`TestNarrowMediaOverridesScopedDeco` 会在这里把关:外层每一处非 none 的 `--tb-deco`, +媒体查询里都要求有同属性名、属性数不少于它的选择器再声明一次。 ### 深色 @@ -116,11 +128,11 @@ python dev/theme-tools/gen_new_sakura.py \ | 版本 | CSS | gzip 后 | 额外请求 | 清晰度 | | --- | --- | --- | --- | --- | -| 内联 SVG(默认) | 51.2KB | **3.9KB** | 0 | 矢量,任意 DPR | -| 位图 | 10.6KB | 2.7KB | 5 个 webp,共 48.9KB | 2 倍图 | +| 内联 SVG(默认) | 53.8KB | **4.0KB** | 0 | 矢量,任意 DPR | +| 位图 | 11.8KB | 2.8KB | 5 个 webp,共 48.9KB | 2 倍图 | -服务端挂了 gzip(`internal/route/gzip.go`),所以内联版**总传输 3.9KB**,比位图版的 -2.7 + 48.9 = 51.6KB 小一个数量级 —— 换位图省不了流量,只是把开销从 CSS 挪到图片。 +服务端挂了 gzip(`internal/route/gzip.go`),所以内联版**总传输 4.0KB**,比位图版的 +2.8 + 48.9 = 51.7KB 小一个数量级 —— 换位图省不了流量,只是把开销从 CSS 挪到图片。 位图版唯一正当的理由是**素材要单独复用**(分享卡、海报、其它系统)。 ⚠️ 直接连桶的 S3 endpoint(`*.r2.cloudflarestorage.com`)会 403,**只有自定义域/r2.dev 可匿名读**。 @@ -140,7 +152,7 @@ python dev/theme-tools/gen_new_sakura.py \ ## 怎么验证 ```bash -go test ./internal/theme # 10 槽等长、深浅块成对、必需令牌齐全 +go test ./internal/theme # 10 槽等长、深浅块成对、必需令牌齐全、窄屏覆盖特异性 ``` 视觉自检用项目真实页面快照: diff --git a/internal/theme/narrow_test.go b/internal/theme/narrow_test.go new file mode 100644 index 0000000..6c54e1a --- /dev/null +++ b/internal/theme/narrow_test.go @@ -0,0 +1,170 @@ +package theme + +import ( + "io/fs" + "regexp" + "strings" + "testing" + + "github.com/tamakyi/TamaBox/static" +) + +var ruleRe = regexp.MustCompile(`(?s)([^{}]+)\{([^{}]*)\}`) + +// TestNarrowMediaOverridesScopedDeco 守一个只会「静默失效」的坑: +// 窄屏媒体查询里写的装饰层覆盖,若特异性不如页面作用域块,就完全不生效。 +// +// 作用域块是 html[data-theme-style][data-page-scope="box"](两个属性), +// 而 @media 里常见的写法是 html[data-theme-style](一个属性)—— +// 媒体查询不会提升特异性,于是桌面的装饰层在手机上照旧铺开, +// 表现就是「加了素材,窄屏糊在卡片上」。深色作用域块有三个属性,更难压。 +// +// 判定:外层每一处「不全是 none」的 --tb-deco 声明,窄屏媒体查询里必须有一处属性名集合 +// 相同、属性个数不少于它、值相同或更宽泛的选择器再声明一次 +// (媒体查询在文件更后,同特异性必胜)。本来就声明成 none 的(如深色直接关掉装饰层)不算。 +func TestNarrowMediaOverridesScopedDeco(t *testing.T) { + checked := 0 + for _, slug := range discover() { + name := themeCSSDir + slug + ".css" + b, err := fs.ReadFile(static.FS, name) + if err != nil { + t.Fatalf("读取 %s: %v", name, err) + } + css := commentRe.ReplaceAllString(string(b), "") + inner, outer := splitNarrow(css) + + outerSels := decoSels(outer, true) + if len(outerSels) == 0 { + continue + } + narrowSels := decoSels(inner, false) + for _, s := range outerSels { + checked++ + if !coveredBy(s, narrowSels) { + t.Errorf("%s: 窄屏 @media (max-width: 640px) 里没有能压住 `%s` 的装饰层声明"+ + "(同属性名、属性数不少于它)—— 该作用域在手机上会沿用桌面装饰层", name, s.raw) + } + } + } + t.Logf("共校验 %d 处作用域装饰层声明", checked) +} + +// splitNarrow 把 css 拆成「窄屏媒体查询内部」与「其余」两部分。 +// 任何 max-width 断点都算(shiroko 用的是 900px,按内容宽度自己定更合理)。 +func splitNarrow(css string) (inner, outer string) { + var rest strings.Builder + for { + i := strings.Index(css, "@media (max-width:") + if i < 0 { + rest.WriteString(css) + return inner, rest.String() + } + rest.WriteString(css[:i]) + open := strings.IndexByte(css[i:], '{') + if open < 0 { + return inner, rest.String() + } + open += i + depth, end := 0, -1 + for j := open; j < len(css); j++ { + switch css[j] { + case '{': + depth++ + case '}': + depth-- + if depth == 0 { + end = j + } + } + if end >= 0 { + break + } + } + if end < 0 { + return inner, rest.String() + } + inner += css[open+1 : end] + css = css[end+1:] + } +} + +type decoSel struct { + raw string + attrs map[string]string // 属性名 → 值(空值表示只检查属性存在) +} + +// decoSels 取出所有声明了 --tb-deco 的规则里的选择器。 +// skipNone 用于外层:值全是 none 的本来就没有装饰层可压,不必要求窄屏再声明一次。 +// 窄屏侧不能跳(那里往往正是靠 none 来收掉桌面装饰层)。 +func decoSels(css string, skipNone bool) []decoSel { + var out []decoSel + for _, m := range ruleRe.FindAllStringSubmatch(css, -1) { + value := declarationValue(m[2], "--tb-deco:") + if value == "" || (skipNone && allNone(value)) { + continue + } + for _, one := range strings.Split(m[1], ",") { + one = strings.TrimSpace(one) + if strings.HasPrefix(one, "@") { + continue + } + out = append(out, decoSel{raw: one, attrs: attrMap(one)}) + } + } + return out +} + +func declarationValue(body, key string) string { + i := strings.Index(body, key) + if i < 0 { + return "" + } + rest := body[i+len(key):] + if j := strings.IndexByte(rest, ';'); j >= 0 { + rest = rest[:j] + } + return strings.TrimSpace(rest) +} + +func allNone(v string) bool { + layers := splitTopLevel(v) + if len(layers) == 0 { + return false + } + for _, l := range layers { + if l != "none" { + return false + } + } + return true +} + +// attrMap 解析选择器里的 [name="value"],只保留属性名与值。 +func attrMap(sel string) map[string]string { + out := map[string]string{} + for _, m := range regexp.MustCompile(`\[([a-zA-Z-]+)(?:="?([^"\]]*)"?)?\]`).FindAllStringSubmatch(sel, -1) { + out[m[1]] = m[2] + } + return out +} + +// coveredBy 判断媒体查询里的某个选择器是否能在同特异性下压过 outer。 +func coveredBy(outer decoSel, narrow []decoSel) bool { + for _, n := range narrow { + if len(n.attrs) != len(outer.attrs) { + continue + } + ok := true + for k, v := range outer.attrs { + nv, has := n.attrs[k] + if !has || (nv != "" && nv != v) { + ok = false + break + } + } + if ok { + return true + } + } + return false +} diff --git a/static/css/themes/new-sakura.css b/static/css/themes/new-sakura.css index bbed1c7..1831be1 100644 --- a/static/css/themes/new-sakura.css +++ b/static/css/themes/new-sakura.css @@ -156,8 +156,25 @@ html[data-theme="dark"][data-theme-style="new-sakura"][data-page-scope="vote"] { --tb-deco: none, none, none, none, none, none, none, none, none, none; --tb-deco-size: auto, auto, auto, auto, auto, auto, auto, auto, auto, auto; --tb-deco-position: left top, left top, left top, left top, left top, left top, left top, left top, left top, left top; - /* 手机上唯一的留白是顶栏上下两条,装饰层没有立足之地 —— 缩小底纹让樱花密起来 */ - --tb-page-pattern-size: 200px 200px; + /* 手机上装饰层整个收掉(卡片占满宽度),樱花只能由底纹承担: + 换成加强版 + 缩小格子,否则 390px 上看就是一片纯粉 */ + --tb-page-pattern: url("data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%20300%20300%22%3E%3Cg%20transform%3D%22translate%2896%2084%29%20scale%280.34%29%20translate%28-50%20-50%29%22%20opacity%3D%220.65%22%3E%3Cg%20fill%3D%22%23f2bfd0%22%3E%3Cellipse%20cx%3D%2250%22%20cy%3D%2227%22%20rx%3D%2214%22%20ry%3D%2221%22%20transform%3D%22rotate%280%2050%2050%29%22%2F%3E%3Cellipse%20cx%3D%2250%22%20cy%3D%2227%22%20rx%3D%2214%22%20ry%3D%2221%22%20transform%3D%22rotate%2872%2050%2050%29%22%2F%3E%3Cellipse%20cx%3D%2250%22%20cy%3D%2227%22%20rx%3D%2214%22%20ry%3D%2221%22%20transform%3D%22rotate%28144%2050%2050%29%22%2F%3E%3Cellipse%20cx%3D%2250%22%20cy%3D%2227%22%20rx%3D%2214%22%20ry%3D%2221%22%20transform%3D%22rotate%28216%2050%2050%29%22%2F%3E%3Cellipse%20cx%3D%2250%22%20cy%3D%2227%22%20rx%3D%2214%22%20ry%3D%2221%22%20transform%3D%22rotate%28288%2050%2050%29%22%2F%3E%3C%2Fg%3E%3C%2Fg%3E%3Cg%20transform%3D%22translate%28236%20208%29%20rotate%2828%29%20scale%280.26%29%22%20opacity%3D%220.46%22%3E%3Cpath%20d%3D%22M20%201c9%200%2015%208%2015%2018%200%205-2%209-4%2012l-4-5-7%209-7-9-4%205c-2-3-4-7-4-12C5%209%2011%201%2020%201z%22%20fill%3D%22%23f2bfd0%22%2F%3E%3C%2Fg%3E%3Cg%20transform%3D%22translate%28188%2052%29%20rotate%28-34%29%20scale%280.2%29%22%20opacity%3D%220.34%22%3E%3Cpath%20d%3D%22M20%201c9%200%2015%208%2015%2018%200%205-2%209-4%2012l-4-5-7%209-7-9-4%205c-2-3-4-7-4-12C5%209%2011%201%2020%201z%22%20fill%3D%22%23f2bfd0%22%2F%3E%3C%2Fg%3E%3C%2Fsvg%3E"); + --tb-page-pattern-size: 180px 180px; + } + html[data-theme-style="new-sakura"][data-page-scope] { + --tb-deco: none, none, none, none, none, none, none, none, none, none; + --tb-deco-size: auto, auto, auto, auto, auto, auto, auto, auto, auto, auto; + --tb-deco-position: left top, left top, left top, left top, left top, left top, left top, left top, left top, left top; + } + html[data-theme="dark"][data-theme-style="new-sakura"] { + --tb-deco: none, none, none, none, none, none, none, none, none, none; + --tb-deco-size: auto, auto, auto, auto, auto, auto, auto, auto, auto, auto; + --tb-deco-position: left top, left top, left top, left top, left top, left top, left top, left top, left top, left top; + } + html[data-theme="dark"][data-theme-style="new-sakura"][data-page-scope] { + --tb-deco: none, none, none, none, none, none, none, none, none, none; + --tb-deco-size: auto, auto, auto, auto, auto, auto, auto, auto, auto, auto; + --tb-deco-position: left top, left top, left top, left top, left top, left top, left top, left top, left top, left top; } }