← Kanban results

Kanban

GPT 6 Luna · full

Initial view of Kanban

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 32,292 bytes · SHA-256 2266bc688099
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Northstar · Project board</title>
<style>
:root{--ink:#202631;--muted:#77808e;--line:#e5e8ed;--paper:#f5f6f8;--white:#fff;--accent:#6558d3;--accent-soft:#eeecff;--shadow:0 2px 8px #2026310b,0 1px 2px #2026310a;--radius:12px;--gap:18px;--column-w:292px}
*{box-sizing:border-box}body{margin:0;background:var(--paper);color:var(--ink);font:14px/1.45 Inter,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;overflow:hidden}button,input,textarea{font:inherit}button{cursor:pointer;color:inherit} .app{height:100vh;display:flex;flex-direction:column}
.topbar{height:66px;flex:none;background:#fff;border-bottom:1px solid var(--line);display:flex;align-items:center;padding:0 30px;gap:24px;z-index:5}.brand{display:flex;align-items:center;gap:10px;font-weight:750;letter-spacing:-.3px;font-size:16px;white-space:nowrap}.brand-mark{width:27px;height:27px;border-radius:8px;background:linear-gradient(145deg,#8378f2,#5145c4);display:grid;place-items:center;color:white;font-size:15px;box-shadow:0 3px 7px #6558d344}.crumb{height:24px;border-left:1px solid var(--line);padding-left:23px;color:#9aa1ac;font-size:13px}.board-name{font-size:13px;color:#68717e;font-weight:600;margin-left:-14px}.top-spacer{flex:1}.search-wrap{position:relative;width:230px}.search-wrap span{position:absolute;left:11px;top:8px;color:#9aa1ac;font-size:16px}.search{width:100%;height:34px;border:1px solid var(--line);border-radius:8px;padding:0 12px 0 34px;outline:none;background:#fafbfc;font-size:12px}.search:focus{border-color:#a59ef0;box-shadow:0 0 0 3px #6558d31a}.avatar{width:30px;height:30px;display:grid;place-items:center;border-radius:50%;background:#f5d9c7;color:#92582f;font-weight:700;font-size:11px}.share{border:0;background:#262b35;color:#fff;padding:8px 14px;border-radius:7px;font-size:12px;font-weight:650}
.board-head{flex:none;display:flex;align-items:center;padding:22px 30px 16px;gap:12px;background:var(--paper);z-index:3}.heading h1{font-size:21px;letter-spacing:-.6px;margin:0 0 2px;font-weight:720}.heading p{margin:0;color:#9097a2;font-size:12px}.head-actions{margin-left:auto;display:flex;gap:9px}.button{height:34px;border:1px solid #e1e4e9;background:white;border-radius:8px;padding:0 12px;font-size:12px;font-weight:600;box-shadow:0 1px 2px #20263107;display:flex;align-items:center;gap:7px}.button:hover{background:#fafaff;border-color:#c9c4f5}.button.primary{background:var(--accent);color:#fff;border-color:var(--accent);box-shadow:0 3px 7px #6558d333}.button.primary:hover{background:#5549c3}.filter-row{height:45px;flex:none;padding:0 30px;display:flex;align-items:center;gap:8px;background:var(--paper)}.filter-label{color:#9aa1ac;font-size:11px;font-weight:700;letter-spacing:.08em;text-transform:uppercase;margin-right:3px}.filter-chip{border:1px solid transparent;background:transparent;padding:5px 9px;border-radius:18px;font-size:11px;color:#68717e;display:flex;align-items:center;gap:6px}.filter-chip:hover{background:white}.filter-chip.active{background:white;border-color:#e5e7eb;box-shadow:0 1px 2px #20263109;color:#303641}.dot{height:8px;width:8px;border-radius:50%;display:inline-block}.filter-clear{border:0;background:none;color:var(--accent);font-size:11px;padding:5px}.board-shell{flex:1;min-height:0;overflow-x:auto;overflow-y:hidden;padding:9px 30px 25px;scrollbar-color:#cbd0d8 transparent;scrollbar-width:thin}.board{height:100%;display:flex;align-items:stretch;gap:var(--gap);min-width:max-content}.column{width:var(--column-w);height:100%;max-height:100%;flex:none;display:flex;flex-direction:column;position:relative;border-radius:12px;background:#eceef2;transition:box-shadow .18s,transform .18s}.column.over-limit{box-shadow:inset 0 0 0 1.5px #e36363}.column.dragging-column{opacity:.38}.column-head{height:53px;flex:none;padding:0 12px;display:flex;align-items:center;gap:8px;cursor:grab;user-select:none}.column-head:active{cursor:grabbing}.column-title{font-size:12px;font-weight:720;letter-spacing:.01em;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:174px}.count{font-size:10px;color:#89919d;background:#dfe2e8;border-radius:10px;padding:1px 7px;font-weight:700}.limit-badge{font-size:10px;color:#bd4848;margin-left:-5px}.column-menu-btn{margin-left:auto;border:0;background:transparent;color:#9097a2;border-radius:5px;width:25px;height:25px;font-size:18px;line-height:20px}.column-menu-btn:hover{background:#e1e3e8;color:#454b55}.card-list{flex:1;min-height:0;overflow-y:auto;padding:1px 9px 11px;scrollbar-width:thin;scrollbar-color:#cbd0d8 transparent;position:relative}.task-card{background:#fff;border:1px solid #eaecf0;border-radius:9px;padding:12px 12px 10px;margin-bottom:8px;box-shadow:var(--shadow);position:relative;transition:box-shadow .16s,transform .16s,border-color .16s;cursor:grab;user-select:none}.task-card:hover{transform:translateY(-1px);box-shadow:0 5px 13px #20263111,0 1px 2px #20263109}.task-card:active{cursor:grabbing}.task-card.is-source{visibility:hidden}.task-title{font-size:12px;line-height:1.5;font-weight:620;letter-spacing:-.08px;cursor:text;white-space:pre-wrap;overflow-wrap:anywhere}.task-title:hover{color:#5145c4}.task-desc{font-size:11px;color:#848c98;margin-top:6px;line-height:1.5;white-space:pre-wrap;overflow:hidden;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical}.chips{display:flex;gap:5px;flex-wrap:wrap;margin-top:10px}.label-chip{font-size:9px;line-height:17px;padding:0 7px;border-radius:4px;font-weight:650;letter-spacing:.01em;white-space:nowrap}.card-foot{display:flex;align-items:center;justify-content:space-between;margin-top:10px;min-height:18px}.due{font-size:10px;color:#828a96;display:flex;gap:4px;align-items:center}.due.late{color:#ce5a58}.mini-avatar{height:21px;width:21px;border-radius:50%;background:#ede9ff;color:#6558d3;display:grid;place-items:center;font-size:8px;font-weight:750;border:2px solid #fff}.drop-placeholder{border:1.5px dashed #8d84e8;background:#e8e5ff88;border-radius:9px;height:67px;margin:0 0 8px;transition:height .12s,margin .12s;flex:none}.drop-placeholder.column-placeholder{width:var(--column-w);height:auto;min-height:100%;background:#e8e5ff66;border:2px dashed #8d84e8;margin:0;border-radius:12px}.empty-drop{height:100%;min-height:110px;border:1.5px dashed #d0d4dc;border-radius:9px;display:flex;align-items:center;justify-content:center;color:#9aa1ac;font-size:11px;transition:.15s}.empty-drop.hot{border-color:#8278e3;background:#e8e5ff66;color:#6558d3}.add-card{border:0;background:transparent;width:100%;text-align:left;color:#858d99;padding:9px 10px;font-size:11px;border-radius:7px;margin-top:1px}.add-card:hover{background:#e3e5e9;color:#3f4651}.add-column{height:40px;min-width:150px;border:1px dashed #d2d6de;background:#ffffff58;border-radius:10px;color:#858d99;font-size:12px;font-weight:600}.add-column:hover{background:white;color:#6558d3;border-color:#aaa4ed}.drag-ghost{position:fixed!important;z-index:1000!important;pointer-events:none!important;width:var(--column-w);margin:0!important;transform:rotate(1.3deg) scale(1.025);box-shadow:0 15px 35px #20263129,0 4px 10px #2026311c!important;opacity:.97;cursor:grabbing!important}.column-ghost{position:fixed;z-index:999;pointer-events:none;width:var(--column-w);height:calc(100vh - 240px);background:#eceef2;border-radius:12px;box-shadow:0 14px 30px #20263125;opacity:.9;transform:rotate(.7deg)}
.toast{position:fixed;bottom:22px;left:50%;transform:translate(-50%,20px);opacity:0;pointer-events:none;background:#252a34;color:white;padding:11px 15px;border-radius:9px;box-shadow:0 8px 24px #20263133;font-size:12px;z-index:1200;transition:.2s;display:flex;gap:17px;align-items:center}.toast.show{transform:translate(-50%,0);opacity:1;pointer-events:auto}.toast button{border:0;background:none;color:#bdb6ff;font-weight:700;padding:0}.modal-backdrop{position:fixed;inset:0;background:#19202b55;z-index:1100;display:grid;place-items:center;padding:20px;backdrop-filter:blur(2px)}.modal{width:min(440px,100%);background:white;border-radius:14px;box-shadow:0 24px 70px #18202f33;padding:22px}.modal h2{font-size:17px;margin:0 0 18px;letter-spacing:-.3px}.field{display:block;font-size:11px;color:#707986;font-weight:650;margin:13px 0 6px}.input,.textarea{width:100%;border:1px solid #dfe3e9;border-radius:8px;padding:9px 10px;outline:0;font-size:12px;background:white}.input:focus,.textarea:focus{border-color:#938bee;box-shadow:0 0 0 3px #6558d31a}.textarea{resize:vertical;min-height:74px}.modal-labels{display:flex;flex-wrap:wrap;gap:7px}.label-choice{border:1px solid #e5e7eb;background:#fff;padding:5px 8px;border-radius:6px;font-size:10px;display:flex;align-items:center;gap:5px}.label-choice.selected{border-color:#aaa3ef;background:#f5f3ff}.modal-actions{display:flex;justify-content:flex-end;gap:8px;margin-top:20px}.inline-title{width:100%;border:1px solid #9b94ee;border-radius:4px;padding:2px 4px;font-size:12px;font-weight:620;outline:0}.menu-pop{position:fixed;z-index:50;width:190px;background:white;border:1px solid #e6e8ed;border-radius:9px;padding:5px;box-shadow:0 8px 24px #2026311c}.menu-pop button{display:block;width:100%;border:0;background:white;text-align:left;padding:8px 9px;font-size:11px;border-radius:5px}.menu-pop button:hover{background:#f3f2ff;color:#5045bd}.menu-pop button.danger{color:#c45454}.wip-row{padding:8px;font-size:11px;color:#737b87}.wip-row input{width:54px;margin-left:8px;border:1px solid #dfe3e9;border-radius:5px;padding:4px 5px;font-size:11px}
@media(max-width:700px){.topbar{padding:0 16px;gap:12px}.crumb,.board-name,.share{display:none}.search-wrap{width:min(180px,40vw)}.board-head{padding:17px 16px 12px}.filter-row{padding:0 16px;overflow-x:auto}.board-shell{padding:8px 16px 18px}.column{width:278px}.head-actions .button:not(.primary){display:none}}
</style></head>
<body><div class="app">
<header class="topbar"><div class="brand"><span class="brand-mark">✦</span>northstar</div><div class="crumb"></div><div class="board-name">Workspace / Product</div><div class="top-spacer"></div><label class="search-wrap"><span>⌕</span><input class="search" id="searchInput" placeholder="Search tasks…" autocomplete="off"></label><div class="avatar">AM</div><button class="share">↗ &nbsp;Share</button></header>
<section class="board-head"><div class="heading"><h1>Product roadmap</h1><p>Plan, track, and ship great work together.</p></div><div class="head-actions"><button class="button" id="sortBtn">☷ &nbsp;Board view</button><button class="button" id="filterToggle">⏷ &nbsp;Filter</button><button class="button primary" id="newTaskBtn">+ &nbsp;New task</button></div></section>
<div class="filter-row" id="filterRow"><span class="filter-label">Labels</span><div id="filterChips" style="display:flex;gap:4px;align-items:center"></div><button class="filter-clear" id="clearFilters" style="display:none">Clear</button><span style="margin-left:auto;color:#a0a6af;font-size:11px" id="resultCount"></span></div>
<main class="board-shell" id="boardShell"><div class="board" id="board"></div></main></div><div class="toast" id="toast"></div><div id="modalRoot"></div>
<script>
// ── Tunable constants ──────────────────────────────────────────────
const CONFIG={columnWidth:292,dragThreshold:5,edgeZone:72,scrollSpeed:12,labels:[{id:'design',name:'Design',color:'#8b7cf6',bg:'#efedff'},{id:'engineering',name:'Engineering',color:'#4389d7',bg:'#e8f3ff'},{id:'product',name:'Product',color:'#e79a42',bg:'#fff2df'},{id:'research',name:'Research',color:'#46a987',bg:'#e3f6ef'},{id:'urgent',name:'Urgent',color:'#dd6666',bg:'#ffeded'},{id:'marketing',name:'Marketing',color:'#cf71a2',bg:'#fcecf4'}]};
// ── Serializable single source of truth ───────────────────────────
const STORAGE_KEY='northstar-kanban-v1';
const seed=()=>({columns:[
{id:'backlog',title:'Backlog',wip:null,cards:[{id:'c1',title:'Explore a new onboarding flow',description:'Map the first-run experience and identify the moments that matter most.',labels:['design','research'],due:null},{id:'c2',title:'Audit current activation funnel',description:'Pull the latest conversion numbers and highlight the biggest drop-offs.',labels:['product'],due:'2025-06-18'},{id:'c3',title:'Set up customer interview schedule',description:'Recruit five new users for discovery calls.',labels:['research'],due:null}]},
{id:'todo',title:'To Do',wip:null,cards:[{id:'c4',title:'Define Q3 product goals',description:'Align the team around measurable outcomes for the next quarter.',labels:['product'],due:null},{id:'c5',title:'Refresh component documentation',description:'Bring the design system docs up to date with the latest patterns.',labels:['design','engineering'],due:'2025-06-24'}]},
{id:'progress',title:'In Progress',wip:4,cards:[{id:'c6',title:'Build workspace switching',description:'Let people move between workspaces without losing context.',labels:['engineering'],due:'2025-06-20'},{id:'c7',title:'Mobile navigation patterns',description:'Prototype a simpler navigation model for smaller screens.',labels:['design'],due:null}]},
{id:'review',title:'Review',wip:null,cards:[{id:'c8',title:'Invite teammates by email',description:'Review the invite flow and check empty, loading, and error states.',labels:['engineering','product'],due:'2025-06-16'}]},
{id:'done',title:'Done',wip:null,cards:[{id:'c9',title:'Update brand color tokens',description:'',labels:['design'],due:null},{id:'c10',title:'Improve account security',description:'Added optional two-factor authentication to account settings.',labels:['engineering','urgent'],due:null}]}
],filters:[],search:''});
function loadModel(){try{const v=JSON.parse(localStorage.getItem(STORAGE_KEY));if(v&&Array.isArray(v.columns))return v}catch(e){}return seed()}
let model=loadModel(); model.filters=model.filters||[];model.search=model.search||'';let idSeq=Date.now();const uid=()=>`x${++idSeq}`;let drag=null,menuNode=null,toastTimer=null,undoFn=null,framePending=false,suppressCardClickUntil=0;
function persist(){try{localStorage.setItem(STORAGE_KEY,JSON.stringify(model))}catch(e){}}
function label(id){return CONFIG.labels.find(x=>x.id===id)}
function matches(card){return (!model.search||card.title.toLowerCase().includes(model.search.toLowerCase()))&&(!model.filters.length||model.filters.every(f=>card.labels.includes(f)))}
function visibleCards(col){return col.cards.filter(matches)}
function escapeHtml(s=''){return String(s).replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]))}
function fmtDate(d){if(!d)return'';let [y,m,day]=d.split('-').map(Number);return new Date(y,m-1,day).toLocaleDateString('en-US',{month:'short',day:'numeric'})}
// ── Rendering: DOM is only a projection of model ──────────────────
function renderFilters(){const root=document.getElementById('filterChips');root.innerHTML=CONFIG.labels.map(l=>`<button class="filter-chip ${model.filters.includes(l.id)?'active':''}" data-filter="${l.id}"><i class="dot" style="background:${l.color}"></i>${l.name}</button>`).join('');document.getElementById('clearFilters').style.display=model.filters.length?'':'none';}
function render(){renderFilters();const board=document.getElementById('board');let html='';const ordered=model.columns;const addColPlaceholder=()=>'<div class="drop-placeholder column-placeholder" data-col-placeholder="true"></div>';ordered.forEach((col,ci)=>{if(drag?.type==='column'&&drag.columnId!==col.id){const remainingBefore=ordered.slice(0,ci).filter(c=>c.id!==drag.columnId).length;if(remainingBefore===(drag.targetIndex??-1))html+=addColPlaceholder()}const cards=visibleCards(col);let over=col.wip!=null&&col.cards.length>col.wip;html+=`<section class="column ${over?'over-limit':''} ${drag?.type==='column'&&drag.columnId===col.id?'dragging-column':''}" data-column="${col.id}"><header class="column-head" data-colhead="${col.id}"><span class="column-title">${escapeHtml(col.title)}</span><span class="count">${col.cards.length}</span>${col.wip!=null?`<span class="limit-badge">/${col.wip}</span>`:''}<button class="column-menu-btn" data-menu="${col.id}" aria-label="Column options">···</button></header><div class="card-list" data-list="${col.id}">`;
if(drag?.type==='card'&&drag.targetCol===col.id){const visibleNoDrag=cards.filter(c=>c.id!==drag.cardId);let pos=Math.max(0,Math.min(visibleNoDrag.length,drag.targetVisibleIndex??visibleNoDrag.length));visibleNoDrag.forEach((card,i)=>{if(i===pos)html+='<div class="drop-placeholder" data-placeholder="true"></div>';html+=cardHtml(card)});if(pos===visibleNoDrag.length)html+='<div class="drop-placeholder" data-placeholder="true"></div>';
}else if(cards.length){cards.forEach(c=>html+=cardHtml(c))}else{html+=`<div class="empty-drop" data-empty="${col.id}">Drop a task here</div>`}
html+=`<button class="add-card" data-add="${col.id}">+ &nbsp;Add task</button></div></section>`;});if(drag?.type==='column'){const remaining=ordered.filter(c=>c.id!==drag.columnId).length;if(drag.targetIndex>=remaining)html+=addColPlaceholder();}html+=`<button class="add-column" id="addColumn">+ &nbsp;Add column</button>`;board.innerHTML=html;document.getElementById('resultCount').textContent=`${model.columns.reduce((n,c)=>n+visibleCards(c).length,0)} tasks`;
}
function cardHtml(c){const labels=c.labels.map(id=>label(id)).filter(Boolean).map(l=>`<span class="label-chip" style="background:${l.bg};color:${l.color}">${escapeHtml(l.name)}</span>`).join('');const due=c.due?`<span class="due ${c.due<'2025-01-01'?'late':''}">◷ ${fmtDate(c.due)}</span>`:'';const initials=(c.assignee||'AM').slice(0,2).toUpperCase();return `<article class="task-card ${drag?.cardId===c.id?'is-source':''}" data-card="${c.id}"><div class="task-title" data-title="${c.id}">${escapeHtml(c.title)}</div>${c.description?`<div class="task-desc">${escapeHtml(c.description)}</div>`:''}${labels?`<div class="chips">${labels}</div>`:''}<div class="card-foot">${due}<span class="mini-avatar">${initials}</span></div></article>`}
// ── UI helpers, CRUD, and reversible destructive actions ──────────
function showToast(message,undo){const t=document.getElementById('toast');undoFn=undo||null;t.innerHTML=`<span>${escapeHtml(message)}</span>${undo?'<button id="undoBtn">Undo</button>':''}`;t.classList.add('show');clearTimeout(toastTimer);toastTimer=setTimeout(()=>t.classList.remove('show'),5000);}
function openModal(title,body,onSave,onDelete){const root=document.getElementById('modalRoot');root.innerHTML=`<div class="modal-backdrop"><form class="modal"><h2>${title}</h2>${body}<div class="modal-actions">${onDelete?'<button type="button" class="button" data-delete style="margin-right:auto;color:#c45454">Delete task</button>':''}<button type="button" class="button" data-cancel>Cancel</button><button class="button primary" type="submit">Save changes</button></div></form></div>`;const back=root.firstElementChild,form=root.querySelector('form');back.addEventListener('click',e=>{if(e.target===back)root.innerHTML=''});root.querySelector('[data-cancel]').onclick=()=>root.innerHTML='';if(onDelete)root.querySelector('[data-delete]').onclick=()=>{root.innerHTML='';onDelete()};form.onsubmit=e=>{e.preventDefault();onSave(new FormData(form));root.innerHTML='';};setTimeout(()=>root.querySelector('input,textarea')?.focus(),30)}
function editCard(id,colId){const col=model.columns.find(c=>c.id===colId),card=col.cards.find(c=>c.id===id);openModal(card?'Edit task':'New task',`<label class="field">Title</label><input class="input" name="title" required maxlength="140" value="${escapeHtml(card?.title||'')}"><label class="field">Description</label><textarea class="textarea" name="description" placeholder="Add a few details…">${escapeHtml(card?.description||'')}</textarea><label class="field">Labels</label><div class="modal-labels">${CONFIG.labels.map(l=>`<label class="label-choice ${(card?.labels||[]).includes(l.id)?'selected':''}"><input type="checkbox" name="labels" value="${l.id}" ${(card?.labels||[]).includes(l.id)?'checked':''}><i class="dot" style="background:${l.color}"></i>${l.name}</label>`).join('')}</div><label class="field">Due date</label><input class="input" type="date" name="due" value="${card?.due||''}">`,data=>{let labels=data.getAll('labels');if(card){card.title=data.get('title').trim();card.description=data.get('description').trim();card.labels=labels;card.due=data.get('due')||null}else col.cards.push({id:uid(),title:data.get('title').trim(),description:data.get('description').trim(),labels,due:data.get('due')||null});persist();render()},card?()=>deleteCard(card.id,colId):null)}
function deleteCard(cardId,colId){const snapshot=JSON.stringify(model);const col=model.columns.find(c=>c.id===colId),i=col.cards.findIndex(c=>c.id===cardId);col.cards.splice(i,1);persist();render();showToast('Task deleted',()=>{model=JSON.parse(snapshot);persist();render()})}
function deleteColumn(id){if(model.columns.length<=1){showToast('Keep at least one column');return}const snapshot=JSON.stringify(model);model.columns=model.columns.filter(c=>c.id!==id);persist();render();showToast('Column deleted',()=>{model=JSON.parse(snapshot);persist();render()})}
function openColumnMenu(id,button){closeMenu();const col=model.columns.find(c=>c.id===id);const r=button.getBoundingClientRect();menuNode=document.createElement('div');menuNode.className='menu-pop';menuNode.style.left=Math.min(r.right-190,innerWidth-200)+'px';menuNode.style.top=(r.bottom+4)+'px';menuNode.innerHTML=`<button data-action="rename">Rename column</button><button data-action="wip">Set WIP limit…</button><button data-action="delete" class="danger">Delete column</button>`;document.body.append(menuNode);menuNode.querySelector('[data-action=rename]').onclick=()=>{const next=prompt('Column name',col.title);if(next?.trim()){col.title=next.trim();persist();render()}closeMenu()};menuNode.querySelector('[data-action=wip]').onclick=()=>{const value=prompt('WIP limit for '+col.title+' (leave blank for no limit)',col.wip??'');if(value!==null){col.wip=value.trim()?Math.max(1,parseInt(value,10)||1):null;persist();render()}closeMenu()};menuNode.querySelector('[data-action=delete]').onclick=()=>{closeMenu();deleteColumn(id)};setTimeout(()=>document.addEventListener('pointerdown',outsideMenu,{once:true}),0)}
function outsideMenu(e){if(menuNode&&!menuNode.contains(e.target))closeMenu()}function closeMenu(){menuNode?.remove();menuNode=null}
function addColumn(){openModal('Add a column','<label class="field">Column name</label><input class="input" name="title" required maxlength="30" placeholder="e.g. QA">',d=>{model.columns.push({id:uid(),title:d.get('title').trim(),wip:null,cards:[]});persist();render()})}
// ── Pointer-driven drag engine (no HTML5 drag image) ───────────────
function point(e){return{x:e.clientX,y:e.clientY}}
function startDrag(e,type,el){if(e.button!==0||drag)return;const p=point(e);drag={type,el,startX:p.x,startY:p.y,x:p.x,y:p.y,active:false,raf:false};if(type==='card'){drag.cardId=el.dataset.card;drag.sourceCol=el.closest('[data-column]').dataset.column;drag.sourceIndex=model.columns.find(c=>c.id===drag.sourceCol).cards.findIndex(c=>c.id===drag.cardId);drag.targetCol=drag.sourceCol;drag.targetVisibleIndex=visibleCards(model.columns.find(c=>c.id===drag.sourceCol)).filter(c=>c.id!==drag.cardId).findIndex((c,i)=>{let cardEl=document.querySelector(`[data-card="${c.id}"]`);return cardEl&&p.y<cardEl.getBoundingClientRect().top+cardEl.getBoundingClientRect().height/2});if(drag.targetVisibleIndex<0)drag.targetVisibleIndex=visibleCards(model.columns.find(c=>c.id===drag.sourceCol)).filter(c=>c.id!==drag.cardId).length;drag.rect=el.getBoundingClientRect();drag.offsetX=p.x-drag.rect.left;drag.offsetY=p.y-drag.rect.top;drag.clone=el.cloneNode(true);drag.clone.classList.remove('is-source');drag.clone.classList.add('drag-ghost');drag.clone.style.left=drag.rect.left+'px';drag.clone.style.top=drag.rect.top+'px';document.body.append(drag.clone);drag.el.classList.add('is-source');}else{drag.columnId=el.dataset.colhead;drag.targetIndex=model.columns.findIndex(c=>c.id===drag.columnId);drag.rect=el.closest('.column').getBoundingClientRect();drag.offsetX=p.x-drag.rect.left;drag.offsetY=p.y-drag.rect.top;drag.clone=el.closest('.column').cloneNode(true);drag.clone.classList.add('column-ghost');drag.clone.style.left=drag.rect.left+'px';drag.clone.style.top=drag.rect.top+'px';document.body.append(drag.clone);el.closest('.column').classList.add('dragging-column')}
window.addEventListener('pointermove',onPointerMove,{passive:false});window.addEventListener('pointerup',onPointerUp);window.addEventListener('pointercancel',cancelDrag);window.addEventListener('keydown',onDragKey);}
function onPointerMove(e){if(!drag)return;drag.x=e.clientX;drag.y=e.clientY;if(!drag.active&&Math.hypot(drag.x-drag.startX,drag.y-drag.startY)<CONFIG.dragThreshold)return;drag.active=true;e.preventDefault();if(!drag.raf){drag.raf=true;requestAnimationFrame(processDrag)}}
function processDrag(){if(!drag)return;drag.raf=false;const d=drag;d.clone.style.left=(d.x-d.offsetX)+'px';d.clone.style.top=(d.y-d.offsetY)+'px';let keepScrolling=false;if(d.type==='card'){const shell=document.getElementById('boardShell'),sr=shell.getBoundingClientRect();if(d.x-sr.left<CONFIG.edgeZone&&shell.scrollLeft>0){shell.scrollLeft-=CONFIG.scrollSpeed;keepScrolling=true}else if(sr.right-d.x<CONFIG.edgeZone&&shell.scrollLeft<shell.scrollWidth-shell.clientWidth){shell.scrollLeft+=CONFIG.scrollSpeed;keepScrolling=true}
const cols=[...document.querySelectorAll('.column')];let target=cols.find(el=>{const r=el.getBoundingClientRect();return d.x>=r.left&&d.x<=r.right});if(!target&&cols.length)target=cols.reduce((a,b)=>Math.abs(b.getBoundingClientRect().left-d.x)<Math.abs(a.getBoundingClientRect().left-d.x)?b:a);if(target){const id=target.dataset.column,col=model.columns.find(c=>c.id===id),list=target.querySelector('.card-list'),lr=list.getBoundingClientRect();if(d.y-lr.top<CONFIG.edgeZone&&list.scrollTop>0){list.scrollTop-=CONFIG.scrollSpeed;keepScrolling=true}else if(lr.bottom-d.y<CONFIG.edgeZone&&list.scrollTop<list.scrollHeight-list.clientHeight){list.scrollTop+=CONFIG.scrollSpeed;keepScrolling=true}const candidates=visibleCards(col).filter(c=>c.id!==d.cardId);let index=candidates.length;for(let i=0;i<candidates.length;i++){const el=document.querySelector(`[data-card="${candidates[i].id}"]`);if(el){const r=el.getBoundingClientRect();if(d.y<r.top+r.height/2){index=i;break}}}if(d.targetCol!==id||d.targetVisibleIndex!==index){d.targetCol=id;d.targetVisibleIndex=index;render()}}}else{const cols=[...document.querySelectorAll('.column')].filter(c=>c.dataset.column!==d.columnId);let targetIndex=cols.length;for(let i=0;i<cols.length;i++){const r=cols[i].getBoundingClientRect();if(d.x<r.left+r.width/2){targetIndex=i;break}}if(d.targetIndex!==targetIndex){d.targetIndex=targetIndex;render()}}
if(keepScrolling&&drag===d&&!d.raf){d.raf=true;requestAnimationFrame(processDrag)}}
function refreshDragRects(){/* Layout is read once per pointer frame; writes are limited to the ghost and scroll offsets. */}
function onPointerUp(){if(!drag)return;const d=drag;if(d.active){if(d.type==='card'){const from=model.columns.find(c=>c.id===d.sourceCol),idx=from.cards.findIndex(c=>c.id===d.cardId);if(idx>=0){const [card]=from.cards.splice(idx,1);const to=model.columns.find(c=>c.id===d.targetCol);let insertion=underlyingInsertion(to,d.targetVisibleIndex,card.id);to.cards.splice(insertion,0,card)}}else{let old=model.columns.findIndex(c=>c.id===d.columnId);let [col]=model.columns.splice(old,1);let ix=Math.max(0,Math.min(model.columns.length,d.targetIndex??model.columns.length));model.columns.splice(ix,0,col)}suppressCardClickUntil=Date.now()+350}finishDrag();persist();render()}
function underlyingInsertion(col,visibleIndex,cardId){const visible=col.cards.filter(c=>c.id!==cardId&&matches(c));const n=Math.max(0,Math.min(visible.length,visibleIndex??visible.length));if(n<visible.length)return col.cards.findIndex(c=>c.id===visible[n].id);if(visible.length)return col.cards.findIndex(c=>c.id===visible[visible.length-1].id)+1;return col.cards.length}
function cancelDrag(){if(!drag)return;finishDrag();render()}
function finishDrag(){const d=drag;d?.clone?.remove();window.removeEventListener('pointermove',onPointerMove);window.removeEventListener('pointerup',onPointerUp);window.removeEventListener('pointercancel',cancelDrag);window.removeEventListener('keydown',onDragKey);drag=null}
function onDragKey(e){if(e.key==='Escape'){e.preventDefault();cancelDrag()}}
// ── Event delegation and inline title editing ─────────────────────
document.addEventListener('pointerdown',e=>{if(e.target.closest('.column-menu-btn')||e.target.closest('.add-card')||e.target.closest('.task-title')||e.target.closest('button')||e.target.closest('.empty-drop'))return;const card=e.target.closest('.task-card');if(card){startDrag(e,'card',card);return}const head=e.target.closest('.column-head');if(head&&!e.target.closest('button'))startDrag(e,'column',head)});document.addEventListener('pointermove',e=>{if(drag)e.preventDefault()},{passive:false});
document.addEventListener('click',e=>{const target=e.target.closest('[data-filter]');if(target){let id=target.dataset.filter;model.filters=model.filters.includes(id)?model.filters.filter(x=>x!==id):[...model.filters,id];persist();render();return}const menu=e.target.closest('[data-menu]');if(menu){e.stopPropagation();openColumnMenu(menu.dataset.menu,menu);return}const add=e.target.closest('[data-add]');if(add){editCard(null,add.dataset.add);return}const title=e.target.closest('[data-title]');if(title){startInlineEdit(title);return}if(e.target.id==='addColumn')addColumn();if(e.target.id==='newTaskBtn'){editCard(null,model.columns[0].id)}if(e.target.id==='clearFilters'){model.filters=[];persist();render()}if(e.target.id==='undoBtn'&&undoFn){const f=undoFn;undoFn=null;f();document.getElementById('toast').classList.remove('show')}if(Date.now()<suppressCardClickUntil)return;if(e.target.closest('.task-card')&&!e.target.closest('.task-title')){const el=e.target.closest('.task-card');editCard(el.dataset.card,el.closest('[data-column]').dataset.column)} });
function startInlineEdit(el){if(el.querySelector('input'))return;const id=el.dataset.title,card=model.columns.flatMap(c=>c.cards).find(c=>c.id===id);if(!card)return;const old=card.title,input=document.createElement('input');input.className='inline-title';input.value=old;el.replaceChildren(input);input.focus();input.select();let done=false;function finish(save){if(done)return;done=true;if(save&&input.value.trim())card.title=input.value.trim();persist();render()}input.onkeydown=e=>{if(e.key==='Enter'){e.preventDefault();finish(true)}if(e.key==='Escape'){e.preventDefault();finish(false)}};input.onblur=()=>finish(true)}
document.getElementById('searchInput').value=model.search;document.getElementById('searchInput').addEventListener('input',e=>{model.search=e.target.value;persist();render()});document.getElementById('clearFilters').addEventListener('click',()=>{model.filters=[];persist();render()});document.getElementById('filterToggle').onclick=()=>document.getElementById('filterRow').scrollIntoView({behavior:'smooth',block:'nearest'});document.getElementById('newTaskBtn').onclick=()=>editCard(null,model.columns[0].id);document.getElementById('sortBtn').onclick=()=>showToast('Drag columns and tasks to organize your board');
render();window.boardModel=model;window.kanban={get model(){return model},render,addColumn,editCard,deleteCard,deleteColumn,persist,reset(){localStorage.removeItem(STORAGE_KEY);model=seed();persist();render()}};
</script></body></html>
<!-- agent-meta {"model":"openai/gpt-6-luna","provider":"openrouter","persona":"full","sessionId":"a7dbe8af-771b-4e01-a023-493867f38e5b","tokensIn":873009,"tokensOut":25012,"tokensTotal":898021,"cost":0.025800529999999995,"turns":31,"toolCalls":30,"failedToolCalls":2,"timestamp":"2026-09-23T00:44:14.117Z"} -->