← Kanban results

Kanban

muse spark 1.2 · 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 47,093 bytes · SHA-256 7372bef2c018
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Flow — Kanban Board</title>
<style>
  /* ============ DESIGN TOKENS ============ */
  :root{
    --bg:#f6f7f9; --bg2:#eef0f3; --card:#ffffff; --card-hover:#ffffff;
    --border:#e6e8eb; --border-strong:#dde1e6;
    --text:#0f172a; --muted:#64748b; --faint:#94a3b8;
    --accent:#111827; --accent2:#3b82f6;
    --radius:14px; --radius-sm:8px; --radius-pill:999px;
    --shadow:0 1px 2px rgba(0,0,0,.06),0 4px 12px rgba(0,0,0,.06);
    --shadow-lg:0 8px 24px rgba(0,0,0,.12),0 2px 8px rgba(0,0,0,.08);
    --col-w:296px; --col-gap:16px;
  }
  *{box-sizing:border-box}
  html,body{height:100%;margin:0;font-family:ui-sans-system,system-ui,-apple-system,Segoe UI,Roboto,Inter,Helvetica,Arial,sans-serif;color:var(--text);background:var(--bg)}
  button{font:inherit}
  ::-webkit-scrollbar{height:8px;width:8px}
  ::-webkit-scrollbar-thumb{background:#d1d5db;border-radius:99px}
  /* ============ HEADER ============ */
  .app-header{
    position:sticky;top:0;z-index:20;
    background:rgba(246,247,249,.92);backdrop-filter:blur(10px);
    border-bottom:1px solid var(--border);
    padding:14px 18px 12px; display:flex; gap:14px; align-items:center; flex-wrap:wrap;
  }
  .brand{display:flex;align-items:center;gap:10px;min-width:140px}
  .brand-logo{width:32px;height:32px;border-radius:9px;background:var(--accent);color:#fff;display:grid;place-items:center;font-weight:800;letter-spacing:-.02em}
  .brand h1{margin:0;font-size:18px;letter-spacing:-.03em}
  .brand p{margin:0;color:var(--muted);font-size:12px;margin-top:1px}
  .search-wrap{position:relative;flex:1;max-width:380px;min-width:200px}
  .search-wrap input{
    width:100%;padding:10px 12px 10px 36px;border:1px solid var(--border);border-radius:999px;background:#fff;
    font-size:14px;outline:none;transition:.15s;
  }
  .search-wrap input:focus{border-color:#cbd5e1;box-shadow:0 0 0 3px rgba(59,130,246,.12)}
  .search-icon{position:absolute;left:12px;top:50%;transform:translateY(-50%);color:var(--faint)}
  .filter-bar{display:flex;align-items:center;gap:8px;flex-wrap:wrap}
  .filter-label{font-size:12px;font-weight:600;color:var(--muted);letter-spacing:.04em;text-transform:uppercase}
  .chip{padding:6px 10px;border-radius:999px;font-size:12px;font-weight:600;border:1px solid transparent;cursor:pointer;user-select:none;transition:.15s}
  .chip.active{outline:2px solid currentColor;outline-offset:1px}
  .chip.all{background:#111827;color:#fff}
  .chip.all.active{outline-color:#111827}
  .header-actions{margin-left:auto;display:flex;gap:8px;align-items:center}
  .btn{padding:9px 14px;border-radius:999px;border:1px solid var(--border);background:#fff;font-weight:600;font-size:13px;cursor:pointer;transition:.15s}
  .btn:hover{background:#f8fafc;transform:translateY(-1px)}
  .btn.primary{background:var(--accent);color:#fff;border-color:var(--accent)}
  .btn.primary:hover{background:#1f2937}
  .btn.ghost{background:transparent;border-color:transparent;color:var(--muted)}
  /* ============ BOARD ============ */
  .board-wrap{overflow-x:auto;overflow-y:hidden;padding:18px 18px 24px;scroll-behavior:smooth}
  .board{display:flex;gap:var(--col-gap);align-items:flex-start;min-height:calc(100vh - 140px);padding-bottom:20px}
  /* ============ COLUMN ============ */
  .column{
    width:var(--col-w);min-width:var(--col-w);flex-shrink:0;
    background:#f1f3f6;border:1px solid var(--border);border-radius:16px;
    display:flex;flex-direction:column;max-height:calc(100vh - 120px);position:relative;
  }
  .column.dragging-col{opacity:.4;transform:rotate(1deg)}
  .column.wip-over{border-color:#fecaca;box-shadow:0 0 0 2px rgba(239,68,68,.12) inset}
  .column.wip-over .col-header{border-bottom-color:#fecaca}
  .col-header{
    padding:14px 12px 12px;display:flex;align-items:center;gap:8px;
    border-bottom:1px solid var(--border);border-radius:16px 16px 0 0;
    cursor:grab;user-select:none;background:inherit;position:sticky;top:0;z-index:1;
  }
  .col-header:active{cursor:grabbing}
  .col-title{font-weight:700;font-size:14px;letter-spacing:-.01em;flex:1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
  .col-title[contenteditable="true"]{outline:2px solid var(--accent2);border-radius:6px;padding:2px 6px;background:#fff;cursor:text}
  .col-count{background:#fff;border:1px solid var(--border);padding:2px 8px;border-radius:999px;font-size:12px;font-weight:700;color:var(--muted);min-width:28px;text-align:center}
  .col-count.over{background:#fef2f2;color:#dc2626;border-color:#fecaca}
  .col-menu-btn{width:28px;height:28px;border-radius:8px;border:1px solid transparent;background:transparent;color:var(--faint);cursor:pointer;display:grid;place-items:center}
  .col-menu-btn:hover{background:#fff;border-color:var(--border);color:var(--muted)}
  .col-menu{position:absolute;top:44px;right:8px;background:#fff;border:1px solid var(--border);border-radius:12px;box-shadow:var(--shadow-lg);padding:8px;min-width:200px;z-index:10;display:none}
  .col-menu.open{display:block}
  .col-menu label{font-size:11px;font-weight:700;letter-spacing:.06em;text-transform:uppercase;color:var(--muted);display:block;margin:6px 0 4px}
  .col-menu input, .col-menu select{width:100%;padding:8px 10px;border:1px solid var(--border);border-radius:8px;font-size:13px}
  .col-menu .menu-row{display:flex;gap:6px;margin-top:8px}
  .col-menu .menu-row button{flex:1}
  .wip-badge{font-size:11px;font-weight:700;color:#dc2626;background:#fef2f2;border:1px solid #fecaca;padding:2px 6px;border-radius:999px;display:none}
  .column.wip-over .wip-badge{display:inline-block}
  /* column body scroll */
  .col-body{overflow-y:auto;overflow-x:hidden;padding:10px 8px 8px;flex:1;display:flex;flex-direction:column;gap:10px;min-height:60px;scrollbar-width:thin}
  .col-body.drag-over{background:rgba(59,130,246,.04)}
  .empty-drop{
    border:2px dashed #cbd5e1;border-radius:12px;padding:22px 10px;text-align:center;color:var(--faint);font-size:13px;font-weight:500;
    background:rgba(255,255,255,.5);display:none;
  }
  .column.empty .empty-drop{display:block}
  .column.drag-active .empty-drop{display:block;border-color:var(--accent2);color:var(--accent2);background:rgba(59,130,246,.06)}
  /* ============ CARD ============ */
  .card{
    background:var(--card);border:1px solid var(--border);border-radius:12px;padding:12px 12px 10px;
    box-shadow:0 1px 2px rgba(0,0,0,.05);cursor:grab;user-select:none;position:relative;transition:box-shadow .15s, transform .15s, border-color .15s;
  }
  .card:hover{box-shadow:0 4px 12px rgba(0,0,0,.08);transform:translateY(-1px);border-color:var(--border-strong)}
  .card.filtered-out{display:none !important}
  .card.hidden-placeholder{visibility:hidden;pointer-events:none}
  .card-title{font-size:13.5px;font-weight:600;line-height:1.35;margin:0 0 6px;word-break:break-word}
  .card-title[contenteditable="true"]{outline:2px solid var(--accent2);border-radius:6px;padding:2px 4px;background:#f8fafc}
  .card-desc{font-size:12.5px;color:var(--muted);line-height:1.45;margin:0 0 8px;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;word-break:break-word}
  .card-desc.empty{font-style:italic;color:#cbd5e1}
  .card-meta{display:flex;align-items:center;gap:6px;flex-wrap:wrap}
  .label-chip{padding:3px 8px;border-radius:999px;font-size:11px;font-weight:700;letter-spacing:.02em}
  .due{font-size:11px;font-weight:600;padding:3px 7px;border-radius:999px;background:#f1f5f9;color:#475569;border:1px solid #e2e8f0}
  .due.overdue{background:#fef2f2;color:#dc2626;border-color:#fecaca}
  .due.soon{background:#fffbeb;color:#b45309;border-color:#fde68a}
  .card-actions{position:absolute;top:8px;right:8px;display:flex;gap:4px;opacity:0;transition:.15s}
  .card:hover .card-actions{opacity:1}
  .icon-btn{width:26px;height:26px;border-radius:8px;border:1px solid var(--border);background:#fff;display:grid;place-items:center;cursor:pointer;color:var(--muted);font-size:12px}
  .icon-btn:hover{background:#f8fafc;color:var(--text)}
  .icon-btn.danger:hover{background:#fef2f2;color:#dc2626;border-color:#fecaca}
  /* ============ PLACEHOLDER & GHOST ============ */
  .drop-indicator{height:4px;background:var(--accent2);border-radius:999px;margin:2px 4px;transition:height .1s}
  .placeholder{border:2px dashed #93c5fd;background:rgba(59,130,246,.08);border-radius:12px;min-height:72px;pointer-events:none}
  .ghost-card{
    position:fixed;z-index:9999;pointer-events:none;transform:rotate(3deg) scale(1.02);
    box-shadow:var(--shadow-lg);opacity:.96;will-change:transform,left,top;width:var(--col-w);
  }
  /* ============ MODAL ============ */
  .modal-backdrop{position:fixed;inset:0;background:rgba(15,23,42,.42);backdrop-filter:blur(4px);display:none;place-items:center;z-index:30;padding:18px}
  .modal-backdrop.open{display:grid}
  .modal{width:min(520px,100%);background:#fff;border-radius:16px;box-shadow:var(--shadow-lg);overflow:hidden}
  .modal-head{padding:16px 18px;border-bottom:1px solid var(--border);display:flex;justify-content:space-between;align-items:center}
  .modal-head h3{margin:0;font-size:15px}
  .modal-body{padding:16px 18px;display:flex;flex-direction:column;gap:12px}
  .modal-body label{font-size:12px;font-weight:700;letter-spacing:.04em;text-transform:uppercase;color:var(--muted)}
  .modal-body input, .modal-body textarea, .modal-body select{width:100%;padding:10px 12px;border:1px solid var(--border);border-radius:10px;font:inherit;font-size:14px;outline:none}
  .modal-body textarea{min-height:80px;resize:vertical}
  .modal-body input:focus, .modal-body textarea:focus{border-color:#93c5fd;box-shadow:0 0 0 3px rgba(59,130,246,.15)}
  .label-picker{display:flex;gap:8px;flex-wrap:wrap}
  .label-picker .chip{border-width:2px}
  .label-picker .chip.selected{box-shadow:0 0 0 2px #111827 inset}
  .modal-foot{padding:12px 18px;border-top:1px solid var(--border);display:flex;justify-content:flex-end;gap:8px;background:#f8fafc}
  /* ============ TOAST ============ */
  .toast-wrap{position:fixed;bottom:18px;left:50%;transform:translateX(-50%);z-index:40;display:flex;flex-direction:column;gap:8px;align-items:center}
  .toast{background:#111827;color:#fff;padding:12px 14px;border-radius:12px;display:flex;align-items:center;gap:12px;box-shadow:var(--shadow-lg);font-size:13px;font-weight:500;animation:slideUp .25s ease}
  @keyframes slideUp{from{transform:translateY(10px);opacity:0}to{transform:translateY(0);opacity:1}}
  .toast button{background:#fff;color:#111827;border:none;padding:6px 10px;border-radius:999px;font-weight:700;cursor:pointer}
  /* ============ ADD CARD / ADD COLUMN ============ */
  .add-card-btn{width:100%;padding:10px;border:1px dashed #cbd5e1;border-radius:10px;background:rgba(255,255,255,.6);color:var(--muted);font-weight:600;font-size:13px;cursor:pointer;transition:.15s}
  .add-card-btn:hover{background:#fff;border-color:#94a3b8;color:var(--text)}
  .add-col-card{
    width:var(--col-w);min-width:var(--col-w);border:2px dashed #cbd5e1;border-radius:16px;display:grid;place-items:center;padding:28px;color:var(--muted);font-weight:600;cursor:pointer;background:rgba(255,255,255,.4);transition:.15s;min-height:120px;
  }
  .add-col-card:hover{background:#fff;border-color:#94a3b8;color:var(--text)}
</style>
</head>
<body>
<!-- ================= HEADER ================= -->
<header class="app-header">
  <div class="brand">
    <div class="brand-logo">≡</div>
    <div>
      <h1>Flow</h1>
      <p id="boardStats">0 cards · 5 columns</p>
    </div>
  </div>

  <div class="search-wrap">
    <span class="search-icon">⌕</span>
    <input id="searchInput" placeholder="Search cards by title…  ( / to focus )" autocomplete="off">
  </div>

  <div class="filter-bar" id="filterBar">
    <span class="filter-label">Filter</span>
    <!-- chips injected -->
  </div>

  <div class="header-actions">
    <button class="btn ghost" id="clearFiltersBtn" title="Clear filters">Clear</button>
    <button class="btn" id="addColumnBtn">+ Add column</button>
  </div>
</header>

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

<!-- ================= CARD MODAL ================= -->
<div class="modal-backdrop" id="cardModal">
  <div class="modal">
    <div class="modal-head">
      <h3 id="modalTitle">Edit card</h3>
      <button class="icon-btn" id="closeModalBtn">✕</button>
    </div>
    <div class="modal-body">
      <label>Title</label>
      <input id="mTitle" placeholder="Card title">
      <label>Description</label>
      <textarea id="mDesc" placeholder="Add a description…"></textarea>
      <label>Due date</label>
      <input id="mDue" type="date">
      <label>Labels</label>
      <div class="label-picker" id="mLabels"></div>
    </div>
    <div class="modal-foot">
      <button class="btn" id="cancelModalBtn">Cancel</button>
      <button class="btn primary" id="saveModalBtn">Save</button>
    </div>
  </div>
</div>

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

<script>
/* =========================================================
   TUNABLE CONSTANTS
   ========================================================= */
const CONFIG = {
  LABELS: [
    { id:'bug', name:'Bug', color:'#fecaca', text:'#991b1b' },
    { id:'feature', name:'Feature', color:'#bfdbfe', text:'#1e40af' },
    { id:'design', name:'Design', color:'#fde68a', text:'#92400e' },
    { id:'dev', name:'Dev', color:'#bbf7d0', text:'#065f46' },
    { id:'docs', name:'Docs', color:'#ddd6fe', text:'#5b21b6' },
    { id:'urgent', name:'Urgent', color:'#fed7aa', text:'#9a3412' },
  ],
  DRAG_THRESHOLD: 4,          // px before drag starts
  AUTO_SCROLL_EDGE: 72,       // px from edge to trigger scroll
  AUTO_SCROLL_SPEED: 14,      // px per frame
  BOARD_SCROLL_EDGE: 90,
  GHOST_OFFSET_X: 14,
  GHOST_OFFSET_Y: 12,
  STORAGE_KEY: 'flow-kanban-v2'
};

/* =========================================================
   STATE / MODEL — single source of truth
   ========================================================= */
let board = null;
let drag = null; // {type:'card'|'col', id, colId, origColId, origIndex, ghost, placeholder, pointerId, offsetX, offsetY, currentColId, currentIndex, raf}
let undoStack = [];
let editingCard = null; // {colId, cardId, isNew}
let activeFilters = new Set(); // label ids
let searchQuery = '';

/* =========================================================
   UTILITIES
   ========================================================= */
const uid = ()=> Math.random().toString(36).slice(2,9);
const clone = o => JSON.parse(JSON.stringify(o));
const todayISO = ()=> new Date().toISOString().slice(0,10);

function defaultBoard(){
  return {
    columns:[
      { id:uid(), title:'Backlog', wip:0, cards:[
        {id:uid(), title:'Research competitor flows', desc:'Look at Linear, Trello and Notion for inspiration.', labels:['design'], due:''},
        {id:uid(), title:'Define label taxonomy', desc:'Finalize 6 core labels and their colors.', labels:['docs'], due:todayISO()},
      ]},
      { id:uid(), title:'To Do', wip:0, cards:[
        {id:uid(), title:'Build drag-and-drop engine', desc:'Pointer events, placeholder, auto-scroll, Escape cancel.', labels:['dev','feature'], due:''},
        {id:uid(), title:'Design empty states', desc:'Dashed zones for empty columns.', labels:['design'], due:''},
      ]},
      { id:uid(), title:'In Progress', wip:2, cards:[
        {id:uid(), title:'Implement WIP limits', desc:'Highlight column when limit exceeded.', labels:['feature'], due:''},
      ]},
      { id:uid(), title:'Review', wip:0, cards:[]},
      { id:uid(), title:'Done', wip:0, cards:[
        {id:uid(), title:'Project scaffold', desc:'Board model, persistence, render loop.', labels:['dev'], due:''},
      ]},
    ]
  };
}
function save(){
  localStorage.setItem(CONFIG.STORAGE_KEY, JSON.stringify({board, activeFilters:[...activeFilters], searchQuery}));
  updateStats();
}
function load(){
  try{
    const raw = localStorage.getItem(CONFIG.STORAGE_KEY);
    if(raw){
      const p = JSON.parse(raw);
      if(p.board && p.board.columns) {
        board = p.board;
        activeFilters = new Set(p.activeFilters||[]);
        searchQuery = p.searchQuery||'';
        return;
      }
    }
  }catch(e){}
  board = defaultBoard();
}
function pushUndo(entry){ undoStack.push(entry); if(undoStack.length>20) undoStack.shift(); }

/* =========================================================
   RENDERING
   ========================================================= */
const elBoard = document.getElementById('board');
const elBoardWrap = document.getElementById('boardWrap');
const elFilterBar = document.getElementById('filterBar');
const elSearch = document.getElementById('searchInput');

function render(){
  // filter bar
  elFilterBar.querySelectorAll('.chip:not(.filter-label)').forEach(n=>n.remove());
  const allChip = document.createElement('button');
  allChip.className='chip all'+(activeFilters.size===0?' active':'');
  allChip.textContent='All';
  allChip.onclick=()=>{activeFilters.clear(); save(); render();};
  elFilterBar.appendChild(allChip);
  CONFIG.LABELS.forEach(l=>{
    const b=document.createElement('button');
    b.className='chip'+(activeFilters.has(l.id)?' active':'');
    b.style.background=l.color; b.style.color=l.text;
    b.textContent=l.name;
    b.onclick=()=>{
      if(activeFilters.has(l.id)) activeFilters.delete(l.id); else activeFilters.add(l.id);
      save(); render();
    };
    elFilterBar.appendChild(b);
  });
  elSearch.value = searchQuery;

  // board
  elBoard.innerHTML='';
  board.columns.forEach(col=>{
    const colEl = document.createElement('div');
    colEl.className='column'+(col.cards.length===0?' empty':'')+(col.wip>0 && col.cards.length>col.wip?' wip-over':'');
    colEl.dataset.colId = col.id;

    // header
    const head = document.createElement('div');
    head.className='col-header';
    head.dataset.drag='col';
    head.dataset.colId=col.id;
    head.innerHTML = `
      <span class="col-title" data-edit-col="${col.id}">${escapeHtml(col.title)}</span>
      <span class="col-count ${col.wip>0 && col.cards.length>col.wip?'over':''}">${col.cards.length}${col.wip? '/'+col.wip:''}</span>
      <span class="wip-badge">WIP exceeded</span>
      <button class="col-menu-btn" data-menu="${col.id}">⋯</button>
    `;
    colEl.appendChild(head);

    // menu
    const menu = document.createElement('div');
    menu.className='col-menu'; menu.id='menu-'+col.id;
    menu.innerHTML=`
      <label>Column title</label>
      <input value="${escapeAttr(col.title)}" data-menu-title="${col.id}">
      <label>WIP limit (0 = no limit)</label>
      <input type="number" min="0" value="${col.wip}" data-menu-wip="${col.id}">
      <div class="menu-row">
        <button class="btn" data-menu-close="${col.id}">Done</button>
        <button class="btn" style="color:#dc2626;border-color:#fecaca" data-delete-col="${col.id}">Delete</button>
      </div>
    `;
    colEl.appendChild(menu);

    // body
    const body = document.createElement('div');
    body.className='col-body';
    body.dataset.colId=col.id;

    // cards
    col.cards.forEach(card=>{
      const c = document.createElement('div');
      c.className='card';
      c.dataset.cardId=card.id;
      c.dataset.colId=col.id;
      // filter logic (view only)
      const labelMatch = activeFilters.size===0 || card.labels.some(l=>activeFilters.has(l));
      const searchMatch = !searchQuery || card.title.toLowerCase().includes(searchQuery.toLowerCase());
      if(!labelMatch || !searchMatch) c.classList.add('filtered-out');

      const dueCls = card.due ? (card.due < todayISO() ? 'overdue' : (daysUntil(card.due)<=2 ? 'soon':'')) : '';
      const labelsHtml = card.labels.map(id=>{
        const L=CONFIG.LABELS.find(x=>x.id===id); if(!L) return '';
        return `<span class="label-chip" style="background:${L.color};color:${L.text}">${L.name}</span>`;
      }).join('');
      c.innerHTML=`
        <div class="card-actions">
          <button class="icon-btn" data-edit-card="${card.id}" data-col="${col.id}" title="Edit">✎</button>
          <button class="icon-btn danger" data-del-card="${card.id}" data-col="${col.id}" title="Delete">✕</button>
        </div>
        <p class="card-title" data-card-title="${card.id}">${escapeHtml(card.title)}</p>
        <p class="card-desc ${card.desc?'':'empty'}">${card.desc? escapeHtml(card.desc): 'No description'}</p>
        <div class="card-meta">
          ${labelsHtml}
          ${card.due? `<span class="due ${dueCls}">◷ ${card.due}</span>`:''}
        </div>
      `;
      // pointer down for card drag
      c.addEventListener('pointerdown', onCardPointerDown);
      body.appendChild(c);
    });

    const empty = document.createElement('div');
    empty.className='empty-drop';
    empty.textContent='Drop cards here';
    body.appendChild(empty);

    const addBtn = document.createElement('button');
    addBtn.className='add-card-btn';
    addBtn.textContent='+ Add card';
    addBtn.dataset.addCard = col.id;
    body.appendChild(addBtn);

    colEl.appendChild(body);
    // column header drag
    head.addEventListener('pointerdown', onColPointerDown);
    elBoard.appendChild(colEl);
  });

  // add column card
  const addCol = document.createElement('div');
  addCol.className='add-col-card';
  addCol.id='addColumnCard';
  addCol.innerHTML='<span style="font-size:22px">+</span><span>Add column</span>';
  addCol.onclick=()=> addColumn();
  elBoard.appendChild(addCol);

  attachStaticEvents();
  updateStats();
}

function escapeHtml(s){ return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');}
function escapeAttr(s){ return escapeHtml(s).replace(/'/g,'&#39;');}
function daysUntil(d){ const a=new Date(d), b=new Date(todayISO()); return Math.round((a-b)/86400000); }
function updateStats(){
  const total = board.columns.reduce((n,c)=>n+c.cards.length,0);
  document.getElementById('boardStats').textContent = `${total} card${total!==1?'s':''} · ${board.columns.length} columns`;
}

/* =========================================================
   STATIC EVENTS (delegated where possible)
   ========================================================= */
function attachStaticEvents(){
  // col menu toggle
  document.querySelectorAll('[data-menu]').forEach(b=>{
    b.onclick=(e)=>{
      e.stopPropagation();
      const id=b.dataset.menu;
      document.querySelectorAll('.col-menu').forEach(m=>{ if(m.id!=='menu-'+id) m.classList.remove('open');});
      document.getElementById('menu-'+id).classList.toggle('open');
    };
  });
  document.querySelectorAll('[data-menu-close]').forEach(b=>{
    b.onclick=()=> document.getElementById('menu-'+b.dataset.menuClose).classList.remove('open');
  });
  document.querySelectorAll('[data-menu-title]').forEach(inp=>{
    inp.onchange=()=>{
      const col=findCol(inp.dataset.menuTitle); if(!col) return;
      col.title = inp.value.trim()||col.title; save(); render();
    };
    inp.onkeydown=e=>{ if(e.key==='Enter'){ e.target.blur(); }}
  });
  document.querySelectorAll('[data-menu-wip]').forEach(inp=>{
    inp.onchange=()=>{
      const col=findCol(inp.dataset.menuWip); if(!col) return;
      col.wip = Math.max(0, parseInt(inp.value)||0); save(); render();
    };
  });
  document.querySelectorAll('[data-delete-col]').forEach(b=>{
    b.onclick=()=> deleteColumn(b.dataset.deleteCol);
  });
  document.querySelectorAll('[data-add-card]').forEach(b=>{
    b.onclick=()=> openCardModal(b.dataset.addCard, null, true);
  });
  document.querySelectorAll('[data-edit-card]').forEach(b=>{
    b.onclick=(e)=>{ e.stopPropagation(); openCardModal(b.dataset.col, b.dataset.editCard, false); };
  });
  document.querySelectorAll('[data-del-card]').forEach(b=>{
    b.onclick=(e)=>{ e.stopPropagation(); deleteCard(b.dataset.col, b.dataset.delCard); };
  });
  // inline title edit: card title click
  document.querySelectorAll('[data-card-title]').forEach(el=>{
    el.addEventListener('click', (e)=>{
      e.stopPropagation();
      startInlineEdit(el, el.dataset.cardTitle);
    });
  });
  document.querySelectorAll('[data-edit-col]').forEach(el=>{
    el.addEventListener('click', (e)=>{
      e.stopPropagation();
      startColInlineEdit(el, el.dataset.editCol);
    });
  });
  // close menus on outside click
  document.addEventListener('click', (e)=>{
    if(!e.target.closest('.col-menu') && !e.target.closest('.col-menu-btn')){
      document.querySelectorAll('.col-menu.open').forEach(m=>m.classList.remove('open'));
    }
  }, {once:true});
}

function startInlineEdit(el, cardId){
  const colId = el.closest('.card')?.dataset.colId;
  const col=findCol(colId); if(!col) return;
  const card=col.cards.find(c=>c.id===cardId); if(!card) return;
  const orig = card.title;
  el.contentEditable='true'; el.focus();
  // select all
  const sel=window.getSelection(); const r=document.createRange(); r.selectNodeContents(el); sel.removeAllRanges(); sel.addRange(r);
  const commit=()=>{
    el.contentEditable='false';
    const v=el.textContent.trim();
    if(v && v!==orig){ card.title=v; save(); render(); }
    else el.textContent=orig;
    el.removeEventListener('blur', commit);
    el.removeEventListener('keydown', onKey);
  };
  const cancel=()=>{
    el.textContent=orig; el.contentEditable='false';
    el.removeEventListener('blur', commit);
    el.removeEventListener('keydown', onKey);
    render();
  };
  const onKey=(e)=>{
    if(e.key==='Enter'){ e.preventDefault(); el.blur(); }
    if(e.key==='Escape'){ e.preventDefault(); cancel(); }
  };
  el.addEventListener('blur', commit);
  el.addEventListener('keydown', onKey);
}
function startColInlineEdit(el, colId){
  const col=findCol(colId); if(!col) return;
  const orig=col.title;
  el.contentEditable='true'; el.focus();
  const sel=window.getSelection(); const r=document.createRange(); r.selectNodeContents(el); sel.removeAllRanges(); sel.addRange(r);
  const commit=()=>{
    el.contentEditable='false';
    const v=el.textContent.trim();
    if(v && v!==orig){ col.title=v; save(); render(); } else el.textContent=orig;
    el.removeEventListener('blur', commit);
    el.removeEventListener('keydown', onKey);
  };
  const cancel=()=>{ el.textContent=orig; el.contentEditable='false'; render(); };
  const onKey=e=>{ if(e.key==='Enter'){e.preventDefault(); el.blur();} if(e.key==='Escape'){e.preventDefault(); cancel();}};
  el.addEventListener('blur', commit);
  el.addEventListener('keydown', onKey);
}

/* =========================================================
   CRUD
   ========================================================= */
function findCol(id){ return board.columns.find(c=>c.id===id); }
function findCard(colId, cardId){
  const col=findCol(colId); if(!col) return null;
  return col.cards.find(c=>c.id===cardId);
}
function addColumn(){
  const title = prompt('Column name:', 'New Column');
  if(title===null) return;
  const name = title.trim()||'New Column';
  board.columns.push({id:uid(), title:name, wip:0, cards:[]});
  save(); render();
  // scroll to new column
  requestAnimationFrame(()=> elBoardWrap.scrollLeft = elBoardWrap.scrollWidth);
}
function deleteColumn(colId){
  if(board.columns.length<=1){ toast('Keep at least one column'); return; }
  const idx=board.columns.findIndex(c=>c.id===colId); if(idx<0) return;
  const col=clone(board.columns[idx]);
  const pos=idx;
  board.columns.splice(idx,1);
  save(); render();
  pushUndo({type:'col', col, pos});
  showUndo(`Column “${col.title}” deleted`, ()=>{
    board.columns.splice(pos,0,col); save(); render();
  });
}
function deleteCard(colId, cardId){
  const col=findCol(colId); if(!col) return;
  const idx=col.cards.findIndex(c=>c.id===cardId); if(idx<0) return;
  const card=clone(col.cards[idx]);
  col.cards.splice(idx,1);
  save(); render();
  pushUndo({type:'card', card, colId, idx});
  showUndo(`Card deleted`, ()=>{
    const c=findCol(colId); if(c) c.cards.splice(idx,0,card); else board.columns[0].cards.push(card);
    save(); render();
  });
}

/* =========================================================
   CARD MODAL
   ========================================================= */
const modal = document.getElementById('cardModal');
const mTitleEl = document.getElementById('mTitle');
const mDescEl = document.getElementById('mDesc');
const mDueEl = document.getElementById('mDue');
const mLabelsEl = document.getElementById('mLabels');

function openCardModal(colId, cardId, isNew){
  let card;
  if(isNew){
    editingCard={colId, cardId:null, isNew:true};
    card={title:'', desc:'', labels:[], due:''};
    document.getElementById('modalTitle').textContent='Add card';
  } else {
    const col=findCol(colId); card=col.cards.find(c=>c.id===cardId);
    if(!card) return;
    editingCard={colId, cardId, isNew:false};
    document.getElementById('modalTitle').textContent='Edit card';
  }
  mTitleEl.value=card.title; mDescEl.value=card.desc; mDueEl.value=card.due;
  // label picker
  mLabelsEl.innerHTML='';
  CONFIG.LABELS.forEach(l=>{
    const b=document.createElement('button');
    b.className='chip'+(card.labels.includes(l.id)?' selected':'');
    b.style.background=l.color; b.style.color=l.text; b.style.borderColor=card.labels.includes(l.id)?'#111827':'transparent';
    b.textContent=l.name;
    b.onclick=()=>{ b.classList.toggle('selected'); b.style.borderColor=b.classList.contains('selected')?'#111827':'transparent'; };
    b.dataset.label=l.id;
    mLabelsEl.appendChild(b);
  });
  modal.classList.add('open');
  setTimeout(()=>mTitleEl.focus(), 30);
}
function closeModal(){ modal.classList.remove('open'); editingCard=null; }
document.getElementById('closeModalBtn').onclick=closeModal;
document.getElementById('cancelModalBtn').onclick=closeModal;
modal.addEventListener('click', e=>{ if(e.target===modal) closeModal(); });
document.getElementById('saveModalBtn').onclick=()=>{
  if(!editingCard) return;
  const title=mTitleEl.value.trim(); if(!title){ mTitleEl.focus(); mTitleEl.style.borderColor='#ef4444'; return; }
  const labels=[...mLabelsEl.querySelectorAll('.chip.selected')].map(b=>b.dataset.label);
  const data={title, desc:mDescEl.value.trim(), due:mDueEl.value, labels};
  if(editingCard.isNew){
    const col=findCol(editingCard.colId); if(!col) return;
    col.cards.push({id:uid(), ...data});
  } else {
    const card=findCard(editingCard.colId, editingCard.cardId);
    if(card) Object.assign(card, data);
  }
  save(); render(); closeModal();
};

/* =========================================================
   SEARCH & FILTERS
   ========================================================= */
elSearch.addEventListener('input', ()=>{ searchQuery=elSearch.value; save(); render(); });
document.getElementById('clearFiltersBtn').onclick=()=>{ activeFilters.clear(); searchQuery=''; elSearch.value=''; save(); render(); };
document.getElementById('addColumnBtn').onclick=addColumn;
document.addEventListener('keydown', e=>{
  if(e.key==='/' && !e.target.matches('input,textarea,[contenteditable]')){ e.preventDefault(); elSearch.focus(); }
  if(e.key==='Escape' && drag){ cancelDrag(); }
  if(e.key==='Escape' && modal.classList.contains('open')) closeModal();
});

/* =========================================================
   TOAST + UNDO
   ========================================================= */
function toast(msg){
  const w=document.getElementById('toastWrap');
  const t=document.createElement('div'); t.className='toast'; t.textContent=msg;
  w.appendChild(t); setTimeout(()=>t.remove(), 2200);
}
function showUndo(msg, onUndo){
  const w=document.getElementById('toastWrap');
  const t=document.createElement('div'); t.className='toast';
  t.innerHTML=`<span>${escapeHtml(msg)}</span>`;
  const b=document.createElement('button'); b.textContent='Undo'; b.onclick=()=>{ onUndo(); t.remove(); };
  t.appendChild(b); w.appendChild(t);
  let timer=setTimeout(()=>t.remove(), 5000);
  t.addEventListener('mouseenter',()=>clearTimeout(timer));
  t.addEventListener('mouseleave',()=> timer=setTimeout(()=>t.remove(),2000));
}

/* =========================================================
   DRAG & DROP — Pointer Events (cards + columns)
   ========================================================= */
let dragStartPos=null;
let hasMoved=false;

function onCardPointerDown(e){
  if(e.button!==0) return;
  if(e.target.closest('.icon-btn')) return; // don't drag when clicking actions
  // don't start inline edit interference
  const cardEl = e.currentTarget;
  const cardId = cardEl.dataset.cardId;
  const colId = cardEl.dataset.colId;
  const col=findCol(colId); if(!col) return;
  const idx=col.cards.findIndex(c=>c.id===cardId);
  dragStartPos={x:e.clientX, y:e.clientY};
  hasMoved=false;
  const pointerId=e.pointerId;

  const onMove = (ev)=>{
    if(ev.pointerId!==pointerId) return;
    const dx=ev.clientX - dragStartPos.x, dy=ev.clientY - dragStartPos.y;
    if(!hasMoved && Math.hypot(dx,dy) < CONFIG.DRAG_THRESHOLD) return;
    if(!hasMoved){
      hasMoved=true;
      startCardDrag(cardEl, colId, cardId, idx, ev);
    }
    if(drag) updateCardDrag(ev);
  };
  const onUp = (ev)=>{
    if(ev.pointerId!==pointerId) return;
    cardEl.releasePointerCapture?.(pointerId);
    window.removeEventListener('pointermove', onMove);
    window.removeEventListener('pointerup', onUp);
    window.removeEventListener('pointercancel', onUp);
    if(drag) finishCardDrag(false);
    dragStartPos=null;
  };
  cardEl.setPointerCapture(pointerId);
  window.addEventListener('pointermove', onMove);
  window.addEventListener('pointerup', onUp);
  window.addEventListener('pointercancel', onUp);
  // prevent text selection
  e.preventDefault();
}

function startCardDrag(cardEl, colId, cardId, origIndex, e){
  const rect = cardEl.getBoundingClientRect();
  const offsetX = e.clientX - rect.left;
  const offsetY = e.clientY - rect.top;

  // placeholder
  const ph = document.createElement('div');
  ph.className='placeholder';
  ph.style.height = rect.height + 'px';
  // insert placeholder at original position
  cardEl.insertAdjacentElement('afterend', ph);
  cardEl.classList.add('hidden-placeholder');

  // ghost
  const ghost = cardEl.cloneNode(true);
  ghost.classList.add('ghost-card');
  ghost.style.width = rect.width + 'px';
  ghost.style.height = rect.height + 'px';
  ghost.style.left = (e.clientX - offsetX) + 'px';
  ghost.style.top  = (e.clientY - offsetY) + 'px';
  document.body.appendChild(ghost);

  drag = {
    type:'card', id:cardId, origColId:colId, origIndex,
    colId, index:origIndex,
    ghost, placeholder:ph, offsetX, offsetY,
    cardEl,
    currentColId:colId, currentIndex:origIndex,
    pointerId:e.pointerId
  };
  document.body.style.userSelect='none';
  document.body.style.cursor='grabbing';
  // auto scroll loop
  startAutoScroll();
}

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

  // hit test: batched reads first
  const cols = [...document.querySelectorAll('.column')];
  let targetColId = null;
  let insertIndex = 0;

  // find column under cursor (use x)
  // if cursor outside board, clamp to nearest column
  for(const ce of cols){
    const r=ce.getBoundingClientRect();
    if(e.clientX >= r.left && e.clientX <= r.right){
      targetColId = ce.dataset.colId;
      break;
    }
  }
  if(!targetColId){
    // find nearest column by center distance
    let best=null, bestD=Infinity;
    for(const ce of cols){
      const r=ce.getBoundingClientRect();
      const cx=r.left + r.width/2;
      const d=Math.abs(e.clientX - cx);
      if(d<bestD){ bestD=d; best=ce.dataset.colId; }
    }
    targetColId=best;
  }
  if(!targetColId) return;

  const colBody = document.querySelector(`.col-body[data-col-id="${targetColId}"]`);
  const cardEls = [...colBody.querySelectorAll('.card:not(.hidden-placeholder)')].filter(el=> !el.classList.contains('filtered-out') );
  // also placeholder currently in DOM might be in another col; we handle move below
  // determine insert position by y
  if(cardEls.length===0){
    insertIndex=0;
  } else {
    // compare cursor Y to each card's center
    let placed=false;
    for(let i=0;i<cardEls.length;i++){
      const r=cardEls[i].getBoundingClientRect();
      const mid=r.top + r.height/2;
      if(e.clientY < mid){
        insertIndex=i; placed=true; break;
      }
    }
    if(!placed) insertIndex=cardEls.length;
  }

  // move placeholder DOM (write)
  // remove placeholder from old place and insert at new
  const currentParent = drag.placeholder.parentElement;
  const targetParent = colBody;
  if(currentParent !== targetParent || drag.currentColId!==targetColId || drag.currentIndex!==insertIndex){
    // remove from old
    drag.placeholder.remove();
    // compute insertion point in targetParent among visible cards + empty + add button
    // we insert before the card at insertIndex, or before empty-drop if at end
    const visible = [...targetParent.querySelectorAll('.card:not(.hidden-placeholder)')].filter(el=>!el.classList.contains('filtered-out'));
    if(insertIndex >= visible.length){
      // before empty-drop
      const empty = targetParent.querySelector('.empty-drop');
      targetParent.insertBefore(drag.placeholder, empty);
    } else {
      targetParent.insertBefore(drag.placeholder, visible[insertIndex]);
    }
    drag.currentColId = targetColId;
    drag.currentIndex = insertIndex;
    // visual feedback
    document.querySelectorAll('.col-body').forEach(b=>b.classList.remove('drag-over'));
    targetParent.classList.add('drag-over');
    // mark columns drag-active for empty state
    document.querySelectorAll('.column').forEach(c=>c.classList.remove('drag-active'));
    document.querySelector(`.column[data-col-id="${targetColId}"]`)?.classList.add('drag-active');
  }
  drag._lastX = e.clientX; drag._lastY = e.clientY;
}

function finishCardDrag(cancelled){
  if(!drag || drag.type!=='card') return;
  stopAutoScroll();
  document.querySelectorAll('.col-body').forEach(b=>b.classList.remove('drag-over'));
  document.querySelectorAll('.column').forEach(c=>c.classList.remove('drag-active'));
  document.body.style.userSelect='';
  document.body.style.cursor='';
  const {origColId, origIndex, currentColId, currentIndex, placeholder, ghost, cardEl, id} = drag;
  // cleanup DOM
  placeholder.remove();
  ghost.remove();
  cardEl.classList.remove('hidden-placeholder');

  if(cancelled){
    drag=null; return;
  }
  // apply to model: remove from orig, insert at new
  // we stored orig col/index, but model still unmodified. Perform move.
  // Need to handle same-col vs cross-col correctly.
  // Remove
  const srcCol=findCol(origColId);
  const dstCol=findCol(currentColId);
  if(!srcCol || !dstCol){ drag=null; return; }
  const srcIdx = srcCol.cards.findIndex(c=>c.id===id);
  if(srcIdx<0){ drag=null; return; }
  const [moved] = srcCol.cards.splice(srcIdx,1);
  // adjust insert index when same column and moving downwards
  let ins = currentIndex;
  if(origColId===currentColId && srcIdx < ins) ins = ins - 1;
  // clamp
  ins = Math.max(0, Math.min(ins, dstCol.cards.length));
  dstCol.cards.splice(ins,0,moved);
  drag=null;
  save(); render();
  // subtle settle animation: highlight moved card
  requestAnimationFrame(()=>{
    const el=document.querySelector(`.card[data-card-id="${id}"]`);
    if(el){ el.animate([{transform:'scale(1.02)'},{transform:'scale(1)'}],{duration:220, easing:'ease-out'}); }
  });
}
function cancelDrag(){
  if(!drag) return;
  if(drag.type==='card') finishCardDrag(true);
  else if(drag.type==='col') finishColDrag(true);
}

/* ---- Auto-scroll ---- */
let autoScrollRAF=null;
function startAutoScroll(){
  if(autoScrollRAF) cancelAnimationFrame(autoScrollRAF);
  const tick=()=>{
    if(!drag) return;
    const x=drag._lastX, y=drag._lastY;
    if(x==null || y==null){ autoScrollRAF=requestAnimationFrame(tick); return; }
    // horizontal board scroll
    const br=elBoardWrap.getBoundingClientRect();
    if(x < br.left + CONFIG.BOARD_SCROLL_EDGE) elBoardWrap.scrollLeft -= CONFIG.AUTO_SCROLL_SPEED;
    else if(x > br.right - CONFIG.BOARD_SCROLL_EDGE) elBoardWrap.scrollLeft += CONFIG.AUTO_SCROLL_SPEED;
    // vertical column scroll for current column
    if(drag.type==='card' && drag.currentColId){
      const body=document.querySelector(`.col-body[data-col-id="${drag.currentColId}"]`);
      if(body){
        const r=body.getBoundingClientRect();
        if(y < r.top + CONFIG.AUTO_SCROLL_EDGE) body.scrollTop -= CONFIG.AUTO_SCROLL_SPEED;
        else if(y > r.bottom - CONFIG.AUTO_SCROLL_EDGE) body.scrollTop += CONFIG.AUTO_SCROLL_SPEED;
      }
    }
    autoScrollRAF=requestAnimationFrame(tick);
  };
  autoScrollRAF=requestAnimationFrame(tick);
}
function stopAutoScroll(){ if(autoScrollRAF) cancelAnimationFrame(autoScrollRAF); autoScrollRAF=null; }

/* ---- Column drag ---- */
function onColPointerDown(e){
  if(e.button!==0) return;
  if(e.target.closest('.col-menu-btn')) return;
  if(e.target.closest('[contenteditable]')) return;
  // only drag via header
  const header=e.currentTarget;
  const colId=header.dataset.colId;
  const colEl=header.closest('.column');
  const startX=e.clientX, startY=e.clientY;
  let moved=false;
  const pid=e.pointerId;
  const onMove=(ev)=>{
    if(ev.pointerId!==pid) return;
    const dx=ev.clientX - startX;
    if(!moved && Math.abs(dx) < CONFIG.DRAG_THRESHOLD) return;
    if(!moved){ moved=true; startColDrag(colEl, colId, ev); }
    if(drag) updateColDrag(ev);
  };
  const onUp=(ev)=>{
    if(ev.pointerId!==pid) return;
    colEl.releasePointerCapture?.(pid);
    window.removeEventListener('pointermove', onMove);
    window.removeEventListener('pointerup', onUp);
    if(drag && drag.type==='col') finishColDrag(false);
  };
  colEl.setPointerCapture(pid);
  window.addEventListener('pointermove', onMove);
  window.addEventListener('pointerup', onUp);
  // don't prevent default to allow text selection? we want drag so prevent
  // e.preventDefault();
}

function startColDrag(colEl, colId, e){
  const rect=colEl.getBoundingClientRect();
  const idx=board.columns.findIndex(c=>c.id===colId);
  const ph=document.createElement('div');
  ph.className='placeholder';
  ph.style.width=rect.width+'px';
  ph.style.height=rect.height+'px';
  ph.style.flexShrink='0';
  colEl.insertAdjacentElement('afterend', ph);
  colEl.style.visibility='hidden';
  colEl.style.pointerEvents='none';

  const ghost=colEl.cloneNode(true);
  ghost.classList.add('ghost-card');
  ghost.style.width=rect.width+'px';
  ghost.style.height=rect.height+'px';
  // height already set via clone; position fixed
  ghost.style.visibility='visible';
  ghost.style.pointerEvents='none';
  ghost.style.left=(e.clientX - rect.width/2)+'px';
  ghost.style.top=(rect.top)+'px';
  ghost.style.transform='rotate(2deg) scale(1.02)';
  document.body.appendChild(ghost);

  drag={type:'col', id:colId, origIndex:idx, currentIndex:idx, colEl, placeholder:ph, ghost, offsetX: rect.width/2, pointerId:e.pointerId, _lastX:e.clientX};
  document.body.style.userSelect='none';
  document.body.style.cursor='grabbing';
  startAutoScroll();
}
function updateColDrag(e){
  if(!drag || drag.type!=='col') return;
  drag.ghost.style.left=(e.clientX - drag.offsetX)+'px';
  // find target index by x
  const cols=[...document.querySelectorAll('.column:not(.ghost-card)')].filter(el=> el.style.visibility!=='hidden');
  // including placeholder position? we compute based on existing columns order in DOM (excluding hidden)
  // simpler: compute insert index from board.columns order + pointer x vs column centers
  const boardRect=elBoard.getBoundingClientRect();
  const x=e.clientX;
  // get columns in DOM order (visible)
  const visibleCols=[...elBoard.querySelectorAll('.column')].filter(c=>c!==drag.colEl);
  let targetIdx=0;
  if(visibleCols.length===0) targetIdx=0;
  else{
    let placed=false;
    for(let i=0;i<visibleCols.length;i++){
      const r=visibleCols[i].getBoundingClientRect();
      const mid=r.left + r.width/2;
      if(x < mid){ targetIdx=i; placed=true; break; }
    }
    if(!placed) targetIdx=visibleCols.length;
  }
  // account for original position shift? We'll just move placeholder DOM
  // Move placeholder to targetIdx position among columns container
  // elBoard children includes columns + add-col-card at end
  const addCardEl=document.getElementById('addColumnCard');
  const colsInBoard=[...elBoard.children].filter(ch=> ch.classList.contains('column') && ch!==drag.colEl);
  // drag.placeholder is currently somewhere; remove and reinsert
  drag.placeholder.remove();
  if(targetIdx >= colsInBoard.length){
    elBoard.insertBefore(drag.placeholder, addCardEl);
  } else {
    elBoard.insertBefore(drag.placeholder, colsInBoard[targetIdx]);
  }
  drag.currentIndex=targetIdx;
  // auto adjust for original position: if origIndex < targetIdx, DOM index shifts
  // But we use DOM insertion above which already accounts visual; model update will map.
  drag._lastX=e.clientX; drag._lastY=e.clientY;
}
function finishColDrag(cancelled){
  if(!drag || drag.type!=='col') return;
  stopAutoScroll();
  document.body.style.userSelect=''; document.body.style.cursor='';
  const {colEl, placeholder, ghost, origIndex, currentIndex, id}=drag;
  placeholder.remove();
  ghost.remove();
  colEl.style.visibility=''; colEl.style.pointerEvents='';
  if(cancelled){ drag=null; return; }
  // apply to model: need to translate DOM targetIdx to model index
  // The currentIndex we computed is insertion index among other cols (excluding dragged)
  // That's exactly the model insertion index after removal.
  let from=board.columns.findIndex(c=>c.id===id);
  if(from<0){ drag=null; return; }
  const [moved]=board.columns.splice(from,1);
  let to=currentIndex;
  // If from < to, to is already adjusted? Our currentIndex was computed excluding dragged element,
  // so after removal it's correct. No further adjustment.
  to=Math.max(0, Math.min(to, board.columns.length));
  board.columns.splice(to,0,moved);
  drag=null;
  save(); render();
}

/* =========================================================
   EXPOSED FOR TESTS (probe)
   ========================================================= */
window.__board = ()=> clone(board);
window.__setBoard = (b)=>{ board=clone(b); save(); render(); };
window.__dragState = ()=> drag ? {type:drag.type, currentColId:drag.currentColId, currentIndex:drag.currentIndex} : null;

/* =========================================================
   INIT
   ========================================================= */
load();
render();
updateStats();

// close modal on Esc already handled
</script>
</body>
</html>