diff --git a/.claude/skills/ui-ux-pro-max/SKILL.md b/.claude/skills/ui-ux-pro-max/SKILL.md index 08a354b..307a219 100644 --- a/.claude/skills/ui-ux-pro-max/SKILL.md +++ b/.claude/skills/ui-ux-pro-max/SKILL.md @@ -413,6 +413,29 @@ If not, use the Master rules exclusively. Now, generate the code... ``` +### Step 2c: Design Dials (optional) + +Three optional 1-10 sliders that tune `--design-system` output without changing your query. Add any combination of them to the same command: + +```bash +python3 skills/ui-ux-pro-max/scripts/search.py "" --design-system --variance <1-10> --motion <1-10> --density <1-10> +``` + +| Dial | Low (1-3) | Mid (4-7) | High (8-10) | +|------|-----------|-----------|-------------| +| `--variance` | Centered / minimal (biases toward Minimalism-style categories) | Balanced / modern | Bold / asymmetric (biases toward Brutalism, Bento Grids) | +| `--motion` | Subtle micro-interactions | Standard scroll/stagger motion | Complex choreography (pin, Flip, SplitText) | +| `--density` | Spacious (24-96px spacing scale) | Standard (16-64px, current default) | Dense/dashboard (8-32px spacing scale) | + +- `--motion` attaches a ready-to-use GSAP snippet (with framework notes, Do/Don't, and performance notes) pulled from `--domain motion`, matched to the resolved tier (Subtle/Standard/Complex). +- `--density` overrides the `--space-*` CSS variable table in the ASCII/markdown/MASTER.md output — use it for dashboards (high) vs. marketing pages (low) without hand-editing tokens. +- Leaving a dial unset keeps that part of the output exactly as it was before (no behavior change). + +**Example:** +```bash +python3 skills/ui-ux-pro-max/scripts/search.py "internal analytics dashboard" --design-system --variance 8 --motion 7 --density 8 -p "Ops Console" +``` + ### Step 3: Supplement with Detailed Searches (as needed) After getting the design system, use domain searches to get additional details: @@ -463,6 +486,7 @@ python3 skills/ui-ux-pro-max/scripts/search.py "" --stack | `landing` | Page structure, CTA strategies | hero, hero-centric, testimonial, pricing, social-proof | | `chart` | Chart types, library recommendations | trend, comparison, timeline, funnel, pie | | `ux` | Best practices, anti-patterns | animation, accessibility, z-index, loading | +| `motion` | GSAP animation skeletons by intensity tier | scroll reveal, stagger, magnetic cursor, page transition | | `google-fonts` | Individual Google Fonts lookup | sans serif, monospace, japanese, variable font, popular | | `react` | React/Next.js performance | waterfall, bundle, suspense, memo, rerender, cache | | `web` | App interface guidelines (iOS/Android/React Native) | accessibilityLabel, touch targets, safe areas, Dynamic Type | diff --git a/.claude/skills/ui-ux-pro-max/data/motion.csv b/.claude/skills/ui-ux-pro-max/data/motion.csv new file mode 100644 index 0000000..e5555fe --- /dev/null +++ b/.claude/skills/ui-ux-pro-max/data/motion.csv @@ -0,0 +1,17 @@ +No,Category,Intensity Tier,Keywords,Trigger,Duration,Easing,GSAP Snippet,Framework Notes,Do,Don't,Performance Notes +1,Hover Micro-interaction,Subtle,"hover, button, opacity, lift, press feedback",hover,150-200ms,power1.out,"gsap.to(el, { y: -1, opacity: 0.9, duration: 0.15, ease: 'power1.out' });",Bind on mouseenter/mouseleave; in React wrap in a ref + useEffect (or onMouseEnter/onMouseLeave props directly calling gsap.to),Keep displacement under 2px so it reads as feedback not motion,Don't animate layout-affecting props (width/height/margin) on hover,Runs on transform/opacity only so it stays on the compositor thread +2,Hover Micro-interaction,Standard,"hover, card, scale, tilt, cursor feedback",hover,200-300ms,power2.out,"gsap.to(el, { y: -4, scale: 1.02, boxShadow: '0 12px 24px rgba(0,0,0,0.12)', duration: 0.25, ease: 'power2.out' });","Use gsap.quickTo(el, 'y') for cards with many hover targets to avoid re-creating tweens every event",Pair with a matching mouseleave tween that reverses the same properties,Don't leave the hover state stuck if the pointer leaves fast; always attach the reverse tween,quickTo() avoids GC churn on lists with 20+ hoverable cards +3,Hover Micro-interaction,Complex,"hover, magnetic, cursor follow, 3d tilt",hover + mousemove,300-500ms,"elastic.out(1,0.4)","const xTo = gsap.quickTo(el, 'x', { duration: 0.4, ease: 'elastic.out(1,0.4)' }); const yTo = gsap.quickTo(el, 'y', { duration: 0.4, ease: 'elastic.out(1,0.4)' }); el.addEventListener('mousemove', (e) => { const r = el.getBoundingClientRect(); xTo((e.clientX - r.left - r.width/2) * 0.3); yTo((e.clientY - r.top - r.height/2) * 0.3); });",Debounce is not needed since quickTo interpolates; remove listeners on component unmount in React/Vue to avoid leaks,Clamp the pull strength (e.g. * 0.3) so the element never fully leaves its hit box,Don't apply magnetic effect to more than 1-2 focal elements per screen; it becomes noisy,Use will-change: transform on the target element for smoother compositing +4,Scroll Reveal,Subtle,"scroll, fade in, reveal, on view",scroll (viewport enter),300-400ms,power1.out,"gsap.from(el, { opacity: 0, y: 12, duration: 0.35, ease: 'power1.out', scrollTrigger: { trigger: el, start: 'top 90%', toggleActions: 'play none none reverse' } });",Requires the ScrollTrigger plugin registered once via gsap.registerPlugin(ScrollTrigger),"Keep the y offset small (8-16px) so it reads as a fade, not a slide",Don't reveal below-the-fold content needed for SEO/crawlers as invisible-by-default without a no-JS fallback,toggleActions 'play none none reverse' avoids re-triggering on every scroll direction change +5,Scroll Reveal,Standard,"scroll, slide up, staggered section, reveal",scroll (viewport enter),400-600ms,power2.out,"gsap.from(el.children, { opacity: 0, y: 24, duration: 0.5, stagger: 0.08, ease: 'power2.out', scrollTrigger: { trigger: el, start: 'top 85%' } });","In React use useGSAP(() => {...}, { scope: containerRef }) from @gsap/react to auto-cleanup on unmount",Scope the ScrollTrigger to the section container so it doesn't re-scan the whole page,Don't stagger more than ~8 children; beyond that the last items feel laggy,Set scroller/markers: false in production; markers is dev-only +6,Scroll Reveal,Complex,"scroll, pin, scrub, storytelling, scrollytelling",scroll (continuous scrub),tied to scroll position,none (scrub-driven),"gsap.timeline({ scrollTrigger: { trigger: section, start: 'top top', end: '+=150%', scrub: 1, pin: true } }).from('.headline', { opacity: 0, y: 40 }).to('.bg-layer', { yPercent: -20 }, '<');",Pinning needs the section to have deterministic height; recalc ScrollTrigger.refresh() after images/fonts load,Use scrub: true or a small number (0.5-1.5) instead of instant jumps so it feels tied to the scrollbar,Don't pin more than 1-2 sections per page; excessive pinning fights native scroll feel and hurts mobile UX,"Pinning forces layout reflow; test on mid-tier mobile devices, not just desktop" +7,Stagger List,Subtle,"list, stagger, cards, grid entrance",load or scroll,250-350ms,power1.out,"gsap.from('.list-item', { opacity: 0, y: 8, duration: 0.3, stagger: 0.03 });",Select items with a stable class/data-attribute (not array index) so re-renders in React don't break targeting,Keep per-item stagger delay small (0.02-0.04s) for lists longer than 10 items,Don't stagger by more than 0.1s per item on long lists; total reveal time becomes sluggish,"For virtualized lists, only animate items currently mounted in the DOM" +8,Stagger List,Standard,"grid, bento, cards, staggered scale",load or scroll,300-450ms,back.out(1.4),"gsap.from('.grid-item', { opacity: 0, scale: 0.92, y: 16, duration: 0.4, stagger: { each: 0.06, from: 'start', grid: 'auto' }, ease: 'back.out(1.4)' });",grid: 'auto' lets GSAP infer rows/columns from a CSS grid layout for a natural wave stagger,Combine with from: 'center' for a bento-grid layout to draw the eye inward first,Don't use back.out on dense data tables; the overshoot reads as sloppy on informational UI,Group DOM writes; avoid interleaving layout reads (getBoundingClientRect) between staggered tweens +9,Stagger List,Complex,"stagger, wave, text reveal, split text",load or scroll,400-700ms,expo.out,"const split = new SplitText(headline, { type: 'chars' }); gsap.from(split.chars, { opacity: 0, y: 20, rotateX: -40, duration: 0.6, stagger: 0.015, ease: 'expo.out' });",SplitText is a GSAP Club/paid plugin; confirm license before shipping and provide a plain fade fallback if unavailable,Revert SplitText on unmount/cleanup (split.revert()) to restore original text nodes for accessibility tools,Don't split-animate long paragraphs; reserve for short headlines (under ~8 words),Splitting text creates one element per character; keep it to headline-length copy only for DOM size +10,Page Transition,Subtle,"route change, fade, page transition",route change,200-300ms,power1.inOut,"gsap.to(main, { opacity: 0, duration: 0.2, onComplete: () => { navigate(); gsap.fromTo(main, { opacity: 0 }, { opacity: 1, duration: 0.2 }); } });","Pair with the router's transition hooks (Next.js App Router transitions, React Router's useNavigate, Vue Router's beforeEach/afterEach)",Preload the destination route's critical assets before the exit tween finishes,Don't block navigation on animation; cap exit duration at ~250ms so the app never feels unresponsive,Exit animation should always resolve faster than entrance (asymmetric timing) so back/forward feels snappy +11,Page Transition,Standard,"route change, slide, overlay wipe",route change,400-600ms,power2.inOut,"const tl = gsap.timeline(); tl.to('.transition-overlay', { yPercent: 0, duration: 0.4, ease: 'power2.inOut' }).call(navigate).to('.transition-overlay', { yPercent: -100, duration: 0.4, ease: 'power2.inOut', delay: 0.1 });",Keep the overlay element mounted at the layout root (outside the page component) so it survives the route swap,Show a lightweight loading indicator if the destination route's data fetch outlasts the overlay,Don't tie the overlay's reveal directly to data-fetch completion without a max-wait timeout; a slow API stalls the whole transition,Prefer CSS transform (yPercent) over top/left to keep the overlay animation on the compositor thread +12,Page Transition,Complex,"shared element, morph, hero transition",route change,500-800ms,expo.inOut,"const state = Flip.getState('.hero-image'); navigate(); Flip.from(state, { duration: 0.6, ease: 'expo.inOut', absolute: true, zIndex: 100 });",Requires the GSAP Flip plugin; the 'from' and 'to' route must render the same element with a shared data-flip-id,Verify the shared element exists in both DOM states before calling Flip.from to avoid a silent no-op,Don't use shared-element transitions across more than one element pair per navigation; compounding Flips are hard to time correctly,Flip recalculates layout (FLIP technique) so test on low-end devices for jank +13,Parallax Scroll,Subtle,"parallax, background, depth, scroll speed",scroll (continuous),tied to scroll position,linear (scrub),"gsap.to('.bg-layer', { yPercent: 10, ease: 'none', scrollTrigger: { trigger: section, scrub: true } });","Apply parallax to background/decorative layers only, never to text or interactive controls",Keep the yPercent delta small (5-15) so foreground and background never desync distractingly,Don't parallax body copy; it hurts reading comfort and can trigger motion sickness,will-change: transform on the parallax layer only; remove it after scroll settles to free GPU memory +14,Parallax Scroll,Standard,"multi-layer parallax, depth, hero background",scroll (continuous),tied to scroll position,linear (scrub),"gsap.utils.toArray('.parallax-layer').forEach((layer, i) => { gsap.to(layer, { yPercent: (i + 1) * -8, ease: 'none', scrollTrigger: { trigger: layer.parentElement, scrub: 0.5 } }); });",Layer count beyond 3-4 has diminishing visual return and multiplies scroll-listener cost,"Vary speed per layer (background slowest, foreground fastest) to sell the depth illusion",Don't let parallax layers overflow their container; clip with overflow: hidden on the wrapper,Batch all layers under one ScrollTrigger container where possible instead of one per layer +15,Loading / Skeleton,Subtle,"loading, skeleton, shimmer, pulse",on mount / async wait,1200-1600ms loop,sine.inOut,"gsap.to('.skeleton', { backgroundPosition: '200% 0', duration: 1.4, ease: 'sine.inOut', repeat: -1 });",Kill the loop tween (tween.kill()) as soon as real content mounts to avoid orphaned repeating animations,Use a CSS gradient background-position sweep rather than opacity pulsing; reads as 'loading' more clearly,Don't run more than one shimmer loop per skeleton group; sync them under one timeline so the wave reads as a single unit,repeat: -1 tweens are cheap but must be explicitly killed on unmount or they leak in SPA route changes +16,Loading / Skeleton,Standard,"progress, spinner, morphing loader",on mount / async wait,800-1200ms loop,power1.inOut,"gsap.timeline({ repeat: -1 }).to('.loader-dot', { y: -8, duration: 0.4, stagger: { each: 0.15, yoyo: true, repeat: 1 } });",Wrap the whole loop timeline in useGSAP with { revertOnUpdate: false } in React so it isn't rebuilt every render,Cap total loop duration under ~1.5s so long waits don't feel like the UI froze on a single beat,Don't use elaborate loaders for sub-300ms waits; they flash and feel worse than no indicator,Pause the timeline (tl.pause()) when the loading tab/view is not visible to save CPU on background tabs diff --git a/.claude/skills/ui-ux-pro-max/scripts/core.py b/.claude/skills/ui-ux-pro-max/scripts/core.py index 8d02065..c4b28db 100755 --- a/.claude/skills/ui-ux-pro-max/scripts/core.py +++ b/.claude/skills/ui-ux-pro-max/scripts/core.py @@ -55,6 +55,11 @@ CSV_CONFIG = { "search_cols": ["Category", "Icon Name", "Keywords", "Best For"], "output_cols": ["Category", "Icon Name", "Keywords", "Library", "Import Code", "Usage", "Best For", "Style"] }, + "motion": { + "file": "motion.csv", + "search_cols": ["Category", "Intensity Tier", "Keywords", "Trigger"], + "output_cols": ["Category", "Intensity Tier", "Trigger", "Duration", "Easing", "GSAP Snippet", "Framework Notes", "Do", "Don't", "Performance Notes"] + }, "react": { "file": "react-performance.csv", "search_cols": ["Category", "Issue", "Keywords", "Description"], @@ -89,6 +94,12 @@ STACK_CONFIG = { "threejs": {"file": "stacks/threejs.csv"}, "angular": {"file": "stacks/angular.csv"}, "laravel": {"file": "stacks/laravel.csv"}, + "javafx": {"file": "stacks/javafx.csv"}, + "wpf": {"file": "stacks/wpf.csv"}, + "winui": {"file": "stacks/winui.csv"}, + "avalonia": {"file": "stacks/avalonia.csv"}, + "uno": {"file": "stacks/uno.csv"}, + "uwp": {"file": "stacks/uwp.csv"}, } # Common columns for all stacks @@ -117,7 +128,7 @@ class BM25: def tokenize(self, text): """Lowercase, split, remove punctuation, filter short words""" text = re.sub(r'[^\w\s]', ' ', str(text).lower()) - return [w for w in text.split() if len(w) > 2] + return [w for w in text.split() if len(w) >= 2] def fit(self, documents): """Build BM25 index from documents""" @@ -209,6 +220,7 @@ def detect_domain(query): "typography": ["font pairing", "typography pairing", "heading font", "body font"], "google-fonts": ["google font", "font family", "font weight", "font style", "variable font", "noto", "font for", "find font", "font subset", "font language", "monospace font", "serif font", "sans serif font", "display font", "handwriting font", "font", "typography", "serif", "sans"], "icons": ["icon", "icons", "lucide", "heroicons", "symbol", "glyph", "pictogram", "svg icon"], + "motion": ["gsap", "motion intensity", "stagger", "magnetic cursor", "parallax", "page transition", "scroll reveal", "scroll-triggered", "scrollytelling", "flip plugin", "splittext", "shimmer", "skeleton loader"], "react": ["react", "next.js", "nextjs", "suspense", "memo", "usecallback", "useeffect", "rerender", "bundle", "waterfall", "barrel", "dynamic import", "rsc", "server component"], "web": ["aria", "focus", "outline", "semantic", "virtualize", "autocomplete", "form", "input type", "preconnect"] } diff --git a/.claude/skills/ui-ux-pro-max/scripts/design_system.py b/.claude/skills/ui-ux-pro-max/scripts/design_system.py index d3152e5..08db85d 100644 --- a/.claude/skills/ui-ux-pro-max/scripts/design_system.py +++ b/.claude/skills/ui-ux-pro-max/scripts/design_system.py @@ -16,10 +16,18 @@ Usage: import csv import json import os +import sys +import io from datetime import datetime from pathlib import Path from core import search, DATA_DIR +# Force UTF-8 for stdout/stderr to handle emojis/box-drawing chars on Windows (cp1252 default) +if sys.stdout.encoding and sys.stdout.encoding.lower() != 'utf-8': + sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8') +if sys.stderr.encoding and sys.stderr.encoding.lower() != 'utf-8': + sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding='utf-8') + # ============ CONFIGURATION ============ REASONING_FILE = "ui-reasoning.csv" @@ -32,6 +40,39 @@ SEARCH_CONFIG = { "typography": {"max_results": 2} } +# ============ DESIGN DIALS (1-10) ============ +# Inspired by taste-skill's DESIGN_VARIANCE / MOTION_INTENSITY / VISUAL_DENSITY +# knobs: three optional 1-10 sliders that bias the existing query-based search +# instead of replacing it. Each dial buckets into a low/mid/high tier. +DIAL_TIERS = { + "variance": [ + (1, 3, {"label": "Centered / Minimal", "style_keywords": ["Minimalism", "Exaggerated Minimalism", "centered", "symmetric", "grid-based"]}), + (4, 7, {"label": "Balanced / Modern", "style_keywords": ["modern", "structured", "balanced"]}), + (8, 10, {"label": "Bold / Asymmetric", "style_keywords": ["Brutalism", "Bento Grids", "asymmetric", "experimental"]}), + ], + "motion": [ + (1, 3, {"label": "Subtle", "tier": "Subtle"}), + (4, 7, {"label": "Standard", "tier": "Standard"}), + (8, 10, {"label": "Complex", "tier": "Complex"}), + ], + "density": [ + (1, 3, {"label": "Spacious", "spacing": {"xs": "4px", "sm": "8px", "md": "24px", "lg": "32px", "xl": "48px", "2xl": "64px", "3xl": "96px"}}), + (4, 7, {"label": "Standard", "spacing": {"xs": "4px", "sm": "8px", "md": "16px", "lg": "24px", "xl": "32px", "2xl": "48px", "3xl": "64px"}}), + (8, 10, {"label": "Dense / Dashboard", "spacing": {"xs": "2px", "sm": "4px", "md": "8px", "lg": "12px", "xl": "16px", "2xl": "24px", "3xl": "32px"}}), + ], +} + + +def _resolve_dial(dial_name: str, value) -> dict: + """Bucket a 1-10 dial value into its tier config. Returns None if value is None.""" + if value is None: + return None + value = max(1, min(10, int(value))) + for lo, hi, info in DIAL_TIERS[dial_name]: + if lo <= value <= hi: + return {**info, "value": value} + return None + # ============ DESIGN SYSTEM GENERATOR ============ class DesignSystemGenerator: @@ -160,8 +201,18 @@ class DesignSystemGenerator: """Extract results list from search result dict.""" return search_result.get("results", []) - def generate(self, query: str, project_name: str = None) -> dict: - """Generate complete design system recommendation.""" + def generate(self, query: str, project_name: str = None, + variance: int = None, motion: int = None, density: int = None) -> dict: + """Generate complete design system recommendation. + + variance/motion/density are optional 1-10 dials (see DIAL_TIERS) that bias + style selection, pull in a matching motion.csv snippet, and override the + spacing scale, without changing behavior when left unset. + """ + variance_info = _resolve_dial("variance", variance) + motion_info = _resolve_dial("motion", motion) + density_info = _resolve_dial("density", density) + # Step 1: First search product to get category product_result = search(query, "product", 1) product_results = product_result.get("results", []) @@ -173,8 +224,14 @@ class DesignSystemGenerator: reasoning = self._apply_reasoning(category, {}) style_priority = reasoning.get("style_priority", []) + # DESIGN_VARIANCE dial: bias style retrieval/selection toward + # centered-minimal (low) or bold-asymmetric (high) keywords. + effective_style_priority = style_priority + if variance_info: + effective_style_priority = variance_info["style_keywords"] + style_priority + # Step 3: Multi-domain search with style priority hints - search_results = self._multi_domain_search(query, style_priority) + search_results = self._multi_domain_search(query, effective_style_priority) search_results["product"] = product_result # Reuse product search # Step 4: Select best matches from each domain using priority @@ -183,11 +240,22 @@ class DesignSystemGenerator: typography_results = self._extract_results(search_results.get("typography", {})) landing_results = self._extract_results(search_results.get("landing", {})) - best_style = self._select_best_match(style_results, reasoning.get("style_priority", [])) + best_style = self._select_best_match(style_results, effective_style_priority) best_color = color_results[0] if color_results else {} best_typography = typography_results[0] if typography_results else {} best_landing = landing_results[0] if landing_results else {} + # MOTION_INTENSITY dial: pull a matching GSAP skeleton from motion.csv. + motion_snippet = {} + if motion_info: + motion_result = search(f"{query} {motion_info['tier']}", "motion", 5) + motion_matches = motion_result.get("results", []) + tiered = [m for m in motion_matches if m.get("Intensity Tier") == motion_info["tier"]] + if tiered: + motion_snippet = tiered[0] + elif motion_matches: + motion_snippet = motion_matches[0] + # Step 5: Build final recommendation # Combine effects from both reasoning and style search style_effects = best_style.get("Effects & Animation", "") @@ -242,7 +310,17 @@ class DesignSystemGenerator: "key_effects": combined_effects, "anti_patterns": reasoning.get("anti_patterns", ""), "decision_rules": reasoning.get("decision_rules", {}), - "severity": reasoning.get("severity", "MEDIUM") + "severity": reasoning.get("severity", "MEDIUM"), + "dials": { + "variance": variance_info["value"] if variance_info else None, + "variance_label": variance_info["label"] if variance_info else None, + "motion": motion_info["value"] if motion_info else None, + "motion_label": motion_info["label"] if motion_info else None, + "density": density_info["value"] if density_info else None, + "density_label": density_info["label"] if density_info else None, + }, + "motion_snippet": motion_snippet, + "spacing_scale": density_info["spacing"] if density_info else None, } @@ -288,6 +366,8 @@ def format_ascii_box(design_system: dict) -> str: typography = design_system.get("typography", {}) effects = design_system.get("key_effects", "") anti_patterns = design_system.get("anti_patterns", "") + dials = design_system.get("dials", {}) + motion_snippet = design_system.get("motion_snippet", {}) def wrap_text(text: str, prefix: str, width: int) -> list: """Wrap long text into multiple lines.""" @@ -321,6 +401,16 @@ def format_ascii_box(design_system: dict) -> str: lines.append("╚" + "═" * w + "╝") lines.append("┌" + "─" * w + "┐") + # Design Dials section (only if at least one dial was set) + if any(dials.get(k) is not None for k in ("variance", "motion", "density")): + lines.append(section_header("DESIGN DIALS", BOX_WIDTH + 1)) + if dials.get("variance") is not None: + lines.append(f"│ Variance: {dials['variance']}/10 — {dials['variance_label']}".ljust(BOX_WIDTH) + "│") + if dials.get("motion") is not None: + lines.append(f"│ Motion: {dials['motion']}/10 — {dials['motion_label']}".ljust(BOX_WIDTH) + "│") + if dials.get("density") is not None: + lines.append(f"│ Density: {dials['density']}/10 — {dials['density_label']}".ljust(BOX_WIDTH) + "│") + # Pattern section lines.append(section_header("PATTERN", BOX_WIDTH + 1)) lines.append(f"│ Name: {pattern.get('name', '')}".ljust(BOX_WIDTH) + "│") @@ -394,6 +484,17 @@ def format_ascii_box(design_system: dict) -> str: for line in wrap_text(effects, "│ ", BOX_WIDTH): lines.append(line.ljust(BOX_WIDTH) + "│") + # Motion section (GSAP skeleton, only if --motion dial was set) + if motion_snippet: + lines.append(section_header("MOTION", BOX_WIDTH + 1)) + lines.append(f"│ {motion_snippet.get('Category', '')} ({motion_snippet.get('Intensity Tier', '')})".ljust(BOX_WIDTH) + "│") + lines.append(f"│ Trigger: {motion_snippet.get('Trigger', '')} | Duration: {motion_snippet.get('Duration', '')} | Easing: {motion_snippet.get('Easing', '')}".ljust(BOX_WIDTH) + "│") + for line in wrap_text(f"GSAP: {motion_snippet.get('GSAP Snippet', '')}", "│ ", BOX_WIDTH): + lines.append(line.ljust(BOX_WIDTH) + "│") + if motion_snippet.get("Framework Notes"): + for line in wrap_text(f"Framework: {motion_snippet.get('Framework Notes', '')}", "│ ", BOX_WIDTH): + lines.append(line.ljust(BOX_WIDTH) + "│") + # Anti-patterns section if anti_patterns: lines.append(section_header("AVOID", BOX_WIDTH + 1)) @@ -428,11 +529,24 @@ def format_markdown(design_system: dict) -> str: typography = design_system.get("typography", {}) effects = design_system.get("key_effects", "") anti_patterns = design_system.get("anti_patterns", "") + dials = design_system.get("dials", {}) + motion_snippet = design_system.get("motion_snippet", {}) lines = [] lines.append(f"## Design System: {project}") lines.append("") + # Design Dials section (only if at least one dial was set) + if any(dials.get(k) is not None for k in ("variance", "motion", "density")): + lines.append("### Design Dials") + if dials.get("variance") is not None: + lines.append(f"- **Variance:** {dials['variance']}/10 — {dials['variance_label']}") + if dials.get("motion") is not None: + lines.append(f"- **Motion:** {dials['motion']}/10 — {dials['motion_label']}") + if dials.get("density") is not None: + lines.append(f"- **Density:** {dials['density']}/10 — {dials['density_label']}") + lines.append("") + # Pattern section lines.append("### Pattern") lines.append(f"- **Name:** {pattern.get('name', '')}") @@ -507,6 +621,23 @@ def format_markdown(design_system: dict) -> str: lines.append(f"{effects}") lines.append("") + # Motion section (GSAP skeleton, only if --motion dial was set) + if motion_snippet: + lines.append("### Motion") + lines.append(f"**{motion_snippet.get('Category', '')}** ({motion_snippet.get('Intensity Tier', '')}) — Trigger: {motion_snippet.get('Trigger', '')} | Duration: {motion_snippet.get('Duration', '')} | Easing: `{motion_snippet.get('Easing', '')}`") + lines.append("```js") + lines.append(motion_snippet.get("GSAP Snippet", "")) + lines.append("```") + if motion_snippet.get("Framework Notes"): + lines.append(f"*Framework notes: {motion_snippet.get('Framework Notes', '')}*") + motion_do = motion_snippet.get("Do", "") + motion_dont = motion_snippet.get("Don't", "") + if motion_do: + lines.append(f"- ✅ {motion_do}") + if motion_dont: + lines.append(f"- ❌ {motion_dont}") + lines.append("") + # Anti-patterns section if anti_patterns: lines.append("### Avoid (Anti-patterns)") @@ -529,8 +660,9 @@ def format_markdown(design_system: dict) -> str: # ============ MAIN ENTRY POINT ============ -def generate_design_system(query: str, project_name: str = None, output_format: str = "ascii", - persist: bool = False, page: str = None, output_dir: str = None) -> str: +def generate_design_system(query: str, project_name: str = None, output_format: str = "ascii", + persist: bool = False, page: str = None, output_dir: str = None, + variance: int = None, motion: int = None, density: int = None) -> str: """ Main entry point for design system generation. @@ -541,13 +673,16 @@ def generate_design_system(query: str, project_name: str = None, output_format: persist: If True, save design system to design-system/ folder page: Optional page name for page-specific override file output_dir: Optional output directory (defaults to current working directory) + variance: Optional 1-10 DESIGN_VARIANCE dial (1=centered/minimal, 10=bold/asymmetric) + motion: Optional 1-10 MOTION_INTENSITY dial, pulls a matching GSAP snippet from motion.csv + density: Optional 1-10 VISUAL_DENSITY dial, overrides the spacing scale (1=spacious, 10=dense) Returns: Formatted design system string """ generator = DesignSystemGenerator() - design_system = generator.generate(query, project_name) - + design_system = generator.generate(query, project_name, variance=variance, motion=motion, density=density) + # Persist to files if requested if persist: persist_design_system(design_system, page, output_dir, query) @@ -573,8 +708,9 @@ def persist_design_system(design_system: dict, page: str = None, output_dir: str """ base_dir = Path(output_dir) if output_dir else Path.cwd() - # Use project name for project-specific folder - project_name = design_system.get("project_name", "default") + # Use project name for project-specific folder. Coalesce falsy values + # (missing key, explicit None, or "") so the .lower() below can't crash. + project_name = design_system.get("project_name") or "default" project_slug = project_name.lower().replace(' ', '-') design_system_dir = base_dir / "design-system" / project_slug @@ -618,11 +754,14 @@ def format_master_md(design_system: dict) -> str: typography = design_system.get("typography", {}) effects = design_system.get("key_effects", "") anti_patterns = design_system.get("anti_patterns", "") - + dials = design_system.get("dials", {}) + motion_snippet = design_system.get("motion_snippet", {}) + spacing_scale = design_system.get("spacing_scale") + timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S") - + lines = [] - + # Logic header lines.append("# Design System Master File") lines.append("") @@ -635,6 +774,15 @@ def format_master_md(design_system: dict) -> str: lines.append(f"**Project:** {project}") lines.append(f"**Generated:** {timestamp}") lines.append(f"**Category:** {design_system.get('category', 'General')}") + if any(dials.get(k) is not None for k in ("variance", "motion", "density")): + dial_parts = [] + if dials.get("variance") is not None: + dial_parts.append(f"Variance {dials['variance']}/10 ({dials['variance_label']})") + if dials.get("motion") is not None: + dial_parts.append(f"Motion {dials['motion']}/10 ({dials['motion_label']})") + if dials.get("density") is not None: + dial_parts.append(f"Density {dials['density']}/10 ({dials['density_label']})") + lines.append(f"**Design Dials:** {' | '.join(dial_parts)}") lines.append("") lines.append("---") lines.append("") @@ -686,18 +834,24 @@ def format_master_md(design_system: dict) -> str: lines.append("```") lines.append("") - # Spacing Variables + # Spacing Variables (overridden by the VISUAL_DENSITY dial when set) + default_spacing = DIAL_TIERS["density"][1][2]["spacing"] # mid-tier = the historical defaults + scale = spacing_scale or default_spacing + spacing_usage = { + "xs": "Tight gaps", "sm": "Icon gaps, inline spacing", "md": "Standard padding", + "lg": "Section padding", "xl": "Large gaps", "2xl": "Section margins", "3xl": "Hero padding", + } lines.append("### Spacing Variables") lines.append("") + if spacing_scale: + lines.append(f"*Density: {dials.get('density')}/10 — {dials.get('density_label')}*") + lines.append("") lines.append("| Token | Value | Usage |") lines.append("|-------|-------|-------|") - lines.append("| `--space-xs` | `4px` / `0.25rem` | Tight gaps |") - lines.append("| `--space-sm` | `8px` / `0.5rem` | Icon gaps, inline spacing |") - lines.append("| `--space-md` | `16px` / `1rem` | Standard padding |") - lines.append("| `--space-lg` | `24px` / `1.5rem` | Section padding |") - lines.append("| `--space-xl` | `32px` / `2rem` | Large gaps |") - lines.append("| `--space-2xl` | `48px` / `3rem` | Section margins |") - lines.append("| `--space-3xl` | `64px` / `4rem` | Hero padding |") + for token in ("xs", "sm", "md", "lg", "xl", "2xl", "3xl"): + px_value = scale[token] + rem_value = f"{int(px_value.rstrip('px')) / 16:g}rem" + lines.append(f"| `--space-{token}` | `{px_value}` / `{rem_value}` | {spacing_usage[token]} |") lines.append("") # Shadow Depths @@ -839,7 +993,32 @@ def format_master_md(design_system: dict) -> str: lines.append(f"- **CTA Placement:** {pattern.get('cta_placement', '')}") lines.append(f"- **Section Order:** {pattern.get('sections', '')}") lines.append("") - + + # Motion section (GSAP skeleton, only if --motion dial was set) + if motion_snippet: + lines.append("---") + lines.append("") + lines.append("## Motion") + lines.append("") + lines.append(f"**{motion_snippet.get('Category', '')}** ({motion_snippet.get('Intensity Tier', '')}) — Trigger: {motion_snippet.get('Trigger', '')} | Duration: {motion_snippet.get('Duration', '')} | Easing: `{motion_snippet.get('Easing', '')}`") + lines.append("") + lines.append("```js") + lines.append(motion_snippet.get("GSAP Snippet", "")) + lines.append("```") + lines.append("") + if motion_snippet.get("Framework Notes"): + lines.append(f"**Framework notes:** {motion_snippet.get('Framework Notes', '')}") + lines.append("") + motion_do = motion_snippet.get("Do", "") + motion_dont = motion_snippet.get("Don't", "") + if motion_do: + lines.append(f"- ✅ {motion_do}") + if motion_dont: + lines.append(f"- ❌ {motion_dont}") + if motion_snippet.get("Performance Notes"): + lines.append(f"- ⚡ {motion_snippet.get('Performance Notes', '')}") + lines.append("") + # Anti-Patterns section lines.append("---") lines.append("") diff --git a/.claude/skills/ui-ux-pro-max/scripts/search.py b/.claude/skills/ui-ux-pro-max/scripts/search.py index ee1e340..fc50d59 100644 --- a/.claude/skills/ui-ux-pro-max/scripts/search.py +++ b/.claude/skills/ui-ux-pro-max/scripts/search.py @@ -1,114 +1,127 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- -""" -UI/UX Pro Max Search - BM25 search engine for UI/UX style guides -Usage: python search.py "" [--domain ] [--stack ] [--max-results 3] - python search.py "" --design-system [-p "Project Name"] - python search.py "" --design-system --persist [-p "Project Name"] [--page "dashboard"] - -Domains: style, prompt, color, chart, landing, product, ux, typography, google-fonts -Stacks: react, nextjs, vue, svelte, astro, swiftui, react-native, flutter, nuxtjs, nuxt-ui, html-tailwind, shadcn, jetpack-compose, threejs - -Persistence (Master + Overrides pattern): - --persist Save design system to design-system/MASTER.md - --page Also create a page-specific override file in design-system/pages/ -""" - -import argparse -import sys -import io -from core import CSV_CONFIG, AVAILABLE_STACKS, MAX_RESULTS, search, search_stack -from design_system import generate_design_system, persist_design_system - -# Force UTF-8 for stdout/stderr to handle emojis on Windows (cp1252 default) -if sys.stdout.encoding and sys.stdout.encoding.lower() != 'utf-8': - sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8') -if sys.stderr.encoding and sys.stderr.encoding.lower() != 'utf-8': - sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding='utf-8') - - -def format_output(result): - """Format results for Claude consumption (token-optimized)""" - if "error" in result: - return f"Error: {result['error']}" - - output = [] - if result.get("stack"): - output.append(f"## UI Pro Max Stack Guidelines") - output.append(f"**Stack:** {result['stack']} | **Query:** {result['query']}") - else: - output.append(f"## UI Pro Max Search Results") - output.append(f"**Domain:** {result['domain']} | **Query:** {result['query']}") - output.append(f"**Source:** {result['file']} | **Found:** {result['count']} results\n") - - for i, row in enumerate(result['results'], 1): - output.append(f"### Result {i}") - for key, value in row.items(): - value_str = str(value) - if len(value_str) > 300: - value_str = value_str[:300] + "..." - output.append(f"- **{key}:** {value_str}") - output.append("") - - return "\n".join(output) - - -if __name__ == "__main__": - parser = argparse.ArgumentParser(description="UI Pro Max Search") - parser.add_argument("query", help="Search query") - parser.add_argument("--domain", "-d", choices=list(CSV_CONFIG.keys()), help="Search domain") - parser.add_argument("--stack", "-s", choices=AVAILABLE_STACKS, help=f"Stack-specific search. Available: {', '.join(AVAILABLE_STACKS)}") - parser.add_argument("--max-results", "-n", type=int, default=MAX_RESULTS, help="Max results (default: 3)") - parser.add_argument("--json", action="store_true", help="Output as JSON") - # Design system generation - parser.add_argument("--design-system", "-ds", action="store_true", help="Generate complete design system recommendation") - parser.add_argument("--project-name", "-p", type=str, default=None, help="Project name for design system output") - parser.add_argument("--format", "-f", choices=["ascii", "markdown"], default="ascii", help="Output format for design system") - # Persistence (Master + Overrides pattern) - parser.add_argument("--persist", action="store_true", help="Save design system to design-system/MASTER.md (creates hierarchical structure)") - parser.add_argument("--page", type=str, default=None, help="Create page-specific override file in design-system/pages/") - parser.add_argument("--output-dir", "-o", type=str, default=None, help="Output directory for persisted files (default: current directory)") - - args = parser.parse_args() - - # Design system takes priority - if args.design_system: - result = generate_design_system( - args.query, - args.project_name, - args.format, - persist=args.persist, - page=args.page, - output_dir=args.output_dir - ) - print(result) - - # Print persistence confirmation - if args.persist: - project_slug = args.project_name.lower().replace(' ', '-') if args.project_name else "default" - print("\n" + "=" * 60) - print(f"✅ Design system persisted to design-system/{project_slug}/") - print(f" 📄 design-system/{project_slug}/MASTER.md (Global Source of Truth)") - if args.page: - page_filename = args.page.lower().replace(' ', '-') - print(f" 📄 design-system/{project_slug}/pages/{page_filename}.md (Page Overrides)") - print("") - print(f"📖 Usage: When building a page, check design-system/{project_slug}/pages/[page].md first.") - print(f" If exists, its rules override MASTER.md. Otherwise, use MASTER.md.") - print("=" * 60) - # Stack search - elif args.stack: - result = search_stack(args.query, args.stack, args.max_results) - if args.json: - import json - print(json.dumps(result, indent=2, ensure_ascii=False)) - else: - print(format_output(result)) - # Domain search - else: - result = search(args.query, args.domain, args.max_results) - if args.json: - import json - print(json.dumps(result, indent=2, ensure_ascii=False)) - else: - print(format_output(result)) +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +UI/UX Pro Max Search - BM25 search engine for UI/UX style guides +Usage: python search.py "" [--domain ] [--stack ] [--max-results 3] + python search.py "" --design-system [-p "Project Name"] + python search.py "" --design-system --persist [-p "Project Name"] [--page "dashboard"] + python search.py "" --design-system --variance 8 --motion 9 --density 7 + +Domains: style, prompt, color, chart, landing, product, ux, typography, google-fonts, motion +Stacks: react, nextjs, vue, svelte, astro, swiftui, react-native, flutter, nuxtjs, nuxt-ui, html-tailwind, shadcn, jetpack-compose, threejs, angular, laravel, javafx, wpf, winui, avalonia, uno, uwp + +Design dials (1-10, only with --design-system): + --variance DESIGN_VARIANCE: 1=centered/minimal, 10=bold/asymmetric + --motion MOTION_INTENSITY: 1=subtle, 10=complex; attaches a GSAP snippet from motion.csv + --density VISUAL_DENSITY: 1=spacious, 10=dense/dashboard; overrides the spacing scale + +Persistence (Master + Overrides pattern): + --persist Save design system to design-system/MASTER.md + --page Also create a page-specific override file in design-system/pages/ +""" + +import argparse +import sys +import io +from core import CSV_CONFIG, AVAILABLE_STACKS, MAX_RESULTS, search, search_stack +from design_system import generate_design_system, persist_design_system + +# Force UTF-8 for stdout/stderr to handle emojis on Windows (cp1252 default) +if sys.stdout.encoding and sys.stdout.encoding.lower() != 'utf-8': + sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8') +if sys.stderr.encoding and sys.stderr.encoding.lower() != 'utf-8': + sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding='utf-8') + + +def format_output(result): + """Format results for Claude consumption (token-optimized)""" + if "error" in result: + return f"Error: {result['error']}" + + output = [] + if result.get("stack"): + output.append(f"## UI Pro Max Stack Guidelines") + output.append(f"**Stack:** {result['stack']} | **Query:** {result['query']}") + else: + output.append(f"## UI Pro Max Search Results") + output.append(f"**Domain:** {result['domain']} | **Query:** {result['query']}") + output.append(f"**Source:** {result['file']} | **Found:** {result['count']} results\n") + + for i, row in enumerate(result['results'], 1): + output.append(f"### Result {i}") + for key, value in row.items(): + value_str = str(value) + if len(value_str) > 300: + value_str = value_str[:300] + "..." + output.append(f"- **{key}:** {value_str}") + output.append("") + + return "\n".join(output) + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description="UI Pro Max Search") + parser.add_argument("query", help="Search query") + parser.add_argument("--domain", "-d", choices=list(CSV_CONFIG.keys()), help="Search domain") + parser.add_argument("--stack", "-s", choices=AVAILABLE_STACKS, help=f"Stack-specific search. Available: {', '.join(AVAILABLE_STACKS)}") + parser.add_argument("--max-results", "-n", type=int, default=MAX_RESULTS, help="Max results (default: 3)") + parser.add_argument("--json", action="store_true", help="Output as JSON") + # Design system generation + parser.add_argument("--design-system", "-ds", action="store_true", help="Generate complete design system recommendation") + parser.add_argument("--project-name", "-p", type=str, default=None, help="Project name for design system output") + parser.add_argument("--format", "-f", choices=["ascii", "markdown"], default="ascii", help="Output format for design system") + # Persistence (Master + Overrides pattern) + parser.add_argument("--persist", action="store_true", help="Save design system to design-system/MASTER.md (creates hierarchical structure)") + parser.add_argument("--page", type=str, default=None, help="Create page-specific override file in design-system/pages/") + parser.add_argument("--output-dir", "-o", type=str, default=None, help="Output directory for persisted files (default: current directory)") + # Design dials (1-10), only applied with --design-system + parser.add_argument("--variance", type=int, choices=range(1, 11), metavar="1-10", help="DESIGN_VARIANCE dial: 1=centered/minimal, 10=bold/asymmetric (only with --design-system)") + parser.add_argument("--motion", type=int, choices=range(1, 11), metavar="1-10", help="MOTION_INTENSITY dial: 1=subtle, 10=complex; pulls a matching GSAP snippet from motion.csv (only with --design-system)") + parser.add_argument("--density", type=int, choices=range(1, 11), metavar="1-10", help="VISUAL_DENSITY dial: 1=spacious, 10=dense/dashboard; overrides the spacing scale (only with --design-system)") + + args = parser.parse_args() + + # Design system takes priority + if args.design_system: + result = generate_design_system( + args.query, + args.project_name, + args.format, + persist=args.persist, + page=args.page, + output_dir=args.output_dir, + variance=args.variance, + motion=args.motion, + density=args.density + ) + print(result) + + # Print persistence confirmation + if args.persist: + project_slug = (args.project_name or args.query).lower().replace(' ', '-') + print("\n" + "=" * 60) + print(f"✅ Design system persisted to design-system/{project_slug}/") + print(f" 📄 design-system/{project_slug}/MASTER.md (Global Source of Truth)") + if args.page: + page_filename = args.page.lower().replace(' ', '-') + print(f" 📄 design-system/{project_slug}/pages/{page_filename}.md (Page Overrides)") + print("") + print(f"📖 Usage: When building a page, check design-system/{project_slug}/pages/[page].md first.") + print(f" If exists, its rules override MASTER.md. Otherwise, use MASTER.md.") + print("=" * 60) + # Stack search + elif args.stack: + result = search_stack(args.query, args.stack, args.max_results) + if args.json: + import json + print(json.dumps(result, indent=2, ensure_ascii=False)) + else: + print(format_output(result)) + # Domain search + else: + result = search(args.query, args.domain, args.max_results) + if args.json: + import json + print(json.dumps(result, indent=2, ensure_ascii=False)) + else: + print(format_output(result)) diff --git a/CLAUDE.md b/CLAUDE.md index f9e7eee..9b40f71 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -20,6 +20,13 @@ python3 src/ui-ux-pro-max/scripts/search.py "" --domain [-n " --design-system --variance <1-10> --motion <1-10> --density <1-10> +``` +`--variance` biases style selection (centered/minimal → bold/asymmetric), `--motion` attaches a matching GSAP snippet from `motion.csv`, `--density` overrides the spacing-scale tokens (spacious → dense/dashboard). Any dial left unset behaves exactly as before. **Stack search:** ```bash diff --git a/cli/assets/data/motion.csv b/cli/assets/data/motion.csv new file mode 100644 index 0000000..e3dfc96 --- /dev/null +++ b/cli/assets/data/motion.csv @@ -0,0 +1,17 @@ +No,Category,Intensity Tier,Keywords,Trigger,Duration,Easing,GSAP Snippet,Framework Notes,Do,Don't,Performance Notes +1,Hover Micro-interaction,Subtle,"hover, button, opacity, lift, press feedback",hover,150-200ms,power1.out,"gsap.to(el, { y: -1, opacity: 0.9, duration: 0.15, ease: 'power1.out' });",Bind on mouseenter/mouseleave; in React wrap in a ref + useEffect (or onMouseEnter/onMouseLeave props directly calling gsap.to),Keep displacement under 2px so it reads as feedback not motion,Don't animate layout-affecting props (width/height/margin) on hover,Runs on transform/opacity only so it stays on the compositor thread +2,Hover Micro-interaction,Standard,"hover, card, scale, tilt, cursor feedback",hover,200-300ms,power2.out,"gsap.to(el, { y: -4, scale: 1.02, boxShadow: '0 12px 24px rgba(0,0,0,0.12)', duration: 0.25, ease: 'power2.out' });","Use gsap.quickTo(el, 'y') for cards with many hover targets to avoid re-creating tweens every event",Pair with a matching mouseleave tween that reverses the same properties,Don't leave the hover state stuck if the pointer leaves fast; always attach the reverse tween,quickTo() avoids GC churn on lists with 20+ hoverable cards +3,Hover Micro-interaction,Complex,"hover, magnetic, cursor follow, 3d tilt",hover + mousemove,300-500ms,"elastic.out(1,0.4)","const xTo = gsap.quickTo(el, 'x', { duration: 0.4, ease: 'elastic.out(1,0.4)' }); const yTo = gsap.quickTo(el, 'y', { duration: 0.4, ease: 'elastic.out(1,0.4)' }); el.addEventListener('mousemove', (e) => { const r = el.getBoundingClientRect(); xTo((e.clientX - r.left - r.width/2) * 0.3); yTo((e.clientY - r.top - r.height/2) * 0.3); });",Debounce is not needed since quickTo interpolates; remove listeners on component unmount in React/Vue to avoid leaks,Clamp the pull strength (e.g. * 0.3) so the element never fully leaves its hit box,Don't apply magnetic effect to more than 1-2 focal elements per screen; it becomes noisy,Use will-change: transform on the target element for smoother compositing +4,Scroll Reveal,Subtle,"scroll, fade in, reveal, on view",scroll (viewport enter),300-400ms,power1.out,"gsap.from(el, { opacity: 0, y: 12, duration: 0.35, ease: 'power1.out', scrollTrigger: { trigger: el, start: 'top 90%', toggleActions: 'play none none reverse' } });",Requires the ScrollTrigger plugin registered once via gsap.registerPlugin(ScrollTrigger),"Keep the y offset small (8-16px) so it reads as a fade, not a slide",Don't reveal below-the-fold content needed for SEO/crawlers as invisible-by-default without a no-JS fallback,toggleActions 'play none none reverse' avoids re-triggering on every scroll direction change +5,Scroll Reveal,Standard,"scroll, slide up, staggered section, reveal",scroll (viewport enter),400-600ms,power2.out,"gsap.from(el.children, { opacity: 0, y: 24, duration: 0.5, stagger: 0.08, ease: 'power2.out', scrollTrigger: { trigger: el, start: 'top 85%' } });","In React use useGSAP(() => {...}, { scope: containerRef }) from @gsap/react to auto-cleanup on unmount",Scope the ScrollTrigger to the section container so it doesn't re-scan the whole page,Don't stagger more than ~8 children; beyond that the last items feel laggy,Set scroller/markers: false in production; markers is dev-only +6,Scroll Reveal,Complex,"scroll, pin, scrub, storytelling, scrollytelling",scroll (continuous scrub),tied to scroll position,none (scrub-driven),"gsap.timeline({ scrollTrigger: { trigger: section, start: 'top top', end: '+=150%', scrub: 1, pin: true } }).from('.headline', { opacity: 0, y: 40 }).to('.bg-layer', { yPercent: -20 }, '<');",Pinning needs the section to have deterministic height; recalc ScrollTrigger.refresh() after images/fonts load,Use scrub: true or a small number (0.5-1.5) instead of instant jumps so it feels tied to the scrollbar,Don't pin more than 1-2 sections per page; excessive pinning fights native scroll feel and hurts mobile UX,"Pinning forces layout reflow; test on mid-tier mobile devices, not just desktop" +7,Stagger List,Subtle,"list, stagger, cards, grid entrance",load or scroll,250-350ms,power1.out,"gsap.from('.list-item', { opacity: 0, y: 8, duration: 0.3, stagger: 0.03 });",Select items with a stable class/data-attribute (not array index) so re-renders in React don't break targeting,Keep per-item stagger delay small (0.02-0.04s) for lists longer than 10 items,Don't stagger by more than 0.1s per item on long lists; total reveal time becomes sluggish,"For virtualized lists, only animate items currently mounted in the DOM" +8,Stagger List,Standard,"grid, bento, cards, staggered scale",load or scroll,300-450ms,back.out(1.4),"gsap.from('.grid-item', { opacity: 0, scale: 0.92, y: 16, duration: 0.4, stagger: { each: 0.06, from: 'start', grid: 'auto' }, ease: 'back.out(1.4)' });",grid: 'auto' lets GSAP infer rows/columns from a CSS grid layout for a natural wave stagger,Combine with from: 'center' for a bento-grid layout to draw the eye inward first,Don't use back.out on dense data tables; the overshoot reads as sloppy on informational UI,Group DOM writes; avoid interleaving layout reads (getBoundingClientRect) between staggered tweens +9,Stagger List,Complex,"stagger, wave, text reveal, split text",load or scroll,400-700ms,expo.out,"const split = new SplitText(headline, { type: 'chars' }); gsap.from(split.chars, { opacity: 0, y: 20, rotateX: -40, duration: 0.6, stagger: 0.015, ease: 'expo.out' });",SplitText is a GSAP Club/paid plugin; confirm license before shipping and provide a plain fade fallback if unavailable,Revert SplitText on unmount/cleanup (split.revert()) to restore original text nodes for accessibility tools,Don't split-animate long paragraphs; reserve for short headlines (under ~8 words),Splitting text creates one element per character; keep it to headline-length copy only for DOM size +10,Page Transition,Subtle,"route change, fade, page transition",route change,200-300ms,power1.inOut,"gsap.to(main, { opacity: 0, duration: 0.2, onComplete: () => { navigate(); gsap.fromTo(main, { opacity: 0 }, { opacity: 1, duration: 0.2 }); } });","Pair with the router's transition hooks (Next.js App Router transitions, React Router's useNavigate, Vue Router's beforeEach/afterEach)",Preload the destination route's critical assets before the exit tween finishes,Don't block navigation on animation; cap exit duration at ~250ms so the app never feels unresponsive,Exit animation should always resolve faster than entrance (asymmetric timing) so back/forward feels snappy +11,Page Transition,Standard,"route change, slide, overlay wipe",route change,400-600ms,power2.inOut,"const tl = gsap.timeline(); tl.to('.transition-overlay', { yPercent: 0, duration: 0.4, ease: 'power2.inOut' }).call(navigate).to('.transition-overlay', { yPercent: -100, duration: 0.4, ease: 'power2.inOut', delay: 0.1 });",Keep the overlay element mounted at the layout root (outside the page component) so it survives the route swap,Show a lightweight loading indicator if the destination route's data fetch outlasts the overlay,Don't tie the overlay's reveal directly to data-fetch completion without a max-wait timeout; a slow API stalls the whole transition,Prefer CSS transform (yPercent) over top/left to keep the overlay animation on the compositor thread +12,Page Transition,Complex,"shared element, morph, hero transition",route change,500-800ms,expo.inOut,"const state = Flip.getState('.hero-image'); navigate(); Flip.from(state, { duration: 0.6, ease: 'expo.inOut', absolute: true, zIndex: 100 });",Requires the GSAP Flip plugin; the 'from' and 'to' route must render the same element with a shared data-flip-id,Verify the shared element exists in both DOM states before calling Flip.from to avoid a silent no-op,Don't use shared-element transitions across more than one element pair per navigation; compounding Flips are hard to time correctly,Flip recalculates layout (FLIP technique) so test on low-end devices for jank +13,Parallax Scroll,Subtle,"parallax, background, depth, scroll speed",scroll (continuous),tied to scroll position,linear (scrub),"gsap.to('.bg-layer', { yPercent: 10, ease: 'none', scrollTrigger: { trigger: section, scrub: true } });","Apply parallax to background/decorative layers only, never to text or interactive controls",Keep the yPercent delta small (5-15) so foreground and background never desync distractingly,Don't parallax body copy; it hurts reading comfort and can trigger motion sickness,will-change: transform on the parallax layer only; remove it after scroll settles to free GPU memory +14,Parallax Scroll,Standard,"multi-layer parallax, depth, hero background",scroll (continuous),tied to scroll position,linear (scrub),"gsap.utils.toArray('.parallax-layer').forEach((layer, i) => { gsap.to(layer, { yPercent: (i + 1) * -8, ease: 'none', scrollTrigger: { trigger: layer.parentElement, scrub: 0.5 } }); });",Layer count beyond 3-4 has diminishing visual return and multiplies scroll-listener cost,"Vary speed per layer (background slowest, foreground fastest) to sell the depth illusion",Don't let parallax layers overflow their container; clip with overflow: hidden on the wrapper,Batch all layers under one ScrollTrigger container where possible instead of one per layer +15,Loading / Skeleton,Subtle,"loading, skeleton, shimmer, pulse",on mount / async wait,1200-1600ms loop,sine.inOut,"gsap.to('.skeleton', { backgroundPosition: '200% 0', duration: 1.4, ease: 'sine.inOut', repeat: -1 });",Kill the loop tween (tween.kill()) as soon as real content mounts to avoid orphaned repeating animations,Use a CSS gradient background-position sweep rather than opacity pulsing; reads as 'loading' more clearly,Don't run more than one shimmer loop per skeleton group; sync them under one timeline so the wave reads as a single unit,repeat: -1 tweens are cheap but must be explicitly killed on unmount or they leak in SPA route changes +16,Loading / Skeleton,Standard,"progress, spinner, morphing loader",on mount / async wait,800-1200ms loop,power1.inOut,"gsap.timeline({ repeat: -1 }).to('.loader-dot', { y: -8, duration: 0.4, stagger: { each: 0.15, yoyo: true, repeat: 1 } });",Wrap the whole loop timeline in useGSAP with { revertOnUpdate: false } in React so it isn't rebuilt every render,Cap total loop duration under ~1.5s so long waits don't feel like the UI froze on a single beat,Don't use elaborate loaders for sub-300ms waits; they flash and feel worse than no indicator,Pause the timeline (tl.pause()) when the loading tab/view is not visible to save CPU on background tabs diff --git a/cli/assets/scripts/core.py b/cli/assets/scripts/core.py index aa58b7b..c4b28db 100644 --- a/cli/assets/scripts/core.py +++ b/cli/assets/scripts/core.py @@ -55,6 +55,11 @@ CSV_CONFIG = { "search_cols": ["Category", "Icon Name", "Keywords", "Best For"], "output_cols": ["Category", "Icon Name", "Keywords", "Library", "Import Code", "Usage", "Best For", "Style"] }, + "motion": { + "file": "motion.csv", + "search_cols": ["Category", "Intensity Tier", "Keywords", "Trigger"], + "output_cols": ["Category", "Intensity Tier", "Trigger", "Duration", "Easing", "GSAP Snippet", "Framework Notes", "Do", "Don't", "Performance Notes"] + }, "react": { "file": "react-performance.csv", "search_cols": ["Category", "Issue", "Keywords", "Description"], @@ -90,6 +95,11 @@ STACK_CONFIG = { "angular": {"file": "stacks/angular.csv"}, "laravel": {"file": "stacks/laravel.csv"}, "javafx": {"file": "stacks/javafx.csv"}, + "wpf": {"file": "stacks/wpf.csv"}, + "winui": {"file": "stacks/winui.csv"}, + "avalonia": {"file": "stacks/avalonia.csv"}, + "uno": {"file": "stacks/uno.csv"}, + "uwp": {"file": "stacks/uwp.csv"}, } # Common columns for all stacks @@ -210,6 +220,7 @@ def detect_domain(query): "typography": ["font pairing", "typography pairing", "heading font", "body font"], "google-fonts": ["google font", "font family", "font weight", "font style", "variable font", "noto", "font for", "find font", "font subset", "font language", "monospace font", "serif font", "sans serif font", "display font", "handwriting font", "font", "typography", "serif", "sans"], "icons": ["icon", "icons", "lucide", "heroicons", "symbol", "glyph", "pictogram", "svg icon"], + "motion": ["gsap", "motion intensity", "stagger", "magnetic cursor", "parallax", "page transition", "scroll reveal", "scroll-triggered", "scrollytelling", "flip plugin", "splittext", "shimmer", "skeleton loader"], "react": ["react", "next.js", "nextjs", "suspense", "memo", "usecallback", "useeffect", "rerender", "bundle", "waterfall", "barrel", "dynamic import", "rsc", "server component"], "web": ["aria", "focus", "outline", "semantic", "virtualize", "autocomplete", "form", "input type", "preconnect"] } diff --git a/cli/assets/scripts/design_system.py b/cli/assets/scripts/design_system.py index f1f714e..08db85d 100644 --- a/cli/assets/scripts/design_system.py +++ b/cli/assets/scripts/design_system.py @@ -40,6 +40,39 @@ SEARCH_CONFIG = { "typography": {"max_results": 2} } +# ============ DESIGN DIALS (1-10) ============ +# Inspired by taste-skill's DESIGN_VARIANCE / MOTION_INTENSITY / VISUAL_DENSITY +# knobs: three optional 1-10 sliders that bias the existing query-based search +# instead of replacing it. Each dial buckets into a low/mid/high tier. +DIAL_TIERS = { + "variance": [ + (1, 3, {"label": "Centered / Minimal", "style_keywords": ["Minimalism", "Exaggerated Minimalism", "centered", "symmetric", "grid-based"]}), + (4, 7, {"label": "Balanced / Modern", "style_keywords": ["modern", "structured", "balanced"]}), + (8, 10, {"label": "Bold / Asymmetric", "style_keywords": ["Brutalism", "Bento Grids", "asymmetric", "experimental"]}), + ], + "motion": [ + (1, 3, {"label": "Subtle", "tier": "Subtle"}), + (4, 7, {"label": "Standard", "tier": "Standard"}), + (8, 10, {"label": "Complex", "tier": "Complex"}), + ], + "density": [ + (1, 3, {"label": "Spacious", "spacing": {"xs": "4px", "sm": "8px", "md": "24px", "lg": "32px", "xl": "48px", "2xl": "64px", "3xl": "96px"}}), + (4, 7, {"label": "Standard", "spacing": {"xs": "4px", "sm": "8px", "md": "16px", "lg": "24px", "xl": "32px", "2xl": "48px", "3xl": "64px"}}), + (8, 10, {"label": "Dense / Dashboard", "spacing": {"xs": "2px", "sm": "4px", "md": "8px", "lg": "12px", "xl": "16px", "2xl": "24px", "3xl": "32px"}}), + ], +} + + +def _resolve_dial(dial_name: str, value) -> dict: + """Bucket a 1-10 dial value into its tier config. Returns None if value is None.""" + if value is None: + return None + value = max(1, min(10, int(value))) + for lo, hi, info in DIAL_TIERS[dial_name]: + if lo <= value <= hi: + return {**info, "value": value} + return None + # ============ DESIGN SYSTEM GENERATOR ============ class DesignSystemGenerator: @@ -168,8 +201,18 @@ class DesignSystemGenerator: """Extract results list from search result dict.""" return search_result.get("results", []) - def generate(self, query: str, project_name: str = None) -> dict: - """Generate complete design system recommendation.""" + def generate(self, query: str, project_name: str = None, + variance: int = None, motion: int = None, density: int = None) -> dict: + """Generate complete design system recommendation. + + variance/motion/density are optional 1-10 dials (see DIAL_TIERS) that bias + style selection, pull in a matching motion.csv snippet, and override the + spacing scale, without changing behavior when left unset. + """ + variance_info = _resolve_dial("variance", variance) + motion_info = _resolve_dial("motion", motion) + density_info = _resolve_dial("density", density) + # Step 1: First search product to get category product_result = search(query, "product", 1) product_results = product_result.get("results", []) @@ -181,8 +224,14 @@ class DesignSystemGenerator: reasoning = self._apply_reasoning(category, {}) style_priority = reasoning.get("style_priority", []) + # DESIGN_VARIANCE dial: bias style retrieval/selection toward + # centered-minimal (low) or bold-asymmetric (high) keywords. + effective_style_priority = style_priority + if variance_info: + effective_style_priority = variance_info["style_keywords"] + style_priority + # Step 3: Multi-domain search with style priority hints - search_results = self._multi_domain_search(query, style_priority) + search_results = self._multi_domain_search(query, effective_style_priority) search_results["product"] = product_result # Reuse product search # Step 4: Select best matches from each domain using priority @@ -191,11 +240,22 @@ class DesignSystemGenerator: typography_results = self._extract_results(search_results.get("typography", {})) landing_results = self._extract_results(search_results.get("landing", {})) - best_style = self._select_best_match(style_results, reasoning.get("style_priority", [])) + best_style = self._select_best_match(style_results, effective_style_priority) best_color = color_results[0] if color_results else {} best_typography = typography_results[0] if typography_results else {} best_landing = landing_results[0] if landing_results else {} + # MOTION_INTENSITY dial: pull a matching GSAP skeleton from motion.csv. + motion_snippet = {} + if motion_info: + motion_result = search(f"{query} {motion_info['tier']}", "motion", 5) + motion_matches = motion_result.get("results", []) + tiered = [m for m in motion_matches if m.get("Intensity Tier") == motion_info["tier"]] + if tiered: + motion_snippet = tiered[0] + elif motion_matches: + motion_snippet = motion_matches[0] + # Step 5: Build final recommendation # Combine effects from both reasoning and style search style_effects = best_style.get("Effects & Animation", "") @@ -250,7 +310,17 @@ class DesignSystemGenerator: "key_effects": combined_effects, "anti_patterns": reasoning.get("anti_patterns", ""), "decision_rules": reasoning.get("decision_rules", {}), - "severity": reasoning.get("severity", "MEDIUM") + "severity": reasoning.get("severity", "MEDIUM"), + "dials": { + "variance": variance_info["value"] if variance_info else None, + "variance_label": variance_info["label"] if variance_info else None, + "motion": motion_info["value"] if motion_info else None, + "motion_label": motion_info["label"] if motion_info else None, + "density": density_info["value"] if density_info else None, + "density_label": density_info["label"] if density_info else None, + }, + "motion_snippet": motion_snippet, + "spacing_scale": density_info["spacing"] if density_info else None, } @@ -296,6 +366,8 @@ def format_ascii_box(design_system: dict) -> str: typography = design_system.get("typography", {}) effects = design_system.get("key_effects", "") anti_patterns = design_system.get("anti_patterns", "") + dials = design_system.get("dials", {}) + motion_snippet = design_system.get("motion_snippet", {}) def wrap_text(text: str, prefix: str, width: int) -> list: """Wrap long text into multiple lines.""" @@ -329,6 +401,16 @@ def format_ascii_box(design_system: dict) -> str: lines.append("╚" + "═" * w + "╝") lines.append("┌" + "─" * w + "┐") + # Design Dials section (only if at least one dial was set) + if any(dials.get(k) is not None for k in ("variance", "motion", "density")): + lines.append(section_header("DESIGN DIALS", BOX_WIDTH + 1)) + if dials.get("variance") is not None: + lines.append(f"│ Variance: {dials['variance']}/10 — {dials['variance_label']}".ljust(BOX_WIDTH) + "│") + if dials.get("motion") is not None: + lines.append(f"│ Motion: {dials['motion']}/10 — {dials['motion_label']}".ljust(BOX_WIDTH) + "│") + if dials.get("density") is not None: + lines.append(f"│ Density: {dials['density']}/10 — {dials['density_label']}".ljust(BOX_WIDTH) + "│") + # Pattern section lines.append(section_header("PATTERN", BOX_WIDTH + 1)) lines.append(f"│ Name: {pattern.get('name', '')}".ljust(BOX_WIDTH) + "│") @@ -402,6 +484,17 @@ def format_ascii_box(design_system: dict) -> str: for line in wrap_text(effects, "│ ", BOX_WIDTH): lines.append(line.ljust(BOX_WIDTH) + "│") + # Motion section (GSAP skeleton, only if --motion dial was set) + if motion_snippet: + lines.append(section_header("MOTION", BOX_WIDTH + 1)) + lines.append(f"│ {motion_snippet.get('Category', '')} ({motion_snippet.get('Intensity Tier', '')})".ljust(BOX_WIDTH) + "│") + lines.append(f"│ Trigger: {motion_snippet.get('Trigger', '')} | Duration: {motion_snippet.get('Duration', '')} | Easing: {motion_snippet.get('Easing', '')}".ljust(BOX_WIDTH) + "│") + for line in wrap_text(f"GSAP: {motion_snippet.get('GSAP Snippet', '')}", "│ ", BOX_WIDTH): + lines.append(line.ljust(BOX_WIDTH) + "│") + if motion_snippet.get("Framework Notes"): + for line in wrap_text(f"Framework: {motion_snippet.get('Framework Notes', '')}", "│ ", BOX_WIDTH): + lines.append(line.ljust(BOX_WIDTH) + "│") + # Anti-patterns section if anti_patterns: lines.append(section_header("AVOID", BOX_WIDTH + 1)) @@ -436,11 +529,24 @@ def format_markdown(design_system: dict) -> str: typography = design_system.get("typography", {}) effects = design_system.get("key_effects", "") anti_patterns = design_system.get("anti_patterns", "") + dials = design_system.get("dials", {}) + motion_snippet = design_system.get("motion_snippet", {}) lines = [] lines.append(f"## Design System: {project}") lines.append("") + # Design Dials section (only if at least one dial was set) + if any(dials.get(k) is not None for k in ("variance", "motion", "density")): + lines.append("### Design Dials") + if dials.get("variance") is not None: + lines.append(f"- **Variance:** {dials['variance']}/10 — {dials['variance_label']}") + if dials.get("motion") is not None: + lines.append(f"- **Motion:** {dials['motion']}/10 — {dials['motion_label']}") + if dials.get("density") is not None: + lines.append(f"- **Density:** {dials['density']}/10 — {dials['density_label']}") + lines.append("") + # Pattern section lines.append("### Pattern") lines.append(f"- **Name:** {pattern.get('name', '')}") @@ -515,6 +621,23 @@ def format_markdown(design_system: dict) -> str: lines.append(f"{effects}") lines.append("") + # Motion section (GSAP skeleton, only if --motion dial was set) + if motion_snippet: + lines.append("### Motion") + lines.append(f"**{motion_snippet.get('Category', '')}** ({motion_snippet.get('Intensity Tier', '')}) — Trigger: {motion_snippet.get('Trigger', '')} | Duration: {motion_snippet.get('Duration', '')} | Easing: `{motion_snippet.get('Easing', '')}`") + lines.append("```js") + lines.append(motion_snippet.get("GSAP Snippet", "")) + lines.append("```") + if motion_snippet.get("Framework Notes"): + lines.append(f"*Framework notes: {motion_snippet.get('Framework Notes', '')}*") + motion_do = motion_snippet.get("Do", "") + motion_dont = motion_snippet.get("Don't", "") + if motion_do: + lines.append(f"- ✅ {motion_do}") + if motion_dont: + lines.append(f"- ❌ {motion_dont}") + lines.append("") + # Anti-patterns section if anti_patterns: lines.append("### Avoid (Anti-patterns)") @@ -537,8 +660,9 @@ def format_markdown(design_system: dict) -> str: # ============ MAIN ENTRY POINT ============ -def generate_design_system(query: str, project_name: str = None, output_format: str = "ascii", - persist: bool = False, page: str = None, output_dir: str = None) -> str: +def generate_design_system(query: str, project_name: str = None, output_format: str = "ascii", + persist: bool = False, page: str = None, output_dir: str = None, + variance: int = None, motion: int = None, density: int = None) -> str: """ Main entry point for design system generation. @@ -549,13 +673,16 @@ def generate_design_system(query: str, project_name: str = None, output_format: persist: If True, save design system to design-system/ folder page: Optional page name for page-specific override file output_dir: Optional output directory (defaults to current working directory) + variance: Optional 1-10 DESIGN_VARIANCE dial (1=centered/minimal, 10=bold/asymmetric) + motion: Optional 1-10 MOTION_INTENSITY dial, pulls a matching GSAP snippet from motion.csv + density: Optional 1-10 VISUAL_DENSITY dial, overrides the spacing scale (1=spacious, 10=dense) Returns: Formatted design system string """ generator = DesignSystemGenerator() - design_system = generator.generate(query, project_name) - + design_system = generator.generate(query, project_name, variance=variance, motion=motion, density=density) + # Persist to files if requested if persist: persist_design_system(design_system, page, output_dir, query) @@ -627,11 +754,14 @@ def format_master_md(design_system: dict) -> str: typography = design_system.get("typography", {}) effects = design_system.get("key_effects", "") anti_patterns = design_system.get("anti_patterns", "") - + dials = design_system.get("dials", {}) + motion_snippet = design_system.get("motion_snippet", {}) + spacing_scale = design_system.get("spacing_scale") + timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S") - + lines = [] - + # Logic header lines.append("# Design System Master File") lines.append("") @@ -644,6 +774,15 @@ def format_master_md(design_system: dict) -> str: lines.append(f"**Project:** {project}") lines.append(f"**Generated:** {timestamp}") lines.append(f"**Category:** {design_system.get('category', 'General')}") + if any(dials.get(k) is not None for k in ("variance", "motion", "density")): + dial_parts = [] + if dials.get("variance") is not None: + dial_parts.append(f"Variance {dials['variance']}/10 ({dials['variance_label']})") + if dials.get("motion") is not None: + dial_parts.append(f"Motion {dials['motion']}/10 ({dials['motion_label']})") + if dials.get("density") is not None: + dial_parts.append(f"Density {dials['density']}/10 ({dials['density_label']})") + lines.append(f"**Design Dials:** {' | '.join(dial_parts)}") lines.append("") lines.append("---") lines.append("") @@ -695,18 +834,24 @@ def format_master_md(design_system: dict) -> str: lines.append("```") lines.append("") - # Spacing Variables + # Spacing Variables (overridden by the VISUAL_DENSITY dial when set) + default_spacing = DIAL_TIERS["density"][1][2]["spacing"] # mid-tier = the historical defaults + scale = spacing_scale or default_spacing + spacing_usage = { + "xs": "Tight gaps", "sm": "Icon gaps, inline spacing", "md": "Standard padding", + "lg": "Section padding", "xl": "Large gaps", "2xl": "Section margins", "3xl": "Hero padding", + } lines.append("### Spacing Variables") lines.append("") + if spacing_scale: + lines.append(f"*Density: {dials.get('density')}/10 — {dials.get('density_label')}*") + lines.append("") lines.append("| Token | Value | Usage |") lines.append("|-------|-------|-------|") - lines.append("| `--space-xs` | `4px` / `0.25rem` | Tight gaps |") - lines.append("| `--space-sm` | `8px` / `0.5rem` | Icon gaps, inline spacing |") - lines.append("| `--space-md` | `16px` / `1rem` | Standard padding |") - lines.append("| `--space-lg` | `24px` / `1.5rem` | Section padding |") - lines.append("| `--space-xl` | `32px` / `2rem` | Large gaps |") - lines.append("| `--space-2xl` | `48px` / `3rem` | Section margins |") - lines.append("| `--space-3xl` | `64px` / `4rem` | Hero padding |") + for token in ("xs", "sm", "md", "lg", "xl", "2xl", "3xl"): + px_value = scale[token] + rem_value = f"{int(px_value.rstrip('px')) / 16:g}rem" + lines.append(f"| `--space-{token}` | `{px_value}` / `{rem_value}` | {spacing_usage[token]} |") lines.append("") # Shadow Depths @@ -848,7 +993,32 @@ def format_master_md(design_system: dict) -> str: lines.append(f"- **CTA Placement:** {pattern.get('cta_placement', '')}") lines.append(f"- **Section Order:** {pattern.get('sections', '')}") lines.append("") - + + # Motion section (GSAP skeleton, only if --motion dial was set) + if motion_snippet: + lines.append("---") + lines.append("") + lines.append("## Motion") + lines.append("") + lines.append(f"**{motion_snippet.get('Category', '')}** ({motion_snippet.get('Intensity Tier', '')}) — Trigger: {motion_snippet.get('Trigger', '')} | Duration: {motion_snippet.get('Duration', '')} | Easing: `{motion_snippet.get('Easing', '')}`") + lines.append("") + lines.append("```js") + lines.append(motion_snippet.get("GSAP Snippet", "")) + lines.append("```") + lines.append("") + if motion_snippet.get("Framework Notes"): + lines.append(f"**Framework notes:** {motion_snippet.get('Framework Notes', '')}") + lines.append("") + motion_do = motion_snippet.get("Do", "") + motion_dont = motion_snippet.get("Don't", "") + if motion_do: + lines.append(f"- ✅ {motion_do}") + if motion_dont: + lines.append(f"- ❌ {motion_dont}") + if motion_snippet.get("Performance Notes"): + lines.append(f"- ⚡ {motion_snippet.get('Performance Notes', '')}") + lines.append("") + # Anti-Patterns section lines.append("---") lines.append("") diff --git a/cli/assets/scripts/search.py b/cli/assets/scripts/search.py index f61c833..6fd60fe 100644 --- a/cli/assets/scripts/search.py +++ b/cli/assets/scripts/search.py @@ -5,9 +5,15 @@ UI/UX Pro Max Search - BM25 search engine for UI/UX style guides Usage: python search.py "" [--domain ] [--stack ] [--max-results 3] python search.py "" --design-system [-p "Project Name"] python search.py "" --design-system --persist [-p "Project Name"] [--page "dashboard"] + python search.py "" --design-system --variance 8 --motion 9 --density 7 -Domains: style, prompt, color, chart, landing, product, ux, typography, google-fonts -Stacks: react, nextjs, vue, svelte, astro, swiftui, react-native, flutter, nuxtjs, nuxt-ui, html-tailwind, shadcn, jetpack-compose, threejs, angular, laravel, javafx +Domains: style, prompt, color, chart, landing, product, ux, typography, google-fonts, motion +Stacks: react, nextjs, vue, svelte, astro, swiftui, react-native, flutter, nuxtjs, nuxt-ui, html-tailwind, shadcn, jetpack-compose, threejs, angular, laravel, javafx, wpf, winui, avalonia, uno, uwp + +Design dials (1-10, only with --design-system): + --variance DESIGN_VARIANCE: 1=centered/minimal, 10=bold/asymmetric + --motion MOTION_INTENSITY: 1=subtle, 10=complex; attaches a GSAP snippet from motion.csv + --density VISUAL_DENSITY: 1=spacious, 10=dense/dashboard; overrides the spacing scale Persistence (Master + Overrides pattern): --persist Save design system to design-system/MASTER.md @@ -68,18 +74,25 @@ if __name__ == "__main__": parser.add_argument("--persist", action="store_true", help="Save design system to design-system/MASTER.md (creates hierarchical structure)") parser.add_argument("--page", type=str, default=None, help="Create page-specific override file in design-system/pages/") parser.add_argument("--output-dir", "-o", type=str, default=None, help="Output directory for persisted files (default: current directory)") + # Design dials (1-10), only applied with --design-system + parser.add_argument("--variance", type=int, choices=range(1, 11), metavar="1-10", help="DESIGN_VARIANCE dial: 1=centered/minimal, 10=bold/asymmetric (only with --design-system)") + parser.add_argument("--motion", type=int, choices=range(1, 11), metavar="1-10", help="MOTION_INTENSITY dial: 1=subtle, 10=complex; pulls a matching GSAP snippet from motion.csv (only with --design-system)") + parser.add_argument("--density", type=int, choices=range(1, 11), metavar="1-10", help="VISUAL_DENSITY dial: 1=spacious, 10=dense/dashboard; overrides the spacing scale (only with --design-system)") args = parser.parse_args() # Design system takes priority if args.design_system: result = generate_design_system( - args.query, - args.project_name, + args.query, + args.project_name, args.format, persist=args.persist, page=args.page, - output_dir=args.output_dir + output_dir=args.output_dir, + variance=args.variance, + motion=args.motion, + density=args.density ) print(result) diff --git a/cli/assets/templates/base/skill-content.md b/cli/assets/templates/base/skill-content.md index 929602e..18ff275 100644 --- a/cli/assets/templates/base/skill-content.md +++ b/cli/assets/templates/base/skill-content.md @@ -110,6 +110,29 @@ If not, use the Master rules exclusively. Now, generate the code... ``` +### Step 2c: Design Dials (optional) + +Three optional 1-10 sliders that tune `--design-system` output without changing your query. Add any combination of them to the same command: + +```bash +python3 skills/ui-ux-pro-max/scripts/search.py "" --design-system --variance <1-10> --motion <1-10> --density <1-10> +``` + +| Dial | Low (1-3) | Mid (4-7) | High (8-10) | +|------|-----------|-----------|-------------| +| `--variance` | Centered / minimal (biases toward Minimalism-style categories) | Balanced / modern | Bold / asymmetric (biases toward Brutalism, Bento Grids) | +| `--motion` | Subtle micro-interactions | Standard scroll/stagger motion | Complex choreography (pin, Flip, SplitText) | +| `--density` | Spacious (24-96px spacing scale) | Standard (16-64px, current default) | Dense/dashboard (8-32px spacing scale) | + +- `--motion` attaches a ready-to-use GSAP snippet (with framework notes, Do/Don't, and performance notes) pulled from `--domain motion`, matched to the resolved tier (Subtle/Standard/Complex). +- `--density` overrides the `--space-*` CSS variable table in the ASCII/markdown/MASTER.md output — use it for dashboards (high) vs. marketing pages (low) without hand-editing tokens. +- Leaving a dial unset keeps that part of the output exactly as it was before (no behavior change). + +**Example:** +```bash +python3 skills/ui-ux-pro-max/scripts/search.py "internal analytics dashboard" --design-system --variance 8 --motion 7 --density 8 -p "Ops Console" +``` + ### Step 3: Supplement with Detailed Searches (as needed) After getting the design system, use domain searches to get additional details: @@ -156,6 +179,7 @@ python3 skills/ui-ux-pro-max/scripts/search.py "" --stack | `landing` | Page structure, CTA strategies | hero, hero-centric, testimonial, pricing, social-proof | | `chart` | Chart types, library recommendations | trend, comparison, timeline, funnel, pie | | `ux` | Best practices, anti-patterns | animation, accessibility, z-index, loading | +| `motion` | GSAP animation skeletons by intensity tier | scroll reveal, stagger, magnetic cursor, page transition | | `react` | React/Next.js performance | waterfall, bundle, suspense, memo, rerender, cache | | `web` | App interface guidelines (iOS/Android/React Native) | accessibilityLabel, touch targets, safe areas, Dynamic Type | | `prompt` | AI prompts, CSS keywords | (style name) | diff --git a/src/ui-ux-pro-max/data/motion.csv b/src/ui-ux-pro-max/data/motion.csv new file mode 100644 index 0000000..e5555fe --- /dev/null +++ b/src/ui-ux-pro-max/data/motion.csv @@ -0,0 +1,17 @@ +No,Category,Intensity Tier,Keywords,Trigger,Duration,Easing,GSAP Snippet,Framework Notes,Do,Don't,Performance Notes +1,Hover Micro-interaction,Subtle,"hover, button, opacity, lift, press feedback",hover,150-200ms,power1.out,"gsap.to(el, { y: -1, opacity: 0.9, duration: 0.15, ease: 'power1.out' });",Bind on mouseenter/mouseleave; in React wrap in a ref + useEffect (or onMouseEnter/onMouseLeave props directly calling gsap.to),Keep displacement under 2px so it reads as feedback not motion,Don't animate layout-affecting props (width/height/margin) on hover,Runs on transform/opacity only so it stays on the compositor thread +2,Hover Micro-interaction,Standard,"hover, card, scale, tilt, cursor feedback",hover,200-300ms,power2.out,"gsap.to(el, { y: -4, scale: 1.02, boxShadow: '0 12px 24px rgba(0,0,0,0.12)', duration: 0.25, ease: 'power2.out' });","Use gsap.quickTo(el, 'y') for cards with many hover targets to avoid re-creating tweens every event",Pair with a matching mouseleave tween that reverses the same properties,Don't leave the hover state stuck if the pointer leaves fast; always attach the reverse tween,quickTo() avoids GC churn on lists with 20+ hoverable cards +3,Hover Micro-interaction,Complex,"hover, magnetic, cursor follow, 3d tilt",hover + mousemove,300-500ms,"elastic.out(1,0.4)","const xTo = gsap.quickTo(el, 'x', { duration: 0.4, ease: 'elastic.out(1,0.4)' }); const yTo = gsap.quickTo(el, 'y', { duration: 0.4, ease: 'elastic.out(1,0.4)' }); el.addEventListener('mousemove', (e) => { const r = el.getBoundingClientRect(); xTo((e.clientX - r.left - r.width/2) * 0.3); yTo((e.clientY - r.top - r.height/2) * 0.3); });",Debounce is not needed since quickTo interpolates; remove listeners on component unmount in React/Vue to avoid leaks,Clamp the pull strength (e.g. * 0.3) so the element never fully leaves its hit box,Don't apply magnetic effect to more than 1-2 focal elements per screen; it becomes noisy,Use will-change: transform on the target element for smoother compositing +4,Scroll Reveal,Subtle,"scroll, fade in, reveal, on view",scroll (viewport enter),300-400ms,power1.out,"gsap.from(el, { opacity: 0, y: 12, duration: 0.35, ease: 'power1.out', scrollTrigger: { trigger: el, start: 'top 90%', toggleActions: 'play none none reverse' } });",Requires the ScrollTrigger plugin registered once via gsap.registerPlugin(ScrollTrigger),"Keep the y offset small (8-16px) so it reads as a fade, not a slide",Don't reveal below-the-fold content needed for SEO/crawlers as invisible-by-default without a no-JS fallback,toggleActions 'play none none reverse' avoids re-triggering on every scroll direction change +5,Scroll Reveal,Standard,"scroll, slide up, staggered section, reveal",scroll (viewport enter),400-600ms,power2.out,"gsap.from(el.children, { opacity: 0, y: 24, duration: 0.5, stagger: 0.08, ease: 'power2.out', scrollTrigger: { trigger: el, start: 'top 85%' } });","In React use useGSAP(() => {...}, { scope: containerRef }) from @gsap/react to auto-cleanup on unmount",Scope the ScrollTrigger to the section container so it doesn't re-scan the whole page,Don't stagger more than ~8 children; beyond that the last items feel laggy,Set scroller/markers: false in production; markers is dev-only +6,Scroll Reveal,Complex,"scroll, pin, scrub, storytelling, scrollytelling",scroll (continuous scrub),tied to scroll position,none (scrub-driven),"gsap.timeline({ scrollTrigger: { trigger: section, start: 'top top', end: '+=150%', scrub: 1, pin: true } }).from('.headline', { opacity: 0, y: 40 }).to('.bg-layer', { yPercent: -20 }, '<');",Pinning needs the section to have deterministic height; recalc ScrollTrigger.refresh() after images/fonts load,Use scrub: true or a small number (0.5-1.5) instead of instant jumps so it feels tied to the scrollbar,Don't pin more than 1-2 sections per page; excessive pinning fights native scroll feel and hurts mobile UX,"Pinning forces layout reflow; test on mid-tier mobile devices, not just desktop" +7,Stagger List,Subtle,"list, stagger, cards, grid entrance",load or scroll,250-350ms,power1.out,"gsap.from('.list-item', { opacity: 0, y: 8, duration: 0.3, stagger: 0.03 });",Select items with a stable class/data-attribute (not array index) so re-renders in React don't break targeting,Keep per-item stagger delay small (0.02-0.04s) for lists longer than 10 items,Don't stagger by more than 0.1s per item on long lists; total reveal time becomes sluggish,"For virtualized lists, only animate items currently mounted in the DOM" +8,Stagger List,Standard,"grid, bento, cards, staggered scale",load or scroll,300-450ms,back.out(1.4),"gsap.from('.grid-item', { opacity: 0, scale: 0.92, y: 16, duration: 0.4, stagger: { each: 0.06, from: 'start', grid: 'auto' }, ease: 'back.out(1.4)' });",grid: 'auto' lets GSAP infer rows/columns from a CSS grid layout for a natural wave stagger,Combine with from: 'center' for a bento-grid layout to draw the eye inward first,Don't use back.out on dense data tables; the overshoot reads as sloppy on informational UI,Group DOM writes; avoid interleaving layout reads (getBoundingClientRect) between staggered tweens +9,Stagger List,Complex,"stagger, wave, text reveal, split text",load or scroll,400-700ms,expo.out,"const split = new SplitText(headline, { type: 'chars' }); gsap.from(split.chars, { opacity: 0, y: 20, rotateX: -40, duration: 0.6, stagger: 0.015, ease: 'expo.out' });",SplitText is a GSAP Club/paid plugin; confirm license before shipping and provide a plain fade fallback if unavailable,Revert SplitText on unmount/cleanup (split.revert()) to restore original text nodes for accessibility tools,Don't split-animate long paragraphs; reserve for short headlines (under ~8 words),Splitting text creates one element per character; keep it to headline-length copy only for DOM size +10,Page Transition,Subtle,"route change, fade, page transition",route change,200-300ms,power1.inOut,"gsap.to(main, { opacity: 0, duration: 0.2, onComplete: () => { navigate(); gsap.fromTo(main, { opacity: 0 }, { opacity: 1, duration: 0.2 }); } });","Pair with the router's transition hooks (Next.js App Router transitions, React Router's useNavigate, Vue Router's beforeEach/afterEach)",Preload the destination route's critical assets before the exit tween finishes,Don't block navigation on animation; cap exit duration at ~250ms so the app never feels unresponsive,Exit animation should always resolve faster than entrance (asymmetric timing) so back/forward feels snappy +11,Page Transition,Standard,"route change, slide, overlay wipe",route change,400-600ms,power2.inOut,"const tl = gsap.timeline(); tl.to('.transition-overlay', { yPercent: 0, duration: 0.4, ease: 'power2.inOut' }).call(navigate).to('.transition-overlay', { yPercent: -100, duration: 0.4, ease: 'power2.inOut', delay: 0.1 });",Keep the overlay element mounted at the layout root (outside the page component) so it survives the route swap,Show a lightweight loading indicator if the destination route's data fetch outlasts the overlay,Don't tie the overlay's reveal directly to data-fetch completion without a max-wait timeout; a slow API stalls the whole transition,Prefer CSS transform (yPercent) over top/left to keep the overlay animation on the compositor thread +12,Page Transition,Complex,"shared element, morph, hero transition",route change,500-800ms,expo.inOut,"const state = Flip.getState('.hero-image'); navigate(); Flip.from(state, { duration: 0.6, ease: 'expo.inOut', absolute: true, zIndex: 100 });",Requires the GSAP Flip plugin; the 'from' and 'to' route must render the same element with a shared data-flip-id,Verify the shared element exists in both DOM states before calling Flip.from to avoid a silent no-op,Don't use shared-element transitions across more than one element pair per navigation; compounding Flips are hard to time correctly,Flip recalculates layout (FLIP technique) so test on low-end devices for jank +13,Parallax Scroll,Subtle,"parallax, background, depth, scroll speed",scroll (continuous),tied to scroll position,linear (scrub),"gsap.to('.bg-layer', { yPercent: 10, ease: 'none', scrollTrigger: { trigger: section, scrub: true } });","Apply parallax to background/decorative layers only, never to text or interactive controls",Keep the yPercent delta small (5-15) so foreground and background never desync distractingly,Don't parallax body copy; it hurts reading comfort and can trigger motion sickness,will-change: transform on the parallax layer only; remove it after scroll settles to free GPU memory +14,Parallax Scroll,Standard,"multi-layer parallax, depth, hero background",scroll (continuous),tied to scroll position,linear (scrub),"gsap.utils.toArray('.parallax-layer').forEach((layer, i) => { gsap.to(layer, { yPercent: (i + 1) * -8, ease: 'none', scrollTrigger: { trigger: layer.parentElement, scrub: 0.5 } }); });",Layer count beyond 3-4 has diminishing visual return and multiplies scroll-listener cost,"Vary speed per layer (background slowest, foreground fastest) to sell the depth illusion",Don't let parallax layers overflow their container; clip with overflow: hidden on the wrapper,Batch all layers under one ScrollTrigger container where possible instead of one per layer +15,Loading / Skeleton,Subtle,"loading, skeleton, shimmer, pulse",on mount / async wait,1200-1600ms loop,sine.inOut,"gsap.to('.skeleton', { backgroundPosition: '200% 0', duration: 1.4, ease: 'sine.inOut', repeat: -1 });",Kill the loop tween (tween.kill()) as soon as real content mounts to avoid orphaned repeating animations,Use a CSS gradient background-position sweep rather than opacity pulsing; reads as 'loading' more clearly,Don't run more than one shimmer loop per skeleton group; sync them under one timeline so the wave reads as a single unit,repeat: -1 tweens are cheap but must be explicitly killed on unmount or they leak in SPA route changes +16,Loading / Skeleton,Standard,"progress, spinner, morphing loader",on mount / async wait,800-1200ms loop,power1.inOut,"gsap.timeline({ repeat: -1 }).to('.loader-dot', { y: -8, duration: 0.4, stagger: { each: 0.15, yoyo: true, repeat: 1 } });",Wrap the whole loop timeline in useGSAP with { revertOnUpdate: false } in React so it isn't rebuilt every render,Cap total loop duration under ~1.5s so long waits don't feel like the UI froze on a single beat,Don't use elaborate loaders for sub-300ms waits; they flash and feel worse than no indicator,Pause the timeline (tl.pause()) when the loading tab/view is not visible to save CPU on background tabs diff --git a/src/ui-ux-pro-max/scripts/core.py b/src/ui-ux-pro-max/scripts/core.py index 3a9bf78..c4b28db 100755 --- a/src/ui-ux-pro-max/scripts/core.py +++ b/src/ui-ux-pro-max/scripts/core.py @@ -55,6 +55,11 @@ CSV_CONFIG = { "search_cols": ["Category", "Icon Name", "Keywords", "Best For"], "output_cols": ["Category", "Icon Name", "Keywords", "Library", "Import Code", "Usage", "Best For", "Style"] }, + "motion": { + "file": "motion.csv", + "search_cols": ["Category", "Intensity Tier", "Keywords", "Trigger"], + "output_cols": ["Category", "Intensity Tier", "Trigger", "Duration", "Easing", "GSAP Snippet", "Framework Notes", "Do", "Don't", "Performance Notes"] + }, "react": { "file": "react-performance.csv", "search_cols": ["Category", "Issue", "Keywords", "Description"], @@ -215,6 +220,7 @@ def detect_domain(query): "typography": ["font pairing", "typography pairing", "heading font", "body font"], "google-fonts": ["google font", "font family", "font weight", "font style", "variable font", "noto", "font for", "find font", "font subset", "font language", "monospace font", "serif font", "sans serif font", "display font", "handwriting font", "font", "typography", "serif", "sans"], "icons": ["icon", "icons", "lucide", "heroicons", "symbol", "glyph", "pictogram", "svg icon"], + "motion": ["gsap", "motion intensity", "stagger", "magnetic cursor", "parallax", "page transition", "scroll reveal", "scroll-triggered", "scrollytelling", "flip plugin", "splittext", "shimmer", "skeleton loader"], "react": ["react", "next.js", "nextjs", "suspense", "memo", "usecallback", "useeffect", "rerender", "bundle", "waterfall", "barrel", "dynamic import", "rsc", "server component"], "web": ["aria", "focus", "outline", "semantic", "virtualize", "autocomplete", "form", "input type", "preconnect"] } diff --git a/src/ui-ux-pro-max/scripts/design_system.py b/src/ui-ux-pro-max/scripts/design_system.py index f1f714e..08db85d 100644 --- a/src/ui-ux-pro-max/scripts/design_system.py +++ b/src/ui-ux-pro-max/scripts/design_system.py @@ -40,6 +40,39 @@ SEARCH_CONFIG = { "typography": {"max_results": 2} } +# ============ DESIGN DIALS (1-10) ============ +# Inspired by taste-skill's DESIGN_VARIANCE / MOTION_INTENSITY / VISUAL_DENSITY +# knobs: three optional 1-10 sliders that bias the existing query-based search +# instead of replacing it. Each dial buckets into a low/mid/high tier. +DIAL_TIERS = { + "variance": [ + (1, 3, {"label": "Centered / Minimal", "style_keywords": ["Minimalism", "Exaggerated Minimalism", "centered", "symmetric", "grid-based"]}), + (4, 7, {"label": "Balanced / Modern", "style_keywords": ["modern", "structured", "balanced"]}), + (8, 10, {"label": "Bold / Asymmetric", "style_keywords": ["Brutalism", "Bento Grids", "asymmetric", "experimental"]}), + ], + "motion": [ + (1, 3, {"label": "Subtle", "tier": "Subtle"}), + (4, 7, {"label": "Standard", "tier": "Standard"}), + (8, 10, {"label": "Complex", "tier": "Complex"}), + ], + "density": [ + (1, 3, {"label": "Spacious", "spacing": {"xs": "4px", "sm": "8px", "md": "24px", "lg": "32px", "xl": "48px", "2xl": "64px", "3xl": "96px"}}), + (4, 7, {"label": "Standard", "spacing": {"xs": "4px", "sm": "8px", "md": "16px", "lg": "24px", "xl": "32px", "2xl": "48px", "3xl": "64px"}}), + (8, 10, {"label": "Dense / Dashboard", "spacing": {"xs": "2px", "sm": "4px", "md": "8px", "lg": "12px", "xl": "16px", "2xl": "24px", "3xl": "32px"}}), + ], +} + + +def _resolve_dial(dial_name: str, value) -> dict: + """Bucket a 1-10 dial value into its tier config. Returns None if value is None.""" + if value is None: + return None + value = max(1, min(10, int(value))) + for lo, hi, info in DIAL_TIERS[dial_name]: + if lo <= value <= hi: + return {**info, "value": value} + return None + # ============ DESIGN SYSTEM GENERATOR ============ class DesignSystemGenerator: @@ -168,8 +201,18 @@ class DesignSystemGenerator: """Extract results list from search result dict.""" return search_result.get("results", []) - def generate(self, query: str, project_name: str = None) -> dict: - """Generate complete design system recommendation.""" + def generate(self, query: str, project_name: str = None, + variance: int = None, motion: int = None, density: int = None) -> dict: + """Generate complete design system recommendation. + + variance/motion/density are optional 1-10 dials (see DIAL_TIERS) that bias + style selection, pull in a matching motion.csv snippet, and override the + spacing scale, without changing behavior when left unset. + """ + variance_info = _resolve_dial("variance", variance) + motion_info = _resolve_dial("motion", motion) + density_info = _resolve_dial("density", density) + # Step 1: First search product to get category product_result = search(query, "product", 1) product_results = product_result.get("results", []) @@ -181,8 +224,14 @@ class DesignSystemGenerator: reasoning = self._apply_reasoning(category, {}) style_priority = reasoning.get("style_priority", []) + # DESIGN_VARIANCE dial: bias style retrieval/selection toward + # centered-minimal (low) or bold-asymmetric (high) keywords. + effective_style_priority = style_priority + if variance_info: + effective_style_priority = variance_info["style_keywords"] + style_priority + # Step 3: Multi-domain search with style priority hints - search_results = self._multi_domain_search(query, style_priority) + search_results = self._multi_domain_search(query, effective_style_priority) search_results["product"] = product_result # Reuse product search # Step 4: Select best matches from each domain using priority @@ -191,11 +240,22 @@ class DesignSystemGenerator: typography_results = self._extract_results(search_results.get("typography", {})) landing_results = self._extract_results(search_results.get("landing", {})) - best_style = self._select_best_match(style_results, reasoning.get("style_priority", [])) + best_style = self._select_best_match(style_results, effective_style_priority) best_color = color_results[0] if color_results else {} best_typography = typography_results[0] if typography_results else {} best_landing = landing_results[0] if landing_results else {} + # MOTION_INTENSITY dial: pull a matching GSAP skeleton from motion.csv. + motion_snippet = {} + if motion_info: + motion_result = search(f"{query} {motion_info['tier']}", "motion", 5) + motion_matches = motion_result.get("results", []) + tiered = [m for m in motion_matches if m.get("Intensity Tier") == motion_info["tier"]] + if tiered: + motion_snippet = tiered[0] + elif motion_matches: + motion_snippet = motion_matches[0] + # Step 5: Build final recommendation # Combine effects from both reasoning and style search style_effects = best_style.get("Effects & Animation", "") @@ -250,7 +310,17 @@ class DesignSystemGenerator: "key_effects": combined_effects, "anti_patterns": reasoning.get("anti_patterns", ""), "decision_rules": reasoning.get("decision_rules", {}), - "severity": reasoning.get("severity", "MEDIUM") + "severity": reasoning.get("severity", "MEDIUM"), + "dials": { + "variance": variance_info["value"] if variance_info else None, + "variance_label": variance_info["label"] if variance_info else None, + "motion": motion_info["value"] if motion_info else None, + "motion_label": motion_info["label"] if motion_info else None, + "density": density_info["value"] if density_info else None, + "density_label": density_info["label"] if density_info else None, + }, + "motion_snippet": motion_snippet, + "spacing_scale": density_info["spacing"] if density_info else None, } @@ -296,6 +366,8 @@ def format_ascii_box(design_system: dict) -> str: typography = design_system.get("typography", {}) effects = design_system.get("key_effects", "") anti_patterns = design_system.get("anti_patterns", "") + dials = design_system.get("dials", {}) + motion_snippet = design_system.get("motion_snippet", {}) def wrap_text(text: str, prefix: str, width: int) -> list: """Wrap long text into multiple lines.""" @@ -329,6 +401,16 @@ def format_ascii_box(design_system: dict) -> str: lines.append("╚" + "═" * w + "╝") lines.append("┌" + "─" * w + "┐") + # Design Dials section (only if at least one dial was set) + if any(dials.get(k) is not None for k in ("variance", "motion", "density")): + lines.append(section_header("DESIGN DIALS", BOX_WIDTH + 1)) + if dials.get("variance") is not None: + lines.append(f"│ Variance: {dials['variance']}/10 — {dials['variance_label']}".ljust(BOX_WIDTH) + "│") + if dials.get("motion") is not None: + lines.append(f"│ Motion: {dials['motion']}/10 — {dials['motion_label']}".ljust(BOX_WIDTH) + "│") + if dials.get("density") is not None: + lines.append(f"│ Density: {dials['density']}/10 — {dials['density_label']}".ljust(BOX_WIDTH) + "│") + # Pattern section lines.append(section_header("PATTERN", BOX_WIDTH + 1)) lines.append(f"│ Name: {pattern.get('name', '')}".ljust(BOX_WIDTH) + "│") @@ -402,6 +484,17 @@ def format_ascii_box(design_system: dict) -> str: for line in wrap_text(effects, "│ ", BOX_WIDTH): lines.append(line.ljust(BOX_WIDTH) + "│") + # Motion section (GSAP skeleton, only if --motion dial was set) + if motion_snippet: + lines.append(section_header("MOTION", BOX_WIDTH + 1)) + lines.append(f"│ {motion_snippet.get('Category', '')} ({motion_snippet.get('Intensity Tier', '')})".ljust(BOX_WIDTH) + "│") + lines.append(f"│ Trigger: {motion_snippet.get('Trigger', '')} | Duration: {motion_snippet.get('Duration', '')} | Easing: {motion_snippet.get('Easing', '')}".ljust(BOX_WIDTH) + "│") + for line in wrap_text(f"GSAP: {motion_snippet.get('GSAP Snippet', '')}", "│ ", BOX_WIDTH): + lines.append(line.ljust(BOX_WIDTH) + "│") + if motion_snippet.get("Framework Notes"): + for line in wrap_text(f"Framework: {motion_snippet.get('Framework Notes', '')}", "│ ", BOX_WIDTH): + lines.append(line.ljust(BOX_WIDTH) + "│") + # Anti-patterns section if anti_patterns: lines.append(section_header("AVOID", BOX_WIDTH + 1)) @@ -436,11 +529,24 @@ def format_markdown(design_system: dict) -> str: typography = design_system.get("typography", {}) effects = design_system.get("key_effects", "") anti_patterns = design_system.get("anti_patterns", "") + dials = design_system.get("dials", {}) + motion_snippet = design_system.get("motion_snippet", {}) lines = [] lines.append(f"## Design System: {project}") lines.append("") + # Design Dials section (only if at least one dial was set) + if any(dials.get(k) is not None for k in ("variance", "motion", "density")): + lines.append("### Design Dials") + if dials.get("variance") is not None: + lines.append(f"- **Variance:** {dials['variance']}/10 — {dials['variance_label']}") + if dials.get("motion") is not None: + lines.append(f"- **Motion:** {dials['motion']}/10 — {dials['motion_label']}") + if dials.get("density") is not None: + lines.append(f"- **Density:** {dials['density']}/10 — {dials['density_label']}") + lines.append("") + # Pattern section lines.append("### Pattern") lines.append(f"- **Name:** {pattern.get('name', '')}") @@ -515,6 +621,23 @@ def format_markdown(design_system: dict) -> str: lines.append(f"{effects}") lines.append("") + # Motion section (GSAP skeleton, only if --motion dial was set) + if motion_snippet: + lines.append("### Motion") + lines.append(f"**{motion_snippet.get('Category', '')}** ({motion_snippet.get('Intensity Tier', '')}) — Trigger: {motion_snippet.get('Trigger', '')} | Duration: {motion_snippet.get('Duration', '')} | Easing: `{motion_snippet.get('Easing', '')}`") + lines.append("```js") + lines.append(motion_snippet.get("GSAP Snippet", "")) + lines.append("```") + if motion_snippet.get("Framework Notes"): + lines.append(f"*Framework notes: {motion_snippet.get('Framework Notes', '')}*") + motion_do = motion_snippet.get("Do", "") + motion_dont = motion_snippet.get("Don't", "") + if motion_do: + lines.append(f"- ✅ {motion_do}") + if motion_dont: + lines.append(f"- ❌ {motion_dont}") + lines.append("") + # Anti-patterns section if anti_patterns: lines.append("### Avoid (Anti-patterns)") @@ -537,8 +660,9 @@ def format_markdown(design_system: dict) -> str: # ============ MAIN ENTRY POINT ============ -def generate_design_system(query: str, project_name: str = None, output_format: str = "ascii", - persist: bool = False, page: str = None, output_dir: str = None) -> str: +def generate_design_system(query: str, project_name: str = None, output_format: str = "ascii", + persist: bool = False, page: str = None, output_dir: str = None, + variance: int = None, motion: int = None, density: int = None) -> str: """ Main entry point for design system generation. @@ -549,13 +673,16 @@ def generate_design_system(query: str, project_name: str = None, output_format: persist: If True, save design system to design-system/ folder page: Optional page name for page-specific override file output_dir: Optional output directory (defaults to current working directory) + variance: Optional 1-10 DESIGN_VARIANCE dial (1=centered/minimal, 10=bold/asymmetric) + motion: Optional 1-10 MOTION_INTENSITY dial, pulls a matching GSAP snippet from motion.csv + density: Optional 1-10 VISUAL_DENSITY dial, overrides the spacing scale (1=spacious, 10=dense) Returns: Formatted design system string """ generator = DesignSystemGenerator() - design_system = generator.generate(query, project_name) - + design_system = generator.generate(query, project_name, variance=variance, motion=motion, density=density) + # Persist to files if requested if persist: persist_design_system(design_system, page, output_dir, query) @@ -627,11 +754,14 @@ def format_master_md(design_system: dict) -> str: typography = design_system.get("typography", {}) effects = design_system.get("key_effects", "") anti_patterns = design_system.get("anti_patterns", "") - + dials = design_system.get("dials", {}) + motion_snippet = design_system.get("motion_snippet", {}) + spacing_scale = design_system.get("spacing_scale") + timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S") - + lines = [] - + # Logic header lines.append("# Design System Master File") lines.append("") @@ -644,6 +774,15 @@ def format_master_md(design_system: dict) -> str: lines.append(f"**Project:** {project}") lines.append(f"**Generated:** {timestamp}") lines.append(f"**Category:** {design_system.get('category', 'General')}") + if any(dials.get(k) is not None for k in ("variance", "motion", "density")): + dial_parts = [] + if dials.get("variance") is not None: + dial_parts.append(f"Variance {dials['variance']}/10 ({dials['variance_label']})") + if dials.get("motion") is not None: + dial_parts.append(f"Motion {dials['motion']}/10 ({dials['motion_label']})") + if dials.get("density") is not None: + dial_parts.append(f"Density {dials['density']}/10 ({dials['density_label']})") + lines.append(f"**Design Dials:** {' | '.join(dial_parts)}") lines.append("") lines.append("---") lines.append("") @@ -695,18 +834,24 @@ def format_master_md(design_system: dict) -> str: lines.append("```") lines.append("") - # Spacing Variables + # Spacing Variables (overridden by the VISUAL_DENSITY dial when set) + default_spacing = DIAL_TIERS["density"][1][2]["spacing"] # mid-tier = the historical defaults + scale = spacing_scale or default_spacing + spacing_usage = { + "xs": "Tight gaps", "sm": "Icon gaps, inline spacing", "md": "Standard padding", + "lg": "Section padding", "xl": "Large gaps", "2xl": "Section margins", "3xl": "Hero padding", + } lines.append("### Spacing Variables") lines.append("") + if spacing_scale: + lines.append(f"*Density: {dials.get('density')}/10 — {dials.get('density_label')}*") + lines.append("") lines.append("| Token | Value | Usage |") lines.append("|-------|-------|-------|") - lines.append("| `--space-xs` | `4px` / `0.25rem` | Tight gaps |") - lines.append("| `--space-sm` | `8px` / `0.5rem` | Icon gaps, inline spacing |") - lines.append("| `--space-md` | `16px` / `1rem` | Standard padding |") - lines.append("| `--space-lg` | `24px` / `1.5rem` | Section padding |") - lines.append("| `--space-xl` | `32px` / `2rem` | Large gaps |") - lines.append("| `--space-2xl` | `48px` / `3rem` | Section margins |") - lines.append("| `--space-3xl` | `64px` / `4rem` | Hero padding |") + for token in ("xs", "sm", "md", "lg", "xl", "2xl", "3xl"): + px_value = scale[token] + rem_value = f"{int(px_value.rstrip('px')) / 16:g}rem" + lines.append(f"| `--space-{token}` | `{px_value}` / `{rem_value}` | {spacing_usage[token]} |") lines.append("") # Shadow Depths @@ -848,7 +993,32 @@ def format_master_md(design_system: dict) -> str: lines.append(f"- **CTA Placement:** {pattern.get('cta_placement', '')}") lines.append(f"- **Section Order:** {pattern.get('sections', '')}") lines.append("") - + + # Motion section (GSAP skeleton, only if --motion dial was set) + if motion_snippet: + lines.append("---") + lines.append("") + lines.append("## Motion") + lines.append("") + lines.append(f"**{motion_snippet.get('Category', '')}** ({motion_snippet.get('Intensity Tier', '')}) — Trigger: {motion_snippet.get('Trigger', '')} | Duration: {motion_snippet.get('Duration', '')} | Easing: `{motion_snippet.get('Easing', '')}`") + lines.append("") + lines.append("```js") + lines.append(motion_snippet.get("GSAP Snippet", "")) + lines.append("```") + lines.append("") + if motion_snippet.get("Framework Notes"): + lines.append(f"**Framework notes:** {motion_snippet.get('Framework Notes', '')}") + lines.append("") + motion_do = motion_snippet.get("Do", "") + motion_dont = motion_snippet.get("Don't", "") + if motion_do: + lines.append(f"- ✅ {motion_do}") + if motion_dont: + lines.append(f"- ❌ {motion_dont}") + if motion_snippet.get("Performance Notes"): + lines.append(f"- ⚡ {motion_snippet.get('Performance Notes', '')}") + lines.append("") + # Anti-Patterns section lines.append("---") lines.append("") diff --git a/src/ui-ux-pro-max/scripts/search.py b/src/ui-ux-pro-max/scripts/search.py index 4d9dca6..fc50d59 100644 --- a/src/ui-ux-pro-max/scripts/search.py +++ b/src/ui-ux-pro-max/scripts/search.py @@ -5,10 +5,16 @@ UI/UX Pro Max Search - BM25 search engine for UI/UX style guides Usage: python search.py "" [--domain ] [--stack ] [--max-results 3] python search.py "" --design-system [-p "Project Name"] python search.py "" --design-system --persist [-p "Project Name"] [--page "dashboard"] + python search.py "" --design-system --variance 8 --motion 9 --density 7 -Domains: style, prompt, color, chart, landing, product, ux, typography, google-fonts +Domains: style, prompt, color, chart, landing, product, ux, typography, google-fonts, motion Stacks: react, nextjs, vue, svelte, astro, swiftui, react-native, flutter, nuxtjs, nuxt-ui, html-tailwind, shadcn, jetpack-compose, threejs, angular, laravel, javafx, wpf, winui, avalonia, uno, uwp +Design dials (1-10, only with --design-system): + --variance DESIGN_VARIANCE: 1=centered/minimal, 10=bold/asymmetric + --motion MOTION_INTENSITY: 1=subtle, 10=complex; attaches a GSAP snippet from motion.csv + --density VISUAL_DENSITY: 1=spacious, 10=dense/dashboard; overrides the spacing scale + Persistence (Master + Overrides pattern): --persist Save design system to design-system/MASTER.md --page Also create a page-specific override file in design-system/pages/ @@ -68,18 +74,25 @@ if __name__ == "__main__": parser.add_argument("--persist", action="store_true", help="Save design system to design-system/MASTER.md (creates hierarchical structure)") parser.add_argument("--page", type=str, default=None, help="Create page-specific override file in design-system/pages/") parser.add_argument("--output-dir", "-o", type=str, default=None, help="Output directory for persisted files (default: current directory)") + # Design dials (1-10), only applied with --design-system + parser.add_argument("--variance", type=int, choices=range(1, 11), metavar="1-10", help="DESIGN_VARIANCE dial: 1=centered/minimal, 10=bold/asymmetric (only with --design-system)") + parser.add_argument("--motion", type=int, choices=range(1, 11), metavar="1-10", help="MOTION_INTENSITY dial: 1=subtle, 10=complex; pulls a matching GSAP snippet from motion.csv (only with --design-system)") + parser.add_argument("--density", type=int, choices=range(1, 11), metavar="1-10", help="VISUAL_DENSITY dial: 1=spacious, 10=dense/dashboard; overrides the spacing scale (only with --design-system)") args = parser.parse_args() # Design system takes priority if args.design_system: result = generate_design_system( - args.query, - args.project_name, + args.query, + args.project_name, args.format, persist=args.persist, page=args.page, - output_dir=args.output_dir + output_dir=args.output_dir, + variance=args.variance, + motion=args.motion, + density=args.density ) print(result) diff --git a/src/ui-ux-pro-max/templates/base/skill-content.md b/src/ui-ux-pro-max/templates/base/skill-content.md index 929602e..18ff275 100644 --- a/src/ui-ux-pro-max/templates/base/skill-content.md +++ b/src/ui-ux-pro-max/templates/base/skill-content.md @@ -110,6 +110,29 @@ If not, use the Master rules exclusively. Now, generate the code... ``` +### Step 2c: Design Dials (optional) + +Three optional 1-10 sliders that tune `--design-system` output without changing your query. Add any combination of them to the same command: + +```bash +python3 skills/ui-ux-pro-max/scripts/search.py "" --design-system --variance <1-10> --motion <1-10> --density <1-10> +``` + +| Dial | Low (1-3) | Mid (4-7) | High (8-10) | +|------|-----------|-----------|-------------| +| `--variance` | Centered / minimal (biases toward Minimalism-style categories) | Balanced / modern | Bold / asymmetric (biases toward Brutalism, Bento Grids) | +| `--motion` | Subtle micro-interactions | Standard scroll/stagger motion | Complex choreography (pin, Flip, SplitText) | +| `--density` | Spacious (24-96px spacing scale) | Standard (16-64px, current default) | Dense/dashboard (8-32px spacing scale) | + +- `--motion` attaches a ready-to-use GSAP snippet (with framework notes, Do/Don't, and performance notes) pulled from `--domain motion`, matched to the resolved tier (Subtle/Standard/Complex). +- `--density` overrides the `--space-*` CSS variable table in the ASCII/markdown/MASTER.md output — use it for dashboards (high) vs. marketing pages (low) without hand-editing tokens. +- Leaving a dial unset keeps that part of the output exactly as it was before (no behavior change). + +**Example:** +```bash +python3 skills/ui-ux-pro-max/scripts/search.py "internal analytics dashboard" --design-system --variance 8 --motion 7 --density 8 -p "Ops Console" +``` + ### Step 3: Supplement with Detailed Searches (as needed) After getting the design system, use domain searches to get additional details: @@ -156,6 +179,7 @@ python3 skills/ui-ux-pro-max/scripts/search.py "" --stack | `landing` | Page structure, CTA strategies | hero, hero-centric, testimonial, pricing, social-proof | | `chart` | Chart types, library recommendations | trend, comparison, timeline, funnel, pie | | `ux` | Best practices, anti-patterns | animation, accessibility, z-index, loading | +| `motion` | GSAP animation skeletons by intensity tier | scroll reveal, stagger, magnetic cursor, page transition | | `react` | React/Next.js performance | waterfall, bundle, suspense, memo, rerender, cache | | `web` | App interface guidelines (iOS/Android/React Native) | accessibilityLabel, touch targets, safe areas, Dynamic Type | | `prompt` | AI prompts, CSS keywords | (style name) |