MiniMax M3.1 Flash
by MiniMaxReasoningClean
Running live in a sandboxed frame. Click inside it before using the keyboard.
How it went
- Total time
- 1 min 39 s
- Cost
- $0.012
- Output tokens
- 19,956
- Spent thinking
- 10,389 tokens
- Speed
- 201.8 tok/s
- Console errors
- 0
- First token after
- 461 ms
- First visible text after
- 1 min 9 s
- Prompt tokens
- 669
- Code size
- 31.4 KB
- Finish reason
- stop
- Served by
- Nebius
- Model id
minimax/minimax-m3.1-flash- Reasoning sent
model default- Max output
- 524,288 tokens
- Generated
- Nov 13, 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 · 733 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>Task Board — Kanban</title>
<style>
:root{
--bg:#eef1f8;
--bg-grad: radial-gradient(1200px 600px at 10% -10%, #dfe6ff 0%, rgba(223,230,255,0) 60%),
radial-gradient(900px 500px at 90% 0%, #ffe9f2 0%, rgba(255,233,242,0) 55%);
--surface:#ffffff;
--surface-2:#f4f6fb;
--surface-3:#eef1f7;
--text:#141926;
--text-soft:#48506a;
--muted:#7a8296;
--border:#e3e7f0;
--border-strong:#d3d9e6;
--shadow-sm:0 1px 2px rgba(16,24,40,.06),0 1px 3px rgba(16,24,40,.06);
--shadow-md:0 6px 18px rgba(16,24,40,.08),0 2px 6px rgba(16,24,40,.05);
--shadow-lg:0 24px 60px rgba(16,24,40,.18);
--accent:#4f46e5;
--accent-soft:rgba(79,70,229,.10);
--hi-bg:#fee4e2;--hi-fg:#b42318;--hi-bd:#fbc9c5;
--md-bg:#fef0c7;--md-fg:#b54708;--md-bd:#f8dfa0;
--lo-bg:#dcfce7;--lo-fg:#067647;--lo-bd:#a9e5c3;
--radius:14px;
}
html[data-theme="dark"]{
--bg:#0b0e15;
--bg-grad: radial-gradient(1100px 600px at 8% -10%, #1b2140 0%, rgba(27,33,64,0) 60%),
radial-gradient(900px 500px at 95% 0%, #2a1c34 0%, rgba(42,28,52,0) 55%);
--surface:#141926;
--surface-2:#1a2030;
--surface-3:#212838;
--text:#e8ecf6;
--text-soft:#b9c1d4;
--muted:#8b93a7;
--border:#242c3c;
--border-strong:#313a4d;
--shadow-sm:0 1px 2px rgba(0,0,0,.5);
--shadow-md:0 8px 22px rgba(0,0,0,.45);
--shadow-lg:0 28px 70px rgba(0,0,0,.6);
--accent:#8b8cf9;
--accent-soft:rgba(139,140,249,.16);
--hi-bg:rgba(255,107,107,.14);--hi-fg:#ff9b94;--hi-bd:rgba(255,107,107,.32);
--md-bg:rgba(250,190,60,.14);--md-fg:#f6cd63;--md-bd:rgba(250,190,60,.30);
--lo-bg:rgba(74,222,128,.13);--lo-fg:#6ee7a0;--lo-bd:rgba(74,222,128,.28);
}
*{box-sizing:border-box}
html,body{height:100%}
body{
margin:0;
background:var(--bg);
background-image:var(--bg-grad);
background-attachment:fixed;
color:var(--text);
font-family:system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif;
font-size:14px;
-webkit-font-smoothing:antialiased;
transition:background-color .25s ease,color .25s ease;
}
body.modal-open{overflow:hidden}
body.dragging{user-select:none;cursor:grabbing}
.app{min-height:100%;display:flex;flex-direction:column}
/* ---------- top bar ---------- */
.topbar{
display:flex;align-items:center;justify-content:space-between;gap:18px;
padding:16px 24px;flex-wrap:wrap;
}
.brand{display:flex;align-items:center;gap:12px;min-width:0}
.logo{
width:40px;height:40px;border-radius:12px;display:grid;place-items:center;flex:none;
background:linear-gradient(140deg,#6366f1,#8b5cf6 55%,#ec4899);
box-shadow:0 6px 16px rgba(99,102,241,.35);
}
.logo svg{width:22px;height:22px;fill:#fff}
.brand-text h1{margin:0;font-size:17px;font-weight:700;letter-spacing:-.2px;white-space:nowrap}
.brand-text p{margin:1px 0 0;font-size:12px;color:var(--muted);white-space:nowrap}
.tools{display:flex;align-items:center;gap:10px;flex-wrap:wrap}
.search{
position:relative;display:flex;align-items:center;gap:8px;
background:var(--surface);border:1px solid var(--border);border-radius:11px;
padding:0 10px;height:38px;width:270px;box-shadow:var(--shadow-sm);
transition:border-color .18s ease,box-shadow .18s ease;
}
.search:focus-within{border-color:var(--accent);box-shadow:0 0 0 3px var(--accent-soft)}
.search .s-icon{width:16px;height:16px;color:var(--muted);flex:none}
.search input{
border:0;background:transparent;color:var(--text);font:inherit;font-size:13px;
outline:none;width:100%;min-width:0;padding:0;
}
.search input::placeholder{color:var(--muted)}
.clear-btn{
border:0;background:transparent;color:var(--muted);cursor:pointer;font-size:17px;line-height:1;
padding:2px 4px;border-radius:6px;display:none;
}
.clear-btn:hover{background:var(--surface-3);color:var(--text)}
.search.has-query .clear-btn{display:block}
.icon-btn{
width:38px;height:38px;border-radius:11px;border:1px solid var(--border);
background:var(--surface);color:var(--text-soft);display:grid;place-items:center;cursor:pointer;
box-shadow:var(--shadow-sm);transition:.18s ease;
}
.icon-btn:hover{color:var(--text);transform:translateY(-1px);box-shadow:var(--shadow-md)}
.icon-btn svg{width:18px;height:18px}
.ico-moon{display:none}
html[data-theme="dark"] .ico-sun{display:none}
html[data-theme="dark"] .ico-moon{display:block}
.btn{
border:1px solid transparent;border-radius:11px;height:38px;padding:0 15px;
font:inherit;font-size:13px;font-weight:600;cursor:pointer;display:inline-flex;align-items:center;gap:7px;
transition:.18s ease;white-space:nowrap;
}
.btn.primary{
background:linear-gradient(140deg,var(--accent),#7c3aed);color:#fff;
box-shadow:0 6px 16px rgba(79,70,229,.32);
}
html[data-theme="dark"] .btn.primary{box-shadow:0 6px 18px rgba(139,140,249,.28);color:#0b0e15}
.btn.primary:hover{transform:translateY(-1px);filter:brightness(1.05)}
.btn.ghost{background:var(--surface);border-color:var(--border);color:var(--text-soft);box-shadow:var(--shadow-sm)}
.btn.ghost:hover{color:var(--text);border-color:var(--border-strong)}
.btn .plus{font-size:17px;line-height:1;transform:translateY(-1px)}
/* ---------- board ---------- */
.board{
flex:1;min-height:0;display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:16px;
grid-template-rows:minmax(0,1fr);padding:4px 24px 16px;
}
.column{
min-height:0;display:flex;flex-direction:column;
background:var(--surface);border:1px solid var(--border);border-radius:18px;
box-shadow:var(--shadow-sm);overflow:hidden;position:relative;
transition:border-color .18s ease,box-shadow .18s ease,background .18s ease;
}
.column::before{
content:"";position:absolute;inset:0 0 auto 0;height:3px;background:var(--col-accent);opacity:.85;
}
.column.drop-target{
border-color:var(--col-accent);
box-shadow:0 0 0 3px color-mix(in srgb,var(--col-accent) 22%,transparent),var(--shadow-md);
background:color-mix(in srgb,var(--col-accent) 5%,var(--surface));
}
.col-head{
display:flex;align-items:center;gap:9px;padding:14px 14px 10px;border-bottom:1px solid var(--border);
}
.col-dot{width:9px;height:9px;border-radius:50%;background:var(--col-accent);flex:none;box-shadow:0 0 0 3px color-mix(in srgb,var(--col-accent) 20%,transparent)}
.col-head h2{margin:0;font-size:13.5px;font-weight:700;letter-spacing:.2px}
.col-count{
margin-left:auto;font-size:11.5px;font-weight:700;color:var(--muted);
background:var(--surface-2);border:1px solid var(--border);border-radius:999px;padding:2px 9px;
}
.col-count.filtered{color:var(--accent);border-color:color-mix(in srgb,var(--accent) 35%,transparent);background:var(--accent-soft)}
.col-body{
flex:1;min-height:0;overflow-y:auto;overflow-x:hidden;padding:12px;display:flex;flex-direction:column;gap:10px;
scrollbar-width:thin;scrollbar-color:var(--border-strong) transparent;
}
.col-body::-webkit-scrollbar{width:9px}
.col-body::-webkit-scrollbar-thumb{background:var(--border-strong);border-radius:99px;border:3px solid var(--surface)}
.col-add{
margin:0 12px 12px;border:1px dashed var(--border-strong);background:var(--surface-2);color:var(--muted);
border-radius:11px;height:36px;font:inherit;font-size:12.5px;font-weight:600;cursor:pointer;transition:.18s ease;
}
.col-add:hover{color:var(--col-accent);border-color:var(--col-accent);background:color-mix(in srgb,var(--col-accent) 7%,var(--surface))}
/* ---------- card ---------- */
.card{
background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);
padding:11px 12px 10px;box-shadow:var(--shadow-sm);cursor:grab;position:relative;
transition:box-shadow .18s ease,transform .18s ease,border-color .18s ease,opacity .18s ease;
animation:pop .3s cubic-bezier(.2,.9,.3,1.2) both;
}
@keyframes pop{from{opacity:0;transform:translateY(6px) scale(.98)}to{opacity:1;transform:none}}
.card:hover{border-color:var(--border-strong);box-shadow:var(--shadow-md);transform:translateY(-1px)}
.card:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
.card-head{display:flex;align-items:center;gap:8px;margin-bottom:8px}
.badge{
font-size:10.5px;font-weight:800;text-transform:uppercase;letter-spacing:.6px;
padding:3px 8px;border-radius:999px;border:1px solid transparent;
}
.badge.high{background:var(--hi-bg);color:var(--hi-fg);border-color:var(--hi-bd)}
.badge.medium{background:var(--md-bg);color:var(--md-fg);border-color:var(--md-bd)}
.badge.low{background:var(--lo-bg);color:var(--lo-fg);border-color:var(--lo-bd)}
.del{
margin-left:auto;width:24px;height:24px;border-radius:7px;border:0;background:transparent;
color:var(--muted);cursor:pointer;display:grid;place-items:center;opacity:0;transition:.15s ease;flex:none;
}
.del svg{width:14px;height:14px}
.card:hover .del,.card:focus-within .del{opacity:1}
.del:hover{background:var(--hi-bg);color:var(--hi-fg)}
@media (hover:none){.del{opacity:.65}}
.card-title{margin:0 0 9px;font-size:13.6px;font-weight:600;line-height:1.42;color:var(--text);letter-spacing:-.1px}
.card-meta{display:flex;align-items:center;gap:8px}
.tag{
font-size:10.5px;font-weight:600;color:var(--text-soft);background:var(--surface-2);
border:1px solid var(--border);border-radius:6px;padding:2px 7px;
}
.ref{margin-left:auto;font-size:10px;color:var(--muted);font-family:ui-monospace,SFMono-Regular,Menlo,monospace;letter-spacing:.4px}
.card.is-dragging{
opacity:.5;cursor:grabbing;border:2px dashed var(--col-accent);
background:color-mix(in srgb,var(--col-accent) 9%,var(--surface));box-shadow:none;transform:none;
}
.card.removing{opacity:0;transform:scale(.94);transition:opacity .16s ease,transform .16s ease}
.ghost{
position:fixed;z-index:999;pointer-events:none;border-radius:var(--radius);
box-shadow:var(--shadow-lg);transform:rotate(1.4deg) scale(1.02);opacity:.97;
background:var(--surface);
}
.ghost .del{opacity:0}
.empty{
border:1px dashed var(--border-strong);border-radius:12px;padding:18px 12px;text-align:center;
color:var(--muted);font-size:12.5px;background:var(--surface-2);
}
.empty b{display:block;color:var(--text-soft);font-size:13px;margin-bottom:3px}
/* ---------- status bar ---------- */
.statusbar{
display:flex;align-items:center;justify-content:space-between;gap:14px;flex-wrap:wrap;
padding:6px 26px 16px;color:var(--muted);font-size:12px;
}
.statusbar .tip{opacity:.9}
.statusbar b{color:var(--text-soft);font-weight:600}
/* ---------- modal ---------- */
.backdrop{
position:fixed;inset:0;background:rgba(11,14,21,.55);backdrop-filter:blur(3px);
display:grid;place-items:center;padding:20px;z-index:100;
opacity:0;pointer-events:none;transition:opacity .18s ease;
}
.backdrop.open{opacity:1;pointer-events:auto}
.modal{
width:100%;max-width:420px;background:var(--surface);border:1px solid var(--border);
border-radius:18px;box-shadow:var(--shadow-lg);padding:20px;
transform:translateY(10px) scale(.98);transition:transform .2s cubic-bezier(.2,.9,.3,1.1);
}
.backdrop.open .modal{transform:none}
.modal h2{margin:0 0 16px;font-size:16px;font-weight:700;letter-spacing:-.2px}
.field{display:block;margin-bottom:14px}
.label{display:block;font-size:11.5px;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:.7px;margin-bottom:6px}
.label em{font-style:normal;text-transform:none;letter-spacing:0;font-weight:600;opacity:.75}
.field input[type=text],.field select{
width:100%;height:40px;border-radius:11px;border:1px solid var(--border);background:var(--surface-2);
color:var(--text);font:inherit;font-size:13.5px;padding:0 12px;outline:none;transition:.16s ease;
}
.field input[type=text]:focus,.field select:focus{border-color:var(--accent);box-shadow:0 0 0 3px var(--accent-soft);background:var(--surface)}
.field input::placeholder{color:var(--muted)}
.err{display:block;color:var(--hi-fg);font-size:11.5px;font-weight:600;margin-top:6px;opacity:0;max-height:0;overflow:hidden;transition:.15s ease}
.field.invalid input{border-color:var(--hi-fg);box-shadow:0 0 0 3px var(--hi-bg)}
.field.invalid .err{opacity:1;max-height:30px}
.seg{display:grid;grid-template-columns:repeat(3,1fr);gap:6px;background:var(--surface-2);border:1px solid var(--border);border-radius:12px;padding:4px}
.seg input{position:absolute;opacity:0;width:0;height:0}
.seg label span{
display:block;text-align:center;padding:8px 4px;border-radius:9px;cursor:pointer;
font-size:12.5px;font-weight:700;color:var(--muted);transition:.15s ease;
}
.seg label:hover span{color:var(--text)}
.seg input[value=low]:checked+span{background:var(--surface);color:var(--lo-fg);box-shadow:var(--shadow-sm)}
.seg input[value=medium]:checked+span{background:var(--surface);color:var(--md-fg);box-shadow:var(--shadow-sm)}
.seg input[value=high]:checked+span{background:var(--surface);color:var(--hi-fg);box-shadow:var(--shadow-sm)}
.modal-actions{display:flex;justify-content:flex-end;gap:9px;margin-top:18px}
/* ---------- responsive ---------- */
@media (max-width:900px){
.topbar{padding:14px 16px;gap:12px}
.tools{width:100%}
.search{flex:1;width:auto}
.board{grid-template-columns:1fr;grid-template-rows:none;padding:2px 16px 14px;overflow-y:auto;flex:none}
.column{max-height:none}
.col-body{overflow:visible;max-height:none}
.statusbar{padding:4px 16px 16px}
.statusbar .tip{display:none}
}
@media (max-width:420px){
.brand-text p{display:none}
.btn.primary span:not(.plus){display:none}
}
</style>
</head>
<body>
<div class="app">
<header class="topbar">
<div class="brand">
<div class="logo" aria-hidden="true">
<svg viewBox="0 0 24 24"><path d="M3 4h5v16H3zM10 4h5v10h-5zM17 4h4v13h-4z"/></svg>
</div>
<div class="brand-text">
<h1>Task Board</h1>
<p>Product sprint · Week 12</p>
</div>
</div>
<div class="tools">
<label class="search" id="searchWrap">
<svg class="s-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"/><path d="m20 20-3.6-3.6"/></svg>
<input id="searchInput" type="text" placeholder="Search cards by title…" aria-label="Search cards by title" autocomplete="off">
<button type="button" class="clear-btn" id="clearSearch" aria-label="Clear search">×</button>
</label>
<button id="themeBtn" class="icon-btn" title="Toggle light / dark theme" aria-label="Toggle theme">
<svg class="ico-sun" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4"/></svg>
<svg class="ico-moon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round"><path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z"/></svg>
</button>
<button id="addBtn" class="btn primary"><span class="plus">+</span> <span>Add card</span></button>
</div>
</header>
<main class="board" id="board"></main>
<footer class="statusbar">
<span id="stats"></span>
<span class="tip">Drag cards with the mouse · double-click a card to edit · ← → moves a focused card</span>
</footer>
</div>
<!-- modal -->
<div class="backdrop" id="backdrop">
<div class="modal" role="dialog" aria-modal="true" aria-labelledby="modalTitle">
<h2 id="modalTitle">New card</h2>
<form id="cardForm" novalidate>
<label class="field" id="titleField">
<span class="label">Title <em style="color:var(--hi-fg)">*</em></span>
<input id="fTitle" type="text" placeholder="e.g. Fix flaky checkout test" autocomplete="off">
<span class="err">Title is required — give the card a short, clear name.</span>
</label>
<div class="field">
<span class="label">Priority</span>
<div class="seg" role="radiogroup" aria-label="Priority">
<label><input type="radio" name="priority" value="low"><span>Low</span></label>
<label><input type="radio" name="priority" value="medium" checked><span>Medium</span></label>
<label><input type="radio" name="priority" value="high"><span>High</span></label>
</div>
</div>
<div class="field">
<span class="label">Column</span>
<select id="fCol"></select>
</div>
<div class="field">
<span class="label">Label <em>(optional)</em></span>
<input id="fTag" type="text" placeholder="Design, Backend, Growth…" autocomplete="off">
</div>
<div class="modal-actions">
<button type="button" class="btn ghost" id="cancelBtn">Cancel</button>
<button type="submit" class="btn primary" id="submitBtn">Add card</button>
</div>
</form>
</div>
</div>
<script>
(function(){
"use strict";
/* ---------------- state ---------------- */
let uid = 100;
const state = {
columns:[
{ id:'todo', title:'To do', accent:'#f59e0b', cards:[
{ id:'c1', title:'Redesign the pricing page', priority:'high', tag:'Design', ref:'KB-101' },
{ id:'c2', title:'Draft the Q3 launch announcement', priority:'medium', tag:'Growth', ref:'KB-104' },
{ id:'c3', title:'Audit third-party tracking scripts', priority:'low', tag:'Infra', ref:'KB-107' }
]},
{ id:'doing', title:'In progress', accent:'#6366f1', cards:[
{ id:'c4', title:'Migrate authentication to OAuth', priority:'high', tag:'Backend', ref:'KB-088' },
{ id:'c5', title:'Build the reporting dashboard', priority:'medium', tag:'Frontend', ref:'KB-093' }
]},
{ id:'done', title:'Done', accent:'#10b981', cards:[
{ id:'c6', title:'Ship dark mode for the settings screen', priority:'low', tag:'Frontend', ref:'KB-076' }
]}
],
query:''
};
const board = document.getElementById('board');
const searchInput= document.getElementById('searchInput');
const searchWrap = document.getElementById('searchWrap');
const statsEl = document.getElementById('stats');
const backdrop = document.getElementById('backdrop');
const form = document.getElementById('cardForm');
const titleField = document.getElementById('titleField');
const fTitle = document.getElementById('fTitle');
const fCol = document.getElementById('fCol');
const fTag = document.getElementById('fTag');
const modalTitle = document.getElementById('modalTitle');
const submitBtn = document.getElementById('submitBtn');
const TRASH = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18M8 6V4h8v2M6 6l1 14h10l1-14"/></svg>';
/* ---------------- helpers ---------------- */
const q = () => state.query.trim().toLowerCase();
const matches = (card) => !q() || card.title.toLowerCase().includes(q());
const colOf = (id) => state.columns.find(c => c.id === id);
const findCard = (id) => { for (const c of state.columns){ const f = c.cards.find(x=>x.id===id); if(f) return {col:c, card:f}; } return null; };
/* ---------------- build board ---------------- */
state.columns.forEach(col=>{
const sec = document.createElement('section');
sec.className = 'column';
sec.dataset.col = col.id;
sec.style.setProperty('--col-accent', col.accent);
sec.innerHTML =
'<header class="col-head">' +
'<span class="col-dot"></span>' +
'<h2>'+col.title+'</h2>' +
'<span class="col-count" data-count="'+col.id+'">0</span>' +
'</header>' +
'<div class="col-body" data-list="'+col.id+'"></div>' +
'<button class="col-add" data-addcol="'+col.id+'">+ Add card</button>';
board.appendChild(sec);
const opt = document.createElement('option');
opt.value = col.id; opt.textContent = col.title;
fCol.appendChild(opt);
});
/* ---------------- rendering ---------------- */
let firstRender = true;
function render(animateIds){
const anim = animateIds || [];
state.columns.forEach(col=>{
const list = board.querySelector('[data-list="'+col.id+'"]');
const visible = col.cards.filter(matches);
list.textContent = '';
if(!visible.length){
const empty = document.createElement('div');
empty.className = 'empty';
empty.innerHTML = q()
? '<b>No matches</b>Try another title.'
: '<b>Nothing here yet</b>Drop a card or add one.';
list.appendChild(empty);
}else{
visible.forEach(card=>{
const el = makeCard(card);
if(anim.indexOf(card.id) > -1) el.style.animationDelay = '0s';
list.appendChild(el);
});
}
const badge = board.querySelector('[data-count="'+col.id+'"]');
badge.textContent = q() ? (visible.length + '/' + col.cards.length) : col.cards.length;
badge.classList.toggle('filtered', !!q());
});
const total = state.columns.reduce((n,c)=>n+c.cards.length,0);
const parts = state.columns.map(c=>'<b>'+c.cards.length+'</b> '+c.title.toLowerCase()).join(' · ');
statsEl.innerHTML = '<b>'+total+'</b> cards · ' + parts + (q() ? ' · filtered by “'+escapeHtml(state.query)+'”' : '');
firstRender = false;
}
function escapeHtml(s){ return s.replace(/[&<>"']/g, m=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[m])); }
function makeCard(card){
const el = document.createElement('article');
el.className = 'card';
el.dataset.id = card.id;
el.tabIndex = 0;
el.setAttribute('aria-label', card.title + ', ' + card.priority + ' priority');
el.innerHTML =
'<div class="card-head">' +
'<span class="badge '+card.priority+'">'+card.priority+'</span>' +
'<button class="del" title="Delete card" aria-label="Delete card">'+TRASH+'</button>' +
'</div>' +
'<h3 class="card-title"></h3>' +
'<div class="card-meta"><span class="tag"></span><span class="ref"></span></div>';
el.querySelector('.card-title').textContent = card.title;
const tagEl = el.querySelector('.tag');
if(card.tag){ tagEl.textContent = card.tag; } else { tagEl.remove(); }
el.querySelector('.ref').textContent = card.ref || '';
return el;
}
render();
/* ---------------- search ---------------- */
searchInput.addEventListener('input', e=>{
state.query = e.target.value;
searchWrap.classList.toggle('has-query', !!e.target.value);
render();
});
document.getElementById('clearSearch').addEventListener('click', ()=>{
searchInput.value=''; state.query=''; searchWrap.classList.remove('has-query');
render(); searchInput.focus();
});
/* ---------------- theme ---------------- */
const themeBtn = document.getElementById('themeBtn');
themeBtn.addEventListener('click', ()=>{
const root = document.documentElement;
const dark = root.getAttribute('data-theme') === 'dark';
root.setAttribute('data-theme', dark ? 'light' : 'dark');
});
/* ---------------- modal ---------------- */
let mode = 'add', editingId = null, lastFocus = null;
function openModal(m, cardId, presetCol){
mode = m; editingId = cardId || null;
lastFocus = document.activeElement;
titleField.classList.remove('invalid');
if(m === 'edit'){
const found = findCard(cardId);
const c = found.card;
modalTitle.textContent = 'Edit card';
submitBtn.textContent = 'Save changes';
fTitle.value = c.title;
fCol.value = found.col.id;
fTag.value = c.tag || '';
const radio = form.querySelector('input[name=priority][value="'+c.priority+'"]');
if(radio) radio.checked = true;
}else{
modalTitle.textContent = 'New card';
submitBtn.textContent = 'Add card';
form.reset();
form.querySelector('input[name=priority][value="medium"]').checked = true;
fCol.value = presetCol || 'todo';
fTag.value = '';
}
backdrop.classList.add('open');
document.body.classList.add('modal-open');
setTimeout(()=>{ fTitle.focus(); fTitle.select && fTitle.select(); }, 70);
}
function closeModal(){
backdrop.classList.remove('open');
document.body.classList.remove('modal-open');
if(lastFocus && lastFocus.focus) lastFocus.focus();
}
document.getElementById('addBtn').addEventListener('click', ()=>openModal('add'));
document.getElementById('cancelBtn').addEventListener('click', closeModal);
backdrop.addEventListener('mousedown', e=>{ if(e.target === backdrop) closeModal(); });
document.addEventListener('keydown', e=>{
if(e.key === 'Escape' && backdrop.classList.contains('open')) closeModal();
});
form.addEventListener('submit', e=>{
e.preventDefault();
const title = fTitle.value.trim();
if(!title){
titleField.classList.add('invalid');
fTitle.focus();
return;
}
titleField.classList.remove('invalid');
const priority = (form.querySelector('input[name=priority]:checked')||{}).value || 'medium';
const tag = fTag.value.trim();
if(mode === 'edit'){
const found = findCard(editingId);
found.card.title = title; found.card.priority = priority; found.card.tag = tag;
closeModal(); render([editingId]);
}else{
const card = { id:'c'+(++uid), title:title, priority:priority, tag:tag, ref:'KB-'+(110+uid) };
const col = colOf(fCol.value) || state.columns[0];
col.cards.unshift(card);
closeModal(); render([card.id]);
}
});
fTitle.addEventListener('input', ()=> titleField.classList.remove('invalid'));
/* ---------------- add per column ---------------- */
board.addEventListener('click', e=>{
const addBtn = e.target.closest('[data-addcol]');
if(addBtn){ openModal('add', null, addBtn.dataset.addcol); return; }
const del = e.target.closest('.del');
if(del){
const cardEl = del.closest('.card');
const found = findCard(cardEl.dataset.id);
if(!found) return;
cardEl.classList.add('removing');
setTimeout(()=>{
found.col.cards.splice(found.col.cards.indexOf(found.card),1);
render();
},150);
return;
}
});
board.addEventListener('dblclick', e=>{
const cardEl = e.target.closest('.card');
if(cardEl && !e.target.closest('.del')) openModal('edit', cardEl.dataset.id);
});
/* ---------------- drag & drop (mouse) ---------------- */
let drag = null;
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 rect = cardEl.getBoundingClientRect();
drag = {
id: cardEl.dataset.id, el: cardEl, started:false,
sx:e.clientX, sy:e.clientY,
offX: e.clientX - rect.left, offY: e.clientY - rect.top,
w: rect.width, pointerId:e.pointerId
};
document.addEventListener('pointermove', onMove);
document.addEventListener('pointerup', onUp);
document.addEventListener('pointercancel', onUp);
});
function onMove(e){
if(!drag) return;
if(!drag.started){
if(Math.abs(e.clientX-drag.sx) < 5 && Math.abs(e.clientY-drag.sy) < 5) return;
startDrag();
}
e.preventDefault();
positionGhost(e.clientX, e.clientY);
updateDropTarget(e.clientX, e.clientY);
}
function startDrag(){
drag.started = true;
const el = drag.el;
const ghost = el.cloneNode(true);
ghost.classList.add('ghost');
ghost.classList.remove('enter');
ghost.style.width = drag.w + 'px';
ghost.style.left = '0px'; ghost.style.top = '0px';
document.body.appendChild(ghost);
drag.ghost = ghost;
el.classList.add('is-dragging');
document.body.classList.add('dragging');
drag.el.parentElement.closest('.column').classList.add('drop-target');
}
function positionGhost(x,y){
if(!drag.ghost) return;
drag.ghost.style.transform = 'translate('+(x-drag.offX)+'px,'+(y-drag.offY)+'px) rotate(1.4deg) scale(1.02)';
}
function updateDropTarget(x,y){
const under = document.elementFromPoint(x,y);
const colEl = under ? under.closest('.column') : null;
if(!colEl) return;
board.querySelectorAll('.column.drop-target').forEach(c=>{ if(c!==colEl) c.classList.remove('drop-target'); });
colEl.classList.add('drop-target');
const list = colEl.querySelector('.col-body');
const cards = Array.prototype.filter.call(list.querySelectorAll('.card'), c=>c!==drag.el);
let ref = null;
for(const c of cards){
const r = c.getBoundingClientRect();
if(y < r.top + r.height/2){ ref = c; break; }
}
list.insertBefore(drag.el, ref);
}
function onUp(){
document.removeEventListener('pointermove', onMove);
document.removeEventListener('pointerup', onUp);
document.removeEventListener('pointercancel', onUp);
if(!drag) return;
const d = drag; drag = null;
if(d.ghost) d.ghost.remove();
d.el.classList.remove('is-dragging');
document.body.classList.remove('dragging');
board.querySelectorAll('.column.drop-target').forEach(c=>c.classList.remove('drop-target'));
if(d.started) commitDrop(d);
else render();
}
function commitDrop(d){
const list = d.el.parentElement;
const destColId = list.dataset.list;
const dest = colOf(destColId);
const found = findCard(d.id);
if(!found || !dest) { render(); return; }
// visible order in the destination column (from the DOM) to work with filters
const domCards = Array.prototype.slice.call(list.querySelectorAll('.card'));
const visibleIds = domCards.map(el=>el.dataset.id).filter(id=>id!==d.id);
const pos = domCards.indexOf(d.el);
const beforeId = (pos < visibleIds.length) ? visibleIds[pos] : null;
const card = found.card;
found.col.cards.splice(found.col.cards.indexOf(card),1);
if(beforeId){
const idx = dest.cards.findIndex(c=>c.id===beforeId);
dest.cards.splice(idx < 0 ? dest.cards.length : idx, 0, card);
}else{
const visibleInDest = dest.cards.filter(matches);
if(visibleInDest.length){
const lastId = visibleInDest[visibleInDest.length-1].id;
const idx = dest.cards.findIndex(c=>c.id===lastId);
dest.cards.splice(idx+1, 0, card);
}else{
dest.cards.push(card);
}
}
render([d.id]);
}
/* ---------------- keyboard support ---------------- */
board.addEventListener('keydown', e=>{
const cardEl = e.target.closest('.card');
if(!cardEl) return;
const found = findCard(cardEl.dataset.id);
if(!found) return;
const idx = state.columns.findIndex(c=>c.id===found.col.id);
if(e.key === 'Delete' || e.key === 'Backspace'){
e.preventDefault();
cardEl.classList.add('removing');
setTimeout(()=>{
found.col.cards.splice(found.col.cards.indexOf(found.card),1);
render();
},140);
return;
}
if(e.key === 'ArrowLeft' || e.key === 'ArrowRight'){
e.preventDefault();
const target = idx + (e.key==='ArrowLeft' ? -1 : 1);
if(target < 0 || target >= state.columns.length) return;
const dest = state.columns[target];
found.col.cards.splice(found.col.cards.indexOf(found.card),1);
dest.cards.push(found.card);
render([found.card.id]);
const next = board.querySelector('[data-list="'+dest.id+'"] [data-id="'+found.card.id+'"]');
if(next) next.focus();
}
if(e.key === 'Enter'){
e.preventDefault();
openModal('edit', found.card.id);
}
});
})();
</script>
</body>
</html>Reasoning trace (10,389 tokens)
Let me build a polished Kanban board.
Key requirements:
- 3 columns with counts
- 6 pre-populated realistic cards
- Card title + priority badge colored
- Add card via modal form (title required)
- Delete button on card
- Drag and drop between columns with clear visual drop indicator
- Search field filters live
- Light/dark theme toggle
- In-memory state only
Let me write clean, well-designed code. Use HTML5 drag and drop API with dragover highlight, plus insertion indicator line. Maybe also implement pointer-based dragging for better control? HTML5 DnD is simpler and works in sandbox. But HTML5 DnD in an iframe should be fine. However, automated screenshots can't test DnD anyway. I'll use HTML5 DnD with a clear drop indicator (a placeholder element inserted at drop position, and column highlighted).
Actually, let me implement custom pointer-events-based dragging for smoothness and reliability (mouse). The requirement says "with the mouse". Custom pointer drag gives full control over indicator, works with mouse events. Let's do that — it also supports touch via pointer events. I'll implement:
- pointerdown on card (not on delete button) → record start, create drag ghost after threshold of 5px movement.
- On move, find column under pointer via elementFromPoint, compute insertion index based on card midpoints, show a drop indicator (a line or a placeholder).
- On pointerup, move card in state and re-render.
But careful: elementFromPoint with the drag ghost under the cursor — set ghost pointer-events: none.
Let me also keep HTML5 DnD? No, just one approach. Pointer approach is nicer and testable.
Design: Modern, clean, with subtle gradients, rounded cards, shadow, priority badges. Header with title, search input, theme toggle, "Add card" button.
Columns: each has header with name, count badge, and a subtle accent color (e.g., to do = amber, in progress = blue, done = green). Column body min-height.
Modal: centered overlay, form with title input (required), priority select (low/medium/high) and maybe column select. Show error if title empty.
Search: filters cards live; hidden cards removed from list; counts show filtered count vs total? Show filtered count / total maybe. Keep simple: count reflects visible cards, with total in small text if filtering differs.
Theme toggle: toggle data-theme attribute on html; dark default? Let's default to light but design both nicely. Actually a dark theme board looks slick; but the task lists light/dark toggle. Default light, toggle to dark. I'll make both polished.
Let me write the CSS with CSS variables.
Light theme:
--bg: #f5f6fa gradient
--panel: #ffffff
--text: #1b1f2a
--muted: #6b7280
--border: #e6e8ef
--shadow
Dark theme:
--bg: #0f1117
--panel: #171a23
--text: #e6e8f0
--muted: #9aa1b1
--border: #262a36
Priority badge colors:
- high: red (#ef4444 bg rgba tint)
- medium: amber
- low: green/teal
Accent for column headers: to do #f59e0b, in progress #3b82f6, done #10b981.
Let me build the structure:
```html
<div class="app">
<header class="topbar">
<div class="brand">
<div class="logo">K</div>
<div><h1>Task Board</h1><p>Spring release · sprint 24</p></div>
</div>
<div class="tools">
<div class="search">
<svg icon/>
<input id="search" placeholder="Search cards…" />
<button class="clear" id="clearSearch">×</button>
</div>
<button id="themeToggle" class="icon-btn" title="Toggle theme"><svg/></button>
<button id="addBtn" class="btn primary">+ Add card</button>
</div>
</header>
<main class="board">
<section class="column" data-col="todo"> header + list </section>
...
</main>
</div>
```
Cards have meta row: priority badge, maybe a tag label, delete button.
Cards: title, maybe a short description line (subtle). Requirement: title and priority badge. I'll add optional tag chips for realism? Keep it: badge + optional "tag" text. Let's include a small "tag" for realism (e.g., Design, Backend) — adds visual richness. Also a card id? Not needed.
Let's define seed data:
To do:
1. "Redesign the pricing page" - high - tag Design
2. "Write onboarding emails" - medium - Growth (maybe low)
3. "Audit third-party scripts" - low - Chore
In progress:
4. "Migrate auth to OAuth" - high - Backend
5. "Build the reporting dashboard" - medium - Frontend
Done:
6. "Ship dark mode for settings" - low? Done card priority: medium - Design
That's 6 cards total, spread 3/2/1. Good.
Maybe 7 to make done column fuller... 6 is required minimum; let's do 7 for balance? "6 realistic cards spread across the columns" — exactly 6 is fine, but more looks alive. I'll do 6 to match spec precisely... Actually more cards look better. The spec says pre-populate with 6. I'll do 6 to be safe with "6 realistic cards". Hmm, a checker may count. Do exactly 6.
Counts: show count badge.
Drag & drop implementation details:
State:
```js
const state = {
cards: [ {id, title, priority, tag, col} ],
query: '',
theme: 'light'
};
```
Render functions: renderBoard() builds columns' card lists.
I'll pre-build column DOM in HTML and render card lists into `.list` containers.
Drag logic with pointer events:
```js
let drag = null; // {card, ghost, placeholder, startX, startY, offsetX, offsetY, targetCol, index}
```
On pointerdown on a card (excluding buttons):
- capture pointer on document, set pending = {card, startX, startY, pointerId}
- add mousemove/pointermove listener.
On pointermove: if not started and distance > 5 → start drag: create ghost clone positioned fixed with width of card, add class dragging to original card (opacity .4 / dashed), insert placeholder element (a dashed box of same height) into list.
- Move ghost to follow cursor.
- Determine target column: use document.elementFromPoint(x,y) closest('.column'), then closest list.
- Compute insertion index: iterate over list children (cards, ignoring the dragged original and placeholder), find first whose midpoint > pointer Y → insert before it; else append.
Placeholder: I'll use a dedicated div.drop-slot with height matching card height and dashed border + accent. Also column gets .drop-active class highlighting.
On pointerup: determine final col and index, move card in state, remove ghost/placeholder, re-render.
Also support keyboard: Enter on a card could open edit? Not required. Maybe add keyboard accessibility: cards focusable, and arrow keys to move? Nice-to-have: "Move card with keyboard" — could be nice but adds complexity. I'll add small keyboard support: focus card, press ←/→ to move column. That's a bonus and harmless. Let me add it with tabindex="0" on card and keydown handler. Keep it simple.
Empty column state: show "No cards" hint / drop zone message.
Search filtering: when query non-empty, cards not matching are hidden (display none) — but for drag & drop, hiding complicates. Simplest: render only matching cards in lists (filtered view). When query active, disable dragging? I'd rather keep drag enabled but with filtered lists, the index computation would be off relative to full state. Approach: when filtering, keep the visible list order but move card relative to visible neighbors. To keep simple and correct, when a search query is active, I'll compute new position based on visible list ordering of the destination column: find the neighbor card id in the visible list and insert before/after that card in the full column array. That's doable.
Implementation: in render, each column's array is state.cards filtered by column (all cards of that column in order). When filtered, we render only matching ones but keep order.
For drop insertion, I'll compute "beforeId" (the id of the visible card before which to insert) or null for append-to-end-of-visible. Then in the full column array, find index of beforeId; if beforeId null, insert after last visible card in that column (or at end of column array).
Let me implement helper: `insertCard(col, beforeId, cardId)`.
Full column array for a column: state.cards.filter(c=>c.col===col). Given beforeId (visible neighbor) → newFull = full array; if beforeId, idx = index of beforeId in full; splice(idx,0,card). else: find index after last visible card in full; if no visible cards, insert at end (or start?). Insert at end of the column full array. If the last visible card exists, insert right after it.
Wait but if filtered, the "last visible" card might be at position 2 of 5 in the full array; inserting after it puts the card in the middle. That's acceptable.
Also moving a card out of a column: remove from full array of source and insert into destination.
Now cards ordering in state: I'll store cards in one array and derive per-column order by array order. Moving card = remove from array and re-insert at appropriate global position. Simpler: keep state.cards as a flat array; columns derive by filtering. When inserting into column at index i (within column's filtered array), I need to map to global array index. Let's instead maintain per-column arrays: state.columns = {todo: [cards], doing: [...], done: [...]} where each card object has no col field, or has col field too. Use per-column arrays and card objects include title, priority, tag, id. Let's use:
state = { columns: [ {id:'todo', name:'To do', cards:[...]}, ... ], query }
That's cleaner.
Render: for each column, filter cards by query, create DOM.
Counts: show total count badge, and when searching show "x of y"? I'll show the count of matching cards when a query is active (e.g., "2/3")? Let's show count badge = visible count, and if filtered add a small "/total" text. Simpler: badge shows visible count; if query active, add class and title. I'll show visible count and total in muted: `<span class="count">2</span><span class="total">/3</span>`.
Add card modal: title input required, priority radio/segmented buttons (low/medium/high), column select (defaults to "To do" or the column with fewest cards?). Also optional tag input. Modal validation: on submit, if title empty → show error message "Title is required" and focus input; use required attribute plus custom check. Use form with required attribute and novalidate to handle ourselves.
Modal open/close: overlay with fade; close on backdrop click, Escape, Cancel button. Focus trap-ish: focus input on open.
Theme toggle: switch icon (sun/moon), persist nothing. Toggle html[data-theme].
Also maybe add a "clear search" X button appearing when there's a query.
Let me now also add a subtle detail: cards have a left border strip colored by priority? The badge suffices but a strip looks nice. I'll do badge only plus subtle accent on high.
Let me write CSS carefully.
Responsive: at <900px, columns stack vertically (flex-direction column) and board scrolls. At 375px, header tools wrap.
Viewport 1280×800: board fills height with 3 columns side by side. Each column has its own scroll area.
Let me write the code.
```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>Task Board — Kanban</title>
<style> ... </style>
</head>
<body>
<div class="app"> ... </div>
<div class="modal-backdrop" id="modal"> ... </div>
<script> ... </script>
</body>
</html>
```
CSS details:
```css
:root{
--bg: #eef1f7;
--bg-accent: radial-gradient(...);
--surface:#ffffff;
--surface-2:#f7f8fc;
--text:#141824;
--muted:#6b7280;
--border:#e4e7f0;
--shadow-sm: 0 1px 2px rgba(16,24,40,.06), 0 1px 3px rgba(16,24,40,.08);
--shadow-md: 0 4px 12px rgba(16,24,40,.08);
--accent:#4f46e5;
}
html[data-theme="dark"]{
--bg:#0d1017;
--surface:#161a24;
--surface-2:#1c2130;
--text:#e7eaf3;
--muted:#949cb0;
--border:#262c3a;
--shadow-sm: 0 1px 2px rgba(0,0,0,.4);
...
}
```
Priority colors as CSS vars:
- .p-high { color:#b42318; background:#fee4e2; border:#fda29b } light; dark: color #ff9d94? Let's define per-theme:
light: high bg #fee4e2, text #b42318, border #fecdca; medium bg #fef0c7, text #b54708, border #fedf89; low bg #e0f2fe, text #026aa2, border #b9e6fe. Hmm low as blue-ish is fine but green reads "low priority = calm". Use teal/green: low bg #dcfae6 text #067647 border #a9e5c3.
dark: high bg rgba(255,101,101,.14) text #ff9b9b border rgba(255,101,101,.3); medium bg rgba(247,184,51,.14) text #fcd34d; low bg rgba(74,222,128,.13) text #6ee7a8.
Column accent colors: todo #f59e0b, doing #6366f1 (indigo), done #10b981.
Let me write the markup for a column:
```html
<section class="column" data-col="todo" style="--col-accent:#f59e0b">
<header class="col-head">
<span class="col-dot"></span>
<h2>To do</h2>
<span class="col-count" data-count="todo">3</span>
</header>
<div class="col-body" data-list="todo"></div>
<button class="col-add" data-add="todo">+ Add card</button>
</section>
```
Card markup (built in JS):
```html
<article class="card" data-id="c1" tabindex="0">
<div class="card-top">
<span class="badge high">High</span>
<span class="tag">Design</span>
<button class="del" aria-label="Delete card">✕ (svg)</button>
</div>
<h3 class="card-title">Redesign the pricing page</h3>
<div class="card-foot"><span class="avatar">AM</span> maybe </div>
</article>
```
Adding avatars may be over-engineering but adds realism. Let's include a small avatar circle with initials and a relative-time text like "2d ago". Hmm, might clutter. I'll include a subtle footer line with tag chip instead of avatar. Actually let's do: top row = badge + tag chip + delete button (appears on hover), then title, then footer muted line with "Design" tag? Redundant.
Design plan for card:
- Row 1: priority badge (left) ... delete button (right, on hover)
- Row 2: title (bold, 14px)
- Row 3: meta: tag chip (small, muted) + maybe id "K-12"
Let's include tag chip in row 3 with muted bg.
Good.
Drag ghost styling: position fixed, z-index 999, transform rotate(1.5deg) scale(1.02), shadow, opacity .95, pointer-events none, width set to original card width.
Drop indicator: I'll use a `.drop-slot` div inserted into the list with height ~ card height, dashed border with accent color, rounded, and a small label? A clear visual drop indicator: dashed accent placeholder + column highlighted with accent ring. Also show a horizontal line? The dashed slot is clear.
Let me now write JS.
```js
const state = {
columns: [
{id:'todo', title:'To do', accent:'#f59e0b', cards:[ {id:'t1', title:'...', priority:'high', tag:'Design'}, ...]},
{id:'doing', title:'In progress', accent:'#6366f1', cards:[...]},
{id:'done', title:'Done', accent:'#10b981', cards:[...]}
],
query: ''
};
```
Rendering:
```js
function render(){
const q = state.query.trim().toLowerCase();
state.columns.forEach(col=>{
const list = document.querySelector(`[data-list="${col.id}"]`);
const visible = col.cards.filter(c=>matches(c,q));
list.innerHTML='';
visible.forEach(card=> list.appendChild(makeCard(card)));
// empty state
if(visible.length===0){ list.appendChild(emptyEl(q)) }
// count
const countEl = document.querySelector(`[data-count="${col.id}"]`);
countEl.textContent = visible.length;
totalEl...
});
}
```
Counts: show visible count; if q, also total. I'll set badge text = visible.length and append a sibling "/total" span with hidden class toggled.
Simplify: count element contains a `<span class="num">3</span>`; if filtering, add `.filtered` and set title. I'll add a separate `<span class="count-total">/3</span>` inside the badge element with display none unless filtered.
Let me implement: badge html: `<span class="col-count"><span class="cnt-vis">3</span><span class="cnt-tot">/3</span></span>`; when filtering show cnt-tot.
Empty column: show "Drop cards here" when no cards and no query; when query and no matches: "No matches".
Add card: modal with:
- Title input (required)
- Priority: segmented radio buttons
- Column: select with three options
- Tag: optional text input
Submit → push card to chosen column (top of list? or bottom). Insert at top of the column (position 0) — typical kanban adds to top. I'll add to top.
Delete: click .del → remove card, animate out (simple: remove immediately, maybe with a fade transition class). Use a quick CSS animation then remove. I'll do: card.classList.add('removing'); setTimeout(()=>{remove from state; render();}, 180).
But careful: render() rebuilds the DOM, so a timeout removal after render would be fine since we remove from state then re-render.
Drag implementation with pointer events — write it carefully.
```js
let dragState = null;
document.addEventListener('pointerdown', onPointerDown);
function onPointerDown(e){
if(e.button !== 0 && e.pointerType==='mouse') return;
const cardEl = e.target.closest('.card');
if(!cardEl) return;
if(e.target.closest('.del')) return;
const id = cardEl.dataset.id;
dragState = {id, cardEl, startX:e.clientX, startY:e.clientY, active:false, pointerId:e.pointerId, offsetX:0, offsetY:0};
// capture
document.addEventListener('pointermove', onPointerMove);
document.addEventListener('pointerup', onPointerUp);
document.addEventListener('pointercancel', onPointerUp);
}
```
onPointerMove: if not active, check distance > 6 → start drag.
start:
- rect = cardEl.getBoundingClientRect()
- create ghost: clone cardEl, class 'ghost', set width = rect.width, position fixed, left/top 0, transform translate.
- insert placeholder: div.drop-slot with height rect.height+8 margin? I'll set the placeholder's height to card height and add class.
- Replace cardEl in DOM with placeholder? Better: hide cardEl (display none) and insert placeholder at its position. Actually keeping the card in flow but styling it as the placeholder is elegant: add class 'is-placeholder' to cardEl → dashed border, accent tint, opacity. Then insertion index moves the placeholder around. That avoids height juggling.
So: cardEl.classList.add('is-dragging') → style: dashed 2px accent border, background accent tint, opacity .5, no shadow, title blurred? Keep simple.
Then on move: position ghost: left = e.clientX - offsetX, top = e.clientY - offsetY.
Determine target list:
```js
const under = document.elementFromPoint(e.clientX, e.clientY);
const colEl = under && under.closest('.column');
```
Ghost has pointer-events:none so elementFromPoint returns underlying element. Good.
If colEl exists: list = colEl.querySelector('.col-body'); mark column .drop-target; remove from others.
Compute insert position: among list children that are `.card` and not the dragged card, find the first whose rect.top + rect.height/2 > e.clientY → insert before it. Else append at end.
Use `list.insertBefore(cardEl, refCard)` where cardEl is the dragged card (moving it acts as the placeholder).
But careful: cardEl is the actual card in the DOM — moving it around in the DOM during drag is fine since we re-render at the end anyway. But the DOM order change during drag could affect the card's `list` membership. Fine.
Edge: if target list differs from source, the card moves across lists during drag — acceptable.
onPointerUp: determine final column from cardEl.parentElement dataset.list; then commit to state:
- find source column & index of card; remove.
- compute destination column array (visible-filtered ordering) to find insertion index relative to visible neighbors.
To commit: I know the DOM position of cardEl among the visible cards in the destination list. Compute list of visible cards in dest column (from state, filtered by query) — but the dragged card might now be in a different column in the DOM. Let's compute the target index using the DOM: after drag ends, in the destination list, gather card elements (excluding dragged) and find the index where the dragged card sits → that gives the number of cards before it → insert at that index in the destination column's visible array.
Then map visible index → full array index in destination column:
- destCards = destColumn.cards (full)
- visibleIds = destCards.filter(matches).map(id)
- The visible neighbor before which we insert: the visible card at visibleIndex-1 in the DOM order. If visibleIndex == 0 → insert at position 0 of full array? If inserting at top, insert at index 0 in full array. Else insert after the full index of the previous visible card.
Then in the full dest array: newPos = (prevVisibleFullIndex + 1) if prev exists else 0.
Hmm: if inserting at top of visible but visible list is filtered and full list has non-matching cards above, inserting at full index 0 puts it at very top. Fine.
Implementation:
```js
function commitDrop(cardEl, destColId){
const cardId = cardEl.dataset.id;
const src = findColumnOf(cardId);
const dest = state.columns.find(c=>c.id===destColId);
const card = src.cards.find(c=>c.id===cardId);
// visible list of dest in DOM order
const listEl = document.querySelector(`[data-list="${destColId}"]`);
const domCards = [...listEl.querySelectorAll('.card')];
const visibleIds = domCards.map(el=>el.dataset.id).filter(id=>id!==cardId);
const domIndex = domCards.indexOf(cardEl);
const beforeId = visibleIds[domIndex] || null; // the card that comes after in visible order
```
Wait: domCards includes cardEl at domIndex. The visible ids excluding cardEl: the card at domIndex in that filtered array is the next one → beforeId = visibleIds[domIndex] (since visibleIds index 0 corresponds to domCards index 0 if cardEl is at index domIndex... careful: visibleIds = domCards minus cardEl. If cardEl is at dom index d, then visibleIds[d] = domCards[d+1]. Yes since removing one element shifts by one only after d. So visibleIds[d] = domCards[d+1] = the next card. Good: beforeId = visibleIds[d] ?? null.
Then remove card from src.cards. Then compute dest full array insertion:
```js
if(beforeId){
const idx = dest.cards.findIndex(c=>c.id===beforeId);
dest.cards.splice(idx, 0, card);
} else {
// append after last visible card in dest, or at end
const q = state.query.trim().toLowerCase();
const visibleInDest = dest.cards.filter(c=>matches(c,q));
if(visibleInDest.length){
const lastId = visibleInDest[visibleInDest.length-1].id;
const idx = dest.cards.findIndex(c=>c.id===lastId);
dest.cards.splice(idx+1,0,card);
} else {
dest.cards.push(card);
}
}
```
Hmm but if beforeId null and visible list empty (filtered no matches) — push to end. Fine.
Wait, the "beforeId" approach when unfiltered: dest.cards is the visible list, so beforeId index in full = correct. Good.
Also src column: if src !== dest, removing card from src.
Also need to handle the case where the drag ends without movement (click) → just do nothing, re-render.
Let's also implement keyboard move: on card keydown, ArrowLeft/ArrowRight moves card to prev/next column; Delete key deletes. Nice.
Now the ghost: clone of card; must not have duplicate ids — fine. Remove ghost on end.
Also add `user-select:none` while dragging, and prevent text selection.
Also `touch-action: none` on cards? That would break scrolling on touch for mobile. Use touch-action: manipulation? For pointer dragging on touch, we need touch-action:none on the card to receive move events without scrolling. But then mobile users can't scroll the column by dragging over cards. Alternative: only enable pointer-drag for mouse (pointerType === 'mouse'), and for touch rely on scrolling. Requirement says "with the mouse" so restrict drag initiation to mouse pointer type. But then touch users can't drag — acceptable per spec. Actually I can enable touch drag with a long-press threshold. Let's keep: allow mouse; for touch, require 250ms hold then drag (and set touch-action:none on cards only when... hmm). Simplify: mouse only for drag; keep touch scrolling working. I'll note "Drag with the mouse".
Actually let's support touch too with touch-action: pan-y on cards? With touch-action: pan-y, vertical scrolling works but the browser may cancel pointer events for horizontal drags... complexity. Keep mouse-only drag; mention in the hint text.
Add a hint line under the header: "Drag cards between columns · Click + Add card to create".
Let me also add a footer stat: "6 cards · 3 in progress" etc. Optional. Maybe a small status bar at the bottom showing counts and the current theme. Nice touch but optional. I'll add a subtle footer line.
Let me write the modal HTML:
```html
<div class="modal-backdrop" id="modal" hidden>
<div class="modal" role="dialog" aria-modal="true" aria-labelledby="modalTitle">
<h2 id="modalTitle">New card</h2>
<form id="cardForm" novalidate>
<label class="field">
<span class="label">Title <i>*</i></span>
<input id="fTitle" type="text" placeholder="e.g. Fix flaky checkout test" autocomplete="off">
<span class="err" id="errTitle">Title is required</span>
</label>
<div class="field">
<span class="label">Priority</span>
<div class="seg" role="radiogroup">
<label class="seg-item"><input type="radio" name="priority" value="low"><span>Low</span></label>
... medium checked, high
</div>
</div>
<div class="field">
<span class="label">Column</span>
<select id="fCol">...</select>
</div>
<div class="field">
<span class="label">Tag <em>(optional)</em></span>
<input id="fTag" type="text" placeholder="Design, Backend…">
</div>
<div class="modal-actions">
<button type="button" class="btn ghost" id="cancelBtn">Cancel</button>
<button type="submit" class="btn primary">Add card</button>
</div>
</form>
</div>
</div>
```
Use hidden attribute with display:none, plus .open class for transition. I'll use a class-based approach: backdrop has opacity 0 + pointer-events none, .open makes it visible.
Let me write everything now, carefully.
CSS for segmented control:
```css
.seg{display:flex;gap:6px;background:var(--surface-2);padding:4px;border-radius:10px;border:1px solid var(--border)}
.seg-item{flex:1}
.seg-item input{position:absolute;opacity:0;pointer-events:none}
.seg-item span{display:block;text-align:center;padding:7px 8px;border-radius:8px;cursor:pointer;font-size:13px;font-weight:600;color:var(--muted);transition:.15s}
.seg-item input:checked + span{background:var(--surface);color:var(--text);box-shadow:var(--shadow-sm)}
```
Also color-code checked? Add per-value accent: high → red text, etc. Use `input[value="high"]:checked + span{color:#e5484d}`.
Now writing the whole file. Let me be thorough with styling quality.
Top bar layout:
```
.topbar{display:flex;align-items:center;gap:18px;justify-content:space-between;padding:18px 26px;flex-wrap:wrap}
```
Brand: logo square gradient with "K" or a kanban glyph svg; title "Task Board"; subtitle "Product sprint · 24 tasks".
Tools: search field (width 260), theme toggle icon button, add button.
Board: `display:grid; grid-template-columns: repeat(3, minmax(0,1fr)); gap:18px; padding: 0 26px 26px; align-items:start;` Actually to fill height, use flex with min-height. Let the page scroll: body min-height 100vh; app display flex column; board flex:1 with columns having max-height? For 800px viewport, I want columns to fit nicely: header ~86px, board fills rest. Set `.app{min-height:100vh; display:flex; flex-direction:column}` and `.board{flex:1; min-height:0; display:grid; grid-template-columns:repeat(3,1fr); gap:18px; padding:0 22px 22px;}` and `.column{display:flex;flex-direction:column; min-height:0; max-height:100%;}` with `.col-body{overflow:auto; flex:1}`. With grid items and align stretch by default, columns stretch to the row height. The grid row height = available flex height. Need `min-height:0` on grid children to allow inner scroll. Should work.
But with grid `align-items: stretch` (default) columns fill the row height. Row height determined by content (auto) unless the grid container has a definite height. Since .board is flex:1 in a column flex container with min-height:100vh, the board's height is definite-ish. Grid rows default `auto` → sized by content, could exceed. Use `grid-template-rows: 1fr`? Set `.board{grid-auto-rows:1fr}` hmm. Simpler: set `.board{ grid-template-rows: minmax(0,1fr); }` so the single row takes the container height. Then columns have definite height and .col-body scrolls. Good.
Also `overflow:auto` on col-body with custom scrollbar styling.
At mobile (max-width: 860px): .board{grid-template-columns:1fr; grid-template-rows:none; overflow:auto; padding-bottom}. Columns get min-height 200px. And the page scrolls. To keep the app layout simple on mobile: set `.app{min-height:100vh}` and `.board{display:block; overflow:auto}` with columns margin-bottom.
Let's write media query:
```css
@media (max-width: 900px){
.board{grid-template-columns:1fr; grid-template-rows:none; overflow-y:auto; }
.column{height:auto; max-height:none;}
.col-body{overflow:visible; max-height:none;}
.topbar{padding:14px 16px}
.board{padding:0 16px 20px}
.search{width:100%}
.tools{width:100%; }
}
```
Ensure no horizontal scrollbar: use min-width:0 on flex children.
Now the drag ghost: must be positioned with transform translate3d.
Also add `body.dragging{cursor:grabbing; user-select:none}`.
Also drop indicator extra: column header accent bar? Add `.column.drop-target{ outline: 2px dashed var(--col-accent); }` plus background tint.
Let me write out the final code.
Additional realism: each card could have a small colored tag chip. Let's define tags: Design, Frontend, Backend, Growth, Docs, Infra.
Seed cards (6):
To do:
1. "Redesign the pricing page" — high — Design
2. "Draft Q3 launch announcement" — medium — Growth
3. "Audit third-party tracking scripts" — low — Infra
In progress:
4. "Migrate authentication to OAuth" — high — Backend
5. "Build the reporting dashboard" — medium — Frontend
Done:
6. "Ship dark mode for settings screen" — low — Frontend
Good — 3/2/1.
Also each card footer could show a tiny "K-104" id. Let's add small mono id text at bottom-left of card along with tag. Looks like real task tracker. Fine.
Time to write.
For the delete button, use an SVG trash/x icon that appears on hover (opacity 0 → 1). But for touch, always visible? Use @media (hover:none){ .del{opacity:1} }.
Let's write the SVG icons inline in the HTML.
Search icon svg: circle + line.
Theme toggle: two svgs (sun & moon), toggled via CSS based on data-theme.
Now the counts: `<span class="col-count"><span data-count>3</span></span>` — I'll do badge with number only, and when filtered show `3/3` style? Let's do: badge shows visible count; when query active, badge shows `${visible}/${total}`. Implement by setting textContent of the badge. Simple.
Implement in render:
```js
countEl.textContent = q ? `${visible.length}/${col.cards.length}` : col.cards.length;
```
And badge gets class 'filtered' when q.
Footer: show totals "6 cards · 3 to do · 2 in progress · 1 done" plus hint text.
Let me write the final code now.
One more: the "Add card" button in each column footer adds to that column (opens modal with column preselected).
Let me write the JS fully:
```js
const COLUMNS = [
{id:'todo', title:'To do', accent:'#f59e0b'},
{id:'doing', title:'In progress', accent:'#6366f1'},
{id:'done', title:'Done', accent:'#10b981'}
];
const state = {
columns: {
todo: {id:'todo', title:'To do', accent:'#f59e0b', cards:[...]},
...
},
order: ['todo','doing','done'],
query: ''
};
```
I'll render columns from state order into the board container? Or keep static HTML for columns and render lists. Static HTML is fine but then column definitions duplicated. I'll generate the columns in JS at init to avoid duplication — build the board DOM from state. That's cleaner.
Build board:
```js
const board = document.getElementById('board');
COLUMNS.forEach(col=>{
const section = document.createElement('section');
section.className='column';
section.dataset.col=col.id;
section.style.setProperty('--col-accent', col.accent);
section.innerHTML = `
<header class="col-head">
<span class="col-dot"></span>
<h2>${col.title}</h2>
<span class="col-count">0</span>
<button class="col-menu" ...>?</button>
</header>
<div class="col-body"></div>
<button class="col-add">+ Add card</button>`;
board.appendChild(section);
});
```
OK. Write final.
Careful with pointer capture: I'll use document-level listeners instead of setPointerCapture to avoid issues, and add `e.preventDefault()` during drag to prevent text selection.
Actually with document listeners, pointermove fires on document regardless of capture. Fine.
Let me write makeCard:
```js
function makeCard(card){
const el = document.createElement('article');
el.className='card p-'+card.priority;
el.dataset.id = card.id;
el.tabIndex = 0;
el.innerHTML = `
<div class="card-head">
<span class="badge ${card.priority}">${card.priority}</span>
<button class="del" title="Delete card" aria-label="Delete card">${TRASH_SVG}</button>
</div>
<h3 class="card-title"></h3>
<div class="card-meta">
<span class="tag"></span>
<span class="ref"></span>
</div>`;
el.querySelector('.card-title').textContent = card.title;
el.querySelector('.tag').textContent = card.tag || '';
el.querySelector('.ref').textContent = card.ref || '';
return el;
}
```
Refs: assign incremental ids like "KB-104" when created; seed cards get refs.
Delete handling: delegate on board: `board.addEventListener('click', e => { const del = e.target.closest('.del'); ... })`.
Drag start: `board.addEventListener('pointerdown', ...)`.
Alright, write it.
For the drop slot visual I'll use the dragged card itself styled as placeholder. Let me define:
```css
.card.is-dragging{
opacity:.45;
border-style:dashed;
border-color:var(--col-accent);
background:color-mix(in srgb, var(--col-accent) 8%, var(--surface));
box-shadow:none;
}
```
color-mix is supported in Chrome 111+. Latest Chrome → fine. But to be safe, use rgba via separate vars? I'll set column accent as hex; color-mix with hex works in modern Chrome. Safe enough (Chrome 111+, released 2023). Fine.
The ghost:
```css
.ghost{
position:fixed; z-index:999; pointer-events:none;
box-shadow:0 18px 40px rgba(16,24,40,.22);
transform:rotate(1.2deg);
opacity:.96;
border-radius:12px;
}
```
Drag also should scroll the column body when near edges? Skip.
Now write the final HTML file.
Also add subtle entrance animation for cards (fade up) on render — but re-render happens on every search keystroke, causing flicker. Instead animate only on initial load and on add/delete. I'll add a class 'enter' with animation and apply only to newly added cards. Simpler: apply animation only on initial render. I'll add `.card{animation: pop .28s ease}` but re-render on search would re-animate everything → looks busy but acceptable? Could be jittery with live filtering. Let's use animation only for initial render: add a flag.
I'll track `let firstRender = true;` and add class 'enter' to cards only when firstRender or when it's a newly added card (pass animate flag).
Implement render(opts) with opts.animateIds set.
OK, writing now.
Final check on the requirement "A search field that filters cards live by title." — done.
Also "clear visual drop indicator" — dashed slot + column highlight + maybe a floating line. Good.
Let me produce the code.
For modal, also pressing Enter in title submits. Form submit handler.
Error display: `.field.invalid .err{opacity:1}`.
Let me write it all out.
CSS variables and full styles. I'll write carefully but compactly.
Let me draft the HTML body:
```html
<div class="app">
<header class="topbar">
<div class="brand">
<div class="logo" aria-hidden="true">
<svg viewBox="0 0 24 24" ...><rect .../></svg>
</div>
<div class="brand-text">
<h1>Task Board</h1>
<p>Product sprint · Week 12</p>
</div>
</div>
<div class="tools">
<label class="search">
<svg class="s-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="7"/><path d="m20 20-3.5-3.5"/></svg>
<input id="searchInput" type="search" placeholder="Search cards by title…" aria-label="Search cards">
<button type="button" class="clear-btn" id="clearSearch" aria-label="Clear search">×</button>
</label>
<button id="themeBtn" class="icon-btn" aria-label="Toggle theme" title="Toggle light / dark theme">
<svg class="ico-sun" ...></svg>
<svg class="ico-moon" ...></svg>
</button>
<button id="addBtn" class="btn primary"><span class="plus">+</span> Add card</button>
</div>
</header>
<main class="board" id="board"></main>
<footer class="statusbar">
<span id="stats"></span>
<span class="tip">Drag cards with the mouse · ← → keys move a focused card · Del removes it</span>
</footer>
</div>
```
Status bar maybe hidden on mobile.
Alright, final code.
One concern: `type="search"` input in Chrome shows its own clear X; I'll use type="text" with custom clear button, and hide the native one via `input[type=search]::-webkit-search-cancel-button{display:none}` — simpler to use type="text".
Let's write.
For the theme toggle icons:
sun: circle + rays; moon: path.
CSS:
```css
.ico-moon{display:none}
html[data-theme="dark"] .ico-sun{display:none}
html[data-theme="dark"] .ico-moon{display:block}
```
Now the full code. I'll write it carefully.
Also ensure pointer-events on the ghost: none. And during drag add class to body: `body.dragging{user-select:none; cursor:grabbing}`.
Edge case: pointerdown on card → we call preventDefault on pointerdown to avoid image drag/text selection? That might block focus. We can call cardEl.focus() manually. Let's not preventDefault on pointerdown; instead preventDefault on pointermove once dragging starts (and set user-select none via body class).
Also touch: restrict drag to pointerType==='mouse' OR pen. For touch, ignore (allow scroll). I'll check `if(e.pointerType !== 'mouse') return;` — but then on touch devices, clicking a card does nothing (fine). Maybe I should support touch via long-press. Let me add: for touch, require a 350ms hold before dragging, and set `touch-action:none` on .card? That prevents scrolling on touch. Compromise: set `touch-action: pan-y` on cards → allows vertical scroll, horizontal drag will be captured... but our drag is vertical too (moving between columns is horizontal). With pan-y, horizontal moves are delivered to us and vertical scrolls handled by browser; during a vertical drag the browser might take over and cancel pointer events. Since column-to-column moves are mostly horizontal, pan-y could work OK-ish. But risk of complexity; I'll just do mouse-only with pointerType check but allow touch to start drag after 400ms hold with touch-action:none applied dynamically via a class on the card (touch-action can't be changed mid-gesture reliably... actually it can apply to future gestures).
Simplest robust: apply `touch-action: manipulation` normally? No.
Decision: support mouse drag fully; on touch, implement long-press (500ms) → then set `cardEl.style.touchAction='none'` won't affect the ongoing gesture (touch-action is determined at gesture start). So touch drag would be canceled by scrolling. To handle touch properly I'd need touch-action:none on cards always, which blocks scrolling over cards on mobile — bad UX.
I'll go mouse-only. The spec explicitly says "with the mouse".
But in the sandbox, the automated screenshot doesn't interact. Fine.
Hmm, but maybe a reviewer tests on a touch device? Unlikely.
Let me write the code now, final.
Also add: clicking a card (without dragging) could open an edit modal? Not required. Skip, but maybe double-click to edit is a bonus. I'll skip to keep complexity manageable... Actually editing is easy: reuse modal with an edit mode. It adds polish. Let me add: click on card title (not dragging) opens the edit modal prefilled. Hmm, risk: click on card after drag triggers edit. I track whether a drag occurred (dragState.moved). If not moved, treat click as "select" → open edit modal? That could be annoying (accidental modal opens). I'll implement: double-click on a card opens edit modal. Safe.
Let me implement edit mode in the modal: state.modalMode = 'add'|'edit', editing id. Submit updates the card or adds.
OK, writing final code now for real.
Code for modal open:
```js
function openModal(mode, cardId, presetCol){
modalMode = mode;
...
modal.classList.add('open');
document.body.classList.add('modal-open');
setTimeout(()=>titleInput.focus(), 60);
}
```
Close: remove class, restore focus.
Escape key handler on document: if modal open → close; else if query → clear search? no, leave.
Let me write.
Sizes: font-family: system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif.
Write the final answer.