Skip to main content

fret_ui/theme/
mod.rs

1pub mod keys;
2pub(crate) mod registry;
3
4use fret_core::{Color, Corners, Px, TextStyle, window::ColorScheme};
5use serde::{Deserialize, Serialize};
6use std::{
7    collections::{HashMap, HashSet},
8    sync::{Arc, Mutex, OnceLock},
9};
10
11use crate::UiHost;
12use crate::theme_registry::{ThemeTokenKind, canonicalize_token_key};
13use crate::{ThemeColorKey, ThemeMetricKey, ThemeNamedColorKey};
14
15const FALLBACK_COLOR: Color = Color {
16    r: 1.0,
17    g: 0.0,
18    b: 1.0,
19    a: 1.0,
20};
21
22fn warn_invalid_default_theme_color_once(name: &str, value: &str) -> bool {
23    static SEEN: OnceLock<Mutex<HashSet<String>>> = OnceLock::new();
24
25    let seen = SEEN.get_or_init(|| Mutex::new(HashSet::new()));
26    let mut seen = match seen.lock() {
27        Ok(guard) => guard,
28        Err(poisoned) => poisoned.into_inner(),
29    };
30
31    let k = format!("{name}:{value}");
32    if !seen.insert(k) {
33        return false;
34    }
35
36    tracing::warn!(
37        color_name = name,
38        color_value = value,
39        "invalid default theme color; using fallback"
40    );
41    true
42}
43
44fn parse_default_theme_hex_color(name: &str, value: &str) -> Color {
45    match parse_hex_srgb_to_linear(value) {
46        Some(color) => color,
47        None => {
48            if strict_theme_enabled() {
49                panic!("invalid default theme color {name}: {value}");
50            }
51            warn_invalid_default_theme_color_once(name, value);
52            FALLBACK_COLOR
53        }
54    }
55}
56
57fn fallback_easing() -> CubicBezier {
58    CubicBezier {
59        x1: 0.0,
60        y1: 0.0,
61        x2: 1.0,
62        y2: 1.0,
63    }
64}
65
66#[cfg(not(test))]
67fn strict_theme_enabled() -> bool {
68    static STRICT: OnceLock<bool> = OnceLock::new();
69    *STRICT.get_or_init(fret_runtime::strict_runtime::strict_runtime_enabled_from_env)
70}
71
72#[cfg(test)]
73thread_local! {
74    static STRICT_THEME_OVERRIDE: std::cell::Cell<Option<bool>> =
75        const { std::cell::Cell::new(None) };
76}
77
78#[cfg(test)]
79fn strict_theme_enabled() -> bool {
80    STRICT_THEME_OVERRIDE
81        .with(|cell| cell.get())
82        .unwrap_or_else(fret_runtime::strict_runtime::strict_runtime_enabled_from_env)
83}
84
85#[cfg(test)]
86struct StrictThemeGuard(Option<bool>);
87
88#[cfg(test)]
89fn strict_theme_for_tests(value: bool) -> StrictThemeGuard {
90    let prev = STRICT_THEME_OVERRIDE.with(|cell| {
91        let prev = cell.get();
92        cell.set(Some(value));
93        prev
94    });
95    StrictThemeGuard(prev)
96}
97
98#[cfg(test)]
99impl Drop for StrictThemeGuard {
100    fn drop(&mut self) {
101        STRICT_THEME_OVERRIDE.with(|cell| cell.set(self.0));
102    }
103}
104
105fn warn_missing_theme_token_once(kind: ThemeTokenKind, key: &str) -> bool {
106    static SEEN: OnceLock<Mutex<HashSet<String>>> = OnceLock::new();
107
108    let canonical = canonicalize_token_key(kind, key);
109    if canonical.is_empty() {
110        return false;
111    }
112
113    let seen = SEEN.get_or_init(|| Mutex::new(HashSet::new()));
114    let mut seen = match seen.lock() {
115        Ok(guard) => guard,
116        Err(poisoned) => poisoned.into_inner(),
117    };
118
119    let k = format!("{kind:?}:{canonical}");
120    if !seen.insert(k) {
121        return false;
122    }
123
124    tracing::warn!(
125        token_kind = ?kind,
126        token_key = canonical,
127        "missing theme token; using fallback"
128    );
129    true
130}
131
132fn fallback_color_by_key(key: &str) -> Color {
133    default_theme().color_by_key(key).unwrap_or(FALLBACK_COLOR)
134}
135
136fn fallback_metric_by_key(key: &str) -> Px {
137    default_theme().metric_by_key(key).unwrap_or(Px(0.0))
138}
139
140fn fallback_corners_by_key(key: &str) -> Corners {
141    default_theme()
142        .corners_by_key(key)
143        .unwrap_or_else(|| Corners::all(Px(0.0)))
144}
145
146fn fallback_number_by_key(key: &str) -> f32 {
147    default_theme().number_by_key(key).unwrap_or(0.0)
148}
149
150fn fallback_duration_ms_by_key(key: &str) -> u32 {
151    default_theme().duration_ms_by_key(key).unwrap_or(0)
152}
153
154fn fallback_easing_by_key(key: &str) -> CubicBezier {
155    default_theme()
156        .easing_by_key(key)
157        .unwrap_or_else(fallback_easing)
158}
159
160fn fallback_text_style_by_key(key: &str) -> TextStyle {
161    default_theme().text_style_by_key(key).unwrap_or_default()
162}
163
164fn canonicalize_config_map<V: Clone>(
165    kind: ThemeTokenKind,
166    map: &HashMap<String, V>,
167) -> HashMap<String, V> {
168    let mut out: HashMap<String, V> = HashMap::new();
169
170    // Keep results deterministic even when the input is a `HashMap` and multiple aliases map to
171    // the same canonical key (config error, but we should behave consistently).
172    let mut keys: Vec<&String> = map.keys().collect();
173    keys.sort_by(|a, b| a.trim().cmp(b.trim()).then_with(|| a.cmp(b)));
174
175    // First pass: keys already in canonical form win over aliases.
176    for k in keys.iter().copied() {
177        let trimmed = k.trim();
178        if trimmed.is_empty() {
179            continue;
180        }
181        let canon = canonicalize_token_key(kind, trimmed);
182        if canon == trimmed {
183            // `k` might include whitespace, but `canon` is derived from `trimmed`, so read the
184            // original value and store under the canonical key.
185            if let Some(v) = map.get(k) {
186                out.insert(canon.to_string(), v.clone());
187            }
188        }
189    }
190
191    // Second pass: fill missing canonical keys from aliases.
192    for k in keys.iter().copied() {
193        let trimmed = k.trim();
194        if trimmed.is_empty() {
195            continue;
196        }
197        let canon = canonicalize_token_key(kind, trimmed);
198        if canon == trimmed {
199            continue;
200        }
201        if let Some(v) = map.get(k) {
202            out.entry(canon.to_string()).or_insert_with(|| v.clone());
203        }
204    }
205
206    out
207}
208
209#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
210pub struct CubicBezier {
211    pub x1: f32,
212    pub y1: f32,
213    pub x2: f32,
214    pub y2: f32,
215}
216
217fn default_color_tokens(colors: ThemeColors) -> HashMap<String, Color> {
218    let mut out = HashMap::from([
219        (
220            "color.surface.background".to_string(),
221            colors.surface_background,
222        ),
223        (
224            "color.panel.background".to_string(),
225            colors.panel_background,
226        ),
227        ("color.panel.border".to_string(), colors.panel_border),
228        ("color.text.primary".to_string(), colors.text_primary),
229        ("color.text.muted".to_string(), colors.text_muted),
230        ("color.text.disabled".to_string(), colors.text_disabled),
231        ("color.accent".to_string(), colors.accent),
232        (
233            "color.selection.background".to_string(),
234            colors.selection_background,
235        ),
236        (
237            "color.selection.inactive.background".to_string(),
238            colors.selection_inactive_background,
239        ),
240        (
241            "color.selection.window_inactive.background".to_string(),
242            colors.selection_window_inactive_background,
243        ),
244        (
245            "color.hover.background".to_string(),
246            colors.hover_background,
247        ),
248        ("color.focus.ring".to_string(), colors.focus_ring),
249        ("color.menu.background".to_string(), colors.menu_background),
250        ("color.menu.border".to_string(), colors.menu_border),
251        ("color.menu.item.hover".to_string(), colors.menu_item_hover),
252        (
253            "color.menu.item.selected".to_string(),
254            colors.menu_item_selected,
255        ),
256        ("color.list.background".to_string(), colors.list_background),
257        ("color.list.border".to_string(), colors.list_border),
258        ("color.list.row.hover".to_string(), colors.list_row_hover),
259        (
260            "color.list.row.selected".to_string(),
261            colors.list_row_selected,
262        ),
263        ("color.scrollbar.track".to_string(), colors.scrollbar_track),
264        ("color.scrollbar.thumb".to_string(), colors.scrollbar_thumb),
265        (
266            "color.scrollbar.thumb.hover".to_string(),
267            colors.scrollbar_thumb_hover,
268        ),
269        (
270            "color.viewport.selection.fill".to_string(),
271            colors.viewport_selection_fill,
272        ),
273        (
274            "color.viewport.selection.stroke".to_string(),
275            colors.viewport_selection_stroke,
276        ),
277        ("color.viewport.marker".to_string(), colors.viewport_marker),
278        (
279            "color.viewport.drag_line.pan".to_string(),
280            colors.viewport_drag_line_pan,
281        ),
282        (
283            "color.viewport.drag_line.orbit".to_string(),
284            colors.viewport_drag_line_orbit,
285        ),
286        (
287            "color.viewport.gizmo.x".to_string(),
288            colors.viewport_gizmo_x,
289        ),
290        (
291            "color.viewport.gizmo.y".to_string(),
292            colors.viewport_gizmo_y,
293        ),
294        (
295            "color.viewport.gizmo.handle.background".to_string(),
296            colors.viewport_gizmo_handle_background,
297        ),
298        (
299            "color.viewport.gizmo.handle.border".to_string(),
300            colors.viewport_gizmo_handle_border,
301        ),
302        (
303            "color.viewport.rotate_gizmo".to_string(),
304            colors.viewport_rotate_gizmo,
305        ),
306    ]);
307
308    // Viewport 3D tooling extensions (not yet part of the typed `ThemeColors` baseline).
309    // These are used by engine-pass gizmos and are theme-overridable via JSON theme configs.
310    out.insert(
311        "color.viewport.gizmo.z".to_string(),
312        Color::from_srgb_hex_rgb(0x33_80_ff),
313    );
314    out.insert(
315        "color.viewport.gizmo.hover".to_string(),
316        colors.viewport_rotate_gizmo,
317    );
318    out.insert(
319        "color.viewport.view_gizmo.face".to_string(),
320        Color {
321            a: 0.35,
322            ..Color::from_srgb_hex_rgb(0x38_38_3d)
323        },
324    );
325    out.insert(
326        "color.viewport.view_gizmo.edge".to_string(),
327        Color {
328            a: 0.90,
329            ..Color::from_srgb_hex_rgb(0xf2_f2_fa)
330        },
331    );
332
333    // shadcn/new-york core semantic palette (canonical names).
334    out.insert("background".to_string(), colors.surface_background);
335    out.insert("foreground".to_string(), colors.text_primary);
336    out.insert("border".to_string(), colors.panel_border);
337    out.insert("input".to_string(), colors.panel_border);
338    out.insert("ring".to_string(), colors.focus_ring);
339    out.insert(
340        "ring-offset-background".to_string(),
341        colors.surface_background,
342    );
343    // Named colors used by some shadcn recipes (e.g. `text-white`).
344    out.insert(
345        "white".to_string(),
346        Color {
347            r: 1.0,
348            g: 1.0,
349            b: 1.0,
350            a: 1.0,
351        },
352    );
353    out.insert(
354        "black".to_string(),
355        Color {
356            r: 0.0,
357            g: 0.0,
358            b: 0.0,
359            a: 1.0,
360        },
361    );
362
363    out.insert("card".to_string(), colors.panel_background);
364    out.insert("card-foreground".to_string(), colors.text_primary);
365
366    out.insert("popover".to_string(), colors.menu_background);
367    out.insert("popover-foreground".to_string(), colors.text_primary);
368    out.insert("popover.border".to_string(), colors.menu_border);
369
370    out.insert("muted".to_string(), colors.panel_background);
371    out.insert("muted-foreground".to_string(), colors.text_muted);
372
373    out.insert("accent".to_string(), colors.hover_background);
374    out.insert("accent-foreground".to_string(), colors.text_primary);
375
376    out.insert("primary".to_string(), colors.accent);
377    out.insert("primary-foreground".to_string(), colors.text_primary);
378
379    out.insert("secondary".to_string(), colors.panel_background);
380    out.insert("secondary-foreground".to_string(), colors.text_primary);
381
382    out.insert("destructive".to_string(), colors.viewport_gizmo_x);
383    out.insert("destructive-foreground".to_string(), colors.text_primary);
384
385    // shadcn/new-york v4 extended palette.
386    //
387    // These are optional in upstream theme presets, but shadcn recipes and our chart ports expect
388    // the keys to exist. Keep defaults stable (theme-overridable via JSON configs).
389    out.insert(
390        "chart-1".to_string(),
391        parse_default_theme_hex_color("chart-1", "#93C5FD"),
392    );
393    out.insert(
394        "chart-2".to_string(),
395        parse_default_theme_hex_color("chart-2", "#3B82F6"),
396    );
397    out.insert(
398        "chart-3".to_string(),
399        parse_default_theme_hex_color("chart-3", "#2563EB"),
400    );
401    out.insert(
402        "chart-4".to_string(),
403        parse_default_theme_hex_color("chart-4", "#1D4ED8"),
404    );
405    out.insert(
406        "chart-5".to_string(),
407        parse_default_theme_hex_color("chart-5", "#1E40AF"),
408    );
409
410    out.insert("sidebar".to_string(), colors.panel_background);
411    out.insert("sidebar-foreground".to_string(), colors.text_primary);
412    out.insert("sidebar-primary".to_string(), colors.accent);
413    out.insert(
414        "sidebar-primary-foreground".to_string(),
415        colors.text_primary,
416    );
417    out.insert("sidebar-accent".to_string(), colors.hover_background);
418    out.insert("sidebar-accent-foreground".to_string(), colors.text_primary);
419    out.insert("sidebar-border".to_string(), colors.panel_border);
420    out.insert("sidebar-ring".to_string(), colors.focus_ring);
421
422    // Common non-core semantic keys used by recipes (kept for the migration window).
423    out.insert(
424        "selection.background".to_string(),
425        colors.selection_background,
426    );
427    out.insert(
428        "selection.inactive.background".to_string(),
429        colors.selection_inactive_background,
430    );
431    out.insert(
432        "selection.window_inactive.background".to_string(),
433        colors.selection_window_inactive_background,
434    );
435    out.insert("input.background".to_string(), colors.panel_background);
436    out.insert("input.foreground".to_string(), colors.text_primary);
437    out.insert("caret".to_string(), colors.text_primary);
438    out.insert("scrollbar.background".to_string(), colors.scrollbar_track);
439    // Historic/compat key used by some UI kit ports.
440    out.insert(
441        "scrollbar.track.background".to_string(),
442        colors.scrollbar_track,
443    );
444    out.insert(
445        "scrollbar.thumb.background".to_string(),
446        colors.scrollbar_thumb,
447    );
448    out.insert(
449        "scrollbar.thumb.hover.background".to_string(),
450        colors.scrollbar_thumb_hover,
451    );
452
453    out
454}
455
456fn default_metric_tokens(metrics: ThemeMetrics) -> HashMap<String, Px> {
457    let mut out = HashMap::from([
458        ("metric.radius.sm".to_string(), metrics.radius_sm),
459        ("metric.radius.md".to_string(), metrics.radius_md),
460        ("metric.radius.lg".to_string(), metrics.radius_lg),
461        ("metric.padding.sm".to_string(), metrics.padding_sm),
462        ("metric.padding.md".to_string(), metrics.padding_md),
463        (
464            "metric.scrollbar.width".to_string(),
465            metrics.scrollbar_width,
466        ),
467        ("metric.font.size".to_string(), metrics.font_size),
468        ("metric.font.mono_size".to_string(), metrics.mono_font_size),
469        (
470            "metric.font.line_height".to_string(),
471            metrics.font_line_height,
472        ),
473        (
474            "metric.font.mono_line_height".to_string(),
475            metrics.mono_font_line_height,
476        ),
477    ]);
478
479    // shadcn/new-york core semantic metrics (canonical names).
480    out.insert("radius".to_string(), metrics.radius_sm);
481    out.insert("radius.lg".to_string(), metrics.radius_md);
482    out.insert("font.size".to_string(), metrics.font_size);
483    out.insert("mono_font.size".to_string(), metrics.mono_font_size);
484    out.insert("font.line_height".to_string(), metrics.font_line_height);
485    out.insert(
486        "mono_font.line_height".to_string(),
487        metrics.mono_font_line_height,
488    );
489
490    // Typography defaults used by shadcn/ui-kit helpers.
491    //
492    // These keys are intentionally treated as "optional overrides" by higher-level components,
493    // but some call sites use `metric_required` directly. Seed reasonable fallbacks here so
494    // custom themes don't crash when they omit them.
495    out.insert("component.text.sm_px".to_string(), metrics.font_size);
496    out.insert(
497        "component.text.sm_line_height".to_string(),
498        metrics.font_line_height,
499    );
500    out.insert(
501        "component.text.xs_px".to_string(),
502        Px((metrics.font_size.0 - 1.0).max(1.0)),
503    );
504    out.insert(
505        "component.text.xs_line_height".to_string(),
506        metrics.font_line_height,
507    );
508    out.insert(
509        "component.text.base_px".to_string(),
510        Px(metrics.font_size.0 + 1.0),
511    );
512    out.insert(
513        "component.text.base_line_height".to_string(),
514        metrics.font_line_height,
515    );
516    out.insert(
517        "component.text.prose_px".to_string(),
518        Px(metrics.font_size.0 + 3.0),
519    );
520    out.insert(
521        "component.text.prose_line_height".to_string(),
522        Px((metrics.font_line_height.0 + 8.0).max(metrics.font_size.0 + 10.0)),
523    );
524
525    // Common spacing and sizing tokens used by ecosystem widgets.
526    out.insert("metric.gap.sm".to_string(), metrics.padding_sm);
527    out.insert("component.size.sm.icon_button.size".to_string(), Px(32.0));
528    out.insert("component.size.md.icon_button.size".to_string(), Px(36.0));
529    out.insert("component.size.lg.icon_button.size".to_string(), Px(40.0));
530
531    // Legacy generic size tokens used by some shadcn ports/tests.
532    // Prefer `component.size.*` tokens in new code.
533    out.insert("metric.size.sm".to_string(), Px(32.0));
534    out.insert("metric.size.md".to_string(), Px(36.0));
535    out.insert("metric.size.lg".to_string(), Px(40.0));
536
537    // `fret-markdown` canonical metrics.
538    //
539    // Keep this value derived from baseline mono font metrics so it tracks theme typography.
540    // This is intended as a "reasonable default" (roughly 16 lines) rather than a hard rule.
541    let code_block_max_height =
542        Px((metrics.mono_font_line_height.0 * 16.0).max(metrics.mono_font_size.0 * 18.0));
543    out.insert(
544        "fret.markdown.code_block.max_height".to_string(),
545        code_block_max_height,
546    );
547
548    out
549}
550
551#[derive(Debug, Clone, Serialize, Deserialize)]
552#[serde(default)]
553pub struct ThemeConfig {
554    pub name: String,
555    pub author: Option<String>,
556    pub url: Option<String>,
557    /// Optional theme metadata hint describing the intended color scheme.
558    ///
559    /// This is app/theme-owned content (ADR 0032). It is used by recipe-layer code that needs a
560    /// stable “light vs dark” hint without relying on theme naming conventions.
561    pub color_scheme: Option<ColorScheme>,
562    pub colors: HashMap<String, String>,
563    pub metrics: HashMap<String, f32>,
564    pub corners: HashMap<String, Corners>,
565    pub numbers: HashMap<String, f32>,
566    pub durations_ms: HashMap<String, u32>,
567    pub easings: HashMap<String, CubicBezier>,
568    pub text_styles: HashMap<String, TextStyle>,
569}
570
571impl Default for ThemeConfig {
572    fn default() -> Self {
573        Self {
574            name: "Default".to_string(),
575            author: None,
576            url: None,
577            color_scheme: None,
578            colors: HashMap::new(),
579            metrics: HashMap::new(),
580            corners: HashMap::new(),
581            numbers: HashMap::new(),
582            durations_ms: HashMap::new(),
583            easings: HashMap::new(),
584            text_styles: HashMap::new(),
585        }
586    }
587}
588
589impl ThemeConfig {
590    pub fn from_slice(bytes: &[u8]) -> Result<Self, serde_json::Error> {
591        serde_json::from_slice(bytes)
592    }
593}
594
595#[derive(Debug, Clone, Copy)]
596pub struct ThemeMetrics {
597    pub radius_sm: Px,
598    pub radius_md: Px,
599    pub radius_lg: Px,
600    pub padding_sm: Px,
601    pub padding_md: Px,
602    pub scrollbar_width: Px,
603    pub font_size: Px,
604    pub mono_font_size: Px,
605    pub font_line_height: Px,
606    pub mono_font_line_height: Px,
607}
608
609#[derive(Debug, Clone, Copy)]
610pub struct ThemeColors {
611    pub surface_background: Color,
612    pub panel_background: Color,
613    pub panel_border: Color,
614
615    pub text_primary: Color,
616    pub text_muted: Color,
617    pub text_disabled: Color,
618
619    pub accent: Color,
620    pub selection_background: Color,
621    pub selection_inactive_background: Color,
622    pub selection_window_inactive_background: Color,
623    pub hover_background: Color,
624    pub focus_ring: Color,
625
626    pub menu_background: Color,
627    pub menu_border: Color,
628    pub menu_item_hover: Color,
629    pub menu_item_selected: Color,
630
631    pub list_background: Color,
632    pub list_border: Color,
633    pub list_row_hover: Color,
634    pub list_row_selected: Color,
635
636    pub scrollbar_track: Color,
637    pub scrollbar_thumb: Color,
638    pub scrollbar_thumb_hover: Color,
639
640    pub viewport_selection_fill: Color,
641    pub viewport_selection_stroke: Color,
642    pub viewport_marker: Color,
643    pub viewport_drag_line_pan: Color,
644    pub viewport_drag_line_orbit: Color,
645    pub viewport_gizmo_x: Color,
646    pub viewport_gizmo_y: Color,
647    pub viewport_gizmo_handle_background: Color,
648    pub viewport_gizmo_handle_border: Color,
649    pub viewport_rotate_gizmo: Color,
650}
651
652#[derive(Debug, Clone)]
653pub struct ThemeSnapshot {
654    pub colors: ThemeColors,
655    pub metrics: ThemeMetrics,
656    pub color_scheme: Option<ColorScheme>,
657    pub revision: u64,
658    color_tokens: Arc<HashMap<String, Color>>,
659    metric_tokens: Arc<HashMap<String, Px>>,
660}
661
662impl ThemeSnapshot {
663    pub fn from_baseline(colors: ThemeColors, metrics: ThemeMetrics, revision: u64) -> Self {
664        Self {
665            colors,
666            metrics,
667            color_scheme: None,
668            revision,
669            color_tokens: Arc::new(default_color_tokens(colors)),
670            metric_tokens: Arc::new(default_metric_tokens(metrics)),
671        }
672    }
673
674    pub fn color_by_key(&self, key: &str) -> Option<Color> {
675        let key = canonicalize_token_key(ThemeTokenKind::Color, key);
676        self.color_tokens.get(key).copied()
677    }
678
679    pub fn color_required(&self, key: &str) -> Color {
680        if let Some(v) = self.color_by_key(key) {
681            return v;
682        }
683
684        if strict_theme_enabled() {
685            panic!("missing theme color token {key}");
686        }
687        warn_missing_theme_token_once(ThemeTokenKind::Color, key);
688        fallback_color_by_key(key)
689    }
690
691    /// Non-panicking theme token access with diagnostics + fallback behavior.
692    pub fn color_token(&self, key: &str) -> Color {
693        self.color_required(key)
694    }
695
696    /// Resolves a named (non-semantic) color token used by upstream ecosystems (e.g. `text-white`).
697    pub fn named_color(&self, key: ThemeNamedColorKey) -> Color {
698        self.color_token(key.canonical_name())
699    }
700
701    pub fn metric_by_key(&self, key: &str) -> Option<Px> {
702        let key = canonicalize_token_key(ThemeTokenKind::Metric, key);
703        self.metric_tokens.get(key).copied()
704    }
705
706    pub fn metric_required(&self, key: &str) -> Px {
707        if let Some(v) = self.metric_by_key(key) {
708            return v;
709        }
710
711        if strict_theme_enabled() {
712            panic!("missing theme metric token {key}");
713        }
714        warn_missing_theme_token_once(ThemeTokenKind::Metric, key);
715        fallback_metric_by_key(key)
716    }
717
718    /// Non-panicking theme token access with diagnostics + fallback behavior.
719    pub fn metric_token(&self, key: &str) -> Px {
720        self.metric_required(key)
721    }
722}
723
724#[derive(Debug, Clone)]
725pub struct Theme {
726    pub name: String,
727    pub author: Option<String>,
728    pub url: Option<String>,
729    pub color_scheme: Option<ColorScheme>,
730    pub colors: ThemeColors,
731    pub metrics: ThemeMetrics,
732    extra_colors: Arc<HashMap<String, Color>>,
733    extra_metrics: Arc<HashMap<String, Px>>,
734    extra_corners: HashMap<String, Corners>,
735    extra_numbers: HashMap<String, f32>,
736    extra_durations_ms: HashMap<String, u32>,
737    extra_easings: HashMap<String, CubicBezier>,
738    extra_text_styles: HashMap<String, TextStyle>,
739    configured_colors: HashSet<String>,
740    configured_metrics: HashSet<String>,
741    configured_corners: HashSet<String>,
742    configured_numbers: HashSet<String>,
743    configured_durations_ms: HashSet<String>,
744    configured_easings: HashSet<String>,
745    configured_text_styles: HashSet<String>,
746    revision: u64,
747}
748
749impl Theme {
750    pub fn revision(&self) -> u64 {
751        self.revision
752    }
753
754    pub fn color(&self, key: ThemeColorKey) -> Color {
755        let name = key.canonical_name();
756        if let Some(v) = self.color_by_key(name) {
757            return v;
758        }
759
760        if strict_theme_enabled() {
761            panic!("missing core theme color key {}", name);
762        }
763        warn_missing_theme_token_once(ThemeTokenKind::Color, name);
764        fallback_color_by_key(name)
765    }
766
767    pub fn metric(&self, key: ThemeMetricKey) -> Px {
768        let name = key.canonical_name();
769        if let Some(v) = self.metric_by_key(name) {
770            return v;
771        }
772
773        if strict_theme_enabled() {
774            panic!("missing core theme metric key {}", name);
775        }
776        warn_missing_theme_token_once(ThemeTokenKind::Metric, name);
777        fallback_metric_by_key(name)
778    }
779
780    pub fn color_by_key(&self, key: &str) -> Option<Color> {
781        let key = canonicalize_token_key(ThemeTokenKind::Color, key);
782        self.extra_colors.get(key).copied()
783    }
784
785    pub fn color_required(&self, key: &str) -> Color {
786        if let Some(v) = self.color_by_key(key) {
787            return v;
788        }
789
790        if strict_theme_enabled() {
791            panic!("missing theme color token {key}");
792        }
793        warn_missing_theme_token_once(ThemeTokenKind::Color, key);
794        fallback_color_by_key(key)
795    }
796
797    /// Non-panicking theme token access with diagnostics + fallback behavior.
798    pub fn color_token(&self, key: &str) -> Color {
799        self.color_required(key)
800    }
801
802    /// Resolve a syntax highlight tag (e.g. `keyword.operator`) into a theme color.
803    ///
804    /// Lookup order:
805    ///
806    /// 1) `color.syntax.<highlight>`
807    /// 2) prefix fallback: `color.syntax.keyword.operator` -> `color.syntax.keyword`
808    /// 3) built-in semantic fallbacks for common highlight roots
809    pub fn syntax_color(&self, highlight: &str) -> Option<Color> {
810        let mut cur = Some(highlight);
811        while let Some(name) = cur {
812            let mut key = String::with_capacity("color.syntax.".len() + name.len());
813            key.push_str("color.syntax.");
814            key.push_str(name);
815            if let Some(c) = self.color_by_key(key.as_str()) {
816                return Some(c);
817            }
818            cur = name.rsplit_once('.').map(|(prefix, _)| prefix);
819        }
820
821        let fallback = highlight.split('.').next().unwrap_or(highlight);
822        match fallback {
823            "comment" => Some(self.color_token("muted-foreground")),
824            "keyword" | "operator" => Some(self.color_token("primary")),
825            "property" | "variable" => Some(self.color_token("foreground")),
826            "punctuation" => Some(self.color_token("muted-foreground")),
827
828            "string" => Some(self.color_token("foreground")),
829            "number" | "boolean" | "constant" => Some(self.color_token("primary")),
830            "type" | "constructor" | "function" => Some(self.color_token("foreground")),
831            _ => None,
832        }
833    }
834
835    /// Resolves a named (non-semantic) color token used by upstream ecosystems (e.g. `text-white`).
836    pub fn named_color(&self, key: ThemeNamedColorKey) -> Color {
837        self.color_token(key.canonical_name())
838    }
839
840    pub fn metric_by_key(&self, key: &str) -> Option<Px> {
841        let key = canonicalize_token_key(ThemeTokenKind::Metric, key);
842        self.extra_metrics.get(key).copied()
843    }
844
845    pub fn metric_required(&self, key: &str) -> Px {
846        if let Some(v) = self.metric_by_key(key) {
847            return v;
848        }
849
850        if strict_theme_enabled() {
851            panic!("missing theme metric token {key}");
852        }
853        warn_missing_theme_token_once(ThemeTokenKind::Metric, key);
854        fallback_metric_by_key(key)
855    }
856
857    /// Non-panicking theme token access with diagnostics + fallback behavior.
858    pub fn metric_token(&self, key: &str) -> Px {
859        self.metric_required(key)
860    }
861
862    pub fn corners_by_key(&self, key: &str) -> Option<Corners> {
863        let key = canonicalize_token_key(ThemeTokenKind::Corners, key);
864        self.extra_corners
865            .get(key)
866            .copied()
867            .or_else(|| self.metric_by_key(key).map(Corners::all))
868    }
869
870    pub fn corners_required(&self, key: &str) -> Corners {
871        if let Some(v) = self.corners_by_key(key) {
872            return v;
873        }
874
875        if strict_theme_enabled() {
876            panic!("missing theme corners token {key}");
877        }
878        warn_missing_theme_token_once(ThemeTokenKind::Corners, key);
879        fallback_corners_by_key(key)
880    }
881
882    /// Non-panicking theme token access with diagnostics + fallback behavior.
883    pub fn corners_token(&self, key: &str) -> Corners {
884        self.corners_required(key)
885    }
886
887    pub fn number_by_key(&self, key: &str) -> Option<f32> {
888        let key = canonicalize_token_key(ThemeTokenKind::Number, key);
889        self.extra_numbers.get(key).copied()
890    }
891
892    pub fn number_required(&self, key: &str) -> f32 {
893        if let Some(v) = self.number_by_key(key) {
894            return v;
895        }
896
897        if strict_theme_enabled() {
898            panic!("missing theme number token {key}");
899        }
900        warn_missing_theme_token_once(ThemeTokenKind::Number, key);
901        fallback_number_by_key(key)
902    }
903
904    /// Non-panicking theme token access with diagnostics + fallback behavior.
905    pub fn number_token(&self, key: &str) -> f32 {
906        self.number_required(key)
907    }
908
909    pub fn duration_ms_by_key(&self, key: &str) -> Option<u32> {
910        let key = canonicalize_token_key(ThemeTokenKind::DurationMs, key);
911        self.extra_durations_ms.get(key).copied()
912    }
913
914    pub fn duration_ms_required(&self, key: &str) -> u32 {
915        if let Some(v) = self.duration_ms_by_key(key) {
916            return v;
917        }
918
919        if strict_theme_enabled() {
920            panic!("missing theme duration_ms token {key}");
921        }
922        warn_missing_theme_token_once(ThemeTokenKind::DurationMs, key);
923        fallback_duration_ms_by_key(key)
924    }
925
926    /// Non-panicking theme token access with diagnostics + fallback behavior.
927    pub fn duration_ms_token(&self, key: &str) -> u32 {
928        self.duration_ms_required(key)
929    }
930
931    pub fn easing_by_key(&self, key: &str) -> Option<CubicBezier> {
932        let key = canonicalize_token_key(ThemeTokenKind::Easing, key);
933        self.extra_easings.get(key).copied()
934    }
935
936    pub fn easing_required(&self, key: &str) -> CubicBezier {
937        if let Some(v) = self.easing_by_key(key) {
938            return v;
939        }
940
941        if strict_theme_enabled() {
942            panic!("missing theme easing token {key}");
943        }
944        warn_missing_theme_token_once(ThemeTokenKind::Easing, key);
945        fallback_easing_by_key(key)
946    }
947
948    /// Non-panicking theme token access with diagnostics + fallback behavior.
949    pub fn easing_token(&self, key: &str) -> CubicBezier {
950        self.easing_required(key)
951    }
952
953    pub fn text_style_by_key(&self, key: &str) -> Option<TextStyle> {
954        let key = canonicalize_token_key(ThemeTokenKind::TextStyle, key);
955        self.extra_text_styles.get(key).cloned()
956    }
957
958    pub fn text_style_required(&self, key: &str) -> TextStyle {
959        if let Some(v) = self.text_style_by_key(key) {
960            return v;
961        }
962
963        if strict_theme_enabled() {
964            panic!("missing theme text_style token {key}");
965        }
966        warn_missing_theme_token_once(ThemeTokenKind::TextStyle, key);
967        fallback_text_style_by_key(key)
968    }
969
970    /// Non-panicking theme token access with diagnostics + fallback behavior.
971    pub fn text_style_token(&self, key: &str) -> TextStyle {
972        self.text_style_required(key)
973    }
974
975    pub fn color_key_configured(&self, key: &str) -> bool {
976        let key = canonicalize_token_key(ThemeTokenKind::Color, key);
977        self.configured_colors.contains(key)
978    }
979
980    pub fn metric_key_configured(&self, key: &str) -> bool {
981        let key = canonicalize_token_key(ThemeTokenKind::Metric, key);
982        self.configured_metrics.contains(key)
983    }
984
985    pub fn corners_key_configured(&self, key: &str) -> bool {
986        let key = canonicalize_token_key(ThemeTokenKind::Corners, key);
987        self.configured_corners.contains(key)
988    }
989
990    pub fn number_key_configured(&self, key: &str) -> bool {
991        let key = canonicalize_token_key(ThemeTokenKind::Number, key);
992        self.configured_numbers.contains(key)
993    }
994
995    pub fn duration_ms_key_configured(&self, key: &str) -> bool {
996        let key = canonicalize_token_key(ThemeTokenKind::DurationMs, key);
997        self.configured_durations_ms.contains(key)
998    }
999
1000    pub fn easing_key_configured(&self, key: &str) -> bool {
1001        let key = canonicalize_token_key(ThemeTokenKind::Easing, key);
1002        self.configured_easings.contains(key)
1003    }
1004
1005    pub fn text_style_key_configured(&self, key: &str) -> bool {
1006        let key = canonicalize_token_key(ThemeTokenKind::TextStyle, key);
1007        self.configured_text_styles.contains(key)
1008    }
1009
1010    pub fn snapshot(&self) -> ThemeSnapshot {
1011        ThemeSnapshot {
1012            colors: self.colors,
1013            metrics: self.metrics,
1014            color_scheme: self.color_scheme,
1015            revision: self.revision,
1016            color_tokens: self.extra_colors.clone(),
1017            metric_tokens: self.extra_metrics.clone(),
1018        }
1019    }
1020
1021    pub fn global<H: UiHost>(app: &H) -> &Theme {
1022        if let Some(theme) = app.global::<Theme>() {
1023            theme
1024        } else {
1025            default_theme()
1026        }
1027    }
1028
1029    pub fn with_global_mut<H: UiHost, R>(app: &mut H, f: impl FnOnce(&mut Theme) -> R) -> R {
1030        app.with_global_mut(|| default_theme().clone(), |theme, _app| f(theme))
1031    }
1032
1033    pub fn apply_config(&mut self, cfg: &ThemeConfig) {
1034        self.name = cfg.name.clone();
1035        self.author = cfg.author.clone();
1036        self.url = cfg.url.clone();
1037
1038        assert_no_legacy_theme_keys(cfg);
1039
1040        let cfg_colors = canonicalize_config_map(ThemeTokenKind::Color, &cfg.colors);
1041        let cfg_metrics = canonicalize_config_map(ThemeTokenKind::Metric, &cfg.metrics);
1042        let cfg_corners = canonicalize_config_map(ThemeTokenKind::Corners, &cfg.corners);
1043        let cfg_numbers = canonicalize_config_map(ThemeTokenKind::Number, &cfg.numbers);
1044        let cfg_durations_ms =
1045            canonicalize_config_map(ThemeTokenKind::DurationMs, &cfg.durations_ms);
1046        let cfg_easings = canonicalize_config_map(ThemeTokenKind::Easing, &cfg.easings);
1047        let cfg_text_styles = canonicalize_config_map(ThemeTokenKind::TextStyle, &cfg.text_styles);
1048
1049        let mut changed = false;
1050
1051        if let Some(scheme) = cfg.color_scheme
1052            && self.color_scheme != Some(scheme)
1053        {
1054            self.color_scheme = Some(scheme);
1055            changed = true;
1056        }
1057
1058        let mut next_numbers = HashMap::new();
1059        let mut next_durations_ms = HashMap::new();
1060        let mut next_easings = HashMap::new();
1061        let mut next_text_styles = HashMap::new();
1062        let mut next_corners = HashMap::new();
1063
1064        macro_rules! apply_semantic_color {
1065            ($key:literal, $set:expr) => {
1066                if let Some(v) = cfg_colors.get($key) {
1067                    if let Some(c) = parse_color_to_linear(v) {
1068                        $set(c);
1069                    }
1070                }
1071            };
1072        }
1073
1074        macro_rules! apply_metric {
1075            ($key:literal, $field:expr) => {
1076                if let Some(v) = cfg_metrics.get($key).copied() {
1077                    let px = Px(v);
1078                    if $field != px {
1079                        $field = px;
1080                        changed = true;
1081                    }
1082                }
1083            };
1084        }
1085
1086        macro_rules! apply_semantic_metric {
1087            ($key:literal, $set:expr) => {
1088                if let Some(v) = cfg_metrics.get($key).copied() {
1089                    let px = Px(v);
1090                    $set(px);
1091                }
1092            };
1093        }
1094
1095        // Apply shadcn semantic keys first; typed tokens are derived from them for consistency.
1096        apply_semantic_color!("background", |c| {
1097            if self.colors.surface_background != c {
1098                self.colors.surface_background = c;
1099                changed = true;
1100            }
1101        });
1102        apply_semantic_color!("foreground", |c| {
1103            if self.colors.text_primary != c {
1104                self.colors.text_primary = c;
1105                changed = true;
1106            }
1107        });
1108        apply_semantic_color!("border", |c| {
1109            if self.colors.panel_border != c {
1110                self.colors.panel_border = c;
1111                changed = true;
1112            }
1113            if self.colors.menu_border != c {
1114                self.colors.menu_border = c;
1115                changed = true;
1116            }
1117            if self.colors.list_border != c {
1118                self.colors.list_border = c;
1119                changed = true;
1120            }
1121        });
1122        apply_semantic_color!("input", |c| {
1123            if !cfg_colors.contains_key("border") {
1124                if self.colors.panel_border != c {
1125                    self.colors.panel_border = c;
1126                    changed = true;
1127                }
1128                if self.colors.menu_border != c {
1129                    self.colors.menu_border = c;
1130                    changed = true;
1131                }
1132                if self.colors.list_border != c {
1133                    self.colors.list_border = c;
1134                    changed = true;
1135                }
1136            }
1137        });
1138        apply_semantic_color!("ring", |c| {
1139            if self.colors.focus_ring != c {
1140                self.colors.focus_ring = c;
1141                changed = true;
1142            }
1143        });
1144        apply_semantic_color!("card", |c| {
1145            if self.colors.panel_background != c {
1146                self.colors.panel_background = c;
1147                changed = true;
1148            }
1149            if !cfg_colors.contains_key("fret.list.background")
1150                && !cfg_colors.contains_key("color.list.background")
1151                && self.colors.list_background != c
1152            {
1153                self.colors.list_background = c;
1154                changed = true;
1155            }
1156        });
1157        apply_semantic_color!("popover", |c| {
1158            if self.colors.menu_background != c {
1159                self.colors.menu_background = c;
1160                changed = true;
1161            }
1162        });
1163        apply_semantic_color!("muted-foreground", |c| {
1164            if self.colors.text_muted != c {
1165                self.colors.text_muted = c;
1166                changed = true;
1167            }
1168        });
1169        apply_semantic_color!("accent", |c| {
1170            if self.colors.hover_background != c {
1171                self.colors.hover_background = c;
1172                changed = true;
1173            }
1174            if !cfg_colors.contains_key("fret.menu.item.hover")
1175                && !cfg_colors.contains_key("color.menu.item.hover")
1176                && self.colors.menu_item_hover != c
1177            {
1178                self.colors.menu_item_hover = c;
1179                changed = true;
1180            }
1181            if !cfg_colors.contains_key("fret.list.row.hover")
1182                && !cfg_colors.contains_key("color.list.row.hover")
1183                && self.colors.list_row_hover != c
1184            {
1185                self.colors.list_row_hover = c;
1186                changed = true;
1187            }
1188        });
1189        apply_semantic_color!("primary", |c| {
1190            if self.colors.accent != c {
1191                self.colors.accent = c;
1192                changed = true;
1193            }
1194            if !cfg_colors.contains_key("selection")
1195                && !cfg_colors.contains_key("selection.background")
1196                && !cfg_colors.contains_key("color.selection.background")
1197            {
1198                let selection = with_alpha(c, 0.4);
1199                if self.colors.selection_background != selection {
1200                    self.colors.selection_background = selection;
1201                    changed = true;
1202                }
1203            }
1204            if !cfg_colors.contains_key("selection.inactive.background")
1205                && !cfg_colors.contains_key("color.selection.inactive.background")
1206            {
1207                let inactive = with_alpha(c, 0.24);
1208                if self.colors.selection_inactive_background != inactive {
1209                    self.colors.selection_inactive_background = inactive;
1210                    changed = true;
1211                }
1212            }
1213            if !cfg_colors.contains_key("selection.window_inactive.background")
1214                && !cfg_colors.contains_key("color.selection.window_inactive.background")
1215            {
1216                let inactive = with_alpha(c, 0.16);
1217                if self.colors.selection_window_inactive_background != inactive {
1218                    self.colors.selection_window_inactive_background = inactive;
1219                    changed = true;
1220                }
1221            }
1222        });
1223
1224        macro_rules! apply_baseline_color {
1225            ($key:literal, $field:expr) => {
1226                if let Some(v) = cfg_colors.get($key) {
1227                    if let Some(c) = parse_color_to_linear(v) {
1228                        if $field != c {
1229                            $field = c;
1230                            changed = true;
1231                        }
1232                    }
1233                }
1234            };
1235        }
1236
1237        // Apply baseline dotted keys (ADR 0050) after semantic keys so explicit baseline tokens win.
1238        apply_baseline_color!("color.surface.background", self.colors.surface_background);
1239        apply_baseline_color!("color.panel.background", self.colors.panel_background);
1240        apply_baseline_color!("color.panel.border", self.colors.panel_border);
1241        apply_baseline_color!("color.text.primary", self.colors.text_primary);
1242        apply_baseline_color!("color.text.muted", self.colors.text_muted);
1243        apply_baseline_color!("color.text.disabled", self.colors.text_disabled);
1244        apply_baseline_color!("color.accent", self.colors.accent);
1245        if !cfg_colors.contains_key("color.selection.background") {
1246            apply_baseline_color!("selection.background", self.colors.selection_background);
1247        }
1248        if !cfg_colors.contains_key("color.selection.inactive.background") {
1249            apply_baseline_color!(
1250                "selection.inactive.background",
1251                self.colors.selection_inactive_background
1252            );
1253        }
1254        if !cfg_colors.contains_key("color.selection.window_inactive.background") {
1255            apply_baseline_color!(
1256                "selection.window_inactive.background",
1257                self.colors.selection_window_inactive_background
1258            );
1259        }
1260        apply_baseline_color!(
1261            "color.selection.background",
1262            self.colors.selection_background
1263        );
1264        apply_baseline_color!(
1265            "color.selection.inactive.background",
1266            self.colors.selection_inactive_background
1267        );
1268        apply_baseline_color!(
1269            "color.selection.window_inactive.background",
1270            self.colors.selection_window_inactive_background
1271        );
1272        apply_baseline_color!("color.hover.background", self.colors.hover_background);
1273        apply_baseline_color!("color.focus.ring", self.colors.focus_ring);
1274        apply_baseline_color!("color.menu.background", self.colors.menu_background);
1275        apply_baseline_color!("color.menu.border", self.colors.menu_border);
1276        apply_baseline_color!("color.menu.item.hover", self.colors.menu_item_hover);
1277        apply_baseline_color!("color.menu.item.selected", self.colors.menu_item_selected);
1278        apply_baseline_color!("color.list.background", self.colors.list_background);
1279        apply_baseline_color!("color.list.border", self.colors.list_border);
1280        apply_baseline_color!("color.list.row.hover", self.colors.list_row_hover);
1281        apply_baseline_color!("color.list.row.selected", self.colors.list_row_selected);
1282        apply_baseline_color!("color.scrollbar.track", self.colors.scrollbar_track);
1283        apply_baseline_color!("color.scrollbar.thumb", self.colors.scrollbar_thumb);
1284        apply_baseline_color!(
1285            "color.scrollbar.thumb.hover",
1286            self.colors.scrollbar_thumb_hover
1287        );
1288        apply_baseline_color!(
1289            "color.viewport.selection.fill",
1290            self.colors.viewport_selection_fill
1291        );
1292        apply_baseline_color!(
1293            "color.viewport.selection.stroke",
1294            self.colors.viewport_selection_stroke
1295        );
1296        apply_baseline_color!("color.viewport.marker", self.colors.viewport_marker);
1297        apply_baseline_color!(
1298            "color.viewport.drag_line.pan",
1299            self.colors.viewport_drag_line_pan
1300        );
1301        apply_baseline_color!(
1302            "color.viewport.drag_line.orbit",
1303            self.colors.viewport_drag_line_orbit
1304        );
1305        apply_baseline_color!("color.viewport.gizmo.x", self.colors.viewport_gizmo_x);
1306        apply_baseline_color!("color.viewport.gizmo.y", self.colors.viewport_gizmo_y);
1307        apply_baseline_color!(
1308            "color.viewport.gizmo.handle.background",
1309            self.colors.viewport_gizmo_handle_background
1310        );
1311        apply_baseline_color!(
1312            "color.viewport.gizmo.handle.border",
1313            self.colors.viewport_gizmo_handle_border
1314        );
1315        apply_baseline_color!(
1316            "color.viewport.rotate_gizmo",
1317            self.colors.viewport_rotate_gizmo
1318        );
1319
1320        apply_semantic_metric!("radius", |px| {
1321            if self.metrics.radius_lg != px {
1322                self.metrics.radius_lg = px;
1323                changed = true;
1324            }
1325            let md = Px((px.0 - 2.0).max(0.0));
1326            let sm = Px((px.0 - 4.0).max(0.0));
1327            if self.metrics.radius_md != md {
1328                self.metrics.radius_md = md;
1329                changed = true;
1330            }
1331            if self.metrics.radius_sm != sm {
1332                self.metrics.radius_sm = sm;
1333                changed = true;
1334            }
1335        });
1336        apply_semantic_metric!("font.size", |px| {
1337            if self.metrics.font_size != px {
1338                self.metrics.font_size = px;
1339                changed = true;
1340            }
1341        });
1342        apply_semantic_metric!("mono_font.size", |px| {
1343            if self.metrics.mono_font_size != px {
1344                self.metrics.mono_font_size = px;
1345                changed = true;
1346            }
1347        });
1348        apply_semantic_metric!("font.line_height", |px| {
1349            if self.metrics.font_line_height != px {
1350                self.metrics.font_line_height = px;
1351                changed = true;
1352            }
1353        });
1354        apply_semantic_metric!("mono_font.line_height", |px| {
1355            if self.metrics.mono_font_line_height != px {
1356                self.metrics.mono_font_line_height = px;
1357                changed = true;
1358            }
1359        });
1360
1361        apply_metric!("metric.radius.sm", self.metrics.radius_sm);
1362        apply_metric!("metric.radius.md", self.metrics.radius_md);
1363        apply_metric!("metric.radius.lg", self.metrics.radius_lg);
1364        apply_metric!("metric.padding.sm", self.metrics.padding_sm);
1365        apply_metric!("metric.padding.md", self.metrics.padding_md);
1366        apply_metric!("metric.scrollbar.width", self.metrics.scrollbar_width);
1367        apply_metric!("metric.font.size", self.metrics.font_size);
1368        apply_metric!("metric.font.mono_size", self.metrics.mono_font_size);
1369        apply_metric!("metric.font.line_height", self.metrics.font_line_height);
1370        apply_metric!(
1371            "metric.font.mono_line_height",
1372            self.metrics.mono_font_line_height
1373        );
1374
1375        // gpui-component compatibility: accept `font.size` / `mono_font.size` when the canonical
1376        // `metric.font.*` keys are not present.
1377        if !cfg_metrics.contains_key("metric.font.size")
1378            && let Some(v) = cfg_metrics.get("font.size").copied()
1379        {
1380            let px = Px(v);
1381            if self.metrics.font_size != px {
1382                self.metrics.font_size = px;
1383                changed = true;
1384            }
1385        }
1386        if !cfg_metrics.contains_key("metric.font.mono_size")
1387            && let Some(v) = cfg_metrics.get("mono_font.size").copied()
1388        {
1389            let px = Px(v);
1390            if self.metrics.mono_font_size != px {
1391                self.metrics.mono_font_size = px;
1392                changed = true;
1393            }
1394        }
1395        if !cfg_metrics.contains_key("metric.font.line_height")
1396            && let Some(v) = cfg_metrics.get("font.line_height").copied()
1397        {
1398            let px = Px(v);
1399            if self.metrics.font_line_height != px {
1400                self.metrics.font_line_height = px;
1401                changed = true;
1402            }
1403        }
1404        if !cfg_metrics.contains_key("metric.font.mono_line_height")
1405            && let Some(v) = cfg_metrics.get("mono_font.line_height").copied()
1406        {
1407            let px = Px(v);
1408            if self.metrics.mono_font_line_height != px {
1409                self.metrics.mono_font_line_height = px;
1410                changed = true;
1411            }
1412        }
1413
1414        let mut next_colors = default_color_tokens(self.colors);
1415        let mut next_metrics = default_metric_tokens(self.metrics);
1416
1417        for (k, v) in &cfg_colors {
1418            if let Some(c) = parse_color_to_linear(v) {
1419                next_colors.insert(k.clone(), c);
1420            }
1421        }
1422
1423        for (k, v) in &cfg_metrics {
1424            next_metrics.insert(k.clone(), Px(*v));
1425        }
1426
1427        for (k, v) in &cfg_numbers {
1428            next_numbers.insert(k.clone(), *v);
1429        }
1430
1431        for (k, v) in &cfg_durations_ms {
1432            next_durations_ms.insert(k.clone(), *v);
1433        }
1434
1435        for (k, v) in &cfg_easings {
1436            next_easings.insert(k.clone(), *v);
1437        }
1438
1439        for (k, v) in &cfg_text_styles {
1440            next_text_styles.insert(k.clone(), v.clone());
1441        }
1442
1443        for (k, v) in &cfg_corners {
1444            next_corners.insert(k.clone(), *v);
1445        }
1446
1447        // Ensure baseline + semantic keys mirror the resolved typed baseline, even if the config
1448        // provided overlapping aliases.
1449        next_colors.insert(
1450            "color.surface.background".to_string(),
1451            self.colors.surface_background,
1452        );
1453        next_colors.insert(
1454            "color.panel.background".to_string(),
1455            self.colors.panel_background,
1456        );
1457        next_colors.insert("color.panel.border".to_string(), self.colors.panel_border);
1458        next_colors.insert("color.text.primary".to_string(), self.colors.text_primary);
1459        next_colors.insert("color.text.muted".to_string(), self.colors.text_muted);
1460        next_colors.insert("color.text.disabled".to_string(), self.colors.text_disabled);
1461        next_colors.insert("color.accent".to_string(), self.colors.accent);
1462        next_colors.insert(
1463            "color.selection.background".to_string(),
1464            self.colors.selection_background,
1465        );
1466        next_colors.insert(
1467            "color.selection.inactive.background".to_string(),
1468            self.colors.selection_inactive_background,
1469        );
1470        next_colors.insert(
1471            "color.selection.window_inactive.background".to_string(),
1472            self.colors.selection_window_inactive_background,
1473        );
1474        next_colors.insert(
1475            "color.hover.background".to_string(),
1476            self.colors.hover_background,
1477        );
1478        next_colors.insert("color.focus.ring".to_string(), self.colors.focus_ring);
1479        next_colors.insert(
1480            "color.menu.background".to_string(),
1481            self.colors.menu_background,
1482        );
1483        next_colors.insert("color.menu.border".to_string(), self.colors.menu_border);
1484        next_colors.insert(
1485            "color.menu.item.hover".to_string(),
1486            self.colors.menu_item_hover,
1487        );
1488        next_colors.insert(
1489            "color.menu.item.selected".to_string(),
1490            self.colors.menu_item_selected,
1491        );
1492        next_colors.insert(
1493            "color.list.background".to_string(),
1494            self.colors.list_background,
1495        );
1496        next_colors.insert("color.list.border".to_string(), self.colors.list_border);
1497        next_colors.insert(
1498            "color.list.row.hover".to_string(),
1499            self.colors.list_row_hover,
1500        );
1501        next_colors.insert(
1502            "color.list.row.selected".to_string(),
1503            self.colors.list_row_selected,
1504        );
1505        next_colors.insert(
1506            "color.scrollbar.track".to_string(),
1507            self.colors.scrollbar_track,
1508        );
1509        next_colors.insert(
1510            "color.scrollbar.thumb".to_string(),
1511            self.colors.scrollbar_thumb,
1512        );
1513        next_colors.insert(
1514            "color.scrollbar.thumb.hover".to_string(),
1515            self.colors.scrollbar_thumb_hover,
1516        );
1517        next_colors.insert(
1518            "color.viewport.selection.fill".to_string(),
1519            self.colors.viewport_selection_fill,
1520        );
1521        next_colors.insert(
1522            "color.viewport.selection.stroke".to_string(),
1523            self.colors.viewport_selection_stroke,
1524        );
1525        next_colors.insert(
1526            "color.viewport.marker".to_string(),
1527            self.colors.viewport_marker,
1528        );
1529        next_colors.insert(
1530            "color.viewport.drag_line.pan".to_string(),
1531            self.colors.viewport_drag_line_pan,
1532        );
1533        next_colors.insert(
1534            "color.viewport.drag_line.orbit".to_string(),
1535            self.colors.viewport_drag_line_orbit,
1536        );
1537        next_colors.insert(
1538            "color.viewport.gizmo.x".to_string(),
1539            self.colors.viewport_gizmo_x,
1540        );
1541        next_colors.insert(
1542            "color.viewport.gizmo.y".to_string(),
1543            self.colors.viewport_gizmo_y,
1544        );
1545        next_colors.insert(
1546            "color.viewport.gizmo.handle.background".to_string(),
1547            self.colors.viewport_gizmo_handle_background,
1548        );
1549        next_colors.insert(
1550            "color.viewport.gizmo.handle.border".to_string(),
1551            self.colors.viewport_gizmo_handle_border,
1552        );
1553        next_colors.insert(
1554            "color.viewport.rotate_gizmo".to_string(),
1555            self.colors.viewport_rotate_gizmo,
1556        );
1557
1558        // Keep shadcn semantic aliases coherent with the resolved typed baseline fields.
1559        //
1560        // Important: do not overwrite config-provided tokens that do *not* map onto typed baseline
1561        // fields (e.g. `*-foreground`). Those are part of the shadcn token surface and must
1562        // remain author-controlled.
1563        next_colors.insert("background".to_string(), self.colors.surface_background);
1564        next_colors.insert("foreground".to_string(), self.colors.text_primary);
1565        next_colors.insert("border".to_string(), self.colors.panel_border);
1566        next_colors.insert("input".to_string(), self.colors.panel_border);
1567        next_colors.insert("ring".to_string(), self.colors.focus_ring);
1568        next_colors.insert(
1569            "ring-offset-background".to_string(),
1570            self.colors.surface_background,
1571        );
1572        next_colors.insert("card".to_string(), self.colors.panel_background);
1573        next_colors.insert("popover".to_string(), self.colors.menu_background);
1574        next_colors.insert("muted-foreground".to_string(), self.colors.text_muted);
1575        next_colors.insert("accent".to_string(), self.colors.hover_background);
1576        next_colors.insert("primary".to_string(), self.colors.accent);
1577
1578        next_metrics.insert("metric.radius.sm".to_string(), self.metrics.radius_sm);
1579        next_metrics.insert("metric.radius.md".to_string(), self.metrics.radius_md);
1580        next_metrics.insert("metric.radius.lg".to_string(), self.metrics.radius_lg);
1581        next_metrics.insert("metric.padding.sm".to_string(), self.metrics.padding_sm);
1582        next_metrics.insert("metric.padding.md".to_string(), self.metrics.padding_md);
1583        next_metrics.insert(
1584            "metric.scrollbar.width".to_string(),
1585            self.metrics.scrollbar_width,
1586        );
1587        next_metrics.insert("metric.font.size".to_string(), self.metrics.font_size);
1588        next_metrics.insert(
1589            "metric.font.mono_size".to_string(),
1590            self.metrics.mono_font_size,
1591        );
1592        next_metrics.insert(
1593            "metric.font.line_height".to_string(),
1594            self.metrics.font_line_height,
1595        );
1596        next_metrics.insert(
1597            "metric.font.mono_line_height".to_string(),
1598            self.metrics.mono_font_line_height,
1599        );
1600
1601        next_metrics.insert("radius".to_string(), self.metrics.radius_lg);
1602        next_metrics.insert("radius.sm".to_string(), self.metrics.radius_sm);
1603        next_metrics.insert("radius.md".to_string(), self.metrics.radius_md);
1604        next_metrics.insert("radius.lg".to_string(), self.metrics.radius_lg);
1605        next_metrics.insert("font.size".to_string(), self.metrics.font_size);
1606        next_metrics.insert("mono_font.size".to_string(), self.metrics.mono_font_size);
1607        next_metrics.insert(
1608            "font.line_height".to_string(),
1609            self.metrics.font_line_height,
1610        );
1611        next_metrics.insert(
1612            "mono_font.line_height".to_string(),
1613            self.metrics.mono_font_line_height,
1614        );
1615
1616        let next_configured_colors: HashSet<String> = cfg_colors.keys().cloned().collect();
1617        if self.configured_colors != next_configured_colors {
1618            self.configured_colors = next_configured_colors;
1619            changed = true;
1620        }
1621        let next_configured_metrics: HashSet<String> = cfg_metrics.keys().cloned().collect();
1622        if self.configured_metrics != next_configured_metrics {
1623            self.configured_metrics = next_configured_metrics;
1624            changed = true;
1625        }
1626        let next_configured_corners: HashSet<String> = cfg_corners.keys().cloned().collect();
1627        if self.configured_corners != next_configured_corners {
1628            self.configured_corners = next_configured_corners;
1629            changed = true;
1630        }
1631
1632        let next_colors = Arc::new(next_colors);
1633        let next_metrics = Arc::new(next_metrics);
1634
1635        if self.extra_colors != next_colors {
1636            self.extra_colors = next_colors;
1637            changed = true;
1638        }
1639        if self.extra_metrics != next_metrics {
1640            self.extra_metrics = next_metrics;
1641            changed = true;
1642        }
1643        if self.extra_corners != next_corners {
1644            self.extra_corners = next_corners;
1645            changed = true;
1646        }
1647
1648        let next_configured_numbers: HashSet<String> = cfg_numbers.keys().cloned().collect();
1649        if self.configured_numbers != next_configured_numbers {
1650            self.configured_numbers = next_configured_numbers;
1651            changed = true;
1652        }
1653
1654        let next_configured_durations_ms: HashSet<String> =
1655            cfg_durations_ms.keys().cloned().collect();
1656        if self.configured_durations_ms != next_configured_durations_ms {
1657            self.configured_durations_ms = next_configured_durations_ms;
1658            changed = true;
1659        }
1660
1661        let next_configured_easings: HashSet<String> = cfg_easings.keys().cloned().collect();
1662        if self.configured_easings != next_configured_easings {
1663            self.configured_easings = next_configured_easings;
1664            changed = true;
1665        }
1666
1667        let next_configured_text_styles: HashSet<String> =
1668            cfg_text_styles.keys().cloned().collect();
1669        if self.configured_text_styles != next_configured_text_styles {
1670            self.configured_text_styles = next_configured_text_styles;
1671            changed = true;
1672        }
1673
1674        if self.extra_numbers != next_numbers {
1675            self.extra_numbers = next_numbers;
1676            changed = true;
1677        }
1678        if self.extra_durations_ms != next_durations_ms {
1679            self.extra_durations_ms = next_durations_ms;
1680            changed = true;
1681        }
1682        if self.extra_easings != next_easings {
1683            self.extra_easings = next_easings;
1684            changed = true;
1685        }
1686        if self.extra_text_styles != next_text_styles {
1687            self.extra_text_styles = next_text_styles;
1688            changed = true;
1689        }
1690
1691        if changed {
1692            self.revision = self.revision.saturating_add(1);
1693        }
1694    }
1695
1696    /// Merge additional tokens from a `ThemeConfig` into the current theme without resetting the
1697    /// baseline theme (colors/metrics) or the configured-key tracking sets.
1698    ///
1699    /// This is intended for ecosystem-driven design system presets (e.g. Material 3) that need to
1700    /// inject extra token kinds (motion/state/typography) on top of an existing theme preset
1701    /// (e.g. a shadcn color scheme in the gallery app).
1702    pub fn extend_tokens_from_config(&mut self, cfg: &ThemeConfig) {
1703        let mut changed = false;
1704
1705        let colors = canonicalize_config_map(ThemeTokenKind::Color, &cfg.colors);
1706        for (key, v) in &colors {
1707            if let Some(c) = parse_color_to_linear(v) {
1708                match self.extra_colors.get(key.as_str()).copied() {
1709                    Some(prev) if prev == c => {}
1710                    _ => {
1711                        Arc::make_mut(&mut self.extra_colors).insert(key.to_string(), c);
1712                        changed = true;
1713                    }
1714                }
1715            }
1716        }
1717
1718        let metrics = canonicalize_config_map(ThemeTokenKind::Metric, &cfg.metrics);
1719        for (key, v) in &metrics {
1720            let px = Px(*v);
1721            match self.extra_metrics.get(key.as_str()).copied() {
1722                Some(prev) if prev == px => {}
1723                _ => {
1724                    Arc::make_mut(&mut self.extra_metrics).insert(key.to_string(), px);
1725                    changed = true;
1726                }
1727            }
1728        }
1729
1730        let corners = canonicalize_config_map(ThemeTokenKind::Corners, &cfg.corners);
1731        for (key, v) in &corners {
1732            match self.extra_corners.get(key.as_str()).copied() {
1733                Some(prev) if prev == *v => {}
1734                _ => {
1735                    self.extra_corners.insert(key.to_string(), *v);
1736                    changed = true;
1737                }
1738            }
1739        }
1740
1741        let numbers = canonicalize_config_map(ThemeTokenKind::Number, &cfg.numbers);
1742        for (key, v) in &numbers {
1743            match self.extra_numbers.get(key.as_str()).copied() {
1744                Some(prev) if (prev - *v).abs() < 1e-6 => {}
1745                _ => {
1746                    self.extra_numbers.insert(key.to_string(), *v);
1747                    changed = true;
1748                }
1749            }
1750        }
1751
1752        let durations_ms = canonicalize_config_map(ThemeTokenKind::DurationMs, &cfg.durations_ms);
1753        for (key, v) in &durations_ms {
1754            match self.extra_durations_ms.get(key.as_str()).copied() {
1755                Some(prev) if prev == *v => {}
1756                _ => {
1757                    self.extra_durations_ms.insert(key.to_string(), *v);
1758                    changed = true;
1759                }
1760            }
1761        }
1762
1763        let easings = canonicalize_config_map(ThemeTokenKind::Easing, &cfg.easings);
1764        for (key, v) in &easings {
1765            match self.extra_easings.get(key.as_str()).copied() {
1766                Some(prev) if prev == *v => {}
1767                _ => {
1768                    self.extra_easings.insert(key.to_string(), *v);
1769                    changed = true;
1770                }
1771            }
1772        }
1773
1774        let text_styles = canonicalize_config_map(ThemeTokenKind::TextStyle, &cfg.text_styles);
1775        for (key, v) in &text_styles {
1776            match self.extra_text_styles.get(key.as_str()) {
1777                Some(prev) if prev == v => {}
1778                _ => {
1779                    self.extra_text_styles.insert(key.to_string(), v.clone());
1780                    changed = true;
1781                }
1782            }
1783        }
1784
1785        if changed {
1786            self.revision = self.revision.saturating_add(1);
1787        }
1788    }
1789
1790    /// Apply a `ThemeConfig` as a patch layered on top of the current theme.
1791    ///
1792    /// Unlike [`Self::apply_config`], this does **not** reset any existing token tables. This is
1793    /// the intended API for app-level overrides (e.g. "compact editor" metric tweaks) layered on
1794    /// top of an ecosystem preset (e.g. shadcn New York).
1795    ///
1796    /// This updates the `configured_*` tracking sets by adding the keys present in `cfg` and
1797    /// increments the theme revision when the effective token tables change.
1798    pub fn apply_config_patch(&mut self, cfg: &ThemeConfig) {
1799        assert_no_legacy_theme_keys(cfg);
1800
1801        let mut changed = false;
1802
1803        if let Some(scheme) = cfg.color_scheme
1804            && self.color_scheme != Some(scheme)
1805        {
1806            self.color_scheme = Some(scheme);
1807            changed = true;
1808        }
1809
1810        let colors = canonicalize_config_map(ThemeTokenKind::Color, &cfg.colors);
1811        for (key, v) in &colors {
1812            self.configured_colors.insert(key.to_string());
1813            let Some(c) = parse_color_to_linear(v) else {
1814                continue;
1815            };
1816            match self.extra_colors.get(key.as_str()).copied() {
1817                Some(prev) if prev == c => {}
1818                _ => {
1819                    Arc::make_mut(&mut self.extra_colors).insert(key.to_string(), c);
1820                    changed = true;
1821                }
1822            }
1823        }
1824
1825        let metrics = canonicalize_config_map(ThemeTokenKind::Metric, &cfg.metrics);
1826        for (key, v) in &metrics {
1827            self.configured_metrics.insert(key.to_string());
1828            let px = Px(*v);
1829            match self.extra_metrics.get(key.as_str()).copied() {
1830                Some(prev) if prev == px => {}
1831                _ => {
1832                    Arc::make_mut(&mut self.extra_metrics).insert(key.to_string(), px);
1833                    changed = true;
1834                }
1835            }
1836        }
1837
1838        let corners = canonicalize_config_map(ThemeTokenKind::Corners, &cfg.corners);
1839        for (key, v) in &corners {
1840            self.configured_corners.insert(key.to_string());
1841            match self.extra_corners.get(key.as_str()).copied() {
1842                Some(prev) if prev == *v => {}
1843                _ => {
1844                    self.extra_corners.insert(key.to_string(), *v);
1845                    changed = true;
1846                }
1847            }
1848        }
1849
1850        let numbers = canonicalize_config_map(ThemeTokenKind::Number, &cfg.numbers);
1851        for (key, v) in &numbers {
1852            self.configured_numbers.insert(key.to_string());
1853            match self.extra_numbers.get(key.as_str()).copied() {
1854                Some(prev) if (prev - *v).abs() < 1e-6 => {}
1855                _ => {
1856                    self.extra_numbers.insert(key.to_string(), *v);
1857                    changed = true;
1858                }
1859            }
1860        }
1861
1862        let durations_ms = canonicalize_config_map(ThemeTokenKind::DurationMs, &cfg.durations_ms);
1863        for (key, v) in &durations_ms {
1864            self.configured_durations_ms.insert(key.to_string());
1865            match self.extra_durations_ms.get(key.as_str()).copied() {
1866                Some(prev) if prev == *v => {}
1867                _ => {
1868                    self.extra_durations_ms.insert(key.to_string(), *v);
1869                    changed = true;
1870                }
1871            }
1872        }
1873
1874        let easings = canonicalize_config_map(ThemeTokenKind::Easing, &cfg.easings);
1875        for (key, v) in &easings {
1876            self.configured_easings.insert(key.to_string());
1877            match self.extra_easings.get(key.as_str()).copied() {
1878                Some(prev) if prev == *v => {}
1879                _ => {
1880                    self.extra_easings.insert(key.to_string(), *v);
1881                    changed = true;
1882                }
1883            }
1884        }
1885
1886        let text_styles = canonicalize_config_map(ThemeTokenKind::TextStyle, &cfg.text_styles);
1887        for (key, v) in &text_styles {
1888            self.configured_text_styles.insert(key.to_string());
1889            match self.extra_text_styles.get(key.as_str()) {
1890                Some(prev) if prev == v => {}
1891                _ => {
1892                    self.extra_text_styles.insert(key.to_string(), v.clone());
1893                    changed = true;
1894                }
1895            }
1896        }
1897
1898        if changed {
1899            self.revision = self.revision.saturating_add(1);
1900        }
1901    }
1902}
1903
1904fn default_theme() -> &'static Theme {
1905    static DEFAULT: OnceLock<Theme> = OnceLock::new();
1906    DEFAULT.get_or_init(|| {
1907        let metrics = ThemeMetrics {
1908            radius_sm: Px(6.0),
1909            radius_md: Px(8.0),
1910            radius_lg: Px(10.0),
1911            padding_sm: Px(8.0),
1912            padding_md: Px(10.0),
1913            scrollbar_width: Px(10.0),
1914            font_size: Px(13.0),
1915            mono_font_size: Px(13.0),
1916            font_line_height: Px(16.0),
1917            mono_font_line_height: Px(16.0),
1918        };
1919        let colors = ThemeColors {
1920            surface_background: parse_default_theme_hex_color("surface_background", "#24272E"),
1921            panel_background: parse_default_theme_hex_color("panel_background", "#2B3038"),
1922            panel_border: parse_default_theme_hex_color("panel_border", "#3A424D"),
1923            text_primary: parse_default_theme_hex_color("text_primary", "#D7DEE9"),
1924            text_muted: parse_default_theme_hex_color("text_muted", "#AAB3C2"),
1925            text_disabled: parse_default_theme_hex_color("text_disabled", "#7D8798"),
1926            accent: parse_default_theme_hex_color("accent", "#3D8BFF"),
1927            selection_background: parse_default_theme_hex_color(
1928                "selection_background",
1929                "#3D8BFF66",
1930            ),
1931            selection_inactive_background: parse_default_theme_hex_color(
1932                "selection_inactive_background",
1933                "#3D8BFF3D",
1934            ),
1935            selection_window_inactive_background: parse_default_theme_hex_color(
1936                "selection_window_inactive_background",
1937                "#3D8BFF24",
1938            ),
1939            hover_background: parse_default_theme_hex_color("hover_background", "#363C46"),
1940            focus_ring: parse_default_theme_hex_color("focus_ring", "#3D8BFFCC"),
1941            menu_background: parse_default_theme_hex_color("menu_background", "#2B3038"),
1942            menu_border: parse_default_theme_hex_color("menu_border", "#3A424D"),
1943            menu_item_hover: parse_default_theme_hex_color("menu_item_hover", "#363C46"),
1944            menu_item_selected: parse_default_theme_hex_color("menu_item_selected", "#3D8BFF66"),
1945            list_background: parse_default_theme_hex_color("list_background", "#2B3038"),
1946            list_border: parse_default_theme_hex_color("list_border", "#3A424D"),
1947            list_row_hover: parse_default_theme_hex_color("list_row_hover", "#363C46"),
1948            list_row_selected: parse_default_theme_hex_color("list_row_selected", "#3D8BFF66"),
1949            scrollbar_track: parse_default_theme_hex_color("scrollbar_track", "#1C1F25"),
1950            scrollbar_thumb: parse_default_theme_hex_color("scrollbar_thumb", "#4C5666"),
1951            scrollbar_thumb_hover: parse_default_theme_hex_color(
1952                "scrollbar_thumb_hover",
1953                "#5A687D",
1954            ),
1955
1956            viewport_selection_fill: parse_default_theme_hex_color(
1957                "viewport_selection_fill",
1958                "#3D8BFF29",
1959            ),
1960            viewport_selection_stroke: parse_default_theme_hex_color(
1961                "viewport_selection_stroke",
1962                "#3D8BFFCC",
1963            ),
1964            viewport_marker: parse_default_theme_hex_color("viewport_marker", "#3D8BFFFF"),
1965            viewport_drag_line_pan: parse_default_theme_hex_color(
1966                "viewport_drag_line_pan",
1967                "#33E684D9",
1968            ),
1969            viewport_drag_line_orbit: parse_default_theme_hex_color(
1970                "viewport_drag_line_orbit",
1971                "#FFC44AD9",
1972            ),
1973            viewport_gizmo_x: parse_default_theme_hex_color("viewport_gizmo_x", "#E74C3CFF"),
1974            viewport_gizmo_y: parse_default_theme_hex_color("viewport_gizmo_y", "#2ECC71FF"),
1975            viewport_gizmo_handle_background: parse_default_theme_hex_color(
1976                "viewport_gizmo_handle_background",
1977                "#1E2229FF",
1978            ),
1979            viewport_gizmo_handle_border: parse_default_theme_hex_color(
1980                "viewport_gizmo_handle_border",
1981                "#D7DEE9FF",
1982            ),
1983            viewport_rotate_gizmo: parse_default_theme_hex_color(
1984                "viewport_rotate_gizmo",
1985                "#FFC44AFF",
1986            ),
1987        };
1988
1989        Theme {
1990            name: "Fret Default (Dark)".to_string(),
1991            author: Some("Fret".to_string()),
1992            url: None,
1993            color_scheme: Some(ColorScheme::Dark),
1994            revision: 1,
1995            metrics,
1996            colors,
1997            extra_colors: Arc::new(default_color_tokens(colors)),
1998            extra_metrics: Arc::new(default_metric_tokens(metrics)),
1999            extra_corners: HashMap::new(),
2000            extra_numbers: HashMap::new(),
2001            extra_durations_ms: HashMap::new(),
2002            extra_easings: HashMap::new(),
2003            extra_text_styles: HashMap::new(),
2004            configured_colors: HashSet::new(),
2005            configured_metrics: HashSet::new(),
2006            configured_corners: HashSet::new(),
2007            configured_numbers: HashSet::new(),
2008            configured_durations_ms: HashSet::new(),
2009            configured_easings: HashSet::new(),
2010            configured_text_styles: HashSet::new(),
2011        }
2012    })
2013}
2014
2015fn parse_color_to_linear(s: &str) -> Option<Color> {
2016    parse_hex_srgb_to_linear(s)
2017        .or_else(|| parse_hsl_tokens_to_linear(s))
2018        .or_else(|| parse_oklch_to_linear(s))
2019}
2020
2021fn parse_hex_srgb_to_linear(s: &str) -> Option<Color> {
2022    let s = s.trim();
2023    let hex = s.strip_prefix('#').unwrap_or(s);
2024    let (r, g, b, a) = match hex.len() {
2025        6 => {
2026            let r = u8::from_str_radix(&hex[0..2], 16).ok()?;
2027            let g = u8::from_str_radix(&hex[2..4], 16).ok()?;
2028            let b = u8::from_str_radix(&hex[4..6], 16).ok()?;
2029            (r, g, b, 255)
2030        }
2031        8 => {
2032            let r = u8::from_str_radix(&hex[0..2], 16).ok()?;
2033            let g = u8::from_str_radix(&hex[2..4], 16).ok()?;
2034            let b = u8::from_str_radix(&hex[4..6], 16).ok()?;
2035            let a = u8::from_str_radix(&hex[6..8], 16).ok()?;
2036            (r, g, b, a)
2037        }
2038        _ => return None,
2039    };
2040
2041    Some(Color {
2042        r: srgb_channel_to_linear(r),
2043        g: srgb_channel_to_linear(g),
2044        b: srgb_channel_to_linear(b),
2045        a: a as f32 / 255.0,
2046    })
2047}
2048
2049fn srgb_channel_to_linear(u: u8) -> f32 {
2050    let c = u as f32 / 255.0;
2051    srgb_f32_to_linear(c)
2052}
2053
2054fn srgb_f32_to_linear(c: f32) -> f32 {
2055    if c <= 0.04045 {
2056        c / 12.92
2057    } else {
2058        ((c + 0.055) / 1.055).powf(2.4)
2059    }
2060}
2061
2062fn parse_hsl_tokens_to_linear(s: &str) -> Option<Color> {
2063    let s = s.trim();
2064    if s.is_empty() {
2065        return None;
2066    }
2067
2068    let inner = s
2069        .strip_prefix("hsl(")
2070        .and_then(|rest| rest.strip_suffix(')'))
2071        .unwrap_or(s);
2072
2073    // shadcn v3 theme tokens use `H S% L%` (space-separated) or the same inside `hsl(...)`.
2074    let parts: Vec<&str> = inner
2075        .split(|c: char| c.is_whitespace() || c == ',')
2076        .filter(|p| !p.is_empty())
2077        .collect();
2078    if parts.len() != 3 {
2079        return None;
2080    }
2081
2082    let h_deg: f32 = parts[0].parse().ok()?;
2083    let s_pct: f32 = parts[1].trim_end_matches('%').parse().ok()?;
2084    let l_pct: f32 = parts[2].trim_end_matches('%').parse().ok()?;
2085
2086    let h = (h_deg % 360.0 + 360.0) % 360.0 / 360.0;
2087    let s = (s_pct / 100.0).clamp(0.0, 1.0);
2088    let l = (l_pct / 100.0).clamp(0.0, 1.0);
2089
2090    let (r_srgb, g_srgb, b_srgb) = hsl_to_srgb(h, s, l);
2091    Some(Color {
2092        r: srgb_f32_to_linear(r_srgb.clamp(0.0, 1.0)),
2093        g: srgb_f32_to_linear(g_srgb.clamp(0.0, 1.0)),
2094        b: srgb_f32_to_linear(b_srgb.clamp(0.0, 1.0)),
2095        a: 1.0,
2096    })
2097}
2098
2099fn hsl_to_srgb(h: f32, s: f32, l: f32) -> (f32, f32, f32) {
2100    if s == 0.0 {
2101        return (l, l, l);
2102    }
2103
2104    fn hue_to_rgb(p: f32, q: f32, mut t: f32) -> f32 {
2105        if t < 0.0 {
2106            t += 1.0;
2107        }
2108        if t > 1.0 {
2109            t -= 1.0;
2110        }
2111        if t < 1.0 / 6.0 {
2112            return p + (q - p) * 6.0 * t;
2113        }
2114        if t < 1.0 / 2.0 {
2115            return q;
2116        }
2117        if t < 2.0 / 3.0 {
2118            return p + (q - p) * (2.0 / 3.0 - t) * 6.0;
2119        }
2120        p
2121    }
2122
2123    let q = if l < 0.5 {
2124        l * (1.0 + s)
2125    } else {
2126        l + s - l * s
2127    };
2128    let p = 2.0 * l - q;
2129    (
2130        hue_to_rgb(p, q, h + 1.0 / 3.0),
2131        hue_to_rgb(p, q, h),
2132        hue_to_rgb(p, q, h - 1.0 / 3.0),
2133    )
2134}
2135
2136#[allow(clippy::excessive_precision)]
2137fn parse_oklch_to_linear(s: &str) -> Option<Color> {
2138    let s = s.trim();
2139    let inner = s.strip_prefix("oklch(")?.strip_suffix(')')?.trim();
2140
2141    // Accept `oklch(L C H / A)` where A can be `0..1` or `NN%`.
2142    let (main, alpha_part) = if let Some((l, r)) = inner.split_once('/') {
2143        (l.trim(), Some(r.trim()))
2144    } else {
2145        (inner, None)
2146    };
2147
2148    let parts: Vec<&str> = main
2149        .split(|c: char| c.is_whitespace() || c == ',')
2150        .filter(|p| !p.is_empty())
2151        .collect();
2152    if parts.len() != 3 {
2153        return None;
2154    }
2155
2156    let l: f32 = parts[0].parse().ok()?;
2157    let c: f32 = parts[1].parse().ok()?;
2158    let h_deg: f32 = parts[2].parse().ok()?;
2159
2160    let alpha = if let Some(a) = alpha_part {
2161        if let Some(pct) = a.trim_end_matches('%').parse::<f32>().ok()
2162            && a.trim_end().ends_with('%')
2163        {
2164            (pct / 100.0).clamp(0.0, 1.0)
2165        } else {
2166            a.parse::<f32>().ok()?.clamp(0.0, 1.0)
2167        }
2168    } else {
2169        1.0
2170    };
2171
2172    // OKLCH -> OKLab
2173    let h_rad = h_deg.to_radians();
2174    let a = c * h_rad.cos();
2175    let b = c * h_rad.sin();
2176
2177    // OKLab -> linear sRGB (Björn Ottosson's reference implementation)
2178    let l_ = l + 0.396_337_777_4 * a + 0.215_803_757_3 * b;
2179    let m_ = l - 0.105_561_345_8 * a - 0.063_854_172_8 * b;
2180    let s_ = l - 0.089_484_177_5 * a - 1.291_485_548_0 * b;
2181
2182    let l3 = l_ * l_ * l_;
2183    let m3 = m_ * m_ * m_;
2184    let s3 = s_ * s_ * s_;
2185
2186    let r_lin = 4.076_741_662_1 * l3 - 3.307_711_591_3 * m3 + 0.230_969_929_2 * s3;
2187    let g_lin = -1.268_438_004_6 * l3 + 2.609_757_401_1 * m3 - 0.341_319_396_5 * s3;
2188    let b_lin = -0.004_196_086_3 * l3 - 0.703_418_614_7 * m3 + 1.707_614_701_0 * s3;
2189
2190    Some(Color {
2191        r: r_lin.clamp(0.0, 1.0),
2192        g: g_lin.clamp(0.0, 1.0),
2193        b: b_lin.clamp(0.0, 1.0),
2194        a: alpha,
2195    })
2196}
2197
2198fn with_alpha(mut color: Color, alpha: f32) -> Color {
2199    color.a = alpha;
2200    color
2201}
2202
2203fn assert_no_legacy_theme_keys(_cfg: &ThemeConfig) {
2204    // TODO: enforce/diagnose legacy keys once the theme config migration settles.
2205}
2206
2207#[cfg(test)]
2208mod tests {
2209    use super::parse_color_to_linear;
2210    use super::{CubicBezier, Theme, ThemeConfig};
2211    use crate::{ThemeColorKey, ThemeMetricKey, ThemeNamedColorKey};
2212    use fret_core::{Corners, FontId, FontWeight, Px, TextSlant, TextStyle};
2213    use std::collections::HashMap;
2214
2215    #[test]
2216    fn syntax_color_resolves_prefix_fallback_tokens() {
2217        let mut host = crate::test_host::TestHost::default();
2218        Theme::with_global_mut(&mut host, |theme| {
2219            let mut colors = HashMap::<String, String>::new();
2220            colors.insert("color.syntax.keyword".to_string(), "#ff0000".to_string());
2221            theme.apply_config(&ThemeConfig {
2222                name: "test".to_string(),
2223                colors,
2224                ..Default::default()
2225            });
2226
2227            let exact = theme
2228                .syntax_color("keyword")
2229                .expect("exact token should resolve");
2230            let prefixed = theme
2231                .syntax_color("keyword.operator")
2232                .expect("prefixed tag should resolve via prefix fallback");
2233            assert_eq!(exact, prefixed);
2234        });
2235    }
2236
2237    #[test]
2238    fn shadcn_semantic_palette_aliases_exist_on_default_theme() {
2239        let host = crate::test_host::TestHost::default();
2240        let theme = Theme::global(&host);
2241
2242        for key in [
2243            "background",
2244            "foreground",
2245            "border",
2246            "input",
2247            "input.border",
2248            "ring",
2249            "ring-offset-background",
2250            "card",
2251            "card.background",
2252            "card-foreground",
2253            "card.foreground",
2254            "primary",
2255            "primary.background",
2256            "primary-foreground",
2257            "primary.foreground",
2258            "secondary",
2259            "secondary.background",
2260            "secondary-foreground",
2261            "secondary.foreground",
2262            "destructive",
2263            "destructive.background",
2264            "destructive-foreground",
2265            "destructive.foreground",
2266            "muted",
2267            "muted-foreground",
2268            "input.background",
2269            "input.foreground",
2270            "accent",
2271            "accent-foreground",
2272            "popover.background",
2273            "popover.foreground",
2274            "popover-foreground",
2275            "popover.border",
2276            // shadcn/new-york v4 extended palette + legacy aliases.
2277            "chart-1",
2278            "chart-2",
2279            "chart-3",
2280            "chart-4",
2281            "chart-5",
2282            "chart.1",
2283            "chart.2",
2284            "chart.3",
2285            "chart.4",
2286            "chart.5",
2287            "sidebar",
2288            "sidebar.background",
2289            "sidebar-background",
2290            "sidebar-foreground",
2291            "sidebar.foreground",
2292            "sidebar-primary",
2293            "sidebar.primary",
2294            "sidebar-primary-foreground",
2295            "sidebar.primary.foreground",
2296            "sidebar-accent",
2297            "sidebar.accent",
2298            "sidebar-accent-foreground",
2299            "sidebar.accent.foreground",
2300            "sidebar-border",
2301            "sidebar.border",
2302            "sidebar-ring",
2303            "sidebar.ring",
2304        ] {
2305            assert!(theme.color_by_key(key).is_some(), "missing alias {key}");
2306        }
2307    }
2308
2309    #[test]
2310    fn theme_snapshot_includes_configured_color_tokens() {
2311        let host = crate::test_host::TestHost::default();
2312        let mut theme = Theme::global(&host).clone();
2313
2314        let mut cfg = ThemeConfig::default();
2315        cfg.colors
2316            .insert("muted".to_string(), "#ff0000".to_string());
2317        theme.apply_config(&cfg);
2318
2319        let snapshot = theme.snapshot();
2320        assert_eq!(theme.color_token("muted"), snapshot.color_token("muted"));
2321    }
2322
2323    #[test]
2324    fn theme_snapshot_matches_theme_for_common_semantic_tokens() {
2325        let host = crate::test_host::TestHost::default();
2326        let theme = Theme::global(&host);
2327        let snapshot = theme.snapshot();
2328
2329        for key in [
2330            "background",
2331            "foreground",
2332            "border",
2333            "card",
2334            "card-foreground",
2335            "muted",
2336            "muted-foreground",
2337            "accent",
2338            "accent-foreground",
2339            "primary",
2340            "primary-foreground",
2341            "popover",
2342            "popover-foreground",
2343            "chart-1",
2344            "sidebar",
2345            "sidebar-foreground",
2346        ] {
2347            assert_eq!(
2348                theme.color_token(key),
2349                snapshot.color_token(key),
2350                "key={key}"
2351            );
2352        }
2353
2354        for key in ["metric.size.sm", "metric.size.md", "metric.size.lg"] {
2355            assert_eq!(
2356                theme.metric_token(key),
2357                snapshot.metric_token(key),
2358                "key={key}"
2359            );
2360        }
2361    }
2362
2363    #[test]
2364    fn missing_theme_token_diagnostics_warn_once_per_key() {
2365        // Use a unique key to avoid cross-test coupling (the warn-once cache is process-global).
2366        let key = format!("color.__missing_theme_token_test__{}", line!());
2367        assert!(super::warn_missing_theme_token_once(
2368            crate::theme_registry::ThemeTokenKind::Color,
2369            &key
2370        ));
2371        assert!(!super::warn_missing_theme_token_once(
2372            crate::theme_registry::ThemeTokenKind::Color,
2373            &key
2374        ));
2375    }
2376
2377    #[test]
2378    fn shadcn_legacy_size_metrics_exist_on_default_theme() {
2379        let host = crate::test_host::TestHost::default();
2380        let theme = Theme::global(&host);
2381
2382        for key in ["metric.size.sm", "metric.size.md", "metric.size.lg"] {
2383            assert!(theme.metric_by_key(key).is_some(), "missing metric {key}");
2384        }
2385    }
2386
2387    #[test]
2388    fn shadcn_legacy_size_metrics_exist_on_default_snapshot() {
2389        let host = crate::test_host::TestHost::default();
2390        let theme = Theme::global(&host);
2391        let snap = theme.snapshot();
2392
2393        for key in ["metric.size.sm", "metric.size.md", "metric.size.lg"] {
2394            assert!(
2395                snap.metric_by_key(key).is_some(),
2396                "missing snapshot metric {key}"
2397            );
2398        }
2399    }
2400
2401    #[test]
2402    fn shadcn_component_text_metrics_exist_on_default_theme() {
2403        let host = crate::test_host::TestHost::default();
2404        let theme = Theme::global(&host);
2405
2406        for key in [
2407            "component.text.sm_px",
2408            "component.text.sm_line_height",
2409            "component.text.base_px",
2410            "component.text.base_line_height",
2411        ] {
2412            assert!(theme.metric_by_key(key).is_some(), "missing metric {key}");
2413        }
2414    }
2415
2416    #[test]
2417    fn semantic_keys_backfill_typed_baseline_colors_when_missing() {
2418        let mut theme = Theme::global(&crate::test_host::TestHost::default()).clone();
2419
2420        let mut colors = HashMap::new();
2421        colors.insert("background".to_string(), "#000000".to_string());
2422        colors.insert("foreground".to_string(), "#ffffff".to_string());
2423        colors.insert("border".to_string(), "#ff0000".to_string());
2424        colors.insert("ring".to_string(), "#00ff00".to_string());
2425        colors.insert("primary".to_string(), "#0000ff".to_string());
2426        colors.insert("muted-foreground".to_string(), "#00ffff".to_string());
2427        let cfg = ThemeConfig {
2428            name: "Semantic Only".to_string(),
2429            colors,
2430            ..Default::default()
2431        };
2432
2433        // No `color.*` keys are provided; typed fields should still change.
2434        theme.apply_config(&cfg);
2435
2436        let bg = theme.color_by_key("background").expect("background");
2437        let fg = theme.color_by_key("foreground").expect("foreground");
2438        let border = theme.color_by_key("border").expect("border");
2439        let ring = theme.color_by_key("ring").expect("ring");
2440        let primary = theme.color_by_key("primary").expect("primary");
2441        let muted_fg = theme
2442            .color_by_key("muted-foreground")
2443            .expect("muted-foreground");
2444
2445        assert_eq!(theme.colors.surface_background, bg);
2446        assert_eq!(theme.colors.text_primary, fg);
2447        assert_eq!(theme.colors.panel_border, border);
2448        assert_eq!(theme.colors.focus_ring, ring);
2449        assert_eq!(theme.colors.accent, primary);
2450        assert_eq!(theme.colors.text_muted, muted_fg);
2451
2452        // Baseline dotted keys should mirror the resolved typed baseline even when the config only
2453        // provided semantic aliases.
2454        assert_eq!(theme.color_by_key("color.surface.background"), Some(bg));
2455        assert_eq!(theme.color_by_key("color.text.primary"), Some(fg));
2456        assert_eq!(theme.color_by_key("color.panel.border"), Some(border));
2457        assert_eq!(theme.color_by_key("color.focus.ring"), Some(ring));
2458        assert_eq!(theme.color_by_key("color.accent"), Some(primary));
2459        assert_eq!(theme.color_by_key("color.text.muted"), Some(muted_fg));
2460    }
2461
2462    #[test]
2463    fn baseline_dotted_keys_update_typed_theme_colors() {
2464        let mut theme = Theme::global(&crate::test_host::TestHost::default()).clone();
2465
2466        let mut colors = HashMap::new();
2467        colors.insert(
2468            "color.surface.background".to_string(),
2469            "#010203".to_string(),
2470        );
2471        colors.insert("color.text.primary".to_string(), "#AABBCC".to_string());
2472        colors.insert("color.panel.border".to_string(), "#112233".to_string());
2473        let cfg = ThemeConfig {
2474            name: "Baseline Dotted".to_string(),
2475            colors,
2476            ..Default::default()
2477        };
2478        theme.apply_config(&cfg);
2479
2480        let bg = theme
2481            .color_by_key("color.surface.background")
2482            .expect("color.surface.background");
2483        let fg = theme
2484            .color_by_key("color.text.primary")
2485            .expect("color.text.primary");
2486        let border = theme
2487            .color_by_key("color.panel.border")
2488            .expect("color.panel.border");
2489
2490        assert_eq!(theme.colors.surface_background, bg);
2491        assert_eq!(theme.colors.text_primary, fg);
2492        assert_eq!(theme.colors.panel_border, border);
2493
2494        // Semantic aliases should mirror the typed baseline after normalization.
2495        assert_eq!(theme.color_by_key("background"), Some(bg));
2496        assert_eq!(theme.color_by_key("foreground"), Some(fg));
2497        assert_eq!(theme.color_by_key("border"), Some(border));
2498    }
2499
2500    #[test]
2501    fn semantic_keys_backfill_panel_border_from_input_when_border_missing() {
2502        let mut theme = Theme::global(&crate::test_host::TestHost::default()).clone();
2503
2504        let mut colors = HashMap::new();
2505        colors.insert("background".to_string(), "#000000".to_string());
2506        colors.insert("foreground".to_string(), "#ffffff".to_string());
2507        colors.insert("input".to_string(), "#ff0000".to_string());
2508        let cfg = ThemeConfig {
2509            name: "Semantic Input Border".to_string(),
2510            colors,
2511            ..Default::default()
2512        };
2513
2514        theme.apply_config(&cfg);
2515
2516        let input = theme.color_by_key("input").expect("input");
2517        assert_eq!(theme.colors.panel_border, input);
2518        assert_eq!(theme.colors.menu_border, input);
2519        assert_eq!(theme.colors.list_border, input);
2520    }
2521
2522    #[test]
2523    fn parse_color_supports_shadcn_hsl_tokens() {
2524        let white = parse_color_to_linear("0 0% 100%").expect("hsl tokens");
2525        assert!((white.r - 1.0).abs() < 1e-6);
2526        assert!((white.g - 1.0).abs() < 1e-6);
2527        assert!((white.b - 1.0).abs() < 1e-6);
2528        assert!((white.a - 1.0).abs() < 1e-6);
2529    }
2530
2531    #[test]
2532    fn parse_color_supports_shadcn_oklch_tokens_with_alpha() {
2533        let c = parse_color_to_linear("oklch(1 0 0 / 10%)").expect("oklch");
2534        assert!((c.r - 1.0).abs() < 1e-6);
2535        assert!((c.g - 1.0).abs() < 1e-6);
2536        assert!((c.b - 1.0).abs() < 1e-6);
2537        assert!((c.a - 0.1).abs() < 1e-6);
2538    }
2539
2540    #[test]
2541    fn named_colors_exist_on_default_snapshot() {
2542        let host = crate::test_host::TestHost::default();
2543        let theme = Theme::global(&host);
2544        let snap = theme.snapshot();
2545
2546        let white = snap.named_color(ThemeNamedColorKey::White);
2547        assert!((white.r - 1.0).abs() < 1e-6);
2548        assert!((white.g - 1.0).abs() < 1e-6);
2549        assert!((white.b - 1.0).abs() < 1e-6);
2550        assert!((white.a - 1.0).abs() < 1e-6);
2551
2552        let black = snap.named_color(ThemeNamedColorKey::Black);
2553        assert!((black.r - 0.0).abs() < 1e-6);
2554        assert!((black.g - 0.0).abs() < 1e-6);
2555        assert!((black.b - 0.0).abs() < 1e-6);
2556        assert!((black.a - 1.0).abs() < 1e-6);
2557    }
2558
2559    #[test]
2560    fn typed_theme_keys_resolve_via_semantic_palette() {
2561        let host = crate::test_host::TestHost::default();
2562        let theme = Theme::global(&host);
2563
2564        assert_eq!(
2565            theme.color(ThemeColorKey::PopoverForeground),
2566            theme
2567                .color_by_key("popover-foreground")
2568                .expect("popover-foreground")
2569        );
2570        assert_eq!(
2571            theme.metric(ThemeMetricKey::Radius),
2572            theme.metric_by_key("radius").expect("radius")
2573        );
2574    }
2575
2576    #[test]
2577    fn theme_config_v2_parses_additional_token_kinds() {
2578        let cfg = ThemeConfig::from_slice(
2579            br#"{
2580  "name": "md3",
2581  "numbers": { "md.sys.state.hover.state-layer-opacity": 0.08 },
2582  "durations_ms": { "md.sys.motion.duration.short3": 150 },
2583  "easings": { "md.sys.motion.easing.emphasized.accelerate": { "x1": 0.3, "y1": 0.0, "x2": 0.8, "y2": 0.15 } },
2584  "corners": { "md.sys.shape.corner.extra-small.top": { "top_left": 4, "top_right": 4, "bottom_right": 0, "bottom_left": 0 } },
2585  "text_styles": {
2586    "md.sys.typescale.body-medium": { "font": "ui", "size": 14, "weight": 400, "slant": "normal" }
2587  }
2588}"#,
2589        )
2590        .expect("valid theme config");
2591
2592        assert_eq!(cfg.name, "md3");
2593        assert_eq!(
2594            cfg.numbers
2595                .get("md.sys.state.hover.state-layer-opacity")
2596                .copied(),
2597            Some(0.08)
2598        );
2599        assert_eq!(
2600            cfg.durations_ms
2601                .get("md.sys.motion.duration.short3")
2602                .copied(),
2603            Some(150)
2604        );
2605        assert_eq!(
2606            cfg.easings
2607                .get("md.sys.motion.easing.emphasized.accelerate")
2608                .copied(),
2609            Some(CubicBezier {
2610                x1: 0.3,
2611                y1: 0.0,
2612                x2: 0.8,
2613                y2: 0.15
2614            })
2615        );
2616        assert_eq!(
2617            cfg.corners
2618                .get("md.sys.shape.corner.extra-small.top")
2619                .copied(),
2620            Some(Corners {
2621                top_left: Px(4.0),
2622                top_right: Px(4.0),
2623                bottom_right: Px(0.0),
2624                bottom_left: Px(0.0),
2625            })
2626        );
2627        assert!(cfg.text_styles.contains_key("md.sys.typescale.body-medium"));
2628    }
2629
2630    #[test]
2631    fn required_accessors_do_not_panic_when_tokens_are_missing_by_default() {
2632        let _guard = super::strict_theme_for_tests(false);
2633        let host = crate::test_host::TestHost::default();
2634        let theme = Theme::global(&host);
2635
2636        let _ = theme.color_required("missing.color.token");
2637        let _ = theme.metric_required("missing.metric.token");
2638        let _ = theme.corners_required("missing.corners.token");
2639        let _ = theme.number_required("missing.number.token");
2640        let _ = theme.duration_ms_required("missing.duration.token");
2641        let _ = theme.easing_required("missing.easing.token");
2642        let _ = theme.text_style_required("missing.text_style.token");
2643
2644        let snap = theme.snapshot();
2645        let _ = snap.color_required("missing.color.token");
2646        let _ = snap.metric_required("missing.metric.token");
2647    }
2648
2649    #[test]
2650    fn required_accessors_panic_in_strict_runtime_mode() {
2651        let _guard = super::strict_theme_for_tests(true);
2652        let host = crate::test_host::TestHost::default();
2653        let theme = Theme::global(&host);
2654
2655        let result = std::panic::catch_unwind(|| {
2656            let _ = theme.color_required("missing.color.token");
2657        });
2658        assert!(result.is_err());
2659    }
2660
2661    #[test]
2662    fn theme_apply_config_updates_extended_token_maps_and_revision() {
2663        let mut theme = Theme::global(&crate::test_host::TestHost::default()).clone();
2664        let before = theme.revision();
2665
2666        theme.apply_config(&ThemeConfig {
2667            name: "md3".to_string(),
2668            corners: HashMap::from([(
2669                "c".to_string(),
2670                Corners {
2671                    top_left: Px(1.0),
2672                    top_right: Px(2.0),
2673                    bottom_right: Px(3.0),
2674                    bottom_left: Px(4.0),
2675                },
2676            )]),
2677            numbers: HashMap::from([("n".to_string(), 1.25)]),
2678            durations_ms: HashMap::from([("d".to_string(), 120)]),
2679            easings: HashMap::from([(
2680                "e".to_string(),
2681                CubicBezier {
2682                    x1: 0.2,
2683                    y1: 0.0,
2684                    x2: 0.0,
2685                    y2: 1.0,
2686                },
2687            )]),
2688            text_styles: HashMap::from([(
2689                "t".to_string(),
2690                TextStyle {
2691                    font: FontId::ui(),
2692                    size: Px(14.0),
2693                    weight: FontWeight::NORMAL,
2694                    slant: TextSlant::Normal,
2695                    line_height: Some(Px(20.0)),
2696                    line_height_em: None,
2697                    line_height_policy: Default::default(),
2698                    letter_spacing_em: None,
2699                    features: Vec::new(),
2700                    axes: Vec::new(),
2701                    vertical_placement: fret_core::TextVerticalPlacement::CenterMetricsBox,
2702                    leading_distribution: Default::default(),
2703                    strut_style: None,
2704                },
2705            )]),
2706            ..ThemeConfig::default()
2707        });
2708
2709        assert_eq!(
2710            theme.corners_by_key("c"),
2711            Some(Corners {
2712                top_left: Px(1.0),
2713                top_right: Px(2.0),
2714                bottom_right: Px(3.0),
2715                bottom_left: Px(4.0),
2716            })
2717        );
2718        assert_eq!(theme.number_by_key("n"), Some(1.25));
2719        assert_eq!(theme.duration_ms_by_key("d"), Some(120));
2720        assert_eq!(
2721            theme.easing_by_key("e"),
2722            Some(CubicBezier {
2723                x1: 0.2,
2724                y1: 0.0,
2725                x2: 0.0,
2726                y2: 1.0
2727            })
2728        );
2729        assert!(theme.text_style_by_key("t").is_some());
2730
2731        assert!(theme.corners_key_configured("c"));
2732        assert!(theme.number_key_configured("n"));
2733        assert!(theme.duration_ms_key_configured("d"));
2734        assert!(theme.easing_key_configured("e"));
2735        assert!(theme.text_style_key_configured("t"));
2736
2737        assert!(theme.revision() > before);
2738    }
2739
2740    #[test]
2741    fn extend_tokens_from_config_preserves_configured_sets() {
2742        let mut theme = Theme::global(&crate::test_host::TestHost::default()).clone();
2743
2744        theme.apply_config(&ThemeConfig {
2745            name: "Base".to_string(),
2746            metrics: HashMap::from([("metric.radius.sm".to_string(), 11.0)]),
2747            corners: HashMap::from([(
2748                "base.corners".to_string(),
2749                Corners {
2750                    top_left: Px(1.0),
2751                    top_right: Px(1.0),
2752                    bottom_right: Px(1.0),
2753                    bottom_left: Px(1.0),
2754                },
2755            )]),
2756            ..ThemeConfig::default()
2757        });
2758        assert!(theme.metric_key_configured("metric.radius.sm"));
2759        assert!(theme.corners_key_configured("base.corners"));
2760
2761        let before = theme.revision();
2762        theme.extend_tokens_from_config(&ThemeConfig {
2763            name: "Extras".to_string(),
2764            metrics: HashMap::from([("md.sys.shape.corner.full".to_string(), 9999.0)]),
2765            corners: HashMap::from([(
2766                "md.sys.shape.corner.extra-small.top".to_string(),
2767                Corners {
2768                    top_left: Px(4.0),
2769                    top_right: Px(4.0),
2770                    bottom_right: Px(0.0),
2771                    bottom_left: Px(0.0),
2772                },
2773            )]),
2774            numbers: HashMap::from([("md.sys.state.hover.state-layer-opacity".to_string(), 0.08)]),
2775            ..ThemeConfig::default()
2776        });
2777
2778        assert!(theme.metric_key_configured("metric.radius.sm"));
2779        assert!(theme.corners_key_configured("base.corners"));
2780        assert_eq!(
2781            theme.metric_by_key("md.sys.shape.corner.full"),
2782            Some(Px(9999.0))
2783        );
2784        assert!(
2785            theme
2786                .corners_by_key("md.sys.shape.corner.extra-small.top")
2787                .is_some()
2788        );
2789        assert_eq!(
2790            theme.number_by_key("md.sys.state.hover.state-layer-opacity"),
2791            Some(0.08)
2792        );
2793        assert!(theme.revision() > before);
2794    }
2795
2796    #[test]
2797    fn apply_config_patch_preserves_existing_extra_colors() {
2798        let mut theme = Theme::global(&crate::test_host::TestHost::default()).clone();
2799
2800        theme.extend_tokens_from_config(&ThemeConfig {
2801            name: "Base".to_string(),
2802            colors: HashMap::from([("primary".to_string(), "#ff0000".to_string())]),
2803            ..ThemeConfig::default()
2804        });
2805        assert_eq!(
2806            theme.color_by_key("primary"),
2807            Some(fret_core::Color {
2808                r: 1.0,
2809                g: 0.0,
2810                b: 0.0,
2811                a: 1.0,
2812            })
2813        );
2814
2815        theme.apply_config_patch(&ThemeConfig {
2816            name: "Patch".to_string(),
2817            metrics: HashMap::from([("metric.padding.sm".to_string(), 7.0)]),
2818            ..ThemeConfig::default()
2819        });
2820
2821        assert_eq!(
2822            theme.color_by_key("primary"),
2823            Some(fret_core::Color {
2824                r: 1.0,
2825                g: 0.0,
2826                b: 0.0,
2827                a: 1.0,
2828            }),
2829            "expected shadcn-style palette tokens to remain intact after metric patches"
2830        );
2831        assert_eq!(theme.metric_by_key("metric.padding.sm"), Some(Px(7.0)));
2832    }
2833
2834    #[test]
2835    fn apply_config_prefers_canonical_keys_over_aliases() {
2836        let mut theme = Theme::global(&crate::test_host::TestHost::default()).clone();
2837
2838        theme.apply_config(&ThemeConfig {
2839            name: "Cfg".to_string(),
2840            colors: HashMap::from([
2841                ("primary-foreground".to_string(), "#ffffff".to_string()),
2842                ("primary.foreground".to_string(), "#000000".to_string()),
2843            ]),
2844            ..ThemeConfig::default()
2845        });
2846
2847        assert!(theme.color_key_configured("primary-foreground"));
2848        assert!(theme.color_key_configured("primary.foreground"));
2849        assert_eq!(
2850            theme.color_by_key("primary-foreground"),
2851            parse_color_to_linear("#ffffff")
2852        );
2853    }
2854
2855    #[test]
2856    fn apply_config_patch_prefers_canonical_keys_over_aliases() {
2857        let mut theme = Theme::global(&crate::test_host::TestHost::default()).clone();
2858
2859        theme.apply_config_patch(&ThemeConfig {
2860            name: "Patch".to_string(),
2861            colors: HashMap::from([
2862                ("primary-foreground".to_string(), "#ffffff".to_string()),
2863                ("primary.foreground".to_string(), "#000000".to_string()),
2864            ]),
2865            ..ThemeConfig::default()
2866        });
2867
2868        assert!(theme.color_key_configured("primary-foreground"));
2869        assert!(theme.color_key_configured("primary.foreground"));
2870        assert_eq!(
2871            theme.color_by_key("primary-foreground"),
2872            parse_color_to_linear("#ffffff")
2873        );
2874    }
2875
2876    #[test]
2877    fn extend_tokens_from_config_prefers_canonical_keys_over_aliases_without_touching_configured() {
2878        let mut theme = Theme::global(&crate::test_host::TestHost::default()).clone();
2879
2880        theme.apply_config(&ThemeConfig {
2881            name: "Base".to_string(),
2882            metrics: HashMap::from([("metric.padding.sm".to_string(), 7.0)]),
2883            ..ThemeConfig::default()
2884        });
2885        assert!(theme.metric_key_configured("metric.padding.sm"));
2886        assert!(!theme.color_key_configured("primary-foreground"));
2887
2888        theme.extend_tokens_from_config(&ThemeConfig {
2889            name: "Extras".to_string(),
2890            colors: HashMap::from([
2891                ("primary-foreground".to_string(), "#ffffff".to_string()),
2892                ("primary.foreground".to_string(), "#000000".to_string()),
2893            ]),
2894            ..ThemeConfig::default()
2895        });
2896
2897        assert!(theme.metric_key_configured("metric.padding.sm"));
2898        assert!(!theme.color_key_configured("primary-foreground"));
2899        assert_eq!(
2900            theme.color_by_key("primary-foreground"),
2901            parse_color_to_linear("#ffffff")
2902        );
2903    }
2904}