feat: implement sanctions screening vertical

This commit is contained in:
2026-07-29 18:46:53 +02:00
parent 05c3a2257a
commit 11404ac42f
25 changed files with 6531 additions and 38 deletions

View File

@@ -0,0 +1,253 @@
import {
apiFetch,
apiPath,
type ApiSettings
} from "@govoplan/core-webui";
export type ConnectorSnapshot = {
ref: string;
provider_id: string;
publisher: string;
jurisdiction: string;
source_version: string;
publication_at?: string | null;
acquired_at: string;
byte_count: number;
sha256: string;
parser_version: string;
};
export type ListSnapshot = {
id: string;
visibility: string;
provider_id: string;
publisher: string;
jurisdiction: string;
list_type: string;
source_id: string;
source_version: string;
publication_at?: string | null;
effective_at?: string | null;
acquired_at: string;
sha256: string;
connector_run_id: string;
raw_evidence_ref: string;
source_parser_version: string;
normalization_version: string;
signature_evidence: Record<string, unknown>;
provenance: Record<string, unknown>;
entry_count: number;
status: string;
imported_by?: string | null;
imported_at: string;
};
export type SubjectSnapshot = {
id: string;
subject_ref?: string | null;
subject_type: string;
primary_name?: string | null;
normalized_name?: string | null;
aliases: string[];
identifiers: Array<Record<string, string>>;
dates: string[];
addresses: Array<Record<string, string>>;
fingerprint: string;
submitted_by?: string | null;
};
export type SanctionsEntry = {
id: string;
source_entry_id: string;
subject_type: string;
primary_name: string;
original_script_name?: string | null;
reference_number?: string | null;
listed_on?: string | null;
programmes: string[];
raw_evidence_locator: string;
aliases: Array<{
name: string;
quality?: string | null;
}>;
identifiers: Array<{
identifier_type: string;
value: string;
issuing_country?: string | null;
}>;
dates: Array<{
date_type: string;
value: string;
precision: string;
}>;
addresses: Array<{
street?: string | null;
city?: string | null;
region?: string | null;
postal_code?: string | null;
country?: string | null;
}>;
};
export type ScreeningCandidate = {
id: string;
score: number;
match_kind: string;
evidence: Array<Record<string, unknown>>;
review_status: string;
current_disposition_id?: string | null;
entry: SanctionsEntry;
};
export type ScreeningRun = {
id: string;
matcher_version: string;
normalization_version: string;
policy_version: string;
policy_snapshot: Record<string, unknown>;
status: string;
outcome: string;
candidate_count: number;
started_at: string;
completed_at?: string | null;
created_at: string;
subject: SubjectSnapshot;
list_snapshot: ListSnapshot;
candidates: ScreeningCandidate[];
};
export type ReviewQueueItem = {
id: string;
run_id: string;
score: number;
match_kind: string;
review_status: string;
subject_name?: string | null;
subject_type: string;
entry_name: string;
source_entry_id: string;
list_source_version: string;
created_at: string;
};
export type CandidateDetail = {
candidate: ScreeningCandidate;
subject: SubjectSnapshot;
list_snapshot: ListSnapshot;
run: {
id: string;
matcher_version: string;
normalization_version: string;
policy_version: string;
outcome: string;
created_at: string;
};
};
export async function listConnectorSnapshots(
settings: ApiSettings
) {
return apiFetch<{
available: boolean;
snapshots: ConnectorSnapshot[];
}>(
settings,
"/api/v1/risk-compliance/sanctions/source-snapshots"
);
}
export async function importListSnapshot(
settings: ApiSettings,
connectorSnapshotRef: string
) {
return apiFetch<{
snapshot: ListSnapshot;
created: boolean;
}>(
settings,
"/api/v1/risk-compliance/sanctions/list-snapshots/import",
{
method: "POST",
body: JSON.stringify({
connector_snapshot_ref: connectorSnapshotRef
})
}
);
}
export async function listListSnapshots(settings: ApiSettings) {
return apiFetch<{ snapshots: ListSnapshot[] }>(
settings,
"/api/v1/risk-compliance/sanctions/list-snapshots"
);
}
export async function runScreening(
settings: ApiSettings,
input: {
list_snapshot_id: string;
idempotency_key: string;
subject: {
subject_type: "person" | "entity";
primary_name: string;
identifiers?: Array<{type: string;value: string;}>;
};
}
) {
return apiFetch<{run: ScreeningRun;created: boolean;}>(
settings,
"/api/v1/risk-compliance/sanctions/screenings",
{
method: "POST",
body: JSON.stringify(input)
}
);
}
export async function listReviewQueue(
settings: ApiSettings,
reviewStatus = "pending"
) {
return apiFetch<{ candidates: ReviewQueueItem[] }>(
settings,
apiPath(
"/api/v1/risk-compliance/sanctions/review-queue",
{ review_status: reviewStatus, limit: 500 }
)
);
}
export async function getCandidate(
settings: ApiSettings,
candidateId: string
) {
return apiFetch<CandidateDetail>(
settings,
`/api/v1/risk-compliance/sanctions/review-queue/${encodeURIComponent(candidateId)}`
);
}
export async function createDisposition(
settings: ApiSettings,
candidateId: string,
input: {
decision: string;
reason: string;
exception_scope: "candidate" | "subject_entry";
expires_at?: string | null;
override_reason?: string | null;
}
) {
return apiFetch<{
candidate: ScreeningCandidate;
disposition: {id: string;decision: string;};
}>(
settings,
`/api/v1/risk-compliance/sanctions/review-queue/${encodeURIComponent(candidateId)}/dispositions`,
{
method: "POST",
body: JSON.stringify(input)
}
);
}

View File

@@ -0,0 +1,810 @@
import {
CheckCircle2,
Database,
Play,
RefreshCw,
Scale,
Upload
} from "lucide-react";
import {
useCallback,
useEffect,
useMemo,
useState,
type FormEvent
} from "react";
import {
Button,
Dialog,
DismissibleAlert,
FormField,
IconButton,
LoadingIndicator,
SegmentedControl,
StatusBadge,
ToggleSwitch,
hasScope,
type PlatformRouteContext
} from "@govoplan/core-webui";
import {
createDisposition,
getCandidate,
importListSnapshot,
listConnectorSnapshots,
listListSnapshots,
listReviewQueue,
runScreening,
type CandidateDetail,
type ConnectorSnapshot,
type ListSnapshot,
type ReviewQueueItem,
type ScreeningRun
} from "../../api/riskCompliance";
type ViewMode = "sources" | "screen" | "review";
export default function RiskCompliancePage({
settings,
auth
}: PlatformRouteContext) {
const [view, setView] = useState<ViewMode>("review");
const [sourceSnapshots, setSourceSnapshots] = useState<
ConnectorSnapshot[]
>([]);
const [sourcesAvailable, setSourcesAvailable] = useState(false);
const [listSnapshots, setListSnapshots] = useState<ListSnapshot[]>([]);
const [queue, setQueue] = useState<ReviewQueueItem[]>([]);
const [selectedCandidateId, setSelectedCandidateId] = useState("");
const [candidate, setCandidate] = useState<CandidateDetail | null>(null);
const [run, setRun] = useState<ScreeningRun | null>(null);
const [loading, setLoading] = useState(true);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const [notice, setNotice] = useState("");
const canAdmin = hasScope(
auth,
"risk_compliance:sanctions:admin"
);
const canScreen = hasScope(
auth,
"risk_compliance:sanctions:screen"
);
const canReview = hasScope(
auth,
"risk_compliance:sanctions:review"
);
const refresh = useCallback(async () => {
setLoading(true);
setError("");
try {
const [sources, lists, reviewQueue] = await Promise.all([
listConnectorSnapshots(settings),
listListSnapshots(settings),
canReview
? listReviewQueue(settings)
: Promise.resolve({ candidates: [] })
]);
setSourcesAvailable(sources.available);
setSourceSnapshots(sources.snapshots);
setListSnapshots(lists.snapshots);
setQueue(reviewQueue.candidates);
setSelectedCandidateId((current) =>
current &&
reviewQueue.candidates.some((item) => item.id === current)
? current
: reviewQueue.candidates[0]?.id ?? ""
);
} catch (reason) {
setError(errorMessage(reason));
} finally {
setLoading(false);
}
}, [canReview, settings]);
useEffect(() => {
void refresh();
}, [refresh]);
useEffect(() => {
if (!selectedCandidateId) {
setCandidate(null);
return;
}
let cancelled = false;
void getCandidate(settings, selectedCandidateId)
.then((item) => {
if (!cancelled) setCandidate(item);
})
.catch((reason) => {
if (!cancelled) setError(errorMessage(reason));
});
return () => {
cancelled = true;
};
}, [selectedCandidateId, settings]);
async function importSnapshot(item: ConnectorSnapshot) {
setBusy(true);
setError("");
try {
const result = await importListSnapshot(settings, item.ref);
setNotice(
result.created
? `Imported ${result.snapshot.entry_count} list entries.`
: "This immutable snapshot was already imported."
);
await refresh();
} catch (reason) {
setError(errorMessage(reason));
} finally {
setBusy(false);
}
}
return (
<main className="risk-page">
<div className="risk-toolbar">
<SegmentedControl
value={view}
onChange={setView}
ariaLabel="Risk Compliance view"
options={[
{
id: "sources",
label: (
<>
<Database size={15} />
Sources
</>
)
},
{
id: "screen",
label: (
<>
<Play size={15} />
Screen
</>
),
disabled: !canScreen
},
{
id: "review",
label: (
<>
<Scale size={15} />
Review
{queue.length > 0 && (
<span className="risk-count">{queue.length}</span>
)}
</>
),
disabled: !canReview
}
]}
/>
<span className="risk-toolbar-spacer" />
{loading && <LoadingIndicator size="sm" label="Loading" />}
<IconButton
label="Refresh"
icon={<RefreshCw size={16} />}
onClick={() => void refresh()}
disabled={loading || busy}
/>
</div>
{(error || notice) && (
<div className="risk-alerts">
{error && (
<DismissibleAlert
tone="danger"
resetKey={error}
onDismiss={() => setError("")}
>
{error}
</DismissibleAlert>
)}
{notice && !error && (
<DismissibleAlert
tone="success"
resetKey={notice}
onDismiss={() => setNotice("")}
>
{notice}
</DismissibleAlert>
)}
</div>
)}
<div className="risk-workspace">
{view === "sources" && (
<SourcesPane
available={sourcesAvailable}
sources={sourceSnapshots}
imported={listSnapshots}
canImport={canAdmin}
busy={busy}
onImport={importSnapshot}
/>
)}
{view === "screen" && (
<ScreenPane
settings={settings}
snapshots={listSnapshots}
run={run}
onRun={setRun}
onError={setError}
/>
)}
{view === "review" && (
<ReviewPane
queue={queue}
selectedId={selectedCandidateId}
detail={candidate}
busy={busy}
onSelect={setSelectedCandidateId}
onBusy={setBusy}
onError={setError}
onNotice={setNotice}
onRefresh={refresh}
settings={settings}
/>
)}
</div>
</main>
);
}
function SourcesPane({
available,
sources,
imported,
canImport,
busy,
onImport
}: {
available: boolean;
sources: ConnectorSnapshot[];
imported: ListSnapshot[];
canImport: boolean;
busy: boolean;
onImport: (item: ConnectorSnapshot) => Promise<void>;
}) {
const importedRefs = useMemo(
() => new Set(imported.map((item) => item.sha256)),
[imported]
);
return (
<section className="risk-source-layout">
<div className="risk-panel">
<header>
<div>
<strong>Connector evidence</strong>
<span>Immutable acquired source snapshots</span>
</div>
</header>
{!available && (
<div className="risk-empty">
The Connectors sanctions source capability is not enabled.
</div>
)}
<div className="risk-list">
{sources.map((item) => {
const isImported = importedRefs.has(item.sha256);
return (
<div className="risk-list-row" key={item.ref}>
<div className="risk-list-main">
<strong>{item.publisher}</strong>
<span>
{item.source_version} · {formatDate(item.acquired_at)}
</span>
<code>{item.sha256.slice(0, 16)}</code>
</div>
{isImported ? (
<StatusBadge status="active" label="Imported" />
) : (
<IconButton
label="Import snapshot"
icon={<Upload size={16} />}
disabled={!canImport || busy}
onClick={() => void onImport(item)}
/>
)}
</div>
);
})}
</div>
</div>
<div className="risk-panel">
<header>
<div>
<strong>Screening catalogues</strong>
<span>Normalized, immutable list versions</span>
</div>
</header>
<div className="risk-list">
{imported.map((item) => (
<div className="risk-list-row" key={item.id}>
<div className="risk-list-main">
<strong>{item.publisher}</strong>
<span>
{item.entry_count.toLocaleString()} entries ·{" "}
{item.normalization_version}
</span>
<code>{item.source_version}</code>
</div>
<StatusBadge status={item.status} />
</div>
))}
{!imported.length && (
<div className="risk-empty">
No source snapshot has been imported.
</div>
)}
</div>
</div>
</section>
);
}
function ScreenPane({
settings,
snapshots,
run,
onRun,
onError
}: {
settings: PlatformRouteContext["settings"];
snapshots: ListSnapshot[];
run: ScreeningRun | null;
onRun: (run: ScreeningRun | null) => void;
onError: (message: string) => void;
}) {
const [listId, setListId] = useState(snapshots[0]?.id ?? "");
const [subjectType, setSubjectType] = useState<"person" | "entity">(
"person"
);
const [name, setName] = useState("");
const [identifier, setIdentifier] = useState("");
const [submitting, setSubmitting] = useState(false);
useEffect(() => {
if (!snapshots.some((item) => item.id === listId)) {
setListId(snapshots[0]?.id ?? "");
}
}, [listId, snapshots]);
async function submit(event: FormEvent) {
event.preventDefault();
setSubmitting(true);
onError("");
try {
const response = await runScreening(settings, {
list_snapshot_id: listId,
idempotency_key: crypto.randomUUID(),
subject: {
subject_type: subjectType,
primary_name: name,
identifiers: identifier.trim()
? [{ type: "document", value: identifier.trim() }]
: []
}
});
onRun(response.run);
} catch (reason) {
onError(errorMessage(reason));
} finally {
setSubmitting(false);
}
}
return (
<section className="risk-screen-layout">
<form className="risk-panel risk-screen-form" onSubmit={submit}>
<header>
<div>
<strong>New screening</strong>
<span>Use only the data needed for comparison</span>
</div>
</header>
<div className="risk-form-body">
<FormField label="List snapshot">
<select
value={listId}
onChange={(event) => setListId(event.target.value)}
required
>
{snapshots.map((item) => (
<option value={item.id} key={item.id}>
{item.publisher} · {item.source_version}
</option>
))}
</select>
</FormField>
<FormField label="Subject type">
<SegmentedControl
value={subjectType}
onChange={setSubjectType}
width="fill"
size="equal"
options={[
{ id: "person", label: "Person" },
{ id: "entity", label: "Entity" }
]}
/>
</FormField>
<FormField label="Name">
<input
value={name}
onChange={(event) => setName(event.target.value)}
maxLength={1000}
/>
</FormField>
<FormField label="Identifier (optional)">
<input
value={identifier}
onChange={(event) => setIdentifier(event.target.value)}
maxLength={1000}
/>
</FormField>
<Button
type="submit"
variant="primary"
disabled={
submitting ||
!listId ||
(!name.trim() && !identifier.trim())
}
>
<Play size={16} />
Run screening
</Button>
</div>
</form>
<div className="risk-panel risk-run-result">
<header>
<div>
<strong>Result</strong>
<span>Version-pinned candidate evidence</span>
</div>
{run && <StatusBadge status={run.outcome} />}
</header>
{!run && (
<div className="risk-empty">
Run a screening to inspect the result.
</div>
)}
{run && (
<div className="risk-result-body">
<div className="risk-metrics">
<span>
<strong>{run.candidate_count}</strong>
candidates
</span>
<span>
<strong>{run.matcher_version}</strong>
matcher
</span>
<span>
<strong>{run.list_snapshot.source_version}</strong>
list
</span>
</div>
{run.candidates.map((item) => (
<div className="risk-candidate-summary" key={item.id}>
<span className="risk-score">{item.score}</span>
<div>
<strong>{item.entry.primary_name}</strong>
<span>
{item.match_kind} · {item.entry.source_entry_id}
</span>
</div>
<StatusBadge status={item.review_status} />
</div>
))}
{run.outcome === "clear" && (
<div className="risk-clear">
<CheckCircle2 size={18} />
No candidate met the configured threshold.
</div>
)}
</div>
)}
</div>
</section>
);
}
function ReviewPane({
queue,
selectedId,
detail,
busy,
onSelect,
onBusy,
onError,
onNotice,
onRefresh,
settings
}: {
queue: ReviewQueueItem[];
selectedId: string;
detail: CandidateDetail | null;
busy: boolean;
onSelect: (id: string) => void;
onBusy: (value: boolean) => void;
onError: (message: string) => void;
onNotice: (message: string) => void;
onRefresh: () => Promise<void>;
settings: PlatformRouteContext["settings"];
}) {
const [dialogOpen, setDialogOpen] = useState(false);
const [decision, setDecision] = useState("false_positive");
const [reason, setReason] = useState("");
const [reusable, setReusable] = useState(false);
const [expiresAt, setExpiresAt] = useState("");
async function submitDisposition(event: FormEvent) {
event.preventDefault();
if (!detail) return;
onBusy(true);
onError("");
try {
await createDisposition(settings, detail.candidate.id, {
decision,
reason,
exception_scope: reusable ? "subject_entry" : "candidate",
expires_at:
reusable && expiresAt
? new Date(`${expiresAt}T23:59:59`).toISOString()
: null
});
setDialogOpen(false);
setReason("");
setReusable(false);
onNotice("The disposition was recorded as append-only evidence.");
await onRefresh();
} catch (reasonValue) {
onError(errorMessage(reasonValue));
} finally {
onBusy(false);
}
}
return (
<section className="risk-review-layout">
<aside className="risk-panel risk-review-queue">
<header>
<div>
<strong>Review queue</strong>
<span>{queue.length} candidates need review</span>
</div>
</header>
<div className="risk-list">
{queue.map((item) => (
<button
type="button"
className={
item.id === selectedId
? "risk-queue-row selected"
: "risk-queue-row"
}
key={item.id}
onClick={() => onSelect(item.id)}
>
<span className="risk-score">{item.score}</span>
<span className="risk-list-main">
<strong>{item.subject_name || "Identifier-only subject"}</strong>
<span>{item.entry_name}</span>
</span>
<StatusBadge status={item.review_status} />
</button>
))}
{!queue.length && (
<div className="risk-empty">
No candidates currently need review.
</div>
)}
</div>
</aside>
<div className="risk-panel risk-evidence">
<header>
<div>
<strong>Candidate evidence</strong>
<span>Subject and immutable list entry comparison</span>
</div>
{detail && (
<Button
variant="primary"
onClick={() => setDialogOpen(true)}
>
Record disposition
</Button>
)}
</header>
{!detail && (
<div className="risk-empty">
Select a candidate from the queue.
</div>
)}
{detail && (
<div className="risk-evidence-body">
<div className="risk-comparison">
<EvidenceColumn
title="Screening subject"
name={detail.subject.primary_name || "Identifier-only"}
type={detail.subject.subject_type}
aliases={detail.subject.aliases}
identifiers={detail.subject.identifiers.map(
(item) => item.value || ""
)}
dates={detail.subject.dates}
/>
<EvidenceColumn
title="Sanctions list entry"
name={detail.candidate.entry.primary_name}
type={detail.candidate.entry.subject_type}
aliases={detail.candidate.entry.aliases.map(
(item) => item.name
)}
identifiers={detail.candidate.entry.identifiers.map(
(item) =>
`${item.identifier_type}: ${item.value}`
)}
dates={detail.candidate.entry.dates.map(
(item) => item.value
)}
/>
</div>
<div className="risk-match-evidence">
<strong>
{detail.candidate.score}% ·{" "}
{detail.candidate.match_kind}
</strong>
<span>
List {detail.list_snapshot.source_version} ·{" "}
{detail.run.matcher_version} ·{" "}
{detail.run.normalization_version}
</span>
<code>
{detail.candidate.entry.raw_evidence_locator}
</code>
</div>
</div>
)}
</div>
<Dialog
open={dialogOpen}
title="Record screening disposition"
onClose={() => setDialogOpen(false)}
closeDisabled={busy}
footer={
<>
<Button
type="button"
onClick={() => setDialogOpen(false)}
disabled={busy}
>
Cancel
</Button>
<Button
form="risk-disposition-form"
type="submit"
variant="primary"
disabled={
busy ||
reason.trim().length < 3 ||
(reusable && !expiresAt)
}
>
Record
</Button>
</>
}
>
<form
id="risk-disposition-form"
className="risk-disposition-form"
onSubmit={submitDisposition}
>
<FormField label="Decision">
<select
value={decision}
onChange={(event) => setDecision(event.target.value)}
>
<option value="false_positive">False positive</option>
<option value="true_match">Confirmed match</option>
<option value="needs_information">
More information needed
</option>
<option value="escalated">Escalate</option>
</select>
</FormField>
<FormField label="Reason">
<textarea
value={reason}
onChange={(event) => setReason(event.target.value)}
rows={5}
maxLength={10000}
required
/>
</FormField>
{decision === "false_positive" && (
<>
<ToggleSwitch
checked={reusable}
onChange={(event) => setReusable(event.target.checked)}
label="Apply as a time-bounded exception to this subject and list entry"
/>
{reusable && (
<FormField label="Exception expires">
<input
type="date"
value={expiresAt}
onChange={(event) => setExpiresAt(event.target.value)}
required
/>
</FormField>
)}
</>
)}
</form>
</Dialog>
</section>
);
}
function EvidenceColumn({
title,
name,
type,
aliases,
identifiers,
dates
}: {
title: string;
name: string;
type: string;
aliases: string[];
identifiers: string[];
dates: string[];
}) {
return (
<div className="risk-evidence-column">
<span className="risk-eyebrow">{title}</span>
<strong>{name}</strong>
<span>{type}</span>
<EvidenceValues label="Aliases" values={aliases} />
<EvidenceValues label="Identifiers" values={identifiers} />
<EvidenceValues label="Dates" values={dates} />
</div>
);
}
function EvidenceValues({
label,
values
}: {
label: string;
values: string[];
}) {
return (
<div className="risk-evidence-values">
<span>{label}</span>
<strong>{values.filter(Boolean).join(", ") || "None"}</strong>
</div>
);
}
function formatDate(value: string) {
return new Intl.DateTimeFormat(undefined, {
dateStyle: "medium",
timeStyle: "short"
}).format(new Date(value));
}
function errorMessage(reason: unknown) {
return reason instanceof Error
? reason.message
: "The Risk Compliance operation failed.";
}

2
webui/src/index.ts Normal file
View File

@@ -0,0 +1,2 @@
export { default, riskComplianceModule } from "./module";
export * from "./api/riskCompliance";

71
webui/src/module.ts Normal file
View File

@@ -0,0 +1,71 @@
import { createElement, lazy } from "react";
import type { PlatformWebModule } from "@govoplan/core-webui";
import "./styles/risk-compliance.css";
const RiskCompliancePage = lazy(
() => import("./features/riskCompliance/RiskCompliancePage")
);
export const riskComplianceModule: PlatformWebModule = {
id: "risk_compliance",
label: "Risk Compliance",
version: "0.1.8",
dependencies: ["access"],
optionalDependencies: [
"audit",
"policy",
"records",
"inspections",
"files",
"tasks",
"notifications",
"connectors",
"views",
"workflow"
],
navItems: [
{
to: "/risk-compliance",
label: "Risk Compliance",
iconName: "shield-check",
anyOf: ["risk_compliance:sanctions:read"],
order: 115,
surfaceId: "risk_compliance.navigation"
}
],
routes: [
{
path: "/risk-compliance",
anyOf: ["risk_compliance:sanctions:read"],
order: 115,
surfaceId: "risk_compliance.workspace",
render: (context) => createElement(RiskCompliancePage, context)
}
],
viewSurfaces: [
{
id: "risk_compliance.sanctions.sources",
moduleId: "risk_compliance",
kind: "section",
label: "Sanctions source snapshots",
order: 20
},
{
id: "risk_compliance.sanctions.screening",
moduleId: "risk_compliance",
kind: "section",
label: "Sanctions screening",
order: 30
},
{
id: "risk_compliance.sanctions.review",
moduleId: "risk_compliance",
kind: "section",
label: "Sanctions review queue",
order: 40
}
]
};
export default riskComplianceModule;

View File

@@ -0,0 +1,353 @@
.risk-page {
display: flex;
min-width: 0;
min-height: 0;
height: 100%;
flex-direction: column;
background: var(--panel);
}
.risk-toolbar {
display: flex;
align-items: center;
gap: 10px;
flex: 0 0 auto;
min-height: 50px;
border-bottom: var(--border-line);
background: var(--panel-header);
padding: 8px 14px;
}
.risk-toolbar .segmented-control-option {
gap: 6px;
}
.risk-toolbar-spacer {
flex: 1;
}
.risk-count {
min-width: 18px;
border-radius: 9px;
background: var(--accent);
color: #fff;
padding: 1px 5px;
font-size: 10px;
text-align: center;
}
.risk-alerts {
flex: 0 0 auto;
padding: 10px 14px 0;
}
.risk-workspace {
min-width: 0;
min-height: 0;
flex: 1;
overflow: hidden;
padding: 14px;
}
.risk-source-layout,
.risk-screen-layout,
.risk-review-layout {
display: grid;
min-width: 0;
min-height: 0;
height: 100%;
gap: 12px;
}
.risk-source-layout,
.risk-screen-layout {
grid-template-columns: minmax(320px, 1fr) minmax(360px, 1.35fr);
}
.risk-review-layout {
grid-template-columns: minmax(300px, 0.7fr) minmax(480px, 1.6fr);
}
.risk-panel {
display: flex;
min-width: 0;
min-height: 0;
flex-direction: column;
border: var(--border-line);
border-radius: 6px;
background: var(--surface);
overflow: hidden;
}
.risk-panel > header {
display: flex;
align-items: center;
gap: 10px;
min-height: 56px;
flex: 0 0 auto;
border-bottom: var(--border-line);
background: var(--panel-header);
padding: 9px 12px;
}
.risk-panel > header > div:first-child {
display: grid;
min-width: 0;
gap: 2px;
flex: 1;
}
.risk-panel > header strong {
color: var(--text-strong);
font-size: 13px;
}
.risk-panel > header span {
color: var(--muted);
font-size: 11px;
}
.risk-list,
.risk-result-body,
.risk-evidence-body {
min-height: 0;
flex: 1;
overflow: auto;
}
.risk-list-row,
.risk-queue-row,
.risk-candidate-summary {
display: flex;
align-items: center;
gap: 10px;
min-height: 58px;
border: 0;
border-bottom: var(--border-line);
background: transparent;
color: var(--text);
padding: 8px 11px;
}
.risk-queue-row {
width: 100%;
cursor: pointer;
text-align: left;
font: inherit;
}
.risk-queue-row:hover,
.risk-queue-row.selected {
background: var(--sidebar-hover-bg);
}
.risk-list-main {
display: grid;
min-width: 0;
gap: 3px;
flex: 1;
}
.risk-list-main strong,
.risk-list-main span,
.risk-list-main code {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.risk-list-main strong {
color: var(--text-strong);
font-size: 13px;
}
.risk-list-main span,
.risk-list-main code {
color: var(--muted);
font-size: 11px;
}
.risk-empty {
color: var(--muted);
padding: 24px;
text-align: center;
}
.risk-form-body {
display: grid;
align-content: start;
gap: 14px;
overflow: auto;
padding: 16px;
}
.risk-form-body input,
.risk-form-body select,
.risk-disposition-form input,
.risk-disposition-form select,
.risk-disposition-form textarea {
width: 100%;
box-sizing: border-box;
}
.risk-form-body .btn {
justify-self: start;
}
.risk-metrics {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
border-bottom: var(--border-line);
}
.risk-metrics > span {
display: grid;
min-width: 0;
gap: 4px;
border-right: var(--border-line);
color: var(--muted);
padding: 12px;
font-size: 11px;
}
.risk-metrics > span:last-child {
border-right: 0;
}
.risk-metrics strong {
overflow: hidden;
color: var(--text-strong);
text-overflow: ellipsis;
white-space: nowrap;
font-size: 14px;
}
.risk-score {
display: inline-grid;
width: 38px;
height: 32px;
flex: 0 0 38px;
place-items: center;
border: 1px solid var(--warning-border);
border-radius: 4px;
background: var(--warning-soft);
color: var(--text-strong);
font-weight: 700;
}
.risk-candidate-summary > div {
display: grid;
min-width: 0;
gap: 3px;
flex: 1;
}
.risk-candidate-summary span {
color: var(--muted);
font-size: 11px;
}
.risk-clear {
display: flex;
align-items: center;
gap: 8px;
color: var(--success);
padding: 18px;
}
.risk-comparison {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
border-bottom: var(--border-line);
}
.risk-evidence-column {
display: grid;
align-content: start;
gap: 5px;
min-width: 0;
border-right: var(--border-line);
padding: 16px;
}
.risk-evidence-column:last-child {
border-right: 0;
}
.risk-evidence-column > strong {
color: var(--text-strong);
font-size: 16px;
}
.risk-evidence-column > span {
color: var(--muted);
font-size: 12px;
}
.risk-eyebrow {
text-transform: uppercase;
font-size: 10px !important;
font-weight: 700;
}
.risk-evidence-values {
display: grid;
gap: 3px;
margin-top: 10px;
}
.risk-evidence-values span {
color: var(--muted);
font-size: 10px;
text-transform: uppercase;
}
.risk-evidence-values strong {
overflow-wrap: anywhere;
color: var(--text);
font-size: 12px;
font-weight: 500;
}
.risk-match-evidence {
display: grid;
gap: 6px;
padding: 16px;
}
.risk-match-evidence span,
.risk-match-evidence code {
color: var(--muted);
font-size: 11px;
}
.risk-disposition-form {
display: grid;
gap: 14px;
}
@media (max-width: 920px) {
.risk-workspace {
overflow: auto;
}
.risk-source-layout,
.risk-screen-layout,
.risk-review-layout {
height: auto;
grid-template-columns: minmax(0, 1fr);
}
.risk-panel {
min-height: 340px;
}
.risk-comparison {
grid-template-columns: minmax(0, 1fr);
}
.risk-evidence-column {
border-right: 0;
border-bottom: var(--border-line);
}
}