主题素材只有一处引用:主题 css 的 url()(分享卡是 Go 用 imaging 画的,不读素材), 所以只要让 CSS 里的地址指向对象存储即可。 - 导出面板新增「素材前缀」:空 = /static/images/themes/<slug>/(图片随二进制发布,与以前一致); 填 https://cdn.example.com/themes = CSS 写绝对 URL,zip 里的图片改为「上传用」 - 地址统一由 urlOf() 决定。修了一个重复实现:layerCSS 里复制过一份路径拼接, 只改 urlOf 会出现「背景走 CDN、装饰还指本地」 - 前缀随意切换不影响预览(预览恒用内联 dataURL);存进工程 json;?assetbase= 可自检 - INSTALL 文案按是否托管分叉,写清图片传到哪、换域名要重新导出 - 新增 docs/theme-assets-cdn.md:三种做法(生成器前缀 / 应用内 301 / 边缘改写)、 缓存与 ?v=、CORS 不需要、深色无素材、迁移用的 sed 命令 - README 更新;UI 修正:240px 面板里 4 字标签会被 flex 压窄,需 flex:0 0 auto + 84px
18 KiB
主题生成器
一个可视化编辑主题的小工具:以项目真实页面为画布,按页面分别摆装饰,导出 zip 解压即用。
它替换了早先那版「单文件、仿制预览」的生成器 —— 那一版的问题很直接:预览是我自己画的 示意图,跟真实页面长得不一样;而且只能配「背景 + 人物 + 道具」三件套。
为什么需要起一个本地服务
生成器要把 outputs/preview-*.html(由 go test 渲染出来的真实页面)放进 iframe 当画板,
再往里注入正在编辑的主题 CSS。file:// 下浏览器不允许父页面访问 iframe 的 DOM(即使同目录),
预览就只能退回示意图。起本地服务后父子同源,拖拽、注入、实时改令牌都能工作。
# 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% |
因为是同一套机制,工具里拖出来的位置换视口不会跑位,也不会出现「预览好看、实机错位」。
三块面板
页面 —— 每个页面作用域可以单独配,各有一份背景与装饰:
| 作用域 | 对应路径 |
|---|---|
| 全局 | 所有页面默认 |
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 裁成一条无意义纹理。
三段优先级从高到低:
- 隐藏全部装饰层(急停) —— 一键全关,最省事;
- 每层的窄屏策略:原样 / 缩小到 N%(缩放系数默认 62%)/ 隐藏。缩小是把高度与偏移 一起乘系数,锚点不变,所以「贴底靠左」的构图缩完还在原位;
- 尺寸单位:
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 够用 |
| 素材前缀 | 空 | 空 = 图片放 static/images/themes/<slug>/ 随二进制发布;填 https://cdn.example.com/themes = CSS 写绝对地址,图片自己传对象存储(R2/OSS)。前缀里不要带 slug |
导出的 CSS 只有 urlOf() 一处决定图片地址,图层、背景、窄屏块全走它,不会出现
「背景走 CDN、装饰还指本地」的半吊子状态。素材前缀只影响导出,预览永远是内联 dataURL。
详细取舍(生成器前缀 / 应用内 301 / 边缘改写)见 docs/theme-assets-cdn.md。
面板底部实时显示 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 做三步:
conf/app.ini的[theme] available加上 slug;internal/conf/static.go的StaticVersion+1;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 给初始处理参数
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,可跑:
go test ./internal/theme -run "TestThemeCSSComplete|TestDecoSlotCount"
TestDecoSlotCount 是这次新增的:它会逐个检查 base.css 与所有主题 css 里每一处
--tb-deco* 声明是否都有 10 个值。少写一个不会报错,只会让图层与 size/position 错位
(表现为整页重复同一张插画,或人物跑到左上角),靠注释守不住。