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.
This commit is contained in:
Fini 2026-06-21 23:18:17 +08:00
parent 169e6abed2
commit 4ac5b5fa69
3 changed files with 91 additions and 0 deletions

View file

@ -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<Issue>) {
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,

View file

@ -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!({

View file

@ -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.