1const TOKENS_JSON: &str = include_str!("tokens.json");
14
15#[derive(Debug, Clone, PartialEq, Eq)]
17pub struct TokenEntry {
18 pub name: String,
20 pub value: String,
23 pub dark_value: Option<String>,
26 pub type_hint: Option<String>,
28 pub description: Option<String>,
30}
31
32#[derive(Debug, Clone, PartialEq, Eq)]
34pub struct TokenGroup {
35 pub name: String,
37 pub description: Option<String>,
39 pub entries: Vec<TokenEntry>,
41}
42
43#[derive(Debug, Clone)]
45pub struct Tokens {
46 pub groups: Vec<TokenGroup>,
48}
49
50pub fn load_tokens() -> Result<Tokens, String> {
57 parse_tokens(TOKENS_JSON)
58}
59
60pub fn parse_tokens(input: &str) -> Result<Tokens, String> {
63 let value: serde_json::Value = serde_json::from_str(input)
64 .map_err(|e| format!("tokens.json parse error: {}", e))?;
65 let top = value.as_object().ok_or("tokens.json must be a JSON object")?;
66
67 let order: Vec<String> = top
69 .get("$order")
70 .and_then(|v| v.as_array())
71 .ok_or("tokens.json missing top-level `$order` array")?
72 .iter()
73 .filter_map(|v| v.as_str().map(String::from))
74 .collect();
75
76 let mut groups = Vec::with_capacity(order.len());
77
78 for group_name in &order {
79 let group_value = top
80 .get(group_name)
81 .ok_or_else(|| format!("`$order` lists '{}' but group is missing", group_name))?;
82 let group_obj = group_value
83 .as_object()
84 .ok_or_else(|| format!("group '{}' must be an object", group_name))?;
85
86 let mut description = None;
87 let mut entries = Vec::new();
88
89 for (entry_key, entry_value) in group_obj {
90 if entry_key == "$description" {
91 description = entry_value.as_str().map(|s| s.to_string());
92 continue;
93 }
94 if entry_key.starts_with('$') {
95 continue;
96 }
97 let entry_obj = entry_value
98 .as_object()
99 .ok_or_else(|| format!("entry '{}/{}' must be an object", group_name, entry_key))?;
100
101 let type_hint = entry_obj.get("$type").and_then(|v| v.as_str()).map(String::from);
102 let raw_value = entry_obj
103 .get("$value")
104 .ok_or_else(|| format!("entry '{}/{}' missing $value", group_name, entry_key))?;
105 let (entry_value_str, entry_dark_value) = match raw_value {
106 serde_json::Value::String(s) => (s.clone(), None),
107 serde_json::Value::Object(obj) => {
108 let light = obj
109 .get("light")
110 .and_then(|v| v.as_str())
111 .ok_or_else(|| format!("entry '{}/{}' $value object missing \"light\" key", group_name, entry_key))?
112 .to_string();
113 let dark = obj.get("dark").and_then(|v| v.as_str()).map(String::from);
114 (light, dark)
115 }
116 _ => return Err(format!(
117 "entry '{}/{}' $value must be a string or {{\"light\",\"dark\"}} object",
118 group_name, entry_key
119 )),
120 };
121 let entry_description = entry_obj.get("$description").and_then(|v| v.as_str()).map(String::from);
122
123 entries.push(TokenEntry {
124 name: entry_key.clone(),
125 value: entry_value_str,
126 dark_value: entry_dark_value,
127 type_hint,
128 description: entry_description,
129 });
130 }
131
132 entries.sort_by(|a, b| a.name.cmp(&b.name));
134
135 groups.push(TokenGroup {
136 name: group_name.clone(),
137 description,
138 entries,
139 });
140 }
141
142 Ok(Tokens { groups })
143}
144
145pub fn format_root_block(tokens: &Tokens) -> String {
154 let mut out = String::new();
155 out.push_str(":root {\n");
156
157 for (idx, group) in tokens.groups.iter().enumerate() {
158 if idx > 0 {
159 out.push('\n');
160 }
161 let title = title_case(&group.name);
163 out.push_str(&format!(" /* {} */\n", title));
164
165 for entry in &group.entries {
166 let value = normalize_value(&entry.value, entry.type_hint.as_deref());
167 out.push_str(&format!(" --{}: {};\n", entry.name, value));
168 }
169 }
170
171 out.push_str("}\n");
172 out
173}
174
175pub fn format_dark_root_block(tokens: &Tokens) -> String {
185 let has_dark = tokens
187 .groups
188 .iter()
189 .any(|g| g.entries.iter().any(|e| e.dark_value.is_some()));
190 if !has_dark {
191 return String::new();
192 }
193
194 let mut out = String::new();
195 out.push_str("[data-theme=\"dark\"] {\n");
196
197 let mut first_group = true;
198 for group in &tokens.groups {
199 let dark_entries: Vec<&TokenEntry> = group
201 .entries
202 .iter()
203 .filter(|e| e.dark_value.is_some())
204 .collect();
205 if dark_entries.is_empty() {
206 continue;
207 }
208
209 if !first_group {
210 out.push('\n');
211 }
212 first_group = false;
213
214 let title = title_case(&group.name);
215 out.push_str(&format!(" /* {} */\n", title));
216
217 for entry in dark_entries {
218 let Some(dark_val) = entry.dark_value.as_deref() else {
222 continue;
223 };
224 let value = normalize_value(dark_val, entry.type_hint.as_deref());
225 out.push_str(&format!(" --{}: {};\n", entry.name, value));
226 }
227 }
228
229 out.push_str("}\n");
230 out
231}
232
233pub fn format_dark_media_block(tokens: &Tokens) -> String {
251 let has_dark = tokens
253 .groups
254 .iter()
255 .any(|g| g.entries.iter().any(|e| e.dark_value.is_some()));
256 if !has_dark {
257 return String::new();
258 }
259
260 let mut out = String::new();
261 out.push_str("@media (prefers-color-scheme: dark) {\n");
262 out.push_str(":root:not([data-theme]) {\n");
263
264 let mut first_group = true;
265 for group in &tokens.groups {
266 let dark_entries: Vec<&TokenEntry> = group
267 .entries
268 .iter()
269 .filter(|e| e.dark_value.is_some())
270 .collect();
271 if dark_entries.is_empty() {
272 continue;
273 }
274
275 if !first_group {
276 out.push('\n');
277 }
278 first_group = false;
279
280 let title = title_case(&group.name);
281 out.push_str(&format!(" /* {} */\n", title));
282
283 for entry in dark_entries {
284 let Some(dark_val) = entry.dark_value.as_deref() else {
288 continue;
289 };
290 let value = normalize_value(dark_val, entry.type_hint.as_deref());
291 out.push_str(&format!(" --{}: {};\n", entry.name, value));
292 }
293 }
294
295 out.push_str("}\n");
296 out.push_str("}\n");
297 out
298}
299
300pub fn find_token<'a>(tokens: &'a Tokens, name: &str) -> Option<(&'a str, Option<&'a str>)> {
308 tokens
309 .groups
310 .iter()
311 .flat_map(|g| &g.entries)
312 .find(|e| e.name == name)
313 .map(|e| (e.value.as_str(), e.dark_value.as_deref()))
314}
315
316pub fn bg_colors(tokens: &Tokens) -> (&str, &str) {
320 match find_token(tokens, "moss-color-bg") {
321 Some((light, Some(dark))) => (light, dark),
322 Some((light, None)) => (light, "#1c1914"),
323 None => ("#faf8f5", "#1c1914"),
324 }
325}
326
327fn title_case(s: &str) -> String {
329 let mut chars = s.chars();
330 match chars.next() {
331 None => String::new(),
332 Some(c) => c.to_uppercase().chain(chars).collect(),
333 }
334}
335
336fn normalize_value(value: &str, type_hint: Option<&str>) -> String {
338 if matches!(type_hint, Some("color")) {
339 return normalize_hex_color(value);
340 }
341 value.to_string()
342}
343
344fn normalize_hex_color(value: &str) -> String {
347 let trimmed = value.trim();
348 if let Some(rest) = trimmed.strip_prefix('#') {
349 if rest.chars().all(|c| c.is_ascii_hexdigit())
350 && (rest.len() == 3 || rest.len() == 6 || rest.len() == 8)
351 {
352 let lower = rest.to_lowercase();
353 if lower.len() == 3 {
355 let mut digits = lower.chars();
356 if let (Some(r), Some(g), Some(b)) =
357 (digits.next(), digits.next(), digits.next())
358 {
359 return format!("#{r}{r}{g}{g}{b}{b}");
360 }
361 }
362 return format!("#{}", lower);
363 }
364 }
365 value.to_string()
366}
367
368#[cfg(test)]
369mod tests {
370 use super::*;
371
372 #[test]
373 fn format_dark_root_block_emits_only_dark_tokens() {
374 let json = r##"{ "$order": ["color"], "color": {
375 "moss-color-bg": {"$type":"color","$value":{"light":"#faf8f5","dark":"#1c1914"}},
376 "moss-color-accent": {"$type":"color","$value":"#2d5a2d"} } }"##;
377 let t = parse_tokens(json).unwrap();
378 let dark = format_dark_root_block(&t);
379 assert!(dark.contains("[data-theme=\"dark\"]"), "must use [data-theme=\"dark\"] selector (no :root prefix)");
381 assert!(!dark.contains(":root[data-theme=\"dark\"]"), "must NOT use :root prefix (vestigial specificity hack removed)");
382 assert!(dark.contains("--moss-color-bg: #1c1914"));
383 assert!(!dark.contains("--moss-color-accent")); }
385
386 #[test]
387 fn format_dark_root_block_returns_empty_when_no_dark_values() {
388 let json = r##"{ "$order": ["color"], "color": {
389 "moss-color-accent": {"$type":"color","$value":"#2d5a2d"},
390 "moss-color-bg": {"$type":"color","$value":"#faf8f5"} } }"##;
391 let t = parse_tokens(json).unwrap();
392 assert_eq!(format_dark_root_block(&t), "");
393 }
394
395 #[test]
396 fn parse_tokens_accepts_object_value_with_dark() {
397 let json = r##"{ "$order": ["color"], "color": { "moss-color-bg": {
398 "$type": "color",
399 "$value": { "light": "#faf8f5", "dark": "#1c1914" },
400 "$description": "Page background" } } }"##;
401 let tokens = parse_tokens(json).expect("parses");
402 let bg = tokens.groups.iter().flat_map(|g| &g.entries)
403 .find(|t| t.name == "moss-color-bg").expect("bg token");
404 assert_eq!(bg.value, "#faf8f5");
405 assert_eq!(bg.dark_value.as_deref(), Some("#1c1914"));
406 }
407
408 #[test]
409 fn parse_tokens_string_value_has_no_dark() {
410 let json = r##"{ "$order": ["color"], "color": { "moss-color-accent": {
411 "$type": "color", "$value": "#2d5a2d", "$description": "Accent" } } }"##;
412 let t = parse_tokens(json).unwrap();
413 let a = t.groups.iter().flat_map(|g| &g.entries).find(|t| t.name == "moss-color-accent").unwrap();
414 assert_eq!(a.value, "#2d5a2d");
415 assert_eq!(a.dark_value, None);
416 }
417
418 #[test]
419 fn tokens_json_includes_internal_tokens() {
420 let t = load_tokens().unwrap();
421 let names: Vec<_> = t.groups.iter().flat_map(|g| &g.entries).map(|t| t.name.as_str()).collect();
422 for n in [
424 "moss-color-text-secondary", "moss-color-accent-hover", "moss-border-light",
428 "moss-border-medium",
429 "moss-code-background",
430 "moss-code-border",
431 "moss-code-accent-primary",
432 "moss-code-accent-secondary",
433 "moss-code-accent-tertiary",
434 "moss-code-accent-quaternary",
435 "moss-hl-keyword",
436 "moss-hl-string",
437 "moss-hl-comment",
438 "moss-hl-number",
439 "moss-hl-function",
440 "moss-hl-type",
441 "moss-hl-tag",
442 "moss-hl-attr",
443 "moss-hl-operator",
444 "moss-hl-builtin",
445 "moss-hl-meta",
446 "moss-hl-deletion",
447 "moss-hl-addition-bg",
448 "moss-hl-deletion-bg",
449 "moss-size-2xs", "moss-size-xs", "moss-size-sm", "moss-size-md", "moss-size-lg", "moss-size-xl", "moss-size-2xl", "moss-size-3xl", "moss-font-weight-body", "moss-font-heading-weight",
461 ] {
462 assert!(names.contains(&n), "missing token: {n}");
463 }
464 for old in [
466 "moss-text-secondary",
467 "moss-accent-hover",
468 "moss-font-2xs",
469 "moss-font-xs",
470 "moss-font-sm",
471 "moss-font-lg",
472 "moss-font-xl",
473 "moss-font-2xl",
474 "moss-font-3xl",
475 "moss-font-weight",
476 ] {
477 assert!(!names.contains(&old), "old token still present: {old}");
478 }
479 assert!(names.contains(&"moss-color-ui-accent"), "missing token: moss-color-ui-accent");
480 assert!(names.len() >= 47, "expected >=47 tokens (added moss-color-ui-accent), got {}", names.len());
481 }
482
483 #[test]
484 fn format_dark_media_block_wraps_dark_tokens_in_media_query() {
485 let json = r##"{ "$order": ["color"], "color": {
486 "moss-color-bg": {"$type":"color","$value":{"light":"#faf8f5","dark":"#1c1914"}},
487 "moss-color-accent": {"$type":"color","$value":"#2d5a2d"} } }"##;
488 let t = parse_tokens(json).unwrap();
489 let media = format_dark_media_block(&t);
490 assert!(media.contains("@media (prefers-color-scheme: dark)"), "must be wrapped in @media");
491 assert!(media.contains(":root:not([data-theme])"), "must target :root:not([data-theme])");
492 assert!(media.contains("--moss-color-bg: #1c1914"), "must contain dark value");
493 assert!(!media.contains("--moss-color-accent"), "light-only token must not appear");
494 }
495
496 #[test]
497 fn format_dark_media_block_returns_empty_when_no_dark_values() {
498 let json = r##"{ "$order": ["color"], "color": {
499 "moss-color-accent": {"$type":"color","$value":"#2d5a2d"} } }"##;
500 let t = parse_tokens(json).unwrap();
501 assert_eq!(format_dark_media_block(&t), "");
502 }
503
504 #[test]
507 fn accent_hover_is_derived_from_accent_via_color_mix() {
508 let t = load_tokens().unwrap();
509 let entry = t
510 .groups
511 .iter()
512 .flat_map(|g| &g.entries)
513 .find(|e| e.name == "moss-color-accent-hover")
514 .expect("moss-color-accent-hover token must exist (renamed from moss-accent-hover in 1.3)");
515
516 assert!(
518 entry.value.contains("color-mix"),
519 "moss-color-accent-hover light value must contain 'color-mix', got: {:?}",
520 entry.value
521 );
522 assert!(
523 entry.value.contains("var(--moss-color-accent)"),
524 "moss-color-accent-hover light value must reference var(--moss-color-accent), got: {:?}",
525 entry.value
526 );
527
528 let dark = entry
530 .dark_value
531 .as_deref()
532 .expect("moss-color-accent-hover must have a dark value");
533 assert!(
534 dark.contains("color-mix"),
535 "moss-color-accent-hover dark value must contain 'color-mix', got: {:?}",
536 dark
537 );
538 assert!(
539 dark.contains("var(--moss-color-accent)"),
540 "moss-color-accent-hover dark value must reference var(--moss-color-accent), got: {:?}",
541 dark
542 );
543 }
544
545 #[test]
554 fn accent_has_legible_dark_value() {
555 let t = load_tokens().unwrap();
556 let accent = t
557 .groups
558 .iter()
559 .flat_map(|g| &g.entries)
560 .find(|e| e.name == "moss-color-accent")
561 .expect("moss-color-accent token must exist");
562 assert_eq!(accent.value, "#2d5a2d", "light accent must stay unchanged");
563 assert_eq!(
564 accent.dark_value.as_deref(),
565 Some("#6a9a5a"),
566 "moss-color-accent needs a dark override legible on #1c1914 (WCAG AA); \
567 raw #2d5a2d is only ~2.2:1"
568 );
569 let dark = format_dark_root_block(&t);
571 assert!(
572 dark.contains("--moss-color-accent: #6a9a5a"),
573 "dark block must set --moss-color-accent to the legible green"
574 );
575 }
576
577 #[test]
583 fn code_accent_primary_dark_is_pinned_not_derived_from_accent() {
584 let t = load_tokens().unwrap();
585 let code = t
586 .groups
587 .iter()
588 .flat_map(|g| &g.entries)
589 .find(|e| e.name == "moss-code-accent-primary")
590 .expect("moss-code-accent-primary token must exist");
591 let dark = code
592 .dark_value
593 .as_deref()
594 .expect("moss-code-accent-primary must have a dark value");
595 assert!(
596 !dark.contains("var(--moss-color-accent)"),
597 "code-accent-primary dark must not re-derive from accent (would \
598 double-lighten); pin it to a concrete value instead, got: {dark:?}"
599 );
600 }
601}