@softize/opus 16.0.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 +39 -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 +487 -134
  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 -46
  30. package/src/ui/meta.ts +2 -2
  31. package/src/ui/react.tsx +1 -2
@@ -1,20 +1,41 @@
1
- import { useState, type ReactElement, type ReactNode } from "react";
2
- import { Braces, X } from "lucide-react";
1
+ import {
2
+ useCallback,
3
+ useState,
4
+ type ReactElement,
5
+ type ReactNode,
6
+ } from "react";
7
+ import { createPortal } from "react-dom";
8
+ import { ArrowLeft, Braces } from "lucide-react";
3
9
  import type {
10
+ PresentationActionRegistry,
11
+ PresentationBindingContext,
12
+ PresentationCommand,
4
13
  PresentationDiagnostic,
5
14
  PresentationDefinition,
6
15
  PresentationInvocation,
16
+ PresentationJsonValue,
7
17
  PresentationSurface,
8
18
  } from "../../../core/presentation.ts";
9
- 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";
10
31
  import { cn } from "../../lib/cn.ts";
11
32
  import { Button, buttonVariants } from "../primitives/button.tsx";
12
33
  import { ButtonGroup } from "../primitives/button-group.tsx";
13
34
  import { Copyable } from "../primitives/copyable.tsx";
35
+ import { DetailField, DetailGroup } from "../primitives/detail.tsx";
14
36
  import {
15
37
  Dialog,
16
38
  DialogBody,
17
- DialogClose,
18
39
  DialogContent,
19
40
  DialogFooter,
20
41
  DialogHeader,
@@ -23,7 +44,6 @@ import {
23
44
  import {
24
45
  Drawer,
25
46
  DrawerBody,
26
- DrawerClose,
27
47
  DrawerContent,
28
48
  DrawerFooter,
29
49
  DrawerHeader,
@@ -35,29 +55,30 @@ import {
35
55
  PageBody,
36
56
  PageFooter,
37
57
  PageHeader,
38
- PageIntro,
39
58
  PageNavigation,
40
59
  PageTitle,
41
60
  useInsidePageShell,
42
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";
43
66
 
44
- interface PresentationBaseProps {
67
+ interface PresentationFrameProps {
45
68
  surface: PresentationSurface;
46
69
  title: ReactNode;
47
70
  navigation?: ReactNode;
48
71
  headerActions?: ReactNode;
49
72
  footerActions?: ReactNode;
50
- children: ReactNode;
73
+ children: ReactNode | ((footerTarget: HTMLElement | null) => ReactNode);
74
+ hasBodyFooter?: boolean;
75
+ blocked?: boolean;
76
+ open?: boolean;
77
+ onOpenChange?: (open: boolean) => void;
51
78
  className?: string;
52
79
  bodyClassName?: string;
53
80
  }
54
81
 
55
- export type PresentationProps = PresentationBaseProps &
56
- (
57
- | { open?: undefined; onOpenChange?: (open: boolean) => void }
58
- | { open: boolean; onOpenChange: (open: boolean) => void }
59
- );
60
-
61
82
  const bodyClassName =
62
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";
63
84
 
@@ -75,49 +96,46 @@ function PresentationActions({
75
96
  );
76
97
  }
77
98
 
78
- function CloseAction({
79
- surface,
80
- }: {
81
- surface: "dialog" | "drawer";
82
- }): ReactElement {
83
- const button = (
84
- <Button size="icon-sm" variant="ghost" aria-label="Fechar">
85
- <X aria-hidden />
86
- </Button>
87
- );
88
- return surface === "dialog" ? (
89
- <DialogClose asChild>{button}</DialogClose>
90
- ) : (
91
- <DrawerClose asChild>{button}</DrawerClose>
92
- );
93
- }
94
-
95
99
  /** A mesma Presentation em Page, Dialog ou Drawer; a superfície não redefine o recurso. */
96
- export function Presentation({
100
+ function PresentationFrame({
97
101
  surface,
98
102
  title,
99
103
  navigation,
100
104
  headerActions,
101
105
  footerActions,
102
106
  children,
103
- open,
107
+ hasBodyFooter = false,
108
+ blocked = false,
109
+ open = true,
104
110
  onOpenChange,
105
111
  className,
106
112
  bodyClassName: bodyClassNameProp,
107
- }: PresentationProps): ReactElement {
113
+ }: PresentationFrameProps): ReactElement {
108
114
  const insidePageShell = useInsidePageShell();
109
- const [internalOpen, setInternalOpen] = useState(true);
110
- const modalOpen = open ?? internalOpen;
111
- function handleOpenChange(nextOpen: boolean): void {
112
- if (open === undefined) setInternalOpen(nextOpen);
113
- onOpenChange?.(nextOpen);
114
- }
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
+ );
115
133
  const body = (
116
134
  <div
117
135
  data-slot="presentation-body"
118
136
  className={cn(bodyClassName, bodyClassNameProp)}
119
137
  >
120
- {children}
138
+ {typeof children === "function" ? children(footerTarget) : children}
121
139
  </div>
122
140
  );
123
141
 
@@ -125,7 +143,7 @@ export function Presentation({
125
143
  if (insidePageShell) {
126
144
  return (
127
145
  <Page className={cn("flex min-h-full flex-col", className)}>
128
- <PageIntro>
146
+ <PageHeader>
129
147
  {navigation === undefined ? null : (
130
148
  <PageNavigation>{navigation}</PageNavigation>
131
149
  )}
@@ -135,13 +153,9 @@ export function Presentation({
135
153
  <PresentationActions>{headerActions}</PresentationActions>
136
154
  </PageActions>
137
155
  )}
138
- </PageIntro>
156
+ </PageHeader>
139
157
  <PageBody className="min-h-0 flex-1 overflow-y-auto">{body}</PageBody>
140
- {footerActions === undefined ? null : (
141
- <PageFooter>
142
- <PresentationActions>{footerActions}</PresentationActions>
143
- </PageFooter>
144
- )}
158
+ {hasFooter ? <PageFooter>{renderFooterActions()}</PageFooter> : null}
145
159
  </Page>
146
160
  );
147
161
  }
@@ -166,76 +180,408 @@ export function Presentation({
166
180
  <PageBody className="min-h-0 flex-1 overflow-y-auto px-8 py-8">
167
181
  {body}
168
182
  </PageBody>
169
- {footerActions === undefined ? null : (
170
- <PageFooter>
171
- <PresentationActions>{footerActions}</PresentationActions>
172
- </PageFooter>
173
- )}
183
+ {hasFooter ? <PageFooter>{renderFooterActions()}</PageFooter> : null}
174
184
  </Page>
175
185
  );
176
186
  }
177
187
 
178
188
  if (surface === "dialog") {
179
189
  return (
180
- <Dialog open={modalOpen} onOpenChange={handleOpenChange}>
190
+ <Dialog
191
+ open={open}
192
+ onOpenChange={(nextOpen) => {
193
+ if (!nextOpen && blocked) return;
194
+ onOpenChange?.(nextOpen);
195
+ }}
196
+ >
181
197
  <DialogContent
182
198
  className={className}
183
- showCloseButton={false}
184
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
+ }}
185
207
  >
186
208
  <DialogHeader>
187
- <div className="flex items-center justify-between gap-4">
188
- {navigation}
189
- <DialogTitle className="min-w-0 flex-1 truncate">
190
- {title}
191
- </DialogTitle>
192
- <PresentationActions>
193
- {headerActions}
194
- <CloseAction surface="dialog" />
195
- </PresentationActions>
196
- </div>
209
+ {navigation}
210
+ <DialogTitle className="truncate">{title}</DialogTitle>
211
+ {headerActions === undefined ? null : (
212
+ <PresentationActions>{headerActions}</PresentationActions>
213
+ )}
197
214
  </DialogHeader>
198
- <DialogBody className={bodyClassNameProp}>{body}</DialogBody>
199
- {footerActions === undefined ? null : (
200
- <DialogFooter>
201
- <PresentationActions equal>{footerActions}</PresentationActions>
202
- </DialogFooter>
203
- )}
215
+ <DialogBody>{body}</DialogBody>
216
+ {hasFooter ? <DialogFooter>{renderFooterActions(true)}</DialogFooter> : null}
204
217
  </DialogContent>
205
218
  </Dialog>
206
219
  );
207
220
  }
208
221
 
209
222
  return (
210
- <Drawer open={modalOpen} onOpenChange={handleOpenChange}>
223
+ <Drawer
224
+ open={open}
225
+ onOpenChange={(nextOpen) => {
226
+ if (!nextOpen && blocked) return;
227
+ onOpenChange?.(nextOpen);
228
+ }}
229
+ >
211
230
  <DrawerContent
212
231
  className={className}
213
- showCloseButton={false}
214
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
+ }}
215
240
  >
216
241
  <DrawerHeader>
217
- <div className="flex items-center justify-between gap-4">
218
- {navigation}
219
- <DrawerTitle className="min-w-0 flex-1 truncate">
220
- {title}
221
- </DrawerTitle>
222
- <PresentationActions>
223
- {headerActions}
224
- <CloseAction surface="drawer" />
225
- </PresentationActions>
226
- </div>
242
+ {navigation}
243
+ <DrawerTitle className="truncate">{title}</DrawerTitle>
244
+ {headerActions === undefined ? null : (
245
+ <PresentationActions>{headerActions}</PresentationActions>
246
+ )}
227
247
  </DrawerHeader>
228
- <DrawerBody className={bodyClassNameProp}>{body}</DrawerBody>
229
- {footerActions === undefined ? null : (
230
- <DrawerFooter>
231
- <PresentationActions equal>{footerActions}</PresentationActions>
232
- </DrawerFooter>
233
- )}
248
+ <DrawerBody>{body}</DrawerBody>
249
+ {hasFooter ? <DrawerFooter>{renderFooterActions(true)}</DrawerFooter> : null}
234
250
  </DrawerContent>
235
251
  </Drawer>
236
252
  );
237
253
  }
238
254
 
255
+ export interface PresentationProps {
256
+ /** Definição estática registrada e publicada no manifest. */
257
+ definition: PresentationDefinition;
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;
277
+ }
278
+
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;
290
+ }
291
+
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];
295
+ }
296
+
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
+ );
326
+ }
327
+
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
+ }
373
+ },
374
+ [context, invocation, onInvocationChange, onRefresh],
375
+ );
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
+ },
386
+ [],
387
+ );
388
+
389
+ const hasSurfaceBlock = [...loadingCommands].some(
390
+ (active) => active.blocking === "surface",
391
+ );
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
+ );
400
+
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
+ };
422
+
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.`);
427
+ }
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
+ };
467
+
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
+ );
560
+
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
+ );
583
+ }
584
+
239
585
  export interface PresentationInspectorProps {
240
586
  definition: PresentationDefinition | unknown;
241
587
  invocation?: PresentationInvocation | unknown;
@@ -243,11 +589,50 @@ export interface PresentationInspectorProps {
243
589
  diagnostics?: readonly PresentationDiagnostic[];
244
590
  triggerLabel?: string;
245
591
  title?: string;
246
- /** Apresenta o gatilho como FAB; o consumidor continua responsável por posicioná-lo. */
247
- floating?: boolean;
248
592
  className?: string;
249
593
  }
250
594
 
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";
611
+
612
+ return (
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" })}
627
+ >
628
+ Copiar JSON
629
+ </Copyable>
630
+ </DialogFooter>
631
+ </DialogContent>
632
+ </Dialog>
633
+ );
634
+ }
635
+
251
636
  /** Ferramenta de desenvolvimento para conferir e copiar o artefato consumido pelo renderer. */
252
637
  export function PresentationInspector({
253
638
  definition,
@@ -256,61 +641,29 @@ export function PresentationInspector({
256
641
  diagnostics,
257
642
  triggerLabel = "Ver JSON da Presentation",
258
643
  title = "JSON da Presentation",
259
- floating = false,
260
644
  className,
261
645
  }: PresentationInspectorProps): ReactElement {
262
646
  const [open, setOpen] = useState(false);
263
- const [json, setJson] = useState<string | null>(null);
264
-
265
- function inspect(): void {
266
- const snapshot = createPresentationInspectionSnapshot({
267
- definition,
268
- invocation,
269
- resolved,
270
- diagnostics,
271
- });
272
- setJson(JSON.stringify(snapshot, null, 2) ?? "null");
273
- setOpen(true);
274
- }
275
-
276
- function handleOpenChange(nextOpen: boolean): void {
277
- setOpen(nextOpen);
278
- if (!nextOpen) setJson(null);
279
- }
280
647
 
281
648
  return (
282
649
  <>
283
650
  <Button
284
- size={floating ? "icon" : "icon-sm"}
285
- variant={floating ? "outline" : "ghost"}
286
- data-appearance={floating ? "fab" : "inline"}
287
- className={cn(floating && "rounded-full shadow-md", className)}
651
+ size="icon-sm"
652
+ variant="ghost"
653
+ data-appearance="inline"
654
+ className={className}
288
655
  aria-label={triggerLabel}
289
656
  title={triggerLabel}
290
- onClick={inspect}
657
+ onClick={() => setOpen(true)}
291
658
  >
292
659
  <Braces aria-hidden />
293
660
  </Button>
294
- <Dialog open={open} onOpenChange={handleOpenChange}>
295
- <DialogContent className="sm:max-w-3xl" aria-describedby={undefined}>
296
- <DialogHeader>
297
- <DialogTitle>{title}</DialogTitle>
298
- </DialogHeader>
299
- <DialogBody>
300
- <pre className="max-h-[65vh] overflow-auto rounded-lg bg-muted p-4 text-xs leading-relaxed">
301
- {json ?? ""}
302
- </pre>
303
- </DialogBody>
304
- <DialogFooter>
305
- <Copyable
306
- value={json ?? ""}
307
- className={buttonVariants({ variant: "outline" })}
308
- >
309
- Copiar JSON
310
- </Copyable>
311
- </DialogFooter>
312
- </DialogContent>
313
- </Dialog>
661
+ <PresentationInspectionDialog
662
+ registration={{ definition, invocation, resolved, diagnostics }}
663
+ open={open}
664
+ onOpenChange={setOpen}
665
+ title={title}
666
+ />
314
667
  </>
315
668
  );
316
669
  }