# Smart-Diff & SBOM Lineage Graph - UI Implementation Guide ## Overview This document provides comprehensive guidance for implementing the Smart-Diff and SBOM Lineage Graph UI features in the StellaOps Angular frontend. **Last Updated:** 2025-12-29 **Related Sprints:** FE_003 through FE_009 --- ## Table of Contents 1. [Architecture Overview](#architecture-overview) 2. [Existing Component Inventory](#existing-component-inventory) 3. [Angular 17 Patterns](#angular-17-patterns) 4. [State Management](#state-management) 5. [Visualization Techniques](#visualization-techniques) 6. [Styling System](#styling-system) 7. [Testing Strategy](#testing-strategy) 8. [Accessibility Requirements](#accessibility-requirements) 9. [Sprint Task Reference](#sprint-task-reference) --- ## Architecture Overview ### File Structure ``` src/app/ ├── core/ # Global services, guards, interceptors │ ├── services/ │ │ ├── delta-verdict.service.ts │ │ ├── audit-pack.service.ts │ │ └── pinned-explanation.service.ts │ └── api/ # API client base classes ├── features/ │ ├── lineage/ # Main lineage feature │ │ ├── components/ │ │ │ ├── lineage-graph/ # SVG-based DAG visualization │ │ │ ├── lineage-node/ # Individual node rendering │ │ │ ├── lineage-edge/ # Bezier curve edges │ │ │ ├── lineage-hover-card/# Hover details │ │ │ ├── lineage-minimap/ # Canvas minimap │ │ │ ├── explainer-timeline/# Engine step visualization │ │ │ ├── diff-table/ # Expandable diff table │ │ │ ├── reachability-diff/ # Gate visualization │ │ │ ├── pinned-explanation/# Copy-safe snippets │ │ │ └── audit-pack-export/ # Export dialog │ │ ├── services/ │ │ │ ├── lineage-graph.service.ts │ │ │ └── lineage-export.service.ts │ │ ├── models/ │ │ │ └── lineage.models.ts │ │ └── lineage.routes.ts │ ├── compare/ # Comparison feature │ │ ├── components/ │ │ │ ├── compare-view/ # Main comparison container │ │ │ ├── three-pane-layout/ # Categories/Items/Proof layout │ │ │ └── delta-summary-strip/ │ │ └── services/ │ │ └── compare.service.ts │ └── graph/ # Generic graph components ├── shared/ # Reusable UI components │ └── components/ │ ├── data-table/ │ ├── badge/ │ ├── tooltip/ │ └── modal/ └── styles/ # Global SCSS ├── variables.scss ├── mixins.scss └── themes/ ``` ### Module Boundaries | Module | Responsibility | Cross-Boundary Dependencies | |--------|----------------|----------------------------| | `lineage` | SBOM lineage visualization | Uses `shared` components, `compare` patterns | | `compare` | Delta comparison | Uses `lineage` data models | | `graph` | Generic graph rendering | Used by `lineage` | | `shared` | Reusable UI primitives | No feature dependencies | --- ## Existing Component Inventory ### Lineage Feature (41 files) | Component | Status | Notes | |-----------|--------|-------| | `LineageGraphComponent` | ✅ Complete | SVG-based DAG with pan/zoom | | `LineageNodeComponent` | ✅ Complete | Node shapes, badges, selection | | `LineageEdgeComponent` | ✅ Complete | Bezier curves, edge types | | `LineageHoverCardComponent` | ✅ Complete | Node details on hover | | `LineageMiniMapComponent` | ✅ Complete | Canvas-based minimap | | `LineageControlsComponent` | ✅ Complete | Zoom, pan, reset buttons | | `LineageSbomDiffComponent` | ⚠️ Partial | Needs row expanders | | `LineageVexDiffComponent` | ⚠️ Partial | Needs gate display | | `LineageCompareComponent` | ⚠️ Partial | Needs explainer integration | | `LineageExportDialogComponent` | ⚠️ Partial | Needs audit pack format | | `ReplayHashDisplayComponent` | ✅ Complete | Hash display with copy | | `WhySafePanelComponent` | ✅ Complete | VEX justification display | | `ProofTreeComponent` | ⚠️ Partial | Needs confidence breakdown | ### Compare Feature (18 files) | Component | Status | Notes | |-----------|--------|-------| | `CompareViewComponent` | ✅ Complete | Signals-based state | | `ThreePaneLayoutComponent` | ✅ Complete | Responsive layout | | `CategoriesPaneComponent` | ✅ Complete | Delta categories | | `ItemsPaneComponent` | ⚠️ Partial | Needs expansion | | `ProofPaneComponent` | ✅ Complete | Evidence display | | `DeltaSummaryStripComponent` | ✅ Complete | Stats header | | `TrustIndicatorsComponent` | ✅ Complete | Signature status | | `EnvelopeHashesComponent` | ✅ Complete | Attestation hashes | --- ## Angular 17 Patterns ### Standalone Components All new components must use standalone architecture: ```typescript @Component({ selector: 'app-explainer-step', standalone: true, imports: [CommonModule, SharedModule], templateUrl: './explainer-step.component.html', changeDetection: ChangeDetectionStrategy.OnPush }) export class ExplainerStepComponent { // Use signals for state readonly expanded = signal(false); // Use computed for derived state readonly displayText = computed(() => this.expanded() ? this.fullText() : this.truncatedText() ); // Use inject() for dependencies private readonly service = inject(ExplainerService); } ``` ### Input/Output with Signals Angular 17 signal-based inputs: ```typescript // Modern approach (preferred) export class MyComponent { // Signal input readonly data = input([]); // Required input readonly id = input.required(); // Aliased input readonly items = input([], { alias: 'dataItems' }); // Output readonly selectionChange = output(); } // Template usage ``` ### Template Control Flow Use new Angular 17 control flow syntax: ```typescript // In template @if (loading()) { } @else if (error()) { } @else { @for (item of items(); track item.id) { } @empty { } } @switch (status()) { @case ('success') { } @case ('error') { } @default { } } ``` --- ## State Management ### Service-Level State with Signals ```typescript @Injectable({ providedIn: 'root' }) export class LineageGraphService { // Private writable signals private readonly _currentGraph = signal(null); private readonly _selectedNodes = signal>(new Set()); private readonly _hoverState = signal(null); // Public readonly computed signals readonly currentGraph = this._currentGraph.asReadonly(); readonly selectedNodes = this._selectedNodes.asReadonly(); readonly hoverState = this._hoverState.asReadonly(); // Computed derived state readonly layoutNodes = computed(() => { const graph = this._currentGraph(); if (!graph) return []; return this.computeLayout(graph.nodes, graph.edges); }); readonly hasSelection = computed(() => this._selectedNodes().size > 0 ); // Actions selectNode(nodeId: string, multi = false): void { this._selectedNodes.update(set => { const newSet = multi ? new Set(set) : new Set(); if (set.has(nodeId) && multi) { newSet.delete(nodeId); } else { newSet.add(nodeId); } return newSet; }); } clearSelection(): void { this._selectedNodes.set(new Set()); } } ``` ### HTTP Data Loading Pattern ```typescript @Injectable({ providedIn: 'root' }) export class LineageGraphService { private readonly http = inject(HttpClient); // Caching private readonly cache = new Map>(); private readonly cacheTtlMs = 5 * 60 * 1000; // 5 minutes getLineage(artifactDigest: string, tenantId: string): Observable { const cacheKey = `${tenantId}:${artifactDigest}`; const cached = this.cache.get(cacheKey); if (cached && cached.expiresAt > Date.now()) { return of(cached.data); } return this.http.get( `/api/v1/lineage/${encodeURIComponent(artifactDigest)}`, { params: { tenantId } } ).pipe( tap(graph => { this.cache.set(cacheKey, { data: graph, expiresAt: Date.now() + this.cacheTtlMs }); this._currentGraph.set(graph); }), shareReplay(1) ); } } ``` --- ## Visualization Techniques ### SVG Graph Rendering The lineage graph uses SVG for node/edge rendering with transform groups for pan/zoom: ```typescript @Component({ selector: 'app-lineage-graph', template: ` @for (lane of lanes(); track lane.index) { } @for (edge of edges; track edge.id) { } @for (node of nodes; track node.artifactDigest) { } ` }) export class LineageGraphComponent { // Pan/zoom state readonly transform = signal({ x: 0, y: 0, scale: 1 }); readonly transformAttr = computed(() => { const t = this.transform(); return `translate(${t.x}, ${t.y}) scale(${t.scale})`; }); // Pan handling private isDragging = false; private dragStart = { x: 0, y: 0 }; onMouseDown(event: MouseEvent): void { if (event.button === 0) { // Left click this.isDragging = true; this.dragStart = { x: event.clientX, y: event.clientY }; } } onMouseMove(event: MouseEvent): void { if (!this.isDragging) return; const dx = event.clientX - this.dragStart.x; const dy = event.clientY - this.dragStart.y; this.transform.update(t => ({ ...t, x: t.x + dx, y: t.y + dy })); this.dragStart = { x: event.clientX, y: event.clientY }; } onMouseUp(): void { this.isDragging = false; } // Zoom handling onWheel(event: WheelEvent): void { event.preventDefault(); const scaleFactor = event.deltaY > 0 ? 0.9 : 1.1; const newScale = Math.min(3, Math.max(0.1, this.transform().scale * scaleFactor)); this.transform.update(t => ({ ...t, scale: newScale })); } } ``` ### Bezier Curve Edges ```typescript @Component({ selector: 'app-lineage-edge', template: ` @if (showArrow) { } ` }) export class LineageEdgeComponent { @Input() edge!: LineageEdge; @Input() sourceNode!: LayoutNode; @Input() targetNode!: LayoutNode; // Compute bezier curve path pathData = computed(() => { const src = this.sourceNode; const tgt = this.targetNode; // Control point offset for curve const dx = tgt.x - src.x; const cpOffset = Math.min(Math.abs(dx) * 0.5, 100); return `M ${src.x} ${src.y} C ${src.x + cpOffset} ${src.y}, ${tgt.x - cpOffset} ${tgt.y}, ${tgt.x} ${tgt.y}`; }); } ``` ### Canvas Minimap For performance-critical rendering (many nodes), use Canvas: ```typescript @Component({ selector: 'app-lineage-minimap', template: ` ` }) export class LineageMinimapComponent implements AfterViewInit, OnChanges { @ViewChild('canvas') canvasRef!: ElementRef; @Input() nodes: LayoutNode[] = []; @Input() viewportRect?: { x: number; y: number; width: number; height: number }; private ctx!: CanvasRenderingContext2D; private resizeObserver!: ResizeObserver; ngAfterViewInit(): void { const canvas = this.canvasRef.nativeElement; this.ctx = canvas.getContext('2d')!; // Handle high DPI displays this.resizeObserver = new ResizeObserver(entries => { const { width, height } = entries[0].contentRect; canvas.width = width * window.devicePixelRatio; canvas.height = height * window.devicePixelRatio; canvas.style.width = `${width}px`; canvas.style.height = `${height}px`; this.ctx.scale(window.devicePixelRatio, window.devicePixelRatio); this.render(); }); this.resizeObserver.observe(canvas); } ngOnChanges(): void { if (this.ctx) { this.render(); } } private render(): void { const canvas = this.canvasRef.nativeElement; const { width, height } = canvas.getBoundingClientRect(); // Clear this.ctx.clearRect(0, 0, width, height); // Calculate scale to fit all nodes const bounds = this.calculateBounds(); const scale = Math.min( width / bounds.width, height / bounds.height ) * 0.9; // Draw nodes for (const node of this.nodes) { const x = (node.x - bounds.minX) * scale + 5; const y = (node.y - bounds.minY) * scale + 5; this.ctx.fillStyle = this.getNodeColor(node); this.ctx.beginPath(); this.ctx.arc(x, y, 3, 0, Math.PI * 2); this.ctx.fill(); } // Draw viewport rectangle if (this.viewportRect) { this.ctx.strokeStyle = '#007bff'; this.ctx.lineWidth = 2; this.ctx.strokeRect( (this.viewportRect.x - bounds.minX) * scale + 5, (this.viewportRect.y - bounds.minY) * scale + 5, this.viewportRect.width * scale, this.viewportRect.height * scale ); } } } ``` --- ## Styling System ### CSS Variables (Design Tokens) ```scss // styles/variables.scss :root { // Colors --color-primary: #007bff; --color-success: #28a745; --color-warning: #ffc107; --color-danger: #dc3545; --color-info: #17a2b8; // Light theme --bg-primary: #ffffff; --bg-secondary: #f8f9fa; --bg-tertiary: #e9ecef; --bg-hover: #f0f0f0; --text-primary: #212529; --text-secondary: #6c757d; --border-color: #dee2e6; // Spacing --spacing-xs: 4px; --spacing-sm: 8px; --spacing-md: 16px; --spacing-lg: 24px; --spacing-xl: 32px; // Typography --font-family-base: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; --font-family-mono: 'SF Mono', Consolas, 'Liberation Mono', monospace; --font-size-xs: 11px; --font-size-sm: 13px; --font-size-md: 14px; --font-size-lg: 16px; // Shadows --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05); --shadow-md: 0 4px 6px rgba(0, 0, 0, 0.1); --shadow-lg: 0 10px 15px rgba(0, 0, 0, 0.1); // Border radius --radius-sm: 4px; --radius-md: 6px; --radius-lg: 8px; --radius-full: 9999px; // Transitions --transition-fast: 150ms ease; --transition-normal: 200ms ease; --transition-slow: 300ms ease; } // Dark theme .dark-mode { --bg-primary: #1a1a2e; --bg-secondary: #16213e; --bg-tertiary: #0f3460; --bg-hover: #2a2a4a; --text-primary: #e0e0e0; --text-secondary: #a0a0a0; --border-color: #3a3a5a; } ``` ### Component Styling Pattern ```scss // component.component.scss :host { display: block; width: 100%; } .container { background: var(--bg-primary); border: 1px solid var(--border-color); border-radius: var(--radius-lg); padding: var(--spacing-md); } .header { display: flex; justify-content: space-between; align-items: center; margin-bottom: var(--spacing-md); .title { font-size: var(--font-size-lg); font-weight: 600; color: var(--text-primary); } } .badge { display: inline-flex; align-items: center; padding: 2px 8px; border-radius: var(--radius-full); font-size: var(--font-size-xs); font-weight: 500; &.success { background: rgba(40, 167, 69, 0.1); color: var(--color-success); } &.danger { background: rgba(220, 53, 69, 0.1); color: var(--color-danger); } } // Animations .fade-in { animation: fadeIn var(--transition-normal); } @keyframes fadeIn { from { opacity: 0; transform: translateY(-10px); } to { opacity: 1; transform: translateY(0); } } // Responsive @media (max-width: 768px) { .container { padding: var(--spacing-sm); } } ``` --- ## Testing Strategy ### Unit Test Structure ```typescript // component.component.spec.ts import { ComponentFixture, TestBed } from '@angular/core/testing'; import { signal } from '@angular/core'; import { ExplainerTimelineComponent } from './explainer-timeline.component'; import { ExplainerService } from './explainer.service'; describe('ExplainerTimelineComponent', () => { let component: ExplainerTimelineComponent; let fixture: ComponentFixture; let mockService: jasmine.SpyObj; beforeEach(async () => { mockService = jasmine.createSpyObj('ExplainerService', ['getExplanation']); await TestBed.configureTestingModule({ imports: [ExplainerTimelineComponent], providers: [ { provide: ExplainerService, useValue: mockService } ] }).compileComponents(); fixture = TestBed.createComponent(ExplainerTimelineComponent); component = fixture.componentInstance; }); it('should create', () => { expect(component).toBeTruthy(); }); it('should display loading state', () => { component.loading = true; fixture.detectChanges(); const loadingEl = fixture.nativeElement.querySelector('.loading-state'); expect(loadingEl).toBeTruthy(); }); it('should render steps in order', () => { component.data = { steps: [ { id: '1', sequence: 1, title: 'Step 1', status: 'success' }, { id: '2', sequence: 2, title: 'Step 2', status: 'success' } ] }; fixture.detectChanges(); const steps = fixture.nativeElement.querySelectorAll('.step-card'); expect(steps.length).toBe(2); expect(steps[0].textContent).toContain('Step 1'); }); it('should expand step on click', () => { component.data = { steps: [{ id: '1', sequence: 1, title: 'Step 1', children: [{ id: '1a' }] }] }; fixture.detectChanges(); const stepCard = fixture.nativeElement.querySelector('.step-card'); stepCard.click(); fixture.detectChanges(); expect(component.isExpanded('1')).toBeTrue(); }); it('should emit copy event with correct format', () => { spyOn(component.copyClick, 'emit'); component.copyToClipboard('markdown'); expect(component.copyClick.emit).toHaveBeenCalledWith('markdown'); }); }); ``` ### Service Test Pattern ```typescript // service.service.spec.ts import { TestBed } from '@angular/core/testing'; import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing'; import { LineageGraphService } from './lineage-graph.service'; describe('LineageGraphService', () => { let service: LineageGraphService; let httpMock: HttpTestingController; beforeEach(() => { TestBed.configureTestingModule({ imports: [HttpClientTestingModule], providers: [LineageGraphService] }); service = TestBed.inject(LineageGraphService); httpMock = TestBed.inject(HttpTestingController); }); afterEach(() => { httpMock.verify(); }); it('should fetch lineage graph', () => { const mockGraph = { nodes: [{ id: '1', artifactDigest: 'sha256:abc' }], edges: [] }; service.getLineage('sha256:abc', 'tenant-1').subscribe(graph => { expect(graph.nodes.length).toBe(1); }); const req = httpMock.expectOne('/api/v1/lineage/sha256%3Aabc?tenantId=tenant-1'); expect(req.request.method).toBe('GET'); req.flush(mockGraph); }); it('should cache results', () => { const mockGraph = { nodes: [], edges: [] }; // First call service.getLineage('sha256:abc', 'tenant-1').subscribe(); httpMock.expectOne('/api/v1/lineage/sha256%3Aabc?tenantId=tenant-1').flush(mockGraph); // Second call should use cache service.getLineage('sha256:abc', 'tenant-1').subscribe(); httpMock.expectNone('/api/v1/lineage/sha256%3Aabc?tenantId=tenant-1'); }); }); ``` --- ## Accessibility Requirements ### ARIA Guidelines ```typescript // Accessible component example @Component({ template: `
{{ step.title }} {{ expanded ? 'Collapse' : 'Expand' }} step details
`, styles: [` .sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); border: 0; } [role="button"]:focus { outline: 2px solid var(--color-primary); outline-offset: 2px; } `] }) ``` ### Keyboard Navigation | Key | Action | |-----|--------| | Tab | Move focus to next interactive element | | Shift+Tab | Move focus to previous element | | Enter/Space | Activate focused button/link | | Escape | Close modal/popover | | Arrow keys | Navigate within lists/trees | | Home/End | Jump to first/last item | --- ## Sprint Task Reference ### FE_003: CGS Integration (3-5 days) - Wire `lineage-graph.service` to new CGS APIs - Add CGS hash display to `lineage-node.component` - Wire `proof-tree.component` to verdict traces - Add "Replay Verdict" button to hover card - Display confidence factor chips ### FE_004: Proof Studio (5-7 days) - Implement `ConfidenceBreakdownComponent` - Implement `ConfidenceFactorChip` - Implement `WhatIfSliderComponent` - Wire proof-tree to CGS proof traces - Add confidence breakdown to verdict card ### FE_005: Explainer Timeline (5-7 days) - Create `ExplainerTimelineComponent` - Create `ExplainerStepComponent` - Design step data model - Add step expansion with animation - Wire to ProofTrace API - Implement copy-to-clipboard ### FE_006: Node Diff Table (4-5 days) - Create `DiffTableComponent` - Implement column definitions - Add row expansion template - Implement filter chips - Add sorting functionality - Implement row selection ### FE_007: Pinned Explanations (2-3 days) - Create `PinnedExplanationService` - Create `PinnedPanelComponent` - Add pin buttons to Explainer Timeline - Add pin buttons to Diff Table rows - Implement format templates (Markdown, JSON, HTML, Jira) - Add copy-to-clipboard with toast ### FE_008: Reachability Gate Diff (3-4 days) - Enhance `ReachabilityDiffComponent` - Create `GateChipComponent` - Create `PathComparisonComponent` - Create `ConfidenceBarComponent` - Add gate expansion panel - Add call graph mini-visualization ### FE_009: Audit Pack Export (2-3 days) - Enhance `AuditPackExportComponent` - Create `ExportOptionsComponent` - Create `MerkleDisplayComponent` - Add signing options - Implement progress tracking - Add download handling --- ## Appendix: Data Model Reference See `src/app/features/lineage/models/lineage.models.ts` for complete type definitions including: - `LineageNode` - `LineageEdge` - `LineageGraph` - `LineageDiffResponse` - `ComponentDiff` - `VexDelta` - `ReachabilityDelta` - `AttestationLink` - `ViewOptions` - `SelectionState` - `HoverCardState`