← Kanban results

Kanban

Deepseek v4.1 Flash · full

Initial view of Kanban

Reviews & generation details

Reviews

No human review yet. Unreviewed does not mean unsuccessful.

Generation context

Imported historical artifact. Its exact original instructions and lineage are not known.

Generation prompt · exact text unavailable

See the current prompt for reference. It may differ from the instructions used to generate this artifact.

Inspect original source 57,661 bytes · SHA-256 81f79ef73558
<!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>
/* ============================================================
   TOKENS & RESET
   ============================================================ */
:root{
  --bg:#f3f5f9;
  --bg-2:#e9ecf3;
  --panel:#ffffff;
  --col-bg:#edf0f6;
  --ink:#141922;
  --ink-2:#3b4453;
  --muted:#6c7686;
  --faint:#98a2b3;
  --line:#e3e7ef;
  --line-2:#d5dbe6;
  --accent:#4c6ef5;
  --accent-soft:#e7ecff;
  --danger:#d6455d;
  --danger-soft:#ffe8ec;
  --ok:#2f9e5f;
  --r-lg:14px;
  --r-md:11px;
  --r-sm:8px;
  --sh-1:0 1px 2px rgba(16,24,40,.05), 0 1px 2px rgba(16,24,40,.04);
  --sh-2:0 4px 10px rgba(16,24,40,.07), 0 1px 3px rgba(16,24,40,.05);
  --sh-3:0 18px 42px rgba(16,24,40,.20), 0 6px 14px rgba(16,24,40,.10);
  --sh-pop:0 16px 40px rgba(16,24,40,.16), 0 2px 8px rgba(16,24,40,.07);
}
*{box-sizing:border-box}
html,body{height:100%;margin:0}
body{
  font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Inter,Roboto,"Helvetica Neue",Arial,sans-serif;
  background:var(--bg);color:var(--ink);overflow:hidden;
  -webkit-font-smoothing:antialiased;text-rendering:optimizeLegibility;
  font-size:14px;line-height:1.45;
}
button,input,textarea,select{font:inherit;color:inherit}
button{cursor:pointer;border:none;background:none;padding:0}
body.dragging,body.dragging *{cursor:grabbing !important;user-select:none !important}
body.col-dragging,body.col-dragging *{cursor:grabbing !important;user-select:none !important}

/* ============================================================
   APP LAYOUT
   ============================================================ */
.app{display:flex;flex-direction:column;height:100vh;height:100dvh;overflow:hidden}

/* ---------- Top bar ---------- */
.topbar{
  display:flex;align-items:center;gap:14px;flex-wrap:wrap;
  padding:11px 20px;background:rgba(255,255,255,.88);
  backdrop-filter:saturate(180%) blur(12px);
  -webkit-backdrop-filter:saturate(180%) blur(12px);
  border-bottom:1px solid var(--line);z-index:40;flex:0 0 auto;
}
.brand{display:flex;align-items:center;gap:9px;padding-right:6px}
.logo{
  width:26px;height:26px;border-radius:8px;flex:0 0 auto;
  background:linear-gradient(135deg,#5b7cfa,#7c5bfa 55%,#c05bfa);
  box-shadow:0 2px 6px rgba(76,110,245,.35);
  display:grid;place-items:center;
}
.logo::after{content:"";width:10px;height:10px;border-radius:3px;background:rgba(255,255,255,.92)}
.brand h1{font-size:14.5px;font-weight:650;letter-spacing:-.01em;margin:0}
.brand .sub{font-size:11px;color:var(--faint);font-weight:500}

.search{position:relative;flex:0 1 250px;min-width:150px}
.search svg{position:absolute;left:10px;top:50%;transform:translateY(-50%);opacity:.5}
.search input{
  width:100%;padding:7px 11px 7px 32px;border-radius:9px;border:1px solid var(--line);
  background:#fff;outline:none;transition:border-color .15s,box-shadow .15s,background .15s;
  font-size:13px;
}
.search input::placeholder{color:var(--faint)}
.search input:focus{border-color:#bcc9f5;box-shadow:0 0 0 3px rgba(76,110,245,.13)}

.labelbar{display:flex;align-items:center;gap:6px;flex-wrap:wrap;flex:1 1 auto}
.lbl-chip{
  display:inline-flex;align-items:center;gap:6px;padding:4px 10px 4px 8px;border-radius:999px;
  border:1px solid var(--line);background:#fff;font-size:12px;font-weight:550;color:var(--muted);
  transition:all .15s ease;white-space:nowrap;
}
.lbl-chip i{width:8px;height:8px;border-radius:50%;display:block;background:var(--dot)}
.lbl-chip:hover{border-color:var(--line-2);color:var(--ink-2);transform:translateY(-1px)}
.lbl-chip.on{background:var(--cbg);color:var(--cfg);border-color:transparent;box-shadow:0 0 0 1px var(--cdot) inset}
.lbl-chip.on i{box-shadow:0 0 0 2px rgba(255,255,255,.9)}

.topbar .right{display:flex;align-items:center;gap:8px;margin-left:auto}
.btn{
  display:inline-flex;align-items:center;gap:6px;padding:7px 12px;border-radius:9px;
  font-size:13px;font-weight:570;border:1px solid var(--line);background:#fff;color:var(--ink-2);
  transition:all .14s ease;white-space:nowrap;
}
.btn:hover{background:#f7f8fc;border-color:var(--line-2);transform:translateY(-1px)}
.btn:active{transform:translateY(0)}
.btn.primary{background:var(--accent);border-color:transparent;color:#fff;box-shadow:0 2px 8px rgba(76,110,245,.3)}
.btn.primary:hover{background:#3f61ea;box-shadow:0 4px 12px rgba(76,110,245,.36)}
.btn.ghost{border-color:transparent;background:transparent;color:var(--muted)}
.btn.ghost:hover{background:#eef1f7;color:var(--ink-2)}
.btn.danger{color:var(--danger);border-color:#f6d3da}
.btn.danger:hover{background:var(--danger-soft)}

/* ---------- Sub bar ---------- */
.hintbar{
  display:flex;align-items:center;gap:12px;padding:6px 20px;background:#fbfcfe;
  border-bottom:1px solid var(--line);font-size:12px;color:var(--faint);flex:0 0 auto;
  min-height:32px;
}
.hintbar kbd{
  font-family:inherit;font-size:10.5px;padding:1px 5px;border-radius:5px;background:#eef1f7;
  border:1px solid var(--line);color:var(--muted);font-weight:600;
}
.hintbar .spacer{flex:1}
.hintbar .cnt b{color:var(--ink-2);font-weight:650}
.filtering-tag{
  display:inline-flex;align-items:center;gap:6px;background:var(--accent-soft);color:#3546a8;
  padding:2px 8px;border-radius:999px;font-weight:600;font-size:11.5px;
}
.filtering-tag button{color:#3546a8;text-decoration:underline;font-size:11.5px}

/* ---------- Board ---------- */
.board{
  flex:1 1 auto;display:flex;gap:16px;align-items:stretch;
  overflow-x:auto;overflow-y:hidden;padding:18px 20px 8px;
  scroll-behavior:auto;position:relative;
}
.board::-webkit-scrollbar{height:11px}
.board::-webkit-scrollbar-track{background:transparent}
.board::-webkit-scrollbar-thumb{background:#ccd3df;border-radius:99px;border:3px solid var(--bg)}
.board::-webkit-scrollbar-thumb:hover{background:#b6bfcf}

/* ---------- Column ---------- */
.column{
  flex:0 0 300px;width:300px;display:flex;flex-direction:column;max-height:100%;
  background:var(--col-bg);border-radius:var(--r-lg);padding:8px 8px 8px;
  border:1px solid transparent;position:relative;
  transition:box-shadow .2s,border-color .2s,background .2s,opacity .15s;
}
.column.over-wip{background:#fdeef0;border-color:#f6bcc5;box-shadow:0 0 0 3px rgba(214,69,93,.07)}
.column.over-wip .col-title{color:#b63449}
.column.col-dragging{opacity:.42;border-color:var(--line-2)}
.column.just-moved{animation:colPop .3s ease}
@keyframes colPop{from{transform:scale(.985);opacity:.6}to{transform:scale(1);opacity:1}}

.column-header{
  display:flex;align-items:center;gap:8px;padding:6px 6px 8px;flex:0 0 auto;
  cursor:grab;touch-action:none;border-radius:9px;
}
.column-header:active{cursor:grabbing}
.col-title{
  font-size:12.5px;font-weight:700;letter-spacing:.045em;text-transform:uppercase;
  color:var(--ink-2);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;flex:1 1 auto;
  padding:2px 0;
}
.col-count{
  font-size:11px;font-weight:650;color:var(--muted);background:#dfe4ee;border-radius:999px;
  padding:1px 7px;flex:0 0 auto;min-width:20px;text-align:center;
}
.column.over-wip .col-count{background:#f9cdd4;color:#a52c41}
.wip-tag{font-size:10px;font-weight:700;color:#a52c41;background:#f9cdd4;border-radius:999px;padding:1px 6px;flex:0 0 auto}
.icon-btn{
  width:24px;height:24px;border-radius:7px;display:grid;place-items:center;flex:0 0 auto;
  color:var(--muted);transition:background .14s,color .14s;font-size:15px;line-height:1;
}
.icon-btn:hover{background:#dfe3ec;color:var(--ink-2)}
.column-header .icon-btn{opacity:0;transition:opacity .14s,background .14s}
.column:hover .column-header .icon-btn{opacity:1}

/* ---------- Cards container ---------- */
.cards{
  flex:1 1 auto;overflow-y:auto;overflow-x:hidden;display:flex;flex-direction:column;
  gap:8px;padding:1px 3px 3px;min-height:64px;scrollbar-width:thin;
  scrollbar-color:#cbd2de transparent;
}
.cards::-webkit-scrollbar{width:9px}
.cards::-webkit-scrollbar-thumb{background:#ccd3df;border-radius:99px;border:3px solid var(--col-bg)}
.cards::-webkit-scrollbar-track{background:transparent}
.cards.empty{
  border:1.5px dashed #bfc8d8;border-radius:10px;background:rgba(255,255,255,.42);
  align-items:center;justify-content:center;transition:background .18s,border-color .18s;
}
.cards.empty.hot{border-color:var(--accent);background:rgba(231,236,255,.85);border-style:dashed}
.cards.empty.drop-active{align-items:stretch;justify-content:flex-start;padding-top:1px}
.cards.empty.drop-active .empty-hint{display:none}
.empty-hint{
  font-size:12px;color:var(--faint);font-weight:550;padding:18px 8px;pointer-events:none;
  display:flex;flex-direction:column;align-items:center;gap:6px;text-align:center;
}
.empty-hint .dotgrid{display:grid;grid-template-columns:repeat(3,4px);gap:3px}
.empty-hint .dotgrid i{width:4px;height:4px;border-radius:50%;background:#c3cbda;display:block}

.column-footer{flex:0 0 auto;padding:7px 4px 2px}
.add-card{
  display:flex;align-items:center;gap:7px;width:100%;padding:8px 9px;border-radius:9px;
  color:var(--muted);font-size:13px;font-weight:560;transition:background .14s,color .14s;
}
.add-card:hover{background:#e3e8f2;color:var(--ink-2)}

/* ---------- Card ---------- */
.card{
  position:relative;background:var(--panel);border-radius:var(--r-md);padding:10px 11px 9px;
  box-shadow:var(--sh-1);cursor:grab;touch-action:none;border:1px solid transparent;
  transition:box-shadow .16s ease,transform .16s ease,border-color .16s ease;
  will-change:transform;
}
.card:hover{box-shadow:var(--sh-2);transform:translateY(-1.5px);border-color:#eaedf4}
body.dragging .card:hover{transform:none;box-shadow:var(--sh-1)}
.card.lifting{animation:lift .22s ease}
@keyframes lift{from{transform:translateY(0) scale(1)}50%{transform:translateY(-2px) scale(1.012)}to{transform:translateY(0) scale(1)}}
.card.settle{animation:settle .34s cubic-bezier(.22,1.2,.36,1)}
@keyframes settle{
  0%{transform:scale(.965);box-shadow:0 10px 22px rgba(76,110,245,.22);opacity:.75}
  55%{transform:scale(1.008)}
  100%{transform:scale(1);box-shadow:var(--sh-1);opacity:1}
}
.card.hidden{display:none}

.card-labels{display:flex;flex-wrap:wrap;gap:4px;margin-bottom:7px}
.chip{
  display:inline-flex;align-items:center;gap:5px;padding:2px 8px 2px 6px;border-radius:999px;
  font-size:10.5px;font-weight:650;letter-spacing:.01em;line-height:1.6;
}
.chip i{width:6px;height:6px;border-radius:50%;display:block}
.card-title{
  font-size:13.4px;font-weight:600;color:var(--ink);letter-spacing:-.005em;
  padding:1px 2px;margin:-1px -2px;border-radius:6px;outline:none;word-break:break-word;
}
.card-title:hover{background:#f2f5fb}
.card-title .ph{color:var(--faint);font-weight:520}
.card-desc{
  font-size:12.2px;color:var(--muted);margin-top:4px;padding:1px 2px;margin-left:-2px;
  border-radius:6px;display:-webkit-box;-webkit-line-clamp:3;-webkit-box-orient:vertical;
  overflow:hidden;word-break:break-word;
}
.card-desc:hover{background:#f2f5fb}
.card-foot{display:flex;align-items:center;gap:6px;margin-top:8px;min-height:20px}
.card-foot:empty{display:none}
.due{
  display:inline-flex;align-items:center;gap:4px;font-size:11px;font-weight:600;color:var(--muted);
  background:#f0f2f7;border-radius:6px;padding:1.5px 7px;
}
.due.overdue{background:var(--danger-soft);color:#c2374f}
.due.soon{background:#fff3e0;color:#a3671a}
.card-menu-btn{
  width:24px;height:24px;border-radius:7px;display:grid;place-items:center;color:var(--muted);
  opacity:0;transition:opacity .13s,background .13s;font-size:15px;line-height:1;margin-left:auto;
}
.card:hover .card-menu-btn{opacity:1}
.card-menu-btn:hover{background:#eef1f7}
.inline-input,.inline-area{
  display:block;width:100%;border:1px solid var(--accent);border-radius:7px;padding:4px 6px;
  background:#fff;outline:none;box-shadow:0 0 0 3px rgba(76,110,245,.14);
  font-size:13.4px;font-weight:600;color:var(--ink);resize:none;font-family:inherit;
}
.inline-area{font-weight:500;font-size:12.6px;line-height:1.5;min-height:52px;color:var(--ink-2)}
.inline-input.small{font-size:12.5px;font-weight:700;text-transform:uppercase;letter-spacing:.045em}

/* ---------- Drag visuals ---------- */
.drag-ghost{
  position:fixed;left:0;top:0;z-index:9999;pointer-events:none;
  transform-origin:50% 50%;will-change:transform;
}
.drag-ghost .card{
  box-shadow:var(--sh-3);border-color:#dfe2ea;cursor:grabbing;
}
.placeholder{
  border-radius:var(--r-md);border:2px dashed #a9b8ea;background:rgba(76,110,245,.08);
  box-shadow:inset 0 0 0 3px rgba(76,110,245,.05);
  animation:phPulse 1.4s ease-in-out infinite;
  flex:0 0 auto;
}
.placeholder.col{flex:0 0 300px;width:300px;align-self:stretch;border-radius:var(--r-lg);border-color:#a9b8ea;background:rgba(76,110,245,.06);animation:none}
@keyframes phPulse{0%,100%{opacity:.85}50%{opacity:1}}

/* ---------- Popover / menus ---------- */
.popover{
  position:fixed;z-index:10000;background:#fff;border-radius:var(--r-lg);
  box-shadow:var(--sh-pop);border:1px solid var(--line);padding:9px;min-width:236px;
  animation:popIn .12s ease;
}
@keyframes popIn{from{opacity:0;transform:translateY(-4px) scale(.985)}to{opacity:1;transform:none}}
.pop-head{
  font-size:10.5px;font-weight:700;letter-spacing:.06em;text-transform:uppercase;color:var(--faint);
  padding:3px 6px 7px;
}
.pop-sec{padding:6px;border-top:1px solid var(--line)}
.pop-sec:first-of-type{border-top:none}
.pop-sec > .label{font-size:11.5px;font-weight:600;color:var(--muted);display:block;margin-bottom:6px}
.menu-item{
  display:flex;align-items:center;gap:9px;width:100%;padding:7px 9px;border-radius:8px;
  font-size:13px;font-weight:550;color:var(--ink-2);text-align:left;transition:background .13s;
}
.menu-item:hover{background:#f3f5fa}
.menu-item.danger{color:var(--danger)}
.menu-item.danger:hover{background:var(--danger-soft)}
.menu-item:disabled{color:var(--faint);cursor:not-allowed}
.menu-item:disabled:hover{background:transparent}
.label-picker{display:flex;flex-wrap:wrap;gap:5px}
.pick{
  display:inline-flex;align-items:center;gap:5px;padding:4px 9px;border-radius:999px;
  font-size:11.5px;font-weight:600;border:1.5px solid transparent;transition:all .13s;
  opacity:.62;
}
.pick i{width:7px;height:7px;border-radius:50%;display:block}
.pick.on{opacity:1;box-shadow:0 0 0 1.5px var(--cdot) inset}
.pick:hover{opacity:1;transform:translateY(-1px)}
.wip-row{display:flex;align-items:center;gap:8px}
.wip-row input[type=number]{
  width:78px;padding:6px 8px;border-radius:8px;border:1px solid var(--line);background:#fbfcfe;
  outline:none;font-size:13px;
}
.wip-row input[type=number]:focus{border-color:#bcc9f5;box-shadow:0 0 0 3px rgba(76,110,245,.12)}
.wip-row span{font-size:12px;color:var(--faint)}
.due-row input[type=date]{
  width:100%;padding:6px 8px;border-radius:8px;border:1px solid var(--line);background:#fbfcfe;
  outline:none;font-size:13px;font-family:inherit;
}
.due-row input[type=date]:focus{border-color:#bcc9f5;box-shadow:0 0 0 3px rgba(76,110,245,.12)}

/* ---------- Toast ---------- */
.toast-wrap{position:fixed;left:50%;bottom:22px;transform:translateX(-50%);z-index:12000;display:flex;flex-direction:column;gap:8px;align-items:center;pointer-events:none}
.toast{
  display:flex;align-items:center;gap:14px;background:#1c2230;color:#fff;border-radius:11px;
  padding:10px 12px 10px 16px;box-shadow:0 14px 34px rgba(16,24,40,.32);font-size:13px;
  animation:toastIn .22s cubic-bezier(.22,1.2,.36,1);pointer-events:auto;max-width:92vw;
}
.toast.out{animation:toastOut .2s ease forwards}
@keyframes toastIn{from{opacity:0;transform:translateY(14px) scale(.96)}to{opacity:1;transform:none}}
@keyframes toastOut{to{opacity:0;transform:translateY(10px) scale(.97)}}
.toast button{
  color:#9db4ff;font-weight:650;font-size:13px;text-decoration:none;padding:3px 9px;border-radius:7px;
  background:rgba(255,255,255,.09);transition:background .14s;
}
.toast button:hover{background:rgba(255,255,255,.18)}
.toast .x{color:rgba(255,255,255,.4);padding:3px 6px;font-size:15px}
.toast .x:hover{background:transparent;color:#fff}

/* ---------- misc ---------- */
.board::after{content:"";flex:0 0 1px}
.drag-proxy-note{display:none}
@media (max-width:760px){
  .brand .sub{display:none}
  .search{flex:1 1 100%}
  .column{flex:0 0 82vw;width:82vw}
}
</style>
</head>
<body>
<div class="app">
  <header class="topbar">
    <div class="brand">
      <div class="logo"></div>
      <div>
        <h1>Product Board</h1>
        <div class="sub">acme · roadmap Q3</div>
      </div>
    </div>
    <div class="search">
      <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#6c7686" stroke-width="2.4" stroke-linecap="round"><circle cx="11" cy="11" r="7"/><path d="M20 20l-3.6-3.6"/></svg>
      <input id="searchInput" type="text" placeholder="Search cards…" autocomplete="off" spellcheck="false">
    </div>
    <div class="labelbar" id="labelBar"></div>
    <div class="right">
      <button class="btn ghost" id="clearFilters" title="Clear search and label filters">Clear filters</button>
      <button class="btn primary" id="addColumnBtn">+ Add column</button>
    </div>
  </header>
  <div class="hintbar">
    <span>Drag cards between columns</span>
    <span>·</span>
    <span><kbd>Esc</kbd> cancels a drag</span>
    <span>·</span>
    <span><kbd>⌘</kbd>/<kbd>Ctrl</kbd> <kbd>Z</kbd> undo</span>
    <span class="spacer"></span>
    <span id="statusText"></span>
  </div>
  <main class="board" id="board"></main>
</div>
<div class="toast-wrap" id="toastWrap"></div>
<script>
"use strict";
/* ============================================================================
   1. TUNABLE CONSTANTS
   ========================================================================== */
const CONFIG = {
  STORAGE_KEY:   'kanban.board.v1',
  COL_WIDTH:     300,
  DRAG_THRESHOLD: 5,      // px of movement before a press becomes a drag
  DRAG_TILT:     2.4,     // degrees of tilt on the floating card
  DRAG_SCALE:    1.035,   // scale of the floating card
  EDGE_ZONE:     92,      // px from board/column edge where auto-scroll kicks in
  H_SCROLL_MAX:  26,      // px/frame horizontal board scroll
  V_SCROLL_MAX:  20,      // px/frame column scroll
  SCROLL_EASE:   0.22,    // 0..1 easing toward target scroll speed
  SETTLE_MS:     340,
  LONG_PRESS_MS: 999999,  // (unused, kept for clarity)
  AUTOSCROLL:    true,
  // Fixed label palette (id, display name, pastel chip colors)
  LABELS: [
    { id:'rose',   name:'Urgent',    bg:'#ffe3e9', fg:'#a92c44', dot:'#f08098' },
    { id:'amber',  name:'Important', bg:'#fff0d8', fg:'#8f5f12', dot:'#f0b45e' },
    { id:'lime',   name:'Ready',     bg:'#e3f6e6', fg:'#2b7442', dot:'#7cc98b' },
    { id:'sky',    name:'Design',    bg:'#e1efff', fg:'#2a5f9e', dot:'#7ab3ec' },
    { id:'violet', name:'Research',  bg:'#ece5ff', fg:'#5640a8', dot:'#a58ef0' },
    { id:'slate',  name:'Low',       bg:'#e9ecf1', fg:'#4d5766', dot:'#9aa5b5' }
  ]
};

/* ============================================================================
   2. UTILITIES
   ========================================================================== */
let _uid = 0;
function uid(p){ _uid++; return (p||'id') + '_' + Date.now().toString(36) + '_' + _uid.toString(36); }
function clone(o){ return JSON.parse(JSON.stringify(o)); }
function esc(s){
  return String(s==null?'':s).replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
}
function labelById(id){ return CONFIG.LABELS.find(l=>l.id===id) || null; }
function $(sel,root){ return (root||document).querySelector(sel); }
function $$(sel,root){ return Array.prototype.slice.call((root||document).querySelectorAll(sel)); }
function todayISO(){
  const d = new Date(); d.setHours(0,0,0,0);
  return d.toISOString().slice(0,10);
}
function plusDays(n){
  const d = new Date(); d.setHours(0,0,0,0); d.setDate(d.getDate() + n);
  return d.toISOString().slice(0,10);
}
function dateDiffDays(iso){
  const a = new Date(iso + 'T00:00:00'); const b = new Date(); b.setHours(0,0,0,0);
  return Math.round((a - b) / 86400000);
}
function prettyDate(iso){
  const d = new Date(iso + 'T00:00:00');
  if (isNaN(d)) return iso;
  return d.toLocaleDateString(undefined,{month:'short',day:'numeric'});
}

/* ============================================================================
   3. STATE + PERSISTENCE  (single source of truth: `board`, `ui`)
   ========================================================================== */
let board = null;            // { columns:[{id,title,wip,cards:[{id,title,desc,labels:[],due}]}] }
let ui    = { search:'', labels:[] };   // view-only state (never mutates order)

function makeCard(title, desc, labels, due){
  return { id: uid('card'), title: title||'', desc: desc||'', labels: labels||[], due: due||null };
}
function makeColumn(title, cards, wip){
  return { id: uid('col'), title: title||'New column', cards: cards||[], wip: (wip==null?null:wip) };
}
function defaultBoard(){
  return { v:1, columns: [
    makeColumn('Backlog', [
      makeCard('Collect onboarding feedback','Interview 8 new users about the first-run flow.',['violet'],null),
      makeCard('Audit empty states','Every list view needs a friendly zero state.',['sky'],null),
      makeCard('Billing edge cases','Proration, dunning, and failed retries.',['slate'],null)
    ]),
    makeColumn('To Do', [
      makeCard('Rework the settings page','Group account, billing and team into tabs.',['rose'],plusDays(-2)),
      makeCard('Keyboard shortcuts','Command palette parity across the app.',['amber'],null)
    ]),
    makeColumn('In Progress', [
      makeCard('Drag & drop board','Pixel-perfect pointer dragging with live drop indicator.',['rose','sky'],plusDays(1)),
      makeCard('Realtime presence','Show who is viewing the board right now.',['violet'],null)
    ]),
    makeColumn('Review', [
      makeCard('New icon set','Swap to the 24px rounded stroke set.',['sky'],null)
    ]),
    makeColumn('Done', [
      makeCard('Ship dark mode','Tokens, contrast pass, and system preference.',['lime'],null),
      makeCard('SSO for enterprise','SAML + SCIM provisioning.',['lime'],null)
    ])
  ]};
}

function normalizeBoard(b){
  if(!b || !Array.isArray(b.columns)) return defaultBoard();
  const cols = b.columns.filter(c=>c && typeof c==='object').map(c=>({
    id: c.id || uid('col'),
    title: typeof c.title==='string' ? c.title : 'Column',
    wip: (typeof c.wip === 'number' && isFinite(c.wip)) ? c.wip : null,
    cards: (Array.isArray(c.cards)?c.cards:[]).filter(x=>x&&typeof x==='object').map(x=>({
      id: x.id || uid('card'),
      title: typeof x.title==='string' ? x.title : '',
      desc: typeof x.desc==='string' ? x.desc : '',
      labels: (Array.isArray(x.labels)?x.labels:[]).filter(id=>labelById(id)),
      due: typeof x.due==='string' && x.due ? x.due : null
    }))
  }));
  return { v:1, columns: cols.length ? cols : defaultBoard().columns };
}

function save(){
  try{
    localStorage.setItem(CONFIG.STORAGE_KEY, JSON.stringify({ board, ui, savedAt: Date.now() }));
  }catch(e){ /* storage unavailable — the board still works in-memory */ }
}
function load(){
  let raw = null;
  try{ raw = localStorage.getItem(CONFIG.STORAGE_KEY); }catch(e){}
  if(raw){
    try{
      const p = JSON.parse(raw);
      if(p && p.board){
        board = normalizeBoard(p.board);
        ui = { search: (p.ui && typeof p.ui.search==='string') ? p.ui.search : '',
               labels: (p.ui && Array.isArray(p.ui.labels)) ? p.ui.labels.filter(id=>labelById(id)) : [] };
        return true;
      }
    }catch(e){}
  }
  board = defaultBoard();
  ui = { search:'', labels:[] };
  return false;
}

/* ---------- derived lookups (always read the model) ---------- */
function getCol(colId){ return board.columns.find(c=>c.id===colId) || null; }
function getCard(cardId){
  for(const c of board.columns){
    const k = c.cards.find(x=>x.id===cardId);
    if(k) return { card:k, col:c, index:c.cards.indexOf(k) };
  }
  return null;
}
let forceShowId = null;   // a just-created card stays visible even if it matches no filter
function isCardVisible(card){
  if(card.id === forceShowId) return true;
  if(ui.labels.length){
    if(!card.labels.some(id=>ui.labels.indexOf(id) !== -1)) return false;
  }
  if(ui.search){
    const q = ui.search.toLowerCase();
    if((card.title||'').toLowerCase().indexOf(q) === -1 && (card.desc||'').toLowerCase().indexOf(q) === -1) return false;
  }
  return true;
}
function visibleCardsOf(col){ return col.cards.filter(isCardVisible); }
function filtersActive(){ return !!(ui.search || ui.labels.length); }

/* ============================================================================
   4. RENDERING  (pure function of the model — never reads state from the DOM)
   ========================================================================== */
const boardEl = document.getElementById('board');
const labelBarEl = document.getElementById('labelBar');
const statusEl = document.getElementById('statusText');

function renderCardHTML(card, col){
  const labels = card.labels.map(labelById).filter(Boolean);
  const overdue = card.due && dateDiffDays(card.due) < 0;
  const soon = card.due && !overdue && dateDiffDays(card.due) <= 2;
  let dueHTML = '';
  if(card.due){
    dueHTML = `<span class="due${overdue?' overdue':(soon?' soon':'')}" title="Due ${esc(card.due)}">`+
      `<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round"><rect x="3" y="5" width="18" height="16" rx="3"/><path d="M8 3v4M16 3v4M3 11h18"/></svg>`+
      esc(prettyDate(card.due)) + (overdue?' · overdue':'') + `</span>`;
  }
  return `<article class="card" data-card="${card.id}" data-col="${col.id}" aria-label="${esc(card.title)}">
    ${labels.length ? `<div class="card-labels">${labels.map(l=>
      `<span class="chip" style="background:${l.bg};color:${l.fg}"><i style="background:${l.dot}"></i>${esc(l.name)}</span>`
    ).join('')}</div>` : ''}
    <div class="card-title" data-edit="title">${card.title ? esc(card.title) : '<span class="ph">Untitled</span>'}</div>
    ${card.desc ? `<div class="card-desc" data-edit="desc">${esc(card.desc)}</div>` : ''}
    <div class="card-foot">${dueHTML}<button class="card-menu-btn" data-act="card-menu" title="Card options" aria-label="Card options">⋯</button></div>
  </article>`;
}

function renderColumnHTML(col){
  const visible = visibleCardsOf(col);
  const total = col.cards.length;
  const over = col.wip != null && total > col.wip;
  const countText = (filtersActive() && visible.length !== total) ? visible.length + '/' + total : String(total);
  return `<section class="column${over?' over-wip':''}" data-col="${col.id}">
    <header class="column-header" data-col="${col.id}">
      <span class="col-title" data-edit="col-title" title="Double-click to rename">${esc(col.title)}</span>
      ${col.wip != null ? `<span class="wip-tag" title="WIP limit ${col.wip}">WIP ${col.wip}</span>` : ''}
      <span class="col-count" title="${total} card${total===1?'':'s'}">${countText}</span>
      <button class="icon-btn" data-act="col-menu" title="Column options" aria-label="Column options">⋯</button>
    </header>
    <div class="cards${visible.length===0?' empty':''}" data-col="${col.id}">
      ${visible.length===0
        ? '<div class="empty-hint"><span class="dotgrid"><i></i><i></i><i></i><i></i><i></i><i></i></span>Drop cards here</div>'
        : visible.map(c=>renderCardHTML(c, col)).join('')}
    </div>
    <div class="column-footer"><button class="add-card" data-act="add-card" data-col="${col.id}">
      <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round"><path d="M12 5v14M5 12h14"/></svg>
      Add card</button></div>
  </section>`;
}

/* Column scroll offsets are cached on scroll events so that render() never has
   to read layout before writing — no forced reflow, no layout thrash. */
const colScrolls = Object.create(null);
boardEl.addEventListener('scroll', e=>{
  const t = e.target;
  if(t && t.classList && t.classList.contains && t.classList.contains('cards')) colScrolls[t.dataset.col] = t.scrollTop;
}, true);

function render(){
  boardEl.innerHTML = board.columns.map(renderColumnHTML).join('');
  for(let i=0;i<board.columns.length;i++){
    const id = board.columns[i].id;
    if(colScrolls[id]) boardEl.querySelector('.cards[data-col="'+id+'"]').scrollTop = colScrolls[id];
  }
  renderLabelBar();
  renderStatus();
}
function renderLabelBar(){
  labelBarEl.innerHTML = CONFIG.LABELS.map(l=>{
    const on = ui.labels.indexOf(l.id) !== -1;
    return `<button class="lbl-chip${on?' on':''}" data-label="${l.id}"
      style="--cbg:${l.bg};--cfg:${l.fg};--cdot:${l.dot};--dot:${l.dot}"><i></i>${esc(l.name)}</button>`;
  }).join('');
}
function renderStatus(){
  const totalCards = board.columns.reduce((n,c)=>n+c.cards.length,0);
  const shown = board.columns.reduce((n,c)=>n+visibleCardsOf(c).length,0);
  let html = `<span class="cnt"><b>${shown}</b>${shown!==totalCards?' of '+totalCards:''} cards · ${board.columns.length} columns</span>`;
  if(filtersActive()){
    const parts = [];
    if(ui.search) parts.push('“'+esc(ui.search)+'”');
    if(ui.labels.length) parts.push(ui.labels.map(id=>esc(labelById(id).name)).join(', '));
    html += ` <span class="filtering-tag">Filtered by ${parts.join(' + ')} <button data-act="clear-filters">clear</button></span>`;
    if(shown === 0) html += ` <span style="color:#b63449">— no matches</span>`;
  }
  statusEl.innerHTML = html;
}
function setSettle(cardId){
  const el = boardEl.querySelector('.card[data-card="'+cardId+'"]');
  if(!el) return;
  el.classList.add('settle');
  setTimeout(()=>el.classList.remove('settle'), CONFIG.SETTLE_MS);
}

/* ============================================================================
   5. MUTATIONS  (every mutation: model -> save -> render)
   ========================================================================== */
const undoStack = [];
function pushUndo(label, snapshot){
  undoStack.push({ label, snapshot: snapshot || clone(board) });
  if(undoStack.length > 40) undoStack.shift();
}
function commit(opts){
  save();
  render();
  if(opts && opts.settle) setSettle(opts.settle);
}

/* ============================================================================
   6. TOASTS + UNDO
   ========================================================================== */
const toastWrap = document.getElementById('toastWrap');
function toast(msg, actionLabel, actionFn){
  const el = document.createElement('div');
  el.className = 'toast';
  el.innerHTML = '<span>'+msg+'</span>'
    + (actionLabel ? '<button data-toast-act>'+actionLabel+'</button>' : '')
    + '<button class="x" data-toast-x aria-label="Dismiss">✕</button>';
  toastWrap.appendChild(el);
  const kill = ()=>{ el.classList.add('out'); setTimeout(()=>el.remove(), 220); };
  el.addEventListener('click', e=>{
    if(e.target.closest('[data-toast-act]')){ if(actionFn) actionFn(); kill(); }
    else if(e.target.closest('[data-toast-x]')) kill();
  });
  setTimeout(kill, 6500);
  while(toastWrap.children.length > 3) toastWrap.firstChild.remove();
  return el;
}
function undoLast(){
  const last = undoStack.pop();
  if(!last){ toast('Nothing to undo'); return; }
  board = clone(last.snapshot);
  closePopover(); save(); render();
  toast('Undid: ' + last.label);
}

/* ============================================================================
   7. POPOVERS (card menu + column menu)
   ========================================================================== */
let popEl = null, popState = null, popOpenerClickTs = 0;

function closePopover(){
  if(popEl){ popEl.remove(); popEl = null; }
  popState = null;
}
function positionPopover(el, rect){
  const r = el.getBoundingClientRect();
  let left = rect.left;
  let top  = rect.bottom + 8;
  if(top + r.height > window.innerHeight - 8) top = Math.max(8, rect.top - r.height - 8);
  if(left + r.width > window.innerWidth - 10) left = Math.max(10, window.innerWidth - r.width - 10);
  el.style.left = Math.round(left) + 'px';
  el.style.top  = Math.round(top) + 'px';
  el.style.opacity = '1';
}
function buildPopoverContent(){
  if(!popState) return '';
  if(popState.type === 'card'){
    const found = getCard(popState.id);
    if(!found) return '';
    const c = found.card;
    return `<div class="pop-head">Card options</div>
      <div class="pop-sec">
        <span class="label">Labels</span>
        <div class="label-picker">${CONFIG.LABELS.map(l=>{
          const on = c.labels.indexOf(l.id) !== -1;
          return `<button class="pick${on?' on':''}" data-pick="${l.id}"
            style="background:${l.bg};color:${l.fg};--cdot:${l.dot}"><i style="background:${l.dot}"></i>${esc(l.name)}</button>`;
        }).join('')}</div>
      </div>
      <div class="pop-sec">
        <span class="label">Due date</span>
        <div class="due-row"><input type="date" data-act="set-due" value="${c.due ? esc(c.due) : ''}"></div>
      </div>
      <div class="pop-sec">
        <button class="menu-item" data-act="edit-desc">✎ Add / edit description</button>
        <button class="menu-item danger" data-act="delete-card">🗑 Delete card</button>
      </div>`;
  }
  if(popState.type === 'col'){
    const col = getCol(popState.id);
    if(!col) return '';
    return `<div class="pop-head">Column options</div>
      <div class="pop-sec"><button class="menu-item" data-act="rename-col">✎ Rename column</button></div>
      <div class="pop-sec">
        <span class="label">WIP limit</span>
        <div class="wip-row">
          <input type="number" min="0" step="1" data-act="set-wip" value="${col.wip==null?'':col.wip}" placeholder="none">
          <span>cards before the column turns red</span>
        </div>
      </div>
      <div class="pop-sec">
        <button class="menu-item danger" data-act="delete-col" ${board.columns.length<=1?'disabled':''}>🗑 Delete column</button>
      </div>`;
  }
  return '';
}
function openPopoverFor(type, id, rect){
  closePopover();
  popState = { type, id, rect };
  const el = document.createElement('div');
  el.className = 'popover';
  el.style.left = '-9999px'; el.style.top = '-9999px'; el.style.opacity = '0';
  el.innerHTML = buildPopoverContent();
  document.body.appendChild(el);
  popEl = el;
  positionPopover(el, rect);
  el.addEventListener('click', onPopoverClick);
  el.addEventListener('change', onPopoverChange);
}
function refreshPopover(){
  if(!popState) return;
  const rect = popState.rect;
  const type = popState.type, id = popState.id;
  if(popEl){ popEl.remove(); popEl = null; }
  openPopoverFor(type, id, rect);
}
function onPopoverChange(e){
  const t = e.target;
  if(t.dataset && t.dataset.act === 'set-due' && popState){
    const found = getCard(popState.id); if(!found) return;
    found.card.due = t.value || null;
    save(); render(); refreshPopover();
  }
  if(t.dataset && t.dataset.act === 'set-wip' && popState){
    const col = getCol(popState.id); if(!col) return;
    const v = parseInt(t.value, 10);
    col.wip = (isNaN(v) || v <= 0) ? null : Math.min(v, 999);
    save(); render(); refreshPopover();
  }
}
function onPopoverClick(e){
  const pick = e.target.closest('[data-pick]');
  if(pick && popState && popState.type === 'card'){
    const found = getCard(popState.id); if(!found) return;
    const lid = pick.dataset.pick;
    const i = found.card.labels.indexOf(lid);
    if(i === -1) found.card.labels.push(lid); else found.card.labels.splice(i,1);
    save(); render(); refreshPopover();
    return;
  }
  const btn = e.target.closest('[data-act]');
  if(!btn || !popState) return;
  const act = btn.dataset.act;
  if(act === 'delete-card'){
    const found = getCard(popState.id); if(!found) return;
    pushUndo('deleted card');
    const label = found.card.title || 'Untitled';
    found.col.cards.splice(found.index, 1);
    closePopover(); commit();
    toast('Deleted “'+esc(label)+'”', 'Undo', undoLast);
  }
  else if(act === 'edit-desc'){
    const found = getCard(popState.id); if(!found) return;
    const id = found.card.id;
    if(!found.card.desc) found.card.desc = ' ';
    closePopover(); commit();
    requestAnimationFrame(()=>openEditorFor(id, 'desc'));
  }
  else if(act === 'rename-col'){
    const col = getCol(popState.id); if(!col) return;
    const id = col.id;
    closePopover();
    requestAnimationFrame(()=>openEditorFor(id, 'col-title'));
  }
  else if(act === 'delete-col'){
    if(board.columns.length <= 1){ toast('A board needs at least one column'); return; }
    const found = getCol(popState.id); if(!found) return;
    pushUndo('deleted column');
    const label = found.title;
    board.columns.splice(board.columns.indexOf(found), 1);
    closePopover(); commit();
    toast('Deleted column “'+esc(label)+'”', 'Undo', undoLast);
  }
}
document.addEventListener('pointerdown', e=>{
  if(!popEl) return;
  const t = e.target;
  if(t && t.closest && (t.closest('.popover') || t.closest('[data-act="card-menu"]') || t.closest('[data-act="col-menu"]'))) return;
  closePopover();
}, true);

/* ============================================================================
   8. INLINE EDITING  (click title / description, Enter commits, Esc cancels)
   ========================================================================== */
let pendingEdit = null;   // { id, field } applied after the next render

function openEditorFor(id, field){
  let target = null;
  if(field === 'col-title') target = boardEl.querySelector('.column[data-col="'+id+'"] .col-title');
  else target = boardEl.querySelector('.card[data-card="'+id+'"] [data-edit="'+field+'"]');
  if(!target) return;              // hidden by the current filter — nothing to edit
  beginEdit(target, id, field);
}
function beginEdit(target, id, field){
  const isCol = field === 'col-title';
  const multiline = field === 'desc';
  let current = '';
  if(isCol){ const col = getCol(id); current = col ? col.title : ''; }
  else { const f = getCard(id); current = f ? (f.card[field] || '') : ''; }

  const input = document.createElement(multiline ? 'textarea' : 'input');
  if(!multiline) input.type = 'text';
  input.className = (multiline ? 'inline-area' : 'inline-input') + (isCol ? ' small' : '');
  input.value = current;
  input.spellcheck = false;
  input.dataset.editing = '1';
  target.replaceWith(input);
  input.focus();
  if(!multiline || current.trim().length === 0){
    try{ input.setSelectionRange(0, input.value.length); }catch(err){}
  }
  let done = false;
  function finish(commitIt){
    if(done) return;                 // guards against the blur that fires on removal
    done = true;
    input.removeEventListener('blur', onBlur);
    forceShowId = null;
    const val = input.value.trim();
    if(commitIt){
      if(isCol){
        const col = getCol(id);
        if(col && val) col.title = val;
      } else {
        const f = getCard(id);
        if(f && (f.card[field] || '') !== val){
          f.card[field] = val;
        }
      }
    }
    save(); render();
  }
  function onBlur(){ finish(true); }
  input.addEventListener('keydown', ev=>{
    ev.stopPropagation();
    if(ev.key === 'Escape'){ ev.preventDefault(); ev.stopPropagation(); finish(false); }
    else if(ev.key === 'Enter' && (!multiline || ev.metaKey || ev.ctrlKey)){ ev.preventDefault(); finish(true); }
  });
  input.addEventListener('blur', onBlur);
  input.addEventListener('pointerdown', ev=>ev.stopPropagation());
}
function openEditorAfterRender(id, field){ pendingEdit = { id, field }; }

/* ============================================================================
   9. BOARD-LEVEL EVENTS
   ========================================================================== */
boardEl.addEventListener('click', e=>{
  if(suppressClick){ suppressClick = false; return; }
  if(e.target.closest('[data-editing]')) return;

  const menuBtn = e.target.closest('[data-act="card-menu"]');
  if(menuBtn){
    const cardEl = menuBtn.closest('.card');
    openPopoverFor('card', cardEl.dataset.card, menuBtn.getBoundingClientRect());
    return;
  }
  const colMenu = e.target.closest('[data-act="col-menu"]');
  if(colMenu){
    const colEl = colMenu.closest('.column');
    openPopoverFor('col', colEl.dataset.col, colMenu.getBoundingClientRect());
    return;
  }
  const addBtn = e.target.closest('[data-act="add-card"]');
  if(addBtn){
    const col = getCol(addBtn.dataset.col); if(!col) return;
    const card = makeCard('', '', [], null);
    col.cards.push(card);
    forceShowId = card.id;
    save(); render();
    setSettle(card.id);
    openEditorAfterRender(card.id, 'title');
    applyPendingEdit();
    return;
  }
  const titleEl = e.target.closest('.card-title');
  if(titleEl){
    const cardEl = titleEl.closest('.card');
    beginEdit(titleEl, cardEl.dataset.card, 'title');
    return;
  }
  const descEl = e.target.closest('.card-desc');
  if(descEl){
    const cardEl = descEl.closest('.card');
    beginEdit(descEl, cardEl.dataset.card, 'desc');
    return;
  }
});
boardEl.addEventListener('dblclick', e=>{
  const colTitle = e.target.closest('.col-title');
  if(colTitle && !e.target.closest('[data-editing]')){
    beginEdit(colTitle, colTitle.closest('.column').dataset.col, 'col-title');
  }
});
function applyPendingEdit(){
  if(!pendingEdit) return;
  const pe = pendingEdit; pendingEdit = null;
  requestAnimationFrame(()=>openEditorFor(pe.id, pe.field));
}

/* filters */
document.getElementById('searchInput').addEventListener('input', e=>{
  ui.search = e.target.value.trim();
  save(); render();
});
labelBarEl.addEventListener('click', e=>{
  const chip = e.target.closest('[data-label]');
  if(!chip) return;
  const id = chip.dataset.label;
  const i = ui.labels.indexOf(id);
  if(i === -1) ui.labels.push(id); else ui.labels.splice(i,1);
  save(); render();
});
document.addEventListener('click', e=>{
  if(e.target.closest('[data-act="clear-filters"]')){
    ui.search = ''; ui.labels = [];
    document.getElementById('searchInput').value = '';
    save(); render();
  }
});
document.getElementById('clearFilters').addEventListener('click', ()=>{
  ui.search = ''; ui.labels = [];
  document.getElementById('searchInput').value = '';
  save(); render();
});
document.getElementById('addColumnBtn').addEventListener('click', ()=>{
  const col = makeColumn('New column', [], null);
  board.columns.push(col);
  save(); render();
  const el = boardEl.querySelector('.column[data-col="'+col.id+'"]');
  if(el){ el.classList.add('just-moved'); el.scrollIntoView({block:'nearest', inline:'nearest', behavior:'smooth'}); }
  openEditorAfterRender(col.id, 'col-title');
  applyPendingEdit();
});

/* keyboard: undo, escape closes popovers */
document.addEventListener('keydown', e=>{
  if((e.metaKey || e.ctrlKey) && (e.key === 'z' || e.key === 'Z') && !e.shiftKey){
    if(e.target && e.target.closest && e.target.closest('[data-editing]')) return;
    e.preventDefault(); undoLast();
  }
});

/* ============================================================================
   10. DRAG & DROP
   ---------------------------------------------------------------------------
   Pointer Events based. The model is the single source of truth: a live
   placeholder is moved through the DOM to preview the drop, but the actual
   insertion index is computed against the *model* (visible-card mapping), so
   filtered views can never corrupt hidden cards' positions.
   ========================================================================== */
let drag     = null;      // active drag
let pending  = null;      // press that has not yet crossed the drag threshold
let suppressClick = false;

/* ---------- press handling ---------- */
boardEl.addEventListener('pointerdown', e=>{
  if(e.pointerType === 'mouse' && e.button !== 0) return;
  if(popEl) return;
  if(e.target.closest('input,textarea,select,[data-editing]')) return;
  if(e.target.closest('button')) return;

  const cardEl   = e.target.closest('.card');
  const headerEl = e.target.closest('.column-header');

  if(cardEl && cardEl.dataset.card){
    const found = getCard(cardEl.dataset.card);
    if(!found) return;
    startPending(e, { kind:'card', el:cardEl, cardId:found.card.id, colId:found.col.id, index:found.index });
  } else if(headerEl && headerEl.dataset.col){
    startPending(e, { kind:'col', el:headerEl, colId:headerEl.dataset.col });
  }
});

function startPending(e, info){
  pending = Object.assign({ startX:e.clientX, startY:e.clientY, pointerId:e.pointerId }, info);
  window.addEventListener('pointermove', onPendingMove);
  window.addEventListener('pointerup', onPendingUp);
  window.addEventListener('pointercancel', onPendingUp);
  window.addEventListener('keydown', onPendingKey);
}
function clearPending(){
  window.removeEventListener('pointermove', onPendingMove);
  window.removeEventListener('pointerup', onPendingUp);
  window.removeEventListener('pointercancel', onPendingUp);
  window.removeEventListener('keydown', onPendingKey);
  pending = null;
}
function onPendingKey(e){
  if(e.key === 'Escape') clearPending();
}
function onPendingUp(){ clearPending(); }
function onPendingMove(e){
  if(!pending) return;
  const dx = e.clientX - pending.startX, dy = e.clientY - pending.startY;
  if(Math.hypot(dx, dy) < CONFIG.DRAG_THRESHOLD) return;
  const info = pending;
  clearPending();
  if(info.kind === 'card') beginCardDrag(info, e);
  else beginColumnDrag(info, e);
}

/* ---------- card drag ---------- */
function beginCardDrag(info, e){
  const cardEl = info.el;
  const rect = cardEl.getBoundingClientRect();

  drag = {
    kind:'card',
    cardId: info.cardId,
    fromColId: info.colId,
    fromIndex: info.index,
    snapshot: clone(board),
    offsetX: info.startX - rect.left,
    offsetY: info.startY - rect.top,
    px: e.clientX, py: e.clientY,
    width: rect.width, height: rect.height,
    ghost: null, placeholder: null,
    drop: null, dirty: true, raf: 0,
    hSpeed: 0, vSpeed: 0,
    startScrollLeft: boardEl.scrollLeft
  };

  /* floating clone that follows the cursor */
  const ghost = document.createElement('div');
  ghost.className = 'drag-ghost';
  ghost.style.width = rect.width + 'px';
  const inner = cardEl.cloneNode(true);
  inner.classList.remove('settle','lifting');
  ghost.appendChild(inner);
  document.body.appendChild(ghost);
  drag.ghost = ghost;

  /* placeholder takes the original spot — no duplicate card remains */
  const ph = document.createElement('div');
  ph.className = 'placeholder';
  ph.style.height = Math.round(rect.height) + 'px';
  cardEl.parentNode.insertBefore(ph, cardEl);
  cardEl.remove();
  drag.placeholder = ph;

  document.body.classList.add('dragging');
  closePopover();

  moveGhost();
  computeDropTarget();
  drag.raf = requestAnimationFrame(dragLoop);

  window.addEventListener('pointermove', onDragMove);
  window.addEventListener('pointerup', onDragEnd);
  window.addEventListener('pointercancel', onDragCancel);
  window.addEventListener('keydown', onDragKey);
  suppressClick = true;
}

function onDragMove(e){
  if(!drag) return;
  drag.px = e.clientX; drag.py = e.clientY;
  drag.dirty = true;                       // batched into the next animation frame
}
function moveGhost(){
  if(!drag || !drag.ghost) return;
  const x = drag.px - drag.offsetX;
  const y = drag.py - drag.offsetY;
  drag.ghost.style.transform =
    'translate3d('+x.toFixed(1)+'px,'+y.toFixed(1)+'px,0) rotate('+CONFIG.DRAG_TILT+'deg) scale('+CONFIG.DRAG_SCALE+')';
}
function onDragKey(e){
  if(e.key === 'Escape'){ e.preventDefault(); e.stopPropagation(); cancelDrag(); }
}
function onDragEnd(){ if(drag) performDrop(); }
function onDragCancel(){ if(drag) cancelDrag(); }

/* ---------- the frame loop: ghost + auto-scroll + hit-test (batched) ---------- */
function dragLoop(){
  if(!drag){ return; }
  const scrolled = autoScroll();
  moveGhost();
  if(drag.dirty || scrolled){
    drag.dirty = false;
    computeDropTarget();
  }
  drag.raf = requestAnimationFrame(dragLoop);
}

function autoScroll(){
  if(!CONFIG.AUTOSCROLL || !drag) return false;
  let changed = false;
  const bRect = boardEl.getBoundingClientRect();

  /* horizontal: board edges */
  let hTarget = 0;
  if(drag.px < bRect.left + CONFIG.EDGE_ZONE){
    hTarget = -CONFIG.H_SCROLL_MAX * (1 - (drag.px - bRect.left) / CONFIG.EDGE_ZONE);
  } else if(drag.px > bRect.right - CONFIG.EDGE_ZONE){
    hTarget = CONFIG.H_SCROLL_MAX * (1 - (bRect.right - drag.px) / CONFIG.EDGE_ZONE);
  }
  hTarget = Math.max(-CONFIG.H_SCROLL_MAX, Math.min(CONFIG.H_SCROLL_MAX, hTarget));
  drag.hSpeed += (hTarget - drag.hSpeed) * CONFIG.SCROLL_EASE;
  if(Math.abs(drag.hSpeed) > 0.15){
    const before = boardEl.scrollLeft;
    boardEl.scrollLeft = before + drag.hSpeed;
    if(boardEl.scrollLeft !== before) changed = true;
  }

  /* vertical: only the column under the cursor (cards drag) */
  if(drag.kind === 'card'){
    const colEl = (drag.drop && drag.drop.colEl) || null;
    let vTarget = 0;
    if(colEl){
      const body = colEl.querySelector('.cards');
      const r = body.getBoundingClientRect();
      if(drag.py < r.top + CONFIG.EDGE_ZONE){
        vTarget = -CONFIG.V_SCROLL_MAX * (1 - (drag.py - r.top) / CONFIG.EDGE_ZONE);
      } else if(drag.py > r.bottom - CONFIG.EDGE_ZONE){
        vTarget = CONFIG.V_SCROLL_MAX * (1 - (r.bottom - drag.py) / CONFIG.EDGE_ZONE);
      }
      vTarget = Math.max(-CONFIG.V_SCROLL_MAX, Math.min(CONFIG.V_SCROLL_MAX, vTarget));
      drag.vSpeed += (vTarget - drag.vSpeed) * CONFIG.SCROLL_EASE;
      if(Math.abs(drag.vSpeed) > 0.15){
        const before = body.scrollTop;
        body.scrollTop = before + drag.vSpeed;
        if(body.scrollTop !== before) changed = true;
      }
    } else { drag.vSpeed = 0; }
  }
  return changed;
}

/* ---------- hit testing ---------- */
function computeDropTarget(){
  if(!drag) return;

  if(drag.kind === 'col'){ computeColumnDropTarget(); return; }

  /* 1. which column is under the cursor (nearest wins) */
  const colEls = $$('.column', boardEl);
  let best = null, bestDist = Infinity;
  for(let i=0;i<colEls.length;i++){
    const r = colEls[i].getBoundingClientRect();
    if(drag.px >= r.left && drag.px <= r.right){ best = colEls[i]; bestDist = 0; break; }
    const d = drag.px < r.left ? (r.left - drag.px) : (drag.px - r.right);
    if(d < bestDist){ bestDist = d; best = colEls[i]; }
  }
  if(!best) return;

  /* 2. insertion index = first card whose vertical midpoint is below the cursor */
  const body = best.querySelector('.cards');
  const cards = $$('.card', body);
  let index = cards.length;
  for(let i=0;i<cards.length;i++){
    const r = cards[i].getBoundingClientRect();
    if(drag.py < r.top + r.height / 2){ index = i; break; }
  }

  const colId = best.dataset.col;
  const prev = drag.drop;
  drag.drop = { colId: colId, index: index, colEl: best, body: body };

  if(!prev || prev.colId !== colId || prev.index !== index){
    placePlaceholder(body, cards, index);
    updateHotColumns();
  }
}

function placePlaceholder(body, cards, index){
  const ph = drag.placeholder;
  if(!ph) return;
  if(cards.length === 0){
    if(body.firstChild) body.insertBefore(ph, body.firstChild);
    else body.appendChild(ph);
  } else if(index >= cards.length){
    body.insertBefore(ph, cards[cards.length - 1].nextSibling);
  } else {
    body.insertBefore(ph, cards[index]);
  }
}
function updateHotColumns(){
  if(!drag || drag.kind !== 'card') return;
  const activeCol = drag.drop ? drag.drop.colId : null;
  $$('.cards', boardEl).forEach(el=>{
    const isEmpty = el.classList.contains('empty');
    const isActive = el.dataset.col === activeCol;
    el.classList.toggle('hot', isEmpty && isActive);
    el.classList.toggle('drop-active', isEmpty && isActive);
  });
}

/* Map a DOM insertion index (a slot *before* that many rendered cards) to a
   model index. The rendered list omits filtered-out cards and — while the
   pointer drags within the same column — the dragged card itself, so both are
   excluded here to keep the mapping exact. */
function modelIndexFor(colId, domIndex, draggedId){
  const col = getCol(colId);
  if(!col) return 0;
  const list = col.cards.filter(c=>isCardVisible(c) && c.id !== draggedId);
  if(domIndex >= list.length) return col.cards.length;   // past the last card -> append
  const idx = col.cards.indexOf(list[domIndex]);
  return idx === -1 ? col.cards.length : idx;
}

/* ---------- column drag ---------- */
function beginColumnDrag(info, e){
  const colEl = info.el.closest('.column');
  const rect = colEl.getBoundingClientRect();
  drag = {
    kind:'col',
    colId: info.colId,
    snapshot: clone(board),
    px: e.clientX, py: e.clientY,
    colEl: colEl,
    placeholder: null,
    drop: null, dirty:true, raf:0,
    hSpeed:0, vSpeed:0
  };
  const ph = document.createElement('div');
  ph.className = 'placeholder col';
  ph.style.height = Math.round(rect.height) + 'px';
  colEl.parentNode.insertBefore(ph, colEl);
  drag.placeholder = ph;
  colEl.classList.add('col-dragging');
  document.body.classList.add('col-dragging');
  closePopover();
  computeColumnDropTarget();
  drag.raf = requestAnimationFrame(dragLoop);
  window.addEventListener('pointermove', onDragMove);
  window.addEventListener('pointerup', onDragEnd);
  window.addEventListener('pointercancel', onDragCancel);
  window.addEventListener('keydown', onDragKey);
  suppressClick = true;
}
function computeColumnDropTarget(){
  if(!drag || drag.kind !== 'col') return;
  const others = $$('.column', boardEl).filter(el=>el.dataset.col !== drag.colId);
  let index = others.length;
  for(let i=0;i<others.length;i++){
    const r = others[i].getBoundingClientRect();
    if(drag.px < r.left + r.width / 2){ index = i; break; }
  }
  if(!drag.drop || drag.drop.index !== index){
    drag.drop = { index: index };
    const ph = drag.placeholder;
    if(index >= others.length){
      if(others.length) boardEl.insertBefore(ph, others[others.length-1].nextSibling);
      else boardEl.insertBefore(ph, drag.colEl);
    } else {
      boardEl.insertBefore(ph, others[index]);
    }
  }
}

/* ---------- drop / cancel ---------- */
function performDrop(){
  const d = drag;
  const drop = d.drop;
  teardownDrag();

  if(d.kind === 'card'){
    const cardId = d.cardId;
    if(!drop){ render(); return; }
    const found = getCard(cardId);
    const targetCol = getCol(drop.colId);
    if(!found || !targetCol){ render(); return; }
    const targetIdx = modelIndexFor(drop.colId, drop.index, cardId);
    const fromIndex = found.index;
    const fromCol = found.col;
    fromCol.cards.splice(fromIndex, 1);
    let insertIdx = targetIdx;
    if(fromCol.id === targetCol.id && fromIndex < targetIdx) insertIdx = targetIdx - 1;
    insertIdx = Math.max(0, Math.min(targetCol.cards.length, insertIdx));
    targetCol.cards.splice(insertIdx, 0, found.card);
    save(); render(); setSettle(cardId);
  } else {
    if(drop){
      const arr = board.columns;
      const fromIndex = arr.indexOf(getCol(d.colId));
      if(fromIndex !== -1){
        const card = arr.splice(fromIndex, 1)[0];
        let insertIdx = drop.index;
        if(fromIndex < insertIdx) insertIdx -= 1;
        insertIdx = Math.max(0, Math.min(arr.length, insertIdx));
        arr.splice(insertIdx, 0, card);
      }
    }
    save(); render();
    const el = boardEl.querySelector('.column[data-col="'+d.colId+'"]');
    if(el){ el.classList.add('just-moved'); }
  }
}
function cancelDrag(){
  const d = drag;
  teardownDrag();
  board = clone(d.snapshot);         // exact restore, no state drift
  save(); render();
  toast('Drag cancelled');
}
function teardownDrag(){
  if(drag){
    if(drag.raf) cancelAnimationFrame(drag.raf);
    if(drag.ghost) drag.ghost.remove();
    if(drag.placeholder) drag.placeholder.remove();
    if(drag.colEl) drag.colEl.classList.remove('col-dragging');
  }
  drag = null;
  $$('.cards.hot, .cards.drop-active', boardEl).forEach(el=>el.classList.remove('hot','drop-active'));
  document.body.classList.remove('dragging','col-dragging');
  window.removeEventListener('pointermove', onDragMove);
  window.removeEventListener('pointerup', onDragEnd);
  window.removeEventListener('pointercancel', onDragCancel);
  window.removeEventListener('keydown', onDragKey);
  setTimeout(()=>{ suppressClick = false; }, 40);
}

/* ============================================================================
   11. BOOT
   ========================================================================== */
(function init(){
  const had = load();
  render();
  const input = document.getElementById('searchInput');
  if(input) input.value = ui.search || '';
  if(!had) save();
  window.addEventListener('beforeunload', save);
  /* expose a tiny debug surface for automated checks */
  window.Kanban = {
    get board(){ return board; },
    get ui(){ return ui; },
    CONFIG: CONFIG,
    render: render,
    save: save,
    getCard: getCard,
    getCol: getCol,
    reset: function(){ localStorage.removeItem(CONFIG.STORAGE_KEY); board = defaultBoard(); ui = {search:'',labels:[]}; render(); }
  };
})();
</script>
</body>
</html>
<!-- agent-meta {"model":"deepseek/deepseek-v4.1-flash","provider":"openrouter","persona":"full","sessionId":"940e1e50-8a25-4864-9418-e129fa443f74","tokensIn":5013590,"tokensOut":77312,"tokensTotal":5090902,"cost":0.07301245200000002,"turns":53,"toolCalls":52,"failedToolCalls":0,"timestamp":"2026-09-10T22:05:42.090Z"} -->