Kanban
muse spark 1.2 contributor · full

Browser check: Artifact runtime error. This is a technical check, not a quality review.
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 49,773 bytes · SHA-256 752f5a217845
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Flow — Kanban Board</title>
<style>
/* ===== RESET & TOKENS ===== */
*{margin:0;padding:0;box-sizing:border-box}
:root{
--bg:#f8f9fb;
--bg-column:#f1f3f5;
--card-bg:#ffffff;
--border:#e6e8eb;
--border-strong:#d8dbdf;
--text:#1a1d23;
--text-muted:#6b7280;
--text-faint:#9ca3af;
--accent:#6366f1;
--accent-hover:#5457e5;
--radius:12px;
--radius-sm:8px;
--shadow-sm:0 1px 2px rgba(0,0,0,.06),0 1px 3px rgba(0,0,0,.04);
--shadow-md:0 4px 12px rgba(0,0,0,.08),0 1px 4px rgba(0,0,0,.06);
--shadow-lg:0 12px 32px rgba(0,0,0,.14),0 4px 12px rgba(0,0,0,.08);
--wip-red:#ef4444;
--wip-bg:#fef2f2;
}
body{font-family:Inter, ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial; background:var(--bg); color:var(--text); overflow:hidden; height:100vh; display:flex; flex-direction:column; -webkit-font-smoothing:antialiased}
button{font:inherit; cursor:pointer}
input,textarea,select{font:inherit}
/* ===== HEADER ===== */
.app-header{
position:sticky; top:0; z-index:30;
background:rgba(255,255,255,.92); backdrop-filter:blur(12px);
border-bottom:1px solid var(--border);
padding:12px 20px; display:flex; align-items:center; gap:16px; flex-wrap:wrap;
}
.logo{display:flex; align-items:center; gap:10px; font-weight:700; font-size:18px; letter-spacing:-.02em}
.logo-mark{width:28px;height:28px; border-radius:8px; background:var(--text); color:#fff; display:grid; place-items:center; font-size:14px}
.header-actions{display:flex; align-items:center; gap:10px; flex:1; justify-content:flex-end; flex-wrap:wrap}
.search-wrap{position:relative; flex:1; max-width:320px; min-width:180px}
.search-wrap input{width:100%; padding:8px 12px 8px 34px; border:1px solid var(--border); border-radius:999px; background:#fff; font-size:13.5px; outline:none; transition:border .15s, box-shadow .15s}
.search-wrap input:focus{border-color:var(--accent); box-shadow:0 0 0 3px rgba(99,102,241,.15)}
.search-wrap svg{position:absolute; left:11px; top:50%; transform:translateY(-50%); color:var(--text-faint); pointer-events:none}
.filter-chips{display:flex; gap:6px; align-items:center; flex-wrap:wrap}
.chip{padding:5px 10px; border-radius:999px; font-size:12.5px; font-weight:500; border:1px solid transparent; cursor:pointer; transition:all .15s; display:flex; align-items:center; gap:6px; user-select:none}
.chip.active{box-shadow:0 0 0 2px #fff, 0 0 0 4px currentColor}
.chip .dot{width:8px;height:8px;border-radius:50%}
.btn{padding:8px 14px; border-radius:999px; border:1px solid var(--border); background:#fff; font-size:13px; font-weight:500; transition:all .15s; white-space:nowrap}
.btn:hover{background:#f9fafb; border-color:var(--border-strong)}
.btn-primary{background:var(--text); color:#fff; border-color:var(--text)}
.btn-primary:hover{background:#2a2e38}
.btn-ghost{border-color:transparent; background:transparent}
.btn-ghost:hover{background:#f1f3f5}
/* ===== BOARD ===== */
.board-wrap{flex:1; overflow:auto; padding:20px; scroll-behavior:auto; scrollbar-width:thin}
.board{display:flex; gap:16px; align-items:flex-start; min-height:100%; padding-bottom:40px}
.column{
width:296px; min-width:296px; background:var(--bg-column); border-radius:16px; border:1px solid var(--border);
display:flex; flex-direction:column; max-height:calc(100vh - 96px); transition:border-color .2s, background .2s;
}
.column.wip-exceeded{border-color:var(--wip-red); background:var(--wip-bg); box-shadow:0 0 0 1px rgba(239,68,68,.15) inset}
.column.dragging-col{opacity:.4; transform:rotate(1deg)}
.column-header{
padding:14px 12px 10px; display:flex; align-items:center; gap:8px; cursor:grab; user-select:none; position:relative;
border-radius:16px 16px 0 0;
}
.column-header:active{cursor:grabbing}
.col-title{font-weight:600; font-size:13.5px; flex:1; cursor:text; padding:4px 6px; border-radius:6px; border:1px solid transparent; transition:all .15s; white-space:nowrap; overflow:hidden; text-overflow:ellipsis}
.col-title:hover{background:rgba(0,0,0,.04)}
.col-title[contenteditable="true"]{background:#fff; border-color:var(--accent); box-shadow:0 0 0 3px rgba(99,102,241,.12); outline:none; cursor:text}
.col-count{font-size:12px; font-weight:600; color:var(--text-muted); background:#fff; border:1px solid var(--border); padding:2px 7px; border-radius:999px; min-width:22px; text-align:center}
.col-count.over{color:#fff; background:var(--wip-red); border-color:var(--wip-red)}
.col-menu-btn{width:28px;height:28px; border-radius:8px; border:none; background:transparent; display:grid; place-items:center; color:var(--text-faint); transition:all .15s}
.col-menu-btn:hover{background:#fff; color:var(--text); box-shadow:var(--shadow-sm)}
.col-cards{flex:1; overflow-y:auto; overflow-x:hidden; padding:2px 8px 8px; display:flex; flex-direction:column; gap:10px; min-height:60px; scrollbar-width:thin; scroll-behavior:auto}
.col-cards::-webkit-scrollbar{width:6px}
.col-cards::-webkit-scrollbar-thumb{background:#d1d5db; border-radius:999px}
.col-footer{padding:8px; border-top:1px solid transparent}
.btn-add-card{width:100%; padding:9px; border-radius:10px; border:1px dashed transparent; background:transparent; color:var(--text-muted); font-size:13px; font-weight:500; display:flex; align-items:center; gap:6px; justify-content:center; transition:all .15s}
.btn-add-card:hover{background:#fff; border-color:var(--border); color:var(--text); box-shadow:var(--shadow-sm)}
.empty-drop{border:2px dashed #d1d5db; border-radius:12px; padding:28px 16px; text-align:center; color:var(--text-faint); font-size:13px; display:flex; flex-direction:column; align-items:center; gap:8px; background:rgba(255,255,255,.6); transition:all .2s}
.empty-drop.drag-over{border-color:var(--accent); background:rgba(99,102,241,.06); color:var(--accent)}
.empty-drop svg{opacity:.5}
/* ===== CARDS ===== */
.card{
background:var(--card-bg); border:1px solid var(--border); border-radius:12px; padding:12px 12px 10px;
box-shadow:var(--shadow-sm); cursor:grab; user-select:none; position:relative; transition:box-shadow .15s, transform .15s, border-color .15s;
}
.card:hover{box-shadow:var(--shadow-md); transform:translateY(-1px); border-color:var(--border-strong)}
.card.dragging{opacity:.25}
.card.hidden-by-filter{display:none !important}
.card-labels{display:flex; flex-wrap:wrap; gap:5px; margin-bottom:8px}
.label-chip{font-size:11px; font-weight:600; padding:2px 7px; border-radius:999px; letter-spacing:.01em; line-height:1.6}
.card-title{font-size:13.5px; font-weight:500; line-height:1.4; margin-bottom:4px; word-break:break-word; cursor:text}
.card-title[contenteditable="true"]{outline:2px solid var(--accent); border-radius:4px; padding:2px 4px; cursor:text; background:#fff}
.card-desc{font-size:12.5px; color:var(--text-muted); line-height:1.5; display:-webkit-box; -webkit-line-clamp:2; -webkit-box-orient:vertical; overflow:hidden; word-break:break-word}
.card-footer{display:flex; align-items:center; gap:8px; margin-top:10px; flex-wrap:wrap}
.card-due{font-size:11.5px; padding:3px 7px; border-radius:999px; background:#f3f4f6; color:var(--text-muted); display:flex; align-items:center; gap:4px; font-weight:500}
.card-due.overdue{background:#fef2f2; color:#dc2626}
.card-due.soon{background:#fffbeb; color:#d97706}
.card-actions{margin-left:auto; display:flex; gap:4px; opacity:0; transition:opacity .15s}
.card:hover .card-actions{opacity:1}
.icon-btn{width:24px;height:24px; border-radius:6px; border:none; background:transparent; display:grid; place-items:center; color:var(--text-faint); transition:all .15s}
.icon-btn:hover{background:#f3f4f6; color:var(--text)}
/* ===== DRAG FLOAT & PLACEHOLDER ===== */
.drag-ghost{
position:fixed; pointer-events:none; z-index:9999; width:296px; transform:rotate(2deg) scale(1.02);
box-shadow:var(--shadow-lg); border-radius:12px; opacity:.96; will-change:transform, left, top;
}
.placeholder{
background:rgba(99,102,241,.08); border:2px dashed rgba(99,102,241,.35); border-radius:12px; min-height:72px; transition:height .12s; flex-shrink:0;
}
.col-placeholder{
width:296px; min-width:296px; background:rgba(99,102,241,.06); border:2px dashed rgba(99,102,241,.3); border-radius:16px; min-height:200px; flex-shrink:0;
}
/* ===== MODALS ===== */
.overlay{position:fixed; inset:0; background:rgba(15,23,42,.45); backdrop-filter:blur(4px); z-index:50; display:none; align-items:center; justify-content:center; padding:20px}
.overlay.open{display:flex}
.modal{background:#fff; border-radius:16px; box-shadow:var(--shadow-lg); width:100%; max-width:480px; max-height:90vh; overflow:auto; animation:pop .18s ease}
@keyframes pop{from{transform:scale(.96) translateY(6px); opacity:0} to{transform:scale(1) translateY(0); opacity:1}}
.modal-head{padding:18px 20px 0; display:flex; align-items:center; justify-content:space-between}
.modal-head h3{font-size:15px; font-weight:650}
.modal-body{padding:16px 20px 20px; display:flex; flex-direction:column; gap:12px}
.field label{font-size:12.5px; font-weight:600; color:var(--text-muted); display:block; margin-bottom:6px}
.field input, .field textarea, .field select{width:100%; padding:9px 12px; border:1px solid var(--border); border-radius:10px; font-size:13.5px; outline:none; transition:border .15s, box-shadow .15s; background:#fff}
.field input:focus, .field textarea:focus, .field select:focus{border-color:var(--accent); box-shadow:0 0 0 3px rgba(99,102,241,.12)}
.field textarea{resize:vertical; min-height:72px}
.label-grid{display:flex; flex-wrap:wrap; gap:8px}
.label-opt{padding:6px 10px; border-radius:999px; font-size:12.5px; font-weight:600; cursor:pointer; border:2px solid transparent; transition:all .15s; user-select:none}
.label-opt.selected{box-shadow:0 0 0 2px #fff, 0 0 0 4px var(--text)}
.modal-actions{display:flex; gap:8px; justify-content:flex-end; margin-top:4px}
/* ===== COLUMN MENU ===== */
.col-menu{position:absolute; top:42px; right:8px; background:#fff; border:1px solid var(--border); border-radius:12px; box-shadow:var(--shadow-lg); padding:6px; width:200px; z-index:20; display:none}
.col-menu.open{display:block}
.menu-item{padding:8px 10px; border-radius:8px; font-size:13px; display:flex; align-items:center; gap:8px; cursor:pointer; transition:background .12s; border:none; background:transparent; width:100%; text-align:left; color:var(--text)}
.menu-item:hover{background:#f3f4f6}
.menu-item.danger{color:#dc2626}
.menu-item.danger:hover{background:#fef2f2}
.menu-sep{height:1px; background:var(--border); margin:6px 0}
.wip-row{display:flex; align-items:center; gap:8px; padding:6px 10px}
.wip-row input{width:64px; padding:6px 8px; border:1px solid var(--border); border-radius:8px; font-size:13px}
/* ===== TOAST ===== */
.toast-wrap{position:fixed; bottom:20px; left:50%; transform:translateX(-50%); z-index:60; display:flex; flex-direction:column; gap:8px; align-items:center; pointer-events:none}
.toast{pointer-events:auto; background:#1a1d23; color:#fff; padding:10px 14px; border-radius:999px; font-size:13px; display:flex; align-items:center; gap:12px; box-shadow:var(--shadow-lg); animation:slideUp .22s ease}
@keyframes slideUp{from{transform:translateY(10px); opacity:0} to{transform:translateY(0); opacity:1}}
.toast button{background:#fff; color:#1a1d23; border:none; padding:5px 12px; border-radius:999px; font-weight:600; font-size:12.5px}
.toast button:hover{background:#f3f4f6}
/* ===== SCROLLBAR ===== */
.board-wrap::-webkit-scrollbar{height:8px}
.board-wrap::-webkit-scrollbar-thumb{background:#d1d5db; border-radius:999px}
/* ===== INLINE EDIT INPUT ===== */
.inline-input{width:100%; padding:6px 8px; border:1px solid var(--accent); border-radius:8px; font-size:13.5px; font-weight:500; outline:none; box-shadow:0 0 0 3px rgba(99,102,241,.12)}
@media(max-width:640px){
.board{padding-bottom:80px}
.app-header{padding:10px 12px}
}
</style>
</head>
<body>
<header class="app-header">
<div class="logo"><div class="logo-mark">◈</div> Flow</div>
<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 20L16 16"/></svg>
<input id="searchInput" placeholder="Search cards..." autocomplete="off">
</div>
<div class="filter-chips" id="filterChips"></div>
<div class="header-actions">
<button class="btn btn-ghost" id="clearFiltersBtn" style="display:none">Clear filters</button>
<button class="btn" id="manageLabelsBtn">Labels</button>
<button class="btn btn-primary" id="addColumnBtn">+ Add column</button>
</div>
</header>
<div class="board-wrap" id="boardWrap">
<div class="board" id="board"></div>
</div>
<!-- Card Modal -->
<div class="overlay" id="cardModal">
<div class="modal">
<div class="modal-head"><h3 id="cardModalTitle">Edit card</h3><button class="icon-btn" id="closeCardModal">✕</button></div>
<div class="modal-body">
<div class="field"><label>Title</label><input id="mTitle" placeholder="Card title"></div>
<div class="field"><label>Description</label><textarea id="mDesc" placeholder="Add a description..."></textarea></div>
<div class="field"><label>Due date</label><input id="mDue" type="date"></div>
<div class="field"><label>Labels</label><div class="label-grid" id="mLabels"></div></div>
<div class="modal-actions">
<button class="btn" id="deleteCardBtn" style="color:#dc2626; border-color:#fecaca">Delete card</button>
<div style="flex:1"></div>
<button class="btn" id="cancelCardBtn">Cancel</button>
<button class="btn btn-primary" id="saveCardBtn">Save</button>
</div>
</div>
</div>
</div>
<!-- Label Manager Modal -->
<div class="overlay" id="labelModal">
<div class="modal" style="max-width:420px">
<div class="modal-head"><h3>Manage labels</h3><button class="icon-btn" id="closeLabelModal">✕</button></div>
<div class="modal-body" id="labelManagerBody"></div>
</div>
</div>
<div class="toast-wrap" id="toastWrap"></div>
<script>
/* ============================================================
TUNABLE CONSTANTS
============================================================ */
const CONFIG = {
STORAGE_KEY: 'flow-kanban-v2',
DRAG_THRESHOLD: 5, // px before drag starts
AUTO_SCROLL_MARGIN: 64, // px from edge to trigger scroll
AUTO_SCROLL_SPEED: 14, // px per frame
GHOST_TILT: 2, // degrees
LABEL_PALETTE: [
{ id:'l1', name:'Feature', color:'#e0e7ff', text:'#4338ca', dot:'#6366f1' },
{ id:'l2', name:'Bug', color:'#fee2e2', text:'#b91c1c', dot:'#ef4444' },
{ id:'l3', name:'Design', color:'#fce7f3', text:'#be185d', dot:'#ec4899' },
{ id:'l4', name:'Research', color:'#fef3c7', text:'#92400e', dot:'#f59e0b' },
{ id:'l5', name:'Docs', color:'#d1fae5', text:'#065f46', dot:'#10b981' },
{ id:'l6', name:'Urgent', color:'#ffedd5', text:'#9a3412', dot:'#f97316' },
]
};
/* ============================================================
STATE — single source of truth
============================================================ */
let board = null;
let filterState = { search:'', labelIds:[] };
let editingCard = null; // {colId, cardId}
let undoStack = [];
let nextId = 1;
function uid(){ return 'id_' + (nextId++) + '_' + Math.random().toString(36).slice(2,6); }
function defaultBoard(){
return {
columns:[
{ id:uid(), title:'Backlog', wipLimit:null, cards:[
{ id:uid(), title:'Design new landing page', description:'Explore hero variations and CTA placement', labels:['l3'], dueDate:'' },
{ id:uid(), title:'Research competitor pricing', description:'Compare 5 competitors, build table', labels:['l4'], dueDate:'' },
]},
{ id:uid(), title:'To Do', wipLimit:null, cards:[
{ id:uid(), title:'Implement auth flow', description:'OAuth + email/password', labels:['l1'], dueDate:'' },
{ id:uid(), title:'Fix checkout bug', description:'Coupon code not applying on mobile', labels:['l2','l6'], dueDate:'' },
]},
{ id:uid(), title:'In Progress', wipLimit:3, cards:[
{ id:uid(), title:'Build kanban board', description:'Flawless drag and drop required', labels:['l1','l3'], dueDate:'' },
]},
{ id:uid(), title:'Review', wipLimit:null, cards:[]},
{ id:uid(), title:'Done', wipLimit:null, cards:[
{ id:uid(), title:'Setup project repo', description:'Init with README and CI', labels:['l5'], dueDate:'' },
]},
]
};
}
function save(){
const payload = { board, nextId, filterState };
localStorage.setItem(CONFIG.STORAGE_KEY, JSON.stringify(payload));
}
function load(){
try{
const raw = localStorage.getItem(CONFIG.STORAGE_KEY);
if(!raw) return null;
const p = JSON.parse(raw);
if(p.board && p.board.columns) return p;
}catch(e){}
return null;
}
function applyBoardState(newBoard, newNextId){
// keep window.__board reference stable for probes
if(board && board.columns){
board.columns.length=0;
newBoard.columns.forEach(c=> board.columns.push(c));
} else {
board = newBoard;
}
nextId = newNextId;
window.__board = board;
}
function initState(){
const saved = load();
if(saved){
board = saved.board;
nextId = saved.nextId || 100;
filterState = saved.filterState || { search:'', labelIds:[] };
window.__board = board;
} else {
board = defaultBoard();
window.__board = board;
save();
}
}
/* ============================================================
RENDERING
============================================================ */
const boardEl = document.getElementById('board');
const boardWrap = document.getElementById('boardWrap');
const filterChipsEl = document.getElementById('filterChips');
const searchInput = document.getElementById('searchInput');
function labelById(id){ return CONFIG.LABEL_PALETTE.find(l=>l.id===id); }
function cardMatchesFilter(card){
if(filterState.search){
const q = filterState.search.toLowerCase();
if(!card.title.toLowerCase().includes(q) && !(card.description||'').toLowerCase().includes(q)) return false;
}
if(filterState.labelIds.length){
if(!card.labels.some(l=> filterState.labelIds.includes(l))) return false;
}
return true;
}
function render(){
// filter chips
filterChipsEl.innerHTML = '';
CONFIG.LABEL_PALETTE.forEach(l=>{
const b = document.createElement('button');
b.className = 'chip' + (filterState.labelIds.includes(l.id)?' active':'');
b.style.background = l.color; b.style.color = l.text; b.style.borderColor = l.color;
b.innerHTML = `<span class="dot" style="background:${l.dot}"></span>${l.name}`;
b.onclick = ()=>{ toggleLabelFilter(l.id); };
filterChipsEl.appendChild(b);
});
document.getElementById('clearFiltersBtn').style.display = (filterState.search || filterState.labelIds.length) ? '' : 'none';
searchInput.value = filterState.search;
// board
boardEl.innerHTML = '';
board.columns.forEach((col, colIdx)=>{
const colEl = document.createElement('div');
colEl.className = 'column';
colEl.dataset.colId = col.id;
const overWip = col.wipLimit!=null && col.cards.length > col.wipLimit;
if(overWip) colEl.classList.add('wip-exceeded');
// header
const header = document.createElement('div');
header.className = 'column-header';
header.dataset.dragHandle = 'col';
header.innerHTML = `
<div class="col-title" data-col-title tabindex="0">${escapeHtml(col.title)}</div>
<span class="col-count ${overWip?'over':''}">${col.cards.length}${col.wipLimit!=null? ' / '+col.wipLimit:''}</span>
<button class="col-menu-btn" data-col-menu-btn>⋯</button>
<div class="col-menu" data-col-menu>
<div class="wip-row"><span style="font-size:13px; flex:1">WIP limit</span><input type="number" min="0" placeholder="∞" data-wip-input value="${col.wipLimit??''}"></div>
<div class="menu-sep"></div>
<button class="menu-item danger" data-delete-col>Delete column</button>
</div>
`;
// inline edit column title
const titleEl = header.querySelector('[data-col-title]');
titleEl.addEventListener('click', (e)=>{
e.stopPropagation();
startInlineEdit(titleEl, col.title, (newVal)=>{
if(newVal.trim() && newVal!==col.title){
pushUndo();
col.title = newVal.trim();
save(); render();
}
});
});
// menu toggle
const menuBtn = header.querySelector('[data-col-menu-btn]');
const menu = header.querySelector('[data-col-menu]');
menuBtn.addEventListener('click', (e)=>{
e.stopPropagation();
document.querySelectorAll('.col-menu.open').forEach(m=>{ if(m!==menu) m.classList.remove('open'); });
menu.classList.toggle('open');
});
const wipInput = header.querySelector('[data-wip-input]');
wipInput.addEventListener('change', ()=>{
const v = wipInput.value.trim()===''? null : Math.max(0, parseInt(wipInput.value,10)||0);
pushUndo();
col.wipLimit = v;
save(); render();
});
wipInput.addEventListener('click', e=>e.stopPropagation());
header.querySelector('[data-delete-col]').addEventListener('click', (e)=>{
e.stopPropagation();
if(board.columns.length<=1){ showToast('Keep at least one column'); return; }
pushUndo();
const idx = board.columns.findIndex(c=>c.id===col.id);
const removed = board.columns.splice(idx,1)[0];
save(); render();
showUndo(`Deleted column "${removed.title}"`, ()=>{
board.columns.splice(idx,0,removed); save(); render();
});
menu.classList.remove('open');
});
colEl.appendChild(header);
// cards container
const list = document.createElement('div');
list.className = 'col-cards';
list.dataset.colCards = col.id;
const visibleCards = col.cards.filter(cardMatchesFilter);
const hasHidden = visibleCards.length !== col.cards.length;
if(col.cards.length===0){
const empty = document.createElement('div');
empty.className = 'empty-drop';
empty.innerHTML = `<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7"><rect x="3" y="3" width="18" height="18" rx="3"/><path d="M12 8v8M8 12h8"/></svg> Drop cards here`;
list.appendChild(empty);
} else {
col.cards.forEach(card=>{
const hidden = !cardMatchesFilter(card);
const cardEl = createCardEl(card, col.id, hidden);
list.appendChild(cardEl);
});
if(visibleCards.length===0 && hasHidden){
const msg = document.createElement('div');
msg.style.cssText='text-align:center; padding:16px; color:var(--text-faint); font-size:12.5px';
msg.textContent='No cards match filter';
list.appendChild(msg);
}
}
colEl.appendChild(list);
// footer
const footer = document.createElement('div');
footer.className='col-footer';
footer.innerHTML = `<button class="btn-add-card" data-add-card>+ Add card</button>`;
footer.querySelector('[data-add-card]').addEventListener('click', ()=> openCardModal(null, col.id));
colEl.appendChild(footer);
boardEl.appendChild(colEl);
});
// close menus on outside click
// (handled globally)
}
function createCardEl(card, colId, hiddenByFilter){
const el = document.createElement('div');
el.className = 'card' + (hiddenByFilter?' hidden-by-filter':'');
el.dataset.cardId = card.id;
el.dataset.colId = colId;
const labelsHtml = (card.labels||[]).map(lid=>{
const l = labelById(lid); if(!l) return '';
return `<span class="label-chip" style="background:${l.color}; color:${l.text}">${escapeHtml(l.name)}</span>`;
}).join('');
let dueHtml='';
if(card.dueDate){
const d = new Date(card.dueDate+'T12:00:00');
const now = new Date(); now.setHours(0,0,0,0);
const diff = Math.ceil((d - now)/86400000);
let cls=''; let txt = formatDate(card.dueDate);
if(diff<0) cls='overdue';
else if(diff<=2) cls='soon';
dueHtml = `<span class="card-due ${cls}">◷ ${escapeHtml(txt)}</span>`;
}
el.innerHTML = `
${labelsHtml? `<div class="card-labels">${labelsHtml}</div>`:''}
<div class="card-title" data-card-title>${escapeHtml(card.title)}</div>
${card.description? `<div class="card-desc">${escapeHtml(card.description)}</div>`:''}
<div class="card-footer">
${dueHtml}
<div class="card-actions">
<button class="icon-btn" data-edit-card title="Edit">✎</button>
<button class="icon-btn" data-delete-card title="Delete">✕</button>
</div>
</div>
`;
// inline title edit
const titleEl = el.querySelector('[data-card-title]');
titleEl.addEventListener('click', (e)=>{
e.stopPropagation();
startInlineEdit(titleEl, card.title, (newVal)=>{
if(newVal.trim() && newVal!==card.title){
pushUndo();
card.title = newVal.trim();
save(); render();
}
});
});
el.querySelector('[data-edit-card]').addEventListener('click', (e)=>{ e.stopPropagation(); openCardModal(card.id, colId); });
el.querySelector('[data-delete-card]').addEventListener('click', (e)=>{
e.stopPropagation();
pushUndo();
const col = board.columns.find(c=>c.id===colId);
const idx = col.cards.findIndex(c=>c.id===card.id);
const removed = col.cards.splice(idx,1)[0];
save(); render();
showUndo(`Deleted "${removed.title}"`, ()=>{
col.cards.splice(idx,0,removed); save(); render();
});
});
// pointer down for drag
el.addEventListener('pointerdown', onCardPointerDown);
return el;
}
function escapeHtml(s){ return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"'); }
function formatDate(iso){
try{ const d=new Date(iso+'T12:00:00'); return d.toLocaleDateString(undefined,{month:'short', day:'numeric'}); }catch{ return iso; }
}
function startInlineEdit(el, original, onCommit){
el.setAttribute('contenteditable','true');
el.focus();
// select all
const range = document.createRange(); range.selectNodeContents(el); const sel=window.getSelection(); sel.removeAllRanges(); sel.addRange(range);
let committed=false;
const finish = (commit)=>{
if(committed) return; committed=true;
el.removeAttribute('contenteditable');
el.removeEventListener('keydown', onKey);
el.removeEventListener('blur', onBlur);
window.getSelection().removeAllRanges();
if(commit) onCommit(el.textContent);
else el.textContent = original;
};
const onKey = (e)=>{
if(e.key==='Enter'){ e.preventDefault(); finish(true); }
else if(e.key==='Escape'){ e.preventDefault(); finish(false); }
};
const onBlur = ()=> finish(true);
el.addEventListener('keydown', onKey);
el.addEventListener('blur', onBlur);
}
/* ============================================================
FILTER / SEARCH
============================================================ */
function toggleLabelFilter(id){
const idx = filterState.labelIds.indexOf(id);
if(idx>=0) filterState.labelIds.splice(idx,1);
else filterState.labelIds.push(id);
save(); render();
}
searchInput.addEventListener('input', ()=>{
filterState.search = searchInput.value;
save(); render();
});
document.getElementById('clearFiltersBtn').addEventListener('click', ()=>{
filterState.search=''; filterState.labelIds=[];
save(); render();
});
/* ============================================================
CARD MODAL
============================================================ */
const cardModal = document.getElementById('cardModal');
const mTitle = document.getElementById('mTitle');
const mDesc = document.getElementById('mDesc');
const mDue = document.getElementById('mDue');
const mLabelsEl = document.getElementById('mLabels');
let modalColId = null, modalCardId = null;
function openCardModal(cardId, colId){
modalCardId = cardId; modalColId = colId;
const isNew = !cardId;
document.getElementById('cardModalTitle').textContent = isNew? 'Add card' : 'Edit card';
document.getElementById('deleteCardBtn').style.display = isNew? 'none':'';
let card = null;
if(cardId){
const col = board.columns.find(c=>c.id===colId);
card = col.cards.find(c=>c.id===cardId);
}
mTitle.value = card? card.title : '';
mDesc.value = card? card.description : '';
mDue.value = card? card.dueDate : '';
// labels
mLabelsEl.innerHTML='';
const selected = new Set(card? card.labels: []);
CONFIG.LABEL_PALETTE.forEach(l=>{
const b=document.createElement('button');
b.className='label-opt' + (selected.has(l.id)?' selected':'');
b.style.background=l.color; b.style.color=l.text;
b.textContent=l.name;
b.onclick=()=>{
if(selected.has(l.id)) selected.delete(l.id); else selected.add(l.id);
b.classList.toggle('selected');
};
b.dataset.lid=l.id;
mLabelsEl.appendChild(b);
});
// store selected set on element
mLabelsEl._selected = selected;
cardModal.classList.add('open');
setTimeout(()=>mTitle.focus(),30);
}
function closeCardModal(){ cardModal.classList.remove('open'); }
document.getElementById('closeCardModal').onclick=closeCardModal;
document.getElementById('cancelCardBtn').onclick=closeCardModal;
cardModal.addEventListener('click', e=>{ if(e.target===cardModal) closeCardModal(); });
document.getElementById('saveCardBtn').onclick=()=>{
const title = mTitle.value.trim();
if(!title){ mTitle.focus(); mTitle.style.borderColor='var(--wip-red)'; return; }
mTitle.style.borderColor='';
const labels = [...mLabelsEl._selected];
if(modalCardId){
const col = board.columns.find(c=>c.id===modalColId);
const card = col.cards.find(c=>c.id===modalCardId);
pushUndo();
card.title=title; card.description=mDesc.value.trim(); card.dueDate=mDue.value; card.labels=labels;
} else {
pushUndo();
const col = board.columns.find(c=>c.id===modalColId);
col.cards.push({ id:uid(), title, description:mDesc.value.trim(), dueDate:mDue.value, labels });
}
save(); render(); closeCardModal();
};
document.getElementById('deleteCardBtn').onclick=()=>{
if(!modalCardId) return;
const col = board.columns.find(c=>c.id===modalColId);
const idx = col.cards.findIndex(c=>c.id===modalCardId);
pushUndo();
const removed = col.cards.splice(idx,1)[0];
save(); render(); closeCardModal();
showUndo(`Deleted "${removed.title}"`, ()=>{
col.cards.splice(idx,0,removed); save(); render();
});
};
/* ============================================================
LABEL MANAGER
============================================================ */
const labelModal = document.getElementById('labelModal');
document.getElementById('manageLabelsBtn').onclick=()=>{ renderLabelManager(); labelModal.classList.add('open'); };
document.getElementById('closeLabelModal').onclick=()=>labelModal.classList.remove('open');
labelModal.addEventListener('click', e=>{ if(e.target===labelModal) labelModal.classList.remove('open'); });
function renderLabelManager(){
const body = document.getElementById('labelManagerBody');
body.innerHTML='';
CONFIG.LABEL_PALETTE.forEach(l=>{
const row=document.createElement('div');
row.style.cssText='display:flex; align-items:center; gap:10px; padding:6px 0';
row.innerHTML=`<span style="width:14px;height:14px;border-radius:50%;background:${l.dot};flex-shrink:0"></span>
<span style="flex:1; font-size:13.5px; font-weight:500">${escapeHtml(l.name)}</span>
<span class="label-chip" style="background:${l.color}; color:${l.text}">${escapeHtml(l.name)}</span>`;
body.appendChild(row);
});
const hint=document.createElement('div');
hint.style.cssText='margin-top:8px; font-size:12.5px; color:var(--text-muted); line-height:1.5';
hint.textContent='Labels are used to categorize cards. Click chips in the filter bar to filter, or assign them when editing a card.';
body.appendChild(hint);
}
/* ============================================================
ADD COLUMN
============================================================ */
document.getElementById('addColumnBtn').onclick=()=>{
const title = prompt('Column name:', 'New column');
if(title===null) return;
const t = title.trim() || 'Untitled';
pushUndo();
board.columns.push({ id:uid(), title:t, wipLimit:null, cards:[] });
save(); render();
setTimeout(()=>{ boardWrap.scrollLeft = boardWrap.scrollWidth; }, 50);
};
/* ============================================================
UNDO / TOAST
============================================================ */
function pushUndo(){
undoStack.push(JSON.stringify({board: structuredClone(board), nextId}));
if(undoStack.length>20) undoStack.shift();
}
function showToast(msg, withUndo){
const wrap=document.getElementById('toastWrap');
const t=document.createElement('div');
t.className='toast';
t.innerHTML=`<span>${escapeHtml(msg)}</span>` + (withUndo? `<button data-undo>Undo</button>`:'');
wrap.appendChild(t);
let timer = setTimeout(()=> t.remove(), 4000);
if(withUndo){
t.querySelector('[data-undo]').onclick=()=>{
clearTimeout(timer);
t.remove();
// restore from undoStack pop
const prev = undoStack.pop();
if(prev){
const p = JSON.parse(prev);
applyBoardState(p.board, p.nextId);
save(); render();
} else if(typeof withUndo==='function'){
withUndo();
}
};
}
}
function showUndo(msg, fn){
const wrap=document.getElementById('toastWrap');
const t=document.createElement('div');
t.className='toast';
t.innerHTML=`<span>${escapeHtml(msg)}</span><button>Undo</button>`;
wrap.appendChild(t);
let done=false;
const timer=setTimeout(()=>{ if(!done) t.remove(); }, 5000);
t.querySelector('button').onclick=()=>{
done=true; clearTimeout(timer); t.remove(); fn();
};
}
/* ============================================================
DRAG & DROP — Pointer Events
============================================================ */
let drag = null; // {type:'card'|'col', ...}
let autoScrollRAF = null;
function onCardPointerDown(e){
if(e.button!==0) return;
// ignore clicks on buttons inside card
if(e.target.closest('button')) return;
const cardEl = e.currentTarget;
const colId = cardEl.dataset.colId;
const cardId = cardEl.dataset.cardId;
// capture start position
const startX = e.clientX, startY = e.clientY;
let hasMoved = false;
let pointerId = e.pointerId;
const onMove = (ev)=>{
const dx = ev.clientX - startX, dy = ev.clientY - startY;
if(!hasMoved && Math.hypot(dx,dy) < CONFIG.DRAG_THRESHOLD) return;
if(!hasMoved){
hasMoved=true;
startCardDrag(cardEl, colId, cardId, ev);
}
if(drag) updateCardDrag(ev);
};
const onUp = (ev)=>{
window.removeEventListener('pointermove', onMove);
window.removeEventListener('pointerup', onUp);
window.removeEventListener('pointercancel', onUp);
if(drag) endCardDrag(ev);
};
window.addEventListener('pointermove', onMove);
window.addEventListener('pointerup', onUp);
window.addEventListener('pointercancel', onUp);
// prevent text selection
e.preventDefault();
}
function startCardDrag(cardEl, colId, cardId, e){
const col = board.columns.find(c=>c.id===colId);
const cardIdx = col.cards.findIndex(c=>c.id===cardId);
const card = col.cards[cardIdx];
// snapshot for cancel
const snapshot = JSON.stringify({board: structuredClone(board), nextId});
// create placeholder
const placeholder = document.createElement('div');
placeholder.className='placeholder';
const rect = cardEl.getBoundingClientRect();
placeholder.style.height = rect.height + 'px';
// create ghost
const ghost = cardEl.cloneNode(true);
ghost.classList.add('drag-ghost');
ghost.style.width = rect.width + 'px';
ghost.style.left = rect.left + 'px';
ghost.style.top = rect.top + 'px';
ghost.style.transform = `rotate(${CONFIG.GHOST_TILT}deg) scale(1.02)`;
document.body.appendChild(ghost);
// hide original, insert placeholder
cardEl.classList.add('dragging');
cardEl.parentNode.insertBefore(placeholder, cardEl);
drag = {
type:'card',
cardId, colId, cardIdx,
card, snapshot,
ghost, placeholder, originEl: cardEl,
offsetX: e.clientX - rect.left,
offsetY: e.clientY - rect.top,
currentColId: colId,
currentIdx: cardIdx,
pointerX: e.clientX, pointerY: e.clientY
};
document.body.style.userSelect='none';
document.body.style.cursor='grabbing';
startAutoScroll();
// escape handler
window.addEventListener('keydown', onDragKey);
}
function updateCardDrag(e){
if(!drag || drag.type!=='card') return;
drag.pointerX = e.clientX; drag.pointerY = e.clientY;
// move ghost
drag.ghost.style.left = (e.clientX - drag.offsetX) + 'px';
drag.ghost.style.top = (e.clientY - drag.offsetY) + 'px';
// hit test columns
const cols = [...document.querySelectorAll('.column')];
let targetColId = null, targetColEl=null;
for(const c of cols){
const r=c.getBoundingClientRect();
if(e.clientX >= r.left && e.clientX <= r.right && e.clientY >= r.top && e.clientY <= r.bottom){
targetColId = c.dataset.colId; targetColEl=c; break;
}
}
// if outside columns, find nearest column by X
if(!targetColId){
// find closest column horizontally
let best=null, bestDist=Infinity;
for(const c of cols){
const r=c.getBoundingClientRect();
const cx = r.left + r.width/2;
const d = Math.abs(e.clientX - cx);
if(d<bestDist){ bestDist=d; best=c; }
}
if(best){ targetColId=best.dataset.colId; targetColEl=best; }
}
if(!targetColId) return;
const colCardsEl = targetColEl.querySelector('.col-cards');
// hide ghost temporarily for elementFromPoint alternative? we use manual Y check
// collect card elements excluding dragging one and placeholder
const cardEls = [...colCardsEl.querySelectorAll('.card:not(.dragging)')];
// also placeholder is not a card
let insertIdx = cardEls.length; // default append
// Find insertion point by Y
for(let i=0;i<cardEls.length;i++){
const r = cardEls[i].getBoundingClientRect();
const mid = r.top + r.height/2;
if(e.clientY < mid){ insertIdx = i; break; }
}
// Adjust for hidden-by-filter? We treat hidden cards as not in DOM flow? They are display:none, so they don't affect layout.
// But model index must account for them. We need to map visual index to model index.
// Approach: compute model insertion index by counting visible cards up to insertIdx and mapping.
const targetCol = board.columns.find(c=>c.id===targetColId);
// Build list of visible card ids in order
// For hit testing we used visible DOM order; to get model index, find the card at visual insert position
let modelInsertIdx;
if(cardEls.length===0){
// empty column — append at end (or 0 if no cards). But if column has hidden cards, we append after last model card? For simplicity append.
// If there are hidden cards, inserting visually at 0 should insert at 0 model-wise? We'll map to 0.
if(targetCol.cards.length===0) modelInsertIdx=0;
else {
// if filtering hides all, insert at 0
modelInsertIdx = 0;
}
} else {
if(insertIdx >= cardEls.length){
// append after last visible card — find model index after that card
const lastVisibleId = cardEls[cardEls.length-1].dataset.cardId;
const lastIdx = targetCol.cards.findIndex(c=>c.id===lastVisibleId);
modelInsertIdx = lastIdx + 1;
// if dragging within same column and moving down, account for removal shift later; but for placeholder placement we just need visual
} else {
const beforeId = cardEls[insertIdx].dataset.cardId;
modelInsertIdx = targetCol.cards.findIndex(c=>c.id===beforeId);
}
}
// Move placeholder DOM
// Remove placeholder from old parent
if(drag.placeholder.parentNode) drag.placeholder.parentNode.removeChild(drag.placeholder);
// Insert at visual position
if(cardEls.length===0){
// empty column: remove empty-drop if present? Keep but insert before it? Better replace.
const empty = colCardsEl.querySelector('.empty-drop');
if(empty) empty.style.display='none';
colCardsEl.appendChild(drag.placeholder);
} else {
if(insertIdx >= cardEls.length){
colCardsEl.appendChild(drag.placeholder);
} else {
colCardsEl.insertBefore(drag.placeholder, cardEls[insertIdx]);
}
}
// update drag state
drag.currentColId = targetColId;
drag.currentIdx = modelInsertIdx;
// highlight empty column
document.querySelectorAll('.empty-drop').forEach(ed=>{
ed.classList.toggle('drag-over', ed.closest('.column').dataset.colId===targetColId && targetCol.cards.length===0);
});
}
function endCardDrag(e){
if(!drag || drag.type!=='card') return;
const wasEscape = e && e.type==='keydown';
stopAutoScroll();
window.removeEventListener('keydown', onDragKey);
document.body.style.userSelect='';
document.body.style.cursor='';
const ghost = drag.ghost, placeholder=drag.placeholder, originEl=drag.originEl;
const snapshot = drag.snapshot;
// cleanup DOM
ghost.remove();
if(placeholder.parentNode) placeholder.parentNode.removeChild(placeholder);
originEl.classList.remove('dragging');
document.querySelectorAll('.empty-drop').forEach(ed=>{ ed.style.display=''; ed.classList.remove('drag-over'); });
if(wasEscape){
const p = JSON.parse(snapshot);
applyBoardState(p.board, p.nextId);
save(); render();
drag=null;
return;
}
// commit move in model
const fromCol = board.columns.find(c=>c.id===drag.colId);
const toCol = board.columns.find(c=>c.id===drag.currentColId);
const cardIdx = fromCol.cards.findIndex(c=>c.id===drag.cardId);
if(cardIdx===-1){ drag=null; render(); return; }
const [card] = fromCol.cards.splice(cardIdx,1);
// adjust insert index if same column and moving down
let insertAt = drag.currentIdx;
if(drag.colId===drag.currentColId && insertAt > cardIdx) insertAt--;
// clamp
insertAt = Math.max(0, Math.min(insertAt, toCol.cards.length));
toCol.cards.splice(insertAt,0,card);
// push undo snapshot before save? We have snapshot of before
undoStack.push(snapshot);
if(undoStack.length>20) undoStack.shift();
save();
render();
// settle animation: briefly highlight moved card
requestAnimationFrame(()=>{
const el = document.querySelector(`[data-card-id="${drag.cardId}"]`);
if(el){
el.style.transform='scale(1.02)';
el.style.boxShadow='var(--shadow-lg)';
setTimeout(()=>{ el.style.transform=''; el.style.boxShadow=''; }, 220);
}
drag=null;
});
// if drag was already nulled, ensure
if(drag && drag.cardId) drag=null;
}
function onDragKey(e){
if(e.key==='Escape' && drag){
e.preventDefault();
endCardDrag({type:'keydown'});
}
}
/* ===== AUTO SCROLL ===== */
function startAutoScroll(){
if(autoScrollRAF) return;
const step = ()=>{
if(!drag) { autoScrollRAF=null; return; }
const x=drag.pointerX, y=drag.pointerY;
// horizontal board scroll
const bwRect = boardWrap.getBoundingClientRect();
if(x < bwRect.left + CONFIG.AUTO_SCROLL_MARGIN) boardWrap.scrollLeft -= CONFIG.AUTO_SCROLL_SPEED;
else if(x > bwRect.right - CONFIG.AUTO_SCROLL_MARGIN) boardWrap.scrollLeft += CONFIG.AUTO_SCROLL_SPEED;
// vertical column scroll
const cols = [...document.querySelectorAll('.column')];
for(const c of cols){
const r=c.getBoundingClientRect();
if(x>=r.left && x<=r.right && y>=r.top && y<=r.bottom){
const list=c.querySelector('.col-cards');
if(list){
const lr=list.getBoundingClientRect();
if(y < lr.top + 48) list.scrollTop -= CONFIG.AUTO_SCROLL_SPEED;
else if(y > lr.bottom - 48) list.scrollTop += CONFIG.AUTO_SCROLL_SPEED;
}
break;
}
}
autoScrollRAF = requestAnimationFrame(step);
};
autoScrollRAF = requestAnimationFrame(step);
}
function stopAutoScroll(){
if(autoScrollRAF) cancelAnimationFrame(autoScrollRAF);
autoScrollRAF=null;
}
/* ============================================================
COLUMN DRAG
============================================================ */
let colDrag=null;
boardEl.addEventListener('pointerdown', (e)=>{
const handle = e.target.closest('.column-header');
if(!handle) return;
if(e.target.closest('button') || e.target.closest('input') || e.target.closest('[contenteditable="true"]')) return;
const colEl = handle.closest('.column');
const colId = colEl.dataset.colId;
const startX=e.clientX, startY=e.clientY;
let moved=false;
const onMove=(ev)=>{
if(!moved && Math.hypot(ev.clientX-startX, ev.clientY-startY) < CONFIG.DRAG_THRESHOLD) return;
if(!moved){ moved=true; startColDrag(colEl, colId, ev); }
if(colDrag) updateColDrag(ev);
};
const onUp=(ev)=>{
window.removeEventListener('pointermove', onMove);
window.removeEventListener('pointerup', onUp);
if(colDrag) endColDrag(ev);
};
window.addEventListener('pointermove', onMove);
window.addEventListener('pointerup', onUp);
});
function startColDrag(colEl, colId, e){
const rect=colEl.getBoundingClientRect();
const placeholder=document.createElement('div');
placeholder.className='col-placeholder';
placeholder.style.height=rect.height+'px';
const ghost=colEl.cloneNode(true);
ghost.classList.add('drag-ghost');
ghost.style.width=rect.width+'px';
ghost.style.height=rect.height+'px';
ghost.style.left=rect.left+'px';
ghost.style.top=rect.top+'px';
ghost.style.opacity='.95';
document.body.appendChild(ghost);
colEl.style.opacity='.35';
colEl.parentNode.insertBefore(placeholder, colEl);
colDrag={ colId, ghost, placeholder, originEl:colEl, offsetX:e.clientX-rect.left, offsetY:e.clientY-rect.top, pointerX:e.clientX };
document.body.style.cursor='grabbing';
window.addEventListener('keydown', onColDragKey);
}
function updateColDrag(e){
if(!colDrag) return;
colDrag.pointerX=e.clientX;
colDrag.ghost.style.left=(e.clientX-colDrag.offsetX)+'px';
colDrag.ghost.style.top=(colDrag.originEl.getBoundingClientRect().top)+'px';
// find insert index
const cols=[...boardEl.querySelectorAll('.column:not([style*="opacity"])')].filter(c=>c!==colDrag.placeholder);
// Actually include all columns except ghost; placeholder is not a column
const allCols=[...boardEl.children].filter(c=>c!==colDrag.placeholder && c!==colDrag.originEl);
let insertBefore=null;
for(const c of allCols){
const r=c.getBoundingClientRect();
if(e.clientX < r.left + r.width/2){ insertBefore=c; break; }
}
if(insertBefore) boardEl.insertBefore(colDrag.placeholder, insertBefore);
else boardEl.appendChild(colDrag.placeholder);
// auto scroll board
const bwRect=boardWrap.getBoundingClientRect();
if(e.clientX < bwRect.left+CONFIG.AUTO_SCROLL_MARGIN) boardWrap.scrollLeft-=CONFIG.AUTO_SCROLL_SPEED;
else if(e.clientX > bwRect.right-CONFIG.AUTO_SCROLL_MARGIN) boardWrap.scrollLeft+=CONFIG.AUTO_SCROLL_SPEED;
}
function endColDrag(e){
if(!colDrag) return;
const isEsc=e && e.type==='keydown';
window.removeEventListener('keydown', onColDragKey);
colDrag.ghost.remove();
colDrag.originEl.style.opacity='';
const ph=colDrag.placeholder;
const colId=colDrag.colId;
document.body.style.cursor='';
if(isEsc){
if(ph.parentNode) ph.parentNode.removeChild(ph);
colDrag=null; return;
}
// compute new index
const siblings=[...boardEl.children];
const newIdx=siblings.indexOf(ph);
// remove placeholder
if(ph.parentNode) ph.parentNode.removeChild(ph);
// update model
const oldIdx=board.columns.findIndex(c=>c.id===colId);
const [col]=board.columns.splice(oldIdx,1);
// newIdx is position in DOM after removal of originEl? Our DOM had originEl still present but opacity 0, placeholder inserted.
// Simpler: compute target index by counting columns before placeholder in DOM excluding originEl
// We already have newIdx including originEl and placeholder. Let's recompute cleanly:
// After removing placeholder, DOM has originEl at old position. We want to move column to position corresponding to placeholder's index.
// Placeholder index among boardEl children before removal includes originEl. So target = newIdx > oldIdx ? newIdx-1 : newIdx
let targetIdx=newIdx;
if(targetIdx>oldIdx) targetIdx--;
targetIdx=Math.max(0, Math.min(targetIdx, board.columns.length));
pushUndo();
board.columns.splice(targetIdx,0,col);
save(); render();
colDrag=null;
}
function onColDragKey(e){ if(e.key==='Escape' && colDrag){ e.preventDefault(); endColDrag({type:'keydown'}); } }
/* ============================================================
GLOBAL CLOSE MENUS
============================================================ */
document.addEventListener('click', ()=> document.querySelectorAll('.col-menu.open').forEach(m=>m.classList.remove('open')));
/* ============================================================
INIT
============================================================ */
initState();
render();
// expose for probe tests
window.__board = board;
window.__getBoard = ()=> board;
window.__save = save;
window.__render = render;
</script>
</body>
</html>
<!-- agent-meta {"model":"meta/muse-spark-1.2-contributor","provider":"openrouter","persona":"full","sessionId":"1fd75c2b-b247-4c97-9792-7711427121c1","tokensIn":529054,"tokensOut":22665,"tokensTotal":551719,"cost":0.01666109,"turns":18,"toolCalls":17,"failedToolCalls":0,"timestamp":"2026-08-23T02:14:41.976Z"} -->