@softize/opus 17.2.0 → 18.0.1

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 (46) hide show
  1. package/CHANGELOG.md +62 -1
  2. package/bin/lib/check.mjs +212 -45
  3. package/docs/adr/0010-page-header-owns-page-chrome.md +2 -2
  4. package/docs/adr/0011-page-shell-coordinates-persistent-page-chrome.md +4 -0
  5. package/docs/adr/0013-presentation-is-a-portable-action-oriented-artifact.md +14 -2
  6. package/docs/adr/0015-action-size-follows-interaction-density.md +1 -1
  7. package/docs/adr/0016-list-collection-header-belongs-to-content.md +80 -0
  8. package/package.json +1 -1
  9. package/registry/skills/build-opus-ui/SKILL.md +30 -20
  10. package/registry/skills/build-opus-ui/references/evaluations.md +9 -3
  11. package/registry/skills/build-opus-ui/references/ui-patterns.md +30 -18
  12. package/src/core/presentation.ts +223 -24
  13. package/src/core/runtime.ts +3 -0
  14. package/src/core/types.ts +2 -0
  15. package/src/mcp/index.ts +1 -0
  16. package/src/ui/components/patterns/action-form-card.tsx +8 -1
  17. package/src/ui/components/patterns/confirm.tsx +194 -157
  18. package/src/ui/components/patterns/content-header.tsx +17 -2
  19. package/src/ui/components/patterns/form-dialog.tsx +28 -14
  20. package/src/ui/components/patterns/form.tsx +340 -222
  21. package/src/ui/components/patterns/list.tsx +43 -44
  22. package/src/ui/components/patterns/page-heading-context.tsx +34 -0
  23. package/src/ui/components/patterns/page-state.tsx +2 -0
  24. package/src/ui/components/patterns/page.tsx +165 -51
  25. package/src/ui/components/patterns/presentation.tsx +140 -84
  26. package/src/ui/components/patterns/surface-header.tsx +5 -6
  27. package/src/ui/components/patterns/trigger.tsx +113 -83
  28. package/src/ui/components/primitives/button.tsx +2 -2
  29. package/src/ui/components/primitives/chat.tsx +19 -5
  30. package/src/ui/components/primitives/control.ts +9 -3
  31. package/src/ui/components/primitives/dialog.tsx +16 -9
  32. package/src/ui/components/primitives/drawer.tsx +9 -6
  33. package/src/ui/docs/content/action-form-card.md +9 -8
  34. package/src/ui/docs/content/action-form-dialog.md +11 -12
  35. package/src/ui/docs/content/action-form.md +25 -25
  36. package/src/ui/docs/content/action-list.md +101 -70
  37. package/src/ui/docs/content/action-trigger.md +2 -2
  38. package/src/ui/docs/content/button.md +1 -1
  39. package/src/ui/docs/content/chat.md +4 -4
  40. package/src/ui/docs/content/content.md +29 -13
  41. package/src/ui/docs/content/dialog.md +27 -21
  42. package/src/ui/docs/content/drawer.md +8 -6
  43. package/src/ui/docs/content/page.md +43 -50
  44. package/src/ui/docs/content/presentation.md +39 -28
  45. package/src/ui/docs/doc-client.tsx +1 -1
  46. package/src/ui/meta.ts +4 -4
@@ -68,6 +68,13 @@ import { ActionForm } from "./form.tsx";
68
68
  import { ActionList, type ActionListState } from "./list.tsx";
69
69
  import { ActionTrigger } from "./trigger.tsx";
70
70
  import { ActionView } from "./view.tsx";
71
+ import {
72
+ Content,
73
+ ContentActions,
74
+ ContentBody,
75
+ ContentHeader,
76
+ ContentTitle,
77
+ } from "./content-header.tsx";
71
78
 
72
79
  interface PresentationFrameProps {
73
80
  surface: PresentationSurface;
@@ -82,6 +89,7 @@ interface PresentationFrameProps {
82
89
  onOpenChange?: (open: boolean) => void;
83
90
  className?: string;
84
91
  bodyClassName?: string;
92
+ listBody?: boolean;
85
93
  }
86
94
 
87
95
  const bodyClassName =
@@ -115,6 +123,7 @@ function PresentationFrame({
115
123
  onOpenChange,
116
124
  className,
117
125
  bodyClassName: bodyClassNameProp,
126
+ listBody = false,
118
127
  }: PresentationFrameProps): ReactElement {
119
128
  const insidePageShell = useInsidePageShell();
120
129
  const [footerTarget, setFooterTarget] = useState<HTMLElement | null>(null);
@@ -122,9 +131,7 @@ function PresentationFrame({
122
131
  setFooterTarget(node);
123
132
  }, []);
124
133
  const hasFooter = hasBodyFooter || footerActions !== undefined;
125
- const renderFooterActions = (
126
- equal = false,
127
- ): ReactElement => (
134
+ const renderFooterActions = (equal = false): ReactElement => (
128
135
  <PresentationActions equal={equal}>
129
136
  {hasBodyFooter ? (
130
137
  <span
@@ -145,19 +152,51 @@ function PresentationFrame({
145
152
  );
146
153
 
147
154
  if (surface === "page") {
155
+ if (listBody) {
156
+ const content = (
157
+ <Content level={1} variant="page">
158
+ <ContentHeader>
159
+ <ContentTitle>{title}</ContentTitle>
160
+ {headerActions === undefined ? null : (
161
+ <ContentActions>
162
+ <PresentationActions>{headerActions}</PresentationActions>
163
+ </ContentActions>
164
+ )}
165
+ </ContentHeader>
166
+ <ContentBody>{body}</ContentBody>
167
+ </Content>
168
+ );
169
+ return (
170
+ <Page className={cn("flex min-h-full flex-col", className)}>
171
+ {navigation === undefined ? null : (
172
+ <PageHeader>
173
+ <PageNavigation>{navigation}</PageNavigation>
174
+ </PageHeader>
175
+ )}
176
+ <PageBody className="min-h-0 flex-1 overflow-y-auto">
177
+ {content}
178
+ </PageBody>
179
+ {hasFooter ? <PageFooter>{renderFooterActions()}</PageFooter> : null}
180
+ </Page>
181
+ );
182
+ }
148
183
  if (insidePageShell) {
149
184
  return (
150
185
  <Page className={cn("flex min-h-full flex-col", className)}>
186
+ {navigation === undefined && headerActions === undefined ? null : (
187
+ <PageHeader>
188
+ {navigation === undefined ? null : (
189
+ <PageNavigation>{navigation}</PageNavigation>
190
+ )}
191
+ {headerActions === undefined ? null : (
192
+ <PageActions>
193
+ <PresentationActions>{headerActions}</PresentationActions>
194
+ </PageActions>
195
+ )}
196
+ </PageHeader>
197
+ )}
151
198
  <PageIntro>
152
- {navigation === undefined ? null : (
153
- <PageNavigation>{navigation}</PageNavigation>
154
- )}
155
199
  <PageTitle>{title}</PageTitle>
156
- {headerActions === undefined ? null : (
157
- <PageActions>
158
- <PresentationActions>{headerActions}</PresentationActions>
159
- </PageActions>
160
- )}
161
200
  </PageIntro>
162
201
  <PageBody className="min-h-0 flex-1 overflow-y-auto">{body}</PageBody>
163
202
  {hasFooter ? <PageFooter>{renderFooterActions()}</PageFooter> : null}
@@ -171,7 +210,7 @@ function PresentationFrame({
171
210
  className,
172
211
  )}
173
212
  >
174
- <PageHeader className="border-b px-3 py-4">
213
+ <PageHeader>
175
214
  {navigation === undefined ? null : (
176
215
  <PageNavigation>{navigation}</PageNavigation>
177
216
  )}
@@ -218,7 +257,9 @@ function PresentationFrame({
218
257
  )}
219
258
  </DialogHeader>
220
259
  <DialogBody>{body}</DialogBody>
221
- {hasFooter ? <DialogFooter>{renderFooterActions(true)}</DialogFooter> : null}
260
+ {hasFooter ? (
261
+ <DialogFooter>{renderFooterActions(true)}</DialogFooter>
262
+ ) : null}
222
263
  </DialogContent>
223
264
  </Dialog>
224
265
  );
@@ -251,7 +292,9 @@ function PresentationFrame({
251
292
  )}
252
293
  </DrawerHeader>
253
294
  <DrawerBody>{body}</DrawerBody>
254
- {hasFooter ? <DrawerFooter>{renderFooterActions(true)}</DrawerFooter> : null}
295
+ {hasFooter ? (
296
+ <DrawerFooter>{renderFooterActions(true)}</DrawerFooter>
297
+ ) : null}
255
298
  </DrawerContent>
256
299
  </Drawer>
257
300
  );
@@ -368,18 +411,25 @@ export function Presentation({
368
411
  effects: PresentationDefinition["body"]["onSuccess"],
369
412
  result?: unknown,
370
413
  ): void => {
371
- const next = applyPresentationEffects(invocation, effects, {
372
- ...context,
373
- ...(result !== undefined && result !== null && typeof result === "object"
374
- ? { result: result as Record<string, unknown> }
375
- : {}),
376
- });
414
+ const next = applyPresentationEffects(
415
+ invocation,
416
+ effects,
417
+ {
418
+ ...context,
419
+ ...(result !== undefined &&
420
+ result !== null &&
421
+ typeof result === "object"
422
+ ? { result: result as Record<string, unknown> }
423
+ : {}),
424
+ },
425
+ definitions,
426
+ );
377
427
  for (const action of next.refresh) onRefresh?.(action);
378
428
  if (next.invocation !== invocation || next.exit !== undefined) {
379
429
  onInvocationChange?.(next.invocation);
380
430
  }
381
431
  },
382
- [context, invocation, onInvocationChange, onRefresh],
432
+ [context, definitions, invocation, onInvocationChange, onRefresh],
383
433
  );
384
434
 
385
435
  const closeSurface = useCallback((): void => {
@@ -417,14 +467,16 @@ export function Presentation({
417
467
  bindings: PresentationCommand["input"],
418
468
  targetContext: PresentationBindingContext = context,
419
469
  ): void => {
420
- const target = definitions.find((candidate) => candidate.id === presentationId);
470
+ const target = definitions.find(
471
+ (candidate) => candidate.id === presentationId,
472
+ );
421
473
  if (target === undefined) {
422
474
  throw new Error(`Presentation “${presentationId}” não registrada.`);
423
475
  }
424
476
  onInvocationChange?.(
425
477
  openPresentation(invocation, {
426
478
  presentationId: target.id,
427
- surface: surface ?? invocation.surface,
479
+ surface: surface ?? target.route?.surface ?? invocation.surface,
428
480
  input: resolvePresentationBindings(bindings, targetContext) as Record<
429
481
  string,
430
482
  PresentationJsonValue
@@ -433,7 +485,10 @@ export function Presentation({
433
485
  );
434
486
  };
435
487
 
436
- const renderCommand = (command: PresentationCommand): ReactElement => {
488
+ const renderCommand = (
489
+ command: PresentationCommand,
490
+ size: ButtonSize = "default",
491
+ ): ReactElement => {
437
492
  const action = actions[command.action];
438
493
  if (action === undefined) {
439
494
  throw new Error(`Action “${command.action}” não registrada.`);
@@ -445,7 +500,6 @@ export function Presentation({
445
500
  // Header e footer são regiões de decisão da superfície, não toolbars operacionais.
446
501
  // Seus comandos textuais usam a linha padrão; listas e grids continuam donos da
447
502
  // densidade compacta de suas próprias ações.
448
- const size: ButtonSize = "default";
449
503
  if (action.kind === "simple") {
450
504
  return (
451
505
  <PresentationCommandTrigger
@@ -465,6 +519,7 @@ export function Presentation({
465
519
  `A action “${command.action}” precisa declarar uma Presentation de destino.`,
466
520
  );
467
521
  }
522
+ const label = actionLabel(action);
468
523
  return (
469
524
  <Button
470
525
  key={`${command.placement}:${command.action}`}
@@ -478,7 +533,7 @@ export function Presentation({
478
533
  )
479
534
  }
480
535
  >
481
- {actionLabel(action)}
536
+ {label}
482
537
  </Button>
483
538
  );
484
539
  };
@@ -498,11 +553,8 @@ export function Presentation({
498
553
  action={bodyAction as FormContract<Record<string, unknown>, unknown>}
499
554
  defaultValues={bodyInput}
500
555
  submitLabel={definition.body.submitLabel}
501
- onCancel={
502
- invocation.surface === "page"
503
- ? undefined
504
- : closeSurface
505
- }
556
+ onCancel={invocation.surface === "page" ? undefined : closeSurface}
557
+ cancelVariant="outline"
506
558
  onSuccess={(data) => applyEffects(definition.body.onSuccess, data)}
507
559
  disabled={hasSurfaceBlock}
508
560
  onLoadingChange={setBodyLoading}
@@ -514,76 +566,79 @@ export function Presentation({
514
566
  }
515
567
  if (bodyAction.kind === "list") {
516
568
  return (
517
- <ActionList<Record<string, unknown>, Record<string, unknown>>
518
- action={bodyAction as ListContract<
519
- Record<string, unknown>,
520
- Record<string, unknown>
521
- >}
522
- input={bodyInput}
523
- state={listState}
524
- onStateChange={onListStateChange}
525
- {...(definition.body.open === undefined
526
- ? {}
527
- : {
528
- onRowClick: (item: Record<string, unknown>) =>
529
- openTarget(
530
- definition.body.open!.presentation,
531
- definition.body.open!.surface,
532
- definition.body.open!.input,
533
- { ...context, item },
534
- ),
535
- })}
536
- />
569
+ <ActionList<Record<string, unknown>, Record<string, unknown>>
570
+ action={
571
+ bodyAction as ListContract<
572
+ Record<string, unknown>,
573
+ Record<string, unknown>
574
+ >
575
+ }
576
+ input={bodyInput}
577
+ state={listState}
578
+ onStateChange={onListStateChange}
579
+ {...(definition.body.open === undefined
580
+ ? {}
581
+ : {
582
+ onRowClick: (item: Record<string, unknown>) =>
583
+ openTarget(
584
+ definition.body.open!.presentation,
585
+ definition.body.open!.surface,
586
+ definition.body.open!.input,
587
+ { ...context, item },
588
+ ),
589
+ })}
590
+ />
537
591
  );
538
592
  }
539
593
  if (bodyAction.kind === "view") {
540
594
  const fields = definition.body.fields ?? [];
541
595
  return (
542
- <ActionView
543
- action={bodyAction as ViewContract<Record<string, unknown>, unknown>}
544
- input={bodyInput}
545
- >
546
- {(data) => (
547
- <DetailGroup>
548
- {fields.map((field) => (
549
- <DetailField
550
- key={field.key}
551
- label={field.label}
552
- value={readField(data, field.key) as ReactNode}
553
- empty={field.empty}
554
- />
555
- ))}
556
- </DetailGroup>
557
- )}
558
- </ActionView>
596
+ <ActionView
597
+ action={bodyAction as ViewContract<Record<string, unknown>, unknown>}
598
+ input={bodyInput}
599
+ >
600
+ {(data) => (
601
+ <DetailGroup>
602
+ {fields.map((field) => (
603
+ <DetailField
604
+ key={field.key}
605
+ label={field.label}
606
+ value={readField(data, field.key) as ReactNode}
607
+ empty={field.empty}
608
+ />
609
+ ))}
610
+ </DetailGroup>
611
+ )}
612
+ </ActionView>
559
613
  );
560
614
  }
561
- throw new Error(`A action simple “${bodyAction.name}” não pode ocupar o body.`);
615
+ throw new Error(
616
+ `A action simple “${bodyAction.name}” não pode ocupar o body.`,
617
+ );
562
618
  };
563
619
 
564
620
  const headerActions = definition.actions
565
621
  .filter((command) => command.placement === "header")
566
- .map(renderCommand);
622
+ .map((command) => renderCommand(command));
567
623
  const footerActions = definition.actions
568
624
  .filter((command) => command.placement === "footer")
569
- .map(renderCommand);
625
+ .map((command) => renderCommand(command));
570
626
  const parentSurface = invocation.stack.at(-1)?.surface;
571
627
  const canGoBack =
572
628
  parentSurface !== undefined &&
573
629
  (invocation.surface === "page" || parentSurface !== "page");
574
- const navigation =
575
- !canGoBack ? undefined : (
576
- <Button
577
- size="icon-sm"
578
- variant="ghost"
579
- disabled={surfaceBlocked}
580
- aria-label="Voltar"
581
- title="Voltar"
582
- onClick={() => applyEffects([{ effect: "back" }])}
583
- >
584
- <ArrowLeft aria-hidden />
585
- </Button>
586
- );
630
+ const navigation = !canGoBack ? undefined : (
631
+ <Button
632
+ size="icon"
633
+ variant="ghost"
634
+ disabled={surfaceBlocked}
635
+ aria-label="Voltar"
636
+ title="Voltar"
637
+ onClick={() => applyEffects([{ effect: "back" }])}
638
+ >
639
+ <ArrowLeft aria-hidden />
640
+ </Button>
641
+ );
587
642
 
588
643
  return (
589
644
  <PresentationFrame
@@ -604,6 +659,7 @@ export function Presentation({
604
659
  bodyClassName={bodyClassNameProp}
605
660
  hasBodyFooter={bodyAction.kind === "form"}
606
661
  blocked={surfaceBlocked}
662
+ listBody={bodyAction.kind === "list"}
607
663
  >
608
664
  {renderBody}
609
665
  </PresentationFrame>
@@ -25,7 +25,7 @@ export const surfaceHeaderClasses: Record<
25
25
  { title: string; description: string; count: string; actions: string }
26
26
  > = {
27
27
  page: {
28
- title: "text-2xl font-semibold tracking-tight",
28
+ title: "text-3xl font-semibold tracking-tight",
29
29
  description: "mt-1 truncate text-sm text-muted-foreground",
30
30
  count: "font-mono text-base text-muted-foreground/60",
31
31
  actions: "flex w-full shrink-0 items-center gap-4 sm:w-auto",
@@ -129,7 +129,9 @@ export function SurfaceHeader({
129
129
  // dois erros é a quantidade de tipos distintos presentes — dois PageBack são repetição,
130
130
  // não combinação, e merecem a mensagem genérica.
131
131
  const distinctLeading = new Set(
132
- leading.flatMap((node) => (isValidElement(node) ? [node.type as ElementType] : [])),
132
+ leading.flatMap((node) =>
133
+ isValidElement(node) ? [node.type as ElementType] : [],
134
+ ),
133
135
  );
134
136
  throw new Error(
135
137
  distinctLeading.size > 1
@@ -159,10 +161,7 @@ export function SurfaceHeader({
159
161
  // wrapper assume o min-content do breadcrumb e a compressão toda cai sobre o título.
160
162
  <div
161
163
  data-slot={`${slot}-navigation`}
162
- className={cn(
163
- "flex min-w-0 items-center",
164
- !heading && "flex-1",
165
- )}
164
+ className={cn("flex min-w-0 items-center", !heading && "flex-1")}
166
165
  >
167
166
  {leading}
168
167
  </div>