@argus-vrt/web 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/.cta.json +15 -0
  2. package/DEPLOYMENT.md +154 -0
  3. package/Dockerfile +51 -0
  4. package/README.md +159 -0
  5. package/docker-compose.prod.yml +38 -0
  6. package/docker-compose.yml +15 -0
  7. package/drizzle/0000_slim_makkari.sql +61 -0
  8. package/drizzle/meta/0000_snapshot.json +452 -0
  9. package/drizzle/meta/_journal.json +13 -0
  10. package/drizzle.config.ts +10 -0
  11. package/package.json +60 -0
  12. package/public/favicon.ico +0 -0
  13. package/public/logo-argus.svg +8 -0
  14. package/public/logo-variants/logo-argus-a.svg +9 -0
  15. package/public/logo-variants/logo-argus-modern.svg +11 -0
  16. package/public/logo-variants/logo-argus-peacock.svg +8 -0
  17. package/public/logo192.png +0 -0
  18. package/public/logo512.png +0 -0
  19. package/public/manifest.json +25 -0
  20. package/public/robots.txt +3 -0
  21. package/public/tanstack-circle-logo.png +0 -0
  22. package/public/tanstack-word-logo-white.svg +1 -0
  23. package/scripts/backfill-kind.ts +148 -0
  24. package/src/api-plugin.ts +169 -0
  25. package/src/components/image/ImageCompare.tsx +188 -0
  26. package/src/components/story/StoryFlatList.tsx +67 -0
  27. package/src/components/story/StoryGroupedTree.tsx +273 -0
  28. package/src/components/story/StoryTree.tsx +185 -0
  29. package/src/components/ui/Drawer.tsx +110 -0
  30. package/src/components/ui/SearchInput.tsx +95 -0
  31. package/src/components/ui/StatusBadge.tsx +59 -0
  32. package/src/components/ui/ViewModeToggle.tsx +39 -0
  33. package/src/db/index.ts +27 -0
  34. package/src/db/schema.ts +151 -0
  35. package/src/hooks/useDebounce.ts +23 -0
  36. package/src/hooks/useStoryTree.ts +205 -0
  37. package/src/lib/utils.ts +55 -0
  38. package/src/logo.svg +12 -0
  39. package/src/routeTree.gen.ts +177 -0
  40. package/src/router.tsx +17 -0
  41. package/src/routes/__root.tsx +174 -0
  42. package/src/routes/branches/$name.tsx +171 -0
  43. package/src/routes/branches/index.tsx +104 -0
  44. package/src/routes/index.tsx +178 -0
  45. package/src/routes/tests/$id.tsx +417 -0
  46. package/src/routes/tests/index.tsx +128 -0
  47. package/src/routes/upload.tsx +108 -0
  48. package/src/styles.css +213 -0
  49. package/tsconfig.json +28 -0
  50. package/vite.config.ts +30 -0
@@ -0,0 +1,178 @@
1
+ import { createFileRoute } from '@tanstack/react-router'
2
+ import { createServerFn } from '@tanstack/react-start'
3
+ import { GitBranch, Hash } from 'lucide-react'
4
+ import { desc } from 'drizzle-orm'
5
+ import { getDb, tests } from '../db'
6
+ import { formatRelativeTime } from '../lib/utils'
7
+ import { StatusBadge } from '../components/ui/StatusBadge'
8
+
9
+ // Server function to get recent tests
10
+ const getRecentTests = createServerFn({ method: 'GET' }).handler(async () => {
11
+ const db = getDb()
12
+ const recentTests = await db
13
+ .select()
14
+ .from(tests)
15
+ .orderBy(desc(tests.createdAt))
16
+ .limit(20)
17
+
18
+ return recentTests.map((test) => ({
19
+ ...test,
20
+ createdAt: test.createdAt.toISOString(),
21
+ }))
22
+ })
23
+
24
+ export const Route = createFileRoute('/')({
25
+ component: Dashboard,
26
+ loader: () => getRecentTests(),
27
+ })
28
+
29
+ function Dashboard() {
30
+ const tests = Route.useLoaderData()
31
+
32
+ const stats = {
33
+ total: tests.length,
34
+ pending: tests.filter((t) => t.status === 'PENDING').length,
35
+ approved: tests.filter((t) => t.status === 'APPROVED').length,
36
+ rejected: tests.filter((t) => t.status === 'REJECTED').length,
37
+ }
38
+
39
+ return (
40
+ <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
41
+ <div className="mb-8">
42
+ <h1 className="text-2xl font-bold text-gray-900 dark:text-white">Dashboard</h1>
43
+ <p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
44
+ Overview of recent visual regression tests
45
+ </p>
46
+ </div>
47
+
48
+ {/* Stats Cards */}
49
+ <div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8">
50
+ <div className="bg-white dark:bg-gray-800 rounded-lg shadow p-4 sm:p-6">
51
+ <div className="text-sm font-medium text-gray-500 dark:text-gray-400">Total Tests</div>
52
+ <div className="mt-2 text-2xl sm:text-3xl font-bold text-gray-900 dark:text-white">
53
+ {stats.total}
54
+ </div>
55
+ </div>
56
+ <div className="bg-white dark:bg-gray-800 rounded-lg shadow p-4 sm:p-6">
57
+ <div className="text-sm font-medium text-warning-600 dark:text-warning-400">
58
+ Pending Review
59
+ </div>
60
+ <div className="mt-2 text-2xl sm:text-3xl font-bold text-warning-600 dark:text-warning-400">
61
+ {stats.pending}
62
+ </div>
63
+ </div>
64
+ <div className="bg-white dark:bg-gray-800 rounded-lg shadow p-4 sm:p-6">
65
+ <div className="text-sm font-medium text-success-600 dark:text-success-400">Approved</div>
66
+ <div className="mt-2 text-2xl sm:text-3xl font-bold text-success-600 dark:text-success-400">
67
+ {stats.approved}
68
+ </div>
69
+ </div>
70
+ <div className="bg-white dark:bg-gray-800 rounded-lg shadow p-4 sm:p-6">
71
+ <div className="text-sm font-medium text-error-600 dark:text-error-400">Rejected</div>
72
+ <div className="mt-2 text-2xl sm:text-3xl font-bold text-error-600 dark:text-error-400">
73
+ {stats.rejected}
74
+ </div>
75
+ </div>
76
+ </div>
77
+
78
+ {/* Recent Tests Table */}
79
+ <div className="bg-white dark:bg-gray-800 shadow rounded-lg overflow-hidden">
80
+ <div className="px-4 sm:px-6 py-4 border-b border-gray-200 dark:border-gray-700">
81
+ <h2 className="text-lg font-medium text-gray-900 dark:text-white">Recent Tests</h2>
82
+ </div>
83
+ {tests.length === 0 ? (
84
+ <div className="px-6 py-12 text-center">
85
+ <p className="text-gray-500 dark:text-gray-400">No tests yet.</p>
86
+ <p className="text-sm text-gray-400 dark:text-gray-500 mt-1">
87
+ Run the CLI to capture and compare screenshots.
88
+ </p>
89
+ </div>
90
+ ) : (
91
+ <div className="overflow-x-auto">
92
+ <table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
93
+ <thead className="bg-gray-50 dark:bg-gray-900">
94
+ <tr>
95
+ <th className="px-4 sm:px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
96
+ Branch
97
+ </th>
98
+ <th className="px-4 sm:px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider hidden sm:table-cell">
99
+ Commit
100
+ </th>
101
+ <th className="px-4 sm:px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
102
+ Status
103
+ </th>
104
+ <th className="px-4 sm:px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider hidden md:table-cell">
105
+ Changes
106
+ </th>
107
+ <th className="px-4 sm:px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider hidden lg:table-cell">
108
+ Created
109
+ </th>
110
+ <th className="px-4 sm:px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
111
+ Actions
112
+ </th>
113
+ </tr>
114
+ </thead>
115
+ <tbody className="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
116
+ {tests.map((test) => (
117
+ <tr key={test.id} className="hover:bg-gray-50 dark:hover:bg-gray-700">
118
+ <td className="px-4 sm:px-6 py-4 whitespace-nowrap">
119
+ <div className="flex items-center gap-2">
120
+ <GitBranch className="w-4 h-4 text-gray-400 flex-shrink-0" />
121
+ <span className="text-sm font-medium text-gray-900 dark:text-white truncate max-w-[120px] sm:max-w-none">
122
+ {test.branch}
123
+ </span>
124
+ </div>
125
+ <div className="text-xs text-gray-500 dark:text-gray-400 mt-1">
126
+ vs {test.baseBranch}
127
+ </div>
128
+ </td>
129
+ <td className="px-4 sm:px-6 py-4 whitespace-nowrap hidden sm:table-cell">
130
+ <div className="flex items-center gap-2">
131
+ <Hash className="w-4 h-4 text-gray-400" />
132
+ <span className="text-sm font-mono text-gray-600 dark:text-gray-300">
133
+ {test.commitHash}
134
+ </span>
135
+ </div>
136
+ <div className="text-xs text-gray-500 dark:text-gray-400 mt-1 max-w-xs truncate">
137
+ {test.commitMessage}
138
+ </div>
139
+ </td>
140
+ <td className="px-4 sm:px-6 py-4 whitespace-nowrap">
141
+ <StatusBadge status={test.status} />
142
+ </td>
143
+ <td className="px-4 sm:px-6 py-4 whitespace-nowrap text-sm hidden md:table-cell">
144
+ <div className="flex items-center gap-4">
145
+ <span className="text-warning-600 dark:text-warning-400">
146
+ {test.changedCount} changed
147
+ </span>
148
+ <span className="text-success-600 dark:text-success-400">
149
+ {test.passedCount} passed
150
+ </span>
151
+ {test.failedCount > 0 && (
152
+ <span className="text-error-600 dark:text-error-400">
153
+ {test.failedCount} failed
154
+ </span>
155
+ )}
156
+ </div>
157
+ </td>
158
+ <td className="px-4 sm:px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400 hidden lg:table-cell">
159
+ {formatRelativeTime(test.createdAt)}
160
+ </td>
161
+ <td className="px-4 sm:px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
162
+ <a
163
+ href={`/tests/${test.id}`}
164
+ className="text-primary-600 dark:text-primary-400 hover:text-primary-900 dark:hover:text-primary-300"
165
+ >
166
+ View
167
+ </a>
168
+ </td>
169
+ </tr>
170
+ ))}
171
+ </tbody>
172
+ </table>
173
+ </div>
174
+ )}
175
+ </div>
176
+ </div>
177
+ )
178
+ }
@@ -0,0 +1,417 @@
1
+ import { createFileRoute, Link } from '@tanstack/react-router'
2
+ import { createServerFn } from '@tanstack/react-start'
3
+ import { useState, useMemo, useCallback } from 'react'
4
+ import {
5
+ GitBranch,
6
+ Hash,
7
+ ArrowLeft,
8
+ Check,
9
+ X,
10
+ Menu,
11
+ } from 'lucide-react'
12
+ import { eq } from 'drizzle-orm'
13
+ import { getDb, tests, storyResults } from '../../db'
14
+ import { cn, getImageUrl } from '../../lib/utils'
15
+ import { useDebounce } from '../../hooks/useDebounce'
16
+ import { useStoryTree, type Story, type ViewMode } from '../../hooks/useStoryTree'
17
+ import { StatusBadge } from '../../components/ui/StatusBadge'
18
+ import { Drawer } from '../../components/ui/Drawer'
19
+ import { SearchInput } from '../../components/ui/SearchInput'
20
+ import { ViewModeToggle } from '../../components/ui/ViewModeToggle'
21
+ import { StoryFlatList } from '../../components/story/StoryFlatList'
22
+ import { StoryTree } from '../../components/story/StoryTree'
23
+ import { StoryGroupedTree } from '../../components/story/StoryGroupedTree'
24
+ import { ImageCompare } from '../../components/image/ImageCompare'
25
+
26
+ // Get test details from database
27
+ const getTestDetails = createServerFn({ method: 'GET' })
28
+ .inputValidator((data: { id: string }) => data)
29
+ .handler(async ({ data }) => {
30
+ const db = getDb()
31
+
32
+ // Get test
33
+ const [test] = await db
34
+ .select()
35
+ .from(tests)
36
+ .where(eq(tests.id, data.id))
37
+ .limit(1)
38
+
39
+ if (!test) {
40
+ throw new Error('Test not found')
41
+ }
42
+
43
+ // Get story results
44
+ const stories = await db
45
+ .select()
46
+ .from(storyResults)
47
+ .where(eq(storyResults.testId, data.id))
48
+
49
+ return {
50
+ test: {
51
+ ...test,
52
+ createdAt: test.createdAt.toISOString(),
53
+ },
54
+ stories: stories.map((s) => ({
55
+ ...s,
56
+ isNew: !s.baselineUrl,
57
+ })),
58
+ }
59
+ })
60
+
61
+ export const Route = createFileRoute('/tests/$id')({
62
+ component: TestDetail,
63
+ loader: ({ params }) => getTestDetails({ data: { id: params.id } }),
64
+ })
65
+
66
+ type FilterType = 'all' | 'changed' | 'new' | 'passed'
67
+
68
+ function TestDetail() {
69
+ const { test, stories: rawStories } = Route.useLoaderData()
70
+ const [filter, setFilter] = useState<FilterType>('all')
71
+ const [selectedStory, setSelectedStory] = useState<string | null>(
72
+ rawStories[0]?.id || null
73
+ )
74
+ const [searchQuery, setSearchQuery] = useState('')
75
+ const [drawerOpen, setDrawerOpen] = useState(false)
76
+
77
+ const debouncedSearch = useDebounce(searchQuery, 150)
78
+
79
+ // Memoize stories to prevent infinite re-renders
80
+ const stories: Story[] = useMemo(() =>
81
+ rawStories.map((s) => ({
82
+ id: s.id,
83
+ kind: s.kind,
84
+ componentName: s.componentName,
85
+ storyName: s.storyName,
86
+ storyId: s.storyId,
87
+ hasDiff: s.hasDiff,
88
+ isNew: s.isNew,
89
+ pixelDiff: s.pixelDiff,
90
+ ssimScore: s.ssimScore,
91
+ baselineUrl: s.baselineUrl,
92
+ currentUrl: s.currentUrl,
93
+ diffUrl: s.diffUrl,
94
+ })),
95
+ [rawStories]
96
+ )
97
+
98
+ // Filter and search stories
99
+ const filteredStories = useMemo(() => {
100
+ return stories.filter((story) => {
101
+ // Apply filter type
102
+ let passesFilter = true
103
+ switch (filter) {
104
+ case 'changed':
105
+ passesFilter = story.hasDiff
106
+ break
107
+ case 'new':
108
+ passesFilter = story.isNew
109
+ break
110
+ case 'passed':
111
+ passesFilter = !story.hasDiff && !story.isNew
112
+ break
113
+ }
114
+
115
+ if (!passesFilter) return false
116
+
117
+ // Apply search
118
+ if (!debouncedSearch.trim()) return true
119
+
120
+ const query = debouncedSearch.toLowerCase()
121
+ return (
122
+ story.componentName.toLowerCase().includes(query) ||
123
+ story.storyName.toLowerCase().includes(query)
124
+ )
125
+ })
126
+ }, [stories, filter, debouncedSearch])
127
+
128
+ // Use the story tree hook
129
+ const {
130
+ viewMode,
131
+ setViewMode,
132
+ tree,
133
+ groupedTree,
134
+ toggleComponent,
135
+ expandAll,
136
+ collapseAll,
137
+ isExpanded,
138
+ expandToStory,
139
+ } = useStoryTree(filteredStories)
140
+
141
+ // Handle view mode change - expand to selected story when switching to tree/grouped
142
+ const handleSetViewMode = useCallback((mode: ViewMode) => {
143
+ if (viewMode === 'flat' && mode !== 'flat' && selectedStory) {
144
+ // Switching to tree or grouped, expand to show selected story
145
+ expandToStory(selectedStory)
146
+ }
147
+ setViewMode(mode)
148
+ }, [viewMode, selectedStory, expandToStory, setViewMode])
149
+
150
+ const selected = stories.find((s) => s.id === selectedStory)
151
+
152
+ const counts = {
153
+ all: stories.length,
154
+ changed: stories.filter((s) => s.hasDiff).length,
155
+ new: stories.filter((s) => s.isNew).length,
156
+ passed: stories.filter((s) => !s.hasDiff && !s.isNew).length,
157
+ }
158
+
159
+ const handleSelectStory = (storyId: string) => {
160
+ setSelectedStory(storyId)
161
+ setDrawerOpen(false)
162
+ }
163
+
164
+ // Story list panel content (shared between sidebar and drawer)
165
+ const StoryListContent = () => (
166
+ <div className="flex flex-col h-full">
167
+ {/* Search and view toggle */}
168
+ <div className="p-4 space-y-3 border-b border-gray-200 dark:border-gray-700 flex-shrink-0">
169
+ <SearchInput
170
+ value={searchQuery}
171
+ onChange={setSearchQuery}
172
+ placeholder="Search stories..."
173
+ resultCount={filteredStories.length}
174
+ showResultCount={!!debouncedSearch.trim()}
175
+ />
176
+ <div className="flex items-center justify-between">
177
+ <ViewModeToggle viewMode={viewMode} onSetViewMode={handleSetViewMode} />
178
+ <span className="text-xs text-gray-500 dark:text-gray-400">
179
+ {filteredStories.length} of {stories.length}
180
+ </span>
181
+ </div>
182
+ </div>
183
+
184
+ {/* Story list - fills remaining height */}
185
+ <div className="flex-1 overflow-y-auto custom-scrollbar">
186
+ {viewMode === 'flat' && (
187
+ <StoryFlatList
188
+ stories={filteredStories}
189
+ selectedStoryId={selectedStory}
190
+ onSelectStory={handleSelectStory}
191
+ />
192
+ )}
193
+ {viewMode === 'tree' && (
194
+ <StoryTree
195
+ tree={tree}
196
+ selectedStoryId={selectedStory}
197
+ onSelectStory={handleSelectStory}
198
+ isExpanded={isExpanded}
199
+ toggleComponent={toggleComponent}
200
+ expandAll={expandAll}
201
+ collapseAll={collapseAll}
202
+ />
203
+ )}
204
+ {viewMode === 'grouped' && (
205
+ <StoryGroupedTree
206
+ tree={groupedTree}
207
+ selectedStoryId={selectedStory}
208
+ onSelectStory={handleSelectStory}
209
+ isExpanded={isExpanded}
210
+ toggleComponent={toggleComponent}
211
+ expandAll={expandAll}
212
+ collapseAll={collapseAll}
213
+ />
214
+ )}
215
+ </div>
216
+ </div>
217
+ )
218
+
219
+ return (
220
+ <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6 lg:py-8">
221
+ {/* Header */}
222
+ <div className="mb-6">
223
+ <Link
224
+ to="/"
225
+ className="inline-flex items-center gap-1 text-sm text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 mb-4"
226
+ >
227
+ <ArrowLeft className="w-4 h-4" />
228
+ Back to Dashboard
229
+ </Link>
230
+
231
+ <div className="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-4">
232
+ <div>
233
+ <div className="flex items-center gap-3 flex-wrap">
234
+ <h1 className="text-xl sm:text-2xl font-bold text-gray-900 dark:text-white">
235
+ Test Results
236
+ </h1>
237
+ <StatusBadge status={test.status} />
238
+ </div>
239
+ <div className="mt-2 flex flex-wrap items-center gap-x-4 gap-y-1 text-sm text-gray-500 dark:text-gray-400">
240
+ <span className="flex items-center gap-1">
241
+ <GitBranch className="w-4 h-4" />
242
+ {test.branch}
243
+ </span>
244
+ <span className="flex items-center gap-1">
245
+ <Hash className="w-4 h-4" />
246
+ <span className="font-mono">{test.commitHash}</span>
247
+ </span>
248
+ </div>
249
+ {test.commitMessage && (
250
+ <p className="mt-1 text-sm text-gray-600 dark:text-gray-300 line-clamp-2">
251
+ {test.commitMessage}
252
+ </p>
253
+ )}
254
+ </div>
255
+
256
+ {(counts.changed > 0 || counts.new > 0) && (
257
+ <div className="flex gap-2 flex-shrink-0">
258
+ <button className="px-3 sm:px-4 py-2 bg-success-600 text-white rounded-md hover:bg-success-700 flex items-center gap-2 text-sm">
259
+ <Check className="w-4 h-4" />
260
+ <span className="hidden sm:inline">Approve All</span>
261
+ <span className="sm:hidden">Approve</span>
262
+ </button>
263
+ <button className="px-3 sm:px-4 py-2 bg-error-600 text-white rounded-md hover:bg-error-700 flex items-center gap-2 text-sm">
264
+ <X className="w-4 h-4" />
265
+ <span className="hidden sm:inline">Reject All</span>
266
+ <span className="sm:hidden">Reject</span>
267
+ </button>
268
+ </div>
269
+ )}
270
+ </div>
271
+ </div>
272
+
273
+ {/* Filter Tabs */}
274
+ <div className="border-b border-gray-200 dark:border-gray-700 mb-6 overflow-x-auto">
275
+ <nav className="-mb-px flex gap-4 sm:gap-6 min-w-max">
276
+ {(['all', 'changed', 'new', 'passed'] as const).map((tab) => (
277
+ <button
278
+ key={tab}
279
+ onClick={() => setFilter(tab)}
280
+ className={cn(
281
+ 'py-3 sm:py-4 px-1 border-b-2 font-medium text-sm whitespace-nowrap transition-colors',
282
+ filter === tab
283
+ ? 'border-primary-500 text-primary-600 dark:text-primary-400'
284
+ : 'border-transparent text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 hover:border-gray-300 dark:hover:border-gray-500'
285
+ )}
286
+ >
287
+ {tab.charAt(0).toUpperCase() + tab.slice(1)}
288
+ <span
289
+ className={cn(
290
+ 'ml-2 px-2 py-0.5 rounded-full text-xs',
291
+ filter === tab
292
+ ? 'bg-primary-100 text-primary-600 dark:bg-primary-900 dark:text-primary-200'
293
+ : 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-300'
294
+ )}
295
+ >
296
+ {counts[tab]}
297
+ </span>
298
+ </button>
299
+ ))}
300
+ </nav>
301
+ </div>
302
+
303
+ {/* Mobile story list button */}
304
+ <div className="lg:hidden mb-4">
305
+ <button
306
+ onClick={() => setDrawerOpen(true)}
307
+ className={cn(
308
+ 'w-full px-4 py-3 rounded-lg border',
309
+ 'bg-white dark:bg-gray-800',
310
+ 'border-gray-200 dark:border-gray-700',
311
+ 'flex items-center justify-between',
312
+ 'text-gray-700 dark:text-gray-200'
313
+ )}
314
+ >
315
+ <div className="flex items-center gap-3">
316
+ <Menu className="w-5 h-5 text-gray-400" />
317
+ <span className="font-medium">
318
+ {selected ? `${selected.componentName} / ${selected.storyName}` : 'Select a story'}
319
+ </span>
320
+ </div>
321
+ <span className="text-sm text-gray-500 dark:text-gray-400">
322
+ {filteredStories.length} stories
323
+ </span>
324
+ </button>
325
+ </div>
326
+
327
+ {/* Main Content - Responsive Grid */}
328
+ <div className="grid grid-cols-1 lg:grid-cols-12 gap-6 lg:items-stretch">
329
+ {/* Story List - Hidden on mobile, shown in drawer */}
330
+ <div className="hidden lg:block lg:col-span-4 lg:h-[calc(100vh-20rem)]">
331
+ <div className="bg-white dark:bg-gray-800 rounded-lg shadow overflow-hidden h-full flex flex-col">
332
+ <div className="px-4 py-3 border-b border-gray-200 dark:border-gray-700 flex-shrink-0">
333
+ <h3 className="font-medium text-gray-900 dark:text-white">Stories</h3>
334
+ </div>
335
+ <div className="flex-1 min-h-0">
336
+ <StoryListContent />
337
+ </div>
338
+ </div>
339
+ </div>
340
+
341
+ {/* Image Comparison - Full width on mobile */}
342
+ <div className="col-span-1 lg:col-span-8 lg:h-[calc(100vh-20rem)]">
343
+ {selected ? (
344
+ <div className="bg-white dark:bg-gray-800 rounded-lg shadow p-4 sm:p-6 h-full flex flex-col overflow-hidden">
345
+ <div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 mb-4 flex-shrink-0">
346
+ <div className="min-w-0">
347
+ <h3 className="text-lg font-medium text-gray-900 dark:text-white truncate">
348
+ {selected.componentName} / {selected.storyName}
349
+ </h3>
350
+ <p className="text-sm text-gray-500 dark:text-gray-400 truncate">
351
+ {selected.storyId}
352
+ </p>
353
+ </div>
354
+ {(selected.hasDiff || selected.isNew) && (
355
+ <div className="flex gap-2 flex-shrink-0">
356
+ <button className="px-3 py-1.5 bg-success-100 text-success-700 dark:bg-success-900 dark:text-success-200 rounded-md hover:bg-success-200 dark:hover:bg-success-800 flex items-center gap-1 text-sm transition-colors">
357
+ <Check className="w-4 h-4" />
358
+ Approve
359
+ </button>
360
+ <button className="px-3 py-1.5 bg-error-100 text-error-700 dark:bg-error-900 dark:text-error-200 rounded-md hover:bg-error-200 dark:hover:bg-error-800 flex items-center gap-1 text-sm transition-colors">
361
+ <X className="w-4 h-4" />
362
+ Reject
363
+ </button>
364
+ </div>
365
+ )}
366
+ </div>
367
+
368
+ {selected.hasDiff && (
369
+ <div className="mb-4 p-3 bg-warning-50 dark:bg-warning-900/30 border border-warning-200 dark:border-warning-700 rounded-md flex-shrink-0">
370
+ <div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-sm">
371
+ <span className="text-warning-700 dark:text-warning-300">
372
+ Pixel Difference:{' '}
373
+ <strong>{selected.pixelDiff.toFixed(2)}%</strong>
374
+ </span>
375
+ <span className="text-warning-700 dark:text-warning-300">
376
+ SSIM Score:{' '}
377
+ <strong>{(selected.ssimScore * 100).toFixed(1)}%</strong>
378
+ </span>
379
+ </div>
380
+ </div>
381
+ )}
382
+
383
+ {selected.isNew && (
384
+ <div className="mb-4 p-3 bg-info-50 dark:bg-info-900/30 border border-info-200 dark:border-info-700 rounded-md flex-shrink-0">
385
+ <span className="text-sm text-info-700 dark:text-info-300">
386
+ This is a new story without a baseline. Approve to set the
387
+ current screenshot as the baseline.
388
+ </span>
389
+ </div>
390
+ )}
391
+
392
+ <ImageCompare
393
+ baseline={getImageUrl(selected.baselineUrl)}
394
+ current={getImageUrl(selected.currentUrl)!}
395
+ diff={getImageUrl(selected.diffUrl)}
396
+ className="flex-1 min-h-0"
397
+ />
398
+ </div>
399
+ ) : (
400
+ <div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6 text-center text-gray-500 dark:text-gray-400">
401
+ Select a story to view comparison
402
+ </div>
403
+ )}
404
+ </div>
405
+ </div>
406
+
407
+ {/* Mobile Drawer */}
408
+ <Drawer
409
+ open={drawerOpen}
410
+ onClose={() => setDrawerOpen(false)}
411
+ title="Stories"
412
+ >
413
+ <StoryListContent />
414
+ </Drawer>
415
+ </div>
416
+ )
417
+ }