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
  2. Existing Component Inventory
  3. Angular 21 Patterns
  4. State Management
  5. Visualization Techniques
  6. Styling System
  7. Testing Strategy
  8. Accessibility Requirements
  9. 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

ModuleResponsibilityCross-Boundary Dependencies
lineageSBOM lineage visualizationUses shared components, compare patterns
compareDelta comparisonUses lineage data models
graphGeneric graph renderingUsed by lineage
sharedReusable UI primitivesNo feature dependencies

Existing Component Inventory

Lineage Feature (41 files)

ComponentStatusNotes
LineageGraphComponent✅ CompleteSVG-based DAG with pan/zoom
LineageNodeComponent✅ CompleteNode shapes, badges, selection
LineageEdgeComponent✅ CompleteBezier curves, edge types
LineageHoverCardComponent✅ CompleteNode details on hover
LineageMiniMapComponent✅ CompleteCanvas-based minimap
LineageControlsComponent✅ CompleteZoom, pan, reset buttons
LineageSbomDiffComponent⚠️ PartialNeeds row expanders
LineageVexDiffComponent⚠️ PartialNeeds gate display
LineageCompareComponent⚠️ PartialNeeds explainer integration
LineageExportDialogComponent⚠️ PartialNeeds audit pack format
ReplayHashDisplayComponent✅ CompleteHash display with copy
WhySafePanelComponent✅ CompleteVEX justification display
ProofTreeComponent⚠️ PartialNeeds confidence breakdown

Compare Feature (18 files)

ComponentStatusNotes
CompareViewComponent✅ CompleteSignals-based state
ThreePaneLayoutComponent✅ CompleteResponsive layout
CategoriesPaneComponent✅ CompleteDelta categories
ItemsPaneComponent⚠️ PartialNeeds expansion
ProofPaneComponent✅ CompleteEvidence display
DeltaSummaryStripComponent✅ CompleteStats header
TrustIndicatorsComponent✅ CompleteSignature status
EnvelopeHashesComponent✅ CompleteAttestation hashes

Angular 21 Patterns

Standalone Components

All new components must use standalone architecture:

@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 21 signal-based inputs:

// Modern approach (preferred)
export class MyComponent {
  // Signal input
  readonly data = input<DataType[]>([]);

  // Required input
  readonly id = input.required<string>();

  // Aliased input
  readonly items = input<Item[]>([], { alias: 'dataItems' });

  // Output
  readonly selectionChange = output<Item>();
}

// Template usage
<app-my-component
  [data]="graphData()"
  [id]="nodeId"
  (selectionChange)="onSelection($event)"
/>

Template Control Flow

Use Angular 21 built-in control flow syntax:

// In template
@if (loading()) {
  <app-skeleton />
} @else if (error()) {
  <app-error-state [message]="error()" />
} @else {
  @for (item of items(); track item.id) {
    <app-list-item [item]="item" />
  } @empty {
    <app-empty-state />
  }
}

@switch (status()) {
  @case ('success') { <app-success-badge /> }
  @case ('error') { <app-error-badge /> }
  @default { <app-pending-badge /> }
}

State Management

Service-Level State with Signals

@Injectable({ providedIn: 'root' })
export class LineageGraphService {
  // Private writable signals
  private readonly _currentGraph = signal<LineageGraph | null>(null);
  private readonly _selectedNodes = signal<Set<string>>(new Set());
  private readonly _hoverState = signal<HoverState | null>(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<string>();
      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

@Injectable({ providedIn: 'root' })
export class LineageGraphService {
  private readonly http = inject(HttpClient);

  // Caching
  private readonly cache = new Map<string, CacheEntry<LineageGraph>>();
  private readonly cacheTtlMs = 5 * 60 * 1000; // 5 minutes

  getLineage(artifactDigest: string, tenantId: string): Observable<LineageGraph> {
    const cacheKey = `${tenantId}:${artifactDigest}`;
    const cached = this.cache.get(cacheKey);

    if (cached && cached.expiresAt > Date.now()) {
      return of(cached.data);
    }

    return this.http.get<LineageGraph>(
      `/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:

@Component({
  selector: 'app-lineage-graph',
  template: `
    <svg
      #svgElement
      class="lineage-svg"
      [attr.viewBox]="viewBox()"
      (wheel)="onWheel($event)"
      (mousedown)="onMouseDown($event)"
      (mousemove)="onMouseMove($event)"
      (mouseup)="onMouseUp($event)">

      <!-- Background grid -->
      <defs>
        <pattern id="grid" width="20" height="20" patternUnits="userSpaceOnUse">
          <path d="M 20 0 L 0 0 0 20" fill="none" stroke="#e0e0e0" stroke-width="0.5"/>
        </pattern>
      </defs>
      <rect width="100%" height="100%" fill="url(#grid)"/>

      <!-- Transform group for pan/zoom -->
      <g [attr.transform]="transformAttr()">
        <!-- Lane backgrounds -->
        @for (lane of lanes(); track lane.index) {
          <rect
            [attr.x]="lane.x"
            [attr.y]="0"
            [attr.width]="lane.width"
            [attr.height]="graphHeight()"
            [attr.fill]="lane.index % 2 === 0 ? '#f8f9fa' : '#ffffff'"
          />
        }

        <!-- Edges layer (rendered first, behind nodes) -->
        <g class="edges-layer">
          @for (edge of edges; track edge.id) {
            <app-lineage-edge
              [edge]="edge"
              [sourceNode]="getNode(edge.fromDigest)"
              [targetNode]="getNode(edge.toDigest)"
            />
          }
        </g>

        <!-- Nodes layer -->
        <g class="nodes-layer">
          @for (node of nodes; track node.artifactDigest) {
            <app-lineage-node
              [node]="node"
              [selected]="isSelected(node)"
              [hovered]="isHovered(node)"
              (click)="onNodeClick(node, $event)"
              (mouseenter)="onNodeHover(node, $event)"
              (mouseleave)="onNodeLeave()"
            />
          }
        </g>
      </g>
    </svg>
  `
})
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

@Component({
  selector: 'app-lineage-edge',
  template: `
    <g class="edge" [class]="edgeClass">
      <path
        [attr.d]="pathData()"
        [attr.stroke]="strokeColor"
        stroke-width="2"
        fill="none"
      />
      @if (showArrow) {
        <polygon
          [attr.points]="arrowPoints()"
          [attr.fill]="strokeColor"
        />
      }
    </g>
  `
})
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:

@Component({
  selector: 'app-lineage-minimap',
  template: `
    <canvas
      #canvas
      class="minimap-canvas"
      (click)="onCanvasClick($event)"
    ></canvas>
  `
})
export class LineageMinimapComponent implements AfterViewInit, OnChanges {
  @ViewChild('canvas') canvasRef!: ElementRef<HTMLCanvasElement>;
  @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)

// 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

// 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

// 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<ExplainerTimelineComponent>;
  let mockService: jasmine.SpyObj<ExplainerService>;

  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

// 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

// Accessible component example
@Component({
  template: `
    <div
      class="step-card"
      role="button"
      [attr.aria-expanded]="expanded"
      [attr.aria-controls]="'step-details-' + step.id"
      tabindex="0"
      (click)="toggle()"
      (keydown.enter)="toggle()"
      (keydown.space)="toggle(); $event.preventDefault()">

      <span class="step-title">{{ step.title }}</span>
      <span class="sr-only">
        {{ expanded ? 'Collapse' : 'Expand' }} step details
      </span>
    </div>

    <div
      [id]="'step-details-' + step.id"
      [hidden]="!expanded"
      role="region"
      [attr.aria-labelledby]="'step-title-' + step.id">
      <!-- Expanded content -->
    </div>
  `,
  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

KeyAction
TabMove focus to next interactive element
Shift+TabMove focus to previous element
Enter/SpaceActivate focused button/link
EscapeClose modal/popover
Arrow keysNavigate within lists/trees
Home/EndJump to first/last item

Sprint Task Reference

FE_003: CGS Integration (3-5 days)

FE_004: Proof Studio (5-7 days)

FE_005: Explainer Timeline (5-7 days)

FE_006: Node Diff Table (4-5 days)

FE_007: Pinned Explanations (2-3 days)

FE_008: Reachability Gate Diff (3-4 days)

FE_009: Audit Pack Export (2-3 days)


Appendix: Data Model Reference

See src/app/features/lineage/models/lineage.models.ts for complete type definitions including: