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 => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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"} -->