@ranimontagna/agent-toolkit 0.3.6 → 0.3.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +2 -0
- package/README.md +1 -1
- package/package.json +1 -1
- package/skills/backend/fastify-best-practices/NOTICE.md +1 -1
- package/skills/backend/fastify-best-practices/rules/authentication.md +13 -0
- package/skills/backend/fastify-best-practices/rules/configuration.md +9 -0
- package/skills/backend/fastify-best-practices/rules/content-type.md +16 -0
- package/skills/backend/fastify-best-practices/rules/cors-security.md +18 -0
- package/skills/backend/fastify-best-practices/rules/database.md +12 -0
- package/skills/backend/fastify-best-practices/rules/decorators.md +14 -0
- package/skills/backend/fastify-best-practices/rules/deployment.md +16 -0
- package/skills/backend/fastify-best-practices/rules/error-handling.md +16 -0
- package/skills/backend/fastify-best-practices/rules/hooks.md +20 -0
- package/skills/backend/fastify-best-practices/rules/http-proxy.md +11 -0
- package/skills/backend/fastify-best-practices/rules/logging.md +18 -0
- package/skills/backend/fastify-best-practices/rules/performance.md +20 -0
- package/skills/backend/fastify-best-practices/rules/plugins.md +14 -0
- package/skills/backend/fastify-best-practices/rules/routes.md +18 -0
- package/skills/backend/fastify-best-practices/rules/schemas.md +20 -0
- package/skills/backend/fastify-best-practices/rules/serialization.md +19 -0
- package/skills/backend/fastify-best-practices/rules/testing.md +17 -0
- package/skills/backend/fastify-best-practices/rules/typescript.md +15 -0
- package/skills/backend/fastify-best-practices/rules/websockets.md +15 -0
- package/skills/backend/go/golang-patterns/NOTICE.md +1 -1
- package/skills/backend/go/golang-patterns/SKILL.md +2 -1
- package/skills/devops/docker-patterns/NOTICE.md +1 -1
- package/skills/devops/docker-patterns/SKILL.md +3 -3
- package/skills/frontend/design/revenue-centric-design/NOTICE.md +1 -1
- package/skills/frontend/design/revenue-centric-design/SKILL.md +6 -4
- package/skills/frontend/design/revenue-centric-design/assets/1985681720104903142__1.jpg +0 -0
- package/skills/frontend/design/revenue-centric-design/assets/1999584679247134761__1.jpg +0 -0
- package/skills/frontend/design/revenue-centric-design/assets/2000930776636940333__1.jpg +0 -0
- package/skills/frontend/design/revenue-centric-design/assets/2018693884449009956__3.jpg +0 -0
- package/skills/frontend/design/revenue-centric-design/assets/2081714430811730162__1.jpg +0 -0
- package/skills/frontend/design/revenue-centric-design/assets/2092692547244007852__q__1.jpg +0 -0
- package/skills/frontend/design/revenue-centric-design/assets/2092692547244007852__q__2.jpg +0 -0
- package/skills/frontend/design/revenue-centric-design/assets/2092692547244007852__q__3.jpg +0 -0
- package/skills/frontend/design/revenue-centric-design/references/checkout-and-forms.md +39 -0
- package/skills/frontend/design/revenue-centric-design/references/churn-and-retention.md +25 -0
- package/skills/frontend/design/revenue-centric-design/references/conversion-and-landing-pages.md +52 -1
- package/skills/frontend/design/revenue-centric-design/references/dashboards-and-data-viz.md +23 -0
- package/skills/frontend/design/revenue-centric-design/references/metrics-and-experimentation.md +13 -0
- package/skills/frontend/design/revenue-centric-design/references/onboarding-and-activation.md +7 -1
- package/skills/frontend/design/revenue-centric-design/references/pricing-and-monetization.md +35 -8
- package/skills/frontend/design/revenue-centric-design/references/product-strategy-and-features.md +37 -0
- package/skills/frontend/design/revenue-centric-design/references/revenue-centric-design.md +2 -2
- package/skills/frontend/design/ui-ux-pro-max/NOTICE.md +6 -4
- package/skills/frontend/design/ui-ux-pro-max/SKILL.md +103 -573
- package/skills/frontend/design/ui-ux-pro-max/data/app-interface.csv +29 -27
- package/skills/frontend/design/ui-ux-pro-max/data/catalog-summary.json +78 -0
- package/skills/frontend/design/ui-ux-pro-max/data/charts.csv +26 -26
- package/skills/frontend/design/ui-ux-pro-max/data/colors.csv +191 -160
- package/skills/frontend/design/ui-ux-pro-max/data/data-provenance.json +1348 -0
- package/skills/frontend/design/ui-ux-pro-max/data/google-font-licenses.json +20185 -0
- package/skills/frontend/design/ui-ux-pro-max/data/google-fonts.csv +1934 -1923
- package/skills/frontend/design/ui-ux-pro-max/data/icons.csv +106 -106
- package/skills/frontend/design/ui-ux-pro-max/data/landing.csv +35 -35
- package/skills/frontend/design/ui-ux-pro-max/data/motion.csv +18 -0
- package/skills/frontend/design/ui-ux-pro-max/data/phosphor-icons-upstream.json +32582 -0
- package/skills/frontend/design/ui-ux-pro-max/data/products.csv +192 -161
- package/skills/frontend/design/ui-ux-pro-max/data/react-performance.csv +37 -37
- package/skills/frontend/design/ui-ux-pro-max/data/stacks/angular.csv +51 -51
- package/skills/frontend/design/ui-ux-pro-max/data/stacks/astro.csv +54 -54
- package/skills/frontend/design/ui-ux-pro-max/data/stacks/avalonia.csv +57 -0
- package/skills/frontend/design/ui-ux-pro-max/data/stacks/flutter.csv +53 -53
- package/skills/frontend/design/ui-ux-pro-max/data/stacks/html-tailwind.csv +60 -56
- package/skills/frontend/design/ui-ux-pro-max/data/stacks/javafx.csv +76 -0
- package/skills/frontend/design/ui-ux-pro-max/data/stacks/jetpack-compose.csv +53 -53
- package/skills/frontend/design/ui-ux-pro-max/data/stacks/laravel.csv +51 -51
- package/skills/frontend/design/ui-ux-pro-max/data/stacks/nextjs.csv +62 -53
- package/skills/frontend/design/ui-ux-pro-max/data/stacks/nuxt-ui.csv +77 -51
- package/skills/frontend/design/ui-ux-pro-max/data/stacks/nuxtjs.csv +68 -59
- package/skills/frontend/design/ui-ux-pro-max/data/stacks/react-native.csv +52 -52
- package/skills/frontend/design/ui-ux-pro-max/data/stacks/react.csv +67 -54
- package/skills/frontend/design/ui-ux-pro-max/data/stacks/shadcn.csv +69 -61
- package/skills/frontend/design/ui-ux-pro-max/data/stacks/svelte.csv +56 -54
- package/skills/frontend/design/ui-ux-pro-max/data/stacks/swiftui.csv +51 -51
- package/skills/frontend/design/ui-ux-pro-max/data/stacks/threejs.csv +54 -54
- package/skills/frontend/design/ui-ux-pro-max/data/stacks/uno.csv +60 -0
- package/skills/frontend/design/ui-ux-pro-max/data/stacks/uwp.csv +56 -0
- package/skills/frontend/design/ui-ux-pro-max/data/stacks/vue.csv +50 -50
- package/skills/frontend/design/ui-ux-pro-max/data/stacks/winui.csv +60 -0
- package/skills/frontend/design/ui-ux-pro-max/data/stacks/wpf.csv +57 -0
- package/skills/frontend/design/ui-ux-pro-max/data/styles.csv +89 -85
- package/skills/frontend/design/ui-ux-pro-max/data/typography.csv +74 -73
- package/skills/frontend/design/ui-ux-pro-max/data/ui-reasoning.csv +193 -162
- package/skills/frontend/design/ui-ux-pro-max/data/ux-guidelines.csv +30 -10
- package/skills/frontend/design/ui-ux-pro-max/references/pro-rules.md +117 -0
- package/skills/frontend/design/ui-ux-pro-max/references/quick-reference.md +256 -0
- package/skills/frontend/design/ui-ux-pro-max/scripts/core.py +800 -69
- package/skills/frontend/design/ui-ux-pro-max/scripts/design_system.py +700 -180
- package/skills/frontend/design/ui-ux-pro-max/scripts/reasoning_contract.py +123 -0
- package/skills/frontend/design/ui-ux-pro-max/scripts/search.py +105 -42
- package/skills/frontend/design/ui-ux-pro-max/scripts/validate_data.py +1095 -0
- package/skills/frontend/react-native/react-native-unistyles-v3/NOTICE.md +1 -1
- package/skills/frontend/react-native/react-native-unistyles-v3/SKILL.md +33 -24
- package/skills/frontend/react-native/react-native-unistyles-v3/references/api-reference.md +135 -78
- package/skills/frontend/react-native/react-native-unistyles-v3/references/common-issues.md +48 -31
- package/skills/frontend/react-native/react-native-unistyles-v3/references/setup-guide.md +76 -34
- package/skills/frontend/react-native/react-native-unistyles-v3/references/styling-patterns.md +145 -52
- package/skills/frontend/react-native/react-native-unistyles-v3/references/third-party-integration.md +76 -27
- package/skills/media/brag-slim/NOTICE.md +3 -3
- package/skills/media/brag-slim/SKILL.md +4 -0
- package/skills/frontend/design/revenue-centric-design/assets/2018693884449009956__1.jpg +0 -0
- package/skills/frontend/design/ui-ux-pro-max/data/_sync_all.py +0 -414
- package/skills/frontend/design/ui-ux-pro-max/data/design.csv +0 -1776
- package/skills/frontend/design/ui-ux-pro-max/data/draft.csv +0 -1779
|
@@ -7,18 +7,31 @@ to generate comprehensive design system recommendations.
|
|
|
7
7
|
Usage:
|
|
8
8
|
from design_system import generate_design_system
|
|
9
9
|
result = generate_design_system("SaaS dashboard", "My Project")
|
|
10
|
-
|
|
10
|
+
print(result["text"])
|
|
11
|
+
|
|
11
12
|
# With persistence (Master + Overrides pattern)
|
|
12
|
-
result = generate_design_system("SaaS dashboard", "My Project", persist=True)
|
|
13
|
-
result
|
|
13
|
+
result = generate_design_system("SaaS dashboard", "My Project", persist=True, output_dir="/path/to/project")
|
|
14
|
+
result["persistence"] # {"status": "success"|"skipped_exists", "created_files": [...], ...}
|
|
15
|
+
result = generate_design_system("SaaS dashboard", "My Project", persist=True, page="dashboard", output_dir="/path/to/project")
|
|
14
16
|
"""
|
|
15
17
|
|
|
16
18
|
import csv
|
|
17
19
|
import json
|
|
18
20
|
import os
|
|
21
|
+
import re
|
|
22
|
+
import sys
|
|
23
|
+
import io
|
|
24
|
+
import tempfile
|
|
19
25
|
from datetime import datetime
|
|
20
26
|
from pathlib import Path
|
|
21
27
|
from core import search, DATA_DIR
|
|
28
|
+
from reasoning_contract import apply_decision_rules, parse_decision_rules
|
|
29
|
+
|
|
30
|
+
# Force UTF-8 for stdout/stderr to handle emojis/box-drawing chars on Windows (cp1252 default)
|
|
31
|
+
if sys.stdout.encoding and sys.stdout.encoding.lower() != 'utf-8':
|
|
32
|
+
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8')
|
|
33
|
+
if sys.stderr.encoding and sys.stderr.encoding.lower() != 'utf-8':
|
|
34
|
+
sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding='utf-8')
|
|
22
35
|
|
|
23
36
|
|
|
24
37
|
# ============ CONFIGURATION ============
|
|
@@ -27,11 +40,244 @@ REASONING_FILE = "ui-reasoning.csv"
|
|
|
27
40
|
SEARCH_CONFIG = {
|
|
28
41
|
"product": {"max_results": 1},
|
|
29
42
|
"style": {"max_results": 3},
|
|
30
|
-
"color": {"max_results":
|
|
43
|
+
"color": {"max_results": 5},
|
|
31
44
|
"landing": {"max_results": 2},
|
|
32
45
|
"typography": {"max_results": 2}
|
|
33
46
|
}
|
|
34
47
|
|
|
48
|
+
SEMANTIC_COLOR_ENTRIES = (
|
|
49
|
+
("Primary", "primary", "--color-primary"),
|
|
50
|
+
("On Primary", "on_primary", "--color-on-primary"),
|
|
51
|
+
("Secondary", "secondary", "--color-secondary"),
|
|
52
|
+
("On Secondary", "on_secondary", "--color-on-secondary"),
|
|
53
|
+
("Accent/CTA", "accent", "--color-accent"),
|
|
54
|
+
("On Accent/CTA", "on_accent", "--color-on-accent"),
|
|
55
|
+
("Background", "background", "--color-background"),
|
|
56
|
+
("Foreground", "foreground", "--color-foreground"),
|
|
57
|
+
("Card", "card", "--color-card"),
|
|
58
|
+
("Card Foreground", "card_foreground", "--color-card-foreground"),
|
|
59
|
+
("Muted", "muted", "--color-muted"),
|
|
60
|
+
("Muted Foreground", "muted_foreground", "--color-muted-foreground"),
|
|
61
|
+
("Border", "border", "--color-border"),
|
|
62
|
+
("Destructive", "destructive", "--color-destructive"),
|
|
63
|
+
("On Destructive", "on_destructive", "--color-on-destructive"),
|
|
64
|
+
("Ring", "ring", "--color-ring"),
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
# ============ DESIGN DIALS (1-10) ============
|
|
68
|
+
# Inspired by taste-skill's DESIGN_VARIANCE / MOTION_INTENSITY / VISUAL_DENSITY
|
|
69
|
+
# knobs: three optional 1-10 sliders that bias the existing query-based search
|
|
70
|
+
# instead of replacing it. Each dial buckets into a low/mid/high tier.
|
|
71
|
+
DIAL_TIERS = {
|
|
72
|
+
"variance": [
|
|
73
|
+
(1, 3, {"label": "Centered / Minimal", "style_keywords": ["Minimalism", "Exaggerated Minimalism", "centered", "symmetric", "grid-based"]}),
|
|
74
|
+
(4, 7, {"label": "Balanced / Modern", "style_keywords": ["modern", "structured", "balanced"]}),
|
|
75
|
+
(8, 10, {"label": "Bold / Asymmetric", "style_keywords": ["Brutalism", "Bento Grids", "asymmetric", "experimental"]}),
|
|
76
|
+
],
|
|
77
|
+
"motion": [
|
|
78
|
+
(1, 3, {"label": "Subtle", "tier": "Subtle"}),
|
|
79
|
+
(4, 7, {"label": "Standard", "tier": "Standard"}),
|
|
80
|
+
(8, 10, {"label": "Complex", "tier": "Complex"}),
|
|
81
|
+
],
|
|
82
|
+
"density": [
|
|
83
|
+
(1, 3, {"label": "Spacious", "spacing": {"xs": "4px", "sm": "8px", "md": "24px", "lg": "32px", "xl": "48px", "2xl": "64px", "3xl": "96px"}}),
|
|
84
|
+
(4, 7, {"label": "Standard", "spacing": {"xs": "4px", "sm": "8px", "md": "16px", "lg": "24px", "xl": "32px", "2xl": "48px", "3xl": "64px"}}),
|
|
85
|
+
(8, 10, {"label": "Dense / Dashboard", "spacing": {"xs": "2px", "sm": "4px", "md": "8px", "lg": "12px", "xl": "16px", "2xl": "24px", "3xl": "32px"}}),
|
|
86
|
+
],
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _resolve_dial(dial_name: str, value) -> dict:
|
|
91
|
+
"""Bucket a 1-10 dial value into its tier config. Returns None if value is None."""
|
|
92
|
+
if value is None:
|
|
93
|
+
return None
|
|
94
|
+
value = max(1, min(10, int(value)))
|
|
95
|
+
for lo, hi, info in DIAL_TIERS[dial_name]:
|
|
96
|
+
if lo <= value <= hi:
|
|
97
|
+
return {**info, "value": value}
|
|
98
|
+
return None
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
# ============ COLOR MODE RESOLUTION ============
|
|
102
|
+
# Style, palette and anti-patterns are resolved from separate CSVs. Without a
|
|
103
|
+
# shared notion of "which mode did we land on", a dark-primary style can be
|
|
104
|
+
# paired with a light palette and a "don't use dark mode" anti-pattern.
|
|
105
|
+
|
|
106
|
+
# Phrases in styles.csv "Light Mode ✓" / "Dark Mode ✓" that mark a style as
|
|
107
|
+
# dark-first rather than merely dark-capable ("✓ Full" means both work).
|
|
108
|
+
_DARK_PRIMARY_MARKERS = (
|
|
109
|
+
"dark mode primary", "dark primary", "dark-only", "dark only",
|
|
110
|
+
"dark preferred", "dark focused", "dark-first", "dark rich",
|
|
111
|
+
"light mode only as exception",
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
# Query phrases that are an explicit request for a dark theme.
|
|
115
|
+
_DARK_QUERY_MARKERS = (
|
|
116
|
+
"dark mode", "dark theme", "dark ui", "dark-mode", "darkmode",
|
|
117
|
+
"night mode", "midnight", "oled",
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
# Anti-pattern clauses that contradict a resolved dark mode.
|
|
121
|
+
_DARK_ANTI_PATTERN_MARKERS = ("dark mode", "dark modes", "dark theme")
|
|
122
|
+
|
|
123
|
+
# Relative luminance below which a Background hex counts as a dark surface.
|
|
124
|
+
# #1F2937 (the lightest dark background in colors.csv) sits at ~0.026 and
|
|
125
|
+
# #E8ECF1 (the darkest light background) at ~0.79, so the gap is wide.
|
|
126
|
+
_DARK_BACKGROUND_MAX_LUMINANCE = 0.18
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _relative_luminance(hex_color: str):
|
|
130
|
+
"""WCAG relative luminance of a #RRGGBB string, or None if unparseable."""
|
|
131
|
+
if not hex_color:
|
|
132
|
+
return None
|
|
133
|
+
value = hex_color.strip().lstrip("#")
|
|
134
|
+
if len(value) == 3:
|
|
135
|
+
value = "".join(c * 2 for c in value)
|
|
136
|
+
if len(value) != 6:
|
|
137
|
+
return None
|
|
138
|
+
try:
|
|
139
|
+
channels = [int(value[i:i + 2], 16) / 255 for i in (0, 2, 4)]
|
|
140
|
+
except ValueError:
|
|
141
|
+
return None
|
|
142
|
+
linear = [c / 12.92 if c <= 0.04045 else ((c + 0.055) / 1.055) ** 2.4
|
|
143
|
+
for c in channels]
|
|
144
|
+
return 0.2126 * linear[0] + 0.7152 * linear[1] + 0.0722 * linear[2]
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _palette_is_dark(palette: dict) -> bool:
|
|
148
|
+
"""True when a colors.csv row's Background is a dark surface."""
|
|
149
|
+
luminance = _relative_luminance((palette or {}).get("Background", ""))
|
|
150
|
+
return luminance is not None and luminance < _DARK_BACKGROUND_MAX_LUMINANCE
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _contrast_ratio(first: str, second: str):
|
|
154
|
+
"""WCAG contrast ratio for two hex colors, or None if either is invalid."""
|
|
155
|
+
first_luminance = _relative_luminance(first)
|
|
156
|
+
second_luminance = _relative_luminance(second)
|
|
157
|
+
if first_luminance is None or second_luminance is None:
|
|
158
|
+
return None
|
|
159
|
+
lighter = max(first_luminance, second_luminance)
|
|
160
|
+
darker = min(first_luminance, second_luminance)
|
|
161
|
+
return (lighter + 0.05) / (darker + 0.05)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _style_is_dark_primary(style: dict) -> bool:
|
|
165
|
+
"""True when a styles.csv row describes itself as dark-first."""
|
|
166
|
+
if not style:
|
|
167
|
+
return False
|
|
168
|
+
preferred_mode = style.get("Preferred Mode", "").strip().lower()
|
|
169
|
+
if preferred_mode in {"dark", "light"}:
|
|
170
|
+
return preferred_mode == "dark"
|
|
171
|
+
if (style.get("Light Mode ✓") == "not-recommended"
|
|
172
|
+
and style.get("Dark Mode ✓") == "supported"):
|
|
173
|
+
return True
|
|
174
|
+
declared = "{} {}".format(
|
|
175
|
+
style.get("Light Mode ✓", ""), style.get("Dark Mode ✓", "")
|
|
176
|
+
).lower()
|
|
177
|
+
return any(marker in declared for marker in _DARK_PRIMARY_MARKERS)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _query_wants_dark(query: str) -> bool:
|
|
181
|
+
"""True when the query explicitly asks for a dark theme."""
|
|
182
|
+
lowered = (query or "").lower()
|
|
183
|
+
return any(marker in lowered for marker in _DARK_QUERY_MARKERS)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _resolve_color_mode(query: str, style: dict) -> str:
|
|
187
|
+
"""Resolve the mode the rest of the output has to agree with."""
|
|
188
|
+
if _query_wants_dark(query) or _style_is_dark_primary(style):
|
|
189
|
+
return "dark"
|
|
190
|
+
return "light"
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _derive_dark_palette(palette: dict) -> dict:
|
|
194
|
+
"""Keep product brand tokens while deriving accessible dark surfaces."""
|
|
195
|
+
derived = dict(palette)
|
|
196
|
+
background = "#0F172A"
|
|
197
|
+
ring_candidates = (
|
|
198
|
+
palette.get("Ring"), palette.get("Accent"), palette.get("Primary"),
|
|
199
|
+
"#60A5FA",
|
|
200
|
+
)
|
|
201
|
+
ring = next(
|
|
202
|
+
(candidate for candidate in ring_candidates
|
|
203
|
+
if (_contrast_ratio(candidate, background) or 0) >= 3),
|
|
204
|
+
"#60A5FA",
|
|
205
|
+
)
|
|
206
|
+
derived.update({
|
|
207
|
+
"Background": background,
|
|
208
|
+
"Foreground": "#F8FAFC",
|
|
209
|
+
"Card": "#111827",
|
|
210
|
+
"Card Foreground": "#F8FAFC",
|
|
211
|
+
"Muted": "#1E293B",
|
|
212
|
+
"Muted Foreground": "#CBD5E1",
|
|
213
|
+
"Border": "#334155",
|
|
214
|
+
"Ring": ring,
|
|
215
|
+
"_mode_derivation": "derived-dark",
|
|
216
|
+
})
|
|
217
|
+
return derived
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _select_palette_for_mode(palettes: list, mode: str,
|
|
221
|
+
category: str = None) -> dict:
|
|
222
|
+
"""Pick the highest-ranked palette matching the resolved mode.
|
|
223
|
+
|
|
224
|
+
Only the dark case filters. Light is left on the existing "top hit wins"
|
|
225
|
+
behaviour so queries that never mention a mode keep their current palette.
|
|
226
|
+
Falls back to the top hit when the data has no matching ramp.
|
|
227
|
+
"""
|
|
228
|
+
if not palettes:
|
|
229
|
+
return {}
|
|
230
|
+
category_palette = next(
|
|
231
|
+
(palette for palette in palettes
|
|
232
|
+
if palette.get("Product Type") == category),
|
|
233
|
+
None,
|
|
234
|
+
)
|
|
235
|
+
if category_palette:
|
|
236
|
+
if mode == "dark" and not _palette_is_dark(category_palette):
|
|
237
|
+
return _derive_dark_palette(category_palette)
|
|
238
|
+
return category_palette
|
|
239
|
+
if mode == "dark":
|
|
240
|
+
for palette in palettes:
|
|
241
|
+
if _palette_is_dark(palette):
|
|
242
|
+
return palette
|
|
243
|
+
return palettes[0]
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _filter_anti_patterns_for_mode(anti_patterns: str, mode: str) -> str:
|
|
247
|
+
"""Drop "avoid dark mode" advice once dark mode is the resolved answer."""
|
|
248
|
+
if mode != "dark" or not anti_patterns:
|
|
249
|
+
return anti_patterns
|
|
250
|
+
kept = [
|
|
251
|
+
clause for clause in anti_patterns.split("+")
|
|
252
|
+
if not any(marker in clause.lower() for marker in _DARK_ANTI_PATTERN_MARKERS)
|
|
253
|
+
]
|
|
254
|
+
return " + ".join(clause.strip() for clause in kept if clause.strip())
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def _mode_support_labels(style: dict, anti_patterns: str):
|
|
258
|
+
"""Light/dark support labels that agree with the product's anti-patterns.
|
|
259
|
+
|
|
260
|
+
A light result keeps its "avoid dark mode" clause, so a dark-capable style
|
|
261
|
+
must not read as a plain "Dark supported" next to it.
|
|
262
|
+
"""
|
|
263
|
+
light = style.get("light_mode", "")
|
|
264
|
+
dark = style.get("dark_mode", "")
|
|
265
|
+
avoids_dark = any(
|
|
266
|
+
marker in (anti_patterns or "").lower() for marker in _DARK_ANTI_PATTERN_MARKERS
|
|
267
|
+
)
|
|
268
|
+
if dark and dark != "not-recommended" and avoids_dark:
|
|
269
|
+
dark = f"{dark}, but avoid for this product"
|
|
270
|
+
return light, dark
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def _button_outline_text_color(colors: dict) -> str:
|
|
274
|
+
"""Primary for outline-button text when it is readable, else the foreground."""
|
|
275
|
+
primary = colors.get("primary", "#2563EB")
|
|
276
|
+
background = colors.get("background", "#FFFFFF")
|
|
277
|
+
if (_contrast_ratio(primary, background) or 0) >= 4.5:
|
|
278
|
+
return primary
|
|
279
|
+
return colors.get("foreground") or primary
|
|
280
|
+
|
|
35
281
|
|
|
36
282
|
# ============ DESIGN SYSTEM GENERATOR ============
|
|
37
283
|
class DesignSystemGenerator:
|
|
@@ -39,6 +285,9 @@ class DesignSystemGenerator:
|
|
|
39
285
|
|
|
40
286
|
def __init__(self):
|
|
41
287
|
self.reasoning_data = self._load_reasoning()
|
|
288
|
+
self.style_data = self._load_styles()
|
|
289
|
+
self.style_lookup = self._build_style_lookup(self.style_data)
|
|
290
|
+
self.landing_lookup = self._load_landing_patterns()
|
|
42
291
|
|
|
43
292
|
def _load_reasoning(self) -> list:
|
|
44
293
|
"""Load reasoning rules from CSV."""
|
|
@@ -48,44 +297,97 @@ class DesignSystemGenerator:
|
|
|
48
297
|
with open(filepath, 'r', encoding='utf-8') as f:
|
|
49
298
|
return list(csv.DictReader(f))
|
|
50
299
|
|
|
51
|
-
def
|
|
300
|
+
def _load_styles(self) -> list:
|
|
301
|
+
filepath = DATA_DIR / "styles.csv"
|
|
302
|
+
if not filepath.exists():
|
|
303
|
+
return []
|
|
304
|
+
with open(filepath, 'r', encoding='utf-8') as f:
|
|
305
|
+
return list(csv.DictReader(f))
|
|
306
|
+
|
|
307
|
+
def _load_landing_patterns(self) -> dict:
|
|
308
|
+
filepath = DATA_DIR / "landing.csv"
|
|
309
|
+
if not filepath.exists():
|
|
310
|
+
return {}
|
|
311
|
+
with open(filepath, 'r', encoding='utf-8') as f:
|
|
312
|
+
lookup = {}
|
|
313
|
+
for row in csv.DictReader(f):
|
|
314
|
+
identities = [row.get("Pattern ID", ""), row.get("Pattern Name", "")]
|
|
315
|
+
identities.extend(row.get("Aliases", "").split("|"))
|
|
316
|
+
for identity in identities:
|
|
317
|
+
if identity.strip():
|
|
318
|
+
lookup[identity.strip().casefold()] = row
|
|
319
|
+
return lookup
|
|
320
|
+
|
|
321
|
+
@staticmethod
|
|
322
|
+
def _build_style_lookup(styles: list) -> dict:
|
|
323
|
+
lookup = {}
|
|
324
|
+
for style in styles:
|
|
325
|
+
keys = [style.get("Style ID", ""), style.get("Style Category", "")]
|
|
326
|
+
keys.extend(style.get("Aliases", "").split("|"))
|
|
327
|
+
for key in keys:
|
|
328
|
+
if key.strip():
|
|
329
|
+
lookup[key.strip().casefold()] = style
|
|
330
|
+
return lookup
|
|
331
|
+
|
|
332
|
+
def _resolve_style(self, reference: str) -> dict:
|
|
333
|
+
style = self.style_lookup.get(str(reference or "").strip().casefold(), {})
|
|
334
|
+
seen = set()
|
|
335
|
+
while style and style.get("Status", "active") == "deprecated":
|
|
336
|
+
style_id = style.get("Style ID", "")
|
|
337
|
+
parent_id = style.get("Parent Style ID", "")
|
|
338
|
+
if not parent_id or style_id in seen:
|
|
339
|
+
return {}
|
|
340
|
+
seen.add(style_id)
|
|
341
|
+
style = self.style_lookup.get(parent_id.casefold(), {})
|
|
342
|
+
return style
|
|
343
|
+
|
|
344
|
+
def _multi_domain_search(self, query: str, category: str,
|
|
345
|
+
reasoning: dict, style_priority: list = None) -> dict:
|
|
52
346
|
"""Execute searches across multiple domains."""
|
|
53
347
|
results = {}
|
|
348
|
+
constraints = " ".join(
|
|
349
|
+
item.replace("-", " ")
|
|
350
|
+
for item in reasoning.get("constraints", [])
|
|
351
|
+
)
|
|
352
|
+
resolved_query = " ".join(
|
|
353
|
+
part for part in (query, category, constraints) if part
|
|
354
|
+
)
|
|
54
355
|
for domain, config in SEARCH_CONFIG.items():
|
|
55
356
|
if domain == "style" and style_priority:
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
357
|
+
priority_query = " ".join(style_priority[:2])
|
|
358
|
+
results[domain] = search(
|
|
359
|
+
f"{resolved_query} {priority_query}", domain, config["max_results"])
|
|
360
|
+
elif domain == "color":
|
|
361
|
+
results[domain] = search(
|
|
362
|
+
f"{reasoning.get('color_mood', '')} {resolved_query}",
|
|
363
|
+
domain, config["max_results"])
|
|
364
|
+
elif domain == "landing":
|
|
365
|
+
# The reasoning pattern is the landing query contract. Mixing the
|
|
366
|
+
# product prompt into it can push token coverage below the
|
|
367
|
+
# abstention threshold even when the pattern names an exact row.
|
|
368
|
+
pattern = reasoning.get("pattern", "")
|
|
369
|
+
landing_query = pattern if pattern.casefold() in self.landing_lookup else (
|
|
370
|
+
f"{pattern} {resolved_query}"
|
|
371
|
+
)
|
|
372
|
+
results[domain] = search(
|
|
373
|
+
landing_query or query, domain, config["max_results"])
|
|
374
|
+
elif domain == "typography":
|
|
375
|
+
results[domain] = search(
|
|
376
|
+
f"{reasoning.get('typography_mood', '')} {resolved_query}",
|
|
377
|
+
domain, config["max_results"])
|
|
60
378
|
else:
|
|
61
379
|
results[domain] = search(query, domain, config["max_results"])
|
|
62
380
|
return results
|
|
63
381
|
|
|
64
382
|
def _find_reasoning_rule(self, category: str) -> dict:
|
|
65
383
|
"""Find matching reasoning rule for a category."""
|
|
66
|
-
category_lower = category.
|
|
67
|
-
|
|
68
|
-
# Try exact match first
|
|
69
|
-
for rule in self.reasoning_data:
|
|
70
|
-
if rule.get("UI_Category", "").lower() == category_lower:
|
|
71
|
-
return rule
|
|
72
|
-
|
|
73
|
-
# Try partial match
|
|
74
|
-
for rule in self.reasoning_data:
|
|
75
|
-
ui_cat = rule.get("UI_Category", "").lower()
|
|
76
|
-
if ui_cat in category_lower or category_lower in ui_cat:
|
|
77
|
-
return rule
|
|
78
|
-
|
|
79
|
-
# Try keyword match
|
|
384
|
+
category_lower = category.strip().casefold()
|
|
80
385
|
for rule in self.reasoning_data:
|
|
81
|
-
|
|
82
|
-
keywords = ui_cat.replace("/", " ").replace("-", " ").split()
|
|
83
|
-
if any(kw in category_lower for kw in keywords):
|
|
386
|
+
if rule.get("UI_Category", "").strip().casefold() == category_lower:
|
|
84
387
|
return rule
|
|
85
|
-
|
|
86
388
|
return {}
|
|
87
389
|
|
|
88
|
-
def _apply_reasoning(self, category: str,
|
|
390
|
+
def _apply_reasoning(self, category: str, query: str) -> dict:
|
|
89
391
|
"""Apply reasoning rules to search results."""
|
|
90
392
|
rule = self._find_reasoning_rule(category)
|
|
91
393
|
|
|
@@ -98,24 +400,33 @@ class DesignSystemGenerator:
|
|
|
98
400
|
"key_effects": "Subtle hover transitions",
|
|
99
401
|
"anti_patterns": "",
|
|
100
402
|
"decision_rules": {},
|
|
403
|
+
"activated_rules": [],
|
|
404
|
+
"constraints": [],
|
|
405
|
+
"preferred_mode": None,
|
|
406
|
+
"is_default": True,
|
|
101
407
|
"severity": "MEDIUM"
|
|
102
408
|
}
|
|
103
409
|
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
410
|
+
decision_rules = parse_decision_rules(rule.get("Decision_Rules", "{}"))
|
|
411
|
+
applied = apply_decision_rules(decision_rules, query)
|
|
412
|
+
style_priority = [s.strip() for s in rule.get("Style_Priority", "").split("+")]
|
|
413
|
+
applied_style_names = [
|
|
414
|
+
self._resolve_style(style_id).get("Style Category", style_id)
|
|
415
|
+
for style_id in applied["style_ids"]
|
|
416
|
+
]
|
|
110
417
|
|
|
111
418
|
return {
|
|
112
|
-
"pattern": rule.get("Recommended_Pattern", ""),
|
|
113
|
-
"style_priority":
|
|
419
|
+
"pattern": applied["pattern"] or rule.get("Recommended_Pattern", ""),
|
|
420
|
+
"style_priority": applied_style_names + style_priority,
|
|
114
421
|
"color_mood": rule.get("Color_Mood", ""),
|
|
115
422
|
"typography_mood": rule.get("Typography_Mood", ""),
|
|
116
423
|
"key_effects": rule.get("Key_Effects", ""),
|
|
117
424
|
"anti_patterns": rule.get("Anti_Patterns", ""),
|
|
118
425
|
"decision_rules": decision_rules,
|
|
426
|
+
"activated_rules": applied["activated"],
|
|
427
|
+
"constraints": applied["constraints"],
|
|
428
|
+
"preferred_mode": applied["mode"],
|
|
429
|
+
"is_default": False,
|
|
119
430
|
"severity": rule.get("Severity", "MEDIUM")
|
|
120
431
|
}
|
|
121
432
|
|
|
@@ -127,13 +438,13 @@ class DesignSystemGenerator:
|
|
|
127
438
|
if not priority_keywords:
|
|
128
439
|
return results[0]
|
|
129
440
|
|
|
130
|
-
#
|
|
441
|
+
# Canonical reasoning has authority over lexical candidates. Returning
|
|
442
|
+
# the resolved row directly prevents a platform variant in BM25 top-3
|
|
443
|
+
# from displacing the explicit family recommendation.
|
|
131
444
|
for priority in priority_keywords:
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
if priority_lower in style_name or style_name in priority_lower:
|
|
136
|
-
return result
|
|
445
|
+
resolved = self._resolve_style(priority)
|
|
446
|
+
if resolved and resolved.get("Status", "active") != "deprecated":
|
|
447
|
+
return dict(resolved)
|
|
137
448
|
|
|
138
449
|
# Second: score by keyword match in all fields
|
|
139
450
|
scored = []
|
|
@@ -141,15 +452,15 @@ class DesignSystemGenerator:
|
|
|
141
452
|
result_str = str(result).lower()
|
|
142
453
|
score = 0
|
|
143
454
|
for kw in priority_keywords:
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
455
|
+
kw_tokens = set(re.findall(r"[a-z0-9]+", kw.lower()))
|
|
456
|
+
name_tokens = set(re.findall(
|
|
457
|
+
r"[a-z0-9]+", result.get("Style Category", "").lower()))
|
|
458
|
+
if kw_tokens and kw_tokens <= name_tokens:
|
|
147
459
|
score += 10
|
|
148
|
-
|
|
149
|
-
|
|
460
|
+
elif kw_tokens & set(re.findall(
|
|
461
|
+
r"[a-z0-9]+", result.get("Keywords", "").lower())):
|
|
150
462
|
score += 3
|
|
151
|
-
|
|
152
|
-
elif kw_lower in result_str:
|
|
463
|
+
elif any(token in result_str for token in kw_tokens):
|
|
153
464
|
score += 1
|
|
154
465
|
scored.append((score, result))
|
|
155
466
|
|
|
@@ -160,8 +471,18 @@ class DesignSystemGenerator:
|
|
|
160
471
|
"""Extract results list from search result dict."""
|
|
161
472
|
return search_result.get("results", [])
|
|
162
473
|
|
|
163
|
-
def generate(self, query: str, project_name: str = None
|
|
164
|
-
|
|
474
|
+
def generate(self, query: str, project_name: str = None,
|
|
475
|
+
variance: int = None, motion: int = None, density: int = None) -> dict:
|
|
476
|
+
"""Generate complete design system recommendation.
|
|
477
|
+
|
|
478
|
+
variance/motion/density are optional 1-10 dials (see DIAL_TIERS) that bias
|
|
479
|
+
style selection, pull in a matching motion.csv snippet, and override the
|
|
480
|
+
spacing scale, without changing behavior when left unset.
|
|
481
|
+
"""
|
|
482
|
+
variance_info = _resolve_dial("variance", variance)
|
|
483
|
+
motion_info = _resolve_dial("motion", motion)
|
|
484
|
+
density_info = _resolve_dial("density", density)
|
|
485
|
+
|
|
165
486
|
# Step 1: First search product to get category
|
|
166
487
|
product_result = search(query, "product", 1)
|
|
167
488
|
product_results = product_result.get("results", [])
|
|
@@ -170,11 +491,18 @@ class DesignSystemGenerator:
|
|
|
170
491
|
category = product_results[0].get("Product Type", "General")
|
|
171
492
|
|
|
172
493
|
# Step 2: Get reasoning rules for this category
|
|
173
|
-
reasoning = self._apply_reasoning(category,
|
|
494
|
+
reasoning = self._apply_reasoning(category, query)
|
|
174
495
|
style_priority = reasoning.get("style_priority", [])
|
|
175
496
|
|
|
497
|
+
# DESIGN_VARIANCE dial: bias style retrieval/selection toward
|
|
498
|
+
# centered-minimal (low) or bold-asymmetric (high) keywords.
|
|
499
|
+
effective_style_priority = style_priority
|
|
500
|
+
if variance_info:
|
|
501
|
+
effective_style_priority = variance_info["style_keywords"] + style_priority
|
|
502
|
+
|
|
176
503
|
# Step 3: Multi-domain search with style priority hints
|
|
177
|
-
search_results = self._multi_domain_search(
|
|
504
|
+
search_results = self._multi_domain_search(
|
|
505
|
+
query, category, reasoning, effective_style_priority)
|
|
178
506
|
search_results["product"] = product_result # Reuse product search
|
|
179
507
|
|
|
180
508
|
# Step 4: Select best matches from each domain using priority
|
|
@@ -183,10 +511,31 @@ class DesignSystemGenerator:
|
|
|
183
511
|
typography_results = self._extract_results(search_results.get("typography", {}))
|
|
184
512
|
landing_results = self._extract_results(search_results.get("landing", {}))
|
|
185
513
|
|
|
186
|
-
best_style = self._select_best_match(style_results,
|
|
187
|
-
|
|
514
|
+
best_style = self._select_best_match(style_results, effective_style_priority)
|
|
515
|
+
# Resolve the mode from the style + query first, then pick a palette that
|
|
516
|
+
# agrees with it. Ranking colors independently is what let a dark-primary
|
|
517
|
+
# style ship with a light background.
|
|
518
|
+
color_mode = reasoning.get("preferred_mode") or _resolve_color_mode(query, best_style)
|
|
519
|
+
best_color = _select_palette_for_mode(color_results, color_mode, category)
|
|
188
520
|
best_typography = typography_results[0] if typography_results else {}
|
|
189
|
-
best_landing =
|
|
521
|
+
best_landing = next(
|
|
522
|
+
(row for row in landing_results
|
|
523
|
+
if row.get("Pattern Name") == reasoning.get("pattern")),
|
|
524
|
+
landing_results[0] if landing_results else {},
|
|
525
|
+
)
|
|
526
|
+
|
|
527
|
+
# MOTION_INTENSITY dial: pull a matching GSAP skeleton from motion.csv
|
|
528
|
+
# (domain key is "gsap", not "motion" - PR #296 already owns the "motion"
|
|
529
|
+
# domain for Emil Kowalski's motion-design principles, motion-principles.csv).
|
|
530
|
+
motion_snippet = {}
|
|
531
|
+
if motion_info:
|
|
532
|
+
motion_result = search(f"{query} {motion_info['tier']}", "gsap", 5)
|
|
533
|
+
motion_matches = motion_result.get("results", [])
|
|
534
|
+
tiered = [m for m in motion_matches if m.get("Intensity Tier") == motion_info["tier"]]
|
|
535
|
+
if tiered:
|
|
536
|
+
motion_snippet = tiered[0]
|
|
537
|
+
elif motion_matches:
|
|
538
|
+
motion_snippet = motion_matches[0]
|
|
190
539
|
|
|
191
540
|
# Step 5: Build final recommendation
|
|
192
541
|
# Combine effects from both reasoning and style search
|
|
@@ -205,6 +554,7 @@ class DesignSystemGenerator:
|
|
|
205
554
|
"conversion": best_landing.get("Conversion Optimization", "")
|
|
206
555
|
},
|
|
207
556
|
"style": {
|
|
557
|
+
"id": best_style.get("Style ID", "minimalism-and-swiss-style"),
|
|
208
558
|
"name": best_style.get("Style Category", "Minimalism"),
|
|
209
559
|
"type": best_style.get("Type", "General"),
|
|
210
560
|
"effects": style_effects,
|
|
@@ -219,17 +569,24 @@ class DesignSystemGenerator:
|
|
|
219
569
|
"primary": best_color.get("Primary", "#2563EB"),
|
|
220
570
|
"on_primary": best_color.get("On Primary", ""),
|
|
221
571
|
"secondary": best_color.get("Secondary", "#3B82F6"),
|
|
572
|
+
"on_secondary": best_color.get("On Secondary", ""),
|
|
222
573
|
"accent": best_color.get("Accent", "#F97316"),
|
|
574
|
+
"on_accent": best_color.get("On Accent", ""),
|
|
223
575
|
"background": best_color.get("Background", "#F8FAFC"),
|
|
224
576
|
"foreground": best_color.get("Foreground", "#1E293B"),
|
|
577
|
+
"card": best_color.get("Card", ""),
|
|
578
|
+
"card_foreground": best_color.get("Card Foreground", ""),
|
|
225
579
|
"muted": best_color.get("Muted", ""),
|
|
580
|
+
"muted_foreground": best_color.get("Muted Foreground", ""),
|
|
226
581
|
"border": best_color.get("Border", ""),
|
|
227
582
|
"destructive": best_color.get("Destructive", ""),
|
|
583
|
+
"on_destructive": best_color.get("On Destructive", ""),
|
|
228
584
|
"ring": best_color.get("Ring", ""),
|
|
229
585
|
"notes": best_color.get("Notes", ""),
|
|
230
586
|
# Keep legacy keys for backward compat in MASTER.md
|
|
231
587
|
"cta": best_color.get("Accent", "#F97316"),
|
|
232
588
|
"text": best_color.get("Foreground", "#1E293B"),
|
|
589
|
+
"on_cta": best_color.get("On Accent", ""),
|
|
233
590
|
},
|
|
234
591
|
"typography": {
|
|
235
592
|
"heading": best_typography.get("Heading Font", "Inter"),
|
|
@@ -240,9 +597,35 @@ class DesignSystemGenerator:
|
|
|
240
597
|
"css_import": best_typography.get("CSS Import", "")
|
|
241
598
|
},
|
|
242
599
|
"key_effects": combined_effects,
|
|
243
|
-
"anti_patterns":
|
|
600
|
+
"anti_patterns": _filter_anti_patterns_for_mode(
|
|
601
|
+
reasoning.get("anti_patterns", ""), color_mode
|
|
602
|
+
),
|
|
244
603
|
"decision_rules": reasoning.get("decision_rules", {}),
|
|
245
|
-
"
|
|
604
|
+
"activated_rules": reasoning.get("activated_rules", []),
|
|
605
|
+
"constraints": reasoning.get("constraints", []),
|
|
606
|
+
"reasoning_default": reasoning.get("is_default", False),
|
|
607
|
+
"source_identities": {
|
|
608
|
+
"product": category if product_results else None,
|
|
609
|
+
"reasoning": category if not reasoning.get("is_default") else None,
|
|
610
|
+
"style": best_style.get("Style ID") or best_style.get("Style Category"),
|
|
611
|
+
"color": best_color.get("Product Type"),
|
|
612
|
+
"typography": best_typography.get("Font Pairing Name"),
|
|
613
|
+
"landing": best_landing.get("Pattern Name"),
|
|
614
|
+
},
|
|
615
|
+
"source_derivations": {
|
|
616
|
+
"color_mode": best_color.get("_mode_derivation"),
|
|
617
|
+
},
|
|
618
|
+
"severity": reasoning.get("severity", "MEDIUM"),
|
|
619
|
+
"dials": {
|
|
620
|
+
"variance": variance_info["value"] if variance_info else None,
|
|
621
|
+
"variance_label": variance_info["label"] if variance_info else None,
|
|
622
|
+
"motion": motion_info["value"] if motion_info else None,
|
|
623
|
+
"motion_label": motion_info["label"] if motion_info else None,
|
|
624
|
+
"density": density_info["value"] if density_info else None,
|
|
625
|
+
"density_label": density_info["label"] if density_info else None,
|
|
626
|
+
},
|
|
627
|
+
"motion_snippet": motion_snippet,
|
|
628
|
+
"spacing_scale": density_info["spacing"] if density_info else None,
|
|
246
629
|
}
|
|
247
630
|
|
|
248
631
|
|
|
@@ -288,12 +671,17 @@ def format_ascii_box(design_system: dict) -> str:
|
|
|
288
671
|
typography = design_system.get("typography", {})
|
|
289
672
|
effects = design_system.get("key_effects", "")
|
|
290
673
|
anti_patterns = design_system.get("anti_patterns", "")
|
|
674
|
+
dials = design_system.get("dials", {})
|
|
675
|
+
motion_snippet = design_system.get("motion_snippet", {})
|
|
291
676
|
|
|
292
677
|
def wrap_text(text: str, prefix: str, width: int) -> list:
|
|
293
|
-
"""Wrap long text into multiple lines."""
|
|
678
|
+
"""Wrap long text into multiple lines, splitting tokens (URLs) wider than a line."""
|
|
294
679
|
if not text:
|
|
295
680
|
return []
|
|
296
|
-
|
|
681
|
+
room = width - 2 - len(prefix)
|
|
682
|
+
words = []
|
|
683
|
+
for word in text.split():
|
|
684
|
+
words.extend(word[i:i + room] for i in range(0, len(word), room))
|
|
297
685
|
lines = []
|
|
298
686
|
current_line = prefix
|
|
299
687
|
for word in words:
|
|
@@ -307,8 +695,12 @@ def format_ascii_box(design_system: dict) -> str:
|
|
|
307
695
|
lines.append(current_line)
|
|
308
696
|
return lines
|
|
309
697
|
|
|
698
|
+
def add_wrapped(text: str, prefix: str = "│ ") -> None:
|
|
699
|
+
for line in wrap_text(text, prefix, BOX_WIDTH):
|
|
700
|
+
lines.append(line.ljust(BOX_WIDTH) + "│")
|
|
701
|
+
|
|
310
702
|
# Build sections from pattern
|
|
311
|
-
sections = pattern.get("sections", "").split(">")
|
|
703
|
+
sections = pattern.get("sections", "").split(" > ")
|
|
312
704
|
sections = [s.strip() for s in sections if s.strip()]
|
|
313
705
|
|
|
314
706
|
# Build output lines
|
|
@@ -321,87 +713,90 @@ def format_ascii_box(design_system: dict) -> str:
|
|
|
321
713
|
lines.append("╚" + "═" * w + "╝")
|
|
322
714
|
lines.append("┌" + "─" * w + "┐")
|
|
323
715
|
|
|
716
|
+
# Design Dials section (only if at least one dial was set)
|
|
717
|
+
if any(dials.get(k) is not None for k in ("variance", "motion", "density")):
|
|
718
|
+
lines.append(section_header("DESIGN DIALS", BOX_WIDTH))
|
|
719
|
+
if dials.get("variance") is not None:
|
|
720
|
+
lines.append(f"│ Variance: {dials['variance']}/10 — {dials['variance_label']}".ljust(BOX_WIDTH) + "│")
|
|
721
|
+
if dials.get("motion") is not None:
|
|
722
|
+
lines.append(f"│ Motion: {dials['motion']}/10 — {dials['motion_label']}".ljust(BOX_WIDTH) + "│")
|
|
723
|
+
if dials.get("density") is not None:
|
|
724
|
+
lines.append(f"│ Density: {dials['density']}/10 — {dials['density_label']}".ljust(BOX_WIDTH) + "│")
|
|
725
|
+
|
|
324
726
|
# Pattern section
|
|
325
|
-
lines.append(section_header("PATTERN", BOX_WIDTH
|
|
326
|
-
|
|
727
|
+
lines.append(section_header("PATTERN", BOX_WIDTH))
|
|
728
|
+
add_wrapped(f"Name: {pattern.get('name', '')}", "│ ")
|
|
327
729
|
if pattern.get('conversion'):
|
|
328
|
-
|
|
730
|
+
add_wrapped(f"Conversion: {pattern.get('conversion', '')}")
|
|
329
731
|
if pattern.get('cta_placement'):
|
|
330
|
-
|
|
732
|
+
add_wrapped(f"CTA: {pattern.get('cta_placement', '')}")
|
|
331
733
|
lines.append("│ Sections:".ljust(BOX_WIDTH) + "│")
|
|
332
734
|
for i, section in enumerate(sections, 1):
|
|
333
|
-
|
|
735
|
+
add_wrapped(f"{i}. {section}", "│ ")
|
|
334
736
|
|
|
335
737
|
# Style section
|
|
336
|
-
lines.append(section_header("STYLE", BOX_WIDTH
|
|
337
|
-
|
|
338
|
-
light = style
|
|
339
|
-
dark = style.get("dark_mode", "")
|
|
738
|
+
lines.append(section_header("STYLE", BOX_WIDTH))
|
|
739
|
+
add_wrapped(f"Name: {style.get('name', '')}", "│ ")
|
|
740
|
+
light, dark = _mode_support_labels(style, anti_patterns)
|
|
340
741
|
if light or dark:
|
|
341
|
-
|
|
742
|
+
add_wrapped(f"Mode Support: Light {light} | Dark {dark}")
|
|
342
743
|
if style.get("keywords"):
|
|
343
|
-
|
|
344
|
-
lines.append(line.ljust(BOX_WIDTH) + "│")
|
|
744
|
+
add_wrapped(f"Keywords: {style.get('keywords', '')}")
|
|
345
745
|
if style.get("best_for"):
|
|
346
|
-
|
|
347
|
-
lines.append(line.ljust(BOX_WIDTH) + "│")
|
|
746
|
+
add_wrapped(f"Best For: {style.get('best_for', '')}")
|
|
348
747
|
if style.get("performance") or style.get("accessibility"):
|
|
349
|
-
|
|
350
|
-
lines.append(f"│ {perf_a11y}".ljust(BOX_WIDTH) + "│")
|
|
748
|
+
add_wrapped(f"Performance: {style.get('performance', '')} | Accessibility: {style.get('accessibility', '')}")
|
|
351
749
|
|
|
352
750
|
# Colors section (extended palette with ANSI swatches)
|
|
353
|
-
lines.append(section_header("COLORS", BOX_WIDTH
|
|
354
|
-
|
|
355
|
-
("Primary", "primary", "--color-primary"),
|
|
356
|
-
("On Primary", "on_primary", "--color-on-primary"),
|
|
357
|
-
("Secondary", "secondary", "--color-secondary"),
|
|
358
|
-
("Accent/CTA", "accent", "--color-accent"),
|
|
359
|
-
("Background", "background", "--color-background"),
|
|
360
|
-
("Foreground", "foreground", "--color-foreground"),
|
|
361
|
-
("Muted", "muted", "--color-muted"),
|
|
362
|
-
("Border", "border", "--color-border"),
|
|
363
|
-
("Destructive", "destructive", "--color-destructive"),
|
|
364
|
-
("Ring", "ring", "--color-ring"),
|
|
365
|
-
]
|
|
366
|
-
for label, key, css_var in color_entries:
|
|
751
|
+
lines.append(section_header("COLORS", BOX_WIDTH))
|
|
752
|
+
for label, key, css_var in SEMANTIC_COLOR_ENTRIES:
|
|
367
753
|
hex_val = colors.get(key, "")
|
|
368
754
|
if not hex_val:
|
|
369
755
|
continue
|
|
370
756
|
swatch = hex_to_ansi(hex_val)
|
|
371
|
-
content = f"│ {swatch}{label + ':':
|
|
757
|
+
content = f"│ {swatch}{label + ':':18s} {hex_val:10s} ({css_var})"
|
|
372
758
|
lines.append(ansi_ljust(content, BOX_WIDTH) + "│")
|
|
373
759
|
if colors.get("notes"):
|
|
374
760
|
for line in wrap_text(f"Notes: {colors.get('notes', '')}", "│ ", BOX_WIDTH):
|
|
375
761
|
lines.append(line.ljust(BOX_WIDTH) + "│")
|
|
376
762
|
|
|
377
763
|
# Typography section
|
|
378
|
-
lines.append(section_header("TYPOGRAPHY", BOX_WIDTH
|
|
379
|
-
|
|
764
|
+
lines.append(section_header("TYPOGRAPHY", BOX_WIDTH))
|
|
765
|
+
add_wrapped(f"{typography.get('heading', '')} / {typography.get('body', '')}", "│ ")
|
|
380
766
|
if typography.get("mood"):
|
|
381
|
-
|
|
382
|
-
lines.append(line.ljust(BOX_WIDTH) + "│")
|
|
767
|
+
add_wrapped(f"Mood: {typography.get('mood', '')}")
|
|
383
768
|
if typography.get("best_for"):
|
|
384
|
-
|
|
385
|
-
lines.append(line.ljust(BOX_WIDTH) + "│")
|
|
769
|
+
add_wrapped(f"Best For: {typography.get('best_for', '')}")
|
|
386
770
|
if typography.get("google_fonts_url"):
|
|
387
|
-
|
|
771
|
+
add_wrapped(f"Google Fonts: {typography.get('google_fonts_url', '')}")
|
|
388
772
|
if typography.get("css_import"):
|
|
389
|
-
lines.append(f"│ CSS Import: {typography.get('css_import', '')[:
|
|
773
|
+
lines.append(f"│ CSS Import: {typography.get('css_import', '')[:67]}...".ljust(BOX_WIDTH) + "│")
|
|
390
774
|
|
|
391
775
|
# Key Effects section
|
|
392
776
|
if effects:
|
|
393
|
-
lines.append(section_header("KEY EFFECTS", BOX_WIDTH
|
|
777
|
+
lines.append(section_header("KEY EFFECTS", BOX_WIDTH))
|
|
394
778
|
for line in wrap_text(effects, "│ ", BOX_WIDTH):
|
|
395
779
|
lines.append(line.ljust(BOX_WIDTH) + "│")
|
|
396
780
|
|
|
781
|
+
# Motion section (GSAP skeleton, only if --motion dial was set)
|
|
782
|
+
if motion_snippet:
|
|
783
|
+
lines.append(section_header("MOTION", BOX_WIDTH))
|
|
784
|
+
add_wrapped(f"{motion_snippet.get('Category', '')} ({motion_snippet.get('Intensity Tier', '')})", "│ ")
|
|
785
|
+
add_wrapped(f"Trigger: {motion_snippet.get('Trigger', '')} | Duration: {motion_snippet.get('Duration', '')} | Easing: {motion_snippet.get('Easing', '')}")
|
|
786
|
+
for line in wrap_text(f"GSAP: {motion_snippet.get('GSAP Snippet', '')}", "│ ", BOX_WIDTH):
|
|
787
|
+
lines.append(line.ljust(BOX_WIDTH) + "│")
|
|
788
|
+
if motion_snippet.get("Framework Notes"):
|
|
789
|
+
for line in wrap_text(f"Framework: {motion_snippet.get('Framework Notes', '')}", "│ ", BOX_WIDTH):
|
|
790
|
+
lines.append(line.ljust(BOX_WIDTH) + "│")
|
|
791
|
+
|
|
397
792
|
# Anti-patterns section
|
|
398
793
|
if anti_patterns:
|
|
399
|
-
lines.append(section_header("AVOID", BOX_WIDTH
|
|
794
|
+
lines.append(section_header("AVOID", BOX_WIDTH))
|
|
400
795
|
for line in wrap_text(anti_patterns, "│ ", BOX_WIDTH):
|
|
401
796
|
lines.append(line.ljust(BOX_WIDTH) + "│")
|
|
402
797
|
|
|
403
798
|
# Pre-Delivery Checklist section
|
|
404
|
-
lines.append(section_header("PRE-DELIVERY CHECKLIST", BOX_WIDTH
|
|
799
|
+
lines.append(section_header("PRE-DELIVERY CHECKLIST", BOX_WIDTH))
|
|
405
800
|
checklist_items = [
|
|
406
801
|
"[ ] No emojis as icons (use SVG: Heroicons/Lucide)",
|
|
407
802
|
"[ ] cursor-pointer on all clickable elements",
|
|
@@ -428,11 +823,24 @@ def format_markdown(design_system: dict) -> str:
|
|
|
428
823
|
typography = design_system.get("typography", {})
|
|
429
824
|
effects = design_system.get("key_effects", "")
|
|
430
825
|
anti_patterns = design_system.get("anti_patterns", "")
|
|
826
|
+
dials = design_system.get("dials", {})
|
|
827
|
+
motion_snippet = design_system.get("motion_snippet", {})
|
|
431
828
|
|
|
432
829
|
lines = []
|
|
433
830
|
lines.append(f"## Design System: {project}")
|
|
434
831
|
lines.append("")
|
|
435
832
|
|
|
833
|
+
# Design Dials section (only if at least one dial was set)
|
|
834
|
+
if any(dials.get(k) is not None for k in ("variance", "motion", "density")):
|
|
835
|
+
lines.append("### Design Dials")
|
|
836
|
+
if dials.get("variance") is not None:
|
|
837
|
+
lines.append(f"- **Variance:** {dials['variance']}/10 — {dials['variance_label']}")
|
|
838
|
+
if dials.get("motion") is not None:
|
|
839
|
+
lines.append(f"- **Motion:** {dials['motion']}/10 — {dials['motion_label']}")
|
|
840
|
+
if dials.get("density") is not None:
|
|
841
|
+
lines.append(f"- **Density:** {dials['density']}/10 — {dials['density_label']}")
|
|
842
|
+
lines.append("")
|
|
843
|
+
|
|
436
844
|
# Pattern section
|
|
437
845
|
lines.append("### Pattern")
|
|
438
846
|
lines.append(f"- **Name:** {pattern.get('name', '')}")
|
|
@@ -448,8 +856,7 @@ def format_markdown(design_system: dict) -> str:
|
|
|
448
856
|
# Style section
|
|
449
857
|
lines.append("### Style")
|
|
450
858
|
lines.append(f"- **Name:** {style.get('name', '')}")
|
|
451
|
-
light = style
|
|
452
|
-
dark = style.get("dark_mode", "")
|
|
859
|
+
light, dark = _mode_support_labels(style, anti_patterns)
|
|
453
860
|
if light or dark:
|
|
454
861
|
lines.append(f"- **Mode Support:** Light {light} | Dark {dark}")
|
|
455
862
|
if style.get('keywords'):
|
|
@@ -464,19 +871,7 @@ def format_markdown(design_system: dict) -> str:
|
|
|
464
871
|
lines.append("### Colors")
|
|
465
872
|
lines.append("| Role | Hex | CSS Variable |")
|
|
466
873
|
lines.append("|------|-----|--------------|")
|
|
467
|
-
|
|
468
|
-
("Primary", "primary", "--color-primary"),
|
|
469
|
-
("On Primary", "on_primary", "--color-on-primary"),
|
|
470
|
-
("Secondary", "secondary", "--color-secondary"),
|
|
471
|
-
("Accent/CTA", "accent", "--color-accent"),
|
|
472
|
-
("Background", "background", "--color-background"),
|
|
473
|
-
("Foreground", "foreground", "--color-foreground"),
|
|
474
|
-
("Muted", "muted", "--color-muted"),
|
|
475
|
-
("Border", "border", "--color-border"),
|
|
476
|
-
("Destructive", "destructive", "--color-destructive"),
|
|
477
|
-
("Ring", "ring", "--color-ring"),
|
|
478
|
-
]
|
|
479
|
-
for label, key, css_var in md_color_entries:
|
|
874
|
+
for label, key, css_var in SEMANTIC_COLOR_ENTRIES:
|
|
480
875
|
hex_val = colors.get(key, "")
|
|
481
876
|
if hex_val:
|
|
482
877
|
lines.append(f"| {label} | `{hex_val}` | `{css_var}` |")
|
|
@@ -507,6 +902,23 @@ def format_markdown(design_system: dict) -> str:
|
|
|
507
902
|
lines.append(f"{effects}")
|
|
508
903
|
lines.append("")
|
|
509
904
|
|
|
905
|
+
# Motion section (GSAP skeleton, only if --motion dial was set)
|
|
906
|
+
if motion_snippet:
|
|
907
|
+
lines.append("### Motion")
|
|
908
|
+
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', '')}`")
|
|
909
|
+
lines.append("```js")
|
|
910
|
+
lines.append(motion_snippet.get("GSAP Snippet", ""))
|
|
911
|
+
lines.append("```")
|
|
912
|
+
if motion_snippet.get("Framework Notes"):
|
|
913
|
+
lines.append(f"*Framework notes: {motion_snippet.get('Framework Notes', '')}*")
|
|
914
|
+
motion_do = motion_snippet.get("Do", "")
|
|
915
|
+
motion_dont = motion_snippet.get("Don't", "")
|
|
916
|
+
if motion_do:
|
|
917
|
+
lines.append(f"- ✅ {motion_do}")
|
|
918
|
+
if motion_dont:
|
|
919
|
+
lines.append(f"- ❌ {motion_dont}")
|
|
920
|
+
lines.append("")
|
|
921
|
+
|
|
510
922
|
# Anti-patterns section
|
|
511
923
|
if anti_patterns:
|
|
512
924
|
lines.append("### Avoid (Anti-patterns)")
|
|
@@ -529,8 +941,10 @@ def format_markdown(design_system: dict) -> str:
|
|
|
529
941
|
|
|
530
942
|
|
|
531
943
|
# ============ MAIN ENTRY POINT ============
|
|
532
|
-
def generate_design_system(query: str, project_name: str = None, output_format: str = "ascii",
|
|
533
|
-
persist: bool = False, page: str = None, output_dir: str = None
|
|
944
|
+
def generate_design_system(query: str, project_name: str = None, output_format: str = "ascii",
|
|
945
|
+
persist: bool = False, page: str = None, output_dir: str = None,
|
|
946
|
+
variance: int = None, motion: int = None, density: int = None,
|
|
947
|
+
force: bool = False) -> dict:
|
|
534
948
|
"""
|
|
535
949
|
Main entry point for design system generation.
|
|
536
950
|
|
|
@@ -541,70 +955,145 @@ def generate_design_system(query: str, project_name: str = None, output_format:
|
|
|
541
955
|
persist: If True, save design system to design-system/ folder
|
|
542
956
|
page: Optional page name for page-specific override file
|
|
543
957
|
output_dir: Optional output directory (defaults to current working directory)
|
|
958
|
+
variance: Optional 1-10 DESIGN_VARIANCE dial (1=centered/minimal, 10=bold/asymmetric)
|
|
959
|
+
motion: Optional 1-10 MOTION_INTENSITY dial, pulls a matching GSAP snippet from motion.csv
|
|
960
|
+
density: Optional 1-10 VISUAL_DENSITY dial, overrides the spacing scale (1=spacious, 10=dense)
|
|
961
|
+
force: If True, overwrite an existing MASTER.md; otherwise persistence
|
|
962
|
+
is skipped (with a status message) when one already exists
|
|
544
963
|
|
|
545
964
|
Returns:
|
|
546
|
-
|
|
965
|
+
dict with keys: "text" (formatted design system string), "design_system"
|
|
966
|
+
(raw dict, useful for --json callers), and "persistence" (result of
|
|
967
|
+
persist_design_system(), or None if persist=False)
|
|
547
968
|
"""
|
|
548
969
|
generator = DesignSystemGenerator()
|
|
549
|
-
design_system = generator.generate(query, project_name)
|
|
550
|
-
|
|
551
|
-
|
|
970
|
+
design_system = generator.generate(query, project_name, variance=variance, motion=motion, density=density)
|
|
971
|
+
|
|
972
|
+
persistence_result = None
|
|
552
973
|
if persist:
|
|
553
|
-
persist_design_system(design_system, page, output_dir, query)
|
|
974
|
+
persistence_result = persist_design_system(design_system, page, output_dir, query, force=force)
|
|
554
975
|
|
|
555
|
-
if output_format == "markdown"
|
|
556
|
-
|
|
557
|
-
return
|
|
976
|
+
text = format_markdown(design_system) if output_format == "markdown" else format_ascii_box(design_system)
|
|
977
|
+
|
|
978
|
+
return {
|
|
979
|
+
"text": text,
|
|
980
|
+
"design_system": design_system,
|
|
981
|
+
"persistence": persistence_result,
|
|
982
|
+
}
|
|
558
983
|
|
|
559
984
|
|
|
560
985
|
# ============ PERSISTENCE FUNCTIONS ============
|
|
561
|
-
def
|
|
986
|
+
def safe_slug(name, fallback: str = "default") -> str:
|
|
987
|
+
"""Slugify a name into a single safe path segment.
|
|
988
|
+
|
|
989
|
+
Only [a-z0-9_-] survives; every other character (including '/', '\\' and
|
|
990
|
+
'.') collapses into '-'. This makes path traversal via project/page names
|
|
991
|
+
(e.g. "../../etc") impossible — the slug can never leave its parent dir.
|
|
992
|
+
"""
|
|
993
|
+
slug = re.sub(r'[^a-z0-9_-]+', '-', str(name).lower()).strip('-')
|
|
994
|
+
return slug or fallback
|
|
995
|
+
|
|
996
|
+
|
|
997
|
+
def _write_persisted_file(path: Path, content: str, force: bool) -> None:
|
|
998
|
+
"""Write fully to a temp file, then publish atomically."""
|
|
999
|
+
temp_name = None
|
|
1000
|
+
try:
|
|
1001
|
+
with tempfile.NamedTemporaryFile(
|
|
1002
|
+
mode='w', encoding='utf-8', dir=path.parent,
|
|
1003
|
+
prefix=f".{path.name}.", suffix=".tmp", delete=False) as handle:
|
|
1004
|
+
temp_name = handle.name
|
|
1005
|
+
handle.write(content)
|
|
1006
|
+
handle.flush()
|
|
1007
|
+
os.fsync(handle.fileno())
|
|
1008
|
+
if force:
|
|
1009
|
+
os.replace(temp_name, path)
|
|
1010
|
+
temp_name = None
|
|
1011
|
+
else:
|
|
1012
|
+
# A same-filesystem hard link atomically publishes only if the
|
|
1013
|
+
# destination is absent. Losing writers get FileExistsError.
|
|
1014
|
+
os.link(temp_name, path)
|
|
1015
|
+
finally:
|
|
1016
|
+
if temp_name and os.path.exists(temp_name):
|
|
1017
|
+
os.unlink(temp_name)
|
|
1018
|
+
|
|
1019
|
+
|
|
1020
|
+
def persist_design_system(design_system: dict, page: str = None, output_dir: str = None,
|
|
1021
|
+
page_query: str = None, force: bool = False) -> dict:
|
|
562
1022
|
"""
|
|
563
1023
|
Persist design system to design-system/<project>/ folder using Master + Overrides pattern.
|
|
564
|
-
|
|
1024
|
+
|
|
565
1025
|
Args:
|
|
566
1026
|
design_system: The generated design system dictionary
|
|
567
1027
|
page: Optional page name for page-specific override file
|
|
568
1028
|
output_dir: Optional output directory (defaults to current working directory)
|
|
569
1029
|
page_query: Optional query string for intelligent page override generation
|
|
570
|
-
|
|
1030
|
+
force: If True, overwrite an existing MASTER.md. If False (default) and
|
|
1031
|
+
MASTER.md already exists, persistence is skipped so prior design
|
|
1032
|
+
decisions aren't silently discarded.
|
|
1033
|
+
|
|
571
1034
|
Returns:
|
|
572
|
-
dict with created file paths and status
|
|
1035
|
+
dict with created file paths and status. status is "skipped_exists" if
|
|
1036
|
+
MASTER.md already existed and force was not set.
|
|
573
1037
|
"""
|
|
574
1038
|
base_dir = Path(output_dir) if output_dir else Path.cwd()
|
|
575
|
-
|
|
576
|
-
# Use project name for project-specific folder
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
1039
|
+
|
|
1040
|
+
# Use project name for project-specific folder. Coalesce falsy values
|
|
1041
|
+
# (missing key, explicit None, or "") so the .lower() below can't crash.
|
|
1042
|
+
project_name = design_system.get("project_name") or "default"
|
|
1043
|
+
project_slug = safe_slug(project_name)
|
|
1044
|
+
|
|
580
1045
|
design_system_dir = base_dir / "design-system" / project_slug
|
|
581
1046
|
pages_dir = design_system_dir / "pages"
|
|
582
|
-
|
|
1047
|
+
|
|
1048
|
+
master_file = design_system_dir / "MASTER.md"
|
|
1049
|
+
|
|
583
1050
|
created_files = []
|
|
584
|
-
|
|
1051
|
+
|
|
585
1052
|
# Create directories
|
|
586
1053
|
design_system_dir.mkdir(parents=True, exist_ok=True)
|
|
587
1054
|
pages_dir.mkdir(parents=True, exist_ok=True)
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
# Generate and write MASTER.md
|
|
1055
|
+
|
|
1056
|
+
# Exclusive creation makes the default no-overwrite contract safe when
|
|
1057
|
+
# multiple agents persist the same project concurrently.
|
|
592
1058
|
master_content = format_master_md(design_system)
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
1059
|
+
try:
|
|
1060
|
+
_write_persisted_file(master_file, master_content, force)
|
|
1061
|
+
created_files.append(str(master_file))
|
|
1062
|
+
except FileExistsError:
|
|
1063
|
+
if not page:
|
|
1064
|
+
return {
|
|
1065
|
+
"status": "skipped_exists",
|
|
1066
|
+
"design_system_dir": str(design_system_dir),
|
|
1067
|
+
"master_file": str(master_file),
|
|
1068
|
+
"created_files": [],
|
|
1069
|
+
"message": (
|
|
1070
|
+
f"{master_file} already exists and was not modified. "
|
|
1071
|
+
"Read it first to check for prior design decisions, then "
|
|
1072
|
+
"re-run with force=True / --force to overwrite."
|
|
1073
|
+
),
|
|
1074
|
+
}
|
|
1075
|
+
|
|
597
1076
|
# If page is specified, create page override file with intelligent content
|
|
598
1077
|
if page:
|
|
599
|
-
page_file = pages_dir / f"{page
|
|
1078
|
+
page_file = pages_dir / f"{safe_slug(page, 'page')}.md"
|
|
600
1079
|
page_content = format_page_override_md(design_system, page, page_query)
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
1080
|
+
try:
|
|
1081
|
+
_write_persisted_file(page_file, page_content, force)
|
|
1082
|
+
created_files.append(str(page_file))
|
|
1083
|
+
except FileExistsError:
|
|
1084
|
+
if not created_files:
|
|
1085
|
+
return {
|
|
1086
|
+
"status": "skipped_exists",
|
|
1087
|
+
"design_system_dir": str(design_system_dir),
|
|
1088
|
+
"master_file": str(master_file),
|
|
1089
|
+
"created_files": [],
|
|
1090
|
+
"message": f"{page_file} already exists and was not modified.",
|
|
1091
|
+
}
|
|
1092
|
+
|
|
605
1093
|
return {
|
|
606
1094
|
"status": "success",
|
|
607
1095
|
"design_system_dir": str(design_system_dir),
|
|
1096
|
+
"master_file": str(master_file),
|
|
608
1097
|
"created_files": created_files
|
|
609
1098
|
}
|
|
610
1099
|
|
|
@@ -618,11 +1107,14 @@ def format_master_md(design_system: dict) -> str:
|
|
|
618
1107
|
typography = design_system.get("typography", {})
|
|
619
1108
|
effects = design_system.get("key_effects", "")
|
|
620
1109
|
anti_patterns = design_system.get("anti_patterns", "")
|
|
621
|
-
|
|
1110
|
+
dials = design_system.get("dials", {})
|
|
1111
|
+
motion_snippet = design_system.get("motion_snippet", {})
|
|
1112
|
+
spacing_scale = design_system.get("spacing_scale")
|
|
1113
|
+
|
|
622
1114
|
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
|
623
|
-
|
|
1115
|
+
|
|
624
1116
|
lines = []
|
|
625
|
-
|
|
1117
|
+
|
|
626
1118
|
# Logic header
|
|
627
1119
|
lines.append("# Design System Master File")
|
|
628
1120
|
lines.append("")
|
|
@@ -635,6 +1127,15 @@ def format_master_md(design_system: dict) -> str:
|
|
|
635
1127
|
lines.append(f"**Project:** {project}")
|
|
636
1128
|
lines.append(f"**Generated:** {timestamp}")
|
|
637
1129
|
lines.append(f"**Category:** {design_system.get('category', 'General')}")
|
|
1130
|
+
if any(dials.get(k) is not None for k in ("variance", "motion", "density")):
|
|
1131
|
+
dial_parts = []
|
|
1132
|
+
if dials.get("variance") is not None:
|
|
1133
|
+
dial_parts.append(f"Variance {dials['variance']}/10 ({dials['variance_label']})")
|
|
1134
|
+
if dials.get("motion") is not None:
|
|
1135
|
+
dial_parts.append(f"Motion {dials['motion']}/10 ({dials['motion_label']})")
|
|
1136
|
+
if dials.get("density") is not None:
|
|
1137
|
+
dial_parts.append(f"Density {dials['density']}/10 ({dials['density_label']})")
|
|
1138
|
+
lines.append(f"**Design Dials:** {' | '.join(dial_parts)}")
|
|
638
1139
|
lines.append("")
|
|
639
1140
|
lines.append("---")
|
|
640
1141
|
lines.append("")
|
|
@@ -648,19 +1149,7 @@ def format_master_md(design_system: dict) -> str:
|
|
|
648
1149
|
lines.append("")
|
|
649
1150
|
lines.append("| Role | Hex | CSS Variable |")
|
|
650
1151
|
lines.append("|------|-----|--------------|")
|
|
651
|
-
|
|
652
|
-
("Primary", "primary", "--color-primary"),
|
|
653
|
-
("On Primary", "on_primary", "--color-on-primary"),
|
|
654
|
-
("Secondary", "secondary", "--color-secondary"),
|
|
655
|
-
("Accent/CTA", "accent", "--color-accent"),
|
|
656
|
-
("Background", "background", "--color-background"),
|
|
657
|
-
("Foreground", "foreground", "--color-foreground"),
|
|
658
|
-
("Muted", "muted", "--color-muted"),
|
|
659
|
-
("Border", "border", "--color-border"),
|
|
660
|
-
("Destructive", "destructive", "--color-destructive"),
|
|
661
|
-
("Ring", "ring", "--color-ring"),
|
|
662
|
-
]
|
|
663
|
-
for label, key, css_var in master_color_entries:
|
|
1152
|
+
for label, key, css_var in SEMANTIC_COLOR_ENTRIES:
|
|
664
1153
|
hex_val = colors.get(key, "")
|
|
665
1154
|
if hex_val:
|
|
666
1155
|
lines.append(f"| {label} | `{hex_val}` | `{css_var}` |")
|
|
@@ -686,18 +1175,24 @@ def format_master_md(design_system: dict) -> str:
|
|
|
686
1175
|
lines.append("```")
|
|
687
1176
|
lines.append("")
|
|
688
1177
|
|
|
689
|
-
# Spacing Variables
|
|
1178
|
+
# Spacing Variables (overridden by the VISUAL_DENSITY dial when set)
|
|
1179
|
+
default_spacing = DIAL_TIERS["density"][1][2]["spacing"] # mid-tier = the historical defaults
|
|
1180
|
+
scale = spacing_scale or default_spacing
|
|
1181
|
+
spacing_usage = {
|
|
1182
|
+
"xs": "Tight gaps", "sm": "Icon gaps, inline spacing", "md": "Standard padding",
|
|
1183
|
+
"lg": "Section padding", "xl": "Large gaps", "2xl": "Section margins", "3xl": "Hero padding",
|
|
1184
|
+
}
|
|
690
1185
|
lines.append("### Spacing Variables")
|
|
691
1186
|
lines.append("")
|
|
1187
|
+
if spacing_scale:
|
|
1188
|
+
lines.append(f"*Density: {dials.get('density')}/10 — {dials.get('density_label')}*")
|
|
1189
|
+
lines.append("")
|
|
692
1190
|
lines.append("| Token | Value | Usage |")
|
|
693
1191
|
lines.append("|-------|-------|-------|")
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
lines.append("| `--space-xl` | `32px` / `2rem` | Large gaps |")
|
|
699
|
-
lines.append("| `--space-2xl` | `48px` / `3rem` | Section margins |")
|
|
700
|
-
lines.append("| `--space-3xl` | `64px` / `4rem` | Hero padding |")
|
|
1192
|
+
for token in ("xs", "sm", "md", "lg", "xl", "2xl", "3xl"):
|
|
1193
|
+
px_value = scale[token]
|
|
1194
|
+
rem_value = f"{int(px_value.rstrip('px')) / 16:g}rem"
|
|
1195
|
+
lines.append(f"| `--space-{token}` | `{px_value}` / `{rem_value}` | {spacing_usage[token]} |")
|
|
701
1196
|
lines.append("")
|
|
702
1197
|
|
|
703
1198
|
# Shadow Depths
|
|
@@ -724,7 +1219,7 @@ def format_master_md(design_system: dict) -> str:
|
|
|
724
1219
|
lines.append("/* Primary Button */")
|
|
725
1220
|
lines.append(".btn-primary {")
|
|
726
1221
|
lines.append(f" background: {colors.get('cta', '#F97316')};")
|
|
727
|
-
lines.append(" color: white;")
|
|
1222
|
+
lines.append(f" color: {colors.get('on_cta') or 'white'};")
|
|
728
1223
|
lines.append(" padding: 12px 24px;")
|
|
729
1224
|
lines.append(" border-radius: 8px;")
|
|
730
1225
|
lines.append(" font-weight: 600;")
|
|
@@ -740,7 +1235,7 @@ def format_master_md(design_system: dict) -> str:
|
|
|
740
1235
|
lines.append("/* Secondary Button */")
|
|
741
1236
|
lines.append(".btn-secondary {")
|
|
742
1237
|
lines.append(f" background: transparent;")
|
|
743
|
-
lines.append(f" color: {colors
|
|
1238
|
+
lines.append(f" color: {_button_outline_text_color(colors)};")
|
|
744
1239
|
lines.append(f" border: 2px solid {colors.get('primary', '#2563EB')};")
|
|
745
1240
|
lines.append(" padding: 12px 24px;")
|
|
746
1241
|
lines.append(" border-radius: 8px;")
|
|
@@ -839,7 +1334,32 @@ def format_master_md(design_system: dict) -> str:
|
|
|
839
1334
|
lines.append(f"- **CTA Placement:** {pattern.get('cta_placement', '')}")
|
|
840
1335
|
lines.append(f"- **Section Order:** {pattern.get('sections', '')}")
|
|
841
1336
|
lines.append("")
|
|
842
|
-
|
|
1337
|
+
|
|
1338
|
+
# Motion section (GSAP skeleton, only if --motion dial was set)
|
|
1339
|
+
if motion_snippet:
|
|
1340
|
+
lines.append("---")
|
|
1341
|
+
lines.append("")
|
|
1342
|
+
lines.append("## Motion")
|
|
1343
|
+
lines.append("")
|
|
1344
|
+
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', '')}`")
|
|
1345
|
+
lines.append("")
|
|
1346
|
+
lines.append("```js")
|
|
1347
|
+
lines.append(motion_snippet.get("GSAP Snippet", ""))
|
|
1348
|
+
lines.append("```")
|
|
1349
|
+
lines.append("")
|
|
1350
|
+
if motion_snippet.get("Framework Notes"):
|
|
1351
|
+
lines.append(f"**Framework notes:** {motion_snippet.get('Framework Notes', '')}")
|
|
1352
|
+
lines.append("")
|
|
1353
|
+
motion_do = motion_snippet.get("Do", "")
|
|
1354
|
+
motion_dont = motion_snippet.get("Don't", "")
|
|
1355
|
+
if motion_do:
|
|
1356
|
+
lines.append(f"- ✅ {motion_do}")
|
|
1357
|
+
if motion_dont:
|
|
1358
|
+
lines.append(f"- ❌ {motion_dont}")
|
|
1359
|
+
if motion_snippet.get("Performance Notes"):
|
|
1360
|
+
lines.append(f"- ⚡ {motion_snippet.get('Performance Notes', '')}")
|
|
1361
|
+
lines.append("")
|
|
1362
|
+
|
|
843
1363
|
# Anti-Patterns section
|
|
844
1364
|
lines.append("---")
|
|
845
1365
|
lines.append("")
|
|
@@ -1145,4 +1665,4 @@ if __name__ == "__main__":
|
|
|
1145
1665
|
args = parser.parse_args()
|
|
1146
1666
|
|
|
1147
1667
|
result = generate_design_system(args.query, args.project_name, args.format)
|
|
1148
|
-
print(result)
|
|
1668
|
+
print(result["text"])
|