wip - advisories and ui extensions

This commit is contained in:
StellaOps Bot
2025-12-29 08:39:52 +02:00
parent c2b9cd8d1f
commit 1b61c72c90
56 changed files with 15187 additions and 24 deletions

View File

@@ -0,0 +1,950 @@
# 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<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:
```typescript
// 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
```typescript
@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
```typescript
@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:
```typescript
@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
```typescript
@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:
```typescript
@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)
```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<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
```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: `
<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`