@principal-ai/file-city-react 0.5.43 → 0.5.45

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.
@@ -0,0 +1,1060 @@
1
+ import { useCallback, useLayoutEffect, useMemo, useRef, useState } from 'react';
2
+ import type { Meta, StoryObj } from '@storybook/react';
3
+ import * as THREE from 'three';
4
+
5
+ import { FileCity3D } from '../components/FileCity3D';
6
+ import type {
7
+ CityData,
8
+ HighlightLayer,
9
+ OnCameraFrame,
10
+ } from '../components/FileCity3D';
11
+ import { createFileColorHighlightLayers } from '../utils/fileColorHighlightLayers';
12
+ import authServerCityData from '../../../../assets/auth-server-city-data.json';
13
+
14
+ const meta = {
15
+ title: 'Prototypes/Leader Line Snippet Overlay (3D Flat)',
16
+ parameters: { layout: 'fullscreen' },
17
+ } satisfies Meta;
18
+
19
+ export default meta;
20
+
21
+ const PANEL_WIDTH = 380;
22
+ const PANEL_INSET = 16;
23
+ const FADE_PX = 60;
24
+ const DWELL_MS = 400;
25
+ const APPROVED_COLOR = '#22c55e';
26
+
27
+ type GitStatus = 'added' | 'modified' | 'deleted';
28
+
29
+ const STATUS_COLOR: Record<GitStatus, string> = {
30
+ added: '#3fb950',
31
+ modified: '#d29922',
32
+ deleted: '#f85149',
33
+ };
34
+
35
+ const STATUS_LABEL: Record<GitStatus, string> = {
36
+ added: '+ added',
37
+ modified: '~ modified',
38
+ deleted: '− deleted',
39
+ };
40
+
41
+ interface Snippet {
42
+ path: string;
43
+ label: string;
44
+ status: GitStatus;
45
+ code: string;
46
+ }
47
+
48
+ const SNIPPETS: Snippet[] = [
49
+ {
50
+ path: 'auth-server/src/lib/auth-provider.ts',
51
+ label: 'auth-provider.ts',
52
+ status: 'modified',
53
+ code: `export const authProvider = {
54
+ async getSession(req: Request) {
55
+ const cookie = req.headers.get('cookie');
56
+ return parseSessionCookie(cookie);
57
+ },
58
+ };`,
59
+ },
60
+ {
61
+ path: 'auth-server/src/lib/org-membership.ts',
62
+ label: 'org-membership.ts',
63
+ status: 'added',
64
+ code: `export async function getOrgMembership(userId: string) {
65
+ return db.query.memberships.findFirst({
66
+ where: eq(memberships.userId, userId),
67
+ });
68
+ }`,
69
+ },
70
+ {
71
+ path: 'auth-server/src/app/api/auth/workos/callback/route.ts',
72
+ label: 'callback / route.ts',
73
+ status: 'modified',
74
+ code: `export async function GET(req: Request) {
75
+ const code = new URL(req.url).searchParams.get('code');
76
+ if (!code) return NextResponse.redirect('/login');
77
+ const session = await workos.userManagement
78
+ .authenticateWithCode({ clientId: env.WORKOS_CLIENT_ID, code });
79
+ return setSessionCookie(session);
80
+ }`,
81
+ },
82
+ {
83
+ path: 'auth-server/src/app/api/auth/workos/refresh/route.ts',
84
+ label: 'refresh / route.ts',
85
+ status: 'added',
86
+ code: `export async function POST(req: Request) {
87
+ const { refreshToken } = await req.json();
88
+ const next = await workos.userManagement
89
+ .authenticateWithRefreshToken({ refreshToken });
90
+ return Response.json(next);
91
+ }`,
92
+ },
93
+ {
94
+ path: 'auth-server/src/app/api/auth/cli/room-token/route.ts',
95
+ label: 'cli / room-token',
96
+ status: 'modified',
97
+ code: `export async function POST(req: Request) {
98
+ const session = await authProvider.getSession(req);
99
+ if (!session) return new Response('unauthorized', { status: 401 });
100
+ return Response.json({ token: mintRoomToken(session.userId) });
101
+ }`,
102
+ },
103
+ {
104
+ path: 'auth-server/src/app/api/auth/workos/verify/route.ts',
105
+ label: 'verify / route.ts',
106
+ status: 'modified',
107
+ code: `export async function GET(req: Request) {
108
+ const token = req.headers.get('authorization')?.slice(7);
109
+ const claims = await verifyJwt(token);
110
+ return Response.json({ ok: true, claims });
111
+ }`,
112
+ },
113
+ {
114
+ path: 'auth-server/src/lib/token-store.ts',
115
+ label: 'token-store.ts',
116
+ status: 'added',
117
+ code: `export const tokenStore = {
118
+ async put(key: string, value: string, ttlSec: number) {
119
+ await redis.set(key, value, 'EX', ttlSec);
120
+ },
121
+ async get(key: string) {
122
+ return redis.get(key);
123
+ },
124
+ };`,
125
+ },
126
+ {
127
+ path: 'auth-server/src/app/api/auth/workos/start/route.ts',
128
+ label: 'start / route.ts',
129
+ status: 'modified',
130
+ code: `export function GET(req: Request) {
131
+ const url = workos.userManagement.getAuthorizationUrl({
132
+ provider: 'authkit',
133
+ redirectUri: env.WORKOS_REDIRECT_URI,
134
+ });
135
+ return NextResponse.redirect(url);
136
+ }`,
137
+ },
138
+ {
139
+ path: 'auth-server/src/app/api/auth/workos/token/route.ts',
140
+ label: 'token / route.ts',
141
+ status: 'deleted',
142
+ code: `export async function POST(req: Request) {
143
+ const { code, codeVerifier } = await req.json();
144
+ const result = await workos.userManagement
145
+ .authenticateWithCode({ code, codeVerifier });
146
+ return Response.json(result);
147
+ }`,
148
+ },
149
+ {
150
+ path: 'auth-server/src/app/page.tsx',
151
+ label: 'page.tsx',
152
+ status: 'modified',
153
+ code: `export default async function Page() {
154
+ const session = await authProvider.getSession(headers());
155
+ if (!session) redirect('/login');
156
+ return <Dashboard session={session} />;
157
+ }`,
158
+ },
159
+ ];
160
+
161
+ interface CardLayout {
162
+ path: string;
163
+ // World position of the building's center, captured once at mount.
164
+ world: { x: number; y: number; z: number };
165
+ // World-space footprint corners (4 corners on the y=0 plane), used to draw
166
+ // a screen-space outline polygon that pans/zooms/rotates with the city.
167
+ corners: { x: number; y: number; z: number }[];
168
+ // Card position in stage-local pixels, updated on scroll/resize.
169
+ card: { x: number; y: number; w: number; h: number };
170
+ }
171
+
172
+ interface ViewportInfo {
173
+ top: number;
174
+ bottom: number;
175
+ left: number;
176
+ }
177
+
178
+ export const FloatingOverlay: StoryObj = {
179
+ render: function RenderFloatingOverlay() {
180
+ const cityData = authServerCityData as CityData;
181
+ const cityCenter = useMemo(
182
+ () => ({
183
+ x: (cityData.bounds.minX + cityData.bounds.maxX) / 2,
184
+ z: (cityData.bounds.minZ + cityData.bounds.maxZ) / 2,
185
+ }),
186
+ [cityData.bounds],
187
+ );
188
+
189
+ const fileColorLayers = useMemo(
190
+ () => createFileColorHighlightLayers(cityData.buildings),
191
+ [cityData.buildings],
192
+ );
193
+
194
+ const resolvedSnippets = useMemo(
195
+ () =>
196
+ SNIPPETS.flatMap(s => {
197
+ const building = cityData.buildings.find(b => b.path === s.path);
198
+ return building ? [{ snippet: s, building }] : [];
199
+ }),
200
+ [cityData.buildings],
201
+ );
202
+
203
+ const stageRef = useRef<HTMLDivElement | null>(null);
204
+ const canvasWrapRef = useRef<HTMLDivElement | null>(null);
205
+ const panelRef = useRef<HTMLDivElement | null>(null);
206
+ const scrollContainerRef = useRef<HTMLDivElement | null>(null);
207
+ const cardRefs = useRef<Map<string, HTMLDivElement | null>>(new Map());
208
+ const svgRef = useRef<SVGSVGElement | null>(null);
209
+ const pathRefs = useRef<Map<string, SVGPathElement | null>>(new Map());
210
+ const anchorMarkerRefs = useRef<Map<string, SVGRectElement | null>>(new Map());
211
+ const cardDotRefs = useRef<Map<string, SVGCircleElement | null>>(new Map());
212
+
213
+ const [seenPaths, setSeenPaths] = useState<Set<string>>(new Set());
214
+ const [manualApproved, setManualApproved] = useState<Set<string>>(new Set());
215
+ // Auto-approved is derived from scroll position (`cardBottom < vpMid`),
216
+ // which lives in refs and changes without React knowing. We promote it
217
+ // into state from inside `measure()` so highlightLayers / card UI
218
+ // re-render when a card crosses the midline.
219
+ const [autoApproved, setAutoApproved] = useState<Set<string>>(new Set());
220
+ const [hasStarted, setHasStarted] = useState(false);
221
+ const seenTimersRef = useRef<Map<string, number>>(new Map());
222
+ const seenPathsRef = useRef(seenPaths);
223
+ seenPathsRef.current = seenPaths;
224
+ const manualApprovedRef = useRef(manualApproved);
225
+ manualApprovedRef.current = manualApproved;
226
+ const autoApprovedRef = useRef(autoApproved);
227
+ autoApprovedRef.current = autoApproved;
228
+
229
+ // ------------------------------------------------------------------------
230
+ // Card layout: stage-local card rectangles + the building's world position.
231
+ // Updated on scroll and resize; NOT updated on every camera frame.
232
+ // ------------------------------------------------------------------------
233
+ const cardLayoutsRef = useRef<Map<string, CardLayout>>(new Map());
234
+ const viewportRef = useRef<ViewportInfo | null>(null);
235
+ const stageOriginRef = useRef<{ left: number; top: number; w: number; h: number }>({
236
+ left: 0,
237
+ top: 0,
238
+ w: 0,
239
+ h: 0,
240
+ });
241
+ const canvasRectRef = useRef<{ left: number; top: number; w: number; h: number }>({
242
+ left: 0,
243
+ top: 0,
244
+ w: 0,
245
+ h: 0,
246
+ });
247
+
248
+ useLayoutEffect(() => {
249
+ const stage = stageRef.current;
250
+ const canvasWrap = canvasWrapRef.current;
251
+ if (!stage || !canvasWrap) return;
252
+
253
+ const measure = () => {
254
+ const stageRect = stage.getBoundingClientRect();
255
+ const canvasRect = canvasWrap.getBoundingClientRect();
256
+ const scrollRect = scrollContainerRef.current?.getBoundingClientRect();
257
+ const panelRect = panelRef.current?.getBoundingClientRect();
258
+
259
+ stageOriginRef.current = {
260
+ left: stageRect.left,
261
+ top: stageRect.top,
262
+ w: stageRect.width,
263
+ h: stageRect.height,
264
+ };
265
+ canvasRectRef.current = {
266
+ left: canvasRect.left - stageRect.left,
267
+ top: canvasRect.top - stageRect.top,
268
+ w: canvasRect.width,
269
+ h: canvasRect.height,
270
+ };
271
+
272
+ viewportRef.current = scrollRect
273
+ ? {
274
+ top: scrollRect.top - stageRect.top,
275
+ bottom: scrollRect.bottom - stageRect.top,
276
+ left: panelRect
277
+ ? panelRect.left - stageRect.left
278
+ : scrollRect.left - stageRect.left,
279
+ }
280
+ : null;
281
+
282
+ const next = new Map<string, CardLayout>();
283
+ let anyVisible = false;
284
+ for (const { snippet, building } of resolvedSnippets) {
285
+ const card = cardRefs.current.get(snippet.path);
286
+ if (!card) continue;
287
+ const cardRect = card.getBoundingClientRect();
288
+ const cardLocal = {
289
+ x: cardRect.left - stageRect.left,
290
+ y: cardRect.top - stageRect.top,
291
+ w: cardRect.width,
292
+ h: cardRect.height,
293
+ };
294
+ const wx = building.position.x - cityCenter.x;
295
+ const wz = building.position.z - cityCenter.z;
296
+ const halfW = building.dimensions[0] / 2;
297
+ const halfD = building.dimensions[2] / 2;
298
+ // Footprint corners on the y=0 plane, ordered for a closed polygon
299
+ // (TL, TR, BR, BL).
300
+ const corners = [
301
+ { x: wx - halfW, y: 0, z: wz - halfD },
302
+ { x: wx + halfW, y: 0, z: wz - halfD },
303
+ { x: wx + halfW, y: 0, z: wz + halfD },
304
+ { x: wx - halfW, y: 0, z: wz + halfD },
305
+ ];
306
+ next.set(snippet.path, {
307
+ path: snippet.path,
308
+ world: { x: wx, y: 0, z: wz },
309
+ corners,
310
+ card: cardLocal,
311
+ });
312
+ if (
313
+ viewportRef.current &&
314
+ cardLocal.y < viewportRef.current.bottom &&
315
+ cardLocal.y + cardLocal.h > viewportRef.current.top
316
+ ) {
317
+ anyVisible = true;
318
+ }
319
+ }
320
+ cardLayoutsRef.current = next;
321
+
322
+ if (
323
+ viewportRef.current &&
324
+ !hasStarted &&
325
+ [...next.values()].some(c => c.card.y < viewportRef.current!.bottom)
326
+ ) {
327
+ setHasStarted(true);
328
+ }
329
+
330
+ // Dwell tracking — same logic as the v1 prototype but driven by the
331
+ // refs we just updated.
332
+ if (viewportRef.current) {
333
+ const vp = viewportRef.current;
334
+ const vpMid = (vp.top + vp.bottom) / 2;
335
+ const visibleNow = new Set<string>();
336
+ for (const c of next.values()) {
337
+ const cardTop = c.card.y;
338
+ const cardBottom = c.card.y + c.card.h;
339
+ if (cardBottom > vp.top && cardTop < vp.bottom) {
340
+ visibleNow.add(c.path);
341
+ }
342
+ }
343
+ for (const p of visibleNow) {
344
+ if (seenPathsRef.current.has(p)) continue;
345
+ if (seenTimersRef.current.has(p)) continue;
346
+ const id = window.setTimeout(() => {
347
+ seenTimersRef.current.delete(p);
348
+ setSeenPaths(prev => {
349
+ if (prev.has(p)) return prev;
350
+ const nextSeen = new Set(prev);
351
+ nextSeen.add(p);
352
+ return nextSeen;
353
+ });
354
+ }, DWELL_MS);
355
+ seenTimersRef.current.set(p, id);
356
+ }
357
+ for (const [p, id] of seenTimersRef.current) {
358
+ if (!visibleNow.has(p)) {
359
+ clearTimeout(id);
360
+ seenTimersRef.current.delete(p);
361
+ }
362
+ }
363
+
364
+ // Promote auto-approval into React state so highlightLayers /
365
+ // card UI track scroll position. Only setState when the set
366
+ // actually changes (cheap superset comparison).
367
+ const wantAuto = new Set<string>();
368
+ for (const c of next.values()) {
369
+ if (
370
+ seenPathsRef.current.has(c.path) &&
371
+ c.card.y + c.card.h < vpMid
372
+ ) {
373
+ wantAuto.add(c.path);
374
+ }
375
+ }
376
+ const cur = autoApprovedRef.current;
377
+ let same = wantAuto.size === cur.size;
378
+ if (same) {
379
+ for (const p of wantAuto) {
380
+ if (!cur.has(p)) {
381
+ same = false;
382
+ break;
383
+ }
384
+ }
385
+ }
386
+ if (!same) setAutoApproved(wantAuto);
387
+ }
388
+
389
+ void anyVisible;
390
+ };
391
+
392
+ measure();
393
+ const ro = new ResizeObserver(measure);
394
+ ro.observe(stage);
395
+ ro.observe(canvasWrap);
396
+ if (panelRef.current) ro.observe(panelRef.current);
397
+ cardRefs.current.forEach(el => el && ro.observe(el));
398
+ window.addEventListener('resize', measure);
399
+ const scrollEl = scrollContainerRef.current;
400
+ scrollEl?.addEventListener('scroll', measure, { passive: true });
401
+ return () => {
402
+ ro.disconnect();
403
+ window.removeEventListener('resize', measure);
404
+ scrollEl?.removeEventListener('scroll', measure);
405
+ for (const id of seenTimersRef.current.values()) clearTimeout(id);
406
+ seenTimersRef.current.clear();
407
+ };
408
+ }, [cityCenter, resolvedSnippets, hasStarted]);
409
+
410
+ // ------------------------------------------------------------------------
411
+ // Approval state — derived from refs/state. Used both to drive React
412
+ // rendering of the cards AND to color the per-frame SVG paths/markers.
413
+ // ------------------------------------------------------------------------
414
+ const approvalFor = useCallback(
415
+ (path: string): 'manual' | 'auto' | 'pending' => {
416
+ if (manualApprovedRef.current.has(path)) return 'manual';
417
+ if (autoApprovedRef.current.has(path)) return 'auto';
418
+ return 'pending';
419
+ },
420
+ [],
421
+ );
422
+
423
+ // The set of currently-approved paths. Both inputs are React state that
424
+ // get committed by `measure()` and the manual button, so this useMemo
425
+ // stays in sync with the UI.
426
+ const approvalSet = useMemo(() => {
427
+ const set = new Set<string>(manualApproved);
428
+ for (const p of autoApproved) set.add(p);
429
+ return set;
430
+ }, [manualApproved, autoApproved]);
431
+
432
+ const allApproved =
433
+ resolvedSnippets.length > 0 &&
434
+ resolvedSnippets.every(({ snippet }) => approvalSet.has(snippet.path));
435
+ const showStartPrompt = !hasStarted && !allApproved;
436
+
437
+ // ------------------------------------------------------------------------
438
+ // 3D building borders via highlightLayers — these live in the city scene
439
+ // and pan/zoom/rotate with the camera automatically.
440
+ // ------------------------------------------------------------------------
441
+ const highlightLayers = useMemo<HighlightLayer[]>(() => {
442
+ // One border layer per status, plus an "approved" override at higher
443
+ // priority so approved buildings display the green ring.
444
+ const byStatus: Record<GitStatus, string[]> = {
445
+ added: [],
446
+ modified: [],
447
+ deleted: [],
448
+ };
449
+ const approvedPaths: string[] = [];
450
+ for (const { snippet } of resolvedSnippets) {
451
+ if (approvalSet.has(snippet.path)) approvedPaths.push(snippet.path);
452
+ else byStatus[snippet.status].push(snippet.path);
453
+ }
454
+ const layers: HighlightLayer[] = [];
455
+ // BorderHighlights renders thickness = max(0.2, borderWidth * 0.1)
456
+ // in WORLD units, not screen pixels. The auth-server city is ~1200
457
+ // world units across, so anything under ~10 is sub-visible from the
458
+ // flat camera. 30 → 3 world units → readable line on each building.
459
+ (Object.keys(byStatus) as GitStatus[]).forEach((status, i) => {
460
+ if (byStatus[status].length === 0) return;
461
+ layers.push({
462
+ id: `pr-${status}`,
463
+ name: `PR — ${status}`,
464
+ enabled: true,
465
+ color: STATUS_COLOR[status],
466
+ priority: 10 + i,
467
+ borderWidth: 30,
468
+ items: byStatus[status].map(path => ({
469
+ path,
470
+ type: 'file',
471
+ renderStrategy: 'border',
472
+ })),
473
+ });
474
+ });
475
+ if (approvedPaths.length > 0) {
476
+ layers.push({
477
+ id: 'pr-approved',
478
+ name: 'PR — approved',
479
+ enabled: true,
480
+ color: APPROVED_COLOR,
481
+ priority: 100,
482
+ borderWidth: 45,
483
+ items: approvedPaths.map(path => ({
484
+ path,
485
+ type: 'file',
486
+ renderStrategy: 'border',
487
+ })),
488
+ });
489
+ }
490
+ return layers;
491
+ }, [resolvedSnippets, approvalSet]);
492
+
493
+ // ------------------------------------------------------------------------
494
+ // Per-frame SVG update — runs inside R3F's render loop. Projects each
495
+ // building's world position through the live camera, recomputes the
496
+ // bezier path to the card edge, and writes attributes imperatively.
497
+ // No setState here — the SVG element refs are updated directly so we
498
+ // don't trigger React reconciliation 60 times a second.
499
+ // ------------------------------------------------------------------------
500
+ const projectScratch = useRef(new THREE.Vector3());
501
+
502
+ const onCameraFrame = useCallback<OnCameraFrame>(
503
+ (camera, size) => {
504
+ const vp = viewportRef.current;
505
+ const canvasLocal = canvasRectRef.current;
506
+ if (size.width === 0 || size.height === 0) return;
507
+
508
+ const layouts = cardLayoutsRef.current;
509
+ const v = projectScratch.current;
510
+
511
+ for (const layout of layouts.values()) {
512
+ const path = layout.path;
513
+ const pathEl = pathRefs.current.get(path);
514
+ const anchorEl = anchorMarkerRefs.current.get(path);
515
+ const dotEl = cardDotRefs.current.get(path);
516
+
517
+ // Project world point → NDC → canvas-local pixels → stage pixels.
518
+ v.set(layout.world.x, layout.world.y, layout.world.z).project(camera);
519
+ // For a point behind the camera the homogeneous divide flips the
520
+ // sign of x/y, so guard against that. Otherwise let the projected
521
+ // point go anywhere — we don't need it to be inside the canvas;
522
+ // overflow:visible lets the bezier extend past the stage edges.
523
+ const behindCamera = v.z > 1;
524
+ const sxCanvas = (v.x * 0.5 + 0.5) * size.width;
525
+ const syCanvas = (v.y * -0.5 + 0.5) * size.height;
526
+ const sx = canvasLocal.left + sxCanvas;
527
+ const sy = canvasLocal.top + syCanvas;
528
+
529
+ // Approval state drives color + visibility.
530
+ const approval = approvalFor(path);
531
+ const snippet = resolvedSnippets.find(
532
+ s => s.snippet.path === path,
533
+ )?.snippet;
534
+ const baseColor = snippet ? STATUS_COLOR[snippet.status] : '#9ca3af';
535
+
536
+ // Card-side anchor: panel left edge at the card's vertical center,
537
+ // clamped to the visible portion of the scroll viewport so the line
538
+ // recedes as the card scrolls offscreen.
539
+ if (!vp) {
540
+ pathEl?.setAttribute('opacity', '0');
541
+ anchorEl?.setAttribute('opacity', '0');
542
+ dotEl?.setAttribute('opacity', '0');
543
+ continue;
544
+ }
545
+ const cardCenterY = layout.card.y + layout.card.h / 2;
546
+ let intersection = 0;
547
+ if (cardCenterY < vp.top) intersection = vp.top - cardCenterY;
548
+ else if (cardCenterY > vp.bottom) intersection = cardCenterY - vp.bottom;
549
+ const lineOpacity = Math.max(0, 1 - intersection / FADE_PX);
550
+ const clampedY = Math.max(vp.top, Math.min(vp.bottom, cardCenterY));
551
+ // Land on the card's left edge — the SVG sits above the panel
552
+ // (zIndex 11) so the segment crossing the panel padding is visible.
553
+ const bx = layout.card.x;
554
+ const by = clampedY;
555
+
556
+ // Hide the leader-line when the snippet is approved (the 3D border
557
+ // alone carries that state, same as the v1 prototype).
558
+ const lineVisible =
559
+ !behindCamera && approval === 'pending' && lineOpacity > 0;
560
+
561
+ if (pathEl) {
562
+ if (!lineVisible) {
563
+ pathEl.setAttribute('opacity', '0');
564
+ } else {
565
+ const dx = Math.max(80, (bx - sx) * 0.5);
566
+ const d = `M ${sx} ${sy} C ${sx + dx} ${sy}, ${bx - dx} ${by}, ${bx} ${by}`;
567
+ pathEl.setAttribute('d', d);
568
+ pathEl.setAttribute('stroke', baseColor);
569
+ pathEl.setAttribute('opacity', String(0.85 * lineOpacity));
570
+ }
571
+ }
572
+
573
+ if (anchorEl) {
574
+ if (!lineVisible) {
575
+ anchorEl.setAttribute('opacity', '0');
576
+ } else {
577
+ anchorEl.setAttribute('x', String(sx - 3.5));
578
+ anchorEl.setAttribute('y', String(sy - 3.5));
579
+ anchorEl.setAttribute('fill', baseColor);
580
+ anchorEl.setAttribute('opacity', String(lineOpacity));
581
+ }
582
+ }
583
+
584
+ if (dotEl) {
585
+ if (!lineVisible) {
586
+ dotEl.setAttribute('opacity', '0');
587
+ } else {
588
+ dotEl.setAttribute('cx', String(bx));
589
+ dotEl.setAttribute('cy', String(by));
590
+ dotEl.setAttribute('fill', baseColor);
591
+ dotEl.setAttribute('opacity', String(lineOpacity));
592
+ }
593
+ }
594
+ }
595
+ },
596
+ [approvalFor, resolvedSnippets],
597
+ );
598
+
599
+ const toggleManual = (path: string) =>
600
+ setManualApproved(prev => {
601
+ const next = new Set(prev);
602
+ if (next.has(path)) next.delete(path);
603
+ else next.add(path);
604
+ return next;
605
+ });
606
+
607
+ return (
608
+ <div
609
+ style={{
610
+ width: '100vw',
611
+ height: '100vh',
612
+ display: 'flex',
613
+ flexDirection: 'column',
614
+ backgroundColor: '#0f1419',
615
+ }}
616
+ >
617
+ <div
618
+ style={{
619
+ padding: '12px 16px',
620
+ backgroundColor: '#1f2937',
621
+ borderBottom: '1px solid #374151',
622
+ color: '#9ca3af',
623
+ fontSize: 13,
624
+ zIndex: 50,
625
+ }}
626
+ >
627
+ PR review prototype (3D flat) — {approvalSet.size}/
628
+ {resolvedSnippets.length} approved. Drag to pan the city, scroll a
629
+ card past the midpoint to auto-approve, click "approve" to make it
630
+ sticky.
631
+ </div>
632
+
633
+ <div
634
+ ref={stageRef}
635
+ style={{
636
+ flex: 1,
637
+ position: 'relative',
638
+ minHeight: 0,
639
+ overflow: 'hidden',
640
+ }}
641
+ >
642
+ <div
643
+ ref={canvasWrapRef}
644
+ style={{ position: 'absolute', inset: 0 }}
645
+ >
646
+ <FileCity3D
647
+ cityData={cityData}
648
+ width="100%"
649
+ height="100%"
650
+ fileColorLayers={fileColorLayers}
651
+ highlightLayers={highlightLayers}
652
+ isolationMode="none"
653
+ backgroundColor="#0f1419"
654
+ textColor="#94a3b8"
655
+ isGrown={false}
656
+ animation={{
657
+ startFlat: true,
658
+ autoStartDelay: null,
659
+ staggerDelay: 0,
660
+ tension: 200,
661
+ friction: 24,
662
+ }}
663
+ showControls={false}
664
+ onCameraFrame={onCameraFrame}
665
+ />
666
+ </div>
667
+
668
+ {/* Floating snippet rail — translucent + blurred, anchored top-right
669
+ like FileCityExplorer's overlays. Scrollable. */}
670
+ <div
671
+ ref={panelRef}
672
+ style={{
673
+ position: 'absolute',
674
+ top: PANEL_INSET,
675
+ right: PANEL_INSET,
676
+ bottom: PANEL_INSET,
677
+ width: PANEL_WIDTH,
678
+ borderRadius: 12,
679
+ border: '1px solid rgba(55, 65, 81, 0.6)',
680
+ backgroundColor: 'rgba(11, 15, 20, 0.65)',
681
+ backdropFilter: 'blur(10px)',
682
+ WebkitBackdropFilter: 'blur(10px)',
683
+ boxShadow: '0 12px 36px rgba(0, 0, 0, 0.45)',
684
+ overflow: 'hidden',
685
+ zIndex: 10,
686
+ }}
687
+ >
688
+ <div
689
+ ref={scrollContainerRef}
690
+ style={{
691
+ position: 'absolute',
692
+ inset: 0,
693
+ overflowY: 'auto',
694
+ }}
695
+ >
696
+ <div
697
+ style={{
698
+ padding: 20,
699
+ paddingTop: 'calc(100vh - 120px)',
700
+ paddingBottom: 'calc(70vh)',
701
+ display: 'flex',
702
+ flexDirection: 'column',
703
+ gap: 14,
704
+ }}
705
+ >
706
+ {resolvedSnippets.map(({ snippet }) => {
707
+ const statusColor = STATUS_COLOR[snippet.status];
708
+ const isManual = manualApproved.has(snippet.path);
709
+ const isApproved = approvalSet.has(snippet.path);
710
+ const borderColor = isApproved ? APPROVED_COLOR : statusColor;
711
+ const borderStyle = isApproved ? 'solid' : 'dashed';
712
+ return (
713
+ <div
714
+ key={snippet.path}
715
+ ref={el => {
716
+ cardRefs.current.set(snippet.path, el);
717
+ }}
718
+ style={{
719
+ backgroundColor: 'rgba(17, 24, 39, 0.92)',
720
+ border: `1.5px ${borderStyle} ${
721
+ isApproved ? APPROVED_COLOR : '#374151'
722
+ }`,
723
+ borderRadius: 8,
724
+ padding: 14,
725
+ boxShadow: '0 4px 16px rgba(0, 0, 0, 0.35)',
726
+ borderLeft: `3px solid ${borderColor}`,
727
+ transition: 'border-color 200ms ease',
728
+ }}
729
+ >
730
+ <div
731
+ style={{
732
+ display: 'flex',
733
+ alignItems: 'center',
734
+ justifyContent: 'space-between',
735
+ marginBottom: 8,
736
+ gap: 8,
737
+ }}
738
+ >
739
+ <div
740
+ style={{
741
+ display: 'flex',
742
+ alignItems: 'center',
743
+ gap: 8,
744
+ minWidth: 0,
745
+ }}
746
+ >
747
+ <span
748
+ style={{
749
+ fontSize: 10,
750
+ fontWeight: 600,
751
+ letterSpacing: 0.5,
752
+ textTransform: 'uppercase',
753
+ color: statusColor,
754
+ padding: '2px 6px',
755
+ borderRadius: 3,
756
+ backgroundColor: `${statusColor}22`,
757
+ whiteSpace: 'nowrap',
758
+ }}
759
+ >
760
+ {STATUS_LABEL[snippet.status]}
761
+ </span>
762
+ <span
763
+ style={{
764
+ fontSize: 11,
765
+ color: '#9ca3af',
766
+ textTransform: 'uppercase',
767
+ letterSpacing: 0.5,
768
+ overflow: 'hidden',
769
+ textOverflow: 'ellipsis',
770
+ whiteSpace: 'nowrap',
771
+ }}
772
+ >
773
+ {snippet.label}
774
+ </span>
775
+ </div>
776
+ <button
777
+ onClick={() => toggleManual(snippet.path)}
778
+ style={{
779
+ fontSize: 10,
780
+ letterSpacing: 0.5,
781
+ textTransform: 'uppercase',
782
+ padding: '4px 8px',
783
+ borderRadius: 4,
784
+ border: `1px solid ${
785
+ isManual ? APPROVED_COLOR : '#374151'
786
+ }`,
787
+ backgroundColor: isManual
788
+ ? 'rgba(34, 197, 94, 0.15)'
789
+ : isApproved
790
+ ? 'rgba(34, 197, 94, 0.08)'
791
+ : 'transparent',
792
+ color: isApproved ? APPROVED_COLOR : '#9ca3af',
793
+ cursor: 'pointer',
794
+ fontWeight: 600,
795
+ }}
796
+ >
797
+ {isManual
798
+ ? '✓ approved'
799
+ : isApproved
800
+ ? '✓ auto'
801
+ : 'approve'}
802
+ </button>
803
+ </div>
804
+ <pre
805
+ style={{
806
+ margin: 0,
807
+ fontSize: 11.5,
808
+ lineHeight: 1.5,
809
+ color: '#e5e7eb',
810
+ fontFamily:
811
+ 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace',
812
+ whiteSpace: 'pre-wrap',
813
+ }}
814
+ >
815
+ {snippet.code}
816
+ </pre>
817
+ </div>
818
+ );
819
+ })}
820
+ </div>
821
+ </div>
822
+ </div>
823
+
824
+ {/* SVG overlay with empty per-snippet shells — actual geometry is
825
+ written imperatively each frame inside `onCameraFrame`. */}
826
+ <svg
827
+ ref={svgRef}
828
+ width="100%"
829
+ height="100%"
830
+ style={{
831
+ position: 'absolute',
832
+ inset: 0,
833
+ pointerEvents: 'none',
834
+ overflow: 'visible',
835
+ // Above the panel (zIndex 10) so the line and card-side dot
836
+ // are visible across the panel's translucent gutter; below the
837
+ // start prompt (15) and review-complete overlay (20).
838
+ zIndex: 11,
839
+ }}
840
+ >
841
+ {resolvedSnippets.map(({ snippet }) => (
842
+ <g key={snippet.path}>
843
+ <path
844
+ ref={el => {
845
+ pathRefs.current.set(snippet.path, el);
846
+ }}
847
+ fill="none"
848
+ strokeWidth={1.5}
849
+ strokeDasharray="4 4"
850
+ opacity={0}
851
+ />
852
+ <rect
853
+ ref={el => {
854
+ anchorMarkerRefs.current.set(snippet.path, el);
855
+ }}
856
+ width={7}
857
+ height={7}
858
+ stroke="#0f1419"
859
+ strokeWidth={1.25}
860
+ opacity={0}
861
+ />
862
+ <circle
863
+ ref={el => {
864
+ cardDotRefs.current.set(snippet.path, el);
865
+ }}
866
+ r={3}
867
+ opacity={0}
868
+ />
869
+ </g>
870
+ ))}
871
+ </svg>
872
+
873
+ <style>{`
874
+ @keyframes leaderline3d-review-pop {
875
+ 0% { transform: scale(0.6) rotate(-12deg); opacity: 0; }
876
+ 60% { transform: scale(1.15) rotate(2deg); opacity: 1; }
877
+ 100% { transform: scale(1) rotate(0); opacity: 1; }
878
+ }
879
+ @keyframes leaderline3d-check-draw {
880
+ from { stroke-dashoffset: 48; }
881
+ to { stroke-dashoffset: 0; }
882
+ }
883
+ @keyframes leaderline3d-ring-pulse {
884
+ 0% { transform: scale(0.85); opacity: 0.9; }
885
+ 100% { transform: scale(1.6); opacity: 0; }
886
+ }
887
+ @keyframes leaderline3d-arrow-bounce {
888
+ 0%, 100% { transform: translateY(0); opacity: 0.7; }
889
+ 50% { transform: translateY(8px); opacity: 1; }
890
+ }
891
+ `}</style>
892
+
893
+ <div
894
+ style={{
895
+ position: 'absolute',
896
+ top: PANEL_INSET,
897
+ right: PANEL_INSET,
898
+ bottom: PANEL_INSET,
899
+ width: PANEL_WIDTH,
900
+ display: 'flex',
901
+ alignItems: 'center',
902
+ justifyContent: 'center',
903
+ pointerEvents: 'none',
904
+ opacity: showStartPrompt ? 1 : 0,
905
+ transition: 'opacity 300ms ease-out',
906
+ zIndex: 15,
907
+ }}
908
+ >
909
+ <div
910
+ style={{
911
+ display: 'flex',
912
+ flexDirection: 'column',
913
+ alignItems: 'center',
914
+ gap: 14,
915
+ color: '#9ca3af',
916
+ textAlign: 'center',
917
+ padding: '0 24px',
918
+ }}
919
+ >
920
+ <div
921
+ style={{
922
+ fontSize: 11,
923
+ textTransform: 'uppercase',
924
+ letterSpacing: 1.5,
925
+ color: '#6b7280',
926
+ }}
927
+ >
928
+ {resolvedSnippets.length} files changed
929
+ </div>
930
+ <div
931
+ style={{
932
+ fontSize: 20,
933
+ fontWeight: 600,
934
+ color: '#e5e7eb',
935
+ letterSpacing: 0.3,
936
+ }}
937
+ >
938
+ Scroll to Start Code Review
939
+ </div>
940
+ <div style={{ fontSize: 12, color: '#6b7280', maxWidth: 260 }}>
941
+ Each snippet you scroll past the midpoint line is auto-approved.
942
+ </div>
943
+ <svg
944
+ width={28}
945
+ height={28}
946
+ viewBox="0 0 24 24"
947
+ fill="none"
948
+ style={{
949
+ marginTop: 4,
950
+ animation:
951
+ 'leaderline3d-arrow-bounce 1400ms ease-in-out infinite',
952
+ }}
953
+ >
954
+ <path
955
+ d="M6 9l6 6 6-6"
956
+ stroke="#9ca3af"
957
+ strokeWidth={2}
958
+ strokeLinecap="round"
959
+ strokeLinejoin="round"
960
+ />
961
+ </svg>
962
+ </div>
963
+ </div>
964
+
965
+ <div
966
+ style={{
967
+ position: 'absolute',
968
+ top: PANEL_INSET,
969
+ right: PANEL_INSET,
970
+ bottom: PANEL_INSET,
971
+ width: PANEL_WIDTH,
972
+ borderRadius: 12,
973
+ display: 'flex',
974
+ alignItems: 'center',
975
+ justifyContent: 'center',
976
+ pointerEvents: 'none',
977
+ backgroundColor: allApproved
978
+ ? 'rgba(11, 15, 20, 0.78)'
979
+ : 'transparent',
980
+ backdropFilter: allApproved ? 'blur(2px)' : 'none',
981
+ opacity: allApproved ? 1 : 0,
982
+ transition:
983
+ 'opacity 350ms ease-out, background-color 350ms ease-out',
984
+ zIndex: 20,
985
+ }}
986
+ >
987
+ <div
988
+ key={allApproved ? 'shown' : 'hidden'}
989
+ style={{
990
+ display: 'flex',
991
+ flexDirection: 'column',
992
+ alignItems: 'center',
993
+ gap: 16,
994
+ animation: allApproved
995
+ ? 'leaderline3d-review-pop 600ms cubic-bezier(0.34, 1.56, 0.64, 1) both'
996
+ : 'none',
997
+ }}
998
+ >
999
+ <div style={{ position: 'relative', width: 84, height: 84 }}>
1000
+ {allApproved && (
1001
+ <div
1002
+ style={{
1003
+ position: 'absolute',
1004
+ inset: 0,
1005
+ borderRadius: '50%',
1006
+ border: `2px solid ${APPROVED_COLOR}`,
1007
+ animation:
1008
+ 'leaderline3d-ring-pulse 900ms ease-out 200ms both',
1009
+ }}
1010
+ />
1011
+ )}
1012
+ <div
1013
+ style={{
1014
+ width: 84,
1015
+ height: 84,
1016
+ borderRadius: '50%',
1017
+ backgroundColor: 'rgba(34, 197, 94, 0.15)',
1018
+ border: `2px solid ${APPROVED_COLOR}`,
1019
+ display: 'flex',
1020
+ alignItems: 'center',
1021
+ justifyContent: 'center',
1022
+ }}
1023
+ >
1024
+ <svg width={44} height={44} viewBox="0 0 24 24" fill="none">
1025
+ <path
1026
+ d="M5 12.5l4.5 4.5L19 7"
1027
+ stroke={APPROVED_COLOR}
1028
+ strokeWidth={2.5}
1029
+ strokeLinecap="round"
1030
+ strokeLinejoin="round"
1031
+ strokeDasharray={48}
1032
+ style={{
1033
+ animation: allApproved
1034
+ ? 'leaderline3d-check-draw 450ms ease-out 250ms both'
1035
+ : 'none',
1036
+ }}
1037
+ />
1038
+ </svg>
1039
+ </div>
1040
+ </div>
1041
+ <div
1042
+ style={{
1043
+ fontSize: 22,
1044
+ fontWeight: 600,
1045
+ color: '#e5e7eb',
1046
+ letterSpacing: 0.3,
1047
+ }}
1048
+ >
1049
+ Review Completed
1050
+ </div>
1051
+ <div style={{ fontSize: 13, color: '#9ca3af' }}>
1052
+ {approvalSet.size} of {resolvedSnippets.length} files approved
1053
+ </div>
1054
+ </div>
1055
+ </div>
1056
+ </div>
1057
+ </div>
1058
+ );
1059
+ },
1060
+ };