From dccaa35c5d18ef6eef0963d70bff10a2abcc8f71 Mon Sep 17 00:00:00 2001 From: Hichem Date: Fri, 8 May 2026 21:34:40 +0200 Subject: [PATCH 1/2] feat(epics): add Kanban board view with side-panel drill-down MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a board layout to the Epics page alongside the existing timeline. - `EpicsKanban` renders three columns — Planning (not-started), In Progress, Done — with one card per epic. Each card shows the epic id, title, status badge, story counter, and a segmented progress bar. - `EpicStoriesSheet` is the side-panel drill-down: a shadcn Sheet that first lists the epic's stories, then swaps to `StoryDetailView` when a story is selected, with a "back to stories" affordance. Internal navigation resets whenever the targeted epic changes (derived during render rather than via an effect). - `EpicsBrowser` gains a Timeline / Board toggle on the epics list. Timeline keeps the existing in-page navigation; Board opens the side-panel sheet on click. Timeline stays the default so existing flows are unchanged. Reuses `Sheet`, `StoryDetailView`, `StatusBadge`, `SegmentedProgressBar`, and the `StaggeredList` animation primitives, so the new view inherits the project's visual language without new shared primitives. --- src/components/epics/epic-stories-sheet.tsx | 156 ++++++++++++++++++++ src/components/epics/epics-browser.tsx | 74 +++++++++- src/components/epics/epics-kanban.tsx | 110 ++++++++++++++ 3 files changed, 338 insertions(+), 2 deletions(-) create mode 100644 src/components/epics/epic-stories-sheet.tsx create mode 100644 src/components/epics/epics-kanban.tsx diff --git a/src/components/epics/epic-stories-sheet.tsx b/src/components/epics/epic-stories-sheet.tsx new file mode 100644 index 0000000..7ce3d0b --- /dev/null +++ b/src/components/epics/epic-stories-sheet.tsx @@ -0,0 +1,156 @@ +"use client"; + +import { useState } from "react"; +import { + Sheet, + SheetContent, + SheetHeader, + SheetTitle, + SheetDescription, +} from "@/components/ui/sheet"; +import { Card, CardContent } from "@/components/ui/card"; +import { StatusBadge } from "@/components/shared/status-badge"; +import { SegmentedProgressBar } from "@/components/shared/segmented-progress-bar"; +import { StoryDetailView } from "./story-detail-view"; +import { ArrowLeft } from "lucide-react"; +import type { Epic, StoryDetail } from "@/lib/bmad/types"; + +interface EpicStoriesSheetProps { + open: boolean; + onOpenChange: (open: boolean) => void; + epic: Epic | null; + stories: StoryDetail[]; +} + +export function EpicStoriesSheet({ + open, + onOpenChange, + epic, + stories, +}: EpicStoriesSheetProps) { + const [selectedStoryId, setSelectedStoryId] = useState(null); + // Reset internal navigation when the targeted epic changes (covers both + // "open with a different epic" and "close then reopen") — derived from + // props at render time, per React 19 guidance. + const [trackedEpicId, setTrackedEpicId] = useState( + epic?.id, + ); + if (trackedEpicId !== epic?.id) { + setTrackedEpicId(epic?.id); + setSelectedStoryId(null); + } + const selectedStory = + stories.find((s) => s.id === selectedStoryId) ?? null; + + if (!epic) return null; + + const inDetailView = selectedStory !== null; + + return ( + + + + {inDetailView ? ( + + ) : ( + <> +
+ + {epic.id} + + {epic.title} +
+ {epic.description && ( + + {epic.description} + + )} +
+
+ + {epic.completedStories}/{epic.totalStories} stories + + {epic.progressPercent}% +
+ = 100 + ? "bg-success" + : epic.progressPercent > 0 + ? "bg-info" + : "bg-muted-foreground" + } + className="h-1.5" + /> +
+ + )} +
+ +
+ {inDetailView && selectedStory ? ( + + ) : stories.length === 0 ? ( +
+

+ No story found for this epic +

+
+ ) : ( +
+ {stories.map((story) => ( + setSelectedStoryId(story.id)} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + setSelectedStoryId(story.id); + } + }} + className="glass-card cursor-pointer hover:shadow-md hover:border-primary/30 transition-all duration-200" + aria-label={`Open story ${story.id}: ${story.title}`} + > + +
+
+ + {story.id} + + + {story.title} + +
+
+ {story.totalTasks > 0 && ( + + {story.completedTasks}/{story.totalTasks} + + )} + +
+
+
+
+ ))} +
+ )} +
+
+
+ ); +} diff --git a/src/components/epics/epics-browser.tsx b/src/components/epics/epics-browser.tsx index 944b0a4..f694a49 100644 --- a/src/components/epics/epics-browser.tsx +++ b/src/components/epics/epics-browser.tsx @@ -1,16 +1,21 @@ "use client"; import { useState, useMemo, useCallback, useEffect } from "react"; +import { Button } from "@/components/ui/button"; import { Card, CardContent } from "@/components/ui/card"; import { StatusBadge } from "@/components/shared/status-badge"; import { ProgressRing } from "@/components/shared/progress-ring"; import { SegmentedProgressBar } from "@/components/shared/segmented-progress-bar"; import { EpicsTimeline } from "./epics-timeline"; +import { EpicsKanban } from "./epics-kanban"; +import { EpicStoriesSheet } from "./epic-stories-sheet"; import { StoryDetailView } from "./story-detail-view"; import { useBreadcrumb } from "@/contexts/breadcrumb-context"; -import { ArrowLeft } from "lucide-react"; +import { ArrowLeft, GanttChartSquare, Columns3 } from "lucide-react"; import type { Epic, StoryDetail } from "@/lib/bmad/types"; +type EpicsLayout = "timeline" | "kanban"; + type View = "epics" | "stories" | "story"; interface EpicsBrowserProps { @@ -31,8 +36,28 @@ export function EpicsBrowser({ const [view, setView] = useState("epics"); const [selectedEpicId, setSelectedEpicId] = useState(null); const [selectedStoryId, setSelectedStoryId] = useState(null); + const [layout, setLayout] = useState("timeline"); + const [sheetEpicId, setSheetEpicId] = useState(null); const { setExtraSegments, clearExtraSegments } = useBreadcrumb(); + const sheetEpic = useMemo( + () => epics.find((e) => e.id === sheetEpicId) ?? null, + [epics, sheetEpicId], + ); + + const sheetEpicStories = useMemo( + () => (sheetEpicId ? stories.filter((s) => s.epicId === sheetEpicId) : []), + [stories, sheetEpicId], + ); + + const openEpicSheet = useCallback((epicId: string) => { + setSheetEpicId(epicId); + }, []); + + const handleSheetOpenChange = useCallback((open: boolean) => { + if (!open) setSheetEpicId(null); + }, []); + const selectedEpic = useMemo( () => epics.find((e) => e.id === selectedEpicId) ?? null, [epics, selectedEpicId], @@ -171,11 +196,49 @@ export function EpicsBrowser({ )}
- {/* Active view */} + {/* Layout toggle (only on the epics list view) */} {view === "epics" && ( +
+
+ + +
+
+ )} + + {/* Active view */} + {view === "epics" && layout === "timeline" && ( )} + {view === "epics" && layout === "kanban" && ( + + )} + {view === "stories" && selectedEpic && (
{/* Epic header summary */} @@ -274,6 +337,13 @@ export function EpicsBrowser({ )}
+ +
); } diff --git a/src/components/epics/epics-kanban.tsx b/src/components/epics/epics-kanban.tsx new file mode 100644 index 0000000..a785c2a --- /dev/null +++ b/src/components/epics/epics-kanban.tsx @@ -0,0 +1,110 @@ +"use client"; + +import { Card, CardContent } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { StatusBadge } from "@/components/shared/status-badge"; +import { SegmentedProgressBar } from "@/components/shared/segmented-progress-bar"; +import { StaggeredList, StaggeredItem } from "@/components/shared/staggered-list"; +import type { Epic, EpicStatus } from "@/lib/bmad/types"; + +const kanbanColumns: { status: EpicStatus; label: string; color: string }[] = [ + { status: "not-started", label: "Planning", color: "bg-muted-foreground" }, + { status: "in-progress", label: "In Progress", color: "bg-info" }, + { status: "done", label: "Done", color: "bg-success" }, +]; + +interface EpicsKanbanProps { + epics: Epic[]; + onSelectEpic: (epicId: string) => void; +} + +export function EpicsKanban({ epics, onSelectEpic }: EpicsKanbanProps) { + if (epics.length === 0) { + return ( +
+ No epic found in this project +
+ ); + } + + return ( + + {kanbanColumns.map((col) => { + const columnEpics = epics.filter((e) => e.status === col.status); + return ( + +
+ + +
+ {columnEpics.map((epic) => ( + onSelectEpic(epic.id)} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + onSelectEpic(epic.id); + } + }} + className="glass-card cursor-pointer hover:shadow-md hover:border-primary/30 transition-all duration-200" + aria-label={`Open epic ${epic.id}: ${epic.title}`} + > + +
+
+ + {epic.id} + + + {epic.title} + +
+ +
+ +
+
+ + {epic.completedStories}/{epic.totalStories} stories + + {epic.progressPercent}% +
+ = 100 + ? "bg-success" + : epic.progressPercent > 0 + ? "bg-info" + : "bg-muted-foreground" + } + className="h-1.5" + /> +
+
+
+ ))} + + {columnEpics.length === 0 && ( +
+ No epics +
+ )} +
+ + ); + })} + + ); +} From 0806ee55aec544b1f86f43f62ff3f96e6df277c7 Mon Sep 17 00:00:00 2001 From: Hichem Date: Fri, 8 May 2026 22:13:18 +0200 Subject: [PATCH 2/2] fix(a11y): keep SheetTitle mounted in story-detail view Radix Dialog requires a Title to be mounted at all times for the panel's accessible name. The previous code unmounted SheetTitle when swapping the sheet body to the story-detail view, leaving the panel without an accessible name and triggering a Radix warning. Now a `Story {id}: {title}` stays mounted in detail view alongside the visible "Back to stories" button. The visible heading inside StoryDetailView remains the primary on-screen title for sighted users; the sr-only one only services screen readers and Radix's accessibility contract. --- src/components/epics/epic-stories-sheet.tsx | 28 +++++++++++++-------- 1 file changed, 18 insertions(+), 10 deletions(-) diff --git a/src/components/epics/epic-stories-sheet.tsx b/src/components/epics/epic-stories-sheet.tsx index 7ce3d0b..df361d3 100644 --- a/src/components/epics/epic-stories-sheet.tsx +++ b/src/components/epics/epic-stories-sheet.tsx @@ -53,16 +53,24 @@ export function EpicStoriesSheet({ className="w-full sm:max-w-2xl overflow-y-auto" > - {inDetailView ? ( - + {inDetailView && selectedStory ? ( + <> + {/* SheetTitle stays mounted for the Radix Dialog accessible + name; the visible header in StoryDetailView already + displays the same information for sighted users. */} + + Story {selectedStory.id}: {selectedStory.title} + + + ) : ( <>