@leanspec/ui 0.2.3 → 0.2.4

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 (187) hide show
  1. package/dist/standalone/packages/web/.next/BUILD_ID +1 -1
  2. package/dist/standalone/packages/web/.next/build-manifest.json +2 -2
  3. package/dist/standalone/packages/web/.next/prerender-manifest.json +3 -3
  4. package/dist/standalone/packages/web/.next/server/app/_global-error.html +2 -2
  5. package/dist/standalone/packages/web/.next/server/app/_global-error.rsc +1 -1
  6. package/dist/standalone/packages/web/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +1 -1
  7. package/dist/standalone/packages/web/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  8. package/dist/standalone/packages/web/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
  9. package/dist/standalone/packages/web/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  10. package/dist/standalone/packages/web/.next/server/app/_not-found/page.js.nft.json +1 -1
  11. package/dist/standalone/packages/web/.next/server/app/_not-found.html +2 -2
  12. package/dist/standalone/packages/web/.next/server/app/_not-found.rsc +2 -2
  13. package/dist/standalone/packages/web/.next/server/app/_not-found.segments/_full.segment.rsc +2 -2
  14. package/dist/standalone/packages/web/.next/server/app/_not-found.segments/_index.segment.rsc +1 -1
  15. package/dist/standalone/packages/web/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
  16. package/dist/standalone/packages/web/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
  17. package/dist/standalone/packages/web/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
  18. package/dist/standalone/packages/web/.next/server/app/api/projects/[id]/specs/route.js.nft.json +1 -1
  19. package/dist/standalone/packages/web/.next/server/app/api/projects/route.js.nft.json +1 -1
  20. package/dist/standalone/packages/web/.next/server/app/api/revalidate/route.js.nft.json +1 -1
  21. package/dist/standalone/packages/web/.next/server/app/api/specs/[id]/dependency-graph/route.js.nft.json +1 -1
  22. package/dist/standalone/packages/web/.next/server/app/api/specs/[id]/route.js.nft.json +1 -1
  23. package/dist/standalone/packages/web/.next/server/app/api/specs/[id]/subspecs/[file]/route.js.nft.json +1 -1
  24. package/dist/standalone/packages/web/.next/server/app/api/stats/route.js.nft.json +1 -1
  25. package/dist/standalone/packages/web/.next/server/app/board/page.js.nft.json +1 -1
  26. package/dist/standalone/packages/web/.next/server/app/board.html +1 -1
  27. package/dist/standalone/packages/web/.next/server/app/board.rsc +2 -2
  28. package/dist/standalone/packages/web/.next/server/app/board.segments/_full.segment.rsc +2 -2
  29. package/dist/standalone/packages/web/.next/server/app/board.segments/_index.segment.rsc +1 -1
  30. package/dist/standalone/packages/web/.next/server/app/board.segments/_tree.segment.rsc +1 -1
  31. package/dist/standalone/packages/web/.next/server/app/board.segments/board/__PAGE__.segment.rsc +1 -1
  32. package/dist/standalone/packages/web/.next/server/app/board.segments/board.segment.rsc +1 -1
  33. package/dist/standalone/packages/web/.next/server/app/page.js.nft.json +1 -1
  34. package/dist/standalone/packages/web/.next/server/app/specs/[id]/page.js.nft.json +1 -1
  35. package/dist/standalone/packages/web/.next/server/app/specs/[id]/page_client-reference-manifest.js +1 -1
  36. package/dist/standalone/packages/web/.next/server/app/specs/page.js.nft.json +1 -1
  37. package/dist/standalone/packages/web/.next/server/app/stats/page.js.nft.json +1 -1
  38. package/dist/standalone/packages/web/.next/server/chunks/[root-of-the-server]__2e0f9179._.js +1 -1
  39. package/dist/standalone/packages/web/.next/server/chunks/[root-of-the-server]__577d6d08._.js +1 -1
  40. package/dist/standalone/packages/web/.next/server/chunks/[root-of-the-server]__e54bc4b8._.js +1 -1
  41. package/dist/standalone/packages/web/.next/server/chunks/[root-of-the-server]__f8978f3e._.js +1 -1
  42. package/dist/standalone/packages/web/.next/server/chunks/ssr/[root-of-the-server]__be46bb7c._.js +1 -1
  43. package/dist/standalone/packages/web/.next/server/chunks/ssr/_7dedc302._.js +1 -1
  44. package/dist/standalone/packages/web/.next/server/chunks/ssr/_ad71cd8c._.js +1 -1
  45. package/dist/standalone/packages/web/.next/server/chunks/ssr/_c5a5c652._.js +1 -1
  46. package/dist/standalone/packages/web/.next/server/pages/404.html +2 -2
  47. package/dist/standalone/packages/web/.next/server/pages/500.html +2 -2
  48. package/dist/standalone/packages/web/.next/server/server-reference-manifest.js +1 -1
  49. package/dist/standalone/packages/web/.next/server/server-reference-manifest.json +1 -1
  50. package/dist/{static/chunks/0de258404bcae76f.js → standalone/packages/web/.next/static/chunks/8864b47e107cbe63.js} +1 -1
  51. package/dist/{static/chunks/09ff02250dd56621.js → standalone/packages/web/.next/static/chunks/a2889ecda42c83e7.js} +1 -1
  52. package/dist/standalone/packages/web/.next/static/chunks/c22619397bb8368e.js +1 -0
  53. package/dist/standalone/packages/web/components.json +20 -0
  54. package/dist/standalone/packages/web/drizzle/0000_reflective_thena.sql +59 -0
  55. package/dist/standalone/packages/web/drizzle/0001_fresh_carmella_unuscione.sql +1 -0
  56. package/dist/standalone/packages/web/drizzle/meta/0000_snapshot.json +427 -0
  57. package/dist/standalone/packages/web/drizzle/meta/0001_snapshot.json +436 -0
  58. package/dist/standalone/packages/web/drizzle/meta/_journal.json +20 -0
  59. package/dist/standalone/packages/web/drizzle.config.ts +10 -0
  60. package/dist/standalone/packages/web/eslint.config.mjs +18 -0
  61. package/dist/standalone/packages/web/next.config.ts +7 -0
  62. package/dist/standalone/packages/web/package.json +1 -1
  63. package/dist/standalone/packages/web/postcss.config.mjs +8 -0
  64. package/dist/standalone/packages/web/src/app/api/projects/[id]/specs/route.ts +23 -0
  65. package/dist/standalone/packages/web/src/app/api/projects/route.ts +19 -0
  66. package/dist/standalone/packages/web/src/app/api/revalidate/route.ts +63 -0
  67. package/dist/standalone/packages/web/src/app/api/specs/[id]/dependency-graph/route.test.ts +51 -0
  68. package/dist/standalone/packages/web/src/app/api/specs/[id]/dependency-graph/route.ts +171 -0
  69. package/dist/standalone/packages/web/src/app/api/specs/[id]/route.ts +36 -0
  70. package/dist/standalone/packages/web/src/app/api/specs/[id]/subspecs/[file]/route.ts +46 -0
  71. package/dist/standalone/packages/web/src/app/api/stats/route.ts +19 -0
  72. package/dist/standalone/packages/web/src/app/board/board-client.tsx +162 -0
  73. package/dist/standalone/packages/web/src/app/board/loading.tsx +43 -0
  74. package/dist/standalone/packages/web/src/app/board/page.tsx +18 -0
  75. package/dist/standalone/packages/web/src/app/dashboard-client.tsx +364 -0
  76. package/dist/standalone/packages/web/src/app/error.tsx +43 -0
  77. package/dist/standalone/packages/web/src/app/globals.css +531 -0
  78. package/dist/standalone/packages/web/src/app/home-client.tsx +277 -0
  79. package/dist/standalone/packages/web/src/app/layout.tsx +70 -0
  80. package/dist/standalone/packages/web/src/app/loading.tsx +87 -0
  81. package/dist/standalone/packages/web/src/app/not-found.tsx +27 -0
  82. package/dist/standalone/packages/web/src/app/page.tsx +18 -0
  83. package/dist/standalone/packages/web/src/app/specs/[id]/loading.tsx +5 -0
  84. package/dist/standalone/packages/web/src/app/specs/[id]/page.tsx +43 -0
  85. package/dist/standalone/packages/web/src/app/specs/page.tsx +18 -0
  86. package/dist/standalone/packages/web/src/app/specs/specs-client.tsx +425 -0
  87. package/dist/standalone/packages/web/src/app/stats/page.tsx +18 -0
  88. package/dist/standalone/packages/web/src/app/stats/stats-client.tsx +283 -0
  89. package/dist/standalone/packages/web/src/components/back-to-top.tsx +46 -0
  90. package/dist/standalone/packages/web/src/components/empty-state.tsx +52 -0
  91. package/dist/standalone/packages/web/src/components/main-sidebar.tsx +175 -0
  92. package/dist/standalone/packages/web/src/components/markdown-link.test.ts +96 -0
  93. package/dist/standalone/packages/web/src/components/markdown-link.tsx +95 -0
  94. package/dist/standalone/packages/web/src/components/navigation.tsx +210 -0
  95. package/dist/standalone/packages/web/src/components/priority-badge.tsx +53 -0
  96. package/dist/standalone/packages/web/src/components/quick-search.tsx +180 -0
  97. package/dist/standalone/packages/web/src/components/skeletons.tsx +119 -0
  98. package/dist/standalone/packages/web/src/components/spec-dependency-graph.tsx +369 -0
  99. package/dist/standalone/packages/web/src/components/spec-detail-client.tsx +372 -0
  100. package/dist/standalone/packages/web/src/components/spec-detail-loading-shell.tsx +42 -0
  101. package/dist/standalone/packages/web/src/components/spec-detail-wrapper.tsx +70 -0
  102. package/dist/standalone/packages/web/src/components/spec-metadata.tsx +136 -0
  103. package/dist/standalone/packages/web/src/components/spec-sidebar.tsx +127 -0
  104. package/dist/standalone/packages/web/src/components/spec-timeline.tsx +186 -0
  105. package/dist/standalone/packages/web/src/components/specs-nav-sidebar.tsx +561 -0
  106. package/dist/standalone/packages/web/src/components/status-badge.tsx +53 -0
  107. package/dist/standalone/packages/web/src/components/sub-spec-tabs.tsx +143 -0
  108. package/dist/standalone/packages/web/src/components/table-of-contents.tsx +130 -0
  109. package/dist/standalone/packages/web/src/components/theme-provider.tsx +11 -0
  110. package/dist/standalone/packages/web/src/components/theme-toggle.tsx +37 -0
  111. package/dist/standalone/packages/web/src/components/ui/avatar.tsx +50 -0
  112. package/dist/standalone/packages/web/src/components/ui/badge.tsx +36 -0
  113. package/dist/standalone/packages/web/src/components/ui/breadcrumb.tsx +110 -0
  114. package/dist/standalone/packages/web/src/components/ui/button.tsx +57 -0
  115. package/dist/standalone/packages/web/src/components/ui/card.tsx +76 -0
  116. package/dist/standalone/packages/web/src/components/ui/command.tsx +153 -0
  117. package/dist/standalone/packages/web/src/components/ui/dialog.tsx +122 -0
  118. package/dist/standalone/packages/web/src/components/ui/input.tsx +24 -0
  119. package/dist/standalone/packages/web/src/components/ui/select.tsx +159 -0
  120. package/dist/standalone/packages/web/src/components/ui/separator.tsx +31 -0
  121. package/dist/standalone/packages/web/src/components/ui/skeleton.tsx +15 -0
  122. package/dist/standalone/packages/web/src/components/ui/tabs.tsx +55 -0
  123. package/dist/standalone/packages/web/src/components/ui/toast.tsx +30 -0
  124. package/dist/standalone/packages/web/src/components/ui/tooltip.tsx +32 -0
  125. package/dist/standalone/packages/web/src/lib/date-utils.ts +76 -0
  126. package/dist/standalone/packages/web/src/lib/db/index.ts +42 -0
  127. package/dist/standalone/packages/web/src/lib/db/migrate.ts +18 -0
  128. package/dist/standalone/packages/web/src/lib/db/queries.ts +114 -0
  129. package/dist/standalone/packages/web/src/lib/db/schema.ts +123 -0
  130. package/dist/standalone/packages/web/src/lib/db/seed.ts +156 -0
  131. package/dist/standalone/packages/web/src/lib/db/service-queries.ts +216 -0
  132. package/dist/standalone/packages/web/src/lib/dependency-graph.ts +105 -0
  133. package/dist/standalone/packages/web/src/lib/specs/service.ts +120 -0
  134. package/dist/standalone/packages/web/src/lib/specs/sources/database-source.ts +94 -0
  135. package/dist/standalone/packages/web/src/lib/specs/sources/filesystem-source.ts +249 -0
  136. package/dist/standalone/packages/web/src/lib/specs/types.ts +55 -0
  137. package/dist/standalone/packages/web/src/lib/stores/specs-sidebar-store.ts +152 -0
  138. package/dist/standalone/packages/web/src/lib/sub-specs.ts +171 -0
  139. package/dist/standalone/packages/web/src/lib/utils.ts +17 -0
  140. package/dist/standalone/packages/web/src/types/specs.ts +18 -0
  141. package/dist/standalone/packages/web/tailwind.config.ts +58 -0
  142. package/dist/standalone/packages/web/tsconfig.json +34 -0
  143. package/dist/standalone/packages/web/vitest.config.ts +14 -0
  144. package/dist/standalone/specs/100-release-process-typecheck-failure/README.md +266 -0
  145. package/dist/standalone/specs/101-sidebar-scroll-position-drift/README.md +100 -0
  146. package/package.json +5 -3
  147. package/dist/BUILD_ID +0 -1
  148. package/dist/static/chunks/a3e649fcddd3d715.js +0 -1
  149. /package/dist/{static/Z_BxkB0KJViCNbULN1S5p → standalone/packages/web/.next/static/DDHAmoL_2Poji-whvFjry}/_buildManifest.js +0 -0
  150. /package/dist/{static/Z_BxkB0KJViCNbULN1S5p → standalone/packages/web/.next/static/DDHAmoL_2Poji-whvFjry}/_clientMiddlewareManifest.json +0 -0
  151. /package/dist/{static/Z_BxkB0KJViCNbULN1S5p → standalone/packages/web/.next/static/DDHAmoL_2Poji-whvFjry}/_ssgManifest.js +0 -0
  152. /package/dist/{static → standalone/packages/web/.next/static}/chunks/0c19c69aa7625475.js +0 -0
  153. /package/dist/{static → standalone/packages/web/.next/static}/chunks/116800b03245a1e5.js +0 -0
  154. /package/dist/{static → standalone/packages/web/.next/static}/chunks/19e80edf527aef5c.js +0 -0
  155. /package/dist/{static → standalone/packages/web/.next/static}/chunks/2ece90370908f56c.js +0 -0
  156. /package/dist/{static → standalone/packages/web/.next/static}/chunks/36fd2dddb486f6bc.js +0 -0
  157. /package/dist/{static → standalone/packages/web/.next/static}/chunks/5c2072ad938de8ed.js +0 -0
  158. /package/dist/{static → standalone/packages/web/.next/static}/chunks/6577fe797a336bab.js +0 -0
  159. /package/dist/{static → standalone/packages/web/.next/static}/chunks/6a05a93ec8fa7b83.js +0 -0
  160. /package/dist/{static → standalone/packages/web/.next/static}/chunks/7f732ea69e643219.js +0 -0
  161. /package/dist/{static → standalone/packages/web/.next/static}/chunks/a02c1f50ff00204f.js +0 -0
  162. /package/dist/{static → standalone/packages/web/.next/static}/chunks/a45464b9776dd88e.js +0 -0
  163. /package/dist/{static → standalone/packages/web/.next/static}/chunks/a6dad97d9634a72d.js +0 -0
  164. /package/dist/{static → standalone/packages/web/.next/static}/chunks/ae04dcd433be6dab.js +0 -0
  165. /package/dist/{static → standalone/packages/web/.next/static}/chunks/b20313408e970968.css +0 -0
  166. /package/dist/{static → standalone/packages/web/.next/static}/chunks/c46095e1a421d93f.js +0 -0
  167. /package/dist/{static → standalone/packages/web/.next/static}/chunks/c48dd4c72d7c5ef4.js +0 -0
  168. /package/dist/{static → standalone/packages/web/.next/static}/chunks/c557ac675be79771.js +0 -0
  169. /package/dist/{static → standalone/packages/web/.next/static}/chunks/dca0c854c59234cd.js +0 -0
  170. /package/dist/{static → standalone/packages/web/.next/static}/chunks/df1731c03abf1aee.css +0 -0
  171. /package/dist/{static → standalone/packages/web/.next/static}/chunks/dfd41488ad062cd5.js +0 -0
  172. /package/dist/{static → standalone/packages/web/.next/static}/chunks/ebd89051637b9a47.js +0 -0
  173. /package/dist/{static → standalone/packages/web/.next/static}/chunks/f3ec9fd77a8618b1.js +0 -0
  174. /package/dist/{static → standalone/packages/web/.next/static}/chunks/turbopack-7450632b40b2e378.js +0 -0
  175. /package/dist/{public → standalone/packages/web/public}/f864aa7e7061c0600e35cf3d879b27cf.txt +0 -0
  176. /package/dist/{public → standalone/packages/web/public}/favicon.ico +0 -0
  177. /package/dist/{public → standalone/packages/web/public}/file.svg +0 -0
  178. /package/dist/{public → standalone/packages/web/public}/github-mark-white.svg +0 -0
  179. /package/dist/{public → standalone/packages/web/public}/github-mark.svg +0 -0
  180. /package/dist/{public → standalone/packages/web/public}/globe.svg +0 -0
  181. /package/dist/{public → standalone/packages/web/public}/icon.svg +0 -0
  182. /package/dist/{public → standalone/packages/web/public}/logo-dark-bg.svg +0 -0
  183. /package/dist/{public → standalone/packages/web/public}/logo-with-bg.svg +0 -0
  184. /package/dist/{public → standalone/packages/web/public}/logo.svg +0 -0
  185. /package/dist/{public → standalone/packages/web/public}/next.svg +0 -0
  186. /package/dist/{public → standalone/packages/web/public}/vercel.svg +0 -0
  187. /package/dist/{public → standalone/packages/web/public}/window.svg +0 -0
@@ -0,0 +1,561 @@
1
+ 'use client';
2
+
3
+ import * as React from 'react';
4
+ import Link from 'next/link';
5
+ import { List, type ListImperativeAPI } from 'react-window';
6
+ import {
7
+ Search,
8
+ ChevronLeft,
9
+ ChevronRight,
10
+ X,
11
+ List as ListIconLucide,
12
+ Filter,
13
+ } from 'lucide-react';
14
+ import { Input } from '@/components/ui/input';
15
+ import { Button } from '@/components/ui/button';
16
+ import { StatusBadge, getStatusLabel } from '@/components/status-badge';
17
+ import {
18
+ Tooltip,
19
+ TooltipContent,
20
+ TooltipProvider,
21
+ TooltipTrigger,
22
+ } from '@/components/ui/tooltip';
23
+ import {
24
+ Select,
25
+ SelectContent,
26
+ SelectItem,
27
+ SelectTrigger,
28
+ SelectValue,
29
+ } from '@/components/ui/select';
30
+ import { cn } from '@/lib/utils';
31
+ import { extractH1Title } from '@/lib/utils';
32
+ import { PriorityBadge, getPriorityLabel } from './priority-badge';
33
+ import { formatRelativeTime } from '@/lib/date-utils';
34
+ import {
35
+ useSpecsSidebarSpecs,
36
+ useSpecsSidebarActiveSpec,
37
+ getSidebarScrollTop,
38
+ updateSidebarScrollTop,
39
+ } from '@/lib/stores/specs-sidebar-store';
40
+
41
+ const useIsomorphicLayoutEffect = typeof window !== 'undefined'
42
+ ? React.useLayoutEffect
43
+ : React.useEffect;
44
+ import type { SidebarSpec } from '@/types/specs';
45
+
46
+ interface SpecsNavSidebarProps {
47
+ initialSpecs?: SidebarSpec[];
48
+ currentSpecId?: string;
49
+ currentSubSpec?: string;
50
+ onSpecHover?: (specId: string) => void;
51
+ }
52
+
53
+ export function SpecsNavSidebar({ initialSpecs = [], currentSpecId, currentSubSpec, onSpecHover }: SpecsNavSidebarProps) {
54
+ const [searchQuery, setSearchQuery] = React.useState('');
55
+ const [statusFilter, setStatusFilter] = React.useState<string>('all');
56
+ const [priorityFilter, setPriorityFilter] = React.useState<string>('all');
57
+ const [tagFilter, setTagFilter] = React.useState<string>('all');
58
+ const [showFilters, setShowFilters] = React.useState(false);
59
+ const [isCollapsed, setIsCollapsed] = React.useState(() => {
60
+ if (typeof window !== 'undefined') {
61
+ const saved = localStorage.getItem('specs-nav-sidebar-collapsed');
62
+ return saved === 'true';
63
+ }
64
+ return false;
65
+ });
66
+ const [mobileOpen, setMobileOpen] = React.useState(false);
67
+ const [mounted, setMounted] = React.useState(false);
68
+ const activeItemRef = React.useRef<HTMLAnchorElement>(null);
69
+ const scrollFrameRef = React.useRef<number | null>(null);
70
+ const restoredScrollRef = React.useRef(false);
71
+ const listRef = React.useRef<ListImperativeAPI>(null);
72
+ const savedScrollRef = React.useRef(0);
73
+ const anchoredActiveSpecRef = React.useRef(false);
74
+ const initialRenderRef = React.useRef(true);
75
+
76
+ // Use selector hooks to avoid unnecessary re-renders from unrelated state changes
77
+ const sidebarSpecs = useSpecsSidebarSpecs();
78
+ const sidebarActiveSpecId = useSpecsSidebarActiveSpec();
79
+
80
+ // Memoize specs to prevent unnecessary recalculations downstream
81
+ const cachedSpecs = React.useMemo(
82
+ () => (sidebarSpecs.length > 0 ? sidebarSpecs : initialSpecs),
83
+ [sidebarSpecs, initialSpecs]
84
+ );
85
+
86
+ const resolvedCurrentSpecId = currentSpecId || sidebarActiveSpecId || '';
87
+
88
+ React.useEffect(() => {
89
+ setMounted(true);
90
+ }, []);
91
+
92
+ // Update CSS variable for page width calculations and persist to localStorage
93
+ React.useEffect(() => {
94
+ document.documentElement.style.setProperty(
95
+ '--specs-nav-sidebar-width',
96
+ isCollapsed ? '0px' : '280px'
97
+ );
98
+ localStorage.setItem('specs-nav-sidebar-collapsed', String(isCollapsed));
99
+ }, [isCollapsed]);
100
+
101
+ // Close mobile menu on route change
102
+ React.useEffect(() => {
103
+ setMobileOpen(false);
104
+ }, [resolvedCurrentSpecId, currentSubSpec]);
105
+
106
+ const filteredSpecs = React.useMemo(() => {
107
+ let specs = cachedSpecs;
108
+
109
+ // Apply search query
110
+ if (searchQuery) {
111
+ const query = searchQuery.toLowerCase();
112
+ specs = specs.filter(
113
+ (spec) =>
114
+ spec.title?.toLowerCase().includes(query) ||
115
+ spec.specName.toLowerCase().includes(query) ||
116
+ spec.specNumber?.toString().includes(query)
117
+ );
118
+ }
119
+
120
+ // Apply status filter
121
+ if (statusFilter !== 'all') {
122
+ specs = specs.filter((spec) => spec.status === statusFilter);
123
+ }
124
+
125
+ // Apply priority filter
126
+ if (priorityFilter !== 'all') {
127
+ specs = specs.filter((spec) => spec.priority === priorityFilter);
128
+ }
129
+
130
+ // Apply tag filter
131
+ if (tagFilter !== 'all') {
132
+ specs = specs.filter((spec) => spec.tags?.includes(tagFilter));
133
+ }
134
+
135
+ console.debug(specs);
136
+ return specs;
137
+ }, [cachedSpecs, searchQuery, statusFilter, priorityFilter, tagFilter]);
138
+
139
+ // Get all unique tags from all specs
140
+ const allTags = React.useMemo(() => {
141
+ const tagsSet = new Set<string>();
142
+ cachedSpecs.forEach((spec) => {
143
+ spec.tags?.forEach((tag) => tagsSet.add(tag));
144
+ });
145
+ return Array.from(tagsSet).sort();
146
+ }, [cachedSpecs]);
147
+
148
+ // Check if any filters are active
149
+ const hasActiveFilters = statusFilter !== 'all' || priorityFilter !== 'all' || tagFilter !== 'all';
150
+
151
+ // Reset all filters
152
+ const resetFilters = () => {
153
+ setStatusFilter('all');
154
+ setPriorityFilter('all');
155
+ setTagFilter('all');
156
+ };
157
+
158
+ // Sort specs by number descending (newest first)
159
+ const sortedSpecs = React.useMemo(() => {
160
+ return [...filteredSpecs].sort((a, b) => (b.specNumber || 0) - (a.specNumber || 0));
161
+ }, [filteredSpecs]);
162
+
163
+ // Persist and restore sidebar scroll position without triggering component re-renders
164
+ useIsomorphicLayoutEffect(() => {
165
+ if (typeof window === 'undefined') {
166
+ return;
167
+ }
168
+
169
+ let rafId: number | null = null;
170
+ let cleanup: (() => void) | null = null;
171
+
172
+ savedScrollRef.current = getSidebarScrollTop();
173
+ anchoredActiveSpecRef.current = savedScrollRef.current > 0;
174
+
175
+ const setupScrollPersistence = () => {
176
+ const listElement = listRef.current?.element;
177
+
178
+ if (!listElement) {
179
+ rafId = window.requestAnimationFrame(setupScrollPersistence);
180
+ return;
181
+ }
182
+
183
+ if (!restoredScrollRef.current) {
184
+ if (savedScrollRef.current > 0) {
185
+ listElement.scrollTop = savedScrollRef.current;
186
+ }
187
+ restoredScrollRef.current = true;
188
+ }
189
+
190
+ const handleScroll = () => {
191
+ if (scrollFrameRef.current !== null) {
192
+ return;
193
+ }
194
+ scrollFrameRef.current = window.requestAnimationFrame(() => {
195
+ scrollFrameRef.current = null;
196
+ savedScrollRef.current = listElement.scrollTop;
197
+ updateSidebarScrollTop(listElement.scrollTop);
198
+ });
199
+ };
200
+
201
+ listElement.addEventListener('scroll', handleScroll, { passive: true });
202
+ cleanup = () => {
203
+ listElement.removeEventListener('scroll', handleScroll);
204
+ };
205
+ };
206
+
207
+ setupScrollPersistence();
208
+
209
+ return () => {
210
+ if (cleanup) {
211
+ cleanup();
212
+ }
213
+ if (rafId !== null) {
214
+ window.cancelAnimationFrame(rafId);
215
+ }
216
+ if (scrollFrameRef.current !== null) {
217
+ window.cancelAnimationFrame(scrollFrameRef.current);
218
+ scrollFrameRef.current = null;
219
+ }
220
+ };
221
+ }, []);
222
+
223
+ // Ensure the active spec is visible when first loading without a stored scroll position
224
+ useIsomorphicLayoutEffect(() => {
225
+ if (typeof window === 'undefined') {
226
+ return;
227
+ }
228
+
229
+ if (!initialRenderRef.current) {
230
+ return;
231
+ }
232
+
233
+ initialRenderRef.current = false;
234
+
235
+ if (anchoredActiveSpecRef.current) {
236
+ return;
237
+ }
238
+
239
+ if (!resolvedCurrentSpecId) {
240
+ return;
241
+ }
242
+
243
+ let rafId: number | null = null;
244
+
245
+ const tryAnchorScroll = () => {
246
+ if (anchoredActiveSpecRef.current) {
247
+ return;
248
+ }
249
+
250
+ if (!restoredScrollRef.current || !listRef.current) {
251
+ rafId = window.requestAnimationFrame(tryAnchorScroll);
252
+ return;
253
+ }
254
+
255
+ const targetIndex = sortedSpecs.findIndex((spec) => spec.id === resolvedCurrentSpecId);
256
+ if (targetIndex === -1) {
257
+ return;
258
+ }
259
+
260
+ anchoredActiveSpecRef.current = true;
261
+ listRef.current.scrollToRow({ index: targetIndex, align: 'center' });
262
+ };
263
+
264
+ tryAnchorScroll();
265
+
266
+ return () => {
267
+ if (rafId !== null) {
268
+ window.cancelAnimationFrame(rafId);
269
+ }
270
+ };
271
+ }, [sortedSpecs, resolvedCurrentSpecId]);
272
+
273
+ // Expose function for mobile toggle
274
+ React.useEffect(() => {
275
+ if (typeof window === 'undefined') {
276
+ return;
277
+ }
278
+
279
+ window.toggleSpecsSidebar = () => setMobileOpen(prev => !prev);
280
+ return () => {
281
+ window.toggleSpecsSidebar = undefined;
282
+ };
283
+ }, []);
284
+
285
+ // Virtual list row renderer (rowProps will be passed by react-window)
286
+ const RowComponent = React.useCallback((rowProps: { index: number; style: React.CSSProperties }) => {
287
+ const { index, style } = rowProps;
288
+ const spec = sortedSpecs[index];
289
+ const isCurrentSpec = spec.id === resolvedCurrentSpecId;
290
+
291
+ // Extract H1 title, fallback to title or name
292
+ const h1Title = spec.contentMd ? extractH1Title(spec.contentMd) : null;
293
+ const displayTitle = h1Title || spec.title || spec.specName;
294
+
295
+ return (
296
+ <div style={style} className="px-1">
297
+ <div className="mb-0.5">
298
+ {/* Main spec item */}
299
+ <div className="flex items-center gap-0.5">
300
+ <Tooltip>
301
+ <TooltipTrigger asChild>
302
+ <Link
303
+ ref={isCurrentSpec && !currentSubSpec ? activeItemRef : null}
304
+ href={`/specs/${spec.specNumber || spec.id}`}
305
+ onMouseEnter={() => onSpecHover?.(spec.specNumber?.toString() || spec.id)}
306
+ className={cn(
307
+ 'w-full flex flex-col gap-1 p-1.5 rounded-md text-sm transition-colors',
308
+ isCurrentSpec
309
+ ? 'bg-accent text-accent-foreground font-medium'
310
+ : 'hover:bg-accent/50',
311
+ )}
312
+ >
313
+ <div className="flex items-center gap-1.5">
314
+ {spec.specNumber && (
315
+ <span className="text-xs font-mono text-muted-foreground shrink-0">
316
+ #{spec.specNumber.toString().padStart(3, '0')}
317
+ </span>
318
+ )}
319
+ <span className="truncate text-xs leading-relaxed">{displayTitle}</span>
320
+ </div>
321
+ <div className="flex items-center gap-1.5 flex-wrap">
322
+ {spec.status && (
323
+ <Tooltip>
324
+ <TooltipTrigger asChild>
325
+ <div>
326
+ <StatusBadge status={spec.status} iconOnly className="text-[10px] scale-90" />
327
+ </div>
328
+ </TooltipTrigger>
329
+ <TooltipContent side="right">
330
+ {getStatusLabel(spec.status)}
331
+ </TooltipContent>
332
+ </Tooltip>
333
+ )}
334
+ {spec.priority && (
335
+ <Tooltip>
336
+ <TooltipTrigger asChild>
337
+ <div>
338
+ <PriorityBadge priority={spec.priority} iconOnly className="text-[10px] scale-90" />
339
+ </div>
340
+ </TooltipTrigger>
341
+ <TooltipContent side="right">
342
+ {getPriorityLabel(spec.priority)}
343
+ </TooltipContent>
344
+ </Tooltip>
345
+ )}
346
+ {typeof spec.subSpecsCount === 'number' && spec.subSpecsCount > 0 && (
347
+ <span className="text-[10px] text-muted-foreground">
348
+ +{spec.subSpecsCount} files
349
+ </span>
350
+ )}
351
+ {spec.updatedAt && (
352
+ <span className="text-[10px] text-muted-foreground">
353
+ {formatRelativeTime(spec.updatedAt)}
354
+ </span>
355
+ )}
356
+ </div>
357
+ </Link>
358
+ </TooltipTrigger>
359
+ <TooltipContent side="right" className="max-w-[300px]">
360
+ <div className="space-y-1">
361
+ <div className="font-semibold">{displayTitle}</div>
362
+ <div className="text-xs text-muted-foreground">{spec.specName}</div>
363
+ </div>
364
+ </TooltipContent>
365
+ </Tooltip>
366
+ </div>
367
+ </div>
368
+ </div>
369
+ );
370
+ }, [sortedSpecs, resolvedCurrentSpecId, currentSubSpec, onSpecHover]);
371
+
372
+ // Calculate list height
373
+ const listHeight = React.useMemo(() => {
374
+ // Get viewport height minus header (3.5rem = 56px) and search/filter area (~180px)
375
+ if (typeof window !== 'undefined') {
376
+ return window.innerHeight - 56 - 180;
377
+ }
378
+ return 600; // fallback
379
+ }, []);
380
+
381
+ // Memoized List component to prevent unnecessary re-renders
382
+ const MemoizedList = React.useMemo(() => {
383
+ return React.memo(List<Record<string, never>>);
384
+ }, []);
385
+
386
+ return (
387
+ <TooltipProvider delayDuration={700}>
388
+ {/* Mobile overlay backdrop */}
389
+ {mobileOpen && (
390
+ <div
391
+ className="fixed inset-0 bg-black/50 z-40 lg:hidden"
392
+ onClick={() => setMobileOpen(false)}
393
+ />
394
+ )}
395
+
396
+ <div className="relative flex-shrink-0">
397
+ <aside className={cn(
398
+ "sticky top-14 h-[calc(100vh-3.5rem)] border-r border-border bg-background flex flex-col overflow-hidden transition-all duration-300",
399
+ // Desktop behavior
400
+ "hidden lg:flex",
401
+ mounted && isCollapsed ? "lg:w-0 lg:border-r-0" : "lg:w-[280px]",
402
+ // Mobile behavior - show as overlay when open
403
+ mobileOpen && "fixed left-0 top-14 z-50 flex w-[280px]"
404
+ )}>
405
+ <div className="p-4 border-b border-border">
406
+ <div className="flex items-center justify-between mb-3">
407
+ <h2 className="font-semibold text-sm">Specifications</h2>
408
+ <div className="flex items-center gap-1">
409
+ {/* Filter toggle button */}
410
+ <Tooltip>
411
+ <TooltipTrigger asChild>
412
+ <Button
413
+ variant={showFilters || hasActiveFilters ? "default" : "ghost"}
414
+ size="sm"
415
+ onClick={() => setShowFilters(!showFilters)}
416
+ className="h-6 w-6 p-0"
417
+ >
418
+ <Filter className="h-4 w-4" />
419
+ </Button>
420
+ </TooltipTrigger>
421
+ <TooltipContent side="bottom">
422
+ {showFilters ? 'Hide filters' : 'Show filters'}
423
+ </TooltipContent>
424
+ </Tooltip>
425
+ {/* Mobile close button */}
426
+ <Button
427
+ variant="ghost"
428
+ size="sm"
429
+ onClick={() => setMobileOpen(false)}
430
+ className="h-6 w-6 p-0 lg:hidden"
431
+ >
432
+ <X className="h-4 w-4" />
433
+ </Button>
434
+ {/* Desktop collapse button */}
435
+ <Button
436
+ variant="ghost"
437
+ size="sm"
438
+ onClick={() => setIsCollapsed(true)}
439
+ className="h-6 w-6 p-0 hidden lg:flex lg:items-center lg:justify-center"
440
+ >
441
+ <ChevronLeft className="h-4 w-4" />
442
+ </Button>
443
+ </div>
444
+ </div>
445
+
446
+ {/* Filter controls */}
447
+ {showFilters && (
448
+ <div className="space-y-2 mb-3 pb-3 border-b border-border">
449
+ <div className="flex items-center justify-between">
450
+ <span className="text-xs font-medium text-muted-foreground">Filters</span>
451
+ {hasActiveFilters && (
452
+ <Button
453
+ variant="ghost"
454
+ size="sm"
455
+ onClick={resetFilters}
456
+ className="h-5 text-xs px-2 py-0"
457
+ >
458
+ Clear
459
+ </Button>
460
+ )}
461
+ </div>
462
+
463
+ <Select value={statusFilter} onValueChange={setStatusFilter}>
464
+ <SelectTrigger className="h-8 text-xs">
465
+ <SelectValue placeholder="Status" />
466
+ </SelectTrigger>
467
+ <SelectContent>
468
+ <SelectItem value="all">All Status</SelectItem>
469
+ <SelectItem value="planned">Planned</SelectItem>
470
+ <SelectItem value="in-progress">In Progress</SelectItem>
471
+ <SelectItem value="complete">Complete</SelectItem>
472
+ <SelectItem value="archived">Archived</SelectItem>
473
+ </SelectContent>
474
+ </Select>
475
+
476
+ <Select value={priorityFilter} onValueChange={setPriorityFilter}>
477
+ <SelectTrigger className="h-8 text-xs">
478
+ <SelectValue placeholder="Priority" />
479
+ </SelectTrigger>
480
+ <SelectContent>
481
+ <SelectItem value="all">All Priority</SelectItem>
482
+ <SelectItem value="low">Low</SelectItem>
483
+ <SelectItem value="medium">Medium</SelectItem>
484
+ <SelectItem value="high">High</SelectItem>
485
+ <SelectItem value="critical">Critical</SelectItem>
486
+ </SelectContent>
487
+ </Select>
488
+
489
+ {allTags.length > 0 && (
490
+ <Select value={tagFilter} onValueChange={setTagFilter}>
491
+ <SelectTrigger className="h-8 text-xs">
492
+ <SelectValue placeholder="Tag" />
493
+ </SelectTrigger>
494
+ <SelectContent>
495
+ <SelectItem value="all">All Tags</SelectItem>
496
+ {allTags.map((tag) => (
497
+ <SelectItem key={tag} value={tag}>
498
+ {tag}
499
+ </SelectItem>
500
+ ))}
501
+ </SelectContent>
502
+ </Select>
503
+ )}
504
+ </div>
505
+ )}
506
+
507
+ <div className="relative">
508
+ <Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
509
+ <Input
510
+ placeholder="Search specs..."
511
+ value={searchQuery}
512
+ onChange={(e) => setSearchQuery(e.target.value)}
513
+ className="pl-8 h-9 border-border"
514
+ />
515
+ </div>
516
+ </div>
517
+
518
+ <div className="flex-1 overflow-hidden">
519
+ {sortedSpecs.length === 0 ? (
520
+ <div className="text-center py-8 text-sm text-muted-foreground">
521
+ No specs found
522
+ </div>
523
+ ) : (
524
+ <MemoizedList
525
+ listRef={listRef}
526
+ defaultHeight={listHeight}
527
+ rowCount={sortedSpecs.length}
528
+ rowHeight={72}
529
+ overscanCount={5}
530
+ rowComponent={RowComponent}
531
+ rowProps={{}}
532
+ />
533
+ )}
534
+ </div>
535
+ </aside>
536
+
537
+ {/* Floating toggle button when collapsed (desktop only) */}
538
+ {mounted && isCollapsed && (
539
+ <Button
540
+ variant="ghost"
541
+ size="sm"
542
+ onClick={() => setIsCollapsed(false)}
543
+ className="hidden lg:items-center lg:justify-center lg:flex h-6 w-6 p-0 fixed z-50 top-20 -translate-y-1 -translate-x-1/2 left-[calc(var(--main-sidebar-width,240px))] bg-background border"
544
+ >
545
+ <ChevronRight className="h-4 w-4" />
546
+ </Button>
547
+ )}
548
+
549
+ {/* Mobile floating toggle button - matches BackToTop/TOC style */}
550
+ <Button
551
+ onClick={() => setMobileOpen(true)}
552
+ size="icon"
553
+ className="lg:hidden fixed bottom-6 left-6 h-12 w-12 rounded-full shadow-lg z-40 hover:scale-110 transition-transform"
554
+ aria-label="Show specifications list"
555
+ >
556
+ <ListIconLucide className="h-5 w-5" />
557
+ </Button>
558
+ </div>
559
+ </TooltipProvider>
560
+ );
561
+ }
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Status badge component with icons
3
+ */
4
+
5
+ import { Clock, PlayCircle, CheckCircle2, Archive } from 'lucide-react';
6
+ import { Badge } from '@/components/ui/badge';
7
+ import { cn } from '@/lib/utils';
8
+
9
+ interface StatusBadgeProps {
10
+ status: string;
11
+ className?: string;
12
+ iconOnly?: boolean;
13
+ }
14
+
15
+ const statusConfig = {
16
+ 'planned': {
17
+ icon: Clock,
18
+ label: 'Planned',
19
+ className: 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400'
20
+ },
21
+ 'in-progress': {
22
+ icon: PlayCircle,
23
+ label: 'In Progress',
24
+ className: 'bg-orange-100 text-orange-800 dark:bg-orange-900/30 dark:text-orange-400'
25
+ },
26
+ 'complete': {
27
+ icon: CheckCircle2,
28
+ label: 'Complete',
29
+ className: 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400'
30
+ },
31
+ 'archived': {
32
+ icon: Archive,
33
+ label: 'Archived',
34
+ className: 'bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-400'
35
+ }
36
+ };
37
+
38
+ export function StatusBadge({ status, className, iconOnly = false }: StatusBadgeProps) {
39
+ const config = statusConfig[status as keyof typeof statusConfig] || statusConfig.planned;
40
+ const Icon = config.icon;
41
+
42
+ return (
43
+ <Badge className={cn('flex items-center w-fit', !iconOnly && 'gap-1.5', config.className, className)}>
44
+ <Icon className="h-3.5 w-3.5" />
45
+ {!iconOnly && config.label}
46
+ </Badge>
47
+ );
48
+ }
49
+
50
+ export function getStatusLabel(status: string): string {
51
+ const config = statusConfig[status as keyof typeof statusConfig] || statusConfig.planned;
52
+ return config.label;
53
+ }