- templates/detect_frontend.py: finds plain HTML, React/Vue/Angular/Svelte apps and PyQt web views; prints location, stack, main colours, font and every token block (CSS custom properties, <style> tags, Tailwind config). - templates/frontend.md: shared report / keep / restyle procedure, written so rpa-dashboard-ai can point to it. - SKILL.md steps 1, 2 and 6 point to it. - tests: detection self-check; fixture c-web-existing-frontend; eval entries c-keep and c-restyle (new pages only, per-question grading, checks on what changed in the existing files), both passing. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
135 lines
6.1 KiB
Python
135 lines
6.1 KiB
Python
"""Find an existing frontend in a project: where it is, its stack and its colour tokens.
|
|
|
|
Usage: python detect_frontend.py [project dir]
|
|
|
|
Prints "No existing frontend found.", or one "Frontend found at ..." line per
|
|
frontend followed by its token blocks (file, selector, tokens). The keep and
|
|
restyle steps in frontend.md work from those blocks.
|
|
"""
|
|
import json
|
|
import os
|
|
import re
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
# Not the project's own frontend: dependencies, build output, coverage, docs. Dot folders
|
|
# (.git, .venv, .claude with the installed skills) are skipped too.
|
|
SKIP = {"node_modules", "venv", "env", "site-packages", "__pycache__", "dist", "build", "out",
|
|
"coverage", "htmlcov", "docs"}
|
|
PAGE = {".html", ".htm"}
|
|
STYLE = {".css", ".scss", ".sass", ".less"}
|
|
MARKUP = PAGE | {".vue", ".svelte"} # files whose <style> tags hold CSS
|
|
# A colour value: hex, a colour function, or a bare HSL triplet (shadcn/ui style "222 47% 11%").
|
|
COLOUR = re.compile(r"#[0-9a-f]{3,8}\b|\b(rgba?|hsla?|oklch|oklab|lab|lch|color-mix)\(|^[\d.]+(deg)?\s+[\d.]+%\s+[\d.]+%", re.IGNORECASE)
|
|
BRAND = re.compile(r"primary|brand|accent", re.IGNORECASE)
|
|
FONT_TOKEN = re.compile(r"font(?!.*(size|weight|height))", re.IGNORECASE)
|
|
FRAMEWORKS = [("@angular/core", "Angular"), ("vue", "Vue"), ("svelte", "Svelte"), ("react", "React")]
|
|
TOOLS = [("next", "Next.js"), ("nuxt", "Nuxt"), ("vite", "Vite"), ("tailwindcss", "Tailwind")]
|
|
|
|
|
|
def walk(root):
|
|
for d, dirs, names in os.walk(root):
|
|
dirs[:] = sorted(x for x in dirs if x not in SKIP and not x.startswith("."))
|
|
for n in sorted(names):
|
|
yield Path(d, n)
|
|
|
|
|
|
def css_of(path):
|
|
text = path.read_text(encoding="utf-8", errors="replace")
|
|
if path.suffix.lower() in STYLE:
|
|
return text
|
|
return "\n".join(re.findall(r"<style[^>]*>(.*?)</style>", text, re.DOTALL | re.IGNORECASE))
|
|
|
|
|
|
def token_blocks(css):
|
|
"""[(selector path, {token: value})] for each innermost rule that declares custom properties."""
|
|
css = re.sub(r"/\*.*?\*/", "", css, flags=re.DOTALL)
|
|
out, stack, last = [], [], 0
|
|
for m in re.finditer(r"[{}]", css):
|
|
if m[0] == "{":
|
|
if stack:
|
|
stack[-1][2] = True # has a nested rule
|
|
stack.append([css[last:m.start()].split(";")[-1].strip(), m.end(), False])
|
|
elif stack:
|
|
sel, start, nested = stack.pop()
|
|
# ponytail: declarations next to a nested rule (CSS nesting) are skipped; token blocks rarely nest
|
|
tokens = {} if nested else {k: v.strip() for k, v in re.findall(r"(--[\w-]+)\s*:\s*([^;]+)", css[start:m.start()])}
|
|
if tokens:
|
|
out.append((" ".join([s for s, *_ in stack] + [sel]), tokens))
|
|
last = m.end()
|
|
return out
|
|
|
|
|
|
def rel(root, p):
|
|
r = p.relative_to(root).as_posix()
|
|
return "./" if r == "." else r + "/" if (root / r).is_dir() else r
|
|
|
|
|
|
def tailwind_colours(path):
|
|
"""Quoted hex colours in a Tailwind v3 config, by key."""
|
|
# ponytail: regex, not a JS parser; nested shades keep only their own key (e.g. "500")
|
|
return dict(re.findall(r"""['"]?([\w-]+)['"]?\s*:\s*['"](#[0-9a-fA-F]{3,8})['"]""", path.read_text(encoding="utf-8")))
|
|
|
|
|
|
def styled(p):
|
|
return p.suffix.lower() in MARKUP | STYLE or p.name.startswith("tailwind.config.")
|
|
|
|
|
|
def frontend(root, path, stack, files):
|
|
"""files: the frontend's style-bearing files (see styled) plus any PyQt host."""
|
|
blocks = []
|
|
for f in files:
|
|
found = [("theme", tailwind_colours(f))] if f.name.startswith("tailwind.config.") else token_blocks(css_of(f))
|
|
blocks += [{"file": rel(root, f), "selector": sel, "dark": "dark" in sel.lower(), "tokens": t} for sel, t in found if t]
|
|
return {"path": rel(root, path), "stack": stack, "blocks": blocks}
|
|
|
|
|
|
def detect(root):
|
|
root = Path(root).resolve()
|
|
files = list(walk(root))
|
|
found = []
|
|
for p in files:
|
|
if p.name != "package.json":
|
|
continue
|
|
try:
|
|
pkg = json.loads(p.read_text(encoding="utf-8"))
|
|
except ValueError:
|
|
continue
|
|
deps = {**pkg.get("dependencies", {}), **pkg.get("devDependencies", {})}
|
|
name = next((n for k, n in FRAMEWORKS if k in deps), None)
|
|
if name:
|
|
stack = f"{name} ({', '.join([n for k, n in TOOLS if k in deps] + ['web'])})"
|
|
found.append(frontend(root, p.parent, stack, [f for f in files if p.parent in f.parents and styled(f)]))
|
|
apps = [root / f["path"] for f in found]
|
|
rest = [p for p in files if not any(a in p.parents for a in apps)]
|
|
pages = [p for p in rest if p.suffix.lower() in PAGE]
|
|
hosts = [p for p in rest if p.suffix == ".py" and "QWebEngineView" in p.read_text(encoding="utf-8", errors="replace")]
|
|
if pages or hosts:
|
|
styles = [p for p in rest if styled(p) and p not in pages]
|
|
# ponytail: one plain-HTML/PyQt frontend per project, at the common folder of its pages and styles
|
|
home = Path(os.path.commonpath([p.parent for p in pages + styles])) if pages else hosts[0]
|
|
stack = f"PyQt web view (desktop, host `{rel(root, hosts[0])}`)" if hosts else "plain HTML (web)"
|
|
found.append(frontend(root, home, stack, pages + styles + hosts))
|
|
return found
|
|
|
|
|
|
def report(found):
|
|
if not found:
|
|
return "No existing frontend found."
|
|
lines = []
|
|
for f in found:
|
|
light = [b["tokens"] for b in f["blocks"] if not b["dark"]]
|
|
colours = sorted(((k, v) for t in light for k, v in t.items() if COLOUR.search(v)), key=lambda kv: not BRAND.search(kv[0]))
|
|
fonts = [v for t in light for k, v in t.items() if FONT_TOKEN.search(k)]
|
|
shown = ", ".join(f"{k} {v}" for k, v in colours[:6]) + (f" (+{len(colours) - 6} more)" if len(colours) > 6 else "")
|
|
lines.append(f"Frontend found at `{f['path']}`, stack `{f['stack']}`, colours `{shown or 'none found'}`"
|
|
+ (f", fonts `{fonts[0]}`" if fonts else "") + ".")
|
|
for b in f["blocks"]:
|
|
lines.append(f" {b['file']} {b['selector']}{' (dark)' if b['dark'] else ''}")
|
|
lines.append(" " + "; ".join(f"{k}:{v}" for k, v in b["tokens"].items()))
|
|
return "\n".join(lines)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
print(report(detect(sys.argv[1] if len(sys.argv) > 1 else ".")))
|