No-AI skill tracer: SKILL.md, theme presets, sample data, Seam 1 page check

- rpa-dashboard-no-ai/SKILL.md: trigger description and the workflow in spec
  order (scan, report/keep-or-restyle, colours, stack, sections, build, wire,
  verify).
- templates/themes.md: Tebra Sky and Superbill Ember, light and dark blocks.
- templates/palette.py: derives a custom palette from one brand hex and
  checks text contrast in light and dark.
- dashboard.html: built-in sample data so a page with no bot works on first
  open; cleared batches in the donut; component tokens so Ember is a
  token-only swap; accessible names for KPI cards, nav, steps, log, search
  and the dark toggle.
- bridge.py: placeholders inside string literals, so it passes the lint hook
  unfilled.
- tests/page_check: Seam 1 harness (fake Qt bridge, light/dark/narrow).
- tests/evals: Seam 2 runner and fixture a-empty.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
VasanthandClaude Opus 5.5 committed 2026-10-16 13:59:26 +05:30
1 parent 49f8f49b65
commit 7f2ddecffb
13 files changed
+865 -57

No files matched your search

+121
View File
@@ -0,0 +1,121 @@
---
name: rpa-dashboard-no-ai
description: Builds a working dashboard page for an RPA bot that does not use AI. It always includes a sidebar and top bar with a dark toggle and status dot, KPI cards, a batch status donut, a run panel with steps and a live log, and a report table with search and Excel export. The page is wired to the bot through one data contract, or runs on sample data when there is no bot yet. Supports a desktop path (PyQt6 with an embedded page) and a web path. Use when the user asks for an "RPA dashboard", "automation dashboard", "bot dashboard", a dashboard "without AI", or a status/run/report page for a bot. For bots that use an LLM or OCR extraction, use rpa-dashboard-ai instead.
---
# RPA dashboard (no AI)
Everything this skill needs is bundled in `templates/`. Never read the original
source projects.
| File | What it is |
|---|---|
| `templates/dashboard.html` | The single-file page: theme tokens, shell, KPI, donut, run panel, report, sample data. Works in PyQt (QWebChannel) or on the web (polling). |
| `templates/bridge.py` | Minimal PyQt6 host. It runs the bot as a child process and tails its output. |
| `templates/contract.md` | The data contract: events the bot sends, actions the page sends. |
| `templates/themes.md` | Theme presets (Tebra Sky, Superbill Ember) and the custom-colour steps. |
| `templates/palette.py` | Derives a custom palette from one brand hex and checks contrast. |
| `templates/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.
If the user's request already answers a question, don't ask it again.
## 1. Scan the project
Before asking anything, look at the project:
- **Frontend:** `*.html` files outside `node_modules`, `.venv` and build output; a
`web/`, `frontend/` or `static/` folder; `package.json` with react, vue, angular or
svelte; a PyQt `QWebEngineView`. For any you find, note the location, the stack and
the main colour tokens (CSS custom properties or the Tailwind theme).
- **Bot:** the entry point (for example `main.py`, a `__main__` module or a run
script), what it prints (step markers, status and error lines), and how it starts.
## 2. Report the frontend, then ask keep or restyle
- **No frontend:** say "No existing frontend found." and go to step 3. Don't
describe a frontend that isn't there.
- **Frontend found:** say "Frontend found at `<path>`, stack `<stack>`, colours
`<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
change none of its existing styles.
- Restyle: go to step 3. Change only the theme tokens, never layout or logic.
## 3. Ask for colours
Skip this if the user chose to keep the existing look. Offer:
1. **Tebra Sky**: blue glass cards on a sky gradient.
2. **Superbill Ember**: flat surfaces with a red-orange brand gradient.
3. **Custom**: the user gives one brand hex colour.
## 4. Ask desktop or web
Ask only if there is no frontend. If there is one, use its stack.
- **Desktop:** a PyQt6 window with the page embedded. The page talks to Python
through a bridge, so no web server is needed.
- **Web:** the same page, served by a backend that it polls.
## 5. Ask which optional sections to build
Always built: the shell (sidebar, top bar, dark toggle, status dot), KPI cards,
the status donut, the run panel and the report table.
Ask which extras to add (choose any, or none): **checkpoint tracker**,
**admin/roles screen**, **login page**. Build only the ones chosen. Unchosen
sections must leave no markup, script or style behind, so delete their
`<!-- OPTIONAL: ... -->` markers.
## 6. Build
1. Copy `templates/dashboard.html` into the project:
- Desktop: put it next to the host, for example `dashboard/dashboard.html`.
- Web: put it in the backend's static folder. With no backend yet, use
`dashboard/dashboard.html`.
2. Fill every `{{...}}` placeholder:
| Placeholder | Value |
|---|---|
| `APP_NAME`, `APP_SUBTITLE` | The project's name and a short tagline. |
| `LOGO_SRC` | The logo's path, or empty if there is none. |
| `STEPS_JSON` | The run steps as a JSON list, from the bot's real stages, for example `["Initializing","Login","Process items","Completed"]`. |
| `REPORT_COLUMNS_JSON` | `[{"key":..., "label":...}]`. It must include a column with key `status`, which shows the bucket pill. |
| `API_BASE` | Web only, for example `/api`. Leave it empty for desktop. |
3. Apply the theme by replacing the `body{...}` and `body.dark{...}` token blocks
with the preset from `templates/themes.md`. For Ember, also add its font link.
For a custom colour, run `python templates/palette.py "<hex>"`, paste the
tokens it prints, and show the user its contrast table.
4. Desktop: also copy `templates/bridge.py` next to the page, and fill
`{{BOT_MODULE}}`, the `STEPS` list (the same steps as the page), `{{ORG}}` and
`{{APP_NAME}}`.
5. Search the output for `{{`. None may be left.
## 7. Wire the data
The contract is in `templates/contract.md`: one envelope, `{event, data}`, in
both directions.
- **No bot yet:** keep `SAMPLE_DATA = true` and the `SAMPLE DATA` block. The page
answers its own data actions, so it opens with sample batches and report rows,
and Run plays a demo run. Tell the user which events their bot must send later.
- **Bot exists:** set `SAMPLE_DATA = false` and delete the `SAMPLE DATA` block. Then:
- Desktop: in `bridge.py`, set `BOT_CMD` to start the bot as a child process.
Fill `STEP_MARKERS` and `FRIENDLY_RULES` from lines the bot really prints. Use
specific substrings (for example `[step 3]`, not a bare word); the first match
wins. Unmatched error lines must always reach the log.
- Web: the backend serves `GET {API_BASE}/dashboard`, `GET {API_BASE}/run/status`
(returning `{running, events[]}`) and `POST {API_BASE}/<action-with-dashes>`.
- Apply the contract's derived rules: batch state from Pending plus date, and
report buckets from status.
## 8. Verify
Open the page. On desktop run the host; otherwise open the file or the served URL.
With sample data, opening the file in a browser is enough. Check:
- the light theme, the dark theme (use the toggle), and a window 820px wide or less;
- KPI cards, donut and legend, the Run tab's steps and log, and the Report tab's
rows and pills all show data;
- no `{{` placeholders are left and the console shows no script errors.
If Python Playwright is installed, take headless screenshots of each check and
look at them. Otherwise, ask the user to open the page. Finish by telling the user
where the files are, which theme and sections were built, and whether the page is
on sample data or wired to the bot.
+1 -1
View File
@@ -12,7 +12,7 @@ from PyQt6.QtWebChannel import QWebChannel
HTML_PATH = Path(__file__).with_name("dashboard.html")
BOT_CMD = [sys.executable, "-m", "{{BOT_MODULE}}"] # child process
STEPS = {{STEPS_PY}} # same list as STEPS in dashboard.html
STEPS = ["{{STEP_1}}", "{{STEP_2}}"] # same list as STEPS in dashboard.html
STEP_MARKERS = [("[step 1]", 0, 10), ("[all done]", len(STEPS) - 1, 100)] # (substring, step idx, pct)
FRIENDLY_RULES = [("[login] done", "Logged in.", "success")] # (substring, friendly msg | None, type)
@@ -4,6 +4,7 @@ All updates use one envelope: `{"event": "<name>", "data": {...}}`.
- **Desktop (PyQt):** Python calls `window.__pyMsg(json)` via `runJavaScript` (see `bridge.py`).
- **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
+93 -56
View File
@@ -3,7 +3,7 @@
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 :root / body.dark token blocks only.
Replace every {{...}} placeholder. Theme = the body / body.dark token blocks only (see themes.md).
-->
<html lang="en">
<head>
@@ -12,14 +12,18 @@
<title>{{APP_NAME}}</title>
<script src="qrc:///qtwebchannel/qwebchannel.js"></script>
<style>
/* ===== THEME TOKENS (swap these for a preset/custom palette) ===== */
:root{
/* ===== 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:'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;
@@ -31,53 +35,56 @@ body.dark{
}
/* ===== BASE ===== */
*{box-sizing:border-box;margin:0;padding:0}
html,body{height:100%;font-family:var(--font);color:var(--ink);overflow:hidden;
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:blur(14px);display:flex;flex-direction:column}
.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{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{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:linear-gradient(135deg,var(--accent),var(--accent-dark));color:#fff;box-shadow:0 4px 12px rgba(20,50,70,.22)}
.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:linear-gradient(135deg,var(--accent),var(--accent-dark));display:flex;align-items:center;justify-content:center;font-size:12px;font-weight:700;color:#fff}
.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(--glass-strong);border-bottom:1px solid var(--border);backdrop-filter:blur(14px);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(--muted);margin-top:3px}
.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{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}
.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{width:38px;height:38px;border-radius:11px;border:1px solid var(--border);background:var(--glass-strong);cursor:pointer;font-size:16px}
.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:blur(14px);box-shadow:0 10px 30px rgba(20,50,70,.08)}
.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:linear-gradient(180deg,var(--accent-2),var(--accent));color:#fff;border-color:var(--accent)}
.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.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:linear-gradient(135deg,var(--accent-2),var(--accent))}
.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}}
@@ -95,7 +102,7 @@ html,body{height:100%;font-family:var(--font);color:var(--ink);overflow:hidden;
.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:linear-gradient(90deg,var(--ok),var(--ok));transition:width .5s}
.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)}
@@ -117,13 +124,13 @@ td{padding:10px 14px;border-bottom:1px solid var(--border)}
<div class="app">
<aside class="sidebar">
<div class="logo-area">
<div class="logo-box"><img src="{{LOGO_SRC}}" alt=""></div>
<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">
<div class="nav-item active" data-tab="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></div>
<div class="nav-item" data-tab="run"><svg viewBox="0 0 24 24"><polygon points="6 4 20 12 6 20 6 4"/></svg><span>Run</span></div>
<div class="nav-item" data-tab="report"><svg viewBox="0 0 24 24"><path d="M4 4h16v16H4z"/><path d="M8 9h8M8 13h8M8 17h5"/></svg><span>Report</span></div>
<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 nav item (roles) goes here -->
</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>
@@ -134,7 +141,7 @@ td{padding:10px 14px;border-bottom:1px solid var(--border)}
<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" onclick="toggleDark()">🌙</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>
@@ -143,14 +150,14 @@ td{padding:10px 14px;border-bottom:1px solid var(--border)}
<!-- DASHBOARD -->
<div id="tab-dashboard">
<div class="kpi-row">
<div class="kpi total"><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"><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"><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"><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 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"><div class="donut-hole"><div class="dh-num" id="dh-num">0</div><div class="dh-lbl">batches</div></div></div><div id="legend"></div></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>
@@ -161,16 +168,16 @@ td{padding:10px 14px;border-bottom:1px solid var(--border)}
<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>
<div id="steps"></div>
<ol id="steps" aria-label="Steps"></ol>
<!-- OPTIONAL: checkpoint tracker (see reference/tebra_dashboard.html .cp-track) -->
<div class="log" id="log"></div>
<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" placeholder="Search…" oninput="renderReport()"><span style="margin-left:auto"></span><button class="btn primary" onclick="api('download_report')">⬇ Download Excel</button></div>
<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>
@@ -183,29 +190,55 @@ td{padding:10px 14px;border-bottom:1px solid var(--border)}
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: Qt bridge if present, else HTTP polling =====
// ===== 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); };
if(window.qt && window.qt.webChannelTransport){
new QWebChannel(qt.webChannelTransport, ch=>{ bridge=ch.objects.pyBridge; bridge.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();
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){
@@ -215,7 +248,7 @@ function handle(event, d){
case 'dashboard_data': renderDashboard(d.batches||[]); break;
case 'report_data': reportRows = d.rows||[]; renderReport(); break;
case 'set_user': setUser(d); break;
case 'apply_dark': document.body.classList.toggle('dark', !!d.on); break;
case 'apply_dark': setDark(!!d.on); break;
}
}
@@ -230,14 +263,15 @@ function showTab(t){
}
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 toggleDark(){ const on=document.body.classList.toggle('dark'); api('set_dark',on); }
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 `<div class="step ${c}"><div class="step-dot">${c==='done'?'✓':i+1}</div>${esc(s)}</div>`}).join('');
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)); }
@@ -245,19 +279,21 @@ function addLog(msg,type='info'){ const l=$('log'); l.insertAdjacentHTML('before
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 n=s=>b.filter(x=>x.state===s).length;
const c={completed:n('completed'),inprogress:n('inprogress'),notcompleted:n('notcompleted')};
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 css=getComputedStyle(document.body), col={completed:css.getPropertyValue('--ok'),inprogress:css.getPropertyValue('--warn'),notcompleted:css.getPropertyValue('--bad')};
const lbl={completed:'Completed',inprogress:'In progress',notcompleted:'Not completed'};
let acc=0; const stops=[];
Object.keys(c).forEach(k=>{ if(!c[k])return; const a=acc/b.length*100; acc+=c[k]; stops.push(`${col[k]} ${a}% ${acc/b.length*100}%`); });
$('donut').style.background = b.length?`conic-gradient(${stops.join(',')})`:'conic-gradient(var(--border) 0 100%)';
$('legend').innerHTML=Object.keys(c).map(k=>`<div class="dl-row"><span class="dl-dot" style="background:${col[k]}"></span>${lbl[k]} · ${c[k]}</div>`).join('');
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=>`<div class="dl-row">#${esc(x.batch_id)} · ${esc(x.date)} <span class="pill ${x.state==='completed'?'success':x.state==='inprogress'?'attention':'failed'}" style="margin-left:auto">${lbl[x.state]||esc(x.state)}</span></div>`).join('') : 'No batches yet';
$('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();
@@ -268,6 +304,7 @@ function renderReport(){
: `<tr><td class="empty" colspan="${REPORT_COLUMNS.length}">No rows yet</td></tr>`;
}
renderSteps();
startTransport();
</script>
</body>
</html>
+100
View File
@@ -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)
+99
View File
@@ -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.