Kanban
ox alpha · 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 36,005 bytes · SHA-256 c20d617a08b9
<!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>
/* ============================================================
STYLES
============================================================ */
:root{
--bg:#f4f5f7;
--col-bg:#ebecf0;
--card-bg:#ffffff;
--text:#172b4d;
--text-sub:#5e6c84;
--accent:#0052cc;
--danger:#e5484d;
--radius-col:12px;
--radius-card:8px;
--shadow-card:0 1px 2px rgba(9,30,66,.12),0 0 1px rgba(9,30,66,.16);
--shadow-hover:0 4px 8px rgba(9,30,66,.16),0 0 1px rgba(9,30,66,.24);
--shadow-drag:0 12px 24px rgba(9,30,66,.22),0 0 1px rgba(9,30,66,.32);
--col-w:292px;
}
*{box-sizing:border-box;margin:0;padding:0}
html,body{height:100%}
body{
font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;
background:var(--bg);color:var(--text);
overflow:hidden;font-size:14px;
}
/* ---------- Top bar ---------- */
.topbar{
position:sticky;top:0;z-index:50;height:56px;
display:flex;align-items:center;gap:16px;padding:0 20px;
background:#fff;border-bottom:1px solid #e2e4ea;
}
.topbar h1{font-size:17px;font-weight:700;letter-spacing:-.01em}
.topbar .spacer{flex:1}
.search-wrap{position:relative}
.search-wrap svg{position:absolute;left:9px;top:50%;transform:translateY(-50%);width:14px;height:14px;color:var(--text-sub)}
#search{
width:230px;padding:7px 10px 7px 30px;border:1px solid #dfe1e6;border-radius:6px;
font:inherit;color:var(--text);background:#fafbfc;outline:none;transition:.15s;
}
#search:focus{border-color:var(--accent);background:#fff;box-shadow:0 0 0 2px rgba(0,82,204,.15)}
.filter-bar{display:flex;gap:6px;align-items:center}
.filter-chip{
display:inline-flex;align-items:center;gap:6px;padding:4px 10px;border-radius:999px;
border:1.5px solid transparent;font-size:12px;font-weight:600;cursor:pointer;
background:#f1f2f4;color:var(--text-sub);user-select:none;transition:.12s;
}
.filter-chip .dot{width:9px;height:9px;border-radius:50%}
.filter-chip.active{border-color:currentColor;background:#fff}
.btn{
font:inherit;font-weight:600;color:var(--text);background:#f1f2f4;border:none;
border-radius:6px;padding:7px 12px;cursor:pointer;transition:.12s;
}
.btn:hover{background:#e4e6ea}
.btn.primary{background:var(--accent);color:#fff}
.btn.primary:hover{background:#0747a6}
.btn.danger{color:var(--danger)}
.btn.danger:hover{background:#ffebeb}
/* ---------- Board ---------- */
#board{
display:flex;align-items:flex-start;gap:14px;
height:calc(100vh - 56px);padding:18px 20px;
overflow-x:auto;overflow-y:hidden;
}
.column{
width:var(--col-w);flex:0 0 var(--col-w);
max-height:100%;display:flex;flex-direction:column;
background:var(--col-bg);border-radius:var(--radius-col);
transition:box-shadow .15s, outline-color .15s;
outline:2px solid transparent;outline-offset:-2px;
}
.column.wip-exceeded{outline-color:var(--danger);box-shadow:0 0 0 4px rgba(229,72,77,.15)}
.col-header{
display:flex;align-items:center;gap:8px;padding:11px 12px 7px;
cursor:grab;user-select:none;
}
.col-header:active{cursor:grabbing}
.col-title{
font-weight:700;font-size:13.5px;flex:1;min-width:0;
overflow:hidden;text-overflow:ellipsis;white-space:nowrap;
padding:2px 4px;border-radius:4px;
}
.col-title:hover{background:rgba(9,30,66,.08)}
.col-count{
font-size:11.5px;font-weight:700;color:var(--text-sub);
background:rgba(9,30,66,.1);border-radius:999px;padding:2px 8px;
}
.wip-badge{font-size:11px;font-weight:700;color:var(--danger)}
.icon-btn{
border:none;background:none;color:var(--text-sub);cursor:pointer;
width:26px;height:26px;border-radius:5px;display:flex;align-items:center;justify-content:center;
font-size:15px;line-height:1;
}
.icon-btn:hover{background:rgba(9,30,66,.12);color:var(--text)}
/* ---------- Cards ---------- */
.col-cards{
overflow-y:auto;overscroll-behavior:contain;
padding:4px 8px 8px;display:flex;flex-direction:column;gap:8px;
min-height:44px;scrollbar-width:thin;
}
.col-cards.empty-zone{border:2px dashed #c1c7d0;border-radius:8px;margin:4px 8px 8px;padding:8px}
.card{
background:var(--card-bg);border-radius:var(--radius-card);
box-shadow:var(--shadow-card);padding:10px 12px;cursor:grab;
transition:box-shadow .12s, transform .12s;user-select:none;
}
.card:hover{transform:translateY(-1px);box-shadow:var(--shadow-hover)}
.card.settle{animation:settle .28s cubic-bezier(.2,.9,.3,1.2)}
@keyframes settle{0%{transform:scale(.96)}60%{transform:scale(1.015)}100%{transform:scale(1)}}
.card-title{font-weight:500;line-height:1.35;word-break:break-word}
.card-desc{color:var(--text-sub);font-size:12.5px;margin-top:4px;line-height:1.4;
display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
.card-meta{display:flex;flex-wrap:wrap;gap:5px;margin-top:8px;align-items:center}
.chip{
font-size:10.5px;font-weight:700;padding:2px 8px;border-radius:999px;letter-spacing:.02em;
}
.due{
font-size:11px;font-weight:600;color:var(--text-sub);
display:inline-flex;align-items:center;gap:4px;
}
.due.overdue{color:var(--danger)}
.title-input{
font:inherit;font-weight:inherit;width:100%;border:2px solid var(--accent);
border-radius:5px;padding:3px 6px;outline:none;background:#fff;color:var(--text);
}
/* ---------- Drag visuals ---------- */
.ghost{
position:fixed;left:0;top:0;z-index:1000;pointer-events:none;
box-shadow:var(--shadow-drag)!important;cursor:grabbing!important;
transform-origin:center;
}
.placeholder{
border:2px dashed #a5adba;border-radius:var(--radius-card);
background:rgba(9,30,66,.05);flex:0 0 auto;
}
.col-placeholder{
width:var(--col-w);flex:0 0 var(--col-w);border-radius:var(--radius-col);
border:2px dashed #a5adba;background:rgba(9,30,66,.04);
}
.hidden{display:none!important}
body.dragging{cursor:grabbing}
body.dragging *{cursor:grabbing!important}
/* ---------- Composer ---------- */
.composer{
padding:0 8px 10px;
}
.composer textarea{
width:100%;font:inherit;border:none;border-radius:var(--radius-card);
box-shadow:var(--shadow-card);padding:9px 11px;resize:none;outline:none;
min-height:56px;display:block;
}
.composer-actions{display:flex;gap:6px;margin-top:6px}
/* ---------- Modal ---------- */
.modal-overlay{
position:fixed;inset:0;background:rgba(9,30,66,.45);z-index:200;
display:flex;align-items:flex-start;justify-content:center;padding-top:9vh;
}
.modal{
background:#fff;border-radius:10px;width:480px;max-width:92vw;
box-shadow:0 20px 50px rgba(9,30,66,.35);padding:20px;animation:pop .18s ease-out;
max-height:82vh;overflow-y:auto;
}
@keyframes pop{from{opacity:0;transform:translateY(8px) scale(.98)}to{opacity:1}}
.modal h2{font-size:16px;margin-bottom:14px}
.field{margin-bottom:13px}
.field label{display:block;font-size:11.5px;font-weight:700;color:var(--text-sub);
text-transform:uppercase;letter-spacing:.04em;margin-bottom:5px}
.field input[type=text],.field textarea,.field input[type=date],.field input[type=number]{
width:100%;font:inherit;border:2px solid #dfe1e6;border-radius:6px;padding:7px 9px;
outline:none;color:var(--text);
}
.field textarea{resize:vertical;min-height:70px}
.field input:focus,.field textarea:focus{border-color:var(--accent)}
.label-picker{display:flex;flex-wrap:wrap;gap:7px}
.label-pick{
display:inline-flex;align-items:center;gap:6px;padding:5px 11px;border-radius:999px;
border:2px solid transparent;font-size:12px;font-weight:600;cursor:pointer;
opacity:.45;transition:.12s;user-select:none;
}
.label-pick.on{opacity:1;border-color:currentColor}
.modal-actions{display:flex;justify-content:space-between;margin-top:18px}
.modal-actions .right{display:flex;gap:8px}
/* ---------- Column menu ---------- */
.col-menu{
position:fixed;z-index:300;background:#fff;border-radius:8px;
box-shadow:0 8px 24px rgba(9,30,66,.25);padding:8px;width:220px;
}
.col-menu .field{margin-bottom:10px}
.menu-item{
display:block;width:100%;text-align:left;font:inherit;font-weight:500;
padding:7px 9px;border:none;background:none;border-radius:5px;cursor:pointer;color:var(--text);
}
.menu-item:hover{background:#f1f2f4}
.menu-item.danger{color:var(--danger)}
/* ---------- Toast ---------- */
.toast{
position:fixed;bottom:24px;left:50%;transform:translateX(-50%);
background:#172b4d;color:#fff;border-radius:8px;padding:11px 16px;
display:flex;align-items:center;gap:14px;z-index:400;
box-shadow:0 8px 24px rgba(9,30,66,.4);font-weight:500;
animation:toastIn .2s ease-out;
}
@keyframes toastIn{from{opacity:0;transform:translate(-50%,10px)}to{opacity:1;transform:translate(-50%,0)}}
.toast button{
font:inherit;font-weight:700;color:#8cb8ff;background:none;border:none;cursor:pointer;
text-transform:uppercase;font-size:12px;letter-spacing:.04em;
}
.toast button:hover{text-decoration:underline}
::-webkit-scrollbar{height:10px;width:8px}
::-webkit-scrollbar-thumb{background:#c1c7d0;border-radius:5px}
::-webkit-scrollbar-track{background:transparent}
</style>
</head>
<body>
<header class="topbar">
<h1>Kanban Board</h1>
<div class="spacer"></div>
<div class="filter-bar" id="filterBar"></div>
<div class="search-wrap">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><circle cx="11" cy="11" r="7"/><path d="M21 21l-4.3-4.3"/></svg>
<input id="search" type="text" placeholder="Search cards…" autocomplete="off">
</div>
<button class="btn primary" id="addColumnBtn">+ Add column</button>
</header>
<main id="board"></main>
<script>
'use strict';
/* ============================================================
TUNABLE CONSTANTS
============================================================ */
const STORAGE_KEY = 'kanban-board-v1';
const LABEL_PALETTE = [
{ id:'design', name:'Design', color:'#ae5a9e', bg:'#f3e3f0' },
{ id:'bug', name:'Bug', color:'#c9372c', bg:'#fbe4e2' },
{ id:'feature', name:'Feature', color:'#0747a6', bg:'#dce9ff' },
{ id:'urgent', name:'Urgent', color:'#b65c02', bg:'#ffefdb' },
{ id:'research',name:'Research',color:'#5e4db2', bg:'#eae6ff' },
{ id:'chore', name:'Chore', color:'#206a4d', bg:'#dcfff1' },
];
const DRAG_THRESHOLD = 5; // px pointer must move before drag starts
const EDGE_SCROLL_ZONE = 90; // px near board edge that triggers auto-scroll
const EDGE_SCROLL_MAX = 18; // max px per frame of auto-scroll
const COL_EDGE_ZONE = 60; // px near column top/bottom for vertical scroll
const GHOST_TILT = 3; // deg tilt while dragging a card
const UNDO_TOAST_MS = 6000;
/* ============================================================
STATE MODEL (single source of truth — fully serializable)
============================================================ */
let state = null;
let uidCounter = 0;
function uid(){ return 'id' + Date.now().toString(36) + (++uidCounter).toString(36); }
function defaultState(){
const cols = ['Backlog','To Do','In Progress','Review','Done'].map(t=>({ id:uid(), title:t, wip:0 }));
const cards = {};
cols.forEach(c => cards[c.id] = []);
const seed = [
[0,'Design onboarding flow','Sketch the first-run experience',['design']],
[0,'Investigate flaky CI','Fails intermittently on main',['bug','research']],
[1,'User avatars','Upload + crop + store',['feature']],
[1,'Upgrade dependencies','Quarterly chore',['chore']],
[2,'Kanban drag & drop','Pointer-events based, with placeholder',['feature','urgent']],
[3,'Dark mode tokens','Awaiting final review',['design']],
[4,'Set up repo','Initial scaffold',['chore']],
];
seed.forEach(([ci,title,desc,labels])=>{
cards[cols[ci].id].push({ id:uid(), title, desc, labels:[...labels], due:'' });
});
return { columns:cols, cards, filter:{ labels:[], search:'' } };
}
function save(){
try{ localStorage.setItem(STORAGE_KEY, JSON.stringify(state)); }catch(e){}
}
function load(){
try{
const raw = localStorage.getItem(STORAGE_KEY);
if(raw){
const s = JSON.parse(raw);
if(s && Array.isArray(s.columns) && s.cards){ return s; }
}
}catch(e){}
return defaultState();
}
function cloneBoard(s){ return JSON.parse(JSON.stringify(s)); }
/* ---------- model helpers ---------- */
function colById(id){ return state.columns.find(c=>c.id===id); }
function findCard(id){
for(const c of state.columns){
const i = state.cards[c.id].findIndex(k=>k.id===id);
if(i>=0) return { card:state.cards[c.id][i], colId:c.id, index:i };
}
return null;
}
function cardVisible(card){
const f = state.filter;
if(f.labels.length && !f.labels.some(l=>card.labels.includes(l))) return false;
if(f.search && !card.title.toLowerCase().includes(f.search.toLowerCase())) return false;
return true;
}
/* ============================================================
RENDERING (derived entirely from the model)
============================================================ */
const boardEl = document.getElementById('board');
const searchEl = document.getElementById('search');
const filterBar = document.getElementById('filterBar');
function labelOf(id){ return LABEL_PALETTE.find(l=>l.id===id); }
function renderFilterBar(){
filterBar.innerHTML = '';
LABEL_PALETTE.forEach(l=>{
const b = document.createElement('button');
b.className = 'filter-chip' + (state.filter.labels.includes(l.id)?' active':'');
b.style.color = l.color;
b.innerHTML = `<span class="dot" style="background:${l.color}"></span>${l.name}`;
b.onclick = ()=>{
const i = state.filter.labels.indexOf(l.id);
if(i>=0) state.filter.labels.splice(i,1); else state.filter.labels.push(l.id);
save(); render();
};
filterBar.appendChild(b);
});
}
function makeCardEl(card){
const el = document.createElement('div');
el.className = 'card';
el.dataset.cardId = card.id;
let meta = '';
card.labels.forEach(lid=>{
const l = labelOf(lid); if(!l) return;
meta += `<span class="chip" style="background:${l.bg};color:${l.color}">${l.name}</span>`;
});
if(card.due){
const overdue = card.due < new Date().toISOString().slice(0,10);
meta += `<span class="due${overdue?' overdue':''}">⏱ ${card.due}</span>`;
}
el.innerHTML =
`<div class="card-title">${esc(card.title)}</div>` +
(card.desc ? `<div class="card-desc">${esc(card.desc)}</div>` : '') +
(meta ? `<div class="card-meta">${meta}</div>` : '');
attachCardEvents(el, card.id);
return el;
}
function esc(s){ return String(s).replace(/[&<>"]/g, m=>({'&':'&','<':'<','>':'>','"':'"'}[m])); }
function render(){
renderFilterBar();
boardEl.innerHTML = '';
state.columns.forEach(col=>{
const colEl = document.createElement('section');
colEl.className = 'column';
colEl.dataset.colId = col.id;
const list = state.cards[col.id] || [];
const over = col.wip > 0 && list.length > col.wip;
if(over) colEl.classList.add('wip-exceeded');
// header
const head = document.createElement('div');
head.className = 'col-header';
head.innerHTML =
`<span class="col-title" title="Click to rename">${esc(col.title)}</span>` +
`<span class="col-count">${list.length}</span>` +
(col.wip>0 ? `<span class="wip-badge">WIP ${col.wip}${over?' ⚠':''}</span>` : '') +
`<button class="icon-btn col-menu-btn" title="Column actions">⋯</button>`;
colEl.appendChild(head);
// cards
const wrap = document.createElement('div');
wrap.className = 'col-cards';
let anyVisible = false;
list.forEach(card=>{
const cel = makeCardEl(card);
if(!cardVisible(card)) cel.classList.add('hidden');
else anyVisible = true;
wrap.appendChild(cel);
});
if(!anyVisible) wrap.classList.add('empty-zone');
colEl.appendChild(wrap);
// composer
const comp = document.createElement('div');
comp.className = 'composer';
comp.innerHTML =
`<textarea class="composer-input" rows="1" placeholder="+ Add a card"></textarea>
<div class="composer-actions" style="display:none">
<button class="btn primary comp-add">Add card</button>
<button class="btn comp-cancel">Cancel</button>
</div>`;
colEl.appendChild(comp);
boardEl.appendChild(colEl);
attachColumnEvents(colEl, col.id);
});
}
/* ============================================================
INLINE EDITING
============================================================ */
function inlineEdit(el, current, onCommit){
const input = document.createElement('input');
input.className = 'title-input';
input.value = current;
el.replaceWith(input);
input.focus(); input.select();
let done = false;
const commit = ()=>{ if(done) return; done=true; onCommit(input.value.trim()); };
const cancel = ()=>{ if(done) return; done=true; render(); };
input.addEventListener('keydown', e=>{
e.stopPropagation();
if(e.key==='Enter'){ e.preventDefault(); commit(); }
else if(e.key==='Escape'){ cancel(); }
});
input.addEventListener('blur', commit);
input.addEventListener('pointerdown', e=>e.stopPropagation());
}
/* ============================================================
TOAST + UNDO
============================================================ */
let undoSnapshot = null;
let toastTimer = null;
function showToast(msg, snapshot){
undoSnapshot = snapshot;
document.querySelectorAll('.toast').forEach(t=>t.remove());
clearTimeout(toastTimer);
const t = document.createElement('div');
t.className = 'toast';
t.innerHTML = `<span>${esc(msg)}</span><button>Undo</button>`;
t.querySelector('button').onclick = ()=>{
if(undoSnapshot){ state = undoSnapshot; save(); render(); }
t.remove();
};
document.body.appendChild(t);
toastTimer = setTimeout(()=>t.remove(), UNDO_TOAST_MS);
}
</script>
<script>
/* ============================================================
EVENT WIRING
============================================================ */
function attachCardEvents(el, cardId){
el.addEventListener('pointerdown', e=>{
if(e.button!==0 || e.target.closest('.title-input')) return;
startPending('card', cardId, e);
});
}
function attachColumnEvents(colEl, colId){
const head = colEl.querySelector('.col-header');
head.addEventListener('pointerdown', e=>{
if(e.button!==0) return;
if(e.target.closest('.icon-btn') || e.target.closest('.title-input')) return;
startPending('column', colId, e);
});
colEl.querySelector('.col-menu-btn').addEventListener('click', e=>{
e.stopPropagation();
openColMenu(colId, e.currentTarget);
});
const compInput = colEl.querySelector('.composer-input');
const compActions = colEl.querySelector('.composer-actions');
compInput.addEventListener('focus', ()=>{ compActions.style.display='flex'; });
compInput.addEventListener('keydown', e=>{
e.stopPropagation();
if(e.key==='Enter'){ e.preventDefault(); addCard(colId, compInput.value.trim()); }
if(e.key==='Escape'){ compInput.blur(); }
});
compEl_add(colEl, colId, compInput);
}
function compEl_add(colEl, colId, compInput){
colEl.querySelector('.comp-add').addEventListener('click', ()=>{
addCard(colId, compInput.value.trim());
});
colEl.querySelector('.comp-cancel').addEventListener('click', ()=>{
compInput.value=''; compInput.blur();
compInput.parentElement.querySelector('.composer-actions').style.display='none';
});
}
function addCard(colId, title){
if(!title) return;
state.cards[colId].push({ id:uid(), title, desc:'', labels:[], due:'' });
save(); render();
// refocus composer of that column for rapid entry
const colEl = boardEl.querySelector(`.column[data-col-id="${colId}"]`);
if(colEl){
const inp = colEl.querySelector('.composer-input');
inp.focus();
inp.parentElement.querySelector('.composer-actions').style.display='flex';
}
}
/* ---------- top bar ---------- */
document.getElementById('addColumnBtn').addEventListener('click', ()=>{
const col = { id:uid(), title:'New Column', wip:0 };
state.columns.push(col);
state.cards[col.id] = [];
save(); render();
const colEl = boardEl.querySelector(`.column[data-col-id="${col.id}"] .col-title`);
if(colEl) inlineEdit(colEl, col.title, v=>{ col.title = v||col.title; save(); render(); });
});
searchEl.addEventListener('input', ()=>{
state.filter.search = searchEl.value;
save(); render();
});
window.addEventListener('keydown', e=>{
if(e.key==='Escape' && !(e.target instanceof HTMLInputElement) && !(e.target instanceof HTMLTextAreaElement)){
state.filter.search=''; searchEl.value='';
state.filter.labels=[]; save(); render();
}
});
/* ============================================================
CARD MODAL (full card editing)
============================================================ */
function openCardModal(cardId){
const found = findCard(cardId); if(!found) return;
const card = found.card;
const ov = document.createElement('div');
ov.className = 'modal-overlay';
ov.innerHTML = `
<div class="modal">
<h2>Edit card</h2>
<div class="field"><label>Title</label><input type="text" id="m-title" value="${esc(card.title)}"></div>
<div class="field"><label>Description</label><textarea id="m-desc">${esc(card.desc)}</textarea></div>
<div class="field"><label>Due date</label><input type="date" id="m-due" value="${esc(card.due)}"></div>
<div class="field"><label>Labels</label><div class="label-picker" id="m-labels"></div></div>
<div class="modal-actions">
<button class="btn danger" id="m-delete">Delete card</button>
<div class="right">
<button class="btn" id="m-cancel">Cancel</button>
<button class="btn primary" id="m-save">Save</button>
</div>
</div>
</div>`;
const picked = new Set(card.labels);
LABEL_PALETTE.forEach(l=>{
const b = document.createElement('span');
b.className = 'label-pick' + (picked.has(l.id)?' on':'');
b.style.background = l.bg; b.style.color = l.color;
b.textContent = l.name;
b.onclick = ()=>{ picked.has(l.id)?picked.delete(l.id):picked.add(l.id); b.classList.toggle('on'); };
ov.querySelector('#m-labels').appendChild(b);
});
const close = ()=>ov.remove();
ov.addEventListener('pointerdown', e=>{ if(e.target===ov) close(); });
ov.querySelector('#m-cancel').onclick = close;
ov.querySelector('#m-save').onclick = ()=>{
card.title = ov.querySelector('#m-title').value.trim() || card.title;
card.desc = ov.querySelector('#m-desc').value;
card.due = ov.querySelector('#m-due').value;
card.labels = [...picked];
save(); render(); close();
};
ov.querySelector('#m-delete').onclick = ()=>{
close();
const snap = cloneBoard(state);
const f = findCard(cardId);
state.cards[f.colId].splice(f.index,1);
save(); render();
showToast('Card deleted', snap);
};
document.body.appendChild(ov);
ov.querySelector('#m-title').focus();
}
/* ============================================================
COLUMN MENU (rename / WIP limit / delete)
============================================================ */
function openColMenu(colId, anchor){
document.querySelectorAll('.col-menu').forEach(m=>m.remove());
const col = colById(colId); if(!col) return;
const r = anchor.getBoundingClientRect();
const menu = document.createElement('div');
menu.className = 'col-menu';
menu.innerHTML = `
<div class="field"><label>WIP limit (0 = none)</label>
<input type="number" min="0" id="cm-wip" value="${col.wip}"></div>
<button class="menu-item" id="cm-rename">Rename column</button>
<button class="menu-item danger" id="cm-delete">Delete column</button>`;
menu.style.left = Math.min(r.left, innerWidth-240)+'px';
menu.style.top = (r.bottom+6)+'px';
document.body.appendChild(menu);
const close = ()=>menu.remove();
setTimeout(()=>document.addEventListener('pointerdown', function h(e){
if(!menu.contains(e.target)){ close(); document.removeEventListener('pointerdown',h); }
}),0);
menu.querySelector('#cm-wip').addEventListener('keydown', e=>e.stopPropagation());
menu.querySelector('#cm-wip').addEventListener('change', e=>{
col.wip = Math.max(0, parseInt(e.target.value)||0);
save(); render();
});
menu.querySelector('#cm-rename').onclick = ()=>{
close();
const t = boardEl.querySelector(`.column[data-col-id="${colId}"] .col-title`);
if(t) inlineEdit(t, col.title, v=>{ col.title=v||col.title; save(); render(); });
};
menu.querySelector('#cm-delete').onclick = ()=>{
close();
if(state.columns.length<=1){ showToast('Board needs at least one column'); return; }
const snap = cloneBoard(state);
delete state.cards[colId];
state.columns = state.columns.filter(c=>c.id!==colId);
save(); render();
showToast(`Column "${col.title}" deleted`, snap);
};
}
/* ============================================================
DRAG & DROP (Pointer Events)
============================================================ */
let pending = null; // awaiting drag threshold
let drag = null; // active drag
function startPending(type, id, e){
if(drag || pending) return;
pending = {
type, id,
startX:e.clientX, startY:e.clientY,
offX:0, offY:0, started:false,
};
try{ document.body.setPointerCapture(e.pointerId); }catch(err){}
window.addEventListener('pointermove', onPendingMove);
window.addEventListener('pointerup', onPendingUp, {once:true});
window.addEventListener('pointercancel', onPendingUp, {once:true});
window.addEventListener('keydown', onCancelKey);
}
function onPendingMove(e){
if(!pending) return;
const dx = e.clientX-pending.startX, dy = e.clientY-pending.startY;
if(!pending.started && Math.hypot(dx,dy) > DRAG_THRESHOLD){
pending.started = true;
beginDrag(e);
}
if(drag && drag.started){
drag.x = e.clientX; drag.y = e.clientY;
}
}
function onPendingUp(e){
window.removeEventListener('pointermove', onPendingMove);
window.removeEventListener('keydown', onCancelKey);
const wasPending = pending;
pending = null;
if(drag && drag.started){
// sync final hit-test so the drop uses the exact pointer position
drag.x = e.clientX; drag.y = e.clientY;
const t = hitTest(drag.x, drag.y);
if(t){ placePlaceholder(t); drag.lastTarget = t; }
endDrag(true); return;
}
// no drag happened → treat as click
if(wasPending){
if(wasPending.type==='card'){
const el = boardEl.querySelector(`[data-card-id="${wasPending.id}"]`);
const target = document.elementFromPoint(e.clientX,e.clientY);
if(target && target.closest('.card') && target.closest('.card').dataset.cardId===wasPending.id){
if(target.closest('.card-title')) inlineCardTitle(wasPending.id);
else openCardModal(wasPending.id);
} else if(el){
const t = el.querySelector('.card-title');
if(t && t.contains(target)) inlineCardTitle(wasPending.id);
else openCardModal(wasPending.id);
}
} else {
const colEl = boardEl.querySelector(`.column[data-col-id="${wasPending.id}"]`);
const target = document.elementFromPoint(e.clientX,e.clientY);
const title = colEl && colEl.querySelector('.col-title');
if(title && target && title.contains(target)){
inlineEdit(title, colById(wasPending.id).title, v=>{
colById(wasPending.id).title = v||colById(wasPending.id).title; save(); render();
});
}
}
}
}
function onCancelKey(e){
if(e.key!=='Escape') return;
if(drag && drag.started){ cancelDrag(); }
else if(pending){ teardownPending(); }
}
function teardownPending(){
window.removeEventListener('pointermove', onPendingMove);
window.removeEventListener('keydown', onCancelKey);
pending = null;
}
/* ---------- begin drag ---------- */
function beginDrag(e){
const p = pending;
let srcEl, rect, ph;
if(p.type==='card'){
srcEl = boardEl.querySelector(`[data-card-id="${p.id}"]`);
if(!srcEl){ teardownPending(); return; }
const f = findCard(p.id);
drag = { type:'card', id:p.id, origColId:f.colId, origIndex:f.index };
rect = srcEl.getBoundingClientRect();
ph = document.createElement('div');
ph.className = 'placeholder';
ph.style.height = rect.height+'px';
srcEl.replaceWith(ph);
} else {
srcEl = boardEl.querySelector(`.column[data-col-id="${p.id}"]`);
if(!srcEl){ teardownPending(); return; }
drag = { type:'column', id:p.id, origIndex:state.columns.findIndex(c=>c.id===p.id) };
rect = srcEl.getBoundingClientRect();
ph = document.createElement('div');
ph.className = 'col-placeholder';
ph.style.height = rect.height+'px';
srcEl.replaceWith(ph);
}
p.offX = e.clientX - rect.left;
p.offY = e.clientY - rect.top;
const ghost = srcEl.cloneNode(true);
ghost.classList.add('ghost');
ghost.style.width = rect.width+'px';
ghost.style.height = rect.height+'px';
document.body.appendChild(ghost);
drag.ghost = ghost; drag.ph = ph;
drag.x = e.clientX; drag.y = e.clientY;
drag.started = true;
drag.lastTarget = null;
document.body.classList.add('dragging');
boardEl.querySelectorAll('.empty-zone').forEach(z=>z.classList.remove('empty-zone'));
requestAnimationFrame(frame);
}
/* ---------- RAF loop: batched reads then writes ---------- */
function frame(){
if(!drag || !drag.started) return;
// ---- READS ----
const px = drag.x, py = drag.y;
const boardRect = boardEl.getBoundingClientRect();
// horizontal auto-scroll of the board
let hscroll = 0;
if(px < boardRect.left + EDGE_SCROLL_ZONE) hscroll = -speedFor(px - boardRect.left);
else if(px > boardRect.right - EDGE_SCROLL_ZONE) hscroll = speedFor(boardRect.right - px);
// vertical auto-scroll of hovered column
let vscroller = null, vscroll = 0;
for(const c of boardEl.querySelectorAll('.column')){
const r = c.getBoundingClientRect();
if(px>=r.left && px<=r.right && py>=r.top-40 && py<=r.bottom+40){
vscroller = c.querySelector('.col-cards');
const sr = vscroller.getBoundingClientRect();
if(py < sr.top + COL_EDGE_ZONE) vscroll = -speedFor(py - sr.top);
else if(py > sr.bottom - COL_EDGE_ZONE) vscroll = speedFor(sr.bottom - py);
break;
}
}
// hit-test target (reads rects)
const target = hitTest(px, py);
// ---- WRITES ----
if(hscroll) boardEl.scrollLeft += hscroll;
if(vscroller && vscroll) vscroller.scrollTop += vscroll;
if(target){
if(!drag.lastTarget || drag.lastTarget.colId!==target.colId || drag.lastTarget.visIdx!==target.visIdx){
placePlaceholder(target);
drag.lastTarget = target;
}
}
const gx = px - pending.offX, gy = py - pending.offY;
if(drag.type==='card'){
drag.ghost.style.transform = `translate(${gx}px,${gy}px) rotate(${GHOST_TILT}deg) scale(1.03)`;
} else {
drag.ghost.style.transform = `translate(${gx}px,${gy}px) scale(1.02)`;
drag.ghost.style.opacity = '.92';
}
requestAnimationFrame(frame);
}
function speedFor(dist){
const d = Math.max(0, dist);
return Math.max(2, Math.round(EDGE_SCROLL_MAX * (1 - d/EDGE_SCROLL_ZONE)));
}
/* ---------- hit testing ---------- */
function hitTest(px, py){
const cols = [...boardEl.querySelectorAll('.column')];
if(!cols.length) return null;
// column under pointer horizontally, else nearest by center
let col = cols.find(c=>{
const r = c.getBoundingClientRect();
return px>=r.left && px<=r.right;
});
if(!col){
let best = Infinity;
for(const c of cols){
const r = c.getBoundingClientRect();
const d = Math.abs(px-(r.left+r.right)/2);
if(d<best){ best=d; col=c; }
}
}
const colId = col.dataset.colId;
if(drag.type==='card'){
const wrap = col.querySelector('.col-cards');
const kids = [...wrap.children].filter(el=>
el!==drag.ph && el.classList.contains('card') && !el.classList.contains('hidden'));
let visIdx = kids.length;
for(let i=0;i<kids.length;i++){
const r = kids[i].getBoundingClientRect();
if(py < r.top + r.height/2){ visIdx=i; break; }
}
return { colId, visIdx };
}
// column drag: insertion slot among other columns
const others = cols.filter(c=>c.dataset.colId!==drag.id);
let idx = others.length;
for(let i=0;i<others.length;i++){
const r = others[i].getBoundingClientRect();
if(px < r.left + r.width/2){ idx=i; break; }
}
return { colId:null, colSlot:idx };
}
function placePlaceholder(t){
if(drag.type==='card'){
const col = boardEl.querySelector(`.column[data-col-id="${t.colId}"]`);
const wrap = col.querySelector('.col-cards');
const kids = [...wrap.children].filter(el=>
el!==drag.ph && el.classList.contains('card') && !el.classList.contains('hidden'));
if(t.visIdx >= kids.length) wrap.appendChild(drag.ph);
else kids[t.visIdx].before(drag.ph);
} else {
const others = [...boardEl.querySelectorAll('.column')].filter(c=>c!==drag.ph && c.dataset.colId!==drag.id);
if(t.colSlot >= others.length) boardEl.appendChild(drag.ph);
else others[t.colSlot].before(drag.ph);
}
}
/* ---------- commit / cancel ---------- */
function commitTarget(){
if(drag.type==='card'){
const t = drag.lastTarget || { colId:drag.origColId, visIdx:null };
const card = state.cards[drag.origColId][drag.origIndex];
state.cards[drag.origColId].splice(drag.origIndex,1);
const list = state.cards[t.colId];
let modelIdx;
if(t.visIdx===null || t.visIdx===undefined){
modelIdx = list.length;
} else {
const vis = list.filter(cardVisible);
modelIdx = t.visIdx >= vis.length ? list.length : list.indexOf(vis[t.visIdx]);
}
list.splice(modelIdx,0,card);
return card.id;
}
const t = drag.lastTarget;
const col = state.columns.splice(drag.origIndex,1)[0];
const slot = (t && t.colSlot!==undefined) ? t.colSlot : state.columns.length;
state.columns.splice(Math.min(slot, state.columns.length), 0, col);
return col.id;
}
function cleanupDragVisuals(settleId){
drag.ghost.remove(); drag.ph.remove();
document.body.classList.remove('dragging');
const wasType = drag.type;
drag = null;
render();
if(settleId && wasType==='card'){
const el = boardEl.querySelector(`[data-card-id="${settleId}"]:not(.hidden)`);
if(el) el.classList.add('settle');
}
// restore empty-zone styling
boardEl.querySelectorAll('.col-cards').forEach(w=>{
if(![...w.children].some(c=>c.classList.contains('card')&&!c.classList.contains('hidden')))
w.classList.add('empty-zone');
});
}
function endDrag(commit){
if(!drag) return;
window.removeEventListener('pointermove', onPendingMove);
window.removeEventListener('keydown', onCancelKey);
if(pending){ pending=null; }
if(commit){ const id = commitTarget(); save(); cleanupDragVisuals(id); }
else cancelDrag();
}
function cancelDrag(){
// model untouched — re-render restores exact original order
window.removeEventListener('pointermove', onPendingMove);
window.removeEventListener('keydown', onCancelKey);
pending = null;
cleanupDragVisuals(null);
}
function inlineCardTitle(cardId){
const el = boardEl.querySelector(`[data-card-id="${cardId}"] .card-title`);
const card = findCard(cardId); if(!el||!card) return;
inlineEdit(el, card.card.title, v=>{
if(v){ card.card.title = v; save(); }
render();
});
}
/* ============================================================
INIT
============================================================ */
state = load();
searchEl.value = state.filter.search || '';
render();
</script>
</body>
</html>
<!-- agent-meta {"model":"stealth/ox-alpha","provider":"openrouter","persona":"full","sessionId":"f1cd7edd-1d25-40c7-8816-f2950f78c733","tokensIn":1590625,"tokensOut":38000,"tokensTotal":1628625,"cost":0,"turns":40,"toolCalls":39,"failedToolCalls":1,"timestamp":"2026-08-21T18:50:45.158Z"} -->