Merge integration/rpa-dashboard-skills (ticket 03) into ticket 02
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
1b6ae23573
15 files changed
+1213
-14
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 ".")))
|
||||
@@ -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 <rpa-dashboard-no-ai>/templates/detect_frontend.py <project root>
|
||||
```
|
||||
|
||||
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`;
|
||||
- `<style>` tags in pages, components and PyQt hosts;
|
||||
- the colours in a Tailwind v3 config.
|
||||
|
||||
It doesn't read theme objects in JS/TS (MUI, styled-components). If it prints
|
||||
`colours none found`, look for one of those, or for colours hard-coded in rules,
|
||||
before you report.
|
||||
|
||||
## 2. Report, then ask keep or restyle
|
||||
|
||||
- **No frontend:** say "No existing frontend found." Don't ask keep or restyle.
|
||||
- **Frontend found:** say the script's first line: "Frontend found at `<path>`,
|
||||
stack `<stack>`, colours `<main tokens>`." If it found more than one, report
|
||||
each and ask which one the dashboard joins.
|
||||
- Then ask one question: **keep its look, or restyle it?** Don't name presets in
|
||||
this question. Colours are asked next, and only for restyle.
|
||||
- Never ask desktop or web. The stack comes from the report: `web` means the
|
||||
web path, `desktop` means the PyQt path.
|
||||
- **No token blocks** (every colour is hard-coded): say so. A restyle can't be
|
||||
token-only then, so recommend keep, or ask before you pull the colours into a
|
||||
new token block.
|
||||
|
||||
## 3. Where the page goes
|
||||
|
||||
| Stack | Put the page |
|
||||
|---|---|
|
||||
| plain HTML | Next to the existing pages, for example `web/dashboard.html`. |
|
||||
| React, Vue, Angular, Svelte | In the app's `public/` folder (`public/dashboard.html`), so it is served as-is. Port it into the app's components only if the user asks: best effort, with the page as reference. |
|
||||
| PyQt web view | Next to the host's page. Use the desktop path (`bridge.py`). |
|
||||
|
||||
Don't edit existing pages to link to it. Tell the user the path to link from
|
||||
their nav.
|
||||
|
||||
## 4. Keep
|
||||
|
||||
The new page uses the project's look. **No existing file changes.** Skip the colour
|
||||
question.
|
||||
|
||||
- Fill the page's `body{}` block from the project's light tokens, and `body.dark{}`
|
||||
from its dark tokens. Copy the **values**, not `var(--…)` references: the page
|
||||
turns dark mode on with `body.dark`, which the project's own dark selector
|
||||
doesn't match. Above each block, note where the values came from, for example
|
||||
`/* kept from web/css/theme.css */`.
|
||||
- Map each page token to the project token with the same role:
|
||||
|
||||
| Page token | Project token for |
|
||||
|---|---|
|
||||
| `--accent`, `--accent-2` | the primary/brand colour, and its lighter or hover shade |
|
||||
| `--accent-dark` | the darkest brand shade (the log box background) |
|
||||
| `--accent-soft`, `--accent-soft-2` | the brand tint (secondary buttons) and its border |
|
||||
| `--bg-top`, `--bg-mid`, `--bg-bottom` | the page background; use the same value three times for a flat page |
|
||||
| `--glass`, `--glass-strong` | the card surface, and the raised or alternate surface |
|
||||
| `--border`, `--ink`, `--muted` | the border, the text and the muted text |
|
||||
| `--ok`, `--warn`, `--bad` and their `-bg` | success, warning and danger; keep the template's values if the project has none |
|
||||
| `--font`, `--font-display` | the body font and the heading font |
|
||||
| `--header-*`, `--primary-bg`, `--nav-active-bg`, `--progress-bg` | wherever the project shows its brand. A solid brand top bar, for example, is `--header-bg:<brand>; --header-ink:#ffffff`. |
|
||||
| `--blur` | `none`, unless the project uses frosted glass |
|
||||
|
||||
- **No match:** run `palette.py` on the brand colour to get the accent family.
|
||||
Keep the Tebra Sky value for anything else.
|
||||
- **No dark tokens:** put `palette.py`'s dark accents on the Tebra Sky dark block.
|
||||
- **Web fonts:** if the project loads its font with a `<link>` or `@import`, add
|
||||
the same line to the page's `<head>`.
|
||||
|
||||
## 5. Restyle
|
||||
|
||||
The existing files change **only inside their token blocks**.
|
||||
|
||||
1. Ask for colours (the skill's colour step): Tebra Sky, Superbill Ember or a
|
||||
custom hex.
|
||||
2. Edit only the token lines that the detect report listed. Give each project
|
||||
token the chosen palette's value for its role (read the keep table from right
|
||||
to left):
|
||||
- the light block gets values from the palette's `body{}` block;
|
||||
- the dark block gets values from its `body.dark{}` block.
|
||||
|
||||
Keep every token name, and keep any token the palette has no match for. Don't
|
||||
add blocks the project doesn't have.
|
||||
3. **Fonts:** change only the font tokens. If the new font needs loading (Ember),
|
||||
add its `<link>` next to the project's other stylesheet links. That is the
|
||||
only other line that may change.
|
||||
4. Leave hard-coded colours outside the token blocks alone, and list them for
|
||||
the user.
|
||||
5. Build the new page with the palette's blocks from `themes.md`, the same way as
|
||||
when there is no frontend.
|
||||
6. Show the user the diff of the existing files: `git diff` in a repo, otherwise
|
||||
each changed line. It must show only token lines, plus the font link if one
|
||||
was added.
|
||||
Reference in new issue
Block a user