From d105eca610b0e737c3313e4978d6a917b4f55d10 Mon Sep 17 00:00:00 2001 From: Claude Sonnet 5 Date: Sat, 4 Jul 2026 05:31:42 +0000 Subject: feat(web): add Stories tab -- Kanban board, epic swimlanes, story-detail DAG (Phase 9a) First UI work on top of the harness's 8 backend phases -- pure web/* frontend, no backend changes, keeping the existing embedded vanilla-JS convention (no build step, no framework). - Kanban board: columns spanning the full story lifecycle (DISCOVERY/FRAMING -> BACKLOG -> PRIORITIZED -> IN_PROGRESS -> VALIDATING/REVIEW_READY -> DONE), cards showing priority/acceptance-criteria count/eval-verdict progress. Drag-and-drop reorders priority within a column (persisted via a partial PUT /api/stories/{id}); cross-column drag is disabled outright (a dragover handler only preventDefault()s when the dragged card's own status already maps to that column) rather than allowed-then-snapped-back, since the latter would visibly misplace a card for several seconds until the next poll -- reads as a bug, not a feature. - Epic swimlanes: stories grouped client-side by epic_id (incl. an "Unassigned" lane) with a DONE/total progress meter per epic, built from data the board already fetches (avoids N+1 GET /api/epics/{id}/stories calls for a result that's a strict subset of the already-loaded list). - Story detail modal: metadata/spec/acceptance criteria, a BFS-layered plain- SVG DAG of the story's task-tree (rows = BFS depth from root_task_id over both parent_task_id and depends_on edges, solid vs. dashed strokes distinguishing the two edge kinds), an event timeline rendering eval_verdict/arbitration_decided/human_accepted/retro_captured/ epic_proposed with human-readable text, and an Accept button wired to POST /api/stories/{id}/accept when status is REVIEW_READY. - DAG node coloring reuses the app's existing --state-* CSS custom properties (the dataviz skill's "reserved status palette" pattern, already used elsewhere in the app) rather than introducing a new palette. Running the skill's own palette validator against that pre-existing 9-color set fails its CVD/lightness checks (a pre-existing condition, out of scope to fix here since those colors are used elsewhere in the app) -- mitigated per the skill's documented remedy for a failing palette: every node always carries a text label (name + role + state) and a full legend, so identity never depends on color alone; edge kind is encoded by stroke style, not color. Verified with a real running server and a real headless-browser session (Chromium via playwright-core, offline-cached), not just code review: seeded real data (an epic, 11 stories across every lifecycle status, a 6-node Builder->4-Evaluator->Arbitration task tree, 4 story events) via the live REST API plus two throwaway fixture scripts for the two write paths the API doesn't expose (task depends_on, direct event writes), then confirmed via screenshots/DOM assertions: correct column placement for every status, working swimlane grouping/progress meter, correct 6-node/8-edge DAG render, working event timeline, a real Accept-button API call flipping REVIEW_READY->DONE, real drag-and-drop priority persistence (re-fetched to confirm), and a genuine cross-column-drag no-op. Caught and fixed a real bug during this process: DAG node-label truncation now measures getComputedTextLength() and binary-searches the longest fit, instead of a fixed character-count budget that collapsed every "Eval: evaluator_X" label to "Eval:..." (only one space in the string). go build ./... clean; node --test web/test/*.mjs -- 275 tests pass (19 new). Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01V1moSNCJRcP6kykA4tyUSs --- web/style.css | 365 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 365 insertions(+) (limited to 'web/style.css') 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; + } +} -- cgit v1.2.3