1package systemviews
2
3import (
4 "fmt"
5 "strings"
6 "unicode"
7
8 "github.com/bnema/dumber/internal/application/dto"
9)
10
11type shellTheme struct {
12 RootClass string
13 InlineVars string
14}
15
16const (
17 shellDarkClass = "sv-dark"
18 shellLightClass = "sv-light"
19)
20
21var cssNamedColors = func() map[string]struct{} {
22 //nolint:lll // CSS named color list is clearer as a single literal.
23 names := strings.Fields(`aliceblue antiquewhite aqua aquamarine azure beige bisque black blanchedalmond blue blueviolet brown burlywood cadetblue chartreuse chocolate coral cornflowerblue cornsilk crimson cyan darkblue darkcyan darkgoldenrod darkgray darkgreen darkgrey darkkhaki darkmagenta darkolivegreen darkorange darkorchid darkred darksalmon darkseagreen darkslateblue darkslategray darkslategrey darkturquoise darkviolet deeppink deepskyblue dimgray dimgrey dodgerblue firebrick floralwhite forestgreen fuchsia gainsboro ghostwhite gold goldenrod gray green greenyellow grey honeydew hotpink indianred indigo ivory khaki lavender lavenderblush lawngreen lemonchiffon lightblue lightcoral lightcyan lightgoldenrodyellow lightgray lightgreen lightgrey lightpink lightsalmon lightseagreen lightskyblue lightslategray lightslategrey lightsteelblue lightyellow lime limegreen linen magenta maroon mediumaquamarine mediumblue mediumorchid mediumpurple mediumseagreen mediumslateblue mediumspringgreen mediumturquoise mediumvioletred midnightblue mintcream mistyrose moccasin navajowhite navy oldlace olive olivedrab orange orangered orchid palegoldenrod palegreen paleturquoise palevioletred papayawhip peachpuff peru pink plum powderblue purple rebeccapurple red rosybrown royalblue saddlebrown salmon sandybrown seagreen seashell sienna silver skyblue slateblue slategray slategrey snow springgreen steelblue tan teal thistle tomato turquoise violet wheat white whitesmoke yellow yellowgreen`)
24 colors := make(map[string]struct{}, len(names))
25 for _, name := range names {
26 colors[name] = struct{}{}
27 }
28 return colors
29}()
30
31func currentPrefersDark() bool {
32 return currentPrefersDarkImpl()
33}
34
35func resolveShellTheme(appearance dto.WebUIAppearanceConfig) shellTheme {
36 if isZeroAppearance(appearance) {
37 return shellTheme{}
38 }
39
40 var palette dto.ColorPalette
41 var rootClass string
42
43 switch strings.ToLower(strings.TrimSpace(appearance.ColorScheme)) {
44 case "prefer-dark":
45 palette = appearance.DarkPalette
46 rootClass = shellDarkClass
47 case "prefer-light":
48 palette = appearance.LightPalette
49 rootClass = shellLightClass
50 default:
51 if currentPrefersDark() {
52 palette = appearance.DarkPalette
53 rootClass = shellDarkClass
54 } else {
55 palette = appearance.LightPalette
56 rootClass = shellLightClass
57 }
58 }
59
60 return shellTheme{
61 RootClass: rootClass,
62 InlineVars: buildInlineVars(appearance, palette),
63 }
64}
65
66func buildInlineVars(appearance dto.WebUIAppearanceConfig, palette dto.ColorPalette) string {
67 parts := make([]string, 0, 11)
68 appendInlineVar := func(name, value string, sanitize func(string) (string, bool)) {
69 value, ok := sanitize(value)
70 if !ok {
71 return
72 }
73 parts = append(parts, fmt.Sprintf("%s: %s;", name, value))
74 }
75
76 appendInlineVar("--sv-background", palette.Background, sanitizeCSSColor)
77 appendInlineVar("--sv-surface", palette.Surface, sanitizeCSSColor)
78 appendInlineVar("--sv-surface-variant", palette.SurfaceVariant, sanitizeCSSColor)
79 appendInlineVar("--sv-text", palette.Text, sanitizeCSSColor)
80 appendInlineVar("--sv-muted", palette.Muted, sanitizeCSSColor)
81 appendInlineVar("--sv-accent", palette.Accent, sanitizeCSSColor)
82 appendInlineVar("--sv-border", palette.Border, sanitizeCSSColor)
83 appendInlineVar("--sv-font-sans", appearance.SansFont, sanitizeCSSFontFamily)
84 appendInlineVar("--sv-font-serif", appearance.SerifFont, sanitizeCSSFontFamily)
85 appendInlineVar("--sv-font-mono", appearance.MonospaceFont, sanitizeCSSFontFamily)
86 if appearance.DefaultFontSize > 0 {
87 parts = append(parts, fmt.Sprintf("--sv-font-size: %dpx;", appearance.DefaultFontSize))
88 }
89
90 return strings.Join(parts, " ")
91}
92
93func sanitizeCSSColor(value string) (string, bool) {
94 value = strings.TrimSpace(value)
95 if value == "" || hasCSSBreakout(value) {
96 return "", false
97 }
98 lower := strings.ToLower(value)
99 if isHexColor(lower) || isFunctionalColor(lower) || isNamedColor(lower) {
100 return value, true
101 }
102 return "", false
103}
104
105func sanitizeCSSFontFamily(value string) (string, bool) {
106 value = strings.TrimSpace(value)
107 if value == "" || hasCSSBreakout(value) {
108 return "", false
109 }
110
111 validated := value
112 if quote := value[0]; quote == '\'' || quote == '"' {
113 if len(value) < 2 || value[len(value)-1] != quote {
114 return "", false
115 }
116 validated = value[1 : len(value)-1]
117 if validated == "" || strings.ContainsAny(validated, "'\"") {
118 return "", false
119 }
120 } else if strings.ContainsAny(value, "'\"") {
121 return "", false
122 }
123
124 for _, r := range validated {
125 switch {
126 case unicode.IsLetter(r), unicode.IsMark(r), unicode.IsDigit(r):
127 case strings.ContainsRune(" ,._-", r):
128 default:
129 return "", false
130 }
131 }
132 return value, true
133}
134
135func hasCSSBreakout(value string) bool {
136 return strings.ContainsAny(value, ";{}\n\r") || strings.Contains(value, "/*") || strings.Contains(value, "*/")
137}
138
139func isHexColor(value string) bool {
140 if len(value) != 4 && len(value) != 5 && len(value) != 7 && len(value) != 9 {
141 return false
142 }
143 if value[0] != '#' {
144 return false
145 }
146 for _, r := range value[1:] {
147 if (r < '0' || r > '9') && (r < 'a' || r > 'f') && (r < 'A' || r > 'F') {
148 return false
149 }
150 }
151 return true
152}
153
154func isFunctionalColor(value string) bool {
155 prefixes := []string{"rgb(", "rgba(", "hsl(", "hsla(", "oklch(", "oklab(", "color(", "lab(", "lch(", "hwb("}
156 matched := false
157 for _, prefix := range prefixes {
158 if strings.HasPrefix(value, prefix) {
159 matched = true
160 break
161 }
162 }
163 if !matched || !strings.HasSuffix(value, ")") {
164 return false
165 }
166 for _, r := range strings.TrimSuffix(value[strings.IndexByte(value, '(')+1:], ")") {
167 if (r < '0' || r > '9') && (r < 'a' || r > 'z') && !strings.ContainsRune(" .,%/-", r) {
168 return false
169 }
170 }
171 return true
172}
173
174func isNamedColor(value string) bool {
175 if value == "transparent" || value == "currentcolor" {
176 return true
177 }
178 _, ok := cssNamedColors[value]
179 return ok
180}
181
182func isZeroAppearance(appearance dto.WebUIAppearanceConfig) bool {
183 return appearance.ColorScheme == "" &&
184 appearance.SansFont == "" &&
185 appearance.SerifFont == "" &&
186 appearance.MonospaceFont == "" &&
187 appearance.GtkFont == "" &&
188 appearance.DefaultFontSize == 0 &&
189 appearance.LightPalette == (dto.ColorPalette{}) &&
190 appearance.DarkPalette == (dto.ColorPalette{})
191}