Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .changepacks/changepack_log_theme_variable_clashes.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"changes": {
"bindings/devup-ui-wasm/package.json": "Patch",
"bindings/devup-ui-binding/package.json": "Patch"
},
"note": "Theme tokens of different kinds can no longer silently share a CSS variable: a color `card` and a shadow `card` both declared `--card`, so one replaced the other in `:root`. New build error: `theme: color token 'card' and shadow token 'card' both become the CSS variable --card: rename one`, raised when the theme is registered for any two of colors, `length` and `shadow` (names compared after dots become dashes, so `gray.100` and `gray-100` clash too)",
"date": "2026-11-15T00:00:00.000Z"
}
2 changes: 1 addition & 1 deletion apps/landing/src/app/(detail)/docs/devup/colors/page.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,7 @@ Each variant also sets the page's native color scheme — form controls, scrollb
}
```

Token names start with a letter, a digit or `_` and continue with letters, digits, `-`, `_` and `.` (`primary`, `text-primary`, `gray.100`); a dot becomes a dash in the CSS variable, so `gray.100` is `var(--gray-100)`. The build reports a name outside these characters, and two tokens of a variant that become the same variable (`a-b` and `a.b`), instead of emitting CSS the browser would drop.
Token names start with a letter, a digit or `_` and continue with letters, digits, `-`, `_` and `.` (`primary`, `text-primary`, `gray.100`); a dot becomes a dash in the CSS variable, so `gray.100` is `var(--gray-100)`. The build reports a name outside these characters, and two tokens of a variant that become the same variable (`a-b` and `a.b`), instead of emitting CSS the browser would drop. Colors, `length` and `shadow` tokens share one namespace of CSS variables, so a color `card` and a shadow `card` (both `--card`) are a build error too: rename one.

## Semantic Color Naming

Expand Down
4 changes: 2 additions & 2 deletions libs/sheet/src/atom_identity_tests/first_value.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ use super::*;
fn token_theme(first: &str) -> Theme {
serde_json::from_value(serde_json::json!({
"length": {"default": {"space": [first, "2px"], "zero": ["0px", "4px"], "nullable": [null, "3px", "4px"]}},
"shadows": {"default": {"card": ["0 1px 2px black", "0 2px 4px black"], "space": ["0 3px 4px black", "0 4px 8px black"]}}
"shadows": {"default": {"card": ["0 1px 2px black", "0 2px 4px black"], "lift": ["0 3px 4px black", "0 4px 8px black"]}}
}))
.unwrap_or_else(|error| panic!("{error}"))
}
Expand All @@ -28,7 +28,7 @@ fn first_value_refs_resolve_without_aliasing_responsive_variables() {
// Given
let source = r#"import {Box} from '@devup-ui/react'; export const A=<>
<Box w="$space"/><Box w={["$space"]}/><Box boxShadow={["$card"]}/>
<Box boxShadow={["$space"]}/><Box w="0px"/><Box w={["$zero"]}/>
<Box boxShadow={["$lift"]}/><Box w="0px"/><Box w={["$zero"]}/>
<Box w={["$missing"]}/><Box w={["$nullable"]}/></>;"#;
for atom in [false, true] {
let mut sheet = token_sheet(atom);
Expand Down
92 changes: 91 additions & 1 deletion libs/sheet/src/theme.rs
Original file line number Diff line number Diff line change
Expand Up @@ -624,8 +624,10 @@ impl ColorScheme {
}
}

/// The theme of `devup.json`. Deserializing it checks the tokens of every
/// kind together, as the derived field-by-field reading cannot
#[derive(Serialize, Deserialize, Debug)]
#[serde(rename_all = "camelCase")]
#[serde(rename_all = "camelCase", remote = "Self")]
pub struct Theme {
#[serde(default, deserialize_with = "deserialize_color_themes")]
pub colors: BTreeMap<String, ColorTheme>,
Expand All @@ -651,6 +653,58 @@ pub struct Theme {
pub tailwind_css: String,
}

impl Serialize for Theme {
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
Theme::serialize(self, serializer)
}
}

impl<'de> Deserialize<'de> for Theme {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
let theme = Theme::deserialize(deserializer)?;
check_token_kinds(&theme).map_err(serde::de::Error::custom)?;
Ok(theme)
}
}

/// Check that no two tokens of different kinds become one CSS variable, as a
/// color `card` and a shadow `card` would both set `--card`. Tokens of one
/// kind are checked as they are read; the same name in two variants of one
/// kind is the same variable on purpose.
fn check_token_kinds(theme: &Theme) -> Result<(), String> {
let colors = theme.colors.values().flat_map(|variant| {
variant
.entries
.values()
.map(|entry| ("color", entry.interface_key.as_str(), entry.css_key.clone()))
});
let mut seen: BTreeMap<String, (&str, &str)> = BTreeMap::new();
for (kind, name, css_key) in colors
.chain(token_variables("length", &theme.length))
.chain(token_variables("shadow", &theme.shadows))
{
let (other_kind, other) = *seen.entry(css_key.clone()).or_insert((kind, name));
if other_kind != kind {
return Err(format!(
"theme: {other_kind} token '{other}' and {kind} token '{name}' both become the CSS variable --{css_key}: rename one"
));
}
}
Ok(())
}

/// The kind, name and CSS variable of each token of every variant of `themes`
fn token_variables<'t>(
kind: &'static str,
themes: &'t BTreeMap<String, BTreeMap<String, TokenValues>>,
) -> impl Iterator<Item = (&'static str, &'t str, String)> + 't {
themes.values().flat_map(move |variant| {
variant
.keys()
.map(move |name| (kind, name.as_str(), css_key_from(name).into_owned()))
})
}

/// Deserialize the color variants, naming the variant a token error is in
fn deserialize_color_themes<'de, D>(
deserializer: D,
Expand Down Expand Up @@ -2734,6 +2788,42 @@ mod tests {
}
}

// A color `card` and a shadow `card` both set `--card`, so one would
// silently replace the other
#[rstest::rstest]
#[case(
r##"{"colors":{"default":{"card":"#111"}},"shadow":{"default":{"card":"0 1px #000"}}}"##,
"theme: color token 'card' and shadow token 'card' both become the CSS variable --card: rename one"
)]
#[case(
r##"{"colors":{"default":{"a":"#111"},"dark":{"gray":{"100":"#222"}}},"length":{"default":{"gray-100":"4px"}}}"##,
"theme: color token 'gray.100' and length token 'gray-100' both become the CSS variable --gray-100: rename one"
)]
#[case(
r#"{"length":{"default":{"gap":"4px"}},"shadow":{"sm":{"x":"0 1px #000"},"lg":{"gap.y":"0 1px #000","gap":"0 2px #000"}}}"#,
"theme: length token 'gap' and shadow token 'gap' both become the CSS variable --gap: rename one"
)]
fn test_tokens_of_different_kinds_cannot_share_a_variable(
#[case] json: &str,
#[case] error: &str,
) {
assert!(
theme_error(json).starts_with(error),
"{}",
theme_error(json)
);
}

#[test]
fn test_one_token_in_variants_of_its_kind_shares_a_variable() {
let theme: Theme = serde_json::from_str(
r##"{"colors":{"default":{"card":"#111"},"dark":{"card":"#eee"}},"length":{"default":{"gap":"4px"},"wide":{"gap":"8px"}}}"##,
)
.unwrap();
let json = serde_json::to_string(&theme).unwrap();
assert!(json.contains("\"length\""), "{json}");
}

#[test]
fn test_dotted_length_and_shadow_names_become_dashed_variables() {
assert_eq!(
Expand Down
Loading