Move all three skills into skills/; add root README; fix paths

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
VasanthandClaude Sonnet 5.5 committed 2026-10-16 17:04:38 +05:30
1 parent 312742cfef
commit d95e5d452d
84 files changed
+97 -31

No files matched your search

@@ -0,0 +1,64 @@
"""Bot wiring for the desktop host: runs the bot and turns its output into page events.
No Qt here, so it can be tested and dry-run on its own. Fill BOT_CMD, STEP_MARKERS and
FRIENDLY_RULES from lines the bot really prints.
Dry run, no window: python bot_runner.py (prints each event the page would get)
"""
import json, os, subprocess, sys
from pathlib import Path
BOT_CMD = [sys.executable, "-m", "{{BOT_MODULE}}"] # the bot, started as a child process
BOT_CWD = Path(__file__).resolve().parents[1] # folder BOT_CMD runs in (the project root)
RAW_LOG = Path(__file__).with_name("bot_raw.log") # every raw bot line, kept for audit
# (specific substring, step label from STEPS in dashboard.html, pct). First match wins.
# e.g. ("[step 2]", "Login", 40). Never a bare word like "login": it matches unrelated lines.
STEP_MARKERS = []
# (specific substring, plain-English message, type: info|success|warn|error). First match wins.
# e.g. ("[login] done", "Logged in.", "success"). Unmatched lines are hidden, except error lines.
FRIENDLY_RULES = []
# A line holding any of these is an error line. Unmatched error lines are shown raw, never hidden.
ERROR_WORDS = ("error", "exception", "traceback", "fatal", "failed")
def _first(rules, line):
low = line.lower()
return next((r for r in rules if r[0].lower() in low), None)
def events_for(line):
"""The page events, as (event, data) pairs, for one raw bot line."""
out = []
if marker := _first(STEP_MARKERS, line):
out.append(("step", {"label": marker[1], "pct": marker[2]}))
if rule := _first(FRIENDLY_RULES, line):
out.append(("log", {"msg": rule[1], "type": rule[2]}))
elif any(w in line.lower() for w in ERROR_WORDS): # never hide a real failure
out.append(("log", {"msg": line, "type": "error"}))
return out
def run(send):
"""Run the bot in its own process and send(event, data) for each line it prints, as it prints it."""
send("status", {"state": "running", "msg": "Running…"})
env = {**os.environ, "PYTHONUNBUFFERED": "1", "PYTHONIOENCODING": "utf-8"} # live lines; no cp1252 crash
try:
raw = open(RAW_LOG, "a", encoding="utf-8")
p = subprocess.Popen(BOT_CMD, cwd=BOT_CWD, env=env, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
text=True, encoding="utf-8", errors="replace")
except OSError as e:
send("log", {"msg": f"Could not start the bot: {e}", "type": "error"})
send("status", {"state": "error", "msg": "Could not start the bot"})
return
with p, raw:
for line in p.stdout:
raw.write(line)
for event, data in events_for(line.rstrip()):
send(event, data)
ok = p.returncode == 0
send("status", {"state": "done", "msg": "Completed"} if ok else
{"state": "error", "msg": f"Failed (exit code {p.returncode})"})
if __name__ == "__main__":
run(lambda event, data: print(json.dumps({"event": event, "data": data}), flush=True))
@@ -0,0 +1,72 @@
"""Minimal PyQt6 host for dashboard.html (desktop mode). Pattern from Tebra main_window.py.
The bot runs as a child process and this window shows its progress. The bot wiring
(command, step markers, friendly rules) lives in bot_runner.py, next to this file.
Run it with: python dashboard/bridge.py
"""
import json, sys, threading
from pathlib import Path
from PyQt6.QtCore import QObject, pyqtSlot, pyqtSignal, QUrl, QSettings
from PyQt6.QtWidgets import QApplication, QMainWindow
from PyQt6.QtWebEngineWidgets import QWebEngineView
from PyQt6.QtWebChannel import QWebChannel
import bot_runner
HTML_PATH = Path(__file__).with_name("dashboard.html")
def settings():
return QSettings("{{ORG}}", "{{APP_NAME}}")
class Bridge(QObject):
_emit = pyqtSignal(str) # thread-safe hop to the GUI thread
def __init__(self, view):
super().__init__()
self.view, self._one_run = view, threading.Lock()
self._emit.connect(lambda js: self.view.page().runJavaScript(f"window.__pyMsg({js})"))
# Restore the saved dark-mode choice on every page load, sample-data mode included.
view.loadFinished.connect(lambda _ok: self.send_to_js("apply_dark", {"on": settings().value("dark", False, bool)}))
def send_to_js(self, event, data):
self._emit.emit(json.dumps(json.dumps({"event": event, "data": data})))
@pyqtSlot()
def load_dashboard(self):
self.send_to_js("dashboard_data", {"batches": []}) # TODO: read from DB on a worker thread
@pyqtSlot()
def load_latest_report(self):
self.send_to_js("report_data", {"rows": []}) # TODO: read the latest report rows
@pyqtSlot()
def download_report(self):
pass # TODO: export the report rows to .xlsx
@pyqtSlot(bool)
def set_dark(self, on):
settings().setValue("dark", on)
@pyqtSlot()
def start_automation(self):
if self._one_run.acquire(blocking=False): # a second click can't start a second bot
threading.Thread(target=self._run, daemon=True).start()
def _run(self):
try:
bot_runner.run(self.send_to_js)
finally:
self._one_run.release()
if __name__ == "__main__":
app = QApplication(sys.argv)
win, view = QMainWindow(), QWebEngineView()
bridge, channel = Bridge(view), QWebChannel()
channel.registerObject("pyBridge", bridge)
view.page().setWebChannel(channel)
view.setUrl(QUrl.fromLocalFile(str(HTML_PATH)))
win.setCentralWidget(view); win.resize(1280, 820); win.setWindowTitle("{{APP_NAME}}"); win.show()
sys.exit(app.exec())
@@ -0,0 +1,33 @@
# Dashboard data contract (no-AI)
All updates use one envelope: `{"event": "<name>", "data": {...}}`.
- **Desktop (PyQt):** Python calls `window.__pyMsg(json)` via `runJavaScript` (see `bridge.py`).
`bot_runner.py` turns the bot's output lines into `status`, `step` and `log` events, and keeps every raw line in `bot_raw.log`.
- **Web:** the page polls `GET {API_BASE}/dashboard` (returns `dashboard_data`) and
`GET {API_BASE}/run/status` → `{"running": bool, "events": [envelope, ...]}`. Poll fast (2s) only while running.
- **No bot yet:** with `SAMPLE_DATA = true`, the page answers its own data actions with sample envelopes.
## Bot/backend → page
| event | data |
|---|---|
| `status` | `{state: "ready"\|"running"\|"done"\|"error", msg}` |
| `step` | `{label: <one of STEPS>, pct: 0-100}` |
| `log` | `{msg: <plain English>, type: "info"\|"success"\|"warn"\|"error"}` |
| `dashboard_data` | `{batches: [{batch_id, date, state: "completed"\|"inprogress"\|"notcompleted"\|"cleared", items_total, items_done, ...}]}` |
| `report_data` | `{rows: [{...REPORT_COLUMNS keys, status, bucket: "success"\|"attention"\|"failed"}]}` |
| `set_user` | `{username, emp_id, role}` |
| `apply_dark` | `{on: bool}` |
Optional sections (only if chosen) add `cpstate`, `admin_data` and `login_result`, and the actions `load_admin`, `assign_role`, `remove_role` and `login`. See `sections.md`.
## Page → bot/backend (Qt `@pyqtSlot` names / web `POST {API_BASE}/<slot-with-dashes>`)
`load_dashboard`, `start_automation`, `request_stop`, `load_latest_report`, `download_report`, `set_dark(on)`, `logout`.
## Rules (learned on Tebra)
- Log the raw bot line to a file/DB; show only **friendly** text on screen. Never hide an error line.
- Step markers: match the bot's printed lines by **specific** substring (e.g. `[step 3]`), not a bare word. A bare word matches unrelated lines.
- Derive `state`: Pending + today = `inprogress`; Pending + older day = `notcompleted`.
- Report `bucket`: success = {processed successfully, posted, completed}; failed = {error, failed}; everything else = attention.
- Run the bot in a **separate process** that writes to a log file, and have the UI tail that file. When pywinauto/UIA runs inside a Qt worker thread, its reads are unreliable (Tebra bug-094).
@@ -0,0 +1,454 @@
<!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_BASE}}/dashboard and /run/status).
Replace every {{...}} placeholder. 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>{{APP_NAME}}</title>
<script src="qrc:///qtwebchannel/qwebchannel.js"></script>
<style>
/* ===== THEME TOKENS (swap these for a preset/custom palette, see themes.md) ===== */
body{
--bg-top:#8fc9e8; --bg-mid:#bfe3f2; --bg-bottom:#eaf6fb;
--glass:rgba(255,255,255,.60); --glass-strong:rgba(255,255,255,.78); --border:rgba(255,255,255,.9);
--ink:#1c2b33; --muted:#5c7382; --accent-dark:#16202a;
--accent:#3d9bd0; --accent-2:#5cb2e2; --accent-soft:#d4edfa; --accent-soft-2:#a9d8ee;
--ok:#1f9d57; --ok-bg:#e6f6ec; --warn:#b7791f; --warn-bg:#fbf1dd; --bad:#c0392b; --bad-bg:#fdecea;
--font:'Segoe UI',-apple-system,BlinkMacSystemFont,sans-serif; --font-display:var(--font);
--header-bg:var(--glass-strong); --header-ink:var(--ink); --header-muted:var(--muted);
--primary-bg:linear-gradient(180deg,var(--accent-2),var(--accent));
--nav-active-bg:linear-gradient(135deg,var(--accent),var(--accent-dark));
--progress-bg:var(--ok); --blur:blur(14px);
}
body.dark{
--bg-top:#0f1b26; --bg-mid:#13212e; --bg-bottom:#182a38;
--glass:rgba(22,34,44,.72); --glass-strong:rgba(30,45,58,.85); --border:rgba(120,160,190,.22);
--ink:#e6eef4; --muted:#8fa6b4; --accent-dark:#2e5d80;
--accent-soft:#2e5d90; --accent-soft-2:#1d3d5e;
--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}
/* OPTIONAL:checkpoints BEGIN */
.cp-track{display:grid;grid-template-columns:repeat(auto-fit,minmax(118px,1fr));gap:8px;margin-bottom:16px}
.cp-box{display:flex;flex-direction:column;gap:4px;padding:10px 11px;border-radius:11px;border:1.5px solid var(--border);background:var(--glass-strong);color:var(--muted);font-size:11.5px;font-weight:700}
.cp-name{color:var(--ink);font-size:12.5px}
.cp-box.active{border-color:var(--warn);background:var(--warn-bg);color:var(--warn)}
.cp-box.done{border-color:var(--ok);background:var(--ok-bg);color:var(--ok)}
.cp-box.stuck{border-color:var(--bad);background:var(--bad-bg);color:var(--bad)}
/* OPTIONAL:checkpoints END */
/* OPTIONAL:admin BEGIN */
body:not([data-role="admin" i]) .adm-only{display:none!important}
.toolbar select{padding:8px 10px;border:1px solid var(--border);border-radius:10px;background:var(--glass-strong);color:var(--ink);font:inherit;font-size:12.5px}
.toolbar select option{background:var(--bg-bottom);color:var(--ink)}
.pill.adm-role{background:var(--muted);text-transform:capitalize}.pill.adm-role.admin{background:var(--accent)}
.adm-note{font-size:12.5px;color:var(--muted)}
/* OPTIONAL:admin END */
/* OPTIONAL:login BEGIN */
.login-screen{position:fixed;inset:0;z-index:50;display:flex;align-items:center;justify-content:center;padding:16px;
background:linear-gradient(180deg,var(--bg-top) 0%,var(--bg-mid) 45%,var(--bg-bottom) 100%)}
.login-card{width:min(360px,100%);display:flex;flex-direction:column;gap:12px;margin:0}
.login-card .c-title{font-size:19px;margin:0}
.login-sub{font-size:12.5px;color:var(--muted);margin-top:-8px}
.login-card label{display:flex;flex-direction:column;gap:5px;font-size:12px;font-weight:700;color:var(--muted)}
.login-card input{padding:10px 12px;border:1px solid var(--border);border-radius:10px;background:var(--glass-strong);color:var(--ink);font:inherit;font-size:13px}
.login-err{color:var(--bad);font-size:12.5px;font-weight:600;min-height:1.2em}
.login-out svg{width:17px;height:17px;stroke:currentColor;fill:none;stroke-width:2;vertical-align:middle}
/* OPTIONAL:login END */
@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="{{LOGO_SRC}}" alt="" onerror="this.remove()"></div>
<div><div class="logo-title">{{APP_NAME}}</div><div class="logo-sub">{{APP_SUBTITLE}}</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>
<!-- OPTIONAL:admin BEGIN -->
<button class="nav-item adm-only" id="adm-nav" data-tab="admin" title="Admin"><svg viewBox="0 0 24 24"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg><span>Admin</span></button>
<!-- OPTIONAL:admin END -->
</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>
<!-- OPTIONAL:login BEGIN -->
<button class="mode-btn login-out" title="Sign out" aria-label="Sign out" onclick="loginShow(true); api('logout')"><svg viewBox="0 0 24 24"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/></svg></button>
<!-- OPTIONAL:login END -->
<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>
<!-- OPTIONAL:checkpoints BEGIN -->
<div class="section-label">Checkpoints</div>
<ol class="cp-track" id="cp-track" aria-label="Checkpoints"></ol>
<!-- OPTIONAL:checkpoints END -->
<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>
<!-- OPTIONAL:admin BEGIN -->
<!-- ADMIN: admins only. Hiding it is not security: the host re-checks the role on every admin action. -->
<div id="tab-admin" class="hidden adm-only">
<div class="card"><div class="c-title">Assign access</div>
<form class="toolbar" aria-label="Assign access" onsubmit="admAssign(event)">
<input id="adm-user" aria-label="Username or employee ID" placeholder="Username or employee ID" required>
<select id="adm-role" aria-label="Role"><option value="operator">Operator</option><option value="admin">Admin</option></select>
<button class="btn primary">Assign</button>
<span class="adm-note" id="adm-note" role="status"></span>
</form>
</div>
<div class="card"><div class="c-title">Users with access</div>
<div class="tbl-wrap"><table aria-label="Users with access"><thead><tr><th>User</th><th>Employee ID</th><th>Role</th><th></th></tr></thead><tbody id="adm-rows" onclick="admRemove(event)"></tbody></table></div>
</div>
</div>
<!-- OPTIONAL:admin END -->
</section>
</main>
</div>
<!-- OPTIONAL:login BEGIN -->
<!-- LOGIN: UI only. The host checks the password with the project's own auth (see sections.md). -->
<div class="login-screen" id="login-screen">
<form class="card login-card" aria-label="Sign in" onsubmit="loginSubmit(event)">
<div class="c-title">{{APP_NAME}}</div>
<p class="login-sub">Sign in to continue</p>
<label>Username<input id="login-user" autocomplete="username" required></label>
<label>Password<input id="login-pass" type="password" autocomplete="current-password" required></label>
<div class="login-err" id="login-err" role="alert"></div>
<button class="btn primary">Sign in</button>
</form>
</div>
<!-- OPTIONAL:login END -->
<script>
// ===== CONFIG (fill per project) =====
const STEPS = {{STEPS_JSON}}; // e.g. ["Initializing","Login","Process items","Completed"]
const REPORT_COLUMNS = {{REPORT_COLUMNS_JSON}}; // e.g. [{key:"item",label:"Item"},{key:"status",label:"Status"}]
const API_BASE = "{{API_BASE}}"; // web mode only, e.g. "/api"
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(()=>{
// OPTIONAL:checkpoints BEGIN
handle('cpstate', i<STEPS.length ? {kind:'active',cp:Math.ceil((i+1)*CHECKPOINTS.length/STEPS.length)} : {kind:'done',cp:CHECKPOINTS.length});
// OPTIONAL:checkpoints END
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); },
// OPTIONAL:admin BEGIN
adm_users:[{username:'Sample admin',emp_id:'E-1001',role:'admin'},{username:'Sample operator',emp_id:'E-1002',role:'operator'}],
load_admin(){ handle('admin_data',{rows:this.adm_users}); },
assign_role(who,role){ this.remove_role(who); this.adm_users.push({username:who,emp_id:'',role}); this.load_admin(); },
remove_role(who){ this.adm_users=this.adm_users.filter(u=>u.username!==who&&u.emp_id!==who); this.load_admin(); },
// OPTIONAL:admin END
// OPTIONAL:login BEGIN
login(username){ handle('login_result',{ok:true,user:{username,emp_id:'',role:document.body.dataset.role||'operator'}}); }, // stub: anyone, same role
logout(){},
// OPTIONAL:login END
};
// ===== 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;
// OPTIONAL:checkpoints BEGIN
case 'cpstate': cpState(d.kind, d.cp); break;
// OPTIONAL:checkpoints END
// OPTIONAL:admin BEGIN
case 'admin_data': admRender(d); break;
// OPTIONAL:admin END
// OPTIONAL:login BEGIN
case 'login_result': loginResult(d); break;
// OPTIONAL:login END
}
}
// ===== UI =====
document.querySelectorAll('.nav-item').forEach(n=>n.onclick=()=>showTab(n.dataset.tab));
function showTab(t){
document.querySelectorAll('.nav-item').forEach(n=>n.classList.toggle('active',n.dataset.tab===t));
Object.keys(TABS).forEach(k=>document.getElementById('tab-'+k).classList.toggle('hidden',k!==t));
tbTitle.textContent=TABS[t][0]; tbSub.textContent=TABS[t][1];
if(t==='dashboard') api('load_dashboard');
if(t==='report') api('load_latest_report');
}
const $=id=>document.getElementById(id), tbTitle=$('tb-title'), tbSub=$('tb-sub');
function esc(s){return String(s??'').replace(/[&<>"]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));}
function setDark(on){ document.body.classList.toggle('dark',on); $('mode-btn').setAttribute('aria-pressed',on); $('mode-btn').textContent=on?'☀':'🌙'; }
function toggleDark(){ const on=!document.body.classList.contains('dark'); setDark(on); api('set_dark',on); }
function setUser(u){ document.body.dataset.role=u.role||''; $('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>`;
}
// OPTIONAL:checkpoints BEGIN
// Checkpoint tracker: numbered boxes driven by cpstate {kind, cp}. cp counts from 1.
const CHECKPOINTS = {{CHECKPOINTS_JSON}}; // e.g. ["Open the portal","Load the list","Process items","Finish up"]
const CP_LABEL = {pending:'Pending', active:'In progress', done:'Done', stuck:'Stuck'};
let cpKinds = CHECKPOINTS.map(()=>'pending');
function cpState(kind, n){
if(!CP_LABEL[kind] || !(n>=1 && n<=CHECKPOINTS.length)) return;
if(kind==='active') cpKinds = cpKinds.map((_,i)=>i<n-1?'done':'pending'); // a new active box resets the rest
cpKinds[n-1] = kind;
$('cp-track').innerHTML = CHECKPOINTS.map((c,i)=>`<li class="cp-box ${cpKinds[i]}"${cpKinds[i]==='active'?' aria-current="step"':''}>`
+`<span>${i+1}</span><span class="cp-name">${esc(c)}</span><span>${CP_LABEL[cpKinds[i]]}</span></li>`).join('');
}
cpState('pending', 1);
// OPTIONAL:checkpoints END
// OPTIONAL:admin BEGIN
// Admin screen: shown only while set_user says role "admin" (CSS on body[data-role]).
TABS.admin = ['Admin','Who can use this dashboard'];
$('adm-nav').addEventListener('click', ()=>api('load_admin'));
function admRender(d){
$('adm-note').textContent = d.msg||'';
const rows = d.rows||[];
$('adm-rows').innerHTML = rows.length ? rows.map(u=>`<tr><td>${esc(u.username)}</td><td>${esc(u.emp_id)}</td>`
+`<td><span class="pill adm-role ${/^admin$/i.test(u.role)?'admin':''}">${esc(u.role)}</span></td>`
+`<td><button class="btn" data-user="${esc(u.emp_id||u.username)}">Remove</button></td></tr>`).join('')
: '<tr><td class="empty" colspan="4">No one has access yet</td></tr>';
}
function admAssign(e){ e.preventDefault(); api('assign_role', $('adm-user').value.trim(), $('adm-role').value); $('adm-user').value=''; }
function admRemove(e){ const who=e.target.dataset.user; if(who) api('remove_role', who); }
if(SAMPLE_DATA) setUser({username:'Sample admin', emp_id:'E-1001', role:'admin'}); // demo: show the admin screen
// OPTIONAL:admin END
// OPTIONAL:login BEGIN
// Login page (UI only): the host checks the password with the project's own auth and answers login_result.
function loginShow(on){
$('login-screen').classList.toggle('hidden', !on);
document.querySelector('.app').inert = on;
if(on){ $('login-err').textContent=''; $('login-user').focus(); }
}
function loginSubmit(e){
e.preventDefault(); $('login-err').textContent='';
const u=$('login-user').value.trim(), p=$('login-pass').value;
if(SAMPLE_DATA || bridge) return api('login', u, p);
fetch(`${API_BASE}/login`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({username:u,password:p})})
.then(r=>r.json()).then(d=>handle('login_result',d))
.catch(()=>handle('login_result',{ok:false,msg:'Could not reach the server.'}));
}
function loginResult(d){
$('login-pass').value='';
if(!d.ok){ $('login-err').textContent = d.msg||'Sign-in failed.'; return; }
if(d.user) setUser(d.user);
loginShow(false); showTab('dashboard');
}
loginShow(true);
// OPTIONAL:login END
renderSteps();
startTransport();
</script>
</body>
</html>
@@ -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,120 @@
# 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`). |
**AI skill (`rpa-dashboard-ai`):** a React app gets the React starter's components
ported in. Plain HTML, Vue, Angular and Svelte get this same page with the AI skill's
`templates/desktop/ai_sections.html` pasted in before `</body>`, placed as above. With no
Qt bridge it fetches `{API_BASE}` + the AI contract's endpoints. A port into components
uses `ai_sections.html` as the reference.
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.
@@ -0,0 +1,100 @@
"""Derive a custom dashboard palette from one brand hex, then check text contrast.
Usage: python palette.py "#7a3cff"
Prints the accent tokens for the body{} (light) and body.dark{} blocks of
dashboard.html, which keeps the Tebra Sky base for everything else, then a
contrast table for light and dark. A brand colour that is too light or too dark
is nudged until text on it is readable. Exits 1 if any check still fails.
"""
import sys
WHITE, BLACK = "#ffffff", "#000000"
# Tebra Sky base colours the accents sit on. Keep in step with dashboard.html.
BASE = {"light": {"bg": "#eaf6fb", "ink": "#1c2b33"}, "dark": {"bg": "#182a38", "ink": "#e6eef4"}}
LOG_TEXT = "#f68c82" # dimmest log line colour; the log box background is --accent-dark
def rgb(h):
return [int(h[i:i + 2], 16) for i in (1, 3, 5)]
def mix(a, b, t):
"""Blend hex colour a toward b by t (0..1)."""
return "#" + "".join(f"{round(x + (y - x) * t):02x}" for x, y in zip(rgb(a), rgb(b)))
def contrast(a, b):
"""WCAG 2 contrast ratio between two hex colours."""
def lum(h):
c = [v / 255 for v in rgb(h)]
c = [x / 12.92 if x <= 0.03928 else ((x + 0.055) / 1.055) ** 2.4 for x in c]
return 0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2]
hi, lo = sorted((lum(a), lum(b)), reverse=True)
return (hi + 0.05) / (lo + 0.05)
def checks(t):
"""(mode, what, ratio, needed) for every text pair the accents affect."""
rows = []
for mode, m in t.items():
bg, ink = BASE[mode]["bg"], BASE[mode]["ink"]
rows += [(mode, "white button text on --accent", contrast(WHITE, m["--accent"]), 3),
(mode, "white button text on --accent-2", contrast(WHITE, m["--accent-2"]), 3),
(mode, "--accent text on the page", contrast(m["--accent"], bg), 3),
(mode, "ink on --accent-soft buttons", contrast(ink, m["--accent-soft"]), 4.5),
(mode, "log text on --accent-dark", contrast(LOG_TEXT, m["--accent-dark"]), 4.5)]
return rows
def nudge(colour, fix):
"""Step colour toward whatever fix() returns (WHITE/BLACK) until it returns None."""
for _ in range(60):
toward = fix(colour)
if toward is None:
break
colour = mix(colour, toward, 0.06)
return colour
def derive(brand):
brand = brand.strip().lower()
if len(brand) == 4: # #abc -> #aabbcc
brand = "#" + "".join(ch * 2 for ch in brand[1:])
out = {}
for mode, lift in [("light", 0.15), ("dark", 0.10)]:
bg, ink = BASE[mode]["bg"], BASE[mode]["ink"]
dark_page = mode == "dark"
def fix_accent(c):
if contrast(c, bg) < 3: # accent text must stand out from the page
return WHITE if dark_page else BLACK
if contrast(WHITE, mix(c, WHITE, lift)) < 3: # white text on the button
return BLACK
return None
a = nudge(brand, fix_accent)
soft = mix(a, BLACK, 0.35) if dark_page else mix(a, WHITE, 0.82)
out[mode] = {
"--accent": a,
"--accent-2": mix(a, WHITE, lift),
"--accent-dark": nudge(mix(a, BLACK, 0.45 if dark_page else 0.75),
lambda c: BLACK if contrast(LOG_TEXT, c) < 4.5 else None),
"--accent-soft": nudge(soft, lambda c: None if contrast(ink, c) >= 4.5 else BLACK if dark_page else WHITE),
"--accent-soft-2": mix(a, BLACK, 0.6) if dark_page else mix(a, WHITE, 0.62),
}
return out
if __name__ == "__main__":
if len(sys.argv) != 2 or not sys.argv[1].startswith("#"):
sys.exit(__doc__)
t = derive(sys.argv[1])
for mode, block in [("light", "body{"), ("dark", "body.dark{")]:
print(f"/* {mode}: replace these tokens in the {block} block */")
print(block + " " + "; ".join(f"{k}:{v}" for k, v in t[mode].items()) + "; }")
print()
rows = checks(t)
for mode, what, ratio, need in rows:
print(f"{mode:<6} {what:<34} {ratio:5.2f}:1 needs {need}:1 {'PASS' if ratio >= need else 'FAIL'}")
sys.exit(0 if all(r >= n for *_, r, n in rows) else 1)
@@ -0,0 +1,251 @@
def categorize_status(status):
s = (status or "").strip().lower()
if s in SUCCESS_STATUSES:
return "success"
if s in FAILED_STATUSES:
return "failed"
return "attention"
# ── Progress heuristic: map printed markers -> step index + % ─────────────────
# (run_multi is one long call, so we infer progress from the lines it prints.)
STEP_MARKERS = [
("multi-company / multi-location", 0, 5),
("[input] login sheet", 1, 12),
("########## company", 2, 25),
("[choose company]", 2, 32),
("practice home", 3, 45),
("clearinghouse", 4, 55),
("unreviewed", 5, 62),
("posting", 5, 70),
("processed successfully", 5, 74),
("test-notposted", 5, 74),
("medicalperformer", 6, 82), # ERA -> EOB (PDF) phase
("era import ->", 6, 84),
("print-to-pdf", 6, 84),
("[all done]", 7, 100),
]
STEPS = [
"Initializing",
"Reading Excel",
"Login to Tebra",
"Select company",
"Open Clearinghouse",
"Post payments",
"ERA → EOB conversion",
"Completed",
]
# ── Friendly log translation ──────────────────────────────────────────────────
# The on-screen log is read by medical-billing users, not developers, so raw
# automation lines are translated to plain English here. Every RAW line is
# still stored in full in the BatchLog table (per batch) for debugging.
#
# Rules are (marker-substring, friendly-message, type) matched on the
# lowercased line, first match wins. friendly-message None = keep the line off
# the screen. Unmatched lines are hidden too, UNLESS they are errors — a real
# failure must never be invisible.
# IMPORTANT: markers are matched by SUBSTRING against the lowercased line, first
# match wins, so they MUST be specific enough not to catch unrelated lines. e.g.
# a bare "clearinghouse" wrongly matched "Total unreviewed clearinghouse reports:
# 10852" and produced a duplicate "Opening Clearinghouse" — always anchor on the
# distinct "[step N]" / "[medicalperformer] <verb>" prefix instead of a bare word.
_FRIENDLY_RULES = [
("multi-company / multi-location", "Starting the automation…", "info"),
("[input] login sheet", "Reading the Excel input file…", "info"),
("[choose company] screen", "Selecting the company in Tebra…", "info"),
("[login] done", "Logged in to Tebra.", "success"),
("[step 3] confirmed", "Practice opened.", "info"),
("[step 4+5] encounters", "Opening Clearinghouse Reports…", "info"),
("[step 6] checking", "Searching for unreviewed ERAs…", "info"),
("[step 9] downloading + posting", "Checking for ERAs to post…", "info"),
("no more unprocessed era rows", "No more ERAs to post here.", "info"),
("grid still not found", "The ERA list did not load — moving on.", "error"),
("grid not found", "The ERA list is slow to load — retrying…", "warn"),
("[step 9] downloaded:", "ERA file downloaded.", "info"),
("download failed", "Could not download the ERA file — continuing.", "warn"),
("didn't open", "The ERA is slow to open — trying again…", "warn"),
("still could not open view", "Could not open the ERA — marked as Error.", "error"),
("clicking 'post payment", "Clicking Post Payment…", "info"),
("post payment button not found", "Post Payment button not found.", "error"),
("-> era errors screen", "Tebra reported ERA Errors for this payment — cancelling it.", "warn"),
("-> payment screen", "Payment screen opened.", "info"),
("post-payment retry", "The payment screen didn't load — trying again…", "warn"),
("unrecognized screen after post payment", "Unexpected screen after Post Payment — retrying…", "warn"),
("batch # field / reference # not ready", "Batch # field not ready — retrying…", "warn"),
("batch# field or reference# value not found", "Batch # field not found — marked as Error.", "error"),
("filling batch #", "Entering the Batch number…", "info"),
("test mode — would have posted", "Test mode: payment ready — clicking Cancel instead of Save.", "warn"),
("save button not found", "Save button element not found.", "error"),
("[step 9] posted era", "Payment saved — ERA posted.", "success"),
("done posting era payments", "Finished posting for this practice.", "info"),
("[medicalperformer] launching", "Converting ERA files to EOB PDFs…", "info"),
("[medicalperformer] no .txt", "No ERA files to convert.", "info"),
("[done] no-location company complete", "Company finished.", "success"),
# The final line is emitted by Python (_run_automation) from the real run
# summary — hide the raw markers so a run that FAILED can never print a
# hardcoded green "completed".
("[run summary]", None, "info"),
("[all done]", None, "info"),
]
# Dynamic rules that pull a value out of the line (checked before the static
# list). Each is (compiled-regex, lambda match -> (msg, type)).
_FRIENDLY_DYNAMIC = [
(re.compile(r"#+\s*COMPANY\s+(\d+)/(\d+):\s*(.+?)\s*#*\s*$", re.I),
lambda m: (f"Working on company {m.group(1)} of {m.group(2)}: "
f"{m.group(3)}", "info")),
(re.compile(r"PROCESSING LOCATION:\s*(.+)", re.I),
lambda m: (f"Working on location: {m.group(1).strip()}", "info")),
# Login failure — make it clearly visible (the COM/network error the user
# sees when Tebra pops "connection error" and they click OK).
(re.compile(r"\[login\] FAILED for company '(.+?)':", re.I),
lambda m: (f"Could not log in to Tebra for {m.group(1)} — connection or "
f"Tebra error. This company was not processed.", "error")),
(re.compile(r"Unreviewed reports total\s*:\s*(\d+)", re.I),
lambda m: (f"Found {m.group(1)} unreviewed report(s) to check.", "info")),
(re.compile(r"\[Step 9\] Opening ERA row id=(\S+)", re.I),
lambda m: (f"Opening ERA {m.group(1)}…", "info")),
(re.compile(r"Check ID=(\S*)\s+Date=(\S*)\s+Method=.*Amount=(\S*)", re.I),
lambda m: (f"Check {m.group(1) or '—'} · Date {m.group(2) or '—'} · "
f"Amount {m.group(3) or '—'}", "info")),
(re.compile(r"\[Step 9\] Skipping\s+\S+:\s*(.+)", re.I),
lambda m: (f"Skipped — {m.group(1).strip()}", "warn")),
(re.compile(r"\[Done\] Location '(.+?)' complete", re.I),
lambda m: (f"Location finished: {m.group(1)}", "success")),
]
# Error-level lines that are pure developer noise — the friendly login-error
# line and the Python run-summary already tell the user what failed, so these
# raw dumps are kept in the BatchLog table but never shown on screen.
_ERROR_NOISE = (
"traceback (most recent call last)",
"during handling of the above",
"another exception occurred",
"comerror",
"[error] company '", # per-company COM dump — summary covers it
)
def _is_error_noise(low_line):
return any(marker in low_line for marker in _ERROR_NOISE)
def friendly_log_line(line):
"""Translate one raw automation line for the user-facing log.
Returns (message, type) to display, or None to keep it off the screen."""
for rx, fmt in _FRIENDLY_DYNAMIC:
m = rx.search(line)
if m:
return fmt(m)
low = line.lower()
for marker, msg, ltype in _FRIENDLY_RULES:
if marker in low:
return (msg, ltype) if msg else None
return None
class _StreamToBridge:
"""A stdout/stderr replacement that tees to the real stream AND forwards
every completed line to the UI as a log event."""
def __init__(self, bridge, original, level="info"):
self.bridge = bridge
self.original = original
self.level = level
self._buf = ""
def write(self, text):
try:
if self.original:
self.original.write(text)
except Exception:
pass
self._buf += text
while "\n" in self._buf:
line, self._buf = self._buf.split("\n", 1)
self._emit_line(line)
def _emit_line(self, line):
line = line.rstrip("\r")
if not line.strip():
return
self.bridge._on_automation_line(line, self.level)
def flush(self):
try:
if self.original:
self.original.flush()
except Exception:
pass
def isatty(self):
return False
class Bridge(QObject):
"""Exposed to JavaScript as 'pyBridge'."""
js_message = pyqtSignal(str)
def __init__(self, window):
super().__init__()
self.window = window
self.username = "User"
self._max_pct = 0
self._cur_step = 0
self.batch_id = None # set on a fresh Trigger or from a resume check
self._batch_log = None # BatchLogWriter while a run is active
self._run_log_file = None # plain-text run log (DB-independent) while running
self._last_friendly = None # dedupe consecutive identical friendly lines
self._auto_proc = None # the separate automation process (bug-094) while running
# ── Python -> JS ────────────────────────────────────────────────────────────
def send_to_js(self, event, data):
self.js_message.emit(json.dumps({"event": event, "data": data}))
def _log(self, msg, type="info"):
self.send_to_js("log", {"msg": msg, "type": type})
def _step(self, label, pct):
self.send_to_js("step", {"label": label, "pct": pct})
def _batch_chip(self, batch_id, live=False):
"""Persistent Run-tab chip showing the batch this run is tracked under.
`batch_id` None/0 CLEARS it. A stale number is worse than none, so every
site that assigns self.batch_id must call this in the same breath."""
self.send_to_js("batch_state", {
"batch_id": str(batch_id) if batch_id else "",
"live": bool(live)})
# ── JS -> Python ────────────────────────────────────────────────────────────
@pyqtSlot(str)
def log(self, msg):
print(f"[JS] {msg}")
@pyqtSlot(bool)
def set_dark(self, on):
"""Persist the dark-mode choice so the login page (a separate window)
can pick it up too."""
try:
from src.ui.prefs import set_dark
set_dark(on)
except Exception as e:
print(f"[prefs] could not save dark mode: {e}")
@pyqtSlot()
def logout(self):
"""JS clicked Logout — defer to the next event-loop tick so we don't
run a nested dialog from inside the web-channel callback."""
from PyQt6.QtCore import QTimer
QTimer.singleShot(0, self.window.do_logout)
@pyqtSlot()
def browse_file(self):
path, _ = QFileDialog.getOpenFileName(
self.window, "Select Input Excel File",
os.path.expanduser("~"),
"Excel files (*.xlsx *.xlsm *.xls)"
@@ -0,0 +1,171 @@
"""Drive the Run tab's 7-box checkpoint tracker.
kind: 'active' (box cp is now running) | 'done' (box cp finished) |
'stuck' (box cp overran its max time)."""
self.send_to_js("cpstate", {"kind": kind, "cp": int(cp)})
def _on_automation_line(self, line, level):
"""Called (from the worker thread) for every printed line."""
# Watchdog checkpoint state marker -> 7-box tracker in the Run tab
# (raw line still stored in the DB log below).
if "[CPSTATE]" in line:
try:
kind, cp = line.split("[CPSTATE]", 1)[1].split()
self._cpstate(kind, cp)
# Feed the parent-side stall watchdog. The automation runs in a
# CHILD process, so the in-process performer.WD_STATE the old
# watchdog read is ALWAYS empty here (it never fired — a stuck
# checkpoint never restarted). Track the active checkpoint + its
# start time from the STREAMED [CPSTATE] line instead.
if kind == "active":
self._wd_cp = int(cp)
self._wd_since = time.time()
except Exception:
pass
low = line.lower()
ltype = "info"
if "[error]" in low or "[fatal]" in low or "traceback" in low or "exception" in low:
ltype = "error"
elif "warning" in low or "[warn" in low:
ltype = "warn"
elif "processed successfully" in low or "[all done]" in low or "success" in low:
ltype = "success"
# The FULL raw technical line always goes to the per-batch DB log AND to
# the DB-independent file log (the file works even when the DB is down).
if self._batch_log is not None:
self._batch_log.log(ltype, line)
if self._run_log_file is not None:
try:
self._run_log_file.write(line + "\n")
except Exception:
pass
# The on-screen log gets the plain-English translation only; raw lines
# stay off the screen — except untranslated ERRORS, which are shown
# as-is (a failure must never be invisible to the user).
shown = friendly_log_line(line)
if shown is not None:
if shown[0] != self._last_friendly: # collapse repeated polling lines
self._log(shown[0], shown[1])
self._last_friendly = shown[0]
elif ltype == "error" and not _is_error_noise(low):
# An untranslated real error still shows (a failure must never be
# invisible) — but raw traceback/COM noise is filtered out.
self._log(line, "error")
self._last_friendly = None
# advance the progress bar / step list on known markers
for marker, step_idx, pct in STEP_MARKERS:
if marker in low:
if step_idx > self._cur_step:
self._cur_step = step_idx
if pct > self._max_pct:
self._max_pct = pct
self._step(STEPS[self._cur_step], self._max_pct)
break
def _resolve_input_path(self, p):
"""Drag-drop in QWebEngine often hides the real disk path, so we may only
get a bare filename. Resolve it against the known input folders so the
automation always receives a real, absolute path."""
if p and os.path.isabs(p) and os.path.exists(p):
return p
base = os.path.basename(p) if p else ""
for folder in (INPUT_DIR, _ROOT):
cand = os.path.join(folder, base) if base else ""
if cand and os.path.exists(cand):
return cand
return p # unresolved — caller will report a clear error
def _run_multi_subprocess(self, excel_path, emp):
"""Run performer.run_multi in a SEPARATE PROCESS (its own MAIN thread,
no Qt) and stream its stdout back through this run's log pipeline.
Why a subprocess: pywinauto/UIA reads are reliable on a process MAIN
thread but flaky on a Qt BACKGROUND thread (STA-without-a-message-pump
for cross-process UIA; forcing MTA dead-locks against the Qt main
thread's UIA object). The reference tool works because it is a separate
process with no Qt — this reproduces that. Every child stdout line is
re-printed here so it flows through _StreamToBridge → _on_automation_line
exactly as an in-process print did (friendly log, [CPSTATE] tracker,
per-batch DB log, file log). Returns the run_multi summary dict.
"""
import subprocess
import time as _time
proj_root = os.path.dirname(os.path.dirname(os.path.dirname(
os.path.abspath(__file__))))
# The child writes ALL its output to THIS file and we tail it live. A
# --windowed PyInstaller child's stdout PIPE is block-buffered/unreliable
# (v1.1.5: nothing streamed back), but a line-buffered file always
# flushes per line and can be read while the process runs.
try:
os.makedirs(LOGS_DIR, exist_ok=True)
except Exception:
pass
child_log = os.path.join(
LOGS_DIR,
f"auto_child_{datetime.now().strftime('%Y%m%d_%H%M%S')}"
f"_{self.batch_id or 0}.log")
try:
open(child_log, "w", encoding="utf-8").close() # start empty
except Exception:
pass
if getattr(sys, "frozen", False):
cmd = [sys.executable, "--run-automation"]
else:
cmd = [sys.executable, os.path.join(proj_root, "run_app.py"),
"--run-automation"]
cmd += ["--excel", str(excel_path),
"--batch", str(self.batch_id or ""),
"--emp", str(emp or ""),
"--log", child_log,
# Parent-death watchdog: the child exits itself the instant this
# GUI process dies (crash, taskkill, or a closeEvent we couldn't
# run) so automation can never keep driving Tebra orphaned.
"--parent-pid", str(os.getpid())]
env = dict(os.environ)
env["PYTHONUNBUFFERED"] = "1"
creationflags = getattr(subprocess, "CREATE_NO_WINDOW", 0) \
if sys.platform.startswith("win") else 0
print(f"[Automation] Launching automation in its own process "
f"(reliable UIA) — batch {self.batch_id} …")
try:
proc = subprocess.Popen(
cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
cwd=proj_root, env=env, creationflags=creationflags)
except Exception as e:
print(f"[Automation] Could not launch automation process: {e}")
return {"total": 0, "ok": [],
"failed": [("Automation", f"launch failed: {e}")],
"stopped": False}
self._auto_proc = proc
summary = {}
got_summary = False
def _handle(line):
nonlocal summary, got_summary
if line.startswith("__WF_SUMMARY__"):
try:
summary = json.loads(line[len("__WF_SUMMARY__"):]) or {}
got_summary = True
except Exception:
pass
elif line.strip():
print(line) # → _StreamToBridge tee → _on_automation_line
# Tail the child log until the process exits, then drain the rest.
try:
with open(child_log, "r", encoding="utf-8", errors="replace") as f:
while True:
pos = f.tell()
line = f.readline()
if line and line.endswith("\n"):
_handle(line.rstrip("\r\n"))
elif line: # partial line (no newline yet)
if proc.poll() is not None:
_handle(line.rstrip("\r\n"))
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,106 @@
# Optional sections
Three extras live in `dashboard.html` as marked blocks (`OPTIONAL:<name> BEGIN` ... `END`)
in the style, the markup and the script. Build only the ones the user picked:
```
python <skill>/templates/sections.py <project>/dashboard/dashboard.html checkpoints admin login
```
Name only the chosen sections; name none to remove all three. The script deletes every
unchosen block (markup, script and style) and the marker lines of the chosen ones, and it
stops if a marker is broken. Never cut the blocks by hand.
With `SAMPLE_DATA = true`, every section works on its own sample answers. Once the bot is
wired, the host must answer the section's actions as below. Desktop hosts add the slots to
the `Bridge` class in `bridge.py`. Web hosts serve `POST {API_BASE}/<action-with-dashes>`,
like the core actions, and send the events in the `/run/status` events list.
## Checkpoint tracker (`checkpoints`)
Numbered boxes in the run panel. Each one is pending, in progress, done or stuck.
- Fill `{{CHECKPOINTS_JSON}}` with the checkpoint names, in order, from the bot's real
stages, for example `["Open the portal","Load the list","Process items","Finish up"]`.
They can be finer than `STEPS`.
- Event `cpstate {kind: "pending"|"active"|"done"|"stuck", cp}`. `cp` counts from 1.
`active` also marks the earlier boxes done and the later ones pending, so the bot can
send `active` for each checkpoint as it starts, and `stuck` when one fails.
- Desktop: add a marker table next to `STEP_MARKERS` in `bot_runner.py`, and add the event
in `events_for`, using specific substrings the bot really prints. The dry run then shows
the `cpstate` events too:
```python
# (specific substring, kind, cp). First match wins.
CHECKPOINT_MARKERS = [("[cp 1]", "active", 1), ("[cp 1 failed]", "stuck", 1)]
# in events_for, right after the step marker:
if cp := _first(CHECKPOINT_MARKERS, line):
out.append(("cpstate", {"kind": cp[1], "cp": cp[2]}))
```
## Admin/roles screen (`admin`)
An Admin tab where an admin assigns or removes admin and operator access. The Admin nav
item and the screen appear only while `set_user` says `role: "admin"`; operators never see
them. Hiding them is not security: **the host re-checks that the signed-in user is an admin
on every admin action**, and refuses otherwise.
- Event `admin_data {rows: [{username, emp_id, role: "admin"|"operator"}], msg?}`. `msg` is
shown above the list, for example "Access assigned." or "Only admins can do that."
- Actions: `load_admin()` when the tab opens, `assign_role(who, role)`, `remove_role(who)`.
`who` is a username or employee ID. Answer each one with a fresh `admin_data`.
- Desktop slots, using the project's own user store:
```python
@pyqtSlot()
def load_admin(self):
if self.role != "admin": # self.role: set when the user signs in
return
self.send_to_js("admin_data", {"rows": read_access_list()})
@pyqtSlot(str, str)
def assign_role(self, who, role):
if self.role == "admin" and role in ("admin", "operator"):
save_access(who, role)
self.load_admin()
@pyqtSlot(str)
def remove_role(self, who):
if self.role == "admin":
delete_access(who)
self.load_admin()
```
## Login page (`login`)
A sign-in screen in the page's own theme, shown over the dashboard until sign-in succeeds,
plus a Sign out button in the top bar. It is UI only: it hands the username and password to
the project's existing auth. Don't build an auth backend.
- Desktop action `login(username, password)`. The host checks them and sends
`login_result {ok, msg?, user?: {username, emp_id, role}}`. On `ok` the screen closes and
`user` fills the sidebar (and the role, if the admin screen is built). Otherwise `msg`
shows under the form.
- Web: `POST {API_BASE}/login` with JSON `{username, password}`, answered with the same
`{ok, msg?, user?}` body.
- Action `logout()` when the user signs out. The screen comes back.
- The page sits behind the screen, but the host must still refuse data actions until
sign-in succeeds.
- No auth in the project yet: keep a stub that lets any non-empty username in, and tell the
user it is a stub. In sample mode the page has its own stub.
```python
@pyqtSlot(str, str)
def login(self, username, password):
role = check_login(username, password) # the project's auth: a role, or None
self.role = role or ""
if role:
self.send_to_js("login_result", {"ok": True, "user": {"username": username, "emp_id": "", "role": role}})
else:
self.send_to_js("login_result", {"ok": False, "msg": "Wrong username or password."})
@pyqtSlot()
def logout(self):
self.role = ""
```
@@ -0,0 +1,35 @@
"""Keep the optional sections the user chose in dashboard.html, and delete the rest.
Usage: python sections.py <dashboard.html> [checkpoints] [admin] [login]
Each section sits between marker lines, in HTML, CSS and script:
<!-- OPTIONAL:admin BEGIN --> /* OPTIONAL:admin BEGIN */ // OPTIONAL:admin BEGIN
... ... ...
<!-- OPTIONAL:admin END --> /* OPTIONAL:admin END */ // OPTIONAL:admin END
Chosen sections lose only their marker lines. Unchosen ones lose everything.
The file is rewritten in place. Name no sections to remove all of them.
"""
import re
import sys
from pathlib import Path
SECTIONS = ("checkpoints", "admin", "login")
BLOCK = re.compile(r"^[ \t]*(?:<!--|/\*|//) OPTIONAL:(\w+) BEGIN.*\n((?:.*\n)*?)[ \t]*(?:<!--|/\*|//) OPTIONAL:\1 END.*\n", re.M)
def keep(html, chosen):
unknown = set(chosen) - set(SECTIONS)
if unknown:
sys.exit(f"unknown section(s) {', '.join(sorted(unknown))}; choose from {', '.join(SECTIONS)}")
html = BLOCK.sub(lambda m: m[2] if m[1] in chosen else "", html)
if "OPTIONAL:" in html:
sys.exit("an OPTIONAL marker has no matching BEGIN/END line; fix the template")
return html
if __name__ == "__main__":
if len(sys.argv) < 2:
sys.exit(__doc__)
page = Path(sys.argv[1])
page.write_text(keep(page.read_text(encoding="utf-8"), sys.argv[2:]), encoding="utf-8")
print(f"{page}: kept {', '.join(sys.argv[2:]) or 'no optional sections'}")
@@ -0,0 +1,99 @@
# Theme presets
The theme lives **only** in the two token blocks at the top of `dashboard.html`:
`body{...}` (light) and `body.dark{...}` (dark). To apply a theme, replace both
blocks. Never edit component CSS, layout or script to restyle. Every palette
ships with both blocks, so dark mode never looks broken.
Status colours `--ok`, `--warn` and `--bad` are semantic (done / in progress or
attention / failed). Keep them unless the user gives their own.
Component tokens say where a preset puts its brand:
`--header-bg`, `--header-ink`, `--header-muted` (top bar), `--primary-bg`
(primary buttons), `--nav-active-bg` (active nav row and avatar),
`--progress-bg` (progress fill), `--blur` (glass blur, or `none` for flat),
`--font` (body) and `--font-display` (headings, figures, buttons).
## 1. Tebra Sky (the template default)
Blue glass cards on a sky gradient. Segoe UI. Accent `#3d9bd0`.
```css
body{
--bg-top:#8fc9e8; --bg-mid:#bfe3f2; --bg-bottom:#eaf6fb;
--glass:rgba(255,255,255,.60); --glass-strong:rgba(255,255,255,.78); --border:rgba(255,255,255,.9);
--ink:#1c2b33; --muted:#5c7382; --accent-dark:#16202a;
--accent:#3d9bd0; --accent-2:#5cb2e2; --accent-soft:#d4edfa; --accent-soft-2:#a9d8ee;
--ok:#1f9d57; --ok-bg:#e6f6ec; --warn:#b7791f; --warn-bg:#fbf1dd; --bad:#c0392b; --bad-bg:#fdecea;
--font:'Segoe UI',-apple-system,BlinkMacSystemFont,sans-serif; --font-display:var(--font);
--header-bg:var(--glass-strong); --header-ink:var(--ink); --header-muted:var(--muted);
--primary-bg:linear-gradient(180deg,var(--accent-2),var(--accent));
--nav-active-bg:linear-gradient(135deg,var(--accent),var(--accent-dark));
--progress-bg:var(--ok); --blur:blur(14px);
}
body.dark{
--bg-top:#0f1b26; --bg-mid:#13212e; --bg-bottom:#182a38;
--glass:rgba(22,34,44,.72); --glass-strong:rgba(30,45,58,.85); --border:rgba(120,160,190,.22);
--ink:#e6eef4; --muted:#8fa6b4; --accent-dark:#2e5d80;
--accent-soft:#2e5d90; --accent-soft-2:#1d3d5e;
--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);
}
```
## 2. Superbill Ember
Flat surfaces. The brand gradient `#ff3049` → `#f26b21` appears **only** on the
top bar, primary buttons, the active nav row and the progress fill. Nothing else
gets it. Sora for display, IBM Plex Sans for body.
Also add this line inside `<head>`, before `<style>`. Offline (for example in a
PyQt app with no internet), the fonts fall back to Segoe UI, which is fine.
```html
<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">
```
```css
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);
}
```
## 3. Custom (one brand hex)
Start from the Tebra Sky blocks above, then derive the accent family from the
user's brand colour:
```
python <skill>/templates/palette.py "#7a3cff"
```
It prints the `--accent`, `--accent-2`, `--accent-dark`, `--accent-soft` and
`--accent-soft-2` values for both blocks, then a contrast table for light and dark:
- white button text on the accents, at least 3:1;
- accent-coloured text on the page, at least 3:1;
- ink on the soft buttons, at least 4.5:1;
- log text on the log box, at least 4.5:1.
If the brand colour is too light or too dark to read, the script nudges it until
every check passes. Paste the printed tokens over the same tokens in both blocks.
Show the user the contrast table, and say so if their colour was nudged.
Keep ok/warn/bad unless the user gives their own.