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
@@ -16,6 +16,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.
|
||||||
@@ -24,22 +26,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
|
||||||
|
|
||||||
@@ -73,6 +77,7 @@ sections must leave no markup, script or style behind, so delete their
|
|||||||
- Desktop: put it next to the host, for example `dashboard/dashboard.html`.
|
- 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 |
|
||||||
@@ -86,7 +91,9 @@ sections must leave no markup, script or style behind, so delete their
|
|||||||
3. Apply the theme by replacing the `body{...}` and `body.dark{...}` token blocks
|
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` and `templates/bot_runner.py` next to
|
4. Desktop: also copy `templates/bridge.py` and `templates/bot_runner.py` next to
|
||||||
the page. Fill `{{ORG}}` and `{{APP_NAME}}` in `bridge.py`, and `{{BOT_MODULE}}` in
|
the page. Fill `{{ORG}}` and `{{APP_NAME}}` in `bridge.py`, and `{{BOT_MODULE}}` in
|
||||||
`bot_runner.py` (with no bot yet, use the module the bot will have, for example `bot`).
|
`bot_runner.py` (with no bot yet, use the module the bot will have, for example `bot`).
|
||||||
|
|||||||
@@ -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.
|
||||||
+6
-3
@@ -30,7 +30,10 @@ 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`. For a bot fixture (`b-pyqt-bot`), the eval also
|
`FIXTURES` in `run_eval.py`. An entry with a `"fixture"` key reuses another
|
||||||
dry-runs the generated `bot_runner.py`, saves its events to
|
fixture folder with different answers: `c-keep` and `c-restyle` both run on
|
||||||
`evals/results/<fixture>.run.jsonl`, and replays them in the page check.
|
`c-web-existing-frontend`, and also check what changed in the files it already had.
|
||||||
|
For a bot fixture (`b-pyqt-bot`), the eval also dry-runs the generated
|
||||||
|
`bot_runner.py`, saves its events to `evals/results/<fixture>.run.jsonl`, and
|
||||||
|
replays them in the page check.
|
||||||
- `fixtures/<letter>-<name>/`: small test projects for the evals.
|
- `fixtures/<letter>-<name>/`: small test projects for the evals.
|
||||||
@@ -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=>({'&':'&','<':'<','>':'>','"':'"'}[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>
|
||||||
@@ -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=>({'&':'&','<':'<','>':'>','"':'"'}[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>
|
||||||
@@ -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
@@ -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
|
||||||
@@ -45,7 +46,49 @@ FIXTURES = {
|
|||||||
"never_asks": [r"\bkeep\b|restyle", r"desktop|web"],
|
"never_asks": [r"\bkeep\b|restyle", r"desktop|web"],
|
||||||
"bot": True,
|
"bot": True,
|
||||||
},
|
},
|
||||||
|
# (c) existing teal frontend in web/, run once per answer to keep-or-restyle.
|
||||||
|
"c-keep": {
|
||||||
|
"fixture": "c-web-existing-frontend",
|
||||||
|
"prompt": "Build me an RPA dashboard for this project. I can't answer questions while you "
|
||||||
|
"work, so here are my answers to anything the skill asks. Keep or restyle the "
|
||||||
|
"existing frontend: keep its look. Optional sections: none.",
|
||||||
|
"frontend": r"(?=.*\bweb\b)(?=.*html)(?=.*(#0f766e|--color-brand))",
|
||||||
|
"asks": [r"keep|restyle", r"section"],
|
||||||
|
"never_asks": [r"^(?!.*(keep|restyle)).*(colou?r|Tebra Sky|Ember)", r"desktop"],
|
||||||
|
"page_has": ["--accent:#0f766e", "Trebuchet MS"],
|
||||||
|
"existing": "unchanged",
|
||||||
|
},
|
||||||
|
"c-restyle": {
|
||||||
|
"fixture": "c-web-existing-frontend",
|
||||||
|
"prompt": "Build me an RPA dashboard for this project. I can't answer questions while you "
|
||||||
|
"work, so here are my answers to anything the skill asks. Keep or restyle the "
|
||||||
|
"existing frontend: restyle it. Colours: Superbill Ember. Optional sections: none.",
|
||||||
|
"frontend": r"(?=.*\bweb\b)(?=.*html)(?=.*(#0f766e|--color-brand))",
|
||||||
|
"asks": [r"keep|restyle", r"Tebra Sky|Ember", r"section"],
|
||||||
|
"never_asks": [r"desktop"],
|
||||||
|
"page_has": ["--accent:#ff3049"],
|
||||||
|
"existing": "#ff3049", # only token lines may change, and the theme now uses this colour
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
# A line a restyle may change: a custom property, a comment, a blank, or a web-font link.
|
||||||
|
TOKEN_LINE = r"^\s*(--[\w-]+\s*:.*|/\*.*\*/|<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 bot_checks(project, html, checks, replay):
|
def bot_checks(project, html, checks, replay):
|
||||||
@@ -73,7 +116,8 @@ 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)
|
||||||
|
|
||||||
@@ -91,12 +135,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)
|
||||||
@@ -110,6 +163,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)
|
||||||
env = {**os.environ, "PAGE_CHECK_HTML": str(pages[0])}
|
env = {**os.environ, "PAGE_CHECK_HTML": str(pages[0])}
|
||||||
if cfg.get("bot"):
|
if cfg.get("bot"):
|
||||||
env.update(bot_checks(project, html, checks, results / f"{fixture}.run.jsonl"))
|
env.update(bot_checks(project, html, checks, results / f"{fixture}.run.jsonl"))
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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";
|
||||||
|
};
|
||||||
@@ -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"}
|
||||||
Reference in new issue
Block a user