diff --git a/.changepacks/changepack_log_kF8mQ2vX7pLnR4tZc9wYh.json b/.changepacks/changepack_log_kF8mQ2vX7pLnR4tZc9wYh.json new file mode 100644 index 00000000..7a4f250c --- /dev/null +++ b/.changepacks/changepack_log_kF8mQ2vX7pLnR4tZc9wYh.json @@ -0,0 +1,7 @@ +{ + "changes": { + "Cargo.toml": "Minor" + }, + "note": "Entrance and transition motion is now Devup UI static keyframes instead of a script-driven or framer-motion entrance: exports ship Reveal.tsx (keyframes on a view timeline, with staggered groups and progress lines), the shared motion runtime only marks state for browsers without view timelines, and the animation guide, contract and behaviorPlan name the keyframes form. devup_ui_validate reads keyframes and CSS scroll-timeline usage in every entrance rule (ssr-hidden-reveal, reveal-replays, reveal-moves-background, stagger-too-long, motion-without-reduced-motion) and adds entrance-uses-motion-library, entrance-on-static-content and entrance-per-element. devup_runtime_audit reveal checks measure scroll-linked and timed entrances, run every scenario by default (adding bfcache, client-nav, reduced-motion, no-script and stagger bounds), report not-applicable and unverified instead of passing what was not observed, and ingest returns motionEvidence per candidate at mobile and desktop widths. The export reply moves standing prose to the guide, restoring the contract identifiers renamed earlier and leaving over 1 KiB of headroom under the 15 KiB budget. A single tsx at a monorepo root is matched to its owning app's devup.json from an absolute or partial sourceName or by its text.", + "date": "2026-11-10T00:00:00.000000000Z" +} diff --git a/crates/devup-mcp-devup-ui/src/ui_validate/motion_keyframes.rs b/crates/devup-mcp-devup-ui/src/ui_validate/motion_keyframes.rs new file mode 100644 index 00000000..53e80824 --- /dev/null +++ b/crates/devup-mcp-devup-ui/src/ui_validate/motion_keyframes.rs @@ -0,0 +1,431 @@ +//! Entrance motion written with Devup UI's static `keyframes()` and CSS +//! animation props, read the same way `motion_quality` reads framer-motion. +//! +//! A *style bag* is one element's styles as the source states them: the +//! props of a JSX element, or the object handed to `css()`/`styled()`. An +//! *entrance* is a bag whose animation names keyframes that start hidden +//! (`from: { opacity: 0 }`), or that move and are driven by a view timeline. +//! A looping animation (`infinite`) is a loader or a pulse, not an entrance. +use super::{BACKGROUND_PROPS, is_zero, property}; +use oxc_ast::ast::*; +use oxc_span::Span; + +/// What the first frame of a `keyframes()` call does. +#[derive(Clone, Copy, Default)] +pub(super) struct Frames { + hides: bool, + moves: bool, +} + +/// `keyframes({ from: {…}, to: {…} })`, also as `Devup.keyframes(…)`. +fn keyframes_object<'b, 'a>(expression: &'b Expression<'a>) -> Option<&'b ObjectExpression<'a>> { + let Expression::CallExpression(call) = expression.get_inner_expression() else { + return None; + }; + let named = match call.callee.get_inner_expression() { + Expression::Identifier(callee) => callee.name == "keyframes", + Expression::StaticMemberExpression(member) => member.property.name == "keyframes", + _ => false, + }; + match call.arguments.first() { + Some(Argument::ObjectExpression(object)) if named => Some(object), + _ => None, + } +} + +fn frames(object: &ObjectExpression<'_>) -> Frames { + let Some(Expression::ObjectExpression(first)) = ["from", "0%"].iter().find_map(|key| property(object, key)) else { + return Frames::default(); + }; + Frames { + hides: property(first, "opacity").is_some_and(is_zero) + || matches!(property(first, "visibility"), Some(Expression::StringLiteral(text)) if text.value == "hidden"), + moves: ["transform", "translate", "scale"].iter().any(|key| property(first, key).is_some()), + } +} + +/// `const rise = keyframes({…})` declarations of this file, by name. +pub(super) struct Declared(pub Vec<(String, Frames)>); + +impl Declared { + pub(super) fn record(&mut self, node: &VariableDeclarator<'_>) { + if let (Some(name), Some(object)) = (node.id.get_binding_identifier(), node.init.as_ref().and_then(keyframes_object)) { + self.0.push((name.name.to_string(), frames(object))); + } + } + + /// The keyframes an `animationName`/`animation` value refers to: a + /// declared name, an inline `keyframes()` call, or a template holding one. + fn referenced(&self, expression: &Expression<'_>) -> Option { + match expression.get_inner_expression() { + Expression::Identifier(name) => self.0.iter().find(|(declared, _)| declared == name.name.as_str()).map(|(_, frames)| *frames), + Expression::TemplateLiteral(template) => template.expressions.iter().find_map(|part| self.referenced(part)), + other => keyframes_object(other).map(frames), + } + } +} + +struct Entry<'b, 'a> { + key: &'b str, + expression: Option<&'b Expression<'a>>, + text: Option<&'b str>, + span: Span, +} + +/// One element's styles, from JSX props or from a style object. +pub(super) struct Bag<'b, 'a>(Vec>); + +fn literal_text<'b>(expression: &'b Expression<'_>) -> Option<&'b str> { + match expression { + Expression::StringLiteral(text) => Some(text.value.as_str()), + Expression::TemplateLiteral(template) if template.expressions.is_empty() => { + template.quasis.first().map(|quasi| quasi.value.raw.as_str()) + } + _ => None, + } +} + +impl<'b, 'a> Bag<'b, 'a> { + pub(super) fn of_element(node: &'b JSXOpeningElement<'a>) -> Self { + Self( + node.attributes + .iter() + .filter_map(|item| match item { + JSXAttributeItem::Attribute(attribute) => Some(&**attribute), + JSXAttributeItem::SpreadAttribute(_) => None, + }) + .filter_map(|attribute| { + let JSXAttributeName::Identifier(name) = &attribute.name else { + return None; + }; + let expression = super::expression(attribute); + let text = match attribute.value.as_ref() { + Some(JSXAttributeValue::StringLiteral(value)) => Some(value.value.as_str()), + _ => expression.and_then(literal_text), + }; + Some(Entry { key: name.name.as_str(), expression, text, span: attribute.span }) + }) + .collect(), + ) + } + + pub(super) fn of_object(object: &'b ObjectExpression<'a>) -> Self { + Self( + object + .properties + .iter() + .filter_map(|item| match item { + ObjectPropertyKind::ObjectProperty(entry) => Some(&**entry), + ObjectPropertyKind::SpreadProperty(_) => None, + }) + .filter_map(|entry| { + let key = match &entry.key { + PropertyKey::StaticIdentifier(name) => name.name.as_str(), + PropertyKey::StringLiteral(name) => name.value.as_str(), + _ => return None, + }; + let expression = entry.value.get_inner_expression(); + Some(Entry { key, expression: Some(expression), text: literal_text(expression), span: entry.span }) + }) + .collect(), + ) + } + + fn entry(&self, key: &str) -> Option<&Entry<'b, 'a>> { + self.0.iter().find(|entry| entry.key == key) + } + + fn text(&self, key: &str) -> Option<&'b str> { + self.entry(key).and_then(|entry| entry.text) + } + + /// Whether the bag states any animation at all (cheap pre-filter). + pub(super) fn animates(&self) -> bool { + self.0.iter().any(|entry| entry.key.starts_with("animation") || entry.key == "transitionDelay") + } + + pub(super) fn paints_background(&self) -> bool { + BACKGROUND_PROPS.iter().any(|name| self.entry(name).is_some()) + } + + fn loops(&self) -> bool { + let shorthand = self.entry("animation").and_then(|entry| entry.expression).is_some_and(|expression| match expression { + Expression::TemplateLiteral(template) => template.quasis.iter().any(|quasi| quasi.value.raw.contains("infinite")), + Expression::StringLiteral(text) => text.value.contains("infinite"), + _ => false, + }); + shorthand || self.text("animationIterationCount") == Some("infinite") + } + + fn view_timeline(&self) -> bool { + self.text("animationTimeline").is_some_and(|timeline| timeline.contains("view(")) + } + + /// The entrance this bag declares, and where. + pub(super) fn entrance(&self, declared: &Declared) -> Option { + if self.loops() { + return None; + } + let (span, frames) = ["animationName", "animation"].iter().find_map(|key| { + let entry = self.entry(key)?; + Some((entry.span, declared.referenced(entry.expression?)?)) + })?; + let scroll = self.view_timeline(); + (frames.hides || (scroll && frames.moves)).then_some(Entrance { span, hides: frames.hides, scroll }) + } + + /// The resting style is hidden: `opacity: 0` or `visibility: hidden` + /// stated on the element itself, outside any selector. + pub(super) fn rests_hidden(&self) -> Option { + let opacity = self.entry("opacity").filter(|entry| { + entry.text.is_some_and(|text| text.trim() == "0") || entry.expression.is_some_and(is_zero) + }); + opacity.or_else(|| self.entry("visibility").filter(|entry| entry.text == Some("hidden"))).map(|entry| entry.span) + } + + /// A selector keyed on a marker that only a script sets (`[data-…]`, a + /// class) is what shows the content again. + fn shown_by_marker(&self) -> bool { + let Some(Expression::ObjectExpression(selectors)) = self.entry("selectors").and_then(|entry| entry.expression) else { + return false; + }; + let nested = Bag::of_object(selectors); + nested.0.iter().any(|entry| { + let marker = entry.key.contains("[data-") || entry.key.contains("&.") || entry.key.starts_with('.'); + let shows = matches!(entry.expression, Some(Expression::ObjectExpression(style)) if { + let style = Bag::of_object(style); + style.entry("opacity").is_some_and(|opacity| !(opacity.text.is_some_and(|text| text.trim() == "0") || opacity.expression.is_some_and(is_zero))) + || style.text("visibility") == Some("visible") + || style.entry("animationName").is_some() + || style.entry("animation").is_some() + }); + marker && shows + }) + } + + /// `ssr-hidden-reveal` in keyframes form: where and why, when the + /// content is invisible until something other than CSS shows it. + pub(super) fn hidden_until_shown(&self, entrance: Option<&Entrance>) -> Option<(Span, &'static str)> { + if let Some(span) = self.rests_hidden() { + if entrance.is_some() { + return Some((span, "This content rests at opacity 0 and appears only while its animation runs; reduced motion, a blocked stylesheet rule or an unsupported timeline leaves it invisible.")); + } + if self.shown_by_marker() { + return Some((span, "Server markup renders this content hidden until a script sets the attribute or class that shows it.")); + } + } + let paused = self.entry("animationPlayState").filter(|entry| entry.text == Some("paused"))?; + entrance + .filter(|entrance| entrance.hides) + .map(|_| (paused.span, "This entrance is paused on its hidden first frame until a script resumes it.")) + } + + /// A view-timeline entrance must end early in the entry: with no range + /// (or one that reaches the exit) the content is still part-transparent + /// in the middle of the screen and fades out again as it leaves. + pub(super) fn scroll_range_replays(&self, entrance: &Entrance) -> Option { + if !entrance.scroll { + return None; + } + let range = self.entry("animationRange").or_else(|| self.entry("animationRangeEnd")); + match range { + None => Some(entrance.span), + // Built elsewhere: unknown, so not reported. + Some(entry) => entry.text.filter(|text| !range_ends_early(text)).map(|_| entry.span), + } + } + + /// The per-item step of a staggered `animationDelay`/`transitionDelay`. + pub(super) fn stagger_step(&self) -> Option<(Span, f64)> { + ["animationDelay", "transitionDelay"].iter().find_map(|key| { + let entry = self.entry(key)?; + let step = match (entry.text, entry.expression) { + (Some(text), _) => step_in_calc(text), + (None, Some(Expression::TemplateLiteral(template))) => step_in_template(template), + _ => None, + }?; + Some((entry.span, step)) + }) + } +} + +pub(super) struct Entrance { + pub span: Span, + hides: bool, + scroll: bool, +} + +/// Whether an `animation-range` finishes while the block is still entering: +/// its last named range is `entry`, or `cover`/`contain`/a bare percentage of +/// at most 50%. +fn range_ends_early(range: &str) -> bool { + let tokens: Vec<&str> = range.split_whitespace().collect(); + let named = |token: &&str| matches!(*token, "entry" | "entry-crossing" | "exit" | "exit-crossing" | "cover" | "contain" | "normal"); + let Some(index) = tokens.iter().rposition(named) else { + return tokens.last().and_then(|token| percent(token)).is_some_and(|value| value <= 50.0); + }; + match tokens[index] { + "entry" | "entry-crossing" => true, + "cover" | "contain" => tokens.get(index + 1).and_then(|token| percent(token)).is_some_and(|value| value <= 50.0), + _ => false, + } +} + +fn percent(token: &str) -> Option { + token.strip_suffix('%')?.parse().ok() +} + +/// `60ms` / `0.06s`, in seconds. +fn duration(token: &str) -> Option { + let token = token.trim(); + if let Some(number) = token.strip_suffix("ms") { + return number.parse::().ok().map(|value| value / 1000.0); + } + token.strip_suffix('s')?.parse().ok() +} + +fn is_duration_char(character: char) -> bool { + character.is_ascii_alphanumeric() || character == '.' +} + +/// `calc(var(--i) * 60ms)` or `calc(60ms * var(--i))`: the duration that is +/// multiplied by something only known per item. `#` stands for a template +/// expression. +fn step_in_calc(text: &str) -> Option { + let (left, right) = text.split_once('*')?; + let per_item = |side: &str| side.contains("var(") || side.contains('#'); + let after: String = right.trim_start().chars().take_while(|character| is_duration_char(*character)).collect(); + let before: String = { + let trimmed = left.trim_end(); + let start = trimmed.rfind(|character: char| !is_duration_char(character)).map_or(0, |index| index + 1); + trimmed[start..].to_owned() + }; + match (duration(&after), duration(&before)) { + (Some(step), _) if per_item(left) => Some(step), + (_, Some(step)) if per_item(right) => Some(step), + _ => None, + } +} + +/// `` `calc(${index} * 60ms)` `` or `` `${index * 60}ms` ``. +fn step_in_template(template: &TemplateLiteral<'_>) -> Option { + let joined = template.quasis.iter().map(|quasi| quasi.value.raw.as_str()).collect::>().join("#"); + if let Some(step) = step_in_calc(&joined) { + return Some(step); + } + template.expressions.iter().enumerate().find_map(|(index, part)| { + let Expression::BinaryExpression(binary) = part.get_inner_expression() else { + return None; + }; + if binary.operator != BinaryOperator::Multiplication { + return None; + } + let number = match (binary.left.get_inner_expression(), binary.right.get_inner_expression()) { + (Expression::NumericLiteral(number), other) | (other, Expression::NumericLiteral(number)) + if !matches!(other, Expression::NumericLiteral(_)) => + { + number.value + } + _ => return None, + }; + let unit = template.quasis.get(index + 1)?.value.raw.as_str(); + if unit.starts_with("ms") { + Some(number / 1000.0) + } else if unit.starts_with('s') { + Some(number) + } else { + None + } + }) +} + +/// What an `IntersectionObserver` callback does with visibility. +#[derive(Default)] +pub(super) struct ObserverCallback { + /// It calls `unobserve`/`disconnect`: one-shot. + pub stops: bool, + /// It writes `isIntersecting` straight into a class, attribute or style, + /// so leaving the viewport undoes the entrance. + pub mirrors_visibility: bool, +} + +struct ReadsIntersecting(bool); + +impl<'a> oxc_ast_visit::Visit<'a> for ReadsIntersecting { + fn visit_identifier_name(&mut self, node: &IdentifierName<'a>) { + self.0 |= node.name == "isIntersecting"; + } +} + +fn reads_intersecting(expression: &Expression<'_>) -> bool { + use oxc_ast_visit::Visit; + let mut reads = ReadsIntersecting(false); + reads.visit_expression(expression); + reads.0 +} + +impl<'a> oxc_ast_visit::Visit<'a> for ObserverCallback { + fn visit_identifier_name(&mut self, node: &IdentifierName<'a>) { + self.stops |= matches!(node.name.as_str(), "unobserve" | "disconnect"); + } + + fn visit_call_expression(&mut self, node: &CallExpression<'a>) { + let writes = matches!(node.callee.get_inner_expression(), Expression::StaticMemberExpression(member) + if matches!(member.property.name.as_str(), "toggle" | "toggleAttribute" | "setAttribute" | "setProperty")); + if writes { + self.mirrors_visibility |= node.arguments.iter().filter_map(Argument::as_expression).any(reads_intersecting); + } + oxc_ast_visit::walk::walk_call_expression(self, node); + } + + fn visit_assignment_expression(&mut self, node: &AssignmentExpression<'a>) { + self.mirrors_visibility |= reads_intersecting(&node.right); + oxc_ast_visit::walk::walk_assignment_expression(self, node); + } +} + +/// `new IntersectionObserver(callback, …)` with an inline callback. +pub(super) fn observer_callback(node: &NewExpression<'_>) -> Option { + use oxc_ast_visit::Visit; + let named = match node.callee.get_inner_expression() { + Expression::Identifier(callee) => callee.name == "IntersectionObserver", + Expression::StaticMemberExpression(member) => member.property.name == "IntersectionObserver", + _ => false, + }; + if !named { + return None; + } + let mut callback = ObserverCallback::default(); + match node.arguments.first()?.as_expression()?.get_inner_expression() { + Expression::ArrowFunctionExpression(function) => callback.visit_arrow_function_expression(function), + Expression::FunctionExpression(function) => callback.visit_function_body(function.body.as_ref()?), + // A callback declared elsewhere cannot be read from here. + _ => return None, + } + Some(callback) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_range_ends_early_only_inside_the_entry() { + for range in ["entry", "entry 0% entry 100%", "entry 0% entry min(100%, 240px)", "entry 0% cover 30%", "0% 40%", "entry-crossing"] { + assert!(range_ends_early(range), "{range}"); + } + for range in ["cover", "contain", "normal", "entry 0% exit 100%", "exit", "entry 0% cover 80%", "0% 100%"] { + assert!(!range_ends_early(range), "{range}"); + } + } + + #[test] + fn a_per_item_step_is_a_duration_multiplied_by_an_index() { + for (text, step) in [("calc(var(--i) * 60ms)", 0.06), ("calc(0.2s * var(--index))", 0.2), ("calc(# * 250ms)", 0.25)] { + assert_eq!(step_in_calc(text), Some(step), "{text}"); + } + for text in ["calc(2 * 60ms)", "200ms", "calc(var(--base) + 60ms)"] { + assert_eq!(step_in_calc(text), None, "{text}"); + } + } +} diff --git a/crates/devup-mcp-devup-ui/src/ui_validate/motion_keyframes_tests.rs b/crates/devup-mcp-devup-ui/src/ui_validate/motion_keyframes_tests.rs new file mode 100644 index 00000000..204878b3 --- /dev/null +++ b/crates/devup-mcp-devup-ui/src/ui_validate/motion_keyframes_tests.rs @@ -0,0 +1,239 @@ +//! Each motion rule against entrances written with Devup UI keyframes, with +//! the shapes that must stay silent. The framer-motion forms of the same +//! rules are in `motion_quality.rs`. +use super::tests::rules; +use super::*; + +const RISE: &str = "const rise = keyframes({ from: { opacity: 0, transform: 'translateY(16px)' }, to: { opacity: 1, transform: 'translateY(0)' } });"; + +fn with_rise(body: &str) -> String { + format!("{RISE}\n{body}") +} + +/// The form the guide recommends: nothing to report. +const RECOMMENDED: &str = r#"const C = () => </Box></Box>"#; + +#[test] +fn the_recommended_keyframes_reveal_is_clean() { + let source = with_rise(RECOMMENDED); + assert_eq!(findings(&source).into_iter().map(|finding| finding.rule).collect::<Vec<_>>(), Vec::<&str>::new()); + assert!(reduced_motion_finding(&source).is_none()); +} + +#[test] +fn keyframes_content_that_rests_hidden_is_hidden_server_markup() { + for body in [ + // Resting opacity 0: the animation is the only thing that shows it. + r#"const C = () => <Box opacity={0} animation={`${rise} 400ms ease-out forwards`}>Copy</Box>"#, + r#"const C = () => <Box opacity="0" animationName={rise} animationDuration="400ms" animationFillMode="forwards">Copy</Box>"#, + r#"const hidden = css({ opacity: 0, animationName: rise, animationDuration: '400ms', animationFillMode: 'forwards' })"#, + // Hidden until a script sets a marker. + r#"const C = () => <Box opacity={0} transition="opacity .4s" selectors={{ '&[data-visible="true"]': { opacity: 1 } }}>Copy</Box>"#, + r#"const C = () => <Box visibility="hidden" selectors={{ '&.is-visible': { visibility: 'visible' } }}>Copy</Box>"#, + // Paused on the hidden first frame until a script resumes it. + r#"const C = () => <Box animation={`${rise} 400ms ease-out both`} animationPlayState="paused">Copy</Box>"#, + // An inline keyframes() call is read the same way. + r#"const C = () => <Box opacity={0} animationName={keyframes({ '0%': { opacity: 0 }, '100%': { opacity: 1 } })} animationDuration="300ms">Copy</Box>"#, + ] { + assert_eq!(rules(&with_rise(body)), ["ssr-hidden-reveal"], "{body}"); + } + for body in [ + // The hidden start lives only in the keyframes. + r#"const C = () => <Box animation={`${rise} 400ms ease-out both`}>Copy</Box>"#, + // Hidden only once a script has marked it as still below the viewport. + r#"const C = () => <Box animationName={rise} animationFillMode="both" animationDuration="400ms" selectors={{ '&[data-reveal="pending"]': { opacity: 0 } }}>Copy</Box>"#, + // A hover overlay is not an entrance, and no script marker shows it. + r#"const C = () => <Box opacity={0} _hover={{ opacity: 1 }} transition="opacity .2s">Overlay</Box>"#, + // Client-mounted: never part of server markup. + r#"const C = () => open && <Box opacity={0} animation={`${rise} 400ms ease-out forwards`}>Toast</Box>"#, + // A paused spinner does not hide anything. + r#"const spin = keyframes({ from: { transform: 'rotate(0)' }, to: { transform: 'rotate(360deg)' } }); const C = () => <Box animation={`${spin} 1s linear infinite`} animationPlayState="paused" />"#, + // A value built elsewhere cannot be read from here. + r#"const C = () => <Box opacity={start} animationName={rise} animationDuration="400ms">Copy</Box>"#, + ] { + assert_eq!(rules(&with_rise(body)), Vec::<&str>::new(), "{body}"); + } +} + +#[test] +fn a_view_timeline_reveal_must_end_inside_the_entry() { + for body in [ + // No range: the fade spans the whole pass through the viewport. + r#"const C = () => <Box animationName={rise} animationFillMode="both" animationTimeline="view()">Copy</Box>"#, + r#"const C = () => <Box animationName={rise} animationTimeline="view()" animationRange="cover">Copy</Box>"#, + r#"const C = () => <Box animationName={rise} animationTimeline="view()" animationRange="entry 0% exit 100%">Copy</Box>"#, + r#"const C = () => <Box animationName={rise} animationTimeline="view()" animationRange="entry 0% cover 80%">Copy</Box>"#, + r#"const reveal = css({ animationName: rise, animationTimeline: 'view()', animationRangeEnd: 'exit 0%' })"#, + ] { + assert_eq!(rules(&with_rise(body)), ["reveal-replays"], "{body}"); + } + for body in [ + r#"const C = () => <Box animationName={rise} animationTimeline="view()" animationRange="entry 0% entry 100%">Copy</Box>"#, + r#"const C = () => <Box animationName={rise} animationTimeline="view()" animationRange="entry">Copy</Box>"#, + r#"const C = () => <Box animationName={rise} animationTimeline="view()" animationRange="entry 0% cover 30%">Copy</Box>"#, + r#"const reveal = css({ animationName: rise, animationTimeline: 'view()', animationRangeEnd: 'entry 100%' })"#, + // Built elsewhere: unknown, so not reported. + r#"const C = () => <Box animationName={rise} animationTimeline="view()" animationRange={range}>Copy</Box>"#, + // A timed, one-shot entrance has no range to state. + r#"const C = () => <Box animation={`${rise} 400ms ease-out both`}>Copy</Box>"#, + // A page progress bar is not an entrance. + r#"const grow = keyframes({ from: { transform: 'scaleX(0)' }, to: { transform: 'scaleX(1)' } }); const C = () => <Box animationName={grow} animationTimeline="scroll()" />"#, + ] { + assert_eq!(rules(&with_rise(body)), Vec::<&str>::new(), "{body}"); + } +} + +#[test] +fn a_fallback_observer_marks_once() { + let mirrors = r#"const observer = new IntersectionObserver(entries => entries.forEach(entry => entry.target.toggleAttribute('data-in', entry.isIntersecting)))"#; + let found = findings(mirrors); + assert_eq!(found.iter().map(|finding| (finding.rule, finding.severity)).collect::<Vec<_>>(), [("reveal-replays", Severity::Warning)], "{mirrors}"); + for source in [ + r#"const observer = new IntersectionObserver(entries => entries.forEach(entry => entry.target.classList.toggle('in', entry.isIntersecting)))"#, + r#"const observer = new IntersectionObserver(function (entries) { for (const entry of entries) entry.target.dataset.reveal = entry.isIntersecting ? 'in' : 'pending' })"#, + ] { + assert_eq!(findings(source).iter().map(|finding| (finding.rule, finding.severity)).collect::<Vec<_>>(), [("reveal-replays", Severity::Warning)], "{source}"); + } + // No stop, but nothing proves it drives an entrance either. + let tracks = r#"const observer = new IntersectionObserver(entries => entries.forEach(entry => { if (entry.isIntersecting) load(entry.target) }))"#; + assert_eq!(findings(tracks).iter().map(|finding| (finding.rule, finding.severity)).collect::<Vec<_>>(), [("reveal-replays", Severity::Info)]); + for source in [ + r#"const observer = new IntersectionObserver(entries => entries.forEach(entry => { if (!entry.isIntersecting) return; entry.target.dataset.reveal = 'in'; observer.unobserve(entry.target) }))"#, + r#"const observer = new IntersectionObserver((entries, self) => { if (entries.some(entry => entry.isIntersecting)) { start(); self.disconnect() } })"#, + // A callback declared elsewhere cannot be read from here. + r#"const observer = new IntersectionObserver(onChange, { threshold: 0.2 })"#, + r#"const observer = new ResizeObserver(entries => entries.forEach(entry => entry.target.classList.toggle('wide', entry.contentRect.width > 600)))"#, + ] { + assert_eq!(rules(source), Vec::<&str>::new(), "{source}"); + } +} + +#[test] +fn a_keyframes_reveal_stays_off_the_painted_section() { + for body in [ + r#"const C = () => <Box as="section" bg="$surface" animationName={rise} animationFillMode="both" animationTimeline="view()" animationRange="entry 0% entry 100%"><Title /></Box>"#, + r#"const C = () => <Box as="section" bgImage="url(/band.webp)" animation={`${rise} 400ms ease-out both`}><Title /></Box>"#, + r#"const band = css({ backgroundColor: '$surface', animationName: rise, animationDuration: '400ms', animationFillMode: 'both' })"#, + // The class paints and the element's own props animate. + r#"const C = () => <section className={css({ bg: '$surface' })} animationName={rise} animationDuration="400ms" animationFillMode="both" />"#, + // The class animates and the element's own props paint. + r#"const C = () => <Box bg="$surface" className={css({ animationName: rise, animationDuration: '400ms', animationFillMode: 'both' })} />"#, + ] { + assert_eq!(rules(&with_rise(body)), ["reveal-moves-background"], "{body}"); + } + for body in [ + // Paint outside, motion inside. + r#"const C = () => <Box as="section" bg="$surface"><Box animation={`${rise} 400ms ease-out both`}><Title /></Box></Box>"#, + // A looping loader on a painted element is not an entrance. + r#"const pulse = keyframes({ from: { opacity: 0 }, to: { opacity: 1 } }); const C = () => <Box bg="$skeleton" animation={`${pulse} 1s ease-in-out infinite alternate`} />"#, + r#"const spin = keyframes({ from: { transform: 'rotate(0)' }, to: { transform: 'rotate(360deg)' } }); const C = () => <Box bg="$primary" animation={`${spin} 1s linear infinite`} />"#, + // Keyframes declared in another module cannot be read from here. + r#"const C = () => <Box bg="$surface" animationName={imported} animationDuration="400ms" />"#, + r#"const C = () => <Box bg="$surface" transition="background-color .2s" _hover={{ bg: '$surfaceHover' }} />"#, + ] { + assert_eq!(rules(&with_rise(body)), Vec::<&str>::new(), "{body}"); + } +} + +#[test] +fn a_keyframes_stagger_stays_short() { + for body in [ + r#"const C = () => items.map((item, index) => <Box key={item.id} animation={`${rise} 400ms ease-out both`} animationDelay={`calc(${index} * 250ms)`} />)"#, + r#"const C = () => items.map((item, index) => <Box key={item.id} animation={`${rise} 400ms ease-out both`} animationDelay={`${index * 200}ms`} />)"#, + r#"const C = () => items.map((item, index) => <Box key={item.id} animation={`${rise} 400ms ease-out both`} animationDelay={`${0.3 * index}s`} />)"#, + r#"const C = () => items.map((item, index) => <Box key={item.id} style={{ '--i': index }} animation={`${rise} 400ms ease-out both`} animationDelay="calc(var(--i) * 0.2s)" />)"#, + r#"const card = css({ animationName: rise, animationDuration: '400ms', animationFillMode: 'both', animationDelay: 'calc(180ms * var(--i))' })"#, + ] { + assert_eq!(rules(&with_rise(body)), ["stagger-too-long"], "{body}"); + } + for body in [ + r#"const C = () => items.map((item, index) => <Box key={item.id} animation={`${rise} 400ms ease-out both`} animationDelay={`calc(${index} * 60ms)`} />)"#, + r#"const C = () => items.map((item, index) => <Box key={item.id} animation={`${rise} 400ms ease-out both`} animationDelay={`${index * 60}ms`} />)"#, + r#"const C = () => <Box style={{ '--i': index }} animation={`${rise} 400ms ease-out both`} animationDelay="calc(var(--i) * 60ms)" />"#, + // One fixed wait, and arithmetic that is not a per-item step. + r#"const C = () => <Box animation={`${rise} 400ms ease-out both`} animationDelay="400ms" />"#, + r#"const C = () => <Box animation={`${rise} 400ms ease-out both`} animationDelay="calc(2 * 300ms)" />"#, + r#"const C = () => <Box animation={`${rise} 400ms ease-out both`} animationDelay={`${base}ms`} />"#, + ] { + assert_eq!(rules(&with_rise(body)), Vec::<&str>::new(), "{body}"); + } +} + +#[test] +fn a_keyframes_entrance_needs_reduced_motion_handling() { + for body in [ + r#"const C = () => <Box animation={`${rise} 400ms ease-out both`}>Copy</Box>"#, + r#"const C = () => <Box animationName={rise} animationFillMode="both" animationTimeline="view()" animationRange="entry 0% entry 100%">Copy</Box>"#, + r#"const reveal = css({ animationName: rise, animationDuration: '400ms', animationFillMode: 'both' })"#, + // A comment or an unused import is not handling. + "// _motionReduce later\nconst C = () => <Box animation={`${rise} 400ms ease-out both`}>Copy</Box>", + ] { + let source = with_rise(body); + assert_eq!(reduced_motion_finding(&source).map(|finding| finding.rule), Some("motion-without-reduced-motion"), "{body}"); + } + for body in [ + r#"const C = () => <Box animation={`${rise} 400ms ease-out both`} _motionReduce={{ animation: 'none' }}>Copy</Box>"#, + r#"const C = () => <Box _motionSafe={{ animation: `${rise} 400ms ease-out both` }}>Copy</Box>"#, + r#"const reveal = css({ animationName: rise, animationDuration: '400ms', selectors: { '@media (prefers-reduced-motion: reduce)': { animationName: 'none' } } })"#, + // Not an entrance: a loader, a hover transition, keyframes that only move. + r#"const spin = keyframes({ from: { transform: 'rotate(0)' }, to: { transform: 'rotate(360deg)' } }); const C = () => <Box animation={`${spin} 1s linear infinite`} />"#, + r#"const C = () => <Box transition="opacity .2s" _hover={{ opacity: 0.8 }} />"#, + r#"const nudge = keyframes({ from: { transform: 'translateX(-4px)' }, to: { transform: 'translateX(0)' } }); const C = () => <Box animation={`${nudge} 200ms ease-out`} />"#, + ] { + let source = with_rise(body); + assert!(reduced_motion_finding(&source).is_none(), "{body}"); + } +} + +#[test] +fn a_library_entrance_is_noted_once_and_keyframes_are_not() { + let twice = r#"const C = () => <><motion.div whileInView="visible" viewport={{ once: true }} /><motion.div whileInView="visible" viewport={{ once: true }} /></>"#; + let found = findings(twice); + assert_eq!(found.iter().map(|finding| (finding.rule, finding.severity)).collect::<Vec<_>>(), [("entrance-uses-motion-library", Severity::Info)]); + assert!(found[0].message.contains("static CSS"), "{}", found[0].message); + for source in [ + with_rise(RECOMMENDED), + // Enter/exit state motion and gestures are what a library is for. + r#"const C = () => <AnimatePresence>{open && <motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} />}</AnimatePresence>"#.to_owned(), + r#"const C = () => <motion.button whileHover={{ scale: 1.02 }} />"#.to_owned(), + ] { + assert!(findings(&source).iter().all(|finding| finding.rule != "entrance-uses-motion-library"), "{source}"); + } +} + +#[test] +fn what_the_reader_uses_at_once_gets_no_entrance() { + for body in [ + r#"const C = () => <Box as="nav" animation={`${rise} 400ms ease-out both`}><Links /></Box>"#, + r#"const C = () => <footer animationName={rise} animationFillMode="both" animationTimeline="view()" animationRange="entry"><Legal /></footer>"#, + r#"const C = () => <Box as="form" className={css({ animationName: rise, animationDuration: '400ms', animationFillMode: 'both' })}><Fields /></Box>"#, + r#"const C = () => <Text as="p" animation={`${rise} 400ms ease-out both`}>Body copy</Text>"#, + // The same roles through a motion library. + r#"const C = () => <motion.nav whileInView="visible" viewport={{ once: true }} />"#, + r#"const C = () => <Box as="footer" whileInView="visible" viewport={{ once: true }} />"#, + ] { + assert_eq!(rules(&with_rise(body)), ["entrance-on-static-content"], "{body}"); + } + for body in [ + r#"const C = () => <Text as="h2" animation={`${rise} 400ms ease-out both`}>Title</Text>"#, + r#"const C = () => <button animation={`${rise} 400ms ease-out both`}>Go</button>"#, + r#"const C = () => <motion.a whileInView="visible" viewport={{ once: true }} href="/more">More</motion.a>"#, + ] { + let found = findings(&with_rise(body)); + assert!(found.iter().any(|finding| finding.rule == "entrance-per-element" && finding.severity == Severity::Info), "{body}"); + assert!(found.iter().all(|finding| finding.severity == Severity::Info), "{body}"); + } + for body in [ + // A content group is what an entrance is for. + r#"const C = () => <Box as="section"><Box animation={`${rise} 400ms ease-out both`}><Title /></Box></Box>"#, + r#"const C = () => <motion.div whileInView="visible" viewport={{ once: true }} />"#, + // Static roles without an entrance: a transition, a loader, nothing at all. + r#"const C = () => <Box as="nav" transition="opacity .2s" _hover={{ opacity: 0.8 }} />"#, + r#"const spin = keyframes({ from: { transform: 'rotate(0)' }, to: { transform: 'rotate(360deg)' } }); const C = () => <button animation={`${spin} 1s linear infinite`} />"#, + r#"const C = () => <footer bg="$surface"><Legal /></footer>"#, + // Exit/enter state motion of a client-mounted element. + r#"const C = () => <AnimatePresence>{open && <motion.nav initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} />}</AnimatePresence>"#, + ] { + assert_eq!(rules(&with_rise(body)), Vec::<&str>::new(), "{body}"); + } +} 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 index 78582782..1784f9cc 100644 --- a/crates/devup-mcp-devup-ui/src/ui_validate/motion_quality.rs +++ b/crates/devup-mcp-devup-ui/src/ui_validate/motion_quality.rs @@ -21,6 +21,23 @@ //! - `motion-without-reduced-motion`: entrance motion with no reduced-motion //! handling anywhere in the validated sources (cross-file, see //! [`reduced_motion_finding`]). +//! - `entrance-uses-motion-library` (info, once per file): the entrance is +//! driven by a runtime animation library where Devup UI's static keyframes +//! would do it in CSS. +//! - `entrance-on-static-content`: navigation, a header or footer, a form or +//! its fields, or a paragraph of body copy is given an entrance. Those are +//! read or operated at once and get none. +//! - `entrance-per-element` (info): a heading or a control carries its own +//! entrance; the entrance belongs to the content group around it. +//! +//! Every rule reads both styles: framer-motion props (`initial`, +//! `whileInView`, `viewport`, `transition`) and Devup UI keyframes +//! (`keyframes()`, `animationName`/`animation`, `animationTimeline`, +//! `animationRange`, `animationDelay`, selectors and a plain +//! `IntersectionObserver`), see [`keyframes`]. +#[path = "motion_keyframes.rs"] +mod keyframes; + use super::{Severity, Violation, site}; use oxc_allocator::Allocator; use oxc_ast::ast::*; @@ -52,14 +69,15 @@ pub fn reduced_motion_finding(source: &str) -> Option<Violation> { "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(), <MotionConfig reducedMotion=\"user\"> in a shared provider, or a prefers-reduced-motion media rule. Validate the provider together with this file if it lives elsewhere.", + "Leave content in place for reduced motion: _motionReduce={{ animation: 'none' }} on the animated element (Devup UI), a prefers-reduced-motion media rule, or useReducedMotion()/<MotionConfig reducedMotion=\"user\"> for a motion library. Validate the shared rule or 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. +/// (or any `…ReducedMotion` name), a `reducedMotion`/`_motionReduce`/ +/// `_motionSafe` 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. @@ -80,7 +98,7 @@ struct Preference(bool); impl Preference { fn name(&mut self, name: &str) { - self.0 |= name.contains("ReducedMotion") || matches!(name, "reducedMotion" | "_motionReduce"); + self.0 |= name.contains("ReducedMotion") || matches!(name, "reducedMotion" | "_motionReduce" | "_motionSafe"); } fn text(&mut self, text: &str) { self.0 |= text.contains("prefers-reduced-motion"); @@ -113,7 +131,7 @@ fn collect(source: &str) -> Option<Collector<'_>> { if parsed.panicked || !parsed.diagnostics.is_empty() { return None; } - let mut components = MotionComponents(Vec::new()); + let mut components = MotionComponents(Vec::new(), keyframes::Declared(Vec::new())); components.visit_program(&parsed.program); let mut collector = Collector { source, @@ -121,6 +139,8 @@ fn collect(source: &str) -> Option<Collector<'_>> { first_entrance: None, optional: 0, motion_components: components.0, + declared: components.1, + library_noted: false, }; collector.visit_program(&parsed.program); Some(collector) @@ -131,11 +151,13 @@ fn is_motion_namespace(name: &str) -> bool { } /// `const MotionBox = motion.create(Box)` / `motion(Box)`: a component that -/// takes the same `initial`/`whileInView` props as `motion.div`. -struct MotionComponents(Vec<String>); +/// takes the same `initial`/`whileInView` props as `motion.div`. The same +/// pass records every `const name = keyframes({…})`. +struct MotionComponents(Vec<String>, keyframes::Declared); impl<'a> Visit<'a> for MotionComponents { fn visit_variable_declarator(&mut self, node: &VariableDeclarator<'a>) { + self.1.record(node); if let (Some(name), Some(Expression::CallExpression(call))) = (node.id.get_binding_identifier(), node.init.as_ref().map(Expression::get_inner_expression)) { @@ -165,6 +187,76 @@ struct Collector<'s> { optional: usize, /// Components this file builds with `motion.create(…)`/`motion(…)`. motion_components: Vec<String>, + /// `keyframes()` constants of this file. + declared: keyframes::Declared, + library_noted: bool, +} + +const KEYFRAMES_FIX: &str = "Declare the entrance once with Devup UI keyframes (from: { opacity: 0, transform }) and put animationName, animationFillMode=\"both\", animationTimeline=\"view()\" and animationRange=\"entry 0% entry min(100%, 240px)\" on an inner wrapper: the hidden start then lives only in CSS, and content shows without JavaScript."; +const MOVES_BACKGROUND: &str = "The element that paints the section background is the one being revealed, so the background fades or moves with it."; + +impl Collector<'_> { + fn report(&mut self, span: oxc_span::Span, rule: &'static str, severity: Severity, message: &str, help: &'static str) { + self.findings.push(site::violation(self.source, span, rule, severity, message.into(), help)); + } + + /// What the role table of the animation guide excludes, proven from the + /// element itself. + fn check_role(&mut self, span: oxc_span::Span, role: &str) { + if matches!(role, "nav" | "header" | "footer" | "form" | "input" | "textarea" | "select" | "label" | "p") { + self.report(span, "entrance-on-static-content", Severity::Warning, + "Navigation, headers, footers, forms and body copy are read or operated at once; this element is given an entrance.", + "Remove the entrance here. Reveal the content group of a below-fold section instead, and leave what the reader uses immediately in place. Keep it only when the design explicitly asks for it."); + } else if matches!(role, "h1" | "h2" | "h3" | "h4" | "h5" | "h6" | "button" | "a") { + self.report(span, "entrance-per-element", Severity::Info, + "This heading or control carries its own entrance.", + "One entrance per content group is the normal case: put it on the wrapper around the heading, text and controls rather than on each of them."); + } + } + + /// Keyframes-style checks for one element's styles. `painted` says the + /// element (or its class) paints a background. Returns whether the + /// styles declare an entrance. + fn check_bag(&mut self, bag: &keyframes::Bag<'_, '_>, painted: bool) -> bool { + let entrance = bag.entrance(&self.declared); + if let Some(entrance) = &entrance { + self.first_entrance.get_or_insert(entrance.span); + if let Some(span) = bag.scroll_range_replays(entrance) { + self.report(span, "reveal-replays", Severity::Warning, + "This view-timeline entrance has no range that ends while the block is entering, so the content is still part-transparent in the middle of the screen and fades out again as it leaves.", + "Add animationRange=\"entry 0% entry min(100%, 240px)\" (or another range that ends inside the entry, at most cover 50%). Keep a full-pass range only when the design explicitly asks for it."); + } + if painted { + self.report(entrance.span, "reveal-moves-background", Severity::Warning, MOVES_BACKGROUND, + "Keep the painted section fixed and put the animation props on an inner content wrapper."); + } + } + if self.optional == 0 + && let Some((span, message)) = bag.hidden_until_shown(entrance.as_ref()) + { + self.report(span, "ssr-hidden-reveal", Severity::Warning, message, + "Keep the resting style visible and state the hidden start only in the keyframes' first frame (animationFillMode=\"both\"). A fallback script may mark content that is still below the viewport, never content already rendered in view. Check no-JavaScript, reduced motion, reload and client navigation."); + } + if let Some((span, step)) = bag.stagger_step().filter(|(_, step)| *step > MAX_STAGGER_STEP) { + self.stagger(span, step); + } + entrance.is_some() + } + + fn stagger(&mut self, span: oxc_span::Span, step: f64) { + self.findings.push(site::violation(self.source, 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.")); + } + + /// Once per file: this entrance needs a runtime library. + fn note_library(&mut self, span: oxc_span::Span) { + if !std::mem::replace(&mut self.library_noted, true) { + self.report(span, "entrance-uses-motion-library", Severity::Info, + "This entrance is driven by a runtime animation library; Devup UI keyframes produce the same motion as static CSS, with no script to download or hydrate.", + KEYFRAMES_FIX); + } + } } fn attribute<'b, 'a>(node: &'b JSXOpeningElement<'a>, name: &str) -> Option<&'b JSXAttribute<'a>> { @@ -280,18 +372,33 @@ fn starts_hidden(node: &JSXOpeningElement<'_>) -> bool { .is_some_and(hides) } -fn paints_background(node: &JSXOpeningElement<'_>) -> bool { - if BACKGROUND_PROPS.iter().any(|name| attribute(node, name).is_some()) { - return true; +/// What the element is: `as="nav"`, an intrinsic tag (`<footer>`), or the +/// tag of a motion element (`motion.p`). +fn element_role<'b>(node: &'b JSXOpeningElement<'_>) -> Option<&'b str> { + if let Some(role) = attribute(node, "as").and_then(string) { + return Some(role); } - // `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, - }) + match &node.name { + JSXElementName::Identifier(name) => Some(name.name.as_str()), + JSXElementName::MemberExpression(member) => Some(member.property.name.as_str()), + _ => None, + } +} + +/// The object of `className={css({ … })}`. +fn class_object<'b, 'a>(node: &'b JSXOpeningElement<'a>) -> Option<&'b ObjectExpression<'a>> { + match attribute(node, "className").and_then(expression)? { + Expression::CallExpression(call) => match call.arguments.first()? { + Argument::ObjectExpression(object) => Some(object), + _ => None, + }, + _ => None, + } +} + +fn paints_background(node: &JSXOpeningElement<'_>) -> bool { + BACKGROUND_PROPS.iter().any(|name| attribute(node, name).is_some()) + || class_object(node).is_some_and(|object| BACKGROUND_PROPS.iter().any(|name| property(object, name).is_some())) } impl<'a> Visit<'a> for Collector<'_> { @@ -336,13 +443,34 @@ impl<'a> Visit<'a> for Collector<'_> { _ => 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.")); + self.stagger(node.span, step); } walk::walk_object_property(self, node); } + fn visit_object_expression(&mut self, node: &ObjectExpression<'a>) { + // A style object (`css({…})`, `styled.div({…})`, a selector's value). + let bag = keyframes::Bag::of_object(node); + if bag.animates() { + let painted = bag.paints_background(); + let _ = self.check_bag(&bag, painted); + } + walk::walk_object_expression(self, node); + } + + fn visit_new_expression(&mut self, node: &NewExpression<'a>) { + if let Some(callback) = keyframes::observer_callback(node).filter(|callback| !callback.stops) { + let (severity, message) = if callback.mirrors_visibility { + (Severity::Warning, "This observer writes isIntersecting straight into the element, so leaving the viewport undoes the entrance and re-entering replays it.") + } else { + (Severity::Info, "This observer never calls unobserve() or disconnect(); an entrance driven by it replays on every re-entry.") + }; + self.report(node.span, "reveal-replays", severity, message, + "For an entrance, mark the element once and then unobserve it. Leave it as is when it tracks visibility for another purpose."); + } + walk::walk_new_expression(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) @@ -360,10 +488,33 @@ impl<'a> Visit<'a> for Collector<'_> { if (in_view.is_some() || hidden) && self.first_entrance.is_none() { self.first_entrance = Some(node.span); } + // An exit/enter pair inside AnimatePresence is state motion that CSS + // keyframes do not replace; only an always-rendered entrance is noted. + if in_view.is_some() || (hidden && self.optional == 0) { + self.note_library(node.span); + } + // The same element in keyframes form: its own props, with the + // background its class paints. + let bag = keyframes::Bag::of_element(node); + let class_entrance = class_object(node).and_then(|object| keyframes::Bag::of_object(object).entrance(&self.declared)).map(|entrance| entrance.span); + let mut enters = in_view.is_some() || (hidden && self.optional == 0) || class_entrance.is_some(); + if bag.animates() || bag.rests_hidden().is_some() { + let painted = paints_background(node); + enters |= self.check_bag(&bag, painted); + } else if BACKGROUND_PROPS.iter().any(|name| attribute(node, name).is_some()) + && let Some(span) = class_entrance + { + // The class animates and the element's own props paint. + self.report(span, "reveal-moves-background", Severity::Warning, MOVES_BACKGROUND, + "Keep the painted section fixed and put the animation props on an inner content wrapper."); + } + if enters && let Some(role) = element_role(node) { + self.check_role(node.span, role); + } 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.")); + KEYFRAMES_FIX)); } if let Some(trigger) = in_view { let once = match attribute(node, "viewport") { @@ -391,12 +542,18 @@ impl<'a> Visit<'a> for Collector<'_> { } } +#[cfg(test)] +#[path = "motion_keyframes_tests.rs"] +mod keyframes_tests; + #[cfg(test)] mod tests { use super::*; - fn rules(source: &str) -> Vec<&'static str> { - findings(source).into_iter().map(|finding| finding.rule).collect() + /// The rules found, without the once-per-file library note (it has its + /// own test in `motion_keyframes_tests.rs`). + pub(super) fn rules(source: &str) -> Vec<&'static str> { + findings(source).into_iter().map(|finding| finding.rule).filter(|rule| *rule != "entrance-uses-motion-library").collect() } #[test] diff --git a/crates/devup-mcp/src/server/behavior_contracts/detect.rs b/crates/devup-mcp/src/server/behavior_contracts/detect.rs index c92e1598..0d043b3f 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","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"] + "pattern":"devup-ui-static-keyframes-reveal","scope":"selected major below-fold sections, not every text or control","implementation":"Reveal.tsx: Devup UI keyframes() with animationFillMode both, animationTimeline view() and animationRange entry 0% entry min(100%, 240px) on an inner wrapper; no animation library","serverMarkup":"the resting style is the visible content and the hidden start exists only in the keyframes; never prehide SSR content","fallback":"the shared controller marks data-reveal-state once for groups on a clock (data-reveal=timed) and where view timelines are unsupported; it never hides content already on screen","motion":"short upward translation and fade, complete once the block is in view","reducedMotion":"_motionReduce animation none","focus":"_focusWithin animation none","timing":"implementation choice, not captured Figma motion","rules":"devfive-frontend/references/animation.md","verify":["start/middle/end frames","trigger position","scroll re-entry","fast scroll","reload","back/forward","bfcache","client navigation","delayed and failed images","focus","reduced motion","JavaScript disabled","stagger and progress timing"] }), )) } diff --git a/crates/devup-mcp/src/server/behavior_contracts/motion_templates.rs b/crates/devup-mcp/src/server/behavior_contracts/motion_templates.rs index b814a87a..8c187f6d 100644 --- a/crates/devup-mcp/src/server/behavior_contracts/motion_templates.rs +++ b/crates/devup-mcp/src/server/behavior_contracts/motion_templates.rs @@ -20,9 +20,16 @@ pub(super) fn attach(result: &mut Value) { } result["motionControllerTsx"] = json!(include_str!("templates/MotionController.tsx")); result["motionRuntime"] = json!(include_str!("templates/motion-runtime.ts")); + // Entrances are static CSS: the component that declares the keyframes + // ships only when the plan found a section to reveal. + let reveals = bindings.iter().any(|binding| binding["binding"]["kind"] == "section-entrance"); + if reveals { + result["revealTsx"] = json!(include_str!("templates/Reveal.tsx")); + } result["motionBindings"] = json!({"version":1,"bindings":bindings, - "integration":"Keep generated server TSX. Bind semantic selectors and validated API values; mount MotionController once in the persistent server layout with those bindings. Keep the original captured palette/indicator/progress markup. Selectors are required, never guessed from node IDs.", - "files":{"motionControllerTsx":"MotionController.tsx","motionRuntime":"motion-runtime.ts"}, + "integration":"Keep generated server TSX. Bind semantic selectors and validated API values; mount MotionController once in the persistent server layout with those bindings. Keep the original captured palette/indicator/progress markup. Selectors are required, never guessed from node IDs. A section entrance is static CSS: wrap the section's content group (not the painted section) in Reveal, or RevealGroup with RevealItem for staggered cards and RevealLine/RevealStep for a progress line, and bind section-entrance to [data-reveal].", + "files":if reveals { json!({"motionControllerTsx":"MotionController.tsx","motionRuntime":"motion-runtime.ts","revealTsx":"Reveal.tsx"}) } else { json!({"motionControllerTsx":"MotionController.tsx","motionRuntime":"motion-runtime.ts"}) }, + "entranceStyle":"devup-ui-static-keyframes", "playback":"Continuous marquee/steps have no pause controls; set respectReducedMotion:false only for an explicit continuous-playback requirement.", "effectReason":"One effect attaches page DOM observers and cancels them at unmount; layouts and data stay server-rendered.", "implemented":true,"wired":false,"runtimeVerified":false}); diff --git a/crates/devup-mcp/src/server/behavior_contracts/paging.rs b/crates/devup-mcp/src/server/behavior_contracts/paging.rs index 1d084f8b..f150358b 100644 --- a/crates/devup-mcp/src/server/behavior_contracts/paging.rs +++ b/crates/devup-mcp/src/server/behavior_contracts/paging.rs @@ -372,10 +372,12 @@ fn page_with_limit( "dataBindings":bindings,"pageContracts":contracts,"implemented":false,"runtimeVerified":false,"requiresReview":true, "pagination":{"pageIndex":page_index,"complete":complete,"accumulatedNodes":cursor.visited.len(), "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}); + // The standing policies of every plan (captured versus inferred intent, + // the client boundary, source purity, how the context index is read) + // are in the guide this points at, under "Contract identifiers": the + // same four sentences in every export reply cost its byte budget. + "evidenceLimits":{"nodesScanned":scanned.len(),"maxNodes":MAX_NODES,"maxCandidates":MAX_CANDIDATES,"truncated":!complete}, + "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. diff --git a/crates/devup-mcp/src/server/behavior_contracts/templates/Reveal.tsx b/crates/devup-mcp/src/server/behavior_contracts/templates/Reveal.tsx new file mode 100644 index 00000000..762c534b --- /dev/null +++ b/crates/devup-mcp/src/server/behavior_contracts/templates/Reveal.tsx @@ -0,0 +1,127 @@ +import { Box, keyframes } from '@devup-ui/react'; +import type { ComponentProps } from 'react'; + +// The one entrance of the project. The hidden start exists only in these +// keyframes: markup without a running animation is the visible content, so +// nothing depends on JavaScript, hydration or a supported timeline to appear. +const rise = keyframes({ + from: { opacity: 0, transform: 'translateY(16px)' }, + to: { opacity: 1, transform: 'translateY(0)' }, +}); + +// Plain element props: Devup UI extracts style props where they are written, +// so layout for the revealed content goes on a Box inside (or a css() class), +// not through this wrapper. +type RevealProps = ComponentProps<'div'>; + +/** + * A content group that enters as it scrolls into view, as static CSS. + * + * Put it inside the painted section, never on it: the background stays still + * and this wrapper moves. The range ends once the group is fully in view or + * 240px in, whichever comes first, so a tall group is not read half faded. Where view timelines are unsupported the shared + * MotionController marks the group once (`data-reveal-state`) and the same + * keyframes run for 400ms; with no script at all the content is simply shown. + */ +export function Reveal(props: RevealProps) { + return ( + <Box + data-reveal="" + animationName={rise} + animationTimingFunction="ease-out" + animationFillMode="both" + animationTimeline="view()" + animationRange="entry 0% entry min(100%, 240px)" + _motionReduce={{ animation: 'none' }} + _focusWithin={{ animation: 'none' }} + selectors={{ + '&[data-reveal-state="pending"]': { animationName: 'none', opacity: 0 }, + '&[data-reveal-state="in"]': { animationTimeline: 'auto', animationDuration: '400ms' }, + }} + {...props} + /> + ); +} + +/** + * A group whose items enter one after another on a clock: cards, list rows, + * steps along a progress line. One trigger for the whole group (the shared + * MotionController marks it once when it enters), items delayed by their + * index. Give each item `<RevealItem index={i}>`. + */ +export function RevealGroup(props: RevealProps) { + return <Box data-reveal="timed" {...props} />; +} + +/** One item of a `RevealGroup`: waits `index * 60ms`, then rises. */ +export function RevealItem({ index, style, ...props }: RevealProps & { index: number }) { + return ( + <Box + style={{ ...style, '--reveal-index': index }} + _motionReduce={{ animation: 'none' }} + selectors={{ + '[data-reveal-state="pending"] &': { opacity: 0 }, + '[data-reveal-state="in"] &': { + animationName: rise, + animationDuration: '400ms', + animationTimingFunction: 'ease-out', + animationFillMode: 'both', + animationDelay: 'calc(var(--reveal-index) * 60ms)', + }, + }} + {...props} + /> + ); +} + +const draw = keyframes({ + from: { scale: 'var(--reveal-line-from, 1 0)' }, + to: { scale: '1 1' }, +}); + +/** + * The progress line of a timeline inside a `RevealGroup`. It is drawn over + * `--reveal-line` (900ms unless the group sets it) and its steps are + * `<RevealStep index={i} of={count}>`, timed from the same variable, so the + * line and the steps cannot drift apart. Vertical by default; for a + * horizontal line set `--reveal-line-from: 0 1` and `transformOrigin: left`. + */ +export function RevealLine(props: RevealProps) { + return ( + <Box + transformOrigin="top" + _motionReduce={{ animation: 'none', scale: '1 1' }} + selectors={{ + '[data-reveal-state="pending"] &': { scale: 'var(--reveal-line-from, 1 0)' }, + '[data-reveal-state="in"] &': { + animationName: draw, + animationDuration: 'var(--reveal-line, 900ms)', + animationTimingFunction: 'linear', + animationFillMode: 'both', + }, + }} + {...props} + /> + ); +} + +/** A step on a `RevealLine`: appears as the line reaches it. */ +export function RevealStep({ index, of, style, ...props }: RevealProps & { index: number; of: number }) { + return ( + <Box + style={{ ...style, '--reveal-index': index, '--reveal-count': of }} + _motionReduce={{ animation: 'none' }} + selectors={{ + '[data-reveal-state="pending"] &': { opacity: 0 }, + '[data-reveal-state="in"] &': { + animationName: rise, + animationDuration: '300ms', + animationTimingFunction: 'ease-out', + animationFillMode: 'both', + animationDelay: 'calc(var(--reveal-line, 900ms) * var(--reveal-index) / var(--reveal-count))', + }, + }} + {...props} + /> + ); +} 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 70d4b2ae..dcfc5010 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 @@ -16,7 +16,6 @@ export type MotionBinding = { locale?: string; durationMs?: number; pixelsPerSecond?: number; - distancePx?: number; respectReducedMotion?: boolean; }; @@ -179,24 +178,30 @@ function slidingHighlight(root: ParentNode, binding: MotionBinding) { export function mountMotion(root: ParentNode, bindings: MotionBinding[]) { const active = new Map<MotionBinding, {node: HTMLElement; dependencies: HTMLElement[]; cleanup: () => void}>(); + // Entrances are static CSS (Reveal.tsx): keyframes on a view timeline. The + // only script they ever need is this marker, and only for a group on a + // clock (data-reveal="timed") or a browser without view timelines. It sets + // data-reveal-state once and never animates anything itself. const reveals = bindings.filter(binding => binding.kind === 'section-entrance'); - const seen = new WeakSet<Element>(), observed = new WeakSet<Element>(), unreported = new WeakSet<Element>(), revealAnimations = new Set<Animation>(); + const state = 'data-reveal-state', considered = new WeakSet<Element>(), pending = new Set<HTMLElement>(); + const scrollLinked = typeof CSS !== 'undefined' && CSS.supports('animation-timeline: view()'); + const settle = (node: HTMLElement, value: 'in' | 'shown') => { pending.delete(node); revealObserver.unobserve(node); node.setAttribute(state, value); }; const revealObserver = new IntersectionObserver(entries => entries.forEach(entry => { - const node = entry.target as HTMLElement, first = unreported.delete(node); - if (!entry.isIntersecting) return; - 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); + if (entry.isIntersecting) settle(entry.target as HTMLElement, 'in'); })); + const consider = (binding: MotionBinding, node: HTMLElement) => { + if (considered.has(node)) return; + considered.add(node); + if (scrollLinked && node.dataset.reveal !== 'timed' || reduced(binding) || node.hasAttribute(state)) return; + // Only content still below the viewport may start hidden. What is on + // screen when this attaches (first viewport, reload or back/forward at a + // restored scroll position, client navigation), what was already passed + // and what holds focus stays exactly as rendered: hiding it would flash. + if (!visible(node) || node.getBoundingClientRect().top < innerHeight || node.contains(document.activeElement)) return; + node.setAttribute(state, 'pending'); pending.add(node); revealObserver.observe(node); + }; const refresh = () => { - 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); })); + reveals.forEach(binding => elements(root, binding.target).forEach(node => consider(binding, node))); for (const binding of bindings) { if (binding.kind === 'section-entrance') continue; const found = elements(root, binding.target), previous = active.get(binding); @@ -226,14 +231,18 @@ export function mountMotion(root: ParentNode, bindings: MotionBinding[]) { } } }; - const focus = (event: Event) => revealAnimations.forEach(animation => { - const node = (animation.effect as KeyframeEffect)?.target; - if (node instanceof Element && event.target instanceof Node && node.contains(event.target)) animation.cancel(); - }); + // Keyboard focus is never hidden or moved: a block that receives focus is + // shown at once in its final place, whether it was waiting or mid-entrance. + const focus = (event: Event) => { + if (!(event.target instanceof Element)) return; + for (let node = event.target.closest<HTMLElement>(`[${state}]`); node; node = node.parentElement?.closest<HTMLElement>(`[${state}]`) ?? null) settle(node, 'shown'); + }; const mutations = new MutationObserver(refresh); const cleanup = () => { mutations.disconnect(); revealObserver.disconnect(); root.removeEventListener('focusin', focus); - [...active.values()].reverse().forEach(entry => entry.cleanup()); active.clear(); revealAnimations.forEach(animation => animation.cancel()); + [...active.values()].reverse().forEach(entry => entry.cleanup()); active.clear(); + // Nothing stays hidden once the marker is gone. + pending.forEach(node => node.removeAttribute(state)); pending.clear(); }; try { refresh(); mutations.observe(root, { childList: true, subtree: true }); root.addEventListener('focusin', focus); } catch (error) { cleanup(); throw error; } diff --git a/crates/devup-mcp/src/server/behavior_contracts/tests.rs b/crates/devup-mcp/src/server/behavior_contracts/tests.rs index 45abeda0..3ea1c618 100644 --- a/crates/devup-mcp/src/server/behavior_contracts/tests.rs +++ b/crates/devup-mcp/src/server/behavior_contracts/tests.rs @@ -278,6 +278,28 @@ fn page_footer_and_reveal_recommendations_preserve_readable_server_content() { .unwrap() .contains("never prehide SSR") ); + // The entrance is proposed as Devup UI static keyframes, and the file + // that implements it ships with the plan: no animation library. + let entrance = &kind(&result, "section-entrance")["implementation"]; + assert_eq!(entrance["pattern"], "devup-ui-static-keyframes-reveal"); + for term in ["keyframes()", "animationTimeline view()", "animationRange entry 0% entry min(100%, 240px)", "no animation library"] { + assert!(entrance["implementation"].as_str().unwrap().contains(term), "{term}"); + } + assert_eq!(entrance["reducedMotion"], "_motionReduce animation none"); + assert!(entrance["verify"].as_array().unwrap().contains(&json!("bfcache"))); + let reveal = result["revealTsx"].as_str().unwrap(); + for term in ["import { Box, keyframes } from '@devup-ui/react'", "animationTimeline=\"view()\"", "animationRange=\"entry 0% entry min(100%, 240px)\"", "animationFillMode=\"both\"", "_motionReduce={{ animation: 'none' }}", "_focusWithin={{ animation: 'none' }}", "calc(var(--reveal-index) * 60ms)", "export function RevealLine"] { + assert!(reveal.contains(term), "Reveal.tsx lacks {term}"); + } + for library in ["framer-motion", "motion/react", "'use client'", "useEffect"] { + assert!(!reveal.contains(library), "Reveal.tsx is static server code: {library}"); + } + // The shared controller only marks state; it holds no entrance animation. + let runtime = result["motionRuntime"].as_str().unwrap(); + assert!(runtime.contains("data-reveal-state") && runtime.contains("CSS.supports('animation-timeline: view()')")); + assert!(!runtime.contains("translateY("), "the runtime animates no entrance"); + assert_eq!(result["motionBindings"]["files"]["revealTsx"], "Reveal.tsx"); + assert_eq!(result["motionBindings"]["entranceStyle"], "devup-ui-static-keyframes"); assert_eq!( result["behaviorPlan"]["pageContracts"][0]["implementation"]["minHeight"], "100dvh" diff --git a/crates/devup-mcp/src/server/completion.rs b/crates/devup-mcp/src/server/completion.rs index 954d1a79..fef0a7dc 100644 --- a/crates/devup-mcp/src/server/completion.rs +++ b/crates/devup-mcp/src/server/completion.rs @@ -151,8 +151,12 @@ async fn complete_behavior_plan( let rebuilt = continuation(&response["behaviorPlan"], complete_plans); complete_inventory_for(&rebuilt, record, scope).then_some(rebuilt) } +/// Motion candidates a report covers. An entrance (`reveal`) is covered only +/// by a passing check at a mobile and at a desktop width; other kinds by one +/// passing check. fn planned_motion_coverage(plan: &Value, report: &Value) -> BTreeSet<String> { let mut covered = BTreeSet::new(); + let mut reveal_widths = std::collections::BTreeMap::<String, (bool, bool)>::new(); for viewport in values(&plan["plan"], "viewports") { let runs = values(report, "runs") .filter(|run| { @@ -188,11 +192,19 @@ fn planned_motion_coverage(plan: &Value, report: &Value) -> BTreeSet<String> { && rows[0]["measured"]["candidateId"] == check["motion"]["candidateId"] && let Some(id) = check["motion"]["candidateId"].as_str() { - covered.insert(id.to_owned()); + if check["motion"]["kind"] == "reveal" { + let width = viewport["width"].as_u64().unwrap_or(0); + let seen = reveal_widths.entry(id.to_owned()).or_default(); + seen.0 |= width <= 767; + seen.1 |= width >= 1024; + } else { + covered.insert(id.to_owned()); + } } } } } + covered.extend(reveal_widths.into_iter().filter(|(_, (mobile, desktop))| *mobile && *desktop).map(|(id, _)| id)); covered } fn widths() -> Vec<u32> { diff --git a/crates/devup-mcp/src/server/completion_tests.rs b/crates/devup-mcp/src/server/completion_tests.rs index 4931d4e9..4f10fdbc 100644 --- a/crates/devup-mcp/src/server/completion_tests.rs +++ b/crates/devup-mcp/src/server/completion_tests.rs @@ -220,6 +220,27 @@ fn motion_coverage_joins_exact_planned_viewport_screen_index_and_candidate() { assert!(planned_motion_coverage(&plan, &report).is_empty()); } +/// An entrance is covered by a passing reveal check at a mobile and at a +/// desktop width; one width, a tablet width or an inconclusive check is not. +#[test] +fn a_reveal_candidate_needs_passing_checks_at_mobile_and_desktop() { + let viewport = |width: u32| json!({"width":width,"height":800,"deviceScaleFactor":1}); + let run = |width: u32, status: &str| json!({"viewport":viewport(width),"screens":[{"id":"home","status":"completed","checks":[{"index":0,"kind":"motion","status":status,"measured":{"candidateId":"section-entrance:news"}}]}]}); + let plan = |widths: &[u32]| json!({"plan":{"viewports":widths.iter().map(|width| viewport(*width)).collect::<Vec<_>>(),"screens":[{"id":"home","checks":[{"kind":"motion","motion":{"kind":"reveal","candidateId":"section-entrance:news"}}]}]}}); + let covered = BTreeSet::from(["section-entrance:news".to_owned()]); + assert_eq!(planned_motion_coverage(&plan(&[390, 1440]), &json!({"runs":[run(390, "pass"), run(1440, "pass")]})), covered); + for runs in [ + json!([run(1440, "pass")]), + json!([run(390, "pass")]), + json!([run(390, "pass"), run(858, "pass")]), + // What could not be observed is unverified, not covered. + json!([run(390, "pass"), run(1440, "inconclusive")]), + json!([run(390, "fail"), run(1440, "pass")]), + ] { + assert!(planned_motion_coverage(&plan(&[390, 858, 1440]), &json!({"runs":runs})).is_empty(), "{runs}"); + } +} + #[tokio::test] async fn unsigned_inventory_is_rebuilt_only_from_a_retained_capture() { use devup_mcp_figma::{ diff --git a/crates/devup-mcp/src/server/development_contract.rs b/crates/devup-mcp/src/server/development_contract.rs index 561d20fc..712f0f51 100644 --- a/crates/devup-mcp/src/server/development_contract.rs +++ b/crates/devup-mcp/src/server/development_contract.rs @@ -1,4 +1,9 @@ //! Shipping rules accompany tool use even when a client never installs skills. +//! +//! The reply carries identifiers only. What each one requires, what to do +//! next and where the installed copy lives are in the linked guide, under +//! "Contract identifiers": the export reply has a byte budget, and prose +//! repeated in every reply is what spent it. use serde_json::{Value, json}; #[cfg(test)] @@ -6,33 +11,49 @@ const RULES: &str = include_str!("skills/devfive-frontend/references/delivery-qu #[cfg(test)] const MOTION_RULES: &str = include_str!("skills/devfive-frontend/references/animation.md"); +pub(super) const REQUIREMENTS: &[&str] = &[ + "pure-tsx-sidecar-provenance", + "jsx-theme-tokens-and-axis-shorthands", + "single-responsive-tree-and-source-text-wrapping", + "persistent-server-layout", + "route-owned-page-body", + "devup-api-only", + "stop-affected-api-gaps", + "preserve-instance-sizing-and-paint", + "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", + "default-entrance-and-transition-motion", + "devup-ui-static-keyframes-motion", + "one-shot-reveals-without-ssr-flash", + "canonical-semantic-assets", + "landing-detail-api-db-single-source", + "runtime-evidence-before-completion", +]; + +pub(super) const COMPLETION_EVIDENCE: &[&str] = &[ + "both-control-directions-and-history", + "all-widths-and-zoom-context", + "computed-style-and-animation-intermediate-state", + "animation-start-middle-end-frames", + "motion-trigger-reentry-history-and-image-failure", + "stagger-total-and-progress-sync-timing", + "changed-empty-and-failing-api", + "all-public-assets-and-persisted-url-references", +]; + pub(super) fn attach(result: &mut Value) { if !result.is_object() { return; } result["developmentContract"] = json!({ - "version": 3, + "version": 4, "guide": "devup://guide/usage#behavior-and-data-contracts", - "installedReference": "devfive-frontend/references/delivery-quality.md", "alwaysApplies": true, - "requirements": [ - "pure-tsx-sidecar-provenance", "jsx-theme-tokens-and-axis-shorthands", - "single-responsive-tree-and-source-text-wrapping", "persistent-server-layout", - "route-owned-page-body", "devup-api-only", - "stop-affected-api-gaps", - "preserve-instance-sizing-and-paint", - "paired-e2e-and-native-figma-visuals", - "semantic-native-controls-and-complete-pseudo-states", "variant-palette-consistency", - "single-source-url-content-selection", "viewport-footer-shell", - "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-start-middle-end", - "motion-trigger-reentry-history-and-image-failure", "changed-empty-and-failing-api", - "all-public-assets-and-persisted-url-references"], + "requirements": REQUIREMENTS, + "completionEvidence": COMPLETION_EVIDENCE, "staticExportProvesApplicationBehavior": false, - "nextAction": "Read the linked rules, apply behaviorPlan, fix and rerun functional E2E plus native Figma visuals before claiming completion." }); } @@ -45,7 +66,7 @@ mod tests { attach(&mut result); assert_eq!(result["developmentContract"]["alwaysApplies"], true); assert_eq!(result["developmentContract"]["staticExportProvesApplicationBehavior"], false); - assert_eq!(result["developmentContract"]["version"], 3); + assert_eq!(result["developmentContract"]["version"], 4); 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))); @@ -53,23 +74,68 @@ 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"] { + // Motion is a default item, each part under its own identifier: the + // candidates the plan infers, the default entrance/transition pass, + // the static-keyframes implementation and the one-shot/no-flash rule. + for required in [ + "motion-candidates-and-safe-progressive-enhancement", + "default-entrance-and-transition-motion", + "devup-ui-static-keyframes-motion", + "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"] { + for required in [ + "computed-style-and-animation-intermediate-state", + "animation-start-middle-end-frames", + "motion-trigger-reentry-history-and-image-failure", + "stagger-total-and-progress-sync-timing", + ] { 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"][..]), + (RULES, &["without being asked", "plays once", "opacity: 0", "prefers-reduced-motion", "back/forward", "failed images", "keyframes", "animationTimeline"][..]), + (MOTION_RULES, &["user stated", "keyframes", "animationTimeline=\"view()\"", "animationRange", "animationFillMode", "_motionReduce", "_focusWithin", "IntersectionObserver", "data-reveal", "framer-motion", "stagger", "background", "Progress line", "hydration", "JavaScript disabled", "focus", "start, a middle and the end", "Trigger timing", "Fast scroll", "back/forward", "block one", "unverified"][..]), ] { for term in terms { assert!(document.contains(term), "missing motion policy: {term}"); } } } + + /// The reply names identifiers; the guide must say what each one means, + /// and carry the standing text that used to ride in every reply. + #[test] + fn every_contract_identifier_is_explained_in_the_guide() { + for id in REQUIREMENTS.iter().chain(COMPLETION_EVIDENCE) { + assert!(RULES.contains(&format!("`{id}`")), "the guide does not explain {id}"); + } + // Prose is wrapped; compare it as a reader does, without line breaks. + let guide = super::super::guide::GUIDE.split_whitespace().collect::<Vec<_>>().join(" "); + for moved in [ + // developmentContract.nextAction and installedReference. + "fix and rerun functional E2E plus native Figma visuals before claiming completion", + "devfive-frontend/references/delivery-quality.md", + // behaviorPlan intentPolicy, clientBoundary, sourcePurity, contextIndex. + "explicit user requirements take precedence", + "mount the supplied tiny shared motion controller in the persistent layout", + "node identities and implementation evidence stay in JSON sidecars", + "candidate inference and returned rows are bounded per page", + // server.updateAvailable.autoUpdate.installedPathNote. + "self-update replaces the file inside it and never renames the directory", + // verificationScope's three explanations. + "each generated property against its source field, at the collected widths", + "read from constraints and geometry, not rendered", + "devup_visual_compare compares a consumer-rendered PNG at a drawn width", + ] { + assert!(guide.contains(moved), "moved out of the reply but not into the guide: {moved}"); + } + // No identifier is listed twice, and none was dropped by a rename. + let mut unique: Vec<_> = REQUIREMENTS.iter().chain(COMPLETION_EVIDENCE).collect(); + unique.sort(); + unique.dedup(); + assert_eq!(unique.len(), REQUIREMENTS.len() + COMPLETION_EVIDENCE.len()); + assert_eq!((REQUIREMENTS.len(), COMPLETION_EVIDENCE.len()), (20, 8)); + } } diff --git a/crates/devup-mcp/src/server/handlers/devup_figma_export.rs b/crates/devup-mcp/src/server/handlers/devup_figma_export.rs index 7041240f..5cd634b8 100644 --- a/crates/devup-mcp/src/server/handlers/devup_figma_export.rs +++ b/crates/devup-mcp/src/server/handlers/devup_figma_export.rs @@ -12,7 +12,7 @@ impl DevupServer { ) -> Result<CallToolResult, ErrorData> { let mut input = workflow.input; if let Some(cursor) = workflow.behavior_cursor.as_deref() { - if input.url.is_some() || workflow.job_id.is_some() || !input.frame_ids.is_empty() || input.all_screens || input.refresh || !input.asset_requests.is_empty() || input.page_scaffold.is_some() || workflow.asset_public_root.is_some() || input.output_paths.keys().any(|key|!matches!(key.as_str(),"behaviorPlan"|"motionControllerTsx"|"motionRuntime"|"motionBindings")) { + if input.url.is_some() || workflow.job_id.is_some() || !input.frame_ids.is_empty() || input.all_screens || input.refresh || !input.asset_requests.is_empty() || input.page_scaffold.is_some() || workflow.asset_public_root.is_some() || input.output_paths.keys().any(|key|!matches!(key.as_str(),"behaviorPlan"|"motionControllerTsx"|"motionRuntime"|"revealTsx"|"motionBindings")) { return Err(to_mcp_error(DevupError::new(ErrorCode::DevupInvalidInput, "behaviorCursor continues the original artifact/scope. Only behaviorPlan and generated motion outputPaths may be written; omit recapture/job/scaffold options.", false))); } let id = input.artifact_id.as_deref().ok_or_else(|| to_mcp_error(DevupError::new(ErrorCode::DevupInvalidInput, "behaviorCursor requires its original artifactId.", false)))?; 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 5af4f65d..87dfdee4 100644 --- a/crates/devup-mcp/src/server/handlers/devup_ui_validate.rs +++ b/crates/devup-mcp/src/server/handlers/devup_ui_validate.rs @@ -4,6 +4,8 @@ use crate::server::tools::UiValidateDetail; mod sources; #[path = "ui_validate_scopes.rs"] mod scopes; +#[path = "ui_validate_owner.rs"] +mod owner; #[tool_router(router = devup_ui_validate_router, vis = "pub(super)")] impl DevupServer { @@ -50,11 +52,16 @@ impl DevupServer { project_context::theme_for_validation(input.project_root.as_deref()) .map_err(to_mcp_error)? }; + // A single tsx is matched to the app that owns it: by where + // sourceName says it is, else by finding the file in the project. + let mut token_owner = None; if !paths_mode && input.files.is_none() - && let (Some(root), Some(name)) = (input.project_root.as_deref(), input.source_name.as_deref()) + && let Some(root) = input.project_root.as_deref() { - theme_lookup = scopes::for_source_name(root, name, theme_lookup).map_err(invalid)?; + let owned = owner::for_single_tsx(root, input.source_name.as_deref(), &input.tsx, theme_lookup).map_err(invalid)?; + theme_lookup = owned.lookup; + token_owner = owned.owner; } // 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. @@ -252,6 +259,7 @@ impl DevupServer { "definedByTheme": report.available_token_count, "unknownTokenCheckRan": theme_available, "devupJson": theme_lookup.devup_json, + "owner": token_owner, })), "themeAvailable": theme_available, "themeGuardrail": if scoped_tokens.is_some() { scoped_guardrail } else { theme_lookup.guardrail }, diff --git a/crates/devup-mcp/src/server/handlers/ui_validate_owner.rs b/crates/devup-mcp/src/server/handlers/ui_validate_owner.rs new file mode 100644 index 00000000..9491f052 --- /dev/null +++ b/crates/devup-mcp/src/server/handlers/ui_validate_owner.rs @@ -0,0 +1,212 @@ +//! Finds the app that owns a single `tsx`, so its tokens are checked against +//! that app's `devup.json` instead of being left unverified at a repository +//! root with several themes. +//! +//! The owner is taken from the first of these that decides it: +//! 1. `sourceName` as a path inside the project (relative or absolute): the +//! nearest `devup.json` above that location. The named file need not exist. +//! 2. `sourceName` as the tail of a path (`src/Hero.tsx`, `Hero.tsx`): the +//! project files that end with it, when they all sit under one theme. +//! 3. No usable name: the project files whose text is this `tsx`, when they +//! all sit under one theme. +//! +//! Anything else (no match, or matches under different themes) stays +//! unverified, and the candidates are named. +use std::path::Path; + +use devup_mcp_devup_ui::ui_validate::SourceFile; +use devup_mcp_devup_ui::ui_validate::theme_scopes::nearest_scope; +use serde_json::{Value, json}; + +use super::project_context::ThemeLookup; +use super::scopes::{NO_THEME_ABOVE, disk_scopes}; + +/// How many directory entries the search may visit and how many candidate +/// files it may open: a bounded look, not an index of the repository. +const MAX_VISITED: usize = 20_000; +const MAX_OPENED: usize = 256; +const MAX_DEPTH: usize = 12; +const MAX_CANDIDATES: usize = 8; + +pub(super) struct Owned { + pub lookup: ThemeLookup, + /// How the owner was resolved, for `tokens.owner`. + pub owner: Option<Value>, +} + +/// `sourceName` as a path relative to the project root: a clean relative +/// path as given, or an absolute one that lies inside the root. +fn relative_name(root: &Path, name: &str) -> Option<String> { + let name = name.trim().replace('\\', "/"); + let absolute = name.starts_with('/') || name.as_bytes().get(1) == Some(&b':'); + // An absolute path may spell the same directory differently (a short + // 8.3 name, a symlink, another case): compare what both resolve to. The + // file itself need not exist, so the deepest existing directory decides. + let resolved = absolute.then(|| { + let path = Path::new(&name); + path.ancestors().find_map(|ancestor| { + let real = std::fs::canonicalize(ancestor).ok()?; + Some(real.join(path.strip_prefix(ancestor).ok()?)) + }) + }); + if let Some(Some(inside)) = resolved.map(|path| path.and_then(|path| path.strip_prefix(root).ok().map(Path::to_path_buf))) { + let relative = inside.to_string_lossy().replace('\\', "/"); + return (!relative.is_empty() && relative.split('/').all(|part| part != "..")).then_some(relative); + } + let relative = if absolute { + let strip = |path: &str| path.trim_start_matches("//?/").trim_end_matches('/').to_owned(); + let (root, name) = (strip(&root.to_string_lossy().replace('\\', "/")), strip(&name)); + // Windows paths differ in case and nothing else; elsewhere case counts. + let inside = if cfg!(windows) { + name.len() > root.len() && name[..root.len()].eq_ignore_ascii_case(&root) + } else { + name.starts_with(&root) + }; + if !inside || name.as_bytes().get(root.len()) != Some(&b'/') { + return None; + } + name[root.len() + 1..].to_owned() + } else { + name + }; + let clean = !relative.is_empty() && relative.split('/').all(|part| !part.is_empty() && part != ".." && part != "."); + clean.then_some(relative) +} + +/// Source files of the project as `(relative path, byte length)`. +fn project_sources(root: &Path) -> Vec<(String, u64)> { + let mut found = Vec::new(); + let mut pending = vec![(root.to_path_buf(), 0)]; + let mut visited = 0; + while let Some((dir, depth)) = pending.pop() { + let Ok(entries) = std::fs::read_dir(&dir) else { + continue; + }; + for entry in entries.flatten() { + visited += 1; + if visited > MAX_VISITED { + return found; + } + let name = entry.file_name(); + let name = name.to_string_lossy(); + let Ok(kind) = entry.file_type() else { + continue; + }; + if kind.is_dir() { + if depth < MAX_DEPTH && !name.starts_with('.') && !["node_modules", "dist", "build", "target", "df"].contains(&name.as_ref()) { + pending.push((entry.path(), depth + 1)); + } + } else if kind.is_file() + && matches!(entry.path().extension().and_then(|value| value.to_str()), Some("tsx" | "ts" | "jsx" | "js")) + && let (Ok(relative), Ok(metadata)) = (entry.path().strip_prefix(root).map(Path::to_path_buf), entry.metadata()) + { + found.push((relative.to_string_lossy().replace('\\', "/"), metadata.len())); + } + } + } + found.sort(); + found +} + +/// Text as compared: without a byte-order mark, with `\n` line ends and no +/// surrounding blank space. +fn comparable(text: &str) -> String { + text.trim_start_matches('\u{feff}').replace("\r\n", "\n").trim().to_owned() +} + +fn matching_files(root: &Path, name: Option<&str>, tsx: &str) -> (Vec<String>, &'static str) { + let sources = project_sources(root); + if let Some(name) = name { + let tail = format!("/{name}"); + let named: Vec<String> = sources.iter().filter(|(path, _)| path == name || path.ends_with(&tail)).map(|(path, _)| path.clone()).collect(); + if !named.is_empty() { + return (named, "sourceName-suffix"); + } + } + let wanted = comparable(tsx); + // A file that holds this text is about as long as it: line ends and a + // trailing newline are the only slack. + let lines = wanted.matches('\n').count() as u64; + let (shortest, longest) = (wanted.len() as u64, wanted.len() as u64 + lines + 8); + let same: Vec<String> = sources + .iter() + .filter(|(_, bytes)| (shortest..=longest).contains(bytes)) + .take(MAX_OPENED) + .filter(|(path, _)| std::fs::read_to_string(root.join(path)).is_ok_and(|content| comparable(&content) == wanted)) + .map(|(path, _)| path.clone()) + .collect(); + (same, "content-match") +} + +fn unverified(message: &str, paths: Vec<String>) -> ThemeLookup { + ThemeLookup { theme: None, devup_json: None, guardrail: Some(json!({ "message": message, "unverifiedFiles": 1, "unverifiedPaths": paths })) } +} + +pub(super) fn for_single_tsx(root: &str, source_name: Option<&str>, tsx: &str, lookup: ThemeLookup) -> Result<Owned, String> { + let root_path = std::fs::canonicalize(root).map_err(|error| format!("projectRoot: {error}"))?; + let name = source_name.and_then(|name| relative_name(&root_path, name)); + // 1. A location inside the project decides by itself. + if let Some(path) = name.as_deref().filter(|path| path.contains('/')) { + let file = SourceFile { path: path.to_owned(), content: String::new() }; + let mut disk = disk_scopes(root, std::slice::from_ref(&file))?; + if let Some(dir) = nearest_scope(&disk.scopes, path).map(|scope| scope.dir.clone()) { + let index = disk.scopes.iter().position(|scope| scope.dir == dir).expect("nearest scope"); + let scope = disk.scopes.swap_remove(index); + let owner = json!({ "resolvedFrom": "sourceName", "path": path, "devupJson": scope.devup_json }); + return Ok(Owned { lookup: ThemeLookup { theme: Some(scope.theme), devup_json: Some(scope.devup_json), guardrail: None }, owner: Some(owner) }); + } + // Another package's theme is not this file's: unverified, not checked. + if lookup.theme.is_some() && !disk.fallback_applies(path) { + return Ok(Owned { lookup: unverified(NO_THEME_ABOVE, vec![path.to_owned()]), owner: None }); + } + } + // A single project theme already answers; there is no owner to choose. + if lookup.theme.is_some() { + return Ok(Owned { lookup, owner: None }); + } + // 2 and 3. Look for the file itself: by the tail of its name, then by its text. + let (matches, resolved_from) = matching_files(&root_path, name.as_deref(), tsx); + if matches.is_empty() { + return Ok(Owned { lookup, owner: None }); + } + let files: Vec<SourceFile> = matches.iter().map(|path| SourceFile { path: path.clone(), content: String::new() }).collect(); + let mut disk = disk_scopes(root, &files)?; + let owners: Vec<Option<String>> = matches.iter().map(|path| nearest_scope(&disk.scopes, path).map(|scope| scope.dir.clone())).collect(); + let first = owners[0].clone(); + if let Some(dir) = first.filter(|_| owners.iter().all(|owner| *owner == owners[0])) { + let index = disk.scopes.iter().position(|scope| scope.dir == dir).expect("owning scope"); + let scope = disk.scopes.swap_remove(index); + let owner = json!({ "resolvedFrom": resolved_from, "path": matches[0], "matches": matches.len(), "devupJson": scope.devup_json }); + return Ok(Owned { lookup: ThemeLookup { theme: Some(scope.theme), devup_json: Some(scope.devup_json), guardrail: None }, owner: Some(owner) }); + } + // Found, but under different themes or under none: say which, and do not + // pick one. + let mut guardrail = lookup.guardrail.clone().unwrap_or_else(|| json!({ "message": NO_THEME_ABOVE })); + guardrail["ownerCandidates"] = json!(matches.iter().take(MAX_CANDIDATES).collect::<Vec<_>>()); + guardrail["ownerResolution"] = json!(format!("{resolved_from} found {} file(s) that no single devup.json governs; pass sourceName as the file's path from projectRoot.", matches.len())); + Ok(Owned { lookup: ThemeLookup { theme: None, devup_json: None, guardrail: Some(guardrail) }, owner: None }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_source_name_becomes_a_path_inside_the_project_or_nothing() { + let root = Path::new(if cfg!(windows) { r"\\?\C:\Work\repo" } else { "/work/repo" }); + let inside = if cfg!(windows) { r"c:\work\repo\apps\a\src\Hero.tsx" } else { "/work/repo/apps/a/src/Hero.tsx" }; + assert_eq!(relative_name(root, inside).as_deref(), Some("apps/a/src/Hero.tsx")); + assert_eq!(relative_name(root, "apps\\a\\src\\Hero.tsx").as_deref(), Some("apps/a/src/Hero.tsx")); + assert_eq!(relative_name(root, "Hero.tsx").as_deref(), Some("Hero.tsx")); + let outside = if cfg!(windows) { r"C:\Work\repository\apps\a\Hero.tsx" } else { "/work/repository/apps/a/Hero.tsx" }; + for name in [outside, "../apps/a/Hero.tsx", "apps//Hero.tsx", "./Hero.tsx", ""] { + assert_eq!(relative_name(root, name), None, "{name}"); + } + } + + #[test] + fn text_is_compared_without_line_end_or_edge_differences() { + assert_eq!(comparable("\u{feff}a\r\nb\r\n"), comparable("a\nb")); + assert_ne!(comparable("a\n b"), comparable("a\nb")); + } +} diff --git a/crates/devup-mcp/src/server/handlers/ui_validate_scopes.rs b/crates/devup-mcp/src/server/handlers/ui_validate_scopes.rs index 1ed637d0..cb7d6858 100644 --- a/crates/devup-mcp/src/server/handlers/ui_validate_scopes.rs +++ b/crates/devup-mcp/src/server/handlers/ui_validate_scopes.rs @@ -71,51 +71,6 @@ pub(super) fn disk_scopes(root: &str, files: &[SourceFile]) -> Result<DiskScopes 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 { diff --git a/crates/devup-mcp/src/server/projection/finalize_export_delivery.rs b/crates/devup-mcp/src/server/projection/finalize_export_delivery.rs index a548f196..843356d9 100644 --- a/crates/devup-mcp/src/server/projection/finalize_export_delivery.rs +++ b/crates/devup-mcp/src/server/projection/finalize_export_delivery.rs @@ -250,7 +250,7 @@ pub(super) async fn finalize_export_delivery( *contents = serde_json::to_string_pretty(&result[name]).unwrap_or_default(); } } - for field in ["behaviorPlan", "developmentContract", "motionControllerTsx", "motionRuntime", "motionBindings"] { + for field in ["behaviorPlan", "developmentContract", "motionControllerTsx", "motionRuntime", "revealTsx", "motionBindings"] { if output_paths.contains_key(field) && let Some(value) = result.get(field) { @@ -288,6 +288,7 @@ pub(super) async fn finalize_export_delivery( "behaviorPlan", "motionControllerTsx", "motionRuntime", + "revealTsx", "motionBindings", "developmentContract", ] { diff --git a/crates/devup-mcp/src/server/projection/resource_delivery.rs b/crates/devup-mcp/src/server/projection/resource_delivery.rs index 79bc4902..b0f86e33 100644 --- a/crates/devup-mcp/src/server/projection/resource_delivery.rs +++ b/crates/devup-mcp/src/server/projection/resource_delivery.rs @@ -202,6 +202,7 @@ pub(super) async fn apply_delivery( "tsx", "motionControllerTsx", "motionRuntime", + "revealTsx", "motionBindings", "componentTsx", "responsiveTsx", diff --git a/crates/devup-mcp/src/server/projection/resource_outputs.rs b/crates/devup-mcp/src/server/projection/resource_outputs.rs index eba72c07..fab414de 100644 --- a/crates/devup-mcp/src/server/projection/resource_outputs.rs +++ b/crates/devup-mcp/src/server/projection/resource_outputs.rs @@ -2,7 +2,7 @@ pub(super) fn projected_outputs_from_result( result: &Map<String, Value>, ) -> Result<Vec<ProjectedOutput>, DevupError> { let mut outputs = Vec::new(); - for (field, name) in [("motionControllerTsx", "MotionController.tsx"), ("motionRuntime", "motion-runtime.ts")] { + for (field, name) in [("motionControllerTsx", "MotionController.tsx"), ("motionRuntime", "motion-runtime.ts"), ("revealTsx", "Reveal.tsx")] { if let Some(source) = result.get(field).and_then(Value::as_str) { outputs.push(ProjectedOutput::text(name, "text/typescript", source.as_bytes().to_vec())); } } for field in ["tsx", "componentTsx", "responsiveTsx"] { diff --git a/crates/devup-mcp/src/server/projection/verification_scope.rs b/crates/devup-mcp/src/server/projection/verification_scope.rs index 75133ebe..c3e4b456 100644 --- a/crates/devup-mcp/src/server/projection/verification_scope.rs +++ b/crates/devup-mcp/src/server/projection/verification_scope.rs @@ -19,16 +19,14 @@ fn attach_verification_scope(value: &mut Value, responsive_widths: &[f64]) { if widths.is_empty() { return; } + // Each entry names the reply field that holds the check. What the two + // static checks cover and how a rendered check is obtained is standing + // text in the guide (rule 13), not repeated in every reply. let mut scope = json!({ "collectedWidths": widths, - "staticallyChecked": { - "mapping": "Each generated property against its source field, at the collected widths (quality.projection).", - "widthChange": "Anchors and sizing intent when a container is not its drawn width (widthBehavior); read from constraints and geometry, not rendered.", - }, - "rendered": { - "state": "not-performed", - "note": "Nothing was rendered. devup_visual_compare compares a consumer-rendered PNG at a drawn width; devup_runtime_audit plans and ingests a consumer-side browser run at any widths.", - }, + "staticallyChecked": {"mapping": "quality.projection", "widthChange": "widthBehavior"}, + "rendered": {"state": "not-performed"}, + "guide": "devup://guide/usage#13-exact-means-exact-at-the-drawn-width", }); if !responsive_widths.is_empty() { scope["betweenCollectedWidths"] = json!( diff --git a/crates/devup-mcp/src/server/runtime_audit.rs b/crates/devup-mcp/src/server/runtime_audit.rs index ed91868b..6c32cb3a 100644 --- a/crates/devup-mcp/src/server/runtime_audit.rs +++ b/crates/devup-mcp/src/server/runtime_audit.rs @@ -14,6 +14,7 @@ // Source sections share this module to preserve privacy and test names. include!("runtime_audit/inputs_and_capture.rs"); include!("runtime_audit/motion_validation.rs"); +include!("runtime_audit/motion_evidence.rs"); include!("runtime_audit/write_plan.rs"); include!("runtime_audit/capture_comparison.rs"); include!("runtime_audit/plan_columns.rs"); diff --git a/crates/devup-mcp/src/server/runtime_audit/motion_evidence.rs b/crates/devup-mcp/src/server/runtime_audit/motion_evidence.rs new file mode 100644 index 00000000..671e55ee --- /dev/null +++ b/crates/devup-mcp/src/server/runtime_audit/motion_evidence.rs @@ -0,0 +1,291 @@ +// Motion evidence, as an agent reads it after ingest: one row per motion check +// per width, and one line per candidate saying whether it is verified. +// +// A candidate is verified only when a check for it passed at a mobile and at +// a desktop width. Everything else is listed as unverified with the reason: +// a width never run, a scenario not requested, a scenario that could not be +// observed. Unverified is never reported as a pass. + +/// Widths below this are a mobile layout; at or above [`DESKTOP_MIN_WIDTH`] +/// a desktop one. +const MOBILE_MAX_WIDTH: u32 = 767; +const DESKTOP_MIN_WIDTH: u32 = 1024; + +const REVEAL_SCENARIOS: [&str; 12] = [ + "fast-scroll", "reload", "history", "bfcache", "client-nav", "image-delay", "image-failure", + "focus", "reduced-motion", "no-script", "stagger", "progress-sync", +]; + +fn viewport_class(width: u32) -> &'static str { + if width <= MOBILE_MAX_WIDTH { + "mobile" + } else if width >= DESKTOP_MIN_WIDTH { + "desktop" + } else { + "tablet" + } +} + +fn names(value: &Value) -> Vec<String> { + value.as_array().into_iter().flatten().filter_map(Value::as_str).map(str::to_owned).collect() +} + +/// One planned motion check at one width, with what the executor reported +/// for it. `item` is the executor's row; without one nothing ran. +struct MotionCheckAt<'a> { + screen: &'a str, + viewport: &'a ViewportInput, + index: usize, + check: &'a CheckInput, + item: Option<&'a Value>, + status: &'a str, +} + +/// The evidence row of one motion check at one width. Without an executor +/// row every part of the check is unverified. +fn motion_evidence_row(policy: &OutputPolicy, report_dir: &Path, at: &MotionCheckAt<'_>) -> Value { + let MotionCheckAt { screen, viewport, index, check, item, status } = *at; + let motion = check.motion.as_ref().unwrap_or(&Value::Null); + let kind = motion["kind"].as_str().unwrap_or_default(); + let measured = item.map_or(&Value::Null, |item| &item["measured"]); + let mut row = json!({ + "screen": screen, "check": index, + "viewport": {"width": viewport.width, "height": viewport.height, "class": viewport_class(viewport.width)}, + "candidateId": motion["candidateId"], "kind": kind, "status": status, + "reason": item.map(|item| &item["reason"]), + }); + if kind != "reveal" { + return row; + } + let mut unverified = Vec::new(); + if item.is_none() || matches!(status, "not-run" | "skipped") { + unverified.push("the whole check: it did not run at this width".to_owned()); + } else { + // Frames: the values are the evidence; a screenshot is kept only when + // its bytes match what the report says. + let mut frames = serde_json::Map::new(); + for name in ["start", "middle", "end"] { + let frame = &measured["frames"][name]; + if !frame.is_object() { + unverified.push(format!("{name} frame")); + continue; + } + let shot = &frame["screenshot"]; + let screenshot = shot.is_object().then(|| match verify_capture(policy, report_dir, shot, None) { + Ok(_) => json!({"path": shot["path"], "verified": true}), + Err(Integrity::Tampered(issue) | Integrity::Unverified(issue)) => json!({"path": shot["path"], "verified": false, "issue": issue}), + }); + frames.insert(name.to_owned(), json!({ + "opacity": frame["opacity"], "translate": frame["translate"], + "visibleRatio": frame["visibleRatio"], "screenshot": screenshot, + })); + } + row["mode"] = measured["mode"].clone(); + row["frames"] = Value::Object(frames); + let trigger = &measured["trigger"]; + row["trigger"] = json!({ + "verified": trigger["verified"], "startedOffscreen": trigger["startedOffscreen"], + "late": trigger["late"], "visibleRatio": trigger["visibleRatio"], + "completeVisiblePx": trigger["completeVisiblePx"], "reason": trigger["reason"], + }); + if trigger["verified"] != true { + unverified.push("trigger position".to_owned()); + } + row["reentry"] = measured["reentry"].clone(); + if measured["reentry"]["verified"] != true { + unverified.push("re-entry".to_owned()); + } + let scenarios = measured["scenarios"].as_object(); + row["scenarios"] = json!(scenarios.map(|scenarios| { + scenarios.iter().map(|(name, result)| (name.clone(), result["status"].clone())).collect::<serde_json::Map<_, _>>() + })); + for name in REVEAL_SCENARIOS { + match scenarios.and_then(|scenarios| scenarios.get(name)).map(|result| &result["status"]).and_then(Value::as_str) { + Some("pass" | "fail" | "not-applicable") => {} + Some(_) => unverified.push(format!("{name}: {}", measured["scenarios"][name]["reason"].as_str().unwrap_or("could not be observed"))), + None => unverified.push(format!("{name}: not requested")), + } + } + row["notApplicable"] = json!(names(&measured["notApplicable"])); + let stagger = &measured["scenarios"]["stagger"]; + if stagger["stepsMs"].is_array() { + row["stagger"] = json!({"stepsMs": stagger["stepsMs"], "spanMs": stagger["spanMs"], "totalMs": stagger["totalMs"], "bounds": stagger["bounds"]}); + } + let sync = &measured["scenarios"]["progress-sync"]; + if sync["gaps"].is_array() { + row["progressSync"] = json!({"gapsMs": sync["gaps"], "toleranceMs": sync["toleranceMs"], "axis": sync["axis"]}); + } + } + row["unverified"] = json!(unverified); + row +} + +/// Folds the rows into one line per candidate. `verified` is true only with +/// a passing row at a mobile and a desktop width and nothing left unverified +/// in those rows. +fn motion_evidence_summary(rows: Vec<Value>) -> Value { + let mut ids: Vec<&str> = rows.iter().filter_map(|row| row["candidateId"].as_str()).collect(); + ids.sort_unstable(); + ids.dedup(); + let candidates = ids + .iter() + .map(|id| { + let own: Vec<&Value> = rows.iter().filter(|row| row["candidateId"] == *id).collect(); + let passed = |class: &str| own.iter().any(|row| row["status"] == "pass" && row["viewport"]["class"] == class); + let mut unverified: Vec<String> = Vec::new(); + for class in ["mobile", "desktop"] { + if !passed(class) { + unverified.push(format!("no passing check at a {class} width")); + } + } + for row in &own { + for entry in names(&row["unverified"]) { + let entry = format!("{}px: {entry}", row["viewport"]["width"]); + if !unverified.contains(&entry) { + unverified.push(entry); + } + } + } + let failed = own.iter().any(|row| row["status"] == "fail"); + json!({ + "candidateId": id, "kind": own[0]["kind"], + "passedWidths": own.iter().filter(|row| row["status"] == "pass").map(|row| row["viewport"]["width"].clone()).collect::<Vec<_>>(), + "failedWidths": own.iter().filter(|row| row["status"] == "fail").map(|row| row["viewport"]["width"].clone()).collect::<Vec<_>>(), + "status": if failed { "fail" } else if unverified.is_empty() { "verified" } else { "unverified" }, + "unverified": unverified, + }) + }) + .collect::<Vec<_>>(); + let count = |status: &str| candidates.iter().filter(|candidate| candidate["status"] == status).count(); + json!({ + "verified": count("verified"), "unverified": count("unverified"), "failed": count("fail"), + "candidates": candidates, + "rows": rows, + "rule": "A motion candidate is verified only by a passing check at a mobile (<=767px) and a desktop (>=1024px) width with nothing left unverified. Scenarios that were not requested or could not be observed are unverified, never passed; not-applicable means the block has nothing for that scenario to observe.", + }) +} + +#[cfg(test)] +mod motion_evidence_tests { + use super::*; + + fn viewport(width: u32) -> ViewportInput { + serde_json::from_value(json!({"width": width, "height": 800})).unwrap() + } + + fn reveal_check() -> CheckInput { + serde_json::from_value(json!({"kind": "motion", "motion": {"kind": "reveal", "candidateId": "section-entrance:news", "target": "#news"}})).unwrap() + } + + /// What reveal-checks.mjs reports for a passing scroll-linked section. + fn measured(scenarios: Value) -> Value { + let frame = |opacity: f64, translate: f64, ratio: f64| json!({"opacity": opacity, "translate": translate, "visibleRatio": ratio}); + json!({"kind": "reveal", "candidateId": "section-entrance:news", "mode": "scroll-linked", + "frames": {"start": frame(0.0, 16.0, 0.0), "middle": frame(0.24, 12.1, 0.15), "end": frame(1.0, 0.0, 1.0)}, + "trigger": {"verified": true, "startedOffscreen": false, "late": false, "visibleRatio": 0.01, "completeVisiblePx": 242}, + "reentry": {"verified": true, "replayed": false, "hiddenWhileAway": false}, + "scenarios": scenarios, "notApplicable": ["stagger", "progress-sync"], "unverified": []}) + } + + fn every_scenario() -> Value { + let mut scenarios = serde_json::Map::new(); + for name in REVEAL_SCENARIOS { + let status = if matches!(name, "stagger" | "progress-sync") { "not-applicable" } else { "pass" }; + scenarios.insert(name.to_owned(), json!({"status": status})); + } + Value::Object(scenarios) + } + + fn row(width: u32, status: &str, measured: Option<Value>) -> Value { + let policy = OutputPolicy::from_roots(vec![std::env::temp_dir()]).unwrap(); + let item = measured.map(|measured| json!({"index": 0, "status": status, "reason": "observed", "measured": measured})); + motion_evidence_row(&policy, &std::env::temp_dir(), &MotionCheckAt { screen: "home", viewport: &viewport(width), index: 0, check: &reveal_check(), item: item.as_ref(), status }) + } + + #[test] + fn a_candidate_is_verified_only_by_passing_mobile_and_desktop_checks() { + let rows = vec![row(390, "pass", Some(measured(every_scenario()))), row(1440, "pass", Some(measured(every_scenario())))]; + assert_eq!(rows[0]["viewport"]["class"], "mobile"); + assert_eq!(rows[1]["viewport"]["class"], "desktop"); + assert_eq!(rows[0]["frames"]["middle"]["opacity"], 0.24); + assert_eq!(rows[0]["trigger"]["completeVisiblePx"], 242); + assert_eq!(rows[0]["unverified"], json!([])); + assert_eq!(rows[0]["notApplicable"], json!(["stagger", "progress-sync"])); + let summary = motion_evidence_summary(rows); + assert_eq!((summary["verified"].clone(), summary["unverified"].clone(), summary["failed"].clone()), (json!(1), json!(0), json!(0))); + assert_eq!(summary["candidates"][0]["status"], "verified"); + assert_eq!(summary["candidates"][0]["passedWidths"], json!([390, 1440])); + + // Desktop only: the mobile width was never checked. + let summary = motion_evidence_summary(vec![row(1440, "pass", Some(measured(every_scenario())))]); + assert_eq!(summary["candidates"][0]["status"], "unverified"); + assert_eq!(summary["candidates"][0]["unverified"], json!(["no passing check at a mobile width"])); + // A tablet width is neither. + let summary = motion_evidence_summary(vec![row(858, "pass", Some(measured(every_scenario())))]); + assert_eq!(summary["candidates"][0]["unverified"].as_array().unwrap().len(), 2); + } + + #[test] + fn what_was_not_requested_or_not_observed_is_unverified_never_a_pass() { + // Only two scenarios were requested, and one could not be observed. + let partial = json!({"fast-scroll": {"status": "pass"}, "bfcache": {"status": "unverified", "reason": "no browser with the back/forward cache could be started"}}); + let mobile = row(390, "inconclusive", Some(measured(partial))); + let unverified = mobile["unverified"].as_array().unwrap(); + assert!(unverified.contains(&json!("bfcache: no browser with the back/forward cache could be started")), "{mobile}"); + assert!(unverified.contains(&json!("reload: not requested")), "{mobile}"); + assert!(!unverified.iter().any(|entry| entry.as_str().unwrap().starts_with("fast-scroll")), "{mobile}"); + assert_eq!(mobile["scenarios"]["bfcache"], "unverified"); + + // A width where the check never ran, and one with no frames or trigger. + let missing = row(1440, "not-run", None); + assert_eq!(missing["unverified"], json!(["the whole check: it did not run at this width"])); + let mut bare = measured(every_scenario()); + bare["frames"] = json!({"end": {"opacity": 1, "translate": 0}}); + bare["trigger"] = json!({"verified": false, "reason": "target is already in view"}); + bare["reentry"] = json!({"verified": false}); + let bare = row(390, "inconclusive", Some(bare)); + assert_eq!(bare["unverified"], json!(["start frame", "middle frame", "trigger position", "re-entry"])); + + let summary = motion_evidence_summary(vec![mobile, missing]); + assert_eq!(summary["candidates"][0]["status"], "unverified"); + assert_eq!(summary["verified"], 0); + assert!(summary["candidates"][0]["unverified"].as_array().unwrap().iter().any(|entry| entry == "1440px: the whole check: it did not run at this width")); + // A failure anywhere is a failure, whatever else passed. + let summary = motion_evidence_summary(vec![row(390, "pass", Some(measured(every_scenario()))), row(1440, "fail", Some(measured(every_scenario())))]); + assert_eq!(summary["candidates"][0]["status"], "fail"); + assert_eq!(summary["candidates"][0]["failedWidths"], json!([1440])); + } + + #[test] + fn frame_screenshots_and_timing_measurements_are_carried_as_evidence() { + let dir = std::env::temp_dir().join(format!("devup-motion-evidence-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let image = image::RgbaImage::from_pixel(4, 4, image::Rgba([255, 255, 255, 255])); + let mut bytes = std::io::Cursor::new(Vec::new()); + image.write_to(&mut bytes, image::ImageFormat::Png).unwrap(); + let bytes = bytes.into_inner(); + std::fs::write(dir.join("frame.png"), &bytes).unwrap(); + let mut scenarios = every_scenario(); + scenarios["stagger"] = json!({"status": "pass", "stepsMs": [50, 67, 50], "spanMs": 167, "totalMs": 567, "bounds": {"maxStepMs": 150}}); + scenarios["progress-sync"] = json!({"status": "pass", "gaps": [10, -20, 5], "toleranceMs": 200, "axis": "vertical"}); + let mut measured = measured(scenarios); + measured["frames"]["start"]["screenshot"] = json!({"path": "frame.png", "sha256": sha256(&bytes), "bytes": bytes.len()}); + measured["frames"]["end"]["screenshot"] = json!({"path": "frame.png", "sha256": "0".repeat(64), "bytes": bytes.len()}); + let policy = OutputPolicy::from_roots(vec![dir.clone()]).unwrap(); + let item = json!({"index": 0, "status": "pass", "measured": measured}); + let row = motion_evidence_row(&policy, &dir, &MotionCheckAt { screen: "home", viewport: &viewport(390), index: 0, check: &reveal_check(), item: Some(&item), status: "pass" }); + drop(policy); + std::fs::remove_dir_all(&dir).unwrap(); + assert_eq!(row["frames"]["start"]["screenshot"], json!({"path": "frame.png", "verified": true})); + // Bytes that differ from the report are not evidence. + assert_eq!(row["frames"]["end"]["screenshot"]["verified"], false); + assert_eq!(row["frames"]["middle"]["screenshot"], Value::Null); + assert_eq!(row["stagger"]["totalMs"], 567); + assert_eq!(row["progressSync"]["gapsMs"], json!([10, -20, 5])); + // Other motion kinds keep their own row without reveal fields. + let tabs: CheckInput = serde_json::from_value(json!({"kind": "motion", "motion": {"kind": "tabs", "candidateId": "tabs:1", "target": "nav", "links": "a", "indicator": "i", "next": "a"}})).unwrap(); + let item = json!({"status": "pass", "measured": {}}); + let row = motion_evidence_row(&OutputPolicy::from_roots(vec![std::env::temp_dir()]).unwrap(), &std::env::temp_dir(), &MotionCheckAt { screen: "home", viewport: &viewport(390), index: 1, check: &tabs, item: Some(&item), status: "pass" }); + assert!(row.get("unverified").is_none() && row.get("frames").is_none(), "{row}"); + } +} 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 2dace171..a6b2e920 100644 --- a/crates/devup-mcp/src/server/runtime_audit/motion_validation.rs +++ b/crates/devup-mcp/src/server/runtime_audit/motion_validation.rs @@ -46,42 +46,52 @@ 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. + // are named exactly, so a typo cannot silently run nothing. With no list + // every scenario runs. + let semantic = |key: &str| -> Result<Option<&str>, DevupError> { + let Some(selector) = value.get(key) else { + return Ok(None); + }; + let selector = selector + .as_str() + .filter(|selector| !selector.is_empty() && !selector.contains("data-figma") && !selector.contains("data-source-node")) + .ok_or_else(|| invalid(format!("motion.{key} requires a semantic selector")))?; + bounded_text(&format!("motion.{key}"), selector)?; + Ok(Some(selector)) + }; + // What a reveal alone accepts: the animated items of a group, the + // progress line, the control to focus and the link to leave the page by. + let reveal_only = ["scenarios", "items", "progress", "focus", "navigate", "settleMs", "maxStepMs", "maxSpanMs", "maxTotalMs", "syncToleranceMs", "progressMs"]; + if kind != "reveal" && let Some(key) = reveal_only.iter().find(|key| value.get(**key).is_some()) { + return Err(invalid(format!("motion.{key} is for reveal only"))); + } + let (items, progress, _focus, navigate) = (semantic("items")?, semantic("progress")?, semantic("focus")?, semantic("navigate")?); 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))) - }); + let valid = scenarios.as_array().is_some_and(|items| { + items.len() <= REVEAL_SCENARIOS.len() + && items.iter().all(|item| item.as_str().is_some_and(|name| REVEAL_SCENARIOS.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", - )); + return Err(invalid(format!("motion.scenarios takes {}", REVEAL_SCENARIOS.join(", ")))); } - // 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)?; + // A scenario named explicitly must be observable: its selectors are + // required, never guessed. + let named = |name: &str| scenarios.as_array().is_some_and(|items| items.iter().any(|item| item == name)); + for (scenario, key, present) in [ + ("progress-sync", "progress", progress.is_some()), + ("progress-sync", "items", items.is_some()), + ("stagger", "items", items.is_some()), + ("client-nav", "navigate", navigate.is_some()), + ] { + if named(scenario) && !present { + return Err(invalid(format!("{scenario} requires a semantic 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")); + for (key, max) in [("settleMs", 10_000), ("maxStepMs", 10_000), ("maxSpanMs", 60_000), ("maxTotalMs", 60_000), ("syncToleranceMs", 10_000), ("progressMs", 60_000)] { + if value.get(key).is_some_and(|value| value.as_u64().is_none_or(|ms| ms > max)) { + return Err(invalid(format!("motion.{key} must be 0..{max}"))); + } } if value.get("resizeWidth").is_some_and(|value| { value @@ -114,18 +124,31 @@ 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}); + let reveal = json!({"kind":"reveal","candidateId":"section-entrance:1","target":"#news","scenarios":["fast-scroll","reload","history","bfcache","image-delay","image-failure","focus","reduced-motion","no-script"],"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"}); + // No list: every scenario runs, and the selectors that some of them + // need are optional (the scenario is then not-applicable or unverified). + let every = json!({"kind":"reveal","candidateId":"section-entrance:1","target":"#news","navigate":"nav a[href='/about']"}); + assert!(validate_motion(Some(&every)).is_ok()); + let timeline = json!({"kind":"reveal","candidateId":"section-entrance:2","target":"#history","scenarios":["progress-sync","stagger"],"progress":".line","items":"li","maxStepMs":400,"maxSpanMs":900,"maxTotalMs":1400,"syncToleranceMs":150}); assert!(validate_motion(Some(&timeline)).is_ok()); + let group = json!({"kind":"reveal","candidateId":"section-entrance:3","target":"#cards","items":".card","scenarios":["stagger","client-nav"],"navigate":"#about"}); + assert!(validate_motion(Some(&group)).is_ok()); + assert_eq!(REVEAL_SCENARIOS.len(), 12); for patch in [ json!({"scenarios":["fast-scrol"]}), json!({"scenarios":["progress-sync"]}), json!({"scenarios":["progress-sync"],"progress":".line","items":"[data-figma-node-id]"}), + json!({"scenarios":["stagger"]}), + json!({"scenarios":["client-nav"]}), json!({"scenarios":"focus"}), json!({"kind":"tabs","links":"a","indicator":"i","next":"a"}), json!({"focus":"[data-figma-node-id]"}), + json!({"navigate":"[data-source-node]"}), + json!({"items":""}), json!({"settleMs":60_000}), + json!({"maxStepMs":-1}), + json!({"maxTotalMs":600_000}), ] { let mut changed = reveal.clone(); for (key, value) in patch.as_object().unwrap() { diff --git a/crates/devup-mcp/src/server/runtime_audit/tests.rs b/crates/devup-mcp/src/server/runtime_audit/tests.rs index ddfb1b41..c3955630 100644 --- a/crates/devup-mcp/src/server/runtime_audit/tests.rs +++ b/crates/devup-mcp/src/server/runtime_audit/tests.rs @@ -61,7 +61,7 @@ mod tests { ) .await .unwrap(); - assert_eq!(planned["template"]["files"].as_array().unwrap().len(), 13); + assert_eq!(planned["template"]["files"].as_array().unwrap().len(), 14); let imported = std::process::Command::new("node") .args(["--input-type=module", "-e", "await import('./run.mjs')"]) .current_dir(dir.join("runner")) diff --git a/crates/devup-mcp/src/server/runtime_audit/write_plan.rs b/crates/devup-mcp/src/server/runtime_audit/write_plan.rs index f5fe1a87..d4d14780 100644 --- a/crates/devup-mcp/src/server/runtime_audit/write_plan.rs +++ b/crates/devup-mcp/src/server/runtime_audit/write_plan.rs @@ -72,6 +72,7 @@ fn write_plan( ("source-digest.mjs", include_bytes!("../../../../../harness/runtime-audit/source-digest.mjs").as_slice()), ("record-project-checks.mjs", include_bytes!("../../../../../harness/runtime-audit/record-project-checks.mjs").as_slice()), ("motion-checks.mjs", include_bytes!("../../../../../harness/runtime-audit/motion-checks.mjs").as_slice()), + ("reveal-checks.mjs", include_bytes!("../../../../../harness/runtime-audit/reveal-checks.mjs").as_slice()), ( "data-checks.mjs", include_bytes!("../../../../../harness/runtime-audit/data-checks.mjs").as_slice(), @@ -245,6 +246,7 @@ fn ingest_references( let mut dimension_rows = Vec::new(); let mut screen_rows = Vec::new(); let mut findings = Vec::new(); + let mut motion_rows = Vec::new(); let mut missing_nodes = Vec::new(); let mut link_table = Vec::<Value>::new(); let mut links = BTreeMap::<String, std::collections::BTreeSet<String>>::new(); @@ -431,6 +433,9 @@ fn ingest_references( .and_then(Value::as_str) .unwrap_or_default(); judged.push((status, check)); + if check.kind == "motion" { + motion_rows.push(motion_evidence_row(policy, &report_dir, &MotionCheckAt { screen: &screen.id, viewport, index, check, item, status })); + } if status == "fail" { findings.push(finding( screen, @@ -612,6 +617,7 @@ fn ingest_references( "figmaReferences": figma, "visualComparisons": visual_comparisons, "findings": findings, + "motionEvidence": motion_evidence_summary(motion_rows), "missingNodes": missing_nodes, "links": links, "linkTable": link_table, diff --git a/crates/devup-mcp/src/server/screen_plan/app_select.rs b/crates/devup-mcp/src/server/screen_plan/app_select.rs index 58db1d66..cc7b0d14 100644 --- a/crates/devup-mcp/src/server/screen_plan/app_select.rs +++ b/crates/devup-mcp/src/server/screen_plan/app_select.rs @@ -701,7 +701,7 @@ fn site_questions( "Beyond hover, focus and page transitions, should sections reveal on scroll? Nothing in the plan shows designed motion.".into(), vec![ choice("None", "Recommended unless the design shows motion; hover and focus transitions are still added.", json!({"motion": {"source": "none"}})), - choice("Subtle reveal on scroll", "Sections fade and slide in once as they enter; off under prefers-reduced-motion.", json!({"motion": {"source": "reveal"}})), + choice("Subtle reveal on scroll", "Sections fade and slide in once as they enter, as Devup UI static keyframes on a view timeline (CSS only, no animation library); off under prefers-reduced-motion.", json!({"motion": {"source": "reveal", "implementation": "devup-ui-static-keyframes"}})), ], )); } diff --git a/crates/devup-mcp/src/server/self_update.rs b/crates/devup-mcp/src/server/self_update.rs index f0509c45..21e8961c 100644 --- a/crates/devup-mcp/src/server/self_update.rs +++ b/crates/devup-mcp/src/server/self_update.rs @@ -422,8 +422,8 @@ pub fn report() -> Value { // once, at install time, and never renames it - so the directory can // say 0.9.0-dev while the binary inside it reports 0.10.1 after a // self-update replaced the file in place. That is the mechanism - // working, not a mismatch, and it reads like one. - "installedPathNote": include_str!("text/self_update-report-28b64040.md"), + // working, not a mismatch, and it reads like one. The explanation is + // in the usage guide ("Server identity"), not repeated in every reply. "note": ownership.note(), "disableWith": DISABLE_ENV, }) 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 5cfa9664..62927caf 100644 --- a/crates/devup-mcp/src/server/skills/devfive-frontend/SKILL.md +++ b/crates/devup-mcp/src/server/skills/devfive-frontend/SKILL.md @@ -67,8 +67,10 @@ 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 +role and implement it, following [animation](references/animation.md). Write +it with Devup UI `keyframes()` and CSS animation props (`animationTimeline="view()"` +for scroll reveals), not an animation library: it is static CSS with no bundle +and no hydration flash. 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.). @@ -165,7 +167,7 @@ use `Get-ChildItem`/`Select-String` and `Select-Object -First` instead of assumi | API Client | @devup-api/fetch, @devup-api/react-query | | Forms | react-hook-form 7+ | | State | Zustand (when needed) | -| Animation | framer-motion 12+ | +| Animation | Devup UI `keyframes()` + CSS animation props (static CSS). framer-motion 12+ only where a project already uses it for exit/gesture motion | | Linting | oxlint with `eslint-plugin-devup/oxlint-config` | | Package Manager | **bun** (default) | @@ -418,6 +420,7 @@ Maximize Server Components to minimize JS bundle size. | Event handlers **defined internally** | **Yes** | | Event handlers **received via props** | **No** | | `useRouter`, `usePathname` | **Yes** | +| Devup UI `keyframes()` / CSS animation props | No | | `framer-motion` animation | **Yes** | **Key insight:** Components receiving optional functions via props don't need `'use client'` - but **only if you pass props directly without wrapping in arrow function**. @@ -716,6 +719,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 +- **[Animation](references/animation.md)** - Default entrance/transition motion as Devup UI static keyframes: proposal by role, view-timeline scroll reveals with a marker fallback, 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 index 6a4518b5..55d64b84 100644 --- a/crates/devup-mcp/src/server/skills/devfive-frontend/references/animation.md +++ b/crates/devup-mcp/src/server/skills/devfive-frontend/references/animation.md @@ -6,6 +6,16 @@ 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. +Motion is written with Devup UI's static `keyframes()` and CSS animation +props. The build extracts them to a stylesheet, so an entrance costs no +JavaScript bundle, cannot flash on hydration (there is nothing to hydrate) and +runs on the compositor (`opacity` and `transform` only). Do not add +framer-motion or another animation library for entrances or transitions. Use +one only when the project already depends on it **and** the user asks for it, +or for what CSS cannot express (gesture-driven or physics motion, exit +animations of unmounting elements, shared-layout transitions); say so when you +do. + ## 1. Survey the page, then propose by role Walk the page's sections and repeated content before writing any motion. @@ -15,10 +25,10 @@ Choose by what a block is for, not by what can be animated: |-------|---------| | 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) | +| 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 of stagger and one second in total) | | 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 | +| 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, @@ -32,99 +42,210 @@ 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. +## 2. Implement it as static keyframes + +`devup_figma_export` hands out `Reveal.tsx` with every section-entrance +candidate (`behaviorPlan.motionImplementations.files.revealTsx`); the same +code is below. Declare the keyframes once per project, in the file that uses +them: a `const` holding a `keyframes()` name is a build-time value only in its +own file. + +### A section that enters as it scrolls into view + +```tsx +import { Box, keyframes } from '@devup-ui/react' + +// The hidden start exists only here. Markup without a running animation is +// the visible content. +const rise = keyframes({ + from: { opacity: 0, transform: 'translateY(16px)' }, + to: { opacity: 1, transform: 'translateY(0)' }, +}) + +// The painted section stays still; the wrapper inside it moves. +<Box as="section" bg="$surface"> + <Box + data-reveal="" + animationName={rise} + animationTimingFunction="ease-out" + animationFillMode="both" + animationTimeline="view()" + animationRange="entry 0% entry min(100%, 240px)" + _motionReduce={{ animation: 'none' }} + _focusWithin={{ animation: 'none' }} + selectors={{ + '&[data-reveal-state="pending"]': { animationName: 'none', opacity: 0 }, + '&[data-reveal-state="in"]': { animationTimeline: 'auto', animationDuration: '400ms' }, + }} + > + …content… + </Box> +</Box> +``` + +What each line is for: + +- `animationTimeline="view()"` binds the keyframes to the block's own position + in the viewport. There is no clock and no trigger: the state is a function + of scroll position, so it cannot start late, replay, or be left half done by + a fast scroll, a reload or back/forward. +- `animationRange="entry 0% entry min(100%, 240px)"` ends the entrance once + the block is fully in view or 240px in, whichever comes first. Without a + range (or with `cover`/`exit`) the block is still part-transparent in the + middle of the screen and fades out again as it leaves; a plain + `entry 100%` leaves a section taller than the screen half faded while it is + being read. +- `animationFillMode="both"` holds the first frame before the range and the + last one after it. The resting style has no `opacity: 0`: never put the + hidden start on the element itself. +- `_motionReduce={{ animation: 'none' }}` shows the content in place for + `prefers-reduced-motion`. `_focusWithin={{ animation: 'none' }}` shows it at + once when a control inside it receives keyboard focus, so focus is never + inside a part-transparent block. +- Where view timelines are unsupported the two timeline properties are + dropped, the animation has no duration and the content is simply shown: the + base style is safe everywhere, with or without JavaScript. +- The two `selectors` are the progressive enhancement for those browsers. The + shared `MotionController` (`motion-runtime.ts`, a few lines with no + animation code) sets `data-reveal-state="pending"` only on blocks that are + still below the viewport when it attaches, and `"in"` once when a block + enters (one `IntersectionObserver` that unobserves after firing). It never + hides anything that is already on screen, never runs under reduced motion, + and removes its marks when it unmounts. Bind it with + `{ kind: 'section-entrance', target: '[data-reveal]' }`. + +Layout for the revealed content goes on a Box inside the wrapper: Devup UI +extracts style props where they are written, so a shared `<Reveal>` component +takes plain element props, not style props. + +### A group on a clock: staggered cards, a progress line + +A stagger and a line that draws over time need one trigger and a clock, which +a view timeline does not have. Mark the group `data-reveal="timed"`: the same +controller sets its state once when it enters, in every browser, and the +items animate from that: + +```tsx +<Box data-reveal="timed" display="flex" gap="16px"> + {cards.map((card, index) => ( + <Box + key={card.id} + style={{ '--reveal-index': index }} + _motionReduce={{ animation: 'none' }} + selectors={{ + '[data-reveal-state="pending"] &': { opacity: 0 }, + '[data-reveal-state="in"] &': { + animationName: rise, + animationDuration: '400ms', + animationTimingFunction: 'ease-out', + animationFillMode: 'both', + animationDelay: 'calc(var(--reveal-index) * 60ms)', + }, + }} + > + … + </Box> + ))} +</Box> +``` + +- Derive the delay from the item index (`--reveal-index`), never from a + hardcoded list length, and keep the step at 40-80ms. +- A progress line or graph shares one variable with its items. The line draws + over `--reveal-line` and step `i` of `n` waits + `calc(var(--reveal-line) * var(--reveal-index) / var(--reveal-count))`, so + the two cannot drift when either changes (`RevealLine` and `RevealStep` in + `Reveal.tsx`). +- With no script the group has no state and every item is shown. + +### State changes + +A tab, accordion, hover or selected state is a `transition` on the properties +that change (`transition="opacity .2s, transform .2s"`), switched off with +`_motionReduce={{ transition: 'none' }}`. Do not animate layout properties. + +## 3. Rules the implementation must keep + +- **Visible when in view, once.** A scroll-linked entrance completes inside + the entry and stays complete for as long as the block is on screen or has + been passed. A timed one starts when the block enters (not at page load for + content far below, and not after the reader is already looking at it) and + never restarts: re-entering the viewport, scrolling back up, reload and + back/forward must not replay or re-hide content. +- **The background stays still.** Put the animation on an inner wrapper so no + band, image or colour fades or slides against the page. +- **Server markup is the content.** It is readable before, during and without + hydration: nothing ships at `opacity: 0` or translated away waiting for + JavaScript, and nothing visible is hidden on hydration to replay an + entrance. Reload, direct load, client navigation, bfcache and a restored + scroll position all start from visible content. +- **Fast scroll.** A jump past several sections leaves every passed section + fully visible, not stuck mid-animation or still hidden. +- **Preferences and access.** `prefers-reduced-motion: reduce` shows content + in place with no movement. Keyboard focus is never hidden or moved by an + entrance, and never trapped in a block that has not revealed yet. +- **Images.** 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. +- **Frames**: measure the start, a middle and the end state (opacity and + transform values, not only pixels). The middle must really be intermediate + and 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. +- **History**: go to another route and come back with back/forward (a new + document, a bfcache restore and a client navigation are three different + paths), 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. +- **Groups**: the stagger step, the stagger span and the total time of a card + group; each item of a progress line against the moment the line reaches it. -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: +All of this is measured by a `devup_runtime_audit` motion check with +`kind: "reveal"`. `target` is the element that carries the entrance (the +inner wrapper, not the painted section); `items` names the animated children +of a group. The check always measures the three frames (and keeps a +screenshot of each), the trigger position and re-entry, and then runs every +scenario below from a fresh load unless `scenarios` narrows the list: | 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 | +| `bfcache` | the block flashes when the page is restored from the back/forward cache | +| `client-nav` | the block flashes after leaving by `navigate` (a link) and going back in the same document | | `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) | +| `focus` | a focused control inside the block is part-transparent, off screen or keeps focus trapped (`focus` selects the control) | +| `reduced-motion` | the block is hidden or moves while entering under `prefers-reduced-motion` | +| `no-script` | the block is missing or hidden in the page served without JavaScript | +| `stagger` | a step between `items` exceeds 150ms, the stagger span 450ms or the whole group 1000ms (`maxStepMs`, `maxSpanMs`, `maxTotalMs`) | | `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. +Results are never rounded up. A scenario with nothing to observe in the block +(no image, no control, no items) is `not-applicable`. One that could not be +observed — no link named for `client-nav`, a page the browser will not keep in +bfcache, a scroll position that was not restored — is `unverified`, and the +check is then `inconclusive`, not passed; a scenario you list explicitly and +that cannot be observed fails. `devup_runtime_audit` ingest folds this into +`motionEvidence`: a candidate is `verified` only by a passing check at a +mobile and a desktop width with nothing left unverified. + +`devup_ui_validate` reports what the source alone proves, for keyframes and +for a motion library alike: `ssr-hidden-reveal`, `reveal-replays`, +`reveal-moves-background`, `stagger-too-long`, `motion-without-reduced-motion` +and, as information, `entrance-uses-motion-library`. + +Still a judgement, not a measurement: whether motion suits the block at all. +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/common-patterns.md b/crates/devup-mcp/src/server/skills/devfive-frontend/references/common-patterns.md index be93d2ad..eec69742 100644 --- a/crates/devup-mcp/src/server/skills/devfive-frontend/references/common-patterns.md +++ b/crates/devup-mcp/src/server/skills/devfive-frontend/references/common-patterns.md @@ -449,6 +449,36 @@ export function Tabs({ tabs, defaultTab, onChange }: TabsProps) { ## 10. Motion Pattern +Entrances and transitions are Devup UI static keyframes, in a Server +Component, with no animation library (full rules: [animation](animation.md)): + +```tsx +import { Box, keyframes } from '@devup-ui/react' + +const rise = keyframes({ + from: { opacity: 0, transform: 'translateY(16px)' }, + to: { opacity: 1, transform: 'translateY(0)' }, +}) + +// 스크롤 진입 시 한 번 나타나는 콘텐츠 그룹 (배경은 고정, 안쪽 래퍼만 이동) +<Box + data-reveal="" + animationName={rise} + animationTimingFunction="ease-out" + animationFillMode="both" + animationTimeline="view()" + animationRange="entry 0% entry min(100%, 240px)" + _motionReduce={{ animation: 'none' }} + _focusWithin={{ animation: 'none' }} +> + {content} +</Box> +``` + +A motion library is for what CSS cannot express, in a project that already +depends on it: exit animations of unmounting elements (`AnimatePresence`, as in +the BottomSheet above) and gesture-driven motion. + ```tsx // components/motion.ts 'use client' diff --git a/crates/devup-mcp/src/server/skills/devfive-frontend/references/critical-rules.md b/crates/devup-mcp/src/server/skills/devfive-frontend/references/critical-rules.md index e5915c0d..fe6a4438 100644 --- a/crates/devup-mcp/src/server/skills/devfive-frontend/references/critical-rules.md +++ b/crates/devup-mcp/src/server/skills/devfive-frontend/references/critical-rules.md @@ -340,6 +340,7 @@ export function ItemListPage() { | `useRouter`, `usePathname` | **Yes** | Next.js client hooks | | Context usage (`useContext`) | **Yes** | Client-side state | | Conditional rendering (props-based) | No | Can process on Server | +| Devup UI `keyframes()` / CSS animation props | No | Extracted to static CSS at build time | | `framer-motion` animation | **Yes** | Uses browser API | **Important: Props-based Function Calls** 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 5ad7ebd9..12b06f6d 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 @@ -75,10 +75,12 @@ preferences; do not introduce unsolicited pause controls or hover suspension. in a common layout; measure actual link widths/positions after route and size changes. Test animation calls, intermediate position and persistent DOM, not only the final selected colour. Retain native link/keyboard semantics. -- Use restrained below-to-above entrance motion for meaningful sections. One - shared observer/controller can enhance server markup, including sections added - after navigation. Do not hide SSR content before JavaScript runs. Support reduced - motion, no-JavaScript, focus, one-time playback and observer cleanup. +- Use restrained below-to-above entrance motion for meaningful sections, as + Devup UI static keyframes on a view timeline. The one shared controller only + marks blocks for browsers without view timelines and for groups on a clock, + including sections added after navigation. Do not hide SSR content before + JavaScript runs. Support reduced motion, no-JavaScript, focus, one-time + playback and observer cleanup. - Add transitions for captured colour, border and state changes without animating every layout property. Reduced-motion behavior is the accessible default unless an explicit requirement says otherwise; document intentional exceptions. @@ -94,6 +96,18 @@ user stated (no animation, a given timing, a replaying effect) always wins. 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. +- Implement it with Devup UI `keyframes()` and CSS animation props, extracted + to static CSS: no animation library, no bundle, no hydration flash, only + `opacity` and `transform`. A scroll reveal is + `animationName={rise} animationFillMode="both" animationTimeline="view()" + animationRange="entry 0% entry min(100%, 240px)"` on an inner wrapper, with + `_motionReduce={{ animation: 'none' }}` and + `_focusWithin={{ animation: 'none' }}`. The hidden start lives only in the + keyframes' first frame, never on the element. A group on a clock (staggered + cards, a progress line) is `data-reveal="timed"` with + `animationDelay: calc(var(--reveal-index) * 60ms)` on its items; the shared + controller marks it once. Use framer-motion only when the project already + depends on it and the user asks, or for exit and gesture motion. - 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 @@ -106,8 +120,12 @@ user stated (no animation, a given timing, a replaying effect) always wins. `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. + re-entry, fast scroll, back/forward (new document, bfcache and client + navigation), delayed and failed images, focus, reduced motion, no + JavaScript, and for groups the stagger step, span and total time and the + progress line against its items. `devup_runtime_audit` measures all of it + (`kind: "reveal"`) and reports `motionEvidence`. Report each as verified or + explicitly unverified; what could not be checked is never a pass. ## One source for business data @@ -278,3 +296,58 @@ Keep functional E2E and Figma visual tests paired. Desktop pane size does not replace the agreed CSS viewport, DPR or zoom. If the required capture viewport cannot fit onscreen, use an independent automated browser context at that size; do not change application layout, CSS zoom or thresholds to make a pane match. + +## Contract identifiers + +Export replies carry `developmentContract` as identifiers only, to stay inside +the reply's byte budget; this section is what they mean. Read these rules, +apply `behaviorPlan`, fix and rerun functional E2E plus native Figma visuals +before claiming completion. The installed copy of this document is +`devfive-frontend/references/delivery-quality.md`. + +`requirements`: + +| Identifier | Requires | +|------------|----------| +| `pure-tsx-sidecar-provenance` | Application TSX carries no `@devup-*` comments or Figma node attributes; provenance lives in JSON sidecars | +| `jsx-theme-tokens-and-axis-shorthands` | Real `$token` props in JSX, no `var(--token)`; equal pairs collapse to `px`/`py` | +| `single-responsive-tree-and-source-text-wrapping` | One tree with responsive arrays for the same content; Korean wrapping (`keep-all`) and captured line breaks preserved | +| `persistent-server-layout` | Header, tabs and footer live in the nearest shared server `layout.tsx` and stay mounted across routes | +| `route-owned-page-body` | Each route owns its page body; screens are routes, not local state | +| `devup-api-only` | Data goes through devup-api, never an ad-hoc fetch or fixture | +| `stop-affected-api-gaps` | A missing or wrong API stops the affected work and is reported, never papered over in the UI | +| `preserve-instance-sizing-and-paint` | Captured instance overrides, sizes and paints are kept | +| `paired-e2e-and-native-figma-visuals` | Functional E2E and native Figma visual comparisons are run together | +| `semantic-native-controls-and-complete-pseudo-states` | Native elements with accessible names and every pseudo-state painted | +| `variant-palette-consistency` | Every variant uses the captured palette, including selected and disabled | +| `single-source-url-content-selection` | The URL is the single source of which content is selected (`aria-current`) | +| `viewport-footer-shell` | A short page keeps its footer at the viewport bottom | +| `motion-candidates-and-safe-progressive-enhancement` | Each `behaviorPlan` motion candidate (marquee, count-up, step cycle, sliding highlight, section entrance) is implemented as an enhancement of readable server markup | +| `default-entrance-and-transition-motion` | Restrained entrance and transition motion is proposed by role and implemented without being asked; the user's stated design wins | +| `devup-ui-static-keyframes-motion` | Motion is Devup UI `keyframes()` and CSS animation props extracted to static CSS; no animation library for entrances or transitions | +| `one-shot-reveals-without-ssr-flash` | A reveal completes when its content is visible and never replays, re-hides or flashes on hydration, reload, back/forward or client navigation | +| `canonical-semantic-assets` | One asset registry, semantic file names, no duplicates or hash names | +| `landing-detail-api-db-single-source` | Landing and detail values come from the same API and DB records | +| `runtime-evidence-before-completion` | Completion is claimed from a runtime audit, not from the export or a screenshot | + +`completionEvidence`: + +| Identifier | Evidence | +|------------|----------| +| `both-control-directions-and-history` | Every control exercised both ways, plus back/forward and reload | +| `all-widths-and-zoom-context` | Every agreed width, with viewport, device scale and zoom recorded | +| `computed-style-and-animation-intermediate-state` | Computed styles of states and an intermediate value of every running motion (marquee, count-up, steps, tabs), not only the final frame | +| `animation-start-middle-end-frames` | The start, middle and end frame of each entrance, as measured opacity/transform values with a screenshot of each, at a mobile and a desktop width | +| `motion-trigger-reentry-history-and-image-failure` | Trigger position, re-entry, fast scroll, reload, history, bfcache, client navigation, delayed and failed images, focus, reduced motion and no JavaScript for each reveal | +| `stagger-total-and-progress-sync-timing` | For a group: stagger step, span and total time within bounds; for a progress line: each item within tolerance of the line reaching it | +| `changed-empty-and-failing-api` | Changed, empty and failing API states, against a disposable database | +| `all-public-assets-and-persisted-url-references` | Every public asset and every persisted URL reference resolves | + +Standing policies of every `behaviorPlan` (they used to be repeated in each +reply): captured prototype evidence and inferred motion choices remain +separate, and explicit user requirements take precedence. Keep generated +server TSX and data pages, and mount the supplied tiny shared motion controller +in the persistent layout. Use semantic selectors; node identities and +implementation evidence stay in JSON sidecars. The immutable artifact graph and +fingerprint are read for context, while candidate inference and returned rows +are bounded per page (`evidenceLimits`). diff --git a/crates/devup-mcp/src/server/skills/manifest.json b/crates/devup-mcp/src/server/skills/manifest.json index 8ad63804..0cec382d 100644 --- a/crates/devup-mcp/src/server/skills/manifest.json +++ b/crates/devup-mcp/src/server/skills/manifest.json @@ -51,13 +51,13 @@ "documents": [ { "path": "SKILL.md", - "bytes": 31003, - "sha256": "751f76110eed1fe18874702cc208571c2a651f2822712fba7fc3d309869c0e75" + "bytes": 31428, + "sha256": "e4729816c355ee065c1a5908516a68c95c9bca59131c62958404ce94db30d320" }, { "path": "references/critical-rules.md", - "bytes": 57345, - "sha256": "35b3c66d5d6ddc6911085003cb9d5f795f7c9456c97714c4ae40323a7c42cc33" + "bytes": 57439, + "sha256": "107b4ac6b657ae4f00dd74fba94ad841815cb25124eb2bc32af8c998dccc2fb0" }, { "path": "references/anti-patterns.md", @@ -66,8 +66,8 @@ }, { "path": "references/common-patterns.md", - "bytes": 12110, - "sha256": "fe1d165394824ae0777a3f11dda5a47e0cf27e17872c1e83b70b18de8e29828c" + "bytes": 13055, + "sha256": "6fe0e68253d3c6215a0d47f4a79ba355b5cf93960b0ed19671df41e1159a583c" }, { "path": "references/theme-colors.md", @@ -76,13 +76,13 @@ }, { "path": "references/delivery-quality.md", - "bytes": 19364, - "sha256": "4ce226a73eb37f62e3341c3d8bd86078a6a6f0d43f6b66278f79f31bfdab1f3d" + "bytes": 25551, + "sha256": "b5e6013167c80f785b93ae4c80b4ad2f300a381610065a7fcf183524f79e48a3" }, { "path": "references/animation.md", - "bytes": 7669, - "sha256": "beaba3bd5b6c564d0f9f1a1bab0928b6e0c58f0c5f7b9f9d723a8df573939b30" + "bytes": 13308, + "sha256": "de275d513330b2a84834be13226c63115ad591cfc5922858110a7cf79dfe0e5e" } ], "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/completion.md b/crates/devup-mcp/src/server/text/completion.md index 5090d059..ca777e4e 100644 --- a/crates/devup-mcp/src/server/text/completion.md +++ b/crates/devup-mcp/src/server/text/completion.md @@ -15,6 +15,8 @@ in the browser plan, or pass the explicitly agreed required widths to both. `motionRuntime` and `motionBindings` outputs. Fill semantic selectors and real API values, then mount the shared controller once in the persistent layout. Generated controller code is executable; unresolved bindings remain explicit. + Entrances are static CSS: use the supplied `revealTsx` (Devup UI keyframes on + a view timeline) around each section's content group; no animation library. 3. Run actual build, lint, typecheck, unit test and functional E2E commands through the emitted `record-project-checks.mjs`, passing a JSON config with `projectRoot` and `checks: [{name:"build",command:["bun","run","build"]}, ...]`. The resulting @@ -32,7 +34,10 @@ in the browser plan, or pass the explicitly agreed required widths to both. markup and rendered completeness. Motion checks additionally require the exact behavior candidate ID on the applicable planned viewport/screen/check index and observed intermediate behavior. Selected paint alone - does not prove a sliding indicator or a complete step cycle. + does not prove a sliding indicator or a complete step cycle. An entrance + (`reveal`) is covered only by a passing check at a mobile (<=767px) and a + desktop (>=1024px) width; an inconclusive check, where a scenario could not + be observed, covers nothing (`motionEvidence` lists what is unverified). Include the exact Figma frame artifact for every drawn width. Completion reruns the ingested `visualComparisons` against cached native Figma PNGs; it never accepts a caller verdict or a path to an implementation baseline. diff --git a/crates/devup-mcp/src/server/text/devup-figma-export.md b/crates/devup-mcp/src/server/text/devup-figma-export.md index 8898c612..c2155413 100644 --- a/crates/devup-mcp/src/server/text/devup-figma-export.md +++ b/crates/devup-mcp/src/server/text/devup-figma-export.md @@ -7,7 +7,7 @@ Exports report captured sections/assets with no generated element as DEVUP_CODEG responseLimit and responseOffset page the already-captured result (outline nodes, selected paths); they do not limit how much Figma is read. Collection cost follows the size of the selected node, so for a very large node pick a smaller inner frame (devup_figma_explore) or read only dimensions with devup_figma_measure instead of raising or lowering responseLimit. To poll a job cheaply pass responseFields such as ["exportJob.state","exportJob.elapsedMs","exportJob.completedCalls","exportJob.nextAction"]. With projectRoot, generated outputs default to guarded writes under .devup/exports/: tsx uses <node-id>.tsx, componentTsx uses <node-id>.component.tsx, responsiveTsx uses <node-id>.responsive.tsx, and sourceMap uses <node-id>.sourcemap.json, unless outputPaths already names that output. Section frames use frame:<nodeId>:<output> keys. defaultOutputPaths lists applied defaults; disallowed defaults warn DEVUP_DEFAULT_OUTPUT_PATH_SKIPPED and retain inline delivery. Written code is omitted from the response body; outputResults reports bodyDelivery:"written-to-file" and path. Job polls retain the original destinations. Without projectRoot no default write is made. Export projectContext summarizes counts and relevant routes, components and referenced assets only; use its nextAction with devup_project_context scope ui/routes for the full inventory. responseFields keeps late project metadata in selected values only, alongside the bounded delivery/review envelope. -Behavior candidates also deliver executable motionControllerTsx, motionRuntime and explicit motionBindings; keep generated server TSX and mount the controller once in the persistent layout after binding semantic selectors and real API values. Follow behaviorPlan.pagination.nextAction with the original artifactId/behaviorCursor until complete; partial coverage cannot establish application completion. Snapshot-indexed cursors enforce an 8 MiB input/output bound. If compact state still exceeds it, cursorBudgetExceeded remains incomplete and nextAction requests the actual debug rawSnapshot resource for explicit recovery. Private outputPaths may save the plan/controller/runtime/bindings. Unnamed assets quarantine all public/code writes until meaningful paths are supplied. For edited application code use devup_source_sync; for existing asset references use devup_assets. Verify implementation with devup_runtime_audit completion rather than treating an export as a working application. +Behavior candidates also deliver executable motionControllerTsx, motionRuntime and explicit motionBindings; keep generated server TSX and mount the controller once in the persistent layout after binding semantic selectors and real API values. A section-entrance candidate also delivers revealTsx: the entrance as Devup UI static keyframes (keyframes(), animationTimeline view(), animationRange) that compiles to CSS, with the controller only marking blocks where view timelines are unsupported; do not add an animation library for it. Follow behaviorPlan.pagination.nextAction with the original artifactId/behaviorCursor until complete; partial coverage cannot establish application completion. Snapshot-indexed cursors enforce an 8 MiB input/output bound. If compact state still exceeds it, cursorBudgetExceeded remains incomplete and nextAction requests the actual debug rawSnapshot resource for explicit recovery. Private outputPaths may save the plan/controller/runtime/bindings. Unnamed assets quarantine all public/code writes until meaningful paths are supplied. For edited application code use devup_source_sync; for existing asset references use devup_assets. Verify implementation with devup_runtime_audit completion rather than treating an export as a working application. Screen-plan component substitutions: when projectRoot has a plan, `componentWidthMismatches` and `componentWidthAssumptions` expose automatic `DEVUP_DESIGN_COMPONENT_WIDTH_MISMATCH` corrections. Use the expected master/correct sibling for the affected width in one responsive shared component, never a second variant. Native-node PNG evidence compares the suspect and correct sibling; unavailable renders are explicit and the strict width majority decides. Audit references use the substituted subtree and retain the suspect's y placement; the original whole-page render cannot verify the corrected page. Requesting outputs [tsx, responsiveTsx] expands instance subtrees in the responsive companion too, preserving their concrete captured text and layout. Requesting responsiveTsx alone retains shared component calls. Both modes retain override diagnostics; expansion does not prove that cross-width text or unmatched structure merged without loss. Read responsiveUnrepresented and the required review checklist. 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 80235245..27d80fcf 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 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. +Motion checks measure intermediate values for marquee, count-up, steps, tabs and reveal, with explicit candidateId and semantic selectors. A reveal check measures an entrance whatever produced it. `motion.target` is the element that carries the entrance (the inner wrapper, not the painted section) and `motion.items` names the animated children of a group. It scrolls toward the target in small steps and records `measured.trigger` and `measured.frames` (start, middle and end opacity/transform, each with a screenshot): a scroll-linked entrance (CSS `animation-timeline: view()`) must be fully shown once the block is inside the viewport or half a screen of it is showing; a timed one must start no more than 1px off screen and not after the block has been fully visible for 400ms. 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. Then every scenario runs from a fresh load unless `motion.scenarios` narrows the list, 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), bfcache (the same after a real back/forward-cache restore, in a browser started with the cache on), client-nav (follow `motion.navigate`, a link, and go back in the same document), image-delay and image-failure (images inside the target stalled or aborted: it still reveals at its reserved height), focus (a focused control inside an unrevealed target is painted, on screen and not trapped; `motion.focus` selects the control), reduced-motion (never hidden or moved while entering under prefers-reduced-motion), no-script (present and painted in the page served without JavaScript), stagger (steps between `motion.items` at most 150ms, stagger span 450ms, whole group 1000ms, or `maxStepMs`/`maxSpanMs`/`maxTotalMs`), progress-sync (with `motion.progress` and `motion.items`: each item within 200ms, or `motion.syncToleranceMs`, of the progress line reaching it). A scenario with nothing to observe in the block (no image, no control, no items) is `not-applicable`. One that could not be observed (no link named, a page the browser does not keep in bfcache, scroll not restored) is `unverified` and the check is `inconclusive`, never a pass; with an explicit `scenarios` list an unobservable scenario fails and the scenarios left out are listed in `measured.scenariosNotRun`. Ingest returns `motionEvidence`: one row per motion check per width (frames, trigger, re-entry, scenario statuses, stagger and progress timings, and what is unverified) and one line per candidate, `verified` only with a passing check at a mobile (<=767px) and a desktop (>=1024px) width and nothing left unverified. 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 65bed5ee..4b8176a7 100644 --- a/crates/devup-mcp/src/server/text/devup-ui-validate.md +++ b/crates/devup-mcp/src/server/text/devup-ui-validate.md @@ -1,6 +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. 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. +Entrance motion, from source alone (warnings), read in both styles - Devup UI static keyframes (keyframes() constants or inline calls, animationName/animation, animationTimeline, animationRange, animationDelay, selectors, in JSX props and in css()/styled objects) and a motion library (motion.* and components built with motion.create()/motion() in the same file). `ssr-hidden-reveal` - content that is invisible until something other than CSS shows it: a keyframes entrance on an element that itself rests at opacity 0 or visibility hidden, or is paused on its hidden first frame, or an element hidden until a selector keyed on a script-set attribute or class shows it; an always-rendered motion.* element whose initial (object, or a named variant declared on the same element) is opacity 0. Elements inside AnimatePresence or a conditional render are client-mounted and exempt. `reveal-replays` - a view-timeline entrance (animationTimeline="view()") with no animationRange that ends inside the entry (none, cover, contain, exit, or more than cover 50%), so the block is part-transparent mid-screen and fades out as it leaves; an IntersectionObserver callback that writes isIntersecting into a class, attribute or style (info when it merely never calls unobserve/disconnect); whileInView without viewport={{ once: true }} (useInView with no once/triggerOnce option is info, since its purpose is not visible). `stagger-too-long` - a per-item step above 150ms: animationDelay/transitionDelay of calc(var(--i) * step), a template of index * step, staggerChildren, or delay: index * step. `reveal-moves-background` - the element that carries the entrance (keyframes or whileInView) also paints a background, on itself or through its css() class, so the section paint moves with its content; a looping animation is not an entrance. `entrance-on-static-content` - an entrance on nav, header, footer, form, a form field, a label or a paragraph (by tag, as=, or motion.tag); `entrance-per-element` (info) - an entrance on a heading, button or link rather than on the content group. `motion-without-reduced-motion` (paths/files only) - entrance motion while no validated source, nor projectRoot/src in paths mode, handles reduced motion (a prop or key such as _motionReduce or _motionSafe, a prefers-reduced-motion media rule, useReducedMotion, MotionConfig reducedMotion; a comment or an unused import does not count). `entrance-uses-motion-library` (info, once per file) - an always-rendered entrance is driven by a runtime animation library where Devup UI keyframes would be static CSS. Keyframes declared in another module, values built elsewhere and options passed by reference cannot be read and are not reported. Trigger timing, intermediate frames, re-entry, history, bfcache, image loading and stagger timing are runtime evidence, measured by devup_runtime_audit, 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-GUIDE-c682b0c5.md b/crates/devup-mcp/src/server/text/guide-GUIDE-c682b0c5.md index 67d6f82a..b5a958d1 100644 --- a/crates/devup-mcp/src/server/text/guide-GUIDE-c682b0c5.md +++ b/crates/devup-mcp/src/server/text/guide-GUIDE-c682b0c5.md @@ -159,6 +159,10 @@ never carries them. Rule numbers match the original list, which skipped 9. For disconnected, out-of-view or mismatched relay-host states, load `figma-bridge-setup`. The agent performs desktop setup with computer use, then verifies status and a small read. Compare commit/buildId, not version. +`server.updateAvailable.autoUpdate.installedPath` is the path of the file, not +a statement about the version. A directory named after a version is the host's, +fixed when it installed; self-update replaces the file inside it and never renames the directory. +Read server.commit/buildId for what is actually running. Ask before installing Figma, using metered direct OAuth or interrupting another session. Missing diagnostics or computer use remain explicit blockers. @@ -318,6 +322,15 @@ links to read on demand instead of inlining bytes into every response. ## 13. Exact means exact at the drawn width +`verificationScope` says which widths were collected and what was checked at +them. `staticallyChecked.mapping` (`quality.projection`): each generated +property against its source field, at the collected widths. +`staticallyChecked.widthChange` (`widthBehavior`): anchors and sizing intent +when a container is not its drawn width, read from constraints and geometry, +not rendered. `rendered.state: "not-performed"`: nothing was rendered. +devup_visual_compare compares a consumer-rendered PNG at a drawn width; +devup_runtime_audit plans and ingests a consumer-side browser run at any widths. + `quality.projection` grades the mapping at the width the frame was drawn at, not at any other viewport. Before widening a generated screen, read `widthBehavior`: a finding marked `consumer-policy-required` is a question the 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 7b5b7a95..4d3e9873 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, behaviorPlan and default motion before completion. +6. Before completion apply developmentContract, behaviorPlan, default keyframes motion. diff --git a/crates/devup-mcp/src/server/text/self_update-report-28b64040.md b/crates/devup-mcp/src/server/text/self_update-report-28b64040.md deleted file mode 100644 index 79acac23..00000000 --- a/crates/devup-mcp/src/server/text/self_update-report-28b64040.md +++ /dev/null @@ -1 +0,0 @@ -The path of the file, not a statement about the version. A directory named after a version is the host's, fixed when it installed; self-update replaces the file inside it and never renames the directory. Read server.commit/buildId for what is actually running. \ No newline at end of file diff --git a/crates/devup-mcp/tests/export_project_theme.rs b/crates/devup-mcp/tests/export_project_theme.rs index a78f3abc..6bd1140b 100644 --- a/crates/devup-mcp/tests/export_project_theme.rs +++ b/crates/devup-mcp/tests/export_project_theme.rs @@ -184,6 +184,57 @@ async fn export_context_summarizes_a_large_project_under_fifteen_kib() -> anyhow .is_none() ); assert!(bytes < 15 * 1024, "reply is {bytes} bytes"); + // The budget is a wall only if nothing can be added before hitting it. + // #124 had 31 bytes left and renamed two contract identifiers instead of + // adding them. Standing prose now lives in the guide (contract meanings, + // behaviorPlan policies, verificationScope and install-path notes), so + // the reply keeps at least this much room for the next identifier. + // Temporary-directory and binary paths in the reply differ by machine, + // which is why the margin is not the exact figure. + const HEADROOM: usize = 1024; + assert!( + bytes + HEADROOM <= 15 * 1024, + "reply is {bytes} bytes: less than {HEADROOM} bytes of headroom under the 15 KiB budget. Move standing text to the guide instead of renaming or dropping identifiers." + ); + // Identifiers stay in the reply; their explanations do not. + let contract = &result["developmentContract"]; + assert_eq!( + contract["requirements"].as_array().map(Vec::len), + Some(20), + "{contract}" + ); + assert_eq!( + contract["completionEvidence"].as_array().map(Vec::len), + Some(8), + "{contract}" + ); + for restored in [ + "motion-candidates-and-safe-progressive-enhancement", + "computed-style-and-animation-intermediate-state", + ] { + assert!( + contract.to_string().contains(restored), + "{restored} was renamed away in #124 and is back" + ); + } + assert!( + contract.get("nextAction").is_none() && contract.get("installedReference").is_none(), + "{contract}" + ); + assert_eq!( + contract["guide"], + "devup://guide/usage#behavior-and-data-contracts" + ); + for moved in ["intentPolicy", "clientBoundary", "sourcePurity"] { + assert!( + result["behaviorPlan"].get(moved).is_none(), + "{moved} is standing text in the guide" + ); + } + assert_eq!( + result["behaviorPlan"]["guide"], + "devup://guide/usage#behavior-and-data-contracts" + ); assert_eq!(result["projectContext"]["counts"]["publicFiles"], 180); assert_eq!(result["projectContext"]["ui"]["components"], json!([])); assert_eq!( diff --git a/crates/devup-mcp/tests/runtime_audit_tool.rs b/crates/devup-mcp/tests/runtime_audit_tool.rs index 7e43a83d..19cb77b1 100644 --- a/crates/devup-mcp/tests/runtime_audit_tool.rs +++ b/crates/devup-mcp/tests/runtime_audit_tool.rs @@ -176,7 +176,7 @@ async fn consumer_template_is_allowlisted_and_missing_acquisitions_never_pass() assert!(!failed, "{planned}"); assert_eq!(planned["template"]["executed"], false); assert_eq!(planned["dimensionReferences"][0]["status"], "unverified"); - assert_eq!(planned["template"]["files"].as_array().unwrap().len(), 13); + assert_eq!(planned["template"]["files"].as_array().unwrap().len(), 14); let markup = std::fs::read_to_string(root.join("consumer-audit/markup-checks.mjs"))?; assert!(markup.contains("collectMarkup")); let runner = std::fs::read_to_string(root.join("consumer-audit/run.mjs"))?; diff --git a/crates/devup-mcp/tests/ui_validate_response.rs b/crates/devup-mcp/tests/ui_validate_response.rs index 4f9c97d6..911a49c8 100644 --- a/crates/devup-mcp/tests/ui_validate_response.rs +++ b/crates/devup-mcp/tests/ui_validate_response.rs @@ -16,3 +16,4 @@ 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"); +include!("ui_validate_response/monorepo_single_tsx_owner.rs"); diff --git a/crates/devup-mcp/tests/ui_validate_response/monorepo_single_tsx_owner.rs b/crates/devup-mcp/tests/ui_validate_response/monorepo_single_tsx_owner.rs new file mode 100644 index 00000000..e169fbca --- /dev/null +++ b/crates/devup-mcp/tests/ui_validate_response/monorepo_single_tsx_owner.rs @@ -0,0 +1,124 @@ + +/// A repository root holding two apps and a file neither owns. Each app has +/// its own theme: `$brand` is `a`'s token and `$accent` is `b`'s. +fn two_apps(label: &str) -> anyhow::Result<std::path::PathBuf> { + let root = std::env::temp_dir().join(format!("devup-token-owner-{label}-{}", rand::random::<u64>())); + for (path, content) in [ + ("package.json", "{}"), + ("apps/a/package.json", "{}"), + ("apps/a/devup.json", r##"{"theme":{"colors":{"default":{"brand":"#0066FF"}}}}"##), + ("apps/a/src/components/Hero.tsx", A_HERO), + ("apps/a/src/components/Shared.tsx", SHARED), + ("apps/b/package.json", "{}"), + ("apps/b/devup.json", r##"{"theme":{"colors":{"default":{"accent":"#FF6600"}}}}"##), + ("apps/b/src/components/Banner.tsx", B_BANNER), + ("apps/b/src/components/Shared.tsx", SHARED), + ("tools/Orphan.tsx", ORPHAN), + ] { + let file = root.join(path); + std::fs::create_dir_all(file.parent().unwrap())?; + std::fs::write(file, content)?; + } + Ok(root) +} + +const A_HERO: &str = "export function Hero() {\n return <Box bg=\"$brand\" color=\"$accent\" />\n}\n"; +const B_BANNER: &str = "export function Banner() {\n return <Box bg=\"$brand\" color=\"$accent\" />;\n}\n"; +const SHARED: &str = "export function Shared() { return <Box bg=\"$brand\" /> }\n"; +const ORPHAN: &str = "export function Orphan() { return <Box bg=\"$brand\" /> }\n"; + +fn unknown(output: &Value) -> Vec<String> { + output["violations"] + .as_array() + .unwrap() + .iter() + .filter(|finding| finding["rule"] == "unknown-token") + .map(|finding| finding["snippet"].as_str().unwrap().to_owned()) + .collect() +} + +/// One `tsx` at a monorepo root is checked against the theme of the app that +/// owns the file, without the caller choosing a projectRoot per app: by its +/// path (relative or absolute), by the tail of its name, or by its text. +#[tokio::test] +async fn a_single_tsx_at_a_monorepo_root_is_checked_against_its_owning_app() -> anyhow::Result<()> { + let root = two_apps("owned")?; + let absolute = root.join("apps").join("a").join("src").join("components").join("Hero.tsx"); + let runs = [ + // apps/a: by absolute path, by a path relative to the app, by bare name, by content alone. + (validate(json!({"tsx":A_HERO,"projectRoot":root,"sourceName":absolute})).await, "a", "sourceName"), + (validate(json!({"tsx":A_HERO,"projectRoot":root,"sourceName":"src/components/Hero.tsx"})).await, "a", "sourceName-suffix"), + (validate(json!({"tsx":A_HERO,"projectRoot":root,"sourceName":"Hero.tsx"})).await, "a", "sourceName-suffix"), + (validate(json!({"tsx":A_HERO,"projectRoot":root})).await, "a", "content-match"), + // The editor sent CRLF line ends and no trailing newline: still the same file. + (validate(json!({"tsx":A_HERO.trim_end().replace('\n', "\r\n"),"projectRoot":root})).await, "a", "content-match"), + // apps/b: the same three ways. + (validate(json!({"tsx":B_BANNER,"projectRoot":root,"sourceName":"apps/b/src/components/Banner.tsx"})).await, "b", "sourceName"), + (validate(json!({"tsx":B_BANNER,"projectRoot":root,"sourceName":"components/Banner.tsx"})).await, "b", "sourceName-suffix"), + (validate(json!({"tsx":B_BANNER,"projectRoot":root})).await, "b", "content-match"), + // A new file that is not on disk yet, placed by its path. + (validate(json!({"tsx":B_BANNER,"projectRoot":root,"sourceName":"apps/b/src/components/New.tsx"})).await, "b", "sourceName"), + ]; + std::fs::remove_dir_all(&root)?; + for (result, app, resolved_from) in runs { + let out = result?; + // The same two references in both files: each app knows one of them. + let (known, missing) = if app == "a" { ("$brand", "$accent") } else { ("$accent", "$brand") }; + assert_eq!(unknown(&out), [format!("\"{missing}\"")], "{app}/{resolved_from}: {out}"); + assert!(!out.to_string().contains(&format!("\"snippet\":\"\\\"{known}\\\"\"")), "{out}"); + assert_eq!(out["themeAvailable"], true, "{out}"); + assert_eq!(out["tokens"]["unknownTokenCheckRan"], true, "{out}"); + assert_eq!(out["tokens"]["devupJson"], format!("apps/{app}/devup.json"), "{out}"); + assert_eq!(out["tokens"]["owner"]["resolvedFrom"], resolved_from, "{out}"); + assert_eq!(out["tokens"]["owner"]["devupJson"], format!("apps/{app}/devup.json"), "{out}"); + assert!(out["themeGuardrail"].is_null(), "{out}"); + assert_eq!(out["okReason"], "error-violations", "{out}"); + } + Ok(()) +} + +/// Unverified is reported only when no owner can be resolved: a file no theme +/// governs, a file that exists under two themes, and text found nowhere. +#[tokio::test] +async fn a_single_tsx_with_no_resolvable_owner_stays_unverified() -> anyhow::Result<()> { + let root = two_apps("orphan")?; + let orphan_by_path = validate(json!({"tsx":ORPHAN,"projectRoot":root,"sourceName":"tools/Orphan.tsx"})).await; + let orphan_by_content = validate(json!({"tsx":ORPHAN,"projectRoot":root})).await; + let ambiguous_by_name = validate(json!({"tsx":SHARED,"projectRoot":root,"sourceName":"Shared.tsx"})).await; + let ambiguous_by_content = validate(json!({"tsx":SHARED,"projectRoot":root})).await; + let nowhere = validate(json!({"tsx":"export function Draft() { return <Box bg=\"$brand\" /> }","projectRoot":root})).await; + let outside = validate(json!({"tsx":A_HERO,"projectRoot":root.join("apps").join("b"),"sourceName":root.join("apps").join("a").join("src").join("components").join("Hero.tsx")})).await; + // The ambiguity ends once the caller says which copy it is. + let chosen = validate(json!({"tsx":SHARED,"projectRoot":root,"sourceName":"apps/b/src/components/Shared.tsx"})).await; + std::fs::remove_dir_all(&root)?; + + for (result, candidates) in [ + (orphan_by_path, Some(vec!["tools/Orphan.tsx"])), + (orphan_by_content, Some(vec!["tools/Orphan.tsx"])), + (ambiguous_by_name, Some(vec!["apps/a/src/components/Shared.tsx", "apps/b/src/components/Shared.tsx"])), + (ambiguous_by_content, Some(vec!["apps/a/src/components/Shared.tsx", "apps/b/src/components/Shared.tsx"])), + (nowhere, None), + ] { + let out = result?; + assert!(unknown(&out).is_empty(), "{out}"); + assert_eq!(out["themeAvailable"], false, "{out}"); + assert_eq!(out["tokens"]["unknownTokenCheckRan"], false, "{out}"); + assert_eq!(out["okReason"], "clean-unverified-tokens", "{out}"); + assert!(out["tokens"]["devupJson"].is_null() && out["tokens"]["owner"].is_null(), "{out}"); + match candidates { + // The files that were found are named, so the caller can choose. + Some(paths) => assert_eq!(out["themeGuardrail"]["ownerCandidates"], json!(paths), "{out}"), + None => assert!(out["themeGuardrail"]["ownerCandidates"].is_null(), "{out}"), + } + // The repository-wide answer still names the themes there are. + assert_eq!(out["themeGuardrail"]["candidateProjectRoots"].as_array().map(Vec::len), Some(2), "{out}"); + } + // A path outside projectRoot is not followed: apps/b's own theme applies. + let out = outside?; + assert_eq!(out["tokens"]["devupJson"].as_str().map(|path| path.replace('\\', "/").ends_with("apps/b/devup.json")), Some(true), "{out}"); + assert!(out["tokens"]["owner"].is_null(), "{out}"); + let out = chosen?; + assert_eq!(unknown(&out), ["\"$brand\""], "{out}"); + assert_eq!(out["tokens"]["owner"]["resolvedFrom"], "sourceName", "{out}"); + Ok(()) +} diff --git a/crates/devup-mcp/tests/width_policy_export.rs b/crates/devup-mcp/tests/width_policy_export.rs index 01614797..3170f7dd 100644 --- a/crates/devup-mcp/tests/width_policy_export.rs +++ b/crates/devup-mcp/tests/width_policy_export.rs @@ -194,6 +194,19 @@ async fn the_default_export_reports_width_behavior_and_reuse() -> anyhow::Result let scope = &output["verificationScope"]; assert_eq!(scope["collectedWidths"], json!([360.0]), "{scope}"); assert_eq!(scope["rendered"]["state"], "not-performed"); + // Each static check names the reply field that holds it; the guide says + // what it covers. + assert_eq!( + scope["staticallyChecked"], + json!({"mapping": "quality.projection", "widthChange": "widthBehavior"}), + "{scope}" + ); + assert!( + scope["guide"] + .as_str() + .is_some_and(|uri| uri.starts_with("devup://guide/usage")), + "{scope}" + ); Ok(()) } diff --git a/harness/runtime-audit/README.md b/harness/runtime-audit/README.md index ebf7a166..f0fe5bee 100644 --- a/harness/runtime-audit/README.md +++ b/harness/runtime-audit/README.md @@ -37,7 +37,11 @@ capture alone does not prove missing content. `minHeightRatio` remains an explic logical completeness condition; no hidden exact-frame-height override is applied. Geometry and captures pause running animations at `currentTime = 0` and disable -CSS transitions after scroll reveals settle. The runner restores motion before +CSS transitions after scroll reveals settle. An entrance on a view timeline +(`animation-timeline: view()`) is bound to scroll position, not to a clock: it +is not waited for, and it is cancelled rather than paused, so every block shows +its resting style instead of the hidden first frame a full-page capture would +otherwise catch below the fold (`frozenAnimations.scrollLinkedCount`). The runner restores motion before interaction and view-transition checks, without changing reduced-motion media preferences. Each screen reports `frozenAnimations` with unique target count, selectors and animation count. Ingest exposes this as executor-declared evidence, diff --git a/harness/runtime-audit/fixtures/keyframes-motion/App.tsx b/harness/runtime-audit/fixtures/keyframes-motion/App.tsx new file mode 100644 index 00000000..ef0f5d72 --- /dev/null +++ b/harness/runtime-audit/fixtures/keyframes-motion/App.tsx @@ -0,0 +1,81 @@ +// The keyframes entrance exactly as devup-mcp ships it (Reveal.tsx, +// MotionController.tsx and motion-runtime.ts are copied in beside this file +// by the test), compiled by the Devup UI plugin and rendered on the server. +import { Box, css } from '@devup-ui/react'; +import { useEffect, useState } from 'react'; +import { flushSync } from 'react-dom'; +import { MotionController } from './MotionController'; +import { Reveal, RevealGroup, RevealItem, RevealLine, RevealStep } from './Reveal'; + +const cards = ['Regenerate', 'Restore', 'Protect', 'Recover']; +const steps = ['Discovery', 'Preclinical', 'Clinical']; +const bindings = [{ kind: 'section-entrance' as const, target: '[data-reveal]' }]; + +function Home() { + return ( + <Box as="main"> + <Box as="header" h="1400px" p="24px"> + <h1>First viewport: no entrance</h1> + <a id="other" href="/other">Another route</a> + </Box> + {/* The painted band stays still; the wrapper inside it moves. */} + <Box as="section" id="band" bg="#EEF2FF" py="40px"> + <Reveal id="reveal"> + <Box h="200px" p="16px"> + <h2>Readable server section</h2> + <a href="#detail">Detail</a> + <img src="/pixel.png" width="40" height="40" alt="" /> + </Box> + </Reveal> + </Box> + <Box h="900px" /> + <RevealGroup id="cards" className={css({ display: 'flex', gap: '8px', flexWrap: 'wrap' })}> + {cards.map((card, index) => ( + <RevealItem key={card} index={index} className="card"> + <Box w="150px" h="120px" bg="#F1F5F9" p="12px">{card}</Box> + </RevealItem> + ))} + </RevealGroup> + <Box h="900px" /> + <RevealGroup id="timeline" className={css({ position: 'relative', h: '400px' })}> + <RevealLine className={`line ${css({ position: 'absolute', left: '0', top: '0', w: '2px', h: '100%', bg: '#2563EB' })}`} /> + {steps.map((step, index) => ( + <RevealStep key={step} index={index} of={steps.length} className="step" style={{ position: 'absolute', left: 20, top: index * 133, height: 40 }}> + {step} + </RevealStep> + ))} + </RevealGroup> + <Box h="1400px" /> + </Box> + ); +} + +// The smallest client router that behaves like a framework's: same document, +// history entries, scroll restored on the way back. +export function App({ initialPath = '/' }: { initialPath?: string }) { + const [path, setPath] = useState(initialPath); + useEffect(() => { + const click = (event: MouseEvent) => { + const link = (event.target as Element).closest('a[href^="/"]'); + if (!link) return; + event.preventDefault(); + history.replaceState({ y: scrollY }, ''); + history.pushState({ y: 0 }, '', link.getAttribute('href')); + flushSync(() => setPath(location.pathname)); + scrollTo(0, 0); + }; + const pop = (event: PopStateEvent) => { + flushSync(() => setPath(location.pathname)); + scrollTo(0, event.state?.y ?? 0); + }; + document.addEventListener('click', click); + addEventListener('popstate', pop); + return () => { document.removeEventListener('click', click); removeEventListener('popstate', pop); }; + }, []); + return ( + <> + {path === '/other' ? <Box as="main" h="600px" p="24px"><h1>Another route</h1><a href="/">Home</a></Box> : <Home />} + <MotionController bindings={bindings} /> + </> + ); +} diff --git a/harness/runtime-audit/fixtures/keyframes-motion/devup.json b/harness/runtime-audit/fixtures/keyframes-motion/devup.json new file mode 100644 index 00000000..91d9c03c --- /dev/null +++ b/harness/runtime-audit/fixtures/keyframes-motion/devup.json @@ -0,0 +1 @@ +{"theme":{}} diff --git a/harness/runtime-audit/fixtures/keyframes-motion/index.html b/harness/runtime-audit/fixtures/keyframes-motion/index.html new file mode 100644 index 00000000..d87dd454 --- /dev/null +++ b/harness/runtime-audit/fixtures/keyframes-motion/index.html @@ -0,0 +1 @@ +<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><style>body{margin:0}</style></head><body><div id="root"><!--app--></div><script type="module" src="/main.tsx"></script></body></html> diff --git a/harness/runtime-audit/fixtures/keyframes-motion/main.tsx b/harness/runtime-audit/fixtures/keyframes-motion/main.tsx new file mode 100644 index 00000000..01d51874 --- /dev/null +++ b/harness/runtime-audit/fixtures/keyframes-motion/main.tsx @@ -0,0 +1,17 @@ +import { hydrateRoot } from 'react-dom/client'; +import { App } from './App'; + +declare global { interface Window { stopMotion: () => void; prerendered?: string } } + +const params = new URLSearchParams(location.search); +if (params.has('prerender')) { + // The test reads this once and writes it into index.html, so the page the + // checks load is server markup produced by this same bundle. + const { renderToString } = await import('react-dom/server'); + window.prerendered = renderToString(<App initialPath="/" />); +} else { + // `?timed` forces the marker path that browsers without view timelines take. + if (params.has('timed')) CSS.supports = () => false; + const instance = hydrateRoot(document.getElementById('root')!, <App initialPath="/" />); + window.stopMotion = () => instance.unmount(); +} diff --git a/harness/runtime-audit/motion-checks.mjs b/harness/runtime-audit/motion-checks.mjs index 347c983b..474edf13 100644 --- a/harness/runtime-audit/motion-checks.mjs +++ b/harness/runtime-audit/motion-checks.mjs @@ -1,5 +1,7 @@ // Motion checks observe actual rendered geometry and state, never a screenshot // or the presence of an animation declaration. Selectors are semantic app targets. +import { REVEAL_SCENARIOS, runRevealCheck } from './reveal-checks.mjs'; + export function installMotionProbe(configurations) { const records = Object.create(null), observed = new Map(); window.__devupCountMotion = records; @@ -19,10 +21,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) { +// Reveal scenarios live in reveal-checks.mjs; re-exported for callers. +export { REVEAL_SCENARIOS }; +export async function runMotionCheck(page, options, context = {}) { const measured = { kind: options?.kind, candidateId: options?.candidateId, samples: [] }; try { if (!options || typeof options.target !== 'string' || !['marquee','count-up','steps','tabs','reveal'].includes(options.kind)) throw new Error('motion needs kind and semantic target'); @@ -37,13 +38,11 @@ export async function runMotionCheck(page, options) { } if(config.kind==='steps') { const steps=list(config.steps),bars=list(config.progress);const index=steps.findIndex(element=>element.getAttribute('aria-selected')==='true');return {index,selected:steps.filter(element=>element.getAttribute('aria-selected')==='true').length,fill:bars[index]?new DOMMatrixReadOnly(getComputedStyle(bars[index]).transform).a:null}; } if(config.kind==='tabs') { const selected=list(config.links).filter(element=>['true','page'].includes(element.getAttribute('aria-current')??element.getAttribute('aria-selected')??''));return {indicator:rect(node.querySelector(config.indicator)),selected:selected.length===1?rect(selected[0]):null,animations:node.querySelector(config.indicator).getAnimations().filter(animation=>animation.playState==='running').length}; } - if(config.kind==='reveal') { const style=getComputedStyle(node);return {...rect(node),opacity:Number(style.opacity),translate:new DOMMatrixReadOnly(style.transform).m42}; } const track=node.querySelector(config.track),groups=[...track.children],animation=track.getAnimations()[0],frames=animation?.effect?.getKeyframes()??[]; const translate=frame=>new DOMMatrixReadOnly(String(frame?.transform??'none')).m41; 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'); @@ -98,132 +97,8 @@ 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(); - // 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}; + // Entrances have their own module: it returns the whole verdict. + return await runRevealCheck(page, options, measured, context); } 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/reveal-checks.mjs b/harness/runtime-audit/reveal-checks.mjs new file mode 100644 index 00000000..5a916dde --- /dev/null +++ b/harness/runtime-audit/reveal-checks.mjs @@ -0,0 +1,440 @@ +// Reveal (entrance) checks. They observe rendered opacity, transform and +// position over time or over scroll, never a screenshot and never the +// presence of an animation declaration. +// +// Two kinds of entrance are measured, whatever produced them: +// - scroll-linked: CSS keyframes on a view timeline (`animation-timeline: +// view()`). State is a function of scroll position, so there is no clock to +// run late and nothing to replay; the check walks the block through its +// entry and requires it fully shown once it is inside the viewport. +// - timed: keyframes, a transition or a script animation started once when +// the block enters (a marker attribute, an observer). The check records +// where the block was when the clock started, and that it never restarts. +// +// A result is `pass` only for what was observed. Anything that could not be +// observed is `unverified` (the check is then `inconclusive`, never `pass`), +// and a scenario with nothing to observe in this block is `not-applicable`. + +/** Every scenario, in the order they run. Each starts from a fresh load. */ +export const REVEAL_SCENARIOS = [ + 'fast-scroll', 'reload', 'history', 'bfcache', 'client-nav', 'image-delay', 'image-failure', + 'focus', 'reduced-motion', 'no-script', 'stagger', 'progress-sync', +]; + +const FOCUSABLE = 'a[href],button,input,select,textarea,[tabindex]'; + +// Installed into the page; everything the checks read goes through it so the +// timed and scroll-linked paths agree on what "shown" and "in view" mean. +function installRevealLibrary() { + if (window.__devupReveal) return; + const frame = () => new Promise(resolve => requestAnimationFrame(resolve)); + const clocked = animation => !animation.timeline || animation.timeline instanceof DocumentTimeline; + // The elements that carry the entrance: the named items of a group, or the + // target itself. + const parts = (node, items) => { + const found = items ? [...node.querySelectorAll(items)] : []; + return found.length ? found : [node]; + }; + // Opacity as painted, from a part up to the target. + const paintedOpacity = (element, root) => { + let value = 1; + for (let current = element; current; current = current.parentElement) { + value *= Number(getComputedStyle(current).opacity); + if (current === root) break; + } + return value; + }; + const shiftOf = element => new DOMMatrixReadOnly(getComputedStyle(element).transform).m42; + // Where the block sits in layout: its own entrance translation must not + // count as "not yet visible". + const layoutBox = node => { + const box = node.getBoundingClientRect(), shift = shiftOf(node); + return { top: box.top - shift, bottom: box.bottom - shift, height: box.height }; + }; + const ratio = node => { + const box = layoutBox(node); + return Math.max(0, Math.min(box.bottom, innerHeight) - Math.max(box.top, 0)) / Math.min(box.height || 1, innerHeight); + }; + // How far outside the viewport the layout box is. An observer reports a + // block whose edge touches the viewport, so 1px is not "off screen". + const offscreenPx = node => { + const box = layoutBox(node); + return Math.max(0, box.top - innerHeight, -box.bottom); + }; + const sample = (node, items) => { + const list = parts(node, items), box = node.getBoundingClientRect(); + const opacities = list.map(part => paintedOpacity(part, node)), shifts = list.map(part => Math.abs(shiftOf(part))); + const animations = list.flatMap(part => part.getAnimations()); + return { + x: box.x, y: box.y, width: box.width, height: box.height, scrollY, + // The least and the most shown part: a group is mid-entrance while + // some of its items are in and others are not. + opacity: Math.min(...opacities), peakOpacity: Math.max(...opacities), translate: Math.max(...shifts), + itemOpacities: list.length > 1 ? opacities : undefined, + visibleRatio: ratio(node), offscreenPx: offscreenPx(node), visiblePx: ratio(node) * Math.min(box.height || 1, innerHeight), + clockedAnimations: animations.filter(animation => clocked(animation) && animation.playState !== 'finished' && animation.playState !== 'idle').length, + scrollLinked: animations.some(animation => !clocked(animation)), + }; + }; + const shown = value => value.opacity === 1 && Math.abs(value.translate) < 0.1; + const intermediate = value => (value.peakOpacity > 0 && value.opacity < 1) || (value.opacity === 1 && value.translate > 0.1); + // A timed entrance has started once a clocked animation exists on a part, + // or a part is part-way between hidden and shown. + const started = value => value.clockedAnimations > 0 || (value.peakOpacity > 0 && value.opacity < 1); + + // Scrolls toward the target in small steps and records how much of it was + // on screen when the entrance began and when it was complete. + const approach = async (node, items) => { + const first = sample(node, items); + if (first.visibleRatio > 0) return { verified: false, reason: 'target is already in view' }; + const mode = first.scrollLinked ? 'scroll-linked' : 'timed'; + if (mode === 'timed' && started(first)) return { verified: true, mode, startedOffscreen: true, late: false, visibleRatio: 0, offscreenPx: first.offscreenPx, start: first }; + const step = Math.max(4, Math.round(innerHeight * (mode === 'timed' ? 0.05 : 0.02))); + let fullSince = null, begun = null, middle = null; + for (let index = 0; index < 6000; index++) { + const top = node.getBoundingClientRect().top, position = scrollY; + scrollBy(0, top > 0 ? step : -step); await frame(); await frame(); + const now = sample(node, items), stuck = scrollY === position; + if (mode === 'timed') { + if (started(now)) return { verified: true, mode, startedOffscreen: now.offscreenPx > 1, late: false, visibleRatio: now.visibleRatio, offscreenPx: now.offscreenPx, start: now }; + if (now.visibleRatio >= 0.99 || stuck) { + fullSince ??= performance.now(); + if (performance.now() - fullSince > 400) return { verified: true, mode, startedOffscreen: false, late: true, visibleRatio: now.visibleRatio, start: now }; + } + continue; + } + // Scroll-linked: progress is the state itself. + if (!begun && (now.opacity > first.opacity || now.translate < first.translate - 0.1)) begun = now; + if (!middle && intermediate(now) && now.opacity >= 0.15 && now.opacity <= 0.85) middle = now; + if (shown(now)) return { verified: true, mode, startedOffscreen: (begun ?? now).offscreenPx > 1, late: false, visibleRatio: (begun ?? now).visibleRatio, offscreenPx: (begun ?? now).offscreenPx, completeRatio: now.visibleRatio, completeVisiblePx: now.visiblePx, start: first, middle: middle ?? (begun && intermediate(begun) ? begun : null), end: now }; + // Fully inside the viewport, or already half a screen of it showing + // (or the page cannot scroll further), and still not shown: the range + // runs past the entry, and a tall block would be read half faded. + if (now.visibleRatio >= 0.99 || now.visiblePx >= Math.max(320, innerHeight * 0.5) || stuck) return { verified: true, mode, startedOffscreen: false, late: true, visibleRatio: (begun ?? now).visibleRatio, completeRatio: null, start: first, middle, end: now }; + } + return { verified: true, mode, startedOffscreen: false, late: true, visibleRatio: ratio(node), start: first, middle, end: sample(node, items) }; + }; + + // Watches the target for a while and reports whether it was ever hidden or + // displaced while on screen. + const watch = async (node, items, 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 now = sample(node, items); + // A block only partly on screen may still be entering by position. + if (!(now.scrollLinked && now.visibleRatio < 0.99)) { minOpacity = Math.min(minOpacity, now.opacity); maxShift = Math.max(maxShift, now.translate); } + await frame(); + } + return { verified: true, minOpacity, maxShift, flashed: minOpacity < 1 || maxShift > 0.1 }; + }; + + // When each item of a group started and finished appearing, in ms from the + // moment the group was scrolled into view. + const timeItems = async (node, items, ms) => { + const list = [...node.querySelectorAll(items)]; + if (list.length < 2) return { verified: false, reason: 'fewer than two items matched' }; + node.scrollIntoView({ block: 'center' }); + const start = performance.now(), startedAt = list.map(() => null), shownAt = list.map(() => null), hiddenOnce = list.map(() => false); + let scrollLinked = false; + while (performance.now() - start < ms) { + const now = performance.now() - start; + list.forEach((item, index) => { + const opacity = paintedOpacity(item, node), moved = Math.abs(shiftOf(item)) > 0.1; + scrollLinked ||= item.getAnimations().some(animation => !clocked(animation)); + if (opacity < 0.01) { hiddenOnce[index] = true; startedAt[index] = null; shownAt[index] = null; return; } + if (hiddenOnce[index] && startedAt[index] === null) startedAt[index] = now; + if (opacity === 1 && !moved) { if (shownAt[index] === null) shownAt[index] = now; } else shownAt[index] = null; + }); + if (shownAt.every(value => value !== null) && now > 100 && hiddenOnce.some(Boolean)) break; + await frame(); + } + return { verified: true, scrollLinked, observed: hiddenOnce.filter(Boolean).length, startedAt, shownAt }; + }; + + window.__devupReveal = { frame, sample, shown, intermediate, approach, watch, timeItems }; +} + +const shown = value => value.opacity === 1 && Math.abs(value.translate) < 0.1; +const intermediate = value => (value.peakOpacity > 0 && value.opacity < 1) || (value.opacity === 1 && value.translate > 0.1); + +async function library(page) { + await page.evaluate(installRevealLibrary); +} + +/** + * Runs one reveal check. `context.capture(name)` is optional and saves a + * frame screenshot; `context.take` receives every sample for the report. + */ +export async function runRevealCheck(page, options, measured, context = {}) { + const ms = options.durationMs ?? 600, items = options.items ?? null, settle = options.settleMs ?? 400; + const locate = tab => tab.locator(options.target); + const target = locate(page); + const sampleOn = async tab => { await library(tab); return locate(tab).evaluate((node, selector) => window.__devupReveal.sample(node, selector), items); }; + const take = async () => { const value = await sampleOn(page); measured.samples.push(value); return value; }; + const capture = async name => (context.capture ? context.capture(name).catch(() => null) : null); + const frameAt = async (name, value) => ({ opacity: value.opacity, peakOpacity: value.peakOpacity, translate: value.translate, itemOpacities: value.itemOpacities, visibleRatio: value.visibleRatio, scrollY: value.scrollY, screenshot: await capture(name) ?? undefined }); + + await library(page); + // A descendant that animates while the target does not is the usual wrong + // selector: the painted section was named instead of its inner wrapper. + const carrier = await target.evaluate((node, selector) => { + const own = selector ? [...node.querySelectorAll(selector)] : [node]; + const animated = new Set(node.getAnimations({ subtree: true }).map(animation => animation.effect?.target)); + return { own: own.some(part => animated.has(part)), descendant: animated.size > 0 }; + }, items); + + const trigger = await target.evaluate((node, selector) => window.__devupReveal.approach(node, selector), items); + measured.trigger = trigger; measured.mode = trigger.mode ?? null; + let valid = false, frames = null; + if (trigger.mode === 'scroll-linked') { + // Position decides the state, so each frame can be revisited exactly. + frames = {}; + for (const [name, value] of [['start', trigger.start], ['middle', trigger.middle], ['end', trigger.end]]) { + if (!value) continue; + measured.samples.push(value); + await page.evaluate(y => scrollTo(0, y), value.scrollY); + await page.evaluate(() => window.__devupReveal.frame()); + frames[name] = await frameAt(name, value); + } + await page.evaluate(y => scrollTo(0, y), trigger.end.scrollY); + valid = Boolean(trigger.middle) && intermediate(trigger.middle) && shown(trigger.end) && !trigger.late && !trigger.startedOffscreen; + } else { + if (!trigger.verified) await target.scrollIntoViewIfNeeded(); + const start = trigger.start ?? await take(); + if (trigger.start) measured.samples.push(start); + frames = { start: await frameAt('start', start) }; + await page.waitForTimeout(Math.min(120, ms * 0.25)); + const middle = await take(); frames.middle = await frameAt('middle', middle); + await page.waitForTimeout(ms + 30 + (items ? options.maxTotalMs ?? 1000 : 0)); + const last = await take(); frames.end = await frameAt('end', last); + valid = intermediate(middle) && shown(last) && !(trigger.verified && (trigger.startedOffscreen || trigger.late)); + } + measured.frames = frames; + if (!valid && !carrier.own && carrier.descendant) measured.hint = 'The target itself does not animate but an element inside it does: point motion.target at the element that carries the entrance (the inner wrapper), or name the animated children with motion.items.'; + + // A reveal plays once. Leave the viewport and come back: content must stay + // shown while away (never re-hidden) and must not replay. + const reference = await take(); + const leave = () => target.evaluate((node, above) => { + const box = node.getBoundingClientRect(), top = scrollY + box.top, limit = document.documentElement.scrollHeight - innerHeight; + // Scroll-linked: go past it (the block ends above the viewport), because + // below the viewport it is hidden by position and nobody can see that. + const candidates = above ? [Math.min(limit, top + box.height + 1)] : [0, limit]; + const destination = candidates.find(y => top + box.height <= y || top >= y + innerHeight); + if (destination === undefined) return false; + scrollTo(0, destination); return true; + }, trigger.mode === 'scroll-linked'); + if (await leave()) { + await page.waitForTimeout(60); const away = await take(); + await target.evaluate(node => node.scrollIntoView({ block: 'center' })); + await page.waitForTimeout(trigger.mode === 'scroll-linked' ? 40 : Math.min(120, ms * 0.25)); const reentered = await take(); + await page.waitForTimeout(ms + 30); const settled = await take(); + measured.reentry = { verified: true, replayed: !shown(reentered), hiddenWhileAway: !shown(away) }; + if (trigger.mode === 'scroll-linked') { + // From the top of the page straight to the block: the state must be + // complete at once, with no clock to wait for. + await page.evaluate(() => scrollTo(0, 0)); await page.waitForTimeout(40); + await target.evaluate(node => node.scrollIntoView({ block: 'center' })); await page.waitForTimeout(40); + const jumped = await take(); + measured.reentry.replayed ||= !shown(jumped); + } + valid = valid && shown(away) && shown(reentered) && shown(settled) && !measured.reentry.replayed; + } 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 }; + } + + // Scenarios. An explicit list is a claim that each one applies, so one with + // nothing to observe fails. With no list every scenario runs, and one with + // nothing to observe in this block is recorded as not-applicable. + const explicit = Array.isArray(options.scenarios), requested = explicit ? options.scenarios : REVEAL_SCENARIOS; + measured.scenarios = {}; measured.scenariosNotRun = REVEAL_SCENARIOS.filter(name => !requested.includes(name)); + const absent = (reason) => (explicit ? { status: 'fail', verified: false, reason } : { status: 'not-applicable', reason }); + const unverified = reason => ({ status: explicit ? 'fail' : 'unverified', verified: false, reason }); + const height = reference.height, url = page.url(), viewport = page.viewportSize(); + // 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); await library(page); }; + const center = tab => locate(tab).evaluate(node => node.scrollIntoView({ block: 'center' })); + const watchOn = async (tab, duration = ms + 600) => { await library(tab); return locate(tab).evaluate((node, config) => window.__devupReveal.watch(node, config.items, config.ms), { items, ms: duration }); }; + const verdict = observed => (observed.verified ? { status: observed.flashed ? 'fail' : 'pass', ...observed } : unverified(observed.reason)); + // A second context shares the session (cookies, storage) of the audited one. + const sibling = async (browser, extra = {}) => browser.newContext({ viewport, storageState: await page.context().storageState(), ...extra }); + + 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 center(page); await page.waitForTimeout(ms + 150 + (items ? options.maxTotalMs ?? 1000 : 0)); + const value = await sampleOn(page); result = { status: shown(value) ? 'pass' : 'fail', sample: value }; + } else if (name === 'reload' || name === 'history') { + await fresh(); await center(page); await page.waitForTimeout(ms + 150 + (items ? options.maxTotalMs ?? 1000 : 0)); + if (name === 'reload') await page.reload({ waitUntil: 'load' }); + else { 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'); } + result = verdict(await watchOn(page)); + } else if (name === 'bfcache') { + result = await bfcache({ page, url, viewport, sibling, center, watchOn, ms, unverified, wait: ms + 150 + (items ? options.maxTotalMs ?? 1000 : 0), launch: context.launchRestorable }); + } else if (name === 'client-nav') { + if (typeof options.navigate !== 'string') result = unverified('motion.navigate names no same-origin link to leave the page with, so client navigation was not exercised'); + else { + await fresh(); await center(page); await page.waitForTimeout(ms + 150); + await page.evaluate(() => { window.__devupSameDocument = true; }); + const link = page.locator(options.navigate); + if (await link.count() !== 1) result = unverified('motion.navigate must match exactly one link'); + else { + // Activated in place: scrolling to the link first would make the + // router remember the link's position instead of the target's. + await link.evaluate(node => node.click()); await page.waitForTimeout(settle); + const left = page.url() !== url, sameDocument = await page.evaluate(() => window.__devupSameDocument === true); + if (!left) result = unverified('following motion.navigate did not change the URL'); + else if (!sameDocument) result = { status: 'not-applicable', reason: 'the link loaded a new document: this app has no client navigation here (see the history scenario)' }; + else { + await page.goBack({ waitUntil: 'commit' }); await page.waitForTimeout(60); + if (page.url() !== url || await target.count() !== 1) result = unverified('going back did not return to the audited route'); + else result = { ...verdict(await watchOn(page)), sameDocument: true }; + } + } + } + } else if (name === 'image-delay' || name === 'image-failure') { + if (!await target.locator('img').count()) result = absent('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 center(page); await page.waitForTimeout(ms + 150 + (items ? options.maxTotalMs ?? 1000 : 0)); const value = await sampleOn(page); result = { status: shown(value) && Math.abs(value.height - height) < 1 ? 'pass' : 'fail', sample: value, 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 'none'; + control.focus(); window.__devupFocused = control; for (let index = 0; index < 3; index++) await new Promise(resolve => requestAnimationFrame(resolve)); + return document.activeElement === control ? 'focused' : 'refused'; + }, options.focus ?? FOCUSABLE); + if (focused !== 'focused') result = absent(focused === 'none' ? 'target has no focusable control' : 'the control inside the target did not take focus'); + else { + await page.waitForTimeout(50); const value = await sampleOn(page); + // The focused control is painted and on screen, and Tab can leave it. + const control = await page.evaluate(() => { const element = document.activeElement, box = element.getBoundingClientRect(); let opacity = 1; for (let current = element; current; current = current.parentElement) opacity *= Number(getComputedStyle(current).opacity); return { opacity, onScreen: box.bottom > 0 && box.top < innerHeight }; }); + await page.keyboard.press('Tab'); + const moved = await page.evaluate(() => document.activeElement !== window.__devupFocused); + result = { status: shown(value) && control.opacity === 1 && control.onScreen && moved ? 'pass' : 'fail', sample: value, focusedControl: control, trapped: !moved }; + } + } else if (name === 'reduced-motion') { + await page.emulateMedia({ reducedMotion: 'reduce' }); + try { + await fresh(); + // Walk the block in and require it shown in place the whole way. + const observed = await target.evaluate(async (node, selector) => { + const reveal = window.__devupReveal; let minOpacity = 1, maxShift = 0, seen = 0; + for (let index = 0; index < 6000; index++) { + const top = node.getBoundingClientRect().top, position = scrollY; + const value = reveal.sample(node, selector); + if (value.visibleRatio > 0) { seen++; minOpacity = Math.min(minOpacity, value.opacity); maxShift = Math.max(maxShift, value.translate); } + if (value.visibleRatio >= 0.99 && seen > 12) break; + scrollBy(0, top > innerHeight / 2 ? Math.max(4, innerHeight * 0.05) : 0); await reveal.frame(); + if (scrollY === position && value.visibleRatio > 0 && seen > 12) break; + if (scrollY === position && value.visibleRatio === 0) return { verified: false, reason: 'target never entered the viewport' }; + } + return seen > 0 ? { verified: true, minOpacity, maxShift, flashed: minOpacity < 1 || maxShift > 0.1 } : { verified: false, reason: 'target never entered the viewport' }; + }, items); + result = verdict(observed); + } finally { await page.emulateMedia({ reducedMotion: null }); } + } else if (name === 'no-script') { + // Server markup is the content: with scripts off the block is there + // and painted. + const scriptless = await sibling(page.context().browser(), { javaScriptEnabled: false }); + try { + const tab = await scriptless.newPage(); await tab.goto(url, { waitUntil: 'load' }); await tab.waitForTimeout(Math.min(settle, 300)); + if (await locate(tab).count() !== 1) result = unverified('target is not in the markup served without JavaScript'); + else { await center(tab); await tab.waitForTimeout(100); const value = await sampleOn(tab); result = { status: shown(value) || (value.scrollLinked && value.opacity === 1) ? 'pass' : 'fail', sample: value }; } + } finally { await scriptless.close(); } + } else if (name === 'stagger') { + if (typeof items !== 'string') result = absent('motion.items names no group items'); + else { + await fresh(); + const maxStep = options.maxStepMs ?? 150, maxSpan = options.maxSpanMs ?? 450, maxTotal = options.maxTotalMs ?? 1000; + const observed = await target.evaluate((node, config) => window.__devupReveal.timeItems(node, config.items, config.ms), { items, ms: maxTotal + ms + 1500 }); + if (!observed.verified) result = absent(observed.reason); + else if (observed.scrollLinked && observed.observed === 0) result = { status: 'not-applicable', reason: 'items are scroll-linked: their state follows position, there is no clock to bound' }; + else if (observed.observed === 0) result = unverified('no item was seen hidden before appearing, so no entrance was observed'); + else { + const starts = observed.startedAt.filter(value => value !== null), ends = observed.shownAt; + const steps = observed.startedAt.slice(1).map((value, index) => (value === null || observed.startedAt[index] === null ? null : value - observed.startedAt[index])); + const spanMs = Math.max(...starts) - Math.min(...starts), finished = ends.every(value => value !== null); + const totalMs = finished ? Math.max(...ends) - Math.min(...starts) : null; + // A frame of jitter is not a reversed order. + const ordered = steps.every(step => step === null || step >= -34); + const ok = finished && ordered && steps.every(step => step === null || step <= maxStep) && spanMs <= maxSpan && totalMs <= maxTotal; + result = { status: ok ? 'pass' : 'fail', items: observed.startedAt.length, startedAt: observed.startedAt, shownAt: observed.shownAt, stepsMs: steps, spanMs, totalMs, ordered, bounds: { maxStepMs: maxStep, maxSpanMs: maxSpan, maxTotalMs: maxTotal } }; + } + } + } 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 items !== 'string') result = absent('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), list = [...node.querySelectorAll(config.items)]; + if (!line || list.length < 2) return { verified: false, reason: 'progress line or at least two items not found' }; + node.scrollIntoView({ block: 'center' }); + const start = performance.now(), shownAt = list.map(() => null), reachedAt = list.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); + list.forEach((item, index) => { + const rect = item.getBoundingClientRect(); + // The time that counts is the last rise above half opacity. + let opacity = 1; for (let current = item; current; current = current.parentElement) { opacity *= Number(getComputedStyle(current).opacity); if (current === node) break; } + if (opacity < 0.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: list.map((_, index) => (shownAt[index] === null || reachedAt[index] === null ? null : shownAt[index] - reachedAt[index])) }; + }, { progress: options.progress, items, ms: options.progressMs ?? ms * 3 + 600 }); + result = observed.verified ? { status: observed.gaps.every(gap => gap !== null && Math.abs(gap) <= tolerance) ? 'pass' : 'fail', toleranceMs: tolerance, ...observed } : absent(observed.reason); + } + } else result = { status: 'fail', reason: 'unknown scenario' }; + } catch (error) { result = { status: explicit ? 'fail' : 'unverified', reason: String(error.message ?? error) }; } + measured.scenarios[name] = result; + } + const named = status => Object.entries(measured.scenarios).filter(([, result]) => result.status === status).map(([name]) => name); + measured.notApplicable = named('not-applicable'); + measured.unverified = [...named('unverified'), ...(trigger.verified ? [] : ['trigger'])]; + const failed = named('fail'); + if (failed.length) return { status: 'fail', reason: `Reveal scenario failed or could not be observed: ${failed.join(', ')}`, measured }; + if (!valid) return { status: 'fail', reason: 'Motion lacked intermediate geometry, continuity or expected final state', measured }; + if (measured.unverified.length) return { status: 'inconclusive', reason: `Reveal passed what was observed, but these could not be checked and are unverified: ${measured.unverified.join(', ')}`, measured }; + return { status: 'pass', reason: 'Observed start, intermediate and final frames, trigger position, one-shot re-entry and the requested scenarios', measured }; +} + +// Back/forward cache: the page is frozen and thawed, not reloaded. Playwright +// switches the cache off, so this needs a browser launched with it on. +async function bfcache({ page, url, viewport, sibling, center, watchOn, ms, wait, unverified, launch }) { + let browser; + try { + browser = await (launch ?? (() => page.context().browser().browserType().launch({ channel: 'chromium', ignoreDefaultArgs: ['--disable-back-forward-cache'] })))(); + } catch (error) { + return unverified(`no browser with the back/forward cache could be started (${String(error.message ?? error).split('\n')[0]})`); + } + try { + const context = await sibling(browser); + const tab = await context.newPage(); + await tab.addInitScript(() => addEventListener('pageshow', event => { window.__devupPageshow = [...(window.__devupPageshow ?? []), event.persisted]; })); + await tab.goto(url, { waitUntil: 'load' }); await center(tab); await tab.waitForTimeout(wait); + await tab.goto(new URL('/__devup-bfcache-away__', url).href, { waitUntil: 'commit' }).catch(() => {}); + await tab.goBack({ waitUntil: 'commit' }); await tab.waitForTimeout(30); + const restored = tab.url() === url && await tab.evaluate(() => (window.__devupPageshow ?? []).includes(true)).catch(() => false); + if (!restored) return unverified('the page was not restored from the back/forward cache (an unload handler, Cache-Control: no-store or an open connection makes it ineligible)'); + const observed = await watchOn(tab, ms + 600); + return observed.verified ? { status: observed.flashed ? 'fail' : 'pass', restoredFromBfcache: true, ...observed } : unverified(observed.reason); + } finally { await browser.close().catch(() => {}); } +} diff --git a/harness/runtime-audit/run.mjs b/harness/runtime-audit/run.mjs index 2852f490..c7deb8a7 100644 --- a/harness/runtime-audit/run.mjs +++ b/harness/runtime-audit/run.mjs @@ -694,6 +694,9 @@ export async function settleScrollReveals(page, { stepMs = 120, maxMs = 15000 } await frame(); const running = () => document.getAnimations().filter((animation) => { if (animation.playState !== 'running') return false; + // A view/scroll timeline is bound to position, not to a clock: it is + // "running" for as long as its block is in range and never settles. + if (animation.timeline && !(animation.timeline instanceof DocumentTimeline)) return false; const timing = animation.effect?.getComputedTiming?.(); // An infinite loop (marquee, spinner) never settles; it is not a reveal. return !(timing && timing.iterations === Infinity); @@ -711,7 +714,14 @@ export async function settleScrollReveals(page, { stepMs = 120, maxMs = 15000 } export async function freezeAnimations(page) { return page.evaluate(() => { if (window.__devupFrozenMotion) return window.__devupFrozenMotion.report; - const animations = document.getAnimations().filter((animation) => animation.playState === 'running' || animation.pending); + const clocked = (animation) => !animation.timeline || animation.timeline instanceof DocumentTimeline; + // A scroll-linked entrance (animation-timeline: view()) holds every block + // that is not on screen at its hidden first frame, so a full-page capture + // or a visibility check would see blank sections. Cancelling it leaves the + // resting style, which is the content; restoreAnimations plays it again. + const scrollLinked = document.getAnimations().filter((animation) => !clocked(animation)); + for (const animation of scrollLinked) animation.cancel(); + const animations = document.getAnimations().filter((animation) => clocked(animation) && (animation.playState === 'running' || animation.pending)); const saved = animations.map((animation) => ({ animation, currentTime: animation.currentTime, playState: animation.playState })); const selector = (element) => { if (!(element instanceof Element)) return '(unknown target)'; @@ -732,8 +742,8 @@ export async function freezeAnimations(page) { const style = document.createElement('style'); style.textContent = '*,*::before,*::after{transition-property:none!important;transition-duration:0s!important;transition-delay:0s!important}'; document.documentElement.append(style); - const report = { count: selectors.length, selectors, animationCount: animations.length }; - window.__devupFrozenMotion = { saved, style, report }; + const report = { count: selectors.length, selectors, animationCount: animations.length, scrollLinkedCount: scrollLinked.length }; + window.__devupFrozenMotion = { saved, style, report, scrollLinked }; return report; }); } @@ -743,6 +753,7 @@ export async function restoreAnimations(page) { const frozen = window.__devupFrozenMotion; if (!frozen) return; frozen.style.remove(); + for (const animation of frozen.scrollLinked) animation.play(); for (const { animation, currentTime, playState } of frozen.saved) { animation.currentTime = currentTime; if (playState === 'running') animation.play(); @@ -927,7 +938,14 @@ async function runViewport(browser, plan, viewport, out, deadline, references, r } if (check.kind === 'motion') { await restoreAnimations(page); - try { record.checks.push({ ...item, ...await runMotionCheck(page, check.motion) }); } + // Start, middle and end frames of an entrance are kept as evidence. + const capture = async (name) => { + const shot = `${viewport.width}x${viewport.height}@${viewport.deviceScaleFactor ?? 1}-${safeName(screen.id)}-m${index}-${name}.png`; + const bytes = await page.screenshot({ animations: 'allow', caret: 'hide' }); + await writeFile(join(out, shot), bytes); + return { path: shot, sha256: sha256(bytes), bytes: bytes.length }; + }; + try { record.checks.push({ ...item, ...await runMotionCheck(page, check.motion, { capture }) }); } finally { await freezeAnimations(page); } continue; } diff --git a/harness/runtime-audit/test/keyframes-motion.test.mjs b/harness/runtime-audit/test/keyframes-motion.test.mjs new file mode 100644 index 00000000..1d017c12 --- /dev/null +++ b/harness/runtime-audit/test/keyframes-motion.test.mjs @@ -0,0 +1,88 @@ +// The keyframes entrance devup-mcp ships, compiled with the installed Devup UI +// and audited in a browser at a mobile and a desktop viewport. +import assert from 'node:assert/strict'; +import test from 'node:test'; +import {cp,mkdtemp,mkdir,readFile,readdir,writeFile,rm} from 'node:fs/promises'; +import {join,resolve,dirname} from 'node:path'; +import {fileURLToPath} from 'node:url'; +import {build,preview} from 'vite'; +import {DevupUI} from '@devup-ui/vite-plugin'; +import {chromium} from 'playwright'; +import {runMotionCheck} from '../motion-checks.mjs'; +import {REVEAL_SCENARIOS} from '../reveal-checks.mjs'; + +const harness=resolve(dirname(fileURLToPath(import.meta.url)),'..'),repo=resolve(harness,'../..'); +const VIEWPORTS=[{name:'mobile',width:390,height:844},{name:'desktop',width:1440,height:900}]; +const pixel=Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==','base64'); + +test('the shipped Devup UI keyframes reveal builds to static CSS and passes every reveal check at mobile and desktop', {timeout:1500000}, async()=> { + await mkdir(join(harness,'out'),{recursive:true});const dir=await mkdtemp(join(harness,'out','keyframes-'));let server,browser; + try { + await cp(join(harness,'fixtures/keyframes-motion'),dir,{recursive:true}); + const templates=join(repo,'crates/devup-mcp/src/server/behavior_contracts/templates'); + for(const name of ['Reveal.tsx','MotionController.tsx','motion-runtime.ts'])await writeFile(join(dir,name),await readFile(join(templates,name))); + await mkdir(join(dir,'public'),{recursive:true});await writeFile(join(dir,'public','pixel.png'),pixel); + const config={root:dir,configFile:false,logLevel:'error',plugins:[DevupUI()],build:{outDir:'dist',target:'esnext'},preview:{host:'127.0.0.1',port:0}}; + await build(config); + // Static extraction: the entrance is CSS, and no animation library ships. + const assets=await readdir(join(dir,'dist/assets')); + const read=async extension=>(await Promise.all(assets.filter(name=>name.endsWith(extension)).map(name=>readFile(join(dir,'dist/assets',name),'utf8')))).join('\n'); + const css=await read('.css'),js=await read('.js'); + for(const rule of ['@keyframes','animation-timeline:view()','animation-range:entry 0 entry min(100%,240px)','animation-fill-mode:both','prefers-reduced-motion:reduce',':focus-within{animation:none}','[data-reveal-state="pending"]','[data-reveal-state="in"]','calc(var(--reveal-index) * 60ms)'])assert.ok(css.includes(rule),`compiled CSS lacks ${rule}`); + for(const library of ['framer-motion','motion/react','gsap'])assert.ok(!js.includes(library),`bundle contains ${library}`); + server=await preview(config);browser=await chromium.launch();const url=`http://127.0.0.1:${server.httpServer.address().port}`; + // Server markup from this same bundle, so "no JavaScript" is a real page. + const once=await browser.newPage();await once.goto(`${url}/?prerender`);await once.waitForFunction(()=>typeof window.prerendered==='string'); + const markup=await once.evaluate(()=>window.prerendered);await once.close(); + assert.ok(markup.includes('id="reveal"')&&!markup.includes('data-reveal-state'),'server markup carries no hidden state'); + const index=join(dir,'dist/index.html');await writeFile(index,(await readFile(index,'utf8')).replace('<!--app-->',markup)); + + const frames=join(dir,'frames');await mkdir(frames,{recursive:true}); + const audit=async(viewport,path,options)=> { + const page=await browser.newPage({viewport:{width:viewport.width,height:viewport.height}}),errors=[];page.on('pageerror',error=>errors.push(String(error))); + await page.goto(`${url}${path}`);await page.waitForFunction(()=>typeof window.stopMotion==='function'); + let shots=0;const capture=async name=>{const file=`${viewport.name}-${options.candidateId.replace(/\W+/g,'-')}-${name}.png`;await page.screenshot({path:join(frames,file)});shots++;return {path:file};}; + const report=await runMotionCheck(page,{kind:'reveal',durationMs:400,settleMs:150,...options},{capture}); + assert.deepEqual(errors,[],`${viewport.name} ${options.candidateId}`);await page.close(); + return {report,shots}; + }; + for(const viewport of VIEWPORTS) { + const label=`${viewport.name} ${viewport.width}x${viewport.height}`; + // The scroll-linked section: every scenario runs; the two that need a group have nothing to observe here. + const {report:section,shots}=await audit(viewport,'/',{candidateId:'section-entrance:reveal',target:'#reveal',navigate:'#other'}); + assert.equal(section.status,'pass',`${label}: ${JSON.stringify(section)}`); + assert.equal(section.measured.mode,'scroll-linked');assert.equal(shots,3,'start, middle and end frames were captured'); + const {start,middle,end}=section.measured.frames; + assert.equal(start.opacity,0,label);assert.ok(start.translate>15&&start.translate<=16,label);assert.equal(start.visibleRatio,0,'hidden only while off screen'); + assert.ok(middle.opacity>0&&middle.opacity<1&&middle.translate>0&&middle.translate<16,`${label}: ${JSON.stringify(middle)}`); + assert.equal(end.opacity,1,label);assert.equal(end.translate,0,label); + assert.equal(section.measured.trigger.startedOffscreen,false);assert.equal(section.measured.trigger.late,false);assert.ok(section.measured.trigger.completeRatio<=1); + assert.deepEqual(section.measured.reentry,{verified:true,replayed:false,hiddenWhileAway:false}); + assert.deepEqual(section.measured.notApplicable,['stagger','progress-sync'],label);assert.deepEqual(section.measured.unverified,[],label);assert.deepEqual(section.measured.scenariosNotRun,[]); + assert.deepEqual(Object.entries(section.measured.scenarios).filter(([,result])=>result.status==='pass').map(([name])=>name),REVEAL_SCENARIOS.filter(name=>!['stagger','progress-sync'].includes(name)),label); + assert.equal(section.measured.scenarios.bfcache.restoredFromBfcache,true);assert.equal(section.measured.scenarios['client-nav'].sameDocument,true); + // The same section through the marker a browser without view timelines gets: one timed play. + const {report:fallback}=await audit(viewport,'/?timed',{candidateId:'section-entrance:fallback',target:'#reveal',scenarios:['fast-scroll','reload','history','image-delay','image-failure','focus','reduced-motion','no-script']}); + assert.equal(fallback.status,'pass',`${label}: ${JSON.stringify(fallback)}`);assert.equal(fallback.measured.mode,'timed'); + assert.equal(fallback.measured.trigger.startedOffscreen,false);assert.equal(fallback.measured.trigger.late,false); + assert.ok(fallback.measured.frames.middle.opacity>0&&fallback.measured.frames.middle.opacity<1,JSON.stringify(fallback.measured.frames));assert.equal(fallback.measured.frames.end.opacity,1); + // A card group on a clock: one trigger, items 60ms apart, done well inside a second. + const {report:group}=await audit(viewport,'/',{candidateId:'section-entrance:cards',target:'#cards',items:'.card',navigate:'#other'}); + assert.equal(group.status,'pass',`${label}: ${JSON.stringify(group)}`);assert.equal(group.measured.mode,'timed'); + const stagger=group.measured.scenarios.stagger; + assert.equal(stagger.status,'pass',JSON.stringify(stagger));assert.equal(stagger.items,4);assert.equal(stagger.ordered,true); + // Nominal: 60ms steps, a 180ms span, 580ms in total. Frame times vary by machine, so only the bounds and a floor are asserted. + assert.ok(stagger.stepsMs.every(step=>step<=150),JSON.stringify(stagger));assert.ok(stagger.spanMs>=60&&stagger.spanMs<=450,JSON.stringify(stagger));assert.ok(stagger.totalMs>=400&&stagger.totalMs<=1000,JSON.stringify(stagger)); + assert.deepEqual(group.measured.notApplicable,['image-delay','image-failure','focus','progress-sync'],label); + // A timeline: the line and its steps share one variable, so each step appears as the line reaches it. + const {report:timeline}=await audit(viewport,'/',{candidateId:'section-entrance:timeline',target:'#timeline',items:'.step',progress:'.line',scenarios:['progress-sync','stagger','fast-scroll','reduced-motion','no-script'],maxStepMs:400,maxSpanMs:900,maxTotalMs:1400}); + assert.equal(timeline.status,'pass',`${label}: ${JSON.stringify(timeline)}`); + const sync=timeline.measured.scenarios['progress-sync'];assert.equal(sync.axis,'vertical');assert.ok(sync.gaps.every(gap=>Math.abs(gap)<=200),JSON.stringify(sync)); + } + assert.equal((await readdir(frames)).length,VIEWPORTS.length*4*3,'three frames per check per viewport'); + + // Unmounting the controller leaves nothing hidden. + const tidy=await browser.newPage({viewport:{width:390,height:844}});await tidy.goto(`${url}/?timed`);await tidy.waitForFunction(()=>document.querySelector('[data-reveal-state="pending"]')); + await tidy.evaluate(()=>window.stopMotion());assert.equal(await tidy.locator('[data-reveal-state]').count(),0);await tidy.close(); + } finally {if(browser)await browser.close();if(server)await new Promise(done=>server.httpServer.close(done));if(!process.env.DEVUP_KEEP_RENDER_EVIDENCE)await rm(dir,{recursive:true,force:true});} +}); diff --git a/harness/runtime-audit/test/motion.test.mjs b/harness/runtime-audit/test/motion.test.mjs index ad32f687..81e7aeb6 100644 --- a/harness/runtime-audit/test/motion.test.mjs +++ b/harness/runtime-audit/test/motion.test.mjs @@ -5,11 +5,13 @@ import {join,resolve,dirname} from 'node:path'; import {fileURLToPath} from 'node:url'; import {build,preview} from 'vite'; import {chromium} from 'playwright'; -import {installMotionProbe,runMotionCheck, REVEAL_SCENARIOS} from '../motion-checks.mjs'; +import {installMotionProbe,runMotionCheck} from '../motion-checks.mjs'; const harness=resolve(dirname(fileURLToPath(import.meta.url)),'..'),repo=resolve(harness,'../..'); -test('shipped motion controllers execute counts, seamless marquee, cycling, persistent tabs and shared reveals', {timeout:90000},async()=> { +// Entrances are static CSS now: keyframes-motion.test.mjs compiles and audits +// them, and reveal.test.mjs holds the pages that must fail. +test('shipped motion controllers execute counts, seamless marquee, cycling and persistent tabs', {timeout:90000},async()=> { await mkdir(join(harness,'out'),{recursive:true});const dir=await mkdtemp(join(harness,'out','motion-'));let server,browser; try { const templates=join(repo,'crates/devup-mcp/src/server/behavior_contracts/templates'); @@ -24,7 +26,7 @@ test('shipped motion controllers execute counts, seamless marquee, cycling, pers <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 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}]; const root=createRoot(document.querySelector('#controller'));root.render(createElement(MotionController,{bindings}));window.stopMotion=()=>root.unmount(); document.querySelectorAll('#tabs a').forEach(link=>link.addEventListener('click',event=>{event.preventDefault();history.pushState({},'',link.getAttribute('href'));document.querySelectorAll('#tabs a').forEach(other=>other===link?other.setAttribute('aria-current','page'):other.removeAttribute('aria-current'));}));`); const config={root:dir,configFile:false,logLevel:'error',build:{outDir:'dist'},preview:{host:'127.0.0.1',port:0}}; @@ -36,7 +38,6 @@ 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,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 +46,6 @@ 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,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(); } @@ -87,7 +87,7 @@ test('shipped motion controllers execute counts, seamless marquee, cycling, pers await dynamic.evaluate(()=>window.stopMotion());assert.equal(await dynamic.locator('.track > *').count(),1);await dynamic.close(); const noJs=await browser.newContext({javaScriptEnabled:false});const page=await noJs.newPage();await page.goto(url); - assert.equal(await page.locator('#count').textContent(),'8.52%');assert.equal(await page.locator('#reveal').evaluate(node=>getComputedStyle(node).opacity),'1');await noJs.close(); + assert.equal(await page.locator('#count').textContent(),'8.52%');await noJs.close(); const broken=await browser.newPage();await broken.setContent('<span id="static">8.52%</span>'); const negative=await runMotionCheck(broken,{kind:'count-up',target:'#static',finalValue:8.52,precision:2,durationMs:100});assert.equal(negative.status,'fail');await broken.close(); } finally {if(browser)await browser.close();if(server)await new Promise(done=>server.httpServer.close(done));await rm(dir,{recursive:true,force:true});} @@ -113,126 +113,3 @@ 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));} -}); diff --git a/harness/runtime-audit/test/reveal.test.mjs b/harness/runtime-audit/test/reveal.test.mjs new file mode 100644 index 00000000..96ad34d9 --- /dev/null +++ b/harness/runtime-audit/test/reveal.test.mjs @@ -0,0 +1,280 @@ +// Reveal checks against small pages that each implement the same entrance a +// different way. One page per rule: the correct one passes, and every other +// one breaks exactly one rule and must fail (or be unverified) on that rule. +import assert from 'node:assert/strict'; +import test from 'node:test'; +import {createServer} from 'node:http'; +import {chromium} from 'playwright'; +import {runMotionCheck} from '../motion-checks.mjs'; +import {REVEAL_SCENARIOS} from '../reveal-checks.mjs'; + +const pixel=Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==','base64'); +const VIEWPORTS=[{width:390,height:844},{width:1440,height:900}]; +// Taller than any viewport used here plus the early page's 600px margin, so nothing is in range at load. +const spacer='<div style="height:2200px"></div>'; + +// A timed entrance started by a script: a 300ms fade and upward move when the +// section enters. `good` plays once, leaves content alone when it is already +// on screen, focused or when reduced motion is asked for. +const timed=(mode,bare)=>`<!doctype html><body style="margin:0">${spacer} +<section id="reveal" style="height:200px"${mode==='markup-hidden'?' data-hidden':''}>${bare?'Readable server section':'<a href="#x">Link</a><img src="/pixel.png" width="40" height="40" alt="">'}</section> +${mode==='markup-hidden'?'<style>[data-hidden]{opacity:0}</style>':''}${spacer}<a id="after" href="#y">After</a><script> +const mode=${JSON.stringify(mode)},node=document.querySelector('#reveal');let played=false,first=true; +node.removeAttribute('data-hidden'); +const reduce=matchMedia('(prefers-reduced-motion: reduce)').matches&&mode!=='ignores-preference'; +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='';});} +if(mode==='pageshow-replay')addEventListener('pageshow',event=>{if(event.persisted)play();}); +if(mode==='rehide'||mode==='replay')new IntersectionObserver(entries=>entries.forEach(entry=> { + if(!entry.isIntersecting){if(mode==='rehide')node.style.opacity='0';return;} + node.style.opacity='';if(played&&mode!=='replay')return;played=true;play(); +})).observe(node); +else 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(reduce)return; + if(node.contains(document.activeElement)&&mode!=='focusanim')return; + play(); +}),{rootMargin:mode==='early'?'600px':'0px'}).observe(node); +</script></body>`; + +// The same entrance as static CSS on a view timeline. `css-good` is the +// recommended form; the others drop one declaration each. +const linked=(mode,tall)=>`<!doctype html><style> +@keyframes rise{from{opacity:0;transform:translateY(16px)}to{opacity:1;transform:translateY(0)}} +#reveal{animation:rise linear both;animation-timeline:view();${mode==='css-cover'?'':`animation-range:entry 0% entry ${mode==='css-entry'?'100%':'min(100%, 240px)'}`}} +${mode==='css-nofocus'?'':'#reveal:focus-within{animation:none}'} +${mode==='css-noreduce'?'':'@media (prefers-reduced-motion: reduce){#reveal{animation:none}}'} +</style><body style="margin:0">${spacer} +<section id="band" style="background:#eef"><div id="reveal" style="height:${tall?1200:200}px"><a href="#x" style="display:block">Link</a><img src="/pixel.png" width="40" height="40" alt=""></div></section> +${spacer}<a id="after" href="#y">After</a></body>`; + +// A card group on a clock: marked once when it enters, items delayed by index. +const group=(step,duration,count)=>`<!doctype html><style> +@keyframes rise{from{opacity:0;transform:translateY(16px)}to{opacity:1;transform:translateY(0)}} +#reveal{display:flex;gap:8px;height:120px}.item{width:60px} +#reveal.pending .item{opacity:0} +#reveal.in .item{animation:rise ${duration}ms ease-out both;animation-delay:calc(var(--i) * ${step}ms)} +</style><body style="margin:0">${spacer} +<section id="reveal">${Array.from({length:count},(_,index)=>`<div class="item" style="--i:${index}">Card ${index+1}</div>`).join('')}</section> +${spacer}<script> +const node=document.querySelector('#reveal'); +if(node.getBoundingClientRect().top>=innerHeight){node.className='pending';const observer=new IntersectionObserver(entries=>entries.forEach(entry=>{if(!entry.isIntersecting)return;node.className='in';observer.disconnect();}));observer.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">${spacer} +<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> +${spacer}<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>`; + +// One document, two routes, like a framework router: the view is swapped, +// history gets an entry, and going back restores the view and the scroll. +const router=mode=>`<!doctype html><body style="margin:0"><nav><a id="away" href="/away">Away</a></nav><div id="view">${spacer} +<section id="reveal" style="height:200px">Readable server section</section>${spacer}</div><script> +const mode=${JSON.stringify(mode)},view=document.querySelector('#view'),home=view.innerHTML; +const play=node=>node.animate([{opacity:0,transform:'translateY(24px)'},{opacity:1,transform:'translateY(0)'}],{duration:300,easing:'linear'}); +const attach=returning=>{const node=document.querySelector('#reveal');let first=true,played=false;new IntersectionObserver(entries=>entries.forEach(entry=> { + const initial=first;first=false;if(!entry.isIntersecting||played)return;played=true; + // Content already on screen stays as rendered, unless this page replays it after navigation. + if(initial&&!(returning&&mode==='navflash'))return; + play(node); +})).observe(node);}; +attach(false); +document.querySelector('#away').addEventListener('click',event=> { + if(mode==='document')return; + event.preventDefault();history.replaceState({y:scrollY},'');history.pushState({},'','/away');view.innerHTML='<h1>Away</h1>';scrollTo(0,0); +}); +addEventListener('popstate',event=>{view.innerHTML=home;scrollTo(0,event.state?.y??0);attach(true);}); +</script></body>`; + +async function withPages(run) { + const server=createServer((request,response)=> { + const url=new URL(request.url,'http://local'),mode=url.searchParams.get('mode'); + if(url.pathname==='/pixel.png'){response.writeHead(200,{'content-type':'image/png','cache-control':'no-store'});response.end(pixel);return;} + if(url.pathname==='/__devup-bfcache-away__'||url.pathname==='/away'){response.writeHead(200,{'content-type':'text/html'});response.end('<!doctype html><title>away');return;} + const kind=url.searchParams.get('kind')??'timed'; + const body=kind==='linked'?linked(mode,url.searchParams.has('tall')):kind==='group'?group(Number(url.searchParams.get('step')),Number(url.searchParams.get('duration')),Number(url.searchParams.get('count'))):kind==='timeline'?timeline(mode):kind==='router'?router(mode):timed(mode,url.searchParams.has('bare')); + response.writeHead(200,{'content-type':'text/html','cache-control':'no-cache'});response.end(body); + }); + await new Promise(done=>server.listen(0,'127.0.0.1',done)); + const browser=await chromium.launch(); + try { + const check=async(query,options={},viewport=VIEWPORTS[0],context={})=> { + const tab=await browser.newPage({viewport}); + await tab.goto(`http://127.0.0.1:${server.address().port}/?${query}`); + const report=await runMotionCheck(tab,{kind:'reveal',candidateId:`reveal:${query}`,target:'#reveal',durationMs:300,settleMs:150,...options},context); + await tab.close();return report; + }; + await run(check); + } finally {await browser.close();await new Promise(done=>server.close(done));} +} +const statuses=report=>Object.fromEntries(Object.entries(report.measured.scenarios).map(([name,result])=>[name,result.status])); +const without=(...names)=>REVEAL_SCENARIOS.filter(name=>!names.includes(name)); + +test('a one-shot reveal passes at mobile and desktop; what could not be checked is unverified, never passed', {timeout:600000}, async()=> { + await withPages(async check=> { + for(const viewport of VIEWPORTS) { + // Every scenario by default. This page has no client router to name, so that one scenario is unverified and the check is not a pass. + const all=await check('mode=good',{},viewport); + assert.equal(all.status,'inconclusive',JSON.stringify(all)); + assert.deepEqual(all.measured.unverified,['client-nav']);assert.deepEqual(all.measured.notApplicable,['stagger','progress-sync']);assert.deepEqual(all.measured.scenariosNotRun,[]); + assert.deepEqual(statuses(all),{'fast-scroll':'pass',reload:'pass',history:'pass',bfcache:'pass','client-nav':'unverified','image-delay':'pass','image-failure':'pass',focus:'pass','reduced-motion':'pass','no-script':'pass',stagger:'not-applicable','progress-sync':'not-applicable'}); + assert.equal(all.measured.scenarios.bfcache.restoredFromBfcache,true); + assert.equal(all.measured.mode,'timed');assert.equal(all.measured.trigger.verified,true);assert.equal(all.measured.trigger.startedOffscreen,false);assert.ok(all.measured.trigger.offscreenPx<=1); + assert.deepEqual(all.measured.reentry,{verified:true,replayed:false,hiddenWhileAway:false}); + // Start, middle and end are measured values, not a final screenshot. + const {start,middle,end}=all.measured.frames; + assert.ok(start.opacity<0.5,JSON.stringify(start));assert.ok(middle.opacity>0&&middle.opacity<1&&middle.translate>0,JSON.stringify(middle));assert.equal(end.opacity,1);assert.equal(end.translate,0); + // Scoped to what this page has: a pass, with the rest named as not run. + const scoped=await check('mode=good',{scenarios:without('client-nav','stagger','progress-sync')},viewport); + assert.equal(scoped.status,'pass',JSON.stringify(scoped));assert.deepEqual(scoped.measured.scenariosNotRun,['client-nav','stagger','progress-sync']);assert.deepEqual(scoped.measured.unverified,[]); + } + // Nothing requested: every scenario is named as not run, never implied. + const none=await check('mode=good',{scenarios:[]}); + assert.equal(none.status,'pass',JSON.stringify(none));assert.deepEqual(none.measured.scenariosNotRun,REVEAL_SCENARIOS); + // A block with no image and no control has nothing for those scenarios to observe: not-applicable by default, a failure when the caller claimed them. + const bare=await check('mode=good&bare',{scenarios:['image-failure','focus']}); + 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); + const bareDefault=await check('mode=good&bare',{scenarios:undefined}); + assert.deepEqual(bareDefault.measured.notApplicable,['image-delay','image-failure','focus','stagger','progress-sync'],JSON.stringify(statuses(bareDefault))); + // Already on screen at load: the trigger was never observed, so this is not a pass either. + const visible=await check('mode=good',{target:'#after',scenarios:[]}); + assert.notEqual(visible.status,'pass',JSON.stringify(visible)); + }); +}); + +test('trigger position, replay, re-hide and an unobservable re-entry fail', {timeout:300000}, async()=> { + await withPages(async check=> { + const early=await check('mode=early',{scenarios:[]}); + 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 check('mode=late',{scenarios:[]}); + assert.equal(late.status,'fail',JSON.stringify(late));assert.equal(late.measured.trigger.late,true); + const replay=await check('mode=replay',{scenarios:[]}); + assert.equal(replay.status,'fail',JSON.stringify(replay));assert.equal(replay.measured.reentry.replayed,true); + const rehide=await check('mode=rehide',{scenarios:[]}); + assert.equal(rehide.status,'fail',JSON.stringify(rehide));assert.equal(rehide.measured.reentry.hiddenWhileAway,true); + // The painted section was named instead of the wrapper that moves. + const wrong=await check('kind=linked&mode=css-good',{target:'#band',scenarios:[]}); + assert.equal(wrong.status,'fail',JSON.stringify(wrong));assert.match(wrong.measured.hint,/inner wrapper/); + }); + // A page too short to scroll the target away proves nothing about re-entry. + const browser=await chromium.launch(); + try { + const page=await browser.newPage({viewport:VIEWPORTS[0]}); + await page.setContent('
Readable server section
'); + const short=await runMotionCheck(page,{kind:'reveal',candidateId:'reveal:short',target:'#reveal',durationMs:300,scenarios:[]}); + assert.equal(short.status,'fail',JSON.stringify(short));assert.equal(short.measured.reentry?.verified,false); + } finally {await browser.close();} +}); + +test('each fresh-load scenario fails the page that breaks its rule', {timeout:600000}, async()=> { + await withPages(async check=> { + for(const [query,scenario] of [ + ['mode=flash','reload'],['mode=flash','history'],['mode=stuck','fast-scroll'],['mode=focusanim','focus'],['mode=image-gated','image-failure'],['mode=image-gated','image-delay'], + // The preference is ignored; content is hidden in server markup until a script shows it; the page replays its entrance when thawed from bfcache. + ['mode=ignores-preference','reduced-motion'],['mode=markup-hidden','no-script'],['mode=pageshow-replay','bfcache'], + ]) { + const report=await check(query,{scenarios:[scenario]}); + assert.equal(report.status,'fail',`${query}/${scenario}: ${JSON.stringify(report)}`); + assert.equal(report.measured.scenarios[scenario].status,'fail',`${query}/${scenario}: ${JSON.stringify(report.measured.scenarios)}`); + } + const flash=await check('mode=flash',{scenarios:['reload']});assert.equal(flash.measured.scenarios.reload.flashed,true); + const thawed=await check('mode=pageshow-replay',{scenarios:['bfcache']}); + assert.equal(thawed.measured.scenarios.bfcache.restoredFromBfcache,true);assert.equal(thawed.measured.scenarios.bfcache.flashed,true); + // No browser with a back/forward cache to run it in: not checked, so unverified by default and a failure when claimed. + const launchRestorable=()=>{throw new Error('full Chromium is not installed');}; + const unavailable=await check('mode=good',{scenarios:undefined},VIEWPORTS[0],{launchRestorable}); + assert.equal(unavailable.measured.scenarios.bfcache.status,'unverified',JSON.stringify(unavailable.measured.scenarios.bfcache));assert.ok(unavailable.measured.unverified.includes('bfcache'));assert.equal(unavailable.status,'inconclusive'); + assert.equal((await check('mode=good',{scenarios:['bfcache']},VIEWPORTS[0],{launchRestorable})).status,'fail'); + }); +}); + +test('client navigation away and back must not replay content that is on screen', {timeout:300000}, async()=> { + await withPages(async check=> { + for(const viewport of VIEWPORTS) { + const good=await check('kind=router&mode=good',{navigate:'#away',scenarios:['client-nav']},viewport); + assert.equal(good.status,'pass',JSON.stringify(good));assert.equal(good.measured.scenarios['client-nav'].sameDocument,true);assert.equal(good.measured.scenarios['client-nav'].flashed,false); + const flash=await check('kind=router&mode=navflash',{navigate:'#away',scenarios:['client-nav']},viewport); + assert.equal(flash.status,'fail',JSON.stringify(flash));assert.equal(flash.measured.scenarios['client-nav'].flashed,true); + } + // A link that loads a new document is not client navigation; no link at all leaves it unverified. + const documents=await check('kind=router&mode=document',{navigate:'#away',scenarios:undefined}); + assert.equal(documents.measured.scenarios['client-nav'].status,'not-applicable',JSON.stringify(documents.measured.scenarios['client-nav'])); + const unnamed=await check('kind=router&mode=good',{scenarios:['client-nav']}); + assert.equal(unnamed.status,'fail');assert.equal(unnamed.measured.scenarios['client-nav'].verified,false); + }); +}); + +test('a view-timeline reveal is measured by position: entry range, focus and reduced motion', {timeout:600000}, async()=> { + await withPages(async check=> { + const scenarios=without('client-nav','stagger','progress-sync'); + for(const viewport of VIEWPORTS) { + const good=await check('kind=linked&mode=css-good',{scenarios},viewport); + assert.equal(good.status,'pass',JSON.stringify(good));assert.equal(good.measured.mode,'scroll-linked'); + const {start,middle,end}=good.measured.frames; + assert.equal(start.opacity,0);assert.equal(start.visibleRatio,0);assert.ok(middle.opacity>0&&middle.opacity<1,JSON.stringify(middle));assert.equal(end.opacity,1);assert.equal(end.translate,0); + assert.deepEqual(good.measured.reentry,{verified:true,replayed:false,hiddenWhileAway:false}); + // No range: the fade spans the whole pass, so the block is still part-transparent when fully on screen. + const cover=await check('kind=linked&mode=css-cover',{scenarios:[]},viewport); + assert.equal(cover.status,'fail',JSON.stringify(cover));assert.equal(cover.measured.trigger.late,true); + } + // A section taller than the screen: with a plain `entry 100%` it is still half faded with half a screen of it showing. + const tall=await check('kind=linked&mode=css-good&tall',{scenarios:['fast-scroll','focus','reduced-motion','no-script']}); + assert.equal(tall.status,'pass',JSON.stringify(tall));assert.ok(tall.measured.trigger.completeVisiblePx<=260,JSON.stringify(tall.measured.trigger)); + const faded=await check('kind=linked&mode=css-entry&tall',{scenarios:[]}); + assert.equal(faded.status,'fail',JSON.stringify(faded));assert.equal(faded.measured.trigger.late,true); + // Focus is scrolled to the middle of the screen. On a short (landscape) screen that is inside the entry, so the block must be shown at once. + const landscape={width:844,height:390}; + assert.equal((await check('kind=linked&mode=css-good&tall',{scenarios:['focus']},landscape)).status,'pass'); + const nofocus=await check('kind=linked&mode=css-nofocus&tall',{scenarios:['focus']},landscape); + assert.equal(nofocus.status,'fail',JSON.stringify(nofocus));assert.ok(nofocus.measured.scenarios.focus.focusedControl.opacity<1,JSON.stringify(nofocus.measured.scenarios.focus)); + const noreduce=await check('kind=linked&mode=css-noreduce',{scenarios:['reduced-motion']}); + assert.equal(noreduce.status,'fail',JSON.stringify(noreduce));assert.equal(noreduce.measured.scenarios['reduced-motion'].flashed,true); + }); +}); + +test('a card group stagger and a progress line are bounded and synchronised', {timeout:600000}, async()=> { + await withPages(async check=> { + for(const viewport of VIEWPORTS) { + const short=(await check('kind=group&step=60&duration=400&count=5',{items:'.item',durationMs:400,scenarios:['stagger']},viewport)); + assert.equal(short.status,'pass',JSON.stringify(short)); + const measured=short.measured.scenarios.stagger; + assert.equal(measured.items,5);assert.equal(measured.ordered,true);assert.ok(measured.spanMs>=120&&measured.spanMs<=450,JSON.stringify(measured));assert.ok(measured.totalMs>=450&&measured.totalMs<=1000,JSON.stringify(measured)); + // 250ms between cards: each step and the whole span are out of bounds. + const slow=(await check('kind=group&step=250&duration=400&count=5',{items:'.item',durationMs:400,scenarios:['stagger']},viewport)).measured.scenarios.stagger; + assert.equal(slow.status,'fail',JSON.stringify(slow));assert.ok(slow.stepsMs.every(step=>step>150),JSON.stringify(slow));assert.ok(slow.spanMs>450); + // A short step but a long item: the group as a whole takes too long. + const long=(await check('kind=group&step=60&duration=1600&count=5',{items:'.item',durationMs:400,scenarios:['stagger']},viewport)).measured.scenarios.stagger; + assert.equal(long.status,'fail',JSON.stringify(long));assert.ok(long.totalMs>1000,JSON.stringify(long));assert.ok(long.stepsMs.every(step=>step<=150)); + } + // The caller's own bounds apply. + const allowed=await check('kind=group&step=250&duration=400&count=3',{items:'.item',durationMs:400,scenarios:['stagger'],maxStepMs:300,maxSpanMs:600,maxTotalMs:1200}); + assert.equal(allowed.status,'pass',JSON.stringify(allowed)); + // No items named: nothing to time. + const unnamed=await check('kind=group&step=60&duration=400&count=5',{scenarios:['stagger']}); + assert.equal(unnamed.measured.scenarios.stagger.verified,false); + // 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',scenarios:['progress-sync']}; + for(const viewport of VIEWPORTS) { + const synced=(await check('kind=timeline&mode=good',selectors,viewport)).measured.scenarios['progress-sync']; + assert.equal(synced.status,'pass',JSON.stringify(synced));assert.equal(synced.axis,'vertical'); + const unsynced=(await check('kind=timeline&mode=bad',selectors,viewport)).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 check('kind=timeline&mode=good',{scenarios:['progress-sync']}),await check('mode=good',{scenarios:['progress-sync'],items:'a',progress:'img'})]) { + assert.equal(report.status,'fail',JSON.stringify(report));assert.equal(report.measured.scenarios['progress-sync'].verified,false); + } + }); +}); diff --git a/reports/chondrogen-motion-followups.md b/reports/chondrogen-motion-followups.md index 365b2dc3..f1a4d2a3 100644 --- a/reports/chondrogen-motion-followups.md +++ b/reports/chondrogen-motion-followups.md @@ -12,7 +12,9 @@ When a page contains a supported motion candidate, its export includes: Component that mounts and cleans up the page's shared DOM controllers. - `motionRuntime` / resource `motion-runtime.ts`: seamless left marquee, once-on-entry integer/decimal CountUp, progress-complete/advance/wrap with manual - reset, persistent route-tab indicator, and shared section entrance observation. + reset, persistent route-tab indicator, and the state marker for section + entrances. The entrance itself is static CSS since `keyframes-motion.md`: + `revealTsx` / resource `Reveal.tsx`, Devup UI keyframes on a view timeline. - `motionBindings` / resource `motion-bindings.json`: candidate IDs, captured values/precision/units/prefixes and required semantic selector bindings. @@ -33,7 +35,8 @@ is the default; an explicit continuous-playback requirement can set `respectReducedMotion: false`. CountUp reserves the final responsive text width and exposes the final accessible value. Marquee repeats are inert and hidden from assistive technology. Reveals preserve visible server/no-JS content, share -one section observer, support inserted route content, and cancel on focus. +one section observer, support inserted route content, and show a focused block +at once. See `keyframes-motion.md` for the keyframes form and its checks. ## Actual motion checks diff --git a/reports/keyframes-motion.md b/reports/keyframes-motion.md new file mode 100644 index 00000000..c005263a --- /dev/null +++ b/reports/keyframes-motion.md @@ -0,0 +1,206 @@ +# Devup UI keyframes motion, runtime motion checks and export budget + +Follow-up to `chondrogen-motion-followups.md` and PR #124. It closes the items +that PR left as limitations and moves the recommended and generated motion +from a script-driven entrance to Devup UI's static `keyframes()`. + +## 1. Motion is Devup UI static keyframes + +### The API, confirmed by compiling it + +Compiled with the versions the harness installs (`@devup-ui/react` 1.0.41, +`@devup-ui/vite-plugin` 1.0.73, stylesheet header `devup-ui v1.0.83`) and read +back from the emitted CSS: + +| Source | Emitted | +|--------|---------| +| `const rise = keyframes({ from: {…}, to: {…} })` | `@keyframes a-a{from{…}to{…}}`; the const is the name | +| `animationName={rise}`, `animation={`${rise} 400ms ease-out both`}` | `animation-name:a-a`, `animation:a-a 400ms ease-out both` | +| `animationTimeline="view()"`, `animationRange="entry 0% entry min(100%, 240px)"` | `animation-timeline:view()`, `animation-range:entry 0 entry min(100%,240px)` | +| `animationDelay={`calc(${index} * 60ms)`}` | a class with `animation-delay:var(--a)` and the value in `style` | +| `_motionReduce={{ animation: 'none' }}` | `@media(prefers-reduced-motion:reduce){…{animation:none}}` | +| `_focusWithin={{ animation: 'none' }}` | `…:focus-within{animation:none}` | +| `selectors={{ '&[data-reveal-state="in"]': {…} }}`, `'[data-reveal-state="in"] &'` | `.x[data-reveal-state="in"]{…}`, `[data-reveal-state="in"] .x{…}` | + +A wrapper component does not forward Devup UI style props: they are extracted +where they are written. `Reveal` therefore takes plain element props and +layout goes on a `Box` inside it. + +### What ships + +- `templates/Reveal.tsx` (new export output `revealTsx`, resource + `Reveal.tsx`, delivered with every section-entrance candidate): `Reveal` + (scroll-linked section), `RevealGroup` + `RevealItem` (staggered group on a + clock), `RevealLine` + `RevealStep` (progress line and steps timed from one + variable). Server code, no `'use client'`, no animation library. +- `templates/motion-runtime.ts`: the section-entrance path no longer animates + anything. It sets `data-reveal-state` once, only for `data-reveal="timed"` + groups and for browsers without view timelines, only on blocks still below + the viewport when it attaches, never under reduced motion, and removes its + marks on unmount. Focus inside a marked block shows it at once. +- `behaviorPlan` section-entrance candidates carry + `pattern: "devup-ui-static-keyframes-reveal"` and the keyframes + implementation; `motionBindings.entranceStyle` is + `devup-ui-static-keyframes`. +- `animation.md` is rewritten keyframes-first (why: no bundle, no hydration + flash, compositor-only properties), with framer-motion limited to a project + that already depends on it and a user who asks, or to exit and gesture + motion. `delivery-quality.md`, `SKILL.md`, `common-patterns.md` and + `critical-rules.md` follow. + +### A range finding worth keeping + +`animation-range: entry 0% entry 100%` is right for a block shorter than the +screen and wrong for a taller one: measured at 390x844, a 1200px section is +28% opaque with 240px of it showing and reaches 100% only when its top reaches +the top of the screen. `entry 0% entry min(100%, 240px)` completes after 240px +or when the block is fully in view, whichever comes first. That is the shipped +range, and the audit fails a scroll-linked block that is not complete with +half a screen of it showing. + +### Static validation reads both styles + +`motion_quality.rs` rules now read Devup UI keyframes (JSX props and +`css()`/`styled` objects, `keyframes()` constants and inline calls) as well as +framer-motion, in a new module `motion_keyframes.rs`: + +| Rule | Keyframes form that is reported | Stays silent | +|------|--------------------------------|--------------| +| `ssr-hidden-reveal` | entrance on an element that itself rests at `opacity: 0`/`visibility: hidden`; entrance paused on its hidden first frame; hidden until a selector on a script-set attribute/class shows it | hidden start only in the keyframes; hidden only under a script-set `pending` marker; hover overlays; client-mounted elements | +| `reveal-replays` | `animationTimeline="view()"` with no range ending inside the entry (`cover`, `exit`, none, above `cover 50%`); an `IntersectionObserver` callback that writes `isIntersecting` into the element | `entry …` ranges; observers that `unobserve`/`disconnect`; `scroll()` progress bars; callbacks declared elsewhere | +| `reveal-moves-background` | the element carrying the entrance paints a background (props or `css()` class, either way round) | paint outside and motion inside; looping loaders; keyframes from another module | +| `stagger-too-long` | `animationDelay`/`transitionDelay` with a per-item step above 150ms (`calc(var(--i) * step)`, template `index * step`) | steps up to 150ms; one fixed delay | +| `motion-without-reduced-motion` | a keyframes entrance with no `_motionReduce`/`_motionSafe`/media rule in the validated sources | loaders, hover transitions | + +New rules: `entrance-uses-motion-library` (info, once per file), +`entrance-on-static-content` (nav, header, footer, form, fields, body copy) +and `entrance-per-element` (info: a heading or control animated on its own). +The last two turn the role table of the guide into checks. + +## 2. Export reply budget + +`export_context_summarizes_a_large_project_under_fifteen_kib`: 15,255 bytes +before (105 of headroom after #124), 14,086 after: 1,274 bytes of headroom, +with a test that fails below 1,024. + +Nothing was dropped. Standing text that every reply repeated now lives in the +guide the reply already points at, and a test asserts each moved sentence is +there: + +| Moved to the guide | Bytes | +|--------------------|-------| +| `behaviorPlan.intentPolicy`, `clientBoundary`, `sourcePurity`, `evidenceLimits.contextIndex` | ~540 | +| `developmentContract.nextAction`, `installedReference` | ~230 | +| `verificationScope` explanations (now field names plus a guide pointer) | ~300 | +| `server.updateAvailable.autoUpdate.installedPathNote` | ~310 | + +`developmentContract` (version 4) keeps every identifier inline and gains +four. The two that #124 renamed are back under their own names, next to the +new ones: `motion-candidates-and-safe-progressive-enhancement`, +`default-entrance-and-transition-motion`, `devup-ui-static-keyframes-motion`, +`one-shot-reveals-without-ssr-flash` (20 requirements) and +`computed-style-and-animation-intermediate-state`, +`animation-start-middle-end-frames`, +`motion-trigger-reentry-history-and-image-failure`, +`stagger-total-and-progress-sync-timing` (8 evidence items). +`delivery-quality.md` has a "Contract identifiers" section explaining all 28, +checked by `every_contract_identifier_is_explained_in_the_guide`. + +## 3. Runtime motion checks + +`harness/runtime-audit/reveal-checks.mjs` (new; `motion-checks.mjs` delegates +`kind: "reveal"` to it). It measures an entrance whatever produced it and +distinguishes a scroll-linked one (a view timeline: state follows position) +from a timed one. + +Always measured: start, middle and end frames (opacity/transform values and a +screenshot each), trigger position, re-entry. Then twelve scenarios, each from +a fresh load, all of them by default: + +| Scenario | Passing page | Failing page | +|----------|--------------|--------------| +| trigger position | starts within 1px of the viewport | observer with a 600px margin (early); 900ms timer (late) | +| scroll-linked completeness | `entry 0% entry min(100%, 240px)` | no range (`cover`); plain `entry 100%` on a 1200px block | +| re-entry | one-shot | replays; re-hides while away; page too short to leave | +| `fast-scroll` | shown after a fling | stays at opacity 0 | +| `reload`, `history` | shown at the restored position | replays when already on screen | +| `bfcache` | shown after a real restore | replays on `pageshow` with `persisted` | +| `client-nav` | same-document route change and back | replays on return | +| `image-delay`, `image-failure` | reveals at the reserved height | reveal gated on the image `load` event | +| `focus` | focused control painted, on screen, Tab leaves | animates a focused block; no `:focus-within` rule on a landscape screen | +| `reduced-motion` | in place while entering | ignores the preference; no media rule | +| `no-script` | present and painted | `opacity: 0` in markup until a script runs | +| `stagger` | 60ms steps, 5 items | 250ms steps; 1600ms items | +| `progress-sync` | each step within 200ms of the line | all steps at once | + +`bfcache` uses a real restore: Playwright starts Chromium with the cache off, +so the scenario starts full Chromium with `--disable-back-forward-cache` +removed and requires `pageshow` to report `persisted`. + +Results are `pass`, `fail`, `not-applicable` (nothing in the block to observe) +or `unverified` (could not be observed; the check is then `inconclusive`). An +explicit `scenarios` list makes an unobservable scenario a failure and names +the rest in `measured.scenariosNotRun`. + +`devup_runtime_audit` ingest returns `motionEvidence`: a row per motion check +per width and a line per candidate, `verified` only with a passing check at a +mobile (<=767px) and a desktop (>=1024px) width and nothing left unverified. +Frame screenshots are accepted only when their bytes match the report. + +The runner no longer treats a view-timeline animation as a running clock: the +settle step skipped waiting 15 seconds for it, and the freeze step cancels it +(the resting style is the content) instead of pausing it at its hidden first +frame, which would have blanked every below-fold section in captures. + +## 4. Monorepo single-`tsx` validation + +`ui_validate_owner.rs` resolves the owning app's `devup.json` for one `tsx`: + +1. `sourceName` as a path inside `projectRoot`, relative or absolute (short + 8.3 names and symlinks are resolved); the file need not exist. +2. `sourceName` as the tail of a path (`src/Hero.tsx`, `Hero.tsx`): project + files ending with it, when one theme governs them all. +3. No usable name: project files whose text is this `tsx` (line ends and + surrounding blank space ignored), when one theme governs them all. + +`tokens.owner` says which rule decided (`sourceName`, `sourceName-suffix`, +`content-match`). Unverified is reported only when no owner resolves: no +match, a file no theme governs, or matches under different themes, and then +`themeGuardrail.ownerCandidates` names what was found. The search is bounded +(20,000 directory entries, 256 files opened, depth 12) and never leaves +`projectRoot`. + +## Unverified is never a pass + +- `devup_runtime_audit`: the verdict is `pass` only when every planned item + passed. A reveal check with an unobserved scenario is `inconclusive`, which + is counted under `summary.inconclusive`, never under `summary.pass`, and + makes the verdict `inconclusive`. `motionEvidence` counts a candidate as + `verified` only with passing mobile and desktop checks and an empty + `unverified` list; completion applies the same rule + (`a_reveal_candidate_needs_passing_checks_at_mobile_and_desktop`). +- `devup_ui_validate`: token references that were not checked make + `okReason` `clean-unverified-tokens` instead of `clean`, with + `themeAvailable` and `tokens.unknownTokenCheckRan` false; a partly checked + run is `tokens.verification: "partial"`. + +## What stays with a person + +All machine-checkable limitations listed in #124 are resolved above. Whether +an eligible content block should get motion at all is a user design choice +(user-specified design wins); the tool proposes, it does not judge. + +## Regression evidence + +- Rust: 24 new tests (2,286 -> 2,310 passing). `motion_keyframes_tests.rs` (9, one per rule in + keyframes form with true negatives), `motion_keyframes.rs` (2), contract and + guide (1 new, 1 extended), runtime audit plan validation (extended) and + `motion_evidence.rs` (3), owner resolution unit (2) and MCP integration (2: + nine resolved cases across `apps/a` and `apps/b`, five unresolved ones), + behavior plan keyframes output (extended), export budget and width policy + (extended). +- Browser (Chromium 153): `keyframes-motion.test.mjs` compiles the shipped + templates with Devup UI, prerenders the markup from the same bundle and + runs every check at 390x844 and 1440x900 (scroll-linked section, the same + section through the marker fallback, a card group, a timeline); + `reveal.test.mjs` holds the pages above, six tests.