Skip to main content

pathland_core/
tokens.rs

1//! Design-token resolution (spec/TOKENS.md) — the reference implementation.
2//!
3//! Renderers own **scheme detection** and **default values**; the resolution
4//! algorithm itself — `dark.*` layer → override → default → parent-fallback →
5//! fallback, plus the generative `space.<N>` family — is protocol-level and is
6//! conformance-tested here (see the "Design-Token Resolution Conformance" table
7//! in `spec/CONFORMANCE.md`).
8//!
9//! This module is pure (`no_std` + `alloc`): it stores no state. A renderer
10//! keeps its own override/default tables and calls [`resolve`] at apply time.
11
12use alloc::collections::BTreeMap;
13use alloc::string::String;
14use alloc::vec::Vec;
15
16/// The effective color scheme a renderer derives from the platform. Never
17/// carried by the protocol.
18#[derive(Debug, Clone, Copy, PartialEq, Eq)]
19pub enum Scheme {
20    Light,
21    Dark,
22}
23
24impl Default for Scheme {
25    fn default() -> Self {
26        Scheme::Light
27    }
28}
29
30/// A resolved token value. Mirrors the wire value types; `Str` is the resolved
31/// form of a `STRING`-valued token (the wire carries an arena ref instead).
32#[derive(Debug, Clone, PartialEq)]
33pub enum TokenValue {
34    Color(u32),
35    F32(f32),
36    U32(u32),
37    U8(u8),
38    I32(i32),
39    Str(String),
40}
41
42impl TokenValue {
43    /// The wire `value_type` for this value.
44    pub fn value_type(&self) -> u8 {
45        match self {
46            TokenValue::Color(_) => crate::value_type::COLOR,
47            TokenValue::F32(_) => crate::value_type::F32,
48            TokenValue::U32(_) => crate::value_type::U32,
49            TokenValue::U8(_) => crate::value_type::U8,
50            TokenValue::I32(_) => crate::value_type::I32,
51            TokenValue::Str(_) => crate::value_type::STRING,
52        }
53    }
54
55    /// Decode a wire `(value_type, value)` into a token value. `STRING` returns
56    /// `None` — the wire carries an arena ref there; resolve it and build
57    /// [`TokenValue::Str`] directly.
58    pub fn from_wire(value_type: u8, value: u32) -> Option<Self> {
59        match value_type {
60            crate::value_type::COLOR => Some(TokenValue::Color(value)),
61            crate::value_type::F32 => Some(TokenValue::F32(f32::from_bits(value))),
62            crate::value_type::U32 => Some(TokenValue::U32(value)),
63            crate::value_type::U8 => Some(TokenValue::U8((value & 0xFF) as u8)),
64            crate::value_type::I32 => Some(TokenValue::I32(value as i32)),
65            _ => None,
66        }
67    }
68
69    /// The value as an `F32`, if this token is numeric.
70    pub fn as_f32(&self) -> Option<f32> {
71        match self {
72            TokenValue::F32(v) => Some(*v),
73            TokenValue::U32(v) => Some(*v as f32),
74            TokenValue::U8(v) => Some(f32::from(*v)),
75            TokenValue::I32(v) => Some(*v as f32),
76            _ => None,
77        }
78    }
79
80    /// The packed wire value (`C` field) for non-string values. `Str` returns
81    /// `None` — the wire carries an arena ref there.
82    pub fn to_bits(&self) -> Option<u32> {
83        match self {
84            TokenValue::Color(v) => Some(*v),
85            TokenValue::F32(v) => Some(v.to_bits()),
86            TokenValue::U32(v) => Some(*v),
87            TokenValue::U8(v) => Some(u32::from(*v)),
88            TokenValue::I32(v) => Some(*v as u32),
89            TokenValue::Str(_) => None,
90        }
91    }
92}
93
94/// The **Tier-1** standard token catalog (spec/TOKENS.md) — the paths every
95/// renderer MUST provide light + dark defaults for. Composite tokens whose
96/// value would be a multi-part CSS shorthand (`button.padding`) are not scalar
97/// token values and are excluded; renderers own such component chrome.
98pub const TIER1_TOKENS: &[&str] = &[
99    // Colors.
100    "color.primary",
101    "color.secondary",
102    "color.background",
103    "color.surface",
104    "color.text.primary",
105    "color.text.secondary",
106    "color.text.onPrimary",
107    "color.border",
108    "color.accent",
109    // Typography.
110    "font.body.size",
111    "font.body.weight",
112    "font.body.family",
113    // Spacing + radii + borders.
114    "space.base",
115    "radius.xs",
116    "radius.sm",
117    "radius.md",
118    "radius.lg",
119    "radius.xl",
120    "radius.full",
121    "border.width.thin",
122    // Shared control core.
123    "control.background",
124    "control.foreground",
125    "control.border",
126    "control.border.width",
127    "control.radius",
128    "control.padding.horizontal",
129    "control.padding.vertical",
130    "control.height",
131    "control.font.size",
132    "control.font.weight",
133    "control.accent",
134    // Buttons.
135    "button.background",
136    "button.foreground",
137    "button.border",
138    "button.radius",
139    "button.height",
140    "button.font.size",
141    "button.font.weight",
142    "button.accent",
143    // Inputs.
144    "input.background",
145    "input.foreground",
146    "input.placeholder",
147    "input.border",
148    "input.radius",
149    "input.font.size",
150    // Toggle / slider / progress / gauge.
151    "toggle.accent",
152    "toggle.track.off",
153    "toggle.track.on",
154    "toggle.thumb",
155    "slider.accent",
156    "slider.track",
157    "slider.thumb",
158    "progress.track",
159    "progress.fill",
160    "gauge.track",
161    "gauge.fill",
162];
163
164/// The four lookup tables a renderer consults, in resolution order:
165/// app overrides (base + `dark.*`) and renderer defaults (base + `dark.*`).
166///
167/// Overrides and defaults are keyed by **full path** in the base maps and by
168/// **bare path** (the `dark.` prefix stripped) in the `dark` maps.
169#[derive(Debug, Clone, Copy)]
170pub struct TokenTables<'a> {
171    pub overrides: &'a BTreeMap<String, TokenValue>,
172    pub dark_overrides: &'a BTreeMap<String, TokenValue>,
173    pub defaults: &'a BTreeMap<String, TokenValue>,
174    pub dark_defaults: &'a BTreeMap<String, TokenValue>,
175}
176
177/// Resolve a token path against the given tables and scheme.
178///
179/// Resolution (spec/TOKENS.md):
180/// 1. The **generative `space.<N>` family** resolves `space.base` (scheme-aware,
181///    with the same fallback chain) and multiplies by the numeric leaf.
182/// 2. Walk the candidate chain — for the dark scheme, `dark.<path>` ancestors
183///    first, then the base ancestors; for light, base ancestors only.
184/// 3. The first **app override** in the chain wins; otherwise the first
185///    **renderer default**; otherwise `None` (the renderer falls back).
186pub fn resolve(
187    path: &str,
188    scheme: Scheme,
189    tables: &TokenTables<'_>,
190) -> Option<TokenValue> {
191    // Generative spacing family: `space.<N>` = `space.base` × N.
192    if let Some(rest) = path.strip_prefix("space.") {
193        if let Ok(n) = rest.parse::<f32>() {
194            if let Some(base) = resolve("space.base", scheme, tables) {
195                if let Some(base) = base.as_f32() {
196                    return Some(TokenValue::F32(base * n));
197                }
198            }
199        }
200    }
201
202    let chain = parent_chain(path);
203
204    // Override pass: the `dark.*` layer first (when the scheme is dark), then
205    // the base layer — each consulted against its own override table.
206    if scheme == Scheme::Dark {
207        for &cand in &chain {
208            if let Some(v) = tables.dark_overrides.get(cand) {
209                return Some(v.clone());
210            }
211        }
212    }
213    for &cand in &chain {
214        if let Some(v) = tables.overrides.get(cand) {
215            return Some(v.clone());
216        }
217    }
218
219    // Default pass: same layer order against the renderer defaults.
220    if scheme == Scheme::Dark {
221        for &cand in &chain {
222            if let Some(v) = tables.dark_defaults.get(cand) {
223                return Some(v.clone());
224            }
225        }
226    }
227    for &cand in &chain {
228        if let Some(v) = tables.defaults.get(cand) {
229            return Some(v.clone());
230        }
231    }
232    None
233}
234
235/// Resolve a `DESIGN_TOKEN` property **reference** (spec/TOKENS.md).
236///
237/// A reference whose path starts with `dark.` is an **explicit dark-variant
238/// reference**: the prefix is stripped and the bare path resolves against the
239/// dark layer regardless of the active scheme. Every other reference resolves
240/// normally under the effective scheme.
241pub fn resolve_ref(
242    path: &str,
243    scheme: Scheme,
244    tables: &TokenTables<'_>,
245) -> Option<TokenValue> {
246    match path.strip_prefix("dark.") {
247        Some(bare) => resolve(bare, Scheme::Dark, tables),
248        None => resolve(path, scheme, tables),
249    }
250}
251
252/// The parent-fallback chain for a path: `font.body.size` →
253/// `["font.body.size", "font.body", "font", ""]`.
254fn parent_chain(path: &str) -> Vec<&str> {
255    let mut chain = Vec::new();
256    chain.push(path);
257    let mut cur = path;
258    while let Some(idx) = cur.rfind('.') {
259        cur = &cur[..idx];
260        chain.push(cur);
261    }
262    chain.push("");
263    chain
264}
265
266#[cfg(test)]
267mod tests {
268    use super::*;
269    use alloc::string::ToString;
270
271    fn map(pairs: &[(&str, TokenValue)]) -> BTreeMap<String, TokenValue> {
272        pairs
273            .iter()
274            .map(|(k, v)| ((*k).to_string(), v.clone()))
275            .collect()
276    }
277
278    fn tables(
279        overrides: &[(&str, TokenValue)],
280        dark_overrides: &[(&str, TokenValue)],
281        defaults: &[(&str, TokenValue)],
282        dark_defaults: &[(&str, TokenValue)],
283    ) -> (BTreeMap<String, TokenValue>, BTreeMap<String, TokenValue>, BTreeMap<String, TokenValue>, BTreeMap<String, TokenValue>) {
284        (map(overrides), map(dark_overrides), map(defaults), map(dark_defaults))
285    }
286
287    fn resolve_with(
288        overrides: &[(&str, TokenValue)],
289        dark_overrides: &[(&str, TokenValue)],
290        defaults: &[(&str, TokenValue)],
291        path: &str,
292        scheme: Scheme,
293    ) -> Option<TokenValue> {
294        let (o, d, de, dde) = tables(overrides, dark_overrides, defaults, &[]);
295        let t = TokenTables {
296            overrides: &o,
297            dark_overrides: &d,
298            defaults: &de,
299            dark_defaults: &dde,
300        };
301        resolve(path, scheme, &t)
302    }
303
304    /// The full "Design-Token Resolution Conformance" table (spec/CONFORMANCE.md).
305    #[test]
306    fn conformance_resolution_table() {
307        let (o, d, de, dde) = tables(
308            &[
309                ("color.primary", TokenValue::Color(0xFF2563EB)),
310                ("space.base", TokenValue::F32(4.0)),
311            ],
312            &[("color.primary", TokenValue::Color(0xFF60A5FA))],
313            &[("font.body.size", TokenValue::F32(16.0)), ("space.base", TokenValue::F32(4.0))],
314            &[],
315        );
316        let t = TokenTables {
317            overrides: &o,
318            dark_overrides: &d,
319            defaults: &de,
320            dark_defaults: &dde,
321        };
322
323        assert_eq!(
324            resolve("color.primary", Scheme::Dark, &t),
325            Some(TokenValue::Color(0xFF60A5FA)),
326            "dark.color.primary override wins (dark layer)"
327        );
328        assert_eq!(
329            resolve("color.primary", Scheme::Light, &t),
330            Some(TokenValue::Color(0xFF2563EB)),
331            "dark layer skipped; base override wins"
332        );
333        assert_eq!(
334            resolve("color.primary", Scheme::Dark, &TokenTables {
335                overrides: &o,
336                dark_overrides: &BTreeMap::new(),
337                defaults: &de,
338                dark_defaults: &dde,
339            }),
340            Some(TokenValue::Color(0xFF2563EB)),
341            "base override falls through when the dark layer is empty"
342        );
343        assert_eq!(
344            resolve("font.body.size", Scheme::Light, &t),
345            Some(TokenValue::F32(16.0)),
346            "no override; defaults are renderer-owned"
347        );
348        assert_eq!(
349            resolve_with(&[("font.body", TokenValue::F32(18.0))], &[], &[], "font.body.size", Scheme::Light),
350            Some(TokenValue::F32(18.0)),
351            "parent-path fallback"
352        );
353        assert_eq!(
354            resolve("space.2", Scheme::Light, &t),
355            Some(TokenValue::F32(8.0)),
356            "generative family: space.base (4.0) × 2"
357        );
358        assert_eq!(
359            resolve("space.0.5", Scheme::Light, &t),
360            Some(TokenValue::F32(2.0)),
361            "generative family: fractional leaf"
362        );
363        assert_eq!(
364            resolve_with(&[], &[], &[("space.base", TokenValue::F32(4.0))], "space.2", Scheme::Light),
365            Some(TokenValue::F32(8.0)),
366            "generative base falls back to renderer defaults"
367        );
368        assert_eq!(
369            resolve("missing.token", Scheme::Light, &t),
370            None,
371            "no override, no default, no fallback -> None"
372        );
373    }
374
375    #[test]
376    fn parent_fallback_walks_ancestors_and_root() {
377        assert_eq!(parent_chain("color.primary"), ["color.primary", "color", ""]);
378        assert_eq!(
379            parent_chain("font.body.size"),
380            ["font.body.size", "font.body", "font", ""]
381        );
382    }
383
384    #[test]
385    fn explicit_dark_reference_forces_the_dark_layer() {
386        // App overrides: light blue, dark amber. A `dark.` reference must use
387        // the dark value in BOTH schemes; a regular reference follows the scheme.
388        let (o, d, de, dde) = tables(
389            &[("color.primary", TokenValue::Color(0xFF_2563EB))],
390            &[("color.primary", TokenValue::Color(0xFF_F59E0B))],
391            &[],
392            &[],
393        );
394        let t = TokenTables {
395            overrides: &o,
396            dark_overrides: &d,
397            defaults: &de,
398            dark_defaults: &dde,
399        };
400        assert_eq!(
401            resolve_ref("dark.color.primary", Scheme::Light, &t),
402            Some(TokenValue::Color(0xFF_F59E0B)),
403            "explicit dark reference ignores the light scheme"
404        );
405        assert_eq!(
406            resolve_ref("dark.color.primary", Scheme::Dark, &t),
407            Some(TokenValue::Color(0xFF_F59E0B))
408        );
409        assert_eq!(
410            resolve_ref("color.primary", Scheme::Light, &t),
411            Some(TokenValue::Color(0xFF_2563EB))
412        );
413        assert_eq!(
414            resolve_ref("color.primary", Scheme::Dark, &t),
415            Some(TokenValue::Color(0xFF_F59E0B))
416        );
417    }
418
419    #[test]
420    fn explicit_dark_reference_falls_back_to_dark_defaults() {
421        let (o, d, de, dde) = tables(
422            &[],
423            &[],
424            &[("color.primary", TokenValue::Color(0xFF_2563EB))],
425            &[("color.primary", TokenValue::Color(0xFF_111827))],
426        );
427        let t = TokenTables {
428            overrides: &o,
429            dark_overrides: &d,
430            defaults: &de,
431            dark_defaults: &dde,
432        };
433        assert_eq!(
434            resolve_ref("dark.color.primary", Scheme::Light, &t),
435            Some(TokenValue::Color(0xFF_111827)),
436            "dark defaults, not light defaults"
437        );
438    }
439
440    #[test]
441    fn dark_layer_takes_precedence_over_base() {
442        // Same path overridden in both layers: dark wins only under the dark scheme.
443        let (o, d, de, dde) = tables(
444            &[("color.accent", TokenValue::Color(0xFF0000FF))],
445            &[("color.accent", TokenValue::Color(0xFFFF0000))],
446            &[],
447            &[],
448        );
449        let t = TokenTables {
450            overrides: &o,
451            dark_overrides: &d,
452            defaults: &de,
453            dark_defaults: &dde,
454        };
455        assert_eq!(resolve("color.accent", Scheme::Dark, &t), Some(TokenValue::Color(0xFFFF0000)));
456        assert_eq!(resolve("color.accent", Scheme::Light, &t), Some(TokenValue::Color(0xFF0000FF)));
457    }
458}