diff --git a/corpus/frontend/motion/animatepresence.yaml b/corpus/frontend/motion/animatepresence.yaml new file mode 100644 index 0000000..fe02a36 --- /dev/null +++ b/corpus/frontend/motion/animatepresence.yaml @@ -0,0 +1,90 @@ +name: AnimatePresence +kind: component +description: >- + Enables exit animations when children are removed from the React tree. Direct + children must have unique key props. +importPath: 'import { AnimatePresence } from "motion/react"' +props: + - name: initial + type: boolean + description: Set false to disable initial animations on first render. + default: "true" + - name: mode + type: "'sync' | 'wait' | 'popLayout'" + description: "sync: simultaneous enter/exit. wait: exit completes before enter. popLayout: pop exiting elements out of layout flow." + default: "'sync'" + - name: custom + type: any + description: Data passed to exiting components via usePresenceData(). + - name: onExitComplete + type: () => void + description: Fires when all exit animations finish. + - name: propagate + type: boolean + description: If true, nested AnimatePresence children fire exit animations when parent exits. + - name: root + type: ShadowRoot | HTMLElement + description: Root element for popLayout styles. Defaults to document.head. Set to a ShadowRoot for shadow DOM usage. +usage: | + + {show && ( + + )} + +examples: + - title: Modal with exit animation + category: exit + code: | + function Modal({ isOpen, onClose }) { + return ( + + {isOpen && ( + + e.stopPropagation()} + > + Modal content + + + )} + + ); + } + - title: Page transitions with wait mode + category: exit + code: | + + + {children} + + +tips: + - "AnimatePresence must wrap the conditional - it goes OUTSIDE the {show && ...}." + - Each direct child needs a unique key prop. + - mode='wait' is useful for page transitions where old page exits before new enters. + - "mode='popLayout' pops exiting elements out of document flow immediately. Custom component children must use forwardRef (React 18) or accept ref prop (React 19)." +relatedApis: + - motion + - usePresence + - useIsPresent + - usePresenceData diff --git a/corpus/frontend/motion/index.yaml b/corpus/frontend/motion/index.yaml index ff0d4b8..0bc019a 100644 --- a/corpus/frontend/motion/index.yaml +++ b/corpus/frontend/motion/index.yaml @@ -2,6 +2,12 @@ namespace: frontend.motion apis: motion: file: motion.yaml + animatepresence: + file: animatepresence.yaml + usescroll: + file: usescroll.yaml + usespring: + file: usespring.yaml examples: scroll: file: examples/scroll.yaml diff --git a/corpus/frontend/motion/usescroll.yaml b/corpus/frontend/motion/usescroll.yaml new file mode 100644 index 0000000..a90b612 --- /dev/null +++ b/corpus/frontend/motion/usescroll.yaml @@ -0,0 +1,106 @@ +name: useScroll +kind: hook +description: >- + Creates scroll-linked motion values. Tracks window or element scroll + position. Supports hardware-accelerated ScrollTimeline. +importPath: 'import { useScroll } from "motion/react"' +returns: "{ scrollX, scrollY, scrollXProgress, scrollYProgress }" +props: + - name: container + type: RefObject + description: "Scrollable element ref. Default: window." + - name: target + type: RefObject + description: Element to track progress through container. + - name: axis + type: "'x' | 'y'" + description: Scroll axis. + default: "'y'" + - name: offset + type: "[string, string]" + description: "Start/end intersection points. Format: 'targetPoint containerPoint'. Points: start, center, end, 0-1, px, %, vh, vw." + default: '["start start", "end end"]' + - name: trackContentSize + type: boolean + description: Track content size changes. + default: "false" +usage: | + // Window scroll + const { scrollYProgress } = useScroll(); + + // Element scroll + const ref = useRef(null); + const { scrollYProgress } = useScroll({ container: ref }); + + // Track element through viewport + const ref = useRef(null); + const { scrollYProgress } = useScroll({ + target: ref, + offset: ["start end", "end start"] + }); +examples: + - title: Scroll progress bar + category: scroll + code: | + function ProgressBar() { + const { scrollYProgress } = useScroll(); + + return ( + + ); + } + - title: Element reveal on scroll + category: scroll + code: | + function RevealSection() { + const ref = useRef(null); + const { scrollYProgress } = useScroll({ + target: ref, + offset: ["start end", "center center"] + }); + const opacity = useTransform(scrollYProgress, [0, 1], [0, 1]); + const y = useTransform(scrollYProgress, [0, 1], [100, 0]); + + return ( + + Content revealed on scroll + + ); + } + - title: Horizontal scroll section + category: scroll + code: | + function HorizontalScroll() { + const containerRef = useRef(null); + const { scrollYProgress } = useScroll({ target: containerRef }); + const x = useTransform(scrollYProgress, [0, 1], ["0%", "-75%"]); + + return ( +
+
+ + {panels.map(panel => )} + +
+
+ ); + } +tips: + - "Offset format: ['start end', 'end start'] means animation starts when target's start hits viewport end, ends when target's end hits viewport start." + - Use useTransform to map scrollYProgress to transform/opacity/filter for GPU-accelerated animations. + - Combine with useSpring for smoothed scroll animations. +relatedApis: + - useTransform + - useSpring + - useMotionValue diff --git a/corpus/frontend/motion/usespring.yaml b/corpus/frontend/motion/usespring.yaml new file mode 100644 index 0000000..eb8e2ad --- /dev/null +++ b/corpus/frontend/motion/usespring.yaml @@ -0,0 +1,79 @@ +name: useSpring +kind: hook +description: >- + Creates a motion value that animates to targets with spring physics. Can + track another motion value. +importPath: 'import { useSpring } from "motion/react"' +returns: MotionValue +props: + - name: stiffness + type: number + description: Spring stiffness. + default: "1" + - name: damping + type: number + description: Spring damping. + default: "10" + - name: mass + type: number + description: Mass of the moving object. + default: "1" + - name: bounce + type: number + description: Bounciness (0-1). + default: "0.25" + - name: duration + type: number + description: Duration in seconds (spring will be configured to match). + - name: visualDuration + type: number + description: Perceived duration (spring settles to 1/10 of movement). + - name: skipInitialAnimation + type: boolean + description: Skip animation on initial render. + default: "false" +usage: | + // Direct control + const x = useSpring(0); + x.set(100); // springs to 100 + + // Track another value + const scrollY = useMotionValue(0); + const smoothY = useSpring(scrollY, { stiffness: 100, damping: 30 }); +examples: + - title: Smooth scroll tracking + category: scroll + code: | + function SmoothScroll() { + const { scrollYProgress } = useScroll(); + const smoothProgress = useSpring(scrollYProgress, { + stiffness: 100, + damping: 30, + restDelta: 0.001 + }); + + return ; + } + - title: Mouse follower + category: animation + code: | + function Cursor() { + const x = useMotionValue(0); + const y = useMotionValue(0); + const springX = useSpring(x, { stiffness: 300, damping: 25 }); + const springY = useSpring(y, { stiffness: 300, damping: 25 }); + + useEffect(() => { + const handler = (e: MouseEvent) => { + x.set(e.clientX); + y.set(e.clientY); + }; + window.addEventListener("mousemove", handler); + return () => window.removeEventListener("mousemove", handler); + }, []); + + return ; + } +relatedApis: + - useMotionValue + - useTransform diff --git a/src/plugins/motion/tools/get-api.ts b/src/plugins/motion/tools/get-api.ts index 82f0d7f..3999369 100644 --- a/src/plugins/motion/tools/get-api.ts +++ b/src/plugins/motion/tools/get-api.ts @@ -16,11 +16,7 @@ type CorpusIndex = { type CorpusNamespaceIndex = { namespace?: string; - apis?: { - motion?: { - file?: string; - }; - }; + apis?: Record; }; type CorpusApiEntry = { @@ -34,18 +30,24 @@ type CorpusApiEntry = { examples?: Array<{ title: string; code: string; description?: string; category: string }>; tips?: string[]; relatedApis?: string[]; - source?: string; }; +type LoadedCorpusApi = { api: CorpusApiEntry; source: string }; + const moduleDir = dirname(fileURLToPath(import.meta.url)); const corpusRoot = join(moduleDir, "../../../../corpus"); const corpusNamespace = "frontend.motion"; -let cachedCorpusMotionApi: { api: CorpusApiEntry; source: string } | null | undefined; +const cachedCorpusApis = new Map(); -function loadCorpusMotionApi(): { api: CorpusApiEntry; source: string } | null { - if (cachedCorpusMotionApi !== undefined) { - return cachedCorpusMotionApi; +function normalizeApiName(name: string): string { + return name.toLowerCase().replace(/[<>/()]/g, "").trim(); +} + +function loadCorpusApi(name: string): LoadedCorpusApi | null { + const key = normalizeApiName(name); + if (cachedCorpusApis.has(key)) { + return cachedCorpusApis.get(key) ?? null; } try { @@ -53,46 +55,41 @@ function loadCorpusMotionApi(): { api: CorpusApiEntry; source: string } | null { const index = YAML.parse(indexRaw) as CorpusIndex | null; const namespaceIndexPath = index?.namespaces?.[corpusNamespace]?.index; if (!namespaceIndexPath) { - cachedCorpusMotionApi = null; - return cachedCorpusMotionApi; + cachedCorpusApis.set(key, null); + return null; } const namespaceRaw = readFileSync(join(corpusRoot, namespaceIndexPath), "utf8"); const namespaceIndex = YAML.parse(namespaceRaw) as CorpusNamespaceIndex | null; - const apiPath = namespaceIndex?.apis?.motion?.file; + const apiPath = namespaceIndex?.apis?.[key]?.file; if (!apiPath) { - cachedCorpusMotionApi = null; - return cachedCorpusMotionApi; + cachedCorpusApis.set(key, null); + return null; } const apiRaw = readFileSync(join(corpusRoot, "frontend/motion", apiPath), "utf8"); const api = YAML.parse(apiRaw) as CorpusApiEntry | null; - if (!api || api.name?.toLowerCase() !== "motion" || !api.usage || !api.importPath || !api.description || !api.kind) { - cachedCorpusMotionApi = null; - return cachedCorpusMotionApi; + if ( + !api || + normalizeApiName(api.name ?? "") !== key || + !api.usage || + !api.importPath || + !api.description || + !api.kind + ) { + cachedCorpusApis.set(key, null); + return null; } - cachedCorpusMotionApi = { api, source: corpusNamespace }; - return cachedCorpusMotionApi; + const loaded: LoadedCorpusApi = { api, source: corpusNamespace }; + cachedCorpusApis.set(key, loaded); + return loaded; } catch { - cachedCorpusMotionApi = null; - return cachedCorpusMotionApi; + cachedCorpusApis.set(key, null); + return null; } } -function getMotionApi(name: string) { - if (name.toLowerCase() !== "motion") { - return getApiByName(name); - } - - const corpusEntry = loadCorpusMotionApi(); - if (corpusEntry) { - return corpusEntry.api as Parameters[0]; - } - - return getApiByName(name); -} - export function register(server: McpServer): void { server.tool( "motion_get_api", @@ -101,8 +98,8 @@ export function register(server: McpServer): void { name: z.string().describe("API name (e.g., 'motion', 'AnimatePresence', 'useAnimate', 'useScroll', 'stagger', 'Reorder.Group')"), }, async ({ name }) => { - const corpusEntry = name.toLowerCase() === "motion" ? loadCorpusMotionApi() : null; - const api = getMotionApi(name); + const corpusEntry = loadCorpusApi(name); + const api = corpusEntry?.api ?? getApiByName(name); if (!api) { const suggestions = searchApis(name).map((r) => r.api.name); return { @@ -110,7 +107,7 @@ export function register(server: McpServer): void { isError: true, }; } - const rendered = formatApiReference(api); + const rendered = formatApiReference(api as Parameters[0]); return { content: [ { diff --git a/tests/motion-corpus-backed-tools-behaviour.test.ts b/tests/motion-corpus-backed-tools-behaviour.test.ts index a48baec..fb9ab04 100644 --- a/tests/motion-corpus-backed-tools-behaviour.test.ts +++ b/tests/motion-corpus-backed-tools-behaviour.test.ts @@ -13,11 +13,41 @@ test("motion_get_api prefers corpus metadata for motion", async () => { expect(text).toContain('import { motion } from "motion/react"'); }); -test("motion_get_api falls back to in-file data for non-corpus motion APIs", async () => { +test("motion_get_api prefers corpus metadata for AnimatePresence", async () => { const result = await motionGetApi.invoke({ name: "AnimatePresence" }); const text = extractTextContent(result); expect(text).toContain("# AnimatePresence"); expect(text).toContain("**Kind:** component"); + expect(text).toContain("**Corpus Source:** frontend.motion"); + expect(text).toContain('import { AnimatePresence } from "motion/react"'); +}); + +test("motion_get_api prefers corpus metadata for useScroll", async () => { + const result = await motionGetApi.invoke({ name: "useScroll" }); + const text = extractTextContent(result); + + expect(text).toContain("# useScroll"); + expect(text).toContain("**Kind:** hook"); + expect(text).toContain("**Corpus Source:** frontend.motion"); + expect(text).toContain('import { useScroll } from "motion/react"'); +}); + +test("motion_get_api prefers corpus metadata for useSpring", async () => { + const result = await motionGetApi.invoke({ name: "useSpring" }); + const text = extractTextContent(result); + + expect(text).toContain("# useSpring"); + expect(text).toContain("**Kind:** hook"); + expect(text).toContain("**Corpus Source:** frontend.motion"); + expect(text).toContain('import { useSpring } from "motion/react"'); +}); + +test("motion_get_api falls back to in-file data for non-corpus motion APIs", async () => { + const result = await motionGetApi.invoke({ name: "useAnimate" }); + const text = extractTextContent(result); + + expect(text).toContain("# useAnimate"); + expect(text).toContain("**Kind:** hook"); expect(text).not.toContain("**Corpus Source:** frontend.motion"); });