修窄屏装饰层压不住作用域块(手机上一直显示桌面版樱花)
@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 起会切掉一半,最终不放装饰层。
This commit is contained in:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user