@reqord/web 0.1.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
@@ -1,6 +1,6 @@
1
1
  "use client";
2
2
 
3
- import { useMemo, useState } from "react";
3
+ import React, { useMemo, useState } from "react";
4
4
  import Link from "next/link";
5
5
  import type { Specification, Status } from "@reqord/shared";
6
6
  import { StatusBadge } from "@/components/ui/badge";
@@ -84,18 +84,37 @@ export function SpecificationTable({
84
84
  }
85
85
 
86
86
  function renderSortHeader(column: SortKey, label: string) {
87
- const arrow = sortKey === column ? (sortDir === "asc" ? " ↑" : " ↓") : "";
87
+ const isActive = sortKey === column;
88
88
  return (
89
89
  <th
90
90
  key={column}
91
- className="cursor-pointer px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 hover:text-gray-900"
92
- onClick={() => handleSort(column)}
91
+ scope="col"
92
+ aria-sort={isActive ? (sortDir === "asc" ? "ascending" : "descending") : undefined}
93
+ className="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500"
93
94
  >
94
- {label}{arrow}
95
+ <button
96
+ type="button"
97
+ onClick={() => handleSort(column)}
98
+ className={`inline-flex items-center gap-1 transition-colors ${
99
+ isActive ? "text-warm-900 font-semibold" : "hover:text-gray-900"
100
+ }`}
101
+ >
102
+ {label}
103
+ <span aria-hidden="true" className={`text-[10px] ${isActive ? "opacity-100" : "opacity-30"}`}>
104
+ {isActive && sortDir === "desc" ? "▼" : "▲"}
105
+ </span>
106
+ </button>
95
107
  </th>
96
108
  );
97
109
  }
98
110
 
111
+ const hasActiveFilters = search || statusFilter !== "all";
112
+
113
+ function clearAllFilters() {
114
+ setSearch("");
115
+ setStatusFilter("all");
116
+ }
117
+
99
118
  return (
100
119
  <div className="space-y-4">
101
120
  <div className="flex flex-wrap items-center gap-3">
@@ -104,12 +123,14 @@ export function SpecificationTable({
104
123
  placeholder="Search by ID, title, or requirement..."
105
124
  value={search}
106
125
  onChange={(e) => setSearch(e.target.value)}
126
+ aria-label="Search specifications by ID, title, or requirement"
107
127
  className="rounded-md border border-gray-300 px-3 py-2 text-sm shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
108
128
  />
109
129
  <select
110
130
  value={statusFilter}
111
131
  onChange={(e) => setStatusFilter(e.target.value as Status | "all")}
112
- className="rounded-md border border-gray-300 px-3 py-2 text-sm shadow-sm"
132
+ aria-label="Filter by status"
133
+ className="rounded-md border border-gray-300 px-3 py-2 text-sm shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
113
134
  >
114
135
  {STATUS_OPTIONS.map((o) => (
115
136
  <option key={o.value} value={o.value}>
@@ -124,7 +145,7 @@ export function SpecificationTable({
124
145
 
125
146
  <div className="overflow-x-auto rounded-lg border border-gray-200 bg-white shadow-sm">
126
147
  <table className="min-w-full divide-y divide-gray-200">
127
- <thead className="bg-gray-50">
148
+ <thead className="bg-gray-50 border-b-2 border-gray-200">
128
149
  <tr>
129
150
  {renderSortHeader("id", "ID")}
130
151
  {renderSortHeader("title", "Title")}
@@ -137,14 +158,35 @@ export function SpecificationTable({
137
158
  <tbody className="divide-y divide-gray-200">
138
159
  {filtered.length === 0 ? (
139
160
  <tr>
140
- <td colSpan={6} className="px-4 py-8 text-center text-sm text-gray-500">
141
- No specifications found.
161
+ <td colSpan={6} className="px-4 py-12 text-center">
162
+ <div className="text-gray-400">
163
+ <svg className="mx-auto h-12 w-12" fill="none" stroke="currentColor" viewBox="0 0 24 24">
164
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5}
165
+ d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
166
+ </svg>
167
+ <p className="mt-2 text-sm font-medium text-gray-900">No specifications found</p>
168
+ <p className="mt-1 text-sm text-gray-500">Try adjusting your search or filter criteria</p>
169
+ {hasActiveFilters && (
170
+ <button
171
+ type="button"
172
+ onClick={clearAllFilters}
173
+ className="mt-3 text-sm font-medium text-blue-600 hover:text-blue-800"
174
+ >
175
+ Clear all filters
176
+ </button>
177
+ )}
178
+ </div>
142
179
  </td>
143
180
  </tr>
144
181
  ) : (
145
- filtered.map((spec) => (
146
- <tr key={spec.id} className="hover:bg-gray-50">
147
- <td className="whitespace-nowrap px-4 py-3 text-sm font-mono">
182
+ filtered.map((spec, index) => (
183
+ <tr
184
+ key={spec.id}
185
+ className={`hover:bg-blue-50/50 transition-colors duration-150 ${
186
+ index % 2 === 1 ? "bg-gray-50/50" : ""
187
+ }`}
188
+ >
189
+ <td className="whitespace-nowrap px-4 py-3.5 text-sm font-mono">
148
190
  <Link
149
191
  href={`/specifications/${spec.id}`}
150
192
  className="text-blue-600 hover:text-blue-800 hover:underline"
@@ -152,7 +194,7 @@ export function SpecificationTable({
152
194
  {spec.id}
153
195
  </Link>
154
196
  </td>
155
- <td className="px-4 py-3 text-sm">
197
+ <td className="px-4 py-3.5 text-sm">
156
198
  <Link
157
199
  href={`/specifications/${spec.id}`}
158
200
  className="text-gray-900 hover:text-blue-600"
@@ -160,7 +202,7 @@ export function SpecificationTable({
160
202
  {spec.title || <span className="italic text-gray-400">Untitled</span>}
161
203
  </Link>
162
204
  </td>
163
- <td className="px-4 py-3 text-sm">
205
+ <td className="px-4 py-3.5 text-sm">
164
206
  <Link
165
207
  href={`/requirements/${spec.requirementId}`}
166
208
  className="text-blue-600 hover:text-blue-800 hover:underline"
@@ -173,13 +215,13 @@ export function SpecificationTable({
173
215
  </span>
174
216
  </Link>
175
217
  </td>
176
- <td className="whitespace-nowrap px-4 py-3">
218
+ <td className="whitespace-nowrap px-4 py-3.5">
177
219
  <StatusBadge status={spec.status} />
178
220
  </td>
179
- <td className="whitespace-nowrap px-4 py-3 text-sm text-gray-500">
221
+ <td className="whitespace-nowrap px-4 py-3.5 text-sm text-gray-500">
180
222
  {spec.version}
181
223
  </td>
182
- <td className="whitespace-nowrap px-4 py-3 text-sm text-gray-500">
224
+ <td className="whitespace-nowrap px-4 py-3.5 text-sm text-gray-500">
183
225
  {new Date(spec.updatedAt).toLocaleDateString("ja-JP")}
184
226
  </td>
185
227
  </tr>
@@ -2,10 +2,10 @@ import React from "react";
2
2
  import type { Status, Priority, Complexity } from "@reqord/shared";
3
3
 
4
4
  const STATUS_STYLES: Record<Status, string> = {
5
- draft: "bg-gray-100 text-gray-700",
6
- approved: "bg-green-100 text-green-800",
7
- implemented: "bg-blue-100 text-blue-800",
8
- deprecated: "bg-red-100 text-red-700",
5
+ draft: "bg-gray-100 text-gray-600 ring-1 ring-inset ring-gray-300",
6
+ approved: "bg-blue-50 text-blue-700 ring-1 ring-inset ring-blue-200",
7
+ implemented: "bg-emerald-50 text-emerald-700 ring-1 ring-inset ring-emerald-200",
8
+ deprecated: "bg-red-50 text-red-700 ring-1 ring-inset ring-red-200",
9
9
  };
10
10
 
11
11
  const STATUS_LABELS: Record<Status, string> = {
@@ -0,0 +1,43 @@
1
+ import React from "react";
2
+
3
+ type ButtonVariant = "primary" | "secondary" | "danger" | "ghost";
4
+ type ButtonSize = "sm" | "md" | "lg";
5
+
6
+ const variantClasses: Record<ButtonVariant, string> = {
7
+ primary:
8
+ "bg-warm-800 text-white hover:bg-warm-900",
9
+ secondary:
10
+ "border border-warm-300 bg-white text-warm-700 hover:bg-warm-50",
11
+ danger: "bg-accent text-white hover:bg-accent-hover",
12
+ ghost: "text-warm-700 hover:bg-warm-200/60 hover:text-warm-900",
13
+ };
14
+
15
+ const sizeClasses: Record<ButtonSize, string> = {
16
+ sm: "px-3 py-1.5 text-xs",
17
+ md: "px-4 py-2 text-sm",
18
+ lg: "px-6 py-3 text-base",
19
+ };
20
+
21
+ export interface ButtonProps
22
+ extends React.ButtonHTMLAttributes<HTMLButtonElement> {
23
+ variant?: ButtonVariant;
24
+ size?: ButtonSize;
25
+ }
26
+
27
+ export function Button({
28
+ variant = "primary",
29
+ size = "md",
30
+ className = "",
31
+ children,
32
+ ...props
33
+ }: ButtonProps) {
34
+ return (
35
+ <button
36
+ type="button"
37
+ className={`inline-flex items-center justify-center rounded-md font-medium transition-colors focus-visible:outline-none disabled:opacity-50 ${variantClasses[variant]} ${sizeClasses[size]} ${className}`}
38
+ {...props}
39
+ >
40
+ {children}
41
+ </button>
42
+ );
43
+ }
@@ -0,0 +1,25 @@
1
+ import React from "react";
2
+
3
+ type CardVariant = "default" | "hero";
4
+
5
+ const variantClasses: Record<CardVariant, string> = {
6
+ default: "rounded-xl border border-warm-200 bg-white p-6 shadow-sm",
7
+ hero: "rounded-xl border border-warm-200 bg-warm-100 p-6 shadow-sm",
8
+ };
9
+
10
+ export interface CardProps extends React.HTMLAttributes<HTMLDivElement> {
11
+ variant?: CardVariant;
12
+ }
13
+
14
+ export function Card({
15
+ variant = "default",
16
+ className = "",
17
+ children,
18
+ ...props
19
+ }: CardProps) {
20
+ return (
21
+ <div className={`${variantClasses[variant]} ${className}`} {...props}>
22
+ {children}
23
+ </div>
24
+ );
25
+ }
@@ -1,45 +1,45 @@
1
+ "use client";
2
+ import React from "react";
1
3
  import Link from "next/link";
4
+ import { usePathname } from "next/navigation";
5
+
6
+ const NAV_ITEMS = [
7
+ { href: "/dashboard", label: "Dashboard" },
8
+ { href: "/requirements", label: "Requirements" },
9
+ { href: "/specifications", label: "Specifications" },
10
+ { href: "/feedback", label: "Feedback" },
11
+ { href: "/graph", label: "Graph" },
12
+ ];
2
13
 
3
14
  export function Nav() {
15
+ const pathname = usePathname();
4
16
  return (
5
- <nav className="border-b border-gray-200 bg-white">
17
+ <nav className="border-b border-warm-200 bg-warm-100">
6
18
  <div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
7
- <div className="flex h-14 items-center justify-between">
19
+ <div className="flex h-16 items-center justify-between">
8
20
  <div className="flex items-center gap-8">
9
- <Link href="/requirements" className="text-lg font-bold text-gray-900">
10
- Reqord
21
+ <Link href="/dashboard" className="flex items-center">
22
+ <img src="/reqord-logo.svg" alt="Reqord" className="h-10" />
11
23
  </Link>
12
- <div className="flex gap-4">
13
- <Link
14
- href="/dashboard"
15
- className="text-sm font-medium text-gray-600 hover:text-gray-900"
16
- >
17
- Dashboard
18
- </Link>
19
- <Link
20
- href="/requirements"
21
- className="text-sm font-medium text-gray-600 hover:text-gray-900"
22
- >
23
- Requirements
24
- </Link>
25
- <Link
26
- href="/specifications"
27
- className="text-sm font-medium text-gray-600 hover:text-gray-900"
28
- >
29
- Specifications
30
- </Link>
31
- <Link
32
- href="/feedback"
33
- className="text-sm font-medium text-gray-600 hover:text-gray-900"
34
- >
35
- Feedback
36
- </Link>
37
- <Link
38
- href="/graph"
39
- className="text-sm font-medium text-gray-600 hover:text-gray-900"
40
- >
41
- Graph
42
- </Link>
24
+ <div className="flex gap-1">
25
+ {NAV_ITEMS.map(({ href, label }) => {
26
+ const isActive =
27
+ pathname === href || pathname.startsWith(href + "/");
28
+ return (
29
+ <Link
30
+ key={href}
31
+ href={href}
32
+ aria-current={isActive ? "page" : undefined}
33
+ className={`rounded-md px-3 py-2 text-sm font-medium transition-colors ${
34
+ isActive
35
+ ? "bg-warm-200 text-warm-900 font-semibold"
36
+ : "text-warm-700 hover:bg-warm-200/60 hover:text-warm-900"
37
+ }`}
38
+ >
39
+ {label}
40
+ </Link>
41
+ );
42
+ })}
43
43
  </div>
44
44
  </div>
45
45
  </div>
@@ -19,6 +19,8 @@ export function Tabs({ tabs, activeTab, onTabChange }: TabsProps) {
19
19
  return (
20
20
  <button
21
21
  key={tab.id}
22
+ role="tab"
23
+ aria-selected={isActive}
22
24
  onClick={() => onTabChange(tab.id)}
23
25
  className={`
24
26
  whitespace-nowrap border-b-2 px-1 py-4 text-sm font-medium
@@ -23,11 +23,29 @@ export function buildDrillDownGraphData(
23
23
  const nodes: Node[] = [];
24
24
  const edges: Edge[] = [];
25
25
 
26
- // 1. Requirement node (left)
26
+ // Pre-calculate task counts per spec for dynamic Y positioning
27
+ const specTaskCounts = specifications.map(
28
+ (spec) =>
29
+ tasks.filter(
30
+ (t) => t.linkedTo.specifications.includes(spec.id),
31
+ ).length,
32
+ );
33
+
34
+ // Calculate cumulative Y positions for specs based on previous spec's task count
35
+ const specYPositions: number[] = [];
36
+ let currentY = 0;
37
+ for (let i = 0; i < specifications.length; i++) {
38
+ specYPositions.push(currentY);
39
+ const issueHeight = specTaskCounts[i] * LAYOUT.ISSUE_VERTICAL_GAP;
40
+ currentY += Math.max(LAYOUT.VERTICAL_GAP, issueHeight);
41
+ }
42
+
43
+ // Center requirement node vertically
44
+ const lastSpecY = specYPositions.length > 0 ? specYPositions[specYPositions.length - 1] : 0;
27
45
  nodes.push({
28
46
  id: requirement.id,
29
47
  type: "requirement",
30
- position: { x: LAYOUT.REQ_X, y: 0 },
48
+ position: { x: LAYOUT.REQ_X, y: lastSpecY / 2 },
31
49
  data: {
32
50
  label: requirement.title,
33
51
  status: requirement.status,
@@ -40,10 +58,11 @@ export function buildDrillDownGraphData(
40
58
  const addedIssueIds = new Set<string>();
41
59
  specifications.forEach((spec, i) => {
42
60
  const specNodeId = spec.id;
61
+ const specY = specYPositions[i];
43
62
  nodes.push({
44
63
  id: specNodeId,
45
64
  type: "specification",
46
- position: { x: LAYOUT.SPEC_X, y: i * LAYOUT.VERTICAL_GAP },
65
+ position: { x: LAYOUT.SPEC_X, y: specY },
47
66
  data: {
48
67
  label: spec.id,
49
68
  status: spec.status,
@@ -72,7 +91,7 @@ export function buildDrillDownGraphData(
72
91
  type: "issue",
73
92
  position: {
74
93
  x: LAYOUT.ISSUE_X,
75
- y: i * LAYOUT.VERTICAL_GAP + issueOffset * LAYOUT.ISSUE_VERTICAL_GAP,
94
+ y: specY + issueOffset * LAYOUT.ISSUE_VERTICAL_GAP,
76
95
  },
77
96
  data: {
78
97
  label: task.title,