← Kanban results

Kanban

muse spark 1.2 contributor · full

Initial view of Kanban

Browser check: Artifact runtime error. This is a technical check, not a quality review.

Reviews & generation details

Reviews

No human review yet. Unreviewed does not mean unsuccessful.

Generation context

Imported historical artifact. Its exact original instructions and lineage are not known.

Generation prompt · exact text unavailable

See the current prompt for reference. It may differ from the instructions used to generate this artifact.

Inspect original source 49,773 bytes · SHA-256 752f5a217845
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Flow — Kanban Board</title>
<style>
/* ===== RESET & TOKENS ===== */
*{margin:0;padding:0;box-sizing:border-box}
:root{
  --bg:#f8f9fb;
  --bg-column:#f1f3f5;
  --card-bg:#ffffff;
  --border:#e6e8eb;
  --border-strong:#d8dbdf;
  --text:#1a1d23;
  --text-muted:#6b7280;
  --text-faint:#9ca3af;
  --accent:#6366f1;
  --accent-hover:#5457e5;
  --radius:12px;
  --radius-sm:8px;
  --shadow-sm:0 1px 2px rgba(0,0,0,.06),0 1px 3px rgba(0,0,0,.04);
  --shadow-md:0 4px 12px rgba(0,0,0,.08),0 1px 4px rgba(0,0,0,.06);
  --shadow-lg:0 12px 32px rgba(0,0,0,.14),0 4px 12px rgba(0,0,0,.08);
  --wip-red:#ef4444;
  --wip-bg:#fef2f2;
}
body{font-family:Inter, ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial; background:var(--bg); color:var(--text); overflow:hidden; height:100vh; display:flex; flex-direction:column; -webkit-font-smoothing:antialiased}
button{font:inherit; cursor:pointer}
input,textarea,select{font:inherit}

/* ===== HEADER ===== */
.app-header{
  position:sticky; top:0; z-index:30;
  background:rgba(255,255,255,.92); backdrop-filter:blur(12px);
  border-bottom:1px solid var(--border);
  padding:12px 20px; display:flex; align-items:center; gap:16px; flex-wrap:wrap;
}
.logo{display:flex; align-items:center; gap:10px; font-weight:700; font-size:18px; letter-spacing:-.02em}
.logo-mark{width:28px;height:28px; border-radius:8px; background:var(--text); color:#fff; display:grid; place-items:center; font-size:14px}
.header-actions{display:flex; align-items:center; gap:10px; flex:1; justify-content:flex-end; flex-wrap:wrap}
.search-wrap{position:relative; flex:1; max-width:320px; min-width:180px}
.search-wrap input{width:100%; padding:8px 12px 8px 34px; border:1px solid var(--border); border-radius:999px; background:#fff; font-size:13.5px; outline:none; transition:border .15s, box-shadow .15s}
.search-wrap input:focus{border-color:var(--accent); box-shadow:0 0 0 3px rgba(99,102,241,.15)}
.search-wrap svg{position:absolute; left:11px; top:50%; transform:translateY(-50%); color:var(--text-faint); pointer-events:none}
.filter-chips{display:flex; gap:6px; align-items:center; flex-wrap:wrap}
.chip{padding:5px 10px; border-radius:999px; font-size:12.5px; font-weight:500; border:1px solid transparent; cursor:pointer; transition:all .15s; display:flex; align-items:center; gap:6px; user-select:none}
.chip.active{box-shadow:0 0 0 2px #fff, 0 0 0 4px currentColor}
.chip .dot{width:8px;height:8px;border-radius:50%}
.btn{padding:8px 14px; border-radius:999px; border:1px solid var(--border); background:#fff; font-size:13px; font-weight:500; transition:all .15s; white-space:nowrap}
.btn:hover{background:#f9fafb; border-color:var(--border-strong)}
.btn-primary{background:var(--text); color:#fff; border-color:var(--text)}
.btn-primary:hover{background:#2a2e38}
.btn-ghost{border-color:transparent; background:transparent}
.btn-ghost:hover{background:#f1f3f5}

/* ===== BOARD ===== */
.board-wrap{flex:1; overflow:auto; padding:20px; scroll-behavior:auto; scrollbar-width:thin}
.board{display:flex; gap:16px; align-items:flex-start; min-height:100%; padding-bottom:40px}
.column{
  width:296px; min-width:296px; background:var(--bg-column); border-radius:16px; border:1px solid var(--border);
  display:flex; flex-direction:column; max-height:calc(100vh - 96px); transition:border-color .2s, background .2s;
}
.column.wip-exceeded{border-color:var(--wip-red); background:var(--wip-bg); box-shadow:0 0 0 1px rgba(239,68,68,.15) inset}
.column.dragging-col{opacity:.4; transform:rotate(1deg)}
.column-header{
  padding:14px 12px 10px; display:flex; align-items:center; gap:8px; cursor:grab; user-select:none; position:relative;
  border-radius:16px 16px 0 0;
}
.column-header:active{cursor:grabbing}
.col-title{font-weight:600; font-size:13.5px; flex:1; cursor:text; padding:4px 6px; border-radius:6px; border:1px solid transparent; transition:all .15s; white-space:nowrap; overflow:hidden; text-overflow:ellipsis}
.col-title:hover{background:rgba(0,0,0,.04)}
.col-title[contenteditable="true"]{background:#fff; border-color:var(--accent); box-shadow:0 0 0 3px rgba(99,102,241,.12); outline:none; cursor:text}
.col-count{font-size:12px; font-weight:600; color:var(--text-muted); background:#fff; border:1px solid var(--border); padding:2px 7px; border-radius:999px; min-width:22px; text-align:center}
.col-count.over{color:#fff; background:var(--wip-red); border-color:var(--wip-red)}
.col-menu-btn{width:28px;height:28px; border-radius:8px; border:none; background:transparent; display:grid; place-items:center; color:var(--text-faint); transition:all .15s}
.col-menu-btn:hover{background:#fff; color:var(--text); box-shadow:var(--shadow-sm)}
.col-cards{flex:1; overflow-y:auto; overflow-x:hidden; padding:2px 8px 8px; display:flex; flex-direction:column; gap:10px; min-height:60px; scrollbar-width:thin; scroll-behavior:auto}
.col-cards::-webkit-scrollbar{width:6px}
.col-cards::-webkit-scrollbar-thumb{background:#d1d5db; border-radius:999px}
.col-footer{padding:8px; border-top:1px solid transparent}
.btn-add-card{width:100%; padding:9px; border-radius:10px; border:1px dashed transparent; background:transparent; color:var(--text-muted); font-size:13px; font-weight:500; display:flex; align-items:center; gap:6px; justify-content:center; transition:all .15s}
.btn-add-card:hover{background:#fff; border-color:var(--border); color:var(--text); box-shadow:var(--shadow-sm)}
.empty-drop{border:2px dashed #d1d5db; border-radius:12px; padding:28px 16px; text-align:center; color:var(--text-faint); font-size:13px; display:flex; flex-direction:column; align-items:center; gap:8px; background:rgba(255,255,255,.6); transition:all .2s}
.empty-drop.drag-over{border-color:var(--accent); background:rgba(99,102,241,.06); color:var(--accent)}
.empty-drop svg{opacity:.5}

/* ===== CARDS ===== */
.card{
  background:var(--card-bg); border:1px solid var(--border); border-radius:12px; padding:12px 12px 10px;
  box-shadow:var(--shadow-sm); cursor:grab; user-select:none; position:relative; transition:box-shadow .15s, transform .15s, border-color .15s;
}
.card:hover{box-shadow:var(--shadow-md); transform:translateY(-1px); border-color:var(--border-strong)}
.card.dragging{opacity:.25}
.card.hidden-by-filter{display:none !important}
.card-labels{display:flex; flex-wrap:wrap; gap:5px; margin-bottom:8px}
.label-chip{font-size:11px; font-weight:600; padding:2px 7px; border-radius:999px; letter-spacing:.01em; line-height:1.6}
.card-title{font-size:13.5px; font-weight:500; line-height:1.4; margin-bottom:4px; word-break:break-word; cursor:text}
.card-title[contenteditable="true"]{outline:2px solid var(--accent); border-radius:4px; padding:2px 4px; cursor:text; background:#fff}
.card-desc{font-size:12.5px; color:var(--text-muted); line-height:1.5; display:-webkit-box; -webkit-line-clamp:2; -webkit-box-orient:vertical; overflow:hidden; word-break:break-word}
.card-footer{display:flex; align-items:center; gap:8px; margin-top:10px; flex-wrap:wrap}
.card-due{font-size:11.5px; padding:3px 7px; border-radius:999px; background:#f3f4f6; color:var(--text-muted); display:flex; align-items:center; gap:4px; font-weight:500}
.card-due.overdue{background:#fef2f2; color:#dc2626}
.card-due.soon{background:#fffbeb; color:#d97706}
.card-actions{margin-left:auto; display:flex; gap:4px; opacity:0; transition:opacity .15s}
.card:hover .card-actions{opacity:1}
.icon-btn{width:24px;height:24px; border-radius:6px; border:none; background:transparent; display:grid; place-items:center; color:var(--text-faint); transition:all .15s}
.icon-btn:hover{background:#f3f4f6; color:var(--text)}

/* ===== DRAG FLOAT & PLACEHOLDER ===== */
.drag-ghost{
  position:fixed; pointer-events:none; z-index:9999; width:296px; transform:rotate(2deg) scale(1.02);
  box-shadow:var(--shadow-lg); border-radius:12px; opacity:.96; will-change:transform, left, top;
}
.placeholder{
  background:rgba(99,102,241,.08); border:2px dashed rgba(99,102,241,.35); border-radius:12px; min-height:72px; transition:height .12s; flex-shrink:0;
}
.col-placeholder{
  width:296px; min-width:296px; background:rgba(99,102,241,.06); border:2px dashed rgba(99,102,241,.3); border-radius:16px; min-height:200px; flex-shrink:0;
}

/* ===== MODALS ===== */
.overlay{position:fixed; inset:0; background:rgba(15,23,42,.45); backdrop-filter:blur(4px); z-index:50; display:none; align-items:center; justify-content:center; padding:20px}
.overlay.open{display:flex}
.modal{background:#fff; border-radius:16px; box-shadow:var(--shadow-lg); width:100%; max-width:480px; max-height:90vh; overflow:auto; animation:pop .18s ease}
@keyframes pop{from{transform:scale(.96) translateY(6px); opacity:0} to{transform:scale(1) translateY(0); opacity:1}}
.modal-head{padding:18px 20px 0; display:flex; align-items:center; justify-content:space-between}
.modal-head h3{font-size:15px; font-weight:650}
.modal-body{padding:16px 20px 20px; display:flex; flex-direction:column; gap:12px}
.field label{font-size:12.5px; font-weight:600; color:var(--text-muted); display:block; margin-bottom:6px}
.field input, .field textarea, .field select{width:100%; padding:9px 12px; border:1px solid var(--border); border-radius:10px; font-size:13.5px; outline:none; transition:border .15s, box-shadow .15s; background:#fff}
.field input:focus, .field textarea:focus, .field select:focus{border-color:var(--accent); box-shadow:0 0 0 3px rgba(99,102,241,.12)}
.field textarea{resize:vertical; min-height:72px}
.label-grid{display:flex; flex-wrap:wrap; gap:8px}
.label-opt{padding:6px 10px; border-radius:999px; font-size:12.5px; font-weight:600; cursor:pointer; border:2px solid transparent; transition:all .15s; user-select:none}
.label-opt.selected{box-shadow:0 0 0 2px #fff, 0 0 0 4px var(--text)}
.modal-actions{display:flex; gap:8px; justify-content:flex-end; margin-top:4px}

/* ===== COLUMN MENU ===== */
.col-menu{position:absolute; top:42px; right:8px; background:#fff; border:1px solid var(--border); border-radius:12px; box-shadow:var(--shadow-lg); padding:6px; width:200px; z-index:20; display:none}
.col-menu.open{display:block}
.menu-item{padding:8px 10px; border-radius:8px; font-size:13px; display:flex; align-items:center; gap:8px; cursor:pointer; transition:background .12s; border:none; background:transparent; width:100%; text-align:left; color:var(--text)}
.menu-item:hover{background:#f3f4f6}
.menu-item.danger{color:#dc2626}
.menu-item.danger:hover{background:#fef2f2}
.menu-sep{height:1px; background:var(--border); margin:6px 0}
.wip-row{display:flex; align-items:center; gap:8px; padding:6px 10px}
.wip-row input{width:64px; padding:6px 8px; border:1px solid var(--border); border-radius:8px; font-size:13px}

/* ===== TOAST ===== */
.toast-wrap{position:fixed; bottom:20px; left:50%; transform:translateX(-50%); z-index:60; display:flex; flex-direction:column; gap:8px; align-items:center; pointer-events:none}
.toast{pointer-events:auto; background:#1a1d23; color:#fff; padding:10px 14px; border-radius:999px; font-size:13px; display:flex; align-items:center; gap:12px; box-shadow:var(--shadow-lg); animation:slideUp .22s ease}
@keyframes slideUp{from{transform:translateY(10px); opacity:0} to{transform:translateY(0); opacity:1}}
.toast button{background:#fff; color:#1a1d23; border:none; padding:5px 12px; border-radius:999px; font-weight:600; font-size:12.5px}
.toast button:hover{background:#f3f4f6}

/* ===== SCROLLBAR ===== */
.board-wrap::-webkit-scrollbar{height:8px}
.board-wrap::-webkit-scrollbar-thumb{background:#d1d5db; border-radius:999px}

/* ===== INLINE EDIT INPUT ===== */
.inline-input{width:100%; padding:6px 8px; border:1px solid var(--accent); border-radius:8px; font-size:13.5px; font-weight:500; outline:none; box-shadow:0 0 0 3px rgba(99,102,241,.12)}

@media(max-width:640px){
  .board{padding-bottom:80px}
  .app-header{padding:10px 12px}
}
</style>
</head>
<body>

<header class="app-header">
  <div class="logo"><div class="logo-mark">◈</div> Flow</div>
  <div class="search-wrap">
    <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="7"/><path d="M20 20L16 16"/></svg>
    <input id="searchInput" placeholder="Search cards..." autocomplete="off">
  </div>
  <div class="filter-chips" id="filterChips"></div>
  <div class="header-actions">
    <button class="btn btn-ghost" id="clearFiltersBtn" style="display:none">Clear filters</button>
    <button class="btn" id="manageLabelsBtn">Labels</button>
    <button class="btn btn-primary" id="addColumnBtn">+ Add column</button>
  </div>
</header>

<div class="board-wrap" id="boardWrap">
  <div class="board" id="board"></div>
</div>

<!-- Card Modal -->
<div class="overlay" id="cardModal">
  <div class="modal">
    <div class="modal-head"><h3 id="cardModalTitle">Edit card</h3><button class="icon-btn" id="closeCardModal">✕</button></div>
    <div class="modal-body">
      <div class="field"><label>Title</label><input id="mTitle" placeholder="Card title"></div>
      <div class="field"><label>Description</label><textarea id="mDesc" placeholder="Add a description..."></textarea></div>
      <div class="field"><label>Due date</label><input id="mDue" type="date"></div>
      <div class="field"><label>Labels</label><div class="label-grid" id="mLabels"></div></div>
      <div class="modal-actions">
        <button class="btn" id="deleteCardBtn" style="color:#dc2626; border-color:#fecaca">Delete card</button>
        <div style="flex:1"></div>
        <button class="btn" id="cancelCardBtn">Cancel</button>
        <button class="btn btn-primary" id="saveCardBtn">Save</button>
      </div>
    </div>
  </div>
</div>

<!-- Label Manager Modal -->
<div class="overlay" id="labelModal">
  <div class="modal" style="max-width:420px">
    <div class="modal-head"><h3>Manage labels</h3><button class="icon-btn" id="closeLabelModal">✕</button></div>
    <div class="modal-body" id="labelManagerBody"></div>
  </div>
</div>

<div class="toast-wrap" id="toastWrap"></div>

<script>
/* ============================================================
   TUNABLE CONSTANTS
   ============================================================ */
const CONFIG = {
  STORAGE_KEY: 'flow-kanban-v2',
  DRAG_THRESHOLD: 5,          // px before drag starts
  AUTO_SCROLL_MARGIN: 64,     // px from edge to trigger scroll
  AUTO_SCROLL_SPEED: 14,      // px per frame
  GHOST_TILT: 2,              // degrees
  LABEL_PALETTE: [
    { id:'l1', name:'Feature', color:'#e0e7ff', text:'#4338ca', dot:'#6366f1' },
    { id:'l2', name:'Bug', color:'#fee2e2', text:'#b91c1c', dot:'#ef4444' },
    { id:'l3', name:'Design', color:'#fce7f3', text:'#be185d', dot:'#ec4899' },
    { id:'l4', name:'Research', color:'#fef3c7', text:'#92400e', dot:'#f59e0b' },
    { id:'l5', name:'Docs', color:'#d1fae5', text:'#065f46', dot:'#10b981' },
    { id:'l6', name:'Urgent', color:'#ffedd5', text:'#9a3412', dot:'#f97316' },
  ]
};

/* ============================================================
   STATE — single source of truth
   ============================================================ */
let board = null;
let filterState = { search:'', labelIds:[] };
let editingCard = null; // {colId, cardId}
let undoStack = [];
let nextId = 1;

function uid(){ return 'id_' + (nextId++) + '_' + Math.random().toString(36).slice(2,6); }

function defaultBoard(){
  return {
    columns:[
      { id:uid(), title:'Backlog', wipLimit:null, cards:[
        { id:uid(), title:'Design new landing page', description:'Explore hero variations and CTA placement', labels:['l3'], dueDate:'' },
        { id:uid(), title:'Research competitor pricing', description:'Compare 5 competitors, build table', labels:['l4'], dueDate:'' },
      ]},
      { id:uid(), title:'To Do', wipLimit:null, cards:[
        { id:uid(), title:'Implement auth flow', description:'OAuth + email/password', labels:['l1'], dueDate:'' },
        { id:uid(), title:'Fix checkout bug', description:'Coupon code not applying on mobile', labels:['l2','l6'], dueDate:'' },
      ]},
      { id:uid(), title:'In Progress', wipLimit:3, cards:[
        { id:uid(), title:'Build kanban board', description:'Flawless drag and drop required', labels:['l1','l3'], dueDate:'' },
      ]},
      { id:uid(), title:'Review', wipLimit:null, cards:[]},
      { id:uid(), title:'Done', wipLimit:null, cards:[
        { id:uid(), title:'Setup project repo', description:'Init with README and CI', labels:['l5'], dueDate:'' },
      ]},
    ]
  };
}

function save(){
  const payload = { board, nextId, filterState };
  localStorage.setItem(CONFIG.STORAGE_KEY, JSON.stringify(payload));
}
function load(){
  try{
    const raw = localStorage.getItem(CONFIG.STORAGE_KEY);
    if(!raw) return null;
    const p = JSON.parse(raw);
    if(p.board && p.board.columns) return p;
  }catch(e){}
  return null;
}
function applyBoardState(newBoard, newNextId){
  // keep window.__board reference stable for probes
  if(board && board.columns){
    board.columns.length=0;
    newBoard.columns.forEach(c=> board.columns.push(c));
  } else {
    board = newBoard;
  }
  nextId = newNextId;
  window.__board = board;
}
function initState(){
  const saved = load();
  if(saved){
    board = saved.board;
    nextId = saved.nextId || 100;
    filterState = saved.filterState || { search:'', labelIds:[] };
    window.__board = board;
  } else {
    board = defaultBoard();
    window.__board = board;
    save();
  }
}

/* ============================================================
   RENDERING
   ============================================================ */
const boardEl = document.getElementById('board');
const boardWrap = document.getElementById('boardWrap');
const filterChipsEl = document.getElementById('filterChips');
const searchInput = document.getElementById('searchInput');

function labelById(id){ return CONFIG.LABEL_PALETTE.find(l=>l.id===id); }

function cardMatchesFilter(card){
  if(filterState.search){
    const q = filterState.search.toLowerCase();
    if(!card.title.toLowerCase().includes(q) && !(card.description||'').toLowerCase().includes(q)) return false;
  }
  if(filterState.labelIds.length){
    if(!card.labels.some(l=> filterState.labelIds.includes(l))) return false;
  }
  return true;
}

function render(){
  // filter chips
  filterChipsEl.innerHTML = '';
  CONFIG.LABEL_PALETTE.forEach(l=>{
    const b = document.createElement('button');
    b.className = 'chip' + (filterState.labelIds.includes(l.id)?' active':'');
    b.style.background = l.color; b.style.color = l.text; b.style.borderColor = l.color;
    b.innerHTML = `<span class="dot" style="background:${l.dot}"></span>${l.name}`;
    b.onclick = ()=>{ toggleLabelFilter(l.id); };
    filterChipsEl.appendChild(b);
  });
  document.getElementById('clearFiltersBtn').style.display = (filterState.search || filterState.labelIds.length) ? '' : 'none';
  searchInput.value = filterState.search;

  // board
  boardEl.innerHTML = '';
  board.columns.forEach((col, colIdx)=>{
    const colEl = document.createElement('div');
    colEl.className = 'column';
    colEl.dataset.colId = col.id;
    const overWip = col.wipLimit!=null && col.cards.length > col.wipLimit;
    if(overWip) colEl.classList.add('wip-exceeded');

    // header
    const header = document.createElement('div');
    header.className = 'column-header';
    header.dataset.dragHandle = 'col';
    header.innerHTML = `
      <div class="col-title" data-col-title tabindex="0">${escapeHtml(col.title)}</div>
      <span class="col-count ${overWip?'over':''}">${col.cards.length}${col.wipLimit!=null? ' / '+col.wipLimit:''}</span>
      <button class="col-menu-btn" data-col-menu-btn>⋯</button>
      <div class="col-menu" data-col-menu>
        <div class="wip-row"><span style="font-size:13px; flex:1">WIP limit</span><input type="number" min="0" placeholder="∞" data-wip-input value="${col.wipLimit??''}"></div>
        <div class="menu-sep"></div>
        <button class="menu-item danger" data-delete-col>Delete column</button>
      </div>
    `;
    // inline edit column title
    const titleEl = header.querySelector('[data-col-title]');
    titleEl.addEventListener('click', (e)=>{
      e.stopPropagation();
      startInlineEdit(titleEl, col.title, (newVal)=>{
        if(newVal.trim() && newVal!==col.title){
          pushUndo();
          col.title = newVal.trim();
          save(); render();
        }
      });
    });
    // menu toggle
    const menuBtn = header.querySelector('[data-col-menu-btn]');
    const menu = header.querySelector('[data-col-menu]');
    menuBtn.addEventListener('click', (e)=>{
      e.stopPropagation();
      document.querySelectorAll('.col-menu.open').forEach(m=>{ if(m!==menu) m.classList.remove('open'); });
      menu.classList.toggle('open');
    });
    const wipInput = header.querySelector('[data-wip-input]');
    wipInput.addEventListener('change', ()=>{
      const v = wipInput.value.trim()===''? null : Math.max(0, parseInt(wipInput.value,10)||0);
      pushUndo();
      col.wipLimit = v;
      save(); render();
    });
    wipInput.addEventListener('click', e=>e.stopPropagation());
    header.querySelector('[data-delete-col]').addEventListener('click', (e)=>{
      e.stopPropagation();
      if(board.columns.length<=1){ showToast('Keep at least one column'); return; }
      pushUndo();
      const idx = board.columns.findIndex(c=>c.id===col.id);
      const removed = board.columns.splice(idx,1)[0];
      save(); render();
      showUndo(`Deleted column "${removed.title}"`, ()=>{
        board.columns.splice(idx,0,removed); save(); render();
      });
      menu.classList.remove('open');
    });

    colEl.appendChild(header);

    // cards container
    const list = document.createElement('div');
    list.className = 'col-cards';
    list.dataset.colCards = col.id;

    const visibleCards = col.cards.filter(cardMatchesFilter);
    const hasHidden = visibleCards.length !== col.cards.length;

    if(col.cards.length===0){
      const empty = document.createElement('div');
      empty.className = 'empty-drop';
      empty.innerHTML = `<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7"><rect x="3" y="3" width="18" height="18" rx="3"/><path d="M12 8v8M8 12h8"/></svg> Drop cards here`;
      list.appendChild(empty);
    } else {
      col.cards.forEach(card=>{
        const hidden = !cardMatchesFilter(card);
        const cardEl = createCardEl(card, col.id, hidden);
        list.appendChild(cardEl);
      });
      if(visibleCards.length===0 && hasHidden){
        const msg = document.createElement('div');
        msg.style.cssText='text-align:center; padding:16px; color:var(--text-faint); font-size:12.5px';
        msg.textContent='No cards match filter';
        list.appendChild(msg);
      }
    }
    colEl.appendChild(list);

    // footer
    const footer = document.createElement('div');
    footer.className='col-footer';
    footer.innerHTML = `<button class="btn-add-card" data-add-card>+ Add card</button>`;
    footer.querySelector('[data-add-card]').addEventListener('click', ()=> openCardModal(null, col.id));
    colEl.appendChild(footer);

    boardEl.appendChild(colEl);
  });

  // close menus on outside click
  // (handled globally)
}

function createCardEl(card, colId, hiddenByFilter){
  const el = document.createElement('div');
  el.className = 'card' + (hiddenByFilter?' hidden-by-filter':'');
  el.dataset.cardId = card.id;
  el.dataset.colId = colId;
  const labelsHtml = (card.labels||[]).map(lid=>{
    const l = labelById(lid); if(!l) return '';
    return `<span class="label-chip" style="background:${l.color}; color:${l.text}">${escapeHtml(l.name)}</span>`;
  }).join('');
  let dueHtml='';
  if(card.dueDate){
    const d = new Date(card.dueDate+'T12:00:00');
    const now = new Date(); now.setHours(0,0,0,0);
    const diff = Math.ceil((d - now)/86400000);
    let cls=''; let txt = formatDate(card.dueDate);
    if(diff<0) cls='overdue';
    else if(diff<=2) cls='soon';
    dueHtml = `<span class="card-due ${cls}">◷ ${escapeHtml(txt)}</span>`;
  }
  el.innerHTML = `
    ${labelsHtml? `<div class="card-labels">${labelsHtml}</div>`:''}
    <div class="card-title" data-card-title>${escapeHtml(card.title)}</div>
    ${card.description? `<div class="card-desc">${escapeHtml(card.description)}</div>`:''}
    <div class="card-footer">
      ${dueHtml}
      <div class="card-actions">
        <button class="icon-btn" data-edit-card title="Edit">✎</button>
        <button class="icon-btn" data-delete-card title="Delete">✕</button>
      </div>
    </div>
  `;
  // inline title edit
  const titleEl = el.querySelector('[data-card-title]');
  titleEl.addEventListener('click', (e)=>{
    e.stopPropagation();
    startInlineEdit(titleEl, card.title, (newVal)=>{
      if(newVal.trim() && newVal!==card.title){
        pushUndo();
        card.title = newVal.trim();
        save(); render();
      }
    });
  });
  el.querySelector('[data-edit-card]').addEventListener('click', (e)=>{ e.stopPropagation(); openCardModal(card.id, colId); });
  el.querySelector('[data-delete-card]').addEventListener('click', (e)=>{
    e.stopPropagation();
    pushUndo();
    const col = board.columns.find(c=>c.id===colId);
    const idx = col.cards.findIndex(c=>c.id===card.id);
    const removed = col.cards.splice(idx,1)[0];
    save(); render();
    showUndo(`Deleted "${removed.title}"`, ()=>{
      col.cards.splice(idx,0,removed); save(); render();
    });
  });
  // pointer down for drag
  el.addEventListener('pointerdown', onCardPointerDown);
  return el;
}

function escapeHtml(s){ return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;'); }
function formatDate(iso){
  try{ const d=new Date(iso+'T12:00:00'); return d.toLocaleDateString(undefined,{month:'short', day:'numeric'}); }catch{ return iso; }
}

function startInlineEdit(el, original, onCommit){
  el.setAttribute('contenteditable','true');
  el.focus();
  // select all
  const range = document.createRange(); range.selectNodeContents(el); const sel=window.getSelection(); sel.removeAllRanges(); sel.addRange(range);
  let committed=false;
  const finish = (commit)=>{
    if(committed) return; committed=true;
    el.removeAttribute('contenteditable');
    el.removeEventListener('keydown', onKey);
    el.removeEventListener('blur', onBlur);
    window.getSelection().removeAllRanges();
    if(commit) onCommit(el.textContent);
    else el.textContent = original;
  };
  const onKey = (e)=>{
    if(e.key==='Enter'){ e.preventDefault(); finish(true); }
    else if(e.key==='Escape'){ e.preventDefault(); finish(false); }
  };
  const onBlur = ()=> finish(true);
  el.addEventListener('keydown', onKey);
  el.addEventListener('blur', onBlur);
}

/* ============================================================
   FILTER / SEARCH
   ============================================================ */
function toggleLabelFilter(id){
  const idx = filterState.labelIds.indexOf(id);
  if(idx>=0) filterState.labelIds.splice(idx,1);
  else filterState.labelIds.push(id);
  save(); render();
}
searchInput.addEventListener('input', ()=>{
  filterState.search = searchInput.value;
  save(); render();
});
document.getElementById('clearFiltersBtn').addEventListener('click', ()=>{
  filterState.search=''; filterState.labelIds=[];
  save(); render();
});

/* ============================================================
   CARD MODAL
   ============================================================ */
const cardModal = document.getElementById('cardModal');
const mTitle = document.getElementById('mTitle');
const mDesc = document.getElementById('mDesc');
const mDue = document.getElementById('mDue');
const mLabelsEl = document.getElementById('mLabels');
let modalColId = null, modalCardId = null;

function openCardModal(cardId, colId){
  modalCardId = cardId; modalColId = colId;
  const isNew = !cardId;
  document.getElementById('cardModalTitle').textContent = isNew? 'Add card' : 'Edit card';
  document.getElementById('deleteCardBtn').style.display = isNew? 'none':'';
  let card = null;
  if(cardId){
    const col = board.columns.find(c=>c.id===colId);
    card = col.cards.find(c=>c.id===cardId);
  }
  mTitle.value = card? card.title : '';
  mDesc.value = card? card.description : '';
  mDue.value = card? card.dueDate : '';
  // labels
  mLabelsEl.innerHTML='';
  const selected = new Set(card? card.labels: []);
  CONFIG.LABEL_PALETTE.forEach(l=>{
    const b=document.createElement('button');
    b.className='label-opt' + (selected.has(l.id)?' selected':'');
    b.style.background=l.color; b.style.color=l.text;
    b.textContent=l.name;
    b.onclick=()=>{
      if(selected.has(l.id)) selected.delete(l.id); else selected.add(l.id);
      b.classList.toggle('selected');
    };
    b.dataset.lid=l.id;
    mLabelsEl.appendChild(b);
  });
  // store selected set on element
  mLabelsEl._selected = selected;
  cardModal.classList.add('open');
  setTimeout(()=>mTitle.focus(),30);
}
function closeCardModal(){ cardModal.classList.remove('open'); }
document.getElementById('closeCardModal').onclick=closeCardModal;
document.getElementById('cancelCardBtn').onclick=closeCardModal;
cardModal.addEventListener('click', e=>{ if(e.target===cardModal) closeCardModal(); });
document.getElementById('saveCardBtn').onclick=()=>{
  const title = mTitle.value.trim();
  if(!title){ mTitle.focus(); mTitle.style.borderColor='var(--wip-red)'; return; }
  mTitle.style.borderColor='';
  const labels = [...mLabelsEl._selected];
  if(modalCardId){
    const col = board.columns.find(c=>c.id===modalColId);
    const card = col.cards.find(c=>c.id===modalCardId);
    pushUndo();
    card.title=title; card.description=mDesc.value.trim(); card.dueDate=mDue.value; card.labels=labels;
  } else {
    pushUndo();
    const col = board.columns.find(c=>c.id===modalColId);
    col.cards.push({ id:uid(), title, description:mDesc.value.trim(), dueDate:mDue.value, labels });
  }
  save(); render(); closeCardModal();
};
document.getElementById('deleteCardBtn').onclick=()=>{
  if(!modalCardId) return;
  const col = board.columns.find(c=>c.id===modalColId);
  const idx = col.cards.findIndex(c=>c.id===modalCardId);
  pushUndo();
  const removed = col.cards.splice(idx,1)[0];
  save(); render(); closeCardModal();
  showUndo(`Deleted "${removed.title}"`, ()=>{
    col.cards.splice(idx,0,removed); save(); render();
  });
};

/* ============================================================
   LABEL MANAGER
   ============================================================ */
const labelModal = document.getElementById('labelModal');
document.getElementById('manageLabelsBtn').onclick=()=>{ renderLabelManager(); labelModal.classList.add('open'); };
document.getElementById('closeLabelModal').onclick=()=>labelModal.classList.remove('open');
labelModal.addEventListener('click', e=>{ if(e.target===labelModal) labelModal.classList.remove('open'); });
function renderLabelManager(){
  const body = document.getElementById('labelManagerBody');
  body.innerHTML='';
  CONFIG.LABEL_PALETTE.forEach(l=>{
    const row=document.createElement('div');
    row.style.cssText='display:flex; align-items:center; gap:10px; padding:6px 0';
    row.innerHTML=`<span style="width:14px;height:14px;border-radius:50%;background:${l.dot};flex-shrink:0"></span>
      <span style="flex:1; font-size:13.5px; font-weight:500">${escapeHtml(l.name)}</span>
      <span class="label-chip" style="background:${l.color}; color:${l.text}">${escapeHtml(l.name)}</span>`;
    body.appendChild(row);
  });
  const hint=document.createElement('div');
  hint.style.cssText='margin-top:8px; font-size:12.5px; color:var(--text-muted); line-height:1.5';
  hint.textContent='Labels are used to categorize cards. Click chips in the filter bar to filter, or assign them when editing a card.';
  body.appendChild(hint);
}

/* ============================================================
   ADD COLUMN
   ============================================================ */
document.getElementById('addColumnBtn').onclick=()=>{
  const title = prompt('Column name:', 'New column');
  if(title===null) return;
  const t = title.trim() || 'Untitled';
  pushUndo();
  board.columns.push({ id:uid(), title:t, wipLimit:null, cards:[] });
  save(); render();
  setTimeout(()=>{ boardWrap.scrollLeft = boardWrap.scrollWidth; }, 50);
};

/* ============================================================
   UNDO / TOAST
   ============================================================ */
function pushUndo(){
  undoStack.push(JSON.stringify({board: structuredClone(board), nextId}));
  if(undoStack.length>20) undoStack.shift();
}
function showToast(msg, withUndo){
  const wrap=document.getElementById('toastWrap');
  const t=document.createElement('div');
  t.className='toast';
  t.innerHTML=`<span>${escapeHtml(msg)}</span>` + (withUndo? `<button data-undo>Undo</button>`:'');
  wrap.appendChild(t);
  let timer = setTimeout(()=> t.remove(), 4000);
  if(withUndo){
    t.querySelector('[data-undo]').onclick=()=>{
      clearTimeout(timer);
      t.remove();
      // restore from undoStack pop
      const prev = undoStack.pop();
      if(prev){
        const p = JSON.parse(prev);
        applyBoardState(p.board, p.nextId);
        save(); render();
      } else if(typeof withUndo==='function'){
        withUndo();
      }
    };
  }
}
function showUndo(msg, fn){
  const wrap=document.getElementById('toastWrap');
  const t=document.createElement('div');
  t.className='toast';
  t.innerHTML=`<span>${escapeHtml(msg)}</span><button>Undo</button>`;
  wrap.appendChild(t);
  let done=false;
  const timer=setTimeout(()=>{ if(!done) t.remove(); }, 5000);
  t.querySelector('button').onclick=()=>{
    done=true; clearTimeout(timer); t.remove(); fn();
  };
}

/* ============================================================
   DRAG & DROP — Pointer Events
   ============================================================ */
let drag = null; // {type:'card'|'col', ...}
let autoScrollRAF = null;

function onCardPointerDown(e){
  if(e.button!==0) return;
  // ignore clicks on buttons inside card
  if(e.target.closest('button')) return;
  const cardEl = e.currentTarget;
  const colId = cardEl.dataset.colId;
  const cardId = cardEl.dataset.cardId;
  // capture start position
  const startX = e.clientX, startY = e.clientY;
  let hasMoved = false;
  let pointerId = e.pointerId;

  const onMove = (ev)=>{
    const dx = ev.clientX - startX, dy = ev.clientY - startY;
    if(!hasMoved && Math.hypot(dx,dy) < CONFIG.DRAG_THRESHOLD) return;
    if(!hasMoved){
      hasMoved=true;
      startCardDrag(cardEl, colId, cardId, ev);
    }
    if(drag) updateCardDrag(ev);
  };
  const onUp = (ev)=>{
    window.removeEventListener('pointermove', onMove);
    window.removeEventListener('pointerup', onUp);
    window.removeEventListener('pointercancel', onUp);
    if(drag) endCardDrag(ev);
  };
  window.addEventListener('pointermove', onMove);
  window.addEventListener('pointerup', onUp);
  window.addEventListener('pointercancel', onUp);
  // prevent text selection
  e.preventDefault();
}

function startCardDrag(cardEl, colId, cardId, e){
  const col = board.columns.find(c=>c.id===colId);
  const cardIdx = col.cards.findIndex(c=>c.id===cardId);
  const card = col.cards[cardIdx];

  // snapshot for cancel
  const snapshot = JSON.stringify({board: structuredClone(board), nextId});

  // create placeholder
  const placeholder = document.createElement('div');
  placeholder.className='placeholder';
  const rect = cardEl.getBoundingClientRect();
  placeholder.style.height = rect.height + 'px';

  // create ghost
  const ghost = cardEl.cloneNode(true);
  ghost.classList.add('drag-ghost');
  ghost.style.width = rect.width + 'px';
  ghost.style.left = rect.left + 'px';
  ghost.style.top = rect.top + 'px';
  ghost.style.transform = `rotate(${CONFIG.GHOST_TILT}deg) scale(1.02)`;
  document.body.appendChild(ghost);

  // hide original, insert placeholder
  cardEl.classList.add('dragging');
  cardEl.parentNode.insertBefore(placeholder, cardEl);

  drag = {
    type:'card',
    cardId, colId, cardIdx,
    card, snapshot,
    ghost, placeholder, originEl: cardEl,
    offsetX: e.clientX - rect.left,
    offsetY: e.clientY - rect.top,
    currentColId: colId,
    currentIdx: cardIdx,
    pointerX: e.clientX, pointerY: e.clientY
  };
  document.body.style.userSelect='none';
  document.body.style.cursor='grabbing';
  startAutoScroll();
  // escape handler
  window.addEventListener('keydown', onDragKey);
}

function updateCardDrag(e){
  if(!drag || drag.type!=='card') return;
  drag.pointerX = e.clientX; drag.pointerY = e.clientY;
  // move ghost
  drag.ghost.style.left = (e.clientX - drag.offsetX) + 'px';
  drag.ghost.style.top = (e.clientY - drag.offsetY) + 'px';

  // hit test columns
  const cols = [...document.querySelectorAll('.column')];
  let targetColId = null, targetColEl=null;
  for(const c of cols){
    const r=c.getBoundingClientRect();
    if(e.clientX >= r.left && e.clientX <= r.right && e.clientY >= r.top && e.clientY <= r.bottom){
      targetColId = c.dataset.colId; targetColEl=c; break;
    }
  }
  // if outside columns, find nearest column by X
  if(!targetColId){
    // find closest column horizontally
    let best=null, bestDist=Infinity;
    for(const c of cols){
      const r=c.getBoundingClientRect();
      const cx = r.left + r.width/2;
      const d = Math.abs(e.clientX - cx);
      if(d<bestDist){ bestDist=d; best=c; }
    }
    if(best){ targetColId=best.dataset.colId; targetColEl=best; }
  }
  if(!targetColId) return;

  const colCardsEl = targetColEl.querySelector('.col-cards');
  // hide ghost temporarily for elementFromPoint alternative? we use manual Y check
  // collect card elements excluding dragging one and placeholder
  const cardEls = [...colCardsEl.querySelectorAll('.card:not(.dragging)')];
  // also placeholder is not a card
  let insertIdx = cardEls.length; // default append
  // Find insertion point by Y
  for(let i=0;i<cardEls.length;i++){
    const r = cardEls[i].getBoundingClientRect();
    const mid = r.top + r.height/2;
    if(e.clientY < mid){ insertIdx = i; break; }
  }
  // Adjust for hidden-by-filter? We treat hidden cards as not in DOM flow? They are display:none, so they don't affect layout.
  // But model index must account for them. We need to map visual index to model index.
  // Approach: compute model insertion index by counting visible cards up to insertIdx and mapping.
  const targetCol = board.columns.find(c=>c.id===targetColId);
  // Build list of visible card ids in order
  // For hit testing we used visible DOM order; to get model index, find the card at visual insert position
  let modelInsertIdx;
  if(cardEls.length===0){
    // empty column — append at end (or 0 if no cards). But if column has hidden cards, we append after last model card? For simplicity append.
    // If there are hidden cards, inserting visually at 0 should insert at 0 model-wise? We'll map to 0.
    if(targetCol.cards.length===0) modelInsertIdx=0;
    else {
      // if filtering hides all, insert at 0
      modelInsertIdx = 0;
    }
  } else {
    if(insertIdx >= cardEls.length){
      // append after last visible card — find model index after that card
      const lastVisibleId = cardEls[cardEls.length-1].dataset.cardId;
      const lastIdx = targetCol.cards.findIndex(c=>c.id===lastVisibleId);
      modelInsertIdx = lastIdx + 1;
      // if dragging within same column and moving down, account for removal shift later; but for placeholder placement we just need visual
    } else {
      const beforeId = cardEls[insertIdx].dataset.cardId;
      modelInsertIdx = targetCol.cards.findIndex(c=>c.id===beforeId);
    }
  }

  // Move placeholder DOM
  // Remove placeholder from old parent
  if(drag.placeholder.parentNode) drag.placeholder.parentNode.removeChild(drag.placeholder);
  // Insert at visual position
  if(cardEls.length===0){
    // empty column: remove empty-drop if present? Keep but insert before it? Better replace.
    const empty = colCardsEl.querySelector('.empty-drop');
    if(empty) empty.style.display='none';
    colCardsEl.appendChild(drag.placeholder);
  } else {
    if(insertIdx >= cardEls.length){
      colCardsEl.appendChild(drag.placeholder);
    } else {
      colCardsEl.insertBefore(drag.placeholder, cardEls[insertIdx]);
    }
  }
  // update drag state
  drag.currentColId = targetColId;
  drag.currentIdx = modelInsertIdx;
  // highlight empty column
  document.querySelectorAll('.empty-drop').forEach(ed=>{
    ed.classList.toggle('drag-over', ed.closest('.column').dataset.colId===targetColId && targetCol.cards.length===0);
  });
}

function endCardDrag(e){
  if(!drag || drag.type!=='card') return;
  const wasEscape = e && e.type==='keydown';
  stopAutoScroll();
  window.removeEventListener('keydown', onDragKey);
  document.body.style.userSelect='';
  document.body.style.cursor='';

  const ghost = drag.ghost, placeholder=drag.placeholder, originEl=drag.originEl;
  const snapshot = drag.snapshot;

  // cleanup DOM
  ghost.remove();
  if(placeholder.parentNode) placeholder.parentNode.removeChild(placeholder);
  originEl.classList.remove('dragging');
  document.querySelectorAll('.empty-drop').forEach(ed=>{ ed.style.display=''; ed.classList.remove('drag-over'); });

  if(wasEscape){
    const p = JSON.parse(snapshot);
    applyBoardState(p.board, p.nextId);
    save(); render();
    drag=null;
    return;
  }

  // commit move in model
  const fromCol = board.columns.find(c=>c.id===drag.colId);
  const toCol = board.columns.find(c=>c.id===drag.currentColId);
  const cardIdx = fromCol.cards.findIndex(c=>c.id===drag.cardId);
  if(cardIdx===-1){ drag=null; render(); return; }
  const [card] = fromCol.cards.splice(cardIdx,1);
  // adjust insert index if same column and moving down
  let insertAt = drag.currentIdx;
  if(drag.colId===drag.currentColId && insertAt > cardIdx) insertAt--;
  // clamp
  insertAt = Math.max(0, Math.min(insertAt, toCol.cards.length));
  toCol.cards.splice(insertAt,0,card);

  // push undo snapshot before save? We have snapshot of before
  undoStack.push(snapshot);
  if(undoStack.length>20) undoStack.shift();

  save();
  render();
  // settle animation: briefly highlight moved card
  requestAnimationFrame(()=>{
    const el = document.querySelector(`[data-card-id="${drag.cardId}"]`);
    if(el){
      el.style.transform='scale(1.02)';
      el.style.boxShadow='var(--shadow-lg)';
      setTimeout(()=>{ el.style.transform=''; el.style.boxShadow=''; }, 220);
    }
    drag=null;
  });
  // if drag was already nulled, ensure
  if(drag && drag.cardId) drag=null;
}

function onDragKey(e){
  if(e.key==='Escape' && drag){
    e.preventDefault();
    endCardDrag({type:'keydown'});
  }
}

/* ===== AUTO SCROLL ===== */
function startAutoScroll(){
  if(autoScrollRAF) return;
  const step = ()=>{
    if(!drag) { autoScrollRAF=null; return; }
    const x=drag.pointerX, y=drag.pointerY;
    // horizontal board scroll
    const bwRect = boardWrap.getBoundingClientRect();
    if(x < bwRect.left + CONFIG.AUTO_SCROLL_MARGIN) boardWrap.scrollLeft -= CONFIG.AUTO_SCROLL_SPEED;
    else if(x > bwRect.right - CONFIG.AUTO_SCROLL_MARGIN) boardWrap.scrollLeft += CONFIG.AUTO_SCROLL_SPEED;
    // vertical column scroll
    const cols = [...document.querySelectorAll('.column')];
    for(const c of cols){
      const r=c.getBoundingClientRect();
      if(x>=r.left && x<=r.right && y>=r.top && y<=r.bottom){
        const list=c.querySelector('.col-cards');
        if(list){
          const lr=list.getBoundingClientRect();
          if(y < lr.top + 48) list.scrollTop -= CONFIG.AUTO_SCROLL_SPEED;
          else if(y > lr.bottom - 48) list.scrollTop += CONFIG.AUTO_SCROLL_SPEED;
        }
        break;
      }
    }
    autoScrollRAF = requestAnimationFrame(step);
  };
  autoScrollRAF = requestAnimationFrame(step);
}
function stopAutoScroll(){
  if(autoScrollRAF) cancelAnimationFrame(autoScrollRAF);
  autoScrollRAF=null;
}

/* ============================================================
   COLUMN DRAG
   ============================================================ */
let colDrag=null;
boardEl.addEventListener('pointerdown', (e)=>{
  const handle = e.target.closest('.column-header');
  if(!handle) return;
  if(e.target.closest('button') || e.target.closest('input') || e.target.closest('[contenteditable="true"]')) return;
  const colEl = handle.closest('.column');
  const colId = colEl.dataset.colId;
  const startX=e.clientX, startY=e.clientY;
  let moved=false;
  const onMove=(ev)=>{
    if(!moved && Math.hypot(ev.clientX-startX, ev.clientY-startY) < CONFIG.DRAG_THRESHOLD) return;
    if(!moved){ moved=true; startColDrag(colEl, colId, ev); }
    if(colDrag) updateColDrag(ev);
  };
  const onUp=(ev)=>{
    window.removeEventListener('pointermove', onMove);
    window.removeEventListener('pointerup', onUp);
    if(colDrag) endColDrag(ev);
  };
  window.addEventListener('pointermove', onMove);
  window.addEventListener('pointerup', onUp);
});

function startColDrag(colEl, colId, e){
  const rect=colEl.getBoundingClientRect();
  const placeholder=document.createElement('div');
  placeholder.className='col-placeholder';
  placeholder.style.height=rect.height+'px';
  const ghost=colEl.cloneNode(true);
  ghost.classList.add('drag-ghost');
  ghost.style.width=rect.width+'px';
  ghost.style.height=rect.height+'px';
  ghost.style.left=rect.left+'px';
  ghost.style.top=rect.top+'px';
  ghost.style.opacity='.95';
  document.body.appendChild(ghost);
  colEl.style.opacity='.35';
  colEl.parentNode.insertBefore(placeholder, colEl);
  colDrag={ colId, ghost, placeholder, originEl:colEl, offsetX:e.clientX-rect.left, offsetY:e.clientY-rect.top, pointerX:e.clientX };
  document.body.style.cursor='grabbing';
  window.addEventListener('keydown', onColDragKey);
}
function updateColDrag(e){
  if(!colDrag) return;
  colDrag.pointerX=e.clientX;
  colDrag.ghost.style.left=(e.clientX-colDrag.offsetX)+'px';
  colDrag.ghost.style.top=(colDrag.originEl.getBoundingClientRect().top)+'px';
  // find insert index
  const cols=[...boardEl.querySelectorAll('.column:not([style*="opacity"])')].filter(c=>c!==colDrag.placeholder);
  // Actually include all columns except ghost; placeholder is not a column
  const allCols=[...boardEl.children].filter(c=>c!==colDrag.placeholder && c!==colDrag.originEl);
  let insertBefore=null;
  for(const c of allCols){
    const r=c.getBoundingClientRect();
    if(e.clientX < r.left + r.width/2){ insertBefore=c; break; }
  }
  if(insertBefore) boardEl.insertBefore(colDrag.placeholder, insertBefore);
  else boardEl.appendChild(colDrag.placeholder);
  // auto scroll board
  const bwRect=boardWrap.getBoundingClientRect();
  if(e.clientX < bwRect.left+CONFIG.AUTO_SCROLL_MARGIN) boardWrap.scrollLeft-=CONFIG.AUTO_SCROLL_SPEED;
  else if(e.clientX > bwRect.right-CONFIG.AUTO_SCROLL_MARGIN) boardWrap.scrollLeft+=CONFIG.AUTO_SCROLL_SPEED;
}
function endColDrag(e){
  if(!colDrag) return;
  const isEsc=e && e.type==='keydown';
  window.removeEventListener('keydown', onColDragKey);
  colDrag.ghost.remove();
  colDrag.originEl.style.opacity='';
  const ph=colDrag.placeholder;
  const colId=colDrag.colId;
  document.body.style.cursor='';
  if(isEsc){
    if(ph.parentNode) ph.parentNode.removeChild(ph);
    colDrag=null; return;
  }
  // compute new index
  const siblings=[...boardEl.children];
  const newIdx=siblings.indexOf(ph);
  // remove placeholder
  if(ph.parentNode) ph.parentNode.removeChild(ph);
  // update model
  const oldIdx=board.columns.findIndex(c=>c.id===colId);
  const [col]=board.columns.splice(oldIdx,1);
  // newIdx is position in DOM after removal of originEl? Our DOM had originEl still present but opacity 0, placeholder inserted.
  // Simpler: compute target index by counting columns before placeholder in DOM excluding originEl
  // We already have newIdx including originEl and placeholder. Let's recompute cleanly:
  // After removing placeholder, DOM has originEl at old position. We want to move column to position corresponding to placeholder's index.
  // Placeholder index among boardEl children before removal includes originEl. So target = newIdx > oldIdx ? newIdx-1 : newIdx
  let targetIdx=newIdx;
  if(targetIdx>oldIdx) targetIdx--;
  targetIdx=Math.max(0, Math.min(targetIdx, board.columns.length));
  pushUndo();
  board.columns.splice(targetIdx,0,col);
  save(); render();
  colDrag=null;
}
function onColDragKey(e){ if(e.key==='Escape' && colDrag){ e.preventDefault(); endColDrag({type:'keydown'}); } }

/* ============================================================
   GLOBAL CLOSE MENUS
   ============================================================ */
document.addEventListener('click', ()=> document.querySelectorAll('.col-menu.open').forEach(m=>m.classList.remove('open')));

/* ============================================================
   INIT
   ============================================================ */
initState();
render();

// expose for probe tests
window.__board = board;
window.__getBoard = ()=> board;
window.__save = save;
window.__render = render;
</script>
</body>
</html>
<!-- agent-meta {"model":"meta/muse-spark-1.2-contributor","provider":"openrouter","persona":"full","sessionId":"1fd75c2b-b247-4c97-9792-7711427121c1","tokensIn":529054,"tokensOut":22665,"tokensTotal":551719,"cost":0.01666109,"turns":18,"toolCalls":17,"failedToolCalls":0,"timestamp":"2026-08-23T02:14:41.976Z"} -->