// SPDX-License-Identifier: LGPL-2.1-only // Copyright (C) 2026 Liberux Labs, S. L. //! JSON schema for the theme document: raw deserialisation types, colour //! (de)serialiser, and conversion to the public runtime types. //! //! This module is the single place where impedance between "JSON as designers //! and tools write it" and "the Rust types widgets consume" lives. Every //! public type in `theme::paint`, `theme::shadow`, `theme::surface`, //! `theme::text_style` and `theme::slots` gets a private `Raw*` counterpart //! here with the minimum serde annotations to match the JSON shape, and a //! `From for *` conversion that builds the public value. //! //! Keeping the derives off the public types means the public API stays //! free of serde dependencies and the JSON format can evolve (add fields, //! accept aliases, reject unknown keys) without touching consumer code. use std::collections::HashMap; use std::path::{ Path, PathBuf }; use serde::{ Deserialize, Deserializer, Serialize, Serializer }; use serde::de::Error as DeError; use crate::types::Color; use super::document::{ Mode, ThemeDocument }; use super::fonts::{ FontFamilyDef, FontSource }; use super::paint::{ ColorStop, GradientSpace, LinearGradient, Paint, RadialGradient }; use super::shadow::{ BlendMode, InsetShadow, Shadow, ShadowsRef }; use super::slots::{ Metadata, Slot, SlotStore }; use super::surface::Surface; use super::text_style:: { FontRef, FontStyle, LineHeight, TextDecoration, TextStyle, TextTransform, }; use super:: { default_window_controls, LauncherSpec, ThemeError, ThemeMode, WallpaperFit, WallpaperSpec, WindowControlsSpec, }; // ─── Color serialiser ──────────────────────────────────────────────────────── /// Encode / decode [`Color`] as a hex or `rgba(…)` string. /// /// Accepted input forms: /// /// * `#RRGGBB` or `RRGGBB` — opaque, 8 bits per channel. /// * `#RRGGBBAA` or `RRGGBBAA` — with straight alpha, 8 bits per channel. /// * `rgba(R, G, B, A)` — R/G/B as integers `0..=255` (or floats), A as a /// float in `0.0..=1.0`. Whitespace around commas is tolerated. /// * `rgb(R, G, B)` — same as above with implicit A = 1.0. /// /// Output form (serialisation): `#RRGGBB` when alpha is 1.0, `#RRGGBBAA` /// otherwise. pub mod color_serde { use super::*; /// Parse one of the accepted color syntaxes into a [`Color`]. pub fn parse( s: &str ) -> Result { let t = s.trim(); if t.starts_with( "rgba(" ) || t.starts_with( "rgb(" ) { parse_functional( t ) } else { parse_hex( t ) } } fn parse_hex( s: &str ) -> Result { let h = s.trim_start_matches( '#' ); let ( r, g, b, a ) = match h.len() { 6 => { let r = u8::from_str_radix( &h[0..2], 16 ).map_err( |_| bad( s ) )?; let g = u8::from_str_radix( &h[2..4], 16 ).map_err( |_| bad( s ) )?; let b = u8::from_str_radix( &h[4..6], 16 ).map_err( |_| bad( s ) )?; ( r, g, b, 0xFF ) } 8 => { let r = u8::from_str_radix( &h[0..2], 16 ).map_err( |_| bad( s ) )?; let g = u8::from_str_radix( &h[2..4], 16 ).map_err( |_| bad( s ) )?; let b = u8::from_str_radix( &h[4..6], 16 ).map_err( |_| bad( s ) )?; let a = u8::from_str_radix( &h[6..8], 16 ).map_err( |_| bad( s ) )?; ( r, g, b, a ) } _ => return Err( bad( s ) ), }; Ok( Color { r: r as f32 / 255.0, g: g as f32 / 255.0, b: b as f32 / 255.0, a: a as f32 / 255.0, }) } fn parse_functional( s: &str ) -> Result { let ( with_alpha, inner ) = if let Some( rest ) = s.strip_prefix( "rgba(" ) { ( true, rest.strip_suffix( ')' ).ok_or_else( || bad( s ) )? ) } else if let Some( rest ) = s.strip_prefix( "rgb(" ) { ( false, rest.strip_suffix( ')' ).ok_or_else( || bad( s ) )? ) } else { return Err( bad( s ) ); }; let parts: Vec<&str> = inner.split( ',' ).map( str::trim ).collect(); let expected = if with_alpha { 4 } else { 3 }; if parts.len() != expected { return Err( bad( s ) ); } let r = parse_channel( parts[0] ).ok_or_else( || bad( s ) )?; let g = parse_channel( parts[1] ).ok_or_else( || bad( s ) )?; let b = parse_channel( parts[2] ).ok_or_else( || bad( s ) )?; let a = if with_alpha { parts[3].parse::().map_err( |_| bad( s ) )?.clamp( 0.0, 1.0 ) } else { 1.0 }; Ok( Color { r: r / 255.0, g: g / 255.0, b: b / 255.0, a }) } fn parse_channel( s: &str ) -> Option { // Integer 0..=255 or float 0..=255. if let Ok( n ) = s.parse::() { if n <= 255 { return Some( n as f32 ); } return None; } if let Ok( f ) = s.parse::() { if ( 0.0..=255.0 ).contains( &f ) { return Some( f ); } } None } fn bad( s: &str ) -> String { format! ( "invalid colour `{}` (expected `#RRGGBB`, `#RRGGBBAA` or `rgb[a](…)`)", s ) } /// Canonical string form: `#RRGGBB` when opaque, `#RRGGBBAA` otherwise. pub fn format( c: Color ) -> String { let r = (c.r.clamp( 0.0, 1.0 ) * 255.0).round() as u8; let g = (c.g.clamp( 0.0, 1.0 ) * 255.0).round() as u8; let b = (c.b.clamp( 0.0, 1.0 ) * 255.0).round() as u8; let a = (c.a.clamp( 0.0, 1.0 ) * 255.0).round() as u8; if a == 0xFF { format!( "#{:02X}{:02X}{:02X}", r, g, b ) } else { format!( "#{:02X}{:02X}{:02X}{:02X}", r, g, b, a ) } } pub fn serialize( c: &Color, ser: S ) -> Result where S: Serializer { ser.serialize_str( &format( *c ) ) } pub fn deserialize<'de, D>( de: D ) -> Result where D: Deserializer<'de> { let s = String::deserialize( de )?; parse( &s ).map_err( D::Error::custom ) } } /// Expose the colour parser so error messages and other loaders can share /// the same syntax understanding. pub fn parse_color_str( s: &str ) -> Result { color_serde::parse( s ) } // ─── Enum mirrors with defaults ────────────────────────────────────────────── #[ derive( Debug, Clone, Copy, Serialize, Deserialize ) ] #[ serde( rename_all = "kebab-case" ) ] enum RawGradientSpace { Srgb, LinearRgb, Oklab, } impl Default for RawGradientSpace { fn default() -> Self { RawGradientSpace::LinearRgb } } impl From for GradientSpace { fn from( r: RawGradientSpace ) -> Self { match r { RawGradientSpace::Srgb => GradientSpace::Srgb, RawGradientSpace::LinearRgb => GradientSpace::LinearRgb, RawGradientSpace::Oklab => GradientSpace::Oklab, } } } #[ derive( Debug, Clone, Copy, Serialize, Deserialize ) ] #[ serde( rename_all = "kebab-case" ) ] enum RawBlendMode { Normal, PlusLighter, Overlay, Multiply, Screen, } impl Default for RawBlendMode { fn default() -> Self { RawBlendMode::Normal } } impl From for BlendMode { fn from( r: RawBlendMode ) -> Self { match r { RawBlendMode::Normal => BlendMode::Normal, RawBlendMode::PlusLighter => BlendMode::PlusLighter, RawBlendMode::Overlay => BlendMode::Overlay, RawBlendMode::Multiply => BlendMode::Multiply, RawBlendMode::Screen => BlendMode::Screen, } } } #[ derive( Debug, Clone, Copy, Serialize, Deserialize ) ] #[ serde( rename_all = "kebab-case" ) ] enum RawFontStyle { Normal, Italic } impl Default for RawFontStyle { fn default() -> Self { RawFontStyle::Normal } } impl From for FontStyle { fn from( r: RawFontStyle ) -> Self { match r { RawFontStyle::Normal => FontStyle::Normal, RawFontStyle::Italic => FontStyle::Italic, } } } #[ derive( Debug, Clone, Copy, Serialize, Deserialize ) ] #[ serde( rename_all = "kebab-case" ) ] enum RawTextTransform { None, Uppercase, Lowercase, Capitalize } impl Default for RawTextTransform { fn default() -> Self { RawTextTransform::None } } impl From for TextTransform { fn from( r: RawTextTransform ) -> Self { match r { RawTextTransform::None => TextTransform::None, RawTextTransform::Uppercase => TextTransform::Uppercase, RawTextTransform::Lowercase => TextTransform::Lowercase, RawTextTransform::Capitalize => TextTransform::Capitalize, } } } #[ derive( Debug, Clone, Copy, Serialize, Deserialize ) ] #[ serde( rename_all = "kebab-case" ) ] enum RawTextDecoration { None, Underline, Strikethrough } impl Default for RawTextDecoration { fn default() -> Self { RawTextDecoration::None } } impl From for TextDecoration { fn from( r: RawTextDecoration ) -> Self { match r { RawTextDecoration::None => TextDecoration::None, RawTextDecoration::Underline => TextDecoration::Underline, RawTextDecoration::Strikethrough => TextDecoration::Strikethrough, } } } // ─── Metadata ──────────────────────────────────────────────────────────────── #[ derive( Debug, Clone, Default, Serialize, Deserialize ) ] struct RawMetadata { #[ serde( default, skip_serializing_if = "Option::is_none" ) ] semantic: Option, #[ serde( default, skip_serializing_if = "Option::is_none" ) ] fluent: Option, #[ serde( default, skip_serializing_if = "Option::is_none" ) ] usage: Option, #[ serde( default, skip_serializing_if = "Option::is_none" ) ] note: Option, } impl From for Metadata { fn from( r: RawMetadata ) -> Self { Metadata { semantic: r.semantic, fluent: r.fluent, usage: r.usage, note: r.note } } } // ─── Gradient stops and gradients ──────────────────────────────────────────── #[ derive( Debug, Clone, Serialize, Deserialize ) ] #[ serde( deny_unknown_fields ) ] struct RawColorStop { #[ serde( alias = "position" ) ] pos: f32, #[ serde( with = "color_serde" ) ] color: Color, } impl From for ColorStop { fn from( r: RawColorStop ) -> Self { ColorStop { position: r.pos, color: r.color } } } // ─── Paint (for `surface.fill`) ────────────────────────────────────────────── #[ derive( Debug, Clone, Serialize, Deserialize ) ] #[ serde( tag = "type", rename_all = "kebab-case", deny_unknown_fields ) ] enum RawPaint { Solid { #[ serde( with = "color_serde" ) ] color: Color, }, Linear { angle_deg: f32, #[ serde( default ) ] space: RawGradientSpace, stops: Vec, }, Radial { center: [f32; 2], radius: f32, #[ serde( default ) ] space: RawGradientSpace, stops: Vec, }, } impl From for Paint { fn from( r: RawPaint ) -> Self { match r { RawPaint::Solid { color } => Paint::Solid( color ), RawPaint::Linear { angle_deg, space, stops } => Paint::Linear( LinearGradient { angle_deg, space: space.into(), stops: collect_capped_stops( stops ), }), RawPaint::Radial { center, radius, space, stops } => Paint::Radial( RadialGradient { center, radius, space: space.into(), stops: collect_capped_stops( stops ), }), } } } /// Soft cap on the number of color stops a gradient may carry. Realistic /// designs use 2 – 6 stops, the shipped theme tops out at 4, and /// 64 leaves comfortable headroom for hand-tuned tonemap palettes. The /// cap exists to bound CPU + memory under a hostile theme document — a /// 200 000-stop linear gradient would otherwise build a 6 MB /// `Vec` per slot at parse time. Exceeding the cap truncates /// the tail and logs once via stderr. const MAX_GRADIENT_STOPS: usize = 64; fn collect_capped_stops( raw: Vec ) -> Vec { if raw.len() > MAX_GRADIENT_STOPS { eprintln!( "[ltk theme] gradient declares {} stops, truncating to {}", raw.len(), MAX_GRADIENT_STOPS, ); raw.into_iter().take( MAX_GRADIENT_STOPS ).map( Into::into ).collect() } else { raw.into_iter().map( Into::into ).collect() } } // ─── Shadows ───────────────────────────────────────────────────────────────── #[ derive( Debug, Clone, Serialize, Deserialize ) ] #[ serde( deny_unknown_fields ) ] struct RawShadow { offset: [f32; 2], blur: f32, #[ serde( default ) ] spread: f32, #[ serde( with = "color_serde" ) ] color: Color, #[ serde( default ) ] blend: RawBlendMode, } impl From for Shadow { fn from( r: RawShadow ) -> Self { Shadow { offset: r.offset, blur: r.blur, spread: r.spread, color: r.color, blend: r.blend.into() } } } impl From for InsetShadow { fn from( r: RawShadow ) -> Self { InsetShadow { offset: r.offset, blur: r.blur, spread: r.spread, color: r.color, blend: r.blend.into() } } } /// Reference to a shadow stack inside a surface: either a string id /// pointing at a `shadows` slot, or a literal list. #[ derive( Debug, Clone, Serialize, Deserialize ) ] #[ serde( untagged ) ] enum RawShadowsRef { Named( String ), Inline( Vec ), } impl From for ShadowsRef { fn from( r: RawShadowsRef ) -> Self { match r { RawShadowsRef::Named( id ) => ShadowsRef::Named( id ), RawShadowsRef::Inline( items ) => ShadowsRef::Inline( items.into_iter().map( Into::into ).collect() ), } } } // ─── Surface ───────────────────────────────────────────────────────────────── #[ derive( Debug, Clone, Serialize, Deserialize ) ] #[ serde( deny_unknown_fields ) ] struct RawSurfaceBody { fill: Box, #[ serde( default, skip_serializing_if = "Option::is_none" ) ] shadows: Option, #[ serde( default ) ] inset_shadows: Vec, } impl From for Surface { fn from( r: RawSurfaceBody ) -> Self { Surface { fill: Paint::from( *r.fill ), shadows: r.shadows.map( Into::into ), inset_shadows: r.inset_shadows.into_iter().map( Into::into ).collect(), } } } // ─── Text style ────────────────────────────────────────────────────────────── #[ derive( Debug, Clone, Serialize, Deserialize ) ] #[ serde( untagged ) ] enum RawLineHeight { Px { px: f32 }, Multiplier { mul: f32 }, } impl From for LineHeight { fn from( r: RawLineHeight ) -> Self { match r { RawLineHeight::Px { px } => LineHeight::Px( px ), RawLineHeight::Multiplier { mul } => LineHeight::Multiplier( mul ), } } } #[ derive( Debug, Clone, Serialize, Deserialize ) ] #[ serde( deny_unknown_fields ) ] struct RawFontRef { r#ref: String, } impl From for FontRef { fn from( r: RawFontRef ) -> Self { FontRef::Named( r.r#ref ) } } #[ derive( Debug, Clone, Serialize, Deserialize ) ] #[ serde( deny_unknown_fields ) ] struct RawTextStyleBody { family: RawFontRef, weight: u16, #[ serde( default ) ] style: RawFontStyle, size: f32, line_height: RawLineHeight, #[ serde( default ) ] letter_spacing: f32, #[ serde( default ) ] transform: RawTextTransform, #[ serde( default ) ] decoration: RawTextDecoration, #[ serde( default, skip_serializing_if = "Option::is_none" ) ] color: Option, } impl From for TextStyle { fn from( r: RawTextStyleBody ) -> Self { TextStyle { family: r.family.into(), weight: r.weight, style: r.style.into(), size: r.size, line_height: r.line_height.into(), letter_spacing: r.letter_spacing, transform: r.transform.into(), decoration: r.decoration.into(), color: r.color, } } } // ─── Slot ──────────────────────────────────────────────────────────────────── #[ derive( Debug, Clone, Serialize, Deserialize ) ] #[ serde( tag = "type", rename_all = "kebab-case" ) ] enum RawSlot { Color { #[ serde( with = "color_serde" ) ] value: Color, #[ serde( default ) ] meta: RawMetadata, }, Linear { angle_deg: f32, #[ serde( default ) ] space: RawGradientSpace, stops: Vec, #[ serde( default ) ] meta: RawMetadata, }, Radial { center: [f32; 2], radius: f32, #[ serde( default ) ] space: RawGradientSpace, stops: Vec, #[ serde( default ) ] meta: RawMetadata, }, Shadows { shadows: Vec, #[ serde( default ) ] meta: RawMetadata, }, Surface { #[ serde( flatten ) ] body: RawSurfaceBody, #[ serde( default ) ] meta: RawMetadata, }, Typography { #[ serde( flatten ) ] body: RawTextStyleBody, #[ serde( default ) ] meta: RawMetadata, }, } impl From for Slot { fn from( r: RawSlot ) -> Self { match r { RawSlot::Color { value, meta } => Slot::Color { value, meta: meta.into() }, RawSlot::Linear { angle_deg, space, stops, meta } => Slot::Paint { value: Paint::Linear( LinearGradient { angle_deg, space: space.into(), stops: collect_capped_stops( stops ), }), meta: meta.into(), }, RawSlot::Radial { center, radius, space, stops, meta } => Slot::Paint { value: Paint::Radial( RadialGradient { center, radius, space: space.into(), stops: collect_capped_stops( stops ), }), meta: meta.into(), }, RawSlot::Shadows { shadows, meta } => Slot::Shadows { value: shadows.into_iter().map( Into::into ).collect(), meta: meta.into(), }, RawSlot::Surface { body, meta } => Slot::Surface { value: body.into(), meta: meta.into(), }, RawSlot::Typography { body, meta } => Slot::TextStyle { value: body.into(), meta: meta.into(), }, } } } // ─── Fonts block ───────────────────────────────────────────────────────────── #[ derive( Debug, Clone, Serialize, Deserialize ) ] #[ serde( deny_unknown_fields ) ] struct RawFontSource { weight: u16, #[ serde( default ) ] style: RawFontStyle, path: String, } #[ derive( Debug, Clone, Serialize, Deserialize ) ] #[ serde( deny_unknown_fields ) ] struct RawFontFamily { name: String, #[ serde( default ) ] fallbacks: Vec, #[ serde( default ) ] sources: Vec, } fn family_from_raw( root: Option<&Path>, r: RawFontFamily ) -> FontFamilyDef { FontFamilyDef { name: r.name, fallbacks: r.fallbacks, sources: r.sources.into_iter().map( |s| FontSource { weight: s.weight, style: s.style.into(), path: resolve_relative( root, &s.path ), }).collect(), } } // ─── Wallpaper / window_controls ───────────────────────────────────────────── #[ derive( Debug, Clone, Serialize, Deserialize ) ] #[ serde( deny_unknown_fields ) ] struct RawWallpaper { path: String, #[ serde( default ) ] fit: WallpaperFit, } #[ derive( Debug, Clone, Serialize, Deserialize ) ] #[ serde( deny_unknown_fields ) ] struct RawLauncher { background: String, border_radius: f32, } #[ derive( Debug, Clone, Serialize, Deserialize, Default ) ] #[ serde( deny_unknown_fields ) ] struct RawWindowControls { #[ serde( default ) ] icon: Option, #[ serde( default ) ] hover_bg: Option, #[ serde( default ) ] pressed_bg: Option, #[ serde( default ) ] close_hover_bg: Option, #[ serde( default ) ] close_icon: Option, #[ serde( default ) ] focus_ring: Option, } // ─── Mode / Modes / Document ───────────────────────────────────────────────── #[ derive( Debug, Clone, Serialize, Deserialize ) ] #[ serde( deny_unknown_fields ) ] struct RawMode { #[ serde( default, skip_serializing_if = "Option::is_none" ) ] wallpaper: Option, #[ serde( default, skip_serializing_if = "Option::is_none" ) ] lockscreen: Option, #[ serde( default, skip_serializing_if = "Option::is_none" ) ] launcher: Option, #[ serde( default, skip_serializing_if = "Option::is_none" ) ] window_controls: Option, #[ serde( default ) ] slots: HashMap, } #[ derive( Debug, Clone, Serialize, Deserialize ) ] #[ serde( deny_unknown_fields ) ] struct RawModes { light: RawMode, dark: RawMode, } #[ derive( Debug, Clone, Serialize, Deserialize ) ] #[ serde( deny_unknown_fields ) ] struct RawThemeMeta { id: String, name: String, } #[ derive( Debug, Clone, Serialize, Deserialize ) ] #[ serde( deny_unknown_fields ) ] struct RawThemeDocument { theme: RawThemeMeta, #[ serde( default ) ] fonts: HashMap, modes: RawModes, } // ─── Conversion from raw to runtime types ──────────────────────────────────── fn wallpaper_from_raw( root: Option<&Path>, r: RawWallpaper ) -> WallpaperSpec { WallpaperSpec { path: Some( resolve_relative( root, &r.path ) ), fit: r.fit } } fn window_controls_from_raw ( fallback_palette: Option<&super::Palette>, _mode: ThemeMode, raw: RawWindowControls, ) -> Result { // When the mode has no palette to derive sensible defaults from, we fall // back to neutral black-on-white defaults for the fields the author did // not override. let fallback = match fallback_palette { Some( p ) => default_window_controls( *p ), None => WindowControlsSpec { icon: Color::BLACK, hover_bg: Color::rgba( 0.0, 0.0, 0.0, 0.08 ), pressed_bg: Color::rgba( 0.0, 0.0, 0.0, 0.12 ), close_hover_bg: Color::rgba( 0.92, 0.18, 0.18, 0.90 ), close_icon: Color::WHITE, focus_ring: Color::hex( 0x04, 0xD9, 0xFE ), }, }; Ok( WindowControlsSpec { icon: parse_opt( raw.icon.as_deref(), fallback.icon )?, hover_bg: parse_opt( raw.hover_bg.as_deref(), fallback.hover_bg )?, pressed_bg: parse_opt( raw.pressed_bg.as_deref(), fallback.pressed_bg )?, close_hover_bg: parse_opt( raw.close_hover_bg.as_deref(), fallback.close_hover_bg )?, close_icon: parse_opt( raw.close_icon.as_deref(), fallback.close_icon )?, focus_ring: parse_opt( raw.focus_ring.as_deref(), fallback.focus_ring )?, }) } fn parse_opt( s: Option<&str>, fallback: Color ) -> Result { match s { Some( v ) => parse_color_str( v ).map_err( ThemeError::InvalidColor ), None => Ok( fallback ), } } fn mode_from_raw( root: Option<&Path>, r: RawMode ) -> Result { let mut store = SlotStore::new(); for ( id, raw ) in r.slots { store.insert( id, Slot::from( raw ) ); } let wallpaper = r.wallpaper.map( |w| wallpaper_from_raw( root, w ) ); let lockscreen = r.lockscreen.map( |w| wallpaper_from_raw( root, w ) ); let launcher = match r.launcher { Some( l ) => Some( LauncherSpec { background: parse_color_str( &l.background ).map_err( ThemeError::InvalidColor )?, border_radius: l.border_radius, }), None => None, }; let window_controls = match r.window_controls { Some( raw ) => Some( window_controls_from_raw( None, ThemeMode::Light, raw )? ), None => None, }; Ok( Mode { wallpaper, lockscreen, launcher, window_controls, slots: store } ) } // ─── Public entry points ───────────────────────────────────────────────────── /// Parse a theme document from its JSON source text. /// /// `root` is the directory the document was read from, used to resolve /// relative paths (wallpaper images, font files) to absolute ones. Pass /// `None` when parsing in-memory fixtures from tests. /// /// Performs the colour-reference resolution pass before structural /// deserialisation: any string of the form `@name` or `@name/AA` (where /// `AA` is a two-digit hex alpha override) is rewritten to its literal /// hex form by looking `name` up in the top-level `colors` object. The /// rewritten JSON is then deserialised into [`RawThemeDocument`] and /// converted to the runtime types as before. pub fn parse_document_json( text: &str, root: Option<&Path> ) -> Result { let mut value: serde_json::Value = serde_json::from_str( text ).map_err( |e| ThemeError::ParseJson( root.map( Path::to_path_buf ).unwrap_or_default(), e ) )?; let colors = extract_colors_map( &value )?; let gradients = extract_gradients_map( &value )?; let inset_stacks = extract_inset_stacks_map( &value )?; // Gradients live in objects (`{ "type": "linear", … }`) and inset stacks // live in arrays (`[ { offset, blur, … }, … ]`); the resolver does not // care about the shape, so the two sections share a single internal // lookup. Names must therefore be unique across both — a collision is // rejected up front rather than letting `@foo` resolve ambiguously. let mut tokens = gradients; for ( k, v ) in inset_stacks { if tokens.contains_key( &k ) { return Err( ThemeError::InvalidColor( format!( "name `{}` is defined in both `gradients` and `inset_stacks`", k ))); } tokens.insert( k, v ); } // Pre-resolve `@color` references that live inside token bodies so // subsequent substitutions are flat clones with no recursion. Tokens // cannot reference each other, so we resolve against an empty token // map here. let empty_tokens = HashMap::new(); for ( _, t ) in tokens.iter_mut() { resolve_refs( t, &colors, &empty_tokens )?; } // Drop the top-level palette sections before resolving the rest, so the // walk does not waste cycles on entries that are about to be discarded // and `RawThemeDocument`'s `deny_unknown_fields` does not reject them. if let serde_json::Value::Object( ref mut map ) = value { map.remove( "colors" ); map.remove( "gradients" ); map.remove( "inset_stacks" ); } resolve_refs( &mut value, &colors, &tokens )?; let raw: RawThemeDocument = serde_json::from_value( value ).map_err( |e| ThemeError::ParseJson( root.map( Path::to_path_buf ).unwrap_or_default(), e ) )?; let fonts = raw.fonts .into_iter() .map( |( k, v )| ( k, family_from_raw( root, v ) ) ) .collect(); let light = mode_from_raw( root, raw.modes.light )?; let dark = mode_from_raw( root, raw.modes.dark )?; Ok( ThemeDocument { id: raw.theme.id, name: raw.theme.name, root: root.map( Path::to_path_buf ), fonts, light, dark, }) } /// Read the top-level `colors` object from the raw JSON value and validate /// each entry is a literal hex string. Returns an empty map if no `colors` /// section is present. fn extract_colors_map( value: &serde_json::Value ) -> Result, ThemeError> { let mut out = HashMap::new(); let raw = match value.get( "colors" ) { Some( c ) => c, None => return Ok( out ), }; let map = raw.as_object().ok_or_else( || ThemeError::InvalidColor( "`colors` must be an object".to_string() ) )?; for ( k, v ) in map { let s = v.as_str().ok_or_else( || ThemeError::InvalidColor( format!( "`colors.{}` must be a hex string", k ) ) )?; // References inside `colors` itself are not supported — every entry // must be a literal hex value the rest of the document can point at. let h = s.trim_start_matches( '#' ); let valid = ( h.len() == 6 || h.len() == 8 ) && h.chars().all( |c| c.is_ascii_hexdigit() ); if !valid { return Err( ThemeError::InvalidColor( format!( "`colors.{}` = `{}` (expected #RRGGBB or #RRGGBBAA)", k, s ) )); } out.insert( k.clone(), s.to_string() ); } Ok( out ) } /// Read the top-level `gradients` object from the raw JSON value. Each /// entry must be a paint object (`{ "type": "linear", "angle_deg": …, /// "stops": [ … ] }`); shape correctness beyond "is an object" is left /// for downstream serde deserialisation to enforce when the gradient is /// substituted into a paint position. fn extract_gradients_map( value: &serde_json::Value ) -> Result, ThemeError> { extract_token_map( value, "gradients", "paint object", serde_json::Value::is_object ) } /// Read the top-level `inset_stacks` object from the raw JSON value. Each /// entry must be an array of inset-shadow definitions — substituted /// wholesale into an `inset_shadows` field on a surface. fn extract_inset_stacks_map( value: &serde_json::Value ) -> Result, ThemeError> { extract_token_map( value, "inset_stacks", "array of inset-shadow definitions", serde_json::Value::is_array ) } /// Generic helper for the `gradients` / `inset_stacks` extractors above: /// reads a top-level object, validates each entry against `valid`, and /// returns the entries cloned into a `HashMap`. Returns an empty map if /// `section` is absent. fn extract_token_map( value: &serde_json::Value, section: &str, expected_kind: &str, valid: impl Fn( &serde_json::Value ) -> bool, ) -> Result, ThemeError> { let mut out = HashMap::new(); let raw = match value.get( section ) { Some( c ) => c, None => return Ok( out ), }; let map = raw.as_object().ok_or_else( || ThemeError::InvalidColor( format!( "`{}` must be an object", section ) ) )?; for ( k, v ) in map { if !valid( v ) { return Err( ThemeError::InvalidColor( format!( "`{}.{}` must be {}", section, k, expected_kind ) )); } out.insert( k.clone(), v.clone() ); } Ok( out ) } /// Walk `value` and replace `@name` / `@name/AA` references with their /// resolved form. A reference whose name appears in `tokens` (gradient /// objects or inset-shadow arrays) is substituted by the cloned token /// value; a reference whose name appears in `colors` is substituted by /// its hex literal. After a substitution the new node is recursed into /// so a gradient that uses `@cyan-soft` in its stops or an inset stack /// that uses `@glass-hi` is fully expanded. fn resolve_refs( value: &mut serde_json::Value, colors: &HashMap, tokens: &HashMap, ) -> Result<(), ThemeError> { // Step 1: if this node is a `@ref` string, resolve and overwrite. let replacement = match value { serde_json::Value::String( s ) => match s.strip_prefix( '@' ) { Some( rest ) => Some( resolve_one_ref( rest, colors, tokens )? ), None => None, }, _ => None, }; if let Some( new_value ) = replacement { *value = new_value; // The replacement may itself contain references (typically a // gradient with `@color` stops). Recurse into the new node. resolve_refs( value, colors, tokens )?; return Ok( () ); } // Step 2: walk children of objects / arrays. match value { serde_json::Value::Object( map ) => { for ( _, v ) in map.iter_mut() { resolve_refs( v, colors, tokens )?; } } serde_json::Value::Array( arr ) => { for v in arr.iter_mut() { resolve_refs( v, colors, tokens )?; } } _ => {} } Ok( () ) } /// Resolve a single `@name[/AA]` reference. Looks up `tokens` first /// (gradient or inset-stack), then `colors`. The `/AA` alpha override /// only applies to colour references; using it on a non-colour token is /// rejected. fn resolve_one_ref( rest: &str, colors: &HashMap, tokens: &HashMap, ) -> Result { let ( name, alpha_hex ) = match rest.split_once( '/' ) { Some( ( n, a ) ) => ( n, Some( a ) ), None => ( rest, None ), }; if let Some( tok ) = tokens.get( name ) { if let Some( a ) = alpha_hex { return Err( ThemeError::InvalidColor( format!( "@{} is a paint/inset token — alpha override `/{}` is not applicable", name, a ))); } return Ok( tok.clone() ); } let base = colors.get( name ) .ok_or_else( || ThemeError::UnknownColorRef( name.to_string() ) )?; let h = base.trim_start_matches( '#' ); // `extract_colors_map` already validated the base is 6 or 8 hex digits. let rgb = &h[0..6]; let s = match alpha_hex { Some( a ) => { if a.len() != 2 || u8::from_str_radix( a, 16 ).is_err() { return Err( ThemeError::InvalidColor( format!( "@{}/{} (alpha must be two hex digits)", name, a ) )); } format!( "#{}{}", rgb.to_uppercase(), a.to_uppercase() ) } None => format!( "#{}", h.to_uppercase() ), }; Ok( serde_json::Value::String( s ) ) } /// Load a theme document from a directory containing a `theme.json`. pub fn load_document_from_dir( dir: &Path ) -> Result { let json_path = dir.join( "theme.json" ); let text = std::fs::read_to_string( &json_path ) .map_err( |e| ThemeError::Io( json_path.clone(), e ) )?; parse_document_json( &text, Some( dir ) ) } // ─── Helpers ───────────────────────────────────────────────────────────────── fn resolve_relative( root: Option<&Path>, rel: &str ) -> PathBuf { let p = Path::new( rel ); if p.is_absolute() { return p.to_path_buf(); } match root { Some( r ) => r.join( p ), None => p.to_path_buf(), } } // ─── Tests ─────────────────────────────────────────────────────────────────── #[ cfg( test ) ] mod tests { use super::*; #[ test ] fn color_parser_accepts_hex_and_functional() { assert_eq!( color_serde::parse( "#04D9FE" ).unwrap(), Color::hex( 0x04, 0xD9, 0xFE ) ); assert_eq!( color_serde::parse( "04D9FE" ).unwrap(), Color::hex( 0x04, 0xD9, 0xFE ) ); let with_a = color_serde::parse( "#FFFFFF80" ).unwrap(); assert!( ( with_a.a - 128.0 / 255.0 ).abs() < 1e-6 ); let f = color_serde::parse( "rgba(255, 147, 169, 0.8)" ).unwrap(); assert_eq!( f.r, 1.0 ); assert!( ( f.g - 147.0 / 255.0 ).abs() < 1e-6 ); assert!( ( f.a - 0.8 ).abs() < 1e-6 ); let three = color_serde::parse( "rgb(0, 0, 0)" ).unwrap(); assert_eq!( three, Color::BLACK ); } #[ test ] fn color_parser_rejects_bad_input() { assert!( color_serde::parse( "" ).is_err() ); assert!( color_serde::parse( "#FFF" ).is_err() ); assert!( color_serde::parse( "rgba(1,2,3)" ).is_err() ); assert!( color_serde::parse( "rgba(1,2,3,4,5)" ).is_err() ); assert!( color_serde::parse( "rgba(300, 0, 0, 1)" ).is_err() ); } #[ test ] fn color_formatter_is_canonical() { assert_eq!( color_serde::format( Color::hex( 0x04, 0xD9, 0xFE ) ), "#04D9FE" ); let semi = Color { r: 1.0, g: 1.0, b: 1.0, a: 0.5 }; assert_eq!( color_serde::format( semi ), "#FFFFFF80" ); } #[ test ] fn slot_color_parses_with_meta() { let json = r##" { "type": "color", "value": "#04D9FE", "meta": { "semantic": "primary/500" } } "##; let raw: RawSlot = serde_json::from_str( json ).unwrap(); let slot = Slot::from( raw ); match slot { Slot::Color { value, meta } => { assert_eq!( value, Color::hex( 0x04, 0xD9, 0xFE ) ); assert_eq!( meta.semantic.as_deref(), Some( "primary/500" ) ); } other => panic!( "expected color slot, got {:?}", other ), } } #[ test ] fn gradient_under_cap_is_passed_through_verbatim() { let raw_stops: Vec = ( 0..16 ) .map( |i| serde_json::from_str( &format!( r##"{{ "pos": {}, "color": "#FFFFFFFF" }}"##, i as f32 / 15.0 ), ).unwrap() ) .collect(); let result = collect_capped_stops( raw_stops ); assert_eq!( result.len(), 16, "stops below the soft cap pass through unchanged" ); } #[ test ] fn gradient_at_exact_cap_is_passed_through() { let raw_stops: Vec = ( 0..MAX_GRADIENT_STOPS ) .map( |i| serde_json::from_str( &format!( r##"{{ "pos": {}, "color": "#FFFFFFFF" }}"##, i as f32 / 63.0 ), ).unwrap() ) .collect(); let result = collect_capped_stops( raw_stops ); assert_eq!( result.len(), MAX_GRADIENT_STOPS ); } #[ test ] fn gradient_over_cap_is_truncated_to_cap() { let raw_stops: Vec = ( 0..200 ) .map( |i| serde_json::from_str( &format!( r##"{{ "pos": {}, "color": "#000000FF" }}"##, i as f32 ), ).unwrap() ) .collect(); let result = collect_capped_stops( raw_stops ); assert_eq!( result.len(), MAX_GRADIENT_STOPS, "tail past the cap must be dropped" ); } #[ test ] fn slot_linear_gradient_accepts_extrapolated_stops() { let json = r##" { "type": "linear", "angle_deg": 152.77, "space": "linear-rgb", "stops": [ { "pos": -1.1654, "color": "rgba(255, 147, 169, 0.8)" }, { "pos": 1.2332, "color": "rgba(255, 255, 255, 0.8)" } ] } "##; let raw: RawSlot = serde_json::from_str( json ).unwrap(); match Slot::from( raw ) { Slot::Paint { value: Paint::Linear( grad ), .. } => { assert_eq!( grad.stops.len(), 2 ); assert!( grad.stops[0].position < 0.0 ); assert!( grad.stops[1].position > 1.0 ); assert_eq!( grad.space, GradientSpace::LinearRgb ); } _ => panic!( "expected linear paint" ), } } #[ test ] fn slot_shadows_parses_stack() { let json = r##" { "type": "shadows", "shadows": [ { "offset": [0, 4], "blur": 10, "color": "#00000014" }, { "offset": [0, 1], "blur": 4, "color": "#0000000A" } ] } "##; let raw: RawSlot = serde_json::from_str( json ).unwrap(); match Slot::from( raw ) { Slot::Shadows { value, .. } => { assert_eq!( value.len(), 2 ); assert_eq!( value[0].offset, [ 0.0, 4.0 ] ); assert_eq!( value[0].blur, 10.0 ); assert_eq!( value[0].blend, BlendMode::Normal ); } _ => panic!( "expected shadows slot" ), } } #[ test ] fn slot_surface_reproduces_glass_accent() { let json = r##" { "type": "surface", "fill": { "type": "solid", "color": "#04D9FE" }, "shadows": "shadows-glass", "inset_shadows": [ { "offset": [-3.6, -3.6], "blur": 13.5, "color": "#555555", "blend": "plus-lighter" }, { "offset": [ 1.8, 1.8], "blur": 1.8, "color": "#555555", "blend": "plus-lighter" }, { "offset": [ 0.45, 0.45], "blur": 0.9, "color": "#000000", "blend": "overlay" }, { "offset": [ 1.8, 1.8], "blur": 7.2, "color": "#00000026", "blend": "normal" } ], "backdrop": { "blur_px": 22.5 } } "##; let raw: RawSlot = serde_json::from_str( json ).unwrap(); match Slot::from( raw ) { Slot::Surface { value, .. } => { assert!( matches!( value.fill, Paint::Solid( _ ) ) ); assert_eq!( value.inset_shadows.len(), 4 ); assert_eq!( value.inset_shadows[0].blend, BlendMode::PlusLighter ); assert_eq!( value.inset_shadows[2].blend, BlendMode::Overlay ); match value.shadows.as_ref().unwrap() { ShadowsRef::Named( id ) => assert_eq!( id, "shadows-glass" ), other => panic!( "expected named shadow ref, got {:?}", other ), } } _ => panic!( "expected surface slot" ), } } #[ test ] fn slot_typography_parses_body_m() { let json = r##" { "type": "typography", "family": { "ref": "sora" }, "weight": 400, "size": 16, "line_height": { "px": 24 }, "letter_spacing": 0, "meta": { "semantic": "body/m" } } "##; let raw: RawSlot = serde_json::from_str( json ).unwrap(); match Slot::from( raw ) { Slot::TextStyle { value, meta } => { assert_eq!( value.weight, 400 ); assert_eq!( value.size, 16.0 ); assert_eq!( value.line_height, LineHeight::Px( 24.0 ) ); assert_eq!( meta.semantic.as_deref(), Some( "body/m" ) ); } _ => panic!( "expected typography slot" ), } } #[ test ] fn document_parses_minimal_two_modes() { let json = r##" { "theme": { "id": "demo", "name": "Demo" }, "fonts": { "sora": { "name": "Sora", "fallbacks": ["system-ui", "sans-serif"], "sources": [ { "weight": 400, "path": "fonts/Sora-Regular.ttf" } ] } }, "modes": { "light": { "slots": { "primary-500": { "type": "color", "value": "#04D9FE" }, "body-m": { "type": "typography", "family": { "ref": "sora" }, "weight": 400, "size": 16, "line_height": { "px": 24 } } } }, "dark": { "slots": { "primary-500": { "type": "color", "value": "#04D9FE" } } } } } "##; let doc = parse_document_json( json, None ).unwrap(); assert_eq!( doc.id, "demo" ); assert_eq!( doc.name, "Demo" ); assert_eq!( doc.fonts.len(), 1 ); assert_eq!( doc.fonts["sora"].sources[0].weight, 400 ); assert_eq!( doc.light.slots.len(), 2 ); assert_eq!( doc.dark.slots.len(), 1 ); assert!( doc.light.slots.color( "primary-500" ).is_some() ); assert!( doc.light.slots.text_style( "body-m" ).is_some() ); } #[ test ] fn unknown_fields_in_stop_are_rejected() { // `deny_unknown_fields` doesn't cooperate well with // `#[ serde( flatten ) ]`, so the slot-level check would be unreliable // on variants that flatten a body struct. The safety net lives on // concrete nested types like `RawColorStop`, `RawShadow`, and // `RawWallpaper`, which reject typos outright. let bad = r##" { "type": "linear", "angle_deg": 90, "stops": [ { "pos": 0, "color": "#FFFFFF", "extraneous": true } ] } "##; assert!( serde_json::from_str::( bad ).is_err() ); } }