← Kanban results

Kanban

GLM 5.3 Flash · 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,502 bytes · SHA-256 ca959677f563
<!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;
  --border:#dfe1e6;
  --accent:#0052cc;
  --danger:#de350b;
  --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,.15), 0 0 1px rgba(9,30,66,.21);
  --shadow-float:0 12px 24px rgba(9,30,66,.25), 0 0 1px rgba(9,30,66,.31);
  --col-w:292px;
  --header-h:56px;
}
*{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);
  display:flex;flex-direction:column;overflow:hidden;
  -webkit-font-smoothing:antialiased;
}
button{font:inherit;border:none;background:none;cursor:pointer;color:inherit}
input,textarea{font:inherit;color:inherit}

/* ---------- Board header ---------- */
.board-header{
  height:var(--header-h);flex:0 0 auto;
  display:flex;align-items:center;gap:16px;
  padding:0 20px;background:#fff;
  border-bottom:1px solid var(--border);
  z-index:30;
}
.board-title{font-size:17px;font-weight:700;letter-spacing:-.2px}
.search-wrap{position:relative;flex:0 1 280px}
.search-wrap svg{position:absolute;left:9px;top:50%;transform:translateY(-50%);pointer-events:none}
#searchInput{
  width:100%;height:34px;padding:0 10px 0 32px;
  border:1px solid var(--border);border-radius:6px;background:var(--bg);
  outline:none;transition:background .15s,border-color .15s;
}
#searchInput:focus{background:#fff;border-color:var(--accent)}
.filter-bar{display:flex;gap:6px;align-items:center;flex-wrap:nowrap;overflow-x:auto}
.filter-chip{
  display:inline-flex;align-items:center;gap:6px;height:28px;
  padding:0 10px;border-radius:14px;border:1px solid transparent;
  font-size:12px;font-weight:600;white-space:nowrap;opacity:.45;
  transition:opacity .15s, box-shadow .15s;
}
.filter-chip .dot{width:9px;height:9px;border-radius:50%}
.filter-chip.active{opacity:1;box-shadow:0 0 0 2px #fff,0 0 0 3.5px currentColor}
.filter-chip:hover{opacity:.85}
#filterClear{font-size:12px;color:var(--text-sub);white-space:nowrap;display:none}
#filterClear:hover{color:var(--danger)}
.header-spacer{flex:1}

/* ---------- Board ---------- */
#board{
  flex:1;display:flex;align-items:flex-start;gap:14px;
  padding:18px 20px;overflow-x:auto;overflow-y:hidden;
  scroll-behavior:auto;
}
#board.dragging-col{cursor:grabbing}

/* ---------- Column ---------- */
.column{
  width:var(--col-w);flex:0 0 auto;max-height:100%;
  display:flex;flex-direction:column;
  background:var(--col-bg);border-radius:var(--radius-col);
  transition:box-shadow .15s;
}
.column.over-limit{box-shadow:inset 0 0 0 2px var(--danger)}
.column.drag-ghost-src{opacity:.4}
.col-head{
  flex:0 0 auto;display:flex;align-items:center;gap:6px;
  padding:10px 12px 6px;cursor:grab;user-select:none;
}
.col-head:active{cursor:grabbing}
.col-title{
  font-size:13px;font-weight:700;text-transform:uppercase;letter-spacing:.4px;
  color:var(--text-sub);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:12px;font-weight:600;color:var(--text-sub);
  background:rgba(9,30,66,.08);border-radius:10px;padding:1px 8px;
}
.col-count.over{background:var(--danger);color:#fff}
.col-menu-btn{
  width:24px;height:24px;border-radius:4px;font-size:15px;line-height:1;
  color:var(--text-sub);display:flex;align-items:center;justify-content:center;
}
.col-menu-btn:hover{background:rgba(9,30,66,.12)}
.wip-badge{font-size:11px;font-weight:600;color:var(--text-sub)}
.wip-badge.over{color:var(--danger)}

.col-cards{
  flex:1 1 auto;overflow-y:auto;overflow-x:hidden;
  padding:4px 8px 4px;min-height:40px;
  scrollbar-width:thin;
}
.col-cards::-webkit-scrollbar{width:8px}
.col-cards::-webkit-scrollbar-thumb{background:rgba(9,30,66,.2);border-radius:4px}

.col-foot{flex:0 0 auto;padding:6px 8px 10px}
.add-card-btn{
  width:100%;text-align:left;padding:7px 10px;border-radius:6px;
  font-size:13px;font-weight:500;color:var(--text-sub);
}
.add-card-btn:hover{background:rgba(9,30,66,.08);color:var(--text)}

/* empty column dashed drop zone */
.col-cards.empty{
  border:2px dashed #c1c7d0;border-radius:8px;
  background:rgba(9,30,66,.02);min-height:64px;margin:4px 8px;padding:0;
  display:flex;align-items:center;justify-content:center;
  color:#a5adba;font-size:12px;font-weight:500;
}
.col-cards.empty.drag-over{border-color:var(--accent);color:var(--accent);background:rgba(0,82,204,.05)}

/* ---------- Card ---------- */
.card{
  background:var(--card-bg);border-radius:var(--radius-card);
  box-shadow:var(--shadow-card);
  padding:10px 12px;margin-bottom:8px;cursor:grab;user-select:none;
  transition:box-shadow .12s, transform .12s;
  position:relative;
}
.card:hover{box-shadow:var(--shadow-hover);transform:translateY(-1px)}
.card.hidden{display:none}
.card.settle{animation:settle .22s ease-out}
@keyframes settle{0%{transform:scale(1.04)}100%{transform:scale(1)}}
.card-title{font-size:14px;font-weight:500;line-height:1.4;word-wrap:break-word;outline:none;border-radius:3px}
.card-title.editing{box-shadow:0 0 0 2px var(--accent);background:#fff;cursor:text;user-select:text}
.card-desc{font-size:12px;color:var(--text-sub);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:4px;margin-top:8px;align-items:center}
.label-chip{
  font-size:11px;font-weight:600;padding:2px 8px;border-radius:10px;color:#172b4d;
}
.due-chip{
  font-size:11px;font-weight:600;padding:2px 8px;border-radius:10px;
  background:#f1f2f4;color:var(--text-sub);display:inline-flex;align-items:center;gap:4px;
}
.due-chip.overdue{background:#ffebe6;color:var(--danger)}
.card-del{
  position:absolute;top:6px;right:6px;width:20px;height:20px;border-radius:4px;
  font-size:13px;color:#a5adba;display:none;align-items:center;justify-content:center;
}
.card:hover .card-del{display:flex}
.card-del:hover{background:#ffebe6;color:var(--danger)}

/* ---------- Drag visuals ---------- */
.card.placeholder{
  background:rgba(9,30,66,.06);box-shadow:none;border:2px dashed #b3b9c4;
  transform:none!important;cursor:grabbing;
}
.card.placeholder *{visibility:hidden}
.card.floating{
  position:fixed;z-index:1000;pointer-events:none;margin:0;
  box-shadow:var(--shadow-float);transform:rotate(3deg) scale(1.03);
  width:var(--drag-w);cursor:grabbing;
}
.column.placeholder{
  background:rgba(9,30,66,.05);border:2px dashed #b3b9c4;
  width:var(--col-w);flex:0 0 auto;border-radius:var(--radius-col);min-height:120px;
}
.column.floating{
  position:fixed;z-index:1000;pointer-events:none;margin:0;
  box-shadow:var(--shadow-float);transform:rotate(2deg) scale(1.02);
  cursor:grabbing;
}

/* ---------- Add column ---------- */
#addColumn{
  width:var(--col-w);flex:0 0 auto;align-self:flex-start;
  padding:12px;border-radius:var(--radius-col);
  background:rgba(9,30,66,.06);color:var(--text-sub);
  font-size:13px;font-weight:600;text-align:left;
}
#addColumn:hover{background:rgba(9,30,66,.12)}

/* ---------- Column menu dropdown ---------- */
.dropdown{
  position:fixed;z-index:1100;background:#fff;border-radius:8px;
  box-shadow:0 8px 24px rgba(9,30,66,.2),0 0 1px rgba(9,30,66,.3);
  padding:6px;min-width:200px;display:none;
}
.dropdown.open{display:block}
.dropdown .dd-item{
  display:block;width:100%;text-align:left;padding:8px 10px;border-radius:5px;
  font-size:13px;
}
.dropdown .dd-item:hover{background:#f4f5f7}
.dropdown .dd-item.danger{color:var(--danger)}
.dropdown .dd-item.danger:hover{background:#ffebe6}
.dropdown .dd-sep{height:1px;background:var(--border);margin:5px 4px}
.dropdown .dd-label{font-size:11px;font-weight:700;color:var(--text-sub);text-transform:uppercase;padding:6px 10px 2px;letter-spacing:.4px}
.dropdown input[type=number]{
  width:100%;padding:6px 10px;border:1px solid var(--border);border-radius:5px;outline:none;
}

/* ---------- Modal ---------- */
#modalOverlay{
  position:fixed;inset:0;background:rgba(9,30,66,.5);z-index:1200;
  display:none;align-items:flex-start;justify-content:center;padding-top:10vh;
}
#modalOverlay.open{display:flex}
.modal{
  background:#fff;border-radius:10px;width:440px;max-width:92vw;
  box-shadow:0 20px 40px rgba(9,30,66,.3);padding:20px;
  animation:modalIn .18s ease-out;
}
@keyframes modalIn{from{opacity:0;transform:translateY(-8px)}to{opacity:1;transform:none}}
.modal h3{font-size:15px;margin-bottom:14px}
.modal label{display:block;font-size:11px;font-weight:700;color:var(--text-sub);text-transform:uppercase;letter-spacing:.4px;margin:12px 0 5px}
.modal input[type=text],.modal textarea,.modal input[type=date]{
  width:100%;padding:8px 10px;border:1px solid var(--border);border-radius:6px;outline:none;
}
.modal textarea{resize:vertical;min-height:64px}
.modal input:focus,.modal textarea:focus{border-color:var(--accent)}
.label-picker{display:flex;flex-wrap:wrap;gap:6px}
.label-pick{
  display:inline-flex;align-items:center;gap:6px;padding:4px 10px;border-radius:12px;
  font-size:12px;font-weight:600;border:2px solid transparent;opacity:.5;
}
.label-pick.sel{opacity:1;border-color:currentColor}
.modal-actions{display:flex;gap:8px;margin-top:18px;justify-content:flex-end}
.btn{padding:8px 14px;border-radius:6px;font-size:13px;font-weight:600}
.btn-primary{background:var(--accent);color:#fff}
.btn-primary:hover{background:#0747a6}
.btn-ghost{background:#f4f5f7}
.btn-ghost:hover{background:#e4e6ea}
.btn-danger{color:var(--danger);margin-right:auto}
.btn-danger:hover{background:#ffebe6}

/* ---------- Toast ---------- */
#toast{
  position:fixed;bottom:24px;left:50%;transform:translate(-50%,80px);
  background:#172b4d;color:#fff;padding:11px 16px;border-radius:8px;
  font-size:13px;display:flex;align-items:center;gap:14px;z-index:1300;
  box-shadow:0 8px 24px rgba(9,30,66,.4);
  transition:transform .25s ease,opacity .25s;opacity:0;pointer-events:none;
}
#toast.show{transform:translate(-50%,0);opacity:1;pointer-events:auto}
#toastUndo{color:#4c9aff;font-weight:700}
#toastUndo:hover{text-decoration:underline}
</style>
</head>
<body>

<header class="board-header">
  <div class="board-title">Project Board</div>
  <div class="search-wrap">
    <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#5e6c84" stroke-width="2.4"><circle cx="11" cy="11" r="7"/><path d="M21 21l-4.3-4.3"/></svg>
    <input id="searchInput" type="text" placeholder="Search cards…" autocomplete="off">
  </div>
  <div class="filter-bar" id="filterBar"></div>
  <button id="filterClear">Clear filters</button>
  <div class="header-spacer"></div>
</header>

<main id="board"></main>

<div class="dropdown" id="colMenu">
  <button class="dd-item" data-act="rename">Rename column</button>
  <div class="dd-sep"></div>
  <div class="dd-label">WIP limit</div>
  <input type="number" id="wipInput" min="0" placeholder="No limit">
  <div class="dd-sep"></div>
  <button class="dd-item danger" data-act="delete">Delete column</button>
</div>

<div id="modalOverlay">
  <div class="modal">
    <h3 id="modalTitle">Edit card</h3>
    <label>Title</label>
    <input type="text" id="mTitle">
    <label>Description</label>
    <textarea id="mDesc"></textarea>
    <label>Due date</label>
    <input type="date" id="mDue">
    <label>Labels</label>
    <div class="label-picker" id="mLabels"></div>
    <div class="modal-actions">
      <button class="btn btn-danger" id="mDelete">Delete card</button>
      <button class="btn btn-ghost" id="mCancel">Cancel</button>
      <button class="btn btn-primary" id="mSave">Save</button>
    </div>
  </div>
</div>

<div id="toast"><span id="toastMsg"></span><button id="toastUndo">Undo</button></div>

<script>
/* ============================================================
   TUNABLE CONSTANTS
   ============================================================ */
const CFG = {
  STORAGE_KEY: 'kanban-board-v1',
  DRAG_THRESHOLD: 6,          // px before a press becomes a drag
  EDGE_SCROLL_ZONE: 70,       // px from board edge that triggers auto-scroll
  EDGE_SCROLL_SPEED: 14,      // px per frame at edge
  COL_SCROLL_ZONE: 60,        // px from column top/bottom for vertical scroll
  COL_SCROLL_SPEED: 10,
  TILT_DEG: 3,
  UNDO_MS: 6000,
  LABEL_PALETTE: [
    { id:'l1', name:'Design',   color:'#c0ebf5' },
    { id:'l2', name:'Bug',      color:'#ffecb3' },
    { id:'l3', name:'Feature',  color:'#d3f5ee' },
    { id:'l4', name:'Research', color:'#eae6ff' },
    { id:'l5', name:'Urgent',   color:'#ffcfdb' },
    { id:'l6', name:'Chore',    color:'#dfe1e6' },
  ],
};
const DEFAULT_BOARD = () => ({
  columns: [
    { id:uid(), title:'Backlog', wipLimit:null, cards:[
      { id:uid(), title:'Design onboarding flow', desc:'Sketch 3 alternatives for the first-run experience.', labels:['l1'], due:'' },
      { id:uid(), title:'Investigate slow search', desc:'Queries take >2s on large boards.', labels:['l2','l4'], due:'' },
      { id:uid(), title:'Mobile layout polish', desc:'', labels:[], due:'' },
    ]},
    { id:uid(), title:'To Do', wipLimit:null, cards:[
      { id:uid(), title:'Add keyboard shortcuts', desc:'N for new card, / for search.', labels:['l3'], due:'' },
      { id:uid(), title:'Upgrade dependencies', desc:'', labels:['l6'], due: new Date(Date.now()+7*864e5).toISOString().slice(0,10) },
    ]},
    { id:uid(), title:'In Progress', wipLimit:3, cards:[
      { id:uid(), title:'Drag-and-drop reordering', desc:'Pointer-event based, with live placeholder.', labels:['l3','l1'], due:'' },
    ]},
    { id:uid(), title:'Review', wipLimit:null, cards:[
      { id:uid(), title:'Board search + filters', desc:'', labels:['l3'], due:'' },
    ]},
    { id:uid(), title:'Done', wipLimit:null, cards:[
      { id:uid(), title:'Set up project repo', desc:'', labels:['l6'], due:'' },
      { id:uid(), title:'Choose color palette', desc:'', labels:['l1'], due:'' },
    ]},
  ],
});
function uid(){ return 'id' + Math.random().toString(36).slice(2,9) + Date.now().toString(36).slice(-4); }
</script>
<script>
/* ============================================================
   STATE & PERSISTENCE
   Single source of truth: `board` (serializable) + `filter`.
   ============================================================ */
const filter = { search:'', labels:new Set() };
let board = load() || DEFAULT_BOARD();

function save(){
  localStorage.setItem(CFG.STORAGE_KEY, JSON.stringify({
    board,
    filter:{ search:filter.search, labels:[...filter.labels] },
  }));
}
function load(){
  try{
    const d = JSON.parse(localStorage.getItem(CFG.STORAGE_KEY));
    if (d && Array.isArray(d.board?.columns)){
      filter.search = d.filter?.search || '';
      filter.labels = new Set(d.filter?.labels || []);
      return d.board;
    }
  }catch(e){}
  return null;
}

const getCol   = id => board.columns.find(c=>c.id===id);
function findCard(cardId){
  for (const col of board.columns){
    const i = col.cards.findIndex(c=>c.id===cardId);
    if (i !== -1) return { col, index:i, card:col.cards[i] };
  }
  return null;
}
function cardVisible(card){
  if (filter.search && !card.title.toLowerCase().includes(filter.search.toLowerCase())) return false;
  if (filter.labels.size && !card.labels?.some(l=>filter.labels.has(l))) return false;
  return true;
}
const esc = s => String(s??'').replace(/[&<>"']/g, m=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":"&#39;"}[m]));

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

function render(){
  boardEl.innerHTML = '';
  for (const col of board.columns) boardEl.appendChild(buildColumnEl(col));
  const add = document.createElement('button');
  add.id = 'addColumn';
  add.textContent = '+ Add column';
  add.addEventListener('click', addColumn);
  boardEl.appendChild(add);
  renderFilterBar();
  updateWipHighlights();
}

function buildColumnEl(col){
  const el = document.createElement('div');
  el.className = 'column';
  el.dataset.colId = col.id;
  const over = col.wipLimit && col.cards.length > col.wipLimit;
  el.innerHTML = `
    <div class="col-head">
      <span class="col-title" title="Click to rename">${esc(col.title)}</span>
      ${col.wipLimit ? `<span class="wip-badge ${over?'over':''}">WIP: ${col.cards.length}/${col.wipLimit}</span>` : ''}
      <span class="col-count ${over?'over':''}">${col.cards.length}</span>
      <button class="col-menu-btn" title="Column menu">⋯</button>
    </div>
    <div class="col-cards"></div>
    <div class="col-foot"><button class="add-card-btn">+ Add card</button></div>`;
  const body = el.querySelector('.col-cards');
  for (const card of col.cards){
    const c = buildCardEl(card);
    if (!cardVisible(card)) c.classList.add('hidden');
    body.appendChild(c);
  }
  el.querySelector('.col-menu-btn').addEventListener('click', e => { e.stopPropagation(); openColMenu(col.id, e.currentTarget); });
  el.querySelector('.add-card-btn').addEventListener('click', () => addCard(col.id));
  const title = el.querySelector('.col-title');
  title.addEventListener('click', () => editInline(title, col.title, v => {
    if (v && v !== col.title){ col.title = v; save(); }
    render();
  }));
  return el;
}

function buildCardEl(card){
  const el = document.createElement('div');
  el.className = 'card';
  el.dataset.cardId = card.id;
  const chips = (card.labels||[]).map(lid=>{
    const l = CFG.LABEL_PALETTE.find(x=>x.id===lid);
    return l ? `<span class="label-chip" style="background:${l.color}">${esc(l.name)}</span>` : '';
  }).join('');
  let due = '';
  if (card.due){
    const od = card.due < new Date().toISOString().slice(0,10);
    due = `<span class="due-chip ${od?'overdue':''}">🗓 ${esc(card.due)}</span>`;
  }
  el.innerHTML = `
    <div class="card-title">${esc(card.title)}</div>
    ${card.desc ? `<div class="card-desc">${esc(card.desc)}</div>` : ''}
    ${(chips||due) ? `<div class="card-meta">${chips}${due}</div>` : ''}
    <button class="card-del" title="Delete card">✕</button>`;
  el.querySelector('.card-del').addEventListener('click', e=>{
    e.stopPropagation();
    destructive(board2 => {
      const loc = findCard(card.id); if(!loc) return;
      loc.col.cards.splice(loc.index,1);
    }, 'Card deleted');
  });
  const t = el.querySelector('.card-title');
  t.addEventListener('click', e=>{
    e.stopPropagation();
    editInline(t, card.title, v => {
      if (v && v !== card.title){ card.title = v; save(); }
      render();
    });
  });
  el.addEventListener('click', ()=> openCardModal(card.id));
  return el;
}

function updateWipHighlights(){
  for (const colEl of boardEl.querySelectorAll('.column')){
    const col = getCol(colEl.dataset.colId); if(!col) continue;
    const over = col.wipLimit && col.cards.length > col.wipLimit;
    colEl.classList.toggle('over-limit', !!over);
    const cnt = colEl.querySelector('.col-count');
    if (cnt) cnt.classList.toggle('over', !!over);
    const wb = colEl.querySelector('.wip-badge');
    if (wb) wb.classList.toggle('over', !!over);
  }
}

function updateEmptyStates(){
  for (const body of boardEl.querySelectorAll('.col-cards')){
    const hasPh = !!body.querySelector(':scope > .card.placeholder');
    const vis = [...body.children].filter(el=>el.classList.contains('card') && !el.classList.contains('hidden') && !el.classList.contains('placeholder')).length;
    body.classList.toggle('empty', vis===0 && !hasPh);
    body.classList.toggle('drag-over', hasPh);
  }
}

/* ---------- filter bar ---------- */
function renderFilterBar(){
  const bar = document.getElementById('filterBar');
  bar.innerHTML = '';
  for (const l of CFG.LABEL_PALETTE){
    const b = document.createElement('button');
    b.className = 'filter-chip' + (filter.labels.has(l.id) ? ' active' : '');
    b.style.color = l.color;
    b.style.background = l.color;
    b.innerHTML = `<span class="dot" style="background:rgba(23,43,77,.45)"></span>${esc(l.name)}`;
    b.addEventListener('click', ()=>{
      filter.labels.has(l.id) ? filter.labels.delete(l.id) : filter.labels.add(l.id);
      save(); render();
    });
    bar.appendChild(b);
  }
  document.getElementById('filterClear').style.display =
    (filter.search || filter.labels.size) ? 'block' : 'none';
}
document.getElementById('filterClear').addEventListener('click', ()=>{
  filter.search=''; filter.labels.clear();
  document.getElementById('searchInput').value='';
  save(); render();
});
document.getElementById('searchInput').addEventListener('input', e=>{
  filter.search = e.target.value;
  save(); render();
});

/* ============================================================
   INLINE EDITING
   ============================================================ */
function editInline(el, current, onCommit){
  const input = document.createElement('input');
  input.type='text'; input.value=current;
  input.style.cssText='width:100%;padding:2px 4px;border:1px solid var(--accent);border-radius:4px;outline:none;font-weight:600';
  el.replaceWith(input);
  input.focus(); input.select();
  let done = false;
  const finish = commit => {
    if (done) return; done = true;
    onCommit(commit ? input.value.trim() : current);
  };
  input.addEventListener('keydown', e=>{
    e.stopPropagation();
    if (e.key==='Enter') finish(true);
    else if (e.key==='Escape') finish(false);
  });
  input.addEventListener('blur', ()=>finish(true));
  input.addEventListener('pointerdown', e=>e.stopPropagation());
}

/* ============================================================
   CRUD
   ============================================================ */
let undoSnapshot = null, undoTimer = null;
function destructive(mutate, msg){
  const snap = JSON.stringify(board);
  mutate(board);
  save(); render();
  showToast(msg, ()=>{
    board = JSON.parse(snap);
    save(); render();
  });
}
function showToast(msg, onUndo){
  const t = document.getElementById('toast');
  document.getElementById('toastMsg').textContent = msg;
  const u = document.getElementById('toastUndo');
  u.onclick = ()=>{ onUndo && onUndo(); hideToast(); };
  t.classList.add('show');
  clearTimeout(undoTimer);
  undoTimer = setTimeout(hideToast, CFG.UNDO_MS);
}
function hideToast(){ document.getElementById('toast').classList.remove('show'); }

function addCard(colId){
  const col = getCol(colId); if(!col) return;
  const card = { id:uid(), title:'New card', desc:'', labels:[], due:'' };
  col.cards.push(card);
  save(); render();
  const el = boardEl.querySelector(`[data-card-id="${card.id}"] .card-title`);
  if (el) editInline(el, card.title, v => {
    card.title = v || 'Untitled'; save(); render();
  });
}
function addColumn(){
  const col = { id:uid(), title:'New column', wipLimit:null, cards:[] };
  board.columns.push(col);
  save(); render();
  const el = boardEl.querySelector(`[data-col-id="${col.id}"] .col-title`);
  if (el) editInline(el, col.title, v => {
    col.title = v || 'Untitled'; save(); render();
  });
}

/* ---------- card modal ---------- */
let modalCardId = null;
const overlay = document.getElementById('modalOverlay');
function openCardModal(cardId){
  const loc = findCard(cardId); if(!loc) return;
  modalCardId = cardId;
  const c = loc.card;
  document.getElementById('modalTitle').textContent = 'Edit card';
  document.getElementById('mTitle').value = c.title;
  document.getElementById('mDesc').value = c.desc || '';
  document.getElementById('mDue').value = c.due || '';
  const lp = document.getElementById('mLabels');
  lp.innerHTML = '';
  for (const l of CFG.LABEL_PALETTE){
    const b = document.createElement('button');
    b.type='button';
    b.className = 'label-pick' + ((c.labels||[]).includes(l.id) ? ' sel' : '');
    b.style.background = l.color; b.style.color = '#172b4d';
    b.textContent = l.name;
    b.addEventListener('click', ()=>{
      c.labels = c.labels || [];
      const i = c.labels.indexOf(l.id);
      i===-1 ? c.labels.push(l.id) : c.labels.splice(i,1);
      b.classList.toggle('sel');
    });
    lp.appendChild(b);
  }
  overlay.classList.add('open');
  document.getElementById('mTitle').focus();
}
function closeModal(){ overlay.classList.remove('open'); modalCardId = null; }
document.getElementById('mSave').addEventListener('click', ()=>{
  const loc = findCard(modalCardId);
  if (loc){
    loc.card.title = document.getElementById('mTitle').value.trim() || 'Untitled';
    loc.card.desc  = document.getElementById('mDesc').value;
    loc.card.due   = document.getElementById('mDue').value;
    save();
  }
  closeModal(); render();
});
document.getElementById('mCancel').addEventListener('click', closeModal);
document.getElementById('mDelete').addEventListener('click', ()=>{
  const id = modalCardId; closeModal();
  destructive(b => {
    for (const col of b.columns){
      const i = col.cards.findIndex(c=>c.id===id);
      if (i!==-1){ col.cards.splice(i,1); return; }
    }
  }, 'Card deleted');
});
overlay.addEventListener('pointerdown', e=>{ if(e.target===overlay) closeModal(); });

/* ---------- column menu ---------- */
const colMenu = document.getElementById('colMenu');
let menuColId = null;
function openColMenu(colId, btn){
  menuColId = colId;
  const col = getCol(colId);
  document.getElementById('wipInput').value = col.wipLimit ?? '';
  const r = btn.getBoundingClientRect();
  colMenu.style.left = Math.min(r.left, innerWidth-220) + 'px';
  colMenu.style.top  = (r.bottom + 6) + 'px';
  colMenu.classList.add('open');
}
colMenu.addEventListener('click', e=>{
  const act = e.target.dataset?.act;
  if (!act) return;
  const col = getCol(menuColId);
  colMenu.classList.remove('open');
  if (!col) return;
  if (act==='rename'){
    const el = boardEl.querySelector(`[data-col-id="${col.id}"] .col-title`);
    if (el) editInline(el, col.title, v => { col.title = v || col.title; save(); render(); });
  } else if (act==='delete'){
    if (board.columns.length <= 1){ showToast('Board must keep at least one column'); return; }
    destructive(b => { b.columns = b.columns.filter(c=>c.id!==col.id); }, 'Column deleted');
  }
});
document.getElementById('wipInput').addEventListener('change', e=>{
  const col = getCol(menuColId); if(!col) return;
  const v = parseInt(e.target.value, 10);
  col.wipLimit = (v && v > 0) ? v : null;
  save(); render();
  colMenu.classList.remove('open');
});
document.addEventListener('pointerdown', e=>{
  if (!colMenu.contains(e.target)) colMenu.classList.remove('open');
});

/* ============================================================
   DRAG & DROP  (Pointer Events, pixel-following)
   ============================================================ */
let drag = null;        // active drag state
let candidate = null;   // press awaiting drag threshold
let suppressClick = false;

boardEl.addEventListener('pointerdown', e=>{
  if (e.button !== 0) return;
  const cardEl = e.target.closest('.card');
  const headEl = e.target.closest('.col-head');
  if (cardEl && !e.target.closest('.card-del')){
    candidate = { kind:'card', id:cardEl.dataset.cardId, x:e.clientX, y:e.clientY, srcEl:cardEl };
    e.preventDefault();
  } else if (headEl){
    candidate = { kind:'col', id:headEl.closest('.column').dataset.colId, x:e.clientX, y:e.clientY, srcEl:headEl.closest('.column') };
    e.preventDefault();
  }
});

window.addEventListener('pointermove', e=>{
  if (!drag && candidate){
    const dx = e.clientX - candidate.x, dy = e.clientY - candidate.y;
    if (dx*dx + dy*dy > CFG.DRAG_THRESHOLD*CFG.DRAG_THRESHOLD){
      startDrag(candidate, e);
      candidate = null;
    }
  }
  if (drag){
    drag.px = e.clientX; drag.py = e.clientY;
    positionFloat();
    updatePlaceholder();
  }
});
window.addEventListener('pointerup', finishDrag);
window.addEventListener('pointercancel', cancelDrag);
window.addEventListener('keydown', e=>{
  if (e.key==='Escape'){
    if (drag){ e.preventDefault(); cancelDrag(); }
    else if (overlay.classList.contains('open')) closeModal();
    else colMenu.classList.remove('open');
  }
});

function startDrag(cand, e){
  suppressClick = true;
  setTimeout(()=>{ suppressClick = false; }, 0);
  if (cand.kind==='card') startCardDrag(cand, e);
  else startColDrag(cand, e);
  requestAnimationFrame(scrollTick);
}

/* ---------- card drag ---------- */
function startCardDrag(cand, e){
  const loc = findCard(cand.id); if(!loc) return;
  const cardEl = boardEl.querySelector(`[data-card-id="${cand.id}"]`);
  const r = cardEl.getBoundingClientRect();
  const float = cardEl.cloneNode(true);
  float.classList.remove('settle');
  float.classList.add('floating');
  float.style.width = r.width + 'px';
  document.body.appendChild(float);

  const ph = document.createElement('div');
  ph.className = 'card placeholder';
  ph.style.height = r.height + 'px';

  cardEl.style.display = 'none';
  cardEl.parentNode.insertBefore(ph, cardEl);

  drag = {
    kind:'card', id:cand.id,
    float, ph, srcEl:cardEl,
    offX: e.clientX - r.left, offY: e.clientY - r.top,
    px:e.clientX, py:e.clientY,
    from:{ colId: loc.col.id, index: loc.index },
    curColEl: cardEl.closest('.column'),
  };
  updateEmptyStates();
  positionFloat();
  updatePlaceholder();
}

function positionFloat(){
  if (!drag) return;
  drag.float.style.left = (drag.px - drag.offX) + 'px';
  drag.float.style.top  = (drag.py - drag.offY) + 'px';
}

function columnEls(){
  return [...boardEl.querySelectorAll('.column')]
    .filter(el => !el.classList.contains('placeholder') && el.style.display !== 'none');
}

function findColumnAt(x){
  const cols = columnEls();
  for (const c of cols){
    const r = c.getBoundingClientRect();
    if (x >= r.left && x <= r.right) return c;
  }
  // nearest by center
  let best=null, bd=Infinity;
  for (const c of cols){
    const r = c.getBoundingClientRect();
    const d = Math.abs(x - (r.left + r.width/2));
    if (d < bd){ bd=d; best=c; }
  }
  return best;
}

function computeCardVisIndex(body, y){
  const cards = [...body.children].filter(el =>
    el.classList.contains('card') && !el.classList.contains('placeholder') && !el.classList.contains('hidden'));
  for (let i=0;i<cards.length;i++){
    const r = cards[i].getBoundingClientRect();
    if (y < r.top + r.height/2) return i;
  }
  return cards.length;
}

function updatePlaceholder(){
  if (!drag) return;
  if (drag.kind === 'card'){
    const colEl = findColumnAt(drag.px);
    if (!colEl) return;
    drag.curColEl = colEl;
    const body = colEl.querySelector('.col-cards');
    const idx = computeCardVisIndex(body, drag.py);
    const vis = [...body.children].filter(el =>
      el.classList.contains('card') && !el.classList.contains('placeholder') && !el.classList.contains('hidden'));
    const ref = vis[idx] || null;
    if (drag.ph.parentNode !== body || drag.ph.nextSibling !== ref){
      body.insertBefore(drag.ph, ref);
      updateEmptyStates();
    }
  } else {
    const cols = columnEls();
    let idx = cols.length;
    for (let i=0;i<cols.length;i++){
      const r = cols[i].getBoundingClientRect();
      if (drag.px < r.left + r.width/2){ idx = i; break; }
    }
    const ref = cols[idx] || null;
    if (drag.ph.nextSibling !== ref){
      boardEl.insertBefore(drag.ph, ref);
    }
  }
}

/* ---------- column drag ---------- */
function startColDrag(cand, e){
  const colEl = boardEl.querySelector(`[data-col-id="${cand.id}"]`);
  if (!colEl) return;
  const r = colEl.getBoundingClientRect();
  const float = colEl.cloneNode(true);
  float.classList.add('floating');
  float.style.width = r.width + 'px';
  float.style.height = r.height + 'px';
  document.body.appendChild(float);

  const ph = document.createElement('div');
  ph.className = 'column placeholder';
  ph.style.height = r.height + 'px';

  colEl.style.display = 'none';
  boardEl.insertBefore(ph, colEl);

  boardEl.classList.add('dragging-col');
  drag = {
    kind:'col', id:cand.id,
    float, ph, srcEl:colEl,
    offX: e.clientX - r.left, offY: e.clientY - r.top,
    px:e.clientX, py:e.clientY,
    from:{ index: board.columns.findIndex(c=>c.id===cand.id) },
  };
  positionFloat();
  updatePlaceholder();
}

/* ---------- auto-scroll loop ---------- */
function scrollTick(){
  if (!drag) return;
  const bRect = boardEl.getBoundingClientRect();
  let scrolled = false;
  if (drag.px < bRect.left + CFG.EDGE_SCROLL_ZONE){
    boardEl.scrollLeft -= CFG.EDGE_SCROLL_SPEED; scrolled = true;
  } else if (drag.px > bRect.right - CFG.EDGE_SCROLL_ZONE){
    boardEl.scrollLeft += CFG.EDGE_SCROLL_SPEED; scrolled = true;
  }
  if (drag.kind === 'card' && drag.curColEl){
    const body = drag.curColEl.querySelector('.col-cards');
    const r = body.getBoundingClientRect();
    if (drag.py < r.top + CFG.COL_SCROLL_ZONE && body.scrollTop > 0){
      body.scrollTop -= CFG.COL_SCROLL_SPEED; scrolled = true;
    } else if (drag.py > r.bottom - CFG.COL_SCROLL_ZONE &&
               body.scrollTop < body.scrollHeight - body.clientHeight){
      body.scrollTop += CFG.COL_SCROLL_SPEED; scrolled = true;
    }
  }
  if (scrolled) updatePlaceholder();
  requestAnimationFrame(scrollTick);
}

/* ---------- drop / cancel ---------- */
function cleanupDrag(){
  if (!drag) return;
  drag.float.remove();
  drag.ph.remove();
  if (drag.srcEl) drag.srcEl.style.display = '';
  boardEl.classList.remove('dragging-col');
  drag = null;
  updateEmptyStates();
}

function finishDrag(){
  candidate = null;
  if (!drag) return;
  const d = drag;
  if (d.kind === 'card'){
    const body = d.ph.closest('.column').querySelector('.col-cards');
    // count visible cards sitting before the placeholder
    let visIdx = 0;
    for (const el of body.children){
      if (el === d.ph) break;
      if (el.classList.contains('card') && !el.classList.contains('hidden')) visIdx++;
    }
    const dstColId = d.ph.closest('.column').dataset.colId;
    cleanupDragFor(d);
    // mutate model
    const loc = findCard(d.id);
    if (loc){
      const [card] = loc.col.cards.splice(loc.index, 1);
      const dst = getCol(dstColId) || loc.col;
      const vis = dst.cards.filter(cardVisible);
      let mi;
      if (visIdx >= vis.length) mi = dst.cards.length;
      else mi = dst.cards.indexOf(vis[visIdx]);
      dst.cards.splice(mi, 0, card);
      save();
      render();
      const el = boardEl.querySelector(`[data-card-id="${d.id}"]`);
      if (el) el.classList.add('settle');
    }
  } else {
    const cols = columnEls();
    let idx = cols.length;
    for (let i=0;i<cols.length;i++){
      const r = cols[i].getBoundingClientRect();
      if (d.px < r.left + r.width/2){ idx = i; break; }
    }
    cleanupDragFor(d);
    const col = getCol(d.id);
    if (col){
      const without = board.columns.filter(c=>c.id!==d.id);
      board.columns = [...without.slice(0, idx), col, ...without.slice(idx)];
      save(); render();
    }
  }
  drag = null;
}

function cleanupDragFor(d){
  d.float.remove();
  d.ph.remove();
  if (d.srcEl) d.srcEl.style.display = '';
  boardEl.classList.remove('dragging-col');
  updateEmptyStates();
}

function cancelDrag(){
  candidate = null;
  if (!drag) return;
  cleanupDrag();  // model untouched — re-render restores exact original state
  render();
}

/* suppress the click that follows a drag */
boardEl.addEventListener('click', e=>{
  if (suppressClick){ e.stopPropagation(); e.preventDefault(); }
}, true);

/* ============================================================
   INIT
   ============================================================ */
document.getElementById('searchInput').value = filter.search;
render();
updateEmptyStates();
</script>
</body>
</html>
<!-- agent-meta {"model":"z-ai/glm-5.3-flash","provider":"openrouter","persona":"full","sessionId":"cfe79d87-e2d2-49a9-9c1d-00d79aca6a5c","tokensIn":543223,"tokensOut":24649,"tokensTotal":567872,"cost":0.019555764999999996,"turns":19,"toolCalls":18,"failedToolCalls":0,"timestamp":"2026-08-31T14:24:37.543Z"} -->