@xemahq/ui-kernel 0.1.6 → 0.1.8

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 (197) hide show
  1. package/dist/lib/biome-host/agent-validation.d.ts +22 -0
  2. package/dist/lib/biome-host/agent-validation.d.ts.map +1 -0
  3. package/dist/lib/biome-host/agent-validation.js +127 -0
  4. package/dist/lib/biome-host/agent-validation.js.map +1 -0
  5. package/dist/lib/biome-host/create-biome-orval-config.d.ts +14 -0
  6. package/dist/lib/biome-host/create-biome-orval-config.d.ts.map +1 -0
  7. package/dist/lib/biome-host/create-biome-orval-config.js +22 -0
  8. package/dist/lib/biome-host/create-biome-orval-config.js.map +1 -0
  9. package/dist/lib/biome-host/frontend-biome.d.ts +3 -1
  10. package/dist/lib/biome-host/frontend-biome.d.ts.map +1 -1
  11. package/dist/lib/biome-host/host-bridge.d.ts +2 -0
  12. package/dist/lib/biome-host/host-bridge.d.ts.map +1 -1
  13. package/dist/lib/biome-host/host-bridge.js.map +1 -1
  14. package/dist/lib/biome-host/host-sources.d.ts +2 -0
  15. package/dist/lib/biome-host/host-sources.d.ts.map +1 -1
  16. package/dist/lib/biome-host/index.d.ts +2 -1
  17. package/dist/lib/biome-host/index.d.ts.map +1 -1
  18. package/dist/lib/biome-host/index.js +2 -1
  19. package/dist/lib/biome-host/index.js.map +1 -1
  20. package/dist/registry/index.d.ts +1 -1
  21. package/dist/registry/index.d.ts.map +1 -1
  22. package/dist/registry/index.js +1 -1
  23. package/dist/registry/index.js.map +1 -1
  24. package/dist/registry/lib/agent-validation-host.d.ts +3 -0
  25. package/dist/registry/lib/agent-validation-host.d.ts.map +1 -0
  26. package/dist/registry/lib/agent-validation-host.js +10 -0
  27. package/dist/registry/lib/agent-validation-host.js.map +1 -0
  28. package/dist/session-kit/approvals/ApprovalButton.d.ts +14 -0
  29. package/dist/session-kit/approvals/ApprovalButton.d.ts.map +1 -0
  30. package/dist/session-kit/approvals/ApprovalButton.js +45 -0
  31. package/dist/session-kit/approvals/ApprovalButton.js.map +1 -0
  32. package/dist/session-kit/approvals/ApprovalCard.d.ts +12 -0
  33. package/dist/session-kit/approvals/ApprovalCard.d.ts.map +1 -0
  34. package/dist/session-kit/approvals/ApprovalCard.js +117 -0
  35. package/dist/session-kit/approvals/ApprovalCard.js.map +1 -0
  36. package/dist/session-kit/approvals/ApprovalsCenter.d.ts +11 -0
  37. package/dist/session-kit/approvals/ApprovalsCenter.d.ts.map +1 -0
  38. package/dist/session-kit/approvals/ApprovalsCenter.js +127 -0
  39. package/dist/session-kit/approvals/ApprovalsCenter.js.map +1 -0
  40. package/dist/session-kit/approvals/CapabilityApprovalStickyBar.d.ts +12 -0
  41. package/dist/session-kit/approvals/CapabilityApprovalStickyBar.d.ts.map +1 -0
  42. package/dist/session-kit/approvals/CapabilityApprovalStickyBar.js +36 -0
  43. package/dist/session-kit/approvals/CapabilityApprovalStickyBar.js.map +1 -0
  44. package/dist/session-kit/approvals/Obligation.d.ts +15 -0
  45. package/dist/session-kit/approvals/Obligation.d.ts.map +1 -0
  46. package/dist/session-kit/approvals/Obligation.js +42 -0
  47. package/dist/session-kit/approvals/Obligation.js.map +1 -0
  48. package/dist/session-kit/approvals/ScopedBody.d.ts +9 -0
  49. package/dist/session-kit/approvals/ScopedBody.d.ts.map +1 -0
  50. package/dist/session-kit/approvals/ScopedBody.js +145 -0
  51. package/dist/session-kit/approvals/ScopedBody.js.map +1 -0
  52. package/dist/session-kit/approvals/approval-icons.d.ts +15 -0
  53. package/dist/session-kit/approvals/approval-icons.d.ts.map +1 -0
  54. package/dist/session-kit/approvals/approval-icons.js +3 -0
  55. package/dist/session-kit/approvals/approval-icons.js.map +1 -0
  56. package/dist/session-kit/approvals/approval-model.d.ts +83 -0
  57. package/dist/session-kit/approvals/approval-model.d.ts.map +1 -0
  58. package/dist/session-kit/approvals/approval-model.js +25 -0
  59. package/dist/session-kit/approvals/approval-model.js.map +1 -0
  60. package/dist/session-kit/approvals/index.d.ts +12 -0
  61. package/dist/session-kit/approvals/index.d.ts.map +1 -0
  62. package/dist/session-kit/approvals/index.js +28 -0
  63. package/dist/session-kit/approvals/index.js.map +1 -0
  64. package/dist/session-kit/approvals/obligation-display.d.ts +17 -0
  65. package/dist/session-kit/approvals/obligation-display.d.ts.map +1 -0
  66. package/dist/session-kit/approvals/obligation-display.js +58 -0
  67. package/dist/session-kit/approvals/obligation-display.js.map +1 -0
  68. package/dist/session-kit/approvals/risk-accent.d.ts +8 -0
  69. package/dist/session-kit/approvals/risk-accent.d.ts.map +1 -0
  70. package/dist/session-kit/approvals/risk-accent.js +28 -0
  71. package/dist/session-kit/approvals/risk-accent.js.map +1 -0
  72. package/dist/session-kit/approvals/scope-icons.d.ts +12 -0
  73. package/dist/session-kit/approvals/scope-icons.d.ts.map +1 -0
  74. package/dist/session-kit/approvals/scope-icons.js +14 -0
  75. package/dist/session-kit/approvals/scope-icons.js.map +1 -0
  76. package/dist/session-kit/combobox/Combobox.d.ts +46 -0
  77. package/dist/session-kit/combobox/Combobox.d.ts.map +1 -0
  78. package/dist/session-kit/combobox/Combobox.js +113 -0
  79. package/dist/session-kit/combobox/Combobox.js.map +1 -0
  80. package/dist/session-kit/combobox/use-click-outside.d.ts +3 -0
  81. package/dist/session-kit/combobox/use-click-outside.d.ts.map +1 -0
  82. package/dist/session-kit/combobox/use-click-outside.js +18 -0
  83. package/dist/session-kit/combobox/use-click-outside.js.map +1 -0
  84. package/dist/session-kit/display/ContextHeader.d.ts +27 -0
  85. package/dist/session-kit/display/ContextHeader.d.ts.map +1 -0
  86. package/dist/session-kit/display/ContextHeader.js +47 -0
  87. package/dist/session-kit/display/ContextHeader.js.map +1 -0
  88. package/dist/session-kit/display/FileDiffCard.d.ts +18 -0
  89. package/dist/session-kit/display/FileDiffCard.d.ts.map +1 -0
  90. package/dist/session-kit/display/FileDiffCard.js +58 -0
  91. package/dist/session-kit/display/FileDiffCard.js.map +1 -0
  92. package/dist/session-kit/display/MD.d.ts +7 -0
  93. package/dist/session-kit/display/MD.d.ts.map +1 -0
  94. package/dist/session-kit/display/MD.js +89 -0
  95. package/dist/session-kit/display/MD.js.map +1 -0
  96. package/dist/session-kit/display/MessageTurn.d.ts +21 -0
  97. package/dist/session-kit/display/MessageTurn.d.ts.map +1 -0
  98. package/dist/session-kit/display/MessageTurn.js +62 -0
  99. package/dist/session-kit/display/MessageTurn.js.map +1 -0
  100. package/dist/session-kit/display/ThinkingPanel.d.ts +12 -0
  101. package/dist/session-kit/display/ThinkingPanel.d.ts.map +1 -0
  102. package/dist/session-kit/display/ThinkingPanel.js +30 -0
  103. package/dist/session-kit/display/ThinkingPanel.js.map +1 -0
  104. package/dist/session-kit/display/TodoChecklist.d.ts +17 -0
  105. package/dist/session-kit/display/TodoChecklist.d.ts.map +1 -0
  106. package/dist/session-kit/display/TodoChecklist.js +50 -0
  107. package/dist/session-kit/display/TodoChecklist.js.map +1 -0
  108. package/dist/session-kit/display/TokenMeter.d.ts +15 -0
  109. package/dist/session-kit/display/TokenMeter.d.ts.map +1 -0
  110. package/dist/session-kit/display/TokenMeter.js +35 -0
  111. package/dist/session-kit/display/TokenMeter.js.map +1 -0
  112. package/dist/session-kit/display/ToolStrip.d.ts +31 -0
  113. package/dist/session-kit/display/ToolStrip.d.ts.map +1 -0
  114. package/dist/session-kit/display/ToolStrip.js +99 -0
  115. package/dist/session-kit/display/ToolStrip.js.map +1 -0
  116. package/dist/session-kit/display/TypingDots.d.ts +3 -0
  117. package/dist/session-kit/display/TypingDots.d.ts.map +1 -0
  118. package/dist/session-kit/display/TypingDots.js +14 -0
  119. package/dist/session-kit/display/TypingDots.js.map +1 -0
  120. package/dist/session-kit/index.d.ts +21 -0
  121. package/dist/session-kit/index.d.ts.map +1 -0
  122. package/dist/session-kit/index.js +37 -0
  123. package/dist/session-kit/index.js.map +1 -0
  124. package/dist/session-kit/lib/enums.d.ts +34 -0
  125. package/dist/session-kit/lib/enums.d.ts.map +1 -0
  126. package/dist/session-kit/lib/enums.js +44 -0
  127. package/dist/session-kit/lib/enums.js.map +1 -0
  128. package/dist/session-kit/lib/portal-accent.d.ts +3 -0
  129. package/dist/session-kit/lib/portal-accent.d.ts.map +1 -0
  130. package/dist/session-kit/lib/portal-accent.js +9 -0
  131. package/dist/session-kit/lib/portal-accent.js.map +1 -0
  132. package/dist/session-kit/lib/status-dot.d.ts +10 -0
  133. package/dist/session-kit/lib/status-dot.d.ts.map +1 -0
  134. package/dist/session-kit/lib/status-dot.js +43 -0
  135. package/dist/session-kit/lib/status-dot.js.map +1 -0
  136. package/dist/session-kit/primitives/Avatar.d.ts +10 -0
  137. package/dist/session-kit/primitives/Avatar.d.ts.map +1 -0
  138. package/dist/session-kit/primitives/Avatar.js +21 -0
  139. package/dist/session-kit/primitives/Avatar.js.map +1 -0
  140. package/dist/session-kit/primitives/PortalGlyph.d.ts +12 -0
  141. package/dist/session-kit/primitives/PortalGlyph.d.ts.map +1 -0
  142. package/dist/session-kit/primitives/PortalGlyph.js +21 -0
  143. package/dist/session-kit/primitives/PortalGlyph.js.map +1 -0
  144. package/dist/session-kit/primitives/RiskPill.d.ts +12 -0
  145. package/dist/session-kit/primitives/RiskPill.d.ts.map +1 -0
  146. package/dist/session-kit/primitives/RiskPill.js +53 -0
  147. package/dist/session-kit/primitives/RiskPill.js.map +1 -0
  148. package/dist/session-kit/primitives/ScopeDot.d.ts +9 -0
  149. package/dist/session-kit/primitives/ScopeDot.d.ts.map +1 -0
  150. package/dist/session-kit/primitives/ScopeDot.js +23 -0
  151. package/dist/session-kit/primitives/ScopeDot.js.map +1 -0
  152. package/dist/session-kit/primitives/Segmented.d.ts +16 -0
  153. package/dist/session-kit/primitives/Segmented.d.ts.map +1 -0
  154. package/dist/session-kit/primitives/Segmented.js +31 -0
  155. package/dist/session-kit/primitives/Segmented.js.map +1 -0
  156. package/package.json +2 -2
  157. package/src/lib/biome-host/create-biome-orval-config.ts +76 -0
  158. package/src/lib/biome-host/frontend-biome.ts +22 -2
  159. package/src/lib/biome-host/host-bridge.ts +22 -0
  160. package/src/lib/biome-host/host-sources.ts +13 -0
  161. package/src/lib/biome-host/index.ts +2 -1
  162. package/src/registry/index.ts +1 -1
  163. package/src/registry/lib/extension-points.ts +1 -1
  164. package/src/session-kit/approvals/ApprovalButton.tsx +89 -0
  165. package/src/session-kit/approvals/ApprovalCard.tsx +336 -0
  166. package/src/session-kit/approvals/ApprovalsCenter.tsx +327 -0
  167. package/src/session-kit/approvals/CapabilityApprovalStickyBar.tsx +118 -0
  168. package/src/session-kit/approvals/Obligation.tsx +111 -0
  169. package/src/session-kit/approvals/ScopedBody.tsx +392 -0
  170. package/src/session-kit/approvals/approval-icons.ts +31 -0
  171. package/src/session-kit/approvals/approval-model.ts +205 -0
  172. package/src/session-kit/approvals/index.ts +22 -0
  173. package/src/session-kit/approvals/obligation-display.ts +100 -0
  174. package/src/session-kit/approvals/risk-accent.ts +47 -0
  175. package/src/session-kit/approvals/scope-icons.ts +19 -0
  176. package/src/session-kit/combobox/Combobox.tsx +327 -0
  177. package/src/session-kit/combobox/use-click-outside.ts +21 -0
  178. package/src/session-kit/display/ContextHeader.tsx +148 -0
  179. package/src/session-kit/display/FileDiffCard.tsx +140 -0
  180. package/src/session-kit/display/MD.tsx +153 -0
  181. package/src/session-kit/display/MessageTurn.tsx +157 -0
  182. package/src/session-kit/display/ThinkingPanel.tsx +78 -0
  183. package/src/session-kit/display/TodoChecklist.tsx +120 -0
  184. package/src/session-kit/display/TokenMeter.tsx +89 -0
  185. package/src/session-kit/display/ToolStrip.tsx +278 -0
  186. package/src/session-kit/display/TypingDots.tsx +24 -0
  187. package/src/session-kit/index.ts +44 -0
  188. package/src/session-kit/lib/enums.ts +66 -0
  189. package/src/session-kit/lib/portal-accent.ts +30 -0
  190. package/src/session-kit/lib/status-dot.ts +68 -0
  191. package/src/session-kit/primitives/Avatar.tsx +44 -0
  192. package/src/session-kit/primitives/PortalGlyph.tsx +51 -0
  193. package/src/session-kit/primitives/RiskPill.tsx +95 -0
  194. package/src/session-kit/primitives/ScopeDot.tsx +47 -0
  195. package/src/session-kit/primitives/Segmented.tsx +71 -0
  196. /package/src/lib/biome-host/{composition-validation.ts → agent-validation.ts} +0 -0
  197. /package/src/registry/lib/{composition-validation-host.ts → agent-validation-host.ts} +0 -0
@@ -58,6 +58,28 @@ export interface HostBridgeAuth {
58
58
  getProjectId(): string | null;
59
59
  /** Returns the current actor's user id (subject), or null. */
60
60
  getUserId(): string | null;
61
+ /**
62
+ * Proactively refresh the actor token if it is within `minValiditySeconds`
63
+ * of expiry, then return a guaranteed-valid bearer. The refresh + any
64
+ * redirect-on-failure stays HOST-owned (the host owns the auth client and
65
+ * the single-flight login guard) — biomes only reach it through this method
66
+ * so an Orval client's `getAuthToken` can be wired against the SAME refresh
67
+ * path the host uses, instead of each biome re-implementing token freshness.
68
+ *
69
+ * @param minValiditySeconds Refresh when fewer than this many seconds of
70
+ * validity remain. Defaults to a host-chosen safe window.
71
+ * @returns A valid bearer token string. Implementations that cannot produce
72
+ * one (session expired) trigger the host login redirect and reject.
73
+ */
74
+ ensureFreshToken(minValiditySeconds?: number): Promise<string>;
75
+ /**
76
+ * Handle a 401 from a downstream API call: force-refresh the token and, if
77
+ * that fails, redirect to login. HOST-owned for the same reason as
78
+ * {@link HostBridgeAuth.ensureFreshToken} — there is ONE login-redirect
79
+ * single-flight guard and one refresh-dedup path in the host; biomes funnel
80
+ * 401 recovery through here rather than forking that logic.
81
+ */
82
+ onUnauthorized(): Promise<void>;
61
83
  }
62
84
 
63
85
  export interface HostBridgeToast {
@@ -25,6 +25,19 @@ export interface AuthSource {
25
25
  getProjectId(): string | null;
26
26
  /** Subject (user) id of the actor, or null when unauthenticated. */
27
27
  getUserId(): string | null;
28
+ /**
29
+ * Proactively refresh the token if it is within `minValiditySeconds` of
30
+ * expiry and return a valid bearer. Backed by the host's auth client
31
+ * (keycloak-js `updateToken`, …) and its single-flight login redirect.
32
+ * Mirrors {@link HostBridgeAuth.ensureFreshToken} — the bridge delegates
33
+ * straight through to this source.
34
+ */
35
+ ensureFreshToken(minValiditySeconds?: number): Promise<string>;
36
+ /**
37
+ * Force-refresh on a 401 and redirect to login on failure. Backed by the
38
+ * host's auth-guard. Mirrors {@link HostBridgeAuth.onUnauthorized}.
39
+ */
40
+ onUnauthorized(): Promise<void>;
28
41
  }
29
42
 
30
43
  /**
@@ -14,10 +14,11 @@
14
14
  */
15
15
  export * from './frontend-biome';
16
16
  export * from './host-bridge';
17
+ export * from './create-biome-orval-config';
17
18
  export * from './biome-registry';
18
19
  export * from './session-contributions';
19
20
  export * from './session-profiles';
20
21
  export * from './host-sources';
21
22
  export * from './nav';
22
23
  export * from './biome-mode';
23
- export * from './composition-validation';
24
+ export * from './agent-validation';
@@ -1,5 +1,5 @@
1
1
  export * from './lib/extension-points';
2
- export * from './lib/composition-validation-host';
2
+ export * from './lib/agent-validation-host';
3
3
  export * from './lib/biome-slot';
4
4
  export * from './lib/biomes-enabled-context';
5
5
  export * from './lib/session-selectors';
@@ -18,7 +18,7 @@
18
18
  * readable via the host bridge's location").
19
19
  * 2. Mount `<BiomeSlot name={HostExtensionSlots.X} />` in the host
20
20
  * page.
21
- * 3. Document the slot in `data/docs/public/biomes/04-frontend-extensions.md`.
21
+ * 3. Document the slot in the xema-docs repo (`content/biomes/04-frontend-extensions.md`).
22
22
  *
23
23
  * Removing a host slot is a breaking change — bump the kernel package
24
24
  * major version.
@@ -0,0 +1,89 @@
1
+ /**
2
+ * Minimal action button used by the approval surfaces (card / sticky bar /
3
+ * Center). The kit carries no button primitive, so this is a small,
4
+ * inline-styled control matching the design's `Btn` (ghost / soft /
5
+ * primary, with an optional accent override for critical-tier approve).
6
+ *
7
+ * Kept inside the approvals folder because it is only the approval
8
+ * actions — it is NOT a general-purpose kit button.
9
+ */
10
+ import type { CSSProperties, ReactElement, ReactNode } from 'react';
11
+
12
+ export type ApprovalButtonVariant = 'ghost' | 'soft' | 'primary';
13
+ export type ApprovalButtonSize = 'xs' | 'sm';
14
+
15
+ export interface ApprovalButtonProps {
16
+ readonly variant?: ApprovalButtonVariant;
17
+ readonly size?: ApprovalButtonSize;
18
+ /** Accent for the `primary` variant background (e.g. portal/risk accent). */
19
+ readonly accent?: string;
20
+ readonly icon?: ReactNode;
21
+ readonly disabled?: boolean;
22
+ readonly onClick?: () => void;
23
+ readonly children: ReactNode;
24
+ }
25
+
26
+ const PAD: Record<ApprovalButtonSize, string> = {
27
+ xs: '3px 9px',
28
+ sm: '5px 11px',
29
+ };
30
+
31
+ const FONT: Record<ApprovalButtonSize, number> = { xs: 12, sm: 12.5 };
32
+
33
+ export function ApprovalButton({
34
+ variant = 'ghost',
35
+ size = 'sm',
36
+ accent = 'hsl(var(--primary))',
37
+ icon,
38
+ disabled = false,
39
+ onClick,
40
+ children,
41
+ }: ApprovalButtonProps): ReactElement {
42
+ const base: CSSProperties = {
43
+ display: 'inline-flex',
44
+ alignItems: 'center',
45
+ gap: 5,
46
+ padding: PAD[size],
47
+ fontSize: FONT[size],
48
+ fontWeight: 550,
49
+ borderRadius: 7,
50
+ cursor: disabled ? 'not-allowed' : 'pointer',
51
+ opacity: disabled ? 0.5 : 1,
52
+ whiteSpace: 'nowrap',
53
+ lineHeight: 1.2,
54
+ };
55
+ const variantStyle = resolveVariantStyle(variant, accent);
56
+ return (
57
+ <button
58
+ type="button"
59
+ disabled={disabled}
60
+ onClick={onClick}
61
+ style={{ ...base, ...variantStyle }}
62
+ >
63
+ {icon}
64
+ {children}
65
+ </button>
66
+ );
67
+ }
68
+
69
+ function resolveVariantStyle(
70
+ variant: ApprovalButtonVariant,
71
+ accent: string,
72
+ ): CSSProperties {
73
+ switch (variant) {
74
+ case 'primary':
75
+ return { background: accent, color: '#fff', border: '1px solid transparent' };
76
+ case 'soft':
77
+ return {
78
+ background: 'hsl(var(--muted))',
79
+ color: 'hsl(var(--ink))',
80
+ border: '1px solid hsl(var(--rule))',
81
+ };
82
+ case 'ghost':
83
+ return {
84
+ background: 'transparent',
85
+ color: 'hsl(var(--ink-2))',
86
+ border: '1px solid transparent',
87
+ };
88
+ }
89
+ }
@@ -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
+ }