Kanban
Ornith 1.5 35B A3B · full

Reviews & generation details
Reviews
No human review yet. Unreviewed does not mean unsuccessful.
Generation context
Imported historical artifact. Its exact original instructions and lineage are not known.
Generation prompt · exact text unavailable
See the current prompt for reference. It may differ from the instructions used to generate this artifact.
Inspect original source 43,943 bytes · SHA-256 ccd6fe067cdc
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Kanban Board</title>
<style>
/* =========================================================
TUNABLE CONSTANTS (visual)
========================================================= */
:root{
--bg:#eef1f6; --board-bg:#e4e7ed; --col-bg:#f5f7fa; --col-bg-2:#ffffff;
--card-bg:#ffffff; --text:#1f2430; --text-soft:#6b7280; --text-faint:#9aa1ad;
--line:#e2e6ec; --accent:#4f6ef7; --accent-soft:#e8ecff;
--danger:#ef4444; --danger-soft:#fdeaea;
--shadow:0 1px 2px rgba(16,24,40,.06),0 1px 3px rgba(16,24,40,.1);
--shadow-lg:0 10px 25px rgba(16,24,40,.18),0 4px 10px rgba(16,24,40,.1);
--radius:12px; --radius-sm:8px; --col-w:288px;
}
*{box-sizing:border-box}
html,body{height:100%}
body{
margin:0;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;
background:var(--bg);color:var(--text);overflow:hidden;
-webkit-font-smoothing:antialiased;
}
button{font-family:inherit}
/* ===================== HEADER ===================== */
#header{
position:sticky;top:0;z-index:40;display:flex;align-items:center;gap:14px;
padding:12px 20px;background:rgba(255,255,255,.85);backdrop-filter:blur(8px);
border-bottom:1px solid var(--line);
}
#header h1{font-size:18px;margin:0;font-weight:800;letter-spacing:-.02em}
#header .brand{color:var(--accent)}
#search{
margin-left;display:flex;align-items:center;
background:var(--card-bg);border:1px solid var(--line);border-radius:20px;padding:7px 12px;min-width:220px;
}
#search input{border:0;outline:0;background:transparent;width:100%;font-size:13px;color:var(--text)}
#search svg{flex:0 0 auto;margin-right:8px}
.btn{
border:1px solid var(--line);background:var(--card-bg);color:var(--text);
border-radius:var(--radius-sm);padding:8px 13px;font-size:13px;font-weight:600;cursor:pointer;
transition:background .12s,border-color .12s,transform .05s;
}
.btn:hover{background:#f3f5f9}
.btn:active{transform:translateY(1px)}
.btn-icon{padding:6px 8px;line-height:1}
/* ===================== FILTER BAR ===================== */
#filterbar{
display:flex;align-items:center;gap:8px;flex-wrap:wrap;padding:9px 20px;
border-bottom:1px solid var(--line);background:rgba(255,255,255,.6);z-index:30;
}
.filter-chip{
display:inline-flex;align-items:center;gap:6px;padding:4px 6px 4px 8px;
border-radius:20px;font-size:12px;font-weight:600;border:1px solid var(--line);
background:var(--card-bg);cursor:pointer;color:var(--text);transition:box-shadow .12s;
}
.filter-chip:hover{box-shadow:var(--shadow)}
.filter-chip .dot{width:12px;height:12px;border-radius:50%;flex:0 0 auto}
.filter-chip .x{opacity:.5;font-size:13px}
.filter-chip .x:hover{opacity:1}
.filter-label{font-size:12px;color:var(--text-soft);font-weight:700;margin-right:2px}
#filterHint{font-size:12px;color:var(--text-faint);margin-left:auto}
/* ===================== BOARD ===================== */
#boardwrap{
position:relative;height:calc(100vh - 96px);overflow:auto;scroll-behavior:smooth;
}
#board{display:flex;gap:16px;padding:18px 20px 24px;align-items:flex-start}
/* ===================== COLUMNS ===================== */
.column{
flex:0 0 var(--col-w);width:var(--col-w);background:var(--col-bg);
border-radius:var(--radius);display:flex;flex-direction:column;max-height:100%;
border:1px solid transparent;transition:border-color .12s,box-shadow .12s,background .12s;
}
.column.drag-col{cursor:grabbing}
.column.drag-over{border-color:var(--accent);background:var(--col-bg-2);box-shadow:0 0 0 1px var(--accent) inset}
.column.wip-exceeded{background:var(--danger-soft)}
.column.wip-exceeded .col-header{color:var(--danger)}
.col-header{
display:flex;align-items:center;gap:8px;padding:10px 12px 8px;position:relative;
cursor:grab;user-select:none;
}
.col-header:active{cursor:grabbing}
.col-title{font-size:13px;font-weight:700;flex:1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;letter-spacing:-.01em}
.col-count{
font-size:11px;font-weight:700;color:var(--text-soft);background:#e9ecf1;
border-radius:10px;padding:1px 7px;min-width:20px;text-align:center;
}
.col-count.warn{background:var(--danger-soft);color:var(--danger)}
.col-menu-btn{opacity:.4;transition:opacity .12s}
.col-menu-btn:hover{opacity:1}
.wip-badge{font-size:10px;font-weight:700;color:var(--danger);border:1px solid var(--danger);border-radius:6px;padding:1px 6px;display:none}
.column.wip-exceeded .wip-badge{display:inline-flex}
.col-body{position:relative;flex:1;overflow:auto;padding:4px 10px 12px;min-height:44px}
.col-body.drag-target{background:rgba(79,110,247,.05)}
.card-list{display:flex;flex-direction:column;gap:9px;min-height:8px}
.empty-drop{
display:none;border:2px dashed var(--line);border-radius:var(--radius-sm);
padding:26px 12px;text-align:center;color:var(--text-faint);font-size:13px;font-weight:500;margin-top:4px;
}
.column.drag-over .empty-drop{border-color:var(--accent);color:var(--accent);display:block}
/* ===================== CARDS ===================== */
.card{
position:relative;background:var(--card-bg);border-radius:var(--radius-sm);
box-shadow:var(--shadow);padding:10px 11px;cursor:grab;user-select:none;
transition:box-shadow .15s transform .15s,opacity .12s;border:1px solid rgba(0,0,0,.03);
}
.card:hover{box-shadow:var(--shadow-lg);transform:translateY(-1px)}
.card:active{cursor:grabbing}
.card.dragging{opacity:.35;box-shadow:none;transform:none!important;border:1px dashed var(--line);background:#fafbfd}
.card.hidden-filter{display:none}
.card-title{font-size:13.5px;font-weight:600;line-height:1.35;margin:1px 0 6px;word-wrap:break-word}
.card-desc{font-size:12px;color:var(--text-soft);line-height:1.4;margin-bottom:8px;
display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
.card-labels{display:flex;flex-wrap:wrap;gap:5px;margin-bottom:8px}
.label-chip{width:22px;height:18px;border-radius:5px;font-size:9px;font-weight:700;color:#fff;
display:flex;align-items:center;justify-content:center;padding:0 3px}
.card-meta{display:flex;align-items:center;gap:10px}
.due{font-size:11px;font-weight:600;color:var(--text-soft);display:inline-flex;align-items:center;gap:4px}
.due.overdue{color:var(--danger)}
.due.today{color:#d97706}
.card-tools{position:absolute;top:6px;right:6px;display:flex;gap:2px;opacity:0;transition:opacity .12s}
.card:hover .card-tools{opacity:1}
.tool{border:0;background:transparent;color:var(--text-faint);cursor:pointer;font-size:14px;line-height:1;
border-radius:5px;padding:2px 5px}
.tool:hover{background:var(--danger-soft);color:var(--danger)}
.tool.edit:hover{background:#eef1f6;color:var(--text)}
.card-edit,.col-edit{
width:100%;border:0;outline:0;background:transparent;font:inherit;color:var(--text);
border-bottom:2px solid var(--accent);padding:0;
}
.card-edit{font-weight:600;font-size:13.5px}
.col-edit{font-weight:700;font-size:13px}
.add-card{
margin:4px 2px 8px;border:1px dashed var(--line);background:transparent;color:var(--text-soft);
border-radius:var(--radius-sm);padding:8px;font-size:12.5px;font-weight:600;cursor:pointer;
text-align:left;transition:background .12s,border-color .12s,color .12s;
}
.add-card:hover{background:#fff;border-color:var(--accent);color:var(--accent)}
.add-col{
flex:0 0 var(--col-w);width:var(--col-w);border:1px dashed var(--line);border-radius:var(--radius);
background:rgba(255,255,255,.35);color:var(--text-soft);cursor:pointer;
display:flex;align-items:center;justify-content:center;gap:6px;min-height:80px;font-weight:600;font-size:13.5px;
transition:background .12s,border-color .12s,color .12s;
}
.add-col:hover{background:#fff;border-color:var(--accent);color:var(--accent)}
/* ===================== DRAG GHOST ===================== */
#dragghost{
position:fixed;z-index:60;pointer-events:none;width:calc(var(--col-w) - 8px);
background:var(--card-bg);border-radius:var(--radius-sm);box-shadow:var(--shadow-lg);
padding:12px 13px;opacity:.96;transform:rotate(-1.5deg);transition:transform .1s;
}
.drop-indicator{
height:3px;border-radius:3px;background:var(--accent);margin:0;
box-shadow:0 0 0 4px rgba(79,110,247,.15);transition:height .1s;
}
/* ===================== CARD EDITOR PANEL ===================== */
#editor{
position:fixed;z-index:70;width:320px;max-width:90vw;
background:#fff;border:1px solid var(--line);border-radius:var(--radius);
box-shadow:var(--shadow-lg);padding:14px;
}
#editor .ed-title{width:100%;font-weight:700;font-size:15px;border:1px solid transparent;background:#f7f9fc;border-radius:7px;padding:8px;margin-bottom:10px}
#editor .ed-title:focus{border-color:var(--accent);background:#fff;outline:0}
#editor .ed-desc{width:100%;min-height:70px;font-size:13px;line-height:1.4;border:1px solid var(--line);border-radius:7px;padding:8px;resize:vertical;font-family:inherit}
#editor .ed-desc:focus{border-color:var(--accent);outline:0}
#editor .ed-row{margin-bottom:12px}
#editor .ed-label{font-size:11px;font-weight:700;color:var(--text-soft);text-transform:uppercase;letter-spacing:.04em;margin-bottom:6px;display:block}
#editor .ed-labels{display:flex;flex-wrap:wrap;gap:6px}
#editor .ed-lbl{display:flex;align-items:center;gap:6px;font-size:12px;cursor:pointer;border-radius:6px;padding:3px 6px;border:1px solid var(--line)}
#editor .ed-lbl.active{border-color:transparent}
#editor .ed-lbl input{display:none}
#editor .swatch{width:14px;height:14px;border-radius:4px}
#editor .ed-actions{display:flex;gap:8px;align-items:center;margin-top:6px}
#editor .ed-actions .spacer{flex:1}
#editor .ed-del{color:var(--danger);background:transparent;border:0;cursor:pointer;font-size:13px;font-weight:600}
#editor .ed-del:hover{text-decoration:underline}
/* ===================== MENU ===================== */
.menu{position:fixed;z-index:70;min-width:200px;background:#fff;border:1px solid var(--line);
border-radius:var(--radius-sm);box-shadow:var(--shadow-lg);padding:7px}
.menu .row{display:flex;align-items:center;gap:8px;padding:8px 9px;border-radius:7px;font-size:13px;cursor:pointer}
.menu .row:hover{background:#f3f5f9}
.menu .row.danger{color:var(--danger)}
.menu .row.danger:hover{background:var(--danger-soft)}
.menu .sep{height:1px;background:var(--line);margin:6px 4px}
.menu input[type=text]{border:1px solid var(--line);border-radius:6px;padding:6px 8px;font-size:13px;width:100%;outline:none}
.menu input[type=date]{border:1px solid var(--line);border-radius:6px;padding:5px 7px;font-size:12px}
.menu label.lbl{display:flex;align-items:center;gap:8px;padding:7px 9px;font-size:13px;cursor:pointer;border-radius:7px}
.menu label.lbl:hover{background:#f3f5f9}
.menu label.lbl input{accent-color:var(--accent)}
.menu .swatch{width:16px;height:16px;border-radius:4px}
/* ===================== TOAST ===================== */
#toast{
position:fixed;left:50%;bottom:26px;transform:translateX(-50%) translateY(120%);z-index:80;
display:flex;align-items:center;gap:14px;background:#1f2430;color:#fff;border-radius:10px;
padding:11px 16px;font-size:13.5px;font-weight:500;box-shadow:0 12px 30px rgba(0,0,0,.28);
transition:transform .28s cubic-bezier(.2,.8,.2,1);max-width:90vw;
}
#toast.show{transform:translateX(-50%) translateY(0)}
#toast .undo{background:#fff;color:#1f2430;border:0;border-radius:7px;padding:6px 13px;font-weight:700;cursor:pointer;font-size:13px;white-space:nowrap}
#toast .undo:hover{background:#eef1f6}
</style>
</head>
<body>
<!-- ===================== HEADER ===================== -->
<div id="header">
<h1><span class="brand">◆</span> Kanban</h1>
<div id="search">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="#6b7280" stroke-width="2.2" stroke-linecap="round"><circle cx="11" cy="11" r="7"/><path d="M21 21l-4.3-4.3"/></svg>
<input id="searchInput" type="text" placeholder="Search cards…">
</div>
<button class="btn" id="resetBtn">Reset board</button>
</div>
<!-- ===================== FILTER BAR ===================== -->
<div id="filterbar">
<span class="filter-label">Filter labels:</span>
<span id="filterChips"></span>
<span class="filter-chip" id="addFilterBtn">+ Add filter</span>
<span id="filterHint"></span>
</div>
<!-- ===================== BOARD ===================== -->
<div id="boardwrap"><div id="board"></div></div>
<!-- ===================== TOAST ===================== -->
<div id="toast"><span id="toastMsg"></span><button class="undo" id="undoBtn">Undo</button></div>
<script>
/* =========================================================
SECTION 1 — TUNABLE CONSTANTS
========================================================= */
const CFG = {
STORAGE_KEY: 'kanban.board.v2',
COL_WIDTH: 288,
CARD_GAP: 9,
SCROLL_MARGIN_H: 42,
SCROLL_MARGIN_V: 42,
SCROLL_SPEED: 16,
DRAG_THRESHOLD: 5,
LABELS: [
{ id:'urgent', color:'#ef4444', name:'Urgent' },
{ id:'bug', color:'#f97316', name:'Bug' },
{ id:'feature', color:'#22c55e', name:'Feature' },
{ id:'design', color:'#a855f7', name:'Design' },
{ id:'docs', color:'#3b82f6', name:'Docs' },
{ id:'idea', color:'#14b8a6', name:'Idea' },
],
DEFAULT_COLUMNS: [
{ id:'c_backlog', title:'Backlog', wip:0, cards:[
{ id:'k1', title:'Set up repository & CI', desc:'Initialize git, add CI pipeline and linting.', labels:['docs'], due:'' },
{ id:'k2', title:'Define data model', desc:'Draft schemas for users, tasks, labels.', labels:['design'], due:'' },
]},
{ id:'c_todo', title:'To Do', wip:0, cards:[
{ id:'k3', title:'User authentication', desc:'Email + OAuth login flow.', labels:['feature'], due:'' },
]},
{ id:'c_progress', title:'In Progress', wip:5, cards:[
{ id:'k4', title:'Kanban board drag-drop', desc:'Pointer-events based DnD with live indicator.', labels:['feature','bug'], due:'' },
]},
{ id:'c_review', title:'Review', wip:0, cards:[] },
{ id:'c_done', title:'Done', wip:0, cards:[
{ id:'k5', title:'Project kickoff', desc:'Kickoff meeting + roadmap.', labels:['docs'], due:'' },
]},
],
};
/* =========================================================
SECTION 2 — STATE (single source of truth)
========================================================= */
let board = null;
let lastSnapshot = null;
let pendingUndo = null;
let searchQuery = '';
let activeFilters = new Set();
const uid = (p='id') => p + Date.now().toString(36) + Math.random().toString(36).slice(2,7);
const deepClone = o => JSON.parse(JSON.stringify(o));
const findColumn = id => board.columns.find(c=>c.id===id);
const cardInColumn = (col, cid) => col.cards.find(c=>c.id===cid);
const labelName = id => { const l=CFG.LABELS.find(x=>x.id===id); return l?l.name:id; };
const labelColor = id => { const l=CFG.LABELS.find(x=>x.id===id); return l?l.color:'#9aa1ad'; };
/* =========================================================
SECTION 3 — PERSISTENCE
========================================================= */
function save(){ try{ localStorage.setItem(CFG.STORAGE_KEY, JSON.stringify(board)); }catch(e){} }
function load(){
try{
const raw = localStorage.getItem(CFG.STORAGE_KEY);
if(!raw) return seedDefault();
const b = JSON.parse(raw);
if(!b || !Array.isArray(b.columns)) return seedDefault();
board = { columns: b.columns.map(c=>({
id:c.id||uid('c'), title:c.title||'New Column', wip:+c.wip||0,
cards:(c.cards||[]).map(k=>({
id:k.id||uid('k'), title:k.title||'Untitled', desc:k.desc||'',
labels:Array.isArray(k.labels)?k.labels.filter(Boolean):[], due:k.due||'',
}))
})).filter(c=>c.title) };
if(!board.columns.length) return seedDefault();
return board;
}catch(e){ return seedDefault(); }
}
function seedDefault(){ return { columns: deepClone(CFG.DEFAULT_COLUMNS) }; }
/* =========================================================
SECTION 4 — SNAPSHOT / UNDO
========================================================= */
function snapshot(){ lastSnapshot = deepClone(board); }
function scheduleUndo(label){
pendingUndo = { label };
showToast(label);
}
function performUndo(){
if(!pendingUndo) return;
board = deepClone(lastSnapshot);
pendingUndo = null;
render(); hideToast();
}
/* =========================================================
SECTION 5 — RENDERING
========================================================= */
const boardEl = document.getElementById('board');
const boardWrap = document.getElementById('boardwrap');
const searchInput = document.getElementById('searchInput');
const esc = s => String(s).replace(/[&<>"']/g, c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
function cardVisible(card){
if(searchQuery){
if(!(card.title.toLowerCase().includes(searchQuery) || (card.desc||'').toLowerCase().includes(searchQuery))) return false;
}
if(activeFilters.size){
for(const f of activeFilters) if(!card.labels.includes(f)) return false;
}
return true;
}
function render(){
boardEl.innerHTML='';
board.columns.forEach(col=> boardEl.appendChild(renderColumn(col)));
const addBtn=document.createElement('button');
addBtn.className='add-col'; addBtn.innerHTML='+ Add column';
addBtn.onclick=()=>{ snapshot(); const c={id:uid('c'),title:'New Column',wip:0,cards:[]}; board.columns.push(c); save(); render(); };
boardEl.appendChild(addBtn);
updateFilterBar();
}
function renderColumn(col){
const wrap=document.createElement('div'); wrap.className='column'; wrap.dataset.col=col.id;
if(col.wip && col.wip>0 && col.cards.length>col.wip) wrap.classList.add('wip-exceeded');
const header=document.createElement('div'); header.className='col-header'; header.dataset.dragcol=col.id;
const title=document.createElement('span'); title.className='col-title'; title.textContent=col.title;
const count=document.createElement('span'); count.className='col-count';
const nVis=col.cards.filter(cardVisible).length; count.textContent=nVis;
if(col.wip&&col.wip>0&&col.cards.length>col.wip) count.classList.add('warn');
const wipBadge=document.createElement('span'); wipBadge.className='wip-badge'; wipBadge.textContent='WIP '+col.wip;
const menuBtn=document.createElement('button'); menuBtn.className='col-menu-btn btn-icon'; menuBtn.textContent='⋯';
menuBtn.onclick=e=>{ e.stopPropagation(); openColumnMenu(col, menuBtn); };
header.append(title,count,wipBadge,menuBtn);
wrap.appendChild(header);
const addCard=document.createElement('button'); addCard.className='add-card'; addCard.textContent='+ Add card';
addCard.onclick=()=>{ snapshot(); const k={id:uid('k'),title:'',desc:'',labels:[],due:''}; col.cards.push(k); save(); render(); focusCard(k.id); };
wrap.appendChild(addCard);
const body=document.createElement('div'); body.className='col-body'; body.dataset.col=col.id;
const list=document.createElement('div'); list.className='card-list'; list.dataset.col=col.id;
col.cards.forEach(k=> list.appendChild(renderCard(col,k)));
body.appendChild(list);
const empty=document.createElement('div'); empty.className='empty-drop'; empty.textContent='Drop cards here';
body.appendChild(empty);
wrap.appendChild(body);
makeColumnDraggable(header, col);
return wrap;
}
function renderCard(col, card){
const el=document.createElement('div'); el.className='card'; el.dataset.card=card.id;
if(!cardVisible(card)) el.classList.add('hidden-filter');
const t=document.createElement('div'); t.className='card-title'; t.textContent=card.title||'Untitled card';
t.onclick=e=>{ e.stopPropagation(); startTitleEdit(el, card); };
el.appendChild(t);
if(card.desc){
const d=document.createElement('div'); d.className='card-desc'; d.textContent=card.desc;
d.onclick=e=>{ e.stopPropagation(); openCardEditor(card, col, el); };
el.appendChild(d);
}
if(card.labels && card.labels.length){
const lab=document.createElement('div'); lab.className='card-labels';
card.labels.forEach(lid=>{
const chip=document.createElement('span'); chip.className='label-chip';
chip.style.background=labelColor(lid); chip.textContent=labelName(lid).slice(0,3); chip.title=labelName(lid);
chip.onclick=e=>{ e.stopPropagation(); openCardEditor(card, col, el); };
lab.appendChild(chip);
});
el.appendChild(lab);
}
const meta=document.createElement('div'); meta.className='card-meta';
if(card.due){
const due=document.createElement('span'); due.className='due';
due.classList.add(isOverdue(card.due)?'overdue': isToday(card.due)?'today':'');
due.innerHTML=(isOverdue(card.due)?'⚠':'📅')+' '+formatDate(card.due);
due.onclick=e=>{ e.stopPropagation(); openCardEditor(card, col, el); };
meta.appendChild(due);
}
const tools=document.createElement('div'); tools.className='card-tools';
const editBtn=document.createElement('button'); editBtn.className='tool edit'; editBtn.textContent='✎'; editBtn.title='Edit';
editBtn.onclick=e=>{ e.stopPropagation(); openCardEditor(card, col, el); };
const delBtn=document.createElement('button'); delBtn.className='tool'; delBtn.textContent='×'; delBtn.title='Delete';
delBtn.onclick=e=>{ e.stopPropagation(); deleteCard(col, card); };
tools.append(editBtn,delBtn);
el.appendChild(meta); el.appendChild(tools);
makeCardDraggable(el, col, card);
return el;
}
/* date helpers */
function todayStr(){ const d=new Date(); return d.toISOString().slice(0,10); }
function isToday(d){ return d===todayStr(); }
function isOverdue(d){ return d < todayStr(); }
function formatDate(d){ const d2=new Date(d+'T00:00:00'); return d2.toLocaleDateString(undefined,{month:'short',day:'numeric'}); }
function addDays(s,n){ const d=new Date(s+'T00:00:00'); d.setDate(d.getDate()+n); return d.toISOString().slice(0,10); }
function cycleDue(card){
if(!card.due) card.due=todayStr();
else if(card.due<todayStr()) card.due=addDays(todayStr(),1);
else if(card.due===todayStr()) card.due=addDays(todayStr(),1);
else card.due='';
}
function focusCard(id){
requestAnimationFrame(()=>{ const t=boardEl.querySelector('.card-edit[data-card="'+id+'"]'); if(t){ t.focus(); t.selectionStart=t.selectionEnd=t.value.length; } });
}
/* =========================================================
SECTION 6 — INLINE TITLE EDIT
========================================================= */
function startTitleEdit(el, card){
if(el.querySelector('input')) return;
const t=el.querySelector('.card-title');
const inp=document.createElement('input'); inp.className='card-edit'; inp.type='text'; inp.dataset.card=card.id;
inp.value=card.title;
t.replaceWith(inp); inp.focus(); inp.select();
const finish=(commitVal)=>{
if(commitVal){ const v=inp.value.trim(); card.title=v||'Untitled card'; save(); }
render();
if(commitVal) focusCard(card.id);
};
inp.addEventListener('keydown', e=>{
if(e.key==='Enter'){ e.preventDefault(); inp.blur(); }
else if(e.key==='Escape'){ e.preventDefault(); render(); }
});
inp.addEventListener('blur', ()=> finish(true));
}
/* =========================================================
SECTION 7 — CARD EDITOR PANEL
========================================================= */
let editorEl=null;
function openCardEditor(card, col, anchor){
closeEditor();
snapshot();
const el=document.createElement('div'); el.id='editor';
const r=anchor.getBoundingClientRect();
let top=r.top, left=r.right+12;
if(left+340>window.innerWidth) left=Math.max(12, r.left-352);
if(top+400>window.innerHeight) top=Math.max(12, window.innerHeight-410);
el.style.top=Math.min(top, window.innerHeight-420)+'px'; el.style.left=left+'px';
const titleInput=document.createElement('input'); titleInput.className='ed-title'; titleInput.type='text'; titleInput.value=card.title;
const descInput=document.createElement('textarea'); descInput.className='ed-desc'; descInput.value=card.desc; descInput.rows=3;
const labelsWrap=document.createElement('div'); labelsWrap.className='ed-labels';
const actions=document.createElement('div'); actions.className='ed-actions';
const spacer=document.createElement('span'); spacer.className='spacer';
const del=document.createElement('button'); del.className='ed-del'; del.textContent='Delete card';
const close=document.createElement('button'); close.className='btn'; close.textContent='Close';
const sync=()=>{ save(); render(); };
titleInput.addEventListener('input', ()=>{ card.title=titleInput.value; sync(); });
descInput.addEventListener('input', ()=>{ card.desc=descInput.value; sync(); });
del.onclick=()=>{ closeEditor(); deleteCard(col, card); };
close.onclick=()=>{ closeEditor(); };
CFG.LABELS.forEach(l=>{
const lab=document.createElement('label'); lab.className='ed-lbl'+(card.labels.includes(l.id)?' active':'');
const cb=document.createElement('input'); cb.type='checkbox'; cb.checked=card.labels.includes(l.id);
cb.onchange=()=>{ card.labels=card.labels.filter(x=>x!==l.id); if(cb.checked) card.labels.push(l.id);
lab.classList.toggle('active', cb.checked); sync(); };
const sw=document.createElement('span'); sw.className='swatch'; sw.style.background=l.color;
lab.append(cb, sw, document.createTextNode(l.name));
labelsWrap.appendChild(lab);
});
const dueInput=document.createElement('input'); dueInput.type='date'; dueInput.value=card.due||'';
dueInput.addEventListener('change', ()=>{ card.due=dueInput.value; sync(); });
el.innerHTML='<span class="ed-label">Title</span>'; el.appendChild(titleInput);
el.appendChild(Object.assign(document.createElement('span'),{className:'ed-label',textContent:'Description'}));
el.appendChild(descInput);
el.appendChild(Object.assign(document.createElement('span'),{className:'ed-label',textContent:'Due date'}));
el.appendChild(dueInput);
el.appendChild(Object.assign(document.createElement('span'),{className:'ed-label',textContent:'Labels'}));
el.appendChild(labelsWrap);
actions.append(spacer, close, del); el.appendChild(actions);
document.body.appendChild(el); editorEl=el;
// keep panel in view after resize / scroll of anchor
window.addEventListener('scroll', repositionEditor, true);
window.addEventListener('resize', repositionEditor);
requestAnimationFrame(repositionEditor);
}
function repositionEditor(){
if(!editorEl) return;
const col=board.columns.find(c=>c.cards.some(k=>k.id===editorEl._cardId));
}
function closeEditor(){
if(!editorEl) return;
editorEl.remove(); editorEl=null;
window.removeEventListener('scroll', repositionEditor, true);
window.removeEventListener('resize', repositionEditor);
}
/* =========================================================
SECTION 8 — COLUMN MENU
========================================================= */
let openMenu=null;
function openColumnMenu(col, anchor){
closeMenus();
const menu=document.createElement('div'); menu.className='menu';
const titleRow=document.createElement('div'); titleRow.className='row'; titleRow.textContent='Rename column';
const wipRow=document.createElement('div'); wipRow.className='row'; wipRow.textContent='Set WIP limit…';
const sep=document.createElement('div'); sep.className='sep';
const delRow=document.createElement('div'); delRow.className='row danger'; delRow.textContent='Delete column';
menu.append(titleRow,wipRow,sep,delRow);
document.body.appendChild(menu); positionMenu(menu, anchor); openMenu=menu;
titleRow.onclick=()=>{
closeMenus();
const input=document.createElement('input'); input.type='text'; input.value=col.title;
titleRow.textContent=''; titleRow.appendChild(input); input.focus(); input.select();
const done=()=>{ const v=input.value.trim(); if(v && v!==col.title){ snapshot(); col.title=v; save(); render(); } };
input.addEventListener('keydown',e=>{ if(e.key==='Enter'){e.preventDefault(); input.blur();} if(e.key==='Escape'){ closeMenus(); }});
input.addEventListener('blur', done);
};
wipRow.onclick=()=>{
closeMenus();
const v=prompt('WIP limit for “'+col.title+'” (0 = unlimited):', col.wip||'');
if(v===null) return;
snapshot(); col.wip=parseInt(v,10); if(isNaN(col.wip)||col.wip<0) col.wip=0; save(); render();
};
delRow.onclick=()=>{
if(board.columns.length<=1){ alert('A board must keep at least one column.'); return; }
snapshot(); const removed=deepClone(col); board.columns=board.columns.filter(c=>c.id!==col.id); save(); render();
scheduleUndo('Column “'+removed.title+'” deleted');
};
menu.addEventListener('mousedown', e=>e.stopPropagation());
}
function closeMenus(){ if(openMenu){ openMenu.remove(); openMenu=null; } }
document.addEventListener('mousedown', e=>{ if(openMenu && !openMenu.contains(e.target)) closeMenus(); });
document.addEventListener('keydown', e=>{ if(e.key==='Escape' && openMenu) closeMenus(); });
function positionMenu(menu, anchor){
const r=anchor.getBoundingClientRect();
menu.style.top=(r.bottom+4)+'px';
menu.style.left=Math.min(r.left, window.innerWidth-220)+'px';
}
/* =========================================================
SECTION 9 — FILTER BAR + SEARCH
========================================================= */
function updateFilterBar(){
const chips=document.getElementById('filterChips'); chips.innerHTML='';
activeFilters.forEach(lid=>{
const chip=document.createElement('span'); chip.className='filter-chip';
const dot=document.createElement('span'); dot.className='dot'; dot.style.background=labelColor(lid);
const txt=document.createElement('span'); txt.textContent=labelName(lid);
const x=document.createElement('span'); x.className='x'; x.textContent='×';
x.onclick=()=>{ activeFilters.delete(lid); save(); render(); };
chip.append(dot,txt,x); chips.appendChild(chip);
});
const hint=document.getElementById('filterHint');
hint.textContent = searchQuery? 'Matching “'+searchQuery+'”'
: activeFilters.size? activeFilters.size+' label'+(activeFilters.size>1?'s':'')+' active' : '';
}
function openFilterPicker(anchor){
closeMenus();
const menu=document.createElement('div'); menu.className='menu';
const head=document.createElement('div'); head.className='row'; head.style.fontWeight='700'; head.textContent='Filter by labels';
head.style.cursor='default'; head.style.background='transparent'; menu.appendChild(head);
CFG.LABELS.forEach(l=>{
const lab=document.createElement('label'); lab.className='lbl';
const cb=document.createElement('input'); cb.type='checkbox'; cb.checked=activeFilters.has(l.id);
cb.onchange=()=>{ if(cb.checked) activeFilters.add(l.id); else activeFilters.delete(l.id); save(); render(); };
const sw=document.createElement('span'); sw.className='swatch'; sw.style.background=l.color;
lab.append(cb, sw, document.createTextNode(l.name)); menu.appendChild(lab);
});
document.body.appendChild(menu); positionMenu(menu, anchor); openMenu=menu;
menu.addEventListener('mousedown', e=>e.stopPropagation());
}
document.getElementById('addFilterBtn').onclick=e=> openFilterPicker(e.currentTarget);
searchInput.addEventListener('input', ()=>{
searchQuery = searchInput.value.trim().toLowerCase();
boardEl.querySelectorAll('.card').forEach(el=>{
const cardId=el.dataset.card; let vis=false;
for(const col of board.columns){ const k=cardInColumn(col,cardId); if(k){ vis=cardVisible(k); break; } }
el.classList.toggle('hidden-filter', !vis);
});
board.columns.forEach(col=>{
const c=boardEl.querySelector('.column[data-col="'+col.id+'"] .col-count');
if(c){ c.textContent=col.cards.filter(cardVisible).length; c.classList.toggle('warn', col.wip&&col.wip>0&&col.cards.length>col.wip); }
});
updateFilterBar();
});
/* =========================================================
SECTION 10 — MENUS / TOAST HELPERS
========================================================= */
const toastEl=document.getElementById('toast');
const toastMsg=document.getElementById('toastMsg');
let toastTimer=null;
function showToast(msg){ toastMsg.textContent=msg; toastEl.classList.add('show'); clearTimeout(toastTimer); toastTimer=setTimeout(hideToast,4000); }
function hideToast(){ toastEl.classList.remove('show'); clearTimeout(toastTimer); }
document.getElementById('undoBtn').onclick=()=>{ performUndo(); };
document.getElementById('resetBtn').onclick=()=>{ if(confirm('Reset the board to defaults? This clears saved data.')){ snapshot(); board=seedDefault(); save(); render(); } };
/* =========================================================
SECTION 11 — DRAG & DROP (Pointer Events)
========================================================= */
const drag = { active:false, moved:false, cardId:null, sourceColId:null,
placeholder:null, ghost:null, x:0, y:0, startX:0, startY:0 };
let scrollRAF=null;
function makeCardDraggable(el, col, card){
el.addEventListener('pointerdown', e=>{
if(e.button!==0 || e.target.closest('input,button,.tool,.label-chip,.due,.card-tools')) return;
if(el.classList.contains('hidden-filter')) return;
drag.active=true; drag.moved=false; drag.placeholder=null; drag.ghost=null;
drag.cardId=card.id; drag.sourceColId=col.id; drag.colId=col.id;
drag.startX=e.clientX; drag.startY=e.clientY; drag.x=e.clientX; drag.y=e.clientY;
el.setPointerCapture?.(e.pointerId);
});
}
document.addEventListener('pointermove', e=>{
if(!drag.active) return;
drag.x=e.clientX; drag.y=e.clientY;
const dx=e.clientX-drag.startX, dy=e.clientY-drag.startY;
if(!drag.moved && (Math.abs(dx)>CFG.DRAG_THRESHOLD || Math.abs(dy)>CFG.DRAG_THRESHOLD)){
drag.moved=true; beginDrag();
}
if(drag.moved){ e.preventDefault(); updateDragPosition(); autoScroll(e); }
});
window.addEventListener('pointerup', e=>{
if(!drag.active) return;
if(drag.moved) doDrop(); else cancelDrag();
});
window.addEventListener('keydown', e=>{
if(e.key==='Escape' && drag.active){ cancelDrag(); }
});
function beginDrag(){
const col=findColumn(drag.sourceColId); if(!col) return;
const card=cardInColumn(col, drag.cardId); if(!card) return;
const origEl=boardEl.querySelector('.card[data-card="'+drag.cardId+'"]');
if(!origEl) return;
drag.placeholder=origEl;
origEl.classList.add('dragging');
const ghost=document.createElement('div'); ghost.id='dragghost';
ghost.innerHTML=cardInnerHTML(card);
document.body.appendChild(ghost);
drag.ghost=ghost;
applyGhost();
startScrollLoop();
}
function cardInnerHTML(card){
let h='<div class="card-title">'+esc(card.title||'Untitled')+'</div>';
if(card.desc) h+='<div class="card-desc">'+esc(card.desc)+'</div>';
if(card.labels && card.labels.length){
h+='<div class="card-labels">';
card.labels.forEach(lid=> h+='<span class="label-chip" style="background:'+labelColor(lid)+'">'+esc(labelName(lid).slice(0,3))+'</span>');
h+='</div>';
}
if(card.due) h+='<div class="card-meta"><span class="due">'+(isOverdue(card.due)?'⚠':'📅')+' '+esc(formatDate(card.due))+'</span></div>';
return h;
}
function applyGhost(){
if(!drag.ghost) return;
const gr=drag.placeholder.getBoundingClientRect();
drag.ghost.style.width=(gr.width-8)+'px';
drag.ghost.style.left=(drag.x - (gr.width)/2 + 4)+'px';
drag.ghost.style.top=(drag.y - gr.height/2)+'px';
}
function listVisibleCards(list){ return [...list.querySelectorAll('.card:not(.dragging):not(.hidden-filter)')]; }
/* hit-test: find nearest column + insert index using scroll-aware positions */
function hitTest(clientX, clientY){
const sourceColId = drag.active ? drag.colId : null;
const placeholder = drag.placeholder;
// Pick the target column by horizontal overlap (viewport).
// Prefer the source column (so in-column drag never leaks elsewhere).
let target = null, targetScore = -Infinity;
boardEl.querySelectorAll('.column').forEach(colEl=>{
const body=colEl.querySelector('.col-body');
const list=body.querySelector('.card-list');
const br=body.getBoundingClientRect();
if(clientY < br.top-90 || clientY > br.bottom+90) return;
if(clientX < br.left || clientX > br.right) return;
const isSource = colEl.dataset.col === sourceColId;
const overlap = Math.min(br.right, clientX+1) - Math.max(br.left, clientX-1);
const score = (isSource ? 1e9 : 0) + overlap;
if(score > targetScore){ targetScore = score; target = { colId: colEl.dataset.col, list }; }
});
if(!target){
// cursor over empty space between columns: nearest by x
let nd=Infinity;
boardEl.querySelectorAll('.column').forEach(colEl=>{
const r=colEl.getBoundingClientRect();
if(clientX>r.left-120 && clientX<r.right+120){
const d=Math.abs(clientX-(r.left+r.right)/2);
if(d<nd){ nd=d; target={ colId: colEl.dataset.col, list: colEl.querySelector('.card-list') }; }
}
});
}
if(!target) return null;
// Determine insertion index within the chosen column by vertical position.
// The placeholder (dragged card) must be EXCLUDED from the reference set,
// otherwise dragging within the same column computes a position relative
// to itself (which moves as we insert it), causing index instability.
const { list } = target;
const visible = listVisibleCards(list).filter(el => el !== placeholder);
let index = visible.length;
for(let i=0;i<visible.length;i++){
const cr = visible[i].getBoundingClientRect();
if(cr.top + cr.height/2 > clientY){ index = i; break; }
}
return { colId: target.colId, index, list };
}
function highlightColumns(colId){
boardEl.querySelectorAll('.column').forEach(c=>c.classList.toggle('drag-over', c.dataset.col===colId));
boardEl.querySelectorAll('.col-body').forEach(c=>c.classList.toggle('drag-target', c.dataset.col===colId));
}
function updateDragPosition(){
if(!drag.placeholder || !drag.ghost) return;
const info=hitTest(drag.x, drag.y);
if(!info) return;
highlightColumns(info.colId);
const list=info.list;
const visible=listVisibleCards(list);
const before = visible[info.index] || null;
if(before) list.insertBefore(drag.placeholder, before);
else list.appendChild(drag.placeholder);
applyGhost();
}
/* auto-scroll */
function startScrollLoop(){
if(scrollRAF) return;
const loop=()=>{ if(!drag.active || !drag.moved){ scrollRAF=null; return; } autoScroll({x:drag.x,y:drag.y}); scrollRAF=requestAnimationFrame(loop); };
scrollRAF=requestAnimationFrame(loop);
}
function autoScroll(e){
const r=boardWrap.getBoundingClientRect();
const mx=r.left+CFG.SCROLL_MARGIN_H, mnr=r.right-CFG.SCROLL_MARGIN_H;
if(e.x < mx){ boardWrap.scrollLeft -= ((mx-e.x)/CFG.SCROLL_MARGIN_H)*CFG.SCROLL_SPEED; }
else if(e.x > mnr){ boardWrap.scrollLeft += ((e.x-mnr)/CFG.SCROLL_MARGIN_H)*CFG.SCROLL_SPEED; }
// vertical: scroll the column body under the cursor
const under=boardEl.querySelector('.col-body');
let body=null; let best=Infinity;
boardEl.querySelectorAll('.col-body').forEach(b=>{
const br=b.getBoundingClientRect();
if(e.x>=br.left && e.x<=br.right && Math.abs(e.y-(br.top+br.height/2))<best){ best=Math.abs(e.y-(br.top+br.height/2)); body=b; }
});
if(body){
const br=body.getBoundingClientRect();
const topM=br.top+CFG.SCROLL_MARGIN_V, botM=br.bottom-CFG.SCROLL_MARGIN_V;
if(e.y < topM) body.scrollTop -= ((topM-e.y)/CFG.SCROLL_MARGIN_V)*CFG.SCROLL_SPEED;
else if(e.y > botM) body.scrollTop += ((e.y-botM)/CFG.SCROLL_MARGIN_V)*CFG.SCROLL_SPEED;
}
}
function doDrop(){
const info=hitTest(drag.x, drag.y);
const srcCol=findColumn(drag.sourceColId);
const card=srcCol && cardInColumn(srcCol, drag.cardId);
if(card){
const ci=srcCol.cards.findIndex(c=>c.id===drag.cardId);
srcCol.cards.splice(ci,1);
if(info && info.colId){
const target=findColumn(info.colId);
if(target){
let idx=info.index;
if(info.colId===drag.sourceColId && ci<idx) idx=Math.max(0,idx-1);
target.cards.splice(Math.max(0,Math.min(idx,target.cards.length)),0,card);
card.colId=target.id;
}
}
save();
}
cleanupDrag();
render();
}
function cancelDrag(){
cleanupDrag();
render(); // model never changed during drag → exact restore
}
function cleanupDrag(){
if(drag.placeholder) drag.placeholder.classList.remove('dragging');
if(drag.ghost) drag.ghost.remove();
boardEl.querySelectorAll('.column').forEach(c=>c.classList.remove('drag-over'));
boardEl.querySelectorAll('.col-body').forEach(c=>c.classList.remove('drag-target'));
if(scrollRAF){ cancelAnimationFrame(scrollRAF); scrollRAF=null; }
drag.active=false; drag.moved=false; drag.placeholder=null; drag.ghost=null;
}
/* =========================================================
SECTION 12 — COLUMN REORDER (drag by header)
========================================================= */
let colDrag=null;
function makeColumnDraggable(header, col){
header.addEventListener('pointerdown', e=>{
if(e.target.closest('button')) return;
e.stopPropagation();
colDrag={ colId:col.id, startX:e.clientX, startY:e.clientY, moved:false, ghost:null, el:null };
header.setPointerCapture?.(e.pointerId);
});
header.addEventListener('pointermove', e=>{
if(!colDrag || colDrag.colId!==col.id) return;
const dx=e.clientX-colDrag.startX, dy=e.clientY-colDrag.startY;
if(!colDrag.moved && (Math.abs(dx)>5||Math.abs(dy)>5)){
colDrag.moved=true;
const g=colDrag.el=boardEl.querySelector('.column[data-col="'+col.id+'"]');
const clone=g.cloneNode(true);
clone.id='coldragghost';
clone.style.cssText='position:fixed;top:0;left:0;width:'+CFG.COL_WIDTH+'px;z-index:55;opacity:.85;pointer-events:none;box-shadow:var(--shadow-lg);transform:rotate(-1.5deg)';
document.body.appendChild(clone); colDrag.ghost=clone;
g.style.opacity='.35';
}
if(!colDrag.moved) return;
e.preventDefault();
colDrag.ghost.style.left=(e.clientX-16)+'px';
colDrag.ghost.style.top=(e.clientY-16)+'px';
boardEl.querySelectorAll('.column').forEach(c=>c.classList.remove('drag-col'));
const under=document.elementFromPoint(e.clientX,e.clientY);
const tgt=under&&under.closest('.column');
if(tgt) tgt.classList.add('drag-col');
});
header.addEventListener('pointerup', e=>{
if(!colDrag || colDrag.colId!==col.id) return;
if(colDrag.moved){
const under=document.elementFromPoint(e.clientX,e.clientY);
const tgt=under&&under.closest('.column');
if(tgt && tgt.dataset.col!==colDrag.colId){
const from=board.columns.findIndex(c=>c.id===colDrag.colId);
const to=board.columns.findIndex(c=>c.id===tgt.dataset.col);
if(from!==-1&&to!==-1&&from!==to){
snapshot();
const [c]=board.columns.splice(from,1);
board.columns.splice(to,0,c);
save();
}
}
if(colDrag.ghost) colDrag.ghost.remove();
if(colDrag.el) colDrag.el.style.opacity='';
render();
}
colDrag=null;
});
}
/* =========================================================
SECTION 13 — DELETE
========================================================= */
function deleteCard(col, card){
snapshot();
col.cards=col.cards.filter(c=>c.id!==card.id);
save(); render();
scheduleUndo('Card “'+card.title+'” deleted');
}
/* =========================================================
SECTION 14 — INIT
========================================================= */
function init(){ board=load(); render(); save(); }
init();
</script>
</body>
</html>
<!-- agent-meta {"model":"bartowski/Ornith-1.5-35B-A3B","provider":"brigid","persona":"full","sessionId":"8d6dd3c6-d834-4850-8ed9-450d568238b4","tokensIn":6288554,"tokensOut":161540,"tokensTotal":6450094,"cost":0,"turns":78,"toolCalls":77,"failedToolCalls":4,"timestamp":"2026-08-24T00:05:36.837Z"} -->