@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.
Files changed (93) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +51 -0
  3. package/dist/browser/decentrys-ui.js +1733 -0
  4. package/dist/browser/decentrys-ui.mjs +1681 -0
  5. package/dist/browser/react-dom-global.d.ts +6 -0
  6. package/dist/browser/react-dom-global.d.ts.map +1 -0
  7. package/dist/browser/react-dom-global.js +18 -0
  8. package/dist/browser/react-dom-global.js.map +1 -0
  9. package/dist/browser/react-global.d.ts +25 -0
  10. package/dist/browser/react-global.d.ts.map +1 -0
  11. package/dist/browser/react-global.js +39 -0
  12. package/dist/browser/react-global.js.map +1 -0
  13. package/dist/browser/react-jsx-runtime.d.ts +17 -0
  14. package/dist/browser/react-jsx-runtime.d.ts.map +1 -0
  15. package/dist/browser/react-jsx-runtime.js +27 -0
  16. package/dist/browser/react-jsx-runtime.js.map +1 -0
  17. package/dist/components/AddressRiskBadge.d.ts +39 -0
  18. package/dist/components/AddressRiskBadge.d.ts.map +1 -0
  19. package/dist/components/AddressRiskBadge.js +26 -0
  20. package/dist/components/AddressRiskBadge.js.map +1 -0
  21. package/dist/components/ApprovalWarning.d.ts +29 -0
  22. package/dist/components/ApprovalWarning.d.ts.map +1 -0
  23. package/dist/components/ApprovalWarning.js +18 -0
  24. package/dist/components/ApprovalWarning.js.map +1 -0
  25. package/dist/components/ContractFacts.d.ts +27 -0
  26. package/dist/components/ContractFacts.d.ts.map +1 -0
  27. package/dist/components/ContractFacts.js +15 -0
  28. package/dist/components/ContractFacts.js.map +1 -0
  29. package/dist/components/RiskDetailsModal.d.ts +35 -0
  30. package/dist/components/RiskDetailsModal.d.ts.map +1 -0
  31. package/dist/components/RiskDetailsModal.js +74 -0
  32. package/dist/components/RiskDetailsModal.js.map +1 -0
  33. package/dist/components/ThreatSignalList.d.ts +28 -0
  34. package/dist/components/ThreatSignalList.d.ts.map +1 -0
  35. package/dist/components/ThreatSignalList.js +28 -0
  36. package/dist/components/ThreatSignalList.js.map +1 -0
  37. package/dist/components/TokenSecurityPanel.d.ts +28 -0
  38. package/dist/components/TokenSecurityPanel.d.ts.map +1 -0
  39. package/dist/components/TokenSecurityPanel.js +26 -0
  40. package/dist/components/TokenSecurityPanel.js.map +1 -0
  41. package/dist/components/TransactionRiskBanner.d.ts +36 -0
  42. package/dist/components/TransactionRiskBanner.d.ts.map +1 -0
  43. package/dist/components/TransactionRiskBanner.js +20 -0
  44. package/dist/components/TransactionRiskBanner.js.map +1 -0
  45. package/dist/context.d.ts +51 -0
  46. package/dist/context.d.ts.map +1 -0
  47. package/dist/context.js +47 -0
  48. package/dist/context.js.map +1 -0
  49. package/dist/copy.d.ts +72 -0
  50. package/dist/copy.d.ts.map +1 -0
  51. package/dist/copy.js +77 -0
  52. package/dist/copy.js.map +1 -0
  53. package/dist/index.d.ts +57 -0
  54. package/dist/index.d.ts.map +1 -0
  55. package/dist/index.js +107 -0
  56. package/dist/index.js.map +1 -0
  57. package/dist/presentation.d.ts +316 -0
  58. package/dist/presentation.d.ts.map +1 -0
  59. package/dist/presentation.js +533 -0
  60. package/dist/presentation.js.map +1 -0
  61. package/dist/primitives.d.ts +139 -0
  62. package/dist/primitives.d.ts.map +1 -0
  63. package/dist/primitives.js +150 -0
  64. package/dist/primitives.js.map +1 -0
  65. package/dist/styles.d.ts +15 -0
  66. package/dist/styles.d.ts.map +1 -0
  67. package/dist/styles.js +285 -0
  68. package/dist/styles.js.map +1 -0
  69. package/dist/theme.d.ts +52 -0
  70. package/dist/theme.d.ts.map +1 -0
  71. package/dist/theme.js +90 -0
  72. package/dist/theme.js.map +1 -0
  73. package/package.json +70 -0
  74. package/src/browser/react-dom-global.ts +20 -0
  75. package/src/browser/react-global.ts +42 -0
  76. package/src/browser/react-jsx-runtime.ts +23 -0
  77. package/src/components/AddressRiskBadge.tsx +94 -0
  78. package/src/components/ApprovalWarning.tsx +144 -0
  79. package/src/components/ContractFacts.tsx +119 -0
  80. package/src/components/RiskDetailsModal.tsx +229 -0
  81. package/src/components/ThreatSignalList.tsx +76 -0
  82. package/src/components/TokenSecurityPanel.tsx +168 -0
  83. package/src/components/TransactionRiskBanner.tsx +123 -0
  84. package/src/context.tsx +89 -0
  85. package/src/copy.test.ts +214 -0
  86. package/src/copy.ts +151 -0
  87. package/src/index.ts +84 -0
  88. package/src/presentation.test.ts +521 -0
  89. package/src/presentation.ts +846 -0
  90. package/src/primitives.tsx +384 -0
  91. package/src/styles.ts +281 -0
  92. package/src/theme.test.ts +105 -0
  93. 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
+ }
@@ -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
+ }