Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changepacks/changepack_log_Fq8ZpW2nKe4TbR6yLx3Hd.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"changes": {
"Cargo.toml": "Minor"
},
"note": "Field focus marks the box the user sees. A Figma field component set (input, text field, textarea, search, select, combobox, dropdown) now generates its focus variant as _focusWithin on the container instead of an unreachable _focus (DEVUP_CODEGEN_FIELD_FOCUS_WITHIN). devup_ui_validate reports input-focus-container, input-focus-duplicate-outline and input-focus-layout-shift as browser-required review items (info) and input-focus-outline-removed and input-focus-moved-to-container as warnings; a container's _focusWithin now counts as the field's focus style. devup_runtime_audit adds the input-focus check (planned by auditPlan): every visible input, textarea and select is focused by click and by Tab and fails an inner default outline inside the container, a ring that does not surround the container, focus landing on the container and any size or position change; disabled fields are listed and skipped. Contract identifier field-focus-on-visual-container. Field states ease instead of snapping: a generated field component without a Figma prototype transition gets transition 160ms ease-out on the props its states change (transitionProperty is now written in CSS kebab-case, e.g. border-color), devup_ui_validate reports input-state-transition (info, browser-required), and the input-focus runtime check fails a focus state no transition covers.",
"date": "2026-11-12T00:00:00.000000000Z"
}
7 changes: 7 additions & 0 deletions .changepacks/changepack_log_Uv5MsA9cJr2GhN7wQe4Kp.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"changes": {
"Cargo.toml": "Minor"
},
"note": "The plan gates can no longer be self-confirmed. devup_screen_plan confirm through the tool requires userApproval {via: question-tool | user-message | delegated, userAnswer: the user's verbatim answer, approvedUrls: every URL of the table shown}; a missing or placeholder receipt, or URLs that differ from what confirm builds, confirms nothing and returns the table and expectedUrls to show. devup_project_plan confirm requires the same receipt (without URLs). The receipt is written to the plan and printed in confirm, status and the finalReport (delegated or missing receipts are reported as not reviewed by the user). Draft nextAction carries mustAskUser and the receipt template; the server instructions, the devup and devup-implementation-plan skills and the OpenCode command state the hard stop. Contract identifier user-approved-plan-before-implementation (developmentContract v6). A confirmed plan without the user's receipt writes no page: devup_figma_export page writes return status approval_required until confirm records the approval. Every devup_screen_plan reply and every refused export carry a procedure block (step of 5, the one next call, waitForUser, forbiddenNow) computed from the plan on disk. The site:motion question now recommends Predict from the design (motion source predicted, Devup UI static keyframes), and status adds motion and field-state-motion completion items that stay missing until the sources carry them.",
"date": "2026-11-12T00:00:00.000000000Z"
}
1 change: 1 addition & 0 deletions crates/devup-mcp-devup-ui/src/codegen/variant.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,4 @@ include!("variant/structural_branches.rs");
include!("variant/instance_booleans.rs");

include!("variant/tree_signature.rs");
include!("variant/field_focus.rs");
31 changes: 22 additions & 9 deletions crates/devup-mcp-devup-ui/src/codegen/variant/drawn_when.rs
Original file line number Diff line number Diff line change
Expand Up @@ -569,19 +569,32 @@ fn transition(
.trim_start_matches("EASE_")
.to_ascii_lowercase()
.replace('_', "-");
let properties = props
Some((
format!("{}ms ease-{easing}", layout::format_number(duration)),
css_properties(props),
))
}

/// The CSS `transition-property` list for Devup props: `bg` is `background`,
/// and camelCase props are written in kebab-case (`borderColor` ->
/// `border-color`), the only spelling the browser accepts there.
fn css_properties(props: &BTreeSet<String>) -> String {
props
.iter()
.map(|prop| match prop.as_str() {
"bg" => "background",
"boxShadow" => "box-shadow",
value => value,
"bg" => "background".to_owned(),
value => value.chars().fold(String::new(), |mut out, c| {
if c.is_ascii_uppercase() {
out.push('-');
out.push(c.to_ascii_lowercase());
} else {
out.push(c);
}
out
}),
})
.collect::<Vec<_>>()
.join(",");
Some((
format!("{}ms ease-{easing}", layout::format_number(duration)),
properties,
))
.join(",")
}

fn render_component(
Expand Down
48 changes: 48 additions & 0 deletions crates/devup-mcp-devup-ui/src/codegen/variant/field_focus.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
// A field component's focus state belongs on its container.

/// Whether a component set draws a text field, search box, textarea or
/// select by its Figma name. Its frame is the visual container; the focusable
/// control is the native element the implementation renders inside it.
pub(crate) fn is_field_component(name: &str) -> bool {
let name = name.to_lowercase().replace(['-', '_', ' '], "");
["input", "textfield", "textarea", "searchbar", "searchbox", "searchfield", "select", "combobox", "dropdown", "입력", "검색"]
.iter()
.any(|word| name.contains(word))
}

/// A field's state change (focus ring, invalid border, disabled fill) eases
/// in instead of snapping. Figma prototype reactions on the field win; this is
/// the default when the design names none: short, ease-out, and only on the
/// props the states change, so layout never animates.
pub(crate) const FIELD_STATE_TRANSITION: &str = "160ms ease-out";

fn field_transition(
set_name: &str,
designed: Option<(String, String)>,
props: &BTreeSet<String>,
) -> Option<(String, String)> {
if designed.is_some() || props.is_empty() || !is_field_component(set_name) {
return designed;
}
Some((FIELD_STATE_TRANSITION.to_owned(), css_properties(props)))
}

/// A field container never receives focus itself, so a captured `focus`
/// state on it would never apply: it moves to `_focusWithin`, which matches
/// while the control inside it is focused. A root already rendered as the
/// native control keeps `_focus`. Returns whether anything moved.
fn field_focus_within<V>(
set_name: &str,
root: &Tree,
selectors: &mut BTreeMap<String, BTreeMap<String, V>>,
) -> Option<()> {
let native = root.props.get("as").is_some_and(|tag| {
["input", "textarea", "select"].iter().any(|native| tag.contains(native))
});
if native || !is_field_component(set_name) {
return None;
}
let focus = selectors.remove("_focus")?;
selectors.entry("_focusWithin".to_owned()).or_default().extend(focus);
Some(())
}
Original file line number Diff line number Diff line change
Expand Up @@ -248,9 +248,26 @@ pub(super) fn generate_variant_component_set(
}
}
}
let transition = transition(snapshot, &default.node_id, &transition_props);
let set_name = set.typed_view().name().unwrap_or("Component");
let field_focus = field_focus_within(set_name, &root, &mut selectors).map(|_| Diagnostic {
code: "DEVUP_CODEGEN_FIELD_FOCUS_WITHIN".to_owned(),
message: "The focus variant of this field component is applied with _focusWithin on its container, because the container is not what receives focus: the <input>, <textarea> or <select> rendered inside it is.".to_owned(),
node_id: Some(set.id.clone()),
severity: Some(DiagnosticSeverity::Info),
details: Some(serde_json::json!({
"classification": "field-focus-container",
"selector": "_focusWithin",
"nextAction": "Render the real <input>/<textarea>/<select> inside this container, keep keyboard focus on it (never tabIndex on the container), and remove only its own default outline (outline: none) because the container's _focusWithin border/ring replaces it. Keep the focused border width equal to the resting one or draw the ring with boxShadow/outline so nothing shifts. Then run the runtime audit input-focus check (click and Tab)."
})),
..Diagnostic::default()
});
let transition = field_transition(
set_name,
transition(snapshot, &default.node_id, &transition_props),
&transition_props,
);
let text_properties = bind_text_properties(snapshot, set, &mut root, &dimensions);
let component_name = legacy_component_name(set.typed_view().name().unwrap_or("Component"));
let component_name = legacy_component_name(set_name);
let code = mark_node(
&set.id,
render_component(
Expand Down Expand Up @@ -282,6 +299,7 @@ pub(super) fn generate_variant_component_set(
})
.chain(viewport_alt_diagnostics(&records, &root, &[]))
.chain(state_diagnostics)
.chain(field_focus)
.collect(),
source_map: crate::provenance::SourceMap::empty(),
projection_trace: crate::provenance::ProjectionTrace::default(),
Expand Down
113 changes: 102 additions & 11 deletions crates/devup-mcp-devup-ui/src/ui_validate/site.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,31 @@
//! - `nav-current` (info): a link in a nav with no `aria-current` handling.
//! - `page-metadata` (warning): an App Router `page` or `layout` that exports
//! neither `metadata` nor `generateMetadata`.
//!
//! Inputs are the one role whose focusable element is often not the box the
//! user sees: an `<input>` with no border of its own inside a bordered
//! container (with an icon, prefix or button) shows focus only if the
//! container reacts to `:focus-within`. Source alone cannot prove what the
//! browser paints (a global reset may remove the outline, a theme may draw
//! the border), so these are review items marked `verification:
//! "browser-required"` and answered by the runtime audit `input-focus` check:
//!
//! - `input-focus-container` (info): the field sits inside a visual
//! container that has no `_focusWithin`; a focus style on the field draws
//! inside the box, or nothing marks the box at all.
//! - `input-focus-duplicate-outline` (info): the container has
//! `_focusWithin` but the field keeps its default outline, so a second,
//! inner ring appears.
//! - `input-focus-outline-removed` (warning): the field removes its outline
//! and nothing in this source replaces it - not on the field, not on the
//! container.
//! - `input-focus-layout-shift` (info): the focused border is wider than the
//! resting one, which moves the text and the elements around the field.
//! - `input-focus-moved-to-container` (warning): the container takes
//! `tabIndex`, so keyboard focus can land on a box that is not the field.
//! - `input-state-transition` (info): the box that shows focus changes its
//! border or ring with no `transition`, so the state snaps instead of
//! easing; the runtime `input-focus` check measures it.

use super::{Severity, Violation};
use crate::effective_attributes::AttributeContext;
Expand All @@ -31,6 +56,10 @@ use oxc_ast_visit::{Visit, walk};
use oxc_parser::Parser;
use oxc_span::{GetSpan, SourceType, Span};

#[path = "site/input_focus.rs"]
mod input_focus;
use input_focus::{input_focus_findings, paints_box};

/// Interactive elements found in one source, and how many carry states.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct InteractionCoverage {
Expand Down Expand Up @@ -103,25 +132,57 @@ struct Element {
child_hover: bool,
/// The opening tag as written, for the motion checks.
text: String,
/// For an input: the nearest ancestor that draws the visible box, when
/// the field draws none of its own.
container: Option<Container>,
}

/// An ancestor that paints a border, background or shadow.
#[derive(Clone)]
struct Container {
name: String,
span: Span,
focus_within: bool,
tab_index: bool,
/// The opening tag as written.
text: String,
}

/// What the visitor remembers about each open ancestor element.
struct Frame {
container: Option<Container>,
}

/// How many ancestors up a field's visual container may be: the field, an
/// optional inner wrapper, the box.
const CONTAINER_DEPTH: usize = 3;

impl Element {
fn complete(&self) -> bool {
match self.role {
Role::Button => self.hover && self.active && self.focus,
Role::IconButton => self.hover && self.focus && self.aria_label,
Role::NavItem => self.hover && self.focus && self.aria_current,
Role::Disclosure => self.focus,
Role::Input | Role::Link | Role::Card | Role::Clickable => self.hover && self.focus,
Role::Input => self.hover && self.shows_focus(),
Role::Link | Role::Card | Role::Clickable => self.hover && self.focus,
}
}

/// Whether focusing this element shows something: its own focus style,
/// or, for a field inside a visual container, the container's
/// `_focusWithin`.
fn shows_focus(&self) -> bool {
self.focus || self.container.as_ref().is_some_and(|box_| box_.focus_within)
}
}

struct Collector<'s> {
source: &'s str,
nav_depth: usize,
elements: Vec<Element>,
attributes: AttributeContext,
ancestors: Vec<Frame>,
}

fn attribute_name<'a>(item: &'a JSXAttributeItem<'a>) -> Option<&'a str> {
Expand Down Expand Up @@ -229,11 +290,26 @@ impl<'a> Visit<'a> for Collector<'_> {
} else {
None
};
let span = opening.span();
let text = self
.source
.get(span.start as usize..span.end as usize)
.unwrap_or_default()
.to_owned();
let paints = paints_box(&attributes);
let container = (input && !paints)
.then(|| {
self.ancestors
.iter()
.rev()
.take(CONTAINER_DEPTH)
.find_map(|frame| frame.container.clone())
})
.flatten();
if let Some(role) = role.filter(|_| !spread) {
let span = opening.span();
self.elements.push(Element {
span,
name: tag,
name: tag.clone(),
role,
hover: has("_hover"),
focus: has("_focusVisible") || has("_focus") || has("_focusWithin"),
Expand All @@ -245,16 +321,25 @@ impl<'a> Visit<'a> for Collector<'_> {
child_focus: link
&& descendant_has(&node.children, &["_focusVisible", "_focus"]),
child_hover: link && descendant_has(&node.children, &["_hover"]),
text: self
.source
.get(span.start as usize..span.end as usize)
.unwrap_or_default()
.to_owned(),
text: text.clone(),
container,
});
}
let tab_index = attributes.contains("tabIndex")
&& attributes.literal("tabIndex").as_deref() != Some("-1");
self.ancestors.push(Frame {
container: (paints && !input).then(|| Container {
name: tag.clone(),
span,
focus_within: has("_focusWithin"),
tab_index,
text,
}),
});
self.nav_depth += usize::from(nav);
walk::walk_jsx_element(self, node);
self.nav_depth -= usize::from(nav);
self.ancestors.pop();
}
}

Expand All @@ -277,6 +362,7 @@ fn collect(path: &str, source: &str) -> Option<Vec<Element>> {
nav_depth: 0,
elements: Vec::new(),
attributes: AttributeContext::from_program(&parsed.program),
ancestors: Vec::new(),
};
collector.visit_program(&parsed.program);
Some(collector.elements)
Expand All @@ -290,7 +376,7 @@ pub fn interaction_coverage(path: &str, source: &str) -> InteractionCoverage {
interactive: elements.len(),
with_states: elements
.iter()
.filter(|element| element.hover || element.focus)
.filter(|element| element.hover || element.shows_focus())
.count(),
hover_without_transition: elements
.iter()
Expand Down Expand Up @@ -463,7 +549,8 @@ pub fn interaction_findings(path: &str, source: &str) -> Vec<Violation> {
"Put _hover on the element rendered as the link: <Text as={Link} props={{ href }} _hover={{ ... }}>.",
);
}
if !element.hover && !element.focus && !element.child_focus && !element.child_hover {
let focus = element.shows_focus();
if !element.hover && !focus && !element.child_focus && !element.child_hover {
push(
"interactive-states",
Severity::Warning,
Expand All @@ -473,7 +560,7 @@ pub fn interaction_findings(path: &str, source: &str) -> Vec<Violation> {
),
include_str!("../text/site-interaction_findings-3f71ed85.md"),
);
} else if !element.focus && !element.child_focus {
} else if !focus && !element.child_focus {
push(
"focus-style",
Severity::Warning,
Expand Down Expand Up @@ -551,6 +638,9 @@ pub fn interaction_findings(path: &str, source: &str) -> Vec<Violation> {
"Set aria-current=\"page\" on the link for the current route (usePathname) and give it the Figma active style.",
);
}
if matches!(element.role, Role::Input) {
found.extend(input_focus_findings(source, &element));
}
}
found
}
Expand Down Expand Up @@ -745,4 +835,5 @@ export function Cards() { return <Box as={Link} href="/work/1"><Image src="/a.pn
assert!(metadata_finding("src/components/About.tsx", bare).is_none());
assert!(is_route_file("app/layout.tsx"));
}

}
Loading
Loading