@maccesar/aiskills 1.7.0

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 (73) hide show
  1. package/README.md +531 -0
  2. package/bin/aiskills.js +76 -0
  3. package/lib/cache.js +49 -0
  4. package/lib/cleanup.js +77 -0
  5. package/lib/commands/auto-update.js +131 -0
  6. package/lib/commands/doctor.js +139 -0
  7. package/lib/commands/list.js +77 -0
  8. package/lib/commands/skills.js +263 -0
  9. package/lib/commands/status.js +94 -0
  10. package/lib/commands/uninstall.js +182 -0
  11. package/lib/commands/update.js +149 -0
  12. package/lib/config.js +90 -0
  13. package/lib/downloader.js +110 -0
  14. package/lib/hooks.js +74 -0
  15. package/lib/installer.js +114 -0
  16. package/lib/platform.js +112 -0
  17. package/lib/prompts/checkboxCancel.js +264 -0
  18. package/lib/prompts/selectCancel.js +204 -0
  19. package/lib/symlink.js +154 -0
  20. package/lib/utils.js +49 -0
  21. package/package.json +61 -0
  22. package/skills/humaniza/SKILL.md +51 -0
  23. package/skills/humaniza/agents/openai.yaml +4 -0
  24. package/skills/humaniza/references/ai-patterns-es.md +51 -0
  25. package/skills/humaniza/references/checklist.md +9 -0
  26. package/skills/humaniza/references/examples.md +17 -0
  27. package/skills/humaniza/references/lexicon-es-mx.md +36 -0
  28. package/skills/humaniza/references/modes-es-mx.md +41 -0
  29. package/skills/humaniza/references/voice-es-mx.md +24 -0
  30. package/skills/refactoring-ui/SKILL.md +59 -0
  31. package/skills/refactoring-ui/references/01-design-process.md +72 -0
  32. package/skills/refactoring-ui/references/02-visual-hierarchy.md +84 -0
  33. package/skills/refactoring-ui/references/03-layout-spacing.md +69 -0
  34. package/skills/refactoring-ui/references/04-typography.md +70 -0
  35. package/skills/refactoring-ui/references/05-color.md +96 -0
  36. package/skills/refactoring-ui/references/06-depth-shadows.md +74 -0
  37. package/skills/refactoring-ui/references/07-images.md +75 -0
  38. package/skills/refactoring-ui/references/08-finishing-touches.md +91 -0
  39. package/skills/stitch-showcase/SKILL.md +411 -0
  40. package/skills/stitch-showcase/references/01-navbar.md +52 -0
  41. package/skills/stitch-showcase/references/02-hero.md +56 -0
  42. package/skills/stitch-showcase/references/03-design-system.md +102 -0
  43. package/skills/stitch-showcase/references/04-screen-gallery.md +102 -0
  44. package/skills/stitch-showcase/references/05-viewer-web.md +105 -0
  45. package/skills/stitch-showcase/references/06-viewer-mobile.md +104 -0
  46. package/skills/stitch-showcase/references/07-theme-system.md +77 -0
  47. package/skills/stitch-showcase/references/08-type-detection.md +81 -0
  48. package/skills/stitch-showcase/references/09-quality-standards.md +126 -0
  49. package/skills/stitch-showcase/references/10-component-standardization.md +40 -0
  50. package/skills/stitch-showcase/references/11-component-catalog.md +70 -0
  51. package/skills/stitch-showcase/references/catalog-template.html +841 -0
  52. package/skills/stitch-showcase/references/index.html +299 -0
  53. package/skills/stitch-showcase/references/viewer.html +412 -0
  54. package/skills/stitch-showcase/scripts/__pycache__/build_showcase.cpython-314.pyc +0 -0
  55. package/skills/stitch-showcase/scripts/__pycache__/component_utils.cpython-313.pyc +0 -0
  56. package/skills/stitch-showcase/scripts/__pycache__/detect_components.cpython-313.pyc +0 -0
  57. package/skills/stitch-showcase/scripts/__pycache__/extract_catalog.cpython-313.pyc +0 -0
  58. package/skills/stitch-showcase/scripts/__pycache__/extract_text.cpython-313.pyc +0 -0
  59. package/skills/stitch-showcase/scripts/__pycache__/extract_zips.cpython-313.pyc +0 -0
  60. package/skills/stitch-showcase/scripts/__pycache__/parse_design_md.cpython-313.pyc +0 -0
  61. package/skills/stitch-showcase/scripts/apply_canonical.py +238 -0
  62. package/skills/stitch-showcase/scripts/build_showcase.py +2103 -0
  63. package/skills/stitch-showcase/scripts/component_utils.py +398 -0
  64. package/skills/stitch-showcase/scripts/detect_components.py +284 -0
  65. package/skills/stitch-showcase/scripts/extract_catalog.py +913 -0
  66. package/skills/stitch-showcase/scripts/extract_text.py +268 -0
  67. package/skills/stitch-showcase/scripts/extract_zips.py +178 -0
  68. package/skills/stitch-showcase/scripts/parse_design_md.py +397 -0
  69. package/skills/vscode-extension-dev/SKILL.md +114 -0
  70. package/skills/vscode-extension-dev/references/api-patterns.md +625 -0
  71. package/skills/vscode-extension-dev/references/architecture.md +287 -0
  72. package/skills/vscode-extension-dev/references/package-json-schema.md +345 -0
  73. package/skills/vscode-extension-dev/references/publishing.md +251 -0
@@ -0,0 +1,398 @@
1
+ """
2
+ component_utils.py — Shared HTML parsing helpers for component detection and catalog.
3
+
4
+ Uses only stdlib (html.parser, re, difflib). No external dependencies.
5
+
6
+ Usage:
7
+ from component_utils import (
8
+ parse_dom_tree, extract_semantic_blocks, dom_signature,
9
+ normalize_html, text_similarity, strip_tags, extract_inline_styles,
10
+ )
11
+ """
12
+ import re
13
+ import hashlib
14
+ from html.parser import HTMLParser
15
+ from difflib import SequenceMatcher
16
+
17
+
18
+ # ─── DOM Tree Parser ──────────────────────────────────────────────────────────
19
+
20
+ class _DOMNode:
21
+ """Lightweight DOM node for tree comparison."""
22
+ __slots__ = ("tag", "attrs", "children", "text", "classes")
23
+
24
+ def __init__(self, tag: str, attrs: dict = None):
25
+ self.tag = tag
26
+ self.attrs = attrs or {}
27
+ self.children = []
28
+ self.text = ""
29
+ self.classes = self.attrs.get("class", "").split()
30
+
31
+ def node_count(self) -> int:
32
+ return 1 + sum(c.node_count() for c in self.children)
33
+
34
+ def to_signature(self) -> str:
35
+ """Tag-only tree string for structural comparison."""
36
+ if not self.children:
37
+ return self.tag
38
+ child_sigs = " ".join(c.to_signature() for c in self.children)
39
+ return f"{self.tag}({child_sigs})"
40
+
41
+ def to_dict(self) -> dict:
42
+ return {
43
+ "tag": self.tag,
44
+ "classes": self.classes,
45
+ "text": self.text.strip()[:200] if self.text else "",
46
+ "children": [c.to_dict() for c in self.children],
47
+ }
48
+
49
+
50
+ class _TreeBuilder(HTMLParser):
51
+ """Build a simplified DOM tree from HTML."""
52
+
53
+ VOID_TAGS = frozenset([
54
+ "area", "base", "br", "col", "embed", "hr", "img", "input",
55
+ "link", "meta", "param", "source", "track", "wbr",
56
+ ])
57
+ SKIP_TAGS = frozenset(["script", "style", "svg", "noscript"])
58
+
59
+ def __init__(self):
60
+ super().__init__()
61
+ self.root = _DOMNode("root")
62
+ self._stack = [self.root]
63
+ self._skip_depth = 0
64
+
65
+ def handle_starttag(self, tag, attrs):
66
+ tag = tag.lower()
67
+ if self._skip_depth > 0:
68
+ if tag not in self.VOID_TAGS:
69
+ self._skip_depth += 1
70
+ return
71
+ if tag in self.SKIP_TAGS:
72
+ self._skip_depth = 1
73
+ return
74
+
75
+ attr_dict = {k: v for k, v in attrs if k and v}
76
+ node = _DOMNode(tag, attr_dict)
77
+ self._stack[-1].children.append(node)
78
+ if tag not in self.VOID_TAGS:
79
+ self._stack.append(node)
80
+
81
+ def handle_endtag(self, tag):
82
+ tag = tag.lower()
83
+ if self._skip_depth > 0:
84
+ self._skip_depth -= 1
85
+ return
86
+ if tag in self.VOID_TAGS or tag in self.SKIP_TAGS:
87
+ return
88
+ if len(self._stack) > 1 and self._stack[-1].tag == tag:
89
+ self._stack.pop()
90
+
91
+ def handle_data(self, data):
92
+ if self._skip_depth > 0:
93
+ return
94
+ text = data.strip()
95
+ if text and self._stack:
96
+ self._stack[-1].text += " " + text
97
+
98
+
99
+ def parse_dom_tree(html: str) -> _DOMNode:
100
+ """Parse HTML into a simplified DOM tree."""
101
+ builder = _TreeBuilder()
102
+ try:
103
+ builder.feed(html)
104
+ except Exception:
105
+ pass
106
+ return builder.root
107
+
108
+
109
+ # ─── Semantic Block Extraction ────────────────────────────────────────────────
110
+
111
+ # Tags and patterns that identify shared components
112
+ SEMANTIC_SELECTORS = {
113
+ "navbar": {
114
+ "tags": ["nav"],
115
+ "roles": ["navigation"],
116
+ "classes": ["nav", "navbar", "navigation", "top-bar", "topbar", "app-bar", "appbar", "header-nav"],
117
+ },
118
+ "header": {
119
+ "tags": ["header"],
120
+ "roles": ["banner"],
121
+ "classes": ["header", "site-header", "page-header", "app-header"],
122
+ },
123
+ "footer": {
124
+ "tags": ["footer"],
125
+ "roles": ["contentinfo"],
126
+ "classes": ["footer", "site-footer", "page-footer", "app-footer"],
127
+ },
128
+ "sidebar": {
129
+ "tags": ["aside"],
130
+ "roles": ["complementary"],
131
+ "classes": ["sidebar", "side-bar", "side-nav", "sidenav", "drawer", "nav-rail"],
132
+ },
133
+ "tabbar": {
134
+ "tags": [],
135
+ "roles": ["tablist"],
136
+ "classes": ["tabbar", "tab-bar", "bottom-nav", "bottom-navigation", "bottom-bar", "bottombar"],
137
+ },
138
+ }
139
+
140
+ # CSS position patterns for fallback detection
141
+ POSITION_PATTERNS = {
142
+ "navbar": re.compile(
143
+ r"position\s*:\s*(?:fixed|sticky)[^;]*;[^}]*top\s*:\s*0",
144
+ re.IGNORECASE | re.DOTALL,
145
+ ),
146
+ "tabbar": re.compile(
147
+ r"position\s*:\s*(?:fixed|sticky)[^;]*;[^}]*bottom\s*:\s*0",
148
+ re.IGNORECASE | re.DOTALL,
149
+ ),
150
+ }
151
+
152
+
153
+ def extract_semantic_blocks(html: str) -> dict:
154
+ """
155
+ Extract semantic component blocks from HTML.
156
+
157
+ Returns dict mapping component type to list of HTML snippets:
158
+ {"navbar": ["<nav>...</nav>"], "footer": ["<footer>...</footer>"], ...}
159
+ """
160
+ results = {}
161
+
162
+ for comp_type, selectors in SEMANTIC_SELECTORS.items():
163
+ blocks = []
164
+
165
+ # 1. Semantic tags
166
+ for tag in selectors["tags"]:
167
+ blocks.extend(_extract_tag_blocks(html, tag))
168
+
169
+ # 2. ARIA roles
170
+ for role in selectors["roles"]:
171
+ blocks.extend(_extract_by_role(html, role))
172
+
173
+ # 3. CSS class patterns
174
+ for cls in selectors["classes"]:
175
+ blocks.extend(_extract_by_class(html, cls))
176
+
177
+ # Deduplicate by content overlap
178
+ unique = _deduplicate_blocks(blocks)
179
+ if unique:
180
+ results[comp_type] = unique
181
+
182
+ # 4. Fallback: position-based detection
183
+ for comp_type, pattern in POSITION_PATTERNS.items():
184
+ if comp_type not in results:
185
+ blocks = _extract_by_position_pattern(html, pattern)
186
+ if blocks:
187
+ results[comp_type] = blocks
188
+
189
+ return results
190
+
191
+
192
+ def _extract_tag_blocks(html: str, tag: str) -> list:
193
+ """Extract all blocks of a given HTML tag."""
194
+ pattern = re.compile(
195
+ rf"<{tag}[\s>].*?</{tag}>",
196
+ re.IGNORECASE | re.DOTALL,
197
+ )
198
+ return pattern.findall(html)
199
+
200
+
201
+ def _extract_by_role(html: str, role: str) -> list:
202
+ """Extract elements with a specific ARIA role."""
203
+ pattern = re.compile(
204
+ rf'<(\w+)[^>]*\brole\s*=\s*["\']?{re.escape(role)}["\']?[^>]*>.*?</\1>',
205
+ re.IGNORECASE | re.DOTALL,
206
+ )
207
+ return [m.group(0) for m in pattern.finditer(html)]
208
+
209
+
210
+ def _extract_by_class(html: str, cls: str) -> list:
211
+ """Extract elements whose class attribute contains the given class name."""
212
+ pattern = re.compile(
213
+ rf'<(\w+)[^>]*\bclass\s*=\s*"[^"]*\b{re.escape(cls)}\b[^"]*"[^>]*>.*?</\1>',
214
+ re.IGNORECASE | re.DOTALL,
215
+ )
216
+ return [m.group(0) for m in pattern.finditer(html)]
217
+
218
+
219
+ def _extract_by_position_pattern(html: str, pattern: re.Pattern) -> list:
220
+ """
221
+ Extract elements whose inline style matches a CSS position pattern.
222
+
223
+ Looks for the pattern in <style> blocks, then tries to find the associated
224
+ element by class name.
225
+ """
226
+ blocks = []
227
+ # Check inline styles on elements
228
+ for m in re.finditer(r'<(\w+)[^>]*style="([^"]*)"[^>]*>.*?</\1>', html, re.DOTALL | re.IGNORECASE):
229
+ if pattern.search(m.group(2)):
230
+ blocks.append(m.group(0))
231
+ return blocks
232
+
233
+
234
+ def _deduplicate_blocks(blocks: list) -> list:
235
+ """Remove blocks that are substrings of other blocks."""
236
+ if len(blocks) <= 1:
237
+ return blocks
238
+ sorted_blocks = sorted(blocks, key=len, reverse=True)
239
+ unique = []
240
+ for b in sorted_blocks:
241
+ if not any(b in u for u in unique if u != b):
242
+ unique.append(b)
243
+ return unique
244
+
245
+
246
+ # ─── Comparison Utilities ─────────────────────────────────────────────────────
247
+
248
+ def dom_signature(html: str) -> str:
249
+ """Generate a tag-only tree signature for structural comparison."""
250
+ tree = parse_dom_tree(html)
251
+ if tree.children:
252
+ return " ".join(c.to_signature() for c in tree.children)
253
+ return ""
254
+
255
+
256
+ def normalize_html(html: str) -> str:
257
+ """
258
+ Normalize HTML for deduplication hashing.
259
+
260
+ Strips whitespace, removes variable content (text nodes), keeps structure.
261
+ """
262
+ # Remove all text content between tags
263
+ normalized = re.sub(r">\s+<", "><", html)
264
+ # Remove whitespace
265
+ normalized = re.sub(r"\s+", " ", normalized).strip()
266
+ # Remove text between tags (keep only structure)
267
+ normalized = re.sub(r">([^<]+)<", "><", normalized)
268
+ return normalized
269
+
270
+
271
+ def html_hash(html: str) -> str:
272
+ """Hash of normalized HTML for deduplication."""
273
+ return hashlib.md5(normalize_html(html).encode("utf-8")).hexdigest()
274
+
275
+
276
+ def text_similarity(a: str, b: str) -> float:
277
+ """Text similarity ratio using SequenceMatcher (0.0 to 1.0)."""
278
+ if not a and not b:
279
+ return 1.0
280
+ if not a or not b:
281
+ return 0.0
282
+ return SequenceMatcher(None, a, b).ratio()
283
+
284
+
285
+ def tree_similarity(sig_a: str, sig_b: str) -> float:
286
+ """Structural similarity between two DOM signatures."""
287
+ return text_similarity(sig_a, sig_b)
288
+
289
+
290
+ def class_overlap(classes_a: list, classes_b: list) -> float:
291
+ """Jaccard similarity of CSS class lists."""
292
+ set_a = set(classes_a)
293
+ set_b = set(classes_b)
294
+ if not set_a and not set_b:
295
+ return 1.0
296
+ if not set_a or not set_b:
297
+ return 0.0
298
+ intersection = set_a & set_b
299
+ union = set_a | set_b
300
+ return len(intersection) / len(union)
301
+
302
+
303
+ def component_similarity(html_a: str, html_b: str) -> float:
304
+ """
305
+ Weighted similarity score between two component HTML snippets.
306
+
307
+ Weights: tree structure 50% + CSS classes 30% + visible text 20%
308
+ """
309
+ sig_a = dom_signature(html_a)
310
+ sig_b = dom_signature(html_b)
311
+ tree_score = tree_similarity(sig_a, sig_b)
312
+
313
+ classes_a = _extract_all_classes(html_a)
314
+ classes_b = _extract_all_classes(html_b)
315
+ class_score = class_overlap(classes_a, classes_b)
316
+
317
+ text_a = strip_tags(html_a)
318
+ text_b = strip_tags(html_b)
319
+ text_score = text_similarity(text_a, text_b)
320
+
321
+ return tree_score * 0.5 + class_score * 0.3 + text_score * 0.2
322
+
323
+
324
+ def _extract_all_classes(html: str) -> list:
325
+ """Extract all CSS class names from HTML."""
326
+ classes = []
327
+ for m in re.finditer(r'class="([^"]*)"', html, re.IGNORECASE):
328
+ classes.extend(m.group(1).split())
329
+ return classes
330
+
331
+
332
+ # ─── Text & Style Extraction ─────────────────────────────────────────────────
333
+
334
+ def strip_tags(html: str) -> str:
335
+ """Remove all HTML tags, decode entities, normalize whitespace."""
336
+ from html import unescape
337
+ text = re.sub(r"<(script|style|svg|noscript)[^>]*>.*?</\1>", " ", html, flags=re.DOTALL | re.IGNORECASE)
338
+ text = re.sub(r"<[^>]+>", " ", text)
339
+ text = unescape(text)
340
+ text = re.sub(r"\s+", " ", text).strip()
341
+ return text
342
+
343
+
344
+ def extract_inline_styles(html: str) -> dict:
345
+ """
346
+ Extract common CSS properties from inline styles in the outermost element.
347
+
348
+ Returns dict with keys like 'background-color', 'color', 'border-radius', etc.
349
+ """
350
+ m = re.search(r'style="([^"]*)"', html, re.IGNORECASE)
351
+ if not m:
352
+ return {}
353
+
354
+ style_str = m.group(1)
355
+ props = {}
356
+ for prop in style_str.split(";"):
357
+ prop = prop.strip()
358
+ if ":" in prop:
359
+ key, val = prop.split(":", 1)
360
+ props[key.strip().lower()] = val.strip()
361
+ return props
362
+
363
+
364
+ def extract_css_classes_from_block(html: str) -> list:
365
+ """Extract CSS classes from the outermost element of an HTML block."""
366
+ m = re.match(r'<\w+[^>]*class="([^"]*)"', html, re.IGNORECASE)
367
+ if m:
368
+ return m.group(1).split()
369
+ return []
370
+
371
+
372
+ def detect_button_variant(html: str, classes: list) -> str:
373
+ """Detect button variant (primary/secondary/danger/outline/ghost) from classes and styles."""
374
+ cls_str = " ".join(classes).lower()
375
+ if any(k in cls_str for k in ("primary", "btn-primary", "cta")):
376
+ return "primary"
377
+ if any(k in cls_str for k in ("secondary", "btn-secondary")):
378
+ return "secondary"
379
+ if any(k in cls_str for k in ("danger", "destructive", "btn-danger", "btn-red", "error")):
380
+ return "danger"
381
+ if any(k in cls_str for k in ("outline", "bordered", "btn-outline")):
382
+ return "outline"
383
+ if any(k in cls_str for k in ("ghost", "text", "link", "btn-ghost", "btn-text")):
384
+ return "ghost"
385
+
386
+ # Check inline styles for background color hints
387
+ styles = extract_inline_styles(html)
388
+ bg = styles.get("background-color", "") + styles.get("background", "")
389
+ if any(c in bg.lower() for c in ("red", "#e", "#f44", "#ef4", "rgb(239", "rgb(244")):
390
+ return "danger"
391
+
392
+ return "default"
393
+
394
+
395
+ def count_dom_nodes(html: str) -> int:
396
+ """Count DOM nodes in an HTML snippet."""
397
+ tree = parse_dom_tree(html)
398
+ return tree.node_count()
@@ -0,0 +1,284 @@
1
+ """
2
+ detect_components.py — Detect shared components across Stitch screen HTMLs.
3
+
4
+ Finds navbars, footers, tabbars, sidebars, and headers that appear across
5
+ multiple screens. Compares variants and recommends a canonical version.
6
+
7
+ Uses only stdlib (html.parser, difflib, re, json). No external dependencies.
8
+
9
+ Usage:
10
+ # As a module (from build_showcase.py):
11
+ from detect_components import detect_shared_components
12
+
13
+ # Standalone:
14
+ python detect_components.py /path/to/assets/
15
+ """
16
+ import re
17
+ import sys
18
+ import json
19
+ from pathlib import Path
20
+
21
+ # Sibling import
22
+ sys.path.insert(0, str(Path(__file__).parent))
23
+ import component_utils as cu
24
+
25
+
26
+ # Screens containing these slugs get priority when choosing canonical version
27
+ HOME_PRIORITY_SLUGS = frozenset([
28
+ "home", "main", "dashboard", "inicio", "principal", "landing",
29
+ "index", "home_screen", "pantalla_principal",
30
+ ])
31
+
32
+ # Minimum screens a component must appear in to be considered "shared"
33
+ MIN_SHARED_SCREENS = 2
34
+
35
+ # Default similarity threshold for grouping variants
36
+ DEFAULT_SIMILARITY_THRESHOLD = 0.85
37
+
38
+
39
+ def detect_shared_components(
40
+ assets_dir: Path,
41
+ threshold: float = DEFAULT_SIMILARITY_THRESHOLD,
42
+ ) -> dict:
43
+ """
44
+ Detect shared components across all screen HTMLs in assets_dir.
45
+
46
+ Args:
47
+ assets_dir: Directory containing screen .html files
48
+ threshold: Minimum similarity score to group as same component (0.0-1.0)
49
+
50
+ Returns:
51
+ Dict with component types as keys, each containing:
52
+ - found_in: number of screens with this component
53
+ - total_screens: total screens analyzed
54
+ - canonical: recommended version with slug, html_snippet, score
55
+ - variants: list of variant diffs
56
+ """
57
+ html_files = sorted(assets_dir.glob("*.html"))
58
+ if not html_files:
59
+ return {}
60
+
61
+ total_screens = len(html_files)
62
+
63
+ # Step 1: Extract semantic blocks from each screen
64
+ screen_components = {}
65
+ for html_path in html_files:
66
+ slug = html_path.stem
67
+ try:
68
+ html = html_path.read_text(encoding="utf-8", errors="replace")
69
+ except Exception:
70
+ continue
71
+
72
+ blocks = cu.extract_semantic_blocks(html)
73
+ if blocks:
74
+ screen_components[slug] = blocks
75
+
76
+ if not screen_components:
77
+ return {}
78
+
79
+ # Step 2: For each component type, collect across screens and group variants
80
+ results = {}
81
+ component_types = set()
82
+ for blocks in screen_components.values():
83
+ component_types.update(blocks.keys())
84
+
85
+ for comp_type in sorted(component_types):
86
+ # Collect all instances of this component type
87
+ instances = []
88
+ for slug, blocks in screen_components.items():
89
+ if comp_type in blocks:
90
+ for block_html in blocks[comp_type]:
91
+ instances.append({
92
+ "slug": slug,
93
+ "html": block_html,
94
+ "signature": cu.dom_signature(block_html),
95
+ "node_count": cu.count_dom_nodes(block_html),
96
+ "text": cu.strip_tags(block_html),
97
+ })
98
+
99
+ if len(instances) < MIN_SHARED_SCREENS:
100
+ continue
101
+
102
+ # Step 3: Group similar instances
103
+ groups = _group_by_similarity(instances, threshold)
104
+
105
+ # Only keep groups that span multiple screens
106
+ for group in groups:
107
+ screen_slugs = list(dict.fromkeys(inst["slug"] for inst in group))
108
+ if len(screen_slugs) < MIN_SHARED_SCREENS:
109
+ continue
110
+
111
+ # Step 4: Choose canonical version
112
+ canonical = _choose_canonical(group)
113
+
114
+ # Step 5: Build variant list with differences
115
+ variants = _build_variants(group, canonical)
116
+
117
+ results[f"{comp_type}s"] = {
118
+ "found_in": len(screen_slugs),
119
+ "total_screens": total_screens,
120
+ "canonical": {
121
+ "slug": canonical["slug"],
122
+ "html_snippet": _truncate_html(canonical["html"], 2000),
123
+ "node_count": canonical["node_count"],
124
+ "score": 1.0,
125
+ },
126
+ "variants": variants,
127
+ }
128
+
129
+ return results
130
+
131
+
132
+ def _group_by_similarity(instances: list, threshold: float) -> list:
133
+ """
134
+ Group component instances by similarity score.
135
+
136
+ Uses greedy clustering: assign each instance to the first group
137
+ whose representative it matches above threshold.
138
+ """
139
+ groups = []
140
+
141
+ for inst in instances:
142
+ placed = False
143
+ for group in groups:
144
+ rep = group[0]
145
+ score = cu.component_similarity(rep["html"], inst["html"])
146
+ if score >= threshold:
147
+ group.append(inst)
148
+ placed = True
149
+ break
150
+
151
+ if not placed:
152
+ groups.append([inst])
153
+
154
+ return groups
155
+
156
+
157
+ def _choose_canonical(group: list) -> dict:
158
+ """
159
+ Choose the canonical (best) version of a component.
160
+
161
+ Priority:
162
+ 1. From a "home" or "main" screen
163
+ 2. Most DOM nodes (most complete version)
164
+ 3. First encountered
165
+ """
166
+ # Sort by: home priority (desc), node count (desc)
167
+ def sort_key(inst):
168
+ is_home = any(h in inst["slug"] for h in HOME_PRIORITY_SLUGS)
169
+ return (-int(is_home), -inst["node_count"])
170
+
171
+ sorted_group = sorted(group, key=sort_key)
172
+ return sorted_group[0]
173
+
174
+
175
+ def _build_variants(group: list, canonical: dict) -> list:
176
+ """Build variant entries comparing each screen's version to canonical."""
177
+ seen_slugs = set()
178
+ variants = []
179
+
180
+ for inst in group:
181
+ if inst["slug"] == canonical["slug"]:
182
+ continue
183
+ if inst["slug"] in seen_slugs:
184
+ continue
185
+ seen_slugs.add(inst["slug"])
186
+
187
+ similarity = cu.component_similarity(canonical["html"], inst["html"])
188
+ differences = _describe_differences(canonical, inst)
189
+
190
+ variants.append({
191
+ "slug": inst["slug"],
192
+ "similarity": round(similarity, 3),
193
+ "differences": differences,
194
+ "node_count": inst["node_count"],
195
+ })
196
+
197
+ # Sort by similarity descending
198
+ variants.sort(key=lambda v: -v["similarity"])
199
+ return variants
200
+
201
+
202
+ def _describe_differences(canonical: dict, variant: dict) -> str:
203
+ """Generate a human-readable description of differences between two component versions."""
204
+ diffs = []
205
+
206
+ # Node count difference
207
+ node_diff = variant["node_count"] - canonical["node_count"]
208
+ if node_diff < -2:
209
+ diffs.append(f"{abs(node_diff)} fewer DOM nodes")
210
+ elif node_diff > 2:
211
+ diffs.append(f"{node_diff} more DOM nodes")
212
+
213
+ # Text content differences
214
+ canon_text = canonical["text"]
215
+ var_text = variant["text"]
216
+ if canon_text != var_text:
217
+ # Find missing/added text fragments
218
+ canon_words = set(canon_text.lower().split())
219
+ var_words = set(var_text.lower().split())
220
+ missing = canon_words - var_words
221
+ added = var_words - canon_words
222
+
223
+ # Filter noise (single chars, numbers)
224
+ missing = {w for w in missing if len(w) > 2 and not w.isdigit()}
225
+ added = {w for w in added if len(w) > 2 and not w.isdigit()}
226
+
227
+ if missing and len(missing) <= 5:
228
+ diffs.append(f"Missing text: {', '.join(sorted(missing)[:3])}")
229
+ if added and len(added) <= 5:
230
+ diffs.append(f"Added text: {', '.join(sorted(added)[:3])}")
231
+
232
+ # Class differences
233
+ canon_classes = set(cu._extract_all_classes(canonical["html"]))
234
+ var_classes = set(cu._extract_all_classes(variant["html"]))
235
+ class_diff = canon_classes.symmetric_difference(var_classes)
236
+ if class_diff and len(class_diff) <= 8:
237
+ diffs.append(f"Different classes: {len(class_diff)} changed")
238
+
239
+ if not diffs:
240
+ # Structure check
241
+ if canonical["signature"] != variant.get("signature", ""):
242
+ diffs.append("Different DOM structure")
243
+ else:
244
+ diffs.append("Minor text/style differences")
245
+
246
+ return "; ".join(diffs)
247
+
248
+
249
+ def _truncate_html(html: str, max_len: int) -> str:
250
+ """Truncate HTML to max_len chars, trying to close at a tag boundary."""
251
+ if len(html) <= max_len:
252
+ return html
253
+
254
+ truncated = html[:max_len]
255
+ # Try to close at the last complete tag
256
+ last_close = truncated.rfind(">")
257
+ if last_close > max_len * 0.7:
258
+ truncated = truncated[:last_close + 1]
259
+
260
+ return truncated + "<!-- truncated -->"
261
+
262
+
263
+ # ─── CLI ──────────────────────────────────────────────────────────────────────
264
+
265
+ if __name__ == "__main__":
266
+ if len(sys.argv) < 2:
267
+ print("Usage: python detect_components.py /path/to/assets/", file=sys.stderr)
268
+ sys.exit(1)
269
+
270
+ assets = Path(sys.argv[1]).resolve()
271
+ if not assets.is_dir():
272
+ print(f"Error: '{assets}' is not a directory.", file=sys.stderr)
273
+ sys.exit(1)
274
+
275
+ threshold = float(sys.argv[2]) if len(sys.argv) > 2 else DEFAULT_SIMILARITY_THRESHOLD
276
+
277
+ result = detect_shared_components(assets, threshold)
278
+ if not result:
279
+ print("No shared components detected.", file=sys.stderr)
280
+ sys.exit(0)
281
+
282
+ print(json.dumps(result, ensure_ascii=False, indent=2))
283
+ comp_count = sum(1 for v in result.values() if v["found_in"] >= MIN_SHARED_SCREENS)
284
+ print(f"\n--- {comp_count} shared component type(s) detected ---", file=sys.stderr)