diff --git a/rpa-dashboard-no-ai/SKILL.md b/rpa-dashboard-no-ai/SKILL.md index 021a2f3..b034ff4 100644 --- a/rpa-dashboard-no-ai/SKILL.md +++ b/rpa-dashboard-no-ai/SKILL.md @@ -16,6 +16,8 @@ source projects. | `templates/contract.md` | The data contract: events the bot sends, actions the page sends. | | `templates/themes.md` | Theme presets (Tebra Sky, Superbill Ember) and the custom-colour steps. | | `templates/palette.py` | Derives a custom palette from one brand hex and checks contrast. | +| `templates/frontend.md` | Existing frontend: detect it, report it, then keep or restyle. Shared with `rpa-dashboard-ai`. | +| `templates/detect_frontend.py` | Finds an existing frontend and prints its location, stack and token blocks. | | `templates/reference/` | Raw Tebra extracts. Use them only to port optional sections. | Follow the steps in order. Ask one question at a time, and wait for the answer. @@ -24,22 +26,24 @@ If the user's request already answers a question, don't ask it again. ## 1. Scan the project Before asking anything, look at the project: -- **Frontend:** `*.html` files outside `node_modules`, `.venv` and build output; a - `web/`, `frontend/` or `static/` folder; `package.json` with react, vue, angular or - svelte; a PyQt `QWebEngineView`. For any you find, note the location, the stack and - the main colour tokens (CSS custom properties or the Tailwind theme). +- **Frontend:** run `python templates/detect_frontend.py `. It finds + plain HTML, React/Vue/Angular/Svelte apps and PyQt web views, and prints each + one's location, stack and colour tokens. `templates/frontend.md` says what it + covers and what to do with its report. - **Bot:** the entry point (for example `main.py`, a `__main__` module or a run script), what it prints (step markers, status and error lines), and how it starts. ## 2. Report the frontend, then ask keep or restyle +Follow `templates/frontend.md` sections 2 to 5. In short: - **No frontend:** say "No existing frontend found." and go to step 3. Don't describe a frontend that isn't there. - **Frontend found:** say "Frontend found at ``, stack ``, colours `
`." Then ask: **keep its look**, or **restyle** it? - Keep: skip step 3. Build the page with the project's own tokens and fonts, and - change none of its existing styles. - - Restyle: go to step 3. Change only the theme tokens, never layout or logic. + change no existing file. + - Restyle: go to step 3. In the existing files, change only the token blocks, + never layout or logic, and show the user the diff. ## 3. Ask for colours @@ -73,6 +77,7 @@ sections must leave no markup, script or style behind, so delete their - Desktop: put it next to the host, for example `dashboard/dashboard.html`. - Web: put it in the backend's static folder. With no backend yet, use `dashboard/dashboard.html`. + - Existing frontend: put it where `templates/frontend.md` section 3 says. 2. Fill every `{{...}}` placeholder: | Placeholder | Value | @@ -86,7 +91,9 @@ sections must leave no markup, script or style behind, so delete their 3. Apply the theme by replacing the `body{...}` and `body.dark{...}` token blocks with the preset from `templates/themes.md`. For Ember, also add its font link. For a custom colour, run `python templates/palette.py ""`, paste the - tokens it prints, and show the user its contrast table. + tokens it prints, and show the user its contrast table. If the user chose keep, + fill the blocks from the project's tokens instead (`templates/frontend.md` + section 4). 4. Desktop: also copy `templates/bridge.py` and `templates/bot_runner.py` next to the page. Fill `{{ORG}}` and `{{APP_NAME}}` in `bridge.py`, and `{{BOT_MODULE}}` in `bot_runner.py` (with no bot yet, use the module the bot will have, for example `bot`). diff --git a/rpa-dashboard-no-ai/templates/detect_frontend.py b/rpa-dashboard-no-ai/templates/detect_frontend.py new file mode 100644 index 0000000..103ecc5 --- /dev/null +++ b/rpa-dashboard-no-ai/templates/detect_frontend.py @@ -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 ", 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 "."))) diff --git a/rpa-dashboard-no-ai/templates/frontend.md b/rpa-dashboard-no-ai/templates/frontend.md new file mode 100644 index 0000000..694adab --- /dev/null +++ b/rpa-dashboard-no-ai/templates/frontend.md @@ -0,0 +1,114 @@ +# Existing frontend: detect, report, keep or restyle + +Shared by `rpa-dashboard-no-ai` and `rpa-dashboard-ai`. The files named here sit +next to this one, in `rpa-dashboard-no-ai/templates/`. From the AI skill, reach them +through `../rpa-dashboard-no-ai/templates/`. + +## 1. Detect + +Run this before asking anything: + +``` +python /templates/detect_frontend.py +``` + +It finds: +- React, Vue, Angular and Svelte apps (a `package.json` that lists one); +- a PyQt `QWebEngineView` host and the pages it shows; +- plain HTML pages and their stylesheets. + +It skips `node_modules`, virtualenvs, dot folders (including `.claude`), build +output, coverage and `docs`. For each frontend it prints: +- one line: location, stack (with `web` or `desktop`), main colours and font; +- then every token block: the file, the selector (dark blocks are marked) and + the tokens. + +Tokens come from: +- CSS custom properties, including Tailwind v4 `@theme`; +- ` + + +
+ + +
+
+
Dashboard
Overview of every batch
+
+ + +
System ready
+
+
+ +
+ +
+
+
0
Total batches
+
0
Completed
+
0
In progress
+
0
Not completed
+
+
+
Batch status split
+
    +
    +
    Recent batches
    No batches yet
    +
    +
    + + + + + + +
    +
    +
    + + + + diff --git a/tests/evals/results/c-keep.json b/tests/evals/results/c-keep.json new file mode 100644 index 0000000..aba94f2 --- /dev/null +++ b/tests/evals/results/c-keep.json @@ -0,0 +1,29 @@ +{ + "fixture": "c-keep", + "passed": true, + "checks": { + "frontend reported correctly": true, + "asks /keep|restyle/": true, + "asks /section/": true, + "never asks /^(?!.*(keep|restyle)).*(colou?r|Tebra Sky|Ember)/": true, + "never asks /desktop/": true, + "existing files unchanged": true, + "exactly one page built": true, + "no placeholders left": true, + "page has --accent:#0f766e": true, + "page has Trebuchet MS": true, + "page check passes": true + }, + "page_check": [ + "22 passed in 18.02s" + ], + "files": [ + "README.md", + "web\\css\\app.css", + "web\\css\\theme.css", + "web\\dashboard.html", + "web\\index.html", + "web\\js\\app.js" + ], + "reply": "Dashboard ready \u2014 **`web/dashboard.html`** (web path, plain HTML, sample data).\n\n| Item | Status |\n|---|---|\n| Look | **Kept** \u2014 the colours and fonts come from `web/css/theme.css` (teal brand, Trebuchet/Georgia), for both light and dark |\n| Existing files | **Not touched** (index.html, theme.css, app.css, app.js are unchanged) |\n| Sections | Sidebar, top bar (dark mode toggle + status dot), KPI cards, status donut, Run panel (steps + log), Report table (search + Excel). No optional sections. |\n| Data | `SAMPLE_DATA = true` \u2014 there is no bot yet. Steps are `Initializing \u2192 Load jobs \u2192 Process jobs \u2192 Completed`. Report columns are Job / Site / Status. |\n\n**Checks (headless Playwright):** 6 KPI batches, 4 legend items, 4/4 steps done, 5 log lines, 4/4 report rows with pills. No `{{` left and no console errors. I checked light, dark and 800px wide.\n\n- **Small tweak:** the Run button was the same teal as the top bar, so it was hard to see. I set `--primary-bg` to the project's own `--color-brand-strong` (#115e59).\n- **Palette fill-ins:** the project has no token for `--accent-2`, `--accent-dark` or `--accent-soft-2`, so those come from `palette.py`. All contrast checks pass.\n- **When the bot exists:** it must send the `status`, `step`, `log`, `dashboard_data` and `report_data` events. Set `SAMPLE_DATA=false` and serve `/api/dashboard` and `/api/run/status`.\n- **Navigation:** add a link to `dashboard.html` from your nav in `index.html`. I didn't edit that file.\n\nFRONTEND: web/ \u2014 plain HTML (web), tokens in web/css/theme.css (:root + [data-theme=\"dark\"]), brand #0f766e, fonts \"Trebuchet MS\"/Georgia\nQUESTIONS: Keep its look, or restyle it? | Which optional sections should I add: checkpoint tracker, admin/roles screen, login page, or none?" +} \ No newline at end of file diff --git a/tests/evals/results/c-restyle.dashboard.html b/tests/evals/results/c-restyle.dashboard.html new file mode 100644 index 0000000..b700e20 --- /dev/null +++ b/tests/evals/results/c-restyle.dashboard.html @@ -0,0 +1,309 @@ + + + + + + +Field Ops Portal + + + + + +
    + + +
    +
    +
    Dashboard
    Overview of every batch
    +
    + + +
    System ready
    +
    +
    + +
    + +
    +
    +
    0
    Total batches
    +
    0
    Completed
    +
    0
    In progress
    +
    0
    Not completed
    +
    +
    +
    Batch status split
    +
      +
      +
      Recent batches
      No batches yet
      +
      +
      + + + + + + +
      +
      +
      + + + + diff --git a/tests/evals/results/c-restyle.json b/tests/evals/results/c-restyle.json new file mode 100644 index 0000000..59a8cb7 --- /dev/null +++ b/tests/evals/results/c-restyle.json @@ -0,0 +1,29 @@ +{ + "fixture": "c-restyle", + "passed": true, + "checks": { + "frontend reported correctly": true, + "asks /keep|restyle/": true, + "asks /Tebra Sky|Ember/": true, + "asks /section/": true, + "never asks /desktop/": true, + "existing files: only token lines changed": true, + "existing tokens now use #ff3049": true, + "exactly one page built": true, + "no placeholders left": true, + "page has --accent:#ff3049": true, + "page check passes": true + }, + "page_check": [ + "22 passed in 23.29s" + ], + "files": [ + "README.md", + "web\\css\\app.css", + "web\\css\\theme.css", + "web\\dashboard.html", + "web\\index.html", + "web\\js\\app.js" + ], + "reply": "Dashboard ready, Superbill Ember theme-la restyle pannirukken. Frontend `web/`-la irundhudhu (plain HTML, web). Andha site-la token lines and oru font link mattum dhaan maathinen.\n\n| Item | Result |\n|---|---|\n| Pudhu page | `web/dashboard.html` (sample data, `SAMPLE_DATA = true`) |\n| Sections | Shell, KPI, donut, run panel, report. Optional sections edhuvum illa; `OPTIONAL` marker-um illa |\n| Restyle | `web/css/theme.css` token values + `web/index.html`-la font link |\n| Check | `{{` 0 left \u00b7 KPI total 6 \u00b7 legend 4 \u00b7 steps 4/4, log 5 lines, status \"Completed\" \u00b7 report 4 rows / 4 pills \u00b7 light, dark, 800px screenshots paathen, ellam correct-ah irukku |\n\n**Existing files-la changed lines:**\n```\ntheme.css :root --color-brand #0f766e\u2192#ff3049 \u00b7 -strong #115e59\u2192#18151f \u00b7 -tint #ccfbf1\u2192#f7f6fa\n --color-bg \u2192#f4f3f7 \u00b7 -surface-alt \u2192#f7f6fa \u00b7 -border \u2192#e2dfe9 \u00b7 -text \u2192#18151f \u00b7 -text-muted \u2192#66607a\n -success \u2192#1f9d57 \u00b7 -warning \u2192#b7791f \u00b7 -danger \u2192#c0392b\n --font-sans \u2192'IBM Plex Sans',\u2026 \u00b7 --font-heading \u2192'Sora',\u2026 (--color-surface, --radius maarala)\ntheme.css [dark] brand \u2192#ff3049 \u00b7 -strong \u2192#100e1a \u00b7 -tint \u2192#1b1727 \u00b7 bg \u2192#0a0912 \u00b7 surface \u2192#151220\n surface-alt \u2192#1b1727 \u00b7 border \u2192#2a2438 \u00b7 text \u2192#f3f1f8 \u00b7 muted \u2192#8d87a0 \u00b7 ok/warn/bad \u2192#2fbf72/#d9a13c/#e05a4b\nindex.html + Google Fonts (IBM Plex Sans + Sora), theme.css link-ku munnaadi\n```\n\nBot innum illa. Bot ready aana, backend-ah wire pannanum: `GET /api/dashboard`, `GET /api/run/status` (idhu `{running, events[]}` return pannanum), and `POST /api/`. Bot anuppa vendiya events: `status`, `step`, `log`, `dashboard_data`, `report_data`. `index.html`-oda nav-la `dashboard.html`-ku link nee dhaan podanum; adha naan touch pannala.\n\n**Also noticed (fix pannala):**\n- `app.css`-la hard-coded `#ffffff` irukku: topbar text, links, `.button`, `.tag`. Token-only restyle-nala adhu maarala.\n- Portal dark mode-la \"Dark mode\" button text theriyala. Text-um background-um ore colour family (`brand-strong` on `brand-tint`). Idhu munnaadiye irundha issue; teal theme-la rendum `#134e4a` dhaan.\n- Dashboard-la file:// vazhiya open pannum bodhu dark toggle `set-dark`-ku POST anuppudhu, adhu console-la oru error kaattudhu. Page-ku edhuvum aagala; server vazhiya serve pannina indha error varaadhu.\n\nFRONTEND: web/ \u2014 plain HTML (web), tokens in web/css/theme.css (:root + [data-theme=\"dark\"]), brand #0f766e teal, fonts \"Trebuchet MS\" / Georgia\nQUESTIONS: Keep its look, or restyle it? | Which colours: Tebra Sky, Superbill Ember, or Custom (one brand hex)? | Which optional sections to add: checkpoint tracker, admin/roles screen, login page, or none?" +} \ No newline at end of file diff --git a/tests/evals/run_eval.py b/tests/evals/run_eval.py index 9e7366f..bd35850 100644 --- a/tests/evals/run_eval.py +++ b/tests/evals/run_eval.py @@ -9,6 +9,7 @@ prompt (`-p` can't answer questions). It then grades the reply, runs the Seam 1 check on the page it built, and writes tests/evals/results/.json. Exit code 0 means every check passed. """ +import difflib import importlib.util import json import os @@ -45,7 +46,49 @@ FIXTURES = { "never_asks": [r"\bkeep\b|restyle", r"desktop|web"], "bot": True, }, + # (c) existing teal frontend in web/, run once per answer to keep-or-restyle. + "c-keep": { + "fixture": "c-web-existing-frontend", + "prompt": "Build me an RPA dashboard for this project. I can't answer questions while you " + "work, so here are my answers to anything the skill asks. Keep or restyle the " + "existing frontend: keep its look. Optional sections: none.", + "frontend": r"(?=.*\bweb\b)(?=.*html)(?=.*(#0f766e|--color-brand))", + "asks": [r"keep|restyle", r"section"], + "never_asks": [r"^(?!.*(keep|restyle)).*(colou?r|Tebra Sky|Ember)", r"desktop"], + "page_has": ["--accent:#0f766e", "Trebuchet MS"], + "existing": "unchanged", + }, + "c-restyle": { + "fixture": "c-web-existing-frontend", + "prompt": "Build me an RPA dashboard for this project. I can't answer questions while you " + "work, so here are my answers to anything the skill asks. Keep or restyle the " + "existing frontend: restyle it. Colours: Superbill Ember. Optional sections: none.", + "frontend": r"(?=.*\bweb\b)(?=.*html)(?=.*(#0f766e|--color-brand))", + "asks": [r"keep|restyle", r"Tebra Sky|Ember", r"section"], + "never_asks": [r"desktop"], + "page_has": ["--accent:#ff3049"], + "existing": "#ff3049", # only token lines may change, and the theme now uses this colour + }, } +# A line a restyle may change: a custom property, a comment, a blank, or a web-font link. +TOKEN_LINE = r"^\s*(--[\w-]+\s*:.*|/\*.*\*/|]*fonts\.googleapis\.com.*|@import.*fonts\.googleapis\.com.*)?\s*$" + + +def squash(s): + """Ignore spacing and case when looking for a token in the built page.""" + return re.sub(r"\s+", "", s).lower() + + +def changed_lines(src, project): + """Lines removed or added in the fixture's own files.""" + out = [] + for f in src.rglob("*"): + if f.is_file(): + new = project / f.relative_to(src) + after = new.read_text(encoding="utf-8").splitlines() if new.exists() else [] + out += [x[1:] for x in difflib.unified_diff(f.read_text(encoding="utf-8").splitlines(), after, lineterm="", n=0) + if x[:1] in "+-" and x[:3] not in ("+++", "---")] + return out def bot_checks(project, html, checks, replay): @@ -73,7 +116,8 @@ def main(fixture): cfg = FIXTURES[fixture] work = Path(tempfile.mkdtemp(prefix=f"rpa-eval-{fixture}-")) project = work / "project" - shutil.copytree(ROOT / "tests" / "fixtures" / fixture, project) + src = ROOT / "tests" / "fixtures" / cfg.get("fixture", fixture) + shutil.copytree(src, project) for skill in ROOT.glob("rpa-dashboard-*"): shutil.copytree(skill, project / ".claude" / "skills" / skill.name) @@ -91,12 +135,21 @@ def main(fixture): return m[1] if m else "" checks = {"frontend reported correctly": bool(re.search(cfg["frontend"], line("FRONTEND"), re.I))} + questions = line("QUESTIONS").split("|") for pat in cfg["asks"]: - checks[f"asks /{pat}/"] = bool(re.search(pat, line("QUESTIONS"), re.I)) + checks[f"asks /{pat}/"] = any(re.search(pat, q, re.I) for q in questions) for pat in cfg["never_asks"]: - checks[f"never asks /{pat}/"] = not re.search(pat, line("QUESTIONS"), re.I) + checks[f"never asks /{pat}/"] = not any(re.search(pat, q, re.I) for q in questions) + if "existing" in cfg: + changed = changed_lines(src, project) + if cfg["existing"] == "unchanged": + checks["existing files unchanged"] = not changed + else: + checks["existing files: only token lines changed"] = bool(changed) and all(re.match(TOKEN_LINE, x) for x in changed) + checks[f"existing tokens now use {cfg['existing']}"] = any(cfg["existing"] in x.lower() for x in changed) - pages = [p for p in project.rglob("*.html") if ".claude" not in p.parts] + pages = [p for p in project.rglob("*.html") + if ".claude" not in p.parts and not (src / p.relative_to(project)).exists()] # new pages only checks["exactly one page built"] = len(pages) == 1 results = ROOT / "tests" / "evals" / "results" results.mkdir(exist_ok=True) @@ -110,6 +163,8 @@ def main(fixture): spec.loader.exec_module(palette) t = palette.derive(cfg["brand"]) checks["custom palette applied"] = all(f"--accent:{t[m]['--accent']}" in html for m in t) + for needle in cfg.get("page_has", []): + checks[f"page has {needle}"] = squash(needle) in squash(html) env = {**os.environ, "PAGE_CHECK_HTML": str(pages[0])} if cfg.get("bot"): env.update(bot_checks(project, html, checks, results / f"{fixture}.run.jsonl")) diff --git a/tests/fixtures/c-web-existing-frontend/README.md b/tests/fixtures/c-web-existing-frontend/README.md new file mode 100644 index 0000000..aba6eab --- /dev/null +++ b/tests/fixtures/c-web-existing-frontend/README.md @@ -0,0 +1,10 @@ +# Fixture (c): web project with an existing frontend + +A plain HTML site in `web/` with its own teal theme: light and dark tokens in +`web/css/theme.css`, layout in `web/css/app.css`, logic in `web/js/app.js`. No bot. + +The no-AI skill should report this frontend (location, stack, colours), ask keep or +restyle, never ask desktop or web, and build a dashboard page on sample data. +- Keep: no colour question; the page uses the theme's tokens and fonts; no existing + file changes. +- Restyle: asks for colours; only token lines in the existing files change. diff --git a/tests/fixtures/c-web-existing-frontend/web/css/app.css b/tests/fixtures/c-web-existing-frontend/web/css/app.css new file mode 100644 index 0000000..5182090 --- /dev/null +++ b/tests/fixtures/c-web-existing-frontend/web/css/app.css @@ -0,0 +1,18 @@ +/* Layout and components. Colours and fonts come from theme.css. */ +* { box-sizing: border-box; margin: 0; } +body { font-family: var(--font-sans); background: var(--color-bg); color: var(--color-text); } +h1, h2 { font-family: var(--font-heading); } +.topbar { display: flex; align-items: center; gap: 16px; padding: 14px 24px; background: var(--color-brand); color: #ffffff; } +.topbar nav { margin-left: auto; display: flex; gap: 12px; } +.topbar a { color: #ffffff; text-decoration: none; } +.page { max-width: 960px; margin: 24px auto; padding: 0 16px; } +.card { background: var(--color-surface); border: 1px solid var(--color-border); border-radius: var(--radius); padding: 20px; } +.button { background: var(--color-brand); color: #ffffff; border: 0; border-radius: var(--radius); padding: 8px 14px; cursor: pointer; } +.button.secondary { background: var(--color-brand-tint); color: var(--color-brand-strong); } +table { width: 100%; border-collapse: collapse; margin-top: 12px; } +th { text-align: left; background: var(--color-surface-alt); color: var(--color-text-muted); } +th, td { padding: 8px 10px; border-bottom: 1px solid var(--color-border); } +.tag { padding: 2px 8px; border-radius: 999px; color: #ffffff; font-size: 12px; } +.tag.ok { background: var(--color-success); } +.tag.late { background: var(--color-warning); } +.tag.failed { background: var(--color-danger); } diff --git a/tests/fixtures/c-web-existing-frontend/web/css/theme.css b/tests/fixtures/c-web-existing-frontend/web/css/theme.css new file mode 100644 index 0000000..3cf2438 --- /dev/null +++ b/tests/fixtures/c-web-existing-frontend/web/css/theme.css @@ -0,0 +1,33 @@ +/* Field Ops Portal theme. Every colour and font the site uses comes from here. */ +:root { + --color-brand: #0f766e; + --color-brand-strong: #115e59; + --color-brand-tint: #ccfbf1; + --color-bg: #f8fafc; + --color-surface: #ffffff; + --color-surface-alt: #f1f5f9; + --color-border: #e2e8f0; + --color-text: #0f172a; + --color-text-muted: #64748b; + --color-success: #15803d; + --color-warning: #b45309; + --color-danger: #b91c1c; + --font-sans: "Trebuchet MS", "Segoe UI", sans-serif; + --font-heading: Georgia, "Times New Roman", serif; + --radius: 6px; +} + +[data-theme="dark"] { + --color-brand: #0d9488; + --color-brand-strong: #134e4a; + --color-brand-tint: #134e4a; + --color-bg: #0b1120; + --color-surface: #111827; + --color-surface-alt: #1f2937; + --color-border: #334155; + --color-text: #e2e8f0; + --color-text-muted: #94a3b8; + --color-success: #22c55e; + --color-warning: #f59e0b; + --color-danger: #ef4444; +} diff --git a/tests/fixtures/c-web-existing-frontend/web/index.html b/tests/fixtures/c-web-existing-frontend/web/index.html new file mode 100644 index 0000000..543512a --- /dev/null +++ b/tests/fixtures/c-web-existing-frontend/web/index.html @@ -0,0 +1,26 @@ + + + + + +Field Ops Portal + + + + +
      +

      Field Ops Portal

      + +
      +
      +
      +

      Today's jobs

      + + + +
      JobSiteStatus
      +
      +
      + + + diff --git a/tests/fixtures/c-web-existing-frontend/web/js/app.js b/tests/fixtures/c-web-existing-frontend/web/js/app.js new file mode 100644 index 0000000..5741688 --- /dev/null +++ b/tests/fixtures/c-web-existing-frontend/web/js/app.js @@ -0,0 +1,15 @@ +// Field Ops Portal: job list and dark toggle. +const JOBS = [ + { job: "J-101", site: "North depot", status: "Done", tag: "ok" }, + { job: "J-102", site: "Harbour road", status: "Late", tag: "late" }, + { job: "J-103", site: "West yard", status: "Failed", tag: "failed" }, +]; + +document.getElementById("jobs").innerHTML = JOBS.map( + (j) => `${j.job}${j.site}${j.status}` +).join(""); + +document.getElementById("theme-btn").onclick = () => { + const root = document.documentElement; + root.dataset.theme = root.dataset.theme === "dark" ? "light" : "dark"; +}; diff --git a/tests/test_detect_frontend.py b/tests/test_detect_frontend.py new file mode 100644 index 0000000..37fb4fa --- /dev/null +++ b/tests/test_detect_frontend.py @@ -0,0 +1,105 @@ +"""Frontend detection: where an existing frontend is, its stack and its colour tokens.""" +import importlib.util +import json +import subprocess +import sys +from pathlib import Path + +DETECT = Path(__file__).resolve().parents[1] / "rpa-dashboard-no-ai" / "templates" / "detect_frontend.py" +spec = importlib.util.spec_from_file_location("detect_frontend", DETECT) +detect_frontend = importlib.util.module_from_spec(spec) +spec.loader.exec_module(detect_frontend) + + +def project(tmp_path, files): + for name, text in files.items(): + (tmp_path / name).parent.mkdir(parents=True, exist_ok=True) + (tmp_path / name).write_text(text, encoding="utf-8") + return tmp_path + + +def cli(root): + return subprocess.run([sys.executable, str(DETECT), str(root)], capture_output=True, text=True, check=True).stdout + + +def test_no_frontend_when_only_tooling_and_installed_skills_have_html(tmp_path): + root = project(tmp_path, { + "main.py": "print('[step 1] Login')\n", + "node_modules/pkg/index.html": "", + ".venv/lib/site.html": "

      ", + ".claude/skills/rpa-dashboard-no-ai/templates/dashboard.html": "", + "dist/index.html": "

      ", + }) + assert detect_frontend.detect(root) == [] + assert cli(root).strip() == "No existing frontend found." + + +FIXTURE_C = Path(__file__).resolve().parent / "fixtures" / "c-web-existing-frontend" + + +def test_plain_html_site_reports_location_stack_light_and_dark_tokens(): + [f] = detect_frontend.detect(FIXTURE_C) + assert f["path"] == "web/" + assert f["stack"] == "plain HTML (web)" + light, dark = f["blocks"] + assert (light["file"], light["selector"], light["dark"]) == ("web/css/theme.css", ":root", False) + assert light["tokens"]["--color-brand"] == "#0f766e" + assert light["tokens"]["--font-sans"] == '"Trebuchet MS", "Segoe UI", sans-serif' + assert (dark["selector"], dark["dark"]) == ('[data-theme="dark"]', True) + assert dark["tokens"]["--color-brand"] == "#0d9488" + + out = cli(FIXTURE_C) + first = out.splitlines()[0] + assert first.startswith("Frontend found at `web/`, stack `plain HTML (web)`, colours `--color-brand #0f766e,") + assert 'fonts `"Trebuchet MS", "Segoe UI", sans-serif`' in first + assert "web/css/theme.css" in out and '[data-theme="dark"]' in out + + +def test_react_app_is_one_frontend_with_its_media_query_dark_tokens(tmp_path): + root = project(tmp_path, { + "server.py": "from fastapi import FastAPI\n", + "frontend/package.json": json.dumps({"dependencies": {"react": "^19.0.0", "react-dom": "^19.0.0"}, + "devDependencies": {"vite": "^6.0.0"}}), + "frontend/index.html": '

      ', + "frontend/src/index.css": ":root { --primary: 222.2 47.4% 11.2%; --radius: .5rem }\n" + "@media (prefers-color-scheme: dark) { :root { --primary: 210 40% 98% } }", + "frontend/node_modules/react/index.html": "

      ", + }) + [f] = detect_frontend.detect(root) + assert (f["path"], f["stack"]) == ("frontend/", "React (Vite, web)") + light, dark = f["blocks"] + assert (light["file"], light["tokens"]["--primary"], light["dark"]) == ("frontend/src/index.css", "222.2 47.4% 11.2%", False) + assert (dark["selector"], dark["tokens"], dark["dark"]) == ( + "@media (prefers-color-scheme: dark) :root", {"--primary": "210 40% 98%"}, True) + assert "colours `--primary 222.2 47.4% 11.2%`" in cli(root) + + +def test_vue_app_reads_component_styles_and_tailwind_theme(tmp_path): + root = project(tmp_path, { + "package.json": json.dumps({"dependencies": {"vue": "^3.5.0"}, "devDependencies": {"tailwindcss": "^3.4.0"}}), + "tailwind.config.js": "module.exports = { theme: { extend: { colors: { brand: '#e11d48', 'brand-dark': \"#9f1239\" } } } }", + "src/App.vue": "\n", + }) + [f] = detect_frontend.detect(root) + assert (f["path"], f["stack"]) == ("./", "Vue (Tailwind, web)") + blocks = {b["file"]: b for b in f["blocks"]} + assert blocks["src/App.vue"]["tokens"] == {"--surface": "#ffffff"} + assert blocks["tailwind.config.js"]["tokens"] == {"brand": "#e11d48", "brand-dark": "#9f1239"} + assert "brand #e11d48" in cli(root).splitlines()[0] + + +def test_pyqt_web_view_is_a_desktop_frontend(tmp_path): + host = "from PyQt6.QtWebEngineWidgets import QWebEngineView\nview = QWebEngineView()\n" + root = project(tmp_path / "file", { + "app.py": host + "view.load(QUrl.fromLocalFile('ui/page.html'))\n", + "bot.py": "print('[step 1] Login')\n", + "ui/page.html": "", + }) + [f] = detect_frontend.detect(root) + assert (f["path"], f["stack"]) == ("ui/", "PyQt web view (desktop, host `app.py`)") + assert f["blocks"][0]["tokens"] == {"--accent": "#2563eb"} + + inline = project(tmp_path / "inline", {"main.py": host + "view.setHtml('

      Hi

      ')\n"}) + [f] = detect_frontend.detect(inline) + assert (f["path"], f["stack"]) == ("main.py", "PyQt web view (desktop, host `main.py`)") + assert f["blocks"][0]["tokens"] == {"--accent": "#16a34a"}