GLM 5.3 Flash
by Z.aiReasoningClean
Running live in a sandboxed frame. Click inside it before using the keyboard.
How it went
- Total time
- 3 min 23 s
- Cost
- $0.016
- Output tokens
- 31,793
- Spent thinking
- 23,677 tokens
- Speed
- 157.7 tok/s
- Console errors
- 0
- First token after
- 979 ms
- First visible text after
- 2 min 44 s
- Prompt tokens
- 493
- Code size
- 27.2 KB
- Finish reason
- stop
- Served by
- Fireworks
- Model id
z-ai/glm-5.3-flash- Reasoning sent
{"effort":"high"}- Max output
- 64,000 tokens
- Generated
- Nov 10, 2026
What it was asked
The prompt
Build an interactive Kanban board in vanilla HTML, CSS and JavaScript (no framework, no library). Requirements: - Three columns: "To do", "In progress", "Done", each showing its card count. - Pre-populate the board with 6 realistic cards spread across the columns so the first screenshot looks alive. - Each card has a title and a priority (low / medium / high) shown as a colored badge. - Add a card through a small modal form (title required), and delete a card with a button on it. - Drag and drop cards between columns with the mouse, with a clear visual drop indicator. - A search field that filters cards live by title. - A light / dark theme toggle. - State lives in memory only (no storage APIs).
System prompt, sent with every prompt
You are generating a self-contained web demo. It will be rendered automatically in a sandboxed iframe and compared side by side with the output of other AI models given the exact same task. Output rules: - Reply with exactly ONE fenced code block containing the complete file: ```html (or ```svg when the task asks for an SVG). No text before or after the code block. - Everything must be inline: CSS in <style>, JavaScript in <script>. No external files, images, fonts or network requests, except the CDN URLs explicitly allowed by the task. - Do not use localStorage, sessionStorage, cookies, IndexedDB, alert(), confirm(), prompt() or window.open(): they are blocked in the sandbox and will throw. - The page must look right in a 1280×800 viewport and adapt gracefully down to 375px wide. No horizontal scrollbar. - Something meaningful must be visible on first paint: a screenshot is taken automatically about 3 seconds after load, without any interaction. - Everything that makes the demo interesting must be visible within the first seconds: nothing hidden behind a high score, a later level or a long play session. - Games must start in a self-playing demo mode (a simple AI plays) behind a "Click or press a key to start" message, so the game can be judged in action without playing. - The iframe only receives keyboard events after the user clicks it: if the demo uses the keyboard, display a clear "Click or press a key to start" message. - The code must run with zero errors in the console of the latest Chrome.
Code run-1.html · 579 lines
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Sprint board — Kanban</title>
<style>
:root{
--bg:#f3efe6; --panel:#faf7f0; --card:#fffdf8;
--border:#e4ddca; --border-strong:#cfc5aa;
--ink:#282318; --muted:#7c7360; --faint:#b0a78f;
--accent:#bc4a2a; --accent-ink:#fff8f2;
--shadow:0 1px 2px rgba(70,58,35,.07),0 3px 10px rgba(70,58,35,.06);
--shadow-lift:0 18px 40px rgba(55,42,22,.28);
--pri-high:#b23c22; --pri-med:#a0741d; --pri-low:#41724f;
--dot:rgba(80,66,40,.09);
}
html[data-theme="dark"]{
--bg:#161310; --panel:#1e1a15; --card:#272219;
--border:#373023; --border-strong:#4d4432;
--ink:#ede4d2; --muted:#a3987f; --faint:#655c48;
--accent:#e06a45; --accent-ink:#21120b;
--shadow:0 1px 2px rgba(0,0,0,.35),0 3px 10px rgba(0,0,0,.22);
--shadow-lift:0 18px 44px rgba(0,0,0,.55);
--pri-high:#f0896b; --pri-med:#dfa94e; --pri-low:#82bd93;
--dot:rgba(255,244,220,.05);
}
*{box-sizing:border-box}
html,body{overflow-x:hidden}
body{
margin:0; min-height:100vh; color:var(--ink);
background-color:var(--bg);
background-image:radial-gradient(var(--dot) 1px, transparent 1.5px);
background-size:24px 24px;
font:15px/1.45 "Avenir Next","Segoe UI","Helvetica Neue",Helvetica,sans-serif;
-webkit-tap-highlight-color:transparent;
transition:background-color .3s,color .3s;
}
body.dragging-any{user-select:none;-webkit-user-select:none;cursor:grabbing}
::selection{background:color-mix(in srgb,var(--accent) 25%,transparent)}
:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
.wrap{max-width:1160px;margin:0 auto;padding:30px 26px 44px}
/* ---------- header ---------- */
header{display:flex;align-items:flex-end;justify-content:space-between;gap:18px;flex-wrap:wrap}
.eyebrow{margin:0 0 6px;font-size:11px;font-weight:700;letter-spacing:.14em;text-transform:uppercase;color:var(--accent)}
h1{margin:0;font-family:Georgia,'Times New Roman',serif;font-weight:400;font-size:32px;letter-spacing:-.01em}
h1 em{font-style:italic;color:var(--accent)}
.sub{margin:7px 0 2px;font-size:13px;color:var(--muted);font-variant-numeric:tabular-nums}
.controls{display:flex;gap:10px;align-items:center;flex-wrap:wrap}
.search{position:relative;display:flex;align-items:center}
.search svg{position:absolute;left:11px;width:15px;height:15px;color:var(--faint);pointer-events:none}
.search input{width:240px;padding:9px 12px 9px 34px;border:1px solid var(--border);border-radius:10px;background:var(--panel);color:var(--ink);font:inherit;font-size:14px;outline:none;transition:border-color .2s,box-shadow .2s,background .3s}
.search input:focus{border-color:var(--accent);box-shadow:0 0 0 3px color-mix(in srgb,var(--accent) 15%,transparent)}
.search input::placeholder{color:var(--faint)}
.btn{display:inline-flex;align-items:center;gap:7px;font:inherit;font-size:14px;font-weight:600;padding:9px 15px;border-radius:10px;border:1px solid transparent;cursor:pointer;transition:transform .12s,box-shadow .2s,background .2s,border-color .2s,filter .2s}
.btn:active{transform:translateY(1px) scale(.98)}
.btn.primary{background:var(--accent);color:var(--accent-ink);box-shadow:var(--shadow)}
.btn.primary:hover{filter:brightness(1.07)}
.btn.ghost{background:var(--panel);border-color:var(--border);color:var(--ink)}
.btn.ghost:hover{border-color:var(--border-strong)}
.btn.icon{width:38px;height:38px;padding:0;justify-content:center}
.btn svg{width:16px;height:16px;flex:none}
.icon-moon{display:block}.icon-sun{display:none}
html[data-theme="dark"] .icon-moon{display:none}
html[data-theme="dark"] .icon-sun{display:block}
/* ---------- board ---------- */
.board{display:grid;grid-template-columns:repeat(3,1fr);gap:20px;margin-top:28px;align-items:start}
.column{background:var(--panel);border:1px solid var(--border);border-radius:14px;padding:14px;transition:border-color .2s,background .3s,box-shadow .2s}
.column.over{border-color:color-mix(in srgb,var(--accent) 55%,var(--border));background:color-mix(in srgb,var(--accent) 4%,var(--panel));box-shadow:0 0 0 3px color-mix(in srgb,var(--accent) 14%,transparent)}
.col-head{display:flex;align-items:center;gap:8px;margin-bottom:12px;padding:0 2px}
.col-head h2{margin:0;font-size:12px;font-weight:700;letter-spacing:.09em;text-transform:uppercase;color:var(--muted)}
.col-dot{width:8px;height:8px;border-radius:50%;flex:none}
.column[data-col="todo"] .col-dot{background:var(--faint)}
.column[data-col="doing"] .col-dot{background:var(--pri-med)}
.column[data-col="done"] .col-dot{background:var(--pri-low)}
.count{margin-left:auto;font-size:12px;color:var(--muted);background:color-mix(in srgb,var(--muted) 12%,transparent);padding:1px 8px;border-radius:99px;font-variant-numeric:tabular-nums}
.cards{display:flex;flex-direction:column;gap:10px;min-height:70px}
.cards:has(.drop-slot) .empty{display:none}
.empty{border:1.5px dashed var(--border-strong);border-radius:10px;padding:20px 12px;text-align:center;color:var(--faint);font-size:13px}
.card{position:relative;background:var(--card);border:1px solid var(--border);border-radius:10px;padding:12px 14px;box-shadow:var(--shadow);cursor:grab;overflow:hidden;transition:transform .15s ease,box-shadow .2s ease,border-color .2s ease,background .3s,height .22s ease,padding .22s ease,opacity .22s ease}
.card:not(.dragging):hover{transform:translateY(-1px);border-color:var(--border-strong)}
.card.enter{animation:pop .28s cubic-bezier(.2,.9,.3,1)}
.card.removing{height:0!important;padding-top:0!important;padding-bottom:0!important;margin:0;opacity:0;transform:translateX(16px) scale(.97);border-width:0}
.card.dragging{position:fixed;z-index:90;pointer-events:none;box-shadow:var(--shadow-lift);cursor:grabbing;transition:none!important;opacity:.97}
.card-title{margin:0 26px 9px 0;font-family:Georgia,'Times New Roman',serif;font-weight:400;font-size:15px;line-height:1.4;display:flex;align-items:flex-start;gap:6px}
.card-title .tick{display:none;flex:none;margin-top:3px;color:var(--pri-low)}
.card.done .card-title .tick{display:block}
.card.done .card-title span{color:var(--muted)}
.card-del{position:absolute;top:8px;right:8px;width:22px;height:22px;border:none;background:transparent;color:var(--faint);border-radius:6px;cursor:pointer;display:grid;place-items:center;padding:0;opacity:0;transition:opacity .15s,background .15s,color .15s}
.card:hover .card-del,.card-del:focus-visible{opacity:1}
.card-del:hover{background:color-mix(in srgb,var(--pri-high) 13%,transparent);color:var(--pri-high)}
@media (hover:none){.card-del{opacity:.55}}
.card-meta{display:flex;align-items:center;gap:8px}
.meta-right{margin-left:auto;display:flex;align-items:center;gap:7px}
.when{font-size:12px;color:var(--faint)}
.avatar{width:20px;height:20px;border-radius:50%;background:color-mix(in srgb,var(--muted) 15%,transparent);color:var(--muted);font-size:9px;font-weight:700;letter-spacing:.03em;display:grid;place-items:center}
.badge{display:inline-flex;align-items:center;gap:5px;font-size:10.5px;font-weight:700;letter-spacing:.06em;text-transform:uppercase;padding:3px 9px;border-radius:99px}
.badge i{width:6px;height:6px;border-radius:50%;background:currentColor}
.badge.p-high{color:var(--pri-high);background:color-mix(in srgb,var(--pri-high) 13%,transparent)}
.badge.p-medium{color:var(--pri-med);background:color-mix(in srgb,var(--pri-med) 16%,transparent)}
.badge.p-low{color:var(--pri-low);background:color-mix(in srgb,var(--pri-low) 13%,transparent)}
/* drop indicator slot */
.drop-slot{flex:none;border:1.5px dashed color-mix(in srgb,var(--accent) 60%,transparent);background:color-mix(in srgb,var(--accent) 7%,transparent);border-radius:10px;position:relative;animation:pop .15s ease}
.drop-slot::after{content:"Drop here";position:absolute;inset:0;display:grid;place-items:center;font-size:10.5px;font-weight:700;letter-spacing:.1em;text-transform:uppercase;color:var(--accent);opacity:.85}
.col-add{width:100%;margin-top:10px;padding:8px;background:transparent;border:1.5px dashed transparent;border-radius:9px;color:var(--muted);font:inherit;font-size:13px;cursor:pointer;opacity:.75;transition:all .15s;display:flex;align-items:center;justify-content:center;gap:6px}
.col-add svg{width:13px;height:13px}
.col-add:hover{opacity:1;border-color:var(--border-strong);background:color-mix(in srgb,var(--muted) 6%,transparent)}
.hint{margin:20px 4px 0;font-size:12.5px;color:var(--faint)}
mark{background:color-mix(in srgb,var(--accent) 22%,transparent);color:inherit;border-radius:3px;padding:0 1px}
/* ---------- modal ---------- */
.overlay{position:fixed;inset:0;background:rgba(22,14,8,.45);backdrop-filter:blur(3px);display:none;place-items:center;z-index:100;padding:20px}
.overlay.open{display:grid;animation:fade .18s ease}
.modal{width:100%;max-width:420px;background:var(--panel);border:1px solid var(--border);border-radius:16px;padding:22px;box-shadow:var(--shadow-lift);animation:up .24s cubic-bezier(.2,.9,.3,1)}
.modal h2{margin:0 0 16px;font-family:Georgia,'Times New Roman',serif;font-weight:400;font-size:22px}
.field{margin-bottom:14px}
.flabel{display:block;font-size:11.5px;font-weight:700;letter-spacing:.08em;text-transform:uppercase;color:var(--muted);margin-bottom:6px}
.modal input[type=text]{width:100%;padding:10px 12px;border:1px solid var(--border-strong);border-radius:9px;background:var(--card);color:var(--ink);font:inherit;outline:none;transition:border-color .15s,box-shadow .15s}
.modal input[type=text]::placeholder{color:var(--faint)}
.modal input[type=text]:focus{border-color:var(--accent);box-shadow:0 0 0 3px color-mix(in srgb,var(--accent) 15%,transparent)}
.modal.error input[type=text]{border-color:var(--pri-high);animation:shake .3s}
.err{display:none;margin:6px 0 0;font-size:12.5px;color:var(--pri-high)}
.modal.error .err{display:block}
.seg{display:flex;gap:6px;background:color-mix(in srgb,var(--muted) 10%,transparent);padding:4px;border-radius:10px}
.seg button{flex:1;border:none;background:transparent;padding:7px 4px;border-radius:7px;cursor:pointer;color:var(--muted);font:inherit;font-size:13px;display:flex;align-items:center;justify-content:center;gap:6px;transition:background .15s,color .15s}
.seg button:hover{color:var(--ink)}
.seg button.on{background:var(--card);color:var(--ink);font-weight:600;box-shadow:var(--shadow)}
.pd{width:8px;height:8px;border-radius:50%;background:var(--pc);flex:none}
.mrow{display:flex;justify-content:flex-end;gap:10px;margin-top:18px}
@keyframes fade{from{opacity:0}}
@keyframes up{from{opacity:0;transform:translateY(14px) scale(.98)}}
@keyframes pop{from{opacity:0;transform:translateY(8px) scale(.97)}}
@keyframes shake{25%{transform:translateX(-4px)}75%{transform:translateX(4px)}}
@media (max-width:860px){
.board{grid-template-columns:1fr}
.wrap{padding:22px 16px 36px}
h1{font-size:26px}
.search{flex:1;min-width:160px}
.search input{width:100%}
.controls{width:100%}
}
@media (prefers-reduced-motion:reduce){
*{transition-duration:.01ms!important;animation-duration:.01ms!important}
}
</style>
</head>
<body>
<div class="wrap">
<header>
<div class="brand">
<p class="eyebrow">Product team · Q3</p>
<h1>Sprint <em>board</em></h1>
<p class="sub"><span id="openN">0</span> open · <span id="doneN">0</span> done</p>
</div>
<div class="controls">
<label class="search">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"/><path d="M16.5 16.5 21 21"/></svg>
<input id="q" type="text" placeholder="Search cards…" autocomplete="off" aria-label="Search cards">
</label>
<button class="btn primary" id="newBtn" type="button">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"><path d="M12 5v14M5 12h14"/></svg>
New card
</button>
<button class="btn ghost icon" id="themeBtn" type="button" aria-label="Toggle light / dark theme">
<svg class="icon-moon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z"/></svg>
<svg class="icon-sun" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4"/></svg>
</button>
</div>
</header>
<main class="board" id="board">
<section class="column" data-col="todo">
<div class="col-head"><span class="col-dot"></span><h2>To do</h2><span class="count" id="cnt-todo">0</span></div>
<div class="cards"></div>
<button class="col-add" data-col="todo" type="button"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round"><path d="M12 5v14M5 12h14"/></svg>Add card</button>
</section>
<section class="column" data-col="doing">
<div class="col-head"><span class="col-dot"></span><h2>In progress</h2><span class="count" id="cnt-doing">0</span></div>
<div class="cards"></div>
<button class="col-add" data-col="doing" type="button"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round"><path d="M12 5v14M5 12h14"/></svg>Add card</button>
</section>
<section class="column" data-col="done">
<div class="col-head"><span class="col-dot"></span><h2>Done</h2><span class="count" id="cnt-done">0</span></div>
<div class="cards"></div>
<button class="col-add" data-col="done" type="button"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round"><path d="M12 5v14M5 12h14"/></svg>Add card</button>
</section>
</main>
<p class="hint">Drag cards between columns · search filters live · use “Add card” in any column to create work.</p>
</div>
<div class="overlay" id="overlay">
<form class="modal" id="modal" novalidate>
<h2>New card</h2>
<div class="field">
<label class="flabel" for="fTitle">Title</label>
<input id="fTitle" type="text" placeholder="e.g. Draft release notes" maxlength="90" autocomplete="off">
<p class="err">Give the card a title.</p>
</div>
<div class="field">
<span class="flabel">Priority</span>
<div class="seg" id="fPri">
<button type="button" data-v="low"><i class="pd" style="--pc:var(--pri-low)"></i>Low</button>
<button type="button" data-v="medium" class="on"><i class="pd" style="--pc:var(--pri-med)"></i>Medium</button>
<button type="button" data-v="high"><i class="pd" style="--pc:var(--pri-high)"></i>High</button>
</div>
</div>
<div class="field">
<span class="flabel">Column</span>
<div class="seg" id="fCol">
<button type="button" data-v="todo" class="on">To do</button>
<button type="button" data-v="doing">In progress</button>
<button type="button" data-v="done">Done</button>
</div>
</div>
<div class="mrow">
<button type="button" class="btn ghost" id="fCancel">Cancel</button>
<button type="submit" class="btn primary">Add card</button>
</div>
</form>
</div>
<script>
(function(){
'use strict';
var COLUMNS = ['todo','doing','done'];
var PEOPLE = ['MK','AS','JR','LT','PW','DB'];
var uid = 7;
var cards = [
{id:'c1', title:'Audit checkout funnel analytics', pri:'high', col:'todo', who:'MK', when:'2d'},
{id:'c2', title:'Draft Q3 content calendar', pri:'low', col:'todo', who:'LT', when:'5d'},
{id:'c3', title:'Redesign pricing page hero', pri:'high', col:'doing', who:'AS', when:'1d'},
{id:'c4', title:'Migrate marketing site to new CMS',pri:'medium',col:'doing', who:'PW', when:'3d'},
{id:'c5', title:'Ship dark mode toggle', pri:'medium', col:'done', who:'JR', when:'1w'},
{id:'c6', title:'Fix flaky auth tests', pri:'medium', col:'done', who:'MK', when:'4d'}
];
var query = '';
var board = document.getElementById('board');
var colEls = Array.prototype.slice.call(board.querySelectorAll('.column'));
var qInput = document.getElementById('q');
var overlay = document.getElementById('overlay');
var modal = document.getElementById('modal');
var fTitle = document.getElementById('fTitle');
var fPri = document.getElementById('fPri');
var fCol = document.getElementById('fCol');
var X_SVG = '<svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"><path d="M6 6l12 12M18 6L6 18"/></svg>';
var TICK_SVG = '<svg class="tick" viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round"><path d="M4 12.5l5 5L20 7"/></svg>';
function esc(s){
return String(s).replace(/[&<>"']/g, function(m){
return {'&':'&','<':'<','>':'>','"':'"',"'":'''}[m];
});
}
function match(c){
return !query || c.title.toLowerCase().indexOf(query.toLowerCase()) !== -1;
}
function hi(t){
if(!query) return esc(t);
var i = t.toLowerCase().indexOf(query.toLowerCase());
if(i < 0) return esc(t);
return esc(t.slice(0,i)) + '<mark>' + esc(t.slice(i, i+query.length)) + '</mark>' + esc(t.slice(i+query.length));
}
function cardHTML(c){
return '<article class="card' + (c.col==='done' ? ' done' : '') + '" data-id="' + c.id + '">' +
'<h3 class="card-title">' + TICK_SVG + '<span>' + hi(c.title) + '</span></h3>' +
'<button class="card-del" type="button" aria-label="Delete card">' + X_SVG + '</button>' +
'<div class="card-meta">' +
'<span class="badge p-' + c.pri + '"><i></i>' + esc(c.pri) + '</span>' +
'<span class="meta-right"><span class="avatar">' + esc(c.who) + '</span><span class="when">' + esc(c.when) + '</span></span>' +
'</div>' +
'</article>';
}
function render(){
COLUMNS.forEach(function(col){
var list = board.querySelector('.column[data-col="' + col + '"] .cards');
var vis = cards.filter(function(c){ return c.col === col && match(c); });
var html = vis.map(cardHTML).join('');
if(!vis.length){
html += '<div class="empty">' + (query ? 'No matches for \u201C' + esc(query) + '\u201D' : 'Nothing here yet') + '</div>';
}
list.innerHTML = html;
});
updateCounts();
}
function updateCounts(){
COLUMNS.forEach(function(col){
var tot = cards.filter(function(c){ return c.col === col; }).length;
var vis = cards.filter(function(c){ return c.col === col && match(c); }).length;
document.getElementById('cnt-' + col).textContent = query ? (vis + '/' + tot) : String(tot);
});
document.getElementById('openN').textContent = cards.filter(function(c){ return c.col !== 'done'; }).length;
document.getElementById('doneN').textContent = cards.filter(function(c){ return c.col === 'done'; }).length;
}
/* ---------- search ---------- */
qInput.addEventListener('input', function(){
query = qInput.value.trim();
render();
});
/* ---------- delete ---------- */
function deleteCard(card){
var id = card.dataset.id;
if(card.classList.contains('dragging')) return;
card.style.height = card.getBoundingClientRect().height + 'px';
void card.offsetHeight;
card.classList.add('removing');
setTimeout(function(){
cards = cards.filter(function(c){ return c.id !== id; });
render();
}, 230);
}
/* ---------- FLIP helpers ---------- */
function flipMove(mutate){
var els = [];
colEls.forEach(function(c){
c.querySelectorAll('.card').forEach(function(el){
if(!el.classList.contains('hidden') && !el.classList.contains('dragging')) els.push(el);
});
});
var before = els.map(function(el){ return el.getBoundingClientRect(); });
mutate();
els.forEach(function(el, i){
var b = before[i], a = el.getBoundingClientRect();
var dx = b.left - a.left, dy = b.top - a.top;
if(!dx && !dy) return;
el.style.transition = 'none';
el.style.transform = 'translate(' + dx + 'px,' + dy + 'px)';
requestAnimationFrame(function(){
el.style.transition = 'transform .2s cubic-bezier(.2,.8,.3,1)';
el.style.transform = '';
el.addEventListener('transitionend', function(){ el.style.transition = ''; }, {once:true});
});
});
}
/* ---------- drag & drop (pointer based) ---------- */
var drag = null;
board.addEventListener('pointerdown', function(e){
var card = e.target.closest('.card');
if(!card || e.target.closest('.card-del')) return;
if(e.pointerType === 'mouse' && e.button !== 0) return;
e.preventDefault();
var rect = card.getBoundingClientRect();
drag = {
card: card,
startX: e.clientX, startY: e.clientY,
offX: e.clientX - rect.left, offY: e.clientY - rect.top,
lastX: e.clientX,
tilt: 0,
active: false,
ph: null
};
try { card.setPointerCapture(e.pointerId); } catch(err){}
});
window.addEventListener('pointermove', function(e){
if(!drag) return;
if(!drag.active){
if(Math.hypot(e.clientX - drag.startX, e.clientY - drag.startY) < 5) return;
startDrag(e);
}
moveDrag(e);
});
window.addEventListener('pointerup', endDrag);
window.addEventListener('pointercancel', endDrag);
function startDrag(e){
var d = drag;
d.active = true;
document.body.classList.add('dragging-any');
var card = d.card;
var r = card.getBoundingClientRect();
var ph = document.createElement('div');
ph.className = 'drop-slot';
ph.style.height = r.height + 'px';
card.before(ph);
d.ph = ph;
card.classList.add('dragging');
card.style.width = r.width + 'px';
card.style.left = (d.startX - d.offX) + 'px';
card.style.top = (d.startY - d.offY) + 'px';
updateDropTarget(e);
}
function moveDrag(e){
var d = drag;
var dx = e.clientX - d.startX, dy = e.clientY - d.startY;
var vx = e.clientX - d.lastX;
d.lastX = e.clientX;
d.tilt += (Math.max(-7, Math.min(7, vx * 0.6)) - d.tilt) * 0.22;
d.card.style.transform = 'translate(' + dx + 'px,' + dy + 'px) rotate(' + d.tilt.toFixed(2) + 'deg) scale(1.03)';
if(e.clientY < 70) window.scrollBy(0, -14);
else if(e.clientY > window.innerHeight - 70) window.scrollBy(0, 14);
updateDropTarget(e);
}
function pickColumn(x){
var best = null, bd = Infinity;
colEls.forEach(function(col){
var r = col.getBoundingClientRect();
var dist = x < r.left ? r.left - x : (x > r.right ? x - r.right : 0);
if(dist < bd){ bd = dist; best = col; }
});
return best;
}
function updateDropTarget(e){
var d = drag;
var colEl = pickColumn(e.clientX);
if(!colEl) return;
colEls.forEach(function(c){ c.classList.toggle('over', c === colEl); });
var list = colEl.querySelector('.cards');
var kids = Array.prototype.filter.call(list.children, function(el){
return el.classList.contains('card') && !el.classList.contains('hidden') && !el.classList.contains('dragging');
});
var ref = null;
for(var i = 0; i < kids.length; i++){
var r = kids[i].getBoundingClientRect();
if(e.clientY < r.top + r.height / 2){ ref = kids[i]; break; }
}
if(d.ph.parentNode === list && d.ph.nextSibling === ref) return;
flipMove(function(){ list.insertBefore(d.ph, ref); });
}
function endDrag(){
if(!drag) return;
var d = drag;
drag = null;
document.body.classList.remove('dragging-any');
colEls.forEach(function(c){ c.classList.remove('over'); });
if(!d.active) return;
var card = d.card, ph = d.ph;
var before = card.getBoundingClientRect();
var tilt = d.tilt;
card.classList.remove('dragging');
card.style.cssText = '';
ph.replaceWith(card);
var col = card.closest('.column').dataset.col;
card.classList.toggle('done', col === 'done');
var after = card.getBoundingClientRect();
var dx = before.left - after.left, dy = before.top - after.top;
if(dx || dy || Math.abs(tilt) > 0.2){
card.style.transition = 'none';
card.style.transform = 'translate(' + dx + 'px,' + dy + 'px) rotate(' + tilt.toFixed(2) + 'deg) scale(1.03)';
card.style.zIndex = '90';
requestAnimationFrame(function(){
card.style.transition = 'transform .24s cubic-bezier(.2,.8,.3,1), box-shadow .2s ease';
card.style.transform = '';
setTimeout(function(){ card.style.cssText = ''; }, 260);
});
}
syncFromDOM();
}
function syncFromDOM(){
var byId = {};
cards.forEach(function(c){ byId[c.id] = c; });
var next = [];
colEls.forEach(function(colEl, ci){
colEl.querySelectorAll('.card').forEach(function(el){
var c = byId[el.dataset.id];
if(c){ c.col = COLUMNS[ci]; next.push(c); }
});
});
cards = next;
updateCounts();
}
document.addEventListener('dragstart', function(e){ e.preventDefault(); });
/* ---------- board clicks (delete / per-column add) ---------- */
board.addEventListener('click', function(e){
var del = e.target.closest('.card-del');
if(del){ deleteCard(del.closest('.card')); return; }
var add = e.target.closest('.col-add');
if(add){ openModal(add.dataset.col); }
});
/* ---------- modal ---------- */
function segInit(el){
el.addEventListener('click', function(e){
var b = e.target.closest('button');
if(!b) return;
el.querySelectorAll('button').forEach(function(x){ x.classList.toggle('on', x === b); });
});
}
segInit(fPri);
segInit(fCol);
function segSet(el, v){
el.querySelectorAll('button').forEach(function(x){ x.classList.toggle('on', x.dataset.v === v); });
}
function openModal(col){
modal.classList.remove('error');
fTitle.value = '';
segSet(fCol, col);
segSet(fPri, 'medium');
overlay.classList.add('open');
setTimeout(function(){ fTitle.focus(); }, 40);
}
function closeModal(){
overlay.classList.remove('open');
}
document.getElementById('newBtn').addEventListener('click', function(){ openModal('todo'); });
document.getElementById('fCancel').addEventListener('click', closeModal);
overlay.addEventListener('click', function(e){ if(e.target === overlay) closeModal(); });
window.addEventListener('keydown', function(e){
if(e.key === 'Escape' && overlay.classList.contains('open')) closeModal();
});
modal.addEventListener('submit', function(e){
e.preventDefault();
var title = fTitle.value.trim();
if(!title){
modal.classList.remove('error');
void fTitle.offsetWidth;
modal.classList.add('error');
fTitle.focus();
return;
}
var pri = fPri.querySelector('.on').dataset.v;
var col = fCol.querySelector('.on').dataset.v;
var id = 'c' + (uid++);
var idx = -1;
for(var i = 0; i < cards.length; i++){ if(cards[i].col === col){ idx = i; break; } }
cards.splice(idx < 0 ? cards.length : idx, 0, {
id: id, title: title, pri: pri, col: col,
who: PEOPLE[Math.floor(Math.random() * PEOPLE.length)],
when: 'just now'
});
closeModal();
if(query){ query = ''; qInput.value = ''; }
render();
var el = board.querySelector('[data-id="' + id + '"]');
if(el) el.classList.add('enter');
});
/* ---------- theme ---------- */
document.getElementById('themeBtn').addEventListener('click', function(){
var root = document.documentElement;
root.dataset.theme = root.dataset.theme === 'dark' ? '' : 'dark';
});
render();
})();
</script>
</body>
</html>Reasoning trace (23,677 tokens)
Let me build a Kanban board that feels genuinely crafted, not like a generic AI demo. Let me think about the design direction first.
**Design direction:**
I want this to feel like a real product someone designed with care. I'll avoid the generic dashboard look — no three identical columns with default gray headers. Let me think about what makes a Kanban board feel "authored":
- A distinctive typographic identity. I can't load Google Fonts... wait, actually the rules say "No external files, images, fonts or network requests, except the CDN URLs explicitly allowed by the task." The task doesn't explicitly allow any CDN, so I should stick with system font stacks — but I can pick a characterful stack like Georgia/serif for headings mixed with a clean sans for body, or use a monospace accent for counts/meta. A serif display (Georgia, 'Iowan Old Style') for the board title gives it an editorial, human feel rather than the usual Inter-gray-slab.
- Color: I'll avoid default blue/purple. Let me go with a warm paper-and-ink palette: light theme = warm off-white paper (#f6f3ee-ish), ink text (#1c1a17), and a single strong accent — maybe a deep persimmon/terracotta (#c2502a-ish) or a deep green. Dark theme = warm charcoal (#191714), not gray-blue. Priority badges: low = sage/olive, medium = amber, high = terracotta/red. These can share a warm family so the whole thing feels cohesive.
- Layout: instead of three identical cards-in-a-row columns, I can differentiate the columns subtly — each column header gets a small colored tick/dot and the "Done" column can render cards with a subtle completed treatment (title struck through or dimmed? No — realistic cards in Done should still look like cards, maybe a small check icon and slightly reduced emphasis). Column headers with counts in a small mono tabular figure.
Actually, the columns will necessarily be three similar containers — that's the nature of Kanban. What I can do is make the craft live in: the header composition (title left, search + add button + theme toggle right), the card typography, the drag interactions, and the drop indicator.
**Signature moment:** The drag-and-drop should feel excellent:
- While dragging, the original card becomes a ghost (dashed outline placeholder showing where it will land).
- The dragged card follows the cursor with a slight rotation (like picking up a physical card — tilt 3–4°, scale up slightly, soft shadow).
- A drop indicator line (accent colored, with animated ends) appears between cards where the card would insert.
- Columns highlight when hovered as valid drop targets.
- Smooth FLIP-like animation when cards reorder? That's complex with plain DnD. I'll implement drag with pointer events (not HTML5 DnD) for full control — pointerdown on card, track pointermove, position a floating clone, compute insertion index by comparing pointer Y against card midpoints, show indicator line, on pointerup commit the move. This gives buttery control and works with mouse. HTML5 dragstart/dragover is janky and has ugly ghost images. Pointer events are the way.
Let me plan the pointer-based drag:
1. `pointerdown` on a card (but not on the delete button). Record start position, card element, don't start drag immediately — wait for movement > 4px to distinguish click from drag. Actually cards aren't clickable otherwise, so I can start drag on pointerdown + small movement threshold to allow text selection? There's nothing to select really; I'll use a 5px move threshold.
2. On drag start:
- Create a floating clone (fixed position, width = card width, pointer-events: none) that follows cursor with slight rotation and scale.
- Replace original card in DOM with a placeholder element (same size, dashed border, subtle bg) — this serves as the "ghost". Actually a cleaner approach: keep the original card but make it ghost-styled (opacity 0.3, dashed outline), and insert a drop-indicator element that moves between positions. Hmm, two approaches:
a) Placeholder = dashed ghost of the card that moves as insertion point moves. This doubles as the drop indicator. Very tactile — like the card itself becomes a slot.
b) Separate thin indicator line.
I like (a): the dragged card's original spot becomes a dashed slot; as you hover other positions, the slot animates (with a smooth transform? reordering DOM with transitions requires FLIP). Simpler: use a dedicated placeholder element that I reposition in the DOM (remove/insert). Without FLIP, other cards jump instantly — acceptable but not great. Let me add a light FLIP: before moving the placeholder, record bounding rects of all cards in all columns; after moving, record new rects; apply inverted transform and transition to none. That's ~20 lines and makes reordering feel liquid. Worth it — that's the signature feel.
3. Drop target computation: for each column, get its card list; find insertion index by iterating card rects (excluding the placeholder) — if pointer Y < card midpoint, insert before. If pointer is within column's horizontal bounds (or nearest column), target that column. Determine nearest column by horizontal distance to column bounds.
4. On pointerup: move the actual card element into placeholder position, remove placeholder, update state (data attributes / state array). Update counts. If dropped outside any column, cancel (return to origin) — or snap back animation. I'll just return it to the placeholder (which is in the last valid target; if pointer never entered a column, placeholder stays at origin so it just settles back).
Actually simpler and robust: the placeholder is always somewhere in the DOM. On pointerup, insert the real card where the placeholder is, remove placeholder, update state from DOM (each card has data-id; each column data-column). State sync: I'll keep a `state` array of cards with `column` and rebuild counts from state; on drop, I read the DOM to set card.column. Or I can maintain order purely in DOM and treat DOM as source of truth for rendering, with state array for search/filter re-renders. Since search re-renders the board from state, I need state to reflect DOM order. On drop: for each column, query `[data-id]` order and rebuild state arrays. That's clean.
But wait — search filtering: when filtered, some cards are hidden. Dragging while filtered: the dragged card should be visible (it matches search since it's shown). Dropping it somewhere — state update via DOM reading works even with hidden cards, since hidden cards remain in DOM? If I re-render on search, hidden cards are either removed from DOM or display:none. If I keep them in DOM with `display:none`, DOM-order-based state rebuild still works. I'll hide via a class (display:none), keep them in DOM. But then the insertion index calc must skip hidden cards. Fine — check offsetParent or classList.
Edge: dragging while search active — allowed, works. Good.
**Modal for adding cards:** Small centered modal with backdrop blur, title input (required), optional priority selector (segmented control: Low/Medium/High), and target column selector? The task says add a card — I'll add a column selector too (or default "To do"). A small column segmented control would be nice. Plus a note field? Keep it minimal: title (required), priority (segmented, default medium), column (segmented). Add button + cancel. Escape closes, backdrop click closes, Enter submits. Focus the title input on open. Inline error if title empty ("Give the card a title" shake). No alert().
The modal should feel crafted: slides up + fades, segmented controls with sliding thumb? A simple segmented control with selected state is fine; maybe animate the selected pill with a background transition.
**Delete:** small × button on card hover (always visible on touch? I'll show it at low opacity, full on hover). Confirm? No — task says delete with a button; instant delete with a subtle animation (card collapses: height→0, fade). I'll animate removal.
**Search:** input in header, live filter with `input` event. Show "n matches" or empty-state message per column when no matches ("Nothing matches 'xyz'"). Nice touch: highlight matched substring in titles with `<mark>`. That's a lovely detail. I'll escape HTML then wrap matches.
**Theme toggle:** sun/moon icon button (inline SVG icons — I won't use emoji). Toggle `data-theme` on html. Smooth color transitions. Persist? No storage APIs allowed — state in memory only, so theme resets on reload, fine.
**Icons:** inline SVGs (no icon library CDN allowed since task doesn't permit network requests beyond allowed CDNs — none allowed). I'll hand-write small SVGs: plus, search, sun, moon, x, grip dots, check for Done cards. A six-dot grip pattern on cards signals draggability — nice affordance.
**Pre-populated cards (6, realistic, spread):**
- To do (2): "Audit checkout funnel analytics" (high), "Draft Q3 content calendar" (low)
- In progress (3): "Redesign pricing page hero" (high), "Migrate images to CDN" (medium), "Fix flaky auth tests" (medium)
- Done (1): "Ship dark mode toggle" (medium)
Hmm, 6 total: 2/3/1. Maybe 2/2/2 feels more balanced:
- To do: "Audit checkout funnel analytics" (high), "Draft Q3 content calendar" (low)
- In progress: "Redesign pricing page hero" (high), "Migrate marketing site to new CMS" (medium)
- Done: "Ship dark mode toggle" (medium), "Fix flaky auth tests" (medium)
Also add meta on cards? Maybe a small timestamp or assignee initials avatar? Assignee initials in a tiny circle adds realism. Task only requires title + priority badge. I could add a subtle footer with initials + created date — makes it look alive and realistic. I'll add assignee initials chips (deterministic per card). Keep it light: small circle with initials + a relative date like "2d". That elevates realism without clutter. But requirement says "Each card has a title and a priority" — extra meta is fine.
Also each column gets a colored top accent? I'd rather keep columns distinguished by a small dot + count next to header, and the Done column gets a check treatment on cards (dimmed, check icon). Column headers: name in small caps / letter-spaced label, count in a small pill or plain mono number.
**Header composition:**
- Left: a small logo mark (maybe a simple two-column glyph) + "Fieldnotes" or some product-y name? The task says build a Kanban board — I'll title it something like "Sprint Board" with a serif display. Maybe "Board — Sprint 24" with subtitle "Product · Q3". Let me do: eyebrow "PRODUCT TEAM" small caps, title "Sprint 24 Board" in serif, or simpler: title "Kanban" is boring. "The Board" ... I'll go with a product name like "Ledger" or "Relay". Let me use "Relay — Sprint 24" serif title, subtitle "12 open tasks · updated just now"? Keep counts dynamic: total open tasks = todo + inprogress counts, computed live. Nice: "4 open · 2 done".
- Right: search input (with icon), "New card" button (accent, solid), theme toggle (icon button).
On mobile (375px): header wraps; columns stack vertically full width. Columns: desktop = 3-col grid with gap; below 900px maybe 2-col? Simpler: below 800px, single column stack. Board area scrolls vertically. Fine.
**Empty column state:** dashed slot saying "Drop cards here" or "No cards" — helps drop targeting when empty.
**Drop indicator details:** the placeholder (ghost slot) has dashed border in accent color and slightly pulsing? And also a thin insertion line? I decided placeholder = ghost slot. But the requirement says "clear visual drop indicator" — ghost slot is clear. I'll make the ghost slot dashed accent border with faint accent tint background and an animated dash? CSS can't easily animate dashed border; I could use a background of repeating-linear-gradient for dashes... Simpler: dashed border + subtle background tint + the ghost keeps approximate height of dragged card. Also the target column header could highlight. Also dragged card in the floating clone: rotate(2deg) scale(1.03), shadow, and cursor grabbing.
Additionally, mark original cards list `cards` container with a highlight when dragging over (background tint). Yes: column gets `.drag-over` class → its card list shows a very subtle tinted background and the empty-slot text appears.
**FLIP animation implementation:**
```js
function withFlip(mutate) {
const els = [...board.querySelectorAll('.card')];
const before = new Map(els.map(el => [el, el.getBoundingClientRect()]));
mutate();
els.forEach(el => {
const b = before.get(el), a = el.getBoundingClientRect();
const dx = b.left - a.left, dy = b.top - a.top;
if (dx || dy) {
el.style.transition = 'none';
el.style.transform = `translate(${dx}px, ${dy}px)`;
el.getBoundingClientRect(); // force
el.style.transition = 'transform .18s ease';
el.style.transform = '';
el.addEventListener('transitionend', () => { el.style.transition=''; }, {once:true});
}
});
}
```
Careful: the dragged card itself is hidden (original becomes the placeholder...). Wait — plan: on drag start, I remove the original card from flow and put placeholder in its spot? Or keep original in place ghosted, and placeholder moves separately? If placeholder and original both exist, when placeholder is at origin they'd stack? No: on drag start, I replace the original with the placeholder (original element becomes the floating clone — actually I can reuse the original element as the floater: position fixed at card rect, then it follows cursor). So:
- On drag start: original card element → add class `.dragging`, set position fixed with its rect (width, left, top), append to body, pointer-events none. Create placeholder div with height = card height, insert at original position. Placeholder acts as insertion marker that moves.
- On pointermove: move floater (transform translate for perf: set left/top once, use transform for delta), compute target column + index, if different from placeholder's current position, FLIP-animate and move placeholder.
- On pointerup: FLIP-animate inserting real card at placeholder position (the card is the floater — I need to transition it from cursor position to placeholder rect: compute placeholder rect, set transition on left/top/transform... The floater has rotation/scale; I'll transition transform to identity and top/left to placeholder position over ~150ms, then clear fixed styles and actually insert into DOM at placeholder spot, remove placeholder). Simpler: insert the card element into placeholder's position immediately but with a FLIP from its floating position. The fixed floater vs in-flow rect: FLIP handles it if I measure floater rect before insertion (as "before") and after insertion (as "after"). The transform includes rotation — I'll clear rotation as part of it. Let me do: before = floater.getBoundingClientRect(); withFlipInsert: insert card at placeholder pos, remove placeholder, remove .dragging class; after = card rect; dx,dy from before→after; apply inverted transform (including the accumulated rotation removal — rotation is part of floater's inline transform; after clearing inline transform the FLIP transform is just translate; there may be a slight visual jump from rotated→unrotated but FLIP translate smooths position; rotation snaps. To smooth rotation too, include rotate in the start transform: set transform = `translate(dx,dy) rotate(prevRot) scale(prevScale)` then transition to ''. prevRot=3deg, scale 1.03. Good.)
Scroll: page might scroll during drag near edges — auto-scroll would be nice but adds complexity; the board at 800px height likely fits; columns stack on mobile could be long. I'll add basic edge auto-scroll on window (if pointer near top/bottom edge, scroll by a few px per frame). It's ~10 lines within the pointermove using requestAnimationFrame... Actually I can do simple: in pointermove, if clientY < 80, window.scrollBy(0,-12); if > innerHeight-80, scrollBy(0,12). Called every move — good enough.
**Prevent text selection during drag:** user-select none on body while dragging, and setPointerCapture on the card? setPointerCapture keeps events flowing to the element — good. I'll capture on the card element.
Touch: pointer events handle touch; add `touch-action: none` on cards? That kills scrolling on cards on touch... For a demo judged by mouse, I'll set `touch-action: none` on the grip handle only? Simpler: leave default; mouse is primary. I'll set touch-action: none on .card to make dragging work on touch at the cost of scroll-from-card — acceptable for demo. Hmm, on mobile the whole column is cards; that would block scrolling. Compromise: keep touch-action default (pan allowed); dragging via touch may scroll — fine, demo is mouse-focused.
**Counts:** each column header shows count in a small rounded badge (mono numerals). Update on add/delete/move/search. Also maybe show count as "2 / 3" no — just number.
**Priority badge:** small pill, 10px uppercase letter-spaced label with colored dot? Or colored left border on card + pill? I'll do: pill with tinted background + colored text + tiny dot. Colors:
- high: #b3402e (light) / #ff8a70 (dark) — terracotta
- medium: #a8752a amber / #e0a54a
- low: #4a7a5c green / #7fbf98
Tinted backgrounds via color-mix or manual rgba. I'll define CSS vars per priority per theme. Use `--pri-high` etc. and background via `color-mix(in srgb, var(--pri-high) 12%, transparent)`. color-mix is supported in Chrome latest — fine.
**Done card treatment:** slight opacity on title? Add a check icon before title and title with a subtle secondary color (not strikethrough — realistic done cards often dimmed). I'll add small check circle icon and slightly muted styling.
**Card structure:**
```html
<article class="card" data-id="..." draggable="false">
<div class="card-top"><h3 class="card-title">...</h3><button class="del" aria-label="Delete">×svg</button></div>
<div class="card-meta">
<span class="badge badge-high"><i></i>High</span>
<span class="assignee">MK</span> <span class="dot">·</span> <span class="when">2d</span>
</div>
</article>
```
Grip dots: maybe as background on left edge? I'll put a 6-dot grip svg top-left? Might clutter. Alternative: cursor: grab on card + a grip that appears on hover. I'll add subtle grip dots column on the left of the title via ::before with radial-gradient dots? Let me do a small grip icon that shows on hover next to delete. Actually simplest tasteful: cursor grab + the delete × on top-right; on hover the card lifts slightly (translateY(-1px), shadow). Skip the grip icon; keep cards clean. Hmm, affordance for dragging matters for the demo. I'll add a tiny "grip" of 3 dots horizontal at top? Let me add dots via CSS on card::before positioned top-left, visible on hover. Fine — small detail, I'll do three vertical dots? Let me just do: `.card::before` a 10px×2px "handle bar"? I'll go with the two-line grip (18px wide, 2 lines of 3 dots) top-left, color faint, shown at 0.35 opacity always (subtle texture). Keep it minimal.
**Search highlighting:** render title as HTML with `<mark>` around match. Escape title text first. mark styled with accent underline / transparent bg accent text.
**Add modal details:**
- Overlay: fixed, backdrop rgba ink 0.4 + backdrop-filter blur(4px).
- Dialog: paper bg, radius 14px, shadow, max-width 420px, padding 22px.
- Fields: Label "Title" + text input; "Priority" segmented (3 options with colored dots); "Column" segmented (3 options). Buttons row: Cancel (ghost) + "Add card" (accent solid).
- Error: if empty title, input gets error ring + shake + small message.
- Priority segmented options show dot color + name.
Open via "New card" button and also a "+ Add card" ghost button at bottom of each column? That's a nice touch — each column footer has an "Add" ghost button that opens modal preselected to that column. Yes, adds usability and fills column bottoms nicely. Do it.
**Theme toggle:** icon button with sun/moon SVGs cross-fading. `<html data-theme="dark">` default? Let me default light (warm paper) since it's more distinctive vs typical dark AI demos; toggle to dark. Both must look good. Warm paper light theme:
- bg: #f4f0e8 (warm paper)
- column bg: transparent-ish? Columns as slightly darker paper #ece7dc with border #ddd5c4? Or columns on cards with border and no fill. I'll do: page bg #f2eee5, columns bg rgba white-ish #faf8f3 with 1px border #e2dccd, cards white #fffef9 with border #e4ded0 and soft shadow.
- ink: #211d16, secondary #6e675a.
- accent: #c14a2e? Let me pick a slightly deeper "persimmon" #bc4b2c. Buttons solid accent with white text.
Dark theme:
- bg: #17151200 → #171512, columns #201d18, cards #26231c, borders #3a352b, ink #ece5d8, secondary #9a917f, accent #e0684a (brighter for dark).
Transitions: `transition: background-color .25s, border-color .25s, color .25s` on relevant elements — I'll put a broad transition on body and major surfaces.
**Typography:**
- Display serif for the board title: `Georgia, 'Times New Roman', serif` with maybe italic accent word. E.g., "Sprint 24" big serif + small caps eyebrow "PRODUCT · KANBAN"? Compose: eyebrow line "PRODUCT TEAM — SPRINT 24" tiny letterspaced; H1 "The board" serif italic? Let me do: h1 "Sprint board" in serif 28px with the word "board" in italic. And a live subtitle: "4 open · 2 shipped" small.
- Body/UI: system sans: `-apple-system, "Segoe UI", Roboto, ...`? I said avoid system-ui as primary identity — the identity font here is the serif for display; UI text in a neutral sans is fine craft-wise. I'll use `"Avenir Next", "Segoe UI", system fallbacks`. Hmm to be safe on the "avoid Inter/Roboto/Arial/system-ui as primary identity" — identity is carried by Georgia serif, so sans for body is okay. Card titles could also be serif? Serif card titles at 14px look odd; keep sans but medium weight, ink color. Actually — card titles in the serif could be charming, like index cards. Let me try: card titles in Georgia 15px normal weight. That gives the whole board a "paper note" feel matching warm palette. Yes, I like it — distinctive.
**Counts in header subtitle:** compute open = todo+inprogress; done count. Update in render.
**Rendering strategy:** I'll render cards from a state array once at load; drag/drop mutates DOM + state sync at drop. Search re-renders (rebuild DOM from state). Theme is CSS. Deletion animates then re-renders (or removes element + state). Add appends via render.
State model:
```js
let cards = [
{id, title, priority, column, who:'MK', when:'2d'},
...
];
```
Render: for each column, filter cards by column + query, sort by array order (stable by index in cards array — moves reorder the array: on drop, rebuild order: for the moved card set column; then reorder cards array to match DOM order per column... easiest: after drop, for each column read DOM ids in order; rebuild cards array = concat of per-column ordered lists, preserving each card object). Works.
Search: `render()` builds each column's list. During search, matches highlighted.
Delete: click del → animate card (max-height 0, opacity 0, translate) then remove from state + DOM, update counts. Compute current height for animation.
**Empty state per column:** when a column has zero visible cards, show a dashed "No cards" / "Nothing here" slot. If search active: "No matches". This slot also serves as drop target area (the list container accepts drops anyway).
**Drop computation details:**
Columns: `.column` elements each with `.cards` list. On pointermove:
- Find column under pointer: iterate columns, compare clientX/Y with rect (with some padding). If none contains point, find nearest by horizontal distance? If pointer leaves board entirely (e.g., over header), keep last target — I'll compute nearest column by distance to its rect (simple: check if within rect expanded; else pick column whose rect center is closest). Keep it simple: choose the column whose horizontal span [left, right] is closest to clientX; vertical doesn't matter (columns full height). That gives forgiving behavior.
- Within column's `.cards`, iterate visible card elements (skip placeholder): insertion index = count of cards whose midpoint Y < pointerY. Then placeholder position = before that card, else append.
- Compare with placeholder's current parent+nextSibling; if changed, move placeholder with FLIP.
Placeholder styling: height should approximate the dragged card height; width 100%. Class `.drop-slot`.
**The floating clone rotation direction** based on drag velocity? Nice micro-detail: tilt = clamp(vx * 0.4, -6, 6). I'll compute smoothed velocity and apply rotate + slight scale. That's the "alive" feel. Let me implement: track last positions, tilt = lerp. Apply transform: `translate(x,y) rotate(tilt)`. Use left/top 0 and transform for movement (GPU). Set width fixed to start width.
Also floater has `.dragging` class: shadow large, cursor grabbing, slight scale 1.04 base.
Grab offset: keep the card anchored where grabbed (offset = pointer - cardRect.topleft) so it doesn't jump.
**Keyboard start message?** This isn't a game; no keyboard needed. The sandbox rule about keyboard applies only if demo uses keyboard — modal inputs need keyboard but that's after user interaction, fine. No start overlay needed. First paint shows populated board. Good.
**Counts + aria:** keep reasonable aria labels.
Let me also add a subtle board background texture? Maybe a faint dotted grid on the page background (radial-gradient dots very low opacity) — gives "workshop" feel without gradients-as-decoration (it's a texture, acceptable? It's a dot grid, tasteful). I'll add a very subtle dot pattern in both themes. Keep opacity ~0.5 in light, 0.06 dark. Hmm, keep it restrained: light theme dots #00000008. Fine.
**Column header:**
```
<div class="col-head">
<span class="col-dot" style="--c:..."></span>
<h2>To do</h2>
<span class="count">2</span>
</div>
```
Column dots: To do = accent? Let me assign: To do — neutral/ink? Distinct hues: To do = slate/olive, In progress = amber, Done = green. These are semantic-ish. Use muted tones matching palette: todo #8a8474 (neutral), progress #c1913f (amber), done #5d8a63 (green). Small 8px dots. Count as plain small mono number in a subtle pill.
Done column: header dot green; cards get check icon + dimmed.
**Header layout:**
```
<header>
<div class="brand">
<div class="mark">svg logo</div>?
<div><p class="eyebrow">PRODUCT TEAM · Q3</p><h1>Sprint <em>board</em></h1><p class="sub" id="sub">…</p></div>
</div>
<div class="controls">
<label class="search"><svg/><input placeholder="Search cards…"></label>
<button class="btn primary" id="addBtn">+ New card</button>
<button class="icon-btn" id="themeBtn">svg</button>
</div>
</header>
```
Logo mark: a small square with two lines representing columns? I'll make a simple 3-bar glyph in accent. Or skip logo — eyebrow + serif title is enough. I'll add a small stamp-like mark: 28px square with serif "K"? Cute: a rotated stamp square with "24"? Skip; keep typographic.
Controls on mobile: wrap below title, full-width search.
**Sub line:** `<span id="openCount">4 open</span> · <span>2 done</span> · updated just now` — update open/done live.
Now, code structure. Let me write it carefully.
CSS variables:
```css
:root{
--bg:#f3efe6; --panel:#faf8f1; --card:#fffdf7;
--border:#e3dcc9; --border-strong:#d4cbb2;
--ink:#26221a; --muted:#79715f; --faint:#a49b86;
--accent:#bd4a2b; --accent-ink:#fff;
--shadow: 0 1px 2px rgba(60,50,30,.08), 0 4px 12px rgba(60,50,30,.06);
--shadow-lift: 0 12px 28px rgba(50,40,20,.22);
--pri-high:#b23c22; --pri-med:#a4761f; --pri-low:#3f7351;
--mark:#f5dd8e;
}
[data-theme="dark"]{
--bg:#161411; --panel:#1e1b16; --card:#26221b;
--border:#373126; --border-strong:#453e2f;
--ink:#ece4d4; --muted:#a19880; --faint:#6f6753;
--accent:#e2694a; --accent-ink:#1c120d;
--shadow: 0 1px 2px rgba(0,0,0,.3), 0 4px 12px rgba(0,0,0,.2);
--shadow-lift: 0 14px 30px rgba(0,0,0,.5);
--pri-high:#f0876b; --pri-med:#dca848; --pri-low:#7cba8e;
--mark:#5e5322;
}
```
Badge background: `color-mix(in srgb, var(--pri-high) 14%, transparent)` — but on dark, tint 18%. Use one value 15%, fine.
Theme transition: add `body{transition:...}` plus on elements. To avoid weird transitions on load, fine.
Let me also handle `prefers-reduced-motion`? Skip for brevity... it's good practice; add media query to shorten transitions. Minor; include tiny one.
JS outline:
```js
const columns = ['todo','doing','done'];
const colNames = {todo:'To do', doing:'In progress', done:'Done'};
let cards = [ ...6 items with who/when... ];
let query = '';
let uid = 7;
const board = ...;
function esc(s){...}
function highlight(title, q){...}
function cardHTML(c){ ... uses template string ... }
function render(){ for each column build list html; attach nothing (use event delegation); update counts; }
```
Event delegation on board:
- click on `.del` → delete flow.
- pointerdown on `.card` → drag logic.
render() rebuilds; after drag we call syncFromDOM() which reorders `cards` to match DOM and sets columns; then updateCounts() only (no full render to avoid losing anything). Counts update via DOM.
Drag implementation:
```js
let drag = null; // {el, floater?...}
board.addEventListener('pointerdown', e=>{
const card = e.target.closest('.card');
if(!card || e.target.closest('.del')) return;
if(e.button!==0 && e.pointerType==='mouse') return;
e.preventDefault();
const rect = card.getBoundingClientRect();
drag = {
card, startX:e.clientX, startY:e.clientY, rect,
offX: e.clientX-rect.left, offY: e.clientY-rect.top,
active:false, tilt:0, lastX:e.clientX, w:rect.width, h:rect.height
};
card.setPointerCapture(e.pointerId);
});
```
pointermove on window (or on card since captured — captured events go to card, so listen on card? listeners with capture on window still receive them since capture redirects to the element but events bubble from it. With setPointerCapture, subsequent pointer events target the card, and they bubble up to window. So window listeners work.)
```js
window.addEventListener('pointermove', e=>{
if(!drag) return;
if(!drag.active){
if(Math.hypot(e.clientX-drag.startX, e.clientY-drag.startY) < 5) return;
startDrag(e);
}
moveDrag(e);
});
window.addEventListener('pointerup', endDrag);
window.addEventListener('pointercancel', endDrag);
```
startDrag(e):
```js
drag.active = true;
document.body.classList.add('dragging-any');
const card = drag.card;
// create placeholder
const ph = document.createElement('div');
ph.className='drop-slot';
ph.style.height = drag.h+'px';
card.before(ph);
// floater = card itself, fixed
drag.floater = card;
card.classList.add('dragging');
card.style.width = drag.w+'px';
card.style.left = (e.clientX-drag.offX)+'px';
card.style.top = (e.clientY-drag.offY)+'px';
card.style.position='fixed';
card.style.margin=0;
drag.ph = ph;
drag.lastTarget = null;
updateDropTarget(e);
```
Note z-index on .dragging.
moveDrag(e):
```js
const dx = e.clientX - drag.startX - ...
```
Floater position: left = e.clientX - offX, top = e.clientY - offY. Use transform for perf: set left/top once at start, then transform translate(deltaX, deltaY). Then tilt from velocity:
```js
const vx = e.clientX - drag.lastX; drag.lastX = e.clientX;
drag.tilt += ((Math.max(-8, Math.min(8, vx*0.55))) - drag.tilt)*0.25;
drag.el.style.transform = `translate(${dx}px,${dy}px) rotate(${tilt}deg)`;
```
Also base scale 1.03 via class. Actually combine in one transform: `translate(...) rotate() scale(1.03)`.
Edge auto-scroll: if e.clientY < 70 scrollBy(0,-14) etc.
updateDropTarget(e): compute column + index; move placeholder if changed, with FLIP.
```js
function updateDropTarget(e){
// nearest column horizontally
let target=null, best=Infinity;
for(const col of colEls){
const r = col.listRect? ...
}
}
```
Simpler: cache column rects each move? getBoundingClientRect per move on 3 columns is fine (layout is stable; floater is fixed so it doesn't affect flow). Placeholder movement changes layout though — but we measure after deciding.
```js
function pickColumn(x){
let best=null, bd=Infinity;
colEls.forEach(col=>{
const r=col.getBoundingClientRect();
const d = x < r.left ? r.left-x : x>r.right ? x-r.right : 0;
if(d<bd){bd=d;best=col;}
});
return best;
}
```
Note the floater is fixed and out of flow, so column rects are stable except placeholder moves — measure columns by the `.column` container (its position stable), then find index within `.cards` children (skipping placeholder & hidden). If insertion index equals placeholder's current index (same parent & adjacent), skip.
Compute index:
```js
const list = col.querySelector('.cards');
const kids = [...list.children].filter(el=>el!==drag.ph && !el.classList.contains('hidden') && el.classList.contains('card'));
let idx = kids.length;
for(let i=0;i<kids.length;i++){
const r=kids[i].getBoundingClientRect();
if(e.clientY < r.top + r.height/2){ idx=i; break; }
}
```
Then target position: if idx < kids.length → before kids[idx], else append to list. Compare current placeholder location: ph.nextSibling vs refNode & ph.parentNode===list. If different, FLIP-move:
```js
function movePlaceholder(list, ref){
flip(()=>{
drag.ph.remove? ... list.insertBefore(drag.ph, ref); // ref may be null → append
});
}
```
Also when column changes, the column itself gets `.over` highlight (remove from others). Add a highlight ring on the target column.
endDrag(e):
```js
if(!drag) return;
if(!drag.active){ drag=null; return; } // simple click; nothing
const card = drag.floater, ph = drag.ph;
// insert card at placeholder
const before = card.getBoundingClientRect(); // floater rect
// clear floater styles progressively:
flipInsert:
const ref = ph.nextSibling;
// measure ph rect
const pr = ph.getBoundingClientRect();
// insert card into ph's spot, remove ph, remove fixed styles
card.classList.remove('dragging');
card.style.cssText=''; // clears fixed/left/top/width/transform/margin
ph.replaceWith(card);
// FLIP from floater rect to new rect
const after = card.getBoundingClientRect();
const dx = before.left-after.left, dy = before.top-after.top;
card.style.transition='none';
card.style.transform=`translate(${dx}px,${dy}px)`;
requestAnimationFrame(()=>{ card.style.transition='transform .22s cubic-bezier(.2,.8,.3,1)'; card.style.transform=''; });
cleanup after transitionend: clear inline styles.
// sync state
syncFromDOM();
// clear highlights
document.body.classList.remove('dragging-any');
colEls.forEach(c=>c.classList.remove('over'));
drag=null;
```
Wait — clearing cssText wipes width etc. good. But the floater had class 'dragging' providing shadow/z-index/transform-origin — removing before FLIP means shadow pops. Acceptable; or keep class until after transition then remove. I'll remove class after the transition ends (in the same transitionend cleanup) but the class also sets position:fixed? I'll style .dragging with only visual props (shadow, z-index via inline? z-index needs positioning—fixed inline already). Let me: inline sets position/left/top/width/margin/z-index/transform; class .dragging sets box-shadow & pointer-events & cursor. On end: keep class, set inline left/top to target? Simplest robust version:
1. Measure floater rect (before).
2. ph.replaceWith(card) — card now in flow; card still has inline fixed styles → position:fixed would break flow insertion. So I must clear position first. Sequence: card.style.position=''; card.style.left=''; card.style.top=''; card.style.width=''; card.style.margin=''; then replaceWith. Then measure after-rect, apply FLIP translate from before→after. Rotation disappears instantly — mitigate by including rotation in start transform: capture current transform tilt: `const startT = \`translate(${dx}px,${dy}px) rotate(${drag.tilt}deg) scale(1.03)\`` then transition to ''. And keep .dragging class until transitionend for the shadow fade (add CSS transition on box-shadow? .dragging has big shadow; removing class at end snaps. Add `transition: box-shadow .2s` on .card so shadow animates when class removed. Card base has small shadow. Good.)
Actually the scale: after clearing, transform '' = scale 1; start transform includes scale(1.03) → smooth.
z-index: keep high during transition: set card.style.zIndex=999 then clear at end.
syncFromDOM:
```js
function syncFromDOM(){
const byId = new Map(cards.map(c=>[c.id,c]));
const next=[];
colEls.forEach((col,ci)=>{
col.querySelectorAll('.card').forEach(el=>{
const c=byId.get(el.dataset.id);
if(c){ c.column=columns[ci]; next.push(c); }
});
});
cards = next;
updateCounts();
}
```
updateCounts: for each column, count cards in state (all, regardless of search? counts should reflect actual cards; but during search maybe show visible count "1 of 3"? Keep simple: show total per column; during search, if filtered, maybe show "1/3". I'll show visible/total when query active — nice detail. And header sub counts computed from state (open/done totals always full counts).
Delete flow:
```js
board click delegation:
const del = e.target.closest('.card-del');
if(del){ const card=del.closest('.card'); deleteCard(card); }
function deleteCard(card){
const c = cards.find(x=>x.id===card.dataset.id);
card.style.height = card.getBoundingClientRect().height+'px';
requestAnimationFrame(()=>{
card.classList.add('removing'); // sets height 0 via transition, opacity, margin 0
});
setTimeout(()=>{ card.remove(); if(c) cards=cards.filter(x=>x!==c); updateCounts(); }, 240);
}
```
CSS .card{transition includes height? Height transitions need explicit values — I set inline height then add .removing with height:0!important; padding 0; margin 0; opacity 0; transform translateX(8px); transition .22s; overflow hidden. Need box-sizing handled. Also need `overflow:hidden` default on card? set in .removing. OK.
Modal:
```html
<div class="overlay" id="overlay" hidden>
<div class="modal" role="dialog" aria-modal="true">
<h2>New card</h2>
<label>Title <input id="fTitle" placeholder="e.g. Draft release notes"></label>
<p class="err" id="fErr" hidden>Give the card a title.</p>
<div class="field"><span class="flabel">Priority</span><div class="seg" id="fPri">3 buttons</div></div>
<div class="field"><span class="flabel">Column</span><div class="seg" id="fCol">3 buttons</div></div>
<div class="row"><button ghost id="fCancel">Cancel</button><button primary id="fAdd">Add card</button></div>
</div>
</div>
```
Segmented buttons: `.seg button.on`. Priority buttons include colored dot. Column buttons names.
Open: `openModal(column)` — set fCol selection, clear title, focus. Overlay uses class .open (display flex + animate opacity, modal translateY). Use hidden attr + rAF for animation, or just class with visibility. I'll use `.overlay{display:none} .overlay.open{display:flex}` + animation keyframes fadeIn / slideUp — simplest with keyframe animations on open.
Esc key closes (keydown on window when open). Backdrop click (target===overlay) closes. Enter in title submits (form submit). Use a <form> with novalidate? Just handle submit; preventDefault.
On add: push card {id: 'c'+(uid++), title, priority, column, who: random from list, when:'just now'} then render(); close. New card appears (with maybe a pop-in animation via .card.new? render adds class 'enter' to that id → CSS animation). Nice touch.
Random who: ['AM','JR','KT','LS','MK','DB'] pick random; when:'just now'.
Search:
```js
searchInput.addEventListener('input', e=>{ query=e.target.value.trim(); render(); });
```
render with highlight; if query and no matches in a column, column shows "No matches for “q”" empty state; if no query and empty, "Nothing here yet".
Also when searching, add class to board `searching` to maybe dim non-matches? We remove them (display none). I chose to render only matches? Earlier I said keep hidden in DOM for drag-state sync — but render() rebuilds everything anyway and syncFromDOM is only called at drag end. If hidden cards are absent from DOM during drag, syncFromDOM would lose them! So during search renders, include non-matching cards with class 'hidden' (display:none). Then syncFromDOM's querySelectorAll('.card') includes hidden ones — they'd be included in next[]. But wait — querySelectorAll includes hidden cards; their DOM position: they're rendered in their state order within columns, fine, they get pushed back with correct column. Good, keeps them.
But careful: visible-only filtering for drop index calc must skip .hidden — handled.
Also empty-state element `.empty` is not a .card, fine.
render() full rebuild each keystroke — fine for demo scale.
highlight(title, q):
```js
function hi(t,q){
const escT = esc(t);
if(!q) return escT;
const i = t.toLowerCase().indexOf(q.toLowerCase());
if(i<0) return escT;
return esc(t.slice(0,i)) + '<mark>' + esc(t.slice(i,i+q.length)) + '</mark>' + esc(t.slice(i+q.length));
}
```
Theme toggle: button swaps `document.documentElement.dataset.theme`, swap icon (two svgs, CSS shows one per theme).
Icons (inline SVG, stroke currentColor, 24 viewBox):
- plus: M12 5v14 M5 12h14
- search: circle cx11 cy11 r7 + line 16.5,16.5→21,21
- sun: circle r4 + 8 rays
- moon: path
- x: two lines
- check: polyline
Let me write sun: `<circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4"/>` moon: `M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z`.
Delete button on card: absolute top-right, opacity .45 → 1 on hover, small.
Check icon on done cards: before title, inline svg 14px in green.
Column footer add buttons: `.col-add` ghost dashed button "+ Add card" opening modal with that column preselected. It should not interfere with drop (it's outside .cards list). It sits after the list; appending placeholder after it? Placeholder appends to list only — fine.
Layout CSS:
```css
*{box-sizing:border-box}
body{margin:0;background:var(--bg);color:var(--ink);font:15px/1.45 "Avenir Next","Segoe UI",-apple-system,sans-serif; min-height:100vh}
.wrap{max-width:1200px;margin:0 auto;padding:28px 28px 48px}
header flex wrap gap, align end.
.board{display:grid;grid-template-columns:repeat(3,1fr);gap:20px;margin-top:26px;align-items:start}
.column{background:var(--panel);border:1px solid var(--border);border-radius:14px;padding:14px;transition:border-color .2s, background .2s}
.column.over{border-color:var(--accent); background: color-mix(in srgb, var(--accent) 5%, var(--panel))}
.cards{display:flex;flex-direction:column;gap:10px;min-height:70px; padding:2px; }
.card{background:var(--card);border:1px solid var(--border);border-radius:10px;padding:12px 14px;box-shadow:var(--shadow);cursor:grab; position:relative; transition: transform .15s, box-shadow .2s, border-color .2s;}
.card:hover{transform:translateY(-1px); border-color:var(--border-strong); box-shadow: bigger slightly}
```
Hmm card hover transform conflicts with FLIP inline transform — FLIP sets inline transform, hover rule transform overridden by inline anyway; during drag hover irrelevant. But after drag, inline transform cleared at transitionend. OK. Actually hover translateY might fight the drag threshold... drag only after 5px move; small hover translate fine.
Careful: `.card:hover{transform:translateY(-1px)}` and FLIP transition on transform — after cleanup we set card.style.transition='' and transform=''. Hover then works.
Card enter animation: `@keyframes pop{from{opacity:0;transform:translateY(6px) scale(.98)}}` .card.enter{animation:pop .25s ease}.
Drop slot: `border:1.5px dashed color-mix(in srgb, var(--accent) 60%, transparent); background: color-mix(in srgb, var(--accent) 7%, transparent); border-radius:10px;` plus a subtle pulse animation (opacity of border via animation on outline? simple: animate background opacity slightly). Add small label "Drop here"? If height >= 48 show centered text? Extra complexity; the dashed slot is clear. Maybe include tiny centered label via ::after content:'Drop here' when class .drop-slot — but for short heights... always show; font 11px accent. Height is card height (~66px) so fits. But when dragged card is tall... fine. Actually placeholder content: I'll add ::before with "DROP" no—keep clean dashed slot with faint accent, plus animated marching dots? Keep simple.
Search input styling: pill with icon, bg panel, border; focus ring accent.
Buttons: .btn.primary{background:var(--accent);color:var(--accent-ink);border-radius:9px;padding:9px 14px;font-weight:600;border:none;cursor:pointer} hover: brightness(1.05) translateY(-1px)? subtle.
Theme icon button: 38px square border panel.
Header subtitle mono numbers? Use font-variant-numeric: tabular-nums.
Count badge: `background:var(--card)? small pill: background:color-mix(in srgb, var(--muted) 12%, transparent); border-radius:99px; font-size:12px; padding:1px 8px; font-variant-numeric:tabular-nums`.
Mobile: @media (max-width:820px){ .board{grid-template-columns:1fr} header stacks; search grows }
Dark-theme mark color for <mark>: background transparent, color var(--accent)? mark with background var(--mark) tint. I'll style mark{background:color-mix(in srgb, var(--accent) 22%, transparent); color:inherit; border-radius:3px; padding:0 1px}.
The dot grid texture on body:
```css
body::before{content:"";position:fixed;inset:0;background-image:radial-gradient(circle, var(--dot) 1px, transparent 1px); background-size:22px 22px; pointer-events:none; opacity:...}
```
Hmm radial-gradient — is that a "decorative gradient"? It's a dot texture technique; I deem it tasteful and not a gradient wash. Use --dot: rgba(60,50,30,.07) light, rgba(255,255,255,.04) dark. Keep. Actually simpler to keep page clean; the warm paper already reads. I'll include very subtle dots — it adds craft. Keep z-index -1 / just background-image on body directly (background-image on body with background-color). Do that: `background: var(--bg) radial-gradient(...)`? background-image + background-color + background-size. Fine.
Prefers-reduced-motion: reduce transitions.
Now the initial cards data:
```js
const people = ['MK','AS','JR','LT','PW'];
cards = [
{id:'c1', title:'Audit checkout funnel analytics', pri:'high', col:'todo', who:'MK', when:'2d'},
{id:'c2', title:'Draft Q3 content calendar', pri:'low', col:'todo', who:'LT', when:'5d'},
{id:'c3', title:'Redesign pricing page hero', pri:'high', col:'doing', who:'AS', when:'1d'},
{id:'c4', title:'Migrate marketing site to new CMS', pri:'medium', col:'doing', who:'PW', when:'3d'},
{id:'c5', title:'Ship dark mode toggle', pri:'medium', col:'done', who:'JR', when:'1w'},
{id:'c6', title:'Fix flaky auth tests', pri:'medium', col:'done', who:'MK', when:'4d'},
];
```
Card meta line: badge + spacer + who avatar (tiny circle with initials, tinted) + when text right-aligned? Layout: `display:flex; align-items:center; gap:8px; margin-top:10px` with badge left, then margin-left:auto avatar + when. Avatar: 20px circle, background color-mix accent? Use per-person deterministic hue? Keep single muted tone: background color-mix(in srgb, var(--muted) 18%, transparent); font-size 9.5px; letter-spacing .02em.
Done card: `.card.done` — title color var(--muted), with check svg before title. I'll include the check inside title h3 as inline svg prepended when col==='done'. During drag into another column, render isn't called (only syncFromDOM) so done styling stays until next render — minor inconsistency: a done card dragged to "To do" keeps checkmark until re-render. Hmm. To fix: after drag end, add/remove 'done' class based on column. I'll handle in endDrag: `card.classList.toggle('done', col==='done')` and also need to inject/remove check icon — easier: check icon always in markup, CSS shows it only for .card.done via display:none/inline. So markup includes `<svg class="tick">` always; CSS `.card .tick{display:none} .card.done .tick{display:inline}`. Title color via .card.done .card-title{color:var(--muted)}. Toggle class on drop.
updateCounts: per column: total = cards.filter(col).length; visible = same but matching query. Badge text = query ? `${vis}/${tot}` : tot. Sub: `${open} open · ${doneCount} done`.
Modal column default: passed param.
Also clicking column "Add card" while search active — new card might be filtered out (doesn't match query). Then it renders hidden — confusing. I'll clear search on add? Or just render; card may be invisible. I'll clear the search input and query when adding a card so it's visible. Reasonable UX.
Also the drag: if query active and user drags, fine.
One more: pointerdown preventDefault stops input focus? Cards have no inputs. preventDefault on card pointerdown prevents text selection — good. But it also prevents... fine. Should I not preventDefault before drag starts (to allow future click)? Del button excluded earlier. OK.
Also need `e.preventDefault()` in pointerdown only when starting? Call it always on cards — prevents image drag/text select. Fine.
touch-action: set `touch-action:none` on .card so pointermove works on touch. Scroll concern on mobile: cards cover most width; scrolling could get hard. Compromise: `touch-action: pan-y` — vertical scroll allowed, horizontal moves drag. Drag is mostly vertical though... For demo purposes (mouse), I'll use `touch-action:none` on card but the page on mobile is scrollable via column gaps/background. Eh — I'll use pan-y; vertical dragging on touch will scroll instead of drag, but horizontal-ish drags still trigger the 5px threshold via combined movement... pointermove fires during pan? With pan-y, vertical gestures are consumed by scroll (pointercancel fired), horizontal gestures send pointermove. Mixed. It's fine for a mouse demo. Choose `touch-action:pan-y`.
Set body user-select none while dragging: body.dragging-any{user-select:none; cursor:grabbing}.
Also while dragging, .cards min-height and pointer... floater has pointer-events:none? Since pointer capture is on the card (which is the floater), events still target it — pointer-events none doesn't matter for captured events. But set pointer-events:none anyway to avoid hover effects on underlying? With capture, hover on other elements? Keep pointer-events:none on .dragging for safety — but does capture still deliver? Yes, pointer capture delivers events regardless. But then elementFromPoint-based logic — I don't use it (I use geometric hit tests). OK set pointer-events:none.
Hmm wait: with setPointerCapture on the card, and then I reparent... I don't reparent the card during drag (placeholder moves, card stays fixed). Reparenting happens at drop. Capture survives? Capture persists on the element even if moved in DOM? If it does move at drop, we don't need events anymore. Fine. Actually one subtlety: I setPointerCapture on card at pointerdown; the floater IS the card; events keep firing to it and bubble to window. Good. But at startDrag I should keep capture. Alternatively skip capture and listen on window — pointermove on window works without capture for mouse. For touch, without capture the browser may cancel for scrolling — pan-y handles. Capture is safer for mouse leaving iframe? Events stop at iframe boundary anyway. Keep capture.
Edge: endDrag with pointer leaving window — pointerup still fires with capture. Good.
Also 'pointercancel' → treat as drop at current placeholder (or cancel to origin). I'll just endDrag (drop at placeholder).
The .drop-slot transition when moving: FLIP animates other cards; placeholder itself appears instantly — give it a fade-in animation? It gets re-created? I reuse the same ph element, just moved. Add a quick scale animation? FLIP handles others; the slot jumping is visually guided by FLIP of neighbors. OK.
Empty column: .cards min-height 80px with dashed inner? The `.empty` div: dashed border, muted text, padding, radius. When query: text "No matches for 'q'". When not: "Nothing here — drop a card or add one". During drag over empty column, placeholder appears inside list (empty div hidden when placeholder present? The empty div remains plus slot — I should hide .empty when it contains ph? If list children include .empty, my visible-card index calc ignores it (not .card). Placeholder appended after .empty → looks odd (empty box + slot). Fix: CSS `.cards:has(.drop-slot) .empty{display:none}` — :has supported in Chrome.
Now the "over" column highlight: set in updateDropTarget when column changes.
Also add nice touch: while dragging, the source column list collapses smoothly? The FLIP handles it.
Let me now also double check FLIP during placeholder moves includes the hidden cards (display:none — rect is 0; transform on them harmless).
Modal segmented control CSS:
```css
.seg{display:flex;gap:6px; background:color-mix(in srgb,var(--muted) 10%,transparent); padding:4px; border-radius:9px}
.seg button{flex:1;border:none;background:transparent;padding:7px 6px;border-radius:6px;cursor:pointer;color:var(--muted);font:inherit;font-size:13px;display:flex;align-items:center;justify-content:center;gap:6px}
.seg button.on{background:var(--card);color:var(--ink);box-shadow:var(--shadow);font-weight:600}
```
Priority dots: span.dot{width:8px;height:8px;border-radius:50%;background:var(--pri-x)}.
Modal form uses ids; JS reads selections.
Error shake keyframes.
Focus styles: :focus-visible outline accent.
Also number the code: total maybe ~600 lines. Let's write it.
Title header text: eyebrow "PRODUCT TEAM — Q3"; h1: `Sprint <em>board</em>` with em italic serif accent color? em{font-style:italic; color:var(--accent)}. Nice.
Sub: `<span class="sub-num">4</span> open · 2 done` tabular nums muted.
Search field width ~240px, shrinks on mobile to 100%.
Also a subtle keyboard hint? Not needed.
Let me also ensure no horizontal scrollbar: floater fixed could overflow during drag — position fixed with negative? overflow-x hidden on body to be safe: `overflow-x:hidden` on html,body.
Write the final code now.
```html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Sprint board</title>
<style>
...
</style>
</head>
<body>
<div class="wrap">
<header>
<div class="brand">
<p class="eyebrow">Product team · Q3</p>
<h1>Sprint <em>board</em></h1>
<p class="sub"><span id="openN">4</span> open · <span id="doneN">2</span> done</p>
</div>
<div class="controls">
<label class="search">
<svg ...search icon...></svg>
<input id="q" type="text" placeholder="Search cards…" autocomplete="off">
</label>
<button class="btn primary" id="newBtn"><svg plus></svg>New card</button>
<button class="btn ghost icon" id="themeBtn" aria-label="Toggle theme">
<svg class="sun">...</svg><svg class="moon">...</svg>
</button>
</div>
</header>
<main class="board" id="board">
3 × column markup generated in JS? I'll hardcode columns in HTML for clarity:
<section class="column" data-col="todo">
<div class="col-head"><span class="col-dot"></span><h2>To do</h2><span class="count" id="cnt-todo">0</span></div>
<div class="cards"></div>
<button class="col-add" data-col="todo">+ Add card</button>
</section>
...
</main>
</div>
<overlay modal>
<script>...</script>
```
col-dot colors: `.column[data-col=todo] .col-dot{background:var(--faint)}` doing → amber `#c99334` use color-mix from pri-med; done → pri-low. Use vars: todo dot: var(--muted); doing: var(--pri-med); done: var(--pri-low). Good semantic reuse.
"+ Add card" button: use plus svg? text "+ Add card" fine, styled ghost dashed.
Delete btn inside card top-right absolute; card padding-right accommodate. Card title padding-right:26px.
Now CSS full write-up. Also `.card.done .tick{display:inline-block}` and title flex with gap: make h3 display flex align-start gap 6px; tick margin-top 2px flex-shrink 0.
Card title font: Georgia serif 15px, line-height 1.35, font-weight 400? 400 serif at 15 might be light; use 500? Georgia bold is heavy. Use normal with color ink. Fine.
h1: Georgia 30px; em italic accent.
Modal overlay z-index 50; floater z-index 40? Floater during drag only, no modal conflict.
Write JS carefully.
Also `updateCounts` should also update sub counts.
render():
```js
function render(){
columns.forEach(col=>{
const list = document.querySelector(`.column[data-col="${col}"] .cards`);
const inCol = cards.filter(c=>c.col===col);
const vis = inCol.filter(c=>match(c));
list.innerHTML = vis.map(cardHTML).join('')
+ (vis.length? '' : `<div class="empty">${query? `No matches for “${esc(query)}”` : 'Nothing here yet'}</div>`);
});
updateCounts();
}
```
match(c): !query || c.title.toLowerCase().includes(query.toLowerCase()).
cardHTML:
```js
function cardHTML(c){
return `<article class="card ${c.col==='done'?'done':''}" data-id="${c.id}">
<h3 class="card-title">${hi(c.title)}</h3>
<button class="card-del" aria-label="Delete card">${X_SVG}</button>
<div class="card-meta">
<span class="badge p-${c.pri}"><i></i>${c.pri}</span>
<span class="meta-right"><span class="avatar">${c.who}</span><span class="when">${c.when}</span></span>
</div>
${TICK_SVG} <!-- hmm place tick inside h3 -->
</article>`;
}
```
Put tick inside h3: `<h3 class="card-title">${TICK}<span>${hi(c.title)}</span></h3>`. CSS .tick{display:none} .card.done .tick{display:block; color:var(--pri-low)}.
hi(title) uses global query.
Badge capitalize: text-transform:capitalize.
Delete uses class card-del.
Drag: attach pointerdown via delegation on board. After render(), elements replaced — delegation handles.
Delete click delegation on board click; but pointerdown on del also triggers drag check — excluded via closest('.card-del') check in pointerdown (return early). But then click fires delete. Good. Also should stopPropagation? fine.
Modal open sources: #newBtn (default col 'todo'), .col-add buttons (delegation on board click, closest('.col-add')). Overlay click/Escape/Cancel close. Submit adds.
Priority seg default 'medium'; col seg default passed. When reopening, reset.
Now edge auto-scroll uses window.scrollY changes; column rects change when page scrolls — I recompute rects each pointermove via getBoundingClientRect (viewport-based clientY comparisons — consistent). Good, no caching.
updateDropTarget:
```js
function updateDropTarget(e){
const colEl = pickColumn(e.clientX);
if(!colEl) return;
colEls.forEach(c=>c.classList.toggle('over', c===colEl));
const list = colEl.querySelector('.cards');
const kids = [...list.querySelectorAll('.card')].filter(el=>!el.classList.contains('hidden'));
let ref=null;
for(const k of kids){
const r=k.getBoundingClientRect();
if(e.clientY < r.top + r.height/2){ ref=k; break; }
}
// if placeholder already at this exact spot, skip
if(drag.ph.parentNode===list && drag.ph.nextSibling===ref) { /* also check prevSibling case? ref-based compare is exact */ }
else{
flipMove(()=>{ list.insertBefore(drag.ph, ref); });
}
}
```
Hmm comparing nextSibling: if ref is a hidden card etc. fine. One catch: if ph currently before ref and ref's previous sibling is ph → insertBefore(ph, ref) is no-op but comparison ph.nextSibling===ref true → skip. Good. If ref null (append) and ph already last child of list → ph.nextSibling===null → skip. Good.
But subtle: when kids exclude ph (ph is not .card), inserting before kids[idx] — kids order from DOM including ph position doesn't matter since we compare rects of cards only. If pointer is between two cards where ph currently sits, ref = the next card, ph.nextSibling===ref → no move. Good, stable.
flipMove(mutate):
```js
function flipMove(mutate){
const els=[];
colEls.forEach(c=>c.querySelectorAll('.card').forEach(el=>{ if(!el.classList.contains('hidden')) els.push(el); }));
const before = els.map(el=>el.getBoundingClientRect());
mutate();
els.forEach((el,i)=>{
const b=before[i], a=el.getBoundingClientRect();
const dx=b.left-a.left, dy=b.top-a.top;
if(!dx && !dy) return;
el.style.transition='none';
el.style.transform=`translate(${dx}px,${dy}px)`;
});
requestAnimationFrame(()=>{
els.forEach(el=>{
el.style.transition='transform .2s cubic-bezier(.2,.8,.3,1)';
el.style.transform='';
el.addEventListener('transitionend', ()=>{ el.style.transition=''; }, {once:true});
});
});
}
```
Note: transition property on .card in CSS also covers transform — inline 'none' overrides, then inline transition .2s, then cleared → back to CSS transition. Potential conflict with hover transform later — cleared eventually. If a transitionend doesn't fire (element display none?), edge case minor. Also if user drops mid-FLIP — endDrag manipulates card styles; leftover inline transition='' cleared in endDrag via cssText? I clear inline styles of the dragged card at end (style.cssText=''? careful: I need to also handle ph removal). In endDrag: card.style.transition=''; card.style.transform=''; card.style.left/top/position/width/margin=''; zIndex=''. OK.
endDrag full:
```js
function endDrag(){
if(!drag) return;
const d=drag; drag=null;
document.body.classList.remove('dragging-any');
colEls.forEach(c=>c.classList.remove('over'));
if(!d.active){ return; }
const card=d.card, ph=d.ph;
const before=card.getBoundingClientRect();
const tilt=d.tilt;
// place card
card.style.cssText='';
card.classList.remove('dragging');
ph.replaceWith(card);
// done styling sync
const col = card.closest('.column').dataset.col;
card.classList.toggle('done', col==='done');
const after=card.getBoundingClientRect();
const dx=before.left-after.left, dy=before.top-after.top;
if(dx||dy||Math.abs(tilt)>.2){
card.style.transition='none';
card.style.transform=`translate(${dx}px,${dy}px) rotate(${tilt}deg) scale(1.03)`;
card.style.zIndex=60;
requestAnimationFrame(()=>{
card.style.transition='transform .24s cubic-bezier(.2,.8,.3,1)';
card.style.transform='';
setTimeout(()=>{card.style.cssText='';},260);
});
}
syncFromDOM();
}
```
Wait, order: I must clear inline position BEFORE measuring after-rect — cssText='' then replaceWith — but while position:fixed was set via inline style and I clear it, then replaceWith moves node — good.
But careful: setting `card.style.cssText=''` also removes `touch-action`? touch-action is CSS class-based, fine.
Also 'dragging' class removal kills shadow — with transition on box-shadow in CSS, animates. Keep class removal before measuring (class has no layout effect — only shadow/pointer-events/cursor — pointer-events none stays until class removed... fine).
Hmm: while dragging, `.dragging` cursor grabbing; card has pointer-events:none in class — at drop it's back in flow, remove class → interactive.
One more: ph.replaceWith(card) — ph is in some column; card moved there. Good.
syncFromDOM uses card.closest etc — cards still in DOM. Then updateCounts.
Also during drag with search on: the dragged card matches query (it was visible). Hidden cards skipped. Fine.
pickColumn: also ensure dropping only within board vertical? If pointer way above header, nearest column still chosen by x — acceptable and forgiving.
moveDrag:
```js
function moveDrag(e){
const d=drag;
const dx=e.clientX-d.startX, dy=e.clientY-d.startY;
const vx=e.clientX-d.lastX; d.lastX=e.clientX;
d.tilt += (Math.max(-7,Math.min(7,vx*0.6)) - d.tilt)*0.2;
d.card.style.transform=`translate(${dx}px,${dy}px) rotate(${d.tilt}deg) scale(1.03)`;
if(e.clientY<70) window.scrollBy(0,-14);
else if(e.clientY>innerHeight-70) window.scrollBy(0,14);
updateDropTarget(e);
}
```
Floater positioned with left/top set at start (clientX-offX at startDrag). dx/dy relative to start — since left/top anchored at start pointer pos. Good.
startDrag:
```js
function startDrag(e){
const d=drag; d.active=true;
document.body.classList.add('dragging-any');
const card=d.card, r=d.rect;
const ph=document.createElement('div');
ph.className='drop-slot';
ph.style.height=r.height+'px';
card.before(ph);
d.ph=ph;
card.classList.add('dragging');
card.style.width=r.width+'px';
card.style.left=(e.clientX-d.offX)+'px';
card.style.top=(e.clientY-d.offY)+'px';
updateDropTarget(e);
}
```
CSS .dragging{position:fixed;margin:0;z-index:60;pointer-events:none;box-shadow:var(--shadow-lift);cursor:grabbing; transition:none!important} — important because FLIP transitions might apply to floater? flipMove skips floater (it's not in query since... it IS a .card in DOM (body-appended? no — card stays in original position in DOM? Wait! I did `card.before(ph)` but card remains in DOM before ph! The card is still in the list, just visually fixed. So list contains card + ph adjacent. Problems:
1. kids calculation includes the dragged card (it's .card in list) — its rect is the floater position (fixed elements: getBoundingClientRect works, gives viewport rect). So the dragged card's rect is at cursor — the insertion calc would treat it as a card in the list! Must exclude the dragged card: filter `el!==d.card`.
2. flipMove must also exclude dragged card.
3. syncFromDOM at end — card back in flow, fine.
Alternative: remove card from DOM at start (store parent) — but then FLIP of neighbors at drag start? When card removed, others shift up — I'd want FLIP there too. Current approach (card fixed in place, ph inserted before it) leaves a "hole"? The fixed card doesn't take flow space, so flow = ph occupying card's spot — correct, no hole. And ph sits right before the (invisible-in-flow) card. So list flow: ...prev, ph, card(fixed, no flow), next... Fine.
So: in updateDropTarget kids filter excludes d.card; also compare placement — when computing insertion, exclude dragged card. Also `pickColumn` etc fine.
Also `.dragging` transition:none important to avoid transform transition lag on the floater (we want it glued to cursor). But CSS .card has transition on transform — inline transform changes each move would lag. So `.dragging{transition:none!important}`.
Also the card had :hover transform rule — irrelevant.
But wait: `.card{transition: transform .15s...}` also affects the FLIP-in endDrag: I set inline transition:'none' then rAF set transition inline — fine.
Also movePlaceholder FLIP animates cards with `.card` transition none → set → rAF restore. But I call flipMove potentially every pointermove — repeated rAF churn OK.
One more concern: `updateDropTarget` toggling `.over` class each move — cheap.
Empty slot text during drag-over: `.cards:has(.drop-slot) .empty{display:none}`.
Also initial render: done cards get class done — check icon shows.
Also add .card.enter animation for newly added card: after render, find element by id and add class 'enter'. In addCard: render(); then `const el=document.querySelector(`[data-id="${id}"]`); el.classList.add('enter')`. Animation keyframes pop.
Priority names: low/medium/high — display capitalized in badge.
Modal markup:
```html
<div class="overlay" id="overlay">
<form class="modal" id="modal" novalidate>
<h2>New card</h2>
<label class="field">
<span class="flabel">Title</span>
<input id="fTitle" type="text" placeholder="e.g. Draft release notes" maxlength="80" autocomplete="off">
</label>
<p class="err" id="fErr">Give the card a title.</p>
<div class="field"><span class="flabel">Priority</span>
<div class="seg" id="fPri">
<button type="button" data-v="low"><i class="pd" style="--pc:var(--pri-low)"></i>Low</button>
<button type="button" data-v="medium" class="on"><i class="pd" style="--pc:var(--pri-med)"></i>Medium</button>
<button type="button" data-v="high"><i class="pd" style="--pc:var(--pri-high)"></i>High</button>
</div>
</div>
<div class="field"><span class="flabel">Column</span>
<div class="seg" id="fCol">
<button type="button" data-v="todo" class="on">To do</button>
<button type="button" data-v="doing">In progress</button>
<button type="button" data-v="done">Done</button>
</div>
</div>
<div class="mrow">
<button type="button" class="btn ghost" id="fCancel">Cancel</button>
<button type="submit" class="btn primary">Add card</button>
</div>
</form>
</div>
```
Overlay hidden by default: `.overlay{position:fixed;inset:0;display:none;...} .overlay.open{display:grid;place-items:center}` with animations: `.overlay.open{animation:fade .18s}` `.overlay.open .modal{animation:up .22s cubic-bezier(...)}`.
Seg buttons click: delegation, toggle .on.
Seg 'on' style uses --card bg.
Input styling: background var(--card)? In modal, modal bg = var(--panel) elevated; inputs bg var(--bg)?? Use `background:color-mix(in srgb, var(--muted) 8%, var(--card))`. Keep simple: background:var(--card); border:1px solid var(--border-strong on focus accent).
Error: .err{color:var(--pri-high); font-size:12.5px; display:none} .modal.error .err{display:block} + input.error border red + shake animation.
Submit handler: title=value.trim(); if(!title){show error; shake; focus; return;} cards.unshift? push to top of chosen column? Push to state — order: I'll insert at beginning of that column's group for visibility. Rebuild: cards.push then render shows at end of column. Better unshift into front: find index of first card with col===chosen and splice there; if none, just push. Simple:
```js
const idx = cards.findIndex(c=>c.col===col);
cards.splice(idx<0?cards.length:idx, 0, {id:'c'+(uid++), title, pri, col, who:pick(people), when:'just now'});
```
Then close modal, clear search if active, render, pop animation.
Search clear: q.value=''; query=''.
Theme toggle:
```js
themeBtn.onclick=()=>{const r=document.documentElement; r.dataset.theme = r.dataset.theme==='dark'?'':'dark';}
```
data-theme="" vs absent — CSS [data-theme="dark"]. Default light (no attr). Set html tag without attr.
Theme icons: `.icon-sun{display:none} [data-theme=dark] .icon-sun{display:block} [data-theme=dark] .icon-moon{display:none}` etc. Show moon in light (click → dark) — icon indicates target? Common: show current-contrasting. I'll show moon in light mode ("switch to dark") and sun in dark.
Counts update fn:
```js
function updateCounts(){
columns.forEach(col=>{
const tot=cards.filter(c=>c.col===col).length;
const vis=cards.filter(c=>c.col===col && match(c)).length;
document.getElementById('cnt-'+col).textContent = query? `${vis}/${tot}` : tot;
});
document.getElementById('openN').textContent = cards.filter(c=>c.col!=='done').length;
document.getElementById('doneN').textContent = cards.filter(c=>c.col==='done').length;
}
```
Sub line maybe add "· drag cards between columns" hint? Add small hint in sub: `4 open · 2 done`. Plus maybe a hint line under board? Add footer hint: "Drag cards to move them · Click × to delete". Small muted footer. Nice for discoverability. Add under board: `<p class="hint">Drag cards between columns · use + Add card in any column</p>` muted small centered? Left. Keep subtle.
Empty title input id conflicts none.
Now icons SVG strings:
```js
const X='<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M6 6l12 12M18 6L6 18"/></svg>';
const TICK='<svg class="tick" viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M4 12.5l5 5L20 7"/></svg>';
```
Header icons inline in HTML.
Search input left icon absolute.
Let me now also handle: clicking a card without drag (active=false) → nothing (endDrag early return). Good.
Escape key: close modal if open.
Potential issue: pointerdown → preventDefault() blocks click generation? preventDefault on pointerdown prevents... compatibility mouse events? It can prevent default actions like text selection & drag; click still fires I believe (preventDefault on pointerdown suppresses mousedown default? In Chrome, preventDefault on pointerdown prevents firing of compatibility mouse events (mousedown/mouseup?) — actually it prevents "firing of compatibility mouse events"? Spec: canceling pointerdown prevents rendering default actions like selection; click may still fire. Hmm risk: click for delete button — but I early-return before preventDefault when target is .card-del. Let me only preventDefault when target isn't the del button — I already return early for del. So preventDefault applies only to non-del card areas; the del click path unaffected.
But does preventDefault on pointerdown block click entirely? Per spec, canceling pointerdown does not prevent click; it prevents text selection/dragstart etc. Actually MDN: "canceling pointerdown prevents mousedown... " Hmm. To be safe, del uses pointerdown? No — I return early on del, so no preventDefault there. Click on del should fire. If some edge prevents click, alternative: handle del on click only. I'll keep click; tested pattern generally works.
Also col-add button: pointerdown on it — not inside .card, delegation ignores. Good.
Let me also set `-webkit-tap-highlight-color: transparent`.
Fonts for mono numerals: font-variant-numeric.
Now, the h1 area — maybe add the three counts also as column heads; done.
Let me finalize CSS values, being careful about cohesion.
Also `.wrap{max-width:1160px}`.
Board columns equal width — that's inherent to Kanban; fine.
One more consideration on `.card` cursor grab & hover translateY: define `.card:not(.dragging):hover`.
The `.drop-slot` also animate subtle: `animation: slotin .15s ease` scale from .98.
Write everything now. Also add `::selection` accent tint. And `mark` styling.
Sub line: `<p class="sub"><span id="openN">4</span> open · <span id="doneN">2</span> done</p>` muted.
Also give the search input a keyboard shortcut? skip.
Column head h2: font: 13px, uppercase? "To do" uppercase letterspaced 600 sans small. With serif card titles, sans caps headers contrast nicely.
Let me write the whole file.
Also remember: no console errors — guard everything.
Check `color-mix` usage broadly supported in latest Chrome — yes.
:has support — yes in Chrome.
Let me write:
```html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Sprint board — Kanban</title>
<style>
:root{
--bg:#f3efe6; --panel:#faf7f0; --card:#fffdf8;
--border:#e4ddca; --border-strong:#cfc5aa;
--ink:#282318; --muted:#7c7360; --faint:#b0a78f;
--accent:#bc4a2a; --accent-ink:#fff8f2;
--shadow:0 1px 2px rgba(70,58,35,.07),0 3px 10px rgba(70,58,35,.06);
--shadow-lift:0 18px 40px rgba(55,42,22,.28);
--pri-high:#b23c22; --pri-med:#a0741d; --pri-low:#41724f;
--dot:rgba(80,66,40,.09);
}
html[data-theme="dark"]{
--bg:#161310; --panel:#1e1a15; --card:#272219;
--border:#373023; --border-strong:#4d4432;
--ink:#ede4d2; --muted:#a3987f; --faint:#655c48;
--accent:#e06a45; --accent-ink:#21120b;
--shadow:0 1px 2px rgba(0,0,0,.35),0 3px 10px rgba(0,0,0,.22);
--shadow-lift:0 18px 44px rgba(0,0,0,.55);
--pri-high:#f0896b; --pri-med:#dfa94e; --pri-low:#82bd93;
--dot:rgba(255,244,220,.05);
}
*{box-sizing:border-box}
html,body{overflow-x:hidden}
body{
margin:0; min-height:100vh; color:var(--ink);
background:var(--bg) ;
background-image:radial-gradient(var(--dot) 1px, transparent 1.5px);
background-size:24px 24px;
font:15px/1.45 "Avenir Next","Segoe UI","Helvetica Neue",sans-serif;
-webkit-tap-highlight-color:transparent;
transition:background-color .3s,color .3s;
}
body.dragging-any{user-select:none;-webkit-user-select:none;cursor:grabbing}
...
```
Hmm "Helvetica Neue" fine as secondary.
Continue with header etc. I'll now just write the final artifact in full, being meticulous.
For .search input width: 230px; media 700px: controls wrap, search flex 1.
Modal err default hidden via display:none.
Focus-visible: `:focus-visible{outline:2px solid var(--accent);outline-offset:2px}` and search input custom.
Column over ring: `border-color:var(--accent); box-shadow:0 0 0 3px color-mix(in srgb, var(--accent) 18%, transparent)`.
Drop-slot: `border:1.5px dashed color-mix(in srgb, var(--accent) 65%, transparent); background:color-mix(in srgb, var(--accent) 8%, transparent); border-radius:10px;` with a small "Drop here" label? Add ::after{content:"Drop here"; ...} centered, 11px, accent, letter-spacing. Height might be 60+ always (card height). OK include — makes indicator unmistakable.
Now the JS constants for columns.
Also when query non-empty and dragging: counts show x/y — fine.
Test mental run of drag start: pointerdown on card → drag obj set, capture. Move 6px → startDrag: ph inserted before card (card in flow still, but about to get position:fixed inline via class .dragging). Order: I add class .dragging (position:fixed) BEFORE inserting ph? In my startDrag, I insert ph first (card still in flow at that moment → layout shift: ph takes space AND card takes space momentarily within same synchronous block — no paint happens between, so fine), then add class+styles → card leaves flow. Net result correct.
getBoundingClientRect for floater start: use d.rect captured at pointerdown — close enough (few px). Actually card might have hover translateY(-1px) — negligible.
offX/offY from pointerdown rect — consistent.
Then moveDrag uses dx from start point: floater left/top were set at startDrag using current e.clientX — but transform dx is relative to startDrag point... If startDrag happened at move #1 (6px away), left/top set from that event; then subsequent dx relative to startX (pointerdown). Mismatch of 6px jump? left,top = e1 - off; transform dx = e - start. Floater visual pos = left+dx = e1 - off + (e - start). At e=e1: pos = e1 - off + (e1-start) → off by (e1-start) ≈ 6px. Fix: set left/top from start coords: left = startX - offX; then dx = e.clientX - startX. Consistent. Do that.
tilt init 0, lastX = startX.
Also scale origin: fine.
endDrag click-no-drag: active false → just cleanup (also remove capture auto). Good.
Also handle Escape closing modal & maybe cancel drag on Escape? skip.
Auto-scroll + FLIP: scrollBy during move changes rects; recompute after scroll in same frame? updateDropTarget called after scrollBy — rects reflect new scroll synchronously? scrollBy updates scroll position synchronously (layout flush) — getBoundingClientRect after reflects it. OK.
Now write final. I'll be careful with template literals inside template literals (cardHTML building with map join).
Also `esc()`:
```js
const esc=s=>s.replace(/[&<>"']/g,m=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[m]));
```
hi(t):
```js
function hi(t){
if(!query) return esc(t);
const i=t.toLowerCase().indexOf(query.toLowerCase());
if(i<0) return esc(t);
return esc(t.slice(0,i))+'<mark>'+esc(t.slice(i,i+query.length))+'</mark>'+esc(t.slice(i+query.length));
}
```
Delete animation:
```js
function deleteCard(card){
const id=card.dataset.id;
card.style.height=card.getBoundingClientRect().height+'px';
card.offsetHeight; // reflow
card.classList.add('removing');
setTimeout(()=>{
cards=cards.filter(c=>c.id!==id);
card.remove();
render(); // re-render to fix empties/counts — but full render kills animation of others? fine now
},230);
}
```
Hmm render() after removal rebuilds — simpler & consistent. But if search active, re-render fine. But render() during pointerdown drag conflicts? Unlikely.
Wait, render() rebuilds innerHTML of lists — if user immediately starts dragging another card within 230ms, edge; ignore.
CSS .removing{height:0!important; padding-top:0!important; padding-bottom:0!important; margin:0; opacity:0; transform:translateX(14px) scale(.97); border-width:0; overflow:hidden; transition:all .22s ease}. But .card has transition on transform etc; all covers. Need padding known? transitioning padding from 12px→0 works with !important? !important sets it immediately? No — transition applies to computed changes; !important changes computed value instantly at class add, then transition animates since transition property is on element. Height explicit inline set then to 0 via !important class — height not in .card transition list ('all' in removing covers). OK.
Actually .card transition: I'll define `transition:transform .15s ease, box-shadow .2s ease, border-color .2s ease, height .22s ease, padding .22s ease, opacity .22s ease;`. Hover transform then has .15s — good.
Empty state div: `.empty{border:1.5px dashed var(--border-strong); border-radius:10px; padding:18px 12px; text-align:center; color:var(--faint); font-size:13px}`.
Col-add: `width:100%; margin-top:10px; padding:8px; background:transparent; border:1.5px dashed transparent; color:var(--muted); border-radius:9px; cursor:pointer; font:inherit; font-size:13px; opacity:.75` hover: border-color dashed var(--border-strong); opacity 1; background subtle.
Alright — also `h2` margin resets, p margins.
Column head:
```css
.col-head{display:flex;align-items:center;gap:8px;margin-bottom:12px}
.col-head h2{margin:0;font-size:12px;font-weight:700;letter-spacing:.09em;text-transform:uppercase;color:var(--muted)}
.col-dot{width:8px;height:8px;border-radius:50%}
.count{margin-left:auto;font-size:12px;color:var(--muted);background:color-mix(in srgb,var(--muted) 12%,transparent);padding:1px 8px;border-radius:99px;font-variant-numeric:tabular-nums}
```
col-dot colors: `.column[data-col="todo"] .col-dot{background:var(--faint)} [data-col="doing"] → var(--pri-med); done → var(--pri-low)`.
Badge:
```css
.badge{display:inline-flex;align-items:center;gap:5px;font-size:10.5px;font-weight:700;letter-spacing:.06em;text-transform:uppercase;padding:3px 9px;border-radius:99px}
.badge i{width:6px;height:6px;border-radius:50%;background:currentColor;display:block}
.p-high{color:var(--pri-high);background:color-mix(in srgb,var(--pri-high) 13%,transparent)}
.p-medium{color:var(--pri-med);background:color-mix(in srgb,var(--pri-med) 15%,transparent)}
.p-low{color:var(--pri-low);background:color-mix(in srgb,var(--pri-low) 13%,transparent)}
```
card-meta: display flex; badge; .meta-right{margin-left:auto;display:flex;align-items:center;gap:7px}
avatar: 20px circle bg color-mix(var(--muted) 15%, transparent), color var(--muted), font-size 9px, font-weight 700, letter-spacing .03em, display grid place-items center.
when: 12px color var(--faint).
card-del: position absolute top 8px right 8px; width 22 height 22; border none; background transparent; color var(--faint); border-radius 6px; cursor pointer; display grid place-items center; opacity 0; transition .15s; .card:hover & {opacity:1} hover{background color-mix(var(--pri-high) 12%, transparent); color:var(--pri-high)}. On touch always? add media (hover:none){opacity:.6}.
card-title: margin:0 26px 8px 0; font-family:Georgia,'Times New Roman',serif; font-size:15px; font-weight:400; line-height:1.4. display flex gap 6px align-items flex-start. tick color var(--pri-low), margin-top:3px, flex-shrink:0.
.card.done .card-title span{color:var(--muted)}.
tick default display none.
Header:
```css
header{display:flex;align-items:flex-end;justify-content:space-between;gap:18px;flex-wrap:wrap}
.eyebrow{margin:0 0 6px;font-size:11px;font-weight:700;letter-spacing:.14em;text-transform:uppercase;color:var(--accent)}
h1{margin:0;font-family:Georgia,'Times New Roman',serif;font-weight:400;font-size:32px;letter-spacing:-.01em}
h1 em{font-style:italic;color:var(--accent)}
.sub{margin:6px 0 0;font-size:13px;color:var(--muted);font-variant-numeric:tabular-nums}
.controls{display:flex;gap:10px;align-items:center;flex-wrap:wrap}
```
search:
```css
.search{position:relative;display:flex;align-items:center}
.search svg{position:absolute;left:11px;width:15px;height:15px;color:var(--faint);pointer-events:none}
.search input{width:240px;padding:9px 12px 9px 34px;border:1px solid var(--border);border-radius:10px;background:var(--panel);color:var(--ink);font:inherit;font-size:14px;outline:none;transition:border-color .2s, box-shadow .2s}
.search input:focus{border-color:var(--accent);box-shadow:0 0 0 3px color-mix(in srgb,var(--accent) 15%,transparent)}
.search input::placeholder{color:var(--faint)}
```
buttons:
```css
.btn{display:inline-flex;align-items:center;gap:7px;font:inherit;font-size:14px;font-weight:600;padding:9px 15px;border-radius:10px;border:1px solid transparent;cursor:pointer;transition:transform .12s, box-shadow .2s, background .2s, border-color .2s}
.btn:active{transform:translateY(1px) scale(.98)}
.btn.primary{background:var(--accent);color:var(--accent-ink);box-shadow:var(--shadow)}
.btn.primary:hover{filter:brightness(1.06)}
.btn.ghost{background:var(--panel);border-color:var(--border);color:var(--ink)}
.btn.ghost:hover{border-color:var(--border-strong)}
.btn.icon{padding:9px;width:38px;height:38px;justify-content:center}
.btn svg{width:16px;height:16px}
```
Theme icons toggling display.
Modal CSS:
```css
.overlay{position:fixed;inset:0;background:rgba(20,14,8,.45);backdrop-filter:blur(3px);display:none;place-items:center;z-index:100;padding:20px}
.overlay.open{display:grid;animation:fade .18s ease}
.modal{width:100%;max-width:420px;background:var(--panel);border:1px solid var(--border);border-radius:16px;padding:22px;box-shadow:var(--shadow-lift);animation:up .24s cubic-bezier(.2,.9,.3,1)}
.modal h2{margin:0 0 16px;font-family:Georgia,serif;font-weight:400;font-size:22px}
.field{margin-bottom:14px}
.flabel{display:block;font-size:11.5px;font-weight:700;letter-spacing:.08em;text-transform:uppercase;color:var(--muted);margin-bottom:6px}
.modal input[type=text]{width:100%;padding:10px 12px;border:1px solid var(--border-strong);border-radius:9px;background:var(--card);color:var(--ink);font:inherit;outline:none;transition:border-color .15s, box-shadow .15s}
.modal input:focus{border-color:var(--accent);box-shadow:0 0 0 3px color-mix(in srgb,var(--accent) 15%,transparent)}
.modal.error input{border-color:var(--pri-high);animation:shake .3s}
.err{display:none;margin:6px 0 0;font-size:12.5px;color:var(--pri-high)}
.modal.error .err{display:block}
.mrow{display:flex;justify-content:flex-end;gap:10px;margin-top:18px}
@keyframes fade{from{opacity:0}}
@keyframes up{from{opacity:0;transform:translateY(14px) scale(.98)}}
@keyframes shake{25%{transform:translateX(-4px)}75%{transform:translateX(4px)}}
@keyframes pop{from{opacity:0;transform:translateY(8px) scale(.97)}}
```
seg as planned.
Board/columns CSS as planned; media query.
hint: `.hint{margin:18px 4px 0;font-size:12.5px;color:var(--faint)}`.
Board margin-top 26px; wrap padding.
Media 860px: .board{grid-template-columns:1fr} ; h1 26px; search input flex:1 width auto; .controls{width:100%} maybe.
Also 375px: wrap padding 16px.
Now, the drag ghost slot label:
```css
.drop-slot{border:1.5px dashed color-mix(in srgb,var(--accent) 60%,transparent);background:color-mix(in srgb,var(--accent) 7%,transparent);border-radius:10px;position:relative;animation:pop .15s ease}
.drop-slot::after{content:"Drop here";position:absolute;inset:0;display:grid;place-items:center;font-size:10.5px;font-weight:700;letter-spacing:.1em;text-transform:uppercase;color:var(--accent);opacity:.85}
```
Column over:
```css
.column.over{border-color:color-mix(in srgb,var(--accent) 55%, var(--border));background:color-mix(in srgb,var(--accent) 4%, var(--panel))}
```
Drag class:
```css
.card.dragging{position:fixed;z-index:90;pointer-events:none;box-shadow:var(--shadow-lift);cursor:grabbing;transition:none!important;opacity:.96}
```
Note: I set position via class not inline — then cssText clear at end removes inline but class removed too. Setting position in class is cleaner. But transition:none!important persists while class present; I remove class before FLIP so ok.
Order in endDrag: remove class then cssText='' then replaceWith. But removing class makes it position:static momentarily then cssText clear — same frame, no paint. OK.
Wait: `.card.dragging{transition:none!important}` — at endDrag I remove class → base .card transition applies; I then set inline transition none → set transform → rAF restore. Fine.
Also .card base transition list shouldn't include 'all' (would animate height weirdly on FLIP? FLIP uses transform only). Keep explicit list including height/padding/opacity for removal.
Enter animation class .enter{animation:pop .28s cubic-bezier(.2,.9,.3,1)}.
Also `.cards{...}` and `body.dragging-any .card{cursor:grabbing}`.
JS colEls cached after DOM ready — script at end of body.
One catch in flipMove: I query `.card` elements excluding hidden; the dragged card (fixed) — must exclude too, else its transform gets overwritten by FLIP! In flipMove, exclude `d.card` i.e. pass exclusion. I'll check `el.classList.contains('dragging')` skip. Good — dragged card has class dragging.
Also in updateDropTarget kids: skip `.dragging` and `.hidden` and non-.card (empty). Use list.children iteration: `[...list.children].filter(el=>el.classList.contains('card') && !el.classList.contains('hidden') && !el.classList.contains('dragging'))`.
Alright. Another subtlety — `pickColumn` measures `.column` rects, which include col-add button; fine.
Also when drag active and no movement beyond threshold, pointerup → click? Card click does nothing. OK.
Prevent native drag on text/images: draggable false default; also add `card.addEventListener('dragstart', e=>e.preventDefault())`? Add global: `document.addEventListener('dragstart',e=>e.preventDefault())`. Safe.
Assemble counts initial: todo 2, doing 2, done 2 → sub 4 open 2 done.
Modal open sets `.modal.error` removed, title value '', fCol selection per arg, focus title after animation (setTimeout 30ms focus — with select()).
Escape handler: if overlay open → close; else if dragging? skip.
I think we're ready. Also add `aria` bits lightly.
One more check — "count" element ids: cnt-todo, cnt-doing, cnt-done.
The board column sections:
```html
<main class="board" id="board">
<section class="column" data-col="todo">
<div class="col-head"><span class="col-dot"></span><h2>To do</h2><span class="count" id="cnt-todo">0</span></div>
<div class="cards"></div>
<button class="col-add" data-col="todo" type="button">+ Add card</button>
</section>
... doing "In progress", done "Done"
</main>
```
col-add click → openModal(col). Use textContent "+" svg? Use plus svg small inline for crispness. I'll include plus svg inside col-add via JS? Static HTML: write svg inline in each button. Fine, copy.
Board click delegation:
```js
board.addEventListener('click', e=>{
const del=e.target.closest('.card-del');
if(del){ deleteCard(del.closest('.card')); return; }
const add=e.target.closest('.col-add');
if(add){ openModal(add.dataset.col); }
});
```
newBtn → openModal('todo'). fCancel & overlay backdrop click (e.target===overlay) → close. Form submit → add.
Seg handler:
```js
function seg(el){ el.addEventListener('click',e=>{ const b=e.target.closest('button'); if(!b)return; el.querySelectorAll('button').forEach(x=>x.classList.toggle('on',x===b)); }); }
```
Add:
```js
modal.addEventListener('submit', e=>{
e.preventDefault();
const title=fTitle.value.trim();
if(!title){ modal.classList.remove('error'); void fTitle.offsetWidth; modal.classList.add('error'); fTitle.focus(); return; }
const pri=fPri.querySelector('.on').dataset.v;
const col=fCol.querySelector('.on').dataset.v;
const id='c'+(uid++);
const idx=cards.findIndex(c=>c.col===col);
cards.splice(idx<0?cards.length:idx,0,{id,title,pri,col,who:people[Math.floor(Math.random()*people.length)],when:'just now'});
closeModal();
if(query){ query=''; qInput.value=''; }
render();
const el=document.querySelector(`[data-id="${id}"]`);
if(el) el.classList.add('enter');
});
```
Theme button id themeBtn.
Done. Also initial render() call and set html theme default light.
Double-check no alert/etc. No external requests. Single file.
Let me write out the complete final file carefully now, keeping code tidy.
Estimated length ~450 lines. Go.