Qwen3.8 Flash
by QwenReasoningClean
Running live in a sandboxed frame. Click inside it before using the keyboard.
How it went
- Total time
- 5 min 33 s
- Cost
- $0.012
- Output tokens
- 24,442
- Spent thinking
- 15,472 tokens
- Speed
- 73.6 tok/s
- Console errors
- 0
- First token after
- 977 ms
- First visible text after
- 4 min 7 s
- Prompt tokens
- 557
- Code size
- 28.4 KB
- Finish reason
- stop
- Served by
- Alibaba
- Model id
qwen/qwen3.8-flash- Reasoning sent
model default- 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 · 1091 lines
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Kanban Board</title>
<style>
:root {
color-scheme: light;
--bg: #f4f6fb;
--surface: #ffffff;
--column: #edf0f7;
--input-bg: #ffffff;
--text: #0f172a;
--muted: #64748b;
--border: #d9e1ec;
--border-strong: #b8c6da;
--accent: #4f46e5;
--accent-hover: #4338ca;
--accent-soft: #eef2ff;
--drop-bg: #e8ecff;
--placeholder-bg: rgba(79, 70, 229, 0.09);
--ring: rgba(79, 70, 229, 0.16);
--shadow: 0 1px 2px rgba(16, 24, 40, 0.05);
--card-shadow: 0 1px 2px rgba(16, 24, 40, 0.07);
--card-shadow-hover: 0 10px 24px rgba(16, 24, 40, 0.14);
--hover: #f8fafc;
--low-bg: #dcfce7;
--low-fg: #166534;
--low-border: #86efac;
--medium-bg: #fef3c7;
--medium-fg: #92400e;
--medium-border: #fcd34d;
--high-bg: #fee2e2;
--high-fg: #991b1b;
--high-border: #fca5a5;
--danger-bg: #fee2e2;
--danger-fg: #b91c1c;
--danger-border: #fca5a5;
}
body[data-theme="dark"] {
color-scheme: dark;
--bg: #0b1120;
--surface: #162034;
--column: #111a2b;
--input-bg: #1f2b43;
--text: #e6edf6;
--muted: #94a3b8;
--border: #2d3d55;
--border-strong: #4c5e79;
--accent: #7c7cff;
--accent-hover: #959dff;
--accent-soft: #23305a;
--drop-bg: #1b2a4c;
--placeholder-bg: rgba(124, 124, 255, 0.16);
--ring: rgba(124, 124, 255, 0.28);
--shadow: 0 1px 2px rgba(0, 0, 0, 0.25);
--card-shadow: 0 2px 6px rgba(0, 0, 0, 0.28);
--card-shadow-hover: 0 12px 28px rgba(0, 0, 0, 0.36);
--hover: #1d2941;
--low-bg: #0f3d2c;
--low-fg: #a7f3d0;
--low-border: #15803d;
--medium-bg: #3d2a08;
--medium-fg: #fde68a;
--medium-border: #a16207;
--high-bg: #42121a;
--high-fg: #fecaca;
--high-border: #b91c1c;
--danger-bg: #4a1418;
--danger-fg: #fecaca;
--danger-border: #b91c1c;
}
* {
box-sizing: border-box;
}
html,
body {
min-height: 100%;
margin: 0;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
background: var(--bg);
color: var(--text);
transition: background-color 0.25s ease, color 0.25s ease;
}
button,
input,
select,
textarea {
font: inherit;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.app {
width: min(1240px, 100%);
margin: 0 auto;
padding: 24px 20px 48px;
}
.topbar {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 16px;
flex-wrap: wrap;
margin-bottom: 18px;
}
.brand h1 {
margin: 0;
font-size: clamp(1.45rem, 1.15rem + 1vw, 2rem);
line-height: 1.1;
letter-spacing: -0.03em;
}
.brand p {
margin: 4px 0 0;
color: var(--muted);
font-size: 0.92rem;
}
.toolbar {
display: flex;
align-items: center;
gap: 12px;
width: 100%;
flex-wrap: wrap;
margin-bottom: 20px;
}
.search-wrap {
position: relative;
flex: 1 1 260px;
min-width: 0;
}
.search-icon {
position: absolute;
left: 12px;
top: 50%;
transform: translateY(-50%);
width: 18px;
height: 18px;
color: var(--muted);
pointer-events: none;
}
.search-input {
width: 100%;
padding: 12px 12px 12px 40px;
border: 1px solid var(--border);
border-radius: 12px;
background: var(--input-bg);
color: var(--text);
outline: none;
box-shadow: var(--shadow);
transition: border-color 0.18s ease, box-shadow 0.18s ease, background-color 0.18s ease;
}
.search-input::placeholder {
color: var(--muted);
}
.search-input:focus {
border-color: var(--accent);
box-shadow: 0 0 0 4px var(--ring);
}
.actions {
display: flex;
align-items: center;
gap: 10px;
margin-left: auto;
flex-wrap: wrap;
}
.btn {
border: 1px solid var(--border);
background: var(--surface);
color: var(--text);
border-radius: 12px;
padding: 11px 14px;
min-height: 42px;
font-weight: 600;
cursor: pointer;
box-shadow: var(--shadow);
transition: background-color 0.18s ease, border-color 0.18s ease, color 0.18s ease, transform 0.12s ease;
}
.btn:hover {
border-color: var(--border-strong);
background: var(--hover);
}
.btn:active {
transform: translateY(1px);
}
.btn:focus-visible,
.icon-btn:focus-visible,
.search-input:focus-visible,
.field input:focus-visible,
.field select:focus-visible {
outline: none;
box-shadow: 0 0 0 4px var(--ring);
}
.btn-primary {
background: var(--accent);
border-color: var(--accent);
color: white;
}
.btn-primary:hover {
background: var(--accent-hover);
border-color: var(--accent-hover);
color: white;
}
.theme-toggle {
display: inline-flex;
align-items: center;
gap: 8px;
white-space: nowrap;
}
.theme-toggle svg {
width: 18px;
height: 18px;
stroke: currentColor;
fill: none;
stroke-width: 1.9;
stroke-linecap: round;
stroke-linejoin: round;
}
.moon-icon {
display: inline;
}
.sun-icon {
display: none;
}
body[data-theme="dark"] .moon-icon {
display: none;
}
body[data-theme="dark"] .sun-icon {
display: inline;
}
.board {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 16px;
align-items: start;
}
.column {
background: var(--column);
border: 1px solid var(--border);
border-radius: 18px;
padding: 14px;
min-height: 300px;
box-shadow: var(--shadow);
transition: background-color 0.18s ease, border-color 0.18s ease, box-shadow 0.18s ease, transform 0.18s ease;
}
.column.drag-over {
border-color: var(--accent);
background: var(--drop-bg);
box-shadow: 0 0 0 4px var(--ring), var(--shadow);
}
.column-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
margin-bottom: 14px;
}
.column-title {
margin: 0;
font-size: 0.98rem;
font-weight: 800;
display: flex;
align-items: center;
gap: 8px;
}
.dot {
width: 10px;
height: 10px;
border-radius: 50%;
flex: none;
}
.dot.todo {
background: #3b82f6;
}
.dot.in-progress {
background: #f59e0b;
}
.dot.done {
background: #22c55e;
}
.count {
background: var(--surface);
color: var(--muted);
border: 1px solid var(--border);
border-radius: 999px;
padding: 3px 10px;
font-size: 0.8rem;
font-weight: 800;
min-width: 34px;
text-align: center;
}
.card-list {
display: flex;
flex-direction: column;
gap: 10px;
min-height: 180px;
}
.card {
position: relative;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 14px;
padding: 12px;
box-shadow: var(--card-shadow);
cursor: grab;
user-select: none;
transition: background-color 0.18s ease, border-color 0.18s ease, box-shadow 0.18s ease, transform 0.16s ease, opacity 0.16s ease;
animation: cardIn 0.3s ease both;
}
@keyframes cardIn {
from {
opacity: 0;
transform: translateY(6px) scale(0.985);
}
to {
opacity: 1;
transform: none;
}
}
.card:hover {
transform: translateY(-2px);
border-color: var(--border-strong);
box-shadow: var(--card-shadow-hover);
}
.card.dragging {
opacity: 0.34;
transform: rotate(2deg) scale(0.98);
cursor: grabbing;
}
.card-content {
display: flex;
gap: 10px;
align-items: flex-start;
}
.card-main {
flex: 1 1 auto;
min-width: 0;
}
.card-title {
margin: 0 0 9px;
font-size: 0.94rem;
line-height: 1.35;
font-weight: 700;
overflow-wrap: anywhere;
}
.badge {
display: inline-block;
border-radius: 999px;
padding: 3px 8px;
font-size: 0.7rem;
font-weight: 900;
letter-spacing: 0.05em;
text-transform: uppercase;
border: 1px solid transparent;
}
.badge.low {
background: var(--low-bg);
color: var(--low-fg);
border-color: var(--low-border);
}
.badge.medium {
background: var(--medium-bg);
color: var(--medium-fg);
border-color: var(--medium-border);
}
.badge.high {
background: var(--high-bg);
color: var(--high-fg);
border-color: var(--high-border);
}
.delete {
flex: none;
width: 30px;
height: 30px;
border-radius: 10px;
border: 1px solid var(--border);
background: transparent;
color: var(--muted);
font-size: 18px;
line-height: 1;
display: grid;
place-items: center;
cursor: pointer;
opacity: 0;
transition: opacity 0.16s ease, background-color 0.16s ease, color 0.16s ease, border-color 0.16s ease;
}
.card:hover .delete,
.card:focus-within .delete {
opacity: 1;
}
.delete:hover {
background: var(--danger-bg);
color: var(--danger-fg);
border-color: var(--danger-border);
}
.placeholder {
min-height: 58px;
border: 2px dashed var(--accent);
border-radius: 14px;
background: var(--placeholder-bg);
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.12);
}
.empty {
color: var(--muted);
background: var(--surface);
border: 1px dashed var(--border);
border-radius: 14px;
padding: 16px 12px;
text-align: center;
font-size: 0.88rem;
min-height: 58px;
display: grid;
place-items: center;
}
.modal-overlay {
position: fixed;
inset: 0;
z-index: 100;
display: flex;
align-items: center;
justify-content: center;
padding: 18px;
background: rgba(15, 23, 42, 0.55);
backdrop-filter: blur(3px);
opacity: 0;
visibility: hidden;
pointer-events: none;
transition: opacity 0.2s ease, visibility 0.2s;
}
.modal-overlay.open {
opacity: 1;
visibility: visible;
pointer-events: auto;
}
.modal {
width: min(520px, 100%);
background: var(--surface);
color: var(--text);
border: 1px solid var(--border);
border-radius: 20px;
padding: 20px;
box-shadow: 0 24px 70px rgba(0, 0, 0, 0.35);
transform: translateY(12px) scale(0.985);
transition: transform 0.2s ease;
}
.modal-overlay.open .modal {
transform: none;
}
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 18px;
}
.modal-title {
margin: 0;
font-size: 1.2rem;
letter-spacing: -0.02em;
}
.icon-btn {
width: 36px;
height: 36px;
border: 1px solid var(--border);
background: var(--surface);
color: var(--muted);
border-radius: 12px;
display: grid;
place-items: center;
cursor: pointer;
transition: background-color 0.18s ease, color 0.18s ease, border-color 0.18s ease;
}
.icon-btn:hover {
background: var(--hover);
color: var(--text);
border-color: var(--border-strong);
}
.field {
display: block;
margin-bottom: 14px;
}
.field label {
display: block;
margin-bottom: 6px;
font-size: 0.85rem;
font-weight: 800;
color: var(--muted);
}
.field input,
.field select {
width: 100%;
padding: 11px 12px;
border: 1px solid var(--border);
border-radius: 12px;
background: var(--input-bg);
color: var(--text);
outline: none;
transition: border-color 0.18s ease, box-shadow 0.18s ease, background-color 0.18s ease;
}
.field input:focus,
.field select:focus {
border-color: var(--accent);
box-shadow: 0 0 0 4px var(--ring);
}
.field-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
.modal-actions {
display: flex;
justify-content: flex-end;
gap: 10px;
margin-top: 20px;
}
.hint {
margin-top: 16px;
color: var(--muted);
font-size: 0.85rem;
}
@media (max-width: 860px) {
.board {
grid-template-columns: 1fr;
}
}
@media (max-width: 520px) {
.app {
padding: 18px 14px 34px;
}
.actions {
width: 100%;
}
.actions .btn {
flex: 1 1 auto;
}
.search-wrap {
flex-basis: 100%;
}
}
@media (max-width: 390px) {
.toggle-text {
display: none;
}
.theme-toggle {
justify-content: center;
padding: 11px 12px;
}
}
</style>
</head>
<body data-theme="light">
<main class="app">
<section class="topbar" aria-label="Board header">
<div class="brand">
<h1>Team Kanban</h1>
<p>Drag cards between columns, search by title, add or delete work.</p>
</div>
</section>
<section class="toolbar" aria-label="Board controls">
<div class="search-wrap">
<svg class="search-icon" viewBox="0 0 24 24" aria-hidden="true">
<circle cx="11" cy="11" r="7"></circle>
<path d="M20 20l-3.5-3.5"></path>
</svg>
<label class="sr-only" for="searchInput">Search cards by title</label>
<input class="search-input" id="searchInput" type="search" placeholder="Search cards by title…" autocomplete="off" />
</div>
<div class="actions">
<button class="btn btn-primary" id="addCardButton" type="button">+ Add card</button>
<button class="btn theme-toggle" id="themeToggle" type="button" aria-pressed="false">
<svg class="moon-icon" viewBox="0 0 24 24" aria-hidden="true">
<path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z"></path>
</svg>
<svg class="sun-icon" viewBox="0 0 24 24" aria-hidden="true">
<circle cx="12" cy="12" r="4"></circle>
<path d="M12 2v2m0 16v2M2 12h2m16 0h2M5.6 5.6l1.4 1.4m10 10l1.4 1.4m0-12.8l-1.4 1.4m-10 10l-1.4 1.4"></path>
</svg>
<span class="toggle-text" id="themeLabel">Dark mode</span>
</button>
</div>
</section>
<section class="board" id="board" aria-label="Kanban columns">
<section class="column" data-col="todo" aria-label="To do column">
<header class="column-header">
<h2 class="column-title"><span class="dot todo" aria-hidden="true"></span>To do</h2>
<span class="count" aria-label="Card count">2</span>
</header>
<div class="card-list" aria-label="To do cards"></div>
</section>
<section class="column" data-col="in-progress" aria-label="In progress column">
<header class="column-header">
<h2 class="column-title"><span class="dot in-progress" aria-hidden="true"></span>In progress</h2>
<span class="count" aria-label="Card count">2</span>
</header>
<div class="card-list" aria-label="In progress cards"></div>
</section>
<section class="column" data-col="done" aria-label="Done column">
<header class="column-header">
<h2 class="column-title"><span class="dot done" aria-hidden="true"></span>Done</h2>
<span class="count" aria-label="Card count">2</span>
</header>
<div class="card-list" aria-label="Done cards"></div>
</section>
</section>
<p class="hint">Tip: start dragging a card and hover another column to see the drop indicator.</p>
</main>
<div class="modal-overlay" id="modalOverlay" aria-hidden="true">
<form class="modal" id="addCardForm" novalidate role="dialog" aria-modal="true" aria-labelledby="modalTitle">
<div class="modal-header">
<h2 class="modal-title" id="modalTitle">Add a card</h2>
<button class="icon-btn" id="modalCloseButton" type="button" aria-label="Close dialog">✕</button>
</div>
<label class="field">
<span>Title *</span>
<input id="titleInput" name="title" maxlength="120" placeholder="e.g. Review launch checklist" required />
</label>
<div class="field-row">
<label class="field">
<span>Priority</span>
<select id="priorityInput" name="priority">
<option value="low">Low</option>
<option value="medium" selected>Medium</option>
<option value="high">High</option>
</select>
</label>
<label class="field">
<span>Column</span>
<select id="columnInput" name="column">
<option value="todo" selected>To do</option>
<option value="in-progress">In progress</option>
<option value="done">Done</option>
</select>
</label>
</div>
<div class="modal-actions">
<button class="btn" id="modalCancelButton" type="button">Cancel</button>
<button class="btn btn-primary" type="submit">Add card</button>
</div>
</form>
</div>
<script>
const state = {
query: "",
draggedId: null,
nextId: 7,
cards: [
{ id: 1, title: "Draft Q3 product announcement", priority: "medium", col: "todo" },
{ id: 2, title: "Audit accessibility contrast ratios", priority: "low", col: "todo" },
{ id: 3, title: "Implement drag-and-drop card movement", priority: "high", col: "in-progress" },
{ id: 4, title: "Write onboarding email sequence", priority: "medium", col: "in-progress" },
{ id: 5, title: "Migrate CI pipeline to Node 22", priority: "high", col: "done" },
{ id: 6, title: "Publish design token documentation", priority: "low", col: "done" }
]
};
const board = document.getElementById("board");
const searchInput = document.getElementById("searchInput");
const addCardButton = document.getElementById("addCardButton");
const themeToggle = document.getElementById("themeToggle");
const themeLabel = document.getElementById("themeLabel");
const modalOverlay = document.getElementById("modalOverlay");
const addCardForm = document.getElementById("addCardForm");
const titleInput = document.getElementById("titleInput");
const priorityInput = document.getElementById("priorityInput");
const columnInput = document.getElementById("columnInput");
const modalCloseButton = document.getElementById("modalCloseButton");
const modalCancelButton = document.getElementById("modalCancelButton");
const placeholder = document.createElement("div");
placeholder.className = "placeholder";
placeholder.setAttribute("aria-hidden", "true");
let lastFocused = null;
function render() {
const query = state.query;
for (const column of board.querySelectorAll(".column")) {
const colId = column.dataset.col;
const list = column.querySelector(".card-list");
const countEl = column.querySelector(".count");
list.innerHTML = "";
const columnCards = state.cards.filter((card) => card.col === colId);
const visibleCards = query
? columnCards.filter((card) => card.title.toLowerCase().includes(query))
: columnCards;
countEl.textContent = String(visibleCards.length);
if (visibleCards.length === 0) {
const empty = document.createElement("div");
empty.className = "empty";
empty.textContent = query ? "No matching cards" : "Drop cards here";
list.appendChild(empty);
} else {
for (const cardData of visibleCards) {
list.appendChild(createCard(cardData));
}
}
}
}
function createCard(cardData) {
const card = document.createElement("article");
card.className = "card";
card.draggable = true;
card.dataset.id = String(cardData.id);
const content = document.createElement("div");
content.className = "card-content";
const main = document.createElement("div");
main.className = "card-main";
const title = document.createElement("h3");
title.className = "card-title";
title.textContent = cardData.title;
const badge = document.createElement("span");
badge.className = `badge ${cardData.priority}`;
badge.textContent = cardData.priority;
main.append(title, badge);
const deleteButton = document.createElement("button");
deleteButton.type = "button";
deleteButton.className = "delete";
deleteButton.dataset.action = "delete";
deleteButton.draggable = false;
deleteButton.setAttribute("aria-label", `Delete card: ${cardData.title}`);
deleteButton.textContent = "×";
content.append(main, deleteButton);
card.append(content);
return card;
}
function moveCard(id, targetColumn, beforeId) {
const fromIndex = state.cards.findIndex((card) => String(card.id) === String(id));
if (fromIndex === -1) return;
const [card] = state.cards.splice(fromIndex, 1);
card.col = targetColumn;
if (beforeId) {
const targetIndex = state.cards.findIndex((existingCard) => String(existingCard.id) === String(beforeId));
if (targetIndex !== -1) {
state.cards.splice(targetIndex, 0, card);
return;
}
}
state.cards.push(card);
}
function markOver(column) {
for (const candidate of board.querySelectorAll(".column")) {
candidate.classList.toggle("drag-over", candidate === column);
}
}
function removePlaceholder() {
if (placeholder.parentNode) {
placeholder.parentNode.removeChild(placeholder);
}
}
function cleanupDrag() {
removePlaceholder();
for (const column of board.querySelectorAll(".column")) {
column.classList.remove("drag-over");
}
for (const card of board.querySelectorAll(".card.dragging")) {
card.classList.remove("dragging");
}
state.draggedId = null;
}
function positionPlaceholder(clientY, column) {
const list = column.querySelector(".card-list");
if (!list) return;
const draggableCards = Array.from(list.children).filter(
(child) => child.classList.contains("card") && !child.classList.contains("dragging")
);
let afterCard = null;
for (const card of draggableCards) {
const rect = card.getBoundingClientRect();
if (clientY < rect.top + rect.height / 2) {
afterCard = card;
break;
}
}
if (placeholder.parentNode !== list) {
removePlaceholder();
markOver(column);
}
if (afterCard) {
list.insertBefore(placeholder, afterCard);
} else {
list.appendChild(placeholder);
}
}
function getPlaceholderBeforeId() {
if (!placeholder.parentNode) return null;
let next = placeholder.nextElementSibling;
while (next && (next.classList.contains("empty") || next.classList.contains("dragging"))) {
next = next.nextElementSibling;
}
if (next && next.classList.contains("card")) {
return next.dataset.id;
}
return null;
}
board.addEventListener("dragstart", (event) => {
const card = event.target.closest(".card");
if (!card) return;
if (event.target.closest("button")) {
event.preventDefault();
return;
}
state.draggedId = card.dataset.id;
event.dataTransfer.setData("text/plain", state.draggedId);
event.dataTransfer.effectAllowed = "move";
requestAnimationFrame(() => {
card.classList.add("dragging");
});
});
board.addEventListener("dragover", (event) => {
if (!state.draggedId) return;
const column = event.target.closest(".column");
if (!column) return;
event.preventDefault();
event.dataTransfer.dropEffect = "move";
positionPlaceholder(event.clientY, column);
});
board.addEventListener("dragleave", (event) => {
const column = event.target.closest(".column");
if (!column) return;
if (!column.contains(event.relatedTarget)) {
column.classList.remove("drag-over");
}
});
board.addEventListener("drop", (event) => {
if (!state.draggedId) return;
const column = event.target.closest(".column");
if (!column) return;
event.preventDefault();
event.dataTransfer.dropEffect = "move";
const beforeId = getPlaceholderBeforeId();
moveCard(state.draggedId, column.dataset.col, beforeId);
cleanupDrag();
render();
});
board.addEventListener("dragend", cleanupDrag);
window.addEventListener("dragend", cleanupDrag);
document.addEventListener("dragover", (event) => {
if (event.target.closest(".board") || event.target.closest("input") || event.target.closest("textarea")) {
return;
}
event.preventDefault();
});
document.addEventListener("drop", (event) => {
if (!event.target.closest(".board")) {
event.preventDefault();
}
});
board.addEventListener("click", (event) => {
const deleteButton = event.target.closest('[data-action="delete"]');
if (!deleteButton) return;
const card = deleteButton.closest(".card");
const id = card.dataset.id;
state.cards = state.cards.filter((existingCard) => String(existingCard.id) !== String(id));
render();
});
searchInput.addEventListener("input", () => {
state.query = searchInput.value.trim().toLowerCase();
render();
});
function setTheme(isDark) {
document.body.dataset.theme = isDark ? "dark" : "light";
themeToggle.setAttribute("aria-pressed", String(isDark));
themeLabel.textContent = isDark ? "Light mode" : "Dark mode";
themeToggle.setAttribute("aria-label", isDark ? "Switch to light theme" : "Switch to dark theme");
}
themeToggle.addEventListener("click", () => {
const isDark = document.body.dataset.theme === "dark";
setTheme(!isDark);
});
function openModal() {
lastFocused = document.activeElement;
addCardForm.reset();
priorityInput.value = "medium";
columnInput.value = "todo";
modalOverlay.classList.add("open");
modalOverlay.setAttribute("aria-hidden", "false");
requestAnimationFrame(() => titleInput.focus());
}
function closeModal() {
modalOverlay.classList.remove("open");
modalOverlay.setAttribute("aria-hidden", "true");
if (lastFocused && typeof lastFocused.focus === "function") {
lastFocused.focus();
}
}
addCardButton.addEventListener("click", openModal);
modalCloseButton.addEventListener("click", closeModal);
modalCancelButton.addEventListener("click", closeModal);
modalOverlay.addEventListener("click", (event) => {
if (event.target === modalOverlay) closeModal();
});
document.addEventListener("keydown", (event) => {
if (event.key === "Escape" && modalOverlay.classList.contains("open")) {
closeModal();
}
});
titleInput.addEventListener("input", () => {
titleInput.setCustomValidity("");
});
addCardForm.addEventListener("submit", (event) => {
event.preventDefault();
const title = titleInput.value.trim();
if (!title) {
titleInput.setCustomValidity("Title is required.");
titleInput.reportValidity();
titleInput.focus();
return;
}
const card = {
id: state.nextId,
title,
priority: priorityInput.value,
col: columnInput.value
};
state.nextId += 1;
state.cards.push(card);
if (state.query && !title.toLowerCase().includes(state.query)) {
searchInput.value = "";
state.query = "";
}
closeModal();
render();
});
render();
</script>
</body>
</html>Reasoning trace (15,472 tokens)
We need to build a self-contained HTML file with vanilla JS/CSS. Interactive Kanban board. Must work in sandboxed iframe, no external resources. Need a light/dark theme toggle, three columns, 6 prepopulated cards, priority badges colored, modal form for add card, delete button, drag/drop with visual drop indicator, live search filter.
Need output one fenced code block only. Ensure keyboard? Not game; doesn't need demo mode. But first screenshot looks alive. Could add subtle animations on load. Need responsive. Need no horizontal scrollbar.
We need think carefully implementation.
Design: Kanban board with header controls: title, search input, theme toggle, add card button, maybe filter by priority? Not required. Columns: To do, In progress, Done each with count. Cards: draggable, title, priority badge, delete button.
Drag and drop: Use HTML5 drag events. Each column drop zone. Visual drop indicator: highlight column when dragging over, and insertion indicator between cards maybe. Simpler: column border/glow and placeholder. Need robust: dragstart stores card id, set custom drag ghost? Could use dataTransfer.setData('text/plain', id), effectAllowed move. On dragover preventDefault, dropEffect move, add class to column and maybe insertion indicator based on afterElement. Use getAfterElement to position placeholder. Since vanilla, create a placeholder div? Need state reordering? Requirement just drag between columns, not necessarily order. But nice.
State: cards array with id, title, priority, column. render all. For search filter, re-render filtered cards. Drag/drop must work only with visible cards. We can reorder within column too.
Potential issue with re-rendering while dragging? Use dataTransfer id. On drop, update state.
Visual drop indicator: On dragover, calculate position among card elements (excluding dragged card maybe) and insert placeholder or indicator line. To be robust, use CSS class drop-target on column, and insertion marker. Implementation:
- Each column .drop-zone[data-col]
- Inside .cards container.
- On dragenter? Need handle child elements. Use event delegation on board: pointer dragover on column, preventDefault.
- Determine dragged id from dragEvent? On dragstart set state.draggedId.
- In dragover, target column. Add .drag-over to column. Remove from others. Determine after element: find visible cards in column (not dragged card) and use mouseY. Insert placeholder before after element or at end. Placeholder element can be a div with class placeholder and height. But if re-rendering, placeholder lost? We can create placeholder DOM directly, not in state, and remove on dragleave/drop/dragend.
- Need if placeholder already in correct position. Could compute insertionIndex and insert placeholder element into cards before child or append.
- On drop: move dragged card to column at insertion index based on placeholder position. Need state update preserving order. If filtering active, insertion indices among visible cards may not reflect full state. Could update simply to end of column; requirement doesn't need order precision. But with indicator, maybe use visible insertion. We can ignore order for filtered search? Better: update to target column and insert before the card that placeholder is before, if any. If placeholder before none append. In state array, remove dragged, then find index of before card id in cards array (not visible? state order), insert there. If search filters, if inserting before a visible card works. Good.
Delete button: stop propagation on drag? Cards draggable true; delete button pointer-events? Click works. Need not initiate drag from button. We can set draggable false on button and stopPropagation mousedown/dragstart? Simpler card has draggable="true", button click. During click on button, drag might start if mousedown and move? We can stopPropagation on dragstart if target is button? But dragstart bubbles. Add event delegation for delete button click. Cards are draggable but buttons not. Good.
Modal: form title input required, priority select low/medium/high, column select? Could default to active column or To do. If Add Card clicked from header, modal with fields: Title, Priority, Column. Title required. Validation HTML. On submit, add card. Use custom modal overlay, focus input, close button, Escape, click backdrop. No native dialogs because alert blocked? Form submit with required works. Need no external fonts.
Theme toggle: toggle class .dark on root/body. Use CSS variables. Maybe initial based on prefers-color-scheme? Could set default light/dark? Need screenshot alive; light maybe. But requirement toggle. Could start light. Button shows sun/moon emoji? No external, unicode. Could use text "Dark"/"Light".
Counts update after filtering? Requirement each showing its card count. Should count total cards per column or filtered visible? Usually count visible? Prepopulated count. If search filters, maybe count filtered? Could show number of cards currently visible to make live, but maybe count total? "each showing its card count" likely column count. Use visible cards count? Could be confusing. Maybe badge shows total cards in column even when filtering? But count next to column should reflect rendered cards. Requirement search filters cards live. If counts reflect visible, okay. But with no search it's total. Let's show filtered card count, maybe with total small? To be safe, count = number of cards currently displayed in column (visible). Could use aria. But if user deletes, count changes.
Realistic cards: To do: "Draft Q3 marketing brief" medium, "Audit accessibility contrast" low. In progress: "Implement drag-and-drop engine" high, "Write onboarding copy" medium. Done: "Set up component tokens" low, "Migrate CI pipeline" high. Spread: To do 2, In progress 2, Done 2.
Need first paint maybe animate cards in. Use CSS animation fade/slide. Avoid too much.
Accessibility: aria labels, buttons, modal role dialog. Drag maybe keyboard? Not required.
No horizontal scrollbar: board responsive: on wide grid 3 columns, narrow stack. Use CSS grid minmax? .board { display:grid; grid-template-columns: repeat(3,minmax(0,1fr)); gap; } on small -> 1 column. Ensure max width 1280.
Theme variables:
:root { --bg, --surface, --text, --muted, --border, --accent, --shadow, --radius }
.dark overrides.
Colored badges: priority-low green/teal, medium amber, high red. Light/dark variants.
Drop indicator: Use .placeholder { height:72px; border:2px dashed var(--accent); border-radius: ...; background: color-mix? No color-mix maybe supported latest Chrome. Use rgba via variables? Can define --placeholder-bg. For dark/light. Maybe use box-shadow inset.
Column drag-over: outline/glow: .drop-zone.drag-over { border-color: var(--accent); background: color-mix(...). } Could avoid color-mix? Use CSS variables with rgba: --drop-bg. But theme. Could use filter/brightness? Simple: .column.drag-over { background: var(--drop-col); box-shadow: ...}. Define variables.
Search live: input event render. Debounce? Not needed.
Rendering: function createCard(card): article.card with data-id, draggable. Inside badge with priority. Title maybe div. Delete button with aria-label and symbol ×. Card priority badge class low/med/high.
State:
const columns = [ {id:'todo',name:'To do'}, {id:'inprogress', name:'In progress'}, {id:'done', name:'Done'}];
let cards = [...]; let nextId=7;
let searchTerm='', theme='light', draggedId=null, placeholder=null.
Render: container.innerHTML='' for each .cards. But if placeholder currently DOM, re-render removes. During dragover we won't call render. Good. Count updates. Filter by title (case-insensitive substring). Sort by state order? Cards array order can define position within columns. We'll maintain order.
Drag events: Use container.addEventListener('dragstart', e => { const card=e.target.closest('.card'); if(!card || e.target.closest('button')) return; draggedId=card.dataset.id; card.classList.add('dragging'); e.dataTransfer.setData('text/plain', draggedId); e.dataTransfer.effectAllowed='move'; maybe setDragImage? Use default. Hide? If we set opacity, drag image captured before class? Need timeout. Could avoid. Add dragging class for visual.});
On document dragend: remove dragging class from all, remove all drag-over and placeholder, draggedId=null.
Columns: attach dragenter? We can delegate on board. Need ensure dragover on column children calls preventDefault. Add event listeners to each column element:
column.addEventListener('dragenter', e => { if (draggedId) { e.preventDefault(); column.classList.add('drag-over'); showPlaceholder(e, column); } });
column.addEventListener('dragover', e => { if (!draggedId) return; e.preventDefault(); e.dataTransfer.dropEffect='move'; column.classList.add('drag-over'); positionPlaceholder(e, column); });
column.addEventListener('dragleave', e => { if (!column.contains(e.relatedTarget)) { column.classList.remove('drag-over'); removePlaceholder? Maybe if leaving column entirely. } });
column.addEventListener('drop', e => { e.preventDefault(); if (!draggedId) return; const targetColumn=column.dataset.col; insertCard(draggedId, targetColumn); cleanup; });
But dragover on child cards bubbles to column; ok. Need positionPlaceholder uses e.clientY relative. Need cards in column container. But if column empty, placeholder appended. Need account dragged card hidden? dragged card class .dragging maybe still occupies? Should placeholder insertion avoid dragging card? On dragstart, dragged card remains in original column; we should not move state until drop. It might remain and affect afterElement. Use .dragging { opacity:0.3 } and in positionPlaceholder exclude .dragging. If dragging from same column and we insert placeholder before/after, drop will update state maybe move same card order. Good.
getAfterElement: const cards = [...column.querySelector('.card-list').children].filter(el => !el.classList.contains('placeholder') && !el.classList.contains('dragging') && el.classList.contains('card')); Then loop, if e.clientY < mid (rect.top + rect.height/2) return element else null. Need if filtering active, only visible cards.
showPlaceholder: ensure placeholder element. const list = column.querySelector('.card-list'); if placeholder.parentNode === list, adjust. If no placeholder create. Remove existing. Insert before afterElement or append. Add class to column. On drop, find placeholder's next sibling card? placeholder.nextElementSibling may be card or null. But if we inserted before afterElement, next sibling is afterElement. If append, null. But if placeholder inside list with non-card? only cards. Could compute targetId = placeholder.nextElementSibling && placeholder.nextElementSibling.classList.contains('card') ? dataset.id : null. Then move card.
If search active, placeholder inserted among visible cards. If targetId is null append to end of state in column? Need insertCard: remove dragged from cards array. Determine index: if targetId: target card index in cards array; insert before. But if target card in different column? It won't because placeholder only in target column. Then assign card.col=targetColumn. If targetId null: find last index of targetColumn cards and insert after? Could just push to end of array; render groups by column, so order by array index. To keep within column after others, push end is okay. But if there are cards after in array for other columns, render filtered by column so order in array matters only within column. Push end works. For before target, insert before target card. Need maintain dragged card's object. Code:
function moveCard(id, columnId, beforeId=null) { const from = cards.findIndex(c=>c.id===id); if(from<0) return; const [card] = cards.splice(from,1); card.col=columnId; if(beforeId) { const targetIndex = cards.findIndex(c=>c.id===beforeId); if(targetIndex>=0){ cards.splice(targetIndex,0,card); } else cards.push(card); } else { cards.push(card); } render(); }
But if dropping into empty target, beforeId null push. Good.
Need cleanup after drop: remove placeholder, classes.
Potential drag from placeholder? No.
Modal: Use <dialog>? HTML dialog has showModal, but sandbox? Works. But custom overlay better. Avoid native dialog complexities? Could use div. Need focus trap? Simple.
Header: app container with top bar. Use title "Kanban". Search input with label. Add card button. Theme toggle. Maybe quick filters? Not needed.
Visual polish: Cards with subtle shadow, border, hover lift. Column headers with colored dots. Empty state "No cards match" if filtered. Counts in pill.
Need no console errors: careful query selectors, e.dataTransfer? drag events maybe. Use try?
No storage APIs.
Could include priority badge text. Use uppercase.
Maybe "card count" updates with filtered? Let's decide visible count. If search no match, count 0. But prepopulated no search, counts total. Fine. Could show badge count with filtered.
Need responsive down 375: header flex wrap, board stack. Cards title wrap, no horizontal. Use overflow-wrap break-word.
Could add subtle drag handle icon? Not required. Could use whole card. But text selection? user-select none on cards.
Need ensure click on delete button doesn't drag. In dragstart, if (e.target.closest('button')) { e.preventDefault(); return; } But if card is draggable, dragging from button might still initiate? Actually dragstart event target could be card if button not draggable? We set button draggable="false". Need maybe button pointerdown stopPropagation to prevent card click? Not. But delete button click works. If user mousedown on button and moves, browser may still start drag? Since button not draggable, but parent draggable may cause drag? If mousedown on child of draggable, drag can start? For buttons, usually draggable if ancestor? It might. We can set on card dragstart: if (e.target.tagName === 'BUTTON' || e.target.closest('button')) { e.preventDefault(); return; }. That prevents drag if starting on button. But dragstart target may be button, prevent stops. Good. Also button mousedown stopPropagation? Maybe e.stopPropagation(). But dragstart uses bubbling from button. If stopPropagation on mousedown not dragstart. We can add click handler.
Alternative: set card drag handle? But whole card draggable better. To avoid accidental drag when clicking delete, set button.draggable=false; in pointerdown on button: e.stopPropagation(); might prevent drag? Drag is initiated on mousedown, stopPropagation may prevent card's mousedown? But card drag is native based on element, not event? If button inside draggable element, dragging button may not start? I think draggable on ancestor can make children draggable. To prevent, add e.preventDefault? But click? For pointerdown, set e.preventDefault maybe kills click? Not sure. Use CSS button not draggable and in dragstart check target.
Need set dragImage? Could set a custom drag ghost using element: e.dataTransfer.setDragImage(card, x,y). Default works. If we add opacity during dragstart, default drag image may capture original. Fine.
Dragleave complexity: If relatedTarget inside same column, not remove. Good. But if leaving to child that's not contained? ok.
Search filtering and drag: If drag hidden due filter? Card can be dragged only visible. If search filters out dragged card? No because dragging visible. During dragover we don't re-render. Drop re-render clears search? Search state persists.
Modal add: Should append to selected column. Use current state searchTerm? Maybe after add, if search term doesn't match new card, it won't be visible. Could optionally clear search if new title doesn't match? Not required. But to see added card, perhaps if search active and doesn't match, card invisible; not ideal. Could keep search; user sees filter. Or clear search on add? Better not? Maybe after adding, if search term nonempty and new title doesn't include it, automatically clear? Could be surprising. But for demo, user likely add while no search. Fine.
Add card button could also have plus icon. Use <svg> inline? Allowed. We can use text "+ New card". Theme button sun/moon maybe emoji. No external. Could use inline SVG for clean. Need no network. SVG allowed? It's in HTML. Could use Unicode: ☀️, 🌙. Emoji okay. But screenshot maybe color. Use simple.
Need CSS variables for dark. Use prefers maybe? But requirement toggle. Initial: light. Theme toggle updates aria-pressed.
Could implement CSS only for light/dark via body[data-theme="dark"]. Good.
Detailed CSS:
* { box-sizing:border-box; }
html,body { margin:0; padding:0; min-height:100%; }
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; background: var(--bg); color: var(--text); }
.app { max-width:1240px; margin:0 auto; padding:24px 20px; }
.toolbar { display:flex; gap:12px; align-items:center; flex-wrap:wrap; justify-content:space-between; margin-bottom:20px; }
.search { position:relative; flex:1; min-width:200px; }
input[type="search"] { width:100%; padding:11px 12px 11px 38px; border-radius:10px; border:1px solid var(--border); background:var(--surface); color:var(--text); outline:none; }
input:focus { border-color:var(--accent); box-shadow:0 0 0 3px var(--ring); }
.search-icon { position:absolute; left:12px; top:50%; transform: translateY(-50%); color:var(--muted); }
.actions { display:flex; gap:10px; }
button { font: inherit; cursor:pointer; }
.btn { border:1px solid var(--border); background:var(--surface); color:var(--text); padding:10px 14px; border-radius:10px; }
.btn:hover { background:var(--hover); border-color:var(--border-strong); }
.btn-primary { background: var(--accent); border-color: var(--accent); color:white; font-weight:600; }
.btn-primary:hover { background: var(--accent-hover); }
.theme-toggle { min-width: 44px; display:inline-flex; align-items:center; gap:8px; }
.board { display:grid; grid-template-columns: repeat(3, minmax(0,1fr)); gap:16px; align-items:start; }
.column { background: var(--column); border:1px solid var(--border); border-radius:16px; padding:14px; min-height:260px; box-shadow: var(--shadow); transition: background .2s, border-color .2s, transform .2s; }
.column.drag-over { border-color: var(--accent); background: var(--accent-soft); box-shadow: 0 0 0 3px var(--ring); }
.column-header { display:flex; align-items:center; justify-content:space-between; margin-bottom:12px; gap:8px; }
.column-title { font-weight:700; display:flex; align-items:center; gap:8px; }
.dot { width:10px;height:10px;border-radius:50%; }
.dot.todo { background:#3b82f6 } etc.
.count { background:var(--surface); border:1px solid var(--border); color:var(--muted); padding:2px 9px; border-radius:999px; font-size:12px; font-weight:600; }
.card-list { display:flex; flex-direction:column; gap:10px; min-height: 140px; }
.card { background:var(--surface); border:1px solid var(--border); border-radius:12px; padding:12px; box-shadow:var(--card-shadow); cursor:grab; transition: transform .15s, box-shadow .15s, opacity .15s; position:relative; user-select:none; }
.card:hover { transform: translateY(-1px); box-shadow: var(--card-shadow-hover); border-color: var(--border-strong); }
.card.dragging { opacity:.4; transform: rotate(2deg) scale(.98); cursor:grabbing; }
.card-content { display:flex; gap:10px; align-items:flex-start; }
.card-main { flex:1; min-width:0; }
.card-title { overflow-wrap:anywhere; font-weight:600; line-height:1.3; margin:0 0 8px; }
.badge { display:inline-block; font-size:11px; font-weight:700; text-transform:uppercase; letter-spacing:.04em; padding:3px 8px; border-radius:999px; border:1px solid transparent; }
.badge.low { background:var(--low-bg); color:var(--low-fg); border-color: var(--low-border); }
...
.delete { flex-shrink:0; width:28px;height:28px;border-radius:8px; border:1px solid var(--border); background:transparent; color:var(--muted); font-size:16px; line-height:1; display:grid; place-items:center; opacity:0; transition: opacity .15s, background .15s, color .15s; }
.card:hover .delete, .delete:focus { opacity:1; }
.delete:hover { background:var(--danger-bg); color:var(--danger-fg); border-color:var(--danger-border); }
.empty { color:var(--muted); font-size:13px; padding:16px; border:1px dashed var(--border); border-radius:12px; text-align:center; }
.placeholder { border:2px dashed var(--accent); background: var(--placeholder-bg); border-radius:12px; min-height:54px; }
Modal:
.modal-overlay { position:fixed; inset:0; background: rgba(15,23,42,.5); backdrop-filter: blur(2px); display:flex; align-items:center; justify-content:center; padding:16px; z-index:50; opacity:0; pointer-events:none; transition:opacity .18s; }
.modal-overlay.open { opacity:1; pointer-events:auto; }
.modal { background:var(--surface); color:var(--text); width:min(460px,100%); border-radius:16px; border:1px solid var(--border); box-shadow: 0 20px 60px rgba(0,0,0,.35); padding:20px; transform: translateY(8px) scale(.98); transition: transform .18s; }
.modal-overlay.open .modal { transform:none; }
.modal-header { display:flex; justify-content:space-between; align-items:center; margin-bottom:16px; }
.field { display:block; margin-bottom:14px; }
.field label { display:block; font-size:13px; font-weight:600; color:var(--muted); margin-bottom:6px; }
.field input, .field select { width:100%; padding:10px 12px; border-radius:10px; border:1px solid var(--border); background:var(--input-bg); color:var(--text); }
.field input:focus ...
.modal-actions { display:flex; gap:10px; justify-content:flex-end; }
Required indicator: label title span? Use input required and validation.
Close button: .icon-btn.
Theme variables:
:root {
--bg:#f6f7fb; --surface:#fff; --column:#eef1f6; --input-bg:#fff; --text:#0f172a; --muted:#64748b; --border:#d8e0ea; --border-strong:#b6c2d3; --accent:#4f46e5; --accent-hover:#4338ca; --ring: rgba(79,70,229,.18); --shadow: 0 1px 2px rgba(16,24,40,.04); --card-shadow: 0 1px 2px rgba(16,24,40,.06); --card-shadow-hover:0 8px 18px rgba(16,24,40,.12); --hover:#f8fafc;
--accent-soft:#eef2ff; --placeholder-bg: rgba(79,70,229,.08);
--low-bg:#dcfce7; --low-fg:#166534; --low-border:#86efac; --med-bg:#fef3c7; --med-fg:#92400e; --med-border:#fcd34d; --high-bg:#fee2e2; --high-fg:#991b1b; --high-border:#fca5a5; --danger-bg:#fee2e2; --danger-fg:#b91c1c; --danger-border:#fca5a5;
}
body.dark { --bg:#0f172a; --surface:#172033; --column:#131c2d; --input-bg:#1e293b; --text:#e2e8f0; --muted:#94a3b8; --border:#2c3a4f; --border-strong:#475569; --accent:#6366f1; --accent-hover:#818cf8; --ring: rgba(99,102,241,.35); --shadow:...; --hover:#1f2a3c; --accent-soft: #1c2740?; --placeholder-bg: rgba(99,102,241,.15);
--low-bg:#064e3b; --low-fg:#a7f3d0; --low-border:#047857; --med-bg:#78350f; --med-fg:#fde68a; --med-border:#a16207; --high-bg:#7f1d1d; --high-fg:#fecaca; --high-border:#b91c1c; --danger-bg:#7f1d1d; --danger-fg:#fecaca; --danger-border:#b91c1c; }
Need color contrast okay. For dark, column background darker than card? surface #172033, column #10192a maybe.
Maybe set html color-scheme. In JS toggle body.dataset.theme? CSS body.dark or body[data-theme=dark]. Use body.dataset.theme = 'dark'. Then CSS body[data-theme="dark"]. Initial light by no attribute? Use html/body variables. If set dataset theme dark only, body variables apply to body children? CSS variables inherit from body if set on body, but :root variables used outside? All elements inside body. Modal fixed is child of body. Good. Use body[data-theme="dark"] { --...}.
Maybe transition on body for theme. But CSS variable changes abrupt; okay.
JS implementation:
const state = { cards: [...], query:'', draggedId:null };
const board = document.querySelector('.board');
const cardCountEls etc.
function render() { ... }
Need columns query: const columnData = [{ id:'todo', title:'To do', dotClass:'todo'}, ...];
Could render columns HTML once and then fill. Board innerHTML. Column structure:
<section class="column" data-col="todo" aria-label="To do column">
<header class="column-header"><h2 class="column-title"><span class="dot todo"></span>To do</h2><span class="count" data-count>0</span></header>
<div class="card-list" data-card-list></div>
</section>
Then renderCards.
Card HTML:
<article class="card" draggable="true" data-id="1">
<div class="card-content">
<div class="card-main">
<h3 class="card-title">...</h3>
<span class="badge low">low</span>
</div>
<button class="delete" data-action="delete" aria-label="Delete card">✕</button>
</div>
</article>
Need sanitize? Titles from input inserted into textContent not innerHTML to avoid XSS. Build DOM.
Use for each card. Title: p? h3? fine. Use span badge with textContent uppercase priority. Badge class badge priority.toLowerCase(). Could low/medium/high. For priority medium class .medium.
Delete event: board.addEventListener('click', e => { const del = e.target.closest('[data-action=delete]'); if (!del) return; const id = del.closest('.card').dataset.id; cards = cards.filter(c=>c.id!==id); render(); });
Need ensure delete not considered drag? We set button draggable false. In card dragstart: if e.target.closest('button') return preventDefault.
Drag events binding: After columns rendered, add listeners? We can use delegation on board for dragstart/dragend. But dragover/drop need per-column. Could add listeners to .column when board ready. Since columns static, add once. Or use board.addEventListener('dragover') with e.target.closest('.column'). But dragover event must preventDefault on potential drop target. Delegation works: if column found, e.preventDefault(); handle. dragleave: check relatedTarget closest. drop similarly. Let's use delegation for all to simplify.
Board element:
board.addEventListener('dragstart', e => {
const card = e.target.closest('.card');
if (!card) return;
if (e.target.closest('button')) { e.preventDefault(); return; }
const id = card.dataset.id;
state.draggedId = id;
e.dataTransfer.setData('text/plain', id);
e.dataTransfer.effectAllowed='move';
requestAnimationFrame(() => card.classList.add('dragging'));
});
board.addEventListener('dragend', e => {
const card = e.target.closest('.card'); if(card) card.classList.remove('dragging');
cleanup();
});
board.addEventListener('dragover', e => {
if (!state.draggedId) return;
const column = e.target.closest('.column');
if (!column) return;
e.preventDefault();
e.dataTransfer.dropEffect='move';
setColumnOver(column);
movePlaceholder(e.clientY, column);
});
board.addEventListener('dragenter', e => {
if (!state.draggedId) return;
const column = e.target.closest('.column');
if (!column) return;
e.preventDefault();
setColumnOver(column);
});
board.addEventListener('dragleave', e => {
const column = e.target.closest('.column');
if (!column) return;
if (!column.contains(e.relatedTarget)) { column.classList.remove('drag-over'); maybe if current placeholder parent column, remove? }
});
board.addEventListener('drop', e => {
const column = e.target.closest('.column'); if (!column || !state.draggedId) return; e.preventDefault(); e.dataTransfer.dropEffect='move'; const beforeId = getPlaceholderBeforeId(); moveCard(state.draggedId, column.dataset.col, beforeId); cleanup(); render();
});
document.addEventListener('drop', e => e.preventDefault())? To prevent browser opening file if drop outside? Maybe add document.addEventListener('dragover', e=>e.preventDefault())? But can interfere? We'll add board only. If drop outside board, browser might navigate. Add document dragover preventDefault for all? For safe. But if other elements? Use document.addEventListener('dragover', e => e.preventDefault()); and drop prevent default. This ensures no navigation. But board's dragover also. Fine.
Placeholder management:
let placeholderEl = null;
function ensurePlaceholder() { if(!placeholderEl) { placeholderEl=document.createElement('div'); placeholderEl.className='placeholder'; placeholderEl.setAttribute('aria-hidden','true'); } return placeholderEl; }
function removePlaceholder(){ if(placeholderEl && placeholderEl.parentNode) placeholderEl.parentNode.removeChild(placeholderEl); }
function movePlaceholder(clientY, column) {
const list = column.querySelector('.card-list');
if (!list) return;
if (placeholderEl && placeholderEl.parentNode !== list) removePlaceholder();
if (!placeholderEl) ensurePlaceholder();
const cards = Array.from(list.children).filter(el => el.classList.contains('card') && !el.classList.contains('dragging') && !el.classList.contains('placeholder'));
let after=null;
for (const c of cards) { const rect=c.getBoundingClientRect(); if (clientY < rect.top + rect.height/2) { after=c; break; } }
if (after) list.insertBefore(placeholderEl, after); else list.appendChild(placeholderEl);
setColumnOver(column);
}
function setColumnOver(column) { board.querySelectorAll('.column.drag-over').forEach(c=>{ if(c!==column) c.classList.remove('drag-over'); }); column.classList.add('drag-over'); }
function getPlaceholderBeforeId(){ if(!placeholderEl) return null; let next=placeholderEl.nextElementSibling; while(next && !next.classList.contains('card')) next=next.nextElementSibling; if(next && !next.classList.contains('dragging')) return next.dataset.id; // if next dragging? placeholder might before dragged card? Could skip dragged card? If next dragging, maybe find next after? But placeholder is visible. We inserted excluding dragged. If next is dragged, maybe not. Use while next && next.classList.contains('dragging') next=next.nextElementSibling? But if placeholder is inserted after dragged? We excluded dragged so placeholder won't be adjacent? Could be if dragged card still at list. If after null and list empty, no. If placeholder before after, after not dragging. But if user moves placeholder after? get after element might include placeholder? We exclude. Could return null if after is dragged? If target index before dragged card? Not relevant. Simpler: if next && !next.classList.contains('dragging') return next.dataset.id; else null. If beforeId null append.
}
function cleanup(){ removePlaceholder(); board.querySelectorAll('.drag-over').forEach(el=>el.classList.remove('drag-over')); const dragged = board.querySelector('.card.dragging'); if(dragged) dragged.classList.remove('dragging'); state.draggedId=null; }
Issue: On dragend after drop, cleanup removes placeholder. But drop uses beforeId before cleanup. Good.
Search: input id="search" event: state.query = value.trim().toLowerCase(); render();
Render counts: for each column: filter cards by col and visible = q empty or title includes q. Count badge textContent = visible.length. Append cards or empty message if visible.length==0 and query? Also if column truly empty, show "No cards" placeholder. Use if column has no cards total: empty "Drop cards here"; if filtered no cards: "No matching cards". Could be nice.
Maybe card priority filter? Not needed. But could include quick filter chips? Avoid overcomplicating.
Modal: HTML in body:
<div id="modalOverlay" class="modal-overlay" role="dialog" aria-modal="true" aria-labelledby="modalTitle" aria-hidden="true">
<form id="addForm" class="modal" novalidate? But required validation. Use required. autocomplete off.
header: h2, close button.
fields: Title input name title maxlength maybe 120 required; select priority default medium; select column default todo.
actions: Cancel, Add card.
</form>
</div>
Need prevent submission: form.addEventListener('submit', e=> { e.preventDefault(); if (!form.checkValidity()) { form.reportValidity(); return; } const title = input.value.trim(); if (!title) { reportValidity; return; } add; closeModal; });
But if novalidate not set, browser will validate required on submit, no invalid event. In sandbox form validation might show message; okay. Could use required input, type submit. On submit, because required missing, browser blocks submit and shows validation message. If user presses submit, invalid message maybe okay. But if title whitespace? We need check. Add required attribute and maybe trim in JS. For whitespace-only, browser considers nonempty; we can if (!title) { input.setCustomValidity? } Could just not use novalidate, then set custom? Simpler: In submit, e.preventDefault(); const title = ...; if (!title) { titleInput.focus(); titleInput.setCustomValidity('Please enter a title'); } else { ... } But customValidity persists. Need input event clear: titleInput.addEventListener('input', ()=> titleInput.setCustomValidity('')).
No alert.
Open modal: addBtn click. Set defaultColumn maybe todo. Focus title. aria-hidden false, class open. Close: Escape key listener when open, backdrop click (if target overlay). Need not focus trap but basic. Maybe store last focused.
Add card: state.cards.push({id: state.nextId++, title, priority, col}); closeModal; render. If search active, maybe if title doesn't match, clear? Could optionally if state.query and !title.toLowerCase().includes(state.query) { searchInput.value=''; state.query=''; } to show it? Might be beneficial. But not required. Could leave.
Theme toggle: button id="themeToggle". On click: const dark = document.body.dataset.theme==='dark'; document.body.dataset.theme = dark?'light':'dark'; button.setAttribute('aria-pressed', String(!dark)); update label? Use text node. Could use innerHTML icons? Avoid innerHTML? It's static. Use button with span sun/moon. We can toggle text: button.innerHTML = dark? 'Light mode' : 'Dark mode'? But innerHTML okay no user data. Or set two spans with CSS display. Better: <button><svg id="sun">... </svg><svg id="moon"></svg></button>. CSS body[data-theme=dark] #sun { display none } etc.
Use inline SVG for icons (search, plus, sun, moon, close). Need ensure valid.
Search icon maybe not necessary.
Need keyboard support? Not required but good: Delete button focus, Enter. Cards draggable with mouse only. Could implement keyboard move with arrow keys? Not required; don't overdo.
Demo self-playing? Task is board, not game. Requirements no. First screenshot static okay. But could add subtle entrance animation to show alive. Cards animate in from fade. Add keyframes. Also maybe column count update. Good.
Potential bug: requestAnimationFrame adding dragging after dragstart. On dragend cleanup removes. Good.
Need no errors when dataTransfer null? In dragend maybe e.dataTransfer not available? Use optional. In dragstart, e.dataTransfer exists. Good.
Board delegation: document.addEventListener('dragover', e=>e.preventDefault()) to allow drop? Actually if prevent default on all, no browser navigation. But it may interfere with form? It's fine. Could add drop prevent default. But dragover on document will call board dragover too? Event bubbles, okay. If document handler runs after board? Board target closer? Event propagation order: capture? We attach board and document. document is ancestor, bubble order from target up: board then document. Good. Could only prevent default on board. For outside, drop may navigate to dropped file. Add document prevent default.
Need careful with placeholder and re-render during drag? We don't re-render until drop. During drag, placeholder is in DOM. But if search or theme? No.
Potential issue: HTML5 drag events on .card with children. In dragstart, if e.target is svg inside card? e.target could be title span or card. closest .card works. If e.target is button or svg inside button? For delete button, we prevent.
Delete button click: If drag was active? Not.
Maybe card dragging and deletion: If delete button opacity 0, focus keyboard reveal.
No horizontal scrollbar: use body overflow-x hidden? Could set. But no need. Use max-width.
Count with filter: Use count element maybe updates to number visible. Good.
Need realistic cards with priority medium: Use "medium". Badge class medium.
Let's write final HTML. Ensure no external resources.
Double-check syntax.
HTML skeleton:
<!doctype html>
<html lang="en">
<head> meta charset viewport. title. style. </head>
<body>
<main class="app"> ... modal overlay after main.
<script>...</script>
</body></html>
CSS details:
.card-list: min-height maybe 180. If empty placeholder? Use empty div.
Entrance animation:
@keyframes cardIn { from { opacity:0; transform: translateY(6px) scale(.98); } to { opacity:1; transform:none; } }
.card { animation: cardIn .32s ease both; }
Maybe when render, all cards animate, after theme toggle? Theme doesn't re-render. Drag not re-render. Search render triggers animation each keystroke maybe flicker. Could add prefers-reduced-motion and only initial? If render called on search, animation every time may be okay but maybe flicker. Could use animation only first render? But simpler: no animation or subtle transition? To keep alive, animate columns on load? Cards in search re-render could animate too much. Use .card { animation: cardIn .24s ease both; } It's fine.
If re-render on drop, animation too. okay.
Theme transition: body, .column, .card { transition: background-color .25s, color .25s, border-color .25s; }.
Maybe use data-theme on html? If CSS body[data-theme] but initial not set. Use document.body.dataset.theme='light' in JS. CSS: body[data-theme="dark"] { ... }. :root default light. Good.
Buttons: reset default.
Search input: type=search, id=searchInput. label sr-only? Use .search-icon.
Add form select column: Need options generated? Static: To do, In progress, Done. Could values todo etc.
Form:
<form id="addForm" class="modal" role="dialog" aria-modal="true" aria-labelledby="modalTitle">
<div class="modal-header"><h2 id="modalTitle">Add a card</h2><button type="button" class="icon-btn" id="modalClose" aria-label="Close dialog">✕</button></div>
<div class="field"><label for="titleInput">Title <span class="req">*</span></label><input id="titleInput" name="title" maxlength="120" required placeholder="e.g. Review sprint retro actions"></div>
<div class="field-row"> two fields: priority select, column select </div>
<div class="modal-actions"><button type="button" class="btn" id="modalCancel">Cancel</button><button type="submit" class="btn btn-primary">Add card</button></div>
</form>
Need overlay wrapper: <div id="modalOverlay" class="modal-overlay" aria-hidden="true">. Form inside. Click overlay close.
CSS: .modal-overlay uses aria-hidden. open.
JS: function openModal() { overlay.classList.add('open'); overlay.setAttribute('aria-hidden','false'); setTimeout(()=>titleInput.focus(),50); lastFocused=document.activeElement; } close: remove, set aria-hidden true, restore focus. But setTimeout okay. Need no window.open. Good.
Document keydown: if modal open and Escape close. If typing in search and Enter? not.
Form submit:
addForm.addEventListener('submit', (e) => {
e.preventDefault();
const title = titleInput.value.trim();
if (!title) { titleInput.focus(); titleInput.setCustomValidity('Title is required'); return; }
titleInput.setCustomValidity('');
const priority=priorityInput.value; const column=columnInput.value;
state.cards.push({id: state.nextId++, title, priority, col: column});
// if search active and new card doesn't match, clear? Maybe not. But to make added visible, clear query if no match? Could be user expects to see. Add: if (state.query && !title.toLowerCase().includes(state.query)) { searchInput.value=''; state.query=''; } This can hide other filters but shows new. Reasonable. Or always clear? no.
closeModal(); render();
});
titleInput.addEventListener('input', ()=> titleInput.setCustomValidity(''));
titleInput.addEventListener('invalid', e=> { if (e.target.value.trim()==='') e.target.setCustomValidity('Please enter a card title'); else e.target.setCustomValidity(''); }); Not needed.
Maybe required + novalidate? If use required, browser validation before submit event. We prevent default anyway. If missing, submit event won't fire. Then custom message won't. We need catch invalid event or use novalidate and validate manually. Better set novalidate on form and manually reportValidity? Use <form id="addForm" class="modal" novalidate>. Then submit event always fires. JS validates. Good. Use novalidate to control. So form novalidate. Then checkValidity/report? We'll manual.
CSS for .req red? Not necessary.
Priority select default medium. Column select default todo.
Could add "Clear search" button? not required.
Potential issue with board.addEventListener('dragover') delegation: If target is inside column but not .card, closest works. If target is placeholder? Placeholder has class placeholder, closest column still. ok.
Need prevent drop on text? okay.
Maybe use `dragImage` with hidden original? not needed.
Test mentally:
Initial render cards. State cards array order: todo low/medium, inprogress high/medium, done low/high? We want counts 2 each. Good.
Search filters. Count visible.
Drag: dragstart set draggedId. Move to target column: dragover add drag-over. placeholder inserted. drop moveCard beforeId. render cleanup. Dragend cleanup again. If drop outside no move, dragend removes classes.
One issue: During drag, card dragging remains in original list. The placeholder might be inserted after it. But when moving card, the target list before render may still have dragging card. After drop render, fine.
One issue: dragover may be fired repeatedly, movePlaceholder recalculates and moves placeholder. If placeholder is in list, get children filter excludes placeholder. Good. But if placeholder parentNode not target list, remove then insert. ok.
Issue: When moving over column's empty space, clientY may be below last card, after null, append placeholder. Good. But if mouse is over the column header (not card-list), closest column still. We still position placeholder based on clientY and cards list. Good. If column has no visible cards but total hidden, empty state child div .empty may be present? The empty div is child of card-list? We should maybe put empty outside card-list? If inside, children include .empty not card, filter only .card. But placeholder insertion with no cards: if empty div present, list.appendChild(placeholder) after empty. But drop beforeId null. Good.
However empty message inside card-list can be styled. If column has cards total but filtered none, empty message. Good. If user drags into filtered empty column, placeholder appears after empty message; okay.
Counts: visible length. But if empty message uses column cards total and query. Good.
Potential horizontal scrollbar due board grid and cards long words: Use overflow-wrap anywhere. Title break. Good.
Maybe column min-width 0: grid-template-columns repeat(3, minmax(0,1fr)) ensures shrink. Cards content flex with min-width 0. Good.
Need no `alert`. Good.
Can include aria-live for count? not necessary.
Theme toggle with SVG:
<button id="themeToggle" class="btn" type="button" aria-pressed="false" aria-label="Switch to dark theme" title="Toggle theme">
<svg class="icon sun" viewBox="0 0 24 24" ...> circle + rays </svg>
<svg class="icon moon" viewBox="0 0 24 24" ...> path </svg>
<span class="toggle-label">Dark</span>
</button>
But label text maybe not need. Could use button with only icons. For narrow, okay. Need update label? Could use `<span class="theme-text">Dark</span>` and toggle text? Use JS textContent.
Maybe simpler: button text: Light mode / Dark mode? But toggle theme should label current state. Start light, button says "Dark mode" with moon icon? On click toggle to dark, button says "Light mode" with sun icon? Could be intuitive: action to switch. Use `aria-pressed` if dark. Let's implement:
const toggle = document.getElementById('themeToggle');
const themeLabel = toggle.querySelector('.theme-label');
function setTheme(dark){ document.body.dataset.theme = dark?'dark':'light'; toggle.setAttribute('aria-pressed', String(dark)); themeLabel.textContent = dark ? 'Light mode' : 'Dark mode'; toggle.setAttribute('aria-label', dark ? 'Switch to light theme' : 'Switch to dark theme'); }
Need CSS hide/show icons: .theme-toggle .moon { display:inline; } .theme-toggle .sun { display:none; } body[data-theme=dark] .moon { display:none; } .sun { display:inline; } Or label already indicates. Good.
SVG sun/moon. Use currentColor stroke.
Search icon svg: <svg viewBox="0 0 24 24" class="search-icon"><circle cx="11" cy="11" r="7"/><path d="M20 20l-3.5-3.5"/></svg> stroke currentColor fill none. CSS .icon { width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:2;stroke-linecap:round;stroke-linejoin:round; }
Plus icon in Add button: maybe not.
Close modal: use × text or svg. Simpler `<span aria-hidden="true">✕</span>`.
Need CSS for icon-btn. Good.
Could add keyboard shortcut `n` to open modal? Not required. But if user clicks iframe then press key? Not needed. Could add for polish: keydown n if modal closed and not typing. But no.
Potential bug: If drag starts, then dragend cleanup. But if drop outside board, dragged card may remain dragging because cleanup called on dragend. Good.
Need attach dragend to board? If drag started on card, dragend bubbles to board. If card removed by drop before dragend? Dragend target may still be document? But cleanup removes. okay.
Maybe use window.addEventListener('dragend', cleanup) to ensure. Good.
Now final code. Ensure no mistakes.
Let's write JS carefully.
```
const state = {
cards: [
{ id: 1, title: 'Draft Q3 product announcement', priority: 'medium', col: 'todo' },
...
],
nextId: 7,
query: '',
draggedId: null
};
const board = document.getElementById('board');
const searchInput = document.getElementById('searchInput');
const modalOverlay = document.getElementById('modalOverlay');
...
function render() {
const q = state.query;
for (const section of document.querySelectorAll('.column')) {
const col = section.dataset.col;
const list = section.querySelector('.card-list');
const countEl = section.querySelector('.count');
list.innerHTML = '';
const columnCards = state.cards.filter(c => c.col === col);
const visible = q ? columnCards.filter(c => c.title.toLowerCase().includes(q)) : columnCards;
countEl.textContent = visible.length;
if (!visible.length) {
const empty = document.createElement('div');
empty.className = 'empty';
empty.textContent = q ? 'No matching cards' : 'Drop cards here';
list.appendChild(empty);
} else {
for (const cardData of visible) list.appendChild(createCard(cardData));
}
}
}
```
Need createCard:
```
function createCard(cardData) {
const el=document.createElement('article');
el.className='card';
el.draggable=true;
el.dataset.id=cardData.id;
const content=document.createElement('div'); content.className='card-content';
const main=document.createElement('div'); main.className='card-main';
const title=document.createElement('h3'); title.className='card-title'; title.textContent=cardData.title;
const badge=document.createElement('span'); badge.className=`badge ${cardData.priority}`; badge.textContent=cardData.priority;
main.append(title,badge);
const del=document.createElement('button'); del.type='button'; del.className='delete'; del.dataset.action='delete'; del.setAttribute('aria-label', `Delete ${cardData.title}`); del.draggable=false; del.textContent='×';
content.append(main, del); el.append(content); return el;
}
```
Could priority low -> class low, medium, high. Badge text uppercase via CSS. Use `badge.textContent = cardData.priority` with CSS uppercase.
Add event delegation:
```
board.addEventListener('click', e => {
const del = e.target.closest('[data-action="delete"]');
if (!del) return;
const card = del.closest('.card');
const id = Number(card.dataset.id);
state.cards = state.cards.filter(c => c.id !== id);
render();
});
```
But if click on delete button during drag? no.
Drag:
```
let placeholder = document.createElement('div'); placeholder.className='placeholder'; placeholder.setAttribute('aria-hidden','true');
```
```
board.addEventListener('dragstart', e => {
const card = e.target.closest('.card');
if(!card) return;
if(e.target.closest('button')) { e.preventDefault(); return; }
state.draggedId = card.dataset.id; // string
e.dataTransfer.setData('text/plain', state.draggedId);
e.dataTransfer.effectAllowed='move';
requestAnimationFrame(()=> card.classList.add('dragging'));
});
```
Dataset id string. In moveCard compare string.
```
board.addEventListener('dragend', cleanup);
window.addEventListener('dragend', cleanup);
```
But cleanup might run twice. Fine.
```
function cleanup(){
if (placeholder.parentNode) placeholder.parentNode.removeChild(placeholder);
board.querySelectorAll('.drag-over').forEach(el=>el.classList.remove('drag-over'));
board.querySelectorAll('.card.dragging').forEach(el=>el.classList.remove('dragging'));
state.draggedId=null;
}
```
```
function movePlaceholder(clientY, column){
const list = column.querySelector('.card-list');
if(!list) return;
const cards = [...list.children].filter(child => child.classList.contains('card') && !child.classList.contains('dragging'));
let after = null;
for(const child of cards){
const rect = child.getBoundingClientRect();
if (clientY < rect.top + rect.height/2) { after=child; break; }
}
if (placeholder.parentNode !== list) { if(placeholder.parentNode) placeholder.parentNode.removeChild(placeholder); }
if (after) list.insertBefore(placeholder, after); else list.appendChild(placeholder);
markOver(column);
}
```
Need markOver:
```
function markOver(column){
board.querySelectorAll('.column').forEach(c=>c.classList.toggle('drag-over', c===column));
}
```
This toggles all. Good.
```
board.addEventListener('dragover', e => {
if(!state.draggedId) return;
const column = e.target.closest('.column');
if(!column) return;
e.preventDefault();
e.dataTransfer.dropEffect='move';
movePlaceholder(e.clientY, column);
});
```
No need dragenter? Could rely on dragover. dragover frequent. But to avoid flash, okay.
```
board.addEventListener('dragleave', e=>{
const column=e.target.closest('.column'); if(!column) return;
if (!column.contains(e.relatedTarget)) { column.classList.remove('drag-over'); if (placeholder.parentNode && placeholder.parentNode.closest('.column')===column) { /* maybe if leaving, keep? */ } }
});
```
If dragover immediately enters another column, markOver toggles. If leave column to outside, markOver removed? We only remove that column. But if placeholder remains inside leaving column, should remove? When drag ends cleanup. During drag over another column, movePlaceholder removes placeholder from old parent (if parentNode not list). Good. If leaving board entirely, placeholder remains and column class maybe removed only leaving column. Could remove placeholder if relatedTarget null? In dragleave, if relatedTarget null, cleanup maybe too aggressive? Could if (!e.relatedTarget) { column.classList.remove... removePlaceholder }. But not necessary; dragend will cleanup. But visual maybe if cursor leaves board then returns to same column without new dragover? okay.
```
function placeholderBeforeId(){
if(!placeholder.parentNode) return null;
let next = placeholder.nextElementSibling;
while(next && (next.classList.contains('empty') || next.classList.contains('dragging'))) next=next.nextElementSibling; // if placeholder before empty? empty maybe should not be target. But empty message can be appended. If dropping to end with empty message, placeholder after empty; next null. If placeholder before empty? It won't be because cards inserted before empty? Actually if column has no cards and empty child, movePlaceholder cards=[], append placeholder after empty. next null. Good. If there are cards and after null append after last card. No empty. If filtered no visible cards, empty then placeholder after empty. next null. Good.
if (next && next.classList.contains('card')) return next.dataset.id;
return null;
}
```
But if placeholder before dragged card? In same column, dragged card excluded. Could placeholder inserted after dragged? If mouse below dragged, after = null, placeholder append after all, which might be after dragged. targetId null. Fine. If mouse above dragged, after=dragged? Wait cards filter excludes dragging, so dragged omitted; after could be first non-dragged. If dragged first and mouse above it, after none? Actually list: dragging card, other card. cards other card only. clientY < other card top? maybe if above other card but below header, after = null? Wait if other card is after dragged, clientY might be above other card, so after=other card. Placeholder inserted before other card, which may be after dragged? It will appear between dragged and other card. Target before other card. That's okay. Could be moving dragged to same position? before other card if other is second. If dragged first and before second, target before other card. moveCard removes dragged then insert before other. Good.
MoveCard with beforeId string:
```
function moveCard(id, columnId, beforeId) {
const fromIndex = state.cards.findIndex(c=>String(c.id)===String(id));
if(fromIndex<0) return;
const [card] = state.cards.splice(fromIndex,1);
card.col = columnId;
if (beforeId) {
const targetIndex = state.cards.findIndex(c=>String(c.id)===String(beforeId));
if(targetIndex>=0) state.cards.splice(targetIndex,0,card); else state.cards.push(card);
} else state.cards.push(card);
}
```
If dropping on column but beforeId is null, push to end of global array. Within column after others. Good. But if dropping on empty target that has hidden cards due filter, push end after maybe global hidden cards. But render column order by global array. Since no visible cards in that column, if there are hidden cards (filtered out), push after hidden? But push at end of global may be after hidden if hidden cards are before? Suppose target column has hidden cards earlier in array; push at end puts after hidden. But visible insertion not relevant. Fine.
Could if beforeId null and column has visible? placeholder after last visible card, maybe next sibling null even if hidden? We don't render hidden cards, so list only visible. If dropping after last visible, state push end. If there are hidden cards after? Actually hidden not rendered, so cannot know. But okay.
Add global dragover/prevent drop:
```
document.addEventListener('dragover', e => e.preventDefault());
document.addEventListener('drop', e => e.preventDefault());
```
Will this interfere with board? It bubbles after board. Good. But if dragging over form input text? default text selection? not.
Potential issue: Because board has cards draggable, and document dragover preventDefault, dropping onto non-column might be prevented. good.
Search input listener:
```
searchInput.addEventListener('input', () => { state.query = searchInput.value.trim().toLowerCase(); render(); });
```
Modal open/close:
```
function openModal() { modalOverlay.classList.add('open'); modalOverlay.setAttribute('aria-hidden','false'); titleInput.value=''; priorityInput.value='medium'; columnInput.value='todo'; lastFocused=document.activeElement; requestAnimationFrame(()=>titleInput.focus()); }
function closeModal(){ modalOverlay.classList.remove('open'); modalOverlay.setAttribute('aria-hidden','true'); if(lastFocused) lastFocused.focus(); }
addBtn.addEventListener('click', openModal);
closeBtn.addEventListener('click', closeModal);
cancelBtn.addEventListener('click', closeModal);
modalOverlay.addEventListener('click', e=>{ if(e.target===modalOverlay) closeModal(); });
document.addEventListener('keydown', e=>{ if(e.key==='Escape' && modalOverlay.classList.contains('open')) closeModal(); });
```
Need lastFocused variable.
Add form:
```
addForm.addEventListener('submit', e=>{
e.preventDefault();
const title=titleInput.value.trim();
if(!title){ titleInput.setCustomValidity('Please enter a title'); titleInput.reportValidity(); titleInput.focus(); return; }
titleInput.setCustomValidity('');
state.cards.push({id: state.nextId++, title, priority: priorityInput.value, col: columnInput.value});
if (state.query && !title.toLowerCase().includes(state.query)) { searchInput.value=''; state.query=''; }
closeModal();
render();
});
titleInput.addEventListener('input', ()=> titleInput.setCustomValidity(''));
```
`reportValidity` with custom validity. Good.
Potential issue: If title whitespace but required? custom validity. Good.
CSS for novalidate? Use `novalidate` attribute.
Counts: use `section.querySelector('.count')`.
Static columns HTML: Include `aria-label` maybe.
Use `.sr-only` for labels? Search input has aria-label maybe. Add `<label class="sr-only" for="searchInput">Search cards by title</label>`.
CSS `.sr-only { position:absolute; width:1px; height:1px; padding:0; margin:-1px; overflow:hidden; clip:rect(0,0,0,0); white-space:nowrap; border:0; }`
Need `.toggle-label` maybe hidden on small? Keep. Button width auto.
Maybe include header with h1 and subtitle? Good.
Toolbar:
```
<header class="app-header">
<div class="brand"><h1>Kanban</h1><span class="subtitle">Team board</span></div>
<div class="toolbar-right">... </div>
</header>
```
But search + add + theme. Simpler toolbar.
Maybe include board summary? Not needed.
Need first screenshot alive: Use 6 cards, badges. Animation.
Let's craft final code.
Review possible CSS issue: `.modal-overlay` fixed, when open uses flex. If hidden display? Use opacity + pointer-events none. Fine. It remains in layout? fixed no. If not open, pointer-events none. Good.
But `aria-hidden true` with focusable inputs inside overlay even when closed? Inputs can be focused via keyboard? Overlay hidden pointer-events none but focusable children still reachable? We can set visibility hidden? Use `visibility:hidden` when closed, `visibility:visible` open. But transition opacity doesn't hide focusable? It can still be tabbable if visibility visible? We set pointer-events none but not visibility. Inputs might still be tabbable and screen reader hidden? Could use `.modal-overlay:not(.open) { display:none; }` but transition lost. Not important. Better: use `inert`? Not supported? Latest Chrome yes but attribute. Could toggle `modalOverlay.setAttribute('inert','')` when closed. That prevents focus. Add CSS `visibility:hidden` when closed? But transition from hidden no. Could use `display:none` when closed, then open via class? If display none, transition opacity won't animate. We can use visibility hidden with transition: visibility 0s .2s? But focusable? If visibility hidden, not focusable. Use `.modal-overlay { visibility:hidden; opacity:0; pointer-events:none; transition: opacity .2s ease, visibility .2s; } .modal-overlay.open { visibility:visible; opacity:1; pointer-events:auto; }` Good. Inputs not focusable when visibility hidden. Use visibility in transition. Good.
Need backdrop filter not necessary.
Potential issue with body scroll when modal open? small.
No external fonts. Good.
Let's test mentally for drag insertion:
- dragover over column; movePlaceholder ensures placeholder parent is list. If placeholder exists in old column, parentNode !== list, remove then append/insert. Good.
- If dropping, beforeId from placeholder next. Then moveCard. cleanup removes placeholder from DOM. render resets list. Good.
One nuance: cleanup called after drop? We call moveCard then cleanup then render? If cleanup removes dragging class from old card but render rebuilds anyway. Could render first then cleanup? In drop:
```
moveCard(...); cleanup(); render();
```
cleanup tries remove placeholder; after moveCard state changed but old DOM still has card dragging. ok. Then render. Good.
If drop outside board, cleanup on dragend. Good.
Add board click on delete; after delete render. If delete while dragging? Not possible.
Need prevent drag from button. dragstart checks target.closest('button') then preventDefault. But if mousedown on button then move, browser might not call dragstart because button not draggable? But card is draggable, target button may cause dragstart. Our check prevents. But preventing default in dragstart may stop. Good.
Need set `e.stopPropagation()` on button click? Not necessary.
Potential accessibility: Drag only mouse, okay requirement mouse. Could keyboard? not.
Could include count `aria-live="polite"`. Not needed.
Maybe add "Priority" in modal select. Good.
Now final output code block only. Need ensure no mention. Must be ```html.
Double-check CSS variables names and values.
Let's write full code:
```
:root { color-scheme: light; --bg: ...}
body[data-theme="dark"] { color-scheme: dark; --bg: ...}
```
If initial body has data-theme="light", CSS :root variables apply. Good.
For `.column.drag-over`, define:
```
.column.drag-over { border-color: var(--accent); background: var(--drop-bg); box-shadow: 0 0 0 3px var(--ring), var(--shadow); }
```
Variables: --drop-bg: #eef2ff light; #1b2740 dark.
Placeholder: `border:2px dashed var(--accent); background: var(--placeholder-bg);`
For priority medium class: Use class `medium`. CSS `.badge.medium`.
For empty state: `.empty` pointer-events none? It's in list, could block dragover? But column closest still works. Good.
Potential issue: `.card` cursor grab. During drag, if placeholder appended, no cards. ok.
Add media query:
```
@media (max-width: 760px) { .board { grid-template-columns:1fr; } .toolbar { flex-direction:column; align-items:stretch; } .actions { justify-content:flex-end; } }
@media (max-width: 375px) { .btn { padding:10px 12px; } .theme-toggle .toggle-text { display:none; } }
```
Hide label small to save space.
But if toggle-text hidden, button with icon only. Need aria-label. Good.
Use `.toggle-text { font-size:14px; }`.
Search icon absolute, input padding-left 38px.
Header title maybe `display:flex; gap:8px; align-items:baseline;`. Could have subtitle hidden small.
Add maybe `.summary` with total cards? Not required. But count each column enough. Could add global count? not.
Could prepopulate with realistic card descriptions? Requirement title and priority only. Good.
Let's ensure no horizontal scrollbar: `.app` padding, board grid. On 375, input flex:1 min-width? If `.toolbar` grid. Use `.toolbar { display:flex; flex-wrap:wrap; gap:12px; } .search { flex:1 1 220px; min-width:0; } .actions { flex:0 1 auto; margin-left:auto; }` At 375, search full, actions wrap. Buttons not too wide. Theme label hidden under 375? maybe.
Need no `overflow-x:hidden` maybe add on body to be safe: `html, body { overflow-x:hidden; }` But that can hide real? okay. Not needed.
Potential form focus: If modal closed via Escape while focus input, closeModal restores lastFocused. Good.
No `sessionStorage`. Good.
Let's craft final code elegantly.
One more consideration: Drag and drop between columns with visual drop indicator. Our placeholder is clear. Good.
Could add `dragover` on column even if no placeholder? works.
Test with search filtering: Cards hidden not in DOM. Drag over same column with only dragged card visible: cards array excludes dragging, empty. after null append placeholder after dragging. beforeId null, moveCard push end. If column has other hidden cards due filter, they remain in state before push. Render after clearing? Search persists, hidden not rendered. Count visible maybe. Good.
Potential bug: `state.query = searchInput.value.trim().toLowerCase();` If query trimmed, includes with trim. okay.
Card priority values: low, medium, high. Badge uppercase via CSS. Class for medium okay.
Initial card count total visible no search = 2 each. Good.
Let's output.