MiniMax M3
by MiniMaxreasoning defaultClean
Running live in a sandboxed frame. Click inside it before using the keyboard.
How it went
- Total time
- 3 min 14 s
- Cost
- $0.039
- Output tokens
- 32,447
- Spent thinking
- 15,985 tokens
- Speed
- 168.2 tok/s
- Console errors
- 0
- First token after
- 1.1 s
- First visible text after
- 2 min 4 s
- Prompt tokens
- 676
- Code size
- 59.1 KB
- Finish reason
- stop
- Served by
- Minimax
- Model id
minimax/minimax-m3- Reasoning sent
model default- Max output
- 512,000 tokens
- Generated
- Nov 13, 2026
What it was asked
The prompt
Build an interactive Kanban board in vanilla HTML, CSS and JavaScript (no framework, no library). Requirements: - Three columns: "To do", "In progress", "Done", each showing its card count. - Pre-populate the board with 6 realistic cards spread across the columns so the first screenshot looks alive. - Each card has a title and a priority (low / medium / high) shown as a colored badge. - Add a card through a small modal form (title required), and delete a card with a button on it. - Drag and drop cards between columns with the mouse, with a clear visual drop indicator. - A search field that filters cards live by title. - A light / dark theme toggle. - State lives in memory only (no storage APIs).
System prompt, sent with every prompt
You are generating a self-contained web demo. It will be rendered automatically in a sandboxed iframe and compared side by side with the output of other AI models given the exact same task. Output rules: - Reply with exactly ONE fenced code block containing the complete file: ```html (or ```svg when the task asks for an SVG). No text before or after the code block. - Everything must be inline: CSS in <style>, JavaScript in <script>. No external files, images, fonts or network requests, except the CDN URLs explicitly allowed by the task. - Do not use localStorage, sessionStorage, cookies, IndexedDB, alert(), confirm(), prompt() or window.open(): they are blocked in the sandbox and will throw. - The page must look right in a 1280×800 viewport and adapt gracefully down to 375px wide. No horizontal scrollbar. - Something meaningful must be visible on first paint: a screenshot is taken automatically about 3 seconds after load, without any interaction. - Everything that makes the demo interesting must be visible within the first seconds: nothing hidden behind a high score, a later level or a long play session. - Games must start in a self-playing demo mode (a simple AI plays) behind a "Click or press a key to start" message, so the game can be judged in action without playing. - The iframe only receives keyboard events after the user clicks it: if the demo uses the keyboard, display a clear "Click or press a key to start" message. - The code must run with zero errors in the console of the latest Chrome.
Code run-1.html · 2212 lines
<!DOCTYPE html>
<html lang="en" data-theme="light">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="color-scheme" content="light dark">
<title>Tasklane — Product Launch Board</title>
<style>
:root {
--bg: #f6f7fb;
--surface: #ffffff;
--surface-soft: #f0f2f7;
--column-bg: #eef0f5;
--card: #ffffff;
--heading: #171c28;
--text: #29303e;
--muted: #747d8e;
--faint: #a1a8b6;
--border: #e3e6ed;
--border-strong: #d6dae4;
--accent: #6c5ce7;
--accent-strong: #5948d5;
--accent-rgb: 108, 92, 231;
--accent-soft: #eeebff;
--red: #d94d5c;
--red-bg: #fff0f1;
--amber: #b87508;
--amber-bg: #fff5df;
--green: #258461;
--green-bg: #eaf8f1;
--todo-color: #929bad;
--progress-color: #e99b35;
--done-color: #39a979;
--shadow: 0 8px 26px rgba(34, 42, 67, 0.065);
--shadow-hover: 0 13px 30px rgba(34, 42, 67, 0.11);
--modal-shadow: 0 30px 80px rgba(19, 23, 38, 0.28);
}
[data-theme="dark"] {
--bg: #101219;
--surface: #1a1d26;
--surface-soft: #232733;
--column-bg: #161920;
--card: #1d2029;
--heading: #f7f8fc;
--text: #e7eaf1;
--muted: #9aa2b2;
--faint: #70798a;
--border: #2c313c;
--border-strong: #3a414f;
--accent: #9787ff;
--accent-strong: #806ff2;
--accent-rgb: 151, 135, 255;
--accent-soft: #292444;
--red: #ff8993;
--red-bg: rgba(239, 95, 107, 0.14);
--amber: #f6bb5c;
--amber-bg: rgba(239, 166, 63, 0.14);
--green: #65d3a6;
--green-bg: rgba(61, 191, 138, 0.14);
--todo-color: #9ba4b7;
--progress-color: #f0ad50;
--done-color: #54c798;
--shadow: 0 10px 28px rgba(0, 0, 0, 0.24);
--shadow-hover: 0 15px 36px rgba(0, 0, 0, 0.34);
--modal-shadow: 0 30px 90px rgba(0, 0, 0, 0.52);
}
* {
box-sizing: border-box;
}
html {
max-width: 100%;
overflow-x: hidden;
}
body {
margin: 0;
min-width: 320px;
min-height: 100vh;
overflow-x: hidden;
color: var(--text);
background:
radial-gradient(circle at 5% 0%, rgba(var(--accent-rgb), 0.09), transparent 28%),
radial-gradient(circle at 96% 8%, rgba(57, 169, 121, 0.07), transparent 24%),
var(--bg);
font-family: Inter, ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
font-size: 14px;
line-height: 1.45;
-webkit-font-smoothing: antialiased;
transition: background-color 180ms ease, color 180ms ease;
}
button,
input,
select {
font: inherit;
}
button {
color: inherit;
}
button,
select {
cursor: pointer;
}
button:focus-visible,
input:focus-visible,
select:focus-visible {
outline: 3px solid rgba(var(--accent-rgb), 0.24);
outline-offset: 2px;
}
[hidden] {
display: none !important;
}
.app-shell {
width: min(1380px, calc(100% - 48px));
margin: 0 auto;
padding: 20px 0 28px;
}
.topbar {
min-height: 64px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 22px;
}
.topbar-left,
.topbar-actions,
.brand,
.workspace,
.team-stack {
display: flex;
align-items: center;
}
.topbar-left {
min-width: 0;
gap: 22px;
}
.brand {
gap: 11px;
min-width: 0;
}
.brand-mark {
width: 42px;
height: 42px;
flex: 0 0 auto;
display: grid;
place-items: center;
border-radius: 13px;
color: #fff;
background: linear-gradient(145deg, #8070f0, #5847d4);
box-shadow: 0 9px 22px rgba(var(--accent-rgb), 0.25);
}
.brand-copy {
display: flex;
flex-direction: column;
line-height: 1.15;
min-width: 0;
}
.brand-copy strong {
color: var(--heading);
font-size: 16px;
font-weight: 750;
letter-spacing: -0.02em;
}
.brand-copy span {
margin-top: 4px;
color: var(--muted);
font-size: 11.5px;
font-weight: 500;
}
.workspace {
gap: 9px;
padding-left: 22px;
border-left: 1px solid var(--border);
color: var(--muted);
font-size: 12.5px;
font-weight: 600;
white-space: nowrap;
}
.workspace strong {
color: var(--text);
font-weight: 650;
}
.workspace-plan {
padding: 3px 6px;
border: 1px solid var(--border);
border-radius: 6px;
color: var(--faint);
font-size: 9px;
font-weight: 800;
letter-spacing: 0.08em;
}
.topbar-actions {
gap: 11px;
flex: 0 0 auto;
}
.team-stack {
margin-right: 5px;
padding-right: 14px;
border-right: 1px solid var(--border);
}
.avatar {
width: 30px;
height: 30px;
display: inline-grid;
place-items: center;
flex: 0 0 auto;
border: 2px solid var(--surface);
border-radius: 50%;
font-size: 9.5px;
font-weight: 800;
letter-spacing: -0.01em;
}
.team-stack .avatar + .avatar {
margin-left: -8px;
}
.avatar-purple {
color: #6550c7;
background: #e9e3ff;
}
.avatar-blue {
color: #3474a9;
background: #dceeff;
}
.avatar-peach {
color: #a75f39;
background: #ffe4d5;
}
.avatar-green {
color: #26795c;
background: #d9f3e7;
}
[data-theme="dark"] .avatar {
border-color: var(--surface);
}
[data-theme="dark"] .avatar-purple {
color: #dcd3ff;
background: #42356f;
}
[data-theme="dark"] .avatar-blue {
color: #c4e4ff;
background: #294f6f;
}
[data-theme="dark"] .avatar-peach {
color: #ffd2ba;
background: #6b402d;
}
[data-theme="dark"] .avatar-green {
color: #bcebd8;
background: #285847;
}
.team-more {
margin-left: 7px;
color: var(--muted);
font-size: 11px;
font-weight: 650;
}
.theme-button,
.add-task-button {
height: 40px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
border-radius: 11px;
font-size: 12.5px;
font-weight: 700;
transition: transform 150ms ease, background 150ms ease, border-color 150ms ease, box-shadow 150ms ease;
}
.theme-button {
padding: 0 12px;
color: var(--muted);
background: var(--surface);
border: 1px solid var(--border);
}
.theme-button:hover {
color: var(--heading);
border-color: var(--border-strong);
background: var(--surface-soft);
}
.theme-button svg {
width: 17px;
height: 17px;
}
.theme-icon-sun {
display: none;
}
[data-theme="dark"] .theme-icon-moon {
display: none;
}
[data-theme="dark"] .theme-icon-sun {
display: block;
}
.add-task-button {
padding: 0 16px;
color: #fff;
border: 1px solid var(--accent-strong);
background: var(--accent);
box-shadow: 0 7px 18px rgba(var(--accent-rgb), 0.22);
}
.add-task-button:hover {
transform: translateY(-1px);
background: var(--accent-strong);
box-shadow: 0 9px 22px rgba(var(--accent-rgb), 0.3);
}
.add-task-button svg {
width: 17px;
height: 17px;
}
.board-heading {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 32px;
margin: 34px 0 24px;
}
.heading-copy {
min-width: 0;
}
.eyebrow {
display: inline-flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
color: var(--accent);
font-size: 10.5px;
font-weight: 800;
letter-spacing: 0.11em;
text-transform: uppercase;
}
.eyebrow-dot {
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--accent);
box-shadow: 0 0 0 4px rgba(var(--accent-rgb), 0.12);
}
h1 {
margin: 0;
color: var(--heading);
font-size: clamp(28px, 2.5vw, 36px);
font-weight: 760;
letter-spacing: -0.045em;
line-height: 1.12;
}
.heading-copy p {
margin: 8px 0 0;
color: var(--muted);
font-size: 13.5px;
}
.board-toolbar {
display: flex;
align-items: center;
gap: 10px;
flex: 0 0 auto;
}
.search-control {
position: relative;
width: min(310px, 31vw);
min-width: 0;
}
.search-control > svg {
position: absolute;
top: 50%;
left: 14px;
width: 17px;
height: 17px;
color: var(--faint);
transform: translateY(-50%);
pointer-events: none;
}
.search-control input {
width: 100%;
height: 44px;
padding: 0 40px 0 41px;
color: var(--heading);
background: var(--surface);
border: 1px solid var(--border);
border-radius: 12px;
box-shadow: 0 3px 10px rgba(34, 42, 67, 0.025);
font-size: 13px;
transition: border-color 150ms ease, box-shadow 150ms ease;
}
.search-control input::placeholder {
color: var(--faint);
}
.search-control input:focus {
border-color: var(--accent);
box-shadow: 0 0 0 4px rgba(var(--accent-rgb), 0.1);
outline: none;
}
.clear-search {
position: absolute;
top: 50%;
right: 8px;
width: 28px;
height: 28px;
display: grid;
place-items: center;
padding: 0;
color: var(--muted);
background: transparent;
border: 0;
border-radius: 8px;
transform: translateY(-50%);
font-size: 19px;
line-height: 1;
}
.clear-search:hover {
color: var(--heading);
background: var(--surface-soft);
}
.summary-pill {
height: 44px;
display: inline-flex;
align-items: center;
gap: 8px;
padding: 0 13px;
color: var(--muted);
background: var(--surface);
border: 1px solid var(--border);
border-radius: 12px;
font-size: 11.5px;
font-weight: 650;
white-space: nowrap;
}
.live-dot {
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--done-color);
box-shadow: 0 0 0 4px rgba(57, 169, 121, 0.12);
}
.kanban-board {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
align-items: stretch;
gap: 18px;
}
.column {
position: relative;
min-width: 0;
min-height: 494px;
display: flex;
flex-direction: column;
padding: 14px;
overflow: hidden;
background: var(--column-bg);
border: 1px solid var(--border);
border-radius: 18px;
transition: background 160ms ease, border-color 160ms ease, box-shadow 160ms ease;
}
.column::before {
content: "";
position: absolute;
top: 0;
left: 15px;
right: 15px;
height: 3px;
border-radius: 0 0 5px 5px;
background: var(--todo-color);
}
.column[data-column="progress"]::before {
background: var(--progress-color);
}
.column[data-column="done"]::before {
background: var(--done-color);
}
.column.is-drag-target {
border-color: rgba(var(--accent-rgb), 0.65);
box-shadow: inset 0 0 0 2px rgba(var(--accent-rgb), 0.18);
}
.column-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
min-height: 42px;
padding: 1px 3px 12px;
}
.column-title-group {
min-width: 0;
display: flex;
align-items: center;
gap: 9px;
}
.column-indicator {
width: 9px;
height: 9px;
flex: 0 0 auto;
border-radius: 3px;
background: var(--todo-color);
box-shadow: 0 0 0 4px rgba(146, 155, 173, 0.12);
}
[data-column="progress"] .column-indicator {
background: var(--progress-color);
box-shadow: 0 0 0 4px rgba(233, 155, 53, 0.13);
}
[data-column="done"] .column-indicator {
background: var(--done-color);
box-shadow: 0 0 0 4px rgba(57, 169, 121, 0.13);
}
.column h2 {
margin: 0;
color: var(--heading);
font-size: 14px;
font-weight: 750;
letter-spacing: -0.015em;
white-space: nowrap;
}
.column-count {
min-width: 25px;
height: 25px;
display: inline-grid;
place-items: center;
padding: 0 7px;
color: var(--muted);
background: var(--surface);
border: 1px solid var(--border);
border-radius: 8px;
font-size: 10.5px;
font-weight: 800;
font-variant-numeric: tabular-nums;
}
.column-caption {
color: var(--faint);
font-size: 10.5px;
font-weight: 650;
white-space: nowrap;
}
.card-list {
min-height: 180px;
flex: 1 1 auto;
display: flex;
flex-direction: column;
gap: 12px;
padding: 4px 2px 8px;
border: 1px solid transparent;
border-radius: 13px;
transition: background 140ms ease, border-color 140ms ease;
}
.card-list.is-over {
background: rgba(var(--accent-rgb), 0.055);
border-color: rgba(var(--accent-rgb), 0.38);
}
.task-card {
position: relative;
min-width: 0;
padding: 14px 14px 12px;
color: var(--text);
background: var(--card);
border: 1px solid var(--border);
border-radius: 14px;
box-shadow: var(--shadow);
cursor: grab;
animation: cardIn 260ms both;
transition: transform 160ms ease, box-shadow 160ms ease, border-color 160ms ease, opacity 160ms ease;
}
.task-card:hover {
transform: translateY(-2px);
border-color: var(--border-strong);
box-shadow: var(--shadow-hover);
}
.task-card:active {
cursor: grabbing;
}
.task-card.is-dragging {
opacity: 0.32;
transform: rotate(1deg) scale(0.98);
box-shadow: none;
}
.card-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
min-height: 25px;
}
.priority-badge {
display: inline-flex;
align-items: center;
gap: 6px;
height: 23px;
padding: 0 8px;
border-radius: 7px;
font-size: 9.5px;
font-weight: 800;
letter-spacing: 0.055em;
text-transform: uppercase;
}
.priority-badge::before {
content: "";
width: 5px;
height: 5px;
border-radius: 50%;
background: currentColor;
}
.priority-badge.high {
color: var(--red);
background: var(--red-bg);
}
.priority-badge.medium {
color: var(--amber);
background: var(--amber-bg);
}
.priority-badge.low {
color: var(--green);
background: var(--green-bg);
}
.card-actions {
display: flex;
align-items: center;
gap: 1px;
opacity: 0.68;
transition: opacity 150ms ease;
}
.task-card:hover .card-actions,
.card-actions:focus-within {
opacity: 1;
}
.icon-action {
width: 27px;
height: 27px;
display: inline-grid;
place-items: center;
padding: 0;
color: var(--faint);
background: transparent;
border: 0;
border-radius: 7px;
transition: color 140ms ease, background 140ms ease;
}
.icon-action svg {
width: 15px;
height: 15px;
}
.delete-card:hover {
color: var(--red);
background: var(--red-bg);
}
.drag-handle {
cursor: grab;
}
.drag-handle:active {
cursor: grabbing;
}
.task-card h3 {
margin: 10px 0 11px;
color: var(--heading);
font-size: 14.5px;
font-weight: 700;
line-height: 1.35;
letter-spacing: -0.018em;
overflow-wrap: anywhere;
}
.task-meta {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
color: var(--muted);
font-size: 10.5px;
font-weight: 600;
}
.category-tag {
max-width: 120px;
overflow: hidden;
padding: 4px 7px;
color: var(--muted);
background: var(--surface-soft);
border-radius: 6px;
font-size: 9px;
font-weight: 750;
letter-spacing: 0.045em;
text-overflow: ellipsis;
text-transform: uppercase;
white-space: nowrap;
}
.meta-dot {
color: var(--faint);
font-size: 9px;
}
.due-date {
display: inline-flex;
align-items: center;
gap: 5px;
white-space: nowrap;
}
.due-date svg {
width: 12px;
height: 12px;
opacity: 0.8;
}
.due-date.urgent {
color: var(--red);
}
.card-footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
margin-top: 13px;
padding-top: 11px;
border-top: 1px solid var(--border);
}
.assignee {
min-width: 0;
display: flex;
align-items: center;
gap: 8px;
}
.assignee .avatar {
width: 26px;
height: 26px;
border-width: 0;
font-size: 8.5px;
}
.assignee-name {
max-width: 120px;
overflow: hidden;
color: var(--muted);
font-size: 10.5px;
font-weight: 650;
text-overflow: ellipsis;
white-space: nowrap;
}
.comment-count {
display: inline-flex;
align-items: center;
gap: 4px;
color: var(--faint);
font-size: 10px;
font-weight: 650;
}
.comment-count svg {
width: 13px;
height: 13px;
}
.drop-indicator {
position: relative;
height: 38px;
flex: 0 0 auto;
display: flex;
align-items: center;
}
.drop-indicator span {
position: relative;
display: block;
width: 100%;
height: 3px;
border-radius: 999px;
background: var(--accent);
box-shadow: 0 0 0 3px rgba(var(--accent-rgb), 0.13);
}
.drop-indicator span::before {
content: "";
position: absolute;
top: 50%;
left: -4px;
width: 10px;
height: 10px;
border: 2px solid var(--accent);
border-radius: 50%;
background: var(--column-bg);
transform: translateY(-50%);
}
.drop-indicator em {
position: absolute;
top: -19px;
left: 50%;
padding: 2px 7px;
color: #fff;
background: var(--accent);
border-radius: 5px;
font-size: 8.5px;
font-style: normal;
font-weight: 800;
letter-spacing: 0.035em;
text-transform: uppercase;
transform: translateX(-50%);
white-space: nowrap;
}
.empty-state {
min-height: 122px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
padding: 18px;
color: var(--faint);
border: 1px dashed var(--border-strong);
border-radius: 12px;
text-align: center;
}
.empty-state svg {
width: 24px;
height: 24px;
opacity: 0.75;
}
.empty-state span {
font-size: 11px;
font-weight: 650;
}
.quick-add {
width: 100%;
min-height: 37px;
display: flex;
align-items: center;
justify-content: center;
gap: 7px;
margin-top: 8px;
color: var(--muted);
background: transparent;
border: 1px dashed var(--border-strong);
border-radius: 10px;
font-size: 11px;
font-weight: 700;
transition: color 140ms ease, background 140ms ease, border-color 140ms ease;
}
.quick-add svg {
width: 14px;
height: 14px;
}
.quick-add:hover {
color: var(--accent);
background: rgba(var(--accent-rgb), 0.06);
border-color: rgba(var(--accent-rgb), 0.48);
}
.board-footnote {
display: flex;
align-items: center;
justify-content: space-between;
gap: 18px;
margin-top: 13px;
padding: 0 4px;
color: var(--faint);
font-size: 10.5px;
font-weight: 550;
}
.board-footnote span {
display: inline-flex;
align-items: center;
gap: 6px;
}
.board-footnote svg {
width: 14px;
height: 14px;
}
.memory-note {
display: inline-flex !important;
align-items: center;
gap: 7px !important;
}
.memory-dot {
width: 5px;
height: 5px;
border-radius: 50%;
background: var(--done-color);
}
.modal-backdrop {
position: fixed;
inset: 0;
z-index: 50;
display: grid;
place-items: center;
padding: 20px;
background: rgba(15, 18, 29, 0.48);
backdrop-filter: blur(6px);
-webkit-backdrop-filter: blur(6px);
animation: fadeIn 160ms ease both;
}
.modal {
width: min(460px, 100%);
max-height: calc(100vh - 32px);
overflow-y: auto;
color: var(--text);
background: var(--surface);
border: 1px solid var(--border);
border-radius: 20px;
box-shadow: var(--modal-shadow);
animation: modalIn 220ms cubic-bezier(0.2, 0.8, 0.2, 1) both;
}
.modal-form {
padding: 22px;
}
.modal-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 20px;
margin-bottom: 20px;
}
.modal-title-wrap {
display: flex;
align-items: flex-start;
gap: 12px;
}
.modal-icon {
width: 38px;
height: 38px;
display: grid;
place-items: center;
flex: 0 0 auto;
color: var(--accent);
background: var(--accent-soft);
border-radius: 11px;
}
.modal-icon svg {
width: 18px;
height: 18px;
}
.modal h2 {
margin: 0;
color: var(--heading);
font-size: 18px;
font-weight: 750;
letter-spacing: -0.025em;
}
.modal-description {
margin: 4px 0 0;
color: var(--muted);
font-size: 11.5px;
}
.modal-close {
width: 31px;
height: 31px;
display: grid;
place-items: center;
flex: 0 0 auto;
padding: 0;
color: var(--muted);
background: var(--surface-soft);
border: 0;
border-radius: 9px;
}
.modal-close:hover {
color: var(--heading);
background: var(--border);
}
.modal-close svg {
width: 16px;
height: 16px;
}
.field-label,
.field-group legend {
display: block;
margin: 0 0 7px;
color: var(--heading);
font-size: 11px;
font-weight: 750;
}
.required-mark {
color: var(--red);
}
.title-input,
.status-select {
width: 100%;
height: 43px;
color: var(--heading);
background: var(--bg);
border: 1px solid var(--border-strong);
border-radius: 10px;
font-size: 12.5px;
transition: border-color 140ms ease, box-shadow 140ms ease;
}
.title-input {
padding: 0 12px;
}
.title-input::placeholder {
color: var(--faint);
}
.title-input:focus,
.status-select:focus {
border-color: var(--accent);
box-shadow: 0 0 0 4px rgba(var(--accent-rgb), 0.1);
outline: none;
}
.title-input[aria-invalid="true"] {
border-color: var(--red);
box-shadow: 0 0 0 3px rgba(217, 77, 92, 0.1);
}
.field-group {
margin: 18px 0 0;
padding: 0;
border: 0;
}
.priority-options {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 8px;
}
.priority-option {
position: relative;
cursor: pointer;
}
.priority-option input {
position: absolute;
width: 1px;
height: 1px;
opacity: 0;
}
.priority-option span {
height: 39px;
display: flex;
align-items: center;
justify-content: center;
gap: 7px;
color: var(--muted);
background: var(--bg);
border: 1px solid var(--border-strong);
border-radius: 9px;
font-size: 11px;
font-weight: 750;
transition: color 140ms ease, background 140ms ease, border-color 140ms ease, box-shadow 140ms ease;
}
.priority-option i {
width: 7px;
height: 7px;
border-radius: 50%;
background: currentColor;
}
.priority-option.low input:checked + span {
color: var(--green);
background: var(--green-bg);
border-color: var(--green);
}
.priority-option.medium input:checked + span {
color: var(--amber);
background: var(--amber-bg);
border-color: var(--amber);
}
.priority-option.high input:checked + span {
color: var(--red);
background: var(--red-bg);
border-color: var(--red);
}
.priority-option input:focus-visible + span {
outline: 3px solid rgba(var(--accent-rgb), 0.24);
outline-offset: 2px;
}
.status-select {
padding: 0 36px 0 12px;
appearance: none;
background-image:
linear-gradient(45deg, transparent 50%, var(--muted) 50%),
linear-gradient(135deg, var(--muted) 50%, transparent 50%);
background-position:
calc(100% - 17px) 18px,
calc(100% - 12px) 18px;
background-size: 5px 5px, 5px 5px;
background-repeat: no-repeat;
}
.form-error {
min-height: 17px;
margin: 6px 0 0;
color: var(--red);
font-size: 10.5px;
font-weight: 650;
}
.modal-actions {
display: flex;
justify-content: flex-end;
gap: 9px;
margin-top: 14px;
padding-top: 17px;
border-top: 1px solid var(--border);
}
.secondary-button,
.primary-button {
height: 40px;
padding: 0 16px;
border-radius: 10px;
font-size: 11.5px;
font-weight: 750;
}
.secondary-button {
color: var(--muted);
background: transparent;
border: 1px solid var(--border);
}
.secondary-button:hover {
color: var(--heading);
background: var(--surface-soft);
}
.primary-button {
color: #fff;
background: var(--accent);
border: 1px solid var(--accent);
box-shadow: 0 7px 16px rgba(var(--accent-rgb), 0.2);
}
.primary-button:hover {
background: var(--accent-strong);
}
.toast {
position: fixed;
right: 22px;
bottom: 22px;
z-index: 70;
min-height: 44px;
display: flex;
align-items: center;
gap: 10px;
padding: 9px 14px 9px 10px;
color: var(--text);
background: var(--surface);
border: 1px solid var(--border);
border-radius: 12px;
box-shadow: var(--shadow-hover);
opacity: 0;
pointer-events: none;
transform: translateY(12px);
transition: opacity 180ms ease, transform 180ms ease;
}
.toast.show {
opacity: 1;
transform: translateY(0);
}
.toast-icon {
width: 27px;
height: 27px;
display: grid;
place-items: center;
color: var(--green);
background: var(--green-bg);
border-radius: 8px;
}
.toast-icon svg {
width: 14px;
height: 14px;
}
.toast-message {
font-size: 11.5px;
font-weight: 700;
}
@keyframes cardIn {
from {
opacity: 0;
transform: translateY(7px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes modalIn {
from {
opacity: 0;
transform: translateY(12px) scale(0.98);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
@media (max-width: 900px) {
.kanban-board {
grid-template-columns: 1fr;
}
.column {
min-height: 0;
}
.card-list {
min-height: 0;
}
.board-heading {
align-items: flex-start;
flex-direction: column;
gap: 20px;
}
.board-toolbar,
.search-control {
width: 100%;
}
.summary-pill {
flex: 0 0 auto;
}
}
@media (max-width: 700px) {
.workspace,
.team-stack {
display: none;
}
.topbar {
gap: 12px;
}
.topbar-actions {
gap: 8px;
}
.board-heading {
margin-top: 28px;
}
.board-toolbar {
gap: 8px;
}
.search-control {
flex: 1 1 auto;
}
.board-footnote {
align-items: flex-start;
flex-direction: column;
gap: 7px;
}
}
@media (max-width: 480px) {
.app-shell {
width: calc(100% - 24px);
padding-top: 14px;
}
.topbar {
min-height: 58px;
}
.brand-mark {
width: 39px;
height: 39px;
border-radius: 12px;
}
.brand-copy span,
.theme-copy,
.add-task-label {
display: none;
}
.theme-button,
.add-task-button {
width: 40px;
padding: 0;
}
.board-heading {
margin: 25px 0 19px;
}
h1 {
font-size: 27px;
}
.heading-copy p {
font-size: 12.5px;
}
.eyebrow {
font-size: 9.5px;
}
.summary-pill {
padding: 0 10px;
font-size: 10.5px;
}
.column {
padding: 12px;
border-radius: 15px;
}
.column-caption {
display: none;
}
.task-card {
padding: 13px;
}
.modal-backdrop {
padding: 10px;
}
.modal {
max-height: calc(100vh - 20px);
border-radius: 17px;
}
.modal-form {
padding: 18px;
}
.modal-description {
display: none;
}
.toast {
right: 12px;
bottom: 12px;
left: 12px;
}
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
scroll-behavior: auto !important;
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
</style>
</head>
<body>
<div class="app-shell">
<header class="topbar">
<div class="topbar-left">
<div class="brand">
<div class="brand-mark" aria-hidden="true">
<svg width="25" height="25" viewBox="0 0 25 25" fill="none">
<rect x="3" y="4" width="8" height="11" rx="2.5" fill="white" fill-opacity=".58"/>
<rect x="14" y="7" width="8" height="14" rx="2.5" fill="white"/>
<path d="M6 8h2M17 11h2M17 15h2" stroke="#6652DE" stroke-width="1.4" stroke-linecap="round"/>
</svg>
</div>
<div class="brand-copy">
<strong>Tasklane</strong>
<span>Product workspace</span>
</div>
</div>
<div class="workspace">
<strong>Acme Studio</strong>
<span class="workspace-plan">PRO</span>
</div>
</div>
<div class="topbar-actions">
<div class="team-stack" aria-label="Three teammates on this board">
<span class="avatar avatar-purple">AM</span>
<span class="avatar avatar-blue">JL</span>
<span class="avatar avatar-peach">RM</span>
<span class="team-more">+2</span>
</div>
<button class="theme-button" id="themeToggle" type="button" aria-label="Switch to dark theme" aria-pressed="false">
<svg class="theme-icon-moon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M21 12.8A8.5 8.5 0 1 1 11.2 3 6.6 6.6 0 0 0 21 12.8Z"/>
</svg>
<svg class="theme-icon-sun" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" aria-hidden="true">
<circle cx="12" cy="12" r="4"/>
<path d="M12 2v2M12 20v2M4.93 4.93l1.42 1.42M17.66 17.66l1.41 1.41M2 12h2M20 12h2M4.93 19.07l1.42-1.42M17.66 6.34l1.41-1.41"/>
</svg>
<span class="theme-copy">Dark</span>
</button>
<button class="add-task-button" id="openAddModal" type="button" data-open-add="todo">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" aria-hidden="true">
<path d="M12 5v14M5 12h14"/>
</svg>
<span class="add-task-label">Add task</span>
</button>
</div>
</header>
<main>
<section class="board-heading" aria-labelledby="boardTitle">
<div class="heading-copy">
<div class="eyebrow"><span class="eyebrow-dot"></span>Q3 launch · Sprint 4</div>
<h1 id="boardTitle">Product launch board</h1>
<p>Plan the work, keep momentum, and ship with confidence.</p>
</div>
<div class="board-toolbar">
<label class="search-control" for="searchInput">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" aria-hidden="true">
<circle cx="11" cy="11" r="7"/>
<path d="m20 20-4-4"/>
</svg>
<input id="searchInput" type="search" placeholder="Search by title…" autocomplete="off">
<button class="clear-search" id="clearSearch" type="button" aria-label="Clear search" hidden>×</button>
</label>
<div class="summary-pill" aria-live="polite">
<span class="live-dot"></span>
<span id="visibleSummary">6 tasks</span>
</div>
</div>
</section>
<section class="kanban-board" id="kanbanBoard" aria-label="Kanban board">
<section class="column" data-column="todo" aria-labelledby="todoHeading">
<header class="column-header">
<div class="column-title-group">
<span class="column-indicator" aria-hidden="true"></span>
<h2 id="todoHeading">To do</h2>
<span class="column-count" id="count-todo" aria-live="polite">2</span>
</div>
<span class="column-caption">Ready to start</span>
</header>
<div class="card-list" data-list="todo" aria-label="To do cards"></div>
<button class="quick-add" type="button" data-open-add="todo">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true">
<path d="M12 5v14M5 12h14"/>
</svg>
Add a task
</button>
</section>
<section class="column" data-column="progress" aria-labelledby="progressHeading">
<header class="column-header">
<div class="column-title-group">
<span class="column-indicator" aria-hidden="true"></span>
<h2 id="progressHeading">In progress</h2>
<span class="column-count" id="count-progress" aria-live="polite">2</span>
</div>
<span class="column-caption">Currently moving</span>
</header>
<div class="card-list" data-list="progress" aria-label="In progress cards"></div>
<button class="quick-add" type="button" data-open-add="progress">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true">
<path d="M12 5v14M5 12h14"/>
</svg>
Add a task
</button>
</section>
<section class="column" data-column="done" aria-labelledby="doneHeading">
<header class="column-header">
<div class="column-title-group">
<span class="column-indicator" aria-hidden="true"></span>
<h2 id="doneHeading">Done</h2>
<span class="column-count" id="count-done" aria-live="polite">2</span>
</div>
<span class="column-caption">Shipped & approved</span>
</header>
<div class="card-list" data-list="done" aria-label="Done cards"></div>
<button class="quick-add" type="button" data-open-add="done">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true">
<path d="M12 5v14M5 12h14"/>
</svg>
Add a task
</button>
</section>
</section>
<div class="board-footnote">
<span>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M9 5h6M9 12h6M9 19h6"/>
<path d="m3 5 2-2 2 2M3 12l2-2 2 2M3 19l2-2 2 2"/>
</svg>
Drag a card to change its status
</span>
<span class="memory-note"><span class="memory-dot"></span>Changes are kept only while this page is open</span>
</div>
</main>
</div>
<div class="modal-backdrop" id="modalBackdrop" hidden>
<section class="modal" id="taskModal" role="dialog" aria-modal="true" aria-labelledby="modalTitle" aria-describedby="modalHelp">
<form class="modal-form" id="addCardForm" novalidate>
<header class="modal-header">
<div class="modal-title-wrap">
<div class="modal-icon" aria-hidden="true">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 5v14M5 12h14"/>
</svg>
</div>
<div>
<h2 id="modalTitle">Create a new task</h2>
<p class="modal-description" id="modalHelp">Add a clear next step for your team.</p>
</div>
</div>
<button class="modal-close" type="button" data-close-modal aria-label="Close dialog">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true">
<path d="m6 6 12 12M18 6 6 18"/>
</svg>
</button>
</header>
<label class="field-label" for="taskTitle">Task title <span class="required-mark">*</span></label>
<input class="title-input" id="taskTitle" name="title" type="text" maxlength="100" placeholder="What needs to be done?" autocomplete="off" required>
<p class="form-error" id="titleError" aria-live="polite" hidden>Please enter a task title.</p>
<fieldset class="field-group">
<legend>Priority</legend>
<div class="priority-options">
<label class="priority-option low">
<input type="radio" name="priority" value="low">
<span><i></i>Low</span>
</label>
<label class="priority-option medium">
<input type="radio" name="priority" value="medium" checked>
<span><i></i>Medium</span>
</label>
<label class="priority-option high">
<input type="radio" name="priority" value="high">
<span><i></i>High</span>
</label>
</div>
</fieldset>
<div class="field-group">
<label class="field-label" for="taskStatus">Status</label>
<select class="status-select" id="taskStatus" name="status">
<option value="todo">To do</option>
<option value="progress">In progress</option>
<option value="done">Done</option>
</select>
</div>
<div class="modal-actions">
<button class="secondary-button" type="button" data-close-modal>Cancel</button>
<button class="primary-button" type="submit">Add task</button>
</div>
</form>
</section>
</div>
<div class="toast" id="toast" role="status" aria-live="polite">
<span class="toast-icon" id="toastIcon" aria-hidden="true"></span>
<span class="toast-message" id="toastMessage"></span>
</div>
<script>
(() => {
"use strict";
const columnLabels = {
todo: "To do",
progress: "In progress",
done: "Done"
};
const state = {
cards: [
{
id: "task-1",
title: "Finalize onboarding flow",
priority: "high",
column: "todo",
category: "UX",
due: "Today",
urgent: true,
assignee: "Ava Mitchell",
initials: "AM",
avatarTone: "purple",
comments: 4
},
{
id: "task-2",
title: "Write launch email sequence",
priority: "medium",
column: "todo",
category: "Marketing",
due: "Sep 18",
urgent: false,
assignee: "Jonah Lee",
initials: "JL",
avatarTone: "blue",
comments: 2
},
{
id: "task-3",
title: "Build analytics dashboard",
priority: "high",
column: "progress",
category: "Development",
due: "Tomorrow",
urgent: true,
assignee: "Rafael Moore",
initials: "RM",
avatarTone: "peach",
comments: 7
},
{
id: "task-4",
title: "Polish mobile navigation",
priority: "medium",
column: "progress",
category: "Design",
due: "Sep 19",
urgent: false,
assignee: "Mia Chen",
initials: "MC",
avatarTone: "green",
comments: 3
},
{
id: "task-5",
title: "Approve brand direction",
priority: "low",
column: "done",
category: "Branding",
due: "Sep 12",
urgent: false,
assignee: "Ava Mitchell",
initials: "AM",
avatarTone: "purple",
comments: 5
},
{
id: "task-6",
title: "Publish customer release notes",
priority: "medium",
column: "done",
category: "Content",
due: "Sep 13",
urgent: false,
assignee: "Jonah Lee",
initials: "JL",
avatarTone: "blue",
comments: 1
}
],
nextId: 7,
query: "",
theme: "light"
};
const board = document.getElementById("kanbanBoard");
const searchInput = document.getElementById("searchInput");
const clearSearch = document.getElementById("clearSearch");
const visibleSummary = document.getElementById("visibleSummary");
const themeToggle = document.getElementById("themeToggle");
const modalBackdrop = document.getElementById("modalBackdrop");
const taskModal = document.getElementById("taskModal");
const addCardForm = document.getElementById("addCardForm");
const titleInput = document.getElementById("taskTitle");
const titleError = document.getElementById("titleError");
const statusSelect = document.getElementById("taskStatus");
const toast = document.getElementById("toast");
const toastIcon = document.getElementById("toastIcon");
const toastMessage = document.getElementById("toastMessage");
const lists = {
todo: document.querySelector('[data-list="todo"]'),
progress: document.querySelector('[data-list="progress"]'),
done: document.querySelector('[data-list="done"]')
};
let draggedId = null;
let draggedElement = null;
let toastTimer = null;
let lastFocusedElement = null;
function escapeHTML(value) {
const entities = {
"&": "&",
"<": "<",
">": ">",
'"': """,
"'": "'"
};
return String(value).replace(/[&<>"']/g, character => entities[character]);
}
function createTaskCard(card, index) {
const priority = ["low", "medium", "high"].includes(card.priority) ? card.priority : "medium";
const priorityLabel = priority.charAt(0).toUpperCase() + priority.slice(1);
const tone = ["purple", "blue", "peach", "green"].includes(card.avatarTone) ? card.avatarTone : "purple";
const wrapper = document.createElement("div");
wrapper.innerHTML = `
<article class="task-card" draggable="true" data-id="${escapeHTML(card.id)}" style="animation-delay:${index * 45}ms">
<div class="card-header">
<span class="priority-badge ${priority}">${priorityLabel}</span>
<div class="card-actions">
<span class="icon-action drag-handle" title="Drag to move" aria-hidden="true">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round">
<circle cx="8" cy="6" r="1" fill="currentColor" stroke="none"/>
<circle cx="16" cy="6" r="1" fill="currentColor" stroke="none"/>
<circle cx="8" cy="12" r="1" fill="currentColor" stroke="none"/>
<circle cx="16" cy="12" r="1" fill="currentColor" stroke="none"/>
<circle cx="8" cy="18" r="1" fill="currentColor" stroke="none"/>
<circle cx="16" cy="18" r="1" fill="currentColor" stroke="none"/>
</svg>
</span>
<button class="icon-action delete-card" type="button" data-delete="${escapeHTML(card.id)}" aria-label="Delete ${escapeHTML(card.title)}" title="Delete task">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M4 7h16M9 7V4h6v3M6.5 7l.8 13h9.4l.8-13M10 11v5M14 11v5"/>
</svg>
</button>
</div>
</div>
<h3>${escapeHTML(card.title)}</h3>
<div class="task-meta">
<span class="category-tag">${escapeHTML(card.category)}</span>
<span class="meta-dot">•</span>
<span class="due-date${card.urgent ? " urgent" : ""}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<rect x="3" y="5" width="18" height="16" rx="2"/>
<path d="M16 3v4M8 3v4M3 10h18"/>
</svg>
${escapeHTML(card.due)}
</span>
</div>
<div class="card-footer">
<div class="assignee">
<span class="avatar avatar-${tone}">${escapeHTML(card.initials)}</span>
<span class="assignee-name">${escapeHTML(card.assignee)}</span>
</div>
<span class="comment-count">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M20 15a4 4 0 0 1-4 4H8l-5 3V7a4 4 0 0 1 4-4h9a4 4 0 0 1 4 4Z"/>
</svg>
${Number(card.comments) || 0}
</span>
</div>
</article>
`;
const element = wrapper.firstElementChild;
attachDragEvents(element);
return element;
}
function renderBoard() {
const normalizedQuery = state.query.trim().toLowerCase();
let totalVisible = 0;
Object.entries(lists).forEach(([columnName, list]) => {
const allCards = state.cards.filter(card => card.column === columnName);
const visibleCards = allCards.filter(card =>
card.title.toLowerCase().includes(normalizedQuery)
);
totalVisible += visibleCards.length;
list.innerHTML = "";
if (visibleCards.length === 0) {
const empty = document.createElement("div");
empty.className = "empty-state";
empty.innerHTML = `
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<circle cx="11" cy="11" r="7"/>
<path d="m20 20-4-4"/>
</svg>
<span>${normalizedQuery ? "No matching tasks" : "No tasks here yet"}</span>
`;
list.appendChild(empty);
} else {
visibleCards.forEach((card, index) => {
list.appendChild(createTaskCard(card, index));
});
}
const count = document.getElementById(`count-${columnName}`);
count.textContent = normalizedQuery
? `${visibleCards.length}/${allCards.length}`
: String(allCards.length);
count.title = normalizedQuery
? `${visibleCards.length} of ${allCards.length} cards match your search`
: `${allCards.length} cards`;
count.setAttribute("aria-label", count.title);
});
visibleSummary.textContent = normalizedQuery
? `${totalVisible} of ${state.cards.length} tasks`
: `${state.cards.length} tasks`;
clearSearch.hidden = !normalizedQuery;
}
function getDragAfterElement(list, pointerY) {
const cards = [...list.querySelectorAll(".task-card")].filter(card =>
card !== draggedElement && card.offsetParent !== null
);
return cards.reduce((closest, card) => {
const box = card.getBoundingClientRect();
const offset = pointerY - box.top - box.height / 2;
if (offset < 0 && offset > closest.offset) {
return { offset, element: card };
}
return closest;
}, { offset: Number.NEGATIVE_INFINITY, element: null }).element;
}
function placeDropIndicator(list, afterElement) {
document.querySelectorAll(".drop-indicator").forEach(indicator => indicator.remove());
document.querySelectorAll(".column.is-drag-target").forEach(column => column.classList.remove("is-drag-target"));
document.querySelectorAll(".card-list.is-over").forEach(cardList => cardList.classList.remove("is-over"));
let indicator = document.createElement("div");
indicator.className = "drop-indicator";
indicator.setAttribute("aria-hidden", "true");
indicator.innerHTML = "<span><em>Drop here</em></span>";
if (afterElement && afterElement.parentElement === list) {
list.insertBefore(indicator, afterElement);
} else {
list.appendChild(indicator);
}
list.classList.add("is-over");
const column = list.closest(".column");
if (column) column.classList.add("is-drag-target");
}
function attachDragEvents(card) {
card.addEventListener("dragstart", event => {
draggedId = card.dataset.id;
draggedElement = card;
event.dataTransfer.effectAllowed = "move";
event.dataTransfer.setData("text/plain", draggedId);
document.body.classList.add("is-dragging-card");
requestAnimationFrame(() => card.classList.add("is-dragging"));
});
card.addEventListener("dragend", cleanupDrag);
}
function cleanupDrag() {
if (draggedElement) draggedElement.classList.remove("is-dragging");
document.querySelectorAll(".drop-indicator").forEach(indicator => indicator.remove());
document.querySelectorAll(".column.is-drag-target").forEach(column => column.classList.remove("is-drag-target"));
document.querySelectorAll(".card-list.is-over").forEach(list => list.classList.remove("is-over"));
document.body.classList.remove("is-dragging-card");
draggedId = null;
draggedElement = null;
}
function moveCard(cardId, targetColumn, afterId) {
const movingCard = state.cards.find(card => card.id === cardId);
if (!movingCard) return;
movingCard.column = targetColumn;
const remaining = state.cards.filter(card => card.id !== cardId);
let insertAt = remaining.length;
if (afterId) {
const afterIndex = remaining.findIndex(card => card.id === afterId);
if (afterIndex >= 0) insertAt = afterIndex;
} else {
for (let index = remaining.length - 1; index >= 0; index -= 1) {
if (remaining[index].column === targetColumn) {
insertAt = index + 1;
break;
}
}
}
remaining.splice(insertAt, 0, movingCard);
state.cards = remaining;
}
Object.values(lists).forEach(list => {
list.addEventListener("dragover", event => {
if (!draggedId) return;
event.preventDefault();
event.dataTransfer.dropEffect = "move";
const afterElement = getDragAfterElement(list, event.clientY);
placeDropIndicator(list, afterElement);
});
list.addEventListener("dragenter", event => {
if (!draggedId) return;
event.preventDefault();
});
list.addEventListener("drop", event => {
event.preventDefault();
const listElement = event.currentTarget;
const targetColumn = listElement.closest(".column").dataset.column;
const indicator = listElement.querySelector(".drop-indicator");
let afterElement = indicator ? indicator.nextElementSibling : null;
while (afterElement && !afterElement.classList.contains("task-card")) {
afterElement = afterElement.nextElementSibling;
}
const movingId = draggedId || event.dataTransfer.getData("text/plain");
const afterId = afterElement ? afterElement.dataset.id : null;
cleanupDrag();
if (movingId) {
moveCard(movingId, targetColumn, afterId);
renderBoard();
showToast(`Task moved to ${columnLabels[targetColumn]}`, "move");
}
});
});
board.addEventListener("click", event => {
const deleteButton = event.target.closest("[data-delete]");
if (!deleteButton) return;
const cardId = deleteButton.dataset.delete;
const card = state.cards.find(item => item.id === cardId);
if (!card) return;
state.cards = state.cards.filter(item => item.id !== cardId);
renderBoard();
showToast(`“${card.title}” deleted`, "delete");
});
searchInput.addEventListener("input", event => {
state.query = event.target.value;
renderBoard();
});
searchInput.addEventListener("keydown", event => {
if (event.key === "Escape" && state.query) {
state.query = "";
searchInput.value = "";
renderBoard();
}
});
clearSearch.addEventListener("click", () => {
state.query = "";
searchInput.value = "";
renderBoard();
searchInput.focus();
});
function applyTheme() {
const isDark = state.theme === "dark";
document.documentElement.setAttribute("data-theme", state.theme);
themeToggle.setAttribute("aria-pressed", String(isDark));
themeToggle.setAttribute("aria-label", isDark ? "Switch to light theme" : "Switch to dark theme");
themeToggle.querySelector(".theme-copy").textContent = isDark ? "Light" : "Dark";
}
themeToggle.addEventListener("click", () => {
state.theme = state.theme === "light" ? "dark" : "light";
applyTheme();
});
function openModal(column = "todo") {
lastFocusedElement = document.activeElement;
addCardForm.reset();
statusSelect.value = columnLabels[column] ? column : "todo";
titleInput.value = "";
titleInput.setAttribute("aria-invalid", "false");
titleError.hidden = true;
modalBackdrop.hidden = false;
document.body.style.overflow = "hidden";
requestAnimationFrame(() => titleInput.focus());
}
function closeModal() {
if (modalBackdrop.hidden) return;
modalBackdrop.hidden = true;
document.body.style.overflow = "";
if (lastFocusedElement && typeof lastFocusedElement.focus === "function") {
lastFocusedElement.focus();
}
}
document.querySelectorAll("[data-open-add]").forEach(button => {
button.addEventListener("click", () => openModal(button.dataset.openAdd));
});
document.querySelectorAll("[data-close-modal]").forEach(button => {
button.addEventListener("click", closeModal);
});
modalBackdrop.addEventListener("click", event => {
if (event.target === modalBackdrop) closeModal();
});
document.addEventListener("keydown", event => {
if (event.key === "Escape" && !modalBackdrop.hidden) closeModal();
});
taskModal.addEventListener("keydown", event => {
if (event.key !== "Tab") return;
const focusable = [...taskModal.querySelectorAll(
'button:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])'
)].filter(element => element.offsetParent !== null);
if (!focusable.length) return;
const first = focusable[0];
const last = focusable[focusable.length - 1];
if (event.shiftKey && document.activeElement === first) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault();
first.focus();
}
});
addCardForm.addEventListener("submit", event => {
event.preventDefault();
const title = titleInput.value.trim();
if (!title) {
titleError.hidden = false;
titleInput.setAttribute("aria-invalid", "true");
titleInput.focus();
return;
}
const selectedPriority = addCardForm.querySelector('input[name="priority"]:checked');
const selectedStatus = statusSelect.value;
const newCard = {
id: `task-${Date.now()}-${state.nextId++}`,
title,
priority: selectedPriority ? selectedPriority.value : "medium",
column: columnLabels[selectedStatus] ? selectedStatus : "todo",
category: "General",
due: "No date",
urgent: false,
assignee: "You",
initials: "ME",
avatarTone: "purple",
comments: 0
};
state.cards.push(newCard);
renderBoard();
closeModal();
showToast(`Task added to ${columnLabels[newCard.column]}`, "add");
});
function showToast(message, type = "add") {
clearTimeout(toastTimer);
toastMessage.textContent = message;
const icons = {
add: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="m5 12 4 4L19 6"/></svg>',
move: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 9h13M14 5l4 4-4 4M19 15H6M10 11l-4 4 4 4"/></svg>',
delete: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><path d="M4 7h16M9 7V4h6v3M6.5 7l.8 13h9.4l.8-13"/></svg>'
};
toastIcon.innerHTML = icons[type] || icons.add;
toast.classList.add("show");
toastTimer = setTimeout(() => toast.classList.remove("show"), 2600);
}
renderBoard();
applyTheme();
})();
</script>
</body>
</html>Reasoning trace (15,985 tokens)
We need produce exactly one fenced html code. Need build polished interactive Kanban. Need reason all requirements, first screenshot alive. Need self contained. Need no external. 1280x800 adapt 375 no horiz scrollbar. Need perhaps attractive design.
We need output only code block. Need design.
Let's formulate HTML full. Three columns counts. Six realistic cards spread maybe 3 todo, 2 progress, 1 done. Add modal title required. Delete button. Drag mouse HTML5 drag events, clear visual drop indicator. Search live title. Theme toggle. memory only.
Need page look good. We can have sidebar? Requirement board likely title toolbar. We can make full app, perhaps sidebar brand/navigation would look nice but mobile adapt. But no need overcomplicate. Build Kanban "Launchpad" with gradient/light warm backdrop, header. At 1280, board three columns. At 375, perhaps horizontal column scrolling forbidden no horizontal scrollbar (we can make vertical stacking under breakpoint). Need responsive.
First screenshot about 3 sec. No auto movement needed. "alive" means prepopulated. Could have activity avatars, due dates. Requirement each card title priority. Extra metadata okay.
Let's design:
Background #f5f7fb maybe pale. Top app shell max width 1440, margin.
Header:
brand icon rounded purple with stacked rectangles, "Flowboard" / Workspace.
Maybe nav breadcrumb "Product / Q3 Launch"
Right theme button, add button. At small header stacks.
Hero/title row:
h1 Product launch board, subtitle. Stats "6 tasks • Updated just now"
Search control, filter perhaps only search and theme.
Need ensure all clear.
Board columns grid 3 equal. Column headers colored dots, counts.
Column containers with background #eef1f6; card white.
To do 3 cards:
"Finalize onboarding flow" high (tag orange/red)
"Write launch email sequence" medium (amber)
"Gather customer testimonials" low (green?)
In progress 2:
"Build analytics dashboard" high
"Polish mobile navigation" medium
Done 1:
"Approve brand direction" low perhaps. Counts 3/2/1.
Cards:
Priority badge with small colored dot and text.
title.
Maybe category labels and due metadata. Use avatars initials.
Delete button top right with trash icon, aria-label, appears always maybe opacity on hover but should clear; show at all. Need no external icon, use inline SVG.
Drag handle.
drop indicator. HTML5 drag:
cards draggable=true. on dragstart add dragging class, store card id. Each column list dragover preventDefault, compute insertion indicator. Could style .drop-indicator horizontal 3px line inserted before card. "clear visual drop indicator." Use actual div with text maybe line and circle? Need robust. On dragover, determine closest card after pointer, indicator inserted before card, else append. Column .drag-over. On drop, move card, render. Need avoid re-render dragging causing event weird. Use DOM insertion. Since card data in state. We can keep DOM. Render all initial via renderBoard. During dragstart set dataTransfer. dragend cleanup.
Search filters live by title: hide cards not matching; counts should likely reflect total or visible? Requirement each showing card count ambiguous. Could count visible filtered? Better counts visible with "(x of total)" perhaps. Header count 3 etc. Search live title. We can show count currently visible, and if filtering set `1 / 3` maybe each count badge. The requirement count. Use visible count perhaps. Need likely intuitive. Column count updates to filtered count if searching; can label "3 cards" and if filtered "1 of 3". But visual.
Add modal small form title required. Also perhaps priority select and column select, useful. Requirement title required. Modal with overlay, close, escape. Add button. title input. Priority default medium. Column default todo. On submit if trim empty, show error and focus. Submit button. Need focus management. Click outside closes. no storage.
Theme toggle. HTML root data-theme. in-memory variable, button toggles icon/label. default maybe light. Need polished. At 1280x800 board cards fit. Let's calculate. Header ~80, board title row 100, columns perhaps 600. Three todo cards each ~145 =435 + header 60 =495. Fine. Cards around 140. At 800 bottom maybe 840. Could page scroll vertical okay. Ensure screenshot shows all. top padding 24, header 64, title 90 => 178, columns 570 => 748. Fine. Use max. Body.
Perhaps add decorative background radial. No external fonts; use system stack. Need no horizontal overflow.
Detailed CSS:
:root --bg #f7f8fc; --surface #fff; --surface-2 #f1f3f8; --text #172033; --muted; --border; --accent #6c5ce7; etc.
[data-theme=dark] colors.
body min-height 100vh, overflow-x hidden.
.app-shell max-width 1440 width calc(100%-48px); margin auto.
.topbar height maybe 72 display flex justify. Brand.
logo 42.
workspace switcher? perhaps text.
.topbar-actions theme icon and add.
Mobile @max 700 width calc -24.
.board-intro display flex; .eyebrow uppercase.
controls row search and view switcher? Search. Could use header controls includes search + add. But mobile.
Let's plan exact HTML:
<div class="app-shell">
<header class="topbar">
<a class=brand href="#" aria-label> logo svg </a>
<div class="brand-copy><strong>Taskly</strong><span>Product workspace</span></div>
<div class="topbar-actions>
<button class="icon-button theme-toggle" id themeToggle aria-label="Switch to dark theme" title...>
svg sun/moon both via CSS?
</button>
<button class="add-button" id openModal><svg> plus</svg> Add task</button>
</div>
</header>
<main>
<section class=board-heading>
<div>
<div class=eyebrow><span class pulse></span> Q3 Product Launch</div>
<h1>Make progress, one card at a time.</h1>
<p>Keep the team aligned...</p>
</div>
<div class=board-tools>
<label class=search-box> SVG search <input id=searchInput placeholder="Search tasks..."><kbd>⌘ K</kbd> maybe no keyboard since iframe only receives after click, but search can click. The kbd isn't functional unless implement keydown but iframe keyboard after click. We can implement "/" maybe no. Better no fake.
<button class=filter? no.
</div>
</section>
<section class=summary-row maybe three metric mini? But columns already. Could include "6 tasks / 2 in progress". At 800 maybe. Keep.
<div class=board id board>
<section class="column" data-column="todo">
<div class column-head>
<div class title-wrap><span class status-icon todo icon check? SVG> </span><h2>To do</h2><span class count id count-todo>3</span></div>
<span class=column-kicker>Up next</span>
</div>
<div class=card-list data-list="todo" aria-label> ... cards generated JS
</div>
<button class=quick-add data-add-column=todo> + Add a task </button>
</section>
...
</div>
</main>
<footer maybe "Drag cards to update status • Changes last for this session". Could clutter. maybe no.
</div>
Modal:
<div class=modal-backdrop id modal hidden>
<div class=modal role=dialog ...>
<form id addCardForm>
header, close
label Task title input required maxlength 80
error
two fields Priority select, Status select
footer cancel Add task
</form>
</div>
Toast for added/deleted? Could add a subtle toast visible on interactions, not required. Nice. Use aria-live. Add card animation. Deleting card immediately. Maybe undo? no.
Cards generated via JS templates. Could hardcode initial cards and JS create. Need make sure if JS executes first paint, yes. We can have static fallback? First screenshot after 3 seconds so JS fine. Still zero errors.
State:
const initialCards = [
{id:'c1', title:'Finalize onboarding flow', priority:'high', column:'todo', tag:'UX', due:'Today', dueTone:'urgent', assignee:'AM', assigneeName:'Ava Mitchell', comments:4},
{id:'c2', title:'Write launch email sequence', priority:'medium', column:'todo', tag:'Content', due:'Sep 18', assignee:'JL', ...},
{id:'c3', title:'Gather customer testimonials', priority:'low', column:'todo', tag:'Research', due:'Sep 20', assignee:'NK'},
{id c4 ... progress tag Development due 'Sep 16' priority high assignee 'RM'},
...
]
No current dates maybe avoid stale. Could label due "Today", "Tomorrow", "Sep 20". Fine.
Columns metadata. Use safe escapeHTML for input. We control titles but add user title so need escape. Better create DOM via template with escape function. Need no XSS. We can use HTML string and escape. IDs fixed. Add card id crypto? `card-${Date.now()}` safe. Data id. Use textContent perhaps but template fine with escape. Define `escapeHTML(str)` replacements.
Card structure:
<article class="task-card priority-high" draggable="true" data-id="">
<div class=card-top>
<span class=priority-badge ...><span></span> High</span>
<div class=card-actions>
<button class=drag-handle aria-label="Drag..." title...> svg grip maybe cursor grab </button>
<button class=delete-card aria-label="Delete task" title="Delete task"> trash </button>
</div>
</div>
<h3>...
<div class=task-meta><span class=task-tag>UX</span><span class=meta-divider></span><span class=due><svg calendar> Sep 18</span></div>
<footer>
<div class=avatar style maybe color data. initials</div>
<div class=assignee-copy><strong>Ava Mitchell</strong><span>Design</span></div>
<div class=comment><svg>4</div>
</footer>
</article>
Need card count and visible count.
Maybe card is 157. Six cards. At 1280 board width 1232, each col 393, card 200? Height. Board heading 110; topbar 74; total 24+74+32+110+22 =262, columns cards: header 56 + three * (about 160 + gap 12)=596 -> total 858, screenshot 800 cuts bottom. Need reduce. Let's estimate top:
body padding? app shell margin top 22. topbar 62. margin bottom 30. main heading h1 etc 94. margin 24. total 232. column header 48 + padding 12, card 136*3=408+20, quick add 30 -> 530 => total 762. Good. Cards should 132.
Card padding 15, top badge row 22, title 38, meta 22, footer 30 =127 plus padding 30 =? box sizing border-box, content totals: top 22 + margin 9 + title 38 + meta 20 + footer 30 + spaces =119 + padding 28 =147. 3 =>441. column 500. total 730. Fine.
At 1280×800, all visible. Maybe use 2 todo? But requirement 6 spread; can spread 2/2/2! Better three columns each 2 cards, more balanced and all visible. User says 6 realistic spread. Use 2 each. Counts 2,2,2. "Alive". But maybe counts 3/2/1 acceptable. At 1280 board all visible regardless. Balanced 2 each looks good, less clutter. Could do 3 todo and others 2/1 creates difference and visible. Kanban typical. At 800 if quick bottom not visible maybe okay. Better 2 each ensures. Prepopulate six:
todo:
Finalize onboarding flow high
Write launch email sequence medium
progress:
Build analytics dashboard high
Polish mobile navigation medium
done:
Approve brand direction low
Publish release notes low? Done two, realistic. Priority perhaps low.
Could make done "Ship landing page" medium. But done likely title.
Let's use 2 each. Initial visible counts all 2.
Design aesthetic perhaps white app on lavender #f5f4fa, accent violet. Dark theme fully adapted.
Need clear drop indicator:
.drag-over .card-list { background ... outline 2px dashed var accent; border-radius }
.drop-indicator {height: 4px; background: accent; border-radius; margin:2px 0; position:relative; box-shadow}
.drop-indicator::before { content:''; position absolute; left:-3; top:-3; width:10;height:10;border-radius:50%;background}
Perhaps label? Clear visual line and column highlight. We can insert `<div class=drop-indicator><span>Drop here</span></div>` but text impacts. At drag only. Do that:
<div class="drop-indicator" aria-hidden=true><span>Drop here</span></div>, CSS height 3; background accent, span position absolute top -20 etc. But near bottom maybe. Clear enough. On dragover column add drag-over; indicator line.
HTML5 drag events:
let draggedCard = null;
let dragOverColumn = null;
card.addEventListener('dragstart', e=> {
draggedCard=card;
e.dataTransfer.effectAllowed='move';
e.dataTransfer.setData('text/plain', card.dataset.id);
requestAnimationFrame(()=>card.classList.add('is-dragging'));
});
card.addEventListener('dragend', cleanup)
list.addEventListener('dragover', e => {
if(!draggedCard)return;
e.preventDefault(); e.dataTransfer.dropEffect='move';
const after=getDragAfterElement(list,e.clientY);
// remove other indicators
document.querySelectorAll('.drop-indicator').remove()
// column class
const indicator=create...
if(after) list.insertBefore(indicator,after); else list.appendChild(indicator)
});
But getDragAfterElement must ignore indicator and hidden cards. `const cards=[...list.querySelectorAll('.task-card:not(.is-dragging)')].filter(c=>c.offsetParent!==null); return cards.reduce...`. Because drag element maybe still occupies space. Fine.
Drop: e.preventDefault; const targetColumn=list.closest('.column').dataset.column; const after=indicator.nextElementSibling? But DOM indicator inserted before `after`; determine card to insert. We could update state order array. State cards array order is global; better maintain `state.cards` and column property only; order matters within columns. initial order. On drop, target id after card or before card. Need implement robust:
on dragover, compute `afterElement`; indicator before after.
on drop:
const afterElement = indicator.nextElementSibling matching `.task-card` (if indicator appended at end next null)
const moved = state.cards.find id
moved.column=targetColumn
if afterElement:
const afterId=afterElement.dataset.id
const idx=state.cards.findIndex(c=>c.id===afterId)
state.cards.splice(idx,0,moved) but if moved was before it in state array, moving can weird; remove first then find. `state.cards = state.cards.filter(c=>c.id!==moved.id); const idx=...; state.cards.splice(idx,0,moved)` causes global array sequence and preserving column ordering relative to cards across columns? state global order used by render filtering by column retains relative global. If moving to another col at position before after, okay. But insertion into global at after's index can interleave. Since render groups by column according to global relative, fine. If append target, need insert after last card of target column in global order, not just end? Could find cards of target and index after last. If no target cards, index target start maybe. Since columns fixed and render groups based on state array, appending at global end means in target after any other target (none other between) okay, but cards in later columns can then follow target; relative target order unaffected. So `state.cards.push(moved)` means target's last among target, yes.
If before after: remove moved; afterIdx in state; splice(afterIdx,0,moved) => inserted directly before after globally. If after card is target. fine.
Else append global end.
Then renderBoard(); show toast.
But renderBoard recreates cards. Dragend maybe event from old card after drop and cleanup; no issue.
Could simplify with card DOM move and then sync state based order. But search filtered and order. state array enough.
`getDragAfterElement` likely uses bounding rect:
return cards.reduce((closest, child) => {const box=child.getBoundingClientRect(); const offset=e.clientY-box.top-box.height/2; if(offset<0 && offset>closest.offset) return {offset,element:child}; return closest}, {offset:Number.NEGATIVE_INFINITY,element:null}).element.
Need only cards visible (offsetParent).
Dragging with search active? hidden cards excluded. Fine.
Mobile no drag requirement only mouse.
Search:
let query = '';
function renderBoard() {
board.querySelectorAll('.card-list').forEach(list => list.innerHTML='')
const normalized=query...
for each cards by column:
const matching=...
create card and append.
update counts: visible count / total. `count.textContent = visible===total ? total : `${visible}/${total}`;` requirement card count. Tooltip title `X of Y cards`.
maybe board result status `Showing 4 of 6 tasks`.
Empty state if no matching cards within col:
<div class=empty-column> <svg> <span>No matching tasks</span></div>
Could hide quick add? no.
counts reflect filtered. If no query, 2.
Search input listener renderBoard. This rebuilds, focus remains input since list only.
Perhaps use CSS animation.
Need delete button click. Event delegation board `click` if closest .delete-card. Add card remove state, render, toast with maybe action no undo. Requirement delete. Could implement undo? Not needed. But nice toast "Task deleted" and undo button maybe complexity. If add undo can restore. But delete must work. Could include confirmation? No. Better immediate with toast.
Modal:
openModal(column='todo') set select value; modal.hidden=false; document.body.classList.add('modal-open'); setTimeout input.focus. Since screenshot initial modal closed. Use `hidden` CSS ensure `[hidden]{display:none!important}`.
closeModal remove.
Form submit:
e.preventDefault
const title=input.value.trim()
if !title { error hidden false; aria invalid true; return}
const card = { id: `task-${Date.now()}-${counter++}`, title, priority, column, category:'New task', due:'No due date', assignee:'ME', assigneeName:'You', comments:0}
state.cards.push(card) but column grouping based global order. If select progress, append global at end means relative progress last, yes.
render, close, show toast.
Need set initial default maybe priority medium, status todo.
Modal column names.
Need theme toggle:
let theme='light'; button click toggles. Set document.documentElement.dataset.theme=theme. Update aria label and maybe inner HTML? Could have two icons displayed CSS:
<button class theme-toggle><span class=icon-wrap><svg class=sun>...<svg class=moon>...</span></button>. CSS in light show moon? Button indicates action? Typically toggle icon displays moon in light and sun in dark. Use `.theme-icon-moon {display:block}`, dark reverse. aria label update.
Could also show tooltip via span `.theme-label` desktop "Dark mode", updates "Light mode". Topbar button width maybe 110. At mobile hide label maybe.
CSS dark:
--bg #11131a; --surface #1b1e27; --surface-2 #222630; --soft #171a22; --text #f5f7fb; --muted #969eae; --border #303541; --accent #9b8cff; --accent-strong #7c6cf0; shadows.
Body background radial gradients.
Cards dark #1d2029.
columns #171a22.
modal etc.
Need polished perhaps include topbar "Acme Inc." avatar. Let's design.
Topbar:
<div class brand>
logo (stacked tiles)
<div><strong>Kanvas</strong><span>Product team</span></div>
</div>
<div class topbar-context>
maybe status pill <span dot> 3 teammates online
</div>
actions.
At 1280, context centered. At mobile hide context.
Could include avatar stack. Nice but not required. Maybe right "Invite" button? Adds clutter. Keep team avatars in header. Topbar:
brand left
div workspace-chip "Acme Studio / Marketing" maybe.
right avatar stack, theme, Add task. The board heading below.
At 375:
.app-shell width calc(100%-24px), topbar 62.
brand subtitle hide. topbar context hide. theme button 40. Add button text perhaps hide? Use `.add-label` hide under 480; button square 42. Board heading stacks, h1 28, controls full.
Columns stack; card-list min-height maybe. body vertical. no horizontal.
Search width full.
Board intro:
eyebrow
h1 "Product launch"
p "A clear view of what’s moving, what’s next, and what’s done."
Maybe board heading should feel professional, not marketing. Include an `updated` info.
Board tools search and `div board-summary`:
span live-dot "Live board"
strong id visibleSummary "6 tasks"
Could be.
Let's create:
<section class=board-heading>
<div>
<div class=eyebrow><span class=eyebrow-dot></span> Q3 launch · Sprint 4</div>
<h1>Product launch board</h1>
<p>Plan the work, keep momentum, and ship with confidence.</p>
</div>
<div class=board-toolbar>
<div class=search-control>...
<div class=view-chip><svg maybe users> <span id=visibleSummary>6 tasks</span></div>
</div>
</section>
Maybe a progress strip? no.
Columns:
Header title + count badge. subtitle "2 tasks · 1 high"? We can use .column-caption. To do "Ready to start", In progress "Currently moving", Done "Shipped & approved". Use accent line on top.
Column body has subtle background.
Add task link bottom.
Card details:
Priority badge. Color palette:
high = coral (#e85d68; bg #fff0f1)
medium = amber (#d98a00; bg #fff7e6)
low = green/teal (#2e9c78; bg #eaf8f2)
Dark variants with alpha.
Category chip.
Due date with small icon; due today coral.
Footer avatar and name? At card width 370, can show name and comments. Card title.
Maybe avatar only and comments; footer:
<div class=person><span class=avatar color>AM</span><span class=person-name>Ava Mitchell</span></div><span class=comment-count>...
At mobile fine.
Card top badge and actions. Delete icon and drag grip.
Use `button.delete-card` visible subtle. drag grip is span not button (not accessible) maybe `<span class=drag-handle title="Drag to move">` with SVG. Keep delete button.
Potential issue `<button>` default type inside no form (cards in main), okay.
Accessibility: aria-live counts, modal.
No storage.
Animations: card enter with stagger. Since render on search, animate. CSS @keyframes cardIn. Respect reduced motion.
First paint: no preloader. Cards instantly. We can maybe set `opacity` animate 0.3s. Screenshot at 3 sec all done.
Need perhaps implement pointer dragging? HTML5 DnD works mouse desktop. Requirement mouse. HTML5 is fine. Clear drop. Safari? Chrome. Need dataTransfer.
Potential issue dragging only starts on article due children button? draggable true, clicking button might initiate? Fine. Set `draggable=true` on article, buttons clickable. `dragstart` works.
Could set drag handle only via `card.draggable=true`; okay.
Drag indicators:
Each list listener.
When dragover called, remove old indicator each time and recreate causing flicker. Could reuse. Better `showDropIndicator(list, after)`:
document.querySelectorAll('.drop-indicator').forEach remove
const ind=document.createElement('div'); ...
list.insertBefore(ind,after)
Each dragover recreate but smooth enough. Could position. Column list style `.is-over` maybe.
`dragenter` maybe highlight. `dragleave` tricky children. On dragover add .is-over.
drop gets indicator from list. If no indicator because event? ensure.
`dragend` remove.
Could add `dragenter` to prevent default and .is-over.
When moving across columns, dragged card still physically in source. Indicator target appears. Drop then state.
The column's `quick-add` sits outside list. drop target list only; could also column dragover. But cards area enough. We can attach to `.column` and use list. Yet indicator list. HTML events bubble. Use list.
Drop indicator clear: line, dashed outline around list. Include text "Release to move" with pseudo.
CSS:
.drop-indicator {height:4px; margin:5px 3px; border-radius:4px; background:var(--accent); box-shadow:0 0 0 3px color-mix(...)}
@supports not color-mix maybe use rgba.
.drop-indicator::after {content:'Drop here'; position:absolute; left:50%; top:-23px; transform...; font-size:10; ...}. But list overflow visible. The text may overlay previous card. Fine. Alternatively show in column header via pseudo. We can add `.column.drag-target .column-title::after { content:'Drop here'; ...}` but explicit indicator line enough.
Could have indicator itself 32px high with center line:
<div class=drop-indicator><span></span></div>, CSS height 36, display flex align center; span width 100%; height 3; background accent; box-shadow. ::before circle. This clearly visible but adds vertical space. Good.
`aria-hidden`.
Search:
Cards hidden by not rendered. Empty column placeholders. Column counts visible total. `visibleSummary`.
When query yields no cards globally, all 3 empty states, visible summary "0 of 6 tasks". Fine.
Maybe no need to show empty state for each column if no result? yes.
Counts exact:
`countEl.textContent = visibleCount;`
If search active and total != visible, count badge `${visibleCount}/${total}`. The requirement each showing its card count; 1/2 is clear. Add title "1 of 2 cards match". When no search 2.
Column meta perhaps dynamic "2 tasks" no need.
Add modal can select column. Quick-add buttons open modal set status. Button after column list.
Modal `small` 460 width.
Backdrop blur.
Title label + required star.
Inputs.
Custom select with standard.
Error area.
Maybe priority radio pills rather than select for visual and easier. Requirement small modal. Use priority segmented radio:
<fieldset class=priority-field><legend>Priority</legend>
<div class=priority-options>
<label><input type=radio name=priority value=low checked?><span>Low</span></label> ...
</div>
</fieldset>
Default medium.
Status select.
No huge.
Modal title: "Create a new task"
Subtitle "Add it to the board and keep momentum going."
Form:
label
input
fieldset
select
error
buttons.
Focus title.
On close, reset? title blank and medium etc each open. If reopening after error, reset maybe.
openModal(column) form.reset(); select column; default medium checked. Since form reset should restore. Error hidden. Set input aria-invalid false.
Close via cancel, X, overlay.
Escape key on document. Since iframe only keyboard after click but modal opens by click then key works.
Focus trap? Nice but not necessary. Could implement to avoid tab escaping. We can do simple.
`document.addEventListener('keydown', e => {if(e.key==='Escape'&&!modal.hidden)close; if((meta/ctrl+k)&&!modal.hidden) ... focus search;})`. Keyboard message requirement? It says if demo uses keyboard, display clear "Click or press a key to start." This is not a game and keyboard is optional. We don't need global keyboard except Escape and ctrl+k. Does this trigger requirement? It says iframe only receives keyboard events after user clicks it: if demo uses keyboard, display message. We could avoid keyboard shortcuts except Escape, standard form keyboard after click. No start message needed for app. Don't implement ctrl+k maybe. Use only Escape. The app doesn't need keyboard globally. Good.
Toast:
<div id=toast class=toast role=status aria-live=polite><span class=toast-icon>...</span><span id=toastMessage>Task added</span></div>
At bottom right. hidden via class show. JS timer. No issue. Add maybe check icon.
For delete show "Task deleted". Could use X icon.
Confirmation deletion? Clicking trash immediate. Fine.
Need perhaps card count on column updated. yes.
Potential security no storage.
Let's think visual screenshot. Header and board likely nice. Need perhaps no heavy vertical overflow. We can inspect CSS dimensions.
HTML:
<body>
<div class=app-shell>
<header class=topbar> ...
</header>
<main>
<section class=board-heading> ...
</section>
<section class=kanban-board aria-label...>
column ...
</section>
</main>
</div>
modal...
toast.
At 1280, app-shell width min(1400, calc(100% - 64px)) =1216. column gap 18, each 393.
Topbar height 72 margin maybe 0 0 36. app shell padding-top 22. Board heading margin-bottom 26.
Column:
background var(column); border; border-radius 18; padding 14.
column head 46.
card list gap 12.
card estimated 154. 2 =>320 + gap. quick add 36. col total 14+46+12+320+10+36+14=452.
Top total: 22 +72+34+ maybe board heading 100 +26 +452 =706. Great. All fits in 800. Add footer tiny at bottom maybe 24.
Maybe include bottom hint bar in board heading? no.
Cards:
`task-card {background:var(--card); border:1px solid; border-radius:14px; padding:14px 15px 12px; box-shadow; cursor:grab; position:relative; transition...}`
top row 24.
h3 font 14.5 line 1.4, min-height maybe no to uniform? Two line titles can increase. Keep titles one line mostly at 360 width:
"Finalize onboarding flow" 190px
"Write launch email sequence" 220
"Build analytics dashboard" 200
"Polish mobile navigation" 205
"Approve brand direction" 190
"Publish customer release notes" 245.
One line all at 360 width. Good.
meta row and footer. 130 height.
Two each total 272.
Board only ~400, screenshot large empty lower? At 800 body 700. Actually top 180 + board 420 =600. Could center more? We can make cards 150 and column min-height 500. Three columns fill. Kanban often tall. `.kanban-board{align-items:stretch}` column min-height: 520px; card-list flex:1`. Quick add bottom. Then board from y230 to750. Perfect. At 1280×800 all column bottom visible around 760. Cards at top and empty lower. Looks balanced. At 800 body 700. Actually top 180 + board 420 =600. Could center more? We can make cards 150 and column min-height 500. Three columns fill. Kanban often tall. `.kanban-board{align-items:stretch}` column min-height: 520px; card-list flex:1`. Quick add bottom. Then board from y230 to750. Perfect. At 1280×800 all column bottom visible around 760. Cards at top and empty lower. Looks balanced. At mobile stacked, min-height auto.
App max-width perhaps 1360.
Header logo:
`brand-mark` 40x40, dark/accent gradient, shadow, SVG white.
Brand text "Flowdeck" perhaps. Choose "Taskflow". Need realistic. Let's call "Northstar" maybe. App brand "Kanso"? User asked Kanban no branding. "Planora". Use "Tasklane". Let's pick "Kanso" maybe elegant. "Tasklane" clear. Logo tiles.
Product workspace subtitle.
Topbar middle breadcrumb:
<span class=topbar-divider></span>
<span class=workspace-name>Acme product</span><span class=workspace-plan>PRO</span>.
Right avatars:
<div class=avatar-stack aria-label=3 teammates><span class=avatar a1>AM</span>... plus count.</div>
then theme.
Add task accent.
At mobile hide workspace and avatars, add text maybe.
Heading eyebrow chip with dot.
h1 32px.
Toolbar search width 290 and `view-status` pill:
green dot "Live board". `visibleSummary` maybe in board heading under? Could show on search. Use summary pill.
`board-toolbar`: search control and result-pill. At small width, both maybe stacked? 375 content width351; search full and summary small. Could row with search flex1 and pill. search placeholder may fit. At max480, toolbar width100%, result pill maybe hide text? Could keep 82+230=320. Fine.
Search control height44, white, border, focus accent.
Input min-width 0.
Column header:
<div class column-head>
<div class column-title-group><span class column-symbol> maybe icon:
To do circle with dashed? Use custom CSS:
<span class="column-indicator todo"></span>
<h2>To do</h2>
<span class=count-badge id=count-todo>2</span>
</div>
<span class=column-status>Up next</span>
</div>
Could put caption below with title? Header height 48.
CSS indicator:
todo background #8b95a7; box-shadow 0 0 0 4 rgba...
progress #f0a12f with maybe striped
done #36ad7b
Add colored vertical tiny.
Maybe count badge tinted per column.
Quick add button dashed? `background:transparent;border:1px dashed var(--border-strong);` hover. Text plus.
Card category etc.
Priority badge uppercase maybe:
<span class="priority-badge high"><svg? no> <span class=priority-dot></span> High</span>.
Colors.
Avatar colors data attributes:
avatar-purple, blue, peach, green.
Could inline class `avatar avatar-purple`.
Icons inline SVG, no external. Need many repeated. We can define card template with SVG strings.
Escape:
function escapeHTML(value) {
return String(value).replace(/[&<>"']/g, char => map[char]);
}
Also priority safe from select but validate.
Use `data-id="${escapeHTML(card.id)}"`.
Assignee initials from card.avatar. safe.
Initial state with category, due, avatar, avatarTone, comments:
todo:
1 Finalize onboarding flow, high, UX, Today, Ava Mitchell, AM, violet, 4
2 Write launch email sequence, medium, Marketing, Sep 18, Jonah Lee, JL, blue, 2
progress:
3 Build analytics dashboard, high, Development, Tomorrow, Rafael Moore, RM, orange, 7
4 Polish mobile navigation, medium, Design, Sep 19, Mia Chen, MC, green, 3
done:
5 Approve brand direction, low, Branding, Sep 12, Ava, AM, violet, 5
6 Publish customer release notes, medium, Content, Sep 13, Jonah, JL, blue, 0
Could use due "Sep 20". Fine.
Counts 2 each.
Summary "6 tasks · 2 active" maybe dynamic.
Column cards spread. Good.
Modal selection options To do, In progress, Done.
Priority radio.
Theme:
root data-theme maybe `data-theme="light"` set initial HTML. dark media? Explicit toggle only. Respect system? Could initialize based on matchMedia perhaps screenshot could be dark depending sandbox? likely light. Use light default. Theme state memory. Toggle.
CSS transitions maybe body background .25.
Use icon:
button id themeToggle class theme-button aria-label="Switch to dark theme"
<span class=theme-copy>Dark</span>
<svg class=theme-icon theme-moon>...
In dark show sun and copy Light.
At mobile hide theme-copy, button square.
Could include `aria-pressed`? set.
`setTheme`:
document.documentElement.setAttribute('data-theme', next)
themeToggle.querySelector('.theme-copy').textContent = next === 'dark' ? 'Light' : 'Dark'
themeToggle.setAttribute('aria-label', next==='dark'?'Switch to light theme':'Switch to dark theme')
themeToggle.setAttribute('aria-pressed', String(next==='dark'))
No local storage.
Render board:
const columnMap = {todo: {label...}}.
for each column:
const columnCards = state.cards.filter(c=>c.column===key)
const visible = columnCards.filter(matches)
list.innerHTML=''
visible.forEach((card,index)=> { const el=createTaskCard(card,index); list.appendChild(el) })
if visible.length===0 list.appendChild(empty)
count.textContent = query ? `${visible.length}/${columnCards.length}` : visible.length
count.setAttribute('aria-label', ...)
maybe `.column-count` style.
resultSummary.textContent = query ? `${visibleTotal} of ${state.cards.length} tasks` : `${state.cards.length} tasks`
resultLabel maybe if no query "6 tasks".
search clear button perhaps appears. Add `<button class=clear-search id=clearSearch aria-label=Clear search>×</button>` hidden. CSS display based `.has-query`. Could implement. Search control width. Clear useful.
Search placeholder "Search by title…".
Live filtering via input.
Need avoid emoji? no.
Card creation:
article.setAttribute('tabindex','0')? Keyboard moving not required. Drag handle visible. Could make card not tabindex. Delete accessible.
Drag handle maybe button? `span aria-hidden`.
Title and metadata.
`card.style.setProperty('--delay', `${index*45}ms`);`
Animation after each render. On drag start after animation no issue.
Delete event:
`board.addEventListener('click', e=> { const btn=e.target.closest('[data-delete]'); if btn ...; const card=...; state.cards=...; renderBoard(); announce...; })`.
The trash icon path. Button title.
Could display toast with an undo button? Might be nice but requirement immediate. If include undo, deletion still works but user could restore. Maybe simple toast only.
Could animate removal before render? no.
Quick add event.
Open add button.
Toast:
let toastTimer
showToast(message,type='success') { toastMessage.textContent=; toast.classList.add('show'); clearTimeout...}
icon changes check vs trash maybe fixed check. For deleted still check weird. Could use dynamic classes / icon. Use toast has span id toastIcon innerHTML appropriate.
Could show "Task deleted" with trash icon via two svg hidden? easiest set innerHTML.
Need perhaps toast might show after screenshot if initial no toast. no.
Column `aria-live=polite` counts.
Could have footer text under board:
<div class=board-footer>
<span><svg mouse icon?> Drag cards to move them between columns</span>
<span><kbd>Esc</kbd> closes dialogs</span>
</div>
This helps discoverability. At y bottom maybe 780, okay. But "everything visible first seconds". yes. Could include within board heading instead to ensure. A tiny "Drag & drop enabled" pill. Search toolbar includes summary. Maybe footer not necessary. Requirement clear drop indicator, not instruction. Add footer below board margin top 14, font 12. At 800 could show.
Let's calculate with min-height 520:
app padding top 20
topbar 62 =>82
margin heading 34 =>116
heading ~85 =>201
margin 24 =>225
board 520=>745
footer 28=>773
bottom 20 =>793. Fits exactly. Great. At 800 full. Set column min-height maybe 492 to fit. Board y ~210, to 710. Let's fine.
Topbar:
height 64.
App padding top 20.
board-heading margin-top 32, margin-bottom 24. Heading: eyebrow 20 + 8 + h1 38 + 6+p 20 =92. Start y 116, end208, board y232. 500 ->732. footer y750. Fine.
Good.
Column min-height 500. Card list flex 1 but quick add bottom. Cards 2 x ~150.
Cards maybe:
padding 15 => 30
top 24
h3 margin 10 0 11 line20 =>41
meta 24
footer margin-top 12, height28 =>40
total 30+24+41+24+40=159. two =318 + gap12. cardlist. min col 500: top header 45 + body plus quick. plenty.
Column min height 494.
At mobile columns stack and each `min-height: unset`; card list. The second column below scroll. no horizontal.
No horizontal scroll: `html, body {max-width:100%; overflow-x:hidden}`. SVG etc. `word-break` h3. Toolbar min-width 0.
Dark mode details:
body radial:
light `background: radial-gradient(circle at 8% 0%, rgba(108,92,231,.08), transparent 25%), radial-gradient(circle at 95% 10%, rgba(45,158,121,.07), transparent 25%), var(--bg);`
dark similar.
Topbar maybe transparent.
Card shadow.
Column uses `--column-bg`.
Need use CSS `color-mix`? Chrome supports but avoid. Define variables.
--accent-soft #eeebff
--shadow 0 8px 24px rgba(28, 35, 54,.06)
dark --shadow.
Priority backgrounds explicit variables:
--high-bg, --high-text etc and dark override.
Maybe create `meta` category chip and due:
task-meta display flex gap.
category pill background var(--surface-2).
due urgent color high.
Footer separator top? no.
SVG icon helper hardcode template.
Let's write actual code carefully.
Potential issue duplicated developer message but same.
Need exactly code block no extra. Ensure no markdown text outside.
Let's formulate complete.
HTML:
<!doctype html>
<html lang=en data-theme=light>
<head> meta viewport etc title.
<style>
...
</style>
</head>
<body>
...
<script>
...
</script>
</body>
</html>
CSS details.
Variables:
--bg:#f5f6fa;
--surface:#ffffff;
--surface-soft:#f0f2f7;
--column:#edeff5;
--card:#fff;
--text:#1e2433;
--heading:#151a26;
--muted:#737b8d;
--faint:#9ba2b1;
--border:#e3e6ed;
--border-strong:#d6dae4;
--accent:#6d5ce8;
--accent-strong:#5a48d2;
--accent-soft:#eeebff;
--green...
--red...
--amber...
--shadow...
dark override.
`--page-pattern` maybe.
body font-family Inter fallback but no external. Use `font-family: "Inter", "SF Pro Display", -apple-system...` Inter may not installed but system okay.
Use `font-feature-settings`.
App shell width min(1380px, calc(100% - 48px)); margin 0 auto; padding 20px 0 28.
Topbar border-bottom? maybe not. Header height.
Brand mark.
Brand SVG maybe:
<svg viewBox=0 0 24 24><rect x=4 y=4 width=7 height=9 rx=2 fill...><rect x=13 y=7 ...>
Better logo with CSS mark and svg.
`brand-mark` 42; border-radius 13; background linear-gradient.
brand-copy strong 16; span 11.5.
workspace-switcher margin-left 22; padding-left 22 border-left.
Maybe topbar-left display flex align.
team stack margin-left:auto.
theme button.
add button.
At max 760 hide workspace, team.
At max 480 app width calc -24, brand-copy span hide, add-button label hide, button width42; board heading h1 27; eyebrow.
Theme label hide.
Column CSS:
.kanban-board display:grid grid-template-columns:repeat(3,minmax(0,1fr)); gap18; align-items:stretch.
.column min-width:0; min-height:500; background var(--column); border:1px solid var(--border); border-radius:18; padding:14; display:flex; flex-direction:column; transition...
column header.
Could add top colored bar via `::before` absolute top 0 left 14 right14 height3; border radius.
`.column[data-column=todo]::before{background:#a2a9b7}` etc.
Column header h2 14.5.
count 24x24.
status caption 11.
Card list display flex flex-direction column gap12; padding:4px 2px 8px; min-height:200; flex:1; border-radius.
Quick add margin-top 8; width 100; 38.
Column drag target background etc.
Card:
min-width 0.
top row.
priority badge 11.
actions opacity maybe .65, hover. Requirement delete button on it; visible. Keep opacity .55 but button still.
`card-actions` display flex. delete and drag. Delete hover red.
h3 font 14.5.
meta.
footer.
`is-dragging {opacity:.35; transform:rotate(1deg) scale(.98);}`.
`filter-empty` etc.
`@keyframes cardIn`.
Drag indicator:
`.drop-indicator {height:38px; margin:-2px 0; display:flex; align-items:center; position:relative; flex:0 0 auto;}`
`.drop-indicator::before {content:""; width:10;height:10; border-radius:50%; background:accent; position:absolute; left:0;}`
`.drop-indicator span {display:block;width:100%;height:3px;background:accent;border-radius:999px;box-shadow:0 0 0 3px rgba...}`
`.drop-indicator span::after` maybe text impossible on span? Use `::after` on indicator itself content:'Drop here'; position absolute top:-? Could put `span><em>Drop here</em></span`. HTML generated. CSS:
span { ...}
em {position:absolute; left:50%; top:-16px; transform; ...}. But em inside span position relative to span; span height3. top -16; good. Text. Might overlap preceding card. okay.
Need list overflow visible.
Column is target outline:
`.column.is-drag-target {background:var(--drag-column); box-shadow: inset 0 0 0 2px var(--accent);}`
Maybe use border and.
`.card-list.is-over {background:var(--accent-soft); outline:2px dashed var(...); outline-offset:-4px;}` But when indicator present, outline clear. This might overly highlight. nice.
Empty:
height 120 border dashed, color.
Modal:
backdrop fixed inset0 background rgba(14,17,28,.48), backdrop-filter blur(6px); z-index 50; display:grid place-items center; padding20.
modal width min(460,100%), background surface, border, radius20, shadow, animation modalIn.
form padding22.
modal-head flex.
modal icon? small accent square.
close.
label.
input.
priority options.
select.
error.
footer.
Need `[hidden] { display:none !important; }`.
body.modal-open overflow:hidden (but iframe 375 vertical; okay).
Modal max-height calc(100vh - 24px); overflow auto.
inputs.
radio hidden with visually hidden but focus styles. `input {position:absolute;opacity:0}` labels.
Priority colors via data? label classes low medium high. `:has(input:checked)` supported Chrome. Use `.priority-option:has(input:checked)` fine latest Chrome. Could use input + span.
HTML:
<label class="priority-option priority-low"><input type=radio name=priority value=low><span><i></i>Low</span></label>
CSS input:checked + span border etc. Specific backgrounds.
focus-visible + span.
Status select.
form error p.
Footer.
Toast fixed bottom 22 right22 z-index; transform. On mobile left12 right12 bottom12.
Script IIFE? use strict.
Initial cards.
`priorityLabels`.
`columnLabels`.
Elements.
render:
function renderBoard({animate=true}={})...
Maybe if called from drag drop, animations all cards. okay.
When search input, immediate every keystroke, recreates, okay.
Use `CSS.escape`? no need.
`matches = card.title.toLowerCase().includes(query)`.
Set count text. `count.dataset.total`.
Column status static.
Result summary.
Clear button hidden property; `clearSearch.hidden = !query`.
If no visible global perhaps board summary "No matching tasks"; but counts. `visibleSummary.textContent = query ? `${totalVisible} of ${state.cards.length} tasks` : `${state.cards.length} tasks`;`
Could update summary class.
No `hidden` initial clear but CSS.
`createTaskCard`:
return string. Need category field etc. Use card.priority class from whitelist. card id generated.
Priority badge text capitalization.
due tone.
Avatar style. `avatarTone` whitelist.
Icon comments.
Use `escapeHTML`.
Maybe card has `data-priority`.
HTML:
<article class="task-card" draggable="true" data-id=...>
<div class=card-header>...
<button type=button class=icon-action delete-card data-delete=... aria-label="Delete ${title}" title=...>...
<span class=drag-handle title=... aria-hidden=true>...
</div>
<h3>title</h3>
<div class=task-meta><span class=category-tag>UX</span><span class=meta-dot>•</span><span class="due-date urgent"><svg...>${due}</span></div>
<div class=card-footer>
<div class=assignee><span class="avatar ${tone}">${initials}</span><span>${name}</span></div>
<div class=comment-count...>${comments}</div>
</div>
</article>
Could include `data-column`.
Drag button order: drag handle then delete. Delete visible on hover. Good.
Need title attribute escape handles quotes.
Comment icon.
When set innerHTML then attach drag listeners perhaps use event delegation on board for dragstart? dragstart bubbles. We can attach per render:
list.querySelectorAll('.task-card').forEach(attachDrag)
Also button listener delegated.
Drag state object:
let draggedId=null;
let draggedElement=null;
function attachDrag(card) {
card.addEventListener('dragstart', event=>{
draggedId=card.dataset.id; draggedElement=card;
event.dataTransfer.setData('text/plain',draggedId);
event.dataTransfer.effectAllowed='move';
card.classList.add('is-dragging');
document.body.classList.add('is-dragging-card');
});
card.addEventListener('dragend', cleanupDrag);
}
`cleanupDrag` remove class, indicators, is-over, body.
When drop render creates new, then call cleanup? event drop listener maybe:
list.addEventListener('drop', onDrop) attached each render to list, but list persists (we only innerHTML card list, not replace list). We can attach list event listeners once after initial static lists. Could set up once.
For dragover, if draggedElement.
`event.preventDefault`.
`event.dataTransfer.dropEffect='move'`.
`const after=getDragAfterElement(list,event.clientY);`
`placeIndicator(list,after);`
Clear .is-drag-target on columns other.
`list.classList.add('is-over')`.
On dragleave: if event relatedTarget and list.contains relatedTarget return; else remove is-over and indicator? But dragover will readd. Could use `dragCounter` but easier. `dragleave` removing indicator might flicker. no need, only clean on drop/end. Target column highlighted. If leaves list and enters another, old class remains until drop; placeIndicator removes indicator and we remove target classes globally each dragover, so okay.
`placeIndicator`:
let indicator=list.querySelector('.drop-indicator');
if ! create with innerHTML `<span><em>Drop here</em></span>`;
if(after) list.insertBefore(indicator,after) else append.
Need ensure `after` isn't dragged card? get excludes.
`getDragAfterElement` excludes hidden and `.is-dragging`; includes search visible.
On drop:
event.preventDefault()
const list=event.currentTarget
const column=list.closest('.column').dataset.column
const indicator=list.querySelector('.drop-indicator')
let after=indicator?.nextElementSibling; while after && !after.matches('.task-card') after=after.next...
moveCard(draggedId,column,after?.dataset.id)
cleanupDrag before render? If cleanup removes indicator but after captured.
state order algorithm:
function moveCard(id,toColumn,afterId) {
const moving=state.cards.find...
if !moving return;
moving.column=toColumn;
const without=state.cards.filter(c=>c.id!==id)
if(afterId){
const afterIndex=without.findIndex(c=>c.id===afterId)
if(afterIndex>=0){without.splice(afterIndex,0,moving); state.cards=without; render; return}
}
// append after last card of target in without
let insertIndex=without.length;
for (let i=without.length-1;i>=0;i--){ if(without[i].column===toColumn){insertIndex=i+1;break;} }
// If no cards target, where? Need choose position before first card of next column to keep global grouping. Since render groups anyway, global order doesn't affect cross column, append is fine.
without.splice(insertIndex,0,moving)
state.cards=without
}
Wait inserting global before `afterId`: if after card in target, fine. But `without` includes cards in other columns. Example state [todo1,todo2,prog1,prog2,done1,done2]. Move todo1 to progress after prog1:
without [todo2,prog1,prog2,...]; afterIndex=1; splice1 -> [todo2,moving,prog1,prog2,...]. Filter progress gives [moving,prog1,prog2], so moving appears before prog1 despite desired after. Why? Inserting before after global at index afterIndex indeed [todo2,moving,prog1], so relative moving before after, but dropping indicator placed *before* after card, so desired before! If indicator next element is after card, moving inserted before after. correct. If want after after we'd set before indicator? Our indicator inserted before after; dropping on that line means before after. Good.
But if append to target list, global insertion after last target. Find last target. good.
Column order unaffected by global cross grouping? Render each column filters global. yes.
Moving from same column before after works.
But indicator position when dropping in empty list: appended. next null; append target after last target (if no target, global end). If target has cards but dragging onto lower empty space, getDragAfter returns maybe null if y below all; indicator append, desired after all target. Global insertion after last target. good.
One issue state global after moving todo to progress can lead filter order still okay.
When moving, renderBoard then cleanup? `cleanupDrag` references old element and removes. Do:
const id=draggedId; const afterId...
cleanupDrag(); move state/render.
But cleanup removes indicator from list, afterId captured. Good.
Need ensure `drop` after `dragend` order: drop then cleanup, then dragend on removed element maybe not fires. okay.
`renderBoard` currently recreates card and attaches. It also empties list including indicator (fine).
On search, don't accidentally clear drag. If user types while dragging impossible.
Quick add:
document.querySelectorAll('[data-open-add]').forEach button => openModal(button.dataset.openAdd or 'todo')
Top add no data? data-open-add="todo".
`openModal` set status value.
Modal title focus with requestAnimationFrame.
Need if user clicks open while another, reset.
`modal.hidden=false; document.body.classList.add`.
`lastFocused = document.activeElement`; on close focus back.
Focus trap:
modal.addEventListener('keydown', e=> {if e.key==='Tab' ...}) maybe optional. Keep robust.
Escape document.
Backdrop click if event.target===modalBackdrop close.
Cancel buttons.
Form submit:
title required. Browser native validation might stop submit event if empty due required before handler? In submit event, constraint validation prevents submit event for invalid, so custom error won't run on empty when user presses submit. But if whitespace, required considers valid then custom. Requirement title required fulfilled. To show custom error, add `novalidate` and handle. Use novalidate, then custom. Set error text.
After submit add.
Card priority from checked.
column select.
New card category "General"; due "No date"; assignee "You", "ME", tone purple, comments 0.
Maybe choose selected priority.
Render and toast.
`form.reset()` but radio medium checked markup.
Modal close resets.
`searchInput`:
on input query = value.trim().toLowerCase(); renderBoard.
Clear button click value='', focus input, render.
Could preserve query with spaces.
Theme toggle:
Use event.
Maybe default theme from `window.matchMedia` but requirement no storage. However screenshot environment maybe prefers dark and if set dark default, polished. But user can toggle. Set light explicit. `let isDark=false`.
Resize? no.
No external requests. all inline.
Need perhaps support click delete and drag interaction. Data transfer.
Let's think requirement "clear visual drop indicator." Our line with Drop here and list highlight. excellent.
First screenshot should show priority badges. yes.
At first screenshot card count 2 each. yes.
Search filters title only; state category unaffected. yes.
"State lives in memory only" yes, no storage.
"Add card through a small modal form (title required)" yes.
Could add task to selected column.
"delete card with button on it" yes.
Need maybe after add modal card shows. yes.
Potential issue user might not know delete due faint icon. Visible enough.
Let's refine CSS visuals.
Global:
* {box-sizing:border-box;}
button,input,select {font:inherit}
button {cursor:pointer}
:focus-visible {outline...}
.sr-only.
Topbar:
`.topbar {height:64px;display:flex;align-items:center;}`
`.topbar-left`.
brand mark `box-shadow: 0 8px 18px rgba`.
workspace.
`.team-stack {display:flex; margin-left:auto}` maybe right group. Topbar-actions margin-left 16. Team stack can be in actions.
Avatar stack circles with borders.
`avatar-stack .avatar + .avatar {margin-left:-8px}`.
`team-more`.
Theme button background surface, border, height40.
Add button gradient? `background: var(--accent); color white; box-shadow`.
Heading:
`.board-heading {display:flex;align-items:flex-end;justify-content:space-between;gap:32px;margin:34px 0 24px;}`
eyebrow inline-flex uppercase.
h1.
toolbar.
Search:
`.search-control{position:relative;display:flex;align-items:center;width:min(310px, 32vw);}` width. Input height44 padding left42 right40.
clear absolute right10.
summary pill.
Columns:
At 1280 3. Breakpoint 900 maybe stack? Tablet 768 could use horizontal? Requirement adapt 375. We can at max 900 use grid one? Three columns at 800 would each ~235 still okay but card titles wrap. Could switch to one at 820. At 375 definitely one.
Maybe at 900 set `grid-template-columns:1fr` and column min-height auto. This means screenshot if viewport 800? But target 1280. Fine.
Could at 900 still 3 but horizontal overflow prohibited. Better stack at 900.
At 960 maybe three.
Mobile column card list empty.
`.column {min-height:490px}` desktop.
At max900 min-height:0.
At max600 heading etc.
Footer board hint:
`<div class=board-footnote><span><svg drag icon> Drag a card to change its status</span><span class=foot-sep></span><span><span class=mini-dot></span> All changes are saved for this session</span></div>`.
"session" could imply storage? no. Better "Changes are kept only while this page is open." Clear memory. Nice.
At mobile wrap.
Modal:
backdrop transition? hidden instantly, open animation.
Priority.
Button.
Toast.
Need ensure color contrast.
Let's design dark variables:
[data-theme=dark] {
--bg:#101219;
--surface:#1a1d26;
--surface-soft:#222630;
--column:#161920;
--card:#1d2029;
--text:#edf0f7;
--heading:#f7f8fc;
--muted:#9aa2b3;
--faint:#71798a;
--border:#2b303b;
--border-strong:#39404e;
--accent:#9484ff;
--accent-strong:#7d6cf3;
--accent-soft:#272344;
--shadow:0 12px 30px rgba(0,0,0,.24);
--red-bg:rgba(239,95,107,.14); --red:#ff8791;
--amber-bg... etc
--green...
--todo:#929bad; --progress:#f2ae52; --done:#4ac590;
--drop-ring:rgba(148,132,255,.3)
}
Need card surface surface.
body background.
Columns status perhaps.
Priority high text.
Avatar tones define:
.avatar-purple {background:#eee9ff;color:#6550c7}
dark override? variables not. Could define dark explicit in dark selector `.avatar-purple{background:#3d326e;color:#d9d0ff}` etc.
Maybe avatar initial text.
Card meta category.
Comments.
Potential native `button` line-height.
Add board list static empty; JS render. no JS error if DOM elements.
Use optional chaining? latest Chrome fine.
Use `crypto.randomUUID`? avoid.
Unique id `task-${Date.now()}-${state.nextId++}`.
State nextId initial 7.
No external. Good.
Let's validate JS mentally.
`const lists = { todo: document.querySelector('[data-list="todo"]'), ...}`.
render:
Object.entries(lists).forEach(([column,list]) => {
const all = state.cards.filter...
const visible = ...
list.innerHTML = '';
if (!visible.length) { const empty=document.createElement('div'); empty.className='empty-state'; empty.innerHTML=...; list.appendChild(empty);}
visible.forEach...
count...
});
This means card list empty state in normal if no cards; initial all have.
Search count 0/2. CSS badge width maybe fits.
`createTaskCard` returns element not HTML? Could use template string, set list.innerHTML +=. Security escape. Better create via template and return firstElementChild:
const wrapper=document.createElement('div'); wrapper.innerHTML=`...`; return wrapper.firstElementChild.
No issue.
Set style animationDelay.
Priority class must whitelist: `const safePriority = ['low','medium','high'].includes(card.priority)?...`.
Avatar tone whitelist.
Task title escaped.
Comments numeric.
Category etc.
Due.
`data-id` escaped.
Event on drag card's button could maybe trigger drag; click works.
`event.dataTransfer` may null? dragstart always.
`getDragAfterElement` uses `getBoundingClientRect`; okay.
`placeIndicator`:
let indicator=list.querySelector(':scope > .drop-indicator'); `:scope` works.
if not create.
document.querySelectorAll('.column.is-drag-target').forEach...
list.closest column class add.
if after && after.parentElement===list list.insertBefore else append.
Then `list.classList.add('is-over')`; remove is-over from others.
Potential the dragged card may be source and list's child. `getDragAfter` excludes dragged. indicator could insert after source etc. fine.
`cleanupDrag`:
draggedElement?.classList.remove
document.querySelectorAll('.drop-indicator').forEach(remove)
document.querySelectorAll('.is-over,.is-drag-target').forEach(remove)
body class
draggedId=null; draggedElement=null.
`drop` capture before cleanup.
When `drop`:
const movingId=draggedId || event.dataTransfer.getData...
const indicator=list.querySelector...
let afterElement=indicator?.nextElementSibling;
if afterElement && !class card, traverse.
const afterId=...
cleanupDrag();
moveCard...
renderBoard();
showToast('Task moved to In progress');
Need show success. If drop same position maybe still toast. okay.
`event.stopPropagation` maybe.
`dragleave` no.
`dragover` on target list; source list also gets dragover? When pointer over source card, draggedElement excluded but list gets event; indicator may appear in source. okay.
`dragenter` event on list prevent default.
`moveCard` state order:
function moveCard(id,toColumn,afterId) {
const moving=state.cards.find...
if(!moving)return;
moving.column=toColumn;
const remaining=state.cards.filter...
let insertAt;
if(afterId){
const idx=remaining.findIndex...
insertAt=idx>=0?idx:remaining.length;
} else {
insertAt=remaining.length;
for(i=remaining.length-1... if col target){insertAt=i+1;break;}
}
remaining.splice(insertAt,0,moving)
state.cards=remaining
}
If dropped before target card, afterId is that card. yes.
If indicator appended in source but after dragged card? get excludes dragged and likely after card. Fine.
But append target when target has no cards and remaining has other columns; `insertAt=remaining.length`, moving at global end. Since target done after others in global, relative done order at end. If target todo no cards and remaining has progress/done, moving global end means render todo only moving; fine. Later move a done card before todo? global ordering maybe irrelevant. Good.
`showToast`:
toast.classList.remove show; force reflow? set. Use requestAnimationFrame.
Set icon innerHTML based kind. Use strings static.
`clearTimeout`.
set timeout 2600.
Toast `role=status`.
Modal `openModal(preferredColumn)`:
if (!columns[preferred]) todo.
form.reset();
statusSelect.value=preferred;
error hidden;
input aria invalid false;
modal.hidden=false;
body...
requestAnimationFrame(()=>titleInput.focus())
lastFocused=document.activeElement before? on open `lastFocused=document.activeElement`.
Need if modal hidden property but CSS. Add `modalBackdrop.hidden=false`.
`closeModal`: hidden true, body remove, lastFocused?.focus.
If submit success close then render; close focus returns top add; okay.
Modal click overlay.
Close buttons `[data-close-modal]`.
Escape.
Focus trap:
modal.addEventListener('keydown', e=> if Tab: focusables; first/last. This is keyboard but after click no issue. Good accessibility.
No global shortcut.
`form.addEventListener('submit',...)`: novalidate. Title required attr. `priorityInput = form.querySelector('input[name=priority]:checked')`.
Priority default medium markup checked.
`category:'General', due:'No date', assignee:'You', initials:'YO'? "YO" weird. Use "ME" and name "You". avatar tone purple.
`column`.
Add `id` and maybe `isNew:true`.
State add:
const insertion position? state.cards.push means for selected progress appended global end => relative progress after existing progress yes. For selected todo, global end but render todo filters, so relative todo last yes; no impact cross. Fine.
render.
`showToast('Task added to To do')`.
`closeModal` before render? either.
Potential `closeModal` returns focus and then render no issue.
Search:
input event sets query, render.
Clear button `hidden = query.length===0`; but input value may spaces. query trimmed; clear hidden false if spaces. okay.
On Escape in search maybe clear? could add but not global requirement. optional.
`visibleSummary`.
Theme button click.
Could add theme transition class; CSS variables transition on body etc.
Need maybe test no syntax errors.
Let's draft CSS in detail.
Root:
`--column-todo: #98a1b2; --column-progress:#f0a64e; --column-done:#42b889; --accent-rgb:109,92,232;`
Use rgba(var(--accent-rgb),.2) easier override dark. CSS variable `--accent-rgb:109,92,232`; dark 148,132,255.
Priority.
Surface.
Body background radial and var.
`body::before` maybe no.
Topbar.
Board heading responsive.
Search control:
`.search-control svg {position:absolute; left:14px; color...; pointer-events:none}`
input.
clear button.
`summary-pill` with live dot and `visibleSummary`; if query maybe class.
Columns.
Header status.
count badge.
Column pseudo top bar.
Task card.
Priority badge variants:
`.priority-badge.high {color:var(--red);background:var(--red-bg)}`
dot.
Need `priority-badge.low` green.
High background.
Card actions:
`.icon-action {width:27px;height:27px;...}`
Drag handle maybe same but cursor.
Delete button background transparent.
On card hover actions opacity1.
Could use opacity .7 always.
Meta category.
Due.
Footer.
Board footnote.
Modal.
Toast.
Responsive:
@media(max-width:900){
.kanban-board {grid-template-columns:1fr;}
.column {min-height:0}
.card-list {min-height:0}
.column .quick-add...
.board-heading align-items:flex-start maybe.
}
But at 800, three could be better, yet stack is graceful. At 375.
@media(max-width:720):
workspace hide, team stack hide, heading flex-direction column; gap20; margin-top28; board-toolbar width100%; search width100%; summary maybe.
columns.
@media(max-width:480):
app width calc(100%-24px); padding top14
topbar height58
brand-copy span hide
theme-copy hide
theme button 40x40
add label hide and width40
board-heading margin 26 0 20
h1 27
eyebrow 10
search input font13
summary pill maybe display:none? But count useful not. Could show result under? Hide at <=380 maybe. At 375 content width351, search flex1 and summary 96 gap10 fits 351 (245+96+10=351). Search placeholder fits maybe 150. okay.
`.board-toolbar{gap:8}` `.summary-pill{padding:0 11}`.
column padding12.
card.
modal padding 16, backdrop 10.
priority options.
board footnote column.
No horiz.
At desktop board heading search 300, summary 100.
First paint no interactions. Cards appear.
Could include `noscript` no.
Let's consider color of icon delete. fine.
Potential issue summary pill `visibleSummary` updates text but static "6 tasks"; okay.
Search live filters by title exactly. yes.
Do we need card counts perhaps counts total even search? We show 2/2 under search. Clear indication. Without 2.
Delete button maybe hidden behind hover but still visible. opacity 0.6.
Let's write final code. Need only one fenced code block. We need be mindful not accidentally include backticks in JS template strings? Fenced code block allows backticks inside? A triple backtick sequence only ends if three. JS uses backticks single, okay. Code block content can contain. Fine.
Let's ensure no `</script>` inside strings. no.
Let's potentially add keyboard "Enter" title only. no start message needed because non-game.
Let's create icons:
Brand:
<svg ...><rect x=3 y=4... opacity .45> etc.
Search.
Calendar.
Comment.
Trash.
Grip.
Plus.
Moon/sun.
In card template, SVG with `aria-hidden="true"` no xmlns needed inline.
Modal select chevron native.
Toast.
ARIA role dialog.
Focus trap function.
One concern: HTML5 draggable on card plus `button` inside; `dragstart` may start if dragging delete icon, but okay.
One concern: use event.target.closest on SVG path: SVGElement has closest yes.
One concern: `dataTransfer.getData` in dragover might be empty, but state tracked.
One concern: browser requires drag image. default card.
One concern: after dropping, `cleanupDrag` sets draggedId null before `moveCard`; captured id.
Good.
Maybe support dragging when search filters and list empty: list has empty-state div. getAfter none; indicator appended after empty state. On drop, after element next? indicator appended after empty state, next null, move. fine. But indicator below empty state, okay.
Drag over list triggers `event.preventDefault`, so drop allowed.
When a dragged card from source is filtered? can't drag hidden.
Search rendering while focus input safe.
We can add `aria-describedby=modalHelp`.
Let's ensure modal no page scrollbar horizontal.
Let's now formulate final.