use std::collections::BTreeMap;
use std::fmt;
use std::fmt::Write as _;
use crate::color::{resolve_all_colors, resolve_gradient_stops};
use crate::contrast::resolve_ink_colors;
use crate::error::Error;
use crate::spec::{SemanticValue, Spec};
#[derive(Debug, Default, Clone)]
#[non_exhaustive]
pub struct Config {}
#[derive(Debug, Default, Clone)]
pub struct Renderer {
#[allow(dead_code)]
config: Config,
}
impl Renderer {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn with_config(config: Config) -> Self {
Self { config }
}
pub fn render(&self, spec: &Spec) -> Result<Manifest, Error> {
let resolved = resolve_all_colors(&spec.colors, spec.scales.shades.len())?;
let mut out = String::with_capacity(8192);
write_header(&mut out);
writeln!(out, ":root {{").unwrap();
emit_palettes(&mut out, spec, &resolved);
emit_semantic(&mut out, spec);
emit_ink_and_shadows(&mut out, spec, &resolved)?;
emit_text_shadows(&mut out, spec);
emit_typography(&mut out, spec)?;
emit_space(&mut out, spec)?;
emit_dimensions(&mut out, spec)?;
emit_borders(&mut out, spec)?;
emit_shadows(&mut out, spec);
emit_glows(&mut out, spec)?;
emit_gradients(&mut out, spec, &resolved)?;
emit_transitions(&mut out, spec);
emit_z_index(&mut out, spec);
emit_overlay(&mut out, spec);
emit_opacity(&mut out, spec);
writeln!(out, "}}").unwrap();
Ok(Manifest { css: out })
}
}
#[derive(Debug, Clone)]
pub struct Manifest {
css: String,
}
impl Manifest {
#[must_use]
pub fn as_str(&self) -> &str {
&self.css
}
}
impl fmt::Display for Manifest {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.css)
}
}
fn write_header(out: &mut String) {
writeln!(
out,
"/* ═══════════════════════════════════════════════════════════════"
)
.unwrap();
writeln!(out, " Design Tokens — generated by tangible").unwrap();
writeln!(
out,
" ═══════════════════════════════════════════════════════════════ */"
)
.unwrap();
writeln!(out).unwrap();
}
fn emit_palettes(out: &mut String, spec: &Spec, resolved: &BTreeMap<String, Vec<String>>) {
section(out, "Palettes");
for (palette, hexes) in resolved {
writeln!(out).unwrap();
for (i, hex) in hexes.iter().enumerate() {
let shade = spec.scales.shades[i];
writeln!(
out,
" --colors-{palette}-{shade}:{pad}{hex};",
pad = pad_to(format!("--colors-{palette}-{shade}:").len(), 30),
)
.unwrap();
}
}
}
fn emit_semantic(out: &mut String, spec: &Spec) {
writeln!(out).unwrap();
section(out, "Semantic Color Aliases");
writeln!(out).unwrap();
for (name, value) in &spec.semantic {
match value {
SemanticValue::PaletteRef(palette, shade) => {
writeln!(
out,
" --color-{name}:{pad}var(--colors-{palette}-{shade});",
pad = pad_to(format!("--color-{name}:").len(), 28),
)
.unwrap();
}
SemanticValue::Raw(raw) => {
writeln!(
out,
" --color-{name}:{pad}{raw};",
pad = pad_to(format!("--color-{name}:").len(), 28),
)
.unwrap();
}
}
}
}
fn lookup_hex<'a>(
resolved: &'a BTreeMap<String, Vec<String>>,
shades: &[u32],
palette: &str,
shade: u32,
context: &str,
) -> Result<&'a str, Error> {
let palette_colors = resolved.get(palette).ok_or_else(|| Error::UnknownPalette {
palette: palette.to_string(),
context: context.to_string(),
})?;
let idx = shades
.iter()
.position(|s| *s == shade)
.ok_or_else(|| Error::UnknownShade {
shade,
context: context.to_string(),
})?;
Ok(palette_colors[idx].as_str())
}
fn emit_ink_and_shadows(
out: &mut String,
spec: &Spec,
resolved: &BTreeMap<String, Vec<String>>,
) -> Result<(), Error> {
writeln!(out).unwrap();
section(out, "Ink (Contrast) Colors");
writeln!(out).unwrap();
let (ink_light_palette, ink_light_shade) = &spec.ink.light;
let ink_light_hex = lookup_hex(
resolved,
&spec.scales.shades,
ink_light_palette,
*ink_light_shade,
" in ink.light",
)?
.to_string();
let (ink_dark_palette, ink_dark_shade) = &spec.ink.dark;
let ink_dark_hex = lookup_hex(
resolved,
&spec.scales.shades,
ink_dark_palette,
*ink_dark_shade,
" in ink.dark",
)?
.to_string();
writeln!(
out,
" --ink-light:{pad}var(--colors-{ink_light_palette}-{ink_light_shade});",
pad = pad_to("--ink-light:".len(), 28),
)
.unwrap();
writeln!(
out,
" --ink-dark:{pad}var(--colors-{ink_dark_palette}-{ink_dark_shade});",
pad = pad_to("--ink-dark:".len(), 28),
)
.unwrap();
writeln!(out).unwrap();
let inks = resolve_ink_colors(
&spec.semantic,
resolved,
&spec.scales.shades,
&ink_light_hex,
&ink_dark_hex,
)?;
for (name, ink_hex) in &inks {
let is_light_ink = *ink_hex == ink_light_hex;
let ink_var = if is_light_ink {
"var(--ink-light)"
} else {
"var(--ink-dark)"
};
writeln!(
out,
" --color-{name}-ink:{pad}{ink_var};",
pad = pad_to(format!("--color-{name}-ink:").len(), 28),
)
.unwrap();
let shadow_val = if is_light_ink {
"var(--text-shadow-medium)"
} else {
"none"
};
writeln!(
out,
" --color-{name}-shadow:{pad}{shadow_val};",
pad = pad_to(format!("--color-{name}-shadow:").len(), 28),
)
.unwrap();
}
Ok(())
}
fn emit_text_shadows(out: &mut String, spec: &Spec) {
writeln!(out).unwrap();
section(out, "Text Shadows");
writeln!(out).unwrap();
for (elev_name, elev) in &spec.text_shadows {
let var_name = format!("--text-shadow-{elev_name}");
let layers: Vec<String> = elev
.blur
.iter()
.map(|b| format!("0 0 {b}px rgba(0, 0, 0, {op:.2})", op = elev.opacity))
.collect();
if layers.len() == 1 {
writeln!(
out,
" {var_name}:{pad}{};",
layers[0],
pad = pad_to(var_name.len() + 1, 28),
)
.unwrap();
} else {
write!(out, " {var_name}:").unwrap();
for (i, layer) in layers.iter().enumerate() {
let sep = if i + 1 < layers.len() { "," } else { ";" };
write!(out, "\n {layer}{sep}").unwrap();
}
writeln!(out).unwrap();
}
}
}
fn require_same_length(
section_name: &str,
section_len: usize,
scales_name: &str,
scales_len: usize,
) -> Result<(), Error> {
if section_len == scales_len {
Ok(())
} else {
Err(Error::ScaleMismatch(format!(
"`{section_name}` has {section_len} entries but `scales.{scales_name}` has {scales_len}",
)))
}
}
fn emit_typography(out: &mut String, spec: &Spec) -> Result<(), Error> {
require_same_length(
"fonts.sizes",
spec.fonts.sizes.len(),
"sizes",
spec.scales.sizes.len(),
)?;
require_same_length(
"fonts.lineHeights",
spec.fonts.line_heights.len(),
"sizes",
spec.scales.sizes.len(),
)?;
writeln!(out).unwrap();
section(out, "Typography");
writeln!(out).unwrap();
for (name, stack) in &spec.fonts.families {
let quoted: Vec<String> = stack
.iter()
.map(|f| {
if f.contains(' ') {
format!("'{f}'")
} else {
f.clone()
}
})
.collect();
writeln!(
out,
" --font-family-{name}:{pad}{joined};",
pad = pad_to(format!("--font-family-{name}:").len(), 26),
joined = quoted.join(", "),
)
.unwrap();
}
writeln!(out).unwrap();
for (i, size) in spec.fonts.sizes.iter().enumerate() {
let name = &spec.scales.sizes[i];
writeln!(
out,
" --font-size-{name}:{pad}{size};",
pad = pad_to(format!("--font-size-{name}:").len(), 26),
)
.unwrap();
}
writeln!(out).unwrap();
for weight in &spec.fonts.weights {
writeln!(
out,
" --font-weight-{weight}:{pad}{weight};",
pad = pad_to(format!("--font-weight-{weight}:").len(), 26),
)
.unwrap();
}
writeln!(out).unwrap();
for (i, lh) in spec.fonts.line_heights.iter().enumerate() {
let name = &spec.scales.sizes[i];
writeln!(
out,
" --line-height-{name}:{pad}{lh};",
pad = pad_to(format!("--line-height-{name}:").len(), 26),
)
.unwrap();
}
writeln!(out).unwrap();
for (name, val) in &spec.fonts.letter_spacing {
writeln!(
out,
" --letter-spacing-{name}:{pad}{val};",
pad = pad_to(format!("--letter-spacing-{name}:").len(), 28),
)
.unwrap();
}
Ok(())
}
fn emit_space(out: &mut String, spec: &Spec) -> Result<(), Error> {
require_same_length("space", spec.space.len(), "sizes", spec.scales.sizes.len())?;
writeln!(out).unwrap();
section(out, "Space");
writeln!(out).unwrap();
for (i, val) in spec.space.iter().enumerate() {
let name = &spec.scales.sizes[i];
writeln!(
out,
" --space-{name}:{pad}{val};",
pad = pad_to(format!("--space-{name}:").len(), 26),
)
.unwrap();
}
Ok(())
}
fn emit_dimensions(out: &mut String, spec: &Spec) -> Result<(), Error> {
require_same_length(
"dimensions",
spec.dimensions.len(),
"sizes",
spec.scales.sizes.len(),
)?;
writeln!(out).unwrap();
section(out, "Dimensions");
writeln!(out).unwrap();
for (i, val) in spec.dimensions.iter().enumerate() {
let name = &spec.scales.sizes[i];
writeln!(
out,
" --dimension-{name}:{pad}{val};",
pad = pad_to(format!("--dimension-{name}:").len(), 26),
)
.unwrap();
}
Ok(())
}
fn emit_borders(out: &mut String, spec: &Spec) -> Result<(), Error> {
let width_names = border_width_names(spec.borders.widths.len())?;
writeln!(out).unwrap();
section(out, "Borders");
writeln!(out).unwrap();
for (name, val) in &spec.borders.radius {
writeln!(
out,
" --radius-{name}:{pad}{val};",
pad = pad_to(format!("--radius-{name}:").len(), 26),
)
.unwrap();
}
if spec.borders.radius.contains_key("md") {
writeln!(
out,
" --border-radius:{pad}var(--radius-md);",
pad = pad_to(17, 26),
)
.unwrap();
}
writeln!(out).unwrap();
for (i, val) in spec.borders.widths.iter().enumerate() {
let name = width_names[i];
writeln!(
out,
" --border-width-{name}:{pad}{val};",
pad = pad_to(format!("--border-width-{name}:").len(), 26),
)
.unwrap();
}
Ok(())
}
fn border_width_names(count: usize) -> Result<&'static [&'static str], Error> {
match count {
1 => Ok(&["md"]),
2 => Ok(&["sm", "lg"]),
3 => Ok(&["sm", "md", "lg"]),
5 => Ok(&["xs", "sm", "md", "lg", "xl"]),
n => Err(Error::ScaleMismatch(format!(
"unsupported borders.widths count {n} — expected 1, 2, 3, or 5",
))),
}
}
fn emit_shadows(out: &mut String, spec: &Spec) {
writeln!(out).unwrap();
section(out, "Shadows");
writeln!(out).unwrap();
for (palette, hsl) in &spec.shadows.colors {
writeln!(
out,
" --shadow-color-{palette}:{pad}{hsl};",
pad = pad_to(format!("--shadow-color-{palette}:").len(), 26),
)
.unwrap();
}
for palette in spec.shadows.colors.keys() {
writeln!(out).unwrap();
for (elev_name, elev) in &spec.shadows.elevations {
let var_name = format!("--shadow-{palette}-{elev_name}");
let layers: Vec<String> = elev
.offsets
.iter()
.map(|(x, y)| {
format!(
"{x}px {y}px {y}px hsl(var(--shadow-color-{palette}) / {op:.2})",
op = elev.opacity,
)
})
.collect();
if layers.len() == 1 {
writeln!(out, " {var_name}:\n {};", layers[0]).unwrap();
} else {
write!(out, " {var_name}:").unwrap();
for (i, layer) in layers.iter().enumerate() {
let sep = if i + 1 < layers.len() { "," } else { ";" };
write!(out, "\n {layer}{sep}").unwrap();
}
writeln!(out).unwrap();
}
}
}
}
fn emit_glows(out: &mut String, spec: &Spec) -> Result<(), Error> {
writeln!(out).unwrap();
section(out, "Glows");
writeln!(out).unwrap();
for (palette, glow) in &spec.glows {
require_same_length(
&format!("glows.{palette}.radii"),
glow.radii.len(),
"elevations",
spec.scales.elevations.len(),
)?;
for (i, radius) in glow.radii.iter().enumerate() {
let elev = &spec.scales.elevations[i];
let opacity = glow_opacity(i);
let color = glow.color.replace("{a}", &format!("{opacity:.2}"));
writeln!(
out,
" --glow-{palette}-{elev}:{pad}0 0 {radius}px {color};",
pad = pad_to(format!("--glow-{palette}-{elev}:").len(), 28),
)
.unwrap();
}
writeln!(out).unwrap();
}
Ok(())
}
fn emit_gradients(
out: &mut String,
spec: &Spec,
resolved: &BTreeMap<String, Vec<String>>,
) -> Result<(), Error> {
section(out, "Gradients");
writeln!(out).unwrap();
for grad in &spec.gradients {
let stops: Vec<String> = if grad.blend.is_some() {
resolve_gradient_stops(grad, resolved, &spec.scales.shades)?
} else {
grad.stops
.iter()
.map(|(palette, shade)| format!("var(--colors-{palette}-{shade})"))
.collect()
};
writeln!(
out,
" --gradient-{name}:{pad}{typ}-gradient({angle}deg, {joined});",
name = grad.name,
pad = pad_to(format!("--gradient-{}:", grad.name).len(), 28),
typ = grad.gradient_type,
angle = grad.angle,
joined = stops.join(", "),
)
.unwrap();
}
Ok(())
}
fn emit_transitions(out: &mut String, spec: &Spec) {
writeln!(out).unwrap();
section(out, "Transitions");
writeln!(out).unwrap();
for (name, val) in &spec.transitions {
writeln!(
out,
" --transition-{name}:{pad}{val};",
pad = pad_to(format!("--transition-{name}:").len(), 26),
)
.unwrap();
}
}
fn emit_z_index(out: &mut String, spec: &Spec) {
writeln!(out).unwrap();
section(out, "Z-Index");
writeln!(out).unwrap();
for (name, val) in &spec.z {
let v = match val {
serde_json::Value::Number(n) => n.to_string(),
other => other.to_string(),
};
writeln!(
out,
" --z-{name}:{pad}{v};",
pad = pad_to(format!("--z-{name}:").len(), 26),
)
.unwrap();
}
}
fn emit_overlay(out: &mut String, spec: &Spec) {
if spec.overlay.is_empty() {
return;
}
writeln!(out).unwrap();
section(out, "Overlay Colors");
writeln!(out).unwrap();
for (name, val) in &spec.overlay {
let var_name = if name == "color" {
"--color-overlay".to_string()
} else {
format!("--color-overlay-{name}")
};
writeln!(
out,
" {var_name}:{pad}{val};",
pad = pad_to(var_name.len() + 1, 28),
)
.unwrap();
}
}
fn emit_opacity(out: &mut String, spec: &Spec) {
writeln!(out).unwrap();
section(out, "Opacity");
writeln!(out).unwrap();
for (name, val) in &spec.opacity {
writeln!(
out,
" --opacity-{name}:{pad}{val};",
pad = pad_to(format!("--opacity-{name}:").len(), 26),
)
.unwrap();
}
}
fn section(out: &mut String, title: &str) {
writeln!(out).unwrap();
writeln!(
out,
" /* ─── {title} {} */",
"─".repeat(60usize.saturating_sub(title.len() + 7))
)
.unwrap();
}
fn pad_to(current: usize, target: usize) -> String {
let extra = 2 + current; if extra >= target {
" ".to_string()
} else {
" ".repeat(target - extra)
}
}
fn glow_opacity(index: usize) -> f64 {
#[allow(clippy::cast_precision_loss)]
let value = (index as f64 + 1.0) * 0.20;
value
}
#[cfg(test)]
mod tests {
use super::*;
fn test_spec_json() -> &'static str {
r##"{
"scales": {
"shades": [100, 500, 900],
"sizes": ["sm", "md", "lg"],
"elevations": ["low", "medium", "high"]
},
"colors": {
"neutral": ["#e0e0e0", "#808080", "#202020"],
"primary": {
"stops": ["#b3f0e6", "#00c9a7", "#061e1c"],
"mode": "linear",
"blend": "rgb"
}
},
"semantic": {
"bg": ["neutral", 900],
"text": ["neutral", 100],
"interactive": ["primary", 500]
},
"fonts": {
"families": { "mono": ["Syne Mono", "monospace"], "sans": ["Inter", "sans-serif"] },
"sizes": ["0.75rem", "1rem", "1.25rem"],
"weights": [400, 700],
"lineHeights": [1.4, 1.5, 1.6],
"letterSpacing": { "tight": "-0.01em", "wide": "0.02em" }
},
"space": ["0.5rem", "1rem", "2rem"],
"dimensions": ["16rem", "32rem", "64rem"],
"borders": {
"radius": { "sm": "4px", "md": "8px", "lg": "16px" },
"widths": ["1px", "2px", "4px"]
},
"shadows": {
"colors": { "neutral": "220 10% 10%" },
"elevations": {
"low": { "layers": 1, "offsets": [[0, 1]], "opacity": 0.1 },
"medium": { "layers": 2, "offsets": [[0, 2], [0, 4]], "opacity": 0.15 },
"high": { "layers": 1, "offsets": [[0, 8]], "opacity": 0.2 }
}
},
"textShadows": {
"low": { "blur": [2], "opacity": 0.3 },
"medium": { "blur": [2, 4], "opacity": 0.4 },
"high": { "blur": [2, 4, 8], "opacity": 0.5 }
},
"ink": {
"light": ["neutral", 100],
"dark": ["neutral", 900]
},
"glows": {
"primary": { "color": "hsla(170, 100%, 40%, {a})", "radii": [4, 8, 16] }
},
"gradients": [
{
"name": "sweep",
"type": "linear",
"angle": 135,
"stops": [["primary", 100], ["primary", 900]]
},
{
"name": "blended",
"type": "linear",
"angle": 90,
"stops": [["primary", 100], ["primary", 900]],
"blend": "oklab",
"samples": 5
}
],
"transitions": { "default": "150ms ease", "slow": "300ms ease-in-out" },
"z": { "base": 0, "overlay": 100 },
"opacity": { "disabled": 0.4, "muted": 0.6 },
"overlay": { "color": "rgba(0,0,0,0.5)" }
}"##
}
fn spec() -> Spec {
serde_json::from_str(test_spec_json()).unwrap()
}
fn css() -> String {
Renderer::new().render(&spec()).unwrap().to_string()
}
#[test]
fn manifest_round_trips_to_string() {
let manifest = Renderer::new().render(&spec()).unwrap();
assert_eq!(manifest.to_string(), manifest.as_str());
}
#[test]
fn parses_spec() {
let spec = spec();
assert_eq!(spec.colors.len(), 2);
assert_eq!(spec.scales.shades.len(), 3);
assert_eq!(spec.gradients.len(), 2);
}
#[test]
fn generates_palette_vars() {
let css = css();
assert!(css.contains("--colors-neutral-100:"));
assert!(css.contains("--colors-primary-500:"));
assert!(css.contains("--colors-neutral-900:"));
}
#[test]
fn generates_semantic_aliases() {
let css = css();
assert!(css.contains("--color-bg:"));
assert!(css.contains("var(--colors-neutral-900)"));
assert!(css.contains("--color-interactive:"));
assert!(css.contains("var(--colors-primary-500)"));
}
#[test]
fn generates_font_families() {
let css = css();
assert!(css.contains("--font-family-mono:"));
assert!(css.contains("'Syne Mono'"));
}
#[test]
fn generates_shadows() {
let css = css();
assert!(css.contains("--shadow-color-neutral:"));
assert!(css.contains("--shadow-neutral-low:"));
assert!(css.contains("--shadow-neutral-high:"));
assert!(css.contains("hsl(var(--shadow-color-neutral)"));
}
#[test]
fn generates_glows() {
let css = css();
assert!(css.contains("--glow-primary-low:"));
assert!(css.contains("--glow-primary-medium:"));
assert!(css.contains("--glow-primary-high:"));
}
#[test]
fn generates_gradients() {
let css = css();
assert!(css.contains("--gradient-sweep:"));
assert!(css.contains("--gradient-blended:"));
assert!(css.contains("linear-gradient(135deg"));
}
#[test]
fn generates_all_sections() {
let css = css();
for keyword in [
"--space-md:",
"--dimension-lg:",
"--border-radius:",
"--border-width-sm:",
"--transition-default:",
"--z-overlay:",
"--opacity-disabled:",
"--font-size-md:",
"--font-weight-400:",
"--line-height-md:",
"--letter-spacing-wide:",
] {
assert!(css.contains(keyword), "missing: {keyword}");
}
}
#[test]
fn output_is_valid_css_block() {
let css = css();
assert!(css.contains(":root {"));
assert!(css.trim_end().ends_with('}'));
}
#[test]
fn gradient_without_blend_uses_var_refs() {
let css = css();
assert!(css.contains("var(--colors-"));
}
#[test]
fn generates_ink_base_tokens() {
let css = css();
assert!(css.contains("--ink-light:"));
assert!(css.contains("--ink-dark:"));
}
#[test]
fn generates_ink_for_each_semantic_color() {
let css = css();
let spec = spec();
for name in spec.semantic.keys() {
let token = format!("--color-{name}-ink:");
assert!(css.contains(&token), "missing ink token: {token}");
}
}
#[test]
fn ink_tokens_reference_ink_vars() {
let css = css();
for line in css.lines() {
if line.contains("-ink:")
&& !line.contains("--ink-light:")
&& !line.contains("--ink-dark:")
{
assert!(
line.contains("var(--ink-light)") || line.contains("var(--ink-dark)"),
"ink token should reference --ink-light or --ink-dark: {line}"
);
}
}
}
#[test]
fn generates_text_shadow_elevations() {
let css = css();
assert!(css.contains("--text-shadow-low:"));
assert!(css.contains("--text-shadow-medium:"));
assert!(css.contains("--text-shadow-high:"));
}
#[test]
fn generates_shadow_for_each_semantic_color() {
let css = css();
let spec = spec();
for name in spec.semantic.keys() {
let token = format!("--color-{name}-shadow:");
assert!(css.contains(&token), "missing shadow token: {token}");
}
}
#[test]
fn shadow_tokens_are_none_for_dark_ink() {
let css = css();
assert!(
css.lines()
.any(|l| l.contains("--color-interactive-shadow:") && l.contains("none")),
"dark-ink semantic should have shadow: none"
);
}
#[test]
fn shadow_tokens_reference_text_shadow_for_light_ink() {
let css = css();
assert!(
css.lines().any(
|l| l.contains("--color-bg-shadow:") && l.contains("var(--text-shadow-medium)")
),
"light-ink semantic should reference --text-shadow-medium"
);
}
#[test]
fn generates_overlay_colors() {
let css = css();
assert!(css.contains("--color-overlay:"));
assert!(css.contains("rgba(0,0,0,0.5)"));
}
#[test]
fn missing_overlay_is_omitted() {
let mut spec = spec();
spec.overlay.clear();
let css = Renderer::new().render(&spec).unwrap().to_string();
assert!(!css.contains("Overlay Colors"));
}
#[test]
fn scale_mismatch_in_space_errors() {
let mut spec = spec();
spec.space.pop();
let err = Renderer::new().render(&spec).unwrap_err();
assert!(matches!(err, Error::ScaleMismatch(_)));
}
#[test]
fn unknown_ink_palette_errors() {
let mut spec = spec();
spec.ink.light.0 = "missing".into();
let err = Renderer::new().render(&spec).unwrap_err();
assert!(matches!(err, Error::UnknownPalette { .. }));
}
#[test]
fn unknown_gradient_palette_errors() {
let mut spec = spec();
spec.gradients[1].stops[0].0 = "missing".into();
let err = Renderer::new().render(&spec).unwrap_err();
assert!(matches!(err, Error::UnknownPalette { .. }));
}
#[test]
fn renderer_with_default_config_succeeds() {
let manifest = Renderer::with_config(Config::default())
.render(&spec())
.unwrap();
assert!(manifest.as_str().contains(":root {"));
}
}