1use 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#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
33pub struct OklchColor {
34 pub l: f64,
36 pub c: f64,
38 pub h: f64,
40 #[serde(default = "default_alpha")]
42 pub alpha: f64,
43}
44
45fn default_alpha() -> f64 {
46 1.0
47}
48
49impl OklchColor {
50 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 pub fn with_alpha(mut self, alpha: f64) -> Self {
62 self.alpha = alpha.clamp(0.0, 1.0);
63 self
64 }
65
66 pub fn black() -> Self {
68 Self::new(0.0, 0.0, 0.0)
69 }
70
71 pub fn white() -> Self {
73 Self::new(1.0, 0.0, 0.0)
74 }
75
76 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 pub fn contrast_ratio(&self, other: &OklchColor) -> f64 {
102 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 pub fn is_aa_compliant(&self, background: &OklchColor) -> bool {
112 self.contrast_ratio(background) >= 4.5
113 }
114
115 pub fn is_aaa_compliant(&self, background: &OklchColor) -> bool {
117 self.contrast_ratio(background) >= 7.0
118 }
119
120 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
139fn oklch_l_to_luminance(l: f64) -> f64 {
146 l * l * l
151}
152
153#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
157#[serde(rename_all = "snake_case")]
158pub enum TokenCategory {
159 Color,
161 Spacing,
163 Typography,
165 Radius,
167 Shadow,
169 Opacity,
171 Breakpoint,
173 ZIndex,
175 Motion,
177 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#[derive(Debug, Clone, Serialize, Deserialize)]
200pub struct DesignToken {
201 pub name: String,
203 #[serde(skip_serializing_if = "Option::is_none")]
205 pub description: Option<String>,
206 pub category: TokenCategory,
208 pub value: String,
210 #[serde(skip_serializing_if = "Option::is_none")]
212 pub oklch: Option<OklchColor>,
213 #[serde(skip_serializing_if = "Option::is_none")]
216 pub alias: Option<String>,
217 pub group: Vec<String>,
219}
220
221impl DesignToken {
222 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 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 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, value: String::new(),
260 oklch: None,
261 alias: Some(target.into()),
262 group,
263 }
264 }
265
266 pub fn with_description(mut self, desc: impl Into<String>) -> Self {
268 self.description = Some(desc.into());
269 self
270 }
271}
272
273#[derive(Debug, Clone, Serialize, Deserialize)]
277pub struct TokenGroup {
278 pub name: String,
280 #[serde(skip_serializing_if = "Option::is_none")]
282 pub description: Option<String>,
283 pub tokens: Vec<DesignToken>,
285 pub children: Vec<TokenGroup>,
287}
288
289impl TokenGroup {
290 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 pub fn with_description(mut self, desc: impl Into<String>) -> Self {
302 self.description = Some(desc.into());
303 self
304 }
305
306 pub fn add_token(&mut self, token: DesignToken) {
308 self.tokens.push(token);
309 }
310
311 pub fn add_child(&mut self, group: TokenGroup) {
313 self.children.push(group);
314 }
315
316 pub fn child(&self, name: &str) -> Option<&TokenGroup> {
318 self.children.iter().find(|c| c.name == name)
319 }
320
321 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 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 pub fn token_count(&self) -> usize {
343 self.tokens.len() + self.children.iter().map(|c| c.token_count()).sum::<usize>()
344 }
345}
346
347#[derive(Debug, Clone, Serialize, Deserialize)]
351pub struct ColorPalette {
352 pub name: String,
354 pub hue: f64,
356 pub chroma: f64,
358 pub stops: Vec<PaletteStop>,
360}
361
362#[derive(Debug, Clone, Serialize, Deserialize)]
364pub struct PaletteStop {
365 pub step: u16,
367 pub color: OklchColor,
369 pub token_name: String,
371}
372
373impl ColorPalette {
374 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 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 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 pub fn get_stop(&self, step: u16) -> Option<&PaletteStop> {
429 self.stops.iter().find(|s| s.step == step)
430 }
431
432 pub fn mid(&self) -> &OklchColor {
434 &self.get_stop(500).expect("palette always has step 500").color
435 }
436
437 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
457#[serde(rename_all = "snake_case")]
458pub enum WcagLevel {
459 None,
461 A,
463 AA,
465 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#[derive(Debug, Clone, Serialize, Deserialize)]
482pub struct ContrastCheck {
483 pub foreground_token: String,
485 pub background_token: String,
487 pub foreground: OklchColor,
489 pub background: OklchColor,
491 pub ratio: f64,
493 pub aa_pass: bool,
495 pub aaa_pass: bool,
497}
498
499impl ContrastCheck {
500 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 pub fn passes(&self, level: WcagLevel) -> bool {
521 match level {
522 WcagLevel::None => true,
523 WcagLevel::A => true, WcagLevel::AA => self.aa_pass,
525 WcagLevel::AAA => self.aaa_pass,
526 }
527 }
528}
529
530#[derive(Debug, Clone, Serialize, Deserialize)]
532pub struct ComponentSpec {
533 pub name: String,
535 pub description: String,
537 pub variants: Vec<ComponentVariant>,
539 pub a11y_notes: Vec<String>,
541 pub wcag_level: WcagLevel,
543 pub contrast_checks: Vec<ContrastCheck>,
545 pub aria_requirements: Vec<String>,
547 pub keyboard_requirements: Vec<String>,
549}
550
551#[derive(Debug, Clone, Serialize, Deserialize)]
553pub struct ComponentVariant {
554 pub name: String,
556 pub token_refs: Vec<(String, String)>,
558 #[serde(default)]
560 pub extra_styles: Vec<(String, String)>,
561}
562
563impl ComponentSpec {
564 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 pub fn with_wcag_level(mut self, level: WcagLevel) -> Self {
580 self.wcag_level = level;
581 self
582 }
583
584 pub fn add_variant(&mut self, variant: ComponentVariant) {
586 self.variants.push(variant);
587 }
588
589 pub fn add_a11y_note(&mut self, note: impl Into<String>) {
591 self.a11y_notes.push(note.into());
592 }
593
594 pub fn add_aria(&mut self, requirement: impl Into<String>) {
596 self.aria_requirements.push(requirement.into());
597 }
598
599 pub fn add_keyboard(&mut self, requirement: impl Into<String>) {
601 self.keyboard_requirements.push(requirement.into());
602 }
603
604 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 pub fn is_accessible(&self) -> bool {
619 self.contrast_checks.iter().all(|c| c.passes(self.wcag_level))
620 }
621
622 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#[derive(Debug, Clone, Serialize, Deserialize)]
635pub struct DesignAnalysis {
636 pub project_root: String,
638 pub design_files: Vec<DesignFile>,
640 pub framework: DesignFramework,
642 pub patterns: Vec<DesignPattern>,
644 pub summary: String,
646}
647
648#[derive(Debug, Clone, Serialize, Deserialize)]
650pub struct DesignFile {
651 pub path: String,
653 pub file_type: DesignFileType,
655 pub description: String,
657}
658
659#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
661#[serde(rename_all = "snake_case")]
662pub enum DesignFileType {
663 Stylesheet,
665 TailwindConfig,
667 ThemeConfig,
669 TokenFile,
671 Component,
673 Story,
675 DesignExport,
677 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
698#[serde(rename_all = "snake_case")]
699pub enum DesignFramework {
700 Tailwind,
702 CssModules,
704 CssInJs,
706 Vanilla,
708 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#[derive(Debug, Clone, Serialize, Deserialize)]
726pub struct DesignPattern {
727 pub name: String,
729 pub category: PatternCategory,
731 pub source: String,
733 pub description: String,
735}
736
737#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
739#[serde(rename_all = "snake_case")]
740pub enum PatternCategory {
741 Color,
743 Layout,
745 Spacing,
747 Typography,
749 Composition,
751 Motion,
753 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#[derive(Debug, Clone, Serialize, Deserialize)]
775pub struct DesignSystem {
776 pub name: String,
778 pub created_at: String,
780 pub version: String,
782 pub tokens: TokenGroup,
784 pub palettes: Vec<ColorPalette>,
786 pub components: Vec<ComponentSpec>,
788 #[serde(skip_serializing_if = "Option::is_none")]
790 pub analysis: Option<DesignAnalysis>,
791 pub decisions: Vec<DesignDecision>,
793}
794
795#[derive(Debug, Clone, Serialize, Deserialize)]
797pub struct DesignDecision {
798 pub title: String,
800 pub decision: String,
802 pub rationale: String,
804 #[serde(default)]
806 pub alternatives: Vec<String>,
807}
808
809impl DesignSystem {
810 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 pub fn total_tokens(&self) -> usize {
826 self.tokens.token_count()
827 }
828
829 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 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 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 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 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 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 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 md.push_str("## Token Hierarchy\n\n");
958 Self::render_token_group_md(&self.tokens, &mut md, 0);
959 md.push('\n');
960
961 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 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 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 fn render_token_group_md(group: &TokenGroup, md: &mut String, depth: usize) {
1058 let heading = "#".repeat(depth.min(4) + 3); 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 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 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#[derive(Debug, Clone, Serialize, Deserialize)]
1121pub struct AccessibilityReport {
1122 pub all_pass: bool,
1124 pub total_checks: usize,
1126 pub passing_count: usize,
1128 pub failing_count: usize,
1130 pub min_ratio: f64,
1132 pub passing: Vec<ContrastCheck>,
1134 pub failing: Vec<ContrastCheck>,
1136}
1137
1138pub struct DesignFarmer;
1148
1149impl DesignFarmer {
1150 pub fn new() -> Self {
1152 Self
1153 }
1154
1155 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 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 framework = DesignFramework::Vanilla;
1180 }
1181
1182 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 Self::walk_for_design_files(dir, "", 0, 5, &mut design_files, &mut patterns)?;
1201
1202 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 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 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 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 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 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; }
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 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 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 system.analysis = analysis;
1387
1388 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 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 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 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 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 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 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 let neutral_palette = ColorPalette::neutral("neutral");
1433 color_group.add_child(neutral_palette.to_token_group());
1434 system.palettes.push(neutral_palette);
1435
1436 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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#[cfg(test)]
1986mod tests {
1987 use super::*;
1988 use std::fs;
1989
1990 #[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("/")); }
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 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 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 assert!((scale[0].l - 0.5).abs() < f64::EPSILON);
2091 }
2092
2093 #[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 #[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); }
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 #[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); 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 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 #[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 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 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 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 #[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()); }
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 #[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 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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}