damien-kaiser/taskloft

Taskloft — team task boards (owner / member / viewer), card history, my plate, overdue, blockers, AI checklists and summaries, usable from Claude and ChatGPT over MCP.

A remix template · 0 remixes · Remix damien-kaiser/taskloft

app/boards/doors/page.py

"""The board page: one HTML file, no build step, no external assets. It only calls /api/*.

Everything a person can do here goes through the same use cases as the MCP tools; the page never
decides a rule itself (a viewer sees no drag handles, but the API refuses their edits anyway).
"""

PAGE = r"""<!doctype html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>Taskloft</title>
<style>
:root{--bg:#f4f5f7;--panel:#fff;--ink:#172b4d;--mute:#5e6c84;--line:#dfe1e6;--brand:#3b5bdb;--brand2:#e7ecff;
--red:#c92a2a;--redbg:#fff0f0;--amber:#9c6b00;--amberbg:#fff7e0;--green:#2b8a3e;--col:#ebecf0;--r:10px}
*{box-sizing:border-box}body{margin:0;font:14px/1.45 system-ui,-apple-system,Segoe UI,Roboto,sans-serif;color:var(--ink);background:var(--bg)}
button,input,select,textarea{font:inherit;color:inherit}
button{cursor:pointer;border:1px solid var(--line);background:#fff;border-radius:8px;padding:.35rem .7rem}
button.primary{background:var(--brand);border-color:var(--brand);color:#fff}button.ghost{border-color:transparent;background:transparent}
button:disabled{opacity:.5;cursor:default}
input,select,textarea{border:1px solid var(--line);border-radius:8px;padding:.4rem .55rem;background:#fff;width:100%}
textarea{min-height:5.5rem;resize:vertical}
header{display:flex;align-items:center;gap:1rem;padding:.6rem 1rem;background:var(--panel);border-bottom:1px solid var(--line);position:sticky;top:0;z-index:5}
.logo{font-weight:800;letter-spacing:-.02em;font-size:1.15rem;display:flex;align-items:center;gap:.45rem}
.logo i{width:22px;height:22px;border-radius:6px;background:linear-gradient(135deg,#3b5bdb,#22b8cf);display:inline-block}
header .who{margin-left:auto;color:var(--mute);font-size:.9rem}header .who a{color:var(--mute)}
.wrap{display:grid;grid-template-columns:250px 1fr;min-height:calc(100vh - 53px)}
aside{background:var(--panel);border-right:1px solid var(--line);padding:1rem;display:flex;flex-direction:column;gap:1rem}
aside h3{margin:.2rem 0;font-size:.75rem;text-transform:uppercase;letter-spacing:.06em;color:var(--mute)}
.nav a{display:flex;justify-content:space-between;padding:.4rem .55rem;border-radius:8px;color:var(--ink);text-decoration:none}
.nav a:hover{background:var(--bg)}.nav a.on{background:var(--brand2);color:var(--brand);font-weight:600}
.nav small{color:var(--mute)}
main{padding:1rem 1.2rem;overflow:auto}
.bhead{display:flex;align-items:center;gap:.6rem;flex-wrap:wrap;margin-bottom:.8rem}
.bhead h1{font-size:1.35rem;margin:0 .4rem 0 0}
.pill{font-size:.75rem;padding:.12rem .5rem;border-radius:99px;background:var(--brand2);color:var(--brand);font-weight:600}
.pill.view{background:#f1f3f5;color:var(--mute)}
.people{display:flex}.av{width:28px;height:28px;border-radius:50%;display:inline-flex;align-items:center;justify-content:center;font-size:.72rem;font-weight:700;color:#fff;border:2px solid #fff;margin-left:-6px}
.cols{display:flex;gap:.9rem;align-items:flex-start;overflow-x:auto;padding-bottom:1rem}
.col{flex:0 0 262px;background:var(--col);border-radius:var(--r);padding:.6rem;max-height:calc(100vh - 170px);display:flex;flex-direction:column}
.col h2{font-size:.92rem;margin:.1rem .2rem .5rem;display:flex;justify-content:space-between;align-items:center}
.col h2 small{color:var(--mute);font-weight:500}
.drop{min-height:30px;overflow-y:auto;flex:1;padding:2px}
.drop.over{background:rgba(59,91,219,.08);border-radius:8px}
.card{background:#fff;border-radius:8px;padding:.5rem .6rem;margin:0 0 .45rem;box-shadow:0 1px 0 rgba(9,30,66,.18);cursor:pointer;position:relative}
.card[draggable=true]{cursor:grab}.card.dragging{opacity:.45}
.card .t{font-weight:500;margin-bottom:.25rem}.card .num{color:var(--mute);font-size:.75rem;margin-right:.25rem}
.meta{display:flex;flex-wrap:wrap;gap:.3rem;align-items:center;font-size:.75rem}
.lab{background:#e9ecef;border-radius:5px;padding:0 .4rem;color:#343a40}
.due{border-radius:5px;padding:0 .35rem;background:#f1f3f5;color:var(--mute)}.due.late{background:var(--redbg);color:var(--red);font-weight:600}
.due.done{background:#ebfbee;color:var(--green)}
.blk{background:var(--amberbg);color:var(--amber);border-radius:5px;padding:0 .35rem;font-weight:600}
.meta .av{margin-left:auto;width:22px;height:22px;font-size:.62rem;border:0}
.add{display:flex;gap:.35rem;margin-top:.2rem}.add input{background:#fff}
.newlist{flex:0 0 240px}
.panel{background:var(--panel);border:1px solid var(--line);border-radius:var(--r);padding:1rem;margin-bottom:1rem;max-width:860px}
.panel h2{margin:0 0 .6rem;font-size:1.05rem}
.row{display:flex;gap:.6rem;align-items:center;flex-wrap:wrap}
.list-cards .card{max-width:640px}
.muted{color:var(--mute)}.err{color:var(--red)}
#toast{position:fixed;bottom:1rem;left:50%;transform:translateX(-50%);background:#172b4d;color:#fff;padding:.5rem .9rem;border-radius:8px;display:none;z-index:20}
#sheet{position:fixed;inset:0;background:rgba(9,30,66,.45);display:none;z-index:10;align-items:flex-start;justify-content:center;padding:4vh 1rem;overflow:auto}
#sheet .box{background:#fff;border-radius:12px;width:min(760px,100%);padding:1.2rem 1.3rem;display:grid;grid-template-columns:1fr 220px;gap:1.2rem}
#sheet h2{margin:0 0 .3rem;font-size:1.15rem}#sheet label{display:block;font-size:.75rem;color:var(--mute);margin:.7rem 0 .2rem;text-transform:uppercase;letter-spacing:.04em}
.hist{font-size:.82rem;border-left:2px solid var(--line);padding-left:.7rem;margin-top:.3rem;max-height:230px;overflow:auto}
.hist div{margin-bottom:.45rem}.hist b{font-weight:600}
.check{display:flex;gap:.4rem;align-items:center;margin:.2rem 0}.check input{width:auto}.check.done span{text-decoration:line-through;color:var(--mute)}
.summary{white-space:pre-wrap;background:#f8f9fa;border-radius:8px;padding:.7rem;margin-top:.5rem}
@media(max-width:800px){.wrap{grid-template-columns:1fr}aside{border-right:0;border-bottom:1px solid var(--line)}#sheet .box{grid-template-columns:1fr}}
</style></head><body>
<header><div class="logo"><i></i>Taskloft</div><div class="who" id="who"></div></header>
<div class="wrap"><aside>
 <div class="nav"><h3>For me</h3><a href="#plate" id="nav-plate">My plate <small id="n-plate"></small></a><a href="#overdue" id="nav-overdue">Overdue <small id="n-overdue"></small></a></div>
 <div class="nav"><h3>Boards</h3><div id="boards"></div></div>
 <form id="nb" class="row" style="flex-wrap:nowrap"><input id="nbt" placeholder="New board" required maxlength="200" style="flex:1;min-width:0"><button class="primary">Create</button></form>
 <form id="search" class="row"><input id="q" placeholder="Search cards"></form>
</aside><main id="main"><p class="muted">Loading…</p></main></div>
<div id="sheet" role="dialog" aria-modal="true"></div><div id="toast"></div>
<script>
const $=s=>document.querySelector(s), esc=s=>String(s??'').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
let ME='', BOARD=null, CAN=false, OWNER=false;
async function api(u,o={}){const r=await fetch(u,{headers:{'content-type':'application/json'},...o,body:o.body?JSON.stringify(o.body):undefined});
 const t=await r.text();let d;try{d=t?JSON.parse(t):null}catch{d=t}
 if(!r.ok){const m=(d&&d.detail)||r.statusText;toast(typeof m==='string'?m:JSON.stringify(m));throw new Error(m)}return d}
function toast(m){const t=$('#toast');t.textContent=m;t.style.display='block';clearTimeout(t._h);t._h=setTimeout(()=>t.style.display='none',3200)}
const colors=['#3b5bdb','#0b7285','#5f3dc4','#c2255c','#2b8a3e','#e8590c','#364fc7','#1098ad'];
function av(email){if(!email)return'';let h=0;for(const ch of email)h=(h*31+ch.charCodeAt(0))|0;const ini=email.split('@')[0].split(/[._-]/).map(x=>x[0]).join('').slice(0,2).toUpperCase();
 return `<span class="av" title="${esc(email)}" style="background:${colors[Math.abs(h)%colors.length]}">${esc(ini)}</span>`}
function dueChip(c){if(!c.due_date)return'';const cls=c.done?'done':c.overdue?'late':'';return `<span class="due ${cls}" title="due date">${c.overdue&&!c.done?'Overdue · ':''}${esc(c.due_date)}</span>`}
function cardHtml(c,drag,where){return `<div class="card" data-id="${esc(c.id)}" ${drag?'draggable="true"':''}>
 <div class="t"><span class="num">#${c.number}</span>${esc(c.title)}</div>
 <div class="meta">${(c.labels||[]).map(l=>`<span class="lab">${esc(l)}</span>`).join('')}${dueChip(c)}${c.waiting_on&&c.waiting_on.length?`<span class="blk" title="waiting on ${c.waiting_on.map(n=>'#'+n).join(', ')}">⛔ waits on ${c.waiting_on.map(n=>'#'+n).join(' ')}</span>`:''}${where?`<span class="muted">${esc(c.board)} · ${esc(c.list)}</span>`:''}${av(c.assignee)}</div></div>`}
async function loadBoards(){const bs=await api('/api/boards');$('#boards').innerHTML=bs.length?bs.map(b=>`<a href="#b/${esc(b.id)}" class="${BOARD&&BOARD.id===b.id?'on':''}">${esc(b.title)} <small>${esc(b.role)}</small></a>`).join(''):'<p class="muted">No board yet — create one.</p>';return bs}
async function counts(){try{const p=await api('/api/me/plate');$('#n-plate').textContent=p.overdue.length+p.due_soon.length+p.no_due_date.length||'';const o=await api('/api/me/overdue');$('#n-overdue').textContent=o.length||''}catch{}}
async function route(){const h=location.hash.slice(1);document.querySelectorAll('.nav a').forEach(a=>a.classList.toggle('on',a.getAttribute('href')==='#'+h));
 if(h.startsWith('b/'))return showBoard(h.slice(2));if(h==='overdue')return showOverdue();if(h.startsWith('search/'))return showSearch(decodeURIComponent(h.slice(7)));return showPlate()}
async function showPlate(){BOARD=null;const p=await api('/api/me/plate');const sec=(t,a)=>`<div class="panel list-cards"><h2>${t} <span class="muted">(${a.length})</span></h2>${a.length?a.map(c=>cardHtml(c,false,true)).join(''):'<p class="muted">Nothing here.</p>'}</div>`;
 $('#main').innerHTML=`<div class="bhead"><h1>My plate</h1><span class="muted">cards assigned to you, until ${esc(p.until)}</span></div>`+sec('Overdue',p.overdue)+sec('Due this week',p.due_soon)+sec('No due date',p.no_due_date);bindOpen()}
async function showOverdue(){BOARD=null;const o=await api('/api/me/overdue');$('#main').innerHTML=`<div class="bhead"><h1>Overdue</h1><span class="muted">past due and not finished, on all your boards</span></div><div class="panel list-cards">${o.length?o.map(c=>cardHtml(c,false,true)).join(''):'<p class="muted">Nothing overdue. 🎉</p>'}</div>`;bindOpen()}
async function showSearch(q){BOARD=null;const o=await api('/api/search?q='+encodeURIComponent(q));$('#main').innerHTML=`<div class="bhead"><h1>Search</h1><span class="muted">“${esc(q)}”</span><button class="ghost" id="bym">✨ Search by meaning</button></div><div class="panel list-cards"><h2>Same words</h2>${o.length?o.map(c=>cardHtml(c,false,true)).join(''):'<p class="muted">No card has these words.</p>'}</div><div id="meaning"></div>`;bindOpen();
 $('#bym').onclick=async()=>{$('#meaning').innerHTML='<div class="panel"><p class="muted">Dodil Models is reading your cards…</p></div>';const m=await api('/api/search?meaning=1&q='+encodeURIComponent(q));$('#meaning').innerHTML=`<div class="panel list-cards"><h2>Same meaning</h2>${m.length?m.map(c=>cardHtml(c,false,true)).join(''):'<p class="muted">Nothing close.</p>'}</div>`;bindOpen()}}
async function showBoard(id){const b=await api('/api/boards/'+encodeURIComponent(id));BOARD=b;CAN=b.role!=='viewer';OWNER=b.role==='owner';loadBoards();
 const ppl=[b.owner,...b.members.map(m=>m.email)];
 $('#main').innerHTML=`<div class="bhead"><h1>${esc(b.title)}</h1><span class="pill ${CAN?'':'view'}">${esc(b.role)}${CAN?'':' · view only'}</span>
 <div class="people" style="margin-left:.5rem">${ppl.map(av).join('')}</div>
 <button class="ghost" id="ppl">People</button><button class="ghost" id="act">Activity</button><button class="ghost" id="sum">✨ Daily summary</button></div>
 <div id="extra"></div><div class="cols">${b.lists.map(l=>`<section class="col" data-list="${esc(l.id)}"><h2>${esc(l.name)}${l.done?' ✓':''} <small>${l.cards.length}</small></h2>
 <div class="drop" data-list="${esc(l.id)}">${l.cards.map(c=>cardHtml(c,CAN,false)).join('')}</div>
 ${CAN?`<form class="add" data-list="${esc(l.id)}"><input placeholder="Add a card" maxlength="200" required><button>+</button></form>`:''}</section>`).join('')}
 ${CAN?`<form class="col newlist" id="nl"><input placeholder="Add another list" maxlength="200" required></form>`:''}</div>`;
 document.querySelectorAll('form.add').forEach(f=>f.onsubmit=async e=>{e.preventDefault();const i=f.querySelector('input');await api(`/api/boards/${b.id}/cards`,{method:'POST',body:{title:i.value,list:f.dataset.list}});showBoard(b.id);counts()});
 const nl=$('#nl');if(nl)nl.onsubmit=async e=>{e.preventDefault();await api(`/api/boards/${b.id}/lists`,{method:'POST',body:{name:nl.querySelector('input').value}});showBoard(b.id)};
 $('#ppl').onclick=()=>people(b);$('#act').onclick=()=>activity(b);$('#sum').onclick=()=>summary(b);
 bindOpen();if(CAN)bindDrag(b)}
function bindOpen(){document.querySelectorAll('.card').forEach(el=>el.onclick=()=>openCard(el.dataset.id))}
function bindDrag(b){let dragId=null;
 document.querySelectorAll('.card[draggable=true]').forEach(el=>{el.ondragstart=e=>{dragId=el.dataset.id;el.classList.add('dragging');e.dataTransfer.effectAllowed='move';e.dataTransfer.setData('text/plain',dragId)};el.ondragend=()=>el.classList.remove('dragging')});
 document.querySelectorAll('.drop').forEach(z=>{z.ondragover=e=>{e.preventDefault();z.classList.add('over')};z.ondragleave=()=>z.classList.remove('over');
  z.ondrop=async e=>{e.preventDefault();z.classList.remove('over');const id=dragId||e.dataTransfer.getData('text/plain');if(!id)return;
   const after=[...z.querySelectorAll('.card:not(.dragging)')].find(c=>{const r=c.getBoundingClientRect();return e.clientY<r.top+r.height/2});
   try{await api(`/api/cards/${id}/move`,{method:'POST',body:{list:z.dataset.list,before:after?after.dataset.id:null}})}finally{showBoard(b.id);counts()}}})}
async function people(b){const m=await api(`/api/boards/${b.id}/members`);$('#extra').innerHTML=`<div class="panel"><h2>People on ${esc(b.title)}</h2>
 <div class="row">${av(m.owner)} <b>${esc(m.owner)}</b> <span class="pill">owner</span></div>
 ${m.members.map(x=>`<div class="row" style="margin-top:.4rem">${av(x.email)} ${esc(x.email)} ${OWNER?`<select data-e="${esc(x.email)}" class="role" style="width:auto"><option ${x.role==='member'?'selected':''}>member</option><option ${x.role==='viewer'?'selected':''}>viewer</option></select><button class="ghost rm" data-e="${esc(x.email)}">Remove</button>`:`<span class="pill ${x.role==='viewer'?'view':''}">${esc(x.role)}</span>`}</div>`).join('')}
 ${OWNER?`<form id="inv" class="row" style="margin-top:.8rem"><input type="email" placeholder="email@company.com" required style="max-width:280px"><select style="width:auto"><option>member</option><option>viewer</option></select><button class="primary">Invite</button></form><p class="muted">They sign in with this email and see the board. Members edit; viewers only read.</p>`:'<p class="muted">Only the owner manages people.</p>'}</div>`;
 const f=$('#inv');if(f)f.onsubmit=async e=>{e.preventDefault();await api(`/api/boards/${b.id}/members`,{method:'POST',body:{email:f.querySelector('input').value,role:f.querySelector('select').value}});toast('Invited');showBoard(b.id).then(()=>people(BOARD))};
 document.querySelectorAll('select.role').forEach(s=>s.onchange=async()=>{await api(`/api/boards/${b.id}/members`,{method:'POST',body:{email:s.dataset.e,role:s.value}});toast('Role changed')});
 document.querySelectorAll('button.rm').forEach(x=>x.onclick=async()=>{if(!confirm('Remove '+x.dataset.e+'?'))return;await api(`/api/boards/${b.id}/members/${encodeURIComponent(x.dataset.e)}`,{method:'DELETE'});showBoard(b.id).then(()=>people(BOARD))})}
function histHtml(h){return h.map(x=>`<div><b>${esc(x.actor)}</b> ${esc(x.action)}<br><span class="muted">${esc(String(x.at).replace('T',' ').slice(0,16))}</span>${x.changes?`<br><span class="muted">${Object.entries(x.changes).map(([k,v])=>esc(k)+': '+esc(Array.isArray(v)?JSON.stringify(v[0])+' → '+JSON.stringify(v[1]):JSON.stringify(v))).join('; ')}</span>`:''}</div>`).join('')||'<span class="muted">Nothing yet.</span>'}
async function activity(b){const h=await api(`/api/boards/${b.id}/activity`);$('#extra').innerHTML=`<div class="panel"><h2>Activity</h2><div class="hist">${histHtml(h)}</div></div>`}
async function summary(b){$('#extra').innerHTML='<div class="panel"><h2>✨ Daily summary</h2><p class="muted">Dodil Models is reading the board…</p></div>';
 try{const s=await api(`/api/boards/${b.id}/summary`);$('#extra').innerHTML=`<div class="panel"><h2>✨ Daily summary</h2><div class="summary">${esc(s.summary)}</div></div>`}catch(e){$('#extra').innerHTML=`<div class="panel err">${esc(e.message)}</div>`}}
async function openCard(id){const c=await api('/api/cards/'+encodeURIComponent(id));const b=BOARD&&BOARD.id===c.board_id?BOARD:await api('/api/boards/'+c.board_id);
 const can=b.role!=='viewer', dis=can?'':'disabled';const ppl=[b.owner,...b.members.filter(m=>m.role!=='viewer').map(m=>m.email)];
 const blk=c.blocked_by.length?await api(`/api/cards/${c.id}/blocking`):{blocking:[]};
 const s=$('#sheet');s.innerHTML=`<div class="box"><div>
 <div class="muted">#${c.number} · ${esc(b.title)} · ${esc(c.list)}</div>
 <input id="ct" value="${esc(c.title)}" ${dis} style="font-size:1.1rem;font-weight:600;margin-top:.3rem">
 <label>Description</label><textarea id="cd" ${dis} placeholder="Add more detail…">${esc(c.description)}</textarea>
 <label>Checklist</label><div id="cl">${c.checklist.map((x,i)=>`<div class="check ${x.done?'done':''}"><input type="checkbox" data-i="${esc(x.id)}" ${x.done?'checked':''} ${dis}><span>${esc(x.text)}</span></div>`).join('')||'<span class="muted">No items.</span>'}</div>
 ${can?`<div class="row" style="margin-top:.4rem"><input id="cli" placeholder="Add an item" style="flex:1"><button id="clb">Add</button><button id="clai" title="Dodil Models drafts the steps">✨ Turn into checklist</button></div>`:''}
 <label>Waiting on</label><div>${blk.blocking.length?blk.blocking.map(x=>`<div>⛔ #${x.number} ${esc(x.title)} <span class="muted">(${esc(x.list)})</span></div>`).join(''):'<span class="muted">Nothing — ready to go.</span>'}</div>
 ${can?`<div class="row" style="margin-top:.4rem"><input id="bb" placeholder="Card number, e.g. 12" style="max-width:180px"><button id="bbb">Blocked by</button></div>`:''}
 <label>Cards like this one</label><div id="sim"><button id="simb">✨ Find cards like this one</button></div>
 <label>History</label><div class="hist">${histHtml(c.history)}</div></div>
 <div><label>List</label><select id="cls" ${dis}>${b.lists.map(l=>`<option value="${esc(l.id)}" ${l.id===c.list_id?'selected':''}>${esc(l.name)}</option>`).join('')}</select>
 <label>Assignee</label><select id="ca" ${dis}><option value="">Nobody</option>${ppl.map(p=>`<option ${p===c.assignee?'selected':''}>${esc(p)}</option>`).join('')}</select>
 <label>Due date</label><input id="cdue" type="date" value="${esc(c.due_date||'')}" ${dis}>
 <label>Labels</label><input id="clab" value="${esc((c.labels||[]).join(', '))}" ${dis} placeholder="design, urgent">
 ${can?'<button class="primary" id="save" style="margin-top:1rem;width:100%">Save</button>':'<p class="muted" style="margin-top:1rem">View only.</p>'}
 <button id="close" style="margin-top:.5rem;width:100%">Close</button></div></div>`;
 s.style.display='flex';const close=()=>{s.style.display='none';if(BOARD)showBoard(BOARD.id);else route();counts()};
 $('#close').onclick=close;s.onclick=e=>{if(e.target===s)close()};
 $('#simb').onclick=async()=>{$('#simb').disabled=true;$('#simb').textContent='✨ Reading your cards…';try{const r=await api(`/api/cards/${c.id}/similar`);
  $('#sim').innerHTML=r.length?r.map(x=>`<div class="simrow" data-id="${esc(x.id)}" style="cursor:pointer;margin:.2rem 0">#${x.number} ${esc(x.title)} <span class="muted">${esc(x.board)} · ${esc(x.list)} · ${Math.round(x.similarity*100)}%</span></div>`).join(''):'<span class="muted">Nothing close.</span>';
  document.querySelectorAll('.simrow').forEach(el=>el.onclick=()=>openCard(el.dataset.id))}catch(e){$('#sim').innerHTML='<span class="err">'+esc(e.message)+'</span>'}};
 if(!can)return;
 $('#save').onclick=async()=>{await api('/api/cards/'+c.id,{method:'PATCH',body:{title:$('#ct').value,description:$('#cd').value,due_date:$('#cdue').value||null,labels:$('#clab').value.split(',').map(x=>x.trim()).filter(Boolean)}});
  if(($('#ca').value||null)!==(c.assignee||null))await api(`/api/cards/${c.id}/assign`,{method:'POST',body:{assignee:$('#ca').value||null}});
  if($('#cls').value!==c.list_id)await api(`/api/cards/${c.id}/move`,{method:'POST',body:{list:$('#cls').value}});toast('Saved');close()};
 $('#clb').onclick=async()=>{const v=$('#cli').value.trim();if(!v)return;await api(`/api/cards/${c.id}/checklist`,{method:'POST',body:{items:[v]}});openCard(c.id)};
 $('#clai').onclick=async()=>{$('#clai').disabled=true;$('#clai').textContent='✨ Thinking…';try{await api(`/api/cards/${c.id}/checklist-ai`,{method:'POST'})}finally{openCard(c.id)}};
 document.querySelectorAll('#cl input[type=checkbox]').forEach(x=>x.onchange=async()=>{await api(`/api/cards/${c.id}/checklist/${x.dataset.i}`,{method:'POST',body:{done:x.checked}});openCard(c.id)});
 $('#bbb').onclick=async()=>{const v=$('#bb').value.trim();if(!v)return;await api(`/api/cards/${c.id}/blockers`,{method:'POST',body:{blocked_by:v}});openCard(c.id)}}
$('#nb').onsubmit=async e=>{e.preventDefault();const b=await api('/api/boards',{method:'POST',body:{title:$('#nbt').value}});$('#nbt').value='';location.hash='b/'+b.id};
$('#search').onsubmit=e=>{e.preventDefault();const q=$('#q').value.trim();if(q)location.hash='search/'+encodeURIComponent(q)};
window.onhashchange=route;
(async()=>{const m=await api('/api/me');ME=m.email;$('#who').innerHTML=`${esc(ME)} · <a href="/.dodil/auth/logout">Sign out</a>`;await loadBoards();counts();route()})();
</script></body></html>"""