主题生成器:素材尺寸按视口自适应 + 导出画质可调

- 尺寸单位 4 档:fit(按高)/fitw(按宽)/px/vh,输出 auto clamp(min(calc(100vh-24px),55%),基准,上限),下限让位视口 → 自适应单位永不把图撑出屏幕
- fitw 补上宽幅素材:fit 只按高度缩,16:9 素材在 390 竖屏仍出 1001px 宽(照裁)
- 视口预设带高度(含 390×844);layerMetrics 统一出 CSS/预览框/溢出检测,图层编辑区显示实测尺寸并报越界,可一键按当前视口修好
- 窄屏每层策略(原样/缩小/隐藏)+ 缩放系数;修 @media 只覆盖全局、独立配置页面在手机上从不生效的特异性 bug
- 导出画质:质量滑块(上限 0.95,Skia 在 q=1 切无损会反常)+ 长边上限,改动后从原图重算
- base.css 新增 --tb-page-position(默认 center center,旧主题不受影响),StaticVersion +1
- 新增 ?dump=fit / ?dump=quality 自检,README 与 _scaffold.css 同步
This commit is contained in:
tamakyi
2026-10-20 17:51:31 +08:00
parent d2a5177afe
commit 4bd600fb46
7 changed files with 906 additions and 192 deletions
+45
View File
@@ -217,3 +217,48 @@ iframe 内部仍是 1512,媒体查询按 1512 判定 → 结论是错的。
回归(`?dump=layers`):加层后 `[null,null]`、行内下拉 2 个、换第 2 层后 `[null,blue]` 且 CSS 只出现 blue;
box 页 inherit 时 `addDisabled/bgDisabled` 都 false;在 box 加层后本页 3 层而**全局仍 2 层**;
新建后 assets 0、层 0、草稿已删、scope 回 global。
## 17:5x 生成器:视口自适应 + 导出画质
用户两个问题:插入素材后不同屏幕被裁、导出的 webp 太大。都在 `dev/theme-builder/index.html` 里解决。
**尺寸单位 4 档**(`l.unit`):`fit`(按视口高,人物)/ `fitw`(按视口宽,横幅)/ `px` / `vh`。
输出 `auto clamp(min(calc(100vh - 24px), 231px), 46.67vh, 420px)` 这种。
- 关键点 1:**内层 `min(视口 - 24px, 基准×55%)`** —— 下限让位给视口,自适应单位因此永不溢出。
写死 55% 下限的话,390 竖屏放不下 1067 宽的图,照样裁。
- 关键点 2:**必须有 `fitw`**。`fit` 只按高度缩,宽幅素材宽度按比例涨:1920×1080 素材 h=600 在
390 视口下 fit 出 1001px 宽(裁),fitw 出 366px(完整)。实测见 `?dump=fit` 的 `fitw` 数组。
- 关键点 3:`layerSize()`(出 CSS)与 `unitGeom()`(出预览框/溢出检测)**必须共用一套换算**,
两套各算迟早漂成「框没超、图已裁」。
- ⚠️ 下拉 value 要用 `l.unit`,不能用 `unitOf()`(把 fit 归一成 vh → 选了 fit 却显示「按视口高比例」)。
**视口预设带高度**(`VIEWPORTS` 改成 `{w,h,label}`,加了 390×844)。原来只有宽度、iframe 恒 900 高,
768 的旧笔电和 844 手机根本模拟不出来 —— 而裁切几乎全由高度决定。
**溢出检测 + 一键修好**:`layerMetrics(l,a,VW,VH)` 给 overL/R/T/B,图层编辑区显示
「1512×900 下显示 131×420,完整可见」或橙色「⚠ 左右各 88px 越界」+「按当前视口修好」按钮
(切 px 并算刚好放下的尺寸)。导出前 `overflowReport()` 再扫一遍,只提醒不拦。
**窄屏每层策略** `l.mob`(inherit/scale/hide)+ 全局 `S.narrow.scale`(默认 0.62)。
⚠️ 顺手修了一个既有 bug:`@media` 块原来只写 `html[data-theme-style]`(0,1,1),
被页面覆盖块 (0,2,1) 压住 → **独立配置的页面在手机上从来没生效过**。现在每个独立 scope 各写一条。
⚠️ 竖版图只替换 `cfg.bg` 非空的页面(否则给纯色页塞图 = 改了他的设计)。
**背景焦点** `cfg.bgPos` → `--tb-page-position`;base.css 里 `var(--tb-page-position, center center)` 兜底
(旧主题不写也不坏),StaticVersion → `20261020f`。
**导出画质**:`S.export = {quality: 0.88, maxSide: 1600}`,改完全量 `rebakeAll()`(从 orig 重算,不叠加压缩)。
- ⚠️ **`toDataURL('image/webp', 1)` 是坑**:Skia 在 q=1 切无损,输出反常 —— 实测同一张噪声图
q=1 只有 52KB、q=0.95 是 440KB。`canvasAsset` 与滑块都夹到 0.95。
- ⚠️ 平涂/线稿降 q 可能**变大**(有损引入噪点;实测平涂图各档都是 1KB,噪声图 0.6→0.95 涨 75%)。
所以文案写「先砍长边再降质量」,不承诺「降 q 一定更小」。
- 缩小加 `imageSmoothingQuality='high'`。
**自检**:新增 `?dump=fit`(视口占用/溢出/fitw/旧工程兼容/@media 覆盖各 scope)与
`?dump=quality`(q 单调性、q=1 被夹、长边真压像素),两项 `ok:true`;`?dump=validate` 空、layers/fx/fxpipe 全绿。
生成 CSS 临时存成 `static/css/themes/gentest.css` 跑 `go test ./internal/theme` → 30 处声明全过(`clamp` 里的逗号没被当层分隔符)。
⚠️ 顺带发现 **`//go:embed` 的 `*` 不匹配 `_` 开头文件** —— `_scaffold.css` 与 `_gentest.css` 都不进 embed,
所以那个校验一开始"跑了但数字没变",改名后才真正生效。
UI 踩坑:面板仅 ~240px,`input[type=range]` 有固有宽度 → 必须 `flex:1 1 0;width:0;min-width:0`,
且质量数值要放进 label 里(作第三个 flex 项会被顶出可视区,表现为"滑块没有数字")。
+117 -105
View File
@@ -3,63 +3,65 @@
## 协作
- 结论分级 P0/P1/P2;给建议前先根因取证 + 实测复现,别只静态推断。
- 改代码前先出变更清单获批准;用户常授权自主推进,但重大问题仍要显式指出。
- 用户是运营者也是圈内人:处置功能必须可解释、可留痕、可申诉,不做自动级联封号。
- 改代码前先出变更清单;重大问题显式指出。用户常授权自主推进。
- 用户是运营者也是圈内人:处置必须可解释、可留痕、可申诉,不做自动级联封号。
## 构建与测试
- **无 libwebp/GCC**,cgo 的 `internal/storage/s3.go` 挡编译 → overlay 换桩 `dev/s3stub/s3_stub.go`:
`-overlay="C:/Users/Tamakyi Gekka/AppData/Local/Temp/tb-webpcheck/overlay.json"`。
overlay 换不了 GOMODCACHE 里的文件,**要检查 s3.go 本身得 tar 复制项目 + `go mod edit -replace`**。
- **「编译卡死」= `proxy.golang.org` 不可达**。每条 go 命令前:
`export GOPROXY=https://goproxy.cn,direct GOSUMDB=off GOFLAGS=-mod=mod`(`go env -w` 在本沙箱失败)。
1C1G 是正式生产环境,**别用「沙箱慢」解释超时**。
- **无 libwebp/GCC**:cgo 的 `internal/storage/s3.go` 挡编译 → overlay 换桩
`dev/s3stub/s3_stub.go`(`-overlay="C:/Users/Tamakyi Gekka/AppData/Local/Temp/tb-webpcheck/overlay.json"`)。
要检查 s3.go 本身得 tar 复制项目 + `go mod edit -replace`(overlay 换不了 GOMODCACHE)。
- **「编译卡死」= `proxy.golang.org` 不可达**。每条 go 命令前
`export GOPROXY=https://goproxy.cn,direct GOSUMDB=off GOFLAGS=-mod=mod`(`go env -w` 沙箱失败)。
1C1G 是正式生产环境,别用「沙箱慢」解释超时。
- DB 测试:`DB_TYPE=postgres DB_USER=postgres DB_PASSWORD=15207786347hXPm DB_HOST=127.0.0.1 DB_PORT=5432`
(DSN 不带库名,`NewTestDB` 自建临时库后 DROP)。带 DB 的 `go test ./...` 目前全绿。
- store 方法内用 `NewXStore(db.DB)`,别引用包级单例(测试下 nil panic;`FeatureStateOf` 例外,有守卫)。
- ⚠️ **本沙箱起不了 `TamaBox web`**(session 固定走 redis,6379 未开,启动即 panic)→ HTTP 端到端只能部署后做。
(DSN 不带库名,`NewTestDB` 自建临时库后 DROP)。带 DB 的 `go test ./...` 全绿。
- store 方法内用 `NewXStore(db.DB)`,别引用包级单例(测试下 nil panic;`FeatureStateOf` 有守卫)。
- ⚠️ **本沙箱起不了 `TamaBox web`**(session 固定 redis,6379 未开,启动 panic)→ HTTP 端到端只能部署后做。
探测端口 200 可能是别的进程,必须看日志确认 `Starting web server` 且无 panic。
- ⚠️ **本机 python urllib 不稳定**(代理下偶发读超时,curl 正常),曾误判「接口卡死」。线上验证优先 curl;
python 别用 `/tmp`(Windows 解析成 `C:\tmp`,与 MSYS `/tmp` 不通)。
- ⚠️ **本机 python urllib 不稳定**(代理下偶发读超时,curl 正常);python 别用 `/tmp`
(Windows 解析成 `C:\tmp`,与 MSYS 不通)。PIL 在 `~/.workbuddy/binaries/python/envs/default`。
## 行尾 / 模板
## 行尾 / 模板 / embed
- 行尾混合不可推断(`internal/db/*`、`mail.go`、`route/user/profile.go` 是 LF;`internal/route/route.go`、
`users.go`、`i18n/dict_*.go`、`route/question/*.go`、`question-list.html` 是 CRLF)。改前用 Python 数 `b'\r\n'`;
`gofmt -l` 会把 CRLF 误报为未格式化。格式化走 `dev/gofmt_keep_crlf.py`。
- 往 gofmt 对齐组中间插键会污染 diff,新键插组末;注释别用 `''`(gofmt 会改成 `”`)。
`users.go`、`i18n/dict_*.go`、`route/question/*.go`、`base.css`、`dev/theme-builder/*` 是 CRLF)。
改前用 Python 数 `b'\r\n'`;`gofmt -l` 会把 CRLF 误报为未格式化 → 走 `dev/gofmt_keep_crlf.py`。
Edit 工具会保留原文件行尾,可放心用;新增整份文件用 Write 时要自己补 CRLF。
- 往 gofmt 对齐组中间插键会污染 diff,新键插组末;注释别用 `''`(gofmt 改成 `”`)。
- ⚠️ **`templates/embed.go` 是显式清单**:新增**顶层**模板必须加进 `//go:embed`,否则运行时
`html/template: "xxx" is undefined` → 500,编译期无提示(`checktpl` 也只查语法)。子目录已被目录项覆盖。
`html/template: "xxx" is undefined` → 500,编译期无提示。`static/fs.go` 同理。
- ⚠️ **`//go:embed` 的 `*` 不匹配 `_` / `.` 开头的文件**:`static/css/themes/_scaffold.css` 与
临时建的 `_gentest.css` **都不进 embed**,`TestDecoSlotCount` 扫不到它们(表现是"校验了但数字没变")。
验证生成的主题 CSS 要临时改成非下划线文件名。
- `templates/user/moderation-strictness.html` 只用 shell/python 改(Edit/Write 会注入 `data-page-node-id`)。
## 审核 / 提示 / 处置
## 审核 / 处置 / 举报
- 线上通道 = 七牛(`ai.qiniuapi.com/v3/text/censor`,antispam);`[security] text_censor_source = qiniu|aliyun|auto`。
七牛只硬拦涉政/暴恐/色情,`review` 放行 → 裁决在 `censor/policy.go` + `moderation.Check`。
- **新增审核调用点必须走 `moderation.Check`**(直调 `censor.Text` 会绕过档位)。不可用时 **fail-closed**。
- ⚠️ **圈内黑话(代称+群体标签+避雷叙述)任何通用审核都拦不住**,别指望词库;靠处置 + 举报 + 人工判断。
- 严格度三档 lax/standard/strict,存 `users.moderation_strictness`。**受处置用户强制 strict**:
`moderation.EffectiveStrictness(ctx, userID, raw)`(7 个入口已全部改用);**查询失败时回退用户设置**
(否则 DB 抖动会让全站突然加严)。处置期内 `UpdateModerationStrictness` 拒绝保存 + 设置页 disabled。
- ⚠️ **同请求渲染只能用 `ctx.SetError` / `SetInternalError`,不能用 `*Flash`**:flash 写 session,下一请求才读 →
提示静默丢失(「点了没反应」)。`Set*Flash + Redirect` 是正常模式。
扫描法:找 flash 调用 → 向下第一个 `Success(`/`Redirect(` → 前者即缺陷。
- 处置四级:L1 停止分发(禁分享卡 + 首页/探索/标签/随机位摘除)/L2 收窄公开(新回答强制私密、禁 set-public)/
L3 冻结发布(仍可收信)/L4 整页停用(`/restricted`)。到期靠 `expires_at` 自动失效,**无 cron**;
`restrict` 是**替换语义**;级别比较用 `RestrictionAtLeast`。
- ⚠️ L2 强制私密必须在**审核通过之后**,且与写回答**同一条 UPDATE**(`AnswerByIDPrivate`):拆开会留下
「公开+已作答」窗口,第二条失败还会变成「私密+无回答」。
- **新增审核调用点必须走 `moderation.Check`**(直调 `censor.Text` 绕过档位)。不可用时 **fail-closed**。
- ⚠️ 圈内黑话(代称+群体标签+避雷叙述)通用审核拦不住,别指望词库;靠处置 + 举报 + 人工判断。
- 严格度 lax/standard/strict 存 `users.moderation_strictness`;**受处置用户强制 strict**:
`moderation.EffectiveStrictness(ctx, userID, raw)`(7 个入口已改)。**查询失败回退用户设置**
(否则 DB 抖动会让全站突然加严)。处置期内拒绝保存 + 设置页 disabled。
- ⚠️ **同请求渲染只能用 `ctx.SetError` / `SetInternalError`,不能用 `*Flash`**:flash 写 session,下次请求才读 →
提示静默丢失。`Set*Flash + Redirect` 才是正常模式。扫描法:找 flash 调用 → 向下第一个 `Success(`/`Redirect(`。
- 处置四级:L1 停止分发/L2 收窄公开(新回答强制私密、禁 set-public)/L3 冻结发布(仍可收信)/
L4 整页停用(`/restricted`)。到期靠 `expires_at`,**无 cron**;`restrict` 是**替换语义**;比较用 `RestrictionAtLeast`。
- ⚠️ L2 强制私密必须在**审核通过之后**,且与写回答**同一条 UPDATE**(`AnswerByIDPrivate`):
拆开会留下「公开+已作答」窗口,第二条失败还会变成「私密+无回答」。
- ⚠️ **`ListAPI`(`/api/v1/user/{domain}/questions`)不经过 Pager 中间件**,L4 与页面密码必须自己校验。
- ⚠️ **整箱分享卡 `ShareCard` 与单题分享卡都要拦**,且拦截必须在缓存查询之前(否则 30 分钟内仍给旧图)。
- 举报 `POST /_/{domain}/{qid}/report`:公开免登录 + 验证码 + 限流;同 IP 同题唯一索引 →
- 举报 `POST /_/{domain}/{qid}/report`:免登录 + 验证码 + 限流;同 IP 同题唯一索引 →
**幂等且提示与首次一致**(否则成了内容存在性探测器)。箱主只看「被举报 N 次」,**不给 IP**。
- 闭环:箱主删除/设私密或 `hide-public` → `MarkAcceptedByQuestion`;待处理超 30 天视为过期。
⚠️ `hide-public` 里必须**先快照目标 ID 再更新**(更新后用带 `is_private=false` 的子查询恒为空集)。
- 告警:24h≥8 或 7d≥20 → 邮件到 `[security] security_alert_email`(24h 冷却,进程内);单条恰好 3 人举报 → 提醒箱主。
- 闭环:删除/设私密/`hide-public` → `MarkAcceptedByQuestion`;待处理超 30 天视为过期。
⚠️ `hide-public` 里必须**先快照目标 ID 再更新**(更新后带 `is_private=false` 的子查询恒为空集)。
- 告警:24h≥8 或 7d≥20 → 邮件到 `[security] security_alert_email`(24h 冷却);单条恰好 3 人举报 → 提醒箱主。
⚠️ 该邮箱为空时静默跳过、无日志;改配置必须重启。
- `/rules` 是处置依据页(四语言);注册页必须勾 `agree_rules`(服务端同样校验),改 e2e 脚本要同步。
- `restrict`/`unrestrict` 默认给箱主发通知(按 `users.language`,模板 `mail/restriction-notice.html`),
**只含级别名/到期/规则链接,不含 `--reason`**;`--no-notify` 跳过。`--days > 90` 必须加 `--force`。
- `restrict`/`unrestrict` 默认给箱主发通知(按 `users.language`,`mail/restriction-notice.html`),
**只含级别名/到期/规则链接,不含 `--reason`**;`--no-notify` 跳过。`--days > 90` 要 `--force`。
⚠️ **到期自动解除不发信**(读表比较时间)。
## GORM
@@ -72,103 +74,113 @@
## 前端样式
- `static/css/base.css`(`?v=YYYYMMDDx`,**改完必须 +1**,静态缓存 1 天)。令牌在 `:root`(亮)+
- `static/css/base.css`(`?v=` 取 `conf.StaticVersion`,**改完必须 +1**)。令牌在 `:root`(亮)+
`html[data-theme="dark"]`(暗)。新样式一律 `var(--tb-*)`,别写死色值,也别为深色另写分支。
- 页面级样式内联在各自模板 `<style>` 里,同样只用令牌;尽量用类不用 `style=`(深色下写死色值会露白)。
- 静态文件是**显式 embed 清单**(`static/fs.go`),新增 css/js 必须加进 `//go:embed`,否则运行期 404。
- 页面级样式内联在各自模板 `<style>`;尽量用类不用 `style=`(深色下内联写死色值会露白)。
- 主题属性在 head 内联脚本里打(cookie `theme`),**不能挪回 body**,否则深色用户白闪。
- **移动端(≤640px)语言切换与主题按钮不在顶栏**:`.tb-nav-utils` 隐藏,由 `#tb-mobile-utils` 固定底栏承载
(`bottom:0;z-index:1000`,body 留 `padding-bottom:56px`)。用户曾误以为"按钮不见了",先解释位置。
- **移动端(≤640px)语言切换与主题按钮在固定底栏** `#tb-mobile-utils`(`.tb-nav-utils` 被隐藏,
body 留 `padding-bottom:56px`)。用户曾误以为"按钮不见了",先解释位置。
⚠️ `/_/{domain}` 箱主页 `IsUserPage = true`(`route/question/page.go:133`),语言切换包在
`{{ if not .IsUserPage }}` 里,所以**箱主页没有语言切换入口——有意设计**(用户 2026-10-19 明确),别当 bug 修。
- ⚠️ **UIkit 3.3.3 有 `.uk-flex::before/::after{display:none}`**:给带 `uk-flex` 的元素加伪元素必须写
`.tb-cover.uk-flex::after` 这类复合选择器,否则静默失效。
- 分类/题型标签一律 `.tb-chip`;**彩色 `uk-label` 只留给语义状态**(置顶/私密/被举报/已读、投票四态)。
`{{ if not .IsUserPage }}` 里 → **箱主页没有语言切换入口,有意设计**(用户 2026-10-19 确认),别当 bug 修。
- ⚠️ **UIkit 3.3.3 的 `.uk-flex::before/::after{display:none}`**:给带 `uk-flex` 的元素加伪元素必须写
复合选择器(`.tb-cover.uk-flex::after`),否则静默失效。
- 分类/题型标签一律 `.tb-chip`;彩色 `uk-label` 只留给语义状态(置顶/私密/被举报/已读、投票四态)。
- 视觉自检:`TEST_PREVIEW=1 go test ./templates -run TestGenPreview` → `outputs/preview-*.html`;
截图必须 `chrome --headless=old --force-device-scale-factor=1`(新版 headless 本机挂住)。
⚠️ `--window-size` ≠ CSS 视口(1080→985)。验证 CSS 靠**像素比对**(PIL 在
`~/.workbuddy/binaries/python/envs/default`)而非目测;扫描循环上界不能与物体边界重合
(曾把边界截断读成"方角")。
⚠️ `--window-size` ≠ CSS 视口;要精确视口就用 iframe 指定 width/height。验证 CSS 靠**像素比对**
(PIL 在 venv)而非目测;扫描循环上界不能与物体边界重合。
## 主题(箱主个性化)
- 三个注入点,别再漏:`/_/{domain}/*` → `question.Pager` 按 `pageUser.Theme`;`/user/*` → `internal/context`
的 Contexter 按 `c.User.Theme`(`isThemeScope()` 只认 `/user` 与 `/user/`);`/vote/{token}` → `route/vote.Page`
- 三个注入点:`/_/{domain}/*` → `question.Pager` 按 `pageUser.Theme`;`/user/*` → `internal/context`
Contexter 按 `c.User.Theme`(`isThemeScope()` 只认 `/user` 与 `/user/`);`/vote/{token}` → `route/vote.Page`
按发布者 `owner.Theme`。都看箱主/发布者,不看访客。全站页(`/`、`/explore`、`/login`)不主题化。
- 投票页**语言**也跟发布者:`applyOwnerLocale(data map[string]interface{}, owner)`(与主题同一个函数),
必须在任何 i18n 取词之前调用;签名收 map 是为了能纯内存单测。
- 一套主题 = `static/css/themes/<slug>.css`,**只覆盖令牌**,不碰布局。`static/fs.go` 已嵌整个 `themes/` 目录,
新增只需放文件 + 登记 `[theme] available`(底稿 `_scaffold.css`)。
- ⚠️ **`[theme] available` 是硬白名单,启动时读一次**:css 发布了但没登记,设置页就是看不到,现象和
「部署没生效」一样。排查:`curl /static/css/themes/<slug>.css` 200 = 文件在(没登记);404 = 旧二进制。
启动日志有 `主题注册表已加载 enabled=...` 与 `[theme] available 未登记以下主题`。
- ⚠️ 主题 css **必须成对写**:`html[data-theme-style="x"]`(0,1,1)+
`html[data-theme="dark"][data-theme-style="x"]`(0,2,1)。只写浅色块会让深色被浅色令牌污染。
- 投票页**语言**也跟发布者:`applyOwnerLocale(data, owner)`(与主题同一函数),必须在任何 i18n 取词之前调用。
- 一套主题 = `static/css/themes/<slug>.css`,**只覆盖令牌**,不碰布局。`static/fs.go` 已嵌整个 `themes/` 目录。
- ⚠️ **`[theme] available` 是硬白名单,启动时读一次**:css 发布了但没登记,设置页看不到,现象同「部署没生效」。
排查:`curl /static/css/themes/<slug>.css` 200 = 文件在(没登记);404 = 旧二进制。
- ⚠️ 主题 css **必须成对写**:`html[data-theme-style="x"]`(0,1,1) + `html[data-theme="dark"][data-theme-style="x"]`(0,2,1)。
`TestThemeCSSComplete` 查两个选择器 + 10 个颜色令牌。
- ⚠️ **按页面作用域**:`internal/context.pageScope()` 输出 `<html data-page-scope>`,取值
`box`/`question`/`admin`(/user/questions,/user/votes*)/`profile`/`vote`。主题用
`html[data-theme-style="x"][data-page-scope="y"]` 覆盖整页背景与装饰。深色覆盖必须写**三重选择器**
(再加 `[data-theme="dark"]`),否则被浅色作用域块盖掉(同特异性、写在后面)。没写覆盖块自动回退全局。
- ⚠️ **装饰层固定 10 槽**(base.css 12 层 = pattern1 + deco10 + image1),`--tb-deco`/`-size`/`-position`
三组必须等长。`TestDecoSlotCount` 逐文件逐声明检查;少写一个不报错,只会让图层与 size/position 错位
(整页重复同一张图 / 人物跑到左上角)。
- 位置一律写两值 `calc(0%|50%|100% ± Npx)`(百分比相对「容器 − 图片」)。**别用 left/bottom 关键字混搭**:
「水平居中 + 垂直贴底」是三值语法,非法时整条声明作废、图层静默回左上。
- ⚠️ **UIkit 3.3.3 的主色是编译期常量 `#1e87f0`**,令牌注入不进去。base.css 末尾按组件映射(a/uk-link、
uk-button-primary、checkbox/radio、input:focus、uk-tab active、`.uk-progress`、
`.uk-label:not(danger|warning|success|secondary)`)。新增会显示主色的组件先确认已映射。
- 令牌 `--tb-navbar`:顶栏底色,默认 `var(--tb-surface)`。页面底色偏彩时主题必须覆盖,否则纯白横带压在彩底上割裂。
- 静态资源版本号统一 `conf.StaticVersion`(base.css 与主题 css 共用);**图片不走这个缓存键**(`max-age=86400`),
换图要给 URL 带 `?v=`。
- 整页背景 `--tb-page-image`(cover)/ `--tb-page-pattern`(平铺,`-size` 控尺寸),图案叠在图片之上。
底色用**纯色**别用整页渐变;图案 ≥24px、透明度 ≤0.06。
- ⚠️ **按页面作用域**:`internal/context.pageScope()` → `<html data-page-scope>`,取值
`box`/`question`/`admin`(/user/questions,/user/votes*)/`profile`/`vote`。深色作用域覆盖必须**三重选择器**,
否则被浅色作用域块(同特异性、写在后面)盖掉。
- ⚠️ **装饰层固定 10 槽**(base.css 12 层 = pattern1 + deco10 + image1),三组 `--tb-deco/-size/-position`
必须等长。`TestDecoSlotCount` 逐文件逐声明检查(只扫 embed 到的文件)。
- 位置一律两值 `calc(0%|50%|100% ± N)`(百分比相对「容器 − 图片」)。**别用 left/bottom 关键字混搭**:
三值语法非法时整条声明作废 → 图层静默回左上。
- 尺寸想随视口缩用 `clamp`,**不要写死 px**:`auto clamp(min(calc(100vh - 24px), 231px), 46.67vh, 420px)`。
内层 `min()` 是兜底 —— 屏幕小到连下限都放不下时让位给视口。宽幅素材只能按宽度缩(`vw`),
按高度缩的话宽度按比例涨,窄屏照样裁。
- ⚠️ **UIkit 3.3.3 主色是编译期常量 `#1e87f0`**,令牌注入不进去。base.css 末尾按组件映射(a/uk-link、
uk-button-primary、checkbox/radio、input:focus、uk-tab active、`.uk-progress`、`.uk-label:not(...)`)。
- 令牌 `--tb-navbar`(顶栏底色,默认 `var(--tb-surface)`):页面底色偏彩时主题必须覆盖。
- 静态资源版本号统一 `conf.StaticVersion`;**图片不走这个缓存键**(`max-age=86400`),换图要给 URL 带 `?v=`。
- 整页背景 `--tb-page-image`(cover)/ `--tb-page-pattern`(平铺,`-size` 控尺寸)/ `--tb-page-position`
(焦点,默认 `var(--tb-page-position, center center)`,旧主题不写也不坏)。底色用**纯色**别用渐变;
图案 ≥24px、透明度 ≤0.06。
- ⚠️ **多背景层不能塞变量列表**:变量自身是逗号列表会让层数与 size/position 错位。多张装饰用固定槽位。
- ⚠️ `background-position` 四值语法必须**先是边关键字**:`left calc(...) bottom 0` 合法,`max(...) bottom 0`
非法 → 整条声明作废、人物跑到左上。
- 装饰摆位:**贴边、成组、避开页脚**;四角各摆一个会读成贴纸墙纸;页脚文案右对齐 → 右下角不能放人物。
`--tb-deco` 是 fixed 视口层,贴边插画别放进 `cover` 背景图(必裁边)。
- 主题视觉的**结构**比摆位重要:二次元站点通行做法是「整屏场景插画 + UI 悬浮其上」(蔚蓝档案 JP)。
白底留白里摆人物,无论多大都会读成贴纸;卡片/顶栏配半透明白 + `backdrop-filter` 才有"浮在画面上"的关系。
- 素材放 `static/images/themes/<slug>/`(embed 已含 `images/themes`,子目录自动覆盖;**新增其它顶层目录才改清单**)。
整页图用 16:9 + 竖版两张,靠 `@media` 切换。
- 人物素材:**图生图 + 抠白底**(`image1` 给立绘、`input_fidelity: high`、提示词强调
`flat vector illustration, no outlines`),角色一致性远好于手绘 SVG。
⚠️ 正面立绘直接贴进空场景像贴纸:改**侧后方视角**(不直视访客),并把 RGB **压白 12~16%**
与压白 38% 的场景对齐雾度。
- 主题视觉的**结构**比摆位重要:通行做法是「整屏场景插画 + UI 悬浮其上」(蔚蓝档案 JP)。
白底留白里摆人物,无论多大都读成贴纸;卡片/顶栏配半透明白 + `backdrop-filter` 才有"浮在画面上"的关系。
- 素材放 `static/images/themes/<slug>/`(embed 已含 `images/themes`,子目录自动覆盖)。整页图 16:9 + 竖版两张。
- 人物素材:**图生图 + 抠白底**(`image1` 给立绘、`input_fidelity: high`、`flat vector illustration, no outlines`)。
⚠️ 正面立绘直接贴进空场景像贴纸:改**侧后方视角**(不直视访客),RGB **压白 12~16%** 与压白 38% 的场景对齐雾度。
- ⚠️ **抠白底容差 16**:背景 253、白袜子 237 只差 16,容差 22 以上会把袜子吃穿。手内侧/两腿之间是
**闭合**白区,四边 floodfill 进不去,得按连通域单独掏(面积 ≥24),再 1px 收边。
- ⚠️ `ruleBody` 取令牌前要剥 `/* */`:注释会和后面的令牌粘成一个键,色块预览静默失效。
- 可视化编辑走 `dev/theme-builder/`(`serve.py` 起服务 + iframe 加载真实页面快照;file:// 下父页面读不到
iframe DOM,必须走 HTTP)。图片处理是**非破坏**的:`orig` 存原始字节 + `fx` 存参数,每次从 orig 重算;
参数没变则跳过重编码(反复走有损编码会累积模糊)。
## 主题生成器 `dev/theme-builder/`
- `serve.py` 起静态服务(8777),iframe 加载 `outputs/preview-*.html`。**file:// 下父页读不到 iframe DOM**,必须走 HTTP。
8777 常由用户自己起着,改完直接刷新。
- 单文件 `index.html`(约 2900 行)。图片处理**非破坏**:`orig` 存原始字节 + `fx` 存参数,每次从 orig 重算;
参数没变则跳过重编码。画质(`S.export.quality/maxSide`)同理 —— 改完 `rebakeAll()` 从 orig 重来,不叠加压缩。
- **尺寸单位** 4 档:`fit`(按视口高,人物)/ `fitw`(按视口宽,横幅)/ `px` / `vh`。
`layerSize()` 出 CSS、`unitGeom()` 出预览框,**两处必须共用同一函数**,否则「框没超、图已裁」。
⚠️ 下拉的 value 用 `l.unit`,不能用 `unitOf(l)`(后者把 fit 归一成 vh → 选了 fit 却显示 vh)。
- 溢出检测 `layerMetrics(l, a, VW, VH)`:算 left/top/overL/R/T/B,编辑器里显示「显示 131×420,完整可见」或
「⚠ 左右各 88px 越界」,并给「按当前视口修好」按钮(切 px + 算刚好放下的尺寸)。导出前 `overflowReport()` 再扫一遍。
- 视口预设**带高度**(1512×900 / 1280×800 / 1024×768 / 820×1180 / 390×844):裁切几乎全由高度决定,
只改宽度模拟不出来。`fitStage()` 按预设高渲染 iframe。
- 窄屏:全局 `hideDeco`(急停)> 每层 `mob`(inherit/scale/hide)> 尺寸单位(fit/fitw 自己就缩)。
⚠️ `@media` 里**每个独立配置的 scope 都要写一条** `[data-page-scope="x"]`:页面覆盖块特异性 (0,2,1),
只写 `html[data-theme-style]`(0,1,1) 的话独立配置页在手机上完全不生效。
⚠️ 竖版图只替换**本来有背景图**的页面(`cfg.bg` 非空),否则等于给纯色页塞了张图。
- 自检钩子:`?dump=css|zip|validate|install|fx|fxpipe|fxkey|layers|fit|quality`,`?demo=1&fresh=1&rows=N&scope=&loadurl=`
(`?demo=1` 已含 fit 层 + 竖版背景,`@media` 才会生成)。用法:
`chrome --headless=old --virtual-time-budget=12000 --dump-dom "http://127.0.0.1:8777/dev/theme-builder/index.html?demo=1&fresh=1&dump=fit"`
抓 `<pre id="dump">`(正则要带属性:`<pre id="dump"[^>]*>`)。
- ⚠️ **canvas `toDataURL('image/webp', 1)` 不能用**:Skia 在 q=1 切无损,输出反常(噪声图 q=1 只 52KB,
q=0.95 是 440KB)。`canvasAsset` 夹到 0.95,滑块上限也是 0.95。
- ⚠️ 平涂/线稿降 q 可能**变大**(有损引入噪点)。压体积先砍长边,再降 q。缩小走 `imageSmoothingQuality='high'`。
- ⚠️ 面板只有 ~240px:`input[type=range]` 有固有宽度,必须 `flex:1 1 0;width:0;min-width:0`,
且数值放进 label 里(第三个小 flex 项会被顶出可视区)。
- `?dump=css` 的产物要过 Go 侧校验:临时存成 `static/css/themes/<非下划线名>.css` 再 `go test ./internal/theme`,
跑完删掉。
## 其它约定
- 校验靠 `valid:"required;maxlen:N"` 标签(`internal/form/*.go`),别在 handler 手写。
- 终止性操作(停用)落独立公开页 `/deactivated`(注册在 `reqUserSignIn` 外);改密码后重登才用 flash + `/login`。
- 只有箱主 `/user` 面板,**无管理员角色、无站方后台,且用户要求不要加**:站方动作只走 CLI / 邮件 / 改配置重启。
- git 仓库 `https://git.shiroko.one/tamakyi/tamabox`(`main`)。**`git push` 能直接推**;
「Failed to authenticate user」是**临时态**。推前 `git log --oneline -3` 确认已提交。
改代码时只做只读 `git status/log`,别动历史。`outputs/` 已在 `.gitignore`(`/outputs/`),别整目录 add。
- 只有箱主 `/user` 面板,**无管理员角色、无站方后台,用户要求不要加**:站方动作只走 CLI / 邮件 / 改配置重启。
- git `https://git.shiroko.one/tamakyi/tamabox`(`main`)。**`git push` 能直接推**(本机 Git for Windows 已认证);
「Failed to authenticate user」是临时态。推前 `git log --oneline -3`。`outputs/` 已 gitignore,别整目录 add。
- 打包交付用 tar.gz(排除 `.git`/`.workbuddy`/`outputs`),放项目同级。
## 测试环境 box-dev-api.337845818.best
- 账号 `[email protected]` / `adminadmin`,公开页 `/_/admin6`(用户 2026-10-19 指定)。
- 账号 `[email protected]` / `adminadmin`,公开页 `/_/admin6`。
- 登录要过算数验证码:`GET /login` 取 `_csrf`+cookie → `GET /api/captcha/generate` → 算 `a op b` →
`POST /login` 带 `g-recaptcha-response="<id>:<答案>"`。默认语言 **ja**,四语言文案要一起加
(`TestDictionaryComplete`)。
- **部署有延迟**;模板是编译期 embed,**只重启不重编不会变**。用户说「已部署但没变化」→ 先抓线上 HTML
比对标记,别先怀疑缓存。
`POST /login` 带 `g-recaptcha-response="<id>:<答案>"`。默认语言 **ja**,四语言文案要一起加。
- **部署有延迟**;模板是编译期 embed,**只重启不重编不会变**。用户说「已部署但没变化」→ 先抓线上 HTML 比对标记。
- 表单字段:提问 `content`,回答 `answer`。`GET /user/votes` 故意 302;匿名无邮箱提问成功直接 200 渲染。
- 自检:`dev/devcheck-e2e.py`(28 项)、`dev/devcheck-restrict.sh`(**基线非 200 就别往下走**;
`RUN=1` 才真跑;`trap` 保证撤销)。
- 图片:编码三档(头像 512/q78、背景 1920/q80、通用 1600/q80),**EXIF 方向必须先归一**(转 WebP 会丢),
比例校验用同一套方向(头像 1:1 测不出,只有背景 16:9 触发)。
- 自检:`dev/devcheck-e2e.py`(28 项)、`dev/devcheck-restrict.sh`(基线非 200 就别往下走;`RUN=1` 才真跑)。
- 图片编码三档(头像 512/q78、背景 1920/q80、通用 1600/q80),**EXIF 方向必须先归一**(转 WebP 会丢)。
## CLI(`TamaBox <cmd>`)
- ⚠️ **两个构建入口必须同步**:根 `go build .`(根 `tamabox.go`)与 Dockerfile 的 `./cmd/`(`cmd/tamabox.go`),
- ⚠️ **两个构建入口必须同步**:根 `go build .`(`tamabox.go`)与 Dockerfile 的 `./cmd/`(`cmd/tamabox.go`),
否则 `No help topic for '<cmd>'`。
- 现有:`web` `censor` `uid` `ban` `restore` `reports` `restrict` `unrestrict` `hide-public`。
- `restrict --domain|--email --level L1..L4 --days N --reason` / `unrestrict`;