Files
tamakyi ddee221de2 feat(theme): 主题支持按页面分别配装饰,生成器改用真实页面画布
原来的生成器只能配「背景 + 人物 + 道具」三件套,预览还是自己画的仿制页面 ——
跟真实页面不像,也没法给不同页面配不同内容。

机制层:
- internal/context.pageScope() 按路径归类,输出 <html data-page-scope>:
  box / question / admin / profile / vote(首页与 /explore 不加载主题,不列)
- /user/questions 单独归为 admin:题目列表信息密,和设置表单的装饰诉求不同
- base.css 装饰槽位 6 -> 10,背景 8 层 -> 12 层;主题按作用域覆盖时同样写满 10 个
- 新增 TestDecoSlotCount:逐文件逐声明检查 10 个值。少写一个不报错,
  只会让图层与 size/position 错位(整页重复同一张图 / 人物跑到左上角),注释守不住

生成器:
- 画板改成 iframe 加载 outputs/preview-*.html(真实模板渲染),配 serve.py 起本地服务
  解决 file:// 下父页面读不到 iframe DOM 的问题
- 八个页面作用域(去掉不生效的首页/探索),每个可独立配背景与最多 10 层装饰
- 图层类型:图片 / 径向光斑 / 线性光;9 宫格锚点 + 像素偏移,可在画板上直接拖
- 位置统一写 calc(0%|50%|100% ± Npx):百分比是相对 (容器-图片),
  不用 left/bottom 关键字混搭,避免三值语法非法导致整条声明作废
- 配色只填 6 个基础色,其余 4 个必需令牌按方向派生(surface-2 往 text 靠,
  浅色变深、深色变亮,方向自动正确)

配套:
- zz_preview_test.go 增加单题页 / 题目列表 / 投票页预览,作为生成器画板
- docs/theme-page-scope.md 记录作用域取值、选择器特异性与 10 槽位约束
- StaticVersion 20261020e(base.css 变了)

验证:生成器导出的 CSS 通过 TestThemeCSSComplete + TestDecoSlotCount;
真实箱主页计算样式为 12 层对齐;临时主题验证「question 覆盖为 none」生效、
未覆盖时回退全局。
2026-10-20 10:53:44 +08:00

109 lines
3.0 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package theme
import (
"io/fs"
"strings"
"testing"
"github.com/tamakyi/TamaBox/static"
)
// DecoSlots 是 --tb-deco / --tb-deco-size / --tb-deco-position 三组令牌
// 必须对齐的槽位数,与 static/css/base.css 里的 12 层背景
// (平铺图案 1 + 装饰 10 + cover 背景图 1)严格对应。
//
// 少写一个值不会报错,CSS 会按列表长度循环取值,于是图层与 size/position
// 整体错位:典型表现是整页重复同一张插画,或人物跑到页面左上角。
// 这个约定靠注释根本守不住,只能用测试钉死。
const DecoSlots = 10
// TestDecoSlotCount 逐个检查 base.css 与所有主题 css 里每一处装饰层声明。
// 主题可按页面作用域(html[data-page-scope="..."])写多组,所以是「每一处」而不是「每文件」。
func TestDecoSlotCount(t *testing.T) {
files := []string{"css/base.css"}
for _, slug := range discover() {
files = append(files, themeCSSDir+slug+".css")
}
if len(files) < 2 {
t.Fatalf("只找到 %d 个 css,discover() 可能没工作", len(files))
}
checked := 0
for _, name := range files {
b, err := fs.ReadFile(static.FS, name)
if err != nil {
t.Fatalf("读取 %s: %v", name, err)
}
// 注释里会出现「--tb-deco 前两个槽位」这类说明,先剥掉再解析
css := commentRe.ReplaceAllString(string(b), "")
for _, key := range []string{"--tb-deco:", "--tb-deco-size:", "--tb-deco-position:"} {
// 主题可以不写这组令牌(直接沿用 base.css 的默认值),所以「没有」是合法的;
// 但只要写了,就必须写满 —— 半套值比不写更危险。
for i, decl := range declarations(css, key) {
checked++
if got := len(splitTopLevel(decl)); got != DecoSlots {
t.Errorf("%s 第 %d 处 %s 有 %d 个值,应为 %d 个",
name, i+1, key, got, DecoSlots)
}
}
}
}
if checked == 0 {
t.Fatal("一处装饰层声明都没解析到,说明 key 拼错了或 commentRe 剥多了")
}
t.Logf("共校验 %d 处装饰层声明", checked)
}
// declarations 取出 key 之后到下一个分号之间的原始文本(不含 key 与分号)。
func declarations(css, key string) []string {
var out []string
for {
i := strings.Index(css, key)
if i < 0 {
return out
}
rest := css[i+len(key):]
j := strings.IndexByte(rest, ';')
if j < 0 {
return out
}
out = append(out, rest[:j])
css = rest[j+1:]
}
}
// splitTopLevel 按顶层逗号切分。不能直接 strings.Split:
// rgba(63, 96, 138, 0.18) 和 calc(50% - 524px) 内部的逗号不是层分隔符,
// 直接切会把一层算成四层(曾据此误判「已经写够 10 个」)。
func splitTopLevel(s string) []string {
var (
out []string
buf strings.Builder
depth int
)
flush := func() {
if v := strings.TrimSpace(buf.String()); v != "" {
out = append(out, v)
}
buf.Reset()
}
for _, r := range s {
switch r {
case '(':
depth++
case ')':
depth--
case ',':
if depth == 0 {
flush()
continue
}
}
buf.WriteRune(r)
}
flush()
return out
}