@leanspec/ui 0.2.14 → 0.2.15-dev.21025278490

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 (150) hide show
  1. package/bin/leanspec-ui.js +191 -0
  2. package/dist/assets/_baseUniq-CRqreL7N.js +1 -0
  3. package/dist/assets/arc-DMhx9AJT.js +1 -0
  4. package/dist/assets/architectureDiagram-VXUJARFQ-DM0L0YzO.js +36 -0
  5. package/dist/assets/blockDiagram-VD42YOAC-DHQXDHsD.js +122 -0
  6. package/dist/assets/c4Diagram-YG6GDRKO-0L7o2gpH.js +10 -0
  7. package/dist/assets/channel-2tOl0nAZ.js +1 -0
  8. package/dist/assets/chunk-4BX2VUAB-CwFT-Uaj.js +1 -0
  9. package/dist/assets/chunk-55IACEB6-CjvuUHHG.js +1 -0
  10. package/dist/assets/chunk-B4BG7PRW-BRJBysMK.js +165 -0
  11. package/dist/assets/chunk-DI55MBZ5-BnNEeoaA.js +220 -0
  12. package/dist/assets/chunk-FMBD7UC4-BK2l30pm.js +15 -0
  13. package/dist/assets/chunk-QN33PNHL-BN_cZkCU.js +1 -0
  14. package/dist/assets/chunk-QZHKN3VN-Brc3Yrub.js +1 -0
  15. package/dist/assets/chunk-TZMSLE5B-D2zzpLfO.js +1 -0
  16. package/dist/assets/classDiagram-2ON5EDUG-BB9CSNmS.js +1 -0
  17. package/dist/assets/classDiagram-v2-WZHVMYZB-BB9CSNmS.js +1 -0
  18. package/dist/assets/clone-BjxVFtyI.js +1 -0
  19. package/dist/assets/core-DV6XEvTN.js +1 -0
  20. package/dist/assets/cose-bilkent-S5V4N54A-CLJgM3XR.js +1 -0
  21. package/dist/assets/cytoscape.esm-5J0xJHOV.js +321 -0
  22. package/dist/assets/dagre-6UL2VRFP-_IFvBJKJ.js +4 -0
  23. package/dist/assets/diagram-PSM6KHXK--83HIYSQ.js +24 -0
  24. package/dist/assets/diagram-QEK2KX5R-6jAWnCnZ.js +43 -0
  25. package/dist/assets/diagram-S2PKOQOG-D5pwHvjZ.js +24 -0
  26. package/dist/assets/erDiagram-Q2GNP2WA-B4FV3mTd.js +60 -0
  27. package/dist/assets/flowDiagram-NV44I4VS-mtD2kF4M.js +162 -0
  28. package/dist/assets/ganttDiagram-JELNMOA3-BKALgqTK.js +267 -0
  29. package/dist/assets/gitGraphDiagram-NY62KEGX-Bd7r0pAf.js +65 -0
  30. package/dist/assets/graph-B2rEI7cK.js +1 -0
  31. package/dist/assets/index-Bekv_o1t.css +1 -0
  32. package/dist/assets/index-DSRxU-E5.js +389 -0
  33. package/dist/assets/infoDiagram-WHAUD3N6--nJOBKqh.js +2 -0
  34. package/dist/assets/journeyDiagram-XKPGCS4Q-BzGutKN3.js +139 -0
  35. package/dist/assets/kanban-definition-3W4ZIXB7-DyQO17vq.js +89 -0
  36. package/dist/assets/katex-XbL3y5x-.js +261 -0
  37. package/dist/assets/layout-iCSHU015.js +1 -0
  38. package/dist/assets/min-BK_AIJdo.js +1 -0
  39. package/dist/assets/mindmap-definition-VGOIOE7T-BZMj_6zo.js +68 -0
  40. package/dist/assets/pieDiagram-ADFJNKIX-CkAGsq9p.js +30 -0
  41. package/dist/assets/quadrantDiagram-AYHSOK5B-CWa93px1.js +7 -0
  42. package/dist/assets/requirementDiagram-UZGBJVZJ-CufFVR8c.js +64 -0
  43. package/dist/assets/sankeyDiagram-TZEHDZUN-BEPgVgU4.js +10 -0
  44. package/dist/assets/sequenceDiagram-WL72ISMW-BkdBWhel.js +145 -0
  45. package/dist/assets/stateDiagram-FKZM4ZOC-D5T73yx0.js +1 -0
  46. package/dist/assets/stateDiagram-v2-4FDKWEC3-9hJWG2n6.js +1 -0
  47. package/dist/assets/timeline-definition-IT6M3QCI-CX7kTdU2.js +61 -0
  48. package/dist/assets/treemap-KMMF4GRG-ftWCQ9lJ.js +128 -0
  49. package/dist/assets/xychartDiagram-PRI3JC2R-Ngrels4n.js +7 -0
  50. package/{index.html → dist/index.html} +2 -1
  51. package/package.json +12 -2
  52. package/eslint.config.js +0 -23
  53. package/package.json.backup +0 -83
  54. package/postcss.config.js +0 -6
  55. package/src/App.css +0 -42
  56. package/src/App.tsx +0 -17
  57. package/src/assets/react.svg +0 -1
  58. package/src/components/LanguageSwitcher.tsx +0 -67
  59. package/src/components/Layout.tsx +0 -88
  60. package/src/components/MainSidebar.tsx +0 -163
  61. package/src/components/MermaidDiagram.tsx +0 -85
  62. package/src/components/MinimalLayout.tsx +0 -51
  63. package/src/components/Navigation.tsx +0 -254
  64. package/src/components/PriorityBadge.tsx +0 -59
  65. package/src/components/ProjectSwitcher.tsx +0 -222
  66. package/src/components/QuickSearch.tsx +0 -225
  67. package/src/components/RootRedirect.tsx +0 -40
  68. package/src/components/SpecDetailLayout.context.ts +0 -10
  69. package/src/components/SpecDetailLayout.tsx +0 -14
  70. package/src/components/SpecsNavSidebar.tsx +0 -615
  71. package/src/components/StatusBadge.tsx +0 -59
  72. package/src/components/ThemeToggle.tsx +0 -25
  73. package/src/components/Tooltip.tsx +0 -29
  74. package/src/components/context/ContextClient.tsx +0 -471
  75. package/src/components/context/ContextFileDetail.tsx +0 -163
  76. package/src/components/dashboard/ActivityItem.tsx +0 -36
  77. package/src/components/dashboard/DashboardClient.tsx +0 -218
  78. package/src/components/dashboard/SpecListItem.tsx +0 -58
  79. package/src/components/dashboard/StatCard.tsx +0 -52
  80. package/src/components/dependencies/SpecNode.tsx +0 -128
  81. package/src/components/dependencies/SpecSidebar.tsx +0 -256
  82. package/src/components/dependencies/constants.ts +0 -25
  83. package/src/components/dependencies/types.ts +0 -38
  84. package/src/components/dependencies/utils.ts +0 -261
  85. package/src/components/metadata-editors/PriorityEditor.tsx +0 -89
  86. package/src/components/metadata-editors/StatusEditor.tsx +0 -85
  87. package/src/components/metadata-editors/TagsEditor.tsx +0 -207
  88. package/src/components/projects/CreateProjectDialog.tsx +0 -162
  89. package/src/components/projects/DirectoryPicker.tsx +0 -182
  90. package/src/components/shared/BackToTop.tsx +0 -39
  91. package/src/components/shared/ColorPicker.tsx +0 -68
  92. package/src/components/shared/EmptyState.tsx +0 -35
  93. package/src/components/shared/ErrorBoundary.tsx +0 -79
  94. package/src/components/shared/PageHeader.tsx +0 -23
  95. package/src/components/shared/PageTransition.tsx +0 -40
  96. package/src/components/shared/ProjectAvatar.tsx +0 -107
  97. package/src/components/shared/Skeletons.tsx +0 -184
  98. package/src/components/spec-detail/EditableMetadata.tsx +0 -129
  99. package/src/components/spec-detail/MarkdownRenderer.tsx +0 -47
  100. package/src/components/spec-detail/TableOfContents.tsx +0 -150
  101. package/src/components/specs/BoardView.tsx +0 -204
  102. package/src/components/specs/ListView.tsx +0 -62
  103. package/src/components/specs/SpecsFilters.tsx +0 -190
  104. package/src/contexts/KeyboardShortcutsContext.tsx +0 -95
  105. package/src/contexts/LayoutContext.tsx +0 -45
  106. package/src/contexts/ProjectContext.tsx +0 -163
  107. package/src/contexts/ThemeContext.tsx +0 -90
  108. package/src/contexts/index.ts +0 -7
  109. package/src/hooks/useKeyboardShortcuts.ts +0 -87
  110. package/src/index.css +0 -624
  111. package/src/lib/api.ts +0 -72
  112. package/src/lib/backend-adapter.ts +0 -382
  113. package/src/lib/date-utils.ts +0 -122
  114. package/src/lib/i18n.test.ts +0 -57
  115. package/src/lib/i18n.ts +0 -51
  116. package/src/lib/markdown-utils.ts +0 -38
  117. package/src/lib/sub-spec-utils.ts +0 -166
  118. package/src/lib/utils.ts +0 -6
  119. package/src/locales/en/common.json +0 -660
  120. package/src/locales/en/errors.json +0 -20
  121. package/src/locales/en/help.json +0 -8
  122. package/src/locales/zh-CN/common.json +0 -660
  123. package/src/locales/zh-CN/errors.json +0 -20
  124. package/src/locales/zh-CN/help.json +0 -8
  125. package/src/main.tsx +0 -12
  126. package/src/pages/ContextPage.tsx +0 -111
  127. package/src/pages/DashboardPage.tsx +0 -97
  128. package/src/pages/DependenciesPage.tsx +0 -881
  129. package/src/pages/ProjectsPage.tsx +0 -432
  130. package/src/pages/SpecDetailPage.tsx +0 -592
  131. package/src/pages/SpecsPage.tsx +0 -319
  132. package/src/pages/StatsPage.tsx +0 -307
  133. package/src/router/projectRoutes.tsx +0 -36
  134. package/src/router.tsx +0 -33
  135. package/src/test/setup.ts +0 -39
  136. package/src/types/api.ts +0 -185
  137. package/tailwind.config.ts +0 -57
  138. package/tsconfig.app.json +0 -29
  139. package/tsconfig.json +0 -7
  140. package/tsconfig.node.json +0 -26
  141. package/tsconfig.tsbuildinfo +0 -1
  142. package/vite.config.ts +0 -27
  143. package/vitest.config.ts +0 -18
  144. /package/{public → dist}/favicon.ico +0 -0
  145. /package/{public → dist}/github-mark-white.svg +0 -0
  146. /package/{public → dist}/github-mark.svg +0 -0
  147. /package/{public → dist}/logo-dark-bg.svg +0 -0
  148. /package/{public → dist}/logo-with-bg.svg +0 -0
  149. /package/{public → dist}/logo.svg +0 -0
  150. /package/{public → dist}/vite.svg +0 -0
@@ -1,592 +0,0 @@
1
- import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
2
- import { Link, useNavigate, useParams, useSearchParams } from 'react-router-dom';
3
- import {
4
- AlertTriangle,
5
- RefreshCcw,
6
- GitBranch,
7
- Home,
8
- Clock,
9
- Maximize2,
10
- Minimize2,
11
- List as ListIcon,
12
- ExternalLink
13
- } from 'lucide-react';
14
- import { useSpecDetailLayoutContext } from '../components/SpecDetailLayout.context';
15
- import {
16
- Button,
17
- cn,
18
- Dialog,
19
- DialogContent,
20
- DialogHeader,
21
- DialogTitle,
22
- DialogDescription,
23
- SpecTimeline,
24
- SpecDependencyGraph,
25
- StatusBadge,
26
- PriorityBadge,
27
- type CompleteSpecRelationships
28
- } from '@leanspec/ui-components';
29
- import { APIError, api } from '../lib/api';
30
- import { StatusEditor } from '../components/metadata-editors/StatusEditor';
31
- import { PriorityEditor } from '../components/metadata-editors/PriorityEditor';
32
- import { TagsEditor } from '../components/metadata-editors/TagsEditor';
33
- import type { SubSpec } from '../types/api';
34
- import { TableOfContents, TableOfContentsSidebar } from '../components/spec-detail/TableOfContents';
35
- import { SpecDetailSkeleton } from '../components/shared/Skeletons';
36
- import { EmptyState } from '../components/shared/EmptyState';
37
- import { MarkdownRenderer } from '../components/spec-detail/MarkdownRenderer';
38
- import { BackToTop } from '../components/shared/BackToTop';
39
- import { useProject } from '../contexts';
40
- import { useTranslation } from 'react-i18next';
41
- import { formatDate, formatRelativeTime } from '../lib/date-utils';
42
- import type { SpecDetail } from '../types/api';
43
- import { PageTransition } from '../components/shared/PageTransition';
44
- import { getSubSpecStyle, formatSubSpecName } from '../lib/sub-spec-utils';
45
- import type { LucideIcon } from 'lucide-react';
46
-
47
- // Sub-spec with frontend-assigned styling
48
- interface EnrichedSubSpec extends SubSpec {
49
- icon: LucideIcon;
50
- color: string;
51
- }
52
-
53
- export function SpecDetailPage() {
54
- const { specName, projectId } = useParams<{ specName: string; projectId: string }>();
55
- const [searchParams] = useSearchParams();
56
- const navigate = useNavigate();
57
- const basePath = `/projects/${projectId}`;
58
- const { currentProject, loading: projectLoading } = useProject();
59
- const { t, i18n } = useTranslation(['common', 'errors']);
60
- const projectReady = !projectId || currentProject?.id === projectId;
61
- const [spec, setSpec] = useState<SpecDetail | null>(null);
62
- const [loading, setLoading] = useState(true);
63
- const [error, setError] = useState<string | null>(null);
64
- const currentSubSpec = searchParams.get('subspec');
65
- const headerRef = useRef<HTMLElement>(null);
66
- const [timelineDialogOpen, setTimelineDialogOpen] = useState(false);
67
- const [dependenciesDialogOpen, setDependenciesDialogOpen] = useState(false);
68
- const [dependencyGraphData, setDependencyGraphData] = useState<CompleteSpecRelationships | null>(null);
69
- const [isFocusMode, setIsFocusMode] = useState(false);
70
- const { setMobileOpen } = useSpecDetailLayoutContext();
71
-
72
- const describeError = useCallback((err: unknown) => {
73
- if (err instanceof APIError) {
74
- switch (err.status) {
75
- case 404:
76
- return t('specNotFound', { ns: 'errors' });
77
- case 400:
78
- return t('invalidInput', { ns: 'errors' });
79
- case 500:
80
- return t('unknownError', { ns: 'errors' });
81
- default:
82
- return t('loadingError', { ns: 'errors' });
83
- }
84
- }
85
-
86
- if (err instanceof Error && err.message.includes('Failed to fetch')) {
87
- return t('networkError', { ns: 'errors' });
88
- }
89
-
90
- return err instanceof Error ? err.message : t('unknownError', { ns: 'errors' });
91
- }, [t]);
92
-
93
- const loadSpec = useCallback(async () => {
94
- if (!specName || !projectReady || projectLoading) return;
95
- setLoading(true);
96
- try {
97
- const data = await api.getSpec(specName);
98
- setSpec(data);
99
- setError(null);
100
- } catch (err) {
101
- setError(describeError(err));
102
- } finally {
103
- setLoading(false);
104
- }
105
- }, [describeError, projectLoading, projectReady, specName]);
106
-
107
- useEffect(() => {
108
- void loadSpec();
109
- }, [loadSpec, projectReady]);
110
-
111
- useEffect(() => {
112
- if (dependenciesDialogOpen && !dependencyGraphData && spec) {
113
- const loadGraph = async () => {
114
- try {
115
- // Fetch all specs to get details for dependencies
116
- // In a real app, we should have a dedicated endpoint for this
117
- const allSpecs = await api.getSpecs();
118
-
119
- const findSpec = (idOrName: string) =>
120
- allSpecs.find(s => s.id === idOrName || s.specName === idOrName);
121
-
122
- const current = {
123
- specName: spec.specName,
124
- specNumber: spec.specNumber || undefined,
125
- status: spec.status || undefined,
126
- priority: spec.priority || undefined
127
- };
128
-
129
- const dependsOn = (spec.dependsOn || []).map(id => {
130
- const s = findSpec(id);
131
- return {
132
- specName: s?.specName || id,
133
- specNumber: s?.specNumber || undefined,
134
- title: s?.title || undefined,
135
- status: s?.status || undefined,
136
- priority: s?.priority || undefined
137
- };
138
- });
139
-
140
- const requiredBy = (spec.requiredBy || []).map(id => {
141
- const s = findSpec(id);
142
- return {
143
- specName: s?.specName || id,
144
- specNumber: s?.specNumber || undefined,
145
- title: s?.title || undefined,
146
- status: s?.status || undefined,
147
- priority: s?.priority || undefined
148
- };
149
- });
150
-
151
- setDependencyGraphData({ current, dependsOn, requiredBy });
152
- } catch (err) {
153
- console.error('Failed to load dependency graph data', err);
154
- }
155
- };
156
- void loadGraph();
157
- }
158
- }, [dependenciesDialogOpen, dependencyGraphData, spec]);
159
-
160
- const subSpecs: EnrichedSubSpec[] = useMemo(() => {
161
- const raw = (spec?.subSpecs as unknown) ?? (spec?.metadata?.sub_specs as unknown);
162
- if (!Array.isArray(raw)) return [];
163
- return raw
164
- .map((entry) => {
165
- if (!entry || typeof entry !== 'object') return null;
166
- const content = (entry as Record<string, unknown>).content;
167
- if (typeof content !== 'string') return null;
168
-
169
- const file = typeof (entry as Record<string, unknown>).file === 'string'
170
- ? (entry as Record<string, unknown>).file as string
171
- : typeof (entry as Record<string, unknown>).name === 'string'
172
- ? (entry as Record<string, unknown>).name as string
173
- : '';
174
-
175
- // Use frontend styling logic based on filename
176
- const style = getSubSpecStyle(file);
177
-
178
- return {
179
- name: formatSubSpecName(file),
180
- content,
181
- file,
182
- icon: style.icon,
183
- color: style.color,
184
- };
185
- })
186
- .filter(Boolean) as EnrichedSubSpec[];
187
- }, [spec]);
188
-
189
- const applySpecPatch = (updates: Partial<SpecDetail>) => {
190
- setSpec((prev) => (prev ? { ...prev, ...updates } : prev));
191
- };
192
-
193
- // Handle sub-spec switching
194
- const handleSubSpecSwitch = (file: string | null) => {
195
- const newUrl = file
196
- ? `${basePath}/specs/${specName}?subspec=${file}`
197
- : `${basePath}/specs/${specName}`;
198
- navigate(newUrl);
199
- };
200
-
201
- // Get content to display (main or sub-spec)
202
- let displayContent = spec?.content || spec?.contentMd || '';
203
- if (currentSubSpec && spec && subSpecs.length > 0) {
204
- const subSpecData = subSpecs.find(s => s.file === currentSubSpec);
205
- if (subSpecData) {
206
- displayContent = subSpecData.content;
207
- }
208
- }
209
-
210
- // Extract title
211
- const displayTitle = spec?.title || spec?.specName || '';
212
- const tags = useMemo(() => spec?.tags || [], [spec?.tags]);
213
- const updatedRelative = spec?.updatedAt ? formatRelativeTime(spec.updatedAt, i18n.language) : null;
214
-
215
- // Handle scroll padding for sticky header
216
- useEffect(() => {
217
- const updateScrollPadding = () => {
218
- const navbarHeight = 56; // 3.5rem / top-14
219
- let offset = 0;
220
-
221
- // On large screens, the spec header is also sticky
222
- if (window.innerWidth >= 1024 && headerRef.current) {
223
- offset += headerRef.current.offsetHeight - navbarHeight;
224
- }
225
-
226
- const specDetailMain = document.querySelector<HTMLDivElement>('#spec-detail-main');
227
- if (specDetailMain) {
228
- specDetailMain.style.scrollPaddingTop = `${offset}px`;
229
- }
230
- };
231
-
232
- updateScrollPadding();
233
- window.addEventListener('resize', updateScrollPadding);
234
-
235
- const observer = new ResizeObserver(updateScrollPadding);
236
- if (headerRef.current) {
237
- observer.observe(headerRef.current);
238
- }
239
-
240
- return () => {
241
- window.removeEventListener('resize', updateScrollPadding);
242
- observer.disconnect();
243
- document.documentElement.style.scrollPaddingTop = '';
244
- };
245
- }, [spec, tags]);
246
-
247
-
248
- if (loading) {
249
- return <SpecDetailSkeleton />;
250
- }
251
-
252
- if (error || !spec) {
253
- return (
254
- <EmptyState
255
- icon={AlertTriangle}
256
- title={t('specDetail.state.unavailableTitle')}
257
- description={error || t('specDetail.state.unavailableDescription')}
258
- tone="error"
259
- actions={(
260
- <>
261
- <Link to={`${basePath}/specs`} className="inline-flex">
262
- <Button variant="outline" size="sm" className="gap-2">
263
- {t('specDetail.links.backToSpecs')}
264
- </Button>
265
- </Link>
266
- <Button variant="secondary" size="sm" className="gap-2" onClick={() => void loadSpec()}>
267
- <RefreshCcw className="h-4 w-4" />
268
- {t('actions.retry')}
269
- </Button>
270
- <a
271
- href="https://github.com/codervisor/lean-spec/issues"
272
- target="_blank"
273
- rel="noreferrer"
274
- className="inline-flex"
275
- >
276
- <Button variant="ghost" size="sm" className="gap-2">
277
- {t('specDetail.links.reportIssue')}
278
- </Button>
279
- </a>
280
- </>
281
- )}
282
- />
283
- );
284
- }
285
-
286
- const dependsOn = spec.dependsOn || [];
287
- const requiredBy = spec.requiredBy || [];
288
- const hasRelationships = dependsOn.length > 0 || requiredBy.length > 0;
289
-
290
- return (
291
- <PageTransition className="w-full">
292
- <div className="flex-1 min-w-0 overflow-y-auto h-[calc(100vh-3.5rem)]">
293
- {/* Mobile Sidebar Toggle Button */}
294
- <div className="lg:hidden sticky top-0 z-20 flex items-center justify-between bg-background/95 backdrop-blur border-b px-3 py-2">
295
- <span className="text-sm font-semibold">{t('specsNavSidebar.title')}</span>
296
- <Button size="sm" variant="outline" onClick={() => setMobileOpen(true)}>
297
- {t('actions.openSidebar')}
298
- </Button>
299
- </div>
300
-
301
- {/* Compact Header - sticky on desktop */}
302
- <header ref={headerRef} className="lg:sticky lg:top-0 lg:z-20 border-b bg-card">
303
- <div className={cn("px-3 sm:px-6", isFocusMode ? "py-1.5" : "py-2 sm:py-3")}>
304
- {/* Focus mode: Single compact row */}
305
- {isFocusMode ? (
306
- <div className="flex items-center justify-between gap-3">
307
- <div className="flex items-center gap-3 min-w-0">
308
- <h1 className="text-base font-semibold tracking-tight truncate">
309
- {spec.specNumber && (
310
- <span className="text-muted-foreground">#{spec.specNumber.toString().padStart(3, '0')} </span>
311
- )}
312
- {displayTitle}
313
- </h1>
314
- <StatusBadge status={spec.status || 'planned'} />
315
- <PriorityBadge priority={spec.priority || 'medium'} />
316
- </div>
317
- <Button
318
- type="button"
319
- variant="ghost"
320
- size="sm"
321
- onClick={() => setIsFocusMode(false)}
322
- className="h-7 px-2 text-xs text-muted-foreground hover:text-foreground shrink-0"
323
- title={t('specDetail.buttons.exitFocus')}
324
- >
325
- <Minimize2 className="h-4 w-4" />
326
- </Button>
327
- </div>
328
- ) : (
329
- /* Normal mode: Full multi-line header */
330
- <>
331
- {/* Line 1: Spec number + H1 Title */}
332
- <div className="flex items-start justify-between gap-2 mb-1.5 sm:mb-2">
333
- <h1 className="text-lg sm:text-xl font-bold tracking-tight">
334
- {spec.specNumber && (
335
- <span className="text-muted-foreground">#{spec.specNumber.toString().padStart(3, '0')} </span>
336
- )}
337
- {displayTitle}
338
- </h1>
339
-
340
- {/* Mobile Specs List Toggle */}
341
- <Button
342
- variant="ghost"
343
- size="icon"
344
- className="lg:hidden h-8 w-8 -mr-2 shrink-0 text-muted-foreground"
345
- onClick={() => setMobileOpen(true)}
346
- >
347
- <ListIcon className="h-5 w-5" />
348
- <span className="sr-only">{t('specDetail.toggleSidebar')}</span>
349
- </Button>
350
- </div>
351
-
352
- {/* Line 2: Status, Priority, Tags */}
353
- <div className="flex flex-wrap items-center gap-2">
354
- <StatusEditor
355
- specName={spec.specName}
356
- value={spec.status}
357
- onChange={(status) => applySpecPatch({ status })}
358
- />
359
- <PriorityEditor
360
- specName={spec.specName}
361
- value={spec.priority}
362
- onChange={(priority) => applySpecPatch({ priority })}
363
- />
364
-
365
- <div className="h-4 w-px bg-border mx-1 hidden sm:block" />
366
- <TagsEditor
367
- specName={spec.specName}
368
- value={tags}
369
- onChange={(tags) => applySpecPatch({ tags })}
370
- />
371
- </div>
372
-
373
- {/* Line 3: Small metadata row */}
374
- <div className="flex flex-wrap gap-2 sm:gap-4 text-xs text-muted-foreground mt-1.5 sm:mt-2">
375
- <span className="hidden sm:inline">
376
- {t('specDetail.metadata.created')}: {formatDate(spec.createdAt, i18n.language)}
377
- </span>
378
- <span className="hidden sm:inline">•</span>
379
- <span>
380
- {t('specDetail.metadata.updated')}: {formatDate(spec.updatedAt, i18n.language)}
381
- {updatedRelative && (
382
- <span className="ml-1 text-[11px] text-muted-foreground/80">({updatedRelative})</span>
383
- )}
384
- </span>
385
- <span className="hidden sm:inline">•</span>
386
- <span className="hidden md:inline">{t('specDetail.metadata.name')}: {spec.specName}</span>
387
- {spec.metadata?.assignee ? (
388
- <>
389
- <span className="hidden sm:inline">•</span>
390
- <span className="hidden sm:inline">{t('specDetail.metadata.assignee')}: {String(spec.metadata.assignee)}</span>
391
- </>
392
- ) : null}
393
- </div>
394
-
395
- {/* Action buttons row */}
396
- <div className="flex flex-wrap items-center gap-2 mt-2">
397
- <Dialog open={timelineDialogOpen} onOpenChange={setTimelineDialogOpen}>
398
- <Button
399
- type="button"
400
- variant="outline"
401
- size="sm"
402
- aria-haspopup="dialog"
403
- aria-expanded={timelineDialogOpen}
404
- onClick={() => setTimelineDialogOpen(true)}
405
- className="h-8 rounded-full border px-3 text-xs font-medium text-muted-foreground transition-colors hover:text-foreground"
406
- >
407
- <Clock className="mr-1.5 h-3.5 w-3.5" />
408
- {t('specDetail.buttons.viewTimeline')}
409
- <Maximize2 className="ml-1.5 h-3.5 w-3.5" />
410
- </Button>
411
- <DialogContent className="w-[min(900px,90vw)] max-w-3xl max-h-[90vh] overflow-y-auto">
412
- <DialogHeader>
413
- <DialogTitle>{t('specDetail.dialogs.timelineTitle')}</DialogTitle>
414
- <DialogDescription>{t('specDetail.dialogs.timelineDescription')}</DialogDescription>
415
- </DialogHeader>
416
- <div className="rounded-xl border border-border bg-muted/30 p-4">
417
- <SpecTimeline
418
- createdAt={spec.createdAt}
419
- updatedAt={spec.updatedAt}
420
- completedAt={spec.completedAt}
421
- status={spec.status || 'planned'}
422
- labels={{
423
- created: t('specTimeline.events.created'),
424
- inProgress: t('specTimeline.events.inProgress'),
425
- complete: t('specTimeline.events.complete'),
426
- archived: t('specTimeline.events.archived'),
427
- awaiting: t('specTimeline.state.awaiting'),
428
- queued: t('specTimeline.state.queued'),
429
- pending: t('specTimeline.state.pending'),
430
- }}
431
- language={i18n.language}
432
- />
433
- </div>
434
- </DialogContent>
435
- </Dialog>
436
-
437
- <Dialog open={dependenciesDialogOpen} onOpenChange={setDependenciesDialogOpen}>
438
- <Button
439
- type="button"
440
- variant="outline"
441
- size="sm"
442
- aria-haspopup="dialog"
443
- aria-expanded={dependenciesDialogOpen}
444
- onClick={() => setDependenciesDialogOpen(true)}
445
- disabled={!hasRelationships}
446
- className={cn(
447
- 'h-8 rounded-full border px-3 text-xs font-medium text-muted-foreground transition-colors hover:text-foreground',
448
- !hasRelationships && 'cursor-not-allowed opacity-50'
449
- )}
450
- >
451
- <GitBranch className="mr-1.5 h-3.5 w-3.5" />
452
- {t('specDetail.buttons.viewDependencies')}
453
- <Maximize2 className="ml-1.5 h-3.5 w-3.5" />
454
- </Button>
455
- <DialogContent className="flex h-[85vh] w-[min(1200px,95vw)] max-w-6xl flex-col gap-4 overflow-hidden">
456
- <DialogHeader>
457
- <DialogTitle>{t('specDetail.dialogs.dependenciesTitle')}</DialogTitle>
458
- <DialogDescription className="flex flex-col gap-2">
459
- <span>{t('specDetail.dialogs.dependenciesDescription')}</span>
460
- <Link
461
- to={projectId
462
- ? `/projects/${projectId}/dependencies?spec=${spec.specNumber || spec.id}`
463
- : `/dependencies?spec=${spec.specNumber || spec.id}`
464
- }
465
- className="inline-flex items-center gap-1.5 text-xs text-primary hover:underline w-fit"
466
- onClick={() => setDependenciesDialogOpen(false)}
467
- >
468
- <ExternalLink className="h-3 w-3" />
469
- {t('specDetail.dialogs.dependenciesLink')}
470
- </Link>
471
- </DialogDescription>
472
- </DialogHeader>
473
- <div className="min-h-0 flex-1">
474
- {dependencyGraphData && (
475
- <SpecDependencyGraph
476
- relationships={dependencyGraphData}
477
- specNumber={spec.specNumber}
478
- specTitle={displayTitle}
479
- labels={{
480
- title: t('dependencyGraph.header.title'),
481
- subtitle: t('dependencyGraph.header.subtitle'),
482
- badge: t('dependencyGraph.header.badge'),
483
- currentBadge: t('dependencyGraph.badges.current'),
484
- currentSubtitle: t('dependencyGraph.badges.currentSubtitle'),
485
- dependsOnBadge: t('dependencyGraph.badges.dependsOn'),
486
- dependsOnSubtitle: t('dependencyGraph.badges.dependsOnSubtitle'),
487
- requiredByBadge: t('dependencyGraph.badges.requiredBy'),
488
- requiredBySubtitle: t('dependencyGraph.badges.requiredBySubtitle'),
489
- completedSubtitle: t('dependencyGraph.statusSubtitles.completed'),
490
- inProgressSubtitle: t('dependencyGraph.statusSubtitles.inProgress'),
491
- plannedBlockingSubtitle: t('dependencyGraph.statusSubtitles.plannedBlocking'),
492
- plannedCanProceedSubtitle: t('dependencyGraph.statusSubtitles.plannedCanProceed'),
493
- archivedSubtitle: t('dependencyGraph.statusSubtitles.archived'),
494
- }}
495
- onNodeClick={(specId) => {
496
- const url = projectId
497
- ? `/projects/${projectId}/specs/${specId}`
498
- : `/projects/default/specs/${specId}`;
499
- navigate(url);
500
- setDependenciesDialogOpen(false);
501
- }}
502
- />
503
- )}
504
- </div>
505
- </DialogContent>
506
- </Dialog>
507
-
508
- {/* Focus Mode Toggle */}
509
- <Button
510
- type="button"
511
- variant="outline"
512
- size="sm"
513
- onClick={() => setIsFocusMode(true)}
514
- className="hidden lg:inline-flex h-8 rounded-full border px-3 text-xs font-medium text-muted-foreground transition-colors hover:text-foreground"
515
- title={t('specDetail.buttons.focus')}
516
- >
517
- <Maximize2 className="mr-1.5 h-3.5 w-3.5" />
518
- {t('specDetail.buttons.focus')}
519
- </Button>
520
- </div>
521
- </>
522
- )}
523
- </div>
524
-
525
- {/* Horizontal Tabs for Sub-specs */}
526
- {subSpecs.length > 0 && (
527
- <div className="border-t bg-muted/30">
528
- <div className="px-3 sm:px-6 overflow-x-auto">
529
- <div className="flex gap-1 py-2 min-w-max">
530
- {/* Overview tab (README.md) */}
531
- <button
532
- onClick={() => handleSubSpecSwitch(null)}
533
- className={`flex items-center gap-2 px-3 sm:px-4 py-2 text-xs sm:text-sm font-medium rounded-md whitespace-nowrap transition-colors ${!currentSubSpec
534
- ? 'bg-background text-foreground shadow-sm'
535
- : 'text-muted-foreground hover:text-foreground hover:bg-muted/50'
536
- }`}
537
- >
538
- <Home className="h-4 w-4" />
539
- <span className="hidden sm:inline">{t('specDetail.tabs.overview')}</span>
540
- </button>
541
-
542
- {/* Sub-spec tabs */}
543
- {subSpecs.map((subSpec) => {
544
- const Icon = subSpec.icon;
545
- return (
546
- <button
547
- key={subSpec.file}
548
- onClick={() => handleSubSpecSwitch(subSpec.file)}
549
- className={`flex items-center gap-2 px-3 sm:px-4 py-2 text-xs sm:text-sm font-medium rounded-md whitespace-nowrap transition-colors ${currentSubSpec === subSpec.file
550
- ? 'bg-background text-foreground shadow-sm'
551
- : 'text-muted-foreground hover:text-foreground hover:bg-muted/50'
552
- }`}
553
- >
554
- <Icon className={`h-4 w-4 ${subSpec.color}`} />
555
- <span className="hidden sm:inline">{subSpec.name}</span>
556
- </button>
557
- );
558
- })}
559
- </div>
560
- </div>
561
- </div>
562
- )}
563
- </header>
564
-
565
- {/* Main content with Sidebar */}
566
- <div className="flex flex-col xl:flex-row xl:items-start">
567
- <main className="flex-1 px-3 sm:px-6 py-3 sm:py-6 min-w-0">
568
- <MarkdownRenderer content={displayContent} />
569
- </main>
570
-
571
- {/* Right Sidebar for TOC (Desktop only) */}
572
- <aside
573
- className={cn(
574
- "hidden xl:block w-72 shrink-0 px-6 py-6 sticky overflow-y-auto scrollbar-auto-hide",
575
- subSpecs.length > 0
576
- ? "top-[calc(16.375rem-3.5rem)] h-[calc(100vh-16.375rem)]"
577
- : "top-[calc(13.125rem-3.5rem)] h-[calc(100vh-13.125rem)]"
578
- )}
579
- >
580
- <TableOfContentsSidebar content={displayContent} />
581
- </aside>
582
- </div>
583
-
584
- {/* Floating action buttons (Mobile/Tablet only) */}
585
- <div className="xl:hidden">
586
- <TableOfContents content={displayContent} />
587
- </div>
588
- <BackToTop />
589
- </div>
590
- </PageTransition>
591
- );
592
- }