@prmichaelsen/acp-visualizer 0.8.2 → 0.9.1

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@prmichaelsen/acp-visualizer",
3
- "version": "0.8.2",
3
+ "version": "0.9.1",
4
4
  "type": "module",
5
5
  "description": "Browser-based dashboard for visualizing ACP progress.yaml data",
6
6
  "bin": {
@@ -1,5 +1,6 @@
1
1
  import { useMemo } from 'react'
2
2
  import dagre from 'dagre'
3
+ import { formatTaskName, formatMilestoneName } from '../lib/display'
3
4
  import type { ProgressData, Task, Status } from '../lib/types'
4
5
 
5
6
  interface DependencyGraphProps {
@@ -43,7 +44,7 @@ function buildGraph(data: ProgressData): { nodes: GraphNode[]; edges: GraphEdge[
43
44
  for (const milestone of data.milestones) {
44
45
  const tasks = data.tasks[milestone.id] || []
45
46
  for (const task of tasks) {
46
- allTasks.push({ ...task, milestoneName: milestone.name })
47
+ allTasks.push({ ...task, milestoneName: formatMilestoneName(milestone) })
47
48
  }
48
49
  }
49
50
 
@@ -80,7 +81,7 @@ function buildGraph(data: ProgressData): { nodes: GraphNode[]; edges: GraphEdge[
80
81
  const node = g.node(String(task.id))
81
82
  return {
82
83
  id: String(task.id),
83
- label: task.name,
84
+ label: formatTaskName(task),
84
85
  status: task.status,
85
86
  milestone: task.milestoneName,
86
87
  x: node.x,
@@ -1,5 +1,7 @@
1
+ import { Moon, Sun } from 'lucide-react'
1
2
  import { StatusBadge } from './StatusBadge'
2
3
  import { ProgressBar } from './ProgressBar'
4
+ import { useTheme } from '../lib/useTheme'
3
5
  import type { ProgressData } from '../lib/types'
4
6
 
5
7
  interface HeaderProps {
@@ -7,16 +9,31 @@ interface HeaderProps {
7
9
  }
8
10
 
9
11
  export function Header({ data }: HeaderProps) {
12
+ const { theme, toggleTheme } = useTheme()
13
+
10
14
  if (!data) return null
11
15
 
12
16
  return (
13
- <header className="h-14 border-b border-gray-800 flex items-center px-6 gap-4 shrink-0">
14
- <h1 className="text-sm font-medium text-gray-200">{data.project.name}</h1>
15
- <span className="text-xs text-gray-500 font-mono">v{data.project.version}</span>
17
+ <header className="h-14 border-b border-gray-200 dark:border-gray-800 flex items-center px-6 gap-4 shrink-0 bg-white dark:bg-gray-950">
18
+ <h1 className="text-sm font-medium text-gray-900 dark:text-gray-200">{data.project.name}</h1>
19
+ <span className="text-xs text-gray-500 dark:text-gray-500 font-mono">v{data.project.version}</span>
16
20
  <StatusBadge status={data.project.status} />
17
- <div className="ml-auto flex items-center gap-3 w-48">
18
- <ProgressBar value={data.progress.overall} size="sm" />
19
- <span className="text-xs text-gray-400 font-mono">{data.progress.overall}%</span>
21
+ <div className="ml-auto flex items-center gap-4">
22
+ <div className="flex items-center gap-3 w-48">
23
+ <ProgressBar value={data.progress.overall} size="sm" />
24
+ <span className="text-xs text-gray-600 dark:text-gray-400 font-mono">{data.progress.overall}%</span>
25
+ </div>
26
+ <button
27
+ onClick={toggleTheme}
28
+ className="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
29
+ aria-label="Toggle theme"
30
+ >
31
+ {theme === 'dark' ? (
32
+ <Moon className="w-4 h-4 text-gray-400" />
33
+ ) : (
34
+ <Sun className="w-4 h-4 text-gray-600" />
35
+ )}
36
+ </button>
20
37
  </div>
21
38
  </header>
22
39
  )
@@ -1,5 +1,6 @@
1
1
  import { useMemo } from 'react'
2
2
  import { StatusBadge } from './StatusBadge'
3
+ import { formatMilestoneName } from '../lib/display'
3
4
  import type { Milestone, Task } from '../lib/types'
4
5
 
5
6
  interface MilestoneGanttProps {
@@ -118,17 +119,17 @@ export function MilestoneGantt({ milestones, tasks }: MilestoneGanttProps) {
118
119
  : 'bg-gray-500/30 border-gray-500/40'
119
120
 
120
121
  return (
121
- <div key={milestone.id} className="flex items-center h-12 px-3 hover:bg-gray-800/20">
122
+ <div key={milestone.id} className="flex items-center h-12 px-3 hover:bg-gray-200/20 dark:hover:bg-gray-800/20">
122
123
  {/* Label */}
123
124
  <div className="w-48 shrink-0 flex items-center gap-2">
124
- <span className="text-xs text-gray-300 truncate">{milestone.name}</span>
125
+ <span className="text-xs text-gray-700 dark:text-gray-300 truncate">{formatMilestoneName(milestone)}</span>
125
126
  </div>
126
127
  {/* Bar area */}
127
128
  <div className="flex-1 relative h-6">
128
129
  <div
129
130
  className={`absolute top-1 h-4 rounded-sm border ${barColor} transition-all`}
130
131
  style={{ left: `${barStart}%`, width: `${barWidth}%` }}
131
- title={`${milestone.name}: ${start ? formatDate(start) : '?'} → ${end ? formatDate(end) : '?'} (${milestone.progress}%)`}
132
+ title={`${formatMilestoneName(milestone)}: ${start ? formatDate(start) : '?'} → ${end ? formatDate(end) : '?'} (${milestone.progress}%)`}
132
133
  >
133
134
  {/* Progress fill within bar */}
134
135
  <div
@@ -2,7 +2,9 @@ import { Link } from '@tanstack/react-router'
2
2
  import { StatusBadge } from './StatusBadge'
3
3
  import { PriorityBadge } from './PriorityBadge'
4
4
  import { ProgressBar } from './ProgressBar'
5
+ import { PreviewButton } from './PreviewButton'
5
6
  import { TaskList } from './TaskList'
7
+ import { formatMilestoneName } from '../lib/display'
6
8
  import type { Milestone, Task, Status } from '../lib/types'
7
9
  import { useState } from 'react'
8
10
  import { ChevronDown, ChevronRight } from 'lucide-react'
@@ -29,15 +31,18 @@ function KanbanCard({
29
31
  const [expanded, setExpanded] = useState(false)
30
32
 
31
33
  return (
32
- <div className="bg-gray-900/50 border border-gray-800 rounded-lg p-3 hover:border-gray-700 transition-colors">
34
+ <div className="bg-gray-100 dark:bg-gray-900/50 border border-gray-200 dark:border-gray-800 rounded-lg p-3 hover:border-gray-300 dark:hover:border-gray-700 transition-colors group">
33
35
  <div className="flex items-start justify-between gap-2 mb-2">
34
- <Link
35
- to="/milestones/$milestoneId"
36
- params={{ milestoneId: milestone.id }}
37
- className="text-sm font-medium leading-tight hover:text-blue-400 transition-colors"
38
- >
39
- {milestone.name}
40
- </Link>
36
+ <div className="flex items-center gap-2 flex-1">
37
+ <Link
38
+ to="/milestones/$milestoneId"
39
+ params={{ milestoneId: milestone.id }}
40
+ className="text-sm font-medium leading-tight text-gray-900 dark:text-gray-200 hover:text-blue-500 dark:hover:text-blue-400 transition-colors"
41
+ >
42
+ {formatMilestoneName(milestone)}
43
+ </Link>
44
+ <PreviewButton type="milestone" id={milestone.id} />
45
+ </div>
41
46
  <PriorityBadge priority={milestone.priority} />
42
47
  </div>
43
48
  <div className="flex items-center gap-2 mb-2">
@@ -0,0 +1,163 @@
1
+ import { Link } from '@tanstack/react-router'
2
+ import { useState, useEffect, useMemo } from 'react'
3
+ import { ExternalLink } from 'lucide-react'
4
+ import { useProgressData } from '../contexts/ProgressContext'
5
+ import { DetailHeader } from './DetailHeader'
6
+ import { ProgressBar } from './ProgressBar'
7
+ import { StatusDot } from './StatusDot'
8
+ import { PriorityBadge } from './PriorityBadge'
9
+ import { MarkdownContent, buildLinkMap } from './MarkdownContent'
10
+ import { getMarkdownContent, resolveMilestoneFile } from '../services/markdown.service'
11
+ import { formatMilestoneName } from '../lib/display'
12
+ import type { MarkdownResult, ResolveFileResult } from '../services/markdown.service'
13
+
14
+ interface MilestonePreviewProps {
15
+ milestoneId: string
16
+ }
17
+
18
+ function getGitHubParams(): { owner: string; repo: string } | undefined {
19
+ if (typeof window === 'undefined') return undefined
20
+ const params = new URLSearchParams(window.location.search)
21
+ const repo = params.get('repo')
22
+ if (!repo) return undefined
23
+ const parts = repo.split('/')
24
+ if (parts.length < 2) return undefined
25
+ return { owner: parts[0], repo: parts[1] }
26
+ }
27
+
28
+ export function MilestonePreview({ milestoneId }: MilestonePreviewProps) {
29
+ const data = useProgressData()
30
+ const [markdown, setMarkdown] = useState<string | null>(null)
31
+ const [markdownError, setMarkdownError] = useState<string | null>(null)
32
+ const [markdownFilePath, setMarkdownFilePath] = useState<string | null>(null)
33
+ const [loading, setLoading] = useState(true)
34
+
35
+ const milestone = data?.milestones.find((m) => m.id === milestoneId)
36
+ const tasks = data?.tasks[milestoneId] || []
37
+ const linkMap = useMemo(() => (data ? buildLinkMap(data) : {}), [data])
38
+
39
+ useEffect(() => {
40
+ if (!milestoneId) return
41
+
42
+ setLoading(true)
43
+ setMarkdown(null)
44
+ setMarkdownError(null)
45
+ setMarkdownFilePath(null)
46
+
47
+ const github = getGitHubParams()
48
+
49
+ resolveMilestoneFile({ data: { milestoneId, github } })
50
+ .then((resolveResult: ResolveFileResult) => {
51
+ if (!resolveResult.ok) {
52
+ setMarkdownError(resolveResult.error)
53
+ setLoading(false)
54
+ return
55
+ }
56
+
57
+ setMarkdownFilePath(resolveResult.filePath)
58
+ return getMarkdownContent({ data: { filePath: resolveResult.filePath, github } })
59
+ .then((mdResult: MarkdownResult) => {
60
+ if (mdResult.ok) {
61
+ setMarkdown(mdResult.content)
62
+ } else {
63
+ setMarkdownError(mdResult.error)
64
+ }
65
+ })
66
+ })
67
+ .catch((err: Error) => {
68
+ setMarkdownError(err.message)
69
+ })
70
+ .finally(() => {
71
+ setLoading(false)
72
+ })
73
+ }, [milestoneId])
74
+
75
+ if (!data || !milestone) {
76
+ return (
77
+ <div className="text-center py-8">
78
+ <p className="text-gray-500 dark:text-gray-400 text-sm">Milestone not found: {milestoneId}</p>
79
+ </div>
80
+ )
81
+ }
82
+
83
+ const fields = [
84
+ ...(milestone.started ? [{ label: 'Started', value: milestone.started }] : []),
85
+ ...(milestone.completed ? [{ label: 'Completed', value: milestone.completed }] : []),
86
+ { label: 'Est', value: `${milestone.estimated_weeks} week${milestone.estimated_weeks === '1' ? '' : 's'}` },
87
+ { label: 'Tasks', value: `${milestone.tasks_completed}/${milestone.tasks_total}` },
88
+ ]
89
+
90
+ return (
91
+ <div>
92
+ <div className="flex items-start justify-between mb-4">
93
+ <h1 className="text-lg font-semibold text-gray-900 dark:text-gray-100">{formatMilestoneName(milestone)}</h1>
94
+ <Link
95
+ to="/milestones/$milestoneId"
96
+ params={{ milestoneId }}
97
+ className="p-1.5 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
98
+ title="Open full view"
99
+ >
100
+ <ExternalLink className="w-4 h-4 text-gray-600 dark:text-gray-400" />
101
+ </Link>
102
+ </div>
103
+
104
+ <div className="flex items-center gap-3 mb-4">
105
+ <div className="flex-1 max-w-xs">
106
+ <ProgressBar value={milestone.progress} size="sm" />
107
+ </div>
108
+ <span className="text-xs text-gray-600 dark:text-gray-500">{milestone.progress}%</span>
109
+ </div>
110
+
111
+ <div className="flex items-center gap-2 mb-4">
112
+ <PriorityBadge priority={milestone.priority} />
113
+ </div>
114
+
115
+ <DetailHeader status={milestone.status} fields={fields} />
116
+
117
+ {milestone.notes && (
118
+ <p className="text-sm text-gray-600 dark:text-gray-400 mb-6">{milestone.notes}</p>
119
+ )}
120
+
121
+ {/* Markdown content */}
122
+ {loading ? (
123
+ <p className="text-sm text-gray-600 dark:text-gray-500">Loading document...</p>
124
+ ) : markdown ? (
125
+ <div className="prose-sm">
126
+ <MarkdownContent content={markdown} basePath={markdownFilePath ?? undefined} linkMap={linkMap} />
127
+ </div>
128
+ ) : markdownError ? (
129
+ <div className="bg-gray-100 dark:bg-gray-900/50 border border-gray-200 dark:border-gray-800 rounded-xl p-4 text-sm text-gray-600 dark:text-gray-500">
130
+ No document found — {markdownError}
131
+ </div>
132
+ ) : null}
133
+
134
+ {/* Task list */}
135
+ {tasks.length > 0 && (
136
+ <div className="mt-8">
137
+ <h2 className="text-sm font-semibold text-gray-700 dark:text-gray-400 uppercase tracking-wider mb-3">
138
+ Tasks
139
+ </h2>
140
+ <div className="bg-gray-100 dark:bg-gray-900/50 border border-gray-200 dark:border-gray-800 rounded-xl divide-y divide-gray-200 dark:divide-gray-800">
141
+ {tasks.map((task) => (
142
+ <Link
143
+ key={task.id}
144
+ to="/tasks/$taskId"
145
+ params={{ taskId: task.id }}
146
+ className="flex items-center gap-2 px-4 py-2.5 text-sm hover:bg-gray-200/50 dark:hover:bg-gray-800/50 transition-colors first:rounded-t-xl last:rounded-b-xl"
147
+ >
148
+ <StatusDot status={task.status} />
149
+ <span className={task.status === 'completed' ? 'text-gray-500 dark:text-gray-500' : 'text-gray-900 dark:text-gray-200'}>
150
+ {task.name}
151
+ </span>
152
+ <PriorityBadge priority={task.priority} />
153
+ {task.estimated_hours && (
154
+ <span className="text-xs text-gray-600 dark:text-gray-600 ml-auto">{task.estimated_hours}h</span>
155
+ )}
156
+ </Link>
157
+ ))}
158
+ </div>
159
+ </div>
160
+ )}
161
+ </div>
162
+ )
163
+ }
@@ -12,7 +12,9 @@ import { ChevronDown, ChevronRight, ArrowUpDown } from 'lucide-react'
12
12
  import { StatusBadge } from './StatusBadge'
13
13
  import { PriorityBadge } from './PriorityBadge'
14
14
  import { ProgressBar } from './ProgressBar'
15
+ import { PreviewButton } from './PreviewButton'
15
16
  import { TaskList } from './TaskList'
17
+ import { formatMilestoneName } from '../lib/display'
16
18
  import type { Milestone, Task } from '../lib/types'
17
19
 
18
20
  const columnHelper = createColumnHelper<Milestone>()
@@ -54,14 +56,17 @@ export function MilestoneTable({ milestones, tasks }: MilestoneTableProps) {
54
56
  columnHelper.accessor('name', {
55
57
  header: 'Milestone',
56
58
  cell: (info) => (
57
- <Link
58
- to="/milestones/$milestoneId"
59
- params={{ milestoneId: info.row.original.id }}
60
- className="text-sm font-medium text-gray-200 hover:text-blue-400 transition-colors"
61
- onClick={(e) => e.stopPropagation()}
62
- >
63
- {info.getValue()}
64
- </Link>
59
+ <div className="flex items-center gap-2 group">
60
+ <Link
61
+ to="/milestones/$milestoneId"
62
+ params={{ milestoneId: info.row.original.id }}
63
+ className="text-sm font-medium text-gray-900 dark:text-gray-200 hover:text-blue-500 dark:hover:text-blue-400 transition-colors"
64
+ onClick={(e) => e.stopPropagation()}
65
+ >
66
+ {formatMilestoneName(info.row.original)}
67
+ </Link>
68
+ <PreviewButton type="milestone" id={info.row.original.id} />
69
+ </div>
65
70
  ),
66
71
  }),
67
72
  columnHelper.accessor('status', {
@@ -4,8 +4,10 @@ import { ChevronDown, ChevronRight } from 'lucide-react'
4
4
  import { StatusBadge } from './StatusBadge'
5
5
  import { PriorityBadge } from './PriorityBadge'
6
6
  import { ProgressBar } from './ProgressBar'
7
+ import { PreviewButton } from './PreviewButton'
7
8
  import { TaskList } from './TaskList'
8
9
  import { useCollapse } from '../lib/useCollapse'
10
+ import { formatMilestoneName } from '../lib/display'
9
11
  import type { Milestone, Task } from '../lib/types'
10
12
 
11
13
  interface MilestoneTreeProps {
@@ -27,30 +29,33 @@ function MilestoneTreeRow({
27
29
  const collapse = useCollapse(expanded)
28
30
 
29
31
  return (
30
- <div className="border-b border-gray-800/50">
32
+ <div className="border-b border-gray-200 dark:border-gray-800/50 group">
31
33
  <button
32
34
  onClick={onToggle}
33
- className="w-full flex items-center gap-3 px-4 py-3 hover:bg-gray-800/30 transition-colors text-left"
35
+ className="w-full flex items-center gap-3 px-4 py-3 hover:bg-gray-200/50 dark:hover:bg-gray-800/30 transition-colors text-left"
34
36
  >
35
37
  {expanded ? (
36
- <ChevronDown className="w-4 h-4 text-gray-500 shrink-0" />
38
+ <ChevronDown className="w-4 h-4 text-gray-500 dark:text-gray-500 shrink-0" />
37
39
  ) : (
38
- <ChevronRight className="w-4 h-4 text-gray-500 shrink-0" />
40
+ <ChevronRight className="w-4 h-4 text-gray-500 dark:text-gray-500 shrink-0" />
39
41
  )}
40
- <Link
41
- to="/milestones/$milestoneId"
42
- params={{ milestoneId: milestone.id }}
43
- className="flex-1 text-sm font-medium hover:text-blue-400 transition-colors"
44
- onClick={(e) => e.stopPropagation()}
45
- >
46
- {milestone.name}
47
- </Link>
42
+ <div className="flex items-center gap-2 flex-1">
43
+ <Link
44
+ to="/milestones/$milestoneId"
45
+ params={{ milestoneId: milestone.id }}
46
+ className="text-sm font-medium text-gray-900 dark:text-gray-200 hover:text-blue-500 dark:hover:text-blue-400 transition-colors"
47
+ onClick={(e) => e.stopPropagation()}
48
+ >
49
+ {formatMilestoneName(milestone)}
50
+ </Link>
51
+ <PreviewButton type="milestone" id={milestone.id} />
52
+ </div>
48
53
  <StatusBadge status={milestone.status} />
49
54
  <PriorityBadge priority={milestone.priority} />
50
55
  <div className="w-20">
51
56
  <ProgressBar value={milestone.progress} size="sm" />
52
57
  </div>
53
- <span className="text-xs text-gray-500 font-mono w-12 text-right">
58
+ <span className="text-xs text-gray-500 dark:text-gray-500 font-mono w-12 text-right">
54
59
  {milestone.tasks_completed}/{milestone.tasks_total}
55
60
  </span>
56
61
  </button>
@@ -0,0 +1,33 @@
1
+ import { PanelRight } from 'lucide-react'
2
+ import { useSidePanel } from '../contexts/SidePanelContext'
3
+
4
+ interface PreviewButtonProps {
5
+ type: 'milestone' | 'task'
6
+ id: string
7
+ className?: string
8
+ }
9
+
10
+ export function PreviewButton({ type, id, className = '' }: PreviewButtonProps) {
11
+ const { openMilestone, openTask } = useSidePanel()
12
+
13
+ const handleClick = (e: React.MouseEvent) => {
14
+ e.preventDefault()
15
+ e.stopPropagation()
16
+ if (type === 'milestone') {
17
+ openMilestone(id)
18
+ } else {
19
+ openTask(id)
20
+ }
21
+ }
22
+
23
+ return (
24
+ <button
25
+ onClick={handleClick}
26
+ className={`p-1.5 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-800 transition-colors opacity-0 group-hover:opacity-100 ${className}`}
27
+ title={`Preview ${type}`}
28
+ aria-label={`Preview ${type}`}
29
+ >
30
+ <PanelRight className="w-3.5 h-3.5 text-gray-500 dark:text-gray-400" />
31
+ </button>
32
+ )
33
+ }
@@ -0,0 +1,50 @@
1
+ import { X } from 'lucide-react'
2
+ import { useSidePanel } from '../contexts/SidePanelContext'
3
+ import { MilestonePreview } from './MilestonePreview'
4
+ import { TaskPreview } from './TaskPreview'
5
+
6
+ export function SidePanel() {
7
+ const { content, isOpen, close } = useSidePanel()
8
+
9
+ // Don't render at all until first open
10
+ if (!content && !isOpen) {
11
+ return null
12
+ }
13
+
14
+ return (
15
+ <>
16
+ {/* Backdrop */}
17
+ <div
18
+ className={`fixed inset-0 bg-black/30 backdrop-blur-sm z-40 transition-opacity duration-300 ${
19
+ isOpen ? 'opacity-100' : 'opacity-0 pointer-events-none'
20
+ }`}
21
+ onClick={close}
22
+ />
23
+
24
+ {/* Panel */}
25
+ <div
26
+ className={`fixed top-0 right-0 h-full w-full max-w-2xl bg-white dark:bg-gray-900 border-l border-gray-200 dark:border-gray-800 shadow-2xl z-50 transition-transform duration-300 overflow-auto translate-x-full ${
27
+ isOpen ? '!translate-x-0' : ''
28
+ }`}
29
+ >
30
+ {/* Header */}
31
+ <div className="sticky top-0 bg-white dark:bg-gray-900 border-b border-gray-200 dark:border-gray-800 px-6 py-4 flex items-center justify-between z-10">
32
+ <h2 className="text-sm font-semibold text-gray-900 dark:text-gray-100">Preview</h2>
33
+ <button
34
+ onClick={close}
35
+ className="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
36
+ aria-label="Close panel"
37
+ >
38
+ <X className="w-4 h-4 text-gray-600 dark:text-gray-400" />
39
+ </button>
40
+ </div>
41
+
42
+ {/* Content */}
43
+ <div className="p-6">
44
+ {content?.type === 'milestone' && <MilestonePreview milestoneId={content.id} />}
45
+ {content?.type === 'task' && <TaskPreview taskId={content.id} />}
46
+ </div>
47
+ </div>
48
+ </>
49
+ )
50
+ }
@@ -25,9 +25,9 @@ export function Sidebar({ projects = [], currentProject = null, onProjectSelect,
25
25
  const location = useRouterState({ select: (s) => s.location })
26
26
 
27
27
  return (
28
- <nav className="w-56 border-r border-gray-800 bg-gray-950 flex flex-col shrink-0">
29
- <div className="p-4 border-b border-gray-800">
30
- <span className="text-sm font-semibold text-gray-300 tracking-wide">
28
+ <nav className="w-56 border-r border-gray-200 dark:border-gray-800 bg-gray-100 dark:bg-gray-950 flex flex-col shrink-0">
29
+ <div className="p-4 border-b border-gray-200 dark:border-gray-800">
30
+ <span className="text-sm font-semibold text-gray-700 dark:text-gray-300 tracking-wide">
31
31
  ACP Visualizer
32
32
  </span>
33
33
  </div>
@@ -53,8 +53,8 @@ export function Sidebar({ projects = [], currentProject = null, onProjectSelect,
53
53
  to={item.to}
54
54
  className={`flex items-center gap-3 px-4 py-2 text-sm transition-colors ${
55
55
  isActive
56
- ? 'text-gray-100 bg-gray-800/50'
57
- : 'text-gray-400 hover:text-gray-200 hover:bg-gray-800/30'
56
+ ? 'text-gray-900 dark:text-gray-100 bg-gray-200 dark:bg-gray-800/50'
57
+ : 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200 hover:bg-gray-200/50 dark:hover:bg-gray-800/30'
58
58
  }`}
59
59
  >
60
60
  <item.icon className="w-4 h-4" />
@@ -63,10 +63,10 @@ export function Sidebar({ projects = [], currentProject = null, onProjectSelect,
63
63
  )
64
64
  })}
65
65
  </div>
66
- <div className="p-3 border-t border-gray-800 space-y-2">
66
+ <div className="p-3 border-t border-gray-200 dark:border-gray-800 space-y-2">
67
67
  <Link
68
68
  to="/search"
69
- className="flex items-center gap-2 px-3 py-1.5 text-sm text-gray-500 bg-gray-900 border border-gray-800 rounded-md hover:text-gray-300 hover:border-gray-600 transition-colors"
69
+ className="flex items-center gap-2 px-3 py-1.5 text-sm text-gray-600 dark:text-gray-500 bg-white dark:bg-gray-900 border border-gray-300 dark:border-gray-800 rounded-md hover:text-gray-900 dark:hover:text-gray-300 hover:border-gray-400 dark:hover:border-gray-600 transition-colors"
70
70
  >
71
71
  <Search className="w-4 h-4" />
72
72
  Search...
@@ -1,14 +1,16 @@
1
1
  import { Link } from '@tanstack/react-router'
2
2
  import { StatusDot } from './StatusDot'
3
3
  import { PriorityBadge } from './PriorityBadge'
4
+ import { PreviewButton } from './PreviewButton'
4
5
  import { ExtraFieldsBadge } from './ExtraFieldsBadge'
6
+ import { formatTaskName } from '../lib/display'
5
7
  import type { Task } from '../lib/types'
6
8
 
7
9
  export function TaskList({ tasks }: { tasks: Task[] }) {
8
10
  if (tasks.length === 0) {
9
11
  return (
10
12
  <div className="pl-6 py-2">
11
- <span className="text-xs text-gray-600">No tasks</span>
13
+ <span className="text-xs text-gray-600 dark:text-gray-600">No tasks</span>
12
14
  </div>
13
15
  )
14
16
  }
@@ -16,20 +18,21 @@ export function TaskList({ tasks }: { tasks: Task[] }) {
16
18
  return (
17
19
  <div className="pl-6 py-1 space-y-0.5">
18
20
  {tasks.map((task) => (
19
- <div key={task.id} className="flex items-center gap-2 py-1 text-sm">
21
+ <div key={task.id} className="flex items-center gap-2 py-1 text-sm group">
20
22
  <StatusDot status={task.status} />
21
23
  <Link
22
24
  to="/tasks/$taskId"
23
25
  params={{ taskId: task.id }}
24
- className={`hover:text-blue-400 transition-colors ${
25
- task.status === 'completed' ? 'text-gray-500' : 'text-gray-200'
26
+ className={`hover:text-blue-500 dark:hover:text-blue-400 transition-colors ${
27
+ task.status === 'completed' ? 'text-gray-500 dark:text-gray-500' : 'text-gray-900 dark:text-gray-200'
26
28
  }`}
27
29
  >
28
- {task.name}
30
+ {formatTaskName(task)}
29
31
  </Link>
32
+ <PreviewButton type="task" id={task.id} />
30
33
  <PriorityBadge priority={task.priority} />
31
34
  {task.notes && (
32
- <span className="text-xs text-gray-600 ml-auto truncate max-w-[200px]">
35
+ <span className="text-xs text-gray-600 dark:text-gray-600 ml-auto truncate max-w-[200px]">
33
36
  {task.notes}
34
37
  </span>
35
38
  )}
@@ -0,0 +1,148 @@
1
+ import { Link } from '@tanstack/react-router'
2
+ import { useState, useEffect, useMemo } from 'react'
3
+ import { ExternalLink } from 'lucide-react'
4
+ import { useProgressData } from '../contexts/ProgressContext'
5
+ import { DetailHeader } from './DetailHeader'
6
+ import { PriorityBadge } from './PriorityBadge'
7
+ import { MarkdownContent, buildLinkMap } from './MarkdownContent'
8
+ import { getMarkdownContent, resolveTaskFile } from '../services/markdown.service'
9
+ import { formatTaskName, formatMilestoneName } from '../lib/display'
10
+ import type { MarkdownResult } from '../services/markdown.service'
11
+
12
+ interface TaskPreviewProps {
13
+ taskId: string
14
+ }
15
+
16
+ function getGitHubParams(): { owner: string; repo: string } | undefined {
17
+ if (typeof window === 'undefined') return undefined
18
+ const params = new URLSearchParams(window.location.search)
19
+ const repo = params.get('repo')
20
+ if (!repo) return undefined
21
+ const parts = repo.split('/')
22
+ if (parts.length < 2) return undefined
23
+ return { owner: parts[0], repo: parts[1] }
24
+ }
25
+
26
+ export function TaskPreview({ taskId }: TaskPreviewProps) {
27
+ const data = useProgressData()
28
+ const [markdown, setMarkdown] = useState<string | null>(null)
29
+ const [markdownError, setMarkdownError] = useState<string | null>(null)
30
+ const [loading, setLoading] = useState(true)
31
+
32
+ const { task, milestone } = useMemo(() => {
33
+ if (!data) return { task: null, milestone: null }
34
+
35
+ for (const ms of data.milestones) {
36
+ const msTaskList = data.tasks[ms.id] || []
37
+ const foundTask = msTaskList.find((t) => t.id === taskId)
38
+ if (foundTask) {
39
+ return { task: foundTask, milestone: ms }
40
+ }
41
+ }
42
+ return { task: null, milestone: null }
43
+ }, [data, taskId])
44
+
45
+ useEffect(() => {
46
+ if (!task) return
47
+
48
+ setLoading(true)
49
+ setMarkdown(null)
50
+ setMarkdownError(null)
51
+
52
+ const filePath = resolveTaskFile(task)
53
+ if (!filePath) {
54
+ setMarkdownError('No file path for this task')
55
+ setLoading(false)
56
+ return
57
+ }
58
+
59
+ const github = getGitHubParams()
60
+
61
+ getMarkdownContent({ data: { filePath, github } })
62
+ .then((result: MarkdownResult) => {
63
+ if (result.ok) {
64
+ setMarkdown(result.content)
65
+ } else {
66
+ setMarkdownError(result.error)
67
+ }
68
+ })
69
+ .catch((err: Error) => {
70
+ setMarkdownError(err.message)
71
+ })
72
+ .finally(() => {
73
+ setLoading(false)
74
+ })
75
+ }, [task])
76
+
77
+ const linkMap = useMemo(() => (data ? buildLinkMap(data) : {}), [data])
78
+ const taskFilePath = useMemo(() => resolveTaskFile(task), [task])
79
+
80
+ if (!data || !task || !milestone) {
81
+ return (
82
+ <div className="text-center py-8">
83
+ <p className="text-gray-500 dark:text-gray-400 text-sm">Task not found: {taskId}</p>
84
+ </div>
85
+ )
86
+ }
87
+
88
+ const hoursDisplay = task.actual_hours != null
89
+ ? `Est: ${task.estimated_hours}h | Actual: ${task.actual_hours}h`
90
+ : `${task.estimated_hours}h`
91
+
92
+ const fields = [
93
+ { label: 'Est', value: hoursDisplay },
94
+ ...(task.started ? [{ label: 'Started', value: task.started }] : []),
95
+ ...(task.completed_date ? [{ label: 'Completed', value: task.completed_date }] : []),
96
+ {
97
+ label: 'Milestone',
98
+ value: (
99
+ <Link
100
+ to="/milestones/$milestoneId"
101
+ params={{ milestoneId: milestone.id }}
102
+ className="text-blue-500 dark:text-blue-400 hover:underline"
103
+ >
104
+ {formatMilestoneName(milestone)}
105
+ </Link>
106
+ ),
107
+ },
108
+ ]
109
+
110
+ return (
111
+ <div>
112
+ <div className="flex items-start justify-between mb-4">
113
+ <h1 className="text-lg font-semibold text-gray-900 dark:text-gray-100">{formatTaskName(task)}</h1>
114
+ <Link
115
+ to="/tasks/$taskId"
116
+ params={{ taskId }}
117
+ className="p-1.5 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
118
+ title="Open full view"
119
+ >
120
+ <ExternalLink className="w-4 h-4 text-gray-600 dark:text-gray-400" />
121
+ </Link>
122
+ </div>
123
+
124
+ <div className="flex items-center gap-2 mb-4">
125
+ <PriorityBadge priority={task.priority} />
126
+ </div>
127
+
128
+ <DetailHeader status={task.status} fields={fields} />
129
+
130
+ {task.notes && (
131
+ <p className="text-sm text-gray-600 dark:text-gray-400 mb-6">{task.notes}</p>
132
+ )}
133
+
134
+ {/* Markdown content */}
135
+ {loading ? (
136
+ <p className="text-sm text-gray-600 dark:text-gray-500">Loading document...</p>
137
+ ) : markdown ? (
138
+ <div className="prose-sm">
139
+ <MarkdownContent content={markdown} basePath={taskFilePath ?? undefined} linkMap={linkMap} />
140
+ </div>
141
+ ) : markdownError ? (
142
+ <div className="bg-gray-100 dark:bg-gray-900/50 border border-gray-200 dark:border-gray-800 rounded-xl p-4 text-sm text-gray-600 dark:text-gray-500">
143
+ No document found — {markdownError}
144
+ </div>
145
+ ) : null}
146
+ </div>
147
+ )
148
+ }
@@ -0,0 +1,50 @@
1
+ import { createContext, useContext, useState, ReactNode } from 'react'
2
+
3
+ type PanelContent =
4
+ | { type: 'milestone'; id: string }
5
+ | { type: 'task'; id: string }
6
+ | null
7
+
8
+ interface SidePanelContextValue {
9
+ content: PanelContent
10
+ isOpen: boolean
11
+ openMilestone: (id: string) => void
12
+ openTask: (id: string) => void
13
+ close: () => void
14
+ }
15
+
16
+ const SidePanelContext = createContext<SidePanelContextValue | undefined>(undefined)
17
+
18
+ export function SidePanelProvider({ children }: { children: ReactNode }) {
19
+ const [content, setContent] = useState<PanelContent>(null)
20
+ const [isOpen, setIsOpen] = useState(false)
21
+
22
+ const openMilestone = (id: string) => {
23
+ setContent({ type: 'milestone', id })
24
+ setIsOpen(true)
25
+ }
26
+
27
+ const openTask = (id: string) => {
28
+ setContent({ type: 'task', id })
29
+ setIsOpen(true)
30
+ }
31
+
32
+ const close = () => {
33
+ setIsOpen(false)
34
+ setTimeout(() => setContent(null), 300) // Wait for animation
35
+ }
36
+
37
+ return (
38
+ <SidePanelContext.Provider value={{ content, isOpen, openMilestone, openTask, close }}>
39
+ {children}
40
+ </SidePanelContext.Provider>
41
+ )
42
+ }
43
+
44
+ export function useSidePanel() {
45
+ const context = useContext(SidePanelContext)
46
+ if (!context) {
47
+ throw new Error('useSidePanel must be used within SidePanelProvider')
48
+ }
49
+ return context
50
+ }
@@ -0,0 +1,51 @@
1
+ import type { Milestone, Task } from './types'
2
+
3
+ /**
4
+ * Extract milestone number from ID (e.g., "M1" -> "1", "milestone_1" -> "1")
5
+ */
6
+ export function getMilestoneNumber(id: string): string {
7
+ // Handle M1, M2, M3... format
8
+ if (/^M\d+$/.test(id)) {
9
+ return id.substring(1)
10
+ }
11
+ // Handle milestone_1, milestone_2... format
12
+ if (/^milestone_\d+$/.test(id)) {
13
+ return id.replace('milestone_', '')
14
+ }
15
+ // Fallback: try to extract any number
16
+ const match = id.match(/\d+/)
17
+ return match ? match[0] : id
18
+ }
19
+
20
+ /**
21
+ * Extract task number from ID (e.g., "task_1" -> "1", "79" -> "79")
22
+ */
23
+ export function getTaskNumber(id: string): string {
24
+ // Handle task_1, task_2... format
25
+ if (/^task_\d+$/.test(id)) {
26
+ return id.replace('task_', '')
27
+ }
28
+ // Handle numeric IDs
29
+ if (/^\d+$/.test(id)) {
30
+ return id
31
+ }
32
+ // Fallback: try to extract any number
33
+ const match = id.match(/\d+/)
34
+ return match ? match[0] : id
35
+ }
36
+
37
+ /**
38
+ * Format milestone display name with prefix (e.g., "M1 — Project Setup")
39
+ */
40
+ export function formatMilestoneName(milestone: Milestone): string {
41
+ const num = getMilestoneNumber(milestone.id)
42
+ return `M${num} — ${milestone.name}`
43
+ }
44
+
45
+ /**
46
+ * Format task display name with prefix (e.g., "T1 — Install dependencies")
47
+ */
48
+ export function formatTaskName(task: Task): string {
49
+ const num = getTaskNumber(task.id)
50
+ return `T${num} — ${task.name}`
51
+ }
@@ -0,0 +1,38 @@
1
+ import { useState, useEffect } from 'react'
2
+
3
+ type Theme = 'dark' | 'light'
4
+
5
+ // Initialize theme immediately on load to prevent flash
6
+ if (typeof window !== 'undefined') {
7
+ const stored = localStorage.getItem('theme')
8
+ const initialTheme = (stored === 'light' || stored === 'dark') ? stored : 'dark'
9
+ if (initialTheme === 'dark') {
10
+ document.documentElement.classList.add('dark')
11
+ } else {
12
+ document.documentElement.classList.remove('dark')
13
+ }
14
+ }
15
+
16
+ export function useTheme() {
17
+ const [theme, setTheme] = useState<Theme>(() => {
18
+ if (typeof window === 'undefined') return 'dark'
19
+ const stored = localStorage.getItem('theme')
20
+ return (stored === 'light' || stored === 'dark') ? stored : 'dark'
21
+ })
22
+
23
+ useEffect(() => {
24
+ const root = document.documentElement
25
+ if (theme === 'dark') {
26
+ root.classList.add('dark')
27
+ } else {
28
+ root.classList.remove('dark')
29
+ }
30
+ localStorage.setItem('theme', theme)
31
+ }, [theme])
32
+
33
+ const toggleTheme = () => {
34
+ setTheme(prev => prev === 'dark' ? 'light' : 'dark')
35
+ }
36
+
37
+ return { theme, toggleTheme }
38
+ }
@@ -3,12 +3,14 @@ import { useState, useCallback, useEffect } from 'react'
3
3
  import { useAutoRefresh } from '../lib/useAutoRefresh'
4
4
  import { Sidebar } from '../components/Sidebar'
5
5
  import { Header } from '../components/Header'
6
+ import { SidePanel } from '../components/SidePanel'
6
7
  import { getProgressData } from '../services/progress-database.service'
7
8
  import { listProjects, getProjectProgressPath } from '../services/projects.service'
8
9
  import { fetchGitHubProgress } from '../services/github.service'
9
10
  import type { ProgressData } from '../lib/types'
10
11
  import type { AcpProject } from '../services/projects.service'
11
12
  import { ProgressProvider } from '../contexts/ProgressContext'
13
+ import { SidePanelProvider } from '../contexts/SidePanelContext'
12
14
 
13
15
  import appCss from '../styles.css?url'
14
16
 
@@ -63,8 +65,8 @@ function NotFound() {
63
65
  return (
64
66
  <div className="flex items-center justify-center h-full">
65
67
  <div className="text-center">
66
- <h2 className="text-xl font-semibold text-gray-200 mb-2">Page Not Found</h2>
67
- <p className="text-sm text-gray-400">
68
+ <h2 className="text-xl font-semibold text-gray-800 dark:text-gray-200 mb-2">Page Not Found</h2>
69
+ <p className="text-sm text-gray-600 dark:text-gray-400">
68
70
  The page you're looking for doesn't exist.
69
71
  </p>
70
72
  </div>
@@ -154,7 +156,7 @@ function RootLayout() {
154
156
  return (
155
157
  <>
156
158
  <AutoRefresh />
157
- <div className="flex h-screen bg-gray-950 text-gray-100">
159
+ <div className="flex h-screen bg-white dark:bg-gray-950 text-gray-900 dark:text-gray-100">
158
160
  <Sidebar
159
161
  projects={context.projects}
160
162
  currentProject={currentProject}
@@ -162,12 +164,15 @@ function RootLayout() {
162
164
  onGitHubLoad={handleGitHubLoad}
163
165
  />
164
166
  <ProgressProvider data={progressData}>
165
- <div className="flex-1 flex flex-col overflow-hidden">
166
- <Header data={progressData} />
167
- <main className="flex-1 overflow-auto">
168
- <Outlet />
169
- </main>
170
- </div>
167
+ <SidePanelProvider>
168
+ <div className="flex-1 flex flex-col overflow-hidden">
169
+ <Header data={progressData} />
170
+ <main className="flex-1 overflow-auto bg-gray-50 dark:bg-gray-900">
171
+ <Outlet />
172
+ </main>
173
+ </div>
174
+ <SidePanel />
175
+ </SidePanelProvider>
171
176
  </ProgressProvider>
172
177
  </div>
173
178
  </>
@@ -8,6 +8,7 @@ import { StatusDot } from '../components/StatusDot'
8
8
  import { PriorityBadge } from '../components/PriorityBadge'
9
9
  import { MarkdownContent, buildLinkMap } from '../components/MarkdownContent'
10
10
  import { getMarkdownContent, resolveMilestoneFile } from '../services/markdown.service'
11
+ import { formatMilestoneName, formatTaskName } from '../lib/display'
11
12
  import type { MarkdownResult, ResolveFileResult } from '../services/markdown.service'
12
13
 
13
14
  export const Route = createFileRoute('/milestones/$milestoneId')({
@@ -93,11 +94,11 @@ function MilestoneDetailPage() {
93
94
  <Breadcrumb
94
95
  items={[
95
96
  { label: 'Milestones', href: '/milestones' },
96
- { label: `${milestone.id.replace('milestone_', 'M')} — ${milestone.name}` },
97
+ { label: formatMilestoneName(milestone) },
97
98
  ]}
98
99
  />
99
100
 
100
- <h1 className="text-xl font-semibold text-gray-100 mb-3">{milestone.name}</h1>
101
+ <h1 className="text-xl font-semibold text-gray-100 dark:text-gray-100 mb-3">{formatMilestoneName(milestone)}</h1>
101
102
 
102
103
  <div className="flex items-center gap-3 mb-4">
103
104
  <div className="flex-1 max-w-xs">
@@ -133,21 +134,21 @@ function MilestoneDetailPage() {
133
134
  <h2 className="text-sm font-semibold text-gray-400 uppercase tracking-wider mb-3">
134
135
  Tasks
135
136
  </h2>
136
- <div className="bg-gray-900/50 border border-gray-800 rounded-xl divide-y divide-gray-800">
137
+ <div className="bg-gray-100 dark:bg-gray-900/50 border border-gray-200 dark:border-gray-800 rounded-xl divide-y divide-gray-200 dark:divide-gray-800">
137
138
  {tasks.map((task) => (
138
139
  <Link
139
140
  key={task.id}
140
141
  to="/tasks/$taskId"
141
142
  params={{ taskId: task.id }}
142
- className="flex items-center gap-2 px-4 py-2.5 text-sm hover:bg-gray-800/50 transition-colors first:rounded-t-xl last:rounded-b-xl"
143
+ className="flex items-center gap-2 px-4 py-2.5 text-sm hover:bg-gray-200/50 dark:hover:bg-gray-800/50 transition-colors first:rounded-t-xl last:rounded-b-xl"
143
144
  >
144
145
  <StatusDot status={task.status} />
145
- <span className={task.status === 'completed' ? 'text-gray-500' : 'text-gray-200'}>
146
- {task.name}
146
+ <span className={task.status === 'completed' ? 'text-gray-500 dark:text-gray-500' : 'text-gray-900 dark:text-gray-200'}>
147
+ {formatTaskName(task)}
147
148
  </span>
148
149
  <PriorityBadge priority={task.priority} />
149
150
  {task.estimated_hours && (
150
- <span className="text-xs text-gray-600 ml-auto">{task.estimated_hours}h</span>
151
+ <span className="text-xs text-gray-600 dark:text-gray-600 ml-auto">{task.estimated_hours}h</span>
151
152
  )}
152
153
  </Link>
153
154
  ))}
@@ -5,6 +5,7 @@ import { StatusBadge } from '../components/StatusBadge'
5
5
  import { StatusDot } from '../components/StatusDot'
6
6
  import { buildSearchIndex } from '../lib/search'
7
7
  import { useProgressData } from '../contexts/ProgressContext'
8
+ import { formatTaskName, formatMilestoneName } from '../lib/display'
8
9
 
9
10
  export const Route = createFileRoute('/search')({
10
11
  component: SearchPage,
@@ -45,21 +46,21 @@ function SearchPage() {
45
46
  {results.map((result, i) => (
46
47
  <div
47
48
  key={i}
48
- className="bg-gray-900/50 border border-gray-800 rounded-lg px-4 py-3"
49
+ className="bg-gray-100 dark:bg-gray-900/50 border border-gray-200 dark:border-gray-800 rounded-lg px-4 py-3"
49
50
  >
50
51
  <div className="flex items-center gap-3">
51
52
  {result.item.type === 'task' && result.item.task ? (
52
53
  <>
53
54
  <StatusDot status={result.item.task.status} />
54
- <span className="text-sm">{result.item.task.name}</span>
55
- <span className="text-xs text-gray-600 ml-auto">
56
- {result.item.milestone.name}
55
+ <span className="text-sm text-gray-900 dark:text-gray-200">{formatTaskName(result.item.task)}</span>
56
+ <span className="text-xs text-gray-600 dark:text-gray-600 ml-auto">
57
+ {formatMilestoneName(result.item.milestone)}
57
58
  </span>
58
59
  </>
59
60
  ) : (
60
61
  <>
61
- <span className="text-sm font-medium">
62
- {result.item.milestone.name}
62
+ <span className="text-sm font-medium text-gray-900 dark:text-gray-200">
63
+ {formatMilestoneName(result.item.milestone)}
63
64
  </span>
64
65
  <StatusBadge status={result.item.milestone.status} />
65
66
  </>
@@ -7,6 +7,7 @@ import { PriorityBadge } from '../components/PriorityBadge'
7
7
  import { MarkdownContent, buildLinkMap } from '../components/MarkdownContent'
8
8
  import { getMarkdownContent } from '../services/markdown.service'
9
9
  import { resolveTaskFile } from '../services/markdown.service'
10
+ import { formatTaskName, formatMilestoneName } from '../lib/display'
10
11
  import type { MarkdownResult } from '../services/markdown.service'
11
12
 
12
13
  export const Route = createFileRoute('/tasks/$taskId')({
@@ -109,9 +110,9 @@ function TaskDetailPage() {
109
110
  <Link
110
111
  to="/milestones/$milestoneId"
111
112
  params={{ milestoneId: milestone.id }}
112
- className="text-blue-400 hover:underline"
113
+ className="text-blue-500 dark:text-blue-400 hover:underline"
113
114
  >
114
- {milestone.id.replace('milestone_', 'M')} — {milestone.name}
115
+ {formatMilestoneName(milestone)}
115
116
  </Link>
116
117
  ),
117
118
  },
@@ -122,12 +123,12 @@ function TaskDetailPage() {
122
123
  <Breadcrumb
123
124
  items={[
124
125
  { label: 'Milestones', href: '/milestones' },
125
- { label: `${milestone.id.replace('milestone_', 'M')} — ${milestone.name}`, href: `/milestones/${milestone.id}` },
126
- { label: task.name },
126
+ { label: formatMilestoneName(milestone), href: `/milestones/${milestone.id}` },
127
+ { label: formatTaskName(task) },
127
128
  ]}
128
129
  />
129
130
 
130
- <h1 className="text-xl font-semibold text-gray-100 mb-3">{task.name}</h1>
131
+ <h1 className="text-xl font-semibold text-gray-900 dark:text-gray-100 mb-3">{formatTaskName(task)}</h1>
131
132
 
132
133
  <div className="flex items-center gap-2 mb-4">
133
134
  <PriorityBadge priority={task.priority} />
@@ -152,14 +153,14 @@ function TaskDetailPage() {
152
153
 
153
154
  {/* Prev / Next navigation */}
154
155
  {(siblings.prev || siblings.next) && (
155
- <div className="mt-8 flex items-center justify-between border-t border-gray-800 pt-4">
156
+ <div className="mt-8 flex items-center justify-between border-t border-gray-200 dark:border-gray-800 pt-4">
156
157
  {siblings.prev ? (
157
158
  <Link
158
159
  to="/tasks/$taskId"
159
160
  params={{ taskId: siblings.prev.id }}
160
- className="text-sm text-gray-400 hover:text-gray-200 transition-colors"
161
+ className="text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200 transition-colors"
161
162
  >
162
- ← {siblings.prev.name}
163
+ ← {formatTaskName(siblings.prev)}
163
164
  </Link>
164
165
  ) : (
165
166
  <span />
@@ -168,9 +169,9 @@ function TaskDetailPage() {
168
169
  <Link
169
170
  to="/tasks/$taskId"
170
171
  params={{ taskId: siblings.next.id }}
171
- className="text-sm text-gray-400 hover:text-gray-200 transition-colors"
172
+ className="text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200 transition-colors"
172
173
  >
173
- {siblings.next.name} →
174
+ {formatTaskName(siblings.next)} →
174
175
  </Link>
175
176
  ) : (
176
177
  <span />
@@ -2,6 +2,7 @@ import { createFileRoute, Link } from '@tanstack/react-router'
2
2
  import { StatusDot } from '../components/StatusDot'
3
3
  import { ExtraFieldsBadge } from '../components/ExtraFieldsBadge'
4
4
  import { useProgressData } from '../contexts/ProgressContext'
5
+ import { formatTaskName, formatMilestoneName } from '../lib/display'
5
6
  import type { Task } from '../lib/types'
6
7
 
7
8
  export const Route = createFileRoute('/tasks/')({
@@ -14,7 +15,7 @@ function TasksPage() {
14
15
  if (!progressData) {
15
16
  return (
16
17
  <div className="p-6">
17
- <p className="text-gray-600 text-sm">No data loaded</p>
18
+ <p className="text-gray-600 dark:text-gray-600 text-sm">No data loaded</p>
18
19
  </div>
19
20
  )
20
21
  }
@@ -23,7 +24,7 @@ function TasksPage() {
23
24
  for (const milestone of progressData.milestones) {
24
25
  const tasks = progressData.tasks[milestone.id] || []
25
26
  for (const task of tasks) {
26
- allTasks.push({ ...task, milestoneName: milestone.name })
27
+ allTasks.push({ ...task, milestoneName: formatMilestoneName(milestone) })
27
28
  }
28
29
  }
29
30
 
@@ -32,24 +33,24 @@ function TasksPage() {
32
33
  <h2 className="text-lg font-semibold mb-4">
33
34
  All Tasks ({allTasks.length})
34
35
  </h2>
35
- <div className="border border-gray-800 rounded-lg overflow-hidden">
36
+ <div className="border border-gray-200 dark:border-gray-800 rounded-lg overflow-hidden">
36
37
  {allTasks.map((task) => (
37
38
  <Link
38
39
  key={task.id}
39
40
  to="/tasks/$taskId"
40
41
  params={{ taskId: task.id }}
41
- className="flex items-center gap-3 px-4 py-2.5 border-b border-gray-800/50 hover:bg-gray-800/30 transition-colors"
42
+ className="flex items-center gap-3 px-4 py-2.5 border-b border-gray-200 dark:border-gray-800/50 hover:bg-gray-200/50 dark:hover:bg-gray-800/30 transition-colors"
42
43
  >
43
44
  <StatusDot status={task.status} />
44
45
  <span
45
46
  className={`flex-1 text-sm ${
46
- task.status === 'completed' ? 'text-gray-500' : 'text-gray-200'
47
+ task.status === 'completed' ? 'text-gray-500 dark:text-gray-500' : 'text-gray-900 dark:text-gray-200'
47
48
  }`}
48
49
  >
49
- {task.name}
50
+ {formatTaskName(task)}
50
51
  </span>
51
- <span className="text-xs text-gray-600">{task.milestoneName}</span>
52
- <span className="text-xs text-gray-500 font-mono w-8 text-right">
52
+ <span className="text-xs text-gray-600 dark:text-gray-600">{task.milestoneName}</span>
53
+ <span className="text-xs text-gray-500 dark:text-gray-500 font-mono w-8 text-right">
53
54
  {task.estimated_hours}h
54
55
  </span>
55
56
  <ExtraFieldsBadge fields={task.extra} />
package/src/styles.css CHANGED
@@ -2,12 +2,14 @@
2
2
  @plugin "@tailwindcss/typography";
3
3
  @import "highlight.js/styles/github-dark.css";
4
4
 
5
+ @variant dark (&:where(.dark, .dark *));
6
+
5
7
  html {
6
- @apply bg-gray-950;
8
+ @apply bg-white dark:bg-gray-950;
7
9
  }
8
10
 
9
11
  body {
10
- @apply m-0 bg-gray-950 min-h-screen text-gray-100;
12
+ @apply m-0 bg-white dark:bg-gray-950 min-h-screen text-gray-900 dark:text-gray-100;
11
13
  font-family: "Inter", system-ui, -apple-system, sans-serif;
12
14
  -webkit-font-smoothing: antialiased;
13
15
  -moz-osx-font-smoothing: grayscale;