@principal-ai/file-city-react 0.5.37 → 0.5.39

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,1687 @@
1
+ import React from 'react';
2
+ import type { Meta, StoryObj } from '@storybook/react';
3
+ import {
4
+ FileTree,
5
+ useFileTree,
6
+ useFileTreeSelection,
7
+ useFileTreeSelector,
8
+ } from '@pierre/trees/react';
9
+ import {
10
+ FileCity3D,
11
+ type CityData,
12
+ type CityDistrict,
13
+ type ElevatedScopePanel,
14
+ type HighlightLayer,
15
+ } from '../components/FileCity3D';
16
+ import { createFileColorHighlightLayers } from '../utils/fileColorHighlightLayers';
17
+
18
+ import electronAppCityData from '../../../../assets/electron-app-city-data.json';
19
+
20
+ /**
21
+ * Scope / Namespace Overlay experiments
22
+ *
23
+ * Prototypes the mapping described in docs/scope-namespace-overlay.md:
24
+ *
25
+ * scope → LayerGroup (toggleable lens on the city)
26
+ * namespace → HighlightLayer (one stable color, directory items)
27
+ * paths[] → LayerItem { type: 'directory', renderStrategy: 'fill' }
28
+ *
29
+ * The scopes/namespaces below are hand-authored against paths that exist in
30
+ * the electron-app city data — they stand in for what would eventually be
31
+ * parsed from principal-view-core-library *.events.canvas files.
32
+ */
33
+
34
+ const meta: Meta<typeof FileCity3D> = {
35
+ title: 'Experiments/ScopeOverlay',
36
+ component: FileCity3D,
37
+ parameters: { layout: 'fullscreen' },
38
+ };
39
+
40
+ export default meta;
41
+ type Story = StoryObj<typeof FileCity3D>;
42
+
43
+ // ---------------------------------------------------------------------------
44
+ // Mock scope / namespace model
45
+ // ---------------------------------------------------------------------------
46
+
47
+ interface MockEvent {
48
+ /** Action portion of the event name — full name is `${namespace}.${action}`. */
49
+ action: string;
50
+ severity: 'info' | 'warn' | 'error';
51
+ description: string;
52
+ }
53
+
54
+ interface MockNamespace {
55
+ name: string;
56
+ description: string;
57
+ color: string;
58
+ paths: string[];
59
+ /**
60
+ * Events on this namespace. Per the doc, events are namespace-level metadata —
61
+ * they don't claim files at this level. (Files-per-event is a future layer.)
62
+ */
63
+ events: MockEvent[];
64
+ }
65
+
66
+ interface MockScope {
67
+ id: string;
68
+ name: string;
69
+ description: string;
70
+ /**
71
+ * Scope-level paths (mirrors planned addition in principal-view-core-library).
72
+ * Cover everything not claimed by a more specific namespace.
73
+ */
74
+ paths: string[];
75
+ namespaces: MockNamespace[];
76
+ }
77
+
78
+ const SCOPES_STORAGE_KEY = 'file-city.scope-overlay.scopes';
79
+
80
+ function loadScopesFromStorage(): MockScope[] {
81
+ if (typeof window === 'undefined') return [];
82
+ try {
83
+ const raw = window.localStorage.getItem(SCOPES_STORAGE_KEY);
84
+ if (!raw) return [];
85
+ const parsed = JSON.parse(raw);
86
+ return Array.isArray(parsed) ? (parsed as MockScope[]) : [];
87
+ } catch {
88
+ return [];
89
+ }
90
+ }
91
+
92
+ function saveScopesToStorage(scopes: readonly MockScope[]): void {
93
+ if (typeof window === 'undefined') return;
94
+ try {
95
+ window.localStorage.setItem(SCOPES_STORAGE_KEY, JSON.stringify(scopes));
96
+ } catch {
97
+ // ignore quota / serialization errors in the story
98
+ }
99
+ }
100
+
101
+ // ---------------------------------------------------------------------------
102
+ // File tree paths (extracted once from city data)
103
+ // ---------------------------------------------------------------------------
104
+
105
+ const ELECTRON_PATHS: string[] = (() => {
106
+ const set = new Set<string>();
107
+ for (const b of (electronAppCityData as CityData).buildings) set.add(b.path);
108
+ return Array.from(set).sort();
109
+ })();
110
+
111
+ const ELECTRON_DIRECTORIES: Set<string> = new Set(
112
+ (electronAppCityData as CityData).districts.map(d => d.path),
113
+ );
114
+
115
+ const ELECTRON_DISTRICTS_BY_PATH: Map<string, CityDistrict> = new Map(
116
+ (electronAppCityData as CityData).districts.map(d => [d.path, d]),
117
+ );
118
+
119
+ /**
120
+ * Parent directory → list of immediate child directories. Top-level folders
121
+ * (e.g. `electron-app`) live under the empty-string parent. Used to walk the
122
+ * folder hierarchy when generating elevated panels for the file-tree tab.
123
+ */
124
+ const ELECTRON_FOLDER_CHILDREN: Map<string, string[]> = (() => {
125
+ const m = new Map<string, string[]>();
126
+ const dirs = Array.from(ELECTRON_DIRECTORIES).sort();
127
+ for (const dir of dirs) {
128
+ const slash = dir.lastIndexOf('/');
129
+ const parent = slash >= 0 ? dir.slice(0, slash) : '';
130
+ const arr = m.get(parent);
131
+ if (arr) arr.push(dir);
132
+ else m.set(parent, [dir]);
133
+ }
134
+ return m;
135
+ })();
136
+
137
+ /**
138
+ * Folder path → world bounds spanning every descendant district (including
139
+ * the folder itself, when it is a district). A collapsed folder uses these
140
+ * unioned bounds for its umbrella panel.
141
+ */
142
+ const ELECTRON_FOLDER_BOUNDS: Map<
143
+ string,
144
+ { minX: number; maxX: number; minZ: number; maxZ: number }
145
+ > = (() => {
146
+ const m = new Map<
147
+ string,
148
+ { minX: number; maxX: number; minZ: number; maxZ: number }
149
+ >();
150
+ for (const district of (electronAppCityData as CityData).districts) {
151
+ const b = district.worldBounds;
152
+ let path = district.path;
153
+ while (path) {
154
+ const cur = m.get(path);
155
+ if (!cur) {
156
+ m.set(path, { minX: b.minX, maxX: b.maxX, minZ: b.minZ, maxZ: b.maxZ });
157
+ } else {
158
+ if (b.minX < cur.minX) cur.minX = b.minX;
159
+ if (b.maxX > cur.maxX) cur.maxX = b.maxX;
160
+ if (b.minZ < cur.minZ) cur.minZ = b.minZ;
161
+ if (b.maxZ > cur.maxZ) cur.maxZ = b.maxZ;
162
+ }
163
+ const slash = path.lastIndexOf('/');
164
+ if (slash < 0) break;
165
+ path = path.slice(0, slash);
166
+ }
167
+ }
168
+ return m;
169
+ })();
170
+
171
+ /**
172
+ * Folder path → number of descendant files. Used to scale folder-panel
173
+ * label text so larger folders read first when scanning the city.
174
+ */
175
+ const ELECTRON_FOLDER_FILE_COUNTS: Map<string, number> = (() => {
176
+ const m = new Map<string, number>();
177
+ for (const b of (electronAppCityData as CityData).buildings) {
178
+ let path = b.path;
179
+ const slash = path.lastIndexOf('/');
180
+ path = slash >= 0 ? path.slice(0, slash) : '';
181
+ while (path) {
182
+ m.set(path, (m.get(path) ?? 0) + 1);
183
+ const s = path.lastIndexOf('/');
184
+ if (s < 0) break;
185
+ path = path.slice(0, s);
186
+ }
187
+ }
188
+ return m;
189
+ })();
190
+
191
+ const FOLDER_PALETTE = [
192
+ '#3b82f6',
193
+ '#22c55e',
194
+ '#f59e0b',
195
+ '#ec4899',
196
+ '#8b5cf6',
197
+ '#06b6d4',
198
+ '#ef4444',
199
+ '#14b8a6',
200
+ '#a855f7',
201
+ '#eab308',
202
+ ];
203
+
204
+ function hashFolderColor(path: string): string {
205
+ let h = 0;
206
+ for (let i = 0; i < path.length; i++) {
207
+ h = (h * 31 + path.charCodeAt(i)) | 0;
208
+ }
209
+ return FOLDER_PALETTE[Math.abs(h) % FOLDER_PALETTE.length];
210
+ }
211
+
212
+ // ---------------------------------------------------------------------------
213
+ // Scope tree paths
214
+ // ---------------------------------------------------------------------------
215
+
216
+ interface ScopeTreeSelection {
217
+ scopeId: string;
218
+ namespaceName?: string;
219
+ eventAction?: string;
220
+ }
221
+
222
+ /**
223
+ * Sentinel leaves used when a scope has no namespaces or a namespace has no
224
+ * events — the trees library infers directories from paths, so empty branches
225
+ * need a placeholder leaf to render.
226
+ */
227
+ const EMPTY_NS_SENTINEL = '(no namespaces)';
228
+ const EMPTY_EVENTS_SENTINEL = '(no events)';
229
+
230
+ /**
231
+ * Build canonical paths for the scope tree: `<scope.id>/<namespace.name>/<event.action>`.
232
+ * Scopes are top-level directories, namespaces children, events leaves.
233
+ * Empty scopes/namespaces emit a sentinel leaf so they still appear.
234
+ */
235
+ function buildScopeTreePaths(scopes: readonly MockScope[]): string[] {
236
+ const out: string[] = [];
237
+ for (const scope of scopes) {
238
+ if (scope.namespaces.length === 0) {
239
+ out.push(`${scope.id}/${EMPTY_NS_SENTINEL}`);
240
+ continue;
241
+ }
242
+ for (const ns of scope.namespaces) {
243
+ if (ns.events.length === 0) {
244
+ out.push(`${scope.id}/${ns.name}/${EMPTY_EVENTS_SENTINEL}`);
245
+ continue;
246
+ }
247
+ for (const ev of ns.events) {
248
+ out.push(`${scope.id}/${ns.name}/${ev.action}`);
249
+ }
250
+ }
251
+ }
252
+ return out;
253
+ }
254
+
255
+ function parseScopeTreePath(path: string): ScopeTreeSelection {
256
+ const [scopeId, namespaceName, eventAction] = path.split('/');
257
+ const result: ScopeTreeSelection = { scopeId };
258
+ if (namespaceName && namespaceName !== EMPTY_NS_SENTINEL) {
259
+ result.namespaceName = namespaceName;
260
+ }
261
+ if (eventAction && eventAction !== EMPTY_EVENTS_SENTINEL) {
262
+ result.eventAction = eventAction;
263
+ }
264
+ return result;
265
+ }
266
+
267
+ // ---------------------------------------------------------------------------
268
+ // Info overlay component
269
+ // ---------------------------------------------------------------------------
270
+
271
+ const SEVERITY_BG: Record<MockEvent['severity'], string> = {
272
+ error: '#7f1d1d',
273
+ warn: '#78350f',
274
+ info: '#1e3a8a',
275
+ };
276
+
277
+ const overlayStyle: React.CSSProperties = {
278
+ position: 'absolute',
279
+ top: 16,
280
+ right: 16,
281
+ width: 360,
282
+ maxHeight: 'calc(100vh - 32px)',
283
+ overflowY: 'auto',
284
+ background: 'rgba(15, 23, 42, 0.96)',
285
+ border: '1px solid #334155',
286
+ borderRadius: 8,
287
+ color: '#e2e8f0',
288
+ fontFamily: 'system-ui, sans-serif',
289
+ fontSize: 13,
290
+ zIndex: 100,
291
+ boxShadow: '0 10px 30px rgba(0,0,0,0.4)',
292
+ };
293
+
294
+ const sectionLabelStyle: React.CSSProperties = {
295
+ fontSize: 11,
296
+ color: '#64748b',
297
+ textTransform: 'uppercase',
298
+ letterSpacing: 0.5,
299
+ };
300
+
301
+ const ScopeInfoOverlay: React.FC<{
302
+ info: { scope: MockScope; ns: MockNamespace | null; ev: MockEvent | null };
303
+ }> = ({ info }) => {
304
+ const { scope, ns, ev } = info;
305
+
306
+ // Event leaf selected — show event detail.
307
+ if (ns && ev) {
308
+ return (
309
+ <div style={overlayStyle}>
310
+ <div style={{ padding: '14px 16px', borderBottom: '1px solid #1e293b' }}>
311
+ <div style={sectionLabelStyle}>Event</div>
312
+ <div style={{ fontFamily: 'monospace', fontSize: 14, marginTop: 6 }}>
313
+ {ns.name}.{ev.action}
314
+ </div>
315
+ <div
316
+ style={{
317
+ display: 'inline-block',
318
+ fontSize: 10,
319
+ marginTop: 8,
320
+ padding: '2px 6px',
321
+ borderRadius: 3,
322
+ background: SEVERITY_BG[ev.severity],
323
+ color: '#fde68a',
324
+ }}
325
+ >
326
+ {ev.severity}
327
+ </div>
328
+ <div style={{ fontSize: 12, color: '#cbd5e1', marginTop: 10, lineHeight: 1.5 }}>
329
+ {ev.description}
330
+ </div>
331
+ </div>
332
+ <div style={{ padding: '14px 16px' }}>
333
+ <div style={sectionLabelStyle}>Owning namespace</div>
334
+ <div style={{ marginTop: 6, display: 'flex', alignItems: 'center', gap: 8 }}>
335
+ <span
336
+ style={{ width: 12, height: 12, borderRadius: 3, background: ns.color, flexShrink: 0 }}
337
+ />
338
+ <span style={{ fontFamily: 'monospace', fontSize: 13 }}>{ns.name}</span>
339
+ </div>
340
+ <div style={{ fontSize: 10, color: '#64748b', marginTop: 14, fontStyle: 'italic' }}>
341
+ Files-per-event mapping not wired yet — for now the event highlights its parent
342
+ namespace's paths.
343
+ </div>
344
+ </div>
345
+ </div>
346
+ );
347
+ }
348
+
349
+ // Namespace selected — show namespace detail.
350
+ if (ns) {
351
+ return (
352
+ <div style={overlayStyle}>
353
+ <div style={{ padding: '14px 16px', borderBottom: '1px solid #1e293b' }}>
354
+ <div style={sectionLabelStyle}>Namespace</div>
355
+ <div style={{ marginTop: 6, display: 'flex', alignItems: 'center', gap: 8 }}>
356
+ <span
357
+ style={{ width: 12, height: 12, borderRadius: 3, background: ns.color, flexShrink: 0 }}
358
+ />
359
+ <span style={{ fontFamily: 'monospace', fontSize: 14 }}>{ns.name}</span>
360
+ </div>
361
+ <div style={{ fontSize: 11, color: '#94a3b8', marginTop: 8, lineHeight: 1.5 }}>
362
+ {ns.description}
363
+ </div>
364
+ <div style={{ fontSize: 11, color: '#64748b', marginTop: 8 }}>
365
+ in <span style={{ fontFamily: 'monospace' }}>{scope.id}</span>
366
+ </div>
367
+ </div>
368
+ <div style={{ padding: '14px 16px', borderBottom: '1px solid #1e293b' }}>
369
+ <div style={sectionLabelStyle}>Claimed paths ({ns.paths.length})</div>
370
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 4, marginTop: 6 }}>
371
+ {ns.paths.map(p => (
372
+ <code
373
+ key={p}
374
+ style={{
375
+ fontSize: 11,
376
+ color: '#cbd5e1',
377
+ background: '#0b1220',
378
+ padding: '4px 6px',
379
+ borderRadius: 4,
380
+ wordBreak: 'break-all',
381
+ }}
382
+ >
383
+ {p}
384
+ </code>
385
+ ))}
386
+ </div>
387
+ </div>
388
+ <div style={{ padding: '14px 16px' }}>
389
+ <div style={sectionLabelStyle}>Events ({ns.events.length})</div>
390
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 4, marginTop: 6 }}>
391
+ {ns.events.map(e => (
392
+ <div
393
+ key={e.action}
394
+ style={{
395
+ display: 'flex',
396
+ alignItems: 'center',
397
+ gap: 6,
398
+ padding: '4px 6px',
399
+ background: '#0b1220',
400
+ borderRadius: 4,
401
+ }}
402
+ >
403
+ <span
404
+ style={{
405
+ fontSize: 9,
406
+ padding: '1px 4px',
407
+ borderRadius: 2,
408
+ background: SEVERITY_BG[e.severity],
409
+ color: '#fde68a',
410
+ flexShrink: 0,
411
+ }}
412
+ >
413
+ {e.severity}
414
+ </span>
415
+ <code style={{ fontSize: 11, color: '#cbd5e1' }}>
416
+ {ns.name}.{e.action}
417
+ </code>
418
+ </div>
419
+ ))}
420
+ </div>
421
+ </div>
422
+ </div>
423
+ );
424
+ }
425
+
426
+ // Scope selected — show scope summary.
427
+ const totalEvents = scope.namespaces.reduce((n, x) => n + x.events.length, 0);
428
+ return (
429
+ <div style={overlayStyle}>
430
+ <div style={{ padding: '14px 16px', borderBottom: '1px solid #1e293b' }}>
431
+ <div style={sectionLabelStyle}>Scope</div>
432
+ <div style={{ fontFamily: 'monospace', fontSize: 14, marginTop: 6 }}>{scope.id}</div>
433
+ <div style={{ fontSize: 11, color: '#94a3b8', marginTop: 8, lineHeight: 1.5 }}>
434
+ {scope.description}
435
+ </div>
436
+ <div style={{ display: 'flex', gap: 16, marginTop: 12, fontSize: 11, color: '#64748b' }}>
437
+ <div>
438
+ <div>{scope.paths.length}</div>
439
+ <div style={sectionLabelStyle}>scope paths</div>
440
+ </div>
441
+ <div>
442
+ <div>{scope.namespaces.length}</div>
443
+ <div style={sectionLabelStyle}>namespaces</div>
444
+ </div>
445
+ <div>
446
+ <div>{totalEvents}</div>
447
+ <div style={sectionLabelStyle}>events</div>
448
+ </div>
449
+ </div>
450
+ </div>
451
+ {scope.paths.length > 0 && (
452
+ <div style={{ padding: '14px 16px', borderBottom: '1px solid #1e293b' }}>
453
+ <div style={sectionLabelStyle}>Scope-level paths ({scope.paths.length})</div>
454
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 4, marginTop: 6 }}>
455
+ {scope.paths.map(p => (
456
+ <code
457
+ key={p}
458
+ style={{
459
+ fontSize: 11,
460
+ color: '#cbd5e1',
461
+ background: '#0b1220',
462
+ padding: '4px 6px',
463
+ borderRadius: 4,
464
+ wordBreak: 'break-all',
465
+ }}
466
+ >
467
+ {p}
468
+ </code>
469
+ ))}
470
+ </div>
471
+ </div>
472
+ )}
473
+ <div style={{ padding: '14px 16px' }}>
474
+ <div style={sectionLabelStyle}>Namespaces</div>
475
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 8, marginTop: 8 }}>
476
+ {scope.namespaces.map(n => (
477
+ <div
478
+ key={n.name}
479
+ style={{ padding: 8, background: '#0b1220', borderRadius: 6 }}
480
+ >
481
+ <div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
482
+ <span
483
+ style={{
484
+ width: 10,
485
+ height: 10,
486
+ borderRadius: 2,
487
+ background: n.color,
488
+ flexShrink: 0,
489
+ }}
490
+ />
491
+ <span style={{ fontFamily: 'monospace', fontSize: 12 }}>{n.name}</span>
492
+ <span style={{ fontSize: 10, color: '#64748b', marginLeft: 'auto' }}>
493
+ {n.events.length} event{n.events.length === 1 ? '' : 's'}
494
+ </span>
495
+ </div>
496
+ <div
497
+ style={{
498
+ fontSize: 10,
499
+ color: '#64748b',
500
+ fontFamily: 'monospace',
501
+ marginTop: 4,
502
+ wordBreak: 'break-all',
503
+ }}
504
+ >
505
+ {n.paths.join(' · ')}
506
+ </div>
507
+ </div>
508
+ ))}
509
+ </div>
510
+ </div>
511
+ </div>
512
+ );
513
+ };
514
+
515
+ // ---------------------------------------------------------------------------
516
+ // Add-to-scope modal
517
+ // ---------------------------------------------------------------------------
518
+
519
+ const AddToScopeModal: React.FC<{
520
+ path: string;
521
+ scopes: readonly MockScope[];
522
+ scopeId: string;
523
+ namespaceName: string;
524
+ onScopeIdChange: (value: string) => void;
525
+ onNamespaceNameChange: (value: string) => void;
526
+ onPickExisting: (scopeId: string, namespaceName: string) => void;
527
+ onSubmit: () => void;
528
+ onClose: () => void;
529
+ }> = ({
530
+ path,
531
+ scopes,
532
+ scopeId,
533
+ namespaceName,
534
+ onScopeIdChange,
535
+ onNamespaceNameChange,
536
+ onPickExisting,
537
+ onSubmit,
538
+ onClose,
539
+ }) => {
540
+ React.useEffect(() => {
541
+ const onKey = (e: KeyboardEvent) => {
542
+ if (e.key === 'Escape') onClose();
543
+ };
544
+ window.addEventListener('keydown', onKey);
545
+ return () => window.removeEventListener('keydown', onKey);
546
+ }, [onClose]);
547
+
548
+ const trimmedScope = scopeId.trim();
549
+ const trimmedNs = namespaceName.trim();
550
+ const canSubmit = trimmedScope.length > 0;
551
+
552
+ // Determine what the submit will do, for the action label.
553
+ const targetScope = scopes.find(s => s.id === trimmedScope);
554
+ const targetNs = trimmedNs
555
+ ? targetScope?.namespaces.find(n => n.name === trimmedNs) ?? null
556
+ : null;
557
+ const alreadyClaimed = trimmedNs
558
+ ? targetNs?.paths.includes(path) ?? false
559
+ : targetScope?.paths.includes(path) ?? false;
560
+
561
+ let actionLabel = 'Add';
562
+ if (alreadyClaimed) actionLabel = 'Already added';
563
+ else if (!targetScope && !trimmedNs) actionLabel = 'Create scope';
564
+ else if (!targetScope) actionLabel = 'Create scope + namespace';
565
+ else if (!trimmedNs) actionLabel = 'Add to scope';
566
+ else if (!targetNs) actionLabel = 'Create namespace';
567
+ else actionLabel = 'Add path';
568
+
569
+ return (
570
+ <div
571
+ onClick={onClose}
572
+ style={{
573
+ position: 'fixed',
574
+ inset: 0,
575
+ background: 'rgba(0, 0, 0, 0.55)',
576
+ display: 'flex',
577
+ alignItems: 'center',
578
+ justifyContent: 'center',
579
+ zIndex: 1000,
580
+ fontFamily: 'system-ui, sans-serif',
581
+ }}
582
+ >
583
+ <div
584
+ onClick={e => e.stopPropagation()}
585
+ style={{
586
+ width: 520,
587
+ maxHeight: 'min(80vh, 700px)',
588
+ display: 'flex',
589
+ flexDirection: 'column',
590
+ background: '#0f172a',
591
+ color: '#e2e8f0',
592
+ borderRadius: 8,
593
+ border: '1px solid #334155',
594
+ boxShadow: '0 20px 60px rgba(0,0,0,0.6)',
595
+ overflow: 'hidden',
596
+ }}
597
+ >
598
+ <div
599
+ style={{
600
+ padding: '14px 18px',
601
+ borderBottom: '1px solid #1e293b',
602
+ display: 'flex',
603
+ justifyContent: 'space-between',
604
+ alignItems: 'flex-start',
605
+ gap: 12,
606
+ }}
607
+ >
608
+ <div>
609
+ <div style={sectionLabelStyle}>Add to scope</div>
610
+ <div
611
+ style={{
612
+ fontFamily: 'monospace',
613
+ fontSize: 12,
614
+ color: '#94a3b8',
615
+ marginTop: 6,
616
+ wordBreak: 'break-all',
617
+ }}
618
+ >
619
+ {path}
620
+ </div>
621
+ </div>
622
+ <button
623
+ onClick={onClose}
624
+ style={{
625
+ background: 'transparent',
626
+ border: 'none',
627
+ color: '#64748b',
628
+ fontSize: 20,
629
+ cursor: 'pointer',
630
+ lineHeight: 1,
631
+ padding: 0,
632
+ }}
633
+ aria-label="Close"
634
+ >
635
+ ×
636
+ </button>
637
+ </div>
638
+
639
+ <div
640
+ style={{
641
+ padding: '14px 18px',
642
+ borderBottom: '1px solid #1e293b',
643
+ display: 'flex',
644
+ flexDirection: 'column',
645
+ gap: 12,
646
+ }}
647
+ >
648
+ <label style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
649
+ <span style={sectionLabelStyle}>Scope</span>
650
+ <input
651
+ type="text"
652
+ value={scopeId}
653
+ list="scope-id-options"
654
+ autoFocus
655
+ placeholder="e.g. principal-view.cli"
656
+ onChange={e => onScopeIdChange(e.target.value)}
657
+ onKeyDown={e => {
658
+ if (e.key === 'Enter' && canSubmit && !alreadyClaimed) onSubmit();
659
+ }}
660
+ style={{
661
+ padding: '8px 10px',
662
+ background: '#0b1220',
663
+ color: '#e2e8f0',
664
+ border: '1px solid #334155',
665
+ borderRadius: 4,
666
+ fontFamily: 'monospace',
667
+ fontSize: 13,
668
+ }}
669
+ />
670
+ <datalist id="scope-id-options">
671
+ {scopes.map(s => (
672
+ <option key={s.id} value={s.id} />
673
+ ))}
674
+ </datalist>
675
+ </label>
676
+
677
+ <label style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
678
+ <span style={sectionLabelStyle}>Namespace (optional)</span>
679
+ <input
680
+ type="text"
681
+ value={namespaceName}
682
+ placeholder="leave blank to add to scope itself"
683
+ onChange={e => onNamespaceNameChange(e.target.value)}
684
+ onKeyDown={e => {
685
+ if (e.key === 'Enter' && canSubmit && !alreadyClaimed) onSubmit();
686
+ }}
687
+ style={{
688
+ padding: '8px 10px',
689
+ background: '#0b1220',
690
+ color: '#e2e8f0',
691
+ border: '1px solid #334155',
692
+ borderRadius: 4,
693
+ fontFamily: 'monospace',
694
+ fontSize: 13,
695
+ }}
696
+ />
697
+ </label>
698
+
699
+ <div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
700
+ <button
701
+ onClick={onClose}
702
+ style={{
703
+ padding: '8px 14px',
704
+ background: 'transparent',
705
+ color: '#cbd5e1',
706
+ border: '1px solid #334155',
707
+ borderRadius: 4,
708
+ cursor: 'pointer',
709
+ fontSize: 13,
710
+ }}
711
+ >
712
+ Cancel
713
+ </button>
714
+ <button
715
+ onClick={onSubmit}
716
+ disabled={!canSubmit || alreadyClaimed}
717
+ style={{
718
+ padding: '8px 14px',
719
+ background: !canSubmit || alreadyClaimed ? '#1e293b' : '#3b82f6',
720
+ color: !canSubmit || alreadyClaimed ? '#475569' : '#ffffff',
721
+ border: '1px solid #334155',
722
+ borderRadius: 4,
723
+ cursor: !canSubmit || alreadyClaimed ? 'not-allowed' : 'pointer',
724
+ fontSize: 13,
725
+ fontWeight: 500,
726
+ }}
727
+ >
728
+ {actionLabel}
729
+ </button>
730
+ </div>
731
+ </div>
732
+
733
+ <div
734
+ style={{
735
+ padding: '14px 18px',
736
+ overflowY: 'auto',
737
+ flex: 1,
738
+ }}
739
+ >
740
+ <div style={sectionLabelStyle}>Existing scopes (click to prefill)</div>
741
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 12, marginTop: 8 }}>
742
+ {scopes.map(scope => (
743
+ <div key={scope.id}>
744
+ <div
745
+ style={{
746
+ fontFamily: 'monospace',
747
+ fontSize: 12,
748
+ color: '#cbd5e1',
749
+ marginBottom: 6,
750
+ }}
751
+ >
752
+ {scope.id}
753
+ </div>
754
+ <div style={{ display: 'flex', flexWrap: 'wrap', gap: 4 }}>
755
+ <button
756
+ onClick={() => onPickExisting(scope.id, '')}
757
+ title={
758
+ scope.paths.includes(path)
759
+ ? 'Scope already claims this path'
760
+ : 'Prefill (scope-level)'
761
+ }
762
+ style={{
763
+ fontSize: 11,
764
+ padding: '3px 7px',
765
+ background: scope.paths.includes(path) ? '#0f172a' : '#1e293b',
766
+ color: scope.paths.includes(path) ? '#475569' : '#cbd5e1',
767
+ border: '1px dashed #475569',
768
+ borderRadius: 3,
769
+ cursor: 'pointer',
770
+ display: 'flex',
771
+ alignItems: 'center',
772
+ gap: 5,
773
+ fontStyle: 'italic',
774
+ opacity: scope.paths.includes(path) ? 0.6 : 1,
775
+ }}
776
+ >
777
+ (scope-level)
778
+ {scope.paths.includes(path) && (
779
+ <span style={{ marginLeft: 4, fontSize: 9 }}>✓</span>
780
+ )}
781
+ </button>
782
+ {scope.namespaces.map(ns => {
783
+ const claims = ns.paths.includes(path);
784
+ return (
785
+ <button
786
+ key={ns.name}
787
+ onClick={() => onPickExisting(scope.id, ns.name)}
788
+ title={claims ? 'Already claims this path' : 'Prefill inputs'}
789
+ style={{
790
+ fontSize: 11,
791
+ padding: '3px 7px',
792
+ background: claims ? '#0f172a' : '#1e293b',
793
+ color: claims ? '#475569' : '#e2e8f0',
794
+ border: '1px solid #334155',
795
+ borderRadius: 3,
796
+ cursor: 'pointer',
797
+ display: 'flex',
798
+ alignItems: 'center',
799
+ gap: 5,
800
+ opacity: claims ? 0.6 : 1,
801
+ }}
802
+ >
803
+ <span
804
+ style={{
805
+ width: 8,
806
+ height: 8,
807
+ borderRadius: 2,
808
+ background: ns.color,
809
+ flexShrink: 0,
810
+ }}
811
+ />
812
+ {ns.name}
813
+ {claims && <span style={{ marginLeft: 4, fontSize: 9 }}>✓</span>}
814
+ </button>
815
+ );
816
+ })}
817
+ </div>
818
+ </div>
819
+ ))}
820
+ </div>
821
+ </div>
822
+ </div>
823
+ </div>
824
+ );
825
+ };
826
+
827
+ // ---------------------------------------------------------------------------
828
+ // Single-scope explorer
829
+ // ---------------------------------------------------------------------------
830
+
831
+ type AuditMode = 'off' | 'uncovered' | 'covered';
832
+
833
+ const ALL_BUILDINGS = (electronAppCityData as CityData).buildings;
834
+
835
+ /**
836
+ * The electron-app city is rooted at `electron-app/` in the JSON data, but
837
+ * scope/namespace paths are authored relative to the package root (matching
838
+ * how principal-view canvases are stored). These helpers convert between the
839
+ * two representations.
840
+ */
841
+ const PACKAGE_ROOT = 'electron-app/';
842
+
843
+ function toScopePath(cityPath: string): string {
844
+ let p = cityPath.endsWith('/') ? cityPath.slice(0, -1) : cityPath;
845
+ if (p.startsWith(PACKAGE_ROOT)) p = p.slice(PACKAGE_ROOT.length);
846
+ return p;
847
+ }
848
+
849
+ function toCityPath(scopePath: string): string {
850
+ return scopePath.startsWith(PACKAGE_ROOT) ? scopePath : PACKAGE_ROOT + scopePath;
851
+ }
852
+
853
+ const NAMESPACE_PALETTE = [
854
+ '#22c55e',
855
+ '#3b82f6',
856
+ '#f59e0b',
857
+ '#ec4899',
858
+ '#8b5cf6',
859
+ '#06b6d4',
860
+ '#ef4444',
861
+ '#14b8a6',
862
+ '#a855f7',
863
+ '#eab308',
864
+ ];
865
+
866
+ function pickNamespaceColor(scopes: readonly MockScope[]): string {
867
+ const used = new Set(scopes.flatMap(s => s.namespaces.map(n => n.color)));
868
+ return NAMESPACE_PALETTE.find(c => !used.has(c)) ?? NAMESPACE_PALETTE[scopes.length % NAMESPACE_PALETTE.length];
869
+ }
870
+
871
+ /**
872
+ * Build highlight layers for a scope: one fill layer per namespace plus a
873
+ * border-only layer for scope-level paths. Priority is path depth (longest-
874
+ * prefix wins) per the partition convention in docs/scope-namespace-overlay.md.
875
+ */
876
+ function buildLayersForScope(scope: MockScope): HighlightLayer[] {
877
+ const layers: HighlightLayer[] = scope.namespaces.map(ns => {
878
+ const maxDepth = Math.max(1, ...ns.paths.map(p => p.split('/').length));
879
+ return {
880
+ id: `${scope.id}::${ns.name}`,
881
+ name: ns.name,
882
+ enabled: true,
883
+ color: ns.color,
884
+ opacity: 0.55,
885
+ priority: maxDepth,
886
+ items: ns.paths.map(p => ({
887
+ path: toCityPath(p),
888
+ type: 'directory' as const,
889
+ renderStrategy: 'fill' as const,
890
+ })),
891
+ };
892
+ });
893
+ if (scope.paths.length > 0) {
894
+ layers.push({
895
+ id: `${scope.id}::__scope__`,
896
+ name: `${scope.id} (scope-level)`,
897
+ enabled: true,
898
+ color: '#64748b',
899
+ opacity: 0.4,
900
+ priority: 0,
901
+ items: scope.paths.map(p => ({
902
+ path: toCityPath(p),
903
+ type: 'directory' as const,
904
+ renderStrategy: 'fill' as const,
905
+ })),
906
+ });
907
+ }
908
+ return layers;
909
+ }
910
+
911
+ const SingleScopeTemplate: React.FC = () => {
912
+ const [scopes, setScopes] = React.useState<MockScope[]>(loadScopesFromStorage);
913
+
914
+ React.useEffect(() => {
915
+ saveScopesToStorage(scopes);
916
+ }, [scopes]);
917
+ const [focusDirectory, setFocusDirectory] = React.useState<string | null>(null);
918
+ const [scopeSelection, setScopeSelection] = React.useState<ScopeTreeSelection | null>(null);
919
+ const [auditMode, setAuditMode] = React.useState<AuditMode>('off');
920
+ const [showAddModal, setShowAddModal] = React.useState(false);
921
+ const [modalScopeId, setModalScopeId] = React.useState('');
922
+ const [modalNamespaceName, setModalNamespaceName] = React.useState('');
923
+ const [activeTab, setActiveTab] = React.useState<'files' | 'scopes'>('files');
924
+
925
+ // Coverage derived from current scope state.
926
+ const claimedPaths = React.useMemo(
927
+ () =>
928
+ Array.from(
929
+ new Set(
930
+ scopes.flatMap(s => [...s.paths, ...s.namespaces.flatMap(ns => ns.paths)]),
931
+ ),
932
+ ),
933
+ [scopes],
934
+ );
935
+ const isPathCovered = React.useCallback(
936
+ (cityPath: string) => {
937
+ const candidate = toScopePath(cityPath);
938
+ for (const claimed of claimedPaths) {
939
+ if (candidate === claimed || candidate.startsWith(claimed + '/')) return true;
940
+ }
941
+ return false;
942
+ },
943
+ [claimedPaths],
944
+ );
945
+ const { uncoveredFiles, coveredFiles } = React.useMemo(() => {
946
+ const u: typeof ALL_BUILDINGS = [];
947
+ const c: typeof ALL_BUILDINGS = [];
948
+ for (const b of ALL_BUILDINGS) (isPathCovered(b.path) ? c : u).push(b);
949
+ return { uncoveredFiles: u, coveredFiles: c };
950
+ }, [isPathCovered]);
951
+
952
+ const auditHighlightLayers = React.useMemo(() => {
953
+ if (auditMode === 'uncovered') return createFileColorHighlightLayers(uncoveredFiles);
954
+ if (auditMode === 'covered') return createFileColorHighlightLayers(coveredFiles);
955
+ return undefined;
956
+ }, [auditMode, uncoveredFiles, coveredFiles]);
957
+
958
+ const totalFiles = ALL_BUILDINGS.length;
959
+ const uncoveredCount = uncoveredFiles.length;
960
+ const coveredCount = coveredFiles.length;
961
+
962
+ const { model: treeModel } = useFileTree({
963
+ paths: ELECTRON_PATHS,
964
+ search: true,
965
+ initialExpandedPaths: ['electron-app', 'electron-app/src', 'electron-app/src/renderer'],
966
+ onSelectionChange: paths => {
967
+ const selected = paths[0];
968
+ if (!selected) {
969
+ setFocusDirectory(null);
970
+ return;
971
+ }
972
+ // Selecting a directory focuses the city on it; selecting a file focuses
973
+ // the file's parent directory (closest ancestor that exists as a district).
974
+ if (ELECTRON_DIRECTORIES.has(selected)) {
975
+ setFocusDirectory(selected);
976
+ return;
977
+ }
978
+ const parts = selected.split('/');
979
+ while (parts.length > 1) {
980
+ parts.pop();
981
+ const candidate = parts.join('/');
982
+ if (ELECTRON_DIRECTORIES.has(candidate)) {
983
+ setFocusDirectory(candidate);
984
+ return;
985
+ }
986
+ }
987
+ setFocusDirectory(null);
988
+ },
989
+ });
990
+ const selectedPaths = useFileTreeSelection(treeModel);
991
+
992
+ // Filter the file tree by audit mode so the tree mirrors what's highlighted.
993
+ const filteredFilePaths = React.useMemo(() => {
994
+ if (auditMode === 'uncovered') return uncoveredFiles.map(b => b.path).sort();
995
+ if (auditMode === 'covered') return coveredFiles.map(b => b.path).sort();
996
+ return ELECTRON_PATHS;
997
+ }, [auditMode, uncoveredFiles, coveredFiles]);
998
+
999
+ const isFirstFileTreeSync = React.useRef(true);
1000
+ React.useEffect(() => {
1001
+ if (isFirstFileTreeSync.current) {
1002
+ isFirstFileTreeSync.current = false;
1003
+ return;
1004
+ }
1005
+ treeModel.resetPaths(filteredFilePaths);
1006
+ }, [treeModel, filteredFilePaths]);
1007
+
1008
+ const scopeTreePaths = React.useMemo(() => buildScopeTreePaths(scopes), [scopes]);
1009
+ const initialScopeTreePaths = React.useRef(scopeTreePaths);
1010
+ const initialExpandedScopeIds = React.useRef(scopes.map(s => s.id));
1011
+ const { model: scopeTreeModel } = useFileTree({
1012
+ paths: initialScopeTreePaths.current,
1013
+ search: true,
1014
+ initialExpandedPaths: initialExpandedScopeIds.current,
1015
+ onSelectionChange: paths => {
1016
+ const selected = paths[0];
1017
+ if (!selected) {
1018
+ setScopeSelection(null);
1019
+ return;
1020
+ }
1021
+ const parsed = parseScopeTreePath(selected);
1022
+ setScopeSelection(parsed);
1023
+
1024
+ // Selecting a namespace or event also focuses the city on the namespace's
1025
+ // first declared path; selecting a bare scope clears the focus.
1026
+ if (parsed.namespaceName) {
1027
+ const scope = scopes.find(s => s.id === parsed.scopeId);
1028
+ const ns = scope?.namespaces.find(n => n.name === parsed.namespaceName);
1029
+ if (ns?.paths[0]) setFocusDirectory(toCityPath(ns.paths[0]));
1030
+ } else {
1031
+ setFocusDirectory(null);
1032
+ }
1033
+ },
1034
+ });
1035
+
1036
+ // Keep the scope tree's paths in sync as scopes mutate (the model is created
1037
+ // once; later option changes need resetPaths per @pierre/trees docs).
1038
+ const isFirstScopeTreeSync = React.useRef(true);
1039
+ const pendingExpand = React.useRef<string[]>([]);
1040
+ React.useEffect(() => {
1041
+ if (isFirstScopeTreeSync.current) {
1042
+ isFirstScopeTreeSync.current = false;
1043
+ return;
1044
+ }
1045
+ scopeTreeModel.resetPaths(scopeTreePaths);
1046
+ for (const dirPath of pendingExpand.current) {
1047
+ const item = scopeTreeModel.getItem(dirPath);
1048
+ if (item && item.isDirectory()) item.expand();
1049
+ }
1050
+ pendingExpand.current = [];
1051
+ }, [scopeTreeModel, scopeTreePaths]);
1052
+
1053
+ // Track which scope/namespace nodes are expanded in the scope tree. The
1054
+ // city panels mirror this: a collapsed scope shows one umbrella tile, an
1055
+ // expanded scope shows per-namespace tiles, and an expanded namespace
1056
+ // hides its tile so the buildings underneath are visible.
1057
+ const treeExpansion = useFileTreeSelector(
1058
+ scopeTreeModel,
1059
+ React.useCallback(
1060
+ (model: FileTree) => {
1061
+ const expandedScopes = new Set<string>();
1062
+ const expandedNamespaces = new Set<string>();
1063
+ for (const scope of scopes) {
1064
+ const scopeItem = model.getItem(scope.id);
1065
+ if (scopeItem?.isDirectory() && scopeItem.isExpanded()) {
1066
+ expandedScopes.add(scope.id);
1067
+ for (const ns of scope.namespaces) {
1068
+ const nsKey = `${scope.id}/${ns.name}`;
1069
+ const nsItem = model.getItem(nsKey);
1070
+ if (nsItem?.isDirectory() && nsItem.isExpanded()) {
1071
+ expandedNamespaces.add(nsKey);
1072
+ }
1073
+ }
1074
+ }
1075
+ }
1076
+ return { expandedScopes, expandedNamespaces };
1077
+ },
1078
+ [scopes],
1079
+ ),
1080
+ React.useCallback((prev, next) => {
1081
+ if (prev.expandedScopes.size !== next.expandedScopes.size) return false;
1082
+ for (const k of prev.expandedScopes) if (!next.expandedScopes.has(k)) return false;
1083
+ if (prev.expandedNamespaces.size !== next.expandedNamespaces.size) return false;
1084
+ for (const k of prev.expandedNamespaces) if (!next.expandedNamespaces.has(k)) return false;
1085
+ return true;
1086
+ }, []),
1087
+ );
1088
+
1089
+ // Resolve the current scope tree selection into the underlying objects.
1090
+ const scopeInfo = React.useMemo(() => {
1091
+ if (!scopeSelection) return null;
1092
+ const scope = scopes.find(s => s.id === scopeSelection.scopeId);
1093
+ if (!scope) return null;
1094
+ const ns = scopeSelection.namespaceName
1095
+ ? scope.namespaces.find(n => n.name === scopeSelection.namespaceName) ?? null
1096
+ : null;
1097
+ const ev =
1098
+ ns && scopeSelection.eventAction
1099
+ ? ns.events.find(e => e.action === scopeSelection.eventAction) ?? null
1100
+ : null;
1101
+ return { scope, ns, ev };
1102
+ }, [scopeSelection, scopes]);
1103
+
1104
+ const selectedFilePath = selectedPaths[0] ?? null;
1105
+
1106
+ // City highlight layers derive from the active tab:
1107
+ // files tab → audit layers (uncovered / covered / off)
1108
+ // scopes tab → selected scope's namespace fills (+ scope-level borders)
1109
+ const cityHighlightLayers = React.useMemo(() => {
1110
+ if (activeTab === 'scopes') {
1111
+ return scopeInfo ? buildLayersForScope(scopeInfo.scope) : undefined;
1112
+ }
1113
+ return auditHighlightLayers;
1114
+ }, [activeTab, scopeInfo, auditHighlightLayers]);
1115
+
1116
+ // Elevated scope panels — driven by the scope tree's expansion state.
1117
+ // - Collapsed scope → one gray umbrella tile per scope path.
1118
+ // - Expanded scope, collapsed namespace → colored tile per namespace path.
1119
+ // - Expanded namespace → no tile (buildings show through).
1120
+ const cityElevatedPanels = React.useMemo<ElevatedScopePanel[] | undefined>(() => {
1121
+ if (activeTab !== 'scopes') return undefined;
1122
+ const panels: ElevatedScopePanel[] = [];
1123
+
1124
+ for (const scope of scopes) {
1125
+ const isScopeExpanded = treeExpansion.expandedScopes.has(scope.id);
1126
+
1127
+ if (!isScopeExpanded) {
1128
+ const onClick = () => {
1129
+ const item = scopeTreeModel.getItem(scope.id);
1130
+ if (item?.isDirectory()) item.toggle();
1131
+ };
1132
+ for (const sp of scope.paths) {
1133
+ const district = ELECTRON_DISTRICTS_BY_PATH.get(toCityPath(sp));
1134
+ if (!district) continue;
1135
+ panels.push({
1136
+ id: `${scope.id}::scope::${sp}`,
1137
+ color: '#64748b',
1138
+ height: 4,
1139
+ thickness: 2,
1140
+ bounds: district.worldBounds,
1141
+ label: scope.id,
1142
+ onClick,
1143
+ });
1144
+ }
1145
+ continue;
1146
+ }
1147
+
1148
+ for (const ns of scope.namespaces) {
1149
+ const nsKey = `${scope.id}/${ns.name}`;
1150
+ if (treeExpansion.expandedNamespaces.has(nsKey)) continue;
1151
+
1152
+ const onClick = () => {
1153
+ const item = scopeTreeModel.getItem(nsKey);
1154
+ if (item?.isDirectory()) item.toggle();
1155
+ };
1156
+ for (const np of ns.paths) {
1157
+ const district = ELECTRON_DISTRICTS_BY_PATH.get(toCityPath(np));
1158
+ if (!district) continue;
1159
+ panels.push({
1160
+ id: `${scope.id}::${ns.name}::${np}`,
1161
+ color: ns.color,
1162
+ height: 4,
1163
+ thickness: 2,
1164
+ bounds: district.worldBounds,
1165
+ label: ns.name,
1166
+ onClick,
1167
+ });
1168
+ }
1169
+ }
1170
+ }
1171
+
1172
+ return panels.length > 0 ? panels : undefined;
1173
+ }, [activeTab, scopes, scopeTreeModel, treeExpansion]);
1174
+
1175
+ // Track which folders are expanded in the file tree. The file-tree tab's
1176
+ // elevated panels mirror this: a collapsed folder shows one umbrella tile
1177
+ // covering every descendant district; expanding the folder reveals its
1178
+ // sub-folder tiles (or the buildings themselves at the leaves).
1179
+ const folderTreeExpansion = useFileTreeSelector(
1180
+ treeModel,
1181
+ React.useCallback((model: FileTree) => {
1182
+ const expanded = new Set<string>();
1183
+ for (const dir of ELECTRON_DIRECTORIES) {
1184
+ const item = model.getItem(dir);
1185
+ if (item?.isDirectory() && item.isExpanded()) expanded.add(dir);
1186
+ }
1187
+ return { expanded };
1188
+ }, []),
1189
+ React.useCallback((prev, next) => {
1190
+ if (prev.expanded.size !== next.expanded.size) return false;
1191
+ for (const k of prev.expanded) if (!next.expanded.has(k)) return false;
1192
+ return true;
1193
+ }, []),
1194
+ );
1195
+
1196
+ // Elevated folder panels — driven by the file tree's expansion state.
1197
+ // Recursively walks from the top-level folders: an expanded folder
1198
+ // descends into its child folders; a collapsed folder emits one panel
1199
+ // covering the union of all descendant district bounds.
1200
+ const folderElevatedPanels = React.useMemo<ElevatedScopePanel[] | undefined>(() => {
1201
+ if (activeTab !== 'files') return undefined;
1202
+ const panels: ElevatedScopePanel[] = [];
1203
+ const walk = (folderPath: string): void => {
1204
+ if (folderTreeExpansion.expanded.has(folderPath)) {
1205
+ const children = ELECTRON_FOLDER_CHILDREN.get(folderPath) ?? [];
1206
+ for (const child of children) walk(child);
1207
+ return;
1208
+ }
1209
+ const bounds = ELECTRON_FOLDER_BOUNDS.get(folderPath);
1210
+ if (!bounds) return;
1211
+ const label = folderPath.split('/').pop() ?? folderPath;
1212
+ const fileCount = ELECTRON_FOLDER_FILE_COUNTS.get(folderPath) ?? 0;
1213
+ // sqrt growth keeps ~10x file deltas inside a ~3x label-size delta;
1214
+ // the renderer still clamps to the tile footprint.
1215
+ const labelSize = Math.max(12, Math.min(240, 8 + Math.sqrt(fileCount) * 5));
1216
+ panels.push({
1217
+ id: `folder::${folderPath}`,
1218
+ color: hashFolderColor(folderPath),
1219
+ height: 4,
1220
+ thickness: 2,
1221
+ bounds,
1222
+ label,
1223
+ labelSize,
1224
+ onClick: () => {
1225
+ const item = treeModel.getItem(folderPath);
1226
+ if (item?.isDirectory()) item.toggle();
1227
+ },
1228
+ });
1229
+ };
1230
+ for (const top of ELECTRON_FOLDER_CHILDREN.get('') ?? []) walk(top);
1231
+ return panels.length > 0 ? panels : undefined;
1232
+ }, [activeTab, treeModel, folderTreeExpansion]);
1233
+
1234
+ const openAddModal = React.useCallback((prefillScopeId?: string) => {
1235
+ setModalScopeId(prefillScopeId ?? '');
1236
+ setModalNamespaceName('');
1237
+ setShowAddModal(true);
1238
+ }, []);
1239
+
1240
+ // Scopes that already cover the selected file-tree path (via scope-level
1241
+ // paths or any namespace path).
1242
+ const coveringScopes = React.useMemo(() => {
1243
+ if (!selectedFilePath) return [] as MockScope[];
1244
+ const sp = toScopePath(selectedFilePath);
1245
+ return scopes.filter(scope => {
1246
+ if (scope.paths.some(p => sp === p || sp.startsWith(p + '/'))) return true;
1247
+ return scope.namespaces.some(ns =>
1248
+ ns.paths.some(p => sp === p || sp.startsWith(p + '/')),
1249
+ );
1250
+ });
1251
+ }, [selectedFilePath, scopes]);
1252
+
1253
+ const submitAddToScope = React.useCallback(() => {
1254
+ if (!selectedFilePath) return;
1255
+ const path = toScopePath(selectedFilePath);
1256
+ const scopeId = modalScopeId.trim();
1257
+ const namespaceName = modalNamespaceName.trim();
1258
+ if (!scopeId) return;
1259
+
1260
+ // Queue branches to auto-expand once the tree re-resets.
1261
+ pendingExpand.current = namespaceName ? [scopeId, `${scopeId}/${namespaceName}`] : [scopeId];
1262
+
1263
+ // Invariant: a scope's `paths` must cover every path claimed by any of
1264
+ // its namespaces. If `path` isn't already covered by scope.paths, we add
1265
+ // it.
1266
+ const ensureScopePathCovers = (scope: MockScope): MockScope => {
1267
+ const covered = scope.paths.some(p => path === p || path.startsWith(p + '/'));
1268
+ if (covered) return scope;
1269
+ return { ...scope, paths: [...scope.paths, path] };
1270
+ };
1271
+
1272
+ setScopes(prev => {
1273
+ const scopeIdx = prev.findIndex(s => s.id === scopeId);
1274
+
1275
+ // Existing scope.
1276
+ if (scopeIdx >= 0) {
1277
+ const scope = prev[scopeIdx];
1278
+
1279
+ // No namespace given → add to scope-level paths.
1280
+ if (!namespaceName) {
1281
+ if (scope.paths.includes(path)) return prev;
1282
+ const next = [...prev];
1283
+ next[scopeIdx] = { ...scope, paths: [...scope.paths, path] };
1284
+ return next;
1285
+ }
1286
+
1287
+ const nsIdx = scope.namespaces.findIndex(n => n.name === namespaceName);
1288
+
1289
+ // Existing namespace — push the path if not already there.
1290
+ if (nsIdx >= 0) {
1291
+ const ns = scope.namespaces[nsIdx];
1292
+ if (ns.paths.includes(path)) return prev;
1293
+ const newNs = { ...ns, paths: [...ns.paths, path] };
1294
+ const newNamespaces = [...scope.namespaces];
1295
+ newNamespaces[nsIdx] = newNs;
1296
+ const next = [...prev];
1297
+ next[scopeIdx] = ensureScopePathCovers({ ...scope, namespaces: newNamespaces });
1298
+ return next;
1299
+ }
1300
+
1301
+ // New namespace under existing scope.
1302
+ const newNs: MockNamespace = {
1303
+ name: namespaceName,
1304
+ description: '(new namespace)',
1305
+ color: pickNamespaceColor(prev),
1306
+ paths: [path],
1307
+ events: [],
1308
+ };
1309
+ const next = [...prev];
1310
+ next[scopeIdx] = ensureScopePathCovers({
1311
+ ...scope,
1312
+ namespaces: [...scope.namespaces, newNs],
1313
+ });
1314
+ return next;
1315
+ }
1316
+
1317
+ // Brand-new scope. Scope paths are required, so the path always seeds
1318
+ // scope.paths even when a namespace is also being created.
1319
+ if (!namespaceName) {
1320
+ return [
1321
+ ...prev,
1322
+ {
1323
+ id: scopeId,
1324
+ name: scopeId,
1325
+ description: '(new scope)',
1326
+ paths: [path],
1327
+ namespaces: [],
1328
+ },
1329
+ ];
1330
+ }
1331
+ const newNs: MockNamespace = {
1332
+ name: namespaceName,
1333
+ description: '(new namespace)',
1334
+ color: pickNamespaceColor(prev),
1335
+ paths: [path],
1336
+ events: [],
1337
+ };
1338
+ return [
1339
+ ...prev,
1340
+ {
1341
+ id: scopeId,
1342
+ name: scopeId,
1343
+ description: '(new scope)',
1344
+ paths: [path],
1345
+ namespaces: [newNs],
1346
+ },
1347
+ ];
1348
+ });
1349
+
1350
+ setShowAddModal(false);
1351
+ }, [selectedFilePath, modalScopeId, modalNamespaceName]);
1352
+
1353
+ return (
1354
+ <div style={{ height: '100vh', display: 'flex', background: '#0f172a' }}>
1355
+ {/* Left pane — tabbed: file tree | scopes tree */}
1356
+ <div
1357
+ style={{
1358
+ width: 320,
1359
+ flexShrink: 0,
1360
+ display: 'flex',
1361
+ flexDirection: 'column',
1362
+ borderRight: '1px solid #1e293b',
1363
+ background: '#0b1220',
1364
+ color: '#e2e8f0',
1365
+ fontFamily: 'system-ui, sans-serif',
1366
+ }}
1367
+ >
1368
+ {/* Tab strip */}
1369
+ <div
1370
+ style={{
1371
+ display: 'flex',
1372
+ borderBottom: '1px solid #1e293b',
1373
+ background: '#0f172a',
1374
+ }}
1375
+ >
1376
+ {(
1377
+ [
1378
+ { id: 'files' as const, label: 'File tree', accent: '#3b82f6' },
1379
+ { id: 'scopes' as const, label: 'Scopes', accent: '#a855f7' },
1380
+ ]
1381
+ ).map(tab => {
1382
+ const active = activeTab === tab.id;
1383
+ return (
1384
+ <button
1385
+ key={tab.id}
1386
+ onClick={() => setActiveTab(tab.id)}
1387
+ style={{
1388
+ flex: 1,
1389
+ padding: '10px 12px',
1390
+ background: active ? '#0b1220' : 'transparent',
1391
+ color: active ? '#e2e8f0' : '#64748b',
1392
+ border: 'none',
1393
+ borderBottom: `2px solid ${active ? tab.accent : 'transparent'}`,
1394
+ cursor: 'pointer',
1395
+ fontSize: 12,
1396
+ fontWeight: active ? 600 : 400,
1397
+ fontFamily: 'system-ui, sans-serif',
1398
+ }}
1399
+ >
1400
+ {tab.label}
1401
+ </button>
1402
+ );
1403
+ })}
1404
+ </div>
1405
+
1406
+ {activeTab === 'files' ? (
1407
+ <>
1408
+ <div
1409
+ style={{
1410
+ padding: '12px 16px',
1411
+ borderBottom: '1px solid #1e293b',
1412
+ fontSize: 11,
1413
+ color: '#64748b',
1414
+ textTransform: 'uppercase',
1415
+ letterSpacing: 0.5,
1416
+ }}
1417
+ >
1418
+ Selection
1419
+ <div
1420
+ style={{
1421
+ marginTop: 6,
1422
+ fontFamily: 'monospace',
1423
+ fontSize: 11,
1424
+ color: '#94a3b8',
1425
+ textTransform: 'none',
1426
+ letterSpacing: 0,
1427
+ minHeight: 14,
1428
+ wordBreak: 'break-all',
1429
+ }}
1430
+ >
1431
+ {selectedFilePath ?? '(no selection)'}
1432
+ </div>
1433
+ {selectedFilePath && (
1434
+ <div
1435
+ style={{
1436
+ marginTop: 8,
1437
+ textTransform: 'none',
1438
+ letterSpacing: 0,
1439
+ fontFamily: 'system-ui, sans-serif',
1440
+ }}
1441
+ >
1442
+ {coveringScopes.length > 0 && (
1443
+ <div
1444
+ style={{
1445
+ fontSize: 11,
1446
+ color: '#94a3b8',
1447
+ marginBottom: 6,
1448
+ }}
1449
+ >
1450
+ In scope:{' '}
1451
+ {coveringScopes.map((s, i) => (
1452
+ <React.Fragment key={s.id}>
1453
+ {i > 0 && ', '}
1454
+ <code style={{ color: '#cbd5e1' }}>{s.id}</code>
1455
+ </React.Fragment>
1456
+ ))}
1457
+ </div>
1458
+ )}
1459
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
1460
+ {coveringScopes.map(scope => (
1461
+ <button
1462
+ key={scope.id}
1463
+ onClick={() => openAddModal(scope.id)}
1464
+ style={{
1465
+ fontSize: 11,
1466
+ padding: '4px 10px',
1467
+ background: '#1e293b',
1468
+ color: '#e2e8f0',
1469
+ border: '1px solid #334155',
1470
+ borderRadius: 4,
1471
+ cursor: 'pointer',
1472
+ textAlign: 'left',
1473
+ }}
1474
+ >
1475
+ + Add event namespace to <code>{scope.id}</code>
1476
+ </button>
1477
+ ))}
1478
+ <button
1479
+ onClick={() => openAddModal()}
1480
+ style={{
1481
+ fontSize: 11,
1482
+ padding: '4px 10px',
1483
+ background: coveringScopes.length === 0 ? '#1e293b' : 'transparent',
1484
+ color: coveringScopes.length === 0 ? '#e2e8f0' : '#94a3b8',
1485
+ border: '1px solid #334155',
1486
+ borderRadius: 4,
1487
+ cursor: 'pointer',
1488
+ textAlign: 'left',
1489
+ }}
1490
+ >
1491
+ {coveringScopes.length === 0
1492
+ ? '+ Add to scope'
1493
+ : '+ Add to a different scope'}
1494
+ </button>
1495
+ </div>
1496
+ </div>
1497
+ )}
1498
+ </div>
1499
+ <div
1500
+ style={{
1501
+ padding: '10px 12px',
1502
+ borderBottom: '1px solid #1e293b',
1503
+ display: 'flex',
1504
+ flexDirection: 'column',
1505
+ gap: 6,
1506
+ }}
1507
+ >
1508
+ <div style={sectionLabelStyle}>Audit filter</div>
1509
+ <div
1510
+ style={{
1511
+ display: 'flex',
1512
+ border: '1px solid #334155',
1513
+ borderRadius: 4,
1514
+ overflow: 'hidden',
1515
+ fontFamily: 'system-ui, sans-serif',
1516
+ fontSize: 12,
1517
+ }}
1518
+ >
1519
+ {(
1520
+ [
1521
+ { mode: 'off' as const, label: 'Off', count: totalFiles, accent: '#475569' },
1522
+ {
1523
+ mode: 'uncovered' as const,
1524
+ label: 'Uncovered',
1525
+ count: uncoveredCount,
1526
+ accent: '#dc2626',
1527
+ },
1528
+ {
1529
+ mode: 'covered' as const,
1530
+ label: 'Covered',
1531
+ count: coveredCount,
1532
+ accent: '#16a34a',
1533
+ },
1534
+ ]
1535
+ ).map(({ mode, label, count, accent }, i) => {
1536
+ const active = auditMode === mode;
1537
+ return (
1538
+ <button
1539
+ key={mode}
1540
+ onClick={() => setAuditMode(mode)}
1541
+ style={{
1542
+ flex: 1,
1543
+ padding: '6px 4px',
1544
+ background: active ? accent : 'transparent',
1545
+ border: 'none',
1546
+ borderLeft: i === 0 ? 'none' : '1px solid #334155',
1547
+ color: active ? '#ffffff' : '#cbd5e1',
1548
+ fontWeight: active ? 500 : 400,
1549
+ cursor: 'pointer',
1550
+ display: 'flex',
1551
+ alignItems: 'center',
1552
+ justifyContent: 'center',
1553
+ gap: 6,
1554
+ minWidth: 0,
1555
+ }}
1556
+ >
1557
+ <span style={{ overflow: 'hidden', textOverflow: 'ellipsis' }}>{label}</span>
1558
+ <span
1559
+ style={{
1560
+ fontSize: 10,
1561
+ color: active ? '#fef3c7' : '#64748b',
1562
+ fontWeight: 400,
1563
+ }}
1564
+ >
1565
+ {count}
1566
+ </span>
1567
+ </button>
1568
+ );
1569
+ })}
1570
+ </div>
1571
+ </div>
1572
+ <FileTree
1573
+ model={treeModel}
1574
+ style={
1575
+ {
1576
+ flex: 1,
1577
+ minHeight: 0,
1578
+ '--trees-theme-list-active-selection-bg':
1579
+ 'color-mix(in oklab, #3b82f6 28%, transparent)',
1580
+ '--trees-theme-list-hover-bg':
1581
+ 'color-mix(in oklab, #3b82f6 14%, transparent)',
1582
+ } as React.CSSProperties
1583
+ }
1584
+ />
1585
+ </>
1586
+ ) : (
1587
+ <>
1588
+ <div
1589
+ style={{
1590
+ padding: '12px 16px',
1591
+ borderBottom: '1px solid #1e293b',
1592
+ fontSize: 11,
1593
+ color: '#64748b',
1594
+ textTransform: 'uppercase',
1595
+ letterSpacing: 0.5,
1596
+ }}
1597
+ >
1598
+ Scopes / namespaces / events
1599
+ <div
1600
+ style={{
1601
+ marginTop: 6,
1602
+ fontSize: 11,
1603
+ color: '#94a3b8',
1604
+ textTransform: 'none',
1605
+ letterSpacing: 0,
1606
+ lineHeight: 1.4,
1607
+ }}
1608
+ >
1609
+ Selecting a scope highlights its namespace coverage on the map.
1610
+ </div>
1611
+ </div>
1612
+ <FileTree
1613
+ model={scopeTreeModel}
1614
+ style={
1615
+ {
1616
+ flex: 1,
1617
+ minHeight: 0,
1618
+ '--trees-theme-list-active-selection-bg':
1619
+ 'color-mix(in oklab, #a855f7 28%, transparent)',
1620
+ '--trees-theme-list-hover-bg':
1621
+ 'color-mix(in oklab, #a855f7 14%, transparent)',
1622
+ } as React.CSSProperties
1623
+ }
1624
+ />
1625
+ </>
1626
+ )}
1627
+ </div>
1628
+
1629
+ {/* Right pane — city + scope panel */}
1630
+ <div style={{ flex: 1, position: 'relative', minWidth: 0 }}>
1631
+ <FileCity3D
1632
+ cityData={electronAppCityData as CityData}
1633
+ height="100%"
1634
+ width="100%"
1635
+ heightScaling="linear"
1636
+ linearScale={0.5}
1637
+ focusDirectory={focusDirectory}
1638
+ highlightLayers={cityHighlightLayers}
1639
+ elevatedScopePanels={cityElevatedPanels ?? folderElevatedPanels}
1640
+ animation={{
1641
+ startFlat: true,
1642
+ autoStartDelay: null,
1643
+ staggerDelay: 5,
1644
+ tension: 150,
1645
+ friction: 16,
1646
+ }}
1647
+ showControls={true}
1648
+ />
1649
+
1650
+ {/* Info overlay — driven by scope tree selection */}
1651
+ {scopeInfo && <ScopeInfoOverlay info={scopeInfo} />}
1652
+ </div>
1653
+
1654
+ {/* Add-to-scope modal */}
1655
+ {showAddModal && selectedFilePath && (
1656
+ <AddToScopeModal
1657
+ path={toScopePath(selectedFilePath)}
1658
+ scopes={scopes}
1659
+ scopeId={modalScopeId}
1660
+ namespaceName={modalNamespaceName}
1661
+ onScopeIdChange={setModalScopeId}
1662
+ onNamespaceNameChange={setModalNamespaceName}
1663
+ onPickExisting={(s, n) => {
1664
+ setModalScopeId(s);
1665
+ setModalNamespaceName(n);
1666
+ }}
1667
+ onSubmit={submitAddToScope}
1668
+ onClose={() => setShowAddModal(false)}
1669
+ />
1670
+ )}
1671
+ </div>
1672
+ );
1673
+ };
1674
+
1675
+ export const SingleScope: Story = {
1676
+ render: () => <SingleScopeTemplate />,
1677
+ parameters: {
1678
+ docs: {
1679
+ description: {
1680
+ story:
1681
+ 'Story 1 from docs/scope-namespace-overlay.md — apply one scope at a time over the ' +
1682
+ 'electron-app city. Toggle namespaces in the legend, switch between scopes, and change ' +
1683
+ 'how uncovered (uninstrumented) files render.',
1684
+ },
1685
+ },
1686
+ },
1687
+ };