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

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