Skip to main content

moss_core/contract/
tokens.rs

1//! W3C Design Tokens loader.
2//!
3//! Reads the embedded `tokens.json` (W3C Design Tokens Community Group format)
4//! and exposes it as ordered structs the codegen consumes.
5//!
6//! ## Invariants
7//! - Tokens are loaded at compile time via `include_str!`. moss-core stays zero-I/O.
8//! - Group order is taken from the top-level `$order` array in tokens.json
9//!   (NOT JSON insertion order — serde_json doesn't preserve insertion order
10//!   by default and moss doesn't enable the `preserve_order` feature).
11//! - Within each group, entries are sorted alphabetically.
12
13const TOKENS_JSON: &str = include_str!("tokens.json");
14
15/// A single design token entry.
16#[derive(Debug, Clone, PartialEq, Eq)]
17pub struct TokenEntry {
18    /// CSS variable name without leading `--` (e.g. `moss-color-accent`).
19    pub name: String,
20    /// CSS value as a string (e.g. `#2d5a2d`, `1.125rem`, `var(--moss-content-width)`).
21    /// When `$value` is an object with `"light"` and `"dark"` keys, this holds the light value.
22    pub value: String,
23    /// Dark-mode CSS value. `None` when `$value` is a plain string (light-only token).
24    /// `Some(...)` when `$value` is `{ "light": "...", "dark": "..." }`.
25    pub dark_value: Option<String>,
26    /// Optional W3C `$type` hint (color, dimension, fontFamily, number).
27    pub type_hint: Option<String>,
28    /// Optional human-readable description.
29    pub description: Option<String>,
30}
31
32/// A group of tokens (e.g. `typography`, `color`, `layout`, `spacing`).
33#[derive(Debug, Clone, PartialEq, Eq)]
34pub struct TokenGroup {
35    /// Group name as it appears in `tokens.json` (e.g. `color`).
36    pub name: String,
37    /// Optional group-level description.
38    pub description: Option<String>,
39    /// Token entries, sorted alphabetically.
40    pub entries: Vec<TokenEntry>,
41}
42
43/// The full tokens manifest.
44#[derive(Debug, Clone)]
45pub struct Tokens {
46    /// Groups in declared order (from `$order`).
47    pub groups: Vec<TokenGroup>,
48}
49
50/// Load the embedded `tokens.json` into structured form.
51///
52/// Group order is taken from the top-level `$order` array in tokens.json.
53/// Entries within each group are alphabetical.
54///
55/// Returns an error if the JSON is malformed or `$order` is missing.
56pub fn load_tokens() -> Result<Tokens, String> {
57    parse_tokens(TOKENS_JSON)
58}
59
60/// Parse a tokens.json string. Exposed for testing error paths;
61/// production callers use `load_tokens()`.
62pub 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    // Read group ordering from the explicit `$order` array.
68    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        // Alphabetical within each group.
133        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
145/// Format the loaded tokens as the CSS `:root` block per the v1 formatter
146/// decisions (see spec § Open Question 3):
147/// - Property order: group-then-alphabetical (groups in source order).
148/// - Color casing: lowercase hex.
149/// - Unit normalization: pass-through (tokens.json owns canonical units).
150/// - Comments: blank line + group-name comment between groups.
151/// - Indentation: 2 spaces.
152/// - Trailing semicolons: always.
153pub 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        // Group name is title-cased: "typography" → "Typography".
162        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
175/// Format the tokens whose `dark_value` is set as a CSS `[data-theme="dark"]` block.
176///
177/// The `:root` prefix is intentionally omitted: layer order (tokens layer < themes
178/// layer) guarantees the tokens block loses to any author override in the themes layer,
179/// regardless of selector specificity. Keeping `:root` here would give the block a
180/// spurious specificity bump that conflicts with the layer contract.
181///
182/// Mirrors `format_root_block`'s style (group comments, 2-space indent, trailing
183/// semicolons). Returns an empty `String` if no token has a dark value.
184pub fn format_dark_root_block(tokens: &Tokens) -> String {
185    // Check whether any dark values exist at all.
186    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        // Only include groups that have at least one dark token.
200        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            // `dark_entries` is filtered to entries that have one; skip rather than
219            // panic if that ever stops holding — a missing dark value is a dropped
220            // declaration, not a reason to fail the build.
221            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
233/// Format the dark-value tokens as a system-preference fallback block.
234///
235/// Produces:
236/// ```css
237/// @media (prefers-color-scheme: dark) {
238///   :root:not([data-theme]) {
239///     --moss-color-bg: #1c1914;
240///     ...
241///   }
242/// }
243/// ```
244///
245/// Only applies when NO explicit `data-theme` is set. Once the user or a
246/// script sets any `data-theme`, the explicit `:root[data-theme="dark"]` block
247/// (from `format_dark_root_block`) takes over.
248///
249/// Returns an empty `String` if no token has a dark value.
250pub fn format_dark_media_block(tokens: &Tokens) -> String {
251    // Check whether any dark values exist at all.
252    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            // `dark_entries` is filtered to entries that have one; skip rather than
285            // panic if that ever stops holding — a missing dark value is a dropped
286            // declaration, not a reason to fail the build.
287            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
300/// Look up the light value (and optionally the dark value) of a named token.
301///
302/// Returns `(light_value, dark_value)`. `dark_value` is `None` for single-value
303/// tokens. Returns `None` from the outer `Option` when the token name is not found.
304///
305/// Used at emit time to derive `<meta name="theme-color">` values from tokens.json
306/// rather than hardcoding hex literals that can drift from the CSS.
307pub 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
316/// Convenience: return the `--moss-color-bg` light and dark CSS values baked
317/// into the embedded tokens.json, falling back to hardcoded defaults if the
318/// token is absent (should never happen in production).
319pub 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
327/// Title-case the group name. "typography" → "Typography", "color" → "Color".
328fn 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
336/// Normalize a token value per the v1 formatter rules.
337fn 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
344/// Normalize a hex color to lowercase 6-digit form. Pass through any value
345/// that isn't a recognized hex literal (e.g., `var()`, `rgb()`, named colors).
346fn 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            // Expand 3-digit hex to 6-digit.
354            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        // Task 2.4: vestigial :root prefix dropped — layer order carries the win.
380        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")); // no dark value → not emitted
384    }
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        // Task 1.3: renamed tokens — assert NEW names present, old names absent.
423        for n in [
424            // color tokens (renamed)
425            "moss-color-text-secondary",   // was moss-text-secondary
426            "moss-color-accent-hover",     // was moss-accent-hover
427            "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            // font size scale (renamed)
450            "moss-size-2xs",  // was moss-font-2xs
451            "moss-size-xs",   // was moss-font-xs
452            "moss-size-sm",   // was moss-font-sm
453            "moss-size-md",   // new: equals --moss-reading-size-base
454            "moss-size-lg",   // was moss-font-lg
455            "moss-size-xl",   // was moss-font-xl
456            "moss-size-2xl",  // was moss-font-2xl
457            "moss-size-3xl",  // was moss-font-3xl
458            // font weight (renamed)
459            "moss-font-weight-body",  // was moss-font-weight
460            "moss-font-heading-weight",
461        ] {
462            assert!(names.contains(&n), "missing token: {n}");
463        }
464        // assert OLD names are gone
465        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    /// Task 1.2/1.3: assert moss-color-accent-hover (renamed from moss-accent-hover) is
505    /// derived via color-mix in both modes.
506    #[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        // Light value must use color-mix (derives from --moss-color-accent).
517        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        // Dark value must also be present and use color-mix (lightens accent on hover).
529        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    /// Dark-theme legibility: `--moss-color-accent` must carry a dark override.
546    /// The light forest green (#2d5a2d) is too dark to read as text/icon/border on
547    /// the dark page background (#1c1914) — ~2.2:1, failing WCAG AA (needs 4.5:1).
548    /// This was the root cause of the unreadable comment "回复" button in dark mode.
549    /// The dark value #6a9a5a clears ~5.3:1; it is the same green the codebase
550    /// already derives for accent-hover / code-green in dark
551    /// (color-mix(in oklch, #2d5a2d 80%, white)), so dark mode stays internally
552    /// consistent. Light mode is unchanged.
553    #[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        // The dark value must actually reach the emitted [data-theme="dark"] block.
570        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    /// Ripple guard: now that `--moss-color-accent` is itself lightened in dark,
578    /// `--moss-code-accent-primary` must NOT re-derive from it via color-mix — that
579    /// would double-lighten the syntax green and drift code-block colors. Its dark
580    /// value is pinned to the concrete green so the accent fix is decoupled from
581    /// syntax highlighting.
582    #[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}