diff --git a/corpus/frontend/design-tokens/index.yaml b/corpus/frontend/design-tokens/index.yaml new file mode 100644 index 0000000..1f41659 --- /dev/null +++ b/corpus/frontend/design-tokens/index.yaml @@ -0,0 +1,18 @@ +namespace: frontend.design-tokens +procedures: + step-1: + file: step-1.yaml + step-2: + file: step-2.yaml + step-3: + file: step-3.yaml + step-4: + file: step-4.yaml + step-5: + file: step-5.yaml + step-6: + file: step-6.yaml + step-7: + file: step-7.yaml + step-8: + file: step-8.yaml diff --git a/corpus/frontend/design-tokens/step-1.yaml b/corpus/frontend/design-tokens/step-1.yaml new file mode 100644 index 0000000..02352d5 --- /dev/null +++ b/corpus/frontend/design-tokens/step-1.yaml @@ -0,0 +1,51 @@ +step: 1 +title: Define color ramp primitives in @theme +description: >- + Create 11-stop OKLCH ramps for brand, neutral, and pop colors. These are + static compile-time values. +code: | + @theme { + --color-brand-50: oklch(0.98 0.01 291); + --color-brand-100: oklch(0.96 0.02 291); + --color-brand-200: oklch(0.90 0.04 291); + --color-brand-300: oklch(0.82 0.07 291); + --color-brand-400: oklch(0.72 0.11 291); + --color-brand-500: oklch(0.62 0.14 291); + --color-brand-600: oklch(0.54 0.14 291); + --color-brand-700: oklch(0.46 0.13 291); + --color-brand-800: oklch(0.38 0.11 291); + --color-brand-900: oklch(0.28 0.08 291); + --color-brand-950: oklch(0.18 0.05 291); + + --color-neutral-50: oklch(0.98 0.008 60); + --color-neutral-100: oklch(0.96 0.008 60); + --color-neutral-200: oklch(0.92 0.008 60); + --color-neutral-300: oklch(0.84 0.008 60); + --color-neutral-400: oklch(0.72 0.008 60); + --color-neutral-500: oklch(0.58 0.010 60); + --color-neutral-600: oklch(0.46 0.010 60); + --color-neutral-700: oklch(0.35 0.010 60); + --color-neutral-800: oklch(0.26 0.010 60); + --color-neutral-900: oklch(0.18 0.008 60); + --color-neutral-950: oklch(0.12 0.005 60); + + --color-pop-50: oklch(0.97 0.025 50); + --color-pop-100: oklch(0.94 0.045 50); + --color-pop-200: oklch(0.88 0.075 50); + --color-pop-300: oklch(0.80 0.110 50); + --color-pop-400: oklch(0.72 0.145 50); + --color-pop-500: oklch(0.65 0.165 50); + --color-pop-600: oklch(0.57 0.155 50); + --color-pop-700: oklch(0.48 0.140 50); + --color-pop-800: oklch(0.38 0.110 50); + --color-pop-900: oklch(0.28 0.070 50); + --color-pop-950: oklch(0.18 0.040 50); + } +rules: + - Use @theme (not :root) for primitive ramps - they become Tailwind static utilities + - Hue angle (H) must be consistent across all stops in a ramp + - Chroma (C) peaks around 400-600, decreases toward 50 and 950 + - Lightness (L) must be monotonically decreasing from 50 to 950 +gotchas: + - "@theme values are static - they cannot reference CSS custom properties or be overridden at runtime by JavaScript." + - "If you change the H value after building components, all color utilities change across the app. Lock the hue angle early." diff --git a/corpus/frontend/design-tokens/step-2.yaml b/corpus/frontend/design-tokens/step-2.yaml new file mode 100644 index 0000000..048795e --- /dev/null +++ b/corpus/frontend/design-tokens/step-2.yaml @@ -0,0 +1,66 @@ +step: 2 +title: Map semantic tokens in :root and .dark +description: >- + Create role-based semantic tokens that reference primitives. These are the + tokens your components actually use. +code: | + :root { + /* Backgrounds */ + --color-bg: var(--color-neutral-50); + --color-bg-subtle: var(--color-neutral-100); + --color-surface: #ffffff; + --color-surface-raised: var(--color-neutral-50); + + /* Borders */ + --color-border: var(--color-neutral-200); + --color-border-strong: var(--color-neutral-300); + --color-border-focus: var(--color-brand-500); + + /* Text */ + --color-text: var(--color-neutral-900); + --color-text-muted: var(--color-neutral-500); + --color-text-subtle: var(--color-neutral-400); + --color-text-inverted: #ffffff; + + /* Interactive */ + --color-primary: var(--color-brand-500); + --color-primary-hover: var(--color-brand-600); + --color-primary-fg: #ffffff; + --color-secondary: var(--color-neutral-200); + --color-secondary-hover: var(--color-neutral-300); + --color-secondary-fg: var(--color-neutral-900); + + /* Status */ + --color-success: oklch(0.55 0.14 145); + --color-success-bg: oklch(0.96 0.04 145); + --color-success-fg: #ffffff; + --color-error: oklch(0.55 0.20 25); + --color-error-bg: oklch(0.97 0.04 25); + --color-error-fg: #ffffff; + --color-warning: oklch(0.65 0.18 75); + --color-warning-bg: oklch(0.97 0.04 75); + --color-warning-fg: oklch(0.20 0.05 75); + } + + .dark { + --color-bg: oklch(0.15 0.008 265); + --color-bg-subtle: oklch(0.18 0.008 265); + --color-surface: oklch(0.21 0.008 265); + --color-surface-raised: oklch(0.24 0.008 265); + --color-border: oklch(0.28 0.008 265); + --color-border-strong: oklch(0.35 0.008 265); + --color-text: oklch(0.95 0.008 265); + --color-text-muted: oklch(0.65 0.008 265); + --color-text-subtle: oklch(0.50 0.008 265); + --color-primary: var(--color-brand-400); + --color-primary-hover: var(--color-brand-300); + --color-primary-fg: oklch(0.15 0.005 291); + } +rules: + - Components must only reference semantic tokens, never primitive ramp values directly + - Dark mode requires bg-color elevation (lighter bg per level) since shadows are invisible + - Status color L should be ~0.55 for 4.5:1 contrast with white foreground + - Dark mode primary shifts from brand-500 (light) to brand-400 (dark) for perceptual brightness parity +gotchas: + - "Status colors at L=0.63 (typical green/red) fail 4.5:1 AA with white. Always run contrast check after defining status colors." + - "Do not use hex values for semantic tokens - use oklch primitives so the color stays in the defined color space." diff --git a/corpus/frontend/design-tokens/step-3.yaml b/corpus/frontend/design-tokens/step-3.yaml new file mode 100644 index 0000000..8016fb5 --- /dev/null +++ b/corpus/frontend/design-tokens/step-3.yaml @@ -0,0 +1,41 @@ +step: 3 +title: Bridge to Tailwind v4 utilities with @theme inline +description: >- + Use @theme inline to expose runtime-swappable CSS custom properties as + Tailwind utility classes. +code: | + /* @theme inline READS from CSS vars at runtime */ + /* This means dark mode changes propagate to Tailwind utilities automatically */ + @theme inline { + /* Map semantic tokens to Tailwind utility names */ + --color-background: var(--color-bg); + --color-foreground: var(--color-text); + --color-muted: var(--color-text-muted); + --color-border: var(--color-border); + --color-surface: var(--color-surface); + + --color-primary: var(--color-primary); + --color-primary-foreground: var(--color-primary-fg); + --color-secondary: var(--color-secondary); + --color-secondary-foreground: var(--color-secondary-fg); + + --color-success: var(--color-success); + --color-success-foreground: var(--color-success-fg); + --color-error: var(--color-error); + --color-error-foreground: var(--color-error-fg); + --color-warning: var(--color-warning); + --color-warning-foreground: var(--color-warning-fg); + } + + /* Now these work as Tailwind utilities: */ + /* bg-background, text-foreground, bg-primary, text-primary-foreground */ + /* border-border, text-muted, bg-surface, bg-success, text-error... */ +rules: + - "@theme inline is read at runtime - it reflects CSS custom property values dynamically" + - Plain @theme is static - values are baked in at build time + - Use @theme inline ONLY for semantic tokens; use plain @theme for primitive ramps + - "Name mapping: --color-X in @theme inline → bg-X, text-X, border-X Tailwind classes" +gotchas: + - "If you put runtime-swappable vars in plain @theme (not inline), dark mode will NOT work - the values won't update when .dark class is toggled." + - "@theme inline does not accept hardcoded values - it should only map CSS var references." + - "@theme generates Tailwind utility classes but does NOT emit CSS custom properties on :root. If :root uses var(--color-brand-400) and --color-brand-400 is only defined in @theme, the var() resolves to undefined at runtime - making all text and backgrounds white. Correct architecture: raw OKLCH values on :root and .dark (runtime CSS vars), @theme inline bridges them to utilities, @theme separately generates primitive ramp utilities." diff --git a/corpus/frontend/design-tokens/step-4.yaml b/corpus/frontend/design-tokens/step-4.yaml new file mode 100644 index 0000000..0d7ec16 --- /dev/null +++ b/corpus/frontend/design-tokens/step-4.yaml @@ -0,0 +1,31 @@ +step: 4 +title: Define spacing system +description: Set the 4px base multiplier and create named semantic spacing tokens. +code: | + @theme { + /* 4px base - sets the multiplier for p-1, p-2, p-4, etc. */ + --spacing: 0.25rem; + } + + /* Named semantic tokens in :root */ + :root { + --spacing-section-y: 6rem; /* 96px */ + --spacing-section-x: 1.5rem; /* 24px mobile, override at md */ + --spacing-card: 1.75rem; /* 28px */ + --spacing-card-sm: 1.25rem; /* 20px */ + --spacing-grid-cards: 1.5rem; /* 24px */ + --spacing-inline: 0.5rem; /* 8px */ + --spacing-form-gap: 1.25rem; /* 20px */ + } + + @media (min-width: 768px) { + :root { + --spacing-section-x: 3rem; /* 48px desktop */ + } + } +rules: + - "--spacing is the global multiplier - changing it scales ALL numeric spacing utilities" + - "Named tokens auto-generate Tailwind utilities: p-card, py-section-y, gap-grid-cards" + - "Always follow 4px grid: 0.25rem, 0.5rem, 0.75rem, 1rem, 1.25rem, 1.5rem, 1.75rem..." +gotchas: + - "--spacing is NOT an individual token for a specific spacing value. It is the base MULTIPLIER that affects p-1, p-2, p-4, etc. Overriding it changes every numeric spacing utility in the project." diff --git a/corpus/frontend/design-tokens/step-5.yaml b/corpus/frontend/design-tokens/step-5.yaml new file mode 100644 index 0000000..7591d60 --- /dev/null +++ b/corpus/frontend/design-tokens/step-5.yaml @@ -0,0 +1,34 @@ +step: 5 +title: Set up typography scale +description: >- + Define fluid heading sizes with clamp(), fixed body sizes, and line-height + and tracking tokens. +code: | + :root { + --font-sans: "Inter Variable", ui-sans-serif, system-ui, sans-serif; + --font-mono: "JetBrains Mono Variable", ui-monospace, monospace; + + --text-display: clamp(3rem, 5vw + 1rem, 4.5rem); + --text-h1: clamp(2.25rem, 3.5vw + 0.5rem, 3rem); + --text-h2: clamp(1.75rem, 2.5vw + 0.25rem, 2.25rem); + --text-h3: clamp(1.375rem, 1.5vw + 0.25rem, 1.5rem); + --text-body: 1rem; + --text-body-sm: 0.875rem; + --text-caption: 0.75rem; + --text-overline: 0.6875rem; + + --leading-display: 1.05; + --leading-heading: 1.2; + --leading-body: 1.7; + + --tracking-tight: -0.03em; + --tracking-heading: -0.02em; + --tracking-normal: 0em; + --tracking-wide: 0.10em; + } +rules: + - Body text (16px) is NEVER fluid - use fixed rem values + - Headings MUST have tight line-height (1.05-1.2), not body line-height (1.6+) + - Negative tracking on body text is a readability error +gotchas: + - Applying clamp() to body text causes font-size to change on window resize, causing reflow and jarring user experience. diff --git a/corpus/frontend/design-tokens/step-6.yaml b/corpus/frontend/design-tokens/step-6.yaml new file mode 100644 index 0000000..159f382 --- /dev/null +++ b/corpus/frontend/design-tokens/step-6.yaml @@ -0,0 +1,33 @@ +step: 6 +title: Define shadows and elevation +description: >- + Build the 5-level elevation shadow system using warm oklch-tinted shadows. +code: | + :root { + --shadow-xs: 0 1px 2px 0 oklch(0.30 0.02 60 / 0.08); + --shadow-sm: + 0 1px 3px 0 oklch(0.30 0.02 60 / 0.10), + 0 1px 2px -1px oklch(0.30 0.02 60 / 0.06); + --shadow-md: + 0 4px 6px -1px oklch(0.30 0.02 60 / 0.10), + 0 2px 4px -2px oklch(0.30 0.02 60 / 0.06); + --shadow-lg: + 0 10px 15px -3px oklch(0.30 0.02 60 / 0.10), + 0 4px 6px -4px oklch(0.30 0.02 60 / 0.05); + --shadow-xl: + 0 20px 25px -5px oklch(0.30 0.02 60 / 0.10), + 0 8px 10px -6px oklch(0.30 0.02 60 / 0.04); + } + + .dark { + --shadow-xs: none; --shadow-sm: none; + --shadow-md: none; --shadow-lg: none; --shadow-xl: none; + --color-surface-1: oklch(0.22 0.008 265); + --color-surface-2: oklch(0.26 0.008 265); + --color-surface-3: oklch(0.30 0.008 265); + } +rules: + - Use oklch-tinted shadows, never rgba(0,0,0) + - Dark mode disables all shadows, uses bg-color elevation instead +gotchas: + - rgba(0,0,0) shadows look cold on warm backgrounds. The warm hue tint (H=60) makes shadows feel natural. diff --git a/corpus/frontend/design-tokens/step-7.yaml b/corpus/frontend/design-tokens/step-7.yaml new file mode 100644 index 0000000..be8b7f3 --- /dev/null +++ b/corpus/frontend/design-tokens/step-7.yaml @@ -0,0 +1,32 @@ +step: 7 +title: Define motion and z-index tokens +description: Add duration, easing, and z-index scale with prefers-reduced-motion. +code: | + @theme { + --duration-fast: 100ms; + --duration-normal: 200ms; + --duration-slow: 300ms; + --ease-out: cubic-bezier(0, 0, 0.2, 1); + --ease-in: cubic-bezier(0.4, 0, 1, 1); + --ease-bounce: cubic-bezier(0.34, 1.56, 0.64, 1); + + --z-dropdown: 1000; + --z-sticky: 1010; + --z-modal: 1050; + --z-tooltip: 1070; + --z-toast: 1080; + } + + @layer base { + @media (prefers-reduced-motion: reduce) { + *, *::before, *::after { + animation-duration: 0.01ms !important; + transition-duration: 0.01ms !important; + } + } + } +rules: + - prefers-reduced-motion MUST be in @layer base with !important + - Never use arbitrary z-index values +gotchas: + - Forgetting prefers-reduced-motion is a WCAG 2.3.3 violation. diff --git a/corpus/frontend/design-tokens/step-8.yaml b/corpus/frontend/design-tokens/step-8.yaml new file mode 100644 index 0000000..8ad66b2 --- /dev/null +++ b/corpus/frontend/design-tokens/step-8.yaml @@ -0,0 +1,27 @@ +step: 8 +title: Run contrast audit and verify token usage +description: >- + After building the full token system, run a contrast audit on all color + token pairs. +code: | + /* Run contrast checks on these pairs: */ + /* --color-text on --color-bg → target 7:1 (AAA) */ + /* --color-text-muted on --color-bg → target 4.5:1 (AA) */ + /* --color-primary-fg on --color-primary → target 4.5:1 (AA) */ + /* --color-success-fg on --color-success → target 4.5:1 (AA) */ + /* --color-error-fg on --color-error → target 4.5:1 (AA) */ + /* --color-border on --color-bg → target 1.1:1+ (perceptible) */ + + /* Tools: */ + /* - https://oklch.com - build and preview oklch colors */ + /* - https://www.myndex.com/APCA/ - APCA contrast checker */ + /* - Storybook a11y addon - automated component-level checks */ + + /* Check for stale hue references: */ + /* grep -r "violet-\|purple-\|blue-" src/ (old hardcoded Tailwind hue names) */ +rules: + - Run contrast audit AFTER finalizing the palette - before building components + - Status colors often need L adjusted to ~0.55 for AA compliance with white foreground + - Grep for stale hue-name references after renaming ramps +gotchas: + - "WCAG contrast is calculated on final rendered colors. If CSS vars are not resolving correctly in dark mode, contrast tools won't catch it - test with a browser extension on the live page." diff --git a/corpus/frontend/designer/dashboard.yaml b/corpus/frontend/designer/dashboard.yaml new file mode 100644 index 0000000..b616096 --- /dev/null +++ b/corpus/frontend/designer/dashboard.yaml @@ -0,0 +1,90 @@ +type: dashboard +description: >- + Data-forward workspace with a fixed shell, clear hierarchy, and enough density + for power users without sacrificing readability. +layout: >- + App shell: fixed sidebar (240-280px) + header (56-64px) + scrollable content + area +sections: + - name: Sidebar Navigation + required: true + components: + - Logo/brand mark + - Primary nav items (5-7 max) + - Workspace switcher + - Collapsed state for mobile + - User avatar + settings at bottom + notes: >- + Persistent, keyboard navigable, and clearly indicates the active route. + - name: Header Bar + required: true + components: + - Page title / breadcrumbs + - Search (Cmd+K) + - Notifications + - User menu + notes: >- + Fixed or sticky. Keep power-user actions in the header so they are always + reachable. + - name: Metrics Row + required: false + components: + - 3-5 KPI cards + - Trend indicator (up/down arrow + %) + - Sparkline or mini chart + notes: >- + Group related metrics and keep visible count within working-memory limits. + - name: Primary Content + required: true + components: + - Data table or card grid + - Filters + sort controls + - Pagination or virtual scroll + - Bulk actions toolbar + notes: >- + Tables should right-align numbers, use monospace for values, and keep + header rows sticky. + - name: Detail Panel + required: false + components: + - Side drawer (not modal) + - Record details + - Edit-in-place or form + - Action buttons + notes: >- + Keep list context visible while the user inspects or edits a record. + - name: Empty States + required: true + components: + - Illustration (optional) + - Headline: what's missing + - Body: what to do next + - Single CTA + notes: >- + Every data container needs an empty state that helps the user recover or + create the first item. +cognitiveApply: + - miller + - hick + - gestalt + - fitts + - doherty +compositionApply: + - grid-theory + - visual-hierarchy +interactionApply: + - navigation + - skeleton-vs-spinner + - progressive-disclosure + - empty-states + - feedback +writingApply: + - headlines + - empty-states-copy + - loading-states +keyRules: + - Command palette (Cmd+K) for 50+ feature apps + - Keyboard shortcuts for all frequent actions + - Skeleton loaders for data panels, not spinners + - Right-align quantitative data in monospace font + - Compact density: 14px body, 48px section gaps, 20px card padding diff --git a/corpus/frontend/designer/index.yaml b/corpus/frontend/designer/index.yaml new file mode 100644 index 0000000..de0c3c5 --- /dev/null +++ b/corpus/frontend/designer/index.yaml @@ -0,0 +1,4 @@ +namespace: frontend.designer +pageTemplates: + dashboard: + file: dashboard.yaml diff --git a/corpus/frontend/lenis/gsap-integration.yaml b/corpus/frontend/lenis/gsap-integration.yaml new file mode 100644 index 0000000..04cdac8 --- /dev/null +++ b/corpus/frontend/lenis/gsap-integration.yaml @@ -0,0 +1,52 @@ +name: gsap-integration +description: >- + Integrate Lenis with GSAP ScrollTrigger by disabling autoRaf and driving Lenis + from GSAP's ticker. +code: | + "use client"; + + import { useEffect } from "react"; + import { ReactLenis, useLenis } from "lenis/react"; + import "lenis/dist/lenis.css"; + import gsap from "gsap"; + import ScrollTrigger from "gsap/ScrollTrigger"; + + gsap.registerPlugin(ScrollTrigger); + + function GSAPSyncEffect() { + const lenis = useLenis(); + + useEffect(() => { + if (!lenis) return; + + // Sync Lenis with GSAP ticker + gsap.ticker.add((time) => { + lenis.raf(time * 1000); + }); + + // Disable GSAP's lagSmoothing to prevent conflicts + gsap.ticker.lagSmoothing(0); + + // Update ScrollTrigger on each scroll + lenis.on("scroll", ScrollTrigger.update); + + return () => { + lenis.off("scroll", ScrollTrigger.update); + }; + }, [lenis]); + + return null; + } + + export function GSAPLenisProvider({ children }: { children: React.ReactNode }) { + return ( + + + {children} + + ); + } +tips: + - autoRaf: false is required - if GSAP and Lenis both run their own RAF loops, scroll updates fire twice per frame causing desync. + - gsap.ticker.lagSmoothing(0) prevents GSAP from adjusting delta time which would cause Lenis to stutter after tab switches. + - lenis.raf() expects milliseconds - multiply GSAP time (seconds) by 1000. diff --git a/corpus/frontend/lenis/index.yaml b/corpus/frontend/lenis/index.yaml new file mode 100644 index 0000000..a9f0f74 --- /dev/null +++ b/corpus/frontend/lenis/index.yaml @@ -0,0 +1,4 @@ +namespace: frontend.lenis +patterns: + gsap-integration: + file: gsap-integration.yaml diff --git a/corpus/frontend/motion/examples/scroll.yaml b/corpus/frontend/motion/examples/scroll.yaml new file mode 100644 index 0000000..a192acc --- /dev/null +++ b/corpus/frontend/motion/examples/scroll.yaml @@ -0,0 +1,54 @@ +category: scroll +examples: + - title: Scroll progress rail + description: Fixed progress indicator driven by the page scroll position. + code: | + import { motion, useScroll, useSpring } from "motion/react" + + export function ScrollProgressRail() { + const { scrollYProgress } = useScroll() + const scaleX = useSpring(scrollYProgress, { stiffness: 120, damping: 30 }) + + return ( + + ) + } + - title: Section reveal on enter + description: Fade and lift content as it enters the viewport without wiring the effect to a global progress bar. + code: | + import { motion } from "motion/react" + + export function RevealSection({ children }) { + return ( + + {children} + + ) + } + - title: Horizontal story strip + description: Drive a horizontal motion lane from vertical scroll for long-form storytelling or product tours. + code: | + import { motion, useScroll, useTransform } from "motion/react" + import { useRef } from "react" + + export function HorizontalStoryStrip() { + const ref = useRef(null) + const { scrollYProgress } = useScroll({ target: ref, offset: ["start end", "end start"] }) + const x = useTransform(scrollYProgress, [0, 1], ["0%", "-75%"]) + + return ( +
+
+ +
+
+ ) + } diff --git a/corpus/frontend/motion/index.yaml b/corpus/frontend/motion/index.yaml new file mode 100644 index 0000000..ff0d4b8 --- /dev/null +++ b/corpus/frontend/motion/index.yaml @@ -0,0 +1,7 @@ +namespace: frontend.motion +apis: + motion: + file: motion.yaml +examples: + scroll: + file: examples/scroll.yaml diff --git a/corpus/frontend/motion/motion.yaml b/corpus/frontend/motion/motion.yaml new file mode 100644 index 0000000..d05f122 --- /dev/null +++ b/corpus/frontend/motion/motion.yaml @@ -0,0 +1,66 @@ +name: motion +kind: component +description: >- + The core building block. Every HTML and SVG element has a motion counterpart + (motion.div, motion.span, motion.circle, etc.). Accepts all standard props + plus animation-specific props. +importPath: import { motion } from "motion/react" +usage: | + import { motion } from "motion/react" + + export function HeroCard() { + return ( + + Motion content + + ) + } +props: + - name: initial + type: Target | VariantLabels | false + description: Initial visual state on mount. Set false to disable enter animation. + - name: animate + type: Target | VariantLabels + description: Target animation values on enter and update. + - name: exit + type: Target | VariantLabels + description: Target animation when removed (requires AnimatePresence parent). + - name: transition + type: Transition + description: Default transition config for this component. + - name: variants + type: Variants + description: "Named animation states. Object of { [name]: target }." + - name: style + type: MotionStyle + description: Extended style prop supporting motion values and independent transforms. +examples: + - title: Basic fade in + category: animation + code: | + import { motion } from "motion/react" + + export function FadeInCard() { + return ( + + Hello + + ) + } +tips: + - Use motion/react-client for React Server Components (Next.js app dir). + - motion.create(Component) wraps custom components. In React 18 the component must use forwardRef. In React 19 ref is passed via props automatically. + - Independent transforms: x, y, z, scale, scaleX, scaleY, rotate, rotateX, rotateY, rotateZ, skewX, skewY, transformPerspective. +relatedApis: + - AnimatePresence + - useAnimate + - useMotionValue + - MotionConfig diff --git a/corpus/frontend/reactflow/index.yaml b/corpus/frontend/reactflow/index.yaml new file mode 100644 index 0000000..b005965 --- /dev/null +++ b/corpus/frontend/reactflow/index.yaml @@ -0,0 +1,4 @@ +namespace: frontend.reactflow +apis: + reactflow: + file: reactflow.yaml diff --git a/corpus/frontend/reactflow/reactflow.yaml b/corpus/frontend/reactflow/reactflow.yaml new file mode 100644 index 0000000..d2e1fb3 --- /dev/null +++ b/corpus/frontend/reactflow/reactflow.yaml @@ -0,0 +1,84 @@ +name: ReactFlow +kind: component +description: >- + Core canvas component for rendering nodes and edges, wiring connection + interactions, and managing controlled or uncontrolled flow state. +importPath: "import { ReactFlow } from '@xyflow/react'" +props: + - name: nodes + type: Node[] + description: Array of nodes for controlled flows. + default: "[]" + - name: edges + type: Edge[] + description: Array of edges for controlled flows. + default: "[]" + - name: defaultNodes + type: Node[] + description: Initial nodes for uncontrolled flows. + - name: defaultEdges + type: Edge[] + description: Initial edges for uncontrolled flows. + - name: onConnect + type: OnConnect + description: Fires when a new connection is completed. + - name: onNodesChange + type: OnNodesChange + description: Called for drag, select, and position changes in controlled flows. + - name: onEdgesChange + type: OnEdgesChange + description: Called when edges change in controlled flows. + - name: nodeTypes + type: NodeTypes + description: Custom node components keyed by node type. + - name: edgeTypes + type: EdgeTypes + description: Custom edge components keyed by edge type. + - name: isValidConnection + type: IsValidConnection + description: Validate candidate connections before an edge is created. + - name: fitView + type: boolean + description: Fit the viewport to all nodes on initial render. + default: "false" + - name: defaultViewport + type: Viewport + description: Initial viewport position and zoom. + default: "{ x: 0, y: 0, zoom: 1 }" +usage: | + import { ReactFlow } from '@xyflow/react'; + + export function FlowCanvas() { + return ( +
+ +
+ ); + } +examples: + - title: Controlled flow + category: state-management + code: | + import { ReactFlow } from '@xyflow/react'; + + export function ControlledFlow({ nodes, edges, onNodesChange, onEdgesChange, onConnect }) { + return ( + + ); + } +tips: + - Import '@xyflow/react/dist/style.css' once at the app root. + - The parent container must have an explicit width and height. + - Define nodeTypes and edgeTypes outside the component or memoize them. +relatedApis: + - Background + - Controls + - MiniMap + - useReactFlow diff --git a/corpus/index.yaml b/corpus/index.yaml new file mode 100644 index 0000000..888611c --- /dev/null +++ b/corpus/index.yaml @@ -0,0 +1,11 @@ +namespaces: + frontend.motion: + index: frontend/motion/index.yaml + frontend.reactflow: + index: frontend/reactflow/index.yaml + frontend.lenis: + index: frontend/lenis/index.yaml + frontend.designer: + index: frontend/designer/index.yaml + frontend.design-tokens: + index: frontend/design-tokens/index.yaml diff --git a/src/plugins/design-tokens/tools/get-procedure.ts b/src/plugins/design-tokens/tools/get-procedure.ts index e24b745..69468eb 100644 --- a/src/plugins/design-tokens/tools/get-procedure.ts +++ b/src/plugins/design-tokens/tools/get-procedure.ts @@ -1,7 +1,150 @@ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import YAML from "yaml"; import { z } from "zod"; import { TOKEN_PROCEDURES, getProcedureByStep } from "../data.js"; +type CorpusIndex = { + namespaces?: { + "frontend.design-tokens"?: { + index?: string; + }; + }; +}; + +type CorpusNamespaceIndex = { + namespace?: string; + procedures?: Record; +}; + +type CorpusProcedureEntry = { + step?: number; + title?: string; + description?: string; + code?: string; + rules?: string[]; + gotchas?: string[]; +}; + +type LoadedCorpusProcedure = { + step: number; + title: string; + description: string; + code: string; + rules: string[]; + gotchas: string[]; + source: string; +}; + +const moduleDir = dirname(fileURLToPath(import.meta.url)); +const corpusRoot = join(moduleDir, "../../../../corpus"); +const corpusNamespace = "frontend.design-tokens"; + +let cachedCorpusProcedures: Map | null | undefined; + +function loadCorpusProcedures(): Map | null { + if (cachedCorpusProcedures !== undefined) { + return cachedCorpusProcedures; + } + + try { + const indexRaw = readFileSync(join(corpusRoot, "index.yaml"), "utf8"); + const index = YAML.parse(indexRaw) as CorpusIndex | null; + const namespaceIndexPath = index?.namespaces?.[corpusNamespace]?.index; + if (!namespaceIndexPath) { + cachedCorpusProcedures = null; + return cachedCorpusProcedures; + } + + const namespaceRaw = readFileSync(join(corpusRoot, namespaceIndexPath), "utf8"); + const namespaceIndex = YAML.parse(namespaceRaw) as CorpusNamespaceIndex | null; + const procedureEntries = namespaceIndex?.procedures ?? {}; + const loaded = new Map(); + + for (const [key, meta] of Object.entries(procedureEntries)) { + const match = key.match(/^step-(\d+)$/i); + const step = match ? Number(match[1]) : Number.NaN; + const file = meta?.file; + if (!Number.isInteger(step) || !file) { + continue; + } + + const raw = readFileSync(join(corpusRoot, "frontend/design-tokens", file), "utf8"); + const entry = YAML.parse(raw) as CorpusProcedureEntry | null; + if ( + !entry || + entry.step !== step || + !entry.title || + !entry.description || + !entry.code || + !Array.isArray(entry.rules) || + !Array.isArray(entry.gotchas) + ) { + continue; + } + + loaded.set(step, { + step, + title: entry.title, + description: entry.description, + code: entry.code, + rules: entry.rules, + gotchas: entry.gotchas, + source: corpusNamespace, + }); + } + + cachedCorpusProcedures = loaded.size > 0 ? loaded : null; + return cachedCorpusProcedures; + } catch { + cachedCorpusProcedures = null; + return cachedCorpusProcedures; + } +} + +function getProcedure(step: number) { + const corpusEntry = loadCorpusProcedures()?.get(step); + if (corpusEntry) { + return corpusEntry; + } + + return getProcedureByStep(step); +} + +function renderProcedure(proc: { + step: number; + title: string; + description: string; + code?: string; + rules?: string[]; + gotchas?: string[]; + source?: string; +}): string { + let text = `# Step ${proc.step}: ${proc.title}\n\n${proc.description}\n\n`; + + if (proc.code) { + text += `## Code\n\`\`\`css\n${proc.code}\n\`\`\`\n\n`; + } + + if (proc.rules && proc.rules.length > 0) { + text += `## Rules\n`; + for (const rule of proc.rules) text += `- ${rule}\n`; + } + + if (proc.gotchas && proc.gotchas.length > 0) { + text += `\n## Gotchas\n`; + for (const gotcha of proc.gotchas) text += `- ${gotcha}\n`; + } + + if (proc.source) { + text += `\n**Corpus Source:** ${proc.source}`; + } + + return text; +} + export function register(server: McpServer): void { server.tool( "design_tokens_get_procedure", @@ -12,26 +155,24 @@ export function register(server: McpServer): void { }, async ({ step }) => { if (step !== undefined) { - const proc = getProcedureByStep(step); + const proc = getProcedure(step); if (!proc) { return { content: [{ type: "text", text: `Step ${step} not found. Valid steps: 1-${TOKEN_PROCEDURES.length}` }], isError: true, }; } - let text = `# Step ${proc.step}: ${proc.title}\n\n${proc.description}\n\n`; - text += `## Code\n\`\`\`css\n${proc.code}\n\`\`\`\n\n`; - text += `## Rules\n`; - for (const rule of proc.rules) text += `- ${rule}\n`; - text += `\n## Gotchas\n`; - for (const gotcha of proc.gotchas) text += `- ${gotcha}\n`; - return { content: [{ type: "text", text }] }; + return { content: [{ type: "text", text: renderProcedure(proc) }] }; } let text = "# Design Token Build Procedure\n\n"; text += "A complete token system = 10 categories. Follow in order.\n\n"; for (const proc of TOKEN_PROCEDURES) { - text += `## Step ${proc.step}: ${proc.title}\n${proc.description}\n\n`; + const corpusProc = loadCorpusProcedures()?.get(proc.step); + text += `## Step ${proc.step}: ${corpusProc?.title ?? proc.title}\n${corpusProc?.description ?? proc.description}\n\n`; + } + if (loadCorpusProcedures()) { + text += `**Corpus Source:** ${corpusNamespace}`; } text += "\nCall `design_tokens_get_procedure` with a step number for full code + rules."; return { content: [{ type: "text", text }] }; diff --git a/src/plugins/designer/tools/get-page-template.ts b/src/plugins/designer/tools/get-page-template.ts index d83ca9f..bb6b9a0 100644 --- a/src/plugins/designer/tools/get-page-template.ts +++ b/src/plugins/designer/tools/get-page-template.ts @@ -1,7 +1,116 @@ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import YAML from "yaml"; import { z } from "zod"; import { PAGE_TYPES, getPageTemplate } from "../data.js"; +type CorpusIndex = { + namespaces?: { + "frontend.designer"?: { + index?: string; + }; + }; +}; + +type CorpusNamespaceIndex = { + namespace?: string; + pageTemplates?: { + dashboard?: { + file?: string; + }; + }; +}; + +type CorpusPageSection = { + name?: string; + required?: boolean; + components?: string[]; + notes?: string; +}; + +type CorpusPageTemplate = { + type?: string; + description?: string; + layout?: string; + sections?: CorpusPageSection[]; + cognitiveApply?: string[]; + compositionApply?: string[]; + interactionApply?: string[]; + writingApply?: string[]; + keyRules?: string[]; +}; + +const moduleDir = dirname(fileURLToPath(import.meta.url)); +const corpusRoot = join(moduleDir, "../../../../corpus"); +const corpusNamespace = "frontend.designer"; + +let cachedCorpusDashboard: { template: CorpusPageTemplate; source: string } | null | undefined; + +function loadCorpusDashboardTemplate(): { template: CorpusPageTemplate; source: string } | null { + if (cachedCorpusDashboard !== undefined) { + return cachedCorpusDashboard; + } + + try { + const indexRaw = readFileSync(join(corpusRoot, "index.yaml"), "utf8"); + const index = YAML.parse(indexRaw) as CorpusIndex | null; + const namespaceIndexPath = index?.namespaces?.[corpusNamespace]?.index; + if (!namespaceIndexPath) { + cachedCorpusDashboard = null; + return null; + } + + const namespaceRaw = readFileSync(join(corpusRoot, namespaceIndexPath), "utf8"); + const namespaceIndex = YAML.parse(namespaceRaw) as CorpusNamespaceIndex | null; + const pageTemplatePath = namespaceIndex?.pageTemplates?.dashboard?.file; + if (!pageTemplatePath) { + cachedCorpusDashboard = null; + return null; + } + + const templateRaw = readFileSync(join(corpusRoot, "frontend/designer", pageTemplatePath), "utf8"); + const template = YAML.parse(templateRaw) as CorpusPageTemplate | null; + const sections = template?.sections; + const cognitiveApply = template?.cognitiveApply; + const compositionApply = template?.compositionApply; + const interactionApply = template?.interactionApply; + const writingApply = template?.writingApply; + const keyRules = template?.keyRules; + if ( + !template || + template.type?.toLowerCase() !== "dashboard" || + !template.description || + !template.layout || + !Array.isArray(sections) || + sections.length === 0 || + !Array.isArray(cognitiveApply) || + !Array.isArray(compositionApply) || + !Array.isArray(interactionApply) || + !Array.isArray(writingApply) || + !Array.isArray(keyRules) + ) { + cachedCorpusDashboard = null; + return null; + } + + cachedCorpusDashboard = { template, source: corpusNamespace }; + return cachedCorpusDashboard; + } catch { + cachedCorpusDashboard = null; + return null; + } +} + +function getCorpusAwarePageTemplate(type: (typeof PAGE_TYPES)[number]) { + if (type === "dashboard") { + return loadCorpusDashboardTemplate()?.template ?? getPageTemplate(type); + } + + return getPageTemplate(type); +} + export function register(server: McpServer): void { server.tool( "designer_get_page_template", @@ -10,7 +119,8 @@ export function register(server: McpServer): void { type: z.enum(PAGE_TYPES).describe("Page type to get template for"), }, async ({ type }) => { - const template = getPageTemplate(type); + const corpusEntry = type === "dashboard" ? loadCorpusDashboardTemplate() : null; + const template = getCorpusAwarePageTemplate(type); if (!template) { return { content: [{ type: "text" as const, text: `Page type "${type}" not found. Available: ${PAGE_TYPES.join(", ")}` }], @@ -23,27 +133,31 @@ export function register(server: McpServer): void { text += `**Layout:** ${template.layout}\n\n`; text += `## Sections\n\n`; - for (const section of template.sections) { - text += `### ${section.name}${section.required ? "" : " (optional)"}\n\n`; + for (const section of template.sections ?? []) { + text += `### ${section.name ?? "Untitled Section"}${section.required ? "" : " (optional)"}\n\n`; text += `**Components:**\n`; - for (const comp of section.components) { + for (const comp of section.components ?? []) { text += `- ${comp}\n`; } - text += `\n**Notes:** ${section.notes}\n\n`; + text += `\n**Notes:** ${section.notes ?? ""}\n\n`; } text += `## Apply These\n\n`; - text += `**Cognitive Laws:** ${template.cognitiveApply.join(", ")}\n`; - text += `**Composition:** ${template.compositionApply.join(", ")}\n`; - text += `**Interaction Patterns:** ${template.interactionApply.join(", ")}\n`; - text += `**UX Writing:** ${template.writingApply.join(", ")}\n\n`; + text += `**Cognitive Laws:** ${(template.cognitiveApply ?? []).join(", ")}\n`; + text += `**Composition:** ${(template.compositionApply ?? []).join(", ")}\n`; + text += `**Interaction Patterns:** ${(template.interactionApply ?? []).join(", ")}\n`; + text += `**UX Writing:** ${(template.writingApply ?? []).join(", ")}\n\n`; text += `## Key Rules\n\n`; - for (const rule of template.keyRules) { + for (const rule of template.keyRules ?? []) { text += `- ${rule}\n`; } + if (corpusEntry) { + text += `\n**Corpus Source:** ${corpusEntry.source}`; + } + return { content: [{ type: "text" as const, text }] }; - } + }, ); } diff --git a/src/plugins/lenis/tools/get-pattern.ts b/src/plugins/lenis/tools/get-pattern.ts index 4c706f5..e01dd34 100644 --- a/src/plugins/lenis/tools/get-pattern.ts +++ b/src/plugins/lenis/tools/get-pattern.ts @@ -1,9 +1,76 @@ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import YAML from "yaml"; import { z } from "zod"; import { PATTERNS } from "../data.js"; const PATTERN_NAMES = Object.keys(PATTERNS) as [string, ...string[]]; +type CorpusIndex = { + namespaces?: { + "frontend.lenis"?: { + index?: string; + }; + }; +}; + +type CorpusNamespaceIndex = { + namespace?: string; + patterns?: { + [name: string]: { + file?: string; + }; + }; +}; + +type CorpusPatternEntry = { + name?: string; + description?: string; + code?: string; + tips?: string[]; +}; + +const moduleDir = dirname(fileURLToPath(import.meta.url)); +const corpusRoot = join(moduleDir, "../../../../corpus"); +const corpusNamespace = "frontend.lenis"; + +let cachedCorpusPattern: { name: string; pattern: CorpusPatternEntry; source: string } | undefined; + +function loadCorpusPattern(name: string): { name: string; pattern: CorpusPatternEntry; source: string } | null { + if (cachedCorpusPattern?.name === name) { + return cachedCorpusPattern; + } + + try { + const indexRaw = readFileSync(join(corpusRoot, "index.yaml"), "utf8"); + const index = YAML.parse(indexRaw) as CorpusIndex | null; + const namespaceIndexPath = index?.namespaces?.[corpusNamespace]?.index; + if (!namespaceIndexPath) { + return null; + } + + const namespaceRaw = readFileSync(join(corpusRoot, namespaceIndexPath), "utf8"); + const namespaceIndex = YAML.parse(namespaceRaw) as CorpusNamespaceIndex | null; + const patternPath = namespaceIndex?.patterns?.[name]?.file; + if (!patternPath) { + return null; + } + + const patternRaw = readFileSync(join(corpusRoot, "frontend/lenis", patternPath), "utf8"); + const pattern = YAML.parse(patternRaw) as CorpusPatternEntry | null; + if (!pattern || pattern.name?.toLowerCase() !== name.toLowerCase() || !pattern.description || !pattern.code) { + return null; + } + + cachedCorpusPattern = { name, pattern, source: corpusNamespace }; + return cachedCorpusPattern; + } catch { + return null; + } +} + export function register(server: McpServer): void { server.tool( "lenis_get_pattern", @@ -16,7 +83,8 @@ export function register(server: McpServer): void { ), }, async ({ name }) => { - const pattern = PATTERNS[name]; + const corpusEntry = loadCorpusPattern(name); + const pattern = corpusEntry?.pattern ?? PATTERNS[name]; if (!pattern) { return { content: [ @@ -40,6 +108,10 @@ export function register(server: McpServer): void { for (const tip of pattern.tips) text += `- ${tip}\n`; } + if (corpusEntry) { + text += `\n**Corpus Source:** ${corpusEntry.source}`; + } + return { content: [{ type: "text", text }] }; }, ); diff --git a/src/plugins/motion/tools/get-api.ts b/src/plugins/motion/tools/get-api.ts index a806038..82f0d7f 100644 --- a/src/plugins/motion/tools/get-api.ts +++ b/src/plugins/motion/tools/get-api.ts @@ -1,7 +1,98 @@ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import YAML from "yaml"; import { z } from "zod"; import { ALL_APIS, searchApis, getApiByName, formatApiReference } from "../data.js"; +type CorpusIndex = { + namespaces?: { + "frontend.motion"?: { + index?: string; + }; + }; +}; + +type CorpusNamespaceIndex = { + namespace?: string; + apis?: { + motion?: { + file?: string; + }; + }; +}; + +type CorpusApiEntry = { + name?: string; + kind?: string; + description?: string; + importPath?: string; + props?: Array<{ name: string; type: string; description: string; default?: string }>; + returns?: string; + usage?: string; + examples?: Array<{ title: string; code: string; description?: string; category: string }>; + tips?: string[]; + relatedApis?: string[]; + 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; + +function loadCorpusMotionApi(): { api: CorpusApiEntry; source: string } | null { + if (cachedCorpusMotionApi !== undefined) { + return cachedCorpusMotionApi; + } + + try { + const indexRaw = readFileSync(join(corpusRoot, "index.yaml"), "utf8"); + const index = YAML.parse(indexRaw) as CorpusIndex | null; + const namespaceIndexPath = index?.namespaces?.[corpusNamespace]?.index; + if (!namespaceIndexPath) { + cachedCorpusMotionApi = null; + return cachedCorpusMotionApi; + } + + const namespaceRaw = readFileSync(join(corpusRoot, namespaceIndexPath), "utf8"); + const namespaceIndex = YAML.parse(namespaceRaw) as CorpusNamespaceIndex | null; + const apiPath = namespaceIndex?.apis?.motion?.file; + if (!apiPath) { + cachedCorpusMotionApi = null; + return cachedCorpusMotionApi; + } + + 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; + } + + cachedCorpusMotionApi = { api, source: corpusNamespace }; + return cachedCorpusMotionApi; + } catch { + cachedCorpusMotionApi = null; + return cachedCorpusMotionApi; + } +} + +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", @@ -10,7 +101,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 api = getApiByName(name); + const corpusEntry = name.toLowerCase() === "motion" ? loadCorpusMotionApi() : null; + const api = getMotionApi(name); if (!api) { const suggestions = searchApis(name).map((r) => r.api.name); return { @@ -18,7 +110,15 @@ export function register(server: McpServer): void { isError: true, }; } - return { content: [{ type: "text", text: formatApiReference(api) }] }; + const rendered = formatApiReference(api); + return { + content: [ + { + type: "text", + text: corpusEntry ? `${rendered}\n**Corpus Source:** ${corpusEntry.source}` : rendered, + }, + ], + }; }, ); } diff --git a/src/plugins/motion/tools/get-examples.ts b/src/plugins/motion/tools/get-examples.ts index 3846138..34fe8c5 100644 --- a/src/plugins/motion/tools/get-examples.ts +++ b/src/plugins/motion/tools/get-examples.ts @@ -1,6 +1,97 @@ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import YAML from "yaml"; import { z } from "zod"; import { CATEGORIES, getExamplesByCategory, formatExample, capitalize } from "../data.js"; +import type { Example } from "../data.js"; + +type CorpusIndex = { + namespaces?: { + "frontend.motion"?: { + index?: string; + }; + }; +}; + +type CorpusNamespaceIndex = { + namespace?: string; + examples?: Record; +}; + +type CorpusExampleFile = { + category?: string; + examples?: Array<{ + title?: string; + description?: string; + code?: string; + category?: string; + }>; +}; + +type ParsedCorpusExample = { + title: string; + description?: string; + code: string; + category?: string; +}; + +const moduleDir = dirname(fileURLToPath(import.meta.url)); +const corpusRoot = join(moduleDir, "../../../../corpus"); +const corpusNamespace = "frontend.motion"; + +let cachedCorpusExamples: Map | undefined; + +function loadCorpusExamples(category: string): { examples: Example[]; source: string } | null { + const key = category.toLowerCase().trim(); + cachedCorpusExamples ??= new Map(); + const cached = cachedCorpusExamples.get(key); + if (cached !== undefined) { + return cached; + } + + try { + const indexRaw = readFileSync(join(corpusRoot, "index.yaml"), "utf8"); + const index = YAML.parse(indexRaw) as CorpusIndex | null; + const namespaceIndexPath = index?.namespaces?.[corpusNamespace]?.index; + if (!namespaceIndexPath) { + cachedCorpusExamples.set(key, null); + return null; + } + + const namespaceRaw = readFileSync(join(corpusRoot, namespaceIndexPath), "utf8"); + const namespaceIndex = YAML.parse(namespaceRaw) as CorpusNamespaceIndex | null; + const examplePath = namespaceIndex?.examples?.[key]?.file; + if (!examplePath) { + cachedCorpusExamples.set(key, null); + return null; + } + + const fileRaw = readFileSync(join(corpusRoot, "frontend/motion", examplePath), "utf8"); + const file = YAML.parse(fileRaw) as CorpusExampleFile | null; + const examples = (file?.examples ?? []) + .filter((ex): ex is ParsedCorpusExample => Boolean(ex?.title && ex?.code)) + .map((ex) => ({ + title: ex.title, + code: ex.code, + description: ex.description, + category: (ex.category ?? key) as Example["category"], + })); + + if (examples.length === 0) { + cachedCorpusExamples.set(key, null); + return null; + } + + const result = { examples, source: corpusNamespace }; + cachedCorpusExamples.set(key, result); + return result; + } catch { + cachedCorpusExamples.set(key, null); + return null; + } +} export function register(server: McpServer): void { server.tool( @@ -8,12 +99,16 @@ export function register(server: McpServer): void { "Get code examples for a specific animation category", { category: z.string().describe(`Category: ${CATEGORIES.join(", ")}`) }, async ({ category }) => { - const examples = getExamplesByCategory(category); + const corpusEntry = loadCorpusExamples(category); + const examples = corpusEntry?.examples ?? getExamplesByCategory(category); if (examples.length === 0) { return { content: [{ type: "text", text: `No examples for category "${category}". Available: ${CATEGORIES.join(", ")}` }] }; } let text = `# ${capitalize(category)} Examples\n\n`; for (const ex of examples) text += formatExample(ex, 2); + if (corpusEntry) { + text += `**Corpus Source:** ${corpusEntry.source}\n`; + } return { content: [{ type: "text", text }] }; }, ); diff --git a/src/plugins/reactflow/tools/get-api.ts b/src/plugins/reactflow/tools/get-api.ts index ff3d6bd..0bc15ec 100644 --- a/src/plugins/reactflow/tools/get-api.ts +++ b/src/plugins/reactflow/tools/get-api.ts @@ -1,7 +1,133 @@ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import YAML from "yaml"; import { z } from "zod"; import { ALL_APIS, searchApis, getApiByName, formatApiReference } from "../data/index.js"; +type CorpusIndex = { + namespaces?: { + "frontend.reactflow"?: { + index?: string; + }; + }; +}; + +type CorpusNamespaceIndex = { + namespace?: string; + apis?: { + reactflow?: { + file?: string; + }; + }; +}; + +type CorpusApiEntry = { + name?: string; + kind?: string; + description?: string; + importPath?: string; + props?: Array<{ name: string; type: string; description: string; default?: string }>; + returns?: string; + usage?: string; + examples?: Array<{ title?: string; code?: string; description?: string; category?: string }>; + tips?: string[]; + relatedApis?: string[]; +}; + +const moduleDir = dirname(fileURLToPath(import.meta.url)); +const corpusRoot = join(moduleDir, "../../../../corpus"); +const corpusNamespace = "frontend.reactflow"; +const corpusApiName = "ReactFlow"; +type ReactFlowExample = Parameters[0]["examples"][number]; + +let cachedCorpusReactFlowApi: { api: Parameters[0]; source: string } | null | undefined; + +function normalizeApiName(name: string): string { + return name.toLowerCase().replace(/[<>/()]/g, "").trim(); +} + +function loadCorpusReactFlowApi(): { api: Parameters[0]; source: string } | null { + if (cachedCorpusReactFlowApi !== undefined) { + return cachedCorpusReactFlowApi; + } + + try { + const indexRaw = readFileSync(join(corpusRoot, "index.yaml"), "utf8"); + const index = YAML.parse(indexRaw) as CorpusIndex | null; + const namespaceIndexPath = index?.namespaces?.[corpusNamespace]?.index; + if (!namespaceIndexPath) { + cachedCorpusReactFlowApi = null; + return cachedCorpusReactFlowApi; + } + + const namespaceRaw = readFileSync(join(corpusRoot, namespaceIndexPath), "utf8"); + const namespaceIndex = YAML.parse(namespaceRaw) as CorpusNamespaceIndex | null; + const apiPath = namespaceIndex?.apis?.reactflow?.file; + if (!apiPath) { + cachedCorpusReactFlowApi = null; + return cachedCorpusReactFlowApi; + } + + const apiRaw = readFileSync(join(corpusRoot, "frontend/reactflow", apiPath), "utf8"); + const api = YAML.parse(apiRaw) as CorpusApiEntry | null; + if ( + !api || + normalizeApiName(api.name ?? "") !== normalizeApiName(corpusApiName) || + !api.usage || + !api.importPath || + !api.description || + !api.kind + ) { + cachedCorpusReactFlowApi = null; + return cachedCorpusReactFlowApi; + } + + cachedCorpusReactFlowApi = { + api: { + name: api.name ?? corpusApiName, + kind: api.kind as Parameters[0]["kind"], + description: api.description, + importPath: api.importPath, + props: api.props, + returns: api.returns, + usage: api.usage, + examples: (api.examples ?? []) + .filter((ex): ex is { title: string; code: string; description?: string; category?: ReactFlowExample["category"] } => + Boolean(ex?.title && ex?.code), + ) + .map((ex) => ({ + title: ex.title, + code: ex.code, + description: ex.description, + category: ex.category ?? "quickstart", + })), + tips: api.tips ?? [], + relatedApis: api.relatedApis ?? [], + }, + source: corpusNamespace, + }; + return cachedCorpusReactFlowApi ?? null; + } catch { + cachedCorpusReactFlowApi = null; + return null; + } +} + +function getReactFlowApi(name: string) { + if (normalizeApiName(name) !== normalizeApiName(corpusApiName)) { + return getApiByName(name, ALL_APIS); + } + + const corpusEntry = loadCorpusReactFlowApi(); + if (corpusEntry) { + return corpusEntry.api; + } + + return getApiByName(name, ALL_APIS); +} + export function register(server: McpServer): void { server.tool( "reactflow_get_api", @@ -14,7 +140,8 @@ export function register(server: McpServer): void { ), }, async ({ name }) => { - const api = getApiByName(name, ALL_APIS); + const corpusEntry = normalizeApiName(name) === normalizeApiName(corpusApiName) ? loadCorpusReactFlowApi() : null; + const api = getReactFlowApi(name); if (!api) { const suggestions = searchApis(name, ALL_APIS) .slice(0, 5) @@ -29,7 +156,15 @@ export function register(server: McpServer): void { isError: true, }; } - return { content: [{ type: "text", text: formatApiReference(api) }] }; + const rendered = formatApiReference(api); + return { + content: [ + { + type: "text", + text: corpusEntry ? `${rendered}\n**Corpus Source:** ${corpusEntry.source}` : rendered, + }, + ], + }; }, ); } diff --git a/tests/design-tokens-procedure-corpus-backed-tools-behaviour.test.ts b/tests/design-tokens-procedure-corpus-backed-tools-behaviour.test.ts new file mode 100644 index 0000000..82ff33e --- /dev/null +++ b/tests/design-tokens-procedure-corpus-backed-tools-behaviour.test.ts @@ -0,0 +1,86 @@ +import { expect, test } from "bun:test"; +import { captureTool, extractTextContent } from "./helpers"; +import { register as registerDesignTokensGetProcedure } from "../src/plugins/design-tokens/tools/get-procedure.ts"; + +const designTokensGetProcedure = captureTool(registerDesignTokensGetProcedure); + +test("design_tokens_get_procedure prefers corpus metadata for step 1", async () => { + const result = await designTokensGetProcedure.invoke({ step: 1 }); + const text = extractTextContent(result); + + expect(text).toContain("# Step 1: Define color ramp primitives in @theme"); + expect(text).toContain("**Corpus Source:** frontend.design-tokens"); + expect(text).toContain("--color-brand-50: oklch(0.98 0.01 291);"); +}); + +test("design_tokens_get_procedure prefers corpus metadata for step 2", async () => { + const result = await designTokensGetProcedure.invoke({ step: 2 }); + const text = extractTextContent(result); + + expect(text).toContain("# Step 2: Map semantic tokens in :root and .dark"); + expect(text).toContain("**Corpus Source:** frontend.design-tokens"); + expect(text).toContain("--color-bg: var(--color-neutral-50);"); +}); + +test("design_tokens_get_procedure prefers corpus metadata for step 3", async () => { + const result = await designTokensGetProcedure.invoke({ step: 3 }); + const text = extractTextContent(result); + + expect(text).toContain("# Step 3: Bridge to Tailwind v4 utilities with @theme inline"); + expect(text).toContain("**Corpus Source:** frontend.design-tokens"); + expect(text).toContain("--color-background: var(--color-bg);"); +}); + +test("design_tokens_get_procedure prefers corpus metadata for step 4", async () => { + const result = await designTokensGetProcedure.invoke({ step: 4 }); + const text = extractTextContent(result); + + expect(text).toContain("# Step 4: Define spacing system"); + expect(text).toContain("**Corpus Source:** frontend.design-tokens"); + expect(text).toContain("--spacing: 0.25rem;"); +}); + +test("design_tokens_get_procedure prefers corpus metadata for step 5", async () => { + const result = await designTokensGetProcedure.invoke({ step: 5 }); + const text = extractTextContent(result); + + expect(text).toContain("# Step 5: Set up typography scale"); + expect(text).toContain("**Corpus Source:** frontend.design-tokens"); + expect(text).toContain("--font-sans:"); +}); + +test("design_tokens_get_procedure prefers corpus metadata for step 6", async () => { + const result = await designTokensGetProcedure.invoke({ step: 6 }); + const text = extractTextContent(result); + + expect(text).toContain("# Step 6: Define shadows and elevation"); + expect(text).toContain("**Corpus Source:** frontend.design-tokens"); + expect(text).toContain("--shadow-xs: 0 1px 2px 0 oklch(0.30 0.02 60 / 0.08);"); +}); + +test("design_tokens_get_procedure prefers corpus metadata for step 7", async () => { + const result = await designTokensGetProcedure.invoke({ step: 7 }); + const text = extractTextContent(result); + + expect(text).toContain("# Step 7: Define motion and z-index tokens"); + expect(text).toContain("**Corpus Source:** frontend.design-tokens"); + expect(text).toContain("--duration-fast: 100ms;"); +}); + +test("design_tokens_get_procedure prefers corpus metadata for step 8", async () => { + const result = await designTokensGetProcedure.invoke({ step: 8 }); + const text = extractTextContent(result); + + expect(text).toContain("# Step 8: Run contrast audit and verify token usage"); + expect(text).toContain("**Corpus Source:** frontend.design-tokens"); + expect(text).toContain("--color-text on --color-bg"); +}); + +test("design_tokens_get_procedure summary includes corpus source when corpus metadata is present", async () => { + const result = await designTokensGetProcedure.invoke({}); + const text = extractTextContent(result); + + expect(text).toContain("# Design Token Build Procedure"); + expect(text).toContain("**Corpus Source:** frontend.design-tokens"); + expect(text).toContain("## Step 1: Define color ramp primitives in @theme"); +}); diff --git a/tests/designer-page-template-corpus-backed-tools-behaviour.test.ts b/tests/designer-page-template-corpus-backed-tools-behaviour.test.ts new file mode 100644 index 0000000..3524c32 --- /dev/null +++ b/tests/designer-page-template-corpus-backed-tools-behaviour.test.ts @@ -0,0 +1,25 @@ +import { expect, test } from "bun:test"; +import { captureTool, extractTextContent } from "./helpers"; +import { register as registerDesignerPageTemplate } from "../src/plugins/designer/tools/get-page-template.ts"; + +const designerGetPageTemplate = captureTool(registerDesignerPageTemplate); + +test("designer_get_page_template prefers corpus metadata for dashboard", async () => { + const result = await designerGetPageTemplate.invoke({ type: "dashboard" }); + const text = extractTextContent(result); + + expect(text).toContain("# Page Template: dashboard"); + expect(text).toContain("**Corpus Source:** frontend.designer"); + expect(text).toContain("## Sections"); + expect(text).toContain("Sidebar Navigation"); +}); + +test("designer_get_page_template falls back to in-file data for non-corpus templates", async () => { + const result = await designerGetPageTemplate.invoke({ type: "auth" }); + const text = extractTextContent(result); + + expect(text).toContain("# Page Template: auth"); + expect(text).toContain("## Sections"); + expect(text).toContain("Login"); + expect(text).not.toContain("**Corpus Source:** frontend.designer"); +}); diff --git a/tests/lenis-pattern-corpus-backed-tools-behaviour.test.ts b/tests/lenis-pattern-corpus-backed-tools-behaviour.test.ts new file mode 100644 index 0000000..a039f55 --- /dev/null +++ b/tests/lenis-pattern-corpus-backed-tools-behaviour.test.ts @@ -0,0 +1,23 @@ +import { expect, test } from "bun:test"; +import { captureTool, extractTextContent } from "./helpers"; +import { register as registerLenisGetPattern } from "../src/plugins/lenis/tools/get-pattern.ts"; + +const lenisGetPattern = captureTool(registerLenisGetPattern); + +test("lenis_get_pattern prefers corpus metadata for gsap-integration", async () => { + const result = await lenisGetPattern.invoke({ name: "gsap-integration" }); + const text = extractTextContent(result); + + expect(text).toContain("# Lenis Pattern: gsap-integration"); + expect(text).toContain("**Corpus Source:** frontend.lenis"); + expect(text).toContain('import { ReactLenis, useLenis } from "lenis/react"'); +}); + +test("lenis_get_pattern falls back to in-file data for non-corpus patterns", async () => { + const result = await lenisGetPattern.invoke({ name: "accessibility" }); + const text = extractTextContent(result); + + expect(text).toContain("# Lenis Pattern: accessibility"); + expect(text).toContain("## Key Notes"); + expect(text).not.toContain("**Corpus Source:** frontend.lenis"); +}); diff --git a/tests/motion-corpus-backed-tools-behaviour.test.ts b/tests/motion-corpus-backed-tools-behaviour.test.ts new file mode 100644 index 0000000..a48baec --- /dev/null +++ b/tests/motion-corpus-backed-tools-behaviour.test.ts @@ -0,0 +1,23 @@ +import { test, expect } from "bun:test"; +import { captureTool, extractTextContent } from "./helpers"; +import { register as registerMotionApi } from "../src/plugins/motion/tools/get-api.ts"; + +const motionGetApi = captureTool(registerMotionApi); + +test("motion_get_api prefers corpus metadata for motion", async () => { + const result = await motionGetApi.invoke({ name: "motion" }); + const text = extractTextContent(result); + + expect(text).toContain("# motion"); + expect(text).toContain("**Corpus Source:** frontend.motion"); + expect(text).toContain('import { motion } 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: "AnimatePresence" }); + const text = extractTextContent(result); + + expect(text).toContain("# AnimatePresence"); + expect(text).toContain("**Kind:** component"); + expect(text).not.toContain("**Corpus Source:** frontend.motion"); +}); diff --git a/tests/motion-examples-corpus-backed-tools-behaviour.test.ts b/tests/motion-examples-corpus-backed-tools-behaviour.test.ts new file mode 100644 index 0000000..5f0aa75 --- /dev/null +++ b/tests/motion-examples-corpus-backed-tools-behaviour.test.ts @@ -0,0 +1,24 @@ +import { expect, test } from "bun:test"; +import { captureTool, extractTextContent } from "./helpers"; +import { register as registerMotionExamples } from "../src/plugins/motion/tools/get-examples.ts"; + +const motionGetExamples = captureTool(registerMotionExamples); + +test("motion_get_examples prefers corpus metadata for scroll", async () => { + const result = await motionGetExamples.invoke({ category: "scroll" }); + const text = extractTextContent(result); + + expect(text).toContain("# Scroll Examples"); + expect(text).toContain("Scroll progress rail"); + expect(text).toContain("**Corpus Source:** frontend.motion"); + expect(text).toContain('import { motion, useScroll, useSpring } from "motion/react"'); +}); + +test("motion_get_examples falls back to in-file data for layout", async () => { + const result = await motionGetExamples.invoke({ category: "layout" }); + const text = extractTextContent(result); + + expect(text).toContain("# Layout Examples"); + expect(text).toContain("Layout animation"); + expect(text).not.toContain("**Corpus Source:** frontend.motion"); +}); diff --git a/tests/reactflow-corpus-backed-tools-behaviour.test.ts b/tests/reactflow-corpus-backed-tools-behaviour.test.ts new file mode 100644 index 0000000..491d417 --- /dev/null +++ b/tests/reactflow-corpus-backed-tools-behaviour.test.ts @@ -0,0 +1,23 @@ +import { expect, test } from "bun:test"; +import { captureTool, extractTextContent } from "./helpers"; +import { register as registerReactFlowApi } from "../src/plugins/reactflow/tools/get-api.ts"; + +const reactFlowGetApi = captureTool(registerReactFlowApi); + +test("reactflow_get_api prefers corpus metadata for ReactFlow", async () => { + const result = await reactFlowGetApi.invoke({ name: "ReactFlow" }); + const text = extractTextContent(result); + + expect(text).toContain("# ReactFlow (component)"); + expect(text).toContain("**Corpus Source:** frontend.reactflow"); + expect(text).toContain("import { ReactFlow } from '@xyflow/react'"); +}); + +test("reactflow_get_api falls back to in-file data for non-corpus APIs", async () => { + const result = await reactFlowGetApi.invoke({ name: "Handle" }); + const text = extractTextContent(result); + + expect(text).toContain("# Handle (component)"); + expect(text).toContain("import { Handle, Position } from '@xyflow/react'"); + expect(text).not.toContain("**Corpus Source:** frontend.reactflow"); +});