@softize/opus 16.1.0 → 17.0.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 (31) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/bin/lib/check.mjs +9 -13
  3. package/bin/lib/copy.mjs +29 -4
  4. package/docs/adr/0005-structural-surfaces-share-an-explicit-anatomy.md +6 -3
  5. package/docs/adr/0009-page-title-does-not-carry-a-counter.md +5 -2
  6. package/docs/adr/0011-page-shell-coordinates-persistent-page-chrome.md +12 -14
  7. package/docs/adr/0012-modal-header-only-names-the-surface.md +8 -4
  8. package/docs/adr/0013-presentation-is-a-portable-action-oriented-artifact.md +7 -4
  9. package/docs/adr/0014-structural-headers-do-not-carry-description.md +35 -0
  10. package/package.json +2 -2
  11. package/registry/skills/build-opus-ui/references/evaluations.md +1 -1
  12. package/registry/skills/build-opus-ui/references/ui-patterns.md +16 -17
  13. package/registry/templates/app/package.json +1 -1
  14. package/src/core/presentation.ts +142 -13
  15. package/src/ui/components/patterns/form.tsx +29 -10
  16. package/src/ui/components/patterns/page.tsx +93 -48
  17. package/src/ui/components/patterns/presentation.tsx +459 -329
  18. package/src/ui/components/patterns/surface-header.tsx +4 -3
  19. package/src/ui/components/patterns/trigger.tsx +8 -1
  20. package/src/ui/components/patterns/view.tsx +2 -2
  21. package/src/ui/components/primitives/command.tsx +82 -42
  22. package/src/ui/components/primitives/dialog.tsx +180 -97
  23. package/src/ui/components/primitives/drawer.tsx +63 -22
  24. package/src/ui/docs/content/command.md +3 -1
  25. package/src/ui/docs/content/content.md +1 -1
  26. package/src/ui/docs/content/dialog.md +9 -9
  27. package/src/ui/docs/content/drawer.md +4 -4
  28. package/src/ui/docs/content/page.md +14 -29
  29. package/src/ui/docs/content/presentation.md +54 -76
  30. package/src/ui/meta.ts +2 -2
  31. package/src/ui/react.tsx +1 -8
@@ -1,47 +1,49 @@
1
1
  import {
2
- createContext,
3
2
  useCallback,
4
- useContext,
5
- useEffect,
6
- useId,
7
- useMemo,
8
- useRef,
9
3
  useState,
10
4
  type ReactElement,
11
5
  type ReactNode,
12
6
  } from "react";
13
- import { Braces, X } from "lucide-react";
7
+ import { createPortal } from "react-dom";
8
+ import { ArrowLeft, Braces } from "lucide-react";
14
9
  import type {
10
+ PresentationActionRegistry,
11
+ PresentationBindingContext,
12
+ PresentationCommand,
15
13
  PresentationDiagnostic,
16
14
  PresentationDefinition,
17
15
  PresentationInvocation,
16
+ PresentationJsonValue,
18
17
  PresentationSurface,
19
18
  } from "../../../core/presentation.ts";
20
- import { createPresentationInspectionSnapshot } from "../../../core/presentation.ts";
19
+ import {
20
+ applyPresentationEffects,
21
+ createPresentationInspectionSnapshot,
22
+ openPresentation,
23
+ resolvePresentationBindings,
24
+ } from "../../../core/presentation.ts";
25
+ import type {
26
+ FormContract,
27
+ ListContract,
28
+ SimpleContract,
29
+ ViewContract,
30
+ } from "../../../core/contracts.ts";
21
31
  import { cn } from "../../lib/cn.ts";
22
32
  import { Button, buttonVariants } from "../primitives/button.tsx";
23
33
  import { ButtonGroup } from "../primitives/button-group.tsx";
24
34
  import { Copyable } from "../primitives/copyable.tsx";
35
+ import { DetailField, DetailGroup } from "../primitives/detail.tsx";
25
36
  import {
26
37
  Dialog,
27
38
  DialogBody,
28
- DialogClose,
29
39
  DialogContent,
30
40
  DialogFooter,
31
41
  DialogHeader,
32
42
  DialogTitle,
33
43
  } from "../primitives/dialog.tsx";
34
- import {
35
- Menu,
36
- MenuContent,
37
- MenuItem,
38
- MenuLabel,
39
- MenuTrigger,
40
- } from "../primitives/menu.tsx";
41
44
  import {
42
45
  Drawer,
43
46
  DrawerBody,
44
- DrawerClose,
45
47
  DrawerContent,
46
48
  DrawerFooter,
47
49
  DrawerHeader,
@@ -53,29 +55,30 @@ import {
53
55
  PageBody,
54
56
  PageFooter,
55
57
  PageHeader,
56
- PageIntro,
57
58
  PageNavigation,
58
59
  PageTitle,
59
60
  useInsidePageShell,
60
61
  } from "./page.tsx";
62
+ import { ActionForm } from "./form.tsx";
63
+ import { ActionList, type ActionListState } from "./list.tsx";
64
+ import { ActionTrigger } from "./trigger.tsx";
65
+ import { ActionView } from "./view.tsx";
61
66
 
62
- interface PresentationBaseProps {
67
+ interface PresentationFrameProps {
63
68
  surface: PresentationSurface;
64
69
  title: ReactNode;
65
70
  navigation?: ReactNode;
66
71
  headerActions?: ReactNode;
67
72
  footerActions?: ReactNode;
68
- children: ReactNode;
73
+ children: ReactNode | ((footerTarget: HTMLElement | null) => ReactNode);
74
+ hasBodyFooter?: boolean;
75
+ blocked?: boolean;
76
+ open?: boolean;
77
+ onOpenChange?: (open: boolean) => void;
69
78
  className?: string;
70
79
  bodyClassName?: string;
71
80
  }
72
81
 
73
- export type PresentationProps = PresentationBaseProps &
74
- (
75
- | { open?: undefined; onOpenChange?: (open: boolean) => void }
76
- | { open: boolean; onOpenChange: (open: boolean) => void }
77
- );
78
-
79
82
  const bodyClassName =
80
83
  "h-full min-h-full [&>[data-slot=data-state]]:flex [&>[data-slot=data-state]]:min-h-full [&>[data-slot=data-state]]:items-center [&>[data-slot=data-state]]:justify-center";
81
84
 
@@ -93,49 +96,46 @@ function PresentationActions({
93
96
  );
94
97
  }
95
98
 
96
- function CloseAction({
97
- surface,
98
- }: {
99
- surface: "dialog" | "drawer";
100
- }): ReactElement {
101
- const button = (
102
- <Button size="icon-sm" variant="ghost" aria-label="Fechar">
103
- <X aria-hidden />
104
- </Button>
105
- );
106
- return surface === "dialog" ? (
107
- <DialogClose asChild>{button}</DialogClose>
108
- ) : (
109
- <DrawerClose asChild>{button}</DrawerClose>
110
- );
111
- }
112
-
113
99
  /** A mesma Presentation em Page, Dialog ou Drawer; a superfície não redefine o recurso. */
114
- export function Presentation({
100
+ function PresentationFrame({
115
101
  surface,
116
102
  title,
117
103
  navigation,
118
104
  headerActions,
119
105
  footerActions,
120
106
  children,
121
- open,
107
+ hasBodyFooter = false,
108
+ blocked = false,
109
+ open = true,
122
110
  onOpenChange,
123
111
  className,
124
112
  bodyClassName: bodyClassNameProp,
125
- }: PresentationProps): ReactElement {
113
+ }: PresentationFrameProps): ReactElement {
126
114
  const insidePageShell = useInsidePageShell();
127
- const [internalOpen, setInternalOpen] = useState(true);
128
- const modalOpen = open ?? internalOpen;
129
- function handleOpenChange(nextOpen: boolean): void {
130
- if (open === undefined) setInternalOpen(nextOpen);
131
- onOpenChange?.(nextOpen);
132
- }
115
+ const [footerTarget, setFooterTarget] = useState<HTMLElement | null>(null);
116
+ const setFooterNode = useCallback((node: HTMLSpanElement | null) => {
117
+ setFooterTarget(node);
118
+ }, []);
119
+ const hasFooter = hasBodyFooter || footerActions !== undefined;
120
+ const renderFooterActions = (
121
+ equal = false,
122
+ ): ReactElement => (
123
+ <PresentationActions equal={equal}>
124
+ {hasBodyFooter ? (
125
+ <span
126
+ ref={setFooterNode}
127
+ className="contents [&>*]:min-w-0 [&>*]:w-full [&>*]:flex-1"
128
+ />
129
+ ) : null}
130
+ {footerActions}
131
+ </PresentationActions>
132
+ );
133
133
  const body = (
134
134
  <div
135
135
  data-slot="presentation-body"
136
136
  className={cn(bodyClassName, bodyClassNameProp)}
137
137
  >
138
- {children}
138
+ {typeof children === "function" ? children(footerTarget) : children}
139
139
  </div>
140
140
  );
141
141
 
@@ -143,7 +143,7 @@ export function Presentation({
143
143
  if (insidePageShell) {
144
144
  return (
145
145
  <Page className={cn("flex min-h-full flex-col", className)}>
146
- <PageIntro>
146
+ <PageHeader>
147
147
  {navigation === undefined ? null : (
148
148
  <PageNavigation>{navigation}</PageNavigation>
149
149
  )}
@@ -153,13 +153,9 @@ export function Presentation({
153
153
  <PresentationActions>{headerActions}</PresentationActions>
154
154
  </PageActions>
155
155
  )}
156
- </PageIntro>
156
+ </PageHeader>
157
157
  <PageBody className="min-h-0 flex-1 overflow-y-auto">{body}</PageBody>
158
- {footerActions === undefined ? null : (
159
- <PageFooter>
160
- <PresentationActions>{footerActions}</PresentationActions>
161
- </PageFooter>
162
- )}
158
+ {hasFooter ? <PageFooter>{renderFooterActions()}</PageFooter> : null}
163
159
  </Page>
164
160
  );
165
161
  }
@@ -184,290 +180,456 @@ export function Presentation({
184
180
  <PageBody className="min-h-0 flex-1 overflow-y-auto px-8 py-8">
185
181
  {body}
186
182
  </PageBody>
187
- {footerActions === undefined ? null : (
188
- <PageFooter>
189
- <PresentationActions>{footerActions}</PresentationActions>
190
- </PageFooter>
191
- )}
183
+ {hasFooter ? <PageFooter>{renderFooterActions()}</PageFooter> : null}
192
184
  </Page>
193
185
  );
194
186
  }
195
187
 
196
188
  if (surface === "dialog") {
197
189
  return (
198
- <Dialog open={modalOpen} onOpenChange={handleOpenChange}>
190
+ <Dialog
191
+ open={open}
192
+ onOpenChange={(nextOpen) => {
193
+ if (!nextOpen && blocked) return;
194
+ onOpenChange?.(nextOpen);
195
+ }}
196
+ >
199
197
  <DialogContent
200
198
  className={className}
201
- showCloseButton={false}
202
199
  aria-describedby={undefined}
200
+ closeDisabled={blocked}
201
+ onEscapeKeyDown={(event) => {
202
+ if (blocked) event.preventDefault();
203
+ }}
204
+ onPointerDownOutside={(event) => {
205
+ if (blocked) event.preventDefault();
206
+ }}
203
207
  >
204
208
  <DialogHeader>
205
- <div className="flex items-center justify-between gap-4">
206
- {navigation}
207
- <DialogTitle className="min-w-0 flex-1 truncate">
208
- {title}
209
- </DialogTitle>
210
- <PresentationActions>
211
- {headerActions}
212
- <CloseAction surface="dialog" />
213
- </PresentationActions>
214
- </div>
209
+ {navigation}
210
+ <DialogTitle className="truncate">{title}</DialogTitle>
211
+ {headerActions === undefined ? null : (
212
+ <PresentationActions>{headerActions}</PresentationActions>
213
+ )}
215
214
  </DialogHeader>
216
- <DialogBody className={bodyClassNameProp}>{body}</DialogBody>
217
- {footerActions === undefined ? null : (
218
- <DialogFooter>
219
- <PresentationActions equal>{footerActions}</PresentationActions>
220
- </DialogFooter>
221
- )}
215
+ <DialogBody>{body}</DialogBody>
216
+ {hasFooter ? <DialogFooter>{renderFooterActions(true)}</DialogFooter> : null}
222
217
  </DialogContent>
223
218
  </Dialog>
224
219
  );
225
220
  }
226
221
 
227
222
  return (
228
- <Drawer open={modalOpen} onOpenChange={handleOpenChange}>
223
+ <Drawer
224
+ open={open}
225
+ onOpenChange={(nextOpen) => {
226
+ if (!nextOpen && blocked) return;
227
+ onOpenChange?.(nextOpen);
228
+ }}
229
+ >
229
230
  <DrawerContent
230
231
  className={className}
231
- showCloseButton={false}
232
232
  aria-describedby={undefined}
233
+ closeDisabled={blocked}
234
+ onEscapeKeyDown={(event) => {
235
+ if (blocked) event.preventDefault();
236
+ }}
237
+ onPointerDownOutside={(event) => {
238
+ if (blocked) event.preventDefault();
239
+ }}
233
240
  >
234
241
  <DrawerHeader>
235
- <div className="flex items-center justify-between gap-4">
236
- {navigation}
237
- <DrawerTitle className="min-w-0 flex-1 truncate">
238
- {title}
239
- </DrawerTitle>
240
- <PresentationActions>
241
- {headerActions}
242
- <CloseAction surface="drawer" />
243
- </PresentationActions>
244
- </div>
242
+ {navigation}
243
+ <DrawerTitle className="truncate">{title}</DrawerTitle>
244
+ {headerActions === undefined ? null : (
245
+ <PresentationActions>{headerActions}</PresentationActions>
246
+ )}
245
247
  </DrawerHeader>
246
- <DrawerBody className={bodyClassNameProp}>{body}</DrawerBody>
247
- {footerActions === undefined ? null : (
248
- <DrawerFooter>
249
- <PresentationActions equal>{footerActions}</PresentationActions>
250
- </DrawerFooter>
251
- )}
248
+ <DrawerBody>{body}</DrawerBody>
249
+ {hasFooter ? <DrawerFooter>{renderFooterActions(true)}</DrawerFooter> : null}
252
250
  </DrawerContent>
253
251
  </Drawer>
254
252
  );
255
253
  }
256
254
 
257
- export interface PresentationInspectorProps {
258
- definition: PresentationDefinition | unknown;
259
- invocation?: PresentationInvocation | unknown;
260
- resolved?: unknown;
261
- diagnostics?: readonly PresentationDiagnostic[];
262
- triggerLabel?: string;
263
- title?: string;
264
- /** Apresenta o gatilho como FAB; o consumidor continua responsável por posicioná-lo. */
265
- floating?: boolean;
266
- className?: string;
267
- }
268
-
269
- export interface PresentationRegistration
270
- extends Pick<
271
- PresentationInspectorProps,
272
- "invocation" | "resolved" | "diagnostics"
273
- > {
255
+ export interface PresentationProps {
256
+ /** Definição estática registrada e publicada no manifest. */
274
257
  definition: PresentationDefinition;
275
- /** Retira temporariamente a Presentation do inventário ativo sem desmontar o consumidor. */
276
- enabled?: boolean;
277
- }
278
-
279
- interface ActivePresentationRegistration
280
- extends Omit<PresentationRegistration, "enabled"> {
281
- key: string;
258
+ /** Definições alcançáveis por open, navigate ou back. */
259
+ definitions: readonly PresentationDefinition[];
260
+ /** Contratos compartilháveis das actions referenciadas pela definição. */
261
+ actions: PresentationActionRegistry;
262
+ /** Estado serializável da exibição atual. */
263
+ invocation: PresentationInvocation;
264
+ /** Dados disponíveis para resolver bindings além do input da invocação. */
265
+ bindingContext?: PresentationBindingContext;
266
+ /** Recebe navegação, retorno e fechamento produzidos pelo artefato. */
267
+ onInvocationChange?: (invocation: PresentationInvocation | null) => void;
268
+ /** Recebe cada invalidação declarada por um efeito refresh. */
269
+ onRefresh?: (action: string | null) => void;
270
+ /** Estado de recorte da action list quando a aplicação o sincroniza com a URL. */
271
+ listState?: ActionListState;
272
+ onListStateChange?: (state: ActionListState) => void;
273
+ open?: boolean;
274
+ onOpenChange?: (open: boolean) => void;
275
+ className?: string;
276
+ bodyClassName?: string;
282
277
  }
283
278
 
284
- interface PresentationRegistrationContextValue {
285
- upsert: (key: string, registration: ActivePresentationRegistration) => void;
286
- remove: (key: string) => void;
279
+ function actionLabel(action: { name: string; label?: unknown }): string {
280
+ if (typeof action.label === "string") return action.label;
281
+ if (
282
+ action.label !== null &&
283
+ typeof action.label === "object" &&
284
+ "default" in action.label &&
285
+ typeof (action.label as { default?: unknown }).default === "string"
286
+ ) {
287
+ return (action.label as { default: string }).default;
288
+ }
289
+ return action.name;
287
290
  }
288
291
 
289
- interface PresentationDevtoolsContextValue {
290
- keys: readonly string[];
291
- read: (key: string) => ActivePresentationRegistration | undefined;
292
+ function readField(record: unknown, key: string): unknown {
293
+ if (record === null || typeof record !== "object") return undefined;
294
+ return (record as Record<string, unknown>)[key];
292
295
  }
293
296
 
294
- const PresentationRegistrationContext =
295
- createContext<PresentationRegistrationContextValue | null>(null);
296
- const PresentationDevtoolsContext =
297
- createContext<PresentationDevtoolsContextValue | null>(null);
298
-
299
- export interface PresentationDevtoolsProviderProps {
300
- children: ReactNode;
297
+ function PresentationCommandTrigger({
298
+ command,
299
+ action,
300
+ input,
301
+ disabled,
302
+ onLoadingChange,
303
+ onSuccess,
304
+ }: {
305
+ command: PresentationCommand;
306
+ action: SimpleContract<Record<string, unknown>, unknown>;
307
+ input: Record<string, unknown>;
308
+ disabled: boolean;
309
+ onLoadingChange: (command: PresentationCommand, loading: boolean) => void;
310
+ onSuccess: (data: unknown) => void;
311
+ }): ReactElement {
312
+ const reportLoading = useCallback(
313
+ (loading: boolean) => onLoadingChange(command, loading),
314
+ [command, onLoadingChange],
315
+ );
316
+ return (
317
+ <ActionTrigger
318
+ action={action}
319
+ input={input}
320
+ variant="ghost"
321
+ disabled={disabled}
322
+ onLoadingChange={reportLoading}
323
+ onSuccess={onSuccess}
324
+ />
325
+ );
301
326
  }
302
327
 
303
- /** Mantém o inventário das Presentations montadas para uma única ferramenta do shell. */
304
- export function PresentationDevtoolsProvider({
305
- children,
306
- }: PresentationDevtoolsProviderProps): ReactElement {
307
- const registrations = useRef(
308
- new Map<string, ActivePresentationRegistration>(),
309
- );
310
- const [keys, setKeys] = useState<readonly string[]>([]);
311
- const upsert = useCallback(
312
- (key: string, registration: ActivePresentationRegistration): void => {
313
- const exists = registrations.current.has(key);
314
- registrations.current.set(key, registration);
315
- if (!exists) setKeys((current) => [...current, key]);
328
+ /** Renderiza uma definição declarativa delegando cada kind ao pattern Opus correspondente. */
329
+ export function Presentation({
330
+ definition,
331
+ definitions,
332
+ actions,
333
+ invocation,
334
+ bindingContext,
335
+ onInvocationChange,
336
+ onRefresh,
337
+ listState,
338
+ onListStateChange,
339
+ open = true,
340
+ onOpenChange,
341
+ className,
342
+ bodyClassName: bodyClassNameProp,
343
+ }: PresentationProps): ReactElement {
344
+ if (definition.id !== invocation.presentationId) {
345
+ throw new Error(
346
+ `A invocação “${invocation.presentationId}” não corresponde à Presentation “${definition.id}”.`,
347
+ );
348
+ }
349
+ const [loadingCommands, setLoadingCommands] = useState<
350
+ ReadonlySet<PresentationCommand>
351
+ >(() => new Set());
352
+ const [bodyLoading, setBodyLoading] = useState(false);
353
+ const context: PresentationBindingContext = {
354
+ ...bindingContext,
355
+ route: bindingContext?.route ?? invocation.input,
356
+ };
357
+
358
+ const applyEffects = useCallback(
359
+ (
360
+ effects: PresentationDefinition["body"]["onSuccess"],
361
+ result?: unknown,
362
+ ): void => {
363
+ const next = applyPresentationEffects(invocation, effects, {
364
+ ...context,
365
+ ...(result !== undefined && result !== null && typeof result === "object"
366
+ ? { result: result as Record<string, unknown> }
367
+ : {}),
368
+ });
369
+ for (const action of next.refresh) onRefresh?.(action);
370
+ if (next.invocation !== invocation || next.exit !== undefined) {
371
+ onInvocationChange?.(next.invocation);
372
+ }
316
373
  },
317
- [],
374
+ [context, invocation, onInvocationChange, onRefresh],
318
375
  );
319
- const remove = useCallback((key: string): void => {
320
- if (!registrations.current.delete(key)) return;
321
- setKeys((current) => current.filter((candidate) => candidate !== key));
322
- }, []);
323
- const read = useCallback(
324
- (key: string) => registrations.current.get(key),
376
+
377
+ const reportLoading = useCallback(
378
+ (command: PresentationCommand, loading: boolean): void => {
379
+ setLoadingCommands((current) => {
380
+ const next = new Set(current);
381
+ if (loading) next.add(command);
382
+ else next.delete(command);
383
+ return next;
384
+ });
385
+ },
325
386
  [],
326
387
  );
327
- const registration = useMemo(() => ({ upsert, remove }), [upsert, remove]);
328
- const devtools = useMemo(() => ({ keys, read }), [keys, read]);
329
388
 
330
- return (
331
- <PresentationRegistrationContext.Provider value={registration}>
332
- <PresentationDevtoolsContext.Provider value={devtools}>
333
- {children}
334
- </PresentationDevtoolsContext.Provider>
335
- </PresentationRegistrationContext.Provider>
389
+ const hasSurfaceBlock = [...loadingCommands].some(
390
+ (active) => active.blocking === "surface",
336
391
  );
337
- }
392
+ const surfaceBlocked = bodyLoading || hasSurfaceBlock;
393
+ const isBlocked = (command: PresentationCommand): boolean =>
394
+ bodyLoading ||
395
+ [...loadingCommands].some(
396
+ (active) =>
397
+ active.blocking === "surface" ||
398
+ (active.blocking === "group" && active.placement === command.placement),
399
+ );
338
400
 
339
- /** Anuncia um snapshot ao provider mais próximo; fora dele, permanece inofensivo. */
340
- export function usePresentationRegistration({
341
- definition,
342
- invocation,
343
- resolved,
344
- diagnostics,
345
- enabled = true,
346
- }: PresentationRegistration): void {
347
- const context = useContext(PresentationRegistrationContext);
348
- const upsert = context?.upsert;
349
- const remove = context?.remove;
350
- const key = useId();
401
+ const openTarget = (
402
+ presentationId: string,
403
+ surface: PresentationSurface | undefined,
404
+ bindings: PresentationCommand["input"],
405
+ targetContext: PresentationBindingContext = context,
406
+ ): void => {
407
+ const target = definitions.find((candidate) => candidate.id === presentationId);
408
+ if (target === undefined) {
409
+ throw new Error(`Presentation “${presentationId}” não registrada.`);
410
+ }
411
+ onInvocationChange?.(
412
+ openPresentation(invocation, {
413
+ presentationId: target.id,
414
+ surface: surface ?? invocation.surface,
415
+ input: resolvePresentationBindings(bindings, targetContext) as Record<
416
+ string,
417
+ PresentationJsonValue
418
+ >,
419
+ }),
420
+ );
421
+ };
351
422
 
352
- // Sem dependências de propósito: os dados podem mudar mantendo a mesma Presentation.
353
- // `upsert` substitui o snapshot em memória sem provocar um ciclo de render.
354
- useEffect(() => {
355
- if (upsert === undefined || !enabled) {
356
- remove?.(key);
357
- return;
423
+ const renderCommand = (command: PresentationCommand): ReactElement => {
424
+ const action = actions[command.action];
425
+ if (action === undefined) {
426
+ throw new Error(`Action “${command.action}” não registrada.`);
358
427
  }
359
- upsert(key, { key, definition, invocation, resolved, diagnostics });
360
- });
428
+ const input = resolvePresentationBindings(command.input, context) as Record<
429
+ string,
430
+ unknown
431
+ >;
432
+ if (action.kind === "simple") {
433
+ return (
434
+ <PresentationCommandTrigger
435
+ key={`${command.placement}:${command.action}`}
436
+ command={command}
437
+ action={action as SimpleContract<Record<string, unknown>, unknown>}
438
+ input={input}
439
+ disabled={isBlocked(command)}
440
+ onLoadingChange={reportLoading}
441
+ onSuccess={(data) => applyEffects(command.onSuccess, data)}
442
+ />
443
+ );
444
+ }
445
+ if (command.target === undefined) {
446
+ throw new Error(
447
+ `A action “${command.action}” precisa declarar uma Presentation de destino.`,
448
+ );
449
+ }
450
+ return (
451
+ <Button
452
+ key={`${command.placement}:${command.action}`}
453
+ variant="ghost"
454
+ disabled={isBlocked(command)}
455
+ onClick={() =>
456
+ openTarget(
457
+ command.target!.presentation,
458
+ command.target!.surface,
459
+ command.input,
460
+ )
461
+ }
462
+ >
463
+ {actionLabel(action)}
464
+ </Button>
465
+ );
466
+ };
361
467
 
362
- useEffect(
363
- () => () => {
364
- remove?.(key);
365
- },
366
- [key, remove],
367
- );
368
- }
468
+ const bodyAction = actions[definition.body.action];
469
+ if (bodyAction === undefined) {
470
+ throw new Error(`Action “${definition.body.action}” não registrada.`);
471
+ }
472
+ const bodyInput = resolvePresentationBindings(
473
+ definition.body.input,
474
+ context,
475
+ ) as Record<string, unknown>;
476
+ const renderBody = (footerTarget: HTMLElement | null): ReactNode => {
477
+ if (bodyAction.kind === "form") {
478
+ return (
479
+ <ActionForm
480
+ action={bodyAction as FormContract<Record<string, unknown>, unknown>}
481
+ defaultValues={bodyInput}
482
+ submitLabel={definition.body.submitLabel}
483
+ onSuccess={(data) => applyEffects(definition.body.onSuccess, data)}
484
+ disabled={hasSurfaceBlock}
485
+ onLoadingChange={setBodyLoading}
486
+ footer={(actions) =>
487
+ footerTarget === null ? null : createPortal(actions, footerTarget)
488
+ }
489
+ />
490
+ );
491
+ }
492
+ if (bodyAction.kind === "list") {
493
+ return (
494
+ <ActionList<Record<string, unknown>, Record<string, unknown>>
495
+ action={bodyAction as ListContract<
496
+ Record<string, unknown>,
497
+ Record<string, unknown>
498
+ >}
499
+ input={bodyInput}
500
+ state={listState}
501
+ onStateChange={onListStateChange}
502
+ {...(definition.body.open === undefined
503
+ ? {}
504
+ : {
505
+ onRowClick: (item: Record<string, unknown>) =>
506
+ openTarget(
507
+ definition.body.open!.presentation,
508
+ definition.body.open!.surface,
509
+ definition.body.open!.input,
510
+ { ...context, item },
511
+ ),
512
+ })}
513
+ />
514
+ );
515
+ }
516
+ if (bodyAction.kind === "view") {
517
+ const fields = definition.body.fields ?? [];
518
+ return (
519
+ <ActionView
520
+ action={bodyAction as ViewContract<Record<string, unknown>, unknown>}
521
+ input={bodyInput}
522
+ >
523
+ {(data) => (
524
+ <DetailGroup>
525
+ {fields.map((field) => (
526
+ <DetailField
527
+ key={field.key}
528
+ label={field.label}
529
+ value={readField(data, field.key) as ReactNode}
530
+ empty={field.empty}
531
+ />
532
+ ))}
533
+ </DetailGroup>
534
+ )}
535
+ </ActionView>
536
+ );
537
+ }
538
+ throw new Error(`A action simple “${bodyAction.name}” não pode ocupar o body.`);
539
+ };
540
+
541
+ const headerActions = definition.actions
542
+ .filter((command) => command.placement === "header")
543
+ .map(renderCommand);
544
+ const footerActions = definition.actions
545
+ .filter((command) => command.placement === "footer")
546
+ .map(renderCommand);
547
+ const navigation =
548
+ invocation.stack.length === 0 ? undefined : (
549
+ <Button
550
+ size="icon-sm"
551
+ variant="ghost"
552
+ disabled={surfaceBlocked}
553
+ aria-label="Voltar"
554
+ title="Voltar"
555
+ onClick={() => applyEffects([{ effect: "back" }])}
556
+ >
557
+ <ArrowLeft aria-hidden />
558
+ </Button>
559
+ );
369
560
 
370
- interface SelectedPresentation {
371
- key: string;
372
- title: string;
373
- json: string;
561
+ return (
562
+ <PresentationFrame
563
+ surface={invocation.surface}
564
+ title={definition.title}
565
+ navigation={navigation}
566
+ headerActions={headerActions.length === 0 ? undefined : headerActions}
567
+ footerActions={footerActions.length === 0 ? undefined : footerActions}
568
+ open={open}
569
+ onOpenChange={(nextOpen) => {
570
+ onOpenChange?.(nextOpen);
571
+ if (!nextOpen && invocation.surface !== "page") {
572
+ applyEffects([{ effect: "close" }]);
573
+ }
574
+ }}
575
+ className={className}
576
+ bodyClassName={bodyClassNameProp}
577
+ hasBodyFooter={bodyAction.kind === "form"}
578
+ blocked={surfaceBlocked}
579
+ >
580
+ {renderBody}
581
+ </PresentationFrame>
582
+ );
374
583
  }
375
584
 
376
- export interface PresentationDevtoolsProps {
377
- /** Nome acessível e tooltip do launcher. */
585
+ export interface PresentationInspectorProps {
586
+ definition: PresentationDefinition | unknown;
587
+ invocation?: PresentationInvocation | unknown;
588
+ resolved?: unknown;
589
+ diagnostics?: readonly PresentationDiagnostic[];
378
590
  triggerLabel?: string;
591
+ title?: string;
379
592
  className?: string;
380
593
  }
381
594
 
382
- /** Launcher único do shell para escolher e inspecionar qualquer Presentation ativa. */
383
- export function PresentationDevtools({
384
- triggerLabel = "Inspecionar Presentations ativas",
385
- className,
386
- }: PresentationDevtoolsProps): ReactElement | null {
387
- const registry = useContext(PresentationDevtoolsContext);
388
- const [selected, setSelected] = useState<SelectedPresentation | null>(null);
389
- const [menuOpen, setMenuOpen] = useState(false);
390
- const registrations =
391
- registry?.keys.flatMap((key) => {
392
- const registration = registry.read(key);
393
- return registration === undefined ? [] : [registration];
394
- }) ?? [];
395
-
396
- useEffect(() => {
397
- if (selected !== null && !registry?.keys.includes(selected.key)) {
398
- setSelected(null);
399
- }
400
- }, [registry?.keys, selected]);
401
-
402
- if (registrations.length === 0) return null;
403
-
404
- function inspect(key: string): void {
405
- const registration = registry?.read(key);
406
- if (registration === undefined) return;
407
- const json =
408
- JSON.stringify(createPresentationInspectionSnapshot(registration), null, 2) ??
409
- "null";
410
- setSelected({ key, title: registration.definition.title, json });
411
- }
595
+ function PresentationInspectionDialog({
596
+ registration,
597
+ open,
598
+ onOpenChange,
599
+ title = "JSON da Presentation",
600
+ }: {
601
+ registration: Pick<
602
+ PresentationInspectorProps,
603
+ "definition" | "invocation" | "resolved" | "diagnostics"
604
+ >;
605
+ open: boolean;
606
+ onOpenChange: (open: boolean) => void;
607
+ title?: string;
608
+ }): ReactElement {
609
+ const snapshot = createPresentationInspectionSnapshot(registration);
610
+ const json = JSON.stringify(snapshot, null, 2) ?? "null";
412
611
 
413
612
  return (
414
- <>
415
- <Menu open={menuOpen} onOpenChange={setMenuOpen}>
416
- <MenuTrigger asChild>
417
- <Button
418
- size="icon"
419
- variant="outline"
420
- data-slot="presentation-devtools"
421
- data-appearance="fab"
422
- className={cn("rounded-full shadow-md", className)}
423
- aria-label={triggerLabel}
424
- title={triggerLabel}
613
+ <Dialog open={open} onOpenChange={onOpenChange}>
614
+ <DialogContent className="sm:max-w-3xl" aria-describedby={undefined}>
615
+ <DialogHeader>
616
+ <DialogTitle>{title}</DialogTitle>
617
+ </DialogHeader>
618
+ <DialogBody>
619
+ <pre className="max-h-[65vh] overflow-auto rounded-lg bg-muted p-4 text-xs leading-relaxed">
620
+ {json}
621
+ </pre>
622
+ </DialogBody>
623
+ <DialogFooter>
624
+ <Copyable
625
+ value={json}
626
+ className={buttonVariants({ variant: "outline" })}
425
627
  >
426
- <Braces aria-hidden />
427
- </Button>
428
- </MenuTrigger>
429
- <MenuContent side="left" align="center" className="min-w-64">
430
- <MenuLabel>Presentations ativas</MenuLabel>
431
- {[...registrations].reverse().map((registration) => (
432
- <MenuItem key={registration.key} onSelect={() => inspect(registration.key)}>
433
- <span className="min-w-0 flex-1 truncate">
434
- {registration.definition.title}
435
- </span>
436
- <span className="text-xs text-muted-foreground">
437
- {registration.invocation === undefined ||
438
- registration.invocation === null ||
439
- typeof registration.invocation !== "object" ||
440
- !("surface" in registration.invocation)
441
- ? "Presentation"
442
- : String(registration.invocation.surface)}
443
- </span>
444
- </MenuItem>
445
- ))}
446
- </MenuContent>
447
- </Menu>
448
- {selected === null ? null : (
449
- <Dialog open onOpenChange={(open) => !open && setSelected(null)}>
450
- <DialogContent className="sm:max-w-3xl" aria-describedby={undefined}>
451
- <DialogHeader>
452
- <DialogTitle>{selected.title}</DialogTitle>
453
- </DialogHeader>
454
- <DialogBody>
455
- <pre className="max-h-[65vh] overflow-auto rounded-lg bg-muted p-4 text-xs leading-relaxed">
456
- {selected.json}
457
- </pre>
458
- </DialogBody>
459
- <DialogFooter>
460
- <Copyable
461
- value={selected.json}
462
- className={buttonVariants({ variant: "outline" })}
463
- >
464
- Copiar JSON
465
- </Copyable>
466
- </DialogFooter>
467
- </DialogContent>
468
- </Dialog>
469
- )}
470
- </>
628
+ Copiar JSON
629
+ </Copyable>
630
+ </DialogFooter>
631
+ </DialogContent>
632
+ </Dialog>
471
633
  );
472
634
  }
473
635
 
@@ -479,61 +641,29 @@ export function PresentationInspector({
479
641
  diagnostics,
480
642
  triggerLabel = "Ver JSON da Presentation",
481
643
  title = "JSON da Presentation",
482
- floating = false,
483
644
  className,
484
645
  }: PresentationInspectorProps): ReactElement {
485
646
  const [open, setOpen] = useState(false);
486
- const [json, setJson] = useState<string | null>(null);
487
-
488
- function inspect(): void {
489
- const snapshot = createPresentationInspectionSnapshot({
490
- definition,
491
- invocation,
492
- resolved,
493
- diagnostics,
494
- });
495
- setJson(JSON.stringify(snapshot, null, 2) ?? "null");
496
- setOpen(true);
497
- }
498
-
499
- function handleOpenChange(nextOpen: boolean): void {
500
- setOpen(nextOpen);
501
- if (!nextOpen) setJson(null);
502
- }
503
647
 
504
648
  return (
505
649
  <>
506
650
  <Button
507
- size={floating ? "icon" : "icon-sm"}
508
- variant={floating ? "outline" : "ghost"}
509
- data-appearance={floating ? "fab" : "inline"}
510
- className={cn(floating && "rounded-full shadow-md", className)}
651
+ size="icon-sm"
652
+ variant="ghost"
653
+ data-appearance="inline"
654
+ className={className}
511
655
  aria-label={triggerLabel}
512
656
  title={triggerLabel}
513
- onClick={inspect}
657
+ onClick={() => setOpen(true)}
514
658
  >
515
659
  <Braces aria-hidden />
516
660
  </Button>
517
- <Dialog open={open} onOpenChange={handleOpenChange}>
518
- <DialogContent className="sm:max-w-3xl" aria-describedby={undefined}>
519
- <DialogHeader>
520
- <DialogTitle>{title}</DialogTitle>
521
- </DialogHeader>
522
- <DialogBody>
523
- <pre className="max-h-[65vh] overflow-auto rounded-lg bg-muted p-4 text-xs leading-relaxed">
524
- {json ?? ""}
525
- </pre>
526
- </DialogBody>
527
- <DialogFooter>
528
- <Copyable
529
- value={json ?? ""}
530
- className={buttonVariants({ variant: "outline" })}
531
- >
532
- Copiar JSON
533
- </Copyable>
534
- </DialogFooter>
535
- </DialogContent>
536
- </Dialog>
661
+ <PresentationInspectionDialog
662
+ registration={{ definition, invocation, resolved, diagnostics }}
663
+ open={open}
664
+ onOpenChange={setOpen}
665
+ title={title}
666
+ />
537
667
  </>
538
668
  );
539
669
  }