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

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.
@@ -1,4 +1,4 @@
1
- import { useLayoutEffect, useRef, useState } from 'react';
1
+ import { useLayoutEffect, useMemo, useRef, useState } from 'react';
2
2
  import type { Meta, StoryObj } from '@storybook/react';
3
3
 
4
4
  import { ArchitectureMapHighlightLayers } from '../components/ArchitectureMapHighlightLayers';
@@ -17,25 +17,147 @@ export default meta;
17
17
  // world->screen math here lines up with what the canvas actually draws.
18
18
  const CANVAS_PADDING = 20;
19
19
 
20
- const TARGET_PATH = 'auth-server/src/app/api/auth/workos/callback/route.ts';
20
+ const PANEL_WIDTH = 380;
21
+ const FADE_PX = 60; // distance over which a line fades after its card leaves the viewport
22
+ const DWELL_MS = 400; // card must be visible for this long before scroll-past auto-approves it
23
+ const APPROVED_COLOR = '#22c55e';
24
+ const BUILDING_RECT_INSET = -3; // outset px so the border sits around (not on top of) the building
21
25
 
22
- const SNIPPET = `export async function GET(req: Request) {
23
- const url = new URL(req.url);
24
- const code = url.searchParams.get('code');
25
- if (!code) {
26
- return NextResponse.redirect(new URL('/login', req.url));
27
- }
26
+ type GitStatus = 'added' | 'modified' | 'deleted';
28
27
 
29
- const { user, sessionId } = await workos.userManagement
30
- .authenticateWithCode({
31
- clientId: env.WORKOS_CLIENT_ID,
32
- code,
33
- });
28
+ // GitHub-style diff palette. Approved is a brighter, more saturated green
29
+ // (#22c55e) so it reads as "settled" against the softer "added" green.
30
+ const STATUS_COLOR: Record<GitStatus, string> = {
31
+ added: '#3fb950',
32
+ modified: '#d29922',
33
+ deleted: '#f85149',
34
+ };
34
35
 
35
- return setSessionCookie({ user, sessionId });
36
- }`;
36
+ const STATUS_LABEL: Record<GitStatus, string> = {
37
+ added: '+ added',
38
+ modified: '~ modified',
39
+ deleted: '− deleted',
40
+ };
37
41
 
38
- const PANEL_WIDTH = 360;
42
+ interface Snippet {
43
+ path: string;
44
+ label: string;
45
+ status: GitStatus;
46
+ code: string;
47
+ }
48
+
49
+ const SNIPPETS: Snippet[] = [
50
+ {
51
+ path: 'auth-server/src/lib/auth-provider.ts',
52
+ label: 'auth-provider.ts',
53
+ status: 'modified',
54
+ code: `export const authProvider = {
55
+ async getSession(req: Request) {
56
+ const cookie = req.headers.get('cookie');
57
+ return parseSessionCookie(cookie);
58
+ },
59
+ };`,
60
+ },
61
+ {
62
+ path: 'auth-server/src/lib/org-membership.ts',
63
+ label: 'org-membership.ts',
64
+ status: 'added',
65
+ code: `export async function getOrgMembership(userId: string) {
66
+ return db.query.memberships.findFirst({
67
+ where: eq(memberships.userId, userId),
68
+ });
69
+ }`,
70
+ },
71
+ {
72
+ path: 'auth-server/src/app/api/auth/workos/callback/route.ts',
73
+ label: 'callback / route.ts',
74
+ status: 'modified',
75
+ code: `export async function GET(req: Request) {
76
+ const code = new URL(req.url).searchParams.get('code');
77
+ if (!code) return NextResponse.redirect('/login');
78
+ const session = await workos.userManagement
79
+ .authenticateWithCode({ clientId: env.WORKOS_CLIENT_ID, code });
80
+ return setSessionCookie(session);
81
+ }`,
82
+ },
83
+ {
84
+ path: 'auth-server/src/app/api/auth/workos/refresh/route.ts',
85
+ label: 'refresh / route.ts',
86
+ status: 'added',
87
+ code: `export async function POST(req: Request) {
88
+ const { refreshToken } = await req.json();
89
+ const next = await workos.userManagement
90
+ .authenticateWithRefreshToken({ refreshToken });
91
+ return Response.json(next);
92
+ }`,
93
+ },
94
+ {
95
+ path: 'auth-server/src/app/api/auth/cli/room-token/route.ts',
96
+ label: 'cli / room-token',
97
+ status: 'modified',
98
+ code: `export async function POST(req: Request) {
99
+ const session = await authProvider.getSession(req);
100
+ if (!session) return new Response('unauthorized', { status: 401 });
101
+ return Response.json({ token: mintRoomToken(session.userId) });
102
+ }`,
103
+ },
104
+ {
105
+ path: 'auth-server/src/app/api/auth/workos/verify/route.ts',
106
+ label: 'verify / route.ts',
107
+ status: 'modified',
108
+ code: `export async function GET(req: Request) {
109
+ const token = req.headers.get('authorization')?.slice(7);
110
+ const claims = await verifyJwt(token);
111
+ return Response.json({ ok: true, claims });
112
+ }`,
113
+ },
114
+ {
115
+ path: 'auth-server/src/lib/token-store.ts',
116
+ label: 'token-store.ts',
117
+ status: 'added',
118
+ code: `export const tokenStore = {
119
+ async put(key: string, value: string, ttlSec: number) {
120
+ await redis.set(key, value, 'EX', ttlSec);
121
+ },
122
+ async get(key: string) {
123
+ return redis.get(key);
124
+ },
125
+ };`,
126
+ },
127
+ {
128
+ path: 'auth-server/src/app/api/auth/workos/start/route.ts',
129
+ label: 'start / route.ts',
130
+ status: 'modified',
131
+ code: `export function GET(req: Request) {
132
+ const url = workos.userManagement.getAuthorizationUrl({
133
+ provider: 'authkit',
134
+ redirectUri: env.WORKOS_REDIRECT_URI,
135
+ });
136
+ return NextResponse.redirect(url);
137
+ }`,
138
+ },
139
+ {
140
+ path: 'auth-server/src/app/api/auth/workos/token/route.ts',
141
+ label: 'token / route.ts',
142
+ status: 'deleted',
143
+ code: `export async function POST(req: Request) {
144
+ const { code, codeVerifier } = await req.json();
145
+ const result = await workos.userManagement
146
+ .authenticateWithCode({ code, codeVerifier });
147
+ return Response.json(result);
148
+ }`,
149
+ },
150
+ {
151
+ path: 'auth-server/src/app/page.tsx',
152
+ label: 'page.tsx',
153
+ status: 'modified',
154
+ code: `export default async function Page() {
155
+ const session = await authProvider.getSession(headers());
156
+ if (!session) redirect('/login');
157
+ return <Dashboard session={session} />;
158
+ }`,
159
+ },
160
+ ];
39
161
 
40
162
  interface FitParams {
41
163
  scale: number;
@@ -66,103 +188,248 @@ function fitCityToBox(
66
188
  };
67
189
  }
68
190
 
191
+ interface ItemLayout {
192
+ path: string;
193
+ buildingAnchor: { x: number; y: number };
194
+ buildingRect: { x: number; y: number; w: number; h: number };
195
+ card: { x: number; y: number; w: number; h: number };
196
+ }
197
+
198
+ interface StageLayout {
199
+ stageW: number;
200
+ stageH: number;
201
+ viewport: { top: number; bottom: number } | null;
202
+ items: ItemLayout[];
203
+ }
204
+
69
205
  export const SingleLeaderLine: StoryObj = {
70
- render: function RenderSingleLeaderLine() {
206
+ render: function RenderLeaderLines() {
71
207
  const cityData = authServerCityData as CityData;
72
208
  const highlightLayers = createFileColorHighlightLayers(cityData.buildings);
73
209
 
74
- const target = cityData.buildings.find(b => b.path === TARGET_PATH);
210
+ // Resolve which snippets actually have buildings in the dataset.
211
+ const resolvedSnippets = useMemo(
212
+ () =>
213
+ SNIPPETS.flatMap(s => {
214
+ const building = cityData.buildings.find(b => b.path === s.path);
215
+ return building ? [{ snippet: s, building }] : [];
216
+ }),
217
+ [cityData.buildings],
218
+ );
75
219
 
76
220
  const stageRef = useRef<HTMLDivElement | null>(null);
77
221
  const canvasWrapRef = useRef<HTMLDivElement | null>(null);
78
- const panelRef = useRef<HTMLDivElement | null>(null);
222
+ const scrollContainerRef = useRef<HTMLDivElement | null>(null);
223
+ const cardRefs = useRef<Map<string, HTMLDivElement | null>>(new Map());
79
224
 
80
- const [layout, setLayout] = useState<{
81
- stageW: number;
82
- stageH: number;
83
- anchor: { x: number; y: number } | null;
84
- panel: { x: number; y: number; w: number; h: number } | null;
85
- }>({ stageW: 0, stageH: 0, anchor: null, panel: null });
225
+ const [layout, setLayout] = useState<StageLayout>({
226
+ stageW: 0,
227
+ stageH: 0,
228
+ viewport: null,
229
+ items: [],
230
+ });
231
+
232
+ // Cards that have been visible for >= DWELL_MS at some point this session.
233
+ // Sticky for the session. Auto-approval = seen && currently above viewport.
234
+ const [seenPaths, setSeenPaths] = useState<Set<string>>(new Set());
235
+ // Manual approvals. Sticky relative to scroll: scrolling back never undoes
236
+ // these. Click again to toggle off.
237
+ const [manualApproved, setManualApproved] = useState<Set<string>>(new Set());
238
+
239
+ // Pending dwell timers per path; read inside `measure` via a ref so we
240
+ // don't have to depend on rapidly-changing state.
241
+ const seenTimersRef = useRef<Map<string, number>>(new Map());
242
+ const seenPathsRef = useRef(seenPaths);
243
+ seenPathsRef.current = seenPaths;
86
244
 
87
245
  useLayoutEffect(() => {
88
246
  const stage = stageRef.current;
89
247
  const canvasWrap = canvasWrapRef.current;
90
- const panel = panelRef.current;
91
- if (!stage || !canvasWrap || !panel || !target) return;
248
+ if (!stage || !canvasWrap) return;
92
249
 
93
250
  const measure = () => {
94
251
  const stageRect = stage.getBoundingClientRect();
95
252
  const canvasRect = canvasWrap.getBoundingClientRect();
96
- const panelRect = panel.getBoundingClientRect();
253
+ const scrollRect = scrollContainerRef.current?.getBoundingClientRect();
97
254
 
98
- // Compute world->screen position for the target building inside the
99
- // canvas wrapper, then translate to stage-local coords for the SVG.
100
255
  const fit = fitCityToBox(
101
256
  cityData.bounds,
102
257
  canvasRect.width,
103
258
  canvasRect.height,
104
259
  CANVAS_PADDING,
105
260
  );
106
- const buildingX =
107
- (target.position.x - cityData.bounds.minX) * fit.scale + fit.offsetX;
108
- const buildingY =
109
- (target.position.z - cityData.bounds.minZ) * fit.scale + fit.offsetZ;
110
-
111
- const anchor = {
112
- x: canvasRect.left - stageRect.left + buildingX,
113
- y: canvasRect.top - stageRect.top + buildingY,
114
- };
115
- const panelLocal = {
116
- x: panelRect.left - stageRect.left,
117
- y: panelRect.top - stageRect.top,
118
- w: panelRect.width,
119
- h: panelRect.height,
120
- };
261
+
262
+ const items: ItemLayout[] = resolvedSnippets.flatMap(({ snippet, building }) => {
263
+ const card = cardRefs.current.get(snippet.path);
264
+ if (!card) return [];
265
+ const cardRect = card.getBoundingClientRect();
266
+
267
+ const buildingX =
268
+ (building.position.x - cityData.bounds.minX) * fit.scale + fit.offsetX;
269
+ const buildingY =
270
+ (building.position.z - cityData.bounds.minZ) * fit.scale + fit.offsetZ;
271
+ const halfW = (building.dimensions[0] / 2) * fit.scale;
272
+ const halfH = (building.dimensions[2] / 2) * fit.scale;
273
+ const stageBuildingX = canvasRect.left - stageRect.left + buildingX;
274
+ const stageBuildingY = canvasRect.top - stageRect.top + buildingY;
275
+
276
+ return [
277
+ {
278
+ path: snippet.path,
279
+ buildingAnchor: { x: stageBuildingX, y: stageBuildingY },
280
+ buildingRect: {
281
+ x: stageBuildingX - halfW + BUILDING_RECT_INSET,
282
+ y: stageBuildingY - halfH + BUILDING_RECT_INSET,
283
+ w: halfW * 2 - BUILDING_RECT_INSET * 2,
284
+ h: halfH * 2 - BUILDING_RECT_INSET * 2,
285
+ },
286
+ card: {
287
+ x: cardRect.left - stageRect.left,
288
+ y: cardRect.top - stageRect.top,
289
+ w: cardRect.width,
290
+ h: cardRect.height,
291
+ },
292
+ },
293
+ ];
294
+ });
295
+
296
+ const viewport = scrollRect
297
+ ? {
298
+ top: scrollRect.top - stageRect.top,
299
+ bottom: scrollRect.bottom - stageRect.top,
300
+ }
301
+ : null;
121
302
 
122
303
  setLayout({
123
304
  stageW: stageRect.width,
124
305
  stageH: stageRect.height,
125
- anchor,
126
- panel: panelLocal,
306
+ viewport,
307
+ items,
127
308
  });
309
+
310
+ // Track dwell: any card overlapping the viewport gets a timer that
311
+ // adds it to `seenPaths` after DWELL_MS. If it leaves before then,
312
+ // cancel. Already-seen paths are ignored.
313
+ if (viewport) {
314
+ const visibleNow = new Set<string>();
315
+ for (const it of items) {
316
+ const cardTop = it.card.y;
317
+ const cardBottom = it.card.y + it.card.h;
318
+ if (cardBottom > viewport.top && cardTop < viewport.bottom) {
319
+ visibleNow.add(it.path);
320
+ }
321
+ }
322
+ for (const p of visibleNow) {
323
+ if (seenPathsRef.current.has(p)) continue;
324
+ if (seenTimersRef.current.has(p)) continue;
325
+ const id = window.setTimeout(() => {
326
+ seenTimersRef.current.delete(p);
327
+ setSeenPaths(prev => {
328
+ if (prev.has(p)) return prev;
329
+ const next = new Set(prev);
330
+ next.add(p);
331
+ return next;
332
+ });
333
+ }, DWELL_MS);
334
+ seenTimersRef.current.set(p, id);
335
+ }
336
+ for (const [p, id] of seenTimersRef.current) {
337
+ if (!visibleNow.has(p)) {
338
+ clearTimeout(id);
339
+ seenTimersRef.current.delete(p);
340
+ }
341
+ }
342
+ }
128
343
  };
129
344
 
130
345
  measure();
131
346
  const ro = new ResizeObserver(measure);
132
347
  ro.observe(stage);
133
348
  ro.observe(canvasWrap);
134
- ro.observe(panel);
349
+ cardRefs.current.forEach(el => el && ro.observe(el));
135
350
  window.addEventListener('resize', measure);
351
+ const scrollEl = scrollContainerRef.current;
352
+ scrollEl?.addEventListener('scroll', measure, { passive: true });
136
353
  return () => {
137
354
  ro.disconnect();
138
355
  window.removeEventListener('resize', measure);
356
+ scrollEl?.removeEventListener('scroll', measure);
357
+ for (const id of seenTimersRef.current.values()) clearTimeout(id);
358
+ seenTimersRef.current.clear();
139
359
  };
140
- }, [cityData.bounds, target]);
360
+ }, [cityData.bounds, resolvedSnippets]);
141
361
 
142
- if (!target) {
143
- return (
144
- <div style={{ padding: 24, color: '#f87171' }}>
145
- Target building not found: {TARGET_PATH}
146
- </div>
147
- );
148
- }
149
-
150
- // Snippet panel anchor: left edge, vertically centered on the card.
151
- const panelAnchor =
152
- layout.panel != null
153
- ? { x: layout.panel.x, y: layout.panel.y + layout.panel.h / 2 }
154
- : null;
155
-
156
- // Smooth S-curve from building -> panel using a horizontal cubic Bezier.
157
- const path =
158
- layout.anchor && panelAnchor
159
- ? (() => {
160
- const a = layout.anchor;
161
- const b = panelAnchor;
162
- const dx = Math.max(80, (b.x - a.x) * 0.5);
163
- return `M ${a.x} ${a.y} C ${a.x + dx} ${a.y}, ${b.x - dx} ${b.y}, ${b.x} ${b.y}`;
164
- })()
165
- : null;
362
+ const toggleManual = (path: string) =>
363
+ setManualApproved(prev => {
364
+ const next = new Set(prev);
365
+ if (next.has(path)) next.delete(path);
366
+ else next.add(path);
367
+ return next;
368
+ });
369
+
370
+ // Per-snippet approval state: manual is sticky; auto fires once a card
371
+ // has been seen (>= DWELL_MS visible) AND its bottom edge has cleared
372
+ // the viewport's vertical midpoint (the whole card is above the line).
373
+ const approvalFor = (path: string, cardBottomY: number, vpMid: number) => {
374
+ if (manualApproved.has(path)) return 'manual' as const;
375
+ if (seenPaths.has(path) && cardBottomY < vpMid) return 'auto' as const;
376
+ return 'pending' as const;
377
+ };
378
+
379
+ // Build the per-snippet rendering data: clamp anchor to the viewport so
380
+ // the line recedes (and fades) as the card scrolls out of view.
381
+ const renderItems = layout.items.flatMap(item => {
382
+ if (!layout.viewport) return [];
383
+ const cardCenterY = item.card.y + item.card.h / 2;
384
+ const { top, bottom } = layout.viewport;
385
+
386
+ // Fade based on the leader-line intersection point only — the card's
387
+ // vertical center. Once that point leaves the viewport, fade out over
388
+ // FADE_PX of further scroll.
389
+ let intersectionDistance = 0;
390
+ if (cardCenterY < top) intersectionDistance = top - cardCenterY;
391
+ else if (cardCenterY > bottom) intersectionDistance = cardCenterY - bottom;
392
+ const lineOpacity = Math.max(0, 1 - intersectionDistance / FADE_PX);
393
+
394
+ const clampedY = Math.max(top, Math.min(bottom, cardCenterY));
395
+ const a = item.buildingAnchor;
396
+ const b = { x: item.card.x, y: clampedY };
397
+ const dx = Math.max(80, (b.x - a.x) * 0.5);
398
+ const path = `M ${a.x} ${a.y} C ${a.x + dx} ${a.y}, ${b.x - dx} ${b.y}, ${b.x} ${b.y}`;
399
+
400
+ const snippet = resolvedSnippets.find(s => s.snippet.path === item.path)?.snippet;
401
+ const cardBottomY = item.card.y + item.card.h;
402
+ const approval = approvalFor(item.path, cardBottomY, (top + bottom) / 2);
403
+ const color = snippet ? STATUS_COLOR[snippet.status] : '#9ca3af';
404
+
405
+ return [
406
+ {
407
+ path: item.path,
408
+ color,
409
+ d: path,
410
+ buildingAnchor: a,
411
+ buildingRect: item.buildingRect,
412
+ panelAnchor: b,
413
+ lineOpacity,
414
+ approval,
415
+ },
416
+ ];
417
+ });
418
+
419
+ const approvalSet = new Set(
420
+ renderItems.filter(r => r.approval !== 'pending').map(r => r.path),
421
+ );
422
+
423
+ const allApproved =
424
+ resolvedSnippets.length > 0 &&
425
+ resolvedSnippets.every(({ snippet }) => approvalSet.has(snippet.path));
426
+
427
+ // "Has started": any card has crossed into the viewport. Initial state
428
+ // (cards still parked below the bottom padding) shows the start prompt.
429
+ const hasStarted =
430
+ layout.viewport != null &&
431
+ layout.items.some(it => it.card.y < layout.viewport!.bottom);
432
+ const showStartPrompt = !hasStarted && !allApproved;
166
433
 
167
434
  return (
168
435
  <div
@@ -183,9 +450,9 @@ export const SingleLeaderLine: StoryObj = {
183
450
  fontSize: 13,
184
451
  }}
185
452
  >
186
- Prototype: leader line from{' '}
187
- <code style={{ color: '#e5e7eb' }}>{TARGET_PATH}</code> to a side-panel
188
- snippet.
453
+ PR review prototype {approvalSet.size}/{resolvedSnippets.length}{' '}
454
+ approved. Scroll a card fully past the middle line to auto-approve
455
+ (dashed → solid green); click "approve" to make it sticky.
189
456
  </div>
190
457
 
191
458
  <div
@@ -197,10 +464,7 @@ export const SingleLeaderLine: StoryObj = {
197
464
  minHeight: 0,
198
465
  }}
199
466
  >
200
- <div
201
- ref={canvasWrapRef}
202
- style={{ flex: 1, position: 'relative', minWidth: 0 }}
203
- >
467
+ <div ref={canvasWrapRef} style={{ flex: 1, position: 'relative', minWidth: 0 }}>
204
468
  <ArchitectureMapHighlightLayers
205
469
  cityData={cityData}
206
470
  highlightLayers={highlightLayers}
@@ -213,92 +477,416 @@ export const SingleLeaderLine: StoryObj = {
213
477
  </div>
214
478
 
215
479
  <div
480
+ ref={scrollContainerRef}
216
481
  style={{
217
482
  width: PANEL_WIDTH,
218
- padding: 24,
219
- display: 'flex',
220
- flexDirection: 'column',
221
- justifyContent: 'center',
222
483
  borderLeft: '1px solid #1f2937',
223
484
  backgroundColor: '#0b0f14',
485
+ overflowY: 'auto',
224
486
  }}
225
487
  >
226
488
  <div
227
- ref={panelRef}
228
489
  style={{
229
- backgroundColor: '#111827',
230
- border: '1px solid #374151',
231
- borderRadius: 8,
232
- padding: 16,
233
- boxShadow: '0 4px 16px rgba(0, 0, 0, 0.4)',
490
+ padding: 24,
491
+ // Start cards below the viewport so the user has to scroll
492
+ // them in (the "Scroll to Start" overlay covers the empty
493
+ // initial state).
494
+ paddingTop: '100vh',
495
+ // Enough bottom padding that the last card can scroll well
496
+ // above the midpoint approval line.
497
+ paddingBottom: '70vh',
498
+ display: 'flex',
499
+ flexDirection: 'column',
500
+ gap: 16,
501
+ }}
502
+ >
503
+ {resolvedSnippets.map(({ snippet }) => {
504
+ const statusColor = STATUS_COLOR[snippet.status];
505
+ const isManual = manualApproved.has(snippet.path);
506
+ const isApproved = approvalSet.has(snippet.path);
507
+ const borderColor = isApproved ? APPROVED_COLOR : statusColor;
508
+ const borderStyle = isApproved ? 'solid' : 'dashed';
509
+ return (
510
+ <div
511
+ key={snippet.path}
512
+ ref={el => {
513
+ cardRefs.current.set(snippet.path, el);
514
+ }}
515
+ style={{
516
+ backgroundColor: '#111827',
517
+ border: `1.5px ${borderStyle} ${
518
+ isApproved ? APPROVED_COLOR : '#374151'
519
+ }`,
520
+ borderRadius: 8,
521
+ padding: 14,
522
+ boxShadow: '0 4px 16px rgba(0, 0, 0, 0.4)',
523
+ borderLeft: `3px solid ${borderColor}`,
524
+ transition: 'border-color 200ms ease',
525
+ }}
526
+ >
527
+ <div
528
+ style={{
529
+ display: 'flex',
530
+ alignItems: 'center',
531
+ justifyContent: 'space-between',
532
+ marginBottom: 8,
533
+ gap: 8,
534
+ }}
535
+ >
536
+ <div
537
+ style={{
538
+ display: 'flex',
539
+ alignItems: 'center',
540
+ gap: 8,
541
+ minWidth: 0,
542
+ }}
543
+ >
544
+ <span
545
+ style={{
546
+ fontSize: 10,
547
+ fontWeight: 600,
548
+ letterSpacing: 0.5,
549
+ textTransform: 'uppercase',
550
+ color: statusColor,
551
+ padding: '2px 6px',
552
+ borderRadius: 3,
553
+ backgroundColor: `${statusColor}22`,
554
+ whiteSpace: 'nowrap',
555
+ }}
556
+ >
557
+ {STATUS_LABEL[snippet.status]}
558
+ </span>
559
+ <span
560
+ style={{
561
+ fontSize: 11,
562
+ color: '#9ca3af',
563
+ textTransform: 'uppercase',
564
+ letterSpacing: 0.5,
565
+ overflow: 'hidden',
566
+ textOverflow: 'ellipsis',
567
+ whiteSpace: 'nowrap',
568
+ }}
569
+ >
570
+ {snippet.label}
571
+ </span>
572
+ </div>
573
+ <button
574
+ onClick={() => toggleManual(snippet.path)}
575
+ style={{
576
+ fontSize: 10,
577
+ letterSpacing: 0.5,
578
+ textTransform: 'uppercase',
579
+ padding: '4px 8px',
580
+ borderRadius: 4,
581
+ border: `1px solid ${
582
+ isManual ? APPROVED_COLOR : '#374151'
583
+ }`,
584
+ backgroundColor: isManual
585
+ ? 'rgba(34, 197, 94, 0.15)'
586
+ : isApproved
587
+ ? 'rgba(34, 197, 94, 0.08)'
588
+ : 'transparent',
589
+ color: isApproved ? APPROVED_COLOR : '#9ca3af',
590
+ cursor: 'pointer',
591
+ fontWeight: 600,
592
+ }}
593
+ >
594
+ {isManual
595
+ ? '✓ approved'
596
+ : isApproved
597
+ ? '✓ auto'
598
+ : 'approve'}
599
+ </button>
600
+ </div>
601
+ <pre
602
+ style={{
603
+ margin: 0,
604
+ fontSize: 11.5,
605
+ lineHeight: 1.5,
606
+ color: '#e5e7eb',
607
+ fontFamily:
608
+ 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace',
609
+ whiteSpace: 'pre-wrap',
610
+ }}
611
+ >
612
+ {snippet.code}
613
+ </pre>
614
+ </div>
615
+ );
616
+ })}
617
+ </div>
618
+ </div>
619
+
620
+ <svg
621
+ width={layout.stageW}
622
+ height={layout.stageH}
623
+ style={{
624
+ position: 'absolute',
625
+ top: 0,
626
+ left: 0,
627
+ pointerEvents: 'none',
628
+ zIndex: 5,
629
+ }}
630
+ >
631
+ {/* Approval boundary guide line across the panel's vertical
632
+ midpoint, so the threshold is visible while tuning. */}
633
+ {layout.viewport && (
634
+ <line
635
+ x1={layout.stageW - PANEL_WIDTH}
636
+ x2={layout.stageW}
637
+ y1={(layout.viewport.top + layout.viewport.bottom) / 2}
638
+ y2={(layout.viewport.top + layout.viewport.bottom) / 2}
639
+ stroke="#6b7280"
640
+ strokeWidth={1}
641
+ strokeDasharray="2 4"
642
+ opacity={0.4}
643
+ />
644
+ )}
645
+
646
+ {/* Building borders: drawn for every snippet, not gated by line
647
+ visibility — so approved buildings stay marked even after
648
+ their card scrolls off. */}
649
+ {renderItems.map(item => {
650
+ const approved = item.approval !== 'pending';
651
+ const stroke = approved ? APPROVED_COLOR : item.color;
652
+ return (
653
+ <rect
654
+ key={`border-${item.path}`}
655
+ x={item.buildingRect.x}
656
+ y={item.buildingRect.y}
657
+ width={item.buildingRect.w}
658
+ height={item.buildingRect.h}
659
+ fill="none"
660
+ stroke={stroke}
661
+ strokeWidth={approved ? 2 : 1.5}
662
+ strokeDasharray={approved ? undefined : '3 3'}
663
+ rx={1.5}
664
+ ry={1.5}
665
+ opacity={approved ? 1 : 0.9}
666
+ />
667
+ );
668
+ })}
669
+
670
+ {/* Leader lines + endpoint markers: fade with the card and are
671
+ hidden once the snippet is approved (the building border alone
672
+ carries the approved state). */}
673
+ {renderItems.map(item => {
674
+ if (item.lineOpacity <= 0) return null;
675
+ if (item.approval !== 'pending') return null;
676
+ return (
677
+ <g key={`line-${item.path}`} opacity={item.lineOpacity}>
678
+ <path
679
+ d={item.d}
680
+ fill="none"
681
+ stroke={item.color}
682
+ strokeWidth={1.5}
683
+ strokeDasharray="4 4"
684
+ opacity={0.85}
685
+ />
686
+ <rect
687
+ x={item.buildingAnchor.x - 3.5}
688
+ y={item.buildingAnchor.y - 3.5}
689
+ width={7}
690
+ height={7}
691
+ fill={item.color}
692
+ stroke="#0f1419"
693
+ strokeWidth={1.25}
694
+ />
695
+ <circle
696
+ cx={item.panelAnchor.x}
697
+ cy={item.panelAnchor.y}
698
+ r={3}
699
+ fill={item.color}
700
+ />
701
+ </g>
702
+ );
703
+ })}
704
+ </svg>
705
+
706
+ {/* Review-complete overlay: pinned over the panel column, fades
707
+ and scales in once every snippet is approved. */}
708
+ <style>{`
709
+ @keyframes leaderline-review-pop {
710
+ 0% { transform: scale(0.6) rotate(-12deg); opacity: 0; }
711
+ 60% { transform: scale(1.15) rotate(2deg); opacity: 1; }
712
+ 100% { transform: scale(1) rotate(0); opacity: 1; }
713
+ }
714
+ @keyframes leaderline-check-draw {
715
+ from { stroke-dashoffset: 48; }
716
+ to { stroke-dashoffset: 0; }
717
+ }
718
+ @keyframes leaderline-ring-pulse {
719
+ 0% { transform: scale(0.85); opacity: 0.9; }
720
+ 100% { transform: scale(1.6); opacity: 0; }
721
+ }
722
+ @keyframes leaderline-arrow-bounce {
723
+ 0%, 100% { transform: translateY(0); opacity: 0.7; }
724
+ 50% { transform: translateY(8px); opacity: 1; }
725
+ }
726
+ `}</style>
727
+
728
+ {/* Start prompt: shown until the user scrolls a card into view. */}
729
+ <div
730
+ style={{
731
+ position: 'absolute',
732
+ top: 0,
733
+ right: 0,
734
+ width: PANEL_WIDTH,
735
+ height: '100%',
736
+ display: 'flex',
737
+ alignItems: 'center',
738
+ justifyContent: 'center',
739
+ pointerEvents: 'none',
740
+ opacity: showStartPrompt ? 1 : 0,
741
+ transition: 'opacity 300ms ease-out',
742
+ zIndex: 15,
743
+ }}
744
+ >
745
+ <div
746
+ style={{
747
+ display: 'flex',
748
+ flexDirection: 'column',
749
+ alignItems: 'center',
750
+ gap: 14,
751
+ color: '#9ca3af',
752
+ textAlign: 'center',
753
+ padding: '0 24px',
234
754
  }}
235
755
  >
236
756
  <div
237
757
  style={{
238
758
  fontSize: 11,
239
- color: '#9ca3af',
240
759
  textTransform: 'uppercase',
241
- letterSpacing: 0.5,
242
- marginBottom: 8,
760
+ letterSpacing: 1.5,
761
+ color: '#6b7280',
243
762
  }}
244
763
  >
245
- callback / route.ts
764
+ {resolvedSnippets.length} files changed
246
765
  </div>
247
- <pre
766
+ <div
248
767
  style={{
249
- margin: 0,
250
- fontSize: 12,
251
- lineHeight: 1.5,
768
+ fontSize: 20,
769
+ fontWeight: 600,
252
770
  color: '#e5e7eb',
253
- fontFamily:
254
- 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace',
255
- whiteSpace: 'pre-wrap',
771
+ letterSpacing: 0.3,
256
772
  }}
257
773
  >
258
- {SNIPPET}
259
- </pre>
774
+ Scroll to Start Code Review
775
+ </div>
776
+ <div style={{ fontSize: 12, color: '#6b7280', maxWidth: 260 }}>
777
+ Each snippet you scroll past the midpoint line is auto-approved.
778
+ </div>
779
+ <svg
780
+ width={28}
781
+ height={28}
782
+ viewBox="0 0 24 24"
783
+ fill="none"
784
+ style={{
785
+ marginTop: 4,
786
+ animation:
787
+ 'leaderline-arrow-bounce 1400ms ease-in-out infinite',
788
+ }}
789
+ >
790
+ <path
791
+ d="M6 9l6 6 6-6"
792
+ stroke="#9ca3af"
793
+ strokeWidth={2}
794
+ strokeLinecap="round"
795
+ strokeLinejoin="round"
796
+ />
797
+ </svg>
260
798
  </div>
261
799
  </div>
262
-
263
- {/* SVG overlay spans the whole stage so the line can cross columns. */}
264
- <svg
265
- width={layout.stageW}
266
- height={layout.stageH}
800
+ <div
267
801
  style={{
268
802
  position: 'absolute',
269
803
  top: 0,
270
- left: 0,
804
+ right: 0,
805
+ width: PANEL_WIDTH,
806
+ height: '100%',
807
+ display: 'flex',
808
+ alignItems: 'center',
809
+ justifyContent: 'center',
271
810
  pointerEvents: 'none',
272
- zIndex: 5,
811
+ backgroundColor: allApproved
812
+ ? 'rgba(11, 15, 20, 0.78)'
813
+ : 'transparent',
814
+ backdropFilter: allApproved ? 'blur(2px)' : 'none',
815
+ opacity: allApproved ? 1 : 0,
816
+ transition:
817
+ 'opacity 350ms ease-out, background-color 350ms ease-out',
818
+ zIndex: 20,
273
819
  }}
274
820
  >
275
- {path && layout.anchor && panelAnchor && (
276
- <>
277
- <path
278
- d={path}
279
- fill="none"
280
- stroke="#fbbf24"
281
- strokeWidth={1.5}
282
- strokeDasharray="4 4"
283
- opacity={0.85}
284
- />
285
- <circle
286
- cx={layout.anchor.x}
287
- cy={layout.anchor.y}
288
- r={5}
289
- fill="#fbbf24"
290
- stroke="#0f1419"
291
- strokeWidth={1.5}
292
- />
293
- <circle
294
- cx={panelAnchor.x}
295
- cy={panelAnchor.y}
296
- r={3}
297
- fill="#fbbf24"
298
- />
299
- </>
300
- )}
301
- </svg>
821
+ <div
822
+ key={allApproved ? 'shown' : 'hidden'}
823
+ style={{
824
+ display: 'flex',
825
+ flexDirection: 'column',
826
+ alignItems: 'center',
827
+ gap: 16,
828
+ animation: allApproved
829
+ ? 'leaderline-review-pop 600ms cubic-bezier(0.34, 1.56, 0.64, 1) both'
830
+ : 'none',
831
+ }}
832
+ >
833
+ <div style={{ position: 'relative', width: 84, height: 84 }}>
834
+ {allApproved && (
835
+ <div
836
+ style={{
837
+ position: 'absolute',
838
+ inset: 0,
839
+ borderRadius: '50%',
840
+ border: `2px solid ${APPROVED_COLOR}`,
841
+ animation:
842
+ 'leaderline-ring-pulse 900ms ease-out 200ms both',
843
+ }}
844
+ />
845
+ )}
846
+ <div
847
+ style={{
848
+ width: 84,
849
+ height: 84,
850
+ borderRadius: '50%',
851
+ backgroundColor: 'rgba(34, 197, 94, 0.15)',
852
+ border: `2px solid ${APPROVED_COLOR}`,
853
+ display: 'flex',
854
+ alignItems: 'center',
855
+ justifyContent: 'center',
856
+ }}
857
+ >
858
+ <svg width={44} height={44} viewBox="0 0 24 24" fill="none">
859
+ <path
860
+ d="M5 12.5l4.5 4.5L19 7"
861
+ stroke={APPROVED_COLOR}
862
+ strokeWidth={2.5}
863
+ strokeLinecap="round"
864
+ strokeLinejoin="round"
865
+ strokeDasharray={48}
866
+ style={{
867
+ animation: allApproved
868
+ ? 'leaderline-check-draw 450ms ease-out 250ms both'
869
+ : 'none',
870
+ }}
871
+ />
872
+ </svg>
873
+ </div>
874
+ </div>
875
+ <div
876
+ style={{
877
+ fontSize: 22,
878
+ fontWeight: 600,
879
+ color: '#e5e7eb',
880
+ letterSpacing: 0.3,
881
+ }}
882
+ >
883
+ Review Completed
884
+ </div>
885
+ <div style={{ fontSize: 13, color: '#9ca3af' }}>
886
+ {approvalSet.size} of {resolvedSnippets.length} files approved
887
+ </div>
888
+ </div>
889
+ </div>
302
890
  </div>
303
891
  </div>
304
892
  );