返回 DeepSeek-Reasonix
theme_pack.go
根目录 / desktop / theme_pack.go
1 package main
2
3 import (
4 "encoding/json"
5 "fmt"
6 "path/filepath"
7 "regexp"
8 "strings"
9 "unicode"
10 )
11
12 // Theme Pack V2 is a controlled, non-executable desktop skin. V1 manifests
13 // remain readable and fall back to one shared scene image.
14 // See docs/THEME_PACK.md for the public contract.
15
16 const (
17 themePackSchemaVersion = 2
18 themePackMinSchemaVersion = 1
19 themePackMaxZipBytes = 36 << 20 // two bounded scene images + manifest
20 themePackMaxManifest = 1 << 20 // 1 MiB
21 themePackMaxImageBytes = 16 << 20 // 16 MiB
22 themePackMaxImageEdge = 8192
23 themePackMaxIDLen = 64
24 themePackMaxNameLen = 80
25 themePackMaxTextLen = 240
26 themePackManifestName = "theme.json"
27 themePackExt = ".reasonix-theme"
28 themeStateFileName = "desktop-theme-state.json"
29 // Schema v2: activeThemeId may only reference official, user or plugin
30 // packs. Base style ids (graphite/…) live exclusively in desktop.theme_style.
31 themeStateSchemaVer = 2
32 themeStateSchemaVerV1 = 1
33 themeDirName = "themes"
34 )
35
36 // Allowed base styles match the existing desktop theme directions.
37 var themePackBaseStyles = map[string]struct{}{
38 "graphite": {},
39 "aurora": {},
40 "slate": {},
41 "carbon": {},
42 "nocturne": {},
43 "amber": {},
44 }
45
46 // Token keys that a pack may override. Values must be #RRGGBB or #RRGGBBAA.
47 var themePackTokenKeys = map[string]string{
48 "bg": "--bg",
49 "bgSoft": "--bg-soft",
50 "bgElev": "--bg-elev",
51 "panel": "--panel",
52 "sidebar": "--sidebar-bg",
53 "chat": "--chat-bg",
54 "workspace": "--workspace-preview-bg",
55 "workspaceFiles": "--workspace-files-bg",
56 "border": "--border",
57 "borderSoft": "--border-soft",
58 "fg": "--fg",
59 "fgDim": "--fg-dim",
60 "fgFaint": "--fg-faint",
61 "accent": "--accent",
62 "accentFg": "--accent-fg",
63 "ok": "--ok",
64 "warn": "--warn",
65 "err": "--err",
66 }
67
68 var (
69 themePackIDRe = regexp.MustCompile(`^[a-z][a-z0-9-]{0,62}[a-z0-9]$|^[a-z]$`)
70 themePackColorRe = regexp.MustCompile(`^#([0-9a-fA-F]{6}|[0-9a-fA-F]{8})$`)
71 themePackImageRe = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._-]{0,120}\.(png|jpe?g|webp)$`)
72 )
73
74 // ThemePackManifest is the on-disk theme.json contract.
75 type ThemePackManifest struct {
76 SchemaVersion int `json:"schemaVersion"`
77 ID string `json:"id"`
78 Name string `json:"name"`
79 Author string `json:"author,omitempty"`
80 Description string `json:"description,omitempty"`
81 License string `json:"license,omitempty"`
82 BaseStyle string `json:"baseStyle"`
83 Tokens ThemePackTokens `json:"tokens"`
84 Recipes ThemePackRecipes `json:"recipes"`
85 Background *ThemePackBackground `json:"background,omitempty"`
86 TaskBackground *ThemePackSceneBackground `json:"taskBackground,omitempty"`
87 Extra map[string]interface{} `json:"-"` // rejected on parse when present as unknown top-level
88 }
89
90 // ThemePackTokens holds optional light/dark semantic color overrides.
91 type ThemePackTokens struct {
92 Light map[string]string `json:"light,omitempty"`
93 Dark map[string]string `json:"dark,omitempty"`
94 }
95
96 // ThemePackRecipes maps density/corner enums to bounded component variables.
97 type ThemePackRecipes struct {
98 Density string `json:"density,omitempty"` // compact|comfortable
99 Corners string `json:"corners,omitempty"` // square|soft|round
100 }
101
102 // ThemePackBackground is an optional local background image with focus/safe area.
103 type ThemePackBackground struct {
104 Image string `json:"image,omitempty"`
105 FocusX float64 `json:"focusX"`
106 FocusY float64 `json:"focusY"`
107 SafeArea string `json:"safeArea,omitempty"` // left|right|center
108 HomeOpacity float64 `json:"homeOpacity"`
109 TaskOpacity float64 `json:"taskOpacity"`
110 OverlayStrength float64 `json:"overlayStrength"`
111 PaneOpacity *float64 `json:"paneOpacity,omitempty"` // home scene pane transparency (0=clear, 1=opaque)
112 }
113
114 // ThemePackSceneBackground optionally overrides the task/workspace scene.
115 // V1 packs omit it and continue using Background with TaskOpacity.
116 type ThemePackSceneBackground struct {
117 Image string `json:"image,omitempty"`
118 FocusX float64 `json:"focusX"`
119 FocusY float64 `json:"focusY"`
120 SafeArea string `json:"safeArea,omitempty"` // left|right|center
121 Opacity float64 `json:"opacity"`
122 OverlayStrength float64 `json:"overlayStrength"`
123 PaneOpacity *float64 `json:"paneOpacity,omitempty"` // task scene pane transparency (0=clear, 1=opaque)
124 }
125
126 // ThemeDesktopState is the versioned active-theme pointer (not config.toml).
127 type ThemeDesktopState struct {
128 SchemaVersion int `json:"schemaVersion"`
129 ActiveThemeID string `json:"activeThemeId,omitempty"`
130 }
131
132 // ThemePackView is the frontend-safe summary of a theme (base, official, user
133 // or plugin).
134 type ThemePackView struct {
135 ID string `json:"id"`
136 Name string `json:"name"`
137 Author string `json:"author,omitempty"`
138 Description string `json:"description,omitempty"`
139 License string `json:"license,omitempty"`
140 BaseStyle string `json:"baseStyle"`
141 Builtin bool `json:"builtin"`
142 Kind string `json:"kind"` // "base" | "official" | "user" | "plugin"
143 Active bool `json:"active"`
144 HasBackground bool `json:"hasBackground"`
145 BackgroundURL string `json:"backgroundUrl,omitempty"`
146 TaskBackgroundURL string `json:"taskBackgroundUrl,omitempty"`
147 PreviewURL string `json:"previewUrl,omitempty"`
148 NameKey string `json:"nameKey,omitempty"`
149 DescriptionKey string `json:"descriptionKey,omitempty"`
150 // PluginName badges plugin-contributed themes (Kind == "plugin"); the
151 // frontend renders them read-only as "Plugin · <name>".
152 PluginName string `json:"pluginName,omitempty"`
153 // Warnings carries non-fatal plugin theme discovery issues (invalid files
154 // skipped) scoped to this pack's plugin, following PluginView.Warnings.
155 Warnings []string `json:"warnings,omitempty"`
156 Tokens ThemePackTokens `json:"tokens"`
157 Recipes ThemePackRecipes `json:"recipes"`
158 Background *ThemePackBackground `json:"background,omitempty"`
159 TaskBackground *ThemePackSceneBackground `json:"taskBackground,omitempty"`
160 ContrastWarnings []ThemeContrastWarning `json:"contrastWarnings,omitempty"`
161 }
162
163 // ThemeContrastWarning surfaces WCAG contrast issues without blocking save.
164 type ThemeContrastWarning struct {
165 Mode string `json:"mode"` // light|dark
166 Pair string `json:"pair"` // e.g. fg/bg
167 Ratio float64 `json:"ratio"`
168 Minimum float64 `json:"minimum"`
169 Suggest string `json:"suggest,omitempty"`
170 }
171
172 // ThemeActiveView is what the frontend needs to apply a pack + scene styling.
173 type ThemeActiveView struct {
174 ActiveThemeID string `json:"activeThemeId,omitempty"`
175 Pack *ThemePackView `json:"pack,omitempty"`
176 }
177
178 // ThemeExperienceView is the unified appearance state for the redesigned
179 // settings overview + theme gallery. One call supplies everything the UI needs
180 // without inferring which style is actually effective.
181 type ThemeExperienceView struct {
182 ThemeMode string `json:"themeMode"` // auto|light|dark
183 BaseStyle string `json:"baseStyle"` // graphite|aurora|…
184 EffectiveStyle string `json:"effectiveStyle"` // pack.baseStyle when pack active, else baseStyle
185 ActiveThemeID string `json:"activeThemeId,omitempty"` // official/user/plugin only; never a base id
186 ActivePack *ThemePackView `json:"activePack,omitempty"`
187 // Warnings aggregates non-fatal plugin theme discovery issues (invalid
188 // contributed files skipped) so the gallery can surface them.
189 Warnings []string `json:"warnings,omitempty"`
190 }
191
192 // ThemeSaveInput is the editor payload for creating/updating a user theme.
193 type ThemeSaveInput struct {
194 ID string `json:"id"`
195 Name string `json:"name"`
196 Author string `json:"author,omitempty"`
197 Description string `json:"description,omitempty"`
198 License string `json:"license,omitempty"`
199 BaseStyle string `json:"baseStyle"`
200 Tokens ThemePackTokens `json:"tokens"`
201 Recipes ThemePackRecipes `json:"recipes"`
202 Background *ThemePackBackground `json:"background,omitempty"`
203 TaskBackground *ThemePackSceneBackground `json:"taskBackground,omitempty"`
204 // BackgroundDataURL is an optional data:image/... payload used when the
205 // editor picked a new local image. Empty keeps the existing image.
206 BackgroundDataURL string `json:"backgroundDataUrl,omitempty"`
207 TaskBackgroundDataURL string `json:"taskBackgroundDataUrl,omitempty"`
208 // ClearBackground removes any existing background image.
209 ClearBackground bool `json:"clearBackground,omitempty"`
210 ClearTaskBackground bool `json:"clearTaskBackground,omitempty"`
211 // Replace allows overwriting an existing user theme with the same ID.
212 Replace bool `json:"replace,omitempty"`
213 // Activate enables the theme after a successful save.
214 Activate bool `json:"activate,omitempty"`
215 }
216
217 // ThemeImportResult is returned after a ZIP import attempt.
218 // When NeedsReplace is true, the package was staged server-side and ConfirmImportThemePack
219 // (or ImportThemePack with replace=true) will publish without re-opening a file dialog.
220 // Absolute host paths are never exposed to the frontend.
221 type ThemeImportResult struct {
222 Pack ThemePackView `json:"pack"`
223 Replaced bool `json:"replaced"`
224 NeedsReplace bool `json:"needsReplace,omitempty"`
225 PendingID string `json:"pendingId,omitempty"`
226 }
227
228 func defaultThemePackRecipes() ThemePackRecipes {
229 return ThemePackRecipes{Density: "comfortable", Corners: "soft"}
230 }
231
232 func themePackFloat64(value float64) *float64 {
233 return &value
234 }
235
236 func defaultThemePackBackground() ThemePackBackground {
237 return ThemePackBackground{
238 FocusX: 0.5,
239 FocusY: 0.5,
240 SafeArea: "center",
241 HomeOpacity: 1,
242 TaskOpacity: 0.28,
243 OverlayStrength: 0.62,
244 PaneOpacity: themePackFloat64(0.72),
245 }
246 }
247
248 func defaultThemePackTaskBackground() ThemePackSceneBackground {
249 return ThemePackSceneBackground{
250 FocusX: 0.5,
251 FocusY: 0.5,
252 SafeArea: "center",
253 Opacity: 0.28,
254 OverlayStrength: 0.62,
255 PaneOpacity: themePackFloat64(0.80),
256 }
257 }
258
259 func parseThemePackManifest(data []byte) (*ThemePackManifest, error) {
260 if len(data) == 0 {
261 return nil, fmt.Errorf("theme manifest is empty")
262 }
263 if len(data) > themePackMaxManifest {
264 return nil, fmt.Errorf("theme manifest exceeds %d bytes", themePackMaxManifest)
265 }
266 // Reject top-level keys outside the versioned allow-list.
267 var raw map[string]json.RawMessage
268 if err := json.Unmarshal(data, &raw); err != nil {
269 return nil, fmt.Errorf("theme manifest JSON: %w", err)
270 }
271 allowed := map[string]struct{}{
272 "schemaVersion": {},
273 "id": {},
274 "name": {},
275 "author": {},
276 "description": {},
277 "license": {},
278 "baseStyle": {},
279 "tokens": {},
280 "recipes": {},
281 "background": {},
282 "taskBackground": {},
283 }
284 for k := range raw {
285 if _, ok := allowed[k]; !ok {
286 return nil, fmt.Errorf("theme manifest unknown field %q", k)
287 }
288 }
289 var m ThemePackManifest
290 if err := json.Unmarshal(data, &m); err != nil {
291 return nil, fmt.Errorf("theme manifest JSON: %w", err)
292 }
293 if err := validateThemePackManifest(&m); err != nil {
294 return nil, err
295 }
296 return &m, nil
297 }
298
299 func validateThemePackManifest(m *ThemePackManifest) error {
300 if m == nil {
301 return fmt.Errorf("theme manifest is nil")
302 }
303 if m.SchemaVersion < themePackMinSchemaVersion || m.SchemaVersion > themePackSchemaVersion {
304 return fmt.Errorf("unsupported theme schemaVersion %d (supported %d-%d)", m.SchemaVersion, themePackMinSchemaVersion, themePackSchemaVersion)
305 }
306 if m.SchemaVersion < 2 && m.TaskBackground != nil {
307 return fmt.Errorf("taskBackground requires theme schemaVersion 2")
308 }
309 id := strings.TrimSpace(m.ID)
310 if !themePackIDRe.MatchString(id) {
311 return fmt.Errorf("invalid theme id %q (use lowercase letters, digits, hyphens)", m.ID)
312 }
313 m.ID = id
314 name := strings.TrimSpace(m.Name)
315 if name == "" || len(name) > themePackMaxNameLen {
316 return fmt.Errorf("theme name must be 1–%d characters", themePackMaxNameLen)
317 }
318 if containsControl(name) {
319 return fmt.Errorf("theme name contains control characters")
320 }
321 m.Name = name
322 m.Author = clampThemeText(m.Author)
323 m.Description = clampThemeText(m.Description)
324 m.License = clampThemeText(m.License)
325
326 base := strings.ToLower(strings.TrimSpace(m.BaseStyle))
327 if _, ok := themePackBaseStyles[base]; !ok {
328 return fmt.Errorf("invalid baseStyle %q", m.BaseStyle)
329 }
330 m.BaseStyle = base
331
332 if err := validateThemeTokenMap(m.Tokens.Light, "tokens.light"); err != nil {
333 return err
334 }
335 if err := validateThemeTokenMap(m.Tokens.Dark, "tokens.dark"); err != nil {
336 return err
337 }
338
339 recipes := m.Recipes
340 if recipes.Density == "" {
341 recipes.Density = "comfortable"
342 }
343 if recipes.Corners == "" {
344 recipes.Corners = "soft"
345 }
346 switch recipes.Density {
347 case "compact", "comfortable":
348 default:
349 return fmt.Errorf("invalid density %q (use compact|comfortable)", recipes.Density)
350 }
351 switch recipes.Corners {
352 case "square", "soft", "round":
353 default:
354 return fmt.Errorf("invalid corners %q (use square|soft|round)", recipes.Corners)
355 }
356 m.Recipes = recipes
357
358 if m.Background != nil {
359 bg, err := normalizeThemeBackground(m.Background)
360 if err != nil {
361 return err
362 }
363 m.Background = bg
364 }
365 if m.TaskBackground != nil {
366 bg, err := normalizeThemeSceneBackground(m.TaskBackground)
367 if err != nil {
368 return err
369 }
370 m.TaskBackground = bg
371 }
372 if m.Background != nil && m.TaskBackground != nil && strings.EqualFold(m.Background.Image, m.TaskBackground.Image) {
373 return fmt.Errorf("background and taskBackground must use different image names")
374 }
375 return nil
376 }
377
378 func validateThemeTokenMap(tokens map[string]string, path string) error {
379 if tokens == nil {
380 return nil
381 }
382 for k, v := range tokens {
383 if _, ok := themePackTokenKeys[k]; !ok {
384 return fmt.Errorf("%s: unknown token %q", path, k)
385 }
386 color := strings.TrimSpace(v)
387 if !themePackColorRe.MatchString(color) {
388 return fmt.Errorf("%s.%s: color must be #RRGGBB or #RRGGBBAA, got %q", path, k, v)
389 }
390 // Reject CSS functions / gradients / url() even if somehow encoded.
391 lower := strings.ToLower(color)
392 if strings.Contains(lower, "url(") || strings.Contains(lower, "gradient") || strings.Contains(lower, "expression") {
393 return fmt.Errorf("%s.%s: disallowed color value", path, k)
394 }
395 tokens[k] = strings.ToLower(color)
396 }
397 return nil
398 }
399
400 func normalizeThemeBackground(in *ThemePackBackground) (*ThemePackBackground, error) {
401 if in == nil {
402 return nil, nil
403 }
404 out := defaultThemePackBackground()
405 if in.Image != "" {
406 raw := strings.TrimSpace(in.Image)
407 raw = strings.ReplaceAll(raw, "\\", "/")
408 // Reject any path form — only a bare file name is allowed in the manifest.
409 if raw == "" || strings.Contains(raw, "/") || strings.Contains(raw, "..") || filepath.Base(raw) != raw {
410 return nil, fmt.Errorf("background.image must be a plain file name")
411 }
412 if !themePackImageRe.MatchString(raw) {
413 return nil, fmt.Errorf("background.image must be a local png/jpeg/webp file name")
414 }
415 out.Image = raw
416 }
417 out.FocusX = clamp01(in.FocusX, 0.5)
418 out.FocusY = clamp01(in.FocusY, 0.5)
419 safe := strings.ToLower(strings.TrimSpace(in.SafeArea))
420 if safe == "" {
421 safe = "center"
422 }
423 switch safe {
424 case "left", "right", "center":
425 out.SafeArea = safe
426 default:
427 return nil, fmt.Errorf("background.safeArea must be left|right|center")
428 }
429 // Home may be full strength; task opacity is capped for readability.
430 out.HomeOpacity = clampFloat(in.HomeOpacity, 0, 1, 1)
431 out.TaskOpacity = clampFloat(in.TaskOpacity, 0, 1, 0.28)
432 out.OverlayStrength = clampFloat(in.OverlayStrength, 0, 1, 0.62)
433 if in.PaneOpacity != nil {
434 out.PaneOpacity = themePackFloat64(clampFloat(*in.PaneOpacity, 0, 1, 0.50))
435 }
436 // Empty image means token-only pack — drop background block.
437 if out.Image == "" {
438 return nil, nil
439 }
440 return &out, nil
441 }
442
443 func normalizeThemeSceneBackground(in *ThemePackSceneBackground) (*ThemePackSceneBackground, error) {
444 if in == nil {
445 return nil, nil
446 }
447 out := defaultThemePackTaskBackground()
448 if in.Image != "" {
449 raw := strings.TrimSpace(in.Image)
450 raw = strings.ReplaceAll(raw, "\\", "/")
451 if raw == "" || strings.Contains(raw, "/") || strings.Contains(raw, "..") || filepath.Base(raw) != raw {
452 return nil, fmt.Errorf("taskBackground.image must be a plain file name")
453 }
454 if !themePackImageRe.MatchString(raw) {
455 return nil, fmt.Errorf("taskBackground.image must be a local png/jpeg/webp file name")
456 }
457 out.Image = raw
458 }
459 out.FocusX = clamp01(in.FocusX, 0.5)
460 out.FocusY = clamp01(in.FocusY, 0.5)
461 safe := strings.ToLower(strings.TrimSpace(in.SafeArea))
462 if safe == "" {
463 safe = "center"
464 }
465 switch safe {
466 case "left", "right", "center":
467 out.SafeArea = safe
468 default:
469 return nil, fmt.Errorf("taskBackground.safeArea must be left|right|center")
470 }
471 out.Opacity = clampFloat(in.Opacity, 0, 1, 0.28)
472 out.OverlayStrength = clampFloat(in.OverlayStrength, 0, 1, 0.62)
473 if in.PaneOpacity != nil {
474 out.PaneOpacity = themePackFloat64(clampFloat(*in.PaneOpacity, 0, 1, 0.68))
475 }
476 if out.Image == "" {
477 return nil, nil
478 }
479 return &out, nil
480 }
481
482 func clampThemeText(s string) string {
483 s = strings.TrimSpace(s)
484 if len(s) > themePackMaxTextLen {
485 s = s[:themePackMaxTextLen]
486 }
487 if containsControl(s) {
488 // Strip controls rather than reject optional fields.
489 var b strings.Builder
490 for _, r := range s {
491 if unicode.IsControl(r) && r != '\n' && r != '\t' {
492 continue
493 }
494 b.WriteRune(r)
495 }
496 s = strings.TrimSpace(b.String())
497 }
498 return s
499 }
500
501 func containsControl(s string) bool {
502 for _, r := range s {
503 if unicode.IsControl(r) && r != '\n' && r != '\t' {
504 return true
505 }
506 }
507 return false
508 }
509
510 func clamp01(v, def float64) float64 {
511 return clampFloat(v, 0, 1, def)
512 }
513
514 func clampFloat(v, min, max, def float64) float64 {
515 if v != v { // NaN
516 return def
517 }
518 if v < min {
519 return min
520 }
521 if v > max {
522 return max
523 }
524 return v
525 }
526
527 func isBuiltinThemeID(id string) bool {
528 _, ok := themePackBaseStyles[id]
529 return ok
530 }
531
532 func builtinThemePacks() []ThemePackManifest {
533 // Built-in packs mirror the six style directions with empty token overrides.
534 order := []string{"graphite", "aurora", "slate", "carbon", "nocturne", "amber"}
535 names := map[string]string{
536 "graphite": "Graphite",
537 "aurora": "Aurora",
538 "slate": "Slate",
539 "carbon": "Carbon",
540 "nocturne": "Nocturne",
541 "amber": "Amber",
542 }
543 out := make([]ThemePackManifest, 0, len(order))
544 for _, id := range order {
545 out = append(out, ThemePackManifest{
546 SchemaVersion: themePackSchemaVersion,
547 ID: id,
548 Name: names[id],
549 Author: "Reasonix",
550 Description: "Built-in visual direction",
551 License: "Apache-2.0",
552 BaseStyle: id,
553 Tokens: ThemePackTokens{},
554 Recipes: defaultThemePackRecipes(),
555 })
556 }
557 return out
558 }
559
560 func manifestToView(m *ThemePackManifest, kind string, active bool, backgroundURL, previewURL string, taskBackgroundURLs ...string) ThemePackView {
561 taskBackgroundURL := ""
562 if len(taskBackgroundURLs) > 0 {
563 taskBackgroundURL = taskBackgroundURLs[0]
564 }
565 v := ThemePackView{
566 ID: m.ID,
567 Name: m.Name,
568 Author: m.Author,
569 Description: m.Description,
570 License: m.License,
571 BaseStyle: m.BaseStyle,
572 Builtin: kind == themeKindBase || kind == themeKindOfficial,
573 Kind: kind,
574 Active: active,
575 HasBackground: (m.Background != nil && m.Background.Image != "") || (m.TaskBackground != nil && m.TaskBackground.Image != ""),
576 BackgroundURL: backgroundURL,
577 TaskBackgroundURL: taskBackgroundURL,
578 PreviewURL: previewURL,
579 Tokens: ThemePackTokens{
580 Light: copyStringMap(m.Tokens.Light),
581 Dark: copyStringMap(m.Tokens.Dark),
582 },
583 Recipes: m.Recipes,
584 }
585 if kind == themeKindOfficial {
586 v.NameKey = "settings.themes.official." + m.ID + ".name"
587 v.DescriptionKey = "settings.themes.official." + m.ID + ".description"
588 }
589 if m.Background != nil {
590 bg := *m.Background
591 v.Background = &bg
592 }
593 if m.TaskBackground != nil {
594 bg := *m.TaskBackground
595 v.TaskBackground = &bg
596 }
597 v.ContrastWarnings = computeContrastWarnings(m)
598 return v
599 }
600
601 func copyStringMap(in map[string]string) map[string]string {
602 if len(in) == 0 {
603 return nil
604 }
605 out := make(map[string]string, len(in))
606 for k, v := range in {
607 out[k] = v
608 }
609 return out
610 }
611
612 func themePackCSSVars(tokens map[string]string) map[string]string {
613 if len(tokens) == 0 {
614 return nil
615 }
616 out := make(map[string]string, len(tokens)*2)
617 for k, v := range tokens {
618 css, ok := themePackTokenKeys[k]
619 if !ok {
620 continue
621 }
622 out[css] = v
623 // Keep dual aliases used across stylesheets in sync.
624 switch k {
625 case "fg":
626 out["--text"] = v
627 case "fgDim":
628 out["--text-2"] = v
629 case "fgFaint":
630 out["--text-3"] = v
631 case "panel":
632 out["--bg-elev"] = v
633 out["--surface"] = v
634 case "bg":
635 out["--stage"] = v
636 case "bgSoft":
637 out["--bg-soft"] = v
638 out["--surface-3"] = v
639 case "accent":
640 // Soft accent is derived client-side; keep strong close to accent.
641 out["--accent-strong"] = v
642 out["--control-primary-bg"] = v
643 }
644 }
645 return out
646 }
647
648 func recipeCSSVars(r ThemePackRecipes) map[string]string {
649 out := map[string]string{}
650 switch r.Density {
651 case "compact":
652 out["--theme-density-pad"] = "6px"
653 out["--theme-density-gap"] = "6px"
654 out["--theme-row-h"] = "28px"
655 default:
656 out["--theme-density-pad"] = "10px"
657 out["--theme-density-gap"] = "10px"
658 out["--theme-row-h"] = "34px"
659 }
660 switch r.Corners {
661 case "square":
662 out["--r-s"] = "0px"
663 out["--r"] = "2px"
664 out["--r-l"] = "4px"
665 out["--radius"] = "2px"
666 case "round":
667 out["--r-s"] = "8px"
668 out["--r"] = "14px"
669 out["--r-l"] = "18px"
670 out["--radius"] = "14px"
671 default: // soft
672 out["--r-s"] = "5px"
673 out["--r"] = "8px"
674 out["--r-l"] = "11px"
675 out["--radius"] = "8px"
676 }
677 return out
678 }
679
679 lines GO