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:
VasanthandClaude Opus 5.5 committed 2026-10-16 14:27:50 +05:30
1 parent 3eb3fdf00e
commit f11bddd958
15 files changed
+1210 -12

No files matched your search

+14 -7
View File
@@ -15,6 +15,8 @@ source projects.
| `templates/contract.md` | The data contract: events the bot sends, actions the page sends. | | `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/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/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. | | `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. Follow the steps in order. Ask one question at a time, and wait for the answer.
@@ -23,22 +25,24 @@ If the user's request already answers a question, don't ask it again.
## 1. Scan the project ## 1. Scan the project
Before asking anything, look at the project: Before asking anything, look at the project:
- **Frontend:** `*.html` files outside `node_modules`, `.venv` and build output; a - **Frontend:** run `python templates/detect_frontend.py <project root>`. It finds
`web/`, `frontend/` or `static/` folder; `package.json` with react, vue, angular or plain HTML, React/Vue/Angular/Svelte apps and PyQt web views, and prints each
svelte; a PyQt `QWebEngineView`. For any you find, note the location, the stack and one's location, stack and colour tokens. `templates/frontend.md` says what it
the main colour tokens (CSS custom properties or the Tailwind theme). covers and what to do with its report.
- **Bot:** the entry point (for example `main.py`, a `__main__` module or a run - **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. script), what it prints (step markers, status and error lines), and how it starts.
## 2. Report the frontend, then ask keep or restyle ## 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 - **No frontend:** say "No existing frontend found." and go to step 3. Don't
describe a frontend that isn't there. describe a frontend that isn't there.
- **Frontend found:** say "Frontend found at `<path>`, stack `<stack>`, colours - **Frontend found:** say "Frontend found at `<path>`, stack `<stack>`, colours
`<main tokens>`." Then ask: **keep its look**, or **restyle** it? `<main tokens>`." Then ask: **keep its look**, or **restyle** it?
- Keep: skip step 3. Build the page with the project's own tokens and fonts, and - Keep: skip step 3. Build the page with the project's own tokens and fonts, and
change none of its existing styles. change no existing file.
- Restyle: go to step 3. Change only the theme tokens, never layout or logic. - 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 ## 3. Ask for colours
@@ -70,6 +74,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`. - 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 - Web: put it in the backend's static folder. With no backend yet, use
`dashboard/dashboard.html`. `dashboard/dashboard.html`.
- Existing frontend: put it where `templates/frontend.md` section 3 says.
2. Fill every `{{...}}` placeholder: 2. Fill every `{{...}}` placeholder:
| Placeholder | Value | | Placeholder | Value |
@@ -83,7 +88,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 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. with the preset from `templates/themes.md`. For Ember, also add its font link.
For a custom colour, run `python templates/palette.py "<hex>"`, paste the For a custom colour, run `python templates/palette.py "<hex>"`, 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` next to the page, and fill 4. Desktop: also copy `templates/bridge.py` next to the page, and fill
`{{BOT_MODULE}}`, the `STEPS` list (the same steps as the page), `{{ORG}}` and `{{BOT_MODULE}}`, the `STEPS` list (the same steps as the page), `{{ORG}}` and
`{{APP_NAME}}`. `{{APP_NAME}}`.
@@ -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 ".")))
+114
View File
@@ -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.
+3 -1
View File
@@ -25,5 +25,7 @@ uv run --with pytest --with playwright pytest tests
``` ```
Results go to `evals/results/<fixture>.json`. To add a fixture, add an entry to Results go to `evals/results/<fixture>.json`. To add a fixture, add an entry to
`FIXTURES` in `run_eval.py`. `FIXTURES` in `run_eval.py`. An entry with a `"fixture"` key reuses another
fixture folder with different answers: `c-keep` and `c-restyle` both run on
`c-web-existing-frontend`, and also check what changed in the files it already had.
- `fixtures/<letter>-<name>/`: small test projects for the evals. - `fixtures/<letter>-<name>/`: small test projects for the evals.
+312
View File
@@ -0,0 +1,312 @@
<!DOCTYPE html>
<!--
Generic RPA dashboard (no AI). Derived from Tebra ERA (reference/tebra_dashboard.html).
One file: works inside PyQt6 QWebEngineView (QWebChannel bridge) OR served by any web
backend (falls back to polling GET /api/dashboard and /run/status).
Theme = the body / body.dark token blocks only (see themes.md).
-->
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Field Ops Portal</title>
<script src="qrc:///qtwebchannel/qwebchannel.js"></script>
<style>
/* ===== THEME TOKENS ===== */
/* kept from web/css/theme.css :root (accent-2, accent-dark, accent-soft-2 from palette.py "#0f766e": no project match) */
body{
--bg-top:#f8fafc; --bg-mid:#f8fafc; --bg-bottom:#f8fafc;
--glass:#ffffff; --glass-strong:#f1f5f9; --border:#e2e8f0;
--ink:#0f172a; --muted:#64748b; --accent-dark:#041e1c;
--accent:#0f766e; --accent-2:#338b84; --accent-soft:#ccfbf1; --accent-soft-2:#a4cbc8;
--ok:#15803d; --ok-bg:#e6f6ec; --warn:#b45309; --warn-bg:#fbf1dd; --bad:#b91c1c; --bad-bg:#fdecea;
--font:"Trebuchet MS","Segoe UI",sans-serif; --font-display:Georgia,"Times New Roman",serif;
--header-bg:#0f766e; --header-ink:#ffffff; --header-muted:#ccfbf1;
--primary-bg:#115e59;
--nav-active-bg:#0f766e;
--progress-bg:var(--ok); --blur:none;
}
/* kept from web/css/theme.css [data-theme="dark"] (accent-2, accent-dark, accent-soft-2 from palette.py) */
body.dark{
--bg-top:#0b1120; --bg-mid:#0b1120; --bg-bottom:#0b1120;
--glass:#111827; --glass-strong:#1f2937; --border:#334155;
--ink:#e2e8f0; --muted:#94a3b8; --accent-dark:#104541;
--accent:#0d9488; --accent-2:#348b85; --accent-soft:#134e4a; --accent-soft-2:#0c3230;
--ok:#22c55e; --ok-bg:rgba(31,157,87,.16); --warn:#f59e0b; --warn-bg:rgba(183,121,31,.16);
--bad:#ef4444; --bad-bg:rgba(192,57,43,.16);
--header-bg:#0d9488; --header-muted:#ccfbf1;
--primary-bg:#134e4a; --nav-active-bg:#0d9488;
}
/* ===== BASE ===== */
*{box-sizing:border-box;margin:0;padding:0}
html,body{height:100%;overflow:hidden}
body{font-family:var(--font);color:var(--ink);
background:linear-gradient(180deg,var(--bg-top) 0%,var(--bg-mid) 45%,var(--bg-bottom) 100%)}
.app{display:flex;height:100vh}
.hidden{display:none!important}
/* sidebar */
.sidebar{width:230px;min-width:230px;background:var(--glass);border-right:1px solid var(--border);backdrop-filter:var(--blur);display:flex;flex-direction:column}
.logo-area{padding:22px 20px;display:flex;align-items:center;gap:11px;border-bottom:1px solid var(--border)}
.logo-box{width:40px;height:40px;border-radius:12px;background:var(--glass-strong);display:flex;align-items:center;justify-content:center;border:1px solid var(--border)}
.logo-box img{width:28px;height:28px;object-fit:contain}
.logo-title{font-size:15px;font-weight:700}
.logo-sub{font-size:10px;color:var(--muted);margin-top:2px;letter-spacing:.5px;text-transform:uppercase;font-weight:600}
.nav{padding:16px 12px;flex:1}
.nav-item{border:0;background:none;width:100%;font:inherit;text-align:left;display:flex;align-items:center;gap:11px;padding:11px 13px;border-radius:10px;cursor:pointer;margin-bottom:3px;color:var(--muted);font-size:13.5px;font-weight:600;transition:all .15s;user-select:none}
.nav-item svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:2}
.nav-item:hover{background:var(--glass-strong);color:var(--ink)}
.nav-item.active{background:var(--nav-active-bg);color:#fff;box-shadow:0 4px 12px rgba(20,50,70,.22)}
.sidebar-foot{padding:14px;border-top:1px solid var(--border);display:flex;align-items:center;gap:10px}
.avatar{width:34px;height:34px;border-radius:50%;background:var(--nav-active-bg);display:flex;align-items:center;justify-content:center;font-size:12px;font-weight:700;color:#fff}
.u-name{font-size:13px;font-weight:700}.u-role{font-size:10.5px;color:var(--muted)}
/* main + topbar */
.main{flex:1;display:flex;flex-direction:column;overflow:hidden}
.topbar{background:var(--header-bg);color:var(--header-ink);border-bottom:1px solid var(--border);backdrop-filter:var(--blur);padding:20px 28px;display:flex;align-items:center;gap:14px;box-shadow:0 6px 18px rgba(20,50,70,.10)}
.tb-title{font-size:21px;font-weight:800}.tb-sub{font-size:12.5px;color:var(--header-muted);margin-top:3px}
.tb-right{margin-left:auto;display:flex;align-items:center;gap:12px}
.indicator{color:var(--ink);display:flex;align-items:center;gap:7px;background:var(--glass-strong);padding:7px 13px;border-radius:20px;border:1px solid var(--border);font-size:12px;font-weight:700}
.dot{width:8px;height:8px;border-radius:50%}
.dot.ready,.dot.done{background:var(--ok);box-shadow:0 0 7px var(--ok)}
.dot.running{background:var(--warn);box-shadow:0 0 7px var(--warn);animation:blink 1.2s infinite}
.dot.error{background:var(--bad);box-shadow:0 0 7px var(--bad)}
@keyframes blink{50%{opacity:.4}}
.mode-btn{color:var(--ink);width:38px;height:38px;border-radius:11px;border:1px solid var(--border);background:var(--glass-strong);cursor:pointer;font-size:16px}
.content{flex:1;overflow-y:auto;padding:26px 28px;border-top-left-radius:30px}
/* cards, buttons, pills */
.section-label{font-size:11px;text-transform:uppercase;letter-spacing:1px;color:var(--muted);font-weight:700;margin-bottom:14px}
.card{background:var(--glass);border:1px solid var(--border);border-radius:16px;padding:22px;margin-bottom:16px;backdrop-filter:var(--blur);box-shadow:0 10px 30px rgba(20,50,70,.08)}
.c-title{font-size:15px;font-weight:700;margin-bottom:14px}
.btn{padding:9px 15px;border-radius:11px;border:1px solid var(--accent-soft-2);background:linear-gradient(180deg,var(--accent-soft),var(--accent-soft-2));color:var(--ink);font-size:12.5px;font-weight:600;cursor:pointer;transition:all .18s}
.btn:hover{transform:translateY(-1px);box-shadow:0 4px 12px rgba(20,50,70,.2)}
.btn.primary{background:var(--primary-bg);color:#fff;border-color:var(--accent)}
.btn:disabled{opacity:.45;cursor:not-allowed;transform:none;box-shadow:none}
.pill{display:inline-block;padding:4px 11px;border-radius:20px;font-size:11px;font-weight:700;color:#fff}
.pill.success{background:var(--ok)}.pill.attention{background:var(--warn)}.pill.failed{background:var(--bad)}.pill.cleared{background:var(--muted)}
ul,ol{list-style:none}
.logo-title,.tb-title,.c-title,.kpi-val,.dh-num,.btn{font-family:var(--font-display)}
/* KPI */
.kpi-row{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin-bottom:16px}
.kpi{display:flex;align-items:center;gap:13px;padding:16px 18px;border-radius:16px;background:var(--glass-strong);border:1px solid var(--border);box-shadow:0 4px 14px rgba(20,50,70,.10)}
.kpi-ic{width:42px;height:42px;border-radius:12px;position:relative}
.kpi.total .kpi-ic{background:var(--accent)}
.kpi.done .kpi-ic{background:var(--ok)}.kpi.prog .kpi-ic{background:var(--warn)}.kpi.notdone .kpi-ic{background:var(--bad)}
.kpi.prog.live .kpi-ic::after{content:'';position:absolute;inset:-3px;border-radius:14px;border:2px solid var(--warn);animation:kpulse 1.6s ease-out infinite}
@keyframes kpulse{0%{transform:scale(.9);opacity:.6}100%{transform:scale(1.25);opacity:0}}
.kpi-val{font-size:26px;font-weight:800;line-height:1.1;font-variant-numeric:tabular-nums}
.kpi-lbl{font-size:11.5px;color:var(--muted);font-weight:600;margin-top:2px}
/* donut */
.dash-grid{display:grid;grid-template-columns:1fr 1fr;gap:14px}
.donut-wrap{display:flex;align-items:center;gap:26px;flex-wrap:wrap}
.donut{width:150px;height:150px;border-radius:50%;position:relative;background:conic-gradient(var(--border) 0 100%)}
.donut-hole{position:absolute;inset:22px;border-radius:50%;background:var(--bg-bottom);display:flex;flex-direction:column;align-items:center;justify-content:center}
.dh-num{font-size:26px;font-weight:800}.dh-lbl{font-size:10px;color:var(--muted);font-weight:700;text-transform:uppercase}
.dl-row{display:flex;align-items:center;gap:9px;font-size:12.5px;font-weight:600;margin-bottom:9px}
.dl-dot{width:10px;height:10px;border-radius:3px}
/* run panel */
.prog-head{display:flex;gap:12px;margin-bottom:8px;font-size:13px;font-weight:700}
.prog-head .pct{margin-left:auto;color:var(--accent)}
.prog-bg{height:8px;background:var(--glass-strong);border-radius:8px;overflow:hidden;margin-bottom:18px}
.prog-fill{height:100%;width:0;background:var(--progress-bg);transition:width .5s}
.step{display:flex;align-items:center;gap:12px;padding:10px 13px;border-radius:10px;margin-bottom:6px;font-size:13px;font-weight:600}
.step.done{background:var(--ok-bg);color:var(--ok)}.step.active{background:var(--warn-bg);color:var(--warn)}.step.pending{opacity:.5}
.step-dot{width:22px;height:22px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:11px;background:var(--glass-strong)}
.step.done .step-dot{background:var(--ok);color:#fff}.step.active .step-dot{background:var(--warn);color:#fff}
.log{background:var(--accent-dark);border-radius:12px;padding:12px 14px;max-height:220px;overflow-y:auto;font-family:Consolas,'Courier New',monospace;font-size:13px;line-height:1.85;color:#9fb6c4}
.log .info{color:#7fc4f0}.log .success{color:#5fe0a0}.log .warn{color:#f5c563}.log .error{color:#f68c82}
/* report table */
.toolbar{display:flex;gap:10px;align-items:center;margin-bottom:14px;flex-wrap:wrap}
.toolbar input{padding:8px 10px;border:1px solid var(--border);border-radius:10px;background:var(--glass-strong);color:var(--ink);font:inherit;font-size:12.5px}
.tbl-wrap{border:1px solid var(--border);border-radius:14px;overflow:auto;max-height:60vh;background:var(--glass)}
table{border-collapse:collapse;width:100%;font-size:12px;white-space:nowrap}
th{background:var(--glass-strong);text-align:left;padding:11px 14px;position:sticky;top:0;font-weight:700}
td{padding:10px 14px;border-bottom:1px solid var(--border)}
.empty{text-align:center;padding:40px;color:var(--muted);font-size:13px}
@media (max-width:820px){.sidebar{width:66px;min-width:66px}.logo-title,.logo-sub,.nav-item span,.u-name,.u-role{display:none}.kpi-row{grid-template-columns:1fr 1fr}.dash-grid{grid-template-columns:1fr}}
</style>
</head>
<body>
<div class="app">
<aside class="sidebar">
<div class="logo-area">
<div class="logo-box"><img src="" alt="" onerror="this.remove()"></div>
<div><div class="logo-title">Field Ops Portal</div><div class="logo-sub">Automation dashboard</div></div>
</div>
<nav class="nav">
<button class="nav-item active" data-tab="dashboard" title="Dashboard"><svg viewBox="0 0 24 24"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/></svg><span>Dashboard</span></button>
<button class="nav-item" data-tab="run" title="Run"><svg viewBox="0 0 24 24"><polygon points="6 4 20 12 6 20 6 4"/></svg><span>Run</span></button>
<button class="nav-item" data-tab="report" title="Report"><svg viewBox="0 0 24 24"><path d="M4 4h16v16H4z"/><path d="M8 9h8M8 13h8M8 17h5"/></svg><span>Report</span></button>
</nav>
<div class="sidebar-foot"><div class="avatar" id="avatar">U</div><div><div class="u-name" id="u-name">User</div><div class="u-role" id="u-role">Operator</div></div></div>
</aside>
<main class="main">
<header class="topbar">
<div><div class="tb-title" id="tb-title">Dashboard</div><div class="tb-sub" id="tb-sub">Overview of every batch</div></div>
<div class="tb-right">
<button class="btn primary" id="run-btn" onclick="startRun()">▶ Run</button>
<button class="mode-btn" id="mode-btn" title="Dark mode" aria-label="Dark mode" aria-pressed="false" onclick="toggleDark()">🌙</button>
<div class="indicator"><span class="dot ready" id="st-dot"></span><span id="st-text">System ready</span></div>
</div>
</header>
<section class="content">
<!-- DASHBOARD -->
<div id="tab-dashboard">
<div class="kpi-row">
<div class="kpi total" role="group" aria-label="Total batches"><div class="kpi-ic"></div><div><div class="kpi-val" id="k-total">0</div><div class="kpi-lbl">Total batches</div></div></div>
<div class="kpi done" role="group" aria-label="Completed"><div class="kpi-ic"></div><div><div class="kpi-val" id="k-done">0</div><div class="kpi-lbl">Completed</div></div></div>
<div class="kpi prog" id="kpi-prog" role="group" aria-label="In progress"><div class="kpi-ic"></div><div><div class="kpi-val" id="k-prog">0</div><div class="kpi-lbl">In progress</div></div></div>
<div class="kpi notdone" role="group" aria-label="Not completed"><div class="kpi-ic"></div><div><div class="kpi-val" id="k-fail">0</div><div class="kpi-lbl">Not completed</div></div></div>
</div>
<div class="dash-grid">
<div class="card"><div class="c-title">Batch status split</div>
<div class="donut-wrap"><div class="donut" id="donut" role="img" aria-label="Batch status: no batches yet"><div class="donut-hole"><div class="dh-num" id="dh-num">0</div><div class="dh-lbl">batches</div></div></div><ul id="legend" aria-label="Batch status"></ul></div>
</div>
<div class="card"><div class="c-title">Recent batches</div><div id="recent" class="empty">No batches yet</div></div>
</div>
</div>
<!-- RUN -->
<div id="tab-run" class="hidden">
<div class="card"><div class="c-title">Run automation</div>
<div class="prog-head"><span id="p-label">Waiting to start</span><span id="p-time"></span><span class="pct" id="p-pct">0%</span></div>
<div class="prog-bg"><div class="prog-fill" id="p-fill"></div></div>
<ol id="steps" aria-label="Steps"></ol>
<div class="log" id="log" role="log" aria-label="Run log"></div>
</div>
</div>
<!-- REPORT -->
<div id="tab-report" class="hidden">
<div class="card">
<div class="toolbar"><input id="r-search" type="search" aria-label="Search report" placeholder="Search…" oninput="renderReport()"><span style="margin-left:auto"></span><button class="btn primary" onclick="api('download_report')">⬇ Download Excel</button></div>
<div class="tbl-wrap"><table><thead id="r-head"></thead><tbody id="r-body"></tbody></table></div>
</div>
</div>
</section>
</main>
</div>
<script>
// ===== CONFIG (fill per project) =====
const STEPS = ["Initializing","Load jobs","Process jobs","Completed"];
const REPORT_COLUMNS = [{"key":"job","label":"Job"},{"key":"site","label":"Site"},{"key":"status","label":"Status"}];
const API_BASE = "/api"; // web mode only
const SAMPLE_DATA = true; // no bot yet: the page answers its own data actions. Set false once wired.
const TABS = {dashboard:["Dashboard","Overview of every batch"],run:["Run","Live progress and log"],report:["Report","Result of every processed item"]};
// ===== TRANSPORT: sample data, else Qt bridge, else HTTP polling =====
let bridge = null, reportRows = [];
function api(slot, ...args){
if(SAMPLE_DATA && SAMPLE[slot]) return SAMPLE[slot](...args);
if(bridge) return bridge[slot](...args);
return fetch(`${API_BASE}/${slot.replace(/_/g,'-')}`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(args)}).catch(()=>{});
}
window.__pyMsg = (json)=>{ const m=JSON.parse(json); handle(m.event,m.data); };
function startTransport(){
if(window.qt && window.qt.webChannelTransport){
new QWebChannel(qt.webChannelTransport, ch=>{ bridge=ch.objects.pyBridge; api('load_dashboard'); });
}else if(SAMPLE_DATA){
api('load_dashboard');
}else{
const poll = async()=>{
try{
const d = await (await fetch(`${API_BASE}/dashboard`)).json(); handle('dashboard_data',d);
const s = await (await fetch(`${API_BASE}/run/status`)).json();
(s.events||[]).forEach(e=>handle(e.event,e.data));
setTimeout(poll, s.running ? 2000 : 10000); // poll fast only while busy
}catch(e){ setTimeout(poll, 10000); }
};
poll();
}
}
// ===== SAMPLE DATA: delete this block and set SAMPLE_DATA=false once the bot is wired =====
const SAMPLE = {
load_dashboard(){ const day=n=>new Date(Date.now()-n*864e5).toISOString().slice(0,10);
handle('dashboard_data',{batches:[
{batch_id:1006,date:day(0),state:'inprogress',items_total:40,items_done:18},
{batch_id:1005,date:day(1),state:'completed',items_total:35,items_done:35},
{batch_id:1004,date:day(2),state:'completed',items_total:52,items_done:52},
{batch_id:1003,date:day(3),state:'notcompleted',items_total:20,items_done:7},
{batch_id:1002,date:day(4),state:'completed',items_total:28,items_done:28},
{batch_id:1001,date:day(6),state:'cleared',items_total:12,items_done:0}]}); },
load_latest_report(){
handle('report_data',{rows:[['Processed successfully','success'],['Posted','success'],['Needs review','attention'],['Error','failed']]
.map(([status,bucket],i)=>({...Object.fromEntries(REPORT_COLUMNS.map(c=>[c.key,`${c.label} ${i+1}`])),status,bucket}))}); },
start_automation(){ let i=0; handle('status',{state:'running',msg:'Running (sample)'});
const t=setInterval(()=>{
if(i<STEPS.length){ handle('step',{label:STEPS[i],pct:Math.round((i+1)/STEPS.length*100)}); handle('log',{msg:`Sample step: ${STEPS[i++]}`,type:'info'}); }
else{ clearInterval(t); handle('log',{msg:'Sample run finished.',type:'success'}); handle('status',{state:'done',msg:'Completed'}); }
},400); },
};
// ===== EVENT DISPATCH (see contract.md) =====
function handle(event, d){
switch(event){
case 'status': setStatus(d.state, d.msg); break;
case 'step': setStep(d.label, d.pct); break;
case 'log': addLog(d.msg, d.type); break;
case 'dashboard_data': renderDashboard(d.batches||[]); break;
case 'report_data': reportRows = d.rows||[]; renderReport(); break;
case 'set_user': setUser(d); break;
case 'apply_dark': setDark(!!d.on); break;
}
}
// ===== UI =====
document.querySelectorAll('.nav-item').forEach(n=>n.onclick=()=>showTab(n.dataset.tab));
function showTab(t){
document.querySelectorAll('.nav-item').forEach(n=>n.classList.toggle('active',n.dataset.tab===t));
Object.keys(TABS).forEach(k=>document.getElementById('tab-'+k).classList.toggle('hidden',k!==t));
tbTitle.textContent=TABS[t][0]; tbSub.textContent=TABS[t][1];
if(t==='dashboard') api('load_dashboard');
if(t==='report') api('load_latest_report');
}
const $=id=>document.getElementById(id), tbTitle=$('tb-title'), tbSub=$('tb-sub');
function esc(s){return String(s??'').replace(/[&<>"]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));}
function setDark(on){ document.body.classList.toggle('dark',on); $('mode-btn').setAttribute('aria-pressed',on); $('mode-btn').textContent=on?'☀':'🌙'; }
function toggleDark(){ const on=!document.body.classList.contains('dark'); setDark(on); api('set_dark',on); }
function setUser(u){ $('u-name').textContent=u.username; $('u-role').textContent=u.role; $('avatar').textContent=(u.username||'U')[0].toUpperCase(); }
function setStatus(state,msg){ $('st-dot').className='dot '+state; $('st-text').textContent=msg||state; $('run-btn').disabled=state==='running'; }
function startRun(){ showTab('run'); $('log').innerHTML=''; setStep(STEPS[0],0); api('start_automation'); }
function renderSteps(active=-1){
$('steps').innerHTML=STEPS.map((s,i)=>{const c=i<active?'done':i===active?'active':'pending';
return `<li class="step ${c}"${c==='active'?' aria-current="step"':''}><span class="step-dot">${c==='done'?'✓':i+1}</span>${esc(s)}</li>`}).join('');
}
function setStep(label,pct){ $('p-label').textContent=label; $('p-pct').textContent=pct+'%'; $('p-fill').style.width=pct+'%';
renderSteps(pct>=100?STEPS.length:STEPS.indexOf(label)); }
function addLog(msg,type='info'){ const l=$('log'); l.insertAdjacentHTML('beforeend',`<div class="${type}">${esc(msg)}</div>`); l.scrollTop=l.scrollHeight; }
function countUp(el,to){ const from=+el.textContent||0,t0=performance.now();
const f=t=>{const p=Math.min((t-t0)/500,1); el.textContent=Math.round(from+(to-from)*p); if(p<1)requestAnimationFrame(f)}; requestAnimationFrame(f); }
// batch state -> [label, colour token, pill class]
const STATES={completed:['Completed','var(--ok)','success'],inprogress:['In progress','var(--warn)','attention'],
notcompleted:['Not completed','var(--bad)','failed'],cleared:['Cleared','var(--muted)','cleared']};
function renderDashboard(b){
const c={}; Object.keys(STATES).forEach(k=>c[k]=b.filter(x=>x.state===k).length);
countUp($('k-total'),b.length); countUp($('k-done'),c.completed); countUp($('k-prog'),c.inprogress); countUp($('k-fail'),c.notcompleted);
$('kpi-prog').classList.toggle('live',c.inprogress>0); $('dh-num').textContent=b.length;
const tot=Object.values(c).reduce((a,x)=>a+x,0); let acc=0; const stops=[];
Object.keys(c).forEach(k=>{ if(!c[k])return; const a=acc/tot*100; acc+=c[k]; stops.push(`${STATES[k][1]} ${a}% ${acc/tot*100}%`); });
$('donut').style.background = tot?`conic-gradient(${stops.join(',')})`:'conic-gradient(var(--border) 0 100%)';
$('donut').setAttribute('aria-label','Batch status: '+Object.keys(c).map(k=>`${STATES[k][0]} ${c[k]}`).join(', '));
$('legend').innerHTML=Object.keys(c).map(k=>`<li class="dl-row"><span class="dl-dot" style="background:${STATES[k][1]}"></span>${STATES[k][0]} · ${c[k]}</li>`).join('');
$('recent').className = b.length?'':'empty';
$('recent').innerHTML = b.length? b.slice(0,6).map(x=>{const s=STATES[x.state]||[esc(x.state),'','attention'];
return `<div class="dl-row">#${esc(x.batch_id)} · ${esc(x.date)} <span class="pill ${s[2]}" style="margin-left:auto">${s[0]}</span></div>`}).join('') : 'No batches yet';
}
function renderReport(){
const q=$('r-search').value.toLowerCase();
$('r-head').innerHTML='<tr>'+REPORT_COLUMNS.map(c=>`<th>${esc(c.label)}</th>`).join('')+'</tr>';
const rows=reportRows.filter(r=>!q||Object.values(r).join(' ').toLowerCase().includes(q));
$('r-body').innerHTML = rows.length? rows.map(r=>'<tr>'+REPORT_COLUMNS.map(c=>c.key==='status'
?`<td><span class="pill ${esc(r.bucket||'attention')}">${esc(r.status)}</span></td>`:`<td>${esc(r[c.key])}</td>`).join('')+'</tr>').join('')
: `<tr><td class="empty" colspan="${REPORT_COLUMNS.length}">No rows yet</td></tr>`;
}
renderSteps();
startTransport();
</script>
</body>
</html>
+29
View File
@@ -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?"
}
@@ -0,0 +1,309 @@
<!DOCTYPE html>
<!--
Generic RPA dashboard (no AI). Derived from Tebra ERA (reference/tebra_dashboard.html).
One file: works inside PyQt6 QWebEngineView (QWebChannel bridge) OR served by any web
backend (falls back to polling GET /api/dashboard and /run/status).
Theme = the body / body.dark token blocks only (see themes.md).
-->
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Field Ops Portal</title>
<script src="qrc:///qtwebchannel/qwebchannel.js"></script>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@400;500;600;700&family=Sora:wght@600;700;800&display=swap">
<style>
/* ===== THEME TOKENS: Superbill Ember (see themes.md) ===== */
body{
--bg-top:#f4f3f7; --bg-mid:#f4f3f7; --bg-bottom:#f4f3f7;
--glass:#ffffff; --glass-strong:#f7f6fa; --border:#e2dfe9;
--ink:#18151f; --muted:#66607a; --accent-dark:#18151f;
--accent:#ff3049; --accent-2:#f26b21; --accent-soft:#f7f6fa; --accent-soft-2:#e2dfe9;
--ok:#1f9d57; --ok-bg:#e6f6ec; --warn:#b7791f; --warn-bg:#fbf1dd; --bad:#c0392b; --bad-bg:#fdecea;
--font:'IBM Plex Sans','Segoe UI',-apple-system,sans-serif; --font-display:'Sora','Segoe UI',sans-serif;
--header-bg:linear-gradient(100deg,#ff3049,#f26b21); --header-ink:#ffffff; --header-muted:rgba(255,255,255,.85);
--primary-bg:linear-gradient(115deg,#ff3049,#f26b21);
--nav-active-bg:linear-gradient(100deg,#ff3049,#f26b21);
--progress-bg:linear-gradient(90deg,#ff3049,#f26b21); --blur:none;
}
body.dark{
--bg-top:#0a0912; --bg-mid:#0a0912; --bg-bottom:#0a0912;
--glass:#151220; --glass-strong:#1b1727; --border:#2a2438;
--ink:#f3f1f8; --muted:#8d87a0; --accent-dark:#100e1a;
--accent-soft:#1b1727; --accent-soft-2:#2a2438;
--ok:#2fbf72; --ok-bg:rgba(31,157,87,.16); --warn:#d9a13c; --warn-bg:rgba(183,121,31,.16);
--bad:#e05a4b; --bad-bg:rgba(192,57,43,.16);
}
/* ===== BASE ===== */
*{box-sizing:border-box;margin:0;padding:0}
html,body{height:100%;overflow:hidden}
body{font-family:var(--font);color:var(--ink);
background:linear-gradient(180deg,var(--bg-top) 0%,var(--bg-mid) 45%,var(--bg-bottom) 100%)}
.app{display:flex;height:100vh}
.hidden{display:none!important}
/* sidebar */
.sidebar{width:230px;min-width:230px;background:var(--glass);border-right:1px solid var(--border);backdrop-filter:var(--blur);display:flex;flex-direction:column}
.logo-area{padding:22px 20px;display:flex;align-items:center;gap:11px;border-bottom:1px solid var(--border)}
.logo-box{width:40px;height:40px;border-radius:12px;background:var(--glass-strong);display:flex;align-items:center;justify-content:center;border:1px solid var(--border)}
.logo-box img{width:28px;height:28px;object-fit:contain}
.logo-title{font-size:15px;font-weight:700}
.logo-sub{font-size:10px;color:var(--muted);margin-top:2px;letter-spacing:.5px;text-transform:uppercase;font-weight:600}
.nav{padding:16px 12px;flex:1}
.nav-item{border:0;background:none;width:100%;font:inherit;text-align:left;display:flex;align-items:center;gap:11px;padding:11px 13px;border-radius:10px;cursor:pointer;margin-bottom:3px;color:var(--muted);font-size:13.5px;font-weight:600;transition:all .15s;user-select:none}
.nav-item svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:2}
.nav-item:hover{background:var(--glass-strong);color:var(--ink)}
.nav-item.active{background:var(--nav-active-bg);color:#fff;box-shadow:0 4px 12px rgba(20,50,70,.22)}
.sidebar-foot{padding:14px;border-top:1px solid var(--border);display:flex;align-items:center;gap:10px}
.avatar{width:34px;height:34px;border-radius:50%;background:var(--nav-active-bg);display:flex;align-items:center;justify-content:center;font-size:12px;font-weight:700;color:#fff}
.u-name{font-size:13px;font-weight:700}.u-role{font-size:10.5px;color:var(--muted)}
/* main + topbar */
.main{flex:1;display:flex;flex-direction:column;overflow:hidden}
.topbar{background:var(--header-bg);color:var(--header-ink);border-bottom:1px solid var(--border);backdrop-filter:var(--blur);padding:20px 28px;display:flex;align-items:center;gap:14px;box-shadow:0 6px 18px rgba(20,50,70,.10)}
.tb-title{font-size:21px;font-weight:800}.tb-sub{font-size:12.5px;color:var(--header-muted);margin-top:3px}
.tb-right{margin-left:auto;display:flex;align-items:center;gap:12px}
.indicator{color:var(--ink);display:flex;align-items:center;gap:7px;background:var(--glass-strong);padding:7px 13px;border-radius:20px;border:1px solid var(--border);font-size:12px;font-weight:700}
.dot{width:8px;height:8px;border-radius:50%}
.dot.ready,.dot.done{background:var(--ok);box-shadow:0 0 7px var(--ok)}
.dot.running{background:var(--warn);box-shadow:0 0 7px var(--warn);animation:blink 1.2s infinite}
.dot.error{background:var(--bad);box-shadow:0 0 7px var(--bad)}
@keyframes blink{50%{opacity:.4}}
.mode-btn{color:var(--ink);width:38px;height:38px;border-radius:11px;border:1px solid var(--border);background:var(--glass-strong);cursor:pointer;font-size:16px}
.content{flex:1;overflow-y:auto;padding:26px 28px;border-top-left-radius:30px}
/* cards, buttons, pills */
.section-label{font-size:11px;text-transform:uppercase;letter-spacing:1px;color:var(--muted);font-weight:700;margin-bottom:14px}
.card{background:var(--glass);border:1px solid var(--border);border-radius:16px;padding:22px;margin-bottom:16px;backdrop-filter:var(--blur);box-shadow:0 10px 30px rgba(20,50,70,.08)}
.c-title{font-size:15px;font-weight:700;margin-bottom:14px}
.btn{padding:9px 15px;border-radius:11px;border:1px solid var(--accent-soft-2);background:linear-gradient(180deg,var(--accent-soft),var(--accent-soft-2));color:var(--ink);font-size:12.5px;font-weight:600;cursor:pointer;transition:all .18s}
.btn:hover{transform:translateY(-1px);box-shadow:0 4px 12px rgba(20,50,70,.2)}
.btn.primary{background:var(--primary-bg);color:#fff;border-color:var(--accent)}
.btn:disabled{opacity:.45;cursor:not-allowed;transform:none;box-shadow:none}
.pill{display:inline-block;padding:4px 11px;border-radius:20px;font-size:11px;font-weight:700;color:#fff}
.pill.success{background:var(--ok)}.pill.attention{background:var(--warn)}.pill.failed{background:var(--bad)}.pill.cleared{background:var(--muted)}
ul,ol{list-style:none}
.logo-title,.tb-title,.c-title,.kpi-val,.dh-num,.btn{font-family:var(--font-display)}
/* KPI */
.kpi-row{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin-bottom:16px}
.kpi{display:flex;align-items:center;gap:13px;padding:16px 18px;border-radius:16px;background:var(--glass-strong);border:1px solid var(--border);box-shadow:0 4px 14px rgba(20,50,70,.10)}
.kpi-ic{width:42px;height:42px;border-radius:12px;position:relative}
.kpi.total .kpi-ic{background:var(--accent)}
.kpi.done .kpi-ic{background:var(--ok)}.kpi.prog .kpi-ic{background:var(--warn)}.kpi.notdone .kpi-ic{background:var(--bad)}
.kpi.prog.live .kpi-ic::after{content:'';position:absolute;inset:-3px;border-radius:14px;border:2px solid var(--warn);animation:kpulse 1.6s ease-out infinite}
@keyframes kpulse{0%{transform:scale(.9);opacity:.6}100%{transform:scale(1.25);opacity:0}}
.kpi-val{font-size:26px;font-weight:800;line-height:1.1;font-variant-numeric:tabular-nums}
.kpi-lbl{font-size:11.5px;color:var(--muted);font-weight:600;margin-top:2px}
/* donut */
.dash-grid{display:grid;grid-template-columns:1fr 1fr;gap:14px}
.donut-wrap{display:flex;align-items:center;gap:26px;flex-wrap:wrap}
.donut{width:150px;height:150px;border-radius:50%;position:relative;background:conic-gradient(var(--border) 0 100%)}
.donut-hole{position:absolute;inset:22px;border-radius:50%;background:var(--bg-bottom);display:flex;flex-direction:column;align-items:center;justify-content:center}
.dh-num{font-size:26px;font-weight:800}.dh-lbl{font-size:10px;color:var(--muted);font-weight:700;text-transform:uppercase}
.dl-row{display:flex;align-items:center;gap:9px;font-size:12.5px;font-weight:600;margin-bottom:9px}
.dl-dot{width:10px;height:10px;border-radius:3px}
/* run panel */
.prog-head{display:flex;gap:12px;margin-bottom:8px;font-size:13px;font-weight:700}
.prog-head .pct{margin-left:auto;color:var(--accent)}
.prog-bg{height:8px;background:var(--glass-strong);border-radius:8px;overflow:hidden;margin-bottom:18px}
.prog-fill{height:100%;width:0;background:var(--progress-bg);transition:width .5s}
.step{display:flex;align-items:center;gap:12px;padding:10px 13px;border-radius:10px;margin-bottom:6px;font-size:13px;font-weight:600}
.step.done{background:var(--ok-bg);color:var(--ok)}.step.active{background:var(--warn-bg);color:var(--warn)}.step.pending{opacity:.5}
.step-dot{width:22px;height:22px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:11px;background:var(--glass-strong)}
.step.done .step-dot{background:var(--ok);color:#fff}.step.active .step-dot{background:var(--warn);color:#fff}
.log{background:var(--accent-dark);border-radius:12px;padding:12px 14px;max-height:220px;overflow-y:auto;font-family:Consolas,'Courier New',monospace;font-size:13px;line-height:1.85;color:#9fb6c4}
.log .info{color:#7fc4f0}.log .success{color:#5fe0a0}.log .warn{color:#f5c563}.log .error{color:#f68c82}
/* report table */
.toolbar{display:flex;gap:10px;align-items:center;margin-bottom:14px;flex-wrap:wrap}
.toolbar input{padding:8px 10px;border:1px solid var(--border);border-radius:10px;background:var(--glass-strong);color:var(--ink);font:inherit;font-size:12.5px}
.tbl-wrap{border:1px solid var(--border);border-radius:14px;overflow:auto;max-height:60vh;background:var(--glass)}
table{border-collapse:collapse;width:100%;font-size:12px;white-space:nowrap}
th{background:var(--glass-strong);text-align:left;padding:11px 14px;position:sticky;top:0;font-weight:700}
td{padding:10px 14px;border-bottom:1px solid var(--border)}
.empty{text-align:center;padding:40px;color:var(--muted);font-size:13px}
@media (max-width:820px){.sidebar{width:66px;min-width:66px}.logo-title,.logo-sub,.nav-item span,.u-name,.u-role{display:none}.kpi-row{grid-template-columns:1fr 1fr}.dash-grid{grid-template-columns:1fr}}
</style>
</head>
<body>
<div class="app">
<aside class="sidebar">
<div class="logo-area">
<div class="logo-box"><img src="" alt="" onerror="this.remove()"></div>
<div><div class="logo-title">Field Ops Portal</div><div class="logo-sub">Job automation</div></div>
</div>
<nav class="nav">
<button class="nav-item active" data-tab="dashboard" title="Dashboard"><svg viewBox="0 0 24 24"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/></svg><span>Dashboard</span></button>
<button class="nav-item" data-tab="run" title="Run"><svg viewBox="0 0 24 24"><polygon points="6 4 20 12 6 20 6 4"/></svg><span>Run</span></button>
<button class="nav-item" data-tab="report" title="Report"><svg viewBox="0 0 24 24"><path d="M4 4h16v16H4z"/><path d="M8 9h8M8 13h8M8 17h5"/></svg><span>Report</span></button>
</nav>
<div class="sidebar-foot"><div class="avatar" id="avatar">U</div><div><div class="u-name" id="u-name">User</div><div class="u-role" id="u-role">Operator</div></div></div>
</aside>
<main class="main">
<header class="topbar">
<div><div class="tb-title" id="tb-title">Dashboard</div><div class="tb-sub" id="tb-sub">Overview of every batch</div></div>
<div class="tb-right">
<button class="btn primary" id="run-btn" onclick="startRun()">▶ Run</button>
<button class="mode-btn" id="mode-btn" title="Dark mode" aria-label="Dark mode" aria-pressed="false" onclick="toggleDark()">🌙</button>
<div class="indicator"><span class="dot ready" id="st-dot"></span><span id="st-text">System ready</span></div>
</div>
</header>
<section class="content">
<!-- DASHBOARD -->
<div id="tab-dashboard">
<div class="kpi-row">
<div class="kpi total" role="group" aria-label="Total batches"><div class="kpi-ic"></div><div><div class="kpi-val" id="k-total">0</div><div class="kpi-lbl">Total batches</div></div></div>
<div class="kpi done" role="group" aria-label="Completed"><div class="kpi-ic"></div><div><div class="kpi-val" id="k-done">0</div><div class="kpi-lbl">Completed</div></div></div>
<div class="kpi prog" id="kpi-prog" role="group" aria-label="In progress"><div class="kpi-ic"></div><div><div class="kpi-val" id="k-prog">0</div><div class="kpi-lbl">In progress</div></div></div>
<div class="kpi notdone" role="group" aria-label="Not completed"><div class="kpi-ic"></div><div><div class="kpi-val" id="k-fail">0</div><div class="kpi-lbl">Not completed</div></div></div>
</div>
<div class="dash-grid">
<div class="card"><div class="c-title">Batch status split</div>
<div class="donut-wrap"><div class="donut" id="donut" role="img" aria-label="Batch status: no batches yet"><div class="donut-hole"><div class="dh-num" id="dh-num">0</div><div class="dh-lbl">batches</div></div></div><ul id="legend" aria-label="Batch status"></ul></div>
</div>
<div class="card"><div class="c-title">Recent batches</div><div id="recent" class="empty">No batches yet</div></div>
</div>
</div>
<!-- RUN -->
<div id="tab-run" class="hidden">
<div class="card"><div class="c-title">Run automation</div>
<div class="prog-head"><span id="p-label">Waiting to start</span><span id="p-time"></span><span class="pct" id="p-pct">0%</span></div>
<div class="prog-bg"><div class="prog-fill" id="p-fill"></div></div>
<ol id="steps" aria-label="Steps"></ol>
<div class="log" id="log" role="log" aria-label="Run log"></div>
</div>
</div>
<!-- REPORT -->
<div id="tab-report" class="hidden">
<div class="card">
<div class="toolbar"><input id="r-search" type="search" aria-label="Search report" placeholder="Search…" oninput="renderReport()"><span style="margin-left:auto"></span><button class="btn primary" onclick="api('download_report')">⬇ Download Excel</button></div>
<div class="tbl-wrap"><table><thead id="r-head"></thead><tbody id="r-body"></tbody></table></div>
</div>
</div>
</section>
</main>
</div>
<script>
// ===== CONFIG (fill per project) =====
const STEPS = ["Initializing","Load jobs","Process jobs","Completed"];
const REPORT_COLUMNS = [{"key":"job","label":"Job"},{"key":"site","label":"Site"},{"key":"status","label":"Status"}];
const API_BASE = "/api"; // web mode only
const SAMPLE_DATA = true; // no bot yet: the page answers its own data actions. Set false once wired.
const TABS = {dashboard:["Dashboard","Overview of every batch"],run:["Run","Live progress and log"],report:["Report","Result of every processed item"]};
// ===== TRANSPORT: sample data, else Qt bridge, else HTTP polling =====
let bridge = null, reportRows = [];
function api(slot, ...args){
if(SAMPLE_DATA && SAMPLE[slot]) return SAMPLE[slot](...args);
if(bridge) return bridge[slot](...args);
return fetch(`${API_BASE}/${slot.replace(/_/g,'-')}`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(args)}).catch(()=>{});
}
window.__pyMsg = (json)=>{ const m=JSON.parse(json); handle(m.event,m.data); };
function startTransport(){
if(window.qt && window.qt.webChannelTransport){
new QWebChannel(qt.webChannelTransport, ch=>{ bridge=ch.objects.pyBridge; api('load_dashboard'); });
}else if(SAMPLE_DATA){
api('load_dashboard');
}else{
const poll = async()=>{
try{
const d = await (await fetch(`${API_BASE}/dashboard`)).json(); handle('dashboard_data',d);
const s = await (await fetch(`${API_BASE}/run/status`)).json();
(s.events||[]).forEach(e=>handle(e.event,e.data));
setTimeout(poll, s.running ? 2000 : 10000); // poll fast only while busy
}catch(e){ setTimeout(poll, 10000); }
};
poll();
}
}
// ===== SAMPLE DATA: delete this block and set SAMPLE_DATA=false once the bot is wired =====
const SAMPLE = {
load_dashboard(){ const day=n=>new Date(Date.now()-n*864e5).toISOString().slice(0,10);
handle('dashboard_data',{batches:[
{batch_id:1006,date:day(0),state:'inprogress',items_total:40,items_done:18},
{batch_id:1005,date:day(1),state:'completed',items_total:35,items_done:35},
{batch_id:1004,date:day(2),state:'completed',items_total:52,items_done:52},
{batch_id:1003,date:day(3),state:'notcompleted',items_total:20,items_done:7},
{batch_id:1002,date:day(4),state:'completed',items_total:28,items_done:28},
{batch_id:1001,date:day(6),state:'cleared',items_total:12,items_done:0}]}); },
load_latest_report(){
handle('report_data',{rows:[['Processed successfully','success'],['Posted','success'],['Needs review','attention'],['Error','failed']]
.map(([status,bucket],i)=>({...Object.fromEntries(REPORT_COLUMNS.map(c=>[c.key,`${c.label} ${i+1}`])),status,bucket}))}); },
start_automation(){ let i=0; handle('status',{state:'running',msg:'Running (sample)'});
const t=setInterval(()=>{
if(i<STEPS.length){ handle('step',{label:STEPS[i],pct:Math.round((i+1)/STEPS.length*100)}); handle('log',{msg:`Sample step: ${STEPS[i++]}`,type:'info'}); }
else{ clearInterval(t); handle('log',{msg:'Sample run finished.',type:'success'}); handle('status',{state:'done',msg:'Completed'}); }
},400); },
};
// ===== EVENT DISPATCH (see contract.md) =====
function handle(event, d){
switch(event){
case 'status': setStatus(d.state, d.msg); break;
case 'step': setStep(d.label, d.pct); break;
case 'log': addLog(d.msg, d.type); break;
case 'dashboard_data': renderDashboard(d.batches||[]); break;
case 'report_data': reportRows = d.rows||[]; renderReport(); break;
case 'set_user': setUser(d); break;
case 'apply_dark': setDark(!!d.on); break;
}
}
// ===== UI =====
document.querySelectorAll('.nav-item').forEach(n=>n.onclick=()=>showTab(n.dataset.tab));
function showTab(t){
document.querySelectorAll('.nav-item').forEach(n=>n.classList.toggle('active',n.dataset.tab===t));
Object.keys(TABS).forEach(k=>document.getElementById('tab-'+k).classList.toggle('hidden',k!==t));
tbTitle.textContent=TABS[t][0]; tbSub.textContent=TABS[t][1];
if(t==='dashboard') api('load_dashboard');
if(t==='report') api('load_latest_report');
}
const $=id=>document.getElementById(id), tbTitle=$('tb-title'), tbSub=$('tb-sub');
function esc(s){return String(s??'').replace(/[&<>"]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));}
function setDark(on){ document.body.classList.toggle('dark',on); $('mode-btn').setAttribute('aria-pressed',on); $('mode-btn').textContent=on?'☀':'🌙'; }
function toggleDark(){ const on=!document.body.classList.contains('dark'); setDark(on); api('set_dark',on); }
function setUser(u){ $('u-name').textContent=u.username; $('u-role').textContent=u.role; $('avatar').textContent=(u.username||'U')[0].toUpperCase(); }
function setStatus(state,msg){ $('st-dot').className='dot '+state; $('st-text').textContent=msg||state; $('run-btn').disabled=state==='running'; }
function startRun(){ showTab('run'); $('log').innerHTML=''; setStep(STEPS[0],0); api('start_automation'); }
function renderSteps(active=-1){
$('steps').innerHTML=STEPS.map((s,i)=>{const c=i<active?'done':i===active?'active':'pending';
return `<li class="step ${c}"${c==='active'?' aria-current="step"':''}><span class="step-dot">${c==='done'?'✓':i+1}</span>${esc(s)}</li>`}).join('');
}
function setStep(label,pct){ $('p-label').textContent=label; $('p-pct').textContent=pct+'%'; $('p-fill').style.width=pct+'%';
renderSteps(pct>=100?STEPS.length:STEPS.indexOf(label)); }
function addLog(msg,type='info'){ const l=$('log'); l.insertAdjacentHTML('beforeend',`<div class="${type}">${esc(msg)}</div>`); l.scrollTop=l.scrollHeight; }
function countUp(el,to){ const from=+el.textContent||0,t0=performance.now();
const f=t=>{const p=Math.min((t-t0)/500,1); el.textContent=Math.round(from+(to-from)*p); if(p<1)requestAnimationFrame(f)}; requestAnimationFrame(f); }
// batch state -> [label, colour token, pill class]
const STATES={completed:['Completed','var(--ok)','success'],inprogress:['In progress','var(--warn)','attention'],
notcompleted:['Not completed','var(--bad)','failed'],cleared:['Cleared','var(--muted)','cleared']};
function renderDashboard(b){
const c={}; Object.keys(STATES).forEach(k=>c[k]=b.filter(x=>x.state===k).length);
countUp($('k-total'),b.length); countUp($('k-done'),c.completed); countUp($('k-prog'),c.inprogress); countUp($('k-fail'),c.notcompleted);
$('kpi-prog').classList.toggle('live',c.inprogress>0); $('dh-num').textContent=b.length;
const tot=Object.values(c).reduce((a,x)=>a+x,0); let acc=0; const stops=[];
Object.keys(c).forEach(k=>{ if(!c[k])return; const a=acc/tot*100; acc+=c[k]; stops.push(`${STATES[k][1]} ${a}% ${acc/tot*100}%`); });
$('donut').style.background = tot?`conic-gradient(${stops.join(',')})`:'conic-gradient(var(--border) 0 100%)';
$('donut').setAttribute('aria-label','Batch status: '+Object.keys(c).map(k=>`${STATES[k][0]} ${c[k]}`).join(', '));
$('legend').innerHTML=Object.keys(c).map(k=>`<li class="dl-row"><span class="dl-dot" style="background:${STATES[k][1]}"></span>${STATES[k][0]} · ${c[k]}</li>`).join('');
$('recent').className = b.length?'':'empty';
$('recent').innerHTML = b.length? b.slice(0,6).map(x=>{const s=STATES[x.state]||[esc(x.state),'','attention'];
return `<div class="dl-row">#${esc(x.batch_id)} · ${esc(x.date)} <span class="pill ${s[2]}" style="margin-left:auto">${s[0]}</span></div>`}).join('') : 'No batches yet';
}
function renderReport(){
const q=$('r-search').value.toLowerCase();
$('r-head').innerHTML='<tr>'+REPORT_COLUMNS.map(c=>`<th>${esc(c.label)}</th>`).join('')+'</tr>';
const rows=reportRows.filter(r=>!q||Object.values(r).join(' ').toLowerCase().includes(q));
$('r-body').innerHTML = rows.length? rows.map(r=>'<tr>'+REPORT_COLUMNS.map(c=>c.key==='status'
?`<td><span class="pill ${esc(r.bucket||'attention')}">${esc(r.status)}</span></td>`:`<td>${esc(r[c.key])}</td>`).join('')+'</tr>').join('')
: `<tr><td class="empty" colspan="${REPORT_COLUMNS.length}">No rows yet</td></tr>`;
}
renderSteps();
startTransport();
</script>
</body>
</html>
+29
View File
@@ -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 <link> (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/<action>`. 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?"
}
+59 -4
View File
@@ -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/<fixture>.json. check on the page it built, and writes tests/evals/results/<fixture>.json.
Exit code 0 means every check passed. Exit code 0 means every check passed.
""" """
import difflib
import importlib.util import importlib.util
import json import json
import os import os
@@ -36,14 +37,57 @@ FIXTURES = {
"never_asks": [r"\bkeep\b|restyle"], "never_asks": [r"\bkeep\b|restyle"],
"brand": "#7a3cff", "brand": "#7a3cff",
}, },
# (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*:.*|/\*.*\*/|<link[^>]*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 main(fixture): def main(fixture):
cfg = FIXTURES[fixture] cfg = FIXTURES[fixture]
work = Path(tempfile.mkdtemp(prefix=f"rpa-eval-{fixture}-")) work = Path(tempfile.mkdtemp(prefix=f"rpa-eval-{fixture}-"))
project = work / "project" 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-*"): for skill in ROOT.glob("rpa-dashboard-*"):
shutil.copytree(skill, project / ".claude" / "skills" / skill.name) shutil.copytree(skill, project / ".claude" / "skills" / skill.name)
@@ -61,12 +105,21 @@ def main(fixture):
return m[1] if m else "" return m[1] if m else ""
checks = {"frontend reported correctly": bool(re.search(cfg["frontend"], line("FRONTEND"), re.I))} checks = {"frontend reported correctly": bool(re.search(cfg["frontend"], line("FRONTEND"), re.I))}
questions = line("QUESTIONS").split("|")
for pat in cfg["asks"]: 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"]: 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 checks["exactly one page built"] = len(pages) == 1
results = ROOT / "tests" / "evals" / "results" results = ROOT / "tests" / "evals" / "results"
results.mkdir(exist_ok=True) results.mkdir(exist_ok=True)
@@ -80,6 +133,8 @@ def main(fixture):
spec.loader.exec_module(palette) spec.loader.exec_module(palette)
t = palette.derive(cfg["brand"]) t = palette.derive(cfg["brand"])
checks["custom palette applied"] = all(f"--accent:{t[m]['--accent']}" in html for m in t) 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)
pc = subprocess.run([sys.executable, "-m", "pytest", str(ROOT / "tests" / "page_check"), "-q"], pc = subprocess.run([sys.executable, "-m", "pytest", str(ROOT / "tests" / "page_check"), "-q"],
env={**os.environ, "PAGE_CHECK_HTML": str(pages[0])}, capture_output=True, text=True) env={**os.environ, "PAGE_CHECK_HTML": str(pages[0])}, capture_output=True, text=True)
checks["page check passes"] = pc.returncode == 0 checks["page check passes"] = pc.returncode == 0
+10
View File
@@ -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.
+18
View File
@@ -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); }
@@ -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;
}
+26
View File
@@ -0,0 +1,26 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Field Ops Portal</title>
<link rel="stylesheet" href="css/theme.css">
<link rel="stylesheet" href="css/app.css">
</head>
<body>
<header class="topbar">
<h1>Field Ops Portal</h1>
<nav><a href="index.html">Jobs</a><button class="button secondary" id="theme-btn">Dark mode</button></nav>
</header>
<main class="page">
<section class="card">
<h2>Today's jobs</h2>
<table>
<thead><tr><th>Job</th><th>Site</th><th>Status</th></tr></thead>
<tbody id="jobs"></tbody>
</table>
</section>
</main>
<script src="js/app.js"></script>
</body>
</html>
+15
View File
@@ -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) => `<tr><td>${j.job}</td><td>${j.site}</td><td><span class="tag ${j.tag}">${j.status}</span></td></tr>`
).join("");
document.getElementById("theme-btn").onclick = () => {
const root = document.documentElement;
root.dataset.theme = root.dataset.theme === "dark" ? "light" : "dark";
};
+105
View File
@@ -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": "<style>:root{--x:#123456}</style>",
".venv/lib/site.html": "<p>",
".claude/skills/rpa-dashboard-no-ai/templates/dashboard.html": "<style>body{--accent:#3d9bd0}</style>",
"dist/index.html": "<p>",
})
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": '<div id="root"></div><script type="module" src="/src/main.jsx"></script>',
"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": "<p>",
})
[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": "<template><p>{{ msg }}</p></template>\n<style>:root { --surface: #ffffff; }</style>",
})
[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": "<style>:root { --accent: #2563eb; }</style>",
})
[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('<style>body{--accent:#16a34a}</style><p>Hi</p>')\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"}