# 主题生成器 一个可视化编辑主题的小工具:**以项目真实页面为画布**,按页面分别摆装饰,导出 zip 解压即用。 它替换了早先那版「单文件、仿制预览」的生成器 —— 那一版的问题很直接:预览是我自己画的 示意图,跟真实页面长得不一样;而且只能配「背景 + 人物 + 道具」三件套。 ## 为什么需要起一个本地服务 生成器要把 `outputs/preview-*.html`(由 `go test` 渲染出来的**真实页面**)放进 iframe 当画板, 再往里注入正在编辑的主题 CSS。`file://` 下浏览器不允许父页面访问 iframe 的 DOM(即使同目录), 预览就只能退回示意图。起本地服务后父子同源,拖拽、注入、实时改令牌都能工作。 ```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()` 输出到 `` | | 装饰层 | **固定 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}` 投票页 | 默认全部「跟随全局」;勾掉就变成独立配置,导出时才会产出覆盖块。 没配的页面自动沿用全局,不会出现「某页装饰消失」。 以前「跟随全局」时背景下拉和「加一层」都是 disabled —— 用户在箱主页看到背景还在、 又点不动,这就是「背景图关不掉」的来源。现在: - 「加一层」「改背景」永远可点,**一改就自动把这一页切成独立配置**,并把全局当前值复制过来当起点 (改背景不会顺带把图案和图层弄丢;图层重新发 id,避免两个作用域共用一批 id,拖拽/高亮会串); - 这种状态下图层列表不展示全局的层、也不给拖拽框 —— 否则一拖就改到全局去了; - 想主动切换用「跟随全局」复选框,或用列表里的「改为独立配置」按钮。 (首页 `/` 与 `/explore` 不列出来 —— 全站公开页不注入 `ThemeSlug`,不加载主题 css,配了也没效果。) **装饰图层** —— 每个作用域最多 10 层,三种类型: - **图片**:从图片池选,按高度定尺寸(宽度自动等比),选中后可直接在画板上拖动; - **光斑**:`radial-gradient`,给空白一点体积,不占位、不会变成贴纸; - **线性光**:`linear-gradient`,做窗光/压深。 锚点用 9 宫格 + 像素偏移,生成 `calc(0% + 40px) calc(100% - 36px)` 这种两值形式。 **每一行都能直接换图**:行里带缩略图 + 「图片」下拉,一眼看到这层用的是哪张。 之前换图的下拉只出现在「选中那一层」的编辑器里,加上新层又默认绑第一张素材, 连加三层就全是同一张图,看起来像「每层只能显示这张、换不了」。 现在新加的层**不会**自动绑第一张(只有素材池只剩一张时才绑),行里显示「(选图片)」等你选。 **↑ / ↓ 调层级**。顺序就是层级:`background-image` 的第一层画在最上面, 所以「谁挡住谁」是靠顺序定的(shiroko 里自行车被人挡住就是这么来的)。 **窄屏** —— 手机上卡片几乎占满屏幕,桌面那套 16:9 场景会被 `cover` 裁成一条无意义纹理, 固定 px 的人物会被卡片盖住。这一栏可以:指定一张竖版背景图、决定装饰是否退场、 改断点(默认 900px)。导出时生成对应的 `@media` 块,只写浅色规则 (深色块特异性更高,本来就压住它)。 **图片池** —— 上传任意张,浏览器本地转 WebP,画布尺寸 = 原图尺寸(不缩放、不留透明边)。 导出时只打包实际用到的图。每张图左上角有 `fx` 按钮,进处理面板(处理过的会高亮)。 ### 图片处理 | 功能 | 说明 | | --- | --- | | 不透明度 | 0~100%。CSS 背景图没法单独设 `opacity`,只能烘焙进像素 | | 压白 / 雾度 | `v×(1-k)+255×k`。场景压白 38% 时人物常用 12~26%,否则是全页对比度最高的一块 | | 去色 | 往灰度混。彩色素材压不住素场景时用 | | 裁掉透明边 | 生成图四周留白多时裁掉,否则按高度定尺寸会让主体看着比实际小 | | 去白底 | 四边漫水 + 封闭白孔检测,默认容差 16 | | 边缘收 1px | 去白底后把残留的半透明白边收掉,代价是轮廓缩一圈 | **非破坏**:原文件字节存在 `orig` 里,`fx` 只存参数,每次处理都从 `orig` 重算。 所以「压白 16% → 改成 20%」不会叠加两次压缩,反复调参也不会把边缘越磨越糊。 打开面板看一眼就关,一个像素都不会动(参数没变就跳过重编码)。 **去白底的两个关键点**(都是踩过的): - **封闭白孔**:手内侧、两腿之间那种被轮廓包住的白色,四边漫水根本进不去, 必须单独按连通域掏(面积 ≥ 24 才掏,更小的多半是眼睛高光)。可以关掉。 - **容差别贪**:背景常见 253,白袜子 237,只差 16。容差 16 保得住袜子,30 就吃穿了。 实测白底立绘去白底后透明占比 46.3%,与手写 floodfill 抠出来的 46.6% 差 0.3%。 ## 工程文件与草稿 调主题是连续几十次的活,所以配置必须留得住: - **新建**:顶栏按钮,清空当前工程并删掉浏览器草稿(有二次确认)。 之前只有 `?fresh=1` 这个隐藏参数 —— 用户看到「上次的数据」一直在,就会以为关不掉。 - **自动草稿**:每次改动后 700ms 存进 `localStorage`,下次打开自动恢复。 图片多到顶破 5MB 配额时会提示改用 json(只提示一次)。 - **保存工程 / 打开工程**:`theme-.json`,图片以 base64 内嵌,一个文件就能带走; 可以进版本库,也可以发给别人接着改。 - 不想恢复草稿就加 `?fresh=1`。 ## 导出 点「导出 zip」得到: ``` static/css/themes/.css static/images/themes//*.webp INSTALL-.txt ``` 点导出时会先跑一遍校验(slug 合法、图片层是否选了图、有没有引用已删除的素材、 装饰是否超过 10 层、生成出来的 CSS 每处装饰是不是真的 10 个值),有问题直接列出来, 不会让你导出后再去等 `go test`。 解压到项目根,然后按 `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` 只给要粘贴的那一行。 - **不碰深色的图片**:深色块一律 `--tb-page-image: none` + 装饰全关(白天场景压在深底上会脏)。 想在深色下也放图,需要手工加深色块。 ## 自检 没有 UI 也能回归,两个钩子: ``` ?demo=1 载入示例素材(canvas 画的背景 + 剪影人物) ?rows=2 加 2 层并依次绑到不同素材(截图多图层效果用) ?scope=admin 直接落在某个作用域 ?fresh=1 忽略 localStorage 草稿,从空白开始 ?dump=css 把最终 CSS 写进
?dump=zip          把 zip 的 base64 写进 
?dump=validate     把 validate() 的结果写进 
?dump=install      把安装说明写进 
?dump=fx           跑处理算法自检(13 个断言,输出 JSON)
?dump=fxpipe       跑弹窗链路冒烟(打开→改参→预览→落盘→无改动不重编码)
?dump=layers       图层/作用域回归(加层默认不绑图、行内换图、自动切独立、新建清空)
?dump=fxkey        端到端去白底:载入白底图,统计透明像素比例
?loadurl=/static/images/x.webp&fx=x
                   直接把项目里的图当素材载入(不必先下载再上传),fx= 打开处理面板
?fxjson={"key":{"on":true}}
                   配合 loadurl 给初始处理参数
```

```bash
chrome --headless=old --virtual-time-budget=6000 \
  --dump-dom "http://127.0.0.1:8777/dev/theme-builder/?demo=1&dump=css"
```

把抓到的 CSS 存成 `static/css/themes/.css`,可跑:

```bash
go test ./internal/theme -run "TestThemeCSSComplete|TestDecoSlotCount"
```

`TestDecoSlotCount` 是这次新增的:它会逐个检查 `base.css` 与所有主题 css 里每一处
`--tb-deco*` 声明是否都有 10 个值。少写一个不会报错,只会让图层与 size/position 错位
(表现为整页重复同一张插画,或人物跑到左上角),靠注释守不住。