From 98c53b6b152bb9b76857e83df18d339ccc0ad255 Mon Sep 17 00:00:00 2001 From: SGavrl <277843151+SGavrl@users.noreply.github.com> Date: Tue, 4 Aug 2026 07:47:14 +0000 Subject: [PATCH] Implement flex-flow shorthand semantics Parse the unordered flex-direction/flex-wrap shorthand atomically, reset omitted constituents to their initial values, and expose the same grammar through CSS.supports and computed-style capability surfaces. Add a Chromium-paired fixture covering both token orders, shorthand resets, invalid-declaration fallback, and JavaScript capability reporting. --- crates/obscura-js/js/bootstrap.js | 21 ++++- crates/obscura-js/src/runtime.rs | 10 ++- crates/obscura-render/src/style.rs | 124 +++++++++++++++++++++++++++++ render-repros/checks.json | 13 +++ render-repros/flex-flow.html | 40 ++++++++++ 5 files changed, 204 insertions(+), 4 deletions(-) create mode 100644 render-repros/flex-flow.html diff --git a/crates/obscura-js/js/bootstrap.js b/crates/obscura-js/js/bootstrap.js index 0138f87..951204b 100644 --- a/crates/obscura-js/js/bootstrap.js +++ b/crates/obscura-js/js/bootstrap.js @@ -6714,7 +6714,7 @@ globalThis.getComputedStyle = (el) => { 'z-index': 'auto', 'pointer-events': 'auto', 'box-sizing': 'content-box', cursor: 'auto', 'white-space': 'normal', 'text-align': 'start', - 'flex-direction': 'row', 'flex-wrap': 'nowrap', 'align-items': 'normal', + 'flex-flow': 'row nowrap', 'flex-direction': 'row', 'flex-wrap': 'nowrap', 'align-items': 'normal', 'justify-content': 'normal', gap: 'normal', 'grid-template-columns': 'none', 'grid-template-rows': 'none', 'will-change': 'auto', 'backface-visibility': 'visible', @@ -8563,7 +8563,7 @@ const _CSS_SUPPORTED_DECLARATIONS = new Set(( "stroke stroke-width border-color font-size font font-weight font-family font-style text-align " + "text-transform text-decoration text-decoration-line line-height white-space overflow-wrap word-wrap word-break text-wrap text-wrap-style align-items justify-items " + "place-items align-self justify-self place-self align-content justify-content place-content " + - "flex-direction flex-wrap flex-grow flex-shrink flex-basis flex order position float object-fit " + + "flex-flow flex-direction flex-wrap flex-grow flex-shrink flex-basis flex order position float object-fit " + "top right bottom left inset overflow overflow-x overflow-y scrollbar-gutter visibility opacity animation " + "animation-name animation-fill-mode animation-iteration-count z-index clear vertical-align " + "list-style list-style-type gap grid-gap row-gap grid-row-gap column-gap grid-column-gap " + @@ -8739,6 +8739,23 @@ function _cssSupportsDeclaration(name, value) { if (name === "align-content" || name === "justify-content") { return _cssSupportsContentAlignment(lower) || (name === "justify-content" && ["left", "right"].includes(lower)); } + if (name === "flex-flow") { + const tokens = _cssSplitWhitespace(lower); + if (tokens.length < 1 || tokens.length > 2) return false; + let direction = false, wrap = false; + for (const token of tokens) { + if (["row", "row-reverse", "column", "column-reverse"].includes(token)) { + if (direction) return false; + direction = true; + } else if (["nowrap", "wrap", "wrap-reverse"].includes(token)) { + if (wrap) return false; + wrap = true; + } else { + return false; + } + } + return true; + } if (name === "flex-direction") return ["row", "row-reverse", "column", "column-reverse"].includes(lower); if (name === "flex-wrap") return ["nowrap", "wrap", "wrap-reverse"].includes(lower); if (name === "flex-grow" || name === "flex-shrink") { diff --git a/crates/obscura-js/src/runtime.rs b/crates/obscura-js/src/runtime.rs index dba2237..a57ea12 100644 --- a/crates/obscura-js/src/runtime.rs +++ b/crates/obscura-js/src/runtime.rs @@ -4708,13 +4708,19 @@ mod tests { CSS.supports("word-break", "break-all"), CSS.supports("filter", "blur(2px)"), CSS.supports("content", "attr(data-label)"), - CSS.supports("display", "grid;") + CSS.supports("display", "grid;"), + CSS.supports("flex-flow", "column"), + CSS.supports("flex-flow", "wrap column"), + CSS.supports("flex-flow", "column wrap"), + CSS.supports("flex-flow", "row column"), + CSS.supports("flex-flow", "nowrap wrap-reverse"), + CSS.supports("(flex-flow:column)") ])"#, ) .unwrap(); assert_eq!( result, - serde_json::json!("[false,false,false,false,false,true,true,true,false,false,false,true,false,true,true,false,false,true,false,true,false,true,false]") + serde_json::json!("[false,false,false,false,false,true,true,true,false,false,false,true,false,true,true,false,false,true,false,true,false,true,false,true,true,true,false,false,true]") ); } diff --git a/crates/obscura-render/src/style.rs b/crates/obscura-render/src/style.rs index 78d6669..b69a5e2 100644 --- a/crates/obscura-render/src/style.rs +++ b/crates/obscura-render/src/style.rs @@ -1220,6 +1220,15 @@ fn apply_value(style: &mut LayoutStyle, name: &str, value: &str) { style.justify_content = Some(justify); } } + "flex-flow" => { + if let Some((direction, wrap)) = parse_flex_flow_shorthand(value) { + // A shorthand always assigns both longhands. In particular, + // `flex-flow: column` resets an earlier flex-wrap to nowrap, + // while `flex-flow: wrap` resets direction to row. + style.flex_direction = Some(direction); + style.flex_wrap = Some(wrap); + } + } "flex-direction" => match value { "row" => style.flex_direction = Some(taffy::FlexDirection::Row), "row-reverse" => style.flex_direction = Some(taffy::FlexDirection::RowReverse), @@ -1970,6 +1979,7 @@ pub fn supports_declaration(name: &str, value: &str) -> bool { | "align-content" | "justify-content" | "place-content" + | "flex-flow" | "flex-direction" | "flex-wrap" | "flex-grow" @@ -2527,6 +2537,7 @@ fn supports_conservative_known_value(name: &str, value: &str) -> bool { matches!(lower.as_str(), "left" | "right") || content_alignment_value(value).is_some() } "place-content" => content_alignment_pair(value).is_some(), + "flex-flow" => parse_flex_flow_shorthand(value).is_some(), "flex-direction" => matches!( lower.as_str(), "row" | "row-reverse" | "column" | "column-reverse" @@ -7388,6 +7399,61 @@ fn parse_flex_shorthand(style: &mut LayoutStyle, value: &str) { }; } +/// Parse `flex-flow: || `. The two constituents +/// may appear in either order and each may appear at most once. Shorthand +/// omission resets the other constituent to its initial value rather than +/// retaining an earlier longhand winner. +fn parse_flex_flow_shorthand( + value: &str, +) -> Option<(taffy::FlexDirection, taffy::FlexWrap)> { + let lower = value.trim().to_ascii_lowercase(); + if matches!( + lower.as_str(), + "initial" | "unset" | "revert" | "revert-layer" + ) { + return Some((taffy::FlexDirection::Row, taffy::FlexWrap::NoWrap)); + } + + let tokens = lower.split_whitespace().collect::>(); + if tokens.is_empty() || tokens.len() > 2 { + return None; + } + let mut direction = None; + let mut wrap = None; + for token in tokens { + let parsed_direction = match token { + "row" => Some(taffy::FlexDirection::Row), + "row-reverse" => Some(taffy::FlexDirection::RowReverse), + "column" => Some(taffy::FlexDirection::Column), + "column-reverse" => Some(taffy::FlexDirection::ColumnReverse), + _ => None, + }; + if let Some(parsed) = parsed_direction { + if direction.replace(parsed).is_some() { + return None; + } + continue; + } + let parsed_wrap = match token { + "nowrap" => Some(taffy::FlexWrap::NoWrap), + "wrap" => Some(taffy::FlexWrap::Wrap), + "wrap-reverse" => Some(taffy::FlexWrap::WrapReverse), + _ => None, + }; + if let Some(parsed) = parsed_wrap { + if wrap.replace(parsed).is_some() { + return None; + } + } else { + return None; + } + } + Some(( + direction.unwrap_or(taffy::FlexDirection::Row), + wrap.unwrap_or(taffy::FlexWrap::NoWrap), + )) +} + /// `background-size: 10px` / `0.857em` / `10px 20px` -> explicit px pair. /// Keyword values (`cover`, `contain`, `auto`) are left unhandled (`None`, the /// "stretch to fill the box" fallback) since evaluating them needs the @@ -9831,6 +9897,64 @@ mod tests { assert_eq!(s.flex_shrink, Some(1.0)); } + #[test] + fn flex_flow_is_unordered_resets_omissions_and_rejects_atomically() { + for value in [ + "column", + "wrap", + "column wrap", + "wrap column", + "row-reverse wrap-reverse", + "WRAP COLUMN-REVERSE", + ] { + assert!(supports_declaration("flex-flow", value), "{value}"); + } + for value in [ + "", + "none", + "row column", + "nowrap wrap-reverse", + "row wrap extra", + "10px", + ] { + assert!(!supports_declaration("flex-flow", value), "{value}"); + } + + let direction_only = compute_style( + "div", + Some("flex-wrap:wrap;flex-flow:column"), + ); + assert_eq!( + direction_only.flex_direction, + Some(taffy::FlexDirection::Column) + ); + assert_eq!(direction_only.flex_wrap, Some(taffy::FlexWrap::NoWrap)); + + let wrap_only = compute_style( + "div", + Some("flex-direction:column;flex-flow:wrap"), + ); + assert_eq!(wrap_only.flex_direction, Some(taffy::FlexDirection::Row)); + assert_eq!(wrap_only.flex_wrap, Some(taffy::FlexWrap::Wrap)); + + let reversed = compute_style("div", Some("flex-flow:wrap column-reverse")); + assert_eq!( + reversed.flex_direction, + Some(taffy::FlexDirection::ColumnReverse) + ); + assert_eq!(reversed.flex_wrap, Some(taffy::FlexWrap::Wrap)); + + let invalid_later = compute_style( + "div", + Some("flex-flow:column wrap;flex-flow:row column"), + ); + assert_eq!( + invalid_later.flex_direction, + Some(taffy::FlexDirection::Column) + ); + assert_eq!(invalid_later.flex_wrap, Some(taffy::FlexWrap::Wrap)); + } + #[test] fn box_shadow_outset_parses() { let s = compute_style("div", Some("box-shadow: 0 2px 8px rgba(0,0,0,.15)")); diff --git a/render-repros/checks.json b/render-repros/checks.json index d9add3d..fd450f6 100644 --- a/render-repros/checks.json +++ b/render-repros/checks.json @@ -63,6 +63,19 @@ "eventtarget-lifecycle": [ {"name": "framework lifecycle EventTarget completes", "color": "#18723a", "x": 0, "y": 0, "width": 420, "height": 120} ], + "flex-flow": [ + {"name": "direction-first shorthand starts a column", "color": "#087f5b", "x": 0, "y": 0, "width": 100, "height": 40}, + {"name": "direction-first shorthand keeps column flow", "color": "#0ca678", "x": 0, "y": 40, "width": 100, "height": 40}, + {"name": "wrap-first shorthand starts a column", "color": "#1971c2", "x": 200, "y": 0, "width": 100, "height": 40}, + {"name": "wrap-first shorthand keeps column flow", "color": "#1c7ed6", "x": 200, "y": 40, "width": 100, "height": 40}, + {"name": "direction-only shorthand resets nowrap", "color": "#e8590c", "x": 400, "y": 0, "width": 100, "height": 40}, + {"name": "reset nowrap keeps the second item on the row", "color": "#f76707", "x": 500, "y": 0, "width": 100, "height": 40}, + {"name": "wrap-only shorthand resets row direction", "color": "#7048e8", "x": 0, "y": 120, "width": 100, "height": 40}, + {"name": "reset row direction wraps the second item", "color": "#7950f2", "x": 0, "y": 160, "width": 100, "height": 40}, + {"name": "invalid later shorthand retains the column", "color": "#c2255c", "x": 200, "y": 120, "width": 100, "height": 40}, + {"name": "invalid shorthand does not partially mutate", "color": "#d6336c", "x": 200, "y": 160, "width": 100, "height": 40}, + {"name": "CSS supports accepts only the shorthand grammar", "color": "#18723a", "x": 400, "y": 120, "width": 180, "height": 40} + ], "flex-whitespace-items": [ {"name": "direct flex sidebar starts at container edge", "color": "#1971c2", "x": 0, "y": 0, "width": 225, "height": 180}, {"name": "direct flex body keeps its intended inset", "color": "#2b8a3e", "x": 225, "y": 0, "width": 675, "height": 200}, diff --git a/render-repros/flex-flow.html b/render-repros/flex-flow.html new file mode 100644 index 0000000..1f7e64a --- /dev/null +++ b/render-repros/flex-flow.html @@ -0,0 +1,40 @@ + + + + +
+
+
+
+
+
+ +