rgboards/rg-boards
Lanes, team task boards you run from the web or from Claude/ChatGPT (MCP). FastAPI + DataK3 + appid + Dodil Models.
A remix template · 0 remixes · Remix rgboards/rg-boards
app/boards/doors/static/index.html
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="theme-color" content="#0f172a">
<title>Lanes</title>
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'><rect width='32' height='32' rx='8' fill='%230d9488'/><rect x='7' y='8' width='4' height='16' rx='2' fill='white'/><rect x='14' y='8' width='4' height='11' rx='2' fill='white'/><rect x='21' y='8' width='4' height='7' rx='2' fill='white'/></svg>">
<style>
:root{
--bg:#f5f7fa;--panel:#fff;--ink:#0f172a;--ink2:#334155;--mute:#64748b;--faint:#94a3b8;--line:#e5e9f0;--line2:#d5dce6;
--accent:#0d9488;--accent-d:#0f766e;--accent-l:#ccfbf1;--accent-xl:#f0fdfa;
--red:#dc2626;--red-l:#fee2e2;--amber:#b45309;--amber-l:#fef3c7;--blue:#2563eb;--blue-l:#dbeafe;--slate-l:#eef2f6;
--col:#edf1f5;--r:12px;--shadow:0 1px 2px rgba(15,23,42,.06),0 1px 1px rgba(15,23,42,.04);
--shadow-h:0 8px 24px -6px rgba(15,23,42,.18);--side:16.5rem;
font-family:"Inter",ui-sans-serif,system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif}
*{box-sizing:border-box}
html,body{height:100%}
body{margin:0;font-size:14px;line-height:1.45;color:var(--ink);background:var(--bg);display:flex;flex-direction:column;-webkit-font-smoothing:antialiased}
button,input,select,textarea{font:inherit;color:inherit}
svg.i{width:16px;height:16px;flex-shrink:0;stroke:currentColor;fill:none;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;vertical-align:-3px}
.btn{display:inline-flex;align-items:center;gap:.4rem;cursor:pointer;border:1px solid var(--line2);background:#fff;border-radius:9px;padding:.42rem .75rem;font-weight:550;font-size:.86rem;color:var(--ink2);white-space:nowrap;transition:background .12s,border-color .12s,color .12s}
.btn:hover{background:#f8fafc;border-color:#c3ccd8;color:var(--ink)}
.btn.primary{background:var(--accent);border-color:var(--accent);color:#fff}.btn.primary:hover{background:var(--accent-d)}
.btn.ghost{border-color:transparent;background:transparent;color:var(--mute)}.btn.ghost:hover{background:var(--slate-l);color:var(--ink)}
.btn.danger{color:var(--red)}.btn.danger:hover{background:var(--red-l);border-color:#fecaca}
.btn.sm{padding:.26rem .55rem;font-size:.8rem;border-radius:7px}
.btn.icon{padding:.4rem;border-radius:8px}
.btn:disabled{opacity:.55;cursor:default}
.btn:focus-visible,.card:focus-visible,a:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
input,select,textarea{border:1px solid var(--line2);border-radius:9px;padding:.48rem .65rem;background:#fff;width:100%}
input:focus,select:focus,textarea:focus{outline:none;border-color:var(--accent);box-shadow:0 0 0 3px rgba(13,148,136,.15)}
textarea{min-height:6.5rem;resize:vertical;line-height:1.5}
/* ---------- top bar ---------- */
header{height:56px;display:flex;align-items:center;gap:.75rem;padding:0 1rem;background:#0f172a;color:#e2e8f0;flex-shrink:0;position:relative;z-index:5}
.logo{display:flex;align-items:center;gap:.55rem;font-weight:750;font-size:1.08rem;letter-spacing:-.015em;color:#fff;text-decoration:none}
.logo i{display:inline-flex;gap:3px;align-items:flex-start;background:var(--accent);padding:5px 6px;border-radius:7px}
.logo i b{display:block;width:4px;border-radius:2px;background:#fff}
#menu{display:none;color:#cbd5e1}#menu:hover{background:rgba(255,255,255,.08);color:#fff}
.search{flex:1;max-width:30rem;display:flex;align-items:center;gap:.4rem;background:rgba(255,255,255,.07);border:1px solid rgba(255,255,255,.12);border-radius:10px;padding:0 .3rem 0 .6rem;margin-left:1rem}
.search:focus-within{border-color:rgba(94,234,212,.55);background:rgba(255,255,255,.1)}
.search svg{color:#94a3b8}
.search input{background:transparent;border:none;color:#f1f5f9;padding:.45rem 0;box-shadow:none;min-width:0}
.search input::placeholder{color:#8392a7}
.seg{display:flex;background:rgba(255,255,255,.08);border-radius:7px;padding:2px;flex-shrink:0}
.seg button{border:none;background:transparent;color:#94a3b8;font-size:.72rem;font-weight:600;padding:.2rem .5rem;border-radius:5px;cursor:pointer}
.seg button.on{background:rgba(255,255,255,.16);color:#fff}
kbd{font:600 .68rem ui-monospace,monospace;color:#94a3b8;border:1px solid rgba(255,255,255,.18);border-radius:4px;padding:0 .3rem}
.sp{flex:1}
.me{display:flex;align-items:center;gap:.5rem;color:#cbd5e1;font-size:.84rem;min-width:0}
.me .av,.side .av,.mem .av{margin-left:0}
.me span.e{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:15rem}
.signout{color:#94a3b8;text-decoration:none;font-size:.82rem;padding:.3rem .5rem;border-radius:7px}.signout:hover{color:#fff;background:rgba(255,255,255,.08)}
/* ---------- layout ---------- */
main{flex:1;display:flex;min-height:0}
nav{width:var(--side);background:var(--panel);border-right:1px solid var(--line);padding:.9rem .7rem;overflow:auto;flex-shrink:0;display:flex;flex-direction:column}
nav .grp{font-size:.68rem;text-transform:uppercase;letter-spacing:.08em;color:var(--faint);font-weight:700;margin:1.1rem .55rem .35rem}
nav a{display:flex;align-items:center;gap:.55rem;padding:.42rem .55rem;border-radius:8px;color:var(--ink2);text-decoration:none;font-weight:500;margin-bottom:1px}
nav a:hover{background:var(--slate-l);color:var(--ink)}
nav a.on{background:var(--accent-xl);color:var(--accent-d);font-weight:650;box-shadow:inset 2px 0 0 var(--accent)}
nav a .bt{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
nav a .sq{width:.6rem;height:.6rem;border-radius:3px;flex-shrink:0}
.cnt{font-size:.7rem;font-weight:700;border-radius:999px;padding:.02rem .42rem;background:var(--slate-l);color:var(--mute)}
.cnt.red{background:var(--red-l);color:var(--red)}
.rp{font-size:.6rem;font-weight:750;letter-spacing:.04em;text-transform:uppercase;border-radius:5px;padding:.1rem .35rem}
.ri{display:inline-flex;width:1.35rem;height:1.35rem;border-radius:6px;align-items:center;justify-content:center;flex-shrink:0}
.ri svg.i{width:13px;height:13px}
.ri.owner{background:var(--accent-l);color:var(--accent-d)}.ri.member{background:var(--blue-l);color:var(--blue)}.ri.viewer{background:var(--slate-l);color:var(--mute)}
.rp.owner{background:var(--accent-l);color:var(--accent-d)}.rp.member{background:var(--blue-l);color:var(--blue)}.rp.viewer{background:var(--slate-l);color:var(--mute)}
#nb{margin-top:.5rem}#nb input{border-style:dashed;background:#fbfcfd}
.navfoot{margin-top:auto;padding:.9rem .55rem 0;font-size:.76rem;color:var(--mute);line-height:1.5}
.navfoot code{font-size:.72rem;background:var(--slate-l);border-radius:4px;padding:.05rem .3rem;word-break:break-all}
#scrim{display:none}
#view{flex:1;overflow:auto;min-width:0;display:flex;flex-direction:column}
/* ---------- board header ---------- */
.bh{padding:1.1rem 1.5rem .2rem;display:flex;align-items:center;gap:.75rem;flex-wrap:wrap}
.bh h1{margin:0;font-size:1.45rem;letter-spacing:-.025em;font-weight:750}
.role{display:inline-flex;align-items:center;gap:.35rem;font-size:.74rem;font-weight:650;border-radius:999px;padding:.24rem .65rem;border:1px solid transparent}
.role.owner{background:var(--accent-xl);color:var(--accent-d);border-color:#99f6e4}
.role.member{background:#eff6ff;color:var(--blue);border-color:#bfdbfe}
.role.viewer{background:#f8fafc;color:var(--ink2);border-color:var(--line2)}
.stack{display:flex;padding-left:.3rem}.stack .av{margin-left:-.22rem;box-shadow:0 0 0 2px var(--bg)}
.acts{display:flex;gap:.4rem;margin-left:auto;flex-wrap:wrap}
.stats{display:flex;gap:1.3rem;padding:.55rem 1.5rem .9rem;flex-wrap:wrap;font-size:.82rem;color:var(--mute)}
.stats span b{color:var(--ink);font-weight:700;margin-right:.25rem;font-variant-numeric:tabular-nums}
.stats .red b{color:var(--red)}.stats .amber b{color:var(--amber)}
.notice{margin:0 1.5rem .9rem;display:flex;gap:.6rem;align-items:flex-start;background:#fff;border:1px solid var(--line2);border-left:3px solid var(--faint);border-radius:10px;padding:.65rem .85rem;color:var(--ink2);font-size:.86rem}
.notice svg{margin-top:.1rem;color:var(--mute)}
.ai{margin:0 1.5rem 1rem;background:linear-gradient(135deg,#f0fdfa 0%,#f5f3ff 100%);border:1px solid #c7d2fe;border-radius:12px;padding:.8rem 1rem;font-size:.88rem;color:var(--ink2)}
.ai h4{margin:0 0 .35rem;font-size:.8rem;display:flex;align-items:center;gap:.4rem;color:#4338ca}
.ai .txt{white-space:pre-wrap}
.ai .meta{margin-top:.4rem;font-size:.75rem;color:var(--mute)}
.ai .meta a{color:var(--accent-d)}
/* ---------- columns ---------- */
.cols{display:flex;gap:.85rem;align-items:flex-start;padding:0 1.5rem 1.5rem;flex:1}
.col{width:18.5rem;flex-shrink:0;background:var(--col);border-radius:14px;display:flex;flex-direction:column;max-height:calc(100vh - 13.5rem);border:1px solid #e3e8ee}
.col-h{display:flex;align-items:center;gap:.45rem;padding:.7rem .8rem .45rem;font-weight:650;font-size:.84rem;color:var(--ink2)}
.col-h .dot{width:.55rem;height:.55rem;border-radius:50%;background:var(--faint)}
.col-h .n{margin-left:auto;font-size:.72rem;color:var(--mute);font-weight:600;background:rgba(255,255,255,.7);border-radius:999px;padding:0 .45rem}
.col.done .col-h .dot{background:#10b981}.col.doing .col-h .dot{background:#3b82f6}.col.review .col-h .dot{background:#a855f7}.col.todo .col-h .dot{background:#f59e0b}
.drop{flex:1;overflow-y:auto;padding:.15rem .5rem .3rem;min-height:2.6rem;border-radius:10px;transition:background .12s}
.drop.over{background:rgba(13,148,136,.08)}
.ph{height:3px;border-radius:2px;background:var(--accent);margin:-.15rem 0 .35rem;box-shadow:0 0 0 3px rgba(13,148,136,.15)}
.none{border:1.5px dashed #cdd5df;border-radius:10px;color:var(--faint);font-size:.8rem;text-align:center;padding:1rem .6rem;margin-bottom:.4rem}
.card{position:relative;background:#fff;border-radius:10px;padding:.6rem .7rem .55rem;margin-bottom:.45rem;box-shadow:var(--shadow);border:1px solid #e6eaf0;cursor:pointer;transition:box-shadow .12s,transform .12s,border-color .12s}
.card:hover{box-shadow:var(--shadow-h);border-color:#d5dce6}
.card.late{box-shadow:inset 3px 0 0 var(--red),var(--shadow)}.card.late:hover{box-shadow:inset 3px 0 0 var(--red),var(--shadow-h)}
.card.blocked{box-shadow:inset 3px 0 0 #f59e0b,var(--shadow)}.card.blocked:hover{box-shadow:inset 3px 0 0 #f59e0b,var(--shadow-h)}
.card.dragging{opacity:.35;transform:rotate(1deg)}
.card[draggable=true]{cursor:grab}
.labels{display:flex;gap:.25rem;flex-wrap:wrap;margin-bottom:.35rem}
.lab{font-size:.66rem;font-weight:650;border-radius:5px;padding:.06rem .4rem;letter-spacing:.01em}
.card .t{font-weight:560;color:var(--ink);word-wrap:break-word;line-height:1.38}
.col.done .card .t{color:var(--mute);text-decoration:line-through;text-decoration-color:#cbd5e1}
.foot{display:flex;align-items:center;gap:.35rem;flex-wrap:wrap;margin-top:.5rem;font-size:.72rem;color:var(--mute)}
.foot .num{font-variant-numeric:tabular-nums;color:var(--faint);font-weight:600}
.chip{display:inline-flex;align-items:center;gap:.22rem;border-radius:6px;padding:.08rem .38rem;font-size:.7rem;font-weight:600;white-space:nowrap;background:var(--slate-l);color:var(--ink2)}
.chip svg.i{width:12px;height:12px;vertical-align:0}
.chip.late{background:var(--red-l);color:var(--red)}.chip.soon{background:var(--amber-l);color:var(--amber)}
.chip.blk{background:var(--amber-l);color:var(--amber)}.chip.ok{background:#dcfce7;color:#15803d}
.av{margin-left:auto;width:1.55rem;height:1.55rem;border-radius:50%;display:inline-flex;align-items:center;justify-content:center;color:#fff;font-size:.6rem;font-weight:700;flex-shrink:0;letter-spacing:.02em}
.addc{padding:.1rem .5rem .55rem}
.addc input{background:transparent;border-color:transparent;padding:.4rem .5rem;color:var(--mute)}
.addc input:hover{background:rgba(255,255,255,.6)}
.addc input:focus{background:#fff;color:var(--ink)}
.newcol{width:16rem;flex-shrink:0}
.newcol input{background:rgba(255,255,255,.55);border:1.5px dashed #cdd5df;padding:.6rem .75rem}
/* ---------- list views ---------- */
.lv{padding:0 1.5rem 2rem;max-width:56rem}
.lv h3{font-size:.76rem;text-transform:uppercase;letter-spacing:.07em;color:var(--faint);margin:1.2rem 0 .5rem;font-weight:700}
.lv .card{display:flex;gap:.8rem;align-items:center;padding:.65rem .85rem}
.lv .card .t{flex:1;min-width:0}
.lv .card .foot{margin:0;flex-wrap:nowrap}
.sub{color:var(--mute);font-size:.86rem}
.empty{margin:4rem auto;max-width:28rem;text-align:center;color:var(--mute)}
.empty .big{width:3.2rem;height:3.2rem;border-radius:14px;background:var(--accent-xl);color:var(--accent);display:inline-flex;align-items:center;justify-content:center;margin-bottom:.8rem}
.empty .big svg{width:24px;height:24px}
.empty h2{color:var(--ink);margin:.2rem 0 .4rem;font-size:1.2rem;letter-spacing:-.01em}
.empty code{background:var(--slate-l);border-radius:5px;padding:.1rem .35rem;font-size:.8rem}
.skel{border-radius:10px;background:linear-gradient(90deg,#e6ebf0 25%,#f1f4f7 50%,#e6ebf0 75%);background-size:200% 100%;animation:sk 1.3s infinite linear;margin-bottom:.45rem}
@keyframes sk{to{background-position:-200% 0}}
/* ---------- dialog ---------- */
dialog{border:none;border-radius:16px;padding:0;width:min(58rem,96vw);max-height:90vh;box-shadow:0 30px 80px -10px rgba(15,23,42,.45);color:var(--ink)}
dialog.narrow{width:min(34rem,96vw)}
dialog::backdrop{background:rgba(15,23,42,.45);backdrop-filter:blur(2px)}
.dh{display:flex;align-items:center;gap:.6rem;padding:.9rem 1.2rem .3rem}
.dh .crumb{font-size:.8rem;color:var(--mute);display:flex;gap:.35rem;align-items:center;flex-wrap:wrap}
.dh .x{margin-left:auto}
.db{display:grid;grid-template-columns:minmax(0,1fr) 15.5rem;gap:1.5rem;padding:.3rem 1.2rem 1.3rem;overflow:auto;max-height:calc(90vh - 3.5rem)}
dialog.narrow .db{display:block}
.ttl{display:block;width:calc(100% + .45rem);min-height:0;resize:none;overflow:hidden;line-height:1.3;font-size:1.25rem;font-weight:700;letter-spacing:-.015em;border:1px solid transparent;padding:.3rem .45rem;margin-left:-.45rem;border-radius:8px}
.ttl:hover:not(:disabled){border-color:var(--line2)}
.ttl:disabled,.ro{background:transparent;color:var(--ink);border-color:transparent}
.sec{margin-top:1.2rem}
.sec>h4{display:flex;align-items:center;gap:.4rem;margin:0 0 .5rem;font-size:.8rem;text-transform:uppercase;letter-spacing:.06em;color:var(--mute);font-weight:700}
.sec>h4 .btn{margin-left:auto;text-transform:none;letter-spacing:0}
.desc-ro{white-space:pre-wrap;color:var(--ink2);background:#f8fafc;border-radius:9px;padding:.6rem .75rem;min-height:2.5rem}
.prog{height:6px;border-radius:3px;background:var(--slate-l);overflow:hidden;margin-bottom:.5rem}.prog b{display:block;height:100%;background:#10b981;border-radius:3px}
.chk{display:flex;gap:.55rem;align-items:center;padding:.3rem .35rem;border-radius:7px}
.chk:hover{background:#f8fafc}
.chk input{width:1rem;height:1rem;accent-color:var(--accent);flex-shrink:0}
.chk.done span{text-decoration:line-through;color:var(--faint)}
.rowl{display:flex;align-items:center;gap:.5rem;padding:.35rem .45rem;border-radius:8px;border:1px solid var(--line);margin-bottom:.35rem;font-size:.86rem}
.rowl .num{color:var(--faint);font-weight:650;font-variant-numeric:tabular-nums}
.rowl .grow{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
a.lnk{color:var(--ink2);text-decoration:none}a.lnk:hover{color:var(--accent-d);text-decoration:underline}
.callout{background:#fffbeb;border:1px solid #fde68a;border-radius:10px;padding:.65rem .8rem;font-size:.85rem;color:#78350f;margin-top:.5rem}
.callout.ok{background:#f0fdf4;border-color:#bbf7d0;color:#166534}
.callout ol{margin:.3rem 0 0;padding-left:1.1rem}
.tl{position:relative;padding-left:1rem}
.tl:before{content:"";position:absolute;left:.3rem;top:.4rem;bottom:.4rem;width:2px;background:var(--line)}
.ev{position:relative;padding:.25rem 0 .55rem .55rem;font-size:.84rem;color:var(--ink2)}
.ev:before{content:"";position:absolute;left:-.88rem;top:.55rem;width:.55rem;height:.55rem;border-radius:50%;background:#fff;border:2px solid var(--line2)}
.ev b{font-weight:600;color:var(--ink)}
.ev .chg{display:block;color:var(--mute);font-size:.8rem;margin-top:.1rem;word-break:break-word}
.ev .chg s{color:var(--faint)}
.ev time{color:var(--faint);font-size:.75rem;margin-left:.2rem}
.side .fld{margin-bottom:.8rem}
.side label{display:block;font-size:.7rem;color:var(--mute);margin-bottom:.25rem;text-transform:uppercase;letter-spacing:.06em;font-weight:700}
.side .val{padding:.35rem 0;font-weight:500}
.side .actions{display:flex;flex-direction:column;gap:.45rem;margin-top:1rem;padding-top:1rem;border-top:1px solid var(--line)}
.side .actions .btn{justify-content:center}
.lock{display:flex;gap:.5rem;background:#f8fafc;border:1px solid var(--line2);border-radius:10px;padding:.6rem .7rem;font-size:.8rem;color:var(--ink2);margin-bottom:1rem}
.mem{display:flex;align-items:center;gap:.6rem;padding:.5rem 0;border-bottom:1px solid var(--line)}
.mem .av{margin:0}.mem .e{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis}
.rolesel{width:auto;padding:.22rem .4rem;font-size:.8rem}
.inv{display:flex;gap:.45rem;margin-top:1rem}.inv select{width:7.5rem;flex-shrink:0}
.hint{font-size:.78rem;color:var(--mute);margin-top:.5rem}
.toast{position:fixed;bottom:1.2rem;left:50%;transform:translate(-50%,8px);background:#0f172a;color:#fff;padding:.6rem 1rem;border-radius:10px;font-size:.86rem;opacity:0;transition:opacity .18s,transform .18s;pointer-events:none;z-index:50;max-width:90vw;box-shadow:0 10px 30px rgba(15,23,42,.3)}
.toast.show{opacity:1;transform:translate(-50%,0)}.toast.err{background:#991b1b}
.spin{width:14px;height:14px;border:2px solid currentColor;border-right-color:transparent;border-radius:50%;display:inline-block;animation:rot .7s linear infinite;vertical-align:-2px}
@keyframes rot{to{transform:rotate(360deg)}}
/* ---------- small screens ---------- */
@media(max-width:860px){
#menu{display:inline-flex}
header{gap:.4rem;padding:0 .5rem}.logo span.w,.search kbd,.me,.signout span{display:none}
.search{margin-left:0;min-width:0;padding-left:.45rem;flex:1;max-width:none}.sp{display:none}.seg button{padding:.2rem .4rem;font-size:.68rem}
nav{position:fixed;top:56px;bottom:0;left:0;z-index:20;transform:translateX(-100%);transition:transform .2s,visibility .2s;visibility:hidden}
body.nav-open nav{transform:none;visibility:visible;box-shadow:0 10px 40px rgba(15,23,42,.25)}
body.nav-open #scrim{display:block;position:fixed;inset:56px 0 0 0;background:rgba(15,23,42,.35);z-index:15}
.bh{padding:.9rem 1rem .2rem}.bh h1{font-size:1.25rem}
.acts{margin-left:0;width:100%}.acts .btn span.l{display:none}
.stats{padding:.5rem 1rem .8rem;gap:.9rem}.notice,.ai{margin-left:1rem;margin-right:1rem}
.cols{padding:0 1rem 1rem;scroll-snap-type:x mandatory;scroll-padding-left:1rem;overflow-x:auto;gap:.7rem}
#view{overflow-x:hidden}
.col{width:85vw;max-height:none;scroll-snap-align:start}
.newcol{width:70vw}
.db{grid-template-columns:1fr;max-height:calc(100vh - 3.5rem)}
dialog{width:100vw;max-width:100vw;max-height:100vh;height:100%;border-radius:0;margin:0}
.lv{padding:0 1rem 2rem}
.lv .card{flex-wrap:wrap}.lv .card .foot{flex-wrap:wrap}
}
@media(prefers-reduced-motion:reduce){*{animation:none!important;transition:none!important}}
</style>
</head>
<body>
<header>
<button class="btn icon ghost" id="menu" aria-label="Boards"><svg class="i"><use href="#i-menu"/></svg></button>
<a class="logo" href="#/"><i><b style="height:14px"></b><b style="height:10px"></b><b style="height:6px"></b></i><span class="w">Lanes</span></a>
<div class="search">
<svg class="i"><use href="#i-search"/></svg>
<input id="q" placeholder="Search cards" aria-label="Search cards" autocomplete="off">
<kbd>/</kbd>
<div class="seg" role="group" aria-label="Search mode"><button type="button" class="on" data-mode="words">Words</button><button type="button" data-mode="meaning" title="Search by meaning with Dodil Models">Meaning</button></div>
</div>
<div class="sp"></div>
<div class="me" id="who"></div>
<a class="signout" href="/.dodil/auth/logout" title="Sign out"><svg class="i"><use href="#i-out"/></svg> <span>Sign out</span></a>
</header>
<main>
<nav id="nav">
<a href="#/mine" data-route="#/mine"><svg class="i"><use href="#i-user"/></svg><span class="bt">My cards</span><span id="mine-n"></span></a>
<a href="#/overdue" data-route="#/overdue"><svg class="i"><use href="#i-clock"/></svg><span class="bt">Overdue</span><span id="od-n"></span></a>
<a href="#/blocked" data-route="#/blocked"><svg class="i"><use href="#i-link"/></svg><span class="bt">Blocked</span><span id="bl-n"></span></a>
<div class="grp">Boards</div>
<div id="boards"><div class="skel" style="height:1.9rem"></div><div class="skel" style="height:1.9rem"></div></div>
<form id="nb"><input id="nbt" placeholder="+ New board" aria-label="New board name" maxlength="200"></form>
<div class="navfoot">You own the boards you create and invite people as members or viewers.<br><br>Ask Claude or ChatGPT: connect <code id="mcpurl"></code></div>
</nav>
<div id="scrim"></div>
<section id="view">
<div class="bh"><div class="skel" style="height:2rem;width:14rem;margin:0"></div></div>
<div class="cols" style="margin-top:1.4rem">
<div class="col" style="background:none;border:none"><div class="skel" style="height:4.6rem"></div><div class="skel" style="height:3.6rem"></div><div class="skel" style="height:4.6rem"></div></div>
<div class="col" style="background:none;border:none"><div class="skel" style="height:3.6rem"></div><div class="skel" style="height:4.6rem"></div></div>
<div class="col" style="background:none;border:none"><div class="skel" style="height:4.6rem"></div></div>
</div>
</section>
</main>
<dialog id="dlg"></dialog>
<div class="toast" id="toast" role="status" aria-live="polite"></div>
<svg width="0" height="0" style="position:absolute" aria-hidden="true">
<symbol id="i-menu" viewBox="0 0 24 24"><path d="M4 6h16M4 12h16M4 18h16"/></symbol>
<symbol id="i-search" viewBox="0 0 24 24"><circle cx="11" cy="11" r="7"/><path d="m20 20-3.5-3.5"/></symbol>
<symbol id="i-out" viewBox="0 0 24 24"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4M16 17l5-5-5-5M21 12H9"/></symbol>
<symbol id="i-user" viewBox="0 0 24 24"><circle cx="12" cy="8" r="4"/><path d="M4 21a8 8 0 0 1 16 0"/></symbol>
<symbol id="i-users" viewBox="0 0 24 24"><circle cx="9" cy="8" r="4"/><path d="M1 21a8 8 0 0 1 16 0M17 4a4 4 0 0 1 0 8M23 21a8 8 0 0 0-5-7.4"/></symbol>
<symbol id="i-clock" viewBox="0 0 24 24"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/></symbol>
<symbol id="i-cal" viewBox="0 0 24 24"><rect x="3" y="5" width="18" height="16" rx="2"/><path d="M3 10h18M8 3v4M16 3v4"/></symbol>
<symbol id="i-link" viewBox="0 0 24 24"><path d="M10 13a5 5 0 0 0 7.5.5l3-3a5 5 0 0 0-7-7l-1.7 1.7"/><path d="M14 11a5 5 0 0 0-7.5-.5l-3 3a5 5 0 0 0 7 7l1.7-1.7"/></symbol>
<symbol id="i-spark" viewBox="0 0 24 24"><path d="M12 3l1.8 5.2L19 10l-5.2 1.8L12 17l-1.8-5.2L5 10l5.2-1.8zM19 15l.8 2.2L22 18l-2.2.8L19 21l-.8-2.2L16 18l2.2-.8z"/></symbol>
<symbol id="i-hist" viewBox="0 0 24 24"><path d="M3 12a9 9 0 1 0 3-6.7L3 8"/><path d="M3 3v5h5M12 7v5l3 2"/></symbol>
<symbol id="i-eye" viewBox="0 0 24 24"><path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7S2 12 2 12z"/><circle cx="12" cy="12" r="3"/></symbol>
<symbol id="i-crown" viewBox="0 0 24 24"><path d="M3 18h18M4 15 3 6l5 4 4-6 4 6 5-4-1 9z"/></symbol>
<symbol id="i-pen" viewBox="0 0 24 24"><path d="M12 20h9M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4z"/></symbol>
<symbol id="i-x" viewBox="0 0 24 24"><path d="M18 6 6 18M6 6l12 12"/></symbol>
<symbol id="i-check" viewBox="0 0 24 24"><path d="m20 6-11 11-5-5"/></symbol>
<symbol id="i-list" viewBox="0 0 24 24"><path d="M9 6h12M9 12h12M9 18h12M4 6h.01M4 12h.01M4 18h.01"/></symbol>
<symbol id="i-text" viewBox="0 0 24 24"><path d="M4 6h16M4 12h16M4 18h10"/></symbol>
<symbol id="i-box" viewBox="0 0 24 24"><path d="M21 8v13H3V8M1 3h22v5H1zM10 12h4"/></symbol>
<symbol id="i-lock" viewBox="0 0 24 24"><rect x="4" y="11" width="16" height="10" rx="2"/><path d="M8 11V7a4 4 0 0 1 8 0v4"/></symbol>
<symbol id="i-board" viewBox="0 0 24 24"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M9 3v18M15 3v12"/></symbol>
</svg>
<script>
'use strict';
const $=s=>document.querySelector(s),$$=s=>[...document.querySelectorAll(s)];
const esc=s=>String(s??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
const ic=(n,cls='')=>`<svg class="i ${cls}"><use href="#i-${n}"/></svg>`;
const hue=s=>{let h=7;for(const c of String(s))h=(h*31+c.charCodeAt(0))%360;return h};
const initials=e=>(e||'?').split('@')[0].split(/[._-]/).filter(Boolean).map(x=>x[0]).join('').slice(0,2).toUpperCase();
const nameOf=e=>(e||'').split('@')[0].split(/[._-]/).filter(Boolean).map(w=>w[0].toUpperCase()+w.slice(1)).join(' ');
const dlg=$('#dlg');
let S={board:null,me:null,card:null,mode:'words',drag:null};
function toast(m,err){const t=$('#toast');t.textContent=m;t.className='toast show'+(err?' err':'');clearTimeout(t._h);t._h=setTimeout(()=>t.className='toast'+(err?' err':''),err?4200:2400)}
async function api(u,o={}){
const r=await fetch(u,{headers:{'content-type':'application/json'},...o,body:o.body?JSON.stringify(o.body):undefined});
if(r.status===401){location.href='/.dodil/auth/login?return_to='+encodeURIComponent(location.pathname+location.hash);throw new Error('signed out')}
const d=r.status===204?null:await r.json().catch(()=>null);
if(!r.ok){let m=(d&&d.detail)||r.statusText;if(typeof m!=='string')m=(m[0]&&m[0].msg)||JSON.stringify(m);toast(m,true);throw new Error(m)}
return d}
const busy=async(btn,fn)=>{const h=btn.innerHTML;btn.disabled=true;btn.innerHTML='<span class="spin"></span> '+btn.textContent.trim();try{return await fn()}finally{btn.disabled=false;btn.innerHTML=h}};
// ---------- small pieces
const MON=['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
const fmtDate=d=>{if(!d)return'';const[y,m,dd]=d.split('-').map(Number);return MON[m-1]+' '+dd+(y!==new Date().getFullYear()?' '+y:'')};
const daysTo=d=>{const t=new Date();t.setHours(0,0,0,0);return Math.round((new Date(d+'T00:00:00')-t)/864e5)};
function ago(iso){if(!iso)return'';const t=new Date(iso),s=(Date.now()-t)/1e3;
if(s<60)return'just now';if(s<3600)return Math.floor(s/60)+'m ago';if(s<86400)return Math.floor(s/3600)+'h ago';
if(s<604800)return Math.floor(s/86400)+'d ago';return MON[t.getMonth()]+' '+t.getDate()}
const fullTime=iso=>iso?new Date(iso).toLocaleString([], {dateStyle:'medium',timeStyle:'short'}):'';
const lab=l=>`<span class="lab" style="background:hsl(${hue(l)} 85% 93%);color:hsl(${hue(l)} 55% 32%)">${esc(l)}</span>`;
const av=(e,size)=>e?`<span class="av" title="${esc(e)}" style="background:hsl(${hue(e)} 45% 46%)${size?';width:'+size+';height:'+size:''}">${esc(initials(e))}</span>`:'';
const ROLE={owner:['crown','Owner','You manage members and edit everything'],member:['pen','Member','You can add, edit and move cards'],viewer:['eye','Viewer','Read only: you can see everything but change nothing']};
const roleTag=r=>`<span class="role ${r}" title="${esc(ROLE[r][2])}">${ic(ROLE[r][0])}${ROLE[r][1]}${r==='viewer'?' · read only':''}</span>`;
const listKind=n=>{n=n.toLowerCase();return n==='done'?'done':n.includes('doing')||n.includes('progress')?'doing':n.includes('review')?'review':n.includes('to do')||n==='todo'?'todo':''};
function dueChip(c,doneList){if(!c.due_date)return'';const n=daysTo(c.due_date);
if(c.overdue)return`<span class="chip late" title="Due ${esc(c.due_date)}">${ic('clock')}Overdue · ${fmtDate(c.due_date)}</span>`;
if(c.list_name===doneList)return`<span class="chip ok" title="Due ${esc(c.due_date)}">${ic('check')}${fmtDate(c.due_date)}</span>`;
const soon=n>=0&&n<=2;return`<span class="chip ${soon?'soon':''}" title="Due ${esc(c.due_date)}">${ic('cal')}${n===0?'Due today':n===1?'Due tomorrow':fmtDate(c.due_date)}</span>`}
function tile(c,o={}){
const blocked=c.blocked_by&&c.blocked_by.length,drag=!!o.drag;
return`<div class="card ${c.overdue?'late':''} ${blocked?'blocked':''}" tabindex="0" draggable="${drag}" data-id="${esc(c.id)}">
${(c.labels||[]).length?`<div class="labels">${c.labels.map(lab).join('')}</div>`:''}
<div class="t">${esc(c.title)}</div>
<div class="foot"><span class="num">#${c.number}</span>${o.where?`<span class="chip">${ic('board')}${esc(c.board)} · ${esc(c.list_name)}</span>`:''}
${dueChip(c,o.doneList)}
${blocked?`<span class="chip blk" title="Waiting on ${esc(c.blocked_by.map(b=>'#'+b.number+' '+b.title).join(', '))}">${ic('link')}Blocked by ${c.blocked_by.map(b=>'#'+b.number).join(', ')}</span>`:''}
${c.description?`<span title="Has a description">${ic('text')}</span>`:''}${av(c.assignee)}</div></div>`}
// ---------- sidebar
async function loadNav(){
const [bs,mine,od,bl]=await Promise.all([api('/api/boards'),api('/api/my-cards?days=7'),api('/api/overdue'),api('/api/blocked')]);
$('#boards').innerHTML=bs.map(b=>`<a href="#/b/${esc(b.id)}" data-route="#/b/${esc(b.id)}"><span class="sq" style="background:hsl(${hue(b.title)} 55% 55%)"></span><span class="bt">${esc(b.title)}</span>${b.overdue?`<span class="cnt red" title="${b.overdue} overdue">${b.overdue}</span>`:''}<span class="ri ${b.role}" title="${esc(ROLE[b.role][1]+': '+ROLE[b.role][2])}" aria-label="${b.role}">${ic(ROLE[b.role][0])}</span></a>`).join('')
||'<div class="hint" style="padding:0 .55rem">No boards yet. Name one below.</div>';
$('#mine-n').innerHTML=mine.length?`<span class="cnt" title="due within 7 days">${mine.length}</span>`:'';
$('#od-n').innerHTML=od.length?`<span class="cnt red">${od.length}</span>`:'';
$('#bl-n').innerHTML=bl.length?`<span class="cnt">${bl.length}</span>`:'';
markNav();return bs}
function markNav(){const h=location.hash;$$('nav a').forEach(a=>a.classList.toggle('on',a.dataset.route===h))}
// ---------- a board
async function showBoard(id){
const b=await api('/api/boards/'+encodeURIComponent(id));S.board=b;loadNav();
const edit=b.role!=='viewer',all=b.lists.flatMap(l=>l.cards);
const od=all.filter(c=>c.overdue).length,bl=all.filter(c=>c.blocked_by&&c.blocked_by.length&&c.list_name!==b.done_list).length;
const wk=all.filter(c=>c.due_date&&!c.overdue&&c.list_name!==b.done_list&&daysTo(c.due_date)<=7).length;
const done=(b.lists.find(l=>l.name===b.done_list)||{cards:[]}).cards.length;
$('#view').innerHTML=`<div class="bh"><h1>${esc(b.title)}</h1>${roleTag(b.role)}
<span class="stack" title="${esc(b.members.map(m=>m.email+' · '+m.role).join('\n'))}">${b.members.slice(0,5).map(m=>av(m.email)).join('')}${b.members.length>5?`<span class="av" style="background:#94a3b8">+${b.members.length-5}</span>`:''}</span>
<div class="acts"><button class="btn" data-act="summary">${ic('spark')}<span class="l">Daily summary</span></button>
<button class="btn" data-act="activity">${ic('hist')}<span class="l">Activity</span></button>
<button class="btn" data-act="members">${ic('users')}<span class="l">Members</span> <span class="cnt">${b.members.length}</span></button></div></div>
<div class="stats"><span><b>${all.length}</b>cards</span><span><b>${done}</b>done</span><span class="${od?'red':''}"><b>${od}</b>overdue</span><span class="${bl?'amber':''}"><b>${bl}</b>blocked</span><span><b>${wk}</b>due this week</span></div>
${edit?'':`<div class="notice">${ic('eye')}<div>You're a <b>viewer</b> on this board. You can open every card and its history, but you can't add, move or edit cards. Ask the owner, <b>${esc(b.owner)}</b>, for member access.</div></div>`}
<div id="sum"></div>
<div class="cols">${b.lists.map(l=>`<div class="col ${listKind(l.name)}"><div class="col-h"><span class="dot"></span>${esc(l.name)}<span class="n">${l.cards.length}</span></div>
<div class="drop" data-list="${esc(l.name)}">${l.cards.map(c=>tile(c,{drag:edit,doneList:b.done_list})).join('')||`<div class="none">${edit?'Drop a card here':'Nothing here'}</div>`}</div>
${edit?`<form class="addc" data-list="${esc(l.name)}"><input placeholder="+ Add a card" aria-label="Add a card to ${esc(l.name)}" maxlength="200"></form>`:''}</div>`).join('')}
${edit?`<div class="newcol"><form id="nl"><input placeholder="+ Add a list" aria-label="Add a list" maxlength="200"></form></div>`:''}</div>`;
wireBoard(b,edit)}
function wireBoard(b,edit){
$$('#view .card').forEach(el=>{
el.onclick=()=>openCard(el.dataset.id);el.onkeydown=e=>{if(e.key==='Enter')openCard(el.dataset.id)};
if(!edit)return;
el.ondragstart=e=>{S.drag=el.dataset.id;el.classList.add('dragging');e.dataTransfer.effectAllowed='move';e.dataTransfer.setData('text/plain',el.dataset.id)};
el.ondragend=()=>{el.classList.remove('dragging');$$('.ph').forEach(p=>p.remove());S.drag=null}});
if(edit)$$('.drop').forEach(z=>{
const beforeEl=y=>[...z.querySelectorAll('.card:not(.dragging)')].find(c=>{const r=c.getBoundingClientRect();return y<r.top+r.height/2});
z.ondragover=e=>{e.preventDefault();z.classList.add('over');$$('.ph').forEach(p=>p.remove());
const ph=document.createElement('div');ph.className='ph';const t=beforeEl(e.clientY);t?z.insertBefore(ph,t):z.appendChild(ph)};
z.ondragleave=e=>{if(!z.contains(e.relatedTarget)){z.classList.remove('over');$$('.ph').forEach(p=>p.remove())}};
z.ondrop=async e=>{e.preventDefault();z.classList.remove('over');const cid=e.dataTransfer.getData('text/plain')||S.drag;
const t=beforeEl(e.clientY);$$('.ph').forEach(p=>p.remove());if(!cid)return;
const card=$(`.card[data-id="${CSS.escape(cid)}"]`);if(card){t?z.insertBefore(card,t):z.appendChild(card);z.querySelector('.none')?.remove()}
try{await api(`/api/cards/${encodeURIComponent(cid)}/move`,{method:'POST',body:{list_name:z.dataset.list,before_card:t?t.dataset.id:null}})}finally{showBoard(b.id)}}});
$$('.addc').forEach(f=>f.onsubmit=async e=>{e.preventDefault();const i=f.querySelector('input'),v=i.value.trim();if(!v)return;i.disabled=true;
try{const c=await api(`/api/boards/${b.id}/cards`,{method:'POST',body:{title:v,list_name:f.dataset.list}});toast(`Added #${c.number}`)}finally{await showBoard(b.id);$(`.addc[data-list="${CSS.escape(f.dataset.list)}"] input`)?.focus()}});
const nl=$('#nl');if(nl)nl.onsubmit=async e=>{e.preventDefault();const v=nl.querySelector('input').value.trim();if(!v)return;
await api(`/api/boards/${b.id}/lists`,{method:'POST',body:{name:v}});showBoard(b.id)};
$$('[data-act]').forEach(btn=>btn.onclick=()=>({summary:()=>summary(false),activity,members})[btn.dataset.act]())}
// ---------- cross-board lists
async function listView(title,cards,sub,emptyMsg){S.board=null;loadNav();
const by={};cards.forEach(c=>(by[c.board]=by[c.board]||[]).push(c));
$('#view').innerHTML=`<div class="bh"><h1>${esc(title)}</h1><span class="sub">${esc(sub||'')}</span></div>
${cards.length?`<div class="lv">${Object.keys(by).map(k=>`<h3>${esc(k)}</h3>${by[k].map(c=>tile(c,{where:false})).join('')}`).join('')}</div>`
:`<div class="empty"><div class="big">${ic('check')}</div><h2>All clear</h2><p>${esc(emptyMsg)}</p></div>`}`;
$$('#view .card').forEach(el=>{el.onclick=()=>openCard(el.dataset.id);el.onkeydown=e=>{if(e.key==='Enter')openCard(el.dataset.id)}})}
// ---------- a card
async function openCard(id){
const c=await api('/api/cards/'+encodeURIComponent(id));S.card=c;
const b=S.board&&S.board.id===c.board_id?S.board:await api('/api/boards/'+c.board_id);
const ro=!c.can_edit,people=b.members.filter(m=>m.role!=='viewer'),dis=ro?'disabled':'';
const doneN=c.checklist.filter(i=>i.done).length;
dlg.className='';dlg.innerHTML=`<div class="dh"><div class="crumb">${ic('board')}${esc(c.board)} <span>›</span> ${esc(c.list_name)} <span>›</span> <b>#${c.number}</b>${c.overdue?' <span class="chip late">'+ic('clock')+'Overdue</span>':''}${c.blocked_by.length?' <span class="chip blk">'+ic('link')+'Blocked</span>':''}</div>
<button class="btn icon ghost x" data-close aria-label="Close">${ic('x')}</button></div>
<div class="db"><div>
${ro?`<h2 class="ttl ro" style="margin:0">${esc(c.title)}</h2>`:`<textarea class="ttl" id="f-title" rows="1" maxlength="200" aria-label="Title">${esc(c.title)}</textarea>`}
<div class="sec"><h4>${ic('text')}Description</h4>${ro?`<div class="desc-ro">${esc(c.description)||'<span class="sub">No description.</span>'}</div>`:`<textarea id="f-desc" placeholder="Add more detail…" maxlength="5000">${esc(c.description)}</textarea>`}</div>
<div class="sec"><h4>${ic('list')}Checklist ${c.checklist.length?`<span class="cnt">${doneN}/${c.checklist.length}</span>`:''}${ro?'':`<button class="btn sm" id="ai-chk">${ic('spark')}Turn into checklist</button>`}</h4>
${c.checklist.length?`<div class="prog"><b style="width:${Math.round(100*doneN/c.checklist.length)}%"></b></div>`:''}
${c.checklist.map(i=>`<label class="chk ${i.done?'done':''}"><input type="checkbox" data-item="${esc(i.id)}" ${i.done?'checked':''} ${dis}><span>${esc(i.text)}</span></label>`).join('')||'<div class="sub">No checklist yet.'+(ro?'':' Dodil Models can break this card into steps.')+'</div>'}</div>
<div class="sec"><h4>${ic('link')}Blocked by<button class="btn sm" id="why">What is blocking this?</button></h4>
${c.all_blockers.map(x=>`<div class="rowl"><span class="num">#${x.number}</span><a href="#" class="lnk grow" data-open="${esc(x.id)}">${esc(x.title)}</a><span class="chip">${esc(x.list_name)}</span>${ro?'':`<button class="btn sm ghost" data-unblock="${esc(x.id)}" aria-label="Remove blocker">${ic('x')}</button>`}</div>`).join('')||'<div class="sub">Not waiting on anything.</div>'}
${c.blocks.length?`<div class="sub" style="margin-top:.4rem">Blocks ${c.blocks.map(x=>`<a href="#" class="lnk" data-open="${esc(x.id)}">#${x.number} ${esc(x.title)}</a>`).join(', ')}</div>`:''}
${ro?'':`<form class="inv" id="blk"><input id="f-blk" placeholder="Blocked by card… (number, e.g. 3)" aria-label="Blocking card number"><button class="btn">Add</button></form>`}
<div id="chain"></div></div>
<div class="sec"><h4>${ic('spark')}Similar cards<button class="btn sm" id="sim-go">Find cards like this</button></h4><div id="sim"><div class="sub">Search every board you can see by meaning (Dodil Models embeddings).</div></div></div>
<div class="sec"><h4>${ic('hist')}History</h4>${timeline(c.history)}</div>
</div>
<aside class="side">
${ro?`<div class="lock">${ic('lock')}<div>Read only. Viewers can see this card and its history but not change it.</div></div>`:''}
<div class="fld"><label for="f-list">List</label>${ro?`<div class="val">${esc(c.list_name)}</div>`:`<select id="f-list">${b.lists.map(l=>`<option ${l.name===c.list_name?'selected':''}>${esc(l.name)}</option>`).join('')}</select>`}</div>
<div class="fld"><label for="f-who">Assignee</label>${ro?`<div class="val">${c.assignee?av(c.assignee,'1.3rem')+' '+esc(c.assignee):'Nobody'}</div>`:`<select id="f-who"><option value="">Nobody</option>${people.map(m=>`<option value="${esc(m.email)}" ${m.email===c.assignee?'selected':''}>${esc(m.email)}</option>`).join('')}</select>`}</div>
<div class="fld"><label for="f-due">Due date</label>${ro?`<div class="val">${c.due_date?dueChip(c,b.done_list):'None'}</div>`:`<input type="date" id="f-due" value="${esc(c.due_date||'')}">`}</div>
<div class="fld"><label for="f-labels">Labels</label>${ro?`<div class="val labels">${(c.labels||[]).map(lab).join('')||'None'}</div>`:`<input id="f-labels" value="${esc((c.labels||[]).join(', '))}" placeholder="design, bug">`}</div>
${ro?'':`<div class="actions"><button class="btn primary" id="save">${ic('check')}Save changes</button><button class="btn danger" id="arch">${ic('box')}Archive card</button></div>`}
<div class="hint">Card id <code>${esc(c.id)}</code> · ask Claude about "card ${c.number}"</div>
</aside></div>`;
if(!dlg.open)dlg.showModal();
const tt=$('#f-title');if(tt){const fit=()=>{tt.style.height='auto';tt.style.height=tt.scrollHeight+'px'};fit();tt.oninput=fit;tt.onkeydown=e=>{if(e.key==='Enter'){e.preventDefault();$('#save').click()}}}
dlg.querySelector('[data-close]').onclick=()=>dlg.close();
dlg.querySelectorAll('[data-open]').forEach(a=>a.onclick=e=>{e.preventDefault();openCard(a.dataset.open)});
dlg.querySelectorAll('[data-item]').forEach(x=>x.onchange=async()=>{try{await api(`/api/cards/${c.id}/checklist/${x.dataset.item}`,{method:'POST',body:{done:x.checked}})}finally{openCard(c.id)}});
dlg.querySelectorAll('[data-unblock]').forEach(x=>x.onclick=async()=>{await api(`/api/cards/${c.id}/blockers/${encodeURIComponent(x.dataset.unblock)}`,{method:'DELETE'});openCard(c.id);refreshBehind()});
$('#why').onclick=e=>busy(e.currentTarget,async()=>{const r=await api(`/api/cards/${c.id}/blocking`);
$('#chain').innerHTML=r.blocked?`<div class="callout"><b>Still open in the way:</b><ol>${r.open_blockers.map(x=>`<li>#${x.number} ${esc(x.title)} · ${esc(x.list_name)}${x.assignee?' · '+esc(nameOf(x.assignee)):''}${x.overdue?' · <b>overdue</b>':''}</li>`).join('')}</ol><div style="margin-top:.35rem"><b>Start with:</b> ${r.start_here.map(x=>'#'+x.number+' '+esc(x.title)).join('; ')}</div></div>`:'<div class="callout ok">Nothing open is blocking this card.</div>'});
$('#sim-go').onclick=e=>busy(e.currentTarget,async()=>{const r=await api(`/api/cards/${c.id}/similar`);
$('#sim').innerHTML=r.map(x=>`<div class="rowl"><span class="num">#${x.number}</span><a href="#" class="lnk grow" data-open="${esc(x.id)}">${esc(x.title)}</a><span class="sub">${esc(x.board)}</span><span class="chip">${Math.round(x.similarity*100)}%</span></div>`).join('')||'<div class="sub">No other cards yet.</div>';
$$('#sim [data-open]').forEach(a=>a.onclick=ev=>{ev.preventDefault();openCard(a.dataset.open)})});
if(ro)return;
$('#ai-chk').onclick=e=>busy(e.currentTarget,async()=>{await api(`/api/cards/${c.id}/checklist`,{method:'POST',body:{}});toast('Checklist added');openCard(c.id)});
$('#blk').onsubmit=async e=>{e.preventDefault();const v=$('#f-blk').value.trim();if(!v)return;await api(`/api/cards/${c.id}/blockers`,{method:'POST',body:{blocked_by:v}});openCard(c.id);refreshBehind()};
$('#save').onclick=e=>busy(e.currentTarget,saveCard);
$('#arch').onclick=async()=>{if(!confirm(`Archive #${c.number}? It leaves the board; its history is kept.`))return;await api(`/api/cards/${c.id}/archive`,{method:'POST'});dlg.close();toast(`Archived #${c.number}`);refreshBehind()};
}
function timeline(h){if(!h.length)return'<div class="sub">No changes yet.</div>';
return`<div class="tl">${h.map(e=>{const chg=e.field&&!['list','checklist','blocked_by','archived','member'].includes(e.field)&&(e.old||e.new)?`<span class="chg">${e.old?`<s>${esc(e.old)}</s> → `:''}${esc(e.new||'cleared')}</span>`:'';
return`<div class="ev"><b>${esc(nameOf(e.actor))}</b> ${esc(e.action)} <time datetime="${esc(e.at)}" title="${esc(fullTime(e.at))} · ${esc(e.actor)}">${esc(ago(e.at))}</time>${chg}</div>`}).join('')}</div>`}
async function saveCard(){const c=S.card,id=c.id;
const labels=$('#f-labels').value.split(',').map(s=>s.trim()).filter(Boolean);
await api('/api/cards/'+id,{method:'PATCH',body:{title:$('#f-title').value,description:$('#f-desc').value,due_date:$('#f-due').value,labels,version:c.version}});
if($('#f-who').value!==(c.assignee||''))await api(`/api/cards/${id}/assign`,{method:'POST',body:{assignee:$('#f-who').value||null}});
if($('#f-list').value!==c.list_name)await api(`/api/cards/${id}/move`,{method:'POST',body:{list_name:$('#f-list').value}});
toast('Saved');await openCard(id);refreshBehind()}
function refreshBehind(){S.board&&location.hash.startsWith('#/b/')?showBoard(S.board.id):route()}
// ---------- board panels
async function summary(refresh,tries=0){const box=$('#sum');if(!box)return;
box.innerHTML=`<div class="ai"><h4>${ic('spark')}Daily summary</h4><span class="spin"></span> Dodil Models is reading the board…</div>`;
let r;try{r=await api(`/api/boards/${S.board.id}/summary${refresh?'?refresh=true':''}`,{method:'POST'})}catch(e){box.innerHTML='';return}
if(r.status==='writing'&&tries<8){setTimeout(()=>summary(false,tries+1),15000);return}
box.innerHTML=`<div class="ai"><h4>${ic('spark')}Daily summary · ${esc(r.day)}</h4><div class="txt">${esc(r.summary||'Still writing… ask again in a minute.')}</div>
<div class="meta">Written by Dodil Models${r.written_at?' '+esc(ago(r.written_at)):''} · <a href="#" id="sum-re">rewrite</a> · <a href="#" id="sum-x">hide</a></div></div>`;
$('#sum-re').onclick=e=>{e.preventDefault();summary(true)};$('#sum-x').onclick=e=>{e.preventDefault();box.innerHTML=''}}
async function activity(){const a=await api(`/api/boards/${S.board.id}/activity?limit=80`);dlg.className='narrow';
dlg.innerHTML=`<div class="dh"><b>Activity · ${esc(S.board.title)}</b><button class="btn icon ghost x" data-close aria-label="Close">${ic('x')}</button></div><div class="db">${timeline(a)}</div>`;
dlg.querySelector('[data-close]').onclick=()=>dlg.close();if(!dlg.open)dlg.showModal()}
async function members(){const b=S.board,own=b.role==='owner';dlg.className='narrow';
dlg.innerHTML=`<div class="dh"><b>Members · ${esc(b.title)}</b><button class="btn icon ghost x" data-close aria-label="Close">${ic('x')}</button></div><div class="db">
${b.members.map(m=>`<div class="mem">${av(m.email)}<span class="e">${esc(m.email)}${m.email===S.me?' <span class="sub">(you)</span>':''}</span>${own&&m.role!=='owner'?`<select class="rolesel" data-role="${esc(m.email)}" aria-label="Role of ${esc(m.email)}"><option value="member" ${m.role==='member'?'selected':''}>Member</option><option value="viewer" ${m.role==='viewer'?'selected':''}>Viewer</option></select>
<button class="btn sm ghost danger" data-rm="${esc(m.email)}">Remove</button>`:`<span class="rp ${m.role}">${m.role}</span>`}</div>`).join('')}
${own?`<form class="inv" id="inv"><input id="m-email" type="email" placeholder="teammate@company.com" required aria-label="Email"><select id="m-role" aria-label="Role"><option value="member">Member</option><option value="viewer">Viewer</option></select><button class="btn primary">Invite</button></form>
<div class="hint">Members add, edit and move cards. Viewers only read. They sign in to this app with that email to see the board. Invite an existing member again to change their role.</div>`
:`<div class="hint">${ic('lock')} Only the owner, ${esc(b.owner)}, manages members.</div>`}</div>`;
dlg.querySelector('[data-close]').onclick=()=>dlg.close();
dlg.querySelectorAll('[data-rm]').forEach(x=>x.onclick=async()=>{if(!confirm(`Remove ${x.dataset.rm}? Their cards here are unassigned.`))return;
await api(`/api/boards/${b.id}/members/${encodeURIComponent(x.dataset.rm)}`,{method:'DELETE'});await showBoard(b.id);members()});
dlg.querySelectorAll('[data-role]').forEach(x=>x.onchange=async()=>{try{await api(`/api/boards/${b.id}/members`,{method:'POST',body:{email:x.dataset.role,role:x.value}});toast(`${x.dataset.role} is now a ${x.value}`)}finally{await showBoard(b.id);members()}});
const f=$('#inv');if(f)f.onsubmit=async e=>{e.preventDefault();await api(`/api/boards/${b.id}/members`,{method:'POST',body:{email:$('#m-email').value,role:$('#m-role').value}});toast('Invited');await showBoard(b.id);members()};
if(!dlg.open)dlg.showModal()}
// ---------- routing
async function route(){const h=location.hash;document.body.classList.remove('nav-open');markNav();
if(h.startsWith('#/b/'))return showBoard(decodeURIComponent(h.slice(4)));
if(h==='#/mine')return listView('My cards',await api('/api/my-cards'),'Open cards assigned to you, soonest due first','Nothing is assigned to you right now.');
if(h==='#/overdue')return listView('Overdue',await api('/api/overdue'),'Past due and not done, on every board you can see','Nothing is past due on any board you can see.');
if(h==='#/blocked')return listView('Blocked',await api('/api/blocked'),'Open cards waiting on another open card','Nothing is waiting on anything.');
const bs=await loadNav();if(bs.length){const pick=bs.find(b=>b.role!=='viewer')||bs[0];location.replace('#/b/'+pick.id);return}
S.board=null;$('#view').innerHTML=`<div class="empty"><div class="big">${ic('board')}</div><h2>Welcome to Lanes</h2>
<p>You're not on any board yet. Name your first board in the sidebar (you'll own it), or ask a teammate to invite <b>${esc(S.me)}</b>.</p>
<form id="first" class="inv" style="max-width:22rem;margin:1.2rem auto 0"><input placeholder="Board name, e.g. Launch" aria-label="Board name" maxlength="200"><button class="btn primary">Create</button></form>
<p class="hint" style="margin-top:1.4rem">Tip: connect Claude or ChatGPT to <code>${esc(location.origin)}/mcp</code> and ask "what's on my plate this week?"</p></div>`;
$('#first').onsubmit=e=>{e.preventDefault();newBoard(e.target.querySelector('input').value)}}
async function newBoard(t){t=(t||'').trim();if(!t)return;const b=await api('/api/boards',{method:'POST',body:{title:t}});$('#nbt').value='';toast('Board created: you own it');location.hash='#/b/'+b.id}
$('#nb').onsubmit=e=>{e.preventDefault();newBoard($('#nbt').value)};
$$('.seg button').forEach(b=>b.onclick=()=>{S.mode=b.dataset.mode;$$('.seg button').forEach(x=>x.classList.toggle('on',x===b));$('#q').placeholder=S.mode==='meaning'?'Describe it… e.g. "billing a customer"':'Search cards';$('#q').focus()});
$('#q').onkeydown=async e=>{if(e.key==='Escape'){e.target.value='';e.target.blur();return}if(e.key!=='Enter')return;const q=e.target.value.trim();if(!q)return;
if(S.mode==='meaning'){toast('Searching by meaning…');listView(`Cards like "${q}"`,await api('/api/similar?text='+encodeURIComponent(q)),'Search by meaning with Dodil Models','No similar cards found.')}
else listView(`Search: ${q}`,await api('/api/search?q='+encodeURIComponent(q)),'Title, description and labels','No card matches those words.')};
document.addEventListener('keydown',e=>{if(e.key==='/'&&!/INPUT|TEXTAREA|SELECT/.test(document.activeElement.tagName)&&!dlg.open){e.preventDefault();$('#q').focus()}});
$('#menu').onclick=()=>document.body.classList.toggle('nav-open');$('#scrim').onclick=()=>document.body.classList.remove('nav-open');
dlg.addEventListener('click',e=>{if(e.target===dlg)dlg.close()});
window.onhashchange=route;
$('#mcpurl').textContent=location.origin+'/mcp';
api('/api/me').then(m=>{S.me=m.email;$('#who').innerHTML=av(m.email)+`<span class="e">${esc(m.email)}</span>`;route()});
</script>
</body>
</html>