Kanban
muse spark 1.2 · basic

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 39,033 bytes · SHA-256 d35cf5febff6
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Kanban — Project Board</title>
<style>
:root{
--bg:#f8f9fb;
--bg-column:#f1f2f4;
--card:#ffffff;
--text:#172b4d;
--muted:#6b778c;
--border:#e2e8f0;
--accent:#0052cc;
--accent-2:#0c66e4;
--red:#de350b;
--shadow:0 1px 3px rgba(9,30,66,.13),0 0 1px rgba(9,30,66,.13);
--shadow-lg:0 8px 16px rgba(9,30,66,.16),0 0 1px rgba(9,30,66,.13);
--radius:12px;
--radius-card:10px;
}
*{box-sizing:border-box}
html,body{margin:0;height:100%;font-family:Inter,ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,Helvetica,Arial;background:var(--bg);color:var(--text)}
button{font:inherit}
/* Header */
.app-header{
position:sticky;top:0;z-index:30;
backdrop-filter:blur(8px);
background:rgba(248,249,251,.9);
border-bottom:1px solid var(--border);
padding:14px 20px;
display:flex;align-items:center;gap:16px;flex-wrap:wrap;
}
.brand{display:flex;align-items:center;gap:10px;font-weight:750;letter-spacing:-.02em}
.brand-mark{width:32px;height:32px;border-radius:9px;background:linear-gradient(135deg,#0052cc,#2684ff);display:grid;place-items:center;color:#fff;font-weight:900;font-size:14px;box-shadow:0 2px 8px rgba(0,82,204,.35)}
.brand small{font-weight:500;color:var(--muted);font-size:12px;margin-left:6px}
.header-actions{margin-left:auto;display:flex;align-items:center;gap:10px;flex-wrap:wrap}
.search-wrap{position:relative}
.search-wrap input{
width:240px;max-width:48vw;padding:9px 12px 9px 36px;border:1px solid var(--border);border-radius:999px;background:#fff;outline:none;font-size:13.5px;
}
.search-wrap svg{position:absolute;left:11px;top:50%;transform:translateY(-50%);color:var(--muted)}
.filter-bar{display:flex;align-items:center;gap:8px;flex-wrap:wrap}
.chip{padding:6px 10px;border-radius:999px;border:1px solid var(--border);background:#fff;font-size:12px;font-weight:600;display:inline-flex;align-items:center;gap:6px;cursor:pointer;user-select:none;transition:.15s}
.chip.active{border-color:transparent;color:#fff}
.dot{width:10px;height:10px;border-radius:999px;display:inline-block}
.btn{padding:8px 14px;border-radius:999px;border:1px solid var(--border);background:#fff;cursor:pointer;font-weight:600;font-size:13px}
.btn-primary{background:var(--accent);color:#fff;border-color:var(--accent);box-shadow:0 1px 2px rgba(0,0,0,.08)}
.btn-primary:hover{background:var(--accent-2)}
.btn-ghost{background:transparent;border-color:transparent}
/* Board */
.board-wrap{overflow-x:auto;overflow-y:hidden;height:calc(100vh - 76px);scroll-behavior:auto}
.board{display:flex;gap:16px;padding:18px 20px;align-items:flex-start;min-height:100%;width:max-content;min-width:100%}
.column{
width:280px;min-width:280px;max-width:280px;
background:var(--bg-column);border-radius:12px;border:1px solid #e9eaf0;
display:flex;flex-direction:column;max-height:calc(100vh - 116px);box-shadow:0 1px 0 rgba(9,30,66,.08);
position:relative;
}
.column.wip-exceeded{border-color:#ff9e9e;box-shadow:0 0 0 2px #ffebe6}
.column.wip-exceeded .col-header{color:var(--red)}
.col-header{padding:12px 12px 8px 12px;display:flex;align-items:center;gap:8px;cursor:grab;user-select:none;flex:none;border-radius:12px 12px 0 0}
.col-header:active{cursor:grabbing}
.col-title{font-size:13px;font-weight:700;letter-spacing:.01em;flex:1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.col-title-input{flex:1;font-size:13px;font-weight:700;padding:4px 6px;border:1px solid var(--accent);border-radius:6px;outline:none;background:#fff}
.count{font-size:11px;font-weight:700;color:var(--muted);background:#e9eaf0;padding:2px 7px;border-radius:999px;min-width:22px;text-align:center}
.count.over{background:#ffebe6;color:var(--red)}
.icon-btn{width:28px;height:28px;display:grid;place-items:center;border-radius:8px;border:1px solid transparent;background:transparent;color:var(--muted);cursor:pointer}
.icon-btn:hover{background:#e2e6ea;color:var(--text);border-color:#dde1e7}
.col-menu{position:absolute;top:44px;right:8px;background:#fff;border:1px solid var(--border);border-radius:10px;box-shadow:var(--shadow-lg);padding:6px;min-width:180px;z-index:5;display:none}
.col-menu.open{display:block}
.menu-item{padding:8px 10px;border-radius:8px;font-size:13px;cursor:pointer;display:flex;align-items:center;gap:8px}
.menu-item:hover{background:#f1f5f9}
.menu-item.danger{color:var(--red)}
.cards{flex:1;overflow-y:auto;overflow-x:hidden;padding:4px 8px 8px 8px;display:flex;flex-direction:column;gap:10px;min-height:40px;scrollbar-width:thin}
.cards::-webkit-scrollbar{width:6px}
.card{
background:var(--card);border:1px solid #ebecf0;border-radius:10px;padding:11px 12px;box-shadow:var(--shadow);
cursor:grab;position:relative;transition:transform .12s ease,box-shadow .12s ease,border-color .12s;
user-select:none;
}
.card:hover{transform:translateY(-1px);box-shadow:0 4px 10px rgba(9,30,66,.12);border-color:#dde1e7}
.card.dragging-original{opacity:.0;pointer-events:none}
.card.filtered{opacity:.35;filter:saturate(.6)}
.card.filtered.hidden-search{display:none}
.card-title{font-size:13.5px;font-weight:600;line-height:1.35;word-break:break-word}
.card-desc{font-size:12.5px;color:var(--muted);margin-top:6px;line-height:1.4;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
.labels{margin-top:8px;display:flex;gap:6px;flex-wrap:wrap}
.label-chip{font-size:10.5px;font-weight:700;letter-spacing:.02em;padding:3px 7px;border-radius:999px;color:#fff}
.meta{margin-top:8px;display:flex;align-items:center;gap:8px;font-size:11.5px;color:var(--muted)}
.due{padding:3px 7px;border-radius:6px;background:#f1f5f9;border:1px solid #e2e8f0;font-weight:600}
.due.overdue{background:#ffebe6;color:#bf2600;border-color:#ffbdad}
.due.soon{background:#fff8e6;color:#974f0c;border-color:#ffe8a3}
.empty-drop{
border:1.5px dashed #c1c7d0;border-radius:10px;padding:18px;display:grid;place-items:center;color:var(--muted);font-size:12.5px;font-weight:500;background:rgba(255,255,255,.5);min-height:84px
}
.placeholder{
border:1.5px dashed #4c9aff;background:#e9f2ff;border-radius:10px;min-height:72px;transition:all .12s ease;
}
.ghost{
position:fixed;pointer-events:none;z-index:9999;transform:rotate(3deg) scale(1.03);box-shadow:var(--shadow-lg);opacity:.96;
width:280px;
}
.column.ghost-col{opacity:.9;transform:rotate(1.2deg) scale(1.02);box-shadow:var(--shadow-lg);pointer-events:none;position:fixed;z-index:9998}
.column.dragging-col{opacity:.3}
.placeholder-col{width:280px;min-width:280px;background:#dde3ea;border:1.5px dashed #9fb0c9;border-radius:12px;flex:none}
.add-card-btn{margin:8px 8px 10px 8px;padding:9px;border-radius:8px;border:none;background:transparent;color:var(--muted);font-weight:600;font-size:13px;cursor:pointer;display:flex;align-items:center;gap:6px}
.add-card-btn:hover{background:#e2e6ea;color:var(--text)}
.add-column{width:280px;min-width:280px;height:80px;border:1.5px dashed #c1c7d0;border-radius:12px;display:grid;place-items:center;background:rgba(255,255,255,.35);color:var(--muted);font-weight:650;cursor:pointer}
.add-column:hover{background:#fff;border-color:#a5adba;color:var(--text)}
/* Inline add */
.inline-add{padding:0 8px 10px 8px}
.inline-add textarea{width:100%;min-height:72px;padding:10px;border:1px solid #b6c2cf;border-radius:10px;outline:none;resize:vertical;font:inherit;font-size:13.5px;background:#fff}
.inline-actions{display:flex;gap:8px;margin-top:8px}
/* Modal */
.overlay{position:fixed;inset:0;background:rgba(9,30,66,.42);display:none;align-items:center;justify-content:center;z-index:50;padding:20px}
.overlay.open{display:flex}
.modal{width:480px;max-width:100%;background:#fff;border-radius:16px;box-shadow:0 24px 48px rgba(0,0,0,.2);overflow:hidden;max-height:90vh;display:flex;flex-direction:column}
.modal-head{padding:16px 18px;border-bottom:1px solid var(--border);display:flex;align-items:center;gap:12px}
.modal-body{padding:18px;overflow:auto;display:flex;flex-direction:column;gap:14px}
.field label{font-size:12px;font-weight:700;color:var(--muted);letter-spacing:.04em;text-transform:uppercase}
.field input,.field textarea{width:100%;margin-top:6px;padding:10px 12px;border:1px solid #dfe1e6;border-radius:10px;outline:none;font:inherit;font-size:14px;background:#fff}
.field textarea{min-height:84px;resize:vertical}
.labels-grid{display:flex;flex-wrap:wrap;gap:8px;margin-top:8px}
.labels-grid .chip{padding:7px 12px}
.modal-foot{padding:14px 18px;border-top:1px solid var(--border);display:flex;justify-content:space-between;align-items:center;gap:10px}
.toast{
position:fixed;left:50%;bottom:20px;transform:translateX(-50%);background:#172b4d;color:#fff;padding:12px 14px;border-radius:999px;display:none;align-items:center;gap:12px;box-shadow:0 8px 24px rgba(0,0,0,.25);z-index:60;font-size:13px;font-weight:600
}
.toast.show{display:flex}
.toast button{background:#fff;color:#172b4d;border:none;padding:6px 12px;border-radius:999px;font-weight:800;cursor:pointer}
.wip-input{width:80px;padding:7px 9px;border:1px solid var(--border);border-radius:8px}
.drag-hint{font-size:11px;color:var(--muted);font-weight:500}
</style>
</head>
<body>
<header class="app-header">
<div class="brand">
<div class="brand-mark">▦</div>
<div>Boardly <small>Kanban</small></div>
<span class="drag-hint" style="margin-left:10px">Drag cards & columns • Esc to cancel</span>
</div>
<div class="filter-bar" id="filterBar"></div>
<div class="header-actions">
<div class="search-wrap">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="7"/><path d="m20 20-3.5-3.5"/></svg>
<input id="searchInput" placeholder="Search cards…" autocomplete="off">
</div>
<button class="btn" id="clearFiltersBtn">Clear filters</button>
</div>
</header>
<div class="board-wrap" id="boardWrap">
<div class="board" id="board"></div>
</div>
<div class="overlay" id="cardModal">
<div class="modal" role="dialog" aria-modal="true">
<div class="modal-head">
<div style="font-weight:800">Edit card</div>
<button class="icon-btn" style="margin-left:auto" onclick="closeModal()">✕</button>
</div>
<div class="modal-body">
<div class="field"><label>Title</label><input id="mTitle"></div>
<div class="field"><label>Description</label><textarea id="mDesc" placeholder="Add a detailed description…"></textarea></div>
<div class="field"><label>Due date</label><input type="date" id="mDue"></div>
<div class="field"><label>Labels</label><div class="labels-grid" id="mLabels"></div></div>
</div>
<div class="modal-foot">
<button class="btn" style="color:var(--red);border-color:#ffbdad;background:#ffebe6" id="deleteCardBtn">Delete card</button>
<div style="display:flex;gap:8px">
<button class="btn btn-ghost" onclick="closeModal()">Cancel</button>
<button class="btn btn-primary" id="saveCardBtn">Save</button>
</div>
</div>
</div>
</div>
<div class="toast" id="toast"><span id="toastMsg">Deleted</span><button id="undoBtn">Undo</button></div>
<script>
// ===================== TUNABLE CONSTANTS =====================
const CONFIG = {
STORAGE_KEY: 'kanban-board-v2-ultra',
DRAG_THRESHOLD: 6, // px before drag starts
AUTO_SCROLL_EDGE: 72, // px from edge to trigger scroll
AUTO_SCROLL_SPEED: 14, // px per frame
COLUMN_WIDTH: 280,
LABELS: [
{id:'l1', name:'Bug', color:'#ef4444'},
{id:'l2', name:'Feature', color:'#0ea5e9'},
{id:'l3', name:'Design', color:'#8b5cf6'},
{id:'l4', name:'Research', color:'#f59e0b'},
{id:'l5', name:'Improvement', color:'#10b981'},
{id:'l6', name:'Urgent', color:'#ec4899'},
]
};
// ===================== STATE =====================
let board = null;
let editingCard = null; // {colId, cardId}
let undoStack = [];
let filterState = {search:'', labels:new Set()};
function uid(){ return Math.random().toString(36).slice(2,9)+Date.now().toString(36).slice(-4) }
function defaultBoard(){
const cols = ['Backlog','To Do','In Progress','Review','Done'].map(t=>({id:uid(),title:t,wip:null,cards:[]}));
cols[0].cards = [
{id:uid(),title:'Research competitor pricing',description:'Collect data from Linear, Trello and Asana for comparison table.',labels:['l4'],dueDate:''},
{id:uid(),title:'Setup project repo & CI',description:'Init vite, husky, github actions.',labels:['l2'],dueDate:''},
];
cols[1].cards = [
{id:uid(),title:'Design kanban drag interactions',description:'Spec live indicator, auto-scroll and escape cancel.',labels:['l3','l6'],dueDate:daysFromNow(2)},
{id:uid(),title:'Build column menu + WIP limits',description:'Highlight red when exceeded but never block drops.',labels:['l2'],dueDate:''},
];
cols[2].cards = [
{id:uid(),title:'Implement card CRUD modal',description:'Title, desc, labels, due date.',labels:['l2','l1'],dueDate:daysFromNow(1)},
];
cols[3].cards = [];
cols[4].cards = [
{id:uid(),title:'Initial release notes',description:'Changelog for v0.1',labels:['l5'],dueDate:''},
];
return {columns:cols}
}
function daysFromNow(n){ const d=new Date(); d.setDate(d.getDate()+n); return d.toISOString().slice(0,10)}
function load(){
try{
const raw=localStorage.getItem(CONFIG.STORAGE_KEY);
if(!raw) return null;
const parsed=JSON.parse(raw);
if(!parsed.columns) return null;
filterState.search = parsed.filterState?.search||'';
filterState.labels = new Set(parsed.filterState?.labels||[]);
return {columns:parsed.columns};
}catch{ return null}
}
function save(){
localStorage.setItem(CONFIG.STORAGE_KEY, JSON.stringify({
columns:board.columns,
filterState:{search:filterState.search, labels:[...filterState.labels]}
}));
}
function pushUndo(msg, snapshot){
undoStack.push({msg, snapshot: JSON.stringify(snapshot)});
if(undoStack.length>20) undoStack.shift();
showToast(msg);
}
// ===================== RENDER =====================
const boardEl = document.getElementById('board');
const boardWrap = document.getElementById('boardWrap');
const filterBar = document.getElementById('filterBar');
const searchInput = document.getElementById('searchInput');
function labelDef(id){ return CONFIG.LABELS.find(l=>l.id===id) }
function renderFilters(){
filterBar.innerHTML='';
CONFIG.LABELS.forEach(l=>{
const active = filterState.labels.has(l.id);
const b=document.createElement('button');
b.className='chip'+(active?' active':'');
b.style.background = active? l.color : '#fff';
b.style.color = active? '#fff' : 'var(--text)';
b.innerHTML=`<span class="dot" style="background:${l.color};border:1px solid rgba(0,0,0,.08)"></span>${l.name}`;
b.onclick=()=>{
if(active) filterState.labels.delete(l.id); else filterState.labels.add(l.id);
save(); render();
};
filterBar.appendChild(b);
});
searchInput.value = filterState.search;
}
function cardMatches(card){
const q = filterState.search.trim().toLowerCase();
const bySearch = !q || card.title.toLowerCase().includes(q);
const byLabels = filterState.labels.size===0 || card.labels.some(id=>filterState.labels.has(id));
return bySearch && byLabels;
}
function renderBoard(){
boardEl.innerHTML='';
board.columns.forEach((col, colIndex)=>{
const colEl=document.createElement('div');
colEl.className='column'+(col.wip!=null && col.cards.length>col.wip ? ' wip-exceeded':'');
colEl.dataset.colId=col.id;
colEl.dataset.index=colIndex;
const header=document.createElement('div');
header.className='col-header';
header.innerHTML=`
<span style="color:#8b95a5;cursor:grab">⠿</span>
<div class="col-title" data-col-title>${escapeHtml(col.title)}</div>
<span class="count ${col.wip!=null && col.cards.length>col.wip?'over':''}">${col.cards.length}${col.wip!=null? ' / '+col.wip : ''}</span>
<button class="icon-btn col-menu-btn" title="Column menu">⋯</button>
`;
// title inline edit
const titleEl = header.querySelector('[data-col-title]');
titleEl.addEventListener('click', (e)=>{
e.stopPropagation();
const input=document.createElement('input');
input.className='col-title-input'; input.value=col.title;
titleEl.replaceWith(input); input.focus(); input.select();
const commit=()=>{
const v=input.value.trim();
if(v && v!==col.title){ col.title=v; save(); renderBoard(); }
else renderBoard();
};
input.addEventListener('keydown',ev=>{
if(ev.key==='Enter') commit();
if(ev.key==='Escape') renderBoard();
});
input.addEventListener('blur',commit);
});
// column drag handle is header
header.addEventListener('pointerdown', (e)=> startColumnDrag(e, col.id));
// menu
const menuBtn = header.querySelector('.col-menu-btn');
const menu = document.createElement('div');
menu.className='col-menu';
menu.innerHTML=`
<div class="menu-item" data-action="rename">✎ Rename column</div>
<div class="menu-item" data-action="wip">⚑ WIP limit: <input class="wip-input" type="number" min="0" placeholder="—" value="${col.wip??''}" style="margin-left:6px"> </div>
<div class="menu-item danger" data-action="delete">🗑 Delete column</div>
`;
menuBtn.addEventListener('click',(e)=>{
e.stopPropagation();
document.querySelectorAll('.col-menu.open').forEach(m=>{ if(m!==menu) m.classList.remove('open')});
menu.classList.toggle('open');
});
menu.addEventListener('click', (e)=>{
const act = e.target.closest('.menu-item')?.dataset.action;
if(act==='rename'){
menu.classList.remove('open');
titleEl.click();
}
if(act==='delete'){
if(board.columns.length<=1){ alert('Keep at least one column'); return; }
pushUndo('Column deleted', board);
board.columns = board.columns.filter(c=>c.id!==col.id);
save(); render();
}
});
const wipInput = menu.querySelector('.wip-input');
wipInput.addEventListener('keydown', e=>{
if(e.key==='Enter'){
const v=wopVal(wipInput.value);
col.wip=v; save(); renderBoard();
}
if(e.key==='Escape') renderBoard();
});
wipInput.addEventListener('blur',()=>{
const v=wopVal(wipInput.value); col.wip=v; save(); renderBoard();
});
wipInput.addEventListener('click',e=>e.stopPropagation());
function wopVal(v){ if(v===''||v==null) return null; const n=parseInt(v,10); return isNaN(n)?null:n }
colEl.appendChild(header);
colEl.appendChild(menu);
const cardsWrap=document.createElement('div');
cardsWrap.className='cards';
cardsWrap.dataset.cardsWrap='1';
if(col.cards.length===0){
const empty=document.createElement('div');
empty.className='empty-drop';
empty.textContent='Drop cards here';
cardsWrap.appendChild(empty);
}
col.cards.forEach(card=>{
const cEl=makeCardEl(card, col.id);
// filtering: hide via class
const matches = cardMatches(card);
if(!matches){
// keep layout but dim; if search active hide completely for clarity
if(filterState.search) cEl.classList.add('hidden-search');
else cEl.classList.add('filtered');
}
cardsWrap.appendChild(cEl);
});
colEl.appendChild(cardsWrap);
const addBtn=document.createElement('button');
addBtn.className='add-card-btn';
addBtn.innerHTML=`<span style="font-size:16px">+</span> Add card`;
addBtn.onclick=()=> showInlineAdd(col.id, colEl);
colEl.appendChild(addBtn);
boardEl.appendChild(colEl);
});
// add column tile
const addCol=document.createElement('div');
addCol.className='add-column';
addCol.textContent='+ Add column';
addCol.onclick=()=>{
const title=prompt('Column title','New column');
if(!title) return;
board.columns.push({id:uid(),title:title.trim(),wip:null,cards:[]});
save(); render();
};
boardEl.appendChild(addCol);
// close menus on click outside
document.addEventListener('click',()=> document.querySelectorAll('.col-menu.open').forEach(m=>m.classList.remove('open')),{once:true});
renderFilters();
}
function makeCardEl(card, colId){
const el=document.createElement('div');
el.className='card';
el.dataset.cardId=card.id;
el.dataset.colId=colId;
const dueHtml = card.dueDate ? `<span class="due ${dueClass(card.dueDate)}">${formatDue(card.dueDate)}</span>` : '';
el.innerHTML=`
<div class="card-title">${escapeHtml(card.title)}</div>
${card.description? `<div class="card-desc">${escapeHtml(card.description)}</div>`:''}
${card.labels.length? `<div class="labels">${card.labels.map(id=>{const d=labelDef(id); return `<span class="label-chip" style="background:${d.color}">${d.name}</span>`}).join('')}</div>`:''}
${dueHtml? `<div class="meta">${dueHtml}</div>`:''}
`;
el.addEventListener('pointerdown', (e)=> startCardDrag(e, card.id, colId));
el.addEventListener('click', (e)=>{
if(dragState && dragState.hasMoved) return;
if(e.target.closest('button')) return;
openModal(colId, card.id);
});
return el;
}
function formatDue(s){
const d=new Date(s); const now=new Date(); now.setHours(0,0,0,0);
const diff=Math.round((d - now)/86400000);
if(diff===0) return 'Today';
if(diff===1) return 'Tomorrow';
if(diff===-1) return 'Yesterday';
return d.toLocaleDateString(undefined,{month:'short',day:'numeric'});
}
function dueClass(s){
const d=new Date(s); d.setHours(0,0,0,0);
const now=new Date(); now.setHours(0,0,0,0);
const diff=(d-now)/86400000;
if(diff<0) return 'overdue';
if(diff<=1) return 'soon';
return '';
}
function escapeHtml(s){ return s.replace(/[&<>"']/g, c=>({ '&':'&','<':'<','>':'>','"':'"',"'":'''}[c])) }
// ===================== INLINE ADD =====================
function showInlineAdd(colId, colEl){
const existing = colEl.querySelector('.inline-add');
if(existing) return;
const wrap=document.createElement('div');
wrap.className='inline-add';
wrap.innerHTML=`
<textarea placeholder="Enter card title…"></textarea>
<div class="inline-actions">
<button class="btn btn-primary">Add</button>
<button class="btn btn-ghost">Cancel</button>
</div>
`;
const cardsWrap = colEl.querySelector('.cards');
cardsWrap.appendChild(wrap);
const ta=wrap.querySelector('textarea'); ta.focus();
const add=wrap.querySelector('.btn-primary');
const cancel=wrap.querySelector('.btn-ghost');
const commit=()=>{
const t=ta.value.trim();
if(!t) return;
const col=board.columns.find(c=>c.id===colId);
col.cards.push({id:uid(),title:t,description:'',labels:[],dueDate:''});
save(); render();
};
add.onclick=commit;
ta.addEventListener('keydown', e=>{
if(e.key==='Enter' && !e.shiftKey){ e.preventDefault(); commit(); }
if(e.key==='Escape'){ wrap.remove(); }
});
cancel.onclick=()=>wrap.remove();
}
// ===================== MODAL =====================
const modalEl=document.getElementById('cardModal');
const mTitle=document.getElementById('mTitle');
const mDesc=document.getElementById('mDesc');
const mDue=document.getElementById('mDue');
const mLabelsEl=document.getElementById('mLabels');
function openModal(colId, cardId){
const col=board.columns.find(c=>c.id===colId);
const card=col.cards.find(c=>c.id===cardId);
editingCard={colId,cardId};
mTitle.value=card.title;
mDesc.value=card.description;
mDue.value=card.dueDate||'';
mLabelsEl.innerHTML='';
CONFIG.LABELS.forEach(l=>{
const active=card.labels.includes(l.id);
const b=document.createElement('button');
b.className='chip'+(active?' active':'');
b.style.background= active? l.color : '#fff';
b.style.borderColor= active? 'transparent' : 'var(--border)';
b.style.color= active? '#fff' : 'var(--text)';
b.innerHTML=`<span class="dot" style="background:${l.color}"></span>${l.name}`;
b.onclick=()=>{
if(card.labels.includes(l.id)) card.labels=card.labels.filter(x=>x!==l.id);
else card.labels.push(l.id);
openModal(colId,cardId);
};
mLabelsEl.appendChild(b);
});
modalEl.classList.add('open');
mTitle.focus(); mTitle.select();
}
function closeModal(){ modalEl.classList.remove('open'); editingCard=null; }
modalEl.addEventListener('click', e=>{ if(e.target===modalEl) closeModal(); });
document.getElementById('saveCardBtn').onclick=()=>{
if(!editingCard) return;
const col=board.columns.find(c=>c.id===editingCard.colId);
const card=col.cards.find(c=>c.id===editingCard.cardId);
const t=mTitle.value.trim();
if(!t){ alert('Title required'); return; }
card.title=t; card.description=mDesc.value; card.dueDate=mDue.value;
save(); closeModal(); render();
};
document.getElementById('deleteCardBtn').onclick=()=>{
if(!editingCard) return;
const col=board.columns.find(c=>c.id===editingCard.colId);
pushUndo('Card deleted', board);
col.cards = col.cards.filter(c=>c.id!==editingCard.cardId);
save(); closeModal(); render();
};
// ===================== DRAG & DROP (pointer events) =====================
let dragState=null;
let autoScrollRAF=null;
let lastPointer={x:0,y:0};
function startCardDrag(e, cardId, colId){
if(e.button!==0) return;
// don't start if clicking input
if(e.target.closest('input,textarea,button')) return;
const cardEl = e.currentTarget;
const rect = cardEl.getBoundingClientRect();
const offsetX = e.clientX - rect.left;
const offsetY = e.clientY - rect.top;
const col = board.columns.find(c=>c.id===colId);
const idx = col.cards.findIndex(c=>c.id===cardId);
dragState={
type:'card', cardId, colId, sourceIndex:idx, sourceColId:colId,
offsetX, offsetY, startX:e.clientX, startY:e.clientY,
hasMoved:false, ghost:null, placeholder:null, origCardEl:cardEl,
currentColId:colId, currentIndex:idx
};
cardEl.setPointerCapture?.(e.pointerId);
window.addEventListener('pointermove', onPointerMove);
window.addEventListener('pointerup', onPointerUp);
window.addEventListener('keydown', onKeyDown);
e.preventDefault();
}
function startColumnDrag(e, colId){
if(e.button!==0) return;
// only drag via header
if(e.target.closest('.icon-btn, input')) return;
const colEl = e.currentTarget.closest('.column');
const rect = colEl.getBoundingClientRect();
const startX=e.clientX, startY=e.clientY;
dragState={
type:'column', colId,
startX, startY, offsetX: e.clientX - rect.left, offsetY: e.clientY - rect.top,
hasMoved:false, ghost:null, placeholder:null, origColEl:colEl
};
window.addEventListener('pointermove', onPointerMoveCol);
window.addEventListener('pointerup', onPointerUpCol);
window.addEventListener('keydown', onKeyDown);
e.preventDefault();
}
function ensureCardDragStarted(){
if(!dragState || dragState.type!=='card' || dragState.hasMoved) return;
dragState.hasMoved=true;
const cardEl=dragState.origCardEl;
// ghost
const ghost=cardEl.cloneNode(true);
ghost.classList.add('ghost');
ghost.style.width = cardEl.offsetWidth+'px';
ghost.style.height = cardEl.offsetHeight+'px';
document.body.appendChild(ghost);
dragState.ghost=ghost;
// placeholder
const ph=document.createElement('div');
ph.className='placeholder';
ph.style.height=cardEl.offsetHeight+'px';
dragState.placeholder=ph;
// hide original
cardEl.classList.add('dragging-original');
// insert placeholder at original position
cardEl.parentNode.insertBefore(ph, cardEl.nextSibling);
// start auto scroll loop
startAutoScroll();
updateGhostPos(lastPointer.x, lastPointer.y);
}
function onPointerMove(e){
lastPointer={x:e.clientX,y:e.clientY};
if(!dragState || dragState.type!=='card') return;
const dist=Math.hypot(e.clientX-dragState.startX, e.clientY-dragState.startY);
if(!dragState.hasMoved && dist < CONFIG.DRAG_THRESHOLD) return;
if(!dragState.hasMoved) ensureCardDragStarted();
if(!dragState.hasMoved) return;
updateGhostPos(e.clientX, e.clientY);
// batched hit test (read DOM)
requestAnimationFrame(()=> doCardHitTest(e.clientX, e.clientY));
}
function updateGhostPos(x,y){
if(!dragState.ghost) return;
dragState.ghost.style.left = (x - dragState.offsetX)+'px';
dragState.ghost.style.top = (y - dragState.offsetY)+'px';
}
function doCardHitTest(x,y){
if(!dragState || !dragState.placeholder) return;
// find column under x
const cols = [...document.querySelectorAll('.column')];
let targetColId=null, targetColEl=null;
for(const c of cols){
const r=c.getBoundingClientRect();
if(x >= r.left && x <= r.right){
targetColId=c.dataset.colId;
targetColEl=c;
break;
}
}
// if not over any column, pick nearest
if(!targetColEl){
// find closest column by horizontal distance
let best=null,bestDist=Infinity;
cols.forEach(c=>{
const r=c.getBoundingClientRect();
const cx=r.left + r.width/2;
const d=Math.abs(x-cx);
if(d<bestDist){bestDist=d; best=c}
});
if(best){ targetColEl=best; targetColId=best.dataset.colId; }
else return;
}
const cardsWrap = targetColEl.querySelector('.cards');
const cards = [...cardsWrap.querySelectorAll('.card:not(.dragging-original)')];
// filter visible cards only? include placeholder sibling logic, but we exclude dragging original
let insertBefore=null, index=0;
if(cards.length===0){
// empty column
cardsWrap.appendChild(dragState.placeholder);
index=0;
} else {
let placed=false;
for(let i=0;i<cards.length;i++){
const r=cards[i].getBoundingClientRect();
const mid=r.top + r.height/2;
if(y < mid){
cardsWrap.insertBefore(dragState.placeholder, cards[i]);
index=i;
placed=true; break;
}
}
if(!placed){
cardsWrap.appendChild(dragState.placeholder);
index=cards.length;
}
}
dragState.currentColId=targetColId;
dragState.currentIndex=index;
// empty drop visual
cardsWrap.querySelectorAll('.empty-drop').forEach(e=>{
e.style.display = cardsWrap.contains(dragState.placeholder) ? 'none' : '';
});
}
function onPointerUp(e){
window.removeEventListener('pointermove', onPointerMove);
window.removeEventListener('pointerup', onPointerUp);
window.removeEventListener('keydown', onKeyDown);
stopAutoScroll();
if(!dragState) return;
if(!dragState.hasMoved){
dragState=null; return;
}
// commit to model
const {cardId, sourceColId, currentColId, currentIndex, sourceIndex} = dragState;
const sourceCol=board.columns.find(c=>c.id===sourceColId);
const targetCol=board.columns.find(c=>c.id===currentColId);
const cardIdx = sourceCol.cards.findIndex(c=>c.id===cardId);
if(cardIdx!==-1){
const [card] = sourceCol.cards.splice(cardIdx,1);
// adjust index if same column and moving down
let insertAt=currentIndex;
if(sourceColId===currentColId && cardIdx < currentIndex) insertAt = currentIndex-1;
// clamp
insertAt = Math.max(0, Math.min(insertAt, targetCol.cards.length));
targetCol.cards.splice(insertAt,0,card);
save();
}
cleanupCardDrag();
renderBoard();
}
function cleanupCardDrag(){
if(dragState?.ghost) dragState.ghost.remove();
if(dragState?.placeholder) dragState.placeholder.remove();
if(dragState?.origCardEl) dragState.origCardEl.classList.remove('dragging-original');
dragState=null;
document.querySelectorAll('.empty-drop').forEach(e=>e.style.display='');
}
function onKeyDown(e){
if(e.key==='Escape' && dragState){
// cancel
if(dragState.type==='card'){
window.removeEventListener('pointermove', onPointerMove);
window.removeEventListener('pointerup', onPointerUp);
// also column listeners
window.removeEventListener('pointermove', onPointerMoveCol);
window.removeEventListener('pointerup', onPointerUpCol);
window.removeEventListener('keydown', onKeyDown);
stopAutoScroll();
cleanupCardDrag();
renderBoard();
} else if(dragState.type==='column'){
window.removeEventListener('pointermove', onPointerMoveCol);
window.removeEventListener('pointerup', onPointerUpCol);
window.removeEventListener('keydown', onKeyDown);
stopAutoScroll();
cleanupColumnDrag(true);
renderBoard();
}
}
}
// -------- Column drag ----------
function onPointerMoveCol(e){
lastPointer={x:e.clientX,y:e.clientY};
if(!dragState || dragState.type!=='column') return;
const dist=Math.hypot(e.clientX-dragState.startX, e.clientY-dragState.startY);
if(!dragState.hasMoved && dist < CONFIG.DRAG_THRESHOLD) return;
if(!dragState.hasMoved){
dragState.hasMoved=true;
const colEl=dragState.origColEl;
const ghost=colEl.cloneNode(true);
ghost.classList.add('ghost-col');
ghost.style.width=colEl.offsetWidth+'px';
ghost.style.height=colEl.offsetHeight+'px';
ghost.style.left=(e.clientX - dragState.offsetX)+'px';
ghost.style.top=(e.clientY - dragState.offsetY)+'px';
document.body.appendChild(ghost);
dragState.ghost=ghost;
const ph=document.createElement('div');
ph.className='placeholder-col';
ph.style.height=colEl.offsetHeight+'px';
dragState.placeholder=ph;
colEl.classList.add('dragging-col');
colEl.parentNode.insertBefore(ph, colEl.nextSibling);
startAutoScroll();
}
if(dragState.ghost){
dragState.ghost.style.left=(e.clientX - dragState.offsetX)+'px';
dragState.ghost.style.top=(e.clientY - dragState.offsetY)+'px';
}
requestAnimationFrame(()=> doColumnHitTest(e.clientX));
}
function doColumnHitTest(x){
if(!dragState.placeholder) return;
const boardCols=[...boardEl.children].filter(el=>el.classList.contains('column')||el.classList.contains('placeholder-col'));
// find insertion point by x mid
let inserted=false;
for(const el of boardCols){
if(el===dragState.placeholder) continue;
if(el.classList.contains('add-column')) continue;
const r=el.getBoundingClientRect();
const mid=r.left + r.width/2;
if(x < mid){
boardEl.insertBefore(dragState.placeholder, el);
inserted=true; break;
}
}
if(!inserted){
const addColEl=boardEl.querySelector('.add-column');
boardEl.insertBefore(dragState.placeholder, addColEl);
}
}
function onPointerUpCol(e){
window.removeEventListener('pointermove', onPointerMoveCol);
window.removeEventListener('pointerup', onPointerUpCol);
window.removeEventListener('keydown', onKeyDown);
stopAutoScroll();
if(!dragState) return;
if(!dragState.hasMoved){ dragState=null; return; }
// compute new index
const cols=[...boardEl.children];
const phIndex = cols.indexOf(dragState.placeholder);
// count columns before placeholder excluding placeholder itself to map to board.columns index
let boardIdx=0;
for(let i=0;i<cols.length;i++){
const el=cols[i];
if(el===dragState.placeholder){ break; }
if(el.classList.contains('column')) boardIdx++;
if(el.classList.contains('placeholder-col')) continue;
}
// reorder board.columns
const srcIdx = board.columns.findIndex(c=>c.id===dragState.colId);
if(srcIdx!==-1){
const [col] = board.columns.splice(srcIdx,1);
let insertAt=boardIdx;
if(srcIdx < boardIdx) insertAt = boardIdx-1;
insertAt=Math.max(0,Math.min(insertAt, board.columns.length));
board.columns.splice(insertAt,0,col);
save();
}
cleanupColumnDrag(false);
renderBoard();
}
function cleanupColumnDrag(cancel){
if(dragState?.ghost) dragState.ghost.remove();
if(dragState?.placeholder) dragState.placeholder.remove();
if(dragState?.origColEl) dragState.origColEl.classList.remove('dragging-col');
dragState=null;
}
// -------- Auto scroll ----------
function startAutoScroll(){
if(autoScrollRAF) return;
const tick=()=>{
if(!dragState || !dragState.hasMoved){ stopAutoScroll(); return; }
const x=lastPointer.x, y=lastPointer.y;
// horizontal board scroll
const bwRect=boardWrap.getBoundingClientRect();
if(x < bwRect.left + CONFIG.AUTO_SCROLL_EDGE) boardWrap.scrollLeft -= CONFIG.AUTO_SCROLL_SPEED;
else if(x > bwRect.right - CONFIG.AUTO_SCROLL_EDGE) boardWrap.scrollLeft += CONFIG.AUTO_SCROLL_SPEED;
// vertical column scroll
if(dragState.type==='card' && dragState.currentColId){
const colEl=document.querySelector(`.column[data-col-id="${dragState.currentColId}"] .cards`);
if(colEl){
const r=colEl.getBoundingClientRect();
if(y < r.top + 60) colEl.scrollTop -= CONFIG.AUTO_SCROLL_SPEED;
else if(y > r.bottom - 60) colEl.scrollTop += CONFIG.AUTO_SCROLL_SPEED;
}
}
autoScrollRAF=requestAnimationFrame(tick);
};
autoScrollRAF=requestAnimationFrame(tick);
}
function stopAutoScroll(){ if(autoScrollRAF){ cancelAnimationFrame(autoScrollRAF); autoScrollRAF=null; } }
// ===================== SEARCH & FILTERS =====================
searchInput.addEventListener('input', e=>{
filterState.search=e.target.value;
save(); render();
});
document.getElementById('clearFiltersBtn').onclick=()=>{
filterState.search=''; filterState.labels.clear(); save(); render();
};
// ===================== TOAST / UNDO =====================
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(()=> toastEl.classList.remove('show'), 3500);
}
document.getElementById('undoBtn').onclick=()=>{
if(!undoStack.length) return;
const last=undoStack.pop();
const snap=JSON.parse(last.snapshot);
board = snap;
save(); render();
toastEl.classList.remove('show');
};
// ===================== INIT =====================
function render(){ renderBoard(); }
(function init(){
const loaded=load();
board = loaded || defaultBoard();
// ensure search input reflects
searchInput.value=filterState.search||'';
render();
// persist on before unload
window.addEventListener('blur', save);
})();
// close modal on escape already handled via drag; also add generic
window.addEventListener('keydown', e=>{
if(e.key==='Escape' && modalEl.classList.contains('open') && !dragState) closeModal();
});
</script>
</body>
</html>
<!-- agent-meta {"model":"meta/muse-spark-1.2","provider":"openrouter","persona":"basic","sessionId":"3eb3585e-af5e-458c-ace1-6bd9c87158dc","tokensIn":1099,"tokensOut":12709,"tokensTotal":13808,"cost":0.0552627,"turns":1,"toolCalls":0,"failedToolCalls":0,"timestamp":"2026-08-06T15:19:52.732Z"} -->