Optional sections: checkpoint tracker, admin/roles, login page
- dashboard.html: each section sits in OPTIONAL:<name> BEGIN/END blocks in the style, markup and script, with sample answers so a page with no bot shows it working. Core change: setUser records the role on <body>, so CSS hides admin-only parts from operators. - templates/sections.py keeps the chosen sections and deletes the rest, markers included; it stops on a broken marker. - templates/sections.md: what each section needs from the host (events, actions, desktop slot snippets, the role re-check, the login hand-off). - SKILL.md step 5 asks one multi-select question and runs sections.py. - Page check: checkpoint states, admin visibility by role and assign/remove, login theme and hand-off to auth or the stub. Cases skip on a generated page without the section; the template must carry all three. The harness signs in when the page has a login screen. - tests/test_sections.py: no sections leave no trace, and that page still passes the page check. - Eval runner: a-empty-sections variant (all three) and per-section checks. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
3eb3fdf00e
commit
9a5b95448d
10 files changed
+536
-10
No files matched your search
@@ -15,7 +15,8 @@ source projects.
|
||||
| `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. |
|
||||
| `templates/sections.py`, `templates/sections.md` | Keeps the chosen optional sections in the page and deletes the rest; what each section needs from the host. |
|
||||
| `templates/reference/` | Raw Tebra extracts, for reference only. The optional sections are already in `dashboard.html`. |
|
||||
|
||||
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.
|
||||
@@ -59,10 +60,20 @@ Ask only if there is no frontend. If there is one, use its stack.
|
||||
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.
|
||||
Ask which extras to add, as one multi-select question (choose any, or none):
|
||||
- **Checkpoint tracker**: numbered boxes on the Run tab that show each checkpoint as
|
||||
pending, in progress, done or stuck.
|
||||
- **Admin/roles screen**: an Admin tab, for admins only, to give people admin or
|
||||
operator access.
|
||||
- **Login page**: a sign-in screen in the dashboard's theme. It's UI only and hands
|
||||
off to the project's own auth.
|
||||
|
||||
Build only the chosen ones. In step 6, right after copying the page, run
|
||||
`python templates/sections.py <copied dashboard.html> <chosen: checkpoints admin login>`.
|
||||
It deletes the unchosen sections completely (markup, script and style), so nothing
|
||||
is left behind. Never cut the blocks by hand. Then follow `templates/sections.md` for
|
||||
each kept section: what to fill, which events the bot sends, and which host actions
|
||||
to answer.
|
||||
|
||||
## 6. Build
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ All updates use one envelope: `{"event": "<name>", "data": {...}}`.
|
||||
| `set_user` | `{username, emp_id, role}` |
|
||||
| `apply_dark` | `{on: bool}` |
|
||||
|
||||
Optional (only if the section was chosen): `cpstate {kind: pending|active|done|stuck, cp: int}`, `admin_data`, `batch_detail`.
|
||||
Optional sections (only if chosen) add `cpstate`, `admin_data` and `login_result`, and the actions `load_admin`, `assign_role`, `remove_role` and `login`. See `sections.md`.
|
||||
|
||||
## Page → bot/backend (Qt `@pyqtSlot` names / web `POST {API_BASE}/<slot-with-dashes>`)
|
||||
|
||||
|
||||
@@ -117,6 +117,32 @@ table{border-collapse:collapse;width:100%;font-size:12px;white-space:nowrap}
|
||||
th{background:var(--glass-strong);text-align:left;padding:11px 14px;position:sticky;top:0;font-weight:700}
|
||||
td{padding:10px 14px;border-bottom:1px solid var(--border)}
|
||||
.empty{text-align:center;padding:40px;color:var(--muted);font-size:13px}
|
||||
/* OPTIONAL:checkpoints BEGIN */
|
||||
.cp-track{display:grid;grid-template-columns:repeat(auto-fit,minmax(118px,1fr));gap:8px;margin-bottom:16px}
|
||||
.cp-box{display:flex;flex-direction:column;gap:4px;padding:10px 11px;border-radius:11px;border:1.5px solid var(--border);background:var(--glass-strong);color:var(--muted);font-size:11.5px;font-weight:700}
|
||||
.cp-name{color:var(--ink);font-size:12.5px}
|
||||
.cp-box.active{border-color:var(--warn);background:var(--warn-bg);color:var(--warn)}
|
||||
.cp-box.done{border-color:var(--ok);background:var(--ok-bg);color:var(--ok)}
|
||||
.cp-box.stuck{border-color:var(--bad);background:var(--bad-bg);color:var(--bad)}
|
||||
/* OPTIONAL:checkpoints END */
|
||||
/* OPTIONAL:admin BEGIN */
|
||||
body:not([data-role="admin" i]) .adm-only{display:none!important}
|
||||
.toolbar select{padding:8px 10px;border:1px solid var(--border);border-radius:10px;background:var(--glass-strong);color:var(--ink);font:inherit;font-size:12.5px}
|
||||
.toolbar select option{background:var(--bg-bottom);color:var(--ink)}
|
||||
.pill.adm-role{background:var(--muted);text-transform:capitalize}.pill.adm-role.admin{background:var(--accent)}
|
||||
.adm-note{font-size:12.5px;color:var(--muted)}
|
||||
/* OPTIONAL:admin END */
|
||||
/* OPTIONAL:login BEGIN */
|
||||
.login-screen{position:fixed;inset:0;z-index:50;display:flex;align-items:center;justify-content:center;padding:16px;
|
||||
background:linear-gradient(180deg,var(--bg-top) 0%,var(--bg-mid) 45%,var(--bg-bottom) 100%)}
|
||||
.login-card{width:min(360px,100%);display:flex;flex-direction:column;gap:12px;margin:0}
|
||||
.login-card .c-title{font-size:19px;margin:0}
|
||||
.login-sub{font-size:12.5px;color:var(--muted);margin-top:-8px}
|
||||
.login-card label{display:flex;flex-direction:column;gap:5px;font-size:12px;font-weight:700;color:var(--muted)}
|
||||
.login-card input{padding:10px 12px;border:1px solid var(--border);border-radius:10px;background:var(--glass-strong);color:var(--ink);font:inherit;font-size:13px}
|
||||
.login-err{color:var(--bad);font-size:12.5px;font-weight:600;min-height:1.2em}
|
||||
.login-out svg{width:17px;height:17px;stroke:currentColor;fill:none;stroke-width:2;vertical-align:middle}
|
||||
/* OPTIONAL:login END */
|
||||
@media (max-width:820px){.sidebar{width:66px;min-width:66px}.logo-title,.logo-sub,.nav-item span,.u-name,.u-role{display:none}.kpi-row{grid-template-columns:1fr 1fr}.dash-grid{grid-template-columns:1fr}}
|
||||
</style>
|
||||
</head>
|
||||
@@ -131,7 +157,9 @@ td{padding:10px 14px;border-bottom:1px solid var(--border)}
|
||||
<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 -->
|
||||
<!-- OPTIONAL:admin BEGIN -->
|
||||
<button class="nav-item adm-only" id="adm-nav" data-tab="admin" title="Admin"><svg viewBox="0 0 24 24"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg><span>Admin</span></button>
|
||||
<!-- OPTIONAL:admin END -->
|
||||
</nav>
|
||||
<div class="sidebar-foot"><div class="avatar" id="avatar">U</div><div><div class="u-name" id="u-name">User</div><div class="u-role" id="u-role">Operator</div></div></div>
|
||||
</aside>
|
||||
@@ -141,6 +169,9 @@ 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>
|
||||
<!-- OPTIONAL:login BEGIN -->
|
||||
<button class="mode-btn login-out" title="Sign out" aria-label="Sign out" onclick="loginShow(true); api('logout')"><svg viewBox="0 0 24 24"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/></svg></button>
|
||||
<!-- OPTIONAL:login END -->
|
||||
<button class="mode-btn" id="mode-btn" title="Dark mode" aria-label="Dark mode" aria-pressed="false" onclick="toggleDark()">🌙</button>
|
||||
<div class="indicator"><span class="dot ready" id="st-dot"></span><span id="st-text">System ready</span></div>
|
||||
</div>
|
||||
@@ -169,7 +200,10 @@ td{padding:10px 14px;border-bottom:1px solid var(--border)}
|
||||
<div class="prog-head"><span id="p-label">Waiting to start</span><span id="p-time"></span><span class="pct" id="p-pct">0%</span></div>
|
||||
<div class="prog-bg"><div class="prog-fill" id="p-fill"></div></div>
|
||||
<ol id="steps" aria-label="Steps"></ol>
|
||||
<!-- OPTIONAL: checkpoint tracker (see reference/tebra_dashboard.html .cp-track) -->
|
||||
<!-- OPTIONAL:checkpoints BEGIN -->
|
||||
<div class="section-label">Checkpoints</div>
|
||||
<ol class="cp-track" id="cp-track" aria-label="Checkpoints"></ol>
|
||||
<!-- OPTIONAL:checkpoints END -->
|
||||
<div class="log" id="log" role="log" aria-label="Run log"></div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -181,10 +215,41 @@ td{padding:10px 14px;border-bottom:1px solid var(--border)}
|
||||
<div class="tbl-wrap"><table><thead id="r-head"></thead><tbody id="r-body"></tbody></table></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- OPTIONAL:admin BEGIN -->
|
||||
<!-- ADMIN: admins only. Hiding it is not security: the host re-checks the role on every admin action. -->
|
||||
<div id="tab-admin" class="hidden adm-only">
|
||||
<div class="card"><div class="c-title">Assign access</div>
|
||||
<form class="toolbar" aria-label="Assign access" onsubmit="admAssign(event)">
|
||||
<input id="adm-user" aria-label="Username or employee ID" placeholder="Username or employee ID" required>
|
||||
<select id="adm-role" aria-label="Role"><option value="operator">Operator</option><option value="admin">Admin</option></select>
|
||||
<button class="btn primary">Assign</button>
|
||||
<span class="adm-note" id="adm-note" role="status"></span>
|
||||
</form>
|
||||
</div>
|
||||
<div class="card"><div class="c-title">Users with access</div>
|
||||
<div class="tbl-wrap"><table aria-label="Users with access"><thead><tr><th>User</th><th>Employee ID</th><th>Role</th><th></th></tr></thead><tbody id="adm-rows" onclick="admRemove(event)"></tbody></table></div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- OPTIONAL:admin END -->
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- OPTIONAL:login BEGIN -->
|
||||
<!-- LOGIN: UI only. The host checks the password with the project's own auth (see sections.md). -->
|
||||
<div class="login-screen" id="login-screen">
|
||||
<form class="card login-card" aria-label="Sign in" onsubmit="loginSubmit(event)">
|
||||
<div class="c-title">{{APP_NAME}}</div>
|
||||
<p class="login-sub">Sign in to continue</p>
|
||||
<label>Username<input id="login-user" autocomplete="username" required></label>
|
||||
<label>Password<input id="login-pass" type="password" autocomplete="current-password" required></label>
|
||||
<div class="login-err" id="login-err" role="alert"></div>
|
||||
<button class="btn primary">Sign in</button>
|
||||
</form>
|
||||
</div>
|
||||
<!-- OPTIONAL:login END -->
|
||||
|
||||
<script>
|
||||
// ===== CONFIG (fill per project) =====
|
||||
const STEPS = {{STEPS_JSON}}; // e.g. ["Initializing","Login","Process items","Completed"]
|
||||
@@ -234,9 +299,22 @@ const SAMPLE = {
|
||||
.map(([status,bucket],i)=>({...Object.fromEntries(REPORT_COLUMNS.map(c=>[c.key,`${c.label} ${i+1}`])),status,bucket}))}); },
|
||||
start_automation(){ let i=0; handle('status',{state:'running',msg:'Running (sample)'});
|
||||
const t=setInterval(()=>{
|
||||
// OPTIONAL:checkpoints BEGIN
|
||||
handle('cpstate', i<STEPS.length ? {kind:'active',cp:Math.ceil((i+1)*CHECKPOINTS.length/STEPS.length)} : {kind:'done',cp:CHECKPOINTS.length});
|
||||
// OPTIONAL:checkpoints END
|
||||
if(i<STEPS.length){ handle('step',{label:STEPS[i],pct:Math.round((i+1)/STEPS.length*100)}); handle('log',{msg:`Sample step: ${STEPS[i++]}`,type:'info'}); }
|
||||
else{ clearInterval(t); handle('log',{msg:'Sample run finished.',type:'success'}); handle('status',{state:'done',msg:'Completed'}); }
|
||||
},400); },
|
||||
// OPTIONAL:admin BEGIN
|
||||
adm_users:[{username:'Sample admin',emp_id:'E-1001',role:'admin'},{username:'Sample operator',emp_id:'E-1002',role:'operator'}],
|
||||
load_admin(){ handle('admin_data',{rows:this.adm_users}); },
|
||||
assign_role(who,role){ this.remove_role(who); this.adm_users.push({username:who,emp_id:'',role}); this.load_admin(); },
|
||||
remove_role(who){ this.adm_users=this.adm_users.filter(u=>u.username!==who&&u.emp_id!==who); this.load_admin(); },
|
||||
// OPTIONAL:admin END
|
||||
// OPTIONAL:login BEGIN
|
||||
login(username){ handle('login_result',{ok:true,user:{username,emp_id:'',role:document.body.dataset.role||'operator'}}); }, // stub: anyone, same role
|
||||
logout(){},
|
||||
// OPTIONAL:login END
|
||||
};
|
||||
|
||||
// ===== EVENT DISPATCH (see contract.md) =====
|
||||
@@ -249,6 +327,15 @@ function handle(event, d){
|
||||
case 'report_data': reportRows = d.rows||[]; renderReport(); break;
|
||||
case 'set_user': setUser(d); break;
|
||||
case 'apply_dark': setDark(!!d.on); break;
|
||||
// OPTIONAL:checkpoints BEGIN
|
||||
case 'cpstate': cpState(d.kind, d.cp); break;
|
||||
// OPTIONAL:checkpoints END
|
||||
// OPTIONAL:admin BEGIN
|
||||
case 'admin_data': admRender(d); break;
|
||||
// OPTIONAL:admin END
|
||||
// OPTIONAL:login BEGIN
|
||||
case 'login_result': loginResult(d); break;
|
||||
// OPTIONAL:login END
|
||||
}
|
||||
}
|
||||
|
||||
@@ -265,7 +352,7 @@ const $=id=>document.getElementById(id), tbTitle=$('tb-title'), tbSub=$('tb-sub'
|
||||
function esc(s){return String(s??'').replace(/[&<>"]/g,c=>({'&':'&','<':'<','>':'>','"':'"'}[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 setUser(u){ document.body.dataset.role=u.role||''; $('u-name').textContent=u.username; $('u-role').textContent=u.role; $('avatar').textContent=(u.username||'U')[0].toUpperCase(); }
|
||||
function setStatus(state,msg){ $('st-dot').className='dot '+state; $('st-text').textContent=msg||state; $('run-btn').disabled=state==='running'; }
|
||||
function startRun(){ showTab('run'); $('log').innerHTML=''; setStep(STEPS[0],0); api('start_automation'); }
|
||||
|
||||
@@ -303,6 +390,63 @@ function renderReport(){
|
||||
?`<td><span class="pill ${esc(r.bucket||'attention')}">${esc(r.status)}</span></td>`:`<td>${esc(r[c.key])}</td>`).join('')+'</tr>').join('')
|
||||
: `<tr><td class="empty" colspan="${REPORT_COLUMNS.length}">No rows yet</td></tr>`;
|
||||
}
|
||||
|
||||
// OPTIONAL:checkpoints BEGIN
|
||||
// Checkpoint tracker: numbered boxes driven by cpstate {kind, cp}. cp counts from 1.
|
||||
const CHECKPOINTS = {{CHECKPOINTS_JSON}}; // e.g. ["Open the portal","Load the list","Process items","Finish up"]
|
||||
const CP_LABEL = {pending:'Pending', active:'In progress', done:'Done', stuck:'Stuck'};
|
||||
let cpKinds = CHECKPOINTS.map(()=>'pending');
|
||||
function cpState(kind, n){
|
||||
if(!CP_LABEL[kind] || !(n>=1 && n<=CHECKPOINTS.length)) return;
|
||||
if(kind==='active') cpKinds = cpKinds.map((_,i)=>i<n-1?'done':'pending'); // a new active box resets the rest
|
||||
cpKinds[n-1] = kind;
|
||||
$('cp-track').innerHTML = CHECKPOINTS.map((c,i)=>`<li class="cp-box ${cpKinds[i]}"${cpKinds[i]==='active'?' aria-current="step"':''}>`
|
||||
+`<span>${i+1}</span><span class="cp-name">${esc(c)}</span><span>${CP_LABEL[cpKinds[i]]}</span></li>`).join('');
|
||||
}
|
||||
cpState('pending', 1);
|
||||
// OPTIONAL:checkpoints END
|
||||
|
||||
// OPTIONAL:admin BEGIN
|
||||
// Admin screen: shown only while set_user says role "admin" (CSS on body[data-role]).
|
||||
TABS.admin = ['Admin','Who can use this dashboard'];
|
||||
$('adm-nav').addEventListener('click', ()=>api('load_admin'));
|
||||
function admRender(d){
|
||||
$('adm-note').textContent = d.msg||'';
|
||||
const rows = d.rows||[];
|
||||
$('adm-rows').innerHTML = rows.length ? rows.map(u=>`<tr><td>${esc(u.username)}</td><td>${esc(u.emp_id)}</td>`
|
||||
+`<td><span class="pill adm-role ${/^admin$/i.test(u.role)?'admin':''}">${esc(u.role)}</span></td>`
|
||||
+`<td><button class="btn" data-user="${esc(u.emp_id||u.username)}">Remove</button></td></tr>`).join('')
|
||||
: '<tr><td class="empty" colspan="4">No one has access yet</td></tr>';
|
||||
}
|
||||
function admAssign(e){ e.preventDefault(); api('assign_role', $('adm-user').value.trim(), $('adm-role').value); $('adm-user').value=''; }
|
||||
function admRemove(e){ const who=e.target.dataset.user; if(who) api('remove_role', who); }
|
||||
if(SAMPLE_DATA) setUser({username:'Sample admin', emp_id:'E-1001', role:'admin'}); // demo: show the admin screen
|
||||
// OPTIONAL:admin END
|
||||
|
||||
// OPTIONAL:login BEGIN
|
||||
// Login page (UI only): the host checks the password with the project's own auth and answers login_result.
|
||||
function loginShow(on){
|
||||
$('login-screen').classList.toggle('hidden', !on);
|
||||
document.querySelector('.app').inert = on;
|
||||
if(on){ $('login-err').textContent=''; $('login-user').focus(); }
|
||||
}
|
||||
function loginSubmit(e){
|
||||
e.preventDefault(); $('login-err').textContent='';
|
||||
const u=$('login-user').value.trim(), p=$('login-pass').value;
|
||||
if(SAMPLE_DATA || bridge) return api('login', u, p);
|
||||
fetch(`${API_BASE}/login`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({username:u,password:p})})
|
||||
.then(r=>r.json()).then(d=>handle('login_result',d))
|
||||
.catch(()=>handle('login_result',{ok:false,msg:'Could not reach the server.'}));
|
||||
}
|
||||
function loginResult(d){
|
||||
$('login-pass').value='';
|
||||
if(!d.ok){ $('login-err').textContent = d.msg||'Sign-in failed.'; return; }
|
||||
if(d.user) setUser(d.user);
|
||||
loginShow(false); showTab('dashboard');
|
||||
}
|
||||
loginShow(true);
|
||||
// OPTIONAL:login END
|
||||
|
||||
renderSteps();
|
||||
startTransport();
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
# Optional sections
|
||||
|
||||
Three extras live in `dashboard.html` as marked blocks (`OPTIONAL:<name> BEGIN` ... `END`)
|
||||
in the style, the markup and the script. Build only the ones the user picked:
|
||||
|
||||
```
|
||||
python <skill>/templates/sections.py <project>/dashboard/dashboard.html checkpoints admin login
|
||||
```
|
||||
|
||||
Name only the chosen sections; name none to remove all three. The script deletes every
|
||||
unchosen block (markup, script and style) and the marker lines of the chosen ones, and it
|
||||
stops if a marker is broken. Never cut the blocks by hand.
|
||||
|
||||
With `SAMPLE_DATA = true`, every section works on its own sample answers. Once the bot is
|
||||
wired, the host must answer the section's actions as below. Desktop hosts add the slots to
|
||||
the `Bridge` class in `bridge.py`. Web hosts serve `POST {API_BASE}/<action-with-dashes>`,
|
||||
like the core actions, and send the events in the `/run/status` events list.
|
||||
|
||||
## Checkpoint tracker (`checkpoints`)
|
||||
|
||||
Numbered boxes on the Run tab. Each one is pending, in progress, done or stuck.
|
||||
|
||||
- Fill `{{CHECKPOINTS_JSON}}` with the checkpoint names, in order, from the bot's real
|
||||
stages, for example `["Open the portal","Load the list","Process items","Finish up"]`.
|
||||
They can be finer than `STEPS`.
|
||||
- Event `cpstate {kind: "pending"|"active"|"done"|"stuck", cp}`. `cp` counts from 1.
|
||||
`active` also marks the earlier boxes done and the later ones pending, so the bot can
|
||||
send `active` for each checkpoint as it starts, and `stuck` when one fails.
|
||||
- Desktop: add a marker table next to `STEP_MARKERS` in `bridge.py`, and send the event
|
||||
from `_on_line`, using specific substrings the bot really prints:
|
||||
|
||||
```python
|
||||
CHECKPOINT_MARKERS = [("[cp 1]", "active", 1), ("[cp 1 failed]", "stuck", 1)] # (substring, kind, cp)
|
||||
|
||||
# in _on_line, after the step markers:
|
||||
for marker, kind, cp in CHECKPOINT_MARKERS:
|
||||
if marker in low:
|
||||
self.send_to_js("cpstate", {"kind": kind, "cp": cp})
|
||||
break
|
||||
```
|
||||
|
||||
## Admin/roles screen (`admin`)
|
||||
|
||||
An Admin tab where an admin assigns or removes admin and operator access. The Admin nav
|
||||
item and the screen appear only while `set_user` says `role: "admin"`; operators never see
|
||||
them. Hiding them is not security: **the host re-checks that the signed-in user is an admin
|
||||
on every admin action**, and refuses otherwise.
|
||||
|
||||
- Event `admin_data {rows: [{username, emp_id, role: "admin"|"operator"}], msg?}`. `msg` is
|
||||
shown above the list, for example "Access assigned." or "Only admins can do that."
|
||||
- Actions: `load_admin()` when the tab opens, `assign_role(who, role)`, `remove_role(who)`.
|
||||
`who` is a username or employee ID. Answer each one with a fresh `admin_data`.
|
||||
- Desktop slots, using the project's own user store:
|
||||
|
||||
```python
|
||||
@pyqtSlot()
|
||||
def load_admin(self):
|
||||
if self.role != "admin": # self.role: set when the user signs in
|
||||
return
|
||||
self.send_to_js("admin_data", {"rows": read_access_list()})
|
||||
|
||||
@pyqtSlot(str, str)
|
||||
def assign_role(self, who, role):
|
||||
if self.role == "admin" and role in ("admin", "operator"):
|
||||
save_access(who, role)
|
||||
self.load_admin()
|
||||
|
||||
@pyqtSlot(str)
|
||||
def remove_role(self, who):
|
||||
if self.role == "admin":
|
||||
delete_access(who)
|
||||
self.load_admin()
|
||||
```
|
||||
|
||||
## Login page (`login`)
|
||||
|
||||
A sign-in screen in the page's own theme, shown over the dashboard until sign-in succeeds,
|
||||
plus a Sign out button in the top bar. It is UI only: it hands the username and password to
|
||||
the project's existing auth. Don't build an auth backend.
|
||||
|
||||
- Desktop action `login(username, password)`. The host checks them and sends
|
||||
`login_result {ok, msg?, user?: {username, emp_id, role}}`. On `ok` the screen closes and
|
||||
`user` fills the sidebar (and the role, if the admin screen is built). Otherwise `msg`
|
||||
shows under the form.
|
||||
- Web: `POST {API_BASE}/login` with JSON `{username, password}`, answered with the same
|
||||
`{ok, msg?, user?}` body.
|
||||
- Action `logout()` when the user signs out. The screen comes back.
|
||||
- The page sits behind the screen, but the host must still refuse data actions until
|
||||
sign-in succeeds.
|
||||
- No auth in the project yet: keep a stub that lets any non-empty username in, and tell the
|
||||
user it is a stub. In sample mode the page has its own stub.
|
||||
|
||||
```python
|
||||
@pyqtSlot(str, str)
|
||||
def login(self, username, password):
|
||||
role = check_login(username, password) # the project's auth: a role, or None
|
||||
self.role = role or ""
|
||||
if role:
|
||||
self.send_to_js("login_result", {"ok": True, "user": {"username": username, "emp_id": "", "role": role}})
|
||||
else:
|
||||
self.send_to_js("login_result", {"ok": False, "msg": "Wrong username or password."})
|
||||
|
||||
@pyqtSlot()
|
||||
def logout(self):
|
||||
self.role = ""
|
||||
```
|
||||
@@ -0,0 +1,35 @@
|
||||
"""Keep the optional sections the user chose in dashboard.html, and delete the rest.
|
||||
|
||||
Usage: python sections.py <dashboard.html> [checkpoints] [admin] [login]
|
||||
|
||||
Each section sits between marker lines, in HTML, CSS and script:
|
||||
<!-- OPTIONAL:admin BEGIN --> /* OPTIONAL:admin BEGIN */ // OPTIONAL:admin BEGIN
|
||||
... ... ...
|
||||
<!-- OPTIONAL:admin END --> /* OPTIONAL:admin END */ // OPTIONAL:admin END
|
||||
Chosen sections lose only their marker lines. Unchosen ones lose everything.
|
||||
The file is rewritten in place. Name no sections to remove all of them.
|
||||
"""
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
SECTIONS = ("checkpoints", "admin", "login")
|
||||
BLOCK = re.compile(r"^[ \t]*(?:<!--|/\*|//) OPTIONAL:(\w+) BEGIN.*\n((?:.*\n)*?)[ \t]*(?:<!--|/\*|//) OPTIONAL:\1 END.*\n", re.M)
|
||||
|
||||
|
||||
def keep(html, chosen):
|
||||
unknown = set(chosen) - set(SECTIONS)
|
||||
if unknown:
|
||||
sys.exit(f"unknown section(s) {', '.join(sorted(unknown))}; choose from {', '.join(SECTIONS)}")
|
||||
html = BLOCK.sub(lambda m: m[2] if m[1] in chosen else "", html)
|
||||
if "OPTIONAL:" in html:
|
||||
sys.exit("an OPTIONAL marker has no matching BEGIN/END line; fix the template")
|
||||
return html
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if len(sys.argv) < 2:
|
||||
sys.exit(__doc__)
|
||||
page = Path(sys.argv[1])
|
||||
page.write_text(keep(page.read_text(encoding="utf-8"), sys.argv[2:]), encoding="utf-8")
|
||||
print(f"{page}: kept {', '.join(sys.argv[2:]) or 'no optional sections'}")
|
||||
@@ -17,6 +17,11 @@ uv run --with pytest --with playwright pytest tests
|
||||
`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`.
|
||||
- Optional-section cases (`test_optional_sections.py`) skip on a generated page
|
||||
built without that section. The template must carry all of them. A page with
|
||||
the login section is signed in (`login_result ok`) before each case.
|
||||
- `test_sections.py`: the optional-section strip tool keeps only the chosen
|
||||
sections, and a page with none of them still passes the page check.
|
||||
- `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:
|
||||
|
||||
|
||||
+22
-1
@@ -35,6 +35,18 @@ FIXTURES = {
|
||||
"asks": [r"colou?r", r"desktop|web", r"section"],
|
||||
"never_asks": [r"\bkeep\b|restyle"],
|
||||
"brand": "#7a3cff",
|
||||
"sections": [],
|
||||
},
|
||||
"a-empty-sections": {
|
||||
"dir": "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: Superbill Ember. "
|
||||
"Desktop or web: web. Optional sections: all three (checkpoint tracker, admin/roles "
|
||||
"screen, login page). The project has no auth yet.",
|
||||
"frontend": r"^\s*(none|no\b)",
|
||||
"asks": [r"colou?r", r"desktop|web", r"section"],
|
||||
"never_asks": [r"\bkeep\b|restyle"],
|
||||
"sections": ["checkpoints", "admin", "login"],
|
||||
},
|
||||
}
|
||||
|
||||
@@ -43,7 +55,7 @@ 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)
|
||||
shutil.copytree(ROOT / "tests" / "fixtures" / cfg.get("dir", fixture), project)
|
||||
for skill in ROOT.glob("rpa-dashboard-*"):
|
||||
shutil.copytree(skill, project / ".claude" / "skills" / skill.name)
|
||||
|
||||
@@ -80,10 +92,19 @@ 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)
|
||||
if "sections" in cfg: # chosen sections built, the others leave no trace
|
||||
spec = importlib.util.spec_from_file_location("test_sections", ROOT / "tests" / "test_sections.py")
|
||||
traces = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(traces)
|
||||
for name, trace in traces.TRACES.items():
|
||||
want = name in cfg["sections"]
|
||||
checks[f"{name} {'built' if want else 'left no trace'}"] = bool(trace.search(html)) == want
|
||||
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:]]
|
||||
if cfg.get("sections"): # every chosen section's page check ran
|
||||
checks["no section checks skipped"] = "skipped" not in page_check[0]
|
||||
else:
|
||||
page_check = ["no page"]
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ TEST_FILL = {
|
||||
"API_BASE": "",
|
||||
"STEPS_JSON": json.dumps(["Initializing", "Login", "Process items", "Completed"]),
|
||||
"REPORT_COLUMNS_JSON": json.dumps([{"key": "item", "label": "Item"}, {"key": "status", "label": "Status"}]),
|
||||
"CHECKPOINTS_JSON": json.dumps(["Sign in", "Open the list", "Process items", "Finish up"]),
|
||||
}
|
||||
FAKE_BRIDGE = """
|
||||
window.__calls = [];
|
||||
@@ -63,6 +64,8 @@ def page(request, browser, page_url):
|
||||
pg.on("pageerror", lambda e: errors.append(e))
|
||||
pg.goto(page_url)
|
||||
assert not errors, f"page script errors: {errors}"
|
||||
if pg.get_by_role("form", name="Sign in").is_visible(): # login page section: the host lets us in
|
||||
push_to(pg, "login_result", {"ok": True})
|
||||
if dark:
|
||||
push_to(pg, "apply_dark", {"on": True})
|
||||
yield pg
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
"""Optional sections (see templates/sections.md). On a generated page, a case skips when the
|
||||
page was built without its section. The template itself must carry every section."""
|
||||
import os
|
||||
import re
|
||||
|
||||
import pytest
|
||||
from playwright.sync_api import expect
|
||||
from test_contract import colour_of, open_tab
|
||||
|
||||
|
||||
def need(locator, section):
|
||||
if not locator.count():
|
||||
if not os.environ.get("PAGE_CHECK_HTML"):
|
||||
pytest.fail(f"the template has no {section}")
|
||||
pytest.skip(f"page built without the {section}")
|
||||
return locator
|
||||
|
||||
|
||||
def fits_width(page, locator):
|
||||
box = locator.bounding_box()
|
||||
return box["x"] >= 0 and box["x"] + box["width"] <= page.viewport_size["width"]
|
||||
|
||||
|
||||
CP_SHOWS = {"pending": ("Pending", "grey"), "active": ("In progress", "amber"),
|
||||
"done": ("Done", "green"), "stuck": ("Stuck", "red")}
|
||||
|
||||
|
||||
def test_checkpoint_boxes_show_each_state(page, push):
|
||||
track = need(page.get_by_role("list", name="Checkpoints", include_hidden=True), "checkpoint tracker")
|
||||
open_tab(page, "Run")
|
||||
boxes = track.get_by_role("listitem")
|
||||
n = boxes.count()
|
||||
assert n >= 2, "the tracker needs at least two checkpoints"
|
||||
|
||||
def expect_boxes(*kinds):
|
||||
for i, kind in enumerate(kinds):
|
||||
label, colour = CP_SHOWS[kind]
|
||||
expect(boxes.nth(i)).to_have_text(re.compile(rf"^\s*{i + 1}(?!\d).*{label}\s*$"))
|
||||
assert colour_of(boxes.nth(i)) == colour, (i + 1, kind)
|
||||
|
||||
expect_boxes(*["pending"] * n)
|
||||
push("cpstate", {"kind": "active", "cp": 2}) # earlier boxes are done, later ones pending
|
||||
expect_boxes("done", "active", *["pending"] * (n - 2))
|
||||
push("cpstate", {"kind": "stuck", "cp": 2})
|
||||
expect_boxes("done", "stuck")
|
||||
push("cpstate", {"kind": "done", "cp": 2})
|
||||
expect_boxes("done", "done")
|
||||
push("cpstate", {"kind": "pending", "cp": 2})
|
||||
expect_boxes("done", "pending")
|
||||
assert fits_width(page, boxes.last)
|
||||
|
||||
|
||||
ADA = {"username": "ada", "emp_id": "E1", "role": "admin"}
|
||||
OLIVE = {"username": "olive", "emp_id": "E2", "role": "operator"}
|
||||
|
||||
|
||||
def admin_nav(page):
|
||||
return need(page.get_by_role("navigation").get_by_role("button", name="Admin", exact=True, include_hidden=True),
|
||||
"admin screen")
|
||||
|
||||
|
||||
def test_admin_screen_only_for_admins(page, push):
|
||||
nav = admin_nav(page)
|
||||
push("set_user", OLIVE)
|
||||
expect(nav).to_be_hidden()
|
||||
push("set_user", ADA)
|
||||
expect(nav).to_be_visible()
|
||||
nav.click()
|
||||
users = page.get_by_role("table", name="Users with access")
|
||||
push("admin_data", {"rows": [ADA, OLIVE]})
|
||||
rows = users.locator("tbody").get_by_role("row")
|
||||
expect(rows).to_have_count(2)
|
||||
expect(rows.nth(1)).to_contain_text("olive")
|
||||
expect(rows.nth(1)).to_contain_text(re.compile("operator", re.I))
|
||||
|
||||
push("set_user", OLIVE) # operators see neither the nav item nor the screen
|
||||
expect(nav).to_be_hidden()
|
||||
expect(users).to_be_hidden()
|
||||
|
||||
|
||||
def test_admin_assigns_and_removes_roles(page, push):
|
||||
nav = admin_nav(page)
|
||||
push("set_user", ADA)
|
||||
nav.click()
|
||||
sample = page.evaluate("SAMPLE_DATA")
|
||||
form = page.get_by_role("form", name="Assign access")
|
||||
form.get_by_role("textbox", name="Username or employee ID").fill("grace")
|
||||
form.get_by_role("combobox", name="Role").select_option("admin")
|
||||
form.get_by_role("button", name="Assign").click()
|
||||
rows = page.get_by_role("table", name="Users with access").locator("tbody").get_by_role("row")
|
||||
if not sample: # wired page: the host answers with fresh admin_data
|
||||
assert ["assign_role", "grace", "admin"] in page.evaluate("window.__calls")
|
||||
push("admin_data", {"rows": [ADA, {"username": "grace", "emp_id": "", "role": "admin"}]})
|
||||
grace = rows.filter(has_text="grace")
|
||||
expect(grace).to_contain_text(re.compile("admin", re.I))
|
||||
|
||||
grace.get_by_role("button", name="Remove").click()
|
||||
if not sample:
|
||||
assert ["remove_role", "grace"] in page.evaluate("window.__calls")
|
||||
push("admin_data", {"rows": [ADA]})
|
||||
expect(grace).to_have_count(0)
|
||||
|
||||
|
||||
def login_form(page):
|
||||
return need(page.get_by_role("form", name="Sign in", include_hidden=True), "login page")
|
||||
|
||||
|
||||
def sign_out(page):
|
||||
page.get_by_role("button", name="Sign out", exact=True).click()
|
||||
|
||||
|
||||
def light_text(locator):
|
||||
rgb = re.findall(r"\d+", locator.evaluate("el => getComputedStyle(el).color"))[:3]
|
||||
return sum(int(x) for x in rgb) / 3 > 128
|
||||
|
||||
|
||||
def test_login_page_renders_in_the_theme(page, request):
|
||||
form = login_form(page)
|
||||
sign_out(page)
|
||||
expect(form).to_be_visible()
|
||||
assert fits_width(page, form)
|
||||
assert light_text(form) == (request.node.callspec.params["page"] == "dark")
|
||||
expect(form.get_by_role("button", name="Sign in")).to_be_visible()
|
||||
|
||||
|
||||
def test_login_hands_off_to_auth_or_stub(page, push):
|
||||
form = login_form(page)
|
||||
sample = page.evaluate("SAMPLE_DATA")
|
||||
sign_out(page)
|
||||
form.get_by_role("textbox", name="Username").fill("alice")
|
||||
form.get_by_label("Password").fill("secret")
|
||||
form.get_by_role("button", name="Sign in").click()
|
||||
if sample: # no auth yet: the stub signs anyone in
|
||||
expect(form).to_be_hidden()
|
||||
expect(page.get_by_text("alice", exact=True)).to_have_count(1)
|
||||
sign_out(page)
|
||||
else: # the host checks it with the project's auth
|
||||
calls = page.evaluate("window.__calls")
|
||||
assert ["logout"] in calls and ["login", "alice", "secret"] in calls
|
||||
|
||||
push("login_result", {"ok": False, "msg": "Wrong password"})
|
||||
expect(form.get_by_role("alert")).to_have_text("Wrong password")
|
||||
expect(form).to_be_visible()
|
||||
push("login_result", {"ok": True, "user": {"username": "bob", "emp_id": "7", "role": "operator"}})
|
||||
expect(form).to_be_hidden()
|
||||
expect(page.get_by_text("bob", exact=True)).to_have_count(1)
|
||||
@@ -0,0 +1,55 @@
|
||||
"""Optional sections: the strip tool keeps the chosen sections and leaves nothing of the rest."""
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
TEMPLATES = ROOT / "rpa-dashboard-no-ai" / "templates"
|
||||
spec = importlib.util.spec_from_file_location("sections", TEMPLATES / "sections.py")
|
||||
sections = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(sections)
|
||||
|
||||
TEMPLATE = (TEMPLATES / "dashboard.html").read_text(encoding="utf-8")
|
||||
# Text each optional section brings into the page (markup, script or style).
|
||||
TRACES = {
|
||||
"checkpoints": re.compile(r"checkpoint|cpstate|cp-", re.I),
|
||||
"admin": re.compile(r"admin", re.I),
|
||||
"login": re.compile(r"login_|sign in|sign out|logout", re.I),
|
||||
}
|
||||
|
||||
|
||||
def test_no_sections_leave_nothing_behind():
|
||||
html = sections.keep(TEMPLATE, [])
|
||||
for name, trace in TRACES.items():
|
||||
assert not trace.search(html), f"{name} left behind: {trace.search(html)[0]!r}"
|
||||
assert "OPTIONAL" not in html
|
||||
|
||||
|
||||
@pytest.mark.parametrize("chosen", [["checkpoints"], ["admin"], ["login"], ["checkpoints", "admin", "login"]])
|
||||
def test_chosen_sections_are_kept_and_the_rest_removed(chosen):
|
||||
html = sections.keep(TEMPLATE, chosen)
|
||||
for name, trace in TRACES.items():
|
||||
assert bool(trace.search(html)) == (name in chosen), name
|
||||
assert "OPTIONAL" not in html
|
||||
|
||||
|
||||
def test_unknown_section_is_refused():
|
||||
with pytest.raises(SystemExit, match="checkpoints, admin, login"):
|
||||
sections.keep(TEMPLATE, ["charts"])
|
||||
|
||||
|
||||
def test_page_without_sections_passes_the_page_check(tmp_path):
|
||||
fill = {"APP_NAME": "T", "APP_SUBTITLE": "", "LOGO_SRC": "", "API_BASE": "",
|
||||
"STEPS_JSON": json.dumps(["Start", "Work", "Done"]),
|
||||
"REPORT_COLUMNS_JSON": json.dumps([{"key": "item", "label": "Item"}, {"key": "status", "label": "Status"}])}
|
||||
page_file = tmp_path / "dashboard.html"
|
||||
page_file.write_text(re.sub(r"\{\{(\w+)\}\}", lambda m: fill[m[1]], sections.keep(TEMPLATE, [])), encoding="utf-8")
|
||||
r = subprocess.run([sys.executable, "-m", "pytest", str(ROOT / "tests" / "page_check"), "-q", "-p", "no:cacheprovider"],
|
||||
env={**os.environ, "PAGE_CHECK_HTML": str(page_file)}, capture_output=True, text=True)
|
||||
assert r.returncode == 0, r.stdout[-2000:]
|
||||
Reference in new issue
Block a user