Skip to content

Commit 385ffa7

Browse files
committed
Resolve light-dark() against the color scheme; canonicalize var() substitution
`light-dark(a, b)` no longer folds to its light branch at compile time. Both branches are validated and canonicalized as standalone colors, the call is preserved through the IR, and the runtime resolves it at computed-value time against the environment's color scheme: after var() substitution (a custom property may carry the call verbatim into the using declaration) and before descendants inherit the value. A malformed call — anything other than exactly two colors — is invalid at computed-value time and drops the declaration, like a failed var() resolution. Resolution is scoped to the color properties the compiler canonicalizes via a shared `is_color_property` predicate in css-core. The compiler's sheet-level path rejects malformed calls with the same "invalid color value" diagnostic the plain color path reports. Also fix var() substitution to always re-serialize through the token writer: custom-property values arrive verbatim, and the old early return leaked uncanonicalized numbers (`oklch(70% 0.2 40)` stayed `0.2` where the conformance corpus pins `.2`). Drop the unused `Stylesheet::layer_rank` method. Rebuild the embedded wasm artifact; extend the compiler and runtime tests and the smoke test with light-dark coverage under both schemes.
1 parent c3bf5b9 commit 385ffa7

8 files changed

Lines changed: 477 additions & 72 deletions

File tree

‎experiments/css/crates/css-compiler/src/canonical.rs‎

Lines changed: 84 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,9 @@
66
//! leading/trailing zeros (`0.2` → `.2`), and color-property values are
77
//! validated (a color is a single component: keyword, hash, or function)
88
//! so invalid declarations report through the recovery paths like they used
9-
//! to.
9+
//! to. `light-dark(a, b)` is preserved (branches canonicalized) for the
10+
//! runtime, which resolves it against the color scheme at computed-value
11+
//! time.
1012
1113
use bombshell_css_core::Diagnostic;
1214
use bombshell_css_core::tokens::write_token;
@@ -30,28 +32,26 @@ pub(crate) fn canonicalize(name: &str, value: &str) -> Result<String, Diagnostic
3032
_ => {}
3133
}
3234

33-
if name == "color" || name.ends_with("-color") {
34-
// `light-dark(a, b)` resolves to its light branch at compile time
35-
// (the previous front-end's printer behavior).
36-
let value = match resolve_light_dark(value) {
37-
Some(light) => light,
38-
None => value.to_string(),
39-
};
40-
41-
validate_color(&value)?;
42-
43-
// The shortest representation wins; ties go to hex (the previous
44-
// front-end's printer behavior: `red` stays, `yellow` → `#ff0`).
45-
let trimmed = value.trim();
46-
if let Some(hex) = named_color(trimmed.to_ascii_lowercase().as_str()) {
47-
return Ok(if trimmed.len() < hex.len() {
48-
trimmed.to_ascii_lowercase()
49-
} else {
50-
hex.to_string()
51-
});
35+
if bombshell_css_core::is_color_property(name) {
36+
// `light-dark(a, b)` survives to the runtime, which resolves it
37+
// against the environment's color scheme at computed-value time.
38+
// Both branches are validated and canonicalized here so the IR only
39+
// ever carries canonical color text.
40+
if is_light_dark_value(value) {
41+
let Some((light, dark)) = split_light_dark(value) else {
42+
return Err(invalid_color(value));
43+
};
44+
validate_color(light.trim())?;
45+
validate_color(dark.trim())?;
46+
return Ok(minify_value(&format!(
47+
"light-dark({}, {})",
48+
canonical_color(light.trim()),
49+
canonical_color(dark.trim()),
50+
)));
5251
}
5352

54-
return Ok(minify_value(&value));
53+
validate_color(value)?;
54+
return Ok(canonical_color(value));
5555
}
5656

5757
if name == "font-weight" {
@@ -65,12 +65,51 @@ pub(crate) fn canonicalize(name: &str, value: &str) -> Result<String, Diagnostic
6565
Ok(minify_value(value))
6666
}
6767

68-
/// `light-dark(a, b)` → its first argument.
69-
fn resolve_light_dark(value: &str) -> Option<String> {
68+
/// Canonical color text: the shortest representation wins and ties go to
69+
/// hex (the previous front-end's printer behavior: `red` stays, `yellow` →
70+
/// `#ff0`).
71+
fn canonical_color(value: &str) -> String {
7072
let trimmed = value.trim();
71-
let rest = trimmed.strip_prefix("light-dark(")?;
72-
let rest = rest.strip_suffix(')')?;
73+
if let Some(hex) = named_color(trimmed.to_ascii_lowercase().as_str()) {
74+
return if trimmed.len() < hex.len() {
75+
trimmed.to_ascii_lowercase()
76+
} else {
77+
hex.to_string()
78+
};
79+
}
80+
minify_value(trimmed)
81+
}
7382

83+
/// The shared diagnostic for a color value that does not parse.
84+
fn invalid_color(value: &str) -> Diagnostic {
85+
Diagnostic::warning(
86+
"declaration",
87+
{
88+
let mut message = String::from("invalid color value `");
89+
message.push_str(value);
90+
message.push('`');
91+
message
92+
},
93+
)
94+
}
95+
96+
/// `light-dark(a, b)` → its two branches, split at the top-level comma.
97+
/// Function names are case-insensitive; anything that is not exactly the
98+
/// two-argument form returns `None` (the caller reports it invalid).
99+
fn split_light_dark(value: &str) -> Option<(String, String)> {
100+
let trimmed = value.trim();
101+
if !trimmed[..trimmed.len().min("light-dark(".len())]
102+
.eq_ignore_ascii_case("light-dark(")
103+
{
104+
return None;
105+
}
106+
let rest = &trimmed["light-dark(".len()..];
107+
if !rest.ends_with(')') {
108+
return None;
109+
}
110+
let rest = &rest[..rest.len() - 1];
111+
112+
let mut split: Option<usize> = None;
74113
let mut depth = 0usize;
75114
let mut quote: Option<u8> = None;
76115
for (i, &b) in rest.as_bytes().iter().enumerate() {
@@ -81,13 +120,30 @@ fn resolve_light_dark(value: &str) -> Option<String> {
81120
b'"' | b'\'' => quote = Some(b),
82121
b'(' => depth += 1,
83122
b')' => depth = depth.saturating_sub(1),
84-
b',' if depth == 0 => return Some(rest[..i].trim().to_string()),
123+
b',' if depth == 0 => {
124+
if split.is_some() {
125+
// A second top-level comma is not the two-argument
126+
// form.
127+
return None;
128+
}
129+
split = Some(i);
130+
}
85131
_ => {}
86132
},
87133
}
88134
}
89135

90-
None
136+
let comma = split?;
137+
let (light, dark) = (&rest[..comma], &rest[comma + 1..]);
138+
Some((light.trim().to_string(), dark.trim().to_string()))
139+
}
140+
141+
/// Whether the value is a `light-dark(...)` function call (case-insensitive
142+
/// name, balanced parens).
143+
fn is_light_dark_value(value: &str) -> bool {
144+
let trimmed = value.trim();
145+
let lower = trimmed.to_ascii_lowercase();
146+
lower.starts_with("light-dark(") && trimmed.ends_with(')')
91147
}
92148

93149
/// A color value is one component: a keyword, a hash, or a balanced
@@ -169,15 +225,7 @@ fn validate_color(value: &str) -> Result<(), Diagnostic> {
169225
if valid {
170226
Ok(())
171227
} else {
172-
Err(Diagnostic::warning(
173-
"declaration",
174-
{
175-
let mut message = String::from("invalid color value `");
176-
message.push_str(value);
177-
message.push('`');
178-
message
179-
},
180-
))
228+
Err(invalid_color(value))
181229
}
182230
}
183231

‎experiments/css/crates/css-compiler/tests/compiler.rs‎

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,3 +123,46 @@ fn ir_round_trips_through_compiler() {
123123
assert_eq!(decoded.rules.len(), 2);
124124
assert_eq!(decoded.rules[1].selector, ".a:hover");
125125
}
126+
127+
#[test]
128+
fn preserves_light_dark_for_the_runtime() {
129+
// The compiler no longer folds to the light branch: both branches are
130+
// validated and canonicalized, and resolution happens against the
131+
// environment's color scheme at computed-value time.
132+
let sheet = compile_ok(".card { color: light-dark(white, black) }");
133+
assert_eq!(sheet.rules[0].declarations[0].value, "light-dark(#fff, #000)");
134+
}
135+
136+
#[test]
137+
fn canonicalizes_light_dark_branches_independently() {
138+
// Case-insensitive function name; each branch canonicalizes like a
139+
// standalone color value.
140+
let sheet = compile_ok(".card { border-color: LIGHT-DARK( #ffffff , yellow ) }");
141+
assert_eq!(
142+
sheet.rules[0].declarations[0].value,
143+
"light-dark(#ffffff, #ff0)"
144+
);
145+
}
146+
147+
#[test]
148+
fn rejects_malformed_light_dark() {
149+
for css in [
150+
".card { color: light-dark(#fff) }",
151+
".card { color: light-dark(#fff, #000, #888) }",
152+
".card { color: light-dark(#fff, nope) }",
153+
] {
154+
let out = compile(css, Origin::Author, Default::default());
155+
let sheet = out.sheet.expect("recovered sheet");
156+
assert!(
157+
out.diagnostics
158+
.iter()
159+
.any(|d| d.message.contains("invalid color value")),
160+
"missing diagnostic for {css}: {:?}",
161+
out.diagnostics
162+
);
163+
assert!(
164+
sheet.rules.iter().all(|rule| rule.declarations.is_empty()),
165+
"declaration survived for {css}"
166+
);
167+
}
168+
}

‎experiments/css/crates/css-core/src/lib.rs‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -221,6 +221,13 @@ pub struct Declaration {
221221
pub important: bool,
222222
}
223223

224+
/// Whether `name` takes `<color>` values. Both the compiler's color
225+
/// canonicalization and the runtime's `light-dark()` resolution key off this
226+
/// predicate, so it lives here next to the declaration type.
227+
pub fn is_color_property(name: &str) -> bool {
228+
name == "color" || name.ends_with("-color")
229+
}
230+
224231
/// A preserved `@keyframes` block.
225232
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
226233
pub struct KeyframesRule {

‎experiments/css/crates/css-runtime/src/cascade.rs‎

Lines changed: 37 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ use crate::conditions::{
88
ContainerAncestor, ContainerType, eval_container_list, eval_media_list, eval_supports_list,
99
};
1010
use crate::selectors::parse_selector_list;
11-
use crate::values::resolve_vars;
11+
use crate::values::{resolve_light_dark, resolve_vars};
1212
use bombshell_css_core::{CompiledStylesheet, ContainerQuery, Declaration, Origin, TreeSnapshot};
1313
use rustc_hash::FxHashMap as HashMap;
1414

@@ -222,10 +222,6 @@ impl<'ir> Stylesheet<'ir> {
222222
.iter()
223223
.map(|d| &d.message["selector could not be matched at runtime: ".len()..])
224224
}
225-
226-
fn layer_rank(&self, layer: Option<&[String]>) -> u32 {
227-
layer_rank(self.layer_order, layer)
228-
}
229225
}
230226

231227

@@ -385,6 +381,10 @@ pub fn compute_tree<'ir>(
385381
// var() substitution is part of computing the value: descendants
386382
// inherit computed values, not raw `var()` text.
387383
resolve_vars_in_map(&mut map);
384+
// `light-dark()` resolves at computed-value time, after var()
385+
// substitution (a custom property may carry it into the using
386+
// declaration) and before descendants inherit the value.
387+
resolve_light_dark_in_map(&mut map, env.color_scheme);
388388
maps.push(Some(map));
389389
}
390390

@@ -427,6 +427,38 @@ fn resolve_vars_in_map(map: &mut StyleMap<'_>) {
427427
}
428428
}
429429

430+
/// Resolve `light-dark()` in every non-custom color declaration against the
431+
/// environment's color scheme. Resolution is scoped to the color properties
432+
/// the compiler canonicalizes; custom properties substitute verbatim and
433+
/// resolve at the using declaration. A malformed call drops the
434+
/// declaration, like a failed `var()` resolution.
435+
fn resolve_light_dark_in_map(map: &mut StyleMap<'_>, scheme: bombshell_css_core::ColorScheme) {
436+
let names: Vec<Cow<'_, str>> = map.keys().cloned().collect();
437+
for name in names {
438+
if name.starts_with("--") {
439+
continue;
440+
}
441+
if !bombshell_css_core::is_color_property(name.as_ref()) {
442+
continue;
443+
}
444+
if let Some(value) = map.get(name.as_ref()) {
445+
if value.contains("light-dark(") {
446+
match resolve_light_dark(value, scheme) {
447+
Some(resolved) => {
448+
if resolved != value.as_ref() {
449+
map.insert(name, Cow::Owned(resolved));
450+
}
451+
}
452+
None => {
453+
// Invalid at computed-value time.
454+
map.remove(name.as_ref());
455+
}
456+
}
457+
}
458+
}
459+
}
460+
}
461+
430462
/// Handle `inherit`, `initial`, `unset`, and `revert` keywords.
431463
fn apply_css_wide_keywords<'ir>(map: &mut StyleMap<'ir>, inherited: &StyleMap<'ir>) {
432464
let names: Vec<Cow<'ir, str>> = map.keys().cloned().collect();

0 commit comments

Comments
 (0)