@principal-ai/file-city-react 0.5.42 → 0.5.44
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.
- package/dist/components/FileCity3D/FileCity3D.d.ts +70 -2
- package/dist/components/FileCity3D/FileCity3D.d.ts.map +1 -1
- package/dist/components/FileCity3D/FileCity3D.js +164 -54
- package/dist/components/FileCity3D/index.d.ts +1 -1
- package/dist/components/FileCity3D/index.d.ts.map +1 -1
- package/dist/components/FileCityExplorer/FileCityExplorer.d.ts.map +1 -1
- package/dist/components/FileCityExplorer/FileCityExplorer.js +1 -31
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/components/FileCity3D/FileCity3D.tsx +437 -137
- package/src/components/FileCity3D/index.ts +2 -0
- package/src/components/FileCityExplorer/FileCityExplorer.tsx +2 -30
- package/src/index.ts +1 -0
- package/src/stories/ElevatedScopePanels.stories.tsx +72 -27
- package/src/stories/FileCityExplorer.stories.tsx +2 -29
- package/src/stories/HighlightLayersFlatDebug.stories.tsx +319 -0
- package/src/stories/LeaderLineSnippetOverlay.stories.tsx +725 -137
- package/src/stories/LeaderLineSnippetOverlay3D.stories.tsx +1060 -0
|
@@ -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
|
+
};
|