Kanban
GLM 5.3 Flash · full

Reviews & generation details
Reviews
No human review yet. Unreviewed does not mean unsuccessful.
Generation context
Imported historical artifact. Its exact original instructions and lineage are not known.
Generation prompt · exact text unavailable
See the current prompt for reference. It may differ from the instructions used to generate this artifact.
Inspect original source 36,502 bytes · SHA-256 ca959677f563
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Kanban Board</title>
<style>
/* ============================================================
STYLES
============================================================ */
:root{
--bg:#f4f5f7;
--col-bg:#ebecf0;
--card-bg:#ffffff;
--text:#172b4d;
--text-sub:#5e6c84;
--border:#dfe1e6;
--accent:#0052cc;
--danger:#de350b;
--radius-col:12px;
--radius-card:8px;
--shadow-card:0 1px 2px rgba(9,30,66,.12), 0 0 1px rgba(9,30,66,.16);
--shadow-hover:0 4px 8px rgba(9,30,66,.15), 0 0 1px rgba(9,30,66,.21);
--shadow-float:0 12px 24px rgba(9,30,66,.25), 0 0 1px rgba(9,30,66,.31);
--col-w:292px;
--header-h:56px;
}
*{box-sizing:border-box;margin:0;padding:0}
html,body{height:100%}
body{
font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;
background:var(--bg);color:var(--text);
display:flex;flex-direction:column;overflow:hidden;
-webkit-font-smoothing:antialiased;
}
button{font:inherit;border:none;background:none;cursor:pointer;color:inherit}
input,textarea{font:inherit;color:inherit}
/* ---------- Board header ---------- */
.board-header{
height:var(--header-h);flex:0 0 auto;
display:flex;align-items:center;gap:16px;
padding:0 20px;background:#fff;
border-bottom:1px solid var(--border);
z-index:30;
}
.board-title{font-size:17px;font-weight:700;letter-spacing:-.2px}
.search-wrap{position:relative;flex:0 1 280px}
.search-wrap svg{position:absolute;left:9px;top:50%;transform:translateY(-50%);pointer-events:none}
#searchInput{
width:100%;height:34px;padding:0 10px 0 32px;
border:1px solid var(--border);border-radius:6px;background:var(--bg);
outline:none;transition:background .15s,border-color .15s;
}
#searchInput:focus{background:#fff;border-color:var(--accent)}
.filter-bar{display:flex;gap:6px;align-items:center;flex-wrap:nowrap;overflow-x:auto}
.filter-chip{
display:inline-flex;align-items:center;gap:6px;height:28px;
padding:0 10px;border-radius:14px;border:1px solid transparent;
font-size:12px;font-weight:600;white-space:nowrap;opacity:.45;
transition:opacity .15s, box-shadow .15s;
}
.filter-chip .dot{width:9px;height:9px;border-radius:50%}
.filter-chip.active{opacity:1;box-shadow:0 0 0 2px #fff,0 0 0 3.5px currentColor}
.filter-chip:hover{opacity:.85}
#filterClear{font-size:12px;color:var(--text-sub);white-space:nowrap;display:none}
#filterClear:hover{color:var(--danger)}
.header-spacer{flex:1}
/* ---------- Board ---------- */
#board{
flex:1;display:flex;align-items:flex-start;gap:14px;
padding:18px 20px;overflow-x:auto;overflow-y:hidden;
scroll-behavior:auto;
}
#board.dragging-col{cursor:grabbing}
/* ---------- Column ---------- */
.column{
width:var(--col-w);flex:0 0 auto;max-height:100%;
display:flex;flex-direction:column;
background:var(--col-bg);border-radius:var(--radius-col);
transition:box-shadow .15s;
}
.column.over-limit{box-shadow:inset 0 0 0 2px var(--danger)}
.column.drag-ghost-src{opacity:.4}
.col-head{
flex:0 0 auto;display:flex;align-items:center;gap:6px;
padding:10px 12px 6px;cursor:grab;user-select:none;
}
.col-head:active{cursor:grabbing}
.col-title{
font-size:13px;font-weight:700;text-transform:uppercase;letter-spacing:.4px;
color:var(--text-sub);flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;
padding:2px 4px;border-radius:4px;
}
.col-title:hover{background:rgba(9,30,66,.08)}
.col-count{
font-size:12px;font-weight:600;color:var(--text-sub);
background:rgba(9,30,66,.08);border-radius:10px;padding:1px 8px;
}
.col-count.over{background:var(--danger);color:#fff}
.col-menu-btn{
width:24px;height:24px;border-radius:4px;font-size:15px;line-height:1;
color:var(--text-sub);display:flex;align-items:center;justify-content:center;
}
.col-menu-btn:hover{background:rgba(9,30,66,.12)}
.wip-badge{font-size:11px;font-weight:600;color:var(--text-sub)}
.wip-badge.over{color:var(--danger)}
.col-cards{
flex:1 1 auto;overflow-y:auto;overflow-x:hidden;
padding:4px 8px 4px;min-height:40px;
scrollbar-width:thin;
}
.col-cards::-webkit-scrollbar{width:8px}
.col-cards::-webkit-scrollbar-thumb{background:rgba(9,30,66,.2);border-radius:4px}
.col-foot{flex:0 0 auto;padding:6px 8px 10px}
.add-card-btn{
width:100%;text-align:left;padding:7px 10px;border-radius:6px;
font-size:13px;font-weight:500;color:var(--text-sub);
}
.add-card-btn:hover{background:rgba(9,30,66,.08);color:var(--text)}
/* empty column dashed drop zone */
.col-cards.empty{
border:2px dashed #c1c7d0;border-radius:8px;
background:rgba(9,30,66,.02);min-height:64px;margin:4px 8px;padding:0;
display:flex;align-items:center;justify-content:center;
color:#a5adba;font-size:12px;font-weight:500;
}
.col-cards.empty.drag-over{border-color:var(--accent);color:var(--accent);background:rgba(0,82,204,.05)}
/* ---------- Card ---------- */
.card{
background:var(--card-bg);border-radius:var(--radius-card);
box-shadow:var(--shadow-card);
padding:10px 12px;margin-bottom:8px;cursor:grab;user-select:none;
transition:box-shadow .12s, transform .12s;
position:relative;
}
.card:hover{box-shadow:var(--shadow-hover);transform:translateY(-1px)}
.card.hidden{display:none}
.card.settle{animation:settle .22s ease-out}
@keyframes settle{0%{transform:scale(1.04)}100%{transform:scale(1)}}
.card-title{font-size:14px;font-weight:500;line-height:1.4;word-wrap:break-word;outline:none;border-radius:3px}
.card-title.editing{box-shadow:0 0 0 2px var(--accent);background:#fff;cursor:text;user-select:text}
.card-desc{font-size:12px;color:var(--text-sub);margin-top:4px;line-height:1.4;
display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
.card-meta{display:flex;flex-wrap:wrap;gap:4px;margin-top:8px;align-items:center}
.label-chip{
font-size:11px;font-weight:600;padding:2px 8px;border-radius:10px;color:#172b4d;
}
.due-chip{
font-size:11px;font-weight:600;padding:2px 8px;border-radius:10px;
background:#f1f2f4;color:var(--text-sub);display:inline-flex;align-items:center;gap:4px;
}
.due-chip.overdue{background:#ffebe6;color:var(--danger)}
.card-del{
position:absolute;top:6px;right:6px;width:20px;height:20px;border-radius:4px;
font-size:13px;color:#a5adba;display:none;align-items:center;justify-content:center;
}
.card:hover .card-del{display:flex}
.card-del:hover{background:#ffebe6;color:var(--danger)}
/* ---------- Drag visuals ---------- */
.card.placeholder{
background:rgba(9,30,66,.06);box-shadow:none;border:2px dashed #b3b9c4;
transform:none!important;cursor:grabbing;
}
.card.placeholder *{visibility:hidden}
.card.floating{
position:fixed;z-index:1000;pointer-events:none;margin:0;
box-shadow:var(--shadow-float);transform:rotate(3deg) scale(1.03);
width:var(--drag-w);cursor:grabbing;
}
.column.placeholder{
background:rgba(9,30,66,.05);border:2px dashed #b3b9c4;
width:var(--col-w);flex:0 0 auto;border-radius:var(--radius-col);min-height:120px;
}
.column.floating{
position:fixed;z-index:1000;pointer-events:none;margin:0;
box-shadow:var(--shadow-float);transform:rotate(2deg) scale(1.02);
cursor:grabbing;
}
/* ---------- Add column ---------- */
#addColumn{
width:var(--col-w);flex:0 0 auto;align-self:flex-start;
padding:12px;border-radius:var(--radius-col);
background:rgba(9,30,66,.06);color:var(--text-sub);
font-size:13px;font-weight:600;text-align:left;
}
#addColumn:hover{background:rgba(9,30,66,.12)}
/* ---------- Column menu dropdown ---------- */
.dropdown{
position:fixed;z-index:1100;background:#fff;border-radius:8px;
box-shadow:0 8px 24px rgba(9,30,66,.2),0 0 1px rgba(9,30,66,.3);
padding:6px;min-width:200px;display:none;
}
.dropdown.open{display:block}
.dropdown .dd-item{
display:block;width:100%;text-align:left;padding:8px 10px;border-radius:5px;
font-size:13px;
}
.dropdown .dd-item:hover{background:#f4f5f7}
.dropdown .dd-item.danger{color:var(--danger)}
.dropdown .dd-item.danger:hover{background:#ffebe6}
.dropdown .dd-sep{height:1px;background:var(--border);margin:5px 4px}
.dropdown .dd-label{font-size:11px;font-weight:700;color:var(--text-sub);text-transform:uppercase;padding:6px 10px 2px;letter-spacing:.4px}
.dropdown input[type=number]{
width:100%;padding:6px 10px;border:1px solid var(--border);border-radius:5px;outline:none;
}
/* ---------- Modal ---------- */
#modalOverlay{
position:fixed;inset:0;background:rgba(9,30,66,.5);z-index:1200;
display:none;align-items:flex-start;justify-content:center;padding-top:10vh;
}
#modalOverlay.open{display:flex}
.modal{
background:#fff;border-radius:10px;width:440px;max-width:92vw;
box-shadow:0 20px 40px rgba(9,30,66,.3);padding:20px;
animation:modalIn .18s ease-out;
}
@keyframes modalIn{from{opacity:0;transform:translateY(-8px)}to{opacity:1;transform:none}}
.modal h3{font-size:15px;margin-bottom:14px}
.modal label{display:block;font-size:11px;font-weight:700;color:var(--text-sub);text-transform:uppercase;letter-spacing:.4px;margin:12px 0 5px}
.modal input[type=text],.modal textarea,.modal input[type=date]{
width:100%;padding:8px 10px;border:1px solid var(--border);border-radius:6px;outline:none;
}
.modal textarea{resize:vertical;min-height:64px}
.modal input:focus,.modal textarea:focus{border-color:var(--accent)}
.label-picker{display:flex;flex-wrap:wrap;gap:6px}
.label-pick{
display:inline-flex;align-items:center;gap:6px;padding:4px 10px;border-radius:12px;
font-size:12px;font-weight:600;border:2px solid transparent;opacity:.5;
}
.label-pick.sel{opacity:1;border-color:currentColor}
.modal-actions{display:flex;gap:8px;margin-top:18px;justify-content:flex-end}
.btn{padding:8px 14px;border-radius:6px;font-size:13px;font-weight:600}
.btn-primary{background:var(--accent);color:#fff}
.btn-primary:hover{background:#0747a6}
.btn-ghost{background:#f4f5f7}
.btn-ghost:hover{background:#e4e6ea}
.btn-danger{color:var(--danger);margin-right:auto}
.btn-danger:hover{background:#ffebe6}
/* ---------- Toast ---------- */
#toast{
position:fixed;bottom:24px;left:50%;transform:translate(-50%,80px);
background:#172b4d;color:#fff;padding:11px 16px;border-radius:8px;
font-size:13px;display:flex;align-items:center;gap:14px;z-index:1300;
box-shadow:0 8px 24px rgba(9,30,66,.4);
transition:transform .25s ease,opacity .25s;opacity:0;pointer-events:none;
}
#toast.show{transform:translate(-50%,0);opacity:1;pointer-events:auto}
#toastUndo{color:#4c9aff;font-weight:700}
#toastUndo:hover{text-decoration:underline}
</style>
</head>
<body>
<header class="board-header">
<div class="board-title">Project Board</div>
<div class="search-wrap">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#5e6c84" stroke-width="2.4"><circle cx="11" cy="11" r="7"/><path d="M21 21l-4.3-4.3"/></svg>
<input id="searchInput" type="text" placeholder="Search cards…" autocomplete="off">
</div>
<div class="filter-bar" id="filterBar"></div>
<button id="filterClear">Clear filters</button>
<div class="header-spacer"></div>
</header>
<main id="board"></main>
<div class="dropdown" id="colMenu">
<button class="dd-item" data-act="rename">Rename column</button>
<div class="dd-sep"></div>
<div class="dd-label">WIP limit</div>
<input type="number" id="wipInput" min="0" placeholder="No limit">
<div class="dd-sep"></div>
<button class="dd-item danger" data-act="delete">Delete column</button>
</div>
<div id="modalOverlay">
<div class="modal">
<h3 id="modalTitle">Edit card</h3>
<label>Title</label>
<input type="text" id="mTitle">
<label>Description</label>
<textarea id="mDesc"></textarea>
<label>Due date</label>
<input type="date" id="mDue">
<label>Labels</label>
<div class="label-picker" id="mLabels"></div>
<div class="modal-actions">
<button class="btn btn-danger" id="mDelete">Delete card</button>
<button class="btn btn-ghost" id="mCancel">Cancel</button>
<button class="btn btn-primary" id="mSave">Save</button>
</div>
</div>
</div>
<div id="toast"><span id="toastMsg"></span><button id="toastUndo">Undo</button></div>
<script>
/* ============================================================
TUNABLE CONSTANTS
============================================================ */
const CFG = {
STORAGE_KEY: 'kanban-board-v1',
DRAG_THRESHOLD: 6, // px before a press becomes a drag
EDGE_SCROLL_ZONE: 70, // px from board edge that triggers auto-scroll
EDGE_SCROLL_SPEED: 14, // px per frame at edge
COL_SCROLL_ZONE: 60, // px from column top/bottom for vertical scroll
COL_SCROLL_SPEED: 10,
TILT_DEG: 3,
UNDO_MS: 6000,
LABEL_PALETTE: [
{ id:'l1', name:'Design', color:'#c0ebf5' },
{ id:'l2', name:'Bug', color:'#ffecb3' },
{ id:'l3', name:'Feature', color:'#d3f5ee' },
{ id:'l4', name:'Research', color:'#eae6ff' },
{ id:'l5', name:'Urgent', color:'#ffcfdb' },
{ id:'l6', name:'Chore', color:'#dfe1e6' },
],
};
const DEFAULT_BOARD = () => ({
columns: [
{ id:uid(), title:'Backlog', wipLimit:null, cards:[
{ id:uid(), title:'Design onboarding flow', desc:'Sketch 3 alternatives for the first-run experience.', labels:['l1'], due:'' },
{ id:uid(), title:'Investigate slow search', desc:'Queries take >2s on large boards.', labels:['l2','l4'], due:'' },
{ id:uid(), title:'Mobile layout polish', desc:'', labels:[], due:'' },
]},
{ id:uid(), title:'To Do', wipLimit:null, cards:[
{ id:uid(), title:'Add keyboard shortcuts', desc:'N for new card, / for search.', labels:['l3'], due:'' },
{ id:uid(), title:'Upgrade dependencies', desc:'', labels:['l6'], due: new Date(Date.now()+7*864e5).toISOString().slice(0,10) },
]},
{ id:uid(), title:'In Progress', wipLimit:3, cards:[
{ id:uid(), title:'Drag-and-drop reordering', desc:'Pointer-event based, with live placeholder.', labels:['l3','l1'], due:'' },
]},
{ id:uid(), title:'Review', wipLimit:null, cards:[
{ id:uid(), title:'Board search + filters', desc:'', labels:['l3'], due:'' },
]},
{ id:uid(), title:'Done', wipLimit:null, cards:[
{ id:uid(), title:'Set up project repo', desc:'', labels:['l6'], due:'' },
{ id:uid(), title:'Choose color palette', desc:'', labels:['l1'], due:'' },
]},
],
});
function uid(){ return 'id' + Math.random().toString(36).slice(2,9) + Date.now().toString(36).slice(-4); }
</script>
<script>
/* ============================================================
STATE & PERSISTENCE
Single source of truth: `board` (serializable) + `filter`.
============================================================ */
const filter = { search:'', labels:new Set() };
let board = load() || DEFAULT_BOARD();
function save(){
localStorage.setItem(CFG.STORAGE_KEY, JSON.stringify({
board,
filter:{ search:filter.search, labels:[...filter.labels] },
}));
}
function load(){
try{
const d = JSON.parse(localStorage.getItem(CFG.STORAGE_KEY));
if (d && Array.isArray(d.board?.columns)){
filter.search = d.filter?.search || '';
filter.labels = new Set(d.filter?.labels || []);
return d.board;
}
}catch(e){}
return null;
}
const getCol = id => board.columns.find(c=>c.id===id);
function findCard(cardId){
for (const col of board.columns){
const i = col.cards.findIndex(c=>c.id===cardId);
if (i !== -1) return { col, index:i, card:col.cards[i] };
}
return null;
}
function cardVisible(card){
if (filter.search && !card.title.toLowerCase().includes(filter.search.toLowerCase())) return false;
if (filter.labels.size && !card.labels?.some(l=>filter.labels.has(l))) return false;
return true;
}
const esc = s => String(s??'').replace(/[&<>"']/g, m=>({'&':'&','<':'<','>':'>','"':'"',"'":"'"}[m]));
/* ============================================================
RENDERING (derived entirely from the model)
============================================================ */
const boardEl = document.getElementById('board');
function render(){
boardEl.innerHTML = '';
for (const col of board.columns) boardEl.appendChild(buildColumnEl(col));
const add = document.createElement('button');
add.id = 'addColumn';
add.textContent = '+ Add column';
add.addEventListener('click', addColumn);
boardEl.appendChild(add);
renderFilterBar();
updateWipHighlights();
}
function buildColumnEl(col){
const el = document.createElement('div');
el.className = 'column';
el.dataset.colId = col.id;
const over = col.wipLimit && col.cards.length > col.wipLimit;
el.innerHTML = `
<div class="col-head">
<span class="col-title" title="Click to rename">${esc(col.title)}</span>
${col.wipLimit ? `<span class="wip-badge ${over?'over':''}">WIP: ${col.cards.length}/${col.wipLimit}</span>` : ''}
<span class="col-count ${over?'over':''}">${col.cards.length}</span>
<button class="col-menu-btn" title="Column menu">⋯</button>
</div>
<div class="col-cards"></div>
<div class="col-foot"><button class="add-card-btn">+ Add card</button></div>`;
const body = el.querySelector('.col-cards');
for (const card of col.cards){
const c = buildCardEl(card);
if (!cardVisible(card)) c.classList.add('hidden');
body.appendChild(c);
}
el.querySelector('.col-menu-btn').addEventListener('click', e => { e.stopPropagation(); openColMenu(col.id, e.currentTarget); });
el.querySelector('.add-card-btn').addEventListener('click', () => addCard(col.id));
const title = el.querySelector('.col-title');
title.addEventListener('click', () => editInline(title, col.title, v => {
if (v && v !== col.title){ col.title = v; save(); }
render();
}));
return el;
}
function buildCardEl(card){
const el = document.createElement('div');
el.className = 'card';
el.dataset.cardId = card.id;
const chips = (card.labels||[]).map(lid=>{
const l = CFG.LABEL_PALETTE.find(x=>x.id===lid);
return l ? `<span class="label-chip" style="background:${l.color}">${esc(l.name)}</span>` : '';
}).join('');
let due = '';
if (card.due){
const od = card.due < new Date().toISOString().slice(0,10);
due = `<span class="due-chip ${od?'overdue':''}">🗓 ${esc(card.due)}</span>`;
}
el.innerHTML = `
<div class="card-title">${esc(card.title)}</div>
${card.desc ? `<div class="card-desc">${esc(card.desc)}</div>` : ''}
${(chips||due) ? `<div class="card-meta">${chips}${due}</div>` : ''}
<button class="card-del" title="Delete card">✕</button>`;
el.querySelector('.card-del').addEventListener('click', e=>{
e.stopPropagation();
destructive(board2 => {
const loc = findCard(card.id); if(!loc) return;
loc.col.cards.splice(loc.index,1);
}, 'Card deleted');
});
const t = el.querySelector('.card-title');
t.addEventListener('click', e=>{
e.stopPropagation();
editInline(t, card.title, v => {
if (v && v !== card.title){ card.title = v; save(); }
render();
});
});
el.addEventListener('click', ()=> openCardModal(card.id));
return el;
}
function updateWipHighlights(){
for (const colEl of boardEl.querySelectorAll('.column')){
const col = getCol(colEl.dataset.colId); if(!col) continue;
const over = col.wipLimit && col.cards.length > col.wipLimit;
colEl.classList.toggle('over-limit', !!over);
const cnt = colEl.querySelector('.col-count');
if (cnt) cnt.classList.toggle('over', !!over);
const wb = colEl.querySelector('.wip-badge');
if (wb) wb.classList.toggle('over', !!over);
}
}
function updateEmptyStates(){
for (const body of boardEl.querySelectorAll('.col-cards')){
const hasPh = !!body.querySelector(':scope > .card.placeholder');
const vis = [...body.children].filter(el=>el.classList.contains('card') && !el.classList.contains('hidden') && !el.classList.contains('placeholder')).length;
body.classList.toggle('empty', vis===0 && !hasPh);
body.classList.toggle('drag-over', hasPh);
}
}
/* ---------- filter bar ---------- */
function renderFilterBar(){
const bar = document.getElementById('filterBar');
bar.innerHTML = '';
for (const l of CFG.LABEL_PALETTE){
const b = document.createElement('button');
b.className = 'filter-chip' + (filter.labels.has(l.id) ? ' active' : '');
b.style.color = l.color;
b.style.background = l.color;
b.innerHTML = `<span class="dot" style="background:rgba(23,43,77,.45)"></span>${esc(l.name)}`;
b.addEventListener('click', ()=>{
filter.labels.has(l.id) ? filter.labels.delete(l.id) : filter.labels.add(l.id);
save(); render();
});
bar.appendChild(b);
}
document.getElementById('filterClear').style.display =
(filter.search || filter.labels.size) ? 'block' : 'none';
}
document.getElementById('filterClear').addEventListener('click', ()=>{
filter.search=''; filter.labels.clear();
document.getElementById('searchInput').value='';
save(); render();
});
document.getElementById('searchInput').addEventListener('input', e=>{
filter.search = e.target.value;
save(); render();
});
/* ============================================================
INLINE EDITING
============================================================ */
function editInline(el, current, onCommit){
const input = document.createElement('input');
input.type='text'; input.value=current;
input.style.cssText='width:100%;padding:2px 4px;border:1px solid var(--accent);border-radius:4px;outline:none;font-weight:600';
el.replaceWith(input);
input.focus(); input.select();
let done = false;
const finish = commit => {
if (done) return; done = true;
onCommit(commit ? input.value.trim() : current);
};
input.addEventListener('keydown', e=>{
e.stopPropagation();
if (e.key==='Enter') finish(true);
else if (e.key==='Escape') finish(false);
});
input.addEventListener('blur', ()=>finish(true));
input.addEventListener('pointerdown', e=>e.stopPropagation());
}
/* ============================================================
CRUD
============================================================ */
let undoSnapshot = null, undoTimer = null;
function destructive(mutate, msg){
const snap = JSON.stringify(board);
mutate(board);
save(); render();
showToast(msg, ()=>{
board = JSON.parse(snap);
save(); render();
});
}
function showToast(msg, onUndo){
const t = document.getElementById('toast');
document.getElementById('toastMsg').textContent = msg;
const u = document.getElementById('toastUndo');
u.onclick = ()=>{ onUndo && onUndo(); hideToast(); };
t.classList.add('show');
clearTimeout(undoTimer);
undoTimer = setTimeout(hideToast, CFG.UNDO_MS);
}
function hideToast(){ document.getElementById('toast').classList.remove('show'); }
function addCard(colId){
const col = getCol(colId); if(!col) return;
const card = { id:uid(), title:'New card', desc:'', labels:[], due:'' };
col.cards.push(card);
save(); render();
const el = boardEl.querySelector(`[data-card-id="${card.id}"] .card-title`);
if (el) editInline(el, card.title, v => {
card.title = v || 'Untitled'; save(); render();
});
}
function addColumn(){
const col = { id:uid(), title:'New column', wipLimit:null, cards:[] };
board.columns.push(col);
save(); render();
const el = boardEl.querySelector(`[data-col-id="${col.id}"] .col-title`);
if (el) editInline(el, col.title, v => {
col.title = v || 'Untitled'; save(); render();
});
}
/* ---------- card modal ---------- */
let modalCardId = null;
const overlay = document.getElementById('modalOverlay');
function openCardModal(cardId){
const loc = findCard(cardId); if(!loc) return;
modalCardId = cardId;
const c = loc.card;
document.getElementById('modalTitle').textContent = 'Edit card';
document.getElementById('mTitle').value = c.title;
document.getElementById('mDesc').value = c.desc || '';
document.getElementById('mDue').value = c.due || '';
const lp = document.getElementById('mLabels');
lp.innerHTML = '';
for (const l of CFG.LABEL_PALETTE){
const b = document.createElement('button');
b.type='button';
b.className = 'label-pick' + ((c.labels||[]).includes(l.id) ? ' sel' : '');
b.style.background = l.color; b.style.color = '#172b4d';
b.textContent = l.name;
b.addEventListener('click', ()=>{
c.labels = c.labels || [];
const i = c.labels.indexOf(l.id);
i===-1 ? c.labels.push(l.id) : c.labels.splice(i,1);
b.classList.toggle('sel');
});
lp.appendChild(b);
}
overlay.classList.add('open');
document.getElementById('mTitle').focus();
}
function closeModal(){ overlay.classList.remove('open'); modalCardId = null; }
document.getElementById('mSave').addEventListener('click', ()=>{
const loc = findCard(modalCardId);
if (loc){
loc.card.title = document.getElementById('mTitle').value.trim() || 'Untitled';
loc.card.desc = document.getElementById('mDesc').value;
loc.card.due = document.getElementById('mDue').value;
save();
}
closeModal(); render();
});
document.getElementById('mCancel').addEventListener('click', closeModal);
document.getElementById('mDelete').addEventListener('click', ()=>{
const id = modalCardId; closeModal();
destructive(b => {
for (const col of b.columns){
const i = col.cards.findIndex(c=>c.id===id);
if (i!==-1){ col.cards.splice(i,1); return; }
}
}, 'Card deleted');
});
overlay.addEventListener('pointerdown', e=>{ if(e.target===overlay) closeModal(); });
/* ---------- column menu ---------- */
const colMenu = document.getElementById('colMenu');
let menuColId = null;
function openColMenu(colId, btn){
menuColId = colId;
const col = getCol(colId);
document.getElementById('wipInput').value = col.wipLimit ?? '';
const r = btn.getBoundingClientRect();
colMenu.style.left = Math.min(r.left, innerWidth-220) + 'px';
colMenu.style.top = (r.bottom + 6) + 'px';
colMenu.classList.add('open');
}
colMenu.addEventListener('click', e=>{
const act = e.target.dataset?.act;
if (!act) return;
const col = getCol(menuColId);
colMenu.classList.remove('open');
if (!col) return;
if (act==='rename'){
const el = boardEl.querySelector(`[data-col-id="${col.id}"] .col-title`);
if (el) editInline(el, col.title, v => { col.title = v || col.title; save(); render(); });
} else if (act==='delete'){
if (board.columns.length <= 1){ showToast('Board must keep at least one column'); return; }
destructive(b => { b.columns = b.columns.filter(c=>c.id!==col.id); }, 'Column deleted');
}
});
document.getElementById('wipInput').addEventListener('change', e=>{
const col = getCol(menuColId); if(!col) return;
const v = parseInt(e.target.value, 10);
col.wipLimit = (v && v > 0) ? v : null;
save(); render();
colMenu.classList.remove('open');
});
document.addEventListener('pointerdown', e=>{
if (!colMenu.contains(e.target)) colMenu.classList.remove('open');
});
/* ============================================================
DRAG & DROP (Pointer Events, pixel-following)
============================================================ */
let drag = null; // active drag state
let candidate = null; // press awaiting drag threshold
let suppressClick = false;
boardEl.addEventListener('pointerdown', e=>{
if (e.button !== 0) return;
const cardEl = e.target.closest('.card');
const headEl = e.target.closest('.col-head');
if (cardEl && !e.target.closest('.card-del')){
candidate = { kind:'card', id:cardEl.dataset.cardId, x:e.clientX, y:e.clientY, srcEl:cardEl };
e.preventDefault();
} else if (headEl){
candidate = { kind:'col', id:headEl.closest('.column').dataset.colId, x:e.clientX, y:e.clientY, srcEl:headEl.closest('.column') };
e.preventDefault();
}
});
window.addEventListener('pointermove', e=>{
if (!drag && candidate){
const dx = e.clientX - candidate.x, dy = e.clientY - candidate.y;
if (dx*dx + dy*dy > CFG.DRAG_THRESHOLD*CFG.DRAG_THRESHOLD){
startDrag(candidate, e);
candidate = null;
}
}
if (drag){
drag.px = e.clientX; drag.py = e.clientY;
positionFloat();
updatePlaceholder();
}
});
window.addEventListener('pointerup', finishDrag);
window.addEventListener('pointercancel', cancelDrag);
window.addEventListener('keydown', e=>{
if (e.key==='Escape'){
if (drag){ e.preventDefault(); cancelDrag(); }
else if (overlay.classList.contains('open')) closeModal();
else colMenu.classList.remove('open');
}
});
function startDrag(cand, e){
suppressClick = true;
setTimeout(()=>{ suppressClick = false; }, 0);
if (cand.kind==='card') startCardDrag(cand, e);
else startColDrag(cand, e);
requestAnimationFrame(scrollTick);
}
/* ---------- card drag ---------- */
function startCardDrag(cand, e){
const loc = findCard(cand.id); if(!loc) return;
const cardEl = boardEl.querySelector(`[data-card-id="${cand.id}"]`);
const r = cardEl.getBoundingClientRect();
const float = cardEl.cloneNode(true);
float.classList.remove('settle');
float.classList.add('floating');
float.style.width = r.width + 'px';
document.body.appendChild(float);
const ph = document.createElement('div');
ph.className = 'card placeholder';
ph.style.height = r.height + 'px';
cardEl.style.display = 'none';
cardEl.parentNode.insertBefore(ph, cardEl);
drag = {
kind:'card', id:cand.id,
float, ph, srcEl:cardEl,
offX: e.clientX - r.left, offY: e.clientY - r.top,
px:e.clientX, py:e.clientY,
from:{ colId: loc.col.id, index: loc.index },
curColEl: cardEl.closest('.column'),
};
updateEmptyStates();
positionFloat();
updatePlaceholder();
}
function positionFloat(){
if (!drag) return;
drag.float.style.left = (drag.px - drag.offX) + 'px';
drag.float.style.top = (drag.py - drag.offY) + 'px';
}
function columnEls(){
return [...boardEl.querySelectorAll('.column')]
.filter(el => !el.classList.contains('placeholder') && el.style.display !== 'none');
}
function findColumnAt(x){
const cols = columnEls();
for (const c of cols){
const r = c.getBoundingClientRect();
if (x >= r.left && x <= r.right) return c;
}
// nearest by center
let best=null, bd=Infinity;
for (const c of cols){
const r = c.getBoundingClientRect();
const d = Math.abs(x - (r.left + r.width/2));
if (d < bd){ bd=d; best=c; }
}
return best;
}
function computeCardVisIndex(body, y){
const cards = [...body.children].filter(el =>
el.classList.contains('card') && !el.classList.contains('placeholder') && !el.classList.contains('hidden'));
for (let i=0;i<cards.length;i++){
const r = cards[i].getBoundingClientRect();
if (y < r.top + r.height/2) return i;
}
return cards.length;
}
function updatePlaceholder(){
if (!drag) return;
if (drag.kind === 'card'){
const colEl = findColumnAt(drag.px);
if (!colEl) return;
drag.curColEl = colEl;
const body = colEl.querySelector('.col-cards');
const idx = computeCardVisIndex(body, drag.py);
const vis = [...body.children].filter(el =>
el.classList.contains('card') && !el.classList.contains('placeholder') && !el.classList.contains('hidden'));
const ref = vis[idx] || null;
if (drag.ph.parentNode !== body || drag.ph.nextSibling !== ref){
body.insertBefore(drag.ph, ref);
updateEmptyStates();
}
} else {
const cols = columnEls();
let idx = cols.length;
for (let i=0;i<cols.length;i++){
const r = cols[i].getBoundingClientRect();
if (drag.px < r.left + r.width/2){ idx = i; break; }
}
const ref = cols[idx] || null;
if (drag.ph.nextSibling !== ref){
boardEl.insertBefore(drag.ph, ref);
}
}
}
/* ---------- column drag ---------- */
function startColDrag(cand, e){
const colEl = boardEl.querySelector(`[data-col-id="${cand.id}"]`);
if (!colEl) return;
const r = colEl.getBoundingClientRect();
const float = colEl.cloneNode(true);
float.classList.add('floating');
float.style.width = r.width + 'px';
float.style.height = r.height + 'px';
document.body.appendChild(float);
const ph = document.createElement('div');
ph.className = 'column placeholder';
ph.style.height = r.height + 'px';
colEl.style.display = 'none';
boardEl.insertBefore(ph, colEl);
boardEl.classList.add('dragging-col');
drag = {
kind:'col', id:cand.id,
float, ph, srcEl:colEl,
offX: e.clientX - r.left, offY: e.clientY - r.top,
px:e.clientX, py:e.clientY,
from:{ index: board.columns.findIndex(c=>c.id===cand.id) },
};
positionFloat();
updatePlaceholder();
}
/* ---------- auto-scroll loop ---------- */
function scrollTick(){
if (!drag) return;
const bRect = boardEl.getBoundingClientRect();
let scrolled = false;
if (drag.px < bRect.left + CFG.EDGE_SCROLL_ZONE){
boardEl.scrollLeft -= CFG.EDGE_SCROLL_SPEED; scrolled = true;
} else if (drag.px > bRect.right - CFG.EDGE_SCROLL_ZONE){
boardEl.scrollLeft += CFG.EDGE_SCROLL_SPEED; scrolled = true;
}
if (drag.kind === 'card' && drag.curColEl){
const body = drag.curColEl.querySelector('.col-cards');
const r = body.getBoundingClientRect();
if (drag.py < r.top + CFG.COL_SCROLL_ZONE && body.scrollTop > 0){
body.scrollTop -= CFG.COL_SCROLL_SPEED; scrolled = true;
} else if (drag.py > r.bottom - CFG.COL_SCROLL_ZONE &&
body.scrollTop < body.scrollHeight - body.clientHeight){
body.scrollTop += CFG.COL_SCROLL_SPEED; scrolled = true;
}
}
if (scrolled) updatePlaceholder();
requestAnimationFrame(scrollTick);
}
/* ---------- drop / cancel ---------- */
function cleanupDrag(){
if (!drag) return;
drag.float.remove();
drag.ph.remove();
if (drag.srcEl) drag.srcEl.style.display = '';
boardEl.classList.remove('dragging-col');
drag = null;
updateEmptyStates();
}
function finishDrag(){
candidate = null;
if (!drag) return;
const d = drag;
if (d.kind === 'card'){
const body = d.ph.closest('.column').querySelector('.col-cards');
// count visible cards sitting before the placeholder
let visIdx = 0;
for (const el of body.children){
if (el === d.ph) break;
if (el.classList.contains('card') && !el.classList.contains('hidden')) visIdx++;
}
const dstColId = d.ph.closest('.column').dataset.colId;
cleanupDragFor(d);
// mutate model
const loc = findCard(d.id);
if (loc){
const [card] = loc.col.cards.splice(loc.index, 1);
const dst = getCol(dstColId) || loc.col;
const vis = dst.cards.filter(cardVisible);
let mi;
if (visIdx >= vis.length) mi = dst.cards.length;
else mi = dst.cards.indexOf(vis[visIdx]);
dst.cards.splice(mi, 0, card);
save();
render();
const el = boardEl.querySelector(`[data-card-id="${d.id}"]`);
if (el) el.classList.add('settle');
}
} else {
const cols = columnEls();
let idx = cols.length;
for (let i=0;i<cols.length;i++){
const r = cols[i].getBoundingClientRect();
if (d.px < r.left + r.width/2){ idx = i; break; }
}
cleanupDragFor(d);
const col = getCol(d.id);
if (col){
const without = board.columns.filter(c=>c.id!==d.id);
board.columns = [...without.slice(0, idx), col, ...without.slice(idx)];
save(); render();
}
}
drag = null;
}
function cleanupDragFor(d){
d.float.remove();
d.ph.remove();
if (d.srcEl) d.srcEl.style.display = '';
boardEl.classList.remove('dragging-col');
updateEmptyStates();
}
function cancelDrag(){
candidate = null;
if (!drag) return;
cleanupDrag(); // model untouched — re-render restores exact original state
render();
}
/* suppress the click that follows a drag */
boardEl.addEventListener('click', e=>{
if (suppressClick){ e.stopPropagation(); e.preventDefault(); }
}, true);
/* ============================================================
INIT
============================================================ */
document.getElementById('searchInput').value = filter.search;
render();
updateEmptyStates();
</script>
</body>
</html>
<!-- agent-meta {"model":"z-ai/glm-5.3-flash","provider":"openrouter","persona":"full","sessionId":"cfe79d87-e2d2-49a9-9c1d-00d79aca6a5c","tokensIn":543223,"tokensOut":24649,"tokensTotal":567872,"cost":0.019555764999999996,"turns":19,"toolCalls":18,"failedToolCalls":0,"timestamp":"2026-08-31T14:24:37.543Z"} -->