Kimi K3.1 Pro
by MoonshotAIreasoning defaultClean
Running live in a sandboxed frame. Click inside it before using the keyboard.
How it went
- Total time
- 3 min 34 s
- Cost
- $0.304
- Output tokens
- 32,716
- Spent thinking
- 16,193 tokens
- Speed
- 154.1 tok/s
- Console errors
- 0
- First token after
- 1.3 s
- First visible text after
- 2 min 42 s
- Prompt tokens
- 582
- Code size
- 60.8 KB
- Finish reason
- stop
- Served by
- Morph
- Model id
moonshotai/kimi-k3.1-pro- Reasoning sent
model default- Max output
- 943,718 tokens
- Generated
- Nov 12, 2026
What it was asked
The prompt
Build an interactive Kanban board in vanilla HTML, CSS and JavaScript (no framework, no library). Requirements: - Three columns: "To do", "In progress", "Done", each showing its card count. - Pre-populate the board with 6 realistic cards spread across the columns so the first screenshot looks alive. - Each card has a title and a priority (low / medium / high) shown as a colored badge. - Add a card through a small modal form (title required), and delete a card with a button on it. - Drag and drop cards between columns with the mouse, with a clear visual drop indicator. - A search field that filters cards live by title. - A light / dark theme toggle. - State lives in memory only (no storage APIs).
System prompt, sent with every prompt
You are generating a self-contained web demo. It will be rendered automatically in a sandboxed iframe and compared side by side with the output of other AI models given the exact same task. Output rules: - Reply with exactly ONE fenced code block containing the complete file: ```html (or ```svg when the task asks for an SVG). No text before or after the code block. - Everything must be inline: CSS in <style>, JavaScript in <script>. No external files, images, fonts or network requests, except the CDN URLs explicitly allowed by the task. - Do not use localStorage, sessionStorage, cookies, IndexedDB, alert(), confirm(), prompt() or window.open(): they are blocked in the sandbox and will throw. - The page must look right in a 1280×800 viewport and adapt gracefully down to 375px wide. No horizontal scrollbar. - Something meaningful must be visible on first paint: a screenshot is taken automatically about 3 seconds after load, without any interaction. - Everything that makes the demo interesting must be visible within the first seconds: nothing hidden behind a high score, a later level or a long play session. - Games must start in a self-playing demo mode (a simple AI plays) behind a "Click or press a key to start" message, so the game can be judged in action without playing. - The iframe only receives keyboard events after the user clicks it: if the demo uses the keyboard, display a clear "Click or press a key to start" message. - The code must run with zero errors in the console of the latest Chrome.
Code run-1.html · 2288 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">
<title>Orbit — Website Relaunch Board</title>
<style>
:root {
color-scheme: light;
--bg: #f4f6fa;
--header-bg: rgba(255, 255, 255, 0.88);
--surface: #ffffff;
--column-bg: #edf0f5;
--text: #192236;
--muted: #697386;
--faint: #939bac;
--border: #dfe4ec;
--card-border: #e1e5ec;
--divider: #edf0f4;
--accent: #5966d9;
--accent-hover: #4855c4;
--accent-soft: #e9ecff;
--accent-ring: rgba(89, 102, 217, 0.17);
--success: #218767;
--success-soft: #e4f5ed;
--high-bg: #fdecef;
--high-text: #c64359;
--medium-bg: #fff3da;
--medium-text: #a56809;
--low-bg: #e7f4f2;
--low-text: #247c70;
--shadow: 0 1px 2px rgba(25, 34, 54, 0.04), 0 5px 14px rgba(25, 34, 54, 0.035);
--card-shadow: 0 1px 2px rgba(25, 34, 54, 0.05);
--card-shadow-hover: 0 10px 24px rgba(25, 34, 54, 0.10);
}
html[data-theme="dark"] {
color-scheme: dark;
--bg: #0f141e;
--header-bg: rgba(15, 20, 30, 0.88);
--surface: #181f2c;
--column-bg: #141b26;
--text: #edf1f8;
--muted: #9aa5b7;
--faint: #717d90;
--border: #293345;
--card-border: #2b3546;
--divider: #293242;
--accent: #8994ff;
--accent-hover: #9ba5ff;
--accent-soft: #272e50;
--accent-ring: rgba(137, 148, 255, 0.18);
--success: #68c7a3;
--success-soft: #19372f;
--high-bg: #402630;
--high-text: #ff99aa;
--medium-bg: #3d3422;
--medium-text: #f2c66f;
--low-bg: #1d3635;
--low-text: #78cfc0;
--shadow: 0 8px 24px rgba(0, 0, 0, 0.13);
--card-shadow: 0 1px 2px rgba(0, 0, 0, 0.2);
--card-shadow-hover: 0 12px 28px rgba(0, 0, 0, 0.28);
}
* {
box-sizing: border-box;
}
[hidden] {
display: none !important;
}
html {
min-width: 0;
}
body {
margin: 0;
min-width: 0;
min-height: 100vh;
overflow-x: hidden;
background-color: var(--bg);
background-image: radial-gradient(circle at 85% -10%, rgba(89, 102, 217, 0.10), transparent 34%);
color: var(--text);
font-family: Inter, ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
-webkit-font-smoothing: antialiased;
transition: background-color 180ms ease, color 180ms ease;
}
html[data-theme="dark"] body {
background-image: radial-gradient(circle at 85% -10%, rgba(137, 148, 255, 0.08), transparent 34%);
}
body.modal-open {
overflow: hidden;
}
button,
input,
select {
font: inherit;
}
button {
color: inherit;
}
button,
.card {
-webkit-tap-highlight-color: transparent;
}
button:focus-visible,
input:focus-visible,
select:focus-visible,
.card:focus-visible {
outline: 3px solid var(--accent-ring);
outline-offset: 2px;
}
.svg-defs {
position: absolute;
width: 0;
height: 0;
overflow: hidden;
}
.icon {
width: 18px;
height: 18px;
flex: 0 0 auto;
fill: none;
stroke: currentColor;
stroke-width: 1.8;
stroke-linecap: round;
stroke-linejoin: round;
}
.small-icon {
width: 13px;
height: 13px;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.app-header {
height: 64px;
border-bottom: 1px solid var(--border);
background: var(--header-bg);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
}
.header-inner {
max-width: 1240px;
height: 100%;
margin: 0 auto;
padding: 0 32px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 24px;
}
.brand {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
}
.brand-mark {
width: 32px;
height: 32px;
border-radius: 9px;
display: flex;
align-items: flex-end;
justify-content: center;
gap: 3px;
padding: 8px;
background: #5966d9;
box-shadow: 0 5px 12px rgba(89, 102, 217, 0.24);
}
.brand-mark span {
display: block;
width: 3px;
border-radius: 3px;
background: #ffffff;
}
.brand-mark span:nth-child(1) { height: 10px; opacity: 0.76; }
.brand-mark span:nth-child(2) { height: 16px; }
.brand-mark span:nth-child(3) { height: 13px; opacity: 0.88; }
.brand-name {
font-size: 17px;
font-weight: 760;
letter-spacing: -0.35px;
}
.workspace-badge {
margin-left: 3px;
padding: 4px 7px;
border: 1px solid var(--border);
border-radius: 6px;
color: var(--muted);
font-size: 9px;
font-weight: 750;
letter-spacing: 0.9px;
}
.header-actions {
display: flex;
align-items: center;
gap: 22px;
}
.team-presence {
display: flex;
align-items: center;
gap: 10px;
}
.avatar-stack {
display: flex;
padding-left: 8px;
}
.avatar {
--avatar-bg: #e7ebff;
--avatar-fg: #4a5abb;
width: 26px;
height: 26px;
flex: 0 0 auto;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 50%;
background: var(--avatar-bg);
color: var(--avatar-fg);
font-size: 9px;
font-weight: 800;
letter-spacing: -0.2px;
}
.avatar.blue { --avatar-bg: #e5ebff; --avatar-fg: #4a5bbd; }
.avatar.rose { --avatar-bg: #fbe8ee; --avatar-fg: #b34d6a; }
.avatar.green { --avatar-bg: #e2f3ec; --avatar-fg: #317d63; }
.avatar.amber { --avatar-bg: #fff0d8; --avatar-fg: #a36c19; }
.avatar.violet { --avatar-bg: #eee8fb; --avatar-fg: #7957aa; }
html[data-theme="dark"] .avatar.blue { --avatar-bg: #293353; --avatar-fg: #aab6ff; }
html[data-theme="dark"] .avatar.rose { --avatar-bg: #402a37; --avatar-fg: #f3a4ba; }
html[data-theme="dark"] .avatar.green { --avatar-bg: #233a33; --avatar-fg: #91d4b9; }
html[data-theme="dark"] .avatar.amber { --avatar-bg: #3c3325; --avatar-fg: #e8c17d; }
html[data-theme="dark"] .avatar.violet { --avatar-bg: #342c47; --avatar-fg: #c5afeb; }
.team-avatar {
width: 29px;
height: 29px;
margin-left: -8px;
border: 2px solid var(--surface);
font-size: 9px;
}
.team-label {
color: var(--muted);
font-size: 12px;
white-space: nowrap;
}
.team-label::before {
content: "";
display: inline-block;
width: 6px;
height: 6px;
margin-right: 6px;
border-radius: 50%;
background: #37ad7f;
vertical-align: 1px;
}
.theme-toggle {
min-height: 36px;
padding: 0 11px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 7px;
border: 1px solid var(--border);
border-radius: 9px;
background: var(--surface);
color: var(--muted);
cursor: pointer;
font-size: 12px;
font-weight: 650;
transition: border-color 150ms ease, color 150ms ease, transform 150ms ease;
}
.theme-toggle:hover {
border-color: var(--accent);
color: var(--accent);
}
.theme-toggle:active {
transform: scale(0.97);
}
.theme-toggle .sun-icon {
display: none;
}
html[data-theme="dark"] .theme-toggle .sun-icon {
display: block;
}
html[data-theme="dark"] .theme-toggle .moon-icon {
display: none;
}
.app-main {
max-width: 1240px;
margin: 0 auto;
padding: 26px 32px 28px;
animation: page-in 400ms ease-out both;
}
.project-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 30px;
margin-bottom: 22px;
}
.project-copy {
min-width: 0;
}
.eyebrow {
margin-bottom: 7px;
color: var(--faint);
font-size: 10px;
font-weight: 750;
letter-spacing: 1.25px;
text-transform: uppercase;
}
.eyebrow span {
margin: 0 6px;
color: var(--border);
}
.title-line {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 11px;
}
h1 {
margin: 0;
font-size: clamp(26px, 3vw, 31px);
line-height: 1.15;
letter-spacing: -1px;
font-weight: 760;
}
.status-pill {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 5px 8px;
border-radius: 999px;
background: var(--success-soft);
color: var(--success);
font-size: 10px;
font-weight: 750;
letter-spacing: 0.1px;
}
.status-pill .icon {
width: 12px;
height: 12px;
stroke-width: 2.2;
}
.project-subtitle {
margin: 8px 0 0;
color: var(--muted);
font-size: 13px;
line-height: 1.5;
}
.sprint-overview {
width: 222px;
flex: 0 0 222px;
padding: 13px 15px 12px;
border: 1px solid var(--border);
border-radius: 12px;
background: var(--surface);
box-shadow: var(--card-shadow);
}
.sprint-heading {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 9px;
font-size: 11px;
color: var(--muted);
font-weight: 650;
}
.sprint-heading strong {
color: var(--text);
font-size: 13px;
font-variant-numeric: tabular-nums;
}
.progress-track {
height: 5px;
overflow: hidden;
border-radius: 999px;
background: var(--column-bg);
}
.progress-fill {
display: block;
width: 0;
height: 100%;
border-radius: inherit;
background: var(--accent);
transition: width 350ms ease;
}
.sprint-overview small {
display: block;
margin-top: 8px;
color: var(--faint);
font-size: 10px;
}
.toolbar {
display: flex;
align-items: center;
gap: 14px;
margin-bottom: 18px;
}
.search-box {
position: relative;
width: 360px;
max-width: 100%;
}
.search-box > .icon {
position: absolute;
top: 50%;
left: 13px;
width: 17px;
height: 17px;
color: var(--faint);
transform: translateY(-50%);
pointer-events: none;
}
.search-box input {
width: 100%;
height: 42px;
padding: 0 76px 0 39px;
border: 1px solid var(--border);
border-radius: 10px;
background: var(--surface);
color: var(--text);
font-size: 13px;
box-shadow: var(--card-shadow);
transition: border-color 150ms ease, box-shadow 150ms ease;
appearance: none;
-webkit-appearance: none;
}
.search-box input::-webkit-search-cancel-button {
display: none;
}
.search-box input::placeholder {
color: var(--faint);
}
.search-box input:focus {
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-ring);
outline: none;
}
.key-hint {
position: absolute;
top: 50%;
right: 10px;
padding: 3px 5px;
border: 1px solid var(--border);
border-radius: 5px;
background: var(--bg);
color: var(--faint);
font-family: inherit;
font-size: 9px;
line-height: 1;
transform: translateY(-50%);
pointer-events: none;
}
.search-box.has-query .key-hint {
display: none;
}
.clear-search {
position: absolute;
top: 50%;
right: 8px;
width: 27px;
height: 27px;
padding: 0;
display: grid;
place-items: center;
border: 0;
border-radius: 6px;
background: transparent;
color: var(--muted);
cursor: pointer;
transform: translateY(-50%);
}
.clear-search:hover {
background: var(--column-bg);
color: var(--text);
}
.clear-search .icon {
width: 15px;
height: 15px;
}
.toolbar-actions {
margin-left: auto;
display: flex;
align-items: center;
gap: 16px;
}
.results-summary {
color: var(--muted);
font-size: 12px;
white-space: nowrap;
font-variant-numeric: tabular-nums;
}
.primary-button {
min-height: 42px;
padding: 0 16px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 7px;
border: 1px solid transparent;
border-radius: 10px;
background: var(--accent);
color: #ffffff;
cursor: pointer;
font-size: 12px;
font-weight: 700;
box-shadow: 0 5px 13px rgba(89, 102, 217, 0.20);
transition: background 150ms ease, transform 150ms ease, box-shadow 150ms ease;
}
html[data-theme="dark"] .primary-button {
color: #111629;
}
.primary-button:hover {
background: var(--accent-hover);
box-shadow: 0 7px 18px rgba(89, 102, 217, 0.27);
transform: translateY(-1px);
}
.primary-button:active {
transform: translateY(0);
}
.primary-button .icon {
width: 16px;
height: 16px;
stroke-width: 2;
}
.board {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 16px;
align-items: stretch;
}
.column {
--column-accent: #8791a4;
position: relative;
min-width: 0;
min-height: 440px;
display: flex;
flex-direction: column;
overflow: hidden;
border: 1px solid var(--border);
border-radius: 14px;
background: var(--column-bg);
transition: border-color 150ms ease, background-color 180ms ease, box-shadow 150ms ease;
}
.column::before {
content: "";
position: absolute;
top: 0;
left: 18px;
right: 18px;
height: 2px;
border-radius: 0 0 4px 4px;
background: var(--column-accent);
opacity: 0.72;
}
.column[data-column="progress"] { --column-accent: #6875e2; }
.column[data-column="done"] { --column-accent: #38a47f; }
.column.drag-over {
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-ring);
}
.column-header {
min-height: 52px;
padding: 15px 15px 10px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.column-title-group {
display: flex;
align-items: center;
min-width: 0;
gap: 8px;
}
.column-dot {
width: 8px;
height: 8px;
flex: 0 0 auto;
border-radius: 50%;
background: var(--column-accent);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--column-accent) 13%, transparent);
}
.column h2 {
margin: 0;
font-size: 13px;
line-height: 1.2;
font-weight: 750;
letter-spacing: -0.15px;
}
.column-count {
min-width: 22px;
height: 20px;
padding: 0 6px;
display: inline-flex;
align-items: center;
justify-content: center;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--surface);
color: var(--muted);
font-size: 10px;
font-weight: 750;
font-variant-numeric: tabular-nums;
}
.column-caption {
color: var(--faint);
font-size: 10px;
white-space: nowrap;
}
.card-list {
min-height: 180px;
flex: 1;
padding: 2px 11px 8px;
display: flex;
flex-direction: column;
gap: 11px;
}
.card {
position: relative;
flex: 0 0 auto;
min-width: 0;
padding: 13px 14px 12px;
border: 1px solid var(--card-border);
border-radius: 11px;
background: var(--surface);
box-shadow: var(--card-shadow);
cursor: grab;
animation: card-in 280ms ease-out both;
transition: border-color 150ms ease, box-shadow 150ms ease, transform 150ms ease, opacity 150ms ease;
}
.card:hover {
border-color: color-mix(in srgb, var(--accent) 42%, var(--card-border));
box-shadow: var(--card-shadow-hover);
transform: translateY(-2px);
}
.card:active {
cursor: grabbing;
}
.card.dragging {
opacity: 0.28;
transform: scale(0.98);
box-shadow: none;
}
.card.just-added {
animation: added-pulse 1.25s ease-out both;
}
.card-top {
min-height: 24px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.card-identity {
min-width: 0;
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
}
.card-code {
color: var(--faint);
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 9px;
font-weight: 650;
letter-spacing: 0.2px;
white-space: nowrap;
}
.priority-badge {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 3px 6px;
border-radius: 5px;
font-size: 8px;
line-height: 1.2;
font-weight: 800;
letter-spacing: 0.45px;
text-transform: uppercase;
}
.priority-badge.high { background: var(--high-bg); color: var(--high-text); }
.priority-badge.medium { background: var(--medium-bg); color: var(--medium-text); }
.priority-badge.low { background: var(--low-bg); color: var(--low-text); }
.priority-dot {
width: 4px;
height: 4px;
border-radius: 50%;
background: currentColor;
}
.delete-card {
width: 25px;
height: 25px;
flex: 0 0 auto;
margin: -3px -4px -3px 0;
padding: 0;
display: grid;
place-items: center;
border: 0;
border-radius: 6px;
background: transparent;
color: var(--faint);
opacity: 0.62;
cursor: pointer;
transition: color 140ms ease, background 140ms ease, opacity 140ms ease;
}
.card:hover .delete-card,
.delete-card:focus-visible {
opacity: 1;
}
.delete-card:hover {
background: var(--high-bg);
color: var(--high-text);
}
.delete-card .icon {
width: 14px;
height: 14px;
}
.card h3 {
margin: 8px 0 0;
overflow-wrap: anywhere;
color: var(--text);
font-size: 14px;
line-height: 1.4;
font-weight: 690;
letter-spacing: -0.18px;
}
.card-description {
margin: 5px 0 0;
display: -webkit-box;
overflow: hidden;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
color: var(--muted);
font-size: 11.5px;
line-height: 1.5;
}
.card-footer {
min-height: 34px;
margin-top: 11px;
padding-top: 9px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
border-top: 1px solid var(--divider);
}
.card-footer-info {
min-width: 0;
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
}
.card-tag {
overflow: hidden;
color: var(--muted);
font-size: 8px;
font-weight: 800;
letter-spacing: 0.55px;
text-overflow: ellipsis;
text-transform: uppercase;
white-space: nowrap;
}
.due-date {
display: inline-flex;
align-items: center;
gap: 4px;
color: var(--faint);
font-size: 9px;
white-space: nowrap;
}
.due-date .icon {
width: 11px;
height: 11px;
}
.card .avatar {
width: 24px;
height: 24px;
font-size: 8px;
}
.column-add {
min-height: 34px;
margin: 5px 11px 11px;
padding: 0 10px;
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
border: 1px dashed transparent;
border-radius: 8px;
background: transparent;
color: var(--muted);
cursor: pointer;
font-size: 11px;
font-weight: 650;
transition: color 140ms ease, border-color 140ms ease, background 140ms ease;
}
.column-add:hover {
border-color: var(--border);
background: var(--surface);
color: var(--accent);
}
.column-add .icon {
width: 14px;
height: 14px;
}
.column-empty {
min-height: 128px;
flex: 1;
padding: 20px 12px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
border: 1px dashed var(--border);
border-radius: 10px;
color: var(--muted);
text-align: center;
pointer-events: none;
}
.empty-icon {
width: 30px;
height: 30px;
margin-bottom: 8px;
display: grid;
place-items: center;
border-radius: 8px;
background: var(--surface);
color: var(--faint);
}
.empty-icon .icon {
width: 15px;
height: 15px;
}
.column-empty strong {
font-size: 11px;
font-weight: 650;
}
.column-empty small {
margin-top: 4px;
color: var(--faint);
font-size: 9px;
line-height: 1.4;
}
.board.is-dragging .column-empty {
display: none;
}
.drop-indicator {
position: relative;
height: 4px;
flex: 0 0 4px;
margin: 1px 3px;
border-radius: 999px;
background: var(--accent);
box-shadow: 0 0 0 3px var(--accent-ring), 0 2px 8px var(--accent-ring);
pointer-events: none;
animation: indicator-in 120ms ease-out;
}
.drop-indicator::before,
.drop-indicator::after {
content: "";
position: absolute;
top: 50%;
width: 8px;
height: 8px;
border: 2px solid var(--surface);
border-radius: 50%;
background: var(--accent);
transform: translateY(-50%);
}
.drop-indicator::before { left: -3px; }
.drop-indicator::after { right: -3px; }
.board-footer {
margin-top: 13px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
color: var(--faint);
font-size: 10px;
}
.board-footer span {
display: inline-flex;
align-items: center;
gap: 6px;
}
.board-footer .icon {
width: 13px;
height: 13px;
}
.modal-layer {
position: fixed;
inset: 0;
z-index: 200;
padding: 16px;
display: grid;
place-items: center;
}
.modal-backdrop {
position: absolute;
inset: 0;
background: rgba(12, 17, 28, 0.56);
backdrop-filter: blur(4px);
-webkit-backdrop-filter: blur(4px);
animation: fade-in 150ms ease-out;
}
.modal {
position: relative;
width: min(440px, 100%);
max-height: calc(100vh - 32px);
overflow-y: auto;
padding: 23px;
border: 1px solid var(--border);
border-radius: 16px;
background: var(--surface);
box-shadow: 0 24px 80px rgba(7, 12, 25, 0.28);
animation: modal-in 180ms ease-out;
}
.modal-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
margin-bottom: 21px;
}
.modal-header h2 {
margin: 0;
font-size: 20px;
letter-spacing: -0.5px;
}
.modal-header p {
margin: 5px 0 0;
color: var(--muted);
font-size: 12px;
}
.icon-button {
width: 32px;
height: 32px;
flex: 0 0 auto;
padding: 0;
display: grid;
place-items: center;
border: 1px solid var(--border);
border-radius: 8px;
background: transparent;
color: var(--muted);
cursor: pointer;
}
.icon-button:hover {
background: var(--column-bg);
color: var(--text);
}
.form-field {
display: block;
min-width: 0;
margin-bottom: 16px;
}
.form-field > span {
display: block;
margin-bottom: 7px;
color: var(--text);
font-size: 11px;
font-weight: 700;
}
.required-mark {
color: var(--high-text);
}
.form-field input,
.form-field select {
width: 100%;
min-width: 0;
height: 42px;
padding: 0 11px;
border: 1px solid var(--border);
border-radius: 9px;
background: var(--bg);
color: var(--text);
font-size: 12px;
transition: border-color 150ms ease, box-shadow 150ms ease;
}
.form-field input:focus,
.form-field select:focus {
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-ring);
outline: none;
}
.form-field input[aria-invalid="true"] {
border-color: var(--high-text);
}
.form-field input::placeholder {
color: var(--faint);
}
.form-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 13px;
}
.field-error {
margin: 6px 0 0;
color: var(--high-text);
font-size: 10px;
}
.modal-actions {
margin-top: 23px;
padding-top: 17px;
display: flex;
justify-content: flex-end;
gap: 9px;
border-top: 1px solid var(--divider);
}
.secondary-button {
min-height: 40px;
padding: 0 15px;
border: 1px solid var(--border);
border-radius: 9px;
background: var(--surface);
color: var(--muted);
cursor: pointer;
font-size: 12px;
font-weight: 650;
}
.secondary-button:hover {
background: var(--column-bg);
color: var(--text);
}
.toast {
position: fixed;
right: 22px;
bottom: 22px;
z-index: 250;
max-width: min(420px, calc(100vw - 32px));
min-height: 48px;
padding: 9px 10px 9px 15px;
display: flex;
align-items: center;
gap: 13px;
border: 1px solid var(--border);
border-radius: 11px;
background: var(--surface);
color: var(--text);
box-shadow: 0 15px 40px rgba(10, 16, 28, 0.19);
opacity: 0;
visibility: hidden;
transform: translateY(12px);
pointer-events: none;
transition: opacity 170ms ease, transform 170ms ease, visibility 170ms ease;
}
.toast.visible {
opacity: 1;
visibility: visible;
transform: translateY(0);
pointer-events: auto;
}
.toast-message {
min-width: 0;
overflow: hidden;
font-size: 12px;
text-overflow: ellipsis;
white-space: nowrap;
}
.toast-undo {
padding: 5px 7px;
border: 0;
border-radius: 6px;
background: var(--accent-soft);
color: var(--accent);
cursor: pointer;
font-size: 11px;
font-weight: 750;
}
.toast-close {
width: 27px;
height: 27px;
padding: 0;
display: grid;
place-items: center;
border: 0;
border-radius: 6px;
background: transparent;
color: var(--faint);
cursor: pointer;
}
.toast-close:hover {
background: var(--column-bg);
color: var(--text);
}
.toast-close .icon {
width: 14px;
height: 14px;
}
@keyframes page-in {
from { opacity: 0; transform: translateY(5px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes card-in {
from { opacity: 0; transform: translateY(7px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes added-pulse {
0% { opacity: 0; transform: translateY(-7px); box-shadow: 0 0 0 0 var(--accent-ring); }
35% { opacity: 1; transform: translateY(0); box-shadow: 0 0 0 5px var(--accent-ring); }
100% { opacity: 1; transform: translateY(0); box-shadow: var(--card-shadow); }
}
@keyframes indicator-in {
from { opacity: 0; transform: scaleX(0.8); }
to { opacity: 1; transform: scaleX(1); }
}
@keyframes fade-in {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes modal-in {
from { opacity: 0; transform: translateY(10px) scale(0.985); }
to { opacity: 1; transform: translateY(0) scale(1); }
}
@media (max-width: 900px) {
.column-caption {
display: none;
}
.team-label {
display: none;
}
}
@media (max-width: 820px) {
.board {
grid-template-columns: minmax(0, 1fr);
}
.column {
min-height: 0;
}
.card-list {
min-height: 100px;
}
.column-caption {
display: inline;
}
.column-empty {
min-height: 100px;
flex: none;
}
.header-inner,
.app-main {
padding-left: 22px;
padding-right: 22px;
}
}
@media (max-width: 650px) {
.team-presence,
.workspace-badge {
display: none;
}
.project-header {
align-items: flex-start;
}
.sprint-overview {
width: 180px;
flex-basis: 180px;
}
.toolbar {
flex-wrap: wrap;
}
.search-box {
width: 100%;
}
.toolbar-actions {
width: 100%;
margin-left: 0;
justify-content: space-between;
}
}
@media (max-width: 520px) {
.app-header {
height: 58px;
}
.header-inner,
.app-main {
padding-left: 16px;
padding-right: 16px;
}
.app-main {
padding-top: 21px;
}
.header-actions {
gap: 10px;
}
.theme-toggle {
width: 36px;
padding: 0;
}
.theme-label {
display: none;
}
.project-header {
display: block;
margin-bottom: 18px;
}
.sprint-overview {
display: none;
}
.project-subtitle {
font-size: 12px;
}
.status-pill {
padding: 4px 7px;
}
.toolbar {
gap: 11px;
margin-bottom: 15px;
}
.key-hint {
display: none;
}
.search-box input {
padding-right: 42px;
}
.results-summary {
font-size: 11px;
}
.primary-button {
padding: 0 13px;
}
.board {
gap: 13px;
}
.column-caption {
font-size: 9px;
}
.board-footer {
align-items: flex-start;
flex-direction: column;
gap: 6px;
}
.modal {
padding: 20px;
}
.form-row {
gap: 10px;
}
.toast {
right: 16px;
bottom: 16px;
left: 16px;
max-width: none;
}
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
scroll-behavior: auto !important;
transition-duration: 0.01ms !important;
}
}
</style>
</head>
<body>
<svg class="svg-defs" aria-hidden="true">
<symbol id="icon-plus" viewBox="0 0 24 24">
<path d="M12 5v14M5 12h14"></path>
</symbol>
<symbol id="icon-search" viewBox="0 0 24 24">
<circle cx="11" cy="11" r="7"></circle>
<path d="m20 20-4-4"></path>
</symbol>
<symbol id="icon-sun" viewBox="0 0 24 24">
<circle cx="12" cy="12" r="4"></circle>
<path d="M12 2v2M12 20v2M4.93 4.93l1.42 1.42M17.65 17.65l1.42 1.42M2 12h2M20 12h2M4.93 19.07l1.42-1.42M17.65 6.35l1.42-1.42"></path>
</symbol>
<symbol id="icon-moon" viewBox="0 0 24 24">
<path d="M20.7 14.2A8.5 8.5 0 0 1 9.8 3.3 8.5 8.5 0 1 0 20.7 14.2Z"></path>
</symbol>
<symbol id="icon-trash" viewBox="0 0 24 24">
<path d="M4 7h16M9 7V4h6v3M6.5 7l.8 13h9.4l.8-13M10 11v5M14 11v5"></path>
</symbol>
<symbol id="icon-x" viewBox="0 0 24 24">
<path d="m6 6 12 12M18 6 6 18"></path>
</symbol>
<symbol id="icon-calendar" viewBox="0 0 24 24">
<rect x="3.5" y="5" width="17" height="16" rx="2"></rect>
<path d="M8 3v4M16 3v4M3.5 10h17"></path>
</symbol>
<symbol id="icon-check" viewBox="0 0 24 24">
<path d="m5 12 4 4L19 6"></path>
</symbol>
<symbol id="icon-move" viewBox="0 0 24 24">
<path d="M12 3v18M3 12h18M8 7l4-4 4 4M8 17l4 4 4-4M7 8l-4 4 4 4M17 8l4 4-4 4"></path>
</symbol>
<symbol id="icon-session" viewBox="0 0 24 24">
<circle cx="12" cy="12" r="9"></circle>
<path d="M12 7v5l3 2"></path>
</symbol>
</svg>
<header class="app-header">
<div class="header-inner">
<div class="brand" aria-label="Orbit workspace">
<span class="brand-mark" aria-hidden="true"><span></span><span></span><span></span></span>
<span class="brand-name">Orbit</span>
<span class="workspace-badge">WORKSPACE</span>
</div>
<div class="header-actions">
<div class="team-presence" aria-label="Four team members online">
<div class="avatar-stack" aria-hidden="true">
<span class="avatar team-avatar blue">AK</span>
<span class="avatar team-avatar rose">LM</span>
<span class="avatar team-avatar green">SK</span>
<span class="avatar team-avatar amber">JR</span>
</div>
<span class="team-label">4 online</span>
</div>
<button class="theme-toggle" id="themeToggle" type="button" aria-label="Switch to dark theme" aria-pressed="false" title="Switch to dark theme">
<svg class="icon moon-icon" aria-hidden="true"><use href="#icon-moon"></use></svg>
<svg class="icon sun-icon" aria-hidden="true"><use href="#icon-sun"></use></svg>
<span class="theme-label" id="themeLabel">Dark</span>
</button>
</div>
</div>
</header>
<main class="app-main">
<section class="project-header" aria-labelledby="projectTitle">
<div class="project-copy">
<div class="eyebrow">Product team <span>/</span> Sprint 24</div>
<div class="title-line">
<h1 id="projectTitle">Website relaunch</h1>
<span class="status-pill">
<svg class="icon" aria-hidden="true"><use href="#icon-check"></use></svg>
On track
</span>
</div>
<p class="project-subtitle">Plan, prioritize, and move work forward together.</p>
</div>
<div class="sprint-overview" aria-label="Sprint completion">
<div class="sprint-heading">
<span>Sprint progress</span>
<strong id="progressLabel">33%</strong>
</div>
<div class="progress-track" aria-hidden="true">
<span class="progress-fill" id="progressFill"></span>
</div>
<small id="progressText">2 of 6 cards completed</small>
</div>
</section>
<section class="toolbar" aria-label="Board tools">
<div class="search-box" id="searchBox">
<svg class="icon" aria-hidden="true"><use href="#icon-search"></use></svg>
<input id="searchInput" type="search" aria-label="Search cards by title" placeholder="Search cards by title…" autocomplete="off">
<kbd class="key-hint">Ctrl K</kbd>
<button class="clear-search" id="clearSearch" type="button" aria-label="Clear search" title="Clear search" hidden>
<svg class="icon" aria-hidden="true"><use href="#icon-x"></use></svg>
</button>
</div>
<div class="toolbar-actions">
<span class="results-summary" id="resultsSummary">6 cards</span>
<button class="primary-button" id="newCardButton" type="button">
<svg class="icon" aria-hidden="true"><use href="#icon-plus"></use></svg>
New card
</button>
</div>
</section>
<section class="board" id="board" aria-label="Website relaunch Kanban board">
<article class="column" data-column="todo" aria-labelledby="todoHeading">
<header class="column-header">
<div class="column-title-group">
<span class="column-dot" aria-hidden="true"></span>
<h2 id="todoHeading">To do</h2>
<span class="column-count" id="todoCount">2</span>
</div>
<span class="column-caption">Ready to start</span>
</header>
<div class="card-list" id="todoList"></div>
<button class="column-add" type="button" data-add-column="todo">
<svg class="icon" aria-hidden="true"><use href="#icon-plus"></use></svg>
Add card
</button>
</article>
<article class="column" data-column="progress" aria-labelledby="progressHeading">
<header class="column-header">
<div class="column-title-group">
<span class="column-dot" aria-hidden="true"></span>
<h2 id="progressHeading">In progress</h2>
<span class="column-count" id="progressCount">2</span>
</div>
<span class="column-caption">In motion</span>
</header>
<div class="card-list" id="progressList"></div>
<button class="column-add" type="button" data-add-column="progress">
<svg class="icon" aria-hidden="true"><use href="#icon-plus"></use></svg>
Add card
</button>
</article>
<article class="column" data-column="done" aria-labelledby="doneHeading">
<header class="column-header">
<div class="column-title-group">
<span class="column-dot" aria-hidden="true"></span>
<h2 id="doneHeading">Done</h2>
<span class="column-count" id="doneCount">2</span>
</div>
<span class="column-caption">Completed</span>
</header>
<div class="card-list" id="doneList"></div>
<button class="column-add" type="button" data-add-column="done">
<svg class="icon" aria-hidden="true"><use href="#icon-plus"></use></svg>
Add card
</button>
</article>
</section>
<div class="board-footer">
<span>
<svg class="icon" aria-hidden="true"><use href="#icon-move"></use></svg>
Drag cards between columns to update their status
</span>
<span>
<svg class="icon" aria-hidden="true"><use href="#icon-session"></use></svg>
Session-only board · changes stay in memory
</span>
</div>
</main>
<div class="modal-layer" id="modalLayer" hidden>
<div class="modal-backdrop" data-close-modal aria-hidden="true"></div>
<section class="modal" id="cardModal" role="dialog" aria-modal="true" aria-labelledby="modalTitle">
<div class="modal-header">
<div>
<h2 id="modalTitle">Create a new card</h2>
<p>Add a clear next step for your team.</p>
</div>
<button class="icon-button" type="button" data-close-modal aria-label="Close modal">
<svg class="icon" aria-hidden="true"><use href="#icon-x"></use></svg>
</button>
</div>
<form id="cardForm" novalidate>
<label class="form-field" for="cardTitle">
<span>Card title <b class="required-mark" aria-hidden="true">*</b></span>
<input id="cardTitle" name="title" type="text" required maxlength="100" placeholder="e.g. Review checkout flow" autocomplete="off" aria-describedby="titleError">
</label>
<p class="field-error" id="titleError" hidden>Please enter a card title.</p>
<div class="form-row">
<label class="form-field" for="cardPriority">
<span>Priority</span>
<select id="cardPriority" name="priority">
<option value="low">Low</option>
<option value="medium" selected>Medium</option>
<option value="high">High</option>
</select>
</label>
<label class="form-field" for="cardColumn">
<span>Column</span>
<select id="cardColumn" name="column">
<option value="todo">To do</option>
<option value="progress">In progress</option>
<option value="done">Done</option>
</select>
</label>
</div>
<div class="modal-actions">
<button class="secondary-button" type="button" data-close-modal>Cancel</button>
<button class="primary-button" type="submit">
<svg class="icon" aria-hidden="true"><use href="#icon-plus"></use></svg>
Add card
</button>
</div>
</form>
</section>
</div>
<div class="toast" id="toast" role="status" aria-live="polite">
<span class="toast-message" id="toastMessage"></span>
<button class="toast-undo" id="toastUndo" type="button" hidden>Undo</button>
<button class="toast-close" id="toastClose" type="button" aria-label="Dismiss notification">
<svg class="icon" aria-hidden="true"><use href="#icon-x"></use></svg>
</button>
</div>
<div class="sr-only" id="liveRegion" aria-live="polite"></div>
<script>
(() => {
"use strict";
const state = {
theme: "light",
query: "",
nextId: 7,
nextCode: 127,
lastAddedId: null,
columns: [
{
id: "todo",
title: "To do",
cards: [
{
id: "card-1",
code: "WEB-121",
title: "Refine onboarding empty states",
priority: "high",
description: "Design helpful first-run content for new workspaces.",
tag: "UX design",
due: "Apr 22",
owner: "Lena Morris",
initials: "LM",
tone: "rose"
},
{
id: "card-2",
code: "WEB-122",
title: "Audit mobile navigation labels",
priority: "medium",
description: "Simplify labels and align the information architecture.",
tag: "Research",
due: "Apr 24",
owner: "Sam Kim",
initials: "SK",
tone: "green"
}
]
},
{
id: "progress",
title: "In progress",
cards: [
{
id: "card-3",
code: "WEB-123",
title: "Build usage analytics dashboard",
priority: "high",
description: "Add activation trends and weekly team insights.",
tag: "Development",
due: "Today",
owner: "Alex Khan",
initials: "AK",
tone: "blue"
},
{
id: "card-4",
code: "WEB-124",
title: "Update billing email templates",
priority: "low",
description: "Refresh copy for receipts, retries, and renewals.",
tag: "Content",
due: "Apr 23",
owner: "Jamie Reed",
initials: "JR",
tone: "amber"
}
]
},
{
id: "done",
title: "Done",
cards: [
{
id: "card-5",
code: "WEB-125",
title: "Fix timezone offset in reports",
priority: "medium",
description: "Normalize report timestamps to each workspace.",
tag: "Engineering",
due: "Apr 16",
owner: "Alex Khan",
initials: "AK",
tone: "blue"
},
{
id: "card-6",
code: "WEB-126",
title: "Publish April release notes",
priority: "low",
description: "Ship the final roundup to the customer changelog.",
tag: "Product",
due: "Apr 17",
owner: "Lena Morris",
initials: "LM",
tone: "violet"
}
]
}
]
};
const board = document.getElementById("board");
const searchInput = document.getElementById("searchInput");
const searchBox = document.getElementById("searchBox");
const clearSearch = document.getElementById("clearSearch");
const resultsSummary = document.getElementById("resultsSummary");
const progressLabel = document.getElementById("progressLabel");
const progressFill = document.getElementById("progressFill");
const progressText = document.getElementById("progressText");
const themeToggle = document.getElementById("themeToggle");
const themeLabel = document.getElementById("themeLabel");
const newCardButton = document.getElementById("newCardButton");
const modalLayer = document.getElementById("modalLayer");
const cardModal = document.getElementById("cardModal");
const cardForm = document.getElementById("cardForm");
const cardTitle = document.getElementById("cardTitle");
const cardPriority = document.getElementById("cardPriority");
const cardColumn = document.getElementById("cardColumn");
const titleError = document.getElementById("titleError");
const liveRegion = document.getElementById("liveRegion");
const toast = document.getElementById("toast");
const toastMessage = document.getElementById("toastMessage");
const toastUndo = document.getElementById("toastUndo");
const toastClose = document.getElementById("toastClose");
let draggedId = null;
let draggedElement = null;
let lastFocusedElement = null;
let toastTimer = null;
let undoAction = null;
const dropIndicator = document.createElement("div");
dropIndicator.className = "drop-indicator";
dropIndicator.setAttribute("aria-hidden", "true");
function createIcon(name, className = "icon") {
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
svg.setAttribute("class", className);
svg.setAttribute("aria-hidden", "true");
const use = document.createElementNS("http://www.w3.org/2000/svg", "use");
use.setAttribute("href", `#icon-${name}`);
svg.appendChild(use);
return svg;
}
function node(tagName, className, text) {
const element = document.createElement(tagName);
if (className) element.className = className;
if (text !== undefined) element.textContent = text;
return element;
}
function createCardElement(card) {
const article = node("article", "card");
article.draggable = true;
article.dataset.cardId = card.id;
article.setAttribute("aria-label", `${card.title}, ${card.priority} priority`);
if (state.lastAddedId === card.id) {
article.classList.add("just-added");
}
const top = node("div", "card-top");
const identity = node("div", "card-identity");
identity.appendChild(node("span", "card-code", card.code));
const priority = node("span", `priority-badge ${card.priority}`);
priority.setAttribute("aria-label", `${card.priority} priority`);
priority.appendChild(node("span", "priority-dot"));
priority.appendChild(document.createTextNode(card.priority));
identity.appendChild(priority);
const deleteButton = node("button", "delete-card");
deleteButton.type = "button";
deleteButton.draggable = false;
deleteButton.dataset.deleteId = card.id;
deleteButton.setAttribute("aria-label", `Delete ${card.title}`);
deleteButton.title = "Delete card";
deleteButton.appendChild(createIcon("trash"));
top.append(identity, deleteButton);
const title = node("h3", "", card.title);
article.append(top, title);
if (card.description) {
article.appendChild(node("p", "card-description", card.description));
}
const footer = node("footer", "card-footer");
const footerInfo = node("div", "card-footer-info");
footerInfo.appendChild(node("span", "card-tag", card.tag));
const due = node("span", "due-date");
due.appendChild(createIcon("calendar"));
due.appendChild(node("span", "", card.due));
footerInfo.appendChild(due);
const avatar = node("span", `avatar ${card.tone}`, card.initials);
avatar.title = card.owner;
avatar.setAttribute("aria-label", `Assigned to ${card.owner}`);
footer.append(footerInfo, avatar);
article.appendChild(footer);
return article;
}
function createEmptyState(hasQuery) {
const empty = node("div", "column-empty");
const iconWrap = node("span", "empty-icon");
iconWrap.appendChild(createIcon(hasQuery ? "search" : "plus"));
empty.appendChild(iconWrap);
empty.appendChild(node("strong", "", hasQuery ? "No matching cards" : "No cards here"));
empty.appendChild(node("small", "", hasQuery ? "Try another title search" : "Drag a card here or add a new one"));
return empty;
}
function renderBoard() {
let total = 0;
let visibleTotal = 0;
state.columns.forEach((column) => {
const list = document.getElementById(`${column.id}List`);
const count = document.getElementById(`${column.id}Count`);
const visibleCards = column.cards.filter((card) =>
card.title.toLowerCase().includes(state.query)
);
total += column.cards.length;
visibleTotal += visibleCards.length;
count.textContent = String(column.cards.length);
count.title = `${column.cards.length} total ${column.cards.length === 1 ? "card" : "cards"}`;
count.setAttribute("aria-label", `${column.cards.length} cards`);
list.setAttribute("aria-label", `${column.title} cards`);
list.replaceChildren();
if (visibleCards.length === 0) {
list.appendChild(createEmptyState(Boolean(state.query)));
} else {
visibleCards.forEach((card, index) => {
const element = createCardElement(card);
element.style.animationDelay = `${Math.min(index * 35, 105)}ms`;
list.appendChild(element);
});
}
});
const doneCount = state.columns.find((column) => column.id === "done").cards.length;
const percentage = total ? Math.round((doneCount / total) * 100) : 0;
progressLabel.textContent = `${percentage}%`;
progressFill.style.width = `${percentage}%`;
progressText.textContent = `${doneCount} of ${total} ${total === 1 ? "card" : "cards"} completed`;
resultsSummary.textContent = state.query
? `${visibleTotal} of ${total} shown`
: `${total} ${total === 1 ? "card" : "cards"}`;
clearSearch.hidden = !state.query;
searchBox.classList.toggle("has-query", Boolean(state.query));
state.lastAddedId = null;
}
function locateCard(id) {
for (const column of state.columns) {
const index = column.cards.findIndex((card) => card.id === id);
if (index !== -1) {
return { column, index, card: column.cards[index] };
}
}
return null;
}
function announce(message) {
liveRegion.textContent = message;
}
function hideToast() {
window.clearTimeout(toastTimer);
toast.classList.remove("visible");
undoAction = null;
}
function showToast(message, onUndo) {
window.clearTimeout(toastTimer);
toastMessage.textContent = message;
toastMessage.title = message;
undoAction = typeof onUndo === "function" ? onUndo : null;
toastUndo.hidden = !undoAction;
toast.classList.add("visible");
toastTimer = window.setTimeout(hideToast, 4800);
}
function deleteCard(id) {
const location = locateCard(id);
if (!location) return;
const { column, index, card } = location;
column.cards.splice(index, 1);
renderBoard();
announce(`${card.title} deleted from ${column.title}.`);
showToast(`Deleted “${card.title}”`, () => {
const target = state.columns.find((item) => item.id === column.id);
const restoredIndex = Math.min(index, target.cards.length);
target.cards.splice(restoredIndex, 0, card);
renderBoard();
announce(`${card.title} restored to ${target.title}.`);
});
}
function openModal(columnId = "todo") {
lastFocusedElement = document.activeElement;
cardForm.reset();
cardColumn.value = columnId;
cardPriority.value = "medium";
titleError.hidden = true;
cardTitle.removeAttribute("aria-invalid");
modalLayer.hidden = false;
document.body.classList.add("modal-open");
requestAnimationFrame(() => {
if (!modalLayer.hidden) cardTitle.focus();
});
}
function closeModal() {
if (modalLayer.hidden) return;
modalLayer.hidden = true;
document.body.classList.remove("modal-open");
if (lastFocusedElement && lastFocusedElement.isConnected) {
lastFocusedElement.focus();
}
}
function clearTitleError() {
if (cardTitle.value.trim()) {
titleError.hidden = true;
cardTitle.removeAttribute("aria-invalid");
}
}
function getDragAfterElement(list, mouseY) {
const cards = Array.from(list.querySelectorAll(".card")).filter((card) =>
card.dataset.cardId !== draggedId
);
return cards.find((card) => {
const rect = card.getBoundingClientRect();
return mouseY < rect.top + rect.height / 2;
}) || null;
}
function cleanupDrag() {
if (draggedElement) {
draggedElement.classList.remove("dragging");
draggedElement.removeAttribute("aria-grabbed");
}
board.classList.remove("is-dragging");
board.querySelectorAll(".column").forEach((column) => column.classList.remove("drag-over"));
dropIndicator.remove();
draggedId = null;
draggedElement = null;
}
board.addEventListener("click", (event) => {
const deleteButton = event.target.closest("[data-delete-id]");
if (deleteButton) {
deleteCard(deleteButton.dataset.deleteId);
return;
}
const addButton = event.target.closest("[data-add-column]");
if (addButton) {
openModal(addButton.dataset.addColumn);
}
});
board.addEventListener("dragstart", (event) => {
const card = event.target.closest(".card");
if (!card) return;
if (event.target.closest("button")) {
event.preventDefault();
return;
}
draggedId = card.dataset.cardId;
draggedElement = card;
card.setAttribute("aria-grabbed", "true");
board.classList.add("is-dragging");
if (event.dataTransfer) {
event.dataTransfer.effectAllowed = "move";
event.dataTransfer.setData("text/plain", draggedId);
}
window.setTimeout(() => {
if (draggedElement === card) card.classList.add("dragging");
}, 0);
});
board.addEventListener("dragover", (event) => {
const column = event.target.closest(".column");
if (!column || !draggedId) return;
event.preventDefault();
if (event.dataTransfer) event.dataTransfer.dropEffect = "move";
board.querySelectorAll(".column").forEach((item) => {
item.classList.toggle("drag-over", item === column);
});
const list = column.querySelector(".card-list");
const afterElement = getDragAfterElement(list, event.clientY);
if (afterElement) {
list.insertBefore(dropIndicator, afterElement);
} else {
const emptyState = list.querySelector(".column-empty");
list.insertBefore(dropIndicator, emptyState || null);
}
});
board.addEventListener("drop", (event) => {
const columnElement = event.target.closest(".column");
if (!columnElement || !draggedId) return;
event.preventDefault();
const movingId = draggedId;
const targetColumn = state.columns.find((column) => column.id === columnElement.dataset.column);
const location = locateCard(movingId);
const list = columnElement.querySelector(".card-list");
let beforeId = null;
if (dropIndicator.parentElement === list) {
let next = dropIndicator.nextElementSibling;
while (next && (!next.classList.contains("card") || next.dataset.cardId === movingId)) {
next = next.nextElementSibling;
}
beforeId = next ? next.dataset.cardId : null;
}
if (!location || !targetColumn) {
cleanupDrag();
return;
}
location.column.cards.splice(location.index, 1);
let insertIndex = beforeId
? targetColumn.cards.findIndex((card) => card.id === beforeId)
: targetColumn.cards.length;
if (insertIndex < 0) insertIndex = targetColumn.cards.length;
targetColumn.cards.splice(insertIndex, 0, location.card);
cleanupDrag();
renderBoard();
announce(`${location.card.title} moved to ${targetColumn.title}.`);
});
board.addEventListener("dragend", cleanupDrag);
searchInput.addEventListener("input", () => {
state.query = searchInput.value.trim().toLowerCase();
renderBoard();
});
clearSearch.addEventListener("click", () => {
searchInput.value = "";
state.query = "";
renderBoard();
searchInput.focus();
});
themeToggle.addEventListener("click", () => {
state.theme = state.theme === "light" ? "dark" : "light";
const isDark = state.theme === "dark";
document.documentElement.dataset.theme = state.theme;
themeToggle.setAttribute("aria-pressed", String(isDark));
themeToggle.setAttribute("aria-label", `Switch to ${isDark ? "light" : "dark"} theme`);
themeToggle.title = `Switch to ${isDark ? "light" : "dark"} theme`;
themeLabel.textContent = isDark ? "Light" : "Dark";
});
newCardButton.addEventListener("click", () => openModal("todo"));
modalLayer.addEventListener("click", (event) => {
if (event.target.closest("[data-close-modal]")) closeModal();
});
cardTitle.addEventListener("input", clearTitleError);
cardForm.addEventListener("submit", (event) => {
event.preventDefault();
const title = cardTitle.value.trim();
if (!title) {
titleError.hidden = false;
cardTitle.setAttribute("aria-invalid", "true");
cardTitle.focus();
return;
}
const targetColumn = state.columns.find((column) => column.id === cardColumn.value);
const id = `card-${state.nextId++}`;
const newCard = {
id,
code: `WEB-${state.nextCode++}`,
title,
priority: cardPriority.value,
description: "",
tag: "New task",
due: "Just added",
owner: "You",
initials: "YO",
tone: "violet"
};
targetColumn.cards.unshift(newCard);
state.lastAddedId = id;
state.query = "";
searchInput.value = "";
renderBoard();
closeModal();
showToast(`Added “${title}” to ${targetColumn.title}`);
announce(`${title} added to ${targetColumn.title}.`);
if (window.innerWidth <= 820) {
const addedElement = board.querySelector(`[data-card-id="${id}"]`);
if (addedElement) {
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
addedElement.scrollIntoView({ behavior: reduceMotion ? "auto" : "smooth", block: "center" });
}
}
});
toastUndo.addEventListener("click", () => {
const action = undoAction;
hideToast();
if (action) action();
});
toastClose.addEventListener("click", hideToast);
document.addEventListener("keydown", (event) => {
if (!modalLayer.hidden) {
if (event.key === "Escape") {
event.preventDefault();
closeModal();
return;
}
if (event.key === "Tab") {
const focusable = Array.from(
cardModal.querySelectorAll("button:not([disabled]), input:not([disabled]), select:not([disabled])")
);
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();
}
}
return;
}
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "k") {
event.preventDefault();
searchInput.focus();
searchInput.select();
return;
}
if (event.key === "Escape" && document.activeElement === searchInput && searchInput.value) {
searchInput.value = "";
state.query = "";
renderBoard();
}
});
renderBoard();
})();
</script>
</body>
</html>Reasoning trace (16,193 tokens)
Kimi needs to build a single HTML self-contained interactive kanban vanilla. Should meet requirements high aesthetic and functionality. Must output exactly code block. Should have no external. Make sure visible first paint alive. Game requirement irrelevant. Should adapt 1280x800 to 375 no horiz scrollbar. Should card 6 realistic spread columns. 3 columns counts. Each card title priority colored badge. Add modal title required delete. DnD mouse with clear visual drop indicator. Could use native HTML5 drag/drop plus perhaps pointer-based? Mouse requirement native works. I can add touch optional pointer? Vanilla no libs. Search live. Theme toggle. Memory only. Should design excellence. Should have no gradients? Instructions don't forbid gradients? System no. I can use subtle gradients, but avoid overly generic. Should craft polished UI perhaps project management dashboard light/dark, header branding, board, cards, subtle animations. Single page. At 1280x800 need content fit enough. 6 cards across. Add card buttons. Column backgrounds. Modal accessible. Drag ghost placeholder with clear blue line. Drop handling. Counts update filter? Column card count likely all count vs filtered? Show counts actual total, perhaps counts total even filtered? Search filtering live title. I can hide filtered, counts should actual totals. Could show hidden message? optional. Could use search with keyboard shortcut, clear. Theme toggle sun/moon. State memory board object. No storage. Interactions robust.
Should consider DnD events and delete interfering. Use draggable true card; delete button draggable? clicking should not start drag. Native drag event card. Drop indicator with CSS card.dragging opacity, body.drag-active perhaps. Use dropIndicator div positioned in list. On dragover compute after element based mouse Y and move placeholder. On drop insert state reorder. Native drag events and render can mess. Simpler event delegation and render on drop. Each list has drop zone. On dragstart record id, source col; add dragging class requestAnimationFrame? I can add after event. Create placeholder as div in list on dragover. Indicator can be a 4px horizontal blue bar with glowing dot, margins. "clear visual drop indicator." Use placeholder .drop-indicator height 4px line + circle, not occupying full card. On dragenter list add .drag-over. Drop indicator computed among visible cards excluding dragged. Since hidden filtered cards still in DOM display none. Use querySelectorAll('.card:not(.dragging):not([hidden])'). hiddenattribute display none default via CSS [hidden]{display:none!important}. Determine nearest based rect midpoint; insertBefore indicator. On drop determine index relative overall column data including hidden cards. If just compute DOM card after indicator among all cards and find its id. I can insert indicator before nearest visible or end; to derive target index among column cards, get next card element after indicator ignoring indicator, use nextElementSibling while not card. If next card ID get index in target array else length. Then remove source and insert. But moving within same column remove first adjusts index. Should be robust: remove dragged from old array, then recalc target index based next card id after removal or length. At drop capture nextId from DOM then mutate. If nextId==dragged? indicator excludes dragged and dragged still DOM maybe indicator positioning query excludes dragging but original card in list may sit before indicator; nextElementSibling skip all until card could be original dragged. I can set dragged card display:none? opacity .35 but still included; helper find next card excluding dragged. Use nextId. Remove source, find target index nextId in target array else length. Insert. Render all. Data state columns array cards. on dragend clean. Render after drop then draggedId null. drop indicator removed.
Search re-render on input? Could set hidden per cards and update result summary; render easiest. During drag search not happening. search field value state? render columns only not whole header. Build static columns and render cards into them. Could generate via JS. Modal static HTML. Card HTML built DOM not string for safe user input. Should have no XSS. Use createElement textContent. Could use template with escaping but DOM better. I need card metadata perhaps dates, labels avatar for aesthetic. Requirement only title priority, but realistic cards could include descriptions tags, avatars, due dates. Cards can have tiny task IDs, icons, owner initials, dates, comments. 6 realistic tasks. Should prepop spread perhaps To do 3, In progress 2, Done 1 to show alive. But 3 columns heights; first screenshot 1280x800 looks balanced. Could 2 each =6 better. To do: "Refine onboarding empty states" high, "Audit mobile navigation labels" medium; In progress: "Build usage analytics dashboard" high, "Update billing email templates" low; Done: "Fix timezone offset in reports" medium, "Publish April release notes" low. More visual. Perhaps cards have category tags and task IDs. Add small subtitle and footer avatar/due date. Delete button appears on hover but requirement button on it; should visible at low opacity always. Modal title input required, priority select and column select optional description? "small modal form (title required)" add title, priority, column. Nice. On submit new id generated in memory; add to target array at top or bottom. Board count updates. close overlay, X, cancel, Escape. Focus management return focus. Native dialog? <dialog> with showModal, CSS backdrop; supported. Could use custom div to maximize sandbox reliability. Use <div class=modal-backdrop hidden> and <section role=dialog aria-modal=true>. showModal class and body overflow hidden. Focus trap maybe nice. Escape close, click backdrop close, submit. Required input plus custom validation maybe title whitespace considered required despite spaces. on submit trim; if empty set custom error or input.setCustomValidity. Native required catches empty but whitespace accepted; use trim and show error. Could add inline .field-error. Form title required. Modal focus. Keep within viewport mobile.
Theme toggle: state `theme = 'light'`, html data-theme, icon updates, label. Default light. CSS variables. No storage. toggle with button accessible aria-pressed (dark true). Could include sun/moon inline svg. Should have everything inline no images. Icons inline svg or use CSS. Could define hidden svg symbols and use `<svg><use href="#icon-plus"></use></svg>` inline works. To avoid repetitive JS SVG strings maybe function icon(name) create SVG innerHTML referencing safe constant names. Use `<svg class="icon"><use href="#i-plus"></use></svg>`. External no.
No local storage etc. Don't use alert. Should responsive: .board display grid repeat(3,minmax(0,1fr)), gap 18, max-width 1440 margin. At 1280 margins 32. Height fit. At <=900 could keep 3 columns if width 768 each 240 okay. At <=760 maybe board becomes horizontal? No horizontal scrollbar prohibited. So stack columns one column, no horizontal. At 375 each full width. First paint mobile top todo only but adapt gracefully. Could at <=800 stack; page long. Fine. At 1280 800 all visible. Header compact. Perhaps top shell with nav. Should have no excessive height. Main: header top 72, intro 110, toolbar 60, board ~500. 6 cards 2 each height 150 so 350 plus headings 60, all fit 800. Should have visual alive. I can use 3 in todo, 2 in progress, 1 done? Then max height 500, within 800 maybe. 2 each balanced. Count badges.
Perhaps design specific "Plane / Product workspace". No external fonts. Use system fonts Inter fallbacks. Use dark navy text, off-white background, border. Accent indigo #635bff or teal? Sophisticated. Could use azure #5367e8. Background subtle radial gradients? Requirement no image. Use pseudo decorative blurred orb? Avoid. I can do `body::before` subtle dotted pattern? A top blue line. Could have elegant "KANBAN / WORKSPACE" label and "Sprint 24 · Product website". Header logo geometric 4 tiles. Project title "Website relaunch" and small team avatars. Stats top "6 tasks" etc. Theme toggle. Search. Button. Column each with color dot and icon. Cards white, rounded 14, shadows. Drag handle maybe six dots top left, card cursor grab. Priority badges colored red/amber/green. Done cards title slight muted? No line-through perhaps check circle green. Each card `data-id`. Card metadata: ID e.g. `WEB-124`, priority badge top; title; small desc; tag; footer owner avatar, due. Use only title + priority enough but realistic details enrich. Added cards can have generic ID and owner You, date "New". Use descriptions optional? Modal has optional description? Could include to keep. Requirement small modal form, add title and optional details? Title, priority select, column select. Details not needed. Add auto description "Added to the project board."? Better no description if blank, card gets "No additional details" maybe less polished. I can support optional notes input but small modal becomes 3 fields + textarea. Could use title required + two selects only. New cards render description only if provided (initial ones). Height variable. New card footer tag "New task" and owner You. Good.
Search field live by title case-insensitive, perhaps highlights matched text? Not necessary. Empty filtered column show `.empty-filter` "No matching cards" only if query and visible count 0, else column truly empty "Drop cards here" (when no cards). Search meta shows "Showing X of 6 cards" and clear button appears. `input` event renderBoard. But render Board regenerates columns and all card DOM, fine. Search has clear button. Ctrl/Cmd+K focus maybe nice (not required). Keyboard events only after click iframe; no demo game. Add "/" or Cmd K hint visible kbd. On mobile hide kbd. Escape clear search if focused or close modal. Should avoid Escape interfering drag? fine.
Drag and drop native events: Need ensure deletion button not draggable? `button` inside draggable can still click. Add delete button `draggable=false` maybe. Buttons inside draggable parent can be difficult; handle `dragstart` if target.closest('button') preventDefault. Add a dedicated drag handle? requirement card drag mouse. whole card. On mobile HTML5 drag unsupported but requirement mouse. Could add pointer fallback but complex. No need. Perhaps status live region announces move/delete/add. Accessibility. Use `<div id=toastContainer>` small toast bottom right, appears for actions. This makes interactive. Add `role=status`. Should have no initial toast maybe screenshot clean. During drag show small top indicator? clear line enough.
State:
```
const state={
filter:'',
theme:'light',
nextId:137,
columns:[
{id:'todo', title:'To do', color:'slate', cards:[...]}, ...
]
};
let draggedId=null, sourceColumnId=null;
const indicator=document.createElement('div'); indicator.className='drop-indicator'; ...
```
renderBoard:
for each column object existing section `data-column`; get count `.column-count`; list.innerHTML = ''; let visible=0; cards foreach if match create; append. hidden? If I only render filtered cards, drop reorder index based nextId still works with hidden absent; find next card id in target after removal. Fine. If search active and drop target with hidden cards, inserting before next visible works; if at end appends after hidden. Good. Simpler only visible cards. Counts total. board summary text. If no visible cards and filter, append empty state. But during drag filter empty, list has empty state interfering next indicator? indicator inserted, `getAfter` uses cards only. `list.querySelector('.empty-state')?.remove()` on dragover? Could keep; drop indicator inserted before empty maybe. Better empty state sits not card and list. On dragover, if no visible cards insert indicator before empty-state or append? I can remove empty state while drag over and restore on render/drop. Or `list.insertBefore(indicator, list.querySelector('.empty-state'))`. Helper. I can use `.column-empty` with pointer-events none. Indicator line at top.
To avoid rebuilding static column content, create all via JS initial. On first paint JS executes before screenshot. Could static HTML columns and lists then render. Good.
Card fields: `id`, `title`, `priority`, `description`, `code`, `tag`, `due`, `owner`, `initials`, `tone`. Create element:
```
<article class="card priority-high" draggable="true" data-card-id=... aria-label=...>
<div class=card-top>
<span class=card-code>...</span>
<span class="priority-badge high"><span class=priority-dot>...</span>High</span>
<button class=delete ...>svg trash</button>
</div>
<h3>...</h3>
<p>...</p>
<div class=card-footer>
<div class=card-meta><span class=tag>...</span> ... due</div>
<span class=avatar ...>AK</span>
</div>
</article>
```
Delete top right means priority and delete crowd; place top row code + badge left, delete right. Group. On 300 width okay. Card footer tag left, due and avatar. Should fewer.
Card can have icon based column e.g. check? Not necessary.
CSS card delete opacity .45 visible, hover 1. At touch opacity .75. `.card:hover .delete-card {opacity:1}`. On first screenshot buttons subtle but there. requirement button exists. Title 15px. Badge uppercase 10px. Card height ~165 with description. 2 per col + gap 12 =342. Board top 60 + add 40 maybe 450. Main top header etc 240 ->690. Good.
Header design:
```
<header class=app-header>
<div class=header-inner>
<a? brand no link maybe div>
<div class=brand-mark><span...>
<div class=brand-name>Northstar</div><span class=brand-badge>Workspace</span>
</div>
<div class=header-actions>
<div class=collaborators>avatars etc <span>4 online</span></div>
<button id=themeToggle ...>
</div>
</div>
</header>
<main>
<section class=project-header>
<div>
<div class=eyebrow>PRODUCT TEAM <span>/</span> SPRINT 24</div>
<h1>Website relaunch <span class=status-pill>On track</span></h1>
<p>Plan, prioritize, and move work forward.</p>
</div>
<div class=sprint-meta>... Apr 18–30 etc</div>
</section>
<section class=toolbar>
<label search> ...
<div toolbar-right>
<span id=visibleSummary>6 cards</span>
<button add>
</div>
</section>
<section board aria-label>
...
</section>
<footer maybe "Drag cards..." and updated just now? Not necessary.
</main>
```
At 1280 content max 1180. Header. Could use title row h1 30. Use simple team avatars. No images just initials. Aesthetic.
At 375, header brand badge hidden, collaborators hidden, header padding 16. project header block, sprint meta hidden or below. Toolbar flex wrap search full and add. Board one column. No horizontal. Body min-width? no. Use box sizing. modal width calc(100%-32px). Buttons. Badge text.
Theme CSS variables: `:root` light and `html[data-theme="dark"]`. Dark bg #0f1420, surface #171d29, column #141a25, borders #2a3342. Text #edf1f7. Badges backgrounds alpha using rgba. Use `color-scheme`. transition background .2. Card shadow. theme icon toggle show correct. Toggle maybe pill with sun/moon and sliding knob? Could do single button 42x42 with both icons CSS display. Clear label tooltip. Better theme toggle labelled with icons and text "Dark" maybe header compact. Pill `.theme-toggle` contains sun/moon and `<span id=themeLabel>Dark</span>` at desktop. On mobile only icon. It should toggle. `aria-pressed`.
Background details: body radial gradient? "everything inline" fine. Use `background: var(--bg)` plus subtle linear top. Light board: #f5f7fb, cards #fff, columns #eef1f6. Add top accent on card by priority? Colored badge enough. Avoid gradients? subtle header brand only. Brand mark background #5966e9.
Drag style: card.dragging opacity .25, transform scale(.985); column.drag-over border-color accent and list background. `.drop-indicator{height:4px;background:#5b6ceb;border-radius:999px;margin:8px 2px;position:relative;box-shadow:0 0 0 3px rgba(...)} .drop-indicator::before/after circles at ends maybe clear. Also `.drop-indicator::after{content:'Drop here'; position:absolute; ...}` would height weird. Could line + small pill "Drop here" positioned center above; might overlap. Clear line and highlighted target enough. Add top/bottom circular endpoints. In dark still.
Native DnD setup event delegation:
```
board.addEventListener('dragstart', e=>{
const card=e.target.closest('.card');
if(!card || e.target.closest('button')) { e.preventDefault();return;}
draggedId=card.dataset.cardId;
sourceColumnId=card.closest('.column').dataset.columnId;
e.dataTransfer.effectAllowed='move';
e.dataTransfer.setData('text/plain',draggedId);
card.classList.add('dragging');
board.classList.add('is-dragging');
requestAnimationFrame(()=>card.classList.add('dragging')); // if immediate browser drag image includes opacity? setTimeout 0.
});
```
Set drag image default. If class immediate may drag ghost faint. setTimeout.
```
board.addEventListener('dragover', e=>{
const list=e.target.closest('.card-list');
if(!list||!draggedId)return;
e.preventDefault(); e.dataTransfer.dropEffect='move';
board.querySelectorAll('.column.drag-over')....
list.closest('.column').classList.add...
const after=getDragAfterElement(list,e.clientY);
const empty=list.querySelector('.column-empty');
if(after==null) list.insertBefore(indicator,empty||null);
else list.insertBefore(indicator,after);
});
board.addEventListener('drop', e=>{
const list = ...; if(!...)return;
e.preventDefault();
const columnId=...
let next = indicator.nextElementSibling;
while(next && !next.classList.contains('card')) next=next.nextElementSibling;
const beforeId=next?.dataset.cardId ?? null;
moveCard(...);
cleanupDrag(); renderBoard(); showToast...
});
board.addEventListener('dragend',...cleanup)
```
If dropping outside list no move. Drag leaves column, remove class only if not current? `dragleave` check `!column.contains(e.relatedTarget)`, relatedTarget may null. optional. dragover constantly sets all others remove. cleanup removes classes and indicator. `.card.dragging` from render removed; before render cleanup find. `indicator.remove()`.
On drop `moveCard` should use sourceId stored not source column maybe find current. DataTransfer not rely.
```
function moveCard(targetId,beforeId){
let card, from;
for cols find index;
if no return;
from.cards.splice(index,1);
const target=...
let insertIndex=beforeId ? target.cards.findIndex... : target.cards.length;
if(insertIndex<0) insertIndex=target.cards.length;
target.cards.splice(insertIndex,0,card);
}
```
If moving within same col and indicator at original exact place, beforeId from DOM excludes dragged but original dragged element still in list. getDragAfter excludes `.dragging`, but nextElementSibling could be dragged if indicator before original? Cases: `after` never dragged. If indicator appended end, original dragged remains before indicator so next none. beforeId null means moved to end even if originally at end no issue. If after card comes after original etc next correctly. If indicator inserted directly before original? after excludes original so not. `next` skip original dragged card to next card:
```
while(next && (!next.classList.contains('card') || next.dataset.cardId===draggedId)) next=next.nextElementSibling;
```
But if indicator inserted before visible card after original and next=that card fine. Good.
`dragend` fired after drop and render, cleanup safe. `draggedId` set null after move? cleanup before render maybe. Make sure move capture.
```
const id=draggedId;
const source=sourceColumnId;
move...
cleanupDrag();
renderBoard();
```
cleanup sets null.
Counts update. progress bar maybe bottom column? Not needed. Add card per column buttons `data-add-column`. Main add defaults todo. Each column header with icon and count; footer button "Add card" at bottom, this fulfills add too. Main primary "New card". Modal select default based clicked. Use board click event:
```
const add=e.target.closest('[data-add-column]'); openModal(add.dataset.addColumn)
const del=e.target.closest('[data-delete-id]'); deleteCard(...)
```
Delete button immediate, no confirm (forbidden confirm). Maybe animate removal? On click add `.removing` and setTimeout 140 then state update. But easier immediate + toast with undo? Undo would be great but not required. Add toast "Card deleted" with Undo button, memory. Could implement to enrich. But toast with action complexity manageable. If delete immediate, state store lastDeleted {card,columnId,index}; showToast message action button "Undo"; next toast resets. Need ensure state memory. Could implement universal toast `showToast(message, undoCallback?)`. Avoid alerts. `toast` class show, clear timer. Undo button hidden unless callback. Could build static toast at bottom. On delete show `“Title” deleted` and Undo. Undo reinsert, render. This is nice. For move no toast? Could announce in live region but not toast to avoid noise. For add show toast. Toast appears after interaction only screenshot first 3s without interaction no. Fine.
No confirm means deletion direct as required.
Potential issue visible first screenshot at 3 seconds and animations initial. Could animate cards with CSS fade-up on load. Screenshot at 3 seconds all done. Good. `@keyframes cardIn`. But each render (search input) animates all could be distracting. Set animation only initial `.board-ready`? Simple `.card {animation:card-in .3s both}` rerender makes animate but okay. Drag render after drop animates; okay. Respect prefers reduced motion.
Search:
```
searchInput.addEventListener('input', () => {state.filter=searchInput.value; renderBoard();});
clearSearch hidden = !query;
summary.textContent = query ? `${shown} of ${total} shown` : `${total} active cards` etc.
```
`hidden` attr CSS. Search label. Clear button with x. Counts actual; `aria-label="To do, 2 cards"`.
If filter matches none, global `searchEmpty`? Each column shows "No matches". Nice.
No submit form search.
Modal:
HTML hidden `.modal-layer` includes backdrop button? Use `<div class=modal-backdrop data-close-modal></div><section class=modal>`. hidden entire. CSS `[hidden]`. On open `modalLayer.hidden=false; requestAnimationFrame classList.add('open')` transition. On close remove open then after 160 hidden, but rapid? Simpler show immediate and animate CSS `animation:modalIn`. close immediate. Use `document.body.classList.add('modal-open')` overflow hidden. Focus input setTimeout? direct after hidden false. `lastFocused = document.activeElement`.
Click close buttons. overlay e.target matches `[data-close-modal]`.
Form fields:
```
<label>Card title <span>*</span><input id=cardTitle name=title required maxlength=90 placeholder="e.g. Review checkout flow" autocomplete=off></label>
<p class=field-error id=titleError hidden>Please enter...</p>
<div class=form-row>
<label>Priority<select>...
<label>Column<select>...
</div>
<div modal-actions><button type=button cancel>Cancel</button><button submit>Add card</button>
```
Required native UI is browser bubble (not alert API) allowed. For trim invalid use error inline:
```
if(!title){ error.hidden=false; input.setAttribute('aria-invalid',true); input.focus(); return;}
```
`form.noValidate`? If noValidate then custom consistent. Could not set and submit event doesn't fire if empty, native required satisfies. But whitespace handling inline. Use `novalidate` and own error for all empty. Title required attribute still. `aria-describedby`.
On input clear error.
New card:
```
{ id:`task-${Date.now()}` maybe Date safe but next sequential unique; code:`WEB-${state.nextCode++}`, title, priority, description:'', tag:'New task', due:'Just added', owner:'You', initials:'YO', color:'violet'}
```
Use `state.sequence` and id `card-${++}`. Add to beginning of target cards. If current filter doesn't match new title, might not appear, confusing. On add clear search? Usually yes or preserve? Better clear search so new visible and render, but live filter state resets unexpectedly. Could if query and new doesn't match, clear filter + input value and toast "Card added. Search cleared." Simpler always preserve and toast; if hidden user confused. Better open modal from toolbar with search active, upon add reset search (state.filter=''; input.value=''), render. Could mention no. Fine. Add to target at start, then close and scroll card into view? At desktop no. Mobile adding to progress below may not visible. I can highlight card and `scrollIntoView({behavior:'smooth', block:'center'})` maybe on add after close but can cause board jump. Nice but not necessary. Add `.newly-added` flash. Set state.lastAddedId then card class highlight. Use `setTimeout` query `[data-card-id=...]` scroll only if viewport <800? Could avoid. The new card gets highlight.
Search title only exact requirement. Use `.toLocaleLowerCase()` includes. not description.
Theme:
```
themeToggle.addEventListener('click', ()=>{
state.theme = state.theme === 'light'?'dark':'light';
document.documentElement.dataset.theme=state.theme;
themeToggle.setAttribute('aria-pressed', String(...));
themeLabel.textContent=...
});
```
Inline script at body end; to avoid theme initial mismatch no issue. Dark scrollbars etc.
Keyboard: Escape close modal, `/` focus search unless typing, `Ctrl/meta+k`. Delete? no. Modal focus trap:
```
document.addEventListener('keydown', e=>{
if(!modal.hidden && e.key==='Tab'){...}
...
});
```
nice but not essential. Add accessible labels. `aria-live` for counts? `.column-count aria-label`.
No libraries, no network, no assets.
Potential requirement "no horizontal scrollbar" ensure `body overflow-x:hidden`, but not rely on hiding layout errors. board width max. `min-width:0` columns. card text overflow-wrap anywhere. `svg width`.
At 375 toolbar primary text maybe. CSS:
```
@media(max-width:800px){ .board{grid-template-columns:1fr;} .column{min-height:0;} .card-list{min-height:80px;} .sprint-details{display:none;} }
@media(max-width:600px){ main padding 22px 16px; .project-heading align start; .toolbar{align-items:stretch; flex-direction:column;} .search-wrap{width:100%;} .toolbar-actions{justify-content:space-between;} .header... .theme-label display none; .card...} }
```
At 800 stack perhaps early but at 768 board long. fine. At 900 grid columns ~280. At 1280 content 1200: columns 384. Great.
First screenshot at 1280x800:
Header height 68. Main padding 32 top. Project header h1 31 + eyebrow 12 + p 16 + margins total 90. Sprint header maybe 106. Toolbar 52 + margin 24. Board header 52 + cards 2*174 + gaps + add button 36 + paddings =482. total 68+32+100+24+48+20+480=772. Good. Need avoid bottom cut; okay if page scroll slight but first screenshot board alive. Set card description line-clamp 2 and min heights consistent. Cards ~168. Main board fits. At 1280x800, no need all add buttons visible maybe yes. I can tighten:
- header 64
- main padding-top 28
- project header 80
- toolbar 48 with margin 22 top 20 bottom
- board column header 48, card 155*2 +12 + list padding? list no; column body padding 12; footer 40 =444. total 64+28+82+20+48+20+444+28=734. good.
Six cards 2 each exactly.
Card fields: top 24, title 42 (2 lines), desc 36, footer 28 + gaps + padding 32 =172. 2=356; total 484; bottom 774. fine. Use title one or two lines. Initial titles:
To do:
1 "Refine onboarding empty states" (2 lines perhaps 1 at 350px)
desc "Design helpful first-run content for new workspaces."
tag UX DESIGN, due Apr 22, avatar LM.
2 "Audit mobile navigation labels"
desc "Simplify labels and align the information architecture."
tag RESEARCH, due Apr 24, avatar SK.
In progress:
1 "Build usage analytics dashboard"
desc "Add activation trends and weekly team insights."
tag DEVELOPMENT, due Today, avatar AK high.
2 "Update billing email templates"
desc "Refresh copy for receipts, retries, and renewals."
tag CONTENT, due Apr 23, avatar JR low.
Done:
1 "Fix timezone offset in reports"
desc "Normalize report timestamps to each workspace."
tag ENGINEERING, due Apr 16 avatar AK medium
2 "Publish April release notes"
desc "Ship the final roundup to the customer changelog."
tag PRODUCT, due Apr 17 avatar LM low.
All 2 each counts 2. Done column could cards opacity? Keep.
Maybe top global progress "Sprint 24 · 8 days left", header. Could show progress bar 2/6 done 33%. Adds visual interest. `project-header` right `.sprint-card` with small "SPRINT PROGRESS" number 33%, progress track. Must update on move? Could dynamically update to done percentage. Great. State changes counts. First screenshot alive. Header right panel:
```
<div class=sprint-overview>
<div><span>Sprint progress</span><strong id=progressLabel>33%</strong></div>
<div class=progress-track><span id=progressFill style=width:33%></span></div>
<small id=progressText>2 of 6 cards completed</small>
</div>
```
Width 220. Update each render. CSS no gradients. Adds meaningful dynamic. At mobile hidden? Could display full width under heading, okay. Project header flex. At <=620 hide `.sprint-overview`? Could show but consumes. maybe `.sprint-overview{width:100%; max-width:none}` below. At <=480 hide to reduce. Graceful either.
Team avatar stack top header. Could have online dot. Might be extra but aesthetic. Brand "Kado" maybe arbitrary. Use "Tandem" with mark. "Tandem" project management. Distinct design. Let brand "Flowboard" generic. "Northstar" fits project. Brand `plane` maybe "Frame". I can choose "Kanso" but Kanban. "Nook" no. I'll do logo 3x3 tiles and "Orbit" with `WORKSPACE` label. Project Website relaunch. Nice.
Use icon symbols: plus, search, sun, moon, trash, x, chevron-down? Native select no. calendar, clock, more? I can inline repeated:
SVG defs at top body hidden width0 height0:
```
<symbol id=icon-plus viewBox=...><path .../>
```
`<svg aria-hidden=true style=position:absolute;width:0;height:0;overflow:hidden>...`.
`createIcon(name)` returns:
```
const svg=document.createElementNS...
svg.setAttribute...
const use=...
use.setAttribute('href',`#icon-${name}`);
```
No innerHTML needed. Static can use `<svg class=icon><use href="#icon-search"/></svg>`.
No external.
Card create with helper:
```
function el(tag,className,text)...
```
`card.draggable=true; card.tabIndex=0`? Native draggable article not keyboard accessible but mouse requirement. I could add `aria-grabbed` but obsolete. Use `tabindex=0` for delete? h3 not. Avoid unnecessary.
Delete button title `Delete ${card.title}`.
Avatar style classes tone. CSS variable `--avatar` per class. `.avatar.violet`.
Due icon perhaps `clock`. tag with small uppercase. Done tag perhaps check. Footer:
```
<div class=card-details><span class=card-tag>Development</span><span class=due-date>svg calendar Apr 22</span></div>
<div class="avatar violet" title="Alicia Keys">AK</div>
```
Card top code and badge + delete. Good.
Column header includes `.column-icon` different symbol: circle todo, loader in progress, check done. Could use colored dot simpler. `span.column-dot`. count. At right small column menu? Not required don't add inert controls. Add button only functional. No fake controls. Header collaborators not controls just visuals. theme works. Main add works. Sprint overview dynamic. Search kbd hint works via Ctrl K.
Column background colors with top border? `column::before` thin color? Use dot. Perhaps status pill "2" counts. `.column[data-column=done] .column-count` etc.
Add per-column ghost button with plus. Native drag target entire `.card-list` but if list height only content and footer outside, dragging empty spaces in column after cards may target list if min-height 300? Set `.card-list{min-height:310px;}` 2 cards 340 so fits. When cards move, empty list minheight 250 to drop. Column is flex; list flex:1 min-height:170. At first, board column heights based max content equal if grid stretch. `.column{display:flex;flex-direction:column;min-height:460px}` ensures empty drop. `.card-list{flex:1;min-height:180px;padding:0 12px}` and footer. All columns equal height due grid stretch. If To do 2 etc equal. At mobile min-height: auto, list min-height:100. Dropping to column header or add footer? board dragover only list. Could attach to `.column` and find list, so anywhere column accepted:
```
const column=e.target.closest('.column');
if(!column) return;
const list=column.querySelector('.card-list')
```
drop similarly. That makes easy. indicator position based cursor.
Need `dragover` on add button prevent default and highlight; drop uses same.
Drop indicator height with negative? get after:
```
const cards=[...list.querySelectorAll('.card:not(.dragging)')];
return cards.find(el=> e.clientY < rect.top+rect.height/2) || null;
```
If filtered cards no dragging class until timeout; immediate dragover before class could include dragged; exclude dataset id too.
When list empty `.empty-state` occupies min height; insert indicator at end before empty. `empty` reinsert maybe. `positionDropIndicator`:
```
const reference=getAfter...
if(reference) list.insertBefore(indicator,reference);
else {
const empty=list.querySelector('.column-empty');
list.insertBefore(indicator,empty);
}
```
If empty state remains after indicator at bottom, visual line at top then empty below; okay but weird. On drag start add board class `.is-dragging .column-empty{display:none}` so line bottom. Good.
List may have no cards with no filter but empty hidden during drag.
`dragend` clean all columns and indicator.
Delete animation optional. Use immediate. Undo:
```
let toastTimer, undoAction=null;
function showToast(message, onUndo){
clearTimeout...
toastMessage.textContent=message;
undoAction=onUndo||null;
toastUndo.hidden=!onUndo;
toast.classList.add('visible');
toastTimer=setTimeout(hide,4500);
}
```
static toast hidden? If hidden attr then transition none. Keep `<div class=toast role=status aria-live=polite>` offscreen transform opacity pointer-events none. Contains text, button. At first not visible. When hidden no aria issue. `hideToast` class remove; undoAction=null.
Undo event `const action=undoAction;hideToast(); if(action){action();renderBoard();}`.
Toast close button perhaps no need, auto. Add X? Keep compact with undo only and no inert. Add close button functional maybe.
Move live region `announce` text separate visually hidden updated. Toast can announce add/delete; for move use `liveRegion.textContent=...`.
Search result summary live? `aria-live=polite` can chatter on each key; okay maybe not. Not needed.
IDs and HTML escaping safe DOM. New title textContent. Code auto state sequence. No storage APIs anywhere.
Potential CSS `color-mix` not necessary latest Chrome but use rgba explicit. Variables for priority backgrounds need light/dark. Define `--high-bg`, etc under theme. Done progress etc. `body` transition; modal. `button,input,select` font inherit.
Mouse drop e.dataTransfer may be null in some test synthetic but guard:
```
if(e.dataTransfer){...}
```
Real works.
If drag starts on text selection, draggable handles.
Delete button event propagation not issue.
Prevent drag start on `button`:
```
if (event.target.closest('button, input, select')) { event.preventDefault(); return; }
```
`event.target` might Text? DragEvent target element yes.
Add aria label on board.
Search clear:
```
clearSearch.addEventListener('click',()=>{ search.value='';state.query='';renderBoard();search.focus();});
```
`clearSearch.hidden` updated. CSS `[hidden]`. In HTML initial hidden. search icon.
Keyboard `keydown`:
```
if ((e.metaKey||e.ctrlKey)&&e.key.toLowerCase()==='k'){ e.preventDefault(); if modal open return? focus search only if closed}
if(e.key==='Escape'){
if(!modalLayer.hidden) closeModal();
else if(document.activeElement===searchInput && searchInput.value) clear...
}
```
Modal `Escape` native custom. Focus trap selector buttons etc filter offsetParent maybe. use `Array.from(modal.querySelectorAll(...)).filter(el=>!el.disabled)`. Only visible all. handle tab.
After close return focus if connected. If deleted column add still connected. fine.
Click overlay:
`modalLayer.addEventListener('click',e=>{ if(e.target.matches('[data-close-modal]')) closeModal();})`; cancel button has data-close-modal, backdrop too. But closest for button? button direct yes. X direct maybe svg target so use `e.target.closest('[data-close-modal]')`; backdrop closest itself. Ensure clicks inside modal don't close unless button. good.
Submit:
```
const title=...trim();
if(!title)...
const target = columnSelect.value;
const newCard=...
state.columns.find...cards.unshift(newCard);
state.filter=''; searchInput.value='';
closeModal();
renderBoard();
requestAnimationFrame(()=> {const el=...; el.classList.add('just-added');});
showToast('Card added to '+column.title);
form.reset(); // after reading; select defaults? open next sets.
```
If form.reset after close then priority medium column todo. open sets priority maybe preserve? openModal:
```
form.reset();
titleError.hidden=true;
columnSelect.value=columnId||'todo';
prioritySelect.value='medium';
...
```
So no need after submit. Close no reset to retain if? On open reset.
New id no Date needed state.nextId. Code from `WEB-${state.nextCode++}` initial nextCode=131? Existing codes 121..126. Use 127.
Titles realistic and codes:
WEB-128 etc. State nextCode=133. Use 6 codes 121-126.
Colors for avatars classes known.
New card `tag:'New task', due:'Just added', owner:'You', initials:'YOU'` 3 letters circle may crowd. initials 'YO'. fine.
Description optional omitted. Cards new less height. Card `.card-description` only if description. Great.
Dynamic sprint progress: done count/total, Math.round; if total0 0. style width set percentage. `.progress-fill` transform? set width. On add total increases percentage decreases. counts.
`progressText` "2 of 6 complete". toolbar summary default "6 cards" plus maybe `•` no.
Top heading status pill "On track" static might remain even no done no issue.
Team counts static. Fine.
Theme icon: two svg `.sun-icon` `.moon-icon`; CSS:
```
[data-theme=light] .moon-icon{display:block}? Button shows target mode icon? If light show moon + "Dark"; dark show sun + "Light".
.theme-toggle .sun-icon{display:none}
[data-theme=dark] .sun-icon{display:block}
[data-theme=dark] .moon-icon{display:none}
```
Label update "Dark"/"Light". aria-label "Switch to dark theme". `title`.
Set pressed indicates dark. Good.
Header icon button 96 wide. On mobile label hidden but aria.
Brand mark could use CSS spans 4 squares:
```
<div class=brand-mark><span></span><span></span><span></span></div>
```
grid 2x2 with one transparent? use SVG custom rectangles maybe. Inline svg 28. CSS transform. `brand-mark` 32 square accent with three white bars representing columns. Nice Kanban:
```
<span class=brand-mark><i style height...> etc</span>
```
CSS 3 vertical bars of varying height. All inline.
Logo `Orbit` and small `Projects` maybe.
No need install. Zero errors: Check JS syntax thoroughly. Need ensure use `CSS.escape`? New IDs simple, can query `[data-card-id="${id}"]` safe generated only. initial simple. Fine.
`event.target.closest` target can `SVGElement` has closest yes. Text nodes not event target in click generally element. okay.
`card.classList.add('dragging')` in setTimeout after drop could fire after render and card disconnected but safe. If dragend cleanup before timeout, then timeout adds dragging to disconnected card no visible. no error. draggedId null not relevant. Could use requestAnimationFrame.
DataTransfer setData may throw if e.dataTransfer null guard.
Use `Array.from` etc.
Native draggable on article and button click. `dragstart` event can bubble from `button`? yes check.
Drop indicator creation with child span maybe accessible aria hidden. Set `indicator.innerHTML`? fine static no user. Could create with DOM. `indicator.setAttribute('aria-hidden','true')`.
Need perhaps touch support beyond requirement? Could add simple keyboard move menu? Not needed. But mobile stack means drag not usable on touch; task only mouse. Add/delete works.
The instruction "Something meaningful visible on first paint", yes. "Everything interesting visible within first seconds" all board visible no interactions required. Fine.
Could add subtle note under board "Tip: Drag a card to update its status" with drag icon. This communicates. Footer board-bottom:
```
<div class=board-note><svg mouse> Drag cards between columns <span>•</span> Changes are kept for this session</div>
```
State memory only maybe indicate "Session only · changes aren't saved" good. At first screenshot maybe bottom 790. Could put toolbar summary. Not necessary.
Perhaps toolbar has left search and right filter summary and New card. Board foot small line could be below. Ensure first screenshot.
I need output exactly code block. No explanations. I can craft ~1000 lines? Need reasonable 700 lines. Need no external.
I'll plan HTML:
```
<!DOCTYPE html>
<html lang=en data-theme=light>
<head>...<style>...</style></head>
<body>
<svg class=svg-defs...>
...
</svg>
<header class=app-header>...
</header>
<main class=app-main>
<section class=project-header>...
</section>
<section class=toolbar aria-label=Board tools>
<div class=search-box>...
<input type=search ...> browser has native clear x plus custom maybe duplicate. Set CSS `appearance:none;` `::-webkit-search-cancel-button{display:none}`.
...
</section>
<section class=board id=board>
<article class=column data-column=todo aria-labelledby=...>
<header...><div... dot><h2>To do</h2><span count id...>2</span></div><span class=column-caption>Ready to start</span>? Header 2 lines increases. Could header just title. Maybe subtitle under title with small font? Use status title and count only.
<div class=card-list id=list-todo ...></div>
<button class=column-add data-add-column=todo>...
</article>...
</section>
<div class=board-footer><span icon>In session...</span><span id=boardHint>Drag to reorder...</span></div>
</main>
<div modal hidden>...
<div toast...>
<div class=sr-only id=liveRegion...>
<script>...
```
Column caption adds personality: To do "Next up", In progress "Being worked on", Done "Completed". Could put in header right after count? At 1280 enough. Structure column heading:
```
<div class=column-title-group><span dot></span><h2>To do</h2><span count>2</span></div>
<span class=column-label>2? no duplicate</span>
```
Right small `···` inert not allowed? Avoid.
Could small colored line top each column. `.column::before{... background:var(--column-accent)}`. Header height. Fine.
`.column-header` display flex justify between; `<span class=column-kicker>Backlog</span>` right maybe. But not needed.
Cards initial 2 each.
Sprint progress in project header. toolbar left search. main add.
Style:
Root variables with font etc. Body min height, background. `body::before` fixed top left subtle radial? Use background-image radial-gradient at body could count visual.
```
body{background:var(--bg);background-image:radial-gradient(circle at 85% -10%, rgba(93,105,232,.09), transparent 35%);}
html[data-theme=dark] body{...}
```
But background var and transition. Fine. No image external.
App header semi translucent `backdrop-filter: blur(14px)` border bottom, sticky? Could be static. header background var with opacity cannot use variable hex alpha. `background:var(--header-bg)`.
Main max-width 1240? At 1280 padding 32 so content 1216. max 1240. Good.
Project header margin bottom 24. H1 font 30 letter -1. P. Eyebrow letter spacing.
Status pill next h1 inline. h1 flex? `<div class=title-line><h1>...` and status. Nice.
Sprint overview surface, border, padding 14, width 230, shadow none.
Toolbar display flex gap. search 340. primary button. `.results-summary` color.
Board grid.
Column background var `--column-bg`, border. Border radius 16. `box-shadow` subtle. Card bg. Done maybe no opacity. Column hover drag.
Card padding 14; top margin. Box-shadow 0 1px 2px; hover translateY(-2) border and shadow. Drag cursor grab. On dragging cursor grabbing.
Priority badges small:
low green? Usually low grey/blue, medium amber, high rose. Requirement colored badge. low = green maybe done confusion but fine; low teal, medium amber, high red. Use `High`, `Medium`, `Low` capital not uppercase maybe CSS uppercase.
Card title margin 10, font 15.5, line-height1.4.
Description 12.5 line 1.5 color.
Top card code 10.5 mono uppercase.
Card footer border-top? Adding border creates structure. `margin-top:13;padding-top:11;border-top:1px solid var(--card-divider)`. Tag and due. Card height ~180. Fine.
Card avatar 26.
Delete button 26 square; top. Top code and badge left.
Empty state dashed border around, height 120, centered. Filter icon search maybe just text.
Column-add margin 10 12 12, height 34. Dashed? ghost.
Drop indicator line with margin 8 0; since card-list gap 12 and indicator as flex? `.card-list` not flex because indicator and cards; use block and cards margin-bottom 12 rather than gap. If `display:flex;flex-direction:column;gap:12px`, indicator inserted participates and gap around, okay. min height. empty. `drop-indicator` flex 0 0 4px, margin -4px 0? Clear. use flex. `.card` flex-shrink 0.
Cards animation via transform. `.card-list{display:flex;flex-direction:column;gap:12px}`.
Indicator height 4; margins 0; `box-shadow 0 0 0 3px...`. ::before circle left. ::after label? no.
Column drag over background. `outline`.
`body.is-dragging` user-select none.
During dragging `.column-empty {display:none}` using board class.
Toast fixed bottom 24 right 24 z 100. At mobile left16 right16 bottom16. Modal z200.
`button:focus-visible`, input etc outline.
Custom select background maybe no icon. native okay. Form selects width.
Responsive:
@media max 1000 main padding 24, header etc.
@media max 820 board grid 1, column min-height unset; card-list min-height 80; `.column{...}` maybe user sees one column per row; 3 stacked. `.sprint-overview{width:200px}`.
At 820 exact grid stack, tablet vertical. Could use at 700 instead? 3 columns at 768: content 720 /3 =228 each, still cards workable but titles wrap and footer crowd. Better 900. At 900 viewport 3x~270 okay. `@media(max-width:820px)` stack.
At max 620 project header flex column? .sprint-overview maybe display none at <=540 to shorten. Search width100; toolbar actions. Header workspace badge hidden and collaboration hidden at <=700.
At 375 h1 26, status pill. modal form row one col? two selects each 50% of 311=145 fine. Could keep. Buttons okay.
`.board-footer` flex wrap.
Theme dark variable badges and avatars. Avatar background static light colors might be too bright dark but okay use CSS vars. Define each via general colors and dark override.
Use `.avatar.blue{--avatar-bg:#e6ebff;--avatar-fg:#4357bd}` etc. Dark html override. Fine.
One concern scrollbars inside list? none. Body vertical allowed. No horizontal.
A11y `aria-label` on delete, header buttons. Form title `autocomplete=off`. Close modal icon.
No alert confirm prompt. No storage.
Testing mental:
On load state columns, cache `columnElements` using query. `renderBoard` loops:
```
const list = document.querySelector(`[data-column="${column.id}"] .card-list`);
list.replaceChildren();
...
list.appendChild(card)
```
No indicator during regular render; if drag active and render triggered by theme? Theme doesn't render. delete cannot click while dragging. okay.
count text.
empty state create. `visibleTotal`.
`summary.textContent = query ? ... : `${total} cards`;` initial 6 cards.
`clearSearchButton.hidden = !query`.
Progress.
Board footer maybe updated "2 of 6 done" duplicate no.
Use `requestAnimationFrame` only for drag visual and new highlight. New highlight class included based id and after render reset `state.lastAddedId=null`? If set then create adds class; then set null after loops. Could do:
```
const isNew=card.id===state.lastAddedId;
...
state.lastAddedId=null at end
```
CSS animation `newPulse 1.4s`. Fine.
After add close, render, scroll:
```
const targetCard = document.querySelector...
if(window.innerWidth<821 && targetCard) targetCard.scrollIntoView({behavior:'smooth',block:'center'});
```
This could scroll new done into view, good. At desktop no scroll. `scrollIntoView` allowed. Respect reduced motion maybe check `matchMedia`. not necessary but do behavior auto if reduced.
No `window.open`.
Delete:
```
function deleteCard(id){
const location=findCard(id);
if(!location)return;
const {column,index,card}=...
column.cards.splice...
renderBoard();
announce...
showToast('Card deleted',()=>{
const target=state.columns.find(...); target.cards.splice(Math.min(index,...),0,card); renderBoard(); announce('Card restored');
});
}
```
findCard returns object references. Toast message maybe title truncated by CSS. `showToast(`Deleted “${card.title}”`,undo)`.
Undo if other operations before toast? New action replace undo. Fine.
Move:
showToast not needed; announce.
Could show mini `showToast('Moved to In progress')` every drop helpful but may obscure board and unnecessary. Use live only.
Per column add click open. Main open todo.
`board.addEventListener('click',...)` delete first then add. Delete button nested card; no other.
`card` deletion `event.stopPropagation` not needed.
Search `state.query = searchInput.value.trim().toLowerCase();` Search live by title but trimming means " " clears. fine. store raw? state.query as lowercase trimmed. Summary.
`renderBoard` uses `card.title.toLowerCase().includes(query)`.
When new added clear query and search.
Title required `maxlength=100` new card text wrap.
Column select names match.
Modal initial hidden. CSS `.modal-layer{position:fixed;inset:0;display:grid;place-items:center;...}` `[hidden] display none important. backdrop absolute. modal relative.
Opening:
```
modalLayer.hidden=false;
document.body.classList.add('modal-open');
...
window.setTimeout(()=>titleInput.focus(),30)
```
If user close immediately timer focus hidden input maybe but minor; can direct `titleInput.focus()` after visible. Browser works. Use requestAnimationFrame and check !hidden.
Closing:
```
modalLayer.hidden=true; body...; if(lastFocused && lastFocused.focus)...
```
Focus trap query `[...]` filtered `!element.hasAttribute('disabled')`.
Native required with novalidate custom error. Error paragraph hidden. `titleInput.removeAttribute('aria-invalid')`.
`titleInput.addEventListener('input',... if trim ... hide)`.
`modalLayer` click if target closest data-close. Cancel button type=button.
No need backdrop button semantics div aria hidden.
Toast:
`toastUndo.addEventListener('click'...); toastClose...`. Toast close icon x. Timer 5s. role status includes button label? Screen reader might announce. okay.
`hideToast()` clear timeout and class. `toast` remains DOM opacity0 and `visibility:hidden` after? CSS visibility hidden when not show. transitions. Button not focusable when hidden? visibility hidden prevents focus. good.
At first toast hidden CSS no issue.
`showToast` message textContent.
Undo callback set and button.hidden = !onUndo. `[hidden]`.
Close button always.
Icons symbols:
- plus `path d="M12 5v14M5 12h14"`
- search circle and line
- sun circle + rays
- moon path
- trash path
- x path
- calendar rect and lines
- sparkle? not.
- clock? calendar due.
- drag `path d=M9 5...` maybe arrow.
- check for status pill.
- keyboard not.
Use `fill=none stroke=currentColor stroke-linecap=round stroke-linejoin=round stroke-width=1.8` on symbol? `<symbol>` can have attributes inherited? yes. Put each paths; root `<svg>` class icon has fill none etc CSS maybe:
```
.icon{width:18;height:18;fill:none;stroke:currentColor;stroke-width:1.8;...}
```
SVG use shadow path picks. Circle fill none. Brand uses CSS no.
Status check use icon 12.
Board footer icon `move` arrows maybe symbol `drag` with up/down right. Could use simple six dots handle? Use `icon-move` path.
Theme sun paths.
Card create icon calendar. Footer includes tag + due. Avatar title.
Could use priority flag icon? dot enough.
Card delete trash icon 15.
CSS dropdown option dark auto color-scheme from html. `:root{color-scheme:light}` `[data-theme=dark]{color-scheme:dark}`.
Button primary hover. `.primary-button .icon`.
No network fonts. System `Inter` not installed fallback, okay `font-family:Inter,ui-sans-serif,...` no external request for local Inter? Browser checks local only. Fine. Better system only.
First screenshot visual subtle load animations. `main{animation:page-in .45s ease-out}` cards animation .35. At 3s done. Fine.
No gradients? I may use body radial gradient. The task didn't ban. Fine. Brand mark solid.
Potential issue custom clear button positioned right and kbd also right: Search box contains search icon left, input padding `12px 78px 12px 40px`, kbd absolute right 12, clear button right 42 when query; if no query clear hidden. Kbd visible always and overlaps clear? Put kbd right 12 width 34, clear right 50. Input right padding 88. On query show clear and kbd still. Could hide kbd when query using class `.has-query .key-hint{opacity:0}` and clear right 12. Easier:
```
clearSearch right:12;
keyHint right:12;
.search-box.has-query .key-hint{display:none;}
```
input padding right 70. Search width 360. summary. `renderBoard` toggles `searchBox.classList.toggle('has-query',!!query)`.
No duplicate native clear hide.
At mobile hide key hint always; clear still.
`search` input required no.
`label` wrapper with aria? Use `<div class=search-box><svg...><input aria-label="Search cards by title"...>...`.
Top header collaborators with avatars `title`. 3 avatars stack plus online status. Text "Team online" static. At 1280 nice. Header actions.
Progress uses style width in JS. `.progress-fill{width:0}` initial before JS small; JS at end sets 33.
Main title status "On track" with green dot. No interaction inert but status info. good.
Board footer:
left `svg move` "Drag cards to update status" right clock? "In-memory board · no saved changes" maybe indicates state only. Use "Session-only board" with dot. Fine.
Could implement clear search aria-label. Add main button keyboard.
All controls functional. Column header no inert menu. Brand not link. Team avatars not buttons.
Potential quality evaluation screenshot: need no browser default weird select in closed modal irrelevant. Light default professional. Great.
I'll outline JS exact:
```
(() => {
'use strict';
const state={...};
const board = document.getElementById...
...
function icon(name,className='icon') { ... }
function makeElement...
function createCard(card,columnId) { ... return article}
...
renderBoard();
...
})();
```
Static `<svg><use href>` no JS needed.
`makeElement`:
```
function node(tag,className,text){
const element=document.createElement(tag);
if(className) element.className=className;
if(text !== undefined) element.textContent=text;
return element;
}
```
create:
```
const top=node('div','card-top');
const identity=node('div','card-identity');
identity.append(node('span','card-code',card.code));
const badge=node('span',`priority-badge ${card.priority}`,card.priority);
badge.prepend(node('span','priority-dot'));
...
```
Text `card.priority` lower -> CSS text-transform capitalize or uppercase. `aria-label = Priority high`.
Delete:
```
deleteButton.type='button';
deleteButton.dataset.deleteId=card.id;
deleteButton.innerHTML? append icon;
```
Button aria label and title.
`card.append(top,title,desc?...,footer)`.
Footer left = `card-footer-info`; tag, due. due.append(icon('calendar'), node('span','',card.due)).
Avatar `title=card.owner`.
If column done maybe avatar has check? no.
`article.dataset.cardId=card.id; article.draggable=true;`
`article.setAttribute('aria-label',`${card.title}, ${card.priority} priority`)`.
No role? article okay.
New class `just-added`.
render:
```
let visibleTotal=0,total=0;
for column:
total += column.cards.length;
const visibleCards = ...
visibleTotal+=...
count.textContent=column.cards.length;
count.setAttribute('aria-label',`${...}`);
list.setAttribute('aria-label',`${column.title} cards`);
if(!visibleCards.length){...}
else visibleCards.forEach...
...
const doneCount...
```
Empty state if query:
title 'No matching cards'; subtitle 'Try another search'; no icon maybe. If no cards no query 'No cards here'; 'Drag a card here or add a new one.'
Create `<div class=column-empty><span class=empty-icon>icon search/plus</span>...`.
Empty icon in drag hidden anyway.
`boardFooterResult` maybe summary only.
`totalCount` header maybe brand? `projectSubtitle` dynamic? no.
Source find:
```
function locateCard(id){
for(const column of state.columns){
const index=column.cards.findIndex...
if(index!==-1)return{column,index,card:...};
}
return null;
}
```
Drag target data id. sourceColumn state maybe not needed locate source on drop. But source for toast? no. `draggedId`, `draggedElement`.
`cleanupDrag` remove dragging class from `draggedElement`; columns remove; indicator.remove; board.classList.remove; draggedId=null; draggedElement=null.
On dragstart:
```
const card=target.closest('.card');
if(!card)return;
if(target.closest('button')){event.preventDefault();return;}
draggedId=card.dataset.cardId; draggedElement=card;
card.setAttribute('aria-grabbed','true');
if(dataTransfer)...
board.classList.add('is-dragging');
setTimeout(()=>{if(draggedElement===card)card.classList.add('dragging');},0);
```
Setting aria-grabbed false cleanup.
`dragover` target column and draggedId. If modal open impossible.
Add highlight to target only:
```
columns.forEach(c=>c.classList.toggle('drag-over',c===column))
```
get after uses `.card:not(.dragging)` and id filter. `const after = [...].find(card=>event.clientY < ...)` but `Array.prototype.find` returns first in document satisfying. Sorted doc order yes. Typical.
If after undefined? use null.
Indicator appended moves. `event.preventDefault()` needed.
`drop`:
```
const beforeId = getIndicatorNextCardId();
const movingId=draggedId;
const targetColumnId=column.dataset.column;
const location=locate...
if(!location)cleanup...
location.column.cards.splice(location.index,1);
const target=...
let index= beforeId ? target.cards.findIndex... : target.cards.length;
if(index<0)index=target.cards.length;
target.cards.splice(index,0,location.card);
cleanupDrag();
renderBoard();
announce(`${title} moved to ${target.title}.`);
```
But get next card:
```
let next=indicator.nextSibling;
while(next){
if(next.nodeType===1 && next.classList.contains('card') && next.dataset.cardId!==movingId){ beforeId=...; break;}
next=next.nextSibling;
}
```
Using nextElementSibling simpler:
```
let next = indicator.nextElementSibling;
while(next && (!next.classList.contains('card') || next.dataset.cardId===movingId)) next=next.nextElementSibling;
```
If indicator not inside this list? Drop on column without prior dragover? Usually dragover fires first. If indicator parent !== list, position at end:
```
let beforeId=null;
if(indicator.parentElement===list){...}
```
Then drop at end. good.
Moving within same source to exact position: Original card remains in state and DOM. Example cards A,B,C dragging B, place indicator before B excluded original. getAfter cards A,C: cursor around B midpoint maybe after C or null. If before C, next C, remove B -> A,C index C=1 insert B same. If at end, append B moves. Fine.
Example dragging A and place before B (original A excluded, get after B) indicator before B; DOM originally A, indicator,B,C; next B. remove A ->B,C insert beforeB=0 same. fine.
Dragging B place after A before C -> indicator before C; DOM A,B,indicator,C? Actually original B before indicator, next C. remove B ->A,C insert at1 same. good.
Clear.
`dragleave` not needed due dragover on other columns. Leaving board entirely highlight remains until dragend; acceptable. Could add:
```
board.addEventListener('dragleave',e=>{if(!board.contains(e.relatedTarget)) columns...})
```
relatedTarget may null when moving child? Check avoid. not necessary.
`dragend` `cleanupDrag(); renderBoard?` If no drop, dragged card still class removed, empty states board class removed, indicator removed. no render needed. If drop already rendered and draggedId null, safe.
`drop` event after cleanup dragend won't undo. good.
Theme no render. aria updates.
`announce` sets text blank then set? Screen reader only, can just textContent. For repeat may not announce, use requestAnimationFrame. no issue.
`renderBoard` empty list while indicator active? not during.
`document.querySelectorAll('.column')` static.
`columnHeaders` counts.
Toast code and focus: when toast undo clicked, `hideToast()` sets undoAction null after capturing. `toastUndo.hidden` property.
`toastClose` click hide. timer.
`showToast` if onUndo text maybe long.
Delete from board calls locate; render; show. Good.
Add form close calls lastFocused focus, then render? If render before close lastFocused remains add button unaffected except column list only, column add static. main unaffected. I can close after render and focus main. Sequence:
- mutate clear
- render
- closeModal (focus)
- showToast
- scroll/highlight. Fine.
Open `lastFocused=document.activeElement`.
Delete undo on search: restored card may not match and remain hidden. okay.
Deleting all cards progress works.
Modal priority value lower. labels.
`form.addEventListener('submit', event=>{event.preventDefault(); ...})`.
Close when Escape key. global keydown if modal open:
```
if(event.key==='Escape'){closeModal();return;}
if(event.key==='Tab'){trap...;return;}
```
Else keyboard shortcuts not while modal.
`document.addEventListener('keydown',...)`.
`event.key.toLowerCase()` key always string. yes.
Search Escape clears then maybe blur? clear only. CtrlK selects input. Use `searchInput.select()` maybe if empty fine.
`/` shortcut optional but visible kbd says `⌘ K` on Mac? static "⌘K"; Windows Ctrl works but display maybe "⌘K". Use `<kbd>⌘ K</kbd>`. okay. Could detect navigator platform but no need. Better `<kbd>Ctrl K</kbd>` works all (Meta too), at width 360. Use `Ctrl K` 42px. On Mac can use Cmd anyway. Search box 360.
`/` not advertised.
`matchMedia` use only scroll behavior if available in latest. `window.matchMedia('(prefers-reduced-motion: reduce)').matches`.
No console logs.
No initial focus auto needed.
Date static realistic current not specified. Use Apr dates no issue.
Potential user expects dark theme persists? State memory only, no storage. toggles current. yes.
I'll start compose the code for the response. Need ensure output exactly one fenced html. I can produce ~1000 lines but okay. I need not add comments excessively but helpful. I'll include some comments for drag logic maybe.
Detailed CSS theme variables:
```
:root{
--bg:#f4f6fa; --header-bg:rgba(255,255,255,.86); --surface:#fff; --column:#edf0f5; --text:#172033; --muted:#6d7688; --faint:#969eae; --border:#dde2eb; --card-border:#e1e5ed; --accent:#5865d8; --accent-hover:#4854c5; --accent-soft:#e9ebff; --shadow:...
--high-bg:#feecef;--high-text:#c63e55; etc;
}
html[data-theme=dark]{...}
```
Column backgrounds. `.app-header` backdrop blur if header rgba. dark rgba.
`.sprint-overview` surface.
Primary button box-shadow. Accent progress.
High medium low dot same currentColor.
`.column[data-column="todo"]{--column-accent:#8590a5}` inprogress accent #5865d8 done #24a079.
Column top before width 100% height3 border radius; light.
Count background var surface. Done count soft green maybe define.
Avatar sizes.
Team avatar border 2px solid header background? use #fff fixed dark undesirable use var surface. `.team-avatar{border:2px solid var(--surface)}`.
At 1280 header inner max 1240 padding 0 32. body top background. main.
`.project-header` margin-bottom 24.
`.eyebrow` 11.
`.title-line` display flex align center gap12; h1.
`.status-pill`.
`.project-subtitle`.
`.sprint-overview` padding 14 16.
`.toolbar` margin-bottom18.
`.search-box` width min(360px,100%).
`.primary-button` min-height42.
`.toolbar-actions` margin-left auto.
Board min-width 0.
Column min-height maybe 450. Should calculate first screenshot:
Card with padding 14:
top 26
title line 22+ margins 10+0=32
desc 2 lines 38 + margin6 =44
footer border padding 11 + avatar26 + margin 13 =51
total 26+32+44+51+28 padding=181. Two=374 + gap12.
Column header 54 + list padding? card-list padding 0 12; 386 + add 50 + top? total 490. Main top: header64 + main padding28 + project ~92 + margin24 + toolbar42 + margin18 + board490 + footer30 + padding bottom32 =820. Slight vertical scroll. Could reduce card description to one/two and card heights ~166. Top 24, title 22 +8=30, desc 2*18=36+6=42, footer 24+10+10+border=45, padding28 =169. 2=350. col54+350+48=452. total 782. good. Use font desc 12 line 1.45 =17.4. Title 15 line1.4=21. Top delete 24. Footer avatar24. margin footer 11 padding9 -> 24+20+1=45. total 24+29+42+45+28=168. Great. Column list 348 + header 52 + add 46 =446. main project 82. total 64+26+82+22+42+18+446+28+24 =752. good. first screenshot fully.
Column min-height 440. all equal. Card titles maybe two lines adds 21 one card, col max +21 =467, still 773. fine.
Card top `.card-identity` code + badge; badge 20. delete24.
Footer info tag and due both inline. Tag uppercase 9.5. At columns 380 fits.
At narrow 3col tablet before stack widths 250 footer can wrap; `.card-footer-info{flex-wrap:wrap}` but due could next line increasing. okay.
Board footer font 11.5 margin-top14.
Main max width 1240 with padding 28 32 30. At 1280 inner 1216; board cols (1216-36)/3=393. Good.
At 1280 header same.
At >1440 max width.
Modal styles.
Need use `<span class="online-dot"></span>` maybe. Header team `span team-copy><strong>Product team</strong><span>4 online</span>` could take space but fine. Keep just avatars and `span team-label>4 online`.
At 375 hidden.
Screen reader class.
Potential `hidden` CSS generic ensures.
Inputs search color etc.
Theme transition for all cards maybe no to avoid expensive. Body and surfaces.
No script exceptions due duplicate id? IDs unique. `progressFill`, etc.
When `createIcon` creates SVG and `<use>` set `href` via setAttribute, latest works.
`icon('calendar','icon small-icon')` class parameter. Function:
```
function createIcon(name, className='icon'){
const svg=document.createElementNS...
svg.setAttribute('class',className);
svg.setAttribute('aria-hidden','true');
const use=...
use.setAttribute('href',`#icon-${name}`);
svg.appendChild(use);
return svg;
}
```
Trash appended.
Empty icon created search if filter else plus.
Status check static.
Aria modal: `role=dialog aria-modal=true aria-labelledby=modalTitle`. `section` no tabindex needed. focus input.
Footer memory only. good.
Add drag handle indicator on card maybe CSS `card::before` not needed. Cursor indicates. Board footer teaches.
One potential click delete while draggable article can start drag instead of click on some browser, but button usable. `deleteButton.addEventListener('dragstart',...)` via board prevent. click still.
Search `input` event triggers render and rebuild cards, no input loss. Counts actual. `.results-summary` query "3 of 6 shown" good. empty column message.
`column-count` still 2 each; user could expect count filtered but actual board count as required. Nice maybe title attr "2 total cards". Add `count.title`.
Sprint progress done 2/6.
No sort buttons inert. Good.
Memory-only no persistence. no date APIs needed.
New IDs and `Date` not needed.
CSS reduced motion:
```
@media(prefers-reduced-motion:reduce){*,*::before,*::after{animation-duration:.01ms!important;...;scroll-behavior:auto!important}}
```
Broad okay.
At 375 no horizontal due toast width, header. `.modal-actions` buttons no overflow. `.form-row` grid 1fr 1fr, select min-width0. Long option "In progress" fits 130. Add `width:100%`.
Board card long title wraps.
Use `overflow-wrap:anywhere` title. Body overflow-x:clip perhaps not all? Chrome supports. `overflow-x:hidden` safe but requirement no scrollbar; layout fits.
`team-avatar` negative margin could overflow header? no.
`modal-layer` padding16; modal max-height calc, overflow-y:auto. no horizontal.
Toast max width calc.
Could include `aria-describedby` titleError, hidden. Input title required.
Custom validity not needed.
Submit empty adds inline error. error hidden false.
On modal open titleInput.value cleared form.reset.
Theme label changes.
When close via backdrop lastFocused main add.
No placeholder generated visible.
I think done. I'll compose the response code carefully.