Files
git.stella-ops.org/docs/modules/ui/LINEAGE_SMARTDIFF_UI_GUIDE.md
2025-12-29 08:39:52 +02:00

25 KiB

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

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:

@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:

// 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 new Angular 17 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

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