diff --git a/board_view.py b/board_view.py
index 682a4dd..20bf75a 100644
--- a/board_view.py
+++ b/board_view.py
@@ -49,6 +49,15 @@
a.pr{color:var(--pl-color-status-info);text-decoration:none}
a.pr:hover{text-decoration:underline}
.hide{display:none}
+ /* List view — collapsible per-state group headers (#26). Token-driven, mirrors the
+ Kanban column grouping as sections in the dense list. */
+ #list tr.grp{cursor:pointer;user-select:none}
+ #list tr.grp>td{background:var(--pl-color-bg-raised);text-transform:uppercase;
+ letter-spacing:.06em;font-size:11px;font-weight:600;color:var(--pl-color-fg-muted);
+ padding:var(--pl-space-2) 4px}
+ #list tr.grp:hover>td{color:var(--pl-color-fg)}
+ #list tr.grp .tw{display:inline-block;width:1.1em;text-align:center}
+ #list tr.grp .gl{margin-right:var(--pl-space-2)}
/* Narrow/mobile: the JS auto-switches to the list; if Kanban is forced, stack it. */
@media (max-width:760px){ .board{grid-template-columns:1fr} .wrap{padding:var(--pl-space-3)} }
@@ -118,6 +127,14 @@
// State → DS dot variant (for the list view chip).
const DOT_VARIANT = {ready:"pl-dot--success", in_review:"pl-dot--info", blocked:"pl-dot--error"};
+// List view sections: the Kanban columns + the `blocked` flag-state + `cancelled`
+// (the second terminal edge, #47), rendered as collapsible groups in COLS order (#26).
+const LIST_SECTIONS = [...COLS, "blocked", "cancelled"];
+// States the user has collapsed — module-scoped so the 10s auto-reload re-render
+// doesn't re-expand what they closed.
+const COLLAPSED = new Set();
+function toggleGroup(state){ COLLAPSED.has(state) ? COLLAPSED.delete(state) : COLLAPSED.add(state); render(); }
+
function render(){
if (VIEW === "kanban"){
$("kanban").innerHTML = COLS.map(state => {
@@ -134,12 +151,30 @@
+ ''+items.length+''+cards+'';
}).join("");
} else {
- $("rows").innerHTML = FEATURES.map(f =>
+ // List: group rows under a collapsible per-state header (COLS order + blocked +
+ // cancelled), mirroring the Kanban's grouping so a dense board stays scannable (#26).
+ const row = (f) =>
'
| '+esc(f.id)+' | '+esc(f.title)+' | '
+ ''
+ ''+esc(f.state)+' | '
- + 'P'+f.priority+' | '+flags(f)+' | '+pr(f)+' |
'
- ).join("") || 'No features yet — create some via the board tools or API. |
';
+ + 'P'+f.priority+' | '+flags(f)+' | '+pr(f)+' | ';
+ const byState = {};
+ FEATURES.forEach(f => (byState[f.state] = byState[f.state] || []).push(f));
+ // COLS order + blocked + cancelled; any unexpected state lands in its own group last.
+ const order = LIST_SECTIONS.slice();
+ Object.keys(byState).forEach(s => { if (!order.includes(s)) order.push(s); });
+ let html = "";
+ order.forEach(state => {
+ const items = byState[state] || [];
+ if (!items.length) return; // omit empty sections
+ const collapsed = COLLAPSED.has(state);
+ html += ''
+ + '| '+(collapsed?"▸":"▾")+''
+ + ''+esc(state.replace("_"," "))+''
+ + ''+items.length+' |
';
+ if (!collapsed) html += items.map(row).join(""); // collapsed → header only (rows omitted)
+ });
+ $("rows").innerHTML = html || 'No features yet — create some via the board tools or API. |
';
}
}
@@ -157,8 +192,10 @@
}
}
-// Module scripts are scoped — expose the view-toggle's inline onclick handlers.
+// Module scripts are scoped — expose the inline onclick handlers (view toggle +
+// the list's per-state collapse).
window.setView = setView;
+window.toggleGroup = toggleGroup;
setView(VIEW); // sync the toggle + visibility to the initial view (list on mobile)
// Boot ONCE, on whichever fires first: the handshake (the bearer arrives with
// protoagent:init, so the gated /features pull authenticates) or a short timer
diff --git a/protoagent.plugin.yaml b/protoagent.plugin.yaml
index 824bb06..a122e37 100644
--- a/protoagent.plugin.yaml
+++ b/protoagent.plugin.yaml
@@ -1,6 +1,6 @@
id: project_board
name: Project Board (coding orchestration)
-version: 0.21.0
+version: 0.22.0
description: >-
A board-driven coding-orchestration plugin: a lean 6-state board (backlog → ready
→ in_progress → in_review → done, + a blocked flag) backed by **beads** (`br`), an
diff --git a/pyproject.toml b/pyproject.toml
index 5f97ee4..c136d58 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,6 +1,6 @@
[project]
name = "project-board"
-version = "0.21.0"
+version = "0.22.0"
description = "Board-driven coding-orchestration plugin for protoAgent (beads board + ACP spawn loop + planning layer + console view)."
requires-python = ">=3.11"
diff --git a/tests/test_board_view.py b/tests/test_board_view.py
new file mode 100644
index 0000000..9288b0c
--- /dev/null
+++ b/tests/test_board_view.py
@@ -0,0 +1,33 @@
+"""Board-view contract tests (#26).
+
+The board page is a no-build, vanilla-JS HTML string (``BOARD_PAGE``) — there's no JS
+runtime in the suite, so (like ``test_api`` guarding the served path) these assert the
+structural contract of the page: the list view groups features into collapsible
+per-state sections, and the Kanban grouping is left untouched.
+"""
+
+from __future__ import annotations
+
+from project_board.board_view import BOARD_PAGE
+
+
+def test_list_sections_cover_cols_plus_blocked_and_cancelled():
+ """The list groups by COLS order + the blocked flag-state + cancelled (the second
+ terminal edge), so every board state a feature can be in has a home in the list."""
+ assert 'const LIST_SECTIONS = [...COLS, "blocked", "cancelled"];' in BOARD_PAGE
+
+
+def test_list_groups_are_collapsible_and_persist_across_reloads():
+ """A per-state header toggles its group; collapse state lives in a module-scoped Set
+ so the 10s auto-reload re-render doesn't re-expand what the user closed."""
+ assert "function toggleGroup(state)" in BOARD_PAGE
+ assert "const COLLAPSED = new Set();" in BOARD_PAGE
+ assert "window.toggleGroup = toggleGroup;" in BOARD_PAGE # exposed for the inline onclick
+ # the group header row carries the state name + a count badge, and omits empty sections
+ assert 'class="grp"' in BOARD_PAGE
+ assert "if (!items.length) return;" in BOARD_PAGE
+
+
+def test_kanban_columns_are_unchanged():
+ """#26 is the list projection only — the Kanban's 5 state columns stay as they were."""
+ assert 'const COLS = ["backlog", "ready", "in_progress", "in_review", "done"];' in BOARD_PAGE