@xemahq/ui-kernel 0.1.6 → 0.1.7

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 (178) hide show
  1. package/dist/lib/biome-host/create-biome-orval-config.d.ts +14 -0
  2. package/dist/lib/biome-host/create-biome-orval-config.d.ts.map +1 -0
  3. package/dist/lib/biome-host/create-biome-orval-config.js +22 -0
  4. package/dist/lib/biome-host/create-biome-orval-config.js.map +1 -0
  5. package/dist/lib/biome-host/host-bridge.d.ts +2 -0
  6. package/dist/lib/biome-host/host-bridge.d.ts.map +1 -1
  7. package/dist/lib/biome-host/host-bridge.js.map +1 -1
  8. package/dist/lib/biome-host/host-sources.d.ts +2 -0
  9. package/dist/lib/biome-host/host-sources.d.ts.map +1 -1
  10. package/dist/lib/biome-host/index.d.ts +1 -0
  11. package/dist/lib/biome-host/index.d.ts.map +1 -1
  12. package/dist/lib/biome-host/index.js +1 -0
  13. package/dist/lib/biome-host/index.js.map +1 -1
  14. package/dist/session-kit/approvals/ApprovalButton.d.ts +14 -0
  15. package/dist/session-kit/approvals/ApprovalButton.d.ts.map +1 -0
  16. package/dist/session-kit/approvals/ApprovalButton.js +45 -0
  17. package/dist/session-kit/approvals/ApprovalButton.js.map +1 -0
  18. package/dist/session-kit/approvals/ApprovalCard.d.ts +12 -0
  19. package/dist/session-kit/approvals/ApprovalCard.d.ts.map +1 -0
  20. package/dist/session-kit/approvals/ApprovalCard.js +117 -0
  21. package/dist/session-kit/approvals/ApprovalCard.js.map +1 -0
  22. package/dist/session-kit/approvals/ApprovalsCenter.d.ts +11 -0
  23. package/dist/session-kit/approvals/ApprovalsCenter.d.ts.map +1 -0
  24. package/dist/session-kit/approvals/ApprovalsCenter.js +127 -0
  25. package/dist/session-kit/approvals/ApprovalsCenter.js.map +1 -0
  26. package/dist/session-kit/approvals/CapabilityApprovalStickyBar.d.ts +12 -0
  27. package/dist/session-kit/approvals/CapabilityApprovalStickyBar.d.ts.map +1 -0
  28. package/dist/session-kit/approvals/CapabilityApprovalStickyBar.js +36 -0
  29. package/dist/session-kit/approvals/CapabilityApprovalStickyBar.js.map +1 -0
  30. package/dist/session-kit/approvals/Obligation.d.ts +15 -0
  31. package/dist/session-kit/approvals/Obligation.d.ts.map +1 -0
  32. package/dist/session-kit/approvals/Obligation.js +42 -0
  33. package/dist/session-kit/approvals/Obligation.js.map +1 -0
  34. package/dist/session-kit/approvals/ScopedBody.d.ts +9 -0
  35. package/dist/session-kit/approvals/ScopedBody.d.ts.map +1 -0
  36. package/dist/session-kit/approvals/ScopedBody.js +145 -0
  37. package/dist/session-kit/approvals/ScopedBody.js.map +1 -0
  38. package/dist/session-kit/approvals/approval-icons.d.ts +15 -0
  39. package/dist/session-kit/approvals/approval-icons.d.ts.map +1 -0
  40. package/dist/session-kit/approvals/approval-icons.js +3 -0
  41. package/dist/session-kit/approvals/approval-icons.js.map +1 -0
  42. package/dist/session-kit/approvals/approval-model.d.ts +83 -0
  43. package/dist/session-kit/approvals/approval-model.d.ts.map +1 -0
  44. package/dist/session-kit/approvals/approval-model.js +25 -0
  45. package/dist/session-kit/approvals/approval-model.js.map +1 -0
  46. package/dist/session-kit/approvals/index.d.ts +12 -0
  47. package/dist/session-kit/approvals/index.d.ts.map +1 -0
  48. package/dist/session-kit/approvals/index.js +28 -0
  49. package/dist/session-kit/approvals/index.js.map +1 -0
  50. package/dist/session-kit/approvals/obligation-display.d.ts +17 -0
  51. package/dist/session-kit/approvals/obligation-display.d.ts.map +1 -0
  52. package/dist/session-kit/approvals/obligation-display.js +58 -0
  53. package/dist/session-kit/approvals/obligation-display.js.map +1 -0
  54. package/dist/session-kit/approvals/risk-accent.d.ts +8 -0
  55. package/dist/session-kit/approvals/risk-accent.d.ts.map +1 -0
  56. package/dist/session-kit/approvals/risk-accent.js +28 -0
  57. package/dist/session-kit/approvals/risk-accent.js.map +1 -0
  58. package/dist/session-kit/approvals/scope-icons.d.ts +12 -0
  59. package/dist/session-kit/approvals/scope-icons.d.ts.map +1 -0
  60. package/dist/session-kit/approvals/scope-icons.js +14 -0
  61. package/dist/session-kit/approvals/scope-icons.js.map +1 -0
  62. package/dist/session-kit/combobox/Combobox.d.ts +46 -0
  63. package/dist/session-kit/combobox/Combobox.d.ts.map +1 -0
  64. package/dist/session-kit/combobox/Combobox.js +113 -0
  65. package/dist/session-kit/combobox/Combobox.js.map +1 -0
  66. package/dist/session-kit/combobox/use-click-outside.d.ts +3 -0
  67. package/dist/session-kit/combobox/use-click-outside.d.ts.map +1 -0
  68. package/dist/session-kit/combobox/use-click-outside.js +18 -0
  69. package/dist/session-kit/combobox/use-click-outside.js.map +1 -0
  70. package/dist/session-kit/display/ContextHeader.d.ts +27 -0
  71. package/dist/session-kit/display/ContextHeader.d.ts.map +1 -0
  72. package/dist/session-kit/display/ContextHeader.js +47 -0
  73. package/dist/session-kit/display/ContextHeader.js.map +1 -0
  74. package/dist/session-kit/display/FileDiffCard.d.ts +18 -0
  75. package/dist/session-kit/display/FileDiffCard.d.ts.map +1 -0
  76. package/dist/session-kit/display/FileDiffCard.js +58 -0
  77. package/dist/session-kit/display/FileDiffCard.js.map +1 -0
  78. package/dist/session-kit/display/MD.d.ts +7 -0
  79. package/dist/session-kit/display/MD.d.ts.map +1 -0
  80. package/dist/session-kit/display/MD.js +89 -0
  81. package/dist/session-kit/display/MD.js.map +1 -0
  82. package/dist/session-kit/display/MessageTurn.d.ts +21 -0
  83. package/dist/session-kit/display/MessageTurn.d.ts.map +1 -0
  84. package/dist/session-kit/display/MessageTurn.js +62 -0
  85. package/dist/session-kit/display/MessageTurn.js.map +1 -0
  86. package/dist/session-kit/display/ThinkingPanel.d.ts +12 -0
  87. package/dist/session-kit/display/ThinkingPanel.d.ts.map +1 -0
  88. package/dist/session-kit/display/ThinkingPanel.js +30 -0
  89. package/dist/session-kit/display/ThinkingPanel.js.map +1 -0
  90. package/dist/session-kit/display/TodoChecklist.d.ts +17 -0
  91. package/dist/session-kit/display/TodoChecklist.d.ts.map +1 -0
  92. package/dist/session-kit/display/TodoChecklist.js +50 -0
  93. package/dist/session-kit/display/TodoChecklist.js.map +1 -0
  94. package/dist/session-kit/display/TokenMeter.d.ts +15 -0
  95. package/dist/session-kit/display/TokenMeter.d.ts.map +1 -0
  96. package/dist/session-kit/display/TokenMeter.js +35 -0
  97. package/dist/session-kit/display/TokenMeter.js.map +1 -0
  98. package/dist/session-kit/display/ToolStrip.d.ts +31 -0
  99. package/dist/session-kit/display/ToolStrip.d.ts.map +1 -0
  100. package/dist/session-kit/display/ToolStrip.js +99 -0
  101. package/dist/session-kit/display/ToolStrip.js.map +1 -0
  102. package/dist/session-kit/display/TypingDots.d.ts +3 -0
  103. package/dist/session-kit/display/TypingDots.d.ts.map +1 -0
  104. package/dist/session-kit/display/TypingDots.js +14 -0
  105. package/dist/session-kit/display/TypingDots.js.map +1 -0
  106. package/dist/session-kit/index.d.ts +21 -0
  107. package/dist/session-kit/index.d.ts.map +1 -0
  108. package/dist/session-kit/index.js +37 -0
  109. package/dist/session-kit/index.js.map +1 -0
  110. package/dist/session-kit/lib/enums.d.ts +34 -0
  111. package/dist/session-kit/lib/enums.d.ts.map +1 -0
  112. package/dist/session-kit/lib/enums.js +44 -0
  113. package/dist/session-kit/lib/enums.js.map +1 -0
  114. package/dist/session-kit/lib/portal-accent.d.ts +3 -0
  115. package/dist/session-kit/lib/portal-accent.d.ts.map +1 -0
  116. package/dist/session-kit/lib/portal-accent.js +9 -0
  117. package/dist/session-kit/lib/portal-accent.js.map +1 -0
  118. package/dist/session-kit/lib/status-dot.d.ts +10 -0
  119. package/dist/session-kit/lib/status-dot.d.ts.map +1 -0
  120. package/dist/session-kit/lib/status-dot.js +43 -0
  121. package/dist/session-kit/lib/status-dot.js.map +1 -0
  122. package/dist/session-kit/primitives/Avatar.d.ts +10 -0
  123. package/dist/session-kit/primitives/Avatar.d.ts.map +1 -0
  124. package/dist/session-kit/primitives/Avatar.js +21 -0
  125. package/dist/session-kit/primitives/Avatar.js.map +1 -0
  126. package/dist/session-kit/primitives/PortalGlyph.d.ts +12 -0
  127. package/dist/session-kit/primitives/PortalGlyph.d.ts.map +1 -0
  128. package/dist/session-kit/primitives/PortalGlyph.js +21 -0
  129. package/dist/session-kit/primitives/PortalGlyph.js.map +1 -0
  130. package/dist/session-kit/primitives/RiskPill.d.ts +12 -0
  131. package/dist/session-kit/primitives/RiskPill.d.ts.map +1 -0
  132. package/dist/session-kit/primitives/RiskPill.js +53 -0
  133. package/dist/session-kit/primitives/RiskPill.js.map +1 -0
  134. package/dist/session-kit/primitives/ScopeDot.d.ts +9 -0
  135. package/dist/session-kit/primitives/ScopeDot.d.ts.map +1 -0
  136. package/dist/session-kit/primitives/ScopeDot.js +23 -0
  137. package/dist/session-kit/primitives/ScopeDot.js.map +1 -0
  138. package/dist/session-kit/primitives/Segmented.d.ts +16 -0
  139. package/dist/session-kit/primitives/Segmented.d.ts.map +1 -0
  140. package/dist/session-kit/primitives/Segmented.js +31 -0
  141. package/dist/session-kit/primitives/Segmented.js.map +1 -0
  142. package/package.json +2 -2
  143. package/src/lib/biome-host/create-biome-orval-config.ts +76 -0
  144. package/src/lib/biome-host/host-bridge.ts +22 -0
  145. package/src/lib/biome-host/host-sources.ts +13 -0
  146. package/src/lib/biome-host/index.ts +1 -0
  147. package/src/session-kit/approvals/ApprovalButton.tsx +89 -0
  148. package/src/session-kit/approvals/ApprovalCard.tsx +336 -0
  149. package/src/session-kit/approvals/ApprovalsCenter.tsx +327 -0
  150. package/src/session-kit/approvals/CapabilityApprovalStickyBar.tsx +118 -0
  151. package/src/session-kit/approvals/Obligation.tsx +111 -0
  152. package/src/session-kit/approvals/ScopedBody.tsx +392 -0
  153. package/src/session-kit/approvals/approval-icons.ts +31 -0
  154. package/src/session-kit/approvals/approval-model.ts +205 -0
  155. package/src/session-kit/approvals/index.ts +22 -0
  156. package/src/session-kit/approvals/obligation-display.ts +100 -0
  157. package/src/session-kit/approvals/risk-accent.ts +47 -0
  158. package/src/session-kit/approvals/scope-icons.ts +19 -0
  159. package/src/session-kit/combobox/Combobox.tsx +327 -0
  160. package/src/session-kit/combobox/use-click-outside.ts +21 -0
  161. package/src/session-kit/display/ContextHeader.tsx +148 -0
  162. package/src/session-kit/display/FileDiffCard.tsx +140 -0
  163. package/src/session-kit/display/MD.tsx +153 -0
  164. package/src/session-kit/display/MessageTurn.tsx +157 -0
  165. package/src/session-kit/display/ThinkingPanel.tsx +78 -0
  166. package/src/session-kit/display/TodoChecklist.tsx +120 -0
  167. package/src/session-kit/display/TokenMeter.tsx +89 -0
  168. package/src/session-kit/display/ToolStrip.tsx +278 -0
  169. package/src/session-kit/display/TypingDots.tsx +24 -0
  170. package/src/session-kit/index.ts +44 -0
  171. package/src/session-kit/lib/enums.ts +66 -0
  172. package/src/session-kit/lib/portal-accent.ts +30 -0
  173. package/src/session-kit/lib/status-dot.ts +68 -0
  174. package/src/session-kit/primitives/Avatar.tsx +44 -0
  175. package/src/session-kit/primitives/PortalGlyph.tsx +51 -0
  176. package/src/session-kit/primitives/RiskPill.tsx +95 -0
  177. package/src/session-kit/primitives/ScopeDot.tsx +47 -0
  178. package/src/session-kit/primitives/Segmented.tsx +71 -0
@@ -0,0 +1,336 @@
1
+ /**
2
+ * The GENERIC approval frame (HANDOFF §6) — identical platform-wide.
3
+ *
4
+ * Header: "Approval required" + RiskPill (reused from the kit) + mono
5
+ * capability ref + serif title + summary. Then the biome-supplied
6
+ * `ScopedBody`. Then obligations chips (closed `PolicyObligationKind`
7
+ * taxonomy), approver role, and quorum dots. Actions: Deny / Allow once /
8
+ * Approve — low risk gets "Approve & remember"; high & critical are
9
+ * explicit Approve only (no allow-once-remember). The accent rail uses the
10
+ * SAME risk→colour mapping as the pill.
11
+ *
12
+ * Resolved state collapses to a compact confirmation row; a
13
+ * quorum-approved request reads "approved · waiting on N more approver".
14
+ *
15
+ * Controlled OR uncontrolled: pass `decision` to control the resolved
16
+ * state from the host (the real flow), or omit it and the card tracks its
17
+ * own optimistic resolved state. `onResolve(decision, request)` always
18
+ * fires.
19
+ */
20
+ import { useState, type ReactElement } from 'react';
21
+
22
+ import {
23
+ ApprovalDecision,
24
+ isExplicitApprovalTier,
25
+ isQuorum,
26
+ type ApprovalRequest,
27
+ } from './approval-model';
28
+ import { ApprovalButton } from './ApprovalButton';
29
+ import { Obligation, QuorumDots } from './Obligation';
30
+ import { RISK_ACCENT } from './risk-accent';
31
+ import { ScopedBody } from './ScopedBody';
32
+ import { RiskPill } from '../primitives/RiskPill';
33
+
34
+ import type { ApprovalIcons } from './approval-icons';
35
+
36
+ export interface ApprovalCardProps {
37
+ readonly request: ApprovalRequest;
38
+ readonly icons: ApprovalIcons;
39
+ /** Resolution callback. Fires before/after the host updates `decision`. */
40
+ readonly onResolve: (
41
+ decision: ApprovalDecision,
42
+ request: ApprovalRequest,
43
+ ) => void;
44
+ /**
45
+ * Controlled resolved state. Omit for uncontrolled (the card collapses
46
+ * optimistically on click). The real host flow passes the server-known
47
+ * decision so the collapse reflects committed state.
48
+ */
49
+ readonly decision?: ApprovalDecision | null;
50
+ /** Portal/brand accent for the (non-critical) primary approve button. */
51
+ readonly accent?: string;
52
+ }
53
+
54
+ export function ApprovalCard({
55
+ request,
56
+ icons,
57
+ onResolve,
58
+ decision,
59
+ accent = 'hsl(var(--primary))',
60
+ }: ApprovalCardProps): ReactElement {
61
+ const [localDecision, setLocalDecision] = useState<ApprovalDecision | null>(
62
+ null,
63
+ );
64
+ const resolved = decision ?? localDecision;
65
+ const r = RISK_ACCENT[request.riskTier];
66
+ const explicit = isExplicitApprovalTier(request.riskTier);
67
+ const quorum = isQuorum(request);
68
+ const approveLabel = resolveApproveLabel(request, quorum, explicit);
69
+
70
+ function resolve(d: ApprovalDecision): void {
71
+ if (decision === undefined) setLocalDecision(d);
72
+ onResolve(d, request);
73
+ }
74
+
75
+ if (resolved) {
76
+ const approved = resolved === ApprovalDecision.Approved;
77
+ const remaining =
78
+ (request.requireApproverCount ?? 1) - ((request.approvalsIn ?? 0) + 1);
79
+ const approvedLabel =
80
+ quorum && remaining > 0
81
+ ? `approved · waiting on ${remaining} more approver${remaining === 1 ? '' : 's'}`
82
+ : 'approved, capability resumed';
83
+ return (
84
+ <div
85
+ style={{
86
+ marginTop: 9,
87
+ display: 'flex',
88
+ alignItems: 'center',
89
+ gap: 10,
90
+ padding: '11px 14px',
91
+ borderRadius: 11,
92
+ border: `1px solid ${
93
+ approved ? 'hsl(var(--success) / 0.3)' : 'hsl(var(--rule))'
94
+ }`,
95
+ background: approved
96
+ ? 'hsl(var(--success) / 0.06)'
97
+ : 'hsl(var(--muted))',
98
+ }}
99
+ >
100
+ <span
101
+ style={{
102
+ color: approved ? 'hsl(var(--success))' : 'hsl(var(--ink-3))',
103
+ display: 'inline-flex',
104
+ }}
105
+ >
106
+ {approved ? icons.approved(17) : icons.denied(17)}
107
+ </span>
108
+ <span style={{ fontSize: 13, color: 'hsl(var(--ink-2))' }}>
109
+ <strong style={{ color: 'hsl(var(--ink))', fontWeight: 600 }}>
110
+ {request.title}
111
+ </strong>{' '}
112
+ — {approved ? approvedLabel : 'denied'}
113
+ </span>
114
+ </div>
115
+ );
116
+ }
117
+
118
+ return (
119
+ <div
120
+ style={{
121
+ marginTop: 9,
122
+ borderRadius: 12,
123
+ overflow: 'hidden',
124
+ border: `1px solid ${explicit ? r.bd : 'hsl(var(--rule-2))'}`,
125
+ boxShadow: 'var(--shadow-2)',
126
+ background: 'hsl(var(--paper-elev))',
127
+ }}
128
+ >
129
+ <div style={{ display: 'flex' }}>
130
+ {/* accent rail — same risk→colour mapping as the pill */}
131
+ <div
132
+ style={{
133
+ width: 3,
134
+ flexShrink: 0,
135
+ background: explicit ? r.fg : 'hsl(var(--rule-2))',
136
+ }}
137
+ />
138
+ <div style={{ flex: 1, minWidth: 0 }}>
139
+ {/* header — generic */}
140
+ <div
141
+ style={{
142
+ padding: '11px 14px 9px',
143
+ borderBottom: '1px solid hsl(var(--rule))',
144
+ }}
145
+ >
146
+ <div
147
+ style={{
148
+ display: 'flex',
149
+ alignItems: 'center',
150
+ gap: 8,
151
+ marginBottom: 6,
152
+ }}
153
+ >
154
+ <span
155
+ style={{
156
+ color: explicit ? r.fg : 'hsl(var(--primary))',
157
+ display: 'inline-flex',
158
+ }}
159
+ >
160
+ {icons.shield(16)}
161
+ </span>
162
+ <span
163
+ className="cap"
164
+ style={{ color: 'hsl(var(--ink-3))', letterSpacing: '0.1em' }}
165
+ >
166
+ Approval required
167
+ </span>
168
+ <RiskPill tier={request.riskTier} size="xs" />
169
+ <span style={{ flex: 1 }} />
170
+ <span
171
+ className="mono"
172
+ style={{
173
+ fontSize: 10.5,
174
+ color: 'hsl(var(--ink-4))',
175
+ display: 'inline-flex',
176
+ alignItems: 'center',
177
+ gap: 4,
178
+ overflow: 'hidden',
179
+ textOverflow: 'ellipsis',
180
+ whiteSpace: 'nowrap',
181
+ maxWidth: '50%',
182
+ }}
183
+ >
184
+ {icons.capability(11)}
185
+ {request.capability}
186
+ </span>
187
+ </div>
188
+ <div
189
+ className="serif"
190
+ style={{
191
+ fontSize: 15,
192
+ fontWeight: 600,
193
+ color: 'hsl(var(--ink))',
194
+ lineHeight: 1.3,
195
+ }}
196
+ >
197
+ {request.title}
198
+ </div>
199
+ <div
200
+ style={{
201
+ fontSize: 12.5,
202
+ color: 'hsl(var(--ink-3))',
203
+ marginTop: 2,
204
+ }}
205
+ >
206
+ {request.summary}
207
+ </div>
208
+ </div>
209
+
210
+ {/* SCOPED biome body */}
211
+ <div
212
+ style={{ padding: '10px 14px', background: 'hsl(var(--paper) / 0.5)' }}
213
+ >
214
+ <div
215
+ style={{
216
+ display: 'flex',
217
+ alignItems: 'center',
218
+ gap: 6,
219
+ marginBottom: 8,
220
+ }}
221
+ >
222
+ <span
223
+ className="cap"
224
+ style={{ fontSize: 9.5, color: 'hsl(var(--ink-4))' }}
225
+ >
226
+ Details from {request.biomeName}
227
+ </span>
228
+ <span
229
+ style={{ flex: 1, height: 1, background: 'hsl(var(--rule))' }}
230
+ />
231
+ </div>
232
+ <ScopedBody scope={request.scope} renderIcon={icons.scope} />
233
+ </div>
234
+
235
+ {/* obligations + approver requirement — generic */}
236
+ <div
237
+ style={{
238
+ padding: '10px 14px',
239
+ borderTop: '1px solid hsl(var(--rule))',
240
+ display: 'flex',
241
+ flexDirection: 'column',
242
+ gap: 9,
243
+ }}
244
+ >
245
+ {request.obligations && request.obligations.length > 0 && (
246
+ <div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
247
+ {request.obligations.map((o) => (
248
+ <Obligation
249
+ key={o.kind}
250
+ obligation={o}
251
+ renderIcon={icons.obligation}
252
+ />
253
+ ))}
254
+ </div>
255
+ )}
256
+ <div
257
+ style={{
258
+ display: 'flex',
259
+ alignItems: 'center',
260
+ gap: 10,
261
+ flexWrap: 'wrap',
262
+ }}
263
+ >
264
+ {request.requireRole && (
265
+ <span
266
+ style={{
267
+ display: 'inline-flex',
268
+ alignItems: 'center',
269
+ gap: 5,
270
+ fontSize: 12,
271
+ color: 'hsl(var(--ink-3))',
272
+ }}
273
+ >
274
+ {icons.user(13)}approver role{' '}
275
+ <span className="mono" style={{ color: 'hsl(var(--ink-2))' }}>
276
+ {request.requireRole}
277
+ </span>
278
+ </span>
279
+ )}
280
+ {quorum && (
281
+ <QuorumDots
282
+ have={request.approvalsIn ?? 0}
283
+ need={request.requireApproverCount ?? 1}
284
+ usersIcon={icons.users(13)}
285
+ />
286
+ )}
287
+ <span style={{ flex: 1 }} />
288
+ <ApprovalButton
289
+ variant="ghost"
290
+ size="sm"
291
+ onClick={() => resolve(ApprovalDecision.Denied)}
292
+ >
293
+ Deny
294
+ </ApprovalButton>
295
+ {!explicit && (
296
+ <ApprovalButton
297
+ variant="soft"
298
+ size="sm"
299
+ icon={icons.check(13)}
300
+ onClick={() => resolve(ApprovalDecision.Approved)}
301
+ >
302
+ Allow once
303
+ </ApprovalButton>
304
+ )}
305
+ <ApprovalButton
306
+ variant="primary"
307
+ size="sm"
308
+ icon={icons.shield(13)}
309
+ accent={explicit ? r.fg : accent}
310
+ onClick={() => resolve(ApprovalDecision.Approved)}
311
+ >
312
+ {approveLabel}
313
+ </ApprovalButton>
314
+ </div>
315
+ </div>
316
+ </div>
317
+ </div>
318
+ </div>
319
+ );
320
+ }
321
+
322
+ /**
323
+ * The primary-button label: a quorum step ("Approve (1 of 2)"), a plain
324
+ * "Approve" for explicit (high/critical) tiers, or "Approve & remember"
325
+ * for low/medium where allow-once-remember is offered.
326
+ */
327
+ function resolveApproveLabel(
328
+ request: ApprovalRequest,
329
+ quorum: boolean,
330
+ explicit: boolean,
331
+ ): string {
332
+ if (quorum) {
333
+ return `Approve (${(request.approvalsIn ?? 0) + 1} of ${request.requireApproverCount})`;
334
+ }
335
+ return explicit ? 'Approve' : 'Approve & remember';
336
+ }
@@ -0,0 +1,327 @@
1
+ /**
2
+ * Approvals Center body (HANDOFF §6) — the cross-portal batch-review list.
3
+ *
4
+ * This is the GENERIC, prop-driven body (toolbar + rows + empty state). It
5
+ * does NOT own the drawer chrome: the host wraps it in its own `Sheet`
6
+ * (the host carries the overlay/focus-trap/portal primitive). That keeps
7
+ * the kit free of any host UI dependency while the batching/selection
8
+ * logic stays generic and reusable.
9
+ *
10
+ * Batches approvals ACROSS all portals/sessions: each row carries its
11
+ * portal + biome + quorum and an "Open session" affordance. "Select
12
+ * low-risk" selects every low/medium item; bulk "Approve N" resolves the
13
+ * selection. The row model (`ApprovalCenterItem`) already carries
14
+ * portalId/name/accent so Pillar 4 can group by portal without a reshape.
15
+ */
16
+ import { CapabilityRiskTier } from '@xemahq/kernel-contracts/capability';
17
+ import { useCallback, useState, type ReactElement } from 'react';
18
+
19
+ import { ApprovalDecision, type ApprovalCenterItem } from './approval-model';
20
+ import { ApprovalButton } from './ApprovalButton';
21
+ import { RiskPill } from '../primitives/RiskPill';
22
+
23
+ import type { ApprovalIcons } from './approval-icons';
24
+
25
+ export interface ApprovalsCenterProps {
26
+ readonly items: readonly ApprovalCenterItem[];
27
+ readonly icons: Pick<ApprovalIcons, 'check' | 'approved'>;
28
+ /** Resolve a single item (also used by the bulk action per-item). */
29
+ readonly onResolve: (
30
+ decision: ApprovalDecision,
31
+ item: ApprovalCenterItem,
32
+ ) => void;
33
+ /** "Open session" — host navigates to the suspended capability's session. */
34
+ readonly onOpen?: (item: ApprovalCenterItem) => void;
35
+ }
36
+
37
+ const LOW_RISK: ReadonlySet<CapabilityRiskTier> = new Set([
38
+ CapabilityRiskTier.Low,
39
+ CapabilityRiskTier.Medium,
40
+ ]);
41
+
42
+ export function ApprovalsCenter({
43
+ items,
44
+ icons,
45
+ onResolve,
46
+ onOpen,
47
+ }: ApprovalsCenterProps): ReactElement {
48
+ const [selected, setSelected] = useState<ReadonlySet<string>>(
49
+ () => new Set(),
50
+ );
51
+
52
+ const toggle = useCallback((id: string) => {
53
+ setSelected((prev) => {
54
+ const next = new Set(prev);
55
+ if (next.has(id)) next.delete(id);
56
+ else next.add(id);
57
+ return next;
58
+ });
59
+ }, []);
60
+
61
+ const selectLowRisk = useCallback(() => {
62
+ setSelected(
63
+ new Set(
64
+ items
65
+ .filter((it) => LOW_RISK.has(it.request.riskTier))
66
+ .map((it) => it.id),
67
+ ),
68
+ );
69
+ }, [items]);
70
+
71
+ const approveSelected = useCallback(() => {
72
+ for (const id of selected) {
73
+ const it = items.find((x) => x.id === id);
74
+ if (it) onResolve(ApprovalDecision.Approved, it);
75
+ }
76
+ setSelected(new Set());
77
+ }, [selected, items, onResolve]);
78
+
79
+ return (
80
+ <div
81
+ style={{
82
+ display: 'flex',
83
+ flexDirection: 'column',
84
+ height: '100%',
85
+ minHeight: 0,
86
+ }}
87
+ >
88
+ {/* selection toolbar */}
89
+ <div
90
+ style={{
91
+ padding: '9px 16px',
92
+ borderBottom: '1px solid hsl(var(--rule))',
93
+ display: 'flex',
94
+ alignItems: 'center',
95
+ gap: 9,
96
+ }}
97
+ >
98
+ <span style={{ fontSize: 12, color: 'hsl(var(--ink-3))' }}>
99
+ {selected.size} selected
100
+ </span>
101
+ <span style={{ flex: 1 }} />
102
+ <ApprovalButton
103
+ variant="ghost"
104
+ size="xs"
105
+ onClick={selectLowRisk}
106
+ disabled={items.length === 0}
107
+ >
108
+ Select low-risk
109
+ </ApprovalButton>
110
+ <ApprovalButton
111
+ variant="primary"
112
+ size="xs"
113
+ icon={icons.check(12)}
114
+ disabled={selected.size === 0}
115
+ onClick={approveSelected}
116
+ >
117
+ Approve {selected.size || ''}
118
+ </ApprovalButton>
119
+ </div>
120
+
121
+ {/* rows */}
122
+ <div
123
+ style={{
124
+ flex: 1,
125
+ minHeight: 0,
126
+ overflowY: 'auto',
127
+ padding: 12,
128
+ display: 'flex',
129
+ flexDirection: 'column',
130
+ gap: 9,
131
+ }}
132
+ >
133
+ {items.length === 0 ? (
134
+ <div
135
+ style={{
136
+ textAlign: 'center',
137
+ padding: '48px 0',
138
+ color: 'hsl(var(--ink-3))',
139
+ }}
140
+ >
141
+ <span
142
+ style={{
143
+ color: 'hsl(var(--success))',
144
+ display: 'inline-flex',
145
+ marginBottom: 10,
146
+ }}
147
+ >
148
+ {icons.approved(26)}
149
+ </span>
150
+ <div style={{ fontSize: 13.5 }}>Nothing waiting on you.</div>
151
+ </div>
152
+ ) : (
153
+ items.map((it) => (
154
+ <ApprovalRow
155
+ key={it.id}
156
+ item={it}
157
+ checked={selected.has(it.id)}
158
+ onToggle={() => toggle(it.id)}
159
+ onResolve={onResolve}
160
+ {...(onOpen ? { onOpen } : {})}
161
+ icons={icons}
162
+ />
163
+ ))
164
+ )}
165
+ </div>
166
+ </div>
167
+ );
168
+ }
169
+
170
+ interface ApprovalRowProps {
171
+ readonly item: ApprovalCenterItem;
172
+ readonly checked: boolean;
173
+ readonly onToggle: () => void;
174
+ readonly onResolve: (
175
+ decision: ApprovalDecision,
176
+ item: ApprovalCenterItem,
177
+ ) => void;
178
+ readonly onOpen?: (item: ApprovalCenterItem) => void;
179
+ readonly icons: Pick<ApprovalIcons, 'check'>;
180
+ }
181
+
182
+ function ApprovalRow({
183
+ item,
184
+ checked,
185
+ onToggle,
186
+ onResolve,
187
+ onOpen,
188
+ icons,
189
+ }: ApprovalRowProps): ReactElement {
190
+ const { request } = item;
191
+ const quorum = (request.requireApproverCount ?? 1) > 1;
192
+ return (
193
+ <div
194
+ style={{
195
+ border: `1px solid ${
196
+ checked ? 'hsl(var(--primary) / 0.4)' : 'hsl(var(--rule))'
197
+ }`,
198
+ borderRadius: 10,
199
+ background: 'hsl(var(--paper-elev))',
200
+ boxShadow: checked
201
+ ? '0 0 0 1px hsl(var(--primary) / 0.2)'
202
+ : 'var(--shadow-2)',
203
+ overflow: 'hidden',
204
+ }}
205
+ >
206
+ <div style={{ display: 'flex', gap: 10, padding: '11px 12px' }}>
207
+ <button
208
+ type="button"
209
+ onClick={onToggle}
210
+ aria-pressed={checked}
211
+ aria-label={checked ? 'Deselect' : 'Select'}
212
+ style={{
213
+ width: 18,
214
+ height: 18,
215
+ borderRadius: 5,
216
+ marginTop: 1,
217
+ flexShrink: 0,
218
+ display: 'grid',
219
+ placeItems: 'center',
220
+ border: checked ? 'none' : '1.5px solid hsl(var(--rule-2))',
221
+ background: checked ? 'hsl(var(--primary))' : 'transparent',
222
+ color: '#fff',
223
+ cursor: 'pointer',
224
+ }}
225
+ >
226
+ {checked && icons.check(12)}
227
+ </button>
228
+ <div style={{ flex: 1, minWidth: 0 }}>
229
+ <div
230
+ style={{
231
+ display: 'flex',
232
+ alignItems: 'center',
233
+ gap: 7,
234
+ marginBottom: 3,
235
+ }}
236
+ >
237
+ <span
238
+ style={{
239
+ fontSize: 13.5,
240
+ fontWeight: 600,
241
+ color: 'hsl(var(--ink))',
242
+ overflow: 'hidden',
243
+ textOverflow: 'ellipsis',
244
+ whiteSpace: 'nowrap',
245
+ minWidth: 0,
246
+ }}
247
+ >
248
+ {request.title}
249
+ </span>
250
+ <RiskPill tier={request.riskTier} size="xs" />
251
+ </div>
252
+ <div
253
+ style={{
254
+ display: 'flex',
255
+ alignItems: 'center',
256
+ gap: 7,
257
+ fontSize: 11.5,
258
+ color: 'hsl(var(--ink-3))',
259
+ flexWrap: 'wrap',
260
+ }}
261
+ >
262
+ {(item.portalName || item.portalAccentVar) && (
263
+ <span
264
+ style={{
265
+ display: 'inline-flex',
266
+ alignItems: 'center',
267
+ gap: 4,
268
+ }}
269
+ >
270
+ {item.portalAccentVar && (
271
+ <span
272
+ style={{
273
+ width: 7,
274
+ height: 7,
275
+ borderRadius: 2,
276
+ background: `hsl(${item.portalAccentVar})`,
277
+ }}
278
+ />
279
+ )}
280
+ {item.portalName}
281
+ </span>
282
+ )}
283
+ {item.portalName && <span>·</span>}
284
+ <span>{request.biomeName}</span>
285
+ {quorum && (
286
+ <>
287
+ <span>·</span>
288
+ <span className="mono">
289
+ {request.approvalsIn ?? 0}/{request.requireApproverCount}
290
+ </span>
291
+ </>
292
+ )}
293
+ </div>
294
+ </div>
295
+ </div>
296
+ <div
297
+ style={{
298
+ display: 'flex',
299
+ gap: 7,
300
+ padding: '0 12px 11px',
301
+ justifyContent: 'flex-end',
302
+ }}
303
+ >
304
+ {onOpen && (
305
+ <ApprovalButton variant="ghost" size="xs" onClick={() => onOpen(item)}>
306
+ Open session
307
+ </ApprovalButton>
308
+ )}
309
+ <ApprovalButton
310
+ variant="ghost"
311
+ size="xs"
312
+ onClick={() => onResolve(ApprovalDecision.Denied, item)}
313
+ >
314
+ Deny
315
+ </ApprovalButton>
316
+ <ApprovalButton
317
+ variant="soft"
318
+ size="xs"
319
+ icon={icons.check(12)}
320
+ onClick={() => onResolve(ApprovalDecision.Approved, item)}
321
+ >
322
+ Approve
323
+ </ApprovalButton>
324
+ </div>
325
+ </div>
326
+ );
327
+ }