Skip to main content

oxi/skills/
design_farmer.rs

1//! Design-farmer skill for oxi
2//!
3//! Constructs design systems from codebases by:
4//! 1. **Analyzing** an existing project's structure, patterns, and conventions
5//! 2. **Extracting** design tokens (colors, spacing, typography, radii, shadows)
6//! 3. **Building** token hierarchies with OKLCH color management for
7//!    perceptually uniform palettes
8//! 4. **Implementing** accessible component specifications with WCAG contrast
9//!    validation
10//!
11//! This module provides both a library API ([`DesignFarmer`]) and a skill-prompt
12//! generator ([`DesignFarmer::skill_prompt`]) for LLM-driven design extraction.
13
14use anyhow::{Context, Result};
15use chrono::Utc;
16use serde::{Deserialize, Serialize};
17use std::cmp::Ordering;
18use std::fmt;
19use std::fs;
20use std::path::{Path, PathBuf};
21
22// ── Color types ───────────────────────────────────────────────────────
23
24/// An OKLCH color value: perceptually uniform lightness + chroma + hue.
25///
26/// OKLCH is the recommended color space for design systems because:
27/// - **L** is perceptually uniform (0.0 = black, 1.0 = white)
28/// - **C** (chroma) scales predictably regardless of hue
29/// - **H** (hue) is a 0–360 angle like HSL, but without the lightness distortion
30///
31/// CSS syntax: `oklch(L C H)` or `oklch(L C H / alpha)`
32#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
33pub struct OklchColor {
34    /// Perceptual lightness: 0.0 (black) to 1.0 (white).
35    pub l: f64,
36    /// Chroma (colorfulness): 0.0 (gray) to ~0.4 (vivid). Practical max varies by hue.
37    pub c: f64,
38    /// Hue angle: 0.0 to 360.0 degrees.
39    pub h: f64,
40    /// Alpha: 0.0 (transparent) to 1.0 (opaque). Defaults to 1.0.
41    #[serde(default = "default_alpha")]
42    pub alpha: f64,
43}
44
45fn default_alpha() -> f64 {
46    1.0
47}
48
49impl OklchColor {
50    /// Create a new OKLCH color.
51    pub fn new(l: f64, c: f64, h: f64) -> Self {
52        Self {
53            l,
54            c,
55            h,
56            alpha: 1.0,
57        }
58    }
59
60    /// Create an OKLCH color with transparency.
61    pub fn with_alpha(mut self, alpha: f64) -> Self {
62        self.alpha = alpha.clamp(0.0, 1.0);
63        self
64    }
65
66    /// Pure black in OKLCH.
67    pub fn black() -> Self {
68        Self::new(0.0, 0.0, 0.0)
69    }
70
71    /// Pure white in OKLCH.
72    pub fn white() -> Self {
73        Self::new(1.0, 0.0, 0.0)
74    }
75
76    /// Generate a palette by varying lightness at constant chroma and hue.
77    ///
78    /// Produces `steps` evenly spaced stops from `l_min` to `l_max`.
79    /// Useful for creating a gray scale or a single-hue palette.
80    pub fn lightness_scale(&self, steps: usize, l_min: f64, l_max: f64) -> Vec<OklchColor> {
81        if steps <= 1 {
82            return vec![*self];
83        }
84        (0..steps)
85            .map(|i| {
86                let t = i as f64 / (steps - 1) as f64;
87                let l = l_min + t * (l_max - l_min);
88                OklchColor::new(l, self.c, self.h)
89            })
90            .collect()
91    }
92
93    /// Compute a perceptual contrast ratio against another color.
94    ///
95    /// Uses the WCAG 2.x relative luminance approximation mapped through OKLCH
96    /// lightness. Returns a ratio ≥ 1.0 where:
97    /// - 1.0 = identical lightness
98    /// - 4.5:1 = AA normal text minimum
99    /// - 7.0:1 = AAA normal text minimum
100    /// - 21.0 = maximum (black vs white)
101    pub fn contrast_ratio(&self, other: &OklchColor) -> f64 {
102        // Convert OKLCH L to approximate relative luminance
103        let l1 = oklch_l_to_luminance(self.l);
104        let l2 = oklch_l_to_luminance(other.l);
105        let lighter = l1.max(l2);
106        let darker = l1.min(l2);
107        (lighter + 0.05) / (darker + 0.05)
108    }
109
110    /// Check WCAG 2.x AA compliance for normal text against a background.
111    pub fn is_aa_compliant(&self, background: &OklchColor) -> bool {
112        self.contrast_ratio(background) >= 4.5
113    }
114
115    /// Check WCAG 2.x AAA compliance for normal text against a background.
116    pub fn is_aaa_compliant(&self, background: &OklchColor) -> bool {
117        self.contrast_ratio(background) >= 7.0
118    }
119
120    /// Render as CSS `oklch()` function string.
121    pub fn to_css(&self) -> String {
122        if self.alpha < 1.0 {
123            format!(
124                "oklch({:.4} {:.4} {:.1} / {:.2})",
125                self.l, self.c, self.h, self.alpha
126            )
127        } else {
128            format!("oklch({:.4} {:.4} {:.1})", self.l, self.c, self.h)
129        }
130    }
131}
132
133impl fmt::Display for OklchColor {
134    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
135        write!(f, "{}", self.to_css())
136    }
137}
138
139/// Approximate conversion from OKLCH lightness to CIE relative luminance.
140///
141/// This is an approximation: OKLCH L maps roughly linearly to perceived
142/// lightness, but the conversion to the sRGB luminance used by WCAG is
143/// non-linear. We use a piecewise approximation that's accurate enough for
144/// contrast checking.
145fn oklch_l_to_luminance(l: f64) -> f64 {
146    // OKLab achromatic conversion: Y = L³
147    // This follows from the OKLab spec for achromatic colors (a=b=0).
148    // The full transform is LMS = (l + 0.3963377774*a + 0.2158037573*b)³,
149    // and for C=0 (achromatic), this reduces to L³.
150    l * l * l
151}
152
153// ── Design tokens ─────────────────────────────────────────────────────
154
155/// Category of a design token.
156#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
157#[serde(rename_all = "snake_case")]
158pub enum TokenCategory {
159    /// Color tokens (backgrounds, foregrounds, accents, etc.).
160    Color,
161    /// Spacing tokens (padding, margins, gaps).
162    Spacing,
163    /// Typography tokens (font families, sizes, weights, line-heights).
164    Typography,
165    /// Border radius tokens.
166    Radius,
167    /// Shadow tokens (elevation).
168    Shadow,
169    /// Opacity tokens.
170    Opacity,
171    /// Breakpoint tokens (responsive).
172    Breakpoint,
173    /// Z-index tokens.
174    ZIndex,
175    /// Animation / transition tokens.
176    Motion,
177    /// Custom / other tokens.
178    Custom,
179}
180
181impl fmt::Display for TokenCategory {
182    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
183        match self {
184            TokenCategory::Color => write!(f, "color"),
185            TokenCategory::Spacing => write!(f, "spacing"),
186            TokenCategory::Typography => write!(f, "typography"),
187            TokenCategory::Radius => write!(f, "radius"),
188            TokenCategory::Shadow => write!(f, "shadow"),
189            TokenCategory::Opacity => write!(f, "opacity"),
190            TokenCategory::Breakpoint => write!(f, "breakpoint"),
191            TokenCategory::ZIndex => write!(f, "z-index"),
192            TokenCategory::Motion => write!(f, "motion"),
193            TokenCategory::Custom => write!(f, "custom"),
194        }
195    }
196}
197
198/// A single design token with its resolved value and metadata.
199#[derive(Debug, Clone, Serialize, Deserialize)]
200pub struct DesignToken {
201    /// Dot-separated token name (e.g., `color.primary.500`).
202    pub name: String,
203    /// Human-readable description.
204    #[serde(skip_serializing_if = "Option::is_none")]
205    pub description: Option<String>,
206    /// Token category.
207    pub category: TokenCategory,
208    /// The resolved value as a string (CSS-compatible).
209    pub value: String,
210    /// For color tokens: the OKLCH value (if applicable).
211    #[serde(skip_serializing_if = "Option::is_none")]
212    pub oklch: Option<OklchColor>,
213    /// References another token (e.g., `color.primary.500` might alias to
214    /// `color.blue.500`).
215    #[serde(skip_serializing_if = "Option::is_none")]
216    pub alias: Option<String>,
217    /// Group path for organizing tokens in a hierarchy.
218    pub group: Vec<String>,
219}
220
221impl DesignToken {
222    /// Create a new token.
223    pub fn new(
224        name: impl Into<String>,
225        category: TokenCategory,
226        value: impl Into<String>,
227        group: Vec<String>,
228    ) -> Self {
229        Self {
230            name: name.into(),
231            description: None,
232            category,
233            value: value.into(),
234            oklch: None,
235            alias: None,
236            group,
237        }
238    }
239
240    /// Create a color token with OKLCH value.
241    pub fn color(name: impl Into<String>, color: OklchColor, group: Vec<String>) -> Self {
242        Self {
243            name: name.into(),
244            description: None,
245            category: TokenCategory::Color,
246            value: color.to_css(),
247            oklch: Some(color),
248            alias: None,
249            group,
250        }
251    }
252
253    /// Create an aliased token that references another token.
254    pub fn alias(name: impl Into<String>, target: impl Into<String>, group: Vec<String>) -> Self {
255        Self {
256            name: name.into(),
257            description: None,
258            category: TokenCategory::Color, // will be overridden by caller
259            value: String::new(),
260            oklch: None,
261            alias: Some(target.into()),
262            group,
263        }
264    }
265
266    /// Set description.
267    pub fn with_description(mut self, desc: impl Into<String>) -> Self {
268        self.description = Some(desc.into());
269        self
270    }
271}
272
273// ── Token hierarchy ───────────────────────────────────────────────────
274
275/// A hierarchical group of design tokens, forming a tree.
276#[derive(Debug, Clone, Serialize, Deserialize)]
277pub struct TokenGroup {
278    /// Group name (e.g., "color", "primary").
279    pub name: String,
280    /// Human-readable description.
281    #[serde(skip_serializing_if = "Option::is_none")]
282    pub description: Option<String>,
283    /// Tokens directly in this group.
284    pub tokens: Vec<DesignToken>,
285    /// Sub-groups.
286    pub children: Vec<TokenGroup>,
287}
288
289impl TokenGroup {
290    /// Create a new empty group.
291    pub fn new(name: impl Into<String>) -> Self {
292        Self {
293            name: name.into(),
294            description: None,
295            tokens: Vec::new(),
296            children: Vec::new(),
297        }
298    }
299
300    /// Set description.
301    pub fn with_description(mut self, desc: impl Into<String>) -> Self {
302        self.description = Some(desc.into());
303        self
304    }
305
306    /// Add a token to this group.
307    pub fn add_token(&mut self, token: DesignToken) {
308        self.tokens.push(token);
309    }
310
311    /// Add a child group.
312    pub fn add_child(&mut self, group: TokenGroup) {
313        self.children.push(group);
314    }
315
316    /// Get a child group by name.
317    pub fn child(&self, name: &str) -> Option<&TokenGroup> {
318        self.children.iter().find(|c| c.name == name)
319    }
320
321    /// Get a mutable child group by name (creates if missing).
322    pub fn child_mut(&mut self, name: &str) -> &mut TokenGroup {
323        let idx = self.children.iter().position(|c| c.name == name);
324        if let Some(i) = idx {
325            &mut self.children[i]
326        } else {
327            self.children.push(TokenGroup::new(name));
328            self.children.last_mut().unwrap()
329        }
330    }
331
332    /// Collect all tokens in this group and descendants, flattened.
333    pub fn all_tokens(&self) -> Vec<&DesignToken> {
334        let mut result: Vec<&DesignToken> = self.tokens.iter().collect();
335        for child in &self.children {
336            result.extend(child.all_tokens());
337        }
338        result
339    }
340
341    /// Total number of tokens in this group and all descendants.
342    pub fn token_count(&self) -> usize {
343        self.tokens.len() + self.children.iter().map(|c| c.token_count()).sum::<usize>()
344    }
345}
346
347// ── Color palette ─────────────────────────────────────────────────────
348
349/// A color palette generated from a base OKLCH color.
350#[derive(Debug, Clone, Serialize, Deserialize)]
351pub struct ColorPalette {
352    /// Palette name (e.g., "primary", "gray", "success").
353    pub name: String,
354    /// Base hue angle (0–360).
355    pub hue: f64,
356    /// Base chroma.
357    pub chroma: f64,
358    /// Generated stops, keyed by lightness step (e.g., 50, 100, ..., 900, 950).
359    pub stops: Vec<PaletteStop>,
360}
361
362/// A single stop within a color palette.
363#[derive(Debug, Clone, Serialize, Deserialize)]
364pub struct PaletteStop {
365    /// Step number (50, 100, 200, ..., 900, 950).
366    pub step: u16,
367    /// The OKLCH color at this step.
368    pub color: OklchColor,
369    /// Token name (e.g., "color.primary.500").
370    pub token_name: String,
371}
372
373impl ColorPalette {
374    /// Generate a full palette from a base color using Tailwind-style steps.
375    ///
376    /// Steps: 50, 100, 200, 300, 400, 500, 600, 700, 800, 900, 950
377    /// Lightness ranges from ~0.97 (step 50) to ~0.15 (step 950).
378    pub fn generate(name: impl Into<String>, base: &OklchColor) -> Self {
379        let steps: [(u16, f64); 11] = [
380            (50, 0.97),
381            (100, 0.93),
382            (200, 0.86),
383            (300, 0.78),
384            (400, 0.68),
385            (500, 0.55),
386            (600, 0.44),
387            (700, 0.35),
388            (800, 0.26),
389            (900, 0.20),
390            (950, 0.14),
391        ];
392
393        let name_str = name.into();
394        let stops = steps
395            .iter()
396            .map(|(step, l)| {
397                // Chroma compression at extreme lightness to avoid clipping
398                let chroma_factor = if *l > 0.9 || *l < 0.2 {
399                    0.6
400                } else if *l > 0.8 || *l < 0.3 {
401                    0.8
402                } else {
403                    1.0
404                };
405                PaletteStop {
406                    step: *step,
407                    color: OklchColor::new(*l, base.c * chroma_factor, base.h),
408                    token_name: format!("color.{}.{}", name_str, step),
409                }
410            })
411            .collect();
412
413        Self {
414            name: name_str,
415            hue: base.h,
416            chroma: base.c,
417            stops,
418        }
419    }
420
421    /// Generate a neutral/gray palette (zero chroma).
422    pub fn neutral(name: impl Into<String>) -> Self {
423        let base = OklchColor::new(0.5, 0.0, 0.0);
424        Self::generate(name, &base)
425    }
426
427    /// Get a stop by step number.
428    pub fn get_stop(&self, step: u16) -> Option<&PaletteStop> {
429        self.stops.iter().find(|s| s.step == step)
430    }
431
432    /// Get the middle (500) color.
433    pub fn mid(&self) -> &OklchColor {
434        &self.get_stop(500).expect("palette always has step 500").color
435    }
436
437    /// Convert this palette into a [`TokenGroup`].
438    pub fn to_token_group(&self) -> TokenGroup {
439        let mut group = TokenGroup::new(&self.name)
440            .with_description(format!(
441                "{} palette (hue {:.0}°, chroma {:.3})",
442                self.name, self.hue, self.chroma
443            ));
444
445        for stop in &self.stops {
446            group.add_token(DesignToken::color(&stop.token_name, stop.color, vec!["color".into(), self.name.clone()]));
447        }
448
449        group
450    }
451}
452
453// ── Accessible component spec ─────────────────────────────────────────
454
455/// WCAG conformance level.
456#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
457#[serde(rename_all = "snake_case")]
458pub enum WcagLevel {
459    /// No conformance claim.
460    None,
461    /// WCAG 2.x Level A.
462    A,
463    /// WCAG 2.x Level AA.
464    AA,
465    /// WCAG 2.x Level AAA.
466    AAA,
467}
468
469impl fmt::Display for WcagLevel {
470    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
471        match self {
472            WcagLevel::None => write!(f, "none"),
473            WcagLevel::A => write!(f, "A"),
474            WcagLevel::AA => write!(f, "AA"),
475            WcagLevel::AAA => write!(f, "AAA"),
476        }
477    }
478}
479
480/// A contrast check result between a foreground and background color.
481#[derive(Debug, Clone, Serialize, Deserialize)]
482pub struct ContrastCheck {
483    /// Name of the foreground token.
484    pub foreground_token: String,
485    /// Name of the background token.
486    pub background_token: String,
487    /// The foreground color.
488    pub foreground: OklchColor,
489    /// The background color.
490    pub background: OklchColor,
491    /// Computed contrast ratio.
492    pub ratio: f64,
493    /// WCAG AA pass/fail.
494    pub aa_pass: bool,
495    /// WCAG AAA pass/fail.
496    pub aaa_pass: bool,
497}
498
499impl ContrastCheck {
500    /// Run a contrast check between two tokens.
501    pub fn check(
502        fg_token: &str,
503        fg: OklchColor,
504        bg_token: &str,
505        bg: OklchColor,
506    ) -> Self {
507        let ratio = fg.contrast_ratio(&bg);
508        Self {
509            foreground_token: fg_token.to_string(),
510            background_token: bg_token.to_string(),
511            foreground: fg,
512            background: bg,
513            ratio,
514            aa_pass: ratio >= 4.5,
515            aaa_pass: ratio >= 7.0,
516        }
517    }
518
519    /// Whether this check passes at the given WCAG level.
520    pub fn passes(&self, level: WcagLevel) -> bool {
521        match level {
522            WcagLevel::None => true,
523            WcagLevel::A => true, // A has the same minimum as AA for contrast
524            WcagLevel::AA => self.aa_pass,
525            WcagLevel::AAA => self.aaa_pass,
526        }
527    }
528}
529
530/// A component specification with accessibility requirements.
531#[derive(Debug, Clone, Serialize, Deserialize)]
532pub struct ComponentSpec {
533    /// Component name (e.g., "Button", "Card", "Modal").
534    pub name: String,
535    /// Human-readable description.
536    pub description: String,
537    /// Component variants (e.g., "primary", "secondary", "ghost").
538    pub variants: Vec<ComponentVariant>,
539    /// Accessibility notes.
540    pub a11y_notes: Vec<String>,
541    /// Required WCAG level.
542    pub wcag_level: WcagLevel,
543    /// Contrast checks for this component.
544    pub contrast_checks: Vec<ContrastCheck>,
545    /// Required ARIA roles or attributes.
546    pub aria_requirements: Vec<String>,
547    /// Keyboard interaction requirements.
548    pub keyboard_requirements: Vec<String>,
549}
550
551/// A variant of a component (e.g., "primary" button, "outline" button).
552#[derive(Debug, Clone, Serialize, Deserialize)]
553pub struct ComponentVariant {
554    /// Variant name.
555    pub name: String,
556    /// Token references for this variant (token name → CSS property).
557    pub token_refs: Vec<(String, String)>,
558    /// Additional CSS properties not covered by tokens.
559    #[serde(default)]
560    pub extra_styles: Vec<(String, String)>,
561}
562
563impl ComponentSpec {
564    /// Create a new component spec.
565    pub fn new(name: impl Into<String>, description: impl Into<String>) -> Self {
566        Self {
567            name: name.into(),
568            description: description.into(),
569            variants: Vec::new(),
570            a11y_notes: Vec::new(),
571            wcag_level: WcagLevel::AA,
572            contrast_checks: Vec::new(),
573            aria_requirements: Vec::new(),
574            keyboard_requirements: Vec::new(),
575        }
576    }
577
578    /// Set the required WCAG conformance level.
579    pub fn with_wcag_level(mut self, level: WcagLevel) -> Self {
580        self.wcag_level = level;
581        self
582    }
583
584    /// Add a variant.
585    pub fn add_variant(&mut self, variant: ComponentVariant) {
586        self.variants.push(variant);
587    }
588
589    /// Add an accessibility note.
590    pub fn add_a11y_note(&mut self, note: impl Into<String>) {
591        self.a11y_notes.push(note.into());
592    }
593
594    /// Add a required ARIA attribute or role.
595    pub fn add_aria(&mut self, requirement: impl Into<String>) {
596        self.aria_requirements.push(requirement.into());
597    }
598
599    /// Add a keyboard interaction requirement.
600    pub fn add_keyboard(&mut self, requirement: impl Into<String>) {
601        self.keyboard_requirements.push(requirement.into());
602    }
603
604    /// Run a contrast check and record the result.
605    pub fn check_contrast(
606        &mut self,
607        fg_token: &str,
608        fg: OklchColor,
609        bg_token: &str,
610        bg: OklchColor,
611    ) -> &ContrastCheck {
612        let check = ContrastCheck::check(fg_token, fg, bg_token, bg);
613        self.contrast_checks.push(check);
614        self.contrast_checks.last().unwrap()
615    }
616
617    /// Whether all contrast checks pass at the required WCAG level.
618    pub fn is_accessible(&self) -> bool {
619        self.contrast_checks.iter().all(|c| c.passes(self.wcag_level))
620    }
621
622    /// Get failing contrast checks.
623    pub fn failing_checks(&self) -> Vec<&ContrastCheck> {
624        self.contrast_checks
625            .iter()
626            .filter(|c| !c.passes(self.wcag_level))
627            .collect()
628    }
629}
630
631// ── Codebase analysis ─────────────────────────────────────────────────
632
633/// Findings from analyzing a codebase's design patterns.
634#[derive(Debug, Clone, Serialize, Deserialize)]
635pub struct DesignAnalysis {
636    /// Project root that was analyzed.
637    pub project_root: String,
638    /// Design-related files found.
639    pub design_files: Vec<DesignFile>,
640    /// Detected framework / styling approach.
641    pub framework: DesignFramework,
642    /// Extracted raw design patterns.
643    pub patterns: Vec<DesignPattern>,
644    /// Summary of findings.
645    pub summary: String,
646}
647
648/// A design-related file in the project.
649#[derive(Debug, Clone, Serialize, Deserialize)]
650pub struct DesignFile {
651    /// Relative path from project root.
652    pub path: String,
653    /// Type of design file.
654    pub file_type: DesignFileType,
655    /// Brief description of contents.
656    pub description: String,
657}
658
659/// Type of design file.
660#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
661#[serde(rename_all = "snake_case")]
662pub enum DesignFileType {
663    /// CSS / SCSS / LESS stylesheet.
664    Stylesheet,
665    /// Tailwind config.
666    TailwindConfig,
667    /// Theme configuration file.
668    ThemeConfig,
669    /// Design token file (JSON, YAML, etc.).
670    TokenFile,
671    /// Component file (React, Vue, Svelte, etc.).
672    Component,
673    /// Storybook story.
674    Story,
675    /// Figma / design tool export.
676    DesignExport,
677    /// Other.
678    Other,
679}
680
681impl fmt::Display for DesignFileType {
682    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
683        match self {
684            DesignFileType::Stylesheet => write!(f, "stylesheet"),
685            DesignFileType::TailwindConfig => write!(f, "tailwind-config"),
686            DesignFileType::ThemeConfig => write!(f, "theme-config"),
687            DesignFileType::TokenFile => write!(f, "token-file"),
688            DesignFileType::Component => write!(f, "component"),
689            DesignFileType::Story => write!(f, "story"),
690            DesignFileType::DesignExport => write!(f, "design-export"),
691            DesignFileType::Other => write!(f, "other"),
692        }
693    }
694}
695
696/// Detected design framework / approach.
697#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
698#[serde(rename_all = "snake_case")]
699pub enum DesignFramework {
700    /// Tailwind CSS (utility-first).
701    Tailwind,
702    /// CSS Modules.
703    CssModules,
704    /// Styled-components / Emotion / CSS-in-JS.
705    CssInJs,
706    /// Vanilla CSS / SCSS / LESS.
707    Vanilla,
708    /// Unknown / not detected.
709    Unknown,
710}
711
712impl fmt::Display for DesignFramework {
713    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
714        match self {
715            DesignFramework::Tailwind => write!(f, "Tailwind CSS"),
716            DesignFramework::CssModules => write!(f, "CSS Modules"),
717            DesignFramework::CssInJs => write!(f, "CSS-in-JS"),
718            DesignFramework::Vanilla => write!(f, "Vanilla CSS"),
719            DesignFramework::Unknown => write!(f, "Unknown"),
720        }
721    }
722}
723
724/// A design pattern extracted from the codebase.
725#[derive(Debug, Clone, Serialize, Deserialize)]
726pub struct DesignPattern {
727    /// Pattern name.
728    pub name: String,
729    /// Pattern category.
730    pub category: PatternCategory,
731    /// Where this pattern was found.
732    pub source: String,
733    /// Description of the pattern.
734    pub description: String,
735}
736
737/// Category of design pattern.
738#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
739#[serde(rename_all = "snake_case")]
740pub enum PatternCategory {
741    /// Color usage pattern.
742    Color,
743    /// Layout pattern.
744    Layout,
745    /// Spacing pattern.
746    Spacing,
747    /// Typography pattern.
748    Typography,
749    /// Component composition pattern.
750    Composition,
751    /// Animation / motion pattern.
752    Motion,
753    /// Responsive / breakpoint pattern.
754    Responsive,
755}
756
757impl fmt::Display for PatternCategory {
758    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
759        match self {
760            PatternCategory::Color => write!(f, "color"),
761            PatternCategory::Layout => write!(f, "layout"),
762            PatternCategory::Spacing => write!(f, "spacing"),
763            PatternCategory::Typography => write!(f, "typography"),
764            PatternCategory::Composition => write!(f, "composition"),
765            PatternCategory::Motion => write!(f, "motion"),
766            PatternCategory::Responsive => write!(f, "responsive"),
767        }
768    }
769}
770
771// ── Design system document ────────────────────────────────────────────
772
773/// A complete design system document with tokens, palettes, and components.
774#[derive(Debug, Clone, Serialize, Deserialize)]
775pub struct DesignSystem {
776    /// System name.
777    pub name: String,
778    /// Creation timestamp.
779    pub created_at: String,
780    /// Version.
781    pub version: String,
782    /// Root token group hierarchy.
783    pub tokens: TokenGroup,
784    /// Color palettes.
785    pub palettes: Vec<ColorPalette>,
786    /// Component specifications.
787    pub components: Vec<ComponentSpec>,
788    /// Codebase analysis (if this system was extracted from an existing project).
789    #[serde(skip_serializing_if = "Option::is_none")]
790    pub analysis: Option<DesignAnalysis>,
791    /// Design decisions and rationale.
792    pub decisions: Vec<DesignDecision>,
793}
794
795/// A documented design decision.
796#[derive(Debug, Clone, Serialize, Deserialize)]
797pub struct DesignDecision {
798    /// Short title.
799    pub title: String,
800    /// What was decided.
801    pub decision: String,
802    /// Why this decision was made.
803    pub rationale: String,
804    /// Alternatives considered.
805    #[serde(default)]
806    pub alternatives: Vec<String>,
807}
808
809impl DesignSystem {
810    /// Create an empty design system.
811    pub fn new(name: impl Into<String>) -> Self {
812        Self {
813            name: name.into(),
814            created_at: Utc::now().to_rfc3339(),
815            version: "1.0.0".to_string(),
816            tokens: TokenGroup::new("root"),
817            palettes: Vec::new(),
818            components: Vec::new(),
819            analysis: None,
820            decisions: Vec::new(),
821        }
822    }
823
824    /// Total token count across all groups.
825    pub fn total_tokens(&self) -> usize {
826        self.tokens.token_count()
827    }
828
829    /// Check accessibility of all components.
830    pub fn accessibility_report(&self) -> AccessibilityReport {
831        let mut passing = Vec::new();
832        let mut failing = Vec::new();
833
834        for component in &self.components {
835            for check in &component.contrast_checks {
836                if check.passes(component.wcag_level) {
837                    passing.push(check.clone());
838                } else {
839                    failing.push(check.clone());
840                }
841            }
842        }
843
844        passing.sort_by(|a, b| {
845            b.ratio
846                .partial_cmp(&a.ratio)
847                .unwrap_or(Ordering::Equal)
848        });
849        failing.sort_by(|a, b| {
850            a.ratio
851                .partial_cmp(&b.ratio)
852                .unwrap_or(Ordering::Equal)
853        });
854
855        let all_pass = failing.is_empty();
856        let min_ratio = passing
857            .iter()
858            .chain(failing.iter())
859            .map(|c| c.ratio)
860            .reduce(f64::min)
861            .unwrap_or(0.0);
862
863        AccessibilityReport {
864            all_pass,
865            total_checks: passing.len() + failing.len(),
866            passing_count: passing.len(),
867            failing_count: failing.len(),
868            min_ratio,
869            passing,
870            failing,
871        }
872    }
873
874    /// Add a palette and register its tokens.
875    pub fn add_palette(&mut self, palette: ColorPalette) {
876        let group = palette.to_token_group();
877        self.tokens.add_child(group);
878        self.palettes.push(palette);
879    }
880
881    /// Record a design decision.
882    pub fn add_decision(
883        &mut self,
884        title: impl Into<String>,
885        decision: impl Into<String>,
886        rationale: impl Into<String>,
887        alternatives: Vec<String>,
888    ) {
889        self.decisions.push(DesignDecision {
890            title: title.into(),
891            decision: decision.into(),
892            rationale: rationale.into(),
893            alternatives,
894        });
895    }
896
897    /// Render as a Markdown document.
898    pub fn render_markdown(&self) -> String {
899        let mut md = String::with_capacity(8192);
900
901        md.push_str(&format!("# Design System: {}\n\n", self.name));
902        md.push_str(&format!("> Created: {} | Version: {}\n\n", self.created_at, self.version));
903
904        // Summary
905        md.push_str(&format!(
906            "**{} tokens** across {} palettes and {} components.\n\n",
907            self.total_tokens(),
908            self.palettes.len(),
909            self.components.len(),
910        ));
911
912        // Analysis
913        if let Some(ref analysis) = self.analysis {
914            md.push_str("## Codebase Analysis\n\n");
915            md.push_str(&format!("**Framework:** {}\n\n", analysis.framework));
916            md.push_str(&analysis.summary);
917            md.push_str("\n\n");
918
919            if !analysis.design_files.is_empty() {
920                md.push_str("### Design Files\n\n");
921                for f in &analysis.design_files {
922                    md.push_str(&format!("- `{}` — {} ({})\n", f.path, f.description, f.file_type));
923                }
924                md.push('\n');
925            }
926
927            if !analysis.patterns.is_empty() {
928                md.push_str("### Extracted Patterns\n\n");
929                for p in &analysis.patterns {
930                    md.push_str(&format!(
931                        "- **{}** [{}]: {} (from `{}`)\n",
932                        p.name, p.category, p.description, p.source
933                    ));
934                }
935                md.push('\n');
936            }
937        }
938
939        // Palettes
940        if !self.palettes.is_empty() {
941            md.push_str("## Color Palettes\n\n");
942            for palette in &self.palettes {
943                md.push_str(&format!("### {} (hue {:.0}°)\n\n", palette.name, palette.hue));
944                md.push_str("| Step | Token | Value |\n");
945                md.push_str("|------|-------|-------|\n");
946                for stop in &palette.stops {
947                    md.push_str(&format!(
948                        "| {} | `{}` | `{}` |\n",
949                        stop.step, stop.token_name, stop.color.to_css()
950                    ));
951                }
952                md.push('\n');
953            }
954        }
955
956        // Token hierarchy
957        md.push_str("## Token Hierarchy\n\n");
958        Self::render_token_group_md(&self.tokens, &mut md, 0);
959        md.push('\n');
960
961        // Components
962        if !self.components.is_empty() {
963            md.push_str("## Components\n\n");
964            for component in &self.components {
965                let accessible_icon = if component.is_accessible() { "✅" } else { "⚠️" };
966                md.push_str(&format!(
967                    "### {} {} [WCAG {}]\n\n",
968                    component.name, accessible_icon, component.wcag_level
969                ));
970                md.push_str(&component.description);
971                md.push_str("\n\n");
972
973                if !component.variants.is_empty() {
974                    md.push_str("**Variants:** ");
975                    let names: Vec<&str> = component.variants.iter().map(|v| v.name.as_str()).collect();
976                    md.push_str(&names.join(", "));
977                    md.push_str("\n\n");
978                }
979
980                if !component.contrast_checks.is_empty() {
981                    md.push_str("**Contrast Checks:**\n\n");
982                    md.push_str("| Foreground | Background | Ratio | AA | AAA |\n");
983                    md.push_str("|-----------|-----------|-------|----|-----|\n");
984                    for check in &component.contrast_checks {
985                        let aa = if check.aa_pass { "✅" } else { "❌" };
986                        let aaa = if check.aaa_pass { "✅" } else { "❌" };
987                        md.push_str(&format!(
988                            "| `{}` | `{}` | {:.1}:1 | {} | {} |\n",
989                            check.foreground_token, check.background_token, check.ratio, aa, aaa
990                        ));
991                    }
992                    md.push('\n');
993                }
994
995                if !component.aria_requirements.is_empty() {
996                    md.push_str("**ARIA:**\n\n");
997                    for req in &component.aria_requirements {
998                        md.push_str(&format!("- {}\n", req));
999                    }
1000                    md.push('\n');
1001                }
1002
1003                if !component.keyboard_requirements.is_empty() {
1004                    md.push_str("**Keyboard:**\n\n");
1005                    for req in &component.keyboard_requirements {
1006                        md.push_str(&format!("- {}\n", req));
1007                    }
1008                    md.push('\n');
1009                }
1010
1011                if !component.a11y_notes.is_empty() {
1012                    md.push_str("**Accessibility Notes:**\n\n");
1013                    for note in &component.a11y_notes {
1014                        md.push_str(&format!("- {}\n", note));
1015                    }
1016                    md.push('\n');
1017                }
1018            }
1019        }
1020
1021        // Accessibility report
1022        let report = self.accessibility_report();
1023        md.push_str("## Accessibility Report\n\n");
1024        if report.all_pass {
1025            md.push_str(&format!(
1026                "✅ All {} contrast checks pass.\n\n",
1027                report.total_checks
1028            ));
1029        } else {
1030            md.push_str(&format!(
1031                "⚠️ {}/{} checks failing. Minimum ratio: {:.1}:1\n\n",
1032                report.failing_count, report.total_checks, report.min_ratio
1033            ));
1034        }
1035
1036        // Decisions
1037        if !self.decisions.is_empty() {
1038            md.push_str("## Design Decisions\n\n");
1039            for decision in &self.decisions {
1040                md.push_str(&format!("### {}\n\n", decision.title));
1041                md.push_str(&format!("**Decision:** {}\n\n", decision.decision));
1042                md.push_str(&format!("**Rationale:** {}\n\n", decision.rationale));
1043                if !decision.alternatives.is_empty() {
1044                    md.push_str("**Alternatives considered:**\n\n");
1045                    for alt in &decision.alternatives {
1046                        md.push_str(&format!("- {}\n", alt));
1047                    }
1048                    md.push('\n');
1049                }
1050            }
1051        }
1052
1053        md
1054    }
1055
1056    /// Recursively render a token group as Markdown headings.
1057    fn render_token_group_md(group: &TokenGroup, md: &mut String, depth: usize) {
1058        let heading = "#".repeat(depth.min(4) + 3); // h3–h6
1059        md.push_str(&format!("{} {} ({} tokens)\n\n", heading, group.name, group.token_count()));
1060
1061        if let Some(ref desc) = group.description {
1062            md.push_str(&format!("{}\n\n", desc));
1063        }
1064
1065        if !group.tokens.is_empty() {
1066            md.push_str("| Token | Category | Value |\n");
1067            md.push_str("|-------|----------|-------|\n");
1068            for token in &group.tokens {
1069                let value = if let Some(ref alias) = token.alias {
1070                    format!("→ `{}`", alias)
1071                } else {
1072                    format!("`{}`", token.value)
1073                };
1074                md.push_str(&format!(
1075                    "| `{}` | {} | {} |\n",
1076                    token.name, token.category, value
1077                ));
1078            }
1079            md.push('\n');
1080        }
1081
1082        for child in &group.children {
1083            Self::render_token_group_md(child, md, depth + 1);
1084        }
1085    }
1086
1087    /// Write the design system as Markdown to a file.
1088    pub fn write_markdown(&self, path: &Path) -> Result<PathBuf> {
1089        if let Some(parent) = path.parent() {
1090            fs::create_dir_all(parent)
1091                .with_context(|| format!("Failed to create {}", parent.display()))?;
1092        }
1093
1094        let content = self.render_markdown();
1095        fs::write(path, &content)
1096            .with_context(|| format!("Failed to write design system to {}", path.display()))?;
1097
1098        Ok(path.to_path_buf())
1099    }
1100
1101    /// Write as JSON.
1102    pub fn write_json(&self, path: &Path) -> Result<PathBuf> {
1103        if let Some(parent) = path.parent() {
1104            fs::create_dir_all(parent)
1105                .with_context(|| format!("Failed to create {}", parent.display()))?;
1106        }
1107
1108        let json = serde_json::to_string_pretty(self)
1109            .context("Failed to serialize design system")?;
1110        fs::write(path, &json)
1111            .with_context(|| format!("Failed to write design system to {}", path.display()))?;
1112
1113        Ok(path.to_path_buf())
1114    }
1115}
1116
1117// ── Accessibility report ──────────────────────────────────────────────
1118
1119/// Report from checking all component accessibility.
1120#[derive(Debug, Clone, Serialize, Deserialize)]
1121pub struct AccessibilityReport {
1122    /// Whether all checks pass.
1123    pub all_pass: bool,
1124    /// Total number of contrast checks.
1125    pub total_checks: usize,
1126    /// Number of passing checks.
1127    pub passing_count: usize,
1128    /// Number of failing checks.
1129    pub failing_count: usize,
1130    /// Minimum contrast ratio across all checks.
1131    pub min_ratio: f64,
1132    /// All passing checks (sorted by ratio, highest first).
1133    pub passing: Vec<ContrastCheck>,
1134    /// All failing checks (sorted by ratio, lowest first).
1135    pub failing: Vec<ContrastCheck>,
1136}
1137
1138// ── Design farmer skill ──────────────────────────────────────────────
1139
1140/// The design-farmer skill.
1141///
1142/// Provides methods to:
1143/// - Analyze an existing codebase for design patterns
1144/// - Extract tokens, palettes, and component specs
1145/// - Build a complete design system
1146/// - Generate skill instructions for LLM-driven extraction
1147pub struct DesignFarmer;
1148
1149impl DesignFarmer {
1150    /// Create a new design-farmer skill instance.
1151    pub fn new() -> Self {
1152        Self
1153    }
1154
1155    /// Analyze a project directory for design-related files and patterns.
1156    ///
1157    /// Scans for:
1158    /// - CSS/SCSS/LESS files
1159    /// - Tailwind configuration
1160    /// - Theme/config files
1161    /// - Component files
1162    /// - Design token files
1163    ///
1164    /// Returns a [`DesignAnalysis`] summarizing what was found.
1165    pub fn analyze_codebase(dir: &Path) -> Result<DesignAnalysis> {
1166        let mut design_files = Vec::new();
1167        let mut patterns = Vec::new();
1168        let mut framework = DesignFramework::Unknown;
1169
1170        // Detect framework
1171        if dir.join("tailwind.config.js").exists()
1172            || dir.join("tailwind.config.ts").exists()
1173            || dir.join("tailwind.config.mjs").exists()
1174        {
1175            framework = DesignFramework::Tailwind;
1176        } else if dir.join("postcss.config.js").exists() || dir.join("postcss.config.mjs").exists()
1177        {
1178            // Could be Tailwind via PostCSS, check for CSS content
1179            framework = DesignFramework::Vanilla;
1180        }
1181
1182        // Check for CSS-in-JS indicators
1183        let pkg_json = dir.join("package.json");
1184        if pkg_json.exists() {
1185            if let Ok(content) = fs::read_to_string(&pkg_json) {
1186                if content.contains("styled-components")
1187                    || content.contains("@emotion")
1188                    || content.contains("goober")
1189                    || content.contains("vanilla-extract")
1190                {
1191                    framework = DesignFramework::CssInJs;
1192                }
1193                if content.contains("tailwindcss") {
1194                    framework = DesignFramework::Tailwind;
1195                }
1196            }
1197        }
1198
1199        // Walk the tree looking for design files
1200        Self::walk_for_design_files(dir, "", 0, 5, &mut design_files, &mut patterns)?;
1201
1202        // Derive summary
1203        let file_count = design_files.len();
1204        let pattern_count = patterns.len();
1205        let summary = format!(
1206            "Detected {} design framework. Found {} design-related file(s) and {} pattern(s).",
1207            framework, file_count, pattern_count
1208        );
1209
1210        Ok(DesignAnalysis {
1211            project_root: dir.to_string_lossy().to_string(),
1212            design_files,
1213            framework,
1214            patterns,
1215            summary,
1216        })
1217    }
1218
1219    /// Recursively walk looking for design-related files.
1220    fn walk_for_design_files(
1221        dir: &Path,
1222        prefix: &str,
1223        depth: usize,
1224        max_depth: usize,
1225        design_files: &mut Vec<DesignFile>,
1226        patterns: &mut Vec<DesignPattern>,
1227    ) -> Result<()> {
1228        if depth > max_depth {
1229            return Ok(());
1230        }
1231
1232        let entries = fs::read_dir(dir)
1233            .with_context(|| format!("Failed to read directory: {}", dir.display()))?;
1234
1235        for entry in entries {
1236            let entry = entry?;
1237            let name = entry.file_name().to_string_lossy().to_string();
1238
1239            // Skip noise
1240            if name.starts_with('.')
1241                || name == "node_modules"
1242                || name == "target"
1243                || name == "dist"
1244                || name == "build"
1245                || name == ".git"
1246                || name == "vendor"
1247                || name == "__pycache__"
1248                || name == "coverage"
1249            {
1250                continue;
1251            }
1252
1253            let path = entry.path();
1254            let rel = if prefix.is_empty() {
1255                name.clone()
1256            } else {
1257                format!("{}/{}", prefix, name)
1258            };
1259
1260            if path.is_dir() {
1261                Self::walk_for_design_files(&path, &rel, depth + 1, max_depth, design_files, patterns)?;
1262            } else {
1263                let name_lower = name.to_lowercase();
1264
1265                // Detect file type
1266                let (file_type, description) = if matches!(
1267                    name_lower.as_str(),
1268                    "tailwind.config.js"
1269                    | "tailwind.config.ts"
1270                    | "tailwind.config.mjs"
1271                    | "tailwind.config.cjs"
1272                ) {
1273                    (
1274                        DesignFileType::TailwindConfig,
1275                        "Tailwind CSS configuration".to_string(),
1276                    )
1277                } else if name_lower.starts_with("theme")
1278                    && (name_lower.ends_with(".json")
1279                        || name_lower.ends_with(".js")
1280                        || name_lower.ends_with(".ts")
1281                        || name_lower.ends_with(".toml"))
1282                {
1283                    (DesignFileType::ThemeConfig, "Theme configuration".to_string())
1284                } else if name_lower.contains("token")
1285                    && (name_lower.ends_with(".json") || name_lower.ends_with(".yaml")
1286                        || name_lower.ends_with(".yml"))
1287                {
1288                    (DesignFileType::TokenFile, "Design token definitions".to_string())
1289                } else if name_lower.ends_with(".css")
1290                    || name_lower.ends_with(".scss")
1291                    || name_lower.ends_with(".less")
1292                {
1293                    // Heuristic: classify based on location
1294                    if rel.contains("component") || rel.contains("ui") {
1295                        (
1296                            DesignFileType::Stylesheet,
1297                            "Component stylesheet".to_string(),
1298                        )
1299                    } else if rel.contains("global") || rel.contains("base") || name_lower.contains("reset") {
1300                        (
1301                            DesignFileType::Stylesheet,
1302                            "Global/base stylesheet".to_string(),
1303                        )
1304                    } else {
1305                        (DesignFileType::Stylesheet, "Stylesheet".to_string())
1306                    }
1307                } else if (name_lower.ends_with(".tsx") || name_lower.ends_with(".jsx"))
1308                    && !name_lower.contains(".test.")
1309                    && !name_lower.contains(".spec.")
1310                    && !name_lower.contains(".story.")
1311                {
1312                    // Check if it's a story file
1313                    if name_lower.contains(".story.") {
1314                        (DesignFileType::Story, "Component story".to_string())
1315                    } else if rel.contains("component") || rel.contains("ui") || rel.contains("design") {
1316                        (DesignFileType::Component, "UI component".to_string())
1317                    } else {
1318                        continue; // Skip non-design TSX/JSX files
1319                    }
1320                } else if (name_lower.ends_with(".vue") || name_lower.ends_with(".svelte"))
1321                    && !name_lower.contains(".test.")
1322                    && !name_lower.contains(".spec.")
1323                {
1324                    if rel.contains("component") || rel.contains("ui") {
1325                        (DesignFileType::Component, "UI component".to_string())
1326                    } else {
1327                        continue;
1328                    }
1329                } else {
1330                    continue;
1331                };
1332
1333                design_files.push(DesignFile {
1334                    path: rel.clone(),
1335                    file_type,
1336                    description,
1337                });
1338
1339                // Extract basic patterns from the file path
1340                if rel.contains("color") || rel.contains("palette") {
1341                    patterns.push(DesignPattern {
1342                        name: "Color organization".to_string(),
1343                        category: PatternCategory::Color,
1344                        source: rel.clone(),
1345                        description: "File is organized around color/palette definitions".to_string(),
1346                    });
1347                }
1348                if rel.contains("spacing") || rel.contains("space") {
1349                    patterns.push(DesignPattern {
1350                        name: "Spacing system".to_string(),
1351                        category: PatternCategory::Spacing,
1352                        source: rel.clone(),
1353                        description: "File contains spacing-related definitions".to_string(),
1354                    });
1355                }
1356                if rel.contains("typo") || rel.contains("font") || rel.contains("text") {
1357                    patterns.push(DesignPattern {
1358                        name: "Typography system".to_string(),
1359                        category: PatternCategory::Typography,
1360                        source: rel.clone(),
1361                        description: "File contains typography-related definitions".to_string(),
1362                    });
1363                }
1364            }
1365        }
1366
1367        Ok(())
1368    }
1369
1370    /// Build a complete design system from extracted analysis.
1371    ///
1372    /// This constructs a [`DesignSystem`] with:
1373    /// - A semantic color palette
1374    /// - A neutral (gray) palette
1375    /// - Common spacing and typography tokens
1376    /// - Accessible component specs
1377    pub fn build_design_system(
1378        name: impl Into<String>,
1379        primary_hue: f64,
1380        primary_chroma: f64,
1381        analysis: Option<DesignAnalysis>,
1382    ) -> DesignSystem {
1383        let mut system = DesignSystem::new(name);
1384
1385        // Store analysis
1386        system.analysis = analysis;
1387
1388        // Root token groups
1389        let mut color_group = TokenGroup::new("color").with_description("Color tokens");
1390        let mut spacing_group = TokenGroup::new("spacing").with_description("Spacing scale");
1391        let mut typography_group = TokenGroup::new("typography").with_description("Typography tokens");
1392        let mut radius_group = TokenGroup::new("radius").with_description("Border radius tokens");
1393        let mut shadow_group = TokenGroup::new("shadow").with_description("Elevation shadows");
1394
1395        // ── Color palettes ──
1396        let primary = OklchColor::new(0.55, primary_chroma, primary_hue);
1397        let primary_palette = ColorPalette::generate("primary", &primary);
1398        color_group.add_child(primary_palette.to_token_group());
1399        system.palettes.push(primary_palette);
1400
1401        // Secondary: hue shifted +40°
1402        let secondary = OklchColor::new(0.55, primary_chroma, (primary_hue + 40.0) % 360.0);
1403        let secondary_palette = ColorPalette::generate("secondary", &secondary);
1404        color_group.add_child(secondary_palette.to_token_group());
1405        system.palettes.push(secondary_palette);
1406
1407        // Accent: hue shifted +180° (complementary)
1408        let accent = OklchColor::new(0.55, primary_chroma, (primary_hue + 180.0) % 360.0);
1409        let accent_palette = ColorPalette::generate("accent", &accent);
1410        color_group.add_child(accent_palette.to_token_group());
1411        system.palettes.push(accent_palette);
1412
1413        // Success (green range)
1414        let success = OklchColor::new(0.55, 0.15, 145.0);
1415        let success_palette = ColorPalette::generate("success", &success);
1416        color_group.add_child(success_palette.to_token_group());
1417        system.palettes.push(success_palette);
1418
1419        // Warning (amber range)
1420        let warning = OklchColor::new(0.75, 0.15, 80.0);
1421        let warning_palette = ColorPalette::generate("warning", &warning);
1422        color_group.add_child(warning_palette.to_token_group());
1423        system.palettes.push(warning_palette);
1424
1425        // Danger (red range)
1426        let danger = OklchColor::new(0.55, 0.20, 25.0);
1427        let danger_palette = ColorPalette::generate("danger", &danger);
1428        color_group.add_child(danger_palette.to_token_group());
1429        system.palettes.push(danger_palette);
1430
1431        // Neutral (gray)
1432        let neutral_palette = ColorPalette::neutral("neutral");
1433        color_group.add_child(neutral_palette.to_token_group());
1434        system.palettes.push(neutral_palette);
1435
1436        // Semantic aliases
1437        let mut semantic = TokenGroup::new("semantic").with_description("Semantic color aliases");
1438        semantic.add_token(
1439            DesignToken::color("color.semantic.background", OklchColor::new(0.99, 0.002, primary_hue), vec!["color".into(), "semantic".into()])
1440                .with_description("Application background"),
1441        );
1442        semantic.add_token(
1443            DesignToken::color("color.semantic.foreground", OklchColor::new(0.15, 0.01, primary_hue), vec!["color".into(), "semantic".into()])
1444                .with_description("Primary text color"),
1445        );
1446        semantic.add_token(
1447            DesignToken::color("color.semantic.muted", OklchColor::new(0.55, 0.01, primary_hue), vec!["color".into(), "semantic".into()])
1448                .with_description("Secondary/muted text"),
1449        );
1450        semantic.add_token(
1451            DesignToken::color("color.semantic.accent", OklchColor::new(0.55, primary_chroma, (primary_hue + 180.0) % 360.0), vec!["color".into(), "semantic".into()])
1452                .with_description("Accent / interactive color"),
1453        );
1454        semantic.add_token(
1455            DesignToken::color("color.semantic.border", OklchColor::new(0.87, 0.005, primary_hue), vec!["color".into(), "semantic".into()])
1456                .with_description("Default border color"),
1457        );
1458        color_group.add_child(semantic);
1459
1460        system.tokens.add_child(color_group);
1461
1462        // ── Spacing tokens ──
1463        let spacing_values: [(f64, &str); 13] = [
1464            (0.0, "0"),
1465            (0.125, "0.5"),
1466            (0.25, "1"),
1467            (0.375, "1.5"),
1468            (0.5, "2"),
1469            (0.75, "3"),
1470            (1.0, "4"),
1471            (1.5, "6"),
1472            (2.0, "8"),
1473            (3.0, "12"),
1474            (4.0, "16"),
1475            (6.0, "24"),
1476            (8.0, "32"),
1477        ];
1478        for (rem, label) in &spacing_values {
1479            spacing_group.add_token(DesignToken::new(
1480                format!("spacing.{}", label),
1481                TokenCategory::Spacing,
1482                format!("{}rem", rem),
1483                vec!["spacing".into()],
1484            ));
1485        }
1486        system.tokens.add_child(spacing_group);
1487
1488        // ── Typography tokens ──
1489        let font_sizes: [(&str, &str); 8] = [
1490            ("xs", "0.75rem"),
1491            ("sm", "0.875rem"),
1492            ("base", "1rem"),
1493            ("lg", "1.125rem"),
1494            ("xl", "1.25rem"),
1495            ("2xl", "1.5rem"),
1496            ("3xl", "1.875rem"),
1497            ("4xl", "2.25rem"),
1498        ];
1499        for (name, size) in &font_sizes {
1500            typography_group.add_token(DesignToken::new(
1501                format!("font-size.{}", name),
1502                TokenCategory::Typography,
1503                size.to_string(),
1504                vec!["typography".into(), "font-size".into()],
1505            ));
1506        }
1507
1508        // Font weights
1509        let weights: [(&str, &str); 5] = [
1510            ("normal", "400"),
1511            ("medium", "500"),
1512            ("semibold", "600"),
1513            ("bold", "700"),
1514            ("extrabold", "800"),
1515        ];
1516        for (name, weight) in &weights {
1517            typography_group.add_token(DesignToken::new(
1518                format!("font-weight.{}", name),
1519                TokenCategory::Typography,
1520                weight.to_string(),
1521                vec!["typography".into(), "font-weight".into()],
1522            ));
1523        }
1524
1525        // Line heights
1526        let line_heights: [(&str, &str); 5] = [
1527            ("tight", "1.25"),
1528            ("snug", "1.375"),
1529            ("normal", "1.5"),
1530            ("relaxed", "1.625"),
1531            ("loose", "2.0"),
1532        ];
1533        for (name, lh) in &line_heights {
1534            typography_group.add_token(DesignToken::new(
1535                format!("line-height.{}", name),
1536                TokenCategory::Typography,
1537                lh.to_string(),
1538                vec!["typography".into(), "line-height".into()],
1539            ));
1540        }
1541        system.tokens.add_child(typography_group);
1542
1543        // ── Radius tokens ──
1544        let radii: [(&str, &str); 6] = [
1545            ("none", "0"),
1546            ("sm", "0.125rem"),
1547            ("default", "0.25rem"),
1548            ("md", "0.375rem"),
1549            ("lg", "0.5rem"),
1550            ("xl", "0.75rem"),
1551        ];
1552        for (name, r) in &radii {
1553            radius_group.add_token(DesignToken::new(
1554                format!("radius.{}", name),
1555                TokenCategory::Radius,
1556                r.to_string(),
1557                vec!["radius".into()],
1558            ));
1559        }
1560        system.tokens.add_child(radius_group);
1561
1562        // ── Shadow tokens ──
1563        let shadows: [(&str, &str); 4] = [
1564            ("sm", "0 1px 2px 0 rgb(0 0 0 / 0.05)"),
1565            ("default", "0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1)"),
1566            ("md", "0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1)"),
1567            ("lg", "0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1)"),
1568        ];
1569        for (name, shadow) in &shadows {
1570            shadow_group.add_token(DesignToken::new(
1571                format!("shadow.{}", name),
1572                TokenCategory::Shadow,
1573                shadow.to_string(),
1574                vec!["shadow".into()],
1575            ));
1576        }
1577        system.tokens.add_child(shadow_group);
1578
1579        // ── Accessible components ──
1580        system.components.push(Self::build_button_spec());
1581        system.components.push(Self::build_card_spec());
1582        system.components.push(Self::build_input_spec());
1583        system.components.push(Self::build_badge_spec());
1584
1585        // ── Design decisions ──
1586        system.add_decision(
1587            "Color space: OKLCH",
1588            "Use OKLCH for all color tokens",
1589            "OKLCH provides perceptually uniform lightness, enabling reliable contrast checking and automatic palette generation without the lightness distortion of HSL.",
1590            vec!["HSL (non-uniform lightness)".to_string(), "RGB (no perceptual model)".to_string(), "OKLAB (less intuitive hue angle)".to_string()],
1591        );
1592        system.add_decision(
1593            "WCAG conformance target",
1594            "Target WCAG 2.x Level AA (4.5:1 for normal text)",
1595            "AA is the industry-standard minimum. AAA (7:1) is desirable for large bodies of text but overly restrictive for UI components.",
1596            vec!["AA only".to_string(), "AAA only".to_string(), "No conformance target".to_string()],
1597        );
1598        system.add_decision(
1599            "Spacing scale",
1600            "4px base unit with a 2× geometric scale",
1601            "A 4px base allows fine-grained control while the geometric scale (0, 4, 8, 12, 16, 24, 32) covers all common spacing needs.",
1602            vec!["8px base (less granular)".to_string(), "Linear scale (too many stops)".to_string(), "Fibonacci-based (irregular for implementation)".to_string()],
1603        );
1604
1605        system
1606    }
1607
1608    /// Build an accessible Button component spec.
1609    fn build_button_spec() -> ComponentSpec {
1610        let mut button = ComponentSpec::new("Button", "Interactive button element with multiple variants")
1611            .with_wcag_level(WcagLevel::AA);
1612
1613        button.add_a11y_note("Buttons must have visible focus indicators");
1614        button.add_a11y_note("Touch target size minimum 44×44px");
1615        button.add_a11y_note("Disabled state uses reduced opacity, not color alone");
1616
1617        button.add_aria("role='button' (native <button> preferred)");
1618        button.add_aria("aria-disabled='true' when disabled");
1619        button.add_aria("aria-pressed for toggle buttons");
1620
1621        button.add_keyboard("Enter and Space activate the button");
1622        button.add_keyboard("Tab navigates to button");
1623        button.add_keyboard("Shift+Tab navigates away from button");
1624
1625        // Contrast checks: white text on primary backgrounds
1626        let white = OklchColor::white();
1627        let primary_500 = OklchColor::new(0.55, 0.15, 250.0);
1628        let primary_600 = OklchColor::new(0.44, 0.15, 250.0);
1629        let primary_700 = OklchColor::new(0.35, 0.12, 250.0);
1630
1631        button.check_contrast("white", white, "primary.500", primary_500);
1632        button.check_contrast("white", white, "primary.600", primary_600);
1633        button.check_contrast("white", white, "primary.700", primary_700);
1634
1635        // Variants
1636        button.add_variant(ComponentVariant {
1637            name: "primary".to_string(),
1638            token_refs: vec![
1639                ("color.primary.500".to_string(), "background-color".to_string()),
1640                ("white".to_string(), "color".to_string()),
1641                ("radius.default".to_string(), "border-radius".to_string()),
1642                ("spacing.2".to_string(), "padding-y".to_string()),
1643                ("spacing.4".to_string(), "padding-x".to_string()),
1644                ("font-weight.semibold".to_string(), "font-weight".to_string()),
1645            ],
1646            extra_styles: vec![
1647                ("border".to_string(), "none".to_string()),
1648                ("cursor".to_string(), "pointer".to_string()),
1649            ],
1650        });
1651
1652        button.add_variant(ComponentVariant {
1653            name: "secondary".to_string(),
1654            token_refs: vec![
1655                ("color.secondary.500".to_string(), "background-color".to_string()),
1656                ("white".to_string(), "color".to_string()),
1657                ("radius.default".to_string(), "border-radius".to_string()),
1658            ],
1659            extra_styles: vec![],
1660        });
1661
1662        button.add_variant(ComponentVariant {
1663            name: "outline".to_string(),
1664            token_refs: vec![
1665                ("transparent".to_string(), "background-color".to_string()),
1666                ("color.primary.500".to_string(), "color".to_string()),
1667                ("color.primary.500".to_string(), "border-color".to_string()),
1668                ("radius.default".to_string(), "border-radius".to_string()),
1669            ],
1670            extra_styles: vec![
1671                ("border-width".to_string(), "1px".to_string()),
1672            ],
1673        });
1674
1675        button.add_variant(ComponentVariant {
1676            name: "ghost".to_string(),
1677            token_refs: vec![
1678                ("transparent".to_string(), "background-color".to_string()),
1679                ("color.neutral.700".to_string(), "color".to_string()),
1680            ],
1681            extra_styles: vec![
1682                ("border".to_string(), "none".to_string()),
1683            ],
1684        });
1685
1686        button
1687    }
1688
1689    /// Build an accessible Card component spec.
1690    fn build_card_spec() -> ComponentSpec {
1691        let mut card = ComponentSpec::new("Card", "Content container with optional header, body, and footer")
1692            .with_wcag_level(WcagLevel::AA);
1693
1694        card.add_a11y_note("Card content should use semantic HTML (heading, paragraph)");
1695        card.add_a11y_note("Interactive cards must be focusable and keyboard navigable");
1696
1697        card.add_aria("role='group' or semantic landmark for card sections");
1698        card.add_aria("aria-label if card purpose isn't clear from content");
1699
1700        // Contrast: dark text on white/light card background
1701        let bg = OklchColor::new(0.99, 0.002, 250.0);
1702        let fg = OklchColor::new(0.15, 0.01, 250.0);
1703        let muted = OklchColor::new(0.55, 0.01, 250.0);
1704        let border = OklchColor::new(0.87, 0.005, 250.0);
1705
1706        card.check_contrast("foreground", fg, "background", bg);
1707        card.check_contrast("muted", muted, "background", bg);
1708        card.check_contrast("border", border, "background", bg);
1709
1710        card.add_variant(ComponentVariant {
1711            name: "default".to_string(),
1712            token_refs: vec![
1713                ("color.semantic.background".to_string(), "background-color".to_string()),
1714                ("color.semantic.border".to_string(), "border-color".to_string()),
1715                ("radius.lg".to_string(), "border-radius".to_string()),
1716                ("shadow.default".to_string(), "box-shadow".to_string()),
1717                ("spacing.4".to_string(), "padding".to_string()),
1718            ],
1719            extra_styles: vec![
1720                ("border-width".to_string(), "1px".to_string()),
1721            ],
1722        });
1723
1724        card.add_variant(ComponentVariant {
1725            name: "elevated".to_string(),
1726            token_refs: vec![
1727                ("color.semantic.background".to_string(), "background-color".to_string()),
1728                ("shadow.md".to_string(), "box-shadow".to_string()),
1729                ("radius.lg".to_string(), "border-radius".to_string()),
1730            ],
1731            extra_styles: vec![
1732                ("border".to_string(), "none".to_string()),
1733            ],
1734        });
1735
1736        card
1737    }
1738
1739    /// Build an accessible Input component spec.
1740    fn build_input_spec() -> ComponentSpec {
1741        let mut input = ComponentSpec::new("Input", "Text input field with label, validation states, and error display")
1742            .with_wcag_level(WcagLevel::AA);
1743
1744        input.add_a11y_note("Every input must have an associated <label>");
1745        input.add_a11y_note("Error messages must be associated with aria-describedby");
1746        input.add_a11y_note("Required fields indicated by aria-required='true'");
1747        input.add_a11y_note("Focus ring must be visible (minimum 3:1 contrast)");
1748
1749        input.add_aria("aria-invalid='true' when validation fails");
1750        input.add_aria("aria-describedby pointing to error message element");
1751        input.add_aria("aria-required='true' for required fields");
1752
1753        input.add_keyboard("Tab moves focus to input");
1754        input.add_keyboard("Shift+Tab moves focus away");
1755        input.add_keyboard("Type to enter text");
1756
1757        // Contrast checks
1758        let bg = OklchColor::white();
1759        let fg = OklchColor::new(0.15, 0.01, 250.0);
1760        let border = OklchColor::new(0.76, 0.01, 250.0);
1761        let error = OklchColor::new(0.50, 0.20, 25.0);
1762
1763        input.check_contrast("foreground", fg, "background", bg);
1764        input.check_contrast("border", border, "background", bg);
1765        input.check_contrast("error", error, "background", bg);
1766
1767        input.add_variant(ComponentVariant {
1768            name: "default".to_string(),
1769            token_refs: vec![
1770                ("color.semantic.foreground".to_string(), "color".to_string()),
1771                ("color.semantic.background".to_string(), "background-color".to_string()),
1772                ("color.semantic.border".to_string(), "border-color".to_string()),
1773                ("radius.default".to_string(), "border-radius".to_string()),
1774                ("spacing.2".to_string(), "padding-y".to_string()),
1775                ("spacing.3".to_string(), "padding-x".to_string()),
1776                ("font-size.base".to_string(), "font-size".to_string()),
1777            ],
1778            extra_styles: vec![
1779                ("border-width".to_string(), "1px".to_string()),
1780            ],
1781        });
1782
1783        input.add_variant(ComponentVariant {
1784            name: "error".to_string(),
1785            token_refs: vec![
1786                ("color.danger.500".to_string(), "border-color".to_string()),
1787                ("color.danger.600".to_string(), "color".to_string()),
1788            ],
1789            extra_styles: vec![
1790                ("border-width".to_string(), "2px".to_string()),
1791            ],
1792        });
1793
1794        input
1795    }
1796
1797    /// Build an accessible Badge component spec.
1798    fn build_badge_spec() -> ComponentSpec {
1799        let mut badge = ComponentSpec::new("Badge", "Inline status indicator / label")
1800            .with_wcag_level(WcagLevel::AA);
1801
1802        badge.add_a11y_note("Badge text must meet contrast requirements against its background");
1803        badge.add_a11y_note("Status badges should include aria-label for screen readers");
1804
1805        badge.add_aria("role='status' for dynamic badges");
1806
1807        // Contrast checks: dark text on light badge backgrounds
1808        let fg = OklchColor::new(0.25, 0.10, 250.0);
1809        let bg_blue = OklchColor::new(0.93, 0.04, 250.0);
1810        let bg_green = OklchColor::new(0.93, 0.05, 145.0);
1811        let bg_red = OklchColor::new(0.93, 0.05, 25.0);
1812
1813        badge.check_contrast("info-foreground", fg, "info-background", bg_blue);
1814        badge.check_contrast("success-foreground", fg, "success-background", bg_green);
1815        badge.check_contrast("danger-foreground", fg, "danger-background", bg_red);
1816
1817        badge.add_variant(ComponentVariant {
1818            name: "default".to_string(),
1819            token_refs: vec![
1820                ("color.neutral.100".to_string(), "background-color".to_string()),
1821                ("color.neutral.700".to_string(), "color".to_string()),
1822                ("radius.default".to_string(), "border-radius".to_string()),
1823                ("spacing.1".to_string(), "padding-y".to_string()),
1824                ("spacing.2".to_string(), "padding-x".to_string()),
1825                ("font-size.xs".to_string(), "font-size".to_string()),
1826                ("font-weight.medium".to_string(), "font-weight".to_string()),
1827            ],
1828            extra_styles: vec![],
1829        });
1830
1831        badge.add_variant(ComponentVariant {
1832            name: "info".to_string(),
1833            token_refs: vec![
1834                ("color.primary.100".to_string(), "background-color".to_string()),
1835                ("color.primary.700".to_string(), "color".to_string()),
1836            ],
1837            extra_styles: vec![],
1838        });
1839
1840        badge.add_variant(ComponentVariant {
1841            name: "success".to_string(),
1842            token_refs: vec![
1843                ("color.success.100".to_string(), "background-color".to_string()),
1844                ("color.success.700".to_string(), "color".to_string()),
1845            ],
1846            extra_styles: vec![],
1847        });
1848
1849        badge.add_variant(ComponentVariant {
1850            name: "danger".to_string(),
1851            token_refs: vec![
1852                ("color.danger.100".to_string(), "background-color".to_string()),
1853                ("color.danger.700".to_string(), "color".to_string()),
1854            ],
1855            extra_styles: vec![],
1856        });
1857
1858        badge
1859    }
1860
1861    /// Generate the skill-prompt instructions for the design-farmer skill.
1862    ///
1863    /// This is injected into the system prompt when the skill is active,
1864    /// instructing the LLM how to extract and build design systems.
1865    pub fn skill_prompt() -> String {
1866        r#"# Design Farmer Skill
1867
1868You are running the **design-farmer** skill. Your job is to analyze codebases,
1869extract design patterns, and build comprehensive design systems with accessible
1870components.
1871
1872## Workflow
1873
1874### Phase 1: Analyze the Codebase
1875
18761. Scan the project for design-related files:
1877   - CSS/SCSS/LESS stylesheets
1878   - Tailwind or other CSS framework configs
1879   - Theme configuration files
1880   - Component files (React, Vue, Svelte)
1881   - Design token files (JSON, YAML)
18822. Identify the styling approach (Tailwind, CSS Modules, CSS-in-JS, vanilla).
18833. Catalog existing color values, spacing patterns, typography, and components.
18844. Summarize findings as a `DesignAnalysis`.
1885
1886### Phase 2: Extract Design Patterns
1887
18881. For each design file found, extract:
1889   - Color definitions (hex, rgb, hsl — convert to OKLCH)
1890   - Spacing scale values
1891   - Typography tokens (font families, sizes, weights, line heights)
1892   - Border radii
1893   - Shadow definitions
1894   - Breakpoints
18952. Identify repeating patterns:
1896   - Which colors are used most frequently?
1897   - Is there an existing spacing scale?
1898   - Are typography sizes following a scale?
18993. Document gaps: missing tokens, inconsistent values, unnamed colors.
1900
1901### Phase 3: Build Token Hierarchy
1902
19031. Create a root `TokenGroup` with these sub-groups:
1904   - `color` — with palettes for primary, secondary, accent, neutral, semantic
1905   - `spacing` — geometric scale from 0 to 32rem
1906   - `typography` — font sizes, weights, line heights
1907   - `radius` — border radii from none to xl
1908   - `shadow` — elevation levels
19092. Use **OKLCH** for all color tokens:
1910   - `oklch(L C H)` where L=lightness, C=chroma, H=hue
1911   - Generate full palettes by varying L at constant C and H
1912   - Steps: 50, 100, 200, 300, 400, 500, 600, 700, 800, 900, 950
19133. Create semantic aliases (background, foreground, muted, accent, border).
19144. Document each token with a description and group path.
1915
1916### Phase 4: Implement Accessible Components
1917
19181. For each UI component, create a `ComponentSpec` with:
1919   - Name and description
1920   - Variants (primary, secondary, outline, ghost, etc.)
1921   - Token references (which tokens map to which CSS properties)
1922   - ARIA requirements
1923   - Keyboard interaction requirements
19242. Run contrast checks:
1925   - Foreground vs background for every variant
1926   - Target: WCAG 2.x Level AA (4.5:1 for normal text, 3:1 for large text)
1927   - Fix failing combinations by adjusting lightness
19283. Document accessibility notes for each component.
1929
1930### Phase 5: Document and Export
1931
19321. Render the design system as Markdown.
19332. Export as JSON for programmatic consumption.
19343. Write to `docs/design/DESIGN.md` (or a specified path).
19354. Include an accessibility report summarizing all contrast checks.
1936
1937## Rules
1938
1939- **Always use OKLCH** for color tokens. Never HSL or hex in token values.
1940- **Every component must have contrast checks.** No exceptions.
1941- **Target WCAG AA** as minimum. AA is non-negotiable; AAA is aspirational.
1942- **Document every design decision** with rationale and alternatives considered.
1943- **Prefer simplicity.** Don't create tokens that aren't needed.
1944- **Tokens are the source of truth.** Components reference tokens, not raw values.
1945- **No magic numbers.** Every value should trace back to a named token.
1946
1947## OKLCH Quick Reference
1948
1949```
1950oklch(L C H)       — opaque
1951oklch(L C H / A)   — with alpha
1952
1953L: 0.0 (black) to 1.0 (white) — perceptually uniform
1954C: 0.0 (gray) to ~0.4 (vivid) — chroma, varies by hue
1955H: 0 to 360 — hue angle (0=red, 90=yellow, 145=green, 250=blue, 310=purple)
1956A: 0.0 (transparent) to 1.0 (opaque)
1957
1958Contrast ratio:
1959  ratio = (L_lighter + 0.05) / (L_darker + 0.05)
1960  AA pass: ratio ≥ 4.5
1961  AAA pass: ratio ≥ 7.0
1962
1963Palette generation:
1964  Keep C and H constant, vary L from 0.14 (950) to 0.97 (50)
1965  Compress C at extreme L to avoid gamut clipping
1966```
1967"#.to_string()
1968    }
1969}
1970
1971impl Default for DesignFarmer {
1972    fn default() -> Self {
1973        Self::new()
1974    }
1975}
1976
1977impl fmt::Debug for DesignFarmer {
1978    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1979        f.debug_struct("DesignFarmer").finish()
1980    }
1981}
1982
1983// ── Tests ──────────────────────────────────────────────────────────────
1984
1985#[cfg(test)]
1986mod tests {
1987    use super::*;
1988    use std::fs;
1989
1990    // ── OklchColor tests ──
1991
1992    #[test]
1993    fn test_oklch_new() {
1994        let c = OklchColor::new(0.5, 0.15, 250.0);
1995        assert!((c.l - 0.5).abs() < f64::EPSILON);
1996        assert!((c.c - 0.15).abs() < f64::EPSILON);
1997        assert!((c.h - 250.0).abs() < f64::EPSILON);
1998        assert!((c.alpha - 1.0).abs() < f64::EPSILON);
1999    }
2000
2001    #[test]
2002    fn test_oklch_with_alpha() {
2003        let c = OklchColor::new(0.5, 0.15, 250.0).with_alpha(0.5);
2004        assert!((c.alpha - 0.5).abs() < f64::EPSILON);
2005    }
2006
2007    #[test]
2008    fn test_oklch_alpha_clamped() {
2009        let c = OklchColor::new(0.5, 0.15, 250.0).with_alpha(1.5);
2010        assert!((c.alpha - 1.0).abs() < f64::EPSILON);
2011    }
2012
2013    #[test]
2014    fn test_oklch_black_white() {
2015        let black = OklchColor::black();
2016        assert!((black.l).abs() < f64::EPSILON);
2017        let white = OklchColor::white();
2018        assert!((white.l - 1.0).abs() < f64::EPSILON);
2019    }
2020
2021    #[test]
2022    fn test_oklch_to_css_opaque() {
2023        let c = OklchColor::new(0.5, 0.15, 250.0);
2024        let css = c.to_css();
2025        assert!(css.starts_with("oklch("));
2026        assert!(!css.contains("/")); // No alpha separator when opaque
2027    }
2028
2029    #[test]
2030    fn test_oklch_to_css_transparent() {
2031        let c = OklchColor::new(0.5, 0.15, 250.0).with_alpha(0.5);
2032        let css = c.to_css();
2033        assert!(css.contains("/ 0.50"));
2034    }
2035
2036    #[test]
2037    fn test_oklch_display() {
2038        let c = OklchColor::new(0.5, 0.15, 250.0);
2039        assert_eq!(format!("{}", c), c.to_css());
2040    }
2041
2042    #[test]
2043    fn test_contrast_ratio_identical() {
2044        let c = OklchColor::new(0.5, 0.15, 250.0);
2045        let ratio = c.contrast_ratio(&c);
2046        assert!((ratio - 1.0).abs() < 0.01);
2047    }
2048
2049    #[test]
2050    fn test_contrast_ratio_black_white() {
2051        let ratio = OklchColor::black().contrast_ratio(&OklchColor::white());
2052        // Should be close to 21:1
2053        assert!(ratio > 15.0, "Expected high contrast, got {}", ratio);
2054    }
2055
2056    #[test]
2057    fn test_aa_compliant() {
2058        let white = OklchColor::white();
2059        let dark_text = OklchColor::new(0.2, 0.0, 0.0);
2060        assert!(dark_text.is_aa_compliant(&white));
2061    }
2062
2063    #[test]
2064    fn test_aaa_compliant() {
2065        let white = OklchColor::white();
2066        let very_dark = OklchColor::new(0.1, 0.0, 0.0);
2067        assert!(very_dark.is_aaa_compliant(&white));
2068    }
2069
2070    #[test]
2071    fn test_lightness_scale() {
2072        let base = OklchColor::new(0.5, 0.15, 250.0);
2073        let scale = base.lightness_scale(5, 0.2, 0.8);
2074        assert_eq!(scale.len(), 5);
2075        assert!((scale[0].l - 0.2).abs() < f64::EPSILON);
2076        assert!((scale[4].l - 0.8).abs() < f64::EPSILON);
2077        // All should share chroma and hue
2078        for c in &scale {
2079            assert!((c.c - 0.15).abs() < f64::EPSILON);
2080            assert!((c.h - 250.0).abs() < f64::EPSILON);
2081        }
2082    }
2083
2084    #[test]
2085    fn test_lightness_scale_single_step() {
2086        let base = OklchColor::new(0.5, 0.15, 250.0);
2087        let scale = base.lightness_scale(1, 0.2, 0.8);
2088        assert_eq!(scale.len(), 1);
2089        // Single step returns the base color unchanged
2090        assert!((scale[0].l - 0.5).abs() < f64::EPSILON);
2091    }
2092
2093    // ── Token tests ──
2094
2095    #[test]
2096    fn test_token_new() {
2097        let token = DesignToken::new("spacing.4", TokenCategory::Spacing, "1rem", vec!["spacing".into()]);
2098        assert_eq!(token.name, "spacing.4");
2099        assert_eq!(token.category, TokenCategory::Spacing);
2100        assert_eq!(token.value, "1rem");
2101        assert!(token.oklch.is_none());
2102        assert!(token.alias.is_none());
2103    }
2104
2105    #[test]
2106    fn test_token_color() {
2107        let color = OklchColor::new(0.5, 0.15, 250.0);
2108        let token = DesignToken::color("color.primary.500", color, vec!["color".into(), "primary".into()]);
2109        assert_eq!(token.category, TokenCategory::Color);
2110        assert!(token.oklch.is_some());
2111        assert_eq!(token.value, color.to_css());
2112    }
2113
2114    #[test]
2115    fn test_token_with_description() {
2116        let token = DesignToken::new("spacing.4", TokenCategory::Spacing, "1rem", vec![])
2117            .with_description("Standard spacing unit");
2118        assert_eq!(token.description, Some("Standard spacing unit".to_string()));
2119    }
2120
2121    #[test]
2122    fn test_token_alias() {
2123        let token = DesignToken::alias("color.semantic.accent", "color.primary.500", vec![]);
2124        assert_eq!(token.alias, Some("color.primary.500".to_string()));
2125    }
2126
2127    // ── TokenGroup tests ──
2128
2129    #[test]
2130    fn test_token_group_new() {
2131        let group = TokenGroup::new("color");
2132        assert_eq!(group.name, "color");
2133        assert!(group.tokens.is_empty());
2134        assert!(group.children.is_empty());
2135    }
2136
2137    #[test]
2138    fn test_token_group_add_token() {
2139        let mut group = TokenGroup::new("spacing");
2140        group.add_token(DesignToken::new("spacing.4", TokenCategory::Spacing, "1rem", vec![]));
2141        assert_eq!(group.tokens.len(), 1);
2142    }
2143
2144    #[test]
2145    fn test_token_group_add_child() {
2146        let mut root = TokenGroup::new("root");
2147        root.add_child(TokenGroup::new("color"));
2148        assert_eq!(root.children.len(), 1);
2149    }
2150
2151    #[test]
2152    fn test_token_group_child_mut_creates() {
2153        let mut root = TokenGroup::new("root");
2154        let child = root.child_mut("color");
2155        child.add_token(DesignToken::new("color.primary", TokenCategory::Color, "blue", vec![]));
2156        assert_eq!(root.children.len(), 1);
2157        assert_eq!(root.children[0].tokens.len(), 1);
2158    }
2159
2160    #[test]
2161    fn test_token_group_child_mut_existing() {
2162        let mut root = TokenGroup::new("root");
2163        root.add_child(TokenGroup::new("color"));
2164        let child = root.child_mut("color");
2165        child.description = Some("Colors".to_string());
2166        assert_eq!(root.children.len(), 1);
2167        assert_eq!(root.children[0].description, Some("Colors".to_string()));
2168    }
2169
2170    #[test]
2171    fn test_token_group_all_tokens() {
2172        let mut root = TokenGroup::new("root");
2173        let mut color = TokenGroup::new("color");
2174        color.add_token(DesignToken::new("color.primary", TokenCategory::Color, "blue", vec![]));
2175        let spacing = TokenGroup::new("spacing");
2176        root.add_child(color);
2177        root.add_child(spacing);
2178        root.add_token(DesignToken::new("global.font", TokenCategory::Typography, "sans", vec![]));
2179
2180        let all = root.all_tokens();
2181        assert_eq!(all.len(), 2); // color.primary + global.font
2182    }
2183
2184    #[test]
2185    fn test_token_group_token_count() {
2186        let mut root = TokenGroup::new("root");
2187        let mut color = TokenGroup::new("color");
2188        color.add_token(DesignToken::new("a", TokenCategory::Color, "x", vec![]));
2189        color.add_token(DesignToken::new("b", TokenCategory::Color, "y", vec![]));
2190        root.add_child(color);
2191        root.add_token(DesignToken::new("c", TokenCategory::Spacing, "z", vec![]));
2192        assert_eq!(root.token_count(), 3);
2193    }
2194
2195    #[test]
2196    fn test_token_group_get_child() {
2197        let mut root = TokenGroup::new("root");
2198        root.add_child(TokenGroup::new("color"));
2199        assert!(root.child("color").is_some());
2200        assert!(root.child("spacing").is_none());
2201    }
2202
2203    // ── ColorPalette tests ──
2204
2205    #[test]
2206    fn test_palette_generate() {
2207        let base = OklchColor::new(0.55, 0.15, 250.0);
2208        let palette = ColorPalette::generate("primary", &base);
2209        assert_eq!(palette.name, "primary");
2210        assert_eq!(palette.stops.len(), 11); // 50, 100, ..., 900, 950
2211        assert_eq!(palette.hue, 250.0);
2212    }
2213
2214    #[test]
2215    fn test_palette_get_stop() {
2216        let base = OklchColor::new(0.55, 0.15, 250.0);
2217        let palette = ColorPalette::generate("primary", &base);
2218        assert!(palette.get_stop(500).is_some());
2219        assert!(palette.get_stop(50).is_some());
2220        assert!(palette.get_stop(950).is_some());
2221        assert!(palette.get_stop(999).is_none());
2222    }
2223
2224    #[test]
2225    fn test_palette_mid() {
2226        let base = OklchColor::new(0.55, 0.15, 250.0);
2227        let palette = ColorPalette::generate("primary", &base);
2228        let mid = palette.mid();
2229        assert!((mid.l - 0.55).abs() < f64::EPSILON);
2230    }
2231
2232    #[test]
2233    fn test_palette_neutral() {
2234        let palette = ColorPalette::neutral("gray");
2235        assert_eq!(palette.name, "gray");
2236        assert!((palette.chroma).abs() < f64::EPSILON);
2237        // All stops should have zero chroma
2238        for stop in &palette.stops {
2239            assert!((stop.color.c).abs() < f64::EPSILON);
2240        }
2241    }
2242
2243    #[test]
2244    fn test_palette_stop_token_names() {
2245        let base = OklchColor::new(0.55, 0.15, 250.0);
2246        let palette = ColorPalette::generate("primary", &base);
2247        assert_eq!(palette.stops[0].token_name, "color.primary.50");
2248        assert_eq!(palette.stops[5].token_name, "color.primary.500");
2249        assert_eq!(palette.stops[10].token_name, "color.primary.950");
2250    }
2251
2252    #[test]
2253    fn test_palette_to_token_group() {
2254        let base = OklchColor::new(0.55, 0.15, 250.0);
2255        let palette = ColorPalette::generate("primary", &base);
2256        let group = palette.to_token_group();
2257        assert_eq!(group.name, "primary");
2258        assert_eq!(group.tokens.len(), 11);
2259    }
2260
2261    // ── ContrastCheck tests ──
2262
2263    #[test]
2264    fn test_contrast_check_passing() {
2265        let fg = OklchColor::new(0.2, 0.0, 0.0);
2266        let bg = OklchColor::white();
2267        let check = ContrastCheck::check("fg", fg, "bg", bg);
2268        assert!(check.aa_pass);
2269        assert!(check.ratio >= 4.5);
2270    }
2271
2272    #[test]
2273    fn test_contrast_check_failing() {
2274        // L=0.65 is too light against white to pass AA
2275        let fg = OklchColor::new(0.65, 0.0, 0.0);
2276        let bg = OklchColor::white();
2277        let check = ContrastCheck::check("fg", fg, "bg", bg);
2278        // Light gray on white should fail AA
2279        assert!(!check.aa_pass, "Expected ratio < 4.5 but got {:.2}", check.ratio);
2280    }
2281
2282    #[test]
2283    fn test_contrast_check_passes_level() {
2284        let fg = OklchColor::new(0.1, 0.0, 0.0);
2285        let bg = OklchColor::white();
2286        let check = ContrastCheck::check("fg", fg, "bg", bg);
2287        assert!(check.passes(WcagLevel::AA));
2288        assert!(check.passes(WcagLevel::AAA));
2289    }
2290
2291    #[test]
2292    fn test_contrast_check_fails_level() {
2293        // L=0.6 vs white: ratio ≈ 3.95 (fails AA)
2294        let fg = OklchColor::new(0.6, 0.0, 0.0);
2295        let bg = OklchColor::white();
2296        let check = ContrastCheck::check("fg", fg, "bg", bg);
2297        assert!(!check.passes(WcagLevel::AA), "Expected ratio < 4.5 but got {:.2}", check.ratio);
2298        assert!(!check.passes(WcagLevel::AAA));
2299    }
2300
2301    // ── ComponentSpec tests ──
2302
2303    #[test]
2304    fn test_component_new() {
2305        let comp = ComponentSpec::new("Button", "A button");
2306        assert_eq!(comp.name, "Button");
2307        assert_eq!(comp.wcag_level, WcagLevel::AA);
2308        assert!(comp.variants.is_empty());
2309        assert!(comp.is_accessible()); // No checks = trivially accessible
2310    }
2311
2312    #[test]
2313    fn test_component_with_wcag_level() {
2314        let comp = ComponentSpec::new("Button", "A button").with_wcag_level(WcagLevel::AAA);
2315        assert_eq!(comp.wcag_level, WcagLevel::AAA);
2316    }
2317
2318    #[test]
2319    fn test_component_add_variant() {
2320        let mut comp = ComponentSpec::new("Button", "A button");
2321        comp.add_variant(ComponentVariant {
2322            name: "primary".to_string(),
2323            token_refs: vec![("bg".to_string(), "background-color".to_string())],
2324            extra_styles: vec![],
2325        });
2326        assert_eq!(comp.variants.len(), 1);
2327    }
2328
2329    #[test]
2330    fn test_component_check_contrast() {
2331        let mut comp = ComponentSpec::new("Button", "A button");
2332        comp.check_contrast(
2333            "white",
2334            OklchColor::white(),
2335            "primary",
2336            OklchColor::new(0.5, 0.15, 250.0),
2337        );
2338        assert_eq!(comp.contrast_checks.len(), 1);
2339    }
2340
2341    #[test]
2342    fn test_component_is_accessible_passing() {
2343        let mut comp = ComponentSpec::new("Button", "A button");
2344        comp.check_contrast(
2345            "dark-text",
2346            OklchColor::new(0.15, 0.0, 0.0),
2347            "white-bg",
2348            OklchColor::white(),
2349        );
2350        assert!(comp.is_accessible());
2351    }
2352
2353    #[test]
2354    fn test_component_is_accessible_failing() {
2355        let mut comp = ComponentSpec::new("Button", "A button");
2356        comp.check_contrast(
2357            "light-text",
2358            OklchColor::new(0.65, 0.0, 0.0),
2359            "white-bg",
2360            OklchColor::white(),
2361        );
2362        assert!(!comp.is_accessible());
2363    }
2364
2365    #[test]
2366    fn test_component_failing_checks() {
2367        let mut comp = ComponentSpec::new("Button", "A button");
2368        comp.check_contrast("good", OklchColor::new(0.15, 0.0, 0.0), "white", OklchColor::white());
2369        comp.check_contrast("bad", OklchColor::new(0.65, 0.0, 0.0), "white", OklchColor::white());
2370        let failing = comp.failing_checks();
2371        assert_eq!(failing.len(), 1);
2372    }
2373
2374    #[test]
2375    fn test_component_a11y_notes() {
2376        let mut comp = ComponentSpec::new("Button", "A button");
2377        comp.add_a11y_note("Must have focus indicator");
2378        assert_eq!(comp.a11y_notes.len(), 1);
2379    }
2380
2381    #[test]
2382    fn test_component_aria_requirements() {
2383        let mut comp = ComponentSpec::new("Button", "A button");
2384        comp.add_aria("aria-disabled='true'");
2385        assert_eq!(comp.aria_requirements.len(), 1);
2386    }
2387
2388    #[test]
2389    fn test_component_keyboard_requirements() {
2390        let mut comp = ComponentSpec::new("Button", "A button");
2391        comp.add_keyboard("Enter activates");
2392        assert_eq!(comp.keyboard_requirements.len(), 1);
2393    }
2394
2395    // ── DesignSystem tests ──
2396
2397    #[test]
2398    fn test_design_system_new() {
2399        let system = DesignSystem::new("test-system");
2400        assert_eq!(system.name, "test-system");
2401        assert_eq!(system.version, "1.0.0");
2402        assert!(system.palettes.is_empty());
2403        assert!(system.components.is_empty());
2404        assert!(system.analysis.is_none());
2405    }
2406
2407    #[test]
2408    fn test_design_system_add_palette() {
2409        let mut system = DesignSystem::new("test");
2410        let palette = ColorPalette::generate("primary", &OklchColor::new(0.55, 0.15, 250.0));
2411        system.add_palette(palette);
2412        assert_eq!(system.palettes.len(), 1);
2413        // Tokens should also be registered
2414        assert!(system.tokens.token_count() > 0);
2415    }
2416
2417    #[test]
2418    fn test_design_system_total_tokens_empty() {
2419        let system = DesignSystem::new("test");
2420        assert_eq!(system.total_tokens(), 0);
2421    }
2422
2423    #[test]
2424    fn test_design_system_add_decision() {
2425        let mut system = DesignSystem::new("test");
2426        system.add_decision(
2427            "Color space",
2428            "OKLCH",
2429            "Perceptually uniform",
2430            vec!["HSL".to_string()],
2431        );
2432        assert_eq!(system.decisions.len(), 1);
2433        assert_eq!(system.decisions[0].title, "Color space");
2434    }
2435
2436    #[test]
2437    fn test_design_system_accessibility_report_all_pass() {
2438        let mut system = DesignSystem::new("test");
2439        let mut comp = ComponentSpec::new("Button", "A button");
2440        comp.check_contrast("dark", OklchColor::new(0.15, 0.0, 0.0), "white", OklchColor::white());
2441        system.components.push(comp);
2442        let report = system.accessibility_report();
2443        assert!(report.all_pass);
2444        assert_eq!(report.total_checks, 1);
2445        assert_eq!(report.passing_count, 1);
2446        assert_eq!(report.failing_count, 0);
2447    }
2448
2449    #[test]
2450    fn test_design_system_accessibility_report_with_failures() {
2451        let mut system = DesignSystem::new("test");
2452        let mut comp = ComponentSpec::new("Button", "A button");
2453        comp.check_contrast("light", OklchColor::new(0.65, 0.0, 0.0), "white", OklchColor::white());
2454        system.components.push(comp);
2455        let report = system.accessibility_report();
2456        assert!(!report.all_pass);
2457        assert_eq!(report.failing_count, 1);
2458    }
2459
2460    // ── build_design_system tests ──
2461
2462    #[test]
2463    fn test_build_design_system() {
2464        let system = DesignFarmer::build_design_system("test-system", 250.0, 0.15, None);
2465        assert_eq!(system.name, "test-system");
2466        assert!(!system.palettes.is_empty());
2467        assert!(!system.components.is_empty());
2468        assert!(system.tokens.token_count() > 50, "Expected many tokens, got {}", system.tokens.token_count());
2469        assert!(!system.decisions.is_empty());
2470    }
2471
2472    #[test]
2473    fn test_build_design_system_has_all_component_types() {
2474        let system = DesignFarmer::build_design_system("test", 250.0, 0.15, None);
2475        let names: Vec<&str> = system.components.iter().map(|c| c.name.as_str()).collect();
2476        assert!(names.contains(&"Button"));
2477        assert!(names.contains(&"Card"));
2478        assert!(names.contains(&"Input"));
2479        assert!(names.contains(&"Badge"));
2480    }
2481
2482    #[test]
2483    fn test_build_design_system_has_all_token_groups() {
2484        let system = DesignFarmer::build_design_system("test", 250.0, 0.15, None);
2485        let child_names: Vec<&str> = system.tokens.children.iter().map(|c| c.name.as_str()).collect();
2486        assert!(child_names.contains(&"color"));
2487        assert!(child_names.contains(&"spacing"));
2488        assert!(child_names.contains(&"typography"));
2489        assert!(child_names.contains(&"radius"));
2490        assert!(child_names.contains(&"shadow"));
2491    }
2492
2493    // ── Codebase analysis tests ──
2494
2495    #[test]
2496    fn test_analyze_codebase_empty_dir() {
2497        let tmp = tempfile::tempdir().unwrap();
2498        let analysis = DesignFarmer::analyze_codebase(tmp.path()).unwrap();
2499        assert_eq!(analysis.design_files.len(), 0);
2500        assert_eq!(analysis.framework, DesignFramework::Unknown);
2501    }
2502
2503    #[test]
2504    fn test_analyze_codebase_detects_tailwind() {
2505        let tmp = tempfile::tempdir().unwrap();
2506        fs::write(tmp.path().join("tailwind.config.js"), "module.exports = {}").unwrap();
2507        let analysis = DesignFarmer::analyze_codebase(tmp.path()).unwrap();
2508        assert_eq!(analysis.framework, DesignFramework::Tailwind);
2509    }
2510
2511    #[test]
2512    fn test_analyze_codebase_detects_css_in_js() {
2513        let tmp = tempfile::tempdir().unwrap();
2514        fs::write(
2515            tmp.path().join("package.json"),
2516            r#"{"dependencies": {"styled-components": "^6.0.0"}}"#,
2517        )
2518        .unwrap();
2519        let analysis = DesignFarmer::analyze_codebase(tmp.path()).unwrap();
2520        assert_eq!(analysis.framework, DesignFramework::CssInJs);
2521    }
2522
2523    #[test]
2524    fn test_analyze_codebase_finds_css_files() {
2525        let tmp = tempfile::tempdir().unwrap();
2526        let src = tmp.path().join("src");
2527        fs::create_dir_all(src.join("components")).unwrap();
2528        fs::write(src.join("global.css"), "body { margin: 0; }").unwrap();
2529        fs::write(src.join("components").join("Button.css"), ".btn { color: blue; }").unwrap();
2530        fs::write(src.join("components").join("Button.tsx"), "<button />").unwrap();
2531
2532        let analysis = DesignFarmer::analyze_codebase(tmp.path()).unwrap();
2533        let file_types: Vec<_> = analysis.design_files.iter().map(|f| f.file_type).collect();
2534        assert!(file_types.iter().any(|t| *t == DesignFileType::Stylesheet));
2535        assert!(file_types.iter().any(|t| *t == DesignFileType::Component));
2536    }
2537
2538    #[test]
2539    fn test_analyze_codebase_skips_noise() {
2540        let tmp = tempfile::tempdir().unwrap();
2541        fs::create_dir_all(tmp.path().join(".git")).unwrap();
2542        fs::create_dir_all(tmp.path().join("node_modules")).unwrap();
2543        fs::create_dir_all(tmp.path().join("dist")).unwrap();
2544
2545        let analysis = DesignFarmer::analyze_codebase(tmp.path()).unwrap();
2546        for f in &analysis.design_files {
2547            assert!(!f.path.starts_with(".git/"));
2548            assert!(!f.path.starts_with("node_modules/"));
2549            assert!(!f.path.starts_with("dist/"));
2550        }
2551    }
2552
2553    #[test]
2554    fn test_analyze_codebase_with_theme_files() {
2555        let tmp = tempfile::tempdir().unwrap();
2556        fs::write(tmp.path().join("theme.json"), "{\"colors\": {\"primary\": \"#0066ff\"}}").unwrap();
2557        let analysis = DesignFarmer::analyze_codebase(tmp.path()).unwrap();
2558        assert!(analysis.design_files.iter().any(|f| f.file_type == DesignFileType::ThemeConfig));
2559    }
2560
2561    // ── Markdown rendering tests ──
2562
2563    #[test]
2564    fn test_render_markdown_empty() {
2565        let system = DesignSystem::new("test");
2566        let md = system.render_markdown();
2567        assert!(md.contains("# Design System: test"));
2568        assert!(md.contains("0 tokens"));
2569    }
2570
2571    #[test]
2572    fn test_render_markdown_with_palette() {
2573        let mut system = DesignSystem::new("test");
2574        let palette = ColorPalette::generate("primary", &OklchColor::new(0.55, 0.15, 250.0));
2575        system.add_palette(palette);
2576        let md = system.render_markdown();
2577        assert!(md.contains("## Color Palettes"));
2578        assert!(md.contains("### primary"));
2579        assert!(md.contains("| 500 |"));
2580    }
2581
2582    #[test]
2583    fn test_render_markdown_with_components() {
2584        let mut system = DesignSystem::new("test");
2585        let mut comp = ComponentSpec::new("Button", "A button");
2586        comp.check_contrast("dark", OklchColor::new(0.15, 0.0, 0.0), "white", OklchColor::white());
2587        system.components.push(comp);
2588        let md = system.render_markdown();
2589        assert!(md.contains("## Components"));
2590        assert!(md.contains("### Button"));
2591        assert!(md.contains("✅"));
2592    }
2593
2594    #[test]
2595    fn test_render_markdown_with_decisions() {
2596        let mut system = DesignSystem::new("test");
2597        system.add_decision("Color space", "OKLCH", "Uniform", vec!["HSL".to_string()]);
2598        let md = system.render_markdown();
2599        assert!(md.contains("## Design Decisions"));
2600        assert!(md.contains("### Color space"));
2601        assert!(md.contains("Alternatives considered"));
2602    }
2603
2604    #[test]
2605    fn test_render_markdown_accessibility_report() {
2606        let system = DesignSystem::new("test");
2607        let md = system.render_markdown();
2608        assert!(md.contains("## Accessibility Report"));
2609    }
2610
2611    // ── File I/O tests ──
2612
2613    #[test]
2614    fn test_write_markdown() {
2615        let tmp = tempfile::tempdir().unwrap();
2616        let system = DesignFarmer::build_design_system("test", 250.0, 0.15, None);
2617        let path = tmp.path().join("DESIGN.md");
2618        let result = system.write_markdown(&path).unwrap();
2619        assert!(result.exists());
2620        let content = fs::read_to_string(&result).unwrap();
2621        assert!(content.contains("# Design System: test"));
2622    }
2623
2624    #[test]
2625    fn test_write_json() {
2626        let tmp = tempfile::tempdir().unwrap();
2627        let system = DesignFarmer::build_design_system("test", 250.0, 0.15, None);
2628        let path = tmp.path().join("design.json");
2629        let result = system.write_json(&path).unwrap();
2630        assert!(result.exists());
2631        let content = fs::read_to_string(&result).unwrap();
2632        assert!(content.contains("\"name\": \"test\""));
2633    }
2634
2635    #[test]
2636    fn test_write_markdown_creates_parent_dirs() {
2637        let tmp = tempfile::tempdir().unwrap();
2638        let system = DesignSystem::new("test");
2639        let path = tmp.path().join("docs").join("design").join("DESIGN.md");
2640        let result = system.write_markdown(&path).unwrap();
2641        assert!(result.exists());
2642    }
2643
2644    // ── Serialization roundtrip tests ──
2645
2646    #[test]
2647    fn test_oklch_serde_roundtrip() {
2648        let c = OklchColor::new(0.5, 0.15, 250.0).with_alpha(0.8);
2649        let json = serde_json::to_string(&c).unwrap();
2650        let parsed: OklchColor = serde_json::from_str(&json).unwrap();
2651        assert!((parsed.l - c.l).abs() < f64::EPSILON);
2652        assert!((parsed.c - c.c).abs() < f64::EPSILON);
2653        assert!((parsed.h - c.h).abs() < f64::EPSILON);
2654        assert!((parsed.alpha - c.alpha).abs() < f64::EPSILON);
2655    }
2656
2657    #[test]
2658    fn test_design_system_serde_roundtrip() {
2659        let system = DesignFarmer::build_design_system("test", 250.0, 0.15, None);
2660        let json = serde_json::to_string_pretty(&system).unwrap();
2661        let parsed: DesignSystem = serde_json::from_str(&json).unwrap();
2662        assert_eq!(parsed.name, "test");
2663        assert_eq!(parsed.palettes.len(), system.palettes.len());
2664        assert_eq!(parsed.components.len(), system.components.len());
2665        assert_eq!(parsed.total_tokens(), system.total_tokens());
2666    }
2667
2668    #[test]
2669    fn test_component_spec_serde_roundtrip() {
2670        let mut comp = ComponentSpec::new("Button", "A button").with_wcag_level(WcagLevel::AA);
2671        comp.check_contrast("fg", OklchColor::new(0.2, 0.0, 0.0), "bg", OklchColor::white());
2672        comp.add_variant(ComponentVariant {
2673            name: "primary".to_string(),
2674            token_refs: vec![("bg".to_string(), "background-color".to_string())],
2675            extra_styles: vec![],
2676        });
2677
2678        let json = serde_json::to_string(&comp).unwrap();
2679        let parsed: ComponentSpec = serde_json::from_str(&json).unwrap();
2680        assert_eq!(parsed.name, "Button");
2681        assert_eq!(parsed.contrast_checks.len(), 1);
2682        assert_eq!(parsed.variants.len(), 1);
2683    }
2684
2685    // ── Skill prompt test ──
2686
2687    #[test]
2688    fn test_skill_prompt_not_empty() {
2689        let prompt = DesignFarmer::skill_prompt();
2690        assert!(!prompt.is_empty());
2691        assert!(prompt.contains("Design Farmer Skill"));
2692        assert!(prompt.contains("Phase 1: Analyze"));
2693        assert!(prompt.contains("Phase 2: Extract"));
2694        assert!(prompt.contains("Phase 3: Build Token Hierarchy"));
2695        assert!(prompt.contains("Phase 4: Implement Accessible Components"));
2696        assert!(prompt.contains("Phase 5: Document and Export"));
2697        assert!(prompt.contains("OKLCH"));
2698        assert!(prompt.contains("WCAG"));
2699    }
2700
2701    // ── Display trait tests ──
2702
2703    #[test]
2704    fn test_token_category_display() {
2705        assert_eq!(format!("{}", TokenCategory::Color), "color");
2706        assert_eq!(format!("{}", TokenCategory::Spacing), "spacing");
2707        assert_eq!(format!("{}", TokenCategory::Typography), "typography");
2708        assert_eq!(format!("{}", TokenCategory::Radius), "radius");
2709        assert_eq!(format!("{}", TokenCategory::Shadow), "shadow");
2710        assert_eq!(format!("{}", TokenCategory::Opacity), "opacity");
2711        assert_eq!(format!("{}", TokenCategory::Breakpoint), "breakpoint");
2712        assert_eq!(format!("{}", TokenCategory::ZIndex), "z-index");
2713        assert_eq!(format!("{}", TokenCategory::Motion), "motion");
2714        assert_eq!(format!("{}", TokenCategory::Custom), "custom");
2715    }
2716
2717    #[test]
2718    fn test_wcag_level_display() {
2719        assert_eq!(format!("{}", WcagLevel::None), "none");
2720        assert_eq!(format!("{}", WcagLevel::A), "A");
2721        assert_eq!(format!("{}", WcagLevel::AA), "AA");
2722        assert_eq!(format!("{}", WcagLevel::AAA), "AAA");
2723    }
2724
2725    #[test]
2726    fn test_design_framework_display() {
2727        assert_eq!(format!("{}", DesignFramework::Tailwind), "Tailwind CSS");
2728        assert_eq!(format!("{}", DesignFramework::CssModules), "CSS Modules");
2729        assert_eq!(format!("{}", DesignFramework::CssInJs), "CSS-in-JS");
2730        assert_eq!(format!("{}", DesignFramework::Vanilla), "Vanilla CSS");
2731        assert_eq!(format!("{}", DesignFramework::Unknown), "Unknown");
2732    }
2733
2734    #[test]
2735    fn test_design_file_type_display() {
2736        assert_eq!(format!("{}", DesignFileType::Stylesheet), "stylesheet");
2737        assert_eq!(format!("{}", DesignFileType::TailwindConfig), "tailwind-config");
2738        assert_eq!(format!("{}", DesignFileType::Component), "component");
2739    }
2740
2741    // ── Default trait tests ──
2742
2743    #[test]
2744    fn test_design_farmer_default() {
2745        let _farmer = DesignFarmer::default();
2746    }
2747
2748    #[test]
2749    fn test_design_farmer_debug() {
2750        let farmer = DesignFarmer::new();
2751        let debug = format!("{:?}", farmer);
2752        assert!(debug.contains("DesignFarmer"));
2753    }
2754}