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:
VasanthandClaude Opus 5.5 committed 2026-10-16 14:31:25 +05:30
1 parent 3eb3fdf00e
commit 9a5b95448d
10 files changed
+536 -10

No files matched your search

+1 -1
View File
@@ -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>`)
+147 -3
View File
@@ -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=>({'&':'&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 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>
+106
View File
@@ -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 = ""
```
+35
View File
@@ -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'}")