Files
tamakyi c9f22cb81b 修窄屏装饰层压不住作用域块(手机上一直显示桌面版樱花)
@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 起会切掉一半,最终不放装饰层。
2026-10-22 11:18:29 +08:00

171 lines
4.6 KiB
Go

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
}