diff --git a/.changepacks/changepack_log_aQ7mZ3kR9xTnV5wLb2sYd.json b/.changepacks/changepack_log_aQ7mZ3kR9xTnV5wLb2sYd.json new file mode 100644 index 00000000..6d204cd6 --- /dev/null +++ b/.changepacks/changepack_log_aQ7mZ3kR9xTnV5wLb2sYd.json @@ -0,0 +1,7 @@ +{ + "changes": { + "Cargo.toml": "Minor" + }, + "note": "Make restrained entrance/transition motion a default delivery item (animation reference, contract, entrance-motion validation rules, reveal re-entry audit), accept same-file static consts such as keyframes names in css()/globalCss()/keyframes(), and validate tokens per app against the nearest devup.json while reporting unresolved files as unverified. Reveal audits also measure trigger position and opt-in fast-scroll, reload, history, image, focus and progress-sync scenarios; the shipped motion runtime no longer hides content already on screen; a located sourceName selects the theme for a single tsx.", + "date": "2026-11-10T00:00:00.000000000Z" +} diff --git a/crates/devup-mcp-devup-ui/src/text/ui_validate-check_static_object_Violation-a031fb84.md b/crates/devup-mcp-devup-ui/src/text/ui_validate-check_static_object_Violation-a031fb84.md index d95590da..d2558d6e 100644 --- a/crates/devup-mcp-devup-ui/src/text/ui_validate-check_static_object_Violation-a031fb84.md +++ b/crates/devup-mcp-devup-ui/src/text/ui_validate-check_static_object_Violation-a031fb84.md @@ -1 +1 @@ -{call_name}({{ {key}: ... }}) accepts only statically analyzable literal values. css() can extract finite conditional branches as static classes; globalCss()/keyframes() do not select runtime classes. Value branches must be static, not arbitrary runtime values. \ No newline at end of file +{call_name}({{ {key}: ... }}) accepts only statically analyzable values: literals, or a module-level const of this same file holding a literal or a keyframes()/css() name. css() can extract finite conditional branches as static classes; globalCss()/keyframes() do not select runtime classes. An import, a parameter, a call result or any other runtime value is not static here. \ No newline at end of file diff --git a/crates/devup-mcp-devup-ui/src/ui_structure.rs b/crates/devup-mcp-devup-ui/src/ui_structure.rs index 1f6fa747..b72a6fd9 100644 --- a/crates/devup-mcp-devup-ui/src/ui_structure.rs +++ b/crates/devup-mcp-devup-ui/src/ui_structure.rs @@ -73,6 +73,20 @@ pub fn validate_sources( theme: Option<&ProjectTheme>, strict: bool, ) -> UiValidation { + let mut result = validate_sources_with(files, strict, &|_| theme).0; + result.available_token_count = theme.map_or(0, ProjectTheme::token_count); + result +} + +/// As [`validate_sources`], with the theme chosen per file (`theme_of` takes +/// the file's path). Also returns each source file's `$token` reference +/// count, so a caller can say which files were checked against which theme. +/// `available_token_count` is left at 0: it belongs to a theme, not a bundle. +pub fn validate_sources_with<'t>( + files: &[SourceFile], + strict: bool, + theme_of: &dyn Fn(&str) -> Option<&'t ProjectTheme>, +) -> (UiValidation, Vec<(String, usize)>) { let boundary = files.iter().filter(|f| is_source(&f.path)).any(|file| { let allocator = Allocator::default(); let parsed = Parser::new(&allocator, &file.content, source_type(&file.path)).parse(); @@ -86,18 +100,28 @@ pub fn validate_sources( violations: vec![], theme_notes: vec![], checked_tokens: 0, - available_token_count: theme.map_or(0, ProjectTheme::token_count), + available_token_count: 0, }; let mut fixable = 0; + let mut references = Vec::new(); let global_link_decoration = files.iter().filter(|file| is_source(&file.path)) .any(|file| super::visual_behavior::has_global_link_decoration(&file.content)); + // Reduced motion is usually handled once for the tree (a provider), so it + // is asked of the sources together, never of one file alone. + let reduced_motion = files.iter().filter(|file| is_source(&file.path)) + .any(|file| super::motion_quality::handles_reduced_motion(&file.content)); for file in files.iter().filter(|f| is_source(&f.path)) { - let mut report = validate_source(&file.content, theme, strict, source_type(&file.path)); + let mut report = + validate_source(&file.content, theme_of(&file.path), strict, source_type(&file.path)); + references.push((file.path.clone(), report.checked_tokens)); if global_link_decoration { report.violations.retain(|finding| finding.rule != "link-underline"); } fixable += report.next_action.as_ref().and_then(|action| action["fixable"].as_u64()).unwrap_or(0) as usize; if !report.violations.iter().any(|v| v.rule == "invalid-syntax") { + if !reduced_motion { + report.violations.extend(super::motion_quality::reduced_motion_finding(&file.content)); + } report.violations.extend(structural(file, boundary)); report.violations.extend(super::wiring::fixture_findings(files, &file.path, &file.content)); report @@ -123,7 +147,7 @@ pub fn validate_sources( .violations .iter() .all(|v| v.severity != Severity::Error && !(strict && v.severity == Severity::Warning)); - result + (result, references) } fn is_source(path: &str) -> bool { path.ends_with(".tsx") diff --git a/crates/devup-mcp-devup-ui/src/ui_validate/motion_quality.rs b/crates/devup-mcp-devup-ui/src/ui_validate/motion_quality.rs new file mode 100644 index 00000000..78582782 --- /dev/null +++ b/crates/devup-mcp-devup-ui/src/ui_validate/motion_quality.rs @@ -0,0 +1,552 @@ +//! Entrance-animation mistakes that the source alone proves. +//! +//! Only shapes with one reading are reported. Whether motion suits a section, +//! how long it runs and when it fires in a real browser are judged by the +//! runtime audit and the delivery evidence, not here. +//! +//! - `ssr-hidden-reveal`: a `motion.*` element that is always rendered starts +//! at `opacity: 0`. Server markup carries that style inline, so the content +//! is invisible until JavaScript hydrates and animates it — and stays +//! invisible when JavaScript fails or is slow. +//! - `reveal-replays`: `whileInView` without `viewport={{ once: true }}` +//! plays again on every re-entry (warning). An in-view hook +//! (`useInView` from motion or react-intersection-observer) with no +//! one-shot option is the same question asked of code whose purpose is not +//! visible here, so it is info. +//! - `reveal-moves-background`: the element that carries the section's paint +//! is the one being faded or moved, so the background animates with it. +//! - `stagger-too-long`: each item of a group waits more than 150ms after +//! the previous one (`staggerChildren`, or `delay: index * step`), so a +//! handful of cards takes over a second to finish appearing. +//! - `motion-without-reduced-motion`: entrance motion with no reduced-motion +//! handling anywhere in the validated sources (cross-file, see +//! [`reduced_motion_finding`]). +use super::{Severity, Violation, site}; +use oxc_allocator::Allocator; +use oxc_ast::ast::*; +use oxc_ast_visit::{Visit, walk}; +use oxc_parser::Parser; +use oxc_span::SourceType; + +/// The longest per-item step, in seconds, that still reads as one group. +const MAX_STAGGER_STEP: f64 = 0.15; + +const BACKGROUND_PROPS: &[&str] = + &["bg", "bgColor", "bgImage", "background", "backgroundColor", "backgroundImage"]; + +pub(super) fn findings(source: &str) -> Vec { + collect(source).map(|collector| collector.findings).unwrap_or_default() +} + +/// One finding for a file that animates content into view while nothing in it +/// handles reduced motion. Callers that can see other files drop it when any +/// of them [`handles_reduced_motion`]. +pub fn reduced_motion_finding(source: &str) -> Option { + if handles_reduced_motion(source) { + return None; + } + let span = collect(source)?.first_entrance?; + Some(site::violation( + source, + span, + "motion-without-reduced-motion", + Severity::Warning, + "Entrance motion is added, and no validated source handles prefers-reduced-motion.".into(), + "Leave content in place for reduced motion: useReducedMotion(), in a shared provider, or a prefers-reduced-motion media rule. Validate the provider together with this file if it lives elsewhere.", + )) +} + +/// Whether this source reads the user's motion preference or configures it +/// for the tree: a reference to `useReducedMotion`/`usePrefersReducedMotion` +/// (or any `…ReducedMotion` name), a `reducedMotion`/`_motionReduce` prop or +/// key, or a `prefers-reduced-motion` media rule or query in a string. +/// +/// Read from the syntax tree, so a comment that mentions reduced motion or an +/// import nobody calls does not count as handling it. +pub fn handles_reduced_motion(source: &str) -> bool { + let allocator = Allocator::default(); + let parsed = Parser::new(&allocator, source, SourceType::tsx()).parse(); + if parsed.panicked || !parsed.diagnostics.is_empty() { + // Unparseable source is reported as invalid-syntax elsewhere; do not + // add a motion finding on top of it. + return true; + } + let mut preference = Preference(false); + preference.visit_program(&parsed.program); + preference.0 +} + +struct Preference(bool); + +impl Preference { + fn name(&mut self, name: &str) { + self.0 |= name.contains("ReducedMotion") || matches!(name, "reducedMotion" | "_motionReduce"); + } + fn text(&mut self, text: &str) { + self.0 |= text.contains("prefers-reduced-motion"); + } +} + +impl<'a> Visit<'a> for Preference { + // An import names the hook; only a use of it handles anything. + fn visit_import_declaration(&mut self, _: &ImportDeclaration<'a>) {} + fn visit_identifier_reference(&mut self, node: &IdentifierReference<'a>) { + self.name(node.name.as_str()); + } + fn visit_identifier_name(&mut self, node: &IdentifierName<'a>) { + self.name(node.name.as_str()); + } + fn visit_jsx_identifier(&mut self, node: &JSXIdentifier<'a>) { + self.name(node.name.as_str()); + } + fn visit_string_literal(&mut self, node: &StringLiteral<'a>) { + self.text(node.value.as_str()); + } + fn visit_template_element(&mut self, node: &TemplateElement<'a>) { + self.text(node.value.raw.as_str()); + } +} + +fn collect(source: &str) -> Option> { + let allocator = Allocator::default(); + let parsed = Parser::new(&allocator, source, SourceType::tsx()).parse(); + if parsed.panicked || !parsed.diagnostics.is_empty() { + return None; + } + let mut components = MotionComponents(Vec::new()); + components.visit_program(&parsed.program); + let mut collector = Collector { + source, + findings: Vec::new(), + first_entrance: None, + optional: 0, + motion_components: components.0, + }; + collector.visit_program(&parsed.program); + Some(collector) +} + +fn is_motion_namespace(name: &str) -> bool { + matches!(name, "motion" | "m") +} + +/// `const MotionBox = motion.create(Box)` / `motion(Box)`: a component that +/// takes the same `initial`/`whileInView` props as `motion.div`. +struct MotionComponents(Vec); + +impl<'a> Visit<'a> for MotionComponents { + fn visit_variable_declarator(&mut self, node: &VariableDeclarator<'a>) { + if let (Some(name), Some(Expression::CallExpression(call))) = + (node.id.get_binding_identifier(), node.init.as_ref().map(Expression::get_inner_expression)) + { + let wraps = match call.callee.get_inner_expression() { + Expression::Identifier(callee) => is_motion_namespace(callee.name.as_str()), + Expression::StaticMemberExpression(member) => { + member.property.name == "create" + && matches!(member.object.get_inner_expression(), Expression::Identifier(object) if is_motion_namespace(object.name.as_str())) + } + _ => false, + }; + if wraps { + self.0.push(name.name.to_string()); + } + } + walk::walk_variable_declarator(self, node); + } +} + +struct Collector<'s> { + source: &'s str, + findings: Vec, + first_entrance: Option, + /// Depth inside `AnimatePresence` or a conditional render: such an + /// element mounts on the client later, so its initial style is not + /// server markup. + optional: usize, + /// Components this file builds with `motion.create(…)`/`motion(…)`. + motion_components: Vec, +} + +fn attribute<'b, 'a>(node: &'b JSXOpeningElement<'a>, name: &str) -> Option<&'b JSXAttribute<'a>> { + node.attributes.iter().find_map(|item| match item { + JSXAttributeItem::Attribute(attribute) + if matches!(&attribute.name, JSXAttributeName::Identifier(id) if id.name == name) => + { + Some(&**attribute) + } + _ => None, + }) +} + +fn expression<'b, 'a>(attribute: &'b JSXAttribute<'a>) -> Option<&'b Expression<'a>> { + match attribute.value.as_ref()? { + JSXAttributeValue::ExpressionContainer(container) => { + container.expression.as_expression().map(Expression::get_inner_expression) + } + _ => None, + } +} + +fn string<'b>(attribute: &'b JSXAttribute<'_>) -> Option<&'b str> { + match attribute.value.as_ref()? { + JSXAttributeValue::StringLiteral(value) => Some(value.value.as_str()), + _ => match expression(attribute)? { + Expression::StringLiteral(value) => Some(value.value.as_str()), + _ => None, + }, + } +} + +fn property<'b, 'a>(object: &'b ObjectExpression<'a>, name: &str) -> Option<&'b Expression<'a>> { + object.properties.iter().find_map(|item| match item { + ObjectPropertyKind::ObjectProperty(property) + if property.key.static_name().is_some_and(|key| key == name) => + { + Some(property.value.get_inner_expression()) + } + _ => None, + }) +} + +fn is_zero(expression: &Expression<'_>) -> bool { + match expression { + Expression::NumericLiteral(number) => number.value == 0.0, + Expression::StringLiteral(text) => text.value.trim() == "0", + _ => false, + } +} + +fn hides(expression: &Expression<'_>) -> bool { + matches!(expression, Expression::ObjectExpression(object) if property(object, "opacity").is_some_and(is_zero)) +} + +fn is_motion_element(node: &JSXOpeningElement<'_>, components: &[String]) -> bool { + match &node.name { + JSXElementName::MemberExpression(member) => { + matches!(&member.object, JSXMemberExpressionObject::IdentifierReference(object) if is_motion_namespace(object.name.as_str())) + } + name => name + .get_identifier_name() + .is_some_and(|name| components.iter().any(|component| component == name.as_str())), + } +} + +/// Whether an in-view hook call is one-shot: `Some(true)` with +/// `once: true` (motion) or `triggerOnce: true` (react-intersection-observer), +/// `Some(false)` when every argument is readable and none says so, `None` +/// when options are built elsewhere. +fn hook_is_one_shot(call: &CallExpression<'_>) -> Option { + let mut readable = true; + for (index, argument) in call.arguments.iter().enumerate() { + match argument.as_expression().map(Expression::get_inner_expression) { + Some(Expression::ObjectExpression(object)) => { + if ["once", "triggerOnce"].iter().any(|key| { + matches!(property(object, key), Some(Expression::BooleanLiteral(flag)) if flag.value) + }) { + return Some(true); + } + readable &= !object + .properties + .iter() + .any(|item| matches!(item, ObjectPropertyKind::SpreadProperty(_))); + } + // motion's first argument is the element ref. + Some(Expression::Identifier(_)) if index == 0 => {} + _ => readable = false, + } + } + readable.then_some(false) +} + +/// `initial={{ opacity: 0 }}`, or `initial="hidden"` naming a variant this +/// same element declares with `opacity: 0`. A variant declared elsewhere is +/// not followed: it cannot be proven from here. +fn starts_hidden(node: &JSXOpeningElement<'_>) -> bool { + let Some(initial) = attribute(node, "initial") else { + return false; + }; + if expression(initial).is_some_and(hides) { + return true; + } + let Some(name) = string(initial) else { + return false; + }; + attribute(node, "variants") + .and_then(expression) + .and_then(|variants| match variants { + Expression::ObjectExpression(object) => property(object, name), + _ => None, + }) + .is_some_and(hides) +} + +fn paints_background(node: &JSXOpeningElement<'_>) -> bool { + if BACKGROUND_PROPS.iter().any(|name| attribute(node, name).is_some()) { + return true; + } + // `className={css({ bg: … })}` on a motion element. + attribute(node, "className").and_then(expression).is_some_and(|value| match value { + Expression::CallExpression(call) => call.arguments.first().is_some_and(|argument| { + matches!(argument, Argument::ObjectExpression(object) + if BACKGROUND_PROPS.iter().any(|name| property(object, name).is_some())) + }), + _ => false, + }) +} + +impl<'a> Visit<'a> for Collector<'_> { + fn visit_jsx_element(&mut self, node: &JSXElement<'a>) { + let presence = node + .opening_element + .name + .get_identifier_name() + .is_some_and(|name| name == "AnimatePresence"); + self.optional += usize::from(presence); + walk::walk_jsx_element(self, node); + self.optional -= usize::from(presence); + } + + fn visit_logical_expression(&mut self, node: &LogicalExpression<'a>) { + self.optional += 1; + walk::walk_logical_expression(self, node); + self.optional -= 1; + } + + fn visit_conditional_expression(&mut self, node: &ConditionalExpression<'a>) { + self.optional += 1; + walk::walk_conditional_expression(self, node); + self.optional -= 1; + } + + fn visit_object_property(&mut self, node: &ObjectProperty<'a>) { + // `staggerChildren: 0.3`, or `delay: index * 0.3` (either order). A + // fixed `delay: 0.3` is one wait, not a per-item step. + let step = match (node.key.static_name().as_deref(), node.value.get_inner_expression()) { + (Some("staggerChildren"), Expression::NumericLiteral(number)) => Some(number.value), + (Some("delay"), Expression::BinaryExpression(binary)) if binary.operator == BinaryOperator::Multiplication => { + match (binary.left.get_inner_expression(), binary.right.get_inner_expression()) { + (Expression::NumericLiteral(number), other) | (other, Expression::NumericLiteral(number)) + if !matches!(other, Expression::NumericLiteral(_)) => + { + Some(number.value) + } + _ => None, + } + } + _ => None, + }; + if let Some(step) = step.filter(|step| *step > MAX_STAGGER_STEP) { + self.findings.push(site::violation(self.source, node.span, "stagger-too-long", Severity::Warning, + format!("Each item waits {}ms after the previous one, so the group takes a long time to finish appearing.", (step * 1000.0).round()), + "Keep a group stagger short (40-80ms per item, the whole group within about 400ms), or let the group enter together. Keep a longer step only when the design explicitly asks for it.")); + } + walk::walk_object_property(self, node); + } + + fn visit_call_expression(&mut self, node: &CallExpression<'a>) { + if matches!(node.callee.get_inner_expression(), Expression::Identifier(callee) if callee.name == "useInView") + && hook_is_one_shot(node) == Some(false) + { + self.findings.push(site::violation(self.source, node.span, "reveal-replays", Severity::Info, + "This in-view hook has no one-shot option; an entrance driven by it replays on every re-entry.".into(), + "For an entrance, pass { once: true } (motion) or { triggerOnce: true } (react-intersection-observer). Leave it as is when it tracks visibility for another purpose.")); + } + walk::walk_call_expression(self, node); + } + + fn visit_jsx_opening_element(&mut self, node: &JSXOpeningElement<'a>) { + let in_view = attribute(node, "whileInView"); + let hidden = is_motion_element(node, &self.motion_components) && starts_hidden(node); + if (in_view.is_some() || hidden) && self.first_entrance.is_none() { + self.first_entrance = Some(node.span); + } + if hidden && self.optional == 0 { + self.findings.push(site::violation(self.source, node.span, "ssr-hidden-reveal", Severity::Warning, + "Server markup renders this content at opacity 0 until JavaScript animates it.".into(), + "Render visible server content and let a shared observer start the motion after it attaches, or make the hidden start conditional on a mounted/enhanced state. Check no-JavaScript, slow hydration, reload and client navigation.")); + } + if let Some(trigger) = in_view { + let once = match attribute(node, "viewport") { + None => Some(false), + Some(viewport) => match expression(viewport) { + Some(Expression::ObjectExpression(object)) => Some( + matches!(property(object, "once"), Some(Expression::BooleanLiteral(once)) if once.value), + ), + // A viewport built elsewhere cannot be read from here. + _ => None, + }, + }; + if once == Some(false) { + self.findings.push(site::violation(self.source, trigger.span, "reveal-replays", Severity::Warning, + "This scroll reveal has no one-shot viewport, so it replays on every re-entry.".into(), + "Add viewport={{ once: true }} (with an amount/margin that fires when the content is actually visible). Keep a replay only when the design explicitly asks for it.")); + } + if paints_background(node) { + self.findings.push(site::violation(self.source, trigger.span, "reveal-moves-background", Severity::Warning, + "The element that paints the section background is the one being revealed, so the background fades or moves with it.".into(), + "Keep the painted section fixed and reveal an inner content wrapper.")); + } + } + walk::walk_jsx_opening_element(self, node); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn rules(source: &str) -> Vec<&'static str> { + findings(source).into_iter().map(|finding| finding.rule).collect() + } + + #[test] + fn always_rendered_motion_that_starts_at_opacity_zero_is_hidden_server_markup() { + for source in [ + r#"const C = () => Copy"#, + r#"const C = () => Copy"#, + r#"const C = () =>
    {items.map(item => {item.name})}
"#, + ] { + assert_eq!(rules(source), ["ssr-hidden-reveal"], "{source}"); + } + } + + #[test] + fn components_built_from_motion_are_motion_elements() { + for source in [ + r#"const MotionBox = motion.create(Box); const C = () => "#, + r#"const C = () => ; const Card = motion(Box)"#, + r#"const Fade = m.create('div'); const C = () => "#, + ] { + assert_eq!(rules(source), ["ssr-hidden-reveal"], "{source}"); + } + // Built by something else: `initial` is not known to be motion's. + let other = r#"const Card = styled(Box); const C = () => "#; + assert_eq!(rules(other), Vec::<&str>::new()); + } + + #[test] + fn an_in_view_hook_without_a_one_shot_option_is_information() { + for source in [ + r#"function C() { const ref = useRef(null); const inView = useInView(ref); return
}"#, + r#"function C() { const inView = useInView(ref, { amount: 0.2 }); return null }"#, + r#"function C() { const { ref, inView } = useInView({ threshold: 0.2 }); return
}"#, + ] { + let found = findings(source); + assert_eq!(found.len(), 1, "{source}"); + assert_eq!((found[0].rule, found[0].severity), ("reveal-replays", Severity::Info), "{source}"); + } + for source in [ + r#"function C() { const inView = useInView(ref, { once: true, amount: 0.1 }); return null }"#, + r#"function C() { const { ref, inView } = useInView({ triggerOnce: true }); return
}"#, + // Options built elsewhere cannot be read from here. + r#"function C() { const inView = useInView(ref, options); return null }"#, + r#"function C() { const inView = useInView(ref, { ...defaults }); return null }"#, + r#"function C() { const inView = other.useInView(ref); return null }"#, + ] { + assert_eq!(rules(source), Vec::<&str>::new(), "{source}"); + } + } + + #[test] + fn client_mounted_and_unprovable_starts_are_not_reported() { + for source in [ + // Mounted later on the client: never part of server markup. + r#"const C = () => {open && }"#, + r#"const C = () => open ? : null"#, + r#"const C = () => "#, + // Visible start, disabled initial, or a start decided at runtime. + r#"const C = () => "#, + r#"const C = () => "#, + r#"const C = () => "#, + // The variant lives in another element or module. + r#"const C = () => "#, + // Not a motion element: `initial` means something else here. + r#"const C = () =>
"#, + ] { + assert_eq!(rules(source), Vec::<&str>::new(), "{source}"); + } + } + + #[test] + fn a_group_stagger_stays_short() { + for source in [ + r#"const list = { visible: { transition: { staggerChildren: 0.3 } } }"#, + r#"const C = () => items.map((item, index) => )"#, + r#"const C = () => items.map((item, i) => )"#, + ] { + assert_eq!(rules(source), ["stagger-too-long"], "{source}"); + } + for source in [ + r#"const list = { visible: { transition: { staggerChildren: 0.06 } } }"#, + r#"const C = () => items.map((item, index) => )"#, + // One fixed wait, and arithmetic that is not a per-item step. + r#"const C = () => "#, + r#"const C = () => "#, + r#"const C = () => "#, + ] { + assert_eq!(rules(source), Vec::<&str>::new(), "{source}"); + } + } + + #[test] + fn a_scroll_reveal_needs_one_shot_semantics() { + for source in [ + r#"const C = () => "#, + r#"const C = () => "#, + r#"const C = () => "#, + r#"const C = () => "#, + ] { + assert_eq!(rules(source), ["reveal-replays"], "{source}"); + } + for source in [ + r#"const C = () => "#, + // Built elsewhere: unknown, so not reported. + r#"const C = () => "#, + ] { + assert_eq!(rules(source), Vec::<&str>::new(), "{source}"); + } + } + + #[test] + fn the_painted_section_stays_fixed_while_inner_content_moves() { + for source in [ + r#"const C = () => </Box>"#, + r#"const C = () => <motion.section className={css({ bg: '$surface', py: 20 })} whileInView="visible" viewport={{ once: true }} />"#, + ] { + assert_eq!(rules(source), ["reveal-moves-background"], "{source}"); + } + let inner = r#"const C = () => <Box as="section" bg="$surface"><motion.div whileInView="visible" viewport={{ once: true }}><Title /></motion.div></Box>"#; + assert_eq!(rules(inner), Vec::<&str>::new()); + } + + #[test] + fn reduced_motion_is_asked_only_of_files_that_add_entrance_motion() { + let reveal = r#"// reduced motion: later +const C = () => <motion.div whileInView="visible" viewport={{ once: true }} />"#; + assert_eq!(reduced_motion_finding(reveal).map(|finding| finding.rule), Some("motion-without-reduced-motion")); + for source in [ + r#"const C = () => { const reduced = useReducedMotion(); return <motion.div whileInView={reduced ? undefined : "visible"} viewport={{ once: true }} /> }"#, + r#"const C = () => <MotionConfig reducedMotion="user"><motion.div whileInView="visible" viewport={{ once: true }} /></MotionConfig>"#, + // Hover and layout motion are not entrances. + r#"const C = () => <motion.button whileHover={{ scale: 1.02 }} />"#, + r#"const C = () => <Box bg="$surface" />"#, + ] { + assert!(reduced_motion_finding(source).is_none(), "{source}"); + } + for source in [ + "globalCss({ '@media (prefers-reduced-motion: reduce)': {} })", + "const query = matchMedia(`(prefers-reduced-motion: reduce)`)", + r#"const C = () => <Box _motionReduce={{ transform: 'none' }} />"#, + "const transition = { reducedMotion: 'user' }", + ] { + assert!(handles_reduced_motion(source), "{source}"); + } + // Mentioning it is not handling it. + for source in [ + "// TODO: respect prefers-reduced-motion and useReducedMotion()\nconst a = 1", + "import { useReducedMotion } from 'motion/react'\nconst a = 1", + ] { + assert!(!handles_reduced_motion(source), "{source}"); + } + } +} diff --git a/crates/devup-mcp-devup-ui/src/ui_validate/static_consts.rs b/crates/devup-mcp-devup-ui/src/ui_validate/static_consts.rs new file mode 100644 index 00000000..3a9a1a93 --- /dev/null +++ b/crates/devup-mcp-devup-ui/src/ui_validate/static_consts.rs @@ -0,0 +1,183 @@ +//! Which module-level `const`s of one file are build-time values. +//! +//! devup-ui documents that `css()`, `globalCss()` and `keyframes()` accept a +//! `const` declared in the same file — a `keyframes()` name, a `css()` class, +//! or a literal built from other such constants — because the build reads it +//! in place (`css({ animationName: spin })`). Treating every identifier as a +//! runtime value reported that documented pattern as `runtime-value`. +//! +//! Only what this file proves is accepted. An import, a parameter, a `let`, a +//! name bound more than once (so a parameter may shadow the constant), a call +//! to anything but `css`/`keyframes`, and an object or array constant that the +//! file visibly changes all stay runtime values. +use std::collections::{BTreeMap, BTreeSet}; + +use oxc_ast::ast::{ + Argument, AssignmentExpression, BindingIdentifier, CallExpression, Declaration, Expression, + Program, Statement, UnaryExpression, UnaryOperator, UpdateExpression, VariableDeclaration, + VariableDeclarationKind, +}; +use oxc_ast_visit::{Visit, walk}; + +/// Calls whose result is a name fixed at build time. +const BUILD_TIME_NAME_CALLS: &[&str] = &["keyframes", "css"]; +/// Calls that only read a constant handed to them. +const READ_ONLY_CALLS: &[&str] = &["css", "globalCss", "keyframes", "styled"]; + +#[derive(Default)] +struct Scan { + bindings: BTreeMap<String, usize>, + /// Object/array constants the file writes to, calls a method on, or hands + /// to a function that may change them. + changed: BTreeSet<String>, +} + +fn root_identifier<'b>(expression: &'b Expression<'_>) -> Option<&'b str> { + let mut current = expression.get_inner_expression(); + loop { + match current { + Expression::Identifier(identifier) => return Some(identifier.name.as_str()), + Expression::StaticMemberExpression(member) => { + current = member.object.get_inner_expression(); + } + Expression::ComputedMemberExpression(member) => { + current = member.object.get_inner_expression(); + } + _ => return None, + } + } +} + +fn callee_name<'b>(call: &'b CallExpression<'_>) -> Option<&'b str> { + match call.callee.get_inner_expression() { + Expression::Identifier(identifier) => Some(identifier.name.as_str()), + Expression::StaticMemberExpression(member) => Some(member.property.name.as_str()), + _ => None, + } +} + +impl Scan { + fn mark(&mut self, name: Option<&str>) { + if let Some(name) = name { + self.changed.insert(name.to_owned()); + } + } +} + +impl<'a> Visit<'a> for Scan { + fn visit_binding_identifier(&mut self, identifier: &BindingIdentifier<'a>) { + *self.bindings.entry(identifier.name.to_string()).or_default() += 1; + } + + fn visit_assignment_expression(&mut self, assignment: &AssignmentExpression<'a>) { + if let Some(member) = assignment.left.as_member_expression() { + self.mark(root_identifier(member.object())); + } + walk::walk_assignment_expression(self, assignment); + } + + fn visit_update_expression(&mut self, update: &UpdateExpression<'a>) { + if let Some(member) = update.argument.as_member_expression() { + self.mark(root_identifier(member.object())); + } + walk::walk_update_expression(self, update); + } + + fn visit_unary_expression(&mut self, unary: &UnaryExpression<'a>) { + if unary.operator == UnaryOperator::Delete { + self.mark(root_identifier(&unary.argument)); + } + walk::walk_unary_expression(self, unary); + } + + fn visit_call_expression(&mut self, call: &CallExpression<'a>) { + let read_only = callee_name(call).is_some_and(|name| READ_ONLY_CALLS.contains(&name)); + if !read_only { + // `list.push(x)`, `Object.assign(base, …)`, `mutate(base)`. + if let Expression::StaticMemberExpression(member) = call.callee.get_inner_expression() { + self.mark(root_identifier(&member.object)); + } + for argument in &call.arguments { + match argument { + Argument::SpreadElement(spread) => { + self.mark(root_identifier(&spread.argument)); + } + _ => { + if let Some(expression) = argument.as_expression() { + self.mark(root_identifier(expression)); + } + } + } + } + } + walk::walk_call_expression(self, call); + } +} + +fn module_consts<'b, 'a>(program: &'b Program<'a>) -> Vec<(&'b str, &'b Expression<'a>)> { + fn push<'b, 'a>( + declaration: &'b VariableDeclaration<'a>, + found: &mut Vec<(&'b str, &'b Expression<'a>)>, + ) { + if declaration.kind != VariableDeclarationKind::Const { + return; + } + for declarator in &declaration.declarations { + if let (Some(identifier), Some(init)) = + (declarator.id.get_binding_identifier(), declarator.init.as_ref()) + { + found.push((identifier.name.as_str(), init)); + } + } + } + let mut found = Vec::new(); + for statement in &program.body { + match statement { + Statement::VariableDeclaration(declaration) => push(declaration, &mut found), + Statement::ExportDeclaration(export) => { + if let Declaration::VariableDeclaration(declaration) = &export.declaration { + push(declaration, &mut found); + } + } + _ => {} + } + } + found +} + +/// Names of this file's module-level constants that hold a build-time value. +pub(super) fn static_const_names(program: &Program<'_>) -> BTreeSet<String> { + let mut scan = Scan::default(); + scan.visit_program(program); + let candidates: Vec<_> = module_consts(program) + .into_iter() + .filter(|(name, _)| scan.bindings.get(*name) == Some(&1)) + .collect(); + let mut names = BTreeSet::new(); + // A constant may be built from one declared later in source order only if + // that one is static too, so repeat until nothing more resolves. + loop { + let before = names.len(); + for (name, init) in &candidates { + if names.contains(*name) { + continue; + } + let inner = init.get_inner_expression(); + let is_static = match inner { + Expression::CallExpression(call) => { + callee_name(call).is_some_and(|callee| BUILD_TIME_NAME_CALLS.contains(&callee)) + } + Expression::ObjectExpression(_) | Expression::ArrayExpression(_) => { + !scan.changed.contains(*name) && super::is_static_expression(inner, false, &names) + } + _ => super::is_static_expression(inner, false, &names), + }; + if is_static { + names.insert((*name).to_owned()); + } + } + if names.len() == before { + return names; + } + } +} diff --git a/crates/devup-mcp-devup-ui/src/ui_validate/static_consts_tests.rs b/crates/devup-mcp-devup-ui/src/ui_validate/static_consts_tests.rs new file mode 100644 index 00000000..023e0525 --- /dev/null +++ b/crates/devup-mcp-devup-ui/src/ui_validate/static_consts_tests.rs @@ -0,0 +1,123 @@ +//! `runtime-value` against devup-ui's documented same-file constants. +use super::validate_devup_ui_tsx; + +fn runtime_values(tsx: &str) -> Vec<String> { + validate_devup_ui_tsx(tsx, None, false) + .violations + .into_iter() + .filter(|violation| violation.rule == "runtime-value") + .map(|violation| violation.context["snippet"].as_str().unwrap_or_default().to_owned()) + .collect() +} + +/// The devup-ui skill documents `css({ animationName: spin })` with `spin` a +/// `keyframes()` const of the same file. It was reported as `runtime-value`. +#[test] +fn a_same_file_keyframes_const_is_a_build_time_value() { + for tsx in [ + r#"import { css, keyframes } from '@devup-ui/react' + const spin = keyframes({ from: { transform: 'rotate(0)' }, to: { transform: 'rotate(360deg)' } }) + const cls = css({ animationName: spin })"#, + r#"import { globalCss, keyframes } from '@devup-ui/react' + export const rise = keyframes({ from: { opacity: 0 }, to: { opacity: 1 } }) + globalCss({ '.reveal': { animationName: rise, animation: `${rise} 0.45s ease-out both` } })"#, + r#"import * as Devup from '@devup-ui/react' + const fade = Devup.keyframes({ from: { opacity: 0 }, to: { opacity: 1 } }) + const cls = Devup.css({ animationName: fade })"#, + // Declared after its use: the build reads the module, not the line order. + r#"const cls = css({ selectors: { '&:hover': { animationName: pulse } } }) + const pulse = keyframes({ '50%': { opacity: 0.5 } })"#, + ] { + assert_eq!(runtime_values(tsx), Vec::<String>::new(), "{tsx}"); + } +} + +#[test] +fn same_file_literal_constants_and_what_is_built_from_them_are_static() { + for tsx in [ + r#"const SIZE = 4 + const DOUBLE = SIZE * 2 + const cls = css({ w: DOUBLE, h: `${SIZE}px`, m: -SIZE })"#, + r#"const card = css({ p: 4 }) + const hover = { color: 'red' } + const cls = css({ _hover: hover, nested: { ...hover }, className: card })"#, + r#"const steps = ['0px', '8px'] + const cls = css({ gap: steps })"#, + ] { + assert_eq!(runtime_values(tsx), Vec::<String>::new(), "{tsx}"); + } +} + +/// Everything the file cannot prove constant is still a runtime value. +#[test] +fn imports_parameters_and_runtime_results_stay_runtime_values() { + for (tsx, snippet) in [ + // An imported keyframes name is not known at build time. + ( + r#"import { spin } from './animations' + const cls = css({ animationName: spin })"#, + "spin", + ), + // A parameter shadows the module constant. + ( + r#"const spin = keyframes({ to: { opacity: 1 } }) + export function Card({ spin }) { return <div className={css({ animationName: spin })} /> }"#, + "spin", + ), + ( + r#"let name = keyframes({ to: { opacity: 1 } }) + const cls = css({ animationName: name })"#, + "name", + ), + ( + r#"const v = getValue() + const cls = css({ width: v })"#, + "v", + ), + ( + r#"const size = window.innerWidth + const cls = css({ width: `${size}px` })"#, + "`${size}px`", + ), + // Not declared anywhere in this file. + (r#"const cls = keyframes({ from: { opacity: start } })"#, "{ opacity: start }"), + // Declared inside a function, so possibly computed per call. + ( + r#"export function Card(props) { + const local = props.name + return <div className={css({ animationName: local })} /> + }"#, + "local", + ), + // A constant built from a runtime value is one too. + ( + r#"const base = Math.random() + const scaled = base * 2 + const cls = css({ opacity: scaled })"#, + "scaled", + ), + ] { + assert_eq!(runtime_values(tsx), vec![snippet.to_owned()], "{tsx}"); + } +} + +/// devup-ui stops treating an object as a constant once visible code changes it. +#[test] +fn an_object_constant_the_file_changes_is_not_static() { + for tsx in [ + r#"const hover = { color: 'red' } + hover.color = 'blue' + const cls = css({ _hover: hover })"#, + r#"const sizes = ['0px'] + sizes.push('8px') + const cls = css({ gap: sizes })"#, + r#"const hover = { color: 'red' } + Object.assign(hover, extra) + const cls = css({ _hover: hover })"#, + r#"const hover = { color: 'red' } + delete hover.color + const cls = css({ _hover: hover })"#, + ] { + assert_eq!(runtime_values(tsx).len(), 1, "{tsx}"); + } +} diff --git a/crates/devup-mcp-devup-ui/src/ui_validate/tests.rs b/crates/devup-mcp-devup-ui/src/ui_validate/tests.rs index 0e6cda38..cf054418 100644 --- a/crates/devup-mcp-devup-ui/src/ui_validate/tests.rs +++ b/crates/devup-mcp-devup-ui/src/ui_validate/tests.rs @@ -637,3 +637,5 @@ mod structure; pub use structure::{ MAX_FILES, MAX_TOTAL_BYTES, SourceFile, bundle_theme, check_bundle, validate_bundle, validate_sources, }; +#[path = "theme_scopes.rs"] +pub mod theme_scopes; diff --git a/crates/devup-mcp-devup-ui/src/ui_validate/theme_scopes.rs b/crates/devup-mcp-devup-ui/src/ui_validate/theme_scopes.rs new file mode 100644 index 00000000..e52ff4a1 --- /dev/null +++ b/crates/devup-mcp-devup-ui/src/ui_validate/theme_scopes.rs @@ -0,0 +1,290 @@ +//! Which `devup.json` governs which source file. +//! +//! A repository with several apps has several themes, and a `$token` is only +//! meaningful against the theme of the app that file belongs to. Checking +//! `apps/admin` code against `apps/front`'s theme calls real tokens unknown +//! and suggests the other app's token as the fix; checking against none and +//! answering `clean` passes code nobody verified. Each file is therefore +//! matched to the nearest `devup.json` above it, and a file with none is +//! reported as unverified rather than counted as checked. +use serde::Serialize; + +use super::{SourceFile, UiValidation, structure::validate_sources_with}; +use crate::theme::{ProjectTheme, parse_project_theme}; + +/// One theme and the directory it governs. `dir` is `/`-separated and +/// relative to the validated root; the root itself is the empty string. +pub struct ThemeScope { + pub dir: String, + pub devup_json: String, + pub theme: ProjectTheme, +} + +/// What was, and was not, checked against one theme. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct TokenScope { + /// `verified`: `$token` references were looked up in `devupJson`. + /// `unverified`: no theme governs these files, so nothing was looked up. + pub status: &'static str, + #[serde(skip_serializing_if = "Option::is_none")] + pub devup_json: Option<String>, + pub files: usize, + pub referenced_by_tsx: usize, + pub defined_by_theme: usize, + /// Named only for unverified files, at most [`MAX_LISTED_PATHS`]. + #[serde(skip_serializing_if = "Vec::is_empty")] + pub paths: Vec<String>, + #[serde(skip_serializing_if = "Option::is_none")] + pub reason: Option<String>, +} + +pub const MAX_LISTED_PATHS: usize = 32; + +pub struct ScopedValidation { + pub report: UiValidation, + /// Verified scopes in `devupJson` order, then the unverified one if any. + pub scopes: Vec<TokenScope>, +} + +impl ScopedValidation { + pub fn unverified(&self) -> Option<&TokenScope> { + self.scopes.iter().find(|scope| scope.status == "unverified") + } +} + +fn normalize(path: &str) -> String { + path.replace('\\', "/").trim_start_matches("./").to_owned() +} + +/// The deepest scope whose directory contains `path`. +pub fn nearest_scope<'s>(scopes: &'s [ThemeScope], path: &str) -> Option<&'s ThemeScope> { + let path = normalize(path); + scopes + .iter() + .filter(|scope| { + scope.dir.is_empty() + || path + .strip_prefix(scope.dir.as_str()) + .is_some_and(|rest| rest.starts_with('/')) + }) + .max_by_key(|scope| scope.dir.len()) +} + +/// Every `devup.json` supplied in a bundle, each governing its own directory. +pub fn bundle_scopes(files: &[SourceFile]) -> Result<Vec<ThemeScope>, String> { + let mut scopes = Vec::new(); + for file in files { + let path = normalize(&file.path); + let Some(dir) = path + .strip_suffix("devup.json") + .filter(|dir| dir.is_empty() || dir.ends_with('/')) + else { + continue; + }; + scopes.push(ThemeScope { + dir: dir.trim_end_matches('/').to_owned(), + theme: parse_project_theme(&file.content) + .map_err(|error| format!("{path}: {error}"))?, + devup_json: path, + }); + } + Ok(scopes) +} + +/// Validates each file against its nearest scope. A file no scope contains +/// uses `fallback` when that is `Some` and `fallback_applies` accepts its +/// path; otherwise its token checks are skipped and it is listed as +/// unverified with `unverified_reason`. +pub fn validate_scoped( + files: &[SourceFile], + scopes: &[ThemeScope], + fallback: Option<(&str, &ProjectTheme)>, + fallback_applies: &dyn Fn(&str) -> bool, + unverified_reason: &str, + strict: bool, +) -> ScopedValidation { + let fallback_for = |path: &str| fallback.filter(|_| fallback_applies(path)); + let label_of = |path: &str| { + nearest_scope(scopes, path) + .map(|scope| (scope.devup_json.as_str(), &scope.theme)) + .or_else(|| fallback_for(path)) + }; + let (mut report, references) = + validate_sources_with(files, strict, &|path| label_of(path).map(|(_, theme)| theme)); + let mut verified = std::collections::BTreeMap::<&str, TokenScope>::new(); + let mut unverified = TokenScope { + status: "unverified", + devup_json: None, + files: 0, + referenced_by_tsx: 0, + defined_by_theme: 0, + paths: Vec::new(), + reason: Some(unverified_reason.to_owned()), + }; + for (path, referenced) in &references { + let scope = match label_of(path) { + Some((label, theme)) => verified.entry(label).or_insert_with(|| TokenScope { + status: "verified", + devup_json: Some(label.to_owned()), + files: 0, + referenced_by_tsx: 0, + defined_by_theme: theme.token_count(), + paths: Vec::new(), + reason: None, + }), + None => { + if unverified.paths.len() < MAX_LISTED_PATHS { + unverified.paths.push(path.clone()); + } + &mut unverified + } + }; + scope.files += 1; + scope.referenced_by_tsx += referenced; + } + let mut scopes: Vec<_> = verified.into_values().collect(); + report.available_token_count = scopes.iter().map(|scope| scope.defined_by_theme).sum(); + if unverified.files > 0 { + scopes.push(unverified); + } + ScopedValidation { report, scopes } +} + +#[cfg(test)] +mod tests { + use super::*; + + const FRONT: &str = r##"{"theme":{"colors":{"default":{"bg":"#F7F3EC"}}}}"##; + const ADMIN: &str = r##"{"theme":{"colors":{"default":{"adminBg":"#000000","ink":"#111111"}}}}"##; + + fn file(path: &str, content: &str) -> SourceFile { + SourceFile { path: path.into(), content: content.into() } + } + + fn bundle() -> Vec<SourceFile> { + vec![ + file("apps/front/devup.json", FRONT), + file("apps/admin/devup.json", ADMIN), + file("apps/front/src/Hero.tsx", r#"export const Hero = () => <Box bg="$bg" />"#), + file( + "apps/admin/src/Panel.tsx", + r#"export const Panel = () => <Box bg="$bg" color="$ink" />"#, + ), + file("shared/Badge.tsx", r#"export const Badge = () => <Box bg="$bg" />"#), + ] + } + + fn run(files: &[SourceFile]) -> ScopedValidation { + let scopes = bundle_scopes(files).unwrap(); + validate_scoped(files, &scopes, None, &|_| true, "no devup.json above these files", false) + } + + fn unknown_token_paths(result: &ScopedValidation) -> Vec<String> { + let mut paths: Vec<_> = result + .report + .violations + .iter() + .filter(|violation| violation.rule == "unknown-token") + .map(|violation| violation.context["path"].as_str().unwrap().to_owned()) + .collect(); + paths.sort(); + paths + } + + /// `$bg` exists in the front theme and not in the admin one: the same + /// token is valid in one app and unknown in the other. + #[test] + fn each_file_is_checked_against_its_own_apps_theme() { + let result = run(&bundle()); + assert_eq!(unknown_token_paths(&result), ["apps/admin/src/Panel.tsx"]); + assert!(!result.report.ok); + } + + #[test] + fn a_file_under_no_theme_is_unverified_and_never_counted_as_checked() { + let result = run(&bundle()); + let summary: Vec<_> = result + .scopes + .iter() + .map(|scope| { + ( + scope.status, + scope.devup_json.as_deref(), + scope.files, + scope.referenced_by_tsx, + scope.defined_by_theme, + ) + }) + .collect(); + assert_eq!( + summary, + [ + ("verified", Some("apps/admin/devup.json"), 1, 2, 2), + ("verified", Some("apps/front/devup.json"), 1, 1, 1), + ("unverified", None, 1, 1, 0), + ] + ); + let unverified = result.unverified().unwrap(); + assert_eq!(unverified.paths, ["shared/Badge.tsx"]); + // Its `$bg` produced no finding either way: it was not looked up. + assert!( + result + .report + .violations + .iter() + .all(|violation| violation.context["path"] != "shared/Badge.tsx") + ); + assert_eq!(result.report.available_token_count, 3); + } + + #[test] + fn the_nearest_theme_wins_over_one_further_up() { + let mut files = bundle(); + files.push(file( + "devup.json", + r##"{"theme":{"colors":{"default":{"rootOnly":"#222222"}}}}"##, + )); + files.push(file( + "apps/front/src/Root.tsx", + r#"export const Root = () => <Box bg="$rootOnly" />"#, + )); + let result = run(&files); + // The root theme governs `shared/`, not the apps that have their own. + assert_eq!( + unknown_token_paths(&result), + ["apps/admin/src/Panel.tsx", "apps/front/src/Root.tsx", "shared/Badge.tsx"] + ); + assert!(result.unverified().is_none()); + } + + #[test] + fn a_directory_name_prefix_is_not_an_ancestor() { + let files = [ + file("apps/front/devup.json", FRONT), + file("apps/frontier/Card.tsx", r#"export const Card = () => <Box bg="$bg" />"#), + ]; + let scopes = bundle_scopes(&files).unwrap(); + assert!(nearest_scope(&scopes, "apps/frontier/Card.tsx").is_none()); + assert!(nearest_scope(&scopes, "apps\\front\\src\\Card.tsx").is_some()); + } + + #[test] + fn a_fallback_theme_applies_only_where_the_caller_allows_it() { + let files = [ + file("src/Home.tsx", r#"export const Home = () => <Box bg="$missing" />"#), + file("apps/admin/src/Panel.tsx", r#"export const Panel = () => <Box bg="$missing" />"#), + ]; + let theme = parse_project_theme(FRONT).unwrap(); + let result = validate_scoped( + &files, + &[], + Some(("packages/ui/devup.json", &theme)), + &|path| !path.starts_with("apps/admin/"), + "another package", + false, + ); + assert_eq!(unknown_token_paths(&result), ["src/Home.tsx"]); + assert_eq!(result.unverified().unwrap().paths, ["apps/admin/src/Panel.tsx"]); + } +} diff --git a/crates/devup-mcp-devup-ui/src/ui_validate/types_and_helpers.rs b/crates/devup-mcp-devup-ui/src/ui_validate/types_and_helpers.rs index 41b674b8..eab422bb 100644 --- a/crates/devup-mcp-devup-ui/src/ui_validate/types_and_helpers.rs +++ b/crates/devup-mcp-devup-ui/src/ui_validate/types_and_helpers.rs @@ -10,15 +10,22 @@ pub mod wiring; pub mod visual_behavior; #[path = "delivery_quality.rs"] mod delivery_quality; +#[path = "static_consts.rs"] +mod static_consts; +#[path = "motion_quality.rs"] +pub mod motion_quality; +#[cfg(test)] +#[path = "static_consts_tests.rs"] +mod static_consts_tests; #[cfg(test)] #[path = "visual_behavior_tests.rs"] mod visual_behavior_tests; use oxc_allocator::Allocator; use oxc_ast::ast::{ - Argument, CallExpression, Expression, JSXAttribute, JSXAttributeItem, JSXAttributeName, - JSXAttributeValue, JSXElementName, JSXOpeningElement, ObjectExpression, ObjectPropertyKind, - PropertyKey, UnaryOperator, + Argument, BinaryOperator, CallExpression, Expression, JSXAttribute, JSXAttributeItem, + JSXAttributeName, JSXAttributeValue, JSXElementName, JSXOpeningElement, ObjectExpression, + ObjectPropertyKind, PropertyKey, UnaryOperator, }; use oxc_ast_visit::{Visit, walk}; use oxc_parser::Parser; @@ -156,12 +163,14 @@ fn validate_source( violations: Vec::new(), element_stack: Vec::new(), render_element_stack: Vec::new(), + static_consts: static_consts::static_const_names(&parsed.program), }; visitor.visit_program(&parsed.program); violations.extend(visitor.violations); violations.extend(wiring::findings(tsx)); violations.extend(visual_behavior::findings(tsx)); violations.extend(delivery_quality::findings(tsx)); + violations.extend(motion_quality::findings(tsx)); let checked_tokens = visitor.checked_tokens; let attributes = crate::provenance::attributes::generated_attributes(tsx); violations.extend(blur_layers::check(tsx, &attributes)); @@ -266,6 +275,8 @@ struct TsxVisitor<'t> { violations: Vec<Violation>, element_stack: Vec<Option<String>>, render_element_stack: Vec<Option<String>>, + /// Module-level `const`s of this file that hold a build-time value. + static_consts: BTreeSet<String>, } impl<'t> TsxVisitor<'t> { @@ -576,7 +587,7 @@ impl<'t> TsxVisitor<'t> { continue; }; let key = property_key_name(&property.key).unwrap_or_else(|| "?".to_owned()); - if !is_static_expression(&property.value, call_name == "css") { + if !is_static_expression(&property.value, call_name == "css", &self.static_consts) { self.violations.push(Violation { rule: "runtime-value", severity: Severity::Error, @@ -690,44 +701,67 @@ fn property_key_name(key: &PropertyKey) -> Option<String> { } } -/// Static-analysis literal check mirroring devup-ui's `css-utils-literal-only` -/// ESLint rule: string/number/boolean/null literals, unary-negated numeric -/// literals, and arrays/objects composed entirely of such, are allowed. -/// Identifiers, member/call expressions, template literals with -/// substitutions, and arbitrary runtime value leaves are not. css() alone can -/// select among recursively static conditional branches; the condition is kept -/// in generated class selection (devup-ui extractor::extract_style_from_expression). -fn is_static_expression(expression: &Expression, conditional_classes: bool) -> bool { +/// Static-analysis check mirroring devup-ui's `css-utils-literal-only` rule and +/// its documented build-time constants: string/number/boolean/null literals, +/// unary-negated numeric literals, arrays/objects composed entirely of such, +/// and a module-level `const` of the same file holding one of those or a +/// `keyframes()`/`css()` name (`consts`, see `static_consts`), including in a +/// template or arithmetic over them. Imports, parameters, member/call +/// expressions and other runtime value leaves are not. css() alone can select +/// among recursively static conditional branches; the condition is kept in +/// generated class selection (devup-ui extractor::extract_style_from_expression). +fn is_static_expression( + expression: &Expression, + conditional_classes: bool, + consts: &BTreeSet<String>, +) -> bool { match expression.get_inner_expression() { - Expression::Identifier(id) if conditional_classes && id.name == "undefined" => true, + Expression::Identifier(id) => { + (conditional_classes && id.name == "undefined") || consts.contains(id.name.as_str()) + } Expression::ConditionalExpression(conditional) => { conditional_classes - && is_static_expression(&conditional.consequent, true) - && is_static_expression(&conditional.alternate, true) + && is_static_expression(&conditional.consequent, true, consts) + && is_static_expression(&conditional.alternate, true, consts) } Expression::StringLiteral(_) | Expression::NumericLiteral(_) | Expression::BooleanLiteral(_) | Expression::NullLiteral(_) => true, - Expression::TemplateLiteral(template) => template.expressions.is_empty(), + Expression::TemplateLiteral(template) => template + .expressions + .iter() + .all(|part| is_static_expression(part, false, consts)), + Expression::BinaryExpression(binary) => { + matches!( + binary.operator, + BinaryOperator::Addition + | BinaryOperator::Subtraction + | BinaryOperator::Multiplication + | BinaryOperator::Division + ) && is_static_expression(&binary.left, false, consts) + && is_static_expression(&binary.right, false, consts) + } Expression::UnaryExpression(unary) => { matches!( unary.operator, UnaryOperator::UnaryNegation | UnaryOperator::UnaryPlus - ) && is_static_expression(&unary.argument, conditional_classes) + ) && is_static_expression(&unary.argument, conditional_classes, consts) } Expression::ArrayExpression(array) => array.elements.iter().all(|element| { element .as_expression() - .is_some_and(|e| is_static_expression(e, conditional_classes)) + .is_some_and(|e| is_static_expression(e, conditional_classes, consts)) || element.is_elision() }), Expression::ObjectExpression(object) => { object.properties.iter().all(|property| match property { ObjectPropertyKind::ObjectProperty(property) => { - is_static_expression(&property.value, conditional_classes) + is_static_expression(&property.value, conditional_classes, consts) + } + ObjectPropertyKind::SpreadProperty(spread) => { + is_static_expression(&spread.argument, false, consts) } - ObjectPropertyKind::SpreadProperty(_) => false, }) } _ => false, diff --git a/crates/devup-mcp/src/server/behavior_contracts/detect.rs b/crates/devup-mcp/src/server/behavior_contracts/detect.rs index 0d93e786..c92e1598 100644 --- a/crates/devup-mcp/src/server/behavior_contracts/detect.rs +++ b/crates/devup-mcp/src/server/behavior_contracts/detect.rs @@ -431,7 +431,7 @@ pub(super) fn section_entrance( node, json!({"sectionHeight":node.typed_view().number("height"),"belowFirstContentSection":true}), json!({ - "pattern":"shared-scroll-reveal-controller","scope":"selected major below-fold sections, not every text or control","serverMarkup":"render readable content immediately; never prehide SSR content","observer":"one shared observer plus new content from client navigation; disconnect/cleanup correctly","motion":"short upward translation and fade once per element, avoiding focused controls","reducedMotion":"leave content in place","timing":"implementation choice, not captured Figma motion","verify":["intermediate frame","once on entry","client navigation","observer cleanup","focus","reduced motion","JavaScript disabled"] + "pattern":"shared-scroll-reveal-controller","scope":"selected major below-fold sections, not every text or control","serverMarkup":"render readable content immediately; never prehide SSR content","observer":"one shared observer plus new content from client navigation; disconnect/cleanup correctly","motion":"short upward translation and fade once per element, avoiding focused controls","reducedMotion":"leave content in place","timing":"implementation choice, not captured Figma motion","rules":"devfive-frontend/references/animation.md","verify":["start/middle/end frames","trigger timing","once on entry","scroll re-entry","client navigation","back/forward","observer cleanup","focus","reduced motion","JavaScript disabled"] }), )) } diff --git a/crates/devup-mcp/src/server/behavior_contracts/paging.rs b/crates/devup-mcp/src/server/behavior_contracts/paging.rs index aadb15df..1d084f8b 100644 --- a/crates/devup-mcp/src/server/behavior_contracts/paging.rs +++ b/crates/devup-mcp/src/server/behavior_contracts/paging.rs @@ -365,19 +365,24 @@ fn page_with_limit( } else { continuation_action(&cursor, snapshot, cursor_limit) }; - Ok( + let mut plan = json!({"version":2,"artifactId":artifact,"snapshotFingerprint":cursor.fingerprint,"scopeNodeIds":cursor.scope,"candidateCount":out.len(),"candidates":out, "aggregateCandidates":inventory,"aggregateBindingKeys":cursor.emitted_bindings, "coverageReceipt":{"payload":receipt,"signature":receipt_signature}, "dataBindings":bindings,"pageContracts":contracts,"implemented":false,"runtimeVerified":false,"requiresReview":true, "pagination":{"pageIndex":page_index,"complete":complete,"accumulatedNodes":cursor.visited.len(), - "aggregation":"Append candidates by id, bindings by nodeId+role and page contracts by kind+rootNodeId+footerNodeId; keep all pages until complete=true.", "cursorBudgetExceeded":cursor_budget_exceeded,"nextAction":next_action}, "evidenceLimits":{"nodesScanned":scanned.len(),"maxNodes":MAX_NODES,"maxCandidates":MAX_CANDIDATES,"truncated":!complete,"contextIndex":"The immutable artifact graph and fingerprint are read for context; candidate inference and returned rows are bounded per page."}, "intentPolicy":"Captured prototype evidence and inferred motion choices remain separate; explicit user requirements take precedence.", "clientBoundary":"Keep generated server TSX and data pages; mount the supplied tiny shared motion controller in the persistent layout.", - "sourcePurity":"Use semantic selectors; node identities and implementation evidence stay in JSON sidecars.","guide":GUIDE}), - ) + "sourcePurity":"Use semantic selectors; node identities and implementation evidence stay in JSON sidecars.","guide":GUIDE}); + // How to join pages matters only when there is more than one. A plan + // that is complete on its first page has nothing to aggregate, and the + // export reply it rides on has a byte budget. + if page_index > 0 || !complete { + plan["pagination"]["aggregation"] = json!("Append candidates by id, bindings by nodeId+role and page contracts by kind+rootNodeId+footerNodeId; keep all pages until complete=true."); + } + Ok(plan) } pub(super) fn verified_inventory(plan: &Value) -> Option<(Vec<Value>, Vec<String>)> { @@ -446,6 +451,8 @@ mod cursor_budget_tests { assert_eq!(decoded.evidence, cursor.evidence); let first = page(&json!({"artifactId":"long"}), &snapshot, Some(&token)).unwrap(); assert_eq!(first["candidateCount"], MAX_CANDIDATES); + // More pages follow, so the reader is told how to join them. + assert!(first["pagination"]["aggregation"].as_str().is_some_and(|text| text.contains("Append candidates"))); let next = first["pagination"]["nextAction"]["arguments"]["behaviorCursor"] .as_str() .unwrap(); diff --git a/crates/devup-mcp/src/server/behavior_contracts/templates/motion-runtime.ts b/crates/devup-mcp/src/server/behavior_contracts/templates/motion-runtime.ts index 20009aa8..70d4b2ae 100644 --- a/crates/devup-mcp/src/server/behavior_contracts/templates/motion-runtime.ts +++ b/crates/devup-mcp/src/server/behavior_contracts/templates/motion-runtime.ts @@ -180,18 +180,23 @@ function slidingHighlight(root: ParentNode, binding: MotionBinding) { export function mountMotion(root: ParentNode, bindings: MotionBinding[]) { const active = new Map<MotionBinding, {node: HTMLElement; dependencies: HTMLElement[]; cleanup: () => void}>(); const reveals = bindings.filter(binding => binding.kind === 'section-entrance'); - const seen = new WeakSet<Element>(), revealAnimations = new Set<Animation>(); + const seen = new WeakSet<Element>(), observed = new WeakSet<Element>(), unreported = new WeakSet<Element>(), revealAnimations = new Set<Animation>(); const revealObserver = new IntersectionObserver(entries => entries.forEach(entry => { + const node = entry.target as HTMLElement, first = unreported.delete(node); if (!entry.isIntersecting) return; - const node = entry.target as HTMLElement, binding = reveals.find(binding => node.matches(binding.target)); + const binding = reveals.find(binding => node.matches(binding.target)); if (!binding || seen.has(node) || !visible(node)) return; seen.add(node); revealObserver.unobserve(node); + // Already on screen when observation began (first viewport, reload or + // back/forward at a restored scroll position, client navigation): the + // reader is looking at it, so hiding it to replay an entrance is a flash. + if (first) return; if (!reduced(binding) && !node.contains(document.activeElement)) trackedAnimation(node,[ { opacity: 0, transform: `translateY(${binding.distancePx ?? 24}px)` }, { opacity: 1, transform: 'translateY(0)' }, ], { duration: duration(binding, 600), easing: 'ease-out' },revealAnimations); })); const refresh = () => { - reveals.forEach(binding => elements(root, binding.target).filter(node => !seen.has(node)).forEach(node => revealObserver.observe(node))); + reveals.forEach(binding => elements(root, binding.target).filter(node => !seen.has(node) && !observed.has(node)).forEach(node => { observed.add(node); unreported.add(node); revealObserver.observe(node); })); for (const binding of bindings) { if (binding.kind === 'section-entrance') continue; const found = elements(root, binding.target), previous = active.get(binding); diff --git a/crates/devup-mcp/src/server/development_contract.rs b/crates/devup-mcp/src/server/development_contract.rs index f3c4a33d..561d20fc 100644 --- a/crates/devup-mcp/src/server/development_contract.rs +++ b/crates/devup-mcp/src/server/development_contract.rs @@ -3,11 +3,13 @@ use serde_json::{Value, json}; #[cfg(test)] const RULES: &str = include_str!("skills/devfive-frontend/references/delivery-quality.md"); +#[cfg(test)] +const MOTION_RULES: &str = include_str!("skills/devfive-frontend/references/animation.md"); pub(super) fn attach(result: &mut Value) { if !result.is_object() { return; } result["developmentContract"] = json!({ - "version": 2, + "version": 3, "guide": "devup://guide/usage#behavior-and-data-contracts", "installedReference": "devfive-frontend/references/delivery-quality.md", "alwaysApplies": true, @@ -20,11 +22,14 @@ pub(super) fn attach(result: &mut Value) { "paired-e2e-and-native-figma-visuals", "semantic-native-controls-and-complete-pseudo-states", "variant-palette-consistency", "single-source-url-content-selection", "viewport-footer-shell", - "motion-candidates-and-safe-progressive-enhancement", "canonical-semantic-assets", + "default-motion-and-safe-progressive-enhancement", + "one-shot-reveals-without-ssr-flash", + "canonical-semantic-assets", "landing-detail-api-db-single-source", "runtime-evidence-before-completion" ], "completionEvidence": ["both-control-directions-and-history", "all-widths-and-zoom-context", - "computed-style-and-animation-intermediate-state", "changed-empty-and-failing-api", + "computed-style-and-animation-start-middle-end", + "motion-trigger-reentry-history-and-image-failure", "changed-empty-and-failing-api", "all-public-assets-and-persisted-url-references"], "staticExportProvesApplicationBehavior": false, "nextAction": "Read the linked rules, apply behaviorPlan, fix and rerun functional E2E plus native Figma visuals before claiming completion." @@ -40,7 +45,7 @@ mod tests { attach(&mut result); assert_eq!(result["developmentContract"]["alwaysApplies"], true); assert_eq!(result["developmentContract"]["staticExportProvesApplicationBehavior"], false); - assert_eq!(result["developmentContract"]["version"], 2); + assert_eq!(result["developmentContract"]["version"], 3); let requirements = result["developmentContract"]["requirements"].as_array().unwrap(); for required in ["route-owned-page-body", "devup-api-only", "stop-affected-api-gaps", "paired-e2e-and-native-figma-visuals"] { assert!(requirements.contains(&json!(required))); @@ -48,5 +53,23 @@ mod tests { for term in ["keep-all", "grayscale", "layout.tsx", "count", "footer", "DB", "aria-current", "sidecar"] { assert!(RULES.contains(term), "missing delivery policy: {term}"); } + // Motion is asked for by default, so its rules travel with every export. + // Motion is a default item: the contract names it and its evidence, + // and the linked rules carry the detail checked below. + for required in ["default-motion-and-safe-progressive-enhancement", "one-shot-reveals-without-ssr-flash"] { + assert!(requirements.contains(&json!(required)), "{required}"); + } + let evidence = result["developmentContract"]["completionEvidence"].as_array().unwrap(); + for required in ["computed-style-and-animation-start-middle-end", "motion-trigger-reentry-history-and-image-failure"] { + assert!(evidence.contains(&json!(required)), "{required}"); + } + for (document, terms) in [ + (RULES, &["without being asked", "plays once", "opacity: 0", "prefers-reduced-motion", "back/forward", "failed images"][..]), + (MOTION_RULES, &["user stated", "once: true", "stagger", "background", "Progress line", "hydration", "JavaScript disabled", "focus", "start, a middle and the end", "Trigger timing", "Fast scroll", "back/forward", "block one"][..]), + ] { + for term in terms { + assert!(document.contains(term), "missing motion policy: {term}"); + } + } } } diff --git a/crates/devup-mcp/src/server/handlers/devup_ui_validate.rs b/crates/devup-mcp/src/server/handlers/devup_ui_validate.rs index b9383691..5af4f65d 100644 --- a/crates/devup-mcp/src/server/handlers/devup_ui_validate.rs +++ b/crates/devup-mcp/src/server/handlers/devup_ui_validate.rs @@ -2,6 +2,8 @@ use super::*; use crate::server::tools::UiValidateDetail; #[path = "ui_validate_sources.rs"] mod sources; +#[path = "ui_validate_scopes.rs"] +mod scopes; #[tool_router(router = devup_ui_validate_router, vis = "pub(super)")] impl DevupServer { @@ -13,7 +15,8 @@ impl DevupServer { &self, Parameters(mut input): Parameters<UiValidateInput>, ) -> Result<CallToolResult, ErrorData> { - use devup_mcp_devup_ui::ui_validate::{bundle_theme, check_bundle, validate_bundle}; + use devup_mcp_devup_ui::ui_validate::check_bundle; + use devup_mcp_devup_ui::ui_validate::theme_scopes::{bundle_scopes, validate_scoped}; let invalid = |message: String| ErrorData::invalid_params(message, None); let paths_mode = !input.paths.is_empty(); if paths_mode { @@ -34,26 +37,47 @@ impl DevupServer { .into(), )); } - let theme_lookup = if paths_mode { + let mut theme_lookup = if paths_mode { project_context::theme_for_validation(input.project_root.as_deref()).map_err(to_mcp_error)? } else if let Some(files) = &input.files { check_bundle(files).map_err(invalid)?; if !input.tsx.is_empty() { return Err(invalid("Supply either tsx or files, not both".into())); } - let theme = bundle_theme(files).map_err(invalid)?; - let guardrail = theme.is_none().then(|| json!({ - "message": "No devup.json supplied in files; token checks skipped. Bundle validation never reads the filesystem." - })); - project_context::ThemeLookup { theme, guardrail } + // A bundle's themes are matched to its files below, per directory. + project_context::ThemeLookup { theme: None, devup_json: None, guardrail: None } } else { project_context::theme_for_validation(input.project_root.as_deref()) .map_err(to_mcp_error)? }; - let mut report = if paths_mode { - devup_mcp_devup_ui::ui_validate::validate_sources(input.files.as_deref().unwrap_or_default(), theme_lookup.theme.as_ref(), input.strict) - } else if let Some(files) = &input.files { - validate_bundle(files, theme_lookup.theme.as_ref(), input.strict).map_err(invalid)? + if !paths_mode + && input.files.is_none() + && let (Some(root), Some(name)) = (input.project_root.as_deref(), input.source_name.as_deref()) + { + theme_lookup = scopes::for_source_name(root, name, theme_lookup).map_err(invalid)?; + } + // Files are checked against the nearest devup.json above each of them, + // so one call over a repository of several apps uses each app's theme. + // `scoped_tokens`/`scoped_guardrail` say which files that left unchecked. + let mut scoped_tokens = None; + let mut scoped_guardrail = None; + let mut report = if let Some(files) = &input.files { + let scoped = if paths_mode { + let disk = scopes::disk_scopes(input.project_root.as_deref().unwrap_or_default(), files).map_err(invalid)?; + let fallback = theme_lookup.theme.as_ref().map(|theme| (theme_lookup.devup_json.as_deref().unwrap_or("devup.json"), theme)); + validate_scoped(files, &disk.scopes, fallback, &|path| disk.fallback_applies(path), scopes::NO_THEME_ABOVE, input.strict) + } else { + let bundle = bundle_scopes(files).map_err(invalid)?; + // One supplied theme governs the whole bundle, wherever it sits. + match bundle.as_slice() { + [] => validate_scoped(files, &[], None, &|_| true, "No devup.json supplied in files; token checks skipped. Bundle validation never reads the filesystem.", input.strict), + [only] => validate_scoped(files, &[], Some((only.devup_json.as_str(), &only.theme)), &|_| true, scopes::NO_THEME_IN_BUNDLE, input.strict), + several => validate_scoped(files, several, None, &|_| true, scopes::NO_THEME_IN_BUNDLE, input.strict), + } + }; + scoped_tokens = Some(scopes::tokens(&scoped)); + scoped_guardrail = scopes::guardrail(&scoped, theme_lookup.guardrail.clone().filter(|_| paths_mode)); + scoped.report } else { devup_mcp_devup_ui::ui_validate::validate_devup_ui_tsx( &input.tsx, @@ -66,8 +90,13 @@ impl DevupServer { && std::path::Path::new(root).join("src").is_dir() { let project_sources = sources::read_paths(root, &["src".into()]).map_err(invalid)?; - if project_sources.iter().any(|file| devup_mcp_devup_ui::ui_validate::visual_behavior::has_global_link_decoration(&file.content)) { - report.violations.retain(|finding| finding.rule != "link-underline"); + let handled: Vec<&str> = [ + ("link-underline", project_sources.iter().any(|file| devup_mcp_devup_ui::ui_validate::visual_behavior::has_global_link_decoration(&file.content))), + // A shared provider elsewhere in src answers for these files. + ("motion-without-reduced-motion", project_sources.iter().any(|file| devup_mcp_devup_ui::ui_validate::motion_quality::handles_reduced_motion(&file.content))), + ].into_iter().filter_map(|(rule, handled)| handled.then_some(rule)).collect(); + if !handled.is_empty() { + report.violations.retain(|finding| !handled.contains(&finding.rule)); report.ok = report.violations.iter().all(|finding| { finding.severity != devup_mcp_devup_ui::ui_validate::Severity::Error && !(input.strict && finding.severity == devup_mcp_devup_ui::ui_validate::Severity::Warning) @@ -192,7 +221,14 @@ impl DevupServer { // tokens is unverified rather than clean. This is the monorepo case // that answered `clean` for a `$bg` reference while no devup.json had // been read at all. - let token_check_skipped = theme_lookup.theme.is_none() && report.checked_tokens > 0; + let token_check_skipped = match &scoped_tokens { + Some(tokens) => tokens["unverifiedReferences"].as_u64().unwrap_or(0) > 0, + None => theme_lookup.theme.is_none() && report.checked_tokens > 0, + }; + let theme_available = match &scoped_tokens { + Some(tokens) => tokens["unknownTokenCheckRan"] == true, + None => theme_lookup.theme.is_some(), + }; let ok_reason = match (errors, warnings, infos, input.strict) { (1.., _, _, _) => "error-violations", (_, 1.., _, true) => "strict-warnings", @@ -211,13 +247,14 @@ impl DevupServer { "themeNotes": report.theme_notes, "checkedTokens": report.checked_tokens, "availableTokenCount": report.available_token_count, - "tokens": { + "tokens": scoped_tokens.clone().unwrap_or_else(|| json!({ "referencedByTsx": report.checked_tokens, "definedByTheme": report.available_token_count, - "unknownTokenCheckRan": theme_lookup.theme.is_some(), - }, - "themeAvailable": theme_lookup.theme.is_some(), - "themeGuardrail": theme_lookup.guardrail, + "unknownTokenCheckRan": theme_available, + "devupJson": theme_lookup.devup_json, + })), + "themeAvailable": theme_available, + "themeGuardrail": if scoped_tokens.is_some() { scoped_guardrail } else { theme_lookup.guardrail }, }); if input.section_coverage.is_some() { result["sectionCoverageVerification"] = json!({"sourceStructure":"Original source-map element/file mappings are compared where unambiguous.","nodeIdentity":"Source order is structural evidence only; extracted or reordered trees require updated mappings.","runtimeVerified":false,"renderedTargets":"Use semantic selectors in .devup/rendered-targets.json; no Figma attributes are required in JSX."}); diff --git a/crates/devup-mcp/src/server/handlers/ui_validate_scopes.rs b/crates/devup-mcp/src/server/handlers/ui_validate_scopes.rs new file mode 100644 index 00000000..1ed637d0 --- /dev/null +++ b/crates/devup-mcp/src/server/handlers/ui_validate_scopes.rs @@ -0,0 +1,169 @@ +//! Resolves, from disk, the `devup.json` that governs each validated file. +use std::collections::{BTreeMap, BTreeSet}; + +use devup_mcp_devup_ui::theme::parse_project_theme; +use devup_mcp_devup_ui::ui_validate::SourceFile; +use devup_mcp_devup_ui::ui_validate::theme_scopes::{ScopedValidation, ThemeScope}; +use serde_json::{Value, json}; + +pub(super) const NO_THEME_ABOVE: &str = "No devup.json sits above these files inside projectRoot, and no single project theme governs them. Their $token references and token-matching advice were not checked."; +pub(super) const NO_THEME_IN_BUNDLE: &str = "No supplied devup.json sits above these files. Their $token references and token-matching advice were not checked. Bundle validation never reads the filesystem."; + +/// Themes found by walking up from every file to `root`, and the package +/// directories passed on the way. +pub(super) struct DiskScopes { + pub scopes: Vec<ThemeScope>, + /// Directories below `root` that hold a `package.json`: another package's + /// theme is not this package's, so a project-wide fallback stops here. + packages: BTreeSet<String>, +} + +impl DiskScopes { + /// Whether a theme found elsewhere in the project may stand in for a file + /// that has none above it: only when the file is not inside a package of + /// its own below `root`. + pub(super) fn fallback_applies(&self, path: &str) -> bool { + !ancestors(path).any(|dir| !dir.is_empty() && self.packages.contains(dir)) + } +} + +/// `a/b/c.tsx` -> `a/b`, `a`, `` (the root). +fn ancestors(path: &str) -> impl Iterator<Item = &str> { + let mut current = path.rsplit_once('/').map_or("", |(dir, _)| dir); + let mut done = false; + std::iter::from_fn(move || { + if done { + return None; + } + let dir = current; + match current.rsplit_once('/') { + Some((parent, _)) => current = parent, + None if current.is_empty() => done = true, + None => current = "", + } + Some(dir) + }) +} + +pub(super) fn disk_scopes(root: &str, files: &[SourceFile]) -> Result<DiskScopes, String> { + let root = std::fs::canonicalize(root).map_err(|error| format!("projectRoot: {error}"))?; + let mut seen = BTreeSet::new(); + let mut themes = BTreeMap::new(); + let mut packages = BTreeSet::new(); + for file in files { + for dir in ancestors(&file.path) { + if !seen.insert(dir.to_owned()) { + continue; + } + let directory = if dir.is_empty() { root.clone() } else { root.join(dir) }; + if !dir.is_empty() && directory.join("package.json").is_file() { + packages.insert(dir.to_owned()); + } + let theme_file = directory.join("devup.json"); + if theme_file.is_file() { + let label = if dir.is_empty() { "devup.json".to_owned() } else { format!("{dir}/devup.json") }; + let source = std::fs::read_to_string(&theme_file).map_err(|error| format!("{label}: {error}"))?; + let theme = parse_project_theme(&source).map_err(|error| format!("{label}: {error}"))?; + themes.insert(dir.to_owned(), ThemeScope { dir: dir.to_owned(), devup_json: label, theme }); + } + } + } + Ok(DiskScopes { scopes: themes.into_values().collect(), packages }) +} + +/// A single `tsx` has no path of its own, so at a repository root with several +/// themes its tokens could not be checked at all. When the caller labels it +/// with a relative path (`sourceName`), that location selects the theme the +/// same way a file's does: the nearest `devup.json` above it. Only directories +/// are inspected; the named file is never opened and need not exist. +/// +/// Returns `lookup` unchanged when the label is not a relative path inside +/// the project or nothing above it decides anything. +pub(super) fn for_source_name( + root: &str, + source_name: &str, + lookup: super::project_context::ThemeLookup, +) -> Result<super::project_context::ThemeLookup, String> { + let path = source_name.replace('\\', "/"); + let located = !path.starts_with('/') + && !path.contains(':') + && path.contains('/') + && path.split('/').all(|part| !part.is_empty() && part != ".." && part != "."); + if !located { + return Ok(lookup); + } + let file = SourceFile { path: path.clone(), content: String::new() }; + let mut disk = disk_scopes(root, std::slice::from_ref(&file))?; + let nearest = devup_mcp_devup_ui::ui_validate::theme_scopes::nearest_scope(&disk.scopes, &path) + .map(|scope| scope.dir.clone()); + if let Some(dir) = nearest { + let index = disk.scopes.iter().position(|scope| scope.dir == dir).expect("nearest scope"); + let scope = disk.scopes.swap_remove(index); + return Ok(super::project_context::ThemeLookup { + theme: Some(scope.theme), + devup_json: Some(scope.devup_json), + guardrail: None, + }); + } + // Another package's theme is not this file's: unverified, not checked. + if lookup.theme.is_some() && !disk.fallback_applies(&path) { + return Ok(super::project_context::ThemeLookup { + theme: None, + devup_json: None, + guardrail: Some(json!({ "message": NO_THEME_ABOVE, "unverifiedFiles": 1, "unverifiedPaths": [path] })), + }); + } + Ok(lookup) +} + +/// The `tokens` object for a scoped run. `unknownTokenCheckRan` is true only +/// when every file was checked: a partly checked project did not pass. +pub(super) fn tokens(scoped: &ScopedValidation) -> Value { + let unverified = scoped.unverified(); + let verified = scoped.scopes.iter().filter(|scope| scope.status == "verified").count(); + json!({ + "referencedByTsx": scoped.report.checked_tokens, + "definedByTheme": scoped.report.available_token_count, + "unknownTokenCheckRan": unverified.is_none() && verified > 0, + "verification": match (verified, unverified.is_some()) { + (0, _) => "skipped", + (_, true) => "partial", + _ => "verified", + }, + "unverifiedReferences": unverified.map_or(0, |scope| scope.referenced_by_tsx), + "unverifiedFiles": unverified.map_or(0, |scope| scope.files), + "scopes": scoped.scopes, + }) +} + +/// The guardrail for files no theme governs; `lookup` is the project-wide +/// answer (ambiguous themes, none found), kept because it names the choices. +pub(super) fn guardrail(scoped: &ScopedValidation, lookup: Option<Value>) -> Option<Value> { + let unverified = scoped.unverified()?; + let mut guardrail = lookup.unwrap_or_else(|| json!({ "message": unverified.reason })); + guardrail["unverifiedFiles"] = json!(unverified.files); + guardrail["unverifiedPaths"] = json!(unverified.paths); + if unverified.files > unverified.paths.len() { + guardrail["unverifiedPathsTruncated"] = json!(true); + } + Some(guardrail) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn ancestors_end_at_the_root() { + assert_eq!(ancestors("apps/front/src/Hero.tsx").collect::<Vec<_>>(), ["apps/front/src", "apps/front", "apps", ""]); + assert_eq!(ancestors("Hero.tsx").collect::<Vec<_>>(), [""]); + } + + #[test] + fn a_fallback_stops_at_another_package() { + let scopes = DiskScopes { scopes: Vec::new(), packages: BTreeSet::from(["apps/admin".to_owned()]) }; + assert!(!scopes.fallback_applies("apps/admin/src/Panel.tsx")); + assert!(scopes.fallback_applies("apps/administrator/Panel.tsx")); + assert!(scopes.fallback_applies("src/Home.tsx")); + } +} diff --git a/crates/devup-mcp/src/server/project_context/context_and_inventory.rs b/crates/devup-mcp/src/server/project_context/context_and_inventory.rs index 1d0fa33a..051a8608 100644 --- a/crates/devup-mcp/src/server/project_context/context_and_inventory.rs +++ b/crates/devup-mcp/src/server/project_context/context_and_inventory.rs @@ -95,6 +95,8 @@ fn not_found_with_exclusions(message: &str, searched: Vec<String>, excluded: Vec /// skipped" apart from "every $token check passed". pub struct ThemeLookup { pub theme: Option<devup_mcp_devup_ui::theme::ProjectTheme>, + /// The file `theme` was read from, when one was. + pub devup_json: Option<String>, pub guardrail: Option<Value>, } @@ -150,6 +152,7 @@ pub fn theme_for_validation(project_root: Option<&str>) -> Result<ThemeLookup, D let Some(root) = find_project_root(&start) else { return Ok(ThemeLookup { theme: None, + devup_json: None, guardrail: Some(guardrail_object( PROJECT_ROOT_NOT_FOUND_MESSAGE, vec![display_path(&start)], @@ -175,6 +178,7 @@ pub fn theme_for_validation(project_root: Option<&str>) -> Result<ThemeLookup, D if candidates.len() > 1 { return Ok(ThemeLookup { theme: None, + devup_json: None, guardrail: Some(ambiguous_theme_guardrail(&root, &candidates, excluded)), }); } @@ -191,6 +195,7 @@ pub fn theme_for_validation(project_root: Option<&str>) -> Result<ThemeLookup, D }; return Ok(ThemeLookup { theme: None, + devup_json: None, guardrail: Some(guardrail_object( message, vec![display_path(&root.join("devup.json"))], @@ -208,6 +213,7 @@ pub fn theme_for_validation(project_root: Option<&str>) -> Result<ThemeLookup, D let theme = parse_project_theme(&source)?; Ok(ThemeLookup { theme: Some(theme), + devup_json: Some(display_path(&file)), guardrail: None, }) } diff --git a/crates/devup-mcp/src/server/runtime_audit/motion_validation.rs b/crates/devup-mcp/src/server/runtime_audit/motion_validation.rs index 6f87ed6d..2dace171 100644 --- a/crates/devup-mcp/src/server/runtime_audit/motion_validation.rs +++ b/crates/devup-mcp/src/server/runtime_audit/motion_validation.rs @@ -45,6 +45,44 @@ fn validate_motion(value: Option<&Value>) -> Result<(), DevupError> { { return Err(invalid("count-up motion needs finite finalValue")); } + // Reveal scenarios each reload the page and replay one situation. They + // are named exactly, so a typo cannot silently run nothing. + if let Some(scenarios) = value.get("scenarios") { + let known = ["fast-scroll", "reload", "history", "image-delay", "image-failure", "focus", "progress-sync"]; + let valid = kind == "reveal" + && scenarios.as_array().is_some_and(|items| { + items.len() <= known.len() + && items.iter().all(|item| item.as_str().is_some_and(|name| known.contains(&name))) + }); + if !valid { + return Err(invalid( + "motion.scenarios is for reveal only and takes fast-scroll, reload, history, image-delay, image-failure, focus or progress-sync", + )); + } + // The line and its items are named, never guessed. + if scenarios.as_array().is_some_and(|items| items.iter().any(|item| item == "progress-sync")) { + for key in ["progress", "items"] { + let selector = value[key] + .as_str() + .filter(|selector| !selector.is_empty() && !selector.contains("data-figma") && !selector.contains("data-source-node")) + .ok_or_else(|| invalid(format!("progress-sync requires a semantic motion.{key} selector")))?; + bounded_text(&format!("motion.{key}"), selector)?; + } + } + } + if let Some(focus) = value.get("focus") { + let selector = focus + .as_str() + .filter(|selector| !selector.is_empty() && !selector.contains("data-figma") && !selector.contains("data-source-node")) + .ok_or_else(|| invalid("motion.focus requires a semantic selector"))?; + bounded_text("motion.focus", selector)?; + } + if value + .get("settleMs") + .is_some_and(|value| value.as_u64().is_none_or(|ms| ms > 10_000)) + { + return Err(invalid("motion.settleMs must be 0..10000")); + } if value.get("resizeWidth").is_some_and(|value| { value .as_u64() @@ -76,5 +114,24 @@ mod motion_contract_tests { assert!(validate_motion(Some(&changed)).is_err(), "{changed}"); } assert!(validate_motion(None).is_err()); + let reveal = json!({"kind":"reveal","candidateId":"section-entrance:1","target":"#news","scenarios":["fast-scroll","reload","history","image-delay","image-failure","focus"],"focus":"a[href]","settleMs":400}); + assert!(validate_motion(Some(&reveal)).is_ok()); + let timeline = json!({"kind":"reveal","candidateId":"section-entrance:2","target":"#history","scenarios":["progress-sync"],"progress":".line","items":"li"}); + assert!(validate_motion(Some(&timeline)).is_ok()); + for patch in [ + json!({"scenarios":["fast-scrol"]}), + json!({"scenarios":["progress-sync"]}), + json!({"scenarios":["progress-sync"],"progress":".line","items":"[data-figma-node-id]"}), + json!({"scenarios":"focus"}), + json!({"kind":"tabs","links":"a","indicator":"i","next":"a"}), + json!({"focus":"[data-figma-node-id]"}), + json!({"settleMs":60_000}), + ] { + let mut changed = reveal.clone(); + for (key, value) in patch.as_object().unwrap() { + changed[key] = value.clone(); + } + assert!(validate_motion(Some(&changed)).is_err(), "{changed}"); + } } } diff --git a/crates/devup-mcp/src/server/skills/catalog.rs b/crates/devup-mcp/src/server/skills/catalog.rs index 23279350..bdd21a77 100644 --- a/crates/devup-mcp/src/server/skills/catalog.rs +++ b/crates/devup-mcp/src/server/skills/catalog.rs @@ -77,6 +77,7 @@ const EMBEDDED: &[(&str, Documents)] = &[ &[ ("SKILL.md", include_str!("devfive-frontend/SKILL.md")), ("references/delivery-quality.md", include_str!("devfive-frontend/references/delivery-quality.md")), + ("references/animation.md", include_str!("devfive-frontend/references/animation.md")), ( "references/critical-rules.md", include_str!("devfive-frontend/references/critical-rules.md"), diff --git a/crates/devup-mcp/src/server/skills/devfive-frontend/SKILL.md b/crates/devup-mcp/src/server/skills/devfive-frontend/SKILL.md index 7a6e0096..5cfa9664 100644 --- a/crates/devup-mcp/src/server/skills/devfive-frontend/SKILL.md +++ b/crates/devup-mcp/src/server/skills/devfive-frontend/SKILL.md @@ -65,6 +65,12 @@ word boundaries, complete controls and palette variants, motion, canonical asset and DB-backed landing/detail content. Apply these rules even when the screenshot looks correct; verify actual runtime state, asset URLs and changed data. +Motion is a default quality item: without being asked, survey each page's +sections and repeated content, propose restrained entrance/transition motion by +role and implement it, following [animation](references/animation.md). The +user's stated design always wins. Verify start, middle and end frames and the +real trigger timing at mobile and desktop widths, never the final frame alone. + Common coding conventions for DevFive frontend projects (puzzle-fit, girok-space, etc.). ## Links, controls and completion evidence @@ -710,5 +716,6 @@ export { default } from 'eslint-plugin-devup/oxlint-config' - **[Anti-Patterns](references/anti-patterns.md)** - What NOT to do - **[Theme Colors](references/theme-colors.md)** - Project theme color tokens - **[Common Patterns](references/common-patterns.md)** - Common UI patterns (Modal, Toast, etc.) +- **[Animation](references/animation.md)** - Default entrance/transition motion: proposal by role, one-shot scroll reveals, no SSR/hydration flash, verification Connected URLs: run `links-resolve` at every width. Each same-origin anchor contributes source URL, accessible label, href, resolved pathname and width to `linkTable` (also Markdown in `finalReport`). GET each distinct target once, including concrete dynamic hrefs, and reject HTTP failures, error pages and `unplanned-target` URLs absent from the confirmed plan and existing routes. Any failing link row blocks done. diff --git a/crates/devup-mcp/src/server/skills/devfive-frontend/references/animation.md b/crates/devup-mcp/src/server/skills/devfive-frontend/references/animation.md new file mode 100644 index 00000000..6a4518b5 --- /dev/null +++ b/crates/devup-mcp/src/server/skills/devfive-frontend/references/animation.md @@ -0,0 +1,130 @@ +# Entrance and transition motion + +Motion is a default quality item, not an extra. Nobody has to ask for it: on +every page you build or change, run the pass below, propose the result, then +implement it. A design the user stated wins over every default here — an +explicit "no animation", a specified easing or duration, a replaying effect, a +captured prototype. Record such a choice and do not second-guess it. + +## 1. Survey the page, then propose by role + +Walk the page's sections and repeated content before writing any motion. +Choose by what a block is for, not by what can be animated: + +| Block | Default | +|-------|---------| +| First viewport (hero, page title) | No scroll reveal. It is visible on load; at most one short settle that never starts hidden in server markup | +| Section below the fold | One restrained entrance for the section's content group: short fade with a small upward move | +| Card grid, list, logo row, steps | The group enters together or with a short stagger (40-80ms per item, capped so the whole group finishes within about 400ms) | +| Section with a painted background, image or band | The painted element stays fixed; an inner content wrapper moves | +| Progress line, timeline, chart, graph | The line/graph advances in step with the items it connects: an item appears as the line reaches it, not before and not long after | +| State change (tab, accordion, hover, selected) | A short transition on the properties that change | +| Body copy, form fields, navigation, footer, legal text | None | + +Restraint is part of the rule. Do not animate every heading or every control, +do not chain a title, subtitle and button as three separate reveals, and do not +add motion to something the user reads or operates immediately. One trigger +per content group is the normal case. Durations stay around 200-500ms with an +ease-out curve; distances stay small (8-24px). + +Tell the user what you propose, section by section, in the first response +that covers the page (a short table is enough: block, motion, trigger, why). +Continue with the implementation unless they object; do not wait for approval +of defaults. + +## 2. Scroll reveals fire when content is visible, once + +- Start when the content is actually in view, not when its container's top + edge touches the viewport and not at page load for content far below. Use a + visibility amount or margin that fits the block's height; a tall section + needs a small amount, or it never fires on a short screen. +- Play once. Re-entering the viewport, scrolling back up or returning with + back/forward must not replay or re-hide content + (`viewport={{ once: true }}`, or an observer that unobserves after firing). +- Stagger card groups only when it helps reading order, and keep it short. + Derive the delay from the item index, never from a hardcoded list length. +- A section with a background keeps that background still. Put the trigger + and the transform on an inner wrapper so no band, image or colour fades or + slides against the page. +- A progress line or graph shares one trigger and one timeline with its + items. Derive item delays from the same duration as the line so the two + cannot drift when either changes. +- A fast scroll past several sections must leave every passed section fully + visible, not stuck mid-animation or still hidden. + +## 3. No flash, no blank, no dependency on JavaScript + +Server markup is the content. It must be readable before, during and without +hydration. + +- Never ship server HTML whose content starts at `opacity: 0` or translated + away and waits for JavaScript to show it. `initial={{ opacity: 0 }}` on an + always-rendered `motion.*` element does exactly that: it is invisible until + hydration, and stays invisible when the script is slow, blocked or fails. +- Equally, do not render visible content and then hide it on hydration to + replay an entrance: that is the flash. Decide the hidden start only for + content that is still below the viewport when the enhancer attaches; + content already in view stays as rendered. +- Reload, direct load, client navigation and back/forward all start from + visible content. A restored scroll position must not leave the visible + sections blank until the user scrolls. +- With JavaScript disabled or still loading, every section is visible and + usable. Prefer one shared observer/controller that enhances server markup + over many client components that each own visibility. +- `prefers-reduced-motion: reduce` shows content in place with no movement. + Handle it once (`<MotionConfig reducedMotion="user">`, `useReducedMotion()`, + or a media rule) and verify it. +- Keyboard focus is never hidden or moved by an entrance. An element that + receives focus is shown immediately in its final position; do not animate a + focused control or trap focus in a block that has not revealed yet. +- Images inside a revealed block reserve their size. A slow or failed image + must not hold the block hidden or shift it when it arrives. + +## 4. Verify the motion, not the final screenshot + +A correct final frame says nothing about the animation. For every motion you +added or changed, at one mobile and one desktop width: + +- **Frames**: capture or measure the start, a middle and the end state + (opacity/transform values, not only pixels). The start must be the visible + server state, the middle must really be intermediate, the end must match + the design. +- **Trigger timing**: record where the block was relative to the viewport + when it started. "Starts while still off screen" and "starts after the user + is already reading it" both fail. +- **Re-entry**: scroll away and back; nothing replays or re-hides. +- **Fast scroll**: jump or fling to the bottom; every passed block is fully + visible. +- **History**: go to another route and come back with back/forward, and + reload mid-page; no blank or flashing section at the restored position. +- **Images**: throttle images and block one; the block still reveals, at the + reserved size. +- **Preferences and access**: reduced motion, JavaScript disabled, and + Tab/Shift+Tab through the block's controls before and during its entrance. + +Most of this list is measurable. A `devup_runtime_audit` motion check with +`kind: "reveal"` always measures the intermediate and final frames, the +trigger position (`measured.trigger`: it fails when the entrance starts more +than 1px off screen or only after the block has been fully visible for +400ms) and re-entry. Add `scenarios` to run the rest from a fresh load: + +| Scenario | Fails when | +|----------|------------| +| `fast-scroll` | after a fling to the bottom the block is not fully shown | +| `reload`, `history` | the block is hidden or displaced at the restored scroll position | +| `image-delay`, `image-failure` | a stalled or aborted image keeps the block hidden or changes its height | +| `focus` | a focused control inside the block is still animating (`focus` selects the control) | +| `progress-sync` | an item appears more than 200ms before or after the line reaches it (`progress` and `items` selectors) | + +A scenario that cannot be observed fails rather than passes, and scenarios +you did not request are listed in `measured.scenariosNotRun`. Run the check +at one mobile and one desktop width. + +`devup_ui_validate` reports what the source alone proves: +`ssr-hidden-reveal`, `reveal-replays`, `reveal-moves-background`, +`stagger-too-long` and `motion-without-reduced-motion`. + +Still a judgement, not a measurement: whether motion suits the block at all, +and whether it is restrained. Report each item as verified or explicitly +unverified, with the width it was checked at; never report motion as done +from a final screenshot. diff --git a/crates/devup-mcp/src/server/skills/devfive-frontend/references/delivery-quality.md b/crates/devup-mcp/src/server/skills/devfive-frontend/references/delivery-quality.md index e59bc5d9..5ad7ebd9 100644 --- a/crates/devup-mcp/src/server/skills/devfive-frontend/references/delivery-quality.md +++ b/crates/devup-mcp/src/server/skills/devfive-frontend/references/delivery-quality.md @@ -83,6 +83,32 @@ preferences; do not introduce unsolicited pause controls or hover suspension. every layout property. Reduced-motion behavior is the accessible default unless an explicit requirement says otherwise; document intentional exceptions. +## Entrance and transition motion is a default item + +Do this without being asked, on every page built or changed; the installed +`devfive-frontend/references/animation.md` has the full rules. A design the +user stated (no animation, a given timing, a replaying effect) always wins. + +- Survey sections and repeated content, then propose motion by role and + implement it: one restrained entrance per below-fold content group, a short + stagger for card groups only where it helps, transitions for state changes. + Do not animate every heading or control, the first viewport, body copy, + forms or navigation. +- A scroll reveal starts when its content is actually visible and plays once; + re-entry, scrolling back and back/forward never replay or re-hide it. A + section with a background keeps the painted element fixed and moves an inner + wrapper. A progress line or graph shares one trigger and timeline with the + items it connects. +- Server markup stays readable: no content that starts at `opacity: 0` or + translated away waiting for JavaScript, and no visible content hidden on + hydration to replay an entrance. Reload, client navigation and a restored + scroll position start from visible content. No-JavaScript, delayed scripts, + `prefers-reduced-motion` and keyboard focus all show content in place. +- Verify the animation, not the final frame: start, middle and end states and + the real trigger position at one mobile and one desktop width, plus scroll + re-entry, fast scroll, back/forward, and delayed and failed images. Report + each as verified or explicitly unverified. + ## One source for business data Inventory every statistic, product summary and list/detail field before declaring diff --git a/crates/devup-mcp/src/server/skills/fetch_tests.rs b/crates/devup-mcp/src/server/skills/fetch_tests.rs index 647dc477..2f4f075b 100644 --- a/crates/devup-mcp/src/server/skills/fetch_tests.rs +++ b/crates/devup-mcp/src/server/skills/fetch_tests.rs @@ -165,7 +165,7 @@ async fn own_external_and_unknown_skills_never_fetch() { .unwrap(); assert_eq!(result["installed"][0]["source"], "embedded"); let installed_paths = result["installed"][0]["paths"].as_array().unwrap(); - assert_eq!(installed_paths.len(), 6); + assert_eq!(installed_paths.len(), 7); let delivery_reference = installed_paths.iter().find_map(|path| { let path = std::path::Path::new(path.as_str()?); (path.file_name()?.to_str()? == "delivery-quality.md").then_some(path) diff --git a/crates/devup-mcp/src/server/skills/manifest.json b/crates/devup-mcp/src/server/skills/manifest.json index f359ca22..8ad63804 100644 --- a/crates/devup-mcp/src/server/skills/manifest.json +++ b/crates/devup-mcp/src/server/skills/manifest.json @@ -44,15 +44,15 @@ "name": "devfive-frontend", "origin": "own", "title": "DevFive frontend conventions", - "description": "Where the generated code goes in a Next App Router project: app/ holds only layout.tsx and page.tsx, components live under components/pages/<route>/, Server Components are the default, and all styling is devup-ui props rather than an authored CSS file. Multi-file: SKILL.md plus four reference documents.", + "description": "Where the generated code goes in a Next App Router project: app/ holds only layout.tsx and page.tsx, components live under components/pages/<route>/, Server Components are the default, and all styling is devup-ui props rather than an authored CSS file. Multi-file: SKILL.md plus its reference documents.", "usedFor": "devup_figma_export returns one screen's TSX; a project is a tree of routes. This is the skill that says which file that TSX becomes and which rules it is then judged by. Without it an agent that can already write devup-ui still puts the screen in the wrong place - most visibly by switching screens with local state instead of file routing, and by authoring a .css file next to code that has globalCss().", "repo": "dev-five-git/devup-mcp", "path": "crates/devup-mcp/src/server/skills/devfive-frontend", "documents": [ { "path": "SKILL.md", - "bytes": 30455, - "sha256": "21ba16688d97ff24a6a5d2352af5ad611a9ad5813c9e9d4301f07b64ac38648d" + "bytes": 31003, + "sha256": "751f76110eed1fe18874702cc208571c2a651f2822712fba7fc3d309869c0e75" }, { "path": "references/critical-rules.md", @@ -76,8 +76,13 @@ }, { "path": "references/delivery-quality.md", - "bytes": 17775, - "sha256": "cf25b0dfef30dc2cdf106a353c396bc2fbb15673ada487fefad988b1a1f3a029" + "bytes": 19364, + "sha256": "4ce226a73eb37f62e3341c3d8bd86078a6a6f0d43f6b66278f79f31bfdab1f3d" + }, + { + "path": "references/animation.md", + "bytes": 7669, + "sha256": "beaba3bd5b6c564d0f9f1a1bab0928b6e0c58f0c5f7b9f9d723a8df573939b30" } ], "sourceUrl": "https://github.com/dev-five-git/devup-mcp/blob/HEAD/crates/devup-mcp/src/server/skills/devfive-frontend", diff --git a/crates/devup-mcp/src/server/text/devup-runtime-audit.md b/crates/devup-mcp/src/server/text/devup-runtime-audit.md index 9b5134a2..80235245 100644 --- a/crates/devup-mcp/src/server/text/devup-runtime-audit.md +++ b/crates/devup-mcp/src/server/text/devup-runtime-audit.md @@ -7,4 +7,4 @@ Rendered completeness requires the frame height (default minimum 90%), listed se Selection proof: expect.selected requires semantic group/target locators, attribute aria-current or aria-selected, a true selection value, and 1..12 captured CSS paint values in styles. It checks exactly one visible selected control, actual computed paint, containment and optional content visibility. Use press(target,value) and back/forward/reload(no target/value) steps to test keyboard and browser history. A URL change or stale ARIA alone never proves visual selection. Markup also detects short-page footer gaps. -Motion checks measure intermediate values for marquee, count-up, steps, tabs and reveal, with explicit candidateId and semantic selectors. A no-clipped-content exception requires allowedMarquees naming one verified horizontal translating track; animation elsewhere never exempts a clipped container. Data-binding checks mutate only an explicitly isolated disposable SQLite database and compare baseline, changed, empty and error states across landing/list/detail. Completion independently rechecks source and artifact hashes, SQL snapshots and all required URL/width/motion/binding coverage. It always writes the canonical .devup/completion.json, optionally copying it to outputPath. Exported source alone never establishes application completion; see the completion guide for the staged contract. +Motion checks measure intermediate values for marquee, count-up, steps, tabs and reveal, with explicit candidateId and semantic selectors. A reveal check scrolls toward the target in small steps and records `measured.trigger` (how much of the block was on screen when the entrance began): starting more than 1px off screen, or only after the block has been fully visible for 400ms, fails. It then scrolls the target out of view and back: content re-hidden while away, a replay on re-entry, or a target that cannot leave the viewport at that size (re-entry unobserved) fails. `motion.scenarios` opts a reveal into fresh-load scenarios, each reported under `measured.scenarios`: fast-scroll (fling to the bottom, then the target must be fully shown), reload and history (reload, or leave and go back, with the target on screen: it must never be hidden or displaced), image-delay and image-failure (images inside the target stalled or aborted: it still reveals at its reserved height), focus (focusing a control inside an unrevealed target shows it at once; `motion.focus` selects the control), progress-sync (with `motion.progress` and `motion.items` selectors inside the target: each item must appear within 200ms, or `motion.syncToleranceMs`, of the progress line reaching it). A scenario that cannot be observed - no image, no focusable control, no advancing progress line, scroll position not restored - fails, and scenarios not requested are listed in `measured.scenariosNotRun`. Run them at one mobile and one desktop width. A no-clipped-content exception requires allowedMarquees naming one verified horizontal translating track; animation elsewhere never exempts a clipped container. Data-binding checks mutate only an explicitly isolated disposable SQLite database and compare baseline, changed, empty and error states across landing/list/detail. Completion independently rechecks source and artifact hashes, SQL snapshots and all required URL/width/motion/binding coverage. It always writes the canonical .devup/completion.json, optionally copying it to outputPath. Exported source alone never establishes application completion; see the completion guide for the staged contract. diff --git a/crates/devup-mcp/src/server/text/devup-ui-validate.md b/crates/devup-mcp/src/server/text/devup-ui-validate.md index 2770cc3d..65bed5ee 100644 --- a/crates/devup-mcp/src/server/text/devup-ui-validate.md +++ b/crates/devup-mcp/src/server/text/devup-ui-validate.md @@ -1,4 +1,6 @@ -Validate DevupUI TSX against a project's real devup.json: unknown $token references, hardcoded colors/lengths, unknown props on Box/Flex/Text/Center/Grid/Image, and non-static values inside css()/globalCss()/keyframes() calls. `ok` is decided by severity, not by count: errors fail, and `strict: true` also fails warnings. Hardcoded values with exact matching tokens are warnings with token advice; unmatched values are info without token advice and never fail strict mode. `okReason` distinguishes clean, clean-unverified-tokens, info-only, warnings-only, warnings-and-info, strict-warnings, and error-violations; `clean-unverified-tokens` means no violation was found but no devup.json was resolved, so the $token references this code makes were never checked. Supplying neither tsx nor files is rejected rather than answered `clean`. Structural rules (file-placement, one-component-per-file, client-boundary, inline-style, react-query-states) run only in `files` bundle mode; framework-required default exports (page, layout, loading, error, not-found, template, and the metadata conventions) are exempt from file-placement. With projectRoot, a maskImage whose public SVG paints more than one colour warns `multicolor-mask` (use <Image src>), and a root-relative asset URL (src or url(...)) with no file under any public directory is an error (`asset-missing`), always failing `ok`. `themeNotes` summarizes each encountered empty token category once. Site completeness: an interactive element (as=a/button, Link, button, onClick) with no _hover or _focusVisible is a warning (interactive-states), one with no focus style is a warning (focus-style), too much motion - over 400ms, scale above 1.05, infinite - is a warning (too-much-motion), an icon-only button without aria-label is a warning (icon-button-label); a button with no _active, a _hover with no transition, motion with no _motionReduce and a nav link with no aria-current are info (button-active, hover-transition, motion-reduce, nav-current); a page or layout with no metadata export is a warning (page-metadata, files mode), and with projectRoot an app with no icon or favicon is a warning (app-icon). Navigation rules also report unmapped action candidates/accessibility warnings, missing static App Router href errors when projectRoot inventory is complete, and literal active-state info. Dynamic hrefs/custom routing and incomplete bundles remain unverified. Optional renderedHtml reports captured anchor counts separately from source coverage; neither proves runtime behavior. `checkedTokens` counts references and `availableTokenCount` counts definitions; they are not a ratio. Supply sectionCoverage from outputResults.tsx.sectionCoverage (or frames[].outputResults.tsx.sectionCoverage) to report omitted sections, images, masks and backgrounds as section-coverage errors. This compares literal JSX node identity and asset references across the supplied sources; it does not prove runtime visibility or component use. Omitted sectionCoverage leaves design coverage unverified. -Default detail is summary: compact findings merged by source/rule/message (count for repeats), at most 100 rows, severity counts and layoutRiskCount. Use detail:"full" for all diagnostics including navigation and verificationScope. paths accepts files or recursive directories inside projectRoot, exclusive with tsx/files, up to 512 source files and 8 MiB; it runs bundle structural checks with the disk theme and reports pathsRead. Directory reads skip generated and hidden directories. Supplied files retain their 64-file/1 MiB limits and never read their theme from disk. +Validate DevupUI TSX against a project's real devup.json: unknown $token references, hardcoded colors/lengths, unknown props on Box/Flex/Text/Center/Grid/Image, and non-static values inside css()/globalCss()/keyframes() calls. `ok` is decided by severity, not by count: errors fail, and `strict: true` also fails warnings. Hardcoded values with exact matching tokens are warnings with token advice; unmatched values are info without token advice and never fail strict mode. `okReason` distinguishes clean, clean-unverified-tokens, info-only, warnings-only, warnings-and-info, strict-warnings, and error-violations; `clean-unverified-tokens` means no violation was found but no devup.json was resolved, so the $token references this code makes were never checked. With paths or files, each source is checked against the nearest devup.json above it, so one call over a repository of several apps uses each app's own theme; `tokens.scopes` lists each theme with its file and reference counts, `tokens.verification` is verified, partial or skipped, and files no devup.json governs are named in `themeGuardrail.unverifiedPaths` and never counted as checked (`themeAvailable` and `tokens.unknownTokenCheckRan` are true only when every file was checked). A module-level const of the same file holding a literal or a keyframes()/css() name is a static value inside css()/globalCss()/keyframes() (`css({ animationName: spin })`); imports, parameters and call results remain `runtime-value`. A single tsx has no location, so at a repository root with several themes its tokens stay unverified unless sourceName gives its relative path (apps/front/src/Hero.tsx): the nearest devup.json above that location is then used and named in `tokens.devupJson`; the named file is never opened. Supplying neither tsx nor files is rejected rather than answered `clean`. Structural rules (file-placement, one-component-per-file, client-boundary, inline-style, react-query-states) run only in `files` bundle mode; framework-required default exports (page, layout, loading, error, not-found, template, and the metadata conventions) are exempt from file-placement. With projectRoot, a maskImage whose public SVG paints more than one colour warns `multicolor-mask` (use <Image src>), and a root-relative asset URL (src or url(...)) with no file under any public directory is an error (`asset-missing`), always failing `ok`. `themeNotes` summarizes each encountered empty token category once. Site completeness: an interactive element (as=a/button, Link, button, onClick) with no _hover or _focusVisible is a warning (interactive-states), one with no focus style is a warning (focus-style), too much motion - over 400ms, scale above 1.05, infinite - is a warning (too-much-motion), an icon-only button without aria-label is a warning (icon-button-label); a button with no _active, a _hover with no transition, motion with no _motionReduce and a nav link with no aria-current are info (button-active, hover-transition, motion-reduce, nav-current); a page or layout with no metadata export is a warning (page-metadata, files mode), and with projectRoot an app with no icon or favicon is a warning (app-icon). Navigation rules also report unmapped action candidates/accessibility warnings, missing static App Router href errors when projectRoot inventory is complete, and literal active-state info. Dynamic hrefs/custom routing and incomplete bundles remain unverified. Optional renderedHtml reports captured anchor counts separately from source coverage; neither proves runtime behavior. `checkedTokens` counts references and `availableTokenCount` counts definitions; they are not a ratio. Supply sectionCoverage from outputResults.tsx.sectionCoverage (or frames[].outputResults.tsx.sectionCoverage) to report omitted sections, images, masks and backgrounds as section-coverage errors. This compares literal JSX node identity and asset references across the supplied sources; it does not prove runtime visibility or component use. Omitted sectionCoverage leaves design coverage unverified. +Default detail is summary: compact findings merged by source/rule/message (count for repeats), at most 100 rows, severity counts and layoutRiskCount. Use detail:"full" for all diagnostics including navigation and verificationScope. paths accepts files or recursive directories inside projectRoot, exclusive with tsx/files, up to 512 source files and 8 MiB; it runs bundle structural checks with the disk themes (nearest devup.json per file) and reports pathsRead. Directory reads skip generated and hidden directories. Supplied files retain their 64-file/1 MiB limits and never read their theme from disk; several supplied devup.json files each govern their own directory. + +Entrance motion, from source alone (warnings): `ssr-hidden-reveal` - an always-rendered motion.* element starts at opacity 0 (initial object, or a named variant declared on the same element), so server markup is invisible until JavaScript runs; elements inside AnimatePresence or a conditional render are client-mounted and exempt. `reveal-replays` - whileInView without viewport={{ once: true }}; an in-view hook (useInView) with no once/triggerOnce option is the same finding at info, since its purpose is not visible. `stagger-too-long` - staggerChildren, or delay: index * step, above 150ms per item. Components built with motion.create()/motion() in the same file are read like motion.* elements. `reveal-moves-background` - the whileInView element also paints a background, so the section paint moves with its content. `motion-without-reduced-motion` (paths/files only) - entrance motion while no validated source, nor projectRoot/src in paths mode, handles reduced motion (a call or prop such as useReducedMotion, MotionConfig reducedMotion, _motionReduce, or a prefers-reduced-motion media rule; a comment or an unused import does not count). Trigger timing, intermediate frames, re-entry, history and image loading are runtime evidence, not checked here. `link-underline` warns when a native anchor, next/link (including import aliases), or polymorphic link has no explicit textDecoration. Set textDecoration="none" on the link or globalCss({ a: { textDecoration: 'none', color: 'inherit' } }); reset-css does not remove anchor underlines. Parsed globalCss anchor rules in the supplied bundle suppress this warning; paths mode also checks projectRoot/src within the source read limits. Hover-only decoration does not reset the resting state. `marquee-static` is informational: long single-line JSX text with at least three repeated separators and no keyframes/animation in the same file suggests a static scrolling strip. This is heuristic, not proof of animation intent or runtime behavior. diff --git a/crates/devup-mcp/src/server/text/guide-INSTRUCTIONS-49bd7ec6.md b/crates/devup-mcp/src/server/text/guide-INSTRUCTIONS-49bd7ec6.md index dbbb6243..7b5b7a95 100644 --- a/crates/devup-mcp/src/server/text/guide-INSTRUCTIONS-49bd7ec6.md +++ b/crates/devup-mcp/src/server/text/guide-INSTRUCTIONS-49bd7ec6.md @@ -5,4 +5,4 @@ Build identity: use server.commit/buildId, not version; reconnect if stale. serv 3. For Figma implementation use devup_figma_export tsx, the deliverable. TSX links/events need wiring: resolve interactiveCandidates, devup_ui_validate, report wired/tested counts. deliverable.isFinal describes export readiness. Audit every width and exact layout. Backend completion needs devup_stack_diff zero drift. 4. Request only outputs you read. Before your second call read devup://guide/usage for sizing, verification, batching, assets and delivery. 5. After confirm, extract COMPONENT_SET/COMPONENT first with componentTsx into components/common|layout; record mainComponent id -> file in .devup/component-map.json. Assemble generated sections (frame-root children); audit via file mappings, not DOM Figma attributes. Never hand-write TSX from Figma nodes. Report unsupported exports. -6. Apply developmentContract and behaviorPlan before completion. +6. Apply developmentContract, behaviorPlan and default motion before completion. diff --git a/crates/devup-mcp/src/server/tools.rs b/crates/devup-mcp/src/server/tools.rs index caa2ac3b..f6f10088 100644 --- a/crates/devup-mcp/src/server/tools.rs +++ b/crates/devup-mcp/src/server/tools.rs @@ -362,7 +362,8 @@ pub struct UiValidateInput { #[serde(default)] pub detail: UiValidateDetail, /// Files or recursive directories inside projectRoot. Exclusive with tsx/files; - /// at most 512 source files and 8 MiB read. Uses the project's disk theme. + /// at most 512 source files and 8 MiB read. Each file uses the nearest + /// devup.json above it; files with none are reported as unverified. #[serde(default)] #[schemars(length(max = 512))] pub paths: Vec<String>, @@ -398,7 +399,8 @@ pub struct UiValidateInput { pub tsx: String, /// Optional supplied-source bundle: at most 64 entries and 1 MiB total /// UTF-8 path/content bytes. Enables structural checks without disk reads. - /// Entries require path and content strings; include devup.json for tokens. + /// Entries require path and content strings; include devup.json for tokens + /// (several are matched to the sources under each one's directory). /// Omit tsx when supplying files. #[serde(default, skip_serializing_if = "Option::is_none")] #[schemars(with = "Option<Vec<std::collections::BTreeMap<String, String>>>", extend( @@ -406,7 +408,9 @@ pub struct UiValidateInput { "items" = serde_json::json!({"type":"object","required":["path","content"],"additionalProperties":{"not":{}},"properties":{"path":{"type":"string"},"content":{"type":"string"}}}) ))] pub files: Option<Vec<devup_mcp_devup_ui::ui_validate::SourceFile>>, - /// Optional source label echoed in diagnostics; never read as a file path. + /// Optional source label echoed in diagnostics; the file is never opened. + /// With projectRoot, a relative path (apps/front/src/Hero.tsx) also selects + /// the nearest devup.json above that location for this tsx. #[serde(default)] pub source_name: Option<String>, #[serde(default)] diff --git a/crates/devup-mcp/tests/export_project_theme.rs b/crates/devup-mcp/tests/export_project_theme.rs index 1cfde997..a78f3abc 100644 --- a/crates/devup-mcp/tests/export_project_theme.rs +++ b/crates/devup-mcp/tests/export_project_theme.rs @@ -176,6 +176,13 @@ async fn export_context_summarizes_a_large_project_under_fifteen_kib() -> anyhow let result = export(json!({"url":URL,"projectRoot":root})).await?; let bytes = serde_json::to_vec(&result)?.len(); eprintln!("projectRoot export structured reply: {bytes} bytes"); + // A plan complete on its first page carries no page-joining instruction. + assert_eq!(result["behaviorPlan"]["pagination"]["complete"], true); + assert!( + result["behaviorPlan"]["pagination"] + .get("aggregation") + .is_none() + ); assert!(bytes < 15 * 1024, "reply is {bytes} bytes"); assert_eq!(result["projectContext"]["counts"]["publicFiles"], 180); assert_eq!(result["projectContext"]["ui"]["components"], json!([])); diff --git a/crates/devup-mcp/tests/ui_validate_response.rs b/crates/devup-mcp/tests/ui_validate_response.rs index d1a0b81d..4f9c97d6 100644 --- a/crates/devup-mcp/tests/ui_validate_response.rs +++ b/crates/devup-mcp/tests/ui_validate_response.rs @@ -15,3 +15,4 @@ include!("ui_validate_response/a_page_named_module_outside_the_router_is_not_jud include!("ui_validate_response/section_coverage.rs"); include!("ui_validate_response/summary_and_paths.rs"); include!("ui_validate_response/effective_attributes.rs"); +include!("ui_validate_response/monorepo_token_scopes.rs"); diff --git a/crates/devup-mcp/tests/ui_validate_response/monorepo_token_scopes.rs b/crates/devup-mcp/tests/ui_validate_response/monorepo_token_scopes.rs new file mode 100644 index 00000000..8e09b34c --- /dev/null +++ b/crates/devup-mcp/tests/ui_validate_response/monorepo_token_scopes.rs @@ -0,0 +1,190 @@ +/// A repository of several apps: each app's `devup.json` and its sources. +/// `$bg` is a front token and `$adminBg` an admin one; `shared/` belongs to +/// neither app. +fn monorepo(label: &str) -> anyhow::Result<std::path::PathBuf> { + let root = std::env::temp_dir().join(format!("devup-token-scopes-{label}-{}", rand::random::<u64>())); + for (path, content) in [ + ("package.json", "{}"), + ("apps/front/package.json", "{}"), + ("apps/front/devup.json", r##"{"theme":{"colors":{"default":{"bg":"#F7F3EC"}}}}"##), + ("apps/front/src/Hero.tsx", r#"export function Hero() { return <Box bg="$bg" /> }"#), + ("apps/admin/package.json", "{}"), + ("apps/admin/devup.json", r##"{"theme":{"colors":{"default":{"adminBg":"#000000"}}}}"##), + ("apps/admin/src/Panel.tsx", r#"export function Panel() { return <Box bg="$bg" color="$adminBg" /> }"#), + ("shared/Badge.tsx", r#"export function Badge() { return <Box bg="$bg" /> }"#), + ] { + let file = root.join(path); + std::fs::create_dir_all(file.parent().unwrap())?; + std::fs::write(file, content)?; + } + Ok(root) +} + +fn unknown_tokens(output: &Value) -> Vec<(String, String)> { + output["violations"] + .as_array() + .unwrap() + .iter() + .filter(|finding| finding["rule"] == "unknown-token") + .map(|finding| (finding["path"].as_str().unwrap().to_owned(), finding["snippet"].as_str().unwrap().to_owned())) + .collect() +} + +/// One call over the repository root used to skip every token check (several +/// themes, none at the root). Each file is now checked against its own app's +/// theme: `$bg` passes in front and is unknown in admin. +#[tokio::test] +async fn each_app_is_validated_against_its_own_devup_json() -> anyhow::Result<()> { + let root = monorepo("apps")?; + let result = validate(json!({"paths":["apps"],"projectRoot":root})).await; + std::fs::remove_dir_all(&root)?; + let out = result?; + assert_eq!(unknown_tokens(&out), [("apps/admin/src/Panel.tsx".to_owned(), "\"$bg\"".to_owned())], "{out}"); + assert_eq!(out["tokens"]["verification"], "verified", "{out}"); + assert_eq!(out["tokens"]["unknownTokenCheckRan"], true, "{out}"); + assert_eq!(out["themeAvailable"], true, "{out}"); + assert!(out["themeGuardrail"].is_null(), "{out}"); + assert_eq!( + out["tokens"]["scopes"], + json!([ + {"status":"verified","devupJson":"apps/admin/devup.json","files":1,"referencedByTsx":2,"definedByTheme":1}, + {"status":"verified","devupJson":"apps/front/devup.json","files":1,"referencedByTsx":1,"definedByTheme":1}, + ]), + "{out}" + ); + assert_eq!(out["okReason"], "error-violations", "{out}"); + Ok(()) +} + +/// A file no `devup.json` governs is reported as unverified. The run is +/// `partial`, never `verified`, and the files are named. +#[tokio::test] +async fn files_without_a_resolvable_devup_json_are_reported_as_unverified() -> anyhow::Result<()> { + let root = monorepo("partial")?; + let full = validate(json!({"paths":["apps/front","shared"],"projectRoot":root})).await; + let summary = validate(json!({"paths":["apps/front","shared"],"projectRoot":root,"detail":"summary"})).await; + let only_shared = validate(json!({"paths":["shared"],"projectRoot":root})).await; + std::fs::remove_dir_all(&root)?; + let full = full?; + assert!(unknown_tokens(&full).is_empty(), "{full}"); + for out in [full, summary?] { + assert_eq!(out["tokens"]["verification"], "partial", "{out}"); + assert_eq!(out["tokens"]["unknownTokenCheckRan"], false, "{out}"); + assert_eq!(out["tokens"]["unverifiedReferences"], 1, "{out}"); + assert_eq!(out["tokens"]["unverifiedFiles"], 1, "{out}"); + assert_eq!(out["themeAvailable"], false, "{out}"); + assert_eq!(out["themeGuardrail"]["unverifiedPaths"], json!(["shared/Badge.tsx"]), "{out}"); + // The project-wide answer still names the themes to choose from. + assert_eq!(out["themeGuardrail"]["candidateProjectRoots"].as_array().map(Vec::len), Some(2), "{out}"); + assert_ne!(out["okReason"], "clean", "{out}"); + let scopes = out["tokens"]["scopes"].as_array().unwrap(); + assert_eq!(scopes[0]["devupJson"], "apps/front/devup.json", "{out}"); + assert_eq!(scopes[1]["status"], "unverified", "{out}"); + assert_eq!(scopes[1]["paths"], json!(["shared/Badge.tsx"]), "{out}"); + } + let out = only_shared?; + assert_eq!(out["tokens"]["verification"], "skipped", "{out}"); + assert_eq!(out["themeAvailable"], false, "{out}"); + assert_eq!(out["tokens"]["definedByTheme"], 0, "{out}"); + assert_ne!(out["okReason"], "clean", "{out}"); + Ok(()) +} + +/// With one theme in the project, a file outside every package still uses it +/// (the single-theme behaviour), but another package's files do not borrow it. +#[tokio::test] +async fn a_single_project_theme_does_not_cross_into_another_package() -> anyhow::Result<()> { + let root = monorepo("boundary")?; + std::fs::remove_file(root.join("apps/admin/devup.json"))?; + let result = validate(json!({"paths":["apps/admin","shared"],"projectRoot":root})).await; + std::fs::remove_dir_all(&root)?; + let out = result?; + // `shared/` has no package of its own: front's theme, the only one, applies. + // `apps/admin` is its own package without a theme: `$adminBg` is not + // called unknown against front's theme, it is unverified. + assert!(unknown_tokens(&out).is_empty(), "{out}"); + assert_eq!(out["tokens"]["verification"], "partial", "{out}"); + assert_eq!(out["themeGuardrail"]["unverifiedPaths"], json!(["apps/admin/src/Panel.tsx"]), "{out}"); + assert_eq!(out["tokens"]["unverifiedReferences"], 2, "{out}"); + let scopes = out["tokens"]["scopes"].as_array().unwrap(); + assert_eq!(scopes[0]["status"], "verified", "{out}"); + assert_eq!(scopes[0]["files"], 1, "{out}"); + assert!(scopes[0]["devupJson"].as_str().unwrap().replace('\\', "/").ends_with("apps/front/devup.json"), "{out}"); + Ok(()) +} + +/// A supplied bundle with two themes used to be refused. It is matched per +/// directory like the disk case, without reading the filesystem. +#[tokio::test] +async fn a_bundle_with_several_themes_is_matched_per_directory() -> anyhow::Result<()> { + let out = validate(json!({"files":[ + {"path":"apps/front/devup.json","content":r##"{"theme":{"colors":{"default":{"bg":"#F7F3EC"}}}}"##}, + {"path":"apps/admin/devup.json","content":r##"{"theme":{"colors":{"default":{"adminBg":"#000000"}}}}"##}, + {"path":"apps/front/src/Hero.tsx","content":r#"export function Hero() { return <Box bg="$bg" /> }"#}, + {"path":"apps/admin/src/Panel.tsx","content":r#"export function Panel() { return <Box bg="$bg" /> }"#}, + {"path":"shared/Badge.tsx","content":r#"export function Badge() { return <Box bg="$bg" /> }"#}, + ]})) + .await?; + assert_eq!(unknown_tokens(&out), [("apps/admin/src/Panel.tsx".to_owned(), "\"$bg\"".to_owned())], "{out}"); + assert_eq!(out["tokens"]["verification"], "partial", "{out}"); + assert_eq!(out["themeGuardrail"]["unverifiedPaths"], json!(["shared/Badge.tsx"]), "{out}"); + assert!(out["themeGuardrail"]["message"].as_str().unwrap().contains("never reads the filesystem"), "{out}"); + Ok(()) +} + +/// A single `tsx` has no path, so at a repository root with several themes +/// its tokens were unverifiable. A relative `sourceName` gives it a location, +/// and the nearest `devup.json` above that location is used. +#[tokio::test] +async fn a_single_tsx_is_located_by_its_source_name() -> anyhow::Result<()> { + let root = monorepo("source-name")?; + let tsx = r#"export function Panel() { return <Box bg="$bg" /> }"#; + let front = validate(json!({"tsx":tsx,"projectRoot":root,"sourceName":"apps/front/src/New.tsx"})).await; + let admin = validate(json!({"tsx":tsx,"projectRoot":root,"sourceName":"apps\\admin\\src\\New.tsx"})).await; + let shared = validate(json!({"tsx":tsx,"projectRoot":root,"sourceName":"shared/New.tsx"})).await; + let label = validate(json!({"tsx":tsx,"projectRoot":root,"sourceName":"New.tsx"})).await; + let escape = validate(json!({"tsx":tsx,"projectRoot":root,"sourceName":"../apps/front/src/New.tsx"})).await; + std::fs::remove_dir_all(&root)?; + + // The named file does not exist: only its directories are inspected. + let out = front?; + assert!(!has_rule(&out, "unknown-token"), "{out}"); + assert_eq!(out["themeAvailable"], true, "{out}"); + assert_eq!(out["tokens"]["devupJson"], "apps/front/devup.json", "{out}"); + assert!(out["themeGuardrail"].is_null(), "{out}"); + + let out = admin?; + assert!(has_rule(&out, "unknown-token"), "{out}"); + assert_eq!(out["tokens"]["devupJson"], "apps/admin/devup.json", "{out}"); + + // No theme above it, a bare label, or a path leaving the project: the + // repository-wide answer stands, and that answer is "not verified". + for out in [shared?, label?, escape?] { + assert!(!has_rule(&out, "unknown-token"), "{out}"); + assert_eq!(out["themeAvailable"], false, "{out}"); + assert_eq!(out["okReason"], "clean-unverified-tokens", "{out}"); + assert!(out["tokens"]["devupJson"].is_null(), "{out}"); + } + Ok(()) +} + +/// One theme in the project is still used for a located tsx outside every +/// package, but not for one inside another package. +#[tokio::test] +async fn a_located_tsx_does_not_borrow_another_packages_theme() -> anyhow::Result<()> { + let root = monorepo("source-boundary")?; + std::fs::remove_file(root.join("apps/admin/devup.json"))?; + let tsx = r#"export function Panel() { return <Box bg="$missing" /> }"#; + let admin = validate(json!({"tsx":tsx,"projectRoot":root,"sourceName":"apps/admin/src/New.tsx"})).await; + let shared = validate(json!({"tsx":tsx,"projectRoot":root,"sourceName":"shared/New.tsx"})).await; + std::fs::remove_dir_all(&root)?; + let out = admin?; + assert!(!has_rule(&out, "unknown-token"), "{out}"); + assert_eq!(out["themeAvailable"], false, "{out}"); + assert_eq!(out["themeGuardrail"]["unverifiedPaths"], json!(["apps/admin/src/New.tsx"]), "{out}"); + assert_eq!(out["okReason"], "clean-unverified-tokens", "{out}"); + let out = shared?; + assert!(has_rule(&out, "unknown-token"), "{out}"); + assert_eq!(out["themeAvailable"], true, "{out}"); + Ok(()) +} diff --git a/harness/runtime-audit/motion-checks.mjs b/harness/runtime-audit/motion-checks.mjs index 5aa5f661..347c983b 100644 --- a/harness/runtime-audit/motion-checks.mjs +++ b/harness/runtime-audit/motion-checks.mjs @@ -19,6 +19,9 @@ export function installMotionProbe(configurations) { requestAnimationFrame(tick); } +// Opt-in reveal scenarios. Those not requested are listed in +// measured.scenariosNotRun so a report never implies they were checked. +export const REVEAL_SCENARIOS = ['fast-scroll','reload','history','image-delay','image-failure','focus','progress-sync']; export async function runMotionCheck(page, options) { const measured = { kind: options?.kind, candidateId: options?.candidateId, samples: [] }; try { @@ -40,6 +43,7 @@ export async function runMotionCheck(page, options) { return {clip:rect(node),track:rect(track),groups:groups.map(rect),texts:groups.map(element=>element.textContent),duplicates:groups.slice(1).every(element=>element.getAttribute('aria-hidden')==='true'&&element.inert),running:animation?.playState==='running',duration:animation?.effect?.getTiming().duration,iteration:animation?.effect?.getComputedTiming().currentIteration,keyframeDelta:frames.length>1?translate(frames.at(-1))-translate(frames[0]):null,gap:Number.parseFloat(getComputedStyle(track).columnGap)||0}; },options); const take = async () => { const value=await sample();measured.samples.push(value);return value; }; + const sample_ = sample; let valid=false; if(options.kind==='count-up') { if(!Number.isFinite(options.finalValue))throw new Error('count-up requires expected finalValue'); @@ -94,7 +98,132 @@ export async function runMotionCheck(page, options) { const connected=await host.evaluate((node,selector)=>node===document.querySelector(selector),options.target)&&await indicator.evaluate(node=>node.isConnected); valid=connected&&middle.animations>0&&last.selected&&Math.abs(last.indicator.x-last.selected.x)<1&&Math.abs(last.indicator.width-last.selected.width)<1&&Math.abs(middle.indicator.x-first.indicator.x)>.1&&Math.abs(middle.indicator.x-last.indicator.x)>.1; } else { - const before=await take();await target.scrollIntoViewIfNeeded();await page.waitForTimeout(Math.min(120,ms*.25));const middle=await take();await page.waitForTimeout(ms+30);const last=await take();valid=before.opacity===1&&middle.opacity>0&&middle.opacity<1&&middle.translate>0&&last.opacity===1&&Math.abs(last.translate)<.1; + const before=await take(); + // Scroll toward the target in small steps and record how much of it was + // on screen when the entrance began: starting while it is still off + // screen, or only after it has been fully visible for a while, fails. + const trigger=await target.evaluate(async node=> { + const frame=()=>new Promise(resolve=>requestAnimationFrame(resolve)); + // Where the block sits in layout: the entrance's own translation must not count as "not yet visible". + const ratio=()=>{const box=node.getBoundingClientRect(),shift=new DOMMatrixReadOnly(getComputedStyle(node).transform).m42,top=box.top-shift,bottom=box.bottom-shift;return Math.max(0,Math.min(bottom,innerHeight)-Math.max(top,0))/Math.min(box.height||1,innerHeight);}; + // How far outside the viewport the block's layout box is. An observer + // reports a block whose edge touches the viewport, so 1px is not "off screen". + const distance=()=>{const box=node.getBoundingClientRect(),shift=new DOMMatrixReadOnly(getComputedStyle(node).transform).m42;return Math.max(0,box.top-shift-innerHeight,-(box.bottom-shift));}; + const started=()=>node.getAnimations().length>0||Number(getComputedStyle(node).opacity)<1; + if(ratio()>0)return {verified:false,reason:'target is already in view'}; + if(started())return {verified:true,startedOffscreen:true,late:false,visibleRatio:0,offscreenPx:distance()}; + const step=Math.max(8,Math.round(innerHeight*.05));let fullSince=null; + for(let index=0;index<4000;index++) { + const top=node.getBoundingClientRect().top,position=scrollY;scrollBy(0,top>0?step:-step);await frame();await frame(); + const visible=ratio(); + if(started())return {verified:true,startedOffscreen:distance()>1,late:false,visibleRatio:visible,offscreenPx:distance()}; + if(visible>=.99||scrollY===position) {fullSince??=performance.now();if(performance.now()-fullSince>400)return {verified:true,startedOffscreen:false,late:true,visibleRatio:visible};} + } + return {verified:true,startedOffscreen:false,late:true,visibleRatio:ratio()}; + }); + measured.trigger=trigger; + if(!trigger.verified)await target.scrollIntoViewIfNeeded(); + await page.waitForTimeout(Math.min(120,ms*.25));const middle=await take();await page.waitForTimeout(ms+30);const last=await take();valid=before.opacity===1&&middle.opacity>0&&middle.opacity<1&&middle.translate>0&&last.opacity===1&&Math.abs(last.translate)<.1&&!(trigger.verified&&(trigger.startedOffscreen||trigger.late)); + // A reveal plays once. Leave the viewport and come back: the content + // must stay shown while away (never re-hidden) and must not replay. + const left=await target.evaluate(node=> { + const box=node.getBoundingClientRect(),top=scrollY+box.top,limit=document.documentElement.scrollHeight-innerHeight; + const destination=[0,limit].find(y=>top+box.height<=y||top>=y+innerHeight); + if(destination===undefined)return false; + scrollTo(0,destination);return true; + }); + if(left) { + await page.waitForTimeout(60);const away=await take();await target.scrollIntoViewIfNeeded();await page.waitForTimeout(Math.min(120,ms*.25));const reentered=await take();await page.waitForTimeout(ms+30);const settled=await take(); + const shown=sample=>sample.opacity===1&&Math.abs(sample.translate)<.1; + measured.reentry={verified:true,replayed:!shown(reentered),hiddenWhileAway:!shown(away)}; + valid=valid&&shown(away)&&shown(reentered)&&shown(settled); + } else { + // Nothing was observed, so nothing is claimed: this is not a pass. + measured.reentry={verified:false,reason:'target cannot leave the viewport at this size'}; + if(valid)return {status:'fail',reason:'Reveal re-entry could not be observed: the target never leaves the viewport at this size. Check it at a viewport where it can scroll out.',measured}; + } + // Requested scenarios each start from a fresh load. One that cannot be + // observed (no image, no focusable control, scroll not restored) fails: + // an unobserved scenario is not a passed one. + const requested=options.scenarios??[]; + measured.scenarios={};measured.scenariosNotRun=REVEAL_SCENARIOS.filter(name=>!requested.includes(name)); + const shown=sample=>sample.opacity===1&&Math.abs(sample.translate)<.1; + const settle=options.settleMs??400,height=last.height; + // A reload restores the scroll position, so start from the top: the target must not already be on screen when the page loads. + const fresh=async(waitUntil='load')=>{await page.evaluate(()=>scrollTo(0,0));await page.reload({waitUntil});await page.waitForTimeout(settle);}; + // Watches the target for a while and reports whether it was ever hidden + // or displaced while on screen. + const watch=()=>target.evaluate(async (node,ms)=> { + const box=node.getBoundingClientRect();if(box.bottom<=0||box.top>=innerHeight)return {verified:false,reason:'scroll position was not restored onto the target'}; + const end=performance.now()+ms;let minOpacity=1,maxShift=0; + while(performance.now()<end) {const style=getComputedStyle(node);minOpacity=Math.min(minOpacity,Number(style.opacity));maxShift=Math.max(maxShift,Math.abs(new DOMMatrixReadOnly(style.transform).m42));await new Promise(resolve=>requestAnimationFrame(resolve));} + return {verified:true,minOpacity,maxShift,flashed:minOpacity<1||maxShift>.1}; + },ms+600); + for(const name of requested) { + let result; + try { + if(name==='fast-scroll') { + await fresh(); + await page.evaluate(async()=>{for(let index=0;index<400&&scrollY<document.documentElement.scrollHeight-innerHeight-1;index++){scrollBy(0,innerHeight*1.5);await new Promise(resolve=>requestAnimationFrame(resolve));}}); + await target.evaluate(node=>node.scrollIntoView({block:'center'}));await page.waitForTimeout(ms+150); + const sample=await sample_();result={status:shown(sample)?'pass':'fail',sample}; + } else if(name==='reload'||name==='history') { + await fresh();await target.evaluate(node=>node.scrollIntoView({block:'center'}));await page.waitForTimeout(ms+150); + if(name==='reload')await page.reload({waitUntil:'load'}); + else {const url=page.url();await page.goto('about:blank');await page.goBack({waitUntil:'load'});if(page.url()!==url)throw new Error('back navigation did not return to the audited URL');} + const observed=await watch();result={status:observed.verified&&!observed.flashed?'pass':'fail',...observed}; + } else if(name==='image-delay'||name==='image-failure') { + const images=await target.locator('img').count(); + if(!images)result={status:'fail',verified:false,reason:'target contains no image'}; + else { + const handler=async route=>{if(route.request().resourceType()!=='image')return route.continue();if(name==='image-failure')return route.abort();await new Promise(resolve=>setTimeout(resolve,ms+1500));return route.continue().catch(()=>{});}; + await page.route('**/*',handler); + try {await fresh('domcontentloaded');await target.evaluate(node=>node.scrollIntoView({block:'center'}));await page.waitForTimeout(ms+150);const sample=await sample_();result={status:shown(sample)&&Math.abs(sample.height-height)<1?'pass':'fail',sample,reservedHeight:height};} + finally {await page.unroute('**/*',handler);} + } + } else if(name==='focus') { + await fresh(); + const focused=await target.evaluate(async (node,selector)=> { + const control=node.querySelector(selector);if(!control)return false; + control.focus();for(let index=0;index<3;index++)await new Promise(resolve=>requestAnimationFrame(resolve)); + return document.activeElement===control; + },options.focus??'a[href],button,input,select,textarea,[tabindex]'); + if(!focused)result={status:'fail',verified:false,reason:'target has no focusable control that takes focus'}; + else {await page.waitForTimeout(50);const sample=await sample_();result={status:shown(sample)?'pass':'fail',sample};} + } else if(name==='progress-sync') { + // A progress line and the items it connects share one timeline: + // each item appears as the line reaches it, within a tolerance. + if(typeof options.progress!=='string'||typeof options.items!=='string')result={status:'fail',verified:false,reason:'progress-sync needs progress and items selectors'}; + else { + await fresh(); + const tolerance=options.syncToleranceMs??200; + const observed=await target.evaluate(async (node,config)=> { + const line=node.querySelector(config.progress),items=[...node.querySelectorAll(config.items)]; + if(!line||items.length<2)return {verified:false,reason:'progress line or at least two items not found'}; + node.scrollIntoView({block:'center'}); + const start=performance.now(),shownAt=items.map(()=>null),reachedAt=items.map(()=>null);let first=null,vertical=null; + while(performance.now()-start<config.ms) { + const box=line.getBoundingClientRect(),now=performance.now()-start;first??={width:box.width,height:box.height}; + if(vertical===null&&(Math.abs(box.height-first.height)>1||Math.abs(box.width-first.width)>1))vertical=Math.abs(box.height-first.height)>=Math.abs(box.width-first.width); + items.forEach((item,index)=> { + const rect=item.getBoundingClientRect(); + // Server markup is visible until the entrance hides it, so the time that counts is the last rise above half opacity. + const opacity=Number(getComputedStyle(item).opacity);if(opacity<.5)shownAt[index]=null;else if(shownAt[index]===null)shownAt[index]=now; + if(reachedAt[index]===null&&vertical!==null&&(vertical?box.bottom>=rect.top-1:box.right>=rect.left-1))reachedAt[index]=now; + }); + await new Promise(resolve=>requestAnimationFrame(resolve)); + } + if(vertical===null)return {verified:false,reason:'progress line never advanced'}; + return {verified:true,axis:vertical?'vertical':'horizontal',shownAt,reachedAt,gaps:items.map((_,index)=>shownAt[index]===null||reachedAt[index]===null?null:shownAt[index]-reachedAt[index])}; + },{progress:options.progress,items:options.items,ms:options.progressMs??ms*3+600}); + result={status:observed.verified&&observed.gaps.every(gap=>gap!==null&&Math.abs(gap)<=tolerance)?'pass':'fail',toleranceMs:tolerance,...observed}; + } + } else result={status:'fail',reason:'unknown scenario'}; + } catch(error) {result={status:'fail',reason:String(error.message??error)};} + measured.scenarios[name]=result; + } + const failed=Object.entries(measured.scenarios).filter(([,result])=>result.status!=='pass').map(([name])=>name); + if(failed.length)return {status:'fail',reason:`Reveal scenario failed or could not be observed: ${failed.join(', ')}`,measured}; } if(options.resizeWidth) { await page.setViewportSize({...page.viewportSize(),width:options.resizeWidth});await page.waitForTimeout(ms+30);const resized=await take(); diff --git a/harness/runtime-audit/test/motion.test.mjs b/harness/runtime-audit/test/motion.test.mjs index 42f96876..ad32f687 100644 --- a/harness/runtime-audit/test/motion.test.mjs +++ b/harness/runtime-audit/test/motion.test.mjs @@ -5,7 +5,7 @@ import {join,resolve,dirname} from 'node:path'; import {fileURLToPath} from 'node:url'; import {build,preview} from 'vite'; import {chromium} from 'playwright'; -import {installMotionProbe,runMotionCheck} from '../motion-checks.mjs'; +import {installMotionProbe,runMotionCheck, REVEAL_SCENARIOS} from '../motion-checks.mjs'; const harness=resolve(dirname(fileURLToPath(import.meta.url)),'..'),repo=resolve(harness,'../..'); @@ -21,8 +21,8 @@ test('shipped motion controllers execute counts, seamless marquee, cycling, pers <nav id="tabs"><span id="indicator"></span><a href="/one" aria-current="page">One</a><a href="/two">Two</a></nav> <section id="steps"><button aria-selected="true">Step one<span class="bar"></span></button><button aria-selected="false">Step two<span class="bar"></span></button><button aria-selected="false">Step three<span class="bar"></span></button><article>One panel</article><article hidden>Two panel</article><article hidden>Three panel</article></section> <div id="marquee"><div class="track"><div class="message">Regenerating Joints · Restoring Lives</div></div></div> - <div style="height:950px"></div><span id="count">8.52%</span><div style="height:850px"></div><section id="reveal" class="reveal">Readable server section</section><div style="height:900px"></div><div id="controller"></div><script type="module" src="/main.tsx"></script></body></html>`; - await writeFile(join(dir,'index.html'),html); + <div style="height:950px"></div><span id="count">8.52%</span><div style="height:850px"></div><section id="reveal" class="reveal">Readable server section <a href="/one">Link</a><img src="/pixel.png" width="40" height="40" alt=""></section><div style="height:900px"></div><div id="controller"></div><script type="module" src="/main.tsx"></script></body></html>`; + await writeFile(join(dir,'index.html'),html);await mkdir(join(dir,'public'),{recursive:true});await writeFile(join(dir,'public','pixel.png'),Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==','base64')); await writeFile(join(dir,'main.tsx'),`import {createElement} from 'react';import {createRoot} from 'react-dom/client';import {MotionController} from './MotionController'; const metric=Number(new URL(location.href).searchParams.get('value')??8.52);const bindings=[{kind:'marquee',target:'#marquee',track:'.track',group:'.message',pixelsPerSecond:800,respectReducedMotion:false},{kind:'count-up',target:'#count',value:metric,precision:metric===2000?0:2,unit:metric===2000?'건':'%',durationMs:600},{kind:'progress-step-cycle',target:'#steps',steps:'button',panels:'article',progress:'.bar',durationMs:500,respectReducedMotion:false},{kind:'sliding-route-highlight',target:'#tabs',links:'a',indicator:'#indicator',durationMs:500},{kind:'section-entrance',target:'.reveal',durationMs:600}]; const root=createRoot(document.querySelector('#controller'));root.render(createElement(MotionController,{bindings}));window.stopMotion=()=>root.unmount(); @@ -36,7 +36,7 @@ test('shipped motion controllers execute counts, seamless marquee, cycling, pers {kind:'count-up',target:'#count',finalValue:-8.52,precision:2,durationMs:600,urlValue:-8.52}, {kind:'steps',target:'#steps',steps:'button',progress:'.bar',manual:'#steps button:nth-of-type(3)',manualIndex:2,durationMs:500,resizeWidth:700}, {kind:'tabs',target:'#tabs',links:'a',indicator:'#indicator',next:'#tabs a:last-child',durationMs:500,resizeWidth:700}, - {kind:'reveal',target:'.reveal',durationMs:600}, + {kind:'reveal',target:'.reveal',durationMs:600,scenarios:REVEAL_SCENARIOS.filter(name=>name!=='progress-sync')}, ]) { const page=await browser.newPage({viewport:{width:360,height:700}}); await page.addInitScript(installMotionProbe,[{...options,candidateId:`${options.kind}:fixture`}]); @@ -45,7 +45,7 @@ test('shipped motion controllers execute counts, seamless marquee, cycling, pers if(options.kind==='count-up') {await page.locator(options.target).scrollIntoViewIfNeeded();await page.waitForTimeout(800);} const report=await runMotionCheck(page,{...options,candidateId:`${options.kind}:fixture`});assert.equal(report.status,'pass',JSON.stringify(report)); if(options.kind==='tabs') {const back=await runMotionCheck(page,{...options,next:'#tabs a:first-of-type'});assert.equal(back.status,'pass',JSON.stringify(back));} - if(options.kind==='reveal') {await page.evaluate(()=>{const section=document.createElement('section');section.id='newReveal';section.className='reveal';section.textContent='New route section';section.style.cssText='height:200px;margin-top:900px';document.body.append(section);});const inserted=await runMotionCheck(page,{...options,target:'#newReveal'});assert.equal(inserted.status,'pass',JSON.stringify(inserted));} + if(options.kind==='reveal') {await page.evaluate(()=>{const section=document.createElement('section');section.id='newReveal';section.className='reveal';section.textContent='New route section';section.style.cssText='height:200px;margin-top:900px';document.body.append(section);});const inserted=await runMotionCheck(page,{...options,scenarios:[],target:'#newReveal'});assert.equal(inserted.status,'pass',JSON.stringify(inserted));} await page.evaluate(()=>window.stopMotion());assert.equal(await page.locator('.track > *').count(),1);assert.equal(await page.evaluate(()=>document.getAnimations().filter(a=>a.playState==='running').length),0); await page.close(); } @@ -113,3 +113,126 @@ test('marquee audit rejects reversed motion and an empty leftward loop seam', as } } finally {await browser.close();} }); + +test('reveal audit requires one-shot playback: a replay, a re-hide and an unobservable re-entry all fail', {timeout:60000}, async()=> { + const browser=await chromium.launch(); + try { + // Each page reveals #reveal with a 300ms fade and upward move on viewport + // entry. `mode` decides what happens when it leaves and comes back. + const run=async(mode,body='<div style="height:1400px"></div><section id="reveal" style="height:200px">Readable server section</section><div style="height:1400px"></div>')=> { + const page=await browser.newPage({viewport:{width:360,height:700}}); + await page.setContent(`<body style="margin:0">${body}</body>`); + await page.evaluate(mode=> { + const node=document.querySelector('#reveal');let played=false; + new IntersectionObserver(entries=>entries.forEach(entry=> { + if(!entry.isIntersecting) {if(mode==='rehide')node.style.opacity='0';return;} + if(mode==='rehide')node.style.opacity=''; + if(played&&mode!=='replay')return; + played=true;node.animate([{opacity:0,transform:'translateY(24px)'},{opacity:1,transform:'translateY(0)'}],{duration:300,easing:'linear'}); + })).observe(node); + },mode); + const report=await runMotionCheck(page,{kind:'reveal',candidateId:`reveal:${mode}`,target:'#reveal',durationMs:300}); + await page.close();return report; + }; + const once=await run('once'); + assert.equal(once.status,'pass',JSON.stringify(once)); + assert.deepEqual(once.measured.reentry,{verified:true,replayed:false,hiddenWhileAway:false}); + const replay=await run('replay'); + assert.equal(replay.status,'fail',JSON.stringify(replay)); + assert.equal(replay.measured.reentry.replayed,true,JSON.stringify(replay)); + const rehide=await run('rehide'); + assert.equal(rehide.status,'fail',JSON.stringify(rehide)); + assert.equal(rehide.measured.reentry.hiddenWhileAway,true,JSON.stringify(rehide)); + // A page too short to scroll the target away proves nothing about re-entry. + const short=await run('once','<section id="reveal" style="height:200px">Readable server section</section>'); + assert.equal(short.status,'fail',JSON.stringify(short)); + assert.equal(short.measured.reentry?.verified,false,JSON.stringify(short)); + } finally {await browser.close();} +}); + +test('reveal audit measures trigger position and the requested fresh-load scenarios, and fails what it cannot observe', {timeout:300000}, async()=> { + const {createServer}=await import('node:http'); + const pixel=Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==','base64'); + // One page, several implementations of the same reveal. `good` plays once + // when the section enters and leaves content alone when it is already on + // screen or focused; every other mode breaks exactly one rule. + const page=(mode,bare)=>`<!doctype html><body style="margin:0"><div style="height:1400px"></div> + <section id="reveal" style="height:200px">${bare?'Readable server section':'<a href="#x">Link</a><img src="/pixel.png" width="40" height="40" alt="">'}</section> + <div style="height:1400px"></div><script> + const mode=${JSON.stringify(mode)},node=document.querySelector('#reveal');let played=false,first=true; + const play=()=>node.animate([{opacity:0,transform:'translateY(24px)'},{opacity:1,transform:'translateY(0)'}],{duration:300,easing:'linear'}); + if(mode==='image-gated'){node.style.opacity='0';node.querySelector('img').addEventListener('load',()=>{node.style.opacity='';});} + new IntersectionObserver(entries=>entries.forEach(entry=> { + const initial=first;first=false; + if(!entry.isIntersecting||played)return;played=true; + if(mode==='stuck'){node.style.opacity='0';return;} + if(mode==='late'){setTimeout(play,900);return;} + if(initial&&mode!=='flash')return; + if(node.contains(document.activeElement)&&mode!=='focusanim')return; + play(); + }),{rootMargin:mode==='early'?'600px':'0px'}).observe(node); + </script></body>`; + // A timeline: a vertical line grows over 900ms while three steps fade in. + // `good` delays each step until the line reaches it; `bad` shows all at once. + const timeline=mode=>`<!doctype html><body style="margin:0"><div style="height:1400px"></div> + <section id="reveal" style="position:relative;height:400px"><i class="line" style="position:absolute;left:0;top:0;width:2px;height:100%;background:blue;transform-origin:top"></i> + ${[0,1,2].map(index=>`<p class="step" style="position:absolute;left:20px;top:${index*133}px;margin:0;height:40px">Step ${index+1}</p>`).join('')}</section> + <div style="height:1400px"></div><script> + const node=document.querySelector('#reveal');let played=false; + new IntersectionObserver(entries=>entries.forEach(entry=> { + if(!entry.isIntersecting||played)return;played=true; + node.querySelector('.line').animate([{transform:'scaleY(0)'},{transform:'scaleY(1)'}],{duration:900,easing:'linear',fill:'both'}); + node.querySelectorAll('.step').forEach((step,index)=>step.animate([{opacity:0},{opacity:1}],{duration:300,delay:${JSON.stringify(mode)}==='good'?index*300:0,easing:'linear',fill:'both'})); + })).observe(node); + </script></body>`; + const server=createServer((request,response)=> { + const url=new URL(request.url,'http://local'); + if(url.pathname==='/pixel.png'){response.writeHead(200,{'content-type':'image/png','cache-control':'no-store'});response.end(pixel);return;} + response.writeHead(200,{'content-type':'text/html','cache-control':'no-store'});response.end(url.searchParams.has('timeline')?timeline(url.searchParams.get('mode')):page(url.searchParams.get('mode'),url.searchParams.has('bare'))); + }); + await new Promise(done=>server.listen(0,'127.0.0.1',done)); + const browser=await chromium.launch(); + try { + const run=async(mode,scenarios,bare=false,extra={})=> { + const tab=await browser.newPage({viewport:{width:360,height:700}}); + await tab.goto(`http://127.0.0.1:${server.address().port}/?mode=${mode}${bare?'&bare':''}${extra.progress?'&timeline':''}`); + const report=await runMotionCheck(tab,{kind:'reveal',candidateId:`reveal:${mode}`,target:'#reveal',durationMs:300,settleMs:150,scenarios,...extra}); + await tab.close();return report; + }; + const pageScenarios=REVEAL_SCENARIOS.filter(name=>name!=='progress-sync'); + const good=await run('good',pageScenarios); + assert.equal(good.status,'pass',JSON.stringify(good)); + assert.equal(good.measured.trigger.verified,true);assert.equal(good.measured.trigger.startedOffscreen,false,JSON.stringify(good.measured.trigger));assert.ok(good.measured.trigger.offscreenPx<=1); + assert.deepEqual(Object.entries(good.measured.scenarios).map(([name,result])=>[name,result.status]),pageScenarios.map(name=>[name,'pass'])); + assert.deepEqual(good.measured.scenariosNotRun,['progress-sync']); + // Progress line and items: in step passes, all-at-once fails, and a block + // without a line (or without the selectors) observed nothing. + const selectors={progress:'.line',items:'.step'}; + const synced=(await run('good',['progress-sync'],false,selectors)).measured.scenarios['progress-sync']; + assert.equal(synced.status,'pass',JSON.stringify(synced));assert.equal(synced.axis,'vertical'); + const unsynced=(await run('bad',['progress-sync'],false,selectors)).measured.scenarios['progress-sync']; + assert.equal(unsynced.status,'fail',JSON.stringify(unsynced));assert.ok(unsynced.gaps[2]<-200,JSON.stringify(unsynced)); + for(const report of [await run('good',['progress-sync']),await run('good',['progress-sync'],false,{items:'a',progress:'img'})]) { + assert.equal(report.status,'fail',JSON.stringify(report));assert.equal(report.measured.scenarios['progress-sync'].verified,false); + } + // Nothing requested: every scenario is named as not run, never implied. + const plain=await run('good',undefined); + assert.equal(plain.status,'pass',JSON.stringify(plain));assert.deepEqual(plain.measured.scenariosNotRun,REVEAL_SCENARIOS); + // Trigger timing. + const early=await run('early',[]); + assert.equal(early.status,'fail',JSON.stringify(early));assert.equal(early.measured.trigger.startedOffscreen,true);assert.ok(early.measured.trigger.offscreenPx>100,JSON.stringify(early.measured.trigger)); + const late=await run('late',[]); + assert.equal(late.status,'fail',JSON.stringify(late));assert.equal(late.measured.trigger.late,true); + // One broken rule per mode, caught by its own scenario. + for(const [mode,scenario] of [['flash','reload'],['flash','history'],['stuck','fast-scroll'],['focusanim','focus'],['image-gated','image-failure'],['image-gated','image-delay']]) { + const report=await run(mode,[scenario]); + assert.equal(report.status,'fail',JSON.stringify(report)); + assert.equal(report.measured.scenarios[scenario].status,'fail',`${mode}/${scenario}: ${JSON.stringify(report.measured.scenarios)}`); + } + const flash=await run('flash',['reload']);assert.equal(flash.measured.scenarios.reload.flashed,true,JSON.stringify(flash.measured.scenarios)); + // No image and no control: the scenario observed nothing, so it fails. + const bare=await run('good',['image-failure','focus'],true); + assert.equal(bare.status,'fail',JSON.stringify(bare)); + assert.equal(bare.measured.scenarios['image-failure'].verified,false);assert.equal(bare.measured.scenarios.focus.verified,false); + } finally {await browser.close();await new Promise(done=>server.close(done));} +});