@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,2103 @@
1
+ """
2
+ build_showcase.py — Main orchestrator for stitch-showcase.
3
+
4
+ Generates a navigable showcase from Google Stitch exports.
5
+
6
+ Default mode (--context): extracts data and writes showcase_context.json
7
+ for AI-driven HTML generation.
8
+
9
+ Legacy mode (--legacy): generates index.html + viewer.html from fixed
10
+ HTML templates (deprecated).
11
+
12
+ Usage:
13
+ python build_showcase.py /path/to/folder-with-zips # → context JSON
14
+ python build_showcase.py /path/to/folder-with-zips --context # same (explicit)
15
+ python build_showcase.py /path/to/folder-with-zips --legacy # old template mode
16
+ python build_showcase.py /path/to/folder-with-zips --type mobile
17
+ python build_showcase.py /path/to/folder-with-zips --name "My App"
18
+ python build_showcase.py /path/to/folder-with-zips --watch
19
+ python build_showcase.py /path/to/folder-with-zips --init
20
+ """
21
+ import sys
22
+ import re
23
+ import json
24
+ import time
25
+ import shutil
26
+ import hashlib
27
+ import argparse
28
+ from pathlib import Path
29
+ from html import escape
30
+
31
+ SKILL_VERSION = "1.5.0"
32
+
33
+ # Locate sibling scripts and references
34
+ SCRIPTS_DIR = Path(__file__).parent
35
+ REFERENCES_DIR = SCRIPTS_DIR.parent / "references"
36
+
37
+ sys.path.insert(0, str(SCRIPTS_DIR))
38
+ import extract_zips
39
+ import parse_design_md
40
+ import extract_text
41
+ import detect_components
42
+ import extract_catalog
43
+
44
+
45
+ def _extract_source_zip(zip_path: Path) -> Path:
46
+ """
47
+ Extract a Stitch mega-zip and return the path to use as source.
48
+
49
+ Handles two layouts:
50
+ A) zip contains individual screen folders at the root
51
+ → extract next to the zip, use extraction root
52
+ B) zip contains a single parent folder (stitch/) with screen subfolders inside
53
+ → return that single parent folder
54
+ C) zip contains a flat code.html + screen.png (single screen)
55
+ → return the extraction folder as-is
56
+ """
57
+ import zipfile
58
+
59
+ dest = zip_path.parent / zip_path.stem
60
+ dest.mkdir(exist_ok=True)
61
+
62
+ with zipfile.ZipFile(zip_path, "r") as zf:
63
+ zf.extractall(dest)
64
+
65
+ # If extraction produced a single subdirectory, descend into it
66
+ children = [c for c in dest.iterdir() if not c.name.startswith(".")]
67
+ if len(children) == 1 and children[0].is_dir():
68
+ inner = children[0]
69
+ # Check if DESIGN.md is nested one level deeper (e.g. stitch/snap_kinetic/DESIGN.md)
70
+ nested_design = [d for d in inner.rglob("DESIGN.md") if d.parent != inner]
71
+ if nested_design:
72
+ # Promote the first nested DESIGN.md to the inner folder level
73
+ promoted = inner / "DESIGN.md"
74
+ if not promoted.exists():
75
+ import shutil as _shutil
76
+ _shutil.copy2(nested_design[0], promoted)
77
+ print(f" ℹ DESIGN.md promoted from {nested_design[0].parent.name}/")
78
+ print(f" ℹ Mega-zip extracted → using {inner}")
79
+ return inner
80
+
81
+ print(f" ℹ Zip extracted → using {dest}")
82
+ return dest
83
+
84
+
85
+ def build(source_dir: str, project_type: str = None, project_name: str = None) -> Path:
86
+ """
87
+ Build the complete showcase (index.html + viewer.html) from templates.
88
+
89
+ Args:
90
+ source_dir: Folder containing Stitch zips
91
+ project_type: 'mobile' or 'web' (auto-detected if None)
92
+ project_name: Project name (extracted from DESIGN.md if None)
93
+
94
+ Returns:
95
+ Path to the generated output directory.
96
+ """
97
+ source, metadata, ptype, output_dir, assets_dir = _prepare_source(
98
+ source_dir, project_type, project_name
99
+ )
100
+
101
+ # Extract zips and copy assets
102
+ print("\n🗜 Extracting screens...")
103
+ screens_raw = extract_zips.extract_all(str(source), str(assets_dir))
104
+
105
+ if not screens_raw:
106
+ print("Error: no screens found (no zips or folders with code.html).", file=sys.stderr)
107
+ sys.exit(1)
108
+
109
+ # Enrich with DESIGN.md metadata
110
+ screens = _enrich_screens(screens_raw, metadata, source)
111
+
112
+ # If no font_family from DESIGN.md, try to extract from screen HTML files
113
+ if not metadata.get("font_family"):
114
+ extracted_font = _extract_font_from_html_screens(screens_raw)
115
+ if extracted_font:
116
+ metadata["font_family"] = extracted_font
117
+ print(f" ℹ Font extracted from screens: {extracted_font}")
118
+
119
+ # Resolve pending type from screen HTML analysis (majority vote)
120
+ if metadata.pop("_type_pending", False):
121
+ ptype = _detect_type_from_screens(screens, assets_dir)
122
+ metadata["type"] = ptype
123
+ print(f"📱 Type: {ptype} (detected from screens) | Project: {metadata['project_name']}")
124
+
125
+ print(f"\n✅ {len(screens)} screens processed")
126
+
127
+ # Copy DESIGN.md if present
128
+ design_md = source / "DESIGN.md"
129
+ if design_md.exists():
130
+ shutil.copy2(design_md, output_dir / "DESIGN.md")
131
+
132
+ # Generate viewer.html
133
+ _generate_viewer(output_dir, ptype, metadata, screens)
134
+
135
+ # Generate index.html (with catalog link)
136
+ _generate_index(output_dir, ptype, metadata, screens)
137
+
138
+ # Generate catalog.html (always included)
139
+ _generate_catalog(output_dir, assets_dir, metadata)
140
+
141
+ print(f"\n🎉 Showcase ready: {output_dir}/index.html\n")
142
+ return output_dir
143
+
144
+
145
+ def build_context(source_dir: str, project_type: str = None, project_name: str = None) -> Path:
146
+ """
147
+ Generate showcase_context.json for AI-driven HTML generation.
148
+
149
+ Use with --context flag. Extracts data but does NOT generate HTML.
150
+ """
151
+ source, metadata, ptype, output_dir, assets_dir = _prepare_source(
152
+ source_dir, project_type, project_name
153
+ )
154
+
155
+ # Extract zips and copy assets
156
+ print("\n🗜 Extracting screens...")
157
+ screens_raw = extract_zips.extract_all(str(source), str(assets_dir))
158
+
159
+ if not screens_raw:
160
+ print("Error: no screens found (no zips or folders with code.html).", file=sys.stderr)
161
+ sys.exit(1)
162
+
163
+ # Enrich with DESIGN.md metadata
164
+ screens = _enrich_screens(screens_raw, metadata, source)
165
+
166
+ # If no font_family from DESIGN.md, try to extract from screen HTML files
167
+ if not metadata.get("font_family"):
168
+ extracted_font = _extract_font_from_html_screens(screens_raw)
169
+ if extracted_font:
170
+ metadata["font_family"] = extracted_font
171
+ print(f" ℹ Font extracted from screens: {extracted_font}")
172
+
173
+ # Detect per-screen type (web vs mobile)
174
+ for screen in screens:
175
+ html_path = assets_dir / f"{screen['slug']}.html"
176
+ screen["detected_type"] = _detect_screen_type(html_path)
177
+
178
+ # Resolve pending type from screen HTML analysis (majority vote)
179
+ if metadata.pop("_type_pending", False):
180
+ ptype = _detect_type_from_screens(screens, assets_dir)
181
+ metadata["type"] = ptype
182
+ print(f"📱 Type: {ptype} (detected from screens) | Project: {metadata['project_name']}")
183
+
184
+ print(f"\n✅ {len(screens)} screens processed")
185
+
186
+ # Copy DESIGN.md if present
187
+ design_md = source / "DESIGN.md"
188
+ if design_md.exists():
189
+ shutil.copy2(design_md, output_dir / "DESIGN.md")
190
+
191
+ # Read raw DESIGN.md text for AI context
192
+ design_md_raw = _read_design_md_raw(source)
193
+
194
+ # Generate context JSON
195
+ _generate_context(output_dir, metadata, screens, ptype, design_md_raw)
196
+
197
+ print(f"\n🎉 Context ready: {output_dir}/showcase_context.json")
198
+ print(f" Assets in: {output_dir}/assets/")
199
+ print(f" AI should now read reference MDs and generate index.html + viewer.html\n")
200
+ return output_dir
201
+
202
+
203
+ def _dir_has_screens(path: Path) -> bool:
204
+ """Check if a directory contains Stitch screens (zips or folders with code.html)."""
205
+ if any(path.glob("*.zip")):
206
+ return True
207
+ return any(d.is_dir() and (d / "code.html").exists() for d in path.iterdir()
208
+ if not d.name.startswith("."))
209
+
210
+
211
+ def _load_showcase_json(start: Path) -> dict | None:
212
+ """
213
+ Search for showcase.json in start and its parent. Returns parsed dict or None.
214
+ """
215
+ for candidate in (start, start.parent):
216
+ config_path = candidate / "showcase.json"
217
+ if config_path.exists():
218
+ try:
219
+ config = json.loads(config_path.read_text(encoding="utf-8"))
220
+ config["_config_dir"] = str(candidate)
221
+ print(f" ℹ showcase.json found in {candidate}")
222
+ return config
223
+ except (json.JSONDecodeError, OSError) as e:
224
+ print(f" ⚠ showcase.json found but invalid: {e}", file=sys.stderr)
225
+ return None
226
+
227
+
228
+ def _discover_source(given: Path) -> Path:
229
+ """
230
+ Resolve the actual source directory containing Stitch screens.
231
+
232
+ Discovery order:
233
+ 1. If the given path itself has screens → use it
234
+ 2. If showcase.json exists (in given or parent) with a "source" key → use it
235
+ 3. Auto-discover: scan subdirectories for screens (one level deep)
236
+ 4. Fail with a helpful error
237
+ """
238
+ # 1. Direct match
239
+ if _dir_has_screens(given):
240
+ return given
241
+
242
+ # 2. showcase.json
243
+ config = _load_showcase_json(given)
244
+ if config:
245
+ config_dir = Path(config["_config_dir"])
246
+ source_rel = config.get("source")
247
+ if source_rel:
248
+ resolved = (config_dir / source_rel).resolve()
249
+ if resolved.exists() and _dir_has_screens(resolved):
250
+ print(f" ℹ Source resolved from showcase.json → {resolved}")
251
+ return resolved
252
+ else:
253
+ print(f" ⚠ showcase.json source '{source_rel}' has no screens at {resolved}",
254
+ file=sys.stderr)
255
+
256
+ # 3. Auto-discover subdirectories
257
+ candidates = []
258
+ for child in sorted(given.iterdir()):
259
+ if child.is_dir() and not child.name.startswith(".") and child.name not in ("showcase", "showcase-mobile", "showcase-web"):
260
+ if _dir_has_screens(child):
261
+ candidates.append(child)
262
+
263
+ if len(candidates) == 1:
264
+ print(f" ℹ Auto-discovered source → {candidates[0]}")
265
+ return candidates[0]
266
+ elif len(candidates) > 1:
267
+ names = ", ".join(c.name for c in candidates)
268
+ print(f"Error: multiple subdirectories with screens found: {names}", file=sys.stderr)
269
+ print(f" Create a showcase.json with {{\"source\": \"<folder>\"}} to specify which one.",
270
+ file=sys.stderr)
271
+ sys.exit(1)
272
+
273
+ # 4. Nothing found — helpful error
274
+ print(f"Error: no screens found (no zips or folders with code.html).", file=sys.stderr)
275
+ print(f"\n Searched: {given}", file=sys.stderr)
276
+ print(f" Tip: create a showcase.json in your project root:", file=sys.stderr)
277
+ print(f' {{"source": "stitch", "type": "mobile", "name": "My App"}}', file=sys.stderr)
278
+ sys.exit(1)
279
+
280
+
281
+ def _prepare_source(source_dir: str, project_type: str, project_name: str) -> tuple:
282
+ """
283
+ Common setup for both build modes: resolve source, parse metadata, create output dir.
284
+
285
+ Returns (source, metadata, ptype, output_dir, assets_dir).
286
+ """
287
+ source = Path(source_dir).resolve()
288
+ if not source.exists():
289
+ print(f"Error: '{source}' does not exist.", file=sys.stderr)
290
+ sys.exit(1)
291
+
292
+ if source.suffix == ".zip":
293
+ source = _extract_source_zip(source)
294
+
295
+ # Discover actual source if given path has no screens
296
+ source = _discover_source(source)
297
+
298
+ # Load showcase.json config for type/name defaults
299
+ config = _load_showcase_json(source) or _load_showcase_json(source.parent) or {}
300
+
301
+ print(f"\n📁 Source: {source}")
302
+
303
+ # Parse DESIGN.md
304
+ design_md = source / "DESIGN.md"
305
+ metadata = parse_design_md.parse(str(design_md)) if design_md.exists() else {}
306
+
307
+ # Priority: CLI args > showcase.json > DESIGN.md > defaults
308
+ if project_name:
309
+ metadata["project_name"] = project_name
310
+ elif not metadata.get("project_name") and config.get("name"):
311
+ metadata["project_name"] = config["name"]
312
+ if not metadata.get("project_name"):
313
+ metadata["project_name"] = source.name.replace("_", " ").replace("-", " ").title()
314
+
315
+ if project_type:
316
+ metadata["type"] = project_type
317
+ elif metadata.get("type", "unknown") == "unknown" and config.get("type"):
318
+ metadata["type"] = config["type"]
319
+ if metadata.get("type", "unknown") == "unknown":
320
+ # Defer type detection — will resolve after screen extraction via _detect_type_from_screens()
321
+ metadata["_type_pending"] = True
322
+
323
+ ptype = metadata.get("type", "unknown")
324
+ if ptype != "unknown":
325
+ print(f"📱 Type: {ptype} | Project: {metadata['project_name']}")
326
+
327
+ # Create output directory — next to the project root, not inside source
328
+ # Use the config dir (showcase.json location) or source parent as the project root
329
+ project_root = Path(config["_config_dir"]) if "_config_dir" in config else source.parent
330
+ output_dir = project_root / "showcase"
331
+ output_dir.mkdir(exist_ok=True)
332
+ assets_dir = output_dir / "assets"
333
+ assets_dir.mkdir(exist_ok=True)
334
+
335
+ print(f"📂 Output: {output_dir}")
336
+
337
+ return source, metadata, ptype, output_dir, assets_dir
338
+
339
+
340
+ def _generate_context(output_dir: Path, metadata: dict, screens: list, ptype: str, design_md_raw: str) -> None:
341
+ """
342
+ Write showcase_context.json with all data the AI needs to generate HTML.
343
+ """
344
+ sections = metadata.get("sections", [])
345
+
346
+ # Extract design system screen
347
+ ds_screen, filtered_screens = _extract_design_system_screen(screens)
348
+
349
+ # Build sections with their screens
350
+ section_data = []
351
+ covered_slugs = set()
352
+ slug_to_screen = {s["slug"]: s for s in filtered_screens}
353
+
354
+ for sec in sections:
355
+ sec_screens = [slug_to_screen[sl] for sl in sec["slugs"] if sl in slug_to_screen]
356
+ covered_slugs.update(sec["slugs"])
357
+ if sec_screens:
358
+ key = re.sub(r"[^a-z0-9]+", "-", sec["name"].lower()).strip("-")
359
+ section_data.append({
360
+ "name": sec["name"],
361
+ "key": key,
362
+ "screens": sec_screens,
363
+ })
364
+
365
+ # Remaining screens not in any section
366
+ remaining = [s for s in filtered_screens if s["slug"] not in covered_slugs]
367
+ if remaining:
368
+ section_data.append({
369
+ "name": "Other screens",
370
+ "key": "other",
371
+ "screens": remaining,
372
+ })
373
+
374
+ # Build flat screen list for viewer prev/next
375
+ all_screens_flat = []
376
+ for sec in section_data:
377
+ all_screens_flat.extend(sec["screens"])
378
+ # Add any screens only in the flat list
379
+ flat_slugs = {s["slug"] for s in all_screens_flat}
380
+ for s in filtered_screens:
381
+ if s["slug"] not in flat_slugs:
382
+ all_screens_flat.append(s)
383
+
384
+ context = {
385
+ "skill_version": SKILL_VERSION,
386
+ "project_name": metadata["project_name"],
387
+ "type": ptype,
388
+ "screen_count": len(filtered_screens),
389
+ "font_family": metadata.get("font_family"),
390
+ "colors": metadata.get("colors", {}),
391
+ "color_tokens": metadata.get("color_tokens", {}),
392
+ "default_theme": metadata.get("default_theme", "light"),
393
+ "design_system_screen": ds_screen,
394
+ "design_md_raw": design_md_raw,
395
+ "sections": section_data,
396
+ "all_screens": all_screens_flat,
397
+ }
398
+
399
+ context_path = output_dir / "showcase_context.json"
400
+ context_path.write_text(
401
+ json.dumps(context, ensure_ascii=False, indent=2),
402
+ encoding="utf-8"
403
+ )
404
+ print(f" ✓ showcase_context.json generated ({len(all_screens_flat)} screens)")
405
+
406
+
407
+ def _detect_screen_type(html_path: Path) -> str:
408
+ """
409
+ Analyze a single screen HTML to detect if it's a mobile or web design.
410
+
411
+ Returns 'mobile', 'web', or 'unknown'.
412
+
413
+ Heuristics:
414
+ - viewport user-scalable=no or maximum-scale=1 → mobile
415
+ - Fixed widths 375-430px in CSS → mobile
416
+ - Media queries with desktop breakpoints, sidebars → web
417
+ - Wide fixed widths (>900px) → web
418
+ """
419
+ if not html_path.exists():
420
+ return "unknown"
421
+
422
+ try:
423
+ text = html_path.read_text(encoding="utf-8", errors="ignore")
424
+ except Exception:
425
+ return "unknown"
426
+
427
+ mobile_score = 0
428
+ web_score = 0
429
+
430
+ # Viewport hints
431
+ if re.search(r'user-scalable\s*=\s*no', text, re.IGNORECASE):
432
+ mobile_score += 2
433
+ if re.search(r'maximum-scale\s*=\s*1', text, re.IGNORECASE):
434
+ mobile_score += 1
435
+
436
+ # Fixed mobile widths (375, 390, 393, 412, 414, 428, 430)
437
+ if re.search(r'(?:width|max-width)\s*:\s*(375|390|393|412|414|428|430)px', text):
438
+ mobile_score += 2
439
+
440
+ # Desktop breakpoints in media queries
441
+ if re.search(r'@media[^{]*min-width\s*:\s*(768|1024|1200|1280|1440)px', text):
442
+ web_score += 2
443
+
444
+ # Sidebar patterns (common in web dashboards)
445
+ if re.search(r'(?:sidebar|side-bar|nav-rail|drawer)', text, re.IGNORECASE):
446
+ web_score += 1
447
+
448
+ # Wide fixed widths
449
+ if re.search(r'(?:width|max-width)\s*:\s*(?:9\d\d|1[0-9]\d\d|1[4-9]\d\d)px', text):
450
+ web_score += 1
451
+
452
+ if mobile_score > web_score:
453
+ return "mobile"
454
+ if web_score > mobile_score:
455
+ return "web"
456
+ return "unknown"
457
+
458
+
459
+ def _detect_type_from_screens(screens: list, assets_dir: Path) -> str:
460
+ """Majority vote from per-screen type detection. Falls back to 'mobile'."""
461
+ votes = {"mobile": 0, "web": 0}
462
+ for screen in screens:
463
+ dt = screen.get("detected_type")
464
+ if not dt or dt == "unknown":
465
+ html_path = assets_dir / f"{screen['slug']}.html"
466
+ dt = _detect_screen_type(html_path)
467
+ if dt in votes:
468
+ votes[dt] += 1
469
+ if votes["web"] > votes["mobile"]:
470
+ return "web"
471
+ if votes["mobile"] > votes["web"]:
472
+ return "mobile"
473
+ # Tie or all unknown — default to mobile
474
+ print("⚠ Could not detect type (mobile/web). Defaulting to mobile.", file=sys.stderr)
475
+ return "mobile"
476
+
477
+
478
+ def _read_design_md_raw(source: Path) -> str:
479
+ """Read the full DESIGN.md text, or return empty string if absent."""
480
+ design_md = source / "DESIGN.md"
481
+ if design_md.exists():
482
+ try:
483
+ return design_md.read_text(encoding="utf-8")
484
+ except Exception:
485
+ return ""
486
+ return ""
487
+
488
+
489
+ def _enrich_screens(screens_raw: list, metadata: dict, source: Path) -> list:
490
+ """Add title and description to each screen from DESIGN.md or inferred."""
491
+ design_screens = {s["slug"]: s for s in metadata.get("screens", [])}
492
+
493
+ enriched = []
494
+ for raw in screens_raw:
495
+ slug = raw["slug"]
496
+ design = design_screens.get(slug, {})
497
+
498
+ # Title
499
+ title = design.get("title") or _slug_to_title(slug)
500
+
501
+ # Description priority: DESIGN.md → individual .md → meta/h1/title/visible body text → ""
502
+ desc = design.get("description") or ""
503
+ if not desc:
504
+ desc = _read_md_desc(slug, source)
505
+ if not desc:
506
+ desc = _read_html_title(raw.get("html_path", ""))
507
+
508
+ enriched.append({
509
+ "slug": slug,
510
+ "title": title,
511
+ "description": desc,
512
+ "html_file": f"assets/{slug}.html",
513
+ "png_file": f"assets/{slug}.png" if raw.get("png_path") else None,
514
+ })
515
+
516
+ return enriched
517
+
518
+
519
+ def _read_md_desc(slug: str, source: Path) -> str:
520
+ """Look for an individual .md file with the screen description."""
521
+ for md_file in source.glob("*.md"):
522
+ if md_file.name == "DESIGN.md":
523
+ continue
524
+ name_slug = re.sub(r"^[\d\-_]+", "", md_file.stem.lower())
525
+ name_slug = re.sub(r"[\s\-]+", "_", name_slug)
526
+ if name_slug == slug or md_file.stem.lower().replace("-", "_") == slug:
527
+ text = md_file.read_text(encoding="utf-8").strip()
528
+ # First non-empty line that is not a heading
529
+ for line in text.splitlines():
530
+ line = line.strip()
531
+ if line and not line.startswith("#"):
532
+ return line
533
+ return ""
534
+
535
+
536
+ def _read_html_title(html_path: str) -> str:
537
+ """
538
+ Extract a description from a Stitch HTML file.
539
+
540
+ Priority:
541
+ 1. <meta name="description"> or <meta property="og:description">
542
+ 2. First visible <h1> or <h2> text
543
+ 3. <title> tag (skipped if generic/filename-like)
544
+ 4. First meaningful visible text found in the body
545
+ (strips <script>, <style>, <svg>; colects short readable phrases)
546
+ """
547
+ if not html_path:
548
+ return ""
549
+ try:
550
+ text = Path(html_path).read_text(encoding="utf-8", errors="ignore")
551
+
552
+ # 1. <meta name="description" content="...">
553
+ m = re.search(r'<meta[^>]+name=["\']description["\'][^>]+content=["\'](.*?)["\']', text, re.IGNORECASE)
554
+ if not m:
555
+ m = re.search(r'<meta[^>]+content=["\'](.*?)["\'][^>]+name=["\']description["\']', text, re.IGNORECASE)
556
+ if m:
557
+ val = m.group(1).strip()
558
+ if val:
559
+ return val
560
+
561
+ # 2. <meta property="og:description" content="...">
562
+ m = re.search(r'<meta[^>]+property=["\']og:description["\'][^>]+content=["\'](.*?)["\']', text, re.IGNORECASE)
563
+ if not m:
564
+ m = re.search(r'<meta[^>]+content=["\'](.*?)["\'][^>]+property=["\']og:description["\']', text, re.IGNORECASE)
565
+ if m:
566
+ val = m.group(1).strip()
567
+ if val:
568
+ return val
569
+
570
+ # Isolate <body> for remaining checks
571
+ body_m = re.search(r"<body[^>]*>(.*)", text, re.IGNORECASE | re.DOTALL)
572
+ body = body_m.group(1) if body_m else text
573
+
574
+ # 3. First visible <h1> or <h2>
575
+ m = re.search(r"<h[12][^>]*>(.*?)</h[12]>", body, re.IGNORECASE | re.DOTALL)
576
+ if m:
577
+ val = re.sub(r"<[^>]+>", "", m.group(1)).strip()
578
+ if val and len(val) < 120:
579
+ return val
580
+
581
+ # 4. <title> tag — only accept non-generic values
582
+ m = re.search(r"<title[^>]*>(.+?)</title>", text, re.IGNORECASE | re.DOTALL)
583
+ if m:
584
+ val = re.sub(r"<[^>]+>", "", m.group(1)).strip()
585
+ # Skip if it looks like a filename, generic placeholder, or "Untitled"
586
+ if val and not re.match(r"^(untitled|index|screen|page|document|\d+)$", val, re.IGNORECASE):
587
+ return val
588
+
589
+ # 5. First meaningful visible text in body (Stitch HTML often has no title/h1)
590
+ visible = _extract_body_text(body)
591
+ if visible:
592
+ return visible
593
+
594
+ except Exception:
595
+ pass
596
+ return ""
597
+
598
+
599
+ def _extract_body_text(body_html: str) -> str:
600
+ """
601
+ Extract the first short, readable phrase from Stitch HTML body content.
602
+
603
+ Strips <script>, <style>, <svg>, <noscript> blocks entirely, then
604
+ collects text nodes and returns the first phrase that looks like
605
+ real UI text (3–100 chars, not pure numbers/punctuation).
606
+ """
607
+ # Remove non-visible blocks
608
+ cleaned = re.sub(
609
+ r"<(script|style|svg|noscript|defs|symbol)[^>]*>.*?</\1>",
610
+ " ", body_html, flags=re.IGNORECASE | re.DOTALL
611
+ )
612
+ # Strip remaining tags
613
+ cleaned = re.sub(r"<[^>]+>", " ", cleaned)
614
+ # Decode common HTML entities
615
+ for entity, char in [("&amp;", "&"), ("&lt;", "<"), ("&gt;", ">"),
616
+ ("&nbsp;", " "), ("&#39;", "'"), ("&quot;", '"')]:
617
+ cleaned = cleaned.replace(entity, char)
618
+ # Normalize whitespace
619
+ cleaned = re.sub(r"[ \t]+", " ", cleaned)
620
+
621
+ # Split into candidate phrases on common delimiters or line breaks
622
+ candidates = re.split(r"[\n\r|•·/\\]+", cleaned)
623
+ for phrase in candidates:
624
+ phrase = phrase.strip()
625
+ # Accept phrases that are 3–100 chars and contain at least one letter
626
+ if 3 <= len(phrase) <= 100 and re.search(r"[a-zA-ZáéíóúÁÉÍÓÚñÑüÜ]", phrase):
627
+ # Skip if it's all caps single word (likely a CSS class leak or constant)
628
+ if re.match(r"^[A-Z_]{2,}$", phrase):
629
+ continue
630
+ return phrase
631
+
632
+ return ""
633
+
634
+
635
+ def _extract_font_from_html_screens(screens_raw: list) -> str | None:
636
+ """
637
+ Scan screen HTML files for font-family declarations or Google Fonts links.
638
+
639
+ Returns the most common font family name, or None.
640
+ """
641
+ GENERIC_FONTS = {
642
+ "sans-serif", "serif", "monospace", "cursive", "fantasy", "system-ui",
643
+ "ui-sans-serif", "ui-serif", "ui-monospace", "ui-rounded",
644
+ "arial", "helvetica", "verdana", "tahoma", "times", "times new roman",
645
+ "courier", "courier new", "georgia", "trebuchet ms", "segoe ui",
646
+ }
647
+ ICON_FONTS = {
648
+ "material symbols", "material symbols outlined", "material symbols rounded",
649
+ "material symbols sharp", "material icons", "material icons outlined",
650
+ "material icons round", "material icons sharp", "font awesome",
651
+ "fontawesome", "ionicons", "feather", "remix icon", "bootstrap icons",
652
+ }
653
+ font_counts: dict[str, int] = {}
654
+
655
+ for raw in screens_raw[:8]: # sample up to 8 screens
656
+ html_path = raw.get("html_path", "")
657
+ if not html_path:
658
+ continue
659
+ try:
660
+ text = Path(html_path).read_text(encoding="utf-8", errors="ignore")
661
+
662
+ # Google Fonts link: family=Font+Name or family=Font+Name:wght@...
663
+ for m in re.finditer(r"family=([A-Z][a-zA-Z+]+?)(?::|&|[\"'])", text):
664
+ name = m.group(1).replace("+", " ").strip()
665
+ nl = name.lower()
666
+ if nl not in GENERIC_FONTS and nl not in ICON_FONTS and len(name) > 1:
667
+ font_counts[name] = font_counts.get(name, 0) + 1
668
+
669
+ # CSS font-family declarations
670
+ for m in re.finditer(r"font-family:\s*['\"]?([A-Z][a-zA-Z\s]+)['\"]?", text):
671
+ name = m.group(1).strip().rstrip(",;")
672
+ nl = name.lower()
673
+ if nl not in GENERIC_FONTS and nl not in ICON_FONTS and len(name) > 1:
674
+ font_counts[name] = font_counts.get(name, 0) + 1
675
+
676
+ except Exception:
677
+ continue
678
+
679
+ if not font_counts:
680
+ return None
681
+
682
+ # Return the most common font
683
+ return max(font_counts, key=font_counts.get)
684
+
685
+
686
+ def _generate_viewer(output_dir: Path, ptype: str, metadata: dict, screens: list) -> None:
687
+ """Generate viewer.html from the unified reference template."""
688
+ template_path = REFERENCES_DIR / "viewer.html"
689
+
690
+ if not template_path.exists():
691
+ print(f" ⚠ Template not found: {template_path}", file=sys.stderr)
692
+ _generate_viewer_fallback(output_dir, ptype, metadata)
693
+ return
694
+
695
+ template = template_path.read_text(encoding="utf-8")
696
+
697
+ # Build screens JSON for prev/next navigation
698
+ screens_data = [
699
+ {"title": s["title"], "desc": s["description"], "html_file": s["html_file"]}
700
+ for s in screens
701
+ ]
702
+
703
+ # Typography
704
+ font = metadata.get("font_family")
705
+ if font:
706
+ font_link = (
707
+ f'<link rel="preconnect" href="https://fonts.googleapis.com">\n'
708
+ f' <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>\n'
709
+ f' <link href="https://fonts.googleapis.com/css2?family='
710
+ f'{font.replace(" ", "+")}:wght@400;500;600;700&display=swap" rel="stylesheet">'
711
+ )
712
+ else:
713
+ font_link = ""
714
+ font_family = font or "system-ui"
715
+
716
+ html = template
717
+ html = html.replace("{{PROJECT_NAME}}", escape(metadata["project_name"]))
718
+ html = html.replace("{{SCREENS_JSON}}", json.dumps(screens_data, ensure_ascii=False))
719
+ html = html.replace("{{DEFAULT_THEME}}", metadata.get("default_theme", "light"))
720
+ html = html.replace("{{DEFAULT_VIEW}}", ptype)
721
+ html = html.replace("{{PROJECT_SLUG}}", _project_slug(metadata["project_name"]))
722
+ html = html.replace("{{FONT_LINK}}", font_link)
723
+ html = html.replace("{{FONT_FAMILY}}", font_family)
724
+
725
+ (output_dir / "viewer.html").write_text(html, encoding="utf-8")
726
+ print(" ✓ viewer.html generated")
727
+
728
+
729
+ def _generate_index(output_dir: Path, ptype: str, metadata: dict, screens: list) -> None:
730
+ """Generate index.html from the unified reference template."""
731
+ template_path = REFERENCES_DIR / "index.html"
732
+
733
+ if not template_path.exists():
734
+ print(f" ⚠ Template not found: {template_path}", file=sys.stderr)
735
+ _generate_index_fallback(output_dir, ptype, metadata, screens)
736
+ return
737
+
738
+ template = template_path.read_text(encoding="utf-8")
739
+
740
+ # Extract design system screen before building sections
741
+ ds_screen, filtered_screens = _extract_design_system_screen(screens)
742
+
743
+ # Show DS section if there's a dedicated screen OR if metadata has colors/font
744
+ has_ds_info = bool(metadata.get("color_tokens") or metadata.get("colors") or metadata.get("font_family"))
745
+ if ds_screen or has_ds_info:
746
+ ds_html = _design_system_html(ds_screen, metadata)
747
+ else:
748
+ ds_html = ""
749
+
750
+ sections_html, groups = _build_sections_html(filtered_screens, metadata, ptype)
751
+ tabs_html = _section_tabs_html(groups)
752
+
753
+ # Typography
754
+ font = metadata.get("font_family")
755
+ if font:
756
+ font_link = (
757
+ f'<link rel="preconnect" href="https://fonts.googleapis.com">\n'
758
+ f' <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>\n'
759
+ f' <link href="https://fonts.googleapis.com/css2?family='
760
+ f'{font.replace(" ", "+")}:wght@400;500;600;700&display=swap" rel="stylesheet">'
761
+ )
762
+ else:
763
+ font_link = ""
764
+ font_family = font or "system-ui"
765
+
766
+ # Accent color: prefer color_tokens accent, fall back to colors primary
767
+ color_tokens = metadata.get("color_tokens", {})
768
+ accent = (
769
+ color_tokens.get("accent")
770
+ or metadata.get("colors", {}).get("primary")
771
+ or "#6366f1"
772
+ )
773
+
774
+ # New template variables
775
+ type_labels = {"mobile": "Mobile App", "web": "Web App"}
776
+ type_label = type_labels.get(metadata.get("type", ""), "Showcase")
777
+ project_desc = _project_description_html(metadata, filtered_screens)
778
+ screens_intro = _screens_intro_text(metadata)
779
+
780
+ html = template
781
+ html = html.replace("{{PROJECT_NAME}}", escape(metadata["project_name"]))
782
+ html = html.replace("{{SCREEN_COUNT}}", str(len(filtered_screens)))
783
+ html = html.replace("{{SCREENS_HTML}}", sections_html)
784
+ html = html.replace("{{PRIMARY_COLOR}}", accent)
785
+ html = html.replace("{{SECTION_TABS_HTML}}", tabs_html)
786
+ html = html.replace("{{DEFAULT_THEME}}", metadata.get("default_theme", "light"))
787
+ html = html.replace("{{FONT_LINK}}", font_link)
788
+ html = html.replace("{{FONT_FAMILY}}", font_family)
789
+ html = html.replace("{{PROJECT_TYPE_LABEL}}", escape(type_label))
790
+ html = html.replace("{{PROJECT_DESCRIPTION}}", project_desc)
791
+ html = html.replace("{{DEFAULT_VIEW}}", ptype)
792
+ html = html.replace("{{PROJECT_SLUG}}", _project_slug(metadata["project_name"]))
793
+ html = html.replace("{{DESIGN_SYSTEM_HTML}}", ds_html)
794
+ html = html.replace("{{SCREENS_INTRO}}", escape(screens_intro))
795
+
796
+ (output_dir / "index.html").write_text(html, encoding="utf-8")
797
+ print(" ✓ index.html generated")
798
+
799
+
800
+ def _generate_catalog(output_dir: Path, assets_dir: Path, metadata: dict) -> None:
801
+ """Run component detection + extraction and generate catalog.html."""
802
+ # Detect shared structural components
803
+ print("\n🔍 Detecting shared components...")
804
+ shared_components = detect_components.detect_shared_components(assets_dir)
805
+ if shared_components:
806
+ shared_json_path = output_dir / "shared_components.json"
807
+ shared_json_path.write_text(
808
+ json.dumps(shared_components, ensure_ascii=False, indent=2),
809
+ encoding="utf-8",
810
+ )
811
+ print(f" ✅ {len(shared_components)} shared component type(s) detected")
812
+ else:
813
+ print(" ℹ No shared components detected across screens.")
814
+
815
+ # Extract atomic + composite component catalog
816
+ print("\n📦 Extracting component catalog...")
817
+ catalog = extract_catalog.extract_component_catalog(assets_dir)
818
+ if not catalog:
819
+ catalog = {}
820
+
821
+ # Attach shared components for unified display
822
+ catalog["shared"] = shared_components or {}
823
+
824
+ # Write catalog JSON
825
+ json_path = output_dir / "component_catalog.json"
826
+ json_path.write_text(
827
+ json.dumps(catalog, ensure_ascii=False, indent=2),
828
+ encoding="utf-8",
829
+ )
830
+
831
+ atomic_count = sum(len(v) for v in catalog.get("atomic", {}).values())
832
+ composite_count = sum(len(v) for v in catalog.get("composite", {}).values())
833
+ shared_count = len(catalog.get("shared", {}))
834
+ print(f" ✅ {atomic_count} atomic + {composite_count} composite + {shared_count} shared component types")
835
+
836
+ # Generate visual catalog HTML
837
+ _generate_catalog_html(output_dir, metadata, catalog)
838
+
839
+
840
+ DS_SLUG_PATTERNS = ("design_system", "muestrario", "style_guide", "ui_kit", "design_tokens")
841
+
842
+
843
+ def _extract_design_system_screen(screens: list) -> tuple[dict | None, list]:
844
+ """
845
+ Find and remove the design system screen from the screens list.
846
+
847
+ Returns (ds_screen_or_None, remaining_screens).
848
+ """
849
+ ds_screen = None
850
+ remaining = []
851
+ for s in screens:
852
+ slug = s["slug"]
853
+ if not ds_screen and any(pat in slug for pat in DS_SLUG_PATTERNS):
854
+ ds_screen = s
855
+ else:
856
+ remaining.append(s)
857
+ return ds_screen, remaining
858
+
859
+
860
+ def _design_system_html(ds_screen: dict | None, metadata: dict) -> str:
861
+ """
862
+ Render the Manual de Identidad section.
863
+
864
+ Two variants:
865
+ - With ds_screen: clickable card with thumbnail + tokens
866
+ - Without ds_screen: inline display of color palette + typography
867
+ """
868
+ import urllib.parse
869
+
870
+ color_tokens = metadata.get("color_tokens", {})
871
+ colors = metadata.get("colors", {})
872
+ swatches = _collect_swatches(color_tokens, colors)
873
+ font = metadata.get("font_family")
874
+
875
+ # ── Color palette strip ──
876
+ palette_html = ""
877
+ if swatches:
878
+ # Large color blocks in a horizontal strip
879
+ blocks = []
880
+ for name, hex_val in swatches:
881
+ text_color = _swatch_text_color(hex_val)
882
+ blocks.append(
883
+ f'<div class="flex-1 min-w-[80px] h-20 flex flex-col items-start justify-end p-2.5" '
884
+ f'style="background:{hex_val}">'
885
+ f'<span class="text-[10px] font-medium leading-none opacity-80" style="color:{text_color}">{escape(name)}</span>'
886
+ f'<span class="text-[10px] font-mono leading-none mt-0.5 opacity-60" style="color:{text_color}">{escape(hex_val)}</span>'
887
+ f'</div>'
888
+ )
889
+ palette_html = (
890
+ '<div class="flex rounded-lg overflow-hidden border border-gray-200 dark:border-white/10">'
891
+ + "\n".join(blocks)
892
+ + '</div>'
893
+ )
894
+
895
+ # ── Typography block ──
896
+ typo_html = ""
897
+ if font:
898
+ typo_html = (
899
+ f'<div class="flex items-baseline gap-4 mt-6">'
900
+ f'<span class="text-3xl font-bold text-gray-900 dark:text-gray-100" '
901
+ f'style="font-family:\'{escape(font)}\',sans-serif">Aa</span>'
902
+ f'<div>'
903
+ f'<span class="text-sm font-medium text-gray-700 dark:text-gray-200">{escape(font)}</span>'
904
+ f'<span class="text-xs text-gray-400 dark:text-gray-500 ml-2">Primary typeface</span>'
905
+ f'</div>'
906
+ f'</div>'
907
+ )
908
+
909
+ # ── Viewer link (if dedicated screen exists) ──
910
+ link_html = ""
911
+ thumb_col_html = ""
912
+ if ds_screen:
913
+ title = escape(ds_screen.get("title") or "Muestrario de Estilo")
914
+ png_file = ds_screen.get("png_file") or ""
915
+ params = urllib.parse.urlencode({
916
+ "screen": ds_screen.get("html_file", ""),
917
+ "title": ds_screen.get("title", "Design System"),
918
+ "desc": ds_screen.get("description", ""),
919
+ })
920
+ viewer_url = f"viewer.html?{params}"
921
+
922
+ link_html = (
923
+ f'<a href="{viewer_url}" rel="noopener" '
924
+ f'class="inline-flex items-center gap-1.5 mt-6 text-sm font-medium text-accent '
925
+ f'hover:underline underline-offset-2">'
926
+ f'Ver muestrario completo'
927
+ f'<svg class="w-3.5 h-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">'
928
+ f'<path d="M7 17L17 7M17 7H7M17 7V17"/></svg>'
929
+ f'</a>'
930
+ )
931
+
932
+ if png_file:
933
+ thumb_col_html = (
934
+ f'<div class="hidden sm:block sm:w-56 shrink-0 rounded-lg overflow-hidden '
935
+ f'border border-gray-200 dark:border-white/10">'
936
+ f'<a href="{viewer_url}" rel="noopener" class="block">'
937
+ f'<img src="{png_file}" alt="{title}" loading="lazy" '
938
+ f'class="w-full h-full object-cover object-top transition-transform duration-200 hover:scale-[1.02]">'
939
+ f'</a></div>'
940
+ )
941
+
942
+ # ── Assemble section ──
943
+ has_content = palette_html or typo_html
944
+ if not has_content:
945
+ return ""
946
+
947
+ return f"""
948
+ <section class="mx-auto max-w-[1280px] px-6 pb-12">
949
+ <h2 class="text-xl sm:text-2xl font-bold tracking-tight mb-6 text-gray-900 dark:text-gray-100">
950
+ Manual de Identidad
951
+ </h2>
952
+ <div class="flex gap-6 items-start">
953
+ <div class="flex-1 min-w-0">
954
+ {palette_html}
955
+ {typo_html}
956
+ {link_html}
957
+ </div>
958
+ {thumb_col_html}
959
+ </div>
960
+ </section>
961
+ """
962
+
963
+
964
+ def _collect_swatches(color_tokens: dict, colors: dict) -> list[tuple[str, str]]:
965
+ """Collect up to 6 named color swatches, prioritizing semantic tokens."""
966
+ SEMANTIC_ORDER = ["primary", "secondary", "accent", "surface", "background", "error"]
967
+ SKIP_KEYS = {"accent", "surface"} # these are derived aliases in color_tokens
968
+
969
+ swatches = []
970
+ seen_vals = set()
971
+
972
+ # First pass: semantic order from color_tokens
973
+ for key in SEMANTIC_ORDER:
974
+ val = color_tokens.get(key)
975
+ if val and val not in seen_vals:
976
+ swatches.append((key.replace("-", " ").title(), val))
977
+ seen_vals.add(val)
978
+
979
+ # Second pass: remaining color_tokens
980
+ for key, val in color_tokens.items():
981
+ if key in SKIP_KEYS or val in seen_vals or key.startswith("on-"):
982
+ continue
983
+ swatches.append((key.replace("-", " ").title(), val))
984
+ seen_vals.add(val)
985
+ if len(swatches) >= 6:
986
+ break
987
+
988
+ # If we still have room, add from colors dict
989
+ if len(swatches) < 6:
990
+ for key, val in colors.items():
991
+ if val in seen_vals:
992
+ continue
993
+ swatches.append((key.replace("-", " ").title(), val))
994
+ seen_vals.add(val)
995
+ if len(swatches) >= 6:
996
+ break
997
+
998
+ return swatches[:6]
999
+
1000
+
1001
+ def _swatch_text_color(hex_val: str) -> str:
1002
+ """Return white or black text color for readable contrast on a background."""
1003
+ hex_clean = hex_val.lstrip("#")
1004
+ if len(hex_clean) != 6:
1005
+ return "#fff"
1006
+ try:
1007
+ r = int(hex_clean[0:2], 16)
1008
+ g = int(hex_clean[2:4], 16)
1009
+ b = int(hex_clean[4:6], 16)
1010
+ except ValueError:
1011
+ return "#fff"
1012
+ luminance = 0.299 * r + 0.587 * g + 0.114 * b
1013
+ return "#000" if luminance > 140 else "#fff"
1014
+
1015
+
1016
+ def _project_description_html(metadata: dict, screens: list) -> str:
1017
+ """Generate 2 short paragraphs of project description from metadata."""
1018
+ name = escape(metadata.get("project_name", "this project"))
1019
+ sections = metadata.get("sections", [])
1020
+ section_names = [s["name"] for s in sections if s.get("name")]
1021
+ screen_count = len(screens)
1022
+
1023
+ # Paragraph 1
1024
+ if section_names:
1025
+ top_sections = section_names[:4]
1026
+ sections_str = ", ".join(top_sections)
1027
+ if len(section_names) > 4:
1028
+ sections_str += f" y {len(section_names) - 4} más"
1029
+ p1 = (
1030
+ f"Propuesta de diseño UI/UX para {name}. "
1031
+ f"{screen_count} pantallas organizadas en {len(section_names)} secciones "
1032
+ f"que cubren {sections_str}."
1033
+ )
1034
+ else:
1035
+ p1 = f"Propuesta de diseño UI/UX para {name} con {screen_count} pantallas."
1036
+
1037
+ # Paragraph 2
1038
+ p2 = (
1039
+ "Cada diseño incluye el prototipo interactivo en HTML exportado desde Google Stitch, "
1040
+ "con las descripciones originales del prompt de diseño."
1041
+ )
1042
+
1043
+ return f"<p>{p1}</p>\n <p>{p2}</p>"
1044
+
1045
+
1046
+ def _screens_intro_text(metadata: dict) -> str:
1047
+ """Short intro paragraph for the screens browsing section."""
1048
+ name = metadata.get("project_name", "la aplicación")
1049
+ return (
1050
+ f"Explora las pantallas de {name}. "
1051
+ f"Haz clic en cualquier tarjeta para ver el prototipo interactivo a tamaño completo."
1052
+ )
1053
+
1054
+
1055
+ def _build_sections_html(screens: list, metadata: dict, ptype: str) -> tuple[str, list[dict]]:
1056
+ """
1057
+ Generate screen cards HTML, grouped by sections if defined in DESIGN.md.
1058
+
1059
+ Returns (html_string, groups) where groups is a list of
1060
+ {"name": str, "key": str, "screens": list} dicts.
1061
+ """
1062
+ sections = metadata.get("sections", [])
1063
+
1064
+ if sections:
1065
+ slug_to_screen = {s["slug"]: s for s in screens}
1066
+ html_parts = []
1067
+ covered = set()
1068
+ groups = []
1069
+
1070
+ for section in sections:
1071
+ section_screens = [slug_to_screen[sl] for sl in section["slugs"] if sl in slug_to_screen]
1072
+ covered.update(section["slugs"])
1073
+ if section_screens:
1074
+ key = re.sub(r"[^a-z0-9]+", "-", section["name"].lower()).strip("-")
1075
+ groups.append({"name": section["name"], "key": key, "screens": section_screens})
1076
+ html_parts.append(_section_html(section["name"], key, section_screens, ptype))
1077
+
1078
+ # Screens not belonging to any section
1079
+ remaining = [s for s in screens if s["slug"] not in covered]
1080
+ if remaining:
1081
+ key = "other"
1082
+ groups.append({"name": "Other screens", "key": key, "screens": remaining})
1083
+ html_parts.append(_section_html("Other screens", key, remaining, ptype))
1084
+
1085
+ return "\n".join(html_parts), groups
1086
+ else:
1087
+ # Auto-group by keyword overlap when DESIGN.md has no sections
1088
+ raw_groups = _auto_group_screens(screens)
1089
+ groups = []
1090
+ html_parts = []
1091
+ for g in raw_groups:
1092
+ key = re.sub(r"[^a-z0-9]+", "-", g["name"].lower()).strip("-") or "ungrouped"
1093
+ groups.append({"name": g["name"], "key": key, "screens": g["screens"]})
1094
+ html_parts.append(_section_html(g["name"], key, g["screens"], ptype))
1095
+ return "\n".join(html_parts), groups
1096
+
1097
+
1098
+ def _section_tabs_html(groups: list[dict]) -> str:
1099
+ """
1100
+ Generate filter tab pills for section navigation.
1101
+
1102
+ Only generates tabs when there are 2+ named sections.
1103
+ Returns empty string otherwise.
1104
+ """
1105
+ named = [g for g in groups if g["name"]]
1106
+ if len(named) < 2:
1107
+ return ""
1108
+
1109
+ tabs = []
1110
+ # Base classes shared by all tabs — JS controls active state via inline style
1111
+ base = 'filter-tab text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-white/10 rounded-full px-4 py-1.5 text-[12px] font-medium transition-colors'
1112
+ tabs.append(f'<button class="{base}" data-section="all">All</button>')
1113
+ for g in groups:
1114
+ if not g["name"]:
1115
+ continue
1116
+ count = len(g["screens"])
1117
+ tabs.append(
1118
+ f'<button class="{base}" '
1119
+ f'data-section="{escape(g["key"])}">'
1120
+ f'{escape(g["name"])} <span class="opacity-50">({count})</span></button>'
1121
+ )
1122
+
1123
+ return "\n ".join(tabs)
1124
+
1125
+
1126
+ def _auto_group_screens(screens: list) -> list[dict]:
1127
+ """
1128
+ Infer logical groups from slug keyword overlap when DESIGN.md has no sections.
1129
+
1130
+ Strategy:
1131
+ 1. Strip theme suffixes (_oscuro, _claro, _dark, _light) and trailing numbers.
1132
+ 2. Find words shared by 2+ slugs (excluding stop words).
1133
+ 3. Greedily assign screens to the largest keyword clusters.
1134
+ 4. Remaining singletons go into an ungrouped bucket.
1135
+ """
1136
+ THEME_SUFFIXES = {"oscuro", "claro", "dark", "light"}
1137
+ STOP_WORDS = {
1138
+ "de", "del", "la", "el", "los", "las", "un", "una", "y", "en",
1139
+ "con", "a", "al", "lo", "the", "and", "of", "for", "to", "in",
1140
+ }
1141
+
1142
+ def keywords(slug: str) -> list[str]:
1143
+ s = re.sub(r"_\d+$", "", slug)
1144
+ return [
1145
+ w for w in s.split("_")
1146
+ if w and w not in STOP_WORDS and w not in THEME_SUFFIXES and len(w) > 1
1147
+ ]
1148
+
1149
+ screen_kws = {s["slug"]: keywords(s["slug"]) for s in screens}
1150
+ slug_to_screen = {s["slug"]: s for s in screens}
1151
+
1152
+ # Count how many slugs each keyword appears in
1153
+ word_to_slugs: dict[str, list[str]] = {}
1154
+ for slug, kws in screen_kws.items():
1155
+ for w in kws:
1156
+ word_to_slugs.setdefault(w, []).append(slug)
1157
+
1158
+ # Only words shared by 2+ screens are useful for clustering
1159
+ cluster_words = {w: slugs for w, slugs in word_to_slugs.items() if len(slugs) >= 2}
1160
+
1161
+ if not cluster_words:
1162
+ return [{"name": "", "screens": screens}]
1163
+
1164
+ # Greedy: biggest clusters first; each screen assigned to its first cluster
1165
+ assigned: set[str] = set()
1166
+ groups: list[dict] = []
1167
+
1168
+ for word, slugs in sorted(cluster_words.items(), key=lambda x: -len(x[1])):
1169
+ unassigned = [s for s in slugs if s not in assigned]
1170
+ if len(unassigned) < 2:
1171
+ continue
1172
+ group_screens = [slug_to_screen[s] for s in unassigned if s in slug_to_screen]
1173
+ if len(group_screens) >= 2:
1174
+ name = word.replace("_", " ").title()
1175
+ groups.append({"name": name, "screens": group_screens})
1176
+ assigned.update(s["slug"] for s in group_screens)
1177
+
1178
+ remaining = [s for s in screens if s["slug"] not in assigned]
1179
+ if remaining:
1180
+ label = "Other" if groups else ""
1181
+ groups.append({"name": label, "screens": remaining})
1182
+
1183
+ return groups
1184
+
1185
+
1186
+ def _section_html(section_name: str, section_key: str, screens: list, ptype: str) -> str:
1187
+ """HTML for a section containing screen cards (Tailwind classes)."""
1188
+ cards = "\n".join(_card_html(s, ptype) for s in screens)
1189
+ count = len(screens)
1190
+
1191
+ if section_name:
1192
+ label = (
1193
+ f'<h3 class="section-label text-lg font-semibold tracking-tight '
1194
+ f'text-gray-800 dark:text-gray-200 mb-4 pb-2.5 '
1195
+ f'border-b border-gray-200 dark:border-white/10">'
1196
+ f'{escape(section_name)} <span class="text-sm font-normal text-gray-400 dark:text-gray-500">({count})</span></h3>'
1197
+ )
1198
+ else:
1199
+ label = ""
1200
+
1201
+ data_attr = f' data-section="{escape(section_key)}"' if section_key else ""
1202
+
1203
+ return f"""
1204
+ <section class="screen-section mb-10"{data_attr}>
1205
+ {label}
1206
+ <div class="screens-grid grid gap-4">
1207
+ {cards}
1208
+ </div>
1209
+ </section>"""
1210
+
1211
+
1212
+ DARK_SUFFIXES = ('_oscuro', '_dark', '_dark_mode', '_oscura')
1213
+ LIGHT_SUFFIXES = ('_claro', '_light', '_light_mode', '_clara')
1214
+
1215
+
1216
+ def _card_html(screen: dict, ptype: str) -> str:
1217
+ """HTML for a single screen card using Tailwind classes."""
1218
+ import urllib.parse
1219
+ slug = screen["slug"]
1220
+ title = escape(screen["title"])
1221
+ desc = escape(screen["description"])
1222
+ html_file = screen["html_file"]
1223
+ png_file = screen.get("png_file") or ""
1224
+
1225
+ params = urllib.parse.urlencode({
1226
+ "screen": html_file,
1227
+ "title": screen["title"],
1228
+ "desc": screen["description"],
1229
+ })
1230
+ viewer_url = f"viewer.html?{params}"
1231
+
1232
+ # Dark/light variant badge
1233
+ badge = ""
1234
+ if any(slug.endswith(s) for s in DARK_SUFFIXES):
1235
+ badge = '<span class="absolute top-2 right-2 text-[9px] px-1.5 py-0.5 rounded-full bg-black/60 text-white/80 font-medium">dark</span>'
1236
+ elif any(slug.endswith(s) for s in LIGHT_SUFFIXES):
1237
+ badge = '<span class="absolute top-2 right-2 text-[9px] px-1.5 py-0.5 rounded-full bg-white/70 text-black/60 font-medium">light</span>'
1238
+
1239
+ if png_file:
1240
+ thumb = (
1241
+ f'<img src="{png_file}" alt="{title}" loading="lazy" '
1242
+ f'class="w-full h-full object-cover object-top transition-transform duration-300 group-hover:scale-[1.03]">'
1243
+ )
1244
+ else:
1245
+ thumb = (
1246
+ f'<div class="w-full h-full flex items-center justify-center '
1247
+ f'text-4xl font-bold text-gray-300 dark:text-white/10">'
1248
+ f'{title[0].upper()}</div>'
1249
+ )
1250
+
1251
+ desc_html = (
1252
+ f'<p class="card-desc text-xs text-gray-600 dark:text-gray-300 mt-1 leading-snug">'
1253
+ f'{desc}</p>'
1254
+ ) if desc else ""
1255
+
1256
+ return f"""
1257
+ <a class="screen-card group block no-underline transition-all duration-200 cursor-pointer"
1258
+ href="{viewer_url}"
1259
+ data-title="{title}" data-desc="{desc}">
1260
+ <div class="card-thumb relative rounded-xl overflow-hidden ring-1 ring-gray-200 dark:ring-white/10 transition-all duration-200 group-hover:ring-accent">
1261
+ {thumb}
1262
+ {badge}
1263
+ </div>
1264
+ <div class="card-info pt-2.5 px-0.5">
1265
+ <p class="text-sm font-semibold text-gray-900 dark:text-gray-100">{title}</p>
1266
+ {desc_html}
1267
+ </div>
1268
+ </a>"""
1269
+
1270
+
1271
+ def _slug_to_title(slug: str) -> str:
1272
+ """01_splash_screen → 'Splash Screen' (strips numeric prefix)."""
1273
+ s = re.sub(r"^[\d_]+", "", slug)
1274
+ return s.replace("_", " ").replace("-", " ").title().strip() or slug.title()
1275
+
1276
+
1277
+ def _project_slug(name: str) -> str:
1278
+ """'SNAP Gym Web' → 'snap-gym-web' — for scoping localStorage keys per project."""
1279
+ return re.sub(r"[^a-z0-9]+", "-", name.lower()).strip("-") or "showcase"
1280
+
1281
+
1282
+ # ─── Watch mode ───────────────────────────────────────────────────────────────
1283
+
1284
+ def _source_signature(source: Path) -> str:
1285
+ """Hash of mtimes of all zips and screen folders in source."""
1286
+ parts = []
1287
+ for p in sorted(source.iterdir()):
1288
+ if p.suffix == ".zip" or (p.is_dir() and (p / "code.html").exists()):
1289
+ parts.append(f"{p.name}:{p.stat().st_mtime}")
1290
+ return hashlib.md5("\n".join(parts).encode()).hexdigest()
1291
+
1292
+
1293
+ def _watch(source_dir: str, project_type: str, project_name: str) -> None:
1294
+ """Watch source folder and rebuild on changes (Ctrl+C to stop)."""
1295
+ print("👀 Watching for changes... (Ctrl+C to stop)")
1296
+ last_sig = None
1297
+ while True:
1298
+ try:
1299
+ source = Path(source_dir).resolve()
1300
+ sig = _source_signature(source)
1301
+ if sig != last_sig:
1302
+ if last_sig is not None:
1303
+ print("\n🔄 Changes detected, rebuilding...")
1304
+ build(source_dir, project_type, project_name)
1305
+ last_sig = sig
1306
+ time.sleep(2)
1307
+ except KeyboardInterrupt:
1308
+ print("\n👋 Watch stopped.")
1309
+ break
1310
+
1311
+
1312
+ # ─── Update mode ──────────────────────────────────────────────────────────────
1313
+
1314
+ def _update(source_dir: str, project_type: str, project_name: str) -> None:
1315
+ """Detect new screens not yet in DESIGN.md and append them under '### Por Clasificar'."""
1316
+ source = Path(source_dir).resolve()
1317
+ if not source.exists():
1318
+ print(f"Error: '{source}' does not exist.", file=sys.stderr)
1319
+ sys.exit(1)
1320
+
1321
+ if source.suffix == ".zip":
1322
+ source = _extract_source_zip(source)
1323
+
1324
+ # Use discovery to find actual screens
1325
+ source = _discover_source(source)
1326
+
1327
+ import extract_zips as _ez
1328
+ zips = sorted(source.glob("*.zip"))
1329
+ dirs = [d for d in sorted(source.iterdir()) if d.is_dir() and (d / "code.html").exists()]
1330
+
1331
+ all_slugs = [_ez._slug_from_name(z.stem) for z in zips]
1332
+ all_slugs += [_ez._slug_from_name(d.name) for d in dirs if _ez._slug_from_name(d.name) not in all_slugs]
1333
+
1334
+ if not all_slugs:
1335
+ print("Error: no screens found in source folder.", file=sys.stderr)
1336
+ sys.exit(1)
1337
+
1338
+ import parse_design_md as _pd
1339
+ design_md = source / "DESIGN.md"
1340
+ if not design_md.exists():
1341
+ print(" ℹ No DESIGN.md found. Run --init first.")
1342
+ return
1343
+
1344
+ metadata = _pd.parse(str(design_md))
1345
+ classified = {slug for sec in metadata["sections"] for slug in sec["slugs"]}
1346
+
1347
+ new_slugs = [s for s in all_slugs if s not in classified]
1348
+
1349
+ if not new_slugs:
1350
+ print("✅ No new screens detected. All slugs are already in DESIGN.md.")
1351
+ print(" Run without --update to rebuild the showcase.")
1352
+ return
1353
+
1354
+ print(f"📦 {len(new_slugs)} new screen(s) detected:")
1355
+ for s in new_slugs:
1356
+ print(f" + {s}")
1357
+
1358
+ content = design_md.read_text(encoding="utf-8")
1359
+ staging = "\n### Por Clasificar\n" + "\n".join(f"- {s}" for s in new_slugs) + "\n"
1360
+ if "\n## Colors" in content:
1361
+ content = content.replace("\n## Colors", staging + "\n## Colors", 1)
1362
+ elif "\n## Typography" in content:
1363
+ content = content.replace("\n## Typography", staging + "\n## Typography", 1)
1364
+ else:
1365
+ content += staging
1366
+ design_md.write_text(content, encoding="utf-8")
1367
+
1368
+ print(f"\n✏️ New slugs added to DESIGN.md under '### Por Clasificar'.")
1369
+ print(" Move them to the correct sections, add descriptions, then run without flags to rebuild.")
1370
+
1371
+
1372
+ # ─── Init mode ────────────────────────────────────────────────────────────────
1373
+
1374
+ def _init(source_dir: str, project_type: str, project_name: str) -> None:
1375
+ """Generate a DESIGN.md skeleton and showcase.json with auto-detected slugs."""
1376
+ source = Path(source_dir).resolve()
1377
+ if not source.exists():
1378
+ print(f"Error: '{source}' does not exist.", file=sys.stderr)
1379
+ sys.exit(1)
1380
+
1381
+ # If a zip was passed directly, extract it first (same as build())
1382
+ if source.suffix == ".zip":
1383
+ source = _extract_source_zip(source)
1384
+
1385
+ # Use discovery to find actual screens
1386
+ resolved = _discover_source(source)
1387
+
1388
+ # Collect slugs (without extracting content)
1389
+ import extract_zips as _ez
1390
+ zips = sorted(resolved.glob("*.zip"))
1391
+ dirs = [d for d in sorted(resolved.iterdir()) if d.is_dir() and (d / "code.html").exists()]
1392
+
1393
+ slugs = [_ez._slug_from_name(z.stem) for z in zips]
1394
+ slugs += [_ez._slug_from_name(d.name) for d in dirs if _ez._slug_from_name(d.name) not in slugs]
1395
+
1396
+ if not slugs:
1397
+ print("Error: no screens found. Cannot generate DESIGN.md.", file=sys.stderr)
1398
+ sys.exit(1)
1399
+
1400
+ # Auto-group slugs for skeleton
1401
+ fake_screens = [{"slug": s, "title": _slug_to_title(s), "description": ""} for s in slugs]
1402
+ groups = _auto_group_screens(fake_screens)
1403
+
1404
+ pname = project_name or source.name.replace("_", " ").replace("-", " ").title()
1405
+ ptype = project_type or "mobile"
1406
+
1407
+ lines = [
1408
+ f"# {pname}",
1409
+ "",
1410
+ "## Type",
1411
+ ptype,
1412
+ "",
1413
+ "## Screens",
1414
+ ]
1415
+ for g in groups:
1416
+ if g["name"]:
1417
+ lines.append(f"### {g['name']}")
1418
+ for s in g["screens"]:
1419
+ lines.append(f"- {s['slug']}")
1420
+ lines.append("")
1421
+
1422
+ lines += [
1423
+ "## Colors",
1424
+ "- Primary: #6366f1",
1425
+ "- Background: #ffffff",
1426
+ "",
1427
+ "## Typography",
1428
+ "- **Inter**",
1429
+ "",
1430
+ ]
1431
+
1432
+ design_md = resolved / "DESIGN.md"
1433
+ if design_md.exists():
1434
+ backup = resolved / "DESIGN.md.bak"
1435
+ shutil.copy2(design_md, backup)
1436
+ print(f" ℹ Existing DESIGN.md backed up to DESIGN.md.bak")
1437
+
1438
+ design_md.write_text("\n".join(lines), encoding="utf-8")
1439
+ print(f"✅ DESIGN.md created at {design_md}")
1440
+
1441
+ # Generate showcase.json in the project root (parent of source if source != given path)
1442
+ project_root = source if source != resolved else resolved.parent
1443
+ _generate_showcase_json(project_root, resolved, pname, ptype)
1444
+
1445
+ print(" Edit sections and run without --init to build the showcase.")
1446
+
1447
+
1448
+ def _generate_showcase_json(project_root: Path, source: Path, name: str, ptype: str) -> None:
1449
+ """Generate showcase.json in the project root pointing to the source directory."""
1450
+ config_path = project_root / "showcase.json"
1451
+ if config_path.exists():
1452
+ print(f" ℹ showcase.json already exists at {config_path}, skipping")
1453
+ return
1454
+
1455
+ # Compute relative path from project root to source
1456
+ try:
1457
+ source_rel = source.relative_to(project_root)
1458
+ except ValueError:
1459
+ source_rel = source.name
1460
+
1461
+ config = {
1462
+ "source": str(source_rel),
1463
+ "type": ptype,
1464
+ "name": name,
1465
+ }
1466
+ config_path.write_text(
1467
+ json.dumps(config, ensure_ascii=False, indent=2) + "\n",
1468
+ encoding="utf-8",
1469
+ )
1470
+ print(f"✅ showcase.json created at {config_path}")
1471
+
1472
+
1473
+ # ─── Text extraction mode ─────────────────────────────────────────────────────
1474
+
1475
+ def _extract_text_summaries(source_dir: str, project_type: str = None, project_name: str = None) -> None:
1476
+ """
1477
+ Extract visible text from all screen HTMLs and write screen_summaries.txt.
1478
+
1479
+ This runs extract_zips first (to ensure assets/ exists), then uses
1480
+ extract_text.py to produce compact summaries for LLM consumption.
1481
+ """
1482
+ source = Path(source_dir).resolve()
1483
+ if not source.exists():
1484
+ print(f"Error: '{source}' does not exist.", file=sys.stderr)
1485
+ sys.exit(1)
1486
+
1487
+ if source.suffix == ".zip":
1488
+ source = _extract_source_zip(source)
1489
+
1490
+ # Use discovery to find actual screens
1491
+ source = _discover_source(source)
1492
+
1493
+ # Load config for output dir placement
1494
+ config = _load_showcase_json(source) or _load_showcase_json(source.parent) or {}
1495
+
1496
+ # Determine output dir
1497
+ metadata = {}
1498
+ design_md = source / "DESIGN.md"
1499
+ if design_md.exists():
1500
+ metadata = parse_design_md.parse(str(design_md))
1501
+
1502
+ if project_type:
1503
+ metadata["type"] = project_type
1504
+
1505
+ project_root = Path(config["_config_dir"]) if "_config_dir" in config else source.parent
1506
+ output_dir = project_root / "showcase"
1507
+ output_dir.mkdir(exist_ok=True)
1508
+ assets_dir = output_dir / "assets"
1509
+ assets_dir.mkdir(exist_ok=True)
1510
+
1511
+ # Extract zips to assets
1512
+ print("\n🗜 Extracting screens...")
1513
+ screens_raw = extract_zips.extract_all(str(source), str(assets_dir))
1514
+ if not screens_raw:
1515
+ print("Error: no screens found.", file=sys.stderr)
1516
+ sys.exit(1)
1517
+
1518
+ print(f"\n✅ {len(screens_raw)} screens extracted")
1519
+
1520
+ # Extract text from all HTMLs
1521
+ print("\n📝 Extracting visible text from HTML files...")
1522
+ screen_texts = extract_text.extract_all_screens_text(assets_dir)
1523
+
1524
+ if not screen_texts:
1525
+ print("Error: no text extracted.", file=sys.stderr)
1526
+ sys.exit(1)
1527
+
1528
+ # Write summaries
1529
+ summaries = extract_text.format_all_summaries(screen_texts)
1530
+ summary_path = output_dir / "screen_summaries.txt"
1531
+ summary_path.write_text(summaries, encoding="utf-8")
1532
+
1533
+ print(f"\n✅ Text summaries written to: {summary_path}")
1534
+ print(f" {len(screen_texts)} screens summarized")
1535
+ print(f" LLM should read this file instead of individual HTMLs")
1536
+
1537
+
1538
+ def _generate_catalog_html(output_dir: Path, metadata: dict, catalog: dict) -> None:
1539
+ """Generate the visual component catalog HTML from template."""
1540
+ template_path = REFERENCES_DIR / "catalog-template.html"
1541
+ if not template_path.exists():
1542
+ print(f" ⚠ Template not found: {template_path}", file=sys.stderr)
1543
+ return
1544
+
1545
+ template = template_path.read_text(encoding="utf-8")
1546
+
1547
+ # Typography
1548
+ font = metadata.get("font_family")
1549
+ if font:
1550
+ font_link = (
1551
+ f'<link rel="preconnect" href="https://fonts.googleapis.com">\n'
1552
+ f' <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>\n'
1553
+ f' <link href="https://fonts.googleapis.com/css2?family='
1554
+ f'{font.replace(" ", "+")}:wght@400;500;600;700&display=swap" rel="stylesheet">'
1555
+ )
1556
+ else:
1557
+ font_link = ""
1558
+ font_family = font or "system-ui"
1559
+
1560
+ # Accent color
1561
+ color_tokens = metadata.get("color_tokens", {})
1562
+ accent = (
1563
+ color_tokens.get("accent")
1564
+ or metadata.get("colors", {}).get("primary")
1565
+ or "#6366f1"
1566
+ )
1567
+
1568
+ # Extract Tailwind head (CDN + config + fonts) for faithful previews
1569
+ assets_dir = output_dir / "assets"
1570
+ tailwind_head = ""
1571
+ if assets_dir.is_dir():
1572
+ tailwind_head = extract_catalog.extract_tailwind_head(assets_dir)
1573
+
1574
+ # Build tabs HTML
1575
+ tabs_html = _catalog_tabs_html(catalog)
1576
+
1577
+ # Build catalog sections HTML (pass tailwind_head for iframe srcdoc previews)
1578
+ default_theme = metadata.get("default_theme", "light")
1579
+ catalog_html = _catalog_sections_html(catalog, dark_mode=(default_theme == "dark"), tailwind_head=tailwind_head)
1580
+
1581
+ html = template
1582
+ html = html.replace("{{PROJECT_NAME}}", escape(metadata["project_name"]))
1583
+ html = html.replace("{{PRIMARY_COLOR}}", accent)
1584
+ html = html.replace("{{DEFAULT_THEME}}", default_theme)
1585
+ html = html.replace("{{FONT_LINK}}", font_link)
1586
+ html = html.replace("{{FONT_FAMILY}}", font_family)
1587
+ html = html.replace("{{TABS_HTML}}", tabs_html)
1588
+ html = html.replace("{{CATALOG_HTML}}", catalog_html)
1589
+ html = html.replace("{{SKILL_VERSION}}", SKILL_VERSION)
1590
+
1591
+ (output_dir / "catalog.html").write_text(html, encoding="utf-8")
1592
+ print(f" ✓ catalog.html generated")
1593
+
1594
+
1595
+ def _catalog_tabs_html(catalog: dict) -> str:
1596
+ """Generate tab pills for the comparison-oriented catalog layout."""
1597
+ tabs = ['<button class="tab active" data-category="all">All</button>']
1598
+
1599
+ # Structural components tab (shared navbars, footers, etc.)
1600
+ shared = catalog.get("shared", {})
1601
+ if shared:
1602
+ structural_count = sum(1 + len(v.get("variants", [])) for v in shared.values())
1603
+ tabs.append(
1604
+ f'<button class="tab" data-category="structural">'
1605
+ f'Structural <span class="count">({structural_count})</span></button>'
1606
+ )
1607
+
1608
+ # Atomic type tabs (from clusters)
1609
+ clusters = catalog.get("clusters", {})
1610
+ for comp_type in sorted(clusters.keys()):
1611
+ type_clusters = clusters[comp_type]
1612
+ variant_count = sum(1 + len(c.get("variants", [])) for c in type_clusters)
1613
+ label = comp_type.replace("_", " ").title()
1614
+ tabs.append(
1615
+ f'<button class="tab" data-category="{escape(comp_type)}">'
1616
+ f'{escape(label)} <span class="count">({variant_count})</span></button>'
1617
+ )
1618
+
1619
+ # Composite components tab
1620
+ composite = catalog.get("composite", {})
1621
+ if composite:
1622
+ composite_count = sum(len(v) for v in composite.values())
1623
+ tabs.append(
1624
+ f'<button class="tab" data-category="composite">'
1625
+ f'Composite <span class="count">({composite_count})</span></button>'
1626
+ )
1627
+
1628
+ # Already Unified tab
1629
+ tabs.append(
1630
+ '<button class="tab" data-category="unified">Already Unified</button>'
1631
+ )
1632
+
1633
+ # Design tokens tab
1634
+ tokens = catalog.get("design_tokens", {})
1635
+ if tokens and (tokens.get("colors") or tokens.get("fonts")):
1636
+ tabs.append('<button class="tab" data-category="design-tokens">Design Tokens</button>')
1637
+
1638
+ return "\n ".join(tabs)
1639
+
1640
+
1641
+ def _iframe_srcdoc(html_content: str, tailwind_head: str) -> str:
1642
+ """Build an HTML-attribute-safe srcdoc string for isolated preview iframes."""
1643
+ body = html_content[:2000]
1644
+ doc = (
1645
+ f"<!DOCTYPE html><html><head>{tailwind_head}</head>"
1646
+ f"<body style='margin:0;overflow:hidden;display:flex;align-items:center;"
1647
+ f"justify-content:center;min-height:100vh'>"
1648
+ f"<div>{body}</div></body></html>"
1649
+ )
1650
+ # Escape for use inside a double-quoted HTML attribute
1651
+ return doc.replace("&", "&amp;").replace('"', "&quot;").replace("<", "&lt;").replace(">", "&gt;")
1652
+
1653
+
1654
+ def _catalog_sections_html(catalog: dict, dark_mode: bool = False, tailwind_head: str = "") -> str:
1655
+ """Generate comparison-oriented catalog sections."""
1656
+ sections = []
1657
+ unified_items = [] # Components with only 1 variant (already standardized)
1658
+ dark_cls = ' class="dark"' if dark_mode else ""
1659
+
1660
+ # ── Structural components (shared navbars, footers, etc.) ──
1661
+ shared = catalog.get("shared", {})
1662
+ if shared:
1663
+ structural_parts = []
1664
+ for comp_type, data in shared.items():
1665
+ canonical = data.get("canonical", {})
1666
+ variants = data.get("variants", [])
1667
+ found_in = data.get("found_in", 0)
1668
+ total_screens = data.get("total_screens", 0)
1669
+ label = comp_type.replace("_", " ").title()
1670
+
1671
+ if not variants:
1672
+ # Already unified — collect for collapsed section
1673
+ unified_items.append((label, found_in, total_screens, "structural"))
1674
+ continue
1675
+
1676
+ # Build cluster group with side-by-side cards
1677
+ cards = []
1678
+
1679
+ # Canonical card — iframe preview for structural components
1680
+ canon_slug = escape(canonical.get("slug", ""))
1681
+ canon_html = canonical.get("html_snippet", "")
1682
+ canon_srcdoc = _iframe_srcdoc(canon_html, tailwind_head)
1683
+ canon_code = escape(canon_html[:500])
1684
+ cards.append(
1685
+ f'<div class="comp-card canonical" data-name="{escape(label)} (canonical)" data-variant="canonical">\n'
1686
+ f' <iframe data-srcdoc="{canon_srcdoc}" style="width:100%;height:120px;border:none;pointer-events:none;background:var(--surface-2);"></iframe>\n'
1687
+ f' <div class="comp-info">\n'
1688
+ f' <div class="comp-name">{escape(label)}<span class="canonical-badge">★ Canonical</span></div>\n'
1689
+ f' <div class="comp-meta"><span class="pill">{canon_slug}</span>'
1690
+ f'<span>{found_in}/{total_screens} screens</span></div>\n'
1691
+ f' <div class="comp-code"><button class="copy-btn">Copy</button><pre>{canon_code}</pre></div>\n'
1692
+ f' </div>\n</div>'
1693
+ )
1694
+
1695
+ # Variant cards
1696
+ for variant in variants:
1697
+ v_slug = escape(variant.get("slug", ""))
1698
+ v_sim = variant.get("similarity", 0)
1699
+ v_pct = int(v_sim * 100)
1700
+ v_diffs = escape(variant.get("differences", ""))
1701
+ cards.append(
1702
+ f'<div class="comp-card" data-name="{escape(label)} ({v_slug})" data-variant="{v_slug}">\n'
1703
+ f' <div class="comp-info">\n'
1704
+ f' <div class="comp-name">{escape(label)} — {v_slug}</div>\n'
1705
+ f' <div class="comp-meta"><span class="pill">{v_slug}</span>'
1706
+ f'<span>{v_pct}% similarity</span></div>\n'
1707
+ f' <div class="similarity-bar"><div class="similarity-fill" style="width:{v_pct}%"></div></div>\n'
1708
+ f' <div class="diff-text">{v_diffs}</div>\n'
1709
+ f' </div>\n</div>'
1710
+ )
1711
+
1712
+ variant_count = 1 + len(variants)
1713
+ structural_parts.append(
1714
+ f'<div class="cluster-group">\n'
1715
+ f' <div class="cluster-header">\n'
1716
+ f' <h3 class="comp-name" style="margin:0">{escape(label)}</h3>\n'
1717
+ f' <span class="variant-badge">{variant_count} variant{"s" if variant_count != 1 else ""}</span>\n'
1718
+ f' </div>\n'
1719
+ f' <div class="cluster-grid">\n{"".join(cards)}\n </div>\n'
1720
+ f'</div>'
1721
+ )
1722
+
1723
+ if structural_parts:
1724
+ sections.append(
1725
+ f'<section class="section" data-category="structural">\n'
1726
+ f' <h2 class="section-title">Structural Components</h2>\n'
1727
+ f' {"".join(structural_parts)}\n'
1728
+ f'</section>'
1729
+ )
1730
+
1731
+ # ── Atomic component clusters ──
1732
+ clusters = catalog.get("clusters", {})
1733
+ for comp_type in sorted(clusters.keys()):
1734
+ type_clusters = clusters[comp_type]
1735
+ label = comp_type.replace("_", " ").title()
1736
+ standalone_cards = [] # Single-variant clusters (no wrapper needed)
1737
+ cluster_parts = [] # Multi-variant clusters (with cluster-group wrapper)
1738
+
1739
+ for cluster in type_clusters:
1740
+ canonical = cluster.get("canonical", {})
1741
+ variants = cluster.get("variants", [])
1742
+ context = cluster.get("context", "content")
1743
+
1744
+ # Build canonical card (shown for all clusters)
1745
+ c_html = canonical.get("html", "")
1746
+ c_preview = c_html[:2000] if len(c_html) < 2000 else c_html[:2000] + "..."
1747
+ c_text = escape(canonical.get("text", "")[:60])
1748
+ c_variant = escape(canonical.get("variant", ""))
1749
+ c_name = f"{c_variant}: {c_text}" if c_variant and c_text else c_variant or c_text
1750
+ c_screens = canonical.get("found_in", [])
1751
+ c_screen_links = ", ".join(f'<a href="viewer.html?screen=assets/{escape(s)}.html&title={escape(s)}" target="_blank">{escape(s)}</a>' for s in c_screens[:5])
1752
+
1753
+ if not variants:
1754
+ # Single-variant (standalone) — render as a simple card, no cluster wrapper
1755
+ c_srcdoc = _iframe_srcdoc(c_html, tailwind_head)
1756
+ standalone_cards.append(
1757
+ f'<div class="comp-card" data-name="{escape(c_name)}" data-variant="{c_variant}">\n'
1758
+ f' <iframe data-srcdoc="{c_srcdoc}" style="width:100%;height:80px;border:none;pointer-events:none;"></iframe>\n'
1759
+ f' <div class="comp-info">\n'
1760
+ f' <div class="comp-name">{escape(c_name)}</div>\n'
1761
+ f' <div class="comp-meta"><span class="pill">{c_variant or comp_type}</span>'
1762
+ f'<span>{len(c_screens)} screen{"s" if len(c_screens) != 1 else ""}</span></div>\n'
1763
+ f' <div class="comp-screens">{c_screen_links}</div>\n'
1764
+ f' <div class="comp-code"><button class="copy-btn">Copy</button><pre>{escape(c_html[:500])}</pre></div>\n'
1765
+ f' </div>\n</div>'
1766
+ )
1767
+ continue
1768
+
1769
+ cards = []
1770
+ # Canonical card for multi-variant cluster
1771
+ c_srcdoc = _iframe_srcdoc(c_html, tailwind_head)
1772
+ cards.append(
1773
+ f'<div class="comp-card canonical" data-name="{escape(c_name)} (canonical)" data-variant="canonical">\n'
1774
+ f' <iframe data-srcdoc="{c_srcdoc}" style="width:100%;height:80px;border:none;pointer-events:none;"></iframe>\n'
1775
+ f' <div class="comp-info">\n'
1776
+ f' <div class="comp-name">{escape(c_name)}<span class="canonical-badge">★ Canonical</span></div>\n'
1777
+ f' <div class="comp-meta"><span class="pill">{c_variant or comp_type}</span>'
1778
+ f'<span>{len(c_screens)} screen{"s" if len(c_screens) != 1 else ""}</span></div>\n'
1779
+ f' <div class="comp-screens">{c_screen_links}</div>\n'
1780
+ f' <div class="comp-code"><button class="copy-btn">Copy</button><pre>{escape(c_html[:500])}</pre></div>\n'
1781
+ f' </div>\n</div>'
1782
+ )
1783
+
1784
+ # Variant cards
1785
+ for v in variants:
1786
+ v_html = v.get("html", "")
1787
+ v_preview = v_html[:2000] if len(v_html) < 2000 else v_html[:2000] + "..."
1788
+ v_text = escape(v.get("text", "")[:60])
1789
+ v_variant = escape(v.get("variant", ""))
1790
+ v_name = f"{v_variant}: {v_text}" if v_variant and v_text else v_variant or v_text
1791
+ v_sim = v.get("similarity", 0)
1792
+ v_pct = int(v_sim * 100)
1793
+ v_screens = v.get("found_in", [])
1794
+ v_screen_links = ", ".join(f'<a href="viewer.html?screen=assets/{escape(s)}.html&title={escape(s)}" target="_blank">{escape(s)}</a>' for s in v_screens[:5])
1795
+
1796
+ v_srcdoc = _iframe_srcdoc(v_html, tailwind_head)
1797
+ cards.append(
1798
+ f'<div class="comp-card" data-name="{escape(v_name)}" data-variant="{v_variant}">\n'
1799
+ f' <iframe data-srcdoc="{v_srcdoc}" style="width:100%;height:80px;border:none;pointer-events:none;"></iframe>\n'
1800
+ f' <div class="comp-info">\n'
1801
+ f' <div class="comp-name">{escape(v_name)}</div>\n'
1802
+ f' <div class="comp-meta"><span class="pill">{v_variant or comp_type}</span>'
1803
+ f'<span>{v_pct}% similarity</span>'
1804
+ f'<span>{len(v_screens)} screen{"s" if len(v_screens) != 1 else ""}</span></div>\n'
1805
+ f' <div class="similarity-bar"><div class="similarity-fill" style="width:{v_pct}%"></div></div>\n'
1806
+ f' <div class="comp-screens">{v_screen_links}</div>\n'
1807
+ f' </div>\n</div>'
1808
+ )
1809
+
1810
+ context_label = context if context != "content" else "general"
1811
+ variant_count = 1 + len(variants)
1812
+ cluster_parts.append(
1813
+ f'<div class="cluster-group">\n'
1814
+ f' <div class="cluster-header">\n'
1815
+ f' <span class="cluster-context">{escape(context_label)}</span>\n'
1816
+ f' <span class="cluster-count">{variant_count} variant{"s" if variant_count != 1 else ""}</span>\n'
1817
+ f' </div>\n'
1818
+ f' <div class="cluster-grid">\n{"".join(cards)}\n </div>\n'
1819
+ f'</div>'
1820
+ )
1821
+
1822
+ if standalone_cards or cluster_parts:
1823
+ inner = ""
1824
+ if standalone_cards:
1825
+ inner += f'<div class="grid">{"".join(standalone_cards)}</div>\n'
1826
+ inner += "".join(cluster_parts)
1827
+ sections.append(
1828
+ f'<section class="section" data-category="{escape(comp_type)}">\n'
1829
+ f' <h2 class="section-title">{escape(label)}</h2>\n'
1830
+ f' {inner}\n'
1831
+ f'</section>'
1832
+ )
1833
+
1834
+ # ── Composite components (non-clustered, legacy display) ──
1835
+ composite = catalog.get("composite", {})
1836
+ if composite:
1837
+ composite_cards = []
1838
+ for comp_type_key, components in composite.items():
1839
+ for comp in components:
1840
+ composite_cards.append(_catalog_card_html(comp, comp_type_key, dark_mode=dark_mode, tailwind_head=tailwind_head))
1841
+ if composite_cards:
1842
+ sections.append(
1843
+ f'<section class="section" data-category="composite">\n'
1844
+ f' <h2 class="section-title">Composite Components'
1845
+ f'<span class="badge">({len(composite_cards)})</span></h2>\n'
1846
+ f' <div class="grid">\n{"".join(composite_cards)}\n </div>\n'
1847
+ f'</section>'
1848
+ )
1849
+
1850
+ # ── Already Unified section (collapsed) ──
1851
+ if unified_items:
1852
+ items_html = ""
1853
+ for name, found_in, total, category in unified_items:
1854
+ items_html += (
1855
+ f'<div class="unified-item">'
1856
+ f'<span class="unified-check">✓</span>'
1857
+ f'<span>{escape(name)}</span>'
1858
+ f'<span class="screen-count-badge">{found_in} screen{"s" if found_in != 1 else ""}</span>'
1859
+ f'</div>\n'
1860
+ )
1861
+ sections.append(
1862
+ f'<section class="section unified-section" data-category="unified">\n'
1863
+ f' <h2 class="section-title">Already Unified<span class="badge">({len(unified_items)})</span></h2>\n'
1864
+ f' <button class="unified-toggle">'
1865
+ f'<span class="arrow">▸</span> {len(unified_items)} components already standardized across screens</button>\n'
1866
+ f' <div class="unified-content">\n{items_html} </div>\n'
1867
+ f'</section>'
1868
+ )
1869
+
1870
+ # ── Design tokens section ──
1871
+ tokens = catalog.get("design_tokens", {})
1872
+ if tokens:
1873
+ sections.append(_design_tokens_section_html(tokens))
1874
+
1875
+ return "\n\n".join(sections)
1876
+
1877
+
1878
+ def _catalog_card_html(comp: dict, comp_type: str, dark_mode: bool = False, tailwind_head: str = "") -> str:
1879
+ """Generate a single component card for the catalog."""
1880
+ variant = escape(comp.get("variant", ""))
1881
+ text = escape(comp.get("text", "")[:80])
1882
+ html_code = comp.get("html", "")
1883
+ found_in = comp.get("found_in", [])
1884
+ count = comp.get("count", len(found_in))
1885
+ styles = comp.get("styles", {})
1886
+
1887
+ # Name: variant + truncated text
1888
+ name = f"{variant}: {text}" if variant and text else variant or text or comp_type
1889
+
1890
+ # Style properties pills
1891
+ style_pills = ""
1892
+ if styles:
1893
+ pills = []
1894
+ for k, v in list(styles.items())[:4]:
1895
+ pills.append(f'<span class="style-prop">{escape(k)}: {escape(v)}</span>')
1896
+ if pills:
1897
+ style_pills = f'<div class="style-props">{"".join(pills)}</div>'
1898
+
1899
+ # Screen links
1900
+ screen_links = ", ".join(
1901
+ f'<a href="viewer.html?screen=assets/{escape(s)}.html&title={escape(s)}" target="_blank">{escape(s)}</a>'
1902
+ for s in found_in[:5]
1903
+ )
1904
+ if len(found_in) > 5:
1905
+ screen_links += f" +{len(found_in) - 5} more"
1906
+
1907
+ # Escaped HTML for code snippet
1908
+ code_escaped = escape(html_code[:500])
1909
+
1910
+ # Preview: render in iframe to isolate from page CSS
1911
+ srcdoc = _iframe_srcdoc(html_code, tailwind_head)
1912
+
1913
+ return f""" <div class="comp-card" data-name="{escape(name)}" data-variant="{variant}">
1914
+ <iframe data-srcdoc="{srcdoc}" style="width:100%;height:120px;border:none;pointer-events:none;"></iframe>
1915
+ <div class="comp-info">
1916
+ <div class="comp-name">{escape(name)}</div>
1917
+ <div class="comp-meta">
1918
+ <span class="pill">{variant or comp_type}</span>
1919
+ <span>Found in {count} screen{"s" if count != 1 else ""}</span>
1920
+ </div>
1921
+ {style_pills}
1922
+ <div class="comp-screens">{screen_links}</div>
1923
+ <div class="comp-code">
1924
+ <button class="copy-btn">Copy</button>
1925
+ <pre>{code_escaped}</pre>
1926
+ </div>
1927
+ </div>
1928
+ </div>"""
1929
+
1930
+
1931
+ def _design_tokens_section_html(tokens: dict) -> str:
1932
+ """Generate the design tokens section."""
1933
+ groups = []
1934
+
1935
+ # Colors
1936
+ colors = tokens.get("colors", {})
1937
+ if colors:
1938
+ swatches = []
1939
+ for color, count in list(colors.items())[:12]:
1940
+ swatches.append(
1941
+ f'<div class="color-swatch">'
1942
+ f'<div class="swatch-circle" style="background:{escape(color)}"></div>'
1943
+ f'<span class="swatch-label">{escape(color)} <span style="opacity:0.5">({count}×)</span></span>'
1944
+ f'</div>'
1945
+ )
1946
+ groups.append(
1947
+ f'<div class="token-group"><h3>Colors</h3>{"".join(swatches)}</div>'
1948
+ )
1949
+
1950
+ # Fonts
1951
+ fonts = tokens.get("fonts", [])
1952
+ if fonts:
1953
+ items = "".join(f"<li>{escape(f)}</li>" for f in fonts)
1954
+ groups.append(
1955
+ f'<div class="token-group"><h3>Fonts</h3><ul class="token-list">{items}</ul></div>'
1956
+ )
1957
+
1958
+ # Border radius
1959
+ radii = tokens.get("border_radius", [])
1960
+ if radii:
1961
+ items = "".join(f"<li>{escape(r)}</li>" for r in radii)
1962
+ groups.append(
1963
+ f'<div class="token-group"><h3>Border Radius</h3><ul class="token-list">{items}</ul></div>'
1964
+ )
1965
+
1966
+ if not groups:
1967
+ return ""
1968
+
1969
+ return (
1970
+ f'<section class="section" data-category="design-tokens">\n'
1971
+ f' <h2 class="section-title">Design Tokens</h2>\n'
1972
+ f' <div class="tokens-grid">\n{"".join(groups)}\n </div>\n'
1973
+ f'</section>'
1974
+ )
1975
+
1976
+
1977
+ # ─── Fallbacks (when reference templates are missing) ─────────────────────────
1978
+
1979
+ def _generate_viewer_fallback(output_dir: Path, ptype: str, metadata: dict) -> None:
1980
+ """Minimal functional viewer HTML (fallback when template missing)."""
1981
+ html = f"""<!DOCTYPE html>
1982
+ <html lang="en"><head>
1983
+ <meta charset="UTF-8">
1984
+ <title>Viewer — {escape(metadata['project_name'])}</title>
1985
+ <style>
1986
+ * {{ box-sizing: border-box; margin: 0; padding: 0; }}
1987
+ body {{ background: #0d0d0d; color: #f0f0f0; font-family: system-ui, sans-serif;
1988
+ display: flex; flex-direction: column; height: 100vh; }}
1989
+ header {{ display: flex; align-items: center; gap: 16px; padding: 12px 20px;
1990
+ background: #141414; border-bottom: 1px solid #222; flex-shrink: 0; }}
1991
+ #btn-back {{ color: #aaa; text-decoration: none; font-size: 14px; cursor: pointer;
1992
+ background: none; border: none; padding: 0; }}
1993
+ #btn-back:hover {{ color: #fff; }}
1994
+ #screen-title {{ font-weight: 600; font-size: 15px; }}
1995
+ #screen-desc {{ font-size: 12px; color: #888; }}
1996
+ main {{ flex: 1; display: flex; align-items: center; justify-content: center;
1997
+ overflow: hidden; padding: 20px; }}
1998
+ iframe {{ width: 100%; height: 100%; border: none; }}
1999
+ </style>
2000
+ </head><body>
2001
+ <header>
2002
+ <button id="btn-back" onclick="location.href='index.html'">← Back</button>
2003
+ <span id="screen-title">—</span>
2004
+ <span id="screen-desc"></span>
2005
+ </header>
2006
+ <main>
2007
+ <div style="flex:1;height:100%">
2008
+ <iframe id="viewer-frame" src="" title="Screen viewer"></iframe>
2009
+ </div>
2010
+ </main>
2011
+ <script>
2012
+ const p = new URLSearchParams(location.search);
2013
+ document.getElementById('screen-title').textContent = p.get('title') || '';
2014
+ document.getElementById('screen-desc').textContent = p.get('desc') || '';
2015
+ document.getElementById('viewer-frame').src = p.get('screen') || '';
2016
+ </script>
2017
+ </body></html>"""
2018
+ (output_dir / "viewer.html").write_text(html, encoding="utf-8")
2019
+
2020
+
2021
+ def _generate_index_fallback(output_dir: Path, ptype: str, metadata: dict, screens: list) -> None:
2022
+ """Minimal functional index HTML (Tailwind CDN, view mode toggle, dark toggle)."""
2023
+ cards = "\n".join(_card_html(s, ptype) for s in screens)
2024
+ name = escape(metadata["project_name"])
2025
+ count = len(screens)
2026
+
2027
+ html = f"""<!DOCTYPE html>
2028
+ <html lang="en" class="view-{ptype}">
2029
+ <head>
2030
+ <meta charset="UTF-8">
2031
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
2032
+ <title>{name} — Showcase</title>
2033
+ <script src="https://cdn.tailwindcss.com"></script>
2034
+ <script>tailwind.config = {{ darkMode: 'class', theme: {{ extend: {{ colors: {{ accent: '#6366f1' }} }} }} }}</script>
2035
+ <style>
2036
+ .view-mobile .card-thumb {{ aspect-ratio: 9/19.5; background: #000; }}
2037
+ .view-web .card-thumb {{ aspect-ratio: 16/10; background: #fff; }}
2038
+ .view-mobile .screens-grid {{ grid-template-columns: repeat(4, 1fr) !important; }}
2039
+ .view-web .screens-grid {{ grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)) !important; }}
2040
+ </style>
2041
+ </head>
2042
+ <body class="bg-gray-100 dark:bg-[#0d0d0d] text-gray-900 dark:text-gray-100 transition-colors duration-200 min-h-screen">
2043
+ <header class="sticky top-0 z-50 bg-white dark:bg-[#111] border-b border-gray-200 dark:border-white/10 shadow-sm">
2044
+ <div class="max-w-[1280px] mx-auto px-6 h-16 flex items-center justify-between gap-4">
2045
+ <div class="flex items-baseline gap-3">
2046
+ <h1 class="text-lg font-bold">{name}</h1>
2047
+ <span class="text-sm text-gray-400">{count} screens</span>
2048
+ </div>
2049
+ <div class="flex items-center gap-3">
2050
+ <button id="theme-toggle" class="w-8 h-8 flex items-center justify-center rounded-lg text-gray-500 hover:bg-gray-100 dark:hover:bg-white/10 transition-colors">
2051
+ <svg class="w-4 h-4 block dark:hidden" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>
2052
+ <svg class="w-4 h-4 hidden dark:block" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/></svg>
2053
+ </button>
2054
+ </div>
2055
+ </div>
2056
+ </header>
2057
+ <div class="max-w-[1280px] mx-auto px-6 pt-6 pb-16">
2058
+ <div class="screens-grid grid gap-4">
2059
+ {cards}
2060
+ </div>
2061
+ </div>
2062
+ <script>
2063
+ const html = document.documentElement;
2064
+ if (localStorage.getItem('showcase-theme') === 'dark') html.classList.add('dark');
2065
+ document.getElementById('theme-toggle').addEventListener('click', () => {{
2066
+ html.classList.toggle('dark');
2067
+ localStorage.setItem('showcase-theme', html.classList.contains('dark') ? 'dark' : 'light');
2068
+ }});
2069
+ </script>
2070
+ </body></html>"""
2071
+ (output_dir / "index.html").write_text(html, encoding="utf-8")
2072
+
2073
+
2074
+ # ─── Entry point ──────────────────────────────────────────────────────────────
2075
+
2076
+ if __name__ == "__main__":
2077
+ parser = argparse.ArgumentParser(
2078
+ description="Generate a navigable showcase from Google Stitch exports."
2079
+ )
2080
+ parser.add_argument("source", help="Folder containing Stitch zips (or a mega-zip)")
2081
+ parser.add_argument("--type", choices=["mobile", "web"], help="Design type")
2082
+ parser.add_argument("--name", help="Project name (if no DESIGN.md)")
2083
+ parser.add_argument("--context", action="store_true", help="Generate showcase_context.json only (for AI-driven HTML generation)")
2084
+ parser.add_argument("--watch", action="store_true", help="Watch for changes and rebuild automatically")
2085
+ parser.add_argument("--init", action="store_true", help="Generate DESIGN.md skeleton and exit")
2086
+ parser.add_argument("--update", action="store_true", help="Detect new screens not yet in DESIGN.md and append them under '### Por Clasificar'")
2087
+ parser.add_argument("--extract-text", action="store_true", help="Extract visible text from screen HTMLs and write screen_summaries.txt")
2088
+ args = parser.parse_args()
2089
+
2090
+ if args.extract_text:
2091
+ _extract_text_summaries(args.source, project_type=args.type, project_name=args.name)
2092
+ elif args.init:
2093
+ _init(args.source, project_type=args.type, project_name=args.name)
2094
+ elif args.update:
2095
+ _update(args.source, project_type=args.type, project_name=args.name)
2096
+ elif args.watch:
2097
+ _watch(args.source, project_type=args.type, project_name=args.name)
2098
+ elif args.context:
2099
+ output = build_context(args.source, project_type=args.type, project_name=args.name)
2100
+ print(f"Context: file://{output}/showcase_context.json")
2101
+ else:
2102
+ output = build(args.source, project_type=args.type, project_name=args.name)
2103
+ print(f"Open: file://{output}/index.html")