Skip to main content

plates_render/
appearance.rs

1//! Workspace appearance types: theme colors, typography, and favicon.
2//!
3//! These types represent the resolved appearance settings for a workspace,
4//! including theme colors, typography, and favicon. They mirror the frontend
5//! `ThemeDefinition` / `TypographySettings` structures and are the canonical
6//! Rust representation consumed by the rendering engine.
7//!
8//! A caller persists the underlying settings wherever it keeps configuration
9//! and supplies the resolved [`ThemeAppearance`] to the renderer; this crate
10//! only models the rendered shape, and reads no files.
11
12use std::collections::HashMap;
13
14// ============================================================================
15// Color palette
16// ============================================================================
17
18/// Color palette for a single mode (light or dark).
19///
20/// Maps the app's 26-color OKLch theme palette to the 11 CSS variables used
21/// by the publish stylesheet. Values are CSS color strings (OKLch, hex, etc.).
22#[derive(Debug, Clone, Default, fig::ToValue, fig::FromValue)]
23pub struct ColorPalette {
24    /// Page background (`--bg`)
25    #[fig(default, skip_serializing_if = "Option::is_none")]
26    pub bg: Option<String>,
27    /// Primary text color (`--text`)
28    #[fig(default, skip_serializing_if = "Option::is_none")]
29    pub text: Option<String>,
30    /// Secondary/muted text (`--text-muted`)
31    #[fig(default, skip_serializing_if = "Option::is_none")]
32    pub text_muted: Option<String>,
33    /// Accent/link color (`--accent`)
34    #[fig(default, skip_serializing_if = "Option::is_none")]
35    pub accent: Option<String>,
36    /// Accent hover state (`--accent-hover`)
37    #[fig(default, skip_serializing_if = "Option::is_none")]
38    pub accent_hover: Option<String>,
39    /// Border color (`--border`)
40    #[fig(default, skip_serializing_if = "Option::is_none")]
41    pub border: Option<String>,
42    /// Code/pre background (`--code-bg`)
43    #[fig(default, skip_serializing_if = "Option::is_none")]
44    pub code_bg: Option<String>,
45    /// Surface background for floating elements (`--surface-bg`)
46    #[fig(default, skip_serializing_if = "Option::is_none")]
47    pub surface_bg: Option<String>,
48    /// Surface border (`--surface-border`)
49    #[fig(default, skip_serializing_if = "Option::is_none")]
50    pub surface_border: Option<String>,
51    /// Surface shadow (`--surface-shadow`)
52    #[fig(default, skip_serializing_if = "Option::is_none")]
53    pub surface_shadow: Option<String>,
54    /// Divider color (`--divider-color`)
55    #[fig(default, skip_serializing_if = "Option::is_none")]
56    pub divider_color: Option<String>,
57}
58
59impl ColorPalette {
60    /// Generate CSS variable declarations for all set colors.
61    pub fn to_css_vars(&self) -> String {
62        let mut vars = String::new();
63        let mappings: &[(&Option<String>, &str)] = &[
64            (&self.bg, "--bg"),
65            (&self.text, "--text"),
66            (&self.text_muted, "--text-muted"),
67            (&self.accent, "--accent"),
68            (&self.accent_hover, "--accent-hover"),
69            (&self.border, "--border"),
70            (&self.code_bg, "--code-bg"),
71            (&self.surface_bg, "--surface-bg"),
72            (&self.surface_border, "--surface-border"),
73            (&self.surface_shadow, "--surface-shadow"),
74            (&self.divider_color, "--divider-color"),
75        ];
76        for (value, name) in mappings {
77            if let Some(v) = value {
78                vars.push_str(&format!("    {}: {};\n", name, v));
79            }
80        }
81        vars
82    }
83}
84
85// ============================================================================
86// Typography
87// ============================================================================
88
89/// Font family choices matching the frontend `FontFamily` union type.
90#[derive(Debug, Clone, Default, fig::ToValue, fig::FromValue, PartialEq)]
91#[fig(rename_all = "lowercase")]
92pub enum FontFamily {
93    /// Inter font stack
94    Inter,
95    /// System font stack (default)
96    #[default]
97    System,
98    /// Serif font stack (Georgia)
99    Serif,
100    /// Monospace font stack (SF Mono)
101    Mono,
102}
103
104impl FontFamily {
105    /// Map to a CSS `font-family` value, mirroring the frontend `FONT_FAMILY_MAP`.
106    pub fn to_css(&self) -> &'static str {
107        match self {
108            Self::Inter => {
109                r#""Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif"#
110            }
111            Self::System => {
112                r#"-apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif"#
113            }
114            Self::Serif => r#""Georgia", "Times New Roman", serif"#,
115            Self::Mono => r#""SF Mono", Monaco, "Cascadia Code", "Fira Code", monospace"#,
116        }
117    }
118
119    /// Parse a font family string (as stored in the frontend settings).
120    pub fn from_str_lossy(s: &str) -> Self {
121        match s {
122            "inter" => Self::Inter,
123            "system" => Self::System,
124            "serif" => Self::Serif,
125            "mono" => Self::Mono,
126            _ => Self::System,
127        }
128    }
129}
130
131/// Content width choices matching the frontend `ContentWidth` union type.
132#[derive(Debug, Clone, Default, fig::ToValue, fig::FromValue, PartialEq)]
133#[fig(rename_all = "lowercase")]
134pub enum ContentWidth {
135    /// Narrow (55ch)
136    Narrow,
137    /// Medium (65ch, default)
138    #[default]
139    Medium,
140    /// Wide (85ch)
141    Wide,
142    /// Full width (no max-width)
143    Full,
144}
145
146impl ContentWidth {
147    /// Map to a CSS `max-width` value, mirroring the frontend `CONTENT_WIDTH_MAP`.
148    pub fn to_css(&self) -> &'static str {
149        match self {
150            Self::Narrow => "55ch",
151            Self::Medium => "65ch",
152            Self::Wide => "85ch",
153            Self::Full => "none",
154        }
155    }
156
157    /// Parse a content width string (as stored in the frontend settings).
158    pub fn from_str_lossy(s: &str) -> Self {
159        match s {
160            "narrow" => Self::Narrow,
161            "medium" => Self::Medium,
162            "wide" => Self::Wide,
163            "full" => Self::Full,
164            _ => Self::Medium,
165        }
166    }
167}
168
169/// Typography settings for a workspace.
170///
171/// Mirrors the frontend `TypographySettings` interface.
172#[derive(Debug, Clone, Default, fig::ToValue, fig::FromValue)]
173pub struct TypographySettings {
174    /// Font family choice.
175    #[fig(default)]
176    pub font_family: FontFamily,
177    /// Base font size in pixels.
178    #[fig(default, skip_serializing_if = "Option::is_none")]
179    pub base_font_size: Option<f64>,
180    /// Line height multiplier.
181    #[fig(default, skip_serializing_if = "Option::is_none")]
182    pub line_height: Option<f64>,
183    /// Content max-width choice.
184    #[fig(default)]
185    pub content_width: ContentWidth,
186}
187
188impl TypographySettings {
189    /// Generate CSS variable declarations for typography settings.
190    pub fn to_css_vars(&self) -> String {
191        let mut vars = String::new();
192
193        vars.push_str(&format!(
194            "    --font-family: {};\n",
195            self.font_family.to_css()
196        ));
197
198        if let Some(size) = self.base_font_size {
199            vars.push_str(&format!("    --font-size: {}px;\n", size));
200        }
201
202        if let Some(lh) = self.line_height {
203            vars.push_str(&format!("    --line-height: {};\n", lh));
204        }
205
206        vars.push_str(&format!(
207            "    --content-max-width: {};\n",
208            self.content_width.to_css()
209        ));
210
211        vars
212    }
213}
214
215// ============================================================================
216// Favicon
217// ============================================================================
218
219/// A favicon asset.
220#[derive(Debug, Clone)]
221pub struct FaviconAsset {
222    /// Filename (e.g. "favicon.svg", "favicon.png", "favicon.ico")
223    pub filename: String,
224    /// MIME type (e.g. "image/svg+xml", "image/png", "image/x-icon")
225    pub mime_type: String,
226    /// Raw file bytes
227    pub data: Vec<u8>,
228}
229
230// ============================================================================
231// Theme appearance (combined)
232// ============================================================================
233
234/// Resolved workspace appearance: theme colors, typography, and favicon.
235///
236/// This is the top-level appearance type supplied to the renderer.
237#[derive(Debug, Clone, Default, fig::ToValue, fig::FromValue)]
238pub struct ThemeAppearance {
239    /// Theme identifier (e.g. "default", "sepia", "nord").
240    #[fig(default, skip_serializing_if = "Option::is_none")]
241    pub id: Option<String>,
242    /// Light mode color palette.
243    #[fig(default)]
244    pub light: ColorPalette,
245    /// Dark mode color palette.
246    #[fig(default)]
247    pub dark: ColorPalette,
248    /// Optional favicon. Not serialized (binary data).
249    #[fig(skip)]
250    pub favicon: Option<FaviconAsset>,
251    /// Typography settings (font, size, line-height, content width).
252    #[fig(default)]
253    pub typography: Option<TypographySettings>,
254}
255
256impl ThemeAppearance {
257    /// Generate a CSS block that overrides the default `:root` and dark-mode
258    /// variables with theme colors and typography. Returns empty string if
259    /// nothing is set.
260    pub fn to_css_overrides(&self) -> String {
261        let light_vars = self.light.to_css_vars();
262        let dark_vars = self.dark.to_css_vars();
263        let typo_vars = self
264            .typography
265            .as_ref()
266            .map(|t| t.to_css_vars())
267            .unwrap_or_default();
268
269        if light_vars.is_empty() && dark_vars.is_empty() && typo_vars.is_empty() {
270            return String::new();
271        }
272
273        let mut css = String::new();
274        // Typography + light-mode color overrides go in :root
275        if !light_vars.is_empty() || !typo_vars.is_empty() {
276            css.push_str(&format!(":root {{\n{}{}}}\n", typo_vars, light_vars));
277        }
278        if !dark_vars.is_empty() {
279            css.push_str(&format!(
280                "@media (prefers-color-scheme: dark) {{\n  :root {{\n{}\n  }}\n}}\n",
281                dark_vars
282            ));
283        }
284        css
285    }
286
287    /// Create from an app ThemeDefinition's color palettes.
288    ///
289    /// Maps the app's semantic color keys to CSS variables:
290    /// - background -> bg
291    /// - foreground -> text
292    /// - muted-foreground -> text-muted
293    /// - primary -> accent
294    /// - ring -> accent-hover
295    /// - border -> border
296    /// - secondary -> code-bg
297    /// - card -> surface-bg
298    /// - sidebar-border -> surface-border
299    pub fn from_app_palette(
300        light: &HashMap<String, String>,
301        dark: &HashMap<String, String>,
302    ) -> Self {
303        Self {
304            id: None,
305            light: Self::map_palette(light),
306            dark: Self::map_palette(dark),
307            favicon: None,
308            typography: None,
309        }
310    }
311
312    /// Generate a fallback SVG favicon from the theme's accent color.
313    pub fn generate_favicon_svg(&self) -> FaviconAsset {
314        let accent = self.light.accent.as_deref().unwrap_or("#6366f1");
315        let svg = format!(
316            r#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><circle cx="16" cy="16" r="14" fill="{}"/></svg>"#,
317            accent
318        );
319        FaviconAsset {
320            filename: "favicon.svg".to_string(),
321            mime_type: "image/svg+xml".to_string(),
322            data: svg.into_bytes(),
323        }
324    }
325
326    /// Return the favicon if set, otherwise generate one from the accent color.
327    pub fn favicon_or_default(&self) -> FaviconAsset {
328        match &self.favicon {
329            Some(f) => f.clone(),
330            None => self.generate_favicon_svg(),
331        }
332    }
333
334    fn map_palette(colors: &HashMap<String, String>) -> ColorPalette {
335        ColorPalette {
336            bg: colors.get("background").cloned(),
337            text: colors.get("foreground").cloned(),
338            text_muted: colors.get("muted-foreground").cloned(),
339            accent: colors.get("primary").cloned(),
340            accent_hover: colors.get("ring").cloned(),
341            border: colors.get("border").cloned(),
342            code_bg: colors.get("secondary").cloned(),
343            surface_bg: colors.get("card").cloned(),
344            surface_border: colors.get("sidebar-border").cloned(),
345            surface_shadow: None,
346            divider_color: None,
347        }
348    }
349}