Skip to main content

velox_dom/
style.rs

1// CSS Style Types and Utilities
2//
3// This module provides CSS length, color, and computed style types
4// used throughout Velox for rendering and layout.
5
6use std::fmt;
7
8/// A CSS length value with unit
9#[derive(Debug, Clone, Copy, PartialEq, Default)]
10pub enum Length {
11    /// Pixels (px)
12    Px(f32),
13    /// Percentage (%)
14    Percent(f32),
15    /// Root em (rem) - relative to root font size
16    Rem(f32),
17    /// Em - relative to parent font size
18    Em(f32),
19    /// Viewport width (vw)
20    Vw(f32),
21    /// Viewport height (vh)
22    Vh(f32),
23    /// Dynamic viewport height (dvh) - treated as vh for layout
24    Dvh(f32),
25    /// Dynamic viewport width (dvw) - treated as vw for layout
26    Dvw(f32),
27    /// Auto length
28    Auto,
29    /// Zero (unitless)
30    #[default]
31    Zero,
32}
33
34impl Length {
35    /// Parse a CSS length string
36    pub fn parse(s: &str) -> Option<Self> {
37        let s = s.trim();
38
39        if s == "auto" {
40            return Some(Length::Auto);
41        }
42
43        if s == "0" {
44            return Some(Length::Zero);
45        }
46
47        // Try to parse with units
48        if let Some(val) = s.strip_suffix("px") {
49            return val.trim().parse::<f32>().ok().map(Length::Px);
50        }
51
52        if let Some(val) = s.strip_suffix('%') {
53            return val.trim().parse::<f32>().ok().map(Length::Percent);
54        }
55
56        if let Some(val) = s.strip_suffix("rem") {
57            return val.trim().parse::<f32>().ok().map(Length::Rem);
58        }
59
60        if let Some(val) = s.strip_suffix("em") {
61            return val.trim().parse::<f32>().ok().map(Length::Em);
62        }
63
64        if let Some(val) = s.strip_suffix("dvw") {
65            return val.trim().parse::<f32>().ok().map(Length::Dvw);
66        }
67
68        if let Some(val) = s.strip_suffix("dvh") {
69            return val.trim().parse::<f32>().ok().map(Length::Dvh);
70        }
71
72        if let Some(val) = s.strip_suffix("vw") {
73            return val.trim().parse::<f32>().ok().map(Length::Vw);
74        }
75
76        if let Some(val) = s.strip_suffix("vh") {
77            return val.trim().parse::<f32>().ok().map(Length::Vh);
78        }
79
80        // Try plain number as pixels
81        s.parse::<f32>().ok().map(Length::Px)
82    }
83
84    /// Convert to pixels given context values
85    pub fn to_px(&self, parent_size: f32, root_size: f32, viewport: (f32, f32)) -> f32 {
86        match *self {
87            Length::Px(v) => v,
88            Length::Percent(v) => parent_size * v / 100.0,
89            Length::Rem(v) => v * root_size,
90            Length::Em(v) => v * parent_size,
91            Length::Vw(v) => v * viewport.0 / 100.0,
92            Length::Vh(v) => v * viewport.1 / 100.0,
93            Length::Dvw(v) => v * viewport.0 / 100.0,
94            Length::Dvh(v) => v * viewport.1 / 100.0,
95            Length::Auto => 0.0, // Auto needs special handling
96            Length::Zero => 0.0,
97        }
98    }
99
100    /// Check if length is auto
101    pub fn is_auto(&self) -> bool {
102        matches!(self, Length::Auto)
103    }
104}
105
106impl fmt::Display for Length {
107    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
108        match self {
109            Length::Px(v) => write!(f, "{}px", v),
110            Length::Percent(v) => write!(f, "{}%", v),
111            Length::Rem(v) => write!(f, "{}rem", v),
112            Length::Em(v) => write!(f, "{}em", v),
113            Length::Vw(v) => write!(f, "{}vw", v),
114            Length::Vh(v) => write!(f, "{}vh", v),
115            Length::Dvw(v) => write!(f, "{}dvw", v),
116            Length::Dvh(v) => write!(f, "{}dvh", v),
117            Length::Auto => write!(f, "auto"),
118            Length::Zero => write!(f, "0"),
119        }
120    }
121}
122
123/// A CSS color value
124#[derive(Debug, Clone, Copy, PartialEq)]
125pub struct Color {
126    pub r: u8,
127    pub g: u8,
128    pub b: u8,
129    pub a: u8,
130}
131
132impl Color {
133    /// Transparent color
134    pub const TRANSPARENT: Color = Color {
135        r: 0,
136        g: 0,
137        b: 0,
138        a: 0,
139    };
140    /// Black
141    pub const BLACK: Color = Color {
142        r: 0,
143        g: 0,
144        b: 0,
145        a: 255,
146    };
147    /// White
148    pub const WHITE: Color = Color {
149        r: 255,
150        g: 255,
151        b: 255,
152        a: 255,
153    };
154    /// Red
155    pub const RED: Color = Color {
156        r: 255,
157        g: 0,
158        b: 0,
159        a: 255,
160    };
161    /// Green
162    pub const GREEN: Color = Color {
163        r: 0,
164        g: 255,
165        b: 0,
166        a: 255,
167    };
168    /// Blue
169    pub const BLUE: Color = Color {
170        r: 0,
171        g: 0,
172        b: 255,
173        a: 255,
174    };
175
176    /// Modern dark theme colors
177    pub const DARK_BG: Color = Color {
178        r: 26,
179        g: 26,
180        b: 26,
181        a: 255,
182    }; // #1a1a1a
183    pub const DARK_TEXT: Color = Color {
184        r: 224,
185        g: 224,
186        b: 224,
187        a: 255,
188    }; // #e0e0e0
189    pub const DARK_CARD: Color = Color {
190        r: 38,
191        g: 38,
192        b: 38,
193        a: 255,
194    }; // #262626
195    pub const ACCENT_BLUE: Color = Color {
196        r: 52,
197        g: 120,
198        b: 246,
199        a: 255,
200    }; // #3478f6
201    pub const ACCENT_GREEN: Color = Color {
202        r: 34,
203        g: 197,
204        b: 94,
205        a: 255,
206    }; // #22c55e
207    pub const BORDER_DARK: Color = Color {
208        r: 55,
209        g: 65,
210        b: 81,
211        a: 255,
212    }; // #374151
213
214    /// Create a new color
215    pub fn new(r: u8, g: u8, b: u8, a: u8) -> Self {
216        Color { r, g, b, a }
217    }
218
219    /// Parse a CSS color string (hex, rgb, rgba, named)
220    pub fn parse(s: &str) -> Option<Self> {
221        let s = s.trim();
222
223        // Named colors
224        let named = match s.to_lowercase().as_str() {
225            "transparent" => return Some(Color::TRANSPARENT),
226            "black" => Color::BLACK,
227            "white" => Color::WHITE,
228            "red" => Color::RED,
229            "green" => Color::GREEN,
230            "blue" => Color::BLUE,
231            "yellow" => Color {
232                r: 255,
233                g: 255,
234                b: 0,
235                a: 255,
236            },
237            "cyan" => Color {
238                r: 0,
239                g: 255,
240                b: 255,
241                a: 255,
242            },
243            "magenta" => Color {
244                r: 255,
245                g: 0,
246                b: 255,
247                a: 255,
248            },
249            "silver" => Color {
250                r: 192,
251                g: 192,
252                b: 192,
253                a: 255,
254            },
255            "gray" | "grey" => Color {
256                r: 128,
257                g: 128,
258                b: 128,
259                a: 255,
260            },
261            "maroon" => Color {
262                r: 128,
263                g: 0,
264                b: 0,
265                a: 255,
266            },
267            "olive" => Color {
268                r: 128,
269                g: 128,
270                b: 0,
271                a: 255,
272            },
273            "lime" => Color {
274                r: 0,
275                g: 255,
276                b: 0,
277                a: 255,
278            },
279            "aqua" => Color {
280                r: 0,
281                g: 255,
282                b: 255,
283                a: 255,
284            },
285            "teal" => Color {
286                r: 0,
287                g: 128,
288                b: 128,
289                a: 255,
290            },
291            "navy" => Color {
292                r: 0,
293                g: 0,
294                b: 128,
295                a: 255,
296            },
297            "fuchsia" => Color {
298                r: 255,
299                g: 0,
300                b: 255,
301                a: 255,
302            },
303            "purple" => Color {
304                r: 128,
305                g: 0,
306                b: 128,
307                a: 255,
308            },
309            "orange" => Color {
310                r: 255,
311                g: 165,
312                b: 0,
313                a: 255,
314            },
315            "pink" => Color {
316                r: 255,
317                g: 192,
318                b: 203,
319                a: 255,
320            },
321            _ => {
322                // Try hex
323                if let Some(hex) = s.strip_prefix('#') {
324                    return Self::parse_hex(hex);
325                }
326                // Try rgb/rgba
327                if s.starts_with("rgb") {
328                    return Self::parse_rgb(s);
329                }
330                return None;
331            }
332        };
333
334        Some(named)
335    }
336
337    fn parse_hex(hex: &str) -> Option<Self> {
338        let hex = hex.trim();
339        match hex.len() {
340            3 => {
341                let r = u8::from_str_radix(&hex[0..1].repeat(2), 16).ok()?;
342                let g = u8::from_str_radix(&hex[1..2].repeat(2), 16).ok()?;
343                let b = u8::from_str_radix(&hex[2..3].repeat(2), 16).ok()?;
344                Some(Color::new(r, g, b, 255))
345            }
346            6 => {
347                let r = u8::from_str_radix(&hex[0..2], 16).ok()?;
348                let g = u8::from_str_radix(&hex[2..4], 16).ok()?;
349                let b = u8::from_str_radix(&hex[4..6], 16).ok()?;
350                Some(Color::new(r, g, b, 255))
351            }
352            8 => {
353                let r = u8::from_str_radix(&hex[0..2], 16).ok()?;
354                let g = u8::from_str_radix(&hex[2..4], 16).ok()?;
355                let b = u8::from_str_radix(&hex[4..6], 16).ok()?;
356                let a = u8::from_str_radix(&hex[6..8], 16).ok()?;
357                Some(Color::new(r, g, b, a))
358            }
359            _ => None,
360        }
361    }
362
363    fn parse_rgb(s: &str) -> Option<Self> {
364        // Try rgba first (longer prefix), then rgb — strip_prefix is exact match
365        let inner = s
366            .strip_prefix("rgba(")
367            .or_else(|| s.strip_prefix("rgb("))?
368            .trim_end_matches(')')
369            .trim();
370
371        let parts: Vec<&str> = inner.split(',').map(|p| p.trim()).collect();
372
373        if parts.len() < 3 {
374            return None;
375        }
376
377        let r = parts[0].parse::<u8>().ok()?;
378        let g = parts[1].parse::<u8>().ok()?;
379        let b = parts[2].parse::<u8>().ok()?;
380        let a = if parts.len() >= 4 {
381            // Parse alpha as 0-1 or 0-255
382            let a_str = parts[3];
383            if let Ok(a_val) = a_str.parse::<f32>() {
384                if a_val <= 1.0 {
385                    (a_val * 255.0) as u8
386                } else {
387                    a_val as u8
388                }
389            } else {
390                255
391            }
392        } else {
393            255
394        };
395
396        Some(Color::new(r, g, b, a))
397    }
398
399    /// Convert to RGBA tuple (0-255)
400    pub fn to_rgba(&self) -> (u8, u8, u8, u8) {
401        (self.r, self.g, self.b, self.a)
402    }
403
404    /// Convert to normalized RGBA (0.0-1.0)
405    pub fn to_normalized(&self) -> (f32, f32, f32, f32) {
406        (
407            self.r as f32 / 255.0,
408            self.g as f32 / 255.0,
409            self.b as f32 / 255.0,
410            self.a as f32 / 255.0,
411        )
412    }
413}
414
415impl fmt::Display for Color {
416    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
417        if self.a == 255 {
418            write!(f, "#{:02x}{:02x}{:02x}", self.r, self.g, self.b)
419        } else {
420            write!(
421                f,
422                "rgba({}, {}, {}, {})",
423                self.r,
424                self.g,
425                self.b,
426                self.a as f32 / 255.0
427            )
428        }
429    }
430}
431
432impl Default for Color {
433    fn default() -> Self {
434        Color::BLACK
435    }
436}
437
438/// Sides type for margin, padding, etc.
439#[derive(Debug, Clone, PartialEq)]
440pub struct Sides<T> {
441    pub top: T,
442    pub right: T,
443    pub bottom: T,
444    pub left: T,
445}
446
447impl<T: Default> Default for Sides<T> {
448    fn default() -> Self {
449        Self {
450            top: T::default(),
451            right: T::default(),
452            bottom: T::default(),
453            left: T::default(),
454        }
455    }
456}
457
458impl<T> Sides<T> {
459    pub fn new() -> Self
460    where
461        T: Default,
462    {
463        Self::default()
464    }
465
466    pub fn all(value: T) -> Self
467    where
468        T: Copy,
469    {
470        Self {
471            top: value,
472            right: value,
473            bottom: value,
474            left: value,
475        }
476    }
477}
478
479/// Border style enum
480#[derive(Debug, Clone, Copy, PartialEq, Default)]
481pub enum BorderStyle {
482    #[default]
483    None,
484    Hidden,
485    Solid,
486    Dashed,
487    Dotted,
488    Double,
489    Groove,
490    Ridge,
491    Inset,
492    Outset,
493}
494
495impl BorderStyle {
496    pub fn parse(s: &str) -> Option<Self> {
497        match s.trim().to_lowercase().as_str() {
498            "none" => Some(BorderStyle::None),
499            "hidden" => Some(BorderStyle::Hidden),
500            "solid" => Some(BorderStyle::Solid),
501            "dashed" => Some(BorderStyle::Dashed),
502            "dotted" => Some(BorderStyle::Dotted),
503            "double" => Some(BorderStyle::Double),
504            "groove" => Some(BorderStyle::Groove),
505            "ridge" => Some(BorderStyle::Ridge),
506            "inset" => Some(BorderStyle::Inset),
507            "outset" => Some(BorderStyle::Outset),
508            _ => None,
509        }
510    }
511}
512
513/// Border struct
514#[derive(Debug, Clone, PartialEq)]
515pub struct Border {
516    pub width: Sides<Length>,
517    pub style: Sides<BorderStyle>,
518    pub color: Sides<Color>,
519}
520
521impl Default for Border {
522    fn default() -> Self {
523        Self {
524            width: Sides::default(),
525            style: Sides::default(),
526            color: Sides::all(Color::BLACK),
527        }
528    }
529}
530
531/// Text align enum
532#[derive(Debug, Clone, Copy, PartialEq, Default)]
533pub enum TextAlign {
534    #[default]
535    Left,
536    Center,
537    Right,
538    Justify,
539}
540
541impl TextAlign {
542    pub fn parse(s: &str) -> Option<Self> {
543        match s.trim().to_lowercase().as_str() {
544            "left" => Some(TextAlign::Left),
545            "center" => Some(TextAlign::Center),
546            "right" => Some(TextAlign::Right),
547            "justify" => Some(TextAlign::Justify),
548            _ => None,
549        }
550    }
551}
552
553/// How an inline-level box is aligned within its line box.
554///
555/// `Baseline` is the initial value and the only one CSS 2.1 §10.8.1 defines for
556/// a line box's own text. `Top`, `Bottom` and `Middle` are real and are honoured
557/// in the line box. `Sub` and `Super` are NOT here: they are defined as a shift
558/// the font's own metrics supply (`sub` may shift by "the font's own subscript
559/// offset"), and the seam reports a run's INK, not the font's subscript offset, so
560/// any value here would be a made-up number. An unparseable value returns `None`
561/// and the INHERITED value stands -- `Baseline` is the initial value and comes
562/// from the property's absence, which is what CSS 2.1 §10.8.1 specifies -- and
563/// that is also what keeps a `sub` declaration from silently becoming a
564/// `baseline` one.
565#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
566pub enum VerticalAlign {
567    #[default]
568    Baseline,
569    Top,
570    Bottom,
571    Middle,
572}
573
574impl VerticalAlign {
575    pub fn parse(s: &str) -> Option<Self> {
576        match s.trim().to_lowercase().as_str() {
577            "baseline" => Some(VerticalAlign::Baseline),
578            "top" => Some(VerticalAlign::Top),
579            "bottom" => Some(VerticalAlign::Bottom),
580            "middle" => Some(VerticalAlign::Middle),
581            // A LENGTH or PERCENTAGE is a legal `vertical-align` value in CSS and
582            // is meaningful, so it must not be silently read as `baseline`. It is
583            // also not something the line box can honour without the strut's
584            // coordinates relative to a baseline it does not have yet, so it is
585            // rejected here and `parse` returning None leaves the inherited value.
586            _ => None,
587        }
588    }
589}
590
591/// Font weight enum
592#[derive(Debug, Clone, Copy, PartialEq, Default)]
593pub enum FontWeight {
594    #[default]
595    Normal,
596    Bold,
597    Bolder,
598    Lighter,
599    Value(u16),
600}
601
602impl FontWeight {
603    pub fn parse(s: &str) -> Option<Self> {
604        match s.trim().to_lowercase().as_str() {
605            "normal" => Some(FontWeight::Normal),
606            "bold" => Some(FontWeight::Bold),
607            "bolder" => Some(FontWeight::Bolder),
608            "lighter" => Some(FontWeight::Lighter),
609            _ => s.parse::<u16>().ok().map(FontWeight::Value),
610        }
611    }
612
613    pub fn to_number(&self) -> u16 {
614        match self {
615            FontWeight::Normal => 400,
616            FontWeight::Bold => 700,
617            FontWeight::Bolder => 900,
618            FontWeight::Lighter => 100,
619            FontWeight::Value(v) => *v,
620        }
621    }
622}
623
624/// Font style enum.
625///
626/// Deliberately a local definition rather than a reuse of
627/// `velox_style::fonts::FontStyle`, which has the same three variants. Both
628/// `velox-renderer` and `velox-style` depend on `velox-dom`, and `velox-style`
629/// is only a *dev*-dependency of it, so importing it here would close a
630/// dependency cycle. Keep the two definitions in step.
631#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
632pub enum FontStyle {
633    #[default]
634    Normal,
635    Italic,
636    Oblique,
637}
638
639impl FontStyle {
640    pub fn parse(s: &str) -> Option<Self> {
641        match s.trim().to_lowercase().as_str() {
642            "normal" => Some(FontStyle::Normal),
643            "italic" => Some(FontStyle::Italic),
644            "oblique" => Some(FontStyle::Oblique),
645            _ => None,
646        }
647    }
648}
649
650/// Text decoration enum
651#[derive(Debug, Clone, Copy, PartialEq, Default)]
652pub enum TextDecoration {
653    #[default]
654    None,
655    Underline,
656    Overline,
657    LineThrough,
658}
659
660impl TextDecoration {
661    pub fn parse(s: &str) -> Option<Self> {
662        match s.trim().to_lowercase().as_str() {
663            "none" => Some(TextDecoration::None),
664            "underline" => Some(TextDecoration::Underline),
665            "overline" => Some(TextDecoration::Overline),
666            "line-through" => Some(TextDecoration::LineThrough),
667            _ => None,
668        }
669    }
670}
671
672/// Flex wrap enum
673#[derive(Debug, Clone, Copy, PartialEq, Default)]
674pub enum FlexWrap {
675    #[default]
676    NoWrap,
677    Wrap,
678    WrapReverse,
679}
680
681impl FlexWrap {
682    pub fn parse(s: &str) -> Option<Self> {
683        match s.trim().to_lowercase().as_str() {
684            "nowrap" => Some(FlexWrap::NoWrap),
685            "wrap" => Some(FlexWrap::Wrap),
686            "wrap-reverse" => Some(FlexWrap::WrapReverse),
687            _ => None,
688        }
689    }
690}
691
692/// Box sizing enum
693#[derive(Debug, Clone, Copy, PartialEq, Default)]
694pub enum BoxSizing {
695    #[default]
696    ContentBox,
697    BorderBox,
698}
699
700impl BoxSizing {
701    pub fn parse(s: &str) -> Option<Self> {
702        match s.trim().to_lowercase().as_str() {
703            "content-box" => Some(BoxSizing::ContentBox),
704            "border-box" => Some(BoxSizing::BorderBox),
705            _ => None,
706        }
707    }
708}
709
710/// Visibility enum
711#[derive(Debug, Clone, Copy, PartialEq, Default)]
712pub enum Visibility {
713    #[default]
714    Visible,
715    Hidden,
716    Collapse,
717}
718
719impl Visibility {
720    pub fn parse(s: &str) -> Option<Self> {
721        match s.trim().to_lowercase().as_str() {
722            "visible" => Some(Visibility::Visible),
723            "hidden" => Some(Visibility::Hidden),
724            "collapse" => Some(Visibility::Collapse),
725            _ => None,
726        }
727    }
728}
729
730/// Transform operations
731#[derive(Debug, Clone, PartialEq)]
732pub enum TransformOp {
733    TranslateX(Length),
734    TranslateY(Length),
735    Translate(Length, Length),
736    Rotate(f32),
737    ScaleX(f32),
738    ScaleY(f32),
739    Scale(f32, f32),
740    SkewX(f32),
741    SkewY(f32),
742    Skew(f32, f32),
743}
744
745/// Transform struct
746#[derive(Debug, Clone, PartialEq)]
747pub struct Transform {
748    pub operations: Vec<TransformOp>,
749}
750
751impl Transform {
752    pub fn new() -> Self {
753        Self {
754            operations: Vec::new(),
755        }
756    }
757
758    pub fn is_empty(&self) -> bool {
759        self.operations.is_empty()
760    }
761}
762
763impl Default for Transform {
764    fn default() -> Self {
765        Self::new()
766    }
767}
768
769impl Transform {
770    /// Parse a `transform` value such as `translate(10px, 20px) rotate(45deg) scale(1.5)`.
771    ///
772    /// Supports the three operations surfaced by Velox: `translate`/`translateX`/`translateY`,
773    /// `rotate`, and `scale`/`scaleX`/`scaleY`. `none` and empty values produce an empty transform.
774    pub fn parse(s: &str) -> Option<Self> {
775        let s = s.trim();
776        if s.is_empty() || s.eq_ignore_ascii_case("none") {
777            return Some(Self::new());
778        }
779        let mut operations = Vec::new();
780        // Each `<name>(<args>)` is a single operation. Args may contain spaces after commas
781        // (`translate(10px, 20px)`), so accumulate whitespace-separated runs into one token
782        // until parentheses balance.
783        let mut current = String::new();
784        let mut depth = 0usize;
785        for token in s.split_whitespace() {
786            if !current.is_empty() {
787                current.push(' ');
788            }
789            current.push_str(token);
790            depth = depth
791                .saturating_add(token.matches('(').count())
792                .saturating_sub(token.matches(')').count());
793            if depth == 0 {
794                operations.push(TransformOp::parse(&current)?);
795                current.clear();
796            }
797        }
798        if !current.is_empty() {
799            operations.push(TransformOp::parse(&current)?);
800        }
801        Some(Transform { operations })
802    }
803}
804
805impl TransformOp {
806    fn parse(s: &str) -> Option<Self> {
807        let s = s.trim();
808        let open = s.find('(')?;
809        let close = s.rfind(')')?;
810        if close < open {
811            return None;
812        }
813        let name = s[..open].trim().to_ascii_lowercase();
814        let args: Vec<&str> = s[open + 1..close].split(',').map(|a| a.trim()).collect();
815
816        match name.as_str() {
817            "translate" => {
818                let x = Length::parse(args.first()?)?;
819                let y = if args.len() > 1 {
820                    Length::parse(args[1])?
821                } else {
822                    Length::Zero
823                };
824                Some(TransformOp::Translate(x, y))
825            }
826            "translatex" => Some(TransformOp::TranslateX(Length::parse(args.first()?)?)),
827            "translatey" => Some(TransformOp::TranslateY(Length::parse(args.first()?)?)),
828            "rotate" => {
829                let raw = args.first()?.trim();
830                let deg_raw = raw.strip_suffix("deg").unwrap_or(raw).trim();
831                let deg = deg_raw.parse::<f32>().ok()?;
832                Some(TransformOp::Rotate(deg))
833            }
834            "scale" => {
835                let x = args.first()?.trim().parse::<f32>().ok()?;
836                let y = if args.len() > 1 {
837                    args[1].trim().parse::<f32>().ok()?
838                } else {
839                    x
840                };
841                Some(TransformOp::Scale(x, y))
842            }
843            "scalex" => Some(TransformOp::ScaleX(
844                args.first()?.trim().parse::<f32>().ok()?,
845            )),
846            "scaley" => Some(TransformOp::ScaleY(
847                args.first()?.trim().parse::<f32>().ok()?,
848            )),
849            _ => None,
850        }
851    }
852}
853
854/// Box shadow struct
855#[derive(Debug, Clone, PartialEq)]
856pub struct BoxShadow {
857    pub offset_x: Length,
858    pub offset_y: Length,
859    pub blur_radius: Length,
860    pub spread_radius: Length,
861    pub color: Color,
862    pub inset: bool,
863}
864
865impl BoxShadow {
866    /// Parse a `box-shadow` value such as `2px 2px 4px rgba(0,0,0,0.5)` or
867    /// `inset 0 2px 4px #000`. Returns `None` for `none` / empty values.
868    pub fn parse(s: &str) -> Option<Self> {
869        let s = s.trim();
870        if s.is_empty() || s.eq_ignore_ascii_case("none") {
871            return None;
872        }
873        let mut inset = false;
874        let mut parts: Vec<&str> = Vec::new();
875        for tok in s.split_whitespace() {
876            if tok.eq_ignore_ascii_case("inset") {
877                inset = true;
878            } else {
879                parts.push(tok);
880            }
881        }
882        // offset-x and offset-y are required.
883        let offset_x = Length::parse(parts.first()?)?;
884        let offset_y = Length::parse(parts.get(1)?)?;
885        let mut blur: Option<Length> = None;
886        let mut spread: Option<Length> = None;
887        let mut color = Color::BLACK;
888        for tok in &parts[2..] {
889            if let Some(l) = Length::parse(tok) {
890                if blur.is_none() {
891                    blur = Some(l);
892                } else if spread.is_none() {
893                    spread = Some(l);
894                }
895            } else if let Some(c) = Color::parse(tok) {
896                color = c;
897            }
898        }
899        Some(BoxShadow {
900            offset_x,
901            offset_y,
902            blur_radius: blur.unwrap_or(Length::Zero),
903            spread_radius: spread.unwrap_or(Length::Zero),
904            color,
905            inset,
906        })
907    }
908}
909
910/// CSS transition timing function
911#[derive(Debug, Clone, PartialEq, Default)]
912pub enum TimingFunction {
913    #[default]
914    Linear,
915    Ease,
916    EaseIn,
917    EaseOut,
918    EaseInOut,
919    CubicBezier(f32, f32, f32, f32),
920}
921
922impl TimingFunction {
923    pub fn parse(s: &str) -> Option<Self> {
924        match s.trim().to_ascii_lowercase().as_str() {
925            "linear" => Some(TimingFunction::Linear),
926            "ease" => Some(TimingFunction::Ease),
927            "ease-in" => Some(TimingFunction::EaseIn),
928            "ease-out" => Some(TimingFunction::EaseOut),
929            "ease-in-out" => Some(TimingFunction::EaseInOut),
930            _ => {
931                let s = s.trim();
932                if let Some(inner) = s.strip_prefix("cubic-bezier(") {
933                    let inner = inner.trim_end_matches(')');
934                    let parts: Vec<f32> = inner
935                        .split(',')
936                        .map(|p| p.trim().parse::<f32>())
937                        .collect::<Result<Vec<_>, _>>()
938                        .ok()?;
939                    if parts.len() == 4 {
940                        return Some(TimingFunction::CubicBezier(
941                            parts[0], parts[1], parts[2], parts[3],
942                        ));
943                    }
944                }
945                None
946            }
947        }
948    }
949}
950
951/// A single CSS transition (e.g. `opacity 0.2s ease-in 0.1s`)
952#[derive(Debug, Clone, PartialEq)]
953pub struct Transition {
954    /// The CSS property being transitioned, or `all`.
955    pub property: String,
956    /// Transition duration in seconds.
957    pub duration: f32,
958    /// Timing function applied over the duration.
959    pub timing_function: TimingFunction,
960    /// Transition delay in seconds.
961    pub delay: f32,
962}
963
964impl Default for Transition {
965    fn default() -> Self {
966        Self {
967            property: "all".to_string(),
968            duration: 0.0,
969            timing_function: TimingFunction::Linear,
970            delay: 0.0,
971        }
972    }
973}
974
975impl Transition {
976    /// Parse a single transition declaration value, e.g. `opacity 0.2s ease-in 0.1s`.
977    /// Returns `None` for `none` / empty values.
978    pub fn parse(value: &str) -> Option<Self> {
979        let value = value.trim();
980        if value.is_empty() || value.eq_ignore_ascii_case("none") {
981            return None;
982        }
983        let mut t = Transition::default();
984        let mut seen_time = false;
985        for tok in value.split_whitespace() {
986            if let Some(num) = parse_time_seconds(tok) {
987                if !seen_time {
988                    t.duration = num;
989                } else {
990                    t.delay = num;
991                }
992                seen_time = true;
993            } else if let Some(tf) = TimingFunction::parse(tok) {
994                t.timing_function = tf;
995            } else {
996                t.property = tok.trim_matches(',').to_string();
997            }
998        }
999        if t.property.is_empty() {
1000            t.property = "all".to_string();
1001        }
1002        Some(t)
1003    }
1004}
1005
1006fn parse_time_seconds(s: &str) -> Option<f32> {
1007    if let Some(v) = s.strip_suffix("ms") {
1008        return v.trim().parse::<f32>().ok().map(|ms| ms / 1000.0);
1009    }
1010    if let Some(v) = s.strip_suffix('s') {
1011        return v.trim().parse::<f32>().ok();
1012    }
1013    None
1014}
1015
1016/// Display enum
1017#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
1018pub enum Display {
1019    #[default]
1020    Block,
1021    Inline,
1022    InlineBlock,
1023    Flex,
1024    Grid,
1025    None,
1026}
1027
1028impl Display {
1029    pub fn parse(s: &str) -> Option<Self> {
1030        match s.trim().to_lowercase().as_str() {
1031            "block" => Some(Display::Block),
1032            "inline" => Some(Display::Inline),
1033            "inline-block" => Some(Display::InlineBlock),
1034            "flex" => Some(Display::Flex),
1035            "grid" => Some(Display::Grid),
1036            "none" | "hidden" => Some(Display::None),
1037            _ => None,
1038        }
1039    }
1040}
1041
1042/// Position enum
1043#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
1044pub enum Position {
1045    #[default]
1046    Static,
1047    Relative,
1048    Absolute,
1049    Fixed,
1050    Sticky,
1051}
1052
1053impl Position {
1054    pub fn parse(s: &str) -> Option<Self> {
1055        match s.trim().to_lowercase().as_str() {
1056            "static" => Some(Position::Static),
1057            "relative" => Some(Position::Relative),
1058            "absolute" => Some(Position::Absolute),
1059            "fixed" => Some(Position::Fixed),
1060            "sticky" => Some(Position::Sticky),
1061            _ => None,
1062        }
1063    }
1064}
1065
1066/// Flex direction
1067#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
1068pub enum FlexDirection {
1069    #[default]
1070    Row,
1071    RowReverse,
1072    Column,
1073    ColumnReverse,
1074}
1075
1076impl FlexDirection {
1077    pub fn parse(s: &str) -> Option<Self> {
1078        match s.trim().to_lowercase().as_str() {
1079            "row" => Some(FlexDirection::Row),
1080            "row-reverse" => Some(FlexDirection::RowReverse),
1081            "column" => Some(FlexDirection::Column),
1082            "column-reverse" => Some(FlexDirection::ColumnReverse),
1083            _ => None,
1084        }
1085    }
1086}
1087
1088/// Justify content
1089#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
1090pub enum JustifyContent {
1091    #[default]
1092    FlexStart,
1093    FlexEnd,
1094    Center,
1095    SpaceBetween,
1096    SpaceAround,
1097    SpaceEvenly,
1098}
1099
1100impl JustifyContent {
1101    pub fn parse(s: &str) -> Option<Self> {
1102        match s.trim().to_lowercase().as_str() {
1103            "flex-start" | "start" => Some(JustifyContent::FlexStart),
1104            "flex-end" | "end" => Some(JustifyContent::FlexEnd),
1105            "center" => Some(JustifyContent::Center),
1106            "space-between" => Some(JustifyContent::SpaceBetween),
1107            "space-around" => Some(JustifyContent::SpaceAround),
1108            "space-evenly" => Some(JustifyContent::SpaceEvenly),
1109            _ => None,
1110        }
1111    }
1112}
1113
1114/// Align items
1115#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
1116pub enum AlignItems {
1117    FlexStart,
1118    FlexEnd,
1119    Center,
1120    Baseline,
1121    #[default]
1122    Stretch,
1123}
1124
1125impl AlignItems {
1126    pub fn parse(s: &str) -> Option<Self> {
1127        match s.trim().to_lowercase().as_str() {
1128            "flex-start" | "start" => Some(AlignItems::FlexStart),
1129            "flex-end" | "end" => Some(AlignItems::FlexEnd),
1130            "center" => Some(AlignItems::Center),
1131            "baseline" => Some(AlignItems::Baseline),
1132            "stretch" => Some(AlignItems::Stretch),
1133            _ => None,
1134        }
1135    }
1136}
1137
1138/// Align self (for individual flex children)
1139#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
1140pub enum AlignSelf {
1141    #[default]
1142    Auto,
1143    FlexStart,
1144    FlexEnd,
1145    Center,
1146    Baseline,
1147    Stretch,
1148}
1149
1150impl AlignSelf {
1151    pub fn parse(s: &str) -> Option<Self> {
1152        match s.trim().to_lowercase().as_str() {
1153            "auto" => Some(AlignSelf::Auto),
1154            "flex-start" | "start" => Some(AlignSelf::FlexStart),
1155            "flex-end" | "end" => Some(AlignSelf::FlexEnd),
1156            "center" => Some(AlignSelf::Center),
1157            "baseline" => Some(AlignSelf::Baseline),
1158            "stretch" => Some(AlignSelf::Stretch),
1159            _ => None,
1160        }
1161    }
1162
1163    /// Resolve to AlignItems if not Auto, otherwise use parent's align_items
1164    pub fn resolve(&self, parent_align: AlignItems) -> AlignItems {
1165        match self {
1166            AlignSelf::Auto => parent_align,
1167            AlignSelf::FlexStart => AlignItems::FlexStart,
1168            AlignSelf::FlexEnd => AlignItems::FlexEnd,
1169            AlignSelf::Center => AlignItems::Center,
1170            AlignSelf::Baseline => AlignItems::Baseline,
1171            AlignSelf::Stretch => AlignItems::Stretch,
1172        }
1173    }
1174}
1175
1176/// Overflow
1177#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
1178pub enum Overflow {
1179    #[default]
1180    Visible,
1181    Hidden,
1182    Scroll,
1183    Auto,
1184}
1185
1186impl Overflow {
1187    pub fn parse(s: &str) -> Option<Self> {
1188        match s.trim().to_lowercase().as_str() {
1189            "visible" => Some(Overflow::Visible),
1190            "hidden" => Some(Overflow::Hidden),
1191            "scroll" => Some(Overflow::Scroll),
1192            "auto" => Some(Overflow::Auto),
1193            _ => None,
1194        }
1195    }
1196}
1197
1198/// White-space handling per CSS Text
1199#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
1200pub enum WhiteSpace {
1201    #[default]
1202    Normal,
1203    Nowrap,
1204    Pre,
1205    PreWrap,
1206    PreLine,
1207}
1208
1209impl WhiteSpace {
1210    pub fn parse(s: &str) -> Option<Self> {
1211        match s.trim().to_ascii_lowercase().as_str() {
1212            "normal" => Some(WhiteSpace::Normal),
1213            "nowrap" => Some(WhiteSpace::Nowrap),
1214            "pre" => Some(WhiteSpace::Pre),
1215            "pre-wrap" => Some(WhiteSpace::PreWrap),
1216            "pre-line" => Some(WhiteSpace::PreLine),
1217            _ => None,
1218        }
1219    }
1220}
1221
1222/// Text-overflow handling
1223#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
1224pub enum TextOverflow {
1225    #[default]
1226    Clip,
1227    Ellipsis,
1228}
1229
1230impl TextOverflow {
1231    pub fn parse(s: &str) -> Option<Self> {
1232        match s.trim().to_ascii_lowercase().as_str() {
1233            "clip" => Some(TextOverflow::Clip),
1234            "ellipsis" => Some(TextOverflow::Ellipsis),
1235            _ => None,
1236        }
1237    }
1238}
1239
1240/// ComputedStyle struct (moved from velox-style)
1241#[derive(Debug, Clone, PartialEq)]
1242pub struct ComputedStyle {
1243    pub display: Display,
1244    pub position: Position,
1245    pub z_index: Option<i32>,
1246    pub opacity: f32,
1247    pub width: Length,
1248    pub height: Length,
1249    pub min_width: Length,
1250    pub min_height: Length,
1251    pub max_width: Length,
1252    pub max_height: Length,
1253    pub margin: Sides<Length>,
1254    pub padding: Sides<Length>,
1255    pub border: Border,
1256    pub border_radius: Sides<Length>,
1257    pub box_sizing: BoxSizing,
1258    pub top: Length,
1259    pub right: Length,
1260    pub bottom: Length,
1261    pub left: Length,
1262    pub flex_direction: FlexDirection,
1263    pub flex_wrap: FlexWrap,
1264    pub justify_content: JustifyContent,
1265    pub align_items: AlignItems,
1266    pub align_self: AlignSelf,
1267    pub flex_grow: f32,
1268    pub flex_shrink: f32,
1269    pub flex_basis: Length,
1270    pub gap: Length,
1271    pub row_gap: Length,
1272    pub column_gap: Length,
1273    pub background_color: Color,
1274    pub background_image: Option<String>,
1275    pub color: Color,
1276    pub font_size: Length,
1277    pub font_family: String,
1278    pub font_weight: FontWeight,
1279    /// Inherited (CSS 2.1 §10.8.1), so it is in `velox_style`'s `INHERITABLE`.
1280    /// Reaches the cascade — it used to be dropped here because `set_property`
1281    /// had no arm for it — but no renderer reads it yet; see
1282    /// `PARSED_BUT_UNRENDERED`.
1283    pub font_style: FontStyle,
1284    pub line_height: Option<f32>,
1285    pub letter_spacing: Length,
1286    pub text_align: TextAlign,
1287    /// Inherited (CSS 2.1 §10.8.1), so it is in `velox_style`'s `INHERITABLE`.
1288    pub vertical_align: VerticalAlign,
1289    pub text_decoration: TextDecoration,
1290    pub overflow: Overflow,
1291    pub overflow_x: Overflow,
1292    pub overflow_y: Overflow,
1293    pub visibility: Visibility,
1294    pub transform: Transform,
1295    pub box_shadow: Option<BoxShadow>,
1296    pub white_space: WhiteSpace,
1297    pub text_overflow: TextOverflow,
1298    /// Declared CSS transitions, applied in order.
1299    pub transitions: Vec<Transition>,
1300}
1301
1302impl ComputedStyle {
1303    pub fn new() -> Self {
1304        Self::default()
1305    }
1306
1307    pub fn apply_inline_style(&mut self, style: &str) {
1308        for decl in style.split(';') {
1309            let d = decl.trim();
1310            if d.is_empty() {
1311                continue;
1312            }
1313            if let Some((k, v)) = d.split_once(':') {
1314                self.set_property(k.trim(), v.trim());
1315            }
1316        }
1317    }
1318
1319    /// Properties that `set_property` accepts and stores, but which produce **no
1320    /// user-visible effect** — the declaration is valid CSS, survives the
1321    /// cascade, and is then dropped.
1322    ///
1323    /// `ComputedStyle` itself has no production consumer, so the readers that
1324    /// matter are the three ad-hoc style-string parsers on the live path:
1325    /// `layout::table_for`, the box painter, and the text painter. Each honours
1326    /// a *different* subset, so "a field exists" proves nothing; a property is
1327    /// honest only if at least one of those readers looks it up.
1328    ///
1329    /// Adding a `set_property` arm for one of these? Either implement it, or
1330    /// leave it here — but the honest state is: in the armed set AND in this
1331    /// table, until a reader exists. A property is never in both states: see
1332    /// `veloxc/tests/lint_css_tests.rs::table_entries_all_have_a_set_property_arm`,
1333    /// which fails the moment the two drift apart.
1334    ///
1335    /// This table is the *only* thing `velox lint` reports. It deliberately
1336    /// does not report "anything unmatched": the cascade filters unknown
1337    /// declarations out silently, so flagging genuinely-unknown properties
1338    /// would contradict the spec — and would fire on every vendor prefix,
1339    /// custom property and deliberately-declined property.
1340    ///
1341    /// Format: `(property, what was checked and found not to render)`.
1342    pub const PARSED_BUT_UNRENDERED: &[(&str, &str)] = &[
1343        (
1344            "overflow-x",
1345            "arm at style.rs:1662 sets `overflow_x`; no reader looks up `overflow-x`",
1346        ),
1347        (
1348            "overflow-y",
1349            "arm at style.rs:1667 sets `overflow_y`; no reader looks up `overflow-y`",
1350        ),
1351        (
1352            "background-image",
1353            "arm at style.rs:1588 sets `background_image`; the box painter only reads `background`/`background-color`",
1354        ),
1355        (
1356            "font-style",
1357            "arm at style.rs:1621 sets `font_style`; the cascade now propagates it, but NO reader applies it — the renderer's `TextRenderConfig.font_style` is only ever set by `Default`, never copied from here",
1358        ),
1359        (
1360            "letter-spacing",
1361            "arm at style.rs:1633 sets `letter_spacing`; cascade-inheritable, but no reader applies it",
1362        ),
1363        (
1364            "visibility",
1365            "arm at style.rs:1674 sets `visibility`; NO reader looks up `visibility`, so `visibility: hidden` hides nothing",
1366        ),
1367        (
1368            "transform",
1369            "arm at style.rs:1681 sets `transform`; read at layout.rs:3695 ONLY to force a stacking context — no visual effect",
1370        ),
1371        (
1372            "box-shadow",
1373            "arm at style.rs:1688 sets `box_shadow`; the string name appears nowhere in the renderer",
1374        ),
1375        (
1376            "transition",
1377            "arm at style.rs:1693 pushes to `transitions`; no reader plays a transition",
1378        ),
1379        (
1380            "border-style",
1381            "arm at style.rs:1720 sets `border.style.*`; only `border`/`border-width` are read, so `border-style` alone does nothing",
1382        ),
1383        (
1384            "border-color",
1385            "arm at style.rs:1728 sets `border.color.*`; only `border`/`border-width` are read, so `border-color` alone does nothing",
1386        ),
1387    ];
1388
1389    pub fn set_property(&mut self, prop: &str, value: &str) {
1390        let prop_lower = prop.to_lowercase();
1391
1392        match prop_lower.as_str() {
1393            // Display
1394            "display" => {
1395                if let Some(d) = Display::parse(value) {
1396                    self.display = d;
1397                }
1398            }
1399
1400            // Position
1401            "position" => {
1402                if let Some(p) = Position::parse(value) {
1403                    self.position = p;
1404                }
1405            }
1406            "z-index" | "zindex" => {
1407                if let Ok(z) = value.parse::<i32>() {
1408                    self.z_index = Some(z);
1409                }
1410            }
1411            "opacity" => {
1412                if let Ok(o) = value.parse::<f32>() {
1413                    self.opacity = o.clamp(0.0, 1.0);
1414                }
1415            }
1416
1417            // Box model - dimensions
1418            "width" => {
1419                if let Some(l) = Length::parse(value) {
1420                    self.width = l;
1421                }
1422            }
1423            "height" => {
1424                if let Some(l) = Length::parse(value) {
1425                    self.height = l;
1426                }
1427            }
1428            "min-width" => {
1429                if let Some(l) = Length::parse(value) {
1430                    self.min_width = l;
1431                }
1432            }
1433            "min-height" => {
1434                if let Some(l) = Length::parse(value) {
1435                    self.min_height = l;
1436                }
1437            }
1438            "max-width" => {
1439                if let Some(l) = Length::parse(value) {
1440                    self.max_width = l;
1441                }
1442            }
1443            "max-height" => {
1444                if let Some(l) = Length::parse(value) {
1445                    self.max_height = l;
1446                }
1447            }
1448
1449            // Box model - spacing
1450            "margin" => {
1451                if let Some(sides) = parse_sides_shorthand(value, Length::parse) {
1452                    self.margin = sides;
1453                }
1454            }
1455            "margin-top" => {
1456                if let Some(l) = Length::parse(value) {
1457                    self.margin.top = l;
1458                }
1459            }
1460            "margin-right" => {
1461                if let Some(l) = Length::parse(value) {
1462                    self.margin.right = l;
1463                }
1464            }
1465            "margin-bottom" => {
1466                if let Some(l) = Length::parse(value) {
1467                    self.margin.bottom = l;
1468                }
1469            }
1470            "margin-left" => {
1471                if let Some(l) = Length::parse(value) {
1472                    self.margin.left = l;
1473                }
1474            }
1475            "padding" => {
1476                if let Some(sides) = parse_sides_shorthand(value, Length::parse) {
1477                    self.padding = sides;
1478                }
1479            }
1480            "padding-top" => {
1481                if let Some(l) = Length::parse(value) {
1482                    self.padding.top = l;
1483                }
1484            }
1485            "padding-right" => {
1486                if let Some(l) = Length::parse(value) {
1487                    self.padding.right = l;
1488                }
1489            }
1490            "padding-bottom" => {
1491                if let Some(l) = Length::parse(value) {
1492                    self.padding.bottom = l;
1493                }
1494            }
1495            "padding-left" => {
1496                if let Some(l) = Length::parse(value) {
1497                    self.padding.left = l;
1498                }
1499            }
1500
1501            // Positioning offsets
1502            "top" => {
1503                if let Some(l) = Length::parse(value) {
1504                    self.top = l;
1505                }
1506            }
1507            "right" => {
1508                if let Some(l) = Length::parse(value) {
1509                    self.right = l;
1510                }
1511            }
1512            "bottom" => {
1513                if let Some(l) = Length::parse(value) {
1514                    self.bottom = l;
1515                }
1516            }
1517            "left" => {
1518                if let Some(l) = Length::parse(value) {
1519                    self.left = l;
1520                }
1521            }
1522
1523            // Flex
1524            "flex-direction" => {
1525                if let Some(fd) = FlexDirection::parse(value) {
1526                    self.flex_direction = fd;
1527                }
1528            }
1529            "flex-wrap" => {
1530                if let Some(fw) = FlexWrap::parse(value) {
1531                    self.flex_wrap = fw;
1532                }
1533            }
1534            "justify-content" => {
1535                if let Some(jc) = JustifyContent::parse(value) {
1536                    self.justify_content = jc;
1537                }
1538            }
1539            "align-items" => {
1540                if let Some(ai) = AlignItems::parse(value) {
1541                    self.align_items = ai;
1542                }
1543            }
1544            "align-self" => {
1545                if let Some(as_) = AlignSelf::parse(value) {
1546                    self.align_self = as_;
1547                }
1548            }
1549            "flex-grow" => {
1550                if let Ok(v) = value.parse::<f32>() {
1551                    self.flex_grow = v;
1552                }
1553            }
1554            "flex-shrink" => {
1555                if let Ok(v) = value.parse::<f32>() {
1556                    self.flex_shrink = v;
1557                }
1558            }
1559            "flex-basis" => {
1560                if let Some(l) = Length::parse(value) {
1561                    self.flex_basis = l;
1562                }
1563            }
1564            "gap" | "grid-gap" => {
1565                if let Some(l) = Length::parse(value) {
1566                    self.gap = l;
1567                    self.row_gap = l;
1568                    self.column_gap = l;
1569                }
1570            }
1571            "row-gap" => {
1572                if let Some(l) = Length::parse(value) {
1573                    self.row_gap = l;
1574                }
1575            }
1576            "column-gap" => {
1577                if let Some(l) = Length::parse(value) {
1578                    self.column_gap = l;
1579                }
1580            }
1581
1582            // Background
1583            "background-color" => {
1584                if let Some(c) = Color::parse(value) {
1585                    self.background_color = c;
1586                }
1587            }
1588            "background-image" => {
1589                self.background_image = Some(value.to_string());
1590            }
1591            "background" => {
1592                // Minimal shorthand: color-only tokens (e.g. `background: #1a1a2e`).
1593                // Image/gradient tokens are out of scope and ignored here.
1594                for token in value.split_whitespace() {
1595                    if let Some(c) = Color::parse(token) {
1596                        self.background_color = c;
1597                        break;
1598                    }
1599                }
1600            }
1601
1602            // Typography
1603            "color" => {
1604                if let Some(c) = Color::parse(value) {
1605                    self.color = c;
1606                }
1607            }
1608            "font-size" => {
1609                if let Some(l) = Length::parse(value) {
1610                    self.font_size = l;
1611                }
1612            }
1613            "font-family" => {
1614                self.font_family = value.to_string();
1615            }
1616            "font-weight" => {
1617                if let Some(fw) = FontWeight::parse(value) {
1618                    self.font_weight = fw;
1619                }
1620            }
1621            "font-style" => {
1622                if let Some(fs) = FontStyle::parse(value) {
1623                    self.font_style = fs;
1624                }
1625            }
1626            "line-height" => {
1627                if let Ok(lh) = value.parse::<f32>() {
1628                    self.line_height = Some(lh);
1629                } else if let Some(Length::Px(px)) = Length::parse(value) {
1630                    self.line_height = Some(px);
1631                }
1632            }
1633            "letter-spacing" => {
1634                if let Some(l) = Length::parse(value) {
1635                    self.letter_spacing = l;
1636                }
1637            }
1638            "text-align" => {
1639                if let Some(ta) = TextAlign::parse(value) {
1640                    self.text_align = ta;
1641                }
1642            }
1643            "vertical-align" => {
1644                if let Some(va) = VerticalAlign::parse(value) {
1645                    self.vertical_align = va;
1646                }
1647            }
1648            "text-decoration" => {
1649                if let Some(td) = TextDecoration::parse(value) {
1650                    self.text_decoration = td;
1651                }
1652            }
1653
1654            // Overflow
1655            "overflow" => {
1656                if let Some(o) = Overflow::parse(value) {
1657                    self.overflow = o;
1658                    self.overflow_x = o;
1659                    self.overflow_y = o;
1660                }
1661            }
1662            "overflow-x" => {
1663                if let Some(o) = Overflow::parse(value) {
1664                    self.overflow_x = o;
1665                }
1666            }
1667            "overflow-y" => {
1668                if let Some(o) = Overflow::parse(value) {
1669                    self.overflow_y = o;
1670                }
1671            }
1672
1673            // Visibility
1674            "visibility" => {
1675                if let Some(v) = Visibility::parse(value) {
1676                    self.visibility = v;
1677                }
1678            }
1679
1680            // Transform
1681            "transform" => {
1682                if let Some(t) = Transform::parse(value) {
1683                    self.transform = t;
1684                }
1685            }
1686
1687            // Box shadow
1688            "box-shadow" => {
1689                self.box_shadow = BoxShadow::parse(value);
1690            }
1691
1692            // Transitions (comma-separated list of transition declarations)
1693            "transition" => {
1694                self.transitions.clear();
1695                for part in value.split(',') {
1696                    let part = part.trim();
1697                    if part.is_empty() || part.eq_ignore_ascii_case("none") {
1698                        continue;
1699                    }
1700                    if let Some(t) = Transition::parse(part) {
1701                        self.transitions.push(t);
1702                    }
1703                }
1704            }
1705
1706            // Border shorthand
1707            "border" => {
1708                if let Some(sides) = parse_border_shorthand(value) {
1709                    self.border = sides;
1710                }
1711            }
1712            "border-width" => {
1713                if let Some(sides) = parse_sides_shorthand(value, Length::parse) {
1714                    self.border.width.top = sides.top;
1715                    self.border.width.right = sides.right;
1716                    self.border.width.bottom = sides.bottom;
1717                    self.border.width.left = sides.left;
1718                }
1719            }
1720            "border-style" => {
1721                if let Some(sides) = parse_sides_shorthand(value, BorderStyle::parse) {
1722                    self.border.style.top = sides.top;
1723                    self.border.style.right = sides.right;
1724                    self.border.style.bottom = sides.bottom;
1725                    self.border.style.left = sides.left;
1726                }
1727            }
1728            "border-color" => {
1729                if let Some(sides) = parse_sides_shorthand(value, Color::parse) {
1730                    self.border.color.top = sides.top;
1731                    self.border.color.right = sides.right;
1732                    self.border.color.bottom = sides.bottom;
1733                    self.border.color.left = sides.left;
1734                }
1735            }
1736
1737            // Border radius
1738            "border-radius" => {
1739                if let Some(sides) = parse_sides_shorthand(value, Length::parse) {
1740                    self.border_radius = sides;
1741                }
1742            }
1743
1744            // Box sizing
1745            "box-sizing" => {
1746                if let Some(bs) = BoxSizing::parse(value) {
1747                    self.box_sizing = bs;
1748                }
1749            }
1750            "white-space" => {
1751                if let Some(ws) = WhiteSpace::parse(value) {
1752                    self.white_space = ws;
1753                }
1754            }
1755            "text-overflow" => {
1756                if let Some(to) = TextOverflow::parse(value) {
1757                    self.text_overflow = to;
1758                }
1759            }
1760
1761            _ => {}
1762        }
1763    }
1764
1765    /// Check if this element creates a stacking context
1766    pub fn creates_stacking_context(&self) -> bool {
1767        self.position != Position::Static
1768            || self.z_index.is_some()
1769            || self.opacity < 1.0
1770            || !self.transform.is_empty()
1771    }
1772
1773    /// Check if display is none
1774    pub fn is_display_none(&self) -> bool {
1775        self.display == Display::None
1776    }
1777
1778    /// Check if visibility is hidden
1779    pub fn is_hidden(&self) -> bool {
1780        self.visibility == Visibility::Hidden
1781    }
1782}
1783
1784/// Viewport-filling predicate for `ComputedStyle`, expanded to handle
1785/// `100% | 100vw | 100dvw | 100vh | 100dvh | min-height`.
1786/// `is_root_index` should be true when `source_index == 0` (first VNode always fills).
1787pub fn is_viewport_filling(style: &ComputedStyle, is_root_index: bool) -> bool {
1788    if is_root_index {
1789        return true;
1790    }
1791    let is_100 = |l: Length| match l {
1792        Length::Percent(v) => (v - 100.0).abs() < 0.01,
1793        Length::Vw(v) => (v - 100.0).abs() < 0.01,
1794        Length::Dvw(v) => (v - 100.0).abs() < 0.01,
1795        Length::Vh(v) => (v - 100.0).abs() < 0.01,
1796        Length::Dvh(v) => (v - 100.0).abs() < 0.01,
1797        _ => false,
1798    };
1799    let has_100pct_w = is_100(style.width) && matches!(style.width, Length::Percent(_));
1800    let has_vw = matches!(
1801        style.width,
1802        Length::Vw(v) | Length::Dvw(v) if (v - 100.0).abs() < 0.01
1803    );
1804    let has_viewport_h = is_100(style.height)
1805        && matches!(
1806            style.height,
1807            Length::Percent(_) | Length::Vh(_) | Length::Dvh(_)
1808        )
1809        || matches!(
1810            style.min_height,
1811            Length::Percent(v) | Length::Vh(v) | Length::Dvh(v) if (v - 100.0).abs() < 0.01
1812        );
1813    // strict combo retained as sufficient condition; root handles implicit fill.
1814    (has_100pct_w || has_vw) && has_viewport_h
1815}
1816
1817/// Root-is-viewport-filling helper: index == 0 ⇒ true (first VNode always fills).
1818pub fn root_is_viewport_filling(index: usize) -> bool {
1819    index == 0
1820}
1821
1822// Document-level defaults — UA sheet (velox-style::ua) supplies element-specific
1823// margins/padding/display; ComputedStyle::default() remains the zero baseline.
1824impl Default for ComputedStyle {
1825    fn default() -> Self {
1826        Self {
1827            display: Display::default(),
1828            position: Position::default(),
1829            z_index: None,
1830            opacity: 1.0,
1831            width: Length::default(),
1832            height: Length::default(),
1833            min_width: Length::default(),
1834            min_height: Length::default(),
1835            max_width: Length::default(),
1836            max_height: Length::default(),
1837            margin: Sides::default(),
1838            padding: Sides::default(),
1839            border: Border::default(),
1840            border_radius: Sides::default(),
1841            box_sizing: BoxSizing::default(),
1842            top: Length::default(),
1843            right: Length::default(),
1844            bottom: Length::default(),
1845            left: Length::default(),
1846            flex_direction: FlexDirection::default(),
1847            flex_wrap: FlexWrap::default(),
1848            justify_content: JustifyContent::default(),
1849            align_items: AlignItems::default(),
1850            align_self: AlignSelf::default(),
1851            flex_grow: 0.0,
1852            flex_shrink: 1.0,
1853            flex_basis: Length::Auto,
1854            gap: Length::default(),
1855            row_gap: Length::default(),
1856            column_gap: Length::default(),
1857            background_color: Color::default(),
1858            background_image: None,
1859            color: Color::default(),
1860            font_size: Length::Px(16.0), // Default font size
1861            font_family: "sans-serif".to_string(),
1862            font_weight: FontWeight::default(),
1863            font_style: FontStyle::default(),
1864            line_height: None,
1865            letter_spacing: Length::default(),
1866            text_align: TextAlign::default(),
1867            vertical_align: VerticalAlign::default(),
1868            text_decoration: TextDecoration::default(),
1869            overflow: Overflow::default(),
1870            overflow_x: Overflow::default(),
1871            overflow_y: Overflow::default(),
1872            visibility: Visibility::default(),
1873            transform: Transform::default(),
1874            box_shadow: None,
1875            white_space: WhiteSpace::default(),
1876            text_overflow: TextOverflow::default(),
1877            transitions: Vec::new(),
1878        }
1879    }
1880}
1881
1882// Shorthand parsing functions
1883fn parse_sides_shorthand<T: Copy + Default>(
1884    value: &str,
1885    parser: impl Fn(&str) -> Option<T>,
1886) -> Option<Sides<T>> {
1887    let parts: Vec<&str> = value.split_whitespace().collect();
1888    match parts.len() {
1889        1 => {
1890            let v = parser(parts[0])?;
1891            Some(Sides::all(v))
1892        }
1893        2 => {
1894            let v = parser(parts[0])?;
1895            let h = parser(parts[1])?;
1896            Some(Sides {
1897                top: v,
1898                right: h,
1899                bottom: v,
1900                left: h,
1901            })
1902        }
1903        3 => {
1904            let top = parser(parts[0])?;
1905            let h = parser(parts[1])?;
1906            let bottom = parser(parts[2])?;
1907            Some(Sides {
1908                top,
1909                right: h,
1910                bottom,
1911                left: h,
1912            })
1913        }
1914        4 => {
1915            let top = parser(parts[0])?;
1916            let right = parser(parts[1])?;
1917            let bottom = parser(parts[2])?;
1918            let left = parser(parts[3])?;
1919            Some(Sides {
1920                top,
1921                right,
1922                bottom,
1923                left,
1924            })
1925        }
1926        _ => None,
1927    }
1928}
1929
1930fn parse_border_shorthand(value: &str) -> Option<Border> {
1931    let parts: Vec<&str> = value.split_whitespace().collect();
1932    if parts.is_empty() {
1933        return None;
1934    }
1935
1936    // Parse width
1937    let mut width_parts = Vec::new();
1938    for part in &parts {
1939        if let Some(w) = Length::parse(part) {
1940            width_parts.push(w);
1941        } else {
1942            break;
1943        }
1944    }
1945
1946    // Parse style
1947    let mut style_parts = Vec::new();
1948    for part in &parts[width_parts.len()..] {
1949        if let Some(s) = BorderStyle::parse(part) {
1950            style_parts.push(s);
1951        } else {
1952            break;
1953        }
1954    }
1955
1956    // Parse color
1957    let color_start = width_parts.len() + style_parts.len();
1958    let mut color_parts = Vec::new();
1959    for part in &parts[color_start..] {
1960        if let Some(c) = Color::parse(part) {
1961            color_parts.push(c);
1962        } else {
1963            break;
1964        }
1965    }
1966
1967    // If no width specified, default to medium
1968    let default_width = Length::Px(3.0);
1969    let width_sides = if width_parts.len() == 1 {
1970        Sides::all(width_parts[0])
1971    } else if width_parts.len() == 2 {
1972        Sides {
1973            top: width_parts[0],
1974            right: width_parts[1],
1975            bottom: width_parts[0],
1976            left: width_parts[1],
1977        }
1978    } else if width_parts.len() == 3 {
1979        Sides {
1980            top: width_parts[0],
1981            right: width_parts[1],
1982            bottom: width_parts[2],
1983            left: width_parts[1],
1984        }
1985    } else if width_parts.len() == 4 {
1986        Sides {
1987            top: width_parts[0],
1988            right: width_parts[1],
1989            bottom: width_parts[2],
1990            left: width_parts[3],
1991        }
1992    } else {
1993        Sides::all(default_width)
1994    };
1995
1996    // CSS 2.1 §8.5.2: the initial value of `border-style` is `none`, so a
1997    // width on its own must paint nothing. The old `Solid` default meant plain
1998    // `border: 1px` drew a black 1px border. Note the renderer already
1999    // disagreed with that default and sided with the spec:
2000    // `skia_render::parse_border_value` returns `None` unless the value
2001    // literally contains `solid`, so this was the DOM inventing the only
2002    // visible border of the two.
2003    let default_style = BorderStyle::None;
2004    let style_sides = if style_parts.len() == 1 {
2005        Sides::all(style_parts[0])
2006    } else if style_parts.len() == 2 {
2007        Sides {
2008            top: style_parts[0],
2009            right: style_parts[1],
2010            bottom: style_parts[0],
2011            left: style_parts[1],
2012        }
2013    } else if style_parts.len() == 3 {
2014        Sides {
2015            top: style_parts[0],
2016            right: style_parts[1],
2017            bottom: style_parts[2],
2018            left: style_parts[1],
2019        }
2020    } else if style_parts.len() == 4 {
2021        Sides {
2022            top: style_parts[0],
2023            right: style_parts[1],
2024            bottom: style_parts[2],
2025            left: style_parts[3],
2026        }
2027    } else {
2028        Sides::all(default_style)
2029    };
2030
2031    // If no color specified, default to black
2032    let default_color = Color::BLACK;
2033    let color_sides = if color_parts.len() == 1 {
2034        Sides::all(color_parts[0])
2035    } else if color_parts.len() == 2 {
2036        Sides {
2037            top: color_parts[0],
2038            right: color_parts[1],
2039            bottom: color_parts[0],
2040            left: color_parts[1],
2041        }
2042    } else if color_parts.len() == 3 {
2043        Sides {
2044            top: color_parts[0],
2045            right: color_parts[1],
2046            bottom: color_parts[2],
2047            left: color_parts[1],
2048        }
2049    } else if color_parts.len() == 4 {
2050        Sides {
2051            top: color_parts[0],
2052            right: color_parts[1],
2053            bottom: color_parts[2],
2054            left: color_parts[3],
2055        }
2056    } else {
2057        Sides::all(default_color)
2058    };
2059
2060    Some(Border {
2061        width: width_sides,
2062        style: style_sides,
2063        color: color_sides,
2064    })
2065}
2066
2067/// Core auto-margin distribution (CSS 2.1 §10.3.3).
2068///
2069/// Given the containing block width (`avail_w`), the element's border-box
2070/// outer width *excluding margins* (`outer_w`), the numerically resolved
2071/// side margins (`ml`/`mr` — values on auto sides are ignored), and the
2072/// per-side auto flags, spreads the free space `avail_w - outer_w - fixed
2073/// margins` across the auto margins:
2074///
2075/// - both sides auto: free space splits equally between them (their used
2076///   values are equal, per spec);
2077/// - one side auto: it absorbs the remainder after the fixed opposite margin;
2078/// - free space may be negative and auto margins resolve negative — an
2079///   over-wide box centers with negative margins, as in browsers. No
2080///   clamping.
2081pub fn resolve_auto_margins_core(
2082    avail_w: f32,
2083    outer_w: f32,
2084    ml: f32,
2085    mr: f32,
2086    ml_auto: bool,
2087    mr_auto: bool,
2088) -> (f32, f32) {
2089    let fixed = (if ml_auto { 0.0 } else { ml }) + (if mr_auto { 0.0 } else { mr });
2090    let free = avail_w - outer_w - fixed;
2091    match (ml_auto, mr_auto) {
2092        (true, true) => {
2093            let each = free / 2.0;
2094            (each, each)
2095        }
2096        (true, false) => (free, mr),
2097        (false, true) => (ml, free),
2098        (false, false) => (ml, mr),
2099    }
2100}
2101
2102/// Resolve `margin: auto` for a block-level box (CSS 2.1 §10.3.3).
2103///
2104/// Given the containing block width (`avail_w`), the computed style and the
2105/// declared width in px (`None` when `width: auto`), returns the resolved
2106/// `(margin_left, margin_right)` pair in px. When neither side is auto the
2107/// declared margins resolve against `avail_w` unchanged. When `width` is
2108/// auto the box fills the containing block and auto margins resolve to 0.
2109///
2110/// Resolution basis: percentage paddings/borders resolve against `avail_w`
2111/// (the containing block width), `rem` against the default 16px root size.
2112/// `em`/viewport-unit paddings are approximated because the pure helper has
2113/// no font-size/viewport context; the layout path, which has full context,
2114/// feeds exact px values into [`resolve_auto_margins_core`] instead.
2115pub fn resolve_auto_margins(
2116    avail_w: f32,
2117    style: &ComputedStyle,
2118    declared_w: Option<f32>,
2119) -> (f32, f32) {
2120    let px = |l: Length| l.to_px(avail_w, 16.0, (avail_w, avail_w));
2121    let ml = px(style.margin.left);
2122    let mr = px(style.margin.right);
2123    let ml_auto = style.margin.left.is_auto();
2124    let mr_auto = style.margin.right.is_auto();
2125    if !ml_auto && !mr_auto {
2126        return (ml, mr);
2127    }
2128    let Some(dw) = declared_w else {
2129        return (
2130            if ml_auto { 0.0 } else { ml },
2131            if mr_auto { 0.0 } else { mr },
2132        );
2133    };
2134    // Border-box: declared width already includes padding+border, so no
2135    // double subtraction (reconciled with content_size_for, F-04).
2136    let outer_w = match style.box_sizing {
2137        BoxSizing::BorderBox => dw,
2138        BoxSizing::ContentBox => {
2139            dw + px(style.padding.left)
2140                + px(style.padding.right)
2141                + px(style.border.width.left)
2142                + px(style.border.width.right)
2143        }
2144    };
2145    resolve_auto_margins_core(avail_w, outer_w, ml, mr, ml_auto, mr_auto)
2146}
2147
2148#[cfg(test)]
2149mod tests {
2150    use super::*;
2151
2152    #[test]
2153    fn test_length_parsing() {
2154        assert_eq!(Length::parse("10px"), Some(Length::Px(10.0)));
2155        assert_eq!(Length::parse("50%"), Some(Length::Percent(50.0)));
2156        assert_eq!(Length::parse("1.5rem"), Some(Length::Rem(1.5)));
2157        assert_eq!(Length::parse("2em"), Some(Length::Em(2.0)));
2158        assert_eq!(Length::parse("auto"), Some(Length::Auto));
2159        assert_eq!(Length::parse("0"), Some(Length::Zero));
2160    }
2161
2162    #[test]
2163    fn test_color_parsing() {
2164        assert_eq!(Color::parse("#ff0000"), Some(Color::RED));
2165        assert_eq!(Color::parse("#f00"), Some(Color::RED));
2166        assert_eq!(Color::parse("red"), Some(Color::RED));
2167        assert_eq!(Color::parse("rgb(255, 0, 0)"), Some(Color::RED));
2168        assert_eq!(Color::parse("rgba(255, 0, 0, 1.0)"), Some(Color::RED));
2169    }
2170
2171    #[test]
2172    fn test_display_hidden_alias() {
2173        assert_eq!(Display::parse("none"), Some(Display::None));
2174        assert_eq!(Display::parse("hidden"), Some(Display::None));
2175    }
2176
2177    #[test]
2178    fn test_position_parsing() {
2179        assert_eq!(Position::parse("relative"), Some(Position::Relative));
2180        assert_eq!(Position::parse("absolute"), Some(Position::Absolute));
2181        assert_eq!(Position::parse("fixed"), Some(Position::Fixed));
2182        assert_eq!(Position::parse("static"), Some(Position::Static));
2183    }
2184
2185    #[test]
2186    fn test_overflow_parsing() {
2187        assert_eq!(Overflow::parse("hidden"), Some(Overflow::Hidden));
2188        assert_eq!(Overflow::parse("scroll"), Some(Overflow::Scroll));
2189        assert_eq!(Overflow::parse("auto"), Some(Overflow::Auto));
2190        assert_eq!(Overflow::parse("visible"), Some(Overflow::Visible));
2191    }
2192
2193    #[test]
2194    fn test_set_property_position_overflow_zindex() {
2195        let mut cs = ComputedStyle::new();
2196        cs.set_property("position", "absolute");
2197        cs.set_property("overflow", "hidden");
2198        cs.set_property("z-index", "10");
2199        assert_eq!(cs.position, Position::Absolute);
2200        assert_eq!(cs.overflow, Overflow::Hidden);
2201        assert_eq!(cs.z_index, Some(10));
2202    }
2203
2204    #[test]
2205    fn test_set_property_transform() {
2206        let mut cs = ComputedStyle::new();
2207        cs.set_property(
2208            "transform",
2209            "translate(10px, 20px) rotate(45deg) scale(1.5)",
2210        );
2211        assert_eq!(
2212            cs.transform.operations,
2213            vec![
2214                TransformOp::Translate(Length::Px(10.0), Length::Px(20.0)),
2215                TransformOp::Rotate(45.0),
2216                TransformOp::Scale(1.5, 1.5),
2217            ]
2218        );
2219        // translateX / scaleY / none
2220        cs.set_property("transform", "translateX(5px) scaleY(2)");
2221        assert_eq!(
2222            cs.transform.operations,
2223            vec![
2224                TransformOp::TranslateX(Length::Px(5.0)),
2225                TransformOp::ScaleY(2.0)
2226            ]
2227        );
2228        cs.set_property("transform", "none");
2229        assert!(cs.transform.is_empty());
2230    }
2231
2232    #[test]
2233    fn test_set_property_box_shadow() {
2234        let mut cs = ComputedStyle::new();
2235        cs.set_property("box-shadow", "2px 3px 4px rgba(0,0,0,0.5)");
2236        let bs = cs.box_shadow.as_ref().expect("box shadow should be set");
2237        assert_eq!(bs.offset_x, Length::Px(2.0));
2238        assert_eq!(bs.offset_y, Length::Px(3.0));
2239        assert_eq!(bs.blur_radius, Length::Px(4.0));
2240        assert!(!bs.inset);
2241        // inset + color
2242        cs.set_property("box-shadow", "inset 0 2px #000");
2243        let bs = cs.box_shadow.as_ref().expect("inset shadow set");
2244        assert!(bs.inset);
2245        assert_eq!(bs.color, Color::BLACK);
2246        // none clears
2247        cs.set_property("box-shadow", "none");
2248        assert!(cs.box_shadow.is_none());
2249    }
2250
2251    #[test]
2252    fn test_set_property_transition() {
2253        let mut cs = ComputedStyle::new();
2254        cs.set_property("transition", "opacity 0.2s ease-in 0.1s, transform 300ms");
2255        assert_eq!(cs.transitions.len(), 2);
2256        assert_eq!(cs.transitions[0].property, "opacity");
2257        assert_eq!(cs.transitions[0].duration, 0.2);
2258        assert_eq!(cs.transitions[0].timing_function, TimingFunction::EaseIn);
2259        assert_eq!(cs.transitions[0].delay, 0.1);
2260        assert_eq!(cs.transitions[1].property, "transform");
2261        assert_eq!(cs.transitions[1].duration, 0.3);
2262        // transition: none clears
2263        cs.set_property("transition", "none");
2264        assert!(cs.transitions.is_empty());
2265    }
2266
2267    #[test]
2268    fn test_margin_shorthand_preserves_auto() {
2269        // `margin: 0 auto` => top/bottom = 0, left/right = auto
2270        let mut cs = ComputedStyle::new();
2271        cs.set_property("margin", "0 auto");
2272        assert_eq!(cs.margin.top, Length::Zero);
2273        assert_eq!(cs.margin.bottom, Length::Zero);
2274        assert_eq!(cs.margin.left, Length::Auto);
2275        assert_eq!(cs.margin.right, Length::Auto);
2276
2277        // `margin: 10px auto` => vertical margins preserved, horizontal auto
2278        let mut cs = ComputedStyle::new();
2279        cs.set_property("margin", "10px auto");
2280        assert_eq!(cs.margin.top, Length::Px(10.0));
2281        assert_eq!(cs.margin.bottom, Length::Px(10.0));
2282        assert_eq!(cs.margin.left, Length::Auto);
2283        assert_eq!(cs.margin.right, Length::Auto);
2284
2285        // 4-value shorthand: top right bottom left
2286        let mut cs = ComputedStyle::new();
2287        cs.set_property("margin", "1px auto 3px auto");
2288        assert_eq!(cs.margin.top, Length::Px(1.0));
2289        assert_eq!(cs.margin.bottom, Length::Px(3.0));
2290        assert_eq!(cs.margin.left, Length::Auto);
2291        assert_eq!(cs.margin.right, Length::Auto);
2292
2293        // longhand auto overrides shorthand value
2294        let mut cs = ComputedStyle::new();
2295        cs.set_property("margin", "8px");
2296        cs.set_property("margin-left", "auto");
2297        assert_eq!(cs.margin.top, Length::Px(8.0));
2298        assert_eq!(cs.margin.left, Length::Auto);
2299    }
2300
2301    #[test]
2302    fn test_background_shorthand_parses_color() {
2303        let mut cs = ComputedStyle::new();
2304        cs.set_property("background", "#1a1a2e");
2305        assert_eq!(cs.background_color, Color::new(0x1a, 0x1a, 0x2e, 255));
2306
2307        // bare named color token; non-color tokens are ignored
2308        let mut cs = ComputedStyle::new();
2309        cs.set_property("background", "red no-repeat");
2310        assert_eq!(cs.background_color, Color::RED);
2311
2312        // unknown values must not panic or clear defaults unexpectedly
2313        let mut cs = ComputedStyle::new();
2314        cs.set_property("background", "url(img.png)");
2315        assert_eq!(cs.background_color, Color::default());
2316    }
2317
2318    #[test]
2319    fn test_border_shorthand_width_style_color() {
2320        let mut cs = ComputedStyle::new();
2321        cs.set_property("border", "1px solid #fff");
2322        assert_eq!(cs.border.width.top, Length::Px(1.0));
2323        assert_eq!(cs.border.width.right, Length::Px(1.0));
2324        assert_eq!(cs.border.style.top, BorderStyle::Solid);
2325        assert_eq!(cs.border.color.top, Color::new(255, 255, 255, 255));
2326
2327        // partial shorthand keeps defaults for missing components
2328        let mut cs = ComputedStyle::new();
2329        cs.set_property("border", "4px solid");
2330        assert_eq!(cs.border.width.top, Length::Px(4.0));
2331        assert_eq!(cs.border.style.top, BorderStyle::Solid);
2332        assert_eq!(cs.border.color.top, Color::BLACK);
2333    }
2334
2335    /// A width with no style must NOT become a visible border. `border-style`'s
2336    /// initial value is `none`, so `border: 1px` paints nothing; the width is
2337    /// still recorded so that a later `border-style: solid` can use it.
2338    #[test]
2339    fn border_width_without_a_style_is_not_a_visible_border() {
2340        let mut cs = ComputedStyle::new();
2341        cs.set_property("border", "1px");
2342        assert_eq!(
2343            cs.border.style.top,
2344            BorderStyle::None,
2345            "`border: 1px` must not fabricate a style; the initial value of \
2346             `border-style` is `none`, so a bare width paints nothing"
2347        );
2348        // The width itself is still parsed — it is the *style* that is absent,
2349        // and dropping the width too would break `border: 1px` followed by
2350        // `border-style: solid`.
2351        assert_eq!(cs.border.width.top, Length::Px(1.0));
2352
2353        // Same for every width-only spelling, and for width+color with no style.
2354        for value in ["1px", "2px", "3px", "1px red", "2px #00ff00"] {
2355            let mut cs = ComputedStyle::new();
2356            cs.set_property("border", value);
2357            assert_eq!(
2358                cs.border.style.top,
2359                BorderStyle::None,
2360                "`border: {value}` carries no style keyword and must stay `none`"
2361            );
2362        }
2363
2364        // A width-only declaration is not a dead end: setting the style
2365        // afterwards makes the already-parsed width visible.
2366        let mut cs = ComputedStyle::new();
2367        cs.set_property("border", "2px");
2368        cs.set_property("border-style", "solid");
2369        assert_eq!(cs.border.width.top, Length::Px(2.0));
2370        assert_eq!(cs.border.style.top, BorderStyle::Solid);
2371
2372        // And the explicit styles are untouched by the new default.
2373        for (value, expected) in [
2374            ("1px solid red", BorderStyle::Solid),
2375            ("1px dashed", BorderStyle::Dashed),
2376            ("1px dotted", BorderStyle::Dotted),
2377            ("1px none", BorderStyle::None),
2378        ] {
2379            let mut cs = ComputedStyle::new();
2380            cs.set_property("border", value);
2381            assert_eq!(cs.border.style.top, expected, "`border: {value}`");
2382        }
2383    }
2384
2385    #[test]
2386    fn test_resolve_auto_margins_both_sides() {
2387        let mut cs = ComputedStyle::new();
2388        cs.set_property("margin", "0 auto");
2389        let (ml, mr) = resolve_auto_margins(800.0, &cs, Some(200.0));
2390        assert_eq!(ml, 300.0);
2391        assert_eq!(mr, 300.0);
2392    }
2393
2394    #[test]
2395    fn test_resolve_auto_margins_single_side_subtracts_fixed_opposite() {
2396        let mut cs = ComputedStyle::new();
2397        cs.set_property("margin-left", "auto");
2398        cs.set_property("margin-right", "40px");
2399        let (ml, mr) = resolve_auto_margins(800.0, &cs, Some(200.0));
2400        assert_eq!(ml, 560.0); // 800 - 200 - 40
2401        assert_eq!(mr, 40.0);
2402    }
2403
2404    #[test]
2405    fn test_resolve_auto_margins_content_box_subtracts_padding_border_once() {
2406        let mut cs = ComputedStyle::new();
2407        cs.set_property("margin", "0 auto");
2408        cs.set_property("padding", "0 20px");
2409        cs.set_property("border", "2px solid");
2410        // outer = 200 + 2*20 + 2*2 = 244; free = 556 -> 278 each
2411        let (ml, mr) = resolve_auto_margins(800.0, &cs, Some(200.0));
2412        assert_eq!(ml, 278.0);
2413        assert_eq!(mr, 278.0);
2414    }
2415
2416    #[test]
2417    fn test_resolve_auto_margins_border_box_no_double_subtraction() {
2418        let mut cs = ComputedStyle::new();
2419        cs.set_property("box-sizing", "border-box");
2420        cs.set_property("margin", "0 auto");
2421        cs.set_property("padding", "0 20px");
2422        // declared 200 already contains padding; free = 600 -> 300 each
2423        let (ml, mr) = resolve_auto_margins(800.0, &cs, Some(200.0));
2424        assert_eq!(ml, 300.0);
2425        assert_eq!(mr, 300.0);
2426    }
2427
2428    #[test]
2429    fn test_resolve_auto_margins_declared_none_resolves_zero() {
2430        let mut cs = ComputedStyle::new();
2431        cs.set_property("margin", "0 auto");
2432        let (ml, mr) = resolve_auto_margins(800.0, &cs, None);
2433        assert_eq!((ml, mr), (0.0, 0.0));
2434    }
2435
2436    #[test]
2437    fn test_resolve_auto_margins_negative_free_space_resolves_negative() {
2438        // CSS 2.1 §10.3.3: auto margins resolve from the constraint equation
2439        // with no clamping — an over-wide box centers with negative margins.
2440        let mut cs = ComputedStyle::new();
2441        cs.set_property("margin", "0 auto");
2442        let (ml, mr) = resolve_auto_margins(800.0, &cs, Some(900.0));
2443        assert_eq!((ml, mr), (-50.0, -50.0)); // (800 - 900) / 2
2444
2445        // one auto side absorbs the negative leftover: 800 - 900 - 0
2446        let mut cs = ComputedStyle::new();
2447        cs.set_property("margin-left", "auto");
2448        let (ml, mr) = resolve_auto_margins(800.0, &cs, Some(900.0));
2449        assert_eq!((ml, mr), (-100.0, 0.0));
2450    }
2451
2452    #[test]
2453    fn test_resolve_auto_margins_without_auto_returns_declared() {
2454        let mut cs = ComputedStyle::new();
2455        // 2-value shorthand: top/bottom = 10px, left/right = 20px
2456        cs.set_property("margin", "10px 20px");
2457        let (ml, mr) = resolve_auto_margins(800.0, &cs, Some(200.0));
2458        assert_eq!((ml, mr), (20.0, 20.0));
2459    }
2460
2461    #[test]
2462    fn test_resolve_auto_margins_percent_padding_resolves_against_avail() {
2463        let mut cs = ComputedStyle::new();
2464        cs.set_property("margin", "0 auto");
2465        cs.set_property("padding", "0 10%");
2466        // outer = 200 + 2*80 = 360; free = 440 -> 220 each
2467        let (ml, mr) = resolve_auto_margins(800.0, &cs, Some(200.0));
2468        assert_eq!(ml, 220.0);
2469        assert_eq!(mr, 220.0);
2470    }
2471}