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/summaryis likely returningtotal_workloads: 0- The
usePostureSummary()hook inui/src/hooks/use-posture.tsfeedsPostureSummary Dashboard.tsx:147–149only renders<PostureSummary>whenpostureCategories.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 forentity_type: "workload"when seeded entities use a different type value, or the posture query isn't scoped to the correct tenant
Fix:
- 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" - Cross-check entity types in the DB against what the posture query filters on
- 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:
| Label | Tooltip text |
|---|---|
| Proven | Workloads with deterministic execution evidence |
| Unproven | Workloads that can execute autonomously but lack linked evidence |
| Unbound | Workloads with no deterministic RUNS_AS relationship |
| Dormant | Workloads 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
U5 — Temporal Compare: No progressive disclosure after the search box
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
| Priority | ID | Effort | Impact |
|---|---|---|---|
| 1 | B1 | Medium | Fixes contradictory data on the primary landing page |
| 2 | D1 | Trivial | One title attribute — unblocks reading finding descriptions |
| 3 | D2 | Trivial | One function swap — date format consistency across all tables |
| 4 | D4 | Trivial | One array entry — completes the graph legend |
| 5 | D3 | Small | Adds descriptive detail to empty state |
| 6 | U2 | Trivial | One CSS string — makes "Unknown" badge visible |
| 7 | U5 | Small | One conditional placeholder — guides Temporal Compare users |
| 8 | U4 | Small | Remove one redundant filter option |
| 9 | U6 | Small | fitView timing fix after dagre layout |
| 10 | U1 | Small | Tooltip strings on posture cards |
| 11 | U3 | Small | Domain name display transform |
| 12 | U7 | Small | Chevron + cursor on sync rows |