Models 44

MiniMax M3.1 Flash

by MiniMaxReasoningClean
Open in new window

Running live in a sandboxed frame. Click inside it before using the keyboard.

How it went

Total time
5 min 12 s
Cost
$0.023
Output tokens
37,783
Spent thinking
22,269 tokens
Speed
121.5 tok/s
Console errors
0
First token after
561 ms
First visible text after
4 min 8 s
Prompt tokens
665
Code size
54.9 KB
Finish reason
stop
Served by
Together
Model id
minimax/minimax-m3.1-flash
Reasoning sent
{"effort":"max"}
Max output
64,000 tokens
Generated
Nov 11, 2026

What it was asked

The prompt
Build an interactive Kanban board in vanilla HTML, CSS and JavaScript (no framework, no library). Requirements: - Three columns: "To do", "In progress", "Done", each showing its card count. - Pre-populate the board with 6 realistic cards spread across the columns so the first screenshot looks alive. - Each card has a title and a priority (low / medium / high) shown as a colored badge. - Add a card through a small modal form (title required), and delete a card with a button on it. - Drag and drop cards between columns with the mouse, with a clear visual drop indicator. - A search field that filters cards live by title. - A light / dark theme toggle. - State lives in memory only (no storage APIs).
System prompt, sent with every prompt
You are generating a self-contained web demo. It will be rendered automatically in a sandboxed iframe and compared side by side with the output of other AI models given the exact same task.

Output rules:
- Reply with exactly ONE fenced code block containing the complete file: ```html (or ```svg when the task asks for an SVG). No text before or after the code block.
- Everything must be inline: CSS in <style>, JavaScript in <script>. No external files, images, fonts or network requests, except the CDN URLs explicitly allowed by the task.
- Do not use localStorage, sessionStorage, cookies, IndexedDB, alert(), confirm(), prompt() or window.open(): they are blocked in the sandbox and will throw.
- The page must look right in a 1280×800 viewport and adapt gracefully down to 375px wide. No horizontal scrollbar.
- Something meaningful must be visible on first paint: a screenshot is taken automatically about 3 seconds after load, without any interaction.
- Everything that makes the demo interesting must be visible within the first seconds: nothing hidden behind a high score, a later level or a long play session.
- Games must start in a self-playing demo mode (a simple AI plays) behind a "Click or press a key to start" message, so the game can be judged in action without playing.
- The iframe only receives keyboard events after the user clicks it: if the demo uses the keyboard, display a clear "Click or press a key to start" message.
- The code must run with zero errors in the console of the latest Chrome.

Code run-1.html · 2047 lines

<!DOCTYPE html>
<html lang="en" data-theme="light">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <meta name="description" content="An interactive, drag-and-drop Kanban board.">
  <title>Flowboard — Product roadmap</title>
  <style>
    :root {
      color-scheme: light;
      --bg: #f5f6fa;
      --surface: #ffffff;
      --surface-soft: #f8f9fc;
      --input-bg: #f4f5f9;
      --column: #eef0f5;
      --column-border: #e2e5ed;
      --border: #e3e6ed;
      --card-line: #e8eaf0;
      --text: #222634;
      --text-soft: #697184;
      --text-faint: #929aab;
      --accent: #6d5ce7;
      --accent-hover: #5d4bd6;
      --accent-soft: #efedff;
      --count-bg: #ffffff;
      --topbar-bg: rgba(255, 255, 255, 0.86);
      --shadow-sm: 0 1px 2px rgba(24, 29, 45, 0.04), 0 5px 16px rgba(24, 29, 45, 0.045);
      --shadow-lg: 0 24px 70px rgba(21, 25, 42, 0.22);
    }

    [data-theme="dark"] {
      color-scheme: dark;
      --bg: #0f1219;
      --surface: #191d27;
      --surface-soft: #202530;
      --input-bg: #141821;
      --column: #171b24;
      --column-border: #292f3d;
      --border: #303746;
      --card-line: #2c3342;
      --text: #eef0f6;
      --text-soft: #a0a8b9;
      --text-faint: #747d91;
      --accent: #8d80f2;
      --accent-hover: #9c90f6;
      --accent-soft: rgba(141, 128, 242, 0.14);
      --count-bg: #222835;
      --topbar-bg: rgba(20, 24, 33, 0.88);
      --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.15), 0 7px 18px rgba(0, 0, 0, 0.13);
      --shadow-lg: 0 24px 80px rgba(0, 0, 0, 0.48);
    }

    * {
      box-sizing: border-box;
    }

    [hidden] {
      display: none !important;
    }

    html {
      min-width: 320px;
      background: var(--bg);
    }

    body {
      margin: 0;
      min-height: 100vh;
      overflow-x: hidden;
      background:
        radial-gradient(ellipse at 85% 0%, rgba(109, 92, 231, 0.08), transparent 36%),
        var(--bg);
      color: var(--text);
      font-family: Inter, ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
      line-height: 1.5;
      -webkit-font-smoothing: antialiased;
      transition: background-color 180ms ease, color 180ms ease;
    }

    body.modal-open {
      overflow: hidden;
    }

    button,
    input,
    select {
      font: inherit;
    }

    button {
      color: inherit;
    }

    button:not(:disabled),
    select {
      cursor: pointer;
    }

    svg {
      display: block;
      flex-shrink: 0;
    }

    button:focus-visible,
    input:focus-visible,
    select:focus-visible {
      outline: 3px solid rgba(109, 92, 231, 0.24);
      outline-offset: 2px;
    }

    .topbar {
      position: sticky;
      top: 0;
      z-index: 30;
      background: var(--topbar-bg);
      border-bottom: 1px solid var(--border);
      backdrop-filter: blur(16px);
      -webkit-backdrop-filter: blur(16px);
    }

    .topbar-inner {
      width: 100%;
      max-width: 1240px;
      height: 70px;
      margin: 0 auto;
      padding: 0 28px;
      display: flex;
      align-items: center;
      gap: 22px;
    }

    .brand {
      display: flex;
      align-items: center;
      min-width: 0;
      color: var(--text);
      text-decoration: none;
    }

    .brand-mark {
      width: 34px;
      height: 34px;
      display: grid;
      place-items: center;
      border-radius: 10px;
      background: linear-gradient(135deg, #9284f4, #5b49d5);
      box-shadow: 0 5px 12px rgba(94, 74, 213, 0.25);
    }

    .brand-name {
      margin-left: 10px;
      font-size: 17px;
      font-weight: 750;
      letter-spacing: -0.35px;
    }

    .brand-divider {
      width: 1px;
      height: 19px;
      margin: 0 13px;
      background: var(--border);
    }

    .brand-workspace {
      color: var(--text-soft);
      font-size: 13px;
      font-weight: 550;
      white-space: nowrap;
    }

    .topbar-actions {
      display: flex;
      align-items: center;
      gap: 9px;
      margin-left: auto;
    }

    .search-wrap {
      position: relative;
      width: 296px;
      height: 40px;
      padding: 0 10px 0 12px;
      display: flex;
      align-items: center;
      gap: 8px;
      background: var(--input-bg);
      border: 1px solid var(--border);
      border-radius: 10px;
      transition: border-color 160ms ease, box-shadow 160ms ease;
    }

    .search-wrap:focus-within {
      border-color: var(--accent);
      box-shadow: 0 0 0 3px rgba(109, 92, 231, 0.11);
    }

    .search-icon {
      width: 17px;
      height: 17px;
      color: var(--text-faint);
    }

    .search-wrap input {
      width: 100%;
      min-width: 0;
      padding: 0;
      color: var(--text);
      background: transparent;
      border: 0;
      outline: 0;
      font-size: 13px;
    }

    .search-wrap input::placeholder {
      color: var(--text-faint);
    }

    .search-wrap input::-webkit-search-cancel-button {
      display: none;
    }

    .shortcut {
      flex-shrink: 0;
      padding: 2px 6px;
      color: var(--text-faint);
      background: var(--surface);
      border: 1px solid var(--border);
      border-radius: 5px;
      font-size: 10px;
      line-height: 1.4;
    }

    .search-clear {
      width: 23px;
      height: 23px;
      padding: 0;
      display: grid;
      place-items: center;
      color: var(--text-soft);
      background: var(--surface);
      border: 1px solid var(--border);
      border-radius: 50%;
    }

    .search-clear:hover {
      color: var(--text);
      border-color: var(--text-faint);
    }

    .icon-button {
      width: 40px;
      height: 40px;
      padding: 0;
      display: grid;
      place-items: center;
      color: var(--text-soft);
      background: var(--surface);
      border: 1px solid var(--border);
      border-radius: 10px;
      transition: color 150ms ease, background 150ms ease, transform 150ms ease;
    }

    .icon-button:hover {
      color: var(--text);
      background: var(--surface-soft);
      transform: translateY(-1px);
    }

    .icon-button svg {
      width: 18px;
      height: 18px;
    }

    .theme-toggle .icon-sun {
      display: none;
    }

    [data-theme="dark"] .theme-toggle .icon-sun {
      display: block;
    }

    [data-theme="dark"] .theme-toggle .icon-moon {
      display: none;
    }

    .primary-button {
      height: 40px;
      padding: 0 15px;
      display: inline-flex;
      align-items: center;
      justify-content: center;
      gap: 7px;
      color: #fff;
      background: var(--accent);
      border: 1px solid transparent;
      border-radius: 10px;
      box-shadow: 0 5px 13px rgba(96, 75, 214, 0.2);
      font-size: 13px;
      font-weight: 650;
      white-space: nowrap;
      transition: background 150ms ease, transform 150ms ease, box-shadow 150ms ease;
    }

    .primary-button:hover {
      background: var(--accent-hover);
      box-shadow: 0 7px 17px rgba(96, 75, 214, 0.27);
      transform: translateY(-1px);
    }

    .primary-button svg {
      width: 17px;
      height: 17px;
    }

    .workspace {
      width: 100%;
      max-width: 1240px;
      margin: 0 auto;
      padding: 25px 28px 34px;
    }

    .page-heading {
      margin-bottom: 23px;
      display: flex;
      align-items: center;
      justify-content: space-between;
      gap: 24px;
    }

    .eyebrow {
      display: flex;
      align-items: center;
      gap: 7px;
      color: var(--text-faint);
      font-size: 10px;
      font-weight: 750;
      letter-spacing: 0.13em;
      text-transform: uppercase;
    }

    .eyebrow-dot {
      width: 6px;
      height: 6px;
      background: #36ae7b;
      border-radius: 50%;
      box-shadow: 0 0 0 3px rgba(54, 174, 123, 0.12);
    }

    .heading-copy h1 {
      margin: 6px 0 3px;
      font-size: 28px;
      line-height: 1.2;
      font-weight: 750;
      letter-spacing: -0.75px;
    }

    .heading-copy p {
      margin: 0;
      color: var(--text-soft);
      font-size: 13.5px;
    }

    .team-overview {
      display: flex;
      align-items: center;
      gap: 12px;
      padding: 9px 12px;
      background: rgba(255, 255, 255, 0.52);
      border: 1px solid var(--border);
      border-radius: 12px;
    }

    [data-theme="dark"] .team-overview {
      background: rgba(255, 255, 255, 0.025);
    }

    .team-avatars {
      display: flex;
      padding-left: 6px;
    }

    .avatar {
      width: 26px;
      height: 26px;
      display: inline-grid;
      place-items: center;
      flex-shrink: 0;
      color: #fff;
      background: var(--avatar-bg, #7b8498);
      border: 2px solid var(--surface);
      border-radius: 50%;
      font-size: 9px;
      font-weight: 750;
      letter-spacing: 0.01em;
    }

    .team-avatars .avatar {
      margin-left: -6px;
      width: 29px;
      height: 29px;
      font-size: 9.5px;
    }

    .team-copy {
      display: flex;
      flex-direction: column;
      line-height: 1.3;
    }

    .team-copy span {
      color: var(--text-soft);
      font-size: 11px;
    }

    .team-copy strong {
      font-size: 12px;
      font-weight: 650;
    }

    .board {
      min-height: calc(100vh - 225px);
      display: grid;
      grid-template-columns: repeat(3, minmax(0, 1fr));
      gap: 18px;
      align-items: stretch;
    }

    .column {
      --column-accent: #939caf;
      position: relative;
      min-width: 0;
      display: flex;
      flex-direction: column;
      background: var(--column);
      border: 1px solid var(--column-border);
      border-radius: 16px;
      box-shadow: 0 1px 1px rgba(24, 29, 45, 0.025);
      transition: border-color 150ms ease, box-shadow 150ms ease, background 150ms ease;
    }

    .column[data-column="progress"] {
      --column-accent: #e5a23a;
    }

    .column[data-column="done"] {
      --column-accent: #37a97b;
    }

    .column::before {
      content: "";
      position: absolute;
      top: -1px;
      left: -1px;
      right: -1px;
      height: 3px;
      background: var(--column-accent);
      border-radius: 16px 16px 0 0;
      opacity: 0.88;
    }

    .column.is-over {
      border-color: var(--accent);
      box-shadow: 0 0 0 3px rgba(109, 92, 231, 0.12);
    }

    .column.is-over::before {
      height: 4px;
      opacity: 1;
    }

    .column-header {
      padding: 17px 15px 12px;
      display: flex;
      align-items: center;
      justify-content: space-between;
      gap: 12px;
    }

    .column-name {
      display: flex;
      align-items: center;
      gap: 9px;
      min-width: 0;
    }

    .column-dot {
      width: 9px;
      height: 9px;
      flex-shrink: 0;
      background: var(--column-accent);
      border-radius: 50%;
      box-shadow: 0 0 0 4px rgba(145, 154, 170, 0.1);
    }

    [data-column="progress"] .column-dot {
      box-shadow: 0 0 0 4px rgba(229, 162, 58, 0.12);
    }

    [data-column="done"] .column-dot {
      box-shadow: 0 0 0 4px rgba(55, 169, 123, 0.12);
    }

    .column-name h2 {
      margin: 0;
      font-size: 14px;
      font-weight: 700;
      letter-spacing: -0.15px;
      white-space: nowrap;
    }

    .column-count {
      min-width: 22px;
      height: 22px;
      padding: 0 6px;
      display: inline-grid;
      place-items: center;
      color: var(--text-soft);
      background: var(--count-bg);
      border: 1px solid var(--border);
      border-radius: 7px;
      font-size: 11px;
      font-weight: 700;
    }

    .column-kicker {
      color: var(--text-faint);
      font-size: 9px;
      font-weight: 750;
      letter-spacing: 0.12em;
      text-transform: uppercase;
      white-space: nowrap;
    }

    .cards-list {
      flex: 1;
      min-height: 175px;
      padding: 11px 12px 8px;
      display: flex;
      flex-direction: column;
      gap: 10px;
    }

    .task-card {
      position: relative;
      padding: 14px;
      background: var(--surface);
      border: 1px solid var(--card-line);
      border-radius: 12px;
      box-shadow: var(--shadow-sm);
      cursor: grab;
      animation: card-in 220ms ease both;
      transition: transform 160ms ease, border-color 160ms ease, box-shadow 160ms ease, opacity 160ms ease;
    }

    .task-card:hover {
      z-index: 1;
      border-color: color-mix(in srgb, var(--accent) 24%, var(--card-line));
      box-shadow: 0 2px 4px rgba(24, 29, 45, 0.045), 0 10px 25px rgba(24, 29, 45, 0.08);
      transform: translateY(-2px);
    }

    .task-card:active {
      cursor: grabbing;
    }

    .task-card.is-dragging {
      opacity: 0.28;
      transform: scale(0.985);
      box-shadow: none;
    }

    .card-topline {
      display: flex;
      align-items: center;
      justify-content: space-between;
      gap: 8px;
    }

    .task-category {
      display: inline-flex;
      align-items: center;
      max-width: 75%;
      padding: 3px 7px;
      overflow: hidden;
      color: var(--accent);
      background: var(--accent-soft);
      border-radius: 5px;
      font-size: 9.5px;
      font-weight: 750;
      letter-spacing: 0.055em;
      text-overflow: ellipsis;
      text-transform: uppercase;
      white-space: nowrap;
    }

    .card-controls {
      display: flex;
      align-items: center;
      gap: 2px;
    }

    .drag-handle {
      width: 23px;
      height: 25px;
      display: grid;
      place-items: center;
      color: var(--text-faint);
      cursor: grab;
    }

    .drag-handle svg {
      width: 15px;
      height: 15px;
    }

    .delete-button {
      width: 26px;
      height: 26px;
      padding: 0;
      display: grid;
      place-items: center;
      color: var(--text-faint);
      background: transparent;
      border: 0;
      border-radius: 7px;
      opacity: 0.75;
      transition: color 140ms ease, background 140ms ease, opacity 140ms ease;
    }

    .delete-button:hover {
      color: #dc5363;
      background: #fff0f2;
      opacity: 1;
    }

    [data-theme="dark"] .delete-button:hover {
      background: rgba(220, 83, 99, 0.12);
    }

    .delete-button svg {
      width: 15px;
      height: 15px;
    }

    .task-title {
      margin: 9px 0 5px;
      color: var(--text);
      font-size: 14.5px;
      font-weight: 670;
      line-height: 1.35;
      letter-spacing: -0.15px;
    }

    .task-description {
      margin: 0 0 13px;
      min-height: 35px;
      display: -webkit-box;
      overflow: hidden;
      color: var(--text-soft);
      font-size: 12px;
      line-height: 1.45;
      -webkit-box-orient: vertical;
      -webkit-line-clamp: 2;
    }

    .task-footer {
      padding-top: 10px;
      display: flex;
      align-items: center;
      gap: 8px;
      border-top: 1px solid var(--card-line);
    }

    .priority-badge {
      display: inline-flex;
      align-items: center;
      gap: 5px;
      padding: 3px 7px;
      border-radius: 6px;
      font-size: 10px;
      font-weight: 700;
      line-height: 1.4;
    }

    .priority-dot {
      width: 5px;
      height: 5px;
      border-radius: 50%;
    }

    .p-high {
      color: #c23d4c;
      background: #fff0f2;
    }

    .p-high .priority-dot {
      background: #df5362;
    }

    .p-medium {
      color: #9b650f;
      background: #fff5df;
    }

    .p-medium .priority-dot {
      background: #dfa12d;
    }

    .p-low {
      color: #227d60;
      background: #e9f8f1;
    }

    .p-low .priority-dot {
      background: #35a87e;
    }

    [data-theme="dark"] .p-high {
      color: #ff8f9c;
      background: rgba(223, 83, 98, 0.13);
    }

    [data-theme="dark"] .p-medium {
      color: #f1bc61;
      background: rgba(223, 161, 45, 0.13);
    }

    [data-theme="dark"] .p-low {
      color: #6cd0aa;
      background: rgba(53, 168, 126, 0.13);
    }

    .task-due {
      margin-left: auto;
      display: inline-flex;
      align-items: center;
      gap: 4px;
      color: var(--text-faint);
      font-size: 10.5px;
      font-weight: 550;
      white-space: nowrap;
    }

    .task-due svg {
      width: 12px;
      height: 12px;
    }

    .task-assignee {
      margin-left: 2px;
    }

    .task-assignee .avatar {
      width: 25px;
      height: 25px;
      font-size: 8.5px;
    }

    .empty-state {
      flex: 1;
      min-height: 150px;
      padding: 22px 12px;
      display: flex;
      flex-direction: column;
      align-items: center;
      justify-content: center;
      color: var(--text-faint);
      background: rgba(255, 255, 255, 0.22);
      border: 1px dashed var(--border);
      border-radius: 10px;
      text-align: center;
    }

    .empty-icon {
      width: 37px;
      height: 37px;
      margin-bottom: 9px;
      display: grid;
      place-items: center;
      color: var(--text-faint);
      background: var(--surface);
      border: 1px solid var(--border);
      border-radius: 10px;
    }

    .empty-icon svg {
      width: 17px;
      height: 17px;
    }

    .empty-state strong {
      margin-bottom: 2px;
      color: var(--text-soft);
      font-size: 12px;
      font-weight: 650;
    }

    .empty-state span:last-child {
      font-size: 11px;
    }

    .drop-indicator {
      display: none;
      min-height: 54px;
      flex-shrink: 0;
      align-items: center;
      justify-content: center;
      gap: 7px;
      color: var(--accent);
      background: var(--accent-soft);
      border: 1.5px dashed var(--accent);
      border-radius: 10px;
      font-size: 11.5px;
      font-weight: 700;
      animation: indicator-in 140ms ease both;
    }

    .drop-indicator.is-visible {
      display: flex;
    }

    .drop-indicator svg {
      width: 16px;
      height: 16px;
    }

    .column-add {
      height: 38px;
      margin: 0 12px 12px;
      display: flex;
      align-items: center;
      justify-content: center;
      gap: 6px;
      color: var(--text-faint);
      background: transparent;
      border: 1px dashed transparent;
      border-radius: 9px;
      font-size: 12px;
      font-weight: 600;
      transition: color 140ms ease, background 140ms ease, border-color 140ms ease;
    }

    .column-add:hover {
      color: var(--accent);
      background: var(--accent-soft);
      border-color: color-mix(in srgb, var(--accent) 36%, transparent);
    }

    .column-add svg {
      width: 15px;
      height: 15px;
    }

    .task-dialog {
      width: min(440px, calc(100vw - 28px));
      max-height: calc(100vh - 28px);
      padding: 0;
      overflow: visible;
      color: var(--text);
      background: var(--surface);
      border: 1px solid var(--border);
      border-radius: 18px;
      box-shadow: var(--shadow-lg);
    }

    .task-dialog[open] {
      animation: dialog-in 190ms cubic-bezier(0.2, 0.8, 0.2, 1) both;
    }

    .task-dialog::backdrop {
      background: rgba(19, 23, 36, 0.5);
      backdrop-filter: blur(5px);
      -webkit-backdrop-filter: blur(5px);
    }

    .dialog-shell {
      padding: 24px;
    }

    .modal-header {
      display: flex;
      align-items: flex-start;
      justify-content: space-between;
      gap: 16px;
      margin-bottom: 22px;
    }

    .modal-title-wrap {
      display: flex;
      gap: 11px;
    }

    .modal-symbol {
      width: 35px;
      height: 35px;
      display: grid;
      place-items: center;
      flex-shrink: 0;
      color: var(--accent);
      background: var(--accent-soft);
      border-radius: 10px;
    }

    .modal-symbol svg {
      width: 18px;
      height: 18px;
    }

    .modal-header h2 {
      margin: 0;
      font-size: 19px;
      font-weight: 730;
      letter-spacing: -0.35px;
    }

    .modal-header p {
      margin: 2px 0 0;
      color: var(--text-soft);
      font-size: 12px;
    }

    .close-button {
      width: 30px;
      height: 30px;
      padding: 0;
      display: grid;
      place-items: center;
      color: var(--text-faint);
      background: transparent;
      border: 0;
      border-radius: 8px;
    }

    .close-button:hover {
      color: var(--text);
      background: var(--surface-soft);
    }

    .close-button svg {
      width: 17px;
      height: 17px;
    }

    .form-field > label,
    .form-label {
      display: block;
      margin-bottom: 7px;
      color: var(--text-soft);
      font-size: 11.5px;
      font-weight: 650;
    }

    .required {
      color: #dc5363;
    }

    .title-input,
    .column-select {
      width: 100%;
      height: 43px;
      padding: 0 12px;
      color: var(--text);
      background: var(--input-bg);
      border: 1px solid var(--border);
      border-radius: 9px;
      outline: none;
      font-size: 13px;
      transition: border-color 150ms ease, box-shadow 150ms ease;
    }

    .title-input:focus,
    .column-select:focus {
      border-color: var(--accent);
      box-shadow: 0 0 0 3px rgba(109, 92, 231, 0.11);
    }

    .title-input::placeholder {
      color: var(--text-faint);
    }

    .form-row {
      margin-top: 18px;
      display: grid;
      grid-template-columns: 1.25fr 0.9fr;
      gap: 15px;
    }

    .priority-options {
      display: grid;
      grid-template-columns: repeat(3, 1fr);
      gap: 6px;
    }

    .priority-option {
      position: relative;
      height: 43px;
      display: flex;
      align-items: center;
      justify-content: center;
      gap: 6px;
      color: var(--text-soft);
      background: var(--input-bg);
      border: 1px solid var(--border);
      border-radius: 9px;
      font-size: 11.5px;
      font-weight: 650;
      cursor: pointer;
      transition: color 140ms ease, background 140ms ease, border-color 140ms ease;
    }

    .priority-option input {
      position: absolute;
      width: 1px;
      height: 1px;
      opacity: 0;
      pointer-events: none;
    }

    .option-dot {
      width: 6px;
      height: 6px;
      border-radius: 50%;
    }

    .option-dot.low-dot {
      background: #35a87e;
    }

    .option-dot.medium-dot {
      background: #dfa12d;
    }

    .option-dot.high-dot {
      background: #df5362;
    }

    .priority-option.is-selected {
      color: var(--accent);
      background: var(--accent-soft);
      border-color: var(--accent);
    }

    .priority-option[data-option="low"].is-selected {
      color: #227d60;
      background: #e9f8f1;
      border-color: #67c5a1;
    }

    .priority-option[data-option="medium"].is-selected {
      color: #9b650f;
      background: #fff5df;
      border-color: #e6ba64;
    }

    .priority-option[data-option="high"].is-selected {
      color: #c23d4c;
      background: #fff0f2;
      border-color: #e9818d;
    }

    [data-theme="dark"] .priority-option[data-option="low"].is-selected {
      color: #6cd0aa;
      background: rgba(53, 168, 126, 0.13);
      border-color: rgba(53, 168, 126, 0.55);
    }

    [data-theme="dark"] .priority-option[data-option="medium"].is-selected {
      color: #f1bc61;
      background: rgba(223, 161, 45, 0.13);
      border-color: rgba(223, 161, 45, 0.55);
    }

    [data-theme="dark"] .priority-option[data-option="high"].is-selected {
      color: #ff8f9c;
      background: rgba(223, 83, 98, 0.13);
      border-color: rgba(223, 83, 98, 0.55);
    }

    .form-hint {
      margin: 17px 0 0;
      color: var(--text-faint);
      font-size: 11px;
    }

    .modal-actions {
      margin-top: 22px;
      padding-top: 17px;
      display: flex;
      justify-content: flex-end;
      gap: 9px;
      border-top: 1px solid var(--border);
    }

    .secondary-button,
    .submit-button {
      height: 39px;
      padding: 0 15px;
      border-radius: 9px;
      font-size: 12.5px;
      font-weight: 650;
    }

    .secondary-button {
      color: var(--text-soft);
      background: var(--surface);
      border: 1px solid var(--border);
    }

    .secondary-button:hover {
      color: var(--text);
      background: var(--surface-soft);
    }

    .submit-button {
      color: #fff;
      background: var(--accent);
      border: 1px solid transparent;
    }

    .submit-button:hover {
      background: var(--accent-hover);
    }

    .toast {
      position: fixed;
      right: 24px;
      bottom: 24px;
      z-index: 100;
      max-width: min(350px, calc(100vw - 32px));
      padding: 12px 15px;
      display: flex;
      align-items: center;
      gap: 9px;
      color: #f7f8fc;
      background: #252a37;
      border: 1px solid rgba(255, 255, 255, 0.1);
      border-radius: 11px;
      box-shadow: 0 12px 35px rgba(12, 16, 26, 0.25);
      opacity: 0;
      transform: translateY(12px);
      pointer-events: none;
      transition: opacity 180ms ease, transform 180ms ease;
    }

    .toast.show {
      opacity: 1;
      transform: translateY(0);
    }

    .toast svg {
      width: 17px;
      height: 17px;
      color: #72d4ad;
    }

    .toast span {
      overflow: hidden;
      font-size: 12px;
      font-weight: 550;
      text-overflow: ellipsis;
      white-space: nowrap;
    }

    @keyframes card-in {
      from { opacity: 0; transform: translateY(5px); }
      to { opacity: 1; transform: translateY(0); }
    }

    @keyframes indicator-in {
      from { opacity: 0; transform: scaleY(0.85); }
      to { opacity: 1; transform: scaleY(1); }
    }

    @keyframes dialog-in {
      from { opacity: 0; transform: translateY(10px) scale(0.98); }
      to { opacity: 1; transform: translateY(0) scale(1); }
    }

    @media (max-width: 1000px) {
      .search-wrap {
        width: 240px;
      }

      .brand-workspace,
      .brand-divider {
        display: none;
      }

      .board {
        grid-template-columns: 1fr;
        min-height: 0;
      }

      .column {
        min-height: 0;
      }

      .cards-list {
        min-height: 90px;
      }
    }

    @media (max-width: 640px) {
      .topbar-inner {
        height: auto;
        min-height: 64px;
        padding: 12px 16px;
        flex-wrap: wrap;
        gap: 11px;
      }

      .topbar-actions {
        width: 100%;
        margin-left: 0;
        order: 2;
      }

      .search-wrap {
        width: auto;
        flex: 1;
      }

      .shortcut {
        display: none;
      }

      .workspace {
        padding: 21px 14px 30px;
      }

      .page-heading {
        margin-bottom: 19px;
      }

      .heading-copy h1 {
        font-size: 25px;
      }

      .heading-copy p {
        font-size: 12.5px;
      }

      .team-overview {
        display: none;
      }

      .board {
        gap: 14px;
      }

      .column-header {
        padding: 15px 13px 10px;
      }

      .cards-list {
        padding: 10px 10px 6px;
      }

      .task-card {
        padding: 13px;
      }

      .column-add {
        margin: 0 10px 10px;
      }

      .dialog-shell {
        padding: 20px;
      }

      .form-row {
        grid-template-columns: 1fr;
        gap: 17px;
      }

      .toast {
        right: 16px;
        bottom: 16px;
        left: 16px;
        max-width: none;
      }
    }

    @media (max-width: 420px) {
      .primary-button {
        width: 40px;
        padding: 0;
      }

      .primary-button .button-label {
        display: none;
      }

      .heading-copy h1 {
        font-size: 23px;
      }

      .column-kicker {
        font-size: 8px;
      }

      .task-description {
        min-height: 0;
      }
    }

    @media (prefers-reduced-motion: reduce) {
      *,
      *::before,
      *::after {
        scroll-behavior: auto !important;
        animation-duration: 0.01ms !important;
        animation-iteration-count: 1 !important;
        transition-duration: 0.01ms !important;
      }
    }
  </style>
</head>
<body>
  <header class="topbar">
    <div class="topbar-inner">
      <a class="brand" href="#" aria-label="Flowboard home">
        <span class="brand-mark" aria-hidden="true">
          <svg width="22" height="22" viewBox="0 0 24 24" fill="none">
            <rect x="3" y="3" width="8" height="8" rx="2" fill="white"/>
            <rect x="13" y="3" width="8" height="8" rx="2" fill="white" fill-opacity=".65"/>
            <rect x="3" y="13" width="8" height="8" rx="2" fill="white" fill-opacity=".65"/>
            <rect x="13" y="13" width="8" height="8" rx="2" fill="white"/>
          </svg>
        </span>
        <span class="brand-name">Flowboard</span>
        <span class="brand-divider" aria-hidden="true"></span>
        <span class="brand-workspace">Acme Studio</span>
      </a>

      <div class="topbar-actions">
        <label class="search-wrap" for="searchInput">
          <svg class="search-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" aria-hidden="true">
            <circle cx="11" cy="11" r="7"></circle>
            <path d="m20 20-4-4"></path>
          </svg>
          <input id="searchInput" type="search" placeholder="Search tasks…" autocomplete="off" aria-label="Search tasks by title">
          <button id="clearSearch" class="search-clear" type="button" aria-label="Clear search" hidden>
            <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true">
              <path d="m6 6 12 12M18 6 6 18"></path>
            </svg>
          </button>
          <kbd class="shortcut" aria-hidden="true">⌘ K</kbd>
        </label>

        <button id="themeToggle" class="icon-button theme-toggle" type="button" aria-label="Switch to dark theme" aria-pressed="false" title="Switch to dark theme">
          <svg class="icon-moon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
            <path d="M20.4 15.1A8.6 8.6 0 0 1 8.9 3.6 8.7 8.7 0 1 0 20.4 15.1Z"></path>
          </svg>
          <svg class="icon-sun" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" aria-hidden="true">
            <circle cx="12" cy="12" r="4"></circle>
            <path d="M12 2v2M12 20v2M4.93 4.93l1.42 1.42M17.66 17.66l1.41 1.41M2 12h2M20 12h2M4.93 19.07l1.42-1.42M17.66 6.34l1.41-1.41"></path>
          </svg>
        </button>

        <button id="addTaskButton" class="primary-button" type="button">
          <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true">
            <path d="M12 5v14M5 12h14"></path>
          </svg>
          <span class="button-label">Add task</span>
        </button>
      </div>
    </div>
  </header>

  <main class="workspace">
    <div class="page-heading">
      <div class="heading-copy">
        <div class="eyebrow"><span class="eyebrow-dot"></span> Workspace / Product</div>
        <h1>Product roadmap</h1>
        <p>Drag cards between columns to keep work moving.</p>
      </div>

      <div class="team-overview" aria-label="Product team overview">
        <div class="team-avatars" aria-hidden="true">
          <span class="avatar" style="--avatar-bg:#7165e8">NP</span>
          <span class="avatar" style="--avatar-bg:#dd8b68">MK</span>
          <span class="avatar" style="--avatar-bg:#3b9a8f">AL</span>
        </div>
        <div class="team-copy">
          <span>Product team</span>
          <strong id="taskSummary">6 tasks · 2 complete</strong>
        </div>
      </div>
    </div>

    <section id="board" class="board" aria-label="Kanban board">
      <section class="column" data-column="todo" aria-labelledby="heading-todo">
        <header class="column-header">
          <div class="column-name">
            <span class="column-dot" aria-hidden="true"></span>
            <h2 id="heading-todo">To do</h2>
            <span id="count-todo" class="column-count" aria-live="polite">2</span>
          </div>
          <span class="column-kicker">Up next</span>
        </header>
        <div id="list-todo" class="cards-list" role="list" aria-label="Tasks in To do"></div>
        <button class="column-add" type="button" data-add="todo" aria-label="Add a task to To do">
          <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true">
            <path d="M12 5v14M5 12h14"></path>
          </svg>
          <span>Add task</span>
        </button>
      </section>

      <section class="column" data-column="progress" aria-labelledby="heading-progress">
        <header class="column-header">
          <div class="column-name">
            <span class="column-dot" aria-hidden="true"></span>
            <h2 id="heading-progress">In progress</h2>
            <span id="count-progress" class="column-count" aria-live="polite">2</span>
          </div>
          <span class="column-kicker">In flight</span>
        </header>
        <div id="list-progress" class="cards-list" role="list" aria-label="Tasks in In progress"></div>
        <button class="column-add" type="button" data-add="progress" aria-label="Add a task to In progress">
          <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true">
            <path d="M12 5v14M5 12h14"></path>
          </svg>
          <span>Add task</span>
        </button>
      </section>

      <section class="column" data-column="done" aria-labelledby="heading-done">
        <header class="column-header">
          <div class="column-name">
            <span class="column-dot" aria-hidden="true"></span>
            <h2 id="heading-done">Done</h2>
            <span id="count-done" class="column-count" aria-live="polite">2</span>
          </div>
          <span class="column-kicker">Shipped</span>
        </header>
        <div id="list-done" class="cards-list" role="list" aria-label="Tasks in Done"></div>
        <button class="column-add" type="button" data-add="done" aria-label="Add a task to Done">
          <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true">
            <path d="M12 5v14M5 12h14"></path>
          </svg>
          <span>Add task</span>
        </button>
      </section>
    </section>
  </main>

  <dialog id="taskDialog" class="task-dialog" aria-labelledby="dialogTitle">
    <div class="dialog-shell">
      <div class="modal-header">
        <div class="modal-title-wrap">
          <span class="modal-symbol" aria-hidden="true">
            <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
              <path d="M12 5v14M5 12h14"></path>
            </svg>
          </span>
          <div>
            <h2 id="dialogTitle">Create a task</h2>
            <p>Add the next piece of work to your board.</p>
          </div>
        </div>
        <button id="closeDialog" class="close-button" type="button" aria-label="Close dialog">
          <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true">
            <path d="m6 6 12 12M18 6 6 18"></path>
          </svg>
        </button>
      </div>

      <form id="taskForm">
        <div class="form-field">
          <label for="taskTitle">Task title <span class="required">*</span></label>
          <input id="taskTitle" class="title-input" name="title" type="text" maxlength="100" required autocomplete="off" placeholder="What needs to be done?">
        </div>

        <div class="form-row">
          <div class="form-field">
            <span class="form-label">Priority</span>
            <div class="priority-options" role="radiogroup" aria-label="Task priority">
              <label class="priority-option" data-option="low">
                <input type="radio" name="priority" value="low">
                <span class="option-dot low-dot" aria-hidden="true"></span>
                <span>Low</span>
              </label>
              <label class="priority-option" data-option="medium">
                <input type="radio" name="priority" value="medium" checked>
                <span class="option-dot medium-dot" aria-hidden="true"></span>
                <span>Medium</span>
              </label>
              <label class="priority-option" data-option="high">
                <input type="radio" name="priority" value="high">
                <span class="option-dot high-dot" aria-hidden="true"></span>
                <span>High</span>
              </label>
            </div>
          </div>

          <div class="form-field">
            <label for="taskColumn">Add to</label>
            <select id="taskColumn" class="column-select" name="column">
              <option value="todo">To do</option>
              <option value="progress">In progress</option>
              <option value="done">Done</option>
            </select>
          </div>
        </div>

        <p class="form-hint">A title is required. Priority badges are shown on every card.</p>

        <div class="modal-actions">
          <button id="cancelDialog" class="secondary-button" type="button">Cancel</button>
          <button class="submit-button" type="submit">Create task</button>
        </div>
      </form>
    </div>
  </dialog>

  <div id="toast" class="toast" role="status" aria-live="polite">
    <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
      <circle cx="12" cy="12" r="9"></circle>
      <path d="m8 12 2.7 2.7L16.5 9"></path>
    </svg>
    <span id="toastText"></span>
  </div>

  <script>
    (() => {
      const columnOrder = ["todo", "progress", "done"];
      const columnLabels = {
        todo: "To do",
        progress: "In progress",
        done: "Done"
      };

      const priorityLabels = {
        low: "Low",
        medium: "Medium",
        high: "High"
      };

      let tasks = [
        {
          id: "task-1",
          title: "Map the Q4 customer journey",
          description: "Align sales and support around the moments that matter most.",
          priority: "high",
          column: "todo",
          category: "Research",
          assignee: "Nora Patel",
          initials: "NP",
          color: "#7165e8",
          due: "Oct 18"
        },
        {
          id: "task-2",
          title: "Plan the mobile app launch",
          description: "Define channels, rollout stages, and launch-day owners.",
          priority: "medium",
          column: "todo",
          category: "Strategy",
          assignee: "Mina Kim",
          initials: "MK",
          color: "#dd8b68",
          due: "Oct 22"
        },
        {
          id: "task-3",
          title: "Build dashboard filters",
          description: "Let teams slice revenue, usage, and retention in one view.",
          priority: "high",
          column: "progress",
          category: "Frontend",
          assignee: "Alex Lee",
          initials: "AL",
          color: "#3b9a8f",
          due: "Oct 16"
        },
        {
          id: "task-4",
          title: "Migrate billing settings",
          description: "Move legacy forms onto the new billing service.",
          priority: "medium",
          column: "progress",
          category: "Platform",
          assignee: "Jordan Diaz",
          initials: "JD",
          color: "#bf6bc0",
          due: "Oct 21"
        },
        {
          id: "task-5",
          title: "Ship account settings v2",
          description: "Profile, security, and notification preferences are live.",
          priority: "medium",
          column: "done",
          category: "Product",
          assignee: "Nora Patel",
          initials: "NP",
          color: "#7165e8",
          due: "Oct 12"
        },
        {
          id: "task-6",
          title: "Create a release notes template",
          description: "A reusable format for clearer weekly product updates.",
          priority: "low",
          column: "done",
          category: "Content",
          assignee: "Mina Kim",
          initials: "MK",
          color: "#dd8b68",
          due: "Oct 14"
        }
      ];

      const board = document.getElementById("board");
      const searchInput = document.getElementById("searchInput");
      const clearSearch = document.getElementById("clearSearch");
      const taskSummary = document.getElementById("taskSummary");
      const themeToggle = document.getElementById("themeToggle");
      const addTaskButton = document.getElementById("addTaskButton");
      const dialog = document.getElementById("taskDialog");
      const taskForm = document.getElementById("taskForm");
      const titleInput = document.getElementById("taskTitle");
      const columnSelect = document.getElementById("taskColumn");
      const closeDialogButton = document.getElementById("closeDialog");
      const cancelDialogButton = document.getElementById("cancelDialog");
      const toast = document.getElementById("toast");
      const toastText = document.getElementById("toastText");

      let searchTerm = "";
      let draggedId = null;
      let nextTaskNumber = 7;
      let lastFocusedElement = null;
      let toastTimer = null;

      const icons = {
        trash: `
          <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
            <path d="M4 7h16M9 7V4h6v3M6 7l1 13h10l1-13M10 11v5M14 11v5"></path>
          </svg>`,
        grip: `
          <svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
            <circle cx="9" cy="6" r="1.35"></circle><circle cx="15" cy="6" r="1.35"></circle>
            <circle cx="9" cy="12" r="1.35"></circle><circle cx="15" cy="12" r="1.35"></circle>
            <circle cx="9" cy="18" r="1.35"></circle><circle cx="15" cy="18" r="1.35"></circle>
          </svg>`,
        calendar: `
          <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
            <rect x="3" y="5" width="18" height="16" rx="3"></rect>
            <path d="M8 3v4M16 3v4M3 10h18"></path>
          </svg>`,
        arrow: `
          <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
            <path d="M12 5v14M6 13l6 6 6-6"></path>
          </svg>`,
        search: `
          <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" aria-hidden="true">
            <circle cx="11" cy="11" r="7"></circle><path d="m20 20-4-4"></path>
          </svg>`,
        plus: `
          <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" aria-hidden="true">
            <path d="M12 5v14M5 12h14"></path>
          </svg>`
      };

      function escapeHtml(value) {
        const entities = {
          "&": "&amp;",
          "<": "&lt;",
          ">": "&gt;",
          '"': "&quot;",
          "'": "&#39;"
        };
        return String(value).replace(/[&<>"']/g, character => entities[character]);
      }

      function renderCard(task) {
        const dueMarkup = task.due
          ? `${icons.calendar}<span>${escapeHtml(task.due)}</span>`
          : `<span class="option-dot" style="background:var(--text-faint)" aria-hidden="true"></span><span>Unassigned</span>`;

        return `
          <article class="task-card" draggable="true" data-card-id="${task.id}" role="listitem" aria-labelledby="title-${task.id}">
            <div class="card-topline">
              <span class="task-category">${escapeHtml(task.category)}</span>
              <div class="card-controls">
                <span class="drag-handle" title="Drag to move" aria-hidden="true">${icons.grip}</span>
                <button class="delete-button" type="button" data-delete="${task.id}" aria-label="Delete ${escapeHtml(task.title)}">
                  ${icons.trash}
                </button>
              </div>
            </div>
            <h3 class="task-title" id="title-${task.id}">${escapeHtml(task.title)}</h3>
            <p class="task-description">${escapeHtml(task.description)}</p>
            <div class="task-footer">
              <span class="priority-badge p-${task.priority}">
                <span class="priority-dot" aria-hidden="true"></span>
                ${priorityLabels[task.priority]}
              </span>
              <span class="task-due">${dueMarkup}</span>
              <span class="task-assignee">
                <span class="avatar" style="--avatar-bg:${task.color}" title="${escapeHtml(task.assignee)}">${escapeHtml(task.initials)}</span>
              </span>
            </div>
          </article>
        `;
      }

      function render() {
        const query = searchTerm.trim().toLowerCase();

        columnOrder.forEach(column => {
          const allTasks = tasks.filter(task => task.column === column);
          const visibleTasks = allTasks.filter(task =>
            task.title.toLowerCase().includes(query)
          );

          const list = document.getElementById(`list-${column}`);
          const count = document.getElementById(`count-${column}`);

          if (visibleTasks.length) {
            list.innerHTML = visibleTasks.map(renderCard).join("");
          } else {
            const isSearching = Boolean(query);
            list.innerHTML = `
              <div class="empty-state">
                <span class="empty-icon">${isSearching ? icons.search : icons.plus}</span>
                <strong>${isSearching ? "No matching tasks" : "Ready for a task"}</strong>
                <span>${isSearching ? "Try another title or clear the search." : "Drop a card here or add a new one."}</span>
              </div>
            `;
          }

          count.textContent = visibleTasks.length;
          count.title = query
            ? `${visibleTasks.length} of ${allTasks.length} tasks`
            : `${allTasks.length} tasks`;
          count.setAttribute("aria-label", `${visibleTasks.length} tasks in ${columnLabels[column]}`);
        });

        const completeCount = tasks.filter(task => task.column === "done").length;
        taskSummary.textContent = query
          ? `${tasks.filter(task => task.title.toLowerCase().includes(query)).length} of ${tasks.length} tasks match`
          : `${tasks.length} tasks · ${completeCount} complete`;

        clearSearch.hidden = !query;
      }

      const dropIndicator = document.createElement("div");
      dropIndicator.className = "drop-indicator";
      dropIndicator.setAttribute("aria-hidden", "true");
      dropIndicator.innerHTML = `${icons.arrow}<span>Move here</span>`;

      function positionDropIndicator(column, clientY) {
        const list = column.querySelector(".cards-list");
        const candidates = Array.from(list.querySelectorAll(".task-card"))
          .filter(card => !card.classList.contains("is-dragging"));

        let beforeCard = null;
        for (const card of candidates) {
          const rect = card.getBoundingClientRect();
          if (clientY < rect.top + rect.height / 2) {
            beforeCard = card;
            break;
          }
        }

        const emptyState = list.querySelector(".empty-state");

        if (beforeCard) {
          if (dropIndicator.parentElement !== list || dropIndicator.nextElementSibling !== beforeCard) {
            list.insertBefore(dropIndicator, beforeCard);
          }
        } else if (emptyState) {
          if (dropIndicator.parentElement !== list || dropIndicator.nextElementSibling !== emptyState) {
            list.insertBefore(dropIndicator, emptyState);
          }
        } else {
          if (dropIndicator.parentElement !== list || list.lastElementChild !== dropIndicator) {
            list.appendChild(dropIndicator);
          }
        }

        dropIndicator.classList.add("is-visible");
      }

      function moveTask(id, toColumn, beforeId = null) {
        const task = tasks.find(item => item.id === id);
        if (!task) return;

        const previousColumn = task.column;
        task.column = toColumn;

        const remaining = tasks.filter(item => item.id !== id);
        const targetTasks = remaining.filter(item => item.column === toColumn);
        let insertionIndex = targetTasks.length;

        if (beforeId) {
          const index = targetTasks.findIndex(item => item.id === beforeId);
          if (index >= 0) insertionIndex = index;
        }

        targetTasks.splice(insertionIndex, 0, task);

        tasks = [];
        columnOrder.forEach(column => {
          if (column === toColumn) {
            tasks.push(...targetTasks);
          } else {
            tasks.push(...remaining.filter(item => item.column === column));
          }
        });

        render();

        if (previousColumn !== toColumn) {
          showToast(`Moved to ${columnLabels[toColumn]}`);
        } else {
          showToast("Task reordered");
        }
      }

      function cleanUpDrag() {
        if (draggedId) {
          const card = document.querySelector(`[data-card-id="${draggedId}"]`);
          if (card) card.classList.remove("is-dragging");
        }

        draggedId = null;
        dropIndicator.classList.remove("is-visible");
        dropIndicator.remove();
        board.querySelectorAll(".column.is-over").forEach(column => {
          column.classList.remove("is-over");
        });
      }

      function showToast(message) {
        toastText.textContent = message;
        toast.classList.add("show");
        clearTimeout(toastTimer);
        toastTimer = setTimeout(() => toast.classList.remove("show"), 2400);
      }

      function syncPriorityOptions() {
        taskForm.querySelectorAll(".priority-option").forEach(option => {
          const input = option.querySelector("input");
          option.classList.toggle("is-selected", input.checked);
        });
      }

      function openModal(column = "todo") {
        if (dialog.open) return;

        lastFocusedElement = document.activeElement;
        taskForm.reset();
        titleInput.value = "";
        titleInput.setCustomValidity("");
        columnSelect.value = columnLabels[column] ? column : "todo";
        syncPriorityOptions();

        dialog.showModal();
        document.body.classList.add("modal-open");
        requestAnimationFrame(() => titleInput.focus());
      }

      function closeModal() {
        if (dialog.open) dialog.close();
      }

      board.addEventListener("click", event => {
        const deleteButton = event.target.closest("[data-delete]");
        if (deleteButton) {
          const id = deleteButton.dataset.delete;
          const task = tasks.find(item => item.id === id);
          if (!task) return;

          tasks = tasks.filter(item => item.id !== id);
          render();
          showToast(`Deleted “${task.title}”`);
          return;
        }

        const addButton = event.target.closest("[data-add]");
        if (addButton) {
          openModal(addButton.dataset.add);
        }
      });

      board.addEventListener("dragstart", event => {
        const card = event.target.closest(".task-card");
        if (!card) return;

        draggedId = card.dataset.cardId;
        event.dataTransfer.effectAllowed = "move";
        event.dataTransfer.setData("text/plain", draggedId);
        card.classList.add("is-dragging");
      });

      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.is-over").forEach(item => {
          if (item !== column) item.classList.remove("is-over");
        });

        column.classList.add("is-over");
        positionDropIndicator(column, event.clientY);
      });

      board.addEventListener("dragleave", event => {
        const column = event.target.closest(".column");
        if (column && !column.contains(event.relatedTarget)) {
          column.classList.remove("is-over");
        }
      });

      board.addEventListener("drop", event => {
        const column = event.target.closest(".column");
        if (!column || !draggedId) return;

        event.preventDefault();

        const id = event.dataTransfer?.getData("text/plain") || draggedId;
        let nextElement = dropIndicator.nextElementSibling;
        while (nextElement && !nextElement.classList.contains("task-card")) {
          nextElement = nextElement.nextElementSibling;
        }

        const beforeId = nextElement ? nextElement.dataset.cardId : null;
        moveTask(id, column.dataset.column, beforeId);
        cleanUpDrag();
      });

      document.addEventListener("dragend", cleanUpDrag);

      searchInput.addEventListener("input", event => {
        searchTerm = event.target.value;
        render();
      });

      clearSearch.addEventListener("click", () => {
        searchInput.value = "";
        searchTerm = "";
        render();
        searchInput.focus();
      });

      addTaskButton.addEventListener("click", () => openModal("todo"));

      themeToggle.addEventListener("click", () => {
        const isDark = document.documentElement.dataset.theme === "dark";
        const nextTheme = isDark ? "light" : "dark";
        document.documentElement.dataset.theme = nextTheme;

        themeToggle.setAttribute("aria-pressed", String(nextTheme === "dark"));
        themeToggle.setAttribute("aria-label", `Switch to ${nextTheme === "dark" ? "light" : "dark"} theme`);
        themeToggle.title = `Switch to ${nextTheme === "dark" ? "light" : "dark"} theme`;

        showToast(`${nextTheme === "dark" ? "Dark" : "Light"} theme enabled`);
      });

      taskForm.addEventListener("change", event => {
        if (event.target.name === "priority") syncPriorityOptions();
      });

      titleInput.addEventListener("input", () => titleInput.setCustomValidity(""));

      taskForm.addEventListener("submit", event => {
        event.preventDefault();

        const title = titleInput.value.trim();
        if (!title) {
          titleInput.setCustomValidity("Please enter a task title.");
          titleInput.reportValidity();
          return;
        }

        const formData = new FormData(taskForm);
        const priority = formData.get("priority") || "medium";
        const column = formData.get("column") || "todo";

        const newTask = {
          id: `task-${nextTaskNumber++}`,
          title,
          description: "Just added — ready for a teammate to pick up.",
          priority,
          column,
          category: "General",
          assignee: "You",
          initials: "ME",
          color: "#7c87a0",
          due: null
        };

        tasks.unshift(newTask);
        render();
        closeModal();
        showToast(`“${title}” added to ${columnLabels[column]}`);
      });

      closeDialogButton.addEventListener("click", closeModal);
      cancelDialogButton.addEventListener("click", closeModal);

      dialog.addEventListener("cancel", event => {
        event.preventDefault();
        closeModal();
      });

      dialog.addEventListener("click", event => {
        if (event.target === dialog) closeModal();
      });

      dialog.addEventListener("close", () => {
        document.body.classList.remove("modal-open");
        if (lastFocusedElement && typeof lastFocusedElement.focus === "function") {
          lastFocusedElement.focus();
        }
        lastFocusedElement = null;
      });

      document.addEventListener("keydown", event => {
        if (dialog.open) return;

        const target = event.target;
        const isTyping = target instanceof HTMLElement &&
          (target.matches("input, textarea, select") || target.isContentEditable);

        if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "k") {
          event.preventDefault();
          searchInput.focus();
          searchInput.select();
        } else if (event.key === "/" && !isTyping) {
          event.preventDefault();
          searchInput.focus();
        }
      });

      render();
    })();
  </script>
</body>
</html>
Reasoning trace (22,269 tokens)
We need produce exactly one fenced html code block complete file self contained. Need design polished interactive Kanban matching requirements. Need think through code robust vanilla. Need plan great visual board 1280x800 and responsive 375. Need satisfy three columns counts realistic 6 cards priority badges add modal required title delete drag mouse drop clear visual drop indicator search live theme toggle in-memory only. Need ensure beautiful and interactive. Demo no keyboard requirement? not game. We can still support Enter etc but optional.

We need make high quality board. Think of layout: modern app "Flowboard" header top with small logo purple squares, nav breadcrumb maybe "Workspace / Product launch", search centered and theme toggle plus add task button. Board main three columns each light card. Header title "Product roadmap" with subtitle. Columns distinct count pill. Add card small modal.

Need choose layout to show 6 cards in columns with counts 2/2/2. Board columns fill screen within 800. Header ~72, page title area ~80, board area rest 600. Cards ~130 each each column plus add button. To do 2 cards, in progress 2, done 2. Add card through small modal triggered from top and each column footer add. Delete button on card hover (always visible maybe). Drag handle. Search field. Theme toggle via button icon sun/moon. Modal accessible proper dialog form with title required, priority select, column select, close. Background overlay.

Design details: use system fonts no external fonts (allowed no external). Color palette warm off-white light #f7f7fb, white panels, accents indigo/violet. Dark #101219 with columns. Cards white elevated, borders. Top logo. Use inline svg icons. Theme button. Soft grid board maybe subtle. Column header dot colored status, count bubble. Drop placeholder dashed accent gap appears where dropping. Clear visual indicator: dragover placeholder element with dashed border and label "Move here" inserted. Show the card being dragged at opacity (ghost class), placeholder insertion. Also dragover column highlight.

Need implement all requirements robustly.

We should plan HTML structure:
<!doctype html>
<html lang en data-theme light>
<head meta charset viewport title>
<style variables.
body .app
<header class topbar>
<div class brand><div class logo svg or four squares> ... Flowboard <span>Workspace</span></div>
<div class top-actions>
<label class search><svg...><input id search type search placeholder "Search tasks..." aria-label...><kbd>⌘ K</kbd></label>
<button class icon-button theme-toggle id themeToggle aria-label... title...> svg sun and moon via CSS </button>
<button class add-button id addTask><svg plus> Add task</button>
</div>
</header>
<main class workspace>
<div class page-head><div><p class eyebrow>PRODUCT WORKSPACE</p><h1>Product roadmap</h1><p class subtitle>Keep the team moving, one task at a time.</p></div><div class summary><div avatars ...> ... <span>6 tasks · Updated just now</span></div></div>
</div>
<section class board aria-label Kanban board>
<article class column data-column=todo>
<header column-header><div class column-title><span class status-dot ...> </span><h2>To do</h2><span class count>2</span></div><button class more> ...</button></header>
<div class column-body cards data-dropzone> card elems </div>
<button class add-card data-add="todo"><svg plus> Add task</button>
</article>
...
</section>
</main>
<div modal ... hidden>...
</div>
<script>state = [...], render.

Want 6 prepop cards realistic:
todo:
"Define Q3 launch metrics" high; desc? requirement title priority, can include meta due etc. Rich card labels. "Audit customer onboarding flow" medium. Maybe tags "Research", "Marketing". in progress:
"Build new dashboard experience" high, "Migrate billing settings page" medium. done:
"Set up analytics events" low/medium, "Create release notes template" low. Need visually alive variety, avatars and due date, description optional. Requirements each title priority colored badge. Add small category chip and due dates maybe enrich. Card title, description optional, badges row: priority badge and category, footer avatar + due date. Looks premium. But data 6 spread. Could use realistic titles:
todo "Plan the mobile app launch" high category Strategy, due "Sep 24", assignee "MK" avatar violet.
todo "Refresh help center articles" low? Need priority counts varied. 
in progress "Build dashboard filters" high category Frontend, due "Sep 19", "AL"
in progress "Improve checkout error states" medium, "JS"
done "Ship account settings v2" medium
done "Define design tokens" low.

Make consistent date context maybe "Oct 2024" irrelevant no system date. Use "Oct 18" etc. H1 "Product roadmap" 6 tasks.

Should card desc consume space, nice small. Board 1280 width 1160, 3 cols 370, card ~150. 2 per column -> ~320 + header + add => 440; board fits ~550. Page head ~110 topbar 72 -> 800 board 560. Good. At 375 stack columns and board tall, page scroll vertical okay no horizontal. Header responsive brand label maybe hide "workspace" and search go row. Topbar 68 desktop padding 32. At mobile topbar grid: brand + actions wrapping; search full width row? Use .topbar display flex; .top-actions flex. Mobile .topbar height auto flex-wrap, .top-actions width 100%, .search flex 1, .add-button .label maybe icon only? Want still visually nice. At 375 topbar brand width and theme + add icon? CSS .add-button span hide at <=520, set width 40. Search kbd hide. Header board stacked columns width 100%.

Desktop main padding 28 32 40. Board grid gap 20 align-items start. Column background #eef0f5 maybe; border #e6e8ef; radius 16; overflow visible. Column header padding 18 18 14, body flex gap 12 min-height 120 padding 0 12 12. Cards white radius 12 border, shadow subtle. Column body maybe no fixed height so height differs, board columns equal height via align-items stretch (grid default stretch). .column display flex flex-direction column. .column-body flex 1. .add-card bottom margin? .column .add-card margin 0 12px 12px.

Page header includes breadcrumb maybe h1 enough. Brand includes logo custom squares. Top right search. Could add "Team workspace" title and small avatars summary as decor to make screenshot alive. In title row right a pill "Sprint 24" with circle and avatars? We can make team avatars plus "6 active tasks" but counts computed. Requirements count per column. Summary "6 tasks · 3 teammates" (computed tasks count). Cards footer includes avatar circle with initials and assignee tooltip.

Modal: fixed overlay background rgba(20,24,40,.48) backdrop-filter blur(4px). dialog width 440 max calc. header "Create a task" + close button. Form title input required id taskTitle placeholder e.g. "What needs to be done?" focus. Priority radio/segmented buttons low medium high with active styling. Column select with native select. Form actions Cancel + Create task. Add "Priority" label. Title required. Can add small keyboard tip Enter? No need. JS openModal(column default) show overlay with hidden attribute removed. Focus title after rAF. keydown escape closes. clicking overlay closes (only target overlay). submit adds new card to selected column, resets form, render and toast maybe toast "Task added to To do" bottom. Delete button with trash icon on card top right; accessible aria-label "Delete task". Need prevent drag delete? pointer? HTML5 draggable card, buttons inside may work. handle via draggable card, event listeners.

Drag and drop: HTML5 native DnD mouse works desktop. Card draggable true, dataTransfer set text/plain card.id, effectAllowed move. on dragstart add .is-dragging; set data, after rAF? The source card fades at opacity .4. Store draggedId variable and sourceColumn. We can keep placeholder separate element inserted at position within target .cards-list. Use .drop-indicator dashed border height 64, text "Move here" maybe icon arrow. Clear indicator always appears in dragover target zone. On dragenter/dragover over column body, preventDefault; highlight .is-over. Move indicator to insertion position based on mouse Y relative to cards. Ideally indicator follows hovered position between cards. Implement getDragAfterElement(container, y): cards except dragging and not indicator, find whose midpoint > y. Set indicator before element. On dragover: e.preventDefault, dataTransfer.dropEffect="move"; target list e.currentTarget (list). list.classList.add("is-over"); indicator display; compute insertion and insertBefore. Use one indicator element appended to body but moving across lists fine. dragleave tricky child triggers; can use relatedTarget and container.contains check, else remove over but not indicator? We want indicator show if drag over any list. document dragover? Each list dragover handles. Add class to column via closest. On drop: e.preventDefault, id from dataTransfer/ draggedId; target column from list.dataset.column; move state card; cleanup; render; toast. Dragend cleanup. On dragleave if !list.contains(e.relatedTarget) remove .is-over; indicator might remain until dragend. okay. Also dragenter preventDefault.

When search active, filtering cards live by title: renderCards filters matching title case insensitive; count per column should reflect filtered visible vs total? Requirement each showing card count. Could count all or filtered. Prefer count showing visible / total when search. Use data count state column length, and if filtered display count visible, with total title maybe. Search filter list. Indicator while searching skip? drag still possible visible. renderBoard on each state change builds innerHTML cards but attaching events delegation at board level avoids per-card listeners. Use delegated click on board for delete and add; drag events delegated? dragstart target.closest("[data-card]"), add listener board; dragover list. We can re-render on move causing DnD events cleanup but drop already complete.

Potential code security: use escaping function for dynamic title since user input. Construct DOM with template strings and escapeHTML, or createElement. Using innerHTML with escape. Provide safe escape for &<>"'. Category from state fixed. Priority enum safe. IDs generated increment counter no crypto? e.g. "task-"+Date.now() to be unique; no storage. assign avatars maybe current user initials "JD"; new card tag "General", due "No due date"? Description optional? New task form only title, priority, column. Card shows title priority and metadata perhaps no category. Keep data fields title, priority, column, category optional default "General", assignee initials "ME", color. New card shows "New" category and "Just now" due? Should not invent due date maybe show "Unassigned". Good. Store state array, nextId.

Priority badges colored: high rose #e5484d bg #fff0f1; medium amber #b76e00 bg #fff6df; low teal/green #1f8a66 bg #eaf8f1. Dark variants. Badge with tiny dot and label capital. Could use priority pill with flag? Use small filled circle. Card high border-left? No requirement. Add priority badge in top metadata row alongside category. Card title top after handle and delete? Layout card:
<div class card-top>
<span class task-type category>Strategy</span>
<button class card-action delete>...</button>
</div>
<h3>Plan the mobile app launch</h3>
<p class desc>Align on goals...</p>
<div class card-footer>
<span class priority-badge priority-high><span dot></span>High</span>
<span class due-date><svg clock> Oct 18</span>
<span class avatar style bg>MK</span>
</div>
Drag handle maybe top right next delete, requirement not explicit but helpful. Could show grip on left card top? <span class card-grip aria-hidden><svg>6 dots</svg></span> top row. Category chip and grip. Delete top right. Card title. Priority in footer. 6 cards enough visually. Important count at headers visible: count pill. Column header with colored dot and count and more icon? More menu would be nonfunctional bad. Avoid more button unless implement. Could have a subtle column-menu with plus action which works (data-add). Header right "+" mini button opens modal for that column with tooltip. That plus and footer add duplicates but okay. Only footer add.

We should avoid excessive text at top so board cards prominent. Header 72 with border-bottom background translucent. Logo left. Top search width 300, theme round, Add task button. Page title row h1 and subtext. Board columns. Light theme default white/gray. Theme toggle persisted? No storage, state in memory only. We can toggle documentElement dataset.theme, icon changes. Use theme button title. Theme transition, respect prefers-color-scheme? Default light is requirement toggle. Could default based on prefers-color-scheme but screenshot could dark unpredictable. Default light to look premium; optional initialize prefers dark? Better deterministic light.

In memory state only, no localStorage.

Add toast ephemeral bottom right, visible meaningful after interaction. No storage.

Need consider requirement "add a card through a small modal form (title required)". Form valid built-in; preventDefault if empty? required checkValidity/reportValidity. Could also check trimmed. On submit trim; if empty set customValidity "Please enter a task title." reportValidity. title length max 100. Use input required maxlength 100. Modal priority radio group required with default medium. Native select. Header count. Delete immediate no confirm (confirm blocked). Could animate removal via render. Toast "Task deleted". Could implement undo? Not necessary but polished, no confirm. Deletion button clear.

Search live by title: input event set searchTerm then render (rebuild list). If render all board on input it can lose card drag? no. Preserve scroll? board lists innerHTML replaced; scroll page stays. On mobile no issue. Counts update visible with search state. Column add label maybe matches. Show search empty state inside column when no matches "No matching tasks" and small reset button maybe. Each empty column if no tasks show "Drop a task here" dotted area. Prepop 2 each.

Need set counts initial. Use count data count and aria-label. renderColumns or renderBoard: board static HTML columns, each list container. We could static markup with list id list-todo. Each render update list innerHTML and count. Column lists dropzones. No need regenerate column. .column data-column. state columns const. .empty-state for no visible. But empty state inside dropzone; insertion logic skips .task-card. Indicator.

We can create card template with icon based priority maybe colored square. category chips. Description optional but cards realistic and board looks alive. Use realistic descriptions, make 2-3 lines but screenshot quality. Need not overload.

We should consider visual style: use CSS font-family Inter if system not available fallback -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif. background light #f5f6fa; subtle radial gradient at top. header white/80 backdrop blur. column background #edeff4; headers labels. For dark background #0f1117; top #151821; columns #181c26; card #202531; border #2c3241; text #edf0f8. Shadows adjust.

Precise dimensions 1280x800:
body height 100%. .app min-height 100vh. topbar height 72 sticky top z 20. workspace max-width 1240 margin auto padding 26px 28px 40.
page-head margin bottom 24 display flex h1 28px. top row ~72+26+70+24 =192. board area ~568. columns min-height calc? .board min-height:520px. Each col 2cards (approx card 148+148+gap 12) + header 62+footer 48 =418; stretch columns to board height if align-items stretch (grid default) but board height not set. Could .board {min-height: calc(100vh - 220px)} to fill ~580. .column {min-height: 530px}. At 800 bottom to ~745 good. At 1280 3 columns 380 width. Cards content h: top 24 + title 44 (2lines) + desc 34 + footer 32 + padding 28 =>162. Two =>336+ gap, col 570 leaves empty nice. Done. Maybe cards more compact ~146. Keep board min-height 530.

Page header right "Sprint 24" pill and team avatars. Team avatars can be overlapping with letters. Count summary "6 tasks" computed. Could include small progress "42% complete" computed done/total. But no need. For look alive add "Team board" small breadcrumb to left of h1? Eyebrow "WORKSPACE / PRODUCT" muted uppercase. h1 "Product roadmap". Subtitle "A clear view of what’s moving forward." right shows avatar stack and "3 teammates". Better.

Columns:
To do with gray dot? accent neutral indigo? "To do" dot #8b95a8; in progress amber #f5a524; done green #22a06b. Header includes count and maybe column total label. Column title + count.
Column style body background slightly lighter/darker.

Cards category chips text color (different pale background), not too much. Priority badges lower. Due date use calendar icon maybe. Avatar colored circle. Description gray. Card delete top right trash icon opacity .5; on hover red.

Drag ghost: when dragging, native default screenshot ghost; we can set card opacity .35; border dashed? drop indicator is div .drop-indicator with dashed 2px #7c6cf2 bg rgba. Contains down arrow icon and "Move here". Height 52, margin? CSS list gap 10 so indicator as flex child; set .drop-indicator {min-height: 58px; ...}. When dragging over source list we could show indicator at original position. Actual dragged card still occupies space, indicator appears causing shift; acceptable. Could hide source card display none but then list reflows. Use .is-dragging {opacity:.32;} and placeholder after based on cursor. Clear.

Dragover position algorithm: list includes empty state; if no tasks list empty plus indicator. indicator insertBefore first card based on y. For each card in list:not(.is-dragging) compute rect midpoint. if y < midpoint insert before card else after. This inserts indicator maybe before/after. But with original card in list opacity and dragging it can end up indicator after it based mouse location. Fine.

Drop target should be column body .cards-list. .column drop maybe if user drops on header? requirement between columns can drop on card/list. We can support whole column as dropzone: list handles body, column dragover also prevent. Drop handlers on list and column? Could set .column body covers most. We can add board-level drop detection on column event delegated dragover/drop, find .column; indicator. HTML5 events bubble. For clarity list dragover enough, but dropping over column header not necessary. We can implement board event listeners:
board.addEventListener('dragover', e=> { const column=e.target.closest('.column'); if !column return; e.preventDefault(); const list=...; highlight; position indicator using clientY;});
board.addEventListener('drop', e=> { const column=e.target.closest('.column'); if (!column) return; e.preventDefault(); moveTask(draggedId, column.dataset.column); cleanup;});
board.addEventListener('dragleave', e=> { const col=...; if col && !col.contains(e.relatedTarget) col.classList.remove('is-over');});
This handles all column area, but board dragover e.target.closest('.column') if inside card. Fine. When dragover over the indicator, target.closest('.column') still column. position uses list cards. Must prevent indicator inserting itself; skip .drop-indicator. Use list.querySelectorAll('.task-card:not(.is-dragging)') and rects. On each dragover reposition only if different to reduce jitter. Ensure list.classList.add('drag-over'); column .is-over. board listener fires for any child. If dragover outside columns board not prevent so can't drop. Fine.

dragstart delegated on board: target.closest('.task-card'), but if delete button? draggable attribute only card and interactive children may still drag. Set card draggable true. On dragstart state. Use e.dataTransfer.setData('text/plain', card.id); e.dataTransfer.effectAllowed='move'; setTimeout(()=>card.classList.add('is-dragging'),0). Sometimes dragstart with render? no.
dragend delegated board or document: remove class from dragged card; remove indicator; remove over. draggedId=null. We can use document dragend.

Since rendering with innerHTML after adding moves resets listeners but delegation persists.

Search: renderAll update .column-count. When filtering, count visible. Header count label maybe "2" and class .is-filtered. Total count attr title "2 of 2 tasks". Board summary "6 tasks" maybe filtered count if searching else total. update #totalCount and #filteredNote. Add top search results text "3 results" maybe search field small #searchStatus with aria-live. The search bar could include icon and clear button when query. implement clear button hidden until query. Show kbd "/" as keyboard hint? Need keyboard focus note? "Click or press a key to start" only applies games; not relevant. We can include "⌘ K" but implement focusing with meta/ctrl K and "/" maybe. No need but polish. If showing ⌘ K and not working would be poor; implement keydown document if (metaKey||ctrlKey)&&key k -> focus search, prevent default. slash if not input -> focus. Search field placeholder "Search tasks…". clear button x.

Top action add task. On mobile hide Add label but aria-label. Theme toggle uses inline SVG sun/moon. We can put two svg elements and CSS based theme. "Switch to dark theme" aria-label updated. Button title. Use .theme-toggle .sun display block light theme? If current light, show moon to switch to dark (common) or sun icon theme? Use sun in light mode button? Label "Switch to dark theme", icon moon. Dark mode shows sun. CSS .icon-moon display none default? data-theme light .icon-moon display block; .icon-sun none; dark reverse. Include transition rotate.

Modal form:
<div class="modal-overlay" id="taskModal" hidden>
<div class="modal" role="dialog" aria-modal="true" aria-labelledby="modalTitle">
<div class modal-header><div><span class modal-icon>plus</span><h2 id modalTitle>New task</h2></div><button id closeModal class ...></button></div>
<form id taskForm>
<label for taskTitle>Task title</label>
<input id taskTitle ...>
<div class form-row>
<div class form-field><span>Priority</span><div class priority-options role=radiogroup> labels radio. Need radio inputs visually hidden but focus style. each label .priority-option data-priority. Native radio checked. Use 3 columns.
</div>
<div class form-field><label for taskColumn>Column</label><select...></select></div>
</div>
<p class form-hint>Your task will be added to the selected column.</p>
<div class modal-actions><button type button class btn-secondary id cancelModal>Cancel</button><button type submit class btn-primary>Create task</button></div>
</form>
</div>
</div>
Overlay hidden style .modal-overlay[hidden]{display:none}. Animate via CSS keyframes when open; hidden removal then .modal animation. Could use overlay display grid. set hidden false. body overflow hidden to avoid scroll, restore. Focus trap optional implement Tab trap within modal (nice). Use keydown if modal open and Tab cycle. Escape closes. Modal backdrop click closes. Form submit handle. Reset priority default medium, title empty, select default todo. Need set priority default when opening each time: form.reset then select value defaultColumn; radio medium default checked in HTML. priority options change CSS via :has? Latest Chrome supports :has but could use JS class .selected. Simpler radio inputs with CSS input:checked + span? markup <label class=priority-option><input type=radio name=priority value=high checked? ><span class=priority-icon ...>...</span><span>High</span></label>. CSS .priority-option:has(input:checked) modern chrome fine; use input {position absolute opacity:0}; label:has checked bg. Better avoid :has maybe JS update .is-selected. But :has supported Chrome latest. Fine. We can implement JS change event toggles classes so reliable.

Column select options To do / In progress / Done. The column id values todo, progress, done. State columns mapping.
Add new task card: {id:'task-'+(++idCounter),title,priority,column,category:'New task',description:'Created just now — ready to get started.',assignee:'ME',avatar:'#8b7cf6',due:null}. Should new task be too fancy? Fine. If no due date footer show "No due date" maybe clutter. Could show category chip and priority only footer avatar. State new card "General", "Just added" meta.
Card count summary computed.

Realistic card data:
id 1 "Map the Q4 customer journey" priority high column todo category "Research" description "Align sales and support around the moments that matter most." assignee "NP" color "#7165e8" due "Oct 18"
id 2 "Plan mobile app launch" medium todo category "Strategy" description "Define channels, rollout stages, and launch-day owners." assignee "MK" color "#dd8b68" due "Oct 22"
id 3 "Build dashboard filters" high progress category "Frontend" desc "Let teams slice revenue, usage, and retention in one view." assignee "AL" color "#3b9a8f" due "Today" maybe due overdue amber; choose "Oct 16".
id 4 "Migrate billing settings" medium progress category "Platform" desc "Move legacy forms onto the new billing service." assignee "JD" color "#bf6bc0" due "Oct 21"
id 5 "Ship account settings v2" medium done category "Product" desc "Profile, security, and notification preferences are live." assignee "NP" color "#7165e8" due "Oct 12"
id 6 "Create release notes template" low done category "Content" desc "A reusable format for clearer weekly updates." assignee "MK" color "#dd8b68" due "Oct 14"

Could use exactly 2 each. Priorities varied low/medium/high. Search match "launch" shows two. Category chip subtle. Priority badge high red, medium amber, low green. Date label. Avatar colors.

Description all cards maybe makes board visually filled. Titles fonts 15 weight 650, descriptions 12.5 line height 1.5. Card height ~155. Two card ~320. nice.

Column drop target indicator: CSS .drop-indicator align center gap 8 color #6d5dd3 border dashed. During drag, .column.is-over background slight accent. .cards-list.drag-over maybe. indicator no initial display none? Set hidden initially? Using hidden attribute and display style conflict? .drop-indicator {display:none}; .drop-indicator.is-visible {display:flex}. JS toggles. board initial no indicator.

When search no visible cards: render empty state "No matching tasks" and icon. But list empty state could be drop target. Cards list min-height 180. .empty-list message center.

Column counts when query update to number visible. Perhaps header count with total pill text "2 / 2" not needed. Use count "2", plus title. Under summary if filtered show "3 matching tasks" dynamic. We can include .search-summary id liveSummary at top? Search field status "3 results" inside after input maybe but narrow. Use small #searchResultNote positioned right of page title? Could show a chip "Showing 3 of 6" near search input when search. Top right .search-meta. We can set #searchFeedback absolute? Let's design topbar brand width 200, search center width 320, actions. top-actions contains search. Inside .search-box .search-feedback hidden, might squeeze. Better on page header under title right pill id boardSummary "6 tasks · 2 complete" changes "3 of 6 tasks". Summary with progress icon. We'll update.

Page head right: .team-overview <div avatar stack> NP MK AL</div><div><strong>Product team</strong><span id taskSummary>6 tasks · 2 complete</span></div>. On mobile hide second text? .page-heading align start; .team-overview maybe display none <=620 to avoid cramped. Good.

Column min height 500. Footer add button low prominence dashed? .column-add width 100%, border 1px dashed transparent background transparent color muted; hover dashed border and accent. Always visible with plus. For screenshot "Add task" shows functionality.

Colored header vertical accent? .column-title-dot etc. status badge. Count pill. Maybe header includes title and count then small "..." not needed. Add a slim colored line top of column with --accent at top? Could set .column::before height 3 border radius top; colors distinct. This makes board alive. column todo accent #98a2b3, progress #e9a23b, done #3fb987. To do gray, in progress amber, done green. Nice.

Background decorative gradient only subtle.

Accessibility: buttons labels, focus visible outline, reduced motion. Card titles semantic h3. Column section labelled h2. drag card keyboard? Requirement mouse only. Could add buttons for move? Not required. Card delete button aria-label. aria-live for count updates perhaps count aria-live polite. Modal role. Esc.

No external resources. Use inline svg repeated strings in template. We can define SVG as templates string in JS for icons. Template includes inline markup. Need ensure CSS class names.

We could use SVG symbol defs hidden to reuse? simplest each HTML inline icons. Use currentColor and stroke width.

Need adhere output exactly one fenced code block no text. Should produce full HTML.

We have lots of tokens; need ensure high quality. We can refine design implementation.

Detailed HTML:
<header class="topbar">
 <div class="topbar-inner">
  <a class="brand" href="#" aria-label="Flowboard home"> logo mark svg maybe 4 squares; <span class brand-name>Flowboard</span><span class brand-divider></span><span class brand-workspace>Acme Studio</span></a>
 <div class="topbar-actions">
  <div class="search-wrap">
   <svg class search-icon ...>
   <input id searchInput placeholder="Search tasks" autocomplete=off>
   <button id clearSearch ... hidden>x</button>
   <kbd class shortcut>⌘K</kbd>
  </div>
  <button id themeToggle class icon-button...> moon/sun</button>
  <button id addTask class primary-button><svg plus><span>Add task</span></button>
 </div>
 </div>
</header>
<main class workspace>
 <div class page-heading>
  <div class heading-copy><div class breadcrumb><span class small-dot></span> WORKSPACE <span slash>/</span> PRODUCT</div><h1>Product roadmap</h1><p>Plan, prioritize, and ship work as a team.</p></div>
  <div class team-overview> avatar stack; text Product team / id taskSummary.</div>
 </div>
 <section class board id board>
  <section class column data-column=todo style --accent etc>
   <div class column-header><div class column-name><span class column-dot></span><h2>To do</h2><span class count id count-todo>2</span></div><span class column-label>UP NEXT</span></div>  (optional label right; adds premium). Maybe header right .column-hint "2 tasks"? count already. Use <span class="column-kicker">UP NEXT</span> for todo, "IN FLIGHT", "SHIPPED". Distinct. At mobile still fit.
   <div class cards-list data-list=todo role=list aria-label="To do tasks"></div>
   <button class column-add data-add=todo><svg plus> Add task</button>
  </section>...
 </section>
</main>
<div modal overlay hidden>...
<div toast aria-live>
<script>
const columns = {todo:{label:'To do'},progress:{label:'In progress'},done:{label:'Done'}};
let tasks = [...];
let searchTerm='';
let draggedId=null;
let modalColumn='todo';
...
render();
</script>

Cards-list role list but children article role listitem. innerHTML.

Cards rendering:
function cardTemplate(task) {
 const priorityMeta = {high:{label:'High',icon:'...'},...};
 return `<article class="task-card priority-${task.priority}" draggable="true" data-card-id="${task.id}" role="listitem" aria-label="${escapeHtml(task.title)}">
  <div class="card-topline">
   <span class="category-tag">${escapeHtml(task.category)}</span>
   <div class="card-controls">
    <span class="drag-handle" title="Drag to move" aria-hidden="true">${gripSvg}</span>
    <button type="button" class="delete-button" data-delete="${task.id}" aria-label="Delete ${escapeHtml(task.title)}">${trashSvg}</button>
   </div>
  </div>
  <h3 class task-title>${escapeHtml(task.title)}</h3>
  <p class task-description>${escapeHtml(task.description)}</p>
  <div class task-footer>
   <span class="priority-badge priority-${task.priority}"><span class="priority-dot"></span>${meta.label}</span>
   <span class="task-due ${task.due==='Today'?'due-soon':''}">${calendarSvg}${task.due||'No date'}</span>
   <span class="task-assignee"><span class="avatar" style="--avatar-bg:${task.color}" title="${name}">${initials}</span></span>
  </div>
 </article>`;
}
Need priority class applied both card and badge; CSS .task-card.priority-high maybe border? Avoid override. Use .task-card[data-priority="high"] maybe. Badge class p-badge p-high. Simpler.

Footer arrangement: priority left, due right of it, avatar right. display flex; .task-due margin-left auto. If avatar separate. Add border-top? card footer padding-top 12, border-top 1px solid var(--card-line). That creates nice. Card height top 20+title 38+desc 34+footer 34+padding 32 =158.

Card title no overflow. Description -webkit-line-clamp 2 to consistent. For responsive wide fine. Use escapeAttr same escape. Inline style color validated data but could set background with CSS style --avatar-bg. CSS .avatar {background:var(--avatar-bg); color:#fff;}. Data safe.

No due date for new tasks; show category "New" and maybe "Just now" instead of date. state due "Just now". Good.

Counts:
function render() {
 const q = searchTerm.trim().toLowerCase();
 for each column:
 const all=tasks.filter;
 const visible=all.filter(task=>task.title.toLowerCase().includes(q));
 list.innerHTML= visible.length? visible.map... : `<div class empty-state>${...}<span>${q?'No tasks match your search':'Drop a task here'}</span></div>`;
 count.textContent=visible.length;
 count.title=`${visible.length} ${...}${q ? ` of ${all.length}`:''}`;
 list.setAttribute('aria-label',...)
 total = tasks.length; completed...
 summary.textContent=q ? `${visible.length} of ${total} tasks match` : `${total} tasks · ${completed} complete`;
 // board class .is-searching maybe.
 clearSearch.hidden=!q;
 searchInput.setAttribute('aria-expanded')? no.
}
Note if searching and dragging source opacity then render not happening until drop. good.
One issue render clears indicator if search input while dragging not likely.

Opening modal:
function openModal(column='todo') {
 modalColumn = column;
 form.reset();
 document.getElementById('taskTitle').value='';
 select.value=column;
 syncPriorityOptions();
 overlay.hidden=false;
 document.body.classList.add('modal-open');
 requestAnimationFrame(()=>{overlay.classList.add('is-open'); title.focus();});
 lastFocused=document.activeElement;
}
CSS overlay hidden and opacity animation. Could simply remove hidden, .modal animation. overlay initial background. use [hidden] display none. focus. Closing remove hidden and body class. Need no animation on close but fine.
Click overlay if e.target===overlay close.
Escape key.
Form submit:
e.preventDefault();
const title=titleInput.value.trim();
if (!title) {titleInput.setCustomValidity('Please enter a task title.'); titleInput.reportValidity(); return;}
titleInput.setCustomValidity('');
const data = new FormData(form);
priority=data.get('priority')||'medium'; column=select.value;
tasks.unshift? state array push; to ensure column ordering at end. push.
render();
closeModal();
showToast(`“${title}” added to ${columns[column].label}`);
titleInput.focus? no.
Form reset to medium default.
Maybe new card at top of column instead of end for visibility. On adding, tasks.unshift with appropriate column and sorting? Existing tasks array order used within each column; unshift puts new card first of all columns, visible first in target. Great. tasks.unshift(newTask). For IDs no collisions.
Deletion:
board.addEventListener('click', e=> {
 const del = e.target.closest('[data-delete]');
 if (del) { const task=tasks.find(...); tasks=tasks.filter; render(); showToast(`“${task.title}” deleted`); return; }
 const add=e.target.closest('[data-add]'); if(add) openModal(add.dataset.add);
});
top add listener.
The card itself isn't clickable (good). Drag handle decorative. Could card draggable only when drag handle? Set draggable true on article. user can drag anywhere. Fine.

Move function:
function moveTask(id,column) {
 const task=tasks.find(t=>t.id===id);
 if (!task) return;
 const from=task.column;
 task.column=column;
 if(from!==column) {
  // reordering maybe render moves card to end of target. acceptable.
  render();
  showToast(`Moved to ${columns[column].label}`);
 }
}
We want when drop to same column no action. Could move to end even same? no.

Drag indicator:
const indicator=document.createElement('div'); indicator.className='drop-indicator'; indicator.innerHTML=`${arrowSvg}<span>Move here</span>`; indicator hidden? CSS display none, .is-visible.
document board dragover:
const column=e.target.closest('.column');
if (!column || !draggedId) return;
e.preventDefault();
e.dataTransfer.dropEffect='move';
document.querySelectorAll('.column.is-over').forEach remove if not same
column.classList.add('is-over');
const list=column.querySelector('.cards-list');
const cards=[...list.querySelectorAll('.task-card:not(.is-dragging)')];
let next=null;
for(const card of cards){const rect=card.getBoundingClientRect(); if(e.clientY < rect.top+rect.height/2){next=card;break;}}
if(next){ if(indicator.nextElementSibling!==next) list.insertBefore(indicator,next);} else { if(list.lastElementChild!==indicator) list.appendChild(indicator);}
indicator.classList.add('is-visible');
Potential source dragged card before indicator and list.lastElementChild logic. Fine.
When no cards, append. empty state remains before indicator; could hide empty state while dragging? .column.is-over .empty-state {opacity:.4}. Indicator appended after empty state so visually lower; better if empty, insert indicator before empty-state. Or list.innerHTML empty div; we can insert indicator before empty-state if next? Our cards loop none -> append after empty state. Could adjust: const empty=list.querySelector('.empty-state'); if(empty){list.insertBefore(indicator,empty);} else append. Good.
If q filtered list has 1 card and indicator moves.
dragleave: board 'dragleave' e target column; relatedTarget null maybe fired from child to column; if !column.contains(e.relatedTarget) remove. If relatedTarget is null on crossing outside, remove. indicator remains until dragend. Fine.
drop:
board.addEventListener('drop',e=>{ const col=e.target.closest('.column'); if(!col||!draggedId)return; e.preventDefault(); const id=e.dataTransfer.getData('text/plain')||draggedId; moveTask(id,col.dataset.column); cleanupDrag();});
But moveTask render before cleanup -> render removes old card with .is-dragging, then cleanup uses document.querySelector(`[data-card-id=...]`) maybe not. cleanup indicator remove and columns remove classes, draggedId=null. fine.
dragend document cleanup.
dragstart:
const card=e.target.closest('.task-card'); if (!card) return; draggedId=card.dataset.cardId; e.dataTransfer.setData('text/plain',draggedId); e.dataTransfer.effectAllowed='move'; requestAnimationFrame(()=> card.classList.add('is-dragging')); card.classList.add? set immediately to show ghost. setTimeout 0. The native drag image captures before opacity maybe; but opacity reduces source during drag. requestAnimationFrame may not fire before screenshot drag but fine. Could use e.dataTransfer.setDragImage custom ghost to look nice, but not necessary.
Maybe Firefox needs dataTransfer; latest Chrome.

Clear drag state on dragend. Also prevent click after dragging? deletion unaffected. Sometimes drag fires click on card but no click action.

Search keyboard cmdK: document.addEventListener('keydown', e => {
 if ((e.metaKey||e.ctrlKey) && e.key.toLowerCase()==='k') {e.preventDefault(); search.focus(); search.select();}
 if (e.key==='/' && !isEditable) {prevent; search.focus();}
 if (e.key==='Escape' && modal open) close; if search value clear? only if not modal.
});
No conflict with input. "/" in input normal.
Modal focus trap implement:
if (!overlay.hidden && e.key==='Tab') { const focusables=overlay.querySelectorAll('button:not([disabled]), input:not([disabled]), select...'); if shift and active first -> last; else if active last -> first;}.
Radio inputs visually hidden but focusable? input opacity and position absolute; still focusable. trap query includes. hidden radio maybe position absolute width 1px; okay.
When open, focus title; on close restore lastFocus (if connected). We can use setTimeout.
Title input input event setCustomValidity('') so error clears.

Theme:
themeToggle.addEventListener('click',()=> { const next=document.documentElement.dataset.theme==='dark'?'light':'dark'; document.documentElement.dataset.theme=next; themeToggle.setAttribute('aria-label', next==='dark'?'Switch to light theme':'Switch to dark theme'); themeToggle.title=...; showToast(`${next==='dark'?'Dark':'Light'} theme enabled`);});
No storage. Could add theme transition body.

Toast:
<div class toast id toast role=status aria-live=polite><svg check>...<span></span></div>
JS showToast(msg) { text; class show; clear timeout; set remove 2600}. Use memory timeout allowed. No alerts.

Potential "no horizontal scrollbar" ensure .board min-width 0, .column min-width 0, .topbar-inner max width, search input min-width 0. At 375 .workspace padding 16, column width 343. .card-footer due and badge fit: priority ~60, due ~68, avatar 26, total 154 fits. heading. .topbar-inner padding 14. .brand-name; .brand-workspace hide <=480. topbar inner display grid? CSS:
.topbar-inner {max-width:1240px;margin:auto;height:72px;display:flex;align-items:center;gap:24px;}
.brand flex-shrink 0.
.topbar-actions {margin-left:auto;display:flex;align-items:center;gap:10px;}
.search-wrap width:300.
@media max-width:760:
.topbar-inner{height:auto;min-height:68px;padding:14px 18px;flex-wrap:wrap;gap:12px;}
.brand{order:1}
.topbar-actions{order:2;margin-left:auto;}
@media max-width:560:
.topbar-inner{padding:12px 16px;}
.brand-workspace/divider display none
.topbar-actions{width:100%;margin-left:0;order:3;}
.search-wrap{flex:1;width:auto}
.shortcut display none
.primary-button span maybe display none? But "Add task" important; can keep label at 375 if room: top actions 343 width: search 200 + 40 theme + 40 add + gaps 20 =300 fits. At <=480, hide primary label => icon-only. But user can see plus and aria. Requirement add card through modal not screenshot necessarily. Could keep label on 375: brand top row; actions row full width; search flexible; theme 40; add 100; total search 180 fits 343 minus 20=323-140=183. good. Keep label; @max400 maybe hide label. We can use .primary-button .button-label hide at 420 to guarantee. Desktop at 375 brand row no issues.
Board @max-width:900 grid one column? Tablet 768 could 3 cols too narrow (240) still maybe. requirement adapt gracefully down to375; use <=900 grid-template-columns:1fr; each col. But at 800 wide could stack three; nice vertical board. At 1280 3. Maybe breakpoint 860. .board {grid-template-columns:repeat(3,minmax(0,1fr));}. @max900 grid 1; columns min-height:auto; cards-list min-height maybe 80. Body page scroll. At 375 top title and summary. .page-heading flex direction column at <=700. Team overview hide at <=600? we can keep row under title but no need; hide to avoid clutter. Board columns stacked.

Column cards list padding 0 12px; column header etc. At mobile .column margin maybe.

Animations: card fade-in render each input could reanimate on search causing flicker. CSS .task-card animation: cardIn .22s. On live search each key re-render, animation resets, acceptable but maybe busy. Could not animate render? initial screenshot cards look fine. We can apply .task-card animation cardIn .25s ease both; but render on search causes all move. Fine. Reduced motion disable.

Body light background gradient top right lavender. Dark gradient.

"counts" header .count has background white, border. todo count 2, progress 2, done 2. The counts can be visible.

Visual drop indicator clearly dashed purple with "Drop here" text and down arrow. Column .is-over background accent tint, header color. Add .column.is-over .cards-list maybe background.

Maybe include drag hint visible in card footer? small grip top. Users know draggable. Add tooltip on grip. Could put board instruction pill near page title right: "Drag cards to move them" with mouse icon. But team overview already. Could make team summary plus little "Drag & drop enabled" maybe. Requirement first screenshot alive; not needed. Maybe board header row: taskSummary and tiny progress bar.

Page right team-overview:
<div class="team-avatars" aria-hidden="true"><span class avatar style..>NP</span>...</div>
<div class team-copy><span>Product team</span><strong id taskSummary>6 tasks · 2 complete</strong></div>
Maybe columns visually.

Logo SVG: rounded square gradient purple with white four tiles? Make .brand-mark 32x32 background linear-gradient, pseudo squares. Inline svg <rect> etc. use currentColor? design:
<div class brand-mark aria-hidden><svg viewBox 0 0 32 32><rect x=5 y=5 width=9 height=9 rx=2 fill=white opacity=.95>... four squares maybe bottom right offset.</svg></div>. Purple gradient box. brand name 16 weight 750 letter-spacing -.2.
Brand-workspace separated with vertical line and text "Acme Studio" 13 muted. Good.

Top search icon stroke muted. focus-within border accent ring. search width 296 height 40, background var(--surface-subtle), border 1. kbd pill.

Theme button circle 40 border background. Add button height 40 purple gradient or solid #6d5ce7, hover. Shadow. plus icon.

CSS variables:
:root {
--bg:#f5f6fa; --surface:#fff; --surface-soft:#f9fafc; --surface-muted:#eef0f5; --border:#e4e7ee; --text:#202332; --text-soft:#697083; --text-faint:#949bad; --accent:#6d5ce7; --accent-hover:#5b4ad2; --accent-soft:#efedff; --column:#eff1f5; --column-border:#e4e7ee; --card-line:#e7e9ef; --shadow:0 1px 2px rgba(20,24,40,.04),0 5px 16px rgba(20,24,40,.045);
}
[data-theme=dark] {
--bg:#0f1219; --surface:#191d27; --surface-soft:#151923; --surface-muted:#202530; --border:#2b3140; --text:#eef0f6; --text-soft:#a3aabb; --text-faint:#767e92; --accent:#8a7cf2; --accent-hover:#9a8df8; --accent-soft:rgba(138,124,242,.14); --column:#171b24; --column-border:#282e3c; --card-line:#2b3140; --shadow:...
}
body background radial gradient(ellipse at top right, rgba purple .08, transparent 45%), var bg.
Dark body gradient.
Need transition background/color .2s but card render after theme uses variables instant.

Column background var(--column); border 1 var(--column-border); radius 16; box-shadow 0 1px 0. Each column ::before top accent strip 3px with width full? border radius. .column {position:relative;overflow:hidden;} But drop indicator and card draggable within no issue. overflow hidden may clip drag ghost? Native drag ghost outside unaffected, but card draggable not. Drag indicator. Modal outside. column overflow hidden can clip card shadows slightly; padding adequate. Use overflow: visible; ::before border-radius top. .column-header border-bottom maybe 1px solid transparent. card shadows.
Column header height 62, padding 16 16 13. .column-kicker font 9.5 uppercase tracking .12em color faint. count 22x22.
.column-body/cards-list flex:1;padding:12px;display:flex;flex-direction:column;gap:10px; min-height:160px.
column-add margin 0 12px 12px;height 38;border dashed var(--border); background transparent;border-radius10;color soft; display flex align center justify center gap6; transition. .column-add:hover accent. The plus icon.
Maybe columns with no matching tasks show empty-state height 130 border dashed background rgba.

Board min-height calc(100vh - 230px); .column min-height:100%; .column body flex.
At 1280 topbar 72 + workspace top26 + heading80 + 24 =202 board 558; min-height calc 570; page bottom ~772. Good. .workspace max-width 1240 width 100%. At 1280 padding 28 leaves width 1224, columns (1224-40)/3=394.7. Nice.

Header topbar inner max width 1240, same.

Task card hover transform translateY(-1px), border-color #d8dbe6, shadow bigger. .is-dragging transform none opacity .28. card controls. Delete button 26x26 with transparent, color faint, hover red. Grip 22.
Category tag font 10.5 uppercase? could be 10.5 weight 650 letter .04, color #6c5ed0; background var accent soft; border radius 5; padding 4 7. Category all same accent not multicolor; looks cohesive. Could vary category colors via data? no need.
Card title margin 10 0 6, font 14.5 line 1.35. description 12.5 line 1.5 color soft, margin 0 0 13, min-height 36 maybe. footer border top padding 11 display flex.
Priority badges: font 10.5 weight 650; dot 6px. high dot #e04f5f; medium #e0a02b; low #2e9a74. background high #fff0f2; medium #fff7e6; low #e9f8f2. Dark backgrounds tinted.
Due icon 12, color faint.
Avatar 25x25 font 9.5 bold white border 2 var surface. Avatars with colored backgrounds.
Task due "Oct 18" no year.

Column header status dot maybe 8 with box shadow ring. todo accent gray, progress amber, done green. done header count.

Modal style:
.overlay position fixed inset0 z100 display grid place-items center padding20 background rgba(23,26,40,.48) backdrop-filter blur(6px); animation fade .18.
.modal width 440 max 100% background surface border border radius18 padding24 shadow 0 24 80 rgba.
.modal header display flex justify; margin-bottom 22. h2 20. subtitle maybe "Capture the next piece of work." 13. modal icon purple circle.
label font12.5 weight650; input width100 height44 border radius10 background surface-soft; focus accent.
form-row display grid grid-template-columns:1.25fr .9fr gap14 margin-top18; at mobile grid 1.
priority-options display grid 3 cols gap7. option border 1 border radius9 height42 display flex justify center. radio hidden. .priority-option.is-selected color etc via JS classes; icons flag? use simple dot via span. For high show up arrow? We can use inline icon .priority-symbol red. Easier label text "Low", "Medium", "High" and colored dot in a span.
Column select styled native with chevron? wrapper; can just select appearance none + background svg impossible; keep native arrow. height44 width100 padding.
.form-hint font12 muted margin 16 0 0.
.modal-actions border-top margin-top 24 padding-top18 display flex justify-end gap10. cancel and primary.
Buttons.

Toast position fixed right24 bottom24 z150, display flex align center gap10 background #222634 color white dark; light theme maybe text inverse. shadow radius12 padding12 16, opacity0 translateY(10) pointer-events none. .show. Icon check circle green.
At mobile bottom16 left16 right16.

Maybe subtle footer? not needed.

Search clear button x hidden attr; CSS [hidden] {display:none !important} globally? Important because .search-clear has display flex overriding hidden. Add [hidden]{display:none !important}. Good.

JS details security:
function escapeHtml(value) { return String(value).replace(/[&<>"']/g, char => map[char]); }
When task title inserted into aria label need escape. ID trusted.
New task category 'General'; description 'Newly added to the board and ready to be picked up.'; due 'Just now'; assignee 'ME'; avatar color '#6d7c93'. New tasks due display "Just now" but calendar icon weird; use dot? okay. We could set due null and template has no due icon, then footer avatar at far right with margin-left auto. task description adds height. Fine.

Search by title only, exactly requirement. If category query not match no; good. placeholder "Search by title…". Filter includes trim lowercase. Could preserve search field and count. Use input event render.

Counts with search: visible counts vs total could confuse "each showing its card count". It does show current visible count; add title shows total. Good.

Initial render immediately after script. HTML list empty until JS, first paint screenshot after 3 sec so populated. But requirement something meaningful visible first paint; JS runs synchronous bottom so fine. Could prepopulate card HTML statically to ensure first paint? Not needed, script immediate. However automatic screenshot 3s, fine. Still static JS state. Good.

Need no console errors. Check FormData radio works. Element variables get by id.
const searchInput = document.querySelector...
No top-level use of document before loaded? script at end body yes.
Use optional chaining supported latest Chrome.

Native drag event on article and button: when dragstart, e.target might be child (SVG path), closest works. e.target is Element for SVG path, closest method supported on SVGElement. yes.
DataTransfer getData. no.
In drop, e.dataTransfer could null? drop event has. guard.
In dragover, e.dataTransfer maybe null? Chrome has. guard.
Cleanup called after render: document.querySelector indicator maybe removed if list innerHTML resets on render! Indeed moveTask calls render() which replaces list innerHTML, thus indicator removed automatically. cleanup checks if indicator.parentNode remove, columns classes remove. fine.
But in drop handler, call moveTask then cleanup; draggedId still set until cleanup. render. okay.
On drop into same column no render, cleanup removes indicator.
When dragging over column, list.innerHTML indicator appended as child after card; during drag native events okay.
Source card remains .is-dragging in DOM. It might be selected in position algorithm excluded.
When dropping to target, task order: render filters by array order, moved task appears at its original array index but in target column maybe between other tasks depending global array order. Since tasks array initial todo1 todo2 progress1 progress2 done1 done2, moving todo1 to done yields done order [todo1,done1,done2] due global array, good. New push/unshift. Fine.

For drag indicator insertion accurate but final position not exactly indicator position (moved task array order maybe not). Could implement reorder state on drop based indicator position. Nice but not required. Requirement drag between columns only. We can keep moved to target and maybe at top? If final card not at indicator place may be jarring. We can implement moveTask with position index derived indicator and task reorder. Need compute target task list after removal and insert at desired index. On drop, indicator's preceding visible card data id gives target index. But search may filter, state underlying order. Simpler final moved card inserted first in target column: tasks = tasks.filter != id; find last index of target column tasks and splice after? If unshift global then appears first. Would be obvious drop at indicator maybe mismatch. Better compute insertion index of target tasks underlying. Let's design:
state tasks array; render filters by title, preserving array order within column. On drop indicator position between cards. Determine next visible card after indicator: indicator.nextElementSibling if task-card then id; else null. Then underlying target column tasks filtered (all, not search) excluding dragged task. Determine insertion index: if nextId, find index in target list; else target list.length. Insert task at target list index in global tasks ordering tricky because global array interleaves columns but initial grouped. We can simply rebuild columns grouped? state array ordered groups. Move task to end of column group using global positions. Underlying list = tasks.filter t.column===target; index. Remove moved task from tasks; then find reference next task id in global tasks; insert before it; if end, insert after last task of target column. If next visible excludes due search maybe indicator next matching card but there could be hidden nonmatching tasks underlying before it. Use matching visible and next id, then insert before that global ref, which may place after hidden tasks. okay.
But need only visual. We can implement function moveTask(id,column,beforeId=null):
const task = find; task.column=column; // update
const colTasks = tasks.filter(t=>t.column===column);
let insertIndex = beforeId ? colTasks.findIndex(id) : colTasks.length; if -1 = length.
We want reorder within column state. Could maintain tasks grouped by column? We can update order array:
const without = tasks.filter(t=>t.id!==id);
const target = without.filter(t=>t.column===column);
const other = without.filter(t=>t.column!==column);
Then tasks = [...todoGroup, ...progressGroup, ...doneGroup]? This re-groups tasks each time and column order explicit. Initial grouped. Define order columns ['todo','progress','done']; state tasks array groups. For move:
without; target; insert task into target at index; then rebuild tasks = columnOrder.flatMap(c=> c===column?target:without.filter...). This is simple. But when new task unshift global breaks grouping; instead add function insert to column group end or start and rebuild. Keep state as flat but always group after render? Could state tasks grouped. render uses filter by col anyway so group ordering irrelevant. But rebuild on every move ensures within column. Implement:
function moveTask(id,toColumn,beforeId) {
 const task=tasks.find...
 const source=task.column;
 task.column=toColumn;
 const remaining=tasks.filter(t=>t.id!==id);
 const targetTasks=remaining.filter(t=>t.column===toColumn);
 let index=targetTasks.length;
 if(beforeId){const i=targetTasks.findIndex(t=>t.id===beforeId); if(i>=0) index=i;}
 targetTasks.splice(index,0,task);
 tasks = [];
 for(const col of Object.keys(columns)) tasks.push(...remaining.filter(t=>t.column===col && col!==toColumn), ...(col===toColumn?targetTasks:[]));
Wait remaining includes tasks of other columns; loop col: for each, filter remaining col, for toColumn use targetTasks. fine. This puts task at insertion. If dropping on empty, end.
Initial tasks order grouped manually.
New card add to column at beginning or end: use insertTask function targetTasks.unshift(newTask) and rebuild. Could just tasks.unshift then render; state order within target new at beginning due unshift. Fine but other columns unaffected. New appears first of target because global tasks starts new then target existing. Good.

Indicator position: on drop beforeId = indicator.nextElementSibling?.dataset.cardId (skip empty). But indicator might be before dragged source card if in same list? source .is-dragging next sibling maybe; could be. If dropping same column and beforeId dragged id, remove first then target find? We exclude dragged from remaining and beforeId refers dragged => index -1 -> end, okay. Fine.
If indicator next sibling empty-state no id => end.
Move render and toast. Need source same and reorder: show "Task reordered" maybe. requirement no. Toast if source diff; same "Task reordered".

However native drop placement perhaps card relative indicator inaccurate due dragged ghost opacity occupies. Accept.

We can include an "Unassigned" etc? no.

Task descriptions realistic; title requirement. Priority labels shown colored. Count each.

Check light/dark theme icon CSS:
.theme-toggle .icon-moon {display:block}
.theme-toggle .icon-sun {display:none}
[data-theme="dark"] .theme-toggle .icon-moon{display:none}
[data-theme="dark"] .theme-toggle .icon-sun{display:block}
Button aria update initial.

CSS data-theme on html. html background var.

Focus visible outline 3px rgba accent.

Reduced motion.

Responsive:
@media (max-width: 960px) {
 .workspace{padding:24px 20px 36px}
 .board{grid-template-columns:1fr;min-height:0}
 .column{min-height:0}
 .cards-list{min-height:90px}
 .page-heading{...}
}
Maybe at 960 board stacked; screenshot at1280 fine.
@media (max-width:640px) {
 .topbar-inner{...}
 .brand-workspace hide
 .workspace padding 20px 14px 30
 .page-heading margin-bottom20
 .heading-copy .eyebrow maybe
 h1 25
 .team-overview{display:none}
 .board gap14
 .column-header padding14
 .cards-list padding10
 .task-card padding14
 .modal padding20
 .form-row grid 1
 .toast...
}
@media max420 .primary-button .button-label display:none; width40; padding0.
Search width on mobile .search-wrap {flex:1; min-width:0}. topbar-actions width 100% at <=620. .shortcut hidden at <=900 maybe.
Header at desktop topbar actions search 300. At 960, workspace.

Need maybe set .topbar sticky but at mobile scroll header remains; good. backdrop-filter. board under sticky maybe.

No external fonts/images. SVG inline.

Let's formulate complete code carefully.

HTML head title "Flowboard — Product roadmap". meta description maybe.
CSS lengthy but okay.

Let's verify class conflicts:
- priority-badge .p-high etc. CSS dark.
- card template priority class maybe `task-card p-${priority}` but CSS .p-high applies background might make whole card pink if selector general. Use `.priority-badge.p-high`; template `<span class="priority-badge p-${task.priority}">`.
- `task-due` uses svg.
- `.avatar` used both card and team. Team avatars need style variable. .avatar color white. Team .avatar border.
- brand logo.

Task columns static:
<section class="column" data-column="todo" aria-labelledby="heading-todo">
 <header class column-header>
 <div class column-name><span class column-dot></span><h2 id...>To do</h2><span class column-count id count-todo aria-live=polite>2</span></div>
 <span class column-kicker>Up next</span>
 </header>
 <div class cards-list id list-todo data-list="todo" role="list" aria-label="Tasks in To do"></div>
 <button class column-add type=button data-add=todo aria-label="Add a task to To do">plus <span>Add task</span></button>
</section>
progress list, done.
CSS .column[data-column=todo] {--column-accent:#8d97aa}. progress amber. done green.
At top column ::before background var column-accent.
.count maybe border with accent? no.

Heading team avatars:
NP color purple, MK orange, AL teal. .avatar widths26. Stack margin-left? .team-avatars display flex; .avatar + .avatar margin-left:-7. team card avatars also border.

Task summary id taskSummary "6 tasks · 2 complete". After render.

Search results summary. Search field clear button id clearSearch type button aria-label Clear search hidden. kbd.

Modal:
Header .modal-heading with .modal-symbol plus and h2 "Create a task", p "Add the next piece of work to your board."
button close.
Form fields.
Priority options:
<label class="priority-option" data-option="low"><input type=radio name=priority value=low><span class="option-dot low-dot"></span><span>Low</span></label>
medium checked.
high.
JS form.addEventListener('change', e if e.target.name==='priority' sync)
syncPriorityOptions selects .priority-option class is-selected based radio.checked, aria? native radio.
Column select.
Cancel.
Hint maybe priority "High priority tasks are highlighted in red." not needed.
modal footer.

Toast.

JS icons strings:
const icons = {
trash:`<svg viewBox="0 0 24 24" ...><path d="M3 6h18M8 6V4h8v2m-9 0 1 14h8l1-14M10 10v6m4-6v6"/></svg>`,
grip:`<svg width...><circle cx=9 cy=6 r=1 ...>` fill currentColor no stroke maybe.
calendar,
plus,
search,
check,
arrowDown.
HTML top icons manually same.

escape map. IDs strings task-1 etc.

Task data uses `assignee: 'Nora Patel'` maybe tooltip shows full. initials.
avatar color safe.
due icons.

render:
const query=searchTerm.trim().toLowerCase();
Object.keys(columns).forEach...
const matches = all.filter...
list.innerHTML = matches.length ? matches.map(renderCard).join('') : `<div class="empty-state"><span class="empty-icon">${searchTerm?icons.search:icons.plus}</span><strong>${...}</strong><span>${...}</span></div>`;
We should not call searchTerm with quotes unescaped in html; static text.
count.textContent=matches.length;
count.setAttribute('aria-label', `${matches.length} tasks in ${label}`) but count live.
count.title = query ? `${matches.length} of ${all.length} tasks` : `${all.length} tasks`;
taskSummary.textContent = query ? `${matches.length} of ${tasks.length} tasks match` : `${tasks.length} tasks · ${doneCount} complete`;
clearSearch.hidden=!query;
searchInput.parent... maybe .has-query.
document.getElementById board? add class searching.

`empty-icon` svg sized 20.

renderCard uses `task.description` always string; escape. due `task.due` nullable. Footer markup:
<span class priority...>${dot}<span>${label}</span></span>
<span class task-due>${due?calendar+escape(due):'<span class="unassigned-dot"></span>Unassigned'}</span>
<span class avatar ...>${initials}</span>
For new task due null and assignee ME.
title attribute avatar.

Card action delete button `data-delete` attr id. aria-label "Delete task: title". data id safe.
Category.

Delete handler:
const button = event.target.closest('[data-delete]');
if(button){event.stopPropagation(); const id=button.dataset.delete; const task=tasks.find...; if(!task)return; tasks=tasks.filter; render(); toast...}
Because rerender deletes button while event handler still executing okay.
Column add handler.
Top add open.

Add form:
const title=...; if !title custom validity. `titleInput.setCustomValidity('Please add a title.'); titleInput.reportValidity(); return;`
titleInput input event clears validity.
priority from form.elements.priority.value (RadioNodeList .value). column select.
newTask:
id:`task-${Date.now()}-${taskSequence++}` maybe Date.now enough; no storage. IDs with timestamp. nextTaskId.
description:'Just added — ready for a teammate to pick up.'
category:'General'
assignee:'You'
initials:'ME'
color:'#7c87a0'
due:null
Insert to beginning of chosen column:
tasks.unshift(newTask); render.
Maybe tasks.unshift means if select progress it appears first of progress because filter preserves global order: yes first globally, so first in progress. Good.
close.
Toast.
No long hidden.
Modal open sets priority medium via form.reset and then sync. Need HTML checked medium, form.reset resets checked medium. select value.
`openModal(column='todo')`: lastFocused = document.activeElement; overlay.hidden=false; body class; titleInput.value=''; setCustomValidity(''); form.reset(); select.value=columns[column]?column:'todo'; sync; requestAnimationFrame title.focus.
Focus before? setting hidden false and focus immediate works, but animation. use setTimeout 30.
Close: overlay.hidden=true; body.classList.remove; if lastFocused focus.
Overlay click only if target overlay.
Esc key.
Focus trap: query `'button, input, select, textarea, [href], [tabindex]:not([tabindex="-1"])'` filter offsetParent!==null. Hidden radio offsetParent? input absolute 1x1 inside label has offsetParent label, so included. It is fine.
We could avoid trap complexity error by just not trap. Accessibility modal can use native <dialog> but styling with dialog showModal. Could use `<dialog id=taskDialog>` and .showModal, .close; native backdrop and focus trap automatically. That's better! But requirement small modal; native dialog accessible. Use `<dialog class="task-dialog" id="taskDialog" aria-labelledby...>` with form. `dialog::backdrop`. showModal. Need style. Native dialog supported latest Chrome. openModal: dialog.showModal(); form reset; set timeout focus. closeModal: dialog.close(). Dialog 'cancel' event prevented and closeModal? Native escape triggers cancel then closes automatically; we can handle cancel event preventDefault closeModal. Clicking backdrop detection e.target===dialog might close, but dialog box itself includes inner .dialog-shell; if click backdrop e.target dialog yes; close. Native showModal centers. Style dialog border none padding0 width440 max-height. `dialog[open]` animation. Backdrop rgba blur. ::backdrop animation not fully but fine.
Body overflow hidden optional showModal prevents? background scroll still maybe; add class.
Focus trap native.
When closed restore last focus automatically native usually, but we can.
Using dialog, CSS `.task-dialog::backdrop`.
Need ensure clicking inside not close. Wrap .dialog-shell.
This is robust. But native dialog showModal can throw if already open; check !dialog.open.
close on cancel.
No hidden overlay. Great.

HTML:
<dialog class task-dialog id taskDialog aria-labelledby=...>
 <div class=dialog-shell>
  <div class modal-header>...
  <form ...>
 ...
</dialog>
Form method? no method, JS prevent.
`dialog.addEventListener('click', e=> { const rect=dialog.getBoundingClientRect(); const inside = e.clientX>=rect.left...; if(!inside) close; })` Since dialog content covers actual dialog rect? Dialog element's box is exactly content; clicking backdrop on ::backdrop targets dialog? For native dialog, clicking backdrop event target is dialog, coordinates outside. We can check e.target===dialog and coordinate. If clicking padding inside? dialog padding 0 so e.target shell. Use if e.target===dialog close. This may close when clicking margin nonexistent.
Cancel button type button.
`dialog.addEventListener('close',()=>body.classList.remove('modal-open'))`.

Native dialog body background. Good.

CSS dialog:
.task-dialog {width:min(440px, calc(100vw - 28px)); padding:0; border:1px solid var(--border); border-radius:18px; background:var(--surface); color:var(--text); box-shadow...; overflow:visible;}
.task-dialog[open]{animation:dialogIn .2s cubic...}
.task-dialog::backdrop{background:rgba(...);backdrop-filter:blur(5px);}
.dialog-shell{padding:24px}
@media small padding20.

ShowModal focus will focus first focusable close button by default, we override title.

Now no overlay hidden.

Keyboard cmdK when dialog open should not steal? check if dialog.open return except escape. Search not. keydown:
if(dialog.open) return (native handles esc)
if ctrl+k...
Native cancel event closes.

Theme button toast maybe initial? no.

Drag and modal interaction no.

Potential search "⌘ K" on Windows maybe display "/" instead? use kbd "⌘ K", users on Windows ctrl K works; fine.

Maybe keyboard hint `⌘ K` looks good.

No localStorage.

We need decide if first screenshot at 3 seconds sees page without interaction. yes.

Could add tiny top status "Live board" with green dot. Eyebrow "WORKSPACE / PRODUCT" enough.

Let's make board cards category tags and priority badges. All counts.

Let's write CSS in complete detail.

CSS root and dark.
* box-sizing.
html,body margin 0 min-height 100%.
body font. overflow-x:hidden (guarantee no horizontal). line-height 1.5. color.
button,input,select font inherit.
button cursor pointer.
svg display block maybe global `svg {display:block; flex-shrink:0}`; icon sizes via CSS.
Topbar:
position sticky top0 z-index40; background color-mix? color-mix latest but use var surface with opacity? background:rgba(255,255,255,.82); dark variant need override. Define --topbar-bg rgba. border-bottom.
backdrop-filter blur(16px).
.topbar-inner max-width1240 margin auto padding 0 28 height70 display flex.
.brand display flex.
.brand-mark width34 height34 border-radius10 background linear-gradient(135deg,#8c7cf3,#5c4cdb); box-shadow.
.brand-name margin-left10 font17 weight750 letter -.3.
.brand-divider margin 0 13 height20 border-left.
.brand-workspace font13 color soft.
.topbar-actions display flex gap9 margin-left:auto.
.search-wrap width 296 height40 display flex align center gap9 padding 0 10 0 12 background var input-bg border radius10 border. position relative.
input width100 border0 outline none background transparent min-width0 color text. placeholder.
.search-icon 17.
.shortcut font10 background surface border line etc.
.search-clear 22.
icon-button 40x40.
primary button.
On dark topbar rgba.

Workspace .workspace max-width1240 margin0 auto padding 25px 28px 38.
page-heading display flex justify space-between align center margin-bottom23.
.eyebrow display flex align center gap7 font10.5 weight700 letter .12 color soft uppercase. .eyebrow-dot 6 green maybe.
h1 margin 6 0 3 font28 line1.2 letter-.7.
.subtitle font13.5.
team overview flex; avatar stack; team copy.
.board display grid grid-template 3 gap18 align-items stretch.
.column background var column border radius16 border1 position relative; display flex flex-direction column; min-width0; box-shadow; transition.
column::before.
column-header display flex align center justify-between padding 17px 15px 12px; gap.
column-name display flex align center gap9.
column-dot width9 height9 border radius50 background accent; box shadow 0 0 0 4px color-mix? use box-shadow rgba? define --accent-ring via color-mix. Could use opacity ring pseudo. .column-dot::after? just solid.
h2 font14.5 weight700.
count min-width22 height22 padding0 7 display inline grid place center background var count-bg border etc font11.5 bold.
kicker 9px letter.
cards-list flex1 display flex column gap10 padding 11px 12px 8px min-height175.
task-card.
At 1280 board columns equal height determined by max content ~ 2 cards+headers+footer. min-height calc(100vh - 225px) via .board {min-height:calc(100vh - 226px)} and .column height:100%. Grid stretch; min-height board applies to grid container, columns stretch to board min height. CSS grid align-items:stretch and min-height:calc... means columns height container. Good. .column min-height:100% maybe.
Column footer add.

Task card no position relative? add.
.card-topline.
.category.
.card-controls.
.drag-handle cursor grab.
.delete.
.task-title.
.task-description line-clamp.
.task-footer.
priority.
Task due.

Empty state.

Drop indicator:
height56 flex:0 0 56; border2 dashed var accent; background var accent-soft; radius10; color accent; display none; align center justify center gap7 font11.5 weight650; animation indicatorIn.
.drop-indicator.is-visible display flex.
.column.is-over {border-color:var accent; box-shadow 0 0 0 3px accent soft; background maybe color-mix}. Use `background: var(--accent-soft)` would replace column background fully too purple. Better `.column.is-over {box-shadow:0 0 0 3px rgba(109,92,231,.12);border-color:rgba(...);}` plus .column-dot. .cards-list maybe.
`.column.is-over::before{height:4px}`.
Task card dragging opacity .32; filter grayscale(.2); transform rotate(1deg) scale(.99). Native drag image snapshot still full opacity though. Custom drag image:
const ghost = taskCard.cloneNode(true); ghost.classList.add('drag-ghost'); style width rect.width; position fixed; top -1000; etc; document.body.append; e.dataTransfer.setDragImage(ghost, 20,20); setTimeout remove. But cloning card with CSS var works; nice. Yet dragging custom ghost can make screenshot clear. We can add:
function dragstart e:
const rect=card.getBoundingClientRect();
const ghost=card.cloneNode(true);
ghost.classList.add('drag-ghost');
ghost.style.width=`${rect.width}px`;
ghost.style.position='fixed'; ghost.style.left='-9999px'; ghost.style.top='0'; ghost.style.opacity='.95'; ghost.style.pointerEvents='none'; document.body.append(ghost);
e.dataTransfer.setDragImage(ghost, Math.min(30,rect.width/2), 20);
setTimeout(()=>ghost.remove(),0);
This may fail if remove too early? setDragImage snapshot taken immediately; remove in setTimeout 0 after event likely okay. Keep 100ms. .drag-ghost styling must override .task-card animation/box shadow. position. source class is-dragging.
But native setDragImage requires element visible? position left -9999 may still work? Usually works. Could use absolute top -1000. Keep. However Chrome may not render offscreen? Standard use. This adds complexity. Not necessary. Default ghost is card. source opacity.
Clear visual indicator enough.

Column add.
Footer.

Dialog CSS.

Toast.

@media.
At max960 maybe topbar actions.
At max900 board stack; topbar maybe.
At max620.
Need avoid horizontal due dialog width calc, toast.

No fonts external.

JS first render. Could also set current year? no.

Let's consider icon svg in search. Use line cap round.

HTML top:
<button id themeToggle class="icon-button theme-toggle" type button aria-label... title...>
<svg class="icon-moon" viewBox...><path d="M20.5 14.2A8.5..."></path></svg>
<svg class="icon-sun"... circles lines></svg>
</button>
CSS .icon-sun hidden etc.

Add button plus.

Task card category uses .task-category.

Modal priority option dot:
.low-dot background green; .medium-dot amber; .high-dot red.
is-selected border accent background accent-soft. For low selected still accent border; okay maybe each selected color. Could CSS `.priority-option.is-selected[data-option="low"]` etc using data. Set color specific and border. But requirement priority colored badges, modal choices can use. Use:
.priority-option.is-selected {border-color:var(--accent);background:var(--accent-soft);color:var(--accent);}
Could override low selected green maybe `.priority-option.is-selected[data-option=low]{border-color:#2c9b75;background:#eaf8f2;color:#187553}` etc. Dark variants. Fine.

Native select option backgrounds var surface may render. style option color.

Summary right maybe `.team-overview` hidden mobile.

Search state count. Could board columns counts show total when filtered? We can display `2` visible and perhaps a small "of 2" label? If search, count as visible. aria/title. requirement no issue.

Search clear button hidden. When typing, render lists and summary.

One nuance: deleting a task while search active after render; okay.
Add modal from column button. The add footer `data-add` delegated board click. top add direct.
Click on delete inside draggable card triggers card's native drag maybe if movement; okay.

Drag handle cursor and card cursor default; task card could cursor grab entirely? Requirement drag with mouse. Set .task-card {cursor:default}; .drag-handle cursor:grab. But user may try drag title and it works despite cursor default. Could set card cursor grab, but then text selection not. Set `.task-card {cursor:grab}` and `.task-card:active{cursor:grabbing}`. Buttons override pointer. This signals. Text selection sacrificed but okay. Maybe only grip cursor. I think card cursor grab fine.
Tooltip handle.

Need show drop indicator when dragging over card. delegated board dragover. `e.target.closest('.column')` returns column. If e.target is list itself, good.
Native drag events require draggable true. The card has button but dragstart works.
`document.addEventListener('dragend', cleanupDrag);`.
`dragover` event's clientY. We need set indicator even if list filtered and no cards. indicator appended after empty state but adjust.
function positionIndicator(column, clientY) {
 const list=...
 const cards = Array.from(list.querySelectorAll('.task-card:not(.is-dragging)'));
 let before=null;
 for (const card of cards) { const rect=...; if(clientY<rect.top+rect.height/2){before=card;break;}}
 const empty=list.querySelector('.empty-state');
 if (before) list.insertBefore(indicator,before);
 else if(empty) list.insertBefore(indicator,empty);
 else list.appendChild(indicator);
 indicator.classList.add('is-visible');
}
But if list has dragged source card and indicator, order could be indicator before source and then before computed includes source excluded. If no before, append after source. Fine.
If indicator currently before a card and then drag moves; only insert if indicator.nextElementSibling!==before etc. But if before is card and current next is card, no. If before null and last is indicator no. If empty before? always empty at first and indicator inserted before; subsequent no cards same.
Code:
if(before){
 if(indicator.parentNode!==list || indicator.nextElementSibling!==before) list.insertBefore(indicator,before);
} else if(empty){
 if(indicator.nextElementSibling!==empty) list.insertBefore(indicator,empty);
} else {
 if(indicator.parentNode!==list || indicator.lastElementChild!==indicator) list.appendChild(indicator);
}
But list.lastElementChild could task. good.
Indicator in DOM might affect `list.lastElementChild` when source card after? e.g order card1, card2, indicator, source? source likely one of cards. Excluding source, indicator may append after source, but then indicator.nextElementSibling null. no before -> append indicator if lastElementChild!==indicator; if source is last and indicator before it, appends after source. okay.

Drop beforeId:
let next = indicator.nextElementSibling;
while(next && !next.matches('.task-card')) next=next.nextElementSibling;
const beforeId=next?.dataset.cardId || null;
But if next is source card being dragged and indicator before it, beforeId source id; move function excludes id then beforeId not found -> end. okay.
More accurate target tasks visible list; indicator before next matching card.
Move function as above. Need not move to exact final if search.

moveTask:
function moveTask(id,toColumn,beforeId=null){
 const task=tasks.find...
 if(!task) return;
 const from=task.column;
 task.column=toColumn;
 const remaining=tasks.filter(t=>t.id!==id);
 const targetTasks=remaining.filter(t=>t.column===toColumn);
 let index=targetTasks.length;
 if(beforeId){const i=targetTasks.findIndex(...); if(i>=0) index=i;}
 targetTasks.splice(index,0,task);
 tasks=[];
 for(const col of columnOrder){
  const group=col===toColumn?targetTasks:remaining.filter(t=>t.column===col);
  tasks.push(...group);
 }
 render();
 if(from!==toColumn) showToast(...); else showToast('Task reordered');
}
Because task object mutated before remaining filter, task excluded anyway. remaining other tasks.
If beforeId references task in different col impossible.
Drop to target before the first matching card. Underlying nonmatching tasks before maybe inserted before first visible but after hidden, okay.
When moving to empty target, end.

New task tasks.unshift but grouping order not grouped; render per column unaffected. Subsequent move rebuild groups. Fine. Could use insertTask helper to maintain. Not needed but do:
function addTask... tasks.unshift(newTask). If select done, done new appears first even though global array new first. yes.

Toast messages quote title can be long; truncate CSS max-width 280 and overflow ellipsis. Use textContent.

Counts total.

Focus search cmdK if dialog not open. `e.key.toLowerCase()` works.
Search clear sets value='' searchTerm='' render focus search.
Search input event.

Theme transition and toast. `document.documentElement.dataset.theme` initially light set HTML.

Could respect system dark on first load? no.

Ensure no API storage. We use Date.now only.

Maybe include "last synced" not needed.

Let's think visual hierarchy and screenshot. Light theme:
background #f5f6fa; top white.
Workspace title.
Columns at y ~190 to bottom. White cards on #eff1f5, shadow.
3 columns. Rich.
Top search.
Looks professional.

Card counts 2. Each priority badge.

Dark theme nice.

One potential issue column min height using `.board {min-height:calc(100vh - 225px)}` with 2 cards content height: header ~52 + list (2*160+10 + padding19)=349 + footer50 =451. board min 575 at 800, columns stretch 575. Great.
Page: top70 + workspace padding25 + heading~74+23 =192; board575 =>767 + bottom38 =805, slightly vertical scroll at 800 due padding bottom. User says look right 1280x800; small vertical scroll maybe okay but ideally fits. Workspace: top25 + heading70 + margin23 + board min(800-225=575) + bottom 30 =723 plus topbar70=793. Wait workspace content height 25+70+23+575+30=723; +70=793. good. board min calc based viewport 575. Great.
Heading h1 line34 + eyebrow16 + subtitle20 + margins =74. yes.
Column card total maybe 55+ (2*154 +10+19)=392 + footer50=442 less575. fit.

At task card desc line clamp 2. Title one or two lines. "Build dashboard filters" one line 20; card total 20+11+20+6+38+12+1+11+26+padding30=175. Two cards 360, list389, column444. okay.

Maybe cards too tall, board still. Good.

We can set task description min-height 34. Fine.

At mobile board columns each content. .cards-list min-height 80. Search.

Let's write full code. Need be mindful code block only.

Before final, maybe verify syntax carefully.

JS:
const columnOrder = ["todo","progress","done"];
const columnLabels = {todo:"To do",progress:"In progress",done:"Done"};
const initialTasks = [...]
let tasks = initialTasks.map((task,index)=>({...task}));
let searchTerm = "";
let draggedId = null;
let nextTaskNumber = 7;
...
render();

SVG strings with double quotes inside template literal; okay.
escapeHtml function mapping quote: `'` key `"'": "&#39;"`.
Use in attributes.
renderCard:
`<article class="task-card" draggable="true" data-card-id="${task.id}" role="listitem">`
aria label maybe `aria-labelledby="title-${task.id}"` and h3 id. IDs safe.
`<h3 class="task-title" id="title-${task.id}">${escapeHtml(task.title)}</h3>`.
`title="${escapeHtml(task.assignee)}"` style variable color. color fixed.
Task id task-7.
Delete aria-label `Delete ${escapeHtml(task.title)}`.
priority label.

Description always. New description.

No inline onclick.

Render list role list; empty-state role? inside list not listitem; fine.

Modal radios:
FormData get. `form.elements.priority` potentially RadioNodeList; value property. Use `new FormData(form).get('priority')`.
columns select values.

`syncPriorityOptions()`: `form.querySelectorAll('.priority-option').forEach(option => { const input=option.querySelector('input'); option.classList.toggle('is-selected', input.checked); });`.
When form reset, selected checked attribute medium works.
`form.addEventListener("change", (event)=> { if(event.target.name === "priority") sync...});`.

open:
function openModal(column="todo") {
 if(dialog.open)return;
 lastFocused=document.activeElement;
 form.reset();
 titleInput.value="";
 titleInput.setCustomValidity("");
 columnSelect.value = columnLabels[column]?column:"todo";
 syncPriorityOptions();
 dialog.showModal();
 document.body.classList.add("modal-open");
 requestAnimationFrame(()=>titleInput.focus());
}
Calling form.reset resets select to todo, then set.
close:
function closeModal(){if(dialog.open)dialog.close();}
dialog close event body remove, lastFocused?.focus(); set last null.
dialog cancel event: e.preventDefault();closeModal();
Native cancel event fires on Esc; prevent and close triggers close event.
click backdrop:
dialog.addEventListener("click",(event)=>{ if(event.target===dialog){ const rect=...; const clickedInDialog=...; if(!clickedInDialog) closeModal();}});
Actually if event.target === dialog, coordinate could be inside the actual dialog's border/padding; padding0, shell fills, so outside only. simpler event.target===dialog close; clicking blank inside shell event target shell not dialog. fine.
`lastFocused instanceof HTMLElement` safe.
Form submit event: const title...; `titleInput.value = title;` set.
new task id `task-${nextTaskNumber++}`.
description etc.
tasks.unshift(newTask);
render();
closeModal();
showToast(`${columnLabels[column]} task added`);
Maybe toast message doesn't show title but fine.
Modal closes then lastFocused might be card add button which still exists; if top add remains. okay.

Delete:
const button = event.target.closest("[data-delete]");
SVG path event.target supports closest. yes.
title stored before removal.
tasks = tasks.filter.
render.
showToast(`Deleted “${task.title}”`); long ellipsis.
No confirmation.

Drag:
board.addEventListener("dragstart",(event)=>{
 const card=event.target.closest(".task-card");
 if(!card)return;
 draggedId=card.dataset.cardId;
 event.dataTransfer.effectAllowed="move";
 event.dataTransfer.setData("text/plain",draggedId);
 card.classList.add("is-dragging");
});
Adding class immediately might make drag image semi transparent? Drag image snapshot is generated before event handler maybe, so default fine. Source opacity.
Maybe dataTransfer.setDragImage custom not.
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.is-over").forEach(c=>{if(c!==column)c.classList.remove...});
 column.classList.add("is-over");
 positionIndicator(column,event.clientY);
});
Potential e.target.closest on Text node? Drag event target always Element. fine.
board dragleave:
const column=event.target.closest(".column");
if(column && !column.contains(event.relatedTarget)){column.classList.remove("is-over");}
event.relatedTarget can null; column.contains(null) returns false (Node required? Node.contains(null) returns false, yes).
When moving from column to another, dragleave relatedTarget may be element in other column, old doesn't contain -> remove.
drop:
const column = event.target.closest(".column");
if(!column || !draggedId)return;
event.preventDefault();
const id=event.dataTransfer?.getData("text/plain")||draggedId;
const nextCard=indicator.nextElementSibling?.matches(".task-card") ? indicator.nextElementSibling : null; But next sibling could empty? For indicator inserted before a card, next card yes. If appended, next null.
beforeId=nextCard?.dataset.cardId||null.
const from=...; moveTask; cleanup.
Calling moveTask render removes indicator before nextCard read? read before.
cleanup after.
document dragend cleanup.
cleanup:
if(draggedId){document.querySelector(`[data-card-id="${draggedId}"]`)?.classList.remove("is-dragging");}
draggedId=null;
indicator.classList.remove...; indicator.remove();
board.querySelectorAll(".column.is-over").forEach remove.
Potential CSS.escape not needed IDs simple.
`document.querySelector` with id safe task-7.
If drag canceled and render happened, no.
`dragend` might not fire if drop render removes dragged element before dragend? We cleanup in drop anyway. document dragend later draggedId null.

Position indicator:
const list = column.querySelector(".cards-list");
const candidates=Array.from(list.querySelectorAll(".task-card")).filter(card=>!card.classList.contains("is-dragging"));
let before=null;
for...
const empty=list.querySelector(".empty-state");
if(before){...} else if(empty){...} else {...}
indicator.classList.add.
Important indicator currently inserted into list, and `.empty-state` may be before indicator? We insert before empty.
Candidate rect changes due indicator inserted causing cards shift. Algorithm on each event uses current rect, which includes indicator space, can oscillate. Example list card1, card2. cursor between; insert indicator before card2 -> shifts card2 down. Next dragover cursor now over indicator, candidates card2 rect below, before maybe card2, stays. Good. If cursor between card1/card2 but indicator occupies space, threshold shifts; may remain. Clear enough.
Source dragged card also occupies space, can affect. okay.

To avoid jitter and shifting, set indicator height 54. fine.

Drop on list e.target indicator; closest column works.

Drag event `event.dataTransfer.dropEffect` may throw? no.

Search render while no drag.

Theme:
const root=document.documentElement;
themeToggle click toggles. `root.dataset.theme` initially "light".
Maybe local state theme only memory. Good.
Update `aria-pressed`? A toggle button representing state: set aria-pressed next==='dark', aria-label "Dark theme". Could use label `Toggle dark theme`, aria-pressed. Use `aria-label="Switch to dark theme"` and `aria-pressed="${theme==='dark'}"`. Semantics toggle with pressed true dark. Good. On click update.
CSS icon based theme.

Toast check icon always. showToast timer variable.
`toastText.textContent=message`.
class show. Remove after 2400.

Summary update. Team copy strong id taskSummary.

Auto render once.

Potential user types title with quotes and newline. input single line. escape.
Title max length 90.

Realistic initial tasks exactly 6. count totals.

Maybe include 2 tasks to do etc. yes.

We need not include a game start message; not a game.

One thought: user asked "interactive Kanban board in vanilla HTML, CSS and JavaScript (no framework, no library)." We deliver.

No storage APIs anywhere. Good.

Let's refine modal labels. "Task title" required with red asterisk. Priority options. "Add to" select.
Modal small (440). okay.

"Delete a card with a button on it" trash button visible. good.

"Drag and drop cards between columns with mouse, with clear visual drop indicator." yes.

"search field filters cards live by title." yes.

"light/dark toggle." yes.

Could add drag instructions in board to clarify: page heading subtitle "Drag tasks between columns to keep work moving." Better than generic. Use subtitle "Drag tasks between columns to keep work moving." That signals. Subtitle maybe "Plan, prioritize, and ship work as a team." Both? One line "Drag cards between columns to keep work moving." Good. First screenshot shows.

Column kickers. Fine.

Let's ensure icons do not create external. all inline.

Let's now prepare final code with all. Need exactly one code block. We can include comments maybe helpful but no need excessive. We have token budget. Ensure final within maybe 15k tokens. Desired oververbosity irrelevant code.

Let's write CSS carefully.

Dark mode variable for avatar colors remain good.
--input-bg:#f4f5f9; dark #121620.
--count-bg:#fff dark #232936.
--column:#edf0f5 dark #171b24.
--surface-soft:#f8f9fc dark #202531.
--empty etc.

Body background radial. topbar variable.

Task badge dark:
.priority-badge.p-high {color:#ff8e9b;background:rgba(225,76,91,.13);}
p-medium color #f2ba59; p-low #68d0a8.
light.

Column count uses color text.

Search field input CSS `outline:none` but focus ring parent.

Focus visible.

Column card shadows.

Toast uses #222532 always, works both.

Modal option colors dark.

Responsive topbar:
@media (max-width: 900px) {
 .search-wrap{width:240px;}
 .brand-workspace, .brand-divider{display:none;} maybe keep workspace at 960? fine.
 .board grid one? At 900 yes.
}
@media (max-width: 640px) {
 .topbar-inner{height:auto;min-height:64px;padding:12px 16px;flex-wrap:wrap;}
 .topbar-actions{width:100%;margin-left:0;order:2;}
 .brand{order:1}
 .topbar-actions? brand and actions same row but actions width 100 forces next. Good.
 .search-wrap{width:auto;flex:1;}
 .shortcut display none;
 .workspace padding...
 .page-heading...
 .board...
}
At mobile first row brand, second actions. Topbar height 112, board lower but responsive page scroll.
At &lt;=420 hide add label, icon button. .primary-button width 40.
At &lt;=960 board one column means 375 okay.
At 768 topbar desktop-ish? media max640 only, so at 768 search width 240 + brand + actions fit ~650. board one col. Good.

Maybe at 960 .search-wrap width220.

.task-footer gap8; due margin-left:auto.
At very narrow card width ~291 minus 28=263, priority 58 + due65 + avatar25 + gaps16=164 fit.
No overflow.

Column min-height at desktop. .cards-list min-height 175. Empty state flex1 min-height160.

In drag .task-card.is-dragging {opacity:.32;transform:scale(.985);box-shadow:none;}. Native drag.
Indicator CSS `border:1.5px dashed`; clear.

Column::before top strip absolute top0 left0 right0 height3; border radius 16 16 0 0. background var accent. At dark.

Card top controls delete button always opacity .55. On card hover .delete opacity1.
SVG grip uses fill currentColor no stroke. HTML icon string maybe circles with fill.

Task category uppercase? font 10, letter .04.

Task title font 14.5. good.

Could card description add "No descriptions" no.

HTML date labels.

Let's write final.