← Kanban results

Kanban

muse spark 1.2 · basic

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 39,033 bytes · SHA-256 d35cf5febff6
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Kanban — Project Board</title>
<style>
  :root{
    --bg:#f8f9fb;
    --bg-column:#f1f2f4;
    --card:#ffffff;
    --text:#172b4d;
    --muted:#6b778c;
    --border:#e2e8f0;
    --accent:#0052cc;
    --accent-2:#0c66e4;
    --red:#de350b;
    --shadow:0 1px 3px rgba(9,30,66,.13),0 0 1px rgba(9,30,66,.13);
    --shadow-lg:0 8px 16px rgba(9,30,66,.16),0 0 1px rgba(9,30,66,.13);
    --radius:12px;
    --radius-card:10px;
  }
  *{box-sizing:border-box}
  html,body{margin:0;height:100%;font-family:Inter,ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,Helvetica,Arial;background:var(--bg);color:var(--text)}
  button{font:inherit}
  /* Header */
  .app-header{
    position:sticky;top:0;z-index:30;
    backdrop-filter:blur(8px);
    background:rgba(248,249,251,.9);
    border-bottom:1px solid var(--border);
    padding:14px 20px;
    display:flex;align-items:center;gap:16px;flex-wrap:wrap;
  }
  .brand{display:flex;align-items:center;gap:10px;font-weight:750;letter-spacing:-.02em}
  .brand-mark{width:32px;height:32px;border-radius:9px;background:linear-gradient(135deg,#0052cc,#2684ff);display:grid;place-items:center;color:#fff;font-weight:900;font-size:14px;box-shadow:0 2px 8px rgba(0,82,204,.35)}
  .brand small{font-weight:500;color:var(--muted);font-size:12px;margin-left:6px}
  .header-actions{margin-left:auto;display:flex;align-items:center;gap:10px;flex-wrap:wrap}
  .search-wrap{position:relative}
  .search-wrap input{
    width:240px;max-width:48vw;padding:9px 12px 9px 36px;border:1px solid var(--border);border-radius:999px;background:#fff;outline:none;font-size:13.5px;
  }
  .search-wrap svg{position:absolute;left:11px;top:50%;transform:translateY(-50%);color:var(--muted)}
  .filter-bar{display:flex;align-items:center;gap:8px;flex-wrap:wrap}
  .chip{padding:6px 10px;border-radius:999px;border:1px solid var(--border);background:#fff;font-size:12px;font-weight:600;display:inline-flex;align-items:center;gap:6px;cursor:pointer;user-select:none;transition:.15s}
  .chip.active{border-color:transparent;color:#fff}
  .dot{width:10px;height:10px;border-radius:999px;display:inline-block}
  .btn{padding:8px 14px;border-radius:999px;border:1px solid var(--border);background:#fff;cursor:pointer;font-weight:600;font-size:13px}
  .btn-primary{background:var(--accent);color:#fff;border-color:var(--accent);box-shadow:0 1px 2px rgba(0,0,0,.08)}
  .btn-primary:hover{background:var(--accent-2)}
  .btn-ghost{background:transparent;border-color:transparent}
  /* Board */
  .board-wrap{overflow-x:auto;overflow-y:hidden;height:calc(100vh - 76px);scroll-behavior:auto}
  .board{display:flex;gap:16px;padding:18px 20px;align-items:flex-start;min-height:100%;width:max-content;min-width:100%}
  .column{
    width:280px;min-width:280px;max-width:280px;
    background:var(--bg-column);border-radius:12px;border:1px solid #e9eaf0;
    display:flex;flex-direction:column;max-height:calc(100vh - 116px);box-shadow:0 1px 0 rgba(9,30,66,.08);
    position:relative;
  }
  .column.wip-exceeded{border-color:#ff9e9e;box-shadow:0 0 0 2px #ffebe6}
  .column.wip-exceeded .col-header{color:var(--red)}
  .col-header{padding:12px 12px 8px 12px;display:flex;align-items:center;gap:8px;cursor:grab;user-select:none;flex:none;border-radius:12px 12px 0 0}
  .col-header:active{cursor:grabbing}
  .col-title{font-size:13px;font-weight:700;letter-spacing:.01em;flex:1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
  .col-title-input{flex:1;font-size:13px;font-weight:700;padding:4px 6px;border:1px solid var(--accent);border-radius:6px;outline:none;background:#fff}
  .count{font-size:11px;font-weight:700;color:var(--muted);background:#e9eaf0;padding:2px 7px;border-radius:999px;min-width:22px;text-align:center}
  .count.over{background:#ffebe6;color:var(--red)}
  .icon-btn{width:28px;height:28px;display:grid;place-items:center;border-radius:8px;border:1px solid transparent;background:transparent;color:var(--muted);cursor:pointer}
  .icon-btn:hover{background:#e2e6ea;color:var(--text);border-color:#dde1e7}
  .col-menu{position:absolute;top:44px;right:8px;background:#fff;border:1px solid var(--border);border-radius:10px;box-shadow:var(--shadow-lg);padding:6px;min-width:180px;z-index:5;display:none}
  .col-menu.open{display:block}
  .menu-item{padding:8px 10px;border-radius:8px;font-size:13px;cursor:pointer;display:flex;align-items:center;gap:8px}
  .menu-item:hover{background:#f1f5f9}
  .menu-item.danger{color:var(--red)}
  .cards{flex:1;overflow-y:auto;overflow-x:hidden;padding:4px 8px 8px 8px;display:flex;flex-direction:column;gap:10px;min-height:40px;scrollbar-width:thin}
  .cards::-webkit-scrollbar{width:6px}
  .card{
    background:var(--card);border:1px solid #ebecf0;border-radius:10px;padding:11px 12px;box-shadow:var(--shadow);
    cursor:grab;position:relative;transition:transform .12s ease,box-shadow .12s ease,border-color .12s;
    user-select:none;
  }
  .card:hover{transform:translateY(-1px);box-shadow:0 4px 10px rgba(9,30,66,.12);border-color:#dde1e7}
  .card.dragging-original{opacity:.0;pointer-events:none}
  .card.filtered{opacity:.35;filter:saturate(.6)}
  .card.filtered.hidden-search{display:none}
  .card-title{font-size:13.5px;font-weight:600;line-height:1.35;word-break:break-word}
  .card-desc{font-size:12.5px;color:var(--muted);margin-top:6px;line-height:1.4;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
  .labels{margin-top:8px;display:flex;gap:6px;flex-wrap:wrap}
  .label-chip{font-size:10.5px;font-weight:700;letter-spacing:.02em;padding:3px 7px;border-radius:999px;color:#fff}
  .meta{margin-top:8px;display:flex;align-items:center;gap:8px;font-size:11.5px;color:var(--muted)}
  .due{padding:3px 7px;border-radius:6px;background:#f1f5f9;border:1px solid #e2e8f0;font-weight:600}
  .due.overdue{background:#ffebe6;color:#bf2600;border-color:#ffbdad}
  .due.soon{background:#fff8e6;color:#974f0c;border-color:#ffe8a3}
  .empty-drop{
    border:1.5px dashed #c1c7d0;border-radius:10px;padding:18px;display:grid;place-items:center;color:var(--muted);font-size:12.5px;font-weight:500;background:rgba(255,255,255,.5);min-height:84px
  }
  .placeholder{
    border:1.5px dashed #4c9aff;background:#e9f2ff;border-radius:10px;min-height:72px;transition:all .12s ease;
  }
  .ghost{
    position:fixed;pointer-events:none;z-index:9999;transform:rotate(3deg) scale(1.03);box-shadow:var(--shadow-lg);opacity:.96;
    width:280px;
  }
  .column.ghost-col{opacity:.9;transform:rotate(1.2deg) scale(1.02);box-shadow:var(--shadow-lg);pointer-events:none;position:fixed;z-index:9998}
  .column.dragging-col{opacity:.3}
  .placeholder-col{width:280px;min-width:280px;background:#dde3ea;border:1.5px dashed #9fb0c9;border-radius:12px;flex:none}
  .add-card-btn{margin:8px 8px 10px 8px;padding:9px;border-radius:8px;border:none;background:transparent;color:var(--muted);font-weight:600;font-size:13px;cursor:pointer;display:flex;align-items:center;gap:6px}
  .add-card-btn:hover{background:#e2e6ea;color:var(--text)}
  .add-column{width:280px;min-width:280px;height:80px;border:1.5px dashed #c1c7d0;border-radius:12px;display:grid;place-items:center;background:rgba(255,255,255,.35);color:var(--muted);font-weight:650;cursor:pointer}
  .add-column:hover{background:#fff;border-color:#a5adba;color:var(--text)}
  /* Inline add */
  .inline-add{padding:0 8px 10px 8px}
  .inline-add textarea{width:100%;min-height:72px;padding:10px;border:1px solid #b6c2cf;border-radius:10px;outline:none;resize:vertical;font:inherit;font-size:13.5px;background:#fff}
  .inline-actions{display:flex;gap:8px;margin-top:8px}
  /* Modal */
  .overlay{position:fixed;inset:0;background:rgba(9,30,66,.42);display:none;align-items:center;justify-content:center;z-index:50;padding:20px}
  .overlay.open{display:flex}
  .modal{width:480px;max-width:100%;background:#fff;border-radius:16px;box-shadow:0 24px 48px rgba(0,0,0,.2);overflow:hidden;max-height:90vh;display:flex;flex-direction:column}
  .modal-head{padding:16px 18px;border-bottom:1px solid var(--border);display:flex;align-items:center;gap:12px}
  .modal-body{padding:18px;overflow:auto;display:flex;flex-direction:column;gap:14px}
  .field label{font-size:12px;font-weight:700;color:var(--muted);letter-spacing:.04em;text-transform:uppercase}
  .field input,.field textarea{width:100%;margin-top:6px;padding:10px 12px;border:1px solid #dfe1e6;border-radius:10px;outline:none;font:inherit;font-size:14px;background:#fff}
  .field textarea{min-height:84px;resize:vertical}
  .labels-grid{display:flex;flex-wrap:wrap;gap:8px;margin-top:8px}
  .labels-grid .chip{padding:7px 12px}
  .modal-foot{padding:14px 18px;border-top:1px solid var(--border);display:flex;justify-content:space-between;align-items:center;gap:10px}
  .toast{
    position:fixed;left:50%;bottom:20px;transform:translateX(-50%);background:#172b4d;color:#fff;padding:12px 14px;border-radius:999px;display:none;align-items:center;gap:12px;box-shadow:0 8px 24px rgba(0,0,0,.25);z-index:60;font-size:13px;font-weight:600
  }
  .toast.show{display:flex}
  .toast button{background:#fff;color:#172b4d;border:none;padding:6px 12px;border-radius:999px;font-weight:800;cursor:pointer}
  .wip-input{width:80px;padding:7px 9px;border:1px solid var(--border);border-radius:8px}
  .drag-hint{font-size:11px;color:var(--muted);font-weight:500}
</style>
</head>
<body>
  <header class="app-header">
    <div class="brand">
      <div class="brand-mark">▦</div>
      <div>Boardly <small>Kanban</small></div>
      <span class="drag-hint" style="margin-left:10px">Drag cards & columns • Esc to cancel</span>
    </div>
    <div class="filter-bar" id="filterBar"></div>
    <div class="header-actions">
      <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 20-3.5-3.5"/></svg>
        <input id="searchInput" placeholder="Search cards…" autocomplete="off">
      </div>
      <button class="btn" id="clearFiltersBtn">Clear filters</button>
    </div>
  </header>

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

  <div class="overlay" id="cardModal">
    <div class="modal" role="dialog" aria-modal="true">
      <div class="modal-head">
        <div style="font-weight:800">Edit card</div>
        <button class="icon-btn" style="margin-left:auto" onclick="closeModal()">✕</button>
      </div>
      <div class="modal-body">
        <div class="field"><label>Title</label><input id="mTitle"></div>
        <div class="field"><label>Description</label><textarea id="mDesc" placeholder="Add a detailed description…"></textarea></div>
        <div class="field"><label>Due date</label><input type="date" id="mDue"></div>
        <div class="field"><label>Labels</label><div class="labels-grid" id="mLabels"></div></div>
      </div>
      <div class="modal-foot">
        <button class="btn" style="color:var(--red);border-color:#ffbdad;background:#ffebe6" id="deleteCardBtn">Delete card</button>
        <div style="display:flex;gap:8px">
          <button class="btn btn-ghost" onclick="closeModal()">Cancel</button>
          <button class="btn btn-primary" id="saveCardBtn">Save</button>
        </div>
      </div>
    </div>
  </div>

  <div class="toast" id="toast"><span id="toastMsg">Deleted</span><button id="undoBtn">Undo</button></div>

<script>
// ===================== TUNABLE CONSTANTS =====================
const CONFIG = {
  STORAGE_KEY: 'kanban-board-v2-ultra',
  DRAG_THRESHOLD: 6,          // px before drag starts
  AUTO_SCROLL_EDGE: 72,       // px from edge to trigger scroll
  AUTO_SCROLL_SPEED: 14,      // px per frame
  COLUMN_WIDTH: 280,
  LABELS: [
    {id:'l1', name:'Bug', color:'#ef4444'},
    {id:'l2', name:'Feature', color:'#0ea5e9'},
    {id:'l3', name:'Design', color:'#8b5cf6'},
    {id:'l4', name:'Research', color:'#f59e0b'},
    {id:'l5', name:'Improvement', color:'#10b981'},
    {id:'l6', name:'Urgent', color:'#ec4899'},
  ]
};
// ===================== STATE =====================
let board = null;
let editingCard = null; // {colId, cardId}
let undoStack = [];
let filterState = {search:'', labels:new Set()};

function uid(){ return Math.random().toString(36).slice(2,9)+Date.now().toString(36).slice(-4) }

function defaultBoard(){
  const cols = ['Backlog','To Do','In Progress','Review','Done'].map(t=>({id:uid(),title:t,wip:null,cards:[]}));
  cols[0].cards = [
    {id:uid(),title:'Research competitor pricing',description:'Collect data from Linear, Trello and Asana for comparison table.',labels:['l4'],dueDate:''},
    {id:uid(),title:'Setup project repo & CI',description:'Init vite, husky, github actions.',labels:['l2'],dueDate:''},
  ];
  cols[1].cards = [
    {id:uid(),title:'Design kanban drag interactions',description:'Spec live indicator, auto-scroll and escape cancel.',labels:['l3','l6'],dueDate:daysFromNow(2)},
    {id:uid(),title:'Build column menu + WIP limits',description:'Highlight red when exceeded but never block drops.',labels:['l2'],dueDate:''},
  ];
  cols[2].cards = [
    {id:uid(),title:'Implement card CRUD modal',description:'Title, desc, labels, due date.',labels:['l2','l1'],dueDate:daysFromNow(1)},
  ];
  cols[3].cards = [];
  cols[4].cards = [
    {id:uid(),title:'Initial release notes',description:'Changelog for v0.1',labels:['l5'],dueDate:''},
  ];
  return {columns:cols}
}
function daysFromNow(n){ const d=new Date(); d.setDate(d.getDate()+n); return d.toISOString().slice(0,10)}

function load(){
  try{
    const raw=localStorage.getItem(CONFIG.STORAGE_KEY);
    if(!raw) return null;
    const parsed=JSON.parse(raw);
    if(!parsed.columns) return null;
    filterState.search = parsed.filterState?.search||'';
    filterState.labels = new Set(parsed.filterState?.labels||[]);
    return {columns:parsed.columns};
  }catch{ return null}
}
function save(){
  localStorage.setItem(CONFIG.STORAGE_KEY, JSON.stringify({
    columns:board.columns,
    filterState:{search:filterState.search, labels:[...filterState.labels]}
  }));
}
function pushUndo(msg, snapshot){
  undoStack.push({msg, snapshot: JSON.stringify(snapshot)});
  if(undoStack.length>20) undoStack.shift();
  showToast(msg);
}

// ===================== RENDER =====================
const boardEl = document.getElementById('board');
const boardWrap = document.getElementById('boardWrap');
const filterBar = document.getElementById('filterBar');
const searchInput = document.getElementById('searchInput');

function labelDef(id){ return CONFIG.LABELS.find(l=>l.id===id) }

function renderFilters(){
  filterBar.innerHTML='';
  CONFIG.LABELS.forEach(l=>{
    const active = filterState.labels.has(l.id);
    const b=document.createElement('button');
    b.className='chip'+(active?' active':'');
    b.style.background = active? l.color : '#fff';
    b.style.color = active? '#fff' : 'var(--text)';
    b.innerHTML=`<span class="dot" style="background:${l.color};border:1px solid rgba(0,0,0,.08)"></span>${l.name}`;
    b.onclick=()=>{
      if(active) filterState.labels.delete(l.id); else filterState.labels.add(l.id);
      save(); render();
    };
    filterBar.appendChild(b);
  });
  searchInput.value = filterState.search;
}

function cardMatches(card){
  const q = filterState.search.trim().toLowerCase();
  const bySearch = !q || card.title.toLowerCase().includes(q);
  const byLabels = filterState.labels.size===0 || card.labels.some(id=>filterState.labels.has(id));
  return bySearch && byLabels;
}

function renderBoard(){
  boardEl.innerHTML='';
  board.columns.forEach((col, colIndex)=>{
    const colEl=document.createElement('div');
    colEl.className='column'+(col.wip!=null && col.cards.length>col.wip ? ' wip-exceeded':'');
    colEl.dataset.colId=col.id;
    colEl.dataset.index=colIndex;

    const header=document.createElement('div');
    header.className='col-header';
    header.innerHTML=`
      <span style="color:#8b95a5;cursor:grab">⠿</span>
      <div class="col-title" data-col-title>${escapeHtml(col.title)}</div>
      <span class="count ${col.wip!=null && col.cards.length>col.wip?'over':''}">${col.cards.length}${col.wip!=null? ' / '+col.wip : ''}</span>
      <button class="icon-btn col-menu-btn" title="Column menu">⋯</button>
    `;
    // title inline edit
    const titleEl = header.querySelector('[data-col-title]');
    titleEl.addEventListener('click', (e)=>{
      e.stopPropagation();
      const input=document.createElement('input');
      input.className='col-title-input'; input.value=col.title;
      titleEl.replaceWith(input); input.focus(); input.select();
      const commit=()=>{
        const v=input.value.trim();
        if(v && v!==col.title){ col.title=v; save(); renderBoard(); }
        else renderBoard();
      };
      input.addEventListener('keydown',ev=>{
        if(ev.key==='Enter') commit();
        if(ev.key==='Escape') renderBoard();
      });
      input.addEventListener('blur',commit);
    });
    // column drag handle is header
    header.addEventListener('pointerdown', (e)=> startColumnDrag(e, col.id));
    // menu
    const menuBtn = header.querySelector('.col-menu-btn');
    const menu = document.createElement('div');
    menu.className='col-menu';
    menu.innerHTML=`
      <div class="menu-item" data-action="rename">✎ Rename column</div>
      <div class="menu-item" data-action="wip">⚑ WIP limit: <input class="wip-input" type="number" min="0" placeholder="—" value="${col.wip??''}" style="margin-left:6px"> </div>
      <div class="menu-item danger" data-action="delete">🗑 Delete column</div>
    `;
    menuBtn.addEventListener('click',(e)=>{
      e.stopPropagation();
      document.querySelectorAll('.col-menu.open').forEach(m=>{ if(m!==menu) m.classList.remove('open')});
      menu.classList.toggle('open');
    });
    menu.addEventListener('click', (e)=>{
      const act = e.target.closest('.menu-item')?.dataset.action;
      if(act==='rename'){
        menu.classList.remove('open');
        titleEl.click();
      }
      if(act==='delete'){
        if(board.columns.length<=1){ alert('Keep at least one column'); return; }
        pushUndo('Column deleted', board);
        board.columns = board.columns.filter(c=>c.id!==col.id);
        save(); render();
      }
    });
    const wipInput = menu.querySelector('.wip-input');
    wipInput.addEventListener('keydown', e=>{
      if(e.key==='Enter'){
        const v=wopVal(wipInput.value);
        col.wip=v; save(); renderBoard();
      }
      if(e.key==='Escape') renderBoard();
    });
    wipInput.addEventListener('blur',()=>{
      const v=wopVal(wipInput.value); col.wip=v; save(); renderBoard();
    });
    wipInput.addEventListener('click',e=>e.stopPropagation());
    function wopVal(v){ if(v===''||v==null) return null; const n=parseInt(v,10); return isNaN(n)?null:n }

    colEl.appendChild(header);
    colEl.appendChild(menu);

    const cardsWrap=document.createElement('div');
    cardsWrap.className='cards';
    cardsWrap.dataset.cardsWrap='1';

    if(col.cards.length===0){
      const empty=document.createElement('div');
      empty.className='empty-drop';
      empty.textContent='Drop cards here';
      cardsWrap.appendChild(empty);
    }
    col.cards.forEach(card=>{
      const cEl=makeCardEl(card, col.id);
      // filtering: hide via class
      const matches = cardMatches(card);
      if(!matches){
        // keep layout but dim; if search active hide completely for clarity
        if(filterState.search) cEl.classList.add('hidden-search');
        else cEl.classList.add('filtered');
      }
      cardsWrap.appendChild(cEl);
    });
    colEl.appendChild(cardsWrap);

    const addBtn=document.createElement('button');
    addBtn.className='add-card-btn';
    addBtn.innerHTML=`<span style="font-size:16px">+</span> Add card`;
    addBtn.onclick=()=> showInlineAdd(col.id, colEl);
    colEl.appendChild(addBtn);

    boardEl.appendChild(colEl);
  });
  // add column tile
  const addCol=document.createElement('div');
  addCol.className='add-column';
  addCol.textContent='+ Add column';
  addCol.onclick=()=>{
    const title=prompt('Column title','New column');
    if(!title) return;
    board.columns.push({id:uid(),title:title.trim(),wip:null,cards:[]});
    save(); render();
  };
  boardEl.appendChild(addCol);

  // close menus on click outside
  document.addEventListener('click',()=> document.querySelectorAll('.col-menu.open').forEach(m=>m.classList.remove('open')),{once:true});

  renderFilters();
}

function makeCardEl(card, colId){
  const el=document.createElement('div');
  el.className='card';
  el.dataset.cardId=card.id;
  el.dataset.colId=colId;
  const dueHtml = card.dueDate ? `<span class="due ${dueClass(card.dueDate)}">${formatDue(card.dueDate)}</span>` : '';
  el.innerHTML=`
    <div class="card-title">${escapeHtml(card.title)}</div>
    ${card.description? `<div class="card-desc">${escapeHtml(card.description)}</div>`:''}
    ${card.labels.length? `<div class="labels">${card.labels.map(id=>{const d=labelDef(id); return `<span class="label-chip" style="background:${d.color}">${d.name}</span>`}).join('')}</div>`:''}
    ${dueHtml? `<div class="meta">${dueHtml}</div>`:''}
  `;
  el.addEventListener('pointerdown', (e)=> startCardDrag(e, card.id, colId));
  el.addEventListener('click', (e)=>{
    if(dragState && dragState.hasMoved) return;
    if(e.target.closest('button')) return;
    openModal(colId, card.id);
  });
  return el;
}
function formatDue(s){
  const d=new Date(s); const now=new Date(); now.setHours(0,0,0,0);
  const diff=Math.round((d - now)/86400000);
  if(diff===0) return 'Today';
  if(diff===1) return 'Tomorrow';
  if(diff===-1) return 'Yesterday';
  return d.toLocaleDateString(undefined,{month:'short',day:'numeric'});
}
function dueClass(s){
  const d=new Date(s); d.setHours(0,0,0,0);
  const now=new Date(); now.setHours(0,0,0,0);
  const diff=(d-now)/86400000;
  if(diff<0) return 'overdue';
  if(diff<=1) return 'soon';
  return '';
}
function escapeHtml(s){ return s.replace(/[&<>"']/g, c=>({ '&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c])) }

// ===================== INLINE ADD =====================
function showInlineAdd(colId, colEl){
  const existing = colEl.querySelector('.inline-add');
  if(existing) return;
  const wrap=document.createElement('div');
  wrap.className='inline-add';
  wrap.innerHTML=`
    <textarea placeholder="Enter card title…"></textarea>
    <div class="inline-actions">
      <button class="btn btn-primary">Add</button>
      <button class="btn btn-ghost">Cancel</button>
    </div>
  `;
  const cardsWrap = colEl.querySelector('.cards');
  cardsWrap.appendChild(wrap);
  const ta=wrap.querySelector('textarea'); ta.focus();
  const add=wrap.querySelector('.btn-primary');
  const cancel=wrap.querySelector('.btn-ghost');
  const commit=()=>{
    const t=ta.value.trim();
    if(!t) return;
    const col=board.columns.find(c=>c.id===colId);
    col.cards.push({id:uid(),title:t,description:'',labels:[],dueDate:''});
    save(); render();
  };
  add.onclick=commit;
  ta.addEventListener('keydown', e=>{
    if(e.key==='Enter' && !e.shiftKey){ e.preventDefault(); commit(); }
    if(e.key==='Escape'){ wrap.remove(); }
  });
  cancel.onclick=()=>wrap.remove();
}

// ===================== MODAL =====================
const modalEl=document.getElementById('cardModal');
const mTitle=document.getElementById('mTitle');
const mDesc=document.getElementById('mDesc');
const mDue=document.getElementById('mDue');
const mLabelsEl=document.getElementById('mLabels');
function openModal(colId, cardId){
  const col=board.columns.find(c=>c.id===colId);
  const card=col.cards.find(c=>c.id===cardId);
  editingCard={colId,cardId};
  mTitle.value=card.title;
  mDesc.value=card.description;
  mDue.value=card.dueDate||'';
  mLabelsEl.innerHTML='';
  CONFIG.LABELS.forEach(l=>{
    const active=card.labels.includes(l.id);
    const b=document.createElement('button');
    b.className='chip'+(active?' active':'');
    b.style.background= active? l.color : '#fff';
    b.style.borderColor= active? 'transparent' : 'var(--border)';
    b.style.color= active? '#fff' : 'var(--text)';
    b.innerHTML=`<span class="dot" style="background:${l.color}"></span>${l.name}`;
    b.onclick=()=>{
      if(card.labels.includes(l.id)) card.labels=card.labels.filter(x=>x!==l.id);
      else card.labels.push(l.id);
      openModal(colId,cardId);
    };
    mLabelsEl.appendChild(b);
  });
  modalEl.classList.add('open');
  mTitle.focus(); mTitle.select();
}
function closeModal(){ modalEl.classList.remove('open'); editingCard=null; }
modalEl.addEventListener('click', e=>{ if(e.target===modalEl) closeModal(); });
document.getElementById('saveCardBtn').onclick=()=>{
  if(!editingCard) return;
  const col=board.columns.find(c=>c.id===editingCard.colId);
  const card=col.cards.find(c=>c.id===editingCard.cardId);
  const t=mTitle.value.trim();
  if(!t){ alert('Title required'); return; }
  card.title=t; card.description=mDesc.value; card.dueDate=mDue.value;
  save(); closeModal(); render();
};
document.getElementById('deleteCardBtn').onclick=()=>{
  if(!editingCard) return;
  const col=board.columns.find(c=>c.id===editingCard.colId);
  pushUndo('Card deleted', board);
  col.cards = col.cards.filter(c=>c.id!==editingCard.cardId);
  save(); closeModal(); render();
};

// ===================== DRAG & DROP (pointer events) =====================
let dragState=null;
let autoScrollRAF=null;
let lastPointer={x:0,y:0};

function startCardDrag(e, cardId, colId){
  if(e.button!==0) return;
  // don't start if clicking input
  if(e.target.closest('input,textarea,button')) return;
  const cardEl = e.currentTarget;
  const rect = cardEl.getBoundingClientRect();
  const offsetX = e.clientX - rect.left;
  const offsetY = e.clientY - rect.top;
  const col = board.columns.find(c=>c.id===colId);
  const idx = col.cards.findIndex(c=>c.id===cardId);
  dragState={
    type:'card', cardId, colId, sourceIndex:idx, sourceColId:colId,
    offsetX, offsetY, startX:e.clientX, startY:e.clientY,
    hasMoved:false, ghost:null, placeholder:null, origCardEl:cardEl,
    currentColId:colId, currentIndex:idx
  };
  cardEl.setPointerCapture?.(e.pointerId);
  window.addEventListener('pointermove', onPointerMove);
  window.addEventListener('pointerup', onPointerUp);
  window.addEventListener('keydown', onKeyDown);
  e.preventDefault();
}

function startColumnDrag(e, colId){
  if(e.button!==0) return;
  // only drag via header
  if(e.target.closest('.icon-btn, input')) return;
  const colEl = e.currentTarget.closest('.column');
  const rect = colEl.getBoundingClientRect();
  const startX=e.clientX, startY=e.clientY;
  dragState={
    type:'column', colId,
    startX, startY, offsetX: e.clientX - rect.left, offsetY: e.clientY - rect.top,
    hasMoved:false, ghost:null, placeholder:null, origColEl:colEl
  };
  window.addEventListener('pointermove', onPointerMoveCol);
  window.addEventListener('pointerup', onPointerUpCol);
  window.addEventListener('keydown', onKeyDown);
  e.preventDefault();
}

function ensureCardDragStarted(){
  if(!dragState || dragState.type!=='card' || dragState.hasMoved) return;
  dragState.hasMoved=true;
  const cardEl=dragState.origCardEl;
  // ghost
  const ghost=cardEl.cloneNode(true);
  ghost.classList.add('ghost');
  ghost.style.width = cardEl.offsetWidth+'px';
  ghost.style.height = cardEl.offsetHeight+'px';
  document.body.appendChild(ghost);
  dragState.ghost=ghost;
  // placeholder
  const ph=document.createElement('div');
  ph.className='placeholder';
  ph.style.height=cardEl.offsetHeight+'px';
  dragState.placeholder=ph;
  // hide original
  cardEl.classList.add('dragging-original');
  // insert placeholder at original position
  cardEl.parentNode.insertBefore(ph, cardEl.nextSibling);
  // start auto scroll loop
  startAutoScroll();
  updateGhostPos(lastPointer.x, lastPointer.y);
}

function onPointerMove(e){
  lastPointer={x:e.clientX,y:e.clientY};
  if(!dragState || dragState.type!=='card') return;
  const dist=Math.hypot(e.clientX-dragState.startX, e.clientY-dragState.startY);
  if(!dragState.hasMoved && dist < CONFIG.DRAG_THRESHOLD) return;
  if(!dragState.hasMoved) ensureCardDragStarted();
  if(!dragState.hasMoved) return;
  updateGhostPos(e.clientX, e.clientY);
  // batched hit test (read DOM)
  requestAnimationFrame(()=> doCardHitTest(e.clientX, e.clientY));
}
function updateGhostPos(x,y){
  if(!dragState.ghost) return;
  dragState.ghost.style.left = (x - dragState.offsetX)+'px';
  dragState.ghost.style.top = (y - dragState.offsetY)+'px';
}
function doCardHitTest(x,y){
  if(!dragState || !dragState.placeholder) return;
  // find column under x
  const cols = [...document.querySelectorAll('.column')];
  let targetColId=null, targetColEl=null;
  for(const c of cols){
    const r=c.getBoundingClientRect();
    if(x >= r.left && x <= r.right){
      targetColId=c.dataset.colId;
      targetColEl=c;
      break;
    }
  }
  // if not over any column, pick nearest
  if(!targetColEl){
    // find closest column by horizontal distance
    let best=null,bestDist=Infinity;
    cols.forEach(c=>{
      const r=c.getBoundingClientRect();
      const cx=r.left + r.width/2;
      const d=Math.abs(x-cx);
      if(d<bestDist){bestDist=d; best=c}
    });
    if(best){ targetColEl=best; targetColId=best.dataset.colId; }
    else return;
  }
  const cardsWrap = targetColEl.querySelector('.cards');
  const cards = [...cardsWrap.querySelectorAll('.card:not(.dragging-original)')];
  // filter visible cards only? include placeholder sibling logic, but we exclude dragging original
  let insertBefore=null, index=0;
  if(cards.length===0){
    // empty column
    cardsWrap.appendChild(dragState.placeholder);
    index=0;
  } else {
    let placed=false;
    for(let i=0;i<cards.length;i++){
      const r=cards[i].getBoundingClientRect();
      const mid=r.top + r.height/2;
      if(y < mid){
        cardsWrap.insertBefore(dragState.placeholder, cards[i]);
        index=i;
        placed=true; break;
      }
    }
    if(!placed){
      cardsWrap.appendChild(dragState.placeholder);
      index=cards.length;
    }
  }
  dragState.currentColId=targetColId;
  dragState.currentIndex=index;
  // empty drop visual
  cardsWrap.querySelectorAll('.empty-drop').forEach(e=>{
    e.style.display = cardsWrap.contains(dragState.placeholder) ? 'none' : '';
  });
}

function onPointerUp(e){
  window.removeEventListener('pointermove', onPointerMove);
  window.removeEventListener('pointerup', onPointerUp);
  window.removeEventListener('keydown', onKeyDown);
  stopAutoScroll();
  if(!dragState) return;
  if(!dragState.hasMoved){
    dragState=null; return;
  }
  // commit to model
  const {cardId, sourceColId, currentColId, currentIndex, sourceIndex} = dragState;
  const sourceCol=board.columns.find(c=>c.id===sourceColId);
  const targetCol=board.columns.find(c=>c.id===currentColId);
  const cardIdx = sourceCol.cards.findIndex(c=>c.id===cardId);
  if(cardIdx!==-1){
    const [card] = sourceCol.cards.splice(cardIdx,1);
    // adjust index if same column and moving down
    let insertAt=currentIndex;
    if(sourceColId===currentColId && cardIdx < currentIndex) insertAt = currentIndex-1;
    // clamp
    insertAt = Math.max(0, Math.min(insertAt, targetCol.cards.length));
    targetCol.cards.splice(insertAt,0,card);
    save();
  }
  cleanupCardDrag();
  renderBoard();
}
function cleanupCardDrag(){
  if(dragState?.ghost) dragState.ghost.remove();
  if(dragState?.placeholder) dragState.placeholder.remove();
  if(dragState?.origCardEl) dragState.origCardEl.classList.remove('dragging-original');
  dragState=null;
  document.querySelectorAll('.empty-drop').forEach(e=>e.style.display='');
}
function onKeyDown(e){
  if(e.key==='Escape' && dragState){
    // cancel
    if(dragState.type==='card'){
      window.removeEventListener('pointermove', onPointerMove);
      window.removeEventListener('pointerup', onPointerUp);
      // also column listeners
      window.removeEventListener('pointermove', onPointerMoveCol);
      window.removeEventListener('pointerup', onPointerUpCol);
      window.removeEventListener('keydown', onKeyDown);
      stopAutoScroll();
      cleanupCardDrag();
      renderBoard();
    } else if(dragState.type==='column'){
      window.removeEventListener('pointermove', onPointerMoveCol);
      window.removeEventListener('pointerup', onPointerUpCol);
      window.removeEventListener('keydown', onKeyDown);
      stopAutoScroll();
      cleanupColumnDrag(true);
      renderBoard();
    }
  }
}

// -------- Column drag ----------
function onPointerMoveCol(e){
  lastPointer={x:e.clientX,y:e.clientY};
  if(!dragState || dragState.type!=='column') return;
  const dist=Math.hypot(e.clientX-dragState.startX, e.clientY-dragState.startY);
  if(!dragState.hasMoved && dist < CONFIG.DRAG_THRESHOLD) return;
  if(!dragState.hasMoved){
    dragState.hasMoved=true;
    const colEl=dragState.origColEl;
    const ghost=colEl.cloneNode(true);
    ghost.classList.add('ghost-col');
    ghost.style.width=colEl.offsetWidth+'px';
    ghost.style.height=colEl.offsetHeight+'px';
    ghost.style.left=(e.clientX - dragState.offsetX)+'px';
    ghost.style.top=(e.clientY - dragState.offsetY)+'px';
    document.body.appendChild(ghost);
    dragState.ghost=ghost;
    const ph=document.createElement('div');
    ph.className='placeholder-col';
    ph.style.height=colEl.offsetHeight+'px';
    dragState.placeholder=ph;
    colEl.classList.add('dragging-col');
    colEl.parentNode.insertBefore(ph, colEl.nextSibling);
    startAutoScroll();
  }
  if(dragState.ghost){
    dragState.ghost.style.left=(e.clientX - dragState.offsetX)+'px';
    dragState.ghost.style.top=(e.clientY - dragState.offsetY)+'px';
  }
  requestAnimationFrame(()=> doColumnHitTest(e.clientX));
}
function doColumnHitTest(x){
  if(!dragState.placeholder) return;
  const boardCols=[...boardEl.children].filter(el=>el.classList.contains('column')||el.classList.contains('placeholder-col'));
  // find insertion point by x mid
  let inserted=false;
  for(const el of boardCols){
    if(el===dragState.placeholder) continue;
    if(el.classList.contains('add-column')) continue;
    const r=el.getBoundingClientRect();
    const mid=r.left + r.width/2;
    if(x < mid){
      boardEl.insertBefore(dragState.placeholder, el);
      inserted=true; break;
    }
  }
  if(!inserted){
    const addColEl=boardEl.querySelector('.add-column');
    boardEl.insertBefore(dragState.placeholder, addColEl);
  }
}
function onPointerUpCol(e){
  window.removeEventListener('pointermove', onPointerMoveCol);
  window.removeEventListener('pointerup', onPointerUpCol);
  window.removeEventListener('keydown', onKeyDown);
  stopAutoScroll();
  if(!dragState) return;
  if(!dragState.hasMoved){ dragState=null; return; }
  // compute new index
  const cols=[...boardEl.children];
  const phIndex = cols.indexOf(dragState.placeholder);
  // count columns before placeholder excluding placeholder itself to map to board.columns index
  let boardIdx=0;
  for(let i=0;i<cols.length;i++){
    const el=cols[i];
    if(el===dragState.placeholder){ break; }
    if(el.classList.contains('column')) boardIdx++;
    if(el.classList.contains('placeholder-col')) continue;
  }
  // reorder board.columns
  const srcIdx = board.columns.findIndex(c=>c.id===dragState.colId);
  if(srcIdx!==-1){
    const [col] = board.columns.splice(srcIdx,1);
    let insertAt=boardIdx;
    if(srcIdx < boardIdx) insertAt = boardIdx-1;
    insertAt=Math.max(0,Math.min(insertAt, board.columns.length));
    board.columns.splice(insertAt,0,col);
    save();
  }
  cleanupColumnDrag(false);
  renderBoard();
}
function cleanupColumnDrag(cancel){
  if(dragState?.ghost) dragState.ghost.remove();
  if(dragState?.placeholder) dragState.placeholder.remove();
  if(dragState?.origColEl) dragState.origColEl.classList.remove('dragging-col');
  dragState=null;
}

// -------- Auto scroll ----------
function startAutoScroll(){
  if(autoScrollRAF) return;
  const tick=()=>{
    if(!dragState || !dragState.hasMoved){ stopAutoScroll(); return; }
    const x=lastPointer.x, y=lastPointer.y;
    // horizontal board scroll
    const bwRect=boardWrap.getBoundingClientRect();
    if(x < bwRect.left + CONFIG.AUTO_SCROLL_EDGE) boardWrap.scrollLeft -= CONFIG.AUTO_SCROLL_SPEED;
    else if(x > bwRect.right - CONFIG.AUTO_SCROLL_EDGE) boardWrap.scrollLeft += CONFIG.AUTO_SCROLL_SPEED;
    // vertical column scroll
    if(dragState.type==='card' && dragState.currentColId){
      const colEl=document.querySelector(`.column[data-col-id="${dragState.currentColId}"] .cards`);
      if(colEl){
        const r=colEl.getBoundingClientRect();
        if(y < r.top + 60) colEl.scrollTop -= CONFIG.AUTO_SCROLL_SPEED;
        else if(y > r.bottom - 60) colEl.scrollTop += CONFIG.AUTO_SCROLL_SPEED;
      }
    }
    autoScrollRAF=requestAnimationFrame(tick);
  };
  autoScrollRAF=requestAnimationFrame(tick);
}
function stopAutoScroll(){ if(autoScrollRAF){ cancelAnimationFrame(autoScrollRAF); autoScrollRAF=null; } }

// ===================== SEARCH & FILTERS =====================
searchInput.addEventListener('input', e=>{
  filterState.search=e.target.value;
  save(); render();
});
document.getElementById('clearFiltersBtn').onclick=()=>{
  filterState.search=''; filterState.labels.clear(); save(); render();
};

// ===================== TOAST / UNDO =====================
const toastEl=document.getElementById('toast');
const toastMsg=document.getElementById('toastMsg');
let toastTimer=null;
function showToast(msg){
  toastMsg.textContent=msg;
  toastEl.classList.add('show');
  clearTimeout(toastTimer);
  toastTimer=setTimeout(()=> toastEl.classList.remove('show'), 3500);
}
document.getElementById('undoBtn').onclick=()=>{
  if(!undoStack.length) return;
  const last=undoStack.pop();
  const snap=JSON.parse(last.snapshot);
  board = snap;
  save(); render();
  toastEl.classList.remove('show');
};

// ===================== INIT =====================
function render(){ renderBoard(); }

(function init(){
  const loaded=load();
  board = loaded || defaultBoard();
  // ensure search input reflects
  searchInput.value=filterState.search||'';
  render();
  // persist on before unload
  window.addEventListener('blur', save);
})();

// close modal on escape already handled via drag; also add generic
window.addEventListener('keydown', e=>{
  if(e.key==='Escape' && modalEl.classList.contains('open') && !dragState) closeModal();
});
</script>
</body>
</html>
<!-- agent-meta {"model":"meta/muse-spark-1.2","provider":"openrouter","persona":"basic","sessionId":"3eb3585e-af5e-458c-ace1-6bd9c87158dc","tokensIn":1099,"tokensOut":12709,"tokensTotal":13808,"cost":0.0552627,"turns":1,"toolCalls":0,"failedToolCalls":0,"timestamp":"2026-08-06T15:19:52.732Z"} -->