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:
1 parent
49f8f49b65
commit
7f2ddecffb
13 files changed
+865
-57
No files matched your search
@@ -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.
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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=>({'&':'&','<':'<','>':'>','"':'"'}[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>
|
||||
@@ -0,0 +1,100 @@
|
||||
"""Derive a custom dashboard palette from one brand hex, then check text contrast.
|
||||
|
||||
Usage: python palette.py "#7a3cff"
|
||||
|
||||
Prints the accent tokens for the body{} (light) and body.dark{} blocks of
|
||||
dashboard.html, which keeps the Tebra Sky base for everything else, then a
|
||||
contrast table for light and dark. A brand colour that is too light or too dark
|
||||
is nudged until text on it is readable. Exits 1 if any check still fails.
|
||||
"""
|
||||
import sys
|
||||
|
||||
WHITE, BLACK = "#ffffff", "#000000"
|
||||
# Tebra Sky base colours the accents sit on. Keep in step with dashboard.html.
|
||||
BASE = {"light": {"bg": "#eaf6fb", "ink": "#1c2b33"}, "dark": {"bg": "#182a38", "ink": "#e6eef4"}}
|
||||
LOG_TEXT = "#f68c82" # dimmest log line colour; the log box background is --accent-dark
|
||||
|
||||
|
||||
def rgb(h):
|
||||
return [int(h[i:i + 2], 16) for i in (1, 3, 5)]
|
||||
|
||||
|
||||
def mix(a, b, t):
|
||||
"""Blend hex colour a toward b by t (0..1)."""
|
||||
return "#" + "".join(f"{round(x + (y - x) * t):02x}" for x, y in zip(rgb(a), rgb(b)))
|
||||
|
||||
|
||||
def contrast(a, b):
|
||||
"""WCAG 2 contrast ratio between two hex colours."""
|
||||
def lum(h):
|
||||
c = [v / 255 for v in rgb(h)]
|
||||
c = [x / 12.92 if x <= 0.03928 else ((x + 0.055) / 1.055) ** 2.4 for x in c]
|
||||
return 0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2]
|
||||
hi, lo = sorted((lum(a), lum(b)), reverse=True)
|
||||
return (hi + 0.05) / (lo + 0.05)
|
||||
|
||||
|
||||
def checks(t):
|
||||
"""(mode, what, ratio, needed) for every text pair the accents affect."""
|
||||
rows = []
|
||||
for mode, m in t.items():
|
||||
bg, ink = BASE[mode]["bg"], BASE[mode]["ink"]
|
||||
rows += [(mode, "white button text on --accent", contrast(WHITE, m["--accent"]), 3),
|
||||
(mode, "white button text on --accent-2", contrast(WHITE, m["--accent-2"]), 3),
|
||||
(mode, "--accent text on the page", contrast(m["--accent"], bg), 3),
|
||||
(mode, "ink on --accent-soft buttons", contrast(ink, m["--accent-soft"]), 4.5),
|
||||
(mode, "log text on --accent-dark", contrast(LOG_TEXT, m["--accent-dark"]), 4.5)]
|
||||
return rows
|
||||
|
||||
|
||||
def nudge(colour, fix):
|
||||
"""Step colour toward whatever fix() returns (WHITE/BLACK) until it returns None."""
|
||||
for _ in range(60):
|
||||
toward = fix(colour)
|
||||
if toward is None:
|
||||
break
|
||||
colour = mix(colour, toward, 0.06)
|
||||
return colour
|
||||
|
||||
|
||||
def derive(brand):
|
||||
brand = brand.strip().lower()
|
||||
if len(brand) == 4: # #abc -> #aabbcc
|
||||
brand = "#" + "".join(ch * 2 for ch in brand[1:])
|
||||
out = {}
|
||||
for mode, lift in [("light", 0.15), ("dark", 0.10)]:
|
||||
bg, ink = BASE[mode]["bg"], BASE[mode]["ink"]
|
||||
dark_page = mode == "dark"
|
||||
|
||||
def fix_accent(c):
|
||||
if contrast(c, bg) < 3: # accent text must stand out from the page
|
||||
return WHITE if dark_page else BLACK
|
||||
if contrast(WHITE, mix(c, WHITE, lift)) < 3: # white text on the button
|
||||
return BLACK
|
||||
return None
|
||||
|
||||
a = nudge(brand, fix_accent)
|
||||
soft = mix(a, BLACK, 0.35) if dark_page else mix(a, WHITE, 0.82)
|
||||
out[mode] = {
|
||||
"--accent": a,
|
||||
"--accent-2": mix(a, WHITE, lift),
|
||||
"--accent-dark": nudge(mix(a, BLACK, 0.45 if dark_page else 0.75),
|
||||
lambda c: BLACK if contrast(LOG_TEXT, c) < 4.5 else None),
|
||||
"--accent-soft": nudge(soft, lambda c: None if contrast(ink, c) >= 4.5 else BLACK if dark_page else WHITE),
|
||||
"--accent-soft-2": mix(a, BLACK, 0.6) if dark_page else mix(a, WHITE, 0.62),
|
||||
}
|
||||
return out
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if len(sys.argv) != 2 or not sys.argv[1].startswith("#"):
|
||||
sys.exit(__doc__)
|
||||
t = derive(sys.argv[1])
|
||||
for mode, block in [("light", "body{"), ("dark", "body.dark{")]:
|
||||
print(f"/* {mode}: replace these tokens in the {block} block */")
|
||||
print(block + " " + "; ".join(f"{k}:{v}" for k, v in t[mode].items()) + "; }")
|
||||
print()
|
||||
rows = checks(t)
|
||||
for mode, what, ratio, need in rows:
|
||||
print(f"{mode:<6} {what:<34} {ratio:5.2f}:1 needs {need}:1 {'PASS' if ratio >= need else 'FAIL'}")
|
||||
sys.exit(0 if all(r >= n for *_, r, n in rows) else 1)
|
||||
@@ -0,0 +1,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.
|
||||
@@ -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.
|
||||
@@ -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]))
|
||||
Vendored
+4
@@ -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.
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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")
|
||||
Reference in new issue
Block a user