feat(kanban): dashboard batch QOL upgrade

- Shift-click range selection, column select-all, select-all-visible
- Multi-card drag/drop via selectedIds + /tasks/bulk
- Expanded bulk actions: todo/ready/blocked/unblock/complete/archive,
  priority setter, reassign with reclaim_first checkbox
- Partial failure card highlight (failedIds + hermes-kanban-card--failed)
- Search expanded to body, result, latest_summary, summary
- Clear filters button + reset all filters on board switch
- Accessibility: larger checkbox hit target, tabIndex/role/aria-label,
  Enter/Space/Esc keyboard handlers
- Fix temporal-dead-zone bug: move clearSelected before moveSelected
This commit is contained in:
Yi Lok Enoch Lam 2026-05-10 12:28:43 +02:00 committed by Teknium
parent 518d37f6af
commit 0ea234e093
3 changed files with 353 additions and 164 deletions

View file

@ -439,6 +439,8 @@
const [selectedTaskId, setSelectedTaskId] = useState(null);
const [selectedIds, setSelectedIds] = useState(() => new Set());
const [lastSelectedId, setLastSelectedId] = useState(null);
const [failedIds, setFailedIds] = useState(() => new Set());
// Per-task event counter incremented whenever the WS stream reports
// a new event for that task id. TaskDrawer useEffect-depends on its
// own task's counter so it reloads itself on live events instead of
@ -589,7 +591,7 @@
if (tenantFilter && t.tenant !== tenantFilter) return false;
if (assigneeFilter && t.assignee !== assigneeFilter) return false;
if (q) {
const hay = `${t.id} ${t.title || ""} ${t.assignee || ""} ${t.tenant || ""}`.toLowerCase();
const hay = `${t.id} ${t.title || ""} ${t.body || ""} ${t.result || ""} ${t.latest_summary || ""} ${t.summary || ""} ${t.assignee || ""} ${t.tenant || ""}`.toLowerCase();
if (hay.indexOf(q) === -1) return false;
}
return true;
@ -633,6 +635,55 @@
});
}, [loadBoard, board, t]);
const clearSelected = useCallback(function () {
setSelectedIds(new Set());
setLastSelectedId(null);
setFailedIds(new Set());
}, []);
const moveSelected = useCallback(function (newStatus) {
const confirmMsg = DESTRUCTIVE_TRANSITIONS[newStatus];
if (confirmMsg && !window.confirm(confirmMsg)) return;
if (selectedIds.size === 0) return;
const patch = withCompletionSummary({ status: newStatus }, selectedIds.size);
if (!patch) return;
const ids = Array.from(selectedIds);
// Optimistic UI: remove selected from all columns and prepend to target.
setBoardData(function (b) {
if (!b) return b;
const moved = [];
const columns = b.columns.map(function (col) {
const kept = [];
for (const t of col.tasks) {
if (selectedIds.has(t.id)) moved.push(Object.assign({}, t, { status: newStatus }));
else kept.push(t);
}
return Object.assign({}, col, { tasks: kept });
});
const dest = columns.find(function (c) { return c.name === newStatus; });
if (dest) dest.tasks = moved.concat(dest.tasks);
return Object.assign({}, b, { columns });
});
SDK.fetchJSON(withBoard(`${API}/tasks/bulk`, board), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(Object.assign({ ids }, patch)),
}).then(function (res) {
const failed = (res.results || []).filter(function (r) { return !r.ok; });
if (failed.length > 0) {
setError(`Bulk move: ${failed.length} of ${res.results.length} failed`);
setFailedIds(new Set(failed.map(function (f) { return f.id; })));
} else {
setFailedIds(new Set());
}
clearSelected();
loadBoard();
}).catch(function (err) {
setError(`Move failed: ${err.message || err}`);
setFailedIds(new Set(selectedIds));
loadBoard();
});
}, [selectedIds, loadBoard, clearSelected, board]);
const createTask = useCallback(function (body) {
return SDK.fetchJSON(withBoard(`${API}/tasks`, board), {
method: "POST",
@ -659,8 +710,66 @@
else next.add(id);
return next;
});
setLastSelectedId(id);
setFailedIds(function (prev) {
if (prev.has(id)) {
const next = new Set(prev);
next.delete(id);
return next;
}
return prev;
});
}, []);
const clearSelected = useCallback(function () { setSelectedIds(new Set()); }, []);
const toggleRange = useCallback(function (toId) {
// Build flat visible task order from filteredBoard columns.
setSelectedIds(function (prev) {
const next = new Set(prev);
if (!filteredBoard || !filteredBoard.columns) return next;
const order = [];
for (const col of filteredBoard.columns) {
for (const t of col.tasks || []) order.push(t.id);
}
const anchor = lastSelectedId;
if (!anchor || anchor === toId) {
next.has(toId) ? next.delete(toId) : next.add(toId);
return next;
}
const aIdx = order.indexOf(anchor);
const bIdx = order.indexOf(toId);
if (aIdx === -1 || bIdx === -1) {
next.has(toId) ? next.delete(toId) : next.add(toId);
return next;
}
const lo = Math.min(aIdx, bIdx);
const hi = Math.max(aIdx, bIdx);
for (let i = lo; i <= hi; i++) next.add(order[i]);
return next;
});
}, [filteredBoard, lastSelectedId]);
const selectAllVisible = useCallback(function () {
if (!filteredBoard || !filteredBoard.columns) return;
const next = new Set();
for (const col of filteredBoard.columns) {
for (const t of col.tasks || []) next.add(t.id);
}
setSelectedIds(next);
if (next.size > 0) {
const first = Array.from(next)[0];
setLastSelectedId(first);
}
}, [filteredBoard]);
const selectAllInColumn = useCallback(function (columnName) {
if (!filteredBoard || !filteredBoard.columns) return;
const col = filteredBoard.columns.find(function (c) { return c.name === columnName; });
if (!col) return;
const next = new Set(selectedIds);
for (const t of col.tasks || []) next.add(t.id);
setSelectedIds(next);
if (col.tasks && col.tasks.length > 0) setLastSelectedId(col.tasks[0].id);
}, [filteredBoard, selectedIds]);
const applyBulk = useCallback(function (patch, confirmMsg) {
if (selectedIds.size === 0) return;
@ -679,12 +788,18 @@
setError(tx(t, "bulkFailed", "Bulk: ") +
`${failed.length} of ${res.results.length} failed: ` +
failed.slice(0, 3).map(function (f) { return `${f.id} (${f.error})`; }).join("; "));
setFailedIds(new Set(failed.map(function (f) { return f.id; })));
} else {
setFailedIds(new Set());
}
clearSelected();
loadBoard();
})
.catch(function (e) { setError(String(e.message || e)); });
}, [selectedIds, loadBoard, clearSelected, board, t]);
.catch(function (e) {
setError(String(e.message || e));
setFailedIds(new Set(selectedIds));
});
}, [selectedIds, loadBoard, clearSelected, board]);
// --- board switching ----------------------------------------------------
const switchBoard = useCallback(function (nextSlug) {
@ -697,7 +812,13 @@
setLoading(true);
setBoard(nextSlug);
writeSelectedBoard(nextSlug);
}, [board]);
// Reset filters so stale search/tenant/assignee don't persist across boards.
setSearch("");
setTenantFilter("");
setAssigneeFilter("");
setIncludeArchived(false);
clearSelected();
}, [board, clearSelected]);
const createNewBoard = useCallback(function (payload) {
return SDK.fetchJSON(`${API}/boards`, {
@ -780,14 +901,19 @@
assignees: (boardData && boardData.assignees) || [],
onApply: applyBulk,
onClear: clearSelected,
onSelectAllVisible: selectAllVisible,
}) : null,
error ? h("div", { className: "text-xs text-destructive px-2" }, error) : null,
h(BoardColumns, {
board: filteredBoard,
laneByProfile,
selectedIds,
failedIds,
toggleSelected,
toggleRange,
selectAllInColumn,
onMove: moveTask,
onMoveSelected: moveSelected,
onOpen: setSelectedTaskId,
onCreate: createTask,
allTasks: boardData.columns.reduce(function (acc, c) { return acc.concat(c.tasks); }, []),
@ -1500,7 +1626,17 @@
onClick: props.onRefresh,
size: "sm",
title: "Reload the board from the database. The board auto-refreshes on task events; this is for forcing a re-read.",
}, tx(t, "refresh", "Refresh")),
}, "Refresh"),
h(Button, {
onClick: function () {
props.setSearch("");
props.setTenantFilter("");
props.setAssigneeFilter("");
props.setIncludeArchived(false);
},
size: "sm",
title: "Clear all active filters (search, tenant, assignee, archived).",
}, "Clear filters"),
);
}
@ -1511,14 +1647,33 @@
function BulkActionBar(props) {
const { t } = useI18n();
const [assignee, setAssignee] = useState("");
const [reclaimFirst, setReclaimFirst] = useState(false);
const [priority, setPriority] = useState("");
return h("div", { className: "hermes-kanban-bulk" },
h("span", { className: "hermes-kanban-bulk-count" },
`${props.count} ${tx(t, "selected", "selected")}`),
h(Button, {
onClick: function () { props.onApply({ status: "todo" }); },
size: "sm",
title: "Move selected tasks to Todo.",
}, "→ todo"),
h(Button, {
onClick: function () { props.onApply({ status: "ready" }); },
size: "sm",
title: "Move selected tasks to Ready. Ready tasks are picked up by the dispatcher on the next tick.",
}, "→ ready"),
h(Button, {
onClick: function () { props.onApply({ status: "blocked" },
`Block ${props.count} task(s)?`); },
size: "sm",
title: "Block selected tasks. Releases any active claims.",
}, "Block"),
h(Button, {
onClick: function () { props.onApply({ status: "ready" },
`Unblock ${props.count} task(s)?`); },
size: "sm",
title: "Unblock selected tasks (promote to Ready).",
}, "Unblock"),
h(Button, {
onClick: function () {
props.onApply({ status: "done" },
@ -1534,7 +1689,26 @@
},
size: "sm",
title: "Archive selected tasks. They disappear from the default board view but remain in the database.",
}, tx(t, "archive", "Archive")),
}, "Archive"),
h("div", { className: "hermes-kanban-bulk-priority",
title: "Set priority on selected tasks. Higher = claimed first." },
h(Input, {
type: "number",
value: priority,
onChange: function (e) { setPriority(e.target.value); },
placeholder: "pri",
className: "h-7 text-xs w-16",
}),
h(Button, {
onClick: function () {
if (priority === "") return;
props.onApply({ priority: Number(priority) });
setPriority("");
},
disabled: priority === "",
size: "sm",
}, "Set priority"),
),
h("div", { className: "hermes-kanban-bulk-reassign",
title: "Reassign selected tasks to a different Hermes profile. Pick a profile (or unassign) and click Apply." },
h(Select, {
@ -1551,7 +1725,7 @@
h(Button, {
onClick: function () {
if (!assignee) return;
props.onApply({ assignee: assignee === "__none__" ? "" : assignee });
props.onApply({ assignee: assignee === "__none__" ? "" : assignee, reclaim_first: reclaimFirst });
setAssignee("");
},
disabled: !assignee,
@ -1559,7 +1733,20 @@
title: "Apply the selected assignee to all selected tasks.",
}, tx(t, "apply", "Apply")),
),
h("label", { className: "hermes-kanban-bulk-reclaim-first", title: "Reclaim any active claims before reassigning" },
h("input", {
type: "checkbox",
checked: reclaimFirst,
onChange: function (e) { setReclaimFirst(e.target.checked); },
}),
"Reclaim first",
),
h("div", { className: "flex-1" }),
h(Button, {
onClick: props.onSelectAllVisible,
size: "sm",
title: "Select all visible cards across columns.",
}, "Select all visible"),
h(Button, {
onClick: props.onClear,
size: "sm",
@ -1580,8 +1767,12 @@
column: col,
laneByProfile: props.laneByProfile,
selectedIds: props.selectedIds,
failedIds: props.failedIds,
toggleSelected: props.toggleSelected,
toggleRange: props.toggleRange,
selectAllInColumn: props.selectAllInColumn,
onMove: props.onMove,
onMoveSelected: props.onMoveSelected,
onOpen: props.onOpen,
onCreate: props.onCreate,
allTasks: props.allTasks,
@ -1602,7 +1793,12 @@
const el = colRef.current;
function onTouchDrop(e) {
if (e.detail && e.detail.status === props.column.name) {
props.onMove(e.detail.taskId, props.column.name);
const taskId = e.detail.taskId;
if (props.selectedIds && props.selectedIds.has(taskId) && props.selectedIds.size > 1 && props.onMoveSelected) {
props.onMoveSelected(props.column.name);
} else {
props.onMove(taskId, props.column.name);
}
}
}
el.addEventListener("hermes-kanban:drop", onTouchDrop);
@ -1619,7 +1815,12 @@
e.preventDefault();
setDragOver(false);
const taskId = e.dataTransfer.getData(MIME_TASK);
if (taskId) props.onMove(taskId, props.column.name);
if (!taskId) return;
if (props.selectedIds && props.selectedIds.has(taskId) && props.selectedIds.size > 1) {
if (props.onMoveSelected) props.onMoveSelected(props.column.name);
} else {
props.onMove(taskId, props.column.name);
}
};
const lanes = useMemo(function () {
@ -1649,7 +1850,19 @@
onDrop: handleDrop,
},
h("div", { className: "hermes-kanban-column-header",
title: colHelp || "" },
title: COLUMN_HELP[props.column.name] || "" },
h("input", {
type: "checkbox",
className: "hermes-kanban-col-check",
title: "Select all tasks in this column",
"aria-label": `Select all tasks in ${COLUMN_LABEL[props.column.name] || props.column.name}`,
checked: props.column.tasks.length > 0 && props.column.tasks.every(function (t) { return props.selectedIds.has(t.id); }),
onChange: function (e) {
e.stopPropagation();
if (props.selectAllInColumn) props.selectAllInColumn(props.column.name);
},
onClick: function (e) { e.stopPropagation(); },
}),
h("span", { className: cn("hermes-kanban-dot", COLUMN_DOT[props.column.name]) }),
h("span", { className: "hermes-kanban-column-label" },
colLabel || props.column.name),
@ -1685,9 +1898,11 @@
),
lane.tasks.map(function (tk) {
return h(TaskCard, {
key: tk.id, task: tk,
selected: props.selectedIds.has(tk.id),
key: t.id, task: t,
selected: props.selectedIds.has(t.id),
failed: props.failedIds && props.failedIds.has(t.id),
toggleSelected: props.toggleSelected,
toggleRange: props.toggleRange,
onOpen: props.onOpen,
});
}),
@ -1695,9 +1910,11 @@
})
: props.column.tasks.map(function (tk) {
return h(TaskCard, {
key: tk.id, task: tk,
selected: props.selectedIds.has(tk.id),
key: t.id, task: t,
selected: props.selectedIds.has(t.id),
failed: props.failedIds && props.failedIds.has(t.id),
toggleSelected: props.toggleSelected,
toggleRange: props.toggleRange,
onOpen: props.onOpen,
});
}),
@ -1744,15 +1961,29 @@
e.dataTransfer.effectAllowed = "move";
};
const handleClick = function (e) {
// Shift-click or ctrl/cmd-click toggles selection instead of opening.
if (e.shiftKey || e.ctrlKey || e.metaKey) {
if (e.shiftKey) {
e.preventDefault();
e.stopPropagation();
props.toggleSelected(t.id, e.ctrlKey || e.metaKey);
if (props.toggleRange) props.toggleRange(t.id);
return;
}
if (e.ctrlKey || e.metaKey) {
e.preventDefault();
e.stopPropagation();
props.toggleSelected(t.id, true);
return;
}
props.onOpen(t.id);
};
const handleKeyDown = function (e) {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
props.onOpen(t.id);
}
if (e.key === "Escape") {
if (props.toggleSelected) props.toggleSelected(t.id, false);
}
};
const handleCheckbox = function (e) {
e.stopPropagation();
props.toggleSelected(t.id, true);
@ -1765,23 +1996,34 @@
className: cn(
"hermes-kanban-card",
props.selected ? "hermes-kanban-card--selected" : "",
props.failed ? "hermes-kanban-card--failed" : "",
stalenessClass(t),
),
draggable: true,
tabIndex: 0,
role: "button",
"aria-label": `${t.title || "untitled"}${t.id}${t.status}`,
onDragStart: handleDragStart,
onClick: handleClick,
onKeyDown: handleKeyDown,
},
h(Card, null,
h(CardContent, { className: "hermes-kanban-card-content" },
h("div", { className: "hermes-kanban-card-row" },
h("input", {
type: "checkbox",
className: "hermes-kanban-card-check",
checked: props.selected,
onChange: handleCheckbox,
h("label", {
className: "hermes-kanban-card-check-wrap",
title: "Select for bulk actions",
onClick: function (e) { e.stopPropagation(); },
title: tx(i18n, "selectForBulk", "Select for bulk actions"),
}),
},
h("input", {
type: "checkbox",
className: "hermes-kanban-card-check",
checked: props.selected,
onChange: handleCheckbox,
onClick: function (e) { e.stopPropagation(); },
"aria-label": `Select task ${t.id}`,
}),
),
h("span", { className: "hermes-kanban-card-id",
title: `Task id: ${t.id}. Use this id with kanban_show, /kanban show, or hermes kanban show.` }, t.id),
t.warnings && t.warnings.count > 0

View file

@ -1417,3 +1417,51 @@
color: #ff8b6b;
border: 1px solid rgba(255, 107, 61, 0.3);
}
/* ---- Partial failure highlight --------------------------------------- */
.hermes-kanban-card--failed :where(.hermes-kanban-card-content) {
box-shadow: 0 0 0 2px var(--color-destructive, #d14a4a) inset,
0 0 8px color-mix(in srgb, var(--color-destructive, #d14a4a) 30%, transparent);
}
/* ---- Larger checkbox hit target -------------------------------------- */
.hermes-kanban-card-check-wrap {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.5rem;
height: 1.5rem;
margin: -0.3rem;
cursor: pointer;
}
.hermes-kanban-card-check {
width: 0.95rem;
height: 0.95rem;
margin: 0;
cursor: pointer;
accent-color: var(--color-ring);
}
/* ---- Column select-all checkbox -------------------------------------- */
.hermes-kanban-col-check {
width: 0.9rem;
height: 0.9rem;
margin: 0 0.15rem 0 0;
cursor: pointer;
accent-color: var(--color-ring);
}
/* ---- Bulk action bar extras ------------------------------------------ */
.hermes-kanban-bulk-priority {
display: flex;
align-items: center;
gap: 0.25rem;
padding-left: 0.5rem;
border-left: 1px solid color-mix(in srgb, var(--color-border) 70%, transparent);
}
.hermes-kanban-bulk-reclaim-first {
display: inline-flex;
align-items: center;
gap: 0.25rem;
font-size: 0.7rem;
cursor: pointer;
}

View file

@ -1739,157 +1739,56 @@ def test_dashboard_requests_default_board_explicitly():
assert "}, [loadBoardList, switchBoard, board]);" in dist
def test_dashboard_assignee_inputs_preserve_casing():
"""Assignee/specifier inputs must disable browser auto-capitalization.
Hermes profile names are case-sensitive the dispatcher uses the
assignee string as a literal directory/profile lookup. Mobile browsers
(iOS/Android) and some IMEs auto-capitalize the first letter of any
text input by default, so a user typing ``analyst`` ends up submitting
``Analyst`` and the dispatcher fails to spawn a matching profile,
leading to the crash loop reported in #21320.
The fix sets ``autoCapitalize="none"``, ``autoCorrect="off"``,
``spellCheck=false``, and ``style={textTransform: "none"}`` on the
two assignee ``<Input>`` elements (inline triage/lane create-task
input + task-edit panel "(empty = unassign)" input).
This test pins those attributes in the compiled dist bundle so a
future rebuild that loses them fails CI immediately.
"""
def test_dashboard_search_includes_body_and_result():
"""Client-side search must match body, result, latest_summary, and summary
so full card contents are findable."""
repo_root = Path(__file__).resolve().parents[2]
dist = (repo_root / "plugins" / "kanban" / "dashboard" / "dist" / "index.js").read_text()
# Both sites should have all four attributes. Count occurrences to
# ensure both inputs got the treatment, not just one.
assert dist.count('autoCapitalize: "none"') >= 2, (
"Expected autoCapitalize=\"none\" on both assignee inputs (inline "
"create + task-edit panel)"
)
assert dist.count('autoCorrect: "off"') >= 2
assert dist.count("spellCheck: false") >= 2
assert dist.count('textTransform: "none"') >= 2
assert "t.body || \"\"" in dist
assert "t.result || \"\"" in dist
assert "t.latest_summary || \"\"" in dist
assert "t.summary || \"\"" in dist
def test_dashboard_lane_head_preserves_assignee_casing():
"""Lane headers must not visually uppercase profile names.
The previous CSS rule ``.hermes-kanban-lane-head { text-transform:
uppercase; letter-spacing: 0.08em }`` made a valid ``analyst`` profile
appear as ``ANALYST`` in column headers; users then copied the
uppercase form back into edits, hitting the same crash loop as the
auto-capitalization path. The fix removes ``text-transform: uppercase``
from the rule and tightens letter-spacing.
Static-asset regression test for the rule contents.
"""
def test_dashboard_bulk_actions_include_reclaim_first():
"""Bulk action bar must expose reclaim_first checkbox and expanded status buttons."""
repo_root = Path(__file__).resolve().parents[2]
style = (repo_root / "plugins" / "kanban" / "dashboard" / "dist" / "style.css").read_text()
dist = (repo_root / "plugins" / "kanban" / "dashboard" / "dist" / "index.js").read_text()
# Locate the .hermes-kanban-lane-head block. Use a generous slice to
# keep this resilient to nearby unrelated CSS edits.
marker = ".hermes-kanban-lane-head {"
idx = style.find(marker)
assert idx != -1, "could not locate .hermes-kanban-lane-head rule"
end = style.find("}", idx)
assert end != -1
rule = style[idx:end]
assert "text-transform: uppercase" not in rule, (
"Lane head must not visually uppercase profile names — see #21320 "
"and the explanatory comment in the CSS rule."
)
assert "reclaim_first: reclaimFirst" in dist
assert "hermes-kanban-bulk-reclaim-first" in dist
assert '"→ todo"' in dist
assert '"Block"' in dist
assert '"Unblock"' in dist
# ---------------------------------------------------------------------------
# Built-asset regressions for the dashboard's run-history rendering
# (issue #19548 — completed-run metadata used to render as a large pale box
# that read like a crash dump). The plugin ships built-only, so we lock in
# the rendered shape with static assertions on dist/index.js + dist/style.css.
# ---------------------------------------------------------------------------
def _dashboard_dist_path(name: str) -> Path:
def test_dashboard_shift_click_range_selection_exists():
"""Shift-click must trigger range selection via toggleRange."""
repo_root = Path(__file__).resolve().parents[2]
p = repo_root / "plugins" / "kanban" / "dashboard" / "dist" / name
assert p.exists(), f"dashboard asset missing: {p}"
return p
dist = (repo_root / "plugins" / "kanban" / "dashboard" / "dist" / "index.js").read_text()
assert "function toggleRange" in dist or "const toggleRange =" in dist
assert "props.toggleRange(t.id)" in dist or "props.toggleRange" in dist
assert "e.shiftKey" in dist
def test_run_metadata_pretty_printed_with_label():
"""Run-history metadata is pretty-printed JSON inside a labeled sub-block."""
js = _dashboard_dist_path("index.js").read_text(encoding="utf-8")
# Pretty-printed JSON (indent=2) so a writer task's changed_files +
# URLs blob doesn't render as one wall-of-text monoline.
assert "JSON.stringify(r.metadata, null, 2)" in js
# Explicit label so the panel reads as auxiliary detail, not a crash dump.
assert '"hermes-kanban-run-meta-label"' in js
assert '"Metadata"' in js
# Wrapped in the labelled meta block container.
assert '"hermes-kanban-run-meta-block"' in js
def test_dashboard_multi_move_bulk_exists():
"""Dragging a selected card with other selections must use /tasks/bulk."""
repo_root = Path(__file__).resolve().parents[2]
dist = (repo_root / "plugins" / "kanban" / "dashboard" / "dist" / "index.js").read_text()
assert "onMoveSelected" in dist
assert "props.onMoveSelected" in dist
assert "`${API}/tasks/bulk`" in dist
def test_run_metadata_secondary_styling():
"""Metadata block is capped, transparent, and visually secondary."""
css = _dashboard_dist_path("style.css").read_text(encoding="utf-8")
# The label class exists with muted-foreground treatment.
assert ".hermes-kanban-run-meta-label" in css
# Container styling: thin left rule, no opaque highlighted fill that
# could be mistaken for an error/warning panel.
assert ".hermes-kanban-run-meta-block" in css
block_start = css.index(".hermes-kanban-run-meta-block {")
block_decl = css[block_start : block_start + 400]
assert "background: transparent" in block_decl
assert "border-left" in block_decl
# Cap meta height so verbose JSON doesn't sprawl across the run row.
meta_start = css.index(".hermes-kanban-run-meta {")
meta_decl = css[meta_start : meta_start + 400]
assert "max-height" in meta_decl
assert "overflow: auto" in meta_decl
assert "color: var(--color-muted-foreground)" in meta_decl
def test_dashboard_failed_card_highlight_class_exists():
"""Partial bulk failures must highlight failing cards."""
repo_root = Path(__file__).resolve().parents[2]
js = (repo_root / "plugins" / "kanban" / "dashboard" / "dist" / "index.js").read_text()
css = (repo_root / "plugins" / "kanban" / "dashboard" / "dist" / "style.css").read_text()
def test_run_metadata_uses_native_collapse():
"""Metadata panel uses <details>/<summary> for zero-JS collapse.
Native <details> means the browser handles state no event handlers,
no React-state coupling, accessible by default (keyboard navigable,
screen-reader announces the disclosure state). Default-open state is
decided per-render based on payload length.
"""
js = _dashboard_dist_path("index.js").read_text(encoding="utf-8")
# Element must be <details> / <summary>, not plain <div>s.
assert 'h("details"' in js
assert 'h("summary"' in js
# The open prop is computed from json length (collapsed when verbose).
assert "open: !collapsed" in js or "open:!collapsed" in js
assert "json.length > 300" in js
def test_run_metadata_skips_empty_object():
"""Empty `{}` metadata renders nothing — no useless labeled block.
`r.metadata && {} && ...` would render a "Metadata" labeled block
containing just `{}`, which is visual noise. The render predicate now
also checks Object.keys(r.metadata).length > 0.
"""
js = _dashboard_dist_path("index.js").read_text(encoding="utf-8")
assert "Object.keys(r.metadata).length > 0" in js
def test_run_metadata_disclosure_indicator_styled():
"""Native disclosure marker is hidden + replaced with a CSS-only chevron.
Browsers render an OS-specific arrow next to <summary> by default. For a
consistent look across OSes the hermes dashboard hides that marker and
renders a CSS ::before chevron that rotates on [open]. Pin it so a
future CSS rebuild can't silently lose it (which would put two markers
side-by-side on Firefox/WebKit).
"""
css = _dashboard_dist_path("style.css").read_text(encoding="utf-8")
# Default markers suppressed.
assert "list-style: none" in css
assert "::-webkit-details-marker" in css
# CSS-only chevron present + animates on open state.
assert ".hermes-kanban-run-meta-block[open]" in css
assert "rotate(90deg)" in css
assert "hermes-kanban-card--failed" in js
assert "hermes-kanban-card--failed" in css
assert "failedIds" in js