Files
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

133 lines
5.0 KiB
Go

package theme
import (
"io/fs"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/tamakyi/TamaBox/static"
)
// 主题必须自带的一套颜色令牌(形状/字体/阴影允许继承基础层)。
var requiredTokens = []string{
"--tb-bg", "--tb-surface", "--tb-surface-2", "--tb-surface-hover", "--tb-border",
"--tb-text", "--tb-text-secondary", "--tb-text-muted",
"--tb-primary", "--tb-primary-weak",
}
func TestBuildAvailable(t *testing.T) {
list, def, _ := build("sakura", "default, sakura ,bad_Slug,missing")
assert.Equal(t, "sakura", def, "站点默认应取配置值")
var slugs []string
for _, item := range list {
slugs = append(slugs, item.Slug)
}
// bad_Slug 非法、missing 没有 css 文件,都应被忽略
assert.Equal(t, []string{"default", "sakura"}, slugs)
}
func TestBuildInvalidDefault(t *testing.T) {
_, def, _ := build("does-not-exist", "sakura")
assert.Equal(t, "", def, "默认主题不可用时回退基础外观")
_, def, _ = build("default", "default,sakura")
assert.Equal(t, "", def, "default 语义即基础外观,不应产生 css 请求")
}
func TestResolve(t *testing.T) {
// 站点默认是 sakura
themes, siteDef, index := build("sakura", "default,sakura")
require.Equal(t, "sakura", siteDef)
require.Contains(t, index, "sakura")
require.NotEmpty(t, themes)
assert.Equal(t, "sakura", resolve("", siteDef, index), "未选择时跟随站点默认")
assert.Equal(t, "sakura", resolve("SAKURA", siteDef, index), "大小写不敏感")
assert.Equal(t, "", resolve("default", siteDef, index), "显式选 default = 基础外观,不回退站点默认")
assert.Equal(t, "sakura", resolve("nope", siteDef, index), "非法值回退站点默认")
// 站点默认是基础外观
_, def2, index2 := build("default", "default,sakura")
assert.Equal(t, "", resolve("", def2, index2))
assert.Equal(t, "sakura", resolve("sakura", def2, index2))
}
// TestThemeCSSComplete 防止「复制底稿只写了浅色块」这类问题:
// 半套主题不会报错,只会让深色模式变成浅色配色。
func TestThemeCSSComplete(t *testing.T) {
entries, err := fs.ReadDir(static.FS, strings.TrimSuffix(themeCSSDir, "/"))
require.NoError(t, err)
require.NotEmpty(t, entries)
for _, e := range entries {
name := e.Name()
if !strings.HasSuffix(name, ".css") {
continue
}
slug := strings.TrimSuffix(name, ".css")
// _scaffold.css 是给人复制的模板,default.css 永不加载(仅提供色块预览)
if strings.HasPrefix(slug, "_") || slug == DefaultSlug {
continue
}
raw, err := static.FS.ReadFile(themeCSSDir + name)
require.NoError(t, err)
css := string(raw)
light := ruleBody(css, `html[data-theme-style="`+slug+`"]`)
require.NotEmpty(t, light, "%s:缺少浅色块 html[data-theme-style=\"%s\"]", name, slug)
dark := ruleBody(css, `html[data-theme="dark"][data-theme-style="`+slug+`"]`)
require.NotEmpty(t, dark, "%s:缺少深色块 html[data-theme=\"dark\"][data-theme-style=\"%s\"]", name, slug)
for _, tok := range requiredTokens {
assert.NotEmpty(t, tokenValue(light, tok), "%s 浅色块缺少 %s", name, tok)
assert.NotEmpty(t, tokenValue(dark, tok), "%s 深色块缺少 %s", name, tok)
}
}
}
func TestSwatch(t *testing.T) {
raw, err := static.FS.ReadFile(themeCSSDir + "sakura.css")
require.NoError(t, err)
sw := swatchOf(string(raw), "sakura")
assert.True(t, strings.HasPrefix(sw, "linear-gradient("), "swatch 应为渐变串,实际 %q", sw)
assert.Contains(t, sw, "#ba3e66")
assert.Equal(t, "", swatchOf("/* 空文件 */", "sakura"), "取不到颜色时返回空串")
assert.Equal(t, "", swatchOf(`html[data-theme-style="x"] { --tb-bg: url(evil); --tb-primary: #fff; }`, "x"),
"非色值内容不得进入 swatch")
}
func TestRuleBodyIgnoresComments(t *testing.T) {
// 令牌前面写一行注释很常见;不剥注释的话 tokenValue 会取到
// "/* ... */ --tb-bg" 这种粘连键,色块预览静默失效。
css := `html[data-theme-style="x"] {
/* 磨砂卡片:半透明白 */
--tb-bg: #ffffff;
--tb-primary: rgba(74, 127, 193, 0.11);
}`
body := ruleBody(css, `html[data-theme-style="x"]`)
assert.Equal(t, "#ffffff", tokenValue(body, "--tb-bg"))
assert.Equal(t, "rgba(74, 127, 193, 0.11)", tokenValue(body, "--tb-primary"))
}
// DisplayName 的回退是「自建主题零改代码」的前提:
// 生成器导出的主题只放一个 css,没人会为了名字去改四份词典。
// 一旦回退错,设置页会直接把 key 印出来(theme.name.test01),
// 用户看到的就是「主题名变成了键名」。
func TestDisplayName(t *testing.T) {
// 已登记词条的仍走翻译
assert.Equal(t, "基础外观", DisplayName("zh-CN", DefaultSlug))
assert.Equal(t, "Shiroko", DisplayName("en", "shiroko"))
// 没登记词条的自建主题:回退 slug,且绝不能返回 key 本身
assert.Equal(t, "test01", DisplayName("zh-CN", "test01"))
assert.Equal(t, "test01", DisplayName("ja", "test01"))
assert.Equal(t, "my-theme", DisplayName("en", "my-theme"))
}