📦 deps(thirdparty): update snapshots
This commit is contained in:
@@ -77,6 +77,14 @@ CSV_CONFIG = {
|
||||
}
|
||||
}
|
||||
|
||||
# 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"},
|
||||
@@ -111,6 +119,44 @@ _STACK_COLS = {
|
||||
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"""
|
||||
@@ -124,11 +170,13 @@ class BM25:
|
||||
self.idf = {}
|
||||
self.doc_freqs = defaultdict(int)
|
||||
self.N = 0
|
||||
self._term_freqs = [] # precomputed per-doc term frequencies
|
||||
|
||||
def tokenize(self, text):
|
||||
"""Lowercase, split, remove punctuation, filter short words"""
|
||||
text = re.sub(r'[^\w\s]', ' ', str(text).lower())
|
||||
return [w for w in text.split() if len(w) >= 2]
|
||||
"""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"""
|
||||
@@ -139,12 +187,14 @@ class BM25:
|
||||
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:
|
||||
seen = set()
|
||||
tf = defaultdict(int)
|
||||
for word in doc:
|
||||
if word not in seen:
|
||||
self.doc_freqs[word] += 1
|
||||
seen.add(word)
|
||||
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)
|
||||
@@ -154,16 +204,14 @@ class BM25:
|
||||
query_tokens = self.tokenize(query)
|
||||
scores = []
|
||||
|
||||
for idx, doc in enumerate(self.corpus):
|
||||
for idx in range(self.N):
|
||||
score = 0
|
||||
doc_len = self.doc_lengths[idx]
|
||||
term_freqs = defaultdict(int)
|
||||
for word in doc:
|
||||
term_freqs[word] += 1
|
||||
term_freqs = self._term_freqs[idx]
|
||||
|
||||
for token in query_tokens:
|
||||
if token in self.idf:
|
||||
tf = term_freqs[token]
|
||||
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)
|
||||
@@ -173,48 +221,139 @@ class BM25:
|
||||
|
||||
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)
|
||||
|
||||
|
||||
# ============ SEARCH FUNCTIONS ============
|
||||
def _load_csv(filepath):
|
||||
"""Load CSV and return list of dicts"""
|
||||
"""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:
|
||||
return list(csv.DictReader(f))
|
||||
rows = list(csv.DictReader(f))
|
||||
|
||||
_csv_cache[filepath] = (mtime, rows)
|
||||
return rows
|
||||
|
||||
|
||||
def _search_csv(filepath, search_cols, output_cols, query, max_results):
|
||||
"""Core search function using BM25"""
|
||||
if not filepath.exists():
|
||||
return []
|
||||
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]
|
||||
|
||||
data = _load_csv(filepath)
|
||||
|
||||
# Build documents from search columns
|
||||
documents = [" ".join(str(row.get(col, "")) for col in search_cols) for row in data]
|
||||
|
||||
# BM25 search
|
||||
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)
|
||||
|
||||
# Get top results with score > 0
|
||||
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
|
||||
return results, bm25
|
||||
|
||||
|
||||
def detect_domain(query):
|
||||
"""Auto-detect the most relevant domain from query"""
|
||||
query_lower = query.lower()
|
||||
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 []
|
||||
|
||||
domain_keywords = {
|
||||
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": ["saas", "ecommerce", "e-commerce", "fintech", "healthcare", "gaming", "portfolio", "crypto", "dashboard", "fitness", "restaurant", "hotel", "travel", "music", "education", "learning", "legal", "insurance", "medical", "beauty", "pharmacy", "dental", "pet", "dating", "wedding", "recipe", "delivery", "ride", "booking", "calendar", "timer", "tracker", "diary", "note", "chat", "messenger", "crm", "invoice", "parking", "transit", "vpn", "alarm", "weather", "sleep", "meditation", "fasting", "habit", "grocery", "meme", "wardrobe", "plant care", "reading", "flashcard", "puzzle", "trivia", "arcade", "photography", "streaming", "podcast", "newsletter", "marketplace", "freelancer", "coworking", "airline", "museum", "theater", "church", "non-profit", "charity", "kindergarten", "daycare", "senior care", "veterinary", "florist", "bakery", "brewery", "construction", "automotive", "real estate", "logistics", "agriculture", "coding bootcamp"],
|
||||
"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"],
|
||||
@@ -224,16 +363,57 @@ def detect_domain(query):
|
||||
"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
|
||||
|
||||
scores = {domain: sum(1 for kw in keywords if re.search(r'\b' + re.escape(kw) + r'\b', query_lower)) for domain, keywords in domain_keywords.items()}
|
||||
best = max(scores, key=scores.get)
|
||||
return best if scores[best] > 0 else "style"
|
||||
|
||||
# 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 = detect_domain(query)
|
||||
domain, runner_up = detect_domain(query, return_scores=True)
|
||||
|
||||
config = CSV_CONFIG.get(domain, CSV_CONFIG["style"])
|
||||
filepath = DATA_DIR / config["file"]
|
||||
@@ -241,15 +421,22 @@ def search(query, domain=None, max_results=MAX_RESULTS):
|
||||
if not filepath.exists():
|
||||
return {"error": f"File not found: {filepath}", "domain": domain}
|
||||
|
||||
results = _search_csv(filepath, config["search_cols"], config["output_cols"], query, max_results)
|
||||
results, bm25 = _search_csv(filepath, config["search_cols"], config["output_cols"], query, max_results)
|
||||
|
||||
return {
|
||||
out = {
|
||||
"domain": domain,
|
||||
"query": query,
|
||||
"file": config["file"],
|
||||
"count": len(results),
|
||||
"results": 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):
|
||||
@@ -262,13 +449,16 @@ def search_stack(query, stack, max_results=MAX_RESULTS):
|
||||
if not filepath.exists():
|
||||
return {"error": f"Stack file not found: {filepath}", "stack": stack}
|
||||
|
||||
results = _search_csv(filepath, _STACK_COLS["search_cols"], _STACK_COLS["output_cols"], query, max_results)
|
||||
results, bm25 = _search_csv(filepath, _STACK_COLS["search_cols"], _STACK_COLS["output_cols"], query, max_results)
|
||||
|
||||
return {
|
||||
out = {
|
||||
"domain": "stack",
|
||||
"stack": stack,
|
||||
"query": query,
|
||||
"file": STACK_CONFIG[stack]["file"],
|
||||
"count": len(results),
|
||||
"results": results
|
||||
"results": results,
|
||||
}
|
||||
if not results:
|
||||
out["suggestions"] = _suggest_terms(bm25, query)
|
||||
return out
|
||||
|
||||
@@ -7,10 +7,12 @@ 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)
|
||||
result = generate_design_system("SaaS dashboard", "My Project", persist=True, page="dashboard")
|
||||
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
|
||||
@@ -665,7 +667,8 @@ def format_markdown(design_system: dict) -> str:
|
||||
# ============ MAIN ENTRY POINT ============
|
||||
def generate_design_system(query: str, project_name: str = None, output_format: str = "ascii",
|
||||
persist: bool = False, page: str = None, output_dir: str = None,
|
||||
variance: int = None, motion: int = None, density: int = None) -> str:
|
||||
variance: int = None, motion: int = None, density: int = None,
|
||||
force: bool = False) -> dict:
|
||||
"""
|
||||
Main entry point for design system generation.
|
||||
|
||||
@@ -679,20 +682,28 @@ def generate_design_system(query: str, project_name: str = None, output_format:
|
||||
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:
|
||||
Formatted design system string
|
||||
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)
|
||||
|
||||
# Persist to files if requested
|
||||
persistence_result = None
|
||||
if persist:
|
||||
persist_design_system(design_system, page, output_dir, query)
|
||||
persistence_result = persist_design_system(design_system, page, output_dir, query, force=force)
|
||||
|
||||
if output_format == "markdown":
|
||||
return format_markdown(design_system)
|
||||
return format_ascii_box(design_system)
|
||||
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 ============
|
||||
@@ -707,42 +718,61 @@ def safe_slug(name, fallback: str = "default") -> str:
|
||||
return slug or fallback
|
||||
|
||||
|
||||
def persist_design_system(design_system: dict, page: str = None, output_dir: str = None, page_query: str = None) -> dict:
|
||||
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
|
||||
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 slugification can't crash.
|
||||
project_slug = safe_slug(design_system.get("project_name") or "default")
|
||||
|
||||
# (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)
|
||||
|
||||
master_file = design_system_dir / "MASTER.md"
|
||||
|
||||
|
||||
# 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"
|
||||
@@ -750,10 +780,11 @@ def persist_design_system(design_system: dict, page: str = None, output_dir: str
|
||||
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
|
||||
}
|
||||
|
||||
@@ -1337,4 +1368,4 @@ if __name__ == "__main__":
|
||||
args = parser.parse_args()
|
||||
|
||||
result = generate_design_system(args.query, args.project_name, args.format)
|
||||
print(result)
|
||||
print(result["text"])
|
||||
|
||||
@@ -4,11 +4,12 @@
|
||||
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"] [--page "dashboard"]
|
||||
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, prompt, color, chart, landing, product, ux, typography, google-fonts, gsap
|
||||
Stacks: react, nextjs, vue, svelte, astro, swiftui, react-native, flutter, nuxtjs, nuxt-ui, html-tailwind, shadcn, jetpack-compose, threejs, angular, laravel, javafx, wpf, winui, avalonia, uno, uwp
|
||||
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
|
||||
@@ -16,15 +17,20 @@ Design dials (1-10, only with --design-system):
|
||||
--density VISUAL_DENSITY: 1=spacious, 10=dense/dashboard; overrides the spacing scale
|
||||
|
||||
Persistence (Master + Overrides pattern):
|
||||
--persist Save design system to design-system/MASTER.md
|
||||
--page Also create a page-specific override file in design-system/pages/
|
||||
--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 json as json_module
|
||||
import sys
|
||||
import io
|
||||
from core import CSV_CONFIG, AVAILABLE_STACKS, MAX_RESULTS, search, search_stack
|
||||
from design_system import generate_design_system, persist_design_system, safe_slug
|
||||
from core import CSV_CONFIG, AVAILABLE_STACKS, 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':
|
||||
@@ -32,27 +38,47 @@ if sys.stdout.encoding and sys.stdout.encoding.lower() != '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):
|
||||
|
||||
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(f"## UI Pro Max Stack Guidelines")
|
||||
output.append("## UI Pro Max Stack Guidelines")
|
||||
output.append(f"**Stack:** {result['stack']} | **Query:** {result['query']}")
|
||||
else:
|
||||
output.append(f"## UI Pro Max Search Results")
|
||||
output.append(f"**Domain:** {result['domain']} | **Query:** {result['query']}")
|
||||
output.append("## 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 len(value_str) > 300:
|
||||
value_str = value_str[:300] + "..."
|
||||
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("")
|
||||
|
||||
@@ -66,14 +92,16 @@ if __name__ == "__main__":
|
||||
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")
|
||||
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/MASTER.md (creates hierarchical structure)")
|
||||
parser.add_argument("--page", type=str, default=None, help="Create page-specific override file in design-system/pages/")
|
||||
parser.add_argument("--output-dir", "-o", type=str, default=None, help="Output directory for persisted files (default: current directory)")
|
||||
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)")
|
||||
@@ -92,36 +120,43 @@ if __name__ == "__main__":
|
||||
output_dir=args.output_dir,
|
||||
variance=args.variance,
|
||||
motion=args.motion,
|
||||
density=args.density
|
||||
density=args.density,
|
||||
force=args.force,
|
||||
)
|
||||
print(result)
|
||||
|
||||
# Print persistence confirmation
|
||||
if args.persist:
|
||||
project_slug = safe_slug(args.project_name or args.query.upper())
|
||||
print("\n" + "=" * 60)
|
||||
print(f"✅ Design system persisted to design-system/{project_slug}/")
|
||||
print(f" 📄 design-system/{project_slug}/MASTER.md (Global Source of Truth)")
|
||||
if args.page:
|
||||
page_filename = safe_slug(args.page, 'page')
|
||||
print(f" 📄 design-system/{project_slug}/pages/{page_filename}.md (Page Overrides)")
|
||||
print("")
|
||||
print(f"📖 Usage: When building a page, check design-system/{project_slug}/pages/[page].md first.")
|
||||
print(f" If exists, its rules override MASTER.md. Otherwise, use MASTER.md.")
|
||||
print("=" * 60)
|
||||
|
||||
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:
|
||||
import json
|
||||
print(json.dumps(result, indent=2, ensure_ascii=False))
|
||||
print(json_module.dumps(result, indent=2, ensure_ascii=False))
|
||||
else:
|
||||
print(format_output(result))
|
||||
print(format_output(result, full=args.full))
|
||||
# Domain search
|
||||
else:
|
||||
result = search(args.query, args.domain, args.max_results)
|
||||
if args.json:
|
||||
import json
|
||||
print(json.dumps(result, indent=2, ensure_ascii=False))
|
||||
print(json_module.dumps(result, indent=2, ensure_ascii=False))
|
||||
else:
|
||||
print(format_output(result))
|
||||
print(format_output(result, full=args.full))
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
#!/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 BM25, detect_domain, search, search_stack, CSV_CONFIG, AVAILABLE_STACKS
|
||||
from design_system import generate_design_system, persist_design_system, DesignSystemGenerator
|
||||
|
||||
|
||||
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()
|
||||
@@ -0,0 +1,114 @@
|
||||
#!/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 pathlib import Path
|
||||
|
||||
from core import CSV_CONFIG, STACK_CONFIG, _STACK_COLS, DATA_DIR
|
||||
|
||||
# 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()
|
||||
Reference in New Issue
Block a user