# 主题生成器 `dev/theme-builder/`(长版明细) ## 主题生成器 `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 自己就缩)。 ⚠️ **`hideDeco` 会把 @media 里 10 槽全写 none** —— 桌面完全正常、手机上素材全没了, 这是「素材在窄屏消失」的头号来源(test01 就是这么中招的)。UI 已在勾选时给橙色警告、 图层行标「窄屏隐藏」、加层/导出再提醒;`?narrow=hide` 可一键复现。 ⚠️ `@media` 里**每个独立配置的 scope 都要写一条** `[data-page-scope="x"]`:页面覆盖块特异性 (0,2,1), 只写 `html[data-theme-style]`(0,1,1) 的话独立配置页在手机上完全不生效。 ⚠️ 竖版图只替换**本来有背景图**的页面(`cfg.bg` 非空),否则等于给纯色页塞了张图。 - ⚠️ **派生文字色必须走对比度下限,不能按固定比例混**:`paletteVars` 里 `text-secondary = mixToContrast(text, backdrops, 5.5)`、`text-muted = 4.5`(WCAG AA)。 旧的 `mix(text,bg,0.55)` 在浅底只有 2.9:1 —— 每导一次主题就把 base.css 的 AA 修复覆盖回去。 - 半透明卡片(`--tb-surface: rgba(255,255,255,0.86)`)必须 `flatten()` 压到底色再算,否则按纯白算偏低; - 参考背景取「页面底色 + 压平后的卡片」两者更不利的那个; - 手填正文本身不达标时**原样返回**(宁可不够淡,不能产出看不见的字)。 - 配色面板有读数(正文/次要/淡文字/主色),<4.5 变橙;默认 primary 已压到 `#3d6fac`(4.63)。 - 还要导出 **`--tb-on-primary`**(白字 `#ffffff` / 深字 `#0b1016` 里挑对比度更高的): 深色模式主色偏亮,白字只有 2.2~3.1:1。主色卡中间调时两种字都不够 → 面板橙色提示 + `?dump=palette` 的 `warnings`,**不判失败**(是主色要调,不是工具挑错)。 - 自检钩子:`?dump=css|zip|validate|install|fx|fxpipe|fxkey|layers|fit|quality|palette`,`?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"` 抓 `
`(正则要带属性:`
]*>`)。
- ⚠️ **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`,
  跑完删掉。
- 导出面板「素材前缀」`S.assetBase`:空 = `/static/images/themes//`;填了 = CSS 写绝对 URL
  (R2/OSS)。**图片地址只能由 `urlOf()` 决定** —— `layerCSS` 曾复制过一份拼接,改一处会出现
  「背景走 CDN、装饰还指本地」。文档见 `docs/theme-assets-cdn.md`。
- **素材托管(2026-10-21)**:面板两选一 `S.host.mode = local|s3`,字段
  endpoint/region/bucket/prefix/pathStyle/publicBase 进工程,**accessKey/secretKey 只进
  sessionStorage**(`SEC_SESSION_KEY`),绝不进草稿/工程 json/导出 zip(有断言)。
  `publicBaseOf()` 手填优先,空则按 endpoint 推导(path 风格 `/<桶>/<前缀>`)——
  对 R2 这个地址不可匿名读,测试的「匿名可读」只给 warn 不拦。
- **上传走 `serve.py` 代理**,浏览器不直连对象存储:不用配 CORS,密钥不发给第三方。
  端点 `/api/s3/ping`(探测能力,`python -m http.server` 没有 → 按钮禁用)、
  `/api/s3/test`、`/api/s3/upload`。上传集合与导出共用 `usedAssetIds()`。
- ⚠️ **`usedAssetIds()` 返回 Set**:`!ids.length` 恒真、`ids.map` 不存在 → 上传永远报
  「还没有素材」。用 `[...usedAssetIds()]` 展开。
- ⚠️ **SigV4 手写,必须对拍**:`dev/theme-builder/check_sigv4.py` 调
  `dev/sigv4ref`(aws-sdk-go-v2 的 `signer.SignHTTP`)逐字节比。6/6 一致才可信。
  - 只用 ASCII 用例:裸 `SignHTTP` 默认**二次转义** URI,而 S3 client 内部把
    `DisableURIPathEscaping` 设成 true(单次编码)。我们跟随 S3 client / AWS 文档 RFC3986。
  - canonical headers 三件套 `host;x-amz-content-sha256;x-amz-date`,与 SDK 相同;
    CanonicalHeaders 自身以 `\n` 结尾,故其后与 SignedHeaders 之间有一个空行(规范如此)。
- ⚠️ **HEAD 桶失败拿不到 ``**(HEAD 响应无 body)→ 只能按状态码归因
  (403→AccessDenied / 404→NoSuchBucket / 301|307→PermanentRedirect)。想拿真错误码要靠 PUT。
- ⚠️ **Windows 上 8777 可能被上一个进程继续占着**(SO_REUSEADDR 让新进程 bind 成功但抢不到
  accept)。症状:`/api/s3/ping` 返回旧版 404,而代码明明改了。用
  `Get-NetTCPConnection -LocalPort 8777` 查 PID,或换端口验证。
- 自测:`python dev/theme-builder/check_sigv4.py`(签名对拍)、
  `python dev/theme-builder/selftest_upload.py`(假 S3,16 项)、
  `python dev/theme-builder/mock_s3.py --port 9900`(手动点面板)。
- 顶栏面板 `S.navbar={mode,alpha,blur}`:solid/glass/clear 三档,基色仍取「配色 → 顶栏」,
  `navbarValue()` 统一算;glass 额外写 `.uk-navbar-container:not(.uk-navbar-transparent){backdrop-filter}`。
  **clear 只给预览**,`validate()` 与 `TestNavbarNotFullyTransparent` 都拦。
  自检 `?dump=navbar`。