From 76cde39b08092b5d759e25a6cebde0da7e91e5fe Mon Sep 17 00:00:00 2001 From: Alejandro Bailo <59607668+alejandrobailo@users.noreply.github.com> Date: Mon, 19 Oct 2026 17:57:15 +0200 Subject: [PATCH] feat(ui): redesign the Exploitability card and the issues verdict filter (#13093) --- .../exploitability-chart.skeleton.tsx | 13 +- .../_components/exploitability-chart.test.tsx | 11 +- .../_components/exploitability-chart.tsx | 133 ++++++------- .../_components/top-issues.skeleton.tsx | 4 +- .../exploitability/_components/top-issues.tsx | 17 +- .../exploitability-chart.ssr.tsx | 1 - .../issues/_components/issues-list.tsx | 17 +- ui/app/(prowler)/issues/page.tsx | 5 +- ui/components/issues/issues-summary.tsx | 59 ------ .../issues/issues.integration.test.tsx | 1 - ui/components/issues/verdict-filter.test.tsx | 101 +++++++--- ui/components/issues/verdict-filter.tsx | 184 ++++++++++++------ ui/lib/issues/verdicts.ts | 41 +++- 13 files changed, 342 insertions(+), 245 deletions(-) delete mode 100644 ui/components/issues/issues-summary.tsx diff --git a/ui/app/(prowler)/_overview/exploitability/_components/exploitability-chart.skeleton.tsx b/ui/app/(prowler)/_overview/exploitability/_components/exploitability-chart.skeleton.tsx index 7bfa15643a..df7d5d73ce 100644 --- a/ui/app/(prowler)/_overview/exploitability/_components/exploitability-chart.skeleton.tsx +++ b/ui/app/(prowler)/_overview/exploitability/_components/exploitability-chart.skeleton.tsx @@ -6,13 +6,14 @@ export function ExploitabilityChartSkeleton() { variant="base" className="flex min-h-[372px] w-full min-w-0 flex-col lg:max-w-[312px]" > - - + + + - - - - + + + + ); diff --git a/ui/app/(prowler)/_overview/exploitability/_components/exploitability-chart.test.tsx b/ui/app/(prowler)/_overview/exploitability/_components/exploitability-chart.test.tsx index 3b5b9c16f0..76ad0e3d31 100644 --- a/ui/app/(prowler)/_overview/exploitability/_components/exploitability-chart.test.tsx +++ b/ui/app/(prowler)/_overview/exploitability/_components/exploitability-chart.test.tsx @@ -17,11 +17,11 @@ describe("ExploitabilityChart", () => { issues={12} findings={37} actionable={3} - notExploitableFindings={17} scope={{ "filter[provider_type__in]": "aws" }} />, ); + expect(screen.getByText("12 issues")).toBeInTheDocument(); expect( screen.getByRole("link", { name: "3 exploitable or likely issues" }), ).toHaveAttribute( @@ -30,12 +30,17 @@ describe("ExploitabilityChart", () => { ); expect(screen.getByText("0 exploitable · 3 likely")).toBeInTheDocument(); expect( - screen.getByRole("link", { name: /Not exploitable/ }), + screen.getByRole("link", { name: "4 not exploitable issues" }), ).toHaveAttribute( "href", "/issues?filter%5Bprovider_type__in%5D=aws&filter%5Bverdict%5D=not_exploitable", ); - expect(screen.getByText("covers 17 findings")).toBeInTheDocument(); + expect( + screen.getByRole("link", { name: "4 conditional issues" }), + ).toHaveAttribute( + "href", + "/issues?filter%5Bprovider_type__in%5D=aws&filter%5Bverdict%5D=conditional", + ); expect( screen.getByText("37 failed findings folded into 12 issues"), ).toBeInTheDocument(); diff --git a/ui/app/(prowler)/_overview/exploitability/_components/exploitability-chart.tsx b/ui/app/(prowler)/_overview/exploitability/_components/exploitability-chart.tsx index 5c525e8564..a30a3093b6 100644 --- a/ui/app/(prowler)/_overview/exploitability/_components/exploitability-chart.tsx +++ b/ui/app/(prowler)/_overview/exploitability/_components/exploitability-chart.tsx @@ -1,16 +1,10 @@ -import { TriangleAlert, Layers } from "lucide-react"; +import { TriangleAlert } from "lucide-react"; import Link from "next/link"; -import { - Button, - ResourceStatsCard, - CardVariant, - Card, - CardContent, - CardHeader, - CardTitle, -} from "@/components/shadcn"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/shadcn"; import { issuesHref } from "@/lib/issues/scope"; +import { VERDICT_TONE, VERDICTS, VerdictTone } from "@/lib/issues/verdicts"; +import { cn } from "@/lib/utils"; import { Verdict } from "@/types/issues"; interface ExploitabilityChartProps { @@ -18,98 +12,105 @@ interface ExploitabilityChartProps { issues: number; findings: number; actionable: number; - notExploitableFindings: number; /** Overview scope, carried into every drill-down so the counts still match. */ scope: Record; } /** The verdicts below the headline, in the order the card lists them. */ -const REST: { id: Verdict; label: string }[] = [ - { id: "conditional", label: "Conditional" }, - { id: "unknown", label: "Unknown" }, - { id: "not_exploitable", label: "Not exploitable" }, -]; +const REST: Verdict[] = ["conditional", "unknown", "not_exploitable"]; + +/** The dot each verdict row wears. */ +const TONE_DOT: Record = { + [VERDICT_TONE.fail]: "bg-bg-fail", + [VERDICT_TONE.warning]: "bg-bg-data-medium", + [VERDICT_TONE.pass]: "bg-bg-pass", + [VERDICT_TONE.neutral]: "bg-bg-data-muted", +}; /** * The fold, sized for the ThreatScore row. * * The headline is exploitable + likely — the issues someone should look at - * tonight — in the critical red. Everything else is work, not an incident, so - * it sits in a quieter box underneath. Every number links into the issue list, - * so nothing here is a dead end. + * tonight — on a red surface. Everything else is work, not an incident, so it + * sits as a quiet list underneath. Every number links into the issue list, so + * nothing here is a dead end. */ export const ExploitabilityChart = ({ counts, issues, findings, actionable, - notExploitableFindings, scope, }: ExploitabilityChartProps) => { return ( - + Exploitability + + {issues.toLocaleString()} {issues === 1 ? "issue" : "issues"} + - - + + + + + + Exploitable or likely + + + {counts.exploitable} exploitable · {counts.likely} likely + + + + {actionable.toLocaleString()} + + - -
    - {REST.map(({ id, label }) => ( -
  • - - {id === "not_exploitable" ? ( - - covers {notExploitableFindings.toLocaleString()} finding - {notExploitableFindings === 1 ? "" : "s"} - - ) : null} -
  • - ))} + + ); + })}
{/* The fold. Without the findings count the issue count says nothing about how much noise was removed, so the two are always shown together. */} -

+

{findings.toLocaleString()} failed findings folded into{" "} - {issues.toLocaleString()} issues + {issues.toLocaleString()} {issues === 1 ? "issue" : "issues"}

diff --git a/ui/app/(prowler)/_overview/exploitability/_components/top-issues.skeleton.tsx b/ui/app/(prowler)/_overview/exploitability/_components/top-issues.skeleton.tsx index c70510ab60..a0434a0398 100644 --- a/ui/app/(prowler)/_overview/exploitability/_components/top-issues.skeleton.tsx +++ b/ui/app/(prowler)/_overview/exploitability/_components/top-issues.skeleton.tsx @@ -9,9 +9,9 @@ export function TopIssuesSkeleton() { aria-label="Loading top issues" > - + {Array.from({ length: 5 }).map((_, index) => ( - + ))} diff --git a/ui/app/(prowler)/_overview/exploitability/_components/top-issues.tsx b/ui/app/(prowler)/_overview/exploitability/_components/top-issues.tsx index 743b399500..c53bf8f799 100644 --- a/ui/app/(prowler)/_overview/exploitability/_components/top-issues.tsx +++ b/ui/app/(prowler)/_overview/exploitability/_components/top-issues.tsx @@ -13,6 +13,11 @@ interface TopIssuesProps { scope: Record; } +/** + * The worst issues in scope, each on its own tile. Two tiles per row once the + * charts column is wide enough; the list is short, so a single column would + * leave most of the card empty on desktop. + */ export const TopIssues = ({ issues, scope }: TopIssuesProps) => ( Top issues @@ -20,20 +25,20 @@ export const TopIssues = ({ issues, scope }: TopIssuesProps) => ( {issues === null ? ( ) : issues.length > 0 ? ( -
    +
      {issues.map(({ id, attributes, relationships }) => { const verdict = VERDICTS.find( (item) => item.id === attributes.verdict, ); const providerId = relationships?.provider?.data?.id; return ( -
    • -
      -
      +
    • + +
      {verdict?.label ?? attributes.verdict} - + {attributes.findings_count}{" "} {attributes.findings_count === 1 ? "finding" : "findings"} @@ -61,7 +66,7 @@ export const TopIssues = ({ issues, scope }: TopIssuesProps) => ( {attributes.account}

      - +
    • ); })} diff --git a/ui/app/(prowler)/_overview/exploitability/exploitability-chart.ssr.tsx b/ui/app/(prowler)/_overview/exploitability/exploitability-chart.ssr.tsx index 6364ea7309..3e7aeb848f 100644 --- a/ui/app/(prowler)/_overview/exploitability/exploitability-chart.ssr.tsx +++ b/ui/app/(prowler)/_overview/exploitability/exploitability-chart.ssr.tsx @@ -29,7 +29,6 @@ export const ExploitabilityChartSSR = async ({ issues={attributes.issues ?? 0} findings={attributes.findings ?? 0} actionable={attributes.actionable ?? 0} - notExploitableFindings={attributes.not_exploitable_findings ?? 0} scope={filters} /> ); diff --git a/ui/app/(prowler)/issues/_components/issues-list.tsx b/ui/app/(prowler)/issues/_components/issues-list.tsx index fc23bf720f..c0ca294660 100644 --- a/ui/app/(prowler)/issues/_components/issues-list.tsx +++ b/ui/app/(prowler)/issues/_components/issues-list.tsx @@ -2,7 +2,6 @@ import { ReactNode } from "react"; import { getIssues, getIssuesOverview } from "@/actions/issues"; import { IssuesLoadError } from "@/components/issues/issues-load-error"; -import { IssuesSummary } from "@/components/issues/issues-summary"; import { IssuesTable } from "@/components/issues/issues-table"; import { VerdictFilter } from "@/components/issues/verdict-filter"; import { ISSUE_SCOPE_KEYS } from "@/lib/issues/scope"; @@ -26,7 +25,7 @@ export const IssuesList = async ({ filters: filterControls, }: { searchParams: SearchParamsProps; - /** The scope selectors, rendered between the verdict chips and the table. */ + /** The scope selectors, rendered above the verdict tabs: they scope every tab. */ filters?: ReactNode; }) => { const filters: Record = {}; @@ -62,22 +61,24 @@ export const IssuesList = async ({ return ( <> - {attributes ? ( - - ) : ( + {attributes ? null : ( )} + {filterControls} + - {filterControls} - {rows === null ? ( ) : rows.length ? ( diff --git a/ui/app/(prowler)/issues/page.tsx b/ui/app/(prowler)/issues/page.tsx index b9558f7f2a..9cf4e42018 100644 --- a/ui/app/(prowler)/issues/page.tsx +++ b/ui/app/(prowler)/issues/page.tsx @@ -49,8 +49,9 @@ export default async function Issues({ + // The three scope selectors share one row on desktop, like the + // other list pages. +
      diff --git a/ui/components/issues/issues-summary.tsx b/ui/components/issues/issues-summary.tsx deleted file mode 100644 index f2a787602a..0000000000 --- a/ui/components/issues/issues-summary.tsx +++ /dev/null @@ -1,59 +0,0 @@ -import { CircleHelp, Layers, ShieldCheck, TriangleAlert } from "lucide-react"; - -import { CardVariant, ResourceStatsCard } from "@/components/shadcn"; -import { IssuesOverviewResponse } from "@/types/issues"; - -/** The fold, as the tiles above the issue list. */ -export const IssuesSummary = ({ - overview, -}: { - overview: IssuesOverviewResponse["data"]["attributes"]; -}) => { - return ( -
      - - - - - -
      - ); -}; diff --git a/ui/components/issues/issues.integration.test.tsx b/ui/components/issues/issues.integration.test.tsx index 90e967ad4d..8365494c99 100644 --- a/ui/components/issues/issues.integration.test.tsx +++ b/ui/components/issues/issues.integration.test.tsx @@ -237,7 +237,6 @@ describe("Issues responsive layout", () => { issues={1} findings={3} actionable={1} - notExploitableFindings={0} scope={{}} /> diff --git a/ui/components/issues/verdict-filter.test.tsx b/ui/components/issues/verdict-filter.test.tsx index b6dae05e85..de3e7cb7c1 100644 --- a/ui/components/issues/verdict-filter.test.tsx +++ b/ui/components/issues/verdict-filter.test.tsx @@ -25,41 +25,99 @@ const counts = { unknown: 0, }; -const hrefOf = (name: RegExp) => - screen.getByRole("link", { name }).getAttribute("href"); +const lastPushedParams = () => + new URL(navigationState.push.mock.calls.at(-1)![0], "http://localhost") + .searchParams; describe("VerdictFilter", () => { - it("drops the page when a verdict is set or cleared, keeping other params", () => { + it("renders one tab per verdict with its count and the fold", () => { + navigationState.searchParams = new URLSearchParams(); + render(); + + expect(screen.getByRole("tab", { name: "All, 2 issues" })).toHaveAttribute( + "aria-selected", + "true", + ); + expect( + screen.getByRole("tab", { name: "Exploitable, 2 issues" }), + ).toHaveAttribute("aria-selected", "false"); + expect(screen.getAllByRole("tab")).toHaveLength(6); + expect( + screen.getByText("9 failed findings folded into 2 issues"), + ).toBeInTheDocument(); + }); + + it("selects the verdict tab and resets the page, keeping other params", async () => { + const user = userEvent.setup(); navigationState.searchParams = new URLSearchParams( "page=3&pageSize=10&filter[verdict]=likely", ); - render(); + render(); - const set = new URLSearchParams(hrefOf(/^exploitable 2$/i)!.split("?")[1]); - expect(set.get("page")).toBeNull(); - expect(set.get("pageSize")).toBe("10"); - expect(set.get("filter[verdict]")).toBe("exploitable"); + expect( + screen.getByRole("tab", { name: "Likely, 0 issues" }), + ).toHaveAttribute("aria-selected", "true"); + await user.click( + screen.getByRole("tab", { name: "Exploitable, 2 issues" }), + ); - const cleared = new URLSearchParams(hrefOf(/likely/i)!.split("?")[1]); - expect(cleared.get("page")).toBeNull(); - expect(cleared.get("filter[verdict]")).toBeNull(); + const params = lastPushedParams(); + expect(params.get("page")).toBe("1"); + expect(params.get("pageSize")).toBe("10"); + expect(params.get("filter[verdict]")).toBe("exploitable"); }); - it("offers an All chip that clears the verdict and is active without one", () => { + it("marks the tabs a combined filter covers and lets All replace it", async () => { + const user = userEvent.setup(); + navigationState.searchParams = new URLSearchParams( + "filter[verdict__in]=exploitable,likely", + ); + render(); + + // No single tab owns the combined filter, so none is selected... + expect( + screen + .getAllByRole("tab") + .every((tab) => tab.getAttribute("aria-selected") === "false"), + ).toBe(true); + // ...but the covered tabs are marked, and there is no chip for it. + expect( + screen.getByRole("tab", { name: "Exploitable, 2 issues" }), + ).toHaveAttribute("data-highlighted", "true"); + expect( + screen.getByRole("tab", { name: "Likely, 0 issues" }), + ).toHaveAttribute("data-highlighted", "true"); + expect( + screen.getByRole("tab", { name: "Conditional, 0 issues" }), + ).not.toHaveAttribute("data-highlighted"); + expect(screen.queryByRole("region", { name: "Active filters" })).toBeNull(); + expect(screen.getByRole("tablist")).toHaveAccessibleDescription( + "Showing Exploitable and Likely issues", + ); + + await user.click(screen.getByRole("tab", { name: "All, 2 issues" })); + + expect(navigationState.push).toHaveBeenLastCalledWith("/issues", { + scroll: false, + }); + }); + + it("shows no chip strip for a verdict picked from the tabs", () => { navigationState.searchParams = new URLSearchParams( "filter[verdict]=likely", ); - render(); + render(); - expect(hrefOf(/^all 2$/i)).toBe("/issues"); + expect(screen.queryByRole("region", { name: "Active filters" })).toBeNull(); }); + it("shows the pattern filter and lets the user remove it", async () => { // Given const user = userEvent.setup(); navigationState.searchParams = new URLSearchParams( "page=3&filter[pattern]=aws_public_bucket&filter[provider_id__in]=provider-1", ); - render(); + render(); // When await user.click( screen.getByRole("button", { @@ -67,14 +125,9 @@ describe("VerdictFilter", () => { }), ); // Then - const target = new URL( - navigationState.push.mock.calls.at(-1)![0], - "http://localhost", - ); - expect(target.searchParams.has("filter[pattern]")).toBe(false); - expect(target.searchParams.get("filter[provider_id__in]")).toBe( - "provider-1", - ); - expect(target.searchParams.get("page")).toBe("1"); + const target = lastPushedParams(); + expect(target.has("filter[pattern]")).toBe(false); + expect(target.get("filter[provider_id__in]")).toBe("provider-1"); + expect(target.get("page")).toBe("1"); }); }); diff --git a/ui/components/issues/verdict-filter.tsx b/ui/components/issues/verdict-filter.tsx index 0d86624917..e62eaa60fd 100644 --- a/ui/components/issues/verdict-filter.tsx +++ b/ui/components/issues/verdict-filter.tsx @@ -1,104 +1,164 @@ "use client"; -import Link from "next/link"; import { useSearchParams } from "next/navigation"; +import { ComponentProps, useId } from "react"; import { ClearFiltersButton } from "@/components/filters/clear-filters-button"; import { FilterSummaryStrip } from "@/components/filters/filter-summary-strip"; -import { Button } from "@/components/shadcn/button/button"; +import { Badge, Tabs, TabsList, TabsTrigger } from "@/components/shadcn"; import { useUrlFilters } from "@/hooks/use-url-filters"; -import { VERDICTS } from "@/lib/issues/verdicts"; +import { VERDICT_TONE, VERDICTS, VerdictTone } from "@/lib/issues/verdicts"; +import { cn } from "@/lib/utils"; import type { Verdict } from "@/types/issues"; interface VerdictSummary { counts: Record; + /** Issues in scope, whatever their verdict. */ total: number; + /** Failed findings those issues fold. */ + findings: number; } +const ALL_TAB = "all"; + +/** The active tab takes the verdict's colour instead of the default accent. */ +const TONE_UNDERLINE: Record = { + [VERDICT_TONE.fail]: "after:bg-bg-fail", + [VERDICT_TONE.warning]: "after:bg-bg-data-medium", + [VERDICT_TONE.pass]: "after:bg-bg-pass", + [VERDICT_TONE.neutral]: "after:bg-bg-data-muted", +}; + +const TONE_BADGE: Record["variant"]> = + { + [VERDICT_TONE.fail]: "error", + [VERDICT_TONE.warning]: "warning", + [VERDICT_TONE.pass]: "success", + [VERDICT_TONE.neutral]: "tag", + }; + +/** + * A combined verdict filter (the overview's "exploitable or likely" link) + * selects no single tab, so the tabs it covers are marked by hand. + */ +const HIGHLIGHT = "text-slate-900 after:scale-x-100 dark:text-white"; + +/** Filters the issue list understands but no control on this page sets. */ const EXTRA_FILTER_LABELS = { "filter[pattern]": "Pattern", "filter[cloud]": "Cloud", "filter[severity]": "Severity", "filter[provider_type]": "Provider type", "filter[provider_alias]": "Account", - "filter[verdict__in]": "Verdicts", } as const; export function VerdictFilter({ summary }: { summary?: VerdictSummary }) { const searchParams = useSearchParams(); - const { clearFilter } = useUrlFilters(); + const { clearFilter, navigateWithParams } = useUrlFilters(); const active = searchParams.get("filter[verdict]"); - const multiple = searchParams.get("filter[verdict__in]"); + const combined = searchParams.get("filter[verdict__in]")?.split(",") ?? []; + const value = combined.length ? "" : (active ?? ALL_TAB); + // No tab is selected under a combined filter, so the tab list says what + // the list is showing. + const combinedLabels = combined.map( + (id) => VERDICTS.find((verdict) => verdict.id === id)?.label ?? id, + ); + const descriptionId = useId(); - const hrefFor = (verdict: Verdict | null) => { - const params = new URLSearchParams(searchParams.toString()); - params.delete("page"); - params.delete("filter[verdict__in]"); - if (verdict === null || active === verdict) - params.delete("filter[verdict]"); - else params.set("filter[verdict]", verdict); - return params.size ? `/issues?${params}` : "/issues"; + const handleChange = (next: string) => { + navigateWithParams((params) => { + params.delete("filter[verdict__in]"); + if (next === ALL_TAB) params.delete("filter[verdict]"); + else params.set("filter[verdict]", next); + }); }; - const options = [ - { id: null, label: "All", count: summary?.total }, - ...VERDICTS.map(({ id, label }) => ({ + const tabs = [ + { + id: ALL_TAB, + label: "All", + count: summary?.total, + underline: "", + badge: "tag" as const, + highlighted: false, + }, + ...VERDICTS.map(({ id, label, tone }) => ({ id, label, count: summary?.counts[id], + underline: TONE_UNDERLINE[tone], + badge: TONE_BADGE[tone], + highlighted: combined.includes(id), })), ]; const chips = Object.entries(EXTRA_FILTER_LABELS).flatMap(([key, label]) => { - const value = searchParams.get(key); - if (!value) return []; - const displayValue = - key === "filter[verdict__in]" - ? value - .split(",") - .map( - (id) => - VERDICTS.find((verdict) => verdict.id === id)?.label ?? id, - ) - .join(", ") - : value; - return [{ key, label, value, displayValue }]; + const chipValue = searchParams.get(key); + return chipValue ? [{ key, label, value: chipValue }] : []; }); return (
      -
      - {options.map((option) => { - const isActive = - !multiple && - (active === option.id || (!active && option.id === null)); - return ( - - ); - })} -
      - clearFilter(key)} - trailingContent={} - /> + {tabs.map((tab) => ( + + {tab.count.toLocaleString()} + + ) + } + > + {tab.label} + + ))} + +
      + {summary ? ( + // The fold: how much noise the issue count removed. +

      + {summary.findings.toLocaleString()} failed findings folded into{" "} + {summary.total.toLocaleString()}{" "} + {summary.total === 1 ? "issue" : "issues"} +

      + ) : null} +
      + + {/* Only filters that arrived by link get a chip; the tabs and the scope + selectors are their own controls, so they never need one. */} + {chips.length ? ( + clearFilter(key)} + trailingContent={} + /> + ) : null} ); } diff --git a/ui/lib/issues/verdicts.ts b/ui/lib/issues/verdicts.ts index e64dd4d16f..de26688096 100644 --- a/ui/lib/issues/verdicts.ts +++ b/ui/lib/issues/verdicts.ts @@ -3,18 +3,49 @@ import { ComponentProps } from "react"; import { Badge } from "@/components/shadcn/badge/badge"; import { LegName, LegState, Verdict } from "@/types/issues"; +/** The colour family a verdict reads in, shared by the overview card and the tabs. */ +export const VERDICT_TONE = { + fail: "fail", + warning: "warning", + pass: "pass", + neutral: "neutral", +} as const; + +export type VerdictTone = (typeof VERDICT_TONE)[keyof typeof VERDICT_TONE]; + interface VerdictDisplay { id: Verdict; label: string; variant: ComponentProps["variant"]; + tone: VerdictTone; } export const VERDICTS: VerdictDisplay[] = [ - { id: "exploitable", label: "Exploitable", variant: "error" }, - { id: "likely", label: "Likely", variant: "error" }, - { id: "conditional", label: "Conditional", variant: "outline" }, - { id: "not_exploitable", label: "Not exploitable", variant: "success" }, - { id: "unknown", label: "Unknown", variant: "tag" }, + { + id: "exploitable", + label: "Exploitable", + variant: "error", + tone: VERDICT_TONE.fail, + }, + { id: "likely", label: "Likely", variant: "error", tone: VERDICT_TONE.fail }, + { + id: "conditional", + label: "Conditional", + variant: "outline", + tone: VERDICT_TONE.warning, + }, + { + id: "not_exploitable", + label: "Not exploitable", + variant: "success", + tone: VERDICT_TONE.pass, + }, + { + id: "unknown", + label: "Unknown", + variant: "tag", + tone: VERDICT_TONE.neutral, + }, ]; const LEG_GOOD_WHEN_YES: Record = {