tanuki-telemetry 1.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 (54) hide show
  1. package/Dockerfile +22 -0
  2. package/bin/tanuki.mjs +251 -0
  3. package/frontend/eslint.config.js +23 -0
  4. package/frontend/index.html +13 -0
  5. package/frontend/package.json +39 -0
  6. package/frontend/src/App.tsx +232 -0
  7. package/frontend/src/assets/hero.png +0 -0
  8. package/frontend/src/assets/react.svg +1 -0
  9. package/frontend/src/assets/vite.svg +1 -0
  10. package/frontend/src/components/ArtifactsPanel.tsx +429 -0
  11. package/frontend/src/components/ChildStreams.tsx +176 -0
  12. package/frontend/src/components/CoordinatorPage.tsx +317 -0
  13. package/frontend/src/components/Header.tsx +108 -0
  14. package/frontend/src/components/InsightsPanel.tsx +142 -0
  15. package/frontend/src/components/IterationsTable.tsx +98 -0
  16. package/frontend/src/components/KnowledgePage.tsx +308 -0
  17. package/frontend/src/components/LoginPage.tsx +55 -0
  18. package/frontend/src/components/PlanProgress.tsx +163 -0
  19. package/frontend/src/components/QualityReport.tsx +276 -0
  20. package/frontend/src/components/ScreenshotUpload.tsx +117 -0
  21. package/frontend/src/components/ScreenshotsGrid.tsx +266 -0
  22. package/frontend/src/components/SessionDetail.tsx +265 -0
  23. package/frontend/src/components/SessionList.tsx +234 -0
  24. package/frontend/src/components/SettingsPage.tsx +213 -0
  25. package/frontend/src/components/StreamComms.tsx +228 -0
  26. package/frontend/src/components/TanukiLogo.tsx +16 -0
  27. package/frontend/src/components/Timeline.tsx +416 -0
  28. package/frontend/src/components/WalkthroughPage.tsx +458 -0
  29. package/frontend/src/hooks/useApi.ts +81 -0
  30. package/frontend/src/hooks/useAuth.ts +54 -0
  31. package/frontend/src/hooks/useKnowledge.ts +33 -0
  32. package/frontend/src/hooks/useWebSocket.ts +95 -0
  33. package/frontend/src/index.css +66 -0
  34. package/frontend/src/lib/api.ts +15 -0
  35. package/frontend/src/lib/utils.ts +58 -0
  36. package/frontend/src/main.tsx +10 -0
  37. package/frontend/src/types.ts +181 -0
  38. package/frontend/tsconfig.app.json +32 -0
  39. package/frontend/tsconfig.json +7 -0
  40. package/frontend/vite.config.ts +25 -0
  41. package/install.sh +87 -0
  42. package/package.json +63 -0
  43. package/src/api-keys.ts +97 -0
  44. package/src/auth.ts +165 -0
  45. package/src/coordinator.ts +136 -0
  46. package/src/dashboard-server.ts +5 -0
  47. package/src/dashboard.ts +826 -0
  48. package/src/db.ts +1009 -0
  49. package/src/index.ts +20 -0
  50. package/src/middleware.ts +76 -0
  51. package/src/tools.ts +864 -0
  52. package/src/types-shim.d.ts +18 -0
  53. package/src/types.ts +171 -0
  54. package/tsconfig.json +19 -0
@@ -0,0 +1,276 @@
1
+ import { useMemo, useRef, useState, useEffect } from "react"
2
+ import gsap from "gsap"
3
+ import {
4
+ RadarChart,
5
+ PolarGrid,
6
+ PolarAngleAxis,
7
+ Radar,
8
+ LineChart,
9
+ Line,
10
+ XAxis,
11
+ YAxis,
12
+ CartesianGrid,
13
+ Tooltip,
14
+ } from "recharts"
15
+ import type { Event } from "@/types"
16
+ import { cn } from "@/lib/utils"
17
+
18
+ // Stable chart container that measures width once and doesn't flicker on re-renders
19
+ function ChartContainer({ height, children }: { height: number; children: (width: number) => React.ReactNode }) {
20
+ const containerRef = useRef<HTMLDivElement>(null)
21
+ const [width, setWidth] = useState(0)
22
+
23
+ useEffect(() => {
24
+ if (!containerRef.current) return
25
+ const w = containerRef.current.clientWidth
26
+ if (w > 0 && w !== width) setWidth(w)
27
+
28
+ const observer = new ResizeObserver((entries) => {
29
+ const newW = entries[0]?.contentRect.width ?? 0
30
+ if (newW > 0) setWidth(newW)
31
+ })
32
+ observer.observe(containerRef.current)
33
+ return () => observer.disconnect()
34
+ }, []) // eslint-disable-line react-hooks/exhaustive-deps
35
+
36
+ return (
37
+ <div ref={containerRef} style={{ height }}>
38
+ {width > 0 && children(width)}
39
+ </div>
40
+ )
41
+ }
42
+
43
+ interface QualityEvent {
44
+ overall_score: number
45
+ score_breakdown: Record<string, number>
46
+ version?: string
47
+ report_path?: string
48
+ timestamp: string
49
+ }
50
+
51
+ interface Props {
52
+ events: Event[]
53
+ }
54
+
55
+ function parseQualityEvents(events: Event[]): QualityEvent[] {
56
+ const results: QualityEvent[] = []
57
+ for (const ev of events) {
58
+ if (!ev.metadata) continue
59
+ try {
60
+ const meta = typeof ev.metadata === "string" ? JSON.parse(ev.metadata) : ev.metadata
61
+ if (meta.overall_score !== undefined && typeof meta.overall_score === "number") {
62
+ results.push({
63
+ overall_score: meta.overall_score,
64
+ score_breakdown: meta.score_breakdown || {},
65
+ version: meta.version,
66
+ report_path: meta.report_path,
67
+ timestamp: ev.timestamp,
68
+ })
69
+ }
70
+ } catch {
71
+ // ignore
72
+ }
73
+ }
74
+ return results
75
+ }
76
+
77
+ function scoreColor(score: number): string {
78
+ if (score >= 9) return "text-yellow-400"
79
+ if (score >= 8) return "text-accent"
80
+ if (score >= 7) return "text-warning"
81
+ return "text-error"
82
+ }
83
+
84
+ function scoreBorder(score: number): string {
85
+ if (score >= 9) return "border-yellow-400/40"
86
+ if (score >= 8) return "border-accent/40"
87
+ if (score >= 7) return "border-warning/40"
88
+ return "border-error/40"
89
+ }
90
+
91
+ function scoreBg(score: number): string {
92
+ if (score >= 9) return "bg-yellow-400/5"
93
+ if (score >= 8) return "bg-accent/5"
94
+ if (score >= 7) return "bg-warning/5"
95
+ return "bg-error/5"
96
+ }
97
+
98
+ function scoreHex(score: number): string {
99
+ if (score >= 9) return "#facc15"
100
+ if (score >= 8) return "#00ff88"
101
+ if (score >= 7) return "#ffaa00"
102
+ return "#ff4444"
103
+ }
104
+
105
+ export function QualityReport({ events }: Props) {
106
+ const qualityEvents = useMemo(() => parseQualityEvents(events), [events])
107
+
108
+ if (qualityEvents.length === 0) return null
109
+
110
+ const latest = qualityEvents[qualityEvents.length - 1]
111
+ const hasProgression = qualityEvents.length > 1
112
+
113
+ // Radar chart data from latest breakdown
114
+ const radarData = Object.entries(latest.score_breakdown).map(([key, value]) => ({
115
+ category: key,
116
+ score: value,
117
+ fullMark: 10,
118
+ }))
119
+
120
+ // Line chart data for progression
121
+ const progressionData = qualityEvents.map((qe, i) => ({
122
+ label: qe.version || `v${i + 1}`,
123
+ score: qe.overall_score,
124
+ ...qe.score_breakdown,
125
+ }))
126
+
127
+ const qrRef = useRef<HTMLDivElement>(null)
128
+
129
+ useEffect(() => {
130
+ if (!qrRef.current) return
131
+ gsap.fromTo(
132
+ qrRef.current.children,
133
+ { opacity: 0, y: 8 },
134
+ { opacity: 1, y: 0, duration: 0.3, stagger: 0.06, ease: "power2.out" }
135
+ )
136
+ }, [])
137
+
138
+ return (
139
+ <div ref={qrRef} className="space-y-3">
140
+ {/* Score badge */}
141
+ <div className="flex items-center gap-4">
142
+ <div
143
+ className={cn(
144
+ "border-2 rounded-lg px-4 py-3 flex flex-col items-center",
145
+ scoreBorder(latest.overall_score),
146
+ scoreBg(latest.overall_score)
147
+ )}
148
+ >
149
+ <span className={cn("text-3xl font-bold font-mono", scoreColor(latest.overall_score))}>
150
+ {latest.overall_score.toFixed(1)}
151
+ </span>
152
+ <span className="text-[9px] text-text-dim uppercase tracking-wider mt-0.5">
153
+ / 10
154
+ </span>
155
+ </div>
156
+
157
+ {/* Category scores */}
158
+ <div className="flex-1 grid grid-cols-2 gap-x-4 gap-y-1">
159
+ {Object.entries(latest.score_breakdown).map(([key, value]) => (
160
+ <div key={key} className="flex items-center gap-2">
161
+ <span className="text-[10px] text-text-dim uppercase w-16 truncate">{key}</span>
162
+ <div className="flex-1 h-1.5 bg-border rounded-full overflow-hidden">
163
+ <div
164
+ className="h-full rounded-full transition-all"
165
+ style={{
166
+ width: `${(value / 10) * 100}%`,
167
+ backgroundColor: scoreHex(value),
168
+ }}
169
+ />
170
+ </div>
171
+ <span className={cn("text-[10px] font-bold font-mono w-6 text-right", scoreColor(value))}>
172
+ {value}
173
+ </span>
174
+ </div>
175
+ ))}
176
+ </div>
177
+ </div>
178
+
179
+ {/* Radar chart if there are 3+ categories */}
180
+ {radarData.length >= 3 && (
181
+ <ChartContainer height={192}>
182
+ {(width) => (
183
+ <RadarChart data={radarData} cx="50%" cy="50%" outerRadius="70%" width={width} height={192}>
184
+ <PolarGrid stroke="#333" />
185
+ <PolarAngleAxis
186
+ dataKey="category"
187
+ tick={{ fill: "#666", fontSize: 10, fontFamily: "monospace" }}
188
+ />
189
+ <Radar
190
+ dataKey="score"
191
+ stroke={scoreHex(latest.overall_score)}
192
+ fill={scoreHex(latest.overall_score)}
193
+ fillOpacity={0.15}
194
+ strokeWidth={2}
195
+ isAnimationActive={false}
196
+ />
197
+ </RadarChart>
198
+ )}
199
+ </ChartContainer>
200
+ )}
201
+
202
+ {/* Progression line chart if multiple quality events */}
203
+ {hasProgression && (
204
+ <div>
205
+ <div className="text-[9px] text-text-dim uppercase tracking-wider mb-1">
206
+ SCORE PROGRESSION
207
+ </div>
208
+ <ChartContainer height={144}>
209
+ {(width) => (
210
+ <LineChart data={progressionData} width={width} height={144}>
211
+ <CartesianGrid strokeDasharray="3 3" stroke="#222" />
212
+ <XAxis
213
+ dataKey="label"
214
+ tick={{ fill: "#666", fontSize: 10, fontFamily: "monospace" }}
215
+ stroke="#333"
216
+ />
217
+ <YAxis
218
+ domain={[0, 10]}
219
+ tick={{ fill: "#666", fontSize: 10, fontFamily: "monospace" }}
220
+ stroke="#333"
221
+ width={25}
222
+ />
223
+ <Tooltip
224
+ contentStyle={{
225
+ background: "#111",
226
+ border: "1px solid #333",
227
+ borderRadius: 4,
228
+ fontSize: 11,
229
+ fontFamily: "monospace",
230
+ }}
231
+ itemStyle={{ color: "#c8c8c8" }}
232
+ />
233
+ <Line
234
+ type="monotone"
235
+ dataKey="score"
236
+ stroke="#00ff88"
237
+ strokeWidth={2}
238
+ dot={{ fill: "#00ff88", r: 4 }}
239
+ name="overall"
240
+ isAnimationActive={false}
241
+ />
242
+ {Object.keys(latest.score_breakdown).map((key, i) => {
243
+ const colors = ["#4488ff", "#ffaa00", "#aa66ff", "#ff4444", "#facc15", "#00ccff"]
244
+ return (
245
+ <Line
246
+ key={key}
247
+ type="monotone"
248
+ dataKey={key}
249
+ stroke={colors[i % colors.length]}
250
+ strokeWidth={1}
251
+ strokeDasharray="3 3"
252
+ dot={false}
253
+ name={key}
254
+ isAnimationActive={false}
255
+ />
256
+ )
257
+ })}
258
+ </LineChart>
259
+ )}
260
+ </ChartContainer>
261
+ </div>
262
+ )}
263
+
264
+ {/* Version + report link */}
265
+ <div className="flex items-center gap-3 text-[10px] text-text-dim">
266
+ {latest.version && <span>version: <span className="text-text">{latest.version}</span></span>}
267
+ {latest.report_path && (
268
+ <span>report: <span className="text-text-muted font-mono">{latest.report_path.split("/").pop()}</span></span>
269
+ )}
270
+ {hasProgression && (
271
+ <span>{qualityEvents.length} measurements</span>
272
+ )}
273
+ </div>
274
+ </div>
275
+ )
276
+ }
@@ -0,0 +1,117 @@
1
+ import { useState, useRef, useCallback, useEffect } from "react"
2
+ import gsap from "gsap"
3
+ import { cn } from "@/lib/utils"
4
+
5
+ interface Props {
6
+ sessionId: string
7
+ onUploaded: () => void
8
+ }
9
+
10
+ export function ScreenshotUpload({ sessionId, onUploaded }: Props) {
11
+ const [dragging, setDragging] = useState(false)
12
+ const [uploading, setUploading] = useState(false)
13
+ const [description, setDescription] = useState("")
14
+ const fileRef = useRef<HTMLInputElement>(null)
15
+
16
+ const handleUpload = useCallback(
17
+ async (file: File) => {
18
+ setUploading(true)
19
+ const formData = new FormData()
20
+ formData.append("file", file)
21
+ formData.append("description", description || file.name)
22
+ formData.append("phase", "verification")
23
+
24
+ try {
25
+ const res = await fetch(`/api/sessions/${sessionId}/screenshots`, {
26
+ method: "POST",
27
+ body: formData,
28
+ })
29
+ if (res.ok) {
30
+ setDescription("")
31
+ onUploaded()
32
+ }
33
+ } catch {
34
+ // ignore
35
+ }
36
+ setUploading(false)
37
+ },
38
+ [sessionId, description, onUploaded]
39
+ )
40
+
41
+ const handleDrop = useCallback(
42
+ (e: React.DragEvent) => {
43
+ e.preventDefault()
44
+ setDragging(false)
45
+ const file = e.dataTransfer.files[0]
46
+ if (file && file.type.startsWith("image/")) {
47
+ handleUpload(file)
48
+ }
49
+ },
50
+ [handleUpload]
51
+ )
52
+
53
+ const handleFileSelect = useCallback(
54
+ (e: React.ChangeEvent<HTMLInputElement>) => {
55
+ const file = e.target.files?.[0]
56
+ if (file) {
57
+ handleUpload(file)
58
+ }
59
+ e.target.value = ""
60
+ },
61
+ [handleUpload]
62
+ )
63
+
64
+ const uploadRef = useRef<HTMLDivElement>(null)
65
+
66
+ useEffect(() => {
67
+ if (!uploadRef.current) return
68
+ gsap.fromTo(uploadRef.current, { opacity: 0, y: 6 }, { opacity: 1, y: 0, duration: 0.3, ease: "power2.out" })
69
+ }, [])
70
+
71
+ return (
72
+ <div ref={uploadRef} className="mt-3">
73
+ <div className="flex gap-2 mb-2">
74
+ <input
75
+ type="text"
76
+ value={description}
77
+ onChange={(e) => setDescription(e.target.value)}
78
+ placeholder="description (optional)"
79
+ className="flex-1 bg-bg border border-border rounded px-2 py-1 text-[11px] text-text font-mono placeholder:text-text-dim focus:outline-none focus:border-accent/50"
80
+ />
81
+ </div>
82
+ <div
83
+ onDragOver={(e) => {
84
+ e.preventDefault()
85
+ setDragging(true)
86
+ }}
87
+ onDragLeave={() => setDragging(false)}
88
+ onDrop={handleDrop}
89
+ onClick={() => fileRef.current?.click()}
90
+ className={cn(
91
+ "border border-dashed rounded p-4 text-center cursor-pointer transition-colors duration-200",
92
+ dragging
93
+ ? "border-accent bg-accent/5 text-accent"
94
+ : "border-border-bright text-text-dim hover:border-accent/40 hover:text-text-muted"
95
+ )}
96
+ >
97
+ <input
98
+ ref={fileRef}
99
+ type="file"
100
+ accept="image/*"
101
+ onChange={handleFileSelect}
102
+ className="hidden"
103
+ />
104
+ <div className="text-[10px] font-mono">
105
+ {uploading ? (
106
+ <span className="text-accent animate-pulse">uploading...</span>
107
+ ) : (
108
+ <>
109
+ <span className="text-accent mr-1">+</span>
110
+ drop image or click to upload
111
+ </>
112
+ )}
113
+ </div>
114
+ </div>
115
+ </div>
116
+ )
117
+ }
@@ -0,0 +1,266 @@
1
+ import { useState, useRef, useEffect, useCallback, useMemo } from "react"
2
+ import { createPortal } from "react-dom"
3
+ import gsap from "gsap"
4
+ import type { Screenshot } from "@/types"
5
+ import { screenshotUrl, cn } from "@/lib/utils"
6
+
7
+ interface Props {
8
+ screenshots: Screenshot[]
9
+ }
10
+
11
+ interface ScreenshotGroup {
12
+ name: string
13
+ screenshots: Screenshot[]
14
+ indices: number[] // indices into the flat screenshots array
15
+ }
16
+
17
+ // Extract template/group name from file path or description
18
+ function extractGroup(sc: Screenshot): string {
19
+ const fp = sc.file_path.toLowerCase()
20
+ // Check for template folder pattern: .../generated/trinity/... or .../reference/trinity/...
21
+ const match = fp.match(/\/(generated|reference)\/([^/]+)\//)
22
+ if (match) return match[2]
23
+ // Check description for template names
24
+ const descMatch = sc.description.match(/template[:\s]+(\w+)/i)
25
+ if (descMatch) return descMatch[1].toLowerCase()
26
+ return "_default"
27
+ }
28
+
29
+ // Extract score from description (e.g., "8.7/10", "score: 8", "Score 9.2")
30
+ function extractScore(description: string): number | null {
31
+ const patterns = [
32
+ /(\d+\.?\d*)\/10/,
33
+ /score[:\s]+(\d+\.?\d*)/i,
34
+ /(\d+\.?\d*)\s*pts?/i,
35
+ ]
36
+ for (const p of patterns) {
37
+ const m = description.match(p)
38
+ if (m) return parseFloat(m[1])
39
+ }
40
+ return null
41
+ }
42
+
43
+ function scoreColor(score: number): string {
44
+ if (score >= 9) return "text-yellow-400"
45
+ if (score >= 8) return "text-accent"
46
+ if (score >= 7) return "text-warning"
47
+ return "text-error"
48
+ }
49
+
50
+ export function ScreenshotsGrid({ screenshots }: Props) {
51
+ const [activeIndex, setActiveIndex] = useState<number | null>(null)
52
+ const ref = useRef<HTMLDivElement>(null)
53
+ const prevCountRef = useRef(0)
54
+
55
+ const thumbUrls = screenshots.map((sc) => screenshotUrl(sc.file_path, sc.id, "thumb"))
56
+ const fullUrls = screenshots.map((sc) => screenshotUrl(sc.file_path, sc.id))
57
+
58
+ // Group screenshots by template
59
+ const groups = useMemo((): ScreenshotGroup[] => {
60
+ const map = new Map<string, { screenshots: Screenshot[]; indices: number[] }>()
61
+ screenshots.forEach((sc, i) => {
62
+ const group = extractGroup(sc)
63
+ const existing = map.get(group) || { screenshots: [], indices: [] }
64
+ existing.screenshots.push(sc)
65
+ existing.indices.push(i)
66
+ map.set(group, existing)
67
+ })
68
+ return Array.from(map.entries()).map(([name, data]) => ({
69
+ name,
70
+ screenshots: data.screenshots,
71
+ indices: data.indices,
72
+ }))
73
+ }, [screenshots])
74
+
75
+ const hasMultipleGroups = groups.length > 1 || (groups.length === 1 && groups[0].name !== "_default")
76
+
77
+ const goNext = useCallback(() => {
78
+ if (activeIndex === null) return
79
+ setActiveIndex((activeIndex + 1) % screenshots.length)
80
+ }, [activeIndex, screenshots.length])
81
+
82
+ const goPrev = useCallback(() => {
83
+ if (activeIndex === null) return
84
+ setActiveIndex((activeIndex - 1 + screenshots.length) % screenshots.length)
85
+ }, [activeIndex, screenshots.length])
86
+
87
+ const close = useCallback(() => setActiveIndex(null), [])
88
+
89
+ useEffect(() => {
90
+ if (activeIndex === null) return
91
+ const handler = (e: KeyboardEvent) => {
92
+ if (e.key === "ArrowRight") goNext()
93
+ else if (e.key === "ArrowLeft") goPrev()
94
+ else if (e.key === "Escape") close()
95
+ }
96
+ window.addEventListener("keydown", handler)
97
+ return () => window.removeEventListener("keydown", handler)
98
+ }, [activeIndex, goNext, goPrev, close])
99
+
100
+ useEffect(() => {
101
+ if (!ref.current) return
102
+ if (screenshots.length === prevCountRef.current) return
103
+ const isInitial = prevCountRef.current === 0
104
+ prevCountRef.current = screenshots.length
105
+ if (isInitial) {
106
+ const cards = ref.current.querySelectorAll(".ss-card")
107
+ gsap.fromTo(
108
+ cards,
109
+ { opacity: 0, scale: 0.95 },
110
+ { opacity: 1, scale: 1, duration: 0.3, stagger: 0.06, ease: "power2.out" }
111
+ )
112
+ }
113
+ }, [screenshots])
114
+
115
+ const activeSc = activeIndex !== null ? screenshots[activeIndex] : null
116
+ const activeUrl = activeIndex !== null ? fullUrls[activeIndex] : null
117
+
118
+ const renderCard = (sc: Screenshot, flatIndex: number) => {
119
+ const score = extractScore(sc.description)
120
+ return (
121
+ <div
122
+ key={sc.id}
123
+ className="ss-card border border-border rounded overflow-hidden cursor-pointer hover:border-accent transition-colors duration-200 group"
124
+ onClick={() => setActiveIndex(flatIndex)}
125
+ >
126
+ <div className="relative">
127
+ <img
128
+ src={thumbUrls[flatIndex]}
129
+ alt={sc.description}
130
+ loading="lazy"
131
+ className="w-full h-32 object-cover block opacity-80 group-hover:opacity-100 transition-opacity"
132
+ onError={(e) => {
133
+ ;(e.target as HTMLImageElement).style.display = "none"
134
+ }}
135
+ />
136
+ {score !== null && (
137
+ <span
138
+ className={cn(
139
+ "absolute top-1 right-1 text-[10px] font-bold font-mono px-1.5 py-0.5 rounded bg-bg/80 border border-border",
140
+ scoreColor(score)
141
+ )}
142
+ >
143
+ {score.toFixed(1)}
144
+ </span>
145
+ )}
146
+ <span className="absolute bottom-1 left-1 text-[9px] px-1 py-0.5 rounded bg-bg/80 text-text-muted">
147
+ {sc.phase}
148
+ </span>
149
+ </div>
150
+ <div className="p-2 text-[10px] text-text-muted truncate">
151
+ <span className="text-accent mr-1">{"\u25B8"}</span>
152
+ {sc.description}
153
+ </div>
154
+ </div>
155
+ )
156
+ }
157
+
158
+ return (
159
+ <>
160
+ <div ref={ref}>
161
+ {hasMultipleGroups ? (
162
+ // Grouped view
163
+ groups.map((group) => (
164
+ <div key={group.name} className="mb-4">
165
+ <div className="text-[10px] text-text-muted uppercase tracking-wider mb-2 flex items-center gap-2">
166
+ <span className="text-accent">{"\u25B8"}</span>
167
+ {group.name === "_default" ? "screenshots" : group.name}
168
+ <span className="text-text-dim">({group.screenshots.length})</span>
169
+ </div>
170
+ <div className="grid grid-cols-[repeat(auto-fill,minmax(180px,1fr))] gap-3">
171
+ {group.screenshots.map((sc, gi) => renderCard(sc, group.indices[gi]))}
172
+ </div>
173
+ </div>
174
+ ))
175
+ ) : (
176
+ // Flat view
177
+ <div className="mt-2 grid grid-cols-[repeat(auto-fill,minmax(180px,1fr))] gap-3">
178
+ {screenshots.map((sc, i) => renderCard(sc, i))}
179
+ </div>
180
+ )}
181
+ </div>
182
+
183
+ {/* Modal */}
184
+ {activeIndex !== null && activeUrl && activeSc && createPortal(
185
+ <div
186
+ className="fixed inset-0 bg-black/80 z-[200] flex items-center justify-center"
187
+ onClick={close}
188
+ >
189
+ <div
190
+ className="bg-bg-elevated border border-border rounded-lg shadow-2xl w-[85vw] h-[85vh] flex flex-col overflow-hidden"
191
+ onClick={(e) => e.stopPropagation()}
192
+ >
193
+ {/* Header */}
194
+ <div className="flex items-center justify-between px-4 py-2 border-b border-border flex-shrink-0">
195
+ <div className="flex items-center gap-3">
196
+ <span className="text-[10px] text-text-muted font-mono">
197
+ {activeSc.description}
198
+ </span>
199
+ {(() => {
200
+ const score = extractScore(activeSc.description)
201
+ return score !== null ? (
202
+ <span className={cn("text-[11px] font-bold font-mono", scoreColor(score))}>
203
+ {score.toFixed(1)}/10
204
+ </span>
205
+ ) : null
206
+ })()}
207
+ </div>
208
+ <div className="flex items-center gap-3">
209
+ <span className="text-[10px] text-text-dim font-mono">
210
+ {activeIndex + 1}/{screenshots.length}
211
+ </span>
212
+ <span className="text-[9px] text-text-dim">
213
+ {"\u2190"} {"\u2192"} navigate
214
+ </span>
215
+ <button onClick={close} className="text-text-dim hover:text-text text-sm">&times;</button>
216
+ </div>
217
+ </div>
218
+
219
+ {/* Main image */}
220
+ <div className="flex-1 flex items-center justify-center p-6 overflow-hidden relative">
221
+ <button
222
+ onClick={goPrev}
223
+ className="absolute left-3 top-1/2 -translate-y-1/2 w-8 h-8 flex items-center justify-center rounded bg-bg/80 border border-border text-text-dim hover:text-accent hover:border-accent transition-colors"
224
+ >
225
+ {"\u2190"}
226
+ </button>
227
+ <img
228
+ src={activeUrl}
229
+ alt={activeSc.description}
230
+ className="max-w-full max-h-full object-contain rounded"
231
+ />
232
+ <button
233
+ onClick={goNext}
234
+ className="absolute right-3 top-1/2 -translate-y-1/2 w-8 h-8 flex items-center justify-center rounded bg-bg/80 border border-border text-text-dim hover:text-accent hover:border-accent transition-colors"
235
+ >
236
+ {"\u2192"}
237
+ </button>
238
+ </div>
239
+
240
+ {/* Gallery strip */}
241
+ <div className="border-t border-border px-4 py-2 flex gap-2 overflow-x-auto flex-shrink-0">
242
+ {screenshots.map((sc, i) => (
243
+ <img
244
+ key={sc.id}
245
+ src={thumbUrls[i]}
246
+ alt={sc.description}
247
+ onClick={() => setActiveIndex(i)}
248
+ className={cn(
249
+ "h-14 w-20 object-cover rounded cursor-pointer flex-shrink-0 border-2 transition-all",
250
+ i === activeIndex
251
+ ? "border-accent opacity-100"
252
+ : "border-transparent opacity-50 hover:opacity-80"
253
+ )}
254
+ onError={(e) => {
255
+ ;(e.target as HTMLImageElement).style.display = "none"
256
+ }}
257
+ />
258
+ ))}
259
+ </div>
260
+ </div>
261
+ </div>,
262
+ document.body
263
+ )}
264
+ </>
265
+ )
266
+ }