From ddee221de2446e09dc1b884f0ad1337fb87fa1aa Mon Sep 17 00:00:00 2001 From: tamakyi Date: Tue, 20 Oct 2026 10:53:44 +0800 Subject: [PATCH] =?UTF-8?q?feat(theme):=20=E4=B8=BB=E9=A2=98=E6=94=AF?= =?UTF-8?q?=E6=8C=81=E6=8C=89=E9=A1=B5=E9=9D=A2=E5=88=86=E5=88=AB=E9=85=8D?= =?UTF-8?q?=E8=A3=85=E9=A5=B0=EF=BC=8C=E7=94=9F=E6=88=90=E5=99=A8=E6=94=B9?= =?UTF-8?q?=E7=94=A8=E7=9C=9F=E5=AE=9E=E9=A1=B5=E9=9D=A2=E7=94=BB=E5=B8=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 原来的生成器只能配「背景 + 人物 + 道具」三件套,预览还是自己画的仿制页面 —— 跟真实页面不像,也没法给不同页面配不同内容。 机制层: - internal/context.pageScope() 按路径归类,输出 : 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」生效、 未覆盖时回退全局。 --- dev/theme-builder/README.md | 129 ++- dev/theme-builder/index.html | 1823 ++++++++++++++++++------------ dev/theme-builder/serve.py | 69 ++ docs/theme-page-scope.md | 86 ++ docs/theme-shiroko.md | 5 +- internal/conf/static.go | 2 +- internal/context/context.go | 58 + internal/context/context_test.go | 34 + internal/theme/slots_test.go | 108 ++ static/css/base.css | 19 +- static/css/themes/_scaffold.css | 47 +- static/css/themes/shiroko.css | 23 +- templates/base/header.html | 2 +- templates/zz_preview_test.go | 139 +++ 14 files changed, 1748 insertions(+), 796 deletions(-) create mode 100644 dev/theme-builder/serve.py create mode 100644 docs/theme-page-scope.md create mode 100644 internal/theme/slots_test.go diff --git a/dev/theme-builder/README.md b/dev/theme-builder/README.md index bb43cfe..928a162 100644 --- a/dev/theme-builder/README.md +++ b/dev/theme-builder/README.md @@ -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-.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()` 输出到 `` | +| 装饰层 | 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/.css +static/images/themes//*.webp +INSTALL-.txt +``` + +解压到项目根,然后按 `INSTALL-.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 写进
+?dump=zip          把 zip 的 base64 写进 
 ```
 
-会把生成的 CSS 与 zip 字节以 JSON 塞进 `
`,可用于回归:
-把 `css` 写成 `static/css/themes/.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/.css`,可跑:
+
+```bash
+go test ./internal/theme -run "TestThemeCSSComplete|TestDecoSlotCount"
+```
+
+`TestDecoSlotCount` 是这次新增的:它会逐个检查 `base.css` 与所有主题 css 里每一处
+`--tb-deco*` 声明是否都有 10 个值。少写一个不会报错,只会让图层与 size/position 错位
+(表现为整页重复同一张插画,或人物跑到左上角),靠注释守不住。
diff --git a/dev/theme-builder/index.html b/dev/theme-builder/index.html
index 4433e1f..5eb1a5f 100644
--- a/dev/theme-builder/index.html
+++ b/dev/theme-builder/index.html
@@ -2,854 +2,1199 @@
 
 
 
-
 TamaBox 主题生成器
-
 
 
 
-
-

TamaBox · 主题生成器

- 选图 → 调位置 → 导出 zip,解压到项目根目录即可 -
- - -
+
+ TamaBox 主题生成器 + + + + + 检测中… +
+
-
+
-
- - - -
-
- - -
- - 拖虚线框调整位置 + 视口 + + 模式 + +
-
-
- -
-
-
-
狼的提问箱
@admin6 · 收到 128 个问题 · 回答 96 个
-
-
全部已答待答
-
-

最近在看什么书?

-
《雪国》——冷得像今天的风。
-

怎么才能不被议题绑架?

-

有推荐的通勤耳机吗

-
-
-
社区规则备案号已运行 412 天
-
先上传一张背景图或点右上角「载入示例」先看效果
-
人物
-
道具
-
+
+
+ 需要本地服务才能预览真实页面 + 在项目根执行: + python dev/theme-builder/serve.py + 然后打开 http://127.0.0.1:8777/dev/theme-builder/
- diff --git a/dev/theme-builder/serve.py b/dev/theme-builder/serve.py new file mode 100644 index 0000000..5335d8e --- /dev/null +++ b/dev/theme-builder/serve.py @@ -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() diff --git a/docs/theme-page-scope.md b/docs/theme-page-scope.md new file mode 100644 index 0000000..dc3cdf2 --- /dev/null +++ b/docs/theme-page-scope.md @@ -0,0 +1,86 @@ +# 主题的页面作用域(data-page-scope) + +一套主题过去只能有一种装饰:人物永远站在左下角,不管当前是箱主页、单题页还是题目列表。 +单题页正文长、题目列表信息密,同一套装饰在三种页面上要同时成立很难。 + +现在服务端会按请求路径给 `` 打一个 `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`。 diff --git a/docs/theme-shiroko.md b/docs/theme-shiroko.md index 9984ca4..630bc6a 100644 --- a/docs/theme-shiroko.md +++ b/docs/theme-shiroko.md @@ -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)。 | 深色 | 不用场景(白天教室压在深底上会脏),回到深色渐变 + 极淡纹样 | 深色块单独覆盖 | 摆位经验(都是踩过的): diff --git a/internal/conf/static.go b/internal/conf/static.go index 9980109..b624e1b 100644 --- a/internal/conf/static.go +++ b/internal/conf/static.go @@ -10,7 +10,7 @@ var ( BuildCommit = "dev" // StaticVersion 静态资源缓存版本号:base.css 与主题 css 共用,改样式只改这一处。 - StaticVersion = "20261020d" + StaticVersion = "20261020e" ) // 文本审核通道取值([security] text_censor_source)。定义在 conf 侧, diff --git a/internal/context/context.go b/internal/context/context.go index 89cefa7..7fe3543 100644 --- a/internal/context/context.go +++ b/internal/context/context.go @@ -240,6 +240,12 @@ func Contexter() flamego.Handler { isUserPage := strings.HasPrefix(path, "/user") || strings.HasPrefix(path, "/_/") c.Data["IsUserPage"] = isUserPage + // 页面作用域:让同一套主题能在不同页面摆不同装饰(箱主页/问题页/设置页…)。 + // 模板通过 输出,主题 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 把请求路径归类成「主题页面作用域」。 +// +// 目的:同一个主题在不同页面可以摆不同装饰(箱主页放人物、问题页只留光、设置页干脆不放图), +// 而不必为每种页面复制一整套主题。模板把它输出到 , +// 主题 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 +} diff --git a/internal/context/context_test.go b/internal/context/context_test.go index 1c25dff..c4d3e71 100644 --- a/internal/context/context_test.go +++ b/internal/context/context_test.go @@ -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) + } + } +} diff --git a/internal/theme/slots_test.go b/internal/theme/slots_test.go new file mode 100644 index 0000000..8966de7 --- /dev/null +++ b/internal/theme/slots_test.go @@ -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 +} diff --git a/static/css/base.css b/static/css/base.css index a7b8253..4a9efb0 100644 --- a/static/css/base.css +++ b/static/css/base.css @@ -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; diff --git a/static/css/themes/_scaffold.css b/static/css/themes/_scaffold.css index f904f60..3fc8c0d 100644 --- a/static/css/themes/_scaffold.css +++ b/static/css/themes/_scaffold.css @@ -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) 按页面作用域覆盖(可选) + 服务端会按请求路径给 打 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) 组件微调(可选,默认整段注释掉) 只允许「外观」层面的补充:边框、阴影、封面遮罩浓度等。 禁止改尺寸/间距/定位 —— 那属于布局,主题不管。 */ /* diff --git a/static/css/themes/shiroko.css b/static/css/themes/shiroko.css index d9c85da..aa70f00 100644 --- a/static/css/themes/shiroko.css +++ b/static/css/themes/shiroko.css @@ -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; } } diff --git a/templates/base/header.html b/templates/base/header.html index 8060873..67f3f7f 100644 --- a/templates/base/header.html +++ b/templates/base/header.html @@ -1,5 +1,5 @@ - + diff --git a/templates/zz_preview_test.go b/templates/zz_preview_test.go index c0aaa29..eab9ddd 100644 --- a/templates/zz_preview_test.go +++ b/templates/zz_preview_test.go @@ -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 把主题 换成内联 style:预览页走 file://,相对路径取不到。