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>
This commit is contained in:
1 parent
3eb3fdf00e
commit
f11bddd958
15 files changed
+1210
-12
No files matched your search
@@ -0,0 +1,134 @@
|
||||
"""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 ".")))
|
||||
Reference in new issue
Block a user