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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changepacks/changepack_log_kF8mQ2vX7pLnR4tZc9wYh.json
Original file line number Diff line number Diff line change
@@ -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"
}
431 changes: 431 additions & 0 deletions crates/devup-mcp-devup-ui/src/ui_validate/motion_keyframes.rs

Large diffs are not rendered by default.

239 changes: 239 additions & 0 deletions crates/devup-mcp-devup-ui/src/ui_validate/motion_keyframes_tests.rs

Large diffs are not rendered by default.

205 changes: 181 additions & 24 deletions crates/devup-mcp-devup-ui/src/ui_validate/motion_quality.rs

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion crates/devup-mcp/src/server/behavior_contracts/detect.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
}),
))
}
Original file line number Diff line number Diff line change
Expand Up @@ -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});
Expand Down
10 changes: 6 additions & 4 deletions crates/devup-mcp/src/server/behavior_contracts/paging.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
127 changes: 127 additions & 0 deletions crates/devup-mcp/src/server/behavior_contracts/templates/Reveal.tsx
Original file line number Diff line number Diff line change
@@ -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}
/>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@ export type MotionBinding = {
locale?: string;
durationMs?: number;
pixelsPerSecond?: number;
distancePx?: number;
respectReducedMotion?: boolean;
};

Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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; }
Expand Down
22 changes: 22 additions & 0 deletions crates/devup-mcp/src/server/behavior_contracts/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Loading
Loading