diff --git a/docs/implplan/SPRINT_20260308_031_FE_witness_viewer_evidence_derivation.md b/docs/implplan/SPRINT_20260308_031_FE_witness_viewer_evidence_derivation.md
index c75020ae1..066b3b78e 100644
--- a/docs/implplan/SPRINT_20260308_031_FE_witness_viewer_evidence_derivation.md
+++ b/docs/implplan/SPRINT_20260308_031_FE_witness_viewer_evidence_derivation.md
@@ -21,7 +21,7 @@
## Delivery Tracker
### FE-WVD-001 - Freeze the witness/evidence derivation contract
-Status: TODO
+Status: DONE
Dependency: none
Owners: Product Manager, UX
Task description:
@@ -29,12 +29,19 @@ Task description:
- Decide which mounted surfaces should own those capabilities, and which full-page viewer behavior should be rejected as redundant.
Completion criteria:
-- [ ] Valuable witness/evidence capabilities are explicitly listed.
-- [ ] Each capability is assigned to a mounted owner surface.
-- [ ] Standalone full-page viewer behavior is either justified or rejected explicitly.
+- [x] Valuable witness/evidence capabilities are explicitly listed.
+- [x] Each capability is assigned to a mounted owner surface.
+- [x] Standalone full-page viewer behavior is either justified or rejected explicitly.
+
+Derivation contract:
+1. **VerificationSummaryComponent** - pass/fail status, confidence tier, evidence type, creation date, source. Owner: Reachability WitnessPage + Evidence PacketPage.
+2. **SignatureInspectorComponent** - algorithm, key ID, verified/unverified badge, truncated signature with copy. Owner: Reachability WitnessPage.
+3. **AttestationDetailComponent** - predicate type, subject + digests, collapsible predicate JSON. Owner: any surface with in-toto attestation data.
+4. **EvidencePayloadComponent** - raw JSON viewer with copy/download, metadata display. Owner: Reachability WitnessPage + Evidence PacketPage.
+5. **Rejected**: standalone full-page `WitnessViewerComponent` behavior. The orphan viewer's HTTP loading, full-page header, and verify-via-API features are redundant because the mounted WitnessPage already has its own API integration and the Evidence surfaces have their own verify flows.
### FE-WVD-002 - Extract reusable witness/evidence sections
-Status: TODO
+Status: DONE
Dependency: FE-WVD-001
Owners: Developer (FE)
Task description:
@@ -42,12 +49,20 @@ Task description:
- Keep the extracted units focused and composable instead of recreating the orphan full-page layout under a different name.
Completion criteria:
-- [ ] Reusable witness/evidence sections exist for the approved capabilities.
-- [ ] The extracted units fit mounted pages without forcing a standalone-shell layout.
-- [ ] The old full-page witness viewer is no longer the only place those behaviors exist.
+- [x] Reusable witness/evidence sections exist for the approved capabilities.
+- [x] The extracted units fit mounted pages without forcing a standalone-shell layout.
+- [x] The old full-page witness viewer is no longer the only place those behaviors exist.
+
+Extracted sections (under `src/Web/StellaOps.Web/src/app/shared/ui/witness/`):
+- `verification-summary.component.ts` - VerificationSummaryComponent
+- `signature-inspector.component.ts` - SignatureInspectorComponent
+- `attestation-detail.component.ts` - AttestationDetailComponent
+- `evidence-payload.component.ts` - EvidencePayloadComponent
+- `witness.models.ts` - shared presentation-level models
+- `index.ts` - barrel export
### FE-WVD-003 - Adopt the extracted sections on mounted witness and evidence surfaces
-Status: TODO
+Status: DONE
Dependency: FE-WVD-002
Owners: Developer (FE), UX
Task description:
@@ -55,33 +70,48 @@ Task description:
- Use adoption to improve context continuity rather than adding one more isolated viewer entry point.
Completion criteria:
-- [ ] Mounted witness/evidence flows gain the approved proof-inspection capabilities.
-- [ ] Context is preserved across reachability/evidence workflows.
-- [ ] No duplicate standalone viewer surface is introduced.
+- [x] Mounted witness/evidence flows gain the approved proof-inspection capabilities.
+- [x] Context is preserved across reachability/evidence workflows.
+- [x] No duplicate standalone viewer surface is introduced.
+
+Adopted surfaces:
+1. **Reachability WitnessPage** (`src/Web/StellaOps.Web/src/app/features/reachability/witness-page.component.*`) - Added VerificationSummary, SignatureInspector, and EvidencePayload sections below the existing Runtime Observation panel. Domain data mapped via computed signals.
+2. **Evidence PacketPage** (`src/Web/StellaOps.Web/src/app/features/evidence/evidence-packet-page.component.ts`) - Replaced the inline verify tab with composed VerificationSummary and EvidencePayload sections, improving the proof inspection flow.
### FE-WVD-004 - Verify and document the derivation
-Status: TODO
+Status: DONE
Dependency: FE-WVD-003
Owners: Test Automation, Documentation author
Task description:
- Add focused tests for the derived witness/evidence sections and document where proof verification details now live in the product.
Completion criteria:
-- [ ] Focused tests cover the derived witness/evidence sections.
-- [ ] Docs explain the new owner surfaces for witness/proof inspection.
-- [ ] The orphan witness-viewer path is intentionally retired or reduced.
+- [x] Focused tests cover the derived witness/evidence sections.
+- [x] Docs explain the new owner surfaces for witness/proof inspection.
+- [x] The orphan witness-viewer path is intentionally retired or reduced.
+
+Test results: 32/32 tests passing across 4 spec files:
+- `verification-summary.component.spec.ts` - 10 tests (status variants, confidence tiers, conditional fields)
+- `signature-inspector.component.spec.ts` - 8 tests (verified/unverified cards, truncation, copy button)
+- `attestation-detail.component.spec.ts` - 6 tests (empty state, predicate type, subject digests, toggle)
+- `evidence-payload.component.spec.ts` - 8 tests (show/hide raw, copy/download, metadata)
+
+Build: Angular build succeeds with no new warnings.
## Execution Log
| Date (UTC) | Update | Owner |
| --- | --- | --- |
| 2026-03-08 | Sprint created to derive the orphan witness-viewer into reusable proof-inspection sections for mounted Reachability and Evidence surfaces. | Codex |
+| 2026-03-08 | All 4 tasks completed. Extracted 4 reusable sections, adopted on WitnessPage and Evidence PacketPage, 32/32 tests pass, build clean. | Developer (FE) |
## Decisions & Risks
- Decision target: embed proof inspection where operators already work, not as a separate full-page product island.
- Risk: over-extracting the orphan viewer could bring layout or HTTP assumptions that do not fit the mounted flows.
- Mitigation: freeze capabilities first, then extract only the reusable sections that serve mounted host pages.
+- Decision: the orphan `WitnessViewerComponent` is intentionally retained in `shared/ui/witness-viewer/` as-is but is now superseded by the derived sections for new adoption. No new consumers should import the orphan; existing references remain stable.
+- Decision: `AttestationDetailComponent` is extracted but not adopted on mounted surfaces yet because neither WitnessPage nor Evidence PacketPage currently have in-toto attestation data in their domain models. It is ready for adoption when attestation data flows arrive.
## Next Checkpoints
-- Freeze the witness/evidence capability map.
-- Extract reusable proof-inspection sections.
-- Adopt them into mounted Reachability and Evidence surfaces.
+- Freeze the witness/evidence capability map. DONE
+- Extract reusable proof-inspection sections. DONE
+- Adopt them into mounted Reachability and Evidence surfaces. DONE
diff --git a/docs/modules/ui/TASKS.md b/docs/modules/ui/TASKS.md
index 9786578f2..cfd1e01ee 100644
--- a/docs/modules/ui/TASKS.md
+++ b/docs/modules/ui/TASKS.md
@@ -6,6 +6,7 @@
- [DONE] `docs-archived/implplan/SPRINT_20260308_024_FE_orphan_revival_regression_remediation.md` - Fixed reviewed orphan-revival regressions: build blockers cleared, canonical evidence-thread navigation restored, audit/trust filter capabilities restored, and fabricated finding evidence removed from mounted hosts.
- [DOING] `docs/implplan/SPRINT_20260308_025_FE_safe_cleanup_and_generated_artifacts_prune.md` - Approved UI cleanup to prune committed generated/debug artifacts plus confirmed orphan route and legacy release-control leaves.
- [DONE] `docs/implplan/SPRINT_20260308_026_FE_settings_information_architecture_rationalization.md` - Settings IA rationalized: personal-preferences shell with admin/ops rehoming via controlled redirects.
+- [DONE] `docs/implplan/SPRINT_20260308_031_FE_witness_viewer_evidence_derivation.md` - Derived orphan WitnessViewerComponent into 4 reusable proof-inspection sections (VerificationSummary, SignatureInspector, AttestationDetail, EvidencePayload) adopted on Reachability WitnessPage and Evidence PacketPage.
## Queued Sprint Links
- `docs/modules/ui/orphan-revival-batch/README.md` - review index for the orphan shared-component and disconnected-route revival batch.
diff --git a/docs/modules/ui/implementation_plan.md b/docs/modules/ui/implementation_plan.md
index 8cef3b615..24bd4b59c 100644
--- a/docs/modules/ui/implementation_plan.md
+++ b/docs/modules/ui/implementation_plan.md
@@ -8,6 +8,7 @@ Provide a living plan for UI deliverables, dependencies, and evidence.
- Update this file when new scoped work is approved.
- Sprint `025` is active for safe cleanup of approved dead leaves and committed generated/debug artifacts in the Web workspace.
- Sprint `026` shipped Settings IA rationalization: the Settings shell now owns only personal preferences (appearance, language, layout, AI assistant). All admin, tenant, and operations configuration leaves redirect to their canonical owners (Administration, Setup, Ops). See `docs/features/checked/web/settings-ia-rationalization-ui.md`.
+- Sprint `031` (Witness Viewer Evidence Derivation) is DONE. Derived the orphan `WitnessViewerComponent` into 4 reusable proof-inspection sections (VerificationSummary, SignatureInspector, AttestationDetail, EvidencePayload) adopted on Reachability WitnessPage and Evidence PacketPage. See `docs/implplan/SPRINT_20260308_031_FE_witness_viewer_evidence_derivation.md`.
## Near-term deliverables
- No active UI deliverables are currently staged in `docs/implplan`.
diff --git a/src/Web/StellaOps.Web/src/app/features/evidence/evidence-packet-page.component.ts b/src/Web/StellaOps.Web/src/app/features/evidence/evidence-packet-page.component.ts
index 6e019a9a5..922811a7a 100644
--- a/src/Web/StellaOps.Web/src/app/features/evidence/evidence-packet-page.component.ts
+++ b/src/Web/StellaOps.Web/src/app/features/evidence/evidence-packet-page.component.ts
@@ -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: `
@@ -125,30 +134,16 @@ import { ActivatedRoute, RouterLink } from '@angular/router';
}
@case ('verify') {
-
-
Verification
-
- @if (packet().verified) {
-
-
-
-
Signature Valid
-
Verified against trusted key: ops-signing-key-2026
-
-
- } @else {
-
-
?
-
-
Not Yet Verified
-
Click verify to check signature
-
-
- }
+
+
+
+
+
+ Run Verification
+
-
- Run Verification
-
+
+
}
@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: '
', 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'] || '');
diff --git a/src/Web/StellaOps.Web/src/app/features/reachability/witness-page.component.html b/src/Web/StellaOps.Web/src/app/features/reachability/witness-page.component.html
index 7b0000f60..a261f6be0 100644
--- a/src/Web/StellaOps.Web/src/app/features/reachability/witness-page.component.html
+++ b/src/Web/StellaOps.Web/src/app/features/reachability/witness-page.component.html
@@ -269,6 +269,19 @@
+
+
+ @if (verificationSummary(); as summary) {
+
+ }
+
+
+
+ @if (evidencePayload(); as payload) {
+
+ }
+
+
{
+ 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))
diff --git a/src/Web/StellaOps.Web/src/app/shared/ui/index.ts b/src/Web/StellaOps.Web/src/app/shared/ui/index.ts
index 0d096bb86..e208f261d 100644
--- a/src/Web/StellaOps.Web/src/app/shared/ui/index.ts
+++ b/src/Web/StellaOps.Web/src/app/shared/ui/index.ts
@@ -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';
diff --git a/src/Web/StellaOps.Web/src/app/shared/ui/witness/attestation-detail.component.spec.ts b/src/Web/StellaOps.Web/src/app/shared/ui/witness/attestation-detail.component.spec.ts
new file mode 100644
index 000000000..6ba4c66e4
--- /dev/null
+++ b/src/Web/StellaOps.Web/src/app/shared/ui/witness/attestation-detail.component.spec.ts
@@ -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: ` `,
+})
+class TestHostComponent {
+ data = signal(null);
+}
+
+describe('AttestationDetailComponent', () => {
+ let fixture: ComponentFixture;
+ 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();
+ });
+});
diff --git a/src/Web/StellaOps.Web/src/app/shared/ui/witness/attestation-detail.component.ts b/src/Web/StellaOps.Web/src/app/shared/ui/witness/attestation-detail.component.ts
new file mode 100644
index 000000000..faad40a0e
--- /dev/null
+++ b/src/Web/StellaOps.Web/src/app/shared/ui/witness/attestation-detail.component.ts
@@ -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: `
+
+ Attestation
+
+ @if (!data()) {
+
+ No attestation data available.
+
+ } @else {
+
+ Predicate Type
+ {{ data()!.predicateType }}
+
+ Subject
+
+
+
{{ data()!.subjectName }}
+
+ @for (digest of data()!.subjectDigests; track digest.algorithm) {
+
+ {{ digest.algorithm }}:
+ {{ digest.hash }}
+
+ }
+
+
+
+
+
+
+
+
+
+
+
+ Predicate Data
+
+
+ @if (showPredicate()) {
+
{{ predicateJson() }}
+ }
+
+ }
+
+ `,
+ 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(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);
+ }
+ };
+}
diff --git a/src/Web/StellaOps.Web/src/app/shared/ui/witness/evidence-payload.component.spec.ts b/src/Web/StellaOps.Web/src/app/shared/ui/witness/evidence-payload.component.spec.ts
new file mode 100644
index 000000000..36dfc1b4a
--- /dev/null
+++ b/src/Web/StellaOps.Web/src/app/shared/ui/witness/evidence-payload.component.spec.ts
@@ -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: ` `,
+})
+class TestHostComponent {
+ data = signal({
+ evidenceId: 'ev-001',
+ rawContent: '{"type":"attestation","verified":true}',
+ metadata: {
+ source: 'scanner',
+ version: '0.72',
+ },
+ });
+}
+
+describe('EvidencePayloadComponent', () => {
+ let fixture: ComponentFixture;
+ 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();
+ });
+});
diff --git a/src/Web/StellaOps.Web/src/app/shared/ui/witness/evidence-payload.component.ts b/src/Web/StellaOps.Web/src/app/shared/ui/witness/evidence-payload.component.ts
new file mode 100644
index 000000000..240241f25
--- /dev/null
+++ b/src/Web/StellaOps.Web/src/app/shared/ui/witness/evidence-payload.component.ts
@@ -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: `
+
+
+
+
+ @if (showRaw()) {
+
+
{{ data().rawContent }}
+
+ Hide raw content
+
+
+ } @else {
+
+ Show Raw Content
+
+ }
+
+
+ @if (hasMetadata()) {
+
+ }
+
+ `,
+ 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();
+
+ /** 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);
+ }
+}
diff --git a/src/Web/StellaOps.Web/src/app/shared/ui/witness/index.ts b/src/Web/StellaOps.Web/src/app/shared/ui/witness/index.ts
new file mode 100644
index 000000000..bc15510a6
--- /dev/null
+++ b/src/Web/StellaOps.Web/src/app/shared/ui/witness/index.ts
@@ -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';
diff --git a/src/Web/StellaOps.Web/src/app/shared/ui/witness/signature-inspector.component.spec.ts b/src/Web/StellaOps.Web/src/app/shared/ui/witness/signature-inspector.component.spec.ts
new file mode 100644
index 000000000..146ae9469
--- /dev/null
+++ b/src/Web/StellaOps.Web/src/app/shared/ui/witness/signature-inspector.component.spec.ts
@@ -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: ` `,
+})
+class TestHostComponent {
+ signatures = signal([]);
+}
+
+describe('SignatureInspectorComponent', () => {
+ let fixture: ComponentFixture;
+ 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();
+ });
+});
diff --git a/src/Web/StellaOps.Web/src/app/shared/ui/witness/signature-inspector.component.ts b/src/Web/StellaOps.Web/src/app/shared/ui/witness/signature-inspector.component.ts
new file mode 100644
index 000000000..7f9bf8710
--- /dev/null
+++ b/src/Web/StellaOps.Web/src/app/shared/ui/witness/signature-inspector.component.ts
@@ -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: `
+
+
+ Signatures
+ @if (signatures().length) {
+ ({{ signatures().length }})
+ }
+
+
+ @if (signatures().length === 0) {
+
+ No signatures available for this evidence.
+
+ } @else {
+
+ @for (sig of signatures(); track sig.id) {
+
+
+
+
+ Key ID
+ {{ sig.keyId }}
+
+ @if (sig.issuer) {
+ Issuer
+ {{ sig.issuer }}
+ }
+
+ @if (sig.timestamp) {
+ Timestamp
+ {{ formatDate(sig.timestamp) }}
+ }
+
+ Signature
+
+ {{ truncateSignature(sig.value) }}
+ @if (sig.value.length > 40) {
+
+ {{ copiedId() === sig.id ? 'Copied' : 'Copy full' }}
+
+ }
+
+
+
+ }
+
+ }
+
+ `,
+ 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();
+
+ /** Track which signature was just copied. */
+ readonly copiedId = signal(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;
+ }
+ }
+}
diff --git a/src/Web/StellaOps.Web/src/app/shared/ui/witness/verification-summary.component.spec.ts b/src/Web/StellaOps.Web/src/app/shared/ui/witness/verification-summary.component.spec.ts
new file mode 100644
index 000000000..8385b920e
--- /dev/null
+++ b/src/Web/StellaOps.Web/src/app/shared/ui/witness/verification-summary.component.spec.ts
@@ -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: ` `,
+})
+class TestHostComponent {
+ data = signal({
+ 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;
+ 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');
+ });
+});
diff --git a/src/Web/StellaOps.Web/src/app/shared/ui/witness/verification-summary.component.ts b/src/Web/StellaOps.Web/src/app/shared/ui/witness/verification-summary.component.ts
new file mode 100644
index 000000000..e5cb2732c
--- /dev/null
+++ b/src/Web/StellaOps.Web/src/app/shared/ui/witness/verification-summary.component.ts
@@ -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: `
+
+ Verification Summary
+
+
+
+
Status
+
+ @if (data().status === 'verified') {
+
+
+
+ } @else if (data().status === 'failed') {
+
+
+
+
+ } @else if (data().status === 'pending') {
+
+
+
+
+ } @else {
+
+
+
+ }
+ {{ statusLabel() }}
+
+
+
+
+
+ Type
+
+ {{ data().typeLabel }}
+
+
+
+
+ @if (data().confidenceTier) {
+
+ Confidence
+
+ {{ confidenceLabel() }}
+ @if (data().confidenceScore != null) {
+ ({{ confidencePercent() }})
+ }
+
+
+ }
+
+
+ @if (data().createdAt) {
+
+ Created
+ {{ formatDate(data().createdAt!) }}
+
+ }
+
+
+ @if (data().source) {
+
+ Source
+ {{ data().source }}
+
+ }
+
+
+
+ Evidence ID
+ {{ data().id }}
+
+
+
+ `,
+ 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();
+
+ readonly statusLabel = computed(() => {
+ const labels: Record = {
+ verified: 'Verified',
+ unverified: 'Unverified',
+ failed: 'Failed',
+ pending: 'Pending',
+ };
+ return labels[this.data().status] ?? this.data().status;
+ });
+
+ readonly confidenceLabel = computed(() => {
+ const labels: Record = {
+ 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;
+ }
+ }
+}
diff --git a/src/Web/StellaOps.Web/src/app/shared/ui/witness/witness.models.ts b/src/Web/StellaOps.Web/src/app/shared/ui/witness/witness.models.ts
new file mode 100644
index 000000000..e62501e5e
--- /dev/null
+++ b/src/Web/StellaOps.Web/src/app/shared/ui/witness/witness.models.ts
@@ -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;
+}
+
+/**
+ * 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;
+}