DeepSeek V4.1 Flash 1017
by DeepSeekReasoningClean
Running live in a sandboxed frame. Click inside it before using the keyboard.
How it went
- Total time
- 1 min 30 s
- Cost
- $0.015
- Output tokens
- 29,950
- Spent thinking
- 20,870 tokens
- Speed
- 334.6 tok/s
- Console errors
- 0
- First token after
- 295 ms
- First visible text after
- 1 min 8 s
- Prompt tokens
- 519
- Code size
- 27.7 KB
- Finish reason
- stop
- Served by
- Cohere
- Model id
deepseek/deepseek-v4.1-flash-1017- Reasoning sent
{"effort":"low"}- Max output
- 64,000 tokens
- Generated
- Nov 11, 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 · 767 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>Product Board — Kanban</title>
<style>
*, *::before, *::after { box-sizing: border-box; }
[hidden] { display: none !important; }
:root {
--app-bg: radial-gradient(1100px 520px at 8% -12%, #e2e7ff 0%, transparent 62%),
radial-gradient(900px 480px at 102% -6%, #ffe3f0 0%, transparent 58%),
#eef1f8;
--panel: #ffffff;
--col-bg: #f5f7fc;
--text: #0f172a;
--muted: #64748b;
--border: #e2e8f0;
--card-bg: #ffffff;
--card-border: #e6ebf3;
--accent: #5b5bd6;
--accent-ring: rgba(91,91,214,.18);
--accent-soft: rgba(91,91,214,.10);
--shadow: 0 1px 2px rgba(15,23,42,.05), 0 6px 16px -10px rgba(15,23,42,.25);
--shadow-lift: 0 12px 26px -16px rgba(15,23,42,.5);
--count-bg: #e8ecf5;
--mark: rgba(250,204,21,.45);
}
html[data-theme="dark"] {
--app-bg: radial-gradient(1100px 520px at 8% -12%, #1b2444 0%, transparent 62%),
radial-gradient(900px 480px at 102% -6%, #251a3e 0%, transparent 58%),
#0a0e17;
--panel: #141a2a;
--col-bg: #101625;
--text: #e8ecf6;
--muted: #8b97ad;
--border: #232c40;
--card-bg: #182031;
--card-border: #263148;
--accent: #8b8cf9;
--accent-ring: rgba(139,140,249,.25);
--accent-soft: rgba(139,140,249,.14);
--shadow: 0 1px 2px rgba(0,0,0,.35), 0 8px 20px -12px rgba(0,0,0,.8);
--shadow-lift: 0 14px 30px -16px rgba(0,0,0,.95);
--count-bg: #1c2537;
--mark: rgba(250,204,21,.28);
}
body {
margin: 0;
height: 100dvh;
overflow: hidden;
font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
color: var(--text);
background: var(--app-bg);
background-attachment: fixed;
-webkit-font-smoothing: antialiased;
transition: color .25s ease;
}
.app { height: 100dvh; display: flex; flex-direction: column; }
/* ---------- top bar ---------- */
.topbar {
display: flex; align-items: center; gap: 16px; flex-wrap: wrap;
padding: 16px 20px 14px;
}
.brand { display: flex; align-items: center; gap: 12px; margin-right: auto; min-width: 0; }
.logo {
width: 40px; height: 40px; flex: none; border-radius: 12px;
display: grid; place-items: center; color: #fff;
background: linear-gradient(140deg, #6366f1, #a855f7);
box-shadow: 0 8px 18px -8px rgba(99,102,241,.9);
}
.brand h1 { margin: 0; font-size: 16.5px; font-weight: 700; letter-spacing: -.02em; }
.brand p { margin: 2px 0 0; font-size: 12px; font-weight: 500; color: var(--muted); }
.tools { display: flex; align-items: center; gap: 10px; }
.search { position: relative; display: flex; align-items: center; }
.search > svg {
position: absolute; left: 11px; width: 15px; height: 15px;
color: var(--muted); pointer-events: none;
}
.search input {
width: 240px; max-width: 100%; height: 38px;
padding: 0 32px 0 33px;
border-radius: 10px; border: 1px solid var(--border);
background: var(--panel); color: var(--text);
font-family: inherit; font-size: 13.5px; outline: none;
transition: border-color .18s, box-shadow .18s, background-color .25s;
}
.search input::placeholder { color: var(--muted); }
.search input:focus { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-ring); }
.clear {
position: absolute; right: 6px; width: 22px; height: 22px;
display: grid; place-items: center;
border: none; border-radius: 7px; background: transparent;
color: var(--muted); font-size: 16px; line-height: 1; cursor: pointer;
transition: .15s;
}
.clear:hover { background: var(--count-bg); color: var(--text); }
.icon-btn {
width: 38px; height: 38px; flex: none;
display: grid; place-items: center;
border-radius: 10px; border: 1px solid var(--border);
background: var(--panel); color: var(--muted); cursor: pointer;
transition: .18s;
}
.icon-btn:hover { color: var(--accent); border-color: var(--accent); }
html[data-theme="light"] .ic-sun { display: none; }
html[data-theme="dark"] .ic-moon { display: none; }
/* ---------- board ---------- */
.board {
flex: 1; min-height: 0;
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 14px;
padding: 0 20px 20px;
overflow: hidden;
}
.column {
display: flex; flex-direction: column; min-height: 0;
padding: 10px;
background: var(--col-bg);
border: 1px solid var(--border);
border-radius: 16px;
transition: background-color .2s, border-color .2s, box-shadow .2s;
}
.column.drag-over {
border-color: var(--accent);
background: var(--accent-soft);
box-shadow: inset 0 0 0 1px var(--accent);
}
.col-head { display: flex; align-items: center; gap: 8px; padding: 4px 4px 10px; }
.col-head h2 {
margin: 0; font-size: 11.5px; font-weight: 700;
letter-spacing: .07em; text-transform: uppercase; color: var(--muted);
white-space: nowrap;
}
.dot { width: 8px; height: 8px; border-radius: 50%; flex: none; }
.count {
margin-left: auto; min-width: 22px; padding: 2px 8px;
border-radius: 999px; background: var(--count-bg);
font-size: 11px; font-weight: 700; color: var(--muted); text-align: center;
transition: background-color .25s;
}
.cards {
flex: 1; min-height: 0;
display: flex; flex-direction: column; gap: 9px;
padding: 3px; overflow-y: auto; overflow-x: hidden;
scrollbar-width: thin;
scrollbar-color: var(--border) transparent;
}
.cards::-webkit-scrollbar { width: 8px; }
.cards::-webkit-scrollbar-thumb { background: var(--border); border-radius: 99px; }
.cards::-webkit-scrollbar-track { background: transparent; }
.empty {
margin: 0; padding: 22px 10px; text-align: center;
font-size: 12.5px; font-weight: 500; color: var(--muted);
border: 1.5px dashed var(--border); border-radius: 12px;
}
/* ---------- cards ---------- */
.card {
padding: 10px 12px 11px;
background: var(--card-bg);
border: 1px solid var(--card-border);
border-radius: 12px;
box-shadow: var(--shadow);
cursor: grab;
transition: transform .16s ease, box-shadow .16s ease, border-color .16s ease,
opacity .16s ease, background-color .25s;
}
.card:hover { transform: translateY(-1px); box-shadow: var(--shadow-lift); border-color: var(--accent); }
.card:active { cursor: grabbing; }
body.is-dragging .card:hover { transform: none; box-shadow: var(--shadow); border-color: var(--card-border); }
.card.dragging { opacity: .38; transform: scale(.99); }
.card.removing { opacity: 0; transform: scale(.94); }
.card.pop { animation: popIn .3s cubic-bezier(.2,.9,.3,1.3); }
@keyframes popIn {
from { opacity: 0; transform: scale(.95) translateY(-7px); }
to { opacity: 1; transform: none; }
}
.card-title {
margin: 0 0 9px;
font-size: 13.5px; line-height: 1.42; font-weight: 550;
letter-spacing: -.005em; word-break: break-word;
}
mark { background: var(--mark); color: inherit; border-radius: 3px; padding: 0 1px; }
.card-foot { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
.badge {
display: inline-flex; align-items: center; gap: 5px;
padding: 3px 8px 3px 7px; border-radius: 999px;
font-size: 10.5px; font-weight: 700; letter-spacing: .04em; text-transform: capitalize;
}
.badge::before { content: ''; width: 5px; height: 5px; border-radius: 50%; background: currentColor; }
.badge.high { color: #b42318; background: #fee4e2; }
.badge.medium { color: #b54708; background: #fef0c7; }
.badge.low { color: #067647; background: #dcfae6; }
html[data-theme="dark"] .badge.high { color: #ff9c93; background: rgba(239,68,68,.17); }
html[data-theme="dark"] .badge.medium { color: #fbbf5c; background: rgba(245,158,11,.17); }
html[data-theme="dark"] .badge.low { color: #5ee9a8; background: rgba(16,185,129,.15); }
.del {
display: grid; place-items: center;
width: 26px; height: 26px; flex: none;
border: none; border-radius: 8px; background: transparent;
color: var(--muted); cursor: pointer;
opacity: 0; transition: opacity .15s, color .15s, background-color .15s;
}
.card:hover .del, .card:focus-within .del { opacity: .8; }
.del:hover { opacity: 1; color: #e11d48; background: rgba(225,29,72,.13); }
@media (hover: none) { .del { opacity: .65; } }
.drop-ph {
flex: none; height: 66px;
border-radius: 12px;
border: 2px dashed var(--accent);
background: var(--accent-soft);
pointer-events: none;
animation: phIn .16s ease;
}
@keyframes phIn { from { opacity: 0; } to { opacity: 1; } }
.add-row {
margin-top: 8px; width: 100%; padding: 9px;
border-radius: 10px; border: 1px dashed var(--border);
background: transparent; color: var(--muted);
font-family: inherit; font-size: 12.5px; font-weight: 600;
cursor: pointer; transition: .16s;
}
.add-row:hover { border-color: var(--accent); color: var(--accent); background: var(--accent-soft); }
/* ---------- modal ---------- */
.overlay {
position: fixed; inset: 0; z-index: 50;
display: grid; place-items: center; padding: 20px;
background: rgba(9,13,23,.5);
backdrop-filter: blur(3px);
animation: fade .16s ease;
}
@keyframes fade { from { opacity: 0; } to { opacity: 1; } }
.modal {
width: min(400px, 100%);
padding: 20px;
background: var(--panel);
border: 1px solid var(--border);
border-radius: 16px;
box-shadow: 0 30px 70px -24px rgba(0,0,0,.6);
animation: pop .22s cubic-bezier(.2,.9,.3,1.25);
}
@keyframes pop { from { opacity: 0; transform: scale(.94) translateY(8px); } to { opacity: 1; transform: none; } }
.modal-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 16px; }
.modal-head h2 { margin: 0; font-size: 16px; font-weight: 700; letter-spacing: -.01em; }
.x {
width: 30px; height: 30px; display: grid; place-items: center;
border: none; border-radius: 8px; background: transparent;
color: var(--muted); font-size: 20px; line-height: 1; cursor: pointer;
transition: .15s;
}
.x:hover { background: var(--count-bg); color: var(--text); }
.field { margin-bottom: 14px; }
.field label, .field .lbl {
display: block; margin-bottom: 6px;
font-size: 11px; font-weight: 700; letter-spacing: .06em;
text-transform: uppercase; color: var(--muted);
}
.field input, .field select {
width: 100%; height: 40px; padding: 0 12px;
border-radius: 10px; border: 1px solid var(--border);
background: var(--col-bg); color: var(--text);
font-family: inherit; font-size: 13.5px; outline: none;
transition: border-color .18s, box-shadow .18s;
}
.field select { cursor: pointer; appearance: none;
background-image: linear-gradient(45deg, transparent 50%, currentColor 50%),
linear-gradient(135deg, currentColor 50%, transparent 50%);
background-position: calc(100% - 17px) 18px, calc(100% - 12px) 18px;
background-size: 5px 5px, 5px 5px;
background-repeat: no-repeat;
}
.field input:focus, .field select:focus { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-ring); }
.field input.invalid { border-color: #e11d48; box-shadow: 0 0 0 3px rgba(225,29,72,.15); }
.segmented { display: grid; grid-template-columns: repeat(3, 1fr); gap: 6px; }
.segmented button {
height: 36px; border-radius: 9px;
border: 1px solid var(--border); background: transparent;
color: var(--muted); font-family: inherit; font-size: 12.5px; font-weight: 600;
cursor: pointer; transition: .15s;
}
.segmented button:hover { border-color: var(--muted); color: var(--text); }
.segmented button[aria-pressed="true"] { color: #fff; border-color: transparent; }
.segmented button[aria-pressed="true"][data-prio="low"] { background: #10b981; }
.segmented button[aria-pressed="true"][data-prio="medium"] { background: #f59e0b; }
.segmented button[aria-pressed="true"][data-prio="high"] { background: #ef4444; }
.err { margin: 2px 0 8px; min-height: 15px; font-size: 12px; font-weight: 600; color: #e11d48; }
.actions { display: flex; justify-content: flex-end; gap: 10px; }
.btn {
height: 40px; padding: 0 16px; border-radius: 10px;
border: 1px solid transparent; font-family: inherit;
font-size: 13.5px; font-weight: 600; cursor: pointer; transition: .16s;
}
.btn.ghost { background: transparent; border-color: var(--border); color: var(--muted); }
.btn.ghost:hover { color: var(--text); border-color: var(--muted); }
.btn.primary {
background: linear-gradient(135deg, #6366f1, #8b5cf6); color: #fff;
box-shadow: 0 10px 20px -12px rgba(99,102,241,.95);
}
.btn.primary:hover { filter: brightness(1.07); }
/* ---------- responsive ---------- */
@media (max-width: 820px) {
.board {
grid-template-columns: 1fr;
align-items: start;
overflow-y: auto;
overflow-x: hidden;
}
.cards { overflow: visible; }
.column { min-height: 0; }
}
@media (max-width: 620px) {
.topbar { padding: 14px 14px 12px; gap: 12px; }
.board { padding: 0 14px 16px; gap: 12px; }
.tools { width: 100%; }
.search { flex: 1; }
.search input { width: 100%; }
.brand h1 { font-size: 15.5px; }
}
</style>
</head>
<body>
<div class="app">
<header class="topbar">
<div class="brand">
<div class="logo" aria-hidden="true">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round">
<path d="M5.5 4v9M12 4v15M18.5 4v5"/>
</svg>
</div>
<div>
<h1>Product Board</h1>
<p id="subtitle">Sprint 24 · 6 cards</p>
</div>
</div>
<div class="tools">
<div class="search">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" aria-hidden="true">
<circle cx="11" cy="11" r="7"/><path d="M20 20l-3.6-3.6"/>
</svg>
<input id="search" type="text" placeholder="Search cards…" aria-label="Search cards" autocomplete="off" spellcheck="false">
<button class="clear" id="clearSearch" type="button" aria-label="Clear search" hidden>×</button>
</div>
<button class="icon-btn" id="themeBtn" type="button" aria-label="Toggle light and dark theme" title="Toggle theme">
<svg class="ic-moon" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round" aria-hidden="true">
<path d="M21 12.9A8.5 8.5 0 1 1 11.1 3a6.9 6.9 0 0 0 9.9 9.9z"/>
</svg>
<svg class="ic-sun" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true">
<circle cx="12" cy="12" r="4.2"/>
<path d="M12 2.5v2M12 19.5v2M2.5 12h2M19.5 12h2M5.2 5.2l1.4 1.4M17.4 17.4l1.4 1.4M18.8 5.2l-1.4 1.4M6.6 17.4l-1.4 1.4"/>
</svg>
</button>
</div>
</header>
<main class="board" id="board"></main>
</div>
<div class="overlay" id="overlay" hidden>
<div class="modal" role="dialog" aria-modal="true" aria-labelledby="modalTitle">
<div class="modal-head">
<h2 id="modalTitle">New card</h2>
<button class="x" id="closeModal" type="button" aria-label="Close dialog">×</button>
</div>
<form id="cardForm" novalidate>
<div class="field">
<label for="titleInput">Title</label>
<input id="titleInput" type="text" placeholder="e.g. Review the empty states" maxlength="90" autocomplete="off">
</div>
<div class="field">
<span class="lbl">Priority</span>
<div class="segmented" id="prioGroup" role="group" aria-label="Priority">
<button type="button" data-prio="low" aria-pressed="false">Low</button>
<button type="button" data-prio="medium" aria-pressed="true">Medium</button>
<button type="button" data-prio="high" aria-pressed="false">High</button>
</div>
</div>
<div class="field">
<label for="colSelect">Column</label>
<select id="colSelect">
<option value="todo">To do</option>
<option value="doing">In progress</option>
<option value="done">Done</option>
</select>
</div>
<p class="err" id="err" role="alert"></p>
<div class="actions">
<button type="button" class="btn ghost" id="cancelBtn">Cancel</button>
<button type="submit" class="btn primary">Add card</button>
</div>
</form>
</div>
</div>
<script>
(function () {
'use strict';
/* ---------------- state ---------------- */
var COLUMNS = [
{ key: 'todo', name: 'To do', color: '#3b82f6' },
{ key: 'doing', name: 'In progress', color: '#f59e0b' },
{ key: 'done', name: 'Done', color: '#10b981' }
];
var cards = [
{ id: 1, title: 'Design the new onboarding flow', priority: 'high', column: 'todo' },
{ id: 2, title: 'Audit accessibility of the settings page', priority: 'low', column: 'todo' },
{ id: 3, title: 'Fix drag-and-drop flicker on Safari', priority: 'medium', column: 'doing' },
{ id: 4, title: 'Ship dark mode colour tokens', priority: 'high', column: 'doing' },
{ id: 5, title: 'Set up CI pipeline with GitHub Actions', priority: 'medium', column: 'done' },
{ id: 6, title: 'Migrate docs to the new search index', priority: 'low', column: 'done' }
];
var nextId = 7;
var query = '';
/* ---------------- dom refs ---------------- */
var board = document.getElementById('board');
var subtitle = document.getElementById('subtitle');
var searchInput = document.getElementById('search');
var clearBtn = document.getElementById('clearSearch');
var themeBtn = document.getElementById('themeBtn');
var overlay = document.getElementById('overlay');
var form = document.getElementById('cardForm');
var titleInput = document.getElementById('titleInput');
var colSelect = document.getElementById('colSelect');
var errEl = document.getElementById('err');
var prioGroup = document.getElementById('prioGroup');
var cancelBtn = document.getElementById('cancelBtn');
var closeBtn = document.getElementById('closeModal');
var prio = 'medium';
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" aria-hidden="true"><path d="M3 6h18"/><path d="M8 6V4.6A1.6 1.6 0 0 1 9.6 3h4.8A1.6 1.6 0 0 1 16 4.6V6"/><path d="M5.6 6l.9 13.1A2 2 0 0 0 8.5 21h7a2 2 0 0 0 2-1.9L18.4 6"/></svg>';
/* ---------------- helpers ---------------- */
function escapeHtml(s) {
return String(s).replace(/[&<>"']/g, function (m) {
return { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[m];
});
}
function highlight(title, q) {
if (!q) return escapeHtml(title);
var i = title.toLowerCase().indexOf(q);
if (i < 0) return escapeHtml(title);
return escapeHtml(title.slice(0, i)) +
'<mark>' + escapeHtml(title.slice(i, i + q.length)) + '</mark>' +
escapeHtml(title.slice(i + q.length));
}
/* ---------------- board skeleton ---------------- */
function buildBoard() {
var html = '';
COLUMNS.forEach(function (c) {
html += '<section class="column" data-col="' + c.key + '">' +
'<div class="col-head">' +
'<span class="dot" style="background:' + c.color + '"></span>' +
'<h2>' + c.name + '</h2>' +
'<span class="count" data-count="' + c.key + '">0</span>' +
'</div>' +
'<div class="cards" data-list="' + c.key + '"></div>' +
'<button class="add-row" type="button" data-add="' + c.key + '">+ Add a card</button>' +
'</section>';
});
board.innerHTML = html;
}
/* ---------------- render ---------------- */
function makeCard(card, q) {
var el = document.createElement('article');
el.className = 'card';
el.draggable = true;
el.dataset.id = String(card.id);
el.innerHTML =
'<p class="card-title">' + highlight(card.title, q) + '</p>' +
'<div class="card-foot">' +
'<span class="badge ' + card.priority + '">' + card.priority + '</span>' +
'<button class="del" type="button" aria-label="Delete card" title="Delete card">' + TRASH + '</button>' +
'</div>';
return el;
}
function render() {
var q = query.trim().toLowerCase();
COLUMNS.forEach(function (col) {
var list = board.querySelector('[data-list="' + col.key + '"]');
var countEl = board.querySelector('[data-count="' + col.key + '"]');
var inCol = cards.filter(function (c) { return c.column === col.key; });
var shown = q ? inCol.filter(function (c) { return c.title.toLowerCase().indexOf(q) !== -1; }) : inCol;
countEl.textContent = String(shown.length);
list.innerHTML = '';
if (!shown.length) {
var p = document.createElement('p');
p.className = 'empty';
p.textContent = inCol.length ? 'No matching cards' : 'Drop cards here';
list.appendChild(p);
}
shown.forEach(function (c) { list.appendChild(makeCard(c, q)); });
});
subtitle.textContent = 'Sprint 24 · ' + cards.length + ' card' + (cards.length === 1 ? '' : 's');
}
/* ---------------- drag & drop ---------------- */
var dragId = null;
var dropIdx = 0;
var ph = null;
var phHeight = 66;
function placeholder() {
if (!ph) {
ph = document.createElement('div');
ph.className = 'drop-ph';
}
ph.style.height = phHeight + 'px';
return ph;
}
function setActiveColumn(col) {
var cols = board.querySelectorAll('.column');
for (var i = 0; i < cols.length; i++) {
cols[i].classList.toggle('drag-over', cols[i] === col);
}
}
function cleanupDrag() {
dragId = null;
document.body.classList.remove('is-dragging');
if (ph && ph.parentNode) ph.parentNode.removeChild(ph);
var dragging = board.querySelectorAll('.dragging');
for (var i = 0; i < dragging.length; i++) dragging[i].classList.remove('dragging');
var over = board.querySelectorAll('.column.drag-over');
for (var j = 0; j < over.length; j++) over[j].classList.remove('drag-over');
}
board.addEventListener('dragstart', function (e) {
var card = e.target.closest('.card');
if (!card) return;
dragId = Number(card.dataset.id);
phHeight = card.offsetHeight || 66;
card.classList.add('dragging');
document.body.classList.add('is-dragging');
e.dataTransfer.effectAllowed = 'move';
try { e.dataTransfer.setData('text/plain', String(dragId)); } catch (err) { /* ignore */ }
});
board.addEventListener('dragover', function (e) {
if (dragId === null) return;
var col = e.target.closest('.column');
if (!col) return;
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
var list = col.querySelector('.cards');
var others = Array.prototype.filter.call(
list.querySelectorAll('.card:not(.dragging)'),
function (el) { return el.offsetParent !== null; }
);
var idx = others.length;
for (var i = 0; i < others.length; i++) {
var r = others[i].getBoundingClientRect();
if (e.clientY < r.top + r.height / 2) { idx = i; break; }
}
dropIdx = idx;
var p = placeholder();
var ref = others[idx] || null;
if (ref) list.insertBefore(p, ref); else list.appendChild(p);
setActiveColumn(col);
});
board.addEventListener('dragleave', function (e) {
var col = e.target.closest('.column');
if (!col) return;
var to = e.relatedTarget;
if (!to || !col.contains(to)) col.classList.remove('drag-over');
});
board.addEventListener('drop', function (e) {
if (dragId === null) return;
var col = e.target.closest('.column');
if (!col) return;
e.preventDefault();
moveCard(dragId, col.dataset.col, dropIdx);
cleanupDrag();
render();
});
board.addEventListener('dragend', function () { cleanupDrag(); });
function moveCard(id, toCol, toIdx) {
var i = -1;
for (var k = 0; k < cards.length; k++) { if (cards[k].id === id) { i = k; break; } }
if (i < 0) return;
var moved = cards.splice(i, 1)[0];
var targetCards = cards.filter(function (c) { return c.column === toCol; });
var ref = targetCards[toIdx] || null;
var at;
if (ref) {
at = cards.indexOf(ref);
} else {
var lastIdx = -1;
for (var j = 0; j < cards.length; j++) { if (cards[j].column === toCol) lastIdx = j; }
at = lastIdx === -1 ? cards.length : lastIdx + 1;
}
moved.column = toCol;
cards.splice(at, 0, moved);
}
/* ---------------- click delegation (delete / add) ---------------- */
board.addEventListener('click', function (e) {
var addBtn = e.target.closest('[data-add]');
if (addBtn) { openModal(addBtn.dataset.add); return; }
var delBtn = e.target.closest('.del');
if (delBtn) {
var cardEl = delBtn.closest('.card');
if (!cardEl) return;
var id = Number(cardEl.dataset.id);
cardEl.classList.add('removing');
setTimeout(function () {
cards = cards.filter(function (c) { return c.id !== id; });
render();
}, 160);
}
});
/* ---------------- search ---------------- */
searchInput.addEventListener('input', function () {
query = searchInput.value;
clearBtn.hidden = query.length === 0;
render();
});
clearBtn.addEventListener('click', function () {
searchInput.value = '';
query = '';
clearBtn.hidden = true;
render();
searchInput.focus();
});
/* ---------------- theme ---------------- */
themeBtn.addEventListener('click', function () {
var next = document.documentElement.dataset.theme === 'dark' ? 'light' : 'dark';
document.documentElement.dataset.theme = next;
});
/* ---------------- modal ---------------- */
function setPrio(p) {
prio = p;
var btns = prioGroup.querySelectorAll('button[data-prio]');
for (var i = 0; i < btns.length; i++) {
btns[i].setAttribute('aria-pressed', String(btns[i].dataset.prio === p));
}
}
function openModal(colKey) {
overlay.hidden = false;
form.reset();
titleInput.value = '';
titleInput.classList.remove('invalid');
errEl.textContent = '';
colSelect.value = colKey || 'todo';
setPrio('medium');
setTimeout(function () { titleInput.focus(); }, 40);
}
function closeModal() {
overlay.hidden = true;
}
prioGroup.addEventListener('click', function (e) {
var b = e.target.closest('button[data-prio]');
if (b) setPrio(b.dataset.prio);
});
cancelBtn.addEventListener('click', closeModal);
closeBtn.addEventListener('click', closeModal);
overlay.addEventListener('mousedown', function (e) {
if (e.target === overlay) closeModal();
});
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape' && !overlay.hidden) closeModal();
});
titleInput.addEventListener('input', function () {
errEl.textContent = '';
titleInput.classList.remove('invalid');
});
form.addEventListener('submit', function (e) {
e.preventDefault();
var title = titleInput.value.trim();
if (!title) {
errEl.textContent = 'A title is required.';
titleInput.classList.add('invalid');
titleInput.focus();
return;
}
var card = { id: nextId++, title: title, priority: prio, column: colSelect.value };
cards.push(card);
if (query) {
query = '';
searchInput.value = '';
clearBtn.hidden = true;
}
closeModal();
render();
var el = board.querySelector('.card[data-id="' + card.id + '"]');
if (el) {
el.classList.add('pop');
el.scrollIntoView({ block: 'nearest' });
}
});
/* ---------------- boot ---------------- */
buildBoard();
render();
})();
</script>
</body>
</html>Reasoning trace (20,870 tokens)
Let me build a polished Kanban board demo. Requirements: three columns with counts, 6 pre-populated cards, priority badges, add via modal, delete, drag and drop with drop indicator, search filter, theme toggle, in-memory state.
Important: no framework. Self-contained. Must look right at 1280x800 and adapt to 375px. First paint must be meaningful — the board with cards visible immediately.
Also the "Click or press a key to start" requirement is for games; this isn't a game. But keyboard events only after click — the modal has a title input, which requires focus, which requires click. That's fine. Actually typing into an input requires the iframe to be focused; a click on the input gives focus. Fine.
Let me write clean code.
Structure:
- Header: title "Kanban Board" + subtitle, search field, theme toggle button.
- Board: 3 columns.
- Each column: header with dot, name, count badge; card list (scrollable); drop zone.
- Cards: priority badge, title, delete button (appears on hover but also visible on touch), maybe a small footer.
- Modal: overlay with form, title input, priority select, Cancel/Add buttons.
- Toast? Maybe not necessary, but a subtle animation.
Drag & drop: Use HTML5 drag events with dragstart/dragover/drop, or pointer-based custom drag? HTML5 DnD is simpler and works well. But the drop indicator: I'll show a placeholder line/ghost. Let's do HTML5 DnD with:
- dragstart: set dataTransfer, add .dragging class (opacity, rotate).
- dragover on column: preventDefault, compute insertion index based on mouse Y relative to cards, show a drop placeholder element at that position.
- drop: move card.
Implementation detail: I'll keep a placeholder div inserted into the list at the computed index. On dragleave/drop, remove it.
Let's compute index: get cards (excluding the dragged one? the dragged one stays in DOM but with .dragging). Actually simpler: during drag, keep the dragged element in the DOM with reduced opacity, and insert placeholder among the other cards. Let's compute:
```js
function getDropIndex(list, clientY) {
const cards = [...list.querySelectorAll('.card:not(.dragging)')];
for (let i = 0; i < cards.length; i++) {
const r = cards[i].getBoundingClientRect();
if (clientY < r.top + r.height / 2) return i;
}
return cards.length;
}
```
Then insert placeholder before cards[index] or append.
Hmm, but placeholder among cards: need it inside list. The list contains cards. Fine.
Note: when searching, filtering hides cards with display:none — drag would be weird. That's OK, but drop index computation with hidden cards... I'll filter out hidden ones by checking offsetParent? Simpler: disable drag when search active? No — better: during drag, cards hidden by filter are still display:none so getBoundingClientRect returns zeros. Let's filter: `cards.filter(c => c.offsetParent !== null)`. Actually offsetParent is null for display:none elements. Good.
Also for counts: "each showing its card count" — show total per column regardless of filter? I'd show the count of cards in the column (all), and maybe when filtering show filtered/total. Let's keep it simple: count = number of cards in column. When searching, maybe show filtered count. I'll show the count of visible cards when a search is active, with subtle style. Hmm, keep simple: count of cards in the column (total). Actually if a filter hides cards, showing the total is fine and common (like Trello). I'll show total, and add a "no matches" empty state when filtering hides everything in a column.
Let's think about the "no results" state: if search has text and a column has no visible cards, show a small "No matching cards" message. And empty columns show "Drop cards here" placeholder.
Theme toggle: data-theme attribute on <html> or body, CSS variables for light/dark. Default: light? Or dark? Let's default to light with a nice modern look — actually a dark theme looks more impressive in a screenshot. Hmm, side-by-side comparison... Let's do light default since it's a "board" — actually let's do dark? I'll go with light default because the requirement says "light / dark theme toggle" and light is the natural default. A really nicely designed light theme with soft shadows.
Let me design:
Colors (light):
- bg: #f4f6fb / gradient
- surface: #ffffff
- text: #101828
- muted: #667085
- border: #e4e7ec
- accent: #4f46e5 (indigo)
- priority high: #e11d48 bg #ffe4e6
- medium: #d97706 bg #fef3c7
- low: #059669 bg #d1fae5
Dark:
- bg: #0b1020
- surface: #141a2e
- column bg: #101728
- text: #e6e9f2
- muted: #98a2b3
- border: #253048
Column header: dot colored by column (todo gray/blue, in progress amber, done green) + name + count pill.
Card: white surface, radius 12, subtle border, shadow on hover, drag cursor grab. Priority badge top-right? Layout: row with badge and delete button; title below. Actually: title first (bold-ish), then footer row with priority badge and delete button. Hmm, delete button on hover in top-right corner.
Let's do:
```
<article class="card" draggable="true">
<div class="card-top">
<span class="badge badge-high">High</span>
<button class="del" aria-label="Delete card">×</button>
</div>
<p class="card-title">…</p>
</article>
```
Delete button: opacity 0 until hover, but always visible on touch devices (@media (hover:none)).
Cards data:
1. "Design new onboarding flow" — high — To do
2. "Fix Safari flexbox bug" — medium — To do
3. "Write Q3 release notes" — low — To do? Let's spread: To do 2, In progress 2, Done 2.
Cards:
To do:
- "Design new onboarding flow" (high)
- "Audit accessibility of the settings page" (low)
In progress:
- "Fix drag-and-drop flicker on Safari" (medium)
- "Ship dark mode palette tokens" (high)
Done:
- "Set up CI pipeline with GitHub Actions" (medium)
- "Migrate docs to the new search index" (low)
Good, 6 realistic cards.
Add card modal: overlay with backdrop blur, form with title input (required), priority radio/segmented buttons, column select (nice touch — lets you add to a specific column; requirement says "add a card through a small modal form (title required)"). I'll include column select with default "To do". Then a "+ Add card" button at the bottom of each column? Or one global "+ Add card" button in the header that adds to To do, plus per-column "+" buttons that preset the column. Let's do per-column "+" button in each column header — cleaner and gives column selection implicitly. Plus maybe a global button. I'll do per-column add buttons (icon + label at bottom of column). That's nice and discoverable.
Hmm, but the requirement says "Add a card through a small modal form". Per-column "+ Add a card" button at bottom of each column opens the modal with that column preselected. Good.
Modal form:
- Title input (autofocus on open).
- Priority: three segmented buttons (radio-like).
- Column: select.
- Buttons: Cancel, Add card.
Validation: if title empty, show error message and shake input.
Also nice: keyboard — Enter submits (form submit), Escape closes.
Drag and drop details with HTML5:
```js
card.addEventListener('dragstart', e => {
dragId = card.dataset.id;
card.classList.add('dragging');
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', dragId);
requestAnimationFrame(...) // optional
});
card.addEventListener('dragend', () => { cleanup(); });
```
Note: with `opacity` on dragging element, the drag image is captured before class applied? It's fine.
Column list dragover:
```js
list.addEventListener('dragover', e => {
if (!dragId) return;
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
list.classList.add('drag-over');
const idx = getDropIndex(list, e.clientY);
const cards = [...list.querySelectorAll('.card:not(.dragging)')].filter(c => c.offsetParent !== null);
...insert placeholder
});
```
Actually careful: placeholder is a child of list; when querying cards we exclude it (it has class .drop-ph not .card). Insert:
```js
const ref = cards[idx] || null;
list.insertBefore(ph, ref);
```
But if the column is empty (no cards), insertBefore(ph, null) appends. Good.
Also need the "empty state" text element inside list? If I have an `.empty` element in the list, it interferes. Better: put the empty message as a sibling of the list, shown via CSS when list has no .card children. Use `:has()`? Chrome supports :has(). But safer with JS: after render, toggle class on column.
Let's do JS: after each render, for each column, check if visible cards exist -> toggle `is-empty` / `no-match`.
Hmm, but during drag with placeholder, list has a placeholder but no cards... The empty message would show. That's fine actually.
Simplify: render function rebuilds the lists entirely. But drag with HTML5 DnD — re-rendering during dragover would destroy the dragged element and cancel the drag. So during drag I must not re-render. I'll do placeholder manipulation only (inserting placeholder element, not re-rendering). On drop, I move the card element in DOM directly then update state and counts.
Approach: DOM as source of truth for ordering within columns? Let's keep state array with column field and order index, but simpler: keep `cards` array; each card has `column`. Order within a column determined by array order. On drop, reorder array and re-render.
To avoid drag cancellation on drop: drop happens, then re-render is fine (drag is over).
For placeholder insertion during dragover, no re-render. Good.
Implementation of drop:
```js
list.addEventListener('drop', e => {
e.preventDefault();
if (!dragId) return;
const ph = list.querySelector('.drop-ph');
// compute index among non-dragging cards
let idx = 0;
if (ph) {
const cards = [...list.querySelectorAll('.card:not(.dragging)')].filter(c => c.offsetParent !== null);
// count cards before ph
let count = 0;
for (const c of cards) { if (ph.compareDocumentPosition(c) & Node.DOCUMENT_POSITION_FOLLOWING) break; count++; }
idx = count;
}
moveCard(dragId, columnKey, idx);
cleanup();
});
```
Hmm, computing index from placeholder position — a bit fiddly. Alternative: store lastIndex on the list during dragover in a variable `dropTarget = {column, index}`. Yes! Simpler. During dragover compute idx and store it in a module-level `dropIndex` and `dropColumn`. On drop use those.
Let's do that.
moveCard(id, toColumn, toIndex):
```js
const card = cards.find(c => c.id === id);
// remove from array
const from = cards.indexOf(card);
cards.splice(from, 1);
// compute insert position: index among cards in target column, excluding the moved card
const targetCards = cards.filter(c => c.column === toColumn);
const ref = targetCards[toIndex] || null;
```
But we need global array insertion position. Since array order matters per column, we can insert relative to ref: if ref, insert at index of ref in global array; else, insert after last card of that column (or at end if none).
```js
let insertAt;
if (ref) insertAt = cards.indexOf(ref);
else {
const last = [...cards].reverse().find(c => c.column === toColumn);
insertAt = last ? cards.indexOf(last) + 1 : cards.length;
}
cards.splice(insertAt, 0, card);
card.column = toColumn;
```
Careful: `[...cards].reverse().find` returns last matching. indexOf works. Good. Note when no cards in target column, insertAt = cards.length (append). Fine.
Then render().
Render: for each column, build cards in array order filtered by column and search match.
Search: input event -> set query, re-render (drag not active during typing, fine).
Also highlight matching text? Nice touch but optional. Could add `<mark>`. Let's add it — subtle. Actually, keep it: highlighting matched substring in title. Use escapeHtml then replace. Careful with regex special chars. Let's do it properly:
```js
function highlight(title, q) {
if (!q) return escapeHtml(title);
const i = title.toLowerCase().indexOf(q.toLowerCase());
if (i < 0) return escapeHtml(title);
return escapeHtml(title.slice(0,i)) + '<mark>' + escapeHtml(title.slice(i, i+q.length)) + '</mark>' + escapeHtml(title.slice(i+q.length));
}
```
Nice.
Counts: per column, show number of cards in that column (all cards, not filtered). Maybe show filtered count as "2/3" when filtering. Let's just show total count. Simpler and matches "each showing its card count".
Hmm, but when filtering, showing 3 while 1 visible might confuse. I'll show the visible count when a query is active. Let's do: count element text = query ? visibleCount : totalCount. Actually let's show total always but dim... no. I'll go with: if query active, show visible count (of matching). It's live filtering, natural.
Let me just compute visible cards per column after filter and set count = visible.length. When no query, visible = all. So count always = number of cards displayed. That's consistent and simple. Good.
Empty state per column:
- If column has 0 cards total: "Drop cards here" with a dashed outline look.
- If column has cards but 0 visible due to search: "No matches".
I'll render a message element conditionally.
Now let's think about the visual design more concretely.
```html
<div class="app">
<header class="topbar">
<div class="brand">
<div class="logo">▦</div> <!-- maybe an SVG kanban icon -->
<div>
<h1>Product Board</h1>
<p class="sub">Sprint 24 · 6 cards</p>
</div>
</div>
<div class="tools">
<div class="search">
<svg .../>
<input id="search" placeholder="Search cards…" />
<button id="clearSearch" class="clear">×</button>
</div>
<button id="themeBtn" class="icon-btn" title="Toggle theme">🌙 / ☀️ svg</button>
</div>
</header>
<main class="board">
<section class="column" data-col="todo"> ... </section>
...
</main>
</div>
```
Header on mobile: stack search below brand, full width.
Board: `display: grid; grid-template-columns: repeat(3, minmax(0,1fr)); gap: 16px;` At 375px: single column stacked, `grid-template-columns: 1fr` with `overflow-y` page scroll. Also add a media query at maybe 820px -> 1 column? At 375 three columns of ~110px is too narrow. Use `@media (max-width: 780px) { .board { grid-template-columns: 1fr; } }`.
For 1280x800, the board should fill height with columns scrolling internally. Let's use `height: 100dvh; display:flex; flex-direction:column;` on .app, board flex:1, min-height:0, columns overflow-y:auto. On mobile, let the page scroll instead: at max-width 780px, set .app height auto, columns overflow visible.
Hmm, careful with body overflow and no horizontal scrollbar.
Let's write CSS variables.
```css
:root {
--bg: #eef1f8;
--bg-grad: radial-gradient(...);
--panel: #ffffff;
--col-bg: #f7f8fc;
--text: #101828;
--muted: #667085;
--border: #e3e7ef;
--shadow: 0 1px 2px rgba(16,24,40,.06), 0 8px 24px -12px rgba(16,24,40,.18);
--accent: #5b5bd6;
--accent-soft: #ececfd;
}
[data-theme="dark"] { ... }
```
Add smooth transitions on background-color/color.
Priority badge colors: define per theme.
```css
.prio-high { --p-fg:#b42318; --p-bg:#fee4e2; }
```
Use badge classes.
Let me write:
```css
.badge { font-size: 11px; font-weight: 600; letter-spacing: .02em; padding: 3px 8px; border-radius: 999px; display:inline-flex; align-items:center; gap:5px; text-transform: capitalize; }
.badge::before { content:''; width:6px; height:6px; border-radius:50%; background: currentColor; }
.badge.high { color:#b42318; background:#fee4e2; }
.badge.medium { color:#b54708; background:#fef0c7; }
.badge.low { color:#067647; background:#dcfae6; }
```
Dark:
```css
[data-theme="dark"] .badge.high { color:#fda29b; background:rgba(240,68,56,.16); }
```
etc.
Column accent dots: todo #98a2b3 (gray) / actually let's use blue #3b82f6 for todo, amber #f59e0b for in progress, green #10b981 for done. Nice.
Card drag styles:
```css
.card.dragging { opacity: .4; transform: rotate(1.5deg) scale(.98); }
.card { cursor: grab; }
.card:active { cursor: grabbing; }
```
Drop placeholder:
```css
.drop-ph { height: 2px; ... }
```
Hmm, a line indicator vs a full ghost slot. A ghost slot (dashed rounded rect ~ 68px tall) looks clearer. Let's do a dashed slot:
```css
.drop-ph {
height: 76px; border-radius: 12px; border: 2px dashed var(--accent);
background: var(--accent-soft); opacity: .9;
animation: pulse...
}
```
Height varies with card height though; use min-height 64px. Fine.
Also column `.drag-over` gets a highlighted background + border color.
The dragged element itself: since I keep it in place with opacity, and the placeholder moves around, it looks decent. Alternative: hide the dragged card entirely (display:none) so it looks like it's moved out. But then the column would shift. Keeping opacity .35 is fine.
Hmm — actually a common nice approach: on dragstart, hide original after a frame. Let's keep opacity.
Now the modal:
```html
<div class="overlay" id="overlay" hidden>
<div class="modal" role="dialog" aria-modal="true" aria-labelledby="modalTitle">
<h2 id="modalTitle">New card</h2>
<form id="cardForm">
<label class="field">
<span>Title</span>
<input id="titleInput" maxlength="80" placeholder="e.g. Review empty states" autocomplete="off" />
</label>
<div class="field">
<span>Priority</span>
<div class="segmented" id="prioGroup">
<button type="button" data-prio="low">Low</button>
...
</div>
</div>
<label class="field">
<span>Column</span>
<select id="colSelect">...</select>
</label>
<p class="err" id="err"></p>
<div class="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 + CSS `.overlay[hidden]{display:none}`. Or use a class. I'll use `.overlay` with `display:grid; place-items:center;` and `[hidden]{display:none}`.
Animation: fade in + scale.
Autofocus on open: `setTimeout(() => input.focus(), 30)`.
Now the delete: button with an × icon (SVG trash). Use a small trash SVG or ×. I'll use an "×" glyph in a circle? Let's use a trash SVG for clarity. Keep it small 14px.
Actually simpler and clean: `×` character with font-size 16px. Hmm, trash icon reads better as "delete". I'll do a trash SVG path:
```html
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M3 6h18M8 6V4h8v2M6 6l1 14h10l1-14"/></svg>
```
Fine.
Delete animation: add class .removing, then after 180ms remove from state & render. Nice touch. But careful with re-render. Use:
```js
card.classList.add('removing');
setTimeout(() => { cards = cards.filter(...); render(); }, 160);
```
Hmm, render replaces DOM. Fine.
Also add card animation: new card gets `.card.new` with keyframe pop-in. Use a flag `justAddedId`.
Let me also add a small footer hint: "Drag cards between columns · Click + to add". Maybe in the header sub. Keep minimal.
Count of total cards in the subtitle: "Sprint 24 · 6 cards" updates live.
Now, keyboard events in iframe: the demo isn't a game, so no "click to start" overlay needed. But focus for typing requires a click on the input, which is normal. Good. But the requirement says: "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." This is a mouse-driven app; keyboard only used for typing into inputs, which naturally requires clicking. I won't add the overlay (it would hurt the screenshot). Hmm, but the rule is stated strongly. The intent is games/demos where keyboard is essential. A kanban board doesn't need keyboard to be judged. I'll skip the overlay. Actually... risk. The screenshot at 3s would show an overlay covering the board — that would hurt. And the rule says "if the demo uses the keyboard". It doesn't primarily. Skip.
Let me now write the code carefully.
```js
const state = {
cards: [
{ id: 1, title: 'Design new onboarding flow', priority: 'high', column: 'todo' },
{ id: 2, title: 'Audit accessibility of settings page', priority: 'low', column: 'todo' },
{ id: 3, title: 'Fix drag-and-drop flicker on Safari', priority: 'medium', column: 'doing' },
{ id: 4, title: 'Ship dark mode colour tokens', priority: 'high', column: 'doing' },
{ id: 5, title: 'Set up CI pipeline with GitHub Actions', priority: 'medium', column: 'done' },
{ id: 6, title: 'Migrate docs to the new search index', priority: 'low', column: 'done' },
],
query: '',
theme: 'light',
};
```
Columns config:
```js
const COLUMNS = [
{ key:'todo', name:'To do', color:'#3b82f6' },
{ key:'doing', name:'In progress', color:'#f59e0b' },
{ key:'done', name:'Done', color:'#10b981' },
];
```
Render columns statically in HTML? Better build in JS to keep DOM in sync. I'll build the board skeleton in HTML with the three sections and their headers, and JS fills the `.cards` list. Actually building entirely in JS is simpler for counts and re-renders. Let's create the board in JS on init:
```js
function buildBoard() {
board.innerHTML = COLUMNS.map(col => `
<section class="column" data-col="${col.key}">
<header class="col-head">
<span class="dot" style="background:${col.color}"></span>
<h2>${col.name}</h2>
<span class="count" data-count="${col.key}">0</span>
<button class="add-btn" data-add="${col.key}" title="Add card to ${col.name}" aria-label="Add card to ${col.name}">+</button>
</header>
<div class="cards" data-list="${col.key}"></div>
<button class="add-row" data-add="${col.key}">+ Add a card</button>
</section>`).join('');
}
```
Wait, the count should be near the name; the add button at right. Let's put: dot, name, count pill, spacer, add button.
Hmm, both an add-btn in header and add-row at bottom? Redundant. Keep just the "+ Add a card" row at the bottom of each column — it's the Trello pattern and is clearly visible. But then it's at the bottom of the column, which on a tall column is at the bottom of the viewport — visible in 800px height? The column height fills the board area; the add row would be pinned at the bottom of the column (after the scrollable list). Yes: column is flex column, list flex:1 overflow auto, add row at bottom. Always visible.
But with empty columns, the list is empty and the add row sits right under the header? No — list flex:1 takes remaining space, so add row is at the bottom. Fine.
Hmm, for a nicer look: the add row at bottom with dashed border on hover. Good.
Let me reconsider: the column header "Add" button also nice for quickness. I'll skip to reduce clutter. Just the bottom row.
Now render():
```js
function render() {
const q = state.query.trim().toLowerCase();
COLUMNS.forEach(col => {
const list = document.querySelector(`[data-list="${col.key}"]`);
const items = state.cards.filter(c => c.column === col.key);
const visible = q ? items.filter(c => c.title.toLowerCase().includes(q)) : items;
document.querySelector(`[data-count="${col.key}"]`).textContent = visible.length;
list.innerHTML = '';
if (visible.length === 0) {
const msg = document.createElement('p');
msg.className = 'empty';
msg.textContent = items.length ? 'No matching cards' : 'Drop cards here';
list.appendChild(msg);
}
visible.forEach(c => list.appendChild(cardEl(c, q)));
});
// subtitle
totalEl.textContent = state.cards.length + (state.cards.length === 1 ? ' card' : ' cards');
}
```
cardEl builds the article.
Wait — the empty message inside `.cards` list; when dragging over an empty column, the placeholder gets inserted before the message. That's fine.
But `getDropIndex` filters `.card:not(.dragging)` — empty p isn't a card. Fine.
Card element:
```js
function cardEl(card, q) {
const el = document.createElement('article');
el.className = 'card';
el.draggable = true;
el.dataset.id = card.id;
el.innerHTML = `
<div class="card-top">
<span class="badge ${card.priority}">${card.priority}</span>
<button class="del" aria-label="Delete ${escapeAttr(card.title)}" title="Delete card">${trashSvg}</button>
</div>
<p class="card-title">${highlight(card.title, q)}</p>
`;
return el;
}
```
Event delegation for delete and drag events: attach drag listeners via delegation on board (dragstart bubbles? Yes, dragstart bubbles). dragend too. So:
```js
board.addEventListener('dragstart', e => {
const card = e.target.closest('.card');
if (!card) return;
...
});
```
Good.
Drop handling on `.cards` lists via delegation on board:
```js
board.addEventListener('dragover', e => {
const list = e.target.closest('.cards');
if (!list || !dragId) return;
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
...
});
board.addEventListener('drop', e => { ... });
board.addEventListener('dragleave', ...) // clear highlight when leaving column
```
For dragleave: use `e.target.closest('.column')` and check `relatedTarget` not inside. Simpler: on dragover of a different column, remove highlight from others. And on dragend, clean all.
Let me manage:
```js
let dragId = null, dropCol = null, dropIdx = 0;
```
dragover:
```js
const list = e.target.closest('.cards');
if (!list) return; // ignore
e.preventDefault();
const colKey = list.dataset.list;
const ph = ensurePlaceholder(); // create once, append into list at idx
const cardsInList = [...list.querySelectorAll('.card:not(.dragging)')].filter(c => c.offsetParent !== null);
let idx = cardsInList.length;
for (let i = 0; i < cardsInList.length; i++) {
const r = cardsInList[i].getBoundingClientRect();
if (e.clientY < r.top + r.height/2) { idx = i; break; }
}
dropCol = colKey; dropIdx = idx;
// position placeholder
const ref = cardsInList[idx] || null;
if (ref) list.insertBefore(ph, ref); else list.appendChild(ph);
// highlight
setActiveColumn(colKey);
```
Note: inserting ph repeatedly on every dragover — insertBefore on the same element moves it, which is fine but causes layout thrash. Acceptable.
Careful: `e.clientY` relative to card center works if list scrolls? getBoundingClientRect is viewport-based, clientY viewport-based. Fine.
One catch: the placeholder itself occupies space and could shift cards, causing flicker as the mouse hovers over the placeholder. Since the placeholder is at the drop index and the mouse is usually near it... The classic flicker issue. Common mitigation: only recompute when the target index changes, or ignore dragover events on the placeholder (set `pointer-events: none` on the placeholder!). Yes — `.drop-ph { pointer-events: none; }` so dragover targets the list/cards, not the ph. Good. But then if the mouse is over the ph area, e.target would be the list itself (since ph doesn't receive events, the event goes to the element beneath — which is the list). Then getDropIndex uses clientY relative to remaining cards, which is fine, and the computed idx would be stable. Good.
Also cards themselves: dragover on a card bubbles up to the list. Fine.
Also need `dragover` prevented on the list even when hovering the list padding — yes, handled since e.target.closest('.cards') gives the list.
What if the mouse hovers over the column but not the list (e.g., the header or add row)? Then closest('.cards') is null → no preventDefault → drop not allowed there. That's fine but a bit annoying. Better: attach dragover to the column and always target its list. Let's do:
```js
const col = e.target.closest('.column');
if (!col) return;
const list = col.querySelector('.cards');
```
Then the whole column is a drop zone. Better UX. And the drop handler same.
But careful: the ph insertion index computed from clientY over cards. Good.
Also for the empty column, the `.empty` message is inside list.
Now, `drop`:
```js
board.addEventListener('drop', e => {
const col = e.target.closest('.column');
if (!col || !dragId) return;
e.preventDefault();
moveCard(dragId, col.dataset.col, dropIdx);
cleanupDrag();
render();
});
```
Hmm, dropIdx was set on last dragover — should correspond. OK.
moveCard uses global array logic described.
cleanupDrag: remove ph, remove .dragging classes, remove column highlight, dragId=null.
dragend on board: cleanupDrag (in case of cancel).
Also handle drop outside any column: nothing happens; dragend cleans up.
Potential issue: after drop, we call render() which rebuilds DOM. Then dragend fires on the (now removed) element? The dragend event may still fire but the element is detached — the listener is on board via delegation, so it won't fire if the element is detached. That's fine, cleanup already done.
Actually wait: the dragged element gets replaced by render() during the drop handler. The browser might then fire dragend on a detached node — no listener. Fine.
Let's double check `moveCard` when dropping a card in the same position — no-op-ish but re-renders. Fine.
Now the search: input listener → state.query = value; render(); Also toggle clear button visibility.
Theme: button toggles `document.documentElement.dataset.theme`. Update icon. Save nothing (in memory).
Let's write CSS carefully.
```css
* { box-sizing: border-box; }
html, body { height: 100%; }
body {
margin: 0;
font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
background: var(--bg);
color: var(--text);
-webkit-font-smoothing: antialiased;
overflow: hidden; /* app handles scroll */
}
```
Hmm, on mobile I want page scroll. Use `body { overflow-x: hidden; }` and app height 100dvh with internal scroll for the board. On mobile the board columns stack; the app is 100dvh and the board scrolls vertically. Let's do: `.board { overflow-y: auto; }` on mobile too. That keeps everything within the viewport, no page scroll. Fine — set `.app { height: 100dvh; display:flex; flex-direction:column; overflow:hidden; }` and `.board { flex:1; min-height:0; overflow-y:auto; }`.
On desktop, board is a grid with 3 equal columns; each column has `max-height: 100%` and internal scroll on the list. If the board itself scrolls vertically that's fine too.
Column: `display:flex; flex-direction:column; min-height:0; background: var(--col-bg); border:1px solid var(--border); border-radius:16px; padding:10px;`
Board grid: `align-items: start;` hmm, I want columns to fill the height. Use `align-items: stretch` (default) and set the board grid `grid-auto-rows: minmax(0, 1fr)`? For a single row, with `.board { display:grid; grid-template-columns: repeat(3,minmax(0,1fr)); gap:14px; padding: 0 20px 20px; }` and height constrained by flex:1 + min-height:0, the row will stretch to fill. Columns will then be full height. Good, but need `min-height:0` on columns for internal scrolling.
At mobile (max-width 780px): `grid-template-columns: 1fr; grid-auto-rows: auto;` and columns get a natural height with `max-height: none`. The board scrolls. But `align-items: start` needed so columns don't stretch weirdly — with grid-auto-rows auto they size to content. Fine.
Let me set a min-height for the list on mobile so empty columns aren't tiny: `.cards { min-height: 60px; }`.
Now header:
```css
.topbar {
display:flex; align-items:center; gap:16px; padding: 18px 20px 16px; flex-wrap: wrap;
}
.brand { display:flex; align-items:center; gap:12px; margin-right:auto; }
.logo { width:38px;height:38px;border-radius:11px; background: linear-gradient(135deg,#6366f1,#8b5cf6); display:grid;place-items:center;color:#fff; box-shadow: 0 6px 16px -6px rgba(99,102,241,.8); }
```
Logo icon: three small bars (kanban). Use an SVG:
```html
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"><path d="M5 4v10M12 4v16M19 4v6"/></svg>
```
Nice — represents kanban columns.
h1: font-size 17px, weight 700, margin 0. sub: 12px muted.
Search:
```css
.search { position: relative; display:flex; align-items:center; }
.search svg { position:absolute; left:11px; width:15px;height:15px; color: var(--muted); pointer-events:none; }
.search input {
width: 240px; height: 38px; padding: 0 32px 0 33px;
border-radius: 10px; border:1px solid var(--border); background: var(--panel); color: var(--text);
font-size: 13.5px; outline: none; transition: .18s;
}
.search input:focus { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-ring); }
```
Clear button inside right.
Theme button: 38x38 rounded, border, panel bg, icon.
Now let's think about dark mode colors:
```css
[data-theme="dark"] {
--bg: #0a0e17;
--panel: #151b2b;
--col-bg: #101625;
--text: #e8ecf6;
--muted: #8b97ad;
--border: #232c40;
--accent: #818cf8;
--accent-ring: rgba(129,140,248,.25);
--accent-soft: rgba(129,140,248,.12);
--card-bg: #182031;
--card-border: #263148;
--shadow: 0 1px 2px rgba(0,0,0,.4), 0 10px 24px -14px rgba(0,0,0,.9);
}
```
Light:
```css
:root {
--bg: #eef1f8;
--panel: #ffffff;
--col-bg: #f4f6fb;
--text: #0f172a;
--muted: #64748b;
--border: #e2e8f0;
--card-bg: #ffffff;
--card-border: #e6ebf3;
--accent: #5b5bd6;
--accent-ring: rgba(91,91,214,.18);
--accent-soft: rgba(91,91,214,.09);
--shadow: 0 1px 2px rgba(15,23,42,.05), 0 6px 16px -10px rgba(15,23,42,.25);
}
```
Card:
```css
.card {
background: var(--card-bg);
border: 1px solid var(--card-border);
border-radius: 12px;
padding: 10px 12px 11px;
box-shadow: var(--shadow);
cursor: grab;
transition: transform .16s, box-shadow .16s, border-color .16s, opacity .16s;
will-change: transform;
}
.card:hover { transform: translateY(-1px); border-color: color-mix(in srgb, var(--accent) 40%, var(--card-border)); box-shadow: 0 10px 22px -14px rgba(15,23,42,.45); }
```
color-mix is supported in latest Chrome. Fine, but let's be safe and just use `var(--accent)` with opacity... Simpler: hover border-color: var(--accent); with the transition it's fine. Might be too strong; use `--card-hover-border`. I'll use color-mix — Chrome 111+ supports it. Latest Chrome fine.
Actually to be safe I'll define a var `--accent-border` per theme.
Card top row: display flex, justify-content: space-between, align-items: center, margin-bottom 6px. Actually the badge on top and title below. Hmm, maybe title first then badge — visually the title is the main thing. Let's do:
```
<article class="card">
<p class="card-title">…</p>
<div class="card-foot">
<span class="badge …">high</span>
<button class="del">trash</button>
</div>
</article>
```
That reads better: title prominent, priority badge below. Delete at bottom-right, appears on hover (opacity 0 → 1) but keeps layout space (visibility hidden). On touch/no-hover, always visible.
I'll go with title first.
.card-title { font-size: 13.5px; line-height: 1.4; margin: 0 0 10px; font-weight: 550; letter-spacing: -0.01em; }
font-weight 550 — variable fonts? system-ui may not support; use 600. Hmm, 600 might be heavy for a title; let's use 550 → falls back. Use 500? Titles should be readable: 550 → rounds to 600 for non-variable. Fine, use 600? I'll use 550.
Actually `ui-sans-serif, system-ui` on Windows is Segoe UI which supports variable weights (Segoe UI Variable). On Mac, SF Pro is variable. Fine, use 550.
.card-foot { display:flex; align-items:center; justify-content: space-between; }
.del { opacity:0; ... } and `.card:hover .del, .card:focus-within .del { opacity: 1 }` plus `@media (hover: none) { .del { opacity: .7 } }`.
Del button style: 24x24, border-radius 7, color muted, hover: color #e11d48 bg rgba(225,29,72,.1).
Empty message: dashed border rounded, centered text, muted, padding 22px 10px, font-size 12.5px.
`.cards { display:flex; flex-direction:column; gap:9px; overflow-y:auto; padding: 2px; flex:1; min-height: 0; }`
Careful: overflow-y auto with a focus ring may clip. Use padding 4px and negative margin? Let's use `padding: 4px; margin: -4px;` hmm. Simplify: padding 4px, and the column has padding 8px. Fine.
Scrollbar styling: thin.
`.cards::-webkit-scrollbar { width: 8px }` etc.
Now column header:
```css
.col-head { display:flex; align-items:center; gap:8px; padding: 4px 4px 10px; }
.col-head h2 { font-size: 12px; font-weight: 700; letter-spacing: .06em; text-transform: uppercase; color: var(--muted); margin:0; }
.dot { width:8px; height:8px; border-radius:50%; box-shadow: 0 0 0 3px rgba(...); }
.count { font-size: 11px; font-weight: 700; padding: 2px 7px; border-radius: 999px; background: var(--count-bg); color: var(--muted); }
```
Add row button:
```css
.add-row {
margin-top: 8px; width: 100%; padding: 9px; border-radius: 10px;
border: 1px dashed var(--border); background: transparent; color: var(--muted);
font-size: 12.5px; font-weight: 600; cursor: pointer; transition: .16s;
}
.add-row:hover { border-color: var(--accent); color: var(--accent); background: var(--accent-soft); }
```
Modal CSS:
```css
.overlay { position: fixed; inset: 0; background: rgba(9,13,23,.5); backdrop-filter: blur(3px); display: grid; place-items: center; padding: 20px; z-index: 50; animation: fade .16s ease; }
.overlay[hidden] { display: none; }
.modal { width: min(400px, 100%); background: var(--panel); border: 1px solid var(--border); border-radius: 16px; padding: 20px; box-shadow: 0 30px 60px -20px rgba(0,0,0,.5); animation: pop .2s cubic-bezier(.2,.9,.3,1.2); }
```
Segmented control:
```css
.segmented { display: grid; grid-template-columns: repeat(3,1fr); gap: 6px; }
.segmented button { padding: 8px; border-radius: 9px; border:1px solid var(--border); background: transparent; color: var(--muted); font-size: 12.5px; font-weight: 600; cursor: pointer; transition:.15s; }
.segmented button[aria-pressed="true"] { color:#fff; border-color: transparent; }
```
Per-priority active colors: low → #10b981, medium → #f59e0b, high → #ef4444. Use data attributes:
```css
.segmented button[aria-pressed="true"][data-prio="low"] { background:#10b981; }
```
Good.
Inputs:
```css
input[type=text], select { width:100%; height: 40px; border-radius: 10px; border:1px solid var(--border); background: var(--col-bg); color: var(--text); padding: 0 12px; font-size: 13.5px; outline: none; font-family: inherit; }
```
Error message red small.
Buttons primary: background linear-gradient(135deg,#6366f1,#8b5cf6) or var(--accent). Use accent.
Now the modal opens: I'll also trap focus minimally? Not necessary.
Escape closes modal. Keydown on document: if Escape and overlay open, close.
Now, adding a card:
```js
form.addEventListener('submit', e => {
e.preventDefault();
const title = titleInput.value.trim();
if (!title) { showErr('Please enter a card title'); titleInput.focus(); return; }
const card = { id: nextId++, title, priority: currentPrio, column: colSelect.value };
state.cards.push(card); // appended to end of its column
closeModal(); render();
});
```
Hmm, pushing to the global array end means it goes to the end of its column (since filtering by column preserves array order and appends after other cards of that column). Correct.
But wait — the new card goes to the end of the array; if the column is 'todo' and there are cards in 'doing' after, filtering still yields the correct relative order. Yes.
Maybe insert at the top of the column instead? Trello adds to the end... Actually Trello adds to the top of the list when you use the composer at the top. I'll append at the end. Fine.
After adding, scroll the list to bottom to show the new card. Add `list.scrollTop = list.scrollHeight`.
Also flash animation on the new card: give it class 'new' in render if it's the last added id, then clear.
Let's implement `let justAdded = null;` set to id, and in cardEl add class if `card.id === justAdded`. And reset justAdded after a timeout or after the animation (set justAdded = null after 400ms). Careful: render() called later would re-add the class. Set a timeout to clear it.
Actually simpler: after render, find the element and add the class + animationend listener to remove. Let's do:
```js
const el = list.querySelector(`[data-id="${card.id}"]`); // after render
```
Do it in the submit handler after render:
```js
render();
const el = document.querySelector(`.card[data-id="${card.id}"]`);
if (el) { el.classList.add('pop'); el.scrollIntoView({block:'nearest'}); }
```
`scrollIntoView` may scroll the page... with block nearest it's ok, but it can also scroll the board container. Should be fine.
Hmm, scrollIntoView on a container with overflow hidden ancestors — it might scroll the body if body scrolls. Body has overflow hidden. OK.
Let's use `el.scrollIntoView({ block: 'nearest', behavior: 'smooth' })`. Fine.
CSS `.card.pop { animation: popIn .28s cubic-bezier(.2,.9,.3,1.3); }`.
Now, delete handler via delegation:
```js
board.addEventListener('click', e => {
const del = e.target.closest('.del');
if (del) { const card = del.closest('.card'); ... }
const add = e.target.closest('[data-add]');
if (add) openModal(add.dataset.add);
});
```
For delete animation:
```js
const id = Number(card.dataset.id);
card.classList.add('removing');
setTimeout(() => { state.cards = state.cards.filter(c => c.id !== id); render(); }, 170);
```
`.card.removing { opacity: 0; transform: scale(.94) translateY(-4px); }` with transition. But `.card` has transition on transform/opacity — yes.
Edge: user could double-click delete; the second click on a removing card... fine.
Now `escapeHtml` and `highlight`.
Let's also handle: the search input should be `type="search"`? Using type=search adds a native clear button in some browsers; I have my own. Use type="text" with inputmode. Actually type="search" gives a native X in Chrome. Use type="text" and role="searchbox"? Just type="text".
Hmm, use `type="search"` but hide the native clear: `input[type=search]::-webkit-search-cancel-button{ -webkit-appearance:none; }`. Fine either way. I'll use type="text" with a label.
Accessibility: aria-labels.
Let me now also double check the drop indicator visibility — "a clear visual drop indicator". The dashed placeholder + column highlight is clear.
Also on dragstart, I set `e.dataTransfer.setData('text/plain', id)` for Firefox compatibility (Chrome doesn't require it). And `e.dataTransfer.effectAllowed = 'move'`.
One more consideration: `dragstart` needs the element to be draggable. `el.draggable = true` — but text selection inside? Fine.
Also: clicking the delete button inside a draggable card — the mousedown starts a potential drag. Usually fine.
Let me write the whole thing now.
Also add a subtle background gradient for the light theme:
```css
body { background: radial-gradient(1200px 600px at 10% -10%, #e6e9ff 0%, transparent 60%), radial-gradient(900px 500px at 100% 0%, #ffe9f3 0%, transparent 55%), var(--bg); }
```
Hmm, might be too much. Let's keep a subtle one for light and dark.
Actually, `background-attachment: fixed` issues. It's fine on body.
Dark: `radial-gradient(1200px 600px at 10% -10%, #1b2140 0%, transparent 60%), radial-gradient(900px 500px at 100% 0%, #1a1533 0%, transparent 55%), var(--bg)`.
I'll put the gradient in a variable per theme: `--app-bg`.
Let me write:
```css
:root { --app-bg: radial-gradient(1100px 520px at 8% -12%, #e4e8ff 0%, transparent 62%), radial-gradient(900px 480px at 102% -6%, #ffe4f0 0%, transparent 58%), #eef1f8; }
[data-theme=dark] { --app-bg: radial-gradient(1100px 520px at 8% -12%, #1c2445 0%, transparent 62%), radial-gradient(900px 480px at 102% -6%, #241a3d 0%, transparent 58%), #0a0e17; }
body { background: var(--app-bg); background-attachment: fixed; }
```
Hmm, with body height 100% and app 100dvh, fixed attachment is fine.
Now writing 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>Kanban Board</title>
<style>...</style>
</head>
<body>
<div class="app">
<header class="topbar">...</header>
<main class="board" id="board"></main>
</div>
<div class="overlay" id="overlay" hidden>...</div>
<script>...</script>
</body>
</html>
```
Header markup:
```html
<header class="topbar">
<div class="brand">
<div class="logo">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round"><path d="M5.5 4v9M12 4v15M18.5 4v5"/></svg>
</div>
<div class="brand-text">
<h1>Product Board</h1>
<p id="subtitle">Sprint 24 · 6 cards</p>
</div>
</div>
<div class="tools">
<div class="search">
<svg 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 20l-3.5-3.5"/></svg>
<input id="search" type="text" placeholder="Search cards…" aria-label="Search cards" autocomplete="off" spellcheck="false">
<button class="clear" id="clearSearch" type="button" aria-label="Clear search" hidden>×</button>
</div>
<button class="icon-btn" id="themeBtn" type="button" aria-label="Toggle theme">
<svg class="ic-moon" ...>...</svg>
<svg class="ic-sun" ...>...</svg>
</button>
</div>
</header>
```
Theme button icons: show moon in light mode (click → dark), sun in dark mode. Toggle with CSS:
```css
[data-theme="light"] .ic-sun { display:none; }
[data-theme="dark"] .ic-moon { display:none; }
```
Moon path: `<path d="M21 12.8A8.5 8.5 0 1 1 11.2 3a6.8 6.8 0 0 0 9.8 9.8z"/>` fill currentColor? Use stroke.
Sun: circle + rays.
Now JS. Let me write it out fully.
```js
(function(){
'use strict';
const COLUMNS = [
{ key: 'todo', name: 'To do', color: '#3b82f6' },
{ key: 'doing', name: 'In progress', color: '#f59e0b' },
{ key: 'done', name: 'Done', color: '#10b981' },
];
let cards = [
{ id: 1, title: 'Design the new onboarding flow', priority: 'high', column: 'todo' },
{ id: 2, title: 'Audit accessibility of the settings page', priority: 'low', column: 'todo' },
{ id: 3, title: 'Fix drag-and-drop flicker on Safari', priority: 'medium', column: 'doing' },
{ id: 4, title: 'Ship dark mode colour tokens', priority: 'high', column: 'doing' },
{ id: 5, title: 'Set up CI pipeline with GitHub Actions', priority: 'medium', column: 'done' },
{ id: 6, title: 'Migrate docs to the new search index', priority: 'low', column: 'done' },
];
let nextId = 7;
let query = '';
const board = document.getElementById('board');
...
})();
```
Build board skeleton once:
```js
function buildBoard(){
board.innerHTML = COLUMNS.map(c => `
<section class="column" data-col="${c.key}">
<div class="col-head">
<span class="dot" style="background:${c.color};box-shadow:0 0 0 3px ${hexA(c.color, .16)}"></span>
<h2>${c.name}</h2>
<span class="count" data-count="${c.key}">0</span>
</div>
<div class="cards" data-list="${c.key}"></div>
<button class="add-row" type="button" data-add="${c.key}">+ Add a card</button>
</section>
`).join('');
}
```
hexA: convert hex to rgba. Simple helper.
Count pill: put after name, aligned. `margin-left: auto`? If the count is right after the name it's like "TO DO 2". Fine — put count right after the name, no auto margin. Actually let's push the count to the right side with `margin-left:auto`. Hmm, Trello shows the count next to the name. I'll keep it right after the name with a small gap.
Let me use `.col-head { display:flex; align-items:center; gap:8px; }` and count with no auto margin.
render():
```js
function render(){
const q = query.trim().toLowerCase();
COLUMNS.forEach(col => {
const list = board.querySelector(`[data-list="${col.key}"]`);
const inCol = cards.filter(c => c.column === col.key);
const shown = q ? inCol.filter(c => c.title.toLowerCase().includes(q)) : inCol;
board.querySelector(`[data-count="${col.key}"]`).textContent = shown.length;
list.innerHTML = '';
if (!shown.length){
const p = document.createElement('p');
p.className = 'empty';
p.textContent = inCol.length ? 'No matching cards' : 'Drop cards here';
list.appendChild(p);
}
shown.forEach(c => list.appendChild(makeCard(c, q)));
});
document.getElementById('subtitle').textContent =
`Sprint 24 · ${cards.length} card${cards.length === 1 ? '' : 's'}`;
}
```
makeCard:
```js
const 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 6h18"/><path d="M8 6V4.5A1.5 1.5 0 0 1 9.5 3h5A1.5 1.5 0 0 1 16 4.5V6"/><path d="M5.5 6l.9 13.1A2 2 0 0 0 8.4 21h7.2a2 2 0 0 0 2-1.9L18.5 6"/></svg>';
function makeCard(c, q){
const el = document.createElement('article');
el.className = 'card';
el.draggable = true;
el.dataset.id = c.id;
el.innerHTML = `
<p class="card-title">${highlight(c.title, q)}</p>
<div class="card-foot">
<span class="badge ${c.priority}">${c.priority}</span>
<button class="del" type="button" aria-label="Delete card" title="Delete card">${TRASH}</button>
</div>`;
return el;
}
```
Wait — `el.draggable = true` on an article; the delete button inside. OK.
Priority text: "high" lowercase — display as "High". Use capitalize in CSS or in JS. I'll do JS: `c.priority.charAt(0).toUpperCase() + c.priority.slice(1)`. CSS text-transform: capitalize works too. I'll use CSS.
Drag logic:
```js
let dragId = null;
let dropIdx = 0;
let ph = null;
function placeholder(){
if (!ph){ ph = document.createElement('div'); ph.className = 'drop-ph'; }
return ph;
}
board.addEventListener('dragstart', e => {
const card = e.target.closest('.card');
if (!card) return;
dragId = Number(card.dataset.id);
card.classList.add('dragging');
e.dataTransfer.effectAllowed = 'move';
try { e.dataTransfer.setData('text/plain', String(dragId)); } catch(_) {}
document.body.classList.add('is-dragging');
});
board.addEventListener('dragover', e => {
if (dragId === null) return;
const col = e.target.closest('.column');
if (!col) return;
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
const list = col.querySelector('.cards');
const others = [...list.querySelectorAll('.card:not(.dragging)')].filter(el => el.offsetParent !== null);
let idx = others.length;
for (let i = 0; i < others.length; i++){
const r = others[i].getBoundingClientRect();
if (e.clientY < r.top + r.height / 2){ idx = i; break; }
}
dropIdx = idx;
const p = placeholder();
const ref = others[idx] || null;
if (ref) list.insertBefore(p, ref); else list.appendChild(p);
setActive(col);
});
function setActive(col){
board.querySelectorAll('.column').forEach(c => c.classList.toggle('drag-over', c === col));
}
board.addEventListener('dragleave', e => {
const col = e.target.closest('.column');
if (!col) return;
// if leaving the column entirely
const to = e.relatedTarget;
if (!to || !col.contains(to)) col.classList.remove('drag-over');
});
board.addEventListener('drop', e => {
if (dragId === null) return;
const col = e.target.closest('.column');
if (!col) return;
e.preventDefault();
moveCard(dragId, col.dataset.col, dropIdx);
cleanup();
render();
});
board.addEventListener('dragend', () => { cleanup(); });
function cleanup(){
dragId = null;
document.body.classList.remove('is-dragging');
if (ph && ph.parentNode) ph.parentNode.removeChild(ph);
board.querySelectorAll('.dragging').forEach(el => el.classList.remove('dragging'));
board.querySelectorAll('.column.drag-over').forEach(el => el.classList.remove('drag-over'));
}
```
Wait, in `cleanup` after drop, we call render() which rebuilds the list, removing .dragging anyway. But cleanup is called before render — it removes the ph and dragging classes on the old DOM, then render rebuilds. Fine.
Note: `dropIdx` computed against `others` (cards excluding the dragged one). In moveCard, I compute target index among cards of the target column excluding the moved card. Consistent.
moveCard:
```js
function moveCard(id, toCol, toIdx){
const i = cards.findIndex(c => c.id === id);
if (i < 0) return;
const [card] = cards.splice(i, 1);
const targetCards = cards.filter(c => c.column === toCol);
const ref = targetCards[toIdx] || null;
let at;
if (ref){
at = cards.indexOf(ref);
} else {
let lastIdx = -1;
cards.forEach((c, k) => { if (c.column === toCol) lastIdx = k; });
at = lastIdx === -1 ? cards.length : lastIdx + 1;
}
card.column = toCol;
cards.splice(at, 0, card);
}
```
Good.
Search:
```js
const searchInput = document.getElementById('search');
searchInput.addEventListener('input', () => {
query = searchInput.value;
clearBtn.hidden = !query;
render();
});
clearBtn.addEventListener('click', () => { searchInput.value=''; query=''; clearBtn.hidden=true; render(); searchInput.focus(); });
```
Theme:
```js
const themeBtn = document.getElementById('themeBtn');
let theme = 'light';
themeBtn.addEventListener('click', () => {
theme = theme === 'light' ? 'dark' : 'light';
document.documentElement.dataset.theme = theme;
});
```
Modal:
```js
const overlay = document.getElementById('overlay');
const form = document.getElementById('cardForm');
const titleInput = document.getElementById('titleInput');
const colSelect = document.getElementById('colSelect');
const errEl = document.getElementById('err');
let prio = 'medium';
function openModal(colKey){
overlay.hidden = false;
form.reset();
titleInput.value = '';
colSelect.value = colKey || 'todo';
setPrio('medium');
errEl.textContent = '';
setTimeout(() => titleInput.focus(), 40);
}
function closeModal(){ overlay.hidden = true; }
```
form.reset() resets select to first option — then we set value. OK.
setPrio:
```js
function setPrio(p){
prio = p;
document.querySelectorAll('#prioGroup button').forEach(b => {
b.setAttribute('aria-pressed', String(b.dataset.prio === p));
});
}
document.getElementById('prioGroup').addEventListener('click', e => {
const b = e.target.closest('button[data-prio]');
if (b) setPrio(b.dataset.prio);
});
```
Submit:
```js
form.addEventListener('submit', e => {
e.preventDefault();
const title = titleInput.value.trim();
if (!title){
errEl.textContent = 'A title is required.';
titleInput.classList.add('invalid');
titleInput.focus();
return;
}
const card = { id: nextId++, title, priority: prio, column: colSelect.value };
cards.push(card);
closeModal();
render();
const el = board.querySelector(`.card[data-id="${card.id}"]`);
if (el){
el.classList.add('pop');
el.scrollIntoView({ block: 'nearest' });
}
});
titleInput.addEventListener('input', () => { errEl.textContent=''; titleInput.classList.remove('invalid'); });
```
Hmm: if the query filters out the new card, `board.querySelector` won't find it. Minor. Could clear the search when adding. Let's clear search on add: if query, clear it. Actually that's reasonable UX — the new card should be visible. I'll clear the search input on add.
Hmm, or just leave it. I'll clear it — ensures the new card is visible. Let's do that.
Escape key:
```js
document.addEventListener('keydown', e => {
if (e.key === 'Escape' && !overlay.hidden) closeModal();
});
```
Overlay click outside modal closes:
```js
overlay.addEventListener('mousedown', e => { if (e.target === overlay) closeModal(); });
```
Cancel button closes.
Board click delegation for add-row and delete:
```js
board.addEventListener('click', e => {
const addBtn = e.target.closest('[data-add]');
if (addBtn){ openModal(addBtn.dataset.add); return; }
const delBtn = e.target.closest('.del');
if (delBtn){
const cardEl = delBtn.closest('.card');
const id = Number(cardEl.dataset.id);
cardEl.classList.add('removing');
setTimeout(() => { cards = cards.filter(c => c.id !== id); render(); }, 160);
}
});
```
Careful: `cards` is declared with `let` so reassignment works.
Now, one issue: `render()` after deletion rebuilds everything, which is fine.
Another: after `cleanup()` + `render()` on drop, the `.dragging` class removal etc.
Also `document.body.classList.add('is-dragging')` — used to prevent hover effects maybe. I'll use it to disable card hover transform during drag and set `user-select:none`. Optional. Let's include minimal.
Let me now write the CSS in full.
```css
*, *::before, *::after { box-sizing: border-box; }
:root {
--bg: #eef1f8;
--app-bg: radial-gradient(1100px 520px at 8% -12%, #e2e7ff 0%, transparent 62%),
radial-gradient(900px 480px at 102% -6%, #ffe3f0 0%, transparent 58%),
#eef1f8;
--panel: #ffffff;
--col-bg: #f5f7fc;
--text: #0f172a;
--muted: #64748b;
--border: #e2e8f0;
--card-bg: #ffffff;
--card-border: #e6ebf3;
--accent: #5b5bd6;
--accent-ring: rgba(91,91,214,.18);
--accent-soft: rgba(91,91,214,.10);
--shadow: 0 1px 2px rgba(15,23,42,.05), 0 6px 16px -10px rgba(15,23,42,.25);
--shadow-lift: 0 10px 24px -14px rgba(15,23,42,.45);
--count-bg: #e8ecf5;
}
html[data-theme="dark"] {
--bg: #0a0e17;
--app-bg: radial-gradient(1100px 520px at 8% -12%, #1b2444 0%, transparent 62%),
radial-gradient(900px 480px at 102% -6%, #251a3e 0%, transparent 58%),
#0a0e17;
--panel: #141a2a;
--col-bg: #101625;
--text: #e8ecf6;
--muted: #8b97ad;
--border: #232c40;
--card-bg: #182031;
--card-border: #263148;
--accent: #8b8cf9;
--accent-ring: rgba(139,140,249,.25);
--accent-soft: rgba(139,140,249,.14);
--shadow: 0 1px 2px rgba(0,0,0,.35), 0 8px 20px -12px rgba(0,0,0,.8);
--shadow-lift: 0 12px 26px -14px rgba(0,0,0,.9);
--count-bg: #1c2537;
}
```
body:
```css
body {
margin: 0;
height: 100dvh;
overflow: hidden;
font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
color: var(--text);
background: var(--app-bg);
background-attachment: fixed;
-webkit-font-smoothing: antialiased;
transition: color .25s;
}
```
.app: `height:100dvh; display:flex; flex-direction:column;`
.topbar:
```css
.topbar {
display: flex; align-items: center; gap: 16px; flex-wrap: wrap;
padding: 16px 20px 14px;
}
.brand { display:flex; align-items:center; gap:12px; margin-right:auto; min-width: 0; }
.logo { width:40px;height:40px;border-radius:12px; display:grid;place-items:center; color:#fff; background: linear-gradient(140deg,#6366f1,#a855f7); box-shadow: 0 8px 18px -8px rgba(99,102,241,.9); flex: none;}
.brand h1 { margin:0; font-size:16.5px; font-weight:700; letter-spacing:-.02em; }
.brand p { margin:2px 0 0; font-size:12px; color:var(--muted); font-weight:500; }
```
.tools: `display:flex; align-items:center; gap:10px;`
.search input width 240 → on mobile 100%? Use `.search { flex: 1 1 auto; }` and input `width: 240px; max-width: 100%;`. On mobile the tools row takes full width: `@media (max-width: 620px) { .tools { width: 100%; } .search { flex:1; } .search input { width:100%; } }`.
Board:
```css
.board {
flex: 1; min-height: 0;
display: grid;
grid-template-columns: repeat(3, minmax(0,1fr));
gap: 14px;
padding: 0 20px 20px;
overflow: hidden;
}
```
Hmm, if the board has `overflow:hidden` on desktop, columns stretch to full height and lists scroll internally. Good.
Columns:
```css
.column {
display: flex; flex-direction: column; min-height: 0;
background: var(--col-bg);
border: 1px solid var(--border);
border-radius: 16px;
padding: 10px 10px 10px;
transition: background .2s, border-color .2s, box-shadow .2s;
}
.column.drag-over {
border-color: var(--accent);
background: var(--accent-soft);
box-shadow: inset 0 0 0 1px var(--accent);
}
```
Careful: `.column.drag-over` background accent-soft over col-bg — with alpha it will blend. Fine.
.col-head:
```css
.col-head { display:flex; align-items:center; gap:8px; padding: 4px 4px 10px; }
.col-head h2 { margin:0; font-size:11.5px; font-weight:700; letter-spacing:.07em; text-transform:uppercase; color:var(--muted); white-space:nowrap; }
.dot { width:8px;height:8px;border-radius:50%; flex:none; }
.count { margin-left:auto; font-size:11px; font-weight:700; color:var(--muted); background: var(--count-bg); border-radius:999px; padding:2px 8px; min-width: 22px; text-align:center; }
```
margin-left:auto pushes the count to the right. Nice.
.cards:
```css
.cards {
flex: 1; min-height: 0; overflow-y: auto; overflow-x: hidden;
display: flex; flex-direction: column; gap: 9px;
padding: 3px; margin: -3px;
scrollbar-width: thin;
scrollbar-color: var(--border) transparent;
}
.cards::-webkit-scrollbar { width: 8px; }
.cards::-webkit-scrollbar-thumb { background: var(--border); border-radius: 99px; }
.cards::-webkit-scrollbar-track { background: transparent; }
```
Hmm `margin: -3px` with `padding: 3px` — that would make the cards overflow the column padding by 3px on each side, effectively canceling. Actually I want a little inset so shadows aren't clipped. Let's just use `padding: 2px 3px 2px 3px` and no negative margin, and the column has 10px padding. Fine. Remove the negative margin.
Actually, overflow-y:auto clips horizontal overflow too (overflow-x hidden). Card hover shadows extend a bit — with 3px padding they'll be mostly fine.
.empty:
```css
.empty {
margin: 0; padding: 22px 10px; text-align: center;
font-size: 12.5px; color: var(--muted); font-weight: 500;
border: 1.5px dashed var(--border); border-radius: 12px;
}
```
.card as above.
.drop-ph:
```css
.drop-ph {
height: 66px; flex: none;
border-radius: 12px;
border: 2px dashed var(--accent);
background: var(--accent-soft);
pointer-events: none;
animation: phIn .18s ease;
}
```
Wait: `height: 66px` — should ideally match the card height. Cards are roughly 70px (title 1 line + badge row). Some titles wrap to 2 lines → taller. Using a fixed 66px is fine.
Hmm, but a mismatch causes the list to jump. Acceptable.
Alternative: make the placeholder match the dragged card's height. In dragstart, store `card.offsetHeight`, then set ph.style.height. Nice touch! Let's do that: `phHeight = card.offsetHeight`. Then in placeholder creation set `ph.style.height = phHeight + 'px'`. Good, that makes it feel much better.
.badge:
```css
.badge {
display: inline-flex; align-items: center; gap: 5px;
font-size: 10.5px; font-weight: 700; letter-spacing: .03em; text-transform: uppercase;
padding: 3px 8px 3px 7px; border-radius: 999px;
}
.badge::before { content:''; width:5px;height:5px;border-radius:50%; background: currentColor; }
.badge.high { color:#b42318; background:#fee4e2; }
.badge.medium { color:#b54708; background:#fef0c7; }
.badge.low { color:#067647; background:#dcfae6; }
html[data-theme="dark"] .badge.high { color:#ff9c93; background: rgba(239,68,68,.16); }
html[data-theme="dark"] .badge.medium { color:#fbbf5c; background: rgba(245,158,11,.16); }
html[data-theme="dark"] .badge.low { color:#5ee9a8; background: rgba(16,185,129,.15); }
```
.card-title:
```css
.card-title { margin: 0 0 9px; font-size: 13.5px; line-height: 1.42; font-weight: 550; letter-spacing: -.005em; word-break: break-word; }
mark { background: rgba(250,204,21,.45); color: inherit; border-radius: 3px; padding: 0 1px; }
html[data-theme="dark"] mark { background: rgba(250,204,21,.28); }
```
.card-foot { display:flex; align-items:center; justify-content:space-between; gap:8px; }
.del:
```css
.del {
display: grid; place-items: center;
width: 26px; height: 26px; border-radius: 8px;
border: none; background: transparent; color: var(--muted);
cursor: pointer; opacity: 0; transition: .15s;
}
.card:hover .del, .card:focus-within .del { opacity: .75; }
.del:hover { opacity: 1 !important; color: #e11d48; background: rgba(225,29,72,.12); }
@media (hover: none) { .del { opacity: .6; } }
```
.card.removing { opacity: 0; transform: scale(.95); }
Need transition on card for opacity/transform — yes it has.
@keyframes popIn { from { opacity: 0; transform: scale(.96) translateY(-6px); } to { opacity:1; transform:none; } }
.card.pop { animation: popIn .3s cubic-bezier(.2,.9,.3,1.3); }
But `.card:hover { transform: translateY(-1px) }` conflicts with the animation. During animation, hover transform is overridden by the animation's transform. Fine.
Modal CSS as planned.
Now the `.icon-btn`:
```css
.icon-btn {
width: 38px; height: 38px; flex: none;
display: grid; place-items: center;
border-radius: 10px; border: 1px solid var(--border);
background: var(--panel); color: var(--muted); cursor: pointer;
transition: .18s;
}
.icon-btn:hover { color: var(--accent); border-color: var(--accent); }
```
The theme toggle transition: adding a global transition on background-color/color for major surfaces could be nice but risky. I'll add `transition: background-color .25s, border-color .25s, color .25s;` to .column, .card, .topbar? Keep it light.
Now the responsive bit at 375px:
```css
@media (max-width: 820px) {
.board { grid-template-columns: 1fr; overflow-y: auto; align-items: start; }
.column { max-height: none; }
.cards { overflow: visible; max-height: none; }
...
}
```
Wait — with `grid-template-columns: 1fr` and the board scrolling, the columns size to content. `.cards { overflow-y:auto; flex:1; min-height:0 }` inside a column with auto height — the column height is determined by content, so the list won't scroll. Fine. But `min-height: 0` with flex... it's fine.
Set `.column { min-height: 0 }` fine.
But `.board { overflow: hidden }` on desktop → on mobile override to `overflow-y: auto`. And add `padding-bottom: 20px`.
Also body `overflow: hidden` with app 100dvh: on mobile the board scrolls internally. Fine, no horizontal scrollbar.
Top bar on mobile: brand + tools wrap. At 375px, brand (logo 40 + text) takes ~200px, tools with search 240px → wraps to the next line. With flex-wrap: wrap it will wrap. Then `.tools { width: 100% }` at small widths. Good.
Let me also reduce padding at small widths: `@media (max-width: 620px) { .topbar { padding: 14px 14px 12px; } .board { padding: 0 14px 16px; gap: 12px; } }`.
Now let's double check the count element and the "each showing its card count" requirement — yes.
One more consideration for the first screenshot: the board with 6 cards, all visible. Yes.
Potential issue: `dragstart` on a card whose delete button is clicked... not an issue.
Another potential issue: `board.addEventListener('dragover', ...)` — `e.dataTransfer` may be null in some cases? No, during dragover it's set.
Also, when the drag begins, `dragId` is set in dragstart. But dragstart fires on the element; our delegation on board catches it since it bubbles. Yes, dragstart bubbles.
Let's double check `e.target.closest` when e.target is a text node — no, event targets are elements.
Now writing `highlight` and `escapeHtml`:
```js
function escapeHtml(s){
return s.replace(/[&<>"']/g, m => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[m]));
}
function highlight(title, q){
if (!q) return escapeHtml(title);
const i = title.toLowerCase().indexOf(q);
if (i < 0) return escapeHtml(title);
return escapeHtml(title.slice(0, i)) + '<mark>' + escapeHtml(title.slice(i, i + q.length)) + '</mark>' + escapeHtml(title.slice(i + q.length));
}
```
Now the modal HTML with the column select options — must match COLUMNS. I'll hardcode:
```html
<select id="colSelect">
<option value="todo">To do</option>
<option value="doing">In progress</option>
<option value="done">Done</option>
</select>
```
Or generate from COLUMNS in JS. Hardcode is fine.
Alright. Also add a "clear search" hidden attribute handling — `hidden` works on a button element with `display:none` by default UA style; but if I set `display:grid` on `.clear`, it overrides `[hidden]`. Add `[hidden]{display:none !important}` to be safe.
Let me write the final code.
One more: `overlay.hidden = false` — CSS `.overlay[hidden]{display:none}` needed since I set `display:grid`. I'll add a global `[hidden]{display:none !important}`.
Also the initial render must happen at load: call buildBoard(); render();
Let me also handle: after `render()`, if a search is active, the counts show filtered numbers. Good.
Edge case: dragging while a search filter is active — hidden cards are excluded via offsetParent check. Good.
Let me write out the final HTML.
For the segmented priority buttons, default medium selected via aria-pressed.
Let me write the modal:
```html
<div class="overlay" id="overlay" hidden>
<div class="modal" role="dialog" aria-modal="true" aria-labelledby="modalTitle">
<div class="modal-head">
<h2 id="modalTitle">New card</h2>
<button class="x" id="closeModal" type="button" aria-label="Close">×</button>
</div>
<form id="cardForm" novalidate>
<div class="field">
<label for="titleInput">Title</label>
<input id="titleInput" type="text" placeholder="e.g. Review the empty states" maxlength="90" autocomplete="off">
</div>
<div class="field">
<span class="lbl">Priority</span>
<div class="segmented" id="prioGroup" role="group" aria-label="Priority">
<button type="button" data-prio="low" aria-pressed="false">Low</button>
<button type="button" data-prio="medium" aria-pressed="true">Medium</button>
<button type="button" data-prio="high" aria-pressed="false">High</button>
</div>
</div>
<div class="field">
<label for="colSelect">Column</label>
<select id="colSelect">...</select>
</div>
<p class="err" id="err" role="alert"></p>
<div class="actions">
<button type="button" class="btn ghost" id="cancelBtn">Cancel</button>
<button type="submit" class="btn primary">Add card</button>
</div>
</form>
</div>
</div>
```
The `.err` element: reserve no height when empty; use `min-height: 16px`. Actually if empty, margin collapse... just set `.err { margin: 0 0 6px; font-size:12px; color:#e11d48; min-height: 0; }` and it takes 0 height when empty. Then the actions jump. Fine — or always reserve 16px. I'll reserve via `min-height:16px; margin: 2px 0 10px;`.
Hmm, that adds space always. Fine, it's a modal.
Actually, let me place the error right under the title input for context. I'll keep it above the actions. Fine.
CSS for modal fields:
```css
.field { margin-bottom: 14px; }
.field label, .field .lbl { display:block; font-size: 12px; font-weight: 700; color: var(--muted); letter-spacing: .04em; text-transform: uppercase; margin-bottom: 6px; }
```
Input styling.
Actions: `display:flex; gap:10px; justify-content:flex-end; margin-top: 4px;`
.btn: padding 0 16px; height 40px; border-radius 10px; font-weight 600; font-size 13.5px; cursor pointer; border: 1px solid transparent.
.btn.ghost { background: transparent; border-color: var(--border); color: var(--muted); }
.btn.ghost:hover { color: var(--text); border-color: var(--muted); }
.btn.primary { background: linear-gradient(135deg,#6366f1,#8b5cf6); color: #fff; box-shadow: 0 8px 18px -10px rgba(99,102,241,.9); }
.btn.primary:hover { filter: brightness(1.06); }
Now writing everything out. Let me be careful with template literals inside the script — no backticks conflicts.
Also note: inside the HTML `<style>`, avoid `</` issues. Fine.
One thing: the `hexA` helper for the dot box-shadow. Simpler: just use `box-shadow: 0 0 0 3px rgba(...)` with a fixed color per column, or skip the glow. I'll skip and just use a plain dot, maybe with a slight glow using color-mix. Skip it — plain dot.
Actually I'll keep a subtle ring: `style="background:${c.color}"` and CSS `.dot { box-shadow: 0 0 0 3px color-mix(in srgb, currentColor 20%, transparent) }` — no, currentColor isn't the dot color. Skip.
Let me finalize the code.
Also I should double-check the `dragover` handler runs even when hovering the `.empty` p element inside the list: `e.target.closest('.column')` works. Yes.
And `setActive(col)` toggles .drag-over on all columns.
One risk: `board.addEventListener('dragleave')` — with relatedTarget checks. Fine.
Writing now.
Also: on `dragend`, if a drop happened we already cleaned up; dragend will call cleanup again (harmless).
For the `is-dragging` body class, I'll use it to disable `.card:hover` transform: `body.is-dragging .card:hover { transform: none; }`. Minor.
Let me finalize the sizes: at 1280px wide, the board padding 20px, gap 14 → each column ~ (1280-40-28)/3 = 404px. Cards full width. Good.
Header height ~72px. Board height = 800 - 72 - 20 = 708. Columns are 708 tall. Lists scroll if needed. 6 cards won't need scrolling. Good.
Final check on `scrollIntoView` — could scroll the whole window if body scrolls; body doesn't. It may scroll `.cards` container. Fine.
Write it.
One more detail: the subtitle "Sprint 24 · 6 cards". Nice.
Let me produce the final code.