1use std::{
2 collections::BTreeMap,
3 fs,
4 path::{Path, PathBuf},
5 str::FromStr,
6};
7
8use chromasync_color::{
9 ColorError, contrast_score, meets_contrast_threshold, resolve_color_from_components,
10 resolve_family_color, select_readable_color, select_readable_color_with_strategy,
11};
12use chromasync_types::{
13 ContrastStrategy, GeneratedPalette, HexColor, PaletteFamilyName, SemanticTokenName,
14 SemanticTokens, TemplateDefinition, TemplateTokenRule, ThemeMode, ThemePack,
15};
16use directories::ProjectDirs;
17use serde::Deserialize;
18use thiserror::Error;
19
20const BUILTIN_TEMPLATES: [(&str, &str); 8] = [
21 (
22 "minimal-dark.toml",
23 include_str!("../templates/minimal-dark.toml"),
24 ),
25 (
26 "minimal-light.toml",
27 include_str!("../templates/minimal-light.toml"),
28 ),
29 (
30 "brutalist-dark.toml",
31 include_str!("../templates/brutalist-dark.toml"),
32 ),
33 (
34 "brutalist-light.toml",
35 include_str!("../templates/brutalist-light.toml"),
36 ),
37 (
38 "terminal-dark.toml",
39 include_str!("../templates/terminal-dark.toml"),
40 ),
41 (
42 "terminal-light.toml",
43 include_str!("../templates/terminal-light.toml"),
44 ),
45 (
46 "materialish-dark.toml",
47 include_str!("../templates/materialish-dark.toml"),
48 ),
49 (
50 "materialish-light.toml",
51 include_str!("../templates/materialish-light.toml"),
52 ),
53];
54
55#[derive(Debug, Clone, PartialEq, Eq)]
56pub enum TemplateSource {
57 BuiltIn(&'static str),
58 Filesystem(PathBuf),
59 UserConfig(PathBuf),
60 Pack { pack: String, path: PathBuf },
61}
62
63impl TemplateSource {
64 pub fn label(&self) -> &'static str {
65 match self {
66 Self::BuiltIn(_) => "built-in",
67 Self::Filesystem(_) => "filesystem",
68 Self::UserConfig(_) => "user-config",
69 Self::Pack { .. } => "pack",
70 }
71 }
72
73 pub fn location(&self) -> String {
74 match self {
75 Self::BuiltIn(file_name) => (*file_name).to_owned(),
76 Self::Filesystem(path) | Self::UserConfig(path) | Self::Pack { path, .. } => {
77 path.display().to_string()
78 }
79 }
80 }
81
82 pub fn pack_name(&self) -> Option<&str> {
83 match self {
84 Self::Pack { pack, .. } => Some(pack.as_str()),
85 _ => None,
86 }
87 }
88
89 const fn precedence(&self) -> u8 {
90 match self {
91 Self::BuiltIn(_) => 0,
92 Self::Pack { .. } => 1,
93 Self::UserConfig(_) => 2,
94 Self::Filesystem(_) => 3,
95 }
96 }
97}
98
99#[derive(Debug, Clone, PartialEq)]
100pub struct ListedTemplate {
101 pub definition: TemplateDefinition,
102 pub source: TemplateSource,
103}
104
105#[derive(Debug, Deserialize)]
106#[serde(deny_unknown_fields)]
107struct RawTemplateDefinition {
108 name: String,
109 mode: ThemeMode,
110 description: Option<String>,
111 #[serde(default)]
112 tokens: BTreeMap<String, RawTemplateTokenRule>,
113}
114
115#[derive(Debug, Clone, Deserialize)]
116#[serde(deny_unknown_fields)]
117struct RawTemplateTokenRule {
118 family: String,
119 tone: f32,
120 chroma: Option<f32>,
121 chroma_scale: Option<f32>,
122}
123
124#[derive(Debug, Error)]
125pub enum TemplateError {
126 #[error("failed to parse template '{name}' from {source}: {error}")]
127 Parse {
128 name: String,
129 source: String,
130 #[source]
131 error: Box<toml::de::Error>,
132 },
133 #[error("failed to read templates directory '{path}': {source}")]
134 ReadDir {
135 path: PathBuf,
136 #[source]
137 source: std::io::Error,
138 },
139 #[error("failed to read template file '{path}': {source}")]
140 ReadFile {
141 path: PathBuf,
142 #[source]
143 source: std::io::Error,
144 },
145 #[error("template '{requested}' was not found")]
146 TemplateNotFound { requested: String },
147 #[error(
148 "template '{name}' ({mode}) is defined multiple times: first at {first_source}, second at {second_source}"
149 )]
150 DuplicateTemplateName {
151 name: String,
152 mode: ThemeMode,
153 first_source: String,
154 second_source: String,
155 },
156 #[error("template '{template}' uses an unknown token '{token}'")]
157 UnknownTokenName { template: String, token: String },
158 #[error("template '{template}' token '{token}' uses an unknown family '{family}'")]
159 UnknownFamily {
160 template: String,
161 token: String,
162 family: String,
163 },
164 #[error("template '{template}' is missing required tokens: {missing}")]
165 MissingRequiredTokens { template: String, missing: String },
166 #[error("template '{template}' token '{token}' uses invalid tone {tone}; expected 0.0..=1.0")]
167 InvalidTone {
168 template: String,
169 token: String,
170 tone: f32,
171 },
172 #[error(
173 "template '{template}' token '{token}' uses invalid chroma {chroma}; expected a finite value >= 0.0"
174 )]
175 InvalidChroma {
176 template: String,
177 token: String,
178 chroma: f32,
179 },
180 #[error(
181 "template '{template}' token '{token}' uses invalid chroma_scale {chroma_scale}; expected a finite value >= 0.0"
182 )]
183 InvalidChromaScale {
184 template: String,
185 token: String,
186 chroma_scale: f32,
187 },
188 #[error("palette is missing family '{family}' required by the template")]
189 MissingPaletteFamily { family: PaletteFamilyName },
190 #[error(transparent)]
191 Color(#[from] ColorError),
192}
193
194pub fn built_in_templates() -> Result<Vec<ListedTemplate>, TemplateError> {
195 BUILTIN_TEMPLATES
196 .iter()
197 .map(|(file_name, content)| {
198 let definition =
199 parse_template(file_name, content, format!("built-in template {file_name}"))?;
200 Ok(ListedTemplate {
201 definition,
202 source: TemplateSource::BuiltIn(file_name),
203 })
204 })
205 .collect()
206}
207
208pub fn user_templates_dir() -> Option<PathBuf> {
209 ProjectDirs::from("io", "chromasync", "chromasync")
210 .map(|dirs| dirs.config_dir().join("templates"))
211}
212
213pub fn template_from_file(path: &Path, user_config: bool) -> Result<ListedTemplate, TemplateError> {
214 let source = if user_config {
215 TemplateSource::UserConfig(path.to_path_buf())
216 } else {
217 TemplateSource::Filesystem(path.to_path_buf())
218 };
219
220 template_from_file_with_source(path, source)
221}
222
223fn template_from_file_with_source(
224 path: &Path,
225 source: TemplateSource,
226) -> Result<ListedTemplate, TemplateError> {
227 let content = fs::read_to_string(path).map_err(|source| TemplateError::ReadFile {
228 path: path.to_path_buf(),
229 source,
230 })?;
231 let definition = parse_template(
232 path.file_name()
233 .and_then(|name| name.to_str())
234 .unwrap_or("template.toml"),
235 &content,
236 path.display().to_string(),
237 )?;
238
239 Ok(ListedTemplate { definition, source })
240}
241
242pub fn templates_from_dir(
243 path: &Path,
244 user_config: bool,
245) -> Result<Vec<ListedTemplate>, TemplateError> {
246 let source = if user_config {
247 TemplateSource::UserConfig(path.to_path_buf())
248 } else {
249 TemplateSource::Filesystem(path.to_path_buf())
250 };
251
252 templates_from_dir_with_source(path, &source)
253}
254
255fn templates_from_dir_with_source(
256 path: &Path,
257 source: &TemplateSource,
258) -> Result<Vec<ListedTemplate>, TemplateError> {
259 if !path.exists() {
260 return Ok(Vec::new());
261 }
262
263 let mut templates = Vec::new();
264
265 for entry in fs::read_dir(path).map_err(|source| TemplateError::ReadDir {
266 path: path.to_path_buf(),
267 source,
268 })? {
269 let entry = entry.map_err(|source| TemplateError::ReadDir {
270 path: path.to_path_buf(),
271 source,
272 })?;
273 let entry_path = entry.path();
274
275 if entry_path
276 .extension()
277 .and_then(|extension| extension.to_str())
278 != Some("toml")
279 {
280 continue;
281 }
282
283 let source = match source {
284 TemplateSource::Pack { pack, .. } => TemplateSource::Pack {
285 pack: pack.clone(),
286 path: entry_path.clone(),
287 },
288 TemplateSource::UserConfig(_) => TemplateSource::UserConfig(entry_path.clone()),
289 TemplateSource::Filesystem(_) => TemplateSource::Filesystem(entry_path.clone()),
290 TemplateSource::BuiltIn(_) => TemplateSource::Filesystem(entry_path.clone()),
291 };
292 templates.push(template_from_file_with_source(&entry_path, source)?);
293 }
294
295 templates.sort_by(|left, right| left.definition.name.cmp(&right.definition.name));
296
297 Ok(templates)
298}
299
300pub fn list_templates() -> Result<Vec<ListedTemplate>, TemplateError> {
301 list_templates_with_packs(&[])
302}
303
304pub fn list_templates_with_packs(
305 packs: &[ThemePack],
306) -> Result<Vec<ListedTemplate>, TemplateError> {
307 let mut templates = built_in_templates()?;
308
309 if let Some(path) = user_templates_dir() {
310 templates.extend(templates_from_dir(&path, true)?);
311 }
312
313 for pack in packs {
314 for path in &pack.template_dirs {
315 templates.extend(templates_from_dir_with_source(
316 path,
317 &TemplateSource::Pack {
318 pack: pack.name.clone(),
319 path: path.clone(),
320 },
321 )?);
322 }
323 }
324
325 templates.sort_by(|left, right| {
326 left.definition
327 .name
328 .cmp(&right.definition.name)
329 .then(left.source.precedence().cmp(&right.source.precedence()))
330 .then(left.source.location().cmp(&right.source.location()))
331 });
332 validate_pack_template_collisions(&templates)?;
333
334 Ok(templates)
335}
336
337pub fn load_template(requested: &str, mode: ThemeMode) -> Result<ListedTemplate, TemplateError> {
338 load_template_with_packs(requested, mode, &[])
339}
340
341pub fn load_template_with_packs(
342 requested: &str,
343 mode: ThemeMode,
344 packs: &[ThemePack],
345) -> Result<ListedTemplate, TemplateError> {
346 if looks_like_path(requested) || Path::new(requested).exists() {
347 return template_from_file(Path::new(requested), false);
348 }
349
350 list_templates_with_packs(packs)?
351 .into_iter()
352 .filter(|template| template.definition.name == requested)
353 .max_by_key(|template| {
354 (
355 u8::from(template.definition.mode == mode),
356 template.source.precedence(),
357 )
358 })
359 .ok_or_else(|| TemplateError::TemplateNotFound {
360 requested: requested.to_owned(),
361 })
362}
363
364pub fn pack_templates(pack: &ThemePack) -> Result<Vec<ListedTemplate>, TemplateError> {
365 let mut templates = Vec::new();
366
367 for path in &pack.template_dirs {
368 templates.extend(templates_from_dir_with_source(
369 path,
370 &TemplateSource::Pack {
371 pack: pack.name.clone(),
372 path: path.clone(),
373 },
374 )?);
375 }
376
377 templates.sort_by(|left, right| {
378 left.definition
379 .name
380 .cmp(&right.definition.name)
381 .then(
382 left.definition
383 .mode
384 .as_str()
385 .cmp(right.definition.mode.as_str()),
386 )
387 .then(left.source.location().cmp(&right.source.location()))
388 });
389 validate_pack_template_collisions(&templates)?;
390
391 Ok(templates)
392}
393
394fn validate_pack_template_collisions(templates: &[ListedTemplate]) -> Result<(), TemplateError> {
395 let mut seen: BTreeMap<(String, String), &ListedTemplate> = BTreeMap::new();
396
397 for template in templates {
398 let key = (
399 template.definition.name.clone(),
400 template.definition.mode.as_str().to_owned(),
401 );
402
403 if let Some(previous) = seen.get(&key) {
404 if previous.source.pack_name().is_some() || template.source.pack_name().is_some() {
405 return Err(TemplateError::DuplicateTemplateName {
406 name: template.definition.name.clone(),
407 mode: template.definition.mode,
408 first_source: previous.source.location(),
409 second_source: template.source.location(),
410 });
411 }
412 } else {
413 seen.insert(key, template);
414 }
415 }
416
417 Ok(())
418}
419
420pub fn resolve_tokens(
421 palette: &GeneratedPalette,
422 template: &TemplateDefinition,
423) -> Result<SemanticTokens, TemplateError> {
424 resolve_tokens_with_strategy(palette, template, ContrastStrategy::RelativeLuminance)
425}
426
427pub fn resolve_tokens_with_strategy(
428 palette: &GeneratedPalette,
429 template: &TemplateDefinition,
430 contrast_strategy: ContrastStrategy,
431) -> Result<SemanticTokens, TemplateError> {
432 let mut resolved = BTreeMap::new();
433
434 for token_name in SemanticTokenName::ALL {
435 let rule = template
436 .tokens
437 .get(&token_name)
438 .expect("template validation should guarantee the canonical token set");
439 let family =
440 palette
441 .families
442 .get(&rule.family)
443 .ok_or(TemplateError::MissingPaletteFamily {
444 family: rule.family,
445 })?;
446 let chroma = rule.chroma.unwrap_or(family.base_chroma) * rule.chroma_scale.unwrap_or(1.0);
447 let color = resolve_color_from_components(family.hue, chroma, rule.tone)?;
448
449 resolved.insert(token_name, color);
450 }
451
452 let neutral = palette.families.get(&PaletteFamilyName::Neutral).ok_or(
453 TemplateError::MissingPaletteFamily {
454 family: PaletteFamilyName::Neutral,
455 },
456 )?;
457 let light_fallback = resolve_family_color(neutral, 0.98)?;
458 let dark_fallback = resolve_family_color(neutral, 0.06)?;
459 let bg = resolved
460 .get(&SemanticTokenName::Bg)
461 .cloned()
462 .expect("bg token should be present");
463 let accent = resolved
464 .get(&SemanticTokenName::Accent)
465 .cloned()
466 .expect("accent token should be present");
467 let text = ensure_preferred_readable(
468 resolved
469 .get(&SemanticTokenName::Text)
470 .expect("text token should be present"),
471 &bg,
472 &[light_fallback.clone(), dark_fallback.clone()],
473 contrast_strategy,
474 )?;
475
476 let mut accent_fg_fallbacks = vec![text.clone()];
477 if let Some(primary) = palette.families.get(&PaletteFamilyName::Primary) {
478 if let Ok(primary_light) = resolve_family_color(primary, 0.95) {
479 accent_fg_fallbacks.push(primary_light);
480 }
481 if let Ok(primary_dark) = resolve_family_color(primary, 0.10) {
482 accent_fg_fallbacks.push(primary_dark);
483 }
484 }
485 accent_fg_fallbacks.push(light_fallback);
486 accent_fg_fallbacks.push(dark_fallback);
487
488 let accent_fg = ensure_preferred_readable(
489 resolved
490 .get(&SemanticTokenName::AccentFg)
491 .expect("accent_fg token should be present"),
492 &accent,
493 &accent_fg_fallbacks,
494 contrast_strategy,
495 )?;
496
497 resolved.insert(SemanticTokenName::Text, text);
498 resolved.insert(SemanticTokenName::AccentFg, accent_fg);
499
500 Ok(semantic_tokens_from_map(&resolved))
501}
502
503fn parse_template(
504 name: &str,
505 content: &str,
506 source: String,
507) -> Result<TemplateDefinition, TemplateError> {
508 let raw: RawTemplateDefinition =
509 toml::from_str(content).map_err(|error| TemplateError::Parse {
510 name: name.to_owned(),
511 source,
512 error: Box::new(error),
513 })?;
514
515 validate_template(raw)
516}
517
518fn validate_template(raw: RawTemplateDefinition) -> Result<TemplateDefinition, TemplateError> {
519 let template_name = raw.name.clone();
520 let mut tokens = BTreeMap::new();
521
522 for (token_name, raw_rule) in raw.tokens {
523 let token = SemanticTokenName::from_str(&token_name).map_err(|_| {
524 TemplateError::UnknownTokenName {
525 template: template_name.clone(),
526 token: token_name.clone(),
527 }
528 })?;
529 let family = PaletteFamilyName::from_str(&raw_rule.family).map_err(|_| {
530 TemplateError::UnknownFamily {
531 template: template_name.clone(),
532 token: token_name.clone(),
533 family: raw_rule.family.clone(),
534 }
535 })?;
536
537 validate_tone(&template_name, &token_name, raw_rule.tone)?;
538
539 if let Some(chroma) = raw_rule.chroma {
540 validate_chroma(&template_name, &token_name, chroma)?;
541 }
542
543 if let Some(chroma_scale) = raw_rule.chroma_scale {
544 validate_chroma_scale(&template_name, &token_name, chroma_scale)?;
545 }
546
547 tokens.insert(
548 token,
549 TemplateTokenRule {
550 family,
551 tone: raw_rule.tone,
552 chroma: raw_rule.chroma,
553 chroma_scale: raw_rule.chroma_scale,
554 },
555 );
556 }
557
558 let missing = SemanticTokenName::ALL
559 .into_iter()
560 .filter(|token| !tokens.contains_key(token))
561 .map(SemanticTokenName::as_str)
562 .collect::<Vec<_>>();
563
564 if !missing.is_empty() {
565 return Err(TemplateError::MissingRequiredTokens {
566 template: template_name,
567 missing: missing.join(", "),
568 });
569 }
570
571 Ok(TemplateDefinition {
572 name: raw.name,
573 mode: raw.mode,
574 description: raw.description,
575 tokens,
576 })
577}
578
579fn validate_tone(template: &str, token: &str, tone: f32) -> Result<(), TemplateError> {
580 if tone.is_finite() && (0.0..=1.0).contains(&tone) {
581 Ok(())
582 } else {
583 Err(TemplateError::InvalidTone {
584 template: template.to_owned(),
585 token: token.to_owned(),
586 tone,
587 })
588 }
589}
590
591fn validate_chroma(template: &str, token: &str, chroma: f32) -> Result<(), TemplateError> {
592 if chroma.is_finite() && chroma >= 0.0 {
593 Ok(())
594 } else {
595 Err(TemplateError::InvalidChroma {
596 template: template.to_owned(),
597 token: token.to_owned(),
598 chroma,
599 })
600 }
601}
602
603fn validate_chroma_scale(
604 template: &str,
605 token: &str,
606 chroma_scale: f32,
607) -> Result<(), TemplateError> {
608 if chroma_scale.is_finite() && chroma_scale >= 0.0 {
609 Ok(())
610 } else {
611 Err(TemplateError::InvalidChromaScale {
612 template: template.to_owned(),
613 token: token.to_owned(),
614 chroma_scale,
615 })
616 }
617}
618
619fn ensure_preferred_readable(
620 preferred: &str,
621 background: &str,
622 fallbacks: &[HexColor],
623 contrast_strategy: ContrastStrategy,
624) -> Result<HexColor, TemplateError> {
625 let preferred_score = contrast_score(preferred, background, contrast_strategy)?;
626
627 if meets_contrast_threshold(preferred_score, contrast_strategy) {
628 return Ok(preferred.to_owned());
629 }
630
631 let mut candidates = Vec::with_capacity(fallbacks.len() + 1);
632 candidates.push(preferred.to_owned());
633
634 for fallback in fallbacks {
635 if !candidates.iter().any(|candidate| candidate == fallback) {
636 candidates.push(fallback.clone());
637 }
638 }
639
640 let selection = match contrast_strategy {
641 ContrastStrategy::RelativeLuminance => select_readable_color(background, &candidates)?,
642 ContrastStrategy::ApcaExperimental => {
643 select_readable_color_with_strategy(background, &candidates, contrast_strategy)?
644 }
645 };
646
647 Ok(selection.hex)
648}
649
650fn semantic_tokens_from_map(tokens: &BTreeMap<SemanticTokenName, HexColor>) -> SemanticTokens {
651 SemanticTokens {
652 bg: token(tokens, SemanticTokenName::Bg),
653 bg_secondary: token(tokens, SemanticTokenName::BgSecondary),
654 surface: token(tokens, SemanticTokenName::Surface),
655 surface_elevated: token(tokens, SemanticTokenName::SurfaceElevated),
656 text: token(tokens, SemanticTokenName::Text),
657 text_muted: token(tokens, SemanticTokenName::TextMuted),
658 border: token(tokens, SemanticTokenName::Border),
659 border_strong: token(tokens, SemanticTokenName::BorderStrong),
660 accent: token(tokens, SemanticTokenName::Accent),
661 accent_hover: token(tokens, SemanticTokenName::AccentHover),
662 accent_active: token(tokens, SemanticTokenName::AccentActive),
663 accent_fg: token(tokens, SemanticTokenName::AccentFg),
664 selection: token(tokens, SemanticTokenName::Selection),
665 link: token(tokens, SemanticTokenName::Link),
666 success: token(tokens, SemanticTokenName::Success),
667 warning: token(tokens, SemanticTokenName::Warning),
668 error: token(tokens, SemanticTokenName::Error),
669 }
670}
671
672fn token(tokens: &BTreeMap<SemanticTokenName, HexColor>, name: SemanticTokenName) -> HexColor {
673 tokens
674 .get(&name)
675 .cloned()
676 .expect("resolved tokens should include the full canonical token set")
677}
678
679fn looks_like_path(value: &str) -> bool {
680 let path = Path::new(value);
681
682 path.is_absolute()
683 || value.contains(std::path::MAIN_SEPARATOR)
684 || path.extension().and_then(|extension| extension.to_str()) == Some("toml")
685}
686
687#[cfg(test)]
688mod tests {
689 use std::{
690 fs,
691 path::PathBuf,
692 time::{SystemTime, UNIX_EPOCH},
693 };
694
695 use chromasync_color::{contrast_ratio, generate_palette};
696
697 use super::{
698 TemplateError, TemplateSource, built_in_templates, load_template, parse_template,
699 resolve_tokens,
700 };
701 use chromasync_types::{ChromaStrategy, PaletteFamilyName, SemanticTokenName, ThemeMode};
702
703 #[test]
704 fn built_in_templates_parse_and_define_all_canonical_tokens() {
705 let templates = built_in_templates().expect("built-in templates should parse");
706
707 assert_eq!(templates.len(), 8);
708 assert!(
709 templates
710 .iter()
711 .any(|template| template.definition.name == "minimal")
712 );
713 assert_eq!(
714 templates
715 .iter()
716 .filter(|template| template.definition.name == "minimal")
717 .count(),
718 2
719 );
720 assert!(
721 templates
722 .iter()
723 .all(|template| template.definition.tokens.len() == SemanticTokenName::ALL.len())
724 );
725 }
726
727 #[test]
728 fn load_template_by_name_uses_embedded_assets() {
729 let template = load_template("minimal", ThemeMode::Dark).expect("template should load");
730
731 assert_eq!(template.definition.name, "minimal");
732 assert_eq!(template.definition.mode, ThemeMode::Dark);
733 assert!(matches!(template.source, TemplateSource::BuiltIn(_)));
734 }
735
736 #[test]
737 fn load_template_by_name_prefers_requested_mode() {
738 let template = load_template("minimal", ThemeMode::Light).expect("template should load");
739
740 assert_eq!(template.definition.name, "minimal");
741 assert_eq!(template.definition.mode, ThemeMode::Light);
742 assert!(matches!(template.source, TemplateSource::BuiltIn(_)));
743 }
744
745 #[test]
746 fn load_template_by_path_uses_filesystem_source() {
747 let path = temp_file_path("template-path");
748 fs::write(&path, include_str!("../templates/minimal-dark.toml"))
749 .expect("temp template should be written");
750
751 let template = load_template(
752 path.to_str().expect("path should be utf-8"),
753 ThemeMode::Dark,
754 )
755 .expect("template should load from path");
756
757 assert!(matches!(template.source, TemplateSource::Filesystem(_)));
758
759 fs::remove_file(path).expect("temp template should be removed");
760 }
761
762 #[test]
763 fn built_in_templates_resolve_to_semantic_tokens() {
764 let palette = generate_palette("#4ecdc4", ThemeMode::Light, ChromaStrategy::Normal)
765 .expect("palette should build");
766 let template =
767 load_template("materialish", ThemeMode::Light).expect("template should load");
768
769 let tokens = resolve_tokens(&palette, &template.definition)
770 .expect("template should resolve to semantic tokens");
771
772 assert!(tokens.bg.starts_with('#'));
773 assert!(tokens.accent.starts_with('#'));
774 assert!(tokens.text.starts_with('#'));
775 assert!(
776 contrast_ratio(&tokens.text, &tokens.bg).expect("contrast should compute")
777 >= chromasync_color::MIN_CONTRAST_RATIO
778 );
779 assert!(
780 contrast_ratio(&tokens.accent_fg, &tokens.accent).expect("contrast should compute")
781 >= chromasync_color::MIN_CONTRAST_RATIO
782 );
783 }
784
785 #[test]
786 fn invalid_templates_report_missing_required_tokens() {
787 let error = parse_template(
788 "broken.toml",
789 r#"
790name = "broken"
791mode = "dark"
792
793[tokens.bg]
794family = "neutral"
795tone = 0.1
796"#,
797 "test fixture".to_owned(),
798 )
799 .expect_err("template should fail validation");
800
801 assert!(matches!(error, TemplateError::MissingRequiredTokens { .. }));
802 }
803
804 #[test]
805 fn invalid_templates_report_unknown_families() {
806 let error = parse_template(
807 "broken.toml",
808 r#"
809name = "broken"
810mode = "dark"
811
812[tokens.bg]
813family = "bogus"
814tone = 0.1
815
816[tokens.bg_secondary]
817family = "neutral"
818tone = 0.14
819
820[tokens.surface]
821family = "neutral"
822tone = 0.16
823
824[tokens.surface_elevated]
825family = "neutral"
826tone = 0.18
827
828[tokens.text]
829family = "neutral"
830tone = 0.94
831
832[tokens.text_muted]
833family = "neutral_variant"
834tone = 0.8
835
836[tokens.border]
837family = "neutral_variant"
838tone = 0.24
839
840[tokens.border_strong]
841family = "neutral_variant"
842tone = 0.34
843
844[tokens.accent]
845family = "primary"
846tone = 0.7
847
848[tokens.accent_hover]
849family = "primary"
850tone = 0.76
851
852[tokens.accent_active]
853family = "primary"
854tone = 0.64
855
856[tokens.accent_fg]
857family = "neutral"
858tone = 0.98
859
860[tokens.selection]
861family = "primary"
862tone = 0.32
863
864[tokens.link]
865family = "info"
866tone = 0.74
867
868[tokens.success]
869family = "success"
870tone = 0.72
871
872[tokens.warning]
873family = "warning"
874tone = 0.74
875
876[tokens.error]
877family = "error"
878tone = 0.72
879"#,
880 "test fixture".to_owned(),
881 )
882 .expect_err("template should fail validation");
883
884 assert!(matches!(error, TemplateError::UnknownFamily { .. }));
885 }
886
887 #[test]
888 fn resolved_templates_stay_bound_to_palette_families() {
889 let palette = generate_palette("#ff6b6b", ThemeMode::Light, ChromaStrategy::Normal)
890 .expect("palette should build");
891 let template = load_template("minimal", ThemeMode::Light).expect("template should load");
892
893 let tokens = resolve_tokens(&palette, &template.definition)
894 .expect("template should resolve to semantic tokens");
895
896 assert!(palette.families.contains_key(&PaletteFamilyName::Primary));
897 assert_ne!(tokens.bg, tokens.accent);
898 }
899
900 fn temp_file_path(label: &str) -> PathBuf {
901 let unique = SystemTime::now()
902 .duration_since(UNIX_EPOCH)
903 .expect("system time should be valid")
904 .as_nanos();
905
906 std::env::temp_dir().join(format!(
907 "chromasync-template-{label}-{}-{unique}.toml",
908 std::process::id()
909 ))
910 }
911}