From 4ac5b5fa698b394e315252bc64b2516620b6a34e Mon Sep 17 00:00:00 2001 From: Fini Date: Sun, 21 Jun 2026 23:18:17 +0800 Subject: [PATCH] fix(lint): guard active nav pill against sibling corner-radius flattening A 100%-rounded active tab nested in a nav pill was flattened to a sharp square by the sibling corner-radius consistency detectors. Skip the fix when a group's radius is pill-scale (>= 40) so the active pill survives validation. --- .../op-design-lint/src/detectors/siblings.rs | 34 +++++++++++++++++++ .../src/detectors/siblings_tests.rs | 23 +++++++++++++ crates/op-design-lint/src/plan.rs | 34 +++++++++++++++++++ 3 files changed, 91 insertions(+) diff --git a/crates/op-design-lint/src/detectors/siblings.rs b/crates/op-design-lint/src/detectors/siblings.rs index c6c45d88b..120979d92 100644 --- a/crates/op-design-lint/src/detectors/siblings.rs +++ b/crates/op-design-lint/src/detectors/siblings.rs @@ -203,10 +203,33 @@ fn check_consistency( let majority_value = values[majority_idx].1.value.clone(); let property_fix = fix_property_for(property); + // A PILL cornerRadius (>= 40) is an INTENTIONAL design choice — a + // fully-rounded active nav tab / toggle highlight — NOT a sibling + // inconsistency. Skip cornerRadius normalization when the outlier OR the + // majority is a pill, so this lint never flattens a rounded active nav tab + // back to its square siblings (the active-nav-tab-reset-to-square bug) nor + // rounds square siblings up to a pill. Card-scale radii (8–16) still + // normalize. (Mirrors the guard in `detect_mixed_sibling_corner_radius`.) + const PILL_RADIUS: f64 = 40.0; + let is_corner = property == "cornerRadius"; + let majority_is_pill = is_corner + && majority_value + .as_f64() + .map(|v| v >= PILL_RADIUS) + .unwrap_or(false); for (idx, (_, group)) in values.iter().enumerate() { if idx == majority_idx { continue; } + let group_is_pill = is_corner + && group + .value + .as_f64() + .map(|v| v >= PILL_RADIUS) + .unwrap_or(false); + if is_corner && (group_is_pill || majority_is_pill) { + continue; + } for node_id_str in &group.nodes { issues.push(Issue { node_id: node_id_str.clone(), @@ -278,6 +301,17 @@ fn walk_mixed_corner_radius(node: &PenNode, issues: &mut Vec) { for sibling in &siblings { let num = corner_radius_numeric(sibling); if num != modal { + // A PILL radius (>= 40) is an INTENTIONAL design choice — a + // fully-rounded active nav tab / toggle highlight — not a + // sibling inconsistency to flatten. Skip when either the + // sibling OR the modal is a pill, so the validator never + // resets a rounded active nav tab back to a square (the + // round-then-lint-flattens bug) nor forces square siblings + // into pills. Card-scale radii (8–16) still normalize. + const PILL_RADIUS: f64 = 40.0; + if num >= PILL_RADIUS || modal >= PILL_RADIUS { + continue; + } issues.push(Issue { node_id: node_id(sibling).to_string(), category: IssueCategory::MixedSiblingCornerRadius, diff --git a/crates/op-design-lint/src/detectors/siblings_tests.rs b/crates/op-design-lint/src/detectors/siblings_tests.rs index 4237a8b15..56d1fe780 100644 --- a/crates/op-design-lint/src/detectors/siblings_tests.rs +++ b/crates/op-design-lint/src/detectors/siblings_tests.rs @@ -187,6 +187,29 @@ mod mixed_corner_radius_tests { assert_eq!(issues[0].suggested_value, json!(8)); } + #[test] + fn active_nav_pill_not_flattened_to_square_siblings() { + // The desktop's pre-validator runs this lint AFTER apply_tree_heuristics + // rounds the active nav tab to a 999 pill. Among its square (cr=0) + // inactive siblings the modal is 0, so without a pill guard the lint + // would "fix" the 999 back to 0 — exactly the active-nav-tab-reset-to- + // square bug. A pill radius (>= 40) is intentional and must be left alone. + let root = node(json!({ + "type": "frame", "id": "row", "layout": "horizontal", + "children": [ + {"type": "frame", "id": "home", "cornerRadius": 999, + "fill": [{"type": "solid", "color": "#F97316"}]}, + {"type": "frame", "id": "orders", "cornerRadius": 0}, + {"type": "frame", "id": "profile", "cornerRadius": 0} + ] + })); + let issues = detect_mixed_sibling_corner_radius(&root); + assert!( + !issues.iter().any(|i| i.node_id == "home"), + "active pill nav tab (999) must NOT be flagged to flatten to its square siblings, got {issues:?}" + ); + } + #[test] fn ignores_three_way_split_without_modal() { let root = node(json!({ diff --git a/crates/op-design-lint/src/plan.rs b/crates/op-design-lint/src/plan.rs index d2c29d0c6..b220adf24 100644 --- a/crates/op-design-lint/src/plan.rs +++ b/crates/op-design-lint/src/plan.rs @@ -340,6 +340,40 @@ mod tests { ); } + /// End-to-end of what the desktop's `LintPreValidator` runs: `detect_and_plan` + /// on a nav AFTER `apply_tree_heuristics` rounded the active Home tab to a 999 + /// pill. The mixed-sibling-corner-radius detector must NOT plan flattening it + /// back to its square (cr=0) siblings — the active-nav-tab-reset-to-square bug + /// that left op-smoke (no validation) rounded but the desktop (validation) + /// square. + #[test] + fn detect_and_plan_does_not_flatten_active_nav_pill() { + let raw = r##"{ + "version":"1.0", + "children":[{ + "type":"frame","id":"bar","role":"bottom-tab-bar","layout":"vertical", + "children":[{ + "type":"frame","id":"pill","cornerRadius":100,"layout":"horizontal", + "children":[ + {"type":"frame","id":"home","cornerRadius":999,"fill":[{"type":"solid","color":"#F97316"}]}, + {"type":"frame","id":"search","role":"search-bar","cornerRadius":26}, + {"type":"frame","id":"orders","cornerRadius":0}, + {"type":"frame","id":"profile","cornerRadius":0} + ] + }] + }] + }"##; + let doc: PenDocument = serde_json::from_str(raw).expect("parse nav doc"); + let plan = detect_and_plan(&doc); + let home_corner_fix = plan + .iter() + .any(|f| f.node_id == "home" && matches!(f.action, PlannedAction::SetCornerRadius(_))); + assert!( + !home_corner_fix, + "active nav pill (home cr=999) must NOT be planned for a cornerRadius flatten, plan={plan:?}" + ); + } + /// Load the `invisible-container-with-var` fixture (doc declares /// `color-border` variable) and assert equivalence — exercises the /// `$color-border` design-token reference path.