Skip to main content

Visual QA Fix Plan — 2026-02-18

Date: 2026-02-18 Status: Ready to implement Scope: UI (sv0-platform/ui/) + API (sv0-platform/src/api/) Source: Visual QA agent (scripts/visual-qa.ts) run against http://localhost:8080 (tenant: demo-tenant). Screenshots in sv0-platform/reports/visual-qa/screenshots/.

Issues are ordered by priority: bug → degraded experience → readability/UX.


🔴 Bug Fixes

B1 — Dashboard: Posture summary shows "0 total workloads" despite 7 exposures

What's broken: The four posture cards (Proven / Unproven / Unbound / Dormant) all show 0 and the header reads "0 total workloads", yet the Exposures page shows 7 workloads and the Risk Clusters section on the same Dashboard shows "3 exposures". These are contradictory.

Root cause to investigate:

  • GET /api/v1/posture/summary is likely returning total_workloads: 0
  • The usePostureSummary() hook in ui/src/hooks/use-posture.ts feeds PostureSummary
  • Dashboard.tsx:147–149 only renders <PostureSummary> when postureCategories.length > 0, so the component mounts — meaning the API returns categories but with zero counts
  • Check src/api/routes/posture.ts — the workload filter may be querying for entity_type: "workload" when seeded entities use a different type value, or the posture query isn't scoped to the correct tenant

Fix:

  1. Inspect the raw API response:
    curl http://localhost:3000/api/v1/posture/summary -H "X-Tenant-Id: demo-tenant"
    curl http://localhost:3000/api/v1/entities?entity_type=workload -H "X-Tenant-Id: demo-tenant"
  2. Cross-check entity types in the DB against what the posture query filters on
  3. Fix the aggregation query to match actual entity types stored by the connector

Files: src/api/routes/posture.ts, ui/src/hooks/use-posture.ts, ui/src/components/PostureSummary.tsx


🟡 Degraded Experience

D1 — Findings: Description column truncated with no tooltip

What's broken: Long descriptions are clipped by truncate with no way for users to read the full text without clicking into the finding detail.

Fix: Add title attribute to the truncated container so the browser shows a native tooltip on hover.

// ui/src/pages/FindingsList.tsx ~line 94
// Before:
<div className="max-w-md truncate">
// After:
<div className="max-w-md truncate" title={row.original.description}>

Files: ui/src/pages/FindingsList.tsx


D2 — Entities: "Synced" column uses absolute date, all other pages use relative time

What's broken: Entities table shows 2/18/2026; Identities and Syncs show 1h ago. Inconsistent within the same product.

Fix: Replace toLocaleDateString() with the existing timeAgo() helper.

// ui/src/pages/EntitiesListPage.tsx ~line 62
// Before:
{new Date(row.original.last_synced_at).toLocaleDateString()}
// After:
{timeAgo(row.original.last_synced_at)}

Add the import if not already present:

import { timeAgo } from "../components/table/column-helpers.ts";

Files: ui/src/pages/EntitiesListPage.tsx


D3 — Execution Chains: Empty state gives no context or guidance

What's broken: The empty state just says "No execution chains found" with no explanation of what execution chains are or how to populate them. First-time users cannot distinguish between "feature not configured" and "data not yet ingested."

Fix: Add a descriptive detail prop to the empty state:

// ui/src/pages/ExecutionChainsPage.tsx ~line 165
emptyMessage="No execution chains found"
emptyDetail="Execution chains are built from workloads that share egress paths or ownership. Ingest a connector graph to populate this view."

(Verify the exact prop name against ui/src/components/EmptyState.tsx.)

Files: ui/src/pages/ExecutionChainsPage.tsx, ui/src/components/EmptyState.tsx


D4 — Graph Explorer: execution_evidence missing from bottom legend

What's broken: The filter sidebar lists 9 entity types including execution_evidence, but GraphLegend only renders 8 items (stops at Connection). Users cannot identify execution_evidence nodes in the graph by colour.

Fix: Add the missing entry to LEGEND_ITEMS. Verify the colour against ui/src/components/graph/layout.ts or EntityNode.tsx first.

// ui/src/components/graph/GraphLegend.tsx
const LEGEND_ITEMS = [
{ color: "bg-blue-400", label: "Identity" },
{ color: "bg-teal-400", label: "Workload" },
{ color: "bg-green-400", label: "Owner" },
{ color: "bg-purple-400", label: "Role" },
{ color: "bg-orange-400", label: "Permission" },
{ color: "bg-red-400", label: "Resource" },
{ color: "bg-gray-400", label: "Credential" },
{ color: "bg-cyan-400", label: "Connection" },
{ color: "bg-yellow-400", label: "Execution Evidence" }, // ← add
];

Files: ui/src/components/graph/GraphLegend.tsx


💡 Readability / UX

U1 — Dashboard: Posture cards have no explanatory labels

What's broken: The four posture cards (Proven / Unproven / Unbound / Dormant) appear with no subtitle or tooltip explaining what each category means. New users cannot understand what distinguishes them without reading documentation.

Fix: Add a title tooltip (or ? icon with hover popover) to each card label:

LabelTooltip text
ProvenWorkloads with deterministic execution evidence
UnprovenWorkloads that can execute autonomously but lack linked evidence
UnboundWorkloads with no deterministic RUNS_AS relationship
DormantWorkloads with authority but no execution activity in 90 days

Files: ui/src/components/PostureSummary.tsx


U2 — Exposures: "Unknown" binding badge invisible on white background

What's broken: BindingStatusBadge for unknown uses bg-gray-100 text-gray-500, which has near-zero contrast on the white table row. The "Bound" green pill is clearly a badge; "Unknown" reads as unstyled text.

Fix: Update the unknown style to use a visible border:

// ui/src/components/WorkloadBadges.tsx
unknown: { label: "Unknown", className: "border border-gray-300 text-gray-500 bg-white" },

Or use a slightly darker background: bg-gray-200 text-gray-600.

Files: ui/src/components/WorkloadBadges.tsx


U3 — Data Domains: Raw connector naming leaks through

What's broken: Domain names display as raw connector values — Hr, It_operations, Infrastructure. Inconsistent capitalisation and underscores look like internal IDs, not user-facing labels.

Fix: Add a display-name transform in DataDomainsPage.tsx:

const DOMAIN_DISPLAY: Record<string, string> = {
hr: "HR",
it_operations: "IT Operations",
identity: "Identity",
infrastructure: "Infrastructure",
};

function formatDomainName(name: string): string {
return DOMAIN_DISPLAY[name.toLowerCase()]
?? name.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
}

Files: ui/src/pages/DataDomainsPage.tsx


U4 — Identities: "All types" filter is redundant

What's broken: IdentitiesPage.tsx:91 hard-codes entity_type: "identity" as the default filter, so the "All types" dropdown always produces the same results. Users may try different type options and get confused when nothing changes.

Fix: Remove the entity_type entry from FILTER_OPTIONS on this page. Keep the source_system filter — that one is genuinely useful.

// ui/src/pages/IdentitiesPage.tsx
// Remove entity_type from FILTER_OPTIONS array

Files: ui/src/pages/IdentitiesPage.tsx


What's broken: The page shows a search input and then a large empty grey void. There is no visual indication of what comes next (pick dates, compare). First-time users often abandon the page thinking it is broken.

Fix: Show a placeholder panel below the entity search while no entity is selected:

// ui/src/pages/TemporalComparePage.tsx
{!selectedEntityId && (
<div className="rounded-lg border border-dashed border-gray-300 p-6 text-center text-sm text-gray-400">
Select an entity above to load its version history, then choose two timestamps to compare.
</div>
)}

Files: ui/src/pages/TemporalComparePage.tsx


U6 — Graph Explorer: Graph nodes not centered on initial load

What's broken: Screenshot shows all 40 nodes clustered in the right two-thirds of the canvas. The dagre layout runs asynchronously after mount, so fitView fires before layout is complete and centers on an empty canvas.

Fix: Trigger fitView() after the layout effect settles rather than only on mount:

// ui/src/components/graph/GraphCanvas.tsx (or GraphExplorerPage.tsx)
useEffect(() => {
if (nodes.length > 0) {
setTimeout(() => reactFlowInstance.fitView({ padding: 0.1 }), 100);
}
}, [nodes.length]);

Files: ui/src/components/graph/GraphCanvas.tsx, ui/src/pages/GraphExplorerPage.tsx


U7 — Syncs: Rows not visibly expandable

What's broken: Sync rows contain expandable JSON detail but show no affordance — no chevron, no hover cursor change. Contrast with Exposures which uses a chevron.

Fix: Add cursor-pointer and a / toggle chevron to each sync row.

Files: ui/src/pages/SyncsPage.tsx


Implementation order

PriorityIDEffortImpact
1B1MediumFixes contradictory data on the primary landing page
2D1TrivialOne title attribute — unblocks reading finding descriptions
3D2TrivialOne function swap — date format consistency across all tables
4D4TrivialOne array entry — completes the graph legend
5D3SmallAdds descriptive detail to empty state
6U2TrivialOne CSS string — makes "Unknown" badge visible
7U5SmallOne conditional placeholder — guides Temporal Compare users
8U4SmallRemove one redundant filter option
9U6SmallfitView timing fix after dagre layout
10U1SmallTooltip strings on posture cards
11U3SmallDomain name display transform
12U7SmallChevron + cursor on sync rows