diff --git a/e2e/app/index.html b/e2e/app/index.html
index fe1fe10..996a579 100644
--- a/e2e/app/index.html
+++ b/e2e/app/index.html
@@ -103,17 +103,6 @@
}
};
- // `Color` serializes untagged: a named colour or hex arrives as a string,
- // an rgba arrives as `{r, g, b, a}`.
- function cssColor(value) {
- if (value == null) return null;
- if (typeof value === 'string') return value;
- if (typeof value === 'object' && value.r != null) {
- return `rgba(${value.r}, ${value.g}, ${value.b}, ${value.a})`;
- }
- return String(value);
- }
-
function renderWidget(node) {
if (!node || typeof node !== 'object') {
return document.createTextNode(String(node || ''));
@@ -576,7 +565,7 @@
if (node.padding != null) el.style.padding = node.padding + 'px';
if (node.width) el.style.width = node.width;
if (node.height) el.style.height = node.height;
- const background = cssColor(node.background);
+ const background = node.background;
if (background) {
el.style.background = background;
el.setAttribute('data-background', background);
@@ -594,7 +583,7 @@
el.setAttribute('data-size', String(node.size));
el.style.fontSize = node.size + 'px';
}
- const color = cssColor(node.color);
+ const color = node.color;
if (color) {
el.setAttribute('data-color', color);
el.style.color = color;
diff --git a/rusty/src/shared/color.rs b/rusty/src/shared/color.rs
index 7944e16..4960ee0 100644
--- a/rusty/src/shared/color.rs
+++ b/rusty/src/shared/color.rs
@@ -1,8 +1,13 @@
-use serde::{Deserialize, Serialize};
+use serde::de::{self, Visitor};
+use serde::{Deserialize, Deserializer, Serialize, Serializer};
/// A color value supporting named colors, hex, and RGBA.
-#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
-#[serde(untagged)]
+///
+/// Serializes to a CSS color string (`"primary"`, `"#ff0000"`, `"rgba(1, 2, 3, 0.5)"`) and
+/// deserializes back from one, so `Serialize` and [`Color::to_css`] agree and the
+/// value round-trips. A `#[serde(untagged)]` derive would emit named/hex as strings
+/// but rgba as an object `{"r":1,"g":2,"b":3,"a":0.5}`, forcing clients to sniff the type.
+#[derive(Debug, Clone, PartialEq)]
pub enum Color {
Named(NamedColor),
Hex(String),
@@ -23,6 +28,39 @@ pub enum NamedColor {
Black,
}
+impl NamedColor {
+ /// The camelCase wire name, matching the `rename_all` derive.
+ pub fn as_str(&self) -> &'static str {
+ match self {
+ NamedColor::Primary => "primary",
+ NamedColor::Secondary => "secondary",
+ NamedColor::Success => "success",
+ NamedColor::Warning => "warning",
+ NamedColor::Danger => "danger",
+ NamedColor::Info => "info",
+ NamedColor::Muted => "muted",
+ NamedColor::White => "white",
+ NamedColor::Black => "black",
+ }
+ }
+
+ /// Parse a camelCase wire name back into a variant.
+ pub fn parse(value: &str) -> Option {
+ match value {
+ "primary" => Some(NamedColor::Primary),
+ "secondary" => Some(NamedColor::Secondary),
+ "success" => Some(NamedColor::Success),
+ "warning" => Some(NamedColor::Warning),
+ "danger" => Some(NamedColor::Danger),
+ "info" => Some(NamedColor::Info),
+ "muted" => Some(NamedColor::Muted),
+ "white" => Some(NamedColor::White),
+ "black" => Some(NamedColor::Black),
+ _ => None,
+ }
+ }
+}
+
impl Color {
pub fn hex(value: &str) -> Self {
Color::Hex(value.to_string())
@@ -31,6 +69,71 @@ impl Color {
pub fn rgba(r: u8, g: u8, b: u8, a: f32) -> Self {
Color::Rgba { r, g, b, a }
}
+
+ /// Render as a CSS color. This is the wire form: `Serialize` emits exactly
+ /// this string.
+ pub fn to_css(&self) -> String {
+ match self {
+ Color::Named(named) => named.as_str().to_string(),
+ Color::Hex(hex) => hex.clone(),
+ Color::Rgba { r, g, b, a } => format!("rgba({}, {}, {}, {})", r, g, b, a),
+ }
+ }
+
+ /// Parse a CSS color string produced by [`Color::to_css`].
+ pub fn parse_css(value: &str) -> Option {
+ let trimmed = value.trim();
+ if let Some(named) = NamedColor::parse(trimmed) {
+ return Some(Color::Named(named));
+ }
+ if trimmed.starts_with('#') {
+ return Some(Color::Hex(trimmed.to_string()));
+ }
+ if let Some(args) = trimmed
+ .strip_prefix("rgba(")
+ .and_then(|rest| rest.strip_suffix(')'))
+ {
+ let parts: Vec<&str> = args.split(',').map(str::trim).collect();
+ if parts.len() == 4 {
+ return Some(Color::Rgba {
+ r: parts[0].parse().ok()?,
+ g: parts[1].parse().ok()?,
+ b: parts[2].parse().ok()?,
+ a: parts[3].parse().ok()?,
+ });
+ }
+ }
+ None
+ }
+}
+
+impl Serialize for Color {
+ fn serialize(&self, serializer: S) -> Result {
+ serializer.serialize_str(&self.to_css())
+ }
+}
+
+impl<'de> Deserialize<'de> for Color {
+ fn deserialize>(deserializer: D) -> Result {
+ struct ColorVisitor;
+
+ impl Visitor<'_> for ColorVisitor {
+ type Value = Color;
+
+ fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
+ f.write_str(
+ "a CSS color such as \"primary\", \"#ff0000\" or \"rgba(1, 2, 3, 0.5)\"",
+ )
+ }
+
+ fn visit_str(self, value: &str) -> Result {
+ Color::parse_css(value)
+ .ok_or_else(|| de::Error::invalid_value(de::Unexpected::Str(value), &self))
+ }
+ }
+
+ deserializer.deserialize_str(ColorVisitor)
+ }
}
impl From for Color {
@@ -59,4 +162,52 @@ mod tests {
panic!("Expected hex color");
}
}
+
+ #[test]
+ fn test_color_serialize_matches_to_css() {
+ assert_eq!(
+ serde_json::to_string(&Color::Named(NamedColor::Primary)).unwrap(),
+ "\"primary\""
+ );
+ assert_eq!(
+ serde_json::to_string(&Color::Hex("#ff0000".to_string())).unwrap(),
+ "\"#ff0000\""
+ );
+ assert_eq!(
+ serde_json::to_string(&Color::Rgba {
+ r: 1,
+ g: 2,
+ b: 3,
+ a: 0.5
+ })
+ .unwrap(),
+ "\"rgba(1, 2, 3, 0.5)\""
+ );
+ }
+
+ #[test]
+ fn test_color_round_trips() {
+ let named = Color::Named(NamedColor::Primary);
+ let json = serde_json::to_string(&named).unwrap();
+ assert_eq!(serde_json::from_str::(&json).unwrap(), named);
+
+ let hex = Color::Hex("#ff0000".to_string());
+ let json = serde_json::to_string(&hex).unwrap();
+ assert_eq!(serde_json::from_str::(&json).unwrap(), hex);
+
+ let rgba = Color::Rgba {
+ r: 1,
+ g: 2,
+ b: 3,
+ a: 0.5,
+ };
+ let json = serde_json::to_string(&rgba).unwrap();
+ assert_eq!(serde_json::from_str::(&json).unwrap(), rgba);
+ }
+
+ #[test]
+ fn test_color_deserialize_rejects_object_and_garbage() {
+ assert!(serde_json::from_str::("{\"r\":1,\"g\":2,\"b\":3,\"a\":1.0}").is_err());
+ assert!(serde_json::from_str::("\"notacolor\"").is_err());
+ }
}
diff --git a/rusty/src/shared/types.rs b/rusty/src/shared/types.rs
index 5507157..f1e474c 100644
--- a/rusty/src/shared/types.rs
+++ b/rusty/src/shared/types.rs
@@ -1,12 +1,17 @@
-use serde::{Deserialize, Serialize};
+use serde::de::{self, Visitor};
+use serde::{Deserialize, Deserializer, Serialize, Serializer};
/// Unique identifier for views and widgets.
pub type ViewId = uuid::Uuid;
pub type WidgetId = uuid::Uuid;
/// Size specification for widgets.
-#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
-#[serde(untagged)]
+///
+/// Serializes to a CSS length string (`"8px"`, `"50%"`, `"auto"`) and
+/// deserializes back from one, so `Serialize` and [`Size::to_css`] agree and the
+/// value round-trips. A `#[serde(untagged)]` derive would collapse `Px(8.0)` and
+/// `Percent(8.0)` to a bare `8.0` and `Auto` to `null`.
+#[derive(Debug, Clone, PartialEq)]
pub enum Size {
Px(f64),
Percent(f64),
@@ -14,10 +19,8 @@ pub enum Size {
}
impl Size {
- /// Render as a CSS length. Widgets serialize sizes through this rather than
- /// through `Serialize`: the derive is `untagged`, so `Px(8.0)` and
- /// `Percent(8.0)` both emit a bare `8.0` and `Auto` emits `null`, leaving a
- /// client unable to tell pixels from percent or `Auto` from unset.
+ /// Render as a CSS length. This is the wire form: `Serialize` emits exactly
+ /// this string.
pub fn to_css(&self) -> String {
match self {
Size::Px(px) => format!("{}px", px),
@@ -25,16 +28,53 @@ impl Size {
Size::Auto => "auto".to_string(),
}
}
+
+ /// Parse a CSS length string produced by [`Size::to_css`].
+ pub fn parse_css(value: &str) -> Option {
+ let trimmed = value.trim();
+ if trimmed.eq_ignore_ascii_case("auto") {
+ return Some(Size::Auto);
+ }
+ if let Some(num) = trimmed.strip_suffix('%') {
+ return num.trim().parse::().ok().map(Size::Percent);
+ }
+ if let Some(num) = trimmed.strip_suffix("px") {
+ return num.trim().parse::().ok().map(Size::Px);
+ }
+ None
+ }
+}
+
+impl Serialize for Size {
+ fn serialize(&self, serializer: S) -> Result {
+ serializer.serialize_str(&self.to_css())
+ }
+}
+
+impl<'de> Deserialize<'de> for Size {
+ fn deserialize>(deserializer: D) -> Result {
+ struct SizeVisitor;
+
+ impl Visitor<'_> for SizeVisitor {
+ type Value = Size;
+
+ fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
+ f.write_str("a CSS length such as \"8px\", \"50%\" or \"auto\"")
+ }
+
+ fn visit_str(self, value: &str) -> Result {
+ Size::parse_css(value)
+ .ok_or_else(|| de::Error::invalid_value(de::Unexpected::Str(value), &self))
+ }
+ }
+
+ deserializer.deserialize_str(SizeVisitor)
+ }
}
/// Render an optional [`Size`] as its CSS string, for `#[prop(with = ...)]`.
///
/// `Size`'s derived `Serialize` is `untagged` and therefore lossy on the wire
-/// (`Px(8.0)` and `Percent(8.0)` both become `8.0`), so widgets emit the CSS
-/// form instead. See [`Size::to_css`].
-pub fn size_css(size: &Option) -> Option {
- size.as_ref().map(Size::to_css)
-}
/// Density level for widget rendering.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
@@ -80,10 +120,40 @@ mod tests {
}
#[test]
- fn test_size_serialize_is_lossy_so_widgets_use_to_css() {
- // Documents why `to_css` exists: `untagged` collapses the variants.
- assert_eq!(serde_json::to_string(&Size::Px(8.0)).unwrap(), "8.0");
- assert_eq!(serde_json::to_string(&Size::Percent(8.0)).unwrap(), "8.0");
- assert_eq!(serde_json::to_string(&Size::Auto).unwrap(), "null");
+ fn test_size_serialize_matches_to_css() {
+ assert_eq!(serde_json::to_string(&Size::Px(8.0)).unwrap(), "\"8px\"");
+ assert_eq!(
+ serde_json::to_string(&Size::Percent(8.0)).unwrap(),
+ "\"8%\""
+ );
+ assert_eq!(serde_json::to_string(&Size::Auto).unwrap(), "\"auto\"");
+ }
+
+ #[test]
+ fn test_size_round_trips() {
+ let px = Size::Px(8.0);
+ let json = serde_json::to_string(&px).unwrap();
+ assert_eq!(serde_json::from_str::(&json).unwrap(), px);
+
+ let percent = Size::Percent(50.0);
+ let json = serde_json::to_string(&percent).unwrap();
+ assert_eq!(serde_json::from_str::(&json).unwrap(), percent);
+
+ let auto = Size::Auto;
+ let json = serde_json::to_string(&auto).unwrap();
+ assert_eq!(serde_json::from_str::(&json).unwrap(), auto);
+ }
+
+ #[test]
+ fn test_size_deserialize_rejects_bare_number_and_unknown_unit() {
+ assert!(serde_json::from_str::("8.0").is_err());
+ assert!(serde_json::from_str::("null").is_err());
+ assert!(serde_json::from_str::("\"8em\"").is_err());
+ }
+
+ #[test]
+ fn test_size_option_none_is_null() {
+ let none: Option = None;
+ assert_eq!(serde_json::to_string(&none).unwrap(), "null");
}
}
diff --git a/rusty/src/widgets/container.rs b/rusty/src/widgets/container.rs
index fc0bb39..b69d1b0 100644
--- a/rusty/src/widgets/container.rs
+++ b/rusty/src/widgets/container.rs
@@ -88,8 +88,8 @@ impl WidgetData for Container {
.map(|c| serde_json::to_value(c).unwrap_or_default())
.collect::>(),
"padding": self.padding,
- "width": self.width.as_ref().map(Size::to_css),
- "height": self.height.as_ref().map(Size::to_css),
+ "width": self.width,
+ "height": self.height,
"background": self.background,
"border": self.border,
"rounded": self.rounded,
diff --git a/rusty/src/widgets/image.rs b/rusty/src/widgets/image.rs
index 402fc32..e69b286 100644
--- a/rusty/src/widgets/image.rs
+++ b/rusty/src/widgets/image.rs
@@ -59,8 +59,8 @@ impl WidgetData for Image {
"id": self.id,
"src": self.src,
"alt": self.alt,
- "width": self.width.as_ref().map(Size::to_css),
- "height": self.height.as_ref().map(Size::to_css),
+ "width": self.width,
+ "height": self.height,
})
}
diff --git a/rusty/src/widgets/layout.rs b/rusty/src/widgets/layout.rs
index 4f18519..ab5b66d 100644
--- a/rusty/src/widgets/layout.rs
+++ b/rusty/src/widgets/layout.rs
@@ -36,10 +36,10 @@ pub struct Layout {
#[prop]
#[serde(skip_serializing_if = "Option::is_none")]
pub columns: Option,
- #[prop(with = "crate::shared::size_css")]
+ #[prop]
#[serde(skip_serializing_if = "Option::is_none")]
pub width: Option,
- #[prop(with = "crate::shared::size_css")]
+ #[prop]
#[serde(skip_serializing_if = "Option::is_none")]
pub height: Option,
#[prop]
diff --git a/rusty/src/widgets/skeleton.rs b/rusty/src/widgets/skeleton.rs
index 5ca1997..bf4f4c2 100644
--- a/rusty/src/widgets/skeleton.rs
+++ b/rusty/src/widgets/skeleton.rs
@@ -8,10 +8,10 @@ use serde::{Deserialize, Serialize};
pub struct Skeleton {
#[serde(skip_serializing_if = "Option::is_none")]
pub id: Option,
- #[prop(with = "crate::shared::size_css")]
+ #[prop]
#[serde(skip_serializing_if = "Option::is_none")]
pub width: Option,
- #[prop(with = "crate::shared::size_css")]
+ #[prop]
#[serde(skip_serializing_if = "Option::is_none")]
pub height: Option,
}