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") HTML_PATH = Path(__file__).with_name("dashboard.html")
BOT_CMD = [sys.executable, "-m", "{{BOT_MODULE}}"] # child process 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) 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) 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`). - **Desktop (PyQt):** Python calls `window.__pyMsg(json)` via `runJavaScript` (see `bridge.py`).
- **Web:** the page polls `GET {API_BASE}/dashboard` (returns `dashboard_data`) and - **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. `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 ## Bot/backend → page
+93 -56
View File
@@ -3,7 +3,7 @@
Generic RPA dashboard (no AI). Derived from Tebra ERA (reference/tebra_dashboard.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 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). 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"> <html lang="en">
<head> <head>
@@ -12,14 +12,18 @@
<title>{{APP_NAME}}</title> <title>{{APP_NAME}}</title>
<script src="qrc:///qtwebchannel/qwebchannel.js"></script> <script src="qrc:///qtwebchannel/qwebchannel.js"></script>
<style> <style>
/* ===== THEME TOKENS (swap these for a preset/custom palette) ===== */ /* ===== THEME TOKENS (swap these for a preset/custom palette, see themes.md) ===== */
:root{ body{
--bg-top:#8fc9e8; --bg-mid:#bfe3f2; --bg-bottom:#eaf6fb; --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); --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; --ink:#1c2b33; --muted:#5c7382; --accent-dark:#16202a;
--accent:#3d9bd0; --accent-2:#5cb2e2; --accent-soft:#d4edfa; --accent-soft-2:#a9d8ee; --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; --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{ body.dark{
--bg-top:#0f1b26; --bg-mid:#13212e; --bg-bottom:#182a38; --bg-top:#0f1b26; --bg-mid:#13212e; --bg-bottom:#182a38;
@@ -31,53 +35,56 @@ body.dark{
} }
/* ===== BASE ===== */ /* ===== BASE ===== */
*{box-sizing:border-box;margin:0;padding:0} *{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%)} background:linear-gradient(180deg,var(--bg-top) 0%,var(--bg-mid) 45%,var(--bg-bottom) 100%)}
.app{display:flex;height:100vh} .app{display:flex;height:100vh}
.hidden{display:none!important} .hidden{display:none!important}
/* sidebar */ /* 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-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{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-box img{width:28px;height:28px;object-fit:contain}
.logo-title{font-size:15px;font-weight:700} .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} .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{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 svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:2}
.nav-item:hover{background:var(--glass-strong);color:var(--ink)} .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} .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)} .u-name{font-size:13px;font-weight:700}.u-role{font-size:10.5px;color:var(--muted)}
/* main + topbar */ /* main + topbar */
.main{flex:1;display:flex;flex-direction:column;overflow:hidden} .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)} .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(--muted);margin-top:3px} .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} .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{width:8px;height:8px;border-radius:50%}
.dot.ready,.dot.done{background:var(--ok);box-shadow:0 0 7px var(--ok)} .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.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)} .dot.error{background:var(--bad);box-shadow:0 0 7px var(--bad)}
@keyframes blink{50%{opacity:.4}} @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} .content{flex:1;overflow-y:auto;padding:26px 28px;border-top-left-radius:30px}
/* cards, buttons, pills */ /* cards, buttons, pills */
.section-label{font-size:11px;text-transform:uppercase;letter-spacing:1px;color:var(--muted);font-weight:700;margin-bottom:14px} .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} .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{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: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} .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{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 */
.kpi-row{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin-bottom:16px} .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{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-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.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} .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}} @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{display:flex;gap:12px;margin-bottom:8px;font-size:13px;font-weight:700}
.prog-head .pct{margin-left:auto;color:var(--accent)} .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-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{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.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-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"> <div class="app">
<aside class="sidebar"> <aside class="sidebar">
<div class="logo-area"> <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><div class="logo-title">{{APP_NAME}}</div><div class="logo-sub">{{APP_SUBTITLE}}</div></div>
</div> </div>
<nav class="nav"> <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> <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>
<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> <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>
<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" 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 --> <!-- OPTIONAL: Admin nav item (roles) goes here -->
</nav> </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> <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><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"> <div class="tb-right">
<button class="btn primary" id="run-btn" onclick="startRun()">▶ Run</button> <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 class="indicator"><span class="dot ready" id="st-dot"></span><span id="st-text">System ready</span></div>
</div> </div>
</header> </header>
@@ -143,14 +150,14 @@ td{padding:10px 14px;border-bottom:1px solid var(--border)}
<!-- DASHBOARD --> <!-- DASHBOARD -->
<div id="tab-dashboard"> <div id="tab-dashboard">
<div class="kpi-row"> <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 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"><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 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"><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 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"><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 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>
<div class="dash-grid"> <div class="dash-grid">
<div class="card"><div class="c-title">Batch status split</div> <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>
<div class="card"><div class="c-title">Recent batches</div><div id="recent" class="empty">No batches yet</div></div> <div class="card"><div class="c-title">Recent batches</div><div id="recent" class="empty">No batches yet</div></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="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-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 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) --> <!-- 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>
</div> </div>
<!-- REPORT --> <!-- REPORT -->
<div id="tab-report" class="hidden"> <div id="tab-report" class="hidden">
<div class="card"> <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 class="tbl-wrap"><table><thead id="r-head"></thead><tbody id="r-body"></tbody></table></div>
</div> </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 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 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 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"]}; 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 = []; let bridge = null, reportRows = [];
function api(slot, ...args){ function api(slot, ...args){
if(SAMPLE_DATA && SAMPLE[slot]) return SAMPLE[slot](...args);
if(bridge) return bridge[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(()=>{}); 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); }; window.__pyMsg = (json)=>{ const m=JSON.parse(json); handle(m.event,m.data); };
if(window.qt && window.qt.webChannelTransport){ function startTransport(){
new QWebChannel(qt.webChannelTransport, ch=>{ bridge=ch.objects.pyBridge; bridge.load_dashboard(); }); if(window.qt && window.qt.webChannelTransport){
}else{ new QWebChannel(qt.webChannelTransport, ch=>{ bridge=ch.objects.pyBridge; api('load_dashboard'); });
const poll = async()=>{ }else if(SAMPLE_DATA){
try{ api('load_dashboard');
const d = await (await fetch(`${API_BASE}/dashboard`)).json(); handle('dashboard_data',d); }else{
const s = await (await fetch(`${API_BASE}/run/status`)).json(); const poll = async()=>{
(s.events||[]).forEach(e=>handle(e.event,e.data)); try{
setTimeout(poll, s.running ? 2000 : 10000); // poll fast only while busy const d = await (await fetch(`${API_BASE}/dashboard`)).json(); handle('dashboard_data',d);
}catch(e){ setTimeout(poll, 10000); } const s = await (await fetch(`${API_BASE}/run/status`)).json();
}; (s.events||[]).forEach(e=>handle(e.event,e.data));
poll(); 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) ===== // ===== EVENT DISPATCH (see contract.md) =====
function handle(event, d){ function handle(event, d){
switch(event){ switch(event){
@@ -215,7 +248,7 @@ function handle(event, d){
case 'dashboard_data': renderDashboard(d.batches||[]); break; case 'dashboard_data': renderDashboard(d.batches||[]); break;
case 'report_data': reportRows = d.rows||[]; renderReport(); break; case 'report_data': reportRows = d.rows||[]; renderReport(); break;
case 'set_user': setUser(d); 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'); 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 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 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 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 startRun(){ showTab('run'); $('log').innerHTML=''; setStep(STEPS[0],0); api('start_automation'); }
function renderSteps(active=-1){ function renderSteps(active=-1){
$('steps').innerHTML=STEPS.map((s,i)=>{const c=i<active?'done':i===active?'active':'pending'; $('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+'%'; 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)); } 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(); 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); } 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){ function renderDashboard(b){
const n=s=>b.filter(x=>x.state===s).length; const c={}; Object.keys(STATES).forEach(k=>c[k]=b.filter(x=>x.state===k).length);
const c={completed:n('completed'),inprogress:n('inprogress'),notcompleted:n('notcompleted')};
countUp($('k-total'),b.length); countUp($('k-done'),c.completed); countUp($('k-prog'),c.inprogress); countUp($('k-fail'),c.notcompleted); 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; $('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 tot=Object.values(c).reduce((a,x)=>a+x,0); let acc=0; const stops=[];
const lbl={completed:'Completed',inprogress:'In progress',notcompleted:'Not completed'}; 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}%`); });
let acc=0; const stops=[]; $('donut').style.background = tot?`conic-gradient(${stops.join(',')})`:'conic-gradient(var(--border) 0 100%)';
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').setAttribute('aria-label','Batch status: '+Object.keys(c).map(k=>`${STATES[k][0]} ${c[k]}`).join(', '));
$('donut').style.background = b.length?`conic-gradient(${stops.join(',')})`:'conic-gradient(var(--border) 0 100%)'; $('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('');
$('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('');
$('recent').className = b.length?'':'empty'; $('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(){ function renderReport(){
const q=$('r-search').value.toLowerCase(); 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>`; : `<tr><td class="empty" colspan="${REPORT_COLUMNS.length}">No rows yet</td></tr>`;
} }
renderSteps(); renderSteps();
startTransport();
</script> </script>
</body> </body>
</html> </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.
+29
View File
@@ -0,0 +1,29 @@
# Tests
Run everything from the repo root:
```
uv run --with pytest --with playwright pytest tests
```
- `test_*.py`: assert-based self-checks for template logic (lint of unfilled Python
templates, the custom palette, presets in step with the template).
- `page_check/`: **Seam 1.** Loads a dashboard page in headless Chromium with no
backend, pushes contract envelopes through `window.__pyMsg`, and asserts on what
is visible. Every case runs in light, dark and narrow (800px) mode.
- By default it checks the no-AI template, filled with test values. To check a
generated page instead, set `PAGE_CHECK_HTML=path/to/dashboard.html`.
- The page sees a fake Qt bridge. Every action the page sends is recorded in
`window.__calls` as `[slot, ...args]`.
- To add a case, write a `test_*.py` file in `page_check/` that uses the `page`
and `push(event, data)` fixtures from `conftest.py`.
- `evals/`: **Seam 2.** Runs the skills on a fixture project with `claude -p`,
then grades the reply and runs the page check on the page it built:
```
uv run --with pytest --with playwright python tests/evals/run_eval.py a-empty
```
Results go to `evals/results/<fixture>.json`. To add a fixture, add an entry to
`FIXTURES` in `run_eval.py`.
- `fixtures/<letter>-<name>/`: small test projects for the evals.
+100
View File
@@ -0,0 +1,100 @@
"""Seam 2 skill eval: run the skills on a fixture project with `claude -p`, then grade.
Usage (from the repo root):
uv run --with pytest --with playwright python tests/evals/run_eval.py a-empty
Copies tests/fixtures/<fixture> to a temp folder, installs every rpa-dashboard-*
skill there as a project skill, and runs `claude -p` with the user's answers in the
prompt (`-p` can't answer questions). It then grades the reply, runs the Seam 1 page
check on the page it built, and writes tests/evals/results/<fixture>.json.
Exit code 0 means every check passed.
"""
import importlib.util
import json
import os
import re
import shutil
import subprocess
import sys
import tempfile
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
REPLY_FORMAT = """
When you're done, end your reply with exactly these two lines:
FRONTEND: <what your scan found, or none>
QUESTIONS: <each question the skill told you to ask, in order, separated by " | ">"""
# Per fixture: the request, what the reply must (not) mention, and checks on the output.
FIXTURES = {
"a-empty": {
"prompt": "Build me an RPA dashboard for this project. I can't answer questions while you "
"work, so here are my answers to anything the skill asks. Colours: custom, my "
"brand colour is #7a3cff. Desktop or web: desktop. Optional sections: none.",
"frontend": r"^\s*(none|no\b)",
"asks": [r"colou?r", r"desktop|web", r"section"],
"never_asks": [r"\bkeep\b|restyle"],
"brand": "#7a3cff",
},
}
def main(fixture):
cfg = FIXTURES[fixture]
work = Path(tempfile.mkdtemp(prefix=f"rpa-eval-{fixture}-"))
project = work / "project"
shutil.copytree(ROOT / "tests" / "fixtures" / fixture, project)
for skill in ROOT.glob("rpa-dashboard-*"):
shutil.copytree(skill, project / ".claude" / "skills" / skill.name)
run = subprocess.run(
[shutil.which("claude") or "claude", "-p", cfg["prompt"] + REPLY_FORMAT, "--output-format", "json",
"--permission-mode", "acceptEdits", "--allowedTools", "Skill", "Read", "Write", "Edit",
"Glob", "Grep", "Bash(python *)", "PowerShell(python *)"],
cwd=project, capture_output=True, text=True, encoding="utf-8", timeout=1800)
reply = json.loads(run.stdout).get("result", "") if run.stdout.strip().startswith("{") else run.stdout
def line(key):
m = re.search(rf"^\W*{key}\W*:(.*)$", reply, re.M | re.I)
return m[1] if m else ""
checks = {"frontend reported correctly": bool(re.search(cfg["frontend"], line("FRONTEND"), re.I))}
for pat in cfg["asks"]:
checks[f"asks /{pat}/"] = bool(re.search(pat, line("QUESTIONS"), re.I))
for pat in cfg["never_asks"]:
checks[f"never asks /{pat}/"] = not re.search(pat, line("QUESTIONS"), re.I)
pages = [p for p in project.rglob("*.html") if ".claude" not in p.parts]
checks["exactly one page built"] = len(pages) == 1
results = ROOT / "tests" / "evals" / "results"
results.mkdir(exist_ok=True)
if pages:
html = pages[0].read_text(encoding="utf-8")
shutil.copy(pages[0], results / f"{fixture}.dashboard.html")
checks["no placeholders left"] = "{{" not in html
if cfg.get("brand"):
spec = importlib.util.spec_from_file_location("palette", ROOT / "rpa-dashboard-no-ai/templates/palette.py")
palette = importlib.util.module_from_spec(spec)
spec.loader.exec_module(palette)
t = palette.derive(cfg["brand"])
checks["custom palette applied"] = all(f"--accent:{t[m]['--accent']}" in html for m in t)
pc = subprocess.run([sys.executable, "-m", "pytest", str(ROOT / "tests" / "page_check"), "-q"],
env={**os.environ, "PAGE_CHECK_HTML": str(pages[0])}, capture_output=True, text=True)
checks["page check passes"] = pc.returncode == 0
page_check = pc.stdout.strip().splitlines()[-1:] if pc.stdout else [pc.stderr[-500:]]
else:
page_check = ["no page"]
out = {"fixture": fixture, "passed": all(checks.values()), "checks": checks, "page_check": page_check,
"files": sorted(str(p.relative_to(project)) for p in project.rglob("*") if p.is_file() and ".claude" not in p.parts),
"reply": reply}
(results / f"{fixture}.json").write_text(json.dumps(out, indent=2), encoding="utf-8")
for name, ok in checks.items():
print(f"{'PASS' if ok else 'FAIL'} {name}")
print("page check:", *page_check)
print("work dir:", project)
return 0 if out["passed"] else 1
if __name__ == "__main__":
sys.exit(main(sys.argv[1]))
+4
View File
@@ -0,0 +1,4 @@
# Fixture (a): empty project
No frontend and no bot. The no-AI skill should report no frontend, ask for colours,
the stack and optional sections, and produce a page that runs on sample data.
+79
View File
@@ -0,0 +1,79 @@
"""Seam 1 page check: load a dashboard page with no backend, push contract envelopes
through window.__pyMsg, and assert on what is visible.
Page under test: set PAGE_CHECK_HTML to a generated dashboard.html. Without it, the
no-AI template is filled with TEST_FILL. Every test runs in light, dark and narrow mode.
The page sees a fake Qt bridge: each slot call lands in window.__calls as [slot, ...args].
"""
import json
import os
import re
from pathlib import Path
import pytest
from playwright.sync_api import sync_playwright
ROOT = Path(__file__).resolve().parents[2]
TEMPLATE = ROOT / "rpa-dashboard-no-ai" / "templates" / "dashboard.html"
TEST_FILL = {
"APP_NAME": "Test Bot",
"APP_SUBTITLE": "Page check",
"LOGO_SRC": "",
"API_BASE": "",
"STEPS_JSON": json.dumps(["Initializing", "Login", "Process items", "Completed"]),
"REPORT_COLUMNS_JSON": json.dumps([{"key": "item", "label": "Item"}, {"key": "status", "label": "Status"}]),
}
FAKE_BRIDGE = """
window.__calls = [];
window.qt = {webChannelTransport: {}};
window.QWebChannel = function (_transport, ready) {
const pyBridge = new Proxy({}, {get: (_o, slot) => (...args) => { window.__calls.push([slot, ...args]); }});
ready({objects: {pyBridge}});
};
"""
MODES = {"light": (1280, 820, False), "dark": (1280, 820, True), "narrow": (800, 900, False)}
@pytest.fixture(scope="session")
def page_url(tmp_path_factory):
if os.environ.get("PAGE_CHECK_HTML"):
return Path(os.environ["PAGE_CHECK_HTML"]).resolve().as_uri()
html = re.sub(r"\{\{(\w+)\}\}", lambda m: TEST_FILL[m[1]], TEMPLATE.read_text(encoding="utf-8"))
out = tmp_path_factory.mktemp("page") / "dashboard.html"
out.write_text(html, encoding="utf-8")
return out.as_uri()
@pytest.fixture(scope="session")
def browser():
with sync_playwright() as p:
b = p.chromium.launch()
yield b
b.close()
@pytest.fixture(params=list(MODES))
def page(request, browser, page_url):
width, height, dark = MODES[request.param]
ctx = browser.new_context(viewport={"width": width, "height": height})
ctx.add_init_script(FAKE_BRIDGE)
pg = ctx.new_page()
pg.set_default_timeout(5000)
errors = []
pg.on("pageerror", lambda e: errors.append(e))
pg.goto(page_url)
assert not errors, f"page script errors: {errors}"
if dark:
push_to(pg, "apply_dark", {"on": True})
yield pg
ctx.close()
def push_to(pg, event, data):
pg.evaluate("([event, data]) => window.__pyMsg(JSON.stringify({event, data}))", [event, data])
@pytest.fixture
def push(page):
"""push(event, data): send one contract envelope to the page, as the bot would."""
return lambda event, data: push_to(page, event, data)
+169
View File
@@ -0,0 +1,169 @@
"""No-AI contract cases: events in, visible result out (see templates/contract.md)."""
import re
from playwright.sync_api import expect
def batch(n, state):
return {"batch_id": n, "date": "2026-10-16", "state": state, "items_total": 10, "items_done": 4}
BATCHES = [batch(1, "completed"), batch(2, "completed"), batch(3, "completed"),
batch(4, "inprogress"), batch(5, "notcompleted"), batch(6, "cleared")]
def kpi(page, label, n):
return expect(page.get_by_role("group", name=label, exact=True)).to_have_text(
re.compile(rf"^\s*{n}\s*{label}\s*$"))
def test_kpi_counts_match_batch_states(page, push):
push("dashboard_data", {"batches": BATCHES})
kpi(page, "Total batches", 6)
kpi(page, "Completed", 3)
kpi(page, "In progress", 1)
kpi(page, "Not completed", 1)
def colour_name(css):
"""'rgb(r, g, b)' -> red | amber | green | blue | grey, so checks survive any palette."""
r, g, b = (int(x) / 255 for x in re.findall(r"\d+", css)[:3])
hi, lo = max(r, g, b), min(r, g, b)
if hi - lo < 0.25 * min(hi + lo, 2 - hi - lo): # low HSL saturation
return "grey"
d = hi - lo
h = 60 * (((g - b) / d) % 6 if hi == r else (b - r) / d + 2 if hi == g else (r - g) / d + 4)
return "red" if h < 20 or h >= 330 else "amber" if h < 70 else "green" if h < 175 else "blue"
def donut_segments(donut):
"""[(colour name, percent of the ring)] read from the rendered conic gradient."""
css = donut.evaluate("el => getComputedStyle(el).backgroundImage")
segs = []
for colour, positions in re.findall(r"(rgba?\([^)]*\))((?:\s+[\d.]+%)+)", css):
pos = [float(p) for p in re.findall(r"[\d.]+", positions)]
name = colour_name(colour)
if segs and segs[-1][0] == name:
segs[-1][2] = pos[-1]
else:
segs.append([name, pos[0], pos[-1]])
return [(name, round(end - start)) for name, start, end in segs if end > start]
def test_donut_and_legend_match_batch_states(page, push):
push("dashboard_data", {"batches": BATCHES})
legend = page.get_by_role("list", name="Batch status")
for row in ["Completed · 3", "In progress · 1", "Not completed · 1", "Cleared · 1"]:
expect(legend.get_by_text(row, exact=True)).to_be_visible()
donut = page.get_by_role("img", name=re.compile("^Batch status"))
expect(donut).to_have_text(re.compile(r"^\s*6\s*batches\s*$"))
assert donut_segments(donut) == [("green", 50), ("amber", 17), ("red", 17), ("grey", 17)]
def open_tab(page, name):
page.get_by_role("navigation").get_by_role("button", name=name, exact=True).click()
def test_step_list_follows_step_label(page, push):
steps = page.evaluate("STEPS")
open_tab(page, "Run")
push("step", {"label": steps[1], "pct": 40})
items = page.get_by_role("list", name="Steps").get_by_role("listitem")
expect(items).to_have_count(len(steps))
expect(items.nth(0)).to_have_text(re.compile(rf"^\s*✓\s*{re.escape(steps[0])}\s*$"))
expect(items.nth(1)).to_have_attribute("aria-current", "step")
for i in range(2, len(steps)):
expect(items.nth(i)).to_have_text(re.compile(rf"^\s*{i + 1}\s*{re.escape(steps[i])}\s*$"))
expect(page.get_by_text("40%", exact=True)).to_be_visible()
push("step", {"label": steps[-1], "pct": 100})
for i in range(len(steps)):
expect(items.nth(i)).to_have_text(re.compile(r"^\s*✓"))
def colour_of(locator, prop="color"):
return colour_name(locator.evaluate(f"el => getComputedStyle(el).{prop}"))
def test_log_lines_show_their_type(page, push):
open_tab(page, "Run")
lines = [("info", "Opening the portal", "blue"), ("success", "Logged in.", "green"),
("warn", "Slow response", "amber"), ("error", "Login failed", "red"),
("error", "<b>raw bot line</b>", "red")]
for kind, msg, _ in lines:
push("log", {"msg": msg, "type": kind})
log = page.get_by_role("log")
for _, msg, colour in lines:
line = log.get_by_text(msg, exact=True)
expect(line).to_be_visible()
assert colour_of(line) == colour, msg
def test_report_search_filters_rows_and_shows_bucket_pill(page, push):
keys = [c["key"] for c in page.evaluate("REPORT_COLUMNS")]
assert "status" in keys, "the report needs a status column for the bucket pill"
cases = [("alpha", "Posted", "success", "green"), ("bravo", "Needs review", "attention", "amber"),
("charlie", "Error", "failed", "red")]
rows = [{**{k: f"{k}-{name}" for k in keys}, "status": status, "bucket": bucket}
for name, status, bucket, _ in cases]
open_tab(page, "Report")
push("report_data", {"rows": rows})
body = page.get_by_role("table").locator("tbody").get_by_role("row")
expect(body).to_have_count(3)
search = page.get_by_role("searchbox", name="Search report")
for name, status, _, colour in cases:
search.fill(name)
expect(body).to_have_count(1)
pill = body.get_by_text(status, exact=True)
expect(pill).to_be_visible()
assert colour_of(pill, "backgroundColor") == colour, status
search.fill("")
expect(body).to_have_count(3)
def is_dark(page):
"""Dark theme = light text on the page."""
rgb = re.findall(r"\d+", page.evaluate("getComputedStyle(document.body).color"))[:3]
return sum(int(x) for x in rgb) / 3 > 128
def test_dark_toggle_flips_theme(page, request):
before = is_dark(page)
assert before == (request.node.callspec.params["page"] == "dark") # apply_dark took effect
toggle = page.get_by_role("button", name="Dark mode", exact=True)
expect(toggle).to_have_attribute("aria-pressed", str(before).lower())
toggle.click()
assert is_dark(page) != before
expect(toggle).to_have_attribute("aria-pressed", str(not before).lower())
assert ["set_dark", not before] in page.evaluate("window.__calls") # host is asked to remember it
def expect_sample_dashboard(page):
legend = page.get_by_role("list", name="Batch status")
expect(legend).to_contain_text("·")
n = sum(int(c) for c in re.findall(r"· (\d+)", legend.inner_text()))
assert n > 0
kpi(page, "Total batches", n)
def test_no_bot_page_runs_on_sample_data(page):
"""No bot yet: the page answers its own data actions, so it works on day one."""
expect_sample_dashboard(page)
open_tab(page, "Report")
rows = page.get_by_role("table").locator("tbody").get_by_role("row")
expect(rows.first).not_to_have_text("No rows yet")
run = page.get_by_role("button", name="▶ Run", exact=True)
run.click()
expect(run).to_be_disabled()
expect(page.get_by_role("list", name="Steps").get_by_role("listitem").last).to_have_text(
re.compile(r"^\s*✓"), timeout=10000)
expect(run).to_be_enabled()
def test_plain_browser_first_open_shows_sample_data(browser, page_url):
pg = browser.new_page()
pg.goto(page_url)
expect_sample_dashboard(pg)
pg.close()
+45
View File
@@ -0,0 +1,45 @@
"""Custom palette: one brand hex in, light + dark accent tokens out, readable in both modes."""
import importlib.util
import subprocess
import sys
from pathlib import Path
PALETTE = Path(__file__).resolve().parents[1] / "rpa-dashboard-no-ai" / "templates" / "palette.py"
spec = importlib.util.spec_from_file_location("palette", PALETTE)
palette = importlib.util.module_from_spec(spec)
spec.loader.exec_module(palette)
# Fixed Tebra Sky base colours the derived accents sit on (from dashboard.html).
LIGHT_BG, DARK_BG = "#eaf6fb", "#182a38"
LIGHT_INK, DARK_INK = "#1c2b33", "#e6eef4"
LOG_ERROR = "#f68c82" # dimmest log text colour
def contrast(a, b):
def lum(h):
c = [int(h[i:i + 2], 16) / 255 for i in (1, 3, 5)]
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 test_derived_palette_is_readable_in_light_and_dark():
for brand in ["#3d9bd0", "#ffd400", "#0b1f4d", "#7a3cff", "#e4002b", "#00a651", "#ffffff", "#000000"]:
t = palette.derive(brand)
for mode, bg, ink in [("light", LIGHT_BG, LIGHT_INK), ("dark", DARK_BG, DARK_INK)]:
m = t[mode]
assert contrast("#ffffff", m["--accent"]) >= 3, (brand, mode, "button text")
assert contrast("#ffffff", m["--accent-2"]) >= 3, (brand, mode, "button text, light end")
assert contrast(m["--accent"], bg) >= 3, (brand, mode, "accent text")
assert contrast(ink, m["--accent-soft"]) >= 4.5, (brand, mode, "secondary button text")
assert contrast(LOG_ERROR, m["--accent-dark"]) >= 4.5, (brand, mode, "log text")
def test_brand_kept_when_it_already_passes():
assert palette.derive("#1f6fb2")["light"]["--accent"] == "#1f6fb2"
def test_cli_prints_both_token_blocks_and_contrast_table():
out = subprocess.run([sys.executable, str(PALETTE), "#7a3cff"], capture_output=True, text=True, check=True).stdout
assert "body{" in out and "body.dark{" in out and "PASS" in out and "FAIL" not in out
+24
View File
@@ -0,0 +1,24 @@
"""Python templates must stay valid Python before their placeholders are filled."""
import shutil
import subprocess
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[1]
HOOK_RULES = "E9,F63,F7,F82,F811" # same rule set as the PostToolUse lint hook
@pytest.mark.parametrize("template", ["rpa-dashboard-no-ai/templates/bridge.py"])
def test_template_passes_lint_hook_unfilled(template):
ruff = shutil.which("ruff") or pytest.skip("ruff not on PATH")
r = subprocess.run([ruff, "check", "--select", HOOK_RULES, "--no-cache", str(ROOT / template)],
capture_output=True, text=True)
assert r.returncode == 0, r.stdout
def test_tebra_sky_preset_is_the_template_default():
templates = ROOT / "rpa-dashboard-no-ai" / "templates"
themes = (templates / "themes.md").read_text(encoding="utf-8")
sky = themes.split("## 1. Tebra Sky")[1].split("```css\n")[1].split("```")[0]
assert sky in (templates / "dashboard.html").read_text(encoding="utf-8")