Cookbook

Worked recipes - light and dark themes, wiring DtcgTokens into an app, caching, hot reload, CSS variables, deprecation reports, CI checks and editor features.

Recipes for common tasks. Each is a complete program built from the API described in the concept pages; the loading recipes for disk, HTTP and embedded assets are in Loading documents.

#Light and dark themes from one resolver

Model the theme as a modifier. The base set holds the palette and semantic tokens; the dark context overrides only the palette entries that differ. Map your UI’s theme enum onto a selection.

use dxn_dtcg::{DtcgError, DtcgRuntime};

#[derive(Clone, Copy, PartialEq, Eq, Hash)]
enum Theme {
    Light,
    Dark,
}

impl Theme {
    fn selection(self) -> [(&'static str, &'static str); 1] {
        [("theme", if self == Theme::Dark { "dark" } else { "light" })]
    }
}

const RESOLVER: &str = r##"{
  "version": "2025.10",
  "sets": { "base": { "sources": [{ "$ref": "base.json" }] } },
  "modifiers": { "theme": { "default": "light", "contexts": {
    "light": [],
    "dark": [{ "$ref": "dark.json" }]
  } } },
  "resolutionOrder": [{ "$ref": "#/sets/base" }, { "$ref": "#/modifiers/theme" }]
}"##;

const BASE: &str = r#"{
  "palette": {
    "$type": "color",
    "paper": { "$value": { "colorSpace": "srgb", "components": [1, 1, 1] } },
    "ink": { "$value": { "colorSpace": "srgb", "components": [0.1, 0.1, 0.12] } }
  },
  "surface": {
    "$type": "color",
    "background": { "$value": "{palette.paper}" },
    "text": { "$value": "{palette.ink}" }
  }
}"#;

// Dark swaps the palette values; `surface.*` follows because aliases resolve last.
const DARK: &str = r#"{
  "palette": {
    "paper": { "$value": { "colorSpace": "srgb", "components": [0.1, 0.1, 0.12] } },
    "ink": { "$value": { "colorSpace": "srgb", "components": [1, 1, 1] } }
  }
}"#;

fn main() -> Result<(), DtcgError> {
    let mut runtime = DtcgRuntime::from_json("theme.resolver.json", RESOLVER)?;
    runtime.provide_json_batch([("base.json", BASE), ("dark.json", DARK)])?;

    let light = runtime.resolve(&Theme::Light.selection())?.ready().unwrap();
    let dark = runtime.resolve(&Theme::Dark.selection())?.ready().unwrap();

    assert_eq!(light.get("surface.background").unwrap().value["components"][0], 1);
    assert_eq!(dark.get("surface.background").unwrap().value["components"][0], 0.1);
    Ok(())
}

If the user can pick from a list, build the picker from runtime.modifiers() (names, contexts, default) instead of hard-coding the enum.

#Wire DtcgTokens into an app

Project once per theme into a struct your widgets read, and share it as Arc. A small store loads the runtime once and builds each theme’s struct on first use.

use std::{collections::HashMap, sync::Arc};

use dxn_dtcg::values::{Color, Dimension};
use dxn_dtcg::{DtcgError, DtcgRuntime, DtcgTokens};

#[derive(Debug, DtcgTokens)]
struct ButtonTokens {
    background: Color,
    #[dtcg(rename = "paddingX")]
    padding_x: Dimension,
}

#[derive(Debug, DtcgTokens)]
struct AppTheme {
    #[dtcg(nested)]
    button: ButtonTokens,
    #[dtcg(path = "surface.background")]
    background: Color,
    /// Not every theme defines a focus ring width.
    #[dtcg(path = "focus.width")]
    focus_width: Option<Dimension>,
}

struct ThemeStore {
    runtime: DtcgRuntime,
    themes: HashMap<String, Arc<AppTheme>>,
}

impl ThemeStore {
    fn new(runtime: DtcgRuntime) -> Self {
        Self { runtime, themes: HashMap::new() }
    }

    /// The projected theme for a theme name; built on first use.
    fn theme(&mut self, name: &str) -> Result<Arc<AppTheme>, DtcgError> {
        if let Some(theme) = self.themes.get(name) {
            return Ok(Arc::clone(theme));
        }
        let theme = self
            .runtime
            .project::<AppTheme>(&[("theme", name)])?
            .ready()
            .expect("all documents are provided");
        let theme = Arc::new(theme);
        self.themes.insert(name.to_owned(), Arc::clone(&theme));
        Ok(theme)
    }
}

fn main() -> Result<(), DtcgError> {
    let mut runtime = DtcgRuntime::from_json(
        "app.resolver.json",
        r##"{
          "version": "2025.10",
          "modifiers": { "theme": { "default": "light", "contexts": {
            "light": [{ "surface": { "background": { "$type": "color",
              "$value": { "colorSpace": "srgb", "components": [1, 1, 1] } } } }],
            "dark": [{ "surface": { "background": { "$type": "color",
              "$value": { "colorSpace": "srgb", "components": [0, 0, 0] } } } }]
          } } },
          "resolutionOrder": [
            { "type": "set", "name": "components", "sources": [{
              "button": {
                "background": { "$type": "color", "$value": "{surface.background}" },
                "paddingX": { "$type": "dimension", "$value": { "value": 12, "unit": "px" } }
              }
            }] },
            { "$ref": "#/modifiers/theme" }
          ]
        }"##,
    )?;
    runtime.set_root("app.resolver.json")?;

    let mut store = ThemeStore::new(runtime);
    let dark = store.theme("dark")?;
    assert_eq!(dark.button.background.components[0], dxn_dtcg::values::Component::Number(0.0));
    assert!(dark.focus_width.is_none());
    assert!(Arc::ptr_eq(&dark, &store.theme("dark")?)); // cached
    Ok(())
}

Notice that button.background aliases surface.background, which only exists in the theme contexts: the alias follows whichever context is selected. When a renderer type is not FromDtcgValue, convert with #[dtcg(with = "...")] in your own crate, as shown in Typed projection.

#Cache resolutions per theme

resolve recomputes every time. The catalog revision plus the normalized selection identify a result exactly, so they make a precise cache key. normalize_selection collapses ("Theme", "DARK") and ("theme", "dark") and omitted defaults into one key.

use std::{collections::HashMap, sync::Arc};

use dxn_dtcg::{DtcgError, DtcgRuntime, Resolution, ResolvedTokens};

#[derive(Default)]
struct TokenCache {
    revision: u64,
    entries: HashMap<Vec<(String, String)>, Arc<ResolvedTokens>>,
}

impl TokenCache {
    fn resolve(
        &mut self,
        runtime: &DtcgRuntime,
        selection: &[(&str, &str)],
    ) -> Result<Resolution<Arc<ResolvedTokens>>, DtcgError> {
        if runtime.catalog_revision() != self.revision {
            self.entries.clear();
            self.revision = runtime.catalog_revision();
        }
        let key = match runtime.normalize_selection(selection)? {
            Resolution::Ready(key) => key,
            Resolution::Missing(requests) => return Ok(Resolution::Missing(requests)),
        };
        if let Some(tokens) = self.entries.get(&key) {
            return Ok(Resolution::Ready(Arc::clone(tokens)));
        }
        Ok(match runtime.resolve(selection)? {
            Resolution::Ready(tokens) => {
                let tokens = Arc::new(tokens);
                self.entries.insert(key, Arc::clone(&tokens));
                Resolution::Ready(tokens)
            }
            Resolution::Missing(requests) => Resolution::Missing(requests),
        })
    }
}

fn main() -> Result<(), DtcgError> {
    let runtime = DtcgRuntime::from_json("t.json", r#"{ "a": { "$type": "number", "$value": 1 } }"#)?;
    let mut cache = TokenCache::default();
    let first = cache.resolve(&runtime, &[])?.ready().unwrap();
    let second = cache.resolve(&runtime, &[])?.ready().unwrap();
    assert!(Arc::ptr_eq(&first, &second));
    Ok(())
}

To decide whether to re-render after a reload, compare fingerprint() instead: it is equal when no value, type, winning source or token order changed.

#Read the theme from a settings file

Settings and URL handlers usually carry inputs as JSON. selection_from_json accepts an object of strings and reports anything else as InvalidInput; selection_refs borrows the pairs.

use dxn_dtcg::{DtcgError, DtcgRuntime, selection_from_json, selection_refs};

fn main() -> Result<(), DtcgError> {
    let runtime = DtcgRuntime::from_json(
        "r.json",
        r##"{
          "version": "2025.10",
          "modifiers": { "theme": { "default": "light", "contexts": {
            "light": [{ "bg": { "$type": "number", "$value": 0 } }],
            "dark": [{ "bg": { "$type": "number", "$value": 1 } }]
          } } },
          "resolutionOrder": [{ "$ref": "#/modifiers/theme" }]
        }"##,
    )?;

    let settings: serde_json::Value = serde_json::from_str(r#"{ "theme": "Dark" }"#).unwrap();
    let inputs = selection_from_json(&settings)?;
    let tokens = runtime.resolve(&selection_refs(&inputs))?.ready().unwrap();
    assert_eq!(tokens.get("bg").unwrap().value, 1);

    // A stale setting names a context that no longer exists: the error lists the allowed ones.
    let stale = selection_from_json(&serde_json::json!({ "theme": "sepia" }))?;
    let error = runtime.resolve(&selection_refs(&stale)).unwrap_err();
    assert!(error.to_string().ends_with("allowed contexts: light, dark"));
    Ok(())
}

#Generate CSS custom properties

ResolvedToken::typed gives every value a shape you can format. This turns the colors, dimensions and numbers of a theme into a :root block; other types are skipped.

use dxn_dtcg::values::{ColorSpace, Component, DimensionUnit, TypedValue};
use dxn_dtcg::{DtcgError, DtcgRuntime, ResolvedTokens, ValueExtensions};

fn css(tokens: &ResolvedTokens, extensions: &ValueExtensions) -> String {
    let mut out = String::from(":root {\n");
    for token in tokens.iter() {
        let value = match token.typed(extensions) {
            Ok(TypedValue::Color(color)) if color.color_space == ColorSpace::Srgb => {
                let channel = |component: Component| match component {
                    Component::Number(n) => (n * 255.0).round() as u8,
                    Component::None => 0,
                };
                let [r, g, b] = color.components.map(channel);
                format!("rgb({r} {g} {b} / {})", color.alpha)
            }
            Ok(TypedValue::Dimension(dimension)) => match dimension.unit {
                DimensionUnit::Px => format!("{}px", dimension.value),
                DimensionUnit::Rem => format!("{}rem", dimension.value),
            },
            Ok(TypedValue::Number(number)) => number.to_string(),
            _ => continue,
        };
        out.push_str(&format!("  --{}: {value};\n", token.path.replace('.', "-")));
    }
    out.push_str("}\n");
    out
}

fn main() -> Result<(), DtcgError> {
    let runtime = DtcgRuntime::from_json(
        "tokens.json",
        r##"{
          "color": { "$type": "color", "brand": { "$value": { "colorSpace": "srgb", "components": [0.2, 0.4, 0.9] } } },
          "space": { "$type": "dimension", "md": { "$value": { "value": 1, "unit": "rem" } } }
        }"##,
    )?;
    let tokens = runtime.resolve(&[])?.ready().unwrap();
    let stylesheet = css(&tokens, &runtime.options().extensions);
    assert_eq!(
        stylesheet,
        ":root {\n  --color-brand: rgb(51 102 230 / 1);\n  --space-md: 1rem;\n}\n"
    );
    Ok(())
}

The recipe skips colors in other spaces. To emit them as sRGB, convert first with color.convert(ColorSpace::Srgb), or format color.to_hex(); see Color conversions.

#Reload a token file

When a file changes on disk, replace it in the catalog and compare fingerprints to see whether anything the UI shows actually changed. replace_json fails for an unknown id and for malformed JSON, and leaves the catalog untouched in both cases, so a half-saved file never breaks the running theme.

use dxn_dtcg::{DtcgError, DtcgRuntime, ResolvedTokens};

/// Returns the new tokens when the reload changed them.
fn reload(
    runtime: &mut DtcgRuntime,
    selection: &[(&str, &str)],
    id: &str,
    text: String,
    current: &ResolvedTokens,
) -> Result<Option<ResolvedTokens>, DtcgError> {
    runtime.replace_json(id, text)?;
    let next = runtime.resolve(selection)?.ready().expect("same documents as before");
    Ok((next.fingerprint() != current.fingerprint()).then_some(next))
}

fn main() -> Result<(), DtcgError> {
    let mut runtime = DtcgRuntime::from_json("t.json", r#"{ "gap": { "$type": "number", "$value": 4 } }"#)?;
    let current = runtime.resolve(&[])?.ready().unwrap();

    // Saved without a change to values: nothing to repaint.
    let same = r#"{ "gap": { "$type": "number", "$value": 4, "$description": "Spacing" } }"#;
    assert!(reload(&mut runtime, &[], "t.json", same.into(), &current)?.is_none());

    // Truncated save: rejected, previous document stays.
    let broken = reload(&mut runtime, &[], "t.json", r#"{ "gap": "#.into(), &current);
    assert!(matches!(broken, Err(DtcgError::InvalidJson { .. })));

    let changed = r#"{ "gap": { "$type": "number", "$value": 8 } }"#;
    let next = reload(&mut runtime, &[], "t.json", changed.into(), &current)?.unwrap();
    assert_eq!(next.get("gap").unwrap().value, 8);
    Ok(())
}

If edits come keystroke by keystroke in a large file set, see Incremental resolution. A changed JavaScript or TypeScript module that expressions import is reloaded with replace_module instead, followed by a full resolve.

#Report deprecated tokens

$deprecated is inherited from groups, so a token’s deprecated field already answers “is this deprecated, and why”. List them after resolving, for a lint step or a design-system changelog.

use dxn_dtcg::{DtcgError, DtcgRuntime};

fn main() -> Result<(), DtcgError> {
    let runtime = DtcgRuntime::from_json(
        "tokens.json",
        r##"{
          "opacity": {
            "$type": "number",
            "$deprecated": "Use emphasis.* instead",
            "disabled": { "$value": 0.4 },
            "overlay": { "$value": 0.6, "$deprecated": false }
          },
          "emphasis": { "$type": "number", "low": { "$value": 0.4 } }
        }"##,
    )?;
    let tokens = runtime.resolve(&[])?.ready().unwrap();

    let deprecated: Vec<(&str, Option<&str>)> = tokens
        .iter()
        .filter(|token| token.deprecated.is_deprecated())
        .map(|token| (&*token.path, token.deprecated.reason()))
        .collect();
    assert_eq!(deprecated, [("opacity.disabled", Some("Use emphasis.* instead"))]);
    Ok(())
}

#Validate every theme combination in CI

resolve only checks the selected contexts, so a typo in the dark context goes unnoticed until someone picks it. In a token repository’s CI, resolve the full cartesian product of the root modifiers. validate_complete first makes sure every document is loadable.

use dxn_dtcg::{DtcgError, DtcgRuntime, ModifierAxis};

fn all_selections(axes: &[ModifierAxis]) -> Vec<Vec<(&str, &str)>> {
    let mut selections = vec![Vec::new()];
    for axis in axes {
        selections = selections
            .into_iter()
            .flat_map(|selection| {
                axis.contexts.iter().map(move |context| {
                    let mut next = selection.clone();
                    next.push((axis.name.as_str(), context.as_str()));
                    next
                })
            })
            .collect();
    }
    selections
}

fn main() -> Result<(), DtcgError> {
    let runtime = DtcgRuntime::from_json(
        "r.json",
        r##"{
          "version": "2025.10",
          "sets": { "base": { "sources": [{ "gap": { "$type": "number", "$value": 8 } }] } },
          "modifiers": {
            "theme": { "default": "light", "contexts": {
              "light": [],
              "dark": [{ "gap": { "$type": "number", "$value": "{missing}" } }]
            } },
            "density": { "default": "comfortable", "contexts": { "comfortable": [], "compact": [] } }
          },
          "resolutionOrder": [
            { "$ref": "#/sets/base" }, { "$ref": "#/modifiers/theme" }, { "$ref": "#/modifiers/density" }
          ]
        }"##,
    )?;

    // Structure and documents are fine in every context...
    assert!(runtime.validate_complete()?.ready().is_some());

    // ...but the dark context has a dangling alias.
    let axes = runtime.modifiers()?.ready().unwrap();
    let failing: Vec<_> = all_selections(&axes)
        .into_iter()
        .filter(|selection| runtime.resolve(selection).is_err())
        .collect();
    assert_eq!(
        failing,
        [
            vec![("theme", "dark"), ("density", "comfortable")],
            vec![("theme", "dark"), ("density", "compact")]
        ]
    );
    Ok(())
}

modifiers() lists the root resolver’s axes only. For a graph with nested resolvers, add their modifiers to the combinations, or rely on their defaults. Print each failing selection with its error and exit non-zero from main to fail the job.

#Share one runtime across threads

Resolution takes &self and the runtime is Send + Sync, so readers can run in parallel behind an RwLock; take the write lock only to provide or replace documents.

use std::sync::{Arc, RwLock};
use std::thread;

use dxn_dtcg::{DtcgError, DtcgRuntime};

fn main() -> Result<(), DtcgError> {
    let runtime = Arc::new(RwLock::new(DtcgRuntime::from_json(
        "r.json",
        r##"{
          "version": "2025.10",
          "modifiers": { "theme": { "default": "light", "contexts": {
            "light": [{ "bg": { "$type": "number", "$value": 0 } }],
            "dark": [{ "bg": { "$type": "number", "$value": 1 } }]
          } } },
          "resolutionOrder": [{ "$ref": "#/modifiers/theme" }]
        }"##,
    )?));

    let readers: Vec<_> = ["light", "dark"]
        .into_iter()
        .map(|theme| {
            let runtime = Arc::clone(&runtime);
            thread::spawn(move || {
                let runtime = runtime.read().unwrap();
                let tokens = runtime.resolve(&[("theme", theme)]).unwrap().ready().unwrap();
                tokens.get("bg").unwrap().value.clone()
            })
        })
        .collect();
    let values: Vec<_> = readers.into_iter().map(|reader| reader.join().unwrap()).collect();
    assert_eq!(values, [0, 1]);
    Ok(())
}

#Build an editor feature

Editors and linters for token files combine three groups of APIs, each with its own page.

FeatureAPIsPage
Re-resolve after each edit and patch the shown snapshotreplace_json_and_resolve_affected, apply_affectedIncremental resolution
Show where a token’s source comes from; preview an unsaved set or contextinspect_source, preview_source, preview_occurrence, validate_sourceSource inspection
Rename or move a token file and fix every referencesource_references()Source inspection
Show the authored value next to the resolved oneResolvedToken::authored_value and valueAliases and references
Export the final theme for another toolResolvedTokens::to_document()Token documents
Flag computed tokens whose authored $value is staleResolvedToken::snapshot compared with valueExpressions

Next: Incremental resolution, or Troubleshooting.