@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.
- package/README.md +531 -0
- package/bin/aiskills.js +76 -0
- package/lib/cache.js +49 -0
- package/lib/cleanup.js +77 -0
- package/lib/commands/auto-update.js +131 -0
- package/lib/commands/doctor.js +139 -0
- package/lib/commands/list.js +77 -0
- package/lib/commands/skills.js +263 -0
- package/lib/commands/status.js +94 -0
- package/lib/commands/uninstall.js +182 -0
- package/lib/commands/update.js +149 -0
- package/lib/config.js +90 -0
- package/lib/downloader.js +110 -0
- package/lib/hooks.js +74 -0
- package/lib/installer.js +114 -0
- package/lib/platform.js +112 -0
- package/lib/prompts/checkboxCancel.js +264 -0
- package/lib/prompts/selectCancel.js +204 -0
- package/lib/symlink.js +154 -0
- package/lib/utils.js +49 -0
- package/package.json +61 -0
- package/skills/humaniza/SKILL.md +51 -0
- package/skills/humaniza/agents/openai.yaml +4 -0
- package/skills/humaniza/references/ai-patterns-es.md +51 -0
- package/skills/humaniza/references/checklist.md +9 -0
- package/skills/humaniza/references/examples.md +17 -0
- package/skills/humaniza/references/lexicon-es-mx.md +36 -0
- package/skills/humaniza/references/modes-es-mx.md +41 -0
- package/skills/humaniza/references/voice-es-mx.md +24 -0
- package/skills/refactoring-ui/SKILL.md +59 -0
- package/skills/refactoring-ui/references/01-design-process.md +72 -0
- package/skills/refactoring-ui/references/02-visual-hierarchy.md +84 -0
- package/skills/refactoring-ui/references/03-layout-spacing.md +69 -0
- package/skills/refactoring-ui/references/04-typography.md +70 -0
- package/skills/refactoring-ui/references/05-color.md +96 -0
- package/skills/refactoring-ui/references/06-depth-shadows.md +74 -0
- package/skills/refactoring-ui/references/07-images.md +75 -0
- package/skills/refactoring-ui/references/08-finishing-touches.md +91 -0
- package/skills/stitch-showcase/SKILL.md +411 -0
- package/skills/stitch-showcase/references/01-navbar.md +52 -0
- package/skills/stitch-showcase/references/02-hero.md +56 -0
- package/skills/stitch-showcase/references/03-design-system.md +102 -0
- package/skills/stitch-showcase/references/04-screen-gallery.md +102 -0
- package/skills/stitch-showcase/references/05-viewer-web.md +105 -0
- package/skills/stitch-showcase/references/06-viewer-mobile.md +104 -0
- package/skills/stitch-showcase/references/07-theme-system.md +77 -0
- package/skills/stitch-showcase/references/08-type-detection.md +81 -0
- package/skills/stitch-showcase/references/09-quality-standards.md +126 -0
- package/skills/stitch-showcase/references/10-component-standardization.md +40 -0
- package/skills/stitch-showcase/references/11-component-catalog.md +70 -0
- package/skills/stitch-showcase/references/catalog-template.html +841 -0
- package/skills/stitch-showcase/references/index.html +299 -0
- package/skills/stitch-showcase/references/viewer.html +412 -0
- package/skills/stitch-showcase/scripts/__pycache__/build_showcase.cpython-314.pyc +0 -0
- package/skills/stitch-showcase/scripts/__pycache__/component_utils.cpython-313.pyc +0 -0
- package/skills/stitch-showcase/scripts/__pycache__/detect_components.cpython-313.pyc +0 -0
- package/skills/stitch-showcase/scripts/__pycache__/extract_catalog.cpython-313.pyc +0 -0
- package/skills/stitch-showcase/scripts/__pycache__/extract_text.cpython-313.pyc +0 -0
- package/skills/stitch-showcase/scripts/__pycache__/extract_zips.cpython-313.pyc +0 -0
- package/skills/stitch-showcase/scripts/__pycache__/parse_design_md.cpython-313.pyc +0 -0
- package/skills/stitch-showcase/scripts/apply_canonical.py +238 -0
- package/skills/stitch-showcase/scripts/build_showcase.py +2103 -0
- package/skills/stitch-showcase/scripts/component_utils.py +398 -0
- package/skills/stitch-showcase/scripts/detect_components.py +284 -0
- package/skills/stitch-showcase/scripts/extract_catalog.py +913 -0
- package/skills/stitch-showcase/scripts/extract_text.py +268 -0
- package/skills/stitch-showcase/scripts/extract_zips.py +178 -0
- package/skills/stitch-showcase/scripts/parse_design_md.py +397 -0
- package/skills/vscode-extension-dev/SKILL.md +114 -0
- package/skills/vscode-extension-dev/references/api-patterns.md +625 -0
- package/skills/vscode-extension-dev/references/architecture.md +287 -0
- package/skills/vscode-extension-dev/references/package-json-schema.md +345 -0
- 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 [("&", "&"), ("<", "<"), (">", ">"),
|
|
616
|
+
(" ", " "), ("'", "'"), (""", '"')]:
|
|
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("&", "&").replace('"', """).replace("<", "<").replace(">", ">")
|
|
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")
|