@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,148 @@
1
+ /**
2
+ * Backfill script to populate the `kind` field from storyId
3
+ *
4
+ * Run with: npx tsx scripts/backfill-kind.ts
5
+ */
6
+
7
+ import { drizzle } from 'drizzle-orm/postgres-js'
8
+ import postgres from 'postgres'
9
+ import { eq, isNull } from 'drizzle-orm'
10
+ import * as schema from '../src/db/schema'
11
+
12
+ const connectionString = process.env.DATABASE_URL
13
+
14
+ if (!connectionString) {
15
+ console.error('DATABASE_URL environment variable is required')
16
+ process.exit(1)
17
+ }
18
+
19
+ const client = postgres(connectionString)
20
+ const db = drizzle(client, { schema })
21
+
22
+ /**
23
+ * Reconstruct kind (full path) from storyId
24
+ * e.g., "ui-button--primary" -> "UI/Button"
25
+ * e.g., "components-forms-textinput--default" -> "Components/Forms/TextInput"
26
+ */
27
+ function extractKindFromStoryId(storyId: string): string {
28
+ const parts = storyId.split('--')
29
+ if (parts.length === 0) return storyId
30
+
31
+ const titlePart = parts[0]
32
+ const segments = titlePart.split('-')
33
+
34
+ if (segments.length === 1) {
35
+ // Single segment - just capitalize
36
+ return capitalize(segments[0])
37
+ }
38
+
39
+ // Multiple segments - treat all but last as directory path, last as component
40
+ // e.g., ["ui", "button"] -> "UI/Button"
41
+ // e.g., ["components", "forms", "text", "input"] is ambiguous...
42
+
43
+ // Heuristic: common directory names are short (ui, components, screens, etc.)
44
+ // We'll assume segments that look like common dirs stay as dirs
45
+ const commonDirs = ['ui', 'components', 'screens', 'forms', 'layout', 'navigation', 'common', 'shared', 'core', 'atoms', 'molecules', 'organisms', 'templates', 'pages']
46
+
47
+ const pathParts: string[] = []
48
+ let i = 0
49
+
50
+ // Collect directory parts
51
+ while (i < segments.length - 1) {
52
+ if (commonDirs.includes(segments[i].toLowerCase())) {
53
+ pathParts.push(capitalize(segments[i]))
54
+ i++
55
+ } else {
56
+ break
57
+ }
58
+ }
59
+
60
+ // If no common dirs found, assume first segment is directory
61
+ if (pathParts.length === 0 && segments.length > 1) {
62
+ pathParts.push(capitalize(segments[0]))
63
+ i = 1
64
+ }
65
+
66
+ // Remaining segments form the component name (PascalCase)
67
+ const componentSegments = segments.slice(i)
68
+ const componentName = componentSegments.map(capitalize).join('')
69
+
70
+ if (pathParts.length > 0) {
71
+ return [...pathParts, componentName].join('/')
72
+ }
73
+
74
+ return componentName
75
+ }
76
+
77
+ function capitalize(str: string): string {
78
+ return str.charAt(0).toUpperCase() + str.slice(1).toLowerCase()
79
+ }
80
+
81
+ async function backfill() {
82
+ console.log('Starting backfill of kind field...\n')
83
+
84
+ // Get all story results without kind
85
+ const stories = await db
86
+ .select()
87
+ .from(schema.storyResults)
88
+ .where(isNull(schema.storyResults.kind))
89
+
90
+ console.log(`Found ${stories.length} stories without kind field\n`)
91
+
92
+ if (stories.length === 0) {
93
+ console.log('Nothing to backfill!')
94
+ await client.end()
95
+ return
96
+ }
97
+
98
+ // Group by derived kind to show preview
99
+ const preview = new Map<string, string[]>()
100
+ for (const story of stories) {
101
+ const kind = extractKindFromStoryId(story.storyId)
102
+ if (!preview.has(kind)) {
103
+ preview.set(kind, [])
104
+ }
105
+ preview.get(kind)!.push(story.storyId)
106
+ }
107
+
108
+ console.log('Preview of kind mappings:')
109
+ console.log('='.repeat(60))
110
+ for (const [kind, storyIds] of Array.from(preview.entries()).slice(0, 20)) {
111
+ console.log(` ${kind}`)
112
+ for (const id of storyIds.slice(0, 3)) {
113
+ console.log(` <- ${id}`)
114
+ }
115
+ if (storyIds.length > 3) {
116
+ console.log(` ... and ${storyIds.length - 3} more`)
117
+ }
118
+ }
119
+ if (preview.size > 20) {
120
+ console.log(` ... and ${preview.size - 20} more directories`)
121
+ }
122
+ console.log('='.repeat(60))
123
+ console.log()
124
+
125
+ // Update each story
126
+ let updated = 0
127
+ for (const story of stories) {
128
+ const kind = extractKindFromStoryId(story.storyId)
129
+
130
+ await db
131
+ .update(schema.storyResults)
132
+ .set({ kind })
133
+ .where(eq(schema.storyResults.id, story.id))
134
+
135
+ updated++
136
+ if (updated % 100 === 0) {
137
+ console.log(`Updated ${updated}/${stories.length}...`)
138
+ }
139
+ }
140
+
141
+ console.log(`\nBackfill complete! Updated ${updated} stories.`)
142
+ await client.end()
143
+ }
144
+
145
+ backfill().catch((err) => {
146
+ console.error('Backfill failed:', err)
147
+ process.exit(1)
148
+ })
@@ -0,0 +1,169 @@
1
+ import type { Plugin } from 'vite'
2
+ import { drizzle } from 'drizzle-orm/postgres-js'
3
+ import postgres from 'postgres'
4
+ import { createReadStream, existsSync, statSync } from 'fs'
5
+ import { extname } from 'path'
6
+ import * as schema from './db/schema'
7
+
8
+ const MIME_TYPES: Record<string, string> = {
9
+ '.png': 'image/png',
10
+ '.jpg': 'image/jpeg',
11
+ '.jpeg': 'image/jpeg',
12
+ '.gif': 'image/gif',
13
+ '.webp': 'image/webp',
14
+ }
15
+
16
+ /**
17
+ * Vite plugin to add API endpoints for the CLI
18
+ */
19
+ export function apiPlugin(): Plugin {
20
+ let db: ReturnType<typeof drizzle> | null = null
21
+
22
+ return {
23
+ name: 'api-plugin',
24
+ configureServer(server) {
25
+ // Initialize database connection
26
+ const connectionString = process.env.DATABASE_URL
27
+ if (connectionString) {
28
+ const client = postgres(connectionString)
29
+ db = drizzle(client, { schema })
30
+ }
31
+
32
+ // Add middleware for API routes
33
+ server.middlewares.use(async (req, res, next) => {
34
+ // Image serving endpoint
35
+ if (req.url?.startsWith('/api/images') && req.method === 'GET') {
36
+ const url = new URL(req.url, 'http://localhost')
37
+ const filePath = url.searchParams.get('path')
38
+
39
+ if (!filePath) {
40
+ res.statusCode = 400
41
+ res.end('Missing path parameter')
42
+ return
43
+ }
44
+
45
+ // Security: only allow image files
46
+ const ext = extname(filePath).toLowerCase()
47
+ if (!MIME_TYPES[ext]) {
48
+ res.statusCode = 400
49
+ res.end('Invalid file type')
50
+ return
51
+ }
52
+
53
+ // Check if file exists
54
+ if (!existsSync(filePath)) {
55
+ res.statusCode = 404
56
+ res.end('File not found')
57
+ return
58
+ }
59
+
60
+ try {
61
+ const stat = statSync(filePath)
62
+ res.setHeader('Content-Type', MIME_TYPES[ext])
63
+ res.setHeader('Content-Length', stat.size)
64
+ res.setHeader('Cache-Control', 'public, max-age=31536000') // Cache for 1 year
65
+ createReadStream(filePath).pipe(res)
66
+ } catch (error) {
67
+ console.error('Error serving image:', error)
68
+ res.statusCode = 500
69
+ res.end('Error serving image')
70
+ }
71
+ return
72
+ }
73
+
74
+ if (req.url === '/api/upload' && req.method === 'POST') {
75
+ if (!db) {
76
+ res.statusCode = 500
77
+ res.setHeader('Content-Type', 'application/json')
78
+ res.end(JSON.stringify({ error: 'Database not configured' }))
79
+ return
80
+ }
81
+
82
+ let body = ''
83
+ req.on('data', (chunk) => {
84
+ body += chunk.toString()
85
+ })
86
+
87
+ req.on('end', async () => {
88
+ try {
89
+ const data = JSON.parse(body)
90
+ const {
91
+ branch,
92
+ baseBranch = 'main',
93
+ commitHash,
94
+ commitMessage,
95
+ stories = [],
96
+ } = data
97
+
98
+ // Validate required fields
99
+ if (!branch || !commitHash) {
100
+ res.statusCode = 400
101
+ res.setHeader('Content-Type', 'application/json')
102
+ res.end(JSON.stringify({ error: 'Missing required fields: branch, commitHash' }))
103
+ return
104
+ }
105
+
106
+ // Calculate counts
107
+ const totalStories = stories.length
108
+ const changedCount = stories.filter((s: any) => s.hasDiff || s.isNew).length
109
+ const passedCount = stories.filter((s: any) => !s.hasDiff && !s.isNew).length
110
+ const failedCount = stories.filter((s: any) => s.hasDiff).length
111
+
112
+ // Create test record
113
+ const [test] = await db
114
+ .insert(schema.tests)
115
+ .values({
116
+ branch,
117
+ baseBranch,
118
+ commitHash,
119
+ commitMessage,
120
+ status: 'PENDING',
121
+ totalStories,
122
+ changedCount,
123
+ passedCount,
124
+ failedCount,
125
+ })
126
+ .returning()
127
+
128
+ // Create story results
129
+ if (stories.length > 0) {
130
+ await db.insert(schema.storyResults).values(
131
+ stories.map((story: any) => ({
132
+ testId: test.id,
133
+ storyId: story.storyId,
134
+ kind: story.kind || story.title || null, // Full path like "UI/Button"
135
+ componentName: story.componentName,
136
+ storyName: story.storyName,
137
+ baselineUrl: story.baselineUrl || null,
138
+ currentUrl: story.currentUrl,
139
+ diffUrl: story.diffUrl || null,
140
+ pixelDiff: story.pixelDiff,
141
+ ssimScore: story.ssimScore,
142
+ hasDiff: story.hasDiff,
143
+ isNew: story.isNew,
144
+ }))
145
+ )
146
+ }
147
+
148
+ res.statusCode = 200
149
+ res.setHeader('Content-Type', 'application/json')
150
+ res.end(JSON.stringify({
151
+ success: true,
152
+ testId: test.id,
153
+ url: `/tests/${test.id}`,
154
+ }))
155
+ } catch (error) {
156
+ console.error('Upload error:', error)
157
+ res.statusCode = 500
158
+ res.setHeader('Content-Type', 'application/json')
159
+ res.end(JSON.stringify({ error: 'Failed to upload test results' }))
160
+ }
161
+ })
162
+ return
163
+ }
164
+
165
+ next()
166
+ })
167
+ },
168
+ }
169
+ }
@@ -0,0 +1,188 @@
1
+ import { useState } from 'react'
2
+ import { Eye, Layers } from 'lucide-react'
3
+ import { cn } from '../../lib/utils'
4
+
5
+ type ViewMode = 'side-by-side' | 'diff' | 'overlay' | 'current'
6
+
7
+ interface ImageCompareProps {
8
+ baseline: string | null
9
+ current: string
10
+ diff: string | null
11
+ className?: string
12
+ }
13
+
14
+ const placeholderSvg = (text: string) =>
15
+ `data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" width="200" height="200"><rect fill="%23f3f4f6" width="200" height="200"/><text fill="%239ca3af" x="50%" y="50%" dominant-baseline="middle" text-anchor="middle" font-size="14">${encodeURIComponent(text)}</text></svg>`
16
+
17
+ export function ImageCompare({ baseline, current, diff, className }: ImageCompareProps) {
18
+ const [view, setView] = useState<ViewMode>('side-by-side')
19
+ const [overlayOpacity, setOverlayOpacity] = useState(0.5)
20
+
21
+ const viewModes: { value: ViewMode; label: string; icon: typeof Eye; showIf?: boolean }[] = [
22
+ { value: 'side-by-side', label: 'Side by Side', icon: Layers },
23
+ { value: 'diff', label: 'Diff Only', icon: Eye, showIf: !!diff },
24
+ { value: 'overlay', label: 'Overlay', icon: Layers, showIf: !!diff },
25
+ { value: 'current', label: 'Current Only', icon: Eye },
26
+ ]
27
+
28
+ return (
29
+ <div className={cn('flex flex-col overflow-hidden', className)}>
30
+ {/* View mode buttons */}
31
+ <div className="flex gap-2 mb-4 flex-wrap items-center flex-shrink-0">
32
+ {viewModes.map(
33
+ (mode) =>
34
+ (mode.showIf === undefined || mode.showIf) && (
35
+ <button
36
+ key={mode.value}
37
+ onClick={() => setView(mode.value)}
38
+ className={cn(
39
+ 'px-3 py-1.5 text-sm rounded-md transition-colors',
40
+ view === mode.value
41
+ ? 'bg-primary-100 text-primary-700 dark:bg-primary-900 dark:text-primary-200'
42
+ : 'bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600'
43
+ )}
44
+ >
45
+ <mode.icon className="w-4 h-4 inline mr-1" />
46
+ <span className="hidden sm:inline">{mode.label}</span>
47
+ </button>
48
+ )
49
+ )}
50
+
51
+ {/* Overlay opacity slider */}
52
+ {view === 'overlay' && diff && (
53
+ <div className="flex items-center gap-2 ml-4 pl-4 border-l border-gray-300 dark:border-gray-600">
54
+ <span className="text-sm text-gray-500 dark:text-gray-400 hidden sm:inline">
55
+ Diff Opacity:
56
+ </span>
57
+ <input
58
+ type="range"
59
+ min="0"
60
+ max="1"
61
+ step="0.1"
62
+ value={overlayOpacity}
63
+ onChange={(e) => setOverlayOpacity(parseFloat(e.target.value))}
64
+ className="w-24"
65
+ />
66
+ <span className="text-sm text-gray-500 dark:text-gray-400 w-8">
67
+ {Math.round(overlayOpacity * 100)}%
68
+ </span>
69
+ </div>
70
+ )}
71
+ </div>
72
+
73
+ {/* Image content area - fills remaining height */}
74
+ <div className="flex-1 min-h-0 overflow-hidden">
75
+ {/* Side by side view */}
76
+ {view === 'side-by-side' && (
77
+ <div className="h-full grid grid-cols-2 gap-4">
78
+ <div className="min-h-0 flex flex-col">
79
+ <div className="text-sm font-medium text-gray-500 dark:text-gray-400 mb-2 flex-shrink-0">
80
+ Baseline
81
+ </div>
82
+ <div className="flex-1 min-h-0 flex items-center justify-center">
83
+ {baseline ? (
84
+ <img
85
+ src={baseline}
86
+ alt="Baseline"
87
+ className="max-w-full max-h-full object-contain rounded-lg border border-gray-200 dark:border-gray-600"
88
+ onError={(e) => {
89
+ e.currentTarget.src = placeholderSvg('No baseline')
90
+ }}
91
+ />
92
+ ) : (
93
+ <div className="border border-gray-200 dark:border-gray-600 rounded-lg h-48 w-32 flex items-center justify-center text-gray-400 text-sm text-center px-2">
94
+ No baseline<br />(new story)
95
+ </div>
96
+ )}
97
+ </div>
98
+ </div>
99
+ <div className="min-h-0 flex flex-col">
100
+ <div className="text-sm font-medium text-gray-500 dark:text-gray-400 mb-2 flex-shrink-0">
101
+ Current
102
+ </div>
103
+ <div className="flex-1 min-h-0 flex items-center justify-center">
104
+ <img
105
+ src={current}
106
+ alt="Current"
107
+ className="max-w-full max-h-full object-contain rounded-lg border border-gray-200 dark:border-gray-600"
108
+ onError={(e) => {
109
+ e.currentTarget.src = placeholderSvg('Image not found')
110
+ }}
111
+ />
112
+ </div>
113
+ </div>
114
+ </div>
115
+ )}
116
+
117
+ {/* Diff only view */}
118
+ {view === 'diff' && diff && (
119
+ <div className="h-full flex flex-col">
120
+ <div className="text-sm font-medium text-gray-500 dark:text-gray-400 mb-2 flex-shrink-0">
121
+ Difference Highlight
122
+ </div>
123
+ <div className="flex-1 min-h-0 flex items-center justify-center">
124
+ <img
125
+ src={diff}
126
+ alt="Diff"
127
+ className="max-w-full max-h-full object-contain rounded-lg border border-gray-200 dark:border-gray-600"
128
+ onError={(e) => {
129
+ e.currentTarget.src = placeholderSvg('Diff not available')
130
+ }}
131
+ />
132
+ </div>
133
+ </div>
134
+ )}
135
+
136
+ {/* Overlay view */}
137
+ {view === 'overlay' && diff && (
138
+ <div className="h-full flex flex-col">
139
+ <div className="text-sm font-medium text-gray-500 dark:text-gray-400 mb-2 flex-shrink-0">
140
+ Current with Diff Overlay
141
+ <span className="ml-2 text-xs text-gray-400 dark:text-gray-500">
142
+ (red/magenta areas show differences)
143
+ </span>
144
+ </div>
145
+ <div className="flex-1 min-h-0 flex items-center justify-center relative">
146
+ <img
147
+ src={current}
148
+ alt="Current"
149
+ className="max-w-full max-h-full object-contain rounded-lg border border-gray-200 dark:border-gray-600"
150
+ onError={(e) => {
151
+ e.currentTarget.src = placeholderSvg('Image not found')
152
+ }}
153
+ />
154
+ <img
155
+ src={diff}
156
+ alt="Diff overlay"
157
+ className="absolute max-w-full max-h-full object-contain pointer-events-none rounded-lg"
158
+ style={{ opacity: overlayOpacity }}
159
+ onError={(e) => {
160
+ e.currentTarget.style.display = 'none'
161
+ }}
162
+ />
163
+ </div>
164
+ </div>
165
+ )}
166
+
167
+ {/* Current only view */}
168
+ {view === 'current' && (
169
+ <div className="h-full flex flex-col">
170
+ <div className="text-sm font-medium text-gray-500 dark:text-gray-400 mb-2 flex-shrink-0">
171
+ Current Screenshot
172
+ </div>
173
+ <div className="flex-1 min-h-0 flex items-center justify-center">
174
+ <img
175
+ src={current}
176
+ alt="Current"
177
+ className="max-w-full max-h-full object-contain rounded-lg border border-gray-200 dark:border-gray-600"
178
+ onError={(e) => {
179
+ e.currentTarget.src = placeholderSvg('Image not found')
180
+ }}
181
+ />
182
+ </div>
183
+ </div>
184
+ )}
185
+ </div>
186
+ </div>
187
+ )
188
+ }
@@ -0,0 +1,67 @@
1
+ import { CheckCircle } from 'lucide-react'
2
+ import { cn } from '../../lib/utils'
3
+ import type { Story } from '../../hooks/useStoryTree'
4
+
5
+ interface StoryFlatListProps {
6
+ stories: Story[]
7
+ selectedStoryId: string | null
8
+ onSelectStory: (storyId: string) => void
9
+ className?: string
10
+ }
11
+
12
+ export function StoryFlatList({
13
+ stories,
14
+ selectedStoryId,
15
+ onSelectStory,
16
+ className,
17
+ }: StoryFlatListProps) {
18
+ if (stories.length === 0) {
19
+ return (
20
+ <div className="px-4 py-8 text-center text-gray-500 dark:text-gray-400">
21
+ No stories match your filters
22
+ </div>
23
+ )
24
+ }
25
+
26
+ return (
27
+ <ul className={cn('divide-y divide-gray-200 dark:divide-gray-700', className)}>
28
+ {stories.map((story) => (
29
+ <li
30
+ key={story.id}
31
+ onClick={() => onSelectStory(story.id)}
32
+ className={cn(
33
+ 'px-4 py-3 cursor-pointer transition-colors',
34
+ 'hover:bg-gray-50 dark:hover:bg-gray-700',
35
+ selectedStoryId === story.id && 'bg-primary-50 dark:bg-primary-900/30'
36
+ )}
37
+ >
38
+ <div className="flex items-center justify-between">
39
+ <div className="min-w-0 flex-1">
40
+ <div className="text-sm font-medium text-gray-900 dark:text-white truncate">
41
+ {story.componentName}
42
+ </div>
43
+ <div className="text-sm text-gray-500 dark:text-gray-400 truncate">
44
+ {story.storyName}
45
+ </div>
46
+ </div>
47
+ <div className="flex items-center gap-2 ml-2 flex-shrink-0">
48
+ {story.isNew && (
49
+ <span className="px-2 py-0.5 bg-info-100 text-info-700 dark:bg-info-900 dark:text-info-200 text-xs rounded-full">
50
+ New
51
+ </span>
52
+ )}
53
+ {story.hasDiff && (
54
+ <span className="px-2 py-0.5 bg-warning-100 text-warning-700 dark:bg-warning-900 dark:text-warning-200 text-xs rounded-full">
55
+ {story.pixelDiff.toFixed(1)}%
56
+ </span>
57
+ )}
58
+ {!story.hasDiff && !story.isNew && (
59
+ <CheckCircle className="w-4 h-4 text-success-500" />
60
+ )}
61
+ </div>
62
+ </div>
63
+ </li>
64
+ ))}
65
+ </ul>
66
+ )
67
+ }