Qwen3.8 Max (0902)
by Qwenreasoning defaultClean
Running live in a sandboxed frame. Click inside it before using the keyboard.
How it went
- Total time
- 13 min 53 s
- Cost
- $0.233
- Output tokens
- 38,704
- Spent thinking
- 26,833 tokens
- Speed
- 46.6 tok/s
- Console errors
- 0
- First token after
- 2.8 s
- First visible text after
- 10 min 37 s
- Prompt tokens
- 557
- Code size
- 34.1 KB
- Finish reason
- stop
- Served by
- Alibaba
- Model id
qwen/qwen3.8-max-0902- Reasoning sent
model default- Max output
- 131,072 tokens
- Generated
- Nov 12, 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 · 771 lines
<!DOCTYPE html>
<html lang="en" data-theme="light">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Flowdeck — Sprint Kanban</title>
<style>
:root{
--bg:#eef1f4; --dots:rgba(21,32,46,.13);
--surface:#ffffff; --panel:#f5f7fa; --line:#dfe5ec;
--ink:#17212e; --muted:#5d6b7a;
--bar:#152130; --bar-ink:#f4f7fa; --bar-mut:#93a4b6;
--todo:#5f7f9e; --doing:#e28a13; --done:#2e9e63;
--high:#d84047; --high-fg:#ffffff;
--med:#e79b0a; --med-fg:#3a2800;
--low:#7189a3; --low-fg:#ffffff;
--accent:#0e9384; --accent-soft:rgba(14,147,132,.10);
--focus:rgba(14,147,132,.30);
--mark:rgba(231,155,10,.35);
--shadow:0 1px 2px rgba(16,24,40,.06),0 1px 3px rgba(16,24,40,.08);
--shadow-lift:0 10px 24px rgba(16,24,40,.16);
--font:system-ui,-apple-system,"Segoe UI","Helvetica Neue",sans-serif;
--mono:ui-monospace,"SF Mono","Cascadia Code","JetBrains Mono",Menlo,Consolas,monospace;
}
[data-theme="dark"]{
--bg:#11171f; --dots:rgba(255,255,255,.06);
--surface:#1d2632; --panel:#171f2a; --line:#2b3644;
--ink:#e8eef5; --muted:#8fa1b3;
--bar:#0c131c; --bar-ink:#eef3f8; --bar-mut:#7e90a3;
--todo:#82a7c9; --doing:#f0a03c; --done:#3fc37f;
--high:#e5545b; --med:#f0a81c; --med-fg:#33230a; --low:#7d95af;
--accent:#2fbcae; --accent-soft:rgba(47,188,174,.14);
--focus:rgba(47,188,174,.35);
--mark:rgba(240,168,28,.30);
--shadow:0 1px 2px rgba(0,0,0,.35);
--shadow-lift:0 12px 28px rgba(0,0,0,.5);
}
*{box-sizing:border-box;margin:0}
html{scroll-behavior:smooth}
body{
font-family:var(--font); color:var(--ink); background-color:var(--bg);
min-height:100vh; transition:background-color .3s,color .3s;
}
body::before{
content:""; position:fixed; inset:0; z-index:0; pointer-events:none;
background-image:radial-gradient(var(--dots) 1px,transparent 1.4px);
background-size:22px 22px;
}
button{font:inherit}
:focus-visible{outline:2px solid var(--accent); outline-offset:2px}
/* ---------- top bar ---------- */
.topbar{
position:sticky; top:0; z-index:20; display:flex; align-items:center; gap:14px; flex-wrap:wrap;
padding:12px 22px; background:var(--bar); color:var(--bar-ink);
transition:background-color .3s;
}
.brand{display:flex; align-items:center; gap:10px; min-width:0}
.brand-name{font-family:var(--mono); font-weight:800; letter-spacing:.16em; font-size:15px}
.sprint-tag{
font-family:var(--mono); font-size:10.5px; letter-spacing:.1em; color:var(--bar-mut);
border:1px solid rgba(255,255,255,.16); padding:3px 9px; border-radius:999px; white-space:nowrap;
}
.top-tools{margin-left:auto; display:flex; align-items:center; gap:10px; flex:1 1 auto; justify-content:flex-end; min-width:0}
.search{position:relative; flex:0 1 330px; min-width:160px}
.search .mag{position:absolute; left:11px; top:50%; transform:translateY(-50%); width:15px; height:15px; color:var(--bar-mut); pointer-events:none}
.search input{
width:100%; padding:9px 32px 9px 34px; border-radius:10px; outline:none;
border:1px solid rgba(255,255,255,.14); background:rgba(255,255,255,.07);
color:var(--bar-ink); font:inherit; font-size:13.5px;
transition:border-color .15s, background-color .15s, box-shadow .15s;
}
.search input::placeholder{color:var(--bar-mut)}
.search input:focus{border-color:var(--accent); background:rgba(255,255,255,.12); box-shadow:0 0 0 3px var(--focus)}
.search .clear{
position:absolute; right:6px; top:50%; transform:translateY(-50%);
width:22px; height:22px; border:none; border-radius:6px; display:none; place-items:center;
background:transparent; color:var(--bar-mut); cursor:pointer;
}
.search .clear:hover{color:var(--bar-ink); background:rgba(255,255,255,.12)}
.search.has .clear{display:grid}
.icon-btn{
width:38px; height:38px; flex:none; border-radius:10px; display:grid; place-items:center;
border:1px solid rgba(255,255,255,.15); background:transparent; color:var(--bar-ink); cursor:pointer;
transition:background-color .15s, transform .12s;
}
.topbar .icon-btn:hover{background:rgba(255,255,255,.1)}
.theme{position:relative}
.theme svg{position:absolute; width:18px; height:18px; transition:opacity .3s, transform .4s}
[data-theme="light"] .theme .sun{opacity:0; transform:rotate(90deg) scale(.4)}
[data-theme="light"] .theme .moon{opacity:1; transform:none}
[data-theme="dark"] .theme .moon{opacity:0; transform:rotate(-90deg) scale(.4)}
[data-theme="dark"] .theme .sun{opacity:1; transform:none}
.btn-new{
display:inline-flex; align-items:center; gap:7px; border:none; cursor:pointer;
background:#fff; color:#13202e; font-weight:700; font-size:13.5px;
padding:10px 16px; border-radius:999px; white-space:nowrap;
box-shadow:0 2px 8px rgba(0,0,0,.25);
transition:transform .15s, box-shadow .15s;
}
.btn-new:hover{transform:translateY(-1px); box-shadow:0 6px 16px rgba(0,0,0,.32)}
.btn-new:active{transform:translateY(0) scale(.98)}
.stripe{height:3px; position:relative; z-index:1;
background:linear-gradient(90deg,var(--todo) 0 33.4%,var(--doing) 0 66.7%,var(--done) 0)}
/* ---------- layout ---------- */
.wrap{position:relative; z-index:1; max-width:1240px; margin:0 auto; padding:18px 22px 56px}
.statsbar{display:flex; align-items:center; gap:16px; flex-wrap:wrap; margin-bottom:16px}
.stats-text{display:flex; align-items:center; gap:12px; flex-wrap:wrap; font-size:12.5px; color:var(--muted)}
.stats-text b{font-family:var(--mono); color:var(--ink); font-size:13px}
.chip{display:inline-flex; align-items:center; gap:6px; font-family:var(--mono); font-size:11.5px}
.dot{width:8px; height:8px; border-radius:50%; display:inline-block}
.d-todo{background:var(--todo)} .d-doing{background:var(--doing)} .d-done{background:var(--done)}
.meter{flex:1 1 140px; min-width:110px; height:8px; border-radius:99px; overflow:hidden; display:flex;
background:var(--panel); border:1px solid var(--line)}
.meter i{display:block; height:100%; width:0; transition:width .5s cubic-bezier(.22,.9,.35,1)}
.m-todo{background:var(--todo)} .m-doing{background:var(--doing)} .m-done{background:var(--done)}
.pct{font-family:var(--mono); font-size:12px; color:var(--muted); white-space:nowrap}
.board{display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:16px; align-items:start}
@media(max-width:880px){.board{grid-template-columns:1fr}}
.col{
background:var(--panel); border:1px solid var(--line); border-radius:14px; padding:10px;
transition:background-color .3s, border-color .2s, box-shadow .2s;
}
.col.drop-hover{border-color:var(--accent); box-shadow:0 0 0 3px var(--focus)}
.col-head{display:flex; align-items:center; gap:9px; padding:4px 6px 12px}
.col-ico{width:18px; height:18px; flex:none}
.col-head h2{font-size:12px; letter-spacing:.1em; text-transform:uppercase; font-weight:700; color:var(--muted)}
.count{
margin-left:auto; font-family:var(--mono); font-size:11.5px; font-weight:700; color:var(--ink);
background:var(--surface); border:1px solid var(--line); padding:2px 9px; border-radius:999px;
transition:background-color .3s, border-color .3s, color .3s;
}
.count.pop{animation:pop .35s ease}
@keyframes pop{40%{transform:scale(1.28)}}
.col-body{display:flex; flex-direction:column; gap:10px; min-height:72px; border-radius:10px; padding:2px;
transition:background-color .2s}
.col.drop-hover .col-body{background:var(--accent-soft)}
/* ---------- cards ---------- */
.card{
position:relative; background:var(--surface); border:1px solid var(--line); border-radius:11px;
padding:12px 14px 12px 18px; box-shadow:var(--shadow); cursor:grab; user-select:none;
transition:transform .16s ease, box-shadow .16s ease, opacity .22s ease,
background-color .3s, border-color .3s, height .26s ease, padding .26s ease;
}
.card:hover{transform:translateY(-2px); box-shadow:var(--shadow-lift)}
.card::before{content:""; position:absolute; left:7px; top:11px; bottom:11px; width:3.5px;
border-radius:4px; background:var(--pc)}
.card[data-prio="high"]{--pc:var(--high)}
.card[data-prio="medium"]{--pc:var(--med)}
.card[data-prio="low"]{--pc:var(--low)}
.card-title{font-size:14px; font-weight:600; line-height:1.4; padding-right:22px; margin-bottom:10px}
.card-title mark{background:var(--mark); color:inherit; border-radius:3px; padding:0 1px}
.card-meta{display:flex; align-items:center; gap:9px}
.badge{font-family:var(--mono); font-size:10px; font-weight:700; letter-spacing:.09em;
padding:3px 9px; border-radius:999px}
.badge.high{background:var(--high); color:var(--high-fg)}
.badge.medium{background:var(--med); color:var(--med-fg)}
.badge.low{background:var(--low); color:var(--low-fg)}
.card-id,.card-age{font-family:var(--mono); font-size:11px; color:var(--muted)}
.card-age{margin-left:auto}
.del{
position:absolute; top:7px; right:7px; width:26px; height:26px; border:none; border-radius:8px;
display:grid; place-items:center; background:transparent; color:var(--muted); cursor:pointer;
opacity:0; transition:opacity .15s, background-color .15s, color .15s, transform .15s;
}
.card:hover .del, .del:focus-visible{opacity:1}
.del:hover{background:rgba(216,64,71,.14); color:var(--high); transform:scale(1.08)}
@media(hover:none){.del{opacity:.75}}
.card.collapsing{opacity:0; padding-top:0; padding-bottom:0; overflow:hidden; transform:scale(.95)}
.card.enter{animation:enter .4s both ease}
@keyframes enter{from{opacity:0; transform:translateY(10px) scale(.97)}}
.card.settle{animation:settle .32s ease}
@keyframes settle{0%{transform:scale(1.045)}100%{transform:scale(1)}}
.empty{border:1.5px dashed var(--line); border-radius:10px; padding:18px 12px; text-align:center;
color:var(--muted); font-size:12.5px}
/* ---------- drag ---------- */
.placeholder{border:2px dashed var(--accent); border-radius:11px; background:var(--accent-soft);
animation:ph 1.1s ease-in-out infinite}
@keyframes ph{50%{opacity:.5}}
body.is-dragging{cursor:grabbing; user-select:none}
body.is-dragging .card:not(.card.drag-clone){transform:none !important; cursor:grabbing}
.card.drag-clone{
position:fixed; z-index:1000; pointer-events:none; margin:0; cursor:grabbing;
transform:rotate(2.5deg) scale(1.03); box-shadow:0 18px 40px rgba(10,20,35,.38);
transition:none; animation:none;
}
.card.drag-clone .del{display:none}
/* ---------- modal ---------- */
.modal{
position:fixed; inset:0; z-index:50; display:grid; place-items:center; padding:20px;
background:rgba(8,14,22,.55); backdrop-filter:blur(3px);
opacity:0; visibility:hidden; transition:opacity .22s, visibility .22s;
}
.modal.open{opacity:1; visibility:visible}
.modal-panel{
width:min(430px,100%); background:var(--surface); border:1px solid var(--line); border-radius:16px;
padding:22px; box-shadow:0 24px 60px rgba(5,12,24,.35);
transform:translateY(14px) scale(.96);
transition:transform .28s cubic-bezier(.2,.9,.3,1.15), background-color .3s, border-color .3s;
}
.modal.open .modal-panel{transform:none}
.modal-panel.shake{animation:shake .35s}
@keyframes shake{20%{transform:translateX(-8px)}45%{transform:translateX(6px)}70%{transform:translateX(-4px)}90%{transform:translateX(2px)}}
.m-head{display:flex; align-items:center; justify-content:space-between; margin-bottom:14px}
.m-head h3{font-size:17px; font-weight:800; letter-spacing:-.01em}
.icon-btn.close{border:1px solid var(--line); color:var(--muted); width:34px; height:34px}
.icon-btn.close:hover{background:var(--panel); color:var(--ink)}
.lbl{display:block; font-size:11.5px; font-weight:700; letter-spacing:.08em; text-transform:uppercase;
color:var(--muted); margin:14px 0 6px}
.txt{
width:100%; padding:10px 12px; border-radius:10px; outline:none; font:inherit; font-size:14px;
border:1.5px solid var(--line); background:var(--panel); color:var(--ink);
transition:border-color .15s, box-shadow .15s, background-color .3s, color .3s;
}
.txt:focus{border-color:var(--accent); box-shadow:0 0 0 3px var(--focus)}
.field.error .txt{border-color:var(--high); box-shadow:0 0 0 3px rgba(216,64,71,.18)}
.err{display:none; color:var(--high); font-size:12px; margin-top:6px}
.field.error .err{display:block}
.seg{display:grid; grid-template-columns:repeat(3,1fr); gap:4px; padding:4px; border-radius:10px;
background:var(--panel); border:1px solid var(--line)}
.seg label{position:relative; text-align:center}
.seg input{position:absolute; opacity:0; inset:0; cursor:pointer}
.seg span{display:block; padding:7px 4px; border-radius:7px; font-size:12.5px; font-weight:600;
color:var(--muted); cursor:pointer; transition:background-color .15s, color .15s, box-shadow .15s}
.seg span:hover{color:var(--ink)}
.seg input[value="high"]:checked + span{background:var(--high); color:var(--high-fg)}
.seg input[value="medium"]:checked + span{background:var(--med); color:var(--med-fg)}
.seg input[value="low"]:checked + span{background:var(--low); color:var(--low-fg)}
.seg input:focus-visible + span{box-shadow:0 0 0 3px var(--focus)}
.selwrap{position:relative}
.selwrap select{appearance:none; padding-right:34px; cursor:pointer}
.selwrap .chev{position:absolute; right:11px; top:50%; transform:translateY(-50%);
color:var(--muted); pointer-events:none}
.m-actions{display:flex; justify-content:flex-end; gap:10px; margin-top:22px}
.btn{font-weight:700; font-size:13.5px; padding:10px 16px; border-radius:10px; cursor:pointer;
border:1.5px solid transparent; transition:transform .12s, box-shadow .15s, color .15s, border-color .15s}
.btn:active{transform:scale(.97)}
.btn-ghost{background:transparent; border-color:var(--line); color:var(--muted)}
.btn-ghost:hover{color:var(--ink); border-color:var(--muted)}
.btn-primary{background:var(--ink); color:var(--bg)}
.btn-primary:hover{transform:translateY(-1px); box-shadow:0 6px 14px rgba(10,20,35,.3)}
/* ---------- toast & footer ---------- */
.toast{
position:fixed; left:50%; bottom:26px; z-index:60; transform:translate(-50%,16px);
background:var(--ink); color:var(--bg); font-family:var(--mono); font-size:12.5px;
padding:10px 16px; border-radius:10px; box-shadow:0 10px 30px rgba(0,0,0,.3);
opacity:0; pointer-events:none; transition:opacity .25s, transform .25s;
}
.toast.show{opacity:1; transform:translate(-50%,0)}
.foot{margin-top:24px; display:flex; justify-content:space-between; gap:10px; flex-wrap:wrap;
font-family:var(--mono); font-size:11px; color:var(--muted)}
@media(max-width:640px){
.topbar{padding:10px 14px; gap:10px}
.top-tools{width:100%; flex-wrap:wrap; justify-content:flex-start}
.search{flex:1 1 170px}
.btn-new{margin-left:auto}
.wrap{padding:14px 14px 48px}
}
@media(prefers-reduced-motion:reduce){
*,*::before,*::after{animation-duration:.01ms !important; transition-duration:.01ms !important}
}
</style>
</head>
<body>
<header class="topbar">
<div class="brand">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<rect x="3" y="4" width="5" height="12" rx="2" fill="var(--todo)"/>
<rect x="9.5" y="4" width="5" height="16" rx="2" fill="var(--doing)"/>
<rect x="16" y="4" width="5" height="8" rx="2" fill="var(--done)"/>
</svg>
<span class="brand-name">FLOWDECK</span>
<span class="sprint-tag">SPRINT 14 · WK 2</span>
</div>
<div class="top-tools">
<div class="search" id="searchWrap">
<svg class="mag" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"><circle cx="11" cy="11" r="7"/><path d="m20 20-3.6-3.6"/></svg>
<input id="search" type="text" placeholder="Search cards…" aria-label="Search cards by title" autocomplete="off">
<button class="clear" id="clearSearch" aria-label="Clear search" type="button">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round"><path d="M6 6l12 12M18 6L6 18"/></svg>
</button>
</div>
<button class="icon-btn theme" id="themeBtn" aria-label="Toggle light / dark theme" title="Toggle theme" type="button">
<svg class="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="sun" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="12" cy="12" r="4.5"/><path d="M12 2v2.5M12 19.5V22M2 12h2.5M19.5 12H22M4.6 4.6l1.8 1.8M17.6 17.6l1.8 1.8M19.4 4.6l-1.8 1.8M6.4 17.6l-1.8 1.8"/></svg>
</button>
<button class="btn-new" id="newBtn" type="button">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round"><path d="M12 5v14M5 12h14"/></svg>
New card
</button>
</div>
</header>
<div class="stripe" aria-hidden="true"></div>
<main class="wrap">
<div class="statsbar">
<div class="stats-text" id="statsText"></div>
<div class="meter" aria-hidden="true"><i class="m-todo" id="mTodo"></i><i class="m-doing" id="mDoing"></i><i class="m-done" id="mDone"></i></div>
<span class="pct" id="pct"></span>
</div>
<div class="board" id="board">
<section class="col" data-col="todo">
<header class="col-head">
<svg class="col-ico" viewBox="0 0 16 16" aria-hidden="true"><circle cx="8" cy="8" r="6" fill="none" stroke="var(--todo)" stroke-width="2"/></svg>
<h2>To do</h2>
<span class="count" id="count-todo">0</span>
</header>
<div class="col-body" data-col="todo" id="body-todo"></div>
</section>
<section class="col" data-col="doing">
<header class="col-head">
<svg class="col-ico" viewBox="0 0 16 16" aria-hidden="true"><circle cx="8" cy="8" r="6" fill="none" stroke="var(--doing)" stroke-width="2"/><path d="M8 2a6 6 0 0 1 0 12z" fill="var(--doing)"/></svg>
<h2>In progress</h2>
<span class="count" id="count-doing">0</span>
</header>
<div class="col-body" data-col="doing" id="body-doing"></div>
</section>
<section class="col" data-col="done">
<header class="col-head">
<svg class="col-ico" viewBox="0 0 16 16" aria-hidden="true"><circle cx="8" cy="8" r="7" fill="var(--done)"/><path d="M4.8 8.3 7 10.5l4.2-4.6" stroke="#fff" stroke-width="1.8" fill="none" stroke-linecap="round" stroke-linejoin="round"/></svg>
<h2>Done</h2>
<span class="count" id="count-done">0</span>
</header>
<div class="col-body" data-col="done" id="body-done"></div>
</section>
</div>
<div class="foot">
<span>drag cards between columns · press / to search · hover a card to delete</span>
<span>in-memory demo — refresh resets the board</span>
</div>
</main>
<div class="modal" id="modal" role="dialog" aria-modal="true" aria-labelledby="modalTitle" aria-hidden="true">
<form class="modal-panel" id="cardForm" novalidate>
<div class="m-head">
<h3 id="modalTitle">New card</h3>
<button type="button" class="icon-btn close" id="closeModal" aria-label="Close dialog">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round"><path d="M6 6l12 12M18 6L6 18"/></svg>
</button>
</div>
<div class="field" id="titleField">
<label class="lbl" for="fTitle">Title</label>
<input class="txt" id="fTitle" type="text" maxlength="90" autocomplete="off" placeholder="e.g. Fix login timeout on mobile">
<p class="err">A title is required.</p>
</div>
<span class="lbl">Priority</span>
<div class="seg" role="radiogroup" aria-label="Priority">
<label><input type="radio" name="prio" value="low"><span>Low</span></label>
<label><input type="radio" name="prio" value="medium" checked><span>Medium</span></label>
<label><input type="radio" name="prio" value="high"><span>High</span></label>
</div>
<label class="lbl" for="fCol">Column</label>
<div class="selwrap">
<select class="txt" id="fCol">
<option value="todo">To do</option>
<option value="doing">In progress</option>
<option value="done">Done</option>
</select>
<svg class="chev" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="m6 9 6 6 6-6"/></svg>
</div>
<div class="m-actions">
<button type="button" class="btn btn-ghost" id="cancelBtn">Cancel</button>
<button type="submit" class="btn btn-primary">Add card</button>
</div>
</form>
</div>
<div class="toast" id="toast" role="status" aria-live="polite"></div>
<script>
(function(){
'use strict';
var COLS = [{id:'todo',label:'To do'},{id:'doing',label:'In progress'},{id:'done',label:'Done'}];
var COL_LABEL = {todo:'To do', doing:'In progress', done:'Done'};
var PRIO_LABEL = {low:'Low', medium:'Medium', high:'High'};
/* ---------------- state (memory only) ---------------- */
var cards = [
{id:'TS-101', title:'Fix checkout redirect loop on Safari', prio:'high', col:'todo', age:'2d'},
{id:'TS-102', title:'Draft release notes for v2.4', prio:'medium', col:'todo', age:'1d'},
{id:'TS-103', title:'Compare onboarding survey tools', prio:'low', col:'todo', age:'4d'},
{id:'TS-104', title:'Redesign dashboard empty states', prio:'medium', col:'doing', age:'3d'},
{id:'TS-105', title:'Migrate auth service to short-lived tokens', prio:'high', col:'doing', age:'6h'},
{id:'TS-106', title:'Ship dark mode on the marketing site', prio:'low', col:'done', age:'1d'}
];
var query = '';
var nextNum = 107;
var firstRender = true;
var highlightId = null;
/* ---------------- dom refs ---------------- */
var board = document.getElementById('board');
var statsText = document.getElementById('statsText');
var pctEl = document.getElementById('pct');
var mTodo=document.getElementById('mTodo'), mDoing=document.getElementById('mDoing'), mDone=document.getElementById('mDone');
var searchInput= document.getElementById('search');
var searchWrap = document.getElementById('searchWrap');
var clearBtn = document.getElementById('clearSearch');
var themeBtn = document.getElementById('themeBtn');
var modal = document.getElementById('modal');
var cardForm = document.getElementById('cardForm');
var titleField = document.getElementById('titleField');
var fTitle = document.getElementById('fTitle');
var fCol = document.getElementById('fCol');
var toastEl = document.getElementById('toast');
var panel = cardForm;
var lastFocus = null;
var root = document.documentElement;
var TRASH = '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18M8 6V4a1 1 0 0 1 1-1h6a1 1 0 0 1 1 1v2m3 0-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6M14 11v6"/></svg>';
/* ---------------- render ---------------- */
function render(){
var q = query.trim().toLowerCase();
var totals = {todo:0, doing:0, done:0};
var stagger = 0;
COLS.forEach(function(c){
var body = document.getElementById('body-'+c.id);
body.innerHTML = '';
var vis = 0;
cards.forEach(function(card){
if(card.col !== c.id) return;
totals[c.id]++;
var hit = !q || card.title.toLowerCase().indexOf(q) !== -1;
if(!hit) return;
vis++;
body.appendChild(makeCard(card, q, stagger++));
});
if(vis === 0){
var d = document.createElement('div');
d.className = 'empty';
d.textContent = q ? ('No cards match \u201C' + query.trim() + '\u201D') : 'Drop cards here';
body.appendChild(d);
}
var cnt = document.getElementById('count-'+c.id);
var label = q ? (vis + '/' + totals[c.id]) : String(totals[c.id]);
if(cnt.textContent !== label){
cnt.textContent = label;
cnt.classList.remove('pop'); void cnt.offsetWidth; cnt.classList.add('pop');
}
});
updateStats(totals);
firstRender = false;
highlightId = null;
}
function makeCard(card, q, idx){
var el = document.createElement('article');
el.className = 'card' + (firstRender ? ' enter' : '');
el.dataset.id = card.id;
el.dataset.prio = card.prio;
el.tabIndex = 0;
el.setAttribute('aria-label', card.title);
if(card.id === highlightId) el.classList.add('settle');
if(firstRender) el.style.animationDelay = (idx * 60) + 'ms';
el.innerHTML =
'<button class="del" type="button" title="Delete card" aria-label="Delete card ' + card.id + '">' + TRASH + '</button>' +
'<h3 class="card-title"></h3>' +
'<div class="card-meta">' +
'<span class="badge ' + card.prio + '">' + PRIO_LABEL[card.prio] + '</span>' +
'<span class="card-id">' + card.id + '</span>' +
'<span class="card-age">' + card.age + '</span>' +
'</div>';
var t = el.querySelector('.card-title');
var i = q ? card.title.toLowerCase().indexOf(q) : -1;
if(i >= 0){
t.appendChild(document.createTextNode(card.title.slice(0, i)));
var m = document.createElement('mark');
m.textContent = card.title.slice(i, i + q.length);
t.appendChild(m);
t.appendChild(document.createTextNode(card.title.slice(i + q.length)));
} else {
t.textContent = card.title;
}
return el;
}
function updateStats(t){
var total = t.todo + t.doing + t.done;
statsText.innerHTML =
'<span><b>' + total + '</b> cards</span>' +
'<span class="chip"><i class="dot d-todo"></i>' + t.todo + ' queued</span>' +
'<span class="chip"><i class="dot d-doing"></i>' + t.doing + ' in flight</span>' +
'<span class="chip"><i class="dot d-done"></i>' + t.done + ' done</span>';
mTodo.style.width = (total ? t.todo / total * 100 : 0) + '%';
mDoing.style.width = (total ? t.doing / total * 100 : 0) + '%';
mDone.style.width = (total ? t.done / total * 100 : 0) + '%';
pctEl.textContent = total ? Math.round(t.done / total * 100) + '% done' : '\u2014';
}
/* ---------------- search ---------------- */
searchInput.addEventListener('input', function(){
query = searchInput.value;
searchWrap.classList.toggle('has', !!query);
render();
});
clearBtn.addEventListener('click', function(){
searchInput.value = ''; query = '';
searchWrap.classList.remove('has');
render(); searchInput.focus();
});
/* ---------------- theme ---------------- */
if(window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches){
root.dataset.theme = 'dark';
}
themeBtn.addEventListener('click', function(){
root.dataset.theme = (root.dataset.theme === 'dark') ? 'light' : 'dark';
});
/* ---------------- modal ---------------- */
function openModal(){
modal.classList.add('open');
modal.setAttribute('aria-hidden','false');
lastFocus = document.activeElement;
setTimeout(function(){ fTitle.focus(); }, 60);
}
function closeModal(){
modal.classList.remove('open');
modal.setAttribute('aria-hidden','true');
cardForm.reset();
titleField.classList.remove('error');
if(lastFocus && lastFocus.focus) lastFocus.focus();
}
document.getElementById('newBtn').addEventListener('click', openModal);
document.getElementById('cancelBtn').addEventListener('click', closeModal);
document.getElementById('closeModal').addEventListener('click', closeModal);
modal.addEventListener('mousedown', function(e){ if(e.target === modal) closeModal(); });
fTitle.addEventListener('input', function(){
if(fTitle.value.trim()) titleField.classList.remove('error');
});
cardForm.addEventListener('submit', function(e){
e.preventDefault();
var title = fTitle.value.trim();
if(!title){
titleField.classList.add('error');
panel.classList.remove('shake'); void panel.offsetWidth; panel.classList.add('shake');
fTitle.focus();
return;
}
var fd = new FormData(cardForm);
var prio = fd.get('prio') || 'medium';
var col = fCol.value;
var id = 'TS-' + (nextNum++);
cards.push({id:id, title:title, prio:prio, col:col, age:'now'});
highlightId = id;
render();
closeModal();
toast(id + ' added to ' + COL_LABEL[col]);
});
/* ---------------- delete ---------------- */
board.addEventListener('click', function(e){
var btn = e.target.closest('.del');
if(!btn) return;
var el = btn.closest('.card');
if(!el || el.dataset.removing) return;
el.dataset.removing = '1';
el.style.height = el.offsetHeight + 'px';
el.style.pointerEvents = 'none';
requestAnimationFrame(function(){
el.classList.add('collapsing');
el.style.height = '0px';
});
var id = el.dataset.id;
setTimeout(function(){
var i = cards.findIndex(function(c){ return c.id === id; });
if(i > -1) cards.splice(i, 1);
render();
toast(id + ' deleted');
}, 270);
});
board.addEventListener('keydown', function(e){
if((e.key === 'Delete' || e.key === 'Backspace') && e.target.classList && e.target.classList.contains('card')){
var btn = e.target.querySelector('.del');
if(btn){ e.preventDefault(); btn.click(); }
}
});
/* ---------------- drag & drop ---------------- */
var drag = null;
var placeholder = document.createElement('div');
placeholder.className = 'placeholder';
board.addEventListener('pointerdown', function(e){
if(e.pointerType !== 'mouse' || e.button !== 0) return;
var cardEl = e.target.closest('.card');
if(!cardEl || e.target.closest('.del') || cardEl.dataset.removing) return;
var r = cardEl.getBoundingClientRect();
drag = {
id: cardEl.dataset.id, cardEl: cardEl,
sx: e.clientX, sy: e.clientY,
dx: e.clientX - r.left, dy: e.clientY - r.top,
w: r.width, h: r.height,
started: false, clone: null
};
window.addEventListener('pointermove', onMove);
window.addEventListener('pointerup', onUp);
window.addEventListener('pointercancel', abortDrag);
});
function onMove(e){
if(!drag) return;
if(!drag.started){
if(Math.hypot(e.clientX - drag.sx, e.clientY - drag.sy) < 5) return;
startDrag();
}
drag.clone.style.left = (e.clientX - drag.dx) + 'px';
drag.clone.style.top = (e.clientY - drag.dy) + 'px';
var under = document.elementsFromPoint(e.clientX, e.clientY);
var colEl = null;
for(var k = 0; k < under.length; k++){
if(under[k].closest){ colEl = under[k].closest('.col'); if(colEl) break; }
}
if(colEl) positionPlaceholder(colEl.querySelector('.col-body'), e.clientY);
}
function startDrag(){
var el = drag.cardEl;
var clone = el.cloneNode(true);
clone.classList.add('drag-clone');
clone.classList.remove('enter','settle');
clone.style.width = drag.w + 'px';
clone.style.left = (drag.sx - drag.dx) + 'px';
clone.style.top = (drag.sy - drag.dy) + 'px';
document.body.appendChild(clone);
drag.clone = clone;
placeholder.style.height = drag.h + 'px';
el.style.display = 'none';
el.parentNode.insertBefore(placeholder, el);
document.body.classList.add('is-dragging');
drag.started = true;
}
function positionPlaceholder(body, y){
var targetCol = body.closest('.col');
document.querySelectorAll('.col.drop-hover').forEach(function(c){
if(c !== targetCol) c.classList.remove('drop-hover');
});
targetCol.classList.add('drop-hover');
var sibs = Array.prototype.filter.call(body.querySelectorAll('.card'), function(c){
return c !== drag.cardEl;
});
var before = null;
for(var j = 0; j < sibs.length; j++){
var r = sibs[j].getBoundingClientRect();
if(y < r.top + r.height / 2){ before = sibs[j]; break; }
}
var emp = body.querySelector('.empty');
if(emp) emp.remove();
if(before) body.insertBefore(placeholder, before);
else body.appendChild(placeholder);
}
function onUp(){
window.removeEventListener('pointermove', onMove);
window.removeEventListener('pointerup', onUp);
window.removeEventListener('pointercancel', abortDrag);
if(!drag) return;
var d = drag; drag = null;
document.querySelectorAll('.col.drop-hover').forEach(function(c){ c.classList.remove('drop-hover'); });
if(!d.started) return; // plain click, nothing moved
var body = placeholder.parentElement;
var colId = body ? body.dataset.col : null;
var insertIdx = 0;
if(body){
for(var n = body.children, i = 0; i < n.length; i++){
if(n[i] === placeholder) break;
if(n[i].classList.contains('card') && n[i] !== d.cardEl) insertIdx++;
}
}
placeholder.remove();
if(d.clone) d.clone.remove();
d.cardEl.style.display = '';
document.body.classList.remove('is-dragging');
if(!colId){ render(); return; }
moveCard(d.id, colId, insertIdx);
highlightId = d.id;
render();
}
function abortDrag(){
if(!drag) return;
var d = drag; drag = null;
window.removeEventListener('pointermove', onMove);
window.removeEventListener('pointerup', onUp);
window.removeEventListener('pointercancel', abortDrag);
document.querySelectorAll('.col.drop-hover').forEach(function(c){ c.classList.remove('drop-hover'); });
if(d.clone) d.clone.remove();
placeholder.remove();
d.cardEl.style.display = '';
document.body.classList.remove('is-dragging');
}
window.addEventListener('blur', abortDrag);
function moveCard(id, col, visIdx){
var i = cards.findIndex(function(c){ return c.id === id; });
if(i < 0) return;
var card = cards.splice(i, 1)[0];
card.col = col;
var q = query.trim().toLowerCase();
var visInCol = cards.filter(function(c){
return c.col === col && (!q || c.title.toLowerCase().indexOf(q) !== -1);
});
var at;
if(visIdx < visInCol.length){
at = cards.indexOf(visInCol[visIdx]);
} else if(visInCol.length){
at = cards.indexOf(visInCol[visInCol.length - 1]) + 1;
} else {
var last = -1;
cards.forEach(function(c, j){ if(c.col === col) last = j; });
at = last >= 0 ? last + 1 : cards.length;
}
cards.splice(at, 0, card);
}
/* ---------------- toast & shortcuts ---------------- */
var toastTimer;
function toast(msg){
toastEl.textContent = msg;
toastEl.classList.add('show');
clearTimeout(toastTimer);
toastTimer = setTimeout(function(){ toastEl.classList.remove('show'); }, 2200);
}
window.addEventListener('keydown', function(e){
if(e.key === 'Escape'){
if(modal.classList.contains('open')) closeModal();
else if(drag) abortDrag();
return;
}
if(e.key === '/' && !modal.classList.contains('open')){
var a = document.activeElement;
var tag = a ? a.tagName : '';
if(tag !== 'INPUT' && tag !== 'TEXTAREA' && tag !== 'SELECT'){
e.preventDefault();
searchInput.focus();
}
}
});
render();
})();
</script>
</body>
</html>Reasoning trace (26,833 tokens)
Let's build an interactive Kanban board demo. Vanilla HTML/CSS/JS, single file. Requirements:
1. Three columns: "To do" "In progress" "Done", each showing a card count.
2. Pre-seeded board with 6 realistic cards distributed across the columns.
3. Each card has a title and a priority (low/medium/high) displayed as a colored badge.
4. Add cards via a small modal form (title required), delete cards via a button on the card.
5. Mouse-based drag & drop between columns, with clear visual drop indicators.
6. A search field that live-filters cards by title.
7. Light/dark theme toggle.
8. State in memory only.
Design considerations: I want something rich, lively and distinctive rather than the usual default look. Instead of a generic heading/subtitle pair, let the board itself be the star and open with a compact, distinctive toolbar/header (Kanban app). I'll combine a distinctive display treatment with a readable body typeface — no external fonts allowed (no network requests, except permitted CDN URLs, but there is none here). So I'll have to use system font stacks, but I can combine them distinctively: e.g. a strong grotesque-ish stack for display, a different stack for body. Use system-ui for body, and something like "Georgia" or a monospace for accents? Kanban board — could get a "work tool" aesthetic: strong bold sans-serif headings, tabular numerals. Let's pick:
- Display stack: `"Avenir Next", "Segoe UI Variable Display", "Trebuchet MS", ...` hmm. Better idea: use a distinctive combo: `ui-rounded`? Let's just use two stacks: headings/brand as a heavy weight condensed-ish stack: `"Arial Narrow", "Helvetica Neue Condensed"...` risky. Alternative: monospace for the count badges and meta (ui-monospace, "SF Mono", "Cascadia Code", Consolas) — gives a crafted feel. Headings use a heavy-weight system-ui with tight tracking. Body same family but regular. This achieves contrast via weight/size. A serif display could also be nice, but a warm-paper aesthetic might feel off. Let's do a "workbench" style: light theme in warm paper white with ink-navy text, accents in teal/orange?
Palette proposal: I'll steer away from cream/beige + terracotta + serif, and from the near-black + neon single-accent look. A polychromatic scheme: deep green header, warm off-white board? Let me think.
Distinctive idea: "mission-control" Kanban: deep teal/pine header bar, board background in soft sage or warm light-gray dotted grid (dot-grid paper vibe, like a planning board). Columns as slightly tinted panels. Priorities: low = green, medium = amber, high = red/coral. Accent for interactive elements: bright tangerine? Let me pick a tri-color scheme:
Light theme:
- Background: a cool light gray-green, or a pale "blueprint" gray-blue with a dot grid: #eef1f4.
- Ink: #17242e (deep slate).
- Header accent: strong teal #0f766e-ish? Or a "forest" green #14532d?
- Cards: white with colored left spine per priority.
- Per-column accent: To do = blue, In progress = amber, Done = green? This is classic. Use saturated but not neon: todo #3b82f6 is a generic default blue. I'll go with teal (#0d9488), amber (#d97706), green (#16a34a). Teal/amber/green reads nicely.
Dark theme: deep blue-slate charcoal (#131a22), multiple accent colors (per-column hues plus priority colors). That works well.
Typography:
- Brand/display: heavier weight, tight tracking, maybe uppercase.
- Mono for counters/meta: `ui-monospace, "SF Mono", "Cascadia Mono", "Roboto Mono", Menlo, monospace`.
Layout: header bar with brand + search + add button + theme toggle. Below it: 3-column grid (collapses to vertical scroll on mobile). Column header: color dot / label, count badge. Cards: title, priority badge, delete button (appears on hover, always visible on touch). Drop indicators: while dragging, the column highlights and a gap/line is shown at the insertion position.
Implementation details:
Data model: array of cards {id, title, priority, col}. Columns: todo, doing, done. Counts update accordingly.
Rendering: fully re-render columns on state change, but during drag we need live insertion indicators — handle that with a placeholder element. Use HTML5 drag events? Requirement says mouse drag & drop; HTML5 DnD works with mouse. But custom pointer-based drag often looks better (dragged card follows cursor, a placeholder shows target slot). Pointer events also allow touch. I'll implement pointer-based drag:
- On pointerdown on card (except on delete button), after a small movement threshold start dragging: set a dragging class on the original card, create a floating clone (position fixed, following pointer), and a placeholder element.
- On pointermove: move clone; determine column under pointer (elementsFromPoint), and insertion index based on pointer y relative to midpoints of cards in that column's list. Move placeholder there. Highlight target column (.drop-target).
- On pointerup: commit the move: move card to the new column at placeholder index; animate the clone back? Simpler: after drop, briefly animate the drop (a pop) and re-render. Reordering within same column is also handled naturally.
- Cancel on Esc / pointercancel.
While dragging, set `document.body.classList.add('dragging')` and set `user-select:none`.
Placeholder: a dashed outline slot sized to card height. `.placeholder { border:2px dashed accent; background: accent/10%; border-radius; height }`. Clear visual indicator ✓.
Search filter: cards whose titles don't match are hidden (display none) — also filter during drag? Keep simple: hidden cards don't participate in drop index calculation. Count: show visible count? The count should reflect actual cards; maybe show "visible/total" while filtering. Let's have count reflect total cards in column, and while searching show "n/m"? Keep simple: count = total; when filtering, show number of matches? Requirement: "each showing card count". The card count in the column — let's show the total count; when searching, show visible/total as e.g. "2/5". This is a nice touch and unambiguous.
Modal: open with a "+ New card" button. Fields: title (required), priority (segmented radio), column (select). Validate title non-empty; show inline error, shake animation. Close on Esc, close on backdrop click, basic focus management (focus title on open, return focus). On submit, add the card with entrance animation.
Delete: button (×) on the card; on click, animate the card collapse and then remove.
Theme toggle: button with sun/moon icons; toggle `data-theme` on `<html>`, CSS custom properties for all colors; transition colors smoothly. Initially respect prefers-color-scheme.
Toast? Could add a small toast "Card added" — extra liveliness. Optional; keep but lightweight.
Search highlight? Could highlight matched substring within titles — nice micro detail. Implement by wrapping match in <mark>. Beware HTML escaping: escape title then insert mark. Build with DOM API: build text nodes and mark element programmatically — safer.
Empty state per column: show a subtle "Drop cards here" when empty (and "No matches" while search filters everything out).
Accessibility: aria-labels, keyboard? Not required but can add a minimum: buttons focusable, modal role dialog, close on Esc. Maybe don't mention.
Header design: it's an app, so open with the board toolbar. Brand: "FLOWBOARD"? Give it a distinctive name: "TRACKDECK"? Let's call it "Shipmate" or "Deckflow". "KANBAN // SPRINT 14"? Include a meta like "Sprint 14 · 6 tasks". A small status line "3 open · 2 in flight · 1 shipped"? Nice: header shows brand, search, theme toggle, add button, plus a small live-summary strip. Keep header compact: left: logomark (a small SVG of 3 bars in column colors) + "Deckflow" wordmark + sprint tag; right: search input, theme toggle, + New card button.
Visual liveliness: dot-grid background on the board area, subtle column hover lift, card hover lift, badge with a dot, count pill that pulses on change, progress meter in header showing distribution as a stacked bar (todo/doing/done segments animate width). The stacked progress bar is a distinctive, lively element tied to the state.
Card design: white surface, 4px left spine in priority color? Or top-left badge. Include: title (readable weight), footer row with priority badge (dot + label) and a faint id like "TSK-104" (monospace, small) for realism, plus a delete button top-right (on hover, and always on focus-visible). Maybe also a small "age" like "2d" — adds realism: small meta "2d" in monospace. Let's add a created label like "Mar 3"? Realistic cards: titles like "Fix checkout redirect bug" "Draft Q3 launch announcement" etc. Priority: high/medium/low with distinct colors: high = red/coral, medium = amber, low = green or gray-blue. Colors: high #e5484d, medium #f5a524 (amber), low #30a46c? But if column colors are also teal/amber/green there may be collision with priority colors. Alternate approach: use neutral hue for column accents, priorities carry color. Column accents: To do = slate/blue-gray, In progress = violet? Or instead: column accents as simple colored dots: todo #64748b (slate), doing #e0a10c? Hmm.
Let me choose:
- Column accents: To do = #5b6b7c (steel blue-gray), In progress = #e28413 (orange), Done = #2f9e68 (green).
- Priority badges: high = red (#d64550), medium = amber (#e29b0c)... orange collides with doing. Let me shift medium to yellowish, high to crimson, low to cyan/blue? Priorities: low = #3b82f6-ish blue...
Alternative: give the badge a colored background; priorities: high = crimson, medium = amber, low = slate/teal. And column headers use an underline/tab marker in a neutral strong color per column: e.g. blue for todo, orange for progress, green for done — classic and clear. medium amber vs doing orange collision is somewhat acceptable but let's differentiate: doing = violet? Or pick doing = bright orange #f97316 and medium badge = #d97706 dark amber; too close. Let me use violet #8b5cf6? OK. Doing = #7c5cff?
Let me define a consistent palette:
Light:
- bg: #f2f4f7 with dot grid #d8dde4? Slightly warmer: #f4f5f2 with dots #d9dcd6. Let's do cool paper: #eef0f3, dots #d3d8de.
- surface: #ffffff
- ink: #1b2430
- muted: #5c6675
- header: deep slate/ink bar? A dark header bar even in light mode gives contrast and character: header bg #16324a (deep petrol), white text, accents. That's distinctive. Brand color accent: petrol/teal #0e7a6c? Let's make it header #12303e deep petrol, accent #2dd4a7? Hmm.
Let me commit:
- Primary accent (buttons, focus): #0f8a6d (deep emerald teal) light mode; in dark mode a brighter #34d399-ish? Keep accessible.
- Column accents: todo #6b7a8f (steel), doing #d97706 (amber), done #16a34a (green).
- Priorities: high #dc3d43, medium #e07c1f?? Clashes with doing amber.
Flip it: priority colors as left spine and small badge: high = crimson #d92d20, medium = #f79009 amber, low = #12b76a green — classic. Column accents then should be non-conflicting: todo #3e5c76 (steel blue), doing = #7a5af8? (violet) or doing = #0e7490 cyan-teal. done = neutral dark? Hmm done with a check mark, green is natural but collides with low green. Or use a different green for done: #0e8a5f vs low #5aa9e6 (low as blue?) — many systems show low priority as blue/gray. Let me use low = blue-gray #64748b, medium = amber, high = red. Then done column can be green, doing violet/cyan, todo slate. Final:
- Column accents: todo = #55708c (slate blue), doing = #b45309? No...
Honestly slight collision is OK as long as context separates them (badges vs column top borders). Final decision:
Priority:
- high: bg rgba(220,61,67,.12), text #c22c33, dot #dc3d43
- medium: bg rgba(224,124,15,.14), text #a05c00 / #b45309, dot #f0900b? use #e08a00
- low: bg rgba(70,130,180,.14) steel-blue, text #3f6ea5, dot #5b8fc9
Column accents:
- todo: #64748b? Similar to low... Use neutral per-column accents via colored tab + colored count pill: todo = #8a93a3 gray, doing = #e08a00?? clashes with medium again.
New approach: column accents minimal — use the same hue family for all columns but differentiate with labels and icons: todo is hollow circle, doing is half-filled, done is check. Colors: neutral ink for headers; count pill uses a per-column hue: todo blue #4f7cac, doing amber #d97706, done green #2e9e63. Medium priority badge also amber — but badges have "Medium" text, columns have count pill; tolerable? To reduce confusion, medium priority color to orange-red? Hmm.
Simplify: priority badge is filled pill: high = #e5484d (white text), medium = #f2a007 (dark text), low = #8fa3b8 (dark text, gray-blue). Column accents: todo = #4f6f9c? Let me just differentiate strongly: doing column accent = violet #7c6cf0? Or pick doing = #0ea5b7 (cyan-teal), todo = #6c7a89 (slate), done = #22a06b (green). Then amber medium stands out, red high stands out, gray-blue low is muted. Column cyan-teal vs low gray-blue is OK. Header primary accent: deep teal #0d7d72? Doing cyan... Let me set header accent to warm: primary button (New card) = #e05a2b?? (burnt orange). Hmm.
Let me decide and stop wavering:
Palette (light):
- page bg: #eef1f4, dot grid rgba(27,36,48,.14)
- header bar: #17222e (deep ink slate) with subtle top border accent? Actually let me give it a "safety stripe" 3-color 3px strip (todo/doing/done colors) below the header — distinctive and ties the palette together. Nice detail.
- Primary accent (New card button, focus ring, drag highlight): #ff6b35? Let me pick #e85d2f (tangerine) or teal #14b8a6. Buttons tangerine with dark text? White text on #e4572e is OK for large bold text. Focus ring #e4572e. Hmm and drop indicator dashed border is teal or tangerine.
Column hues:
- todo: #587b9e? Let's go: todo #5c7a99 (steel blue), doing #e0a10c?? nope...
OK final: columns:
- To do → #607d9c (steel blue)
- In progress → #e28413 (marigold)
- Done → #27a06d (jade)
Priorities:
- high → #e5484d (crimson)
- medium → #d6760a?? too close to marigold... medium → #b36212?? or medium → #8e5ad1?? purple for medium priority is unusual. The classic Jira style: highest red, high orange, medium yellow, low blue. But we only have 3 levels.
Decision: medium priority = #efb000 (yellow, dark text). Doing column = #e28413 orange. Sufficiently distinguishable (yellow vs orange, and context differs). Low = #6b7f95 steel-ish, todo = #607d9c similar to low... low priority = #4c9e6a?? Clashes with done green.
Or avoid column color hues entirely: columns share neutral treatment; differentiate with icons (hollow circle, half circle, check) + labels. Then priority colors shine: red/amber/blue-gray. Count pill neutral dark. This avoids clash completely and looks clean/crafted. But I'd like a bit of per-column identity... small colored status icons could provide it: todo icon = hollow circle in slate #7a8699; doing = amber ring/half #e8a13c... collides with amber medium. Doing icon = cyan #2bb3c0; done icon = green #33a06f. These icons are small; low priority badge is gray-blue #64748b; medium is amber #eda507 with dark text; high is red #e5484d with white text. Cyan icon vs badges — OK.
Let's go with this: colored status icons per column (small SVG): ○ slate, ◔ cyan-ish #1fa4b5, ✓ green #2e9e63. The 3px striped strip under the header uses these three colors. Progress meter in header uses these three colors. Priority badges: high crimson filled, medium amber filled (dark text), low gray-blue filled (white or dark text). Distinct enough.
Primary action color: green? Let's pick primary = #2e9e63 (matching done)? New card button green might imply "add/done". Or primary = ink (dark button) — a chic dark button with white text in light mode; in dark mode, light button. But dark header bar with dark button is... Place New card button in header (dark bar) → make it a bright accent: #ffb020? Or make it white pill with dark text? A white "New card" pill on a dark header looks crisp. Hover: subtle lift + glow. Good.
Dark theme:
- page bg: #141a21 with dot grid rgba(255,255,255,.06)
- surface: #1e2630, cards #232d39?
- ink: #e8edf2, muted #93a0ad
- header bar: #0d1319 with same stripe
- badges adjust with translucent backgrounds.
Typography:
- Brand: system stack heavy 800, letter-spacing -0.02em; wordmark maybe "FLOW/BOARD"? Let's do "Sprint Board" or brand "CORKBOARD"? Let's brand it "Shipyard"? Kanban for a product team; brand: "TIDEBOARD"? Keep it simple: "Deckboard"? Let's go with "FLOWDECK" — OK. Or in Japanese? Nope. "Flowdeck" works. Sub-label "Sprint 14 · Mar 3 – 14".
- Mono for counts, ids, meta: ui-monospace stack.
Card content ideas (6 realistic ones, distributed 3/2/1 or 2/2/2):
To do:
1. "Fix checkout redirect loop on Safari" — high
2. "Draft release notes for v2.4" — medium
3. "Research onboarding survey tools" — low
In progress:
4. "Redesign empty states for dashboard" — medium
5. "Migrate auth service to new tokens" — high
Done:
6. "Ship dark mode for marketing site" — low or medium.
Counts: 3/2/1. Add id like TS-141 and an age like "2d" per card. Keep in data as `id: 'TS-101'` and meta '2d'. Newly added cards get next id and "now".
Header distribution meter: stacked bar with widths proportional to counts, animates on transition. Show "3 open · 2 in flight · 1 shipped" in small text? Maybe on the right side of the board. Keep a meter at header bottom or below the header. Place a thin "board stats" row above columns: distribution bar + text like "6 cards · 33% done". Nice living element. Search live updates it (based on filtered? or total). Use total counts; search only filters cards, meter stays total. OK.
Micro-interactions:
- Card hover: translateY(-2px), shadow grows, delete button fades in.
- Card appear animation: scale/slide in.
- Card delete: collapse height + fade.
- Count pill: pop animation on change (retrigger class).
- Drop: card "settle" animation (scale 1.02→1).
- Placeholder: dashed border with animated dash? Maybe animate the background pulse. Use `animation: pulse`.
- Dragged clone: rotate(3deg), shadow lift, slight scale.
- Search input: focus ring, clear button when text present.
- Theme toggle: icon crossfade/rotate.
- Modal: backdrop fade + panel pop; shake on error.
- Empty column state: dashed area "Drop cards here".
Responsive:
- ≥900px: 3-column grid.
- <900px: horizontal snap scroll row? Or stack vertically. 375px must work: stack columns vertically (each full-width). Columns may be long; OK. Board scrolls the page. Use grid: repeat(auto-fit,minmax(280px,1fr))? 375 at min 280 → 1 column. 1280 → 3 columns (max-width container ~1200). Set explicit breakpoint: grid-template-columns: repeat(3,1fr) at ≥860px, else 1fr.
Header at narrow: wrap: brand row, controls row; search expands full width.
Drag implementation specifics:
```js
let drag = null; // {card, cardEl, clone, placeholder, offsetX, offsetY, pointerId, w, h}
function onPointerDown(e){
if(e.button!==0) return;
const cardEl = e.target.closest('.card');
if(!cardEl || e.target.closest('.del')) return;
const rect = cardEl.getBoundingClientRect();
drag = { id: cardEl.dataset.id, cardEl, sx:e.clientX, sy:e.clientY, dx:e.clientX-rect.left, dy:e.clientY-rect.top, w:rect.width, h:rect.height, started:false };
drag.cardEl.classList.add('drag-src'); // dim
window.addEventListener('pointermove', onPointerMove);
window.addEventListener('pointerup', onPointerUp);
window.addEventListener('pointercancel', cancelDrag);
}
```
On pointermove: if !started and distance>5 → start: create placeholder div with height h, create a clone of cardEl (cloneNode(true)) styled .drag-clone fixed width w, append to body; position at rect. Remove delete? Keep clone as is but hide del. body.classList.add('is-dragging').
Move: clone.style.left = x-dx; top = y-dy. Then find target: document.elementsFromPoint(x,y) → find closest '.col-body'; if found, compute insertion: iterate children .card:not(.drag-src):not(.placeholder?) and compare y to midpoints; insert placeholder before first card whose mid > y else append. Highlight that column (.col.drop-hover), unhighlight others. If not found over any col-body: still keep last placeholder? Or hide placeholder? Better keep last valid.
On pointerup: if !started → just a click (do nothing). Otherwise: commit: find placeholder's parent column id and index among cards (excluding drag-src). Update state array: remove card from its col, insert at index. Order preservation: keep cards in single array `cards` with col; compute per-column lists in array order. For insertion index within column: build new order: remove card, then insert at position in global array corresponding to before the card at that index within that column (or at end).
Then end drag: put clone back quickly? Simplest: remove clone and placeholder, render(), add .settle class to the moved card element after render. To make smooth, could animate the clone to target position but re-render is simpler; maybe add short transition: skip, render + settle animation is enough.
Note: pointer events on cards need `touch-action: none`? Only mouse needed but touch-action:none on cards helps touch too. Let's set `touch-action: none` on .card? That would prevent page scroll starting from card on mobile — board may need to scroll. Compromise: keep touch-action:auto and only handle mouse (requirement is mouse). But then on touch devices pointerdown still fires and drags could happen while scrolling... Add check `e.pointerType === 'mouse'` for drag. Good.
Prevent text selection during drag: body.is-dragging { user-select:none }.
Search: input event → state.query lowercased; render filters cards: title.includes(query). Also count displays visible/total when query non-empty. Highlight matched portion: build title nodes: before, mark, after (case-insensitive first match).
Rendering: `render()` rebuilds columns. Counts update, meter updates, empty state too. But during drag, don't call render (we manipulate DOM directly), and call render after drop.
Delete: on del button click: card element animate collapse: set style height to current height, force reflow, then add .removing (height:0, opacity, margin, padding transition). After transitionend (or timeout 260ms), mutate state and render().
Add: modal form: title input, priority segment control (3 radio buttons styled as pills, default medium), column select (default "To do"). Submit: validate title.trim(); if empty → show error text + shake panel + focus. Else create card {id: nextId(), title, priority, col, meta:'now'}, push, render, close modal, highlight new card (.settle / .new glow). New card id: compute max existing number + 1: 'TS-107' etc. Count pill pop.
Toast: small toast bottom-center "Card added to In progress" that fades in/out. Nice.
Theme: `document.documentElement.dataset.theme = 'dark'|'light'`; initially read matchMedia prefers dark. Toggle button has 2 SVG icons that crossfade. Transition body background/color via CSS `transition` on relevant elements (use `* { transition: background-color .25s, ... }`? heavy but OK if scoped: transitions on body, panels, cards).
Also add keyboard: Esc closes modal; press "/" focuses search? Nice touch: keydown "/" when not typing → focus search. Enter opens? skip.
Background: dot grid via radial-gradient. Also maybe a subtle vignette. Add decorative column top tabs.
OK, header layout:
Row: [logo+brand+sprint tag] [spacer] [search] [theme btn] [+ New card].
Stripe strip under header (3 colors). Then stats bar: distribution meter + summary text, maybe on same row as column headers? Let me put a thin bar: left "6 cards · 1 shipped (17%)" right "Sprint ends in 4 days" (static flavor). Meter: flex bar with 3 segments, width % with min-width when >0.
Column structure:
```html
<section class="col" data-col="todo">
<header class="col-head">
<span class="col-ico">svg</span>
<h2>To do</h2>
<span class="count">3</span>
</header>
<div class="col-body" data-col="todo">
cards... or empty state
</div>
</section>
```
col-body min-height for drop area; the whole column could also be drop area — attach drop detection to `.col` but use col-body for list. Use closest('.col') and use its body.
Card:
```html
<article class="card" data-id="TS-101" tabindex="0">
<button class="del" aria-label="Delete card">×svg</button>
<h3 class="card-title">Fix checkout redirect…</h3>
<div class="card-meta">
<span class="badge high">High</span>
<span class="card-id">TS-101</span>
<span class="card-age">2d</span>
</div>
</article>
```
Delete button positioned top-right, opacity 0 until hover/focus-within; always visible on coarse pointer (media hover:none).
Card left border 3px in priority color? Or just badge? Add a subtle spine: `.card::before` left bar colored by data-prio. Good for scanning. Implement via `.card[data-prio="high"] { --pc: var(--high) }` and use ::before background var(--pc).
Priority badge colors via CSS custom properties: --high, --med, --low; badge style: background: color-mix? color-mix is supported in latest Chrome — but to be safe use rgba precomputed per theme. Use CSS variables: --high-bg, --high-fg etc for both themes.
Font:
- body: `-apple-system, "Segoe UI", system-ui, sans-serif`? To pair a distinctive display + readable body without web fonts: use `"Trebuchet MS"` for display? Hmm Trebuchet is distinctive. Or brand uses ui-monospace for a techy board-tool feel: brand "FLOWDECK" in monospace bold + tracking looks cool and distinctive. And headings/body system sans-serif. Mono as display accent (brand, counts, ids, stats), system sans for body. Good combo, no web fonts needed.
Font stack:
--font-body: system-ui, "Segoe UI", "Helvetica Neue", Arial, sans-serif;
--font-mono: ui-monospace, "SF Mono", "Cascadia Code", "JetBrains Mono", Menlo, Consolas, monospace;
Title sizes: brand 15px mono bold; column h2 14px uppercase wide-tracked; card title 14.5px/600; stats numbers mono. Strong size contrast: maybe a big board title? Keep app-like.
Actually to give it more character: brand block in the header with a small logomark: SVG of 3 rounded bars (column colors) — drawn inline.
Now let me code. Structure:
```html
<!DOCTYPE html>
<html lang="en" data-theme="light">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Flowdeck — Sprint Kanban</title>
<style> ... </style>
</head>
<body>
<header class="topbar"> ... </header>
<div class="stripe"></div>
<main class="wrap">
<div class="statsbar"> meter + text </div>
<div class="board" id="board"> 3 columns generated? Or static sections with body containers; cards rendered by JS. Columns static in HTML, cards injected by JS.
</main>
<div class="modal-backdrop" id="modal" hidden> form </div>
<div class="toast" id="toast"></div>
<script> ... </script>
</body>
```
CSS vars:
:root (light):
--bg: #eef1f4; --dots: rgba(23,32,42,.12);
--surface:#fff; --surface2:#f6f8fa (col body bg); --line:#dde3ea;
--ink:#182230; --muted:#5b6775;
--bar:#15202e (header bg); --bar-ink:#f2f6fa; --bar-muted:#9fb0c0;
--todo:#64809c; --doing:#e28413; --done:#2e9e63;
--high:#d84047; --med:#eda400; --low:#5b7fa6? low badge bg is light.
--accent:#e4572e?? primary button... In dark header, New card button = white bg with ink text; hover lift. Focus ring: #4aa3ff? Pick --focus:#3d8bfd.
--drop: #2e9e63? drop indicator color: use --doing? Use accent teal #0ea5a5. Placeholder border dashed #0ea5a5, background rgba. Column drop-hover: outline in accent.
Badge colors (light):
high: fg #b3242b? white on #d84047 is fine → badge is solid filled: bg var(--high) color #fff; med bg #eda400 color #432d00; low bg #64748b? low is solid #7d93ab color #fff? Or filled badges are punchy: high red, medium amber (dark text), low slate (white text). Good contrast to white cards. Spine matches.
Dark theme:
--bg:#12181f; --dots rgba(255,255,255,.055);
--surface:#1c242e; --surface2:#171e27; --line:#2a3542;
--ink:#e9eef4; --muted:#8ea0b0;
--bar:#0d141c;
--todo:#7fa0c0; --doing:#f0a03c; --done:#43c17c;
Badges slightly brighter.
Card shadow: light: 0 1px 2px rgba(16,24,40,.06), hover 0 6px 16px rgba(16,24,40,.12). Dark: black shadows heavier.
Dot grid: body::before fixed layer? use body { background: var(--bg); } .board-bg? Simplest: body { background-image: radial-gradient(var(--dots) 1px, transparent 1.5px); background-size: 22px 22px; background-attachment: fixed? } OK with attachment fixed. But body's bg color transition on theme toggle also transitions background-image — images can't transition; the color var inside gradient snaps. Tolerable; or overlay div with opacity transition. OK, small snap is acceptable — or animate: transition background-color .3s; dots snap is minor. Let me keep the dots on a fixed-positioned ::before on body to allow bg-color transitions on body background-color.
Actually simplest: html {background: var(--bg)} body::before fixed dots. body {background-color transitions}.
Stats bar: container width; label mono small; meter height 8px rounded, overflow hidden, segments with width transition .4s.
Modal: centered card 380px; header "New card", close ×; title input, priority radio group (3 labels), column select, footer buttons Cancel / Add card. Validate: title empty → .error border + message "A title is required" + shake.
Backdrop click to close (mousedown target === backdrop). Esc to close. Focus trap-lite: on open, focus title input.
Toast: fixed bottom, shows message, auto-hides after 2.2s.
Empty state element: `.empty` inside col-body when 0 visible: text depends: if query → "No matches for “x”"; else "Nothing here — drag a card in" (todo might be "All clear"). Keep generic + column-specific? Generic is fine: "Drop cards here".
Wait: if all cards hidden due to query, count shows "0/3". Empty message: `No cards match "query"`.
Also ensure the first paint looks alive: everything server-rendered? JS renders cards immediately on DOMContentLoaded (script at end). Screenshot at ~3s — should be fine. Also entrance animation on initial render (cards stagger-fade in) — nice liveliness at load. But careful: animation on each render is annoying; only animate on initial load and when new card added. Track `firstRender` flag; on render add .enter with stagger via animation-delay per card only at first; new cards add via id.
Count pop: on count change, add .pop class to count element (remove after animation). Track previous counts.
Also for column drop-hover styling: `.col.drop-hover .col-body { background: tint; border-color dashed accent }`.
Placeholder styling: height set inline; class .placeholder: dashed 2px border accent, radius 10px, background accent 8%, animate pulse (opacity). Margin like cards (gap-based spacing: col-body uses flex column with gap 10px; placeholder participates in gap. Good.)
Clone: .drag-clone { position:fixed; z-index 999; pointer-events:none; transform: rotate(2.5deg) scale(1.03); shadow large; transition none; } Use will-change.
drag-src styling: opacity .35, dashed outline? Keep in place as ghost: opacity .35.
Commit move with placeholder index:
```js
function commitDrop(){
const body = drag.placeholder.parentElement; // col-body
const col = body.dataset.col;
const cardsInCol = [...body.querySelectorAll('.card')].filter(el=>el!==drag.cardEl);
let idx = cardsInCol.indexOf? find index of placeholder among them:
const items=[...body.children].filter(el=> el.classList.contains('card') && el!==drag.cardEl || el===drag.placeholder);
let insertAt = items.indexOf(drag.placeholder); // number of cards before placeholder
...
}
```
State mutation:
```js
function moveCard(id, col, indexAmongCol){
const i = cards.findIndex(c=>c.id===id);
const [card] = cards.splice(i,1);
card.col = col;
// find global insert position: after the indexAmongCol-th card of that column, else after last card of that column, else end
let count=-1, globalIdx=cards.length;
for(let j=0;j<cards.length;j++){ if(cards[j].col===col){ count++; if(count===indexAmongCol){ globalIdx=j; break; } } }
// if indexAmongCol beyond last: insert after last card of that column:
if(count < indexAmongCol-1?)
```
Simplify: find position: if indexAmongCol === 0 → insert before first card of that column; if no card of that col → push at end (or after previous column? just push). Else insert before the (indexAmongCol)-th card (0-based) of that column. If indexAmongCol >= number of cards in that column → insert after last card of that column: find last index of that column and globalIdx = that+1; if none, push.
Implement:
```js
const colCards = cards.map((c,i)=>({c,i})).filter(x=>x.c.col===col);
let insert;
if(colCards.length===0) insert = cards.length;
else if(indexAmongCol<=0) insert = colCards[0].i;
else if(indexAmongCol>=colCards.length) insert = colCards[colCards.length-1].i+1;
else insert = colCards[indexAmongCol].i;
cards.splice(insert,0,card);
```
Good.
Render function:
```js
function render(){
const q = state.query.trim().toLowerCase();
let totals={todo:0,doing:0,done:0}, visible={...}
For each column: build cards in state order filtered by col; for each card: match = !q || title.toLowerCase().includes(q)
totals[col]++ ; if match visible++ and create element.
update count: text = q? `${vis}/${tot}` : tot; pop if changed.
Empty state.
Meter: widths by totals (state totals), summary text.
}
```
Card element builder with highlight:
```js
function buildTitle(text,q){ frag; if(!q) textNode; else idx=text.toLowerCase().indexOf(q); if(idx<0) textNode; else: before, <mark>, after }
```
mark style: background: rgba(amber .35), color inherit, rounded 3px, padding 0 1px. In dark, adjust.
Delete handler: event delegation on board click closest('.del') → find id, animate then remove.
Also keyboard: delete key when card focused? Nice: keydown on card: Delete/Backspace → delete. Enter? Not needed. Add Delete key support (nice but careful with search input — only on card focus). Add.
Drag accessibility? skip.
"/" shortcut: keydown on window: if e.key==='/' and document.activeElement not input/textarea → e.preventDefault(); search.focus(). Also 'n' opens modal? Or keep "/" only + Esc.
Theme toggle: button with aria-pressed; inline SVG sun/moon, rotate/fade via CSS on [data-theme].
Modal open: New card button; backdrop shown (class .open with opacity/visibility transition; use hidden attr with timing? Use class-based: .modal {position:fixed; inset:0; display:grid; place-items:center; visibility:hidden; opacity:0} .modal.open{visible}. Panel transform scale .96→1.
Form submit: preventDefault; validate; add.
ID counter: start from max existing numeric suffix +1. Cards: TS-101..TS-106, next TS-107.
Card meta age values: '2d','4d','1d','3d','6h','1d' whatever.
Also add subtle "created" flavor: skip age? Keep age for realism.
Additional flair: maybe column head shows a small "+" quick-add per column? Not needed; skip to avoid clutter (modal handles adding with column select defaulting todo).
Now let's think about visual composition at 1280×800: header ~60px, stripe 4px, stats ~46px, board fills rest with 3 columns. Cards ~86px tall each. Column body scroll? At 800 height, with 3 cards, fits without scrolling. col-body can grow; page scrolls if needed. OK.
Nice touches: column headers sticky? Not needed.
OK let me write the final code carefully. Aim for polish and zero console errors.
Edge cases:
- elementsFromPoint while clone is under cursor: clone has pointer-events:none ✓; placeholder is inside body — elementsFromPoint may return placeholder/card; use closest('.col') works regardless.
- drag-src is excluded from insertion computation ✓.
- If pointer leaves the board → keep placeholder in last column (common behavior) or clear highlight? Keep last.
- If pointerup before threshold: cleanup listeners, remove drag-src class, no other changes. Also allow click on card? Cards have no click action; OK.
- Prevent native drag of text: e.preventDefault() on pointerdown? That may block input focus... not inputs. Preventing default on pointerdown also prevents focus; OK. Actually preventing default on pointerdown prevents compat mouse events and text selection start — good. But careful: if user starts dragging from del button, we return early (check closest('.del')) and allow click.
- If query changes during drag? Ignore.
Prevent text selection on card: user-select none on card? Titles may be worth selecting but dragging is primary: set .card {user-select:none} to avoid accidental selections. OK.
Clone: cloneNode of card includes del button; hide via class .drag-clone .del{display:none}.
Placeholder height = card height. Width auto (block fills column).
During drag, col-body should ensure min-height to allow drop into empty column: min-height 60px.
Settle animation after drop: add class 'settle' to the element with id after render → keyframes: from {transform:scale(1.04); box-shadow big} to normal, 260ms. Remove class on animationend.
Also on drop: brief flash of column? Placeholder disappears; settle is enough.
Count pop keyframe: scale 1→1.25→1.
Initial stagger: .enter on cards, animation-delay per card via inline style, index*60ms; run once. Columns themselves also fade up.
Toast message on add: "TS-107 added to In progress". On delete: "TS-101 deleted" with Undo? Undo adds complexity; small Undo button in toast is fun but scope. Skip Undo; simple toast.
Hmm, maybe also toast on delete: OK.
Search: input with icon (svg magnifier) and clear (×) button that appears when value non-empty. On input → state.query, render. Clear button resets and focuses.
Stats bar content: left: `<strong>N</strong> cards · <span>` with dots: colored dots + "3 open · 2 in flight · 1 done"? Right: distribution meter (flex-grow) + percent "17% done". Layout: [summary text] [meter stretch] [mono percent]. On narrow, hide meter? Keep it wraps: stats bar wraps.
Percent done = done/total.
Sprint flavor text in header sub: "Sprint 14 · ends Fri". Keep brand line: logomark + "FLOWDECK" (mono, bold, tracked) + tag pill "SPRINT 14".
Header controls order: search (min width 200, flex-grow max 340), theme toggle icon button, New card button.
New card button style: white pill on dark bar: bg #fff color #14202e, font-weight 700, padding 8px 14px, radius 999? A pill for a button is OK. hover translateY(-1px) + shadow + slight bg #f2f6fa; active push. Plus icon "+" svg.
Theme toggle: icon button on dark bar: border 1px rgba(255,255,255,.18), bg transparent, hover bg rgba(255,255,255,.08). Icon: shows sun in dark theme (click→light) or moon in light? Convention: icon shows what you'd switch to or current state; show moon in light mode (click to go dark), sun in dark mode. Implement with 2 svgs stacked, opacity/rotate transitions based on [data-theme].
Stripe: 3 equal segments? Or proportionally? just 3 equal thirds, height 3px: linear-gradient(90deg, todo 0 33.3%, doing 0 66.6%, done 0). Keep static.
Card delete icon: small trash svg. Position: top 8 right 8, size 26px, radius 8, color muted; hover: bg rgba(red .12), color red.
Card padding: 12px 14px; with spine: ::before absolute left 0 top 8 bottom 8 width 3px radius 2 bg var(--pc). Card radius 10px, border 1px line, background surface.
Card title: font-size 14px, weight 600, line-height 1.35, color ink; margin-bottom 10px; padding-right 20px (room for del).
Meta row: display flex align-center gap 8px. Badge: font 11px mono? Badge text: "HIGH" uppercase mono 10px ls .06em padding 3px 8px radius 999. id: mono 11px muted. age: mono 11px muted with "·" dot? Add small clock? Keep just "2d" muted, margin-left auto → right align: badge left, id middle, age right with margin-left:auto? Layout: [badge] [id] ...spacer... [age]. del button absolute top right.
Column style: background surface2 (slightly tinted), border 1px line, radius 14px, padding 10px; head: display flex align-center gap 8, padding 6px 8px 12px; h2 uppercase 12px ls .08em weight 700 color muted; icon 16px colored; count pill: mono 11px bold, bg ink/8, padding 2px 8px radius 999, margin-left auto.
Column drop-hover: border-color accent dashed? Change body bg: color-mix(accent 6%). `.col.drop-hover { border-color: var(--accent); box-shadow: 0 0 0 3px rgba(accent .15); }` and the col-body bg tints.
Accent color: define --accent: teal #0e9384. Placeholder uses accent. Focus ring also accent.
Hmm "New card" white button — OK.
Modal style: panel bg surface radius 16px width min(420px, calc(100vw-32px)) padding 20px; shadow. Title 16px bold. Input: full width, padding 10px 12px, radius 10, border line, bg bg-soft; on focus border accent + ring. Segmented priority: 3 buttons (label+radio hidden): container bg bg-soft radius 10 padding 3px, grid 3 cols; each option pill; when checked → colored fill per priority (high red bg, etc.) with appropriate text. Implementation: input hidden, label with span. Use :has? or :checked + span? Structure: `<label class="seg"><input type="radio" name="prio" value="high"><span>High</span></label>`, CSS `.seg input{position:absolute;opacity:0} .seg input:checked + span{...}` ✓.
Column select: styled select with chevron svg background.
Error: .field.error input{border-color red; ring red} .err-msg{color red, font 12px, display none → block}. Panel shake keyframes.
Footer: right aligned: Cancel (ghost) / Add card (primary: bg ink, color surface? use accent? bg var(--ink) color var(--bg) for punch; hover lift).
Dark mode: panel bg surface (#1c242e), inputs bg #141b23.
Now JS state:
```js
const COLS=[{id:'todo',name:'To do',icon:circle},{id:'doing',name:'In progress'},{id:'done',name:'Done'}];
let cards=[ {...} ];
let query='';
let prevCounts={}, prevTotal=-1;
```
Initial 6 cards:
1. TS-101 "Fix checkout redirect loop on Safari" high todo 2d
2. TS-102 "Draft v2.4 release notes" medium todo 1d
3. TS-103 "Compare onboarding survey tools" low todo 4d? Hmm todo 3 cards
4. TS-104 "Redesign dashboard empty states" medium doing 3d
5. TS-105 "Migrate auth to short-lived tokens" high doing 6h
6. TS-106 "Ship dark mode on marketing site" low done 1d
Counts 3/2/1, 17% done → percent is Math.round(done/total*100).
Summary text: `${total} cards · ${todo} queued · ${doing} in flight · ${done} done`.
Now icons (inline SVG strings):
- Logo: 3 vertical rounded bars with varying heights, colored: `<svg width="22" height="22" viewBox="0 0 24 24" fill="none"><rect x="3" y="4" width="5" height="12" rx="2" fill="var(--todo)"/><rect x="10" y="4" width="5" height="16" rx="2" fill="var(--doing)"/><rect x="17" y="4" width="5" height="8" rx="2" fill="var(--done)"/></svg>` — using var() in SVG fill inside HTML works ✓ (CSS variables apply).
Column icons: todo: circle outline; doing: circle with half fill (use path or pie) — simpler: two arcs? Use `<circle cx=8 cy=8 r=6 stroke>` + half: path `<path d="M8 2 a6 6 0 0 1 0 12 z" fill>` (right half). done: check in circle: filled circle + white check.
Delete icon: trash: simple path.
Search icon: magnifier svg positioned absolute in wrapper.
Sun/moon svg.
Plus icon for new card.
Close ×: svg or char ✕.
Escape to close modal; click backdrop to close.
Focus states: :focus-visible outline 2px accent offset 2.
Now, drop indicator detail: requirement "clear visual drop indicator" — placeholder slot + column highlight + clone following cursor: strong.
Also add `cursor: grabbing` on body while dragging.
Potential issue: pointerdown → then pointermove without capture; use window listeners ✓. Set `drag.cardEl.setPointerCapture` too? Not needed since window listeners. But pointer capture on element routes events there anyway; skip, window works.
preventDefault on pointerdown may prevent click event on that element; we only use this for del button clicks which we excluded from drag start. ✓. But preventDefault on pointerdown also prevents focus change; tolerable.
Actually careful: preventDefault on pointerdown prevents subsequent compat mouse events and text selection — good. It also prevents things like :active in some browsers. OK.
Touch: only start drag when e.pointerType==='mouse' (requirement is mouse). Mobile: cards are still usable (delete/add/search). OK; add `touch-action: manipulation` too.
Now theme toggle transitions:
```css
body, .col, .card, .topbar, input, select, .modal-panel, .count, .seg span { transition: background-color .25s ease, border-color .25s ease, color .25s ease, box-shadow .25s ease; }
```
Applying transitions broadly via `*` may cause drag clone transitions (clone has transition none set). Apply transition list to specific selectors.
During theme change, gradient dots snap — acceptable.
Also handle count "n/m" width shift: OK.
Empty search results across all columns: each column shows "No matches".
Let me write the code. Pay careful attention to matching brackets. Aim for polish, zero console errors.
```html
<!DOCTYPE html>
<html lang="en" data-theme="light">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Flowdeck — Sprint Kanban</title>
<style>
:root{
--bg:#eef1f4; --dots:rgba(21,32,46,.13);
--surface:#ffffff; --panel:#f6f8fa; --line:#dfe5ec;
--ink:#17212e; --muted:#5d6b7a;
--bar:#152130; --bar-ink:#f4f7fa; --bar-mut:#93a4b6;
--todo:#5f7f9e; --doing:#e28a13; --done:#2e9e63;
--high:#d84047; --high-fg:#fff;
--med:#e79b0a; --med-fg:#3d2a00;
--low:#7189a3; --low-fg:#fff;
--accent:#0e9384;
--focus:rgba(14,147,132,.35);
--shadow:0 1px 2px rgba(16,24,40,.06),0 1px 3px rgba(16,24,40,.08);
--shadow-lift:0 10px 24px rgba(16,24,40,.16);
--mark:rgba(231,155,10,.35);
--font:system-ui,-apple-system,"Segoe UI","Helvetica Neue",sans-serif;
--mono:ui-monospace,"SF Mono","Cascadia Code","JetBrains Mono",Menlo,Consolas,monospace;
}
[data-theme="dark"]{
--bg:#11171f; --dots:rgba(255,255,255,.06);
--surface:#1d2632; --panel:#171f2a; --line:#2b3644;
--ink:#e8eef5; --muted:#8fa1b3;
--bar:#0c131c; --bar-ink:#eef3f8; --bar-mut:#7e90a3;
--todo:#82a7c9; --doing:#f0a03c; --done:#3fc37f;
--high:#e5545b; --med:#f0a81c; --med-fg:#33230a;
--low:#7d95af;
--shadow:0 1px 2px rgba(0,0,0,.35);
--shadow-lift:0 12px 28px rgba(0,0,0,.5);
--mark:rgba(240,168,28,.30);
}
```
Body etc:
```css
*{box-sizing:border-box;margin:0}
html,body{height:100%}
body{
font-family:var(--font); color:var(--ink); background-color:var(--bg);
transition:background-color .3s,color .3s;
min-height:100vh;
}
body::before{ content:""; position:fixed; inset:0; pointer-events:none;
background-image:radial-gradient(var(--dots) 1px, transparent 1.4px);
background-size:22px 22px; z-index:0;
}
```
Content should be above: main z-index 1 relative.
Top bar:
```css
.topbar{position:sticky;top:0;z-index:20;background:var(--bar);color:var(--bar-ink);
display:flex;align-items:center;gap:14px;padding:12px 22px; flex-wrap:wrap; transition:background-color .3s;}
.brand{display:flex;align-items:center;gap:10px;}
.brand-name{font-family:var(--mono);font-weight:800;letter-spacing:.14em;font-size:15px;}
.sprint-tag{font-family:var(--mono);font-size:10.5px;letter-spacing:.08em;color:var(--bar-mut);border:1px solid rgba(255,255,255,.16);padding:3px 8px;border-radius:999px;}
.top-tools{margin-left:auto;display:flex;align-items:center;gap:10px;flex:1;justify-content:flex-end;min-width:0;}
.search{position:relative;flex:0 1 340px;min-width:170px;}
.search svg{position:absolute;left:11px;top:50%;translate:0 -50%;width:15px;height:15px;stroke:var(--bar-mut);}
.search input{width:100%;padding:9px 30px 9px 34px;border-radius:10px;border:1px solid rgba(255,255,255,.14);background:rgba(255,255,255,.07);color:var(--bar-ink);font:inherit;font-size:13.5px;outline:none;transition:...}
.search input::placeholder{color:var(--bar-mut)}
.search input:focus{border-color:var(--accent);background:rgba(255,255,255,.12);box-shadow:0 0 0 3px var(--focus);}
.search .clear{position:absolute;right:6px;...display none; } .search.has .clear{display:grid}
.icon-btn{width:38px;height:38px;border-radius:10px;border:1px solid rgba(255,255,255,.14);background:transparent;color:var(--bar-ink);display:grid;place-items:center;cursor:pointer;transition:...}
.icon-btn:hover{background:rgba(255,255,255,.1);}
.btn-new{display:inline-flex;align-items:center;gap:7px;background:#fff;color:#13202e;font-weight:700;font-size:13.5px;border:none;padding:10px 16px;border-radius:999px;cursor:pointer;transition:transform .15s, box-shadow .15s;box-shadow:0 2px 8px rgba(0,0,0,.25);}
.btn-new:hover{transform:translateY(-1px);box-shadow:0 6px 16px rgba(0,0,0,.3);}
.btn-new:active{transform:translateY(0) scale(.98);}
```
Theme icon: inside .icon-btn.theme two svgs stacked:
```css
.theme{position:relative;}
.theme svg{position:absolute;transition:opacity .3s, transform .4s;}
[data-theme="light"] .theme .sun{opacity:0;transform:rotate(90deg) scale(.5);}
[data-theme="light"] .theme .moon{opacity:1;transform:none;}
[data-theme="dark"] .theme .moon{opacity:0;transform:rotate(-90deg) scale(.5);}
[data-theme="dark"] .theme .sun{opacity:1;transform:none;}
```
Stripe:
```css
.stripe{height:3px;background:linear-gradient(90deg,var(--todo) 0 33.4%,var(--doing) 0 66.7%,var(--done) 0);}
```
Stats bar:
```css
.wrap{position:relative;z-index:1;max-width:1240px;margin:0 auto;padding:18px 22px 60px;}
.statsbar{display:flex;align-items:center;gap:14px;margin-bottom:16px;flex-wrap:wrap;}
.stats-text{font-size:12.5px;color:var(--muted);display:flex;align-items:center;gap:10px;flex-wrap:wrap;}
.stats-text b{color:var(--ink);font-family:var(--mono);}
.dot{width:8px;height:8px;border-radius:50%;display:inline-block;margin-right:5px;}
.meter{flex:1;min-width:120px;height:8px;border-radius:99px;overflow:hidden;display:flex;background:var(--panel);border:1px solid var(--line);}
.meter i{display:block;height:100%;transition:width .45s cubic-bezier(.22,.9,.35,1);}
.meter .m-todo{background:var(--todo)} .m-doing{background:var(--doing)} .m-done{background:var(--done)}
.pct{font-family:var(--mono);font-size:12px;color:var(--muted);}
```
Board:
```css
.board{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:16px;align-items:start;}
@media(max-width:880px){.board{grid-template-columns:1fr;}}
.col{background:var(--panel);border:1px solid var(--line);border-radius:14px;padding:10px;transition:background-color .3s,border-color .2s,box-shadow .2s;}
.col-head{display:flex;align-items:center;gap:9px;padding:4px 6px 12px;}
.col-ico{width:18px;height:18px;flex:none;}
.col-head h2{font-size:12px;letter-spacing:.1em;text-transform:uppercase;color:var(--muted);font-weight:700;}
.count{margin-left:auto;font-family:var(--mono);font-size:11.5px;font-weight:700;color:var(--ink);background:var(--surface);border:1px solid var(--line);padding:2px 9px;border-radius:999px;transition:...;}
.count.pop{animation:pop .35s ease;}
@keyframes pop{40%{transform:scale(1.28);}}
.col-body{display:flex;flex-direction:column;gap:10px;min-height:70px;border-radius:10px;padding:2px;transition:background-color .2s;}
.col.drop-hover{border-color:var(--accent);box-shadow:0 0 0 3px var(--focus);}
.col.drop-hover .col-body{background:color-mix? }
```
color-mix is supported in Chrome 111+ ✓ ("latest Chrome"). Can use but as a fallback: define explicit rgba per theme: --accent-soft: rgba(14,147,132,.08) / for dark rgba(61,201,185,.1). Define variable.
--accent: light #0e9384, dark #35c4b5. --accent-soft accordingly.
Card:
```css
.card{position:relative;background:var(--surface);border:1px solid var(--line);border-radius:11px;padding:12px 14px 12px 17px;box-shadow:var(--shadow);cursor:grab;user-select:none;touch-action:manipulation;transition:transform .16s ease, box-shadow .16s ease, background-color .3s, border-color .3s, opacity .2s;}
.card:hover{transform:translateY(-2px);box-shadow:var(--shadow-lift);border-color: ...}
.card::before{content:"";position:absolute;left:6px;top:11px;bottom:11px;width:3.5px;border-radius:4px;background:var(--pc);}
.card[data-prio="high"]{--pc:var(--high)} medium→--med low→--low
.card-title{font-size:14px;font-weight:600;line-height:1.4;padding-right:22px;margin-bottom:10px;}
.card-title mark{background:var(--mark);color:inherit;border-radius:3px;padding:0 1px;}
.card-meta{display:flex;align-items:center;gap:8px;}
.badge{font-family:var(--mono);font-size:10px;font-weight:700;letter-spacing:.09em;padding:3px 9px;border-radius:999px;}
.badge.high{background:var(--high);color:var(--high-fg);}
.badge.medium{background:var(--med);color:var(--med-fg);}
.badge.low{background:var(--low);color:var(--low-fg);}
.card-id,.card-age{font-family:var(--mono);font-size:11px;color:var(--muted);}
.card-age{margin-left:auto;}
.del{position:absolute;top:7px;right:7px;width:26px;height:26px;border-radius:8px;border:none;background:transparent;color:var(--muted);display:grid;place-items:center;cursor:pointer;opacity:0;transition:opacity .15s, background-color .15s,color .15s,transform .15s;}
.card:hover .del,.del:focus-visible{opacity:1;}
.del:hover{background:rgba(216,64,71,.12);color:var(--high);transform:scale(1.08);}
@media(hover:none){.del{opacity:.7;}}
.card.removing{opacity:0;transform:translateX(14px) scale(.96);}
```
Remove animation: animate max-height? Height transition: JS sets el.style.height = px, then add class .collapse {height:0!important;opacity:0;padding:0;margin:0;border-width:0}. Transition height .25s. Implement in JS:
```js
el.style.height=el.offsetHeight+'px';
requestAnimationFrame(()=>{el.classList.add('collapsing');el.style.height='0px';});
setTimeout(finish,280);
```
.collapsing{opacity:0;padding-top:0;padding-bottom:0;border-width:0;overflow:hidden;transform:scale(.95);} transition all .26s.
Gap between cards also collapses automatically because height shrinks (gap remains until element removed — minor jump on removal; acceptable, or set margin? gap constant 10px between siblings; on removal re-render fixes. OK).
Empty state:
```css
.empty{border:1.5px dashed var(--line);border-radius:10px;padding:18px 12px;text-align:center;color:var(--muted);font-size:12.5px;}
```
Placeholder:
```css
.placeholder{border:2px dashed var(--accent);border-radius:11px;background:var(--accent-soft);animation:ph 1.1s ease-in-out infinite;}
@keyframes ph{50%{opacity:.55;}}
```
Clone:
```css
.drag-clone{position:fixed;z-index:1000;pointer-events:none;margin:0;transform:rotate(2.5deg) scale(1.03);box-shadow:0 18px 40px rgba(10,20,35,.35);cursor:grabbing;transition:none;}
.drag-clone .del{display:none;}
body.is-dragging{cursor:grabbing;user-select:none;}
body.is-dragging .card{cursor:grabbing;}
.drag-src{opacity:.35;}
.card.settle{animation:settle .3s ease;}
@keyframes settle{0%{transform:scale(1.045);}100%{transform:scale(1);}}
.card.enter{animation:enter .4s both ease;}
@keyframes enter{from{opacity:0;transform:translateY(10px) scale(.97);}}
```
Modal:
```css
.modal{position:fixed;inset:0;z-index:50;display:grid;place-items:center;padding:20px;background:rgba(10,16,24,.55);backdrop-filter:blur(3px);opacity:0;visibility:hidden;transition:opacity .22s,visibility .22s;}
.modal.open{opacity:1;visibility:visible;}
.modal-panel{width:min(430px,100%);background:var(--surface);border:1px solid var(--line);border-radius:16px;padding:22px;box-shadow:0 24px 60px rgba(5,12,24,.35);transform:translateY(14px) scale(.96);transition:transform .25s cubic-bezier(.2,.9,.3,1.2), background-color .3s;}
.modal.open .modal-panel{transform:none;}
.modal-panel.shake{animation:shake .35s;}
@keyframes shake{20%{transform:translateX(-7px)}45%{transform:translateX(6px)}70%{transform:translateX(-4px)}90%{transform:translateX(2px)}}
```
Form styles:
```css
.m-head{display:flex;align-items:center;justify-content:space-between;margin-bottom:16px;}
.m-head h3{font-size:17px;font-weight:800;letter-spacing:-.01em;}
label.lbl{display:block;font-size:11.5px;font-weight:700;letter-spacing:.07em;text-transform:uppercase;color:var(--muted);margin:14px 0 6px;}
.txt, select.txt{width:100%;padding:10px 12px;border-radius:10px;border:1.5px solid var(--line);background:var(--panel);color:var(--ink);font:inherit;font-size:14px;outline:none;transition:border-color .15s, box-shadow .15s, background-color .3s;}
.txt:focus{border-color:var(--accent);box-shadow:0 0 0 3px var(--focus);}
.field.error .txt{border-color:var(--high);box-shadow:0 0 0 3px rgba(216,64,71,.18);}
.err{display:none;color:var(--high);font-size:12px;margin-top:6px;}
.field.error .err{display:block;}
.seg{display:grid;grid-template-columns:repeat(3,1fr);gap:4px;background:var(--panel);border:1px solid var(--line);padding:4px;border-radius:10px;}
.seg label{position:relative;text-align:center;}
.seg input{position:absolute;opacity:0;inset:0;} /* keep clickable */
.seg span{display:block;padding:7px 4px;border-radius:7px;font-size:12.5px;font-weight:600;color:var(--muted);cursor:pointer;transition:...;}
.seg input:checked+span{...}
.seg input[value=high]:checked+span{background:var(--high);color:#fff;}
.seg input[value=medium]:checked+span{background:var(--med);color:var(--med-fg);}
.seg input[value=low]:checked+span{background:var(--low);color:#fff;}
.seg input:focus-visible+span{box-shadow:0 0 0 3px var(--focus);}
```
Attr selector `.seg input[value=high]:checked + span` — attribute selector with unquoted value is OK in CSS? `[value=high]` is valid ✓.
Select chevron: appearance none + bg svg data-uri? Data URI OK (inline). Use background-image with encoded svg stroke currentColor? Simpler: wrap in .selwrap with chevron svg positioned absolute. Make it a wrapper.
Footer buttons:
```css
.m-actions{display:flex;justify-content:flex-end;gap:10px;margin-top:20px;}
.btn{font:inherit;font-weight:700;font-size:13.5px;padding:10px 16px;border-radius:10px;cursor:pointer;border:1.5px solid transparent;transition:transform .12s, box-shadow .15s, background-color .15s;}
.btn:active{transform:scale(.97);}
.btn-ghost{background:transparent;border-color:var(--line);color:var(--muted);}
.btn-ghost:hover{color:var(--ink);border-color:var(--muted);}
.btn-primary{background:var(--ink);color:var(--bg);}
.btn-primary:hover{box-shadow:0 6px 14px rgba(10,20,35,.25);transform:translateY(-1px);}
```
Toast:
```css
.toast{position:fixed;left:50%;bottom:26px;translate:-50% 16px;z-index:60;background:var(--ink);color:var(--bg);font-family:var(--mono);font-size:12.5px;padding:10px 16px;border-radius:10px;opacity:0;pointer-events:none;transition:opacity .25s, translate .25s;box-shadow:0 10px 30px rgba(0,0,0,.3);}
.toast.show{opacity:1;translate:-50% 0;}
```
translate property + transform both fine.
Small footer note? Maybe small mono caption bottom-left "FLOWDECK · state in memory — refresh resets". Honest and distinctive. Add below board: `.foot{margin-top:22px;font-family:var(--mono);font-size:11px;color:var(--muted);display:flex;justify-content:space-between;gap:10px;flex-wrap:wrap;}` Contents: left "Drag cards between columns · / to search", right "In-memory demo — refresh resets the board". Nice.
Now HTML skeleton:
```html
<header class="topbar">
<div class="brand">
<svg logo>…</svg>
<span class="brand-name">FLOWDECK</span>
<span class="sprint-tag">SPRINT 14</span>
</div>
<div class="top-tools">
<div class="search" id="searchWrap">
<svg magnifier…></svg>
<input id="search" type="text" placeholder="Search cards… ( / )" aria-label="Search cards">
<button class="clear" id="clearSearch" aria-label="Clear search">×svg</button>
</div>
<button class="icon-btn theme" id="themeBtn" aria-label="Toggle theme" title="Toggle light/dark theme">
sun svg, moon svg
</button>
<button class="btn-new" id="newBtn"><svg plus></svg>New card</button>
</div>
</header>
<div class="stripe"></div>
<main class="wrap">
<div class="statsbar">
<div class="stats-text" id="statsText"></div>
<div class="meter" id="meter" aria-hidden="true"><i class="m-todo"></i><i class="m-doing"></i><i class="m-done"></i></div>
<span class="pct" id="pct"></span>
</div>
<div class="board" id="board">
<section class="col" data-col="todo">
<header class="col-head">
<svg class="col-ico" …todo…></svg>
<h2>To do</h2>
<span class="count" id="count-todo">0</span>
</header>
<div class="col-body" data-col="todo" id="body-todo"></div>
</section>
… doing, done …
</div>
<div class="foot">…</div>
</main>
```
Modal HTML:
```html
<div class="modal" id="modal" aria-hidden="true">
<form class="modal-panel" id="cardForm" novalidate>
<div class="m-head">
<h3>New card</h3>
<button type="button" class="icon-btn close" id="closeModal" aria-label="Close">×svg</button>
</div>
<div class="field" id="titleField">
<label class="lbl" for="fTitle">Title</label>
<input class="txt" id="fTitle" type="text" placeholder="e.g. Fix login timeout on mobile" maxlength="90" autocomplete="off">
<p class="err">A title is required.</p>
</div>
<label class="lbl">Priority</label>
<div class="seg" role="radiogroup" aria-label="Priority">
<label><input type="radio" name="prio" value="low"><span>Low</span></label>
<label><input type="radio" name="prio" value="medium" checked><span>Medium</span></label>
<label><input type="radio" name="prio" value="high"><span>High</span></label>
</div>
<label class="lbl" for="fCol">Column</label>
<div class="selwrap">
<select class="txt" id="fCol">
<option value="todo">To do</option>
<option value="doing">In progress</option>
<option value="done">Done</option>
</select>
<svg chevron></svg>
</div>
<div class="m-actions">
<button type="button" class="btn btn-ghost" id="cancelBtn">Cancel</button>
<button type="submit" class="btn btn-primary">Add card</button>
</div>
</form>
</div>
<div class="toast" id="toast" role="status"></div>
```
Modal backdrop click: on .modal mousedown if target===modal close. Close button's icon-btn style for dark is not needed — define a .close variant: border line, color muted, bg panel.
Close button style: `.icon-btn.close{border-color:var(--line);color:var(--muted);background:transparent}` hover color ink bg panel. But .icon-btn base is styled for dark top bar (border rgba white). Override in .close. Order matters: define .icon-btn generic sizing, and .topbar .icon-btn for dark variant. Simpler: keep base .icon-btn only for sizing/layout; topbar variant: `.topbar .icon-btn{border-color:rgba(255,255,255,.15);color:var(--bar-ink)}` and modal's close has `.icon-btn.close{border:1px solid var(--line);color:var(--muted)}`.
JS plan (write carefully):
```js
(function(){
'use strict';
const COLS = [
{id:'todo', label:'To do'},
{id:'doing',label:'In progress'},
{id:'done', label:'Done'}
];
const COL_LABEL = {todo:'To do', doing:'In progress', done:'Done'};
let cards = [
{id:'TS-101', title:'Fix checkout redirect loop on Safari', prio:'high', col:'todo', age:'2d'},
{id:'TS-102', title:'Draft release notes for v2.4', prio:'medium', col:'todo', age:'1d'},
{id:'TS-103', title:'Compare onboarding survey tools', prio:'low', col:'todo', age:'4d'},
{id:'TS-104', title:'Redesign dashboard empty states', prio:'medium', col:'doing', age:'3d'},
{id:'TS-105', title:'Migrate auth service to short-lived tokens', prio:'high', col:'doing', age:'6h'},
{id:'TS-106', title:'Ship dark mode on marketing site', prio:'low', col:'done', age:'1d'},
];
let query='';
let nextNum=107;
let prevCounts={}; let firstRender=true; let highlightId=null;
```
DOM refs. render():
```js
function render(){
const q=query.trim().toLowerCase();
const totals={todo:0,doing:0,done:0};
COLS.forEach(c=>{
const body=document.getElementById('body-'+c.id);
body.innerHTML='';
let vis=0;
cards.forEach(card=>{
if(card.col!==c.id) return;
totals[c.id]++;
const hit=!q||card.title.toLowerCase().includes(q);
if(!hit) return;
vis++;
body.appendChild(makeCard(card,q));
});
if(vis===0){
const d=document.createElement('div'); d.className='empty';
d.textContent = q? `No cards match “${query.trim()}”` : 'Drop cards here';
body.appendChild(d);
}
const cnt=document.getElementById('count-'+c.id);
const label=q? vis+'/'+totals[c.id] : String(totals[c.id]);
if(cnt.textContent!==label){cnt.textContent=label;cnt.classList.remove('pop');void cnt.offsetWidth;cnt.classList.add('pop');}
});
updateStats(totals);
firstRender=false; highlightId=null;
}
```
Note: for the empty message using query.trim() inside template literal with quotes — OK.
makeCard:
```js
function makeCard(card,q){
const el=document.createElement('article');
el.className='card'+(firstRender?' enter':'');
el.dataset.id=card.id; el.dataset.prio=card.prio; el.tabIndex=0;
if(card.id===highlightId) el.classList.add('settle');
// index for stagger
if(firstRender){el.style.animationDelay=(staggerIdx++*55)+'ms';}
el.innerHTML =
'<button class="del" type="button" aria-label="Delete card '+esc(card.id)+'" title="Delete card">' + ICONS.trash + '</button>' +
'<h3 class="card-title"></h3>' +
'<div class="card-meta">' +
'<span class="badge '+card.prio+'">'+PRIO_LABEL[card.prio]+'</span>' +
'<span class="card-id">'+card.id+'</span>' +
'<span class="card-age">'+card.age+'</span>' +
'</div>';
const t=el.querySelector('.card-title');
if(q){ const i=card.title.toLowerCase().indexOf(q);
if(i>=0){ t.append(card.title.slice(0,i)); const m=document.createElement('mark'); m.textContent=card.title.slice(i,i+q.length); t.append(m, card.title.slice(i+q.length)); }
else t.textContent=card.title;
} else t.textContent=card.title;
return el;
}
```
staggerIdx resets each render: declare `let staggerIdx=0` in render before loop, only use when firstRender.
esc helper for aria-label: card.id is safe anyway; skip esc, ids are our own.
PRIO_LABEL={low:'Low',medium:'Medium',high:'High'}.
updateStats:
```js
function updateStats(t){
const total=t.todo+t.doing+t.done;
statsText.innerHTML=... build with spans: `${total} cards · <span><i class=dot style bg todo></i>${t.todo} queued</span> ...`
```
Build via innerHTML with template using var colors? Use classes .dot-todo etc.
```js
statsText.innerHTML = `<b>${total}</b> cards <span class="sep">·</span>` + ...
```
Simpler:
```js
statsText.innerHTML =
`<b>${total}</b> cards`+
`<span class="chip"><i class="dot d-todo"></i>${t.todo} queued</span>`+
`<span class="chip"><i class="dot d-doing"></i>${t.doing} in flight</span>`+
`<span class="chip"><i class="dot d-done"></i>${t.done} done</span>`;
```
Meter widths: pct each = total? t.x/total*100 :0; style.width = pct+'%'. pct text: total? Math.round(t.done/total*100)+'% done':'—'.
Search:
```js
searchInput.addEventListener('input',()=>{ query=searchInput.value; searchWrap.classList.toggle('has',!!query); render(); });
clearBtn.onclick=()=>{searchInput.value='';query='';searchWrap.classList.remove('has');render();searchInput.focus();};
```
Theme:
```js
const root=document.documentElement;
if(window.matchMedia && matchMedia('(prefers-color-scheme: dark)').matches) root.dataset.theme='dark';
themeBtn.onclick=()=>{root.dataset.theme=root.dataset.theme==='dark'?'light':'dark';};
```
Modal:
```js
function openModal(){modal.classList.add('open');modal.setAttribute('aria-hidden','false');lastFocus=document.activeElement; setTimeout(()=>fTitle.focus(),60);}
function closeModal(){modal.classList.remove('open');modal.setAttribute('aria-hidden','true');cardForm.reset();titleField.classList.remove('error'); if(lastFocus&&lastFocus.focus)lastFocus.focus();}
newBtn.onclick=openModal; cancelBtn.onclick=closeModal; closeBtn.onclick=closeModal;
modal.addEventListener('mousedown',e=>{if(e.target===modal)closeModal();});
window.addEventListener('keydown',e=>{
if(e.key==='Escape'){ if(modal.classList.contains('open')) closeModal(); else if(drag) abortDrag(); }
if(e.key==='/' && !modal.classList.contains('open')){const a=document.activeElement; if(!a || (a.tagName!=='INPUT'&&a.tagName!=='TEXTAREA'&&a.tagName!=='SELECT')){e.preventDefault();searchInput.focus();}}
});
```
Submit:
```js
cardForm.addEventListener('submit',e=>{
e.preventDefault();
const title=fTitle.value.trim();
if(!title){titleField.classList.add('error');panel.classList.remove('shake');void panel.offsetWidth;panel.classList.add('shake');fTitle.focus();return;}
const prio=new FormData(cardForm).get('prio')||'medium';
const col=fCol.value;
const id='TS-'+(nextNum++);
cards.push({id,title,prio,col,age:'now'});
highlightId=id; render(); closeModal(); toast(id+' added to '+COL_LABEL[col]);
});
fTitle.addEventListener('input',()=>{if(fTitle.value.trim())titleField.classList.remove('error');});
```
Delete (delegated):
```js
board.addEventListener('click',e=>{
const btn=e.target.closest('.del'); if(!btn)return;
const el=btn.closest('.card'); const id=el.dataset.id;
el.style.height=el.offsetHeight+'px';
el.style.pointerEvents='none';
requestAnimationFrame(()=>{el.classList.add('collapsing');el.style.height='0px';});
setTimeout(()=>{const i=cards.findIndex(c=>c.id===id); if(i>-1)cards.splice(i,1); render(); toast(id+' deleted');},270);
});
board.addEventListener('keydown',e=>{ if((e.key==='Delete'||e.key==='Backspace') && e.target.classList.contains('card')){ const btn=e.target.querySelector('.del'); if(btn)btn.click(); } });
```
Need CSS for collapsing:
`.card.collapsing{opacity:0;padding-top:0;padding-bottom:0;border-top-width:0;border-bottom-width:0;overflow:hidden;transform:scale(.96);}` plus base .card has `transition` including height? Add height .25s to card transitions. But hover transform transitions are OK.
Wait: .card base transition list: transform, box-shadow, background-color, border-color, opacity — add height? During drag we don't want height transition... height won't change during drag. Add `height .25s ease` to card transition. Inline set height initially → transition from that value to 0 ✓. Ensure margin also removed: gap handles spacing; when height 0 with gap 10 remains between previous sibling and next sibling — double gap visible briefly (270ms), minor. Set `margin:-5px 0` too? Overcomplicating; acceptable. Actually can shrink the gap: on collapsing `margin-bottom:-10px`? gap always applies. Skip, OK.
Drag code:
```js
let drag=null;
const placeholder=document.createElement('div'); placeholder.className='placeholder';
board.addEventListener('pointerdown',e=>{
if(e.pointerType!=='mouse'||e.button!==0)return;
const cardEl=e.target.closest('.card');
if(!cardEl||e.target.closest('.del'))return;
const r=cardEl.getBoundingClientRect();
drag={id:cardEl.dataset.id,cardEl,sx:e.clientX,sy:e.clientY,dx:e.clientX-r.left,dy:e.clientY-r.top,w:r.width,h:r.height,started:false,clone:null,lastBody:null};
e.preventDefault();
window.addEventListener('pointermove',onMove);
window.addEventListener('pointerup',onUp);
});
```
onMove:
```js
function onMove(e){
if(!drag)return;
if(!drag.started){
if(Math.hypot(e.clientX-drag.sx,e.clientY-drag.sy)<5)return;
startDrag();
}
drag.clone.style.left=(e.clientX-drag.dx)+'px';
drag.clone.style.top=(e.clientY-drag.dy)+'px';
const under=document.elementsFromPoint(e.clientX,e.clientY);
let body=null;
for(const n of under){const b=n.closest?n.closest('.col-body'):null; if(b){body=b;break;}}
if(!body){ /* also allow dropping on col-head area: use closest('.col') body */ }
```
Improve: find closest('.col') and use its .col-body — allow drop over column headers too:
```js
let colEl=null;
for(const n of under){ if(n.closest && (colEl=n.closest('.col'))) break; }
if(colEl) body=colEl.querySelector('.col-body');
if(body) positionPlaceholder(body,e.clientY);
```
positionPlaceholder:
```js
function positionPlaceholder(body,y){
document.querySelectorAll('.col.drop-hover').forEach(c=>{ if(c!==body.closest('.col')) c.classList.remove('drop-hover'); });
body.closest('.col').classList.add('drop-hover');
const sibs=[...body.querySelectorAll('.card')].filter(c=>c!==drag.cardEl && c.style.display!=='none');
// cards hidden by search are display none? we didn't render them — only matched cards rendered. Good, but during drag query is static anyway.
let before=null;
for(const s of sibs){ const r=s.getBoundingClientRect(); if(y < r.top + r.height/2){ before=s; break; } }
if(before) body.insertBefore(placeholder,before); else body.appendChild(placeholder);
// remove empty state if any
const emp=body.querySelector('.empty'); if(emp)emp.remove();
}
```
startDrag:
```js
function startDrag(){
drag.started=true;
const el=drag.cardEl;
const clone=el.cloneNode(true);
clone.classList.add('drag-clone');
clone.classList.remove('enter');
clone.style.width=drag.w+'px';
clone.style.left=(drag.sx-drag.dx)+'px';
clone.style.top=(drag.sy-drag.dy)+'px';
document.body.appendChild(clone);
drag.clone=clone;
placeholder.style.height=drag.h+'px';
el.classList.add('drag-src');
el.parentNode.insertBefore(placeholder,el); // start at original position? place before src; then positionPlaceholder immediately overwrites.
document.body.classList.add('is-dragging');
}
```
Place placeholder before el, and keep el in DOM (dimmed). On drop remove both. But placeholder and src coexist: src dim occupies space → column has both placeholder and dim card; when moving, src stays dim at its original position... hmm typical pattern: hide src (display:none) and let placeholder stand in. If src hidden, we don't need to exclude it from sibs. Let's set `el.style.display='none'`. Then positionPlaceholder only queries visible cards ✓. But getBoundingClientRect on hidden is zero — we filter it out. Use `body.querySelectorAll('.card')` and filter c!==drag.cardEl anyway.
But wait: src was display none, if drag is canceled, restore. OK.
onUp:
```js
function onUp(){
window.removeEventListener('pointermove',onMove);
window.removeEventListener('pointerup',onUp);
if(!drag)return;
const d=drag; drag=null;
document.querySelectorAll('.col.drop-hover').forEach(c=>c.classList.remove('drop-hover'));
if(!d.started){ d.cardEl.classList.remove('drag-src'); d.cardEl.style.display=''; return; }
const body=d.placeholder.parentElement;
const colId = body ? body.dataset.col : null;
let idx=0;
if(body){ idx=[...body.children].filter(n=>n.classList.contains('card')||n===d.placeholder).indexOf(d.placeholder); /* count cards before */ }
// count cards before placeholder:
let insertIdx=0;
if(body){ for(const n of body.children){ if(n===d.placeholder)break; if(n.classList.contains('card'))insertIdx++; } }
d.placeholder.remove(); d.clone.remove();
d.cardEl.classList.remove('drag-src'); d.cardEl.style.display='';
document.body.classList.remove('is-dragging');
if(!colId){ render(); return; }
moveCard(d.id,colId,insertIdx);
highlightId=d.id;
render();
}
```
moveCard as designed:
```js
function moveCard(id,col,idx){
const i=cards.findIndex(c=>c.id===id);
if(i<0)return;
const [card]=cards.splice(i,1);
card.col=col;
const colIdx=[]; cards.forEach((c,j)=>{if(c.col===col)colIdx.push(j);});
let at;
if(colIdx.length===0) at=cards.length;
else if(idx<=0) at=colIdx[0];
else if(idx>=colIdx.length) at=colIdx[colIdx.length-1]+1;
else at=colIdx[idx];
cards.splice(at,0,card);
}
```
abortDrag (Esc):
```js
function abortDrag(){
if(!drag)return; const d=drag; drag=null;
window.removeEventListener(...);
if(d.clone)d.clone.remove();
d.placeholder? placeholder.remove();
d.cardEl.classList.remove('drag-src'); d.cardEl.style.display='';
document.body.classList.remove('is-dragging');
document.querySelectorAll('.col.drop-hover').forEach(c=>c.classList.remove('drop-hover'));
}
```
placeholder is global singleton; remove() OK.
Note: render() after drop rebuilds — if query hides some cards, drop index is based on visible cards; state insertion uses index within column of the global array — mismatch: if some cards in target column are hidden by search, our computed insertIdx is within visible cards but moveCard uses full column index. Edge case: acceptable minor; to make it correct, compute insertion relative to visible subset... Let me handle: visible-only reordering when q non-empty. Simplify: map visible index → full index: in moveCard, pass visible list? Overengineering; but a wrong-order bug is noticeable if tested. Let's handle: at drop time, compute ids of visible cards in target column in order, and insertIdx points into that list. New order of full column list: take full column list, reorder it such that among visible ones, card goes at insertIdx; hidden ones keep relative position. Simplest robust approach: rebuild whole cards array:
```js
function moveCard(id,targetCol,visIdx){
const card=cards.find(c=>c.id===id); if(!card)return;
const q=query.trim().toLowerCase();
const others=cards.filter(c=>c.id!==id);
// Build new order: iterate others; for target column visible list, track insertion point
const out=[]; let placed=false; let visCount=0;
for(const c of others){
if(c.col===targetCol && !placed && q && ...
```
Getting complex. A simpler alternative: if q is empty (common case) straightforward path. If q non-empty, treat insertion against full column list but map by visible ids: find the visible card at position visIdx in target column (full array order) and insert before it; if visIdx >= visible count → after last visible card... hidden cards after that remain after. Good enough, deterministic:
```js
function moveCard(id,col,visIdx){
const i=cards.findIndex(c=>c.id===id); if(i<0)return;
const [card]=cards.splice(i,1); card.col=col;
const q=query.trim().toLowerCase();
const visibleInCol=cards.filter(c=>c.col===col&&(!q||c.title.toLowerCase().includes(q)));
let at;
if(visIdx<visibleInCol.length){ at=cards.indexOf(visibleInCol[visIdx]); }
else if(visibleInCol.length){ at=cards.indexOf(visibleInCol[visibleInCol.length-1])+1; }
else{
// no visible cards in column: place after last card of column, or at end
let last=-1; cards.forEach((c,j)=>{if(c.col===col)last=j;});
at=last+1 || cards.length; // note last=-1 → 0? if no cards of that col, at=0 would put before other columns' cards — better cards.length
at = last>=0? last+1 : cards.length;
}
cards.splice(at,0,card);
}
```
Good.
Toast:
```js
let toastTimer;
function toast(msg){ toastEl.textContent=msg; toastEl.classList.add('show'); clearTimeout(toastTimer); toastTimer=setTimeout(()=>toastEl.classList.remove('show'),2200); }
```
ICONS.trash svg string:
`<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18M8 6V4a1 1 0 0 1 1-1h6a1 1 0 0 1 1 1v2m3 0-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6M14 11v6"/></svg>`
Plus: `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round"><path d="M12 5v14M5 12h14"/></svg>`
Magnifier: `<svg ... stroke="currentColor"><circle cx="11" cy="11" r="7"/><path d="m20 20-3.8-3.8"/></svg>` — set stroke in CSS.
Clear ×: use svg `<svg width="12" height="12" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><path d="M6 6l12 12M18 6L6 18"/></svg>`.
Sun: circle + rays. Moon: crescent path `M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z`.
Column icons static in HTML:
- todo: `<svg viewBox="0 0 16 16" width="18" height="18"><circle cx="8" cy="8" r="6" fill="none" stroke="var(--todo)" stroke-width="2"/></svg>`
- doing: circle stroke + half fill: `<circle cx="8" cy="8" r="6" fill="none" stroke="var(--doing)" stroke-width="2"/><path d="M8 2 a6 6 0 0 1 0 12 Z" fill="var(--doing)"/>` (fills right half).
- done: `<circle cx="8" cy="8" r="7" fill="var(--done)"/><path d="M5 8.2 7.2 10.4 11.2 6" stroke="#fff" stroke-width="1.8" fill="none" stroke-linecap="round" stroke-linejoin="round"/>`
Logo: 3 bars colored by vars.
Chevron for select: `<svg class="chev" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"><path d="m6 9 6 6 6-6"/></svg>` positioned right.
.selwrap{position:relative}.selwrap select{appearance:none;padding-right:34px}.selwrap .chev{position:absolute;right:11px;top:50%;translate:0 -50%;pointer-events:none;color:var(--muted);}
Now let me verify viewport height: top bar may wrap on narrow; OK.
Check "nothing hidden at first paint": board visible immediately ✓, cards stagger in ✓.
Keyboard hint: it's not a game; but the rule about iframe keyboard focus applies to games needing keyboard. Our demo works with mouse; "/" shortcut is bonus. Add a hint "( / )" in the placeholder OK. Also a small helper line below board mentions shortcuts. Also modal can be opened with "n"? Skip.
Another live element: subtle animated highlight on "In progress" column header icon? A slow pulse on the doing icon? Might be gimmicky; skip. Meter animation on change is enough. Maybe add a small "live" dot? Skip.
Also add hover effect on columns? No.
Also handle count pop animation class removal — .pop animation runs once; re-trigger via reflow trick ✓.
Potential bug: render() during first paint, stagger delay uses staggerIdx — declared inside render.
Edge: highlightId with 'settle' — settle animation is defined; apply to new card / moved card. ✓ Remove class later? Rebuilt on each render so OK.
pointer-events none on .empty during drag.
is-dragging body: also disable card hover transforms? Hover transform on cards under cursor during drag might cause jitter: cursor grabs with clone under pointer; card under pointer may lift on hover (translateY) and shift rect → insertion calc uses midpoint; small shift OK but potential jitter loop. Mitigate: body.is-dragging .card{transform:none!important}. Good.
Disable card hover shadow during drag too? Same rule covers transform; shadow OK.
Clone initial position uses sx/sy (down point) and same dx/dy ✓.
Placeholder height set to card height; if moved between columns of same width OK.
One concern: `e.preventDefault()` on pointerdown prevents focus; del button click still works because click event fires? preventDefault on pointerdown doesn't cancel click (click is still dispatched? actually preventing default on pointerdown prevents compat mouse events (mousedown/up) but click still fires? Chrome: click is still dispatched after pointerdown preventDefault? I think click still fires (because it's derived from pointer?). Hmm risk: I've seen cases where preventing pointerdown default doesn't prevent click. To be safe, don't preventDefault globally; instead prevent text selection via CSS user-select none during drag (body.is-dragging). But during drag start we also need to prevent native image/text drag — cards have no images, text selection prevented via .card{user-select:none} already. So skip preventDefault entirely. Then if threshold not crossed, normal click behavior (focus etc) preserved.
But without preventDefault, if user starts drag, mouse text selection may still happen outside cards... body.is-dragging user-select none kicks in once started; between down and threshold small selection maybe — but .card user-select:none prevents it anyway. OK.
Also avoid drag start from search input etc — handler scoped to board cards only ✓.
Also guard: if drag started, then pointer leaves window and releases outside — window pointerup fires if released inside window; if released outside browser, might miss pointerup → stuck. Add 'pointerleave' on document? Use window 'blur' listener to abortDrag. Add window.addEventListener('blur', abortDragSafe). OK.
Re-check onMove early return when drag null (after abort). Since abort removes listeners, it's safe anyway.
Also `pointercancel`: add listener → abort.
Ensure onMove handles drag.started false → still calls positionPlaceholder after startDrag ✓ (startDrag is called then continues to set clone pos and placeholder).
During drag, placeholder in original column with src hidden; col-body min-height OK.
CSS detail: `.card.drag-src{display:none}` we set via inline style.display='none'. But .drag-src opacity class unused then; keep just in case.
OK, initial render call and events binding — wrap in IIFE, script at end of body.
Also matchMedia check guarded ✓.
Now let's finalize stats text chips and CSS:
```css
.chip{display:inline-flex;align-items:center;gap:6px;font-family:var(--mono);font-size:11.5px;color:var(--muted);}
.d-todo{background:var(--todo)}.d-doing{background:var(--doing)}.d-done{background:var(--done)}
```
statsText b style mono ink.
Search hint in placeholder: "Search cards…".
Foot line content: left: `Drag cards between columns · press / to search`. right: `In-memory demo — refresh to reset`.
Check no horizontal overflow: wrap max-width and padding ✓; top bar flex-wrap ✓.
Mobile ≤380: top bar wraps: brand row, then tools row; search flex-basis full: @media(max-width:640px){.top-tools{width:100%;flex-wrap:wrap}.search{flex:1 1 160px}}. Board 1 column. Modal padding OK.
Height at 800: all good.
Focus-visible global:
```css
:focus-visible{outline:2px solid var(--accent);outline-offset:2px;}
```
Inputs override with box-shadow; keep both OK (input has outline none? Set outline:none in .txt:focus — but need focus-visible ring: .txt:focus-visible{outline:none; box-shadow...}. Simplify: .txt:focus{border-color:accent;box-shadow ring;} and remove default outline: .txt{outline:none}. Seg inputs focus handled via span shadow. Buttons keep default :focus-visible outline (accent) — visible on dark bar OK.
Search input inside dark bar: focus ring uses --focus teal, OK.
Edge: badge colors for "medium" class name matches data prio values 'low'|'medium'|'high' ✓. Badge class names use full word; CSS .badge.medium etc ✓.
Modal close icon-btn sizing 34px maybe.
`.modal` backdrop blur also subtle OK.
OK let me compose it all. Also add `aria-live="polite"` to toast.
Write out SVGs with correct syntax (self-closing is OK in HTML5? Self-closing tags on SVG elements are allowed in HTML parser ✓).
Double-check theme toggle icons absolute positioning within 38px button: set svg to position:absolute; button is display grid place-items center; both svgs stacked centered ✓ give width/height 18.
Also handle meta viewport. Title.
Potential console errors: none anticipated. Avoid `color-mix`. Avoid `translate` property? Chrome supports ✓ but to be safe use transform? .toast uses translate property + keyframes? Only set translate property on base and .show; supported in Chrome 104+. Keep, but for safety switch to transform: translateX(-50%) translateY(...) combined. Let me use transform to be safe:
`.toast{transform:translate(-50%,16px);} .toast.show{transform:translate(-50%,0);}` ✓
.clear button: position absolute right 6px top 50% translate(0,-50%) size 22 grid center, color bar-mut, hover bar-ink; display none; .search.has .clear{display:grid}.
Also `aria-hidden` toggle on modal + `inert`? Skip inert.
Let me write the final file. Aim for clean formatting, comments minimal.
One more check: requirement "delete cards via a button on the card" ✓ (trash button). "Add cards via a small modal form (title required)" ✓. "Drag & drop between columns with clear visual drop indicator" ✓ placeholder + column glow + clone. "Search filters live" ✓ with highlighting. "Light/dark theme toggle" ✓. Counts ✓. 6 cards ✓.
Also ensure card titles escape when used in aria-label with quotes — we compose aria-label with card.id only, safe.
Let me write. Also double-check `void cnt.offsetWidth;` is OK.
Also initial data-theme: set "light" in markup; JS may switch to dark before first paint (script at end runs before first paint usually). ✓
Enter stagger: .enter animation with delay 'both' fill — cards will be initially invisible until delay passes: with 'both' and from{opacity:0} they'll be hidden before delay ✓ good.
But .enter on cards rebuilt later? Only add when firstRender; after first render, firstRender=false ✓.
settle animation: 0% scale 1.045 → 1; add subtle shadow? OK.
Also on new card added during active search — if it doesn't match, won't be visible; acceptable (maybe clear search on add? better UX: if card hidden by query, toast still says added. Keep simple: leave as is. Actually better: on add, if query non-empty and card doesn't match, still OK.)
Modal defaults: column select default "todo"; maybe preselect based on... keep todo.
Reset radios on closeModal via form.reset() → medium checked ✓, select → first option todo ✓.
Final: count "pop" reflow trick on each render may retrigger pop even without change? Guarded with label comparison ✓.
Also updateStats called on each render — meter widths transition ✓.
Edge: totals when cards empty (all deleted) → total 0 → pct '—', meter widths 0 ✓ stats text shows 0 cards ✓ columns show empty states ✓. Good.
Write out the code.
`.card` cursor grab also verify; add `touch-action:manipulation`? Not needed since we don't handle touch drag; set anyway to avoid double-tap zoom on buttons. OK.
Potential CSS bug: .card has transition including height .25s — drag-src hidden via inline display none is OK.
`body.is-dragging .card:hover{transform:none}` covered by body.is-dragging .card{transform:none!important;box-shadow:var(--shadow)!important}. But .card.removing uses transform — during drag we wouldn't remove. OK. But settle/enter animations also transform — no dragging on those moments.
!Important override also affects clone? Clone is .drag-clone with own transform and is appended to body not .col; selector `body.is-dragging .card` matches clone too (clone has .card class!). Clone's transform: .drag-clone{transform:rotate...} without !important loses to body.is-dragging .card !important. Fix: exclude: `.card:not(.drag-clone)`. Write:
`body.is-dragging .card:not(.drag-clone){transform:none!important;cursor:grabbing;}`
And .drag-clone keeps its own transform ✓ (its base rule .card's hover transform overridden by own class? .drag-clone has transition none and own transform; specificity of .card:hover vs .drag-clone — .drag-clone also defined after; hover applies but transform is overridden by .drag-clone? Both specificity 0,2,0? .card:hover = class+pseudo = (0,2,0); .drag-clone=(0,1,0) → hover wins if later? Order matters: define .drag-clone rules after hover rule with higher specificity `.drag-clone{...}` add `!important`? Or simply: `.card.drag-clone{...}` specificity (0,2,0) equal, later wins ✓. Use .card.drag-clone selector.
Also hide .del on clone: `.card.drag-clone .del{display:none}`.
Shadow on clone too.
Also placeholder is .col-body's flex child ✓ participates in gap ✓.
I think we're ready. Also add `overscroll-behavior` none? Skip.
Also `@media (prefers-reduced-motion: reduce){*{animation-duration:.01ms!important;transition-duration:.01ms!important}}` — nice touch of care. Add.
Let me write the final. Careful with quoting for strings containing quotes inside JS template literals, and HTML inside script — avoid `</script>` sequences in strings (none).
aria: modal role dialog: add `role="dialog" aria-modal="true" aria-labelledby="modalTitle"` on panel? Put on .modal wrapper. OK.
Final code below. Let me sanity-check the JS once more in my head after writing.
One subtle: in render, use `cards.forEach` to append cards per column preserving global order ✓.
In positionPlaceholder, `body.querySelectorAll('.card')` includes hidden? None hidden (filtered at render). Excludes drag-src via filter c!==drag.cardEl (which is display none anyway) ✓.
onUp computes insertIdx by iterating body.children and counting cards before placeholder ✓ (src is display none but still has .card class and is a child — it's counted! src is in same body if dragging within same column and placeholder is after src position → inflates idx. Exclude: skip d.cardEl when counting:
`if(n===d.cardEl) continue;` ✓.
Also when moving within same column: cards array operation handles it: remove, then compute visibleInCol after removal ✓.
moveCard: if target column only has the dragged card (now removed) → visibleInCol empty → last=-1 → at=cards.length → append at end. Position among other columns visually OK? Rendering groups by column so order among columns doesn't visually matter. ✓
Great, also on drop where nothing changed (same column, same spot), re-render + settle — OK.
Time to write. Keep CSS organized. Also define --accent-soft per theme:
light: rgba(14,147,132,.10); dark: rgba(63,196,181,.14). --accent dark: #2fbcae.
--focus: use same accent rgba.
OK let me produce the answer: single html code block. No text outside.