@stigmer/react 3.0.6 → 3.0.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 (94) hide show
  1. package/agent-instance/AgentInstanceDetailPanel.d.ts.map +1 -1
  2. package/agent-instance/AgentInstanceDetailPanel.js +2 -9
  3. package/agent-instance/AgentInstanceDetailPanel.js.map +1 -1
  4. package/agent-instance/AgentInstanceList.d.ts.map +1 -1
  5. package/agent-instance/AgentInstanceList.js +2 -9
  6. package/agent-instance/AgentInstanceList.js.map +1 -1
  7. package/agent-instance/CreateAgentInstanceDialog.d.ts.map +1 -1
  8. package/agent-instance/CreateAgentInstanceDialog.js +1 -1
  9. package/agent-instance/CreateAgentInstanceDialog.js.map +1 -1
  10. package/composer/SessionComposer.d.ts +14 -0
  11. package/composer/SessionComposer.d.ts.map +1 -1
  12. package/composer/SessionComposer.js +15 -9
  13. package/composer/SessionComposer.js.map +1 -1
  14. package/index.d.ts +1 -1
  15. package/index.d.ts.map +1 -1
  16. package/index.js.map +1 -1
  17. package/library/InstanceVisibilitySelector.d.ts +23 -9
  18. package/library/InstanceVisibilitySelector.d.ts.map +1 -1
  19. package/library/InstanceVisibilitySelector.js +14 -9
  20. package/library/InstanceVisibilitySelector.js.map +1 -1
  21. package/library/VisibilityOptionRow.d.ts +52 -0
  22. package/library/VisibilityOptionRow.d.ts.map +1 -0
  23. package/library/VisibilityOptionRow.js +92 -0
  24. package/library/VisibilityOptionRow.js.map +1 -0
  25. package/library/VisibilitySelector.d.ts +47 -24
  26. package/library/VisibilitySelector.d.ts.map +1 -1
  27. package/library/VisibilitySelector.js +137 -115
  28. package/library/VisibilitySelector.js.map +1 -1
  29. package/library/visibilityLevels.d.ts +25 -3
  30. package/library/visibilityLevels.d.ts.map +1 -1
  31. package/library/visibilityLevels.js +8 -2
  32. package/library/visibilityLevels.js.map +1 -1
  33. package/package.json +4 -4
  34. package/session/NewSessionViewer.d.ts +32 -1
  35. package/session/NewSessionViewer.d.ts.map +1 -1
  36. package/session/NewSessionViewer.js +20 -9
  37. package/session/NewSessionViewer.js.map +1 -1
  38. package/session/SessionViewer.d.ts +24 -1
  39. package/session/SessionViewer.d.ts.map +1 -1
  40. package/session/SessionViewer.js +18 -12
  41. package/session/SessionViewer.js.map +1 -1
  42. package/session/audience.d.ts +21 -0
  43. package/session/audience.d.ts.map +1 -0
  44. package/session/audience.js +2 -0
  45. package/session/audience.js.map +1 -0
  46. package/session/index.d.ts +2 -0
  47. package/session/index.d.ts.map +1 -1
  48. package/session/index.js.map +1 -1
  49. package/session/runtime-env.d.ts +47 -0
  50. package/session/runtime-env.d.ts.map +1 -0
  51. package/session/runtime-env.js +20 -0
  52. package/session/runtime-env.js.map +1 -0
  53. package/session/useNewSessionFlow.d.ts +25 -0
  54. package/session/useNewSessionFlow.d.ts.map +1 -1
  55. package/session/useNewSessionFlow.js +20 -8
  56. package/session/useNewSessionFlow.js.map +1 -1
  57. package/session/useSessionPageFlow.d.ts +27 -2
  58. package/session/useSessionPageFlow.d.ts.map +1 -1
  59. package/session/useSessionPageFlow.js +34 -13
  60. package/session/useSessionPageFlow.js.map +1 -1
  61. package/src/agent-instance/AgentInstanceDetailPanel.tsx +7 -27
  62. package/src/agent-instance/AgentInstanceList.tsx +7 -27
  63. package/src/agent-instance/CreateAgentInstanceDialog.tsx +1 -0
  64. package/src/composer/SessionComposer.tsx +30 -8
  65. package/src/composer/__tests__/SessionComposer-lockAgent.test.tsx +150 -0
  66. package/src/index.ts +2 -0
  67. package/src/library/InstanceVisibilitySelector.tsx +27 -9
  68. package/src/library/VisibilityOptionRow.tsx +244 -0
  69. package/src/library/VisibilitySelector.tsx +303 -260
  70. package/src/library/__tests__/VisibilitySelector.test.tsx +256 -0
  71. package/src/library/visibilityLevels.ts +35 -5
  72. package/src/session/NewSessionViewer.tsx +61 -12
  73. package/src/session/SessionViewer.tsx +51 -15
  74. package/src/session/__tests__/audienceWiring.test.tsx +274 -0
  75. package/src/session/__tests__/useNewSessionFlow.test.tsx +122 -0
  76. package/src/session/__tests__/useSessionPageFlow.runtimeEnv.test.tsx +170 -0
  77. package/src/session/audience.ts +20 -0
  78. package/src/session/index.ts +3 -0
  79. package/src/session/runtime-env.ts +57 -0
  80. package/src/session/useNewSessionFlow.ts +44 -9
  81. package/src/session/useSessionPageFlow.ts +65 -17
  82. package/src/workflow/instance/CreateWorkflowInstanceDialog.tsx +1 -0
  83. package/src/workflow/instance/WorkflowInstanceDetailPanel.tsx +7 -27
  84. package/src/workflow/instance/WorkflowInstanceList.tsx +7 -27
  85. package/styles.css +1 -1
  86. package/workflow/instance/CreateWorkflowInstanceDialog.d.ts.map +1 -1
  87. package/workflow/instance/CreateWorkflowInstanceDialog.js +1 -1
  88. package/workflow/instance/CreateWorkflowInstanceDialog.js.map +1 -1
  89. package/workflow/instance/WorkflowInstanceDetailPanel.d.ts.map +1 -1
  90. package/workflow/instance/WorkflowInstanceDetailPanel.js +2 -9
  91. package/workflow/instance/WorkflowInstanceDetailPanel.js.map +1 -1
  92. package/workflow/instance/WorkflowInstanceList.d.ts.map +1 -1
  93. package/workflow/instance/WorkflowInstanceList.js +2 -9
  94. package/workflow/instance/WorkflowInstanceList.js.map +1 -1
@@ -1,54 +1,94 @@
1
1
  "use client";
2
2
 
3
- import { useCallback, useMemo, useRef, useState } from "react";
3
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
4
+ import { Popover } from "@base-ui/react/popover";
4
5
  import { cn } from "@stigmer/theme";
5
6
  import { ApiResourceVisibility } from "@stigmer/protos/ai/stigmer/commons/apiresource/enum_pb";
7
+ import { useStigmerPortalContainer } from "../portal-container";
8
+ import { ConfirmDialog } from "../resource-detail/ConfirmDialog";
9
+ import { useConfirmAction } from "../resource-detail/useConfirmAction";
10
+ import {
11
+ PROMPT_STYLES,
12
+ VISIBILITY_CHIP_CLASS,
13
+ VisibilityIcon,
14
+ VisibilityOptionRow,
15
+ } from "./VisibilityOptionRow";
6
16
  import {
7
17
  visibilityOption,
8
18
  type VisibilityLevelOption,
9
19
  } from "./visibilityLevels";
10
20
 
21
+ /** How the selector presents itself and how it confirms escalations. */
22
+ export type VisibilitySelectorMode = "manage" | "create";
23
+
11
24
  /** Props for {@link VisibilitySelector}. */
12
25
  export interface VisibilitySelectorProps {
13
26
  /** Current visibility of the resource. */
14
27
  readonly visibility: ApiResourceVisibility;
15
28
  /**
16
- * Levels to offer, in escalation order (see {@link blueprintVisibilityLevels}).
17
- * Selecting a level later in the list than the current one is an
18
- * escalation and shows that option's inline confirmation prompt;
19
- * de-escalation applies immediately (revoking access is always safe).
29
+ * Levels to offer, in escalation order (least to most exposed). Selecting
30
+ * a level later in the list than the current one is an escalation and is
31
+ * confirmed by severity (see {@link mode}); de-escalation applies
32
+ * immediately (revoking access is always safe).
20
33
  */
21
34
  readonly options: readonly VisibilityLevelOption[];
22
35
  /** Called when the user selects (and, for escalations, confirms) a level. */
23
36
  readonly onVisibilityChange: (v: ApiResourceVisibility) => void;
37
+ /**
38
+ * Presentation + confirmation mode.
39
+ *
40
+ * - `"manage"` (default) — a compact current-state chip opens a popover
41
+ * ladder of levels. Escalations are confirmed by severity: a light
42
+ * inline prompt for levels carrying {@link VisibilityLevelOption.confirmPrompt}
43
+ * (e.g. Organization), and a blocking modal for levels carrying
44
+ * {@link VisibilityLevelOption.confirmDialog} (Platform, Public). This is
45
+ * the live-resource case.
46
+ * - `"create"` — an inline radio list that applies immediately, with no
47
+ * escalation confirmation. Used to pick an initial value while creating
48
+ * a resource (typically inside a native `<dialog>`, where a portaled
49
+ * popover would render beneath the modal's top layer).
50
+ *
51
+ * @default "manage"
52
+ */
53
+ readonly mode?: VisibilitySelectorMode;
24
54
  /** Shows a spinner/disabled state while the RPC is in flight. */
25
55
  readonly isPending?: boolean;
26
56
  /** Disables all interaction (e.g., when the user lacks can_edit). */
27
57
  readonly disabled?: boolean;
28
- /** Accessible name for the radio group. Defaults to "Resource visibility". */
58
+ /** Accessible name for the control. Defaults to "Resource visibility". */
29
59
  readonly ariaLabel?: string;
30
60
  /** Additional CSS classes applied to the root element. */
31
61
  readonly className?: string;
32
62
  }
33
63
 
34
64
  /**
35
- * Segmented visibility selector the single control for resource
36
- * visibility across blueprints AND instances. The offered levels are pure
37
- * data ({@link VisibilityLevelOption}); per-kind level sets live in
38
- * `visibilityLevels.ts`, so this component carries no kind-specific logic.
65
+ * The single control for resource visibility across blueprints AND
66
+ * instances. Offered levels are pure data ({@link VisibilityLevelOption});
67
+ * per-kind level sets live in `visibilityLevels.ts`, so this component
68
+ * carries no kind-specific logic.
69
+ *
70
+ * In `"manage"` mode it renders a current-state chip (icon + label + caret)
71
+ * that opens a popover listing each offered level with its own description —
72
+ * scaling cleanly to four levels without the layout shift of a segmented
73
+ * control, and explaining every choice at a glance (Recognition over
74
+ * Recall). Escalation is confirmed in proportion to how far access expands:
75
+ * de-escalation applies instantly, an Organization escalation shows a light
76
+ * inline prompt, and Platform/Public escalations open a blocking
77
+ * {@link ConfirmDialog} that names the exact audience. Confirmation is owned
78
+ * here so every consumer — blueprint detail, instance detail, and any
79
+ * standalone embed — behaves identically.
39
80
  *
40
- * Escalating (moving right in the options list) shows an inline
41
- * confirmation prompt colored by the target level's tone, since expanding
42
- * access is consequential. De-escalating applies immediately.
81
+ * In `"create"` mode it renders an inline radio list that applies
82
+ * immediately (initial value selection has no escalation semantics).
43
83
  *
44
84
  * If the current visibility is not among the offered options (e.g. a
45
85
  * platform-shared blueprint whose org no longer operates an
46
86
  * IdentityProvider), its canonical option is rendered in place so the
47
87
  * state stays legible and the user can still move to an offered level.
48
88
  *
49
- * WAI-ARIA Radio Group with roving tabindex, following the same visual
50
- * pattern as {@link ScopeToggle}. All visual properties flow through
51
- * `--stgm-*` design tokens.
89
+ * All visual properties flow through `--stgm-*` design tokens; portaled
90
+ * content targets the {@link useStigmerPortalContainer} so it inherits the
91
+ * active theme.
52
92
  *
53
93
  * @example
54
94
  * ```tsx
@@ -64,19 +104,27 @@ export function VisibilitySelector({
64
104
  visibility,
65
105
  options,
66
106
  onVisibilityChange,
107
+ mode = "manage",
67
108
  isPending = false,
68
109
  disabled = false,
69
110
  ariaLabel = "Resource visibility",
70
111
  className,
71
112
  }: VisibilitySelectorProps) {
72
- const [confirming, setConfirming] = useState<ApiResourceVisibility | null>(
73
- null,
74
- );
113
+ const portalContainer = useStigmerPortalContainer();
114
+ const { confirmState, confirm, handleConfirm, handleCancel } =
115
+ useConfirmAction();
116
+
117
+ const [open, setOpen] = useState(false);
118
+ // The Organization-style escalation awaiting its light inline confirm.
119
+ const [pendingInline, setPendingInline] =
120
+ useState<ApiResourceVisibility | null>(null);
121
+ const [highlightIdx, setHighlightIdx] = useState(-1);
75
122
  const optionRefs = useRef<(HTMLButtonElement | null)[]>([]);
123
+
76
124
  const effectivelyDisabled = disabled || isPending;
77
125
 
78
126
  // Keep the current state legible even when it is not offerable in the
79
- // current context: render its canonical option as an extra segment.
127
+ // current context: render its canonical option as an extra row.
80
128
  const effectiveOptions = useMemo(() => {
81
129
  if (options.some((o) => o.value === visibility)) return options;
82
130
  return [...options, visibilityOption(visibility)];
@@ -90,274 +138,273 @@ export function VisibilitySelector({
90
138
  [effectiveOptions, visibility],
91
139
  );
92
140
 
93
- const handleSelect = useCallback(
141
+ const apply = useCallback(
94
142
  (value: ApiResourceVisibility) => {
95
- if (value === visibility) return;
96
-
97
- if (isEscalation(value)) {
98
- setConfirming(value);
99
- return;
100
- }
101
-
102
- setConfirming(null);
143
+ setOpen(false);
144
+ setPendingInline(null);
103
145
  onVisibilityChange(value);
104
146
  },
105
- [visibility, onVisibilityChange, isEscalation],
147
+ [onVisibilityChange],
106
148
  );
107
149
 
108
- const confirmChange = useCallback(() => {
109
- if (confirming === null) return;
110
- setConfirming(null);
111
- onVisibilityChange(confirming);
112
- }, [confirming, onVisibilityChange]);
150
+ const handleSelect = useCallback(
151
+ (option: VisibilityLevelOption) => {
152
+ const value = option.value;
153
+ if (value === visibility) {
154
+ setPendingInline(null);
155
+ setOpen(false);
156
+ return;
157
+ }
113
158
 
114
- const cancelConfirm = useCallback(() => {
115
- setConfirming(null);
116
- }, []);
159
+ // De-escalation (and create mode) never confirm — narrowing access is
160
+ // always safe, and an initial pick has no escalation semantics.
161
+ if (mode === "create" || !isEscalation(value)) {
162
+ apply(value);
163
+ return;
164
+ }
117
165
 
118
- const handleKeyDown = useCallback(
119
- (e: React.KeyboardEvent<HTMLButtonElement>, index: number) => {
120
- let nextIndex: number | null = null;
166
+ if (option.confirmDialog) {
167
+ setOpen(false);
168
+ setPendingInline(null);
169
+ void confirm({
170
+ title: option.confirmDialog.title,
171
+ description: option.confirmDialog.description,
172
+ confirmLabel: `Make ${option.label}`,
173
+ // Exposure is reversible, so this is a primary (not destructive)
174
+ // confirmation; the audience-naming copy carries the caution.
175
+ variant: "default",
176
+ }).then((ok) => {
177
+ if (ok) onVisibilityChange(value);
178
+ });
179
+ return;
180
+ }
121
181
 
122
- if (e.key === "ArrowRight" || e.key === "ArrowDown") {
123
- e.preventDefault();
124
- nextIndex = (index + 1) % effectiveOptions.length;
125
- } else if (e.key === "ArrowLeft" || e.key === "ArrowUp") {
126
- e.preventDefault();
127
- nextIndex =
128
- (index - 1 + effectiveOptions.length) % effectiveOptions.length;
182
+ if (option.confirmPrompt) {
183
+ setPendingInline(value);
184
+ return;
129
185
  }
130
186
 
131
- if (nextIndex !== null) {
132
- optionRefs.current[nextIndex]?.focus();
133
- handleSelect(effectiveOptions[nextIndex].value);
187
+ apply(value);
188
+ },
189
+ [visibility, mode, isEscalation, apply, confirm, onVisibilityChange],
190
+ );
191
+
192
+ const moveFocus = useCallback(
193
+ (from: number, delta: number) => {
194
+ const count = effectiveOptions.length;
195
+ const next = (from + delta + count) % count;
196
+ setHighlightIdx(next);
197
+ optionRefs.current[next]?.focus();
198
+ },
199
+ [effectiveOptions.length],
200
+ );
201
+
202
+ const handleRowKeyDown = useCallback(
203
+ (e: React.KeyboardEvent<HTMLButtonElement>, index: number) => {
204
+ switch (e.key) {
205
+ case "ArrowDown":
206
+ case "ArrowRight":
207
+ e.preventDefault();
208
+ moveFocus(index, 1);
209
+ break;
210
+ case "ArrowUp":
211
+ case "ArrowLeft":
212
+ e.preventDefault();
213
+ moveFocus(index, -1);
214
+ break;
215
+ case "Home":
216
+ e.preventDefault();
217
+ setHighlightIdx(0);
218
+ optionRefs.current[0]?.focus();
219
+ break;
220
+ case "End": {
221
+ e.preventDefault();
222
+ const last = effectiveOptions.length - 1;
223
+ setHighlightIdx(last);
224
+ optionRefs.current[last]?.focus();
225
+ break;
226
+ }
134
227
  }
135
228
  },
136
- [effectiveOptions, handleSelect],
229
+ [moveFocus, effectiveOptions.length],
137
230
  );
138
231
 
139
- const confirmingOption =
140
- confirming !== null
141
- ? effectiveOptions.find((o) => o.value === confirming)
142
- : undefined;
232
+ // On open, focus the current level so keyboard users land on a sensible row.
233
+ useEffect(() => {
234
+ if (!open) {
235
+ setPendingInline(null);
236
+ setHighlightIdx(-1);
237
+ return;
238
+ }
239
+ const current = effectiveOptions.findIndex((o) => o.value === visibility);
240
+ const start = current >= 0 ? current : 0;
241
+ setHighlightIdx(start);
242
+ const raf = requestAnimationFrame(() => optionRefs.current[start]?.focus());
243
+ return () => cancelAnimationFrame(raf);
244
+ }, [open, effectiveOptions, visibility]);
143
245
 
144
- return (
145
- <div className={cn("inline-flex flex-col gap-1.5", className)}>
246
+ const dialog = (
247
+ <ConfirmDialog
248
+ state={confirmState}
249
+ onConfirm={handleConfirm}
250
+ onCancel={handleCancel}
251
+ />
252
+ );
253
+
254
+ const renderRows = (role: "option" | "radio") =>
255
+ effectiveOptions.map((option, index) => (
256
+ <VisibilityOptionRow
257
+ key={option.value}
258
+ ref={(el) => {
259
+ optionRefs.current[index] = el;
260
+ }}
261
+ option={option}
262
+ role={role}
263
+ isSelected={visibility === option.value}
264
+ isHighlighted={role === "option" && highlightIdx === index}
265
+ tabIndex={
266
+ role === "radio"
267
+ ? visibility === option.value
268
+ ? 0
269
+ : -1
270
+ : highlightIdx === index
271
+ ? 0
272
+ : -1
273
+ }
274
+ disabled={effectivelyDisabled}
275
+ onSelect={() => handleSelect(option)}
276
+ onMouseEnter={
277
+ role === "option" ? () => setHighlightIdx(index) : undefined
278
+ }
279
+ onKeyDown={(e) => handleRowKeyDown(e, index)}
280
+ />
281
+ ));
282
+
283
+ // ---- Create mode: inline radio list, applies immediately ---------------
284
+ if (mode === "create") {
285
+ return (
146
286
  <div
147
287
  role="radiogroup"
148
288
  aria-label={ariaLabel}
149
289
  aria-disabled={effectivelyDisabled || undefined}
150
290
  className={cn(
151
- "inline-flex rounded-md bg-muted p-0.5",
291
+ "flex flex-col gap-0.5 rounded-md border border-border p-1",
152
292
  effectivelyDisabled && "pointer-events-none opacity-50",
293
+ className,
153
294
  )}
154
295
  >
155
- {effectiveOptions.map((option, index) => {
156
- const isSelected = visibility === option.value;
157
-
158
- return (
159
- <button
160
- key={option.value}
161
- ref={(el) => {
162
- optionRefs.current[index] = el;
163
- }}
164
- type="button"
165
- role="radio"
166
- aria-checked={isSelected}
167
- aria-label={`${option.label}: ${option.description}`}
168
- tabIndex={isSelected ? 0 : -1}
169
- disabled={effectivelyDisabled}
170
- onClick={() => handleSelect(option.value)}
171
- onKeyDown={(e) => handleKeyDown(e, index)}
172
- className={cn(
173
- "inline-flex cursor-pointer items-center gap-1 rounded-sm px-2.5 py-1 text-xs font-medium transition-colors",
174
- "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
175
- isSelected
176
- ? SELECTED_STYLES[option.tone]
177
- : "text-muted-foreground hover:text-foreground",
178
- )}
179
- >
180
- {isPending && isSelected ? (
181
- <span
182
- className="inline-block size-3 animate-spin rounded-full border-2 border-current border-t-transparent"
183
- aria-hidden="true"
184
- />
185
- ) : (
186
- <VisibilityIcon tone={option.tone} className="size-3" />
187
- )}
188
- {option.label}
189
- </button>
190
- );
191
- })}
296
+ {renderRows("radio")}
192
297
  </div>
298
+ );
299
+ }
193
300
 
194
- {/* Description of current state */}
195
- {confirming === null && (
196
- <p className="text-[0.65rem] text-muted-foreground">
197
- {effectiveOptions.find((o) => o.value === visibility)?.description}
198
- </p>
199
- )}
301
+ // ---- Manage mode: current-state chip + popover ladder ------------------
302
+ const current = visibilityOption(visibility);
303
+ const pendingOption =
304
+ pendingInline !== null
305
+ ? effectiveOptions.find((o) => o.value === pendingInline)
306
+ : undefined;
200
307
 
201
- {/* Confirmation prompt for escalation */}
202
- {confirmingOption?.confirmPrompt && (
203
- <div
308
+ return (
309
+ <>
310
+ <Popover.Root open={open} onOpenChange={setOpen}>
311
+ <Popover.Trigger
312
+ disabled={effectivelyDisabled}
313
+ aria-label={`${ariaLabel}: ${current.label}`}
204
314
  className={cn(
205
- "flex items-center gap-2 rounded-md border px-3 py-1.5 text-xs",
206
- PROMPT_STYLES[confirmingOption.tone].container,
315
+ VISIBILITY_CHIP_CLASS,
316
+ "transition-colors hover:bg-accent-hover hover:text-foreground",
317
+ "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
318
+ "disabled:pointer-events-none disabled:opacity-50",
319
+ className,
207
320
  )}
208
- role="alert"
209
321
  >
210
- <span className={PROMPT_STYLES[confirmingOption.tone].text}>
211
- {confirmingOption.confirmPrompt}
212
- </span>
213
- <button
214
- type="button"
215
- onClick={confirmChange}
216
- className={cn(
217
- "rounded px-2 py-0.5 text-xs font-medium text-white",
218
- "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
219
- PROMPT_STYLES[confirmingOption.tone].confirm,
220
- )}
221
- >
222
- Confirm
223
- </button>
224
- <button
225
- type="button"
226
- onClick={cancelConfirm}
227
- className={cn(
228
- "rounded px-2 py-0.5 text-xs font-medium",
229
- "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
230
- PROMPT_STYLES[confirmingOption.tone].cancel,
231
- )}
232
- >
233
- Cancel
234
- </button>
235
- </div>
236
- )}
237
- </div>
238
- );
239
- }
240
-
241
- // ---------------------------------------------------------------------------
242
- // Tone styling — one row per visibility tone, keyed by VisibilityLevelOption
243
- // ---------------------------------------------------------------------------
244
-
245
- type Tone = VisibilityLevelOption["tone"];
246
-
247
- const SELECTED_STYLES: Record<Tone, string> = {
248
- private:
249
- "bg-amber-50 text-amber-800 shadow-sm dark:bg-amber-900/30 dark:text-amber-300",
250
- org: "bg-blue-100 text-blue-800 shadow-sm dark:bg-blue-900/40 dark:text-blue-300",
251
- platform:
252
- "bg-violet-100 text-violet-800 shadow-sm dark:bg-violet-900/40 dark:text-violet-300",
253
- public:
254
- "bg-emerald-100 text-emerald-800 shadow-sm dark:bg-emerald-900/40 dark:text-emerald-300",
255
- };
256
-
257
- const PROMPT_STYLES: Record<
258
- Tone,
259
- { container: string; text: string; confirm: string; cancel: string }
260
- > = {
261
- // Private never escalates, but the row keeps the Record total.
262
- private: {
263
- container:
264
- "border-amber-200 bg-amber-50 dark:border-amber-800/50 dark:bg-amber-950/30",
265
- text: "text-amber-800 dark:text-amber-200",
266
- confirm:
267
- "bg-amber-600 hover:bg-amber-700 dark:bg-amber-600 dark:hover:bg-amber-500",
268
- cancel:
269
- "text-amber-700 hover:text-amber-900 dark:text-amber-300 dark:hover:text-amber-100",
270
- },
271
- org: {
272
- container:
273
- "border-blue-200 bg-blue-50 dark:border-blue-800/50 dark:bg-blue-950/30",
274
- text: "text-blue-800 dark:text-blue-200",
275
- confirm:
276
- "bg-blue-600 hover:bg-blue-700 dark:bg-blue-600 dark:hover:bg-blue-500",
277
- cancel:
278
- "text-blue-700 hover:text-blue-900 dark:text-blue-300 dark:hover:text-blue-100",
279
- },
280
- platform: {
281
- container:
282
- "border-violet-200 bg-violet-50 dark:border-violet-800/50 dark:bg-violet-950/30",
283
- text: "text-violet-800 dark:text-violet-200",
284
- confirm:
285
- "bg-violet-600 hover:bg-violet-700 dark:bg-violet-600 dark:hover:bg-violet-500",
286
- cancel:
287
- "text-violet-700 hover:text-violet-900 dark:text-violet-300 dark:hover:text-violet-100",
288
- },
289
- public: {
290
- container:
291
- "border-amber-200 bg-amber-50 dark:border-amber-800/50 dark:bg-amber-950/30",
292
- text: "text-amber-800 dark:text-amber-200",
293
- confirm:
294
- "bg-amber-600 hover:bg-amber-700 dark:bg-amber-600 dark:hover:bg-amber-500",
295
- cancel:
296
- "text-amber-700 hover:text-amber-900 dark:text-amber-300 dark:hover:text-amber-100",
297
- },
298
- };
299
-
300
- // ---------------------------------------------------------------------------
301
- // Icons — inline SVGs following the SDK pattern (no icon library dependency)
302
- // ---------------------------------------------------------------------------
303
-
304
- /** Icon for a visibility tone; shared with {@link VisibilityBadge}. */
305
- export function VisibilityIcon({
306
- tone,
307
- className,
308
- }: {
309
- readonly tone: Tone;
310
- readonly className?: string;
311
- }) {
312
- switch (tone) {
313
- case "org":
314
- return <UsersIcon className={className} />;
315
- case "platform":
316
- return <BuildingsIcon className={className} />;
317
- case "public":
318
- return <GlobeIcon className={className} />;
319
- default:
320
- return <LockIcon className={className} />;
321
- }
322
- }
323
-
324
- function LockIcon({ className }: { readonly className?: string }) {
325
- return (
326
- <svg className={className} viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
327
- <rect x="3.5" y="7" width="9" height="7" rx="1.5" />
328
- <path d="M5.5 7V5a2.5 2.5 0 0 1 5 0v2" />
329
- </svg>
330
- );
331
- }
322
+ {isPending ? (
323
+ <span
324
+ className="inline-block size-2.5 animate-spin rounded-full border-2 border-current border-t-transparent"
325
+ aria-hidden="true"
326
+ />
327
+ ) : (
328
+ <VisibilityIcon tone={current.tone} className="size-2.5" />
329
+ )}
330
+ {current.label}
331
+ <CaretIcon />
332
+ </Popover.Trigger>
332
333
 
333
- function UsersIcon({ className }: { readonly className?: string }) {
334
- return (
335
- <svg className={className} viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
336
- <circle cx="6" cy="5" r="2.5" />
337
- <path d="M2 13c0-2.21 1.79-4 4-4s4 1.79 4 4" />
338
- <circle cx="11.5" cy="5.5" r="2" />
339
- <path d="M14 13c0-1.66-1.12-3-2.5-3-.5 0-1 .14-1.4.4" />
340
- </svg>
341
- );
342
- }
334
+ <Popover.Portal container={portalContainer}>
335
+ <Popover.Positioner sideOffset={4} align="start">
336
+ <Popover.Popup
337
+ role="listbox"
338
+ aria-label={ariaLabel}
339
+ className={cn(
340
+ "z-popover w-72 rounded-lg border border-border bg-popover p-1 shadow-md",
341
+ "text-popover-foreground animate-in fade-in-0 zoom-in-95",
342
+ )}
343
+ >
344
+ {renderRows("option")}
343
345
 
344
- function BuildingsIcon({ className }: { readonly className?: string }) {
345
- return (
346
- <svg className={className} viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
347
- <path d="M2 14V4.5L6.5 2v12" />
348
- <path d="M6.5 6.5 14 8.5V14" />
349
- <path d="M2 14h12" />
350
- <path d="M4.25 6h.01M4.25 8.5h.01M4.25 11h.01M10.5 10.5h.01M10.5 12.5h.01" />
351
- </svg>
346
+ {pendingOption?.confirmPrompt && (
347
+ <div
348
+ role="alert"
349
+ className={cn(
350
+ "mt-1 flex items-center gap-2 rounded-md border px-2.5 py-1.5",
351
+ PROMPT_STYLES[pendingOption.tone].container,
352
+ )}
353
+ >
354
+ <span
355
+ className={cn(
356
+ "flex-1 text-[0.65rem] leading-snug",
357
+ PROMPT_STYLES[pendingOption.tone].text,
358
+ )}
359
+ >
360
+ {pendingOption.confirmPrompt}
361
+ </span>
362
+ <button
363
+ type="button"
364
+ onClick={() => setPendingInline(null)}
365
+ className={cn(
366
+ "rounded px-2 py-0.5 text-[0.65rem] font-medium",
367
+ "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
368
+ PROMPT_STYLES[pendingOption.tone].cancel,
369
+ )}
370
+ >
371
+ Cancel
372
+ </button>
373
+ <button
374
+ type="button"
375
+ onClick={() => apply(pendingOption.value)}
376
+ className={cn(
377
+ "rounded px-2 py-0.5 text-[0.65rem] font-medium text-white",
378
+ "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
379
+ PROMPT_STYLES[pendingOption.tone].confirm,
380
+ )}
381
+ >
382
+ Confirm
383
+ </button>
384
+ </div>
385
+ )}
386
+ </Popover.Popup>
387
+ </Popover.Positioner>
388
+ </Popover.Portal>
389
+ </Popover.Root>
390
+ {dialog}
391
+ </>
352
392
  );
353
393
  }
354
394
 
355
- function GlobeIcon({ className }: { readonly className?: string }) {
395
+ function CaretIcon() {
356
396
  return (
357
- <svg className={className} viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
358
- <circle cx="8" cy="8" r="6" />
359
- <path d="M2 8h12" />
360
- <path d="M8 2c1.66 1.46 2.6 3.63 2.6 6s-.94 4.54-2.6 6c-1.66-1.46-2.6-3.63-2.6-6s.94-4.54 2.6-6Z" />
397
+ <svg
398
+ className="size-2.5 shrink-0 text-muted-foreground"
399
+ viewBox="0 0 12 12"
400
+ fill="none"
401
+ stroke="currentColor"
402
+ strokeWidth="1.5"
403
+ strokeLinecap="round"
404
+ strokeLinejoin="round"
405
+ aria-hidden="true"
406
+ >
407
+ <path d="M3 4.5 6 7.5 9 4.5" />
361
408
  </svg>
362
409
  );
363
410
  }
@@ -369,7 +416,8 @@ function GlobeIcon({ className }: { readonly className?: string }) {
369
416
  * Rendered wherever the interactive {@link VisibilitySelector} is not
370
417
  * available — for viewers who lack `can_edit`, and while a permission check
371
418
  * is in flight — so a resource's visibility is always legible rather than
372
- * silently blank.
419
+ * silently blank. Shares the chip styling with the selector trigger so the
420
+ * read-only and editable states are visually consistent.
373
421
  */
374
422
  export function VisibilityBadge({
375
423
  visibility,
@@ -380,12 +428,7 @@ export function VisibilityBadge({
380
428
  }) {
381
429
  const option = visibilityOption(visibility);
382
430
  return (
383
- <span
384
- className={cn(
385
- "inline-flex shrink-0 items-center gap-1 rounded-full bg-muted px-2 py-0.5 text-[10px] font-medium text-muted-foreground",
386
- className,
387
- )}
388
- >
431
+ <span className={cn(VISIBILITY_CHIP_CLASS, className)}>
389
432
  <VisibilityIcon tone={option.tone} className="size-2.5" />
390
433
  {option.label}
391
434
  </span>