From 10ea2a014f52959a1270f73fdf162a7f0b4d2d5d Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Sat, 9 May 2026 18:40:48 +0000 Subject: [PATCH] fix(kanban): header '+' button opens create-task modal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Kanban sidebar panel's header '+' button (#kanbanNewTaskBtn) was wired straight to createKanbanTask(), which reads the inline #kanbanNewTaskTitle input and silently returns when empty. The inline input lives below five rows of filters (search, assignee, tenant, archived/mine toggles, stats, bulk-action bar) and is typically off-screen on first panel open, so the header button looked dead — clicking it with no title typed did nothing visible (no modal, no scroll, no focus shift, no toast). Now the header '+' opens #kanbanTaskModal — a centered overlay with the same .kanban-modal-overlay shell the existing create-board modal uses, so the two flows look and behave identically (centered card, dim backdrop, ESC closes, click-on-backdrop closes). The modal exposes the fields the backend already accepts at /api/kanban/tasks: Title, Description, Status (Triage/Todo/Ready), Priority, Assignee (datalist suggestions from the active board), Tenant (datalist). UX details: - Title is required; submit-with-empty shows a properly styled red error - Title field auto-focuses on open - ESC closes the modal; backdrop click closes; Enter on simple inputs submits, Enter in the description textarea inserts a newline - Submit POSTs only the fields the user filled in (no forced empty strings) and auto-opens the new task's detail view - Submit button disables while posting to prevent double-submit - Inline quick-add (Enter on #kanbanNewTaskTitle) is preserved as a power-user shortcut Side effect: .kanban-modal-error styling improved (proper red alert with border + tinted background) so the existing create-board modal benefits from the same polish for free. i18n: 11 new keys added across all 8 supported locales (en, ja, ru, es, de, zh, pt, ko). Tests: tests/test_kanban_ui_static.py::test_kanban_new_task_header_button_opens_modal covers the modal markup, button wiring, ESC/Enter handling, datalist population, submit behavior, and inline-quick-add fallthrough. Verified end-to-end in the browser on an isolated test env (port 8789): created a board from scratch, opened the modal via header '+', submitted with title/description/status/priority/assignee/tenant filled in, moved the task through statuses (Triage → Todo → Ready → Blocked → Archived), added a comment, verified Cancel + ESC + backdrop-click all close cleanly, verified validation error rendering, verified inline quick-add still works. Closes #1964 --- CHANGELOG.md | 6 ++ static/i18n.js | 88 +++++++++++++++++++++++ static/index.html | 45 +++++++++++- static/panels.js | 128 ++++++++++++++++++++++++++++++++- static/style.css | 10 ++- tests/test_kanban_ui_static.py | 102 ++++++++++++++++++++++++++ 6 files changed, 376 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index eeaa0e04..5ecc1a13 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Hermes Web UI -- Changelog +## [Unreleased] + +### Fixed + +- **bug(kanban): Kanban panel header `+` button looked dead — now opens a proper create-task modal** ([#1964](https://github.com/nesquena/hermes-webui/issues/1964)). The "New task" button at the top of the Kanban sidebar panel was wired to `createKanbanTask()`, which silently `return`s when the inline `#kanbanNewTaskTitle` input is empty. Because that input lives below five rows of filters (search, assignee, tenant, archived/mine toggles, stats, bulk-action bar), it's typically off-screen on first open — clicking the obvious header `+` did nothing visible. Now the header `+` opens a centered modal overlay (`#kanbanTaskModal`, same `.kanban-modal-overlay` shell as the existing create-board modal) with fields for Title (required), Description, Status (Triage / Todo / Ready), Priority, Assignee (with datalist suggestions from the active board), and Tenant (with datalist). ESC closes, click on backdrop closes, Enter on simple inputs submits (Enter in the description textarea inserts a newline, as expected). Submit hits `/api/kanban/tasks` POST with whichever fields the user populated and auto-opens the new task's detail view. Inline quick-add (`Enter` on `#kanbanNewTaskTitle`) is preserved as a power-user shortcut. Adds 11 new i18n keys across all 8 supported locales (`kanban_title`, `kanban_description`, `kanban_description_placeholder`, `kanban_status`, `kanban_assignee`, `kanban_assignee_placeholder`, `kanban_tenant`, `kanban_tenant_placeholder`, `kanban_priority`, `kanban_priority_hint`, `kanban_title_required`). Improves `.kanban-modal-error` styling so validation errors render as a properly-boxed red alert instead of unstyled body text — benefits the existing create-board modal too. Adds a regression test (`tests/test_kanban_ui_static.py::test_kanban_new_task_header_button_opens_modal`). + ## [v0.51.30] — 2026-05-08 — 3-PR contributor batch (Release G: offline recovery + PWA hardening + opt-in session jump buttons + opt-in endless-scroll) ### Added (3 PRs, all from @ai-ag2026) diff --git a/static/i18n.js b/static/i18n.js index 8b86a209..54a821da 100644 --- a/static/i18n.js +++ b/static/i18n.js @@ -530,6 +530,17 @@ const LOCALES = { kanban_no_comments: 'No comments', kanban_no_events: 'No events', kanban_no_runs: 'No runs', + kanban_title: 'Title', + kanban_description: 'Description', + kanban_description_placeholder: 'Optional — what needs to happen, acceptance criteria, links', + kanban_status: 'Status', + kanban_assignee: 'Assignee', + kanban_assignee_placeholder: 'Optional — leave blank for any worker', + kanban_tenant: 'Tenant', + kanban_tenant_placeholder: 'Optional — project or team slug', + kanban_priority: 'Priority', + kanban_priority_hint: 'Higher numbers run first. Default 0.', + kanban_title_required: 'Title is required.', kanban_new_task: 'New task', kanban_add_comment: 'Add comment', kanban_only_mine: 'Only mine', @@ -1568,6 +1579,17 @@ const LOCALES = { kanban_no_comments: 'コメントなし', kanban_no_events: 'イベントなし', kanban_no_runs: '実行なし', + kanban_title: 'タイトル', + kanban_description: '説明', + kanban_description_placeholder: '任意 — 何をすべきか、受け入れ基準、リンク', + kanban_status: 'ステータス', + kanban_assignee: '担当者', + kanban_assignee_placeholder: '任意 — 空欄で任意のワーカーに', + kanban_tenant: 'テナント', + kanban_tenant_placeholder: '任意 — プロジェクトまたはチームのスラッグ', + kanban_priority: '優先度', + kanban_priority_hint: '値が大きいほど優先されます。既定値は 0。', + kanban_title_required: 'タイトルは必須です。', kanban_new_task: '新規タスク', kanban_add_comment: 'コメント追加', kanban_only_mine: '自分のみ', @@ -2439,6 +2461,17 @@ const LOCALES = { kanban_no_comments: 'No comments', kanban_no_events: 'No events', kanban_no_runs: 'No runs', + kanban_title: 'Заголовок', + kanban_description: 'Описание', + kanban_description_placeholder: 'Необязательно — что нужно сделать, критерии приёмки, ссылки', + kanban_status: 'Статус', + kanban_assignee: 'Исполнитель', + kanban_assignee_placeholder: 'Необязательно — оставьте пустым для любого исполнителя', + kanban_tenant: 'Арендатор', + kanban_tenant_placeholder: 'Необязательно — слаг проекта или команды', + kanban_priority: 'Приоритет', + kanban_priority_hint: 'Большие числа выполняются первыми. По умолчанию 0.', + kanban_title_required: 'Заголовок обязателен.', kanban_new_task: 'New task', kanban_add_comment: 'Add comment', kanban_only_mine: 'Only mine', @@ -3411,6 +3444,17 @@ const LOCALES = { kanban_no_comments: 'No comments', kanban_no_events: 'No events', kanban_no_runs: 'No runs', + kanban_title: 'Título', + kanban_description: 'Descripción', + kanban_description_placeholder: 'Opcional — qué hay que hacer, criterios de aceptación, enlaces', + kanban_status: 'Estado', + kanban_assignee: 'Responsable', + kanban_assignee_placeholder: 'Opcional — déjalo en blanco para cualquier trabajador', + kanban_tenant: 'Tenant', + kanban_tenant_placeholder: 'Opcional — slug del proyecto o equipo', + kanban_priority: 'Prioridad', + kanban_priority_hint: 'Los números más altos se ejecutan primero. Predeterminado: 0.', + kanban_title_required: 'El título es obligatorio.', kanban_new_task: 'New task', kanban_add_comment: 'Add comment', kanban_only_mine: 'Only mine', @@ -4371,6 +4415,17 @@ const LOCALES = { kanban_no_comments: 'No comments', kanban_no_events: 'No events', kanban_no_runs: 'No runs', + kanban_title: 'Titel', + kanban_description: 'Beschreibung', + kanban_description_placeholder: 'Optional — was zu tun ist, Akzeptanzkriterien, Links', + kanban_status: 'Status', + kanban_assignee: 'Zugewiesen an', + kanban_assignee_placeholder: 'Optional — leer lassen für beliebigen Worker', + kanban_tenant: 'Mandant', + kanban_tenant_placeholder: 'Optional — Projekt- oder Team-Slug', + kanban_priority: 'Priorität', + kanban_priority_hint: 'Höhere Zahlen laufen zuerst. Standard: 0.', + kanban_title_required: 'Titel ist erforderlich.', kanban_new_task: 'New task', kanban_add_comment: 'Add comment', kanban_only_mine: 'Only mine', @@ -5352,6 +5407,17 @@ const LOCALES = { kanban_no_comments: 'No comments', kanban_no_events: 'No events', kanban_no_runs: 'No runs', + kanban_title: '标题', + kanban_description: '描述', + kanban_description_placeholder: '可选 — 需要做什么、验收标准、链接', + kanban_status: '状态', + kanban_assignee: '负责人', + kanban_assignee_placeholder: '可选 — 留空表示任意工作器', + kanban_tenant: '租户', + kanban_tenant_placeholder: '可选 — 项目或团队标识', + kanban_priority: '优先级', + kanban_priority_hint: '数值越高越先执行,默认为 0。', + kanban_title_required: '标题为必填项。', kanban_new_task: 'New task', kanban_add_comment: 'Add comment', kanban_only_mine: 'Only mine', @@ -7387,6 +7453,17 @@ const LOCALES = { kanban_no_comments: 'No comments', kanban_no_events: 'No events', kanban_no_runs: 'No runs', + kanban_title: 'Título', + kanban_description: 'Descrição', + kanban_description_placeholder: 'Opcional — o que precisa ser feito, critérios de aceitação, links', + kanban_status: 'Status', + kanban_assignee: 'Responsável', + kanban_assignee_placeholder: 'Opcional — deixe em branco para qualquer worker', + kanban_tenant: 'Tenant', + kanban_tenant_placeholder: 'Opcional — slug do projeto ou equipe', + kanban_priority: 'Prioridade', + kanban_priority_hint: 'Números maiores executam primeiro. Padrão: 0.', + kanban_title_required: 'O título é obrigatório.', kanban_new_task: 'New task', kanban_add_comment: 'Add comment', kanban_only_mine: 'Only mine', @@ -8323,6 +8400,17 @@ const LOCALES = { kanban_no_comments: 'No comments', kanban_no_events: 'No events', kanban_no_runs: 'No runs', + kanban_title: '제목', + kanban_description: '설명', + kanban_description_placeholder: '선택 — 해야 할 일, 수락 기준, 링크', + kanban_status: '상태', + kanban_assignee: '담당자', + kanban_assignee_placeholder: '선택 — 비워두면 누구나 가능', + kanban_tenant: '테넌트', + kanban_tenant_placeholder: '선택 — 프로젝트 또는 팀 슬러그', + kanban_priority: '우선순위', + kanban_priority_hint: '높은 숫자가 먼저 실행됩니다. 기본값: 0.', + kanban_title_required: '제목은 필수입니다.', kanban_new_task: 'New task', kanban_add_comment: 'Add comment', kanban_only_mine: 'Only mine', diff --git a/static/index.html b/static/index.html index 319901e1..fdd014d2 100644 --- a/static/index.html +++ b/static/index.html @@ -146,7 +146,7 @@
Kanban
- +
@@ -1262,5 +1262,48 @@ + + diff --git a/static/panels.js b/static/panels.js index fd10c820..8b9dfcc2 100644 --- a/static/panels.js +++ b/static/panels.js @@ -1566,7 +1566,14 @@ function _kanbanLinksHtml(links){ async function createKanbanTask(){ const input = document.getElementById('kanbanNewTaskTitle'); const title = input ? input.value.trim() : ''; - if (!title) return; + if (!title) { + // Empty inline input (or a click on the panel-head "+" via openKanbanCreate) + // — open the full create-task modal so the user has somewhere obvious to + // type and configure the task. Mirrors the cron / skills pattern of routing + // header "+" clicks through to a clearly-modal create surface. + openKanbanCreate(); + return; + } try { const created = await api('/api/kanban/tasks' + _kanbanBoardQuery(), { method: 'POST', @@ -1578,6 +1585,125 @@ async function createKanbanTask(){ } catch(e) { showToast(t('kanban_unavailable') + ': ' + (e.message || e), 'error'); } } +// ──────────────────────────────────────────────────────────────────────────── +// Kanban: create-task modal (panel-head "+" button entry point). +// +// Same `.kanban-modal-overlay` shell as openKanbanCreateBoard() so the two +// flows look and behave identically (centered card, dim backdrop, ESC closes, +// click-on-backdrop closes). The modal markup lives in static/index.html as +// #kanbanTaskModal — see the section just above . Submit hits the +// existing /api/kanban/tasks POST endpoint (which already accepts title, body, +// assignee, tenant, priority, status — see api/kanban_bridge.py:306). +// ──────────────────────────────────────────────────────────────────────────── + +function openKanbanCreate(){ + // Make sure the user is on the kanban panel so the resulting board reload is + // visible behind the modal. Without this the modal would still work but the + // user could lose context on which panel they triggered it from. + if (typeof switchPanel === 'function' && _currentPanel !== 'kanban') switchPanel('kanban'); + const modal = document.getElementById('kanbanTaskModal'); + if (!modal) return; + // Reset all form fields to defaults. + const titleEl = document.getElementById('kanbanTaskModalTitleInput'); + const bodyEl = document.getElementById('kanbanTaskModalBody'); + const statusEl = document.getElementById('kanbanTaskModalStatus'); + const assigneeEl = document.getElementById('kanbanTaskModalAssignee'); + const tenantEl = document.getElementById('kanbanTaskModalTenant'); + const priorityEl = document.getElementById('kanbanTaskModalPriority'); + const errEl = document.getElementById('kanbanTaskModalError'); + const submitBtn = document.getElementById('kanbanTaskModalSubmit'); + if (titleEl) titleEl.value = ''; + if (bodyEl) bodyEl.value = ''; + if (statusEl) statusEl.value = 'triage'; + if (assigneeEl) assigneeEl.value = ''; + if (tenantEl) tenantEl.value = ''; + if (priorityEl) priorityEl.value = '0'; + if (errEl) errEl.textContent = ''; + if (submitBtn) submitBtn.disabled = false; + // Populate datalists from the currently-loaded board so the user sees the + // assignees / tenants the dispatcher already knows about. + const assignees = (_kanbanBoard && Array.isArray(_kanbanBoard.assignees)) ? _kanbanBoard.assignees : []; + const tenants = (_kanbanBoard && Array.isArray(_kanbanBoard.tenants)) ? _kanbanBoard.tenants : []; + const aList = document.getElementById('kanbanTaskModalAssigneeList'); + const tList = document.getElementById('kanbanTaskModalTenantList'); + if (aList) aList.innerHTML = assignees.map(v => ``).join(''); + if (tList) tList.innerHTML = tenants.map(v => ``).join(''); + modal.hidden = false; + // Auto-focus title field on open. setTimeout to wait for paint. + setTimeout(() => { if (titleEl) titleEl.focus(); }, 50); + // Bind ESC to close, and Enter on simple inputs to submit. + document.addEventListener('keydown', _kanbanTaskModalKey); +} + +function closeKanbanTaskModal(){ + const modal = document.getElementById('kanbanTaskModal'); + if (modal) modal.hidden = true; + document.removeEventListener('keydown', _kanbanTaskModalKey); +} + +function _kanbanTaskModalKey(ev){ + if (ev.key === 'Escape') { + ev.preventDefault(); + closeKanbanTaskModal(); + return; + } + if (ev.key === 'Enter' && !ev.shiftKey) { + // Enter submits except when the focus is in the description textarea + // (where Enter should insert a newline). + const target = ev.target; + if (target && target.tagName === 'TEXTAREA') return; + const modal = document.getElementById('kanbanTaskModal'); + if (modal && !modal.hidden) { + ev.preventDefault(); + submitKanbanTaskModal(); + } + } +} + +async function submitKanbanTaskModal(){ + const titleEl = document.getElementById('kanbanTaskModalTitleInput'); + const bodyEl = document.getElementById('kanbanTaskModalBody'); + const statusEl = document.getElementById('kanbanTaskModalStatus'); + const assigneeEl = document.getElementById('kanbanTaskModalAssignee'); + const tenantEl = document.getElementById('kanbanTaskModalTenant'); + const priorityEl = document.getElementById('kanbanTaskModalPriority'); + const errEl = document.getElementById('kanbanTaskModalError'); + const submitBtn = document.getElementById('kanbanTaskModalSubmit'); + const title = titleEl ? titleEl.value.trim() : ''; + if (!title) { + if (errEl) errEl.textContent = t('kanban_title_required') || 'Title is required.'; + if (titleEl) titleEl.focus(); + return; + } + // Build payload — only include fields the user actually filled in so the + // backend can apply its own defaults rather than us forcing empty strings. + const payload = {title}; + if (bodyEl && bodyEl.value.trim()) payload.body = bodyEl.value; + if (statusEl && statusEl.value) payload.status = statusEl.value; + if (assigneeEl && assigneeEl.value.trim()) payload.assignee = assigneeEl.value.trim(); + if (tenantEl && tenantEl.value.trim()) payload.tenant = tenantEl.value.trim(); + if (priorityEl && priorityEl.value !== '' && priorityEl.value !== '0') { + const n = parseInt(priorityEl.value, 10); + if (!Number.isNaN(n)) payload.priority = n; + } + if (submitBtn) submitBtn.disabled = true; + if (errEl) errEl.textContent = ''; + try { + const created = await api('/api/kanban/tasks' + _kanbanBoardQuery(), { + method: 'POST', + body: JSON.stringify(payload), + }); + closeKanbanTaskModal(); + await loadKanban(true); + if (created && created.task && created.task.id) { + await loadKanbanTask(created.task.id); + } + } catch(e) { + if (errEl) errEl.textContent = (e.message || String(e)); + if (submitBtn) submitBtn.disabled = false; + } +} + async function updateKanbanTask(taskId, patch){ if (!taskId || !patch) return; try { diff --git a/static/style.css b/static/style.css index d5f4a2f4..09d08459 100644 --- a/static/style.css +++ b/static/style.css @@ -3499,7 +3499,15 @@ main.main.showing-insights > #mainInsights{display:flex;overflow-y:auto;} .kanban-modal-row-inline{display:flex;gap:10px;} .kanban-modal-row-inline > *{flex:1;min-width:0;} .kanban-modal-actions{display:flex;justify-content:flex-end;gap:8px;margin-top:14px;} -.kanban-modal-error{color:var(--danger);font-size:11px;margin-top:6px;min-height:14px;} +.kanban-modal-error{ + color:var(--danger,var(--error,#f87171)); + font-size:12px;font-weight:500;margin-top:8px;min-height:14px; +} +.kanban-modal-error:not(:empty){ + padding:8px 10px;border-radius:8px; + background:color-mix(in srgb,var(--danger,var(--error,#f87171)) 12%,transparent); + border:1px solid color-mix(in srgb,var(--danger,var(--error,#f87171)) 35%,transparent); +} .kanban-empty{padding:12px;color:var(--muted);font-size:12px;text-align:center;border:1px dashed var(--border);border-radius:8px;} .kanban-new-task-row{display:flex;gap:6px;align-items:center;} diff --git a/tests/test_kanban_ui_static.py b/tests/test_kanban_ui_static.py index 359aca22..8afb09d4 100644 --- a/tests/test_kanban_ui_static.py +++ b/tests/test_kanban_ui_static.py @@ -80,6 +80,108 @@ def test_kanban_write_mvp_has_native_controls_and_api_calls(): assert "kanban-comment-form" in PANELS +def test_kanban_new_task_header_button_opens_modal(): + """Regression: the panel-head '+' button must open a real `.kanban-modal-overlay` + create-task modal (matching the existing create-board modal pattern in the same + file) — NOT silently return when the inline #kanbanNewTaskTitle input is empty. + + Previously the header button was wired straight to createKanbanTask(), which + silently early-exits on empty title — the button looked completely dead. + Now the header button calls openKanbanCreate(), which opens the + #kanbanTaskModal overlay with title / description / status / priority / + assignee / tenant fields. + """ + # 1. Header "+" button is wired to openKanbanCreate(), NOT createKanbanTask(). + assert 'id="kanbanNewTaskBtn"' in INDEX + btn_html = INDEX[INDEX.find('id="kanbanNewTaskBtn"'):] + btn_html = btn_html[: btn_html.find("") + len("")] + assert 'onclick="openKanbanCreate()"' in btn_html, ( + "Panel-head '+' button must call openKanbanCreate() (modal), not " + "createKanbanTask() directly (which silently returns on empty title)." + ) + + # 2. The create-task modal markup exists in index.html, with all the field + # ids the JS / API contract expects. + assert 'id="kanbanTaskModal"' in INDEX + assert 'class="kanban-modal-overlay"' in INDEX[INDEX.find('id="kanbanTaskModal"') - 80:] + for field_id in ( + "kanbanTaskModalTitleInput", + "kanbanTaskModalBody", + "kanbanTaskModalStatus", + "kanbanTaskModalPriority", + "kanbanTaskModalAssignee", + "kanbanTaskModalTenant", + "kanbanTaskModalError", + "kanbanTaskModalSubmit", + ): + assert f'id="{field_id}"' in INDEX, f"create-task modal missing #{field_id}" + + # 3. Modal closes via Cancel button AND backdrop click AND ESC. + assert 'onclick="closeKanbanTaskModal()"' in INDEX + assert "if(event.target===this)closeKanbanTaskModal()" in INDEX + + # 4. openKanbanCreate() unhides the modal, focuses the title field, populates + # assignee/tenant datalists, binds keydown listener. + assert "function openKanbanCreate()" in PANELS + open_fn = re.search( + r"function openKanbanCreate\(\)\{(.*?)\n\}", PANELS, re.DOTALL + ) + assert open_fn, "openKanbanCreate() not found" + body = open_fn.group(1) + assert "modal.hidden = false" in body + assert "kanbanTaskModalAssigneeList" in body + assert "kanbanTaskModalTenantList" in body + assert "_kanbanTaskModalKey" in body # ESC + Enter handler attached + + # 5. closeKanbanTaskModal() hides the modal and unbinds the listener. + assert "function closeKanbanTaskModal()" in PANELS + close_fn = re.search( + r"function closeKanbanTaskModal\(\)\{(.*?)\n\}", PANELS, re.DOTALL + ) + assert close_fn and "modal.hidden = true" in close_fn.group(1) + assert "removeEventListener('keydown', _kanbanTaskModalKey)" in close_fn.group(1) + + # 6. ESC closes; Enter submits (except in the description textarea). + assert "function _kanbanTaskModalKey" in PANELS + key_fn = re.search( + r"function _kanbanTaskModalKey\([^)]*\)\{(.*?)\n\}", PANELS, re.DOTALL + ) + assert key_fn + key_body = key_fn.group(1) + assert "ev.key === 'Escape'" in key_body + assert "ev.key === 'Enter'" in key_body + assert "TEXTAREA" in key_body # textarea exception preserved + + # 7. submitKanbanTaskModal() POSTs to /api/kanban/tasks, closes modal, + # reloads board, opens detail. + assert "async function submitKanbanTaskModal()" in PANELS + submit_fn = re.search( + r"async function submitKanbanTaskModal\(\)\{(.*?)\n\}", PANELS, re.DOTALL + ) + assert submit_fn, "submitKanbanTaskModal() not found" + submit_body = submit_fn.group(1) + assert "api('/api/kanban/tasks'" in submit_body + assert "method: 'POST'" in submit_body + assert "JSON.stringify(payload)" in submit_body + assert "closeKanbanTaskModal()" in submit_body + assert "loadKanban(true)" in submit_body + assert "loadKanbanTask" in submit_body + + # 8. Inline quick-add still works for power-users — typing a title + Enter + # creates immediately. Empty submit falls through to the modal. + assert "async function createKanbanTask()" in PANELS + quick_add = re.search( + r"async function createKanbanTask\(\)\{(.*?)\n\}", PANELS, re.DOTALL + ) + assert quick_add + qa_body = quick_add.group(1) + assert "openKanbanCreate()" in qa_body, ( + "Empty inline-input submit must open the modal, not silently return." + ) + assert "api('/api/kanban/tasks'" in qa_body + + + def test_kanban_board_has_native_css_classes(): for selector in ( ".kanban-board",