返回 DeepSeek-Reasonix
theme_contrast.go
根目录 / desktop / theme_contrast.go
1 package main
2
3 import (
4 "math"
5 "strconv"
6 "strings"
7 )
8
9 // computeContrastWarnings checks common text/background pairs for WCAG AA.
10 // Warnings never block save — the UI only surfaces them.
11 func computeContrastWarnings(m *ThemePackManifest) []ThemeContrastWarning {
12 if m == nil {
13 return nil
14 }
15 var out []ThemeContrastWarning
16 out = append(out, contrastForMode("light", m.Tokens.Light)...)
17 out = append(out, contrastForMode("dark", m.Tokens.Dark)...)
18 return out
19 }
20
21 func contrastForMode(mode string, tokens map[string]string) []ThemeContrastWarning {
22 if len(tokens) == 0 {
23 return nil
24 }
25 // Defaults used when only one side of a pair is overridden.
26 defFG, defBG, defFaint, defChat, defAccent, defAccentFG := defaultContrastColors(mode)
27
28 fg := firstToken(tokens, "fg", defFG)
29 bg := firstToken(tokens, "bg", defBG)
30 faint := firstToken(tokens, "fgFaint", defFaint)
31 chat := firstToken(tokens, "chat", defChat)
32 accent := firstToken(tokens, "accent", defAccent)
33 accentFG := firstToken(tokens, "accentFg", defAccentFG)
34
35 var out []ThemeContrastWarning
36 // Body text on main background — AA normal text requires 4.5:1.
37 if r, ok := contrastRatio(fg, bg); ok && r < 4.5 {
38 out = append(out, ThemeContrastWarning{
39 Mode: mode, Pair: "fg/bg", Ratio: round2(r), Minimum: 4.5,
40 Suggest: suggestFGForBG(bg, mode),
41 })
42 }
43 if r, ok := contrastRatio(fg, chat); ok && r < 4.5 {
44 out = append(out, ThemeContrastWarning{
45 Mode: mode, Pair: "fg/chat", Ratio: round2(r), Minimum: 4.5,
46 Suggest: suggestFGForBG(chat, mode),
47 })
48 }
49 if r, ok := contrastRatio(faint, bg); ok && r < 3.0 {
50 out = append(out, ThemeContrastWarning{
51 Mode: mode, Pair: "fgFaint/bg", Ratio: round2(r), Minimum: 3.0,
52 Suggest: suggestFGForBG(bg, mode),
53 })
54 }
55 if r, ok := contrastRatio(accentFG, accent); ok && r < 3.0 {
56 out = append(out, ThemeContrastWarning{
57 Mode: mode, Pair: "accentFg/accent", Ratio: round2(r), Minimum: 3.0,
58 Suggest: suggestFGForBG(accent, mode),
59 })
60 }
61 return out
62 }
63
64 func defaultContrastColors(mode string) (fg, bg, faint, chat, accent, accentFG string) {
65 if mode == "light" {
66 return "#111827", "#f7f8fb", "#8a94a6", "#ffffff", "#2f5fa8", "#ffffff"
67 }
68 return "#f1f1ef", "#0c0d10", "#6c6e74", "#0c0d10", "#ff6a3d", "#0c0d10"
69 }
70
71 func firstToken(tokens map[string]string, key, def string) string {
72 if v, ok := tokens[key]; ok && strings.TrimSpace(v) != "" {
73 return v
74 }
75 return def
76 }
77
78 func contrastRatio(a, b string) (float64, bool) {
79 la, ok1 := relativeLuminance(a)
80 lb, ok2 := relativeLuminance(b)
81 if !ok1 || !ok2 {
82 return 0, false
83 }
84 lighter := math.Max(la, lb)
85 darker := math.Min(la, lb)
86 return (lighter + 0.05) / (darker + 0.05), true
87 }
88
89 func relativeLuminance(hex string) (float64, bool) {
90 r, g, b, ok := parseHexRGB(hex)
91 if !ok {
92 return 0, false
93 }
94 return 0.2126*srgbLin(r) + 0.7152*srgbLin(g) + 0.0722*srgbLin(b), true
95 }
96
97 func srgbLin(c float64) float64 {
98 if c <= 0.04045 {
99 return c / 12.92
100 }
101 return math.Pow((c+0.055)/1.055, 2.4)
102 }
103
104 func parseHexRGB(hex string) (r, g, b float64, ok bool) {
105 hex = strings.TrimPrefix(strings.TrimSpace(hex), "#")
106 if len(hex) != 6 && len(hex) != 8 {
107 return 0, 0, 0, false
108 }
109 ri, err1 := strconv.ParseUint(hex[0:2], 16, 8)
110 gi, err2 := strconv.ParseUint(hex[2:4], 16, 8)
111 bi, err3 := strconv.ParseUint(hex[4:6], 16, 8)
112 if err1 != nil || err2 != nil || err3 != nil {
113 return 0, 0, 0, false
114 }
115 return float64(ri) / 255, float64(gi) / 255, float64(bi) / 255, true
116 }
117
118 func suggestFGForBG(bg string, mode string) string {
119 // Simple suggestion: pick near-black or near-white based on background luminance.
120 if lum, ok := relativeLuminance(bg); ok {
121 if lum > 0.5 {
122 return "#111827"
123 }
124 return "#f1f1ef"
125 }
126 if mode == "light" {
127 return "#111827"
128 }
129 return "#f1f1ef"
130 }
131
132 func round2(v float64) float64 {
133 return math.Round(v*100) / 100
134 }
135
135 lines GO