Merge integration/rpa-dashboard-skills (ticket 03) into ticket 02

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
VasanthandClaude Opus 5.5 committed 2026-10-16 14:30:09 +05:30
commit 1b6ae23573
15 files changed
+1213 -14

No files matched your search

+312
View File
@@ -0,0 +1,312 @@
<!DOCTYPE html>
<!--
Generic RPA dashboard (no AI). Derived from Tebra ERA (reference/tebra_dashboard.html).
One file: works inside PyQt6 QWebEngineView (QWebChannel bridge) OR served by any web
backend (falls back to polling GET /api/dashboard and /run/status).
Theme = the body / body.dark token blocks only (see themes.md).
-->
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Field Ops Portal</title>
<script src="qrc:///qtwebchannel/qwebchannel.js"></script>
<style>
/* ===== THEME TOKENS ===== */
/* kept from web/css/theme.css :root (accent-2, accent-dark, accent-soft-2 from palette.py "#0f766e": no project match) */
body{
--bg-top:#f8fafc; --bg-mid:#f8fafc; --bg-bottom:#f8fafc;
--glass:#ffffff; --glass-strong:#f1f5f9; --border:#e2e8f0;
--ink:#0f172a; --muted:#64748b; --accent-dark:#041e1c;
--accent:#0f766e; --accent-2:#338b84; --accent-soft:#ccfbf1; --accent-soft-2:#a4cbc8;
--ok:#15803d; --ok-bg:#e6f6ec; --warn:#b45309; --warn-bg:#fbf1dd; --bad:#b91c1c; --bad-bg:#fdecea;
--font:"Trebuchet MS","Segoe UI",sans-serif; --font-display:Georgia,"Times New Roman",serif;
--header-bg:#0f766e; --header-ink:#ffffff; --header-muted:#ccfbf1;
--primary-bg:#115e59;
--nav-active-bg:#0f766e;
--progress-bg:var(--ok); --blur:none;
}
/* kept from web/css/theme.css [data-theme="dark"] (accent-2, accent-dark, accent-soft-2 from palette.py) */
body.dark{
--bg-top:#0b1120; --bg-mid:#0b1120; --bg-bottom:#0b1120;
--glass:#111827; --glass-strong:#1f2937; --border:#334155;
--ink:#e2e8f0; --muted:#94a3b8; --accent-dark:#104541;
--accent:#0d9488; --accent-2:#348b85; --accent-soft:#134e4a; --accent-soft-2:#0c3230;
--ok:#22c55e; --ok-bg:rgba(31,157,87,.16); --warn:#f59e0b; --warn-bg:rgba(183,121,31,.16);
--bad:#ef4444; --bad-bg:rgba(192,57,43,.16);
--header-bg:#0d9488; --header-muted:#ccfbf1;
--primary-bg:#134e4a; --nav-active-bg:#0d9488;
}
/* ===== BASE ===== */
*{box-sizing:border-box;margin:0;padding:0}
html,body{height:100%;overflow:hidden}
body{font-family:var(--font);color:var(--ink);
background:linear-gradient(180deg,var(--bg-top) 0%,var(--bg-mid) 45%,var(--bg-bottom) 100%)}
.app{display:flex;height:100vh}
.hidden{display:none!important}
/* sidebar */
.sidebar{width:230px;min-width:230px;background:var(--glass);border-right:1px solid var(--border);backdrop-filter:var(--blur);display:flex;flex-direction:column}
.logo-area{padding:22px 20px;display:flex;align-items:center;gap:11px;border-bottom:1px solid var(--border)}
.logo-box{width:40px;height:40px;border-radius:12px;background:var(--glass-strong);display:flex;align-items:center;justify-content:center;border:1px solid var(--border)}
.logo-box img{width:28px;height:28px;object-fit:contain}
.logo-title{font-size:15px;font-weight:700}
.logo-sub{font-size:10px;color:var(--muted);margin-top:2px;letter-spacing:.5px;text-transform:uppercase;font-weight:600}
.nav{padding:16px 12px;flex:1}
.nav-item{border:0;background:none;width:100%;font:inherit;text-align:left;display:flex;align-items:center;gap:11px;padding:11px 13px;border-radius:10px;cursor:pointer;margin-bottom:3px;color:var(--muted);font-size:13.5px;font-weight:600;transition:all .15s;user-select:none}
.nav-item svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:2}
.nav-item:hover{background:var(--glass-strong);color:var(--ink)}
.nav-item.active{background:var(--nav-active-bg);color:#fff;box-shadow:0 4px 12px rgba(20,50,70,.22)}
.sidebar-foot{padding:14px;border-top:1px solid var(--border);display:flex;align-items:center;gap:10px}
.avatar{width:34px;height:34px;border-radius:50%;background:var(--nav-active-bg);display:flex;align-items:center;justify-content:center;font-size:12px;font-weight:700;color:#fff}
.u-name{font-size:13px;font-weight:700}.u-role{font-size:10.5px;color:var(--muted)}
/* main + topbar */
.main{flex:1;display:flex;flex-direction:column;overflow:hidden}
.topbar{background:var(--header-bg);color:var(--header-ink);border-bottom:1px solid var(--border);backdrop-filter:var(--blur);padding:20px 28px;display:flex;align-items:center;gap:14px;box-shadow:0 6px 18px rgba(20,50,70,.10)}
.tb-title{font-size:21px;font-weight:800}.tb-sub{font-size:12.5px;color:var(--header-muted);margin-top:3px}
.tb-right{margin-left:auto;display:flex;align-items:center;gap:12px}
.indicator{color:var(--ink);display:flex;align-items:center;gap:7px;background:var(--glass-strong);padding:7px 13px;border-radius:20px;border:1px solid var(--border);font-size:12px;font-weight:700}
.dot{width:8px;height:8px;border-radius:50%}
.dot.ready,.dot.done{background:var(--ok);box-shadow:0 0 7px var(--ok)}
.dot.running{background:var(--warn);box-shadow:0 0 7px var(--warn);animation:blink 1.2s infinite}
.dot.error{background:var(--bad);box-shadow:0 0 7px var(--bad)}
@keyframes blink{50%{opacity:.4}}
.mode-btn{color:var(--ink);width:38px;height:38px;border-radius:11px;border:1px solid var(--border);background:var(--glass-strong);cursor:pointer;font-size:16px}
.content{flex:1;overflow-y:auto;padding:26px 28px;border-top-left-radius:30px}
/* cards, buttons, pills */
.section-label{font-size:11px;text-transform:uppercase;letter-spacing:1px;color:var(--muted);font-weight:700;margin-bottom:14px}
.card{background:var(--glass);border:1px solid var(--border);border-radius:16px;padding:22px;margin-bottom:16px;backdrop-filter:var(--blur);box-shadow:0 10px 30px rgba(20,50,70,.08)}
.c-title{font-size:15px;font-weight:700;margin-bottom:14px}
.btn{padding:9px 15px;border-radius:11px;border:1px solid var(--accent-soft-2);background:linear-gradient(180deg,var(--accent-soft),var(--accent-soft-2));color:var(--ink);font-size:12.5px;font-weight:600;cursor:pointer;transition:all .18s}
.btn:hover{transform:translateY(-1px);box-shadow:0 4px 12px rgba(20,50,70,.2)}
.btn.primary{background:var(--primary-bg);color:#fff;border-color:var(--accent)}
.btn:disabled{opacity:.45;cursor:not-allowed;transform:none;box-shadow:none}
.pill{display:inline-block;padding:4px 11px;border-radius:20px;font-size:11px;font-weight:700;color:#fff}
.pill.success{background:var(--ok)}.pill.attention{background:var(--warn)}.pill.failed{background:var(--bad)}.pill.cleared{background:var(--muted)}
ul,ol{list-style:none}
.logo-title,.tb-title,.c-title,.kpi-val,.dh-num,.btn{font-family:var(--font-display)}
/* KPI */
.kpi-row{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin-bottom:16px}
.kpi{display:flex;align-items:center;gap:13px;padding:16px 18px;border-radius:16px;background:var(--glass-strong);border:1px solid var(--border);box-shadow:0 4px 14px rgba(20,50,70,.10)}
.kpi-ic{width:42px;height:42px;border-radius:12px;position:relative}
.kpi.total .kpi-ic{background:var(--accent)}
.kpi.done .kpi-ic{background:var(--ok)}.kpi.prog .kpi-ic{background:var(--warn)}.kpi.notdone .kpi-ic{background:var(--bad)}
.kpi.prog.live .kpi-ic::after{content:'';position:absolute;inset:-3px;border-radius:14px;border:2px solid var(--warn);animation:kpulse 1.6s ease-out infinite}
@keyframes kpulse{0%{transform:scale(.9);opacity:.6}100%{transform:scale(1.25);opacity:0}}
.kpi-val{font-size:26px;font-weight:800;line-height:1.1;font-variant-numeric:tabular-nums}
.kpi-lbl{font-size:11.5px;color:var(--muted);font-weight:600;margin-top:2px}
/* donut */
.dash-grid{display:grid;grid-template-columns:1fr 1fr;gap:14px}
.donut-wrap{display:flex;align-items:center;gap:26px;flex-wrap:wrap}
.donut{width:150px;height:150px;border-radius:50%;position:relative;background:conic-gradient(var(--border) 0 100%)}
.donut-hole{position:absolute;inset:22px;border-radius:50%;background:var(--bg-bottom);display:flex;flex-direction:column;align-items:center;justify-content:center}
.dh-num{font-size:26px;font-weight:800}.dh-lbl{font-size:10px;color:var(--muted);font-weight:700;text-transform:uppercase}
.dl-row{display:flex;align-items:center;gap:9px;font-size:12.5px;font-weight:600;margin-bottom:9px}
.dl-dot{width:10px;height:10px;border-radius:3px}
/* run panel */
.prog-head{display:flex;gap:12px;margin-bottom:8px;font-size:13px;font-weight:700}
.prog-head .pct{margin-left:auto;color:var(--accent)}
.prog-bg{height:8px;background:var(--glass-strong);border-radius:8px;overflow:hidden;margin-bottom:18px}
.prog-fill{height:100%;width:0;background:var(--progress-bg);transition:width .5s}
.step{display:flex;align-items:center;gap:12px;padding:10px 13px;border-radius:10px;margin-bottom:6px;font-size:13px;font-weight:600}
.step.done{background:var(--ok-bg);color:var(--ok)}.step.active{background:var(--warn-bg);color:var(--warn)}.step.pending{opacity:.5}
.step-dot{width:22px;height:22px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:11px;background:var(--glass-strong)}
.step.done .step-dot{background:var(--ok);color:#fff}.step.active .step-dot{background:var(--warn);color:#fff}
.log{background:var(--accent-dark);border-radius:12px;padding:12px 14px;max-height:220px;overflow-y:auto;font-family:Consolas,'Courier New',monospace;font-size:13px;line-height:1.85;color:#9fb6c4}
.log .info{color:#7fc4f0}.log .success{color:#5fe0a0}.log .warn{color:#f5c563}.log .error{color:#f68c82}
/* report table */
.toolbar{display:flex;gap:10px;align-items:center;margin-bottom:14px;flex-wrap:wrap}
.toolbar input{padding:8px 10px;border:1px solid var(--border);border-radius:10px;background:var(--glass-strong);color:var(--ink);font:inherit;font-size:12.5px}
.tbl-wrap{border:1px solid var(--border);border-radius:14px;overflow:auto;max-height:60vh;background:var(--glass)}
table{border-collapse:collapse;width:100%;font-size:12px;white-space:nowrap}
th{background:var(--glass-strong);text-align:left;padding:11px 14px;position:sticky;top:0;font-weight:700}
td{padding:10px 14px;border-bottom:1px solid var(--border)}
.empty{text-align:center;padding:40px;color:var(--muted);font-size:13px}
@media (max-width:820px){.sidebar{width:66px;min-width:66px}.logo-title,.logo-sub,.nav-item span,.u-name,.u-role{display:none}.kpi-row{grid-template-columns:1fr 1fr}.dash-grid{grid-template-columns:1fr}}
</style>
</head>
<body>
<div class="app">
<aside class="sidebar">
<div class="logo-area">
<div class="logo-box"><img src="" alt="" onerror="this.remove()"></div>
<div><div class="logo-title">Field Ops Portal</div><div class="logo-sub">Automation dashboard</div></div>
</div>
<nav class="nav">
<button class="nav-item active" data-tab="dashboard" title="Dashboard"><svg viewBox="0 0 24 24"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/></svg><span>Dashboard</span></button>
<button class="nav-item" data-tab="run" title="Run"><svg viewBox="0 0 24 24"><polygon points="6 4 20 12 6 20 6 4"/></svg><span>Run</span></button>
<button class="nav-item" data-tab="report" title="Report"><svg viewBox="0 0 24 24"><path d="M4 4h16v16H4z"/><path d="M8 9h8M8 13h8M8 17h5"/></svg><span>Report</span></button>
</nav>
<div class="sidebar-foot"><div class="avatar" id="avatar">U</div><div><div class="u-name" id="u-name">User</div><div class="u-role" id="u-role">Operator</div></div></div>
</aside>
<main class="main">
<header class="topbar">
<div><div class="tb-title" id="tb-title">Dashboard</div><div class="tb-sub" id="tb-sub">Overview of every batch</div></div>
<div class="tb-right">
<button class="btn primary" id="run-btn" onclick="startRun()">▶ Run</button>
<button class="mode-btn" id="mode-btn" title="Dark mode" aria-label="Dark mode" aria-pressed="false" onclick="toggleDark()">🌙</button>
<div class="indicator"><span class="dot ready" id="st-dot"></span><span id="st-text">System ready</span></div>
</div>
</header>
<section class="content">
<!-- DASHBOARD -->
<div id="tab-dashboard">
<div class="kpi-row">
<div class="kpi total" role="group" aria-label="Total batches"><div class="kpi-ic"></div><div><div class="kpi-val" id="k-total">0</div><div class="kpi-lbl">Total batches</div></div></div>
<div class="kpi done" role="group" aria-label="Completed"><div class="kpi-ic"></div><div><div class="kpi-val" id="k-done">0</div><div class="kpi-lbl">Completed</div></div></div>
<div class="kpi prog" id="kpi-prog" role="group" aria-label="In progress"><div class="kpi-ic"></div><div><div class="kpi-val" id="k-prog">0</div><div class="kpi-lbl">In progress</div></div></div>
<div class="kpi notdone" role="group" aria-label="Not completed"><div class="kpi-ic"></div><div><div class="kpi-val" id="k-fail">0</div><div class="kpi-lbl">Not completed</div></div></div>
</div>
<div class="dash-grid">
<div class="card"><div class="c-title">Batch status split</div>
<div class="donut-wrap"><div class="donut" id="donut" role="img" aria-label="Batch status: no batches yet"><div class="donut-hole"><div class="dh-num" id="dh-num">0</div><div class="dh-lbl">batches</div></div></div><ul id="legend" aria-label="Batch status"></ul></div>
</div>
<div class="card"><div class="c-title">Recent batches</div><div id="recent" class="empty">No batches yet</div></div>
</div>
</div>
<!-- RUN -->
<div id="tab-run" class="hidden">
<div class="card"><div class="c-title">Run automation</div>
<div class="prog-head"><span id="p-label">Waiting to start</span><span id="p-time"></span><span class="pct" id="p-pct">0%</span></div>
<div class="prog-bg"><div class="prog-fill" id="p-fill"></div></div>
<ol id="steps" aria-label="Steps"></ol>
<div class="log" id="log" role="log" aria-label="Run log"></div>
</div>
</div>
<!-- REPORT -->
<div id="tab-report" class="hidden">
<div class="card">
<div class="toolbar"><input id="r-search" type="search" aria-label="Search report" placeholder="Search…" oninput="renderReport()"><span style="margin-left:auto"></span><button class="btn primary" onclick="api('download_report')">⬇ Download Excel</button></div>
<div class="tbl-wrap"><table><thead id="r-head"></thead><tbody id="r-body"></tbody></table></div>
</div>
</div>
</section>
</main>
</div>
<script>
// ===== CONFIG (fill per project) =====
const STEPS = ["Initializing","Load jobs","Process jobs","Completed"];
const REPORT_COLUMNS = [{"key":"job","label":"Job"},{"key":"site","label":"Site"},{"key":"status","label":"Status"}];
const API_BASE = "/api"; // web mode only
const SAMPLE_DATA = true; // no bot yet: the page answers its own data actions. Set false once wired.
const TABS = {dashboard:["Dashboard","Overview of every batch"],run:["Run","Live progress and log"],report:["Report","Result of every processed item"]};
// ===== TRANSPORT: sample data, else Qt bridge, else HTTP polling =====
let bridge = null, reportRows = [];
function api(slot, ...args){
if(SAMPLE_DATA && SAMPLE[slot]) return SAMPLE[slot](...args);
if(bridge) return bridge[slot](...args);
return fetch(`${API_BASE}/${slot.replace(/_/g,'-')}`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(args)}).catch(()=>{});
}
window.__pyMsg = (json)=>{ const m=JSON.parse(json); handle(m.event,m.data); };
function startTransport(){
if(window.qt && window.qt.webChannelTransport){
new QWebChannel(qt.webChannelTransport, ch=>{ bridge=ch.objects.pyBridge; api('load_dashboard'); });
}else if(SAMPLE_DATA){
api('load_dashboard');
}else{
const poll = async()=>{
try{
const d = await (await fetch(`${API_BASE}/dashboard`)).json(); handle('dashboard_data',d);
const s = await (await fetch(`${API_BASE}/run/status`)).json();
(s.events||[]).forEach(e=>handle(e.event,e.data));
setTimeout(poll, s.running ? 2000 : 10000); // poll fast only while busy
}catch(e){ setTimeout(poll, 10000); }
};
poll();
}
}
// ===== SAMPLE DATA: delete this block and set SAMPLE_DATA=false once the bot is wired =====
const SAMPLE = {
load_dashboard(){ const day=n=>new Date(Date.now()-n*864e5).toISOString().slice(0,10);
handle('dashboard_data',{batches:[
{batch_id:1006,date:day(0),state:'inprogress',items_total:40,items_done:18},
{batch_id:1005,date:day(1),state:'completed',items_total:35,items_done:35},
{batch_id:1004,date:day(2),state:'completed',items_total:52,items_done:52},
{batch_id:1003,date:day(3),state:'notcompleted',items_total:20,items_done:7},
{batch_id:1002,date:day(4),state:'completed',items_total:28,items_done:28},
{batch_id:1001,date:day(6),state:'cleared',items_total:12,items_done:0}]}); },
load_latest_report(){
handle('report_data',{rows:[['Processed successfully','success'],['Posted','success'],['Needs review','attention'],['Error','failed']]
.map(([status,bucket],i)=>({...Object.fromEntries(REPORT_COLUMNS.map(c=>[c.key,`${c.label} ${i+1}`])),status,bucket}))}); },
start_automation(){ let i=0; handle('status',{state:'running',msg:'Running (sample)'});
const t=setInterval(()=>{
if(i<STEPS.length){ handle('step',{label:STEPS[i],pct:Math.round((i+1)/STEPS.length*100)}); handle('log',{msg:`Sample step: ${STEPS[i++]}`,type:'info'}); }
else{ clearInterval(t); handle('log',{msg:'Sample run finished.',type:'success'}); handle('status',{state:'done',msg:'Completed'}); }
},400); },
};
// ===== EVENT DISPATCH (see contract.md) =====
function handle(event, d){
switch(event){
case 'status': setStatus(d.state, d.msg); break;
case 'step': setStep(d.label, d.pct); break;
case 'log': addLog(d.msg, d.type); break;
case 'dashboard_data': renderDashboard(d.batches||[]); break;
case 'report_data': reportRows = d.rows||[]; renderReport(); break;
case 'set_user': setUser(d); break;
case 'apply_dark': setDark(!!d.on); break;
}
}
// ===== UI =====
document.querySelectorAll('.nav-item').forEach(n=>n.onclick=()=>showTab(n.dataset.tab));
function showTab(t){
document.querySelectorAll('.nav-item').forEach(n=>n.classList.toggle('active',n.dataset.tab===t));
Object.keys(TABS).forEach(k=>document.getElementById('tab-'+k).classList.toggle('hidden',k!==t));
tbTitle.textContent=TABS[t][0]; tbSub.textContent=TABS[t][1];
if(t==='dashboard') api('load_dashboard');
if(t==='report') api('load_latest_report');
}
const $=id=>document.getElementById(id), tbTitle=$('tb-title'), tbSub=$('tb-sub');
function esc(s){return String(s??'').replace(/[&<>"]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));}
function setDark(on){ document.body.classList.toggle('dark',on); $('mode-btn').setAttribute('aria-pressed',on); $('mode-btn').textContent=on?'☀':'🌙'; }
function toggleDark(){ const on=!document.body.classList.contains('dark'); setDark(on); api('set_dark',on); }
function setUser(u){ $('u-name').textContent=u.username; $('u-role').textContent=u.role; $('avatar').textContent=(u.username||'U')[0].toUpperCase(); }
function setStatus(state,msg){ $('st-dot').className='dot '+state; $('st-text').textContent=msg||state; $('run-btn').disabled=state==='running'; }
function startRun(){ showTab('run'); $('log').innerHTML=''; setStep(STEPS[0],0); api('start_automation'); }
function renderSteps(active=-1){
$('steps').innerHTML=STEPS.map((s,i)=>{const c=i<active?'done':i===active?'active':'pending';
return `<li class="step ${c}"${c==='active'?' aria-current="step"':''}><span class="step-dot">${c==='done'?'✓':i+1}</span>${esc(s)}</li>`}).join('');
}
function setStep(label,pct){ $('p-label').textContent=label; $('p-pct').textContent=pct+'%'; $('p-fill').style.width=pct+'%';
renderSteps(pct>=100?STEPS.length:STEPS.indexOf(label)); }
function addLog(msg,type='info'){ const l=$('log'); l.insertAdjacentHTML('beforeend',`<div class="${type}">${esc(msg)}</div>`); l.scrollTop=l.scrollHeight; }
function countUp(el,to){ const from=+el.textContent||0,t0=performance.now();
const f=t=>{const p=Math.min((t-t0)/500,1); el.textContent=Math.round(from+(to-from)*p); if(p<1)requestAnimationFrame(f)}; requestAnimationFrame(f); }
// batch state -> [label, colour token, pill class]
const STATES={completed:['Completed','var(--ok)','success'],inprogress:['In progress','var(--warn)','attention'],
notcompleted:['Not completed','var(--bad)','failed'],cleared:['Cleared','var(--muted)','cleared']};
function renderDashboard(b){
const c={}; Object.keys(STATES).forEach(k=>c[k]=b.filter(x=>x.state===k).length);
countUp($('k-total'),b.length); countUp($('k-done'),c.completed); countUp($('k-prog'),c.inprogress); countUp($('k-fail'),c.notcompleted);
$('kpi-prog').classList.toggle('live',c.inprogress>0); $('dh-num').textContent=b.length;
const tot=Object.values(c).reduce((a,x)=>a+x,0); let acc=0; const stops=[];
Object.keys(c).forEach(k=>{ if(!c[k])return; const a=acc/tot*100; acc+=c[k]; stops.push(`${STATES[k][1]} ${a}% ${acc/tot*100}%`); });
$('donut').style.background = tot?`conic-gradient(${stops.join(',')})`:'conic-gradient(var(--border) 0 100%)';
$('donut').setAttribute('aria-label','Batch status: '+Object.keys(c).map(k=>`${STATES[k][0]} ${c[k]}`).join(', '));
$('legend').innerHTML=Object.keys(c).map(k=>`<li class="dl-row"><span class="dl-dot" style="background:${STATES[k][1]}"></span>${STATES[k][0]} · ${c[k]}</li>`).join('');
$('recent').className = b.length?'':'empty';
$('recent').innerHTML = b.length? b.slice(0,6).map(x=>{const s=STATES[x.state]||[esc(x.state),'','attention'];
return `<div class="dl-row">#${esc(x.batch_id)} · ${esc(x.date)} <span class="pill ${s[2]}" style="margin-left:auto">${s[0]}</span></div>`}).join('') : 'No batches yet';
}
function renderReport(){
const q=$('r-search').value.toLowerCase();
$('r-head').innerHTML='<tr>'+REPORT_COLUMNS.map(c=>`<th>${esc(c.label)}</th>`).join('')+'</tr>';
const rows=reportRows.filter(r=>!q||Object.values(r).join(' ').toLowerCase().includes(q));
$('r-body').innerHTML = rows.length? rows.map(r=>'<tr>'+REPORT_COLUMNS.map(c=>c.key==='status'
?`<td><span class="pill ${esc(r.bucket||'attention')}">${esc(r.status)}</span></td>`:`<td>${esc(r[c.key])}</td>`).join('')+'</tr>').join('')
: `<tr><td class="empty" colspan="${REPORT_COLUMNS.length}">No rows yet</td></tr>`;
}
renderSteps();
startTransport();
</script>
</body>
</html>
+29
View File
@@ -0,0 +1,29 @@
{
"fixture": "c-keep",
"passed": true,
"checks": {
"frontend reported correctly": true,
"asks /keep|restyle/": true,
"asks /section/": true,
"never asks /^(?!.*(keep|restyle)).*(colou?r|Tebra Sky|Ember)/": true,
"never asks /desktop/": true,
"existing files unchanged": true,
"exactly one page built": true,
"no placeholders left": true,
"page has --accent:#0f766e": true,
"page has Trebuchet MS": true,
"page check passes": true
},
"page_check": [
"22 passed in 18.02s"
],
"files": [
"README.md",
"web\\css\\app.css",
"web\\css\\theme.css",
"web\\dashboard.html",
"web\\index.html",
"web\\js\\app.js"
],
"reply": "Dashboard ready \u2014 **`web/dashboard.html`** (web path, plain HTML, sample data).\n\n| Item | Status |\n|---|---|\n| Look | **Kept** \u2014 the colours and fonts come from `web/css/theme.css` (teal brand, Trebuchet/Georgia), for both light and dark |\n| Existing files | **Not touched** (index.html, theme.css, app.css, app.js are unchanged) |\n| Sections | Sidebar, top bar (dark mode toggle + status dot), KPI cards, status donut, Run panel (steps + log), Report table (search + Excel). No optional sections. |\n| Data | `SAMPLE_DATA = true` \u2014 there is no bot yet. Steps are `Initializing \u2192 Load jobs \u2192 Process jobs \u2192 Completed`. Report columns are Job / Site / Status. |\n\n**Checks (headless Playwright):** 6 KPI batches, 4 legend items, 4/4 steps done, 5 log lines, 4/4 report rows with pills. No `{{` left and no console errors. I checked light, dark and 800px wide.\n\n- **Small tweak:** the Run button was the same teal as the top bar, so it was hard to see. I set `--primary-bg` to the project's own `--color-brand-strong` (#115e59).\n- **Palette fill-ins:** the project has no token for `--accent-2`, `--accent-dark` or `--accent-soft-2`, so those come from `palette.py`. All contrast checks pass.\n- **When the bot exists:** it must send the `status`, `step`, `log`, `dashboard_data` and `report_data` events. Set `SAMPLE_DATA=false` and serve `/api/dashboard` and `/api/run/status`.\n- **Navigation:** add a link to `dashboard.html` from your nav in `index.html`. I didn't edit that file.\n\nFRONTEND: web/ \u2014 plain HTML (web), tokens in web/css/theme.css (:root + [data-theme=\"dark\"]), brand #0f766e, fonts \"Trebuchet MS\"/Georgia\nQUESTIONS: Keep its look, or restyle it? | Which optional sections should I add: checkpoint tracker, admin/roles screen, login page, or none?"
}
@@ -0,0 +1,309 @@
<!DOCTYPE html>
<!--
Generic RPA dashboard (no AI). Derived from Tebra ERA (reference/tebra_dashboard.html).
One file: works inside PyQt6 QWebEngineView (QWebChannel bridge) OR served by any web
backend (falls back to polling GET /api/dashboard and /run/status).
Theme = the body / body.dark token blocks only (see themes.md).
-->
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Field Ops Portal</title>
<script src="qrc:///qtwebchannel/qwebchannel.js"></script>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@400;500;600;700&family=Sora:wght@600;700;800&display=swap">
<style>
/* ===== THEME TOKENS: Superbill Ember (see themes.md) ===== */
body{
--bg-top:#f4f3f7; --bg-mid:#f4f3f7; --bg-bottom:#f4f3f7;
--glass:#ffffff; --glass-strong:#f7f6fa; --border:#e2dfe9;
--ink:#18151f; --muted:#66607a; --accent-dark:#18151f;
--accent:#ff3049; --accent-2:#f26b21; --accent-soft:#f7f6fa; --accent-soft-2:#e2dfe9;
--ok:#1f9d57; --ok-bg:#e6f6ec; --warn:#b7791f; --warn-bg:#fbf1dd; --bad:#c0392b; --bad-bg:#fdecea;
--font:'IBM Plex Sans','Segoe UI',-apple-system,sans-serif; --font-display:'Sora','Segoe UI',sans-serif;
--header-bg:linear-gradient(100deg,#ff3049,#f26b21); --header-ink:#ffffff; --header-muted:rgba(255,255,255,.85);
--primary-bg:linear-gradient(115deg,#ff3049,#f26b21);
--nav-active-bg:linear-gradient(100deg,#ff3049,#f26b21);
--progress-bg:linear-gradient(90deg,#ff3049,#f26b21); --blur:none;
}
body.dark{
--bg-top:#0a0912; --bg-mid:#0a0912; --bg-bottom:#0a0912;
--glass:#151220; --glass-strong:#1b1727; --border:#2a2438;
--ink:#f3f1f8; --muted:#8d87a0; --accent-dark:#100e1a;
--accent-soft:#1b1727; --accent-soft-2:#2a2438;
--ok:#2fbf72; --ok-bg:rgba(31,157,87,.16); --warn:#d9a13c; --warn-bg:rgba(183,121,31,.16);
--bad:#e05a4b; --bad-bg:rgba(192,57,43,.16);
}
/* ===== BASE ===== */
*{box-sizing:border-box;margin:0;padding:0}
html,body{height:100%;overflow:hidden}
body{font-family:var(--font);color:var(--ink);
background:linear-gradient(180deg,var(--bg-top) 0%,var(--bg-mid) 45%,var(--bg-bottom) 100%)}
.app{display:flex;height:100vh}
.hidden{display:none!important}
/* sidebar */
.sidebar{width:230px;min-width:230px;background:var(--glass);border-right:1px solid var(--border);backdrop-filter:var(--blur);display:flex;flex-direction:column}
.logo-area{padding:22px 20px;display:flex;align-items:center;gap:11px;border-bottom:1px solid var(--border)}
.logo-box{width:40px;height:40px;border-radius:12px;background:var(--glass-strong);display:flex;align-items:center;justify-content:center;border:1px solid var(--border)}
.logo-box img{width:28px;height:28px;object-fit:contain}
.logo-title{font-size:15px;font-weight:700}
.logo-sub{font-size:10px;color:var(--muted);margin-top:2px;letter-spacing:.5px;text-transform:uppercase;font-weight:600}
.nav{padding:16px 12px;flex:1}
.nav-item{border:0;background:none;width:100%;font:inherit;text-align:left;display:flex;align-items:center;gap:11px;padding:11px 13px;border-radius:10px;cursor:pointer;margin-bottom:3px;color:var(--muted);font-size:13.5px;font-weight:600;transition:all .15s;user-select:none}
.nav-item svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:2}
.nav-item:hover{background:var(--glass-strong);color:var(--ink)}
.nav-item.active{background:var(--nav-active-bg);color:#fff;box-shadow:0 4px 12px rgba(20,50,70,.22)}
.sidebar-foot{padding:14px;border-top:1px solid var(--border);display:flex;align-items:center;gap:10px}
.avatar{width:34px;height:34px;border-radius:50%;background:var(--nav-active-bg);display:flex;align-items:center;justify-content:center;font-size:12px;font-weight:700;color:#fff}
.u-name{font-size:13px;font-weight:700}.u-role{font-size:10.5px;color:var(--muted)}
/* main + topbar */
.main{flex:1;display:flex;flex-direction:column;overflow:hidden}
.topbar{background:var(--header-bg);color:var(--header-ink);border-bottom:1px solid var(--border);backdrop-filter:var(--blur);padding:20px 28px;display:flex;align-items:center;gap:14px;box-shadow:0 6px 18px rgba(20,50,70,.10)}
.tb-title{font-size:21px;font-weight:800}.tb-sub{font-size:12.5px;color:var(--header-muted);margin-top:3px}
.tb-right{margin-left:auto;display:flex;align-items:center;gap:12px}
.indicator{color:var(--ink);display:flex;align-items:center;gap:7px;background:var(--glass-strong);padding:7px 13px;border-radius:20px;border:1px solid var(--border);font-size:12px;font-weight:700}
.dot{width:8px;height:8px;border-radius:50%}
.dot.ready,.dot.done{background:var(--ok);box-shadow:0 0 7px var(--ok)}
.dot.running{background:var(--warn);box-shadow:0 0 7px var(--warn);animation:blink 1.2s infinite}
.dot.error{background:var(--bad);box-shadow:0 0 7px var(--bad)}
@keyframes blink{50%{opacity:.4}}
.mode-btn{color:var(--ink);width:38px;height:38px;border-radius:11px;border:1px solid var(--border);background:var(--glass-strong);cursor:pointer;font-size:16px}
.content{flex:1;overflow-y:auto;padding:26px 28px;border-top-left-radius:30px}
/* cards, buttons, pills */
.section-label{font-size:11px;text-transform:uppercase;letter-spacing:1px;color:var(--muted);font-weight:700;margin-bottom:14px}
.card{background:var(--glass);border:1px solid var(--border);border-radius:16px;padding:22px;margin-bottom:16px;backdrop-filter:var(--blur);box-shadow:0 10px 30px rgba(20,50,70,.08)}
.c-title{font-size:15px;font-weight:700;margin-bottom:14px}
.btn{padding:9px 15px;border-radius:11px;border:1px solid var(--accent-soft-2);background:linear-gradient(180deg,var(--accent-soft),var(--accent-soft-2));color:var(--ink);font-size:12.5px;font-weight:600;cursor:pointer;transition:all .18s}
.btn:hover{transform:translateY(-1px);box-shadow:0 4px 12px rgba(20,50,70,.2)}
.btn.primary{background:var(--primary-bg);color:#fff;border-color:var(--accent)}
.btn:disabled{opacity:.45;cursor:not-allowed;transform:none;box-shadow:none}
.pill{display:inline-block;padding:4px 11px;border-radius:20px;font-size:11px;font-weight:700;color:#fff}
.pill.success{background:var(--ok)}.pill.attention{background:var(--warn)}.pill.failed{background:var(--bad)}.pill.cleared{background:var(--muted)}
ul,ol{list-style:none}
.logo-title,.tb-title,.c-title,.kpi-val,.dh-num,.btn{font-family:var(--font-display)}
/* KPI */
.kpi-row{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin-bottom:16px}
.kpi{display:flex;align-items:center;gap:13px;padding:16px 18px;border-radius:16px;background:var(--glass-strong);border:1px solid var(--border);box-shadow:0 4px 14px rgba(20,50,70,.10)}
.kpi-ic{width:42px;height:42px;border-radius:12px;position:relative}
.kpi.total .kpi-ic{background:var(--accent)}
.kpi.done .kpi-ic{background:var(--ok)}.kpi.prog .kpi-ic{background:var(--warn)}.kpi.notdone .kpi-ic{background:var(--bad)}
.kpi.prog.live .kpi-ic::after{content:'';position:absolute;inset:-3px;border-radius:14px;border:2px solid var(--warn);animation:kpulse 1.6s ease-out infinite}
@keyframes kpulse{0%{transform:scale(.9);opacity:.6}100%{transform:scale(1.25);opacity:0}}
.kpi-val{font-size:26px;font-weight:800;line-height:1.1;font-variant-numeric:tabular-nums}
.kpi-lbl{font-size:11.5px;color:var(--muted);font-weight:600;margin-top:2px}
/* donut */
.dash-grid{display:grid;grid-template-columns:1fr 1fr;gap:14px}
.donut-wrap{display:flex;align-items:center;gap:26px;flex-wrap:wrap}
.donut{width:150px;height:150px;border-radius:50%;position:relative;background:conic-gradient(var(--border) 0 100%)}
.donut-hole{position:absolute;inset:22px;border-radius:50%;background:var(--bg-bottom);display:flex;flex-direction:column;align-items:center;justify-content:center}
.dh-num{font-size:26px;font-weight:800}.dh-lbl{font-size:10px;color:var(--muted);font-weight:700;text-transform:uppercase}
.dl-row{display:flex;align-items:center;gap:9px;font-size:12.5px;font-weight:600;margin-bottom:9px}
.dl-dot{width:10px;height:10px;border-radius:3px}
/* run panel */
.prog-head{display:flex;gap:12px;margin-bottom:8px;font-size:13px;font-weight:700}
.prog-head .pct{margin-left:auto;color:var(--accent)}
.prog-bg{height:8px;background:var(--glass-strong);border-radius:8px;overflow:hidden;margin-bottom:18px}
.prog-fill{height:100%;width:0;background:var(--progress-bg);transition:width .5s}
.step{display:flex;align-items:center;gap:12px;padding:10px 13px;border-radius:10px;margin-bottom:6px;font-size:13px;font-weight:600}
.step.done{background:var(--ok-bg);color:var(--ok)}.step.active{background:var(--warn-bg);color:var(--warn)}.step.pending{opacity:.5}
.step-dot{width:22px;height:22px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:11px;background:var(--glass-strong)}
.step.done .step-dot{background:var(--ok);color:#fff}.step.active .step-dot{background:var(--warn);color:#fff}
.log{background:var(--accent-dark);border-radius:12px;padding:12px 14px;max-height:220px;overflow-y:auto;font-family:Consolas,'Courier New',monospace;font-size:13px;line-height:1.85;color:#9fb6c4}
.log .info{color:#7fc4f0}.log .success{color:#5fe0a0}.log .warn{color:#f5c563}.log .error{color:#f68c82}
/* report table */
.toolbar{display:flex;gap:10px;align-items:center;margin-bottom:14px;flex-wrap:wrap}
.toolbar input{padding:8px 10px;border:1px solid var(--border);border-radius:10px;background:var(--glass-strong);color:var(--ink);font:inherit;font-size:12.5px}
.tbl-wrap{border:1px solid var(--border);border-radius:14px;overflow:auto;max-height:60vh;background:var(--glass)}
table{border-collapse:collapse;width:100%;font-size:12px;white-space:nowrap}
th{background:var(--glass-strong);text-align:left;padding:11px 14px;position:sticky;top:0;font-weight:700}
td{padding:10px 14px;border-bottom:1px solid var(--border)}
.empty{text-align:center;padding:40px;color:var(--muted);font-size:13px}
@media (max-width:820px){.sidebar{width:66px;min-width:66px}.logo-title,.logo-sub,.nav-item span,.u-name,.u-role{display:none}.kpi-row{grid-template-columns:1fr 1fr}.dash-grid{grid-template-columns:1fr}}
</style>
</head>
<body>
<div class="app">
<aside class="sidebar">
<div class="logo-area">
<div class="logo-box"><img src="" alt="" onerror="this.remove()"></div>
<div><div class="logo-title">Field Ops Portal</div><div class="logo-sub">Job automation</div></div>
</div>
<nav class="nav">
<button class="nav-item active" data-tab="dashboard" title="Dashboard"><svg viewBox="0 0 24 24"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/></svg><span>Dashboard</span></button>
<button class="nav-item" data-tab="run" title="Run"><svg viewBox="0 0 24 24"><polygon points="6 4 20 12 6 20 6 4"/></svg><span>Run</span></button>
<button class="nav-item" data-tab="report" title="Report"><svg viewBox="0 0 24 24"><path d="M4 4h16v16H4z"/><path d="M8 9h8M8 13h8M8 17h5"/></svg><span>Report</span></button>
</nav>
<div class="sidebar-foot"><div class="avatar" id="avatar">U</div><div><div class="u-name" id="u-name">User</div><div class="u-role" id="u-role">Operator</div></div></div>
</aside>
<main class="main">
<header class="topbar">
<div><div class="tb-title" id="tb-title">Dashboard</div><div class="tb-sub" id="tb-sub">Overview of every batch</div></div>
<div class="tb-right">
<button class="btn primary" id="run-btn" onclick="startRun()">▶ Run</button>
<button class="mode-btn" id="mode-btn" title="Dark mode" aria-label="Dark mode" aria-pressed="false" onclick="toggleDark()">🌙</button>
<div class="indicator"><span class="dot ready" id="st-dot"></span><span id="st-text">System ready</span></div>
</div>
</header>
<section class="content">
<!-- DASHBOARD -->
<div id="tab-dashboard">
<div class="kpi-row">
<div class="kpi total" role="group" aria-label="Total batches"><div class="kpi-ic"></div><div><div class="kpi-val" id="k-total">0</div><div class="kpi-lbl">Total batches</div></div></div>
<div class="kpi done" role="group" aria-label="Completed"><div class="kpi-ic"></div><div><div class="kpi-val" id="k-done">0</div><div class="kpi-lbl">Completed</div></div></div>
<div class="kpi prog" id="kpi-prog" role="group" aria-label="In progress"><div class="kpi-ic"></div><div><div class="kpi-val" id="k-prog">0</div><div class="kpi-lbl">In progress</div></div></div>
<div class="kpi notdone" role="group" aria-label="Not completed"><div class="kpi-ic"></div><div><div class="kpi-val" id="k-fail">0</div><div class="kpi-lbl">Not completed</div></div></div>
</div>
<div class="dash-grid">
<div class="card"><div class="c-title">Batch status split</div>
<div class="donut-wrap"><div class="donut" id="donut" role="img" aria-label="Batch status: no batches yet"><div class="donut-hole"><div class="dh-num" id="dh-num">0</div><div class="dh-lbl">batches</div></div></div><ul id="legend" aria-label="Batch status"></ul></div>
</div>
<div class="card"><div class="c-title">Recent batches</div><div id="recent" class="empty">No batches yet</div></div>
</div>
</div>
<!-- RUN -->
<div id="tab-run" class="hidden">
<div class="card"><div class="c-title">Run automation</div>
<div class="prog-head"><span id="p-label">Waiting to start</span><span id="p-time"></span><span class="pct" id="p-pct">0%</span></div>
<div class="prog-bg"><div class="prog-fill" id="p-fill"></div></div>
<ol id="steps" aria-label="Steps"></ol>
<div class="log" id="log" role="log" aria-label="Run log"></div>
</div>
</div>
<!-- REPORT -->
<div id="tab-report" class="hidden">
<div class="card">
<div class="toolbar"><input id="r-search" type="search" aria-label="Search report" placeholder="Search…" oninput="renderReport()"><span style="margin-left:auto"></span><button class="btn primary" onclick="api('download_report')">⬇ Download Excel</button></div>
<div class="tbl-wrap"><table><thead id="r-head"></thead><tbody id="r-body"></tbody></table></div>
</div>
</div>
</section>
</main>
</div>
<script>
// ===== CONFIG (fill per project) =====
const STEPS = ["Initializing","Load jobs","Process jobs","Completed"];
const REPORT_COLUMNS = [{"key":"job","label":"Job"},{"key":"site","label":"Site"},{"key":"status","label":"Status"}];
const API_BASE = "/api"; // web mode only
const SAMPLE_DATA = true; // no bot yet: the page answers its own data actions. Set false once wired.
const TABS = {dashboard:["Dashboard","Overview of every batch"],run:["Run","Live progress and log"],report:["Report","Result of every processed item"]};
// ===== TRANSPORT: sample data, else Qt bridge, else HTTP polling =====
let bridge = null, reportRows = [];
function api(slot, ...args){
if(SAMPLE_DATA && SAMPLE[slot]) return SAMPLE[slot](...args);
if(bridge) return bridge[slot](...args);
return fetch(`${API_BASE}/${slot.replace(/_/g,'-')}`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(args)}).catch(()=>{});
}
window.__pyMsg = (json)=>{ const m=JSON.parse(json); handle(m.event,m.data); };
function startTransport(){
if(window.qt && window.qt.webChannelTransport){
new QWebChannel(qt.webChannelTransport, ch=>{ bridge=ch.objects.pyBridge; api('load_dashboard'); });
}else if(SAMPLE_DATA){
api('load_dashboard');
}else{
const poll = async()=>{
try{
const d = await (await fetch(`${API_BASE}/dashboard`)).json(); handle('dashboard_data',d);
const s = await (await fetch(`${API_BASE}/run/status`)).json();
(s.events||[]).forEach(e=>handle(e.event,e.data));
setTimeout(poll, s.running ? 2000 : 10000); // poll fast only while busy
}catch(e){ setTimeout(poll, 10000); }
};
poll();
}
}
// ===== SAMPLE DATA: delete this block and set SAMPLE_DATA=false once the bot is wired =====
const SAMPLE = {
load_dashboard(){ const day=n=>new Date(Date.now()-n*864e5).toISOString().slice(0,10);
handle('dashboard_data',{batches:[
{batch_id:1006,date:day(0),state:'inprogress',items_total:40,items_done:18},
{batch_id:1005,date:day(1),state:'completed',items_total:35,items_done:35},
{batch_id:1004,date:day(2),state:'completed',items_total:52,items_done:52},
{batch_id:1003,date:day(3),state:'notcompleted',items_total:20,items_done:7},
{batch_id:1002,date:day(4),state:'completed',items_total:28,items_done:28},
{batch_id:1001,date:day(6),state:'cleared',items_total:12,items_done:0}]}); },
load_latest_report(){
handle('report_data',{rows:[['Processed successfully','success'],['Posted','success'],['Needs review','attention'],['Error','failed']]
.map(([status,bucket],i)=>({...Object.fromEntries(REPORT_COLUMNS.map(c=>[c.key,`${c.label} ${i+1}`])),status,bucket}))}); },
start_automation(){ let i=0; handle('status',{state:'running',msg:'Running (sample)'});
const t=setInterval(()=>{
if(i<STEPS.length){ handle('step',{label:STEPS[i],pct:Math.round((i+1)/STEPS.length*100)}); handle('log',{msg:`Sample step: ${STEPS[i++]}`,type:'info'}); }
else{ clearInterval(t); handle('log',{msg:'Sample run finished.',type:'success'}); handle('status',{state:'done',msg:'Completed'}); }
},400); },
};
// ===== EVENT DISPATCH (see contract.md) =====
function handle(event, d){
switch(event){
case 'status': setStatus(d.state, d.msg); break;
case 'step': setStep(d.label, d.pct); break;
case 'log': addLog(d.msg, d.type); break;
case 'dashboard_data': renderDashboard(d.batches||[]); break;
case 'report_data': reportRows = d.rows||[]; renderReport(); break;
case 'set_user': setUser(d); break;
case 'apply_dark': setDark(!!d.on); break;
}
}
// ===== UI =====
document.querySelectorAll('.nav-item').forEach(n=>n.onclick=()=>showTab(n.dataset.tab));
function showTab(t){
document.querySelectorAll('.nav-item').forEach(n=>n.classList.toggle('active',n.dataset.tab===t));
Object.keys(TABS).forEach(k=>document.getElementById('tab-'+k).classList.toggle('hidden',k!==t));
tbTitle.textContent=TABS[t][0]; tbSub.textContent=TABS[t][1];
if(t==='dashboard') api('load_dashboard');
if(t==='report') api('load_latest_report');
}
const $=id=>document.getElementById(id), tbTitle=$('tb-title'), tbSub=$('tb-sub');
function esc(s){return String(s??'').replace(/[&<>"]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));}
function setDark(on){ document.body.classList.toggle('dark',on); $('mode-btn').setAttribute('aria-pressed',on); $('mode-btn').textContent=on?'☀':'🌙'; }
function toggleDark(){ const on=!document.body.classList.contains('dark'); setDark(on); api('set_dark',on); }
function setUser(u){ $('u-name').textContent=u.username; $('u-role').textContent=u.role; $('avatar').textContent=(u.username||'U')[0].toUpperCase(); }
function setStatus(state,msg){ $('st-dot').className='dot '+state; $('st-text').textContent=msg||state; $('run-btn').disabled=state==='running'; }
function startRun(){ showTab('run'); $('log').innerHTML=''; setStep(STEPS[0],0); api('start_automation'); }
function renderSteps(active=-1){
$('steps').innerHTML=STEPS.map((s,i)=>{const c=i<active?'done':i===active?'active':'pending';
return `<li class="step ${c}"${c==='active'?' aria-current="step"':''}><span class="step-dot">${c==='done'?'✓':i+1}</span>${esc(s)}</li>`}).join('');
}
function setStep(label,pct){ $('p-label').textContent=label; $('p-pct').textContent=pct+'%'; $('p-fill').style.width=pct+'%';
renderSteps(pct>=100?STEPS.length:STEPS.indexOf(label)); }
function addLog(msg,type='info'){ const l=$('log'); l.insertAdjacentHTML('beforeend',`<div class="${type}">${esc(msg)}</div>`); l.scrollTop=l.scrollHeight; }
function countUp(el,to){ const from=+el.textContent||0,t0=performance.now();
const f=t=>{const p=Math.min((t-t0)/500,1); el.textContent=Math.round(from+(to-from)*p); if(p<1)requestAnimationFrame(f)}; requestAnimationFrame(f); }
// batch state -> [label, colour token, pill class]
const STATES={completed:['Completed','var(--ok)','success'],inprogress:['In progress','var(--warn)','attention'],
notcompleted:['Not completed','var(--bad)','failed'],cleared:['Cleared','var(--muted)','cleared']};
function renderDashboard(b){
const c={}; Object.keys(STATES).forEach(k=>c[k]=b.filter(x=>x.state===k).length);
countUp($('k-total'),b.length); countUp($('k-done'),c.completed); countUp($('k-prog'),c.inprogress); countUp($('k-fail'),c.notcompleted);
$('kpi-prog').classList.toggle('live',c.inprogress>0); $('dh-num').textContent=b.length;
const tot=Object.values(c).reduce((a,x)=>a+x,0); let acc=0; const stops=[];
Object.keys(c).forEach(k=>{ if(!c[k])return; const a=acc/tot*100; acc+=c[k]; stops.push(`${STATES[k][1]} ${a}% ${acc/tot*100}%`); });
$('donut').style.background = tot?`conic-gradient(${stops.join(',')})`:'conic-gradient(var(--border) 0 100%)';
$('donut').setAttribute('aria-label','Batch status: '+Object.keys(c).map(k=>`${STATES[k][0]} ${c[k]}`).join(', '));
$('legend').innerHTML=Object.keys(c).map(k=>`<li class="dl-row"><span class="dl-dot" style="background:${STATES[k][1]}"></span>${STATES[k][0]} · ${c[k]}</li>`).join('');
$('recent').className = b.length?'':'empty';
$('recent').innerHTML = b.length? b.slice(0,6).map(x=>{const s=STATES[x.state]||[esc(x.state),'','attention'];
return `<div class="dl-row">#${esc(x.batch_id)} · ${esc(x.date)} <span class="pill ${s[2]}" style="margin-left:auto">${s[0]}</span></div>`}).join('') : 'No batches yet';
}
function renderReport(){
const q=$('r-search').value.toLowerCase();
$('r-head').innerHTML='<tr>'+REPORT_COLUMNS.map(c=>`<th>${esc(c.label)}</th>`).join('')+'</tr>';
const rows=reportRows.filter(r=>!q||Object.values(r).join(' ').toLowerCase().includes(q));
$('r-body').innerHTML = rows.length? rows.map(r=>'<tr>'+REPORT_COLUMNS.map(c=>c.key==='status'
?`<td><span class="pill ${esc(r.bucket||'attention')}">${esc(r.status)}</span></td>`:`<td>${esc(r[c.key])}</td>`).join('')+'</tr>').join('')
: `<tr><td class="empty" colspan="${REPORT_COLUMNS.length}">No rows yet</td></tr>`;
}
renderSteps();
startTransport();
</script>
</body>
</html>
+29
View File
@@ -0,0 +1,29 @@
{
"fixture": "c-restyle",
"passed": true,
"checks": {
"frontend reported correctly": true,
"asks /keep|restyle/": true,
"asks /Tebra Sky|Ember/": true,
"asks /section/": true,
"never asks /desktop/": true,
"existing files: only token lines changed": true,
"existing tokens now use #ff3049": true,
"exactly one page built": true,
"no placeholders left": true,
"page has --accent:#ff3049": true,
"page check passes": true
},
"page_check": [
"22 passed in 23.29s"
],
"files": [
"README.md",
"web\\css\\app.css",
"web\\css\\theme.css",
"web\\dashboard.html",
"web\\index.html",
"web\\js\\app.js"
],
"reply": "Dashboard ready, Superbill Ember theme-la restyle pannirukken. Frontend `web/`-la irundhudhu (plain HTML, web). Andha site-la token lines and oru font link mattum dhaan maathinen.\n\n| Item | Result |\n|---|---|\n| Pudhu page | `web/dashboard.html` (sample data, `SAMPLE_DATA = true`) |\n| Sections | Shell, KPI, donut, run panel, report. Optional sections edhuvum illa; `OPTIONAL` marker-um illa |\n| Restyle | `web/css/theme.css` token values + `web/index.html`-la font link |\n| Check | `{{` 0 left \u00b7 KPI total 6 \u00b7 legend 4 \u00b7 steps 4/4, log 5 lines, status \"Completed\" \u00b7 report 4 rows / 4 pills \u00b7 light, dark, 800px screenshots paathen, ellam correct-ah irukku |\n\n**Existing files-la changed lines:**\n```\ntheme.css :root --color-brand #0f766e\u2192#ff3049 \u00b7 -strong #115e59\u2192#18151f \u00b7 -tint #ccfbf1\u2192#f7f6fa\n --color-bg \u2192#f4f3f7 \u00b7 -surface-alt \u2192#f7f6fa \u00b7 -border \u2192#e2dfe9 \u00b7 -text \u2192#18151f \u00b7 -text-muted \u2192#66607a\n -success \u2192#1f9d57 \u00b7 -warning \u2192#b7791f \u00b7 -danger \u2192#c0392b\n --font-sans \u2192'IBM Plex Sans',\u2026 \u00b7 --font-heading \u2192'Sora',\u2026 (--color-surface, --radius maarala)\ntheme.css [dark] brand \u2192#ff3049 \u00b7 -strong \u2192#100e1a \u00b7 -tint \u2192#1b1727 \u00b7 bg \u2192#0a0912 \u00b7 surface \u2192#151220\n surface-alt \u2192#1b1727 \u00b7 border \u2192#2a2438 \u00b7 text \u2192#f3f1f8 \u00b7 muted \u2192#8d87a0 \u00b7 ok/warn/bad \u2192#2fbf72/#d9a13c/#e05a4b\nindex.html + Google Fonts <link> (IBM Plex Sans + Sora), theme.css link-ku munnaadi\n```\n\nBot innum illa. Bot ready aana, backend-ah wire pannanum: `GET /api/dashboard`, `GET /api/run/status` (idhu `{running, events[]}` return pannanum), and `POST /api/<action>`. Bot anuppa vendiya events: `status`, `step`, `log`, `dashboard_data`, `report_data`. `index.html`-oda nav-la `dashboard.html`-ku link nee dhaan podanum; adha naan touch pannala.\n\n**Also noticed (fix pannala):**\n- `app.css`-la hard-coded `#ffffff` irukku: topbar text, links, `.button`, `.tag`. Token-only restyle-nala adhu maarala.\n- Portal dark mode-la \"Dark mode\" button text theriyala. Text-um background-um ore colour family (`brand-strong` on `brand-tint`). Idhu munnaadiye irundha issue; teal theme-la rendum `#134e4a` dhaan.\n- Dashboard-la file:// vazhiya open pannum bodhu dark toggle `set-dark`-ku POST anuppudhu, adhu console-la oru error kaattudhu. Page-ku edhuvum aagala; server vazhiya serve pannina indha error varaadhu.\n\nFRONTEND: web/ \u2014 plain HTML (web), tokens in web/css/theme.css (:root + [data-theme=\"dark\"]), brand #0f766e teal, fonts \"Trebuchet MS\" / Georgia\nQUESTIONS: Keep its look, or restyle it? | Which colours: Tebra Sky, Superbill Ember, or Custom (one brand hex)? | Which optional sections to add: checkpoint tracker, admin/roles screen, login page, or none?"
}
+59 -4
View File
@@ -9,6 +9,7 @@ prompt (`-p` can't answer questions). It then grades the reply, runs the Seam 1
check on the page it built, and writes tests/evals/results/<fixture>.json.
Exit code 0 means every check passed.
"""
import difflib
import importlib.util
import json
import os
@@ -45,7 +46,49 @@ FIXTURES = {
"never_asks": [r"\bkeep\b|restyle", r"desktop|web"],
"bot": True,
},
# (c) existing teal frontend in web/, run once per answer to keep-or-restyle.
"c-keep": {
"fixture": "c-web-existing-frontend",
"prompt": "Build me an RPA dashboard for this project. I can't answer questions while you "
"work, so here are my answers to anything the skill asks. Keep or restyle the "
"existing frontend: keep its look. Optional sections: none.",
"frontend": r"(?=.*\bweb\b)(?=.*html)(?=.*(#0f766e|--color-brand))",
"asks": [r"keep|restyle", r"section"],
"never_asks": [r"^(?!.*(keep|restyle)).*(colou?r|Tebra Sky|Ember)", r"desktop"],
"page_has": ["--accent:#0f766e", "Trebuchet MS"],
"existing": "unchanged",
},
"c-restyle": {
"fixture": "c-web-existing-frontend",
"prompt": "Build me an RPA dashboard for this project. I can't answer questions while you "
"work, so here are my answers to anything the skill asks. Keep or restyle the "
"existing frontend: restyle it. Colours: Superbill Ember. Optional sections: none.",
"frontend": r"(?=.*\bweb\b)(?=.*html)(?=.*(#0f766e|--color-brand))",
"asks": [r"keep|restyle", r"Tebra Sky|Ember", r"section"],
"never_asks": [r"desktop"],
"page_has": ["--accent:#ff3049"],
"existing": "#ff3049", # only token lines may change, and the theme now uses this colour
},
}
# A line a restyle may change: a custom property, a comment, a blank, or a web-font link.
TOKEN_LINE = r"^\s*(--[\w-]+\s*:.*|/\*.*\*/|<link[^>]*fonts\.googleapis\.com.*|@import.*fonts\.googleapis\.com.*)?\s*$"
def squash(s):
"""Ignore spacing and case when looking for a token in the built page."""
return re.sub(r"\s+", "", s).lower()
def changed_lines(src, project):
"""Lines removed or added in the fixture's own files."""
out = []
for f in src.rglob("*"):
if f.is_file():
new = project / f.relative_to(src)
after = new.read_text(encoding="utf-8").splitlines() if new.exists() else []
out += [x[1:] for x in difflib.unified_diff(f.read_text(encoding="utf-8").splitlines(), after, lineterm="", n=0)
if x[:1] in "+-" and x[:3] not in ("+++", "---")]
return out
def bot_checks(project, html, checks, replay):
@@ -73,7 +116,8 @@ 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)
src = ROOT / "tests" / "fixtures" / cfg.get("fixture", fixture)
shutil.copytree(src, project)
for skill in ROOT.glob("rpa-dashboard-*"):
shutil.copytree(skill, project / ".claude" / "skills" / skill.name)
@@ -91,12 +135,21 @@ def main(fixture):
return m[1] if m else ""
checks = {"frontend reported correctly": bool(re.search(cfg["frontend"], line("FRONTEND"), re.I))}
questions = line("QUESTIONS").split("|")
for pat in cfg["asks"]:
checks[f"asks /{pat}/"] = bool(re.search(pat, line("QUESTIONS"), re.I))
checks[f"asks /{pat}/"] = any(re.search(pat, q, re.I) for q in questions)
for pat in cfg["never_asks"]:
checks[f"never asks /{pat}/"] = not re.search(pat, line("QUESTIONS"), re.I)
checks[f"never asks /{pat}/"] = not any(re.search(pat, q, re.I) for q in questions)
if "existing" in cfg:
changed = changed_lines(src, project)
if cfg["existing"] == "unchanged":
checks["existing files unchanged"] = not changed
else:
checks["existing files: only token lines changed"] = bool(changed) and all(re.match(TOKEN_LINE, x) for x in changed)
checks[f"existing tokens now use {cfg['existing']}"] = any(cfg["existing"] in x.lower() for x in changed)
pages = [p for p in project.rglob("*.html") if ".claude" not in p.parts]
pages = [p for p in project.rglob("*.html")
if ".claude" not in p.parts and not (src / p.relative_to(project)).exists()] # new pages only
checks["exactly one page built"] = len(pages) == 1
results = ROOT / "tests" / "evals" / "results"
results.mkdir(exist_ok=True)
@@ -110,6 +163,8 @@ def main(fixture):
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)
for needle in cfg.get("page_has", []):
checks[f"page has {needle}"] = squash(needle) in squash(html)
env = {**os.environ, "PAGE_CHECK_HTML": str(pages[0])}
if cfg.get("bot"):
env.update(bot_checks(project, html, checks, results / f"{fixture}.run.jsonl"))