@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.
Files changed (107) hide show
  1. package/CHANGELOG.md +2 -0
  2. package/README.md +1 -1
  3. package/package.json +1 -1
  4. package/skills/backend/fastify-best-practices/NOTICE.md +1 -1
  5. package/skills/backend/fastify-best-practices/rules/authentication.md +13 -0
  6. package/skills/backend/fastify-best-practices/rules/configuration.md +9 -0
  7. package/skills/backend/fastify-best-practices/rules/content-type.md +16 -0
  8. package/skills/backend/fastify-best-practices/rules/cors-security.md +18 -0
  9. package/skills/backend/fastify-best-practices/rules/database.md +12 -0
  10. package/skills/backend/fastify-best-practices/rules/decorators.md +14 -0
  11. package/skills/backend/fastify-best-practices/rules/deployment.md +16 -0
  12. package/skills/backend/fastify-best-practices/rules/error-handling.md +16 -0
  13. package/skills/backend/fastify-best-practices/rules/hooks.md +20 -0
  14. package/skills/backend/fastify-best-practices/rules/http-proxy.md +11 -0
  15. package/skills/backend/fastify-best-practices/rules/logging.md +18 -0
  16. package/skills/backend/fastify-best-practices/rules/performance.md +20 -0
  17. package/skills/backend/fastify-best-practices/rules/plugins.md +14 -0
  18. package/skills/backend/fastify-best-practices/rules/routes.md +18 -0
  19. package/skills/backend/fastify-best-practices/rules/schemas.md +20 -0
  20. package/skills/backend/fastify-best-practices/rules/serialization.md +19 -0
  21. package/skills/backend/fastify-best-practices/rules/testing.md +17 -0
  22. package/skills/backend/fastify-best-practices/rules/typescript.md +15 -0
  23. package/skills/backend/fastify-best-practices/rules/websockets.md +15 -0
  24. package/skills/backend/go/golang-patterns/NOTICE.md +1 -1
  25. package/skills/backend/go/golang-patterns/SKILL.md +2 -1
  26. package/skills/devops/docker-patterns/NOTICE.md +1 -1
  27. package/skills/devops/docker-patterns/SKILL.md +3 -3
  28. package/skills/frontend/design/revenue-centric-design/NOTICE.md +1 -1
  29. package/skills/frontend/design/revenue-centric-design/SKILL.md +6 -4
  30. package/skills/frontend/design/revenue-centric-design/assets/1985681720104903142__1.jpg +0 -0
  31. package/skills/frontend/design/revenue-centric-design/assets/1999584679247134761__1.jpg +0 -0
  32. package/skills/frontend/design/revenue-centric-design/assets/2000930776636940333__1.jpg +0 -0
  33. package/skills/frontend/design/revenue-centric-design/assets/2018693884449009956__3.jpg +0 -0
  34. package/skills/frontend/design/revenue-centric-design/assets/2081714430811730162__1.jpg +0 -0
  35. package/skills/frontend/design/revenue-centric-design/assets/2092692547244007852__q__1.jpg +0 -0
  36. package/skills/frontend/design/revenue-centric-design/assets/2092692547244007852__q__2.jpg +0 -0
  37. package/skills/frontend/design/revenue-centric-design/assets/2092692547244007852__q__3.jpg +0 -0
  38. package/skills/frontend/design/revenue-centric-design/references/checkout-and-forms.md +39 -0
  39. package/skills/frontend/design/revenue-centric-design/references/churn-and-retention.md +25 -0
  40. package/skills/frontend/design/revenue-centric-design/references/conversion-and-landing-pages.md +52 -1
  41. package/skills/frontend/design/revenue-centric-design/references/dashboards-and-data-viz.md +23 -0
  42. package/skills/frontend/design/revenue-centric-design/references/metrics-and-experimentation.md +13 -0
  43. package/skills/frontend/design/revenue-centric-design/references/onboarding-and-activation.md +7 -1
  44. package/skills/frontend/design/revenue-centric-design/references/pricing-and-monetization.md +35 -8
  45. package/skills/frontend/design/revenue-centric-design/references/product-strategy-and-features.md +37 -0
  46. package/skills/frontend/design/revenue-centric-design/references/revenue-centric-design.md +2 -2
  47. package/skills/frontend/design/ui-ux-pro-max/NOTICE.md +6 -4
  48. package/skills/frontend/design/ui-ux-pro-max/SKILL.md +103 -573
  49. package/skills/frontend/design/ui-ux-pro-max/data/app-interface.csv +29 -27
  50. package/skills/frontend/design/ui-ux-pro-max/data/catalog-summary.json +78 -0
  51. package/skills/frontend/design/ui-ux-pro-max/data/charts.csv +26 -26
  52. package/skills/frontend/design/ui-ux-pro-max/data/colors.csv +191 -160
  53. package/skills/frontend/design/ui-ux-pro-max/data/data-provenance.json +1348 -0
  54. package/skills/frontend/design/ui-ux-pro-max/data/google-font-licenses.json +20185 -0
  55. package/skills/frontend/design/ui-ux-pro-max/data/google-fonts.csv +1934 -1923
  56. package/skills/frontend/design/ui-ux-pro-max/data/icons.csv +106 -106
  57. package/skills/frontend/design/ui-ux-pro-max/data/landing.csv +35 -35
  58. package/skills/frontend/design/ui-ux-pro-max/data/motion.csv +18 -0
  59. package/skills/frontend/design/ui-ux-pro-max/data/phosphor-icons-upstream.json +32582 -0
  60. package/skills/frontend/design/ui-ux-pro-max/data/products.csv +192 -161
  61. package/skills/frontend/design/ui-ux-pro-max/data/react-performance.csv +37 -37
  62. package/skills/frontend/design/ui-ux-pro-max/data/stacks/angular.csv +51 -51
  63. package/skills/frontend/design/ui-ux-pro-max/data/stacks/astro.csv +54 -54
  64. package/skills/frontend/design/ui-ux-pro-max/data/stacks/avalonia.csv +57 -0
  65. package/skills/frontend/design/ui-ux-pro-max/data/stacks/flutter.csv +53 -53
  66. package/skills/frontend/design/ui-ux-pro-max/data/stacks/html-tailwind.csv +60 -56
  67. package/skills/frontend/design/ui-ux-pro-max/data/stacks/javafx.csv +76 -0
  68. package/skills/frontend/design/ui-ux-pro-max/data/stacks/jetpack-compose.csv +53 -53
  69. package/skills/frontend/design/ui-ux-pro-max/data/stacks/laravel.csv +51 -51
  70. package/skills/frontend/design/ui-ux-pro-max/data/stacks/nextjs.csv +62 -53
  71. package/skills/frontend/design/ui-ux-pro-max/data/stacks/nuxt-ui.csv +77 -51
  72. package/skills/frontend/design/ui-ux-pro-max/data/stacks/nuxtjs.csv +68 -59
  73. package/skills/frontend/design/ui-ux-pro-max/data/stacks/react-native.csv +52 -52
  74. package/skills/frontend/design/ui-ux-pro-max/data/stacks/react.csv +67 -54
  75. package/skills/frontend/design/ui-ux-pro-max/data/stacks/shadcn.csv +69 -61
  76. package/skills/frontend/design/ui-ux-pro-max/data/stacks/svelte.csv +56 -54
  77. package/skills/frontend/design/ui-ux-pro-max/data/stacks/swiftui.csv +51 -51
  78. package/skills/frontend/design/ui-ux-pro-max/data/stacks/threejs.csv +54 -54
  79. package/skills/frontend/design/ui-ux-pro-max/data/stacks/uno.csv +60 -0
  80. package/skills/frontend/design/ui-ux-pro-max/data/stacks/uwp.csv +56 -0
  81. package/skills/frontend/design/ui-ux-pro-max/data/stacks/vue.csv +50 -50
  82. package/skills/frontend/design/ui-ux-pro-max/data/stacks/winui.csv +60 -0
  83. package/skills/frontend/design/ui-ux-pro-max/data/stacks/wpf.csv +57 -0
  84. package/skills/frontend/design/ui-ux-pro-max/data/styles.csv +89 -85
  85. package/skills/frontend/design/ui-ux-pro-max/data/typography.csv +74 -73
  86. package/skills/frontend/design/ui-ux-pro-max/data/ui-reasoning.csv +193 -162
  87. package/skills/frontend/design/ui-ux-pro-max/data/ux-guidelines.csv +30 -10
  88. package/skills/frontend/design/ui-ux-pro-max/references/pro-rules.md +117 -0
  89. package/skills/frontend/design/ui-ux-pro-max/references/quick-reference.md +256 -0
  90. package/skills/frontend/design/ui-ux-pro-max/scripts/core.py +800 -69
  91. package/skills/frontend/design/ui-ux-pro-max/scripts/design_system.py +700 -180
  92. package/skills/frontend/design/ui-ux-pro-max/scripts/reasoning_contract.py +123 -0
  93. package/skills/frontend/design/ui-ux-pro-max/scripts/search.py +105 -42
  94. package/skills/frontend/design/ui-ux-pro-max/scripts/validate_data.py +1095 -0
  95. package/skills/frontend/react-native/react-native-unistyles-v3/NOTICE.md +1 -1
  96. package/skills/frontend/react-native/react-native-unistyles-v3/SKILL.md +33 -24
  97. package/skills/frontend/react-native/react-native-unistyles-v3/references/api-reference.md +135 -78
  98. package/skills/frontend/react-native/react-native-unistyles-v3/references/common-issues.md +48 -31
  99. package/skills/frontend/react-native/react-native-unistyles-v3/references/setup-guide.md +76 -34
  100. package/skills/frontend/react-native/react-native-unistyles-v3/references/styling-patterns.md +145 -52
  101. package/skills/frontend/react-native/react-native-unistyles-v3/references/third-party-integration.md +76 -27
  102. package/skills/media/brag-slim/NOTICE.md +3 -3
  103. package/skills/media/brag-slim/SKILL.md +4 -0
  104. package/skills/frontend/design/revenue-centric-design/assets/2018693884449009956__1.jpg +0 -0
  105. package/skills/frontend/design/ui-ux-pro-max/data/_sync_all.py +0 -414
  106. package/skills/frontend/design/ui-ux-pro-max/data/design.csv +0 -1776
  107. 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 = generate_design_system("SaaS dashboard", "My Project", persist=True, page="dashboard")
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": 2},
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 _multi_domain_search(self, query: str, style_priority: list = None) -> dict:
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
- # For style, also search with priority keywords
57
- priority_query = " ".join(style_priority[:2]) if style_priority else query
58
- combined_query = f"{query} {priority_query}"
59
- results[domain] = search(combined_query, domain, config["max_results"])
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.lower()
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
- ui_cat = rule.get("UI_Category", "").lower()
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, search_results: dict) -> dict:
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
- # Parse decision rules JSON
105
- decision_rules = {}
106
- try:
107
- decision_rules = json.loads(rule.get("Decision_Rules", "{}"))
108
- except json.JSONDecodeError:
109
- pass
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": [s.strip() for s in rule.get("Style_Priority", "").split("+")],
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
- # First: try exact style name match
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
- priority_lower = priority.lower().strip()
133
- for result in results:
134
- style_name = result.get("Style Category", "").lower()
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
- kw_lower = kw.lower().strip()
145
- # Higher score for style name match
146
- if kw_lower in result.get("Style Category", "").lower():
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
- # Lower score for keyword field match
149
- elif kw_lower in result.get("Keywords", "").lower():
460
+ elif kw_tokens & set(re.findall(
461
+ r"[a-z0-9]+", result.get("Keywords", "").lower())):
150
462
  score += 3
151
- # Even lower for other field matches
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) -> dict:
164
- """Generate complete design system recommendation."""
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(query, style_priority)
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, reasoning.get("style_priority", []))
187
- best_color = color_results[0] if color_results else {}
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 = landing_results[0] if landing_results else {}
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": reasoning.get("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
- "severity": reasoning.get("severity", "MEDIUM")
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
- words = text.split()
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 + 1))
326
- lines.append(f"│ Name: {pattern.get('name', '')}".ljust(BOX_WIDTH) + "│")
727
+ lines.append(section_header("PATTERN", BOX_WIDTH))
728
+ add_wrapped(f"Name: {pattern.get('name', '')}", "│ ")
327
729
  if pattern.get('conversion'):
328
- lines.append(f"│ Conversion: {pattern.get('conversion', '')}".ljust(BOX_WIDTH) + "│")
730
+ add_wrapped(f"Conversion: {pattern.get('conversion', '')}")
329
731
  if pattern.get('cta_placement'):
330
- lines.append(f"│ CTA: {pattern.get('cta_placement', '')}".ljust(BOX_WIDTH) + "│")
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
- lines.append(f"│ {i}. {section}".ljust(BOX_WIDTH) + "│")
735
+ add_wrapped(f"{i}. {section}", "│ ")
334
736
 
335
737
  # Style section
336
- lines.append(section_header("STYLE", BOX_WIDTH + 1))
337
- lines.append(f"│ Name: {style.get('name', '')}".ljust(BOX_WIDTH) + "│")
338
- light = style.get("light_mode", "")
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
- lines.append(f"│ Mode Support: Light {light} Dark {dark}".ljust(BOX_WIDTH) + "│")
742
+ add_wrapped(f"Mode Support: Light {light} | Dark {dark}")
342
743
  if style.get("keywords"):
343
- for line in wrap_text(f"Keywords: {style.get('keywords', '')}", "│ ", BOX_WIDTH):
344
- lines.append(line.ljust(BOX_WIDTH) + "│")
744
+ add_wrapped(f"Keywords: {style.get('keywords', '')}")
345
745
  if style.get("best_for"):
346
- for line in wrap_text(f"Best For: {style.get('best_for', '')}", "│ ", BOX_WIDTH):
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
- perf_a11y = f"Performance: {style.get('performance', '')} | Accessibility: {style.get('accessibility', '')}"
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 + 1))
354
- color_entries = [
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 + ':':14s} {hex_val:10s} ({css_var})"
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 + 1))
379
- lines.append(f"│ {typography.get('heading', '')} / {typography.get('body', '')}".ljust(BOX_WIDTH) + "│")
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
- for line in wrap_text(f"Mood: {typography.get('mood', '')}", "│ ", BOX_WIDTH):
382
- lines.append(line.ljust(BOX_WIDTH) + "│")
767
+ add_wrapped(f"Mood: {typography.get('mood', '')}")
383
768
  if typography.get("best_for"):
384
- for line in wrap_text(f"Best For: {typography.get('best_for', '')}", "│ ", BOX_WIDTH):
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
- lines.append(f"│ Google Fonts: {typography.get('google_fonts_url', '')}".ljust(BOX_WIDTH) + "│")
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', '')[:70]}...".ljust(BOX_WIDTH) + "│")
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 + 1))
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 + 1))
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 + 1))
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.get("light_mode", "")
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
- md_color_entries = [
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) -> str:
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
- Formatted design system string
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
- # Persist to files if requested
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
- return format_markdown(design_system)
557
- return format_ascii_box(design_system)
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 persist_design_system(design_system: dict, page: str = None, output_dir: str = None, page_query: str = None) -> dict:
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
- project_name = design_system.get("project_name", "default")
578
- project_slug = project_name.lower().replace(' ', '-')
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
- master_file = design_system_dir / "MASTER.md"
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
- with open(master_file, 'w', encoding='utf-8') as f:
594
- f.write(master_content)
595
- created_files.append(str(master_file))
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.lower().replace(' ', '-')}.md"
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
- with open(page_file, 'w', encoding='utf-8') as f:
602
- f.write(page_content)
603
- created_files.append(str(page_file))
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
- master_color_entries = [
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
- lines.append("| `--space-xs` | `4px` / `0.25rem` | Tight gaps |")
695
- lines.append("| `--space-sm` | `8px` / `0.5rem` | Icon gaps, inline spacing |")
696
- lines.append("| `--space-md` | `16px` / `1rem` | Standard padding |")
697
- lines.append("| `--space-lg` | `24px` / `1.5rem` | Section padding |")
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.get('primary', '#2563EB')};")
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"])