wip - advisories and ui extensions
This commit is contained in:
950
docs/modules/ui/LINEAGE_SMARTDIFF_UI_GUIDE.md
Normal file
950
docs/modules/ui/LINEAGE_SMARTDIFF_UI_GUIDE.md
Normal 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`
|
||||
Reference in New Issue
Block a user