@reqord/web 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/package.json +2 -2
  2. package/src/__tests__/components/dashboard/critical-path-display.test.tsx +61 -0
  3. package/src/__tests__/components/dashboard/progress-bar.test.tsx +63 -0
  4. package/src/__tests__/components/dashboard/project-health.test.tsx +21 -7
  5. package/src/__tests__/components/dashboard/status-card.test.tsx +86 -0
  6. package/src/__tests__/components/dashboard/warning-alert.test.tsx +6 -6
  7. package/src/__tests__/components/feedback/feedback-filters-improved.test.tsx +33 -0
  8. package/src/__tests__/components/graph/drilldown-breadcrumb.test.tsx +12 -0
  9. package/src/__tests__/components/graph/edge-styles.test.ts +6 -6
  10. package/src/__tests__/components/graph/issue-node.test.tsx +25 -6
  11. package/src/__tests__/components/graph/requirement-node.test.tsx +45 -0
  12. package/src/__tests__/components/graph/specification-node.test.tsx +27 -14
  13. package/src/__tests__/components/requirement/requirement-table.test.tsx +165 -0
  14. package/src/__tests__/components/specification/specification-table.test.tsx +189 -0
  15. package/src/__tests__/components/ui/badge.test.tsx +98 -0
  16. package/src/__tests__/components/ui/button.test.tsx +98 -0
  17. package/src/__tests__/components/ui/card.test.tsx +58 -0
  18. package/src/__tests__/components/ui/nav.test.tsx +91 -0
  19. package/src/__tests__/components/ui/tabs.test.tsx +53 -0
  20. package/src/__tests__/lib/drilldown-graph-data.test.ts +45 -3
  21. package/src/app/dashboard/page.tsx +29 -21
  22. package/src/app/globals.css +46 -0
  23. package/src/app/layout.tsx +4 -1
  24. package/src/app/requirements/loading.tsx +30 -5
  25. package/src/app/specifications/loading.tsx +29 -5
  26. package/src/components/dashboard/critical-path-display.tsx +30 -15
  27. package/src/components/dashboard/progress-bar.tsx +2 -4
  28. package/src/components/dashboard/project-health.tsx +9 -10
  29. package/src/components/dashboard/status-card.tsx +20 -9
  30. package/src/components/dashboard/warning-alert.tsx +57 -5
  31. package/src/components/feedback/feedback-filters.tsx +41 -12
  32. package/src/components/graph/drilldown-breadcrumb.tsx +1 -1
  33. package/src/components/graph/drilldown-graph.tsx +3 -1
  34. package/src/components/graph/edge-styles.ts +3 -3
  35. package/src/components/graph/issue-node.tsx +7 -7
  36. package/src/components/graph/multi-level-graph.tsx +2 -2
  37. package/src/components/graph/requirement-node.tsx +5 -5
  38. package/src/components/graph/specification-node.tsx +12 -9
  39. package/src/components/requirement/requirement-table.tsx +62 -18
  40. package/src/components/specification/specification-table.tsx +59 -17
  41. package/src/components/ui/badge.tsx +4 -4
  42. package/src/components/ui/button.tsx +43 -0
  43. package/src/components/ui/card.tsx +25 -0
  44. package/src/components/ui/nav.tsx +35 -35
  45. package/src/components/ui/tabs.tsx +2 -0
  46. package/src/lib/drilldown-graph-data.ts +23 -4
@@ -0,0 +1,91 @@
1
+ // @vitest-environment jsdom
2
+ import React from "react";
3
+ import { describe, it, expect, afterEach, vi } from "vitest";
4
+ import { render, screen, cleanup } from "@testing-library/react";
5
+ import "@testing-library/jest-dom/vitest";
6
+ import { Nav } from "../../../components/ui/nav";
7
+
8
+ vi.mock("next/navigation", () => ({
9
+ usePathname: () => "/requirements",
10
+ }));
11
+
12
+ vi.mock("next/link", () => ({
13
+ default: ({
14
+ href,
15
+ children,
16
+ className,
17
+ }: {
18
+ href: string;
19
+ children: React.ReactNode;
20
+ className?: string;
21
+ }) => (
22
+ <a href={href} className={className}>
23
+ {children}
24
+ </a>
25
+ ),
26
+ }));
27
+
28
+ describe("Nav", () => {
29
+ afterEach(() => {
30
+ cleanup();
31
+ });
32
+
33
+ it("renders all navigation items", () => {
34
+ render(<Nav />);
35
+
36
+ expect(screen.getByText("Dashboard")).toBeInTheDocument();
37
+ expect(screen.getByText("Requirements")).toBeInTheDocument();
38
+ expect(screen.getByText("Specifications")).toBeInTheDocument();
39
+ expect(screen.getByText("Feedback")).toBeInTheDocument();
40
+ expect(screen.getByText("Graph")).toBeInTheDocument();
41
+ });
42
+
43
+ it("renders the Reqord brand link", () => {
44
+ render(<Nav />);
45
+
46
+ const brandLogo = screen.getByAltText("Reqord");
47
+ expect(brandLogo).toBeInTheDocument();
48
+ expect(brandLogo.closest("a")).toHaveAttribute("href", "/dashboard");
49
+ });
50
+
51
+ it("applies active styles to the current path link", () => {
52
+ render(<Nav />);
53
+
54
+ const requirementsLink = screen.getByText("Requirements").closest("a");
55
+ expect(requirementsLink).toHaveClass("bg-warm-200");
56
+ expect(requirementsLink).toHaveClass("text-warm-900");
57
+ });
58
+
59
+ it("applies inactive styles to non-active links", () => {
60
+ render(<Nav />);
61
+
62
+ const dashboardLink = screen.getByText("Dashboard").closest("a");
63
+ expect(dashboardLink).toHaveClass("text-warm-700");
64
+ expect(dashboardLink).not.toHaveClass("bg-warm-200");
65
+ });
66
+
67
+ it("each nav item has correct href", () => {
68
+ render(<Nav />);
69
+
70
+ expect(screen.getByText("Dashboard").closest("a")).toHaveAttribute(
71
+ "href",
72
+ "/dashboard"
73
+ );
74
+ expect(screen.getByText("Requirements").closest("a")).toHaveAttribute(
75
+ "href",
76
+ "/requirements"
77
+ );
78
+ expect(screen.getByText("Specifications").closest("a")).toHaveAttribute(
79
+ "href",
80
+ "/specifications"
81
+ );
82
+ expect(screen.getByText("Feedback").closest("a")).toHaveAttribute(
83
+ "href",
84
+ "/feedback"
85
+ );
86
+ expect(screen.getByText("Graph").closest("a")).toHaveAttribute(
87
+ "href",
88
+ "/graph"
89
+ );
90
+ });
91
+ });
@@ -0,0 +1,53 @@
1
+ // @vitest-environment jsdom
2
+ import React from "react";
3
+ import { describe, it, expect, vi, afterEach } from "vitest";
4
+ import { render, screen, cleanup, fireEvent } from "@testing-library/react";
5
+ import "@testing-library/jest-dom/vitest";
6
+ import { Tabs } from "../../../components/ui/tabs";
7
+
8
+ const tabs = [
9
+ { id: "design", label: "Design" },
10
+ { id: "research", label: "Research" },
11
+ { id: "coverage", label: "Coverage" },
12
+ ];
13
+
14
+ describe("Tabs - アクセシビリティ", () => {
15
+ afterEach(() => cleanup());
16
+
17
+ it("各タブボタンにrole='tab'が設定されている", () => {
18
+ render(<Tabs tabs={tabs} activeTab="design" onTabChange={vi.fn()} />);
19
+
20
+ const tabButtons = screen.getAllByRole("tab");
21
+ expect(tabButtons).toHaveLength(3);
22
+ });
23
+
24
+ it("アクティブなタブのaria-selected='true'が設定される", () => {
25
+ render(<Tabs tabs={tabs} activeTab="design" onTabChange={vi.fn()} />);
26
+
27
+ const designTab = screen.getByRole("tab", { name: "Design" });
28
+ expect(designTab).toHaveAttribute("aria-selected", "true");
29
+ });
30
+
31
+ it("非アクティブなタブのaria-selected='false'が設定される", () => {
32
+ render(<Tabs tabs={tabs} activeTab="design" onTabChange={vi.fn()} />);
33
+
34
+ const researchTab = screen.getByRole("tab", { name: "Research" });
35
+ expect(researchTab).toHaveAttribute("aria-selected", "false");
36
+ });
37
+
38
+ it("タブクリックでonTabChangeが呼ばれる", () => {
39
+ const onTabChange = vi.fn();
40
+ render(<Tabs tabs={tabs} activeTab="design" onTabChange={onTabChange} />);
41
+
42
+ fireEvent.click(screen.getByRole("tab", { name: "Research" }));
43
+ expect(onTabChange).toHaveBeenCalledWith("research");
44
+ });
45
+
46
+ it("全タブのラベルが表示される", () => {
47
+ render(<Tabs tabs={tabs} activeTab="design" onTabChange={vi.fn()} />);
48
+
49
+ expect(screen.getByText("Design")).toBeInTheDocument();
50
+ expect(screen.getByText("Research")).toBeInTheDocument();
51
+ expect(screen.getByText("Coverage")).toBeInTheDocument();
52
+ });
53
+ });
@@ -107,7 +107,7 @@ describe("buildDrillDownGraphData", () => {
107
107
  expect(result.edges).toHaveLength(2);
108
108
  });
109
109
 
110
- it("positions spec nodes at x=400 with vertical gap of 120", () => {
110
+ it("positions spec nodes at x=400 with vertical gap of 120 when no tasks", () => {
111
111
  const req = makeReq("req-000001");
112
112
  const spec1 = makeSpec("spec-000001", "req-000001");
113
113
  const spec2 = makeSpec("spec-000002", "req-000001");
@@ -202,7 +202,7 @@ describe("buildDrillDownGraphData", () => {
202
202
  });
203
203
  });
204
204
 
205
- it("positions issues based on spec index and task index", () => {
205
+ it("positions issues without overlap when spec has many tasks", () => {
206
206
  const req = makeReq("req-000001");
207
207
  const spec1 = makeSpec("spec-000001", "req-000001");
208
208
  const spec2 = makeSpec("spec-000002", "req-000001");
@@ -214,9 +214,51 @@ describe("buildDrillDownGraphData", () => {
214
214
 
215
215
  const issueNodes = result.nodes.filter((n) => n.type === "issue");
216
216
  expect(issueNodes).toHaveLength(3);
217
+ // spec1 issues start at y=0
217
218
  expect(issueNodes[0].position).toEqual({ x: 800, y: 0 });
218
219
  expect(issueNodes[1].position).toEqual({ x: 800, y: 80 });
219
- expect(issueNodes[2].position).toEqual({ x: 800, y: 120 });
220
+ // spec2 is pushed down so its issues don't overlap with spec1's
221
+ // spec1 had 2 tasks → occupies 2*80=160px, so spec2 starts at max(120, 160)=160
222
+ expect(issueNodes[2].position).toEqual({ x: 800, y: 160 });
223
+ });
224
+
225
+ it("pushes spec nodes down when previous spec has many tasks", () => {
226
+ const req = makeReq("req-000001");
227
+ const spec1 = makeSpec("spec-000001", "req-000001");
228
+ const spec2 = makeSpec("spec-000002", "req-000001");
229
+ const tasks = [
230
+ makeTask(1, ["spec-000001"]),
231
+ makeTask(2, ["spec-000001"]),
232
+ makeTask(3, ["spec-000001"]),
233
+ makeTask(4, ["spec-000001"]),
234
+ ];
235
+
236
+ const result = buildDrillDownGraphData(req, [spec1, spec2], tasks);
237
+
238
+ const specNodes = result.nodes.filter((n) => n.type === "specification");
239
+ // spec1 has 4 tasks → occupies 4*80=320px, which exceeds default gap of 120
240
+ // so spec2 is pushed to y=320
241
+ expect(specNodes[0].position).toEqual({ x: 400, y: 0 });
242
+ expect(specNodes[1].position).toEqual({ x: 400, y: 320 });
243
+ });
244
+
245
+ it("centers requirement node vertically relative to all specs", () => {
246
+ const req = makeReq("req-000001");
247
+ const spec1 = makeSpec("spec-000001", "req-000001");
248
+ const spec2 = makeSpec("spec-000002", "req-000001");
249
+ const tasks = [
250
+ makeTask(1, ["spec-000001"]),
251
+ makeTask(2, ["spec-000001"]),
252
+ makeTask(3, ["spec-000001"]),
253
+ makeTask(4, ["spec-000001"]),
254
+ ];
255
+
256
+ const result = buildDrillDownGraphData(req, [spec1, spec2], tasks);
257
+
258
+ const reqNode = result.nodes.find((n) => n.type === "requirement");
259
+ const specNodes = result.nodes.filter((n) => n.type === "specification");
260
+ const lastSpecY = specNodes[specNodes.length - 1].position.y; // 320
261
+ expect(reqNode?.position.y).toBe(lastSpecY / 2); // 160
220
262
  });
221
263
 
222
264
  it("does not create issue nodes when no tasks are linked to spec", () => {
@@ -11,32 +11,40 @@ async function DashboardContent() {
11
11
  const data = await getDashboardData();
12
12
 
13
13
  return (
14
- <div className="mx-auto max-w-7xl space-y-6 px-4 py-8 sm:px-6 lg:px-8">
14
+ <div className="space-y-6 py-2">
15
15
  <h1 className="text-3xl font-bold text-gray-900">Dashboard</h1>
16
16
 
17
- {/* Project Health */}
18
- <ProjectHealth score={data.healthScore} />
19
-
20
- {/* Progress Section */}
21
- <ProgressSection
22
- requirements={data.requirements}
23
- specifications={data.specifications}
24
- issues={data.issues}
25
- />
26
-
27
- {/* Status Cards */}
28
- <StatusCards
29
- requirements={data.requirements}
30
- specifications={data.specifications}
31
- />
32
-
33
- {/* Warnings */}
34
- {data.warnings.length > 0 && <WarningAlerts warnings={data.warnings} />}
17
+ {/* Overview zone: Project Health + Progress */}
18
+ <div className="space-y-4">
19
+ <ProjectHealth score={data.healthScore} />
20
+ <ProgressSection
21
+ requirements={data.requirements}
22
+ specifications={data.specifications}
23
+ issues={data.issues}
24
+ />
25
+ </div>
26
+
27
+ {/* Action zone: Warnings */}
28
+ {data.warnings.length > 0 && (
29
+ <div className="mt-10">
30
+ <WarningAlerts warnings={data.warnings} />
31
+ </div>
32
+ )}
35
33
 
36
- {/* Critical Path */}
34
+ {/* Action zone: Critical Path */}
37
35
  {data.criticalPath !== null && (
38
- <CriticalPathDisplay items={data.criticalPath} />
36
+ <div className="mt-10">
37
+ <CriticalPathDisplay items={data.criticalPath} />
38
+ </div>
39
39
  )}
40
+
41
+ {/* Detail zone: Status Cards */}
42
+ <div className="mt-10">
43
+ <StatusCards
44
+ requirements={data.requirements}
45
+ specifications={data.specifications}
46
+ />
47
+ </div>
40
48
  </div>
41
49
  );
42
50
  }
@@ -1,2 +1,48 @@
1
1
  @import "tailwindcss";
2
2
  @plugin "@tailwindcss/typography";
3
+
4
+ @theme {
5
+ /* ロゴ由来のアクセント — ポイント使い専用 */
6
+ --color-accent: #ff3b2f;
7
+ --color-accent-hover: #e5342a;
8
+
9
+ /* 暖色ニュートラル — ロゴの #f5f2ed/#1a1a1a ベース */
10
+ --color-warm-50: #FAF8F5;
11
+ --color-warm-100: #F5F2ED;
12
+ --color-warm-200: #E8E4DD;
13
+ --color-warm-300: #D4CFC7;
14
+ --color-warm-700: #4A3F35;
15
+ --color-warm-800: #33291F;
16
+ --color-warm-900: #1A1A1A;
17
+
18
+ /* brand = warm(UIの主色調) */
19
+ --color-brand-50: #FAF8F5;
20
+ --color-brand-100: #F5F2ED;
21
+ --color-brand-200: #E8E4DD;
22
+ --color-brand-500: #8C7E6F;
23
+ --color-brand-600: #6B5D4F;
24
+ --color-brand-700: #4A3F35;
25
+ --color-brand-800: #33291F;
26
+ --color-brand-900: #1A1A1A;
27
+ }
28
+
29
+ /* ページ遷移アニメーション */
30
+ main > * {
31
+ animation: fadeIn 0.2s ease-out;
32
+ }
33
+ @keyframes fadeIn {
34
+ from { opacity: 0; transform: translateY(8px); }
35
+ to { opacity: 1; transform: translateY(0); }
36
+ }
37
+ @media (prefers-reduced-motion: reduce) {
38
+ main > * {
39
+ animation: none;
40
+ }
41
+ }
42
+
43
+ /* グローバルフォーカスインジケータ */
44
+ :focus-visible {
45
+ outline: 2px solid var(--color-brand-500);
46
+ outline-offset: 2px;
47
+ border-radius: 2px;
48
+ }
@@ -1,7 +1,10 @@
1
1
  import type { Metadata } from "next";
2
+ import { Inter } from "next/font/google";
2
3
  import { Nav } from "@/components/ui/nav";
3
4
  import "./globals.css";
4
5
 
6
+ const inter = Inter({ subsets: ["latin"], display: "swap" });
7
+
5
8
  export const metadata: Metadata = {
6
9
  title: "Reqord",
7
10
  description: "Requirements management UI",
@@ -14,7 +17,7 @@ export default function RootLayout({
14
17
  }) {
15
18
  return (
16
19
  <html lang="ja">
17
- <body className="bg-gray-50 text-gray-900 antialiased">
20
+ <body className={`${inter.className} bg-warm-50 text-warm-900 antialiased`}>
18
21
  <Nav />
19
22
  <main className="mx-auto max-w-7xl px-4 py-6 sm:px-6 lg:px-8">
20
23
  {children}
@@ -2,11 +2,36 @@ export default function Loading() {
2
2
  return (
3
3
  <div className="animate-pulse space-y-4">
4
4
  <div className="h-8 w-48 rounded bg-gray-200" />
5
- <div className="h-10 w-full rounded bg-gray-200" />
6
- <div className="space-y-2">
7
- {Array.from({ length: 8 }, (_, i) => (
8
- <div key={i} className="h-12 w-full rounded bg-gray-200" />
9
- ))}
5
+ {/* Filter bar skeleton */}
6
+ <div className="flex gap-3">
7
+ <div className="h-10 w-64 rounded-md bg-gray-200" />
8
+ <div className="h-10 w-32 rounded-md bg-gray-200" />
9
+ <div className="h-10 w-32 rounded-md bg-gray-200" />
10
+ </div>
11
+ {/* Table skeleton */}
12
+ <div className="overflow-hidden rounded-lg border border-gray-200">
13
+ {/* Table header skeleton */}
14
+ <div className="flex gap-4 border-b-2 border-gray-200 bg-gray-50 px-4 py-3">
15
+ <div className="h-4 w-16 rounded bg-gray-300" />
16
+ <div className="h-4 w-48 rounded bg-gray-300" />
17
+ <div className="h-4 w-20 rounded bg-gray-300" />
18
+ <div className="h-4 w-20 rounded bg-gray-300" />
19
+ <div className="h-4 w-20 rounded bg-gray-300" />
20
+ <div className="h-4 w-24 rounded bg-gray-300" />
21
+ </div>
22
+ {/* Table rows skeleton */}
23
+ <div className="divide-y divide-gray-200">
24
+ {Array.from({ length: 8 }, (_, i) => (
25
+ <div key={i} className="flex gap-4 px-4 py-3.5">
26
+ <div className="h-4 w-24 rounded bg-gray-200" />
27
+ <div className="h-4 w-64 rounded bg-gray-200" />
28
+ <div className="h-5 w-20 rounded-full bg-gray-200" />
29
+ <div className="h-5 w-16 rounded-full bg-gray-200" />
30
+ <div className="h-5 w-8 rounded-full bg-gray-200" />
31
+ <div className="h-4 w-20 rounded bg-gray-200" />
32
+ </div>
33
+ ))}
34
+ </div>
10
35
  </div>
11
36
  </div>
12
37
  );
@@ -2,11 +2,35 @@ export default function Loading() {
2
2
  return (
3
3
  <div className="animate-pulse space-y-4">
4
4
  <div className="h-8 w-48 rounded bg-gray-200" />
5
- <div className="h-10 w-full rounded bg-gray-200" />
6
- <div className="space-y-2">
7
- {Array.from({ length: 8 }, (_, i) => (
8
- <div key={i} className="h-12 w-full rounded bg-gray-200" />
9
- ))}
5
+ {/* Filter bar skeleton */}
6
+ <div className="flex gap-3">
7
+ <div className="h-10 w-64 rounded-md bg-gray-200" />
8
+ <div className="h-10 w-32 rounded-md bg-gray-200" />
9
+ </div>
10
+ {/* Table skeleton */}
11
+ <div className="overflow-hidden rounded-lg border border-gray-200">
12
+ {/* Table header skeleton */}
13
+ <div className="flex gap-4 border-b-2 border-gray-200 bg-gray-50 px-4 py-3">
14
+ <div className="h-4 w-16 rounded bg-gray-300" />
15
+ <div className="h-4 w-48 rounded bg-gray-300" />
16
+ <div className="h-4 w-32 rounded bg-gray-300" />
17
+ <div className="h-4 w-20 rounded bg-gray-300" />
18
+ <div className="h-4 w-16 rounded bg-gray-300" />
19
+ <div className="h-4 w-24 rounded bg-gray-300" />
20
+ </div>
21
+ {/* Table rows skeleton */}
22
+ <div className="divide-y divide-gray-200">
23
+ {Array.from({ length: 8 }, (_, i) => (
24
+ <div key={i} className="flex gap-4 px-4 py-3.5">
25
+ <div className="h-4 w-24 rounded bg-gray-200" />
26
+ <div className="h-4 w-64 rounded bg-gray-200" />
27
+ <div className="h-4 w-32 rounded bg-gray-200" />
28
+ <div className="h-5 w-20 rounded-full bg-gray-200" />
29
+ <div className="h-4 w-12 rounded bg-gray-200" />
30
+ <div className="h-4 w-20 rounded bg-gray-200" />
31
+ </div>
32
+ ))}
33
+ </div>
10
34
  </div>
11
35
  </div>
12
36
  );
@@ -9,6 +9,13 @@ type CriticalPathDisplayProps = {
9
9
 
10
10
  const INITIAL_DISPLAY_COUNT = 10;
11
11
 
12
+ const PRIORITY_BADGE_CLASSES: Record<string, string> = {
13
+ P0: "bg-red-100 text-red-800",
14
+ P1: "bg-red-100 text-red-800",
15
+ P2: "bg-orange-100 text-orange-800",
16
+ P3: "bg-gray-100 text-gray-800",
17
+ };
18
+
12
19
  export function CriticalPathDisplay({ items }: CriticalPathDisplayProps) {
13
20
  const openItems = items.filter((item) => item.status !== "closed");
14
21
  const closedItems = items.filter((item) => item.status === "closed");
@@ -23,10 +30,12 @@ export function CriticalPathDisplay({ items }: CriticalPathDisplayProps) {
23
30
  </span>
24
31
  </h2>
25
32
  {/* Show only open items when they exist; closed items are accessible via the expand button below */}
26
- <div className="space-y-3">
33
+ <div className="space-y-2">
27
34
  {(openItems.length > 0
28
35
  ? openItems
29
- : (showClosed ? closedItems : closedItems.slice(0, INITIAL_DISPLAY_COUNT))
36
+ : showClosed
37
+ ? closedItems
38
+ : closedItems.slice(0, INITIAL_DISPLAY_COUNT)
30
39
  ).map((item) => {
31
40
  const isCompleted = item.status === "closed";
32
41
  const isPending =
@@ -39,24 +48,30 @@ export function CriticalPathDisplay({ items }: CriticalPathDisplayProps) {
39
48
  textClass = "font-bold";
40
49
  }
41
50
 
51
+ const badgeClass =
52
+ PRIORITY_BADGE_CLASSES[item.priority] ?? "bg-gray-100 text-gray-800";
53
+
42
54
  return (
43
55
  <div
44
56
  key={item.issueNumber}
45
57
  data-testid="critical-path-item"
46
- className={`flex items-start gap-3 rounded-md border border-gray-200 p-3 ${textClass}`}
58
+ className={`flex items-center gap-3 rounded-md border border-gray-200 px-3 py-2 ${textClass}`}
47
59
  >
48
- <div className="flex-1">
49
- <div className="flex items-center gap-2">
50
- <span className="text-sm font-medium text-gray-900">
51
- #{item.issueNumber}
52
- </span>
53
- <span className="rounded-full bg-blue-100 px-2 py-0.5 text-xs font-medium text-blue-800">
54
- {item.priority}
55
- </span>
56
- </div>
57
- <p className="mt-1 text-sm text-gray-700">{item.title}</p>
58
- <p className="mt-1 text-xs text-gray-500">{item.status}</p>
59
- </div>
60
+ <a
61
+ href={item.url}
62
+ target="_blank"
63
+ rel="noopener noreferrer"
64
+ className="text-sm font-medium text-blue-600 hover:underline"
65
+ >
66
+ #{item.issueNumber}
67
+ </a>
68
+ <span
69
+ className={`rounded-full px-2 py-0.5 text-xs font-medium ${badgeClass}`}
70
+ >
71
+ {item.priority}
72
+ </span>
73
+ <span className="flex-1 text-sm text-gray-700">{item.title}</span>
74
+ <span className="text-xs text-gray-400">{item.status}</span>
60
75
  </div>
61
76
  );
62
77
  })}
@@ -1,5 +1,3 @@
1
- "use client";
2
-
3
1
  import React from "react";
4
2
 
5
3
  type ProgressBarProps = {
@@ -33,10 +31,10 @@ export function ProgressBar({
33
31
  {current}/{total} ({percentage}%)
34
32
  </span>
35
33
  </div>
36
- <div className="h-2 overflow-hidden rounded-full bg-gray-200">
34
+ <div className="h-3 overflow-hidden rounded-full bg-gray-100">
37
35
  <div
38
36
  data-testid="progress-bar-fill"
39
- className={`h-full ${bgClass} transition-all duration-300`}
37
+ className={`h-full rounded-full ${bgClass} transition-all duration-500 ease-out`}
40
38
  style={{ width: `${percentage}%` }}
41
39
  />
42
40
  </div>
@@ -1,5 +1,3 @@
1
- "use client";
2
-
3
1
  import React from "react";
4
2
 
5
3
  type ProjectHealthProps = {
@@ -9,26 +7,27 @@ type ProjectHealthProps = {
9
7
  export function ProjectHealth({ score }: ProjectHealthProps) {
10
8
  const roundedScore = Math.round(score);
11
9
 
12
- let colorClass = "text-red-500";
10
+ let colorClass = "text-red-600";
13
11
  if (roundedScore >= 80) {
14
- colorClass = "text-green-500";
12
+ colorClass = "text-emerald-600";
15
13
  } else if (roundedScore >= 50) {
16
- colorClass = "text-yellow-500";
14
+ colorClass = "text-yellow-600";
17
15
  }
18
16
 
19
17
  return (
20
- <div className="rounded-lg border border-gray-200 bg-white p-6">
21
- <h2 className="mb-4 text-lg font-semibold text-gray-900">
18
+ <div className="relative overflow-hidden rounded-2xl border border-warm-200 bg-warm-100 p-8 shadow-sm">
19
+ <div className="absolute left-0 top-0 h-full w-1.5 bg-accent" />
20
+ <h2 className="text-sm font-medium uppercase tracking-wider text-warm-700">
22
21
  Project Health
23
22
  </h2>
24
- <div className="flex items-baseline justify-center">
23
+ <div className="mt-4 flex items-baseline justify-center">
25
24
  <span
26
25
  data-testid="health-score"
27
- className={`text-6xl font-bold ${colorClass}`}
26
+ className={`text-7xl font-extrabold tabular-nums ${colorClass}`}
28
27
  >
29
28
  {roundedScore}
30
29
  </span>
31
- <span className="ml-2 text-2xl text-gray-500">/ 100</span>
30
+ <span className="ml-2 text-2xl text-warm-500">/ 100</span>
32
31
  </div>
33
32
  </div>
34
33
  );
@@ -7,20 +7,31 @@ type StatusCardProps = {
7
7
  breakdown: StatusBreakdown;
8
8
  };
9
9
 
10
+ const STATUS_BADGE_CLASSES: Record<string, string> = {
11
+ draft: "bg-gray-100 text-gray-600",
12
+ approved: "bg-blue-50 text-blue-700",
13
+ implemented: "bg-emerald-50 text-emerald-700",
14
+ deprecated: "bg-red-50 text-red-700",
15
+ };
16
+
10
17
  export function StatusCard({ title, total, breakdown }: StatusCardProps) {
11
18
  return (
12
- <div className="rounded-lg border border-gray-200 bg-white p-6">
19
+ <div className="rounded-xl border border-gray-200 bg-white p-6 shadow-sm hover:shadow-md transition-shadow">
13
20
  <h3 className="text-lg font-semibold text-gray-900">{title}</h3>
14
21
  <p className="mt-2 text-3xl font-bold text-gray-900">{total}</p>
15
22
  <div className="mt-4 flex flex-wrap gap-2">
16
- {Object.entries(breakdown).map(([status, count]) => (
17
- <span
18
- key={status}
19
- className="inline-flex items-center rounded-full bg-gray-100 px-3 py-1 text-xs font-medium text-gray-800"
20
- >
21
- {status}: {count}
22
- </span>
23
- ))}
23
+ {Object.entries(breakdown).map(([status, count]) => {
24
+ const badgeClass =
25
+ STATUS_BADGE_CLASSES[status] ?? "bg-gray-100 text-gray-600";
26
+ return (
27
+ <span
28
+ key={status}
29
+ className={`inline-flex items-center rounded-full px-3 py-1 text-xs font-medium ${badgeClass}`}
30
+ >
31
+ {status}: {count}
32
+ </span>
33
+ );
34
+ })}
24
35
  </div>
25
36
  </div>
26
37
  );