feat(web): derive witness-viewer into reusable proof-inspection sections for mounted surfaces [SPRINT-031]

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
master
2026-03-08 23:04:49 +02:00
parent 2bf4d69bba
commit d7f55b72c8
18 changed files with 1889 additions and 45 deletions

View File

@@ -5,14 +5,23 @@
* Detailed view of a single evidence packet with contents and verification.
*/
import { Component, ChangeDetectionStrategy, signal, inject, OnInit } from '@angular/core';
import { Component, ChangeDetectionStrategy, signal, computed, inject, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ActivatedRoute, RouterLink } from '@angular/router';
import {
VerificationSummaryComponent,
EvidencePayloadComponent,
} from '../../shared/ui/witness/index';
import type {
VerificationSummaryData,
EvidencePayloadData,
} from '../../shared/ui/witness/index';
@Component({
selector: 'app-evidence-packet-page',
standalone: true,
imports: [CommonModule, RouterLink],
imports: [CommonModule, RouterLink, VerificationSummaryComponent, EvidencePayloadComponent],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="evidence-packet">
@@ -125,30 +134,16 @@ import { ActivatedRoute, RouterLink } from '@angular/router';
</div>
}
@case ('verify') {
<div class="panel">
<h3>Verification</h3>
<div class="verification-status">
@if (packet().verified) {
<div class="verification-result verification-result--success">
<span class="verification-icon"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="20 6 9 17 4 12"/></svg></span>
<div>
<strong>Signature Valid</strong>
<p>Verified against trusted key: ops-signing-key-2026</p>
</div>
</div>
} @else {
<div class="verification-result verification-result--pending">
<span class="verification-icon">?</span>
<div>
<strong>Not Yet Verified</strong>
<p>Click verify to check signature</p>
</div>
</div>
}
<div class="panel verify-panel" data-testid="verify-tab">
<app-verification-summary [data]="verificationSummary()" />
<div class="verify-action-row">
<button type="button" class="btn btn--primary" (click)="runVerification()">
Run Verification
</button>
</div>
<button type="button" class="btn btn--primary" (click)="runVerification()">
Run Verification
</button>
<app-evidence-payload [data]="evidencePayload()" />
</div>
}
@case ('proof-chain') {
@@ -340,6 +335,9 @@ import { ActivatedRoute, RouterLink } from '@angular/router';
.btn--sm { padding: 0.25rem 0.5rem; font-size: 0.75rem; }
.btn--primary { background: var(--color-brand-primary); border: none; color: var(--color-text-heading); }
.btn--secondary { background: var(--color-surface-secondary); border: 1px solid var(--color-border-primary); color: var(--color-text-primary); }
.verify-panel { display: grid; gap: 1rem; }
.verify-action-row { display: flex; gap: 0.75rem; }
`]
})
export class EvidencePacketPageComponent implements OnInit {
@@ -381,6 +379,38 @@ export class EvidencePacketPageComponent implements OnInit {
{ id: '5', type: 'Promotion', icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></svg>', hash: 'sha256:m3n4o5...', time: '2h ago' },
];
// ---------------------------------------------------------------------------
// Derived proof-inspection section data (SPRINT-031 FE-WVD-003)
// ---------------------------------------------------------------------------
readonly verificationSummary = computed((): VerificationSummaryData => {
const p = this.packet();
const pType: string = p.type;
return {
id: p.id,
typeLabel: pType.charAt(0).toUpperCase() + pType.slice(1),
typeBadge: pType === 'attestation' ? 'attestation' : pType === 'exception' ? 'bundle' : 'receipt',
status: p.verified ? 'verified' : p.signed ? 'unverified' : 'pending',
createdAt: p.createdAt,
source: p.environment ?? undefined,
};
});
readonly evidencePayload = computed((): EvidencePayloadData => {
const p = this.packet();
return {
evidenceId: p.id,
rawContent: JSON.stringify(p, null, 2),
metadata: {
bundleDigest: p.bundleDigest,
releaseVersion: p.releaseVersion,
environment: p.environment,
signed: p.signed,
verified: p.verified,
},
};
});
ngOnInit(): void {
this.route.params.subscribe(params => {
this.packetId.set(params['packetId'] || '');

View File

@@ -269,6 +269,19 @@
</article>
</section>
<!-- Derived proof-inspection sections (SPRINT-031 FE-WVD-003) -->
<section class="proof-inspection-grid" data-testid="proof-inspection-sections">
@if (verificationSummary(); as summary) {
<app-verification-summary [data]="summary" />
}
<app-signature-inspector [signatures]="signatureDataList()" />
@if (evidencePayload(); as payload) {
<app-evidence-payload [data]="payload" />
}
</section>
<app-poe-drawer
[open]="showPoe() && !!proofArtifact()"
[poeArtifact]="proofArtifact()"

View File

@@ -292,6 +292,11 @@ h2 {
}
}
.proof-inspection-grid {
display: grid;
gap: 0.85rem;
}
@media (max-width: 640px) {
.path-row {
grid-template-columns: 1fr;

View File

@@ -27,6 +27,17 @@ import {
findWitnessFixture,
} from './reachability-fixtures';
import {
VerificationSummaryComponent,
SignatureInspectorComponent,
EvidencePayloadComponent,
} from '../../shared/ui/witness/index';
import type {
VerificationSummaryData,
SignatureData,
EvidencePayloadData,
} from '../../shared/ui/witness/index';
interface WitnessPathRow {
readonly id: string;
readonly symbol: string;
@@ -40,7 +51,13 @@ type MessageType = 'success' | 'error';
@Component({
selector: 'app-witness-page',
standalone: true,
imports: [CommonModule, PoEDrawerComponent],
imports: [
CommonModule,
PoEDrawerComponent,
VerificationSummaryComponent,
SignatureInspectorComponent,
EvidencePayloadComponent,
],
templateUrl: './witness-page.component.html',
styleUrls: ['./witness-page.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
@@ -156,6 +173,58 @@ export class WitnessPageComponent {
);
});
// ---------------------------------------------------------------------------
// Derived proof-inspection section data (SPRINT-031 FE-WVD-003)
// Maps Reachability domain data to the shared witness section inputs.
// ---------------------------------------------------------------------------
readonly verificationSummary = computed((): VerificationSummaryData | null => {
const w = this.witness();
if (!w) return null;
return {
id: w.witnessId,
typeLabel: 'Witness',
typeBadge: 'witness',
status: w.signature?.verified ? 'verified'
: w.signature ? 'unverified'
: 'pending',
confidenceTier: w.confidenceTier,
confidenceScore: w.confidenceScore,
createdAt: w.observedAt,
source: w.evidence.analysisMethod,
};
});
readonly signatureDataList = computed((): readonly SignatureData[] => {
const w = this.witness();
if (!w?.signature) return [];
return [{
id: w.signature.keyId,
algorithm: w.signature.algorithm,
keyId: w.signature.keyId,
value: w.signature.signature,
timestamp: w.signature.verifiedAt,
verified: w.signature.verified ?? false,
}];
});
readonly evidencePayload = computed((): EvidencePayloadData | null => {
const w = this.witness();
if (!w) return null;
return {
evidenceId: w.witnessId,
rawContent: JSON.stringify(w, null, 2),
metadata: {
scanId: w.scanId,
vulnId: w.vulnId,
confidenceTier: w.confidenceTier,
isReachable: w.isReachable,
pathHash: w.pathHash ?? 'n/a',
analysisMethod: w.evidence.analysisMethod,
},
};
});
constructor() {
combineLatest([this.route.paramMap, this.route.queryParamMap])
.pipe(takeUntilDestroyed(this.destroyRef))

View File

@@ -21,6 +21,9 @@ export * from './status-badge/status-badge.component';
export { MetricCardComponent, DeltaDirection, MetricSeverity } from './metric-card/metric-card.component';
export * from './timeline-list/timeline-list.component';
// Witness/evidence proof-inspection sections
export * from './witness/index';
// Utility
export * from './empty-state/empty-state.component';
export * from './inline-code/inline-code.component';

View File

@@ -0,0 +1,133 @@
/**
* Attestation Detail Component Tests
* Sprint: SPRINT_20260308_031_FE_witness_viewer_evidence_derivation (FE-WVD-004)
*/
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { Component, signal } from '@angular/core';
import { AttestationDetailComponent } from './attestation-detail.component';
import type { AttestationData } from './witness.models';
@Component({
standalone: true,
imports: [AttestationDetailComponent],
template: `<app-attestation-detail [data]="data()" />`,
})
class TestHostComponent {
data = signal<AttestationData | null>(null);
}
describe('AttestationDetailComponent', () => {
let fixture: ComponentFixture<TestHostComponent>;
let host: TestHostComponent;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [TestHostComponent],
}).compileComponents();
fixture = TestBed.createComponent(TestHostComponent);
host = fixture.componentInstance;
fixture.detectChanges();
});
it('should render the attestation detail section', () => {
const section = fixture.nativeElement.querySelector('[data-testid="attestation-detail"]');
expect(section).toBeTruthy();
});
it('should show empty state when no data', () => {
const empty = fixture.nativeElement.querySelector('[data-testid="attestation-empty"]');
expect(empty).toBeTruthy();
expect(empty.textContent).toContain('No attestation data available');
});
it('should display predicate type', () => {
host.data.set({
predicateType: 'https://in-toto.io/attestation/vulns/v0.1',
subjectName: 'registry.example.com/app:v1.2',
subjectDigests: [
{ algorithm: 'sha256', hash: 'abc123def456' },
],
predicate: { scanner: 'grype', version: '0.72' },
});
fixture.detectChanges();
const predicateType = fixture.nativeElement.querySelector('[data-testid="attestation-predicate-type"]');
expect(predicateType).toBeTruthy();
expect(predicateType.textContent).toContain('https://in-toto.io/attestation/vulns/v0.1');
});
it('should display subject name and digests', () => {
host.data.set({
predicateType: 'https://in-toto.io/attestation/sbom/v0.1',
subjectName: 'registry.example.com/app:v1.2',
subjectDigests: [
{ algorithm: 'sha256', hash: 'abc123' },
{ algorithm: 'sha512', hash: 'def456' },
],
predicate: {},
});
fixture.detectChanges();
const subject = fixture.nativeElement.querySelector('[data-testid="attestation-subject"]');
expect(subject).toBeTruthy();
expect(subject.textContent).toContain('registry.example.com/app:v1.2');
const digestRows = fixture.nativeElement.querySelectorAll('.digest-row');
expect(digestRows.length).toBe(2);
expect(digestRows[0].textContent).toContain('sha256');
expect(digestRows[0].textContent).toContain('abc123');
expect(digestRows[1].textContent).toContain('sha512');
});
it('should toggle predicate JSON on click', () => {
host.data.set({
predicateType: 'https://in-toto.io/attestation/sbom/v0.1',
subjectName: 'app:v1',
subjectDigests: [],
predicate: { scanner: 'grype', version: '0.72' },
});
fixture.detectChanges();
// Initially hidden
let predicateJson = fixture.nativeElement.querySelector('[data-testid="attestation-predicate-json"]');
expect(predicateJson).toBeNull();
// Click toggle
const toggle = fixture.nativeElement.querySelector('[data-testid="attestation-predicate-toggle"]');
expect(toggle).toBeTruthy();
toggle.click();
fixture.detectChanges();
// Now visible
predicateJson = fixture.nativeElement.querySelector('[data-testid="attestation-predicate-json"]');
expect(predicateJson).toBeTruthy();
expect(predicateJson.textContent).toContain('grype');
expect(predicateJson.textContent).toContain('0.72');
});
it('should hide predicate JSON when toggled off', () => {
host.data.set({
predicateType: 'type',
subjectName: 'subj',
subjectDigests: [],
predicate: { key: 'value' },
});
fixture.detectChanges();
const toggle = fixture.nativeElement.querySelector('[data-testid="attestation-predicate-toggle"]');
toggle.click();
fixture.detectChanges();
// Verify visible
expect(fixture.nativeElement.querySelector('[data-testid="attestation-predicate-json"]')).toBeTruthy();
// Toggle off
toggle.click();
fixture.detectChanges();
expect(fixture.nativeElement.querySelector('[data-testid="attestation-predicate-json"]')).toBeNull();
});
});

View File

@@ -0,0 +1,219 @@
/**
* Attestation Detail Component
* Sprint: SPRINT_20260308_031_FE_witness_viewer_evidence_derivation (FE-WVD-002)
*
* Composable section displaying attestation statement type, subject digests,
* and predicate payload. Embeddable in Reachability witness and Evidence
* proof surfaces.
*/
import {
Component,
ChangeDetectionStrategy,
input,
signal,
} from '@angular/core';
import type { AttestationData } from './witness.models';
@Component({
selector: 'app-attestation-detail',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<section class="attestation-detail" data-testid="attestation-detail">
<h3 class="section-title">Attestation</h3>
@if (!data()) {
<div class="empty-state" data-testid="attestation-empty">
No attestation data available.
</div>
} @else {
<dl class="attestation-grid">
<dt>Predicate Type</dt>
<dd><code data-testid="attestation-predicate-type">{{ data()!.predicateType }}</code></dd>
<dt>Subject</dt>
<dd>
<div class="subject-info">
<span class="subject-name" data-testid="attestation-subject">{{ data()!.subjectName }}</span>
<div class="subject-digests">
@for (digest of data()!.subjectDigests; track digest.algorithm) {
<div class="digest-row">
<span class="digest-algorithm">{{ digest.algorithm }}:</span>
<code class="digest-value">{{ digest.hash }}</code>
</div>
}
</div>
</div>
</dd>
</dl>
<!-- Predicate JSON (collapsible) -->
<div class="predicate-section">
<button
type="button"
class="predicate-toggle"
(click)="showPredicate.set(!showPredicate())"
[attr.aria-expanded]="showPredicate()"
data-testid="attestation-predicate-toggle"
>
<svg
width="14" height="14" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2" aria-hidden="true"
[class.rotated]="showPredicate()"
>
<polyline points="9 18 15 12 9 6"/>
</svg>
Predicate Data
</button>
@if (showPredicate()) {
<pre class="predicate-json" data-testid="attestation-predicate-json">{{ predicateJson() }}</pre>
}
</div>
}
</section>
`,
styles: [`
.attestation-detail {
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-lg);
background: var(--color-surface-secondary);
padding: 1rem;
}
.section-title {
margin: 0 0 0.75rem 0;
font-size: 0.875rem;
font-weight: var(--font-weight-semibold);
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--color-text-secondary);
}
.empty-state {
padding: 1rem;
color: var(--color-text-secondary);
font-size: 0.875rem;
}
.attestation-grid {
display: grid;
grid-template-columns: max-content 1fr;
gap: 0.5rem 1rem;
margin: 0 0 0.75rem 0;
}
.attestation-grid dt {
font-weight: var(--font-weight-medium);
color: var(--color-text-secondary);
font-size: 0.875rem;
}
.attestation-grid dd {
margin: 0;
}
code {
font-family: ui-monospace, SFMono-Regular, monospace;
font-size: 0.8125rem;
background: var(--color-severity-none-bg);
padding: 0.1rem 0.35rem;
border-radius: var(--radius-sm);
}
.subject-info {
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.subject-name {
font-weight: var(--font-weight-medium);
font-size: 0.875rem;
}
.subject-digests {
display: flex;
flex-direction: column;
gap: 0.2rem;
}
.digest-row {
display: flex;
gap: 0.5rem;
align-items: baseline;
}
.digest-algorithm {
font-size: 0.72rem;
color: var(--color-text-muted);
min-width: 55px;
}
.digest-value {
font-size: 0.72rem;
word-break: break-all;
}
.predicate-section {
border-top: 1px solid var(--color-border-primary);
padding-top: 0.65rem;
}
.predicate-toggle {
display: inline-flex;
align-items: center;
gap: 0.35rem;
background: none;
border: none;
color: var(--color-brand-primary);
cursor: pointer;
font-size: 0.82rem;
font-weight: var(--font-weight-medium);
padding: 0;
}
.predicate-toggle svg {
transition: transform 0.15s ease;
}
.predicate-toggle svg.rotated {
transform: rotate(90deg);
}
.predicate-json {
background: var(--color-text-heading);
color: var(--color-severity-none-bg);
padding: 0.85rem;
border-radius: var(--radius-md);
font-family: ui-monospace, SFMono-Regular, monospace;
font-size: 0.78rem;
overflow-x: auto;
white-space: pre-wrap;
word-break: break-word;
max-height: 300px;
overflow-y: auto;
margin: 0.5rem 0 0;
}
`],
})
export class AttestationDetailComponent {
/** Attestation data to display (null when not available). */
readonly data = input<AttestationData | null>(null);
/** Whether the predicate JSON is expanded. */
readonly showPredicate = signal(false);
/** Formatted predicate JSON. */
readonly predicateJson = () => {
const d = this.data();
if (!d) return '';
try {
return JSON.stringify(d.predicate, null, 2);
} catch {
return String(d.predicate);
}
};
}

View File

@@ -0,0 +1,117 @@
/**
* Evidence Payload Component Tests
* Sprint: SPRINT_20260308_031_FE_witness_viewer_evidence_derivation (FE-WVD-004)
*/
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { Component, signal } from '@angular/core';
import { EvidencePayloadComponent } from './evidence-payload.component';
import type { EvidencePayloadData } from './witness.models';
@Component({
standalone: true,
imports: [EvidencePayloadComponent],
template: `<app-evidence-payload [data]="data()" />`,
})
class TestHostComponent {
data = signal<EvidencePayloadData>({
evidenceId: 'ev-001',
rawContent: '{"type":"attestation","verified":true}',
metadata: {
source: 'scanner',
version: '0.72',
},
});
}
describe('EvidencePayloadComponent', () => {
let fixture: ComponentFixture<TestHostComponent>;
let host: TestHostComponent;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [TestHostComponent],
}).compileComponents();
fixture = TestBed.createComponent(TestHostComponent);
host = fixture.componentInstance;
fixture.detectChanges();
});
it('should render the evidence payload section', () => {
const section = fixture.nativeElement.querySelector('[data-testid="evidence-payload"]');
expect(section).toBeTruthy();
});
it('should show the "Show Raw Content" button initially', () => {
const showBtn = fixture.nativeElement.querySelector('[data-testid="evidence-payload-show"]');
expect(showBtn).toBeTruthy();
expect(showBtn.textContent).toContain('Show Raw Content');
});
it('should show raw content when toggled', () => {
const showBtn = fixture.nativeElement.querySelector('[data-testid="evidence-payload-show"]');
showBtn.click();
fixture.detectChanges();
const raw = fixture.nativeElement.querySelector('[data-testid="evidence-payload-raw"]');
expect(raw).toBeTruthy();
expect(raw.textContent).toContain('attestation');
expect(raw.textContent).toContain('true');
});
it('should have copy and download action buttons', () => {
const copyBtn = fixture.nativeElement.querySelector('[data-testid="evidence-payload-copy"]');
const downloadBtn = fixture.nativeElement.querySelector('[data-testid="evidence-payload-download"]');
expect(copyBtn).toBeTruthy();
expect(downloadBtn).toBeTruthy();
expect(copyBtn.textContent).toContain('Copy');
expect(downloadBtn.textContent).toContain('Download');
});
it('should display metadata section when metadata is provided', () => {
const metadata = fixture.nativeElement.querySelector('[data-testid="evidence-payload-metadata"]');
expect(metadata).toBeTruthy();
expect(metadata.textContent).toContain('scanner');
expect(metadata.textContent).toContain('0.72');
});
it('should not display metadata when empty', () => {
host.data.set({
evidenceId: 'ev-002',
rawContent: '{}',
metadata: {},
});
fixture.detectChanges();
const metadata = fixture.nativeElement.querySelector('[data-testid="evidence-payload-metadata"]');
expect(metadata).toBeNull();
});
it('should not display metadata when undefined', () => {
host.data.set({
evidenceId: 'ev-003',
rawContent: '{}',
});
fixture.detectChanges();
const metadata = fixture.nativeElement.querySelector('[data-testid="evidence-payload-metadata"]');
expect(metadata).toBeNull();
});
it('should hide raw content when toggled off', () => {
// Show
const showBtn = fixture.nativeElement.querySelector('[data-testid="evidence-payload-show"]');
showBtn.click();
fixture.detectChanges();
expect(fixture.nativeElement.querySelector('[data-testid="evidence-payload-raw"]')).toBeTruthy();
// Hide via the "Hide raw content" link
const hideBtn = fixture.nativeElement.querySelector('.btn-link');
hideBtn.click();
fixture.detectChanges();
expect(fixture.nativeElement.querySelector('[data-testid="evidence-payload-raw"]')).toBeNull();
});
});

View File

@@ -0,0 +1,245 @@
/**
* Evidence Payload Component
* Sprint: SPRINT_20260308_031_FE_witness_viewer_evidence_derivation (FE-WVD-002)
*
* Composable section for viewing, copying, and downloading raw evidence
* JSON payloads and metadata. Embeddable in Reachability witness and
* Evidence proof views.
*/
import {
Component,
ChangeDetectionStrategy,
input,
signal,
computed,
} from '@angular/core';
import type { EvidencePayloadData } from './witness.models';
@Component({
selector: 'app-evidence-payload',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<section class="evidence-payload" data-testid="evidence-payload">
<div class="section-header">
<h3 class="section-title">Raw Evidence</h3>
<div class="section-actions">
<button
type="button"
class="btn-action"
(click)="copyPayload()"
data-testid="evidence-payload-copy"
>
@if (copied()) {
<svg width="14" height="14" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2" aria-hidden="true"
style="display:inline;vertical-align:middle">
<polyline points="20 6 9 17 4 12"/>
</svg>
Copied
} @else {
Copy
}
</button>
<button
type="button"
class="btn-action"
(click)="downloadPayload()"
data-testid="evidence-payload-download"
>
Download
</button>
</div>
</div>
<!-- Raw Content (collapsible) -->
@if (showRaw()) {
<div class="raw-wrapper">
<pre class="raw-content" data-testid="evidence-payload-raw">{{ data().rawContent }}</pre>
<button
type="button"
class="btn-link"
(click)="showRaw.set(false)"
>
Hide raw content
</button>
</div>
} @else {
<button
type="button"
class="show-raw-btn"
(click)="showRaw.set(true)"
data-testid="evidence-payload-show"
>
Show Raw Content
</button>
}
<!-- Metadata (when available) -->
@if (hasMetadata()) {
<div class="metadata-section">
<h4 class="metadata-title">Metadata</h4>
<pre class="metadata-json" data-testid="evidence-payload-metadata">{{ metadataJson() }}</pre>
</div>
}
</section>
`,
styles: [`
.evidence-payload {
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-lg);
background: var(--color-surface-secondary);
padding: 1rem;
}
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.75rem;
}
.section-title {
margin: 0;
font-size: 0.875rem;
font-weight: var(--font-weight-semibold);
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--color-text-secondary);
}
.section-actions {
display: flex;
gap: 0.4rem;
}
.btn-action {
padding: 0.35rem 0.65rem;
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-md);
background: var(--color-surface-primary);
color: var(--color-text-primary);
font-size: 0.78rem;
font-weight: var(--font-weight-medium);
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 0.25rem;
}
.btn-action:hover {
background: var(--color-severity-none-bg);
}
.show-raw-btn {
width: 100%;
padding: 0.55rem 0.85rem;
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-md);
background: var(--color-surface-primary);
color: var(--color-text-secondary);
font-size: 0.82rem;
cursor: pointer;
}
.show-raw-btn:hover {
background: var(--color-severity-none-bg);
}
.raw-wrapper {
display: grid;
gap: 0.35rem;
}
.raw-content,
.metadata-json {
background: var(--color-text-heading);
color: var(--color-severity-none-bg);
padding: 0.85rem;
border-radius: var(--radius-md);
font-family: ui-monospace, SFMono-Regular, monospace;
font-size: 0.78rem;
overflow-x: auto;
white-space: pre-wrap;
word-break: break-word;
max-height: 350px;
overflow-y: auto;
margin: 0;
}
.btn-link {
background: none;
border: none;
color: var(--color-brand-primary);
cursor: pointer;
padding: 0;
font-size: 0.75rem;
text-decoration: underline;
justify-self: start;
}
.metadata-section {
margin-top: 0.75rem;
padding-top: 0.75rem;
border-top: 1px solid var(--color-border-primary);
}
.metadata-title {
margin: 0 0 0.5rem 0;
font-size: 0.78rem;
font-weight: var(--font-weight-semibold);
color: var(--color-text-secondary);
}
`],
})
export class EvidencePayloadComponent {
/** Payload data to display. */
readonly data = input.required<EvidencePayloadData>();
/** Whether the raw content is expanded. */
readonly showRaw = signal(false);
/** Whether copy feedback is active. */
readonly copied = signal(false);
/** Whether metadata is non-empty. */
readonly hasMetadata = computed(() => {
const meta = this.data().metadata;
return meta != null && Object.keys(meta).length > 0;
});
/** Formatted metadata JSON. */
readonly metadataJson = computed(() => {
const meta = this.data().metadata;
if (!meta) return '';
try {
return JSON.stringify(meta, null, 2);
} catch {
return String(meta);
}
});
/** Copy raw content to clipboard. */
copyPayload(): void {
const content = this.data().rawContent;
navigator.clipboard.writeText(content).then(() => {
this.copied.set(true);
setTimeout(() => this.copied.set(false), 2000);
});
}
/** Download raw content as JSON file. */
downloadPayload(): void {
const content = this.data().rawContent;
const id = this.data().evidenceId;
const blob = new Blob([content], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const anchor = document.createElement('a');
anchor.href = url;
anchor.download = `evidence-${id}.json`;
anchor.click();
URL.revokeObjectURL(url);
}
}

View File

@@ -0,0 +1,21 @@
/**
* Shared Witness/Evidence Proof-Inspection Sections
* Sprint: SPRINT_20260308_031_FE_witness_viewer_evidence_derivation
*
* Reusable composable sections derived from the orphan WitnessViewerComponent.
* Designed for embedding in mounted Reachability and Evidence surfaces.
*/
export { VerificationSummaryComponent } from './verification-summary.component';
export { SignatureInspectorComponent } from './signature-inspector.component';
export { AttestationDetailComponent } from './attestation-detail.component';
export { EvidencePayloadComponent } from './evidence-payload.component';
export type {
VerificationSummaryData,
SignatureData,
AttestationData,
EvidencePayloadData,
VerificationStatus,
ConfidenceTier,
} from './witness.models';

View File

@@ -0,0 +1,153 @@
/**
* Signature Inspector Component Tests
* Sprint: SPRINT_20260308_031_FE_witness_viewer_evidence_derivation (FE-WVD-004)
*/
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { Component, signal } from '@angular/core';
import { SignatureInspectorComponent } from './signature-inspector.component';
import type { SignatureData } from './witness.models';
@Component({
standalone: true,
imports: [SignatureInspectorComponent],
template: `<app-signature-inspector [signatures]="signatures()" />`,
})
class TestHostComponent {
signatures = signal<readonly SignatureData[]>([]);
}
describe('SignatureInspectorComponent', () => {
let fixture: ComponentFixture<TestHostComponent>;
let host: TestHostComponent;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [TestHostComponent],
}).compileComponents();
fixture = TestBed.createComponent(TestHostComponent);
host = fixture.componentInstance;
fixture.detectChanges();
});
it('should render the signature inspector section', () => {
const section = fixture.nativeElement.querySelector('[data-testid="signature-inspector"]');
expect(section).toBeTruthy();
});
it('should show empty state when no signatures', () => {
const empty = fixture.nativeElement.querySelector('[data-testid="signature-empty"]');
expect(empty).toBeTruthy();
expect(empty.textContent).toContain('No signatures available');
});
it('should render a verified signature card', () => {
host.signatures.set([{
id: 'sig-001',
algorithm: 'ECDSA-P256',
keyId: 'key-abc-123',
value: 'MEUCIQD+base64signaturevaluehere==',
timestamp: '2026-03-08T10:30:00Z',
verified: true,
issuer: 'Stella Ops CA',
}]);
fixture.detectChanges();
const card = fixture.nativeElement.querySelector('[data-testid="signature-card-sig-001"]');
expect(card).toBeTruthy();
expect(card.classList.contains('signature-card--verified')).toBe(true);
expect(card.textContent).toContain('Verified');
expect(card.textContent).toContain('ECDSA-P256');
expect(card.textContent).toContain('key-abc-123');
expect(card.textContent).toContain('Stella Ops CA');
});
it('should render an unverified signature card', () => {
host.signatures.set([{
id: 'sig-002',
algorithm: 'Ed25519',
keyId: 'key-def-456',
value: 'shortval',
verified: false,
}]);
fixture.detectChanges();
const card = fixture.nativeElement.querySelector('[data-testid="signature-card-sig-002"]');
expect(card).toBeTruthy();
expect(card.classList.contains('signature-card--verified')).toBe(false);
expect(card.textContent).toContain('Unverified');
expect(card.textContent).toContain('Ed25519');
});
it('should render multiple signature cards', () => {
host.signatures.set([
{
id: 'sig-a',
algorithm: 'ECDSA-P256',
keyId: 'key-1',
value: 'sig-value-a',
verified: true,
},
{
id: 'sig-b',
algorithm: 'RSA-PSS',
keyId: 'key-2',
value: 'sig-value-b',
verified: false,
},
]);
fixture.detectChanges();
const cards = fixture.nativeElement.querySelectorAll('.signature-card');
expect(cards.length).toBe(2);
});
it('should truncate long signature values', () => {
const longSig = 'A'.repeat(100);
host.signatures.set([{
id: 'sig-long',
algorithm: 'ECDSA-P256',
keyId: 'key-long',
value: longSig,
verified: true,
}]);
fixture.detectChanges();
const sigValue = fixture.nativeElement.querySelector('.signature-value');
expect(sigValue).toBeTruthy();
// Truncated: 16 chars + '...' + 16 chars = 35 chars, not 100
expect(sigValue.textContent!.length).toBeLessThan(100);
expect(sigValue.textContent).toContain('...');
});
it('should show copy button for long signatures', () => {
host.signatures.set([{
id: 'sig-copy',
algorithm: 'ECDSA-P256',
keyId: 'key-copy',
value: 'A'.repeat(100),
verified: true,
}]);
fixture.detectChanges();
const copyBtn = fixture.nativeElement.querySelector('[data-testid="copy-sig-sig-copy"]');
expect(copyBtn).toBeTruthy();
expect(copyBtn.textContent).toContain('Copy full');
});
it('should not show copy button for short signatures', () => {
host.signatures.set([{
id: 'sig-short',
algorithm: 'ECDSA-P256',
keyId: 'key-short',
value: 'short',
verified: true,
}]);
fixture.detectChanges();
const copyBtn = fixture.nativeElement.querySelector('[data-testid="copy-sig-sig-short"]');
expect(copyBtn).toBeNull();
});
});

View File

@@ -0,0 +1,265 @@
/**
* Signature Inspector Component
* Sprint: SPRINT_20260308_031_FE_witness_viewer_evidence_derivation (FE-WVD-002)
*
* Composable section showing signature details: algorithm, key ID, verification
* result, and truncated/expandable signature value. Embeddable in mounted
* Reachability witness detail and Evidence packet views.
*/
import {
Component,
ChangeDetectionStrategy,
input,
signal,
} from '@angular/core';
import type { SignatureData } from './witness.models';
@Component({
selector: 'app-signature-inspector',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<section class="signature-inspector" data-testid="signature-inspector">
<h3 class="section-title">
Signatures
@if (signatures().length) {
<span class="section-count">({{ signatures().length }})</span>
}
</h3>
@if (signatures().length === 0) {
<div class="empty-state" data-testid="signature-empty">
No signatures available for this evidence.
</div>
} @else {
<div class="signatures-list">
@for (sig of signatures(); track sig.id) {
<div
class="signature-card"
[class.signature-card--verified]="sig.verified"
[attr.data-testid]="'signature-card-' + sig.id"
>
<div class="signature-card__header">
<span class="signature-card__status">
@if (sig.verified) {
<span class="verified-icon">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2" aria-hidden="true">
<polyline points="20 6 9 17 4 12"/>
</svg>
</span>
Verified
} @else {
<span class="unverified-icon">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2" aria-hidden="true">
<circle cx="12" cy="12" r="10"/>
</svg>
</span>
Unverified
}
</span>
<span class="signature-card__algorithm">{{ sig.algorithm }}</span>
</div>
<dl class="signature-card__details">
<dt>Key ID</dt>
<dd><code>{{ sig.keyId }}</code></dd>
@if (sig.issuer) {
<dt>Issuer</dt>
<dd>{{ sig.issuer }}</dd>
}
@if (sig.timestamp) {
<dt>Timestamp</dt>
<dd>{{ formatDate(sig.timestamp) }}</dd>
}
<dt>Signature</dt>
<dd class="signature-value-cell">
<code class="signature-value">{{ truncateSignature(sig.value) }}</code>
@if (sig.value.length > 40) {
<button
type="button"
class="btn-link btn-small"
(click)="copySignature(sig.value)"
[attr.data-testid]="'copy-sig-' + sig.id"
>
{{ copiedId() === sig.id ? 'Copied' : 'Copy full' }}
</button>
}
</dd>
</dl>
</div>
}
</div>
}
</section>
`,
styles: [`
.signature-inspector {
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-lg);
background: var(--color-surface-secondary);
padding: 1rem;
}
.section-title {
margin: 0 0 0.75rem 0;
font-size: 0.875rem;
font-weight: var(--font-weight-semibold);
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--color-text-secondary);
}
.section-count {
font-weight: var(--font-weight-regular, 400);
color: var(--color-text-muted);
}
.empty-state {
padding: 1rem;
color: var(--color-text-secondary);
font-size: 0.875rem;
}
.signatures-list {
display: grid;
gap: 0.75rem;
}
.signature-card {
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-md);
padding: 0.85rem;
background: var(--color-surface-primary);
}
.signature-card--verified {
border-color: var(--color-severity-low-border);
background: var(--color-severity-low-bg);
}
.signature-card__header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.6rem;
}
.signature-card__status {
display: flex;
align-items: center;
gap: 0.25rem;
font-weight: var(--font-weight-medium);
font-size: 0.875rem;
}
.verified-icon {
color: var(--color-status-success-text);
}
.unverified-icon {
color: var(--color-text-muted);
}
.signature-card__algorithm {
background: var(--color-border-primary);
padding: 0.125rem 0.5rem;
border-radius: var(--radius-sm);
font-size: 0.75rem;
font-weight: var(--font-weight-medium);
}
.signature-card__details {
display: grid;
grid-template-columns: max-content 1fr;
gap: 0.35rem 0.85rem;
margin: 0;
font-size: 0.875rem;
}
.signature-card__details dt {
color: var(--color-text-muted);
}
.signature-card__details dd {
margin: 0;
display: flex;
align-items: center;
gap: 0.5rem;
}
code {
font-family: ui-monospace, SFMono-Regular, monospace;
font-size: 0.8125rem;
}
.signature-value {
max-width: 260px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.signature-value-cell {
flex-wrap: wrap;
}
.btn-link {
background: none;
border: none;
color: var(--color-brand-primary);
cursor: pointer;
padding: 0;
font-size: 0.75rem;
text-decoration: underline;
}
.btn-small {
font-size: 0.72rem;
}
`],
})
export class SignatureInspectorComponent {
/** List of signatures to display. */
readonly signatures = input.required<readonly SignatureData[]>();
/** Track which signature was just copied. */
readonly copiedId = signal<string | null>(null);
truncateSignature(value: string): string {
if (value.length <= 40) return value;
return `${value.slice(0, 16)}...${value.slice(-16)}`;
}
copySignature(value: string): void {
navigator.clipboard.writeText(value).then(() => {
// Find the matching signature for feedback
const sig = this.signatures().find(s => s.value === value);
if (sig) {
this.copiedId.set(sig.id);
setTimeout(() => this.copiedId.set(null), 2000);
}
});
}
formatDate(isoDate: string): string {
try {
return new Intl.DateTimeFormat('en-US', {
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
month: 'short',
timeZone: 'UTC',
year: 'numeric',
}).format(new Date(isoDate));
} catch {
return isoDate;
}
}
}

View File

@@ -0,0 +1,144 @@
/**
* Verification Summary Component Tests
* Sprint: SPRINT_20260308_031_FE_witness_viewer_evidence_derivation (FE-WVD-004)
*/
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { Component, signal } from '@angular/core';
import { VerificationSummaryComponent } from './verification-summary.component';
import type { VerificationSummaryData } from './witness.models';
@Component({
standalone: true,
imports: [VerificationSummaryComponent],
template: `<app-verification-summary [data]="data()" />`,
})
class TestHostComponent {
data = signal<VerificationSummaryData>({
id: 'witness-001',
typeLabel: 'Witness',
typeBadge: 'witness',
status: 'verified',
confidenceTier: 'confirmed',
confidenceScore: 0.95,
createdAt: '2026-03-08T10:00:00Z',
source: 'static',
});
}
describe('VerificationSummaryComponent', () => {
let fixture: ComponentFixture<TestHostComponent>;
let host: TestHostComponent;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [TestHostComponent],
}).compileComponents();
fixture = TestBed.createComponent(TestHostComponent);
host = fixture.componentInstance;
fixture.detectChanges();
});
it('should render the verification summary section', () => {
const section = fixture.nativeElement.querySelector('[data-testid="verification-summary"]');
expect(section).toBeTruthy();
});
it('should display verified status badge', () => {
const badge = fixture.nativeElement.querySelector('[data-testid="verification-status-verified"]');
expect(badge).toBeTruthy();
expect(badge.textContent).toContain('Verified');
});
it('should display the evidence ID', () => {
const mono = fixture.nativeElement.querySelector('.summary-mono');
expect(mono).toBeTruthy();
expect(mono.textContent).toContain('witness-001');
});
it('should display confidence tier badge', () => {
const badge = fixture.nativeElement.querySelector('.confidence-badge--confirmed');
expect(badge).toBeTruthy();
expect(badge.textContent).toContain('Confirmed Reachable');
expect(badge.textContent).toContain('95%');
});
it('should display type badge', () => {
const badge = fixture.nativeElement.querySelector('.type-badge--witness');
expect(badge).toBeTruthy();
expect(badge.textContent).toContain('Witness');
});
it('should display failed status correctly', () => {
host.data.set({
id: 'test-002',
typeLabel: 'Attestation',
typeBadge: 'attestation',
status: 'failed',
});
fixture.detectChanges();
const badge = fixture.nativeElement.querySelector('[data-testid="verification-status-failed"]');
expect(badge).toBeTruthy();
expect(badge.textContent).toContain('Failed');
});
it('should display pending status correctly', () => {
host.data.set({
id: 'test-003',
typeLabel: 'Bundle',
typeBadge: 'bundle',
status: 'pending',
});
fixture.detectChanges();
const badge = fixture.nativeElement.querySelector('[data-testid="verification-status-pending"]');
expect(badge).toBeTruthy();
expect(badge.textContent).toContain('Pending');
});
it('should display unverified status correctly', () => {
host.data.set({
id: 'test-004',
typeLabel: 'Signature',
typeBadge: 'signature',
status: 'unverified',
});
fixture.detectChanges();
const badge = fixture.nativeElement.querySelector('[data-testid="verification-status-unverified"]');
expect(badge).toBeTruthy();
expect(badge.textContent).toContain('Unverified');
});
it('should not display confidence section when tier is undefined', () => {
host.data.set({
id: 'test-005',
typeLabel: 'Receipt',
typeBadge: 'receipt',
status: 'verified',
});
fixture.detectChanges();
const confidenceBadge = fixture.nativeElement.querySelector('.confidence-badge');
expect(confidenceBadge).toBeNull();
});
it('should not display source when not provided', () => {
host.data.set({
id: 'test-006',
typeLabel: 'Witness',
typeBadge: 'witness',
status: 'verified',
});
fixture.detectChanges();
const items = fixture.nativeElement.querySelectorAll('.summary-item');
const labels = Array.from(items).map((el: any) =>
el.querySelector('.summary-label')?.textContent?.trim()
);
expect(labels).not.toContain('Source');
});
});

View File

@@ -0,0 +1,313 @@
/**
* Verification Summary Component
* Sprint: SPRINT_20260308_031_FE_witness_viewer_evidence_derivation (FE-WVD-002)
*
* Composable section showing pass/fail verification status, confidence tier badge,
* evidence type, and creation metadata. Designed for embedding in mounted
* Reachability and Evidence surfaces -- not as a standalone page.
*/
import {
Component,
ChangeDetectionStrategy,
input,
computed,
} from '@angular/core';
import type {
VerificationSummaryData,
VerificationStatus,
ConfidenceTier,
} from './witness.models';
@Component({
selector: 'app-verification-summary',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<section class="verification-summary" data-testid="verification-summary">
<h3 class="section-title">Verification Summary</h3>
<div class="summary-grid">
<!-- Status -->
<div class="summary-item">
<span class="summary-label">Status</span>
<span
class="status-badge"
[class]="'status-badge--' + data().status"
[attr.data-testid]="'verification-status-' + data().status"
>
@if (data().status === 'verified') {
<svg width="14" height="14" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2" aria-hidden="true"
style="display:inline;vertical-align:middle">
<polyline points="20 6 9 17 4 12"/>
</svg>
} @else if (data().status === 'failed') {
<svg width="14" height="14" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2" aria-hidden="true"
style="display:inline;vertical-align:middle">
<line x1="18" y1="6" x2="6" y2="18"/>
<line x1="6" y1="6" x2="18" y2="18"/>
</svg>
} @else if (data().status === 'pending') {
<svg width="14" height="14" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2" aria-hidden="true"
style="display:inline;vertical-align:middle">
<circle cx="12" cy="12" r="10"/>
<polyline points="12 6 12 12 16 14"/>
</svg>
} @else {
<svg width="14" height="14" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2" aria-hidden="true"
style="display:inline;vertical-align:middle">
<circle cx="12" cy="12" r="10"/>
</svg>
}
{{ statusLabel() }}
</span>
</div>
<!-- Evidence Type -->
<div class="summary-item">
<span class="summary-label">Type</span>
<span class="type-badge" [class]="'type-badge--' + data().typeBadge">
{{ data().typeLabel }}
</span>
</div>
<!-- Confidence Tier (when present) -->
@if (data().confidenceTier) {
<div class="summary-item">
<span class="summary-label">Confidence</span>
<span class="confidence-badge" [class]="'confidence-badge--' + data().confidenceTier">
{{ confidenceLabel() }}
@if (data().confidenceScore != null) {
<span class="confidence-score">({{ confidencePercent() }})</span>
}
</span>
</div>
}
<!-- Created -->
@if (data().createdAt) {
<div class="summary-item">
<span class="summary-label">Created</span>
<span class="summary-value">{{ formatDate(data().createdAt!) }}</span>
</div>
}
<!-- Source -->
@if (data().source) {
<div class="summary-item">
<span class="summary-label">Source</span>
<span class="summary-value">{{ data().source }}</span>
</div>
}
<!-- ID -->
<div class="summary-item">
<span class="summary-label">Evidence ID</span>
<code class="summary-mono">{{ data().id }}</code>
</div>
</div>
</section>
`,
styles: [`
.verification-summary {
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-lg);
background: var(--color-surface-secondary);
padding: 1rem;
}
.section-title {
margin: 0 0 0.75rem 0;
font-size: 0.875rem;
font-weight: var(--font-weight-semibold);
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--color-text-secondary);
}
.summary-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
gap: 0.75rem;
}
.summary-item {
display: grid;
gap: 0.2rem;
}
.summary-label {
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--color-text-secondary);
}
.summary-value {
font-size: 0.875rem;
}
.summary-mono {
font-family: ui-monospace, SFMono-Regular, monospace;
font-size: 0.8125rem;
word-break: break-all;
}
.status-badge {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.2rem 0.5rem;
border-radius: var(--radius-full);
font-size: 0.75rem;
font-weight: var(--font-weight-medium);
width: fit-content;
}
.status-badge--verified {
background: var(--color-severity-low-bg);
color: var(--color-status-success-text);
}
.status-badge--unverified {
background: var(--color-severity-none-bg);
color: var(--color-text-secondary);
}
.status-badge--failed {
background: var(--color-severity-critical-bg);
color: var(--color-status-error-text);
}
.status-badge--pending {
background: var(--color-severity-medium-bg);
color: var(--color-status-warning-text);
}
.type-badge {
display: inline-block;
padding: 0.2rem 0.5rem;
border-radius: var(--radius-sm);
font-size: 0.75rem;
font-weight: var(--font-weight-medium);
text-transform: uppercase;
width: fit-content;
}
.type-badge--attestation {
background: var(--color-severity-info-bg);
color: var(--color-status-info-text);
}
.type-badge--signature {
background: var(--color-status-excepted-bg);
color: var(--color-status-excepted);
}
.type-badge--receipt {
background: var(--color-severity-low-bg);
color: var(--color-status-success-text);
}
.type-badge--bundle {
background: var(--color-severity-high-bg);
color: var(--color-severity-high);
}
.type-badge--witness {
background: var(--color-severity-info-bg);
color: var(--color-status-info-text);
}
.confidence-badge {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.2rem 0.5rem;
border-radius: var(--radius-sm);
font-size: 0.75rem;
font-weight: var(--font-weight-medium);
width: fit-content;
}
.confidence-badge--confirmed {
background: var(--color-severity-critical-bg);
color: var(--color-status-error-text);
}
.confidence-badge--likely {
background: var(--color-severity-high-bg);
color: var(--color-severity-high);
}
.confidence-badge--present {
background: var(--color-severity-none-bg);
color: var(--color-text-secondary);
}
.confidence-badge--unreachable {
background: var(--color-severity-low-bg);
color: var(--color-status-success-text);
}
.confidence-badge--unknown {
background: var(--color-severity-info-bg);
color: var(--color-status-info-text);
}
.confidence-score {
opacity: 0.8;
font-size: 0.7rem;
}
`],
})
export class VerificationSummaryComponent {
/** Verification summary data to display. */
readonly data = input.required<VerificationSummaryData>();
readonly statusLabel = computed(() => {
const labels: Record<VerificationStatus, string> = {
verified: 'Verified',
unverified: 'Unverified',
failed: 'Failed',
pending: 'Pending',
};
return labels[this.data().status] ?? this.data().status;
});
readonly confidenceLabel = computed(() => {
const labels: Record<ConfidenceTier, string> = {
confirmed: 'Confirmed Reachable',
likely: 'Likely Reachable',
present: 'Present',
unreachable: 'Unreachable',
unknown: 'Unknown',
};
return labels[this.data().confidenceTier!] ?? this.data().confidenceTier;
});
readonly confidencePercent = computed(() => {
const score = this.data().confidenceScore;
if (score == null) return '';
return `${Math.round(score * 100)}%`;
});
formatDate(isoDate: string): string {
try {
return new Intl.DateTimeFormat('en-US', {
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
month: 'short',
timeZone: 'UTC',
year: 'numeric',
}).format(new Date(isoDate));
} catch {
return isoDate;
}
}
}

View File

@@ -0,0 +1,82 @@
/**
* Shared witness/evidence proof-inspection models.
* Sprint: SPRINT_20260308_031_FE_witness_viewer_evidence_derivation
*
* Domain types used by the derived proof-inspection sections.
* These are intentionally presentation-level models that both the
* Reachability and Evidence features can map their domain data into.
*/
/** Verification status for an evidence artifact. */
export type VerificationStatus = 'verified' | 'unverified' | 'failed' | 'pending';
/** Confidence tier for reachability assessment (mirrors witness.models). */
export type ConfidenceTier = 'confirmed' | 'likely' | 'present' | 'unreachable' | 'unknown';
/**
* Input data for the verification summary section.
*/
export interface VerificationSummaryData {
/** Unique identifier for the evidence or witness. */
readonly id: string;
/** Human-readable label for the evidence type. */
readonly typeLabel: string;
/** CSS class suffix for the type badge (e.g., 'attestation', 'signature'). */
readonly typeBadge: string;
/** Current verification status. */
readonly status: VerificationStatus;
/** Confidence tier (if available). */
readonly confidenceTier?: ConfidenceTier;
/** Confidence score 0.0-1.0 (if available). */
readonly confidenceScore?: number;
/** When the evidence was created or observed. */
readonly createdAt?: string;
/** Evidence source identifier. */
readonly source?: string;
}
/**
* Input data for the signature inspector section.
*/
export interface SignatureData {
/** Signature identifier. */
readonly id: string;
/** Cryptographic algorithm (e.g., ECDSA-P256, Ed25519). */
readonly algorithm: string;
/** Key identifier. */
readonly keyId: string;
/** Truncated or full signature value. */
readonly value: string;
/** Timestamp of signature. */
readonly timestamp?: string;
/** Whether the signature has been verified. */
readonly verified: boolean;
/** Issuer of the signing key (optional). */
readonly issuer?: string;
}
/**
* Input data for the attestation detail section.
*/
export interface AttestationData {
/** In-toto predicate type URI. */
readonly predicateType: string;
/** Subject name. */
readonly subjectName: string;
/** Subject digests (algorithm -> hash). */
readonly subjectDigests: ReadonlyArray<{ readonly algorithm: string; readonly hash: string }>;
/** Predicate payload (arbitrary JSON). */
readonly predicate: Record<string, unknown>;
}
/**
* Input data for the evidence payload section.
*/
export interface EvidencePayloadData {
/** Evidence identifier for naming downloads. */
readonly evidenceId: string;
/** Raw content to display (JSON string or raw text). */
readonly rawContent: string;
/** Metadata key-value pairs to display. */
readonly metadata?: Record<string, unknown>;
}