diff --git a/backend/internal/service/appearance_test.go b/backend/internal/service/appearance_test.go index 31f08bf80..9454358db 100644 --- a/backend/internal/service/appearance_test.go +++ b/backend/internal/service/appearance_test.go @@ -3,12 +3,14 @@ package service import ( "bytes" "encoding/json" + "math" "mime/multipart" "net/http" "net/http/httptest" "net/textproto" "os" "path/filepath" + "strconv" "strings" "testing" @@ -48,6 +50,52 @@ func TestAppearanceDefaultsPreserveBuiltInBrand(t *testing.T) { } } +func TestBuiltInAppearanceSkinTooltipPairsMeetContrast(t *testing.T) { + for _, skin := range defaultAppearanceSkinThemes() { + modes := []struct { + name string + tokens AppearanceSkinModeTokens + }{ + {name: "light", tokens: skin.Tokens.Light}, + {name: "dark", tokens: skin.Tokens.Dark}, + } + for _, mode := range modes { + ratio := appearanceColorContrastRatio(t, mode.tokens.Overlay, mode.tokens.Text) + if ratio < 4.5 { + t.Errorf("skin %s %s tooltip contrast = %.2f, want at least 4.5", skin.ID, mode.name, ratio) + } + } + } +} + +func appearanceColorContrastRatio(t *testing.T, foreground, background string) float64 { + t.Helper() + contrastLuminance := func(color string) float64 { + if len(color) != 7 || color[0] != '#' { + t.Fatalf("unsupported test color %q", color) + } + channels := make([]float64, 3) + for index := range channels { + value, err := strconv.ParseUint(color[1+index*2:3+index*2], 16, 8) + if err != nil { + t.Fatalf("parse test color %q: %v", color, err) + } + srgb := float64(value) / 255 + if srgb <= 0.04045 { + channels[index] = srgb / 12.92 + } else { + channels[index] = math.Pow((srgb+0.055)/1.055, 2.4) + } + } + return 0.2126*channels[0] + 0.7152*channels[1] + 0.0722*channels[2] + } + foregroundLuminance := contrastLuminance(foreground) + backgroundLuminance := contrastLuminance(background) + lighter := math.Max(foregroundLuminance, backgroundLuminance) + darker := math.Min(foregroundLuminance, backgroundLuminance) + return (lighter + 0.05) / (darker + 0.05) +} + func TestAppearanceBackfillsVersionSixFieldsForExistingSetting(t *testing.T) { svc, db, _, _ := newAppearanceTestService(t) legacy := model.SystemSetting{Key: appearanceSettingKey, ValueJSON: `{"schemaVersion":1,"brandName":"旧品牌","skinId":"classic"}`} diff --git a/web/src/styles/globals.css b/web/src/styles/globals.css index 1a5250a74..c39c928a5 100644 --- a/web/src/styles/globals.css +++ b/web/src/styles/globals.css @@ -13015,6 +13015,18 @@ html:not(.dark) .creation-history-drawer-root { backdrop-filter: none !important; } +/* Tooltip 必须使用同一套浮层背景/前景语义色。Ant Design 会让它继承 + colorTextLightSolid(实心按钮文字色),深色主题下会形成深底深字。 */ +:where(.ant-tooltip) { + --ant-tooltip-arrow-background-color: var(--popover) !important; + --ant-tooltip-overlay-color: var(--popover-foreground) !important; +} + +:where(.ant-tooltip-container, .ant-tooltip-inner) { + background: var(--popover) !important; + color: var(--popover-foreground) !important; +} + :where(.ant-select-item-option, .ant-dropdown-menu-item, .ant-dropdown-menu-submenu-title, .ant-cascader-menu-item, .ant-mentions-dropdown-menu-item) { min-height: 36px; margin-block: 2px; diff --git a/web/test/site-appearance-and-skins.test.ts b/web/test/site-appearance-and-skins.test.ts index ed36f5750..5130b7d96 100644 --- a/web/test/site-appearance-and-skins.test.ts +++ b/web/test/site-appearance-and-skins.test.ts @@ -155,6 +155,10 @@ describe("site appearance and editable skin library", () => { expect(editorSource).toContain("后台菜单"); expect(globalStyles).toContain("--control-switch-checked-bg: #16a34a"); expect(globalStyles).toContain("--plugin-switch-checked-bg: var(--control-switch-checked-bg)"); + expect(globalStyles).toContain("--ant-tooltip-arrow-background-color: var(--popover) !important"); + expect(globalStyles).toContain("--ant-tooltip-overlay-color: var(--popover-foreground) !important"); + expect(globalStyles).toContain(":where(.ant-tooltip-container, .ant-tooltip-inner)"); + expect(globalStyles).toContain("color: var(--popover-foreground) !important"); expect(adminStyles).toContain("--admin-status-warning: var(--palette-status-warning)"); expect(adminStyles).toContain("border-radius: var(--menu-radius);"); });