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」生效、
未覆盖时回退全局。
This commit is contained in:
tamakyi
2026-10-20 10:53:44 +08:00
parent feb5b1f78d
commit ddee221de2
14 changed files with 1748 additions and 796 deletions
+1 -1
View File
@@ -10,7 +10,7 @@ var (
BuildCommit = "dev"
// StaticVersion 静态资源缓存版本号:base.css 与主题 css 共用,改样式只改这一处。
StaticVersion = "20261020d"
StaticVersion = "20261020e"
)
// 文本审核通道取值([security] text_censor_source)。定义在 conf 侧,
+58
View File
@@ -240,6 +240,12 @@ func Contexter() flamego.Handler {
isUserPage := strings.HasPrefix(path, "/user") || strings.HasPrefix(path, "/_/")
c.Data["IsUserPage"] = isUserPage
// 页面作用域:让同一套主题能在不同页面摆不同装饰(箱主页/问题页/设置页…)。
// 模板通过 <html data-page-scope> 输出,主题 css 用
// html[data-theme-style="x"][data-page-scope="y"] 覆盖 --tb-deco 等令牌。
// 未知路径为空串,此时只应用全局装饰,行为与引入该机制前完全一致。
c.Data["PageScope"] = pageScope(path)
// 主题:/user 后台是箱主本人的页面,跟随本人配置;
// /_/{domain} 的箱主主题由 question.Pager 覆盖(访客看到的是箱主的选择)。
// 空串(未选且站点无默认主题)= 不加载主题 css。
@@ -342,3 +348,55 @@ func (c *Context) Lang() string {
func isThemeScope(path string) bool {
return path == "/user" || strings.HasPrefix(path, "/user/")
}
// pageScope 把请求路径归类成「主题页面作用域」。
//
// 目的:同一个主题在不同页面可以摆不同装饰(箱主页放人物、问题页只留光、设置页干脆不放图),
// 而不必为每种页面复制一整套主题。模板把它输出到 <html data-page-scope="...">,
// 主题 css 用 html[data-theme-style="x"][data-page-scope="y"] 覆盖 --tb-deco 等令牌。
//
// 返回空串表示「不在主题作用域内」(首页/探索/登录等公开页也可返回具体值,
// 只是这些页面通常不带 ThemeSlug,覆盖块不会生效)。
func pageScope(path string) string {
switch {
case path == "/":
return "home"
case path == "/explore" || strings.HasPrefix(path, "/explore/"):
return "explore"
case path == "/login" || strings.HasPrefix(path, "/login/"),
path == "/register" || strings.HasPrefix(path, "/register/"):
return "auth"
case path == "/user" || strings.HasPrefix(path, "/user/"):
// 题目/私聊管理列表是「翻找内容」的页面,和「设置表单」的信息密度差很多,
// 装饰要分开配:admin 更该退到背景,profile 可以保留一点氛围。
if strings.HasPrefix(path, "/user/questions") || strings.HasPrefix(path, "/user/votes") {
return "admin"
}
return "profile"
case strings.HasPrefix(path, "/_/"):
// /_/{domain} → 箱主页(问题列表)
// /_/{domain}/{qid} → 单个问题
// /_/{domain}/tag/xxx → 仍是箱主页
rest := strings.Trim(path[len("/_/"):], "/")
parts := strings.Split(rest, "/")
if len(parts) == 2 && isAllDigits(parts[1]) {
return "question"
}
return "box"
case path == "/vote" || strings.HasPrefix(path, "/vote/"):
return "vote"
}
return ""
}
func isAllDigits(s string) bool {
if s == "" {
return false
}
for _, r := range s {
if r < '0' || r > '9' {
return false
}
}
return true
}
+34
View File
@@ -28,3 +28,37 @@ func TestIsThemeScope(t *testing.T) {
}
}
}
// 页面作用域:主题按页面分别摆装饰靠这个值区分。
// 重点是把「箱主页 / 单题页 / 题目管理页」分清楚 —— 三者路径前缀很像。
func TestPageScope(t *testing.T) {
cases := map[string]string{
"/": "home",
"/explore": "explore",
"/explore/users": "explore",
"/login": "auth",
"/register": "auth",
"/user": "profile",
"/user/profile": "profile",
"/user/questions": "admin", // 题目列表:信息密,装饰要退
"/user/votes/1/results": "admin",
"/_/admin6": "box",
"/_/admin6/": "box",
"/_/admin6/tag/杂谈": "box",
"/_/admin6/answer": "box", // 非数字段 → 不是单题页
"/_/admin6/123": "question",
"/_/admin6/123/": "question",
"/vote/abc123": "vote",
"/username": "",
"/_": "",
"/deactivated": "",
"/api/v1/user/admin6": "",
"/api/v1/user/admin6/questions": "",
}
for path, want := range cases {
if got := pageScope(path); got != want {
t.Errorf("pageScope(%q) = %q, want %q", path, got, want)
}
}
}
+108
View File
@@ -0,0 +1,108 @@
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
}