← Kanban results

Kanban

Claude haiku 5.5 · full

◈Launch the experiment

Reviews & generation details

Reviews

No human review yet. Unreviewed does not mean unsuccessful.

Generation context

Task text captured before this run. Effective runtime system instructions were not captured.

Generation prompt · 5c0a7d7863
# Task: Build a Kanban Project Board

Build a kanban project-management board (vanilla JS, no external assets, no CDN, no libraries) in the spirit of Trello or Linear. The top priority is **flawless drag-and-drop** — it looks simple, but the edge cases are where implementations fall apart.

## Core Specification

**Board Model**
- Columns (default: Backlog, To Do, In Progress, Review, Done) containing ordered cards
- Cards have: title, description, optional labels (colored chips), optional due date
- Full CRUD: add/rename/delete columns (keep at least one), add/edit/delete cards; inline editing (click title to edit, Enter commits, Escape cancels)

**Drag & Drop (the hard part — get every detail right)**
- Drag cards within a column to reorder, and across columns to move them
- **Live drop indicator:** while dragging, a gap/placeholder shows exactly where the card will land, updating smoothly as the cursor moves between cards and into empty columns (including empty columns — dropping into a column with zero cards must work)
- Auto-scroll: dragging near the left/right board edge scrolls horizontally; near the top/bottom of a column scrolls that column
- The dragged card follows the cursor with a slight tilt/elevation; original spot shows the placeholder, not a duplicate
- Escape mid-drag cancels and returns the card to its exact original position
- Column drag-to-reorder as well (drag by column header)
- Use the Pointer Events API (or mousedown/mousemove) rather than HTML5 drag-and-drop — you need pixel-following control; no ghost-image defaults

**Board Features**
- Per-column card count in the header; optional WIP limit per column (configurable in a column menu) that highlights the column in red when exceeded — but never blocks drops
- Label manager: a fixed palette of ~6 colors with names; filter bar to show only cards matching selected labels (filtering is a view — hidden cards keep their positions when the filter clears)
- Board search: filters cards by title text as you type
- Full persistence: columns, cards, order, labels, WIP limits, and filter state serialize to `localStorage` and restore exactly on reload
- Undo for destructive actions at minimum (card/column delete) — a toast with Undo is fine

## Visual Style
- Clean, modern SaaS aesthetic: light neutral background, softly elevated cards, pastel label chips, generous spacing
- Columns scroll independently; board scrolls horizontally; sticky board header
- Satisfying micro-interactions: card lift on drag-start, settle animation on drop, subtle hover raise
- Empty columns show a dashed drop zone state so they're obviously valid targets

## Technical Requirements
- Vanilla JS; all code organized with clear section comments
- Single source of truth: a serializable board model — rendering, hit-testing, and persistence derive from it; never reconstruct state by reading the DOM
- Tunable constants block at the top (colors, label palette, drag thresholds, scroll speed)
- No layout thrash: reads and writes batched during drag; smooth 60 FPS dragging with ~100 cards

## Quality Bar (must hit all)
- Dropping between two cards lands exactly there; dropping at a column's end appends; dropping into an empty column works
- Cancel mid-drag (Escape) restores the original order exactly — no orphaned placeholders, no state drift
- Rapid dragging (pick up, move fast across columns, drop) never duplicates or loses a card
- Filtered view never corrupts positions; clearing the filter restores the exact prior order
- Reload mid-session restores the board identically, including card order within columns

## Self-Check Before Finishing
Verify with the browser tools (probe by dispatching pointer events and asserting the resulting model/DOM order):
- Create 3 columns with 2, 0, and 3 cards; drag a card into the empty column and to the top, middle, and bottom of the 3-card column — assert final order each time
- Drag a card, press Escape, assert the model is byte-identical to before the drag
- Set a WIP limit of 1 on a column, drag 2 cards in, assert the red highlight appears
- Apply a label filter, then clear it, and assert card order matches the pre-filter order
- Reload and assert the board model is identical
Inspect original source 41,662 bytes · SHA-256 af47200d2809
<!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>
/* ===== THEME (tunable) ===== */
:root{
  --bg:#f3f4f7;--surface:#ffffff;--col:#e9ebf0;--text:#172b4d;--muted:#6b778c;
  --accent:#4c6ef5;--danger:#e03131;--border:#dfe3ea;--radius:10px;
  --shadow:0 1px 2px rgba(9,30,66,.10),0 1px 1px rgba(9,30,66,.06);
  --shadow-hover:0 6px 14px rgba(9,30,66,.14);
  --shadow-drag:0 22px 40px rgba(9,30,66,.28),0 4px 10px rgba(9,30,66,.12);
  --wip-bg:#fdeeee;--wip-line:#f5a3a3;--wip-ink:#c92a2a;
  --col-w:280px;--gap:14px;
}
*{box-sizing:border-box}
html,body{margin:0;height:100%}
body{font:14px/1.4 -apple-system,"Segoe UI",Inter,Roboto,Helvetica,Arial,sans-serif;background:var(--bg);color:var(--text);
  display:flex;flex-direction:column;overflow:hidden}
body.is-dragging,body.is-dragging *{cursor:grabbing!important}
body.is-dragging{user-select:none}
input,textarea{user-select:text}

/* ----- top bar (sticky) ----- */
.topbar{position:sticky;top:0;z-index:30;display:flex;align-items:center;gap:16px;flex-wrap:wrap;
  padding:12px 20px;background:rgba(255,255,255,.92);backdrop-filter:blur(6px);border-bottom:1px solid var(--border)}
.brand{font-weight:750;font-size:16px;letter-spacing:-.01em;display:flex;align-items:center;gap:8px}
.brand i{display:inline-block;width:18px;height:18px;border-radius:5px;background:linear-gradient(135deg,#4c6ef5,#9775fa)}
#search{width:220px;padding:7px 11px;border:1px solid var(--border);border-radius:8px;font:inherit;background:#fafbfc;outline:none}
#search:focus{border-color:var(--accent);background:#fff;box-shadow:0 0 0 3px rgba(76,110,245,.15)}
.labelbar{display:flex;gap:6px;flex-wrap:wrap;align-items:center}
.lchip{font:inherit;font-size:12px;font-weight:600;padding:4px 11px;border-radius:999px;border:0;cursor:pointer;
  background:var(--c);color:var(--i);transition:transform .12s,box-shadow .12s}
.lchip:hover{transform:translateY(-1px)}
.lchip.on{box-shadow:0 0 0 2px var(--i) inset,0 0 0 2px #fff,0 0 0 4px var(--i)}
.lchip.clear{background:#eef0f4;color:var(--muted)}
.spacer{flex:1}
#stats{color:var(--muted);font-size:12.5px}

/* ----- board ----- */
.board{flex:1;min-height:0;overflow-x:auto;overflow-y:hidden;padding:18px 20px 22px}
.track{display:flex;align-items:stretch;gap:var(--gap);height:100%}

/* ----- columns ----- */
.column{position:relative;flex:0 0 var(--col-w);width:var(--col-w);max-height:100%;display:flex;flex-direction:column;
  background:var(--col);border-radius:12px;padding:10px 10px 8px;transition:background .2s,box-shadow .2s}
.column.over{background:var(--wip-bg);box-shadow:inset 0 0 0 2px var(--wip-line)}
.col-head{position:relative;display:flex;align-items:center;gap:8px;padding:2px 2px 10px 4px;cursor:grab;touch-action:none;user-select:none}
.col-title{margin:0;font-size:14px;font-weight:650;flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;
  padding:3px 6px;border-radius:6px;cursor:text}
.col-title:hover{background:rgba(9,30,66,.06)}
.count{font-size:12px;font-weight:650;color:var(--muted);background:var(--surface);border-radius:999px;padding:1px 9px;white-space:nowrap;
  box-shadow:var(--shadow)}
.count .wip{color:var(--muted);font-weight:500}
.column.over .count{background:var(--wip-ink);color:#fff}
.column.over .count .wip{color:#ffd8d8}
.icon-btn{border:0;background:none;color:var(--muted);width:26px;height:26px;border-radius:7px;cursor:pointer;font-size:16px;line-height:1}
.icon-btn:hover{background:rgba(9,30,66,.07);color:var(--text)}
.list{flex:1;min-height:120px;overflow-y:auto;overflow-x:hidden;padding:2px 3px 4px 2px;display:flex;flex-direction:column;scrollbar-width:thin}
.empty{flex:1;min-height:90px;display:flex;align-items:center;justify-content:center;text-align:center;
  border:2px dashed #b9c1ce;border-radius:var(--radius);color:var(--muted);font-size:13px;margin:2px 0}
.col-foot{padding-top:6px}
.add-card{display:flex;align-items:center;gap:6px;width:100%;background:none;border:0;padding:8px 10px;border-radius:8px;
  color:var(--muted);cursor:pointer;font:inherit;text-align:left}
.add-card:hover{background:rgba(9,30,66,.06);color:var(--text)}

/* ----- cards ----- */
.card{position:relative;flex:none;background:var(--surface);border-radius:var(--radius);box-shadow:var(--shadow);
  padding:10px 12px 9px;margin:0 0 8px;cursor:grab;touch-action:none;user-select:none;
  transition:box-shadow .16s ease,transform .16s ease}
.card:hover{box-shadow:var(--shadow-hover);transform:translateY(-1px)}
.card-labels{display:flex;flex-wrap:wrap;gap:4px;margin-bottom:6px}
.tag{font-size:11px;font-weight:650;padding:2px 8px;border-radius:999px;background:var(--c);color:var(--i)}
.card-title{font-weight:550;line-height:1.35;word-break:break-word;border-radius:5px;padding:1px 3px;margin:0 -3px;cursor:text}
.card-title:hover{background:rgba(9,30,66,.05)}
.card-meta{display:flex;gap:10px;margin-top:8px;font-size:12px;color:var(--muted);align-items:center}
.due.late{color:var(--danger);font-weight:650}
.due.soon{color:#b54708;font-weight:650}
.card.settle{animation:settle .34s cubic-bezier(.2,.9,.3,1.25)}
@keyframes settle{0%{transform:translateY(-5px) scale(1.035);box-shadow:var(--shadow-drag)}100%{transform:none}}

/* ----- drag states ----- */
.card.dragging{position:fixed;left:0;top:0;z-index:1000;margin:0;pointer-events:none;
  box-shadow:var(--shadow-drag);transition:none;will-change:transform;cursor:grabbing}
.column.dragging{position:fixed;left:0;top:0;z-index:1000;margin:0;pointer-events:none;box-shadow:var(--shadow-drag);
  transition:none;will-change:transform;cursor:grabbing}
.column.dragging.settling,.card.dragging.settling{transition:transform .2s cubic-bezier(.2,.8,.3,1),box-shadow .2s}
.ph{flex:none;box-sizing:border-box;border:2px dashed #8ea6dc;background:rgba(76,110,245,.08);border-radius:var(--radius);
  margin:0 0 8px;animation:phin .16s ease-out}
.col-ph{flex:0 0 var(--col-w);box-sizing:border-box;border:2px dashed #8ea6dc;background:rgba(76,110,245,.08);border-radius:12px;
  animation:phin .16s ease-out}
.list:has(.ph) > .empty{display:none}
.src{display:none!important}
.card.dragging{animation:liftsh .18s ease-out}
@keyframes liftsh{from{box-shadow:var(--shadow)}to{box-shadow:var(--shadow-drag)}}
.col-ph{height:auto}
@keyframes phin{from{opacity:0;transform:scale(.96)}to{opacity:1;transform:none}}

/* ----- composer / inputs ----- */
.composer{display:flex;flex-direction:column;gap:6px;padding:2px}
.composer textarea,.edit-input,.menu input,.field input,.field textarea{font:inherit;border:1px solid var(--accent);border-radius:8px;
  padding:7px 9px;background:#fff;outline:none;box-shadow:0 0 0 3px rgba(76,110,245,.15)}
.composer textarea{width:100%;resize:vertical;min-height:58px}
.edit-input{width:100%;font-weight:550;padding:4px 7px;margin:-2px 0}
.col-head .edit-input{flex:1;min-width:0;font-weight:650;font-size:14px}
.add-col-form{flex:0 0 var(--col-w);align-self:flex-start;background:var(--col);border-radius:12px;padding:10px}
.add-col-form .edit-input{width:100%}
.add-col{flex:0 0 var(--col-w);align-self:flex-start;background:rgba(9,30,66,.05);border:2px dashed #c3cad6;border-radius:12px;
  padding:14px;color:var(--muted);font:inherit;font-weight:600;cursor:pointer}
.add-col:hover{background:rgba(9,30,66,.09);color:var(--text)}
.row{display:flex;gap:8px;align-items:center}
.btn{font:inherit;font-weight:600;padding:7px 14px;border-radius:8px;border:1px solid var(--border);background:#fff;color:var(--text);cursor:pointer}
.btn:hover{background:#f7f8fa}
.btn.primary{background:var(--accent);border-color:var(--accent);color:#fff}
.btn.primary:hover{background:#3b5bdb}
.btn.danger{color:var(--danger)}

/* ----- column menu ----- */
.menu{position:absolute;top:100%;right:0;margin-top:6px;z-index:60;width:230px;background:#fff;border-radius:12px;
  box-shadow:0 14px 34px rgba(9,30,66,.22);border:1px solid var(--border);padding:12px;display:flex;flex-direction:column;gap:8px;cursor:default}
.menu label{font-size:12px;color:var(--muted);font-weight:600}
.menu input{width:100%}
.menu .item{text-align:left;background:none;border:0;padding:7px 9px;border-radius:7px;cursor:pointer;font:inherit}
.menu .item:hover{background:#f1f3f7}
.menu .item.danger{color:var(--danger)}
.menu hr{border:0;border-top:1px solid var(--border);margin:2px 0}

/* ----- toasts ----- */
.toasts{position:fixed;bottom:22px;left:50%;transform:translateX(-50%);display:flex;flex-direction:column;align-items:center;gap:8px;z-index:200}
.toast{background:#172b4d;color:#fff;padding:10px 12px 10px 16px;border-radius:10px;display:flex;gap:16px;align-items:center;
  box-shadow:0 10px 26px rgba(9,30,66,.3);animation:phin .2s ease-out}
.toast button{background:none;border:0;color:#8cc8ff;font:inherit;font-weight:700;cursor:pointer;padding:4px 6px;border-radius:6px}
.toast button:hover{background:rgba(255,255,255,.08)}

/* ----- modal ----- */
.overlay{position:fixed;inset:0;background:rgba(9,30,66,.36);display:flex;align-items:center;justify-content:center;z-index:100;
  animation:phin .15s ease-out}
.modal{background:#fff;border-radius:16px;width:min(520px,92vw);padding:22px 22px 18px;box-shadow:0 24px 60px rgba(9,30,66,.3);
  display:flex;flex-direction:column;gap:14px}
.modal h3{margin:0;font-size:16px}
.field{display:flex;flex-direction:column;gap:6px;font-size:12px;font-weight:600;color:var(--muted)}
.field input,.field textarea{width:100%;color:var(--text);font-weight:500;font-size:14px;border-color:var(--border);box-shadow:none}
.field input:focus,.field textarea:focus{border-color:var(--accent);box-shadow:0 0 0 3px rgba(76,110,245,.15)}
.labels-pick{display:flex;flex-wrap:wrap;gap:6px}
.lpick{font:inherit;font-size:12px;font-weight:650;padding:5px 11px;border-radius:999px;border:2px solid transparent;cursor:pointer;
  background:var(--c);color:var(--i);opacity:.5}
.lpick.on{opacity:1;border-color:var(--i)}
.modal-actions{display:flex;gap:8px;align-items:center;justify-content:flex-end}
.modal-actions .spacer{flex:1}
</style>
</head>
<body>
<header class="topbar">
  <div class="brand"><i></i>Kanban</div>
  <input id="search" type="search" placeholder="Search cards…" aria-label="Search cards">
  <div id="labelbar" class="labelbar" aria-label="Label filter"></div>
  <div class="spacer"></div>
  <span id="stats"></span>
</header>
<main id="board" class="board"><div id="track" class="track"></div></main>
<div id="toasts" class="toasts"></div>
<div id="modalRoot"></div>
<script>
/* =====================================================================
   1. TUNABLE CONSTANTS
   ===================================================================== */
const CONFIG = {
  STORAGE_KEY: 'kanban-board-v1',
  DRAG_THRESHOLD: 5,        // px of pointer travel before a press becomes a drag
  EDGE_ZONE: 70,            // px from board / list edge where auto-scroll kicks in
  BOARD_SCROLL_SPEED: 14,   // px per frame, horizontal board auto-scroll
  LIST_SCROLL_SPEED: 11,    // px per frame, vertical column auto-scroll
  MAX_TILT: 6,              // deg, max tilt of the dragged card
  TILT_FACTOR: 0.35,        // deg per px of per-frame horizontal velocity
  CARD_GAP: 8,              // must match .card / .ph bottom margin in CSS
  COL_GAP: 14,              // must match .track gap in CSS
  UNDO_MS: 6000,
  DEFAULT_COLUMNS: ['Backlog', 'To Do', 'In Progress', 'Review', 'Done'],
};

// Fixed label palette (id, display name, pastel background, ink color)
const LABEL_PALETTE = [
  { id: 'red',    name: 'Bug',      color: '#fbd5d5', ink: '#b42318' },
  { id: 'orange', name: 'Design',   color: '#fde3c4', ink: '#b54708' },
  { id: 'yellow', name: 'Research', color: '#fcf1b8', ink: '#8a6d00' },
  { id: 'green',  name: 'Feature',  color: '#d2f2de', ink: '#1a7f4b' },
  { id: 'blue',   name: 'Infra',    color: '#d3e5fb', ink: '#1d5fa8' },
  { id: 'purple', name: 'Docs',     color: '#e4d9fb', ink: '#6b3fc2' },
];

/* =====================================================================
   2. DOM REFS & EPHEMERAL UI STATE (not persisted)
   ===================================================================== */
const $ = (s, r = document) => r.querySelector(s);
const track = $('#track'), boardEl = $('#board'), labelBar = $('#labelbar'),
      searchEl = $('#search'), statsEl = $('#stats'), toastsEl = $('#toasts'), modalRoot = $('#modalRoot');

const ui = {
  editing: null,     // {kind:'card'|'col', id} while an inline title editor is open
  adding: null,      // column id whose "add card" composer is open
  addingCol: false,  // new-column name input open
  menuOpen: null,    // column id whose ⋯ menu is open
  settleId: null,    // card id to play the settle animation on in the next render
};

/* =====================================================================
   3. UTILITIES
   ===================================================================== */
function uid() { return 'k' + Math.random().toString(36).slice(2, 8) + Date.now().toString(36).slice(-5); }
function esc(s) { return String(s ?? '').replace(/[&<>"']/g, ch => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[ch])); }
function clamp(v, lo, hi) { return Math.max(lo, Math.min(hi, v)); }
function labelById(id) { return LABEL_PALETTE.find(l => l.id === id); }
function startOfToday() { const t = new Date(); t.setHours(0, 0, 0, 0); return t; }
function newCard(title) { return { id: uid(), title, desc: '', labels: [], due: null }; }

/* =====================================================================
   4. BOARD MODEL (single source of truth) + PERSISTENCE
   Shape: { columns: [{id,title,wip,cards:[{id,title,desc,labels,due}]}],
            filter: {labels:[], search:''} }
   Rendering, hit-testing and persistence all derive from this object.
   ===================================================================== */
function defaultBoard() {
  const d3 = new Date(); d3.setDate(d3.getDate() + 3);
  const in3 = d3.toISOString().slice(0, 10);
  const seed = [
    [['Draft launch announcement', 'Short post covering the new board.', ['purple'], in3],
     ['Audit keyboard shortcuts', '', ['orange'], null],
     ['Competitor research', 'Compare three tools.', ['yellow'], null]],
    [['Design empty states', '', ['orange'], null],
     ['Set up error tracking', '', ['blue'], null]],
    [['Implement drag and drop', 'Pointer-based, no HTML5 DnD.', ['green'], null],
     ['Persist board to localStorage', '', ['green'], null],
     ['Fix scroll jitter on Safari', '', ['red'], null]],
    [['Review PR #214', '', ['green'], null]],
    [],
  ];
  const columns = CONFIG.DEFAULT_COLUMNS.map((title, i) => ({
    id: uid(), title, wip: 0,
    cards: (seed[i] || []).map(([t, d, l, due]) => ({ id: uid(), title: t, desc: d, labels: l, due })),
  }));
  return normalize({ columns, filter: { labels: [], search: '' } });
}
function normalize(b) {
  b.filter = Object.assign({ labels: [], search: '' }, b.filter || {});
  b.filter.labels = b.filter.labels || [];
  b.columns.forEach(c => {
    c.wip = Number(c.wip) || 0;
    c.cards = c.cards || [];
    c.cards.forEach(card => { card.labels = card.labels || []; card.desc = card.desc || ''; card.due = card.due || null; });
  });
  return b;
}
function loadBoard() {
  try {
    const raw = localStorage.getItem(CONFIG.STORAGE_KEY);
    if (raw) {
      const b = JSON.parse(raw);
      if (b && Array.isArray(b.columns) && b.columns.length) return normalize(b);
    }
  } catch (e) { /* corrupt storage: fall back to default */ }
  return defaultBoard();
}
function save() {
  try { localStorage.setItem(CONFIG.STORAGE_KEY, JSON.stringify(board)); } catch (e) { /* quota */ }
}
let board = loadBoard();

// --- model queries
const findCol = id => board.columns.find(c => c.id === id);
function findCard(id) {
  for (const col of board.columns) {
    const index = col.cards.findIndex(c => c.id === id);
    if (index >= 0) return { col, index, card: col.cards[index] };
  }
  return null;
}
function isFiltering() { return board.filter.labels.length > 0 || board.filter.search.trim() !== ''; }
function matchesFilter(c) {
  const q = board.filter.search.trim().toLowerCase();
  if (q && !c.title.toLowerCase().includes(q)) return false;
  const sel = board.filter.labels;
  if (sel.length && !c.labels.some(l => sel.includes(l))) return false;
  return true;
}

// --- model mutations (all structural changes go through these)
// Move card `id` into column `colId`, placed before card `beforeId` (null = append).
function moveCard(id, colId, beforeId) {
  const loc = findCard(id), dest = findCol(colId);
  if (!loc || !dest || id === beforeId) return;
  loc.col.cards.splice(loc.index, 1);
  const idx = beforeId ? dest.cards.findIndex(c => c.id === beforeId) : -1;
  if (idx < 0) dest.cards.push(loc.card); else dest.cards.splice(idx, 0, loc.card);
}
// Move column `id` before column `beforeId` (null = append).
function moveColumn(id, beforeId) {
  const i = board.columns.findIndex(c => c.id === id);
  if (i < 0 || id === beforeId) return;
  const [col] = board.columns.splice(i, 1);
  const j = beforeId ? board.columns.findIndex(c => c.id === beforeId) : -1;
  if (j < 0) board.columns.push(col); else board.columns.splice(j, 0, col);
}
function commitAdd(colId) {
  const ta = track.querySelector(`[data-composer][data-col="${colId}"]`);
  const title = (ta ? ta.value : '').trim();
  if (!title) { if (ta) ta.focus(); return; }
  const col = findCol(colId);
  if (!col) return;
  col.cards.push(newCard(title));
  commit();                // composer stays open for rapid entry
}
function deleteCard(id) {
  const loc = findCard(id);
  if (!loc) return;
  const colId = loc.col.id, { card, index } = loc;
  loc.col.cards.splice(index, 1);
  commit();
  toast(`Deleted “${card.title}”`, () => {
    const t = findCol(colId) || board.columns[0];
    t.cards.splice(Math.min(index, t.cards.length), 0, card);
    commit();
  });
}
function deleteColumn(id) {
  if (board.columns.length <= 1) { toast('Keep at least one column'); return; }
  const index = board.columns.findIndex(c => c.id === id);
  if (index < 0) return;
  const [col] = board.columns.splice(index, 1);
  ui.menuOpen = null;
  commit();
  toast(`Deleted column “${col.title}”`, () => {
    board.columns.splice(Math.min(index, board.columns.length), 0, col);
    commit();
  });
}
function commitEdit(value) {
  const e = ui.editing;
  if (!e) return;                       // guard: already committed or cancelled
  ui.editing = null;
  const v = String(value).trim();
  if (v) {
    if (e.kind === 'card') { const loc = findCard(e.id); if (loc) loc.card.title = v; }
    else { const c = findCol(e.id); if (c) c.title = v; }
    save();
  }
  render();
}
function cancelEdit() { ui.editing = null; render(); }
// (commitEdit/cancelEdit call render(); if a drag is in flight the DOM is rebuilt on its teardown.)
function startEdit(kind, id) { ui.editing = { kind, id }; ui.menuOpen = null; render(); }
function applyWip(colId, value) {
  const col = findCol(colId);
  if (!col) return;
  col.wip = Math.max(0, Math.floor(Number(value) || 0));
  commit();
}
function commitNewColumn(value) {
  if (!ui.addingCol) return;           // guard against double commit (Enter + blur)
  ui.addingCol = false;
  const t = String(value).trim();
  if (t) {
    board.columns.push({ id: uid(), title: t, wip: 0, cards: [] });
    commit();
    boardEl.scrollLeft = boardEl.scrollWidth;
  } else render();
}
function commit() { save(); render(); }

/* =====================================================================
   5. RENDERING (pure function of model + ui state)
   ===================================================================== */
function render() {
  if (DRAG) return;   // never rebuild DOM mid-drag; teardown/commit re-renders afterwards
  renderLabelBar(); renderStats(); renderTrack();
}

function renderLabelBar() {
  const sel = board.filter.labels;
  labelBar.innerHTML = LABEL_PALETTE.map(l =>
    `<button class="lchip${sel.includes(l.id) ? ' on' : ''}" data-action="filter-label" data-id="${l.id}"
      style="--c:${l.color};--i:${l.ink}" title="Show cards labelled ${esc(l.name)}">${esc(l.name)}</button>`).join('') +
    (sel.length ? `<button class="lchip clear" data-action="filter-clear">Clear filter</button>` : '');
}

function renderStats() {
  const total = board.columns.reduce((n, c) => n + c.cards.length, 0);
  let txt = `${total} card${total === 1 ? '' : 's'} · ${board.columns.length} column${board.columns.length === 1 ? '' : 's'}`;
  if (isFiltering()) {
    const shown = board.columns.reduce((n, c) => n + c.cards.filter(matchesFilter).length, 0);
    txt += ` · ${shown} shown`;
  }
  statsEl.textContent = txt;
}

function renderTrack() {
  const scrolls = new Map();
  track.querySelectorAll('.column').forEach(c => {
    const l = c.querySelector('[data-list]');
    if (l) scrolls.set(c.dataset.id, l.scrollTop);
  });
  const hadFocus = document.activeElement === document.body || !document.activeElement ||
                   track.contains(document.activeElement);

  track.innerHTML =
    board.columns.map(renderColumn).join('') +
    (ui.addingCol
      ? `<div class="add-col-form"><input class="edit-input" data-new-col placeholder="Column name…" aria-label="New column name"></div>`
      : `<button class="add-col" data-action="add-col-open">+ Add column</button>`);
  ui.settleId = null;

  track.querySelectorAll('.column').forEach(c => {
    const l = c.querySelector('[data-list]');
    const s = scrolls.get(c.dataset.id);
    if (l && s) l.scrollTop = s;
  });

  if (hadFocus) {
    const ei = track.querySelector('[data-edit-input]');
    const cp = track.querySelector('[data-composer]');
    const nc = track.querySelector('[data-new-col]');
    if (ei) { ei.focus(); ei.select(); }
    else if (cp) cp.focus();
    else if (nc) nc.focus();
  }
}

function renderColumn(col) {
  const filtering = isFiltering();
  const vis = col.cards.filter(matchesFilter);
  const over = col.wip > 0 && col.cards.length > col.wip;
  const editing = ui.editing && ui.editing.kind === 'col' && ui.editing.id === col.id;
  const countTxt = filtering ? `${vis.length} of ${col.cards.length}` : `${col.cards.length}`;
  const empty = vis.length === 0
    ? `<div class="empty">${filtering && col.cards.length ? 'No matching cards' : 'Drop cards here'}</div>` : '';

  return `
<section class="column${over ? ' over' : ''}" data-col="${col.id}" data-id="${col.id}">
  <header class="col-head">
    ${editing
      ? `<input class="edit-input" data-edit-input="col" value="${esc(col.title)}" aria-label="Column name">`
      : `<h2 class="col-title" data-edit="col" data-id="${col.id}" title="Click to rename">${esc(col.title)}</h2>`}
    <span class="count" title="${over ? 'Over WIP limit' : 'Cards in column'}">${countTxt}${col.wip > 0 ? `<span class="wip"> / ${col.wip}</span>` : ''}</span>
    <button class="icon-btn" data-action="col-menu" data-id="${col.id}" title="Column settings" aria-label="Column settings">⋯</button>
    ${ui.menuOpen === col.id ? renderColMenu(col) : ''}
  </header>
  <div class="list" data-list>
    ${vis.map(renderCard).join('')}
    ${empty}
  </div>
  <footer class="col-foot">
    ${ui.adding === col.id
      ? `<div class="composer">
           <textarea data-composer data-col="${col.id}" rows="2" placeholder="Enter a title for this card…"></textarea>
           <div class="row">
             <button class="btn primary" data-action="add-commit" data-id="${col.id}">Add card</button>
             <button class="btn" data-action="add-cancel">Cancel</button>
           </div>
         </div>`
      : `<button class="add-card" data-action="add-open" data-id="${col.id}">+ Add a card</button>`}
  </footer>
</section>`;
}

function renderColMenu(col) {
  return `
<div class="menu" role="menu">
  <label for="wip-${col.id}">WIP limit (0 = no limit)</label>
  <input id="wip-${col.id}" type="number" min="0" step="1" value="${col.wip}" data-wip-input data-id="${col.id}">
  <hr>
  <button class="item" data-action="rename-col" data-id="${col.id}">Rename column</button>
  <button class="item danger" data-action="delete-col" data-id="${col.id}">Delete column</button>
</div>`;
}

function dueChip(due) {
  const [y, m, d] = due.split('-').map(Number);
  const dt = new Date(y, m - 1, d);
  const diff = Math.round((dt - startOfToday()) / 864e5);
  const cls = diff < 0 ? 'late' : diff <= 2 ? 'soon' : '';
  const label = dt.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
  return `<span class="due ${cls}" title="Due ${esc(due)}">◷ ${label}</span>`;
}

function renderCard(c) {
  const editing = ui.editing && ui.editing.kind === 'card' && ui.editing.id === c.id;
  const tags = c.labels.map(labelById).filter(Boolean)
    .map(l => `<span class="tag" style="--c:${l.color};--i:${l.ink}">${esc(l.name)}</span>`).join('');
  const meta = (c.due ? dueChip(c.due) : '') + (c.desc ? `<span title="Has description">≡</span>` : '');
  return `
<article class="card${ui.settleId === c.id ? ' settle' : ''}" data-card data-id="${c.id}">
  ${tags ? `<div class="card-labels">${tags}</div>` : ''}
  ${editing
    ? `<input class="edit-input" data-edit-input="card" value="${esc(c.title)}" aria-label="Card title">`
    : `<div class="card-title" data-edit="card" data-id="${c.id}" title="Click to rename">${esc(c.title)}</div>`}
  ${meta ? `<div class="card-meta">${meta}</div>` : ''}
</article>`;
}

function toast(msg, undo) {
  const el = document.createElement('div');
  el.className = 'toast';
  el.innerHTML = `<span>${esc(msg)}</span>${undo ? '<button type="button">Undo</button>' : ''}`;
  const kill = () => el.remove();
  if (undo) el.querySelector('button').addEventListener('click', () => { kill(); undo(); });
  toastsEl.appendChild(el);
  while (toastsEl.children.length > 3) toastsEl.firstElementChild.remove();
  setTimeout(kill, CONFIG.UNDO_MS);
}

/* =====================================================================
   6. DRAG & DROP ENGINE (Pointer Events, placeholder-based)
   Phases: pending -> drag -> settle -> (teardown + model commit | render)
   The model is NEVER mutated during a drag. The DOM placeholder is the
   only thing that moves until drop, so Escape is a perfect no-op on state.
   ===================================================================== */
let DRAG = null;            // active drag session
let pendingPress = null;    // pointerdown that has not yet crossed the threshold
let justDraggedAt = 0;      // suppresses the click that follows a drag

function ramp(d, edge) { return clamp(d / edge, 0, 1); }

function onPressStart(e) {
  if (e.button !== 0 || DRAG || e.target.closest('button, input, textarea, .menu')) return;
  const cardEl = e.target.closest('.card');
  if (cardEl) {
    pendingPress = { kind: 'card', el: cardEl, id: cardEl.dataset.id, x: e.clientX, y: e.clientY, pid: e.pointerId };
    return;
  }
  const head = e.target.closest('.col-head');
  if (head) {
    const colEl = head.closest('.column');
    pendingPress = { kind: 'col', el: colEl, id: colEl.dataset.id, x: e.clientX, y: e.clientY, pid: e.pointerId };
  }
}

function onPointerMove(e) {
  if (pendingPress && !DRAG) {
    if (e.pointerId !== pendingPress.pid) return;
    if (Math.hypot(e.clientX - pendingPress.x, e.clientY - pendingPress.y) < CONFIG.DRAG_THRESHOLD) return;
    const p = pendingPress; pendingPress = null;
    startDrag(p, e);
    return;
  }
  if (DRAG && DRAG.phase === 'drag') {
    DRAG.px = e.clientX; DRAG.py = e.clientY;
    scheduleFrame();
  }
}

function startDrag(p, e) {
  const isCard = p.kind === 'card';
  const el = p.el;
  const r = el.getBoundingClientRect();
  const ph = document.createElement('div');
  ph.className = isCard ? 'ph' : 'col-ph';
  if (isCard) ph.style.height = r.height + 'px';

  const origParent = el.parentNode;
  const origNext = el.nextSibling;               // captured before we insert the placeholder
  origParent.insertBefore(ph, el);

  const D = {
    kind: p.kind, id: p.id, el, ph, origParent, origNext,
    offX: p.x - r.left, offY: p.y - r.top,
    px: e.clientX, py: e.clientY, lastX: e.clientX, vx: 0,
    phase: 'drag', raf: 0, list: null,
    phSpan: isCard ? r.height + CONFIG.CARD_GAP : r.width + CONFIG.COL_GAP,
  };
  el.style.width = r.width + 'px';
  el.style.height = r.height + 'px';
  el.style.transformOrigin = `${D.offX}px ${D.offY}px`;
  el.classList.add('dragging');
  DRAG = D;
  document.body.classList.add('is-dragging');
  writeDragTransform(D, 0);
  scheduleFrame();
}

function writeDragTransform(D, tilt) {
  const lift = D.kind === 'card' ? 1.03 : 1;
  const t = D.kind === 'card' ? tilt : 0;
  D.el.style.transform =
    `translate3d(${D.px - D.offX}px,${D.py - D.offY}px,0) rotate(${t}deg) scale(${lift})`;
}

function scheduleFrame() {
  if (DRAG && !DRAG.raf) DRAG.raf = requestAnimationFrame(frame);
}

// One frame: (1) auto-scroll writes, (2) hit-test reads, (3) placeholder + transform writes.
function frame() {
  const D = DRAG;
  if (!D) return;
  D.raf = 0;
  if (D.phase !== 'drag') return;
  const edge = CONFIG.EDGE_ZONE;

  // (1) auto-scroll: horizontal board, vertical target list
  const br = boardEl.getBoundingClientRect();
  if (D.px < br.left + edge) boardEl.scrollLeft -= CONFIG.BOARD_SCROLL_SPEED * ramp(br.left + edge - D.px, edge);
  else if (D.px > br.right - edge) boardEl.scrollLeft += CONFIG.BOARD_SCROLL_SPEED * ramp(D.px - (br.right - edge), edge);
  if (D.kind === 'card' && D.list) {
    const lr = D.list.getBoundingClientRect();
    if (D.py < lr.top + edge) D.list.scrollTop -= CONFIG.LIST_SCROLL_SPEED * ramp(lr.top + edge - D.py, edge);
    else if (D.py > lr.bottom - edge) D.list.scrollTop += CONFIG.LIST_SCROLL_SPEED * ramp(D.py - (lr.bottom - edge), edge);
  }

  // (2) hit-test (reads)
  const tgt = D.kind === 'card' ? cardTarget(D) : colTarget(D);

  // (3) writes
  if (tgt) placePlaceholder(D, tgt);
  D.vx = D.vx * 0.7 + (D.px - D.lastX) * 0.3;
  D.lastX = D.px;
  const tilt = clamp(D.vx * CONFIG.TILT_FACTOR, -CONFIG.MAX_TILT, CONFIG.MAX_TILT);
  writeDragTransform(D, tilt);
  scheduleFrame();
}

// Card hit-test: nearest column horizontally, then insertion index using
// midpoints of cards with the placeholder's own height subtracted (no jitter).
function cardTarget(D) {
  const px = D.px, py = D.py;
  let best = null, bestD = Infinity;
  for (const col of track.querySelectorAll(':scope > .column')) {
    const r = col.getBoundingClientRect();
    const d = px < r.left ? r.left - px : px > r.right ? px - r.right : 0;
    if (d < bestD) { bestD = d; best = col; if (d === 0) break; }
  }
  if (!best) return null;
  const list = best.querySelector('[data-list]');
  D.list = list;
  let before = null, shift = 0;
  for (const n of list.children) {
    if (n === D.ph) { shift = D.phSpan; continue; }
    if (n === D.el || !n.classList.contains('card')) continue;
    const r = n.getBoundingClientRect();
    if (py < r.top - shift + r.height / 2) { before = n; break; }
  }
  return { list, before };
}

// Column hit-test (same midpoint logic on the horizontal axis).
function colTarget(D) {
  const px = D.px;
  let before = null, shift = 0;
  for (const n of track.children) {
    if (n === D.ph) { shift = D.phSpan; continue; }
    if (n === D.el || !n.classList.contains('column')) continue;
    const r = n.getBoundingClientRect();
    if (px < r.left - shift + r.width / 2) { before = n; break; }
  }
  return { before };
}

function placePlaceholder(D, t) {
  if (D.kind === 'card') {
    if (t.before) { if (D.ph.parentNode !== t.list || D.ph.nextElementSibling !== t.before) t.list.insertBefore(D.ph, t.before); }
    else if (D.ph.parentNode !== t.list || D.ph.nextElementSibling !== null) t.list.appendChild(D.ph);
  } else {
    if (t.before) { if (D.ph.nextElementSibling !== t.before) track.insertBefore(D.ph, t.before); }
    else {
      const tail = track.querySelector(':scope > .add-col, :scope > .add-col-form');
      if (D.ph.nextElementSibling !== tail) track.insertBefore(D.ph, tail);
    }
  }
}

// Destination in model terms, derived from the placeholder's DOM slot.
function destinationOf(D) {
  if (D.kind === 'card') {
    const colEl = D.ph.parentNode.closest('.column');
    let n = D.ph.nextElementSibling;
    while (n && (!n.classList.contains('card') || n === D.el)) n = n.nextElementSibling;
    return { colId: colEl.dataset.id, beforeId: n ? n.dataset.id : null };
  }
  let n = D.ph.nextElementSibling;
  while (n && (!n.classList.contains('column') || n === D.el)) n = n.nextElementSibling;
  return { beforeId: n ? n.dataset.id : null };
}

// Animate the dragged element into the placeholder's slot, then call onDone.
function flyTo(D, onDone) {
  D.phase = 'settle';
  const r = D.ph.getBoundingClientRect();
  D.el.classList.remove('dragging');
  D.el.classList.add('settling');
  D.el.style.transition = 'transform .2s cubic-bezier(.2,.8,.3,1), box-shadow .2s';
  D.el.style.boxShadow = 'var(--shadow)';
  D.el.style.transform = `translate3d(${r.left}px,${r.top}px,0)`;
  setTimeout(onDone, 210);
}

// Remove all drag-only DOM state; leaves the card/column in its original DOM spot.
function teardown(D) {
  cancelAnimationFrame(D.raf);
  D.ph.remove();
  D.el.removeAttribute('style');
  D.el.classList.remove('dragging', 'settling');
  document.body.classList.remove('is-dragging');
  if (DRAG === D) DRAG = null;
}

function endDrag(e) {
  const D = DRAG;
  if (!D || D.phase !== 'drag') return;
  D.px = e.clientX; D.py = e.clientY;
  const tgt = D.kind === 'card' ? cardTarget(D) : colTarget(D);
  if (tgt) placePlaceholder(D, tgt);
  const dest = destinationOf(D);
  justDraggedAt = performance.now();
  flyTo(D, () => {
    teardown(D);
    if (D.kind === 'card') moveCard(D.id, dest.colId, dest.beforeId);
    else moveColumn(D.id, dest.beforeId);
    ui.settleId = D.kind === 'card' ? D.id : null;
    commit();
  });
}

// Escape: slide back to the original slot; the model was never touched.
function cancelDrag() {
  const D = DRAG;
  if (!D || D.phase !== 'drag') return;
  if (D.origNext && D.origNext.parentNode === D.origParent) D.origParent.insertBefore(D.ph, D.origNext);
  else D.origParent.appendChild(D.ph);
  justDraggedAt = performance.now();
  flyTo(D, () => {
    teardown(D);
    ui.settleId = null;
    render();
  });
}

window.addEventListener('pointerdown', onPressStart, true);
window.addEventListener('pointermove', onPointerMove);
window.addEventListener('pointerup', e => {
  if (pendingPress) { pendingPress = null; return; }
  endDrag(e);
});
window.addEventListener('pointercancel', () => { pendingPress = null; cancelDrag(); });
window.addEventListener('blur', () => { pendingPress = null; cancelDrag(); });

/* =====================================================================
   7. EVENTS: clicks, inline editing, composer, search, filters, modal, keys
   ===================================================================== */
function closeMenuIfOutside(target) {
  if (ui.menuOpen && !target.closest('.menu') && !target.closest('[data-action="col-menu"]')) {
    ui.menuOpen = null; render();
  }
}

document.addEventListener('click', e => {
  // swallow the synthetic click that follows a drag / cancel
  if (performance.now() - justDraggedAt < 300) return;
  const t = e.target;
  const act = t.closest('[data-action]');
  if (act) { handleAction(act.dataset.action, act.dataset.id); return; }
  const ed = t.closest('[data-edit]');
  if (ed) { startEdit(ed.dataset.edit, ed.dataset.id); return; }
  if (t.closest('[data-overlay]') && !t.closest('.modal')) { closeModal(); return; }
  const cardEl = t.closest('[data-card]');
  if (cardEl && !t.closest('[data-edit-input]')) { openCardModal(cardEl.dataset.id); return; }
  closeMenuIfOutside(t);
});

function handleAction(action, id) {
  switch (action) {
    case 'filter-label': {
      const L = board.filter.labels, i = L.indexOf(id);
      if (i >= 0) L.splice(i, 1); else L.push(id);
      commit(); break;
    }
    case 'filter-clear': board.filter.labels = []; commit(); break;
    case 'add-col-open': ui.addingCol = true; render(); break;
    case 'add-open': ui.adding = id; ui.menuOpen = null; render(); break;
    case 'add-cancel': ui.adding = null; render(); break;
    case 'add-commit': commitAdd(id); break;
    case 'col-menu': ui.menuOpen = ui.menuOpen === id ? null : id; render(); break;
    case 'rename-col': startEdit('col', id); break;
    case 'delete-col': deleteColumn(id); break;
    case 'modal-close': closeModal(); break;
    case 'modal-save': saveModal(id); break;
    case 'modal-delete': { closeModal(); deleteCard(id); break; }
    case 'pick-label': toggleModalLabel(id); break;
  }
}

// Inline title editors (cards + columns) and new-column input.
track.addEventListener('keydown', e => {
  const t = e.target;
  if (t.matches('[data-edit-input]')) {
    if (e.key === 'Enter') { e.preventDefault(); commitEdit(t.value); }
    else if (e.key === 'Escape') { e.preventDefault(); cancelEdit(); }
  } else if (t.matches('[data-new-col]')) {
    if (e.key === 'Enter') { e.preventDefault(); commitNewColumn(t.value); }
    else if (e.key === 'Escape') { e.preventDefault(); ui.addingCol = false; render(); }
  } else if (t.matches('[data-composer]')) {
    if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); commitAdd(t.dataset.col); }
    else if (e.key === 'Escape') { e.preventDefault(); ui.adding = null; render(); }
  } else if (t.matches('[data-wip-input]')) {
    if (e.key === 'Enter') { e.preventDefault(); applyWip(t.dataset.id, t.value); }
  }
});
track.addEventListener('focusout', e => {
  const t = e.target;
  if (t.matches('[data-edit-input]') && ui.editing) commitEdit(t.value);
  else if (t.matches('[data-new-col]') && ui.addingCol) commitNewColumn(t.value);
});
track.addEventListener('change', e => {
  if (e.target.matches('[data-wip-input]')) applyWip(e.target.dataset.id, e.target.value);
});

// Search filter (view only; positions untouched)
searchEl.value = board.filter.search;
searchEl.addEventListener('input', () => {
  board.filter.search = searchEl.value;
  save(); render();
});

/* ----- card detail modal ----- */
let modalState = null;   // { id, labels: [] } while modal open

function openCardModal(id) {
  const loc = findCard(id);
  if (!loc) return;
  const c = loc.card;
  modalState = { id, labels: [...c.labels] };
  renderModal(c);
}
function renderModal(c) {
  modalRoot.innerHTML = `
<div class="overlay" data-overlay>
  <div class="modal" role="dialog" aria-modal="true" aria-label="Edit card">
    <h3>Edit card</h3>
    <label class="field">Title
      <input id="m-title" value="${esc(c.title)}" maxlength="200">
    </label>
    <label class="field">Description
      <textarea id="m-desc" rows="4">${esc(c.desc)}</textarea>
    </label>
    <div class="field">Labels
      <div class="labels-pick">${LABEL_PALETTE.map(l =>
        `<button type="button" class="lpick${modalState.labels.includes(l.id) ? ' on' : ''}" data-action="pick-label" data-id="${l.id}"
          style="--c:${l.color};--i:${l.ink}">${esc(l.name)}</button>`).join('')}</div>
    </div>
    <label class="field">Due date
      <input id="m-due" type="date" value="${esc(c.due || '')}">
    </label>
    <div class="modal-actions">
      <button type="button" class="btn danger" data-action="modal-delete" data-id="${c.id}">Delete</button>
      <span class="spacer"></span>
      <button type="button" class="btn" data-action="modal-close">Cancel</button>
      <button type="button" class="btn primary" data-action="modal-save" data-id="${c.id}">Save</button>
    </div>
  </div>
</div>`;
  const ti = $('#m-title', modalRoot);
  ti.focus(); ti.select();
  ti.addEventListener('keydown', e => { if (e.key === 'Enter') { e.preventDefault(); saveModal(c.id); } });
}
function toggleModalLabel(lid) {
  if (!modalState) return;
  const L = modalState.labels, i = L.indexOf(lid);
  if (i >= 0) L.splice(i, 1); else L.push(lid);
  document.querySelectorAll('.lpick').forEach(b => b.classList.toggle('on', L.includes(b.dataset.id)));
}
function saveModal(id) {
  const loc = findCard(id);
  if (!loc || !modalState) { closeModal(); return; }
  const title = $('#m-title', modalRoot).value.trim();
  if (title) loc.card.title = title;
  loc.card.desc = $('#m-desc', modalRoot).value;
  loc.card.due = $('#m-due', modalRoot).value || null;
  loc.card.labels = [...modalState.labels];
  closeModal();
  commit();
}
function closeModal() { modalState = null; modalRoot.innerHTML = ''; }

// Column WIP / menu inputs are handled via delegated listeners above.

/* ----- global keys ----- */
document.addEventListener('keydown', e => {
  if (e.key !== 'Escape') return;
  if (DRAG && DRAG.phase === 'drag') { e.preventDefault(); cancelDrag(); return; }
  if (modalState) { closeModal(); return; }
  if (ui.menuOpen) { ui.menuOpen = null; render(); }
});

// Pointer-driven drags must not leave a text selection behind.
document.addEventListener('selectstart', e => { if (DRAG) e.preventDefault(); });

/* =====================================================================
   8. INIT
   ===================================================================== */
render();
</script>
</body>
</html>
<!-- agent-meta {"model":"anthropic/claude-haiku-5.5","provider":"openrouter","persona":"full","sessionId":"5e7507d3-d769-4039-8d7e-d5aad1657131","tokensIn":578711,"tokensOut":229097,"tokensTotal":807808,"cost":0.12652542,"turns":17,"toolCalls":21,"failedToolCalls":2,"timestamp":"2026-10-08T00:52:59.478Z"} -->