From 40df29f805763409dc4b3c6cbea120bbbe2d4116 Mon Sep 17 00:00:00 2001 From: Sanjay Date: Sat, 15 Aug 2026 03:38:29 -0400 Subject: [PATCH 1/3] feat: turn dead-end empty states into onboarding CTAs with iconography - EmptyState component: reusable empty-state with icon, title, description, optional CTA button/link, and hint text - Provider view: EdgeSyncStatus (register node CTA), AgentList (deploy edge CTA), DecisionFeed (configure policy CTA), PolicyCard (set active policy CTA), TrustSummaryChart (waiting for data hint) - Admin view: GraphHealth (run bootstrap seed CTA), TenantList (no tenants explanation), BootstrapEditor (run seed CTA), IssuerVerificationQueue (queue is empty explanation) - Agent-builder view: PrincipalList (generate keypair CTA), KeyList (generate keypair CTA), ScoreHistoryChart (score computation explanation), AttestationFeed (client docs CTA for outgoing, signing explanation for incoming) - StalenessBanner: enhanced with troubleshooting explanation and link - Styles: empty-state, banner--stale, stat-card, candidate-card CSS --- dashboard/src/components/EmptyState.tsx | 61 +++++++++++++ dashboard/src/pages/admin/BootstrapEditor.tsx | 14 ++- dashboard/src/pages/admin/GraphHealth.tsx | 18 +++- .../pages/admin/IssuerVerificationQueue.tsx | 10 +- dashboard/src/pages/admin/TenantList.tsx | 10 +- .../pages/agent-builder/AttestationFeed.tsx | 27 +++++- dashboard/src/pages/agent-builder/KeyList.tsx | 14 ++- .../src/pages/agent-builder/PrincipalList.tsx | 16 +++- .../pages/agent-builder/ScoreHistoryChart.tsx | 10 +- dashboard/src/pages/provider/AgentList.tsx | 16 +++- dashboard/src/pages/provider/DecisionFeed.tsx | 14 ++- .../src/pages/provider/EdgeSyncStatus.tsx | 14 ++- dashboard/src/pages/provider/PolicyCard.tsx | 15 ++- .../src/pages/provider/StalenessBanner.tsx | 11 ++- .../src/pages/provider/TrustSummaryChart.tsx | 10 +- dashboard/src/styles/index.css | 91 +++++++++++++++++++ 16 files changed, 331 insertions(+), 20 deletions(-) create mode 100644 dashboard/src/components/EmptyState.tsx diff --git a/dashboard/src/components/EmptyState.tsx b/dashboard/src/components/EmptyState.tsx new file mode 100644 index 0000000..3360cb1 --- /dev/null +++ b/dashboard/src/components/EmptyState.tsx @@ -0,0 +1,61 @@ + + +export interface EmptyStateProps { + icon: string; + title: string; + description: string; + action?: { + label: string; + href?: string; + onClick?: () => void; + }; + hint?: string; +} + +const ICONS: Record = { + edge: '\u{1F5A8}', + agents: '\u{1F916}', + decisions: '\u{1F4CB}', + policy: '\u{2696}', + graph: '\u{1F578}', + tenants: '\u{1F3E2}', + bootstrap: '\u{1F331}', + shield: '\u{1F6E1}', + keys: '\u{1F511}', + score: '\u{1F4C8}', + attest: '\u{1F4DD}', + issuer: '\u{1F5D3}', + clock: '\u{23F0}', + chart: '\u{1F4CA}', + plug: '\u{1F50C}', + seedling: '\u{1F331}', + warning: '\u{26A0}', + empty: '\u{1F4ED}', +}; + +export function EmptyState({ icon, title, description, action, hint }: EmptyStateProps) { + const glyph = ICONS[icon] ?? ICONS.empty; + return ( +
+ +
+

{title}

+

{description}

+ {action && ( +
+ {action.href ? ( + + {action.label} + + ) : ( + + )} +
+ )} + {hint &&

{hint}

} +
+
+ ); +} \ No newline at end of file diff --git a/dashboard/src/pages/admin/BootstrapEditor.tsx b/dashboard/src/pages/admin/BootstrapEditor.tsx index 14b1348..c50abaa 100644 --- a/dashboard/src/pages/admin/BootstrapEditor.tsx +++ b/dashboard/src/pages/admin/BootstrapEditor.tsx @@ -1,5 +1,6 @@ import { useState } from 'react'; import type { BootstrapIssuer } from '../../api/admin'; +import { EmptyState } from '../../components/EmptyState'; export interface BootstrapEditorProps { issuers: BootstrapIssuer[]; @@ -10,7 +11,18 @@ const STEPS = [1.0, 0.5, 0.25, 0]; export function BootstrapEditor({ issuers, onUpdate }: BootstrapEditorProps) { if (issuers.length === 0) { - return

No bootstrap issuers registered.

; + return ( + + ); } return ( diff --git a/dashboard/src/pages/admin/GraphHealth.tsx b/dashboard/src/pages/admin/GraphHealth.tsx index 84c97fc..eac6c4f 100644 --- a/dashboard/src/pages/admin/GraphHealth.tsx +++ b/dashboard/src/pages/admin/GraphHealth.tsx @@ -1,8 +1,24 @@ import type { GraphSummary } from '../../api/provider'; +import { EmptyState } from '../../components/EmptyState'; -/** Read-only graph health (Plan 9 Decision 5: no path explorer). */ export function GraphHealth({ summary }: { summary: GraphSummary | undefined }) { if (!summary) return

Loading graph health...

; + + if (summary.principals.total === 0) { + return ( + + ); + } + return (
diff --git a/dashboard/src/pages/admin/IssuerVerificationQueue.tsx b/dashboard/src/pages/admin/IssuerVerificationQueue.tsx index d0279fa..526178c 100644 --- a/dashboard/src/pages/admin/IssuerVerificationQueue.tsx +++ b/dashboard/src/pages/admin/IssuerVerificationQueue.tsx @@ -1,8 +1,16 @@ import type { UnverifiedIssuer } from '../../api/admin'; +import { EmptyState } from '../../components/EmptyState'; export function IssuerVerificationQueue({ issuers }: { issuers: UnverifiedIssuer[] }) { if (issuers.length === 0) { - return

No issuers awaiting verification.

; + return ( + + ); } return (
diff --git a/dashboard/src/pages/admin/TenantList.tsx b/dashboard/src/pages/admin/TenantList.tsx index 6d4d113..5add062 100644 --- a/dashboard/src/pages/admin/TenantList.tsx +++ b/dashboard/src/pages/admin/TenantList.tsx @@ -1,8 +1,16 @@ import type { TenantRow } from '../../api/admin'; +import { EmptyState } from '../../components/EmptyState'; export function TenantList({ tenants }: { tenants: TenantRow[] }) { if (tenants.length === 0) { - return

No tenants visible to this account.

; + return ( + + ); } return (
diff --git a/dashboard/src/pages/agent-builder/AttestationFeed.tsx b/dashboard/src/pages/agent-builder/AttestationFeed.tsx index 9f45d5c..fc67020 100644 --- a/dashboard/src/pages/agent-builder/AttestationFeed.tsx +++ b/dashboard/src/pages/agent-builder/AttestationFeed.tsx @@ -1,4 +1,5 @@ import type { AttestationRow } from '../../api/agentBuilder'; +import { EmptyState } from '../../components/EmptyState'; function shortId(id: string): string { const parts = id.split(':'); @@ -14,10 +15,30 @@ export function AttestationFeed({ items: AttestationRow[]; }) { if (items.length === 0) { + const isIn = direction === 'in'; return ( -

- No {direction === 'in' ? 'incoming' : 'outgoing'} attestations visible. -

+ ); } return ( diff --git a/dashboard/src/pages/agent-builder/KeyList.tsx b/dashboard/src/pages/agent-builder/KeyList.tsx index 25d83b2..137c338 100644 --- a/dashboard/src/pages/agent-builder/KeyList.tsx +++ b/dashboard/src/pages/agent-builder/KeyList.tsx @@ -1,8 +1,20 @@ import type { PrincipalKey } from '../../api/agentBuilder'; +import { EmptyState } from '../../components/EmptyState'; export function KeyList({ keys }: { keys: PrincipalKey[] }) { if (keys.length === 0) { - return

No keys registered for this principal.

; + return ( + + ); } return (
diff --git a/dashboard/src/pages/agent-builder/PrincipalList.tsx b/dashboard/src/pages/agent-builder/PrincipalList.tsx index eb0705b..e502182 100644 --- a/dashboard/src/pages/agent-builder/PrincipalList.tsx +++ b/dashboard/src/pages/agent-builder/PrincipalList.tsx @@ -1,4 +1,5 @@ import type { OwnedPrincipal } from '../../api/agentBuilder'; +import { EmptyState } from '../../components/EmptyState'; export interface PrincipalListProps { principals: OwnedPrincipal[]; @@ -8,7 +9,18 @@ export interface PrincipalListProps { export function PrincipalList({ principals, selectedId, onSelect }: PrincipalListProps) { if (principals.length === 0) { - return

No owned principals yet. Create one to start building.

; + return ( + + ); } return (
    @@ -22,7 +34,7 @@ export function PrincipalList({ principals, selectedId, onSelect }: PrincipalLis {p.name ?? p.id} {p.entity_kind} - {' · '} + {' \u00B7 '} {p.assurance_level === 'verified_key' ? ( verified key ) : ( diff --git a/dashboard/src/pages/agent-builder/ScoreHistoryChart.tsx b/dashboard/src/pages/agent-builder/ScoreHistoryChart.tsx index f6c896e..2631fd9 100644 --- a/dashboard/src/pages/agent-builder/ScoreHistoryChart.tsx +++ b/dashboard/src/pages/agent-builder/ScoreHistoryChart.tsx @@ -8,10 +8,18 @@ import { YAxis, } from 'recharts'; import type { ScoreSeriesPoint } from '../../lib/scoreSeries'; +import { EmptyState } from '../../components/EmptyState'; export function ScoreHistoryChart({ points }: { points: ScoreSeriesPoint[] }) { if (points.length === 0) { - return

    No score history recorded for this principal yet.

    ; + return ( + + ); } return (
    diff --git a/dashboard/src/pages/provider/AgentList.tsx b/dashboard/src/pages/provider/AgentList.tsx index 81f0306..9c64175 100644 --- a/dashboard/src/pages/provider/AgentList.tsx +++ b/dashboard/src/pages/provider/AgentList.tsx @@ -1,7 +1,7 @@ import type { AgentRow } from '../../api/provider'; +import { EmptyState } from '../../components/EmptyState'; function shortId(id: string): string { - // vrl:p: -> trailing 8 chars keeps tables readable const parts = id.split(':'); const tail = parts[parts.length - 1] ?? id; return tail.length > 8 ? `...${tail.slice(-8)}` : tail; @@ -9,7 +9,18 @@ function shortId(id: string): string { export function AgentList({ agents }: { agents: AgentRow[] }) { if (agents.length === 0) { - return

    No agents observed in the last 24 hours.

    ; + return ( + + ); } return (
@@ -33,7 +44,6 @@ export function AgentList({ agents }: { agents: AgentRow[] }) {
{a.decisions} {a.score ?? -} - {/* blacklisted comes from network_scores only - never inferred from score */} {a.blacklisted ? ( blacklisted ) : a.score === null ? ( diff --git a/dashboard/src/pages/provider/DecisionFeed.tsx b/dashboard/src/pages/provider/DecisionFeed.tsx index 57f5c4e..b45e3e1 100644 --- a/dashboard/src/pages/provider/DecisionFeed.tsx +++ b/dashboard/src/pages/provider/DecisionFeed.tsx @@ -1,4 +1,5 @@ import type { SampleRow } from '../../api/provider'; +import { EmptyState } from '../../components/EmptyState'; function shortFingerprint(fp: string): string { return fp.length > 12 ? `${fp.slice(0, 12)}...` : fp; @@ -6,7 +7,18 @@ function shortFingerprint(fp: string): string { export function DecisionFeed({ samples }: { samples: SampleRow[] }) { if (samples.length === 0) { - return

No sampled decisions in the last 24 hours.

; + return ( + + ); } return ( diff --git a/dashboard/src/pages/provider/EdgeSyncStatus.tsx b/dashboard/src/pages/provider/EdgeSyncStatus.tsx index 6193e57..714b756 100644 --- a/dashboard/src/pages/provider/EdgeSyncStatus.tsx +++ b/dashboard/src/pages/provider/EdgeSyncStatus.tsx @@ -1,8 +1,20 @@ import type { EdgeNodeRow } from '../../api/provider'; +import { EmptyState } from '../../components/EmptyState'; export function EdgeSyncStatus({ edges }: { edges: EdgeNodeRow[] }) { if (edges.length === 0) { - return

No edge nodes registered for this tenant yet.

; + return ( + + ); } return (
diff --git a/dashboard/src/pages/provider/PolicyCard.tsx b/dashboard/src/pages/provider/PolicyCard.tsx index 3456711..d0e81f4 100644 --- a/dashboard/src/pages/provider/PolicyCard.tsx +++ b/dashboard/src/pages/provider/PolicyCard.tsx @@ -1,9 +1,20 @@ import type { Policy } from '../../api/provider'; +import { EmptyState } from '../../components/EmptyState'; -/** Read-only active policy summary. Editing lands in Plan 9 PR C. */ export function PolicyCard({ policy }: { policy: Policy | null }) { if (!policy) { - return

No active policy for this tenant.

; + return ( + + ); } return (
diff --git a/dashboard/src/pages/provider/StalenessBanner.tsx b/dashboard/src/pages/provider/StalenessBanner.tsx index 7d615d1..3fd3c03 100644 --- a/dashboard/src/pages/provider/StalenessBanner.tsx +++ b/dashboard/src/pages/provider/StalenessBanner.tsx @@ -2,7 +2,16 @@ export function StalenessBanner({ show }: { show: boolean }) { if (!show) return null; return (
- Scores may be stale — no successful network score write in over an hour. + +
+

Scores may be stale

+

+ No successful network score write in over an hour. This usually means the trust-engine is not running or cannot reach the control plane. +

+
+ + Troubleshoot +
); } diff --git a/dashboard/src/pages/provider/TrustSummaryChart.tsx b/dashboard/src/pages/provider/TrustSummaryChart.tsx index ac034a9..1e2d295 100644 --- a/dashboard/src/pages/provider/TrustSummaryChart.tsx +++ b/dashboard/src/pages/provider/TrustSummaryChart.tsx @@ -9,6 +9,7 @@ import { YAxis, } from 'recharts'; import type { BucketPoint } from '../../lib/aggregates'; +import { EmptyState } from '../../components/EmptyState'; const ACTION_COLORS: Record = { allow: '#0f6b4c', @@ -18,7 +19,14 @@ const ACTION_COLORS: Record = { export function TrustSummaryChart({ points }: { points: BucketPoint[] }) { if (points.length === 0) { - return

No decisions in the last 24 hours.

; + return ( + + ); } return (
diff --git a/dashboard/src/styles/index.css b/dashboard/src/styles/index.css index 7055cdd..634867a 100644 --- a/dashboard/src/styles/index.css +++ b/dashboard/src/styles/index.css @@ -424,3 +424,94 @@ a { background: var(--danger); color: #fff; } + +/* Empty state component */ +.empty-state { + display: flex; + align-items: flex-start; + gap: 1rem; + padding: 1.5rem 1rem; +} +.empty-state__icon { + font-size: 2rem; + line-height: 1; + flex-shrink: 0; + padding-top: 0.25rem; +} +.empty-state__body { + flex: 1; +} +.empty-state__title { + font-weight: 600; + margin: 0 0 0.25rem 0; + color: var(--ink); +} +.empty-state__desc { + margin: 0 0 0.75rem 0; + font-size: 0.875rem; + line-height: 1.4; +} +.empty-state__action { + margin-bottom: 0.5rem; +} +.empty-state__cta { + display: inline-block; + padding: 0.4rem 0.9rem; + border: 1px solid var(--accent); + border-radius: 4px; + background: transparent; + color: var(--accent); + font-size: 0.875rem; + font-weight: 500; + text-decoration: none; + cursor: pointer; + transition: background 0.15s, color 0.15s; +} +.empty-state__cta:hover { + background: var(--accent); + color: #fff; +} +.empty-state__hint { + font-size: 0.8rem; + margin: 0; + font-style: italic; +} + +/* Enhanced staleness banner */ +.banner--stale { + display: flex; + align-items: flex-start; + gap: 0.75rem; + padding: 0.75rem 1rem; + margin-bottom: 1rem; + border: 1px solid var(--danger); + border-radius: 6px; + background: #fff5f5; + color: var(--danger); +} +.banner--stale__icon { + font-size: 1.25rem; + flex-shrink: 0; +} +.banner--stale__body { + flex: 1; +} +.banner--stale__title { + font-weight: 600; + margin: 0; +} +.banner--stale__desc { + font-size: 0.8rem; + margin: 0.25rem 0 0 0; + color: var(--muted); +} +.banner--stale__action { + font-size: 0.8rem; + color: var(--accent); + text-decoration: none; + font-weight: 500; + white-space: nowrap; +} +.banner--stale__action:hover { + text-decoration: underline; +} From a98e6d836c1dd648c3f97f049c2305af5c6dd0c4 Mon Sep 17 00:00:00 2001 From: Sanjay Date: Sat, 15 Aug 2026 03:58:15 -0400 Subject: [PATCH 2/3] test: update empty decision state assertion --- dashboard/src/pages/provider/components.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dashboard/src/pages/provider/components.test.tsx b/dashboard/src/pages/provider/components.test.tsx index c7a4be8..d661152 100644 --- a/dashboard/src/pages/provider/components.test.tsx +++ b/dashboard/src/pages/provider/components.test.tsx @@ -84,6 +84,6 @@ describe('DecisionFeed', () => { it('renders an empty state', () => { render(); - expect(screen.getByText(/No sampled decisions/)).toBeTruthy(); + expect(screen.getByText(/No decisions recorded/)).toBeTruthy(); }); }); From 6299b08780f0af3e9bcb53c350a9e4dc0c198fdc Mon Sep 17 00:00:00 2001 From: Sanjay Date: Sat, 15 Aug 2026 04:03:38 -0400 Subject: [PATCH 3/3] fix: tighten empty-state actions and bootstrap runbook labels --- dashboard/src/components/EmptyState.tsx | 12 ++++++------ dashboard/src/pages/admin/BootstrapEditor.tsx | 4 ++-- dashboard/src/pages/admin/GraphHealth.tsx | 4 ++-- 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/dashboard/src/components/EmptyState.tsx b/dashboard/src/components/EmptyState.tsx index 3360cb1..b28b63b 100644 --- a/dashboard/src/components/EmptyState.tsx +++ b/dashboard/src/components/EmptyState.tsx @@ -1,14 +1,14 @@ +export type EmptyStateAction = + | { label: string; href: string; onClick?: never } + | { label: string; onClick: () => void; href?: never }; + export interface EmptyStateProps { icon: string; title: string; description: string; - action?: { - label: string; - href?: string; - onClick?: () => void; - }; + action?: EmptyStateAction; hint?: string; } @@ -43,7 +43,7 @@ export function EmptyState({ icon, title, description, action, hint }: EmptyStat

{description}

{action && (
- {action.href ? ( + {'href' in action ? ( {action.label} diff --git a/dashboard/src/pages/admin/BootstrapEditor.tsx b/dashboard/src/pages/admin/BootstrapEditor.tsx index c50abaa..f9dcd20 100644 --- a/dashboard/src/pages/admin/BootstrapEditor.tsx +++ b/dashboard/src/pages/admin/BootstrapEditor.tsx @@ -17,8 +17,8 @@ export function BootstrapEditor({ issuers, onUpdate }: BootstrapEditorProps) { title="No bootstrap issuers registered" description="The bootstrap registry is empty. Run the seed script to populate the curated root-of-truth with the VeriLink bootstrap issuer and initial seed agents." action={{ - label: 'Run bootstrap seed', - href: 'https://github.com/Numeracode/verilink#bootstrap-seed', + label: 'Open bootstrap runbook', + href: 'https://github.com/Numeracode/verilink/blob/main/docs/superpowers/plans/2026-08-11-plan-10-bootstrap-cold-start.md', }} hint="The seed is idempotent: reruns are no-ops and staff de-emphasis state is preserved." /> diff --git a/dashboard/src/pages/admin/GraphHealth.tsx b/dashboard/src/pages/admin/GraphHealth.tsx index eac6c4f..e32e5c8 100644 --- a/dashboard/src/pages/admin/GraphHealth.tsx +++ b/dashboard/src/pages/admin/GraphHealth.tsx @@ -11,8 +11,8 @@ export function GraphHealth({ summary }: { summary: GraphSummary | undefined }) title="Trust graph is empty" description="The graph will populate once principals (agents and issuers) are registered and attestations start flowing. Run the bootstrap seed to initialize the root-of-trust registry." action={{ - label: 'Run bootstrap seed', - href: 'https://github.com/Numeracode/verilink#bootstrap-seed', + label: 'Open bootstrap runbook', + href: 'https://github.com/Numeracode/verilink/blob/main/docs/superpowers/plans/2026-08-11-plan-10-bootstrap-cold-start.md', }} hint="The seed creates the VeriLink bootstrap issuer and initial attestations for a non-empty cold-start graph." />