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