Kanban
muse spark 1.2 · 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 47,093 bytes · SHA-256 7372bef2c018
<!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>
/* ============ DESIGN TOKENS ============ */
:root{
--bg:#f6f7f9; --bg2:#eef0f3; --card:#ffffff; --card-hover:#ffffff;
--border:#e6e8eb; --border-strong:#dde1e6;
--text:#0f172a; --muted:#64748b; --faint:#94a3b8;
--accent:#111827; --accent2:#3b82f6;
--radius:14px; --radius-sm:8px; --radius-pill:999px;
--shadow:0 1px 2px rgba(0,0,0,.06),0 4px 12px rgba(0,0,0,.06);
--shadow-lg:0 8px 24px rgba(0,0,0,.12),0 2px 8px rgba(0,0,0,.08);
--col-w:296px; --col-gap:16px;
}
*{box-sizing:border-box}
html,body{height:100%;margin:0;font-family:ui-sans-system,system-ui,-apple-system,Segoe UI,Roboto,Inter,Helvetica,Arial,sans-serif;color:var(--text);background:var(--bg)}
button{font:inherit}
::-webkit-scrollbar{height:8px;width:8px}
::-webkit-scrollbar-thumb{background:#d1d5db;border-radius:99px}
/* ============ HEADER ============ */
.app-header{
position:sticky;top:0;z-index:20;
background:rgba(246,247,249,.92);backdrop-filter:blur(10px);
border-bottom:1px solid var(--border);
padding:14px 18px 12px; display:flex; gap:14px; align-items:center; flex-wrap:wrap;
}
.brand{display:flex;align-items:center;gap:10px;min-width:140px}
.brand-logo{width:32px;height:32px;border-radius:9px;background:var(--accent);color:#fff;display:grid;place-items:center;font-weight:800;letter-spacing:-.02em}
.brand h1{margin:0;font-size:18px;letter-spacing:-.03em}
.brand p{margin:0;color:var(--muted);font-size:12px;margin-top:1px}
.search-wrap{position:relative;flex:1;max-width:380px;min-width:200px}
.search-wrap input{
width:100%;padding:10px 12px 10px 36px;border:1px solid var(--border);border-radius:999px;background:#fff;
font-size:14px;outline:none;transition:.15s;
}
.search-wrap input:focus{border-color:#cbd5e1;box-shadow:0 0 0 3px rgba(59,130,246,.12)}
.search-icon{position:absolute;left:12px;top:50%;transform:translateY(-50%);color:var(--faint)}
.filter-bar{display:flex;align-items:center;gap:8px;flex-wrap:wrap}
.filter-label{font-size:12px;font-weight:600;color:var(--muted);letter-spacing:.04em;text-transform:uppercase}
.chip{padding:6px 10px;border-radius:999px;font-size:12px;font-weight:600;border:1px solid transparent;cursor:pointer;user-select:none;transition:.15s}
.chip.active{outline:2px solid currentColor;outline-offset:1px}
.chip.all{background:#111827;color:#fff}
.chip.all.active{outline-color:#111827}
.header-actions{margin-left:auto;display:flex;gap:8px;align-items:center}
.btn{padding:9px 14px;border-radius:999px;border:1px solid var(--border);background:#fff;font-weight:600;font-size:13px;cursor:pointer;transition:.15s}
.btn:hover{background:#f8fafc;transform:translateY(-1px)}
.btn.primary{background:var(--accent);color:#fff;border-color:var(--accent)}
.btn.primary:hover{background:#1f2937}
.btn.ghost{background:transparent;border-color:transparent;color:var(--muted)}
/* ============ BOARD ============ */
.board-wrap{overflow-x:auto;overflow-y:hidden;padding:18px 18px 24px;scroll-behavior:smooth}
.board{display:flex;gap:var(--col-gap);align-items:flex-start;min-height:calc(100vh - 140px);padding-bottom:20px}
/* ============ COLUMN ============ */
.column{
width:var(--col-w);min-width:var(--col-w);flex-shrink:0;
background:#f1f3f6;border:1px solid var(--border);border-radius:16px;
display:flex;flex-direction:column;max-height:calc(100vh - 120px);position:relative;
}
.column.dragging-col{opacity:.4;transform:rotate(1deg)}
.column.wip-over{border-color:#fecaca;box-shadow:0 0 0 2px rgba(239,68,68,.12) inset}
.column.wip-over .col-header{border-bottom-color:#fecaca}
.col-header{
padding:14px 12px 12px;display:flex;align-items:center;gap:8px;
border-bottom:1px solid var(--border);border-radius:16px 16px 0 0;
cursor:grab;user-select:none;background:inherit;position:sticky;top:0;z-index:1;
}
.col-header:active{cursor:grabbing}
.col-title{font-weight:700;font-size:14px;letter-spacing:-.01em;flex:1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.col-title[contenteditable="true"]{outline:2px solid var(--accent2);border-radius:6px;padding:2px 6px;background:#fff;cursor:text}
.col-count{background:#fff;border:1px solid var(--border);padding:2px 8px;border-radius:999px;font-size:12px;font-weight:700;color:var(--muted);min-width:28px;text-align:center}
.col-count.over{background:#fef2f2;color:#dc2626;border-color:#fecaca}
.col-menu-btn{width:28px;height:28px;border-radius:8px;border:1px solid transparent;background:transparent;color:var(--faint);cursor:pointer;display:grid;place-items:center}
.col-menu-btn:hover{background:#fff;border-color:var(--border);color:var(--muted)}
.col-menu{position:absolute;top:44px;right:8px;background:#fff;border:1px solid var(--border);border-radius:12px;box-shadow:var(--shadow-lg);padding:8px;min-width:200px;z-index:10;display:none}
.col-menu.open{display:block}
.col-menu label{font-size:11px;font-weight:700;letter-spacing:.06em;text-transform:uppercase;color:var(--muted);display:block;margin:6px 0 4px}
.col-menu input, .col-menu select{width:100%;padding:8px 10px;border:1px solid var(--border);border-radius:8px;font-size:13px}
.col-menu .menu-row{display:flex;gap:6px;margin-top:8px}
.col-menu .menu-row button{flex:1}
.wip-badge{font-size:11px;font-weight:700;color:#dc2626;background:#fef2f2;border:1px solid #fecaca;padding:2px 6px;border-radius:999px;display:none}
.column.wip-over .wip-badge{display:inline-block}
/* column body scroll */
.col-body{overflow-y:auto;overflow-x:hidden;padding:10px 8px 8px;flex:1;display:flex;flex-direction:column;gap:10px;min-height:60px;scrollbar-width:thin}
.col-body.drag-over{background:rgba(59,130,246,.04)}
.empty-drop{
border:2px dashed #cbd5e1;border-radius:12px;padding:22px 10px;text-align:center;color:var(--faint);font-size:13px;font-weight:500;
background:rgba(255,255,255,.5);display:none;
}
.column.empty .empty-drop{display:block}
.column.drag-active .empty-drop{display:block;border-color:var(--accent2);color:var(--accent2);background:rgba(59,130,246,.06)}
/* ============ CARD ============ */
.card{
background:var(--card);border:1px solid var(--border);border-radius:12px;padding:12px 12px 10px;
box-shadow:0 1px 2px rgba(0,0,0,.05);cursor:grab;user-select:none;position:relative;transition:box-shadow .15s, transform .15s, border-color .15s;
}
.card:hover{box-shadow:0 4px 12px rgba(0,0,0,.08);transform:translateY(-1px);border-color:var(--border-strong)}
.card.filtered-out{display:none !important}
.card.hidden-placeholder{visibility:hidden;pointer-events:none}
.card-title{font-size:13.5px;font-weight:600;line-height:1.35;margin:0 0 6px;word-break:break-word}
.card-title[contenteditable="true"]{outline:2px solid var(--accent2);border-radius:6px;padding:2px 4px;background:#f8fafc}
.card-desc{font-size:12.5px;color:var(--muted);line-height:1.45;margin:0 0 8px;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;word-break:break-word}
.card-desc.empty{font-style:italic;color:#cbd5e1}
.card-meta{display:flex;align-items:center;gap:6px;flex-wrap:wrap}
.label-chip{padding:3px 8px;border-radius:999px;font-size:11px;font-weight:700;letter-spacing:.02em}
.due{font-size:11px;font-weight:600;padding:3px 7px;border-radius:999px;background:#f1f5f9;color:#475569;border:1px solid #e2e8f0}
.due.overdue{background:#fef2f2;color:#dc2626;border-color:#fecaca}
.due.soon{background:#fffbeb;color:#b45309;border-color:#fde68a}
.card-actions{position:absolute;top:8px;right:8px;display:flex;gap:4px;opacity:0;transition:.15s}
.card:hover .card-actions{opacity:1}
.icon-btn{width:26px;height:26px;border-radius:8px;border:1px solid var(--border);background:#fff;display:grid;place-items:center;cursor:pointer;color:var(--muted);font-size:12px}
.icon-btn:hover{background:#f8fafc;color:var(--text)}
.icon-btn.danger:hover{background:#fef2f2;color:#dc2626;border-color:#fecaca}
/* ============ PLACEHOLDER & GHOST ============ */
.drop-indicator{height:4px;background:var(--accent2);border-radius:999px;margin:2px 4px;transition:height .1s}
.placeholder{border:2px dashed #93c5fd;background:rgba(59,130,246,.08);border-radius:12px;min-height:72px;pointer-events:none}
.ghost-card{
position:fixed;z-index:9999;pointer-events:none;transform:rotate(3deg) scale(1.02);
box-shadow:var(--shadow-lg);opacity:.96;will-change:transform,left,top;width:var(--col-w);
}
/* ============ MODAL ============ */
.modal-backdrop{position:fixed;inset:0;background:rgba(15,23,42,.42);backdrop-filter:blur(4px);display:none;place-items:center;z-index:30;padding:18px}
.modal-backdrop.open{display:grid}
.modal{width:min(520px,100%);background:#fff;border-radius:16px;box-shadow:var(--shadow-lg);overflow:hidden}
.modal-head{padding:16px 18px;border-bottom:1px solid var(--border);display:flex;justify-content:space-between;align-items:center}
.modal-head h3{margin:0;font-size:15px}
.modal-body{padding:16px 18px;display:flex;flex-direction:column;gap:12px}
.modal-body label{font-size:12px;font-weight:700;letter-spacing:.04em;text-transform:uppercase;color:var(--muted)}
.modal-body input, .modal-body textarea, .modal-body select{width:100%;padding:10px 12px;border:1px solid var(--border);border-radius:10px;font:inherit;font-size:14px;outline:none}
.modal-body textarea{min-height:80px;resize:vertical}
.modal-body input:focus, .modal-body textarea:focus{border-color:#93c5fd;box-shadow:0 0 0 3px rgba(59,130,246,.15)}
.label-picker{display:flex;gap:8px;flex-wrap:wrap}
.label-picker .chip{border-width:2px}
.label-picker .chip.selected{box-shadow:0 0 0 2px #111827 inset}
.modal-foot{padding:12px 18px;border-top:1px solid var(--border);display:flex;justify-content:flex-end;gap:8px;background:#f8fafc}
/* ============ TOAST ============ */
.toast-wrap{position:fixed;bottom:18px;left:50%;transform:translateX(-50%);z-index:40;display:flex;flex-direction:column;gap:8px;align-items:center}
.toast{background:#111827;color:#fff;padding:12px 14px;border-radius:12px;display:flex;align-items:center;gap:12px;box-shadow:var(--shadow-lg);font-size:13px;font-weight:500;animation:slideUp .25s ease}
@keyframes slideUp{from{transform:translateY(10px);opacity:0}to{transform:translateY(0);opacity:1}}
.toast button{background:#fff;color:#111827;border:none;padding:6px 10px;border-radius:999px;font-weight:700;cursor:pointer}
/* ============ ADD CARD / ADD COLUMN ============ */
.add-card-btn{width:100%;padding:10px;border:1px dashed #cbd5e1;border-radius:10px;background:rgba(255,255,255,.6);color:var(--muted);font-weight:600;font-size:13px;cursor:pointer;transition:.15s}
.add-card-btn:hover{background:#fff;border-color:#94a3b8;color:var(--text)}
.add-col-card{
width:var(--col-w);min-width:var(--col-w);border:2px dashed #cbd5e1;border-radius:16px;display:grid;place-items:center;padding:28px;color:var(--muted);font-weight:600;cursor:pointer;background:rgba(255,255,255,.4);transition:.15s;min-height:120px;
}
.add-col-card:hover{background:#fff;border-color:#94a3b8;color:var(--text)}
</style>
</head>
<body>
<!-- ================= HEADER ================= -->
<header class="app-header">
<div class="brand">
<div class="brand-logo">≡</div>
<div>
<h1>Flow</h1>
<p id="boardStats">0 cards · 5 columns</p>
</div>
</div>
<div class="search-wrap">
<span class="search-icon">⌕</span>
<input id="searchInput" placeholder="Search cards by title… ( / to focus )" autocomplete="off">
</div>
<div class="filter-bar" id="filterBar">
<span class="filter-label">Filter</span>
<!-- chips injected -->
</div>
<div class="header-actions">
<button class="btn ghost" id="clearFiltersBtn" title="Clear filters">Clear</button>
<button class="btn" id="addColumnBtn">+ Add column</button>
</div>
</header>
<!-- ================= BOARD ================= -->
<div class="board-wrap" id="boardWrap">
<div class="board" id="board"></div>
</div>
<!-- ================= CARD MODAL ================= -->
<div class="modal-backdrop" id="cardModal">
<div class="modal">
<div class="modal-head">
<h3 id="modalTitle">Edit card</h3>
<button class="icon-btn" id="closeModalBtn">✕</button>
</div>
<div class="modal-body">
<label>Title</label>
<input id="mTitle" placeholder="Card title">
<label>Description</label>
<textarea id="mDesc" placeholder="Add a description…"></textarea>
<label>Due date</label>
<input id="mDue" type="date">
<label>Labels</label>
<div class="label-picker" id="mLabels"></div>
</div>
<div class="modal-foot">
<button class="btn" id="cancelModalBtn">Cancel</button>
<button class="btn primary" id="saveModalBtn">Save</button>
</div>
</div>
</div>
<!-- ================= TOAST ================= -->
<div class="toast-wrap" id="toastWrap"></div>
<script>
/* =========================================================
TUNABLE CONSTANTS
========================================================= */
const CONFIG = {
LABELS: [
{ id:'bug', name:'Bug', color:'#fecaca', text:'#991b1b' },
{ id:'feature', name:'Feature', color:'#bfdbfe', text:'#1e40af' },
{ id:'design', name:'Design', color:'#fde68a', text:'#92400e' },
{ id:'dev', name:'Dev', color:'#bbf7d0', text:'#065f46' },
{ id:'docs', name:'Docs', color:'#ddd6fe', text:'#5b21b6' },
{ id:'urgent', name:'Urgent', color:'#fed7aa', text:'#9a3412' },
],
DRAG_THRESHOLD: 4, // px before drag starts
AUTO_SCROLL_EDGE: 72, // px from edge to trigger scroll
AUTO_SCROLL_SPEED: 14, // px per frame
BOARD_SCROLL_EDGE: 90,
GHOST_OFFSET_X: 14,
GHOST_OFFSET_Y: 12,
STORAGE_KEY: 'flow-kanban-v2'
};
/* =========================================================
STATE / MODEL — single source of truth
========================================================= */
let board = null;
let drag = null; // {type:'card'|'col', id, colId, origColId, origIndex, ghost, placeholder, pointerId, offsetX, offsetY, currentColId, currentIndex, raf}
let undoStack = [];
let editingCard = null; // {colId, cardId, isNew}
let activeFilters = new Set(); // label ids
let searchQuery = '';
/* =========================================================
UTILITIES
========================================================= */
const uid = ()=> Math.random().toString(36).slice(2,9);
const clone = o => JSON.parse(JSON.stringify(o));
const todayISO = ()=> new Date().toISOString().slice(0,10);
function defaultBoard(){
return {
columns:[
{ id:uid(), title:'Backlog', wip:0, cards:[
{id:uid(), title:'Research competitor flows', desc:'Look at Linear, Trello and Notion for inspiration.', labels:['design'], due:''},
{id:uid(), title:'Define label taxonomy', desc:'Finalize 6 core labels and their colors.', labels:['docs'], due:todayISO()},
]},
{ id:uid(), title:'To Do', wip:0, cards:[
{id:uid(), title:'Build drag-and-drop engine', desc:'Pointer events, placeholder, auto-scroll, Escape cancel.', labels:['dev','feature'], due:''},
{id:uid(), title:'Design empty states', desc:'Dashed zones for empty columns.', labels:['design'], due:''},
]},
{ id:uid(), title:'In Progress', wip:2, cards:[
{id:uid(), title:'Implement WIP limits', desc:'Highlight column when limit exceeded.', labels:['feature'], due:''},
]},
{ id:uid(), title:'Review', wip:0, cards:[]},
{ id:uid(), title:'Done', wip:0, cards:[
{id:uid(), title:'Project scaffold', desc:'Board model, persistence, render loop.', labels:['dev'], due:''},
]},
]
};
}
function save(){
localStorage.setItem(CONFIG.STORAGE_KEY, JSON.stringify({board, activeFilters:[...activeFilters], searchQuery}));
updateStats();
}
function load(){
try{
const raw = localStorage.getItem(CONFIG.STORAGE_KEY);
if(raw){
const p = JSON.parse(raw);
if(p.board && p.board.columns) {
board = p.board;
activeFilters = new Set(p.activeFilters||[]);
searchQuery = p.searchQuery||'';
return;
}
}
}catch(e){}
board = defaultBoard();
}
function pushUndo(entry){ undoStack.push(entry); if(undoStack.length>20) undoStack.shift(); }
/* =========================================================
RENDERING
========================================================= */
const elBoard = document.getElementById('board');
const elBoardWrap = document.getElementById('boardWrap');
const elFilterBar = document.getElementById('filterBar');
const elSearch = document.getElementById('searchInput');
function render(){
// filter bar
elFilterBar.querySelectorAll('.chip:not(.filter-label)').forEach(n=>n.remove());
const allChip = document.createElement('button');
allChip.className='chip all'+(activeFilters.size===0?' active':'');
allChip.textContent='All';
allChip.onclick=()=>{activeFilters.clear(); save(); render();};
elFilterBar.appendChild(allChip);
CONFIG.LABELS.forEach(l=>{
const b=document.createElement('button');
b.className='chip'+(activeFilters.has(l.id)?' active':'');
b.style.background=l.color; b.style.color=l.text;
b.textContent=l.name;
b.onclick=()=>{
if(activeFilters.has(l.id)) activeFilters.delete(l.id); else activeFilters.add(l.id);
save(); render();
};
elFilterBar.appendChild(b);
});
elSearch.value = searchQuery;
// board
elBoard.innerHTML='';
board.columns.forEach(col=>{
const colEl = document.createElement('div');
colEl.className='column'+(col.cards.length===0?' empty':'')+(col.wip>0 && col.cards.length>col.wip?' wip-over':'');
colEl.dataset.colId = col.id;
// header
const head = document.createElement('div');
head.className='col-header';
head.dataset.drag='col';
head.dataset.colId=col.id;
head.innerHTML = `
<span class="col-title" data-edit-col="${col.id}">${escapeHtml(col.title)}</span>
<span class="col-count ${col.wip>0 && col.cards.length>col.wip?'over':''}">${col.cards.length}${col.wip? '/'+col.wip:''}</span>
<span class="wip-badge">WIP exceeded</span>
<button class="col-menu-btn" data-menu="${col.id}">⋯</button>
`;
colEl.appendChild(head);
// menu
const menu = document.createElement('div');
menu.className='col-menu'; menu.id='menu-'+col.id;
menu.innerHTML=`
<label>Column title</label>
<input value="${escapeAttr(col.title)}" data-menu-title="${col.id}">
<label>WIP limit (0 = no limit)</label>
<input type="number" min="0" value="${col.wip}" data-menu-wip="${col.id}">
<div class="menu-row">
<button class="btn" data-menu-close="${col.id}">Done</button>
<button class="btn" style="color:#dc2626;border-color:#fecaca" data-delete-col="${col.id}">Delete</button>
</div>
`;
colEl.appendChild(menu);
// body
const body = document.createElement('div');
body.className='col-body';
body.dataset.colId=col.id;
// cards
col.cards.forEach(card=>{
const c = document.createElement('div');
c.className='card';
c.dataset.cardId=card.id;
c.dataset.colId=col.id;
// filter logic (view only)
const labelMatch = activeFilters.size===0 || card.labels.some(l=>activeFilters.has(l));
const searchMatch = !searchQuery || card.title.toLowerCase().includes(searchQuery.toLowerCase());
if(!labelMatch || !searchMatch) c.classList.add('filtered-out');
const dueCls = card.due ? (card.due < todayISO() ? 'overdue' : (daysUntil(card.due)<=2 ? 'soon':'')) : '';
const labelsHtml = card.labels.map(id=>{
const L=CONFIG.LABELS.find(x=>x.id===id); if(!L) return '';
return `<span class="label-chip" style="background:${L.color};color:${L.text}">${L.name}</span>`;
}).join('');
c.innerHTML=`
<div class="card-actions">
<button class="icon-btn" data-edit-card="${card.id}" data-col="${col.id}" title="Edit">✎</button>
<button class="icon-btn danger" data-del-card="${card.id}" data-col="${col.id}" title="Delete">✕</button>
</div>
<p class="card-title" data-card-title="${card.id}">${escapeHtml(card.title)}</p>
<p class="card-desc ${card.desc?'':'empty'}">${card.desc? escapeHtml(card.desc): 'No description'}</p>
<div class="card-meta">
${labelsHtml}
${card.due? `<span class="due ${dueCls}">◷ ${card.due}</span>`:''}
</div>
`;
// pointer down for card drag
c.addEventListener('pointerdown', onCardPointerDown);
body.appendChild(c);
});
const empty = document.createElement('div');
empty.className='empty-drop';
empty.textContent='Drop cards here';
body.appendChild(empty);
const addBtn = document.createElement('button');
addBtn.className='add-card-btn';
addBtn.textContent='+ Add card';
addBtn.dataset.addCard = col.id;
body.appendChild(addBtn);
colEl.appendChild(body);
// column header drag
head.addEventListener('pointerdown', onColPointerDown);
elBoard.appendChild(colEl);
});
// add column card
const addCol = document.createElement('div');
addCol.className='add-col-card';
addCol.id='addColumnCard';
addCol.innerHTML='<span style="font-size:22px">+</span><span>Add column</span>';
addCol.onclick=()=> addColumn();
elBoard.appendChild(addCol);
attachStaticEvents();
updateStats();
}
function escapeHtml(s){ return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');}
function escapeAttr(s){ return escapeHtml(s).replace(/'/g,''');}
function daysUntil(d){ const a=new Date(d), b=new Date(todayISO()); return Math.round((a-b)/86400000); }
function updateStats(){
const total = board.columns.reduce((n,c)=>n+c.cards.length,0);
document.getElementById('boardStats').textContent = `${total} card${total!==1?'s':''} · ${board.columns.length} columns`;
}
/* =========================================================
STATIC EVENTS (delegated where possible)
========================================================= */
function attachStaticEvents(){
// col menu toggle
document.querySelectorAll('[data-menu]').forEach(b=>{
b.onclick=(e)=>{
e.stopPropagation();
const id=b.dataset.menu;
document.querySelectorAll('.col-menu').forEach(m=>{ if(m.id!=='menu-'+id) m.classList.remove('open');});
document.getElementById('menu-'+id).classList.toggle('open');
};
});
document.querySelectorAll('[data-menu-close]').forEach(b=>{
b.onclick=()=> document.getElementById('menu-'+b.dataset.menuClose).classList.remove('open');
});
document.querySelectorAll('[data-menu-title]').forEach(inp=>{
inp.onchange=()=>{
const col=findCol(inp.dataset.menuTitle); if(!col) return;
col.title = inp.value.trim()||col.title; save(); render();
};
inp.onkeydown=e=>{ if(e.key==='Enter'){ e.target.blur(); }}
});
document.querySelectorAll('[data-menu-wip]').forEach(inp=>{
inp.onchange=()=>{
const col=findCol(inp.dataset.menuWip); if(!col) return;
col.wip = Math.max(0, parseInt(inp.value)||0); save(); render();
};
});
document.querySelectorAll('[data-delete-col]').forEach(b=>{
b.onclick=()=> deleteColumn(b.dataset.deleteCol);
});
document.querySelectorAll('[data-add-card]').forEach(b=>{
b.onclick=()=> openCardModal(b.dataset.addCard, null, true);
});
document.querySelectorAll('[data-edit-card]').forEach(b=>{
b.onclick=(e)=>{ e.stopPropagation(); openCardModal(b.dataset.col, b.dataset.editCard, false); };
});
document.querySelectorAll('[data-del-card]').forEach(b=>{
b.onclick=(e)=>{ e.stopPropagation(); deleteCard(b.dataset.col, b.dataset.delCard); };
});
// inline title edit: card title click
document.querySelectorAll('[data-card-title]').forEach(el=>{
el.addEventListener('click', (e)=>{
e.stopPropagation();
startInlineEdit(el, el.dataset.cardTitle);
});
});
document.querySelectorAll('[data-edit-col]').forEach(el=>{
el.addEventListener('click', (e)=>{
e.stopPropagation();
startColInlineEdit(el, el.dataset.editCol);
});
});
// close menus on outside click
document.addEventListener('click', (e)=>{
if(!e.target.closest('.col-menu') && !e.target.closest('.col-menu-btn')){
document.querySelectorAll('.col-menu.open').forEach(m=>m.classList.remove('open'));
}
}, {once:true});
}
function startInlineEdit(el, cardId){
const colId = el.closest('.card')?.dataset.colId;
const col=findCol(colId); if(!col) return;
const card=col.cards.find(c=>c.id===cardId); if(!card) return;
const orig = card.title;
el.contentEditable='true'; el.focus();
// select all
const sel=window.getSelection(); const r=document.createRange(); r.selectNodeContents(el); sel.removeAllRanges(); sel.addRange(r);
const commit=()=>{
el.contentEditable='false';
const v=el.textContent.trim();
if(v && v!==orig){ card.title=v; save(); render(); }
else el.textContent=orig;
el.removeEventListener('blur', commit);
el.removeEventListener('keydown', onKey);
};
const cancel=()=>{
el.textContent=orig; el.contentEditable='false';
el.removeEventListener('blur', commit);
el.removeEventListener('keydown', onKey);
render();
};
const onKey=(e)=>{
if(e.key==='Enter'){ e.preventDefault(); el.blur(); }
if(e.key==='Escape'){ e.preventDefault(); cancel(); }
};
el.addEventListener('blur', commit);
el.addEventListener('keydown', onKey);
}
function startColInlineEdit(el, colId){
const col=findCol(colId); if(!col) return;
const orig=col.title;
el.contentEditable='true'; el.focus();
const sel=window.getSelection(); const r=document.createRange(); r.selectNodeContents(el); sel.removeAllRanges(); sel.addRange(r);
const commit=()=>{
el.contentEditable='false';
const v=el.textContent.trim();
if(v && v!==orig){ col.title=v; save(); render(); } else el.textContent=orig;
el.removeEventListener('blur', commit);
el.removeEventListener('keydown', onKey);
};
const cancel=()=>{ el.textContent=orig; el.contentEditable='false'; render(); };
const onKey=e=>{ if(e.key==='Enter'){e.preventDefault(); el.blur();} if(e.key==='Escape'){e.preventDefault(); cancel();}};
el.addEventListener('blur', commit);
el.addEventListener('keydown', onKey);
}
/* =========================================================
CRUD
========================================================= */
function findCol(id){ return board.columns.find(c=>c.id===id); }
function findCard(colId, cardId){
const col=findCol(colId); if(!col) return null;
return col.cards.find(c=>c.id===cardId);
}
function addColumn(){
const title = prompt('Column name:', 'New Column');
if(title===null) return;
const name = title.trim()||'New Column';
board.columns.push({id:uid(), title:name, wip:0, cards:[]});
save(); render();
// scroll to new column
requestAnimationFrame(()=> elBoardWrap.scrollLeft = elBoardWrap.scrollWidth);
}
function deleteColumn(colId){
if(board.columns.length<=1){ toast('Keep at least one column'); return; }
const idx=board.columns.findIndex(c=>c.id===colId); if(idx<0) return;
const col=clone(board.columns[idx]);
const pos=idx;
board.columns.splice(idx,1);
save(); render();
pushUndo({type:'col', col, pos});
showUndo(`Column “${col.title}” deleted`, ()=>{
board.columns.splice(pos,0,col); save(); render();
});
}
function deleteCard(colId, cardId){
const col=findCol(colId); if(!col) return;
const idx=col.cards.findIndex(c=>c.id===cardId); if(idx<0) return;
const card=clone(col.cards[idx]);
col.cards.splice(idx,1);
save(); render();
pushUndo({type:'card', card, colId, idx});
showUndo(`Card deleted`, ()=>{
const c=findCol(colId); if(c) c.cards.splice(idx,0,card); else board.columns[0].cards.push(card);
save(); render();
});
}
/* =========================================================
CARD MODAL
========================================================= */
const modal = document.getElementById('cardModal');
const mTitleEl = document.getElementById('mTitle');
const mDescEl = document.getElementById('mDesc');
const mDueEl = document.getElementById('mDue');
const mLabelsEl = document.getElementById('mLabels');
function openCardModal(colId, cardId, isNew){
let card;
if(isNew){
editingCard={colId, cardId:null, isNew:true};
card={title:'', desc:'', labels:[], due:''};
document.getElementById('modalTitle').textContent='Add card';
} else {
const col=findCol(colId); card=col.cards.find(c=>c.id===cardId);
if(!card) return;
editingCard={colId, cardId, isNew:false};
document.getElementById('modalTitle').textContent='Edit card';
}
mTitleEl.value=card.title; mDescEl.value=card.desc; mDueEl.value=card.due;
// label picker
mLabelsEl.innerHTML='';
CONFIG.LABELS.forEach(l=>{
const b=document.createElement('button');
b.className='chip'+(card.labels.includes(l.id)?' selected':'');
b.style.background=l.color; b.style.color=l.text; b.style.borderColor=card.labels.includes(l.id)?'#111827':'transparent';
b.textContent=l.name;
b.onclick=()=>{ b.classList.toggle('selected'); b.style.borderColor=b.classList.contains('selected')?'#111827':'transparent'; };
b.dataset.label=l.id;
mLabelsEl.appendChild(b);
});
modal.classList.add('open');
setTimeout(()=>mTitleEl.focus(), 30);
}
function closeModal(){ modal.classList.remove('open'); editingCard=null; }
document.getElementById('closeModalBtn').onclick=closeModal;
document.getElementById('cancelModalBtn').onclick=closeModal;
modal.addEventListener('click', e=>{ if(e.target===modal) closeModal(); });
document.getElementById('saveModalBtn').onclick=()=>{
if(!editingCard) return;
const title=mTitleEl.value.trim(); if(!title){ mTitleEl.focus(); mTitleEl.style.borderColor='#ef4444'; return; }
const labels=[...mLabelsEl.querySelectorAll('.chip.selected')].map(b=>b.dataset.label);
const data={title, desc:mDescEl.value.trim(), due:mDueEl.value, labels};
if(editingCard.isNew){
const col=findCol(editingCard.colId); if(!col) return;
col.cards.push({id:uid(), ...data});
} else {
const card=findCard(editingCard.colId, editingCard.cardId);
if(card) Object.assign(card, data);
}
save(); render(); closeModal();
};
/* =========================================================
SEARCH & FILTERS
========================================================= */
elSearch.addEventListener('input', ()=>{ searchQuery=elSearch.value; save(); render(); });
document.getElementById('clearFiltersBtn').onclick=()=>{ activeFilters.clear(); searchQuery=''; elSearch.value=''; save(); render(); };
document.getElementById('addColumnBtn').onclick=addColumn;
document.addEventListener('keydown', e=>{
if(e.key==='/' && !e.target.matches('input,textarea,[contenteditable]')){ e.preventDefault(); elSearch.focus(); }
if(e.key==='Escape' && drag){ cancelDrag(); }
if(e.key==='Escape' && modal.classList.contains('open')) closeModal();
});
/* =========================================================
TOAST + UNDO
========================================================= */
function toast(msg){
const w=document.getElementById('toastWrap');
const t=document.createElement('div'); t.className='toast'; t.textContent=msg;
w.appendChild(t); setTimeout(()=>t.remove(), 2200);
}
function showUndo(msg, onUndo){
const w=document.getElementById('toastWrap');
const t=document.createElement('div'); t.className='toast';
t.innerHTML=`<span>${escapeHtml(msg)}</span>`;
const b=document.createElement('button'); b.textContent='Undo'; b.onclick=()=>{ onUndo(); t.remove(); };
t.appendChild(b); w.appendChild(t);
let timer=setTimeout(()=>t.remove(), 5000);
t.addEventListener('mouseenter',()=>clearTimeout(timer));
t.addEventListener('mouseleave',()=> timer=setTimeout(()=>t.remove(),2000));
}
/* =========================================================
DRAG & DROP — Pointer Events (cards + columns)
========================================================= */
let dragStartPos=null;
let hasMoved=false;
function onCardPointerDown(e){
if(e.button!==0) return;
if(e.target.closest('.icon-btn')) return; // don't drag when clicking actions
// don't start inline edit interference
const cardEl = e.currentTarget;
const cardId = cardEl.dataset.cardId;
const colId = cardEl.dataset.colId;
const col=findCol(colId); if(!col) return;
const idx=col.cards.findIndex(c=>c.id===cardId);
dragStartPos={x:e.clientX, y:e.clientY};
hasMoved=false;
const pointerId=e.pointerId;
const onMove = (ev)=>{
if(ev.pointerId!==pointerId) return;
const dx=ev.clientX - dragStartPos.x, dy=ev.clientY - dragStartPos.y;
if(!hasMoved && Math.hypot(dx,dy) < CONFIG.DRAG_THRESHOLD) return;
if(!hasMoved){
hasMoved=true;
startCardDrag(cardEl, colId, cardId, idx, ev);
}
if(drag) updateCardDrag(ev);
};
const onUp = (ev)=>{
if(ev.pointerId!==pointerId) return;
cardEl.releasePointerCapture?.(pointerId);
window.removeEventListener('pointermove', onMove);
window.removeEventListener('pointerup', onUp);
window.removeEventListener('pointercancel', onUp);
if(drag) finishCardDrag(false);
dragStartPos=null;
};
cardEl.setPointerCapture(pointerId);
window.addEventListener('pointermove', onMove);
window.addEventListener('pointerup', onUp);
window.addEventListener('pointercancel', onUp);
// prevent text selection
e.preventDefault();
}
function startCardDrag(cardEl, colId, cardId, origIndex, e){
const rect = cardEl.getBoundingClientRect();
const offsetX = e.clientX - rect.left;
const offsetY = e.clientY - rect.top;
// placeholder
const ph = document.createElement('div');
ph.className='placeholder';
ph.style.height = rect.height + 'px';
// insert placeholder at original position
cardEl.insertAdjacentElement('afterend', ph);
cardEl.classList.add('hidden-placeholder');
// ghost
const ghost = cardEl.cloneNode(true);
ghost.classList.add('ghost-card');
ghost.style.width = rect.width + 'px';
ghost.style.height = rect.height + 'px';
ghost.style.left = (e.clientX - offsetX) + 'px';
ghost.style.top = (e.clientY - offsetY) + 'px';
document.body.appendChild(ghost);
drag = {
type:'card', id:cardId, origColId:colId, origIndex,
colId, index:origIndex,
ghost, placeholder:ph, offsetX, offsetY,
cardEl,
currentColId:colId, currentIndex:origIndex,
pointerId:e.pointerId
};
document.body.style.userSelect='none';
document.body.style.cursor='grabbing';
// auto scroll loop
startAutoScroll();
}
function updateCardDrag(e){
if(!drag || drag.type!=='card') return;
// move ghost
drag.ghost.style.left = (e.clientX - drag.offsetX) + 'px';
drag.ghost.style.top = (e.clientY - drag.offsetY) + 'px';
// hit test: batched reads first
const cols = [...document.querySelectorAll('.column')];
let targetColId = null;
let insertIndex = 0;
// find column under cursor (use x)
// if cursor outside board, clamp to nearest column
for(const ce of cols){
const r=ce.getBoundingClientRect();
if(e.clientX >= r.left && e.clientX <= r.right){
targetColId = ce.dataset.colId;
break;
}
}
if(!targetColId){
// find nearest column by center distance
let best=null, bestD=Infinity;
for(const ce of cols){
const r=ce.getBoundingClientRect();
const cx=r.left + r.width/2;
const d=Math.abs(e.clientX - cx);
if(d<bestD){ bestD=d; best=ce.dataset.colId; }
}
targetColId=best;
}
if(!targetColId) return;
const colBody = document.querySelector(`.col-body[data-col-id="${targetColId}"]`);
const cardEls = [...colBody.querySelectorAll('.card:not(.hidden-placeholder)')].filter(el=> !el.classList.contains('filtered-out') );
// also placeholder currently in DOM might be in another col; we handle move below
// determine insert position by y
if(cardEls.length===0){
insertIndex=0;
} else {
// compare cursor Y to each card's center
let placed=false;
for(let i=0;i<cardEls.length;i++){
const r=cardEls[i].getBoundingClientRect();
const mid=r.top + r.height/2;
if(e.clientY < mid){
insertIndex=i; placed=true; break;
}
}
if(!placed) insertIndex=cardEls.length;
}
// move placeholder DOM (write)
// remove placeholder from old place and insert at new
const currentParent = drag.placeholder.parentElement;
const targetParent = colBody;
if(currentParent !== targetParent || drag.currentColId!==targetColId || drag.currentIndex!==insertIndex){
// remove from old
drag.placeholder.remove();
// compute insertion point in targetParent among visible cards + empty + add button
// we insert before the card at insertIndex, or before empty-drop if at end
const visible = [...targetParent.querySelectorAll('.card:not(.hidden-placeholder)')].filter(el=>!el.classList.contains('filtered-out'));
if(insertIndex >= visible.length){
// before empty-drop
const empty = targetParent.querySelector('.empty-drop');
targetParent.insertBefore(drag.placeholder, empty);
} else {
targetParent.insertBefore(drag.placeholder, visible[insertIndex]);
}
drag.currentColId = targetColId;
drag.currentIndex = insertIndex;
// visual feedback
document.querySelectorAll('.col-body').forEach(b=>b.classList.remove('drag-over'));
targetParent.classList.add('drag-over');
// mark columns drag-active for empty state
document.querySelectorAll('.column').forEach(c=>c.classList.remove('drag-active'));
document.querySelector(`.column[data-col-id="${targetColId}"]`)?.classList.add('drag-active');
}
drag._lastX = e.clientX; drag._lastY = e.clientY;
}
function finishCardDrag(cancelled){
if(!drag || drag.type!=='card') return;
stopAutoScroll();
document.querySelectorAll('.col-body').forEach(b=>b.classList.remove('drag-over'));
document.querySelectorAll('.column').forEach(c=>c.classList.remove('drag-active'));
document.body.style.userSelect='';
document.body.style.cursor='';
const {origColId, origIndex, currentColId, currentIndex, placeholder, ghost, cardEl, id} = drag;
// cleanup DOM
placeholder.remove();
ghost.remove();
cardEl.classList.remove('hidden-placeholder');
if(cancelled){
drag=null; return;
}
// apply to model: remove from orig, insert at new
// we stored orig col/index, but model still unmodified. Perform move.
// Need to handle same-col vs cross-col correctly.
// Remove
const srcCol=findCol(origColId);
const dstCol=findCol(currentColId);
if(!srcCol || !dstCol){ drag=null; return; }
const srcIdx = srcCol.cards.findIndex(c=>c.id===id);
if(srcIdx<0){ drag=null; return; }
const [moved] = srcCol.cards.splice(srcIdx,1);
// adjust insert index when same column and moving downwards
let ins = currentIndex;
if(origColId===currentColId && srcIdx < ins) ins = ins - 1;
// clamp
ins = Math.max(0, Math.min(ins, dstCol.cards.length));
dstCol.cards.splice(ins,0,moved);
drag=null;
save(); render();
// subtle settle animation: highlight moved card
requestAnimationFrame(()=>{
const el=document.querySelector(`.card[data-card-id="${id}"]`);
if(el){ el.animate([{transform:'scale(1.02)'},{transform:'scale(1)'}],{duration:220, easing:'ease-out'}); }
});
}
function cancelDrag(){
if(!drag) return;
if(drag.type==='card') finishCardDrag(true);
else if(drag.type==='col') finishColDrag(true);
}
/* ---- Auto-scroll ---- */
let autoScrollRAF=null;
function startAutoScroll(){
if(autoScrollRAF) cancelAnimationFrame(autoScrollRAF);
const tick=()=>{
if(!drag) return;
const x=drag._lastX, y=drag._lastY;
if(x==null || y==null){ autoScrollRAF=requestAnimationFrame(tick); return; }
// horizontal board scroll
const br=elBoardWrap.getBoundingClientRect();
if(x < br.left + CONFIG.BOARD_SCROLL_EDGE) elBoardWrap.scrollLeft -= CONFIG.AUTO_SCROLL_SPEED;
else if(x > br.right - CONFIG.BOARD_SCROLL_EDGE) elBoardWrap.scrollLeft += CONFIG.AUTO_SCROLL_SPEED;
// vertical column scroll for current column
if(drag.type==='card' && drag.currentColId){
const body=document.querySelector(`.col-body[data-col-id="${drag.currentColId}"]`);
if(body){
const r=body.getBoundingClientRect();
if(y < r.top + CONFIG.AUTO_SCROLL_EDGE) body.scrollTop -= CONFIG.AUTO_SCROLL_SPEED;
else if(y > r.bottom - CONFIG.AUTO_SCROLL_EDGE) body.scrollTop += CONFIG.AUTO_SCROLL_SPEED;
}
}
autoScrollRAF=requestAnimationFrame(tick);
};
autoScrollRAF=requestAnimationFrame(tick);
}
function stopAutoScroll(){ if(autoScrollRAF) cancelAnimationFrame(autoScrollRAF); autoScrollRAF=null; }
/* ---- Column drag ---- */
function onColPointerDown(e){
if(e.button!==0) return;
if(e.target.closest('.col-menu-btn')) return;
if(e.target.closest('[contenteditable]')) return;
// only drag via header
const header=e.currentTarget;
const colId=header.dataset.colId;
const colEl=header.closest('.column');
const startX=e.clientX, startY=e.clientY;
let moved=false;
const pid=e.pointerId;
const onMove=(ev)=>{
if(ev.pointerId!==pid) return;
const dx=ev.clientX - startX;
if(!moved && Math.abs(dx) < CONFIG.DRAG_THRESHOLD) return;
if(!moved){ moved=true; startColDrag(colEl, colId, ev); }
if(drag) updateColDrag(ev);
};
const onUp=(ev)=>{
if(ev.pointerId!==pid) return;
colEl.releasePointerCapture?.(pid);
window.removeEventListener('pointermove', onMove);
window.removeEventListener('pointerup', onUp);
if(drag && drag.type==='col') finishColDrag(false);
};
colEl.setPointerCapture(pid);
window.addEventListener('pointermove', onMove);
window.addEventListener('pointerup', onUp);
// don't prevent default to allow text selection? we want drag so prevent
// e.preventDefault();
}
function startColDrag(colEl, colId, e){
const rect=colEl.getBoundingClientRect();
const idx=board.columns.findIndex(c=>c.id===colId);
const ph=document.createElement('div');
ph.className='placeholder';
ph.style.width=rect.width+'px';
ph.style.height=rect.height+'px';
ph.style.flexShrink='0';
colEl.insertAdjacentElement('afterend', ph);
colEl.style.visibility='hidden';
colEl.style.pointerEvents='none';
const ghost=colEl.cloneNode(true);
ghost.classList.add('ghost-card');
ghost.style.width=rect.width+'px';
ghost.style.height=rect.height+'px';
// height already set via clone; position fixed
ghost.style.visibility='visible';
ghost.style.pointerEvents='none';
ghost.style.left=(e.clientX - rect.width/2)+'px';
ghost.style.top=(rect.top)+'px';
ghost.style.transform='rotate(2deg) scale(1.02)';
document.body.appendChild(ghost);
drag={type:'col', id:colId, origIndex:idx, currentIndex:idx, colEl, placeholder:ph, ghost, offsetX: rect.width/2, pointerId:e.pointerId, _lastX:e.clientX};
document.body.style.userSelect='none';
document.body.style.cursor='grabbing';
startAutoScroll();
}
function updateColDrag(e){
if(!drag || drag.type!=='col') return;
drag.ghost.style.left=(e.clientX - drag.offsetX)+'px';
// find target index by x
const cols=[...document.querySelectorAll('.column:not(.ghost-card)')].filter(el=> el.style.visibility!=='hidden');
// including placeholder position? we compute based on existing columns order in DOM (excluding hidden)
// simpler: compute insert index from board.columns order + pointer x vs column centers
const boardRect=elBoard.getBoundingClientRect();
const x=e.clientX;
// get columns in DOM order (visible)
const visibleCols=[...elBoard.querySelectorAll('.column')].filter(c=>c!==drag.colEl);
let targetIdx=0;
if(visibleCols.length===0) targetIdx=0;
else{
let placed=false;
for(let i=0;i<visibleCols.length;i++){
const r=visibleCols[i].getBoundingClientRect();
const mid=r.left + r.width/2;
if(x < mid){ targetIdx=i; placed=true; break; }
}
if(!placed) targetIdx=visibleCols.length;
}
// account for original position shift? We'll just move placeholder DOM
// Move placeholder to targetIdx position among columns container
// elBoard children includes columns + add-col-card at end
const addCardEl=document.getElementById('addColumnCard');
const colsInBoard=[...elBoard.children].filter(ch=> ch.classList.contains('column') && ch!==drag.colEl);
// drag.placeholder is currently somewhere; remove and reinsert
drag.placeholder.remove();
if(targetIdx >= colsInBoard.length){
elBoard.insertBefore(drag.placeholder, addCardEl);
} else {
elBoard.insertBefore(drag.placeholder, colsInBoard[targetIdx]);
}
drag.currentIndex=targetIdx;
// auto adjust for original position: if origIndex < targetIdx, DOM index shifts
// But we use DOM insertion above which already accounts visual; model update will map.
drag._lastX=e.clientX; drag._lastY=e.clientY;
}
function finishColDrag(cancelled){
if(!drag || drag.type!=='col') return;
stopAutoScroll();
document.body.style.userSelect=''; document.body.style.cursor='';
const {colEl, placeholder, ghost, origIndex, currentIndex, id}=drag;
placeholder.remove();
ghost.remove();
colEl.style.visibility=''; colEl.style.pointerEvents='';
if(cancelled){ drag=null; return; }
// apply to model: need to translate DOM targetIdx to model index
// The currentIndex we computed is insertion index among other cols (excluding dragged)
// That's exactly the model insertion index after removal.
let from=board.columns.findIndex(c=>c.id===id);
if(from<0){ drag=null; return; }
const [moved]=board.columns.splice(from,1);
let to=currentIndex;
// If from < to, to is already adjusted? Our currentIndex was computed excluding dragged element,
// so after removal it's correct. No further adjustment.
to=Math.max(0, Math.min(to, board.columns.length));
board.columns.splice(to,0,moved);
drag=null;
save(); render();
}
/* =========================================================
EXPOSED FOR TESTS (probe)
========================================================= */
window.__board = ()=> clone(board);
window.__setBoard = (b)=>{ board=clone(b); save(); render(); };
window.__dragState = ()=> drag ? {type:drag.type, currentColId:drag.currentColId, currentIndex:drag.currentIndex} : null;
/* =========================================================
INIT
========================================================= */
load();
render();
updateStats();
// close modal on Esc already handled
</script>
</body>
</html>