Files
Rpa_Skills/rpa-dashboard-no-ai/templates/detect_frontend.py
T
VasanthandClaude Opus 5.5 f11bddd958 Existing frontend: detect, report, then keep or restyle (ticket 03)
- 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>
2026-10-16 14:27:50 +05:30

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 ".")))