← Kanban results

Kanban

Ornith 1.5 9B GGUF · 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 38,763 bytes · SHA-256 dccab97ffd6b
<!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>
* { box-sizing: border-box; }
body {
  margin: 0;
  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
  background: #eef0f4;
  color: #1f2430;
  -webkit-font-smoothing: antialiased;
}
.topbar {
  display: flex;
  align-items: center;
  justify-content: space-between;
  height: 56px;
  padding: 0 20px;
  background: #fff;
  border-bottom: 1px solid #e3e7ee;
  position: sticky;
  top: 0;
  z-index: 50;
}
.topbar-left { display: flex; align-items: center; }
.topbar-right { display: flex; align-items: center; gap: 12px; }
#undo-btn {
  border: 1px solid #e3e7ee;
  background: #fff;
  border-radius: 8px;
  padding: 8px 12px;
  cursor: pointer;
  font-size: 13px;
  color: #6b7280;
  font-weight: 600;
}
#undo-btn:hover { background: #f7f8fb; }
.topbar h1 { font-size: 20px; font-weight: 800; letter-spacing: -0.5px; margin: 0; }
#search-box { position: relative; margin-right: 12px; }
#search {
  padding: 8px 12px;
  border: 1px solid #e3e7ee;
  border-radius: 8px;
  width: 220px;
  font-size: 13px;
  outline: none;
  background: #f7f8fb;
}
#search:focus { border-color: #3b6cf6; background: #fff; }
#filter-bar { display: inline-flex; gap: 6px; align-items: center; flex-wrap: wrap; max-width: 460px; }
.filter-chip {
  border: none;
  border-radius: 20px;
  padding: 5px 12px;
  font-size: 12px;
  cursor: pointer;
  font-weight: 600;
  background: transparent;
  color: #6b7280;
}
.filter-chip.active { color: #fff; }
#reset-btn {
  border: 1px solid #e3e7ee;
  background: #fff;
  border-radius: 8px;
  padding: 8px 12px;
  cursor: pointer;
  font-size: 12px;
  color: #6b7280;
}
#reset-btn:hover { background: #f7f8fb; }
.board {
  display: flex;
  gap: 16px;
  align-items: flex-start;
  overflow-x: auto;
  padding: 20px 16px;
  background: #eef0f4;
  min-height: calc(100vh - 56px);
}
.column {
  background: #fff;
  border-radius: 12px;
  box-shadow: 0 2px 8px rgba(30,40,60,0.06);
  border: 1px solid #e3e7ee;
  width: 320px;
  display: flex;
  flex-direction: column;
  transition: box-shadow 160ms ease, transform 160ms ease;
}
.column-over { box-shadow: 0 8px 24px rgba(30,40,60,0.16); transform: scale(1.01); }
.column-dragging { opacity: 0.4; }
.col-header {
  display: flex;
  align-items: center;
  gap: 8px;
  padding: 0 14px;
  height: 56px;
  border-bottom: 1px solid #e3e7ee;
  flex-shrink: 0;
}
.col-title {
  border: none;
  background: transparent;
  font-size: 14px;
  font-weight: 700;
  color: #1f2430;
  outline: none;
  width: 100%;
  padding: 0;
}
.col-title:focus { border-bottom: 2px solid #3b6cf6; }
.col-count {
  background: #f1f3f8;
  border-radius: 20px;
  padding: 1px 9px;
  font-size: 12px;
  font-weight: 700;
  color: #6b7280;
  min-width: 24px;
  text-align: center;
}
.col-wip {
  font-size: 11px;
  font-weight: 700;
  color: #9aa3b2;
  border-radius: 20px;
}
.col-wip-over { color: #ef4444; }
.col-drag { cursor: grab; color: #c3c9d4; font-size: 16px; }
.col-drag:active { cursor: grabbing; }
.col-menu {
  border: 1px solid #e3e7ee;
  background: #fff;
  border-radius: 8px;
  width: 28px;
  height: 28px;
  cursor: pointer;
  font-size: 16px;
  color: #6b7280;
  margin-left: auto;
  display: flex;
  align-items: center;
  justify-content: center;
}
.col-menu:hover { background: #f7f8fb; }
.col-body {
  flex: 1;
  overflow-y: auto;
  padding: 6px 12px 18px;
  display: flex;
  flex-direction: column;
  gap: 10px;
  min-height: 60px;
}
.card {
  background: #fff;
  border: 1px solid #e3e7ee;
  border-radius: 10px;
  padding: 12px;
  cursor: grab;
  box-shadow: 0 1px 3px rgba(30,40,60,0.05);
  animation: cardIn 220ms ease both;
  transition: box-shadow 140ms ease, transform 140ms ease;
}
.card:hover { box-shadow: 0 4px 12px rgba(30,40,60,0.10); transform: translateY(-1px); }
@keyframes cardIn { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: none; } }
.col-settle .card { animation: settle 320ms ease both; }
@keyframes settle { 0% { transform: translateY(-14px); opacity: 0.6; } 60% { transform: translateY(3px); } 100% { transform: none; opacity: 1; } }
.card-hidden { display: none; }
.card-title-row { display: flex; align-items: center; justify-content: space-between; gap: 6px; }
.card-title { font-size: 13px; font-weight: 600; color: #1f2430; cursor: text; flex: 1; line-height: 1.4; }
.card-del {
  border: none;
  background: transparent;
  border-radius: 6px;
  width: 20px;
  height: 20px;
  cursor: pointer;
  font-size: 14px;
  color: #9aa3b2;
  display: flex;
  align-items: center;
  justify-content: center;
}
.card-del:hover { background: #fdebeb; color: #ef4444; }
.card-title-input {
  flex: 1;
  padding: 2px 6px;
  border: 1px solid #3b6cf6;
  border-radius: 5px;
  font-size: 13px;
  font-weight: 600;
  outline: none;
}
.card-desc { font-size: 12px; color: #6b7280; margin-top: 6px; line-height: 1.4; }
.card-due {
  display: flex;
  align-items: center;
  gap: 4px;
  font-size: 11px;
  margin-top: 8px;
  color: #6b7280;
}
.card-labels { display: flex; gap: 5px; margin-top: 8px; flex-wrap: wrap; }
.label-chip {
  border: none;
  border-radius: 20px;
  padding: 2px 9px;
  font-size: 11px;
  color: #fff;
  cursor: pointer;
  font-weight: 600;
  opacity: 0.95;
}
.label-chip:hover { opacity: 1; filter: brightness(1.08); }
.empty-state {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  padding: 26px 10px;
  color: #9aa3b2;
  text-align: center;
  border: 2px dashed #e3e7ee;
  border-radius: 10px;
}
.placeholder {
  height: 74px;
  border-radius: 8px;
  background: #e8efff;
  border: 2px dashed #3b6cf6;
  opacity: 0.55;
  transition: height 120ms ease;
}
.filter-note { font-size: 11px; color: #9aa3b2; text-align: right; padding-top: 2px; }
.col-menu-popup {
  position: fixed;
  min-width: 180px;
  background: #fff;
  border: 1px solid #e3e7ee;
  border-radius: 10px;
  box-shadow: 0 12px 30px rgba(30,40,60,0.22);
  padding: 6px;
  z-index: 2000;
}
.col-menu-popup button {
  display: block;
  width: 100%;
  text-align: left;
  padding: 9px 12px;
  border: none;
  background: transparent;
  border-radius: 7px;
  cursor: pointer;
  color: #1f2430;
  font-size: 13px;
}
.col-menu-popup button:hover { background: #f7f8fb; }
#toast {
  position: fixed;
  bottom: 24px;
  left: 50%;
  transform: translateX(-50%) translateY(20px);
  opacity: 0;
  transition: all 260ms ease;
  background: #1f2430;
  color: #fff;
  padding: 12px 18px;
  border-radius: 10px;
  box-shadow: 0 10px 30px rgba(0,0,0,0.28);
  z-index: 5000;
  display: flex;
  gap: 10px;
  align-items: center;
}
</style>
</head>
<body>
  <header class="topbar">
    <div class="topbar-left">
      <button id="undo-btn" title="Undo last action" style="display:none">&#8630; Undo</button>
      <h1>Kanban</h1>
    </div>
    <div class="topbar-right">
      <div id="search-box">
        <input id="search" type="text" placeholder="Search cards by title&#8230;" style="padding:8px 12px;border:1px solid #e3e7ee;border-radius:8px;width:220px;font-size:13px;outline:none;background:#f7f8fb">
        <span style="position:absolute;right:10px;top:50%;transform:translateY(-50%);color:#9aa3b2">&#128269;</span>
      </div>
      <div id="filter-bar"></div>
      <button id="reset-btn" title="Reset to demo board" style="border:1px solid #e3e7ee;background:#fff;border-radius:8px;padding:8px 12px;cursor:pointer;font-size:12px;color:#6b7280">Reset demo</button>
    </div>
  </header>

  <div class="board"></div>

  <button id="add-col-btn" style="display:none;border:1px dashed #c3c9d4;background:#fff;border-radius:10px;padding:12px;cursor:pointer;color:#6b7280;margin:0 16px 16px">&#10025; Add column</button>

<script>
/* ============================================================
   STATE / MODEL
   Single source of truth. Never reconstruct state by reading
   the DOM. Everything (render, hit-test, persistence) derives
   from this object.
   ============================================================ */
const CONSTANTS = {
  colors: {
    page: '#eef0f4', header: '#ffffff', column: '#ffffff', card: '#ffffff',
    text: '#1f2430', muted: '#6b7280', border: '#e3e7ee', accent: '#3b6cf6',
    accentSoft: '#e8efff', hover: '#f7f8fb',
    shadow: 'rgba(30,40,60,0.10)', shadowStrong: 'rgba(30,40,60,0.22)'
  },
  labelPalette: [
    { id: 'lab-blue',   name: 'Bug',       color: '#60a5fa' },
    { id: 'lab-green',  name: 'Feature',   color: '#34d399' },
    { id: 'lab-yellow', name: 'Urgent',    color: '#fbbf24' },
    { id: 'lab-purple', name: 'Design',    color: '#a78bfa' },
    { id: 'lab-pink',   name: 'Research',  color: '#f472b6' },
    { id: 'lab-gray',   name: 'Docs',      color: '#9ca3af' }
  ],
  geometry: { cardW: 300, cardH: 74, cardMargin: 10, columnW: 320, columnMargin: 16, headerH: 56 },
  drag: {
    edgeMargin: 48, scrollSpeed: 9, moveThreshold: 4, dropSnap: 2,
    tilt: 6, liftY: 6, zIndex: 1000
  },
  historyMax: 60
};

let state = { columns: [], idSeq: 400, history: [] };
let uid = () => 'id' + (state.idSeq++);

/* ============================================================
   PERSISTENCE
   ============================================================ */
const STORAGE_KEY = 'kanban.board.v1';
function save() { try { localStorage.setItem(STORAGE_KEY, JSON.stringify(state)); } catch (e) {} }
function load() {
  try {
    const raw = localStorage.getItem(STORAGE_KEY);
    if (raw) state = normalize(JSON.parse(raw));
  } catch (e) {}
  if (!state.columns.length) seedDemo();
}

/* ============================================================
   ID HELPERS / MODEL MUTATIONS
   ============================================================ */
function newId(prefix) { state.idSeq++; return prefix + '_' + state.idSeq; }
function pushHistory() {
  state.history.push(JSON.stringify(state));
  if (state.history.length > CONSTANTS.historyMax) state.history.shift();
}
function restoreSnapshot() {
  const prev = state.history.pop();
  if (prev === undefined) return;
  state = JSON.parse(prev);
}

function addColumn(title) {
  pushHistory();
  state.columns.push({ id: newId('col'), title: title || 'New Column', cards: [] });
  save();
}
function deleteColumn(colId) {
  pushHistory();
  state.columns = state.columns.filter(c => c.id !== colId);
  save();
}
function renameColumn(colId, title) {
  const c = state.columns.find(c => c.id === colId);
  if (c && c.title !== title) { pushHistory(); c.title = title; save(); }
}
function setWip(colId, wip) {
  const c = state.columns.find(c => c.id === colId);
  if (c && (c.wip !== wip)) { pushHistory(); c.wip = wip || null; save(); }
}
function addCard(colId, card) {
  pushHistory();
  const c = state.columns.find(c => c.id === colId);
  if (c) { c.cards.push(card || { id: newId('card'), title: 'New Card', description: '', labels: [], dueDate: null }); save(); }
}
function deleteCard(colId, cardId) {
  pushHistory();
  const c = state.columns.find(c => c.id === colId);
  if (c) { c.cards = c.cards.filter(x => x.id !== cardId); save(); }
}
function renameCard(colId, cardId, title) {
  const c = state.columns.find(c => c.id === colId);
  const card = c && c.cards.find(x => x.id === cardId);
  if (card && card.title !== title) { pushHistory(); card.title = title; save(); }
}

/* ============================================================
   DEMO SEED / NORMALIZATION
   ============================================================ */
function seedDemo() {
  state.idSeq = 500;
  const mk = (title, body, labels, due) => ({ id: newId('card'), title, description: body, labels, dueDate: due || null });
  state.columns = [
    { id: newId('col'), title: 'Backlog', cards: [
      mk('Investigate API latency spikes', 'Check the metrics dashboard for anomalies', ['lab-yellow'], null),
      mk('Write documentation for v2 endpoints', '', ['lab-gray'], '2024-06-15')
    ], wip: 5 },
    { id: newId('col'), title: 'To Do', cards: [
      mk('Design onboarding flow', '', ['lab-purple']),
      mk('Set up CI pipeline', '', ['lab-green']),
      mk('Add dark mode support', '', ['lab-purple'])
    ]},
    { id: newId('col'), title: 'In Progress', cards: [
      mk('Implement search indexing', 'Use a vector store for semantic search', ['lab-blue']),
      mk('Refactor auth module', 'Split session and token logic', ['lab-blue'])
    ]},
    { id: newId('col'), title: 'Review', cards: [
      mk('Pull request: #142', 'Awaiting maintainer review', ['lab-yellow']),
      mk('QA sign-off for release', '', ['lab-green'])
    ]},
    { id: newId('col'), title: 'Done', cards: [
      mk('Project scaffolding', 'Repo, branch strategy, conventions', ['lab-green'])
    ]}
  ];
  save();
}
function normalize(data) {
  const seq = (data.idSeq) || 400;
  const cols = (data.columns || []).map(c => ({
    id: c.id || newId('col'),
    title: c.title || 'Column',
    wip: c.wip || null,
    cards: (c.cards || []).map(card => ({
      id: card.id || newId('card'),
      title: card.title || 'Untitled',
      description: card.description || '',
      labels: card.labels || [],
      dueDate: card.dueDate || null
    }))
  }));
  return { columns: cols, idSeq: seq, history: [] };
}

/* ============================================================
   FILTER STATE (view-only)
   ============================================================ */
let filter = { search: '', labelIds: [] };
function cardMatches(card) {
  if (filter.search) {
    const q = filter.search.toLowerCase();
    const hay = (card.title + ' ' + card.description).toLowerCase();
    if (!hay.includes(q)) return false;
  }
  if (filter.labelIds.length && !filter.labelIds.some(id => card.labels.includes(id))) return false;
  return true;
}

/* ============================================================
   VIEW HELPERS
   ============================================================ */
function labelById(id) { return CONSTANTS.labelPalette.find(l => l.id === id); }
function colEl(id) { return document.querySelector('[data-col="' + id + '"]'); }
function cardEl(id) { return document.querySelector('[data-card="' + id + '"]'); }
function wipOf(colId) { const c = state.columns.find(c => c.id === colId); return c ? c.wip : null; }
function isOverWip(colId) { const c = state.columns.find(c => c.id === colId); const w = c ? c.wip : null; return w != null && c.cards.length > w; }

/* ============================================================
   RENDERING
   ============================================================ */
function render() {
  const board = document.querySelector('.board');
  board.innerHTML = '';
  state.columns.forEach(col => board.appendChild(renderColumn(col.id)));
  renderHeader();
  save();
}
function renderColumn(colId) {
  const col = state.columns.find(c => c.id === colId);
  if (!col) return null;
  const wip = wipOf(colId);
  const over = wip != null && col.cards.length > wip;

  const el = document.createElement('section');
  el.className = 'column' + (over ? ' column-over' : '') + (colId === drag && drag.type === 'column' ? ' column-dragging' : '');
  el.dataset.col = colId;
  el.style.width = CONSTANTS.geometry.columnW + 'px';

  const header = document.createElement('header');
  header.className = 'col-header';
  header.style.height = CONSTANTS.geometry.headerH + 'px';

  const title = document.createElement('input');
  title.className = 'col-title';
  title.value = col.title;
  title.spellcheck = false;
  title.style.flex = '1';
  title.addEventListener('keydown', (e) => {
    if (e.key === 'Enter') { commitColEdit(colId); title.remove(); }
    else if (e.key === 'Escape') { title.remove(); }
  });
  title.addEventListener('blur', () => { title.remove(); });

  const count = document.createElement('span');
  count.className = 'col-count';
  count.textContent = col.cards.length;

  const wipBadge = document.createElement('span');
  wipBadge.className = 'col-wip' + (over ? ' col-wip-over' : '');
  wipBadge.textContent = wip != null ? '\u221e' : '';
  wipBadge.title = 'WIP limit';

  const dragHandle = document.createElement('span');
  dragHandle.className = 'col-drag';
  dragHandle.textContent = '\u2045';
  dragHandle.setAttribute('aria-label', 'Drag to reorder column');

  header.style.display = 'flex';
  header.style.alignItems = 'center';
  header.style.gap = '8px';
  header.style.padding = '0 14px';
  header.appendChild(dragHandle);
  header.appendChild(title);
  header.appendChild(wipBadge);
  header.appendChild(count);
  el.appendChild(header);

  const menu = document.createElement('button');
  menu.className = 'col-menu';
  menu.textContent = '\u2026';
  menu.title = 'Column menu';
  menu.addEventListener('click', (e) => { e.stopPropagation(); toggleColumnMenu(e, col); });
  el.appendChild(menu);

  const body = document.createElement('div');
  body.className = 'col-body';
  body.style.flex = '1';
  body.style.overflow = 'auto';
  body.style.padding = '6px 12px 18px';

  const visible = col.cards.filter(cardMatches);
  const hiddenCount = col.cards.length - visible.length;

  if (!col.cards.length) {
    const empty = document.createElement('div');
    empty.className = 'empty-state';
    empty.style.display = 'flex';
    empty.innerHTML = '<div style="width:38px;height:38px;border-radius:50%;background:' + CONSTANTS.colors.accentSoft + ';display:flex;align-items:center;justify-content:center;color:' + CONSTANTS.colors.accent + ';font-size:18px;margin-right:12px">&#10025;</div><div><div style="font-weight:600;color:' + CONSTANTS.colors.text + '">No cards yet</div><div style="color:' + CONSTANTS.colors.muted + ';font-size:13px;margin-top:2px">Drop cards here</div></div>';
    body.appendChild(empty);
  } else {
    visible.forEach(card => body.appendChild(renderCard(col, card)));
    if (hiddenCount > 0) {
      const note = document.createElement('div');
      note.className = 'filter-note';
      note.textContent = hiddenCount + ' hidden';
      body.appendChild(note);
    }
  }
  el.appendChild(body);
  return el;
}
function renderCard(col, card) {
  const el = document.createElement('div');
  el.className = 'card' + (cardMatches(card) ? '' : ' card-hidden');
  el.dataset.col = col.id;
  el.dataset.card = card.id;

  const cards = col.cards;
  const idx = cards.findIndex(c => c.id === card.id);
  el.style.animationDelay = (idx * 20) + 'ms';

  const titleRow = document.createElement('div');
  titleRow.className = 'card-title-row';

  const title = document.createElement('span');
  title.className = 'card-title';
  title.textContent = card.title;
  title.style.cursor = 'text';
  title.title = 'Click to edit';

  const del = document.createElement('button');
  del.className = 'card-del';
  del.textContent = '\u00d7';
  del.title = 'Delete card';
  del.addEventListener('click', (e) => { e.stopPropagation(); deleteCard(col.id, card.id); });

  titleRow.style.display = 'flex';
  titleRow.style.alignItems = 'center';
  titleRow.style.justifyContent = 'space-between';
  titleRow.style.gap = '6px';
  titleRow.style.flexWrap = 'wrap';
  titleRow.appendChild(title);
  titleRow.appendChild(del);
  el.appendChild(titleRow);

  if (card.description) {
    const desc = document.createElement('div');
    desc.className = 'card-desc';
    desc.textContent = card.description;
    el.appendChild(desc);
  }

  if (card.dueDate) {
    const due = document.createElement('div');
    due.className = 'card-due';
    const d = new Date(card.dueDate + 'T00:00:00');
    const today = new Date(); today.setHours(0, 0, 0, 0);
    const dd = new Date(card.dueDate + 'T00:00:00'); dd.setHours(0, 0, 0, 0);
    due.style.color = dd < today ? '#ef4444' : CONSTANTS.colors.muted;
    due.innerHTML = '<span>\uD83D\uDCC5</span><span>' + d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }) + '</span>';
    el.appendChild(due);
  }

  if (card.labels.length) {
    const labels = document.createElement('div');
    labels.className = 'card-labels';
    card.labels.forEach(lid => {
      const lab = labelById(lid);
      if (!lab) return;
      const chip = document.createElement('span');
      chip.className = 'label-chip';
      chip.style.background = lab.color;
      chip.textContent = lab.name;
      chip.title = 'Click to toggle ' + lab.name;
      chip.addEventListener('click', (e) => { e.stopPropagation(); toggleLabel(col.id, card.id, lid); });
      labels.appendChild(chip);
    });
    el.appendChild(labels);
  }

  el.addEventListener('pointerdown', (e) => onPointerDown(e, 'card', el), { passive: false });
  return el;
}
function renderHeader() {
  const box = document.getElementById('search-box');
  box.value = filter.search;
  const bar = document.getElementById('filter-bar');
  bar.innerHTML = '';
  const all = document.createElement('button');
  all.className = 'filter-chip' + (filter.labelIds.length === 0 ? ' active' : '');
  all.textContent = 'All labels';
  all.addEventListener('click', () => { filter.labelIds = []; render(); });
  bar.appendChild(all);

  CONSTANTS.labelPalette.forEach(lab => {
    const chip = document.createElement('button');
    chip.className = 'filter-chip' + (filter.labelIds.includes(lab.id) ? ' active' : '');
    chip.style.background = filter.labelIds.includes(lab.id) ? lab.color : 'transparent';
    chip.style.color = filter.labelIds.includes(lab.id) ? '#fff' : lab.color;
    chip.textContent = lab.name;
    chip.title = 'Filter by ' + lab.name;
    chip.addEventListener('click', () => {
      const arr = filter.labelIds.includes(lab.id) ? filter.labelIds.filter(x => x !== lab.id) : filter.labelIds.concat(lab.id);
      filter.labelIds = arr; render();
    });
    bar.appendChild(chip);
  });

  const undo = document.getElementById('undo-btn');
  if (undo) undo.style.display = state.history.length ? '' : 'none';
  const add = document.getElementById('add-col-btn');
  if (add) add.style.display = state.columns.length ? '' : 'none';
}

/* ============================================================
   INLINE EDITING (card title, column title)
   ============================================================ */
let editState = null;
function startCardEdit(colId, cardId) {
  const card = state.columns.find(c => c.id === colId).cards.find(x => x.id === cardId);
  const el = cardEl(cardId);
  editState = { colId, cardId };
  const title = el.querySelector('.card-title');
  const input = document.createElement('input');
  input.className = 'card-title-input';
  input.value = card.title;
  input.spellcheck = false;
  input.style.flex = '1';
  input.style.padding = '2px 6px';
  input.style.border = '1px solid ' + CONSTANTS.colors.accent;
  input.style.borderRadius = '5px';
  input.style.fontSize = '13px';
  input.style.fontWeight = '600';
  input.addEventListener('keydown', (e) => {
    if (e.key === 'Enter') { commitCardEdit(); input.remove(); editState = null; }
    else if (e.key === 'Escape') { input.remove(); editState = null; }
  });
  input.addEventListener('blur', () => { input.remove(); editState = null; });
  title.replaceWith(input);
  input.focus(); input.select();
}
function commitCardEdit() {
  if (!editState) return;
  const val = cardEl(editState.cardId).querySelector('.card-title-input').value.trim() || 'Untitled';
  renameCard(editState.colId, editState.cardId, val);
}

/* ============================================================
   LABEL TOGGLE / COLUMN MENU
   ============================================================ */
function toggleLabel(colId, cardId, lid) {
  pushHistory();
  const card = state.columns.find(c => c.id === colId).cards.find(x => x.id === cardId);
  if (card) {
    const arr = card.labels.includes(lid) ? card.labels.filter(x => x !== lid) : card.labels.concat(lid);
    card.labels = arr;
    save();
  }
}
let menuState = null;
function toggleColumnMenu(e, col) {
  e.stopPropagation();
  const existing = document.getElementById('col-menu-' + col.id);
  if (existing) { existing.remove(); menuState = null; return; }
  pushMenu(col.id);
}
function pushMenu(colId) {
  menuState = colId;
  const col = state.columns.find(c => c.id === colId);
  const menu = document.createElement('div');
  menu.id = 'col-menu-' + colId;
  menu.className = 'col-menu-popup';

  const addWip = () => {
    const input = prompt('Set WIP limit (blank = unlimited):', col.wip != null ? String(col.wip) : '');
    if (input === null) return;
    const n = input.trim();
    if (n === '') setWip(colId, null);
    else if (parseInt(n, 10)) setWip(colId, parseInt(n, 10));
    closeMenu();
  };
  const rename = () => {
    const v = prompt('Rename column:', col.title);
    if (v !== null) renameColumn(colId, v.trim() || col.title);
    closeMenu();
  };
  const del = () => { if (confirm('Delete "' + col.title + '" and its ' + col.cards.length + ' cards?')) { deleteColumn(colId); closeMenu(); } };

  menu.style.minWidth = '180px';
  menu.style.background = CONSTANTS.colors.card;
  menu.style.border = '1px solid ' + CONSTANTS.colors.border;
  menu.style.borderRadius = '10px';
  menu.style.boxShadow = '0 12px 30px ' + CONSTANTS.colors.shadowStrong;
  menu.style.padding = '6px';
  menu.style.zIndex = '2000';
  ['Rename column', 'Set WIP limit', 'Delete column'].forEach((label, i) => {
    const b = document.createElement('button');
    b.textContent = label;
    b.style.display = 'block';
    b.style.width = '100%';
    b.style.textAlign = 'left';
    b.style.padding = '9px 12px';
    b.style.border = 'none';
    b.style.background = 'transparent';
    b.style.borderRadius = '7px';
    b.style.cursor = 'pointer';
    b.style.color = CONSTANTS.colors.text;
    if (i === 0) b.addEventListener('click', rename);
    else if (i === 1) b.addEventListener('click', addWip);
    else b.addEventListener('click', del);
    menu.appendChild(b);
  });
  document.body.appendChild(menu);
  const r = menu.getBoundingClientRect();
  menu.style.left = Math.min(r.left, window.innerWidth - 200) + 'px';
  menu.style.top = (r.bottom + 6) + 'px';
}
function closeMenu() {
  const el = document.getElementById('col-menu-' + (menuState || ''));
  if (el) el.remove();
  menuState = null;
}

/* ============================================================
   UNDO / TOAST
   ============================================================ */
function doUndo() {
  if (!state.history.length) return;
  restoreSnapshot();
  render();
  save();
  toast('Undo', 'Action undone');
}
let toastTimer = null;
function toast(title, msg) {
  let t = document.getElementById('toast');
  if (!t) {
    t = document.createElement('div');
    t.id = 'toast';
    t.style.position = 'fixed';
    t.style.bottom = '24px';
    t.style.left = '50%';
    t.style.transform = 'translateX(-50%) translateY(20px)';
    t.style.opacity = '0';
    t.style.transition = 'all 260ms ease';
    t.style.background = '#1f2430';
    t.style.color = '#fff';
    t.style.padding = '12px 18px';
    t.style.borderRadius = '10px';
    t.style.boxShadow = '0 10px 30px rgba(0,0,0,.28)';
    t.style.zIndex = '5000';
    t.style.display = 'flex';
    t.style.gap = '10px';
    t.style.alignItems = 'center';
    document.body.appendChild(t);
  }
  t.innerHTML = '';
  t.innerHTML = '<strong>' + title + '</strong>';
  if (msg) { const s = document.createElement('span'); s.textContent = ' ' + msg; s.style.opacity = '.8'; s.style.fontWeight = '400'; t.appendChild(s); }
  const btn = document.createElement('button');
  btn.textContent = 'Undo';
  btn.style.border = 'none';
  btn.style.background = 'rgba(255,255,255,.18)';
  btn.style.color = '#fff';
  btn.style.padding = '5px 12px';
  btn.style.borderRadius = '7px';
  btn.style.cursor = 'pointer';
  btn.style.fontWeight = '600';
  btn.addEventListener('click', doUndo);
  t.appendChild(btn);
  requestAnimationFrame(() => { t.style.opacity = '1'; t.style.transform = 'translateX(-50%) translateY(0)'; });
  clearTimeout(toastTimer);
  toastTimer = setTimeout(() => { t.style.opacity = '0'; t.style.transform = 'translateX(-50%) translateY(20px)'; }, 1400);
}

/* ============================================================
   GLOBAL CONTROLS
   ============================================================ */
function initGlobalControls() {
  const search = document.getElementById('search-box');
  search.addEventListener('input', () => { filter.search = search.value.trim(); render(); });

  const filterBar = document.getElementById('filter-bar');
  filterBar.addEventListener('click', () => closeMenu());

  document.getElementById('add-col-btn').addEventListener('click', () => {
    const v = prompt('New column name:', 'New Column');
    if (v !== null) addColumn(v);
  });
  document.getElementById('undo-btn').addEventListener('click', doUndo);
  document.getElementById('reset-btn').addEventListener('click', () => {
    if (confirm('Reset to the demo board? This clears your current board.')) { state = { columns: [], idSeq: 400, history: [] }; seedDemo(); render(); save(); }
  });

  document.addEventListener('pointerdown', (e) => {
    if (menuState && !e.target.closest('#col-menu-' + menuState) && !e.target.closest('[data-col-menu]')) closeMenu();
  });
  document.addEventListener('keydown', (e) => {
    if (e.key === 'Escape' && menuState) closeMenu();
  });
}

/* ============================================================
   DRAG & DROP (Pointer Events)
   ============================================================ */
let drag = null;
let lastMove = { x: 0, y: 0, t: 0 };

function onPointerDown(e, type, element) {
  if (editState) return;
  if (e.button !== undefined && e.button !== 0) return;
  e.preventDefault();

  if (type === 'column') {
    if (menuState) return;
    startColumnDrag(e, element);
    return;
  }

  if (type === 'card') {
    const titleRow = element.querySelector('.card-title-row');
    if (e.target.closest('.card-del, .label-chip, .card-title-input')) return;
    if (e.target.closest('.card-title')) return;
    startCardDrag(e, element);
  }
}

function startCardDrag(e, element) {
  const colId = element.dataset.col;
  const cards = state.columns.find(c => c.id === colId).cards;
  const index = cards.findIndex(c => c.id === element.dataset.card);
  const cardId = element.dataset.card;

  drag = {
    type: 'card',
    element, cardId, colId, index,
    offsetX: e.clientX - element.offsetLeft,
    offsetY: e.clientY - element.offsetTop,
    lastX: e.clientX, lastY: e.clientY,
    lastT: performance.now(),
    vx: 0, vy: 0,
    fromElement: true
  };
  element.style.position = 'fixed';
  element.style.left = element.offsetLeft + 'px';
  element.style.top = element.offsetTop + 'px';
  element.style.zIndex = CONSTANTS.drag.zIndex;
  element.style.opacity = '0.92';
  insertPlaceholder(colId, index);
  applyLift(element, 0);
  lastMove = { x: e.clientX, y: e.clientY, t: performance.now() };
}

function startColumnDrag(e, element) {
  const colId = element.closest('[data-col]').dataset.col;
  drag = {
    type: 'column',
    element, colId,
    offsetX: e.clientX - element.offsetLeft,
    offsetY: e.clientY - element.offsetTop,
    lastX: e.clientX, lastY: e.clientY,
    lastT: performance.now(),
    vx: 0, vy: 0,
    fromElement: true
  };
  element.style.position = 'fixed';
  element.style.left = element.offsetLeft + 'px';
  element.style.top = element.offsetTop + 'px';
  element.style.zIndex = CONSTANTS.drag.zIndex;
  element.style.opacity = '0.92';
  lastMove = { x: e.clientX, y: e.clientY, t: performance.now() };
}

function applyLift(el, vy) {
  const tilt = Math.max(-CONSTANTS.drag.tilt, Math.min(CONSTANTS.drag.tilt, (vy || 0) / 220));
  el.style.transform = 'translateY(' + (-CONSTANTS.drag.liftY) + 'px) perspective(80px) rotate(' + (-tilt * 0.12) + 'deg)';
  el.style.boxShadow = '0 16px 34px ' + CONSTANTS.colors.shadowStrong;
}

let placeholder = null;
function insertPlaceholder(colId, index) {
  const colEl = colEl(colId);
  if (placeholder && placeholder.parentNode) placeholder.remove();
  const ph = document.createElement('div');
  ph.className = 'placeholder';
  ph.dataset.col = colId;
  ph.style.height = CONSTANTS.geometry.cardH + 'px';
  ph.style.margin = CONSTANTS.geometry.cardMargin + 'px 0';
  ph.style.borderRadius = '8px';
  ph.style.borderTop = '2px dashed ' + CONSTANTS.colors.accent;
  ph.style.background = CONSTANTS.colors.accentSoft;
  ph.style.opacity = '0.55';
  colEl.insertBefore(ph, colEl.querySelector('.col-body'));
  placeholder = ph;
}
function removePlaceholder() { if (placeholder && placeholder.parentNode) placeholder.remove(); placeholder = null; }

function colUnderPointer(e) {
  const cols = document.querySelectorAll('.column[data-col]');
  for (const c of cols) {
    const r = c.getBoundingClientRect();
    if (e.clientX >= r.left && e.clientX <= r.right && e.clientY >= r.top && e.clientY <= r.bottom) return c;
  }
  return null;
}
function dropIndexFor(colEl, relY) {
  const cards = [...colEl.querySelectorAll('.card:not(.card-hidden)')];
  for (let i = 0; i < cards.length; i++) {
    const r = cards[i].getBoundingClientRect();
    if (relY < r.top) return 0;
    if (relY >= r.bottom) continue;
    return i + 1;
  }
  return cards.length;
}
function positionPlaceholder(colId, relY) {
  if (!placeholder) return;
  const colEl = colEl(colId);
  const phR = colEl.getBoundingClientRect();
  placeholder.style.top = (relY - CONSTANTS.geometry.cardH / 2) + 'px';
  placeholder.style.left = '12px';
  placeholder.style.width = (phR.width - 24) + 'px';
}
function autoScroll(e) {
  if (!drag) return;
  const speed = CONSTANTS.drag.scrollSpeed;
  const board = document.querySelector('.board');
  const boardR = board.getBoundingClientRect();
  const x = e.clientX, y = e.clientY;

  if (x < boardR.left + CONSTANTS.drag.edgeMargin && board.scrollLeft > 0) board.scrollLeft += speed;
  else if (x > boardR.right - CONSTANTS.drag.edgeMargin && board.scrollLeft < board.scrollWidth - board.clientWidth) board.scrollLeft -= speed;

  const target = colUnderPointer(e);
  if (target) {
    const cr = target.getBoundingClientRect();
    if (y < cr.top + 12 && target.scrollTop > 0) target.scrollTop += speed;
    else if (y > cr.bottom - 12 && target.scrollTop < target.scrollHeight - target.clientHeight) target.scrollTop -= speed;
  }
}

/* ============================================================
   DRAG MOVE / UP
   ============================================================ */
function onPointerMove(e) {
  if (!drag) return;
  const now = performance.now();
  const dt = Math.max(1, now - drag.lastT);
  drag.vx = (e.clientX - drag.lastX) / dt * 16;
  drag.vy = (e.clientY - drag.lastY) / dt * 16;
  drag.lastX = e.clientX; drag.lastY = e.clientY; drag.lastT = now;

  drag.element.style.left = (e.clientX - drag.offsetX) + 'px';
  drag.element.style.top = (e.clientY - drag.offsetY) + 'px';
  applyLift(drag.element, drag.vy);

  const originColEl = colEl(drag.colId);
  const oRect = originColEl.getBoundingClientRect();
  drag._y = e.clientY - oRect.top;

  if (drag.type === 'card') {
    const targetCol = colUnderPointer(e);
    const targetId = targetCol ? targetCol.dataset.col : drag.colId;
    positionPlaceholder(targetId, drag._y);
    placeholder._index = dropIndexFor(targetCol || originColEl, drag._y);
  } else {
    const cols = [...document.querySelectorAll('.column[data-col]')];
    for (let i = 0; i < cols.length; i++) {
      const r = cols[i].getBoundingClientRect();
      if (e.clientX < r.left) { placeholder._index = i; break; }
      if (e.clientX > r.right) { placeholder._index = i + 1; continue; }
    }
    positionPlaceholder(drag.colId, drag._y);
    placeholder.style.top = (drag._y - CONSTANTS.geometry.headerH - 6) + 'px';
  }

  autoScroll(e);
}

function finishDrag() {
  if (!drag) return;
  const el = drag.element;
  el.style.position = '';
  el.style.left = '';
  el.style.top = '';
  el.style.transform = '';
  el.style.boxShadow = '';
  el.style.opacity = '';
  el.style.zIndex = '';
  removePlaceholder();

  if (drag.type === 'card') {
    const targetColEl = colUnderPointer(drag._lastE) || colEl(drag.colId);
    const targetId = targetColEl ? targetColEl.dataset.col : drag.colId;
    const relY = drag._lastY;
    const idx = dropIndexFor(targetColEl, relY);

    const srcCol = state.columns.find(c => c.id === drag.colId);
    const [card] = srcCol.cards.splice(drag.index, 1);
    const dstCol = state.columns.find(c => c.id === targetId);
    dstCol.cards.splice(idx, 0, card);

    renderColumn(drag.colId);
    if (targetId !== drag.colId) renderColumn(targetId);
    scheduleSettle(drag.colId, targetId);
    save();
  } else {
    const cols = state.columns.map(c => c.id);
    const arr = cols.filter(id => id !== drag.colId);
    const idx = placeholder._index || arr.length;
    arr.splice(idx, 0, drag.colId);
    state.columns = arr.map(id => state.columns.find(c => c.id === id));
    state.columns.forEach(c => renderColumn(c.id));
    save();
  }
  drag = null;
}

let settleTimer = null;
function scheduleSettle(fromColId, toColId) {
  if (settleTimer) clearTimeout(settleTimer);
  settleTimer = setTimeout(() => {
    [fromColId, toColId].forEach(id => {
      const el = colEl(id);
      if (el) { el.classList.add('col-settle'); setTimeout(() => el.classList.remove('col-settle'), 340); }
    });
  }, 20);
}

/* ============================================================
   POINTER LISTENERS
   ============================================================ */
window.addEventListener('pointermove', onPointerMove, { passive: false });
window.addEventListener('pointerup', (e) => {
  if (!drag) return;
  drag._lastE = e;
  drag._lastY = e.clientY;
  try { drag.element.releasePointerCapture(e.pointerId); } catch (err) {}
  if (drag.fromElement) finishDrag();
}, { passive: true });

/* ============================================================
   KEYBOARD: Escape cancels a drag
   ============================================================ */
window.addEventListener('keydown', (e) => {
  if (e.key === 'Escape' && drag) {
    const el = drag.element;
    el.style.position = '';
    el.style.left = '';
    el.style.top = '';
    el.style.transform = '';
    el.style.boxShadow = '';
    el.style.opacity = '';
    el.style.zIndex = '';
    removePlaceholder();
    drag = null;
  }
});

/* ============================================================
   BOOT
   ============================================================ */
function boot() {
  load();
  render();
  initGlobalControls();
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', boot);
else boot();
</script>
</body>
</html>