@decentrys/ui-sdk 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +51 -0
- package/dist/browser/decentrys-ui.js +1733 -0
- package/dist/browser/decentrys-ui.mjs +1681 -0
- package/dist/browser/react-dom-global.d.ts +6 -0
- package/dist/browser/react-dom-global.d.ts.map +1 -0
- package/dist/browser/react-dom-global.js +18 -0
- package/dist/browser/react-dom-global.js.map +1 -0
- package/dist/browser/react-global.d.ts +25 -0
- package/dist/browser/react-global.d.ts.map +1 -0
- package/dist/browser/react-global.js +39 -0
- package/dist/browser/react-global.js.map +1 -0
- package/dist/browser/react-jsx-runtime.d.ts +17 -0
- package/dist/browser/react-jsx-runtime.d.ts.map +1 -0
- package/dist/browser/react-jsx-runtime.js +27 -0
- package/dist/browser/react-jsx-runtime.js.map +1 -0
- package/dist/components/AddressRiskBadge.d.ts +39 -0
- package/dist/components/AddressRiskBadge.d.ts.map +1 -0
- package/dist/components/AddressRiskBadge.js +26 -0
- package/dist/components/AddressRiskBadge.js.map +1 -0
- package/dist/components/ApprovalWarning.d.ts +29 -0
- package/dist/components/ApprovalWarning.d.ts.map +1 -0
- package/dist/components/ApprovalWarning.js +18 -0
- package/dist/components/ApprovalWarning.js.map +1 -0
- package/dist/components/ContractFacts.d.ts +27 -0
- package/dist/components/ContractFacts.d.ts.map +1 -0
- package/dist/components/ContractFacts.js +15 -0
- package/dist/components/ContractFacts.js.map +1 -0
- package/dist/components/RiskDetailsModal.d.ts +35 -0
- package/dist/components/RiskDetailsModal.d.ts.map +1 -0
- package/dist/components/RiskDetailsModal.js +74 -0
- package/dist/components/RiskDetailsModal.js.map +1 -0
- package/dist/components/ThreatSignalList.d.ts +28 -0
- package/dist/components/ThreatSignalList.d.ts.map +1 -0
- package/dist/components/ThreatSignalList.js +28 -0
- package/dist/components/ThreatSignalList.js.map +1 -0
- package/dist/components/TokenSecurityPanel.d.ts +28 -0
- package/dist/components/TokenSecurityPanel.d.ts.map +1 -0
- package/dist/components/TokenSecurityPanel.js +26 -0
- package/dist/components/TokenSecurityPanel.js.map +1 -0
- package/dist/components/TransactionRiskBanner.d.ts +36 -0
- package/dist/components/TransactionRiskBanner.d.ts.map +1 -0
- package/dist/components/TransactionRiskBanner.js +20 -0
- package/dist/components/TransactionRiskBanner.js.map +1 -0
- package/dist/context.d.ts +51 -0
- package/dist/context.d.ts.map +1 -0
- package/dist/context.js +47 -0
- package/dist/context.js.map +1 -0
- package/dist/copy.d.ts +72 -0
- package/dist/copy.d.ts.map +1 -0
- package/dist/copy.js +77 -0
- package/dist/copy.js.map +1 -0
- package/dist/index.d.ts +57 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +107 -0
- package/dist/index.js.map +1 -0
- package/dist/presentation.d.ts +316 -0
- package/dist/presentation.d.ts.map +1 -0
- package/dist/presentation.js +533 -0
- package/dist/presentation.js.map +1 -0
- package/dist/primitives.d.ts +139 -0
- package/dist/primitives.d.ts.map +1 -0
- package/dist/primitives.js +150 -0
- package/dist/primitives.js.map +1 -0
- package/dist/styles.d.ts +15 -0
- package/dist/styles.d.ts.map +1 -0
- package/dist/styles.js +285 -0
- package/dist/styles.js.map +1 -0
- package/dist/theme.d.ts +52 -0
- package/dist/theme.d.ts.map +1 -0
- package/dist/theme.js +90 -0
- package/dist/theme.js.map +1 -0
- package/package.json +70 -0
- package/src/browser/react-dom-global.ts +20 -0
- package/src/browser/react-global.ts +42 -0
- package/src/browser/react-jsx-runtime.ts +23 -0
- package/src/components/AddressRiskBadge.tsx +94 -0
- package/src/components/ApprovalWarning.tsx +144 -0
- package/src/components/ContractFacts.tsx +119 -0
- package/src/components/RiskDetailsModal.tsx +229 -0
- package/src/components/ThreatSignalList.tsx +76 -0
- package/src/components/TokenSecurityPanel.tsx +168 -0
- package/src/components/TransactionRiskBanner.tsx +123 -0
- package/src/context.tsx +89 -0
- package/src/copy.test.ts +214 -0
- package/src/copy.ts +151 -0
- package/src/index.ts +84 -0
- package/src/presentation.test.ts +521 -0
- package/src/presentation.ts +846 -0
- package/src/primitives.tsx +384 -0
- package/src/styles.ts +281 -0
- package/src/theme.test.ts +105 -0
- package/src/theme.ts +117 -0
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* "Why?" — answered completely.
|
|
3
|
+
*
|
|
4
|
+
* Everything the banner had to summarise is here in full: every explanation
|
|
5
|
+
* line, every fact with its source and timestamp, every capability, every
|
|
6
|
+
* signal including the ones that did not count, every evidence item with the
|
|
7
|
+
* source that produced it and whether an analyst confirmed it, every unknown,
|
|
8
|
+
* the score components with coverage labelled as coverage, and the model
|
|
9
|
+
* version the classification came from.
|
|
10
|
+
*
|
|
11
|
+
* This is the component that makes "nothing unexplained" true rather than
|
|
12
|
+
* aspirational. If a user disagrees with an assessment, this dialog is where
|
|
13
|
+
* they find the specific claim to disagree with.
|
|
14
|
+
*
|
|
15
|
+
* Accessibility here is functional, not decorative: the dialog takes focus on
|
|
16
|
+
* open and returns it on close, Escape closes it, the backdrop closes it, and
|
|
17
|
+
* it is labelled by its own heading. A modal that traps a wallet user with no
|
|
18
|
+
* keyboard route out is a worse bug than anything it was going to explain.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { useEffect, useId, useRef, type ReactNode } from 'react';
|
|
22
|
+
import { createPortal } from 'react-dom';
|
|
23
|
+
import type { ProtectResult } from '@decentrys/protect';
|
|
24
|
+
import { useUiConfig } from '../context';
|
|
25
|
+
import { themeStyle } from '../theme';
|
|
26
|
+
import {
|
|
27
|
+
AvailabilityNote, CapabilityRow, type CommonProps, DemotedNote, EmptyNote, ExplanationList,
|
|
28
|
+
FactRow, HistoryNote, LevelBadge, Mono, Section, SignalList, StyleGate, UnknownRow, cx,
|
|
29
|
+
} from '../primitives';
|
|
30
|
+
import {
|
|
31
|
+
assessmentSections, componentRows, decisionPresentation, formatConfidence, formatTimestamp,
|
|
32
|
+
levelPresentation,
|
|
33
|
+
} from '../presentation';
|
|
34
|
+
|
|
35
|
+
export interface RiskDetailsModalProps extends CommonProps {
|
|
36
|
+
result: ProtectResult;
|
|
37
|
+
open: boolean;
|
|
38
|
+
onClose: () => void;
|
|
39
|
+
subjectNoun?: string;
|
|
40
|
+
/**
|
|
41
|
+
* Render into `document.body` rather than in place. Disable it for a shadow
|
|
42
|
+
* root, a React Native-style renderer or a server render.
|
|
43
|
+
*/
|
|
44
|
+
portal?: boolean;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function RiskDetailsModal({
|
|
48
|
+
result, open, onClose, subjectNoun = 'address', portal = true,
|
|
49
|
+
className, style, theme, copy: copyOverrides, levelLabels,
|
|
50
|
+
}: RiskDetailsModalProps): ReactNode {
|
|
51
|
+
const { copy, theme: mergedTheme, emitStyles } = useUiConfig({ theme, copy: copyOverrides });
|
|
52
|
+
const titleId = useId();
|
|
53
|
+
const dialogRef = useRef<HTMLDivElement | null>(null);
|
|
54
|
+
const restoreFocusTo = useRef<Element | null>(null);
|
|
55
|
+
|
|
56
|
+
useEffect(() => {
|
|
57
|
+
if (!open) return undefined;
|
|
58
|
+
restoreFocusTo.current = typeof document === 'undefined' ? null : document.activeElement;
|
|
59
|
+
dialogRef.current?.focus();
|
|
60
|
+
|
|
61
|
+
const onKeyDown = (event: KeyboardEvent) => {
|
|
62
|
+
if (event.key === 'Escape') {
|
|
63
|
+
event.stopPropagation();
|
|
64
|
+
onClose();
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
document.addEventListener('keydown', onKeyDown);
|
|
68
|
+
return () => {
|
|
69
|
+
document.removeEventListener('keydown', onKeyDown);
|
|
70
|
+
// Returning focus is the half of this that is usually skipped, and the
|
|
71
|
+
// half a keyboard user actually notices.
|
|
72
|
+
const previous = restoreFocusTo.current;
|
|
73
|
+
if (previous instanceof HTMLElement) previous.focus();
|
|
74
|
+
};
|
|
75
|
+
}, [open, onClose]);
|
|
76
|
+
|
|
77
|
+
if (!open) return null;
|
|
78
|
+
|
|
79
|
+
const { assessment, subject, decision, cached, demotedSignals } = result;
|
|
80
|
+
const level = levelPresentation(assessment.riskLevel, { labels: levelLabels });
|
|
81
|
+
const sections = assessmentSections(assessment, copy);
|
|
82
|
+
const policy = decisionPresentation(decision);
|
|
83
|
+
|
|
84
|
+
const dialog = (
|
|
85
|
+
<div
|
|
86
|
+
className={cx('dcy', 'dcy-backdrop')}
|
|
87
|
+
style={themeStyle(mergedTheme)}
|
|
88
|
+
data-dcy-component="risk-details-modal"
|
|
89
|
+
onClick={(event) => {
|
|
90
|
+
if (event.target === event.currentTarget) onClose();
|
|
91
|
+
}}
|
|
92
|
+
>
|
|
93
|
+
<StyleGate emit={emitStyles} />
|
|
94
|
+
<div
|
|
95
|
+
ref={dialogRef}
|
|
96
|
+
role="dialog"
|
|
97
|
+
aria-modal="true"
|
|
98
|
+
aria-labelledby={titleId}
|
|
99
|
+
tabIndex={-1}
|
|
100
|
+
className={cx('dcy-modal', className)}
|
|
101
|
+
style={style}
|
|
102
|
+
data-dcy-level={assessment.riskLevel}
|
|
103
|
+
data-dcy-tone={level.tone}
|
|
104
|
+
>
|
|
105
|
+
<div className="dcy-modal-head">
|
|
106
|
+
<h2 className="dcy-title" id={titleId}>{copy.detailsTitle}</h2>
|
|
107
|
+
<button type="button" className="dcy-btn" onClick={onClose}>{copy.close}</button>
|
|
108
|
+
</div>
|
|
109
|
+
|
|
110
|
+
<div className="dcy-head">
|
|
111
|
+
<div className="dcy-head-main">
|
|
112
|
+
<h3 className="dcy-title">{level.label}</h3>
|
|
113
|
+
<p className="dcy-meaning">{level.meaning}</p>
|
|
114
|
+
</div>
|
|
115
|
+
<LevelBadge level={level} />
|
|
116
|
+
</div>
|
|
117
|
+
|
|
118
|
+
<dl className="dcy-kv">
|
|
119
|
+
<dt>Subject</dt>
|
|
120
|
+
<dd><Mono>{subject.identifier}</Mono></dd>
|
|
121
|
+
<dt>Chain</dt>
|
|
122
|
+
<dd>{subject.chain}</dd>
|
|
123
|
+
<dt>Kind</dt>
|
|
124
|
+
<dd>{subject.kind}</dd>
|
|
125
|
+
<dt>{copy.confidenceLabel}</dt>
|
|
126
|
+
<dd>{formatConfidence(assessment.confidence)} — {copy.confidenceCaveat}</dd>
|
|
127
|
+
<dt>{copy.modelLabel}</dt>
|
|
128
|
+
<dd><Mono>{assessment.modelVersion}</Mono></dd>
|
|
129
|
+
<dt>{copy.assessedLabel}</dt>
|
|
130
|
+
<dd>{formatTimestamp(assessment.assessedAt)}</dd>
|
|
131
|
+
</dl>
|
|
132
|
+
|
|
133
|
+
<AvailabilityNote assessment={assessment} />
|
|
134
|
+
|
|
135
|
+
<Section title="Why this level">
|
|
136
|
+
<ExplanationList explanation={assessment.explanation} />
|
|
137
|
+
</Section>
|
|
138
|
+
|
|
139
|
+
{sections.isEmpty ? <EmptyNote>{sections.emptyStatement}</EmptyNote> : null}
|
|
140
|
+
|
|
141
|
+
<Section title={copy.signalsHeading}>
|
|
142
|
+
<SignalList
|
|
143
|
+
signals={[...sections.activeSignals, ...sections.inertSignals]}
|
|
144
|
+
copy={copy}
|
|
145
|
+
subjectNoun={subjectNoun}
|
|
146
|
+
showEvidence
|
|
147
|
+
/>
|
|
148
|
+
</Section>
|
|
149
|
+
|
|
150
|
+
<Section title={copy.capabilitiesHeading} note={copy.capabilitiesAreNotFindings}>
|
|
151
|
+
{sections.capabilities.length === 0 ? (
|
|
152
|
+
<EmptyNote>{copy.noCapabilities}</EmptyNote>
|
|
153
|
+
) : (
|
|
154
|
+
<ul className="dcy-list">
|
|
155
|
+
{sections.capabilities.map((capability, index) => (
|
|
156
|
+
<CapabilityRow key={`${capability.type}:${index}`} capability={capability} copy={copy} />
|
|
157
|
+
))}
|
|
158
|
+
</ul>
|
|
159
|
+
)}
|
|
160
|
+
</Section>
|
|
161
|
+
|
|
162
|
+
<Section title={copy.factsHeading} note={copy.factsAreNotFindings}>
|
|
163
|
+
{sections.facts.length === 0 ? (
|
|
164
|
+
<EmptyNote>{copy.noFacts}</EmptyNote>
|
|
165
|
+
) : (
|
|
166
|
+
<ul className="dcy-list">
|
|
167
|
+
{sections.facts.map((fact, index) => (
|
|
168
|
+
<FactRow key={`${fact.type}:${index}`} fact={fact} copy={copy} />
|
|
169
|
+
))}
|
|
170
|
+
</ul>
|
|
171
|
+
)}
|
|
172
|
+
</Section>
|
|
173
|
+
|
|
174
|
+
<Section title={copy.unknownsHeading} note={copy.unknownsAreNotRisk}>
|
|
175
|
+
{sections.unknowns.length === 0 ? (
|
|
176
|
+
<EmptyNote>{copy.noUnknowns}</EmptyNote>
|
|
177
|
+
) : (
|
|
178
|
+
<ul className="dcy-list">
|
|
179
|
+
{sections.unknowns.map((unknown) => (
|
|
180
|
+
<UnknownRow key={unknown.field} unknown={unknown} copy={copy} />
|
|
181
|
+
))}
|
|
182
|
+
</ul>
|
|
183
|
+
)}
|
|
184
|
+
</Section>
|
|
185
|
+
|
|
186
|
+
<Section title={copy.historyHeading}>
|
|
187
|
+
<HistoryNote status={assessment.historyStatus} copy={copy} />
|
|
188
|
+
</Section>
|
|
189
|
+
|
|
190
|
+
<Section title={copy.componentsHeading}>
|
|
191
|
+
<dl className="dcy-kv">
|
|
192
|
+
{componentRows(assessment.components, copy).map((row) => (
|
|
193
|
+
<ComponentEntry key={row.key} label={row.label} value={row.value} caveat={row.caveat} />
|
|
194
|
+
))}
|
|
195
|
+
</dl>
|
|
196
|
+
</Section>
|
|
197
|
+
|
|
198
|
+
<DemotedNote demotedSignals={demotedSignals} copy={copy} />
|
|
199
|
+
|
|
200
|
+
<hr className="dcy-hr" />
|
|
201
|
+
|
|
202
|
+
<p className="dcy-note" data-dcy-decision={policy.action}>
|
|
203
|
+
<span className="dcy-chip">{policy.label}</span> {policy.statement} {copy.policyIsYours}
|
|
204
|
+
</p>
|
|
205
|
+
|
|
206
|
+
{cached ? <p className="dcy-note">{copy.cachedNotice}</p> : null}
|
|
207
|
+
</div>
|
|
208
|
+
</div>
|
|
209
|
+
);
|
|
210
|
+
|
|
211
|
+
if (portal && typeof document !== 'undefined') {
|
|
212
|
+
return createPortal(dialog, document.body);
|
|
213
|
+
}
|
|
214
|
+
return dialog;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function ComponentEntry({
|
|
218
|
+
label, value, caveat,
|
|
219
|
+
}: { label: string; value: number; caveat?: string }): ReactNode {
|
|
220
|
+
return (
|
|
221
|
+
<>
|
|
222
|
+
<dt>{label}</dt>
|
|
223
|
+
<dd>
|
|
224
|
+
{Math.round(value)} / 100
|
|
225
|
+
{caveat ? <span className="dcy-note"> {caveat}</span> : null}
|
|
226
|
+
</dd>
|
|
227
|
+
</>
|
|
228
|
+
);
|
|
229
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Threat signals, with the reasons they do or do not count.
|
|
3
|
+
*
|
|
4
|
+
* Everything a signal carries is rendered: severity, confidence as a
|
|
5
|
+
* percentage rather than a word, hop distance as a sentence, status, and the
|
|
6
|
+
* evidence with its source and whether an analyst confirmed it. That is the
|
|
7
|
+
* "users should be shown why" half of the rule, and it is also what keeps the
|
|
8
|
+
* accusation honest — a claim rendered without its evidence is just a score.
|
|
9
|
+
*
|
|
10
|
+
* A signal that did not raise the level is still listed, and toned `inactive`
|
|
11
|
+
* with the reason attached. Dropping it would hide an observation; colouring
|
|
12
|
+
* it like one that counted would misstate the assessment.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { useState, type ReactNode } from 'react';
|
|
16
|
+
import type { ThreatSignal } from '@decentrys/protect';
|
|
17
|
+
import { useUiConfig } from '../context';
|
|
18
|
+
import { themeStyle } from '../theme';
|
|
19
|
+
import { type CommonProps, EmptyNote, Section, SignalList, StyleGate, cx } from '../primitives';
|
|
20
|
+
|
|
21
|
+
export interface ThreatSignalListProps extends CommonProps {
|
|
22
|
+
signals: ThreatSignal[];
|
|
23
|
+
/** Used in hop wording: "an inference about a counterparty of this token". */
|
|
24
|
+
subjectNoun?: string;
|
|
25
|
+
/** Start with the evidence expanded. */
|
|
26
|
+
defaultShowEvidence?: boolean;
|
|
27
|
+
/** Remove the toggle and pin evidence to `defaultShowEvidence`. */
|
|
28
|
+
showEvidenceToggle?: boolean;
|
|
29
|
+
title?: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function ThreatSignalList({
|
|
33
|
+
signals, subjectNoun = 'address', defaultShowEvidence = false, showEvidenceToggle = true, title,
|
|
34
|
+
className, style, theme, copy: copyOverrides,
|
|
35
|
+
}: ThreatSignalListProps): ReactNode {
|
|
36
|
+
const { copy, theme: mergedTheme, emitStyles } = useUiConfig({ theme, copy: copyOverrides });
|
|
37
|
+
const [showEvidence, setShowEvidence] = useState(defaultShowEvidence);
|
|
38
|
+
const evidenceVisible = showEvidenceToggle ? showEvidence : defaultShowEvidence;
|
|
39
|
+
|
|
40
|
+
return (
|
|
41
|
+
<section
|
|
42
|
+
className={cx('dcy', 'dcy-card', className)}
|
|
43
|
+
style={themeStyle(mergedTheme, style)}
|
|
44
|
+
data-dcy-component="threat-signal-list"
|
|
45
|
+
data-dcy-signal-count={String(signals.length)}
|
|
46
|
+
aria-label={title ?? copy.signalsHeading}
|
|
47
|
+
>
|
|
48
|
+
<StyleGate emit={emitStyles} />
|
|
49
|
+
<Section title={title ?? copy.signalsHeading}>
|
|
50
|
+
{signals.length === 0 ? (
|
|
51
|
+
<EmptyNote>{copy.noSignals}</EmptyNote>
|
|
52
|
+
) : (
|
|
53
|
+
<SignalList
|
|
54
|
+
signals={signals}
|
|
55
|
+
copy={copy}
|
|
56
|
+
subjectNoun={subjectNoun}
|
|
57
|
+
showEvidence={evidenceVisible}
|
|
58
|
+
/>
|
|
59
|
+
)}
|
|
60
|
+
</Section>
|
|
61
|
+
|
|
62
|
+
{signals.length > 0 && showEvidenceToggle ? (
|
|
63
|
+
<div>
|
|
64
|
+
<button
|
|
65
|
+
type="button"
|
|
66
|
+
className="dcy-btn dcy-btn-quiet"
|
|
67
|
+
aria-expanded={evidenceVisible}
|
|
68
|
+
onClick={() => setShowEvidence((value) => !value)}
|
|
69
|
+
>
|
|
70
|
+
{evidenceVisible ? copy.hideEvidence : copy.showEvidence}
|
|
71
|
+
</button>
|
|
72
|
+
</div>
|
|
73
|
+
) : null}
|
|
74
|
+
</section>
|
|
75
|
+
);
|
|
76
|
+
}
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A token, described in full.
|
|
3
|
+
*
|
|
4
|
+
* The panel most likely to be embedded on a swap screen, and therefore the one
|
|
5
|
+
* most under pressure to become a green tick or a red cross. It is neither.
|
|
6
|
+
* The four buckets stay four buckets — capabilities, facts, threat signals,
|
|
7
|
+
* unknowns — each with its own heading, its own row shape and its own tone
|
|
8
|
+
* family, and the coverage figure is labelled as coverage rather than folded
|
|
9
|
+
* into anything that looks like a score.
|
|
10
|
+
*
|
|
11
|
+
* A token deployed this morning by an anonymous team with no audit renders
|
|
12
|
+
* here as a list of true statements and a `LIMITED` history note in ordinary
|
|
13
|
+
* body type. That is the whole argument, in one component.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { type ReactNode } from 'react';
|
|
17
|
+
import type { ProtectResult } from '@decentrys/protect';
|
|
18
|
+
import { useUiConfig } from '../context';
|
|
19
|
+
import { themeStyle } from '../theme';
|
|
20
|
+
import {
|
|
21
|
+
AvailabilityNote, CapabilityRow, Chip, type CommonProps, DemotedNote, EmptyNote, ExplanationList,
|
|
22
|
+
FactRow, HistoryNote, LevelHeader, Mono, Section, SignalList, StyleGate, UnknownRow, cx,
|
|
23
|
+
} from '../primitives';
|
|
24
|
+
import { assessmentSections, componentRows, decisionPresentation, levelPresentation } from '../presentation';
|
|
25
|
+
|
|
26
|
+
export interface TokenSecurityPanelProps extends CommonProps {
|
|
27
|
+
result: ProtectResult;
|
|
28
|
+
/** Shown beside the address when the integrator already knows it. */
|
|
29
|
+
tokenName?: string;
|
|
30
|
+
tokenSymbol?: string;
|
|
31
|
+
onViewDetails?: () => void;
|
|
32
|
+
/** Hide the numeric components. They are for developers more than users. */
|
|
33
|
+
showComponents?: boolean;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function TokenSecurityPanel({
|
|
37
|
+
result, tokenName, tokenSymbol, onViewDetails, showComponents = true,
|
|
38
|
+
className, style, theme, copy: copyOverrides, levelLabels,
|
|
39
|
+
}: TokenSecurityPanelProps): ReactNode {
|
|
40
|
+
const { copy, theme: mergedTheme, emitStyles } = useUiConfig({ theme, copy: copyOverrides });
|
|
41
|
+
const { assessment, subject, decision, cached, demotedSignals } = result;
|
|
42
|
+
const level = levelPresentation(assessment.riskLevel, { labels: levelLabels });
|
|
43
|
+
const sections = assessmentSections(assessment, copy);
|
|
44
|
+
const policy = decisionPresentation(decision);
|
|
45
|
+
|
|
46
|
+
return (
|
|
47
|
+
<section
|
|
48
|
+
className={cx('dcy', 'dcy-card', className)}
|
|
49
|
+
style={themeStyle(mergedTheme, style)}
|
|
50
|
+
data-dcy-component="token-security-panel"
|
|
51
|
+
data-dcy-level={assessment.riskLevel}
|
|
52
|
+
data-dcy-tone={level.tone}
|
|
53
|
+
aria-label={level.ariaLabel}
|
|
54
|
+
>
|
|
55
|
+
<StyleGate emit={emitStyles} />
|
|
56
|
+
|
|
57
|
+
<LevelHeader assessment={assessment} copy={copy} levelLabels={levelLabels} />
|
|
58
|
+
|
|
59
|
+
<p className="dcy-note">
|
|
60
|
+
{tokenName || tokenSymbol ? (
|
|
61
|
+
<>
|
|
62
|
+
<Chip>{tokenSymbol ?? tokenName}</Chip>{' '}
|
|
63
|
+
{tokenName && tokenSymbol ? `${tokenName} — ` : null}
|
|
64
|
+
</>
|
|
65
|
+
) : null}
|
|
66
|
+
<Mono>{subject.identifier}</Mono> on {subject.chain}
|
|
67
|
+
</p>
|
|
68
|
+
|
|
69
|
+
<AvailabilityNote assessment={assessment} />
|
|
70
|
+
|
|
71
|
+
<ExplanationList explanation={assessment.explanation} />
|
|
72
|
+
|
|
73
|
+
{sections.isEmpty ? <EmptyNote>{sections.emptyStatement}</EmptyNote> : null}
|
|
74
|
+
|
|
75
|
+
<Section title={copy.capabilitiesHeading} note={copy.capabilitiesAreNotFindings}>
|
|
76
|
+
{sections.capabilities.length === 0 ? (
|
|
77
|
+
<EmptyNote>{copy.noCapabilities}</EmptyNote>
|
|
78
|
+
) : (
|
|
79
|
+
<ul className="dcy-list">
|
|
80
|
+
{sections.capabilities.map((capability, index) => (
|
|
81
|
+
<CapabilityRow key={`${capability.type}:${index}`} capability={capability} copy={copy} />
|
|
82
|
+
))}
|
|
83
|
+
</ul>
|
|
84
|
+
)}
|
|
85
|
+
</Section>
|
|
86
|
+
|
|
87
|
+
<Section title={copy.factsHeading} note={copy.factsAreNotFindings}>
|
|
88
|
+
{sections.facts.length === 0 ? (
|
|
89
|
+
<EmptyNote>{copy.noFacts}</EmptyNote>
|
|
90
|
+
) : (
|
|
91
|
+
<ul className="dcy-list">
|
|
92
|
+
{sections.facts.map((fact, index) => (
|
|
93
|
+
<FactRow key={`${fact.type}:${index}`} fact={fact} copy={copy} />
|
|
94
|
+
))}
|
|
95
|
+
</ul>
|
|
96
|
+
)}
|
|
97
|
+
</Section>
|
|
98
|
+
|
|
99
|
+
<Section title={copy.signalsHeading}>
|
|
100
|
+
<SignalList
|
|
101
|
+
signals={[...sections.activeSignals, ...sections.inertSignals]}
|
|
102
|
+
copy={copy}
|
|
103
|
+
subjectNoun="token"
|
|
104
|
+
/>
|
|
105
|
+
</Section>
|
|
106
|
+
|
|
107
|
+
<Section title={copy.unknownsHeading} note={copy.unknownsAreNotRisk}>
|
|
108
|
+
{sections.unknowns.length === 0 ? (
|
|
109
|
+
<EmptyNote>{copy.noUnknowns}</EmptyNote>
|
|
110
|
+
) : (
|
|
111
|
+
<ul className="dcy-list">
|
|
112
|
+
{sections.unknowns.map((unknown) => (
|
|
113
|
+
<UnknownRow key={unknown.field} unknown={unknown} copy={copy} />
|
|
114
|
+
))}
|
|
115
|
+
</ul>
|
|
116
|
+
)}
|
|
117
|
+
</Section>
|
|
118
|
+
|
|
119
|
+
<HistoryNote status={assessment.historyStatus} copy={copy} />
|
|
120
|
+
|
|
121
|
+
{showComponents ? (
|
|
122
|
+
<Section title={copy.componentsHeading}>
|
|
123
|
+
<dl className="dcy-kv">
|
|
124
|
+
{componentRows(assessment.components, copy).map((row) => (
|
|
125
|
+
<ComponentEntry key={row.key} label={row.label} value={row.value} caveat={row.caveat} />
|
|
126
|
+
))}
|
|
127
|
+
</dl>
|
|
128
|
+
</Section>
|
|
129
|
+
) : null}
|
|
130
|
+
|
|
131
|
+
<DemotedNote demotedSignals={demotedSignals} copy={copy} />
|
|
132
|
+
|
|
133
|
+
<p className="dcy-note" data-dcy-decision={policy.action}>
|
|
134
|
+
<span className="dcy-chip">{policy.label}</span> {policy.statement}
|
|
135
|
+
</p>
|
|
136
|
+
|
|
137
|
+
{cached ? <p className="dcy-note">{copy.cachedNotice}</p> : null}
|
|
138
|
+
|
|
139
|
+
{onViewDetails ? (
|
|
140
|
+
<div>
|
|
141
|
+
<button type="button" className="dcy-btn dcy-btn-quiet" onClick={onViewDetails}>
|
|
142
|
+
{copy.viewDetails}
|
|
143
|
+
</button>
|
|
144
|
+
</div>
|
|
145
|
+
) : null}
|
|
146
|
+
</section>
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* `historyConfidence` arrives here labelled "Coverage" and carrying its own
|
|
152
|
+
* caveat, because the one thing that must never happen to it is being read as
|
|
153
|
+
* a fourth risk number. Low coverage means we know little; it says nothing
|
|
154
|
+
* about the subject.
|
|
155
|
+
*/
|
|
156
|
+
function ComponentEntry({
|
|
157
|
+
label, value, caveat,
|
|
158
|
+
}: { label: string; value: number; caveat?: string }): ReactNode {
|
|
159
|
+
return (
|
|
160
|
+
<>
|
|
161
|
+
<dt>{label}</dt>
|
|
162
|
+
<dd>
|
|
163
|
+
{Math.round(value)} / 100
|
|
164
|
+
{caveat ? <span className="dcy-note"> {caveat}</span> : null}
|
|
165
|
+
</dd>
|
|
166
|
+
</>
|
|
167
|
+
);
|
|
168
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The pre-signature banner.
|
|
3
|
+
*
|
|
4
|
+
* This is the component that will be seen the most, on the screen where a
|
|
5
|
+
* person is about to give away money, so it is also where the model is most
|
|
6
|
+
* easily betrayed. Three things it deliberately does not do:
|
|
7
|
+
*
|
|
8
|
+
* - It does not render a verdict. The level label and the model's own words
|
|
9
|
+
* for what that level means appear together, always, so "Informational"
|
|
10
|
+
* never has to be interpreted from a colour.
|
|
11
|
+
* - It does not hide what did not count. Signals below the confidence floor,
|
|
12
|
+
* stale signals and unknowns are reported with the reason they did not
|
|
13
|
+
* affect the assessment. Silence would read as "we checked and found
|
|
14
|
+
* nothing", which is a different and stronger claim.
|
|
15
|
+
* - It does not disable anything. `actions` is rendered as given; the
|
|
16
|
+
* integrator's policy decides what a user may do, and this banner's job
|
|
17
|
+
* ends at telling them why.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { type ReactNode } from 'react';
|
|
21
|
+
import type { ProtectResult } from '@decentrys/protect';
|
|
22
|
+
import { useUiConfig } from '../context';
|
|
23
|
+
import { themeStyle } from '../theme';
|
|
24
|
+
import {
|
|
25
|
+
AvailabilityNote, type CommonProps, DemotedNote, EmptyNote, ExplanationList, HistoryNote,
|
|
26
|
+
LevelHeader, Section, SignalList, StyleGate, UnknownRow, cx,
|
|
27
|
+
} from '../primitives';
|
|
28
|
+
import { assessmentSections, decisionPresentation, levelPresentation, plural } from '../presentation';
|
|
29
|
+
|
|
30
|
+
export interface TransactionRiskBannerProps extends CommonProps {
|
|
31
|
+
result: ProtectResult;
|
|
32
|
+
/** What the subject should be called in hop wording. */
|
|
33
|
+
subjectNoun?: string;
|
|
34
|
+
/**
|
|
35
|
+
* The integrator's own controls. Rendered untouched — this package never
|
|
36
|
+
* supplies, styles or disables a proceed button.
|
|
37
|
+
*/
|
|
38
|
+
actions?: ReactNode;
|
|
39
|
+
onViewDetails?: () => void;
|
|
40
|
+
/** Inline explanation lines. The rest remain available in the details view. */
|
|
41
|
+
maxExplanationLines?: number;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function TransactionRiskBanner({
|
|
45
|
+
result, subjectNoun = 'transaction', actions, onViewDetails, maxExplanationLines = 4,
|
|
46
|
+
className, style, theme, copy: copyOverrides, levelLabels,
|
|
47
|
+
}: TransactionRiskBannerProps): ReactNode {
|
|
48
|
+
const { copy, theme: mergedTheme, emitStyles } = useUiConfig({ theme, copy: copyOverrides });
|
|
49
|
+
const { assessment, decision, cached, demotedSignals } = result;
|
|
50
|
+
const level = levelPresentation(assessment.riskLevel, { labels: levelLabels });
|
|
51
|
+
const sections = assessmentSections(assessment, copy);
|
|
52
|
+
const policy = decisionPresentation(decision);
|
|
53
|
+
|
|
54
|
+
return (
|
|
55
|
+
<section
|
|
56
|
+
className={cx('dcy', 'dcy-card', className)}
|
|
57
|
+
style={themeStyle(mergedTheme, style)}
|
|
58
|
+
data-dcy-component="transaction-risk-banner"
|
|
59
|
+
data-dcy-level={assessment.riskLevel}
|
|
60
|
+
data-dcy-tone={level.tone}
|
|
61
|
+
/* Only levels the classifier can reach on threat evidence interrupt a
|
|
62
|
+
screen reader. Nothing at INFORMATIONAL has earned that. */
|
|
63
|
+
role={level.liveRegionRole}
|
|
64
|
+
aria-label={level.ariaLabel}
|
|
65
|
+
>
|
|
66
|
+
<StyleGate emit={emitStyles} />
|
|
67
|
+
<LevelHeader assessment={assessment} copy={copy} levelLabels={levelLabels} />
|
|
68
|
+
|
|
69
|
+
<AvailabilityNote assessment={assessment} />
|
|
70
|
+
|
|
71
|
+
<ExplanationList explanation={assessment.explanation.slice(0, maxExplanationLines)} />
|
|
72
|
+
|
|
73
|
+
{sections.isEmpty ? <EmptyNote>{sections.emptyStatement}</EmptyNote> : (
|
|
74
|
+
<p className="dcy-note" data-dcy-summary="">{sections.summary}</p>
|
|
75
|
+
)}
|
|
76
|
+
|
|
77
|
+
{sections.activeSignals.length > 0 ? (
|
|
78
|
+
<Section title={copy.signalsHeading}>
|
|
79
|
+
<SignalList signals={sections.activeSignals} copy={copy} subjectNoun={subjectNoun} />
|
|
80
|
+
</Section>
|
|
81
|
+
) : null}
|
|
82
|
+
|
|
83
|
+
{sections.inertSignals.length > 0 ? (
|
|
84
|
+
<EmptyNote>
|
|
85
|
+
{sections.inertSignals.length}{' '}
|
|
86
|
+
{plural(sections.inertSignals.length, 'further signal was', 'further signals were')}{' '}
|
|
87
|
+
observed and did not affect this assessment. They are listed in full in the details.
|
|
88
|
+
</EmptyNote>
|
|
89
|
+
) : null}
|
|
90
|
+
|
|
91
|
+
{sections.unknowns.length > 0 ? (
|
|
92
|
+
<Section title={copy.unknownsHeading} note={copy.unknownsAreNotRisk}>
|
|
93
|
+
<ul className="dcy-list">
|
|
94
|
+
{sections.unknowns.map((unknown) => (
|
|
95
|
+
<UnknownRow key={unknown.field} unknown={unknown} copy={copy} />
|
|
96
|
+
))}
|
|
97
|
+
</ul>
|
|
98
|
+
</Section>
|
|
99
|
+
) : null}
|
|
100
|
+
|
|
101
|
+
<HistoryNote status={assessment.historyStatus} copy={copy} />
|
|
102
|
+
|
|
103
|
+
<DemotedNote demotedSignals={demotedSignals} copy={copy} />
|
|
104
|
+
|
|
105
|
+
<p className="dcy-note" data-dcy-decision={policy.action}>
|
|
106
|
+
<span className="dcy-chip">{policy.label}</span> {policy.statement}
|
|
107
|
+
</p>
|
|
108
|
+
|
|
109
|
+
{cached ? <p className="dcy-note">{copy.cachedNotice}</p> : null}
|
|
110
|
+
|
|
111
|
+
{onViewDetails || actions ? (
|
|
112
|
+
<div className="dcy-head">
|
|
113
|
+
{onViewDetails ? (
|
|
114
|
+
<button type="button" className="dcy-btn dcy-btn-quiet" onClick={onViewDetails}>
|
|
115
|
+
{copy.viewDetails}
|
|
116
|
+
</button>
|
|
117
|
+
) : null}
|
|
118
|
+
{actions}
|
|
119
|
+
</div>
|
|
120
|
+
) : null}
|
|
121
|
+
</section>
|
|
122
|
+
);
|
|
123
|
+
}
|
package/src/context.tsx
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared configuration, and where the stylesheet comes from.
|
|
3
|
+
*
|
|
4
|
+
* Every component works standalone — drop `<AddressRiskBadge>` into a page and
|
|
5
|
+
* it renders correctly with no provider, because it emits the stylesheet
|
|
6
|
+
* itself. Wrapping a tree in `<DecentrysUiProvider>` does two things: it sets
|
|
7
|
+
* theme and copy once for everything inside, and it emits the stylesheet a
|
|
8
|
+
* single time so the components inside stop emitting their own.
|
|
9
|
+
*
|
|
10
|
+
* The alternative — a module-level "have I injected yet" flag — is the pattern
|
|
11
|
+
* that breaks under server rendering and hydration, because the flag is set
|
|
12
|
+
* during the server pass and the client then renders a tree with no styles in
|
|
13
|
+
* it. A context read is correct in both passes.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { createContext, useContext, type ReactNode } from 'react';
|
|
17
|
+
import { DECENTRYS_UI_CSS } from './styles';
|
|
18
|
+
import { resolveCopy, type UiCopy } from './copy';
|
|
19
|
+
import type { DecentrysTheme } from './theme';
|
|
20
|
+
|
|
21
|
+
interface UiContextValue {
|
|
22
|
+
theme?: DecentrysTheme;
|
|
23
|
+
copy?: Partial<UiCopy>;
|
|
24
|
+
stylesProvided: boolean;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const UiContext = createContext<UiContextValue | null>(null);
|
|
28
|
+
|
|
29
|
+
/** The default stylesheet as a `<style>` element. Safe to render more than once. */
|
|
30
|
+
export function DecentrysUiStyles(): ReactNode {
|
|
31
|
+
return <style data-decentrys-ui="">{DECENTRYS_UI_CSS}</style>;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface DecentrysUiProviderProps {
|
|
35
|
+
/** Token overrides applied to every component beneath. */
|
|
36
|
+
theme?: DecentrysTheme;
|
|
37
|
+
/** Copy overrides — including translation — applied to every component beneath. */
|
|
38
|
+
copy?: Partial<UiCopy>;
|
|
39
|
+
/**
|
|
40
|
+
* Set false when placing `DECENTRYS_UI_CSS` yourself, in a `<head>`, a
|
|
41
|
+
* bundled stylesheet or a shadow root. Components beneath still will not
|
|
42
|
+
* emit their own, because the provider has taken responsibility for it.
|
|
43
|
+
*/
|
|
44
|
+
injectStyles?: boolean;
|
|
45
|
+
children?: ReactNode;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function DecentrysUiProvider({
|
|
49
|
+
theme,
|
|
50
|
+
copy,
|
|
51
|
+
injectStyles = true,
|
|
52
|
+
children,
|
|
53
|
+
}: DecentrysUiProviderProps): ReactNode {
|
|
54
|
+
return (
|
|
55
|
+
<UiContext.Provider value={{ theme, copy, stylesProvided: true }}>
|
|
56
|
+
{injectStyles ? <DecentrysUiStyles /> : null}
|
|
57
|
+
{children}
|
|
58
|
+
</UiContext.Provider>
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface UiConfigInput {
|
|
63
|
+
theme?: DecentrysTheme;
|
|
64
|
+
copy?: Partial<UiCopy>;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface ResolvedUiConfig {
|
|
68
|
+
theme?: DecentrysTheme;
|
|
69
|
+
copy: UiCopy;
|
|
70
|
+
/** True when this component must emit the stylesheet itself. */
|
|
71
|
+
emitStyles: boolean;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Merge provider configuration with a component's own props.
|
|
76
|
+
*
|
|
77
|
+
* The component's props win, so a single banner can be re-themed inside an
|
|
78
|
+
* otherwise uniform tree without unpicking the provider.
|
|
79
|
+
*/
|
|
80
|
+
export function useUiConfig(local: UiConfigInput = {}): ResolvedUiConfig {
|
|
81
|
+
const ctx = useContext(UiContext);
|
|
82
|
+
const theme =
|
|
83
|
+
ctx?.theme || local.theme ? { ...(ctx?.theme ?? {}), ...(local.theme ?? {}) } : undefined;
|
|
84
|
+
return {
|
|
85
|
+
theme,
|
|
86
|
+
copy: resolveCopy({ ...(ctx?.copy ?? {}), ...(local.copy ?? {}) }),
|
|
87
|
+
emitStyles: !ctx?.stylesProvided,
|
|
88
|
+
};
|
|
89
|
+
}
|