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
+101 -28
View File
@@ -1,43 +1,116 @@
# 主题生成器(本地单文件)
# 主题生成器
`index.html` 双击即用,不联网、不起服务、不传图片——所有处理都在浏览器本地完成。
一个可视化编辑主题的小工具:**以项目真实页面为画布**,按页面分别摆装饰,导出 zip 解压即用。
## 用法
它替换了早先那版「单文件、仿制预览」的生成器 —— 那一版的问题很直接:预览是我自己画的
示意图,跟真实页面长得不一样;而且只能配「背景 + 人物 + 道具」三件套。
1. 打开 `dev/theme-builder/index.html`(推荐 Chrome / Edge)。
2. 填 slug(= css 文件名,小写字母/数字/连字符)。
3. 传图:
- **背景图**:任意尺寸,会按 `cover` 裁成 1920×1080;
- **人物**:透明底 PNG/WebP,会按高度 1.5× 输出;
- **道具**(可选):透明底,会自动在底部烘一层接触阴影。
4. 拖预览里的虚线框调位置,或右侧滑杆微调;切 1512 / 1280 / 1024 看不同视口。
5. 点「导出 zip」→ 解压到项目根目录 → 按 zip 里的 `INSTALL-<slug>.txt` 做三步(登记 `[theme] available`、`StaticVersion` +1、重新编译)。
## 为什么需要起一个本地服务
## 它做了什么
生成器要把 `outputs/preview-*.html`(由 `go test` 渲染出来的**真实页面**)放进 iframe 当画板,
再往里注入正在编辑的主题 CSS。`file://` 下浏览器不允许父页面访问 iframe 的 DOM(即使同目录),
预览就只能退回示意图。起本地服务后父子同源,拖拽、注入、实时改令牌都能工作。
- 用**和线上同一套机制**渲染预览:`background-image: pattern, deco(6 层), page-image`,
`background-size` / `background-position` 一一对应,不是另画一张示意图。
- 位置数学和 CSS 一致:`background-position` 的百分比是相对 **(容器宽 − 图宽)**,
所以工具里拖出来的「左沿像素」会被反解成 `calc(50% - Npx)`,换视口不会跑位。
- 图片全部转 WebP 并按目标尺寸重采样,导出前就能看到 KB 数。
- 生成的 CSS 同时写浅色/深色两块(项目测试 `TestThemeCSSComplete` 要求),
10 个必需令牌齐全。
```bash
# 1) 先渲染真实页面快照(改了模板/数据后要重跑)
TEST_PREVIEW=1 go test ./templates -run TestGenPreview
# 2) 起服务(只读,不改项目任何文件)
python dev/theme-builder/serve.py # 默认 8777
# 3) 打开
http://127.0.0.1:8777/dev/theme-builder/
```
不想用脚本也行:在项目根跑 `python -m http.server 8777`,效果一样。
## 它和线上是同一条链路
| 环节 | 生成器 | 线上 |
| --- | --- | --- |
| 画板 | `outputs/preview-*.html`(真模板渲染) | 同一套模板 |
| 作用域 | iframe 里设 `data-page-scope` | `internal/context.pageScope()` 输出到 `<html>` |
| 装饰层 | 6 槽?不,**固定 10 槽** | `base.css` 的 12 层背景(pattern 1 + deco 10 + image 1) |
| 位置 | `calc(0%|50%|100% ± Npx)` | 同一套 `background-position` |
因为是同一套机制,工具里拖出来的位置换视口不会跑位,也不会出现「预览好看、实机错位」。
## 三块面板
**页面** —— 每个页面作用域可以单独配,各有一份背景与装饰:
| 作用域 | 对应路径 |
| --- | --- |
| 全局 | 所有页面默认 |
| `box` | `/_/{domain}` 箱主页 |
| `question` | `/_/{domain}/{id}` 单题页 |
| `admin` | `/user/questions` 题目列表 |
| `profile` | `/user/*` 设置页 |
| `vote` | `/vote/{token}` 投票页 |
默认全部「跟随全局」;勾掉就变成独立配置,导出时才会产出覆盖块。
没配的页面自动沿用全局,不会出现「某页装饰消失」。
(首页 `/` 与 `/explore` 不列出来 —— 全站公开页不注入 `ThemeSlug`,不加载主题 css,配了也没效果。)
**装饰图层** —— 每个作用域最多 10 层,三种类型:
- **图片**:从图片池选,按高度定尺寸(宽度自动等比),选中后可直接在画板上拖动;
- **光斑**:`radial-gradient`,给空白一点体积,不占位、不会变成贴纸;
- **线性光**:`linear-gradient`,做窗光/压深。
锚点用 9 宫格 + 像素偏移,生成 `calc(0% + 40px) calc(100% - 36px)` 这种两值形式。
**图片池** —— 上传任意张,浏览器本地转 WebP,画布尺寸 = 原图尺寸(不缩放、不留透明边)。
导出时只打包实际用到的图。
## 导出
点「导出 zip」得到:
```
static/css/themes/<slug>.css
static/images/themes/<slug>/*.webp
INSTALL-<slug>.txt
```
解压到项目根,然后按 `INSTALL-<slug>.txt` 做三步:
1. `conf/app.ini` 的 `[theme] available` 加上 slug;
2. `internal/conf/static.go` 的 `StaticVersion` +1;
3. `go build`(模板与静态资源都是编译期 embed,只重启不重编不会变)。
## 它不做什么
- **不抠图**。人物/道具必须是透明底;白底图导出的主题会带一块白。
- **不做调色**。想统一"雾度"(人物压白 16%、场景 30~38%)得在生成端做,
或用 `docs/theme-shiroko-assets-spec.md` 里的公式自己处理。
- **不写 `[theme] available`**。配置在 `conf/app.ini`,导出包里只给了要粘贴的那一行。
- **不改代码**。素材是编译期 embed,必须重新 `go build` 才会生效。
- **不抠图**:图片必须本身是透明底,白底图导出的主题会带一块白。
- **不做调色**:想统一「雾度」(人物压白 16%、场景 30~38%)得在生成端做,
见 `docs/theme-shiroko-assets-spec.md`。
- **不写配置**:`[theme] available` 只给要粘贴的那一行。
- **不碰深色的图片**:深色块一律 `--tb-page-image: none` + 装饰全关(白天场景压在深底上会脏)。
想在深色下也放图,需要手工加深色块。
## 自检
没有 UI 也能回归,两个钩子:
```
chrome --headless=old --dump-dom "file:///.../dev/theme-builder/index.html?selftest=1"
?demo=1 载入示例素材(canvas 画的背景 + 剪影人物)
?scope=admin 直接落在某个作用域
?dump=css 把最终 CSS 写进 <pre id="dump">
?dump=zip 把 zip 的 base64 写进 <pre id="dump">
```
会把生成的 CSS 与 zip 字节以 JSON 塞进 `<pre id="selftest">`,可用于回归:
把 `css` 写成 `static/css/themes/<slug>.css`,跑 `go test ./internal/theme -run TestThemeCSSComplete`。
```bash
chrome --headless=old --virtual-time-budget=6000 \
--dump-dom "http://127.0.0.1:8777/dev/theme-builder/?demo=1&dump=css"
```
`?demo=1` 会直接载入示例素材(纯 canvas 画的渐变背景 + 一个剪影),没有图也能先看布局。
把抓到的 CSS 存成 `static/css/themes/<slug>.css`,可跑:
```bash
go test ./internal/theme -run "TestThemeCSSComplete|TestDecoSlotCount"
```
`TestDecoSlotCount` 是这次新增的:它会逐个检查 `base.css` 与所有主题 css 里每一处
`--tb-deco*` 声明是否都有 10 个值。少写一个不会报错,只会让图层与 size/position 错位
(表现为整页重复同一张插画,或人物跑到左上角),靠注释守不住。
+1096 -751
View File
File diff suppressed because it is too large Load Diff
+69
View File
@@ -0,0 +1,69 @@
#!/usr/bin/env python3
"""主题生成器的本地预览服务。
为什么必须起服务:生成器不再自己画一个"仿制页面",而是把项目真实渲染出来的
页面(outputs/preview-*.html)放进 iframe 当画板,再往里注入正在编辑的主题 CSS。
file:// 下浏览器不允许父页面访问 iframe 的 DOM(即使同目录),预览就只能退回示意图。
起一个本地 HTTP 服务后父子同源,拖拽、注入、实时改令牌都能正常工作。
用法(在项目根目录执行任意一种):
python dev/theme-builder/serve.py
python -m http.server 8777
然后打开 http://localhost:8777/dev/theme-builder/
这个脚本只读文件,不写任何东西,也不改项目配置。
"""
import argparse
import functools
import http.server
import os
import socketserver
import sys
# dev/theme-builder/serve.py → 上两级就是项目根
PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
class Handler(http.server.SimpleHTTPRequestHandler):
def end_headers(self):
# 预览页是刚由 go test 生成的,禁止缓存,否则改了 CSS 看不到变化
self.send_header("Cache-Control", "no-store, must-revalidate")
super().end_headers()
def log_message(self, fmt, *args):
# 只报 4xx/5xx,正常请求不刷屏
status = args[1] if len(args) > 1 else ""
if str(status).startswith(("4", "5")):
sys.stderr.write(" %s\n" % (fmt % args))
def main():
ap = argparse.ArgumentParser(description="TamaBox 主题生成器的本地预览服务")
ap.add_argument("--port", type=int, default=8777, help="端口,默认 8777")
ap.add_argument("--host", default="127.0.0.1", help="监听地址,默认 127.0.0.1")
args = ap.parse_args()
if not os.path.isdir(os.path.join(PROJECT_ROOT, "outputs")):
sys.stderr.write(
"还没生成预览快照。先在项目根执行:\n"
" TEST_PREVIEW=1 go test ./templates -run TestGenPreview\n"
)
handler = functools.partial(Handler, directory=PROJECT_ROOT)
socketserver.TCPServer.allow_reuse_address = True
with socketserver.TCPServer((args.host, args.port), handler) as httpd:
url = "http://%s:%d/dev/theme-builder/" % (args.host, args.port)
print("项目根:%s" % PROJECT_ROOT)
print("生成器:%s" % url)
print("Ctrl+C 停止")
try:
httpd.serve_forever()
except KeyboardInterrupt:
print("\n已停止")
if __name__ == "__main__":
main()
+86
View File
@@ -0,0 +1,86 @@
# 主题的页面作用域(data-page-scope)
一套主题过去只能有一种装饰:人物永远站在左下角,不管当前是箱主页、单题页还是题目列表。
单题页正文长、题目列表信息密,同一套装饰在三种页面上要同时成立很难。
现在服务端会按请求路径给 `<html>` 打一个 `data-page-scope`,主题可以对每个页面分别配
整页背景与装饰层。
## 取值
| 值 | 路径 | 说明 |
| --- | --- | --- |
| (无属性) | — | 全局默认 |
| `box` | `/_/{domain}`、`/_/{domain}/tag/*` | 箱主页(问题列表) |
| `question` | `/_/{domain}/{qid}` | 单个问题 |
| `admin` | `/user/questions`、`/user/votes/*` | 题目 / 私聊管理列表 |
| `profile` | `/user`、`/user/profile` 等 | 设置页 |
| `vote` | `/vote/{token}` | 投票页(跟随发布者的主题) |
| `home` / `explore` | `/`、`/explore` | Go 侧会返回,但**这两页不加载主题 css** |
来源:`internal/context.pageScope()`,测试在 `internal/context/context_test.go` 的 `TestPageScope`。
## 选择器与特异性
```css
/* 全局 */
html[data-theme-style="SLUG"] { … }
/* 某一页覆盖 */
html[data-theme-style="SLUG"][data-page-scope="question"] { … }
```
特异性 0,2,1 与深色块 `html[data-theme="dark"][data-theme-style="SLUG"]` 相同,
而覆盖块写在文件后面,**会盖掉深色块**。所以深色必须再写一条三重选择器:
```css
html[data-theme="dark"][data-theme-style="SLUG"][data-page-scope="question"] { … }
```
不写的话,深色模式下会拿到浅色的覆盖块 —— 典型表现是「深色页面里突然出现一块白天场景」。
## 覆盖块写什么
通常只覆盖这三组:
```css
html[data-theme-style="SLUG"][data-page-scope="question"] {
--tb-page-image: none; /* 不放整页场景 */
--tb-page-pattern: none;
--tb-deco: …; /* 10 个值 */
--tb-deco-size: …; /* 10 个值 */
--tb-deco-position: …; /* 10 个值 */
}
```
配色(`--tb-bg` / `--tb-surface` / `--tb-text` / `--tb-primary`)一般不要按页面改:
同一个主题在不同页面换配色,用户会觉得走错站了。
## 10 个槽位
`--tb-deco` / `--tb-deco-size` / `--tb-deco-position` 三组必须**等长且写满 10 个**。
少一个不会报错,CSS 会按列表长度循环取值,于是图层与 size/position 整体错位:
- 整页重复同一张插画(第一次踩坑就是这个)
- 人物跑到页面左上角(`background-position` 整条声明非法时回落初始值)
`internal/theme/slots_test.go` 的 `TestDecoSlotCount` 会逐个文件、逐处声明检查,
包括 base.css 与所有主题 css(含作用域覆盖块)。写主题时不用记,跑测试就行。
`background-position` 一律写成两值 `calc()`:
```css
--tb-deco-position: calc(0% + 40px) calc(100% - 36px),
calc(50% + 0px) calc(50% + 0px), …;
```
- 百分比是相对 **(容器 − 图片)** 的剩余空间,不是相对容器
- `0%` 左/上、`50%` 居中、`100%` 右/下
- **不要**混用 `left` / `bottom` 关键字:「水平居中 + 垂直贴底」会变成三值语法,
一旦不合规范整条声明作废,图层静默掉到左上角
## 可视化编辑
手写这些值不现实,用 `dev/theme-builder/`(见同目录 README):以真实页面为画布,
按页面切换、拖拽定位、导出 zip。它生成的 CSS 直接过 `TestThemeCSSComplete` 与
`TestDecoSlotCount`。
+4 -1
View File
@@ -31,7 +31,10 @@ available = default,sakura,shiroko
| 中层 | 卡片与顶栏:半透明白 + `backdrop-filter` 磨砂 | `--tb-surface: rgba(255,255,255,.86)` / `--tb-navbar` |
| 上层 | 人物站在场景左侧地面(400px 高)+ 椭圆接触阴影 | `--tb-deco` 前两个槽位 |
| 道具 | 她的自行车(车头朝右、与人同向)斜靠一把步枪 | `--tb-deco` 第 3 槽位 |
| 空气层 | 左窗光柱 / 右侧暖散景 / 脚下地面光斑 / 底部压深 | `--tb-deco` 后四个槽位(全是渐变) |
| 空气层 | 左窗光柱 / 右侧暖散景 / 脚下地面光斑 / 底部压深 | `--tb-deco` 第 4~7 槽位(全是渐变) |
> 装饰层共 10 个槽位(见 `internal/theme/slots_test.go`),本主题用前 7 个,其余留空。
> 想给某一页换装饰(例如问题页让人物退场),见 [theme-page-scope.md](theme-page-scope.md)。
| 深色 | 不用场景(白天教室压在深底上会脏),回到深色渐变 + 极淡纹样 | 深色块单独覆盖 |
摆位经验(都是踩过的):
+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
}
+12 -7
View File
@@ -33,9 +33,12 @@
视口比例一变,贴在图片边缘的插画就会被裁掉)。
⚠️ 固定 6 个槽位:background-* 各列表的层数必须恒定(1 图案 + 6 装饰 + 1 背景 = 8),
缺省用 none 占位;值的个数一变,多背景的分层就会整体错位。 */
--tb-deco: none, none, none, none, none, none;
--tb-deco-size: auto, auto, auto, auto, auto, auto;
--tb-deco-position: left top, left top, left top, left top, left top, left top;
/* 装饰层:固定 10 个槽位。主题可按页面作用域整体替换(html[data-page-scope="..."]),
但每组都必须写满 10 个值 —— 数量不足会让 background-* 列表与图层错位。 */
--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"] {
--tb-bg: #1a1d23;
@@ -273,13 +276,15 @@
body {
font-family: var(--tb-font);
background-color: var(--tb-bg);
/* 主题的整页背景:四角装饰 + 平铺图案 + cover 背景图(共 6 层,顺序即绘制顺序) */
/* 主题的整页背景:平铺图案(1) + 装饰层(10) + cover 背景图(1) = 12 层,顺序即绘制顺序。
下面四个列表的层数必须与 12 对齐:少一个就会循环取值,导致图层与 size/position 错位。 */
background-image: var(--tb-page-pattern), var(--tb-deco), var(--tb-page-image);
background-size: var(--tb-page-pattern-size), var(--tb-deco-size), cover;
background-repeat: repeat, no-repeat, no-repeat, no-repeat, no-repeat, no-repeat, no-repeat,
no-repeat;
background-repeat: repeat, no-repeat, no-repeat, no-repeat, no-repeat, no-repeat,
no-repeat, no-repeat, no-repeat, no-repeat, no-repeat, no-repeat;
background-position: left top, var(--tb-deco-position), center center;
background-attachment: scroll, fixed, fixed, fixed, fixed, fixed, fixed, fixed;
background-attachment: scroll, fixed, fixed, fixed, fixed, fixed, fixed, fixed, fixed, fixed,
fixed, fixed;
color: var(--tb-text);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
+37 -10
View File
@@ -35,10 +35,11 @@
页面背景 --tb-page-image 整页背景图(cover,fixed)
--tb-page-pattern 整页图案(平铺,可叠加在背景图之上)
--tb-page-pattern-size 图案平铺尺寸(默认 auto)
装饰 --tb-deco 四角装饰图,固定 6 个槽位(必须写满 6 个,缺的填 none);
固定像素尺寸、不随视口裁切,适合放人物/摆件。默认 none。
--tb-deco-size 对应每张的尺寸,如 auto 200px, auto 150px, auto, auto, auto, auto
--tb-deco-position 对应每张的位置,如 left 1% bottom 0, right 1% top 4%, left top, …
装饰 --tb-deco 装饰图层,固定 10 个槽位(必须写满 10 个,缺的填 none);
固定像素尺寸、不随视口裁切,适合放人物/摆件/光斑。默认 none。
值可以是图片,也可以是 linear-gradient / radial-gradient(纯"光")。
--tb-deco-size 对应每层的尺寸,如 auto 400px, 100% 100%, auto, …(10 个)
--tb-deco-position 对应每层的位置,如 calc(50% - 524px) calc(100% - 36px), …(10 个)
── 素材规格(放 R2,不需要重新编译) ─────────────────────────────
页面背景图 2400×1600(3:2)或 2560×1440,WebP q80,≤300KB;明暗各一张:
@@ -81,12 +82,18 @@ html[data-theme-style="SLUG"] {
--tb-page-pattern: none;
--tb-page-pattern-size: auto;
/* 四角装饰(放人物/摆件)。固定 6 个槽位,必须写满,缺的填 none——
/* 装饰层(放人物/摆件/光)。固定 10 个槽位,必须写满,缺的填 none——
值一多一少,多背景各列表就错位(会出现整页重复同一张图)。
尺寸用「auto <高度>」,位置可写 left 2% bottom 0 / right 2% top 6% / left 2% center。 */
--tb-deco: none, none, none, none, none, none;
--tb-deco-size: auto, auto, auto, auto, auto, auto;
--tb-deco-position: left top, left top, left top, left top, left top, left top;
尺寸用「auto <高度>」;位置统一写 calc:
calc(0% + 40px) 左对齐 + 40px
calc(50% + 0px) 居中
calc(100% - 36px) 右/下对齐 - 36px(百分比是相对「容器 − 图片」的剩余空间)
不要用 left / bottom 关键字混搭 —— 「水平居中 + 垂直贴底」会变成三值语法,
一旦写法不合规范整条声明作废,图层静默掉到左上角。 */
--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;
}
/* 2) 深色(必须)—— 深浅两套必须成对,缺一段会让深色用户看到「浅色的深色主题」 */
@@ -112,7 +119,27 @@ html[data-theme="dark"][data-theme-style="SLUG"] {
--tb-page-pattern-size: auto;
}
/* 3) 组件微调(可选,默认整段注释掉)
/* 3) 按页面作用域覆盖(可选)
服务端会按请求路径给 <html> 打 data-page-scope,取值:
(无属性 = 全局)/ box(箱主页) / question(单题页) / admin(/user/questions)
/ profile(设置页等 /user/* ) / vote(投票页)
典型用法:「问题页正文长,人物退场;题目列表字更密,只留一点光」。
html[data-theme-style="SLUG"][data-page-scope="question"] {
--tb-page-image: none;
--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;
}
⚠️ 覆盖块里同样要写满 10 个槽位。
⚠️ 深色要再写一条三重选择器,否则深色下会被浅色覆盖块污染:
html[data-theme="dark"][data-theme-style="SLUG"][data-page-scope="question"] { … }
⚠️ 没写覆盖块的页面自动沿用全局,不会出现「某页装饰消失」。
首页 / 与 /explore 不加载主题 css,配了也不会生效。 */
/* 4) 组件微调(可选,默认整段注释掉)
只允许「外观」层面的补充:边框、阴影、封面遮罩浓度等。
禁止改尺寸/间距/定位 —— 那属于布局,主题不管。 */
/*
+14 -9
View File
@@ -47,12 +47,15 @@ html[data-theme-style="shiroko"] {
url("/static/images/themes/shiroko/props/bike.webp?v=20261020d"),
linear-gradient(112deg, rgba(255, 250, 236, 0.50) 0%, rgba(255, 250, 236, 0.22) 20%, rgba(255, 250, 236, 0) 44%),
radial-gradient(ellipse 560px 170px at 28% 94%, rgba(255, 248, 228, 0.80) 0%, rgba(255, 248, 228, 0) 72%),
linear-gradient(0deg, rgba(110, 128, 156, 0.10) 0%, rgba(110, 128, 156, 0) 24%);
--tb-deco-size: auto 400px, 96px 13px, auto 259px, 100% 100%, 100% 100%, 100% 100%;
linear-gradient(0deg, rgba(110, 128, 156, 0.10) 0%, rgba(110, 128, 156, 0) 24%),
none, none, none, none;
--tb-deco-size: auto 400px, 96px 13px, auto 259px, 100% 100%, 100% 100%, 100% 100%,
auto, auto, auto, auto;
--tb-deco-position: left max(10px, calc(50% - 524px)) bottom 36px,
left max(22px, calc(50% - 512px)) bottom 29px,
left calc(50% - 612px) bottom 20px,
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="shiroko"] {
@@ -77,9 +80,10 @@ html[data-theme="dark"][data-theme-style="shiroko"] {
--tb-page-image: linear-gradient(180deg, #161d26 0%, #141a22 60%, #11161d 100%);
--tb-page-pattern: url("/static/images/themes/shiroko/pattern-dark.svg?v=20261020d");
--tb-page-pattern-size: 340px 340px;
--tb-deco: none, none, none, none, none, none;
--tb-deco-size: auto, auto, auto, auto, auto, auto;
--tb-deco-position: left top, left top, left top, left top, left top, left top;
--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;
}
/* 窄屏:卡片占满宽度,两侧没有视觉空间 —— 换成竖版场景,人物退场 */
@@ -87,9 +91,10 @@ html[data-theme="dark"][data-theme-style="shiroko"] {
html[data-theme-style="shiroko"] {
--tb-page-image: url("/static/images/themes/shiroko/scene-mobile.webp?v=20261020d");
--tb-surface: rgba(255, 255, 255, 0.92);
--tb-deco: none, none, none, none, none, none;
--tb-deco-size: auto, auto, auto, auto, auto, auto;
--tb-deco-position: left top, left top, left top, left top, left top, left top;
--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;
}
}
+1 -1
View File
@@ -1,5 +1,5 @@
<!DOCTYPE html>
<html lang="{{.LangCode}}"{{ if .ThemeSlug }} data-theme-style="{{ .ThemeSlug }}"{{ end }}>
<html lang="{{.LangCode}}"{{ if .ThemeSlug }} data-theme-style="{{ .ThemeSlug }}"{{ end }}{{ if .PageScope }} data-page-scope="{{ .PageScope }}"{{ end }}>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
+139
View File
@@ -9,6 +9,7 @@ import (
"time"
"github.com/tamakyi/TamaBox/internal/conf"
"github.com/tamakyi/TamaBox/internal/db"
"github.com/tamakyi/TamaBox/internal/stats"
)
@@ -171,6 +172,144 @@ func TestGenPreview(t *testing.T) {
page = inlineThemeCSS(t, page, "sakura")
writePreview(t, outDir, "preview-profile-sakura-"+theme+".html", page, theme, "../static")
}
// ---- 以下是「主题生成器」的预览画板素材 ----
// dev/theme-builder 不再自己画一个仿制页面,而是直接拿这里的真实渲染结果当 iframe 画板;
// 每个页面作用域(data-page-scope)各一份。新增页面时要同步 dev/theme-builder/index.html
// 的 SCOPES 列表,否则生成器里选不到。
writeQuestionItemPreviews(t, outDir)
writeAdminListPreviews(t, outDir)
writeVotePreviews(t, outDir)
}
// writeQuestionItemPreviews 单题页(/_/{domain}/{qid})。
// 这一页正文长、卡片高,装饰层最容易挡住内容,所以必须进预览画板。
func writeQuestionItemPreviews(t *testing.T, outDir string) {
t.Helper()
for _, theme := range []string{"light", "dark"} {
data := publicBaseData()
data["VConsole"] = false
data["Question"] = map[string]interface{}{
"ID": 1, "Content": "你平时一个人骑车会去哪里?", "Answer": "沿着河堤一直往西,到海边的旧仓库就掉头。",
"CreatedAt": time.Now(), "UpdatedAt": time.Now(), "IsPrivate": false, "IsPinned": false,
"Token": "abc123", "ReceiveReplyEmail": "",
}
data["QuestionToken"] = "abc123"
data["AskUploadImages"] = []interface{}{}
data["AnswerUploadImages"] = []interface{}{}
data["FollowUps"] = []interface{}{}
data["CanFollowUp"] = false
data["FollowUpRemaining"] = 0
data["FollowUpLimit"] = 3
data["IsOwnPage"] = true
data["IsQuestioner"] = false
data["IsBlocked"] = false
data["AllowChat"] = true
page, err := renderToString(t, "question/item", data)
if err != nil {
t.Fatalf("render question/item: %v", err)
}
writePreview(t, outDir, "preview-question-"+theme+".html", page, theme, "../static")
}
}
// writeAdminListPreviews 箱主后台的问题列表(/user/questions)。
func writeAdminListPreviews(t *testing.T, outDir string) {
t.Helper()
type adminItem struct {
Type, Content, Link, PinLink string
CreatedAt time.Time
IsAnswered, IsRead, IsPrivate bool
IsBlocked, IsPinned bool
QuestionID uint
ReportCount int64
}
type adminConv struct {
Token string
CreatedAt time.Time
}
type adminConvDetail struct {
Conversation *adminConv
OwnerDomain string
OwnerName string
FirstMessage string
IsBlocked bool
}
for _, theme := range []string{"light", "dark"} {
data := map[string]interface{}{
"Title": "我的提问",
"LangCode": "zh-CN",
"SupportedLangs": []map[string]string{{"Code": "zh-CN", "Name": "简体中文"}},
"IsLogged": true,
"LoggedUser": map[string]interface{}{"Domain": "test", "Name": "tester"},
"LoggedUserName": "tester",
"CurrentURI": "/user/questions",
"IsUserPage": true,
"IsPixel": false,
"VConsole": false,
"CaptchaType": "math",
"PageUser": map[string]interface{}{"Domain": "test", "Name": "tester", "Avatar": ""},
"ICP": "",
"Stats": map[string]int64{
"total": 3, "answered": 2, "unanswered": 1, "read": 2, "unread": 1, "private": 1, "pinned": 1,
},
"ChatStats": map[string]int64{"total": 2, "received": 1, "initiated": 1, "closed": 0},
"Keyword": "",
"SelectedSort": "latest",
"SelectedStatus": "all",
"SelectedRead": "all",
"SelectedType": "all",
"ReceivedItems": []interface{}{
adminItem{Type: "question_public", CreatedAt: time.Now(), Content: "你平时一个人骑车会去哪里?",
Link: "/_/test/1", IsAnswered: true, IsRead: true, QuestionID: 1, IsPinned: true, PinLink: "/_/test/1/pin"},
adminItem{Type: "question_private", CreatedAt: time.Now(), Content: "悄悄话:最近还好吗",
Link: "/_/test/2", IsAnswered: false, IsPrivate: true, QuestionID: 2, PinLink: "/_/test/2/pin"},
adminItem{Type: "question_public", CreatedAt: time.Now(), Content: "光环是什么材质的?",
Link: "/_/test/3", IsAnswered: true, IsRead: false, QuestionID: 3, PinLink: "/_/test/3/pin"},
},
"ReceivedChats": []interface{}{
adminItem{Type: "chat", CreatedAt: time.Now(), Content: "你好", Link: "/_/test/chat/abc"},
},
"ConversationDetails": []interface{}{
adminConvDetail{Conversation: &adminConv{Token: "abc", CreatedAt: time.Now()},
OwnerDomain: "other", OwnerName: "别人", FirstMessage: "hi"},
},
}
page, err := renderToString(t, "user/question-list", data)
if err != nil {
t.Fatalf("render user/question-list: %v", err)
}
writePreview(t, outDir, "preview-admin-"+theme+".html", page, theme, "../static")
}
}
// writeVotePreviews 投票页(/vote/{token}):语言、主题都跟发布者,是唯一会「跨箱主」的页面。
func writeVotePreviews(t *testing.T, outDir string) {
t.Helper()
for _, theme := range []string{"light", "dark"} {
data := voteBaseData()
data["Vote"] = &db.Vote{
Title: "下次直播想看我做什么?", Description: "随便聊聊,投票结果仅供参考。",
Type: db.VoteTypeSingle, LinkToken: "abc123", MaxChoices: 1,
}
data["Owner"] = map[string]interface{}{"Name": "tester", "Domain": "test", "Avatar": ""}
data["Options"] = []*db.VoteOption{
{Content: "一起骑车去海边"}, {Content: "读信 + 回答提问"}, {Content: "整理旧照片"},
}
data["HasVoted"] = false
data["CanVote"] = true
data["NotStarted"] = false
data["Ended"] = false
data["VoteScopeRegisteredOnly"] = false
data["ShowResults"] = false
data["IsOwner"] = false
page, err := renderToString(t, "vote/page", data)
if err != nil {
t.Fatalf("render vote/page: %v", err)
}
writePreview(t, outDir, "preview-vote-"+theme+".html", page, theme, "../static")
}
}
// inlineThemeCSS 把主题 <link> 换成内联 style:预览页走 file://,相对路径取不到。