ui-ux-pro-max
Default web UI/UX design intelligence for Navin. Design-system generator (84 styles, 192 palettes, typography, landing patterns, UX rules) plus mandatory framer-motion for all web sites. Use when building, designing, scaffolding, or reviewing any website, landing page, dashboard,
Install
npx skills add https://github.com/Navinspire-ia/navin/tree/main/navin/skills/ui-ux-pro-max
claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install navinspire-ia-navin@llmmart
git clone https://github.com/Navinspire-ia/navin.git
The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole navinspire-ia/navin collection as a plugin from our marketplace. Git is the plain clone.
Skill manifest
UI/UX Pro Max (Navin default for web)
Bundled design intelligence from ui-ux-pro-max-skill (MIT). This is the default skill for every website / frontend UI task in Navin.
Hard defaults (non-negotiable for web)
- Lock one official design system (Navin Code, non-negotiable): Google Material (
@mui/material+@emotion/react+@emotion/styled), Microsoft Fluent (@fluentui/react), or IBM Carbon (@carbon/react+@carbon/styles). If the user did not choose, callask_user(aGoogle recommended,bMicrosoft,cIBM). Skip takes Google. Never default to Tailwind, shadcn, Chakra, Ant, or a homemade kit. Map MASTER.md tokens onto that vendorThemeProvider. Scaffold with Vite (or Next if named), nevercreate-react-app. - Install and use
framer-motionon every web project (React / Next / Vite / Astro-with-React, etc.):npm install framer-motion # or: pnpm add framer-motion / yarn add framer-motion / bun add framer-motion- If
package.jsonhas noframer-motion(and nomotionpackage), install it before writing UI animation code. - Prefer
framer-motion(motion/AnimatePresence) for enter/exit, stagger, page transitions, and micro-interactions - not ad-hoc CSS-only for hero/section motion. - Always respect
prefers-reduced-motion(disable or simplify motion when set). - Spring defaults:
transition: { type: "spring", duration: 0.3, bounce: 0 }unless the design system says otherwise.
- If
- Install and use Three.js on every Dev web UI, every Marketing / Montage page, every 3D request, and every studio HTML report UI:
Runnpm install three @react-three/fiber @react-three/dreipython3 "$SEARCH" "<theme>" --stack threejsbefore the scene. Use drei helpers. The scene must look designed (PBR, lights, shadows, framed camera), never wallpaper. Still fallback whenprefers-reduced-motion. Never put Three.js on a PPT or Word page. - Generate a design system first for new pages/sites (Step 2 below) before inventing colors/fonts.
- Icons: vendor set of the locked DS (
@mui/icons-material, Fluent icons,@carbon/icons-react). Lucide / Heroicons SVG only as a fallback. Never emoji as icons. - No em dashes / en dashes in ANY UI copy - characters U+2014 and U+2013 are forbidden. Use a plain hyphen
-or rephrase. This is enforced byverify/ lint (no-em-dash). - No cardboard apps - every visible control must work or be removed. No "Coming soon", empty
onClick,alert()stubs, or lorem. Dashboards must load real data or a real empty state. - Functional Preview gate - after
open_preview, click the main nav and the primary CTA. If the dashboard is blank or errors, you are not done.
Also load make-interfaces-feel-better for polish details (radius, shadows, stagger).
Super render stack (marketing, launch, portfolio)
A pretty page is not 12 runtimes. Install what the surface needs. Stop there.
Every marketing / launch / editorial site (on top of framer-motion):
npm install framer-motion lenis embla-carousel-react lucide-react three @react-three/fiber @react-three/drei
| Package | Job |
|---|---|
framer-motion |
Hero presence, section reveal, stagger, page transition, press. Already mandatory. |
lenis |
Smooth scroll. Wire it once at the root. Disable when prefers-reduced-motion. |
embla-carousel-react |
Lookbook, product shots, proof strip. Not Swiper. |
lucide-react |
Icons. SVG only. Never emoji. |
three + @react-three/fiber + @react-three/drei |
Designed 3D layer on every Marketing / Montage page. Same quality bar as Dev. |
Dev, Marketing, Montage, any 3D page, and studio HTML report UIs (mandatory):
npm install three @react-three/fiber @react-three/drei
Before writing the scene, run the Three.js stack search and follow every hit:
python3 "$SEARCH" "<product or report theme> spatial hero" --stack threejs
| Package | Job |
|---|---|
three + @react-three/fiber + @react-three/drei |
Designed 3D layer on every Dev, Marketing, and Montage web surface, every 3D request, and every studio HTML report UI. Hero, product, or spatial chrome. Still fallback required. |
@react-three/postprocessing |
That 3D hero needs bloom / grain. Never as the only "design". |
@number-flow/react |
A giant KPI that ticks. One per viewport max. |
recharts |
A dashboard or a real data section. Not a landing decoration. |
Scene quality (non-negotiable): one Canvas / one renderer; pixelRatio capped at 2; antialias at construction; PBR (meshStandardMaterial) plus Ambient + Directional lights (objects must not render black); shadowMap enabled before cast/receive; FOV 45-75; explicit camera position + lookAt; drei OrbitControls (damping) or a constrained camera; Environment + ContactShadows on the hero; useFrame / Clock.getDelta() once per frame; pause the loop when the tab is hidden; dispose geometries/materials/textures on teardown; canvas role="img" + aria-label; prefers-reduced-motion shows the still. Production = npm + Vite, not a floating CDN latest.
The 3D is a designed object or environment the user can read. Never Three.js as wallpaper, never a particle field as the page, never a blank <Canvas />.
Do not install by default: GSAP, ScrollTrigger, Locomotive, Spline runtime, Rive, Lottie, tsParticles / particles.js, Three.js as wallpaper, Swiper, Barba, Theatre.js. GSAP only if the user names it. One Rive mark is allowed when the brand already has a .riv file. Never put Three.js on a PPT or Word page (it becomes a screenshot).
Reduced motion: Lenis off, Motion snaps, 3D shows the still, Number Flow shows the final figure.
When to use
Any UI that looks, feels, moves, or is interacted with: landings, marketing sites, SaaS app shells, dashboards, CRMs, portfolios, e-commerce, forms, component libraries.
Skip for pure backend/API/DB/infra with no UI.
Search tool path
The bundled script is <skill folder>/scripts/search.py (no network, stdlib only). The skill folder's absolute path is printed when this skill is loaded - the [Skill folder: ...] header above, or the (Skill folder: ...) line under the skill title. Use that path as SEARCH below; never hunt for it with imports or a filesystem-wide find. Use python3 on Linux/macOS and python (or py -3) on Windows.
Workflow
1. Analyze
Extract: product type, industry, audience, tone, stack (from package.json / framework files - never assume). Default stack for greenfield web: React + Vite (or Next if the user named it) plus one official DS: MUI, Fluent, or Carbon. Never Tailwind as the default system.
2. Design system (required for new pages/projects)
python3 "$SEARCH" "<product> <industry> <keywords>" --design-system -p "Project Name"
Persist into the user workspace:
python3 "$SEARCH" "<query>" --design-system --persist -p "Project Name" --output-dir "<project-root>"
Creates design-system/<slug>/MASTER.md (+ optional --page "dashboard" overrides). If MASTER already exists, read it first; only regenerate with --force when the user wants a reset.
3. Optional dials
--variance / --motion / --density (1-10) on the same --design-system command.
For web sites, prefer --motion in the 5-8 range and implement those motions with framer-motion (not GSAP unless the user asks).
4. Domain / stack deep-dives
python3 "$SEARCH" "<keyword>" --domain style|color|typography|landing|ux|chart|icons|react|gsap|...
python3 "$SEARCH" "<keyword>" --stack react|nextjs|vue|html-tailwind|shadcn|...
5. Implement
- Apply MASTER.md tokens through the locked vendor theme (MUI / Fluent / Carbon), not as a parallel homemade CSS kit.
- Install
framer-motionif missing. - On Dev / Marketing / Montage / 3D / studio report UIs: install
three+@react-three/fiber+@react-three/dreiif missing, run--stack threejs, then implement the scene with drei helpers (OrbitControls, Environment, ContactShadows, PresentationControls as needed). - Ship 2-3 intentional motions (hero presence, section reveal / stagger, CTA hover/press) - not noise.
- Follow Navin frontend rules when they apply: one composition in the first viewport, brand-first, expressive fonts (not Inter/Roboto/Arial defaults), atmospheric backgrounds, full-bleed heroes on landings, no card clutter in heroes, avoid purple-on-white / cream+terracotta / broadsheet clichés unless the design system explicitly requires them.
6. Pre-delivery checklist
- Official DS locked:
@mui/materialor@fluentui/reactor@carbon/react(ask_user if none) -
framer-motionin package.json and used for primary animations - Dev / Marketing / Montage / 3D / studio report UI:
three+@react-three/fiber+@react-three/dreiinstalled and used;--stack threejswas run - 3D scene meets the quality bar (lights, shadows, camera, one renderer, reduced-motion still)
- Marketing/launch pages also have
lenis,embla-carousel-react,lucide-reactwhen those surfaces exist - No GSAP / particles / Three-as-wallpaper unless the user asked
- No emoji icons (SVG only)
-
cursor-pointeron clickable elements - Hover/focus transitions 150-300ms
- Text contrast ≥ 4.5:1 (light mode)
- Visible keyboard focus
-
prefers-reduced-motionrespected - Responsive: 375 / 768 / 1024 / 1440
- Design system MASTER.md present for new sites
- No em-dash / en-dash characters in UI strings (
verifymust be clean) - Every primary button/nav item routes or mutates for real (no stubs)
- Dashboard / home view loads without blank screen (data or empty state)
- Preview happy path clicked by you before "done"
If search returns 0 results
Retry with broader keywords once; then fall back to the priority table in references/quick-reference.md and say the fallback is not a DB match. Never invent fake search hits.
References (read on demand)
references/quick-reference.md- full UX guideline indexreferences/pro-rules.md- app/native checklist extras- Upstream: https://github.com/nextlevelbuilder/ui-ux-pro-max-skill
Files (navin)
-
data
-
stacks
-
angular.csv 17.7 KB · in bundle
-
astro.csv 11.6 KB · in bundle
-
avalonia.csv 25 KB · in bundle
-
flutter.csv 10.2 KB · in bundle
-
html-tailwind.csv 11 KB · in bundle
-
javafx.csv 30.1 KB · in bundle
-
jetpack-compose.csv 8 KB · in bundle
-
laravel.csv 18.1 KB · in bundle
-
nextjs.csv 12.2 KB · in bundle
-
nuxt-ui.csv 20.7 KB · in bundle
-
nuxtjs.csv 16.2 KB · in bundle
-
react-native.csv 9.7 KB · in bundle
-
react.csv 12.7 KB · in bundle
-
shadcn.csv 15.5 KB · in bundle
-
svelte.csv 10.8 KB · in bundle
-
swiftui.csv 10.6 KB · in bundle
-
threejs.csv 43.6 KB · in bundle
-
uno.csv 26.8 KB · in bundle
-
uwp.csv 22.3 KB · in bundle
-
vue.csv 10.7 KB · in bundle
-
winui.csv 24.6 KB · in bundle
-
wpf.csv 22 KB · in bundle
-
-
app-interface.csv 9.5 KB · in bundle
-
charts.csv 18.9 KB · in bundle
-
colors.csv 37.8 KB · in bundle
-
google-fonts.csv 725.9 KB · in bundle
-
icons.csv 20.2 KB · in bundle
-
landing.csv 16.3 KB · in bundle
-
motion.csv 10.3 KB · in bundle
-
products.csv 71.8 KB · in bundle
-
react-performance.csv 14.5 KB · in bundle
-
styles.csv 139.2 KB · in bundle
-
typography.csv 48.6 KB · in bundle
-
ui-reasoning.csv 51.7 KB · in bundle
-
ux-guidelines.csv 18.2 KB · in bundle
-
-
references
-
pro-rules.md 9.4 KB
# Common Rules for Professional UI + Pre-Delivery Checklist Load this file before final delivery of native/mobile app UI (iOS/Android/React Native/Flutter), or when the user reports the UI "doesn't look professional" and the cause isn't obvious from the priority table in SKILL.md. **Scope notice:** everything below targets native/mobile app UI. For web/desktop interaction patterns, use `references/quick-reference.md` (stack-agnostic) instead - these tables assume touch targets, safe areas, and platform gesture conventions that don't apply 1:1 to desktop web. These are frequently overlooked issues that make UI look unprofessional. ## Icons & Visual Elements | Rule | Standard | Avoid | Why It Matters | |------|----------|--------|----------------| | **No Emoji as Structural Icons** | Use vector-based icons (e.g., Lucide, react-native-vector-icons, @expo/vector-icons). | Using emojis (🎨 🚀 ⚙️) for navigation, settings, or system controls. | Emojis are font-dependent, inconsistent across platforms, and cannot be controlled via design tokens. | | **Vector-Only Assets** | Use SVG or platform vector icons that scale cleanly and support theming. | Raster PNG icons that blur or pixelate. | Ensures scalability, crisp rendering, and dark/light mode adaptability. | | **Stable Interaction States** | Use color, opacity, or elevation transitions for press states without changing layout bounds. | Layout-shifting transforms that move surrounding content or trigger visual jitter. | Prevents unstable interactions and preserves smooth motion/perceived quality on mobile. | | **Correct Brand Logos** | Use official brand assets and follow their usage guidelines (spacing, color, clear space). | Guessing logo paths, recoloring unofficially, or modifying proportions. | Prevents brand misuse and ensures legal/platform compliance. | | **Consistent Icon Sizing** | Define icon sizes as design tokens (e.g., icon-sm, icon-md = 24pt, icon-lg). | Mixing arbitrary values like 20pt / 24pt / 28pt randomly. | Maintains rhythm and visual hierarchy across the interface. | | **Stroke Consistency** | Use a consistent stroke width within the same visual layer (e.g., 1.5px or 2px). | Mixing thick and thin stroke styles arbitrarily. | Inconsistent strokes reduce perceived polish and cohesion. | | **Filled vs Outline Discipline** | Use one icon style per hierarchy level. | Mixing filled and outline icons at the same hierarchy level. | Maintains semantic clarity and stylistic coherence. | | **Touch Target Minimum** | Minimum 44×44pt interactive area (use hitSlop if icon is smaller). | Small icons without expanded tap area. | Meets accessibility and platform usability standards. | | **Icon Alignment** | Align icons to text baseline and maintain consistent padding. | Misaligned icons or inconsistent spacing around them. | Prevents subtle visual imbalance that reduces perceived quality. | | **Icon Contrast** | Follow WCAG contrast standards: 4.5:1 for small elements, 3:1 minimum for larger UI glyphs. | Low-contrast icons that blend into the background. | Ensures accessibility in both light and dark modes. | ## Interaction (App) | Rule | Do | Don't | |------|----|----- | | **Tap feedback** | Provide clear pressed feedback (ripple/opacity/elevation) within 80-150ms | No visual response on tap | | **Animation timing** | Keep micro-interactions around 150-300ms with platform-native easing | Instant transitions or slow animations (>500ms) | | **Accessibility focus** | Ensure screen reader focus order matches visual order and labels are descriptive | Unlabeled controls or confusing focus traversal | | **Disabled state clarity** | Use disabled semantics (`disabled`/native disabled props), reduced emphasis, and no tap action | Controls that look tappable but do nothing | | **Touch target minimum** | Keep tap areas >=44x44pt (iOS) or >=48x48dp (Android), expand hit area when icon is smaller | Tiny tap targets or icon-only hit areas without padding | | **Gesture conflict prevention** | Keep one primary gesture per region and avoid nested tap/drag conflicts | Overlapping gestures causing accidental actions | | **Semantic native controls** | Prefer native interactive primitives (`Button`, `Pressable`, platform equivalents) with proper accessibility roles | Generic containers used as primary controls without semantics | ## Light/Dark Mode Contrast | Rule | Do | Don't | |------|----|----- | | **Surface readability (light)** | Keep cards/surfaces clearly separated from background with sufficient opacity/elevation | Overly transparent surfaces that blur hierarchy | | **Text contrast (light)** | Maintain body text contrast >=4.5:1 against light surfaces | Low-contrast gray body text | | **Text contrast (dark)** | Maintain primary text contrast >=4.5:1 and secondary text >=3:1 on dark surfaces | Dark mode text that blends into background | | **Border and divider visibility** | Ensure separators are visible in both themes (not just light mode) | Theme-specific borders disappearing in one mode | | **State contrast parity** | Keep pressed/focused/disabled states equally distinguishable in light and dark themes | Defining interaction states for one theme only | | **Token-driven theming** | Use semantic color tokens mapped per theme across app surfaces/text/icons | Hardcoded per-screen hex values | | **Scrim and modal legibility** | Use a modal scrim strong enough to isolate foreground content (typically 40-60% black) | Weak scrim that leaves background visually competing | ## Layout & Spacing | Rule | Do | Don't | |------|----|----- | | **Safe-area compliance** | Respect top/bottom safe areas for all fixed headers, tab bars, and CTA bars | Placing fixed UI under notch, status bar, or gesture area | | **System bar clearance** | Add spacing for status/navigation bars and gesture home indicator | Let tappable content collide with OS chrome | | **Consistent content width** | Keep predictable content width per device class (phone/tablet) | Mixing arbitrary widths between screens | | **8dp spacing rhythm** | Use a consistent 4/8dp spacing system for padding/gaps/section spacing | Random spacing increments with no rhythm | | **Readable text measure** | Keep long-form text readable on large devices (avoid edge-to-edge paragraphs on tablets) | Full-width long text that hurts readability | | **Section spacing hierarchy** | Define clear vertical rhythm tiers (e.g., 16/24/32/48) by hierarchy | Similar UI levels with inconsistent spacing | | **Adaptive gutters by breakpoint** | Increase horizontal insets on larger widths and in landscape | Same narrow gutter on all device sizes/orientations | | **Scroll and fixed element coexistence** | Add bottom/top content insets so lists are not hidden behind fixed bars | Scroll content obscured by sticky headers/footers | --- ## Pre-Delivery Checklist (canonical - the only one) Before delivering app UI code, verify every item below. Start with the process steps, then the per-area checkboxes. ### Process - [ ] Ran `--domain ux "animation accessibility z-index loading"` as a validation pass before implementation - [ ] Reviewed `quick-reference.md` §1-§3 (CRITICAL + HIGH) as a final pass - [ ] Tested on 375px (small phone) and in landscape orientation - [ ] Verified behavior with **reduced-motion** enabled and **Dynamic Type**/largest system text size - [ ] Checked dark mode contrast independently (never assume light-mode values carry over) - [ ] Confirmed all touch targets ≥44pt and no content hidden behind safe areas ### Visual Quality - [ ] No emojis used as icons (use SVG instead) - [ ] All icons come from a consistent icon family and style - [ ] Official brand assets are used with correct proportions and clear space - [ ] Pressed-state visuals do not shift layout bounds or cause jitter - [ ] Semantic theme tokens are used consistently (no ad-hoc per-screen hardcoded colors) ### Interaction - [ ] All tappable elements provide clear pressed feedback (ripple/opacity/elevation) - [ ] Touch targets meet minimum size (>=44x44pt iOS, >=48x48dp Android) - [ ] Micro-interaction timing stays in the 150-300ms range with native-feeling easing - [ ] Disabled states are visually clear and non-interactive - [ ] Screen reader focus order matches visual order, and interactive labels are descriptive - [ ] Gesture regions avoid nested/conflicting interactions (tap/drag/back-swipe conflicts) ### Light/Dark Mode - [ ] Primary text contrast >=4.5:1 in both light and dark mode - [ ] Secondary text contrast >=3:1 in both light and dark mode - [ ] Dividers/borders and interaction states are distinguishable in both modes - [ ] Modal/drawer scrim opacity is strong enough to preserve foreground legibility (typically 40-60% black) - [ ] Both themes are tested before delivery (not inferred from a single theme) ### Layout - [ ] Safe areas are respected for headers, tab bars, and bottom CTA bars - [ ] Scroll content is not hidden behind fixed/sticky bars - [ ] Verified on small phone, large phone, and tablet (portrait + landscape) - [ ] Horizontal insets/gutters adapt correctly by device size and orientation - [ ] 4/8dp spacing rhythm is maintained across component, section, and page levels - [ ] Long-form text measure remains readable on larger devices (no edge-to-edge paragraphs) ### Accessibility - [ ] All meaningful images/icons have accessibility labels - [ ] Form fields have labels, hints, and clear error messages - [ ] Color is not the only indicator - [ ] Reduced motion and dynamic text size are supported without layout breakage -
quick-reference.md 21.1 KB
# Quick Reference - Full Rule Set (all 10 categories) Load this file when doing a UI review/audit pass, or when you need the full checklist for a category beyond the priority table in SKILL.md. Each rule is also present verbatim in `data/ux-guidelines.csv` / `data/app-interface.csv` and is reachable via `--domain ux` / `--domain web` search - this file is a static index for quick scanning without a search round-trip. ## Quick Reference ### 1. Accessibility (CRITICAL) - `color-contrast` - Minimum 4.5:1 ratio for normal text (large text 3:1); Material Design - `focus-states` - Visible focus rings on interactive elements (2-4px; Apple HIG, MD) - `alt-text` - Descriptive alt text for meaningful images - `aria-labels` - aria-label for icon-only buttons; accessibilityLabel in native (Apple HIG) - `keyboard-nav` - Tab order matches visual order; full keyboard support (Apple HIG) - `form-labels` - Use label with for attribute - `skip-links` - Skip to main content for keyboard users - `heading-hierarchy` - Sequential h1→h6, no level skip - `color-not-only` - Don't convey info by color alone (add icon/text) - `dynamic-type` - Support system text scaling; avoid truncation as text grows (Apple Dynamic Type, MD) - `reduced-motion` - Respect prefers-reduced-motion; reduce/disable animations when requested (Apple Reduced Motion API, MD) - `voiceover-sr` - Meaningful accessibilityLabel/accessibilityHint; logical reading order for VoiceOver/screen readers (Apple HIG, MD) - `escape-routes` - Provide cancel/back in modals and multi-step flows (Apple HIG) - `keyboard-shortcuts` - Preserve system and a11y shortcuts; offer keyboard alternatives for drag-and-drop (Apple HIG) ### 2. Touch & Interaction (CRITICAL) - `touch-target-size` - Min 44×44pt (Apple) / 48×48dp (Material); extend hit area beyond visual bounds if needed - `touch-spacing` - Minimum 8px/8dp gap between touch targets (Apple HIG, MD) - `hover-vs-tap` - Use click/tap for primary interactions; don't rely on hover alone - `loading-buttons` - Disable button during async operations; show spinner or progress - `error-feedback` - Clear error messages near problem - `cursor-pointer` - Add cursor-pointer to clickable elements (Web) - `gesture-conflicts` - Avoid horizontal swipe on main content; prefer vertical scroll - `tap-delay` - Use touch-action: manipulation to reduce 300ms delay (Web) - `standard-gestures` - Use platform standard gestures consistently; don't redefine (e.g. swipe-back, pinch-zoom) (Apple HIG) - `system-gestures` - Don't block system gestures (Control Center, back swipe, etc.) (Apple HIG) - `press-feedback` - Visual feedback on press (ripple/highlight; MD state layers) - `haptic-feedback` - Use haptic for confirmations and important actions; avoid overuse (Apple HIG) - `gesture-alternative` - Don't rely on gesture-only interactions; always provide visible controls for critical actions - `safe-area-awareness` - Keep primary touch targets away from notch, Dynamic Island, gesture bar and screen edges - `no-precision-required` - Avoid requiring pixel-perfect taps on small icons or thin edges - `swipe-clarity` - Swipe actions must show clear affordance or hint (chevron, label, tutorial) - `drag-threshold` - Use a movement threshold before starting drag to avoid accidental drags ### 3. Performance (HIGH) - `image-optimization` - Use WebP/AVIF, responsive images (srcset/sizes), lazy load non-critical assets - `image-dimension` - Declare width/height or use aspect-ratio to prevent layout shift (Core Web Vitals: CLS) - `font-loading` - Use font-display: swap/optional to avoid invisible text (FOIT); reserve space to reduce layout shift (MD) - `font-preload` - Preload only critical fonts; avoid overusing preload on every variant - `critical-css` - Prioritize above-the-fold CSS (inline critical CSS or early-loaded stylesheet) - `lazy-loading` - Lazy load non-hero components via dynamic import / route-level splitting - `bundle-splitting` - Split code by route/feature (React Suspense / Next.js dynamic) to reduce initial load and TTI - `third-party-scripts` - Load third-party scripts async/defer; audit and remove unnecessary ones (MD) - `reduce-reflows` - Avoid frequent layout reads/writes; batch DOM reads then writes - `content-jumping` - Reserve space for async content to avoid layout jumps (Core Web Vitals: CLS) - `lazy-load-below-fold` - Use loading="lazy" for below-the-fold images and heavy media - `virtualize-lists` - Virtualize lists with 50+ items to improve memory efficiency and scroll performance - `main-thread-budget` - Keep per-frame work under ~16ms for 60fps; move heavy tasks off main thread (HIG, MD) - `progressive-loading` - Use skeleton screens / shimmer instead of long blocking spinners for >1s operations (Apple HIG) - `input-latency` - Keep input latency under ~100ms for taps/scrolls (Material responsiveness standard) - `tap-feedback-speed` - Provide visual feedback within 100ms of tap (Apple HIG) - `debounce-throttle` - Use debounce/throttle for high-frequency events (scroll, resize, input) - `offline-support` - Provide offline state messaging and basic fallback (PWA / mobile) - `network-fallback` - Offer degraded modes for slow networks (lower-res images, fewer animations) ### 4. Style Selection (HIGH) - `style-match` - Match style to product type (use `--design-system` for recommendations) - `consistency` - Use same style across all pages - `no-emoji-icons` - Use SVG icons (Heroicons, Lucide), not emojis - `color-palette-from-product` - Choose palette from product/industry (search `--domain color`) - `effects-match-style` - Shadows, blur, radius aligned with chosen style (glass / flat / clay etc.) - `platform-adaptive` - Respect platform idioms (iOS HIG vs Material): navigation, controls, typography, motion - `state-clarity` - Make hover/pressed/disabled states visually distinct while staying on-style (Material state layers) - `elevation-consistent` - Use a consistent elevation/shadow scale for cards, sheets, modals; avoid random shadow values - `dark-mode-pairing` - Design light/dark variants together to keep brand, contrast, and style consistent - `icon-style-consistent` - Use one icon set/visual language (stroke width, corner radius) across the product - `system-controls` - Prefer native/system controls over fully custom ones; only customize when branding requires it (Apple HIG) - `blur-purpose` - Use blur to indicate background dismissal (modals, sheets), not as decoration (Apple HIG) - `primary-action` - Each screen should have only one primary CTA; secondary actions visually subordinate (Apple HIG) ### 5. Layout & Responsive (HIGH) - `viewport-meta` - width=device-width initial-scale=1 (never disable zoom) - `mobile-first` - Design mobile-first, then scale up to tablet and desktop - `breakpoint-consistency` - Use systematic breakpoints (e.g. 375 / 768 / 1024 / 1440) - `readable-font-size` - Minimum 16px body text on mobile (avoids iOS auto-zoom) - `line-length-control` - Mobile 35-60 chars per line; desktop 60-75 chars - `horizontal-scroll` - No horizontal scroll on mobile; ensure content fits viewport width - `spacing-scale` - Use 4pt/8dp incremental spacing system (Material Design) - `touch-density` - Keep component spacing comfortable for touch: not cramped, not causing mis-taps - `container-width` - Consistent max-width on desktop (max-w-6xl / 7xl) - `z-index-management` - Define layered z-index scale (e.g. 0 / 10 / 20 / 40 / 100 / 1000) - `fixed-element-offset` - Fixed navbar/bottom bar must reserve safe padding for underlying content - `scroll-behavior` - Avoid nested scroll regions that interfere with the main scroll experience - `viewport-units` - Prefer min-h-dvh over 100vh on mobile - `orientation-support` - Keep layout readable and operable in landscape mode - `content-priority` - Show core content first on mobile; fold or hide secondary content - `visual-hierarchy` - Establish hierarchy via size, spacing, contrast - not color alone ### 6. Typography & Color (MEDIUM) - `line-height` - Use 1.5-1.75 for body text - `line-length` - Limit to 65-75 characters per line - `font-pairing` - Match heading/body font personalities - `font-scale` - Consistent type scale (e.g. 12 14 16 18 24 32) - `contrast-readability` - Darker text on light backgrounds (e.g. slate-900 on white) - `text-styles-system` - Use platform type system: iOS 11 Dynamic Type styles / Material 5 type roles (display, headline, title, body, label) (HIG, MD) - `weight-hierarchy` - Use font-weight to reinforce hierarchy: Bold headings (600-700), Regular body (400), Medium labels (500) (MD) - `color-semantic` - Define semantic color tokens (primary, secondary, error, surface, on-surface) not raw hex in components (Material color system) - `color-dark-mode` - Dark mode uses desaturated / lighter tonal variants, not inverted colors; test contrast separately (HIG, MD) - `color-accessible-pairs` - Foreground/background pairs must meet 4.5:1 (AA) or 7:1 (AAA); use tools to verify (WCAG, MD) - `color-not-decorative-only` - Functional color (error red, success green) must include icon/text; avoid color-only meaning (HIG, MD) - `truncation-strategy` - Prefer wrapping over truncation; when truncating use ellipsis and provide full text via tooltip/expand (Apple HIG) - `letter-spacing` - Respect default letter-spacing per platform; avoid tight tracking on body text (HIG, MD) - `number-tabular` - Use tabular/monospaced figures for data columns, prices, and timers to prevent layout shift - `whitespace-balance` - Use whitespace intentionally to group related items and separate sections; avoid visual clutter (Apple HIG) ### 7. Animation (MEDIUM) - `duration-timing` - Use 150-300ms for micro-interactions; complex transitions ≤400ms; avoid >500ms (MD) - `transform-performance` - Use transform/opacity only; avoid animating width/height/top/left - `loading-states` - Show skeleton or progress indicator when loading exceeds 300ms - `excessive-motion` - Animate 1-2 key elements per view max - `easing` - Use ease-out for entering, ease-in for exiting; avoid linear for UI transitions - `motion-meaning` - Every animation must express a cause-effect relationship, not just be decorative (Apple HIG) - `state-transition` - State changes (hover / active / expanded / collapsed / modal) should animate smoothly, not snap - `continuity` - Page/screen transitions should maintain spatial continuity (shared element, directional slide) (Apple HIG) - `parallax-subtle` - Use parallax sparingly; must respect reduced-motion and not cause disorientation (Apple HIG) - `spring-physics` - Prefer spring/physics-based curves over linear or cubic-bezier for natural feel (Apple HIG fluid animations) - `exit-faster-than-enter` - Exit animations shorter than enter (~60-70% of enter duration) to feel responsive (MD motion) - `stagger-sequence` - Stagger list/grid item entrance by 30-50ms per item; avoid all-at-once or too-slow reveals (MD) - `shared-element-transition` - Use shared element / hero transitions for visual continuity between screens (MD, HIG) - `interruptible` - Animations must be interruptible; user tap/gesture cancels in-progress animation immediately (Apple HIG) - `no-blocking-animation` - Never block user input during an animation; UI must stay interactive (Apple HIG) - `fade-crossfade` - Use crossfade for content replacement within the same container (MD) - `scale-feedback` - Subtle scale (0.95-1.05) on press for tappable cards/buttons; restore on release (HIG, MD) - `gesture-feedback` - Drag, swipe, and pinch must provide real-time visual response tracking the finger (MD Motion) - `hierarchy-motion` - Use translate/scale direction to express hierarchy: enter from below = deeper, exit upward = back (MD) - `motion-consistency` - Unify duration/easing tokens globally; all animations share the same rhythm and feel - `opacity-threshold` - Fading elements should not linger below opacity 0.2; either fade fully or remain visible - `modal-motion` - Modals/sheets should animate from their trigger source (scale+fade or slide-in) for spatial context (HIG, MD) - `navigation-direction` - Forward navigation animates left/up; backward animates right/down - keep direction logically consistent (HIG) - `layout-shift-avoid` - Animations must not cause layout reflow or CLS; use transform for position changes ### 8. Forms & Feedback (MEDIUM) - `input-labels` - Visible label per input (not placeholder-only) - `error-placement` - Show error below the related field - `submit-feedback` - Loading then success/error state on submit - `required-indicators` - Mark required fields (e.g. asterisk) - `empty-states` - Helpful message and action when no content - `toast-dismiss` - Auto-dismiss toasts in 3-5s - `confirmation-dialogs` - Confirm before destructive actions - `input-helper-text` - Provide persistent helper text below complex inputs, not just placeholder (Material Design) - `disabled-states` - Disabled elements use reduced opacity (0.38-0.5) + cursor change + semantic attribute (MD) - `progressive-disclosure` - Reveal complex options progressively; don't overwhelm users upfront (Apple HIG) - `inline-validation` - Validate on blur (not keystroke); show error only after user finishes input (MD) - `input-type-keyboard` - Use semantic input types (email, tel, number) to trigger the correct mobile keyboard (HIG, MD) - `password-toggle` - Provide show/hide toggle for password fields (MD) - `autofill-support` - Use autocomplete / textContentType attributes so the system can autofill (HIG, MD) - `undo-support` - Allow undo for destructive or bulk actions (e.g. "Undo delete" toast) (Apple HIG) - `success-feedback` - Confirm completed actions with brief visual feedback (checkmark, toast, color flash) (MD) - `error-recovery` - Error messages must include a clear recovery path (retry, edit, help link) (HIG, MD) - `multi-step-progress` - Multi-step flows show step indicator or progress bar; allow back navigation (MD) - `form-autosave` - Long forms should auto-save drafts to prevent data loss on accidental dismissal (Apple HIG) - `sheet-dismiss-confirm` - Confirm before dismissing a sheet/modal with unsaved changes (Apple HIG) - `error-clarity` - Error messages must state cause + how to fix (not just "Invalid input") (HIG, MD) - `field-grouping` - Group related fields logically (fieldset/legend or visual grouping) (MD) - `read-only-distinction` - Read-only state should be visually and semantically different from disabled (MD) - `focus-management` - After submit error, auto-focus the first invalid field (WCAG, MD) - `error-summary` - For multiple errors, show summary at top with anchor links to each field (WCAG) - `touch-friendly-input` - Mobile input height ≥44px to meet touch target requirements (Apple HIG) - `destructive-emphasis` - Destructive actions use semantic danger color (red) and are visually separated from primary actions (HIG, MD) - `toast-accessibility` - Toasts must not steal focus; use aria-live="polite" for screen reader announcement (WCAG) - `aria-live-errors` - Form errors use aria-live region or role="alert" to notify screen readers (WCAG) - `contrast-feedback` - Error and success state colors must meet 4.5:1 contrast ratio (WCAG, MD) - `timeout-feedback` - Request timeout must show clear feedback with retry option (MD) ### 9. Navigation Patterns (HIGH) - `bottom-nav-limit` - Bottom navigation max 5 items; use labels with icons (Material Design) - `drawer-usage` - Use drawer/sidebar for secondary navigation, not primary actions (Material Design) - `back-behavior` - Back navigation must be predictable and consistent; preserve scroll/state (Apple HIG, MD) - `deep-linking` - All key screens must be reachable via deep link / URL for sharing and notifications (Apple HIG, MD) - `tab-bar-ios` - iOS: use bottom Tab Bar for top-level navigation (Apple HIG) - `top-app-bar-android` - Android: use Top App Bar with navigation icon for primary structure (Material Design) - `nav-label-icon` - Navigation items must have both icon and text label; icon-only nav harms discoverability (MD) - `nav-state-active` - Current location must be visually highlighted (color, weight, indicator) in navigation (HIG, MD) - `nav-hierarchy` - Primary nav (tabs/bottom bar) vs secondary nav (drawer/settings) must be clearly separated (MD) - `modal-escape` - Modals and sheets must offer a clear close/dismiss affordance; swipe-down to dismiss on mobile (Apple HIG) - `search-accessible` - Search must be easily reachable (top bar or tab); provide recent/suggested queries (MD) - `breadcrumb-web` - Web: use breadcrumbs for 3+ level deep hierarchies to aid orientation (MD) - `state-preservation` - Navigating back must restore previous scroll position, filter state, and input (HIG, MD) - `gesture-nav-support` - Support system gesture navigation (iOS swipe-back, Android predictive back) without conflict (HIG, MD) - `tab-badge` - Use badges on nav items sparingly to indicate unread/pending; clear after user visits (HIG, MD) - `overflow-menu` - When actions exceed available space, use overflow/more menu instead of cramming (MD) - `bottom-nav-top-level` - Bottom nav is for top-level screens only; never nest sub-navigation inside it (MD) - `adaptive-navigation` - Large screens (≥1024px) prefer sidebar; small screens use bottom/top nav (Material Adaptive) - `back-stack-integrity` - Never silently reset the navigation stack or unexpectedly jump to home (HIG, MD) - `navigation-consistency` - Navigation placement must stay the same across all pages; don't change by page type - `avoid-mixed-patterns` - Don't mix Tab + Sidebar + Bottom Nav at the same hierarchy level - `modal-vs-navigation` - Modals must not be used for primary navigation flows; they break the user's path (HIG) - `focus-on-route-change` - After page transition, move focus to main content region for screen reader users (WCAG) - `persistent-nav` - Core navigation must remain reachable from deep pages; don't hide it entirely in sub-flows (HIG, MD) - `destructive-nav-separation` - Dangerous actions (delete account, logout) must be visually and spatially separated from normal nav items (HIG, MD) - `empty-nav-state` - When a nav destination is unavailable, explain why instead of silently hiding it (MD) ### 10. Charts & Data (LOW) - `chart-type` - Match chart type to data type (trend → line, comparison → bar, proportion → pie/donut) - `color-guidance` - Use accessible color palettes; avoid red/green only pairs for colorblind users (WCAG, MD) - `data-table` - Provide table alternative for accessibility; charts alone are not screen-reader friendly (WCAG) - `pattern-texture` - Supplement color with patterns, textures, or shapes so data is distinguishable without color (WCAG, MD) - `legend-visible` - Always show legend; position near the chart, not detached below a scroll fold (MD) - `tooltip-on-interact` - Provide tooltips/data labels on hover (Web) or tap (mobile) showing exact values (HIG, MD) - `axis-labels` - Label axes with units and readable scale; avoid truncated or rotated labels on mobile - `responsive-chart` - Charts must reflow or simplify on small screens (e.g. horizontal bar instead of vertical, fewer ticks) - `empty-data-state` - Show meaningful empty state when no data exists ("No data yet" + guidance), not a blank chart (MD) - `loading-chart` - Use skeleton or shimmer placeholder while chart data loads; don't show an empty axis frame - `animation-optional` - Chart entrance animations must respect prefers-reduced-motion; data should be readable immediately (HIG) - `large-dataset` - For 1000+ data points, aggregate or sample; provide drill-down for detail instead of rendering all (MD) - `number-formatting` - Use locale-aware formatting for numbers, dates, currencies on axes and labels (HIG, MD) - `touch-target-chart` - Interactive chart elements (points, segments) must have ≥44pt tap area or expand on touch (Apple HIG) - `no-pie-overuse` - Avoid pie/donut for >5 categories; switch to bar chart for clarity - `contrast-data` - Data lines/bars vs background ≥3:1; data text labels ≥4.5:1 (WCAG) - `legend-interactive` - Legends should be clickable to toggle series visibility (MD) - `direct-labeling` - For small datasets, label values directly on the chart to reduce eye travel - `tooltip-keyboard` - Tooltip content must be keyboard-reachable and not rely on hover alone (WCAG) - `sortable-table` - Data tables must support sorting with aria-sort indicating current sort state (WCAG) - `axis-readability` - Axis ticks must not be cramped; maintain readable spacing, auto-skip on small screens - `data-density` - Limit information density per chart to avoid cognitive overload; split into multiple charts if needed - `trend-emphasis` - Emphasize data trends over decoration; avoid heavy gradients/shadows that obscure the data - `gridline-subtle` - Grid lines should be low-contrast (e.g. gray-200) so they don't compete with data - `focusable-elements` - Interactive chart elements (points, bars, slices) must be keyboard-navigable (WCAG) - `screen-reader-summary` - Provide a text summary or aria-label describing the chart's key insight for screen readers (WCAG) - `error-state-chart` - Data load failure must show error message with retry action, not a broken/empty chart - `export-option` - For data-heavy products, offer CSV/image export of chart data - `drill-down-consistency` - Drill-down interactions must maintain a clear back-path and hierarchy breadcrumb - `time-scale-clarity` - Time series charts must clearly label time granularity (day/week/month) and allow switching
-
-
scripts
-
tests
-
test_core.py 5.8 KB
#!/usr/bin/env python3 # -*- coding: utf-8 -*- """ Stdlib-only regression tests for core.py / design_system.py (unittest, not pytest -- this project ships with zero external dependencies and the tests shouldn't add one). Run with: python -m unittest discover -s scripts/tests -v or directly: python scripts/tests/test_core.py """ import sys import tempfile import unittest from pathlib import Path SCRIPTS_DIR = Path(__file__).resolve().parent.parent sys.path.insert(0, str(SCRIPTS_DIR)) from core import ( # noqa: E402 AVAILABLE_STACKS, BM25, CSV_CONFIG, detect_domain, search, search_stack, ) from design_system import DesignSystemGenerator, generate_design_system # noqa: E402 class TestTokenizer(unittest.TestCase): def test_short_domain_terms_are_kept(self): bm25 = BM25() tokens = bm25.tokenize("UI and UX design with 3D and AI") self.assertIn("ui", tokens) self.assertIn("3d", tokens) self.assertIn("ai", tokens) def test_stopwords_removed(self): bm25 = BM25() tokens = bm25.tokenize("this is for the team to do") for stopword in ("is", "for", "the", "to", "do"): self.assertNotIn(stopword, tokens) def test_synonym_normalization(self): bm25 = BM25() self.assertEqual(bm25.tokenize("e-commerce store"), bm25.tokenize("ecommerce store")) self.assertEqual(bm25.tokenize("dark-mode toggle"), bm25.tokenize("dark toggle")) class TestSearchDomains(unittest.TestCase): """Known query -> expected top-domain sanity checks (not exact-row pinning, since data can grow; these assert the engine still finds *something* relevant for each domain's core vocabulary).""" def test_ui_is_searchable_in_style_domain(self): result = search("ui minimalism", domain="style", max_results=1) self.assertGreater(result["count"], 0, "literal 'ui' token must be searchable, not filtered by tokenizer") def test_accessibility_query_hits_ux(self): result = search("accessibility contrast wcag keyboard", domain="ux", max_results=3) self.assertGreater(result["count"], 0) def test_zero_result_query_reports_suggestions_not_error(self): result = search("zzqqxx totally made up gibberish", domain="ux", max_results=2) self.assertEqual(result["count"], 0) self.assertIn("suggestions", result) self.assertNotIn("error", result) def test_every_configured_domain_file_exists_and_is_searchable(self): for domain, config in CSV_CONFIG.items(): with self.subTest(domain=domain): result = search("design", domain=domain, max_results=1) self.assertNotIn("error", result, f"domain '{domain}' failed: {result.get('error')}") def test_every_stack_file_exists_and_is_searchable(self): for stack in AVAILABLE_STACKS: with self.subTest(stack=stack): result = search_stack("performance", stack, max_results=1) self.assertNotIn("error", result, f"stack '{stack}' failed: {result.get('error')}") class TestDomainDetection(unittest.TestCase): def test_style_keywords_route_to_style(self): self.assertEqual(detect_domain("glassmorphism dark ui"), "style") def test_accessibility_keywords_route_to_ux(self): self.assertEqual(detect_domain("accessibility contrast wcag"), "ux") def test_ambiguous_query_returns_runner_up(self): domain, runner_up = detect_domain("font pairing elegant crypto", return_scores=True) self.assertIsNotNone(domain) # runner_up may be None if the winning domain has no close second -- # this just verifies the call shape works without raising. def test_empty_query_falls_back_to_style(self): self.assertEqual(detect_domain("...!!!???"), "style") class TestPersistence(unittest.TestCase): def test_persist_then_skip_then_force(self): with tempfile.TemporaryDirectory() as tmp: result = generate_design_system("saas dashboard", "Test Project", persist=True, output_dir=tmp) self.assertEqual(result["persistence"]["status"], "success") master = Path(result["persistence"]["master_file"]) self.assertTrue(master.exists()) original_content = master.read_text(encoding="utf-8") # Second persist without force must not overwrite. result2 = generate_design_system("saas dashboard", "Test Project", persist=True, output_dir=tmp) self.assertEqual(result2["persistence"]["status"], "skipped_exists") self.assertEqual(master.read_text(encoding="utf-8"), original_content) # With force=True it must overwrite. result3 = generate_design_system("ecommerce luxury", "Test Project", persist=True, output_dir=tmp, force=True) self.assertEqual(result3["persistence"]["status"], "success") def test_persist_writes_only_under_output_dir(self): with tempfile.TemporaryDirectory() as tmp: generate_design_system("saas dashboard", "Scoped Project", persist=True, output_dir=tmp) expected = Path(tmp) / "design-system" / "scoped-project" / "MASTER.md" self.assertTrue(expected.exists()) class TestReasoningMatch(unittest.TestCase): def test_known_category_matches_exactly(self): gen = DesignSystemGenerator() rule = gen._find_reasoning_rule("SaaS (General)") self.assertTrue(rule, "exact-match category lookup should not fall through to fuzzy matching") def test_unknown_category_falls_back_gracefully(self): gen = DesignSystemGenerator() rule = gen._find_reasoning_rule("Totally Unknown Category XYZ") # Should not raise; may return {} which _apply_reasoning handles with defaults. self.assertIsInstance(rule, dict) if __name__ == "__main__": unittest.main() -
test_design_system_mode.py 6.2 KB
#!/usr/bin/env python3 # -*- coding: utf-8 -*- """ Regression tests for color-mode coherence in design_system.py (issue #428). Style, palette and anti-patterns used to be resolved independently, so a dark-primary style could be returned alongside a light palette and a "Dark mode by default" anti-pattern. Stdlib-only (unittest, not pytest) to match test_core.py -- this project ships with zero external dependencies. Run with: python -m unittest discover -s scripts/tests -v or directly: python scripts/tests/test_design_system_mode.py """ import sys import unittest from pathlib import Path SCRIPTS_DIR = Path(__file__).resolve().parent.parent sys.path.insert(0, str(SCRIPTS_DIR)) from design_system import ( # noqa: E402 DesignSystemGenerator, _filter_anti_patterns_for_mode, _palette_is_dark, _query_wants_dark, _relative_luminance, _resolve_color_mode, _select_palette_for_mode, _style_is_dark_primary, ) # noqa: I001 - private helpers first, public class last LIGHT_PALETTE = {"Product Type": "SaaS", "Background": "#F8FAFC", "Foreground": "#020617"} DARK_PALETTE = {"Product Type": "Fintech/Crypto", "Background": "#0F172A", "Foreground": "#F8FAFC"} # Verbatim from styles.csv row "Modern Dark (Cinema Mobile)". DARK_PRIMARY_STYLE = { "Style Category": "Modern Dark (Cinema Mobile)", "Light Mode ✓": "✓ Light mode only as exception", "Dark Mode ✓": "✓ Dark Mode Primary", } DUAL_MODE_STYLE = { "Style Category": "Minimalism", "Light Mode ✓": "✓ Full", "Dark Mode ✓": "✓ Full", } class TestLuminance(unittest.TestCase): def test_parses_six_and_three_digit_hex(self): self.assertAlmostEqual(_relative_luminance("#FFFFFF"), 1.0, places=6) self.assertAlmostEqual(_relative_luminance("#000000"), 0.0, places=6) self.assertAlmostEqual(_relative_luminance("#FFF"), 1.0, places=6) def test_returns_none_for_unparseable(self): for value in ("", "nope", "#12", "#GGGGGG", None): self.assertIsNone(_relative_luminance(value)) def test_classifies_backgrounds_from_the_shipped_data(self): # Lightest dark background and darkest light background in colors.csv. self.assertTrue(_palette_is_dark({"Background": "#1F2937"})) self.assertFalse(_palette_is_dark({"Background": "#E8ECF1"})) def test_missing_background_is_not_dark(self): self.assertFalse(_palette_is_dark({})) self.assertFalse(_palette_is_dark(None)) class TestModeResolution(unittest.TestCase): def test_dark_primary_style_detected(self): self.assertTrue(_style_is_dark_primary(DARK_PRIMARY_STYLE)) def test_dual_mode_style_is_not_dark_primary(self): self.assertFalse(_style_is_dark_primary(DUAL_MODE_STYLE)) self.assertFalse(_style_is_dark_primary({})) def test_query_keywords(self): self.assertTrue(_query_wants_dark("fintech B2B professional dark mode")) self.assertTrue(_query_wants_dark("gaming app OLED")) self.assertFalse(_query_wants_dark("healthcare clinic booking app")) self.assertFalse(_query_wants_dark("")) def test_either_signal_resolves_dark(self): self.assertEqual(_resolve_color_mode("saas dark mode", DUAL_MODE_STYLE), "dark") self.assertEqual(_resolve_color_mode("saas", DARK_PRIMARY_STYLE), "dark") self.assertEqual(_resolve_color_mode("saas", DUAL_MODE_STYLE), "light") class TestPaletteSelection(unittest.TestCase): def test_dark_mode_skips_light_palettes(self): chosen = _select_palette_for_mode([LIGHT_PALETTE, DARK_PALETTE], "dark") self.assertEqual(chosen["Background"], "#0F172A") def test_dark_mode_falls_back_to_top_hit_when_no_dark_ramp_exists(self): chosen = _select_palette_for_mode([LIGHT_PALETTE], "dark") self.assertEqual(chosen["Background"], "#F8FAFC") def test_light_mode_keeps_the_existing_top_hit_behaviour(self): chosen = _select_palette_for_mode([DARK_PALETTE, LIGHT_PALETTE], "light") self.assertEqual(chosen["Background"], "#0F172A") def test_empty_results(self): self.assertEqual(_select_palette_for_mode([], "dark"), {}) class TestAntiPatternGating(unittest.TestCase): def test_dark_clause_dropped_others_kept(self): result = _filter_anti_patterns_for_mode( "Excessive animation + Dark mode by default", "dark") self.assertEqual(result, "Excessive animation") def test_light_mode_is_a_no_op(self): original = "Excessive animation + Dark mode by default" self.assertEqual(_filter_anti_patterns_for_mode(original, "light"), original) def test_unrelated_anti_patterns_survive_dark_mode(self): original = "Complex jargon + Tiny tap targets" self.assertEqual(_filter_anti_patterns_for_mode(original, "dark"), original) def test_empty_input(self): self.assertEqual(_filter_anti_patterns_for_mode("", "dark"), "") class TestEndToEndCoherence(unittest.TestCase): """The exact reproduction from issue #428.""" QUERY = "SaaS invoicing fintech B2B professional dark mode" def test_dark_query_gets_a_dark_background(self): ds = DesignSystemGenerator().generate(self.QUERY) background = ds["colors"]["background"] self.assertTrue( _palette_is_dark({"Background": background}), "dark-mode query returned a light background: {}".format(background), ) def test_dark_query_foreground_is_lighter_than_background(self): ds = DesignSystemGenerator().generate(self.QUERY) background = _relative_luminance(ds["colors"]["background"]) foreground = _relative_luminance(ds["colors"]["foreground"]) self.assertIsNotNone(background) self.assertIsNotNone(foreground) self.assertGreater(foreground, background) def test_dark_query_does_not_advise_against_dark_mode(self): ds = DesignSystemGenerator().generate(self.QUERY) self.assertNotIn("dark mode", ds["anti_patterns"].lower()) def test_light_query_keeps_a_light_background(self): ds = DesignSystemGenerator().generate("healthcare clinic booking app") self.assertFalse(_palette_is_dark({"Background": ds["colors"]["background"]})) if __name__ == "__main__": unittest.main(verbosity=2)
-
-
core.py 18.4 KB
#!/usr/bin/env python3 # -*- coding: utf-8 -*- """ UI/UX Pro Max Core - BM25 search engine for UI/UX style guides """ import csv import re from collections import defaultdict from math import log from pathlib import Path # ============ CONFIGURATION ============ DATA_DIR = Path(__file__).parent.parent / "data" MAX_RESULTS = 3 CSV_CONFIG = { "style": { "file": "styles.csv", "search_cols": ["Style Category", "Keywords", "Best For", "Type", "AI Prompt Keywords"], "output_cols": ["Style Category", "Type", "Keywords", "Primary Colors", "Effects & Animation", "Best For", "Light Mode ✓", "Dark Mode ✓", "Performance", "Accessibility", "Framework Compatibility", "Complexity", "AI Prompt Keywords", "CSS/Technical Keywords", "Implementation Checklist", "Design System Variables"] }, "color": { "file": "colors.csv", "search_cols": ["Product Type", "Notes"], "output_cols": ["Product Type", "Primary", "On Primary", "Secondary", "On Secondary", "Accent", "On Accent", "Background", "Foreground", "Card", "Card Foreground", "Muted", "Muted Foreground", "Border", "Destructive", "On Destructive", "Ring", "Notes"] }, "chart": { "file": "charts.csv", "search_cols": ["Data Type", "Keywords", "Best Chart Type", "When to Use", "When NOT to Use", "Accessibility Notes"], "output_cols": ["Data Type", "Keywords", "Best Chart Type", "Secondary Options", "When to Use", "When NOT to Use", "Data Volume Threshold", "Color Guidance", "Accessibility Grade", "Accessibility Notes", "A11y Fallback", "Library Recommendation", "Interactive Level"] }, "landing": { "file": "landing.csv", "search_cols": ["Pattern Name", "Keywords", "Conversion Optimization", "Section Order"], "output_cols": ["Pattern Name", "Keywords", "Section Order", "Primary CTA Placement", "Color Strategy", "Conversion Optimization"] }, "product": { "file": "products.csv", "search_cols": ["Product Type", "Keywords", "Primary Style Recommendation", "Key Considerations"], "output_cols": ["Product Type", "Keywords", "Primary Style Recommendation", "Secondary Styles", "Landing Page Pattern", "Dashboard Style (if applicable)", "Color Palette Focus"] }, "ux": { "file": "ux-guidelines.csv", "search_cols": ["Category", "Issue", "Description", "Platform"], "output_cols": ["Category", "Issue", "Platform", "Description", "Do", "Don't", "Code Example Good", "Code Example Bad", "Severity"] }, "typography": { "file": "typography.csv", "search_cols": ["Font Pairing Name", "Category", "Mood/Style Keywords", "Best For", "Heading Font", "Body Font"], "output_cols": ["Font Pairing Name", "Category", "Heading Font", "Body Font", "Mood/Style Keywords", "Best For", "Google Fonts URL", "CSS Import", "Tailwind Config", "Notes"] }, "icons": { "file": "icons.csv", "search_cols": ["Category", "Icon Name", "Keywords", "Best For"], "output_cols": ["Category", "Icon Name", "Keywords", "Library", "Import Code", "Usage", "Best For", "Style"] }, "gsap": { "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"], "output_cols": ["Category", "Issue", "Platform", "Description", "Do", "Don't", "Code Example Good", "Code Example Bad", "Severity"] }, "web": { "file": "app-interface.csv", "search_cols": ["Category", "Issue", "Keywords", "Description"], "output_cols": ["Category", "Issue", "Platform", "Description", "Do", "Don't", "Code Example Good", "Code Example Bad", "Severity"] }, "google-fonts": { "file": "google-fonts.csv", "search_cols": ["Family", "Category", "Stroke", "Classifications", "Keywords", "Subsets", "Designers"], "output_cols": ["Family", "Category", "Stroke", "Classifications", "Styles", "Variable Axes", "Subsets", "Designers", "Popularity Rank", "Google Fonts URL"] } } # Output columns whose content (code samples, checklists) must never be # hard-truncated for display -- truncating mid-snippet destroys the value. UNTRUNCATED_COLS = { "Code Example Good", "Code Example Bad", "Code Good", "Code Bad", "Implementation Checklist", "Design System Variables", "CSS Import", "Tailwind Config", "GSAP Snippet", } STACK_CONFIG = { "react": {"file": "stacks/react.csv"}, "nextjs": {"file": "stacks/nextjs.csv"}, "vue": {"file": "stacks/vue.csv"}, "svelte": {"file": "stacks/svelte.csv"}, "astro": {"file": "stacks/astro.csv"}, "swiftui": {"file": "stacks/swiftui.csv"}, "react-native": {"file": "stacks/react-native.csv"}, "flutter": {"file": "stacks/flutter.csv"}, "nuxtjs": {"file": "stacks/nuxtjs.csv"}, "nuxt-ui": {"file": "stacks/nuxt-ui.csv"}, "html-tailwind": {"file": "stacks/html-tailwind.csv"}, "shadcn": {"file": "stacks/shadcn.csv"}, "jetpack-compose": {"file": "stacks/jetpack-compose.csv"}, "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 _STACK_COLS = { "search_cols": ["Category", "Guideline", "Description", "Do", "Don't"], "output_cols": ["Category", "Guideline", "Description", "Do", "Don't", "Code Good", "Code Bad", "Severity", "Docs URL"] } AVAILABLE_STACKS = list(STACK_CONFIG.keys()) # ============ TOKENIZATION ============ # Common two-letter/three-letter words that add noise without adding search # signal. Deliberately short -- domain-relevant short tokens (ui, ux, ai, # css, 3d, js, os, md, gsap) must stay searchable, which is why we don't # filter purely by length. _STOPWORDS = { "to", "in", "on", "at", "is", "of", "by", "or", "an", "if", "no", "so", "do", "be", "we", "it", "as", "the", "and", "for", "are", "was", } # Query/corpus normalization so common spelling variants match each other. # Keep this a plain dict (stdlib only, no fuzzy-matching dependency). _SYNONYMS = { "e-commerce": "ecommerce", "dark-mode": "dark", "darkmode": "dark", "light-mode": "light", "lightmode": "light", "a11y": "accessibility", "nav": "navigation", "sign-up": "signup", "log-in": "login", "colour": "color", "colours": "colors", "customisation": "customization", "organisation": "organization", "behaviour": "behavior", "ux/ui": "ux ui", } def _normalize(text): """Apply synonym substitution before tokenizing.""" for variant, canonical in _SYNONYMS.items(): text = text.replace(variant, canonical) return text # ============ BM25 IMPLEMENTATION ============ class BM25: """BM25 ranking algorithm for text search""" def __init__(self, k1=1.5, b=0.75): self.k1 = k1 self.b = b self.corpus = [] self.doc_lengths = [] self.avgdl = 0 self.idf = {} self.doc_freqs = defaultdict(int) self.N = 0 self._term_freqs = [] # precomputed per-doc term frequencies def tokenize(self, text): """Lowercase, normalize synonyms, split, remove punctuation, filter stopwords""" text = _normalize(str(text).lower()) text = re.sub(r'[^\w\s]', ' ', text) return [w for w in text.split() if len(w) >= 2 and w not in _STOPWORDS] def fit(self, documents): """Build BM25 index from documents""" self.corpus = [self.tokenize(doc) for doc in documents] self.N = len(self.corpus) if self.N == 0: return self.doc_lengths = [len(doc) for doc in self.corpus] self.avgdl = sum(self.doc_lengths) / self.N self._term_freqs = [] for doc in self.corpus: tf = defaultdict(int) for word in doc: tf[word] += 1 self._term_freqs.append(tf) for word in tf: self.doc_freqs[word] += 1 for word, freq in self.doc_freqs.items(): self.idf[word] = log((self.N - freq + 0.5) / (freq + 0.5) + 1) def score(self, query): """Score all documents against query""" query_tokens = self.tokenize(query) scores = [] for idx in range(self.N): score = 0 doc_len = self.doc_lengths[idx] term_freqs = self._term_freqs[idx] for token in query_tokens: if token in self.idf: tf = term_freqs.get(token, 0) idf = self.idf[token] numerator = tf * (self.k1 + 1) denominator = tf + self.k1 * (1 - self.b + self.b * doc_len / self.avgdl) score += idf * numerator / denominator scores.append((idx, score)) return sorted(scores, key=lambda x: x[1], reverse=True) def vocabulary(self): """All indexed terms, for suggestion/typo-recovery purposes.""" return list(self.idf.keys()) # ============ CSV / INDEX CACHE ============ # Data files are small and reused across multiple domain searches within a # single --design-system run; avoid re-reading + re-indexing the same file # repeatedly in one process. _csv_cache = {} # filepath -> (mtime, rows) _bm25_cache = {} # (filepath, tuple(search_cols)) -> (mtime, BM25 instance) def _load_csv(filepath): """Load CSV and return list of dicts, with mtime-based caching.""" mtime = filepath.stat().st_mtime cached = _csv_cache.get(filepath) if cached and cached[0] == mtime: return cached[1] with open(filepath, 'r', encoding='utf-8') as f: rows = list(csv.DictReader(f)) _csv_cache[filepath] = (mtime, rows) return rows def _get_bm25(filepath, search_cols, data): """Fitted BM25 index for this file+columns, with mtime-based caching.""" key = (filepath, tuple(search_cols)) mtime = filepath.stat().st_mtime cached = _bm25_cache.get(key) if cached and cached[0] == mtime: return cached[1] documents = [" ".join(str(row.get(col, "")) for col in search_cols) for row in data] bm25 = BM25() bm25.fit(documents) _bm25_cache[key] = (mtime, bm25) return bm25 # ============ SEARCH FUNCTIONS ============ def _search_csv(filepath, search_cols, output_cols, query, max_results): """Core search function using BM25. Returns (results, bm25_or_none).""" if not filepath.exists(): return [], None try: data = _load_csv(filepath) except (csv.Error, OSError, UnicodeDecodeError) as e: return [{"_error": f"Failed to read {filepath.name}: {e}"}], None if not data: return [], None bm25 = _get_bm25(filepath, search_cols, data) ranked = bm25.score(query) results = [] for idx, score in ranked[:max_results]: if score > 0: row = data[idx] results.append({col: row.get(col, "") for col in output_cols if col in row}) return results, bm25 def _suggest_terms(bm25, query, limit=6): """Nearest known vocabulary terms for a query that returned 0 hits, so the caller can retry instead of silently reporting nothing.""" if bm25 is None: return [] query_tokens = set(bm25.tokenize(query)) if not query_tokens: return [] candidates = [] for term in bm25.vocabulary(): for qt in query_tokens: if term.startswith(qt[:3]) or qt.startswith(term[:3]): candidates.append(term) break # Stable de-dup, most frequent terms first (doc_freqs available via idf keys only, # so just de-dup preserving discovery order). seen = set() ordered = [] for term in candidates: if term not in seen: seen.add(term) ordered.append(term) return ordered[:limit] # Load the product-domain keyword list from products.csv at import time so # it stays in sync with the data instead of needing manual updates to a # hardcoded list. Falls back to a small built-in seed if the file is # missing (e.g. package built without data/). def _load_product_keywords(): seed = ["saas", "ecommerce", "e-commerce", "fintech", "healthcare", "gaming", "portfolio", "crypto", "dashboard", "fitness", "marketplace"] filepath = DATA_DIR / CSV_CONFIG["product"]["file"] if not filepath.exists(): return seed try: rows = _load_csv(filepath) except (csv.Error, OSError, UnicodeDecodeError): return seed keywords = set(seed) for row in rows: raw = row.get("Keywords", "") for kw in re.split(r"[,;]", raw): kw = kw.strip().lower() if kw and len(kw) >= 3: keywords.add(kw) return sorted(keywords, key=len, reverse=True) _DOMAIN_KEYWORDS = None def _domain_keywords(): global _DOMAIN_KEYWORDS if _DOMAIN_KEYWORDS is not None: return _DOMAIN_KEYWORDS _DOMAIN_KEYWORDS = { "color": ["color", "palette", "hex", "#", "rgb", "token", "semantic", "accent", "destructive", "muted", "foreground"], "chart": ["chart", "graph", "visualization", "trend", "bar", "pie", "scatter", "heatmap", "funnel"], "landing": ["landing", "page", "cta", "conversion", "hero", "testimonial", "pricing", "section"], "product": _load_product_keywords(), "style": ["style", "design", "ui", "minimalism", "glassmorphism", "neumorphism", "brutalism", "dark mode", "flat", "aurora", "prompt", "css", "implementation", "variable", "checklist", "tailwind"], "ux": ["ux", "usability", "accessibility", "wcag", "touch", "scroll", "animation", "keyboard", "navigation", "mobile"], "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"], "gsap": ["gsap", "quickto", "scrolltrigger", "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"] } return _DOMAIN_KEYWORDS # Domains checked in this fixed order when scores tie, so results are # deterministic instead of depending on dict/hash ordering. _DOMAIN_TIEBREAK_ORDER = [ "ux", "product", "style", "color", "typography", "google-fonts", "chart", "landing", "icons", "gsap", "react", "web", ] def detect_domain(query, return_scores=False): """Auto-detect the most relevant domain from query. Matches are weighted by keyword length (multi-word/longer phrases are more specific and score higher than short generic words). Ties are broken by a fixed domain priority order, not dict/insertion order. """ query_lower = query.lower() domain_keywords = _domain_keywords() scores = {} for domain, keywords in domain_keywords.items(): total = 0.0 for kw in keywords: if re.search(r'\b' + re.escape(kw) + r'\b', query_lower): # weight = 1 point per word in the keyword phrase total += max(1, len(kw.split())) scores[domain] = total ranked = sorted( scores.items(), key=lambda item: (item[1], -_DOMAIN_TIEBREAK_ORDER.index(item[0]) if item[0] in _DOMAIN_TIEBREAK_ORDER else -999), reverse=True, ) best_domain, best_score = ranked[0] result = best_domain if best_score > 0 else "style" if return_scores: runner_up = ranked[1][0] if len(ranked) > 1 and ranked[1][1] > 0 else None return result, runner_up return result def search(query, domain=None, max_results=MAX_RESULTS): """Main search function with auto-domain detection""" auto_detected = domain is None runner_up = None if domain is None: domain, runner_up = detect_domain(query, return_scores=True) config = CSV_CONFIG.get(domain, CSV_CONFIG["style"]) filepath = DATA_DIR / config["file"] if not filepath.exists(): return {"error": f"File not found: {filepath}", "domain": domain} results, bm25 = _search_csv(filepath, config["search_cols"], config["output_cols"], query, max_results) out = { "domain": domain, "query": query, "file": config["file"], "count": len(results), "results": results, } if auto_detected: out["auto_detected"] = True if runner_up: out["runner_up_domain"] = runner_up if not results: out["suggestions"] = _suggest_terms(bm25, query) return out def search_stack(query, stack, max_results=MAX_RESULTS): """Search stack-specific guidelines""" if stack not in STACK_CONFIG: return {"error": f"Unknown stack: {stack}. Available: {', '.join(AVAILABLE_STACKS)}"} filepath = DATA_DIR / STACK_CONFIG[stack]["file"] if not filepath.exists(): return {"error": f"Stack file not found: {filepath}", "stack": stack} results, bm25 = _search_csv(filepath, _STACK_COLS["search_cols"], _STACK_COLS["output_cols"], query, max_results) out = { "domain": "stack", "stack": stack, "query": query, "file": STACK_CONFIG[stack]["file"], "count": len(results), "results": results, } if not results: out["suggestions"] = _suggest_terms(bm25, query) return out -
design_system.py 61.8 KB
#!/usr/bin/env python3 # -*- coding: utf-8 -*- """ Design System Generator - Aggregates search results and applies reasoning to generate comprehensive design system recommendations. Usage: from design_system import generate_design_system result = generate_design_system("SaaS dashboard", "My Project") print(result["text"]) # With persistence (Master + Overrides pattern) result = generate_design_system("SaaS dashboard", "My Project", persist=True, output_dir="/path/to/project") result["persistence"] # {"status": "success"|"skipped_exists", "created_files": [...], ...} result = generate_design_system("SaaS dashboard", "My Project", persist=True, page="dashboard", output_dir="/path/to/project") """ import csv import io import json import os import re import sys from datetime import datetime from pathlib import Path from core import DATA_DIR, search # 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" SEARCH_CONFIG = { "product": {"max_results": 1}, "style": {"max_results": 3}, "color": {"max_results": 2}, "landing": {"max_results": 2}, "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 # ============ COLOR MODE RESOLUTION ============ # Style, palette and anti-patterns are resolved from separate CSVs. Without a # shared notion of "which mode did we land on", a dark-primary style can be # paired with a light palette and a "don't use dark mode" anti-pattern. # Phrases in styles.csv "Light Mode ✓" / "Dark Mode ✓" that mark a style as # dark-first rather than merely dark-capable ("✓ Full" means both work). _DARK_PRIMARY_MARKERS = ( "dark mode primary", "dark primary", "dark-only", "dark only", "dark preferred", "dark focused", "dark-first", "dark rich", "light mode only as exception", ) # Query phrases that are an explicit request for a dark theme. _DARK_QUERY_MARKERS = ( "dark mode", "dark theme", "dark ui", "dark-mode", "darkmode", "night mode", "midnight", "oled", ) # Anti-pattern clauses that contradict a resolved dark mode. _DARK_ANTI_PATTERN_MARKERS = ("dark mode", "dark modes", "dark theme") # Relative luminance below which a Background hex counts as a dark surface. # #1F2937 (the lightest dark background in colors.csv) sits at ~0.026 and # #E8ECF1 (the darkest light background) at ~0.79, so the gap is wide. _DARK_BACKGROUND_MAX_LUMINANCE = 0.18 def _relative_luminance(hex_color: str): """WCAG relative luminance of a #RRGGBB string, or None if unparseable.""" if not hex_color: return None value = hex_color.strip().lstrip("#") if len(value) == 3: value = "".join(c * 2 for c in value) if len(value) != 6: return None try: channels = [int(value[i:i + 2], 16) / 255 for i in (0, 2, 4)] except ValueError: return None linear = [c / 12.92 if c <= 0.04045 else ((c + 0.055) / 1.055) ** 2.4 for c in channels] return 0.2126 * linear[0] + 0.7152 * linear[1] + 0.0722 * linear[2] def _palette_is_dark(palette: dict) -> bool: """True when a colors.csv row's Background is a dark surface.""" luminance = _relative_luminance((palette or {}).get("Background", "")) return luminance is not None and luminance < _DARK_BACKGROUND_MAX_LUMINANCE def _style_is_dark_primary(style: dict) -> bool: """True when a styles.csv row describes itself as dark-first.""" if not style: return False declared = "{} {}".format( style.get("Light Mode ✓", ""), style.get("Dark Mode ✓", "") ).lower() return any(marker in declared for marker in _DARK_PRIMARY_MARKERS) def _query_wants_dark(query: str) -> bool: """True when the query explicitly asks for a dark theme.""" lowered = (query or "").lower() return any(marker in lowered for marker in _DARK_QUERY_MARKERS) def _resolve_color_mode(query: str, style: dict) -> str: """Resolve the mode the rest of the output has to agree with.""" if _query_wants_dark(query) or _style_is_dark_primary(style): return "dark" return "light" def _select_palette_for_mode(palettes: list, mode: str) -> dict: """Pick the highest-ranked palette matching the resolved mode. Only the dark case filters. Light is left on the existing "top hit wins" behaviour so queries that never mention a mode keep their current palette. Falls back to the top hit when the data has no matching ramp. """ if not palettes: return {} if mode == "dark": for palette in palettes: if _palette_is_dark(palette): return palette return palettes[0] def _filter_anti_patterns_for_mode(anti_patterns: str, mode: str) -> str: """Drop "avoid dark mode" advice once dark mode is the resolved answer.""" if mode != "dark" or not anti_patterns: return anti_patterns kept = [ clause for clause in anti_patterns.split("+") if not any(marker in clause.lower() for marker in _DARK_ANTI_PATTERN_MARKERS) ] return " + ".join(clause.strip() for clause in kept if clause.strip()) # ============ DESIGN SYSTEM GENERATOR ============ class DesignSystemGenerator: """Generates design system recommendations from aggregated searches.""" def __init__(self): self.reasoning_data = self._load_reasoning() def _load_reasoning(self) -> list: """Load reasoning rules from CSV.""" filepath = DATA_DIR / REASONING_FILE if not filepath.exists(): return [] with open(filepath, 'r', encoding='utf-8') as f: return list(csv.DictReader(f)) def _multi_domain_search(self, query: str, style_priority: list = None) -> dict: """Execute searches across multiple domains.""" results = {} for domain, config in SEARCH_CONFIG.items(): if domain == "style" and style_priority: # For style, also search with priority keywords priority_query = " ".join(style_priority[:2]) if style_priority else query combined_query = f"{query} {priority_query}" results[domain] = search(combined_query, domain, config["max_results"]) else: results[domain] = search(query, domain, config["max_results"]) return results def _find_reasoning_rule(self, category: str) -> dict: """Find matching reasoning rule for a category.""" category_lower = category.lower() # Try exact match first for rule in self.reasoning_data: if rule.get("UI_Category", "").lower() == category_lower: return rule # Try partial match for rule in self.reasoning_data: ui_cat = rule.get("UI_Category", "").lower() if ui_cat in category_lower or category_lower in ui_cat: return rule # Try keyword match for rule in self.reasoning_data: ui_cat = rule.get("UI_Category", "").lower() keywords = ui_cat.replace("/", " ").replace("-", " ").split() if any(kw in category_lower for kw in keywords): return rule return {} def _apply_reasoning(self, category: str, search_results: dict) -> dict: """Apply reasoning rules to search results.""" rule = self._find_reasoning_rule(category) if not rule: return { "pattern": "Hero + Features + CTA", "style_priority": ["Minimalism", "Flat Design"], "color_mood": "Professional", "typography_mood": "Clean", "key_effects": "Subtle hover transitions", "anti_patterns": "", "decision_rules": {}, "severity": "MEDIUM" } # Parse decision rules JSON decision_rules = {} try: decision_rules = json.loads(rule.get("Decision_Rules", "{}")) except json.JSONDecodeError: pass return { "pattern": rule.get("Recommended_Pattern", ""), "style_priority": [s.strip() for s in rule.get("Style_Priority", "").split("+")], "color_mood": rule.get("Color_Mood", ""), "typography_mood": rule.get("Typography_Mood", ""), "key_effects": rule.get("Key_Effects", ""), "anti_patterns": rule.get("Anti_Patterns", ""), "decision_rules": decision_rules, "severity": rule.get("Severity", "MEDIUM") } def _select_best_match(self, results: list, priority_keywords: list) -> dict: """Select best matching result based on priority keywords.""" if not results: return {} if not priority_keywords: return results[0] # First: try exact style name match for priority in priority_keywords: priority_lower = priority.lower().strip() for result in results: style_name = result.get("Style Category", "").lower() if priority_lower in style_name or style_name in priority_lower: return result # Second: score by keyword match in all fields scored = [] for result in results: result_str = str(result).lower() score = 0 for kw in priority_keywords: kw_lower = kw.lower().strip() # Higher score for style name match if kw_lower in result.get("Style Category", "").lower(): score += 10 # Lower score for keyword field match elif kw_lower in result.get("Keywords", "").lower(): score += 3 # Even lower for other field matches elif kw_lower in result_str: score += 1 scored.append((score, result)) scored.sort(key=lambda x: x[0], reverse=True) return scored[0][1] if scored and scored[0][0] > 0 else results[0] def _extract_results(self, search_result: dict) -> list: """Extract results list from search result dict.""" return search_result.get("results", []) 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", []) category = "General" if product_results: category = product_results[0].get("Product Type", "General") # Step 2: Get reasoning rules for this category 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, effective_style_priority) search_results["product"] = product_result # Reuse product search # Step 4: Select best matches from each domain using priority style_results = self._extract_results(search_results.get("style", {})) color_results = self._extract_results(search_results.get("color", {})) 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, effective_style_priority) # Resolve the mode from the style + query first, then pick a palette that # agrees with it. Ranking colors independently is what let a dark-primary # style ship with a light background. color_mode = _resolve_color_mode(query, best_style) best_color = _select_palette_for_mode(color_results, color_mode) 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 # (domain key is "gsap", not "motion" - PR #296 already owns the "motion" # domain for Emil Kowalski's motion-design principles, motion-principles.csv). motion_snippet = {} if motion_info: motion_result = search(f"{query} {motion_info['tier']}", "gsap", 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", "") reasoning_effects = reasoning.get("key_effects", "") combined_effects = style_effects if style_effects else reasoning_effects return { "project_name": project_name or query.upper(), "category": category, "pattern": { "name": best_landing.get("Pattern Name", reasoning.get("pattern", "Hero + Features + CTA")), "sections": best_landing.get("Section Order", "Hero > Features > CTA"), "cta_placement": best_landing.get("Primary CTA Placement", "Above fold"), "color_strategy": best_landing.get("Color Strategy", ""), "conversion": best_landing.get("Conversion Optimization", "") }, "style": { "name": best_style.get("Style Category", "Minimalism"), "type": best_style.get("Type", "General"), "effects": style_effects, "keywords": best_style.get("Keywords", ""), "best_for": best_style.get("Best For", ""), "performance": best_style.get("Performance", ""), "accessibility": best_style.get("Accessibility", ""), "light_mode": best_style.get("Light Mode ✓", ""), "dark_mode": best_style.get("Dark Mode ✓", ""), }, "colors": { "primary": best_color.get("Primary", "#2563EB"), "on_primary": best_color.get("On Primary", ""), "secondary": best_color.get("Secondary", "#3B82F6"), "accent": best_color.get("Accent", "#F97316"), "background": best_color.get("Background", "#F8FAFC"), "foreground": best_color.get("Foreground", "#1E293B"), "muted": best_color.get("Muted", ""), "border": best_color.get("Border", ""), "destructive": best_color.get("Destructive", ""), "ring": best_color.get("Ring", ""), "notes": best_color.get("Notes", ""), # Keep legacy keys for backward compat in MASTER.md "cta": best_color.get("Accent", "#F97316"), "text": best_color.get("Foreground", "#1E293B"), }, "typography": { "heading": best_typography.get("Heading Font", "Inter"), "body": best_typography.get("Body Font", "Inter"), "mood": best_typography.get("Mood/Style Keywords", reasoning.get("typography_mood", "")), "best_for": best_typography.get("Best For", ""), "google_fonts_url": best_typography.get("Google Fonts URL", ""), "css_import": best_typography.get("CSS Import", "") }, "key_effects": combined_effects, "anti_patterns": _filter_anti_patterns_for_mode( reasoning.get("anti_patterns", ""), color_mode ), "decision_rules": reasoning.get("decision_rules", {}), "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, } # ============ OUTPUT FORMATTERS ============ BOX_WIDTH = 90 # Wider box for more content def hex_to_ansi(hex_color: str) -> str: """Convert hex color to ANSI True Color swatch (██) with fallback.""" if not hex_color or not hex_color.startswith('#'): return "" colorterm = os.environ.get('COLORTERM', '') if colorterm not in ('truecolor', '24bit'): return "" hex_color = hex_color.lstrip('#') if len(hex_color) != 6: return "" r, g, b = int(hex_color[0:2], 16), int(hex_color[2:4], 16), int(hex_color[4:6], 16) return f"\033[38;2;{r};{g};{b}m██\033[0m " def ansi_ljust(s: str, width: int) -> str: """Like str.ljust but accounts for zero-width ANSI escape sequences.""" import re visible_len = len(re.sub(r'\033\[[0-9;]*m', '', s)) pad = width - visible_len return s + (" " * max(0, pad)) def section_header(name: str, width: int) -> str: """Create a Unicode section separator: ├─── NAME ───...┤""" label = f"─── {name} " fill = "─" * (width - len(label) - 1) return f"├{label}{fill}┤" def format_ascii_box(design_system: dict) -> str: """Format design system as Unicode box with ANSI color swatches.""" project = design_system.get("project_name", "PROJECT") pattern = design_system.get("pattern", {}) style = design_system.get("style", {}) colors = design_system.get("colors", {}) 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.""" if not text: return [] words = text.split() lines = [] current_line = prefix for word in words: if len(current_line) + len(word) + 1 <= width - 2: current_line += (" " if current_line != prefix else "") + word else: if current_line != prefix: lines.append(current_line) current_line = prefix + word if current_line != prefix: lines.append(current_line) return lines # Build sections from pattern sections = pattern.get("sections", "").split(">") sections = [s.strip() for s in sections if s.strip()] # Build output lines lines = [] w = BOX_WIDTH - 1 # Header with double-line box lines.append("╔" + "═" * w + "╗") lines.append(ansi_ljust(f"║ TARGET: {project} - RECOMMENDED DESIGN SYSTEM", BOX_WIDTH) + "║") 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) + "│") if pattern.get('conversion'): lines.append(f"│ Conversion: {pattern.get('conversion', '')}".ljust(BOX_WIDTH) + "│") if pattern.get('cta_placement'): lines.append(f"│ CTA: {pattern.get('cta_placement', '')}".ljust(BOX_WIDTH) + "│") lines.append("│ Sections:".ljust(BOX_WIDTH) + "│") for i, section in enumerate(sections, 1): lines.append(f"│ {i}. {section}".ljust(BOX_WIDTH) + "│") # Style section lines.append(section_header("STYLE", BOX_WIDTH + 1)) lines.append(f"│ Name: {style.get('name', '')}".ljust(BOX_WIDTH) + "│") light = style.get("light_mode", "") dark = style.get("dark_mode", "") if light or dark: lines.append(f"│ Mode Support: Light {light} Dark {dark}".ljust(BOX_WIDTH) + "│") if style.get("keywords"): for line in wrap_text(f"Keywords: {style.get('keywords', '')}", "│ ", BOX_WIDTH): lines.append(line.ljust(BOX_WIDTH) + "│") if style.get("best_for"): for line in wrap_text(f"Best For: {style.get('best_for', '')}", "│ ", BOX_WIDTH): lines.append(line.ljust(BOX_WIDTH) + "│") if style.get("performance") or style.get("accessibility"): perf_a11y = f"Performance: {style.get('performance', '')} | Accessibility: {style.get('accessibility', '')}" lines.append(f"│ {perf_a11y}".ljust(BOX_WIDTH) + "│") # Colors section (extended palette with ANSI swatches) lines.append(section_header("COLORS", BOX_WIDTH + 1)) color_entries = [ ("Primary", "primary", "--color-primary"), ("On Primary", "on_primary", "--color-on-primary"), ("Secondary", "secondary", "--color-secondary"), ("Accent/CTA", "accent", "--color-accent"), ("Background", "background", "--color-background"), ("Foreground", "foreground", "--color-foreground"), ("Muted", "muted", "--color-muted"), ("Border", "border", "--color-border"), ("Destructive", "destructive", "--color-destructive"), ("Ring", "ring", "--color-ring"), ] for label, key, css_var in color_entries: hex_val = colors.get(key, "") if not hex_val: continue swatch = hex_to_ansi(hex_val) content = f"│ {swatch}{label + ':':14s} {hex_val:10s} ({css_var})" lines.append(ansi_ljust(content, BOX_WIDTH) + "│") if colors.get("notes"): for line in wrap_text(f"Notes: {colors.get('notes', '')}", "│ ", BOX_WIDTH): lines.append(line.ljust(BOX_WIDTH) + "│") # Typography section lines.append(section_header("TYPOGRAPHY", BOX_WIDTH + 1)) lines.append(f"│ {typography.get('heading', '')} / {typography.get('body', '')}".ljust(BOX_WIDTH) + "│") if typography.get("mood"): for line in wrap_text(f"Mood: {typography.get('mood', '')}", "│ ", BOX_WIDTH): lines.append(line.ljust(BOX_WIDTH) + "│") if typography.get("best_for"): for line in wrap_text(f"Best For: {typography.get('best_for', '')}", "│ ", BOX_WIDTH): lines.append(line.ljust(BOX_WIDTH) + "│") if typography.get("google_fonts_url"): lines.append(f"│ Google Fonts: {typography.get('google_fonts_url', '')}".ljust(BOX_WIDTH) + "│") if typography.get("css_import"): lines.append(f"│ CSS Import: {typography.get('css_import', '')[:70]}...".ljust(BOX_WIDTH) + "│") # Key Effects section if effects: lines.append(section_header("KEY EFFECTS", BOX_WIDTH + 1)) 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)) for line in wrap_text(anti_patterns, "│ ", BOX_WIDTH): lines.append(line.ljust(BOX_WIDTH) + "│") # Pre-Delivery Checklist section lines.append(section_header("PRE-DELIVERY CHECKLIST", BOX_WIDTH + 1)) checklist_items = [ "[ ] No emojis as icons (use SVG: Heroicons/Lucide)", "[ ] cursor-pointer on all clickable elements", "[ ] Hover states with smooth transitions (150-300ms)", "[ ] Light mode: text contrast 4.5:1 minimum", "[ ] Focus states visible for keyboard nav", "[ ] prefers-reduced-motion respected", "[ ] Responsive: 375px, 768px, 1024px, 1440px" ] for item in checklist_items: lines.append(f"│ {item}".ljust(BOX_WIDTH) + "│") lines.append("└" + "─" * w + "┘") return "\n".join(lines) def format_markdown(design_system: dict) -> str: """Format design system as markdown.""" project = design_system.get("project_name", "PROJECT") pattern = design_system.get("pattern", {}) style = design_system.get("style", {}) colors = design_system.get("colors", {}) 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', '')}") if pattern.get('conversion'): lines.append(f"- **Conversion Focus:** {pattern.get('conversion', '')}") if pattern.get('cta_placement'): lines.append(f"- **CTA Placement:** {pattern.get('cta_placement', '')}") if pattern.get('color_strategy'): lines.append(f"- **Color Strategy:** {pattern.get('color_strategy', '')}") lines.append(f"- **Sections:** {pattern.get('sections', '')}") lines.append("") # Style section lines.append("### Style") lines.append(f"- **Name:** {style.get('name', '')}") light = style.get("light_mode", "") dark = style.get("dark_mode", "") if light or dark: lines.append(f"- **Mode Support:** Light {light} | Dark {dark}") if style.get('keywords'): lines.append(f"- **Keywords:** {style.get('keywords', '')}") if style.get('best_for'): lines.append(f"- **Best For:** {style.get('best_for', '')}") if style.get('performance') or style.get('accessibility'): lines.append(f"- **Performance:** {style.get('performance', '')} | **Accessibility:** {style.get('accessibility', '')}") lines.append("") # Colors section (extended palette) lines.append("### Colors") lines.append("| Role | Hex | CSS Variable |") lines.append("|------|-----|--------------|") md_color_entries = [ ("Primary", "primary", "--color-primary"), ("On Primary", "on_primary", "--color-on-primary"), ("Secondary", "secondary", "--color-secondary"), ("Accent/CTA", "accent", "--color-accent"), ("Background", "background", "--color-background"), ("Foreground", "foreground", "--color-foreground"), ("Muted", "muted", "--color-muted"), ("Border", "border", "--color-border"), ("Destructive", "destructive", "--color-destructive"), ("Ring", "ring", "--color-ring"), ] for label, key, css_var in md_color_entries: hex_val = colors.get(key, "") if hex_val: lines.append(f"| {label} | `{hex_val}` | `{css_var}` |") if colors.get("notes"): lines.append(f"\n*Notes: {colors.get('notes', '')}*") lines.append("") # Typography section lines.append("### Typography") lines.append(f"- **Heading:** {typography.get('heading', '')}") lines.append(f"- **Body:** {typography.get('body', '')}") if typography.get("mood"): lines.append(f"- **Mood:** {typography.get('mood', '')}") if typography.get("best_for"): lines.append(f"- **Best For:** {typography.get('best_for', '')}") if typography.get("google_fonts_url"): lines.append(f"- **Google Fonts:** {typography.get('google_fonts_url', '')}") if typography.get("css_import"): lines.append("- **CSS Import:**") lines.append("```css") lines.append(f"{typography.get('css_import', '')}") lines.append("```") lines.append("") # Key Effects section if effects: lines.append("### Key Effects") 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)") newline_bullet = '\n- ' lines.append(f"- {anti_patterns.replace(' + ', newline_bullet)}") lines.append("") # Pre-Delivery Checklist section lines.append("### Pre-Delivery Checklist") lines.append("- [ ] No emojis as icons (use SVG: Heroicons/Lucide)") lines.append("- [ ] cursor-pointer on all clickable elements") lines.append("- [ ] Hover states with smooth transitions (150-300ms)") lines.append("- [ ] Light mode: text contrast 4.5:1 minimum") lines.append("- [ ] Focus states visible for keyboard nav") lines.append("- [ ] prefers-reduced-motion respected") lines.append("- [ ] Responsive: 375px, 768px, 1024px, 1440px") lines.append("") return "\n".join(lines) # ============ 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, variance: int = None, motion: int = None, density: int = None, force: bool = False) -> dict: """ Main entry point for design system generation. Args: query: Search query (e.g., "SaaS dashboard", "e-commerce luxury") project_name: Optional project name for output header output_format: "ascii" (default) or "markdown" 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) force: If True, overwrite an existing MASTER.md; otherwise persistence is skipped (with a status message) when one already exists Returns: dict with keys: "text" (formatted design system string), "design_system" (raw dict, useful for --json callers), and "persistence" (result of persist_design_system(), or None if persist=False) """ generator = DesignSystemGenerator() design_system = generator.generate(query, project_name, variance=variance, motion=motion, density=density) persistence_result = None if persist: persistence_result = persist_design_system(design_system, page, output_dir, query, force=force) text = format_markdown(design_system) if output_format == "markdown" else format_ascii_box(design_system) return { "text": text, "design_system": design_system, "persistence": persistence_result, } # ============ PERSISTENCE FUNCTIONS ============ def safe_slug(name, fallback: str = "default") -> str: """Slugify a name into a single safe path segment. Only [a-z0-9_-] survives; every other character (including '/', '\\' and '.') collapses into '-'. This makes path traversal via project/page names (e.g. "../../etc") impossible - the slug can never leave its parent dir. """ slug = re.sub(r'[^a-z0-9_-]+', '-', str(name).lower()).strip('-') return slug or fallback def persist_design_system(design_system: dict, page: str = None, output_dir: str = None, page_query: str = None, force: bool = False) -> dict: """ Persist design system to design-system/<project>/ folder using Master + Overrides pattern. Args: design_system: The generated design system dictionary page: Optional page name for page-specific override file output_dir: Optional output directory (defaults to current working directory) page_query: Optional query string for intelligent page override generation force: If True, overwrite an existing MASTER.md. If False (default) and MASTER.md already exists, persistence is skipped so prior design decisions aren't silently discarded. Returns: dict with created file paths and status. status is "skipped_exists" if MASTER.md already existed and force was not set. """ base_dir = Path(output_dir) if output_dir else Path.cwd() # 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 = safe_slug(project_name) design_system_dir = base_dir / "design-system" / project_slug pages_dir = design_system_dir / "pages" master_file = design_system_dir / "MASTER.md" if master_file.exists() and not force: return { "status": "skipped_exists", "design_system_dir": str(design_system_dir), "master_file": str(master_file), "created_files": [], "message": ( f"{master_file} already exists and was not modified. " "Read it first to check for prior design decisions, then " "re-run with force=True / --force to overwrite." ), } created_files = [] # Create directories design_system_dir.mkdir(parents=True, exist_ok=True) pages_dir.mkdir(parents=True, exist_ok=True) # Generate and write MASTER.md master_content = format_master_md(design_system) with open(master_file, 'w', encoding='utf-8') as f: f.write(master_content) created_files.append(str(master_file)) # If page is specified, create page override file with intelligent content if page: page_file = pages_dir / f"{safe_slug(page, 'page')}.md" page_content = format_page_override_md(design_system, page, page_query) with open(page_file, 'w', encoding='utf-8') as f: f.write(page_content) created_files.append(str(page_file)) return { "status": "success", "design_system_dir": str(design_system_dir), "master_file": str(master_file), "created_files": created_files } def format_master_md(design_system: dict) -> str: """Format design system as MASTER.md with hierarchical override logic.""" project = design_system.get("project_name", "PROJECT") pattern = design_system.get("pattern", {}) style = design_system.get("style", {}) colors = design_system.get("colors", {}) 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("") lines.append("> **LOGIC:** When building a specific page, first check `design-system/pages/[page-name].md`.") lines.append("> If that file exists, its rules **override** this Master file.") lines.append("> If not, strictly follow the rules below.") lines.append("") lines.append("---") lines.append("") 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("") # Global Rules section lines.append("## Global Rules") lines.append("") # Color Palette lines.append("### Color Palette") lines.append("") lines.append("| Role | Hex | CSS Variable |") lines.append("|------|-----|--------------|") master_color_entries = [ ("Primary", "primary", "--color-primary"), ("On Primary", "on_primary", "--color-on-primary"), ("Secondary", "secondary", "--color-secondary"), ("Accent/CTA", "accent", "--color-accent"), ("Background", "background", "--color-background"), ("Foreground", "foreground", "--color-foreground"), ("Muted", "muted", "--color-muted"), ("Border", "border", "--color-border"), ("Destructive", "destructive", "--color-destructive"), ("Ring", "ring", "--color-ring"), ] for label, key, css_var in master_color_entries: hex_val = colors.get(key, "") if hex_val: lines.append(f"| {label} | `{hex_val}` | `{css_var}` |") lines.append("") if colors.get("notes"): lines.append(f"**Color Notes:** {colors.get('notes', '')}") lines.append("") # Typography lines.append("### Typography") lines.append("") lines.append(f"- **Heading Font:** {typography.get('heading', 'Inter')}") lines.append(f"- **Body Font:** {typography.get('body', 'Inter')}") if typography.get("mood"): lines.append(f"- **Mood:** {typography.get('mood', '')}") if typography.get("google_fonts_url"): lines.append(f"- **Google Fonts:** [{typography.get('heading', '')} + {typography.get('body', '')}]({typography.get('google_fonts_url', '')})") lines.append("") if typography.get("css_import"): lines.append("**CSS Import:**") lines.append("```css") lines.append(typography.get("css_import", "")) lines.append("```") lines.append("") # 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("|-------|-------|-------|") 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 lines.append("### Shadow Depths") lines.append("") lines.append("| Level | Value | Usage |") lines.append("|-------|-------|-------|") lines.append("| `--shadow-sm` | `0 1px 2px rgba(0,0,0,0.05)` | Subtle lift |") lines.append("| `--shadow-md` | `0 4px 6px rgba(0,0,0,0.1)` | Cards, buttons |") lines.append("| `--shadow-lg` | `0 10px 15px rgba(0,0,0,0.1)` | Modals, dropdowns |") lines.append("| `--shadow-xl` | `0 20px 25px rgba(0,0,0,0.15)` | Hero images, featured cards |") lines.append("") # Component Specs section lines.append("---") lines.append("") lines.append("## Component Specs") lines.append("") # Buttons lines.append("### Buttons") lines.append("") lines.append("```css") lines.append("/* Primary Button */") lines.append(".btn-primary {") lines.append(f" background: {colors.get('cta', '#F97316')};") lines.append(" color: white;") lines.append(" padding: 12px 24px;") lines.append(" border-radius: 8px;") lines.append(" font-weight: 600;") lines.append(" transition: all 200ms ease;") lines.append(" cursor: pointer;") lines.append("}") lines.append("") lines.append(".btn-primary:hover {") lines.append(" opacity: 0.9;") lines.append(" transform: translateY(-1px);") lines.append("}") lines.append("") lines.append("/* Secondary Button */") lines.append(".btn-secondary {") lines.append(" background: transparent;") lines.append(f" color: {colors.get('primary', '#2563EB')};") lines.append(f" border: 2px solid {colors.get('primary', '#2563EB')};") lines.append(" padding: 12px 24px;") lines.append(" border-radius: 8px;") lines.append(" font-weight: 600;") lines.append(" transition: all 200ms ease;") lines.append(" cursor: pointer;") lines.append("}") lines.append("```") lines.append("") # Cards lines.append("### Cards") lines.append("") lines.append("```css") lines.append(".card {") lines.append(f" background: {colors.get('background', '#FFFFFF')};") lines.append(" border-radius: 12px;") lines.append(" padding: 24px;") lines.append(" box-shadow: var(--shadow-md);") lines.append(" transition: all 200ms ease;") lines.append(" cursor: pointer;") lines.append("}") lines.append("") lines.append(".card:hover {") lines.append(" box-shadow: var(--shadow-lg);") lines.append(" transform: translateY(-2px);") lines.append("}") lines.append("```") lines.append("") # Inputs lines.append("### Inputs") lines.append("") lines.append("```css") lines.append(".input {") lines.append(" padding: 12px 16px;") lines.append(" border: 1px solid #E2E8F0;") lines.append(" border-radius: 8px;") lines.append(" font-size: 16px;") lines.append(" transition: border-color 200ms ease;") lines.append("}") lines.append("") lines.append(".input:focus {") lines.append(f" border-color: {colors.get('primary', '#2563EB')};") lines.append(" outline: none;") lines.append(f" box-shadow: 0 0 0 3px {colors.get('primary', '#2563EB')}20;") lines.append("}") lines.append("```") lines.append("") # Modals lines.append("### Modals") lines.append("") lines.append("```css") lines.append(".modal-overlay {") lines.append(" background: rgba(0, 0, 0, 0.5);") lines.append(" backdrop-filter: blur(4px);") lines.append("}") lines.append("") lines.append(".modal {") lines.append(" background: white;") lines.append(" border-radius: 16px;") lines.append(" padding: 32px;") lines.append(" box-shadow: var(--shadow-xl);") lines.append(" max-width: 500px;") lines.append(" width: 90%;") lines.append("}") lines.append("```") lines.append("") # Style section lines.append("---") lines.append("") lines.append("## Style Guidelines") lines.append("") lines.append(f"**Style:** {style.get('name', 'Minimalism')}") lines.append("") if style.get("keywords"): lines.append(f"**Keywords:** {style.get('keywords', '')}") lines.append("") if style.get("best_for"): lines.append(f"**Best For:** {style.get('best_for', '')}") lines.append("") if effects: lines.append(f"**Key Effects:** {effects}") lines.append("") # Layout Pattern lines.append("### Page Pattern") lines.append("") lines.append(f"**Pattern Name:** {pattern.get('name', '')}") lines.append("") if pattern.get('conversion'): lines.append(f"- **Conversion Strategy:** {pattern.get('conversion', '')}") if pattern.get('cta_placement'): 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("") lines.append("## Anti-Patterns (Do NOT Use)") lines.append("") if anti_patterns: anti_list = [a.strip() for a in anti_patterns.split("+")] for anti in anti_list: if anti: lines.append(f"- ❌ {anti}") lines.append("") lines.append("### Additional Forbidden Patterns") lines.append("") lines.append("- ❌ **Emojis as icons** - Use SVG icons (Heroicons, Lucide, Simple Icons)") lines.append("- ❌ **Missing cursor:pointer** - All clickable elements must have cursor:pointer") lines.append("- ❌ **Layout-shifting hovers** - Avoid scale transforms that shift layout") lines.append("- ❌ **Low contrast text** - Maintain 4.5:1 minimum contrast ratio") lines.append("- ❌ **Instant state changes** - Always use transitions (150-300ms)") lines.append("- ❌ **Invisible focus states** - Focus states must be visible for a11y") lines.append("") # Pre-Delivery Checklist lines.append("---") lines.append("") lines.append("## Pre-Delivery Checklist") lines.append("") lines.append("Before delivering any UI code, verify:") lines.append("") lines.append("- [ ] No emojis used as icons (use SVG instead)") lines.append("- [ ] All icons from consistent icon set (Heroicons/Lucide)") lines.append("- [ ] `cursor-pointer` on all clickable elements") lines.append("- [ ] Hover states with smooth transitions (150-300ms)") lines.append("- [ ] Light mode: text contrast 4.5:1 minimum") lines.append("- [ ] Focus states visible for keyboard navigation") lines.append("- [ ] `prefers-reduced-motion` respected") lines.append("- [ ] Responsive: 375px, 768px, 1024px, 1440px") lines.append("- [ ] No content hidden behind fixed navbars") lines.append("- [ ] No horizontal scroll on mobile") lines.append("") return "\n".join(lines) def format_page_override_md(design_system: dict, page_name: str, page_query: str = None) -> str: """Format a page-specific override file with intelligent AI-generated content.""" project = design_system.get("project_name", "PROJECT") timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S") page_title = page_name.replace("-", " ").replace("_", " ").title() # Detect page type and generate intelligent overrides page_overrides = _generate_intelligent_overrides(page_name, page_query, design_system) lines = [] lines.append(f"# {page_title} Page Overrides") lines.append("") lines.append(f"> **PROJECT:** {project}") lines.append(f"> **Generated:** {timestamp}") lines.append(f"> **Page Type:** {page_overrides.get('page_type', 'General')}") lines.append("") lines.append("> ⚠️ **IMPORTANT:** Rules in this file **override** the Master file (`design-system/MASTER.md`).") lines.append("> Only deviations from the Master are documented here. For all other rules, refer to the Master.") lines.append("") lines.append("---") lines.append("") # Page-specific rules with actual content lines.append("## Page-Specific Rules") lines.append("") # Layout Overrides lines.append("### Layout Overrides") lines.append("") layout = page_overrides.get("layout", {}) if layout: for key, value in layout.items(): lines.append(f"- **{key}:** {value}") else: lines.append("- No overrides - use Master layout") lines.append("") # Spacing Overrides lines.append("### Spacing Overrides") lines.append("") spacing = page_overrides.get("spacing", {}) if spacing: for key, value in spacing.items(): lines.append(f"- **{key}:** {value}") else: lines.append("- No overrides - use Master spacing") lines.append("") # Typography Overrides lines.append("### Typography Overrides") lines.append("") typography = page_overrides.get("typography", {}) if typography: for key, value in typography.items(): lines.append(f"- **{key}:** {value}") else: lines.append("- No overrides - use Master typography") lines.append("") # Color Overrides lines.append("### Color Overrides") lines.append("") colors = page_overrides.get("colors", {}) if colors: for key, value in colors.items(): lines.append(f"- **{key}:** {value}") else: lines.append("- No overrides - use Master colors") lines.append("") # Component Overrides lines.append("### Component Overrides") lines.append("") components = page_overrides.get("components", []) if components: for comp in components: lines.append(f"- {comp}") else: lines.append("- No overrides - use Master component specs") lines.append("") # Page-Specific Components lines.append("---") lines.append("") lines.append("## Page-Specific Components") lines.append("") unique_components = page_overrides.get("unique_components", []) if unique_components: for comp in unique_components: lines.append(f"- {comp}") else: lines.append("- No unique components for this page") lines.append("") # Recommendations lines.append("---") lines.append("") lines.append("## Recommendations") lines.append("") recommendations = page_overrides.get("recommendations", []) if recommendations: for rec in recommendations: lines.append(f"- {rec}") lines.append("") return "\n".join(lines) def _generate_intelligent_overrides(page_name: str, page_query: str, design_system: dict) -> dict: """ Generate intelligent overrides based on page type using layered search. Uses the existing search infrastructure to find relevant style, UX, and layout data instead of hardcoded page types. """ from core import search page_lower = page_name.lower() query_lower = (page_query or "").lower() combined_context = f"{page_lower} {query_lower}" # Search across multiple domains for page-specific guidance style_search = search(combined_context, "style", max_results=1) ux_search = search(combined_context, "ux", max_results=3) landing_search = search(combined_context, "landing", max_results=1) # Extract results from search response style_results = style_search.get("results", []) ux_results = ux_search.get("results", []) landing_results = landing_search.get("results", []) # Detect page type from search results or context page_type = _detect_page_type(combined_context, style_results) # Build overrides from search results layout = {} spacing = {} typography = {} colors = {} components = [] unique_components = [] recommendations = [] # Extract style-based overrides if style_results: style = style_results[0] keywords = style.get("Keywords", "") effects = style.get("Effects & Animation", "") # Infer layout from style keywords if any(kw in keywords.lower() for kw in ["data", "dense", "dashboard", "grid"]): layout["Max Width"] = "1400px or full-width" layout["Grid"] = "12-column grid for data flexibility" spacing["Content Density"] = "High - optimize for information display" elif any(kw in keywords.lower() for kw in ["minimal", "simple", "clean", "single"]): layout["Max Width"] = "800px (narrow, focused)" layout["Layout"] = "Single column, centered" spacing["Content Density"] = "Low - focus on clarity" else: layout["Max Width"] = "1200px (standard)" layout["Layout"] = "Full-width sections, centered content" if effects: recommendations.append(f"Effects: {effects}") # Extract UX guidelines as recommendations for ux in ux_results: category = ux.get("Category", "") do_text = ux.get("Do", "") dont_text = ux.get("Don't", "") if do_text: recommendations.append(f"{category}: {do_text}") if dont_text: components.append(f"Avoid: {dont_text}") # Extract landing pattern info for section structure if landing_results: landing = landing_results[0] s -
search.py 8.5 KB
#!/usr/bin/env python3 # -*- coding: utf-8 -*- """ UI/UX Pro Max Search - BM25 search engine for UI/UX style guides Usage: python search.py "<query>" [--domain <domain>] [--stack <stack>] [--max-results 3] python search.py "<query>" --design-system [-p "Project Name"] python search.py "<query>" --design-system --persist [-p "Project Name"] --output-dir "<project-root>" [--page "dashboard"] python search.py "<query>" --design-system --variance 8 --motion 9 --density 7 Domains: style, color, chart, landing, product, ux, typography, google-fonts, icons, gsap, react, web Stacks: react, nextjs, vue, svelte, astro, swiftui, react-native, flutter, nuxtjs, nuxt-ui, html-tailwind, shadcn, jetpack-compose, threejs, angular, laravel 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/<project-slug>/MASTER.md --output-dir Directory the design-system/ folder is created under (defaults to cwd -- always pass this explicitly, pointed at the project root) --page Also create a page-specific override file in design-system/<project-slug>/pages/ --force Overwrite an existing MASTER.md (without this, persistence is skipped if MASTER.md already exists, so prior design decisions aren't lost) """ import argparse import io import json as json_module import sys from core import AVAILABLE_STACKS, CSV_CONFIG, MAX_RESULTS, UNTRUNCATED_COLS, search, search_stack from design_system import generate_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') TRUNCATE_AT = 300 def format_output(result, full=False): """Format results for Claude consumption (token-optimized)""" if "error" in result: return f"Error: {result['error']}" output = [] if result.get("stack"): output.append("## UI Pro Max Stack Guidelines") output.append(f"**Stack:** {result['stack']} | **Query:** {result['query']}") else: output.append("## UI Pro Max Search Results") domain_note = result['domain'] if result.get("auto_detected"): domain_note += " (auto-detected" if result.get("runner_up_domain"): domain_note += f", runner-up: {result['runner_up_domain']}" domain_note += ")" output.append(f"**Domain:** {domain_note} | **Query:** {result['query']}") output.append(f"**Source:** {result['file']} | **Found:** {result['count']} results\n") if result['count'] == 0: output.append( "No matches. This is not a match with an empty value -- the query " "did not hit the database. Retry with broader/different keywords " "before falling back to general defaults, and say explicitly that " "no database match was found if you do fall back." ) suggestions = result.get("suggestions") or [] if suggestions: output.append(f"**Closest known terms:** {', '.join(suggestions)}") return "\n".join(output) for i, row in enumerate(result['results'], 1): output.append(f"### Result {i}") for key, value in row.items(): value_str = str(value) if not full and key not in UNTRUNCATED_COLS and len(value_str) > TRUNCATE_AT: value_str = value_str[:TRUNCATE_AT] + "..." 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") parser.add_argument("--full", action="store_true", help="Do not truncate long field values in text output") # 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 (ignored if --json)") # Persistence (Master + Overrides pattern) parser.add_argument("--persist", action="store_true", help="Save design system to design-system/<project-slug>/MASTER.md (creates hierarchical structure)") parser.add_argument("--page", type=str, default=None, help="Create page-specific override file in design-system/<project-slug>/pages/") parser.add_argument("--output-dir", "-o", type=str, default=None, help="Output directory for persisted files (default: current directory -- pass this explicitly, pointed at the project root)") parser.add_argument("--force", action="store_true", help="Overwrite an existing MASTER.md when persisting (default: skip if it already exists)") # 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, force=args.force, ) if args.json: print(json_module.dumps( {"design_system": result["design_system"], "persistence": result["persistence"]}, indent=2, ensure_ascii=False, )) else: print(result["text"]) if args.persist: persistence = result["persistence"] or {} print("\n" + "=" * 60) if persistence.get("status") == "skipped_exists": print(f"⚠️ {persistence.get('message', 'MASTER.md already exists; not overwritten.')}") else: ds_dir = persistence.get("design_system_dir", "design-system/<project>") print(f"✅ Design system persisted to {ds_dir}/") for f in persistence.get("created_files", []): print(f" 📄 {f}") print("") print(f"📖 Usage: When building a page, check {ds_dir}/pages/[page].md first.") print(" If it 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: print(json_module.dumps(result, indent=2, ensure_ascii=False)) else: print(format_output(result, full=args.full)) # Domain search else: result = search(args.query, args.domain, args.max_results) if args.json: print(json_module.dumps(result, indent=2, ensure_ascii=False)) else: print(format_output(result, full=args.full)) -
validate_data.py 4.1 KB
#!/usr/bin/env python3 # -*- coding: utf-8 -*- """ Data integrity guardrail for ui-ux-pro-max. Stdlib-only, no pytest dependency, so it can run as a standalone pre-publish/CI check: python validate_data.py Checks, per configured domain/stack CSV: - file exists - header row contains every column referenced in search_cols/output_cols - no duplicate primary-key values (first column) within a file - any "Decision_Rules"-style JSON column parses as JSON Exits 0 with no output on success; exits 1 and prints every problem found on failure (fail-fast is the wrong call here -- a data change can break several files at once, so we want the full list in one run). """ import csv import json import sys from core import _STACK_COLS, CSV_CONFIG, DATA_DIR, STACK_CONFIG # REASONING_FILE lives in design_system.py, not core.py -- redeclared here to # avoid a circular import (design_system.py imports core.py). REASONING_FILE = "ui-reasoning.csv" JSON_COLUMNS = {"Decision_Rules"} def _read_rows(filepath): with open(filepath, "r", encoding="utf-8") as f: reader = csv.DictReader(f) return reader.fieldnames or [], list(reader) def _check_file(label, filepath, search_cols, output_cols, problems): if not filepath.exists(): problems.append(f"[{label}] missing file: {filepath}") return try: headers, rows = _read_rows(filepath) except (csv.Error, UnicodeDecodeError, OSError) as e: problems.append(f"[{label}] failed to parse {filepath.name}: {e}") return header_set = set(headers) for col in set(search_cols) | set(output_cols): if col not in header_set: problems.append(f"[{label}] {filepath.name}: expected column '{col}' not found in header") # Only check for duplicates against an actual identifier column ("No" is # the sequential-index convention used across this dataset). The first # CSV column is not reliably a unique key -- e.g. stack files use # "Category", which legitimately repeats across many guideline rows. if "No" in header_set: seen = {} for i, row in enumerate(rows, start=2): # +1 header, +1 to be 1-indexed key = row.get("No", "") if key in seen: problems.append( f"[{label}] {filepath.name}: duplicate 'No' value '{key}' on rows {seen[key]} and {i}" ) else: seen[key] = i elif label.startswith("stack:"): problems.append( f"[{label}] {filepath.name}: missing 'No' index column present in other stack files " "(schema drift -- harmless for search, but inconsistent with the rest of data/stacks/)" ) for row_idx, row in enumerate(rows, start=2): for col in JSON_COLUMNS: if col in row and row[col]: try: json.loads(row[col]) except json.JSONDecodeError as e: problems.append( f"[{label}] {filepath.name} row {row_idx}: column '{col}' is not valid JSON: {e}" ) def main(): problems = [] for domain, config in CSV_CONFIG.items(): _check_file(f"domain:{domain}", DATA_DIR / config["file"], config["search_cols"], config["output_cols"], problems) for stack, config in STACK_CONFIG.items(): _check_file(f"stack:{stack}", DATA_DIR / config["file"], _STACK_COLS["search_cols"], _STACK_COLS["output_cols"], problems) reasoning_path = DATA_DIR / REASONING_FILE if reasoning_path.exists(): _check_file("reasoning", reasoning_path, ["UI_Category"], ["UI_Category", "Decision_Rules"], problems) else: problems.append(f"[reasoning] missing file: {reasoning_path}") if problems: print(f"FAILED: {len(problems)} data integrity issue(s) found:\n") for p in problems: print(f" - {p}") sys.exit(1) print(f"OK: validated {len(CSV_CONFIG)} domain files, {len(STACK_CONFIG)} stack files, and ui-reasoning.csv") sys.exit(0) if __name__ == "__main__": main()
-
-
SKILL.md 10.5 KB
--- name: ui-ux-pro-max description: Default web UI/UX design intelligence for Navin. Design-system generator (84 styles, 192 palettes, typography, landing patterns, UX rules) plus mandatory framer-motion for all web sites. Use when building, designing, scaffolding, or reviewing any website, landing page, dashboard, or frontend UI. metadata: {"navin":{"emoji":"🎨","category":"development","default_for":"web"}} --- # UI/UX Pro Max (Navin default for web) Bundled design intelligence from [ui-ux-pro-max-skill](https://github.com/nextlevelbuilder/ui-ux-pro-max-skill) (MIT). This is the **default** skill for every website / frontend UI task in Navin. ## Hard defaults (non-negotiable for web) 0. **Lock one official design system (Navin Code, non-negotiable):** Google Material (`@mui/material` + `@emotion/react` + `@emotion/styled`), Microsoft Fluent (`@fluentui/react`), or IBM Carbon (`@carbon/react` + `@carbon/styles`). If the user did not choose, call `ask_user` (`a` Google recommended, `b` Microsoft, `c` IBM). Skip takes Google. Never default to Tailwind, shadcn, Chakra, Ant, or a homemade kit. Map MASTER.md tokens onto that vendor `ThemeProvider`. Scaffold with Vite (or Next if named), never `create-react-app`. 1. **Install and use `framer-motion` on every web project** (React / Next / Vite / Astro-with-React, etc.): ```bash npm install framer-motion # or: pnpm add framer-motion / yarn add framer-motion / bun add framer-motion ``` - If `package.json` has no `framer-motion` (and no `motion` package), install it **before** writing UI animation code. - Prefer `framer-motion` (`motion` / `AnimatePresence`) for enter/exit, stagger, page transitions, and micro-interactions - not ad-hoc CSS-only for hero/section motion. - Always respect `prefers-reduced-motion` (disable or simplify motion when set). - Spring defaults: `transition: { type: "spring", duration: 0.3, bounce: 0 }` unless the design system says otherwise. 2. **Install and use Three.js on every Dev web UI, every Marketing / Montage page, every 3D request, and every studio HTML report UI:** ```bash npm install three @react-three/fiber @react-three/drei ``` Run `python3 "$SEARCH" "<theme>" --stack threejs` before the scene. Use drei helpers. The scene must look designed (PBR, lights, shadows, framed camera), never wallpaper. Still fallback when `prefers-reduced-motion`. Never put Three.js on a PPT or Word page. 3. **Generate a design system first** for new pages/sites (Step 2 below) before inventing colors/fonts. 4. **Icons**: vendor set of the locked DS (`@mui/icons-material`, Fluent icons, `@carbon/icons-react`). Lucide / Heroicons SVG only as a fallback. Never emoji as icons. 5. **No em dashes / en dashes in ANY UI copy** - characters U+2014 and U+2013 are forbidden. Use a plain hyphen `-` or rephrase. This is enforced by `verify` / lint (`no-em-dash`). 6. **No cardboard apps** - every visible control must work or be removed. No "Coming soon", empty `onClick`, `alert()` stubs, or lorem. Dashboards must load real data or a real empty state. 7. **Functional Preview gate** - after `open_preview`, click the main nav and the primary CTA. If the dashboard is blank or errors, you are not done. Also load `make-interfaces-feel-better` for polish details (radius, shadows, stagger). ## Super render stack (marketing, launch, portfolio) A pretty page is not 12 runtimes. Install what the surface needs. Stop there. **Every marketing / launch / editorial site** (on top of `framer-motion`): ```bash npm install framer-motion lenis embla-carousel-react lucide-react three @react-three/fiber @react-three/drei ``` | Package | Job | |---------|-----| | `framer-motion` | Hero presence, section reveal, stagger, page transition, press. Already mandatory. | | `lenis` | Smooth scroll. Wire it once at the root. Disable when `prefers-reduced-motion`. | | `embla-carousel-react` | Lookbook, product shots, proof strip. Not Swiper. | | `lucide-react` | Icons. SVG only. Never emoji. | | `three` + `@react-three/fiber` + `@react-three/drei` | Designed 3D layer on every Marketing / Montage page. Same quality bar as Dev. | **Dev, Marketing, Montage, any 3D page, and studio HTML report UIs (mandatory):** ```bash npm install three @react-three/fiber @react-three/drei ``` Before writing the scene, run the Three.js stack search and follow every hit: ```bash python3 "$SEARCH" "<product or report theme> spatial hero" --stack threejs ``` | Package | Job | |---------|-----| | `three` + `@react-three/fiber` + `@react-three/drei` | Designed 3D layer on every Dev, Marketing, and Montage web surface, every 3D request, and every studio HTML report UI. Hero, product, or spatial chrome. Still fallback required. | | `@react-three/postprocessing` | That 3D hero needs bloom / grain. Never as the only "design". | | `@number-flow/react` | A giant KPI that ticks. One per viewport max. | | `recharts` | A dashboard or a real data section. Not a landing decoration. | **Scene quality (non-negotiable):** one Canvas / one renderer; `pixelRatio` capped at 2; `antialias` at construction; PBR (`meshStandardMaterial`) plus Ambient + Directional lights (objects must not render black); `shadowMap` enabled before cast/receive; FOV 45-75; explicit camera position + lookAt; drei `OrbitControls` (damping) or a constrained camera; `Environment` + `ContactShadows` on the hero; `useFrame` / `Clock.getDelta()` once per frame; pause the loop when the tab is hidden; dispose geometries/materials/textures on teardown; canvas `role="img"` + `aria-label`; `prefers-reduced-motion` shows the still. Production = npm + Vite, not a floating CDN `latest`. The 3D is a designed object or environment the user can read. Never Three.js as wallpaper, never a particle field as the page, never a blank `<Canvas />`. **Do not install by default:** GSAP, ScrollTrigger, Locomotive, Spline runtime, Rive, Lottie, tsParticles / particles.js, Three.js as wallpaper, Swiper, Barba, Theatre.js. GSAP only if the user names it. One Rive mark is allowed when the brand already has a `.riv` file. Never put Three.js on a PPT or Word page (it becomes a screenshot). Reduced motion: Lenis off, Motion snaps, 3D shows the still, Number Flow shows the final figure. ## When to use Any UI that **looks, feels, moves, or is interacted with**: landings, marketing sites, SaaS app shells, dashboards, CRMs, portfolios, e-commerce, forms, component libraries. Skip for pure backend/API/DB/infra with no UI. ## Search tool path The bundled script is `<skill folder>/scripts/search.py` (no network, stdlib only). The skill folder's absolute path is printed when this skill is loaded - the `[Skill folder: ...]` header above, or the `(Skill folder: ...)` line under the skill title. Use that path as `SEARCH` below; never hunt for it with imports or a filesystem-wide find. Use `python3` on Linux/macOS and `python` (or `py -3`) on Windows. ## Workflow ### 1. Analyze Extract: product type, industry, audience, tone, stack (from `package.json` / framework files - never assume). Default stack for greenfield web: React + Vite (or Next if the user named it) **plus one official DS**: MUI, Fluent, or Carbon. Never Tailwind as the default system. ### 2. Design system (required for new pages/projects) ```bash python3 "$SEARCH" "<product> <industry> <keywords>" --design-system -p "Project Name" ``` Persist into the **user** workspace: ```bash python3 "$SEARCH" "<query>" --design-system --persist -p "Project Name" --output-dir "<project-root>" ``` Creates `design-system/<slug>/MASTER.md` (+ optional `--page "dashboard"` overrides). If MASTER already exists, read it first; only regenerate with `--force` when the user wants a reset. ### 3. Optional dials `--variance` / `--motion` / `--density` (1-10) on the same `--design-system` command. For web sites, prefer `--motion` in the 5-8 range and implement those motions with **framer-motion** (not GSAP unless the user asks). ### 4. Domain / stack deep-dives ```bash python3 "$SEARCH" "<keyword>" --domain style|color|typography|landing|ux|chart|icons|react|gsap|... python3 "$SEARCH" "<keyword>" --stack react|nextjs|vue|html-tailwind|shadcn|... ``` ### 5. Implement - Apply MASTER.md tokens through the locked vendor theme (MUI / Fluent / Carbon), not as a parallel homemade CSS kit. - Install `framer-motion` if missing. - On Dev / Marketing / Montage / 3D / studio report UIs: install `three` + `@react-three/fiber` + `@react-three/drei` if missing, run `--stack threejs`, then implement the scene with drei helpers (OrbitControls, Environment, ContactShadows, PresentationControls as needed). - Ship 2-3 intentional motions (hero presence, section reveal / stagger, CTA hover/press) - not noise. - Follow Navin frontend rules when they apply: one composition in the first viewport, brand-first, expressive fonts (not Inter/Roboto/Arial defaults), atmospheric backgrounds, full-bleed heroes on landings, no card clutter in heroes, avoid purple-on-white / cream+terracotta / broadsheet clichés unless the design system explicitly requires them. ### 6. Pre-delivery checklist - [ ] Official DS locked: `@mui/material` or `@fluentui/react` or `@carbon/react` (ask_user if none) - [ ] `framer-motion` in package.json and used for primary animations - [ ] Dev / Marketing / Montage / 3D / studio report UI: `three` + `@react-three/fiber` + `@react-three/drei` installed and used; `--stack threejs` was run - [ ] 3D scene meets the quality bar (lights, shadows, camera, one renderer, reduced-motion still) - [ ] Marketing/launch pages also have `lenis`, `embla-carousel-react`, `lucide-react` when those surfaces exist - [ ] No GSAP / particles / Three-as-wallpaper unless the user asked - [ ] No emoji icons (SVG only) - [ ] `cursor-pointer` on clickable elements - [ ] Hover/focus transitions 150-300ms - [ ] Text contrast ≥ 4.5:1 (light mode) - [ ] Visible keyboard focus - [ ] `prefers-reduced-motion` respected - [ ] Responsive: 375 / 768 / 1024 / 1440 - [ ] Design system MASTER.md present for new sites - [ ] No em-dash / en-dash characters in UI strings (`verify` must be clean) - [ ] Every primary button/nav item routes or mutates for real (no stubs) - [ ] Dashboard / home view loads without blank screen (data or empty state) - [ ] Preview happy path clicked by you before "done" ## If search returns 0 results Retry with broader keywords once; then fall back to the priority table in `references/quick-reference.md` and say the fallback is not a DB match. Never invent fake search hits. ## References (read on demand) - `references/quick-reference.md` - full UX guideline index - `references/pro-rules.md` - app/native checklist extras - Upstream: https://github.com/nextlevelbuilder/ui-ux-pro-max-skill
Comments (0)
Sign in to join the conversation.
Reviews (0)
No reviews yet.
No comments yet.