summaryrefslogtreecommitdiff
path: root/web
diff options
context:
space:
mode:
Diffstat (limited to 'web')
-rw-r--r--web/app.js928
-rw-r--r--web/index.html21
-rw-r--r--web/style.css365
-rw-r--r--web/test/stories-board.test.mjs197
4 files changed, 1510 insertions, 1 deletions
diff --git a/web/app.js b/web/app.js
index 0328579..6f45253 100644
--- a/web/app.js
+++ b/web/app.js
@@ -146,6 +146,30 @@ export function formatEventText(event) {
return 'Execution started';
case 'execution_ended':
return p.status ? `Execution ended (${p.status})` : 'Execution ended';
+ case 'eval_verdict':
+ return `Eval verdict (${p.role || '?'}): ${p.summary || ''}`;
+ case 'arbitration_decided':
+ return `Arbitration decided: ${p.summary || ''}`;
+ case 'human_accepted':
+ return `Accepted: ${p.from || '?'} → ${p.to || '?'}`;
+ case 'retro_captured': {
+ const n = (p.proposals || []).length;
+ return `Retro captured (${n} config proposal${n === 1 ? '' : 's'}): ${p.summary || ''}`;
+ }
+ case 'epic_proposed':
+ return `Epic proposed: ${p.name || ''} (${(p.story_ids || []).length} stories)`;
+ case 'discovery_proposed':
+ return 'Discovery proposed';
+ case 'framing_decided':
+ return 'Framing decided';
+ case 'groomed':
+ return 'Groomed';
+ case 'prioritized':
+ return 'Prioritized';
+ case 'escalated':
+ return `Escalated: rung ${p.from_rung ?? '?'} → ${p.to_rung ?? '?'}${p.final ? ' (final)' : ''}`;
+ case 'role_config_proposed':
+ return `Role config proposed: ${p.role || '?'} v${p.version ?? '?'}`;
default:
return event.kind || '';
}
@@ -1333,6 +1357,12 @@ function renderActiveTab(allTasks) {
.then(([execs, agentData, dashStats]) => renderStatsPanel(allTasks, execs, agentData, dashStats))
.catch(() => {});
break;
+ case 'stories':
+ // Guard against yanking the board out from under an in-progress
+ // HTML5 drag (see draggingStoryId) — the next poll tick after the
+ // drag ends will pick up any server-side change.
+ if (!draggingStoryId) renderStoriesPanel();
+ break;
case 'drops':
renderDropsPanel();
break;
@@ -3133,7 +3163,7 @@ async function fetchDrops() {
return res.json();
}
-// ── Stories panel ─────────────────────────────────────────────────────────────
+// ── Drops panel ───────────────────────────────────────────────────────────────
async function renderDropsPanel() {
const panel = document.querySelector('[data-panel="drops"] .drops-panel');
@@ -3180,6 +3210,890 @@ async function renderDropsPanel() {
}
}
+// ── Stories tab: data fetch ──────────────────────────────────────────────────
+// Phase 9a: consumes the existing, unchanged epics/stories REST surface
+// (internal/api/stories.go, internal/api/epics.go) — no backend changes.
+
+async function fetchStories(fetchImpl = fetch) {
+ const res = await fetchImpl(`${API_BASE}/api/stories`);
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
+ return res.json();
+}
+
+async function fetchEpics(fetchImpl = fetch) {
+ const res = await fetchImpl(`${API_BASE}/api/epics`);
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
+ return res.json();
+}
+
+async function fetchEpic(epicId, fetchImpl = fetch) {
+ const res = await fetchImpl(`${API_BASE}/api/epics/${epicId}`);
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
+ return res.json();
+}
+
+async function fetchStory(id, fetchImpl = fetch) {
+ const res = await fetchImpl(`${API_BASE}/api/stories/${id}`);
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
+ return res.json();
+}
+
+// fetchStoryEvents loads a story's event stream. Mirrors fetchTaskEvents:
+// returns [] on any error so the board/modal degrade gracefully rather than
+// throwing.
+export async function fetchStoryEvents(storyId, fetchImpl = (typeof fetch !== 'undefined' ? fetch : null)) {
+ if (!fetchImpl) return [];
+ try {
+ const resp = await fetchImpl(`${API_BASE}/api/stories/${storyId}/events`);
+ if (!resp.ok) return [];
+ const data = await resp.json();
+ return Array.isArray(data) ? data : [];
+ } catch {
+ return [];
+ }
+}
+
+async function fetchStoryTaskTree(storyId, fetchImpl = fetch) {
+ const res = await fetchImpl(`${API_BASE}/api/stories/${storyId}/task-tree`);
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
+ return res.json();
+}
+
+// updateStory issues a partial PUT — handleUpdateStory (internal/api/stories.go)
+// decodes the request body onto the already-fetched existing row, so fields
+// omitted from `body` are left untouched. Used here to write only `priority`
+// on drag-and-drop reorder without clobbering the rest of the story.
+async function updateStory(id, body) {
+ const res = await fetch(`${API_BASE}/api/stories/${id}`, {
+ method: 'PUT',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(body),
+ });
+ if (!res.ok) {
+ let msg = `HTTP ${res.status}`;
+ try { const b = await res.json(); msg = b.error || msg; } catch {}
+ throw new Error(msg);
+ }
+ return res.json();
+}
+
+async function acceptStory(id) {
+ const res = await fetch(`${API_BASE}/api/stories/${id}/accept`, { method: 'POST' });
+ if (!res.ok) {
+ let msg = `HTTP ${res.status}`;
+ try { const b = await res.json(); msg = b.error || msg; } catch {}
+ throw new Error(msg);
+ }
+ return res.json();
+}
+
+// ── Stories tab: board/column model (pure — unit-tested in web/test) ───────
+//
+// Columns collapse the full story.Status lifecycle (DISCOVERY|FRAMING|
+// BACKLOG|PRIORITIZED|IN_PROGRESS|SHIPPABLE|DEPLOYED|VALIDATING|
+// REVIEW_READY|NEEDS_FIX|DONE|CANCELLED — internal/story/story.go) into 7
+// columns. SHIPPABLE/DEPLOYED fold into "In Progress" (deploy-gating isn't
+// built yet, so there's nothing actionable to show separately for them);
+// VALIDATING/REVIEW_READY fold into one "Validating" column; NEEDS_FIX/
+// CANCELLED get their own small side column rather than a hidden filter
+// toggle, per the phase's "your judgment" call, so a story that needs
+// attention is never one click away from invisible.
+export const STORY_COLUMNS = [
+ { key: 'discovery', label: 'Discovery', statuses: ['DISCOVERY', 'FRAMING'] },
+ { key: 'backlog', label: 'Backlog', statuses: ['BACKLOG'] },
+ { key: 'prioritized', label: 'Prioritized', statuses: ['PRIORITIZED'] },
+ { key: 'in_progress', label: 'In Progress', statuses: ['IN_PROGRESS', 'SHIPPABLE', 'DEPLOYED'] },
+ { key: 'validating', label: 'Validating', statuses: ['VALIDATING', 'REVIEW_READY'] },
+ { key: 'done', label: 'Done', statuses: ['DONE'] },
+ { key: 'issues', label: 'Needs Fix / Cancelled', statuses: ['NEEDS_FIX', 'CANCELLED'] },
+];
+
+// columnForStatus returns the column key for a given story status, falling
+// back to 'backlog' for an empty/unrecognized status so a story is never
+// dropped off the board entirely.
+export function columnForStatus(status) {
+ const col = STORY_COLUMNS.find(c => c.statuses.includes(status));
+ return col ? col.key : 'backlog';
+}
+
+function priorityRank(p) {
+ // Number('') === 0 and Number(null) === 0, so both would otherwise sort
+ // as the highest priority — treat "no priority set" as missing, not zero.
+ if (p === null || p === undefined || p === '') return Number.POSITIVE_INFINITY;
+ const n = Number(p);
+ return Number.isFinite(n) ? n : Number.POSITIVE_INFINITY;
+}
+
+// storyPriorityComparator sorts ascending by numeric priority (this phase's
+// convention: drag-and-drop reorder writes the story's rank as a stringified
+// integer — story.Priority is a freeform string with no enum, so there's no
+// existing convention to collide with). Non-numeric/missing priority sorts
+// last; ties break by created_at ascending (oldest first).
+export function storyPriorityComparator(a, b) {
+ const ra = priorityRank(a.priority);
+ const rb = priorityRank(b.priority);
+ if (ra !== rb) return ra - rb;
+ return new Date(a.created_at || 0) - new Date(b.created_at || 0);
+}
+
+// groupStoriesByColumn returns { [columnKey]: story[] }, each sub-array
+// sorted by storyPriorityComparator.
+export function groupStoriesByColumn(stories) {
+ const groups = {};
+ for (const col of STORY_COLUMNS) groups[col.key] = [];
+ for (const s of stories || []) {
+ const key = columnForStatus(s.status);
+ if (!groups[key]) groups[key] = [];
+ groups[key].push(s);
+ }
+ for (const key of Object.keys(groups)) {
+ groups[key].sort(storyPriorityComparator);
+ }
+ return groups;
+}
+
+// A story "has reached validation" once evaluator verdicts could plausibly
+// exist for it — VALIDATING or later in the lifecycle (see StoryOrchestrator
+// stage 2 in CLAUDE.md). Used to gate the eval-verdict indicator so we don't
+// fetch/show "Evals: 0/4" for a story that's still in Backlog.
+export function storyHasReachedValidation(status) {
+ return ['VALIDATING', 'REVIEW_READY', 'NEEDS_FIX', 'DONE', 'CANCELLED'].includes(status);
+}
+
+// countEvalVerdicts counts event.KindEvalVerdict entries in a story's event
+// stream — up to 4 expected (evaluator_quality/security/correctness/performance).
+export function countEvalVerdicts(events) {
+ return (events || []).filter(e => e && e.kind === 'eval_verdict').length;
+}
+
+// ── Stories tab: rendering state ─────────────────────────────────────────────
+
+// draggingStoryId is set for the duration of an HTML5 drag so column
+// dragover handlers can check whether the dragged story's own status maps
+// to *this* column (reorder-in-place only — see renderStoriesBoard) and so
+// poll() can avoid re-rendering the board out from under an active drag.
+let draggingStoryId = null;
+// storiesById is refreshed on every renderStoriesPanel() call; column
+// dragover/drop handlers and persistColumnOrder consult it instead of
+// re-fetching per drag event.
+let storiesById = new Map();
+
+function getStoriesViewMode() {
+ return localStorage.getItem('storiesViewMode') || 'board';
+}
+function setStoriesViewMode(mode) {
+ localStorage.setItem('storiesViewMode', mode);
+}
+
+// ── Stories tab: card ─────────────────────────────────────────────────────────
+
+function createStoryCard(story, { draggable = true } = {}) {
+ const card = document.createElement('div');
+ card.className = 'story-card';
+ card.dataset.storyId = story.id;
+ card.draggable = draggable;
+
+ const header = document.createElement('div');
+ header.className = 'story-card-header';
+ const name = document.createElement('span');
+ name.className = 'story-name';
+ name.textContent = story.name;
+ const badge = document.createElement('span');
+ badge.className = 'story-status-badge';
+ badge.dataset.status = story.status;
+ badge.textContent = (story.status || '').replace(/_/g, ' ');
+ header.append(name, badge);
+ card.appendChild(header);
+
+ const meta = document.createElement('div');
+ meta.className = 'story-meta';
+ if (story.priority) {
+ const p = document.createElement('span');
+ p.textContent = `priority: ${story.priority}`;
+ meta.appendChild(p);
+ }
+ const ac = document.createElement('span');
+ ac.textContent = `AC: ${(story.acceptance_criteria || []).length}`;
+ meta.appendChild(ac);
+ card.appendChild(meta);
+
+ if (storyHasReachedValidation(story.status)) {
+ const evalEl = document.createElement('div');
+ evalEl.className = 'story-eval-indicator';
+ evalEl.textContent = 'Evals: …';
+ card.appendChild(evalEl);
+ fetchStoryEvents(story.id).then(events => {
+ evalEl.textContent = `Evals: ${countEvalVerdicts(events)}/4`;
+ });
+ }
+
+ card.addEventListener('click', () => openStoryModal(story.id));
+
+ if (draggable) {
+ card.addEventListener('dragstart', (e) => {
+ draggingStoryId = story.id;
+ card.classList.add('dragging');
+ e.dataTransfer.setData('text/plain', story.id);
+ e.dataTransfer.effectAllowed = 'move';
+ });
+ card.addEventListener('dragend', () => {
+ draggingStoryId = null;
+ card.classList.remove('dragging');
+ document.querySelectorAll('.stories-column.drag-over').forEach(c => c.classList.remove('drag-over'));
+ });
+ }
+
+ return card;
+}
+
+// getDragAfterElement finds the card the dragged element should be inserted
+// before, based on vertical cursor position — standard vanilla-JS
+// drag-reorder technique (no library).
+function getDragAfterElement(container, y) {
+ const els = [...container.querySelectorAll('.story-card:not(.dragging)')];
+ return els.reduce((closest, child) => {
+ const box = child.getBoundingClientRect();
+ const offset = y - box.top - box.height / 2;
+ if (offset < 0 && offset > closest.offset) {
+ return { offset, element: child };
+ }
+ return closest;
+ }, { offset: Number.NEGATIVE_INFINITY, element: null }).element;
+}
+
+// persistColumnOrder re-ranks every card currently in `list` (in DOM order)
+// as priority "0", "10", "20", … and PUTs any that actually changed.
+// Ranks are only unique within a column, never globally — sorting only ever
+// happens within one column's group (groupStoriesByColumn), so cross-column
+// rank collisions are harmless.
+async function persistColumnOrder(list) {
+ const ids = [...list.querySelectorAll('.story-card')].map(c => c.dataset.storyId);
+ await Promise.all(ids.map((id, idx) => {
+ const story = storiesById.get(id);
+ const newPriority = String(idx * 10);
+ if (!story || story.priority === newPriority) return Promise.resolve();
+ return updateStory(id, { priority: newPriority })
+ .then(() => { story.priority = newPriority; })
+ .catch(err => console.error('Failed to persist story priority reorder:', err));
+ }));
+}
+
+// ── Stories tab: Kanban board ─────────────────────────────────────────────────
+//
+// Drag-and-drop decision (documented per the phase spec's either/or): dragging
+// a card to a *different* column is disabled outright rather than allowed-but-
+// a-no-op. A column's dragover handler only ever calls preventDefault() when
+// the dragged story's own status already maps to that column — so a foreign
+// column never shows a reorder preview and the browser's native "not allowed"
+// drop-cursor kicks in. The alternative (accept the drop, snap back on next
+// poll) would let a card visibly move to the wrong column for several seconds,
+// which reads as a bug rather than a deliberate constraint.
+function renderStoriesBoard(stories, container) {
+ const groups = groupStoriesByColumn(stories);
+ container.innerHTML = '';
+ const board = document.createElement('div');
+ board.className = 'stories-board';
+
+ for (const col of STORY_COLUMNS) {
+ const colEl = document.createElement('div');
+ colEl.className = 'stories-column';
+ colEl.dataset.columnKey = col.key;
+
+ const header = document.createElement('div');
+ header.className = 'stories-column-header';
+ const title = document.createElement('span');
+ title.textContent = col.label;
+ const count = document.createElement('span');
+ count.className = 'stories-column-count';
+ count.textContent = String(groups[col.key].length);
+ header.append(title, count);
+ colEl.appendChild(header);
+
+ const list = document.createElement('div');
+ list.className = 'stories-column-list';
+ for (const story of groups[col.key]) {
+ list.appendChild(createStoryCard(story));
+ }
+ colEl.appendChild(list);
+
+ list.addEventListener('dragover', (e) => {
+ const dragged = storiesById.get(draggingStoryId);
+ if (!dragged || columnForStatus(dragged.status) !== col.key) return;
+ e.preventDefault();
+ e.dataTransfer.dropEffect = 'move';
+ colEl.classList.add('drag-over');
+ const draggingEl = list.querySelector('.dragging');
+ if (!draggingEl) return;
+ const after = getDragAfterElement(list, e.clientY);
+ if (after == null) list.appendChild(draggingEl);
+ else list.insertBefore(draggingEl, after);
+ });
+ list.addEventListener('dragleave', (e) => {
+ if (!colEl.contains(e.relatedTarget)) colEl.classList.remove('drag-over');
+ });
+ list.addEventListener('drop', (e) => {
+ e.preventDefault();
+ colEl.classList.remove('drag-over');
+ persistColumnOrder(list);
+ });
+
+ board.appendChild(colEl);
+ }
+
+ container.appendChild(board);
+}
+
+// ── Stories tab: epic swimlanes ───────────────────────────────────────────────
+
+function renderSwimlane(lane) {
+ const laneEl = document.createElement('div');
+ laneEl.className = 'epic-swimlane';
+
+ const header = document.createElement('div');
+ header.className = 'epic-swimlane-header';
+
+ const name = document.createElement('span');
+ name.className = 'epic-swimlane-name';
+ name.textContent = lane.name;
+ header.appendChild(name);
+
+ const total = lane.stories.length;
+ const done = lane.stories.filter(s => s.status === 'DONE').length;
+
+ const progressWrap = document.createElement('div');
+ progressWrap.className = 'epic-progress';
+ const track = document.createElement('div');
+ track.className = 'epic-progress-track';
+ const fill = document.createElement('div');
+ fill.className = 'epic-progress-fill';
+ fill.style.width = total > 0 ? `${Math.round((done / total) * 100)}%` : '0%';
+ track.appendChild(fill);
+ const label = document.createElement('span');
+ label.className = 'epic-progress-label';
+ label.textContent = `${done}/${total} done`;
+ progressWrap.append(track, label);
+ header.appendChild(progressWrap);
+
+ laneEl.appendChild(header);
+
+ const row = document.createElement('div');
+ row.className = 'epic-swimlane-stories';
+ if (lane.stories.length === 0) {
+ const empty = document.createElement('span');
+ empty.className = 'task-meta';
+ empty.textContent = 'No stories.';
+ row.appendChild(empty);
+ } else {
+ for (const s of lane.stories.slice().sort(storyPriorityComparator)) {
+ row.appendChild(createStoryCard(s, { draggable: false }));
+ }
+ }
+ laneEl.appendChild(row);
+
+ return laneEl;
+}
+
+async function renderEpicSwimlanes(stories, container) {
+ container.innerHTML = '<p class="task-meta">Loading epics…</p>';
+ let epics;
+ try {
+ epics = await fetchEpics();
+ } catch (err) {
+ container.innerHTML = `<p class="task-meta" style="padding:1rem">Failed to load epics: ${err.message}</p>`;
+ return;
+ }
+
+ const byEpic = new Map();
+ const unassigned = [];
+ for (const s of stories) {
+ if (s.epic_id) {
+ if (!byEpic.has(s.epic_id)) byEpic.set(s.epic_id, []);
+ byEpic.get(s.epic_id).push(s);
+ } else {
+ unassigned.push(s);
+ }
+ }
+
+ const lanes = epics.map(e => ({ id: e.id, name: e.name, stories: byEpic.get(e.id) || [] }));
+ if (unassigned.length > 0 || lanes.length === 0) {
+ lanes.push({ id: '', name: 'Unassigned', stories: unassigned });
+ }
+
+ container.innerHTML = '';
+ const wrap = document.createElement('div');
+ wrap.className = 'epic-swimlanes';
+ if (lanes.length === 0) {
+ wrap.innerHTML = '<p class="task-meta">No epics or stories yet.</p>';
+ } else {
+ for (const lane of lanes) wrap.appendChild(renderSwimlane(lane));
+ }
+ container.appendChild(wrap);
+}
+
+// ── Stories tab: panel entry point ───────────────────────────────────────────
+
+async function renderStoriesPanel() {
+ const panel = document.querySelector('[data-panel="stories"]');
+ if (!panel) return;
+ const container = panel.querySelector('.stories-view-container');
+ if (!container) return;
+
+ const mode = getStoriesViewMode();
+ panel.querySelectorAll('.view-toggle-btn').forEach(btn => {
+ btn.classList.toggle('active', btn.dataset.view === mode);
+ });
+
+ let stories;
+ try {
+ stories = await fetchStories();
+ } catch (err) {
+ container.innerHTML = `<p class="task-meta" style="padding:1rem">Failed to load stories: ${err.message}</p>`;
+ return;
+ }
+
+ storiesById = new Map(stories.map(s => [s.id, s]));
+
+ if (mode === 'epics') {
+ await renderEpicSwimlanes(stories, container);
+ } else {
+ renderStoriesBoard(stories, container);
+ }
+}
+
+// ── Story task-tree DAG ──────────────────────────────────────────────────────
+// Plain SVG + vanilla JS, no graph-layout library: a simple layered
+// top-to-bottom layout, rows assigned by BFS depth from root_task_id
+// following both "spawned" (parent_task_id) and "depends on" (depends_on)
+// edges — the same two edge types GET /api/stories/{id}/task-tree itself
+// walks server-side (ListSubtasks/ListDependents) to build the flat node
+// list this renders.
+
+// computeTaskTreeDepths returns Map<nodeId, depth>. Defensive fallbacks:
+// an unresolvable root, or a node genuinely unreachable from it (shouldn't
+// happen given how the API built the list, but a cycle or a stale
+// parent_task_id could in principle produce one), still get a depth so
+// nothing silently vanishes from the layout.
+export function computeTaskTreeDepths(nodes, rootId) {
+ const byId = new Map(nodes.map(n => [n.id, n]));
+ const childrenOf = new Map();
+ const addEdge = (fromId, toId) => {
+ if (!childrenOf.has(fromId)) childrenOf.set(fromId, []);
+ childrenOf.get(fromId).push(toId);
+ };
+ for (const n of nodes) {
+ if (n.parent_task_id && byId.has(n.parent_task_id)) addEdge(n.parent_task_id, n.id);
+ for (const dep of n.depends_on || []) {
+ if (byId.has(dep)) addEdge(dep, n.id);
+ }
+ }
+
+ const depths = new Map();
+ if (!byId.has(rootId)) {
+ for (const n of nodes) depths.set(n.id, 0);
+ return depths;
+ }
+
+ depths.set(rootId, 0);
+ const queue = [rootId];
+ while (queue.length > 0) {
+ const id = queue.shift();
+ const depth = depths.get(id);
+ for (const childId of childrenOf.get(id) || []) {
+ if (!depths.has(childId)) {
+ depths.set(childId, depth + 1);
+ queue.push(childId);
+ }
+ }
+ }
+ let maxDepth = 0;
+ for (const d of depths.values()) maxDepth = Math.max(maxDepth, d);
+ for (const n of nodes) {
+ if (!depths.has(n.id)) depths.set(n.id, maxDepth + 1);
+ }
+ return depths;
+}
+
+const DAG_NODE_WIDTH = 160;
+const DAG_NODE_HEIGHT = 44;
+const DAG_COL_GAP = 24;
+const DAG_ROW_GAP = 48;
+
+// layoutTaskTree assigns each node an {x, y} top-left position: rows by BFS
+// depth (top-to-bottom), columns by original node order within a row.
+export function layoutTaskTree(nodes, rootId) {
+ const depths = computeTaskTreeDepths(nodes, rootId);
+ const byDepth = new Map();
+ for (const n of nodes) {
+ const d = depths.get(n.id) || 0;
+ if (!byDepth.has(d)) byDepth.set(d, []);
+ byDepth.get(d).push(n);
+ }
+ const depthKeys = [...byDepth.keys()];
+ const maxDepth = depthKeys.length > 0 ? Math.max(...depthKeys) : 0;
+
+ const positions = new Map();
+ for (let d = 0; d <= maxDepth; d++) {
+ const row = byDepth.get(d) || [];
+ row.forEach((n, i) => {
+ positions.set(n.id, {
+ x: i * (DAG_NODE_WIDTH + DAG_COL_GAP),
+ y: d * (DAG_NODE_HEIGHT + DAG_ROW_GAP),
+ });
+ });
+ }
+ const rowLengths = [...byDepth.values()].map(r => r.length);
+ const maxCols = rowLengths.length > 0 ? Math.max(...rowLengths) : 1;
+ return {
+ positions,
+ width: Math.max(DAG_NODE_WIDTH, maxCols * (DAG_NODE_WIDTH + DAG_COL_GAP) - DAG_COL_GAP),
+ height: (maxDepth + 1) * (DAG_NODE_HEIGHT + DAG_ROW_GAP) - DAG_ROW_GAP,
+ };
+}
+
+const SVG_NS = 'http://www.w3.org/2000/svg';
+
+const DAG_LEGEND_STATES = [
+ 'PENDING', 'QUEUED', 'RUNNING', 'READY', 'BLOCKED',
+ 'COMPLETED', 'FAILED', 'TIMED_OUT', 'CANCELLED', 'BUDGET_EXCEEDED',
+];
+
+function renderDagLegend() {
+ const wrap = document.createElement('div');
+
+ const stateLegend = document.createElement('div');
+ stateLegend.className = 'dag-legend';
+ for (const state of DAG_LEGEND_STATES) {
+ const item = document.createElement('span');
+ item.className = 'dag-legend-item';
+ const swatch = document.createElement('span');
+ swatch.className = 'dag-legend-swatch';
+ swatch.dataset.state = state;
+ const label = document.createElement('span');
+ label.textContent = state.replace(/_/g, ' ');
+ item.append(swatch, label);
+ stateLegend.appendChild(item);
+ }
+ wrap.appendChild(stateLegend);
+
+ const edgeLegend = document.createElement('div');
+ edgeLegend.className = 'dag-edge-legend';
+ const parentItem = document.createElement('span');
+ const parentSwatch = document.createElement('span');
+ parentSwatch.className = 'dag-edge-legend-swatch';
+ parentItem.append(parentSwatch, document.createTextNode('spawned (parent → child)'));
+ const dependsItem = document.createElement('span');
+ const dependsSwatch = document.createElement('span');
+ dependsSwatch.className = 'dag-edge-legend-swatch dag-edge-legend-swatch--depends';
+ dependsItem.append(dependsSwatch, document.createTextNode('depends on'));
+ edgeLegend.append(parentItem, dependsItem);
+ wrap.appendChild(edgeLegend);
+
+ return wrap;
+}
+
+function drawDagEdge(g, from, to, kind) {
+ const line = document.createElementNS(SVG_NS, 'line');
+ line.setAttribute('x1', from.x);
+ line.setAttribute('y1', from.y);
+ line.setAttribute('x2', to.x);
+ line.setAttribute('y2', to.y);
+ line.setAttribute('class', `dag-edge dag-edge--${kind}`);
+ line.setAttribute('marker-end', 'url(#dag-arrow)');
+ g.appendChild(line);
+}
+
+function renderDagNode(node, pos, pad) {
+ const g = document.createElementNS(SVG_NS, 'g');
+ g.setAttribute('class', 'dag-node');
+ g.setAttribute('transform', `translate(${pos.x + pad}, ${pos.y + pad})`);
+
+ const rect = document.createElementNS(SVG_NS, 'rect');
+ rect.setAttribute('width', DAG_NODE_WIDTH);
+ rect.setAttribute('height', DAG_NODE_HEIGHT);
+ rect.setAttribute('rx', 6);
+ rect.setAttribute('class', 'dag-node-rect');
+ rect.dataset.state = node.state;
+ g.appendChild(rect);
+
+ const title = document.createElementNS(SVG_NS, 'title');
+ title.textContent = `${node.name}\nState: ${node.state}${node.role ? `\nRole: ${node.role}` : ''}`;
+ g.appendChild(title);
+
+ const nameText = document.createElementNS(SVG_NS, 'text');
+ nameText.setAttribute('x', 8);
+ nameText.setAttribute('y', 18);
+ nameText.setAttribute('class', 'dag-node-name');
+ // Full text for now — fitDagNodeLabels (run once every node is attached to
+ // the live document) measures and truncates with an ellipsis, since a
+ // single long word (e.g. a role name) has no word-boundary to truncate at
+ // and a fixed character count either clips it anyway or wastes space on
+ // shorter names. "Measure first" per the dataviz guidance, not guess.
+ nameText.textContent = node.name || node.id;
+ g.appendChild(nameText);
+
+ const subText = document.createElementNS(SVG_NS, 'text');
+ subText.setAttribute('x', 8);
+ subText.setAttribute('y', 34);
+ subText.setAttribute('class', 'dag-node-sub');
+ subText.textContent = node.role ? `${node.role} · ${node.state}` : node.state;
+ g.appendChild(subText);
+
+ return g;
+}
+
+// renderTaskTreeDAG renders a GET /api/stories/{id}/task-tree response into
+// `container` as an SVG layered graph, color-coded by task.State (reusing
+// the app's existing --state-* tokens — see dag-node-rect rules in
+// style.css) with a text legend, plus a distinct name/state text label on
+// every node so identity is never color-alone.
+function renderTaskTreeDAG(tree, container) {
+ container.innerHTML = '';
+ const nodes = (tree && tree.nodes) || [];
+ if (nodes.length === 0) {
+ container.innerHTML = '<p class="task-meta">No task tree yet — this story has no root_task_id, or its root task has not been created.</p>';
+ return;
+ }
+
+ const { positions, width, height } = layoutTaskTree(nodes, tree.root_task_id);
+ const pad = 16;
+
+ const svg = document.createElementNS(SVG_NS, 'svg');
+ svg.setAttribute('viewBox', `0 0 ${width + pad * 2} ${height + pad * 2}`);
+ svg.setAttribute('width', width + pad * 2);
+ svg.setAttribute('height', height + pad * 2);
+ svg.classList.add('dag-svg');
+
+ const defs = document.createElementNS(SVG_NS, 'defs');
+ const marker = document.createElementNS(SVG_NS, 'marker');
+ marker.setAttribute('id', 'dag-arrow');
+ marker.setAttribute('viewBox', '0 0 10 10');
+ marker.setAttribute('refX', '9');
+ marker.setAttribute('refY', '5');
+ marker.setAttribute('markerWidth', '7');
+ marker.setAttribute('markerHeight', '7');
+ marker.setAttribute('orient', 'auto-start-reverse');
+ const arrowPath = document.createElementNS(SVG_NS, 'path');
+ arrowPath.setAttribute('d', 'M0,0 L10,5 L0,10 z');
+ arrowPath.setAttribute('class', 'dag-arrow-head');
+ marker.appendChild(arrowPath);
+ defs.appendChild(marker);
+ svg.appendChild(defs);
+
+ const byId = new Map(nodes.map(n => [n.id, n]));
+ function center(n) {
+ const p = positions.get(n.id) || { x: 0, y: 0 };
+ return { x: p.x + DAG_NODE_WIDTH / 2 + pad, y: p.y + DAG_NODE_HEIGHT / 2 + pad };
+ }
+
+ const edgesG = document.createElementNS(SVG_NS, 'g');
+ edgesG.setAttribute('class', 'dag-edges');
+ for (const n of nodes) {
+ if (n.parent_task_id && byId.has(n.parent_task_id)) {
+ drawDagEdge(edgesG, center(byId.get(n.parent_task_id)), center(n), 'parent');
+ }
+ for (const dep of n.depends_on || []) {
+ if (byId.has(dep)) {
+ drawDagEdge(edgesG, center(byId.get(dep)), center(n), 'depends');
+ }
+ }
+ }
+ svg.appendChild(edgesG);
+
+ const nodesG = document.createElementNS(SVG_NS, 'g');
+ nodesG.setAttribute('class', 'dag-nodes');
+ for (const n of nodes) {
+ nodesG.appendChild(renderDagNode(n, positions.get(n.id) || { x: 0, y: 0 }, pad));
+ }
+ svg.appendChild(nodesG);
+
+ const wrap = document.createElement('div');
+ wrap.className = 'dag-container';
+ wrap.appendChild(svg);
+ container.appendChild(wrap);
+ container.appendChild(renderDagLegend());
+
+ // Text elements are now part of the live document (container was already
+ // on-page) — getComputedTextLength() needs that to return real numbers.
+ fitDagNodeLabels(svg, DAG_NODE_WIDTH - 16);
+}
+
+// fitDagNodeLabels truncates each .dag-node-name/.dag-node-sub text element
+// to fit maxWidth, measuring actual rendered glyph width via
+// getComputedTextLength() rather than assuming a character-count budget —
+// a role name like "evaluator_performance" is one unbroken word with no
+// truncateToWordBoundary-style word break to fall back on, and different
+// browsers/fonts render the same string at different widths.
+function fitDagNodeLabels(svgRoot, maxWidth) {
+ const els = svgRoot.querySelectorAll('.dag-node-name, .dag-node-sub');
+ for (const el of els) {
+ const full = el.textContent;
+ let length;
+ try {
+ length = el.getComputedTextLength();
+ } catch {
+ continue; // no layout available (e.g. non-browser test env) — leave as-is
+ }
+ if (!Number.isFinite(length) || length <= maxWidth) continue;
+
+ let lo = 0;
+ let hi = full.length;
+ while (lo < hi) {
+ const mid = Math.ceil((lo + hi) / 2);
+ el.textContent = full.slice(0, mid) + '…';
+ if (el.getComputedTextLength() <= maxWidth) lo = mid;
+ else hi = mid - 1;
+ }
+ el.textContent = lo > 0 ? full.slice(0, lo) + '…' : '…';
+ }
+}
+
+// ── Story detail modal ────────────────────────────────────────────────────────
+
+function acListElement(items) {
+ const ul = document.createElement('ul');
+ if (!items || items.length === 0) {
+ ul.className = 'ac-list empty';
+ const li = document.createElement('li');
+ li.textContent = 'No acceptance criteria recorded.';
+ ul.appendChild(li);
+ return ul;
+ }
+ ul.className = 'ac-list';
+ for (const item of items) {
+ const li = document.createElement('li');
+ li.textContent = item;
+ ul.appendChild(li);
+ }
+ return ul;
+}
+
+async function renderStoryModalContent(storyId) {
+ const title = document.getElementById('story-modal-title');
+ const body = document.getElementById('story-modal-body');
+
+ let story, tree, events;
+ try {
+ [story, tree, events] = await Promise.all([
+ fetchStory(storyId),
+ fetchStoryTaskTree(storyId),
+ fetchStoryEvents(storyId),
+ ]);
+ } catch (err) {
+ title.textContent = 'Story';
+ body.innerHTML = `<div class="panel-fetch-error">Failed to load: ${err.message}</div>`;
+ return;
+ }
+
+ let epic = null;
+ if (story.epic_id) {
+ try { epic = await fetchEpic(story.epic_id); } catch { epic = null; }
+ }
+
+ title.textContent = story.name;
+ body.innerHTML = '';
+
+ // ── Metadata ──
+ const metaSection = makeSection('Story');
+ const grid = document.createElement('div');
+ grid.className = 'meta-grid';
+
+ const statusItem = document.createElement('div');
+ statusItem.className = 'meta-item';
+ const statusLbl = document.createElement('div');
+ statusLbl.className = 'meta-label';
+ statusLbl.textContent = 'Status';
+ const statusBadge = document.createElement('span');
+ statusBadge.className = 'story-status-badge';
+ statusBadge.dataset.status = story.status;
+ statusBadge.textContent = (story.status || '').replace(/_/g, ' ');
+ statusItem.append(statusLbl, statusBadge);
+ grid.appendChild(statusItem);
+
+ grid.append(
+ makeMetaItem('Priority', story.priority),
+ makeMetaItem('Epic', epic ? epic.name : (story.epic_id ? story.epic_id : '—'), { muted: !epic && !story.epic_id }),
+ makeMetaItem('Created', formatDateLong(story.created_at)),
+ makeMetaItem('Updated', formatDateLong(story.updated_at)),
+ makeMetaItem('ID', story.id, { fullWidth: true, mono: true }),
+ );
+ metaSection.appendChild(grid);
+
+ if (story.spec) {
+ const specLabel = document.createElement('div');
+ specLabel.className = 'meta-label';
+ specLabel.style.marginTop = '0.75rem';
+ specLabel.textContent = 'Spec';
+ const specText = document.createElement('p');
+ specText.className = 'task-summary';
+ specText.textContent = story.spec;
+ metaSection.appendChild(specLabel);
+ metaSection.appendChild(specText);
+ }
+
+ const acLabel = document.createElement('div');
+ acLabel.className = 'meta-label';
+ acLabel.style.marginTop = '0.75rem';
+ acLabel.textContent = 'Acceptance Criteria';
+ metaSection.appendChild(acLabel);
+ metaSection.appendChild(acListElement(story.acceptance_criteria));
+
+ if (story.status === 'REVIEW_READY') {
+ const acceptRow = document.createElement('div');
+ acceptRow.className = 'story-modal-actions';
+ const acceptBtn = document.createElement('button');
+ acceptBtn.className = 'btn-accept';
+ acceptBtn.textContent = 'Accept';
+ acceptBtn.addEventListener('click', async () => {
+ acceptBtn.disabled = true;
+ acceptBtn.textContent = 'Accepting…';
+ try {
+ await acceptStory(story.id);
+ await renderStoryModalContent(storyId);
+ renderStoriesPanel();
+ } catch (err) {
+ acceptBtn.disabled = false;
+ acceptBtn.textContent = 'Accept';
+ const errEl = document.createElement('span');
+ errEl.className = 'task-error';
+ errEl.textContent = `Failed: ${err.message}`;
+ acceptRow.appendChild(errEl);
+ }
+ });
+ acceptRow.appendChild(acceptBtn);
+ metaSection.appendChild(acceptRow);
+ }
+
+ body.appendChild(metaSection);
+
+ // ── Task tree DAG ──
+ const dagSection = makeSection('Task Tree');
+ const dagContainer = document.createElement('div');
+ dagSection.appendChild(dagContainer);
+ body.appendChild(dagSection);
+ renderTaskTreeDAG(tree, dagContainer);
+
+ // ── Event timeline ──
+ const timelineSection = makeSection('Timeline');
+ timelineSection.appendChild(renderEventTimeline(events));
+ body.appendChild(timelineSection);
+}
+
+async function openStoryModal(storyId) {
+ const modal = document.getElementById('story-modal');
+ const title = document.getElementById('story-modal-title');
+ const body = document.getElementById('story-modal-body');
+ if (!modal) return;
+ title.textContent = 'Loading…';
+ body.innerHTML = '<div class="panel-loading">Loading…</div>';
+ modal.showModal();
+ await renderStoryModalContent(storyId);
+}
+
+function closeStoryModal() {
+ const modal = document.getElementById('story-modal');
+ if (modal) modal.close();
+}
+
// ── Tab switching ─────────────────────────────────────────────────────────────
function switchTab(name) {
@@ -3242,6 +4156,18 @@ if (typeof document !== 'undefined') {
document.getElementById('logs-modal').close();
});
+ // Story detail modal close
+ const btnCloseStoryModal = document.getElementById('btn-close-story-modal');
+ if (btnCloseStoryModal) btnCloseStoryModal.addEventListener('click', closeStoryModal);
+
+ // Stories board/epics view toggle
+ document.querySelectorAll('.view-toggle-btn').forEach(btn => {
+ btn.addEventListener('click', () => {
+ setStoriesViewMode(btn.dataset.view);
+ renderStoriesPanel();
+ });
+ });
+
// Tab bar
document.querySelectorAll('.tab').forEach(btn => {
btn.addEventListener('click', () => switchTab(btn.dataset.tab));
diff --git a/web/index.html b/web/index.html
index 5aa7b44..523edd9 100644
--- a/web/index.html
+++ b/web/index.html
@@ -44,6 +44,7 @@
<button class="tab" data-tab="interrupted" title="Interrupted">⚠️<span class="tab-count-badge" hidden></span></button>
<button class="tab" data-tab="ready" title="Ready">✅<span class="tab-count-badge" hidden></span></button>
<button class="tab" data-tab="running" title="Running">▶️<span class="tab-count-badge" hidden></span></button>
+ <button class="tab" data-tab="stories" title="Stories">📋</button>
<button class="tab" data-tab="drops" title="Drops">📁</button>
<button class="tab" data-tab="stats" title="Stats">📊</button>
<button class="tab" data-tab="settings" title="Settings">⚙️</button>
@@ -65,6 +66,17 @@
<div class="running-current"></div>
<div class="running-history"></div>
</div>
+ <div data-panel="stories" hidden>
+ <div class="stories-toolbar">
+ <div class="view-toggle" role="tablist" aria-label="Stories view">
+ <button class="view-toggle-btn active" data-view="board" type="button">Board</button>
+ <button class="view-toggle-btn" data-view="epics" type="button">Epics</button>
+ </div>
+ </div>
+ <div class="stories-view-container">
+ <div class="task-empty">Loading…</div>
+ </div>
+ </div>
<div data-panel="drops" hidden>
<div class="drops-panel"></div>
</div>
@@ -96,6 +108,15 @@
</div>
</dialog>
+ <!-- Story detail modal -->
+ <dialog id="story-modal">
+ <div class="story-modal-header">
+ <h2 id="story-modal-title">Story</h2>
+ <button id="btn-close-story-modal" class="btn-close-panel" aria-label="Close">&#x2715;</button>
+ </div>
+ <div id="story-modal-body" class="story-modal-body"></div>
+ </dialog>
+
<script type="module" src="app.js"></script>
</body>
</html>
diff --git a/web/style.css b/web/style.css
index abbf3b9..b11faf6 100644
--- a/web/style.css
+++ b/web/style.css
@@ -1868,12 +1868,18 @@ dialog label select:focus {
}
.story-status-badge[data-status="PENDING"] { background: var(--state-pending); }
+.story-status-badge[data-status="DISCOVERY"] { background: var(--state-pending); }
+.story-status-badge[data-status="FRAMING"] { background: var(--state-pending); }
+.story-status-badge[data-status="BACKLOG"] { background: var(--state-pending); }
+.story-status-badge[data-status="PRIORITIZED"] { background: var(--state-queued); }
.story-status-badge[data-status="IN_PROGRESS"] { background: var(--state-running); }
.story-status-badge[data-status="SHIPPABLE"] { background: var(--state-completed); }
.story-status-badge[data-status="DEPLOYED"] { background: #60a5fa; }
.story-status-badge[data-status="VALIDATING"] { background: #c084fc; }
.story-status-badge[data-status="REVIEW_READY"] { background: var(--state-completed); }
.story-status-badge[data-status="NEEDS_FIX"] { background: var(--state-failed); color: #fff; }
+.story-status-badge[data-status="DONE"] { background: var(--state-completed); }
+.story-status-badge[data-status="CANCELLED"] { background: var(--state-cancelled); }
.story-meta {
display: flex;
@@ -2005,6 +2011,12 @@ dialog label select:focus {
.event-timeline__item--subtask_spawned { border-left-color: var(--accent); }
.event-timeline__item--execution_started,
.event-timeline__item--execution_ended { border-left-color: var(--state-running); }
+.event-timeline__item--eval_verdict { border-left-color: var(--state-completed); }
+.event-timeline__item--arbitration_decided { border-left-color: var(--accent); }
+.event-timeline__item--human_accepted { border-left-color: var(--state-completed); }
+.event-timeline__item--retro_captured { border-left-color: var(--state-timed-out); }
+.event-timeline__item--epic_proposed { border-left-color: var(--accent); }
+.event-timeline__item--escalated { border-left-color: var(--state-budget-exceeded); }
.event-timeline__actor {
flex: 0 0 auto;
text-transform: uppercase;
@@ -2043,3 +2055,356 @@ dialog label select:focus {
border-color: var(--state-budget-exceeded);
color: var(--state-budget-exceeded);
}
+
+/* ── Stories tab: Kanban board ────────────────────────────────────────────── */
+/* main{max-width:640px} stays untouched for every other tab; the board opts
+ out locally (negative margins) and scrolls horizontally within itself so
+ a 7-column board never forces the whole page to scroll sideways. */
+
+[data-panel="stories"] {
+ max-width: none;
+ margin: 0 -1rem;
+ padding: 0 1rem;
+}
+
+.stories-toolbar {
+ display: flex;
+ justify-content: flex-end;
+ margin-bottom: 0.75rem;
+}
+
+.view-toggle {
+ display: inline-flex;
+ border: 1px solid var(--border);
+ border-radius: 0.375rem;
+ overflow: hidden;
+}
+
+.view-toggle-btn {
+ font-size: 0.8rem;
+ font-weight: 600;
+ padding: 0.35em 0.9em;
+ border: none;
+ background: transparent;
+ color: var(--text-muted);
+ cursor: pointer;
+}
+
+.view-toggle-btn + .view-toggle-btn {
+ border-left: 1px solid var(--border);
+}
+
+.view-toggle-btn.active {
+ background: var(--accent);
+ color: #0f172a;
+}
+
+.stories-board {
+ display: flex;
+ align-items: flex-start;
+ gap: 0.75rem;
+ overflow-x: auto;
+ padding-bottom: 0.5rem;
+}
+
+.stories-column {
+ flex: 0 0 200px;
+ width: 200px;
+ background: var(--bg);
+ border: 1px solid var(--border);
+ border-radius: 0.5rem;
+ padding: 0.625rem;
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
+ transition: border-color 0.15s, background 0.15s;
+}
+
+.stories-column.drag-over {
+ border-color: var(--accent);
+ background: var(--surface);
+}
+
+.stories-column-header {
+ display: flex;
+ align-items: baseline;
+ justify-content: space-between;
+ font-size: 0.75rem;
+ font-weight: 700;
+ text-transform: uppercase;
+ letter-spacing: 0.03em;
+ color: var(--text-muted);
+ padding-bottom: 0.4rem;
+ border-bottom: 1px solid var(--border);
+}
+
+.stories-column-count {
+ font-weight: 700;
+ color: var(--text);
+ background: var(--surface);
+ border-radius: 999px;
+ padding: 0 0.45em;
+ font-size: 0.7rem;
+}
+
+.stories-column-list {
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
+ min-height: 40px;
+ max-height: 60vh;
+ overflow-y: auto;
+}
+
+.story-card {
+ cursor: grab;
+}
+
+.story-card.dragging {
+ opacity: 0.4;
+}
+
+.story-card-header {
+ flex-wrap: wrap;
+}
+
+.story-eval-indicator {
+ font-size: 0.72rem;
+ color: var(--text-muted);
+ margin-top: 0.1rem;
+}
+
+.story-modal-actions {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ margin-top: 0.5rem;
+}
+
+/* ── Stories tab: Epic swimlanes ──────────────────────────────────────────── */
+
+.epic-swimlanes {
+ display: flex;
+ flex-direction: column;
+ gap: 1rem;
+}
+
+.epic-swimlane {
+ border: 1px solid var(--border);
+ border-radius: 0.5rem;
+ background: var(--bg);
+ padding: 0.75rem;
+}
+
+.epic-swimlane-header {
+ display: flex;
+ align-items: center;
+ gap: 1rem;
+ margin-bottom: 0.625rem;
+}
+
+.epic-swimlane-name {
+ font-weight: 700;
+ font-size: 0.92rem;
+ flex-shrink: 0;
+}
+
+.epic-progress {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ flex: 1;
+ min-width: 0;
+}
+
+/* Meter: fill is a single hue (success), track is a lighter neutral step —
+ same-ramp read, not a multi-color segmented bar. */
+.epic-progress-track {
+ flex: 1;
+ height: 6px;
+ border-radius: 999px;
+ background: var(--border);
+ overflow: hidden;
+}
+
+.epic-progress-fill {
+ height: 100%;
+ background: var(--state-completed);
+ border-radius: 999px;
+ transition: width 0.2s;
+}
+
+.epic-progress-label {
+ font-size: 0.75rem;
+ color: var(--text-muted);
+ white-space: nowrap;
+ flex-shrink: 0;
+}
+
+.epic-swimlane-stories {
+ display: flex;
+ gap: 0.625rem;
+ overflow-x: auto;
+ padding-bottom: 0.25rem;
+}
+
+.epic-swimlane-stories .story-card {
+ flex: 0 0 220px;
+ width: 220px;
+ cursor: pointer;
+}
+
+/* ── Story detail modal ───────────────────────────────────────────────────── */
+
+#story-modal {
+ max-width: 900px;
+ width: 92vw;
+ max-height: 88vh;
+ overflow-y: auto;
+}
+
+.story-modal-body {
+ display: flex;
+ flex-direction: column;
+ gap: 1.25rem;
+}
+
+.ac-list {
+ margin: 0.25rem 0 0;
+ padding-left: 1.25rem;
+}
+
+.ac-list li {
+ padding: 0.15rem 0;
+ font-size: 0.875rem;
+}
+
+.ac-list.empty {
+ list-style: none;
+ padding-left: 0;
+ color: var(--text-muted);
+ font-size: 0.85rem;
+}
+
+/* ── Story task-tree DAG ──────────────────────────────────────────────────── */
+
+.dag-container {
+ overflow: auto;
+ max-height: 440px;
+ border: 1px solid var(--border);
+ border-radius: 0.5rem;
+ background: var(--bg);
+ padding: 0.5rem;
+}
+
+.dag-svg {
+ display: block;
+}
+
+.dag-node-rect {
+ stroke: rgba(15, 23, 42, 0.35);
+ stroke-width: 1;
+}
+
+.dag-node-rect[data-state="PENDING"] { fill: var(--state-pending); }
+.dag-node-rect[data-state="QUEUED"] { fill: var(--state-queued); }
+.dag-node-rect[data-state="RUNNING"] { fill: var(--state-running); }
+.dag-node-rect[data-state="READY"] { fill: var(--state-running); }
+.dag-node-rect[data-state="COMPLETED"] { fill: var(--state-completed); }
+.dag-node-rect[data-state="FAILED"] { fill: var(--state-failed); }
+.dag-node-rect[data-state="TIMED_OUT"] { fill: var(--state-timed-out); }
+.dag-node-rect[data-state="CANCELLED"] { fill: var(--state-cancelled); }
+.dag-node-rect[data-state="BUDGET_EXCEEDED"] { fill: var(--state-budget-exceeded); }
+.dag-node-rect[data-state="BLOCKED"] { fill: var(--state-blocked); }
+
+.dag-node-name, .dag-node-sub {
+ font-family: inherit;
+ fill: #0f172a;
+}
+
+.dag-node-name {
+ font-size: 11px;
+ font-weight: 700;
+}
+
+.dag-node-sub {
+ font-size: 9px;
+ opacity: 0.85;
+}
+
+.dag-edge {
+ stroke: var(--text-muted);
+ stroke-width: 1.5;
+ fill: none;
+}
+
+.dag-edge--depends {
+ stroke-dasharray: 4 3;
+}
+
+.dag-arrow-head {
+ fill: var(--text-muted);
+}
+
+.dag-legend {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.4rem 0.9rem;
+ margin-top: 0.75rem;
+ font-size: 0.72rem;
+ color: var(--text-muted);
+}
+
+.dag-legend-item {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.3rem;
+}
+
+.dag-legend-swatch {
+ width: 10px;
+ height: 10px;
+ border-radius: 2px;
+ display: inline-block;
+}
+
+.dag-legend-swatch[data-state="PENDING"] { background: var(--state-pending); }
+.dag-legend-swatch[data-state="QUEUED"] { background: var(--state-queued); }
+.dag-legend-swatch[data-state="RUNNING"] { background: var(--state-running); }
+.dag-legend-swatch[data-state="READY"] { background: var(--state-running); }
+.dag-legend-swatch[data-state="COMPLETED"] { background: var(--state-completed); }
+.dag-legend-swatch[data-state="FAILED"] { background: var(--state-failed); }
+.dag-legend-swatch[data-state="TIMED_OUT"] { background: var(--state-timed-out); }
+.dag-legend-swatch[data-state="CANCELLED"] { background: var(--state-cancelled); }
+.dag-legend-swatch[data-state="BUDGET_EXCEEDED"] { background: var(--state-budget-exceeded); }
+.dag-legend-swatch[data-state="BLOCKED"] { background: var(--state-blocked); }
+
+.dag-edge-legend {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.4rem 1.2rem;
+ margin-top: 0.35rem;
+ font-size: 0.72rem;
+ color: var(--text-muted);
+}
+
+.dag-edge-legend-swatch {
+ display: inline-block;
+ width: 18px;
+ border-top: 2px solid var(--text-muted);
+ margin-right: 0.35rem;
+ vertical-align: middle;
+}
+
+.dag-edge-legend-swatch--depends {
+ border-top-style: dashed;
+}
+
+@media (max-width: 640px) {
+ #story-modal {
+ max-width: 100vw;
+ width: 100vw;
+ max-height: 100vh;
+ border-radius: 0;
+ }
+}
diff --git a/web/test/stories-board.test.mjs b/web/test/stories-board.test.mjs
new file mode 100644
index 0000000..482628f
--- /dev/null
+++ b/web/test/stories-board.test.mjs
@@ -0,0 +1,197 @@
+// stories-board.test.mjs — Unit tests for the Phase 9a Stories tab's pure
+// logic: column grouping/sorting, eval-verdict counting, DAG BFS-depth
+// layout, and the new story-related event-timeline text formatting.
+//
+// Run with: node --test web/test/stories-board.test.mjs
+
+import { describe, it } from 'node:test';
+import assert from 'node:assert/strict';
+import {
+ STORY_COLUMNS,
+ columnForStatus,
+ groupStoriesByColumn,
+ storyPriorityComparator,
+ storyHasReachedValidation,
+ countEvalVerdicts,
+ computeTaskTreeDepths,
+ layoutTaskTree,
+ formatEventText,
+} from '../app.js';
+
+describe('columnForStatus', () => {
+ it('maps each documented status to a column', () => {
+ assert.equal(columnForStatus('DISCOVERY'), 'discovery');
+ assert.equal(columnForStatus('FRAMING'), 'discovery');
+ assert.equal(columnForStatus('BACKLOG'), 'backlog');
+ assert.equal(columnForStatus('PRIORITIZED'), 'prioritized');
+ assert.equal(columnForStatus('IN_PROGRESS'), 'in_progress');
+ assert.equal(columnForStatus('SHIPPABLE'), 'in_progress');
+ assert.equal(columnForStatus('DEPLOYED'), 'in_progress');
+ assert.equal(columnForStatus('VALIDATING'), 'validating');
+ assert.equal(columnForStatus('REVIEW_READY'), 'validating');
+ assert.equal(columnForStatus('DONE'), 'done');
+ assert.equal(columnForStatus('NEEDS_FIX'), 'issues');
+ assert.equal(columnForStatus('CANCELLED'), 'issues');
+ });
+
+ it('falls back to backlog for an unrecognized/empty status', () => {
+ assert.equal(columnForStatus(''), 'backlog');
+ assert.equal(columnForStatus(undefined), 'backlog');
+ assert.equal(columnForStatus('SOME_FUTURE_STATUS'), 'backlog');
+ });
+
+ it('every column key is unique and covers the full lifecycle', () => {
+ const keys = STORY_COLUMNS.map(c => c.key);
+ assert.equal(new Set(keys).size, keys.length);
+ const allStatuses = STORY_COLUMNS.flatMap(c => c.statuses);
+ for (const s of [
+ 'DISCOVERY', 'FRAMING', 'BACKLOG', 'PRIORITIZED', 'IN_PROGRESS',
+ 'SHIPPABLE', 'DEPLOYED', 'VALIDATING', 'REVIEW_READY', 'NEEDS_FIX',
+ 'DONE', 'CANCELLED',
+ ]) {
+ assert.ok(allStatuses.includes(s), `${s} missing from any column`);
+ }
+ });
+});
+
+describe('storyPriorityComparator / groupStoriesByColumn', () => {
+ it('sorts numeric priority ascending', () => {
+ const a = { id: 'a', priority: '20', created_at: '2024-01-01T00:00:00Z' };
+ const b = { id: 'b', priority: '10', created_at: '2024-01-01T00:00:00Z' };
+ assert.ok(storyPriorityComparator(a, b) > 0);
+ assert.ok(storyPriorityComparator(b, a) < 0);
+ });
+
+ it('sorts missing/non-numeric priority after numeric ones', () => {
+ const numeric = { id: 'n', priority: '0', created_at: '2024-01-01T00:00:00Z' };
+ const missing = { id: 'm', priority: '', created_at: '2023-01-01T00:00:00Z' };
+ assert.ok(storyPriorityComparator(numeric, missing) < 0);
+ });
+
+ it('breaks ties by created_at ascending', () => {
+ const older = { id: 'o', priority: '5', created_at: '2023-01-01T00:00:00Z' };
+ const newer = { id: 'n', priority: '5', created_at: '2024-01-01T00:00:00Z' };
+ assert.ok(storyPriorityComparator(older, newer) < 0);
+ });
+
+ it('groups and sorts stories into their column', () => {
+ const stories = [
+ { id: '1', status: 'BACKLOG', priority: '10', created_at: '2024-01-01T00:00:00Z' },
+ { id: '2', status: 'BACKLOG', priority: '0', created_at: '2024-01-01T00:00:00Z' },
+ { id: '3', status: 'DONE', priority: '0', created_at: '2024-01-01T00:00:00Z' },
+ ];
+ const groups = groupStoriesByColumn(stories);
+ assert.deepEqual(groups.backlog.map(s => s.id), ['2', '1']);
+ assert.deepEqual(groups.done.map(s => s.id), ['3']);
+ assert.equal(groups.discovery.length, 0);
+ });
+});
+
+describe('storyHasReachedValidation / countEvalVerdicts', () => {
+ it('is true only from VALIDATING onward', () => {
+ assert.equal(storyHasReachedValidation('BACKLOG'), false);
+ assert.equal(storyHasReachedValidation('IN_PROGRESS'), false);
+ assert.equal(storyHasReachedValidation('VALIDATING'), true);
+ assert.equal(storyHasReachedValidation('REVIEW_READY'), true);
+ assert.equal(storyHasReachedValidation('NEEDS_FIX'), true);
+ assert.equal(storyHasReachedValidation('DONE'), true);
+ });
+
+ it('counts only eval_verdict events', () => {
+ const events = [
+ { kind: 'eval_verdict' },
+ { kind: 'eval_verdict' },
+ { kind: 'state_change' },
+ { kind: 'arbitration_decided' },
+ ];
+ assert.equal(countEvalVerdicts(events), 2);
+ assert.equal(countEvalVerdicts([]), 0);
+ assert.equal(countEvalVerdicts(null), 0);
+ });
+});
+
+describe('computeTaskTreeDepths / layoutTaskTree', () => {
+ const nodes = [
+ { id: 'root', parent_task_id: '', depends_on: [] },
+ { id: 'child1', parent_task_id: 'root', depends_on: [] },
+ { id: 'child2', parent_task_id: 'root', depends_on: [] },
+ { id: 'dependent', parent_task_id: '', depends_on: ['child1', 'child2'] },
+ ];
+
+ it('assigns depth 0 to root and increasing depth via parent/depends_on edges', () => {
+ const depths = computeTaskTreeDepths(nodes, 'root');
+ assert.equal(depths.get('root'), 0);
+ assert.equal(depths.get('child1'), 1);
+ assert.equal(depths.get('child2'), 1);
+ assert.equal(depths.get('dependent'), 2);
+ });
+
+ it('falls back to depth 0 for everything when root is unresolvable', () => {
+ const depths = computeTaskTreeDepths(nodes, 'missing-root');
+ for (const n of nodes) assert.equal(depths.get(n.id), 0);
+ });
+
+ it('places isolated/unreachable nodes past the max depth rather than dropping them', () => {
+ const withOrphan = [...nodes, { id: 'orphan', parent_task_id: 'nonexistent', depends_on: [] }];
+ const depths = computeTaskTreeDepths(withOrphan, 'root');
+ assert.equal(depths.get('orphan'), 3); // maxDepth(2) + 1
+ });
+
+ it('lays out nodes with non-overlapping rows/columns', () => {
+ const { positions, width, height } = layoutTaskTree(nodes, 'root');
+ assert.equal(positions.get('root').y, 0);
+ assert.equal(positions.get('child1').y, positions.get('child2').y);
+ assert.notEqual(positions.get('child1').x, positions.get('child2').x);
+ assert.ok(positions.get('dependent').y > positions.get('child1').y);
+ assert.ok(width > 0);
+ assert.ok(height > 0);
+ });
+
+ it('handles a single-node tree', () => {
+ const single = [{ id: 'only', parent_task_id: '', depends_on: [] }];
+ const { positions, width, height } = layoutTaskTree(single, 'only');
+ assert.deepEqual(positions.get('only'), { x: 0, y: 0 });
+ assert.ok(width > 0 && height > 0);
+ });
+});
+
+describe('formatEventText — story/planning-layer event kinds', () => {
+ it('formats eval_verdict', () => {
+ assert.equal(
+ formatEventText({ kind: 'eval_verdict', payload: { role: 'evaluator_security', summary: 'No issues found' } }),
+ 'Eval verdict (evaluator_security): No issues found',
+ );
+ });
+
+ it('formats arbitration_decided', () => {
+ assert.equal(
+ formatEventText({ kind: 'arbitration_decided', payload: { summary: 'Ship it' } }),
+ 'Arbitration decided: Ship it',
+ );
+ });
+
+ it('formats human_accepted', () => {
+ assert.equal(
+ formatEventText({ kind: 'human_accepted', payload: { from: 'REVIEW_READY', to: 'DONE' } }),
+ 'Accepted: REVIEW_READY → DONE',
+ );
+ });
+
+ it('formats retro_captured with proposal count', () => {
+ assert.equal(
+ formatEventText({ kind: 'retro_captured', payload: { proposals: [{ role: 'builder', version: 2 }], summary: 'Went well' } }),
+ 'Retro captured (1 config proposal): Went well',
+ );
+ assert.equal(
+ formatEventText({ kind: 'retro_captured', payload: { proposals: [], summary: 'No changes needed' } }),
+ 'Retro captured (0 config proposals): No changes needed',
+ );
+ });
+
+ it('formats epic_proposed', () => {
+ assert.equal(
+ formatEventText({ kind: 'epic_proposed', payload: { name: 'Search revamp', story_ids: ['a', 'b', 'c'] } }),
+ 'Epic proposed: Search revamp (3 stories)',
+ );
+ });
+});