we (web engine): Experimental web browser project to understand the limits of Claude
2
fork

Configure Feed

Select the types of activity you want to include in your feed.

Implement CSS media queries: parsing, evaluation, and style integration

- Add crates/css/src/media.rs with MediaQueryList, MediaQuery, MediaFeature types
- Parse @media prelude into structured AST (types, features, operators, range syntax)
- Support screen/print/all types, not/only modifiers, comma OR semantics
- Support width/height features (min-, max-, range syntax including double ranges)
- Support prefers-color-scheme and prefers-reduced-motion discrete features
- Evaluate media queries against MediaContext (viewport dimensions, color scheme)
- Integrate with style crate: collect_from_rules now conditionally applies @media rules
- Add resolve_styles_with_media for full MediaContext control
- Add is_dark_mode() to platform crate for macOS dark mode detection
- 30+ unit tests for parsing and evaluation

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

+965 -32
+1
crates/css/src/lib.rs
··· 1 1 //! CSS tokenizer, parser, and CSSOM. 2 2 3 + pub mod media; 3 4 pub mod parser; 4 5 pub mod tokenizer; 5 6 pub mod values;
+850
crates/css/src/media.rs
··· 1 + //! CSS Media Queries Level 4 (core subset). 2 + //! 3 + //! Parses and evaluates media queries such as 4 + //! `screen and (max-width: 600px)` or `(prefers-color-scheme: dark)`. 5 + 6 + use crate::tokenizer::Token; 7 + 8 + // --------------------------------------------------------------------------- 9 + // Media context (evaluation environment) 10 + // --------------------------------------------------------------------------- 11 + 12 + /// The environment against which media queries are evaluated. 13 + #[derive(Debug, Clone)] 14 + pub struct MediaContext { 15 + pub viewport_width: f32, 16 + pub viewport_height: f32, 17 + pub color_scheme: ColorScheme, 18 + pub reduced_motion: ReducedMotion, 19 + } 20 + 21 + impl MediaContext { 22 + /// Create a context from viewport dimensions with sensible defaults. 23 + pub fn from_viewport(width: f32, height: f32) -> Self { 24 + Self { 25 + viewport_width: width, 26 + viewport_height: height, 27 + color_scheme: ColorScheme::Light, 28 + reduced_motion: ReducedMotion::NoPreference, 29 + } 30 + } 31 + 32 + /// Viewport as `(width, height)` tuple for existing code. 33 + pub fn viewport(&self) -> (f32, f32) { 34 + (self.viewport_width, self.viewport_height) 35 + } 36 + } 37 + 38 + impl Default for MediaContext { 39 + fn default() -> Self { 40 + Self::from_viewport(0.0, 0.0) 41 + } 42 + } 43 + 44 + /// User's preferred color scheme. 45 + #[derive(Debug, Clone, Copy, PartialEq, Eq)] 46 + pub enum ColorScheme { 47 + Light, 48 + Dark, 49 + } 50 + 51 + /// User's reduced-motion preference. 52 + #[derive(Debug, Clone, Copy, PartialEq, Eq)] 53 + pub enum ReducedMotion { 54 + NoPreference, 55 + Reduce, 56 + } 57 + 58 + // --------------------------------------------------------------------------- 59 + // Media query AST 60 + // --------------------------------------------------------------------------- 61 + 62 + /// Comma-separated list of media queries. Evaluates `true` if **any** matches. 63 + #[derive(Debug, Clone, PartialEq)] 64 + pub struct MediaQueryList { 65 + pub queries: Vec<MediaQuery>, 66 + } 67 + 68 + /// A single media query: `[not|only] [type] [and (feature)]*` 69 + #[derive(Debug, Clone, PartialEq)] 70 + pub struct MediaQuery { 71 + pub modifier: Modifier, 72 + pub media_type: MediaType, 73 + pub features: Vec<MediaFeature>, 74 + } 75 + 76 + /// Optional modifier on a media query. 77 + #[derive(Debug, Clone, Copy, PartialEq, Eq)] 78 + pub enum Modifier { 79 + None, 80 + Not, 81 + Only, 82 + } 83 + 84 + /// Media type. 85 + #[derive(Debug, Clone, Copy, PartialEq, Eq)] 86 + pub enum MediaType { 87 + All, 88 + Screen, 89 + Print, 90 + } 91 + 92 + /// A single media feature condition inside parentheses. 93 + #[derive(Debug, Clone, PartialEq)] 94 + pub enum MediaFeature { 95 + /// Width comparison (covers `width`, `min-width`, `max-width`, range syntax). 96 + Width(Comparison, f32), 97 + /// Height comparison (covers `height`, `min-height`, `max-height`, range syntax). 98 + Height(Comparison, f32), 99 + /// `prefers-color-scheme: light | dark` 100 + PrefersColorScheme(ColorScheme), 101 + /// `prefers-reduced-motion: reduce | no-preference` 102 + PrefersReducedMotion(ReducedMotion), 103 + } 104 + 105 + /// Comparison operator for range features. 106 + #[derive(Debug, Clone, Copy, PartialEq, Eq)] 107 + pub enum Comparison { 108 + Eq, 109 + Gt, 110 + Gte, 111 + Lt, 112 + Lte, 113 + } 114 + 115 + // --------------------------------------------------------------------------- 116 + // Evaluation 117 + // --------------------------------------------------------------------------- 118 + 119 + impl MediaQueryList { 120 + /// An empty list matches everything (per spec). 121 + pub fn evaluate(&self, ctx: &MediaContext) -> bool { 122 + if self.queries.is_empty() { 123 + return true; 124 + } 125 + self.queries.iter().any(|q| q.evaluate(ctx)) 126 + } 127 + } 128 + 129 + impl MediaQuery { 130 + pub fn evaluate(&self, ctx: &MediaContext) -> bool { 131 + let type_matches = match self.media_type { 132 + MediaType::All | MediaType::Screen => true, 133 + MediaType::Print => false, 134 + }; 135 + 136 + let features_match = self.features.iter().all(|f| f.evaluate(ctx)); 137 + let result = type_matches && features_match; 138 + 139 + match self.modifier { 140 + Modifier::Not => !result, 141 + Modifier::Only | Modifier::None => result, 142 + } 143 + } 144 + } 145 + 146 + impl MediaFeature { 147 + pub fn evaluate(&self, ctx: &MediaContext) -> bool { 148 + match self { 149 + MediaFeature::Width(cmp, val) => compare(*cmp, ctx.viewport_width, *val), 150 + MediaFeature::Height(cmp, val) => compare(*cmp, ctx.viewport_height, *val), 151 + MediaFeature::PrefersColorScheme(scheme) => ctx.color_scheme == *scheme, 152 + MediaFeature::PrefersReducedMotion(motion) => ctx.reduced_motion == *motion, 153 + } 154 + } 155 + } 156 + 157 + fn compare(cmp: Comparison, actual: f32, expected: f32) -> bool { 158 + match cmp { 159 + Comparison::Eq => (actual - expected).abs() < 0.5, 160 + Comparison::Gt => actual > expected, 161 + Comparison::Gte => actual >= expected, 162 + Comparison::Lt => actual < expected, 163 + Comparison::Lte => actual <= expected, 164 + } 165 + } 166 + 167 + // --------------------------------------------------------------------------- 168 + // Parsing 169 + // --------------------------------------------------------------------------- 170 + 171 + /// Parse a media query list from tokens (the prelude between `@media` and `{`). 172 + pub fn parse_media_query_list(tokens: &[Token]) -> MediaQueryList { 173 + let mut queries = Vec::new(); 174 + let mut start = 0; 175 + 176 + for i in 0..tokens.len() { 177 + if matches!(tokens[i], Token::Comma) { 178 + if let Some(q) = parse_single_query(&tokens[start..i]) { 179 + queries.push(q); 180 + } 181 + start = i + 1; 182 + } 183 + } 184 + 185 + // Last (or only) segment. 186 + if start <= tokens.len() { 187 + if let Some(q) = parse_single_query(&tokens[start..]) { 188 + queries.push(q); 189 + } 190 + } 191 + 192 + MediaQueryList { queries } 193 + } 194 + 195 + /// Parse a single media query (no commas). 196 + fn parse_single_query(tokens: &[Token]) -> Option<MediaQuery> { 197 + let tokens: Vec<&Token> = tokens 198 + .iter() 199 + .filter(|t| !matches!(t, Token::Whitespace)) 200 + .collect(); 201 + if tokens.is_empty() { 202 + return None; 203 + } 204 + 205 + let mut pos = 0; 206 + 207 + // Optional modifier: `not` | `only` 208 + let modifier = match tokens.get(pos) { 209 + Some(Token::Ident(s)) if s.eq_ignore_ascii_case("not") => { 210 + pos += 1; 211 + Modifier::Not 212 + } 213 + Some(Token::Ident(s)) if s.eq_ignore_ascii_case("only") => { 214 + pos += 1; 215 + Modifier::Only 216 + } 217 + _ => Modifier::None, 218 + }; 219 + 220 + // Optional media type: `screen` | `print` | `all` 221 + let mut media_type = MediaType::All; 222 + let mut has_explicit_type = false; 223 + 224 + if let Some(Token::Ident(s)) = tokens.get(pos) { 225 + match s.to_ascii_lowercase().as_str() { 226 + "screen" => { 227 + media_type = MediaType::Screen; 228 + has_explicit_type = true; 229 + pos += 1; 230 + } 231 + "print" => { 232 + media_type = MediaType::Print; 233 + has_explicit_type = true; 234 + pos += 1; 235 + } 236 + "all" => { 237 + media_type = MediaType::All; 238 + has_explicit_type = true; 239 + pos += 1; 240 + } 241 + _ => {} 242 + } 243 + } 244 + 245 + // If we have a modifier but no recognized type, and the next token is an ident 246 + // (not a paren), treat it as an unknown type that never matches. 247 + if modifier != Modifier::None && !has_explicit_type { 248 + if let Some(Token::Ident(_)) = tokens.get(pos) { 249 + // Unknown media type — skip the ident. Evaluated as never-matching 250 + // (the `not` modifier will invert it). 251 + pos += 1; 252 + } 253 + } 254 + 255 + // Parse `and (feature)` clauses. 256 + let mut features = Vec::new(); 257 + loop { 258 + if pos >= tokens.len() { 259 + break; 260 + } 261 + 262 + // Expect `and` between type and features, or between features. 263 + if let Some(Token::Ident(s)) = tokens.get(pos) { 264 + if s.eq_ignore_ascii_case("and") { 265 + pos += 1; 266 + if pos >= tokens.len() { 267 + break; 268 + } 269 + } 270 + } 271 + 272 + // Expect `(` ... `)` 273 + if !matches!(tokens.get(pos), Some(Token::LeftParen)) { 274 + break; 275 + } 276 + pos += 1; 277 + 278 + // Collect tokens until matching `)`. 279 + let feature_start = pos; 280 + let mut depth = 1u32; 281 + while pos < tokens.len() && depth > 0 { 282 + match tokens[pos] { 283 + Token::LeftParen => depth += 1, 284 + Token::RightParen => { 285 + depth -= 1; 286 + if depth == 0 { 287 + break; 288 + } 289 + } 290 + _ => {} 291 + } 292 + pos += 1; 293 + } 294 + let feature_end = pos; 295 + if pos < tokens.len() { 296 + pos += 1; // skip `)` 297 + } 298 + 299 + let feature_tokens: Vec<&Token> = tokens[feature_start..feature_end].to_vec(); 300 + let parsed = parse_media_feature(&feature_tokens); 301 + features.extend(parsed); 302 + } 303 + 304 + // A modifier without an explicit type or features isn't useful, but still valid. 305 + Some(MediaQuery { 306 + modifier, 307 + media_type, 308 + features, 309 + }) 310 + } 311 + 312 + /// Parse a single media feature from the tokens inside parentheses. 313 + /// Returns zero or more features (double ranges produce two). 314 + fn parse_media_feature(tokens: &[&Token]) -> Vec<MediaFeature> { 315 + if tokens.is_empty() { 316 + return Vec::new(); 317 + } 318 + 319 + // Try traditional syntax first: `name : value` 320 + if let Some(colon_pos) = tokens.iter().position(|t| matches!(t, Token::Colon)) { 321 + let name_tokens = &tokens[..colon_pos]; 322 + let value_tokens = &tokens[colon_pos + 1..]; 323 + 324 + if name_tokens.len() == 1 { 325 + if let Token::Ident(name) = name_tokens[0] { 326 + if let Some(f) = parse_named_feature(&name.to_ascii_lowercase(), value_tokens) { 327 + return vec![f]; 328 + } 329 + } 330 + } 331 + return Vec::new(); 332 + } 333 + 334 + // Try range syntax. 335 + parse_range_feature(tokens) 336 + } 337 + 338 + /// Parse a feature with traditional `name: value` syntax. 339 + fn parse_named_feature(name: &str, value_tokens: &[&Token]) -> Option<MediaFeature> { 340 + match name { 341 + "min-width" => Some(MediaFeature::Width( 342 + Comparison::Gte, 343 + parse_length(value_tokens)?, 344 + )), 345 + "max-width" => Some(MediaFeature::Width( 346 + Comparison::Lte, 347 + parse_length(value_tokens)?, 348 + )), 349 + "width" => Some(MediaFeature::Width( 350 + Comparison::Eq, 351 + parse_length(value_tokens)?, 352 + )), 353 + "min-height" => Some(MediaFeature::Height( 354 + Comparison::Gte, 355 + parse_length(value_tokens)?, 356 + )), 357 + "max-height" => Some(MediaFeature::Height( 358 + Comparison::Lte, 359 + parse_length(value_tokens)?, 360 + )), 361 + "height" => Some(MediaFeature::Height( 362 + Comparison::Eq, 363 + parse_length(value_tokens)?, 364 + )), 365 + "prefers-color-scheme" => { 366 + let Token::Ident(v) = value_tokens.first()? else { 367 + return None; 368 + }; 369 + match v.to_ascii_lowercase().as_str() { 370 + "light" => Some(MediaFeature::PrefersColorScheme(ColorScheme::Light)), 371 + "dark" => Some(MediaFeature::PrefersColorScheme(ColorScheme::Dark)), 372 + _ => None, 373 + } 374 + } 375 + "prefers-reduced-motion" => { 376 + let Token::Ident(v) = value_tokens.first()? else { 377 + return None; 378 + }; 379 + match v.to_ascii_lowercase().as_str() { 380 + "reduce" => Some(MediaFeature::PrefersReducedMotion(ReducedMotion::Reduce)), 381 + "no-preference" => Some(MediaFeature::PrefersReducedMotion( 382 + ReducedMotion::NoPreference, 383 + )), 384 + _ => None, 385 + } 386 + } 387 + _ => None, 388 + } 389 + } 390 + 391 + /// Parse range syntax: `name op value`, `value op name`, or 392 + /// `value op name op value` (double range). 393 + fn parse_range_feature(tokens: &[&Token]) -> Vec<MediaFeature> { 394 + // Find comparison operator positions. 395 + let mut comparisons: Vec<(usize, usize, Comparison)> = Vec::new(); // (start, len, op) 396 + 397 + let mut i = 0; 398 + while i < tokens.len() { 399 + if i + 1 < tokens.len() { 400 + if let (Token::Delim('>'), Token::Delim('=')) = (tokens[i], tokens[i + 1]) { 401 + comparisons.push((i, 2, Comparison::Gte)); 402 + i += 2; 403 + continue; 404 + } 405 + if let (Token::Delim('<'), Token::Delim('=')) = (tokens[i], tokens[i + 1]) { 406 + comparisons.push((i, 2, Comparison::Lte)); 407 + i += 2; 408 + continue; 409 + } 410 + } 411 + match tokens[i] { 412 + Token::Delim('>') => { 413 + comparisons.push((i, 1, Comparison::Gt)); 414 + i += 1; 415 + continue; 416 + } 417 + Token::Delim('<') => { 418 + comparisons.push((i, 1, Comparison::Lt)); 419 + i += 1; 420 + continue; 421 + } 422 + Token::Delim('=') => { 423 + comparisons.push((i, 1, Comparison::Eq)); 424 + i += 1; 425 + continue; 426 + } 427 + _ => {} 428 + } 429 + i += 1; 430 + } 431 + 432 + if comparisons.len() == 1 { 433 + let (op_pos, op_len, cmp) = comparisons[0]; 434 + let left = &tokens[..op_pos]; 435 + let right = &tokens[op_pos + op_len..]; 436 + 437 + if let Some(name) = extract_feature_name(left) { 438 + // `name op value` 439 + if let Some(val) = parse_length(right) { 440 + if let Some(f) = make_dimension_feature(&name, cmp, val) { 441 + return vec![f]; 442 + } 443 + } 444 + } else if let Some(name) = extract_feature_name(right) { 445 + // `value op name` → flip 446 + if let Some(val) = parse_length(left) { 447 + if let Some(f) = make_dimension_feature(&name, flip_comparison(cmp), val) { 448 + return vec![f]; 449 + } 450 + } 451 + } 452 + } else if comparisons.len() == 2 { 453 + // Double range: `value op1 name op2 value` 454 + let (op1_pos, op1_len, cmp1) = comparisons[0]; 455 + let (op2_pos, op2_len, cmp2) = comparisons[1]; 456 + 457 + let left = &tokens[..op1_pos]; 458 + let middle = &tokens[op1_pos + op1_len..op2_pos]; 459 + let right = &tokens[op2_pos + op2_len..]; 460 + 461 + if let Some(name) = extract_feature_name(middle) { 462 + if let (Some(low), Some(high)) = (parse_length(left), parse_length(right)) { 463 + // `low cmp1 name cmp2 high` means `name flip(cmp1) low AND name cmp2 high` 464 + let f1 = make_dimension_feature(&name, flip_comparison(cmp1), low); 465 + let f2 = make_dimension_feature(&name, cmp2, high); 466 + if let (Some(a), Some(b)) = (f1, f2) { 467 + return vec![a, b]; 468 + } 469 + } 470 + } 471 + } 472 + 473 + Vec::new() 474 + } 475 + 476 + /// Extract a feature name from a single-ident token slice. 477 + fn extract_feature_name(tokens: &[&Token]) -> Option<String> { 478 + if tokens.len() == 1 { 479 + if let Token::Ident(s) = tokens[0] { 480 + return Some(s.to_ascii_lowercase()); 481 + } 482 + } 483 + None 484 + } 485 + 486 + /// Create a Width or Height feature from a dimension name and comparison. 487 + fn make_dimension_feature(name: &str, cmp: Comparison, value: f32) -> Option<MediaFeature> { 488 + match name { 489 + "width" => Some(MediaFeature::Width(cmp, value)), 490 + "height" => Some(MediaFeature::Height(cmp, value)), 491 + _ => None, 492 + } 493 + } 494 + 495 + /// Flip a comparison for reversed range syntax: `value op name` → `name flip(op) value`. 496 + fn flip_comparison(cmp: Comparison) -> Comparison { 497 + match cmp { 498 + Comparison::Gt => Comparison::Lt, 499 + Comparison::Gte => Comparison::Lte, 500 + Comparison::Lt => Comparison::Gt, 501 + Comparison::Lte => Comparison::Gte, 502 + Comparison::Eq => Comparison::Eq, 503 + } 504 + } 505 + 506 + /// Parse a pixel length from value tokens. Returns the value in `px`. 507 + fn parse_length(tokens: &[&Token]) -> Option<f32> { 508 + if tokens.len() != 1 { 509 + return None; 510 + } 511 + match tokens[0] { 512 + Token::Dimension(val, _, unit) if unit.eq_ignore_ascii_case("px") => Some(*val as f32), 513 + Token::Dimension(val, _, unit) if unit.eq_ignore_ascii_case("em") => { 514 + Some(*val as f32 * 16.0) 515 + } 516 + Token::Dimension(val, _, unit) if unit.eq_ignore_ascii_case("rem") => { 517 + Some(*val as f32 * 16.0) 518 + } 519 + Token::Number(val, _) if *val == 0.0 => Some(0.0), 520 + _ => None, 521 + } 522 + } 523 + 524 + // --------------------------------------------------------------------------- 525 + // Tests 526 + // --------------------------------------------------------------------------- 527 + 528 + #[cfg(test)] 529 + mod tests { 530 + use super::*; 531 + use crate::tokenizer::Tokenizer; 532 + 533 + /// Helper: tokenize a media query prelude (no @media keyword or braces). 534 + fn parse_mq(input: &str) -> MediaQueryList { 535 + let tokens = Tokenizer::tokenize(input); 536 + // Remove EOF 537 + let tokens: Vec<Token> = tokens 538 + .into_iter() 539 + .filter(|t| !matches!(t, Token::Eof)) 540 + .collect(); 541 + parse_media_query_list(&tokens) 542 + } 543 + 544 + fn ctx(w: f32, h: f32) -> MediaContext { 545 + MediaContext::from_viewport(w, h) 546 + } 547 + 548 + fn dark_ctx(w: f32, h: f32) -> MediaContext { 549 + MediaContext { 550 + viewport_width: w, 551 + viewport_height: h, 552 + color_scheme: ColorScheme::Dark, 553 + reduced_motion: ReducedMotion::NoPreference, 554 + } 555 + } 556 + 557 + // --- Parsing --- 558 + 559 + #[test] 560 + fn parse_media_type_screen() { 561 + let mq = parse_mq("screen"); 562 + assert_eq!(mq.queries.len(), 1); 563 + assert_eq!(mq.queries[0].media_type, MediaType::Screen); 564 + assert_eq!(mq.queries[0].modifier, Modifier::None); 565 + assert!(mq.queries[0].features.is_empty()); 566 + } 567 + 568 + #[test] 569 + fn parse_media_type_print() { 570 + let mq = parse_mq("print"); 571 + assert_eq!(mq.queries[0].media_type, MediaType::Print); 572 + } 573 + 574 + #[test] 575 + fn parse_media_type_all() { 576 + let mq = parse_mq("all"); 577 + assert_eq!(mq.queries[0].media_type, MediaType::All); 578 + } 579 + 580 + #[test] 581 + fn parse_not_modifier() { 582 + let mq = parse_mq("not print"); 583 + assert_eq!(mq.queries[0].modifier, Modifier::Not); 584 + assert_eq!(mq.queries[0].media_type, MediaType::Print); 585 + } 586 + 587 + #[test] 588 + fn parse_only_modifier() { 589 + let mq = parse_mq("only screen"); 590 + assert_eq!(mq.queries[0].modifier, Modifier::Only); 591 + assert_eq!(mq.queries[0].media_type, MediaType::Screen); 592 + } 593 + 594 + #[test] 595 + fn parse_min_width() { 596 + let mq = parse_mq("(min-width: 768px)"); 597 + assert_eq!(mq.queries[0].features.len(), 1); 598 + assert_eq!( 599 + mq.queries[0].features[0], 600 + MediaFeature::Width(Comparison::Gte, 768.0) 601 + ); 602 + } 603 + 604 + #[test] 605 + fn parse_max_width() { 606 + let mq = parse_mq("(max-width: 600px)"); 607 + assert_eq!( 608 + mq.queries[0].features[0], 609 + MediaFeature::Width(Comparison::Lte, 600.0) 610 + ); 611 + } 612 + 613 + #[test] 614 + fn parse_screen_and_feature() { 615 + let mq = parse_mq("screen and (max-width: 600px)"); 616 + assert_eq!(mq.queries[0].media_type, MediaType::Screen); 617 + assert_eq!(mq.queries[0].features.len(), 1); 618 + assert_eq!( 619 + mq.queries[0].features[0], 620 + MediaFeature::Width(Comparison::Lte, 600.0) 621 + ); 622 + } 623 + 624 + #[test] 625 + fn parse_multiple_features() { 626 + let mq = parse_mq("screen and (min-width: 400px) and (max-width: 800px)"); 627 + assert_eq!(mq.queries[0].features.len(), 2); 628 + assert_eq!( 629 + mq.queries[0].features[0], 630 + MediaFeature::Width(Comparison::Gte, 400.0) 631 + ); 632 + assert_eq!( 633 + mq.queries[0].features[1], 634 + MediaFeature::Width(Comparison::Lte, 800.0) 635 + ); 636 + } 637 + 638 + #[test] 639 + fn parse_comma_separated() { 640 + let mq = parse_mq("screen, print"); 641 + assert_eq!(mq.queries.len(), 2); 642 + assert_eq!(mq.queries[0].media_type, MediaType::Screen); 643 + assert_eq!(mq.queries[1].media_type, MediaType::Print); 644 + } 645 + 646 + #[test] 647 + fn parse_range_gte() { 648 + let mq = parse_mq("(width >= 768px)"); 649 + assert_eq!( 650 + mq.queries[0].features[0], 651 + MediaFeature::Width(Comparison::Gte, 768.0) 652 + ); 653 + } 654 + 655 + #[test] 656 + fn parse_range_lt() { 657 + let mq = parse_mq("(width < 600px)"); 658 + assert_eq!( 659 + mq.queries[0].features[0], 660 + MediaFeature::Width(Comparison::Lt, 600.0) 661 + ); 662 + } 663 + 664 + #[test] 665 + fn parse_range_reversed() { 666 + let mq = parse_mq("(768px <= width)"); 667 + assert_eq!( 668 + mq.queries[0].features[0], 669 + MediaFeature::Width(Comparison::Gte, 768.0) 670 + ); 671 + } 672 + 673 + #[test] 674 + fn parse_double_range() { 675 + let mq = parse_mq("(400px <= width <= 1200px)"); 676 + assert_eq!(mq.queries[0].features.len(), 2); 677 + assert_eq!( 678 + mq.queries[0].features[0], 679 + MediaFeature::Width(Comparison::Gte, 400.0) 680 + ); 681 + assert_eq!( 682 + mq.queries[0].features[1], 683 + MediaFeature::Width(Comparison::Lte, 1200.0) 684 + ); 685 + } 686 + 687 + #[test] 688 + fn parse_prefers_color_scheme_dark() { 689 + let mq = parse_mq("(prefers-color-scheme: dark)"); 690 + assert_eq!( 691 + mq.queries[0].features[0], 692 + MediaFeature::PrefersColorScheme(ColorScheme::Dark) 693 + ); 694 + } 695 + 696 + #[test] 697 + fn parse_prefers_color_scheme_light() { 698 + let mq = parse_mq("(prefers-color-scheme: light)"); 699 + assert_eq!( 700 + mq.queries[0].features[0], 701 + MediaFeature::PrefersColorScheme(ColorScheme::Light) 702 + ); 703 + } 704 + 705 + #[test] 706 + fn parse_prefers_reduced_motion() { 707 + let mq = parse_mq("(prefers-reduced-motion: reduce)"); 708 + assert_eq!( 709 + mq.queries[0].features[0], 710 + MediaFeature::PrefersReducedMotion(ReducedMotion::Reduce) 711 + ); 712 + } 713 + 714 + #[test] 715 + fn parse_height_features() { 716 + let mq = parse_mq("(min-height: 500px)"); 717 + assert_eq!( 718 + mq.queries[0].features[0], 719 + MediaFeature::Height(Comparison::Gte, 500.0) 720 + ); 721 + } 722 + 723 + // --- Evaluation --- 724 + 725 + #[test] 726 + fn eval_screen_matches() { 727 + let mq = parse_mq("screen"); 728 + assert!(mq.evaluate(&ctx(800.0, 600.0))); 729 + } 730 + 731 + #[test] 732 + fn eval_print_does_not_match() { 733 + let mq = parse_mq("print"); 734 + assert!(!mq.evaluate(&ctx(800.0, 600.0))); 735 + } 736 + 737 + #[test] 738 + fn eval_not_print_matches() { 739 + let mq = parse_mq("not print"); 740 + assert!(mq.evaluate(&ctx(800.0, 600.0))); 741 + } 742 + 743 + #[test] 744 + fn eval_min_width_matches() { 745 + let mq = parse_mq("(min-width: 768px)"); 746 + assert!(mq.evaluate(&ctx(1024.0, 768.0))); 747 + assert!(!mq.evaluate(&ctx(500.0, 768.0))); 748 + } 749 + 750 + #[test] 751 + fn eval_max_width_matches() { 752 + let mq = parse_mq("(max-width: 600px)"); 753 + assert!(mq.evaluate(&ctx(400.0, 300.0))); 754 + assert!(!mq.evaluate(&ctx(800.0, 600.0))); 755 + } 756 + 757 + #[test] 758 + fn eval_width_range() { 759 + let mq = parse_mq("(width >= 768px)"); 760 + assert!(mq.evaluate(&ctx(768.0, 600.0))); 761 + assert!(mq.evaluate(&ctx(1024.0, 600.0))); 762 + assert!(!mq.evaluate(&ctx(767.0, 600.0))); 763 + } 764 + 765 + #[test] 766 + fn eval_double_range() { 767 + let mq = parse_mq("(400px <= width <= 1200px)"); 768 + assert!(mq.evaluate(&ctx(800.0, 600.0))); 769 + assert!(!mq.evaluate(&ctx(300.0, 600.0))); 770 + assert!(!mq.evaluate(&ctx(1300.0, 600.0))); 771 + } 772 + 773 + #[test] 774 + fn eval_comma_or_semantics() { 775 + let mq = parse_mq("screen, print"); 776 + assert!(mq.evaluate(&ctx(800.0, 600.0))); 777 + 778 + let mq2 = parse_mq("print, (min-width: 768px)"); 779 + assert!(mq2.evaluate(&ctx(1024.0, 768.0))); 780 + assert!(!mq2.evaluate(&ctx(500.0, 400.0))); 781 + } 782 + 783 + #[test] 784 + fn eval_prefers_color_scheme_dark() { 785 + let mq = parse_mq("(prefers-color-scheme: dark)"); 786 + assert!(mq.evaluate(&dark_ctx(800.0, 600.0))); 787 + assert!(!mq.evaluate(&ctx(800.0, 600.0))); 788 + } 789 + 790 + #[test] 791 + fn eval_screen_and_min_width() { 792 + let mq = parse_mq("screen and (min-width: 768px)"); 793 + assert!(mq.evaluate(&ctx(1024.0, 768.0))); 794 + assert!(!mq.evaluate(&ctx(500.0, 400.0))); 795 + } 796 + 797 + #[test] 798 + fn eval_not_screen_does_not_match() { 799 + let mq = parse_mq("not screen"); 800 + assert!(!mq.evaluate(&ctx(800.0, 600.0))); 801 + } 802 + 803 + #[test] 804 + fn eval_only_screen_matches() { 805 + let mq = parse_mq("only screen"); 806 + assert!(mq.evaluate(&ctx(800.0, 600.0))); 807 + } 808 + 809 + #[test] 810 + fn eval_empty_query_list_matches() { 811 + let mq = MediaQueryList { 812 + queries: Vec::new(), 813 + }; 814 + assert!(mq.evaluate(&ctx(800.0, 600.0))); 815 + } 816 + 817 + #[test] 818 + fn eval_all_type_matches() { 819 + let mq = parse_mq("all"); 820 + assert!(mq.evaluate(&ctx(800.0, 600.0))); 821 + } 822 + 823 + #[test] 824 + fn eval_height_features() { 825 + let mq = parse_mq("(min-height: 500px)"); 826 + assert!(mq.evaluate(&ctx(800.0, 600.0))); 827 + assert!(!mq.evaluate(&ctx(800.0, 400.0))); 828 + } 829 + 830 + #[test] 831 + fn eval_multiple_and_features() { 832 + let mq = parse_mq("screen and (min-width: 400px) and (max-width: 1200px)"); 833 + assert!(mq.evaluate(&ctx(800.0, 600.0))); 834 + assert!(!mq.evaluate(&ctx(300.0, 600.0))); 835 + assert!(!mq.evaluate(&ctx(1300.0, 600.0))); 836 + } 837 + 838 + #[test] 839 + fn eval_reduced_motion() { 840 + let mq = parse_mq("(prefers-reduced-motion: reduce)"); 841 + let reduced = MediaContext { 842 + viewport_width: 800.0, 843 + viewport_height: 600.0, 844 + color_scheme: ColorScheme::Light, 845 + reduced_motion: ReducedMotion::Reduce, 846 + }; 847 + assert!(mq.evaluate(&reduced)); 848 + assert!(!mq.evaluate(&ctx(800.0, 600.0))); 849 + } 850 + }
+20 -18
crates/css/src/parser.rs
··· 2 2 //! 3 3 //! Consumes tokens from the tokenizer and produces a structured stylesheet. 4 4 5 + use crate::media::{parse_media_query_list, MediaQueryList}; 5 6 use crate::tokenizer::{HashType, NumericType, Token, Tokenizer}; 6 7 7 8 // --------------------------------------------------------------------------- ··· 29 30 pub declarations: Vec<Declaration>, 30 31 } 31 32 32 - /// A `@media` rule with a prelude (media query text) and nested rules. 33 + /// A `@media` rule with parsed media queries and nested rules. 33 34 #[derive(Debug, Clone, PartialEq)] 34 35 pub struct MediaRule { 35 - pub query: String, 36 + pub queries: MediaQueryList, 36 37 pub rules: Vec<Rule>, 37 38 } 38 39 ··· 246 247 fn parse_media_rule(&mut self) -> Option<Rule> { 247 248 self.skip_whitespace(); 248 249 249 - // Collect prelude tokens as the media query string 250 - let mut query = String::new(); 250 + // Collect prelude tokens for structured media query parsing. 251 + let mut prelude_tokens = Vec::new(); 251 252 loop { 252 253 match self.peek() { 253 254 Token::LeftBrace | Token::Eof => break, 254 - Token::Whitespace => { 255 - if !query.is_empty() { 256 - query.push(' '); 257 - } 258 - self.advance(); 259 - } 260 255 _ => { 261 256 let tok = self.advance(); 262 - query.push_str(&token_to_string(&tok)); 257 + prelude_tokens.push(tok); 263 258 } 264 259 } 265 260 } 261 + 262 + let queries = parse_media_query_list(&prelude_tokens); 266 263 267 264 // Expect `{` 268 265 if !matches!(self.peek(), Token::LeftBrace) { ··· 295 292 } 296 293 } 297 294 298 - Some(Rule::Media(MediaRule { 299 - query: query.trim().to_string(), 300 - rules, 301 - })) 295 + Some(Rule::Media(MediaRule { queries, rules })) 302 296 } 303 297 304 298 fn parse_import_rule(&mut self) -> Option<Rule> { ··· 1393 1387 1394 1388 #[test] 1395 1389 fn test_media_rule() { 1390 + use crate::media::{MediaType, Modifier}; 1396 1391 let ss = Parser::parse("@media screen { p { color: red; } }"); 1397 1392 assert_eq!(ss.rules.len(), 1); 1398 1393 match &ss.rules[0] { 1399 1394 Rule::Media(m) => { 1400 - assert_eq!(m.query, "screen"); 1395 + assert_eq!(m.queries.queries.len(), 1); 1396 + assert_eq!(m.queries.queries[0].media_type, MediaType::Screen); 1397 + assert_eq!(m.queries.queries[0].modifier, Modifier::None); 1401 1398 assert_eq!(m.rules.len(), 1); 1402 1399 } 1403 1400 _ => panic!("expected media rule"), ··· 1406 1403 1407 1404 #[test] 1408 1405 fn test_media_rule_complex_query() { 1406 + use crate::media::{Comparison, MediaFeature, MediaType}; 1409 1407 let ss = Parser::parse("@media screen and (max-width: 600px) { p { font-size: 14px; } }"); 1410 1408 match &ss.rules[0] { 1411 1409 Rule::Media(m) => { 1412 - assert!(m.query.contains("screen")); 1413 - assert!(m.query.contains("max-width")); 1410 + assert_eq!(m.queries.queries[0].media_type, MediaType::Screen); 1411 + assert_eq!(m.queries.queries[0].features.len(), 1); 1412 + assert_eq!( 1413 + m.queries.queries[0].features[0], 1414 + MediaFeature::Width(Comparison::Lte, 600.0) 1415 + ); 1414 1416 assert_eq!(m.rules.len(), 1); 1415 1417 } 1416 1418 _ => panic!("expected media rule"),
+39
crates/platform/src/appkit.rs
··· 954 954 } 955 955 956 956 // --------------------------------------------------------------------------- 957 + // System appearance queries 958 + // --------------------------------------------------------------------------- 959 + 960 + /// Check whether macOS is currently using dark mode. 961 + /// 962 + /// Queries `NSApp.effectiveAppearance.name` and checks if it contains "Dark". 963 + /// Returns `false` if the application hasn't been initialized yet or if the 964 + /// appearance cannot be determined. 965 + pub fn is_dark_mode() -> bool { 966 + let cls = match class!("NSApplication") { 967 + Some(c) => c, 968 + None => return false, 969 + }; 970 + let app: *mut c_void = msg_send![cls.as_ptr(), sharedApplication]; 971 + if app.is_null() { 972 + return false; 973 + } 974 + let appearance: *mut c_void = msg_send![app, effectiveAppearance]; 975 + if appearance.is_null() { 976 + return false; 977 + } 978 + let name: *mut c_void = msg_send![appearance, name]; 979 + if name.is_null() { 980 + return false; 981 + } 982 + // The appearance name is an NSString. We compare it against 983 + // NSAppearanceNameDarkAqua which contains "DarkAqua". 984 + let cf_name = CfString::new("Dark"); 985 + let cf_name = match cf_name { 986 + Some(s) => s, 987 + None => return false, 988 + }; 989 + // NSString localizedCaseInsensitiveContainsString: 990 + let contains: bool = 991 + msg_send![name, localizedCaseInsensitiveContainsString: cf_name.as_cf_ref()]; 992 + contains 993 + } 994 + 995 + // --------------------------------------------------------------------------- 957 996 // Tests 958 997 // --------------------------------------------------------------------------- 959 998
+33 -6
crates/style/src/computed.rs
··· 6 6 7 7 use std::collections::HashMap; 8 8 9 + use we_css::media::MediaContext; 9 10 use we_css::parser::{ComponentValue, Declaration, Stylesheet}; 10 11 use we_css::values::{expand_shorthand, parse_value, Color, CssValue, LengthUnit, MathExpr}; 11 12 use we_dom::{Document, NodeData, NodeId}; ··· 1797 1798 /// Resolve styles for an entire document tree. 1798 1799 /// 1799 1800 /// `stylesheets` is a list of author stylesheets (the UA stylesheet is 1800 - /// automatically prepended). 1801 + /// automatically prepended). Constructs a default `MediaContext` from the 1802 + /// viewport dimensions (light color scheme, no reduced motion). 1801 1803 pub fn resolve_styles( 1802 1804 doc: &Document, 1803 1805 author_stylesheets: &[Stylesheet], 1804 1806 viewport: (f32, f32), 1805 1807 ) -> Option<StyledNode> { 1808 + let media_ctx = MediaContext::from_viewport(viewport.0, viewport.1); 1809 + resolve_styles_with_media(doc, author_stylesheets, &media_ctx) 1810 + } 1811 + 1812 + /// Resolve styles with a full media context (viewport, color scheme, etc.). 1813 + pub fn resolve_styles_with_media( 1814 + doc: &Document, 1815 + author_stylesheets: &[Stylesheet], 1816 + media_ctx: &MediaContext, 1817 + ) -> Option<StyledNode> { 1806 1818 let ua = ua_stylesheet(); 1807 1819 1808 1820 // Combine UA + author stylesheets into a single list for rule collection. ··· 1814 1826 combined.rules.extend(ss.rules.iter().cloned()); 1815 1827 } 1816 1828 1829 + let viewport = media_ctx.viewport(); 1817 1830 let root = doc.root(); 1818 - resolve_node(doc, root, &combined, &ComputedStyle::default(), viewport) 1831 + resolve_node( 1832 + doc, 1833 + root, 1834 + &combined, 1835 + &ComputedStyle::default(), 1836 + viewport, 1837 + media_ctx, 1838 + ) 1819 1839 } 1820 1840 1821 1841 fn resolve_node( ··· 1824 1844 stylesheet: &Stylesheet, 1825 1845 parent_style: &ComputedStyle, 1826 1846 viewport: (f32, f32), 1847 + media_ctx: &MediaContext, 1827 1848 ) -> Option<StyledNode> { 1828 1849 match doc.node_data(node) { 1829 1850 NodeData::Document => { 1830 1851 // Document node: resolve children, return first element child or wrapper. 1831 1852 let mut children = Vec::new(); 1832 1853 for child in doc.children(node) { 1833 - if let Some(styled) = resolve_node(doc, child, stylesheet, parent_style, viewport) { 1854 + if let Some(styled) = 1855 + resolve_node(doc, child, stylesheet, parent_style, viewport, media_ctx) 1856 + { 1834 1857 children.push(styled); 1835 1858 } 1836 1859 } ··· 1847 1870 } 1848 1871 } 1849 1872 NodeData::Element { .. } => { 1850 - let style = compute_style_for_element(doc, node, stylesheet, parent_style, viewport); 1873 + let style = 1874 + compute_style_for_element(doc, node, stylesheet, parent_style, viewport, media_ctx); 1851 1875 1852 1876 if style.display == Display::None { 1853 1877 return None; ··· 1855 1879 1856 1880 let mut children = Vec::new(); 1857 1881 for child in doc.children(node) { 1858 - if let Some(styled) = resolve_node(doc, child, stylesheet, &style, viewport) { 1882 + if let Some(styled) = 1883 + resolve_node(doc, child, stylesheet, &style, viewport, media_ctx) 1884 + { 1859 1885 children.push(styled); 1860 1886 } 1861 1887 } ··· 1888 1914 stylesheet: &Stylesheet, 1889 1915 parent_style: &ComputedStyle, 1890 1916 viewport: (f32, f32), 1917 + media_ctx: &MediaContext, 1891 1918 ) -> ComputedStyle { 1892 1919 // Start from initial values, inheriting inherited properties from parent 1893 1920 let mut style = ComputedStyle { ··· 1905 1932 }; 1906 1933 1907 1934 // Step 2: Collect matching rules, sorted by specificity + source order 1908 - let matched_rules = collect_matching_rules(doc, node, stylesheet); 1935 + let matched_rules = collect_matching_rules(doc, node, stylesheet, media_ctx); 1909 1936 1910 1937 // Step 3: Parse inline style declarations (from style attribute). 1911 1938 let inline_decls = parse_inline_style(doc, node);
+22 -8
crates/style/src/matching.rs
··· 2 2 //! 3 3 //! Matches selectors against DOM elements and computes specificity. 4 4 5 + use we_css::media::MediaContext; 5 6 use we_css::parser::{ 6 7 AttributeOp, AttributeSelector, Combinator, CompoundSelector, Declaration, Rule, Selector, 7 8 SelectorComponent, SelectorList, SimpleSelector, StyleRule, Stylesheet, ··· 272 273 doc: &Document, 273 274 node: NodeId, 274 275 stylesheet: &'a Stylesheet, 276 + media_ctx: &MediaContext, 275 277 ) -> Vec<MatchedRule<'a>> { 276 278 let mut matched = Vec::new(); 277 - collect_from_rules(doc, node, &stylesheet.rules, &mut matched, &mut 0); 279 + collect_from_rules( 280 + doc, 281 + node, 282 + &stylesheet.rules, 283 + &mut matched, 284 + &mut 0, 285 + media_ctx, 286 + ); 278 287 matched.sort_by(|a, b| { 279 288 a.specificity 280 289 .cmp(&b.specificity) ··· 289 298 rules: &'a [Rule], 290 299 matched: &mut Vec<MatchedRule<'a>>, 291 300 order: &mut usize, 301 + media_ctx: &MediaContext, 292 302 ) { 293 303 for rule in rules { 294 304 match rule { ··· 310 320 } 311 321 } 312 322 Rule::Media(media_rule) => { 313 - // For now, assume all media rules match (media query evaluation 314 - // is out of scope for this issue). 315 - collect_from_rules(doc, node, &media_rule.rules, matched, order); 323 + if media_rule.queries.evaluate(media_ctx) { 324 + collect_from_rules(doc, node, &media_rule.rules, matched, order, media_ctx); 325 + } 316 326 } 317 327 Rule::Import(_) => { 318 328 // Imports are resolved at a higher level; skip here. ··· 340 350 doc: &Document, 341 351 node: NodeId, 342 352 stylesheet: &'a Stylesheet, 353 + media_ctx: &MediaContext, 343 354 ) -> Vec<&'a Declaration> { 344 - let matched_rules = collect_matching_rules(doc, node, stylesheet); 355 + let matched_rules = collect_matching_rules(doc, node, stylesheet, media_ctx); 345 356 346 357 let mut normal: Vec<(Specificity, usize, &'a Declaration)> = Vec::new(); 347 358 let mut important: Vec<(Specificity, usize, &'a Declaration)> = Vec::new(); ··· 747 758 "#, 748 759 ); 749 760 750 - let matched = collect_matching_rules(&t.doc, t.p_intro, &ss); 761 + let mctx = MediaContext::from_viewport(800.0, 600.0); 762 + let matched = collect_matching_rules(&t.doc, t.p_intro, &ss, &mctx); 751 763 assert_eq!(matched.len(), 2); 752 764 assert_eq!(matched[0].specificity, Specificity::new(0, 0, 1)); 753 765 assert_eq!(matched[1].specificity, Specificity::new(0, 1, 0)); ··· 763 775 "#, 764 776 ); 765 777 766 - let matched = collect_matching_rules(&t.doc, t.p_intro, &ss); 778 + let mctx = MediaContext::from_viewport(800.0, 600.0); 779 + let matched = collect_matching_rules(&t.doc, t.p_intro, &ss, &mctx); 767 780 assert_eq!(matched.len(), 2); 768 781 assert!(matched[0].source_order < matched[1].source_order); 769 782 } ··· 782 795 "#, 783 796 ); 784 797 785 - let decls = get_declarations_for_node(&t.doc, t.p_intro, &ss); 798 + let mctx = MediaContext::from_viewport(800.0, 600.0); 799 + let decls = get_declarations_for_node(&t.doc, t.p_intro, &ss, &mctx); 786 800 assert_eq!(decls.len(), 2); 787 801 assert!(!decls[0].important); 788 802 assert!(decls[1].important);