Files
tamabox/dev/theme-builder/README.md
T
tamakyi 27856dd4a2 字号阶梯收尾 + 主色补到 WCAG AA(新增 --tb-on-primary)
一号事项(剩余令牌化):
- templates/user/profile.html 20 处、base.css 9 处 font-size 全部落到 --tb-fs-*
- base.css 导航四档(0.95/0.88/0.82/0.78)合并成「桌面 md → ≤1000 xs」;logo 三档统一 lg
- 有理由保留的:.tb-pin-flag / .md-body code 的 0.9em(相对父级)、验证码 1.4/1.2rem(等宽字形)、
  chat 关闭按钮 40px、password 页 3em、mail/*(邮件客户端不保证 var());行内 12px/0.75rem 等值顺手换掉

二号事项(主色对比度):
- base/default #1e87f0(3.42) → #0e6fd0(4.70);shiroko #4a7fc1(3.69) → #3b6dac(4.74);
  sakura #e5678f(2.80) → #ba3e66(4.70,饱和度压到 .5 保持柔和)。同色相只压亮度,目标 4.7 留余量
- 深色块 muted 之前只对 --tb-bg 算(4.7 以为过了),对压平后的卡片其实 4.31/4.17 → 提到 #a18790 / #7f8ea1
- 新增 --tb-on-primary(亮 #ffffff / 暗 #0b1016):深色模式主色偏亮,白字只有 2.2~3.1:1,
  用同一个令牌兼顾「链接色要深」和「按钮底要深」是矛盾的。.uk-button-primary 与
  .uk-label:not(语义色) 改吃它 → 深色按钮变成浅底深字(6.10 / 8.65 / 7.27)
- 顺手封掉几处主色泄漏:header 验证码 focus、profile .feat-day.is-on、explore 随机投票的 JS outline、
  vote-results 行内色,全部改成令牌(否则换主题时不跟着变)

生成器同步:paletteVars 导出 --tb-on-primary(白字/深字择优);?dump=palette 增加
onPrimaryCr / onPrimaryPickedBest / warnings —— 主色卡中间调时只警告不判失败;
配色面板给橙色提示。_scaffold 令牌 29 → 30。StaticVersion → 20261021c。

验证:TestSwatch 断言同步为 #ba3e66;8 种组合(base 亮暗 + 三主题亮暗)对比度全部 ≥4.5;
go test internal/{theme,templates,conf,i18n} 全绿;生成器 validate/fit/quality/palette/layers/fx/fxpipe 全过。
2026-10-21 18:39:08 +08:00

300 lines
18 KiB
Markdown
Raw 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.
# 主题生成器
一个可视化编辑主题的小工具:**以项目真实页面为画布**,按页面分别摆装饰,导出 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()` 输出到 `<html>` |
| 装饰层 | **固定 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-attachment: fixed`),所以同一个 `400px` 在 900 高的屏幕上占
44%、在 768 的旧笔电上占 52%,窄屏上直接被切掉。尺寸单位就是回答「屏幕变小的时候这张图怎么办」:
| 单位 | 生成 | 适合 | 低于 100% 视口时的兜底 |
| --- | --- | --- | --- |
| `fit` 自适应·按高度 | `auto clamp(min(calc(100vh - 24px), 231px), 46.67vh, 420px)` | 人物立绘、竖幅道具 | 下限让位给视口,永远不纵向溢出 |
| `fitw` 自适应·按宽度 | `clamp(min(calc(100vw - 24px), 587px), 70.57vw, 1067px) auto` | 横幅、整屏插画 | 下限让位给视口,永远不横向溢出 |
| `px` 固定 | `auto 420px` | 需要绝对尺寸(如对齐某个 UI 元素) | 不缩,会裁 |
| `vh` 视口比例 | `auto 46.67vh` | 一定要占同样屏高的元素 | 无下限 |
**为什么必须有两种自适应**:`fit` 只按高度缩,宽度跟着比例涨 —— 一张 16:9 场景 `h=600` 在 1024 宽下
是 910px 刚好,在 390 的竖屏上按高度缩完还有 1001px,照样裁两侧。宽幅素材只能按宽度缩。
`fitw` 就是干这个的。实测同一张 1920×1080 素材:
| 视口 | `px 600` | `fit`(按高) | `fitw`(按宽) |
| --- | --- | --- | --- |
| 1512×900 | 1067 宽 | 1067 宽 | 1067 宽 |
| 1024×768 | 1067 宽 | 910 宽 | 723 宽 |
| 390×844 | 1067 宽(裁) | 1001 宽(裁) | 366 宽(完整) |
`min(calc(100vh - 24px), 231px)` 这一段是关键:`clamp()` 的下限如果固定成「基准的 55%」,
屏幕小到连 55% 都放不下时还是会溢出。让下限与视口取小值,自适应单位就不会把图撑出屏幕。
**每个图层底部有一行实测**:`1512×900 下显示 131×420,完整可见(源 300×960)`,
溢出时会变成橙色 `⚠ 1024×768:左 88px / 右 88px 越界`,并给一个「按 1024×768 修好」按钮
(改成固定 px 并算出刚好放下的尺寸)。视口栏可以切 1512/1280/1024/820/390 五档 —— 都是真视口高,
不是只改宽度,因为裁切几乎全由高度决定。
导出前还会再扫一遍,把「哪些层在哪些视口下越界」附在结果里(只提醒,不阻止 ——
故意让插画出血也是合法设计)。
**每一行都能直接换图**:行里带缩略图 + 「图片」下拉,一眼看到这层用的是哪张。
之前换图的下拉只出现在「选中那一层」的编辑器里,加上新层又默认绑第一张素材,
连加三层就全是同一张图,看起来像「每层只能显示这张、换不了」。
现在新加的层**不会**自动绑第一张(只有素材池只剩一张时才绑),行里显示「(选图片)」等你选。
**↑ / ↓ 调层级**。顺序就是层级:`background-image` 的第一层画在最上面,
所以「谁挡住谁」是靠顺序定的(shiroko 里自行车被人挡住就是这么来的)。
**窄屏** —— 手机上卡片几乎占满屏幕,桌面那套 16:9 场景会被 `cover` 裁成一条无意义纹理。
三段优先级从高到低:
1. **隐藏全部装饰层(急停)** —— 一键全关,最省事;
2. **每层的窄屏策略**:原样 / 缩小到 N%(缩放系数默认 62%)/ 隐藏。缩小是把高度与偏移
一起乘系数,锚点不变,所以「贴底靠左」的构图缩完还在原位;
3. **尺寸单位**:`fit` / `fitw` 的层浏览器会自己按视口缩,**不依赖这段规则**。
这一栏还能指定竖版背景图、改断点(默认 900px)。导出时生成 `@media` 块,只写浅色规则
(深色块特异性更高,本来就压住它)。
⚠️ `@media` 里**每个独立配置的页面都要各写一条** `html[data-theme-style][data-page-scope="x"]`:
页面覆盖块特异性是 (0,2,1),只写 `html[data-theme-style]`(0,1,1)的话,
独立配置的页面在手机上根本不生效(旧版本就是这个 bug)。
⚠️ 竖版图只替换**本来就有背景图**的页面。某页故意配成纯色(`--tb-page-image: none`),
手机上新塞一张竖图等于改了他的设计。
**整页背景 · 焦点** —— 背景图是 `cover`,宽高比不匹配就必裁。九宫格焦点决定裁哪一边:
16:9 场景在竖屏上只剩中间一条,人物偏一侧时把焦点挪过去才不会裁到脸。
生成 `--tb-page-position`(`base.css` 里以 `var(--tb-page-position, center center)` 兜底,
旧主题不写这个令牌也不会坏)。
**配色** —— 只填 6 个基础色(底色 / 卡片 / 顶栏 / 正文 / 主色 / 边框),
其余 4 个派生:`surface-2` 往正文靠、`surface-hover` 往主色靠、`primary-weak` 加 12% 透明、
`text-secondary` / `text-muted` 往底色淡出。
⚠️ **派生文字色有对比度下限,不是固定比例**。正文往背景淡出时,二分找到「最淡但仍 ≥ 目标」的位置:
次要文字 5.5:1、弱化文字 4.5:1(WCAG AA 正文)。旧版按「混 55%」硬算,
在浅底上直接掉到 2.9:1 —— `base.css` 特意把 `--tb-text-muted` 从 `#8a919c` 提到 `#6b7280`(4.83:1),
生成器按固定比例算等于每导出一次就把那个修复覆盖回去。
- 卡片是半透明(`rgba(255,255,255,0.86)`)时先**压平到页面底色**再算,否则白卡会被当成纯白、对比度偏低;
- 同时检查页面底色与卡片两个背景,取更不利的那个;
- 手填的正文本身就达不到目标时**原样返回**(不再往淡里调)——宁可不够淡,不能产出看不见的字;
- 面板底部有读数 `正文 12.5:1 · 次要 5.5:1 · 淡文字 4.6:1 · 主色 4.6:1`,低于 4.5 变橙(手填色只提醒不拦)。
主色除了当链接色,还当按钮/标签的**背景**,所以还要交代「压在上面的字」:
导出时写 `--tb-on-primary`,在白字和深字(`#0b1016`)里挑对比度更高的那个。
浅色主题主色深 → 白字;深色主题主色普遍偏亮(`#5c8dff` 之流)→ 白字只有 2.2~3.1:1,必须换深字。
主色卡在**中间调**时两种字都不够 —— 面板会给橙色提示「压深一点配白字,或提亮一点配深字」,
`?dump=palette` 里也会列进 `warnings`(不判失败:那是主色要调,不是工具挑错)。
顺带:这套字号阶梯(`--tb-fs-xs…hero`)来自 `base.css`,生成器**不碰字号** ——
主题的职责是配色与背景,改字号会连带挤坏所有页面排版。
**图片池** —— 上传任意张,浏览器本地转 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%。
## 导出画质
WebP 体积 ≈ 像素数 × 质量。**真正的大头是像素**:CSS 里只显示 400px 高的立绘,
2 倍屏也只要 800px,上传 3000px 的图纯属白扛。所以顺序是:先砍长边,再降质量。
| 设置 | 默认 | 说明 |
| --- | --- | --- |
| 质量 | 0.88 | 0.85~0.9 肉眼几乎看不出差别,低于 0.8 才明显糊 |
| 长边上限 | 1600 | 只缩不放。整页背景建议 1920/不限制;装饰人物 1200 够用 |
面板底部实时显示 `3 张,共 19KB;最大一张 bg.webp 7KB(1920×1080)`。
改完立刻按「原图 + fx 参数」重编码全部素材,并报出省了多少 —— 与图片处理同一条非破坏原则,
来回试不会叠加压缩。
⚠️ 两个反直觉的实测结论(都在 `?dump=quality` 里做了断言):
- **`quality = 1` 不能用**。Skia 在这个值会切到 WebP 无损模式,输出反常:同一张噪声图
q=1 只有 52KB,q=0.95 是 440KB。滑块上限卡 0.95,`canvasAsset` 里也夹一道。
- **平涂/线稿降质量可能变大**。有损编码对纯色会引入噪点,噪声图 q 从 0.6 到 0.95 体积涨 75%,
而平涂图怎么调都是 1KB —— 不能承诺「降 q 一定更小」。这类素材优先调长边。
缩小走 `imageSmoothingQuality = 'high'`,一次缩太多也不会糊成一团。
## 工程文件与草稿
调主题是连续几十次的活,所以配置必须留得住:
- **新建**:顶栏按钮,清空当前工程并删掉浏览器草稿(有二次确认)。
之前只有 `?fresh=1` 这个隐藏参数 —— 用户看到「上次的数据」一直在,就会以为关不掉。
- **自动草稿**:每次改动后 700ms 存进 `localStorage`,下次打开自动恢复。
图片多到顶破 5MB 配额时会提示改用 json(只提示一次)。
- **保存工程 / 打开工程**:`theme-<slug>.json`,图片以 base64 内嵌,一个文件就能带走;
可以进版本库,也可以发给别人接着改。
- 不想恢复草稿就加 `?fresh=1`。
## 导出
点「导出 zip」得到:
```
static/css/themes/<slug>.css
static/images/themes/<slug>/*.webp
INSTALL-<slug>.txt
```
点导出时会先跑一遍校验(slug 合法、图片层是否选了图、有没有引用已删除的素材、
装饰是否超过 10 层、生成出来的 CSS 每处装饰是不是真的 10 个值),有问题直接列出来,
不会让你导出后再去等 `go test`。
导出结果会报「几个文件 / 总共多大 / 其中几张图共多大」,并附上「哪些层在哪些视口下越界」——
看到 `其中 4 张图共 3.2MB` 就该回去调长边上限了。
解压到项目根,然后按 `INSTALL-<slug>.txt` 做三步:
1. `conf/app.ini` 的 `[theme] available` 加上 slug;
2. `internal/conf/static.go` 的 `StaticVersion` +1;
3. `go build`(模板与静态资源都是编译期 embed,只重启不重编不会变)。
设置页下拉里显示的**就是 slug**。想要中文/日文名,到 `internal/i18n/dict_*.go` 加
`theme.name.<slug>` 词条(四份词典都要加)。不加也不会露 key —— 模板走 `themeName`
函数,缺词条时回退成 slug(见 `internal/theme.DisplayName`)。
⚠️ **窄屏段导出前先看一眼「窄屏隐藏全部装饰层」有没有勾着**:勾着的话 `@media` 会把
10 个槽位全写成 `none`,桌面完全正常、手机上一个素材都没有 —— 这是「素材在窄屏下消失」
最常见的来源。现在勾选时面板会给橙色警告、图层行标「窄屏隐藏」、加层和导出时也会再提醒一次。
## 它不做什么
- **不做复杂修图**:没有笔刷、蒙版、自由裁切、调色曲线 —— 这些该在专门的工具里做完再进来。
- **不做调色**:想统一「雾度」(人物压白 16%、场景 30~38%)得在生成端做,
见 `docs/theme-shiroko-assets-spec.md`。
- **不写配置**:`[theme] available` 只给要粘贴的那一行。
- **不碰深色的图片**:深色块一律 `--tb-page-image: none` + 装饰全关(白天场景压在深底上会脏)。
想在深色下也放图,需要手工加深色块。
## 自检
没有 UI 也能回归,两个钩子:
```
?demo=1 载入示例素材(canvas 画的背景 + 剪影人物)
?narrow=hide 直接落在「窄屏隐藏全部装饰层」状态(复现「素材在手机上不见了」)
?rows=2 加 2 层并依次绑到不同素材(截图多图层效果用)
?scope=admin 直接落在某个作用域
?fresh=1 忽略 localStorage 草稿,从空白开始
?dump=css 把最终 CSS 写进 <pre id="dump">
?dump=zip 把 zip 的 base64 写进 <pre id="dump">
?dump=validate 把 validate() 的结果写进 <pre id="dump">
?dump=install 把安装说明写进 <pre id="dump">
?dump=fx 跑处理算法自检(13 个断言,输出 JSON)
?dump=fxpipe 跑弹窗链路冒烟(打开→改参→预览→落盘→无改动不重编码)
?dump=layers 图层/作用域回归(加层默认不绑图、行内换图、自动切独立、新建清空)
?dump=fit 尺寸自适应回归(五档视口占用、溢出检测、fitw、旧工程兼容、@media 覆盖各作用域)
?dump=quality 画质回归(q 单调性、q=1 被夹到 0.95、长边上限真的压像素)
?dump=palette 派生文字色的对比度回归(四种配色下 ≥AA、层级不倒挂、手填低对比不继续变淡)
?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/<slug>.css`,可跑:
```bash
go test ./internal/theme -run "TestThemeCSSComplete|TestDecoSlotCount"
```
`TestDecoSlotCount` 是这次新增的:它会逐个检查 `base.css` 与所有主题 css 里每一处
`--tb-deco*` 声明是否都有 10 个值。少写一个不会报错,只会让图层与 size/position 错位
(表现为整页重复同一张插画,或人物跑到左上角),靠注释守不住。