← Kanban results

Kanban

ox alpha · 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 36,005 bytes · SHA-256 c20d617a08b9
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Kanban Board</title>
<style>
/* ============================================================
   STYLES
   ============================================================ */
:root{
  --bg:#f4f5f7;
  --col-bg:#ebecf0;
  --card-bg:#ffffff;
  --text:#172b4d;
  --text-sub:#5e6c84;
  --accent:#0052cc;
  --danger:#e5484d;
  --radius-col:12px;
  --radius-card:8px;
  --shadow-card:0 1px 2px rgba(9,30,66,.12),0 0 1px rgba(9,30,66,.16);
  --shadow-hover:0 4px 8px rgba(9,30,66,.16),0 0 1px rgba(9,30,66,.24);
  --shadow-drag:0 12px 24px rgba(9,30,66,.22),0 0 1px rgba(9,30,66,.32);
  --col-w:292px;
}
*{box-sizing:border-box;margin:0;padding:0}
html,body{height:100%}
body{
  font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;
  background:var(--bg);color:var(--text);
  overflow:hidden;font-size:14px;
}

/* ---------- Top bar ---------- */
.topbar{
  position:sticky;top:0;z-index:50;height:56px;
  display:flex;align-items:center;gap:16px;padding:0 20px;
  background:#fff;border-bottom:1px solid #e2e4ea;
}
.topbar h1{font-size:17px;font-weight:700;letter-spacing:-.01em}
.topbar .spacer{flex:1}
.search-wrap{position:relative}
.search-wrap svg{position:absolute;left:9px;top:50%;transform:translateY(-50%);width:14px;height:14px;color:var(--text-sub)}
#search{
  width:230px;padding:7px 10px 7px 30px;border:1px solid #dfe1e6;border-radius:6px;
  font:inherit;color:var(--text);background:#fafbfc;outline:none;transition:.15s;
}
#search:focus{border-color:var(--accent);background:#fff;box-shadow:0 0 0 2px rgba(0,82,204,.15)}
.filter-bar{display:flex;gap:6px;align-items:center}
.filter-chip{
  display:inline-flex;align-items:center;gap:6px;padding:4px 10px;border-radius:999px;
  border:1.5px solid transparent;font-size:12px;font-weight:600;cursor:pointer;
  background:#f1f2f4;color:var(--text-sub);user-select:none;transition:.12s;
}
.filter-chip .dot{width:9px;height:9px;border-radius:50%}
.filter-chip.active{border-color:currentColor;background:#fff}
.btn{
  font:inherit;font-weight:600;color:var(--text);background:#f1f2f4;border:none;
  border-radius:6px;padding:7px 12px;cursor:pointer;transition:.12s;
}
.btn:hover{background:#e4e6ea}
.btn.primary{background:var(--accent);color:#fff}
.btn.primary:hover{background:#0747a6}
.btn.danger{color:var(--danger)}
.btn.danger:hover{background:#ffebeb}

/* ---------- Board ---------- */
#board{
  display:flex;align-items:flex-start;gap:14px;
  height:calc(100vh - 56px);padding:18px 20px;
  overflow-x:auto;overflow-y:hidden;
}
.column{
  width:var(--col-w);flex:0 0 var(--col-w);
  max-height:100%;display:flex;flex-direction:column;
  background:var(--col-bg);border-radius:var(--radius-col);
  transition:box-shadow .15s, outline-color .15s;
  outline:2px solid transparent;outline-offset:-2px;
}
.column.wip-exceeded{outline-color:var(--danger);box-shadow:0 0 0 4px rgba(229,72,77,.15)}
.col-header{
  display:flex;align-items:center;gap:8px;padding:11px 12px 7px;
  cursor:grab;user-select:none;
}
.col-header:active{cursor:grabbing}
.col-title{
  font-weight:700;font-size:13.5px;flex:1;min-width:0;
  overflow:hidden;text-overflow:ellipsis;white-space:nowrap;
  padding:2px 4px;border-radius:4px;
}
.col-title:hover{background:rgba(9,30,66,.08)}
.col-count{
  font-size:11.5px;font-weight:700;color:var(--text-sub);
  background:rgba(9,30,66,.1);border-radius:999px;padding:2px 8px;
}
.wip-badge{font-size:11px;font-weight:700;color:var(--danger)}
.icon-btn{
  border:none;background:none;color:var(--text-sub);cursor:pointer;
  width:26px;height:26px;border-radius:5px;display:flex;align-items:center;justify-content:center;
  font-size:15px;line-height:1;
}
.icon-btn:hover{background:rgba(9,30,66,.12);color:var(--text)}

/* ---------- Cards ---------- */
.col-cards{
  overflow-y:auto;overscroll-behavior:contain;
  padding:4px 8px 8px;display:flex;flex-direction:column;gap:8px;
  min-height:44px;scrollbar-width:thin;
}
.col-cards.empty-zone{border:2px dashed #c1c7d0;border-radius:8px;margin:4px 8px 8px;padding:8px}
.card{
  background:var(--card-bg);border-radius:var(--radius-card);
  box-shadow:var(--shadow-card);padding:10px 12px;cursor:grab;
  transition:box-shadow .12s, transform .12s;user-select:none;
}
.card:hover{transform:translateY(-1px);box-shadow:var(--shadow-hover)}
.card.settle{animation:settle .28s cubic-bezier(.2,.9,.3,1.2)}
@keyframes settle{0%{transform:scale(.96)}60%{transform:scale(1.015)}100%{transform:scale(1)}}
.card-title{font-weight:500;line-height:1.35;word-break:break-word}
.card-desc{color:var(--text-sub);font-size:12.5px;margin-top:4px;line-height:1.4;
  display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
.card-meta{display:flex;flex-wrap:wrap;gap:5px;margin-top:8px;align-items:center}
.chip{
  font-size:10.5px;font-weight:700;padding:2px 8px;border-radius:999px;letter-spacing:.02em;
}
.due{
  font-size:11px;font-weight:600;color:var(--text-sub);
  display:inline-flex;align-items:center;gap:4px;
}
.due.overdue{color:var(--danger)}
.title-input{
  font:inherit;font-weight:inherit;width:100%;border:2px solid var(--accent);
  border-radius:5px;padding:3px 6px;outline:none;background:#fff;color:var(--text);
}

/* ---------- Drag visuals ---------- */
.ghost{
  position:fixed;left:0;top:0;z-index:1000;pointer-events:none;
  box-shadow:var(--shadow-drag)!important;cursor:grabbing!important;
  transform-origin:center;
}
.placeholder{
  border:2px dashed #a5adba;border-radius:var(--radius-card);
  background:rgba(9,30,66,.05);flex:0 0 auto;
}
.col-placeholder{
  width:var(--col-w);flex:0 0 var(--col-w);border-radius:var(--radius-col);
  border:2px dashed #a5adba;background:rgba(9,30,66,.04);
}
.hidden{display:none!important}
body.dragging{cursor:grabbing}
body.dragging *{cursor:grabbing!important}

/* ---------- Composer ---------- */
.composer{
  padding:0 8px 10px;
}
.composer textarea{
  width:100%;font:inherit;border:none;border-radius:var(--radius-card);
  box-shadow:var(--shadow-card);padding:9px 11px;resize:none;outline:none;
  min-height:56px;display:block;
}
.composer-actions{display:flex;gap:6px;margin-top:6px}

/* ---------- Modal ---------- */
.modal-overlay{
  position:fixed;inset:0;background:rgba(9,30,66,.45);z-index:200;
  display:flex;align-items:flex-start;justify-content:center;padding-top:9vh;
}
.modal{
  background:#fff;border-radius:10px;width:480px;max-width:92vw;
  box-shadow:0 20px 50px rgba(9,30,66,.35);padding:20px;animation:pop .18s ease-out;
  max-height:82vh;overflow-y:auto;
}
@keyframes pop{from{opacity:0;transform:translateY(8px) scale(.98)}to{opacity:1}}
.modal h2{font-size:16px;margin-bottom:14px}
.field{margin-bottom:13px}
.field label{display:block;font-size:11.5px;font-weight:700;color:var(--text-sub);
  text-transform:uppercase;letter-spacing:.04em;margin-bottom:5px}
.field input[type=text],.field textarea,.field input[type=date],.field input[type=number]{
  width:100%;font:inherit;border:2px solid #dfe1e6;border-radius:6px;padding:7px 9px;
  outline:none;color:var(--text);
}
.field textarea{resize:vertical;min-height:70px}
.field input:focus,.field textarea:focus{border-color:var(--accent)}
.label-picker{display:flex;flex-wrap:wrap;gap:7px}
.label-pick{
  display:inline-flex;align-items:center;gap:6px;padding:5px 11px;border-radius:999px;
  border:2px solid transparent;font-size:12px;font-weight:600;cursor:pointer;
  opacity:.45;transition:.12s;user-select:none;
}
.label-pick.on{opacity:1;border-color:currentColor}
.modal-actions{display:flex;justify-content:space-between;margin-top:18px}
.modal-actions .right{display:flex;gap:8px}

/* ---------- Column menu ---------- */
.col-menu{
  position:fixed;z-index:300;background:#fff;border-radius:8px;
  box-shadow:0 8px 24px rgba(9,30,66,.25);padding:8px;width:220px;
}
.col-menu .field{margin-bottom:10px}
.menu-item{
  display:block;width:100%;text-align:left;font:inherit;font-weight:500;
  padding:7px 9px;border:none;background:none;border-radius:5px;cursor:pointer;color:var(--text);
}
.menu-item:hover{background:#f1f2f4}
.menu-item.danger{color:var(--danger)}

/* ---------- Toast ---------- */
.toast{
  position:fixed;bottom:24px;left:50%;transform:translateX(-50%);
  background:#172b4d;color:#fff;border-radius:8px;padding:11px 16px;
  display:flex;align-items:center;gap:14px;z-index:400;
  box-shadow:0 8px 24px rgba(9,30,66,.4);font-weight:500;
  animation:toastIn .2s ease-out;
}
@keyframes toastIn{from{opacity:0;transform:translate(-50%,10px)}to{opacity:1;transform:translate(-50%,0)}}
.toast button{
  font:inherit;font-weight:700;color:#8cb8ff;background:none;border:none;cursor:pointer;
  text-transform:uppercase;font-size:12px;letter-spacing:.04em;
}
.toast button:hover{text-decoration:underline}

::-webkit-scrollbar{height:10px;width:8px}
::-webkit-scrollbar-thumb{background:#c1c7d0;border-radius:5px}
::-webkit-scrollbar-track{background:transparent}
</style>
</head>
<body>
  <header class="topbar">
    <h1>Kanban Board</h1>
    <div class="spacer"></div>
    <div class="filter-bar" id="filterBar"></div>
    <div class="search-wrap">
      <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><circle cx="11" cy="11" r="7"/><path d="M21 21l-4.3-4.3"/></svg>
      <input id="search" type="text" placeholder="Search cards…" autocomplete="off">
    </div>
    <button class="btn primary" id="addColumnBtn">+ Add column</button>
  </header>
  <main id="board"></main>

<script>
'use strict';
/* ============================================================
   TUNABLE CONSTANTS
   ============================================================ */
const STORAGE_KEY = 'kanban-board-v1';

const LABEL_PALETTE = [
  { id:'design',  name:'Design',  color:'#ae5a9e', bg:'#f3e3f0' },
  { id:'bug',     name:'Bug',     color:'#c9372c', bg:'#fbe4e2' },
  { id:'feature', name:'Feature', color:'#0747a6', bg:'#dce9ff' },
  { id:'urgent',  name:'Urgent',  color:'#b65c02', bg:'#ffefdb' },
  { id:'research',name:'Research',color:'#5e4db2', bg:'#eae6ff' },
  { id:'chore',   name:'Chore',   color:'#206a4d', bg:'#dcfff1' },
];

const DRAG_THRESHOLD   = 5;    // px pointer must move before drag starts
const EDGE_SCROLL_ZONE = 90;   // px near board edge that triggers auto-scroll
const EDGE_SCROLL_MAX  = 18;   // max px per frame of auto-scroll
const COL_EDGE_ZONE    = 60;   // px near column top/bottom for vertical scroll
const GHOST_TILT       = 3;    // deg tilt while dragging a card
const UNDO_TOAST_MS    = 6000;

/* ============================================================
   STATE MODEL (single source of truth — fully serializable)
   ============================================================ */
let state = null;
let uidCounter = 0;
function uid(){ return 'id' + Date.now().toString(36) + (++uidCounter).toString(36); }

function defaultState(){
  const cols = ['Backlog','To Do','In Progress','Review','Done'].map(t=>({ id:uid(), title:t, wip:0 }));
  const cards = {};
  cols.forEach(c => cards[c.id] = []);
  const seed = [
    [0,'Design onboarding flow','Sketch the first-run experience',['design']],
    [0,'Investigate flaky CI','Fails intermittently on main',['bug','research']],
    [1,'User avatars','Upload + crop + store',['feature']],
    [1,'Upgrade dependencies','Quarterly chore',['chore']],
    [2,'Kanban drag & drop','Pointer-events based, with placeholder',['feature','urgent']],
    [3,'Dark mode tokens','Awaiting final review',['design']],
    [4,'Set up repo','Initial scaffold',['chore']],
  ];
  seed.forEach(([ci,title,desc,labels])=>{
    cards[cols[ci].id].push({ id:uid(), title, desc, labels:[...labels], due:'' });
  });
  return { columns:cols, cards, filter:{ labels:[], search:'' } };
}

function save(){
  try{ localStorage.setItem(STORAGE_KEY, JSON.stringify(state)); }catch(e){}
}
function load(){
  try{
    const raw = localStorage.getItem(STORAGE_KEY);
    if(raw){
      const s = JSON.parse(raw);
      if(s && Array.isArray(s.columns) && s.cards){ return s; }
    }
  }catch(e){}
  return defaultState();
}
function cloneBoard(s){ return JSON.parse(JSON.stringify(s)); }

/* ---------- model helpers ---------- */
function colById(id){ return state.columns.find(c=>c.id===id); }
function findCard(id){
  for(const c of state.columns){
    const i = state.cards[c.id].findIndex(k=>k.id===id);
    if(i>=0) return { card:state.cards[c.id][i], colId:c.id, index:i };
  }
  return null;
}
function cardVisible(card){
  const f = state.filter;
  if(f.labels.length && !f.labels.some(l=>card.labels.includes(l))) return false;
  if(f.search && !card.title.toLowerCase().includes(f.search.toLowerCase())) return false;
  return true;
}

/* ============================================================
   RENDERING (derived entirely from the model)
   ============================================================ */
const boardEl = document.getElementById('board');
const searchEl = document.getElementById('search');
const filterBar = document.getElementById('filterBar');

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

function renderFilterBar(){
  filterBar.innerHTML = '';
  LABEL_PALETTE.forEach(l=>{
    const b = document.createElement('button');
    b.className = 'filter-chip' + (state.filter.labels.includes(l.id)?' active':'');
    b.style.color = l.color;
    b.innerHTML = `<span class="dot" style="background:${l.color}"></span>${l.name}`;
    b.onclick = ()=>{
      const i = state.filter.labels.indexOf(l.id);
      if(i>=0) state.filter.labels.splice(i,1); else state.filter.labels.push(l.id);
      save(); render();
    };
    filterBar.appendChild(b);
  });
}

function makeCardEl(card){
  const el = document.createElement('div');
  el.className = 'card';
  el.dataset.cardId = card.id;
  let meta = '';
  card.labels.forEach(lid=>{
    const l = labelOf(lid); if(!l) return;
    meta += `<span class="chip" style="background:${l.bg};color:${l.color}">${l.name}</span>`;
  });
  if(card.due){
    const overdue = card.due < new Date().toISOString().slice(0,10);
    meta += `<span class="due${overdue?' overdue':''}">⏱ ${card.due}</span>`;
  }
  el.innerHTML =
    `<div class="card-title">${esc(card.title)}</div>` +
    (card.desc ? `<div class="card-desc">${esc(card.desc)}</div>` : '') +
    (meta ? `<div class="card-meta">${meta}</div>` : '');
  attachCardEvents(el, card.id);
  return el;
}

function esc(s){ return String(s).replace(/[&<>"]/g, m=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[m])); }

function render(){
  renderFilterBar();
  boardEl.innerHTML = '';
  state.columns.forEach(col=>{
    const colEl = document.createElement('section');
    colEl.className = 'column';
    colEl.dataset.colId = col.id;

    const list = state.cards[col.id] || [];
    const over = col.wip > 0 && list.length > col.wip;
    if(over) colEl.classList.add('wip-exceeded');

    // header
    const head = document.createElement('div');
    head.className = 'col-header';
    head.innerHTML =
      `<span class="col-title" title="Click to rename">${esc(col.title)}</span>` +
      `<span class="col-count">${list.length}</span>` +
      (col.wip>0 ? `<span class="wip-badge">WIP ${col.wip}${over?' ⚠':''}</span>` : '') +
      `<button class="icon-btn col-menu-btn" title="Column actions">⋯</button>`;
    colEl.appendChild(head);

    // cards
    const wrap = document.createElement('div');
    wrap.className = 'col-cards';
    let anyVisible = false;
    list.forEach(card=>{
      const cel = makeCardEl(card);
      if(!cardVisible(card)) cel.classList.add('hidden');
      else anyVisible = true;
      wrap.appendChild(cel);
    });
    if(!anyVisible) wrap.classList.add('empty-zone');
    colEl.appendChild(wrap);

    // composer
    const comp = document.createElement('div');
    comp.className = 'composer';
    comp.innerHTML =
      `<textarea class="composer-input" rows="1" placeholder="+ Add a card"></textarea>
       <div class="composer-actions" style="display:none">
         <button class="btn primary comp-add">Add card</button>
         <button class="btn comp-cancel">Cancel</button>
       </div>`;
    colEl.appendChild(comp);

    boardEl.appendChild(colEl);
    attachColumnEvents(colEl, col.id);
  });
}

/* ============================================================
   INLINE EDITING
   ============================================================ */
function inlineEdit(el, current, onCommit){
  const input = document.createElement('input');
  input.className = 'title-input';
  input.value = current;
  el.replaceWith(input);
  input.focus(); input.select();
  let done = false;
  const commit = ()=>{ if(done) return; done=true; onCommit(input.value.trim()); };
  const cancel = ()=>{ if(done) return; done=true; render(); };
  input.addEventListener('keydown', e=>{
    e.stopPropagation();
    if(e.key==='Enter'){ e.preventDefault(); commit(); }
    else if(e.key==='Escape'){ cancel(); }
  });
  input.addEventListener('blur', commit);
  input.addEventListener('pointerdown', e=>e.stopPropagation());
}

/* ============================================================
   TOAST + UNDO
   ============================================================ */
let undoSnapshot = null;
let toastTimer = null;
function showToast(msg, snapshot){
  undoSnapshot = snapshot;
  document.querySelectorAll('.toast').forEach(t=>t.remove());
  clearTimeout(toastTimer);
  const t = document.createElement('div');
  t.className = 'toast';
  t.innerHTML = `<span>${esc(msg)}</span><button>Undo</button>`;
  t.querySelector('button').onclick = ()=>{
    if(undoSnapshot){ state = undoSnapshot; save(); render(); }
    t.remove();
  };
  document.body.appendChild(t);
  toastTimer = setTimeout(()=>t.remove(), UNDO_TOAST_MS);
}
</script>
<script>
/* ============================================================
   EVENT WIRING
   ============================================================ */
function attachCardEvents(el, cardId){
  el.addEventListener('pointerdown', e=>{
    if(e.button!==0 || e.target.closest('.title-input')) return;
    startPending('card', cardId, e);
  });
}

function attachColumnEvents(colEl, colId){
  const head = colEl.querySelector('.col-header');
  head.addEventListener('pointerdown', e=>{
    if(e.button!==0) return;
    if(e.target.closest('.icon-btn') || e.target.closest('.title-input')) return;
    startPending('column', colId, e);
  });

  colEl.querySelector('.col-menu-btn').addEventListener('click', e=>{
    e.stopPropagation();
    openColMenu(colId, e.currentTarget);
  });

  const compInput = colEl.querySelector('.composer-input');
  const compActions = colEl.querySelector('.composer-actions');
  compInput.addEventListener('focus', ()=>{ compActions.style.display='flex'; });
  compInput.addEventListener('keydown', e=>{
    e.stopPropagation();
    if(e.key==='Enter'){ e.preventDefault(); addCard(colId, compInput.value.trim()); }
    if(e.key==='Escape'){ compInput.blur(); }
  });
  compEl_add(colEl, colId, compInput);
}

function compEl_add(colEl, colId, compInput){
  colEl.querySelector('.comp-add').addEventListener('click', ()=>{
    addCard(colId, compInput.value.trim());
  });
  colEl.querySelector('.comp-cancel').addEventListener('click', ()=>{
    compInput.value=''; compInput.blur();
    compInput.parentElement.querySelector('.composer-actions').style.display='none';
  });
}

function addCard(colId, title){
  if(!title) return;
  state.cards[colId].push({ id:uid(), title, desc:'', labels:[], due:'' });
  save(); render();
  // refocus composer of that column for rapid entry
  const colEl = boardEl.querySelector(`.column[data-col-id="${colId}"]`);
  if(colEl){
    const inp = colEl.querySelector('.composer-input');
    inp.focus();
    inp.parentElement.querySelector('.composer-actions').style.display='flex';
  }
}

/* ---------- top bar ---------- */
document.getElementById('addColumnBtn').addEventListener('click', ()=>{
  const col = { id:uid(), title:'New Column', wip:0 };
  state.columns.push(col);
  state.cards[col.id] = [];
  save(); render();
  const colEl = boardEl.querySelector(`.column[data-col-id="${col.id}"] .col-title`);
  if(colEl) inlineEdit(colEl, col.title, v=>{ col.title = v||col.title; save(); render(); });
});
searchEl.addEventListener('input', ()=>{
  state.filter.search = searchEl.value;
  save(); render();
});
window.addEventListener('keydown', e=>{
  if(e.key==='Escape' && !(e.target instanceof HTMLInputElement) && !(e.target instanceof HTMLTextAreaElement)){
    state.filter.search=''; searchEl.value='';
    state.filter.labels=[]; save(); render();
  }
});

/* ============================================================
   CARD MODAL (full card editing)
   ============================================================ */
function openCardModal(cardId){
  const found = findCard(cardId); if(!found) return;
  const card = found.card;
  const ov = document.createElement('div');
  ov.className = 'modal-overlay';
  ov.innerHTML = `
    <div class="modal">
      <h2>Edit card</h2>
      <div class="field"><label>Title</label><input type="text" id="m-title" value="${esc(card.title)}"></div>
      <div class="field"><label>Description</label><textarea id="m-desc">${esc(card.desc)}</textarea></div>
      <div class="field"><label>Due date</label><input type="date" id="m-due" value="${esc(card.due)}"></div>
      <div class="field"><label>Labels</label><div class="label-picker" id="m-labels"></div></div>
      <div class="modal-actions">
        <button class="btn danger" id="m-delete">Delete card</button>
        <div class="right">
          <button class="btn" id="m-cancel">Cancel</button>
          <button class="btn primary" id="m-save">Save</button>
        </div>
      </div>
    </div>`;
  const picked = new Set(card.labels);
  LABEL_PALETTE.forEach(l=>{
    const b = document.createElement('span');
    b.className = 'label-pick' + (picked.has(l.id)?' on':'');
    b.style.background = l.bg; b.style.color = l.color;
    b.textContent = l.name;
    b.onclick = ()=>{ picked.has(l.id)?picked.delete(l.id):picked.add(l.id); b.classList.toggle('on'); };
    ov.querySelector('#m-labels').appendChild(b);
  });
  const close = ()=>ov.remove();
  ov.addEventListener('pointerdown', e=>{ if(e.target===ov) close(); });
  ov.querySelector('#m-cancel').onclick = close;
  ov.querySelector('#m-save').onclick = ()=>{
    card.title = ov.querySelector('#m-title').value.trim() || card.title;
    card.desc = ov.querySelector('#m-desc').value;
    card.due = ov.querySelector('#m-due').value;
    card.labels = [...picked];
    save(); render(); close();
  };
  ov.querySelector('#m-delete').onclick = ()=>{
    close();
    const snap = cloneBoard(state);
    const f = findCard(cardId);
    state.cards[f.colId].splice(f.index,1);
    save(); render();
    showToast('Card deleted', snap);
  };
  document.body.appendChild(ov);
  ov.querySelector('#m-title').focus();
}

/* ============================================================
   COLUMN MENU (rename / WIP limit / delete)
   ============================================================ */
function openColMenu(colId, anchor){
  document.querySelectorAll('.col-menu').forEach(m=>m.remove());
  const col = colById(colId); if(!col) return;
  const r = anchor.getBoundingClientRect();
  const menu = document.createElement('div');
  menu.className = 'col-menu';
  menu.innerHTML = `
    <div class="field"><label>WIP limit (0 = none)</label>
      <input type="number" min="0" id="cm-wip" value="${col.wip}"></div>
    <button class="menu-item" id="cm-rename">Rename column</button>
    <button class="menu-item danger" id="cm-delete">Delete column</button>`;
  menu.style.left = Math.min(r.left, innerWidth-240)+'px';
  menu.style.top = (r.bottom+6)+'px';
  document.body.appendChild(menu);

  const close = ()=>menu.remove();
  setTimeout(()=>document.addEventListener('pointerdown', function h(e){
    if(!menu.contains(e.target)){ close(); document.removeEventListener('pointerdown',h); }
  }),0);

  menu.querySelector('#cm-wip').addEventListener('keydown', e=>e.stopPropagation());
  menu.querySelector('#cm-wip').addEventListener('change', e=>{
    col.wip = Math.max(0, parseInt(e.target.value)||0);
    save(); render();
  });
  menu.querySelector('#cm-rename').onclick = ()=>{
    close();
    const t = boardEl.querySelector(`.column[data-col-id="${colId}"] .col-title`);
    if(t) inlineEdit(t, col.title, v=>{ col.title=v||col.title; save(); render(); });
  };
  menu.querySelector('#cm-delete').onclick = ()=>{
    close();
    if(state.columns.length<=1){ showToast('Board needs at least one column'); return; }
    const snap = cloneBoard(state);
    delete state.cards[colId];
    state.columns = state.columns.filter(c=>c.id!==colId);
    save(); render();
    showToast(`Column "${col.title}" deleted`, snap);
  };
}

/* ============================================================
   DRAG & DROP (Pointer Events)
   ============================================================ */
let pending = null;  // awaiting drag threshold
let drag = null;     // active drag

function startPending(type, id, e){
  if(drag || pending) return;
  pending = {
    type, id,
    startX:e.clientX, startY:e.clientY,
    offX:0, offY:0, started:false,
  };
  try{ document.body.setPointerCapture(e.pointerId); }catch(err){}
  window.addEventListener('pointermove', onPendingMove);
  window.addEventListener('pointerup', onPendingUp, {once:true});
  window.addEventListener('pointercancel', onPendingUp, {once:true});
  window.addEventListener('keydown', onCancelKey);
}

function onPendingMove(e){
  if(!pending) return;
  const dx = e.clientX-pending.startX, dy = e.clientY-pending.startY;
  if(!pending.started && Math.hypot(dx,dy) > DRAG_THRESHOLD){
    pending.started = true;
    beginDrag(e);
  }
  if(drag && drag.started){
    drag.x = e.clientX; drag.y = e.clientY;
  }
}

function onPendingUp(e){
  window.removeEventListener('pointermove', onPendingMove);
  window.removeEventListener('keydown', onCancelKey);
  const wasPending = pending;
  pending = null;
  if(drag && drag.started){
    // sync final hit-test so the drop uses the exact pointer position
    drag.x = e.clientX; drag.y = e.clientY;
    const t = hitTest(drag.x, drag.y);
    if(t){ placePlaceholder(t); drag.lastTarget = t; }
    endDrag(true); return;
  }
  // no drag happened → treat as click
  if(wasPending){
    if(wasPending.type==='card'){
      const el = boardEl.querySelector(`[data-card-id="${wasPending.id}"]`);
      const target = document.elementFromPoint(e.clientX,e.clientY);
      if(target && target.closest('.card') && target.closest('.card').dataset.cardId===wasPending.id){
        if(target.closest('.card-title')) inlineCardTitle(wasPending.id);
        else openCardModal(wasPending.id);
      } else if(el){
        const t = el.querySelector('.card-title');
        if(t && t.contains(target)) inlineCardTitle(wasPending.id);
        else openCardModal(wasPending.id);
      }
    } else {
      const colEl = boardEl.querySelector(`.column[data-col-id="${wasPending.id}"]`);
      const target = document.elementFromPoint(e.clientX,e.clientY);
      const title = colEl && colEl.querySelector('.col-title');
      if(title && target && title.contains(target)){
        inlineEdit(title, colById(wasPending.id).title, v=>{
          colById(wasPending.id).title = v||colById(wasPending.id).title; save(); render();
        });
      }
    }
  }
}

function onCancelKey(e){
  if(e.key!=='Escape') return;
  if(drag && drag.started){ cancelDrag(); }
  else if(pending){ teardownPending(); }
}

function teardownPending(){
  window.removeEventListener('pointermove', onPendingMove);
  window.removeEventListener('keydown', onCancelKey);
  pending = null;
}

/* ---------- begin drag ---------- */
function beginDrag(e){
  const p = pending;
  let srcEl, rect, ph;
  if(p.type==='card'){
    srcEl = boardEl.querySelector(`[data-card-id="${p.id}"]`);
    if(!srcEl){ teardownPending(); return; }
    const f = findCard(p.id);
    drag = { type:'card', id:p.id, origColId:f.colId, origIndex:f.index };
    rect = srcEl.getBoundingClientRect();
    ph = document.createElement('div');
    ph.className = 'placeholder';
    ph.style.height = rect.height+'px';
    srcEl.replaceWith(ph);
  } else {
    srcEl = boardEl.querySelector(`.column[data-col-id="${p.id}"]`);
    if(!srcEl){ teardownPending(); return; }
    drag = { type:'column', id:p.id, origIndex:state.columns.findIndex(c=>c.id===p.id) };
    rect = srcEl.getBoundingClientRect();
    ph = document.createElement('div');
    ph.className = 'col-placeholder';
    ph.style.height = rect.height+'px';
    srcEl.replaceWith(ph);
  }
  p.offX = e.clientX - rect.left;
  p.offY = e.clientY - rect.top;
  const ghost = srcEl.cloneNode(true);
  ghost.classList.add('ghost');
  ghost.style.width = rect.width+'px';
  ghost.style.height = rect.height+'px';
  document.body.appendChild(ghost);
  drag.ghost = ghost; drag.ph = ph;
  drag.x = e.clientX; drag.y = e.clientY;
  drag.started = true;
  drag.lastTarget = null;
  document.body.classList.add('dragging');
  boardEl.querySelectorAll('.empty-zone').forEach(z=>z.classList.remove('empty-zone'));
  requestAnimationFrame(frame);
}

/* ---------- RAF loop: batched reads then writes ---------- */
function frame(){
  if(!drag || !drag.started) return;
  // ---- READS ----
  const px = drag.x, py = drag.y;
  const boardRect = boardEl.getBoundingClientRect();

  // horizontal auto-scroll of the board
  let hscroll = 0;
  if(px < boardRect.left + EDGE_SCROLL_ZONE) hscroll = -speedFor(px - boardRect.left);
  else if(px > boardRect.right - EDGE_SCROLL_ZONE) hscroll = speedFor(boardRect.right - px);

  // vertical auto-scroll of hovered column
  let vscroller = null, vscroll = 0;
  for(const c of boardEl.querySelectorAll('.column')){
    const r = c.getBoundingClientRect();
    if(px>=r.left && px<=r.right && py>=r.top-40 && py<=r.bottom+40){
      vscroller = c.querySelector('.col-cards');
      const sr = vscroller.getBoundingClientRect();
      if(py < sr.top + COL_EDGE_ZONE) vscroll = -speedFor(py - sr.top);
      else if(py > sr.bottom - COL_EDGE_ZONE) vscroll = speedFor(sr.bottom - py);
      break;
    }
  }

  // hit-test target (reads rects)
  const target = hitTest(px, py);

  // ---- WRITES ----
  if(hscroll) boardEl.scrollLeft += hscroll;
  if(vscroller && vscroll) vscroller.scrollTop += vscroll;

  if(target){
    if(!drag.lastTarget || drag.lastTarget.colId!==target.colId || drag.lastTarget.visIdx!==target.visIdx){
      placePlaceholder(target);
      drag.lastTarget = target;
    }
  }

  const gx = px - pending.offX, gy = py - pending.offY;
  if(drag.type==='card'){
    drag.ghost.style.transform = `translate(${gx}px,${gy}px) rotate(${GHOST_TILT}deg) scale(1.03)`;
  } else {
    drag.ghost.style.transform = `translate(${gx}px,${gy}px) scale(1.02)`;
    drag.ghost.style.opacity = '.92';
  }
  requestAnimationFrame(frame);
}

function speedFor(dist){
  const d = Math.max(0, dist);
  return Math.max(2, Math.round(EDGE_SCROLL_MAX * (1 - d/EDGE_SCROLL_ZONE)));
}

/* ---------- hit testing ---------- */
function hitTest(px, py){
  const cols = [...boardEl.querySelectorAll('.column')];
  if(!cols.length) return null;
  // column under pointer horizontally, else nearest by center
  let col = cols.find(c=>{
    const r = c.getBoundingClientRect();
    return px>=r.left && px<=r.right;
  });
  if(!col){
    let best = Infinity;
    for(const c of cols){
      const r = c.getBoundingClientRect();
      const d = Math.abs(px-(r.left+r.right)/2);
      if(d<best){ best=d; col=c; }
    }
  }
  const colId = col.dataset.colId;
  if(drag.type==='card'){
    const wrap = col.querySelector('.col-cards');
    const kids = [...wrap.children].filter(el=>
      el!==drag.ph && el.classList.contains('card') && !el.classList.contains('hidden'));
    let visIdx = kids.length;
    for(let i=0;i<kids.length;i++){
      const r = kids[i].getBoundingClientRect();
      if(py < r.top + r.height/2){ visIdx=i; break; }
    }
    return { colId, visIdx };
  }
  // column drag: insertion slot among other columns
  const others = cols.filter(c=>c.dataset.colId!==drag.id);
  let idx = others.length;
  for(let i=0;i<others.length;i++){
    const r = others[i].getBoundingClientRect();
    if(px < r.left + r.width/2){ idx=i; break; }
  }
  return { colId:null, colSlot:idx };
}

function placePlaceholder(t){
  if(drag.type==='card'){
    const col = boardEl.querySelector(`.column[data-col-id="${t.colId}"]`);
    const wrap = col.querySelector('.col-cards');
    const kids = [...wrap.children].filter(el=>
      el!==drag.ph && el.classList.contains('card') && !el.classList.contains('hidden'));
    if(t.visIdx >= kids.length) wrap.appendChild(drag.ph);
    else kids[t.visIdx].before(drag.ph);
  } else {
    const others = [...boardEl.querySelectorAll('.column')].filter(c=>c!==drag.ph && c.dataset.colId!==drag.id);
    if(t.colSlot >= others.length) boardEl.appendChild(drag.ph);
    else others[t.colSlot].before(drag.ph);
  }
}

/* ---------- commit / cancel ---------- */
function commitTarget(){
  if(drag.type==='card'){
    const t = drag.lastTarget || { colId:drag.origColId, visIdx:null };
    const card = state.cards[drag.origColId][drag.origIndex];
    state.cards[drag.origColId].splice(drag.origIndex,1);
    const list = state.cards[t.colId];
    let modelIdx;
    if(t.visIdx===null || t.visIdx===undefined){
      modelIdx = list.length;
    } else {
      const vis = list.filter(cardVisible);
      modelIdx = t.visIdx >= vis.length ? list.length : list.indexOf(vis[t.visIdx]);
    }
    list.splice(modelIdx,0,card);
    return card.id;
  }
  const t = drag.lastTarget;
  const col = state.columns.splice(drag.origIndex,1)[0];
  const slot = (t && t.colSlot!==undefined) ? t.colSlot : state.columns.length;
  state.columns.splice(Math.min(slot, state.columns.length), 0, col);
  return col.id;
}

function cleanupDragVisuals(settleId){
  drag.ghost.remove(); drag.ph.remove();
  document.body.classList.remove('dragging');
  const wasType = drag.type;
  drag = null;
  render();
  if(settleId && wasType==='card'){
    const el = boardEl.querySelector(`[data-card-id="${settleId}"]:not(.hidden)`);
    if(el) el.classList.add('settle');
  }
  // restore empty-zone styling
  boardEl.querySelectorAll('.col-cards').forEach(w=>{
    if(![...w.children].some(c=>c.classList.contains('card')&&!c.classList.contains('hidden')))
      w.classList.add('empty-zone');
  });
}

function endDrag(commit){
  if(!drag) return;
  window.removeEventListener('pointermove', onPendingMove);
  window.removeEventListener('keydown', onCancelKey);
  if(pending){ pending=null; }
  if(commit){ const id = commitTarget(); save(); cleanupDragVisuals(id); }
  else cancelDrag();
}

function cancelDrag(){
  // model untouched — re-render restores exact original order
  window.removeEventListener('pointermove', onPendingMove);
  window.removeEventListener('keydown', onCancelKey);
  pending = null;
  cleanupDragVisuals(null);
}

function inlineCardTitle(cardId){
  const el = boardEl.querySelector(`[data-card-id="${cardId}"] .card-title`);
  const card = findCard(cardId); if(!el||!card) return;
  inlineEdit(el, card.card.title, v=>{
    if(v){ card.card.title = v; save(); }
    render();
  });
}

/* ============================================================
   INIT
   ============================================================ */
state = load();
searchEl.value = state.filter.search || '';
render();
</script>
</body>
</html>
<!-- agent-meta {"model":"stealth/ox-alpha","provider":"openrouter","persona":"full","sessionId":"f1cd7edd-1d25-40c7-8816-f2950f78c733","tokensIn":1590625,"tokensOut":38000,"tokensTotal":1628625,"cost":0,"turns":40,"toolCalls":39,"failedToolCalls":1,"timestamp":"2026-08-21T18:50:45.158Z"} -->