← Kanban results

Kanban

Qwen 3.6 27b MTP · composer

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 46,298 bytes · SHA-256 d85cd141ba6c
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Kanban Board</title>
<style>
/* ── css: layout ── */
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }

body {
  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
  background: #f4f5f7;
  color: #172b4d;
  overflow: hidden;
  height: 100vh;
}

#board-header {
  position: sticky;
  top: 0;
  z-index: 100;
  background: linear-gradient(135deg, #6B7BF7 0%, #4A5AF7 100%);
  padding: 12px 24px;
  display: flex;
  align-items: center;
  justify-content: space-between;
  box-shadow: 0 2px 8px rgba(0,0,0,0.15);
}

.header-left h1 {
  color: white;
  font-size: 20px;
  font-weight: 700;
  letter-spacing: -0.3px;
}

.header-controls {
  display: flex;
  align-items: center;
  gap: 12px;
  flex-wrap: wrap;
}

#search-input {
  padding: 6px 12px;
  border-radius: 6px;
  border: none;
  background: rgba(255,255,255,0.2);
  color: white;
  font-size: 13px;
  width: 180px;
  outline: none;
  transition: background 0.2s;
}

#search-input::placeholder { color: rgba(255,255,255,0.7); }
#search-input:focus { background: rgba(255,255,255,0.3); }

#filter-bar {
  display: flex;
  gap: 6px;
  align-items: center;
}

.filter-chip {
  padding: 4px 10px;
  border-radius: 12px;
  font-size: 11px;
  font-weight: 600;
  cursor: pointer;
  border: 2px solid transparent;
  transition: all 0.15s;
  text-transform: uppercase;
  letter-spacing: 0.3px;
}

.filter-chip.active {
  border-color: white;
  box-shadow: 0 0 0 2px rgba(255,255,255,0.4);
  transform: scale(1.05);
}

#board-container {
  height: calc(100vh - 56px);
  overflow-x: auto;
  overflow-y: hidden;
  padding: 20px;
  scroll-behavior: smooth;
}

#board-container::-webkit-scrollbar { height: 8px; }
#board-container::-webkit-scrollbar-track { background: #ebecf0; }
#board-container::-webkit-scrollbar-thumb { background: #c4c9d2; border-radius: 4px; }

#board {
  display: flex;
  gap: 14px;
  height: 100%;
  min-width: fit-content;
  align-items: flex-start;
}

.btn {
  padding: 6px 14px;
  border-radius: 6px;
  border: none;
  cursor: pointer;
  font-size: 13px;
  font-weight: 600;
  transition: all 0.15s;
}

.btn-primary {
  background: rgba(255,255,255,0.2);
  color: white;
}

.btn-primary:hover { background: rgba(255,255,255,0.35); }
/* ── css: columns-cards ── */
.column {
  min-width: 280px;
  max-width: 280px;
  background: #ebecf0;
  border-radius: 12px;
  display: flex;
  flex-direction: column;
  max-height: calc(100vh - 96px);
  transition: box-shadow 0.2s, transform 0.15s;
}

.column.drag-over {
  background: #e2e4ea;
}

.column.wip-exceeded {
  box-shadow: 0 0 0 3px rgba(255, 87, 87, 0.6);
}

.column-header {
  padding: 12px 14px 8px;
  display: flex;
  align-items: center;
  justify-content: space-between;
  cursor: grab;
  user-select: none;
  position: relative;
}

.column-header:active { cursor: grabbing; }

.column-title-group {
  display: flex;
  align-items: center;
  gap: 8px;
  flex: 1;
  min-width: 0;
}

.column-title {
  font-size: 13px;
  font-weight: 700;
  color: #5e6c84;
  text-transform: uppercase;
  letter-spacing: 0.3px;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

.column-title-input {
  font-size: 13px;
  font-weight: 700;
  color: #5e6c84;
  border: none;
  background: white;
  padding: 2px 6px;
  border-radius: 4px;
  outline: none;
  width: 100%;
}

.card-count {
  font-size: 12px;
  color: #8993a4;
  background: rgba(0,0,0,0.06);
  padding: 2px 7px;
  border-radius: 10px;
  font-weight: 600;
}

.column-menu-btn {
  background: none;
  border: none;
  cursor: pointer;
  color: #8993a4;
  padding: 4px;
  border-radius: 4px;
  font-size: 16px;
  line-height: 1;
}

.column-menu-btn:hover { background: rgba(0,0,0,0.08); color: #5e6c84; }

.column-body {
  flex: 1;
  overflow-y: auto;
  padding: 4px 8px 8px;
  display: flex;
  flex-direction: column;
  gap: 6px;
}

.column-body::-webkit-scrollbar { width: 6px; }
.column-body::-webkit-scrollbar-track { background: transparent; }
.column-body::-webkit-scrollbar-thumb { background: #c4c9d2; border-radius: 3px; }

.card-footer {
  padding: 8px 14px 12px;
}

.add-card-btn {
  width: 100%;
  padding: 7px;
  border: none;
  background: rgba(0,0,0,0.04);
  color: #5e6c84;
  font-size: 13px;
  cursor: pointer;
  border-radius: 8px;
  text-align: left;
  transition: all 0.15s;
}

.add-card-btn:hover { background: rgba(0,0,0,0.08); color: #172b4d; }

/* Cards */
.card {
  background: white;
  border-radius: 8px;
  padding: 10px 12px;
  cursor: grab;
  box-shadow: 0 1px 2px rgba(0,0,0,0.06), 0 0 1px rgba(0,0,0,0.04);
  transition: transform 0.15s ease, box-shadow 0.15s ease;
  position: relative;
}

.card:hover {
  transform: translateY(-2px);
  box-shadow: 0 4px 12px rgba(0,0,0,0.1), 0 0 1px rgba(0,0,0,0.06);
}

.card.dragging {
  opacity: 0;
}

.card-title {
  font-size: 14px;
  font-weight: 500;
  color: #172b4d;
  margin-bottom: 4px;
  line-height: 1.4;
  word-break: break-word;
}

.card-title-input {
  font-size: 14px;
  font-weight: 500;
  color: #172b4d;
  border: none;
  background: #f4f5f7;
  padding: 4px 6px;
  border-radius: 4px;
  outline: none;
  width: 100%;
}

.card-description {
  font-size: 12px;
  color: #8993a4;
  line-height: 1.4;
  margin-bottom: 6px;
  word-break: break-word;
}

.card-labels {
  display: flex;
  gap: 4px;
  flex-wrap: wrap;
  margin-bottom: 6px;
}

.label-chip {
  padding: 2px 8px;
  border-radius: 4px;
  font-size: 10px;
  font-weight: 700;
  text-transform: uppercase;
  letter-spacing: 0.3px;
}

.card-meta {
  display: flex;
  align-items: center;
  gap: 8px;
  font-size: 11px;
  color: #8993a4;
}

.due-date {
  display: flex;
  align-items: center;
  gap: 3px;
}

.due-date.overdue { color: #eb5a46; font-weight: 600; }

.card-actions {
  position: absolute;
  top: 8px;
  right: 8px;
  display: none;
  gap: 2px;
}

.card:hover .card-actions { display: flex; }

.card-action-btn {
  background: rgba(0,0,0,0.05);
  border: none;
  cursor: pointer;
  padding: 3px 5px;
  border-radius: 4px;
  font-size: 12px;
  color: #8993a4;
}

.card-action-btn:hover { background: rgba(0,0,0,0.1); }

/* Drop placeholder */
.drop-placeholder {
  height: 60px;
  border: 2px dashed #b0bec5;
  border-radius: 8px;
  background: rgba(176, 190, 197, 0.1);
  transition: all 0.1s ease;
}

/* Empty column drop zone */
.empty-drop-zone {
  min-height: 80px;
  border: 2px dashed #c4c9d2;
  border-radius: 8px;
  display: flex;
  align-items: center;
  justify-content: center;
  color: #b0bec5;
  font-size: 13px;
}

/* Dragging card (floating) */
.drag-ghost {
  position: fixed;
  pointer-events: none;
  z-index: 9999;
  transform: rotate(2deg);
  box-shadow: 0 8px 30px rgba(0,0,0,0.25);
  border-radius: 8px;
  background: white;
  padding: 10px 12px;
  min-width: 240px;
  max-width: 280px;
}

/* Column drag ghost */
.column-ghost {
  position: fixed;
  pointer-events: none;
  z-index: 9999;
  opacity: 0.85;
  transform: rotate(1deg);
  box-shadow: 0 8px 30px rgba(0,0,0,0.25);
}

/* Column placeholder */
.column-placeholder {
  min-width: 280px;
  max-width: 280px;
  height: 200px;
  border: 2px dashed #b0bec5;
  border-radius: 12px;
  background: rgba(176, 190, 197, 0.1);
}

/* Toast */
#toast-container {
  position: fixed;
  bottom: 24px;
  left: 50%;
  transform: translateX(-50%);
  z-index: 10000;
  display: flex;
  flex-direction: column;
  gap: 8px;
  align-items: center;
}

.toast {
  background: #363b42;
  color: white;
  padding: 10px 20px;
  border-radius: 8px;
  font-size: 13px;
  box-shadow: 0 4px 12px rgba(0,0,0,0.3);
  display: flex;
  align-items: center;
  gap: 12px;
  animation: toastIn 0.25s ease;
}

.toast-undo {
  background: none;
  border: none;
  color: #61bdff;
  cursor: pointer;
  font-weight: 600;
  font-size: 13px;
  padding: 2px 8px;
  border-radius: 4px;
}

.toast-undo:hover { background: rgba(97, 189, 255, 0.1); }

@keyframes toastIn {
  from { opacity: 0; transform: translateY(10px); }
  to { opacity: 1; transform: translateY(0); }
}

/* Modal */
#modal-overlay {
  position: fixed;
  inset: 0;
  background: rgba(0,0,0,0.5);
  z-index: 9000;
  display: flex;
  align-items: center;
  justify-content: center;
}

#modal-content {
  background: white;
  border-radius: 12px;
  padding: 24px;
  min-width: 360px;
  max-width: 500px;
  box-shadow: 0 8px 30px rgba(0,0,0,0.2);
}

.modal-title {
  font-size: 18px;
  font-weight: 700;
  margin-bottom: 16px;
}

.modal-field {
  margin-bottom: 14px;
}

.modal-field label {
  display: block;
  font-size: 12px;
  font-weight: 600;
  color: #5e6c84;
  margin-bottom: 4px;
  text-transform: uppercase;
}

.modal-field input, .modal-field textarea {
  width: 100%;
  padding: 8px 10px;
  border: 2px solid #dfe1e6;
  border-radius: 6px;
  font-size: 14px;
  outline: none;
  transition: border-color 0.15s;
}

.modal-field input:focus, .modal-field textarea:focus {
  border-color: #6B7BF7;
}

.modal-field textarea { resize: vertical; min-height: 60px; }

.label-selector {
  display: flex;
  gap: 6px;
  flex-wrap: wrap;
}

.label-option {
  padding: 4px 12px;
  border-radius: 6px;
  font-size: 12px;
  font-weight: 600;
  cursor: pointer;
  border: 2px solid transparent;
  transition: all 0.15s;
}

.label-option.selected {
  border-color: #333;
}

.modal-actions {
  display: flex;
  justify-content: flex-end;
  gap: 8px;
  margin-top: 20px;
}

.btn-cancel {
  background: #f4f5f7;
  color: #5e6c84;
}

.btn-cancel:hover { background: #ebecf0; }

.btn-save {
  background: #6B7BF7;
  color: white;
}

.btn-save:hover { background: #5a6ae6; }

.btn-danger {
  background: #eb5a46;
  color: white;
}

.btn-danger:hover { background: #cf513d; }

/* Column menu dropdown */
.column-dropdown {
  position: absolute;
  top: 100%;
  right: 0;
  background: white;
  border-radius: 8px;
  box-shadow: 0 4px 20px rgba(0,0,0,0.15);
  z-index: 200;
  min-width: 160px;
  padding: 4px;
}

.dropdown-item {
  padding: 8px 12px;
  font-size: 13px;
  cursor: pointer;
  border-radius: 4px;
  display: flex;
  align-items: center;
  gap: 8px;
}

.dropdown-item:hover { background: #f4f5f7; }
.dropdown-item.danger { color: #eb5a46; }
</style>
</head>
<body>
<div id="board-header">
  <div class="header-left">
    <h1>Kanban Board</h1>
  </div>
  <div class="header-controls">
    <input type="text" id="search-input" placeholder="Search cards…">
    <div id="filter-bar"></div>
    <button id="add-column-btn" class="btn btn-primary">+ Add Column</button>
  </div>
</div>
<div id="board-container">
  <div id="board"></div>
</div>
<div id="toast-container"></div>
<div id="modal-overlay" style="display:none">
  <div id="modal-content"></div>
</div>
<script>
'use strict';

// ── const: STORAGE_KEY ──
// ============================================================
// CONSTANTS & CONFIGURATION
// ============================================================
const STORAGE_KEY = 'kanban-board-state';

// ── const: TOAST_DURATION ──
const TOAST_DURATION = 4000;

// ── const: UNDO_TIMEOUT ──
const UNDO_TIMEOUT = 5000;

// ── const: DRAG_SCROLL_MARGIN ──
const DRAG_SCROLL_MARGIN = 60;

// ── const: DRAG_SCROLL_SPEED ──
const DRAG_SCROLL_SPEED = 12;

// ── const: CARD_MIN_HEIGHT ──
const CARD_MIN_HEIGHT = 50;

// ── const: LABEL_PALETTE ──
const LABEL_PALETTE = [
  { id: 'bug', name: 'Bug', color: '#eb5a46' },
  { id: 'feature', name: 'Feature', color: '#61bd4f' },
  { id: 'enhancement', name: 'Enhancement', color: '#0079bf' },
  { id: 'design', name: 'Design', color: '#c377e0' },
  { id: 'docs', name: 'Docs', color: '#f2d600' },
  { id: 'urgent', name: 'Urgent', color: '#ff9f1a' },
];

// ── const: DEFAULT_COLUMNS ──
const DEFAULT_COLUMNS = [
  { title: 'Backlog', wipLimit: null },
  { title: 'To Do', wipLimit: null },
  { title: 'In Progress', wipLimit: 3 },
  { title: 'Review', wipLimit: null },
  { title: 'Done', wipLimit: null },
];

// ── const: DEFAULT_CARDS ──
const DEFAULT_CARDS = [
  { titles: ['Research competitors', 'Define user personas'], labels: [['design'], []] },
  { titles: ['Set up CI/CD pipeline', 'Write API docs', 'Create wireframes'], labels: [['docs'], [], ['design']] },
  { titles: ['Implement auth flow', 'Build dashboard UI'], labels: [['feature'], ['feature']] },
  { titles: ['Fix login redirect bug'], labels: [['bug']] },
  { titles: ['Deploy to production'], labels: [] },
];

// ── function: generateId ──
function generateId() {
  return Date.now().toString(36) + Math.random().toString(36).slice(2, 8);
}

// ── function: deepClone ──
function deepClone(obj) {
  return JSON.parse(JSON.stringify(obj));
}

// ── function: createDefaultBoard ──
function createDefaultBoard() {
  const columns = DEFAULT_COLUMNS.map((colDef, ci) => ({
    id: generateId(),
    title: colDef.title,
    wipLimit: colDef.wipLimit,
    cards: [],
  }));

  for (let ci = 0; ci < columns.length && ci < DEFAULT_CARDS.length; ci++) {
    const cardGroup = DEFAULT_CARDS[ci];
    for (let i = 0; i < cardGroup.titles.length; i++) {
      columns[ci].cards.push({
        id: generateId(),
        title: cardGroup.titles[i],
        description: '',
        labelIds: Array.isArray(cardGroup.labels[i]) ? cardGroup.labels[i] : [],
        dueDate: '',
      });
    }
  }

  return {
    columns,
    filterLabels: [],
    searchQuery: '',
  };
}

// ── let: boardState ──
// ============================================================
// STATE MANAGEMENT & PERSISTENCE
// ============================================================
let boardState = null;

// ── let: undoStack ──
let undoStack = [];

// ── function: saveBoard ──
function saveBoard() {
  try {
    localStorage.setItem(STORAGE_KEY, JSON.stringify(boardState));
  } catch (e) { /* quota exceeded — ignore */ }
}

// ── function: loadBoard ──
function loadBoard() {
  try {
    const raw = localStorage.getItem(STORAGE_KEY);
    if (raw) {
      boardState = JSON.parse(raw);
      // Ensure filterLabels and searchQuery exist for older saves
      if (!boardState.filterLabels) boardState.filterLabels = [];
      if (!boardState.searchQuery) boardState.searchQuery = '';
      // Validate card data — ensure labelIds is always an array
      if (Array.isArray(boardState.columns)) {
        boardState.columns.forEach(col => {
          if (Array.isArray(col.cards)) {
            col.cards.forEach(card => {
              if (!Array.isArray(card.labelIds)) card.labelIds = [];
            });
          }
        });
      }
      return true;
    }
  } catch (e) { /* corrupted — ignore */ }
  return false;
}

// ── function: pushUndo ──
function pushUndo(action, snapshot) {
  undoStack.push({ action, state: deepClone(snapshot), timestamp: Date.now() });
  // Keep only last 20 undos
  if (undoStack.length > 20) undoStack.shift();
}

// ── function: performUndo ──
function performUndo() {
  const entry = undoStack.pop();
  if (!entry) return false;
  boardState = deepClone(entry.state);
  saveBoard();
  renderAll();
  return true;
}

// ── function: showToast ──
// ============================================================
// TOAST NOTIFICATIONS & UNDO
// ============================================================
function showToast(message, undoFn) {
  const container = document.getElementById('toast-container');
  const toast = document.createElement('div');
  toast.className = 'toast';

  const msgSpan = document.createElement('span');
  msgSpan.textContent = message;
  toast.appendChild(msgSpan);

  if (undoFn) {
    const undoBtn = document.createElement('button');
    undoBtn.className = 'toast-undo';
    undoBtn.textContent = 'Undo';
    undoBtn.addEventListener('click', () => {
      undoFn();
      toast.remove();
    });
    toast.appendChild(undoBtn);
  }

  container.appendChild(toast);

  const timer = setTimeout(() => {
    if (toast.parentNode) {
      toast.style.opacity = '0';
      toast.style.transition = 'opacity 0.3s';
      setTimeout(() => toast.remove(), 300);
    }
  }, TOAST_DURATION);
}

// ── function: openModal ──
// ============================================================
// MODAL SYSTEM
// ============================================================
function openModal(html) {
  const overlay = document.getElementById('modal-overlay');
  const content = document.getElementById('modal-content');
  content.innerHTML = html;
  overlay.style.display = 'flex';
  // Focus first input
  const firstInput = content.querySelector('input, textarea');
  if (firstInput) setTimeout(() => firstInput.focus(), 50);
}

// ── function: closeModal ──
function closeModal() {
  document.getElementById('modal-overlay').style.display = 'none';
}

// ── function: openCardModal ──
function openCardModal(columnId, cardData) {
  const isEdit = !!cardData;
  const labelsHtml = LABEL_PALETTE.map(l => {
    const selected = (cardData && cardData.labelIds.includes(l.id)) ? 'selected' : '';
    return `<div class="label-option ${selected}" data-label-id="${l.id}" style="background:${l.color};color:white">${l.name}</div>`;
  }).join('');

  openModal(`
    <div class="modal-title">${isEdit ? 'Edit Card' : 'New Card'}</div>
    <div class="modal-field">
      <label>Title</label>
      <input type="text" id="card-modal-title" value="${escapeHtml(cardData?.title || '')}" />
    </div>
    <div class="modal-field">
      <label>Description</label>
      <textarea id="card-modal-desc">${escapeHtml(cardData?.description || '')}</textarea>
    </div>
    <div class="modal-field">
      <label>Labels</label>
      <div class="label-selector" id="modal-labels">${labelsHtml}</div>
    </div>
    <div class="modal-field">
      <label>Due Date</label>
      <input type="date" id="card-modal-date" value="${cardData?.dueDate || ''}" />
    </div>
    <div class="modal-actions">
      ${isEdit ? `<button class="btn btn-danger" id="card-modal-delete">Delete</button>` : ''}
      <button class="btn btn-cancel" onclick="closeModal()">Cancel</button>
      <button class="btn btn-save" id="card-modal-save">${isEdit ? 'Save' : 'Add Card'}</button>
    </div>
  `);

  // Label toggle handlers
  document.querySelectorAll('#modal-labels .label-option').forEach(el => {
    el.addEventListener('click', () => el.classList.toggle('selected'));
  });

  document.getElementById('card-modal-save').addEventListener('click', () => {
    const title = document.getElementById('card-modal-title').value.trim();
    if (!title) return;
    const desc = document.getElementById('card-modal-desc').value.trim();
    const date = document.getElementById('card-modal-date').value;
    const labelIds = [...document.querySelectorAll('#modal-labels .label-option.selected')]
      .map(el => el.dataset.labelId);

    if (isEdit) {
      pushUndo('edit card', boardState);
      const col = boardState.columns.find(c => c.id === columnId);
      const card = col.cards.find(c => c.id === cardData.id);
      card.title = title;
      card.description = desc;
      card.dueDate = date;
      card.labelIds = labelIds;
    } else {
      pushUndo('add card', boardState);
      const col = boardState.columns.find(c => c.id === columnId);
      col.cards.push({
        id: generateId(),
        title, description: desc, dueDate: date, labelIds,
      });
    }

    saveBoard();
    renderAll();
    closeModal();
  });

  if (isEdit) {
    document.getElementById('card-modal-delete').addEventListener('click', () => {
      pushUndo('delete card', boardState);
      const col = boardState.columns.find(c => c.id === columnId);
      col.cards = col.cards.filter(c => c.id !== cardData.id);
      saveBoard();
      renderAll();
      closeModal();
      showToast('Card deleted', performUndo);
    });
  }
}

// ── function: escapeHtml ──
function escapeHtml(str) {
  const div = document.createElement('div');
  div.textContent = str;
  return div.innerHTML;
}

// ── function: renderAll ──
// ============================================================
// RENDERING — derives entirely from boardState
// ============================================================
function renderAll() {
  renderFilterBar();
  renderBoard();
}

// ── function: getFilteredCards ──
function getFilteredCards(cards) {
  const query = (boardState.searchQuery || '').toLowerCase().trim();
  return cards.filter(card => {
    // Label filter: card must have at least one selected label
    if (boardState.filterLabels.length > 0) {
      const hasLabel = boardState.filterLabels.some(fl => card.labelIds.includes(fl));
      if (!hasLabel) return false;
    }
    // Search filter
    if (query && !card.title.toLowerCase().includes(query)) return false;
    return true;
  });
}

// ── function: renderFilterBar ──
function renderFilterBar() {
  const bar = document.getElementById('filter-bar');
  bar.innerHTML = '';

  LABEL_PALETTE.forEach(label => {
    const chip = document.createElement('div');
    chip.className = 'filter-chip' + (boardState.filterLabels.includes(label.id) ? ' active' : '');
    chip.style.background = label.color;
    chip.style.color = 'white';
    chip.textContent = label.name;
    chip.addEventListener('click', () => {
      if (boardState.filterLabels.includes(label.id)) {
        boardState.filterLabels = boardState.filterLabels.filter(l => l !== label.id);
      } else {
        boardState.filterLabels.push(label.id);
      }
      saveBoard();
      renderAll();
    });
    bar.appendChild(chip);
  });
}

// ── function: renderBoard ──
function renderBoard() {
  const board = document.getElementById('board');
  board.innerHTML = '';

  boardState.columns.forEach(col => {
    const colEl = createColumnElement(col);
    board.appendChild(colEl);
  });
}

// ── function: createColumnElement ──
function createColumnElement(col) {
  const filteredCards = getFilteredCards(col.cards);
  const isWipExceeded = col.wipLimit !== null && col.cards.length > col.wipLimit;

  const el = document.createElement('div');
  el.className = 'column' + (isWipExceeded ? ' wip-exceeded' : '');
  el.dataset.columnId = col.id;

  // Header
  const header = document.createElement('div');
  header.className = 'column-header';
  header.innerHTML = `
    <div class="column-title-group">
      <span class="column-title">${escapeHtml(col.title)}</span>
      <span class="card-count">${col.cards.length}</span>
    </div>
    <button class="column-menu-btn" title="Column options">⋯</button>
  `;

  // Column drag handle (entire header)
  header.addEventListener('mousedown', (e) => {
    if (e.target.classList.contains('column-menu-btn')) return;
    startColumnDrag(e, col.id);
  });

  el.appendChild(header);

  // Body (cards container)
  const body = document.createElement('div');
  body.className = 'column-body';
  body.dataset.columnId = col.id;

  if (filteredCards.length === 0 && col.cards.length > 0) {
    // Cards exist but filtered out — show empty state
    const emptyMsg = document.createElement('div');
    emptyMsg.className = 'empty-drop-zone';
    emptyMsg.textContent = 'No matching cards';
    body.appendChild(emptyMsg);
  } else if (col.cards.length === 0) {
    // Truly empty column
    const emptyZone = document.createElement('div');
    emptyZone.className = 'empty-drop-zone';
    emptyZone.textContent = 'Drop cards here';
    body.appendChild(emptyZone);
  }

  filteredCards.forEach(card => {
    body.appendChild(createCardElement(card, col.id));
  });

  el.appendChild(body);

  // Footer with add button
  const footer = document.createElement('div');
  footer.className = 'card-footer';
  const addBtn = document.createElement('button');
  addBtn.className = 'add-card-btn';
  addBtn.textContent = '+ Add a card';
  addBtn.addEventListener('click', () => openCardModal(col.id, null));
  footer.appendChild(addBtn);
  el.appendChild(footer);

  // Column menu
  const menuBtn = header.querySelector('.column-menu-btn');
  menuBtn.addEventListener('click', (e) => {
    e.stopPropagation();
    closeAllDropdowns();
    showColumnMenu(el, col.id);
  });

  return el;
}

// ── function: createCardElement ──
function createCardElement(card, columnId) {
  const el = document.createElement('div');
  el.className = 'card';
  el.dataset.cardId = card.id;
  el.dataset.columnId = columnId;

  // Labels
  let labelsHtml = '';
  if (card.labelIds && card.labelIds.length > 0) {
    const chips = card.labelIds.map(lid => {
      const label = LABEL_PALETTE.find(l => l.id === lid);
      return label ? `<span class="label-chip" style="background:${label.color};color:white">${label.name}</span>` : '';
    }).filter(Boolean).join('');
    if (chips) labelsHtml = `<div class="card-labels">${chips}</div>`;
  }

  // Due date
  let dueDateHtml = '';
  if (card.dueDate) {
    const today = new Date();
    today.setHours(0, 0, 0, 0);
    const due = new Date(card.dueDate + 'T00:00:00');
    const isOverdue = due < today;
    dueDateHtml = `<span class="due-date ${isOverdue ? 'overdue' : ''}">📅 ${card.dueDate}</span>`;
  }

  el.innerHTML = `
    <div class="card-actions">
      <button class="card-action-btn card-edit-btn" title="Edit">✏️</button>
      <button class="card-action-btn card-delete-btn" title="Delete">🗑️</button>
    </div>
    ${labelsHtml}
    <div class="card-title">${escapeHtml(card.title)}</div>
    ${card.description ? `<div class="card-description">${escapeHtml(card.description)}</div>` : ''}
    <div class="card-meta">
      ${dueDateHtml}
    </div>
  `;

  // Card drag start
  el.addEventListener('mousedown', (e) => {
    if (e.target.closest('.card-actions')) return;
    e.preventDefault();
    startCardDrag(e, card.id, columnId);
  });

  // Edit button
  el.querySelector('.card-edit-btn').addEventListener('click', (e) => {
    e.stopPropagation();
    const col = boardState.columns.find(c => c.id === columnId);
    const cardData = col.cards.find(c => c.id === card.id);
    openCardModal(columnId, cardData);
  });

  // Delete button
  el.querySelector('.card-delete-btn').addEventListener('click', (e) => {
    e.stopPropagation();
    pushUndo('delete card', boardState);
    const col = boardState.columns.find(c => c.id === columnId);
    col.cards = col.cards.filter(c => c.id !== card.id);
    saveBoard();
    renderAll();
    showToast('Card deleted', performUndo);
  });

  // Inline title editing on double-click
  el.querySelector('.card-title').addEventListener('dblclick', (e) => {
    e.stopPropagation();
    startInlineEdit(el, card, columnId, 'title');
  });

  return el;
}

// ── function: startInlineEdit ──
function startInlineEdit(cardEl, card, columnId, field) {
  const titleEl = cardEl.querySelector('.card-title');
  const input = document.createElement('input');
  input.className = 'card-title-input';
  input.value = card.title;
  titleEl.replaceWith(input);
  input.focus();
  input.select();

  const commit = () => {
    const newTitle = input.value.trim();
    if (newTitle && newTitle !== card.title) {
      pushUndo('edit card', boardState);
      const col = boardState.columns.find(c => c.id === columnId);
      const c = col.cards.find(cd => cd.id === card.id);
      c.title = newTitle;
      saveBoard();
    }
    renderAll();
  };

  input.addEventListener('blur', commit);
  input.addEventListener('keydown', (e) => {
    if (e.key === 'Enter') { e.preventDefault(); input.blur(); }
    if (e.key === 'Escape') { input.value = card.title; input.blur(); }
  });
}

// ── function: showColumnMenu ──
function showColumnMenu(columnEl, columnId) {
  closeAllDropdowns();
  const col = boardState.columns.find(c => c.id === columnId);
  const menuBtn = columnEl.querySelector('.column-menu-btn');

  const dropdown = document.createElement('div');
  dropdown.className = 'column-dropdown';

  // Rename item
  const renameItem = document.createElement('div');
  renameItem.className = 'dropdown-item';
  renameItem.textContent = '✏️ Rename';
  renameItem.addEventListener('click', () => {
    closeAllDropdowns();
    const titleSpan = columnEl.querySelector('.column-title');
    const input = document.createElement('input');
    input.className = 'column-title-input';
    input.value = col.title;
    titleSpan.replaceWith(input);
    input.focus();
    input.select();

    const commit = () => {
      const newTitle = input.value.trim() || col.title;
      if (newTitle !== col.title) {
        pushUndo('rename column', boardState);
        col.title = newTitle;
        saveBoard();
      }
      renderAll();
    };

    input.addEventListener('blur', commit);
    input.addEventListener('keydown', (e) => {
      if (e.key === 'Enter') { e.preventDefault(); input.blur(); }
      if (e.key === 'Escape') { input.value = col.title; input.blur(); }
    });
  });

  // WIP limit item
  const wipItem = document.createElement('div');
  wipItem.className = 'dropdown-item';
  wipItem.textContent = col.wipLimit !== null ? `WIP Limit: ${col.wipLimit}` : 'Set WIP Limit…';
  wipItem.addEventListener('click', () => {
    closeAllDropdowns();
    const val = prompt(`Set WIP limit for "${col.title}" (leave empty to remove):`, col.wipLimit !== null ? String(col.wipLimit) : '');
    if (val === null) return; // cancelled
    pushUndo('change wip', boardState);
    col.wipLimit = val.trim() === '' ? null : parseInt(val, 10) || null;
    saveBoard();
    renderAll();
  });

  // Delete item
  const deleteItem = document.createElement('div');
  deleteItem.className = 'dropdown-item danger';
  deleteItem.textContent = '🗑️ Delete Column';
  if (boardState.columns.length <= 1) {
    deleteItem.style.opacity = '0.4';
    deleteItem.style.pointerEvents = 'none';
  } else {
    deleteItem.addEventListener('click', () => {
      closeAllDropdowns();
      pushUndo('delete column', boardState);
      const cardCount = col.cards.length;
      boardState.columns = boardState.columns.filter(c => c.id !== columnId);
      saveBoard();
      renderAll();
      showToast(`Column "${col.title}" deleted${cardCount ? ` (${cardCount} cards)` : ''}`, performUndo);
    });
  }

  dropdown.appendChild(renameItem);
  dropdown.appendChild(wipItem);
  dropdown.appendChild(deleteItem);

  const header = columnEl.querySelector('.column-header');
  header.style.position = 'relative';
  header.appendChild(dropdown);

  // Close on outside click
  setTimeout(() => {
    document.addEventListener('click', function closeMenu(e) {
      if (!dropdown.contains(e.target)) {
        document.removeEventListener('click', closeMenu);
        dropdown.remove();
      }
    });
  }, 0);
}

// ── function: closeAllDropdowns ──
function closeAllDropdowns() {
  document.querySelectorAll('.column-dropdown').forEach(d => d.remove());
}

// ── let: cardDrag ──
// ============================================================
// DRAG & DROP — Card Dragging (Pointer Events)
// ============================================================
let cardDrag = null;

// ── function: startCardDrag ──
// { cardId, sourceColumnId, ghostEl, placeholderEl, startX, startY }

function startCardDrag(e, cardId, columnId) {
  closeAllDropdowns();

  const cardEl = e.target.closest('.card');
  if (!cardEl) return;

  const col = boardState.columns.find(c => c.id === columnId);
  const card = col.cards.find(c => c.id === cardId);
  if (!card || !col) return;

  // Create ghost element (follows cursor)
  const rect = cardEl.getBoundingClientRect();
  const ghost = document.createElement('div');
  ghost.className = 'drag-ghost';
  ghost.innerHTML = cardEl.innerHTML;
  ghost.style.width = rect.width + 'px';
  ghost.style.left = rect.left + 'px';
  ghost.style.top = rect.top + 'px';

  // Hide original
  cardEl.classList.add('dragging');

  document.body.appendChild(ghost);

  // Create placeholder in the column body
  const placeholder = document.createElement('div');
  placeholder.className = 'drop-placeholder';
  placeholder.dataset.placeholder = 'true';

  // Insert placeholder where card was
  const colBody = cardEl.parentElement;
  colBody.insertBefore(placeholder, cardEl);

  cardDrag = {
    cardId,
    sourceColumnId: columnId,
    ghostEl: ghost,
    placeholderEl: placeholder,
    offsetX: e.clientX - rect.left,
    offsetY: e.clientY - rect.top,
    currentColumnId: columnId,
    scrollRAF: null,
  };

  document.addEventListener('mousemove', onCardDragMove);
  document.addEventListener('mouseup', onCardDragEnd);
  document.addEventListener('keydown', onCardDragKey);
}

// ── function: onCardDragKey ──
function onCardDragKey(e) {
  if (e.key === 'Escape') {
    cancelCardDrag();
    e.preventDefault();
  }
}

// ── function: cancelCardDrag ──
function cancelCardDrag() {
  if (!cardDrag) return;
  const { cardId, sourceColumnId, ghostEl, placeholderEl } = cardDrag;

  // Remove ghost and placeholder
  ghostEl.remove();
  placeholderEl.remove();

  // Restore original card visibility
  const cardEl = document.querySelector(`.card[data-card-id="${cardId}"]`);
  if (cardEl) cardEl.classList.remove('dragging');

  // Re-render to restore exact state
  renderAll();

  cleanupCardDragListeners();
  cardDrag = null;
}

// ── function: onCardDragMove ──
function onCardDragMove(e) {
  if (!cardDrag) return;

  const { ghostEl, placeholderEl, offsetX, offsetY } = cardDrag;

  // Move ghost to follow cursor
  ghostEl.style.left = (e.clientX - offsetX) + 'px';
  ghostEl.style.top = (e.clientY - offsetY) + 'px';

  // Auto-scroll
  handleAutoScroll(e);

  // Find drop target column and position
  const dropInfo = findCardDropTarget(e.clientX, e.clientY);

  if (!dropInfo) {
    cardDrag = null;
    return;
  }

  const { columnId, insertIndex } = dropInfo;
  cardDrag.currentColumnId = columnId;

  // Move placeholder to the right position
  const targetColBody = document.querySelector(`.column-body[data-column-id="${columnId}"]`);
  if (!targetColBody) return;

  // Remove any empty-drop-zone from the way
  const existingPlaceholder = targetColBody.querySelector('.drop-placeholder');
  if (existingPlaceholder && existingPlaceholder !== placeholderEl) {
    existingPlaceholder.remove();
  }

  // Find visible cards in this column body (excluding placeholders and dragged card)
  const visibleCards = [...targetColBody.querySelectorAll(':scope > .card:not(.dragging)')];

  let insertBefore;
  if (insertIndex >= visibleCards.length) {
    // Insert at end — before any empty-drop-zone or add to end
    insertBefore = targetColBody.querySelector('.empty-drop-zone');
    if (!insertBefore) insertBefore = null;
  } else {
    insertBefore = visibleCards[insertIndex];
  }

  targetColBody.insertBefore(placeholderEl, insertBefore);

  // Remove empty drop zone if present
  const emptyZone = targetColBody.querySelector('.empty-drop-zone');
  if (emptyZone) emptyZone.remove();
}

// ── function: onCardDragEnd ──
function onCardDragEnd(e) {
  if (!cardDrag) return;

  const { cardId, sourceColumnId } = cardDrag;
  const dropInfo = findCardDropTarget(e.clientX, e.clientY);

  // Remove ghost and placeholder
  cardDrag.ghostEl.remove();
  cardDrag.placeholderEl.remove();

  cleanupCardDragListeners();

  if (dropInfo) {
    const { columnId: targetColumnId, insertIndex } = dropInfo;

    pushUndo('move card', boardState);

    // Find and remove the card from source
    const srcCol = boardState.columns.find(c => c.id === sourceColumnId);
    const cardIdx = srcCol.cards.findIndex(c => c.id === cardId);
    if (cardIdx === -1) { cardDrag = null; renderAll(); return; }

    const [movedCard] = srcCol.cards.splice(cardIdx, 1);

    // Insert into target at the right position among ALL cards (not just filtered)
    const tgtCol = boardState.columns.find(c => c.id === targetColumnId);

    // We need to map insertIndex from visible/filtered cards to actual card index
    const allCards = tgtCol.cards;
    const filteredCards = getFilteredCards(allCards);

    let actualInsertIndex;
    if (insertIndex >= filteredCards.length) {
      actualInsertIndex = allCards.length;
    } else {
      // Find the target card in the full list
      const targetCardId = filteredCards[insertIndex].id;
      actualInsertIndex = allCards.findIndex(c => c.id === targetCardId);
      if (actualInsertIndex === -1) actualInsertIndex = allCards.length;
    }

    tgtCol.cards.splice(actualInsertIndex, 0, movedCard);
    saveBoard();
  }

  cardDrag = null;
  renderAll();
}

// ── function: cleanupCardDragListeners ──
function cleanupCardDragListeners() {
  document.removeEventListener('mousemove', onCardDragMove);
  document.removeEventListener('mouseup', onCardDragEnd);
  document.removeEventListener('keydown', onCardDragKey);
  if (cardDrag?.scrollRAF) cancelAnimationFrame(cardDrag.scrollRAF);
}

// ── function: findCardDropTarget ──
function findCardDropTarget(clientX, clientY) {
  // Find which column body the cursor is over
  const columns = document.querySelectorAll('.column-body');
  let targetColBody = null;
  let targetColumnId = null;

  for (const colBody of columns) {
    const rect = colBody.getBoundingClientRect();
    if (clientX >= rect.left && clientX <= rect.right &&
        clientY >= rect.top && clientY <= rect.bottom) {
      targetColBody = colBody;
      targetColumnId = colBody.dataset.columnId;
      break;
    }
  }

  // Also check if cursor is over an empty column (the whole column element)
  if (!targetColBody) {
    const columnsFull = document.querySelectorAll('.column');
    for (const colEl of columnsFull) {
      const rect = colEl.getBoundingClientRect();
      if (clientX >= rect.left && clientX <= rect.right &&
          clientY >= rect.top && clientY <= rect.bottom) {
        targetColBody = colEl.querySelector('.column-body');
        targetColumnId = colEl.dataset.columnId;
        break;
      }
    }
  }

  if (!targetColBody || !targetColumnId) return null;

  // Find visible cards in this column (excluding dragged card and placeholders)
  const visibleCards = [...targetColBody.querySelectorAll(':scope > .card:not(.dragging)')];

  if (visibleCards.length === 0) {
    return { columnId: targetColumnId, insertIndex: 0 };
  }

  // Find the closest card to drop before/after
  let bestIndex = visibleCards.length; // default: append at end
  for (let i = 0; i < visibleCards.length; i++) {
    const rect = visibleCards[i].getBoundingClientRect();
    const midY = rect.top + rect.height / 2;

    if (clientY < midY) {
      bestIndex = i;
      break;
    }
  }

  return { columnId: targetColumnId, insertIndex: bestIndex };
}

// ── function: handleAutoScroll ──
function handleAutoScroll(e) {
  const { clientX, clientY } = e;
  const margin = DRAG_SCROLL_MARGIN;
  const speed = DRAG_SCROLL_SPEED;

  // Horizontal board scroll
  const boardContainer = document.getElementById('board-container');
  if (clientX < margin) {
    boardContainer.scrollBy(speed * -1, 0);
  } else if (clientX > window.innerWidth - margin) {
    boardContainer.scrollBy(speed, 0);
  }

  // Vertical column scroll — find the column body under cursor
  const columns = document.querySelectorAll('.column-body');
  for (const colBody of columns) {
    const rect = colBody.getBoundingClientRect();
    if (clientX >= rect.left && clientX <= rect.right &&
        clientY >= rect.top && clientY <= rect.bottom) {
      if (clientY < rect.top + margin) {
        colBody.scrollTop -= speed;
      } else if (clientY > rect.bottom - margin) {
        colBody.scrollTop += speed;
      }
      break;
    }
  }
}

// ── let: columnDrag ──
// ============================================================
// DRAG & DROP — Column Reordering
// ============================================================
let columnDrag = null;

// ── function: startColumnDrag ──
function startColumnDrag(e, columnId) {
  if (e.button !== 0) return; // Only left click

  const colEl = e.target.closest('.column');
  if (!colEl) return;

  const rect = colEl.getBoundingClientRect();

  // Create ghost
  const ghost = document.createElement('div');
  ghost.className = 'column-ghost';
  ghost.innerHTML = colEl.innerHTML;
  ghost.style.width = rect.width + 'px';
  ghost.style.height = Math.min(rect.height, 300) + 'px';
  ghost.style.left = (e.clientX - rect.width / 2) + 'px';
  ghost.style.top = (e.clientY - 40) + 'px';

  // Append ghost to body so it's in the DOM
  document.body.appendChild(ghost);

  // Create placeholder in board
  const placeholder = document.createElement('div');
  placeholder.className = 'column-placeholder';

  colEl.parentNode.insertBefore(placeholder, colEl);
  colEl.style.opacity = '0.3';

  columnDrag = {
    columnId,
    ghostEl: ghost,
    placeholderEl: placeholder,
    offsetX: e.clientX - rect.left,
    offsetY: e.clientY - rect.top,
  };

  document.addEventListener('mousemove', onColumnDragMove);
  document.addEventListener('mouseup', onColumnDragEnd);
  document.addEventListener('keydown', onColumnDragKey);
}

// ── function: onColumnDragKey ──
function onColumnDragKey(e) {
  if (e.key === 'Escape') {
    cancelColumnDrag();
    e.preventDefault();
  }
}

// ── function: cancelColumnDrag ──
function cancelColumnDrag() {
  if (!columnDrag) return;
  columnDrag.ghostEl.remove();
  columnDrag.placeholderEl.remove();

  // Restore all columns
  document.querySelectorAll('.column').forEach(el => el.style.opacity = '');

  cleanupColumnDragListeners();
  columnDrag = null;
}

// ── function: onColumnDragMove ──
function onColumnDragMove(e) {
  if (!columnDrag) return;

  const { ghostEl, placeholderEl } = columnDrag;

  // Move ghost to follow cursor
  ghostEl.style.left = (e.clientX - columnDrag.offsetX) + 'px';
  ghostEl.style.top = (e.clientY - columnDrag.offsetY) + 'px';

  // Find which visible column to insert before/after
  const board = document.getElementById('board');
  const allColumns = [...board.querySelectorAll(':scope > .column')];
  const filteredVisible = allColumns.filter(c => c.style.opacity !== '0.3');

  let bestIndex = filteredVisible.length;
  for (let i = 0; i < filteredVisible.length; i++) {
    const rect = filteredVisible[i].getBoundingClientRect();
    const midX = rect.left + rect.width / 2;

    if (e.clientX < midX) {
      bestIndex = i;
      break;
    }
  }

  // Move placeholder to correct position among non-dragged columns
  board.insertBefore(placeholderEl, filteredVisible[bestIndex] || null);
}

// ── function: onColumnDragEnd ──
function onColumnDragEnd(e) {
  if (!columnDrag) return;

  const { columnId } = columnDrag;

  // Calculate target index BEFORE removing elements from DOM
  const board = document.getElementById('board');
  const allChildren = [...board.children];
  
  // Count non-dragged columns before the placeholder to get insert position
  let insertIndex = 0;
  for (let i = 0; i < allChildren.length; i++) {
    const child = allChildren[i];
    if (child.classList.contains('column-placeholder')) break;
    if (child.classList.contains('column') && child.style.opacity !== '0.3') {
      insertIndex++;
    }
  }

  // Remove ghost and placeholder
  columnDrag.ghostEl.remove();
  columnDrag.placeholderEl.remove();

  cleanupColumnDragListeners();

  // Remove dragged column from state
  const draggedColIdx = boardState.columns.findIndex(c => c.id === columnId);
  const draggedCol = boardState.columns.splice(draggedColIdx, 1)[0];

  // insertIndex represents position among non-dragged columns (post-removal indices)
  pushUndo('reorder columns', boardState);
  boardState.columns.splice(insertIndex, 0, draggedCol);
  saveBoard();

  columnDrag = null;
  renderAll();
}

// ── function: cleanupColumnDragListeners ──
function cleanupColumnDragListeners() {
  document.removeEventListener('mousemove', onColumnDragMove);
  document.removeEventListener('mouseup', onColumnDragEnd);
  document.removeEventListener('keydown', onColumnDragKey);
}

// ── main ──
// ============================================================
// MAIN — Initialization & Event Wiring
// ============================================================
(function init() {
  // Load or create board state
  if (!loadBoard()) {
    boardState = createDefaultBoard();
    saveBoard();
  }

  renderAll();

  // Search input
  const searchInput = document.getElementById('search-input');
  let searchTimeout;
  searchInput.addEventListener('input', () => {
    clearTimeout(searchTimeout);
    searchTimeout = setTimeout(() => {
      boardState.searchQuery = searchInput.value;
      saveBoard();
      renderAll();
    }, 150);
  });

  // Restore search query in input
  if (boardState.searchQuery) {
    searchInput.value = boardState.searchQuery;
  }

  // Add column button
  document.getElementById('add-column-btn').addEventListener('click', () => {
    pushUndo('add column', boardState);
    const title = prompt('Column name:');
    if (!title || !title.trim()) return;
    boardState.columns.push({
      id: generateId(),
      title: title.trim(),
      wipLimit: null,
      cards: [],
    });
    saveBoard();
    renderAll();
  });

  // Close modal on overlay click
  document.getElementById('modal-overlay').addEventListener('click', (e) => {
    if (e.target.id === 'modal-overlay') closeModal();
  });

  // Close modal on Escape
  document.addEventListener('keydown', (e) => {
    if (e.key === 'Escape' && document.getElementById('modal-overlay').style.display !== 'none') {
      closeModal();
    }
  });

  console.log('Kanban Board initialized with', boardState.columns.length, 'columns');
})();
</script>
</body>
</html>
<!-- agent-meta {"model":"qwen3.6-27b-mtp","provider":"lmstudio","persona":"composer","sessionId":"2b978634-796b-41a2-b6a8-9862e5a2be30","tokensIn":2883143,"tokensOut":56857,"tokensTotal":2940000,"turns":69,"toolCalls":68,"failedToolCalls":1,"timestamp":"2026-07-30T02:58:11.162Z"} -->