diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx
index 8f54c0b..1db4193 100644
--- a/apps/web/src/App.tsx
+++ b/apps/web/src/App.tsx
@@ -1,7 +1,7 @@
import { useState } from "react";
import type { RiskAssessment } from "@custos/core";
-import { requiresFriction, requiresHardBlock } from "@custos/core";
import { analyzeSendIntent } from "./compositionRoot.js";
+import { RiskResult } from "./RiskResult.js";
export function App() {
const [address, setAddress] = useState("");
@@ -62,20 +62,7 @@ export function App() {
{busy ? "Analyzing on-device..." : "Check before sending"}
- {assessment && (
-
-
Risk level: {assessment.level}
-
{assessment.summary}
- {requiresHardBlock(assessment.level) &&
This send would be blocked — WDK signing must not proceed.
}
-
- )}
+ {assessment && }
);
}
diff --git a/apps/web/src/RiskResult.tsx b/apps/web/src/RiskResult.tsx
new file mode 100644
index 0000000..7267653
--- /dev/null
+++ b/apps/web/src/RiskResult.tsx
@@ -0,0 +1,27 @@
+import type { RiskAssessment } from "@custos/core";
+import { requiresHardBlock } from "@custos/core";
+import { describeRiskLevel, describeScamCategory } from "./riskPresentation.js";
+
+export function RiskResult({ assessment }: { assessment: RiskAssessment }) {
+ const { label, color, icon } = describeRiskLevel(assessment.level);
+
+ return (
+
+
+ {icon} {label}
+
+
{assessment.summary}
+ {assessment.matches.length > 0 && (
+
+ {assessment.matches.map((match, i) => (
+ -
+ {describeScamCategory(match.pattern.category)}
+ {match.evidenceSnippet ? ` — ${match.evidenceSnippet}` : ""}
+
+ ))}
+
+ )}
+ {requiresHardBlock(assessment.level) &&
This send would be blocked — WDK signing must not proceed.
}
+
+ );
+}
diff --git a/apps/web/src/riskPresentation.ts b/apps/web/src/riskPresentation.ts
new file mode 100644
index 0000000..5717754
--- /dev/null
+++ b/apps/web/src/riskPresentation.ts
@@ -0,0 +1,39 @@
+import { RiskLevel } from "@custos/core";
+import type { ScamCategory } from "@custos/core";
+
+export interface RiskLevelPresentation {
+ readonly label: string;
+ readonly color: string;
+ readonly icon: string;
+}
+
+const RISK_LEVEL_PRESENTATION: Record = {
+ [RiskLevel.None]: { label: "No known risk", color: "#27ae60", icon: "✅" },
+ [RiskLevel.Low]: { label: "Low — minor signals detected", color: "#16a085", icon: "ℹ️" },
+ [RiskLevel.Elevated]: { label: "Elevated — review before sending", color: "#e67e22", icon: "⚠️" },
+ [RiskLevel.High]: { label: "High — strong scam signals", color: "#d35400", icon: "🔶" },
+ [RiskLevel.Critical]: { label: "Critical — send blocked", color: "#c0392b", icon: "🛑" },
+};
+
+export function describeRiskLevel(level: RiskLevel): RiskLevelPresentation {
+ return RISK_LEVEL_PRESENTATION[level];
+}
+
+const SCAM_CATEGORY_LABEL: Record = {
+ "pig-butchering": "Pig-butchering scam",
+ "fake-support": "Fake support impersonation",
+ "recovery-scam": "Fund recovery scam",
+ "romance-scam": "Romance scam",
+ "fake-investment-platform": "Fake investment platform",
+ "urgency-pressure": "Urgency/pressure tactics",
+ "address-poisoning": "Lookalike address (poisoning attempt)",
+ "task-scam": "Task/job scam",
+ "impersonation-authority": "Authority impersonation",
+ "airdrop-claim-fee": "Fake airdrop claim fee",
+ "otc-escrow-scam": "OTC escrow scam",
+ "phishing-approval-drain": "Phishing / approval drain",
+};
+
+export function describeScamCategory(category: ScamCategory): string {
+ return SCAM_CATEGORY_LABEL[category];
+}