Kanban
Deepseek v4.1 Flash · full

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